Files
chemenu/tools/chemenu/commands/confidence_decay.py
T
torben 0b8ca746fa
CI / verify (push) Successful in 49s
Release / release (push) Successful in 36s
docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision (4.3.0, #38)
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
2026-09-03 19:01:21 +02:00

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.")