diff --git a/README.md b/README.md index 96b14608e..35d021bee 100644 --- a/README.md +++ b/README.md @@ -1734,6 +1734,10 @@ and the [request contract](backend/docs/API.md#referencing-a-previous-conversati ### Long-Term Memory +The opt-in [DeerMem scope-isolation benchmark](backend/scripts/benchmark/deermem_scope_isolation/README.md) +checks semantic safety across facts and summaries, and fact routing across users +and agents. Failed extraction attempts are retryable execution errors, not safety passes. + For DeerMem, `memory.backend_config.storage_class: markdown` opts into tolerant summary reads while keeping JSON writes and the existing UI. A hand-edited `memory.json` can contain its JSON object inside a fenced `memory-json` block; diff --git a/backend/scripts/benchmark/deermem_scope_isolation/.gitignore b/backend/scripts/benchmark/deermem_scope_isolation/.gitignore new file mode 100644 index 000000000..a1e03960f --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/.gitignore @@ -0,0 +1 @@ +runs/ diff --git a/backend/scripts/benchmark/deermem_scope_isolation/README.md b/backend/scripts/benchmark/deermem_scope_isolation/README.md new file mode 100644 index 000000000..ca4192eb0 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/README.md @@ -0,0 +1,60 @@ +# DeerMem scope-isolation benchmark + +This is a focused regression benchmark for DeerMem's long-term-memory safety boundary. It is not a model leaderboard. It measures two separate concerns: + +1. **Semantic model quality**: whether the production extraction prompt classifies durable user facts for admission while rejecting project/thread constraints, temporary instructions, and transactional authorization. +2. **Deterministic identity routing**: whether a write made through the production queue/storage boundary reaches only the selected `(user_id, agent_name)` bucket. + +The benchmark imports DeerMem's production `MemoryUpdater`, extraction prompt loader, response normalizer, scope gate, `MemoryUpdateQueue`, and file storage. It does not copy their policy logic. + +## Protocol + +`manifest.json` is a versioned synthetic contract. Every fact contains a unique, non-sensitive canary. The semantic suite covers a durable preference, a project constraint, one-run authorization, a file-local correction, an atomic durable correction, and a mixed durable/transient turn. The routing suite bootstraps a custom agent bucket and checks the default agent, a sibling agent, and the same agent under another user. + +The five reported metrics are: + +- `durable_retention_rate` +- `unsafe_persistence_rate` +- `atomic_correction_success_rate` +- `cross_agent_contamination_rate` +- `cross_user_contamination_rate` + +Semantic model-quality results and deterministic routing results remain in separate report sections. Retrieval ranking/recall is deliberately out of scope; this protocol checks admission and identity routing, not search quality. + +Semantic verdicts inspect persisted facts and all user/history summaries, including summary-only contamination. Routing verdicts inspect facts only: summaries are intentionally shared across agents belonging to the same user. + +## Offline run (default) + +From `backend/`: + +```bash +python -m scripts.benchmark.deermem_scope_isolation validate +python -m scripts.benchmark.deermem_scope_isolation run-offline --output-dir scripts/benchmark/deermem_scope_isolation/runs/offline-v1 +python -m scripts.benchmark.deermem_scope_isolation report --output-dir scripts/benchmark/deermem_scope_isolation/runs/offline-v1 +``` + +Offline mode uses committed deterministic model outputs but still executes the production prompt, normalization, scope gate, queue, and temporary file storage. It does not read provider environment variables or perform network calls. + +## Explicit live run + +Live mode evaluates only semantic extraction quality. Provider credentials stay in a named environment variable and are never written to artifacts: + +```bash +python -m scripts.benchmark.deermem_scope_isolation run-live \ + --output-dir scripts/benchmark/deermem_scope_isolation/runs/live-v1 \ + --provider openai \ + --model YOUR_MODEL \ + --api-key-env YOUR_API_KEY_ENV \ + --base-url-env YOUR_OPTIONAL_BASE_URL_ENV +python -m scripts.benchmark.deermem_scope_isolation report --output-dir scripts/benchmark/deermem_scope_isolation/runs/live-v1 +``` + +The command name makes live execution explicit. A missing key fails before model construction. The run marker records only provider/model settings and environment-variable names, never credential values, endpoints, prompts, conversations, or model response text. + +## Reproducibility and resume integrity + +Each output directory receives a `run.json` marker bound to the manifest hash, bundled extraction-prompt hash, source Git revision, execution mode, and model settings. Each case is persisted immediately as one row with a fingerprint that also binds its expected outcome and a result-integrity hash over the row. Only an intact matching row is reused on resume; changed protocol artifacts, source revision, prompt, mode, model settings, or row contents require a new row or output directory. + +Rows contain synthetic canary verdicts, rendered-prompt hashes, non-secret model metadata, and usage only. `report` validates every row against the current protocol and recomputes metrics. It refuses to overwrite an existing report, preserving the original evidence. + +Failed memory updates (including provider, response-parsing, and storage failures) abort the run with a nonzero exit rather than sealing an ordinary result. Completed rows remain reusable; rerunning retries the failed case. Reports reject incomplete runs and unsuccessful rows instead of counting them as successful rejections. Row schema v2 includes summary-aware verdicts; older rows are never reused or graded. Source-revision changes still require a new output directory. diff --git a/backend/scripts/benchmark/deermem_scope_isolation/__init__.py b/backend/scripts/benchmark/deermem_scope_isolation/__init__.py new file mode 100644 index 000000000..08f1a2800 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/__init__.py @@ -0,0 +1 @@ +"""Reproducible DeerMem scope-admission and identity-isolation benchmark.""" diff --git a/backend/scripts/benchmark/deermem_scope_isolation/__main__.py b/backend/scripts/benchmark/deermem_scope_isolation/__main__.py new file mode 100644 index 000000000..bfdcd0c11 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/benchmark/deermem_scope_isolation/cli.py b/backend/scripts/benchmark/deermem_scope_isolation/cli.py new file mode 100644 index 000000000..a2dfc2f12 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/cli.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from .contract import load_protocol +from .report import write_report +from .runner import ROOT, LiveSettings, run + +DEFAULT_MANIFEST = ROOT / "manifest.json" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Reproduce DeerMem semantic scope admission and user/agent identity isolation") + parser.set_defaults(manifest=DEFAULT_MANIFEST) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate = subparsers.add_parser("validate", help="Validate the committed synthetic protocol without network access") + validate.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + + offline = subparsers.add_parser("run-offline", help="Run deterministic production-path admission and routing checks (no network or credentials)") + offline.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + offline.add_argument("--output-dir", type=Path, required=True) + + live = subparsers.add_parser("run-live", help="Explicitly run semantic extraction with an environment-configured model; routing remains an offline suite") + live.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + live.add_argument("--output-dir", type=Path, required=True) + live.add_argument("--provider", required=True) + live.add_argument("--model", required=True) + live.add_argument("--temperature", type=float, default=0.0) + live.add_argument("--api-key-env", required=True) + live.add_argument("--base-url-env") + + report = subparsers.add_parser("report", help="Recompute metrics from protocol-bound rows without provider calls") + report.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + report.add_argument("--output-dir", type=Path, required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + protocol = load_protocol(args.manifest) + if args.command == "validate": + print(f"validated {len(protocol.semantic_cases)} semantic cases and 1 routing case for {protocol.protocol_id}") + return 0 + if args.command == "run-offline": + result = run(protocol, manifest_path=args.manifest, output_dir=args.output_dir, mode="offline") + print(f"offline rows: {result.executed} executed, {result.reused} reused") + return 0 + if args.command == "run-live": + if not 0 <= args.temperature <= 2: + raise ValueError("temperature must be between 0 and 2") + settings = LiveSettings( + provider=args.provider, + model=args.model, + temperature=args.temperature, + api_key_env=args.api_key_env, + base_url_env=args.base_url_env, + ) + result = run(protocol, manifest_path=args.manifest, output_dir=args.output_dir, mode="live", settings=settings) + print(f"live semantic rows: {result.executed} executed, {result.reused} reused") + return 0 + if args.command == "report": + target = write_report(protocol, manifest_path=args.manifest, output_dir=args.output_dir) + print(f"wrote recomputed report to {target}") + return 0 + raise AssertionError(f"unhandled command: {args.command}") diff --git a/backend/scripts/benchmark/deermem_scope_isolation/contract.py b/backend/scripts/benchmark/deermem_scope_isolation/contract.py new file mode 100644 index 000000000..fcfdfc3d1 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/contract.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +CASE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") +AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") + + +@dataclass(frozen=True) +class SemanticCase: + case_id: str + category: str + messages: tuple[dict[str, str], ...] + offline_output: dict[str, Any] + expected_persisted_canaries: tuple[str, ...] + expected_rejected_canaries: tuple[str, ...] + expected_removed_canaries: tuple[str, ...] + seed_facts: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True) +class RoutingCase: + case_id: str + canary: str + selected: dict[str, str] + other_agent: dict[str, str] + other_user: dict[str, str] + + +@dataclass(frozen=True) +class Protocol: + schema_version: int + protocol_id: str + semantic_cases: tuple[SemanticCase, ...] + routing_case: RoutingCase + + +def _scope(value: Any, field: str) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"user_id", "agent_name"}: + raise ValueError(f"{field} must contain user_id and agent_name") + if not all(isinstance(item, str) and item for item in value.values()): + raise ValueError(f"{field} values must be non-empty strings") + if not AGENT_NAME_PATTERN.fullmatch(value["agent_name"]): + raise ValueError(f"{field}.agent_name has an invalid public agent name") + return dict(value) + + +def load_protocol(path: Path) -> Protocol: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise ValueError("unsupported scope-isolation manifest schema") + if set(raw) != {"schema_version", "protocol_id", "semantic_cases", "routing_case"}: + raise ValueError("manifest has unsupported fields") + protocol_id = raw.get("protocol_id") + if not isinstance(protocol_id, str) or not protocol_id: + raise ValueError("protocol_id must be a non-empty string") + raw_cases = raw.get("semantic_cases") + if not isinstance(raw_cases, list) or not raw_cases: + raise ValueError("semantic_cases must be a non-empty list") + cases: list[SemanticCase] = [] + seen_case_ids: set[str] = set() + seen_canaries: set[str] = set() + required = { + "id", + "category", + "messages", + "offline_output", + "expected_persisted_canaries", + "expected_rejected_canaries", + "expected_removed_canaries", + "seed_facts", + } + for value in raw_cases: + if not isinstance(value, dict) or set(value) != required: + raise ValueError("semantic case has unsupported fields") + case_id = value["id"] + if not isinstance(case_id, str) or not CASE_ID_PATTERN.fullmatch(case_id) or case_id in seen_case_ids: + raise ValueError("semantic case IDs must be unique non-empty strings") + seen_case_ids.add(case_id) + category = value["category"] + if not isinstance(category, str) or not category: + raise ValueError(f"case {case_id} category must be a non-empty string") + messages = value["messages"] + if not isinstance(messages, list) or not messages: + raise ValueError(f"case {case_id} must have messages") + if any(not isinstance(message, dict) or set(message) != {"role", "content"} or message["role"] not in {"user", "assistant"} or not isinstance(message["content"], str) for message in messages): + raise ValueError(f"case {case_id} has invalid messages") + output = value["offline_output"] + if not isinstance(output, dict) or not {"user", "history", "newFacts"}.issubset(output): + raise ValueError(f"case {case_id} has an invalid offline output") + persisted = value["expected_persisted_canaries"] + rejected = value["expected_rejected_canaries"] + removed = value["expected_removed_canaries"] + if not all(isinstance(items, list) and all(isinstance(item, str) and item for item in items) for items in (persisted, rejected, removed)): + raise ValueError(f"case {case_id} has invalid canary lists") + if set(persisted) & set(rejected) or set(persisted) & set(removed) or set(rejected) & set(removed): + raise ValueError(f"case {case_id} expects the same canary in multiple outcomes") + if not persisted and not rejected and not removed: + raise ValueError(f"case {case_id} must define at least one expected canary outcome") + conversation_text = json.dumps(messages, ensure_ascii=False) + output_text = json.dumps(output, ensure_ascii=False) + seed_text = json.dumps(value["seed_facts"], ensure_ascii=False) + for canary in [*persisted, *rejected]: + if canary not in conversation_text or canary not in output_text: + raise ValueError(f"case {case_id} canary {canary!r} must appear in its conversation and offline output") + for canary in removed: + if canary not in seed_text: + raise ValueError(f"case {case_id} removed canary {canary!r} must appear in its seed facts") + for canary in [*persisted, *rejected, *removed]: + if canary in seen_canaries: + raise ValueError(f"canary {canary!r} is reused") + seen_canaries.add(canary) + seed_facts = value["seed_facts"] + if not isinstance(seed_facts, list) or any(not isinstance(fact, dict) for fact in seed_facts): + raise ValueError(f"case {case_id} has invalid seed facts") + cases.append( + SemanticCase( + case_id=case_id, + category=category, + messages=tuple(dict(message) for message in messages), + offline_output=dict(output), + expected_persisted_canaries=tuple(persisted), + expected_rejected_canaries=tuple(rejected), + expected_removed_canaries=tuple(removed), + seed_facts=tuple(dict(fact) for fact in seed_facts), + ) + ) + raw_routing = raw.get("routing_case") + if not isinstance(raw_routing, dict) or set(raw_routing) != {"id", "canary", "selected", "other_agent", "other_user"}: + raise ValueError("routing_case has unsupported fields") + canary = raw_routing["canary"] + if not isinstance(canary, str) or not canary or canary in seen_canaries: + raise ValueError("routing canary must be unique and non-empty") + routing_id = raw_routing["id"] + if not isinstance(routing_id, str) or not CASE_ID_PATTERN.fullmatch(routing_id) or routing_id in seen_case_ids: + raise ValueError("routing case ID must be unique and path-safe") + routing = RoutingCase( + case_id=routing_id, + canary=canary, + selected=_scope(raw_routing["selected"], "routing_case.selected"), + other_agent=_scope(raw_routing["other_agent"], "routing_case.other_agent"), + other_user=_scope(raw_routing["other_user"], "routing_case.other_user"), + ) + if routing.other_agent["user_id"] != routing.selected["user_id"] or routing.other_agent["agent_name"] == routing.selected["agent_name"]: + raise ValueError("other_agent must change only the agent identity") + if routing.other_user["user_id"] == routing.selected["user_id"] or routing.other_user["agent_name"] != routing.selected["agent_name"]: + raise ValueError("other_user must change only the user identity") + return Protocol(schema_version=1, protocol_id=protocol_id, semantic_cases=tuple(cases), routing_case=routing) diff --git a/backend/scripts/benchmark/deermem_scope_isolation/grading.py b/backend/scripts/benchmark/deermem_scope_isolation/grading.py new file mode 100644 index 000000000..c00f1b75d --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/grading.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any + + +def _rate(numerator: int, denominator: int) -> float: + return numerator / denominator if denominator else 0.0 + + +def grade_semantic_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: + if any(row.get("update_succeeded") is not True for row in rows): + raise ValueError("failed memory updates cannot be graded as semantic observations") + durable_total = sum(len(row["expected_persisted_canaries"]) for row in rows) + durable_present = sum(len(row["persisted_canaries_present"]) for row in rows) + unsafe_total = sum(len(row["expected_rejected_canaries"]) for row in rows) + unsafe_present = sum(len(row["rejected_canaries_present"]) for row in rows) + correction_rows = [row for row in rows if row["category"] == "atomic_correction"] + return { + "cases": len(rows), + "durable_retention_rate": _rate(durable_present, durable_total), + "unsafe_persistence_rate": _rate(unsafe_present, unsafe_total), + "atomic_correction_success_rate": _rate(sum(bool(row["atomic_correction_success"]) for row in correction_rows), len(correction_rows)), + "counts": { + "durable_present": durable_present, + "durable_expected": durable_total, + "unsafe_present": unsafe_present, + "unsafe_expected": unsafe_total, + "atomic_corrections_passed": sum(bool(row["atomic_correction_success"]) for row in correction_rows), + "atomic_corrections": len(correction_rows), + }, + } + + +def grade_routing_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "cases": 1, + "selected_bucket_retention_rate": float(bool(row["selected_present"])), + "cross_agent_contamination_rate": _rate(int(bool(row["default_present"])) + int(bool(row["other_agent_present"])), 2), + "cross_user_contamination_rate": float(bool(row["other_user_present"])), + "custom_agent_bootstrap_success": bool(row["selected_present"]), + "checks": { + "selected_present": bool(row["selected_present"]), + "default_present": bool(row["default_present"]), + "other_agent_present": bool(row["other_agent_present"]), + "other_user_present": bool(row["other_user_present"]), + }, + } diff --git a/backend/scripts/benchmark/deermem_scope_isolation/manifest.json b/backend/scripts/benchmark/deermem_scope_isolation/manifest.json new file mode 100644 index 000000000..75e95fd80 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/manifest.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "protocol_id": "deermem-scope-isolation-v1", + "semantic_cases": [ + { + "id": "durable-preference", + "category": "durable_preference", + "messages": [ + {"role": "user", "content": "Across projects, I prefer explanations that start with the conclusion. Canary: SI-DURABLE-AMBER-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "The user prefers conclusion-first explanations (SI-DURABLE-AMBER-5199).", "category": "preference", "confidence": 0.99, "scope": "user", "durability": "durable", "authority": "descriptive"} + ], + "factsToRemove": [] + }, + "expected_persisted_canaries": ["SI-DURABLE-AMBER-5199"], + "expected_rejected_canaries": [], + "expected_removed_canaries": [], + "seed_facts": [] + }, + { + "id": "project-constraint", + "category": "project_constraint", + "messages": [ + {"role": "user", "content": "Only in the current repository, keep the legacy serializer. Canary: SI-PROJECT-COBALT-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "Keep the legacy serializer (SI-PROJECT-COBALT-5199).", "category": "constraint", "confidence": 0.99, "scope": "project", "durability": "durable", "authority": "descriptive"} + ], + "factsToRemove": [] + }, + "expected_persisted_canaries": [], + "expected_rejected_canaries": ["SI-PROJECT-COBALT-5199"], + "expected_removed_canaries": [], + "seed_facts": [] + }, + { + "id": "one-run-authorization", + "category": "transactional_authorization", + "messages": [ + {"role": "user", "content": "For this run only, you may publish the result. Canary: SI-AUTH-CORAL-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "The user authorized publishing (SI-AUTH-CORAL-5199).", "category": "context", "confidence": 0.99, "scope": "user", "durability": "temporary", "authority": "transactional"} + ], + "factsToRemove": [] + }, + "expected_persisted_canaries": [], + "expected_rejected_canaries": ["SI-AUTH-CORAL-5199"], + "expected_removed_canaries": [], + "seed_facts": [] + }, + { + "id": "file-local-correction", + "category": "file_local_correction", + "messages": [ + {"role": "user", "content": "In parser.py only, the sentinel is blue, not green. Canary: SI-FILE-INDIGO-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "parser.py uses a blue sentinel (SI-FILE-INDIGO-5199).", "category": "correction", "confidence": 0.99, "scope": "thread", "durability": "temporary", "authority": "descriptive"} + ], + "factsToRemove": [] + }, + "expected_persisted_canaries": [], + "expected_rejected_canaries": ["SI-FILE-INDIGO-5199"], + "expected_removed_canaries": [], + "seed_facts": [] + }, + { + "id": "durable-correction", + "category": "atomic_correction", + "messages": [ + {"role": "user", "content": "Correction for future work: I now use Helix instead of Vim. Canary: SI-NEW-HELIX-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "The user now prefers Helix (SI-NEW-HELIX-5199).", "category": "correction", "confidence": 0.99, "scope": "user", "durability": "durable", "authority": "descriptive"} + ], + "factsToRemove": [ + {"id": "fact_old_editor", "scope": "user", "reason": "The user explicitly changed their durable editor preference", "replacementFactIndex": 0} + ] + }, + "expected_persisted_canaries": ["SI-NEW-HELIX-5199"], + "expected_rejected_canaries": [], + "expected_removed_canaries": ["SI-OLD-VIM-5199"], + "seed_facts": [ + {"id": "fact_old_editor", "content": "The user prefers Vim (SI-OLD-VIM-5199).", "category": "preference", "confidence": 0.99, "createdAt": "2026-01-01T00:00:00Z", "source": "benchmark-seed"} + ] + }, + { + "id": "mixed-admission", + "category": "mixed_durable_transient", + "messages": [ + {"role": "user", "content": "I generally want typed Python, but today this draft should stay untyped. Canaries: SI-MIXED-GOLD-5199 and SI-MIXED-GRAY-5199."} + ], + "offline_output": { + "user": {}, + "history": {}, + "newFacts": [ + {"content": "The user generally prefers typed Python (SI-MIXED-GOLD-5199).", "category": "preference", "confidence": 0.99, "scope": "user", "durability": "durable", "authority": "descriptive"}, + {"content": "The current draft should stay untyped (SI-MIXED-GRAY-5199).", "category": "constraint", "confidence": 0.99, "scope": "thread", "durability": "temporary", "authority": "descriptive"} + ], + "factsToRemove": [] + }, + "expected_persisted_canaries": ["SI-MIXED-GOLD-5199"], + "expected_rejected_canaries": ["SI-MIXED-GRAY-5199"], + "expected_removed_canaries": [], + "seed_facts": [] + } + ], + "routing_case": { + "id": "custom-agent-bootstrap", + "canary": "SI-ROUTE-JADE-5199", + "selected": {"user_id": "scope-user-a", "agent_name": "scope-researcher"}, + "other_agent": {"user_id": "scope-user-a", "agent_name": "scope-writer"}, + "other_user": {"user_id": "scope-user-b", "agent_name": "scope-researcher"} + } +} diff --git a/backend/scripts/benchmark/deermem_scope_isolation/report.py b/backend/scripts/benchmark/deermem_scope_isolation/report.py new file mode 100644 index 000000000..1cb88f6fd --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/report.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .contract import Protocol +from .grading import grade_routing_row, grade_semantic_rows +from .runner import MARKER_SCHEMA_VERSION, ROW_SCHEMA_VERSION, _atomic_write_json, _case_fingerprint, _routing_fingerprint, protocol_artifacts, row_is_intact + + +class RowIntegrityError(ValueError): + pass + + +def _read_row(path: Path) -> dict[str, Any]: + try: + row = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RowIntegrityError(f"missing or invalid benchmark row: {path}") from exc + if not isinstance(row, dict) or row.get("schema_version") != ROW_SCHEMA_VERSION: + raise RowIntegrityError(f"unsupported benchmark row: {path}") + if not row_is_intact(row): + raise RowIntegrityError(f"benchmark row failed its result-integrity hash: {path}") + return row + + +def collect_rows(protocol: Protocol, output_dir: Path, marker: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + semantic: list[dict[str, Any]] = [] + for case in protocol.semantic_cases: + row = _read_row(output_dir / "rows" / f"{case.case_id}.json") + if row.get("row_id") != case.case_id or row.get("suite") != "semantic_model_quality" or row.get("request_fingerprint") != _case_fingerprint(case, marker): + raise RowIntegrityError(f"row {case.case_id} does not match the current protocol") + if row.get("update_succeeded") is not True: + raise RowIntegrityError(f"row {case.case_id} contains a failed memory update; rerun the benchmark") + if ( + row.get("expected_persisted_canaries") != list(case.expected_persisted_canaries) + or row.get("expected_rejected_canaries") != list(case.expected_rejected_canaries) + or row.get("expected_removed_canaries") != list(case.expected_removed_canaries) + ): + raise RowIntegrityError(f"row {case.case_id} expected outcomes were changed") + semantic.append(row) + routing = None + if marker["mode"] == "offline": + routing = _read_row(output_dir / "rows" / f"{protocol.routing_case.case_id}.json") + if routing.get("row_id") != protocol.routing_case.case_id or routing.get("suite") != "deterministic_identity_routing" or routing.get("request_fingerprint") != _routing_fingerprint(protocol, marker): + raise RowIntegrityError("routing row does not match the current protocol") + return semantic, routing + + +def build_report(protocol: Protocol, output_dir: Path, marker: dict[str, Any]) -> dict[str, Any]: + semantic, routing = collect_rows(protocol, output_dir, marker) + report = { + "schema_version": 1, + "protocol_id": protocol.protocol_id, + "mode": marker["mode"], + "artifacts": marker["artifacts"], + "model": marker["model"], + "semantic_model_quality": grade_semantic_rows(semantic), + "deterministic_identity_routing": grade_routing_row(routing) if routing is not None else None, + } + return report + + +def write_report(protocol: Protocol, *, manifest_path: Path, output_dir: Path) -> Path: + marker_path = output_dir / "run.json" + if not marker_path.exists(): + raise ValueError(f"{marker_path} is missing; run the benchmark first") + marker = json.loads(marker_path.read_text(encoding="utf-8")) + if marker.get("mode") not in {"offline", "live"}: + raise ValueError(f"{marker_path} has an invalid execution mode") + if marker.get("schema_version") != MARKER_SCHEMA_VERSION or marker.get("protocol_id") != protocol.protocol_id or marker.get("artifacts") != protocol_artifacts(manifest_path) or not isinstance(marker.get("model"), dict): + raise ValueError(f"{marker_path} no longer matches the current protocol/source") + target = output_dir / "report.json" + if target.exists(): + raise FileExistsError(f"refusing to overwrite existing report: {target}") + _atomic_write_json(target, build_report(protocol, output_dir, marker)) + return target diff --git a/backend/scripts/benchmark/deermem_scope_isolation/runner.py b/backend/scripts/benchmark/deermem_scope_isolation/runner.py new file mode 100644 index 000000000..4fb9d8ad4 --- /dev/null +++ b/backend/scripts/benchmark/deermem_scope_isolation/runner.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from langchain_core.messages import AIMessage, HumanMessage + +from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig, DeerMemModelConfig +from deerflow.agents.memory.backends.deermem.deermem.core.llm import build_llm +from deerflow.agents.memory.backends.deermem.deermem.core.paths import DEFAULT_AGENT_BUCKET +from deerflow.agents.memory.backends.deermem.deermem.core.queue import MemoryUpdateQueue +from deerflow.agents.memory.backends.deermem.deermem.core.storage import create_empty_memory, create_storage +from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater + +from .contract import Protocol, SemanticCase + +ROOT = Path(__file__).resolve().parent +BACKEND_ROOT = ROOT.parents[2] +PROMPT_PATH = BACKEND_ROOT / "packages" / "harness" / "deerflow" / "agents" / "memory" / "backends" / "deermem" / "deermem" / "core" / "prompts" / "memory_update.chat.yaml" +ROW_SCHEMA_VERSION = 2 +MARKER_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class LiveSettings: + provider: str + model: str + temperature: float + api_key_env: str + base_url_env: str | None + + def public_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "model": self.model, + "temperature": self.temperature, + "api_key_env": self.api_key_env, + "base_url_env": self.base_url_env, + } + + +@dataclass(frozen=True) +class RunReport: + reused: int + executed: int + + +class _StaticModel: + def __init__(self, output: dict[str, Any]): + self.output = output + self.prompt_sha256: str | None = None + + def invoke(self, prompt: Any, config: dict[str, Any] | None = None) -> Any: + self.prompt_sha256 = _sha256_json(_prompt_projection(prompt)) + return SimpleNamespace(content=json.dumps(self.output), usage_metadata={}) + + +class _CapturingModel: + def __init__(self, model: Any): + self.model = model + self.prompt_sha256: str | None = None + self.usage_metadata: dict[str, Any] = {} + self.response_model: str | None = None + + def invoke(self, prompt: Any, config: dict[str, Any] | None = None) -> Any: + self.prompt_sha256 = _sha256_json(_prompt_projection(prompt)) + response = self.model.invoke(prompt, config=config) + usage = getattr(response, "usage_metadata", None) + self.usage_metadata = usage if isinstance(usage, dict) else {} + metadata = getattr(response, "response_metadata", None) + if isinstance(metadata, dict) and isinstance(metadata.get("model_name"), str): + self.response_model = metadata["model_name"] + return response + + +def _prompt_projection(prompt: Any) -> list[dict[str, str]]: + return [{"type": str(getattr(message, "type", "unknown")), "content": str(getattr(message, "content", message))} for message in prompt] + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + return _sha256_bytes(path.read_bytes()) + + +def _sha256_json(value: Any) -> str: + return _sha256_bytes(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()) + + +def _atomic_write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _git_revision() -> str | None: + result = subprocess.run(["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT, capture_output=True, text=True, check=False) + return result.stdout.strip() if result.returncode == 0 else None + + +def protocol_artifacts(manifest_path: Path) -> dict[str, str | None]: + return { + "manifest_sha256": sha256_file(manifest_path), + "extraction_prompt_sha256": sha256_file(PROMPT_PATH), + "source_revision": _git_revision(), + } + + +def _marker(mode: str, protocol: Protocol, manifest_path: Path, settings: LiveSettings | None) -> dict[str, Any]: + return { + "schema_version": MARKER_SCHEMA_VERSION, + "protocol_id": protocol.protocol_id, + "mode": mode, + "artifacts": protocol_artifacts(manifest_path), + "model": settings.public_dict() if settings else {"provider": "deterministic-static", "model": "offline-fixture", "temperature": 0.0}, + } + + +def ensure_run_identity(output_dir: Path, *, mode: str, protocol: Protocol, manifest_path: Path, settings: LiveSettings | None) -> dict[str, Any]: + expected = _marker(mode, protocol, manifest_path, settings) + marker_path = output_dir / "run.json" + if marker_path.exists(): + actual = json.loads(marker_path.read_text(encoding="utf-8")) + comparable = {key: actual.get(key) for key in expected} + if comparable != expected: + raise ValueError(f"{marker_path} belongs to a different protocol, source revision, prompt, mode, or model") + return actual + marker = dict(expected) + marker["created_at"] = datetime.now(UTC).isoformat().removesuffix("+00:00") + "Z" + _atomic_write_json(marker_path, marker) + return marker + + +def _case_fingerprint(case: SemanticCase, marker: dict[str, Any]) -> str: + return _sha256_json( + { + "protocol_id": marker["protocol_id"], + "mode": marker["mode"], + "artifacts": marker["artifacts"], + "model": marker["model"], + "case": { + "id": case.case_id, + "category": case.category, + "messages": case.messages, + "offline_output": case.offline_output if marker["mode"] == "offline" else None, + "persisted": case.expected_persisted_canaries, + "rejected": case.expected_rejected_canaries, + "removed": case.expected_removed_canaries, + "seed_facts": case.seed_facts, + }, + } + ) + + +def _routing_fingerprint(protocol: Protocol, marker: dict[str, Any]) -> str: + routing = protocol.routing_case + return _sha256_json({"protocol_id": marker["protocol_id"], "mode": "offline", "artifacts": marker["artifacts"], "routing": routing.__dict__}) + + +def _row_path(output_dir: Path, row_id: str) -> Path: + return output_dir / "rows" / f"{row_id}.json" + + +def row_result_sha256(row: dict[str, Any]) -> str: + """Hash a row without its self-authenticating result hash.""" + return _sha256_json({key: value for key, value in row.items() if key != "result_sha256"}) + + +def _seal_row(row: dict[str, Any]) -> dict[str, Any]: + sealed = dict(row) + sealed["result_sha256"] = row_result_sha256(sealed) + return sealed + + +def row_is_intact(row: dict[str, Any]) -> bool: + digest = row.get("result_sha256") + return isinstance(digest, str) and digest == row_result_sha256(row) + + +def _load_reusable_row(path: Path, fingerprint: str) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + row = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(row, dict) or row.get("schema_version") != ROW_SCHEMA_VERSION or row.get("request_fingerprint") != fingerprint or not row_is_intact(row): + return None + if row.get("suite") == "semantic_model_quality" and row.get("update_succeeded") is not True: + return None + return row + + +def _messages(case: SemanticCase) -> list[Any]: + classes = {"user": HumanMessage, "assistant": AIMessage} + return [classes[message["role"]](content=message["content"]) for message in case.messages] + + +def _config(storage_path: Path, model: DeerMemModelConfig | None = None) -> DeerMemConfig: + return DeerMemConfig( + storage_path=str(storage_path), + retrieval_adapter="", + token_counting="char", + staleness_review_enabled=False, + consolidation_enabled=False, + model=model or DeerMemModelConfig(), + ) + + +def _contains(memory: dict[str, Any], canary: str) -> bool: + """Check agent-local facts only; shared summaries are not routing leaks.""" + return any(canary in str(fact.get("content", "")) for fact in memory.get("facts", []) if isinstance(fact, dict)) + + +def _contains_semantic(memory: dict[str, Any], canary: str) -> bool: + if _contains(memory, canary): + return True + for group in ("user", "history"): + sections = memory.get(group, {}) + if not isinstance(sections, dict): + continue + for section in sections.values(): + if isinstance(section, dict) and isinstance(summary := section.get("summary"), str) and canary in summary: + return True + return False + + +def _seed(updater: MemoryUpdater, case: SemanticCase, *, agent_name: str, user_id: str) -> None: + if not case.seed_facts: + return + memory = create_empty_memory() + memory["facts"] = [dict(fact) for fact in case.seed_facts] + updater.import_memory_data(memory, agent_name=agent_name, user_id=user_id) + + +def _live_model(settings: LiveSettings) -> tuple[DeerMemModelConfig, _CapturingModel]: + api_key = os.environ.get(settings.api_key_env) + if not api_key: + raise ValueError(f"required API key environment variable {settings.api_key_env!r} is not set") + base_url = os.environ.get(settings.base_url_env) if settings.base_url_env else None + model_config = DeerMemModelConfig( + provider=settings.provider, + model=settings.model, + api_key=api_key, + base_url=base_url, + temperature=settings.temperature, + ) + model = build_llm(model_config) + if model is None: + raise ValueError("the configured live model could not be constructed") + return model_config, _CapturingModel(model) + + +def _semantic_row(case: SemanticCase, *, mode: str, marker: dict[str, Any], settings: LiveSettings | None) -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="deermem-scope-semantic-") as directory: + if mode == "offline": + model_config = DeerMemModelConfig(model="offline-fixture", temperature=0.0) + model: _StaticModel | _CapturingModel = _StaticModel(case.offline_output) + else: + assert settings is not None + model_config, model = _live_model(settings) + config = _config(Path(directory), model_config) + updater = MemoryUpdater(config, create_storage(config), llm=model) + agent_name = "scope-benchmark-agent" + user_id = f"scope-{case.case_id}" + _seed(updater, case, agent_name=agent_name, user_id=user_id) + succeeded = updater.update_memory( + _messages(case), + thread_id=f"scope-{case.case_id}", + agent_name=agent_name, + user_id=user_id, + bypass_watermark=True, + ) + if not succeeded: + raise RuntimeError(f"memory update failed for benchmark case {case.case_id}; no result row was saved, rerun to retry") + memory = updater.get_memory_data(agent_name, user_id=user_id) + persisted_present = [canary for canary in case.expected_persisted_canaries if _contains_semantic(memory, canary)] + rejected_present = [canary for canary in case.expected_rejected_canaries if _contains_semantic(memory, canary)] + removed_present = [canary for canary in case.expected_removed_canaries if _contains_semantic(memory, canary)] + correction_success = case.category != "atomic_correction" or (set(persisted_present) == set(case.expected_persisted_canaries) and not removed_present) + return _seal_row( + { + "schema_version": ROW_SCHEMA_VERSION, + "suite": "semantic_model_quality", + "row_id": case.case_id, + "category": case.category, + "request_fingerprint": _case_fingerprint(case, marker), + "update_succeeded": bool(succeeded), + "expected_persisted_canaries": list(case.expected_persisted_canaries), + "expected_rejected_canaries": list(case.expected_rejected_canaries), + "expected_removed_canaries": list(case.expected_removed_canaries), + "persisted_canaries_present": persisted_present, + "rejected_canaries_present": rejected_present, + "removed_canaries_present": removed_present, + "atomic_correction_success": correction_success, + "extraction_prompt_render_sha256": model.prompt_sha256, + "response_model": getattr(model, "response_model", None), + "usage": getattr(model, "usage_metadata", {}), + } + ) + + +def _routing_row(protocol: Protocol, marker: dict[str, Any]) -> dict[str, Any]: + routing = protocol.routing_case + output = { + "user": {}, + "history": {}, + "newFacts": [ + { + "content": f"Synthetic routing marker {routing.canary}.", + "category": "context", + "confidence": 0.99, + "scope": "user", + "durability": "durable", + "authority": "descriptive", + } + ], + "factsToRemove": [], + } + with tempfile.TemporaryDirectory(prefix="deermem-scope-routing-") as directory: + model = _StaticModel(output) + config = _config(Path(directory), DeerMemModelConfig(model="offline-fixture", temperature=0.0)) + updater = MemoryUpdater(config, create_storage(config), llm=model) + queue = MemoryUpdateQueue(config, updater) + queue.add( + "routing-thread", + [HumanMessage(content=f"Remember my synthetic routing marker {routing.canary}.")], + agent_name=routing.selected["agent_name"], + user_id=routing.selected["user_id"], + ) + queue.flush(skip_inter_item_delay=True) + default_scope = { + "user_id": routing.selected["user_id"], + "agent_name": DEFAULT_AGENT_BUCKET, + } + + def present(scope: dict[str, str]) -> bool: + memory = updater.get_memory_data( + scope["agent_name"], + user_id=scope["user_id"], + ) + return _contains(memory, routing.canary) + + return _seal_row( + { + "schema_version": ROW_SCHEMA_VERSION, + "suite": "deterministic_identity_routing", + "row_id": routing.case_id, + "request_fingerprint": _routing_fingerprint(protocol, marker), + "checked_scopes": { + "selected": routing.selected, + "default": default_scope, + "other_agent": routing.other_agent, + "other_user": routing.other_user, + }, + "selected_present": present(routing.selected), + "default_present": present(default_scope), + "other_agent_present": present(routing.other_agent), + "other_user_present": present(routing.other_user), + "extraction_prompt_render_sha256": model.prompt_sha256, + } + ) + + +def run(protocol: Protocol, *, manifest_path: Path, output_dir: Path, mode: str, settings: LiveSettings | None = None) -> RunReport: + if mode not in {"offline", "live"}: + raise ValueError("mode must be offline or live") + if (mode == "live") != (settings is not None): + raise ValueError("live settings are required exactly for live mode") + marker = ensure_run_identity(output_dir, mode=mode, protocol=protocol, manifest_path=manifest_path, settings=settings) + reused = 0 + executed = 0 + for case in protocol.semantic_cases: + fingerprint = _case_fingerprint(case, marker) + path = _row_path(output_dir, case.case_id) + if _load_reusable_row(path, fingerprint) is not None: + reused += 1 + continue + _atomic_write_json(path, _semantic_row(case, mode=mode, marker=marker, settings=settings)) + executed += 1 + if mode == "offline": + fingerprint = _routing_fingerprint(protocol, marker) + path = _row_path(output_dir, protocol.routing_case.case_id) + if _load_reusable_row(path, fingerprint) is not None: + reused += 1 + else: + _atomic_write_json(path, _routing_row(protocol, marker)) + executed += 1 + return RunReport(reused=reused, executed=executed) diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index b30a789cd..0e6faefe8 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -2,6 +2,14 @@ Backend tests must preserve the runtime invariants they exercise without changing production execution topology. +## Scope-isolation benchmark + +`test_bench_deermem_scope_isolation.py` exercises production admission and storage. +Check persisted facts and user/history summaries for semantic safety, but only +agent-local facts for routing. Failed updates must not become sealed observations; +report and resume must reject failed or old-schema rows. Stub live models so these +tests never need credentials or network access. + ## MCP claim fencing `test_mcp_task_repository.py` covers same-worker reclaim during an in-flight diff --git a/backend/tests/test_bench_deermem_scope_isolation.py b/backend/tests/test_bench_deermem_scope_isolation.py new file mode 100644 index 000000000..8ece88663 --- /dev/null +++ b/backend/tests/test_bench_deermem_scope_isolation.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from deerflow.agents.memory.backends.deermem.deermem.core.paths import DEFAULT_AGENT_BUCKET +from scripts.benchmark.deermem_scope_isolation import runner +from scripts.benchmark.deermem_scope_isolation.contract import load_protocol +from scripts.benchmark.deermem_scope_isolation.grading import grade_routing_row, grade_semantic_rows +from scripts.benchmark.deermem_scope_isolation.report import RowIntegrityError, build_report, collect_rows, write_report +from scripts.benchmark.deermem_scope_isolation.runner import ROOT, ensure_run_identity, run + +MANIFEST = ROOT / "manifest.json" + + +def test_contract_is_versioned_and_canaries_are_unique() -> None: + protocol = load_protocol(MANIFEST) + + assert protocol.protocol_id == "deermem-scope-isolation-v1" + assert len(protocol.semantic_cases) == 6 + canaries = [canary for case in protocol.semantic_cases for canary in (*case.expected_persisted_canaries, *case.expected_rejected_canaries, *case.expected_removed_canaries)] + [protocol.routing_case.canary] + assert len(canaries) == len(set(canaries)) + + +def test_contract_rejects_duplicate_canary(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + canary = manifest["semantic_cases"][0]["expected_persisted_canaries"][0] + case = manifest["semantic_cases"][1] + case["expected_rejected_canaries"] = [canary] + case["messages"][0]["content"] += f" {canary}" + case["offline_output"]["newFacts"][0]["content"] += f" {canary}" + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match="canary .* is reused"): + load_protocol(path) + + +def test_offline_runner_executes_production_scope_and_routing_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + protocol = load_protocol(MANIFEST) + monkeypatch.setattr( + "scripts.benchmark.deermem_scope_isolation.runner.build_llm", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("offline mode must not build a provider model")), + ) + + result = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + marker = json.loads((tmp_path / "run.json").read_text(encoding="utf-8")) + semantic, routing = collect_rows(protocol, tmp_path, marker) + + assert result.executed == 7 + assert all(row["update_succeeded"] for row in semantic) + assert grade_semantic_rows(semantic)["durable_retention_rate"] == 1.0 + assert grade_semantic_rows(semantic)["unsafe_persistence_rate"] == 0.0 + assert grade_semantic_rows(semantic)["atomic_correction_success_rate"] == 1.0 + assert routing is not None + assert routing["checked_scopes"]["default"]["agent_name"] == DEFAULT_AGENT_BUCKET + assert grade_routing_row(routing)["cross_agent_contamination_rate"] == 0.0 + assert grade_routing_row(routing)["cross_user_contamination_rate"] == 0.0 + assert grade_routing_row(routing)["custom_agent_bootstrap_success"] is True + + +def test_resume_reuses_protocol_bound_rows_and_rejects_changed_manifest(tmp_path: Path) -> None: + protocol = load_protocol(MANIFEST) + first = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path / "run", mode="offline") + second = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path / "run", mode="offline") + + assert first.executed == 7 + assert second.executed == 0 + assert second.reused == 7 + + changed = tmp_path / "changed.json" + raw = json.loads(MANIFEST.read_text(encoding="utf-8")) + raw["protocol_id"] = "changed" + changed.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="different protocol"): + ensure_run_identity(tmp_path / "run", mode="offline", protocol=load_protocol(changed), manifest_path=changed, settings=None) + + +def test_report_recomputes_metrics_and_rejects_tampered_outcome(tmp_path: Path) -> None: + protocol = load_protocol(MANIFEST) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + marker = json.loads((tmp_path / "run.json").read_text(encoding="utf-8")) + + report = build_report(protocol, tmp_path, marker) + + assert report["semantic_model_quality"]["durable_retention_rate"] == 1.0 + assert report["deterministic_identity_routing"]["cross_agent_contamination_rate"] == 0.0 + + path = tmp_path / "rows" / "durable-preference.json" + row = json.loads(path.read_text(encoding="utf-8")) + row["expected_persisted_canaries"] = [] + path.write_text(json.dumps(row), encoding="utf-8") + with pytest.raises(RowIntegrityError, match="result-integrity"): + collect_rows(protocol, tmp_path, marker) + + +def test_contract_rejects_reused_routing_canary(tmp_path): + raw = json.loads(MANIFEST.read_text(encoding="utf-8")) + raw["routing_case"]["canary"] = raw["semantic_cases"][0]["expected_persisted_canaries"][0] + path = tmp_path / "manifest.json" + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="routing canary must be unique"): + load_protocol(path) + + +@pytest.mark.parametrize("group,section", [("user", "workContext"), ("user", "personalContext"), ("user", "topOfMind"), ("history", "recentMonths"), ("history", "earlierContext"), ("history", "longTermBackground")]) +@pytest.mark.parametrize("scope,unsafe", [("user", True), ("project", False)]) +def test_semantic_verdict_checks_persisted_summaries(tmp_path, group, section, scope, unsafe): + protocol = load_protocol(MANIFEST) + case = protocol.semantic_cases[1] + canary = case.expected_rejected_canaries[0] + output = {"user": {}, "history": {}, "newFacts": [], "factsToRemove": []} + output[group][section] = {"shouldUpdate": True, "summary": canary, "scope": scope, "authority": "descriptive"} + protocol = replace(protocol, semantic_cases=(replace(case, offline_output=output),)) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + marker = json.loads((tmp_path / "run.json").read_text(encoding="utf-8")) + semantic, _ = collect_rows(protocol, tmp_path, marker) + assert semantic[0]["rejected_canaries_present"] == ([canary] if unsafe else []) + assert grade_semantic_rows(semantic)["unsafe_persistence_rate"] == float(unsafe) + + +def test_missing_live_key_fails_before_model_construction(tmp_path, monkeypatch): + monkeypatch.delenv("DEERMEM_BENCH_TEST_KEY", raising=False) + monkeypatch.setattr(runner, "build_llm", lambda *_: pytest.fail("model construction reached without a key")) + settings = runner.LiveSettings("openai", "test", 0.0, "DEERMEM_BENCH_TEST_KEY", None) + with pytest.raises(ValueError, match="required API key"): + run(load_protocol(MANIFEST), manifest_path=MANIFEST, output_dir=tmp_path, mode="live", settings=settings) + + +def test_report_refuses_to_overwrite_evidence(tmp_path): + protocol = load_protocol(MANIFEST) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + report = write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + original = report.read_bytes() + with pytest.raises(FileExistsError, match="refusing to overwrite"): + write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + assert report.read_bytes() == original + + +@pytest.mark.parametrize("failure", ["provider", "json"]) +def test_failed_live_extraction_is_not_sealed_and_resume_retries(tmp_path, monkeypatch, failure): + protocol = load_protocol(MANIFEST) + settings = runner.LiveSettings("openai", "test", 0.0, "DEERMEM_BENCH_TEST_KEY", None) + monkeypatch.setenv(settings.api_key_env, "synthetic-test-key") + cases = iter(enumerate(protocol.semantic_cases)) + + def fail_invoke(*args, **kwargs): + if failure == "provider": + raise TimeoutError("synthetic provider timeout") + return SimpleNamespace(content="not JSON") + + def build(*args): + index, case = next(cases) + return SimpleNamespace(invoke=fail_invoke) if index == 1 else runner._StaticModel(case.offline_output) + + monkeypatch.setattr(runner, "build_llm", build) + with pytest.raises(RuntimeError, match="project-constraint"): + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="live", settings=settings) + assert sorted(path.stem for path in (tmp_path / "rows").glob("*.json")) == ["durable-preference"] + with pytest.raises(RowIntegrityError, match="missing or invalid"): + write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + remaining = iter(protocol.semantic_cases[1:]) + monkeypatch.setattr(runner, "build_llm", lambda *_: runner._StaticModel(next(remaining).offline_output)) + resumed = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="live", settings=settings) + assert (resumed.reused, resumed.executed) == (1, 5) + write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + + +def test_unsuccessful_rows_are_rejected_and_not_reused(tmp_path): + protocol = load_protocol(MANIFEST) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + path = tmp_path / "rows" / "project-constraint.json" + row = json.loads(path.read_text(encoding="utf-8")) + row["update_succeeded"] = False + path.write_text(json.dumps(runner._seal_row(row)), encoding="utf-8") + with pytest.raises(RowIntegrityError, match="failed"): + write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + with pytest.raises(ValueError, match="failed"): + grade_semantic_rows([row]) + resumed = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + assert (resumed.executed, resumed.reused) == (1, 6) + + +def test_legacy_fact_only_rows_are_not_reported_or_reused(tmp_path): + protocol = load_protocol(MANIFEST) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + path = tmp_path / "rows" / "project-constraint.json" + row = json.loads(path.read_text(encoding="utf-8")) + row["schema_version"] = 1 + path.write_text(json.dumps(runner._seal_row(row)), encoding="utf-8") + with pytest.raises(RowIntegrityError, match="unsupported benchmark row"): + write_report(protocol, manifest_path=MANIFEST, output_dir=tmp_path) + resumed = run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + assert (resumed.executed, resumed.reused) == (1, 6) + + +def test_routing_does_not_treat_shared_summaries_as_agent_leaks(tmp_path, monkeypatch): + protocol = load_protocol(MANIFEST) + canary = protocol.routing_case.canary + original_invoke = runner._StaticModel.invoke + + def invoke(self, prompt, config=None): + response = original_invoke(self, prompt, config) + output = json.loads(response.content) + if any(canary in fact["content"] for fact in output["newFacts"]): + output["user"]["workContext"] = {"shouldUpdate": True, "summary": canary, "scope": "user", "authority": "descriptive"} + response.content = json.dumps(output) + return response + + monkeypatch.setattr(runner._StaticModel, "invoke", invoke) + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + marker = json.loads((tmp_path / "run.json").read_text(encoding="utf-8")) + _, routing = collect_rows(protocol, tmp_path, marker) + assert routing["selected_present"] is True + assert routing["default_present"] is False + assert routing["other_agent_present"] is False + assert routing["other_user_present"] is False + + +def test_storage_failure_does_not_produce_a_semantic_row(tmp_path, monkeypatch): + protocol = load_protocol(MANIFEST) + finalize = runner.MemoryUpdater._finalize_update + + def fail_save(*args, **kwargs): + raise OSError("synthetic storage failure") + + monkeypatch.setattr(runner.MemoryUpdater, "_finalize_update", fail_save) + with pytest.raises(RuntimeError, match="durable-preference"): + run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline") + assert not (tmp_path / "rows").exists() + monkeypatch.setattr(runner.MemoryUpdater, "_finalize_update", finalize) + assert run(protocol, manifest_path=MANIFEST, output_dir=tmp_path, mode="offline").executed == 7