eidos_kernel/calibration.rs
1//! The calibration surface — the single documented home for the constants that shape ranking and
2//! confidence. Moving any value here (or an inline boost listed in the REGISTRY below) moves the
3//! frozen eval baselines, so every change is a reviewed diff against `evals/baseline.json` plus, in
4//! the ideal, a re-freeze justification.
5//!
6//! Why this module exists (D4): the 2026-07-04 gate incident showed that scoring constants
7//! scattered as inline literals across a 1200-line `retrieval.rs` are the "constants nobody
8//! remembers choosing" — a change to one, hidden inside a refactor, silently drifted the baselines.
9//! The band thresholds and the ambiguity ratio live here as named constants; the remaining inline
10//! boosts are catalogued in the REGISTRY so a reviewer can see the whole surface in one place, with
11//! the test that pins each.
12//!
13//! # REGISTRY — the full calibration surface (value · effect · pinning test)
14//!
15//! Housed here (named constants):
16//! - [`BAND_STRONG_MIN`] = 45 — score ≥ this is Strong. Test: `bands_are_calibrated_to_score_distribution`.
17//! - [`BAND_WEAK_MIN`] = 25 — score ≥ this (and < strong) is Weak; below is Fallback. Same test.
18//! - [`AMBIGUITY_RATIO`] = 0.7 — runner-up ≥ ratio × top demotes a Strong top to Ambiguous.
19//! Test: `calibrate_demotes_strong_top_when_runner_up_is_within_ambiguity_ratio`.
20//!
21//! Still inline in `retrieval.rs` / `retrieval/scoring.rs` (candidates for future extraction; each
22//! documented at its use site):
23//! - BM25F params (k1=1.2, b=0.75, weights [5,8,2,6,4,3]) — now the injected [`crate::retrieval::RankerConfig`]
24//! `Default`. Test: `ranker_config_is_injected_not_read_from_env`.
25//! - exact whole-query boost (+80) / partial substring (+6). Test: se-corpus `exact-lookup` golden.
26//! - intent boost (+20 × intents; generic-code gate → +6/0). Tests:
27//! `generic_code_intent_does_not_make_body_only_helper_strong` + `code-intent` golden.
28//! - name boost (+20, kind-gated for code). Covered via ground tests + the monotonicity pin.
29//! - relation-context (+12 × hits) / directional relation (+48 + 8 × endpoint) / code-action
30//! (+28 × hits + kind 8–16 + fn 18). Tests: `directional_relation_*`, `code_action_*`.
31//! - `matched_identity` additive tiebreak (+1/term). Covered via ground tests.
32//! - route penalty for body-only scatter (× coverage, floor 0.25). Test:
33//! `route_score_penalizes_partial_body_only_mentions`.
34//! - anchor arms: `id_coverage ≥ 0.4` + name-grade + `known_coverage ≥ 0.6`. Tests:
35//! `mostly_unknown_query_does_not_anchor_partial_name_match`, `anchored_typo_match_*`.
36//! - canonical dominance (parent → best_child + 1). Test: `canonical_dominance_lifts_a_parent_*`.
37//! - `apply_floor` (drop trailing Fallback when a confident hit exists). Test:
38//! `apply_floor_drops_trailing_fallback_only_when_a_confident_hit_exists`.
39//! - band monotonicity invariant (a garbage term never raises a band). Test:
40//! `adding_a_garbage_term_never_raises_a_hits_band`.
41
42/// Score at or above which a hit bands **Strong**. Calibrated to the measured distribution: clean
43/// retrieval accuracy is 100% down to 45 and garbage tops out ~32, so 45 is discriminating yet
44/// keeps Strong high-accuracy and never labels garbage Strong.
45pub const BAND_STRONG_MIN: i64 = 45;
46
47/// Score at or above which a hit bands **Weak** (below Strong); under this it is **Fallback**.
48pub const BAND_WEAK_MIN: i64 = 25;
49
50/// How close a runner-up must be, as a fraction of the top score, to demote a **Strong** top to
51/// **Ambiguous** — so a coin-flip query stops reporting false confidence.
52pub const AMBIGUITY_RATIO: f64 = 0.7;
53
54/// IDF at or above which an EXACT name-field match counts as a *distinctive* identity anchor — a
55/// query term rare enough that naming a node with it is unambiguous. Fixes band conservatism on
56/// common-vocabulary domains: "why did my CARBONARA turn into scrambled eggs" now anchors on
57/// carbonara (idf ≈ 1.54 here) instead of being capped to Weak by the common co-terms, while "egg"
58/// (idf ≈ 1.3, shared by many recipes) stays below the bar and cannot anchor. Empirically 1.5 is the
59/// separation point: a distinctive dish/symbol name in a small cross-referenced corpus reads as
60/// `df≈3-4` (recipe + its own section + a technique that lists it), so its idf sits ~1.5, while
61/// genuinely common terms fall under it. Gated additionally by `known_coverage ≥ 0.6`, so an
62/// out-of-vocabulary garbage query cannot anchor on its one real term. Verified no-regression across
63/// all eval suites; only lifts bands that were honestly under-confident.
64pub const DISTINCTIVE_NAME_IDF: f64 = 1.5;