Skip to content

Commit 7babad0

Browse files
committed
docs: update evolution plan and add eval harness hardening details
1 parent 524596e commit 7babad0

10 files changed

Lines changed: 1547 additions & 0 deletions

File tree

docs/GOLDEN_DATASET_PIPELINE.md

Lines changed: 692 additions & 0 deletions
Large diffs are not rendered by default.

docs/evolution.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,7 @@ flowchart TB
134134
37. **[x] Task-Specific Templates**: Debug/extend/review/explain/locate prioritization.
135135
38. **[x] Multi-hop Queries**: `trace_calls(depth=N)` and `get_impact()` analysis.
136136
39. **[x] Structured Responses**: JSON with `task_type` and `sufficiency_score`.
137+
138+
### **Phase 8.5: Eval Harness Hardening (NEXT)**
139+
40. **[ ] Decouple eval metadata from response verbosity**: The short-term eval harness workaround uses `verbosity="diagnostic"` so scoring can read `selected_entities`. The long-term fix is to separate context summarization from response projection, so internal evaluators can score ranking metadata while still exercising the production-like minimal retrieval path.
140+
41. **[ ] Fix routing accounting in eval scoring**: `tests/eval/harness/scorer.py` currently infers `routed_local` from `retrieval_result["source"]`, but `retrieve_context_for_query()` does not return `source`. Near-term, derive `would_route_local` from the configured sufficiency threshold plus non-empty context. Long-term, either run routing evals through `Agent.smart_answer()` or expose the route decision explicitly in an internal-only retrieval/eval API.

tests/eval/__init__.py

Whitespace-only changes.

tests/eval/golden/.gitkeep

Whitespace-only changes.

tests/eval/harness/__init__.py

Whitespace-only changes.

tests/eval/harness/calibration.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Sufficiency score calibration for KnowCode's local-first routing claim.
2+
3+
KnowCode's product claim: *"If sufficiency_score >= 0.8, answer locally with
4+
zero external tokens."* This module measures whether that claim is well-
5+
calibrated — i.e. whether a sufficiency_score of X predicts answer correctness
6+
with probability X.
7+
8+
Calibration concepts used here follow Guo et al. (2017) "On Calibration of
9+
Modern Neural Networks": bin predicted confidences, compute actual accuracy per
10+
bin, compute Expected Calibration Error (ECE).
11+
12+
Schema references: docs/GOLDEN_DATASET_PIPELINE.md §11.
13+
14+
"answer_correctness" in this module means the retrieved context contained every
15+
``must_mention_fact`` from the golden record. Because ``must_mention_facts``
16+
checking currently requires an LLM judge (see scorer.score_narrative), the
17+
``correct`` field on each record is an input — callers must populate it before
18+
calling these functions.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import math
24+
from typing import Any
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Record type alias
29+
# ---------------------------------------------------------------------------
30+
31+
# A "calibration record" is the output of scorer.score_record(), optionally
32+
# augmented with a ``correct: bool`` field set by the narrative judge.
33+
CalibrationRecord = dict[str, Any]
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Correctness gate
38+
# ---------------------------------------------------------------------------
39+
40+
_SUFFICIENCY_THRESHOLD = 0.8 # mirrors AppConfig default
41+
42+
43+
def passes_routing_gate(record: CalibrationRecord, threshold: float = _SUFFICIENCY_THRESHOLD) -> bool:
44+
"""Return True if the system would route this query to local answering."""
45+
return float(record.get("sufficiency_score", 0.0)) >= threshold
46+
47+
48+
def answer_correctness_at_threshold(
49+
records: list[CalibrationRecord],
50+
threshold: float = _SUFFICIENCY_THRESHOLD,
51+
) -> dict[str, Any]:
52+
"""Compute answer correctness for queries routed to local answering.
53+
54+
This is the *operational* metric for the routing claim (§11):
55+
of all queries where sufficiency_score >= threshold, what fraction
56+
have ``correct == True``?
57+
58+
Args:
59+
records: List of calibration records. Each must have a ``correct``
60+
field (bool). Records without ``correct`` are excluded.
61+
threshold: The sufficiency gate value. Defaults to 0.8.
62+
63+
Returns:
64+
Dict with:
65+
- ``threshold``: the gate value used
66+
- ``routed_count``: queries whose sufficiency_score >= threshold
67+
- ``judged_count``: subset with a ``correct`` value
68+
- ``correct_count``: subset that are both routed and correct
69+
- ``answer_correctness``: correct_count / judged_count (None if 0)
70+
"""
71+
routed = [r for r in records if passes_routing_gate(r, threshold)]
72+
judged = [r for r in routed if r.get("correct") is not None]
73+
correct = [r for r in judged if r.get("correct") is True]
74+
correctness = len(correct) / len(judged) if judged else None
75+
return {
76+
"threshold": threshold,
77+
"routed_count": len(routed),
78+
"judged_count": len(judged),
79+
"correct_count": len(correct),
80+
"answer_correctness": correctness,
81+
}
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# Calibration curve (reliability diagram data)
86+
# ---------------------------------------------------------------------------
87+
88+
89+
def build_calibration_curve(
90+
records: list[CalibrationRecord],
91+
n_bins: int = 10,
92+
) -> dict[str, Any]:
93+
"""Build the reliability diagram data for sufficiency_score.
94+
95+
Bins [0, 1) into *n_bins* equal-width intervals. For each bin reports
96+
the mean predicted sufficiency and the observed fraction of ``correct``
97+
answers. Records without a ``correct`` field are excluded.
98+
99+
Args:
100+
records: Calibration records with ``sufficiency_score`` and ``correct``.
101+
n_bins: Number of equal-width bins. Must be >= 1.
102+
103+
Returns:
104+
Dict with:
105+
- ``n_bins``: number of bins requested
106+
- ``bins``: list of bin dicts, each with:
107+
- ``bin_lower``: lower edge of this bin
108+
- ``bin_upper``: upper edge
109+
- ``count``: records in this bin
110+
- ``mean_predicted``: mean sufficiency_score in bin
111+
- ``fraction_correct``: observed accuracy (None if count == 0)
112+
- ``ece``: Expected Calibration Error across all judged records
113+
- ``judged_count``: total records with a ``correct`` value
114+
"""
115+
if n_bins < 1:
116+
raise ValueError("n_bins must be >= 1")
117+
118+
judged = [r for r in records if r.get("correct") is not None]
119+
120+
bin_width = 1.0 / n_bins
121+
bins: list[dict[str, Any]] = []
122+
123+
for i in range(n_bins):
124+
lower = i * bin_width
125+
upper = lower + bin_width
126+
# Include upper boundary in the last bin to catch score == 1.0
127+
in_bin = [
128+
r for r in judged
129+
if lower <= float(r.get("sufficiency_score", 0.0)) < upper
130+
or (i == n_bins - 1 and float(r.get("sufficiency_score", 0.0)) == 1.0)
131+
]
132+
mean_pred = (
133+
sum(float(r["sufficiency_score"]) for r in in_bin) / len(in_bin)
134+
if in_bin else None
135+
)
136+
frac_correct = (
137+
sum(1 for r in in_bin if r.get("correct")) / len(in_bin)
138+
if in_bin else None
139+
)
140+
bins.append({
141+
"bin_lower": round(lower, 4),
142+
"bin_upper": round(upper, 4),
143+
"count": len(in_bin),
144+
"mean_predicted": round(mean_pred, 4) if mean_pred is not None else None,
145+
"fraction_correct": round(frac_correct, 4) if frac_correct is not None else None,
146+
})
147+
148+
ece = _compute_ece(bins, total=len(judged))
149+
150+
return {
151+
"n_bins": n_bins,
152+
"bins": bins,
153+
"ece": round(ece, 4),
154+
"judged_count": len(judged),
155+
}
156+
157+
158+
def _compute_ece(bins: list[dict[str, Any]], total: int) -> float:
159+
"""Weighted mean absolute gap between predicted confidence and observed accuracy."""
160+
if total == 0:
161+
return 0.0
162+
error = 0.0
163+
for b in bins:
164+
if b["count"] == 0 or b["mean_predicted"] is None or b["fraction_correct"] is None:
165+
continue
166+
weight = b["count"] / total
167+
gap = abs(b["mean_predicted"] - b["fraction_correct"])
168+
error += weight * gap
169+
return error
170+
171+
172+
# ---------------------------------------------------------------------------
173+
# Over/under-confidence diagnosis
174+
# ---------------------------------------------------------------------------
175+
176+
177+
def diagnose_calibration(curve: dict[str, Any]) -> str:
178+
"""Return a one-line diagnosis of calibration quality.
179+
180+
Possible return values (§11 "Phase 1 reveals a finding"):
181+
- ``"well_calibrated"`` — ECE < 0.05
182+
- ``"over_confident"`` — system reports higher sufficiency than it delivers
183+
- ``"under_confident"`` — system is more correct than it claims
184+
- ``"insufficient_data"`` — fewer than 20 judged records
185+
186+
Args:
187+
curve: Output of ``build_calibration_curve``.
188+
"""
189+
if curve.get("judged_count", 0) < 20:
190+
return "insufficient_data"
191+
192+
ece: float = curve.get("ece", 0.0)
193+
if ece < 0.05:
194+
return "well_calibrated"
195+
196+
# Compute net bias: positive means over-confident
197+
bias_sum = 0.0
198+
weight_sum = 0.0
199+
for b in curve.get("bins", []):
200+
if b["count"] == 0 or b["mean_predicted"] is None or b["fraction_correct"] is None:
201+
continue
202+
bias_sum += b["count"] * (b["mean_predicted"] - b["fraction_correct"])
203+
weight_sum += b["count"]
204+
205+
if weight_sum == 0:
206+
return "insufficient_data"
207+
208+
net_bias = bias_sum / weight_sum
209+
return "over_confident" if net_bias > 0 else "under_confident"

tests/eval/harness/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""pytest configuration for the retrieval-quality eval harness."""
2+
3+
import pytest
4+
5+
6+
def pytest_addoption(parser: pytest.Parser) -> None:
7+
parser.addoption(
8+
"--save-baseline",
9+
action="store_true",
10+
default=False,
11+
help="Write evaluation results to baseline_v1.0.json after the run.",
12+
)
13+
parser.addoption(
14+
"--allow-drift",
15+
action="store_true",
16+
default=False,
17+
help="Skip the SHA guard (local exploration only; never in CI).",
18+
)

0 commit comments

Comments
 (0)