core/evals/run_cognition_eval.py
Claude bec76d4889
fix(evals): carry hash_surface on the CaseResult the register matrix actually uses
Corrects bf17f42, which claimed a fix that did not work.

That commit added hash_surface to evals/cognition/runner.py::CaseResult. The
test imports run_eval from evals/run_cognition_eval.py, which builds
evals/metrics.py::CaseResult — a DIFFERENT class with the same name. Thirteen
files in this repo define one. I matched on the class name instead of following
the import, so the field never reached the assertion and _diff_rows raised
AttributeError: 'CaseResult' object has no attribute 'hash_surface'. That is why
the count went 99 -> 100: the same 99 divergences, plus one error.

The diagnosis in bf17f42 was right; only its aim was wrong. Confirmed directly
at the pipeline seam:

  register=None         surface = 'Truth is what is true. pack-grounded...'
                   hash_surface = 'Truth is what is true. pack-grounded...'
  register=socratic_v1  surface = 'Consider the question: Truth is what is...'
                   hash_surface = 'Truth is what is true. pack-grounded...'

surface varies across the register axis (as it must), hash_surface does not (as
it must not), trace_hash matches. So the field added to CognitiveTurnResult in
bf17f42 is correct and stays; this moves the eval-side plumbing to the class the
test really consumes, and reverts the edit to the one it does not.

[Verification]: PARTIAL. Three registers green — socratic_v1, terse_v1,
manifesto_v1, 24 passed, where all three failed before. The full 99-register run
(~17 min) was still executing when the tree had to be committed; result to
follow, and bf17f42's DO NOT MERGE stands until it is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FduW6Krm3PPQv3P5iwBYtx
2026-07-25 14:25:31 +00:00

137 lines
4.2 KiB
Python

"""Run the cognition eval harness.
Loads cases from cognition_cases.jsonl, runs each through the
CognitiveTurnPipeline, and produces an EvalReport with deterministic
metrics. Each case gets a fresh pipeline instance for isolation.
"""
from __future__ import annotations
import json
from functools import partial
from pathlib import Path
from typing import Callable
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig
from core.cognition.pipeline import CognitiveTurnPipeline
from evals.metrics import CaseResult, EvalReport
from evals._parallel import run_cases_parallel
from generate.intent import IntentTag
_CASES_PATH = Path(__file__).parent / "cognition_cases.jsonl"
def load_cases(path: Path | None = None) -> list[dict]:
p = path or _CASES_PATH
cases = []
for line in p.read_text().splitlines():
line = line.strip()
if line:
cases.append(json.loads(line))
return cases
def _run_case(case: dict, pipeline: CognitiveTurnPipeline) -> CaseResult:
prompt = case["prompt"]
expected_intent = case["expected_intent"]
expected_terms = case.get("expected_terms", [])
expected_surface_contains = case.get("expected_surface_contains", [])
result = pipeline.run(prompt, max_tokens=8)
actual_intent = result.intent.tag if result.intent else IntentTag.UNKNOWN
intent_correct = actual_intent.value == expected_intent
surface_lower = result.surface.lower()
terms_captured = tuple(
t for t in expected_terms if t.lower() in surface_lower
)
surface_contains_pass = all(
s.lower() in surface_lower for s in expected_surface_contains
)
versor_ok = result.versor_condition < 1e-6
return CaseResult(
case_id=case["id"],
category=case.get("category", "unknown"),
prompt=prompt,
intent_correct=intent_correct,
terms_captured=terms_captured,
terms_expected=tuple(expected_terms),
surface_contains_pass=surface_contains_pass,
versor_closure=versor_ok,
versor_condition=result.versor_condition,
trace_hash=result.trace_hash,
surface=result.surface,
hash_surface=result.hash_surface or result.surface,
)
def _build_case_runner(
config: RuntimeConfig | None = None,
) -> Callable[[dict], CaseResult]:
"""Warm worker-local caches once, then return a per-case scorer."""
if config is None:
ChatRuntime(no_load_state=True)
else:
ChatRuntime(config=config, no_load_state=True)
def _run(case: dict) -> CaseResult:
runtime = ChatRuntime(config=config, no_load_state=True) if config else ChatRuntime(no_load_state=True)
pipeline = CognitiveTurnPipeline(runtime)
return _run_case(case, pipeline)
return _run
def run_eval(
cases: list[dict] | None = None,
config: RuntimeConfig | None = None,
*,
workers: int | None = None,
) -> EvalReport:
if cases is None:
cases = load_cases()
report = EvalReport()
case_runner_builder = partial(_build_case_runner, config=config)
case_results = run_cases_parallel(
cases,
case_runner_builder,
n_workers=workers if workers is not None else 4,
)
for case_result in case_results:
report.total += 1
if case_result.intent_correct:
report.intent_correct += 1
report.terms_expected += len(case_result.terms_expected)
report.terms_captured += len(case_result.terms_captured)
if case_result.surface_contains_pass:
report.surface_grounded += 1
if case_result.versor_closure:
report.versor_closures += 1
report.cases.append(case_result)
report.trace_hashes[case_result.case_id] = case_result.trace_hash
return report
def check_determinism(cases: list[dict] | None = None, runs: int = 2) -> bool:
if cases is None:
cases = load_cases()
hashes_by_run: list[dict[str, str]] = []
for _ in range(runs):
report = run_eval(cases)
hashes_by_run.append(dict(report.trace_hashes))
first = hashes_by_run[0]
for run_hashes in hashes_by_run[1:]:
if run_hashes != first:
return False
return True