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

350 lines
17 KiB
Python

from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
import httpx
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.provider import ProviderCallError, ProviderConfigurationError, ProviderSettings, build_client, request_answer, resolve_provider_settings
from scripts.benchmark.deermem_eviction.qa import build_answer_task, render_answer_messages
from scripts.benchmark.deermem_eviction.runner import ensure_run_config_identity, response_path, run_answer_calls, verify_run_identity
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 _template() -> str:
return (EVAL_ROOT / "prompts" / "answer-v1.txt").read_text(encoding="utf-8")
def _case(case_id: str = "case-1", question_date: str | None = "2023/05/20 (Sat) 02:21") -> PreparedCase:
config = _load_config()
facts = [
{"id": "fact-b", "content": "The cat sleeps in the study.", "category": "context", "confidence": 0.7, "createdAt": "2026-02-14T00:00:00Z", "source": "synthetic"},
{"id": "fact-a", "content": "The user moved to Lyon.", "category": "context", "confidence": 0.9, "createdAt": "2026-02-14T00:00:00Z", "source": "synthetic"},
{"id": "fact-c", "content": "The user has two bikes.", "category": "context", "confidence": 0.8, "createdAt": "2026-02-14T00:00:00Z", "source": "synthetic"},
]
return PreparedCase(
case_id=case_id,
source="synthetic",
scenario="correction_reserve",
question_type="synthetic-correction",
question="Where does the user live?",
answer="Lyon",
question_date=question_date,
evaluation_time=config.evaluation_time,
facts=facts,
usage={},
support_fact_ids=("fact-a",),
)
def _policy_result(case: PreparedCase, kept: tuple[str, ...], policy: str = "hybrid-v1") -> 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=kept,
evicted=(),
scores={},
support_all_retained=True,
support_recall=1.0,
reserved_correction_slots=0,
)
def test_rendering_pins_fact_order_date_line_and_block_format() -> None:
messages = render_answer_messages(
_template(),
question="Where does the user live?",
question_date="2023/05/20 (Sat) 02:21",
retained_facts=[("fact-b", "The cat sleeps in the study."), ("fact-a", "The user moved to Lyon.")],
)
assert messages[0]["role"] == "system"
assert messages[0]["content"] == "Answer using only the stored memory below. If the answer is not supported, output exactly INSUFFICIENT. For a YES/NO question, output only YES or NO. Otherwise give only the shortest direct answer."
assert messages[1]["role"] == "user"
assert messages[1]["content"] == ("CURRENT DATE: 2023/05/20 (Sat) 02:21\nSTORED MEMORY:\n[fact-a]\nThe user moved to Lyon.\n\n[fact-b]\nThe cat sleeps in the study.\n\nQUESTION: Where does the user live?")
def test_rendering_omits_the_date_line_when_absent() -> None:
messages = render_answer_messages(_template(), question="Q?", question_date=None, retained_facts=[("fact-a", "content")])
assert messages[1]["content"].startswith("STORED MEMORY:\n")
assert "CURRENT DATE" not in messages[1]["content"]
def test_rendering_rejects_empty_or_duplicate_facts() -> None:
with pytest.raises(ValueError):
render_answer_messages(_template(), question="Q?", question_date=None, retained_facts=[])
with pytest.raises(ValueError):
render_answer_messages(_template(), question="Q?", question_date=None, retained_facts=[("fact-a", "x"), ("fact-a", "y")])
def test_build_answer_task_renders_only_kept_facts() -> None:
case = _case()
task = build_answer_task(case, _policy_result(case, kept=("fact-a", "fact-c")), _template())
assert task.row_id == "case-1__hybrid-v1"
assert task.kept_fact_ids == ("fact-a", "fact-c")
user = task.messages[1]["content"]
assert "[fact-a]" in user and "[fact-c]" in user
assert "fact-b" not in user
with pytest.raises(ValueError):
build_answer_task(_case(case_id="other"), _policy_result(case, kept=("fact-a",)), _template())
def test_provider_settings_errors_name_the_missing_variables(monkeypatch: pytest.MonkeyPatch) -> None:
config = _load_config()
monkeypatch.delenv(config.qa.api_key_env, raising=False)
monkeypatch.delenv(config.qa.base_url_env, raising=False)
with pytest.raises(ProviderConfigurationError) as excinfo:
resolve_provider_settings(config.qa)
assert config.qa.api_key_env in str(excinfo.value)
assert config.qa.base_url_env in str(excinfo.value)
monkeypatch.setenv(config.qa.api_key_env, "secret-key")
monkeypatch.setenv(config.qa.base_url_env, "https://example.invalid/v1")
settings = resolve_provider_settings(config.qa)
assert settings.api_key == "secret-key"
assert settings.base_url == "https://example.invalid/v1"
def _mock_client(handler, qa) -> httpx.Client:
return httpx.Client(base_url="https://example.invalid/v1", headers={"Authorization": "Bearer secret-key"}, transport=httpx.MockTransport(handler), timeout=qa.timeout_seconds)
def _success_body(prediction: str = "Lyon") -> dict:
return {"model": "deepseek-v4-flash", "choices": [{"message": {"role": "assistant", "content": prediction}}], "usage": {"prompt_tokens": 100, "completion_tokens": 3, "detail": "ignored"}}
def test_request_answer_parses_prediction_and_non_secret_metadata() -> None:
qa = _load_config().qa
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, json=_success_body())
answer = request_answer(_mock_client(handler, qa), qa, ({"role": "system", "content": "s"}, {"role": "user", "content": "u"}), backoff_seconds=0)
assert answer.prediction == "Lyon"
assert answer.attempts == 1
assert answer.usage == {"prompt_tokens": 100, "completion_tokens": 3}
assert answer.response_model == "deepseek-v4-flash"
payload = json.loads(seen[0].content)
assert payload["model"] == qa.model
assert payload["temperature"] == qa.temperature
assert payload["max_tokens"] == qa.max_tokens
assert payload["stream"] is qa.stream
assert seen[0].url.path.endswith("/chat/completions")
def test_request_answer_retries_retryable_failures_and_gives_up() -> None:
qa = _load_config().qa
statuses = [429, 500]
def flaky(request: httpx.Request) -> httpx.Response:
if statuses:
return httpx.Response(statuses.pop(0), json={})
return httpx.Response(200, json=_success_body())
answer = request_answer(_mock_client(flaky, qa), qa, ({"role": "user", "content": "u"},), backoff_seconds=0)
assert answer.attempts == 3
def always_broken(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
with pytest.raises(ProviderCallError, match="failed after 3 attempts"):
request_answer(_mock_client(always_broken, qa), qa, ({"role": "user", "content": "u"},), backoff_seconds=0)
def test_request_answer_rejects_non_retryable_and_malformed_responses() -> None:
qa = _load_config().qa
with pytest.raises(ProviderCallError, match="non-retryable status 401"):
request_answer(_mock_client(lambda request: httpx.Response(401, json={}), qa), qa, ({"role": "user", "content": "u"},), backoff_seconds=0)
with pytest.raises(ProviderCallError, match="choices"):
request_answer(_mock_client(lambda request: httpx.Response(200, json={"choices": []}), qa), qa, ({"role": "user", "content": "u"},), backoff_seconds=0)
def test_build_client_uses_configured_timeout_and_bearer_header() -> None:
config = _load_config()
with build_client(ProviderSettings(base_url="https://example.invalid/v1", api_key="secret-key"), config.qa) as client:
assert client.headers["Authorization"] == "Bearer secret-key"
assert client.timeout.read == config.qa.timeout_seconds
def _tasks(count: int = 2) -> list:
template = _template()
tasks = []
for index in range(count):
case = _case(case_id=f"case-{index}")
tasks.append(build_answer_task(case, _policy_result(case, kept=("fact-a", "fact-b")), template))
return tasks
def test_run_answer_calls_persists_rows_and_resumes_without_new_calls(tmp_path: Path) -> None:
config = _load_config()
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return httpx.Response(200, json=_success_body())
tasks = _tasks()
with _mock_client(handler, config.qa) as client:
first = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (first.reused, first.called, first.failed) == (0, 2, ())
assert len(calls) == 2
row = json.loads(response_path(tmp_path, tasks[0].row_id).read_text(encoding="utf-8"))
assert set(row) == {"schema_version", "row_id", "case_id", "source", "scenario", "policy", "capacity", "kept_fact_ids", "prediction", "attempts", "request_fingerprint", "response_model", "usage", "created_at"}
assert row["prediction"] == "Lyon"
serialized = json.dumps(row)
assert "Where does the user live" not in serialized
assert "The user moved to Lyon." not in serialized
assert "Bearer" not in serialized
second = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (second.reused, second.called, second.failed) == (2, 0, ())
assert len(calls) == 2
response_path(tmp_path, tasks[0].row_id).write_text("{not json", encoding="utf-8")
third = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (third.reused, third.called, third.failed) == (1, 1, ())
assert len(calls) == 3
def test_run_answer_calls_refuses_to_reuse_a_row_bound_to_another_case(tmp_path: Path) -> None:
config = _load_config()
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return httpx.Response(200, json=_success_body())
tasks = _tasks()
with _mock_client(handler, config.qa) as client:
run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert len(calls) == 2
path = response_path(tmp_path, tasks[0].row_id)
original = json.loads(path.read_text(encoding="utf-8"))
for field, value in (("case_id", tasks[1].case_id), ("source", f"not-{tasks[0].source}"), ("scenario", f"not-{tasks[0].scenario}")):
path.write_text(json.dumps(dict(original, **{field: value})), encoding="utf-8")
report = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (report.reused, report.called, report.failed) == (1, 1, ())
assert len(calls) == 5
def test_run_answer_calls_reports_failures_without_writing_rows(tmp_path: Path) -> None:
config = _load_config()
def broken(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={})
tasks = _tasks(count=1)
with _mock_client(broken, config.qa) as client:
report = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert report.called == 0
assert len(report.failed) == 1
assert tasks[0].row_id in report.failed[0]
assert not response_path(tmp_path, tasks[0].row_id).exists()
def test_run_directory_is_bound_to_the_full_protocol_identity(tmp_path: Path) -> None:
config = _load_config()
paths = {
"config_path": EVAL_ROOT / "configs" / "pr4789-reproduction-v1.yaml",
"official_manifest_path": EVAL_ROOT / "manifests" / "longmemeval-pr4789-v1.json",
"synthetic_manifest_path": EVAL_ROOT / "manifests" / "synthetic-corrections-pr4789-v1.json",
"prompt_path": EVAL_ROOT / "prompts" / "answer-v1.txt",
}
dataset_path = tmp_path / "dataset.json"
dataset_path.write_text("[]", encoding="utf-8")
backend_root = Path(__file__).parents[1]
output_dir = tmp_path / "run"
ensure_run_config_identity(output_dir, config=config, dataset_path=dataset_path, backend_root=backend_root, **paths)
marker = json.loads((output_dir / "qa_run.json").read_text(encoding="utf-8"))
assert marker["qa"]["model"] == config.qa.model
assert set(marker["artifacts"]) == {"config_sha256", "official_manifest_sha256", "synthetic_manifest_sha256", "answer_prompt_sha256", "dataset_sha256"}
assert "secret" not in json.dumps(marker).lower()
ensure_run_config_identity(output_dir, config=config, dataset_path=dataset_path, backend_root=backend_root, **paths)
for changed_key, marker_field in (("config_path", "config_sha256"), ("synthetic_manifest_path", "synthetic_manifest_sha256"), ("prompt_path", "answer_prompt_sha256")):
changed_paths = dict(paths)
changed_file = tmp_path / f"changed-{changed_key}"
changed_file.write_text(paths[changed_key].read_text(encoding="utf-8") + "\n", encoding="utf-8")
changed_paths[changed_key] = changed_file
with pytest.raises(ValueError, match=f"different protocol artifacts.*{marker_field}"):
ensure_run_config_identity(output_dir, config=config, dataset_path=dataset_path, backend_root=backend_root, **changed_paths)
def test_verify_run_identity_is_read_only_and_names_the_changed_artifact(tmp_path: Path) -> None:
config = _load_config()
paths = {
"config_path": EVAL_ROOT / "configs" / "pr4789-reproduction-v1.yaml",
"official_manifest_path": EVAL_ROOT / "manifests" / "longmemeval-pr4789-v1.json",
"synthetic_manifest_path": EVAL_ROOT / "manifests" / "synthetic-corrections-pr4789-v1.json",
"prompt_path": EVAL_ROOT / "prompts" / "answer-v1.txt",
}
dataset_path = tmp_path / "dataset.json"
dataset_path.write_text("[]", encoding="utf-8")
output_dir = tmp_path / "run"
with pytest.raises(ValueError, match="missing"):
verify_run_identity(output_dir, dataset_path=dataset_path, **paths)
ensure_run_config_identity(output_dir, config=config, dataset_path=dataset_path, backend_root=Path(__file__).parents[1], **paths)
verify_run_identity(output_dir, dataset_path=dataset_path, **paths)
changed_prompt = tmp_path / "changed-prompt.txt"
changed_prompt.write_text(paths["prompt_path"].read_text(encoding="utf-8") + "\n", encoding="utf-8")
with pytest.raises(ValueError, match="refusing to grade.*|answer_prompt_sha256"):
verify_run_identity(output_dir, dataset_path=dataset_path, **{**paths, "prompt_path": changed_prompt})
def test_resume_revalidates_stored_rows_against_the_current_task(tmp_path: Path) -> None:
config = _load_config()
calls: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return httpx.Response(200, json=_success_body())
tasks = _tasks(count=1)
with _mock_client(handler, config.qa) as client:
run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert len(calls) == 1
unchanged = run_answer_calls(tasks, config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (unchanged.reused, unchanged.called) == (1, 0)
assert len(calls) == 1
changed_message = replace(tasks[0], messages=(tasks[0].messages[0], {"role": "user", "content": "a different question"}))
after_message_change = run_answer_calls([changed_message], config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (after_message_change.reused, after_message_change.called) == (0, 1)
assert len(calls) == 2
changed_kept = replace(changed_message, kept_fact_ids=("fact-a",))
after_kept_change = run_answer_calls([changed_kept], config=config, client=client, output_dir=tmp_path, backoff_seconds=0)
assert (after_kept_change.reused, after_kept_change.called) == (0, 1)
assert len(calls) == 3
def test_cli_run_qa_fails_fast_without_provider_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
from scripts.benchmark.deermem_eviction.cli import main
config = _load_config()
monkeypatch.delenv(config.qa.api_key_env, raising=False)
monkeypatch.delenv(config.qa.base_url_env, raising=False)
with pytest.raises(ProviderConfigurationError) as excinfo:
main(["run-qa", "--dataset", str(tmp_path / "missing.json"), "--output-dir", str(tmp_path / "out")])
assert config.qa.api_key_env in str(excinfo.value)