deer-flow/backend/tests/test_bench_deermem_eviction_report.py
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

197 lines
9.8 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.benchmark.deermem_eviction.config import load_evaluation_config
from scripts.benchmark.deermem_eviction.policy import PolicyResult
from scripts.benchmark.deermem_eviction.pool import PreparedCase
from scripts.benchmark.deermem_eviction.report import AnswerRowIntegrityError, collect_answer_rows, compute_qa_statistics, grade_answer_rows, summarize_qa_rows, write_qa_report
from scripts.benchmark.deermem_eviction.runner import response_path
from scripts.benchmark.deermem_eviction.stats import exact_mcnemar, paired_bootstrap_difference
EVAL_ROOT = Path(__file__).parents[1] / "scripts" / "benchmark" / "deermem_eviction"
def _load_config():
return load_evaluation_config(EVAL_ROOT / "configs" / "pr4789-reproduction-v1.yaml")
def _case(case_id: str, *, source: str, scenario: str, answer: str) -> PreparedCase:
config = _load_config()
facts = [{"id": f"{case_id}-fact", "content": "Secret memory content.", "category": "context", "confidence": 0.9, "createdAt": "2026-02-14T00:00:00Z", "source": "synthetic"}]
return PreparedCase(
case_id=case_id,
source=source, # type: ignore[arg-type]
scenario=scenario, # type: ignore[arg-type]
question_type="synthetic-shape",
question="Secret question text?",
answer=answer,
question_date=None,
evaluation_time=config.evaluation_time,
facts=facts,
usage={},
support_fact_ids=(f"{case_id}-fact",),
)
def _policy_result(case: PreparedCase, policy: str) -> PolicyResult:
return PolicyResult(
case_id=case.case_id,
source=case.source,
scenario=case.scenario,
question_type=case.question_type,
policy=policy, # type: ignore[arg-type]
capacity=7,
support_fact_ids=case.support_fact_ids,
kept_fact_ids=case.support_fact_ids,
evicted=(),
scores={},
support_all_retained=True,
support_recall=1.0,
reserved_correction_slots=0,
)
def _write_response(output_dir: Path, case: PreparedCase, policy: str, prediction: str, *, capacity: int = 7) -> None:
row_id = f"{case.case_id}__{policy}"
path = response_path(output_dir, row_id)
path.parent.mkdir(parents=True, exist_ok=True)
row = {
"schema_version": 1,
"row_id": row_id,
"case_id": case.case_id,
"source": case.source,
"scenario": case.scenario,
"policy": policy,
"capacity": capacity,
"kept_fact_ids": list(case.support_fact_ids),
"prediction": prediction,
"attempts": 1,
"request_fingerprint": "f" * 64,
"response_model": "deepseek-v4-flash",
"usage": {"prompt_tokens": 10, "completion_tokens": 2},
"created_at": "2026-08-17T00:00:00Z",
}
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
def _setup(tmp_path: Path):
official = _case("case-off", source="longmemeval", scenario="access_help", answer="Lyon-Reference-Secret")
synthetic = _case("case-syn", source="synthetic", scenario="correction_reserve", answer="NO")
cases = [official, synthetic]
_write_response(tmp_path, official, "confidence", "Paris")
_write_response(tmp_path, official, "hybrid-v1", "Lyon-Reference-Secret")
_write_response(tmp_path, synthetic, "confidence", "NO")
_write_response(tmp_path, synthetic, "hybrid-v1", "NO")
results = {f"{case.case_id}__{policy}": _policy_result(case, policy) for case in cases for policy in ("confidence", "hybrid-v1")}
fingerprints = {row_id: "f" * 64 for row_id in results}
return cases, results, fingerprints
def test_exact_mcnemar_matches_the_two_sided_exact_binomial() -> None:
concordant = exact_mcnemar([(True, True), (False, False)])
assert concordant.p_value == 1.0
one_sided_shift = exact_mcnemar([(False, True)] * 5)
assert one_sided_shift.only_second_correct == 5
assert one_sided_shift.p_value == pytest.approx(2 * 0.5**5)
mixed = exact_mcnemar([(False, True)] * 8 + [(True, False)] + [(True, True)] * 3)
assert (mixed.only_first_correct, mixed.only_second_correct, mixed.both_correct) == (1, 8, 3)
assert mixed.p_value == pytest.approx(2 * (0.5**9) * (1 + 9), rel=1e-12)
def test_paired_bootstrap_is_seed_deterministic_and_signed_second_minus_first() -> None:
config = _load_config()
pairs = [(False, True)] * 4 + [(True, True)] * 4
first = paired_bootstrap_difference(pairs, seed=config.statistics.bootstrap_seed, iterations=1000, alpha=config.statistics.alpha)
second = paired_bootstrap_difference(pairs, seed=config.statistics.bootstrap_seed, iterations=1000, alpha=config.statistics.alpha)
assert first == second
assert first.mean_difference == pytest.approx(0.5)
degenerate = paired_bootstrap_difference([(False, True)] * 3, seed=1, iterations=100, alpha=0.05)
assert (degenerate.mean_difference, degenerate.lower, degenerate.upper) == (1.0, 1.0, 1.0)
def test_grading_is_blind_and_joined_by_row_id(tmp_path: Path) -> None:
cases, results, fingerprints = _setup(tmp_path)
rows = collect_answer_rows(tmp_path, cases)
graded = grade_answer_rows(cases, results, rows, expected_fingerprints=fingerprints)
by_row = {row["row_id"]: row for row in graded}
assert not by_row["case-off__confidence"]["grade_correct"]
assert by_row["case-off__hybrid-v1"]["grade_correct"]
assert by_row["case-syn__confidence"]["grade_correct"]
assert by_row["case-syn__hybrid-v1"]["grade_correct"]
assert all(row["grader_version"] == "deterministic-overlap-v1" for row in graded)
def test_collect_and_integrity_checks_reject_incomplete_or_tampered_rows(tmp_path: Path) -> None:
cases, results, fingerprints = _setup(tmp_path)
response_path(tmp_path, "case-syn__hybrid-v1").unlink()
with pytest.raises(AnswerRowIntegrityError, match="case-syn__hybrid-v1"):
collect_answer_rows(tmp_path, cases)
_write_response(tmp_path, cases[1], "hybrid-v1", "NO", capacity=5)
rows = collect_answer_rows(tmp_path, cases)
with pytest.raises(AnswerRowIntegrityError, match="capacity/policy"):
grade_answer_rows(cases, results, rows, expected_fingerprints=fingerprints)
_write_response(tmp_path, cases[1], "hybrid-v1", "NO")
rows = collect_answer_rows(tmp_path, cases)
tampered = dict(results)
tampered["case-syn__hybrid-v1"] = _policy_result(_case("case-syn", source="synthetic", scenario="correction_reserve", answer="NO"), "hybrid-v1")
object.__setattr__(tampered["case-syn__hybrid-v1"], "kept_fact_ids", ("other-fact",))
with pytest.raises(AnswerRowIntegrityError, match="kept facts"):
grade_answer_rows(cases, tampered, rows, expected_fingerprints=fingerprints)
stale = dict(fingerprints)
stale["case-syn__hybrid-v1"] = "0" * 64
with pytest.raises(AnswerRowIntegrityError, match="request fingerprint"):
grade_answer_rows(cases, results, rows, expected_fingerprints=stale)
def test_grading_rejects_a_row_reassigned_to_another_valid_case(tmp_path: Path) -> None:
cases, results, fingerprints = _setup(tmp_path)
path = response_path(tmp_path, "case-off__hybrid-v1")
original = json.loads(path.read_text(encoding="utf-8"))
for field, value in (("case_id", "case-syn"), ("source", "synthetic"), ("scenario", "correction_reserve"), ("row_id", "case-syn__hybrid-v1")):
path.write_text(json.dumps(dict(original, **{field: value})) + "\n", encoding="utf-8")
rows = collect_answer_rows(tmp_path, cases)
with pytest.raises(AnswerRowIntegrityError, match="case-off__hybrid-v1.*case identity"):
grade_answer_rows(cases, results, rows, expected_fingerprints=fingerprints)
def test_summary_and_statistics_keep_suites_separate(tmp_path: Path) -> None:
cases, results, fingerprints = _setup(tmp_path)
config = _load_config()
graded = grade_answer_rows(cases, results, collect_answer_rows(tmp_path, cases), expected_fingerprints=fingerprints)
summary = summarize_qa_rows(graded)
assert {(group["source"], group["scenario"], group["policy"]): group["accuracy"] for group in summary} == {
("longmemeval", "access_help", "confidence"): 0.0,
("longmemeval", "access_help", "hybrid-v1"): 1.0,
("synthetic", "correction_reserve", "confidence"): 1.0,
("synthetic", "correction_reserve", "hybrid-v1"): 1.0,
}
statistics = compute_qa_statistics(graded, config)
assert statistics["suites"]["official"]["cases"] == 1
assert statistics["suites"]["synthetic"]["cases"] == 1
assert statistics["suites"]["overall"]["cases"] == 2
assert statistics["suites"]["official"]["mcnemar"]["only_second_correct"] == 1
assert statistics["suites"]["official"]["bootstrap"]["seed"] == config.statistics.bootstrap_seed
def test_written_report_redacts_dataset_text_and_refuses_overwrite(tmp_path: Path) -> None:
cases, results, fingerprints = _setup(tmp_path)
config = _load_config()
graded = grade_answer_rows(cases, results, collect_answer_rows(tmp_path, cases), expected_fingerprints=fingerprints)
write_qa_report(tmp_path, graded=graded, summary=summarize_qa_rows(graded), statistics=compute_qa_statistics(graded, config), config=config)
rows_text = (tmp_path / "qa.rows.jsonl").read_text(encoding="utf-8")
assert "Secret question text" not in rows_text
assert "Secret memory content" not in rows_text
confidence_official = json.loads(next(line for line in rows_text.splitlines() if '"case-off__confidence"' in line))
assert "Lyon-Reference-Secret" not in json.dumps(confidence_official)
assert (tmp_path / "qa.summary.json").exists()
assert (tmp_path / "qa.stats.json").exists()
with pytest.raises(FileExistsError):
write_qa_report(tmp_path, graded=graded, summary=summarize_qa_rows(graded), statistics=compute_qa_statistics(graded, config), config=config)