0b8ca746fa
Files changed: - AGENTS.md - CHANGES.md - VERSION - instructions/kb-profiles.md - kb/CONVENTIONS.md - kb/concepts/COLLECTION.md - tools/CONTRACT.md - tools/chemenu/commands/confidence_decay.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/tests/test_confidence_decay.py - tools/chemenu/tests/test_dist_cmd.py
170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
"""Apply the confidence decay formula defined in the wiki contract's
|
|
"Confidence Scoring" section: confidence decays at 1% per month since last
|
|
confirmation (the page's `modified` / `date` / `created` field), floored at 0.2.
|
|
|
|
This is pure arithmetic - previously left to the LLM's judgment even though
|
|
the contract specifies it exactly. Dry-run by default; `--apply` writes changes.
|
|
|
|
`confidence` is a *derived* field: it is always recomputed as
|
|
`confidence_base * (1 - 0.01 * months)`, never from its own previous value.
|
|
Keeping the undecayed anchor in `confidence_base` is what makes repeated runs
|
|
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
|
|
behavior) compounded on every run, because the elapsed-months factor kept
|
|
growing while the multiplicand had already shrunk.
|
|
|
|
Pages with `concept_type: decision` are skipped structurally, not as an
|
|
interim measure. The formula models staleness - a claim that nobody has
|
|
re-checked in a while becomes less trustworthy - and a decision is not a
|
|
claim about the world that time can falsify. What retires a decision is a
|
|
later decision superseding it, never elapsed months on its own; that is a
|
|
category the decay formula does not have a term for, so it does not apply
|
|
one.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import config
|
|
from chemenu.commands._util import success
|
|
from chemenu.frontmatter_io import write_page
|
|
from chemenu.kb_scan import load_kb_pages
|
|
|
|
app = typer.Typer(help="Apply confidence decay per the wiki contract's Confidence Scoring formula.")
|
|
|
|
DECAY_RATE_PER_MONTH = 0.01
|
|
FLOOR = 0.2
|
|
DAYS_PER_MONTH = 30.44
|
|
|
|
|
|
def _parse_date(value) -> Optional[datetime.date]:
|
|
if isinstance(value, datetime.datetime):
|
|
return value.date()
|
|
if isinstance(value, datetime.date):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
return datetime.date.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def compute_decay(confidence: float, last_confirmed: datetime.date, today: datetime.date) -> float:
|
|
months = max(0.0, (today - last_confirmed).days / DAYS_PER_MONTH)
|
|
decayed = confidence * (1 - DECAY_RATE_PER_MONTH * months)
|
|
return round(max(FLOOR, decayed), 2)
|
|
|
|
|
|
def _last_confirmed(frontmatter: dict) -> Optional[datetime.date]:
|
|
return (
|
|
_parse_date(frontmatter.get("modified"))
|
|
or _parse_date(frontmatter.get("date"))
|
|
or _parse_date(frontmatter.get("created"))
|
|
)
|
|
|
|
|
|
@app.command("init-base")
|
|
def confidence_init_base(
|
|
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
|
|
):
|
|
"""Backfill `confidence_base` from the current `confidence` on pages that
|
|
don't have one yet.
|
|
|
|
Needed once, when a wiki predates the derived-`confidence` model. Pages
|
|
already carrying a base are left untouched, so this is safe to re-run.
|
|
"""
|
|
pages = load_kb_pages(config.KB_DIR)
|
|
changes = []
|
|
|
|
for title, page in sorted(pages.items()):
|
|
confidence = page.frontmatter.get("confidence")
|
|
if confidence is None or page.frontmatter.get("confidence_base") is not None:
|
|
continue
|
|
changes.append((title, page, float(confidence)))
|
|
|
|
if not changes:
|
|
success("Every page with a confidence already has a confidence_base.")
|
|
return
|
|
|
|
for title, page, base in changes:
|
|
typer.echo(f"{title}: confidence_base <- {base:.2f}")
|
|
if apply:
|
|
_set_after(page.frontmatter, "confidence", "confidence_base", round(base, 2))
|
|
write_page(page.path, page.frontmatter, page.body)
|
|
|
|
if apply:
|
|
success(f"Set confidence_base on {len(changes)} page(s).")
|
|
else:
|
|
typer.echo(f"\n{len(changes)} page(s) would change. Re-run with --apply to write.")
|
|
|
|
|
|
def _set_after(frontmatter: dict, after_key: str, key: str, value) -> None:
|
|
"""Insert `key` immediately after `after_key`, preserving frontmatter order
|
|
(write_page serializes in dict insertion order, and the schemas list
|
|
confidence_base right after confidence)."""
|
|
if key in frontmatter or after_key not in frontmatter:
|
|
frontmatter[key] = value
|
|
return
|
|
items = list(frontmatter.items())
|
|
frontmatter.clear()
|
|
for existing_key, existing_value in items:
|
|
frontmatter[existing_key] = existing_value
|
|
if existing_key == after_key:
|
|
frontmatter[key] = value
|
|
|
|
|
|
@app.command("decay")
|
|
def confidence_decay(
|
|
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
|
|
):
|
|
pages = load_kb_pages(config.KB_DIR)
|
|
today = datetime.date.today()
|
|
changes = []
|
|
missing_base = []
|
|
|
|
for title, page in sorted(pages.items()):
|
|
if page.frontmatter.get("concept_type") == "decision":
|
|
continue
|
|
confidence = page.frontmatter.get("confidence")
|
|
if confidence is None:
|
|
continue
|
|
base = page.frontmatter.get("confidence_base")
|
|
if base is None:
|
|
missing_base.append(title)
|
|
continue
|
|
last_confirmed = _last_confirmed(page.frontmatter)
|
|
if last_confirmed is None:
|
|
continue
|
|
new_confidence = compute_decay(float(base), last_confirmed, today)
|
|
if abs(new_confidence - round(float(confidence), 2)) >= 0.01:
|
|
changes.append((title, page, float(confidence), new_confidence))
|
|
|
|
if missing_base:
|
|
typer.echo(
|
|
f"Skipped {len(missing_base)} page(s) with a confidence but no confidence_base "
|
|
"- run `wikitool confidence init-base --apply` first:"
|
|
)
|
|
for title in missing_base[:10]:
|
|
typer.echo(f" - {title}")
|
|
if len(missing_base) > 10:
|
|
typer.echo(f" ... and {len(missing_base) - 10} more")
|
|
typer.echo("")
|
|
|
|
if not changes:
|
|
success("No confidence values need decaying.")
|
|
return
|
|
|
|
for title, page, old, new in changes:
|
|
typer.echo(f"{title}: {old:.2f} -> {new:.2f}")
|
|
if apply:
|
|
page.frontmatter["confidence"] = new
|
|
write_page(page.path, page.frontmatter, page.body)
|
|
|
|
if apply:
|
|
success(f"Updated confidence on {len(changes)} page(s).")
|
|
else:
|
|
typer.echo(f"\n{len(changes)} page(s) would change. Re-run with --apply to write.")
|