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

123 lines
6.1 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from scripts.benchmark.deermem_eviction.dataset import DatasetIntegrityError, EvidenceRecord, LongMemEvalDataset
from scripts.benchmark.deermem_eviction.manifest import OfficialManifest
from scripts.benchmark.deermem_eviction.protocol import _distractors, validate_official_selection
SCENARIO_ORDER = ["confirmation_help", "access_help", "confidence_control", "noisy_signal_control"]
def _row(question_id: str, question_type: str, *, answer: str = "short answer", evidence_chars: int = 120) -> dict[str, Any]:
return {
"question_id": question_id,
"question_type": question_type,
"question": f"question for {question_id}?",
"answer": answer,
"haystack_session_ids": ["s1"],
"haystack_dates": ["2023/05/20"],
"haystack_sessions": [[{"role": "user", "content": "x" * evidence_chars, "has_answer": True}]],
}
def _eligible_ids(prefix: str) -> list[str]:
return [f"{prefix}-{index:03d}" for index in range(1, 21)]
def _dataset(rows: list[dict[str, Any]]) -> LongMemEvalDataset:
return LongMemEvalDataset(path=Path("synthetic"), sha256="0" * 64, rows=tuple(rows), rows_by_id={row["question_id"]: row for row in rows})
def _rows_with_exclusions() -> list[dict[str, Any]]:
rows = [_row(question_id, "knowledge-update") for question_id in _eligible_ids("ku")]
rows += [_row(question_id, "temporal-reasoning") for question_id in _eligible_ids("tr")]
# Every excluded row sorts before the eligible IDs, so a broken exclusion changes the recomputed selection.
rows.append(_row("ku-000-pilot", "knowledge-update"))
rows.append(_row("ku-000_abs", "knowledge-update"))
rows.append(_row("ku-000-long-answer", "knowledge-update", answer="a" * 150))
rows.append(_row("ku-000-refusal", "knowledge-update", answer="there is not enough information"))
rows.append(_row("ku-000-evidence", "knowledge-update", evidence_chars=2500))
return rows
def _manifest(*, excluded_pilot_ids: list[str] | None = None, scenarios: dict[str, list[str]] | None = None) -> OfficialManifest:
if scenarios is None:
ku, tr = _eligible_ids("ku"), _eligible_ids("tr")
scenarios = {scenario: ku[index * 5 : (index + 1) * 5] + tr[index * 5 : (index + 1) * 5] for index, scenario in enumerate(SCENARIO_ORDER)}
return OfficialManifest.model_validate(
{
"schema_version": 1,
"protocol_id": "synthetic-protocol",
"selection": {
"eligible_question_types": ["knowledge-update", "temporal-reasoning"],
"excluded_pilot_ids": ["ku-000-pilot"] if excluded_pilot_ids is None else excluded_pilot_ids,
"exclude_abstention_suffix": "_abs",
"answer_min_chars": 1,
"answer_max_chars": 100,
"answer_excluded_substrings": ["not enough", "only mentioned"],
"evidence_min_chars": 1,
"evidence_max_chars": 2000,
"take_per_question_type": 20,
"cases_per_type_per_scenario": 5,
},
"scenario_order": SCENARIO_ORDER,
"loss_ranks": [6, 6, 6, 8, 8, 8, 10, 10, 10, 10],
"scenarios": scenarios,
}
)
def test_selection_recomputation_accepts_a_manifest_matching_the_published_rule() -> None:
validate_official_selection(_dataset(_rows_with_exclusions()), _manifest())
def test_selection_recomputation_rejects_ids_that_break_the_rule() -> None:
ku, tr = _eligible_ids("ku"), _eligible_ids("tr")
scenarios = {scenario: ku[index * 5 : (index + 1) * 5] + tr[index * 5 : (index + 1) * 5] for index, scenario in enumerate(SCENARIO_ORDER)}
scenarios["confirmation_help"], scenarios["access_help"] = (
scenarios["confirmation_help"][:9] + [scenarios["access_help"][9]],
scenarios["access_help"][:9] + [scenarios["confirmation_help"][9]],
)
with pytest.raises(DatasetIntegrityError, match="do not match the declared selection rule"):
validate_official_selection(_dataset(_rows_with_exclusions()), _manifest(scenarios=scenarios))
def test_selection_recomputation_applies_every_published_exclusion() -> None:
manifest = _manifest()
excluded = {"ku-000-pilot", "ku-000_abs", "ku-000-long-answer", "ku-000-refusal", "ku-000-evidence"}
pinned = {question_id for question_ids in manifest.scenarios.values() for question_id in question_ids}
assert not (excluded & pinned)
# Dropping the pilot exclusion changes the recomputed selection, so validation must fail against the pinned IDs.
with pytest.raises(DatasetIntegrityError):
validate_official_selection(_dataset(_rows_with_exclusions()), _manifest(excluded_pilot_ids=[]))
def test_selection_recomputation_requires_enough_eligible_rows() -> None:
rows = [row for row in _rows_with_exclusions() if row["question_id"] != "tr-020"]
with pytest.raises(DatasetIntegrityError, match="not enough eligible"):
validate_official_selection(_dataset(rows), _manifest())
def _bank(size: int = 5) -> list[EvidenceRecord]:
return [EvidenceRecord(question_id=f"b{index}", question_type="single-session-user", question="q?", answer="a", question_date=None, content=f"evidence {index}") for index in range(size)]
def test_distractor_offset_derivation_and_wraparound_are_pinned() -> None:
# Hardcoded expectations for sha256(f"offset-ns:{case_id}").digest()[:4] % 5;
# a regression in the digest slice, modulus, or wraparound changes these indices.
bank = _bank()
no_wrap = _distractors("case-a", bank, count=3, namespace="offset-ns")
assert [record.question_id for record in no_wrap] == ["b0", "b1", "b2"]
wraps = _distractors("case-c", bank, count=3, namespace="offset-ns")
assert [record.question_id for record in wraps] == ["b3", "b4", "b0"]
assert [record.question_id for record in _distractors("case-a", bank, count=3, namespace="alt-ns")] == ["b1", "b2", "b3"]
def test_distractor_selection_requires_a_large_enough_bank() -> None:
with pytest.raises(DatasetIntegrityError, match="required"):
_distractors("case-a", _bank(size=2), count=3, namespace="offset-ns")