16. Fitted, provenance-stamped confidence constants (sprout fit-confidence)
- Status: Accepted
- Date: 2026-07-08
- Author: Chelsea Kelly-Reif
- Deciders: Chelsea Kelly-Reif (maintainer)
- Related: ADR-0012 (the
abstain/low-confidence thresholds this ADR does not change); FIX-08,
docs/ideation/02-large-scale-fixes.md
Context
confidence.py's logistic constants (_MIDPOINT = 0.30, _STEEPNESS = 6.0,
_MARGIN_BONUS = 0.05) carried a comment saying "re-fit if the corpus or retrieval
changes materially" since the module was written, but no tool existed to do that re-fit.
ADR-0012 measured that these particular values pass the ECE gate (0.108 ≤ 0.15) — but the
committed eval report's own reliability diagram already shows two mid-range bins failing
their per-bin tolerance ([0.4,0.5) and [0.5,0.6) in docs/audits/eval-report.md)
inside an overall-passing ECE. Every retrieval change (FIX-07/EXP-03-style work) silently
invalidates the fit further, and nothing would notice except a slow ECE drift toward the
0.15 gate.
Hand-tuning a fresh set of constants each time is exactly the failure mode ADR-0012 documented for ADR-0005: a number gets written down and never round-trips through measurement again.
Decision
Add sprout fit-confidence (src/sprout/fit_confidence.py) as the supported way to
produce new logistic constants, and make the constants it produces a first-class,
provenance-stamped config artifact rather than a source comment:
- Never fits against
eval/suites/. It replays the live engine over a train split —eval/train/calibration_train.yamlby default, a disjoint set of corpus-derived questions covering the same 16 species on a different topic (soil/repotting) than the eval suite's calibration cases (watering/light/toxicity/ common-problems), plus disjoint out-of-scope negatives. This is the same tune-only-against-committed-failures / never-tune-to-the-test-set disciplinedocs/ROADMAP.mdPhase 3 already commits to for retrieval and prompt tuning, applied to confidence fitting. Fitting against the eval set would let the fit "know" the exact cases the calibration suite grades it on, turning the ECE gate into a check of nothing. - Bypasses the confidence gate while collecting evidence.
Assistant.confidence_signal(a new method, sharing the retrieve→generate→guard pipelineanswer()already uses via_retrieve_and_render) returns the raw(best cosine, margin)evidence and whether a grounded, guard-surviving answer resulted — without ever consulting the currentabstain_threshold. Gating evidence collection by the threshold being replaced would be circular. - Stays a 3-parameter logistic — no learned model.
fit_constantsgrid-searches(midpoint, steepness, margin_bonus)minimizing binary cross-entropy over a small, fixed grid (51 × 12 × 7 points). Not gradient descent, not an external solver, no new dependency (no numpy/scipy/sklearn) — the same transparent functionconfidence.py's docstring already promises ("a transparent function of retrieval evidence... It deliberately does not depend on answer fluency"), just fitted instead of hand-picked. This also makes a fit fully reproducible: same train split + same live engine ⇒ same constants, byte for byte. - Writes a provenance-stamped artifact, not bare numbers. The fit is written to
confidence.fitin the target config YAML:midpoint,steepness,margin_bonus,train_dataset_hash(content hash of the train split actually used),retrieval_config_hash(content hash ofRetrievalConfigat fit time),n_items, andfitted_at. It answers "fitted on what, when, against which retrieval config" the way a bare constant never could. The write is a targeted text edit (fit_confidence.upsert_confidence_fit), not ayaml.safe_load/dumpround-trip, so every other line of the hand-maintained config file — including its comments — survives untouched. score_confidencereads from config, not module globals.score_confidence(retrieved, n_rendered, cfg)now takes theConfidenceConfig; whencfg.fitis present it uses the fitted constants, otherwise it falls back to the ADR-0012 defaults (_MIDPOINT/_STEEPNESS/_MARGIN_BONUS, unchanged, still the documented safe default for a fresh install or before any fit has been run). This is whyconfidence.pyremains CODEOWNERS-guarded and this change goes through an ADR: the fallback behavior for every existing config that has never runfit-confidenceis unchanged.- A drift check gates
sprout eval.confidence.fit_drift_warning(cfg.confidence, cfg.retrieval)compares the fit's stampedretrieval_config_hashagainst the live retrieval config; a mismatch failssprout evalclosed, before it even records the engine, with a message naming exactly what to do (sprout fit-confidenceagain).
Consequences
- Positive. The re-fit tool named in
confidence.py's own comment now exists, is tested (tests/test_fit_confidence.py,tests/test_rag.py,tests/test_cli.py::test_fit_confidence_cmd_*), and is wired into the CLI (sprout fit-confidence --train eval/train/calibration_train.yaml --config config/sprout.yaml). - Positive. A future retrieval change (FIX-07, EXP-03) that invalidates the fit is now
a hard
sprout evalfailure with a clear remediation step, not a silent ECE drift. - Neutral — this ADR does not flip the shipped default.
config/sprout.yamland the packagedsrc/sprout/data/sprout.yamlstill carryconfidence.fit: null(absent) after this change;score_confidencetherefore still uses the ADR-0012 constants at runtime, anddocs/audits/eval-baseline.json/eval-report.mdare unchanged. Actually adopting a fresh fit as the shipped default is a follow-on decision for the maintainer to make deliberately (see the note below) — same posture ADR-0012 itself took toward re-tuning ADR-0005's numbers. - Negative — the train split is small, and the fit it produces measurably regresses
the eval set today. Running
sprout fit-confidencefor real against the shipped corpus (sprout ingest+sprout fit-confidence --train eval/train/calibration_train.yaml) producesmidpoint=0.44, steepness=8.0, margin_bonus=0.2from the 24-item train split. Loading that fit and re-runningsprout eval --suites calibrationagainsteval/suites/measures ECE 0.263 — a regression against the current 0.108 (ADR-0012), with five of eight reliability bins failing their per-bin tolerance (vs. two failing bins today). 24 corpus-derived questions is enough to prove the tool works end to end; it is not enough evidence to trust a production re-fit on. This is exactly why this ADR does not commit that fit as the shipped default — see below.
Note for the maintainer
This ADR adds the tool FIX-08 asked for and proves it end to end — unit + CLI tests, plus
an actual run against the real bundled corpus (documented above) — but deliberately
leaves confidence.fit unset in the shipped config, because that real run's own evidence
(ECE 0.263 vs. the shipped 0.108) says the current 24-item train split is not yet good
enough to re-tune against. Before adopting a fit as the shipped default: expand the train
split (more items; ideally the leave-species-out split
docs/ideation/02-large-scale-fixes.md suggests), re-run sprout fit-confidence, and
compare the resulting ECE/reliability diagram against 0.108 — the same measure-before-ship
discipline ADR-0012 used. Do not adopt a fit whose measured ECE is worse than what's
already shipped.