qingbo1011 2eba65449f
eval(memory): add a reproducible hybrid eviction evaluation (#4810)
* eval(memory): scaffold reproducible eviction evaluation

* refactor(eval): align with benchmark layout

* eval(memory): add deterministic QA grading

Implement the disclosed deterministic-overlap-v1 grader as a pure offline
module. Grading is blind by construction: grade_answer() accepts only the
prediction and reference strings, never a policy identity.

The undisclosed stopword list is committed as a fixed part of this grader
version; yes/no/not are deliberately excluded because negation can be the
entire answer. Before freezing, the grader locally reproduced all 90
historical (prediction, grade) pairs disclosed in #4789 with zero
mismatches and no post-hoc tuning.

validate-contracts now rejects a config whose qa.grader_version does not
match the committed grader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): add environment-configured QA runner

Add the exact answer-prompt renderer (retained facts sorted by ID, CURRENT
DATE line omitted when absent), an OpenAI-compatible provider adapter
configured only through the environment variable names pinned in the
config, and a resumable run-qa command that calls both policies with
identical versioned settings.

Each row persists as its own file on success, so a partial paid run
resumes without repeating completed calls; qa_run.json binds an output
directory to one config identity. Row files and errors carry predictions
and non-secret metadata only -- never questions, references, memory text,
credentials, or response headers. All tests are offline via mocked
transports; run-qa fails fast before touching the dataset when the
provider environment is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): add blind QA grading report and paired statistics

Add grade-qa: it recomputes the deterministic selector output, rejects any
answer row whose kept facts, capacity, or policy disagree with it, grades
every prediction through the policy-blind grade_answer(prediction,
reference) call, and only then joins grades back through stable row IDs.

Published artifacts are qa.rows.jsonl (graded rows with non-secret
metadata), qa.summary.json (accuracy by source/scenario/policy; official
and synthetic suites never folded together), and qa.stats.json (exact
paired McNemar and seeded paired bootstrap difference for the official,
synthetic, and overall suites using the pinned statistics parameters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): pin the official DeepSeek model ID

The historical protocol recorded the answer model with an aggregator-style
namespace (deepseek/deepseek-v4-flash). The live run calls the same
underlying model (DeepSeek-V4-Flash-0731, released before the historical
run) directly through DeepSeek's official OpenAI-compatible API, whose
canonical ID is deepseek-v4-flash. The served model is recorded from the
provider response in every answer row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): publish paired eviction QA results

Publish the equal-budget live QA artifacts for pr4789-reproduction-v1:
provenance, 90 graded rows, per-scenario summary, and paired statistics.
At capacity 7 with identical settings, confidence answers 24/45 and
hybrid-v1 40/45 (official 23/40 vs 35/40, exact McNemar p=0.0042; overall
p=0.0004). The noisy-signal control is the one scenario where hybrid-v1
scored below the baseline (10/10 vs 8/10) and is reported separately.

The offline suite now verifies the published statistics are recomputable
from the published rows and that the artifacts carry no dataset text or
credentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eval): decompose the noisy-signal QA cell

Both policies retained the support fact in all ten noisy-signal cases, so
the two rows hybrid-v1 lost are grader phrasing boundaries (verbose
numeric answers rejected by the numeric-conflict rule), not eviction
failures. Documented from the published rows; the grader stays frozen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): address review hardening findings

- ignore the responses/ directory the runner actually writes instead of
  the stale provider-responses/ entry
- cover the official selection-rule recomputation with direct synthetic
  tests: matching manifests pass, rule-breaking IDs and missing eligible
  rows fail, and every published exclusion is load-bearing
- align the report docstring and README with the statistics contract:
  the summary never folds sources; the explicitly labeled overall suite
  is reported alongside the separate official and synthetic suites
- recompute the published bootstrap intervals (not only McNemar) in the
  published-results test
- wire required_policy_version to the production
  EVICTION_POLICY_HYBRID_V1 constant so validate-contracts rejects
  policy drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): restore historical evidence rendering and harden resume identity

Address both blocking findings from the #4789 artifact cross-check.

The evidence renderer now emits the historical SESSION {id} AT {date}
line instead of the divergent bracket format. The byte representation is
protocol-critical: the witness record 35a27287 renders at 697 characters
again, stays inside the 700-character distractor-bank bound, and 60d45044
leaves the bank, restoring row-level pool reproduction. Deterministic
capacity-7 retention is unchanged at 27/45 vs 45/45.

qa_run.json now binds a run directory to the SHA-256 of all five protocol
inputs (config, both manifests, answer prompt, dataset) and names the
changed artifact when it refuses to resume. Stored rows are reused only
when row identity, policy, capacity, kept facts, and the request
fingerprint recomputed from the current task all match; the disclosed
probe (changed message under the same config) is now a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): republish QA results under the historical protocol

Replace the published artifacts with the fresh equal-budget run executed
at 497ff3d0 under the restored historical evidence rendering; the earlier
run under the divergent rendering is discarded entirely. At capacity 7
with identical settings, confidence answers 24/45 and hybrid-v1 38/45
(official 23/40 vs 33/40, exact McNemar p=0.0129; overall p=0.0013).
The confidence control is the one scenario below baseline for hybrid-v1
(8/10 vs 6/10); both lost rows retained the support fact and are grader
phrasing/abstention boundaries, documented from the published rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): adopt the historical fact IDs and prompt serialization

Pool facts now carry the historical protocol IDs (gold_{case} for the
support fact, d_{case}_{index}_{source} for distractors in bank-draw
order), and the rendered STORED MEMORY joins fact blocks with a blank
line. Sorting by these IDs reproduces the historical selection tie-break:
witness case 41698283 at capacity 7 again keeps the 58bf7951 distractor
and evicts 001be529 under both policies. Deterministic capacity-7
retention is unchanged at 27/45 vs 45/45.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): republish QA results under the historical serialization

Replace the published artifacts with the fresh equal-budget run executed
at 01f99d61 under the historical fact IDs and prompt serialization;
earlier runs under divergent serializations are discarded entirely. At
capacity 7 with identical settings, confidence answers 24/45 and
hybrid-v1 40/45 (official 23/40 vs 35/40, exact McNemar p=0.0018;
overall p=0.0001). The single row below baseline (1cea1afa,
confidence-control) retained its support fact; the model abstained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* eval(memory): self-certify the publish path and pin offset coverage

grade-qa now verifies (read-only) that the run marker's five protocol
artifact hashes match the current inputs, rebuilds every answer task, and
rejects any stored row whose request fingerprint does not match the task
recomputed from the current protocol — the staleness class that
previously required an out-of-band cross-check to detect. Verified
end-to-end against the published run: all 90 rows pass and regrade to
byte-identical artifacts, while a tampered fingerprint is refused by row
ID.

The distractor offset derivation and wraparound selection are now pinned
by unit tests with hardcoded indices, including a wrapping offset, so a
digest-slice or modulus regression can no longer stay green offline.

Closes both non-blocking suggestions from the re-review.

* fix(bench): bind persisted answer rows to their expected case identity

Grading derived the reference case from the stored row's embedded case_id,
so reassigning a valid row to another valid case passed every integrity
check while silently changing the published grade. The resume path had the
same gap: _row_matches_task() never compared case_id, source, or scenario.

The recomputed task is now authoritative in both paths: grade_answer_rows()
resolves the reference case from the expected PolicyResult and rejects any
mismatch in the persisted row_id/case_id/source/scenario, and
_row_matches_task() checks the same identity fields so a reassigned row is
re-run instead of reused. Regressions tamper each field individually and
exercise both paths.

* docs(bench): document where to download the pinned LongMemEval file

The README named the dataset but never said it lives on Hugging Face or how
to fetch the pinned revision, so a reviewer could not run the offline
commands. Add the direct download URL, the expected SHA-256, and the mirror
and huggingface-cli alternatives; the CLI still never downloads anything.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 14:36:38 +08:00

146 lines
7.6 KiB
Python

"""Blind grading of completed answer rows and public QA reporting.
Grading is join-free by construction: every prediction is graded through
``grade_answer(prediction, reference)`` — two strings, no policy identity —
and only afterwards joined back to its policy through the stable row ID.
Published rows carry predictions, grades, and non-secret metadata; they never
contain questions, reference answers, or memory content. The per-scenario
summary keeps the official and synthetic sources separate; the statistics
report the ``official`` and ``synthetic`` suites separately and additionally
an explicitly labeled combined ``overall`` suite.
"""
from __future__ import annotations
import json
from collections import defaultdict
from dataclasses import asdict
from pathlib import Path
from typing import Any
from .config import EvaluationConfig
from .grading import GRADER_VERSION, grade_answer
from .policy import PolicyResult
from .pool import PreparedCase
from .results import _atomic_write_text
from .runner import load_completed_row, response_path
from .stats import exact_mcnemar, paired_bootstrap_difference
POLICY_ORDER = ("confidence", "hybrid-v1")
class AnswerRowIntegrityError(RuntimeError):
pass
def collect_answer_rows(output_dir: Path, cases: list[PreparedCase]) -> dict[str, dict[str, Any]]:
rows: dict[str, dict[str, Any]] = {}
missing: list[str] = []
for case in cases:
for policy in POLICY_ORDER:
row_id = f"{case.case_id}__{policy}"
row = load_completed_row(response_path(output_dir, row_id))
if row is None:
missing.append(row_id)
else:
rows[row_id] = row
if missing:
raise AnswerRowIntegrityError(f"missing or invalid answer rows: {', '.join(sorted(missing))}")
return rows
def grade_answer_rows(cases: list[PreparedCase], results_by_row: dict[str, PolicyResult], rows: dict[str, dict[str, Any]], *, expected_fingerprints: dict[str, str]) -> list[dict[str, Any]]:
cases_by_id = {case.case_id: case for case in cases}
graded: list[dict[str, Any]] = []
for row_id in sorted(rows):
row = rows[row_id]
result = results_by_row[row_id]
# The recomputed task is authoritative: the reference case is derived from it,
# never from the stored row, and every persisted identity field must match it.
case = cases_by_id[result.case_id]
if row.get("row_id") != row_id or row.get("case_id") != result.case_id or row.get("source") != result.source or row.get("scenario") != result.scenario:
raise AnswerRowIntegrityError(f"row {row_id} case identity does not match the expected task")
if tuple(row.get("kept_fact_ids", ())) != result.kept_fact_ids:
raise AnswerRowIntegrityError(f"row {row_id} kept facts do not match the deterministic selector output")
if row.get("capacity") != result.capacity or row.get("policy") != result.policy:
raise AnswerRowIntegrityError(f"row {row_id} capacity/policy does not match the protocol")
if row.get("request_fingerprint") != expected_fingerprints.get(row_id):
raise AnswerRowIntegrityError(f"row {row_id} request fingerprint does not match the task recomputed from the current protocol")
prediction = str(row["prediction"])
grade = grade_answer(prediction, case.answer)
graded.append(
{
"schema_version": 1,
"row_id": row_id,
"case_id": case.case_id,
"source": case.source,
"scenario": case.scenario,
"question_type": case.question_type,
"policy": result.policy,
"capacity": result.capacity,
"kept_fact_ids": list(result.kept_fact_ids),
"support_all_retained": result.support_all_retained,
"support_recall": result.support_recall,
"prediction": prediction,
"grade_correct": grade.correct,
"grade_rule": grade.rule,
"grader_version": GRADER_VERSION,
"attempts": row.get("attempts"),
"request_fingerprint": row.get("request_fingerprint"),
"response_model": row.get("response_model"),
"usage": row.get("usage"),
}
)
return graded
def summarize_qa_rows(graded: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
for row in graded:
groups[(row["source"], row["scenario"], row["policy"])].append(row)
summary: list[dict[str, Any]] = []
for (source, scenario, policy), rows in sorted(groups.items()):
correct = sum(1 for row in rows if row["grade_correct"])
summary.append({"source": source, "scenario": scenario, "policy": policy, "cases": len(rows), "correct": correct, "accuracy": correct / len(rows)})
return summary
def _paired_grades(graded: list[dict[str, Any]]) -> dict[str, tuple[bool, bool]]:
by_case: dict[str, dict[str, bool]] = defaultdict(dict)
for row in graded:
by_case[row["case_id"]][row["policy"]] = bool(row["grade_correct"])
pairs: dict[str, tuple[bool, bool]] = {}
for case_id, grades in by_case.items():
if set(grades) != set(POLICY_ORDER):
raise AnswerRowIntegrityError(f"case {case_id} is missing one policy row")
pairs[case_id] = (grades[POLICY_ORDER[0]], grades[POLICY_ORDER[1]])
return pairs
def compute_qa_statistics(graded: list[dict[str, Any]], config: EvaluationConfig) -> dict[str, Any]:
pairs_by_case = _paired_grades(graded)
sources_by_case = {row["case_id"]: row["source"] for row in graded}
suites = {
"official": [pairs_by_case[case_id] for case_id in sorted(pairs_by_case) if sources_by_case[case_id] == "longmemeval"],
"synthetic": [pairs_by_case[case_id] for case_id in sorted(pairs_by_case) if sources_by_case[case_id] == "synthetic"],
"overall": [pairs_by_case[case_id] for case_id in sorted(pairs_by_case)],
}
statistics: dict[str, Any] = {"schema_version": 1, "protocol_id": config.protocol_id, "grader_version": GRADER_VERSION, "policies": list(POLICY_ORDER), "suites": {}}
for suite, pairs in suites.items():
mcnemar = exact_mcnemar(pairs)
bootstrap = paired_bootstrap_difference(pairs, seed=config.statistics.bootstrap_seed, iterations=config.statistics.bootstrap_iterations, alpha=config.statistics.alpha)
statistics["suites"][suite] = {"cases": len(pairs), "mcnemar": asdict(mcnemar), "bootstrap": asdict(bootstrap)}
return statistics
def write_qa_report(output_dir: Path, *, graded: list[dict[str, Any]], summary: list[dict[str, Any]], statistics: dict[str, Any], config: EvaluationConfig) -> None:
targets = [output_dir / "qa.rows.jsonl", output_dir / "qa.summary.json", output_dir / "qa.stats.json"]
existing = [path for path in targets if path.exists()]
if existing:
raise FileExistsError(f"refusing to overwrite existing result files: {', '.join(str(path) for path in existing)}")
rows_lines = "".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in graded)
summary_document = {"schema_version": 1, "protocol_id": config.protocol_id, "grader_version": GRADER_VERSION, "qa_capacity": config.pool.qa_capacity, "groups": summary}
_atomic_write_text(output_dir / "qa.rows.jsonl", rows_lines)
_atomic_write_text(output_dir / "qa.summary.json", json.dumps(summary_document, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
_atomic_write_text(output_dir / "qa.stats.json", json.dumps(statistics, ensure_ascii=False, indent=2, sort_keys=True) + "\n")