diff --git a/AgentMemory.slnx b/AgentMemory.slnx index 5142c838..afe8c063 100644 --- a/AgentMemory.slnx +++ b/AgentMemory.slnx @@ -32,6 +32,7 @@ + @@ -39,6 +40,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6303f4..4c4dc6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Facts are now identified by canonical keys.** `Fact` nodes carry `subject_key`, `predicate_key`, + `object_key` and `owner_key`, and upserts MERGE on those rather than on the raw triple. This is what + makes one relation reachable under all of its stored phrasings. + + **Upgrading an existing database:** call `ISchemaBootstrapper.BootstrapAsync()` before writing, as + the getting-started guide and every sample already do. It backfills the keys onto existing facts + idempotently. **If you skip it, an upsert of a fact that already exists will not match it and will + create a duplicate** — the pre-1.4 rows have no keys to match on. `agentmemory schema-check` now + reports this state explicitly so it is visible before it causes damage. + +- `INeo4jTransactionRunner` implementations that do not also implement `INeo4jAtomicTransactionRunner` + no longer throw at construction. Persistence degrades to pass-through and reports + `SupportsAtomicRollback = false` instead of refusing to start. + + ## [1.3.0] - 2026-07-19 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index ec9b28dc..7c5c0d2e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,9 +20,9 @@ which builds and runs cleanly under net10.0). net8.0 costs nothing to add and lets consumers on the still-widely-deployed .NET 8 LTS use the library without adopting a newer runtime; net10.0 keeps pace with the newest release. Verified with real builds and executed tests on all three TFMs, not - just compiled. Scoped the same way as the packaging metadata below, plus excluding the three - non-packable tools/ console apps (Cli, TckBridge, TckBridge.Nams), which stay single-targeted. --> - + just compiled. Scoped the same way as the packaging metadata below, plus excluding the four + non-packable tools/ console apps (Cli, LongMemEval, TckBridge, TckBridge.Nams), which stay single-targeted. --> + net10.0;net9.0;net8.0 diff --git a/docs/architecture.md b/docs/architecture.md index fd476f03..180df9fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,7 +142,7 @@ graph TD | **Purpose** | Domain contracts — all models, interfaces, and configuration types shared across the system | | **Dependencies** | **Microsoft.Extensions.AI.Abstractions** 10.8.0 (approved, D-AR2-1) — .NET BCL otherwise (multi-targets net8.0/net9.0/net10.0) | | **MUST NOT reference** | Neo4j.Driver, Microsoft.Agents.*, any GraphRAG SDK, any MCP SDK, any NuGet package **except** Microsoft.Extensions.AI.Abstractions | -| **Key types** | 49 domain records (Conversation, Message, Entity, Fact, Preference, Relationship, MemoryHistoryQuery, MemoryHistoryRecord, ReasoningTrace, ReasoningStep, ToolCall, ToolCallStats, IngestionItemOutcome, etc.), 39 service interfaces (incl. `IMemoryIsolationPolicy`, #100), 11 repository interfaces, 16 configuration types (incl. `MemoryRankingOptions`, `MemoryIsolationOptions`), 24 enums (incl. `MemoryProfile`, `RankingIntent`, `DuplicateStatus`, `EntityMatchType`, `MemoryNodeKind`, `MemoryOperationAccess`, `MemoryIsolationMode`, `IngestionStatus`, `IngestionStage`, `IngestionItemStatus`, `MemoryItemKind`, `IngestionFailureMode`, `MemoryTrustLevel`) (see the catalogs in `design.md §5/§6` for the authoritative, per-type list) | +| **Key types** | 51 domain records (Conversation, Message, Entity, Fact, Preference, Relationship, MemoryHistoryQuery, MemoryHistoryRecord, ReasoningTrace, ReasoningStep, ToolCall, ToolCallStats, IngestionItemOutcome, MemoryContextRankedItem, UnifiedExtractionResult, etc.), 41 service interfaces (incl. `IMemoryIsolationPolicy`, `IUnifiedMemoryExtractor`, and `IMultiSessionUnifiedMemoryExtractor`), 11 repository interfaces, 16 configuration types (incl. `MemoryRankingOptions`, `MemoryIsolationOptions`), 24 enums (incl. `MemoryProfile`, `RankingIntent`, `DuplicateStatus`, `EntityMatchType`, `MemoryNodeKind`, `MemoryOperationAccess`, `MemoryIsolationMode`, `IngestionStatus`, `IngestionStage`, `IngestionItemStatus`, `MemoryItemKind`, `IngestionFailureMode`, `MemoryTrustLevel`) | **Namespace structure:** ``` diff --git a/docs/performance/README.md b/docs/performance/README.md index fc37fa17..c9002eef 100644 --- a/docs/performance/README.md +++ b/docs/performance/README.md @@ -75,8 +75,15 @@ the per-fingerprint totals exactly matched `neo4j.queries`. ### Quality guards — deterministic and enforced Every performance run also executes 19 judged retrieval cases and 20 judged extraction cases. Retrieval -is scored with Recall@K, MRR, and forbidden-result checks; extraction is scored with precision and -recall per memory kind plus false positives on six turns that should teach the system nothing. +is scored with **deterministic-plumbing Recall@K/MRR** and forbidden-result checks; extraction is +scored with precision and recall per memory kind plus false positives on six turns that should teach +the system nothing. + +That label is permanent, like `bytes_est`. The fixture uses the deterministic FNV-1a test embedder and +deliberately disjoint vocabulary, so 1.000 / 1.000 proves that retrieval wiring, ranking, scoping and +guard enforcement still behave exactly—not that a production embedding model has perfect semantic +quality. Sampled real-embedding/real-model quality belongs to M-27 (LongMemEval), with its model, +dataset, seed and retrieval configuration fingerprinted. Five fresh-container runs produced identical values for every guarded metric, so the committed tolerance is the observed variance: **zero**. The gate is on by default and returns a non-zero exit when @@ -109,13 +116,58 @@ connection pool, plus explicit Neo4j query-plan-cache clearing after scenario se claim to reset the Neo4j page cache or host filesystem cache; fixture setup may touch both. These local hermetic milliseconds are useful as an in-run ratio, not as deployment latency. -### Not yet measured +### Concurrent correctness and local saturation + +`perf concurrency` is an opt-in reliability characterization against one fixed, fingerprinted product +driver pool (16 connections by default). It self-asserts owner-isolated reads, concurrent fact +dedup-on-create, and non-destructive owner-scoped supersession at 1, 10, and 100 logical sessions. + +The first red probe proved the command was capable of finding a real defect: 10 concurrent same-owner +near-duplicate fact creates left 10 live facts. After serializing that process-local dedup decision and +scoping exact cosine comparison before ranking, the unchanged test left exactly 1 live fact. Every +other correctness guard stayed exact: + +| Sessions | Errors | Owner leaks / misses | Live near-duplicates | Losers present / closed | Edges / live winners | Cross-owner edges | +|---:|---:|---:|---:|---:|---:|---:| +| 1 | 0 | 0 / 0 | 1 | 1 / 1 | 1 / 1 | 0 | +| 10 | 0 | 0 / 0 | 1 | 10 / 10 | 10 / 10 | 0 | +| 100 | 0 | 0 / 0 | 1 | 100 / 100 | 100 / 100 | 0 | + +The same accepted local run reported request p50/p99 and throughput as follows. These numbers describe +that one hermetic run only; they are not deployment latency: + +| Workload | Sessions | p50 ms | p99 ms | operations/s | +|---|---:|---:|---:|---:| +| owner-isolation read | 10 | 14.342 | 14.613 | 662.17 | +| dedup-on-create race | 10 | 202.582 | 255.409 | 38.92 | +| owner-scoped supersession | 10 | 22.076 | 22.167 | 442.39 | +| owner-isolation read | 100 | 1,537.608 | 3,060.237 | 32.65 | +| dedup-on-create race | 100 | 527.468 | 1,295.569 | 76.23 | +| owner-scoped supersession | 100 | 1,533.084 | 3,067.458 | 32.59 | + +The artifact also reports `transaction_entry_ms_est` percentiles. This is permanently labelled an +upper-bound estimate: it includes connection acquisition, routing, and transaction begin, not exact +pool queue time. The correctness claim covers concurrent sessions inside one application process; +distributed dedup coordination across multiple application instances is not yet measured. + +### Fail-fast torn-write rollback + +Fail-fast extraction persistence prepares all external embeddings before opening one explicit Neo4j +transaction. Entity, fact, preference, relationship, provenance, temporal, and supersession repository +operations then join that transaction. Default best-effort mode retains its independent-write behavior. + +A dedicated live-Neo4j integration test kills the Neo4j JVM after the first repository write returns +inside the transaction, verifies from a fresh driver that the database is unreachable, restarts the +same container, and compares an isolated-owner graph snapshot with its pre-turn state. The red-first +run without the atomic boundary left 1 entity and 1 provenance edge. With the boundary enabled, the +post-failure snapshot was empty; one exact retry produced 2 entities, 1 fact, 1 preference, +1 relationship, and 4 provenance edges, with no duplicates, invalidation, valid-time closure, or +supersession artifacts. The test also self-asserts that model/embedding calls finish before the +transaction opens and that the Neo4j coordinator and repositories share the same runner instance. -Stated plainly rather than left for you to discover: +### Not yet measured - **Managed/hosted deployments** — no Aura or NAMS figures yet. -- **Concurrency** — single-session only; no saturation or p99-under-load numbers. - ### Scale-M validation `--scale M` adds exactly 250,000 foreign-scope distractor memories: 50,000 each of entities, facts, @@ -125,7 +177,7 @@ counts after restore. On `PERF-R-04`, Scale S and Scale M performed the same structural work: 43 retrieved items, 25 access-tracked items, 9 queries, 6 read transactions, 1 write transaction, and 43 materialized records. -Recall@K, MRR, and every extraction-quality score remained 1.000. Estimated payload changed from +Deterministic-plumbing Recall@K, MRR, and every extraction-quality score remained 1.000. Estimated payload changed from 144,591 to 144,555 bytes (−36; −0.025%) and context length from 3,906 to 3,886 characters because the approximate vector index selected a different equally relevant near-tied fixture item. A second independent Scale-M restore reproduced 144,555 bytes and 3,886 characters exactly. @@ -135,6 +187,114 @@ second Docker volume clone. This is a harness-usability result, **not deployment --- +## Matched `feat-01` before/after characterization + +The exact pre-`feat-01` harness commit (`b1d924e9929b`) and post-`feat-01` commit (`0455c584ce`) were +rerun back-to-back on the same machine with zero provider latency, 10 measured iterations, and 3 +warm-ups. “Full phase” is the elapsed time for the complete recall or ingestion harness phase. + +| Full phase | Before p50 / p95 | After p50 / p95 | Movement | Interpretation | +|---|---:|---:|---:|---| +| Recall | **313.03 / 641.45 ms** | **50.59 / 113.11 ms** | **−262.44 ms (−83.8%) p50; −528.34 ms (−82.4%) p95** | Attributable to batching 25 access-tracking write transactions into 1; 43 retrieved and 25 tracked items held | +| Ingestion | **336.83 / 2,859.29 ms** | **221.92 / 352.90 ms** | −114.91 ms (−34.1%) p50 | Control variance only: `feat-01` did not change ingestion | + +These are local hermetic characterization timings, not deployment latency. The portable causal result +is recall write transactions **25 → 1**, queries **31 → 9**, and total database round trips **31 → 7**, +with retrieved and access-tracked item guards unchanged. + +--- + +## Measured improvements after the 1.3.0 baseline + +| Improvement | Scenario | Portable counter | Before | After | Change | +|---|---|---|---:|---:|---:| +| Combined single-message Neo4j persistence | `PERF-W-02` | queries per turn | 43 | **40** | **−3 (−7.0%)** | +| Combined single-message Neo4j persistence | `PERF-W-03` | queries per turn | 88 | **70** | **−18 (−20.5%)** | +| Skip redundant provenance re-writes | `PERF-W-02` | write transactions per turn | 18 | **8** | **−10 (−55.6%)** | +| Skip redundant provenance re-writes | `PERF-W-02` | queries per turn | 40 | **30** | **−10 (−25.0%)** | +| Skip redundant provenance re-writes | `PERF-W-03` | write transactions per turn | 48 | **13** | **−35 (−72.9%)** | +| Skip redundant provenance re-writes | `PERF-W-03` | queries per turn | 70 | **35** | **−35 (−50.0%)** | +| Batch memory upserts | `PERF-W-02` | write transactions per turn | 8 | **6** | **−2 (−25.0%)** | +| Batch memory upserts | `PERF-W-02` | queries per turn | 30 | **28** | **−2 (−6.7%)** | +| Batch memory upserts | `PERF-W-03` | write transactions per turn | 13 | **11** | **−2 (−15.4%)** | +| Batch memory upserts | `PERF-W-03` | queries per turn | 35 | **33** | **−2 (−5.7%)** | +| Batch memory upserts | `PERF-W-05` | write transactions per extraction | 7 | **5** | **−2 (−28.6%)** | +| Batch memory upserts | `PERF-W-05` | queries per extraction | 28 | **26** | **−2 (−7.1%)** | +| Batch entity-resolution snapshots | `PERF-W-12-X01` | entity candidate reads per 40 sessions | 80 | **20** | **−60 (−75.0%)** | +| Batch entity-resolution snapshots | `PERF-W-12-X01` | total read transactions per 40 sessions | 120 | **60** | **−60 (−50.0%)** | +| Batch entity-resolution snapshots | `PERF-W-12-X01` | queries per 40 sessions | 930 | **870** | **−60 (−6.5%)** | +| Batch entity-resolution snapshots | `PERF-W-12-X01` | estimated payload bytes per 40 sessions | 2,583,298 | **2,053,922** | **−529,376 (−20.5%)** | +| Fused ordered source-session persistence | `PERF-W-12-X01` | queries per 40 sessions | 870 | **230** | **−640 (−73.6%)** | +| Fused ordered source-session persistence | `PERF-W-12-X01` | read transactions per 40 sessions | 60 | **20** | **−40 (−66.7%)** | +| Fused ordered source-session persistence | `PERF-W-12-X01` | write transactions per 40 sessions | 250 | **50** | **−200 (−80.0%)** | + +Message creation, optional embedding persistence, `HAS_MESSAGE`, `FIRST_MESSAGE`, and `NEXT_MESSAGE` +maintenance now execute as one parameterized Cypher operation. Write transactions remain 18 / 48, +message counts remain 1 / 6, and estimated payload remains 102,960 / 108,964 bytes. Deterministic +retrieval and extraction quality guards remain unchanged at 1.000, with a 0% extraction false-positive +rate. Local-container milliseconds are intentionally omitted because they are not deployment timings. + +Neo4j entity, fact, and preference upserts already create every `EXTRACTED_FROM` edge from the +memory's source-message IDs. The core persistence stage now recognizes that internal capability and +does not issue the same `MERGE` again in a separate transaction per memory/message pair. Repositories +without the capability retain the existing explicit provenance behavior. The 50-message whole-session +guard still reads back exactly 250 provenance edges (5 learned memories × 50 source messages), while +payload, records, learned items, and deterministic quality stay unchanged. + +The remaining entity and fact writes now use one atomic `UNWIND` upsert per memory kind when the +repository advertises batch support. The same opt-in capability also covers preferences and graph +relationships when a turn contains more than one; live Neo4j tests verify their owner, temporal, +embedding, metadata, and provenance fields. `ExtractionOptions.EnableBatchMemoryUpserts` can disable +the optimization. Default best-effort mode rolls a failed atomic batch back and replays the existing +item path so per-item outcomes are preserved; fail-fast mode intentionally keeps item writes inside +its whole-turn transaction so an error still identifies the exact failing item. Two fresh-container +runs reproduced every counter above exactly. Records, estimated bytes, learned items, and both +zero-tolerance quality guards were unchanged. + +Multi-session extraction now fetches each owner/type entity candidate set once, prefetches independent +types concurrently, and updates that request-local snapshot as chronological sessions are resolved. +`ExtractionOptions.UseBatchEntityResolutionSnapshots` can disable the default-on optimization. A +remote-latency-shaped, fresh-container control/candidate characterization moved the X01 extraction-wave +p50 from **47,341.00 to 32,600.96 ms (−31.1%)** and X10 from **7,568.38 to 3,621.08 ms +(−52.2%)**. Writes remained 250; model calls 10; embedding work 130 requests / 720 items; the learned +80/40/40/40 entity/fact/preference/relationship graph, provenance, source order, owner isolation, and +both zero-tolerance quality gates were unchanged. These milliseconds include injected provider delay +and local Docker orchestration; they are controlled-host causal evidence, not deployment latency. +The related five-worker scaling gate reached **2.991×** rather than the locked 3.000×, so the broader +cold-build phase remains fail-closed pending the separate persistence candidate. + +That persistence candidate is now accepted. The pipeline keeps independent owners parallel and +same-owner source sessions chronological, prepares embeddings outside the transaction, defers the +resolver's duplicate entity writes, and commits each source session atomically. Neo4j entity, fact, +and preference `UNWIND` queries now include embedding, message provenance, optional point data, and +dynamic POLE+O labels; relationships were already one bounded query. A transaction-only intermediate +was rejected because it regressed X05/X10. The accepted fused design reduced the query chain inside +each commit and moved paired remote-shape p50 from **39,159.14 → 28,813.92 ms at X01 (−26.42%)**, +**6,774.52 → 5,851.43 ms at X05 (−13.63%)**, and **3,807.44 → 3,788.83 ms at X10 +(−0.49%)**. Candidate X05/X10 scaling reached **4.924× / 7.605×**. Exact model, embedding, +graph, provenance, ordering, isolation, and both quality guards held. These milliseconds include +injected provider delay and local Docker; the portable causal result is the exact counter movement +above. + +### Cold structured-memory build laboratory + +These opt-in laboratory arms measure preparation-workflow candidates; they are not yet shipped +AgentMemory defaults and their controlled-host milliseconds are not deployment latency. + +| Candidate | Controlled comparison | Before p50 / p95 | After p50 / p95 | Movement | Correctness guards | +|---|---|---:|---:|---:|---| +| Batch 50 raw-message embeddings + writes | `PERF-W-06` control/candidate | 167.84 / 323.08 ms | 60.24 / 86.03 ms | **−64.1% / −73.4%** | 50 messages/vectors; requests 50 → 1; queries 102 → 1; quality 1.000 | +| One typed extraction response | `PERF-W-07` → `PERF-W-09` | 903.66 / 909.96 ms | 908.79 / 916.96 ms | +0.6% / +0.8% wall; calls **4 → 1**; total tokens **979 → 353** | Exact 2/2/1/1 output; zero retries/failures; quality 1.000 | +| Bounded independent-owner cold build | `PERF-W-10-C01` → `PERF-W-10-C10` | 34,202.82 / 47,516.61 ms | 3,195.68 / 4,732.44 ms | **10.70× / 10.04× faster** | Exact 10 calls, 10 messages, 20/20/10/10 learned graph, 80 embeddings, 40/70/270 reads/writes/queries, provenance/isolation, quality 1.000 | +| Fused ordered source-session persistence | `PERF-W-12` feature off/on | X01 39,159.14 / 50,946.60 ms | X01 28,813.92 / 32,074.05 ms | **−26.42% / −37.04%**; queries 870 → 230; reads 60 → 20; writes 250 → 50 | Exact 10 calls, 130/720 embeddings, 80/40/40/40 graph, provenance/order/isolation, quality 1.000; X05/X10 scaling 4.924×/7.605× | + +The unified response reduces provider capacity and token cost, but not one-unit wall time because the +four original category calls already overlap. The wall-time lever is bounded concurrency across +independent owners. The next gate integrates that evidence into the prepared LongMemEval cold-build +path and must project the fixed ten-question build below 15 minutes before another full build is run. + +--- + ## Reproduce it yourself Requires Docker. The harness provisions its own pinned Neo4j, so it does not touch your database. @@ -158,6 +318,20 @@ dotnet run --project tools/AgentMemory.Cli -- perf --label graphrag \ dotnet run --project tools/AgentMemory.Cli -- perf --label session-extraction \ --scenarios PERF-W-05 --iterations 3 + +# Isolates resolution, learned-memory embeddings, persistence, provenance, and owner isolation +dotnet run --project tools/AgentMemory.Cli -- perf --label frozen-persistence \ + --scenarios PERF-W-08 --iterations 10 + +# Compares the shipped four-call extractor with one typed unified extraction call +dotnet run --project tools/AgentMemory.Cli -- perf --label unified-extraction \ + --scenarios PERF-W-07,PERF-W-09 --latency remote --iterations 10 + +# Measures ten complete owner-isolated cold-build units at 1, 5, and 10 workers +dotnet run --project tools/AgentMemory.Cli -- perf --label cold-build-concurrency \ + --scenarios PERF-W-10-C01,PERF-W-10-C05,PERF-W-10-C10 \ + --latency remote --iterations 3 + # Restores the reusable 250k-node Scale-M dataset, then runs the same guarded scenario dotnet run --project tools/AgentMemory.Cli -- perf --label scale-m \ --scale M --scenarios PERF-R-04 --iterations 1 @@ -166,6 +340,10 @@ dotnet run --project tools/AgentMemory.Cli -- perf --label scale-m \ dotnet run --project tools/AgentMemory.Cli -- perf cold --label cold-r04 \ --scenarios PERF-R-04 --samples 5 --warmup 3 +# Opt-in concurrent correctness + local saturation (fixed 16-connection product pool) +dotnet run --project tools/AgentMemory.Cli -- perf concurrency --label concurrency \ + --levels 1,10,100 --pool-size 16 + # Compare two in-process recall configurations, with quality in the same report dotnet run --project tools/AgentMemory.Cli -- perf ab \ --control default \ @@ -222,8 +400,16 @@ the complete memory result. The greeting scenario locks its current default-poli the per-turn ingestion scenarios verify message persistence and extraction outcomes. Whole-session extraction additionally requires exactly 50 source messages and reads the graph back after the measured turn to prove that two entities, two facts, one preference, and 250 provenance relationships were -actually stored. Fixture setup and graph verification are outside the measured scope. Those failures -are otherwise silent and would produce a confident, wrong number. +actually stored. Fixture setup and graph verification are outside the measured scope. +`PERF-W-08` separately bypasses model extraction for one harness-only marker, then exercises the real +resolution-to-persistence product path. It requires zero model/storage/recall work inside the measured +turn, exact 2/2/1/1 learned graph output, all supported source provenance, and zero cross-owner edges. +Its deterministic embedding request count includes both semantic entity-resolution probes and +learned-memory embeddings. +`PERF-W-09` exercises the typed unified extractor directly over the same 2/2/1/1 shape as +`PERF-W-07`, requires exactly one purpose-attributed model call with zero retries, and rejects any +storage, resolution, embedding, persistence, or recall work. These self-assertions catch failures +that would otherwise be silent and produce a confident, wrong number. ### Pull-request regression gate diff --git a/docs/performance/baseline-1.3.0.md b/docs/performance/baseline-1.3.0.md index 96bc1c84..4a1ab450 100644 --- a/docs/performance/baseline-1.3.0.md +++ b/docs/performance/baseline-1.3.0.md @@ -9,6 +9,8 @@ What one agent turn costs at shipped defaults, measured per phase. > Read [README.md](README.md) first if you have not. In particular: the counters below are portable and > reproducible; the timings are proportions from a local container, **not** deployment performance. +> This file remains the immutable 1.3.0 reference. Measured post-baseline changes are listed in +> [README.md](README.md#measured-improvements-after-the-130-baseline). --- @@ -66,7 +68,7 @@ entity recall map projection omitted the stored vectors: | Complete 43-item recall turn | 144,591 bytes | 113,871 bytes | **−21.2%** | The 30,720-byte difference is exactly 10 returned entities × 384 vector values × 8 estimated bytes. -Retrieved items, access tracking, queries, transactions, Recall@K, and MRR were unchanged. The +Retrieved items, access tracking, queries, transactions, deterministic-plumbing Recall@K, and MRR were unchanged. The projection was then reverted; it is the future rank-6 optimization, not part of this measurement change. ### Round trips by query @@ -263,7 +265,7 @@ small-graph 1.3.0 baseline, not a replacement deployment-performance baseline. | Materialized records | 43 | 43 | 0 | | Estimated payload bytes | 144,591 | 144,555 | −36 (−0.025%) | | Context characters | 3,906 | 3,886 | −20 (−0.512%) | -| Retrieval Recall@K / MRR | 1.000 / 1.000 | 1.000 / 1.000 | 0 / 0 | +| Deterministic-plumbing Recall@K / MRR | 1.000 / 1.000 | 1.000 / 1.000 | 0 / 0 | | Extraction quality | 1.000 | 1.000 | 0 | The small payload/context difference repeated exactly across independent Scale-M restores. Neo4j's @@ -274,17 +276,23 @@ figures establish that the tier is practical to run; they are not deployment lat ### Quality guard applied beside this cost baseline -The cost counters are only accepted when deterministic quality remains at this committed baseline: +The cost counters are only accepted when deterministic regression guards remain at this committed baseline: | Guard | Baseline | |---|---:| -| Retrieval Recall@K / MRR | 1.000 / 1.000 | +| Deterministic-plumbing Recall@K / MRR | 1.000 / 1.000 | | Retrieval cases with forbidden results | 0 of 19 | | Entity precision / recall | 1.000 / 1.000 | | Fact precision / recall | 1.000 / 1.000 | | Preference precision / recall | 1.000 / 1.000 | | Extraction false positives on learn-nothing turns | 0 of 6 (20 total cases) | +“Deterministic-plumbing” is a permanent scope label, not a footnote. The FNV-1a test embedder and +deliberately disjoint fixture vocabulary make expected neighbors construction-stable; these scores +prove retrieval wiring, ranking, scoping and forbidden-result enforcement. They do **not** claim +perfect semantic quality from a production embedding model. Sampled real-embedding/real-model quality +belongs to M-27 (LongMemEval). + Every value above was identical across five fresh-container runs: maximum observed variance **0.000**. The derived tolerance is therefore **zero**, recorded in `eng/perf/baselines/quality.json`. The combined reviewable counter + quality snapshot used by pull diff --git a/eng/perf/baselines/hermetic-S.json b/eng/perf/baselines/hermetic-S.json index 837c2b28..3b15f0c7 100644 --- a/eng/perf/baselines/hermetic-S.json +++ b/eng/perf/baselines/hermetic-S.json @@ -41,12 +41,12 @@ "items.relevant": 5, "items.retrieved": 43, "items.traces": 3, - "neo4j.bytes_est": 144591, + "neo4j.bytes_est": 145625, "neo4j.queries": 9, "neo4j.records": 43, "neo4j.tx.read": 6, "neo4j.tx.write": 1, - "recall.chars": 3823 + "recall.chars": 3828 } }, "PERF-R-07": { @@ -68,12 +68,12 @@ "items.relevant": 5, "items.retrieved": 43, "items.traces": 3, - "neo4j.bytes_est": 144591, + "neo4j.bytes_est": 145625, "neo4j.queries": 9, "neo4j.records": 43, "neo4j.tx.read": 6, "neo4j.tx.write": 1, - "recall.chars": 3823 + "recall.chars": 3828 } }, "PERF-R-08": { @@ -95,29 +95,29 @@ "items.relevant": 5, "items.retrieved": 43, "items.traces": 3, - "neo4j.bytes_est": 144591, + "neo4j.bytes_est": 145625, "neo4j.queries": 9, "neo4j.records": 43, "neo4j.tx.read": 6, "neo4j.tx.write": 1, - "recall.chars": 3934 + "recall.chars": 3939 } }, "PERF-W-02": { "counters": { "embed.chars": 201, "embed.items": 4, - "embed.requests": 4, + "embed.requests": 2, "extract.candidate_entities": 2, "extract.source_messages": 2, "llm.calls": 4, "llm.tokens_in": 947, "llm.tokens_out": 668, - "neo4j.bytes_est": 102960, - "neo4j.queries": 43, - "neo4j.records": 32, - "neo4j.tx.read": 4, - "neo4j.tx.write": 18, + "neo4j.bytes_est": 99132, + "neo4j.queries": 8, + "neo4j.records": 30, + "neo4j.tx.read": 2, + "neo4j.tx.write": 2, "persist.entities": 2, "persist.facts": 2, "persist.preferences": 1, @@ -129,17 +129,17 @@ "counters": { "embed.chars": 378, "embed.items": 9, - "embed.requests": 9, + "embed.requests": 7, "extract.candidate_entities": 2, "extract.source_messages": 7, "llm.calls": 4, "llm.tokens_in": 1170, "llm.tokens_out": 668, - "neo4j.bytes_est": 108964, - "neo4j.queries": 88, - "neo4j.records": 37, - "neo4j.tx.read": 4, - "neo4j.tx.write": 48, + "neo4j.bytes_est": 103856, + "neo4j.queries": 13, + "neo4j.records": 35, + "neo4j.tx.read": 2, + "neo4j.tx.write": 7, "persist.entities": 2, "persist.facts": 2, "persist.preferences": 1, @@ -151,22 +151,96 @@ "counters": { "embed.chars": 159, "embed.items": 7, - "embed.requests": 7, + "embed.requests": 3, "extract.candidate_entities": 2, "extract.source_messages": 50, "llm.calls": 4, "llm.tokens_in": 7774, "llm.tokens_out": 668, - "neo4j.bytes_est": 43218, - "neo4j.queries": 278, - "neo4j.records": 57, - "neo4j.tx.read": 5, - "neo4j.tx.write": 257, + "neo4j.bytes_est": 53274, + "neo4j.queries": 8, + "neo4j.records": 55, + "neo4j.tx.read": 3, + "neo4j.tx.write": 1, "persist.entities": 2, "persist.facts": 2, "persist.preferences": 1, "persist.relationships": 0 } + }, + "PERF-W-06": { + "counters": { + "embed.chars": 6250, + "embed.items": 50, + "embed.requests": 1, + "neo4j.bytes_est": 173950, + "neo4j.queries": 1, + "neo4j.records": 50, + "neo4j.tx.write": 1, + "store.messages": 50 + } + }, + "PERF-W-07": { + "counters": { + "extract.entities": 2, + "extract.facts": 2, + "extract.input_messages": 1, + "extract.preferences": 1, + "extract.relationships": 1, + "llm.calls": 4, + "llm.entity.calls": 1, + "llm.entity.retries": 0, + "llm.entity.tokens_in": 234, + "llm.entity.tokens_out": 35, + "llm.fact.calls": 1, + "llm.fact.retries": 0, + "llm.fact.tokens_in": 198, + "llm.fact.tokens_out": 49, + "llm.preference.calls": 1, + "llm.preference.retries": 0, + "llm.preference.tokens_in": 204, + "llm.preference.tokens_out": 28, + "llm.relationship.calls": 1, + "llm.relationship.retries": 0, + "llm.relationship.tokens_in": 203, + "llm.relationship.tokens_out": 28, + "llm.tokens_in": 839, + "llm.tokens_out": 140 + } + }, + "PERF-W-08": { + "counters": { + "embed.chars": 157, + "embed.items": 7, + "embed.requests": 3, + "extract.candidate_entities": 2, + "extract.source_messages": 1, + "neo4j.bytes_est": 17520, + "neo4j.queries": 8, + "neo4j.records": 6, + "neo4j.tx.read": 2, + "neo4j.tx.write": 1, + "persist.entities": 2, + "persist.facts": 2, + "persist.preferences": 1, + "persist.relationships": 1 + } + }, + "PERF-W-09": { + "counters": { + "extract.entities": 2, + "extract.facts": 2, + "extract.input_messages": 1, + "extract.preferences": 1, + "extract.relationships": 1, + "llm.calls": 1, + "llm.tokens_in": 188, + "llm.tokens_out": 165, + "llm.unified.calls": 1, + "llm.unified.retries": 0, + "llm.unified.tokens_in": 188, + "llm.unified.tokens_out": 165 + } } }, "quality": { @@ -179,6 +253,8 @@ "factRecall": 1, "preferencePrecision": 1, "preferenceRecall": 1, - "extractionFalsePositiveRate": 0 + "extractionFalsePositiveRate": 0, + "retrievalMeasurement": "deterministic-plumbing", + "semanticQualityClaim": false } } diff --git a/eng/perf/baselines/quality.json b/eng/perf/baselines/quality.json index 981477b6..659f3577 100644 --- a/eng/perf/baselines/quality.json +++ b/eng/perf/baselines/quality.json @@ -11,6 +11,8 @@ "toleranceDerivation": "Every guarded metric was identical across five fresh containers, so tolerance equals observed variance: zero." }, "retrieval": { + "measurement": "deterministic-plumbing", + "semanticQualityClaim": false, "recallAtK": 1.0, "mrr": 1.0, "cases": 19, diff --git a/samples/AgentMemory.Sample.BlendedAgent/Program.cs b/samples/AgentMemory.Sample.BlendedAgent/Program.cs index 192f6ed2..28cbd80a 100644 --- a/samples/AgentMemory.Sample.BlendedAgent/Program.cs +++ b/samples/AgentMemory.Sample.BlendedAgent/Program.cs @@ -55,19 +55,20 @@ }); // ── 2. Core memory services with GraphRAG enabled ───────────────────────────── -builder.Services.AddAgentMemoryCore(options => +// Pass the options instance, not a configure lambda. MemoryOptions is a record with init-only +// properties, so a lambda can neither assign them nor keep the result of a `with` expression — the +// `options = options with { ... }` form this sample used to show rebinds the parameter local and is +// thrown away on return. It compiled, it ran, and it left GraphRAG switched off. +builder.Services.AddAgentMemoryCore(new MemoryOptions { - options = options with + EnableGraphRag = true, + Recall = new RecallOptions { - EnableGraphRag = true, - Recall = new RecallOptions - { - BlendMode = RetrievalBlendMode.Blended, - MaxGraphRagItems = 5, - MaxEntities = 10, - MaxFacts = 10, - } - }; + BlendMode = RetrievalBlendMode.Blended, + MaxGraphRagItems = 5, + MaxEntities = 10, + MaxFacts = 10, + } }); builder.Services.AddSingleton(); diff --git a/samples/AgentMemory.Sample.BlendedAgent/README.md b/samples/AgentMemory.Sample.BlendedAgent/README.md index f329b539..878ac5a1 100644 --- a/samples/AgentMemory.Sample.BlendedAgent/README.md +++ b/samples/AgentMemory.Sample.BlendedAgent/README.md @@ -134,7 +134,10 @@ The blend mode is configured in `Program.cs` via `RecallOptions.BlendMode`. Swit services.AddNeo4jAgentMemory(options => { ... }); // 2. Core memory services (short-term, long-term, reasoning, context assembly) -services.AddAgentMemoryCore(options => { options = options with { EnableGraphRag = true, ... }; }); +// Pass the instance. MemoryOptions has init-only properties, so a configure lambda cannot set +// them: `options = options with { ... }` compiles, rebinds a local, and is discarded — leaving +// every default in place, GraphRAG included. +services.AddAgentMemoryCore(new MemoryOptions { EnableGraphRag = true, ... }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton>>(azureClient.GetEmbeddingClient(embeddingDeployment).AsIEmbeddingGenerator()); diff --git a/src/AgentMemory.Abstractions/Domain/Context/MemoryContext.cs b/src/AgentMemory.Abstractions/Domain/Context/MemoryContext.cs index 3faf9d0d..d628d151 100644 --- a/src/AgentMemory.Abstractions/Domain/Context/MemoryContext.cs +++ b/src/AgentMemory.Abstractions/Domain/Context/MemoryContext.cs @@ -53,6 +53,40 @@ public sealed record MemoryContext /// public string? GraphRagContext { get; init; } + + /// + /// The canonical relations this turn's question resolved to, empty when resolution was off or + /// matched nothing. + /// + /// + /// Distinguishes "predicate expansion had nothing to expand" from "expansion ran and did not + /// help", which need opposite responses — a missing vocabulary entry versus a retrieval or + /// reading problem. The resolution was previously computed inline and discarded, so no report + /// could tell the two apart: a question failing because its verb is absent from the table looked + /// identical to one failing for any other reason. Verified by hand on the n=50 losses, where + /// service/serviced turned out to be absent entirely and has is a + /// deliberate query stop form. + /// + public IReadOnlyList ResolvedQueryRelations { get; init; } = Array.Empty(); + + /// + /// The GraphRAG passages behind , with their scores and source ids. + /// + /// + /// Every other memory surface contributes typed items that evidence accounting can attribute; + /// GraphRAG contributed one opaque string, so it could not be scored, attributed, or shown to + /// have helped or harmed - which is the most plausible reason its recall budget was set to zero + /// and left there. + /// + /// The information was never missing. GraphRagContextItem already carried + /// SourceNodeIds, Score and Metadata; the assembler joined the text and + /// discarded the rest. This retains them. is unchanged, so nothing + /// the reader sees moves - the addition is instrumentation, not a retrieval change. + /// + /// + public IReadOnlyList GraphRagItems { get; init; } = + Array.Empty(); + /// /// The blend mode that produced this context. Determines which sources were retrieved /// (see ) and the order in which memory and GraphRAG-derived diff --git a/src/AgentMemory.Abstractions/Domain/Context/MemoryContextSection.cs b/src/AgentMemory.Abstractions/Domain/Context/MemoryContextSection.cs index 90b6191b..7c8007e4 100644 --- a/src/AgentMemory.Abstractions/Domain/Context/MemoryContextSection.cs +++ b/src/AgentMemory.Abstractions/Domain/Context/MemoryContextSection.cs @@ -11,6 +11,13 @@ public sealed record MemoryContextSection /// public IReadOnlyList Items { get; init; } = Array.Empty(); + /// + /// Ranked retrieval diagnostics for . Empty unless the recall explicitly requested + /// diagnostics and the selected provider can return scores without issuing another retrieval query. + /// + public IReadOnlyList RankedItems { get; init; } = + Array.Empty(); + /// /// Section-level metadata (e.g., retrieval method, scores). /// @@ -22,3 +29,16 @@ public sealed record MemoryContextSection /// public static MemoryContextSection Empty { get; } = new(); } + +/// +/// Identifies an item in a ranked retrieval result without duplicating the item payload. +/// +/// Stable identifier of the retrieved item. +/// Provider similarity score used for the retrieval ordering. +/// One-based rank returned by the provider before context budgeting. +/// One-based position among items that survived context budgeting. +public sealed record MemoryContextRankedItem( + string ItemId, + double Score, + int RetrievalRank, + int ContextRank); diff --git a/src/AgentMemory.Abstractions/Domain/Extraction/UnifiedExtractionResult.cs b/src/AgentMemory.Abstractions/Domain/Extraction/UnifiedExtractionResult.cs new file mode 100644 index 00000000..2b7454fe --- /dev/null +++ b/src/AgentMemory.Abstractions/Domain/Extraction/UnifiedExtractionResult.cs @@ -0,0 +1,14 @@ +namespace AgentMemory.Abstractions.Domain; + +/// Typed result of one model call that extracts every supported memory category. +public sealed record UnifiedExtractionResult +{ + /// Extracted entities. + public IReadOnlyList Entities { get; init; } = Array.Empty(); + /// Extracted facts. + public IReadOnlyList Facts { get; init; } = Array.Empty(); + /// Extracted preferences. + public IReadOnlyList Preferences { get; init; } = Array.Empty(); + /// Extracted entity relationships. + public IReadOnlyList Relationships { get; init; } = Array.Empty(); +} diff --git a/src/AgentMemory.Abstractions/Domain/LongTerm/Fact.cs b/src/AgentMemory.Abstractions/Domain/LongTerm/Fact.cs index 3dc2f7c3..7814e544 100644 --- a/src/AgentMemory.Abstractions/Domain/LongTerm/Fact.cs +++ b/src/AgentMemory.Abstractions/Domain/LongTerm/Fact.cs @@ -5,6 +5,19 @@ namespace AgentMemory.Abstractions.Domain; /// public sealed record Fact { + /// Metadata key recording how a recalled fact entered the context. + /// + /// Facts reaching the context by canonical-predicate expansion may legitimately carry provenance + /// outside the current query's window, because expansion returns a relation across the whole + /// owner rather than only what the query itself matched. Consumers that resolve provenance must + /// be able to tell that apart from a fact whose source genuinely cannot be resolved, which is + /// corruption. Marking the former keeps the latter detectable. + /// + public const string RetrievalSourceMetadataKey = "agentMemory.retrievalSource"; + + /// Value of for predicate-expanded facts. + public const string RetrievalSourcePredicateExpansion = "predicate-expansion"; + /// /// Unique identifier for the fact. /// diff --git a/src/AgentMemory.Abstractions/Options/ExtractionOptions.cs b/src/AgentMemory.Abstractions/Options/ExtractionOptions.cs index 286d9c9e..0529cbc3 100644 --- a/src/AgentMemory.Abstractions/Options/ExtractionOptions.cs +++ b/src/AgentMemory.Abstractions/Options/ExtractionOptions.cs @@ -29,6 +29,40 @@ public sealed class ExtractionOptions /// public IngestionFailureMode FailureMode { get; set; } = IngestionFailureMode.BestEffort; + /// + /// Uses atomic repository batch upserts when the configured repository explicitly advertises + /// support. Best-effort mode falls back to the existing item path if a batch fails, preserving + /// per-item outcomes. Fail-fast mode retains item upserts inside its outer atomic transaction so + /// the exception can still identify the exact failing item. Defaults to . + /// + public bool EnableBatchMemoryUpserts { get; set; } = true; + + /// + /// Generates missing entity, fact, and preference embeddings in one aligned provider batch + /// before persistence. Result-count mismatches and unavailable vectors replay safely through + /// the existing single-item path. Defaults to ; disable to preserve the + /// legacy one-request-per-learned-item behavior. + /// + public bool UseBatchEmbeddingRequests { get; set; } = true; + + /// + /// Reuses owner/type entity-resolution candidates within one multi-session extraction batch. + /// Candidate types are prefetched concurrently, while identity decisions remain chronological + /// and update the request-local snapshot after each resolution. Defaults to . + /// Disable to retain one repository candidate lookup per extracted entity. + /// + public bool UseBatchEntityResolutionSnapshots { get; set; } = true; + + /// + /// Coalesces the successful repository work for one logical best-effort persistence operation + /// into one atomic store transaction when the provider can prove rollback. External embedding + /// work completes before the transaction opens. If any item fails, the coalesced attempt rolls + /// back and replays through the legacy item-isolated path; providers without atomic rollback + /// retain that path directly. Defaults to ; disable to preserve one + /// transaction per repository operation. + /// + public bool UseCoalescedPersistenceTransactions { get; set; } = true; + /// /// The trust level stamped on every entity/fact/preference persisted, unless a specific /// ExtractionRequest.TrustLevel overrides it for that call (#92 Phase 3). Defaults to diff --git a/src/AgentMemory.Abstractions/Options/MemoryDecayOptions.cs b/src/AgentMemory.Abstractions/Options/MemoryDecayOptions.cs index b1dc7fc8..dd209d09 100644 --- a/src/AgentMemory.Abstractions/Options/MemoryDecayOptions.cs +++ b/src/AgentMemory.Abstractions/Options/MemoryDecayOptions.cs @@ -24,10 +24,23 @@ public sealed record MemoryDecayOptions // MaxMemoriesPerSession property here was read nowhere and could not be coherently enforced, so it was removed. /// - /// Boost factor applied per access (recall hit) when computing the retention score. + /// Boost factor applied to the logarithm of the access count when computing the retention + /// score: AccessBoostFactor × ln(1 + accessCount), capped by . /// + /// + /// The boost was applied linearly and undamped until BUG-R7, which let the access count alone + /// decide retention — one recall was enough to hold a memory above + /// permanently, however stale. It is now damped, capped, and subject to the same time decay as + /// confidence, so frequent access slows forgetting rather than preventing it. + /// public double AccessBoostFactor { get; init; } = 0.2; + /// + /// Ceiling on the access-boost contribution to the retention score, so a very frequently recalled + /// memory cannot outweigh every other signal in the blend. + /// + public double MaxAccessBoost { get; init; } = 0.5; + // NOTE: a documented-but-dead `EnableAutoPrune` option was removed (R6 cleanup). It promised // "automatically prune during extraction" but was read nowhere — auto-prune-on-extraction would wire // the decay service into the extraction pipeline, which belongs with the (currently held) decay/forget diff --git a/src/AgentMemory.Abstractions/Options/RecallOptions.cs b/src/AgentMemory.Abstractions/Options/RecallOptions.cs index cac82cdd..e1144304 100644 --- a/src/AgentMemory.Abstractions/Options/RecallOptions.cs +++ b/src/AgentMemory.Abstractions/Options/RecallOptions.cs @@ -46,6 +46,58 @@ public sealed record RecallOptions /// public RankingIntent Intent { get; init; } = RankingIntent.Default; + /// + /// Includes ranked retrieval diagnostics in returned memory-context sections when the selected + /// provider supports them. Disabled by default so ordinary recalls retain their current payload + /// and allocation profile. + /// + public bool IncludeDiagnostics { get; init; } + /// Default singleton instance. public static RecallOptions Default { get; } = new(); + + /// + /// G5 "hard" tier. After the similarity-ranked facts are chosen, also returns every fact sharing + /// their canonical predicates, so a relation arrives whole. Default off. + /// + /// + /// Top-K is a relevance cutoff and gives no completeness guarantee, so aggregation questions + /// ("how many...", "list all...") cannot be answered from it: missing one of five matching facts + /// silently yields four. Enable this when the question is an aggregation; it widens the context, + /// so it is not the default. + /// + public bool ExpandFactsByPredicate { get; init; } + + /// Cap on facts returned by predicate expansion. Unbounded completeness would exhaust the budget. + public int MaxExpandedFacts { get; init; } = 100; + + /// + /// Restricts recalled reasoning traces by outcome: true successful only, false + /// failed only, null (default) no filter. + /// + /// + /// The repository and its Cypher have always supported this, and automatic recall passed a + /// hardcoded null, so nothing could ever reach it — a built, plumbed, unreachable option. + /// + /// It matters because a recalled trace is presented to the reader as precedent with nothing + /// marking it as a failure, so imitating reasoning that did not work is worse than recalling + /// nothing. Upstream neo4j-labs/agent-memory treats this as correctness rather than tuning + /// and defaults its equivalent to successful-only. The default here stays at today's behaviour + /// because nothing becomes a default before it is measured, and the trace surface has never been + /// measured at all — it has carried a recall budget of zero in every quality run to date. + /// + /// + public bool? SuccessfulTracesOnly { get; init; } + + /// + /// Also expand on the relations the query text itself names, not only those the top-K surfaced. + /// + /// + /// Requires . Expansion makes one relation complete, but it can + /// only widen predicates similarity already nominated, so a question naming several relations + /// ("did I buy, assemble, sell, or fix...") reaches only whichever of them retrieval happened to + /// surface. This resolves the question's own verbs instead. Off by default: it widens the context, + /// and nothing is a default here until it has been measured. + /// + public bool ResolveQueryRelations { get; init; } } diff --git a/src/AgentMemory.Abstractions/Options/ShortTermMemoryOptions.cs b/src/AgentMemory.Abstractions/Options/ShortTermMemoryOptions.cs index 6c026af3..61f86ecb 100644 --- a/src/AgentMemory.Abstractions/Options/ShortTermMemoryOptions.cs +++ b/src/AgentMemory.Abstractions/Options/ShortTermMemoryOptions.cs @@ -8,6 +8,13 @@ public sealed record ShortTermMemoryOptions /// Whether to generate embeddings for messages automatically. public bool GenerateEmbeddings { get; init; } = true; + /// + /// Whether AddMessagesAsync generates missing message embeddings in one provider batch. + /// Enabled by default; disable only for provider compatibility or controlled A/B measurement. + /// Positional alignment and already-provided embeddings are preserved in either mode. + /// + public bool UseBatchEmbeddingRequests { get; init; } = true; + /// Default number of recent messages to retrieve. public int DefaultRecentMessageLimit { get; init; } = 10; diff --git a/src/AgentMemory.Abstractions/Repositories/IFactRepository.cs b/src/AgentMemory.Abstractions/Repositories/IFactRepository.cs index 26eeb7b7..d243398b 100644 --- a/src/AgentMemory.Abstractions/Repositories/IFactRepository.cs +++ b/src/AgentMemory.Abstractions/Repositories/IFactRepository.cs @@ -119,4 +119,23 @@ public interface IFactRepository MemoryScope? scope = null, DateTimeOffset? systemAsOf = null, CancellationToken cancellationToken = default); + + /// + /// Every fact under the given canonical predicates, bounded — a relation retrieved whole. + /// + /// + /// Top-K vector search is a relevance cutoff and gives no completeness guarantee, so it cannot + /// answer "how many": miss one of five births and the count is four. This composes with top-K + /// rather than replacing it — similarity finds which relation matters, this returns all of it. + /// + /// Defaults to empty so existing implementations remain source-compatible; a store that cannot + /// retrieve by relation simply contributes nothing rather than failing. + /// + /// + Task> SearchByCanonicalPredicatesAsync( + IReadOnlyList canonicalPredicates, + int limit, + MemoryScope scope, + CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); } diff --git a/src/AgentMemory.Abstractions/Services/ILongTermMemoryService.cs b/src/AgentMemory.Abstractions/Services/ILongTermMemoryService.cs index 4dc62253..d4a3e073 100644 --- a/src/AgentMemory.Abstractions/Services/ILongTermMemoryService.cs +++ b/src/AgentMemory.Abstractions/Services/ILongTermMemoryService.cs @@ -204,4 +204,50 @@ Task> SearchPreferencesAsOfAsync( /// Supersedes the loser preference with the winner (D7). See . Task SupersedePreferenceAsync(string loserPreferenceId, string winnerPreferenceId, MemoryScope? scope = null, CancellationToken cancellationToken = default); + + /// + /// Fact recall with optional canonical-predicate expansion — a relation returned whole. + /// + /// + /// A default interface method, not extra optional parameters on the method above: adding optional + /// parameters to a published interface breaks every implementor. The default ignores expansion, + /// so a store that cannot retrieve by relation behaves exactly as before. + /// + Task> SearchFactsAsync( + float[] queryEmbedding, + int limit, + double minScore, + MemoryScope? scope, + bool expandByPredicate, + int expansionLimit, + CancellationToken cancellationToken) => + SearchFactsAsync(queryEmbedding, limit, minScore, scope, cancellationToken); + + /// + /// Fact recall with expansion driven by the relations the question itself names. + /// + /// + /// Expansion alone can only widen predicates that similarity already surfaced in the top-K, so a + /// question naming several relations reaches only whichever of them retrieval happened to nominate. + /// supplies them from the question instead. An empty list + /// reproduces the previous overload exactly, which is what keeps this from ever being worse than + /// the existing behaviour. + /// + /// A further default interface method for the same reason as the one above: adding optional + /// parameters to a published interface breaks every implementor, and the interface is locked + /// under SemVer. + /// + /// + Task> SearchFactsAsync( + float[] queryEmbedding, + int limit, + double minScore, + MemoryScope? scope, + bool expandByPredicate, + int expansionLimit, + IReadOnlyList questionRelations, + CancellationToken cancellationToken) => + SearchFactsAsync( + queryEmbedding, limit, minScore, scope, expandByPredicate, expansionLimit, + cancellationToken); } diff --git a/src/AgentMemory.Abstractions/Services/IMemoryExtractionPipeline.cs b/src/AgentMemory.Abstractions/Services/IMemoryExtractionPipeline.cs index c3807dc7..089d587f 100644 --- a/src/AgentMemory.Abstractions/Services/IMemoryExtractionPipeline.cs +++ b/src/AgentMemory.Abstractions/Services/IMemoryExtractionPipeline.cs @@ -18,4 +18,40 @@ public interface IMemoryExtractionPipeline Task ExtractAsync( ExtractionRequest request, CancellationToken cancellationToken = default); + + /// + /// Extracts several independent source sessions with token-bounded unified model calls, then + /// resolves and persists each session chronologically. The returned results follow commit order. + /// When no batch extractor is enabled, requests fall back to ordinary one-session extraction. + /// + async Task> ExtractBatchAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxSessionsPerBatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxInputTokens); + + var ordered = requests + .Select((request, index) => new + { + Request = request, + Index = index, + Timestamp = request.Messages + .Select(message => message.TimestampUtc) + .DefaultIfEmpty(DateTimeOffset.MinValue) + .Min(), + }) + .OrderBy(item => item.Timestamp) + .ThenBy(item => item.Index) + .Select(item => item.Request) + .ToArray(); + + var results = new List(ordered.Length); + foreach (var request in ordered) + results.Add(await ExtractAsync(request, cancellationToken).ConfigureAwait(false)); + return results; + } } diff --git a/src/AgentMemory.Abstractions/Services/IMultiSessionUnifiedMemoryExtractor.cs b/src/AgentMemory.Abstractions/Services/IMultiSessionUnifiedMemoryExtractor.cs new file mode 100644 index 00000000..621b9826 --- /dev/null +++ b/src/AgentMemory.Abstractions/Services/IMultiSessionUnifiedMemoryExtractor.cs @@ -0,0 +1,56 @@ +using AgentMemory.Abstractions.Domain; + +namespace AgentMemory.Abstractions.Services; + +/// A deterministic, content-local batch boundary produced before provider work begins. +public sealed record MultiSessionExtractionBatchPlan( + IReadOnlyList SourceSessionIds, + int EstimatedInputTokens); + +/// The complete provider-call plan for a multi-session extraction workload. +public sealed record MultiSessionExtractionPlan( + IReadOnlyList Batches) +{ + /// Number of provider calls before validation retries or recursive splits. + public int BatchCount => Batches.Count; + + /// Number of unique source sessions acknowledged by the plan. + public int SourceSessionCount => Batches.Sum(batch => batch.SourceSessionIds.Count); + + /// Sum of the conservative per-batch input estimates. + public long TotalEstimatedInputTokens => Batches.Sum(batch => (long)batch.EstimatedInputTokens); +} + +/// +/// Optionally extracts typed memory for several source sessions in token-bounded model requests. +/// Every returned result is keyed to exactly one input session so provenance cannot bleed across +/// session or owner boundaries. +/// +public interface IMultiSessionUnifiedMemoryExtractor +{ + /// Whether the extractor is explicitly enabled. + bool IsEnabled { get; } + + /// + /// Produces the exact stable partition that execution will use before any provider call. + /// Implementations that cannot expose a deterministic plan may retain the default failure; + /// callers that require preflight must fail closed rather than estimate. + /// + MultiSessionExtractionPlan Plan( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens) => + throw new NotSupportedException( + $"{GetType().Name} does not expose a deterministic multi-session extraction plan."); + + /// + /// Extracts the supplied requests using contiguous batches no larger than + /// or . + /// Implementations must return exactly one result for every unique input session. + /// + Task> ExtractAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Abstractions/Services/IUnifiedMemoryExtractor.cs b/src/AgentMemory.Abstractions/Services/IUnifiedMemoryExtractor.cs new file mode 100644 index 00000000..fc3420f7 --- /dev/null +++ b/src/AgentMemory.Abstractions/Services/IUnifiedMemoryExtractor.cs @@ -0,0 +1,15 @@ +using AgentMemory.Abstractions.Domain; + +namespace AgentMemory.Abstractions.Services; + +/// Optionally extracts all memory categories in one provider call. +public interface IUnifiedMemoryExtractor +{ + /// Whether this extractor should replace the category-specific fan-out. + bool IsEnabled { get; } + + /// Extracts every supported category from the supplied messages. + Task ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs b/src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs index bcf81b89..2ea5e4cb 100644 --- a/src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs +++ b/src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs @@ -228,6 +228,13 @@ private RecallOptions ResolveEffectiveOptions(AutomaticRecallDecision decision) MaxEntities = decision.Categories.HasFlag(AutomaticRecallCategories.Entities) ? _recallOptions.MaxEntities : 0, MaxFacts = decision.Categories.HasFlag(AutomaticRecallCategories.Facts) ? _recallOptions.MaxFacts : 0, MaxPreferences = decision.Categories.HasFlag(AutomaticRecallCategories.Preferences) ? _recallOptions.MaxPreferences : 0, + // J4.1: the aggregation route. Top-K cannot answer "how many", so a routed decision + // turns on relation completeness for that turn only - it roughly triples the retrieved + // context, which is why it is routed rather than defaulted on. + ExpandFactsByPredicate = + _recallOptions.ExpandFactsByPredicate || decision.RequiresRelationCompleteness, + ResolveQueryRelations = + _recallOptions.ResolveQueryRelations || decision.RequiresRelationCompleteness, MaxTraces = decision.Categories.HasFlag(AutomaticRecallCategories.ReasoningTraces) ? _recallOptions.MaxTraces : 0, MaxGraphRagItems = decision.Categories.HasFlag(AutomaticRecallCategories.GraphRag) ? _recallOptions.MaxGraphRagItems : 0, Intent = decision.Intent ?? _recallOptions.Intent diff --git a/src/AgentMemory.AgentFramework/Recall/AutomaticRecallDecision.cs b/src/AgentMemory.AgentFramework/Recall/AutomaticRecallDecision.cs index c046aad8..29d3817f 100644 --- a/src/AgentMemory.AgentFramework/Recall/AutomaticRecallDecision.cs +++ b/src/AgentMemory.AgentFramework/Recall/AutomaticRecallDecision.cs @@ -25,6 +25,23 @@ public sealed record AutomaticRecallDecision /// public RankingIntent? Intent { get; init; } + /// + /// The question needs a relation returned whole, not merely its most similar members. + /// + /// + /// Top-K is a relevance cutoff and gives no completeness guarantee, so an aggregation question is + /// unanswerable from it: miss one of five matching facts and the count is four, with nothing + /// signalling the loss. Setting this turns on predicate expansion and query-relation resolution + /// for the turn. + /// + /// It is a routed decision rather than a global default because it is expensive — expansion + /// roughly triples the retrieved context — and because it only helps the questions that need + /// completeness. Measured: 73.3% to 90.0% on the questions it applies to, at a cost every other + /// turn would pay for nothing. + /// + /// + public bool RequiresRelationCompleteness { get; init; } + /// /// An explicit, complete override of the effective for /// this turn. When set, and above are ignored -- this diff --git a/src/AgentMemory.AgentFramework/Recall/HeuristicAutomaticRecallPolicy.cs b/src/AgentMemory.AgentFramework/Recall/HeuristicAutomaticRecallPolicy.cs index 08de5680..e37397f7 100644 --- a/src/AgentMemory.AgentFramework/Recall/HeuristicAutomaticRecallPolicy.cs +++ b/src/AgentMemory.AgentFramework/Recall/HeuristicAutomaticRecallPolicy.cs @@ -48,6 +48,19 @@ public sealed class HeuristicAutomaticRecallPolicy : IAutomaticRecallPolicy @"\b(debug|troubleshoot|workflow|steps to|how do i|walk me through|implement|error|bug|incident|root cause)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// + /// Questions that need a relation returned whole rather than its top-K most similar members. + /// + /// + /// Both of the failures this track spent the most effort on open with "how many", which is what + /// makes a lexical route sufficient here instead of a model call. Compiled and anchored on word + /// boundaries; no repeating group, so it cannot backtrack catastrophically the way an earlier + /// greeting pattern in this file did. + /// + private static readonly Regex AggregationOriented = new( + @"\b(how many|how much|count|total|list all|all the|every|number of)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + /// public ValueTask DecideAsync( AutomaticRecallContext context, CancellationToken cancellationToken = default) @@ -77,7 +90,10 @@ public ValueTask DecideAsync( { ShouldRecall = true, Categories = categories, - Intent = intent + Intent = intent, + // G5's aggregation route. Deterministic and free, so it can run on every turn, and it + // catches the shape top-K structurally cannot answer. + RequiresRelationCompleteness = AggregationOriented.IsMatch(query) }); } diff --git a/src/AgentMemory.Core/AgentMemory.Core.csproj b/src/AgentMemory.Core/AgentMemory.Core.csproj index d2015d98..0126e1a3 100644 --- a/src/AgentMemory.Core/AgentMemory.Core.csproj +++ b/src/AgentMemory.Core/AgentMemory.Core.csproj @@ -1,5 +1,12 @@ + + + + + diff --git a/src/AgentMemory.Core/Extraction/ExtractionStage.cs b/src/AgentMemory.Core/Extraction/ExtractionStage.cs index 8936c229..a2f5fb5a 100644 --- a/src/AgentMemory.Core/Extraction/ExtractionStage.cs +++ b/src/AgentMemory.Core/Extraction/ExtractionStage.cs @@ -6,6 +6,7 @@ using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Services; using AgentMemory.Core.Extraction.MergeStrategies; +using AgentMemory.Core.Resolution; using AgentMemory.Core.Validation; namespace AgentMemory.Core.Extraction; @@ -21,6 +22,7 @@ internal sealed class ExtractionStage : IExtractionStage private readonly IReadOnlyList _factExtractors; private readonly IReadOnlyList _preferenceExtractors; private readonly IReadOnlyList _relationshipExtractors; + private readonly IReadOnlyList _unifiedExtractors; private readonly IEntityResolver _entityResolver; private readonly ExtractionOptions _options; private readonly ILogger _logger; @@ -30,6 +32,7 @@ public ExtractionStage( IEnumerable factExtractors, IEnumerable preferenceExtractors, IEnumerable relationshipExtractors, + IEnumerable unifiedExtractors, IEntityResolver entityResolver, IOptions extractionOptions, ILogger logger) @@ -38,16 +41,42 @@ public ExtractionStage( _factExtractors = factExtractors.ToList().AsReadOnly(); _preferenceExtractors = preferenceExtractors.ToList().AsReadOnly(); _relationshipExtractors = relationshipExtractors.ToList().AsReadOnly(); + _unifiedExtractors = unifiedExtractors.ToList().AsReadOnly(); _entityResolver = entityResolver; _options = extractionOptions.Value; _logger = logger; } - public async Task ExtractAsync( + public IDisposable? BeginResolutionBatch() => + (_entityResolver as IExtractionEntityResolver)?.BeginBatch(); + + public void InvalidateResolutionBatch() => + (_entityResolver as IExtractionEntityResolver)?.InvalidateBatch(); + + public Task ExtractAsync( + IReadOnlyList messages, + ExtractionTypes typesToExtract, + MemoryScope? scope = null, + CancellationToken cancellationToken = default) => + ExtractCoreAsync(messages, typesToExtract, scope, preExtracted: null, cancellationToken); + + public Task ProcessUnifiedAsync( IReadOnlyList messages, + UnifiedExtractionResult extracted, ExtractionTypes typesToExtract, MemoryScope? scope = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(extracted); + return ExtractCoreAsync(messages, typesToExtract, scope, extracted, cancellationToken); + } + + private async Task ExtractCoreAsync( + IReadOnlyList messages, + ExtractionTypes typesToExtract, + MemoryScope? scope, + UnifiedExtractionResult? preExtracted, + CancellationToken cancellationToken) { var sourceMessageIds = messages.Select(m => m.MessageId).ToList(); var strategy = _options.MergeStrategy; @@ -58,30 +87,55 @@ public async Task ExtractAsync( _entityExtractors.Count, _factExtractors.Count, _preferenceExtractors.Count, _relationshipExtractors.Count, strategy); - // 1. Run all enabled extractor types in parallel. - var entityRun = typesToExtract.HasFlag(ExtractionTypes.Entities) - ? RunExtractorsAsync(_entityExtractors, e => e.ExtractAsync(messages, cancellationToken), - strategy, MergeStrategyFactory.CreateEntityStrategy, "entity", - MemoryItemKind.Entity, MemoryErrorCodes.EntityExtractionFailed, cancellationToken) - : EmptyRun(); - - var factRun = typesToExtract.HasFlag(ExtractionTypes.Facts) - ? RunExtractorsAsync(_factExtractors, f => f.ExtractAsync(messages, cancellationToken), - strategy, MergeStrategyFactory.CreateFactStrategy, "fact", - MemoryItemKind.Fact, MemoryErrorCodes.FactExtractionFailed, cancellationToken) - : EmptyRun(); - - var prefRun = typesToExtract.HasFlag(ExtractionTypes.Preferences) - ? RunExtractorsAsync(_preferenceExtractors, p => p.ExtractAsync(messages, cancellationToken), - strategy, MergeStrategyFactory.CreatePreferenceStrategy, "preference", - MemoryItemKind.Preference, MemoryErrorCodes.PreferenceExtractionFailed, cancellationToken) - : EmptyRun(); - - var relRun = typesToExtract.HasFlag(ExtractionTypes.Relationships) - ? RunExtractorsAsync(_relationshipExtractors, r => r.ExtractAsync(messages, cancellationToken), - strategy, MergeStrategyFactory.CreateRelationshipStrategy, "relationship", - MemoryItemKind.Relationship, MemoryErrorCodes.RelationshipExtractionFailed, cancellationToken) - : EmptyRun(); + Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> entityRun; + Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> factRun; + Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> prefRun; + Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> relRun; + IReadOnlyList unifiedOutcomes = Array.Empty(); + var unifiedExtractor = typesToExtract != ExtractionTypes.None + ? _unifiedExtractors.FirstOrDefault(extractor => extractor.IsEnabled) + : null; + if (preExtracted is not null) + { + entityRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Entities) ? preExtracted.Entities : []); + factRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Facts) ? preExtracted.Facts : []); + prefRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Preferences) ? preExtracted.Preferences : []); + relRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Relationships) ? preExtracted.Relationships : []); + } + else if (unifiedExtractor is not null) + { + var unifiedRun = await ExtractUnifiedSafeAsync( + unifiedExtractor, messages, typesToExtract, cancellationToken).ConfigureAwait(false); + var unified = unifiedRun.Result; + unifiedOutcomes = unifiedRun.Outcomes; + entityRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Entities) ? unified.Entities : []); + factRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Facts) ? unified.Facts : []); + prefRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Preferences) ? unified.Preferences : []); + relRun = CompletedRun(typesToExtract.HasFlag(ExtractionTypes.Relationships) ? unified.Relationships : []); + } + else + { + entityRun = typesToExtract.HasFlag(ExtractionTypes.Entities) + ? RunExtractorsAsync(_entityExtractors, e => e.ExtractAsync(messages, cancellationToken), + strategy, MergeStrategyFactory.CreateEntityStrategy, "entity", + MemoryItemKind.Entity, MemoryErrorCodes.EntityExtractionFailed, cancellationToken) + : EmptyRun(); + factRun = typesToExtract.HasFlag(ExtractionTypes.Facts) + ? RunExtractorsAsync(_factExtractors, f => f.ExtractAsync(messages, cancellationToken), + strategy, MergeStrategyFactory.CreateFactStrategy, "fact", + MemoryItemKind.Fact, MemoryErrorCodes.FactExtractionFailed, cancellationToken) + : EmptyRun(); + prefRun = typesToExtract.HasFlag(ExtractionTypes.Preferences) + ? RunExtractorsAsync(_preferenceExtractors, p => p.ExtractAsync(messages, cancellationToken), + strategy, MergeStrategyFactory.CreatePreferenceStrategy, "preference", + MemoryItemKind.Preference, MemoryErrorCodes.PreferenceExtractionFailed, cancellationToken) + : EmptyRun(); + relRun = typesToExtract.HasFlag(ExtractionTypes.Relationships) + ? RunExtractorsAsync(_relationshipExtractors, r => r.ExtractAsync(messages, cancellationToken), + strategy, MergeStrategyFactory.CreateRelationshipStrategy, "relationship", + MemoryItemKind.Relationship, MemoryErrorCodes.RelationshipExtractionFailed, cancellationToken) + : EmptyRun(); + } await Task.WhenAll(entityRun, factRun, prefRun, relRun).ConfigureAwait(false); @@ -91,6 +145,7 @@ public async Task ExtractAsync( var (rawRelationships, relOutcomes) = await relRun.ConfigureAwait(false); var outcomes = new List(); + outcomes.AddRange(unifiedOutcomes); outcomes.AddRange(entityOutcomes); outcomes.AddRange(factOutcomes); outcomes.AddRange(prefOutcomes); @@ -106,6 +161,18 @@ public async Task ExtractAsync( "Ingestion failed fast: one or more extractors threw.", outcomes); } + if (_entityResolver is IExtractionEntityResolver batchResolver) + { + var candidateTypes = rawEntities + .Where(entity => + entity.Confidence >= _options.MinConfidenceThreshold && + EntityValidator.IsValid(entity, _options.Validation)) + .Select(entity => entity.Type) + .ToArray(); + await batchResolver.PrepareCandidatesAsync(candidateTypes, scope, cancellationToken) + .ConfigureAwait(false); + } + // 2. Filter + validate + resolve entities; build name→Entity map for relationship resolution. // Spanned separately from extraction: resolution is a SEQUENTIAL per-entity loop, so unlike the // concurrent extractor categories above its cost grows linearly with entity count. @@ -144,8 +211,16 @@ public async Task ExtractAsync( try { - var entity = await _entityResolver.ResolveEntityAsync( - extracted, sourceMessageIds, scope, cancellationToken).ConfigureAwait(false); + // The extraction pipeline always hands this resolved entity to PersistenceStage. When + // that stage owns a coalesced transaction, an eager resolver upsert would write the + // same entity twice and sit outside the logical commit boundary. Direct resolver callers + // retain their historical persist-on-resolve behavior through ResolveEntityAsync. + var deferPersistence = failFast || _options.UseCoalescedPersistenceTransactions; + var entity = deferPersistence && _entityResolver is IExtractionEntityResolver deferredResolver + ? await deferredResolver.ResolveForPersistenceAsync( + extracted, sourceMessageIds, scope, cancellationToken).ConfigureAwait(false) + : await _entityResolver.ResolveEntityAsync( + extracted, sourceMessageIds, scope, cancellationToken).ConfigureAwait(false); resolvedEntityMap[extracted.Name] = entity; _logger.LogDebug("Resolved entity '{Name}' (id={Id}).", entity.Name, entity.EntityId); } @@ -278,6 +353,65 @@ public async Task ExtractAsync( Task.FromResult<(IReadOnlyList, IReadOnlyList)>( (Array.Empty(), Array.Empty())); + private static Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> CompletedRun( + IReadOnlyList items) where T : class => + Task.FromResult((items, (IReadOnlyList)Array.Empty())); + + private async Task<(UnifiedExtractionResult Result, IReadOnlyList Outcomes)> ExtractUnifiedSafeAsync( + IUnifiedMemoryExtractor extractor, + IReadOnlyList messages, + ExtractionTypes typesToExtract, + CancellationToken cancellationToken) + { + try + { + return (await extractor.ExtractAsync(messages, cancellationToken).ConfigureAwait(false), + Array.Empty()); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unified memory extraction threw — continuing with empty results."); + var outcomes = new List(4); + AddUnifiedFailure(outcomes, typesToExtract, ExtractionTypes.Entities, + MemoryItemKind.Entity, "entity", MemoryErrorCodes.EntityExtractionFailed, ex); + AddUnifiedFailure(outcomes, typesToExtract, ExtractionTypes.Facts, + MemoryItemKind.Fact, "fact", MemoryErrorCodes.FactExtractionFailed, ex); + AddUnifiedFailure(outcomes, typesToExtract, ExtractionTypes.Preferences, + MemoryItemKind.Preference, "preference", MemoryErrorCodes.PreferenceExtractionFailed, ex); + AddUnifiedFailure(outcomes, typesToExtract, ExtractionTypes.Relationships, + MemoryItemKind.Relationship, "relationship", MemoryErrorCodes.RelationshipExtractionFailed, ex); + return (new UnifiedExtractionResult(), outcomes); + } + } + + private static void AddUnifiedFailure( + ICollection outcomes, + ExtractionTypes typesToExtract, + ExtractionTypes requiredType, + MemoryItemKind kind, + string sourceKey, + string errorCode, + Exception exception) + { + if (!typesToExtract.HasFlag(requiredType)) + return; + + outcomes.Add(new IngestionItemOutcome + { + Kind = kind, + Stage = IngestionStage.Extraction, + Status = IngestionItemStatus.Failed, + SourceKey = $"unified:{sourceKey}", + ErrorCode = errorCode, + ErrorMessage = exception.Message, + Retryable = true, + }); + } + private async Task<(IReadOnlyList Items, IReadOnlyList Outcomes)> RunExtractorsAsync( IReadOnlyList extractors, Func>> extractFn, diff --git a/src/AgentMemory.Core/Extraction/IBatchMemoryRepository.cs b/src/AgentMemory.Core/Extraction/IBatchMemoryRepository.cs new file mode 100644 index 00000000..54d374ea --- /dev/null +++ b/src/AgentMemory.Core/Extraction/IBatchMemoryRepository.cs @@ -0,0 +1,13 @@ +namespace AgentMemory.Core.Extraction; + +/// +/// Internal opt-in capability for repositories that can atomically upsert one memory kind as a batch. +/// The public repository contracts remain unchanged, so third-party implementations keep their current +/// item-at-a-time behavior unless the built-in pipeline can prove batch semantics are available. +/// +internal interface IBatchMemoryRepository +{ + Task> UpsertBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Core/Extraction/IExtractionStage.cs b/src/AgentMemory.Core/Extraction/IExtractionStage.cs index a1ca268c..171c153f 100644 --- a/src/AgentMemory.Core/Extraction/IExtractionStage.cs +++ b/src/AgentMemory.Core/Extraction/IExtractionStage.cs @@ -9,6 +9,9 @@ namespace AgentMemory.Core.Extraction; /// internal interface IExtractionStage { + IDisposable? BeginResolutionBatch(); + void InvalidateResolutionBatch(); + /// /// Extracts, merges, filters, validates, and resolves items from the given messages. When /// is supplied (R1) entity resolution is confined to the owner's own and @@ -19,4 +22,15 @@ Task ExtractAsync( ExtractionTypes typesToExtract, MemoryScope? scope = null, CancellationToken cancellationToken = default); + + /// + /// Applies the normal validation, owner-scoped resolution, and filtering stages to a unified + /// result that was already extracted by a validated multi-session batch. + /// + Task ProcessUnifiedAsync( + IReadOnlyList messages, + UnifiedExtractionResult extracted, + ExtractionTypes typesToExtract, + MemoryScope? scope = null, + CancellationToken cancellationToken = default); } diff --git a/src/AgentMemory.Core/Extraction/IFusedBatchMemoryRepository.cs b/src/AgentMemory.Core/Extraction/IFusedBatchMemoryRepository.cs new file mode 100644 index 00000000..bec26dfd --- /dev/null +++ b/src/AgentMemory.Core/Extraction/IFusedBatchMemoryRepository.cs @@ -0,0 +1,13 @@ +namespace AgentMemory.Core.Extraction; + +/// +/// Internal opt-in capability for repositories that can fold an item's node mutation, embedding, +/// provider-specific labels/location, and provenance edges into one bounded batch query. The public +/// repository contracts and the legacy path remain unchanged. +/// +internal interface IFusedBatchMemoryRepository +{ + Task> UpsertFusedBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Core/Extraction/IMemoryPersistenceTransaction.cs b/src/AgentMemory.Core/Extraction/IMemoryPersistenceTransaction.cs new file mode 100644 index 00000000..bbaeb340 --- /dev/null +++ b/src/AgentMemory.Core/Extraction/IMemoryPersistenceTransaction.cs @@ -0,0 +1,19 @@ +namespace AgentMemory.Core.Extraction; + +/// +/// Internal transaction boundary for one logical memory-persistence operation. +/// Storage providers that support transactions commit all repository work atomically; +/// providers without that capability execute the callback directly. +/// +internal interface IMemoryPersistenceTransaction +{ + /// + /// Whether a failed callback is guaranteed not to commit and rollback failure is surfaced as a + /// different exception. The default keeps portable/pass-through providers on the legacy path. + /// + bool SupportsAtomicRollback => false; + + Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Core/Extraction/IUpsertPersistsProvenance.cs b/src/AgentMemory.Core/Extraction/IUpsertPersistsProvenance.cs new file mode 100644 index 00000000..1fd90aaf --- /dev/null +++ b/src/AgentMemory.Core/Extraction/IUpsertPersistsProvenance.cs @@ -0,0 +1,7 @@ +namespace AgentMemory.Core.Extraction; + +/// +/// Internal capability marker for repositories whose upsert operation atomically persists every +/// EXTRACTED_FROM edge named by the memory item's source-message IDs. +/// +internal interface IUpsertPersistsProvenance; diff --git a/src/AgentMemory.Core/Extraction/PersistenceStage.EmbeddingBatch.cs b/src/AgentMemory.Core/Extraction/PersistenceStage.EmbeddingBatch.cs new file mode 100644 index 00000000..f9abb936 --- /dev/null +++ b/src/AgentMemory.Core/Extraction/PersistenceStage.EmbeddingBatch.cs @@ -0,0 +1,213 @@ +using AgentMemory.Abstractions.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.Logging; + +namespace AgentMemory.Core.Extraction; + +internal sealed partial class PersistenceStage +{ + private async Task PrepareEmbeddingsAsync( + ExtractionStageResult extraction, + CancellationToken cancellationToken) + { + if (!_options.UseBatchEmbeddingRequests) + return await PrepareEmbeddingsIndividuallyAsync(extraction, cancellationToken).ConfigureAwait(false); + + var inputs = BuildLearnedEmbeddingInputs(extraction); + if (inputs.Count < 2) + return await PrepareEmbeddingsIndividuallyAsync(extraction, cancellationToken).ConfigureAwait(false); + + var failFast = _options.FailureMode == Abstractions.Options.IngestionFailureMode.FailFast; + var outcomes = new List(); + var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); + var facts = new List(extraction.FilteredFacts.Count); + var preferences = new List(extraction.FilteredPreferences.Count); + + foreach (var (name, entity) in extraction.ResolvedEntityMap) + { + if (entity.Embedding is not null) + entities[name] = entity; + } + + IReadOnlyList? batchResults = null; + var replayWholeBatch = false; + try + { + batchResults = await _embeddingOrchestrator + .EmbedBatchAsync(inputs.Select(input => input.Text).ToArray(), cancellationToken) + .ConfigureAwait(false); + + if (batchResults is null || batchResults.Count != inputs.Count) + { + replayWholeBatch = true; + _logger.LogWarning( + "Learned-memory embedding batch returned {Returned} vectors for {Requested} inputs; replaying the batch through the item path.", + batchResults?.Count ?? 0, + inputs.Count); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + replayWholeBatch = true; + _logger.LogWarning( + ex, + "Learned-memory embedding batch failed for {Count} inputs; replaying through the item path.", + inputs.Count); + } + + for (var index = 0; index < inputs.Count; index++) + { + var input = inputs[index]; + float[]? embedding = !replayWholeBatch && + batchResults![index] is { Length: > 0 } available + ? available + : await EmbedSingleLearnedInputAsync( + input, + outcomes, + failFast, + cancellationToken).ConfigureAwait(false); + + if (embedding is null) + continue; + + switch (input.Kind) + { + case MemoryItemKind.Entity: + entities[input.SourceKey] = input.Entity! with { Embedding = embedding }; + break; + case MemoryItemKind.Fact: + facts.Add(new PreparedFact(input.Fact!, embedding)); + break; + case MemoryItemKind.Preference: + preferences.Add(new PreparedPreference(input.Preference!, embedding)); + break; + default: + throw new InvalidOperationException( + $"Unsupported learned-memory embedding kind '{input.Kind}'."); + } + } + + return new PreparedEmbeddings(entities, facts, preferences, outcomes); + } + + private static List BuildLearnedEmbeddingInputs( + ExtractionStageResult extraction) + { + var inputs = new List( + extraction.ResolvedEntityMap.Count + + extraction.FilteredFacts.Count + + extraction.FilteredPreferences.Count); + + foreach (var (name, entity) in extraction.ResolvedEntityMap) + { + if (entity.Embedding is null) + { + inputs.Add(new LearnedEmbeddingInput( + MemoryItemKind.Entity, + name, + entity.Name, + Entity: entity)); + } + } + + foreach (var fact in extraction.FilteredFacts) + { + var text = $"{fact.Subject} {fact.Predicate} {fact.Object}"; + inputs.Add(new LearnedEmbeddingInput( + MemoryItemKind.Fact, + text, + text, + Fact: fact)); + } + + foreach (var preference in extraction.FilteredPreferences) + { + inputs.Add(new LearnedEmbeddingInput( + MemoryItemKind.Preference, + preference.PreferenceText, + preference.PreferenceText, + Preference: preference)); + } + + return inputs; + } + + private async Task EmbedSingleLearnedInputAsync( + LearnedEmbeddingInput input, + List outcomes, + bool failFast, + CancellationToken cancellationToken) + { + try + { + return input.Kind switch + { + MemoryItemKind.Entity => await _embeddingOrchestrator + .EmbedEntityAsync(input.Entity!.Name, cancellationToken) + .ConfigureAwait(false), + MemoryItemKind.Fact => await _embeddingOrchestrator + .EmbedFactAsync( + input.Fact!.Subject, + input.Fact.Predicate, + input.Fact.Object, + cancellationToken) + .ConfigureAwait(false), + MemoryItemKind.Preference => await _embeddingOrchestrator + .EmbedPreferenceAsync(input.Preference!.PreferenceText, cancellationToken) + .ConfigureAwait(false), + _ => throw new InvalidOperationException( + $"Unsupported learned-memory embedding kind '{input.Kind}'.") + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error generating learned-memory embedding for {Kind} '{SourceKey}'.", + input.Kind, + input.SourceKey); + RecordFailureAndMaybeThrow( + outcomes, + failFast, + input.Kind, + IngestionStage.Embedding, + MemoryErrorCodes.EmbeddingGenerationFailed, + input.SourceKey, + null, + ex, + FailFastEmbeddingMessage(input)); + return null; + } + } + + private static string FailFastEmbeddingMessage(LearnedEmbeddingInput input) => + input.Kind switch + { + MemoryItemKind.Entity => + $"Ingestion failed fast: embedding generation failed for entity '{input.SourceKey}'.", + MemoryItemKind.Fact => + $"Ingestion failed fast: embedding generation failed for fact '{input.SourceKey}'.", + MemoryItemKind.Preference => + "Ingestion failed fast: embedding generation failed for a preference.", + _ => + $"Ingestion failed fast: embedding generation failed for '{input.SourceKey}'." + }; + + private sealed record LearnedEmbeddingInput( + MemoryItemKind Kind, + string SourceKey, + string Text, + Entity? Entity = null, + ExtractedFact? Fact = null, + ExtractedPreference? Preference = null); +} diff --git a/src/AgentMemory.Core/Extraction/PersistenceStage.cs b/src/AgentMemory.Core/Extraction/PersistenceStage.cs index 548381ed..b085e800 100644 --- a/src/AgentMemory.Core/Extraction/PersistenceStage.cs +++ b/src/AgentMemory.Core/Extraction/PersistenceStage.cs @@ -13,7 +13,7 @@ namespace AgentMemory.Core.Extraction; /// Embeds and persists the resolved items from . /// Responsibility: generate embeddings, upsert to repositories, wire EXTRACTED_FROM provenance. /// -internal sealed class PersistenceStage : IPersistenceStage +internal sealed partial class PersistenceStage : IPersistenceStage { private readonly IEmbeddingOrchestrator _embeddingOrchestrator; private readonly IEntityRepository _entityRepository; @@ -23,6 +23,7 @@ internal sealed class PersistenceStage : IPersistenceStage private readonly IClock _clock; private readonly IIdGenerator _idGenerator; private readonly ExtractionOptions _options; + private readonly IMemoryPersistenceTransaction _persistenceTransaction; private readonly ILogger _logger; public PersistenceStage( @@ -34,6 +35,7 @@ public PersistenceStage( IClock clock, IIdGenerator idGenerator, ILogger logger, + IMemoryPersistenceTransaction persistenceTransaction, IOptions? extractionOptions = null) { _embeddingOrchestrator = embeddingOrchestrator; @@ -44,6 +46,7 @@ public PersistenceStage( _clock = clock; _idGenerator = idGenerator; _logger = logger; + _persistenceTransaction = persistenceTransaction ?? throw new ArgumentNullException(nameof(persistenceTransaction)); _options = extractionOptions?.Value ?? new ExtractionOptions(); } @@ -53,11 +56,9 @@ public async Task PersistAsync( MemoryTrustLevel trustLevel = MemoryTrustLevel.Untrusted, CancellationToken cancellationToken = default) { - // Spans the whole persistence stage. The four per-kind blocks below are sequential loops that - // embed and upsert one item at a time, so the stage's cost grows with how much the turn produced - // -- the candidate counts are tagged here so that growth is attributable without needing four - // more spans. (Per-kind TIMING would mean restructuring those loops; the counts plus the stage - // total answer "is persistence expensive, and because of how many of what" already.) + // External embedding work is deliberately completed before the storage transaction opens. + // Holding a database transaction while waiting on a model/provider would amplify contention + // and make provider latency part of the database failure surface. using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.persist.total"); if (activity is not null) { @@ -67,70 +68,126 @@ public async Task PersistAsync( activity.SetTag("memory.persist.relationships", extraction.FilteredRelationships.Count); } + var prepared = await PrepareEmbeddingsAsync(extraction, cancellationToken).ConfigureAwait(false); + if (_options.FailureMode == IngestionFailureMode.FailFast) + { + try + { + return await _persistenceTransaction.ExecuteAsync( + ct => PersistPreparedAsync(extraction, ownerId, trustLevel, prepared, ct), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (MemoryIngestionException) + { + throw; + } + catch (Exception ex) + { + // Transaction-entry, commit, and rollback-confirmation failures occur outside the + // per-item catch blocks below. Preserve the documented fail-fast boundary while + // retaining the provider/transaction failure as the inner cause. Outcomes created + // inside the rolled-back transaction are deliberately excluded as non-durable. + var completedOutcomes = extraction.Outcomes.Concat(prepared.Outcomes).ToList(); + throw new MemoryIngestionException( + "Atomic memory persistence failed.", completedOutcomes, ex); + } + } + + if (!_options.UseCoalescedPersistenceTransactions || + !_persistenceTransaction.SupportsAtomicRollback) + { + return await PersistPreparedAsync( + extraction, ownerId, trustLevel, prepared, cancellationToken).ConfigureAwait(false); + } + + try + { + return await _persistenceTransaction.ExecuteAsync( + async ct => + { + var result = await PersistPreparedAsync( + extraction, ownerId, trustLevel, prepared, ct).ConfigureAwait(false); + if (result.Outcomes.Any(outcome => outcome.Status == IngestionItemStatus.Failed)) + throw new ReplayBestEffortPersistenceException(); + return result; + }, + cancellationToken).ConfigureAwait(false); + } + catch (ReplayBestEffortPersistenceException) + { + // ExecuteAsync may surface this marker only after its provider rollback completed. Reuse + // the already prepared embeddings and replay through today's item-isolated best-effort path. + return await PersistPreparedAsync( + extraction, ownerId, trustLevel, prepared, cancellationToken).ConfigureAwait(false); + } + } + + private sealed class ReplayBestEffortPersistenceException : Exception; + + private async Task PersistPreparedAsync( + ExtractionStageResult extraction, + string? ownerId, + MemoryTrustLevel trustLevel, + PreparedEmbeddings prepared, + CancellationToken cancellationToken) + { var sourceMessageIds = extraction.SourceMessageIds; var failFast = _options.FailureMode == IngestionFailureMode.FailFast; var outcomes = new List(extraction.Outcomes); + outcomes.AddRange(prepared.Outcomes); // 1. Embed + upsert entities; build a name→persisted Entity map for relationship resolution. var persistedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (name, entity) in extraction.ResolvedEntityMap) + var entityInputs = prepared.Entities.Select(pair => + { + var effectiveTrustLevel = MaxTrustLevel(pair.Value.Metadata.GetTrustLevel(), trustLevel); + return (Name: pair.Key, Item: pair.Value with + { + OwnerId = ownerId, + Metadata = pair.Value.Metadata.WithTrustLevel(effectiveTrustLevel) + }); + }).ToList(); + + async Task RecordPersistedEntityAsync(string name, Entity persisted) { - // Trust is monotonic, never silently downgraded: when entity resolution (auto-merge/SAME_AS) - // resolves this mention onto an EXISTING, previously-persisted entity, `entity` already carries - // that entity's own prior Metadata/trust level. An unrelated later mention at a lower trust - // level (e.g. an ordinary chat turn) must not erase a deliberately-elevated trust stamp (e.g. - // from a curated ApplicationTrusted import) -- take whichever of the two is higher. - var effectiveTrustLevel = MaxTrustLevel(entity.Metadata.GetTrustLevel(), trustLevel); - var entityToSave = entity with { OwnerId = ownerId, Metadata = entity.Metadata.WithTrustLevel(effectiveTrustLevel) }; + persistedEntityMap[name] = persisted; + RecordSuccess(outcomes, MemoryItemKind.Entity, name, persisted.EntityId); - if (entityToSave.Embedding is null) + foreach (var msgId in ExplicitProvenanceMessageIds(_entityRepository, sourceMessageIds)) { try { - var embedding = await _embeddingOrchestrator.EmbedEntityAsync( - entityToSave.Name, cancellationToken).ConfigureAwait(false); - entityToSave = entityToSave with { Embedding = embedding }; + await _entityRepository.CreateExtractedFromRelationshipAsync( + persisted.EntityId, msgId, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - _logger.LogError(ex, "Error generating embedding for entity '{Name}'.", name); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Entity, IngestionStage.Embedding, - MemoryErrorCodes.EmbeddingGenerationFailed, name, null, ex, - $"Ingestion failed fast: embedding generation failed for entity '{name}'."); - continue; // no embedding — nothing to persist for this entity + _logger.LogWarning(ex, + "Failed to create EXTRACTED_FROM for entity '{Id}' → message '{MsgId}'.", + persisted.EntityId, msgId); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Entity, IngestionStage.Provenance, + MemoryErrorCodes.ProvenancePersistenceFailed, name, persisted.EntityId, ex, + $"Ingestion failed fast: provenance failed for entity '{name}'."); } } + _logger.LogDebug("Persisted entity '{Name}' (id={Id}).", persisted.Name, persisted.EntityId); + } + + async Task PersistEntityIndividuallyAsync(string name, Entity item) + { try { - entityToSave = await _entityRepository.UpsertAsync(entityToSave, cancellationToken).ConfigureAwait(false); - persistedEntityMap[name] = entityToSave; - RecordSuccess(outcomes, MemoryItemKind.Entity, name, entityToSave.EntityId); - - foreach (var msgId in sourceMessageIds) - { - try - { - await _entityRepository.CreateExtractedFromRelationshipAsync( - entityToSave.EntityId, msgId, cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to create EXTRACTED_FROM for entity '{Id}' → message '{MsgId}'.", - entityToSave.EntityId, msgId); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Entity, IngestionStage.Provenance, - MemoryErrorCodes.ProvenancePersistenceFailed, name, entityToSave.EntityId, ex, - $"Ingestion failed fast: provenance failed for entity '{name}'."); - } - } - - _logger.LogDebug("Persisted entity '{Name}' (id={Id}).", entityToSave.Name, entityToSave.EntityId); + var persisted = await _entityRepository.UpsertAsync(item, cancellationToken).ConfigureAwait(false); + await RecordPersistedEntityAsync(name, persisted).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (MemoryIngestionException) { throw; } // already recorded + wrapped above — propagate as-is + catch (MemoryIngestionException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Error persisting entity '{Name}'.", name); @@ -140,79 +197,61 @@ await _entityRepository.CreateExtractedFromRelationshipAsync( } } - // 2. Embed + upsert facts. - var persistedFactCount = 0; - foreach (var extracted in extraction.FilteredFacts) + Dictionary? batchedEntitiesById = null; + var fusedEntityRepository = _options.UseCoalescedPersistenceTransactions + ? _entityRepository as IFusedBatchMemoryRepository : null; + var batchEntityRepository = _entityRepository as IBatchMemoryRepository; + var canBatchEntities = _options.EnableBatchMemoryUpserts && !failFast && + entityInputs.Count > 0 && + entityInputs.Select(input => input.Item.EntityId).Distinct(StringComparer.Ordinal).Count() == entityInputs.Count && + (fusedEntityRepository is not null || (entityInputs.Count > 1 && batchEntityRepository is not null)); + if (canBatchEntities) { - // factSourceKey (outcome/log identification) and the embedding below are both computed from the - // freshly-extracted casing, even though the fact ultimately persisted may use an existing - // record's casing instead when the #92 Phase 5 pre-fetch finds a case-insensitive match (see - // below) -- a disclosed, cosmetic-only inconsistency (found in a post-Phase-5 holistic audit): - // an outcome/log entry for a casing-only re-extraction won't textually match what was persisted, - // and the surviving node's Embedding and Subject/Predicate/Object can reflect two different - // casings of the same triple. Embeddings are semantically robust to case, so this hasn't been - // observed to affect retrieval quality; not fixed here to keep this phase's blast radius narrow. - var factSourceKey = $"{extracted.Subject} {extracted.Predicate} {extracted.Object}"; - - float[] factEmbedding; try { - factEmbedding = await _embeddingOrchestrator.EmbedFactAsync( - extracted.Subject, extracted.Predicate, extracted.Object, cancellationToken).ConfigureAwait(false); + var items = entityInputs.Select(input => input.Item).ToList(); + var persisted = fusedEntityRepository is not null + ? await fusedEntityRepository.UpsertFusedBatchAsync(items, cancellationToken) + .ConfigureAwait(false) + : await batchEntityRepository!.UpsertBatchAsync(items, cancellationToken) + .ConfigureAwait(false); + batchedEntitiesById = persisted.ToDictionary(entity => entity.EntityId, StringComparer.Ordinal); + if (entityInputs.Any(input => !batchedEntitiesById.ContainsKey(input.Item.EntityId))) + throw new InvalidOperationException("The entity batch result omitted one or more input identifiers."); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - _logger.LogError(ex, "Error generating embedding for fact '{Key}'.", factSourceKey); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Embedding, - MemoryErrorCodes.EmbeddingGenerationFailed, factSourceKey, null, ex, - $"Ingestion failed fast: embedding generation failed for fact '{factSourceKey}'."); - continue; + _logger.LogWarning(ex, + "Atomic entity batch failed; replaying {Count} entities through the item path.", + entityInputs.Count); + batchedEntitiesById = null; } + } + + if (batchedEntitiesById is not null) + { + foreach (var input in entityInputs) + await RecordPersistedEntityAsync(input.Name, batchedEntitiesById[input.Item.EntityId]).ConfigureAwait(false); + } + else + { + foreach (var input in entityInputs) + await PersistEntityIndividuallyAsync(input.Name, input.Item).ConfigureAwait(false); + } + // 2. Embed + upsert facts. + var persistedFactCount = 0; + async Task<(Fact Item, string SourceKey)?> PrepareFactAsync(PreparedFact preparedFact) + { + var extracted = preparedFact.Item; + var factSourceKey = $"{extracted.Subject} {extracted.Predicate} {extracted.Object}"; try { - // Trust is monotonic for owner-scoped facts too (#92 Phase 5), mirroring entities (Phase 3): - // the repository's Upsert MERGEs on the exact {subject,predicate,object,owner} triple and its - // Cypher ON MATCH unconditionally overwrites metadata, so re-extracting the identical triple - // at a lower trust level (e.g. an ordinary chat turn re-stating a fact originally imported at - // ApplicationTrusted) would otherwise silently erase the earlier elevation. Unlike entities, - // facts have no upstream resolution step that hands PersistenceStage the prior record for - // free, so this pre-fetch is the "one extra round-trip" the Phase 3 doc flagged as needed. - // A lookup failure falls through to the same catch below as an ordinary persistence failure. - // - // Disclosed, unaddressed limitation (found in a post-Phase-5 holistic audit): this pre-fetch - // and the Upsert below are two separate, non-atomic Neo4j round-trips, not one atomic - // read-modify-write. Two concurrent extractions racing on the identical triple (e.g. a - // curated ApplicationTrusted import racing an ordinary chat-turn extraction) could each read - // the same stale prior state and independently compute their own "effective" trust, so - // whichever Upsert commits last wins outright rather than the two being reconciled -- a - // narrow, real gap in "never decreases" under genuine concurrency on the same triple. - // Matches this codebase's existing precedent of disclosing rather than solving multi-step, - // non-atomic writes (see the threat model's TT-12, record+provenance-edge non-atomicity). - // - // Only performed when ownerId is set (string.IsNullOrEmpty, matching how the rest of this - // codebase treats an empty owner id the same as a null one -- e.g. DefaultMemoryIsolationPolicy): - // FindByTripleAsync's MemoryScope? parameter follows the read/recall convention where an - // unscoped lookup (null, or a scope with no OwnerId) means "search across every owner" -- the - // opposite of what a null ownerId means on the WRITE side (the shared/global bucket). Passing - // an owner-less scope here would risk adopting another owner's trust level into a shared fact - // -- a cross-tenant leak. Unlike FindDuplicateAsync (whose raw ownerId parameter is documented - // as "null -> shared bucket only"), there is no existing repository primitive for a safe - // shared-bucket-only lookup, so shared/global facts don't get this protection yet -- a - // disclosed, narrower-than-ideal limitation for this phase. - // - // includeShared: false -- deliberately excludes shared/global facts from the pre-fetch even - // though MemoryScope.For defaults to including them. The default is right for READS (surface - // everything the caller may see), but wrong here: with no ORDER BY, a shared fact and this - // owner's own fact could both match the same triple, and picking up the shared one would - // graft an unrelated record's ENTIRE metadata (not just its trust level) onto this owner's - // fact -- conflating two conceptually distinct records that merely share text. - // - // FindByTripleAsync matches case-insensitively but Upsert's MERGE key is an exact-string - // match -- if a match is found, this fact is built from the EXISTING record's Subject/ - // Predicate/Object (not the freshly-extracted casing) so the subsequent Upsert's MERGE - // still targets the SAME node instead of creating a same-triple, different-casing duplicate. + // Trust is monotonic for owner-scoped facts. The pre-fetch deliberately excludes shared + // facts and carries an existing triple's casing forward so an exact MERGE cannot create a + // casing-only duplicate. Rank 20 will make this read-modify-write atomic; feat-04 leaves + // that owner boundary and trust behavior unchanged. Fact? existingFact = string.IsNullOrEmpty(ownerId) ? null : await _factRepository.FindByTripleAsync( @@ -225,7 +264,7 @@ await _entityRepository.CreateExtractedFromRelationshipAsync( ? MemoryTrustMetadataExtensions.CreateWithTrustLevel(effectiveFactTrustLevel) : existingFact.Metadata.WithTrustLevel(effectiveFactTrustLevel); - var fact = new Fact + return (new Fact { FactId = _idGenerator.GenerateId(), Subject = existingFact?.Subject ?? extracted.Subject, @@ -234,129 +273,254 @@ await _entityRepository.CreateExtractedFromRelationshipAsync( Confidence = extracted.Confidence, ValidFrom = extracted.ValidFrom, ValidUntil = extracted.ValidUntil, - Embedding = factEmbedding, + Embedding = preparedFact.Embedding, OwnerId = ownerId, SourceMessageIds = sourceMessageIds, CreatedAtUtc = _clock.UtcNow, Metadata = factMetadata - }; - - // Facts MERGE on the natural {subject,predicate,object,owner_key} triple, and ON MATCH - // deliberately never rewrites the surviving node's id (Neo4jFactRepository's own contract) -- - // so on a re-extraction hit, fact.FactId (the freshly-generated guid above) is orphaned and - // was never actually persisted. Reassign from the repository's return value, mirroring the - // entity block above, so RecordSuccess and the EXTRACTED_FROM loop below use the real, - // surviving node's id. - fact = await _factRepository.UpsertAsync(fact, cancellationToken).ConfigureAwait(false); - RecordSuccess(outcomes, MemoryItemKind.Fact, factSourceKey, fact.FactId); - - foreach (var msgId in sourceMessageIds) - { - try - { - await _factRepository.CreateExtractedFromRelationshipAsync( - fact.FactId, msgId, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to create EXTRACTED_FROM for fact '{Id}' → message '{MsgId}'.", - fact.FactId, msgId); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Provenance, - MemoryErrorCodes.ProvenancePersistenceFailed, factSourceKey, fact.FactId, ex, - $"Ingestion failed fast: provenance failed for fact '{factSourceKey}'."); - } - } - - persistedFactCount++; - _logger.LogDebug("Persisted fact '{S} {P} {O}'.", fact.Subject, fact.Predicate, fact.Object); + }, factSourceKey); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (MemoryIngestionException) { throw; } catch (Exception ex) { - _logger.LogError(ex, "Error persisting fact '{Key}'.", factSourceKey); + _logger.LogError(ex, "Error preparing fact '{Key}' for persistence.", factSourceKey); RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Persistence, MemoryErrorCodes.FactPersistenceFailed, factSourceKey, null, ex, $"Ingestion failed fast: persistence failed for fact '{factSourceKey}'."); + return null; } } - // 3. Embed + upsert preferences. - var persistedPrefCount = 0; - foreach (var extracted in extraction.FilteredPreferences) + async Task RecordPersistedFactAsync(string sourceKey, Fact persisted) + { + // Fact upsert MERGEs on the natural triple and may return an older stable id. Always use + // the repository result for outcomes and provenance rather than the fresh caller id. + RecordSuccess(outcomes, MemoryItemKind.Fact, sourceKey, persisted.FactId); + + foreach (var msgId in ExplicitProvenanceMessageIds(_factRepository, sourceMessageIds)) + { + try + { + await _factRepository.CreateExtractedFromRelationshipAsync( + persisted.FactId, msgId, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to create EXTRACTED_FROM for fact '{Id}' → message '{MsgId}'.", + persisted.FactId, msgId); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Provenance, + MemoryErrorCodes.ProvenancePersistenceFailed, sourceKey, persisted.FactId, ex, + $"Ingestion failed fast: provenance failed for fact '{sourceKey}'."); + } + } + + persistedFactCount++; + _logger.LogDebug("Persisted fact '{S} {P} {O}'.", + persisted.Subject, persisted.Predicate, persisted.Object); + } + + async Task PersistFactIndividuallyAsync(Fact item, string sourceKey) { - float[] prefEmbedding; try { - prefEmbedding = await _embeddingOrchestrator.EmbedPreferenceAsync( - extracted.PreferenceText, cancellationToken).ConfigureAwait(false); + var persisted = await _factRepository.UpsertAsync(item, cancellationToken).ConfigureAwait(false); + await RecordPersistedFactAsync(sourceKey, persisted).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (MemoryIngestionException) { throw; } catch (Exception ex) { - _logger.LogError(ex, "Error generating embedding for preference '{Text}'.", extracted.PreferenceText); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Preference, IngestionStage.Embedding, - MemoryErrorCodes.EmbeddingGenerationFailed, extracted.PreferenceText, null, ex, - "Ingestion failed fast: embedding generation failed for a preference."); - continue; + _logger.LogError(ex, "Error persisting fact '{Key}'.", sourceKey); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Persistence, + MemoryErrorCodes.FactPersistenceFailed, sourceKey, null, ex, + $"Ingestion failed fast: persistence failed for fact '{sourceKey}'."); } + } - try + static (string Subject, string Predicate, string Object, string? OwnerId) FactKey(Fact fact) => + (fact.Subject, fact.Predicate, fact.Object, fact.OwnerId); + + var distinctExtractedTriples = extraction.FilteredFacts + .Select(fact => (fact.Subject, fact.Predicate, fact.Object)) + .Distinct(FactTripleComparer.OrdinalIgnoreCase) + .Count() == extraction.FilteredFacts.Count; + var fusedFactRepository = _options.UseCoalescedPersistenceTransactions + ? _factRepository as IFusedBatchMemoryRepository : null; + var batchFactRepository = _factRepository as IBatchMemoryRepository; + var canAttemptFactBatch = _options.EnableBatchMemoryUpserts && !failFast && distinctExtractedTriples && + (fusedFactRepository is not null || + (prepared.Facts.Count > 1 && batchFactRepository is not null)); + + if (canAttemptFactBatch) + { + var factInputs = new List<(Fact Item, string SourceKey)>(prepared.Facts.Count); + foreach (var preparedFact in prepared.Facts) + { + if (await PrepareFactAsync(preparedFact).ConfigureAwait(false) is { } input) + factInputs.Add(input); + } + + Dictionary<(string Subject, string Predicate, string Object, string? OwnerId), Fact>? batchedFactsByKey = null; + if (factInputs.Count > 0 && + factInputs.Select(input => FactKey(input.Item)).Distinct().Count() == factInputs.Count) { - var preference = new Preference + try { - PreferenceId = _idGenerator.GenerateId(), - Category = extracted.Category, - PreferenceText = extracted.PreferenceText, - Context = extracted.Context, - Confidence = extracted.Confidence, - Embedding = prefEmbedding, - OwnerId = ownerId, - SourceMessageIds = sourceMessageIds, - CreatedAtUtc = _clock.UtcNow, - Metadata = MemoryTrustMetadataExtensions.CreateWithTrustLevel(trustLevel) - }; + var items = factInputs.Select(input => input.Item).ToList(); + var persisted = fusedFactRepository is not null + ? await fusedFactRepository.UpsertFusedBatchAsync(items, cancellationToken).ConfigureAwait(false) + : await batchFactRepository!.UpsertBatchAsync(items, cancellationToken).ConfigureAwait(false); + batchedFactsByKey = persisted.ToDictionary(FactKey); + if (factInputs.Any(input => !batchedFactsByKey.ContainsKey(FactKey(input.Item)))) + throw new InvalidOperationException("The fact batch result omitted one or more input triples."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Atomic fact batch failed; replaying {Count} facts through the item path.", + factInputs.Count); + batchedFactsByKey = null; + } + } - await _preferenceRepository.UpsertAsync(preference, cancellationToken).ConfigureAwait(false); - RecordSuccess(outcomes, MemoryItemKind.Preference, extracted.PreferenceText, preference.PreferenceId); + if (batchedFactsByKey is not null) + { + foreach (var input in factInputs) + await RecordPersistedFactAsync( + input.SourceKey, batchedFactsByKey[FactKey(input.Item)]).ConfigureAwait(false); + } + else + { + foreach (var input in factInputs) + await PersistFactIndividuallyAsync(input.Item, input.SourceKey).ConfigureAwait(false); + } + } + else + { + // Preserve the exact original read→write order for non-capable repositories, disabled/fail-fast + // mode, and duplicate triples. The ordering is observable because the next fact's trust/casing + // pre-fetch may intentionally see the fact just written by the previous item. + foreach (var preparedFact in prepared.Facts) + { + if (await PrepareFactAsync(preparedFact).ConfigureAwait(false) is { } input) + await PersistFactIndividuallyAsync(input.Item, input.SourceKey).ConfigureAwait(false); + } + } + // 3. Embed + upsert preferences. + var preferenceInputs = prepared.Preferences.Select(preparedPreference => + { + var extracted = preparedPreference.Item; + return (Item: new Preference + { + PreferenceId = _idGenerator.GenerateId(), + Category = extracted.Category, + PreferenceText = extracted.PreferenceText, + Context = extracted.Context, + Confidence = extracted.Confidence, + Embedding = preparedPreference.Embedding, + OwnerId = ownerId, + SourceMessageIds = sourceMessageIds, + CreatedAtUtc = _clock.UtcNow, + Metadata = MemoryTrustMetadataExtensions.CreateWithTrustLevel(trustLevel) + }, SourceKey: extracted.PreferenceText); + }).ToList(); - foreach (var msgId in sourceMessageIds) + var persistedPrefCount = 0; + + async Task RecordPersistedPreferenceAsync(string sourceKey, Preference persisted) + { + RecordSuccess(outcomes, MemoryItemKind.Preference, sourceKey, persisted.PreferenceId); + + foreach (var msgId in ExplicitProvenanceMessageIds(_preferenceRepository, sourceMessageIds)) + { + try { - try - { - await _preferenceRepository.CreateExtractedFromRelationshipAsync( - preference.PreferenceId, msgId, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to create EXTRACTED_FROM for preference '{Id}' → message '{MsgId}'.", - preference.PreferenceId, msgId); - RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Preference, IngestionStage.Provenance, - MemoryErrorCodes.ProvenancePersistenceFailed, extracted.PreferenceText, preference.PreferenceId, ex, - "Ingestion failed fast: provenance failed for a preference."); - } + await _preferenceRepository.CreateExtractedFromRelationshipAsync( + persisted.PreferenceId, msgId, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to create EXTRACTED_FROM for preference '{Id}' → message '{MsgId}'.", + persisted.PreferenceId, msgId); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Preference, IngestionStage.Provenance, + MemoryErrorCodes.ProvenancePersistenceFailed, sourceKey, persisted.PreferenceId, ex, + "Ingestion failed fast: provenance failed for a preference."); + } + } + + persistedPrefCount++; + _logger.LogDebug("Persisted preference in category '{Category}'.", persisted.Category); + } - persistedPrefCount++; - _logger.LogDebug("Persisted preference in category '{Category}'.", preference.Category); + async Task PersistPreferenceIndividuallyAsync(Preference item, string sourceKey) + { + try + { + var persisted = await _preferenceRepository.UpsertAsync(item, cancellationToken).ConfigureAwait(false); + await RecordPersistedPreferenceAsync(sourceKey, persisted).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (MemoryIngestionException) { throw; } catch (Exception ex) { - _logger.LogError(ex, "Error persisting preference '{Text}'.", extracted.PreferenceText); + _logger.LogError(ex, "Error persisting preference '{Text}'.", sourceKey); RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Preference, IngestionStage.Persistence, - MemoryErrorCodes.PreferencePersistenceFailed, extracted.PreferenceText, null, ex, + MemoryErrorCodes.PreferencePersistenceFailed, sourceKey, null, ex, "Ingestion failed fast: persistence failed for a preference."); } } + Dictionary? batchedPreferencesById = null; + var fusedPreferenceRepository = _options.UseCoalescedPersistenceTransactions + ? _preferenceRepository as IFusedBatchMemoryRepository : null; + var batchPreferenceRepository = _preferenceRepository as IBatchMemoryRepository; + var canBatchPreferences = _options.EnableBatchMemoryUpserts && !failFast && + preferenceInputs.Count > 0 && + preferenceInputs.Select(input => input.Item.PreferenceId).Distinct(StringComparer.Ordinal).Count() == preferenceInputs.Count && + (fusedPreferenceRepository is not null || + (preferenceInputs.Count > 1 && batchPreferenceRepository is not null)); + if (canBatchPreferences) + { + try + { + var items = preferenceInputs.Select(input => input.Item).ToList(); + var persisted = fusedPreferenceRepository is not null + ? await fusedPreferenceRepository.UpsertFusedBatchAsync(items, cancellationToken).ConfigureAwait(false) + : await batchPreferenceRepository!.UpsertBatchAsync(items, cancellationToken).ConfigureAwait(false); + batchedPreferencesById = persisted.ToDictionary( + preference => preference.PreferenceId, StringComparer.Ordinal); + if (preferenceInputs.Any(input => !batchedPreferencesById.ContainsKey(input.Item.PreferenceId))) + throw new InvalidOperationException("The preference batch result omitted one or more input identifiers."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Atomic preference batch failed; replaying {Count} preferences through the item path.", + preferenceInputs.Count); + batchedPreferencesById = null; + } + } + + if (batchedPreferencesById is not null) + { + foreach (var input in preferenceInputs) + await RecordPersistedPreferenceAsync( + input.SourceKey, batchedPreferencesById[input.Item.PreferenceId]).ConfigureAwait(false); + } + else + { + foreach (var input in preferenceInputs) + await PersistPreferenceIndividuallyAsync(input.Item, input.SourceKey).ConfigureAwait(false); + } // 4. Persist relationships — resolve entity IDs from the upserted entity map. - var persistedRelCount = 0; + var relationshipInputs = new List<(Relationship Item, string SourceKey)>( + extraction.FilteredRelationships.Count); foreach (var extracted in extraction.FilteredRelationships) { var relSourceKey = $"{extracted.SourceEntity}-{extracted.RelationshipType}->{extracted.TargetEntity}"; @@ -395,43 +559,86 @@ await _preferenceRepository.CreateExtractedFromRelationshipAsync( continue; } - try + relationshipInputs.Add((new Relationship { - var relationship = new Relationship - { - RelationshipId = _idGenerator.GenerateId(), - SourceEntityId = sourceEntity.EntityId, - TargetEntityId = targetEntity.EntityId, - RelationshipType = extracted.RelationshipType, - Description = extracted.Description, - Confidence = extracted.Confidence, - Attributes = extracted.Attributes, - OwnerId = ownerId, - SourceMessageIds = sourceMessageIds, - CreatedAtUtc = _clock.UtcNow - }; + RelationshipId = _idGenerator.GenerateId(), + SourceEntityId = sourceEntity.EntityId, + TargetEntityId = targetEntity.EntityId, + RelationshipType = extracted.RelationshipType, + Description = extracted.Description, + Confidence = extracted.Confidence, + Attributes = extracted.Attributes, + OwnerId = ownerId, + SourceMessageIds = sourceMessageIds, + CreatedAtUtc = _clock.UtcNow + }, relSourceKey)); + } - await _relationshipRepository.UpsertAsync(relationship, cancellationToken).ConfigureAwait(false); - persistedRelCount++; - RecordSuccess(outcomes, MemoryItemKind.Relationship, relSourceKey, relationship.RelationshipId); + var persistedRelCount = 0; + + void RecordPersistedRelationship(string sourceKey, Relationship persisted) + { + persistedRelCount++; + RecordSuccess(outcomes, MemoryItemKind.Relationship, sourceKey, persisted.RelationshipId); + _logger.LogDebug("Persisted relationship '{SourceKey}'.", sourceKey); + } - _logger.LogDebug( - "Persisted relationship '{Src}-{Type}->{Tgt}'.", - extracted.SourceEntity, extracted.RelationshipType, extracted.TargetEntity); + async Task PersistRelationshipIndividuallyAsync(Relationship item, string sourceKey) + { + try + { + var persisted = await _relationshipRepository.UpsertAsync(item, cancellationToken).ConfigureAwait(false); + RecordPersistedRelationship(sourceKey, persisted); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (MemoryIngestionException) { throw; } // consistent with the other item kinds (#101 review) + catch (MemoryIngestionException) { throw; } catch (Exception ex) { - _logger.LogError(ex, - "Error persisting relationship '{Src}->{Tgt}'.", - extracted.SourceEntity, extracted.TargetEntity); + _logger.LogError(ex, "Error persisting relationship '{SourceKey}'.", sourceKey); RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Relationship, IngestionStage.Persistence, - MemoryErrorCodes.RelationshipPersistenceFailed, relSourceKey, null, ex, - $"Ingestion failed fast: persistence failed for relationship '{relSourceKey}'."); + MemoryErrorCodes.RelationshipPersistenceFailed, sourceKey, null, ex, + $"Ingestion failed fast: persistence failed for relationship '{sourceKey}'."); + } + } + + Dictionary? batchedRelationshipsById = null; + var canBatchRelationships = _options.EnableBatchMemoryUpserts && !failFast && + relationshipInputs.Count > 1 && + relationshipInputs.Select(input => input.Item.RelationshipId).Distinct(StringComparer.Ordinal).Count() == relationshipInputs.Count && + _relationshipRepository is IBatchMemoryRepository; + if (canBatchRelationships) + { + try + { + var persisted = await ((IBatchMemoryRepository)_relationshipRepository) + .UpsertBatchAsync(relationshipInputs.Select(input => input.Item).ToList(), cancellationToken) + .ConfigureAwait(false); + batchedRelationshipsById = persisted.ToDictionary( + relationship => relationship.RelationshipId, StringComparer.Ordinal); + if (relationshipInputs.Any(input => !batchedRelationshipsById.ContainsKey(input.Item.RelationshipId))) + throw new InvalidOperationException("The relationship batch result omitted one or more input identifiers."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Atomic relationship batch failed; replaying {Count} relationships through the item path.", + relationshipInputs.Count); + batchedRelationshipsById = null; } } + if (batchedRelationshipsById is not null) + { + foreach (var input in relationshipInputs) + RecordPersistedRelationship( + input.SourceKey, batchedRelationshipsById[input.Item.RelationshipId]); + } + else + { + foreach (var input in relationshipInputs) + await PersistRelationshipIndividuallyAsync(input.Item, input.SourceKey).ConfigureAwait(false); + } return new PersistenceResult { EntityCount = persistedEntityMap.Count, @@ -442,12 +649,117 @@ await _preferenceRepository.CreateExtractedFromRelationshipAsync( }; } + private async Task PrepareEmbeddingsIndividuallyAsync( + ExtractionStageResult extraction, + CancellationToken cancellationToken) + { + var failFast = _options.FailureMode == IngestionFailureMode.FailFast; + var outcomes = new List(); + var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); + var facts = new List(extraction.FilteredFacts.Count); + var preferences = new List(extraction.FilteredPreferences.Count); + + foreach (var (name, entity) in extraction.ResolvedEntityMap) + { + if (entity.Embedding is not null) + { + entities[name] = entity; + continue; + } + + try + { + var embedding = await _embeddingOrchestrator.EmbedEntityAsync( + entity.Name, cancellationToken).ConfigureAwait(false); + entities[name] = entity with { Embedding = embedding }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating embedding for entity '{Name}'.", name); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Entity, IngestionStage.Embedding, + MemoryErrorCodes.EmbeddingGenerationFailed, name, null, ex, + $"Ingestion failed fast: embedding generation failed for entity '{name}'."); + } + } + + foreach (var extracted in extraction.FilteredFacts) + { + var sourceKey = $"{extracted.Subject} {extracted.Predicate} {extracted.Object}"; + try + { + var embedding = await _embeddingOrchestrator.EmbedFactAsync( + extracted.Subject, extracted.Predicate, extracted.Object, cancellationToken).ConfigureAwait(false); + facts.Add(new PreparedFact(extracted, embedding)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating embedding for fact '{Key}'.", sourceKey); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Fact, IngestionStage.Embedding, + MemoryErrorCodes.EmbeddingGenerationFailed, sourceKey, null, ex, + $"Ingestion failed fast: embedding generation failed for fact '{sourceKey}'."); + } + } + + foreach (var extracted in extraction.FilteredPreferences) + { + try + { + var embedding = await _embeddingOrchestrator.EmbedPreferenceAsync( + extracted.PreferenceText, cancellationToken).ConfigureAwait(false); + preferences.Add(new PreparedPreference(extracted, embedding)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating embedding for preference '{Text}'.", extracted.PreferenceText); + RecordFailureAndMaybeThrow(outcomes, failFast, MemoryItemKind.Preference, IngestionStage.Embedding, + MemoryErrorCodes.EmbeddingGenerationFailed, extracted.PreferenceText, null, ex, + "Ingestion failed fast: embedding generation failed for a preference."); + } + } + + return new PreparedEmbeddings(entities, facts, preferences, outcomes); + } + + private sealed class FactTripleComparer : IEqualityComparer<(string Subject, string Predicate, string Object)> + { + public static FactTripleComparer OrdinalIgnoreCase { get; } = new(); + + public bool Equals( + (string Subject, string Predicate, string Object) left, + (string Subject, string Predicate, string Object) right) => + StringComparer.OrdinalIgnoreCase.Equals(left.Subject, right.Subject) && + StringComparer.OrdinalIgnoreCase.Equals(left.Predicate, right.Predicate) && + StringComparer.OrdinalIgnoreCase.Equals(left.Object, right.Object); + + public int GetHashCode((string Subject, string Predicate, string Object) value) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(value.Subject), + StringComparer.OrdinalIgnoreCase.GetHashCode(value.Predicate), + StringComparer.OrdinalIgnoreCase.GetHashCode(value.Object)); + } + private sealed record PreparedEmbeddings( + IReadOnlyDictionary Entities, + IReadOnlyList Facts, + IReadOnlyList Preferences, + IReadOnlyList Outcomes); + + private sealed record PreparedFact(ExtractedFact Item, float[] Embedding); + + private sealed record PreparedPreference(ExtractedPreference Item, float[] Embedding); + /// /// Trust is monotonic (#92 Phase 3): re-touching an already-persisted entity must never silently lower /// its trust level below whatever it already had. /// private static MemoryTrustLevel MaxTrustLevel(MemoryTrustLevel a, MemoryTrustLevel b) => a > b ? a : b; + private static IEnumerable ExplicitProvenanceMessageIds( + object repository, IReadOnlyList sourceMessageIds) => + repository is IUpsertPersistsProvenance ? Array.Empty() : sourceMessageIds; + /// Appends a outcome (#101). private static void RecordSuccess( List outcomes, MemoryItemKind kind, string? sourceKey, string? persistedId) => diff --git a/src/AgentMemory.Core/Memory/MemoryPredicateSeedVocabulary.cs b/src/AgentMemory.Core/Memory/MemoryPredicateSeedVocabulary.cs new file mode 100644 index 00000000..9abc27f3 --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryPredicateSeedVocabulary.cs @@ -0,0 +1,66 @@ +namespace AgentMemory.Core.Memory; + +/// +/// The starting set of relation names offered to extraction. +/// +/// +/// +/// Curated once rather than mined per run, for two reasons. A vocabulary that accumulated during +/// a run would make each call's prompt depend on which concurrent extraction finished first, so the +/// same input could produce different prompts — the precise property that made an earlier +/// Structured score sequence unattributable. And a reviewed list can be checked by a human for the +/// one mistake that matters here: silently omitting one side of an opposing pair. +/// +/// +/// Drawn from relations actually observed in extracted graphs. Deliberately small: it is injected +/// into every extraction prompt, and a list approaching the 421-predicates-per-700-facts figure that +/// motivated it would consume the budget it exists to improve. +/// +/// +/// Opposing relations are both present by design. bought/sold and +/// likes/dislikes are one embedding threshold apart and mean opposite things; offering +/// only one would invite the extractor to collapse them and invert facts. +/// +/// +public static class MemoryPredicateSeedVocabulary +{ + /// + /// Derived from the single relation table, never authored separately. + /// + /// + /// This list was previously maintained by hand alongside the query lexicon, and the two drifted: + /// **13 relations became resolvable at query time that the extractor was never offered**, so the + /// graph could not contain them however well retrieval worked. `assembled` was one of them, which + /// is why assembly was filed under `completed` and the furniture question could not be answered + /// from the graph. One relation known to two layers must have one definition. + /// + /// Only the canonical keys cross over. Surface forms stay read-side: they never enter an + /// extraction prompt, where they would cost tokens on every call and invite the extractor to + /// choose inconsistently between buy, buys and purchased - the opposite of + /// the consolidation this vocabulary exists to produce. + /// + /// + private static readonly string[] Seed = + [.. MemoryRelationSeedTable.Table.Keys + .Where(relation => !MemoryRelationSeedTable.RetiredRelations.Contains(relation)) + .OrderBy(relation => relation, StringComparer.Ordinal)]; + + /// + /// Content hash of this vocabulary, for recording which table produced a given extracted graph. + /// + /// + /// This list is injected into every extraction prompt, so changing it changes what is stored. Two + /// graphs built under different vocabularies are not comparable, and without this the artifact + /// would not say which one produced it. + /// + public static string Fingerprint { get; } = MemoryVocabularyFingerprint.Of(Seed); + + /// A vocabulary pre-populated with the curated seed relations. + public static MemoryPredicateVocabulary Create() + { + var vocabulary = new MemoryPredicateVocabulary(); + foreach (var predicate in Seed) + vocabulary.Admit(predicate); + return vocabulary; + } +} diff --git a/src/AgentMemory.Core/Memory/MemoryPredicateVocabulary.cs b/src/AgentMemory.Core/Memory/MemoryPredicateVocabulary.cs new file mode 100644 index 00000000..b55acd6e --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryPredicateVocabulary.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; + +namespace AgentMemory.Core.Memory; + +/// +/// The set of relation names extraction has already established, so it reuses them instead of +/// inventing a new phrasing per sentence. +/// +/// +/// +/// Why this exists. collapses spelling +/// (were_born_inwere born in). It cannot collapse phrasing. Measured on a +/// live extracted graph, one real-world event — a birth — arrived as was born, +/// was born in, were born in, had and welcomed, so retrieving any single +/// relation gathered at most three of five and counting questions were unanswerable. That graph held +/// 700 facts under 421 distinct predicates: a vocabulary almost as large as the data, which is what +/// happens when nothing tells the extractor which relations already exist. +/// +/// +/// Deterministic only. Admission matches on the canonical form and nothing else. No embedding +/// or similarity folding: bought/sold and likes/dislikes sit one +/// threshold apart and mean opposite things, and merging them would silently invert stored facts in +/// a way ordinary tests would not catch. Narrowing the vocabulary is the extractor's job, guided by +/// what it is shown; it is never this type's job to guess. +/// +/// +/// First spelling wins, so an established relation never drifts between runs and queries can +/// rely on its name. Growth is capped because the vocabulary is injected into the extraction prompt, +/// and an uncapped one would trend toward one predicate per fact and consume the budget it exists to +/// improve. A predicate beyond the cap is still returned and usable — it is simply not established. +/// +/// +public sealed class MemoryPredicateVocabulary +{ + /// Keyed on the canonical form; the value is the established surface spelling. + private readonly ConcurrentDictionary _established; + private readonly int _maximumSize; + + /// + /// Cap on established relations. The vocabulary is injected into the extraction prompt, so an + /// uncapped one would trend toward one predicate per fact and consume the budget it improves. + /// + public MemoryPredicateVocabulary(int maximumSize = 256) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumSize); + _maximumSize = maximumSize; + _established = new ConcurrentDictionary(StringComparer.Ordinal); + } + + /// Number of established relations. + public int Count => _established.Count; + + /// + /// Returns the established spelling for , establishing it if the + /// relation is new and there is room. + /// + /// + /// The established predicate when one exists or is created; otherwise + /// unchanged, so a full vocabulary degrades to today's behaviour rather than losing a fact. + /// + public string Admit(string? predicate) + { + var canonical = MemoryTripleCanonicalizer.Canonical(predicate); + if (canonical.Length == 0) + return string.Empty; + + if (_established.TryGetValue(canonical, out var established)) + return established; + + // Racing writers may briefly exceed the cap; the bound is a budget guard, not an invariant, + // and rejecting a predicate is never worth a lock on the extraction hot path. + if (_established.Count >= _maximumSize) + return predicate!; + + return _established.GetOrAdd(canonical, predicate!.Trim()); + } + + /// The established relations, ordered so an injected prompt is reproducible. + public IReadOnlyList Snapshot() => + _established.Values.OrderBy(value => value, StringComparer.Ordinal).ToArray(); +} diff --git a/src/AgentMemory.Core/Memory/MemoryRelationLexicon.cs b/src/AgentMemory.Core/Memory/MemoryRelationLexicon.cs new file mode 100644 index 00000000..7cae2069 --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryRelationLexicon.cs @@ -0,0 +1,308 @@ +using System.Collections.Frozen; + +namespace AgentMemory.Core.Memory; + +/// +/// Resolves the verbs a question uses onto the canonical predicates a graph stores. +/// +/// +/// +/// J2.1. Predicate expansion makes one relation complete, but it can only expand predicates +/// that similarity already surfaced in the top-K. A question naming several relations +/// ("did I buy, assemble, sell, or fix") therefore reaches only whichever of them retrieval happened +/// to nominate. This supplies the relations from the question instead. +/// +/// +/// Read-side only, and that asymmetry is the safety argument. Clustering predicates over stored +/// facts was rejected because merging bought onto sold corrupts meaning irreversibly. A +/// wrong entry here costs precision on one query and can never alter a stored fact. Fuzzy is +/// unacceptable at write time and tolerable at read time. +/// +/// +/// The authored direction is canonical → surface forms; the lookup index is derived, so +/// the two can never disagree. Irregular forms are listed explicitly rather than inferred, and +/// suffix stripping is only a fallback for forms the table does not list. +/// +/// +internal sealed class MemoryRelationLexicon +{ + /// Longest multi-word surface form, so the harvester knows its window. + private const int MaximumPhraseWords = 4; + + /// + /// Words that mark a sentence as being about the owner of the memory rather than an instruction. + /// + /// + /// Deliberately narrow. Second person is excluded because "can you tell me…" is the commonest + /// assistant-request opener there is, and admitting it would reopen the exact hole this closes. + /// + private static readonly FrozenSet FirstPersonMarkers = + new[] { "i", "me", "my", "mine", "myself", "we", "us", "our", "ours" } + .ToFrozenSet(StringComparer.Ordinal); + + /// + /// Words that mark a sentence as a question rather than an instruction. + /// + /// + /// Needed because a memory question need not mention its owner: "How many babies were born to + /// friends and family" is about the user's history and contains no first person at all. It is + /// also the one question predicate expansion is measured to flip, so a gate that dropped it would + /// have traded away the only proven win in this track. + /// + private static readonly FrozenSet Interrogatives = + new[] + { + "what", "when", "where", "who", "whom", "whose", "which", "how", "why", + "did", "do", "does", "was", "were", "is", "are", "has", "have", "had", "can", "could" + }.ToFrozenSet(StringComparer.Ordinal); + + private readonly FrozenDictionary _surfaceToCanonical; + private readonly FrozenDictionary _canonicalToStoredForms; + private readonly FrozenSet _canonical; + private readonly FrozenSet _queryStopForms; + + private MemoryRelationLexicon( + FrozenDictionary surfaceToCanonical, + FrozenDictionary canonicalToStoredForms, + FrozenSet canonical, + FrozenSet queryStopForms, + IReadOnlyList ambiguousSurfaceForms) + { + _queryStopForms = queryStopForms; + _surfaceToCanonical = surfaceToCanonical; + _canonicalToStoredForms = canonicalToStoredForms; + _canonical = canonical; + AmbiguousSurfaceForms = ambiguousSurfaceForms; + } + + internal static MemoryRelationLexicon Default { get; } = Build(MemoryRelationSeedTable.Table); + + /// + /// Surface forms that were claimed by more than one canonical relation and therefore dropped. + /// + /// + /// Exposed rather than silently discarded: a duplicate in a hand-authored table is an authoring + /// mistake, and a test asserts this is empty for the shipped table. Dropping keeps the runtime + /// safe; exposing keeps the mistake visible. + /// + internal IReadOnlyList AmbiguousSurfaceForms { get; } + + internal IReadOnlyCollection CanonicalRelations => _canonical; + + /// Resolves one surface form, or null when the table does not know it. + /// + /// A null is load-bearing: it is what lets the caller fall back to today's top-K-derived + /// predicates, which is what makes this incapable of being worse than current behaviour. + /// + internal string? Resolve(string? surfaceForm) + { + var normalized = MemoryTripleCanonicalizer.Canonical(surfaceForm); + if (normalized.Length == 0 || _queryStopForms.Contains(normalized)) + return null; + if (_surfaceToCanonical.TryGetValue(normalized, out var canonical)) + return canonical; + + // Fallback only. Listed irregulars have already matched above. + var stemmed = Stem(normalized); + if (stemmed is null) + return null; + + // The stem is re-checked against the stop list, not just the raw form. Without this a + // suppressed bare verb is handed straight back through its own inflections - "works" and + // "working" both stem to a suppressed "work" - which silently defeats every suppression + // decision in the vocabulary rather than only the one being read. + if (_queryStopForms.Contains(stemmed)) + return null; + + return _surfaceToCanonical.TryGetValue(stemmed, out var stemMatch) ? stemMatch : null; + } + + + /// + /// Whether a stored predicate key is a form the vocabulary knows. + /// + /// + /// Deliberately not . That method answers a query-side question and rejects + /// stop forms on purpose, so a question mentioning "is" does not expand into the whole graph. + /// Those same forms are perfectly legitimate stored predicates — the measured graph holds + /// has with 1,583 facts and is with 1,118 — and scoring storage coverage with the + /// query-side method reports them as unknown vocabulary when they are nothing of the kind. + /// + /// Written for J1.5 gate 1 after exactly that mistake made the gate read 81.5% when the genuine + /// gap was far smaller. The two questions are different and need different methods; the fix is + /// not to make the stop forms resolvable, which would defeat the suppression they exist for. + /// + /// + internal bool IsKnownStoredForm(string? predicateKey) + { + var normalized = MemoryTripleCanonicalizer.Canonical(predicateKey); + if (normalized.Length == 0) + return false; + if (_surfaceToCanonical.ContainsKey(normalized) || _canonical.Contains(normalized)) + return true; + + var stemmed = Stem(normalized); + return stemmed is not null && + (_surfaceToCanonical.ContainsKey(stemmed) || _canonical.Contains(stemmed)); + } + + /// + /// Every form of a relation that could appear as a stored predicate_key, including itself. + /// + /// + /// The write-side canonicalizer folds case and separators but deliberately never morphology, so + /// one relation is stored under several keys: the measured graph holds planned with 839 + /// facts and plans with 14 as separate keys. Expanding on the canonical name alone would + /// silently miss the smaller bucket - precisely the completeness failure expansion exists to + /// prevent. An unknown relation yields just itself, so callers need no special case. + /// + internal IReadOnlyList StoredFormsOf(string? relation) + { + var canonical = MemoryTripleCanonicalizer.Canonical(relation); + if (canonical.Length == 0) + return []; + return _canonicalToStoredForms.TryGetValue(canonical, out var forms) + ? forms + : [canonical]; + } + + /// + /// Harvests every relation a question names, distinct and in order of first appearance. + /// + internal IReadOnlyList ResolveQuestion(string? question) + { + if (string.IsNullOrWhiteSpace(question)) + return []; + + // Tokenized on non-letters rather than through the predicate canonicalizer, which folds + // separators but leaves sentence punctuation intact: "buy," would then never match "buy". + var words = new string(question + .Select(character => char.IsLetter(character) ? char.ToLowerInvariant(character) : ' ') + .ToArray()) + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + return []; + + // Reject the imperative-instruction shape. Two independent reviews measured the same bound: + // per-form suppression cannot reach it, because `create`, `build`, `save`, `read` and `start` + // are all legitimate relations that cannot be deleted, yet "Create a summary" is not a + // question about anyone's past. It is a property of the sentence, not of any single verb, + // which is why no amount of per-form editing reached it. + // + // The discriminator is mood, NOT first person. A first-person test was written first and + // measured against the benchmark before being trusted: it would have blocked "How many babies + // were born to friends and family members" - a question with no first-person marker at all, + // and the single question that predicate expansion is proven to flip. An imperative is a bare + // verb with no subject, so the test is that the sentence OPENS on a relation verb while + // carrying neither an interrogative nor a first-person marker. + if (Resolve(words[0]) is not null && + !words.Any(FirstPersonMarkers.Contains) && + !words.Any(Interrogatives.Contains)) + { + return []; + } + + var resolved = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + for (var index = 0; index < words.Length; index++) + { + // Longest phrase first: "is interested in" must win over "is", or a multi-word relation + // can never be reached. + for (var length = Math.Min(MaximumPhraseWords, words.Length - index); length >= 1; length--) + { + var phrase = string.Join(' ', words, index, length); + if (Resolve(phrase) is not { } canonical) + continue; + if (seen.Add(canonical)) + resolved.Add(canonical); + index += length - 1; + break; + } + } + + return resolved; + } + + private static MemoryRelationLexicon Build( + IReadOnlyDictionary table) + { + var surfaceToCanonical = new Dictionary(StringComparer.Ordinal); + var ambiguous = new SortedSet(StringComparer.Ordinal); + + foreach (var (canonicalRaw, surfaceForms) in table) + { + var canonical = MemoryTripleCanonicalizer.Canonical(canonicalRaw); + // A relation always resolves to itself, so the table never has to repeat its own name. + foreach (var surface in surfaceForms.Append(canonical)) + { + var key = MemoryTripleCanonicalizer.Canonical(surface); + if (key.Length == 0) + continue; + if (surfaceToCanonical.TryGetValue(key, out var existing) && + !string.Equals(existing, canonical, StringComparison.Ordinal)) + { + ambiguous.Add(key); + continue; + } + + surfaceToCanonical[key] = canonical; + } + } + + // A form claimed by two relations is removed outright rather than awarded to whichever was + // authored first, which would make the result depend on table order. + foreach (var key in ambiguous) + surfaceToCanonical.Remove(key); + + // The inverse index is DERIVED from the surviving forward map, never authored separately, so + // the two directions cannot disagree and a dropped ambiguous form stays dropped in both. + var canonicalToStoredForms = surfaceToCanonical + .GroupBy(pair => pair.Value, StringComparer.Ordinal) + .ToFrozenDictionary( + group => group.Key, + group => group.Select(pair => pair.Key) + .OrderBy(form => form, StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + return new MemoryRelationLexicon( + surfaceToCanonical.ToFrozenDictionary(StringComparer.Ordinal), + canonicalToStoredForms, + table.Keys.Select(MemoryTripleCanonicalizer.Canonical) + .ToFrozenSet(StringComparer.Ordinal), + MemoryRelationSeedTable.QueryStopForms + .Select(MemoryTripleCanonicalizer.Canonical) + .ToFrozenSet(StringComparer.Ordinal), + [.. ambiguous]); + } + + /// + /// Light, deterministic suffix stripping for forms the table does not list. + /// + /// + /// The length guards are not cosmetic. is is the single most common predicate in the + /// measured graph - 1,213 facts, 26% of all of them - and naive -s stripping would reduce + /// it to i and lose a quarter of the graph. + /// + private static string? Stem(string value) + { + if (value.Length <= 4) + return null; + + if (value.EndsWith("ing", StringComparison.Ordinal) && value.Length > 6) + return value[..^3]; + if (value.EndsWith("ed", StringComparison.Ordinal) && value.Length > 5) + return value[..^2]; + if (value.EndsWith("es", StringComparison.Ordinal) && value.Length > 5) + return value[..^2]; + if (value.EndsWith('s') && + !value.EndsWith("ss", StringComparison.Ordinal) && + value.Length > 4) + { + return value[..^1]; + } + + return null; + } +} diff --git a/src/AgentMemory.Core/Memory/MemoryRelationSeedTable.cs b/src/AgentMemory.Core/Memory/MemoryRelationSeedTable.cs new file mode 100644 index 00000000..a5f2204f --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryRelationSeedTable.cs @@ -0,0 +1,86 @@ +namespace AgentMemory.Core.Memory; + +/// +/// The reviewed canonical → surface forms table behind . +/// +/// +/// +/// Authored in one direction only. The lookup index is derived at load, so the extraction vocabulary +/// and the query lexicon cannot drift apart. +/// +/// +/// Canonical names are written in stored predicate_key form - lowercase, separators folded to +/// single spaces - because resolution that produced keys the graph cannot match would be worthless. +/// +/// +/// Grounded in measurement, not intuition. The canonical set covers the predicates actually +/// observed in an extracted graph (4,659 facts over 10 owners), including the frequent forms the +/// extractor invented outside the offered vocabulary - wants, is interested in, +/// uses, asked about, requested, considered. Inflectional variants are the +/// bulk of that tail (plans beside planned, was beside is), and they are +/// resolved here rather than by enlarging the write-side vocabulary. +/// +/// +/// Deliberately excluded: genuinely ambiguous forms. got could be bought or +/// received; a form claimed by two relations is dropped at load and reported, so an authoring +/// mistake is visible rather than silently resolved in table order. +/// +/// +/// The data itself lives in relation-vocabulary.json, embedded in this assembly. See the +/// README beside it for where each relation came from, under which licence, and what filtering was +/// applied — provenance ships with the artifact because it draws on schema.org and Wikidata. +/// +/// +internal static class MemoryRelationSeedTable +{ + /// + /// Relations still resolvable at query time but no longer offered to extraction. + /// + /// + /// Retiring a relation must stop new writes without making facts already stored under it + /// unreachable - a graph does not rewrite itself when the vocabulary changes. + /// + /// Two mechanisms, and knowing which is in use matters. Demotion removes the key and + /// keeps its name as a surface form of the relation it merged into, so the old facts are reached + /// through the survivor; this is what finished and welcomed did. Flagging + /// keeps the key but excludes it from the extraction vocabulary, which is what the filter over + /// this set does. Demotion is preferred where a survivor exists, because it leaves one name for + /// one meaning; flagging is for a relation being withdrawn with nothing to merge into. + /// + /// + internal static IReadOnlySet RetiredRelations { get; } = + RelationVocabularyDocument.Load().Retired.ToHashSet(StringComparer.Ordinal); + + /// + /// Content hash of the query lexicon, recorded in run reports. + /// + /// + /// Surface forms never enter a prompt, but they change what a question resolves to and therefore + /// what is retrieved, so a run measured under a different table is not comparable to one measured + /// under this one. + /// + internal static string Fingerprint => MemoryVocabularyFingerprint.OfTable(Table); + + /// + /// The reviewed relation table, loaded from the embedded JSON artifact. + /// + /// + /// Authored as JSON rather than C# so it is diffable in review, can be regenerated by the unifier, + /// and can carry the per-relation source and licence provenance that a C# array cannot express - + /// this vocabulary draws on schema.org and Wikidata and ships inside a package. + /// + internal static IReadOnlyDictionary Table { get; } = + RelationVocabularyDocument.Load().Canonical.ToDictionary( + entry => entry.Key, + entry => entry.Value.SurfaceForms.Concat(entry.Value.StoredOnly) + .Distinct(StringComparer.Ordinal).ToArray(), + StringComparer.Ordinal); + + /// + /// Forms that expansion may fetch but a question must never resolve to. + /// + internal static IReadOnlySet QueryStopForms { get; } = + RelationVocabularyDocument.Load().Canonical + .SelectMany(entry => entry.Value.StoredOnly) + .ToHashSet(StringComparer.Ordinal); +} diff --git a/src/AgentMemory.Core/Memory/MemoryTripleCanonicalizer.cs b/src/AgentMemory.Core/Memory/MemoryTripleCanonicalizer.cs new file mode 100644 index 00000000..765b726e --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryTripleCanonicalizer.cs @@ -0,0 +1,111 @@ +using System.Text; + +namespace AgentMemory.Core.Memory; + +/// +/// Canonical forms for the fact triple's subject, predicate and object. +/// +/// +/// +/// Facts already deduplicate at write time — the repository MERGEs on +/// {subject, predicate, object, owner_key} — but that key uses the raw strings, so trivial +/// surface differences defeat it. Measured on a real extracted graph: +/// "Ava and Lily"/were_born_in/"April" and "Ava and Lily"/were born in/"April" became +/// two nodes, as did "User"/is planning and "user"/is planning. That graph held 575 +/// facts across 407 distinct predicates — 1.41 facts per predicate — which also makes any +/// query keyed on a relation impossible. +/// +/// +/// Canonicalization is therefore not a new deduplication mechanism; it repairs the one that exists. +/// +/// +/// Deterministic only, by design. No embedding or fuzzy similarity is used to fold one +/// predicate into another. Relations such as bought and sold are semantically adjacent +/// and opposite; merging them would silently corrupt meaning in a way ordinary tests would not +/// catch. Only differences that cannot change meaning are collapsed: surrounding whitespace, letter +/// case, and word separators. +/// +/// +/// Compute once, in C#, at write time. Never recompute this in Cypher: .NET's +/// and Cypher's toLower() disagree on U+0130 (Turkish +/// dotted capital I), so a value canonicalized on one side and matched on the other would produce two +/// keys for one relation — reintroducing the exact fragmentation this removes. +/// +/// +public static class MemoryTripleCanonicalizer +{ + /// + /// Returns the canonical form used for identity: trimmed, lower-cased invariantly, with word + /// separators unified and runs of whitespace collapsed. + /// + /// + /// The original text is always retained separately; this value is for matching, never display. + /// + /// + /// Canonical form for a value — a subject or object. Trims, lower-cases invariantly and + /// collapses whitespace, but never rewrites punctuation. + /// + /// + /// Separator folding is correct for predicates, where was_born and was born are one + /// relation under two naming conventions. It is corrupting for values: -5 and + /// 5 would fold into one fact, silently merging a quantity with its negation. Values carry + /// meaning in their punctuation; identifiers do not. + /// + public static string CanonicalValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var builder = new StringBuilder(value.Length); + var pendingSpace = false; + foreach (var character in value.ToLowerInvariant()) + { + if (char.IsWhiteSpace(character)) + { + pendingSpace = builder.Length > 0; + continue; + } + + if (pendingSpace) + { + builder.Append(' '); + pendingSpace = false; + } + + builder.Append(character); + } + + return builder.ToString(); + } + + /// Canonical form for a predicate, folding word separators. + public static string Canonical(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var lowered = value.ToLowerInvariant(); + var builder = new StringBuilder(lowered.Length); + var pendingSeparator = false; + foreach (var character in lowered) + { + // '_' and '-' are word separators in extracted predicates ("was_born" / "was born"), + // never meaningful punctuation, so they normalize to the same break as whitespace. + if (char.IsWhiteSpace(character) || character is '_' or '-') + { + pendingSeparator = builder.Length > 0; + continue; + } + + if (pendingSeparator) + { + builder.Append(' '); + pendingSeparator = false; + } + + builder.Append(character); + } + + return builder.ToString(); + } +} diff --git a/src/AgentMemory.Core/Memory/MemoryVocabularyFingerprint.cs b/src/AgentMemory.Core/Memory/MemoryVocabularyFingerprint.cs new file mode 100644 index 00000000..a16933ae --- /dev/null +++ b/src/AgentMemory.Core/Memory/MemoryVocabularyFingerprint.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AgentMemory.Core.Memory; + +/// +/// A stable content hash of a relation vocabulary or lexicon. +/// +/// +/// +/// The extraction vocabulary decides what is stored and the query lexicon decides what is +/// retrieved, so two runs made under different tables are not comparable. Recording the hash is +/// what lets a report say which table produced a given graph, and it is the same class of defect as +/// retrieval flags that were absent from a run fingerprint: a setting that changes the outcome but +/// leaves no trace in the artifact. +/// +/// +/// Order-independent by construction, because a vocabulary is a set: reordering entries is an +/// authoring change with no meaning, and if it altered the hash then every cosmetic edit would +/// invalidate comparisons for nothing. +/// +/// +internal static class MemoryVocabularyFingerprint +{ + /// Hashes a flat set of relation names. + internal static string Of(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + var normalized = entries + .Select(MemoryTripleCanonicalizer.Canonical) + .Where(entry => entry.Length > 0) + .Distinct(StringComparer.Ordinal) + .OrderBy(entry => entry, StringComparer.Ordinal); + return Hash(string.Join('\n', normalized)); + } + + /// Hashes a canonical-to-surface-forms table. + /// + /// Each relation is hashed together with its own forms, so moving a surface form from one relation + /// to another changes the hash even though the entry count is unchanged. A hash over counts, or + /// over the two sides separately, would miss exactly that. + /// + internal static string OfTable(IReadOnlyDictionary table) + { + ArgumentNullException.ThrowIfNull(table); + var lines = table + .Select(entry => + { + var canonical = MemoryTripleCanonicalizer.Canonical(entry.Key); + var forms = (entry.Value ?? []) + .Select(MemoryTripleCanonicalizer.Canonical) + .Where(form => form.Length > 0) + .Distinct(StringComparer.Ordinal) + .OrderBy(form => form, StringComparer.Ordinal); + return $"{canonical}>{string.Join(',', forms)}"; + }) + .OrderBy(line => line, StringComparer.Ordinal); + return Hash(string.Join('\n', lines)); + } + + // ToHexString().ToLowerInvariant() rather than ToHexStringLower(): Core multi-targets down to + // net8.0, where the lowercase overload does not exist. + private static string Hash(string content) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))) + .ToLowerInvariant(); +} diff --git a/src/AgentMemory.Core/Memory/README.md b/src/AgentMemory.Core/Memory/README.md new file mode 100644 index 00000000..98f3e28b --- /dev/null +++ b/src/AgentMemory.Core/Memory/README.md @@ -0,0 +1,138 @@ +# Relation vocabulary + +`relation-vocabulary.json` is the single reviewed source for **which relations this library knows**. +It is embedded into `AgentMemory.Core` and parsed once at first use. + +## Why it exists + +Extraction previously invented a relation name per phrasing. One measured graph held **700 facts under +421 distinct predicates**, with a single birth arriving as `was born`, `was born in`, `were born in`, +`had` and `welcomed`. Offering a controlled vocabulary at extraction time normalises this at the point +of writing, which is the only place it can be done safely — merging predicates *after* the fact would +eventually merge `bought` onto `sold` and invert the meaning. + +## The one-table rule + +``` +canonical relation ──► surface forms + │ │ + │ └── read side: the query lexicon, derived at load + └── write side: the extraction vocabulary, injected into the extraction prompt +``` + +**Keys are the extraction vocabulary. The inverse index is the query lexicon, and it is derived, never +authored.** Both sides come from this one file, and tests enforce that they agree in both directions. + +This rule exists because it was broken. The two sides were briefly maintained as separate lists and +drifted: **13 relations became resolvable at query time that the extractor was never offered**, so the +graph could not contain them however well retrieval worked. `assembled` was one of them — which is why +assembly was filed under `completed`, and why a benchmark question about furniture bought, assembled, +sold or fixed could not be answered from the graph at all. + +**Only keys cross over.** Surface forms stay read-side: they never enter an extraction prompt, where +they would cost tokens on every call and invite the extractor to choose inconsistently between `buy`, +`buys` and `purchased` — the opposite of the consolidation the vocabulary exists to produce. + +## Where the data comes from + +| source | licence | what it contributed | fetched | +|---|---|---|---| +| [schema.org Action hierarchy](https://schema.org/Action) | CC BY-SA 3.0 | canonical keys for the **event** family — 34 relations, incl. the whole trade/transfer group | 2026-08-08 | +| [Wikidata](https://query.wikidata.org/) property aliases (`skos:altLabel`, SPARQL) | CC0 | surface forms for the **state** family — 6 relations, 141 alias rows over 10 targeted properties | 2026-08-08 | +| [PARAREL](https://github.com/yanaiela/pararel) paraphrase patterns | MIT | verb phrases per relation — **4 relations**; best-shaped source, its patterns *are* verb phrases (`is originally from` → `was born`, `passed away in` → `died`) | 2026-08-08 | +| [Rel2Text](https://github.com/kasnerz/rel2text) crowd verbalisations | Apache-2.0 | delexicalised phrases, `state==ok` rows only — **7 relations** | 2026-08-08 | +| [FewRel `pid2name.json`](https://github.com/thunlp/FewRel) | MIT | **surveyed, near-zero yield** — see below | 2026-08-08 | +| hand-authored | — | **60 relations** — the majority, and the opposite of what the plan assumed. No surveyed source covers domestic life | — | + +### Why the two families are seeded differently + +**schema.org Actions model events; a memory graph stores events *and* states.** Measured against the +top-50 predicates of a real graph, schema.org covers **38.0% by relation and 40.7% by fact mass**, and +**44.7% of fact mass has no Action mapping at all**. The unmapped set is not a tail of oddities — it is +the state/identity backbone: `is`, `is a`, `owns`, `has`, `works at`, `knows`, `belongs to`. The single +most common predicate, `is`, is **26% of every fact in the graph** and schema.org has no Action for it, +because it is the copula. + +So the state family is seeded from schema.org and Wikidata **properties** instead, which is where +relations of that shape actually live: `P108 employer`, `P551 residence`, `P1830 owner of`, `P26 spouse`. + +### Why FewRel contributed almost nothing + +Its 744 Wikidata properties were matched against our relations and produced **28 apparent hits of which +all but one are spurious substring collisions** — `has pet`, `has melody`, `has grammatical mood`, +`has superpartner`, `has anatomical branch`, and `studied by` matching `died`. The one genuine match is +`P2283 uses`. + +This is the same finding as the wider dataset survey, now verified directly: every relation-extraction +corpus reviewed (TACRED, DocRED, REDFM, FewRel, T-REx, NYT10, Google-RE, Wiki-NRE) is **encyclopedic or +newswire**. Their inventories describe grammar, physics, anatomy and geography — not what a person +bought, planned or fixed. **The hand-authored delta is therefore the largest component, which is the +opposite of what was originally assumed.** + +### Sources deliberately excluded + +| source | reason | +|---|---| +| Wiki-NRE, Google-RE, NYT10 | no licence stated anywhere | +| REBEL | README self-contradicts on non-commercial use | +| TACRED | paid ($25 for non-members) | + +## What was done to the data + +1. **schema.org** — Action hierarchy fetched in full (~110 descendant types) and mapped by hand to our + relations. Each mapping is recorded per relation in `sources` as `schema.org:`. +2. **Wikidata** — English `skos:altLabel` aliases fetched by SPARQL for ten targeted properties. + Filtered to **verb-like forms only**: at most three words, letters and spaces only, and not starting + with `of`. A question contains "worked at"; it does not contain "alma mater" or "of employer". +3. **Provenance is earned, not asserted.** A `wikidata:` source is recorded only when that property + actually contributed a form not already present. An early build claimed Wikidata provenance while + containing none of its data, because a file-path error was silently swallowed; the generator now + fails closed instead. +4. **Determinism** — keys and forms are sorted; regenerating from unchanged inputs is byte-identical. + A vocabulary that varied per run would reintroduce the non-determinism it exists to remove. + +## Invariants, enforced by tests + +These fail CI rather than throwing inside a consumer's process on first use: + +- every relation declares at least one source, and a `family` of `event` or `state` +- canonical keys are already in stored `predicate_key` form, so resolution produces keys the graph can match +- no surface form is claimed by two relations; ambiguity is dropped, never guessed +- no surface form collides with a *different* relation's canonical key +- opposing pairs are both present — `bought`/`sold`, `likes`/`dislikes`, `borrowed`/`lent`, `gave`/`received` +- everything offered to extraction resolves at query time, and everything resolvable is offered, unless + explicitly listed in `retired` + +## `retired` + +Currently **`finished`, `welcomed`**. A graph does not rewrite itself when a vocabulary +changes, so removing a key must stop new writes without making facts already stored under it +unreachable. + +Two mechanisms. **Demotion** removes the key and keeps its name as a surface form of the relation it +merged into, so old facts are reached through the survivor — this is what `finished` (into `completed`) +and `welcomed` (into `was born`) did. **Flagging** keeps the key and excludes it from the extraction +vocabulary. Demotion is preferred where a survivor exists, because it leaves one name for one meaning. + +## `storedOnly` + +32 forms across the table. These are fetched by expansion but never trigger retrieval from a +question. Two groups: the copulas (`is`, `was`, `had`, …), which appear in nearly every question and +would expand `is` — 26% of the measured graph — on all of them; and bare verbs that double as +assistant boilerplate (`plan`, `give`, `tell`, `know`, `need`, `want`, `find`, `work`, `order`, `own`, +`used`, `change`, `go to`). Each is re-admitted through a question-anchored phrase such as +`do i work` / `did i work`, so recall survives while the boilerplate stays silent. Bare `plan` was the +worst case: `planned` is the largest measured bucket at 839 facts, so *"which phone plan am I on?"* +could displace most of a retrieval budget on a question that had nothing to do with plans. + +## Known limitations + +- **Mined aliases are noisy.** Wikidata contributed nouns as well as verbs — `works at` acquired + `location` and `organisation`, `belongs to` acquired `club`. A question rarely contains these, and a + wrong surface form is not harmless: resolution expands a **whole relation** into a fixed retrieval + budget, so one bad alias can displace correct items. These are under review. +- **Size.** 101 relations against a ~400 reviewability ceiling, with 619 surface forms. The ceiling applies to *keys*, which cost + prompt tokens on every extraction call; surface forms are read-side and far cheaper. +- **Changing this file changes what gets extracted**, and only takes effect on a fresh build of the + memory graph. Its content hash is recorded in evaluation reports so two graphs built under different + vocabularies are never compared as though equivalent. diff --git a/src/AgentMemory.Core/Memory/RelationVocabularyDocument.cs b/src/AgentMemory.Core/Memory/RelationVocabularyDocument.cs new file mode 100644 index 00000000..334660e1 --- /dev/null +++ b/src/AgentMemory.Core/Memory/RelationVocabularyDocument.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AgentMemory.Core.Memory; + +/// One canonical relation, its provenance, and the forms a question may use for it. +internal sealed class RelationVocabularyEntry +{ + /// event or state. + /// + /// The split is not decorative. schema.org models actions and has no vocabulary for the + /// state/identity relations that carry 44.7% of this corpus's fact mass, so the two families are + /// seeded from different sources and reviewed against different expectations. + /// + [JsonPropertyName("family")] + public string Family { get; init; } = "event"; + + /// Where the relation came from, e.g. schema.org:BuyAction, wikidata:P108. + /// Licence provenance ships with the package; it is part of the artifact, not a footnote. + [JsonPropertyName("sources")] + public IReadOnlyList Sources { get; init; } = []; + + [JsonPropertyName("surfaceForms")] + public IReadOnlyList SurfaceForms { get; init; } = []; + + /// + /// Forms that are expansion targets but must never trigger retrieval from a question. + /// + /// + /// Surface forms carry two jobs at once: they decide what a question retrieves, and they are the + /// stored predicate keys expansion fetches. The copulas need the second and are actively harmful + /// in the first — was, had and have appear in almost every question a person + /// asks, and each one would expand is, which is 26% of the measured graph, exhausting the + /// shared budget before any correct relation is reached. Deleting them is not an option either, + /// because facts really are stored under them. + /// + [JsonPropertyName("storedOnly")] + public IReadOnlyList StoredOnly { get; init; } = []; +} + +internal sealed class RelationVocabularyDocument +{ + private static readonly Lazy Cached = new(Parse); + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("retired")] + public IReadOnlyList Retired { get; init; } = []; + + /// + /// Relations the extractor may write but a question must never expand wholesale, each with its + /// reason. + /// + /// + /// They remain reachable by top-K similarity; what they are exempt from is expansion. The + /// copulas would otherwise flood a fixed budget with almost no meaning per fact — the J1.6 build + /// measured `has` absorbing 518 facts. Declared explicitly rather than left implicit in + /// storedOnly, so the asymmetry is a recorded decision instead of an accident. + /// + [JsonPropertyName("expansionExempt")] + public IReadOnlyDictionary ExpansionExempt { get; init; } = + new Dictionary(StringComparer.Ordinal); + + [JsonPropertyName("canonical")] + public IReadOnlyDictionary Canonical { get; init; } = + new Dictionary(StringComparer.Ordinal); + + /// The reviewed vocabulary, parsed once. + /// + /// Embedded rather than read from disk: this ships as a NuGet package, so a file dependency would + /// be a deployment hazard and would add a startup I/O failure mode to a library. Parsed once into + /// immutable structures, after which lookups are O(1) forever. + /// + internal static RelationVocabularyDocument Load() => Cached.Value; + + private static RelationVocabularyDocument Parse() + { + const string ResourceName = "AgentMemory.Core.Memory.relation-vocabulary.json"; + var assembly = typeof(RelationVocabularyDocument).Assembly; + using var stream = assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"The relation vocabulary resource '{ResourceName}' is missing from {assembly.GetName().Name}. " + + "It must be embedded, not shipped alongside."); + + var document = JsonSerializer.Deserialize( + stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("The relation vocabulary resource is empty."); + + if (document.Canonical.Count == 0) + throw new InvalidOperationException("The relation vocabulary declares no relations."); + + return document; + } +} diff --git a/src/AgentMemory.Core/Memory/relation-vocabulary.json b/src/AgentMemory.Core/Memory/relation-vocabulary.json new file mode 100644 index 00000000..62adb79c --- /dev/null +++ b/src/AgentMemory.Core/Memory/relation-vocabulary.json @@ -0,0 +1,1660 @@ +{ + "schemaVersion": 1, + "about": "Canonical relation -> surface forms. Keys are the extraction vocabulary; the query lexicon is the inverse index, derived at load and never authored, so the two cannot drift apart. Only keys reach the extraction prompt; surface forms are read-side only.", + "sourceLicences": { + "schema.org": "CC BY-SA 3.0 - Action hierarchy, seeds the event family", + "wikidata": "CC0 - property aliases via SPARQL skos:altLabel, seeds the state family", + "fewrel": "MIT - pid2name.json surveyed; 744 relations, one genuine domain match (P2283 uses)", + "hand-authored": "domestic-life relations no surveyed source provides (assembled, fixed)" + }, + "excludedSources": { + "wiki-nre": "no licence", + "google-re": "no licence", + "nyt10": "no licence", + "rebel": "README self-contradicts on non-commercial", + "tacred": "paid" + }, + "retired": [ + "finished" + ], + "canonical": { + "adopted": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "adopt", + "adopting", + "adopts" + ] + }, + "allergic to": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "allergy to", + "is allergic to" + ] + }, + "applied for": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "applied", + "applied to", + "applies for", + "apply", + "apply for", + "apply to", + "applying for" + ] + }, + "arrived": { + "family": "event", + "sources": [ + "schema.org:ArriveAction" + ], + "surfaceForms": [ + "arrive", + "arrives", + "arriving", + "delivered", + "deliveries", + "delivery" + ] + }, + "asked about": { + "family": "event", + "sources": [ + "schema.org:AskAction" + ], + "surfaceForms": [ + "ask", + "ask about", + "asked", + "asking", + "asking about", + "asks", + "asks about" + ] + }, + "assembled": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "assemble", + "assembles", + "assembling", + "put together", + "puts together", + "putting together" + ] + }, + "ate": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "eat", + "eaten", + "eating", + "eats" + ] + }, + "attended": { + "family": "event", + "sources": [ + "wikidata:P1344", + "rel2text" + ], + "surfaceForms": [ + "attend", + "attending", + "attends", + "compete in", + "competed in", + "competes in", + "competing in", + "did i take part", + "do i take part", + "i take part", + "is a student at", + "participate", + "participate in", + "participated", + "participated in", + "participated in the", + "participates", + "participates in", + "participating", + "participating in", + "present at", + "take part", + "take part in", + "takes part", + "taking part", + "taking part in", + "took part", + "took part in", + "took part in the", + "was educated at" + ], + "storedOnly": [ + "played in the" + ] + }, + "avoids": { + "family": "state", + "sources": [ + "schema.org:IgnoreAction" + ], + "surfaceForms": [ + "avoid", + "avoided", + "avoiding" + ] + }, + "believes": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "believe", + "believed", + "believing" + ] + }, + "belongs to": { + "family": "state", + "sources": [ + "wikidata:P463", + "pararel", + "rel2text" + ], + "surfaceForms": [ + "belong to", + "belonged to", + "is a member of", + "is a member of the", + "is affiliated with", + "is member of", + "join", + "joined", + "joining", + "joins", + "member of", + "played for the" + ] + }, + "booked": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "book a", + "book an", + "book me", + "book the", + "book us", + "booking", + "bookings", + "reservation", + "reservations", + "reserve", + "reserved", + "reserves", + "reserving" + ] + }, + "borrowed": { + "family": "event", + "sources": [ + "schema.org:BorrowAction" + ], + "surfaceForms": [ + "borrow", + "borrowing", + "borrows" + ] + }, + "bought": { + "family": "event", + "sources": [ + "schema.org:BuyAction" + ], + "surfaceForms": [ + "buy", + "buying", + "buys", + "did i order", + "do i order", + "ordered", + "ordering", + "orders", + "purchase", + "purchased", + "purchases", + "purchasing" + ], + "storedOnly": [ + "order" + ] + }, + "broke": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "break", + "breaking", + "breaks", + "broke down", + "broken" + ] + }, + "called": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "call", + "calling", + "calls", + "phoned", + "rang" + ] + }, + "cancelled": { + "family": "event", + "sources": [ + "schema.org:CancelAction" + ], + "surfaceForms": [ + "cancel", + "canceled", + "canceling", + "cancelling", + "cancels" + ] + }, + "changed to": { + "family": "event", + "sources": [ + "schema.org:ReplaceAction" + ], + "surfaceForms": [ + "change to", + "changed", + "changes to", + "changing to", + "did i change" + ], + "storedOnly": [ + "change" + ] + }, + "cleaned": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "clean" + ] + }, + "commutes": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "commute", + "commuted", + "commuting" + ] + }, + "completed": { + "family": "event", + "sources": [ + "schema.org:AchieveAction" + ], + "surfaceForms": [ + "complete", + "completes", + "completing", + "finish", + "finished", + "finishes", + "finishing" + ] + }, + "considered": { + "family": "event", + "sources": [ + "schema.org:AssessAction" + ], + "surfaceForms": [ + "consider", + "considering", + "considers", + "is considering" + ] + }, + "cooked": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "bake", + "baked", + "bakes", + "baking", + "cook", + "cooking", + "cooks" + ] + }, + "costs": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "cost", + "cost me", + "priced at" + ] + }, + "created": { + "family": "event", + "sources": [ + "schema.org:CreateAction" + ], + "surfaceForms": [ + "build", + "builds", + "built", + "create", + "creates", + "creating" + ] + }, + "decided": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "choose", + "chooses", + "choosing", + "chose", + "decide", + "decides", + "deciding" + ] + }, + "decreased to": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "decrease", + "decrease to", + "decreased", + "decreases", + "decreases to", + "decreasing", + "drop to", + "dropped to", + "drops to", + "fell to", + "go down", + "goes down", + "going down", + "gone down", + "went down" + ] + }, + "diagnosed with": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "diagnose with", + "diagnosed", + "was diagnosed with" + ] + }, + "died": { + "family": "event", + "sources": [ + "pararel" + ], + "surfaceForms": [ + "die", + "died at", + "died in", + "dies", + "dying", + "lost their life at", + "pass away", + "passed away", + "passed away at", + "passed away in", + "passes away", + "passing away", + "succumbed at" + ], + "storedOnly": [ + "expired at" + ] + }, + "dislikes": { + "family": "state", + "sources": [ + "schema.org:DislikeAction" + ], + "surfaceForms": [ + "dislike", + "disliked", + "disliking", + "hate", + "hated", + "hates", + "hating" + ] + }, + "divorced": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "divorce", + "divorces", + "divorcing" + ] + }, + "drank": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "drink", + "drunk" + ] + }, + "earned": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "earn", + "income", + "salary" + ] + }, + "exercised": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i work out", + "do i work out", + "exercise", + "exercises", + "exercising", + "work out", + "worked out", + "working out", + "works out" + ] + }, + "expires": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "expiration", + "expire", + "expired", + "expiring", + "expiry" + ] + }, + "feels": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "feel", + "feeling", + "felt" + ] + }, + "finds": { + "family": "event", + "sources": [ + "schema.org:FindAction" + ], + "surfaceForms": [ + "did i find", + "finding", + "found" + ], + "storedOnly": [ + "find" + ] + }, + "fixed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "fix", + "fixes", + "fixing", + "mend", + "mended", + "mending", + "mends", + "repair", + "repaired", + "repairing", + "repairs" + ] + }, + "gave": { + "family": "event", + "sources": [ + "schema.org:GiveAction" + ], + "surfaceForms": [ + "did i give", + "donate", + "donated", + "donates", + "donating", + "gifted", + "gifting" + ], + "storedOnly": [ + "give", + "gives", + "giving" + ] + }, + "has": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [], + "storedOnly": [ + "had", + "has", + "have", + "having" + ] + }, + "heard": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "hear", + "hearing", + "hears" + ] + }, + "helped": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "help", + "helping", + "helps", + "assist", + "assisted", + "assisting", + "assists" + ] + }, + "hired": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "hire", + "hiring" + ] + }, + "increased to": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "go up", + "goes up", + "going up", + "gone up", + "increase", + "increase to", + "increased", + "increases", + "increases to", + "increasing", + "rose to", + "went up" + ] + }, + "injured": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "hurt", + "hurts", + "injure", + "injures", + "injuring", + "injury", + "sprained" + ] + }, + "installed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "install", + "installing", + "installs" + ] + }, + "invested in": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "invest", + "invest in", + "invested", + "investing", + "investing in", + "invests", + "invests in" + ] + }, + "is": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [], + "storedOnly": [ + "am", + "are", + "be", + "been", + "is", + "was", + "were" + ] + }, + "is a": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [], + "storedOnly": [ + "is a", + "is an", + "was a", + "was an" + ] + }, + "is interested in": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "interested", + "interested in", + "is interested" + ] + }, + "knows": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i know", + "do i know", + "knew", + "knowing" + ], + "storedOnly": [ + "know" + ] + }, + "learned": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "learn", + "learning", + "learns", + "learnt", + "studied", + "studies", + "study", + "studying" + ] + }, + "led": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "lead", + "leading", + "leads", + "managed", + "manages", + "managing" + ] + }, + "lent": { + "family": "event", + "sources": [ + "schema.org:LendAction" + ], + "surfaceForms": [ + "lend", + "lending", + "lends", + "loaned" + ] + }, + "likes": { + "family": "state", + "sources": [ + "schema.org:LikeAction" + ], + "surfaceForms": [ + "did i like", + "do i like", + "enjoy", + "enjoyed", + "enjoys", + "liked", + "liking", + "love", + "loved", + "loves" + ], + "storedOnly": [ + "like" + ] + }, + "listened to": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "listen", + "listen to", + "listening to", + "listens to" + ] + }, + "lives in": { + "family": "state", + "sources": [ + "wikidata:P551", + "rel2text" + ], + "surfaceForms": [ + "has resided in", + "live", + "live in", + "lived in", + "lives at", + "living in", + "resided at", + "resided in", + "resident in", + "resident of", + "resides in", + "residing in", + "stays in" + ] + }, + "lost": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "lose", + "loses", + "losing", + "misplaced" + ] + }, + "married": { + "family": "event", + "sources": [ + "schema.org:MarryAction", + "wikidata:P26", + "rel2text" + ], + "surfaceForms": [ + "is married to", + "is the spouse of", + "marital partner", + "marriage partner", + "married partner", + "married to", + "marries", + "marry", + "marrying", + "spouse", + "spouses", + "wedded", + "wedded to" + ] + }, + "met": { + "family": "event", + "sources": [ + "schema.org:MeetAction" + ], + "surfaceForms": [ + "meet", + "meeting", + "meets" + ] + }, + "moved to": { + "family": "event", + "sources": [ + "schema.org:MoveAction" + ], + "surfaceForms": [ + "move", + "move to", + "moved", + "moves to", + "moving to", + "relocate to", + "relocated to", + "relocates to", + "relocating to" + ] + }, + "noticed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "notice", + "noticing", + "notices" + ] + }, + "owes": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "owe", + "owed", + "owing" + ] + }, + "owns": { + "family": "state", + "sources": [ + "wikidata:P1830", + "rel2text" + ], + "surfaceForms": [ + "did i own", + "do i own", + "is owned by", + "is the owner of", + "owned", + "owning", + "owns property", + "owns the", + "possess", + "possesses" + ], + "storedOnly": [ + "own" + ] + }, + "paid": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "paid for", + "pay", + "pay for", + "paying", + "paying for", + "pays", + "spend", + "spending", + "spends", + "spent" + ] + }, + "planned": { + "family": "event", + "sources": [ + "schema.org:PlanAction" + ], + "surfaceForms": [ + "did i plan", + "do i plan", + "plan to", + "planning", + "plans" + ], + "storedOnly": [ + "plan" + ] + }, + "played": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "play", + "playing", + "plays" + ] + }, + "prefers": { + "family": "state", + "sources": [ + "schema.org:ChooseAction" + ], + "surfaceForms": [ + "prefer", + "preferred", + "preferring" + ] + }, + "prescribed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "prescribe", + "prescribes", + "prescribing" + ] + }, + "promised": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "promise", + "promises" + ] + }, + "promoted": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "promote", + "promotes", + "promoting", + "promotion" + ] + }, + "provided": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "provide", + "providing", + "provides", + "supplied", + "supplies", + "supplying" + ] + }, + "rated": { + "family": "event", + "sources": [ + "schema.org:ReviewAction" + ], + "surfaceForms": [ + "did i rate", + "do i rate", + "rate it", + "rate my", + "rate that", + "rate the", + "rate them", + "rate this", + "rated it", + "rating" + ] + }, + "read": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "reading", + "reads" + ] + }, + "received": { + "family": "event", + "sources": [ + "schema.org:ReceiveAction" + ], + "surfaceForms": [ + "receive", + "receives", + "receiving" + ] + }, + "recommends": { + "family": "event", + "sources": [ + "schema.org:EndorseAction" + ], + "surfaceForms": [ + "recommend", + "recommended", + "recommending" + ] + }, + "related to": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "relates to" + ], + "storedOnly": [ + "related", + "related to" + ] + }, + "renewed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "renew" + ] + }, + "rented": { + "family": "event", + "sources": [ + "schema.org:RentAction" + ], + "surfaceForms": [ + "lease", + "leased", + "leases", + "rent", + "renting", + "rents" + ] + }, + "rented out": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "leased out", + "let out", + "rent out", + "renting out", + "rents out" + ] + }, + "requested": { + "family": "event", + "sources": [ + "schema.org:AskAction" + ], + "surfaceForms": [ + "ask for", + "asked for", + "asking for", + "asks for", + "request", + "requesting", + "requests" + ] + }, + "requires": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i need", + "do i need", + "needed", + "needs", + "require", + "required", + "requiring" + ], + "storedOnly": [ + "need" + ] + }, + "returned": { + "family": "event", + "sources": [ + "schema.org:ReturnAction" + ], + "surfaceForms": [ + "return", + "returning", + "returns" + ] + }, + "returned from": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "came back from", + "come back from", + "get back from", + "gets back from", + "got back from", + "return from", + "returning from", + "returns from" + ] + }, + "saved": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "save", + "saves", + "saving", + "savings" + ] + }, + "scheduled": { + "family": "event", + "sources": [ + "schema.org:ScheduleAction" + ], + "surfaceForms": [ + "appointment", + "appointments", + "schedule", + "schedules", + "scheduling" + ] + }, + "sent": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i text", + "email", + "emailed", + "emailing", + "emails", + "messaged", + "send", + "sending", + "sends", + "texted", + "texting", + "texts" + ], + "storedOnly": [ + "text" + ] + }, + "signed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "sign", + "sign up for", + "signed up for" + ] + }, + "slept": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "sleep" + ] + }, + "sold": { + "family": "event", + "sources": [ + "schema.org:SellAction" + ], + "surfaceForms": [ + "sell", + "selling", + "sells" + ] + }, + "started": { + "family": "event", + "sources": [ + "schema.org:ActivateAction" + ], + "surfaceForms": [ + "began", + "begin", + "begins", + "begun", + "start", + "starting", + "starts" + ] + }, + "stayed at": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "stay", + "stay at", + "stay in", + "stayed", + "stayed in", + "staying", + "staying at", + "stays at" + ] + }, + "stopped": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "gave up", + "give up", + "gives up", + "giving up", + "quit", + "quits", + "quitting", + "resign", + "stop", + "stopping", + "stops" + ] + }, + "subscribed to": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "subscribe to", + "subscribes to", + "subscribing to", + "subscription", + "subscriptions" + ] + }, + "threw away": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "get rid of", + "got rid of", + "threw out", + "throw away", + "throw out", + "throwing away", + "throwing out", + "thrown away", + "thrown out" + ] + }, + "told": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i tell", + "said", + "saying", + "says", + "tell her", + "tell him", + "telling", + "tells" + ], + "storedOnly": [ + "tell" + ] + }, + "took": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "did i take", + "do i take", + "i take", + "takes", + "taking" + ] + }, + "travelled to": { + "family": "event", + "sources": [ + "schema.org:TravelAction" + ], + "surfaceForms": [ + "been to", + "did i go to", + "do i go to", + "drive to", + "drives to", + "driving to", + "drove to", + "flew", + "flew to", + "flies", + "flies to", + "fly", + "fly to", + "flying", + "flying to", + "gone to", + "travel", + "travel to", + "traveled to", + "traveling to", + "travelled", + "travelling", + "travelling to", + "travels to", + "went to" + ], + "storedOnly": [ + "go to" + ] + }, + "tried": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "sampled", + "tasted", + "tries", + "try", + "trying" + ] + }, + "updated to": { + "family": "event", + "sources": [ + "schema.org:UpdateAction" + ], + "surfaceForms": [ + "update", + "update to", + "updated", + "updates", + "updates to", + "updating" + ] + }, + "uses": { + "family": "state", + "sources": [ + "schema.org:UseAction" + ], + "surfaceForms": [ + "use", + "using" + ], + "storedOnly": [ + "used" + ] + }, + "visited": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "visit", + "visiting", + "visits" + ] + }, + "wakes": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "wake", + "wake up", + "wakes up", + "waking", + "woke" + ] + }, + "wants": { + "family": "state", + "sources": [ + "schema.org:WantAction" + ], + "surfaceForms": [ + "did i want", + "do i want", + "wanted", + "wanting", + "wish", + "wishes" + ], + "storedOnly": [ + "want" + ] + }, + "was born": { + "family": "event", + "sources": [ + "pararel", + "rel2text" + ], + "surfaceForms": [ + "born", + "born in", + "is native to", + "is originally from", + "originated from", + "originates from", + "was born in", + "was native to", + "was originally from", + "were born", + "were born in" + ] + }, + "watched": { + "family": "event", + "sources": [ + "schema.org:WatchAction" + ], + "surfaceForms": [ + "watch", + "watches", + "watching" + ] + }, + "weighs": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "weigh", + "weight" + ] + }, + "welcomed": { + "family": "event", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "welcome", + "welcomes", + "welcoming" + ] + }, + "works at": { + "family": "state", + "sources": [ + "wikidata:P108", + "wikidata:P937", + "pararel", + "rel2text" + ], + "surfaceForms": [ + "did i work", + "do i work", + "employed at", + "employed by", + "found employment in", + "is an employee of", + "is employed by", + "is employed by the", + "location of work", + "took up work in", + "used to work in", + "was employed in", + "who is employed by", + "who works for", + "work at", + "worked", + "worked at", + "worked for", + "worked from", + "worked in", + "working at", + "working for", + "working from", + "workplace", + "works from", + "works in" + ], + "storedOnly": [ + "work", + "working", + "works for" + ] + }, + "works on": { + "family": "state", + "sources": [ + "hand-authored" + ], + "surfaceForms": [ + "work on", + "worked on", + "working on", + "works on" + ] + } + }, + "expansionExempt": { + "is": "copula; 340 facts. Expanding it wholesale floods the budget and carries almost no meaning.", + "is a": "copula variant; same reason as `is`.", + "has": "generic possession; absorbed 518 facts in the J1.6 build. Reachable by similarity, never expanded." + } +} diff --git a/src/AgentMemory.Core/Resolution/CompositeEntityResolver.Batch.cs b/src/AgentMemory.Core/Resolution/CompositeEntityResolver.Batch.cs new file mode 100644 index 00000000..fdce224a --- /dev/null +++ b/src/AgentMemory.Core/Resolution/CompositeEntityResolver.Batch.cs @@ -0,0 +1,174 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; + +namespace AgentMemory.Core.Resolution; + +internal sealed partial class CompositeEntityResolver +{ + private readonly AsyncLocal _candidateBatch = new(); + + public IDisposable BeginBatch() + { + if (!_options.UseBatchEntityResolutionSnapshots) + return NoopBatchLease.Instance; + if (_candidateBatch.Value is not null) + throw new InvalidOperationException("An entity-resolution batch is already active in this async flow."); + + var state = new CandidateBatchState(); + _candidateBatch.Value = state; + return new CandidateBatchLease(this, state); + } + + public async Task PrepareCandidatesAsync( + IReadOnlyCollection entityTypes, + MemoryScope? scope = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entityTypes); + var state = _candidateBatch.Value; + if (state is null || entityTypes.Count == 0) + return; + + var loads = entityTypes + .Where(type => !string.IsNullOrWhiteSpace(type)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(type => state.GetOrAddAsync( + CandidateBatchKey.Create(type, scope), + () => LoadCandidatesAsync(type, scope, cancellationToken))) + .ToArray(); + await Task.WhenAll(loads).ConfigureAwait(false); + } + + public void InvalidateBatch() => _candidateBatch.Value?.Invalidate(); + + private async Task ResolveAndRememberAsync( + ExtractedEntity extractedEntity, + IReadOnlyList sourceMessageIds, + MemoryScope? scope, + bool persistResolution, + CancellationToken cancellationToken) + { + var entity = await ResolveEntityCoreAsync( + extractedEntity, + sourceMessageIds, + scope, + persistResolution, + cancellationToken).ConfigureAwait(false); + _candidateBatch.Value?.Remember( + CandidateBatchKey.Create(extractedEntity.Type, scope), entity); + return entity; + } + + private async Task> GetBatchCandidatesAsync( + string type, + MemoryScope? scope, + CancellationToken cancellationToken) + { + var state = _candidateBatch.Value; + if (state is null) + return await LoadCandidatesAsync(type, scope, cancellationToken).ConfigureAwait(false); + + return await state.GetOrAddAsync( + CandidateBatchKey.Create(type, scope), + () => LoadCandidatesAsync(type, scope, cancellationToken)).ConfigureAwait(false); + } + + private async Task> LoadCandidatesAsync( + string type, + MemoryScope? scope, + CancellationToken cancellationToken) + { + // Candidate reads stay owner-scoped. Type-strict=false retains the historical best-effort + // GetByType behavior because the repository has no unfiltered GetAll contract. + return await _entityRepository.GetByTypeAsync(type, scope, cancellationToken).ConfigureAwait(false); + } + + private void EndBatch(CandidateBatchState state) + { + if (!ReferenceEquals(_candidateBatch.Value, state)) + return; + state.Dispose(); + _candidateBatch.Value = null; + } + + private readonly record struct CandidateBatchKey( + string Type, + string? OwnerId, + bool IncludeShared) + { + public static CandidateBatchKey Create(string type, MemoryScope? scope) => + new(type.ToUpperInvariant(), scope?.OwnerId, scope?.IncludeShared ?? true); + } + + private sealed class CandidateBatchState : IDisposable + { + private readonly object _gate = new(); + private readonly Dictionary>> _snapshots = []; + private bool _disposed; + + public Task> GetOrAddAsync( + CandidateBatchKey key, + Func>> loader) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_snapshots.TryGetValue(key, out var existing)) + return existing; + var created = LoadAsync(loader); + _snapshots.Add(key, created); + return created; + } + } + + public void Remember(CandidateBatchKey key, Entity entity) + { + lock (_gate) + { + if (_disposed || !_snapshots.TryGetValue(key, out var snapshot) || + !snapshot.IsCompletedSuccessfully) + return; + + var candidates = snapshot.Result; + var index = candidates.FindIndex(candidate => candidate.EntityId == entity.EntityId); + if (index >= 0) + candidates[index] = entity; + else + candidates.Add(entity); + } + } + + public void Invalidate() + { + lock (_gate) + _snapshots.Clear(); + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _snapshots.Clear(); + } + } + + private static async Task> LoadAsync(Func>> loader) => + (await loader().ConfigureAwait(false)).ToList(); + } + + private sealed class CandidateBatchLease( + CompositeEntityResolver owner, + CandidateBatchState state) : IDisposable + { + private CompositeEntityResolver? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.EndBatch(state); + } + + private sealed class NoopBatchLease : IDisposable + { + public static NoopBatchLease Instance { get; } = new(); + public void Dispose() { } + } +} diff --git a/src/AgentMemory.Core/Resolution/CompositeEntityResolver.cs b/src/AgentMemory.Core/Resolution/CompositeEntityResolver.cs index 44fcf3e3..a15c8b9f 100644 --- a/src/AgentMemory.Core/Resolution/CompositeEntityResolver.cs +++ b/src/AgentMemory.Core/Resolution/CompositeEntityResolver.cs @@ -21,7 +21,7 @@ namespace AgentMemory.Core.Resolution; /// entity — this is intentional "shared knowledge grows collaboratively" behavior, not a cross-owner /// leak (a future opt-in option could make shared knowledge read-only per owner if a deployment needs it). /// -internal sealed class CompositeEntityResolver : IEntityResolver +internal sealed partial class CompositeEntityResolver : IEntityResolver, IExtractionEntityResolver { private readonly IEntityRepository _entityRepository; private readonly IEmbeddingOrchestrator _embeddingOrchestrator; @@ -49,12 +49,33 @@ public CompositeEntityResolver( _logger = logger; } - /// - public async Task ResolveEntityAsync( + public Task ResolveEntityAsync( ExtractedEntity extractedEntity, IReadOnlyList sourceMessageIds, MemoryScope? scope = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + ResolveAndRememberAsync( + extractedEntity, sourceMessageIds, scope, persistResolution: true, cancellationToken); + + public Task ResolveForPersistenceAsync( + ExtractedEntity extractedEntity, + IReadOnlyList sourceMessageIds, + MemoryScope? scope = null, + CancellationToken cancellationToken = default) => + ResolveAndRememberAsync( + extractedEntity, sourceMessageIds, scope, persistResolution: false, cancellationToken); + + /// + /// Resolves an entity either for a direct caller (preserving the historical persist-on-resolve + /// behavior) or for fail-fast ExtractionStage, which must remain side-effect free until + /// PersistenceStage opens the logical transaction. + /// + private async Task ResolveEntityCoreAsync( + ExtractedEntity extractedEntity, + IReadOnlyList sourceMessageIds, + MemoryScope? scope, + bool persistResolution, + CancellationToken cancellationToken) { var candidates = await GetCandidatesAsync(extractedEntity.Type, scope, cancellationToken) .ConfigureAwait(false); @@ -77,7 +98,7 @@ public async Task ResolveEntityAsync( } if (resolutionResult is null) - return await CreateNewEntityAsync(extractedEntity, sourceMessageIds, scope, cancellationToken) + return await CreateNewEntityAsync(extractedEntity, sourceMessageIds, scope, persistResolution, cancellationToken) .ConfigureAwait(false); var matched = resolutionResult.ResolvedEntity; @@ -117,8 +138,9 @@ public async Task ResolveEntityAsync( mergedEntity = mergedEntity with { Embedding = freshEmbedding }; } - return await _entityRepository.UpsertAsync(mergedEntity, cancellationToken) - .ConfigureAwait(false); + return persistResolution + ? await _entityRepository.UpsertAsync(mergedEntity, cancellationToken).ConfigureAwait(false) + : mergedEntity; } // >= SameAsThreshold and < AutoMergeThreshold: flag for SAME_AS — caller handles relationship @@ -136,7 +158,7 @@ public async Task ResolveEntityAsync( "No match above SameAs threshold for '{Name}' — creating new entity.", extractedEntity.Name); - return await CreateNewEntityAsync(extractedEntity, sourceMessageIds, scope, cancellationToken) + return await CreateNewEntityAsync(extractedEntity, sourceMessageIds, scope, persistResolution, cancellationToken) .ConfigureAwait(false); } @@ -164,22 +186,11 @@ public async Task> FindPotentialDuplicatesAsync( return results; } - private async Task> GetCandidatesAsync( + private Task> GetCandidatesAsync( string type, MemoryScope? scope, - CancellationToken cancellationToken) - { - // The candidate set MUST be owner-scoped (R1): without it, an incoming entity could match and - // auto-merge onto another owner's private entity (a cross-owner write-path leak). A null scope - // (single-tenant / no owner context) preserves the legacy unscoped behavior. - if (_options.EntityResolution.TypeStrictFiltering) - return await _entityRepository.GetByTypeAsync(type, scope, cancellationToken).ConfigureAwait(false); - - // Without type filtering, SearchByVectorAsync is impractical here without an embedding; - // GetByTypeAsync with empty type returns all in many impls, so we fall back gracefully. - // For a complete impl, a GetAllAsync method would be ideal — use GetByTypeAsync("") as best effort. - return await _entityRepository.GetByTypeAsync(type, scope, cancellationToken).ConfigureAwait(false); - } + CancellationToken cancellationToken) => + GetBatchCandidatesAsync(type, scope, cancellationToken); private IReadOnlyList BuildMatchers() { @@ -202,6 +213,7 @@ private async Task CreateNewEntityAsync( ExtractedEntity extracted, IReadOnlyList sourceMessageIds, MemoryScope? scope, + bool persistResolution, CancellationToken cancellationToken) { var entity = new Entity @@ -223,6 +235,8 @@ private async Task CreateNewEntityAsync( CreatedAtUtc = _clock.UtcNow }; - return await _entityRepository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); + return persistResolution + ? await _entityRepository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false) + : entity; } } diff --git a/src/AgentMemory.Core/Resolution/IExtractionEntityResolver.cs b/src/AgentMemory.Core/Resolution/IExtractionEntityResolver.cs new file mode 100644 index 00000000..32004dea --- /dev/null +++ b/src/AgentMemory.Core/Resolution/IExtractionEntityResolver.cs @@ -0,0 +1,22 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; + +namespace AgentMemory.Core.Resolution; + +internal interface IExtractionEntityResolver +{ + IDisposable BeginBatch(); + + Task PrepareCandidatesAsync( + IReadOnlyCollection entityTypes, + MemoryScope? scope = null, + CancellationToken cancellationToken = default); + + void InvalidateBatch(); + + Task ResolveForPersistenceAsync( + ExtractedEntity extractedEntity, + IReadOnlyList sourceMessageIds, + MemoryScope? scope = null, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Core/ServiceCollectionExtensions.cs b/src/AgentMemory.Core/ServiceCollectionExtensions.cs index 5a13c05b..62f51cbe 100644 --- a/src/AgentMemory.Core/ServiceCollectionExtensions.cs +++ b/src/AgentMemory.Core/ServiceCollectionExtensions.cs @@ -17,6 +17,71 @@ namespace AgentMemory.Core; /// public static class ServiceCollectionExtensions { + /// + /// Registers all Core memory services from a fully-constructed . + /// + /// + /// The Action<MemoryOptions> overload cannot configure anything. + /// is a record whose properties are all init-only, so a + /// configure lambda cannot assign them — options.EnableGraphRag = true is a compile error, + /// and the options = options with { ... } form that does compile rebinds the parameter + /// local and is discarded the moment the lambda returns. Code written that way builds, runs, and + /// silently keeps every default. + /// + /// This overload takes the instance directly, which is how every working call site in this + /// repository already builds options, and applies the same validators the other overload + /// registers — a supplied instance is checked, not trusted. + /// + /// + /// Added rather than changing the properties to set: that would alter the setter signature + /// of a public type on a SemVer-locked surface and break binary compatibility for anyone already + /// compiled against it. This is purely additive. + /// + /// + /// Limitation, stated rather than left to be discovered. This replaces the registration of + /// only. A host resolving IOptionsMonitor<MemoryOptions> + /// or IOptionsSnapshot<MemoryOptions> would still go through the options factory and + /// receive defaults. Nothing in this product resolves either — every consumer takes + /// IOptions<MemoryOptions> — but a host that does would see two different values for + /// the same options type, which is worth knowing before it happens rather than after. + /// + /// + public static IServiceCollection AddAgentMemoryCore( + this IServiceCollection services, + MemoryOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + + // Register everything the lambda overload does, including the validator chain, then take over + // the resolution of IOptions. An exact closed-generic registration wins over + // the open IOptions<> one, so the supplied instance is what every consumer resolves. + services.AddAgentMemoryCore(_ => { }); + services.AddSingleton>(serviceProvider => + { + // The validators are the whole reason this is a factory rather than Options.Create: an + // instance that skipped them would fail closed nowhere and misconfigure silently at the + // first affected call, which is the exact failure the validator chain exists to prevent. + var failures = serviceProvider + .GetServices>() + .Select(validator => validator.Validate(Microsoft.Extensions.Options.Options.DefaultName, options)) + .Where(result => result.Failed) + // Failures is only guaranteed non-null once Failed is true, which the Where above + // establishes but the compiler cannot see. + .SelectMany(result => result.Failures ?? []) + .ToArray(); + if (failures.Length > 0) + { + throw new OptionsValidationException( + Microsoft.Extensions.Options.Options.DefaultName, typeof(MemoryOptions), failures); + } + + return Microsoft.Extensions.Options.Options.Create(options); + }); + + return services; + } + /// /// Registers all Core memory services. /// Adapters (repositories, IEmbeddingGenerator, etc.) must be registered separately. @@ -168,6 +233,7 @@ public static IServiceCollection AddAgentMemoryCore( // Extraction pipeline stages. // IExtractionStage receives IEnumerable — all registered extractor implementations. + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -185,7 +251,8 @@ public static IServiceCollection AddAgentMemoryCore( sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService(), - sp.GetService>())); + sp.GetService>(), + sp.GetServices())); // Embedding orchestrator — centralizes embedding generation logic. services.TryAddScoped(); diff --git a/src/AgentMemory.Core/Services/EmbeddingOrchestrator.cs b/src/AgentMemory.Core/Services/EmbeddingOrchestrator.cs index 001732c3..1c19c61a 100644 --- a/src/AgentMemory.Core/Services/EmbeddingOrchestrator.cs +++ b/src/AgentMemory.Core/Services/EmbeddingOrchestrator.cs @@ -36,7 +36,7 @@ public async Task EmbedAsync(string text, CancellationToken cancellatio try { - var result = await _generator.GenerateAsync([text], cancellationToken: cancellationToken).ConfigureAwait(false); + var result = await _generator.GenerateAsync([CapInputLength(text)], cancellationToken: cancellationToken).ConfigureAwait(false); return result[0].Vector.ToArray(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -70,7 +70,7 @@ public async Task> EmbedBatchAsync(IReadOnlyList if (!string.IsNullOrWhiteSpace(texts[i])) { nonBlankIndices.Add(i); - nonBlankTexts.Add(texts[i]); + nonBlankTexts.Add(CapInputLength(texts[i])); } } @@ -97,10 +97,46 @@ public async Task> EmbedBatchAsync(IReadOnlyList } catch (Exception ex) { - _logger.LogWarning(ex, "Batch embedding generation failed for {Count} texts; returning empty vectors.", nonBlankTexts.Count); - // results already initialized to empty vectors — positional alignment preserved. + // One unusable input fails the whole provider call, so leaving every slot empty lets a + // single bad message silently blank an entire batch — which is exactly how one + // 41,855-character turn made all 596 messages of a LongMemEval question unsearchable. + // Retry item by item so the blast radius is the offending input, not its neighbours. + _logger.LogWarning( + ex, + "Batch embedding generation failed for {Count} texts; retrying individually so one bad input cannot blank the rest.", + nonBlankTexts.Count); + + for (int j = 0; j < nonBlankIndices.Count; j++) + { + results[nonBlankIndices[j]] = await EmbedAsync(nonBlankTexts[j], cancellationToken) + .ConfigureAwait(false); + } } return results; } + + /// + /// Caps embedding input length. Embedding models reject inputs beyond a fixed token budget + /// (ada-002 allows 8,191), and the provider rejects the whole request when a single + /// input exceeds it. Stored content is never truncated — only the text handed to the embedder — + /// so an unusually long message stays complete and remains searchable. + /// + /// + /// Deliberately conservative and character-based: an exact tokenizer is not available at this + /// abstraction boundary, and ~4 characters per token is a safe upper bound for the models in use. + /// + private const int MaxEmbeddingInputCharacters = 32_000; + + private string CapInputLength(string text) + { + if (text.Length <= MaxEmbeddingInputCharacters) + return text; + + _logger.LogWarning( + "Embedding input of {Length} characters exceeds the {Cap}-character cap; embedding its leading section. Stored content is unchanged.", + text.Length, + MaxEmbeddingInputCharacters); + return text[..MaxEmbeddingInputCharacters]; + } } diff --git a/src/AgentMemory.Core/Services/LongTermMemoryService.cs b/src/AgentMemory.Core/Services/LongTermMemoryService.cs index 463aa47a..d5bda3b9 100644 --- a/src/AgentMemory.Core/Services/LongTermMemoryService.cs +++ b/src/AgentMemory.Core/Services/LongTermMemoryService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using AgentMemory.Abstractions.Domain; +using AgentMemory.Core.Memory; using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; @@ -12,6 +13,10 @@ namespace AgentMemory.Core.Services; /// internal sealed class LongTermMemoryService : ILongTermMemoryService { + private const int FactDedupLockStripeCount = 256; + private static readonly SemaphoreSlim[] FactDedupLockStripes = + Enumerable.Range(0, FactDedupLockStripeCount).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + private readonly IEntityRepository _entityRepo; private readonly IFactRepository _factRepo; private readonly IPreferenceRepository _prefRepo; @@ -232,7 +237,17 @@ public async Task AddFactAsync( // (zero-dimension) vector, which would otherwise be handed to db.index.vector.queryNodes and throw a // dimension mismatch — aborting the whole add. An empty embedding has no semantic signal, so skip // dedup and fall through to a plain create (the node persists with a NULL, re-queueable embedding). - if (_options.DeduplicateOnCreate && embedding is { Length: > 0 }) + var toSave = embedding is null ? fact : fact with { Embedding = embedding }; + if (!_options.DeduplicateOnCreate || embedding is not { Length: > 0 }) + return await _factRepo.UpsertAsync(toSave, cancellationToken).ConfigureAwait(false); + + // Find + reinforce/create is otherwise a TOCTOU race across concurrent request scopes. A bounded + // process-wide stripe set serializes the same owner + case-insensitive subject/predicate key without + // retaining one lock per memory forever. This deliberately provides in-process session correctness; + // it does not claim distributed coordination between separate application instances. + var dedupLock = FactDedupLock(toSave); + await dedupLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { var dup = await _factRepo.FindDuplicateAsync( fact.Subject, fact.Predicate, embedding, fact.OwnerId, @@ -251,10 +266,22 @@ public async Task AddFactAsync( // create the new node instead of failing the add. _logger.LogDebug("Dedup target fact {Id} vanished before reinforce; creating new node.", dup.FactId); } + + return await _factRepo.UpsertAsync(toSave, cancellationToken).ConfigureAwait(false); } + finally + { + dedupLock.Release(); + } + } - var toSave = embedding is null ? fact : fact with { Embedding = embedding }; - return await _factRepo.UpsertAsync(toSave, cancellationToken).ConfigureAwait(false); + private static SemaphoreSlim FactDedupLock(Fact fact) + { + var hash = new HashCode(); + hash.Add(fact.OwnerId, StringComparer.Ordinal); + hash.Add(fact.Subject, StringComparer.OrdinalIgnoreCase); + hash.Add(fact.Predicate, StringComparer.OrdinalIgnoreCase); + return FactDedupLockStripes[(uint)hash.ToHashCode() % FactDedupLockStripeCount]; } /// Reinforced confidence on a dedup hit: max(existing, incoming) + configured bump, capped at 1.0. @@ -292,15 +319,91 @@ public Task> GetFactsBySubjectAsync( } /// - public async Task> SearchFactsAsync( + public Task> SearchFactsAsync( float[] queryEmbedding, int limit = 10, double minScore = 0.0, MemoryScope? scope = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + SearchFactsAsync( + queryEmbedding, limit, minScore, scope, false, 0, cancellationToken); + + /// + /// Fact recall with optional canonical-predicate expansion (G5 "hard" tier). + /// + /// + /// A separate overload rather than optional parameters on the interface method: adding optional + /// parameters to a published interface breaks every implementor, and the interface is locked + /// under SemVer. + /// + public Task> SearchFactsAsync( + float[] queryEmbedding, + int limit, + double minScore, + MemoryScope? scope, + bool expandByPredicate, + int expansionLimit, + CancellationToken cancellationToken) => + SearchFactsAsync( + queryEmbedding, limit, minScore, scope, expandByPredicate, expansionLimit, + Array.Empty(), cancellationToken); + + /// + public async Task> SearchFactsAsync( + float[] queryEmbedding, + int limit, + double minScore, + MemoryScope? scope, + bool expandByPredicate, + int expansionLimit, + IReadOnlyList questionRelations, + CancellationToken cancellationToken) { - var scored = await _factRepo.SearchByVectorAsync(queryEmbedding, limit, minScore, Resolve(scope, nameof(SearchFactsAsync)), cancellationToken).ConfigureAwait(false); - return scored.Select(r => r.Fact).ToList(); + ArgumentNullException.ThrowIfNull(questionRelations); + var resolved = Resolve(scope, nameof(SearchFactsAsync)); + var scored = await _factRepo.SearchByVectorAsync(queryEmbedding, limit, minScore, resolved, cancellationToken).ConfigureAwait(false); + var top = scored.Select(r => r.Fact).ToList(); + // A question that names its relations outright does not need the top-K to nominate them, so an + // empty top-K is only a dead end when there is nothing else to expand on. + if (!expandByPredicate || (top.Count == 0 && questionRelations.Count == 0)) + return top; + + // G5 "hard" tier. Similarity decides *which* relation matters; this returns that relation + // whole. Top-K is a relevance cutoff and carries no completeness guarantee, so a question + // like "how many babies were born" is unanswerable from it - miss one of five and the count + // is four. Expansion is additive: the similarity-ranked facts stay, in order, at the front. + var predicates = top + .Select(fact => MemoryTripleCanonicalizer.Canonical(fact.Predicate)) + // J2.2. Relations the question named, each widened to every form it could be stored under: + // the write-side canonicalizer never folds morphology, so one relation lives under several + // keys and expanding only the canonical name would miss the smaller buckets. + .Concat(questionRelations.SelectMany( + relation => MemoryRelationLexicon.Default.StoredFormsOf(relation))) + .Where(predicate => predicate.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var expanded = await _factRepo.SearchByCanonicalPredicatesAsync( + predicates, expansionLimit, resolved, cancellationToken).ConfigureAwait(false); + + var seen = top.Select(fact => fact.FactId).ToHashSet(StringComparer.Ordinal); + foreach (var fact in expanded) + { + if (!seen.Add(fact.FactId)) + continue; + + // Marked because expansion returns a relation across the whole owner, so a fact may + // legitimately carry provenance outside the current query's window. A consumer + // resolving provenance must be able to tell that apart from a source that genuinely + // cannot be resolved, which is corruption — marking the former keeps the latter + // detectable rather than silencing both. + var metadata = new Dictionary(fact.Metadata, StringComparer.Ordinal) + { + [Fact.RetrievalSourceMetadataKey] = Fact.RetrievalSourcePredicateExpansion + }; + top.Add(fact with { Metadata = metadata }); + } + + return top; } /// diff --git a/src/AgentMemory.Core/Services/MemoryContextAssembler.cs b/src/AgentMemory.Core/Services/MemoryContextAssembler.cs index 5efc2fb1..4c1bcd12 100644 --- a/src/AgentMemory.Core/Services/MemoryContextAssembler.cs +++ b/src/AgentMemory.Core/Services/MemoryContextAssembler.cs @@ -5,6 +5,7 @@ using AgentMemory.Abstractions.Exceptions; using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Memory; using AgentMemory.Core.Services.Budgeting; namespace AgentMemory.Core.Services; @@ -156,11 +157,23 @@ public async Task AssembleContextAsync( IReadOnlyList recentMessages = Array.Empty(); IReadOnlyList relevantMessages = Array.Empty(); + IReadOnlyList<(Message Message, double Score)> relevantMessageScores = + Array.Empty<(Message, double)>(); IReadOnlyList entities = Array.Empty(); IReadOnlyList preferences = Array.Empty(); IReadOnlyList facts = Array.Empty(); IReadOnlyList traces = Array.Empty(); + // J2.2 resolution, computed once at method scope so it can be both used by the fact search + // and reported on the context. Which relations a question resolved to is the difference + // between "expansion had nothing to expand" and "expansion ran and did not help", and those + // need opposite responses. It was computed inline and discarded, so no report could tell + // them apart. + var resolvedQueryRelations = recallOpts.ExpandFactsByPredicate && + recallOpts.ResolveQueryRelations + ? MemoryRelationLexicon.Default.ResolveQuestion(request.Query) + : Array.Empty(); + if (includeMemory) { // Generate embedding if not provided (only needed for memory-layer semantic search). @@ -193,8 +206,14 @@ public async Task AssembleContextAsync( var relevantTask = hasEmbedding && recallOpts.MaxRelevantMessages > 0 ? TimedAsync("memory.recall.messages", - () => _shortTerm.SearchMessagesAsync(request.SessionId, queryEmbedding, recallOpts.MaxRelevantMessages, minScore, cancellationToken)) - : Empty(); + () => SearchRelevantMessagesAsync( + request.SessionId, + queryEmbedding, + recallOpts.MaxRelevantMessages, + minScore, + recallOpts.IncludeDiagnostics, + cancellationToken)) + : Task.FromResult(RelevantMessageSearchResult.Empty); // D3 — apply the per-request query intent (latest/analog) as an ambient ranking override for // the long-term vector searches below. The long-term repositories read it synchronously while @@ -215,12 +234,38 @@ public async Task AssembleContextAsync( var factsTask = hasEmbedding && recallOpts.MaxFacts > 0 ? TimedAsync("memory.recall.facts", - () => _longTerm.SearchFactsAsync(queryEmbedding, recallOpts.MaxFacts, minScore, scope, cancellationToken)) + // Only the expansion path takes the wider overload. Off by default, the call is + // byte-for-byte the original, so no existing behaviour or contract shifts. + // Hoisted out of the call so the decision is observable. Which relations a + // question resolved to is the difference between "expansion had nothing to + // expand" and "expansion ran and did not help", and the two need opposite + // responses. It was computed inline and discarded, so no report could tell them + // apart - a multi-session question failing because its verb is absent from the + // table looked identical to one failing for any other reason. + () => recallOpts.ExpandFactsByPredicate + ? _longTerm.SearchFactsAsync( + queryEmbedding, recallOpts.MaxFacts, minScore, scope, + true, recallOpts.MaxExpandedFacts, + // J2.2. Empty unless explicitly enabled, and an unrecognised verb resolves + // to nothing, so both the option-off and the no-match paths reproduce the + // previous call exactly. + resolvedQueryRelations, + cancellationToken) + : _longTerm.SearchFactsAsync( + queryEmbedding, recallOpts.MaxFacts, minScore, scope, cancellationToken)) : Empty(); var tracesTask = hasEmbedding && recallOpts.MaxTraces > 0 ? TimedAsync("memory.recall.traces", - () => _reasoning.SearchSimilarTracesAsync(queryEmbedding, null, recallOpts.MaxTraces, minScore, scope, cancellationToken)) + () => _reasoning.SearchSimilarTracesAsync( + queryEmbedding, + // K5: was a hardcoded null, so the outcome filter the repository and its + // Cypher already supported could never be reached from automatic recall. + // A recalled trace is shown to the reader as precedent with nothing marking + // it as a failure, so imitating reasoning that did not work is worse than + // recalling nothing. Default stays null - today's behaviour - until measured. + recallOpts.SuccessfulTracesOnly, + recallOpts.MaxTraces, minScore, scope, cancellationToken)) : Empty(); if (overrideRanking) _rankingContext!.Current = null; @@ -230,7 +275,9 @@ await Task.WhenAll( preferencesTask, factsTask, tracesTask).ConfigureAwait(false); recentMessages = await recentTask.ConfigureAwait(false); - relevantMessages = await relevantTask.ConfigureAwait(false); + var relevantResult = await relevantTask.ConfigureAwait(false); + relevantMessages = relevantResult.Messages; + relevantMessageScores = relevantResult.ScoredMessages; entities = await entitiesTask.ConfigureAwait(false); preferences = await preferencesTask.ConfigureAwait(false); facts = await factsTask.ConfigureAwait(false); @@ -241,11 +288,18 @@ await Task.WhenAll( await graphRagTask.ConfigureAwait(false); string? graphRagContext = null; + // K4: the items are retained, not only their joined text. They already carry SourceNodeIds, + // Score and Metadata, and discarding them here is what left GraphRAG unattributable, and so + // unmeasurable, in every quality run to date. The joined string is byte-identical to before. + IReadOnlyList graphRagItems = Array.Empty(); if (graphRagTask != null) { var graphRagResult = await graphRagTask.ConfigureAwait(false); if (graphRagResult?.Items is { Count: > 0 } items) + { graphRagContext = string.Join("\n\n", items.Select(i => i.Text)); + graphRagItems = items; + } } // Apply context budget if configured @@ -266,17 +320,27 @@ await Task.WhenAll( + ContextBudgetEstimator.EstimateChars(traces) + (graphRagContext?.Length ?? 0); + var rankedRelevantItems = recallOpts.IncludeDiagnostics + ? BuildRankedItems(relevantMessages, relevantMessageScores) + : Array.Empty(); + var context = new MemoryContext { SessionId = request.SessionId, AssembledAtUtc = _clock.UtcNow, RecentMessages = new MemoryContextSection { Items = recentMessages }, - RelevantMessages = new MemoryContextSection { Items = relevantMessages }, + RelevantMessages = new MemoryContextSection + { + Items = relevantMessages, + RankedItems = rankedRelevantItems + }, RelevantEntities = new MemoryContextSection { Items = entities }, RelevantPreferences = new MemoryContextSection { Items = preferences }, RelevantFacts = new MemoryContextSection { Items = facts }, SimilarTraces = new MemoryContextSection { Items = traces }, GraphRagContext = graphRagContext, + GraphRagItems = graphRagItems, + ResolvedQueryRelations = resolvedQueryRelations, BlendMode = blendMode, Truncated = truncated }; @@ -463,6 +527,69 @@ private static async Task TimedAsync(string spanName, Func> factor } } + private async Task SearchRelevantMessagesAsync( + string sessionId, + float[] queryEmbedding, + int limit, + double minScore, + bool includeDiagnostics, + CancellationToken cancellationToken) + { + if (includeDiagnostics && _shortTerm is IScoredMessageSearch scoredSearch) + { + var scoredMessages = await scoredSearch.SearchMessagesWithScoresAsync( + sessionId, queryEmbedding, limit, minScore, cancellationToken).ConfigureAwait(false); + return new RelevantMessageSearchResult( + scoredMessages.Select(result => result.Message).ToArray(), + scoredMessages); + } + + var messages = await _shortTerm.SearchMessagesAsync( + sessionId, queryEmbedding, limit, minScore, cancellationToken).ConfigureAwait(false); + return new RelevantMessageSearchResult(messages, Array.Empty<(Message, double)>()); + } + + internal static IReadOnlyList BuildRankedItems( + IReadOnlyList contextMessages, + IReadOnlyList<(Message Message, double Score)> retrievedMessages) + { + if (contextMessages.Count == 0 || retrievedMessages.Count == 0) + return Array.Empty(); + + var retrievedById = retrievedMessages + .Select((result, index) => new + { + result.Message.MessageId, + result.Score, + RetrievalRank = index + 1 + }) + .ToDictionary(result => result.MessageId, StringComparer.Ordinal); + + var ranked = new List(contextMessages.Count); + for (var index = 0; index < contextMessages.Count; index++) + { + var message = contextMessages[index]; + if (!retrievedById.TryGetValue(message.MessageId, out var retrieved)) + continue; + ranked.Add(new MemoryContextRankedItem( + message.MessageId, + retrieved.Score, + retrieved.RetrievalRank, + ContextRank: index + 1)); + } + + return ranked.AsReadOnly(); + } + + private sealed record RelevantMessageSearchResult( + IReadOnlyList Messages, + IReadOnlyList<(Message Message, double Score)> ScoredMessages) + { + public static RelevantMessageSearchResult Empty { get; } = new( + Array.Empty(), + Array.Empty<(Message, double)>()); + } + private sealed record AssembledSections( IReadOnlyList Recent, IReadOnlyList Relevant, diff --git a/src/AgentMemory.Core/Services/MemoryDecayService.cs b/src/AgentMemory.Core/Services/MemoryDecayService.cs index 2c3081a7..3e7ca06d 100644 --- a/src/AgentMemory.Core/Services/MemoryDecayService.cs +++ b/src/AgentMemory.Core/Services/MemoryDecayService.cs @@ -92,8 +92,16 @@ internal double ComputeScore( double daysSinceAccess = Math.Max(0, (now - reference).TotalDays); double lambda = Math.Log(2) / _options.DecayHalfLifeDays; - return confidence * Math.Exp(-lambda * daysSinceAccess) - + _options.AccessBoostFactor * accessCount; + // BUG-R7. The access term was linear, unbounded, and — unlike confidence — never decayed, so + // access_count alone decided retention: 10,000 accesses scored 2,000 against a [0,1] cosine + // blend, and a single recall reached 0.2, permanently above MinRetentionScore (0.1) however + // stale the memory became. Damp it logarithmically, cap its contribution, and decay it on the + // same curve as confidence, so frequent access slows forgetting instead of preventing it. + double boost = Math.Min( + _options.AccessBoostFactor * Math.Log(1 + Math.Max(0, accessCount)), + _options.MaxAccessBoost); + + return (confidence + boost) * Math.Exp(-lambda * daysSinceAccess); } /// diff --git a/src/AgentMemory.Core/Services/MemoryExtractionPipeline.Batch.cs b/src/AgentMemory.Core/Services/MemoryExtractionPipeline.Batch.cs new file mode 100644 index 00000000..ea7c5635 --- /dev/null +++ b/src/AgentMemory.Core/Services/MemoryExtractionPipeline.Batch.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; + +namespace AgentMemory.Core.Services; + +internal sealed partial class MemoryExtractionPipeline +{ + public async Task> ExtractBatchAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxSessionsPerBatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxInputTokens); + if (requests.Count == 0) + return []; + + var ordered = requests + .Select((request, index) => new + { + Request = request, + Index = index, + Timestamp = request.Messages + .Select(message => message.TimestampUtc) + .DefaultIfEmpty(DateTimeOffset.MinValue) + .Min(), + }) + .OrderBy(item => item.Timestamp) + .ThenBy(item => item.Index) + .Select(item => item.Request) + .ToArray(); + + var batchExtractor = _multiSessionExtractors.FirstOrDefault(extractor => extractor.IsEnabled); + if (batchExtractor is null) + { + var fallback = new List(ordered.Length); + foreach (var request in ordered) + fallback.Add(await ExtractAsync(request, cancellationToken).ConfigureAwait(false)); + return fallback; + } + + var extractedBySession = await batchExtractor.ExtractAsync( + ordered, + maxSessionsPerBatch, + maxInputTokens, + cancellationToken).ConfigureAwait(false); + + var persisted = new List(ordered.Length); + using var resolutionBatch = _extractionStage.BeginResolutionBatch(); + foreach (var request in ordered) + { + if (!extractedBySession.TryGetValue(request.SessionId, out var extracted)) + throw new InvalidOperationException( + $"Validated batch output is missing source session '{request.SessionId}'."); + + var sw = Stopwatch.StartNew(); + var scope = _isolationPolicy.ResolveReadScope( + explicitScope: null, + request.UserId, + nameof(ExtractBatchAsync), + MemoryOperationAccess.Tenant); + var staged = await _extractionStage.ProcessUnifiedAsync( + request.Messages, + extracted, + request.TypesToExtract, + scope, + cancellationToken).ConfigureAwait(false); + + var ownerId = _isolationPolicy.ResolveWriteOwner( + request.UserId, + nameof(ExtractBatchAsync), + MemoryOperationAccess.Tenant); + var trustLevel = request.TrustLevel ?? _options.DefaultTrustLevel; + var result = await _persistenceStage.PersistAsync( + staged, + ownerId, + trustLevel, + cancellationToken).ConfigureAwait(false); + sw.Stop(); + if (result.Outcomes.Any(outcome => outcome.Status == IngestionItemStatus.Failed)) + _extractionStage.InvalidateResolutionBatch(); + + + persisted.Add(new ExtractionResult + { + Entities = staged.RawEntities, + Facts = staged.RawFacts, + Preferences = staged.RawPreferences, + Relationships = staged.RawRelationships, + SourceMessageIds = staged.SourceMessageIds, + Status = ComputeStatus(result.Outcomes), + Outcomes = result.Outcomes, + Metadata = new Dictionary + { + ["sessionId"] = request.SessionId, + ["postBatchProcessingTimeMs"] = sw.ElapsedMilliseconds, + ["entityCount"] = result.EntityCount, + ["factCount"] = result.FactCount, + ["preferenceCount"] = result.PreferenceCount, + ["relationshipCount"] = result.RelationshipCount, + }, + }); + } + + return persisted; + } +} diff --git a/src/AgentMemory.Core/Services/MemoryExtractionPipeline.cs b/src/AgentMemory.Core/Services/MemoryExtractionPipeline.cs index 42c09666..6fab1abe 100644 --- a/src/AgentMemory.Core/Services/MemoryExtractionPipeline.cs +++ b/src/AgentMemory.Core/Services/MemoryExtractionPipeline.cs @@ -13,13 +13,14 @@ namespace AgentMemory.Core.Services; /// merge, filter, validate, resolve) followed by (embed, upsert, /// wire provenance). Implements the public interface. /// -internal sealed class MemoryExtractionPipeline : IMemoryExtractionPipeline +internal sealed partial class MemoryExtractionPipeline : IMemoryExtractionPipeline { private readonly IExtractionStage _extractionStage; private readonly IPersistenceStage _persistenceStage; private readonly ILogger _logger; private readonly IMemoryIsolationPolicy _isolationPolicy; private readonly ExtractionOptions _options; + private readonly IReadOnlyList _multiSessionExtractors; // Internal ctor: the stage interfaces are internal to Core, so this type is activated by an // explicit factory in AddAgentMemoryCore (the default DI activator only selects public ctors). @@ -28,13 +29,16 @@ internal MemoryExtractionPipeline( IPersistenceStage persistenceStage, ILogger logger, IMemoryIsolationPolicy isolationPolicy, - IOptions? extractionOptions = null) + IOptions? extractionOptions = null, + IEnumerable? multiSessionExtractors = null) { _extractionStage = extractionStage; _persistenceStage = persistenceStage; _logger = logger; _isolationPolicy = isolationPolicy; _options = extractionOptions?.Value ?? new ExtractionOptions(); + _multiSessionExtractors = (multiSessionExtractors ?? []) + .ToList().AsReadOnly(); } /// diff --git a/src/AgentMemory.Core/Services/PassThroughMemoryPersistenceTransaction.cs b/src/AgentMemory.Core/Services/PassThroughMemoryPersistenceTransaction.cs new file mode 100644 index 00000000..61b010aa --- /dev/null +++ b/src/AgentMemory.Core/Services/PassThroughMemoryPersistenceTransaction.cs @@ -0,0 +1,17 @@ +using AgentMemory.Core.Extraction; + +namespace AgentMemory.Core.Services; + +/// Portable fallback for stores that do not expose a transaction coordinator. +internal sealed class PassThroughMemoryPersistenceTransaction : IMemoryPersistenceTransaction +{ + public bool SupportsAtomicRollback => false; + + public Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return work(cancellationToken); + } +} diff --git a/src/AgentMemory.Core/Services/ShortTermMemoryService.cs b/src/AgentMemory.Core/Services/ShortTermMemoryService.cs index 60b644e7..d89e0546 100644 --- a/src/AgentMemory.Core/Services/ShortTermMemoryService.cs +++ b/src/AgentMemory.Core/Services/ShortTermMemoryService.cs @@ -10,7 +10,7 @@ namespace AgentMemory.Core.Services; /// /// Service for short-term (conversational) memory operations. /// -internal sealed class ShortTermMemoryService : IShortTermMemoryService +internal sealed class ShortTermMemoryService : IShortTermMemoryService, IScoredMessageSearch { private readonly IConversationRepository _conversationRepo; private readonly IMessageRepository _messageRepo; @@ -90,21 +90,90 @@ public async Task> AddMessagesAsync( CancellationToken cancellationToken = default) { var messageList = messages.ToList(); - var results = new List(messageList.Count); + if (!_options.GenerateEmbeddings) + { + _logger.LogDebug("Batch adding {Count} messages", messageList.Count); + return await _messageRepo.AddBatchAsync(messageList, cancellationToken).ConfigureAwait(false); + } - foreach (var message in messageList) + if (!_options.UseBatchEmbeddingRequests) { - var finalMessage = message; - if (_options.GenerateEmbeddings && message.Embedding is null) + var legacyResults = new List(messageList.Count); + foreach (var message in messageList) { - var embedding = await _embeddingOrchestrator.EmbedMessageAsync(message.Content, cancellationToken).ConfigureAwait(false); - finalMessage = message with { Embedding = embedding }; + var finalMessage = message; + if (message.Embedding is null) + { + var embedding = await _embeddingOrchestrator + .EmbedMessageAsync(message.Content, cancellationToken) + .ConfigureAwait(false); + finalMessage = message with { Embedding = embedding }; + } + legacyResults.Add(finalMessage); + } + + _logger.LogDebug("Batch adding {Count} messages", legacyResults.Count); + return await _messageRepo.AddBatchAsync(legacyResults, cancellationToken).ConfigureAwait(false); + } + + var missingIndices = Enumerable.Range(0, messageList.Count) + .Where(index => messageList[index].Embedding is null) + .ToArray(); + if (missingIndices.Length > 0) + { + var texts = missingIndices.Select(index => messageList[index].Content).ToArray(); + var embeddings = await _embeddingOrchestrator + .EmbedBatchAsync(texts, cancellationToken) + .ConfigureAwait(false); + if (embeddings.Count != missingIndices.Length) + { + throw new InvalidOperationException( + $"Batch embedding returned {embeddings.Count} vectors for " + + $"{missingIndices.Length} messages; positional alignment cannot be guaranteed."); + } + + for (var index = 0; index < missingIndices.Length; index++) + { + var messageIndex = missingIndices[index]; + var vector = embeddings[index]; + + // BUG-M1. The count check above cannot see an empty vector, and an empty list is not + // null — so such a message passes recall's `embedding IS NOT NULL` filter, then + // cosine yields null and the score comparison drops it. The message would be stored + // and permanently unretrievable with no error raised. Replay that slot alone, and if + // it still comes back unusable, fail the write: a message is the only copy of itself, + // so losing the write is recoverable and storing an unsearchable one is not. + // Blank content has no embedding by definition — the orchestrator returns an empty + // vector for it deliberately. That is a property of the input, not a provider + // failure, so it is stored as-is and simply never matches a semantic search. + var contentIsBlank = string.IsNullOrWhiteSpace(messageList[messageIndex].Content); + if (!contentIsBlank && (vector is null || vector.Length == 0)) + { + _logger.LogWarning( + "Batch embedding returned an unusable vector for message {MessageId}; replaying it individually.", + messageList[messageIndex].MessageId); + vector = await _embeddingOrchestrator + .EmbedMessageAsync(messageList[messageIndex].Content, cancellationToken) + .ConfigureAwait(false); + + if (vector is null || vector.Length == 0) + { + throw new InvalidOperationException( + $"Embedding for message {messageList[messageIndex].MessageId} was empty after an " + + "individual replay, although its content is not blank; refusing to persist a " + + "message that could never be retrieved."); + } + } + + messageList[messageIndex] = messageList[messageIndex] with + { + Embedding = vector, + }; } - results.Add(finalMessage); } - _logger.LogDebug("Batch adding {Count} messages", results.Count); - return await _messageRepo.AddBatchAsync(results, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Batch adding {Count} messages", messageList.Count); + return await _messageRepo.AddBatchAsync(messageList, cancellationToken).ConfigureAwait(false); } /// @@ -147,11 +216,25 @@ public async Task> SearchMessagesAsync( double minScore = 0.0, CancellationToken cancellationToken = default) { - var scored = await _messageRepo.SearchByVectorAsync( - queryEmbedding, sessionId, limit, minScore, null, cancellationToken).ConfigureAwait(false); - return scored.Select(r => r.Message).ToList(); + var scored = await SearchMessagesWithScoresAsync( + sessionId, queryEmbedding, limit, minScore, cancellationToken).ConfigureAwait(false); + return scored.Select(result => result.Message).ToList(); } + /// + /// Returns the repository's existing ranked message results without a second query. This internal + /// contract is used only when a recall explicitly requests diagnostics; the public short-term service + /// remains source-compatible for custom implementations. + /// + public Task> SearchMessagesWithScoresAsync( + string? sessionId, + float[] queryEmbedding, + int limit, + double minScore, + CancellationToken cancellationToken) => + _messageRepo.SearchByVectorAsync( + queryEmbedding, sessionId, limit, minScore, null, cancellationToken); + /// public async Task ClearSessionAsync( string sessionId, @@ -177,3 +260,17 @@ public async Task> GetRecentMessagesAsOfAsync( return await _messageRepo.GetRecentBySessionAsOfAsync(sessionId, asOf, cappedLimit, cancellationToken).ConfigureAwait(false); } } + +/// +/// Internal scored-search capability implemented by the built-in short-term memory service. Keeping this +/// separate from avoids a breaking interface addition for providers. +/// +internal interface IScoredMessageSearch +{ + Task> SearchMessagesWithScoresAsync( + string? sessionId, + float[] queryEmbedding, + int limit, + double minScore, + CancellationToken cancellationToken); +} diff --git a/src/AgentMemory.Extraction.Llm/AgentMemory.Extraction.Llm.csproj b/src/AgentMemory.Extraction.Llm/AgentMemory.Extraction.Llm.csproj index c4e99457..c66f6d2f 100644 --- a/src/AgentMemory.Extraction.Llm/AgentMemory.Extraction.Llm.csproj +++ b/src/AgentMemory.Extraction.Llm/AgentMemory.Extraction.Llm.csproj @@ -17,6 +17,7 @@ + diff --git a/src/AgentMemory.Extraction.Llm/Internal/LlmExtractionRunner.cs b/src/AgentMemory.Extraction.Llm/Internal/LlmExtractionRunner.cs index 4d2091f2..18c93e23 100644 --- a/src/AgentMemory.Extraction.Llm/Internal/LlmExtractionRunner.cs +++ b/src/AgentMemory.Extraction.Llm/Internal/LlmExtractionRunner.cs @@ -38,14 +38,16 @@ internal async Task> RunAsync( string userInstruction, string conversationText, Func> project, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool failOnParseExhaustion = false, + ChatResponseFormat? responseFormat = null) { var chatMessages = new List { new(ChatRole.System, systemPrompt), new(ChatRole.User, $"{userInstruction}\n\n{conversationText}") }; - var chatOptions = BuildChatOptions(); + var chatOptions = BuildChatOptions(responseFormat); int maxAttempts = _options.MaxRetries < 0 ? 1 : _options.MaxRetries + 1; @@ -53,7 +55,8 @@ internal async Task> RunAsync( { cancellationToken.ThrowIfCancellationRequested(); - var response = await _chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken) + var response = await GetResponseWithTransportRetryAsync( + chatMessages, chatOptions, cancellationToken) .ConfigureAwait(false); var raw = response.Text; @@ -72,15 +75,116 @@ internal async Task> RunAsync( "That response was not valid JSON. Reply with ONLY the JSON object — no markdown fences, no prose.")); } } + if (failOnParseExhaustion) + throw new FormatException("LLM extraction exhausted its parse retries without valid JSON."); + return Array.Empty(); } - private ChatOptions BuildChatOptions() + + /// + /// Calls the provider, retrying transport failures with backoff. + /// + /// + /// Separate from the parse-retry loop above, and for a different failure. That loop re-prompts a + /// model that answered with unparseable JSON; this one re-sends an identical request that never + /// got an answer at all. Before this existed there was no transport retry anywhere in the + /// extraction path, and two 614-call preparations died mid-run on a single transient, at 37 and + /// 26 minutes each. + /// + /// A is deliberately not retried: it is caused by the request's own + /// shape, so re-sending it unchanged cannot help. That mirrors the batch splitter, which splits + /// on exactly that set and nothing else. Cancellation is never retried — retrying it would make + /// the preparation watchdog's timeout unenforceable. + /// + /// + + /// + /// Whether a provider failure is worth re-sending an identical request for. + /// + /// + /// Retrying a permanent failure is not merely useless, it is expensive: an n=50 preparation spent + /// its 60-minute budget re-sending requests the provider had already rejected with + /// HTTP 400, and the watchdog fired with 7 failures and 544 of 614 calls done. A 400 says + /// the request is wrong — most often too large — and the same request will be just as wrong the + /// third time. + /// + /// Retryable: 408, 429, and 5xx, plus transport-level exceptions that never reached the service + /// and so carry no status. Everything else is permanent. An oversized request is separately + /// recoverable by splitting the batch, which is a different mechanism and the right one. + /// + /// + internal static bool IsTransient(Exception exception) + { + var status = TryGetStatus(exception); + if (status is null) + return true; // never reached the service: a connection reset, a DNS failure, a timeout + return status is 408 or 429 || status >= 500; + } + + /// + /// The HTTP status behind a provider exception, or null when the call never got one. + /// + /// + /// Read reflectively rather than by referencing System.ClientModel: the status lives on + /// ClientResultException.Status for Azure/OpenAI clients and on + /// HttpRequestException.StatusCode for raw HTTP, and this library should not take a + /// package dependency to classify an error. + /// + internal static int? TryGetStatus(Exception exception) + { + if (exception is HttpRequestException { StatusCode: { } code }) + return (int)code; + + var property = exception.GetType().GetProperty("Status"); + if (property?.GetValue(exception) is int status && status > 0) + return status; + + return exception.InnerException is null ? null : TryGetStatus(exception.InnerException); + } + + private async Task GetResponseWithTransportRetryAsync( + List chatMessages, + ChatOptions chatOptions, + CancellationToken cancellationToken) + { + int maxAttempts = _options.MaxRetries < 0 ? 1 : _options.MaxRetries + 1; + for (int attempt = 1; ; attempt++) + { + try + { + return await _chatClient + .GetResponseAsync(chatMessages, chatOptions, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (FormatException) + { + throw; + } + catch (Exception exception) when (attempt < maxAttempts && IsTransient(exception)) + { + _logger.LogWarning( + exception, + "LLM extraction transport failure (attempt {Attempt}/{MaxAttempts}); retrying.", + attempt, maxAttempts); + await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), cancellationToken) + .ConfigureAwait(false); + } + } + } + + private ChatOptions BuildChatOptions(ChatResponseFormat? responseFormat) { var opts = new ChatOptions { Temperature = _options.Temperature }; if (!string.IsNullOrEmpty(_options.ModelId)) opts.ModelId = _options.ModelId; + if (_options.UseJsonResponseFormat) + opts.ResponseFormat = responseFormat ?? ChatResponseFormat.Json; return opts; } diff --git a/src/AgentMemory.Extraction.Llm/Internal/LlmResponseModels.cs b/src/AgentMemory.Extraction.Llm/Internal/LlmResponseModels.cs index b53095b2..642d7b03 100644 --- a/src/AgentMemory.Extraction.Llm/Internal/LlmResponseModels.cs +++ b/src/AgentMemory.Extraction.Llm/Internal/LlmResponseModels.cs @@ -4,6 +4,9 @@ namespace AgentMemory.Extraction.Llm.Internal; internal sealed class LlmEntityDto { + [JsonPropertyName("source_session")] + public string? SourceSession { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = ""; @@ -25,6 +28,9 @@ internal sealed class LlmEntityDto internal sealed class LlmFactDto { + [JsonPropertyName("source_session")] + public string? SourceSession { get; set; } + [JsonPropertyName("subject")] public string Subject { get; set; } = ""; @@ -40,6 +46,9 @@ internal sealed class LlmFactDto internal sealed class LlmPreferenceDto { + [JsonPropertyName("source_session")] + public string? SourceSession { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } = ""; @@ -55,6 +64,9 @@ internal sealed class LlmPreferenceDto internal sealed class LlmRelationshipDto { + [JsonPropertyName("source_session")] + public string? SourceSession { get; set; } + [JsonPropertyName("source")] public string Source { get; set; } = ""; @@ -73,6 +85,9 @@ internal sealed class LlmRelationshipDto internal sealed class LlmExtractionResponse { + [JsonPropertyName("processed_source_sessions")] + public List? ProcessedSourceSessions { get; set; } + [JsonPropertyName("entities")] public List Entities { get; set; } = new(); diff --git a/src/AgentMemory.Extraction.Llm/LlmExtractionBatchConcurrencyLimiter.cs b/src/AgentMemory.Extraction.Llm/LlmExtractionBatchConcurrencyLimiter.cs new file mode 100644 index 00000000..b1e6699a --- /dev/null +++ b/src/AgentMemory.Extraction.Llm/LlmExtractionBatchConcurrencyLimiter.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Options; + +namespace AgentMemory.Extraction.Llm; + +/// +/// Process-local provider-batch limiter shared by all scoped multi-session extractors in one +/// service provider. A zero configured limit keeps the historical uncapped cross-scope behavior. +/// +internal sealed class LlmExtractionBatchConcurrencyLimiter : IDisposable +{ + private readonly SemaphoreSlim? _gate; + + public LlmExtractionBatchConcurrencyLimiter( + IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var maximum = options.Value.MaxConcurrentExtractionBatches; + if (maximum > 0) + _gate = new SemaphoreSlim(maximum, maximum); + } + + internal async Task RunAsync( + Func> operation, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); + if (_gate is null) + return await operation().ConfigureAwait(false); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + public void Dispose() => _gate?.Dispose(); +} diff --git a/src/AgentMemory.Extraction.Llm/LlmExtractionBatchDiagnostics.cs b/src/AgentMemory.Extraction.Llm/LlmExtractionBatchDiagnostics.cs new file mode 100644 index 00000000..5ce9235e --- /dev/null +++ b/src/AgentMemory.Extraction.Llm/LlmExtractionBatchDiagnostics.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; + +namespace AgentMemory.Extraction.Llm; + +internal sealed class LlmExtractionBatchDiagnostics +{ + private const int MaximumDetails = 32; + private readonly ConcurrentQueue _details = new(); + private long _splits; + private long _droppedDetails; + + internal void RecordSplit(Exception exception, int sourceSessions) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sourceSessions); + Interlocked.Increment(ref _splits); + _details.Enqueue(new LlmExtractionBatchSplitDetail( + Classify(exception), + sourceSessions, + exception.GetType().FullName ?? exception.GetType().Name)); + while (_details.Count > MaximumDetails && _details.TryDequeue(out _)) + Interlocked.Increment(ref _droppedDetails); + } + + internal LlmExtractionBatchDiagnosticsSnapshot Snapshot() => + new( + Interlocked.Read(ref _splits), + _details.ToArray(), + Interlocked.Read(ref _droppedDetails)); + + private static string Classify(Exception exception) => + exception.Message switch + { + "Processed-session acknowledgement is incomplete or invalid." => + "acknowledgement", + "A learned item has a missing or unknown source-session key." => + "source-session-key", + "Batch exceeds the configured input-token budget." => + "token-budget", + _ when exception is FormatException => "parse-or-format", + _ when exception is OperationCanceledException => "cancellation", + _ => "other" + }; +} + +internal sealed record LlmExtractionBatchDiagnosticsSnapshot( + long Splits, + IReadOnlyList Details, + long DroppedDetails) +{ + internal LlmExtractionBatchDiagnosticsSnapshot Delta( + LlmExtractionBatchDiagnosticsSnapshot baseline) => + new( + Splits - baseline.Splits, + Details.Skip(Math.Min(Details.Count, baseline.Details.Count)).ToArray(), + DroppedDetails - baseline.DroppedDetails); +} + +internal sealed record LlmExtractionBatchSplitDetail( + string Reason, + int SourceSessions, + string ExceptionType); diff --git a/src/AgentMemory.Extraction.Llm/LlmExtractionOptions.cs b/src/AgentMemory.Extraction.Llm/LlmExtractionOptions.cs index b01b2aea..e2107a89 100644 --- a/src/AgentMemory.Extraction.Llm/LlmExtractionOptions.cs +++ b/src/AgentMemory.Extraction.Llm/LlmExtractionOptions.cs @@ -15,6 +15,38 @@ public sealed class LlmExtractionOptions /// public int MaxRetries { get; set; } = 2; + /// + /// Whether extraction requests should ask the chat provider for a JSON response. + /// Disable only for providers that do not support the portable response-format hint. + /// + public bool UseJsonResponseFormat { get; set; } = true; + + /// + /// Uses one typed model response for entities, facts, preferences, and relationships. + /// Disabled by default until the unified path passes live extraction-quality acceptance; + /// the existing four-category extraction path remains the compatibility control. + /// + public bool UseUnifiedExtraction { get; set; } + + /// + /// Enables token-bounded multi-session unified extraction through + /// IMemoryExtractionPipeline.ExtractBatchAsync. Disabled by default; single-session + /// extraction behavior is unchanged. + /// + public bool UseMultiSessionBatchExtraction { get; set; } + + /// + /// Maximum number of planned multi-session batches that one extraction operation may send + /// concurrently. The default of one preserves the historical sequential provider-call order. + /// + public int MaxConcurrentBatchesPerExtraction { get; set; } = 1; + + /// + /// Optional process-local cap shared by all multi-session extraction operations registered in + /// the same service provider. Zero disables the shared cap. + /// + public int MaxConcurrentExtractionBatches { get; set; } + /// /// Model identifier to use. null (the default) means use the IChatClient default. /// @@ -49,4 +81,15 @@ public sealed class LlmExtractionOptions /// When null the extractor's built-in default prompt is used. /// public string? PreferenceExtractionPrompt { get; set; } + + /// + /// Offers the established relation vocabulary to the extractor so it reuses relation names + /// instead of inventing a phrasing per sentence. Default off. + /// + /// + /// QUALITY-RISK: it changes what the model emits, and it lengthens the prompt, which moves the + /// frozen batch plan's estimated input totals. Opt-in so the effect can be measured against an + /// unchanged control before it becomes the default. + /// + public bool UsePredicateVocabulary { get; set; } } diff --git a/src/AgentMemory.Extraction.Llm/LlmMultiSessionExtractionResponseContract.cs b/src/AgentMemory.Extraction.Llm/LlmMultiSessionExtractionResponseContract.cs new file mode 100644 index 00000000..cfd7d58d --- /dev/null +++ b/src/AgentMemory.Extraction.Llm/LlmMultiSessionExtractionResponseContract.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace AgentMemory.Extraction.Llm; + +internal static class LlmMultiSessionExtractionResponseContract +{ + internal const string Version = "batch-source-alias-schema-v1"; + + internal static string Alias(int zeroBasedIndex) => $"s{zeroBasedIndex + 1}"; + + internal static ChatResponseFormat CreateResponseFormat(int sourceSessions) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sourceSessions); + return ChatResponseFormat.ForJsonSchema( + CreateSchema(sourceSessions), + "agent_memory_multi_session_v1", + "Structured memory extracted independently for each source-session alias."); + } + + internal static JsonElement CreateSchema(int sourceSessions) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sourceSessions); + var aliases = Enumerable.Range(0, sourceSessions).Select(Alias).ToArray(); + + Dictionary StringSchema() => new() { ["type"] = "string" }; + Dictionary NullableStringSchema() => + new() { ["type"] = new[] { "string", "null" } }; + Dictionary NumberSchema() => new() { ["type"] = "number" }; + Dictionary AliasSchema() => new() + { + ["type"] = "string", + ["enum"] = aliases + }; + Dictionary ArraySchema(object items) => new() + { + ["type"] = "array", + ["items"] = items + }; + Dictionary ObjectSchema( + Dictionary properties, + params string[] required) => new() + { + ["type"] = "object", + ["properties"] = properties, + ["required"] = required, + ["additionalProperties"] = false + }; + + var entity = ObjectSchema( + new Dictionary + { + ["source_session"] = AliasSchema(), + ["name"] = StringSchema(), + ["type"] = StringSchema(), + ["subtype"] = NullableStringSchema(), + ["description"] = NullableStringSchema(), + ["confidence"] = NumberSchema(), + ["aliases"] = ArraySchema(StringSchema()) + }, + "source_session", "name", "type", "subtype", "description", "confidence", "aliases"); + var fact = ObjectSchema( + new Dictionary + { + ["source_session"] = AliasSchema(), + ["subject"] = StringSchema(), + ["predicate"] = StringSchema(), + ["object"] = StringSchema(), + ["confidence"] = NumberSchema() + }, + "source_session", "subject", "predicate", "object", "confidence"); + var preference = ObjectSchema( + new Dictionary + { + ["source_session"] = AliasSchema(), + ["category"] = StringSchema(), + ["preference"] = StringSchema(), + ["context"] = NullableStringSchema(), + ["confidence"] = NumberSchema() + }, + "source_session", "category", "preference", "context", "confidence"); + var relationship = ObjectSchema( + new Dictionary + { + ["source_session"] = AliasSchema(), + ["source"] = StringSchema(), + ["target"] = StringSchema(), + ["relation_type"] = StringSchema(), + ["description"] = NullableStringSchema(), + ["confidence"] = NumberSchema() + }, + "source_session", "source", "target", "relation_type", "description", "confidence"); + + return JsonSerializer.SerializeToElement( + ObjectSchema( + new Dictionary + { + ["processed_source_sessions"] = new Dictionary + { + ["type"] = "array", + ["items"] = AliasSchema(), + ["minItems"] = sourceSessions, + ["maxItems"] = sourceSessions + }, + ["entities"] = ArraySchema(entity), + ["facts"] = ArraySchema(fact), + ["preferences"] = ArraySchema(preference), + ["relations"] = ArraySchema(relationship) + }, + "processed_source_sessions", "entities", "facts", "preferences", "relations")); + } +} diff --git a/src/AgentMemory.Extraction.Llm/LlmMultiSessionUnifiedMemoryExtractor.cs b/src/AgentMemory.Extraction.Llm/LlmMultiSessionUnifiedMemoryExtractor.cs new file mode 100644 index 00000000..9d5a7e90 --- /dev/null +++ b/src/AgentMemory.Extraction.Llm/LlmMultiSessionUnifiedMemoryExtractor.cs @@ -0,0 +1,475 @@ +using System.Text; +using AgentMemory.Abstractions.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.Extraction.Llm.Internal; +using AgentMemory.Core.Memory; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AgentMemory.Extraction.Llm; + +/// +/// Token-bounded multi-session unified extraction. Invalid or partial batch responses are never +/// accepted: a multi-session batch is split recursively, while an invalid single-session response +/// fails the operation. +/// +internal sealed class LlmMultiSessionUnifiedMemoryExtractor : IMultiSessionUnifiedMemoryExtractor +{ + private const string SystemPrompt = + """ + You extract structured long-term memory from multiple independent source sessions. + Return JSON only. Include processed_source_sessions containing every supplied source_session. + Every entity, fact, preference, and relation must include its source_session. + Use exactly this shape: + {"processed_source_sessions":["..."],"entities":[{"source_session":"...","name":"...","type":"PERSON|ORGANIZATION|LOCATION|EVENT|OBJECT","confidence":0.9,"aliases":[]}],"facts":[{"source_session":"...","subject":"...","predicate":"...","object":"...","confidence":0.9}],"preferences":[{"source_session":"...","category":"...","preference":"...","confidence":0.85}],"relations":[{"source_session":"...","source":"...","target":"...","relation_type":"...","confidence":0.8}]} + Sessions are independent. Never combine facts or entities across source_session values. + Use empty arrays when a category has no supported memory. Do not emit prose or markdown. + """; + + /// + /// The system prompt, with the established relation vocabulary offered when one is supplied. + /// + /// + /// Extraction invents a predicate per sentence when nothing tells it which relations exist — + /// measured at 700 facts under 421 distinct predicates, with a single birth expressed as + /// "was born", "was born in", "were born in", "had" and "welcomed", which left counting + /// questions unanswerable even once a relation could be retrieved whole. Reconciling phrasings + /// afterwards cannot be done safely, since "bought" and "sold" are one similarity threshold + /// apart, so the vocabulary is applied at generation instead. + /// + /// The extractor is told to prefer these relations, never to be limited to them: a model + /// restricted to a fixed list would drop facts that genuinely need a new relation. An empty + /// vocabulary yields the original prompt byte-for-byte, so callers that do not use this are + /// unaffected — including the frozen batch plan, whose estimated input totals depend on prompt + /// size. + /// + /// + internal static string BuildSystemPrompt(MemoryPredicateVocabulary? vocabulary) + { + var established = vocabulary?.Snapshot() ?? []; + if (established.Count == 0) + return SystemPrompt; + + return SystemPrompt + + "\nEstablished relation predicates, in order of preference: " + + string.Join(", ", established) + + ".\nReuse an established predicate whenever it fits; introduce a new one only when none does."; + } + + private const string UserInstruction = + "Extract every source session independently and acknowledge all processed source sessions:"; + + /// The vocabulary offered to the model, or null when the option is off. + /// + /// Built from the curated seed on each use rather than cached, so the size the plan estimates + /// and the string actually sent can never diverge — an estimate taken from a different prompt + /// than the request would corrupt the frozen plan's token accounting silently. + /// + private MemoryPredicateVocabulary? ActiveVocabulary => + _options.UsePredicateVocabulary ? MemoryPredicateSeedVocabulary.Create() : null; + + private readonly IChatClient _chatClient; + private readonly LlmExtractionOptions _options; + private readonly ILogger _logger; + private readonly LlmExtractionBatchConcurrencyLimiter? _concurrencyLimiter; + private readonly LlmExtractionBatchDiagnostics? _batchDiagnostics; + + public LlmMultiSessionUnifiedMemoryExtractor( + IChatClient chatClient, + IOptions options, + ILogger logger, + LlmExtractionBatchConcurrencyLimiter? concurrencyLimiter = null, + LlmExtractionBatchDiagnostics? batchDiagnostics = null) + { + _chatClient = chatClient; + _options = options.Value; + _logger = logger; + _concurrencyLimiter = concurrencyLimiter; + _batchDiagnostics = batchDiagnostics; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero( + _options.MaxConcurrentBatchesPerExtraction); + } + + public bool IsEnabled => + _options.UseUnifiedExtraction && _options.UseMultiSessionBatchExtraction; + + public MultiSessionExtractionPlan Plan( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxSessionsPerBatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxInputTokens); + + var duplicate = requests.GroupBy(request => request.SessionId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() != 1); + if (duplicate is not null) + throw new ArgumentException($"Source session key '{duplicate.Key}' is not unique.", nameof(requests)); + + var batches = PlanBatches(requests, maxSessionsPerBatch, maxInputTokens) + .Select(batch => new MultiSessionExtractionBatchPlan( + batch.Select(request => request.SessionId).ToArray(), + EstimateInputTokens(batch))) + .ToArray(); + return new MultiSessionExtractionPlan(batches); + } + + public async Task> ExtractAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default) + { + var plan = Plan(requests, maxSessionsPerBatch, maxInputTokens); + var requestsBySession = requests.ToDictionary( + request => request.SessionId, + StringComparer.Ordinal); + var extractedByBatch = + new IReadOnlyDictionary?[plan.BatchCount]; + var concurrency = Math.Min( + _options.MaxConcurrentBatchesPerExtraction, + plan.BatchCount); + + if (concurrency <= 1) + { + for (var index = 0; index < plan.BatchCount; index++) + { + extractedByBatch[index] = await ExtractPlannedBatchAsync( + plan.Batches[index], + requestsBySession, + maxInputTokens, + cancellationToken) + .ConfigureAwait(false); + } + } + else + { + await Parallel.ForEachAsync( + Enumerable.Range(0, plan.BatchCount), + new ParallelOptions + { + MaxDegreeOfParallelism = concurrency, + CancellationToken = cancellationToken + }, + async (index, itemCancellationToken) => + { + extractedByBatch[index] = await ExtractPlannedBatchAsync( + plan.Batches[index], + requestsBySession, + maxInputTokens, + itemCancellationToken) + .ConfigureAwait(false); + }).ConfigureAwait(false); + } + + var unordered = new Dictionary(StringComparer.Ordinal); + foreach (var extracted in extractedByBatch) + { + if (extracted is null) + throw new InvalidOperationException( + "Multi-session extraction did not complete every planned batch."); + foreach (var pair in extracted) + unordered.Add(pair.Key, pair.Value); + } + + if (unordered.Count != requests.Count) + throw new InvalidOperationException( + $"Multi-session extraction returned {unordered.Count} sessions for {requests.Count} inputs."); + + var results = new Dictionary(StringComparer.Ordinal); + foreach (var request in requests) + results.Add(request.SessionId, unordered[request.SessionId]); + return results; + } + + private async Task> + ExtractPlannedBatchAsync( + MultiSessionExtractionBatchPlan plannedBatch, + IReadOnlyDictionary requestsBySession, + int maxInputTokens, + CancellationToken cancellationToken) + { + var batch = plannedBatch.SourceSessionIds + .Select(sessionId => requestsBySession[sessionId]) + .ToArray(); + return await ExtractOrSplitAsync( + batch, + maxInputTokens, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task> ExtractOrSplitAsync( + IReadOnlyList batch, + int maxInputTokens, + CancellationToken cancellationToken) + { + try + { + if (EstimateInputTokens(batch) > maxInputTokens) + throw new BatchValidationException("Batch exceeds the configured input-token budget."); + return await ExtractBatchAsync(batch, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + // FormatException only, deliberately. Every failure the batch's own shape causes arrives as + // one - BatchValidationException derives from it, covering the token budget, the + // acknowledgement check and the source-session-key check, as does an unparseable response - + // and halving the batch is a real remedy for each. A provider transport failure is not that: + // re-sending each half puts the same request shape at the same endpoint that just failed, so + // the split neither diagnoses nor fixes it, and it doubles the call count. That broke a + // 37-minute n=50 preparation at question 20 ("observed 14 calls ... expected exactly 12") + // over one ClientResultException. + // + // NOTE, verified rather than assumed: there is currently NO transport retry on this path. + // LlmExtractionRunner honours MaxRetries, but only by re-prompting on a parse failure - its + // GetResponseAsync call sits outside any catch, so a transport exception propagates + // immediately. Splitting was therefore the only thing resembling a retry for transports, and + // it was a bad one: it re-sent to the endpoint that had just failed and doubled the call + // count. Removing it does not remove a working recovery; it removes a misleading one. A real + // transport retry is tracked separately, because it must be reconciled with the harness's + // exact-call-count invariant rather than quietly breaking it. + catch (Exception ex) when (batch.Count > 1 && IsBatchShapeFailure(ex)) + { + _batchDiagnostics?.RecordSplit(ex, batch.Count); + _logger.LogWarning( + ex, + "Multi-session extraction batch of {Count} did not pass validation; splitting.", + batch.Count); + var midpoint = batch.Count / 2; + var left = await ExtractOrSplitAsync(batch.Take(midpoint).ToArray(), maxInputTokens, cancellationToken) + .ConfigureAwait(false); + var right = await ExtractOrSplitAsync(batch.Skip(midpoint).ToArray(), maxInputTokens, cancellationToken) + .ConfigureAwait(false); + return left.Concat(right).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + } + } + + private async Task> ExtractBatchAsync( + IReadOnlyList batch, + CancellationToken cancellationToken) + { + var estimatedInputTokens = EstimateInputTokens(batch); + using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.extract.unified_batch"); + activity?.SetTag("memory.extract.source_sessions", batch.Count); + activity?.SetTag("memory.extract.estimated_input_tokens", estimatedInputTokens); + var runner = new LlmExtractionRunner(_chatClient, _options, _logger); + + Task>> RunProviderAsync() => + runner.RunAsync( + BuildSystemPrompt(ActiveVocabulary), + UserInstruction, + BuildBatchText(batch), + response => new[] { ProjectAndValidate(response, batch) }, + cancellationToken, + failOnParseExhaustion: true, + responseFormat: LlmMultiSessionExtractionResponseContract.CreateResponseFormat(batch.Count)); + + var projected = _concurrencyLimiter is null + ? await RunProviderAsync().ConfigureAwait(false) + : await _concurrencyLimiter.RunAsync(RunProviderAsync, cancellationToken) + .ConfigureAwait(false); + return projected.Single(); + } + + private static IReadOnlyDictionary ProjectAndValidate( + LlmExtractionResponse response, + IReadOnlyList batch) + { + var sourceSessions = batch.Select((request, index) => + new BatchSourceSession( + LlmMultiSessionExtractionResponseContract.Alias(index), request)).ToArray(); + var expected = sourceSessions.Select(item => item.Alias).ToHashSet(StringComparer.Ordinal); + var acknowledged = (response.ProcessedSourceSessions ?? []) + .ToHashSet(StringComparer.Ordinal); + if (!acknowledged.SetEquals(expected) || response.ProcessedSourceSessions!.Count != expected.Count) + throw new BatchValidationException("Processed-session acknowledgement is incomplete or invalid."); + + var results = expected.ToDictionary( + key => key, + _ => new Accumulator(), + StringComparer.Ordinal); + + foreach (var item in response.Entities ?? []) + { + var target = GetAccumulator(results, item.SourceSession); + if (!string.IsNullOrWhiteSpace(item.Name) && !string.IsNullOrWhiteSpace(item.Type)) + target.Entities.Add(new ExtractedEntity + { + Name = item.Name, + Type = NormalizeType(item.Type), + Subtype = item.Subtype, + Description = item.Description, + Confidence = item.Confidence, + Aliases = item.Aliases, + }); + } + foreach (var item in response.Facts ?? []) + { + var target = GetAccumulator(results, item.SourceSession); + if (!string.IsNullOrWhiteSpace(item.Subject) && + !string.IsNullOrWhiteSpace(item.Predicate) && + !string.IsNullOrWhiteSpace(item.Object)) + target.Facts.Add(new ExtractedFact + { + Subject = item.Subject, + Predicate = item.Predicate, + Object = item.Object, + Confidence = item.Confidence, + }); + } + foreach (var item in response.Preferences ?? []) + { + var target = GetAccumulator(results, item.SourceSession); + if (!string.IsNullOrWhiteSpace(item.Preference)) + target.Preferences.Add(new ExtractedPreference + { + Category = item.Category, + PreferenceText = item.Preference, + Context = item.Context, + Confidence = item.Confidence, + }); + } + foreach (var item in response.Relations ?? []) + { + var target = GetAccumulator(results, item.SourceSession); + if (!string.IsNullOrWhiteSpace(item.Source) && + !string.IsNullOrWhiteSpace(item.Target) && + !string.IsNullOrWhiteSpace(item.RelationType)) + target.Relationships.Add(new ExtractedRelationship + { + SourceEntity = item.Source, + TargetEntity = item.Target, + RelationshipType = item.RelationType, + Description = item.Description, + Confidence = item.Confidence, + }); + } + + return sourceSessions.ToDictionary( + item => item.Request.SessionId, + item => results[item.Alias].ToResult(), + StringComparer.Ordinal); + } + + private static Accumulator GetAccumulator( + IReadOnlyDictionary results, + string? sourceSession) + { + if (string.IsNullOrWhiteSpace(sourceSession) || !results.TryGetValue(sourceSession, out var target)) + throw new BatchValidationException("A learned item has a missing or unknown source-session key."); + return target; + } + + private IReadOnlyList> PlanBatches( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens) + { + var batches = new List>(); + var current = new List(); + foreach (var request in requests) + { + if (EstimateInputTokens([request]) > maxInputTokens) + throw new InvalidOperationException( + $"Source session '{request.SessionId}' exceeds the configured input-token budget."); + + var candidate = current.Append(request).ToArray(); + if (current.Count > 0 && + (candidate.Length > maxSessionsPerBatch || EstimateInputTokens(candidate) > maxInputTokens)) + { + batches.Add(current.ToArray()); + current.Clear(); + } + current.Add(request); + } + if (current.Count > 0) + batches.Add(current.ToArray()); + return batches; + } + + private int EstimateInputTokens(IReadOnlyList batch) => + checked( + Encoding.UTF8.GetByteCount(BuildSystemPrompt(ActiveVocabulary)) + + Encoding.UTF8.GetByteCount(UserInstruction) + + Encoding.UTF8.GetByteCount(BuildBatchText(batch)) + + 35); + + private static string BuildBatchText(IReadOnlyList batch) + { + var builder = new StringBuilder(); + for (var index = 0; index < batch.Count; index++) + { + var request = batch[index]; + builder.Append(""); + foreach (var message in request.Messages) + { + builder.Append('[').Append(message.TimestampUtc.ToString("O")).Append("] ") + .Append(message.Role).Append(": ").AppendLine(message.Content); + } + builder.AppendLine(""); + } + return builder.ToString(); + } + + private static string NormalizeType(string type) => type.ToUpperInvariant() switch + { + "CONCEPT" => "OBJECT", + "PLACE" => "LOCATION", + "COMPANY" => "ORGANIZATION", + "INDIVIDUAL" => "PERSON", + var value => value, + }; + private sealed record BatchSourceSession( + string Alias, + ExtractionRequest Request); + + + private sealed class Accumulator + { + public List Entities { get; } = []; + public List Facts { get; } = []; + public List Preferences { get; } = []; + public List Relationships { get; } = []; + + public UnifiedExtractionResult ToResult() => new() + { + Entities = Entities, + Facts = Facts, + Preferences = Preferences, + Relationships = Relationships, + }; + } + + + /// + /// Whether a failure is caused by the batch's own shape, and so is worth splitting for. + /// + /// + /// Two families qualify. covers the validation and parse failures + /// this class raises itself. A permanent 4xx qualifies too, and missing it cost a full + /// 60-minute preparation: the provider rejected oversized batches with HTTP 400, the splitter + /// had been narrowed to FormatException only so it declined to help, and the transport retry + /// re-sent each rejected request until the watchdog fired. + /// + /// 408 and 429 are excluded deliberately — they are transient and belong to the retry policy, and + /// splitting on a rate limit would answer congestion by sending more requests. + /// + /// + internal static bool IsBatchShapeFailure(Exception exception) + { + if (exception is FormatException) + return true; + + var status = Internal.LlmExtractionRunner.TryGetStatus(exception); + return status is >= 400 and < 500 and not 408 and not 429; + } + + private sealed class BatchValidationException(string message) : FormatException(message); +} diff --git a/src/AgentMemory.Extraction.Llm/LlmUnifiedMemoryExtractor.cs b/src/AgentMemory.Extraction.Llm/LlmUnifiedMemoryExtractor.cs new file mode 100644 index 00000000..3744b94b --- /dev/null +++ b/src/AgentMemory.Extraction.Llm/LlmUnifiedMemoryExtractor.cs @@ -0,0 +1,111 @@ +using AgentMemory.Abstractions.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Extraction.Llm.Internal; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AgentMemory.Extraction.Llm; + +internal sealed class LlmUnifiedMemoryExtractor : IUnifiedMemoryExtractor +{ + private const string SystemPrompt = + """ + You extract structured long-term memory from a conversation. + Return JSON only with all four arrays: entities, facts, preferences, relations. + Use exactly this shape: + {"entities":[{"name":"...","type":"PERSON|ORGANIZATION|LOCATION|EVENT|OBJECT","confidence":0.9,"aliases":[]}],"facts":[{"subject":"...","predicate":"...","object":"...","confidence":0.9}],"preferences":[{"category":"...","preference":"...","confidence":0.85}],"relations":[{"source":"...","target":"...","relation_type":"...","confidence":0.8}]} + Use empty arrays when a category has no supported memory. Do not emit prose or markdown. + """; + + private readonly LlmExtractionOptions _options; + private readonly LlmExtractionRunner _runner; + + public LlmUnifiedMemoryExtractor( + IChatClient chatClient, + IOptions options, + ILogger logger) + { + _options = options.Value; + _runner = new LlmExtractionRunner(chatClient, _options, logger); + } + + public bool IsEnabled => _options.UseUnifiedExtraction; + + public async Task ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default) + { + if (messages.Count == 0) + return new UnifiedExtractionResult(); + + using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.extract.unified"); + var results = await _runner.RunAsync( + SystemPrompt, + "Extract all supported memory from this conversation:", + ConversationTextBuilder.Build(messages), + response => new[] { Project(response) }, + cancellationToken, + failOnParseExhaustion: true).ConfigureAwait(false); + return results.Count == 1 ? results[0] : new UnifiedExtractionResult(); + } + + private static UnifiedExtractionResult Project(LlmExtractionResponse response) => + new() + { + Entities = (response.Entities ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.Name) && !string.IsNullOrWhiteSpace(item.Type)) + .Select(item => new ExtractedEntity + { + Name = item.Name, + Type = NormalizeType(item.Type), + Subtype = item.Subtype, + Description = item.Description, + Confidence = item.Confidence, + Aliases = item.Aliases, + }).ToArray(), + Facts = (response.Facts ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.Subject) && + !string.IsNullOrWhiteSpace(item.Predicate) && + !string.IsNullOrWhiteSpace(item.Object)) + .Select(item => new ExtractedFact + { + Subject = item.Subject, + Predicate = item.Predicate, + Object = item.Object, + Confidence = item.Confidence, + }).ToArray(), + Preferences = (response.Preferences ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.Preference)) + .Select(item => new ExtractedPreference + { + Category = item.Category, + PreferenceText = item.Preference, + Context = item.Context, + Confidence = item.Confidence, + }).ToArray(), + Relationships = (response.Relations ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.Source) && + !string.IsNullOrWhiteSpace(item.Target) && + !string.IsNullOrWhiteSpace(item.RelationType)) + .Select(item => new ExtractedRelationship + { + SourceEntity = item.Source, + TargetEntity = item.Target, + RelationshipType = item.RelationType, + Description = item.Description, + Confidence = item.Confidence, + }).ToArray(), + }; + + private static string NormalizeType(string type) => type.ToUpperInvariant() switch + { + "CONCEPT" => "OBJECT", + "PLACE" => "LOCATION", + "COMPANY" => "ORGANIZATION", + "INDIVIDUAL" => "PERSON", + var value => value, + }; +} diff --git a/src/AgentMemory.Extraction.Llm/ServiceCollectionExtensions.cs b/src/AgentMemory.Extraction.Llm/ServiceCollectionExtensions.cs index 03963277..550298c5 100644 --- a/src/AgentMemory.Extraction.Llm/ServiceCollectionExtensions.cs +++ b/src/AgentMemory.Extraction.Llm/ServiceCollectionExtensions.cs @@ -22,6 +22,10 @@ public static IServiceCollection AddLlmExtraction( llmOptions .Validate(o => o.Temperature >= 0.0f, "LlmExtraction Temperature must be non-negative.") .Validate(o => o.MaxRetries >= 0, "LlmExtraction MaxRetries must be non-negative.") + .Validate(o => o.MaxConcurrentBatchesPerExtraction > 0, + "LlmExtraction MaxConcurrentBatchesPerExtraction must be positive.") + .Validate(o => o.MaxConcurrentExtractionBatches >= 0, + "LlmExtraction MaxConcurrentExtractionBatches must be non-negative.") .ValidateOnStart(); // Replace (not TryAdd) so the real extractors authoritatively override the Core no-op stub @@ -33,6 +37,10 @@ public static IServiceCollection AddLlmExtraction( services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); return services; } diff --git a/src/AgentMemory.Neo4j/Infrastructure/CanonicalKeyBackfillRow.cs b/src/AgentMemory.Neo4j/Infrastructure/CanonicalKeyBackfillRow.cs new file mode 100644 index 00000000..b63159d0 --- /dev/null +++ b/src/AgentMemory.Neo4j/Infrastructure/CanonicalKeyBackfillRow.cs @@ -0,0 +1,11 @@ +namespace AgentMemory.Neo4j.Infrastructure; + +/// +/// One pre-canonical fact awaiting key backfill. Named rather than anonymous so the migration's +/// read shape is part of the type system and can be stubbed in tests. +/// +internal sealed record CanonicalKeyBackfillRow( + string Id, + string Subject, + string Predicate, + string Object); diff --git a/src/AgentMemory.Neo4j/Infrastructure/INeo4jAtomicTransactionRunner.cs b/src/AgentMemory.Neo4j/Infrastructure/INeo4jAtomicTransactionRunner.cs new file mode 100644 index 00000000..f7026f8d --- /dev/null +++ b/src/AgentMemory.Neo4j/Infrastructure/INeo4jAtomicTransactionRunner.cs @@ -0,0 +1,13 @@ +namespace AgentMemory.Neo4j.Infrastructure; + +/// Capability interface for joining repository calls into one logical write transaction. +public interface INeo4jAtomicTransactionRunner +{ + /// + /// Executes a logical persistence unit in one write transaction. Repository calls made from + /// through the paired join it. + /// + Task ExecuteAtomicWriteAsync( + Func> work, + CancellationToken cancellationToken = default); +} diff --git a/src/AgentMemory.Neo4j/Infrastructure/Neo4jMemoryPersistenceTransaction.cs b/src/AgentMemory.Neo4j/Infrastructure/Neo4jMemoryPersistenceTransaction.cs new file mode 100644 index 00000000..bdc92c07 --- /dev/null +++ b/src/AgentMemory.Neo4j/Infrastructure/Neo4jMemoryPersistenceTransaction.cs @@ -0,0 +1,53 @@ +using AgentMemory.Core.Extraction; + +namespace AgentMemory.Neo4j.Infrastructure; + +/// +/// Runs memory persistence inside one Neo4j write transaction when the configured runner can +/// provide one, and passes work straight through when it cannot. +/// +/// +/// is a public extension point, registered with +/// TryAddSingleton precisely so a host can substitute its own implementation. This type is +/// then registered unconditionally with Replace, so it receives whatever the host supplied. +/// +/// It previously hard-cast that runner to — an interface +/// this library added later — and threw when the cast failed. That turned a documented, deliberately +/// overridable seam into a startup crash for any host that had already exercised it: the substitution +/// was legal when they wrote it and became fatal on upgrade, with no compile-time signal. +/// +/// +/// Atomicity is optional by design, which is why carries +/// at all and why PersistenceStage already branches on +/// it. So a runner without atomic support degrades to pass-through and reports that honestly, rather +/// than claiming a rollback guarantee it cannot keep or refusing to start. +/// +/// +internal sealed class Neo4jMemoryPersistenceTransaction : IMemoryPersistenceTransaction +{ + private readonly INeo4jAtomicTransactionRunner? _atomicRunner; + + public Neo4jMemoryPersistenceTransaction(INeo4jTransactionRunner transactionRunner) + { + ArgumentNullException.ThrowIfNull(transactionRunner); + _atomicRunner = transactionRunner as INeo4jAtomicTransactionRunner; + } + + /// True only when the configured runner can actually roll back. + public bool SupportsAtomicRollback => _atomicRunner is not null; + + public Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(work); + + if (_atomicRunner is not null) + return _atomicRunner.ExecuteAtomicWriteAsync(work, cancellationToken); + + // No coordinator: run the work as-is. Callers that need all-or-nothing check + // SupportsAtomicRollback first, so this cannot silently downgrade a guarantee. + cancellationToken.ThrowIfCancellationRequested(); + return work(cancellationToken); + } +} diff --git a/src/AgentMemory.Neo4j/Infrastructure/Neo4jOptions.cs b/src/AgentMemory.Neo4j/Infrastructure/Neo4jOptions.cs index 9400e5fb..647b64f3 100644 --- a/src/AgentMemory.Neo4j/Infrastructure/Neo4jOptions.cs +++ b/src/AgentMemory.Neo4j/Infrastructure/Neo4jOptions.cs @@ -16,6 +16,13 @@ public class Neo4jOptions /// public int EmbeddingDimensions { get; set; } = 1536; + /// + /// Persists a message batch, its embeddings, ordering links, and read-back in one Cypher query. + /// Enabled by default; disable only for compatibility diagnosis or controlled A/B measurement. + /// The legacy path remains available and preserves its original multi-query behavior. + /// + public bool UseOptimizedMessageBatchWrites { get; set; } = true; + /// /// When (the default), schema bootstrap verifies that every existing vector /// index was created with and throws diff --git a/src/AgentMemory.Neo4j/Infrastructure/Neo4jTransactionRunner.cs b/src/AgentMemory.Neo4j/Infrastructure/Neo4jTransactionRunner.cs index 9d35bc25..6d2b3eb8 100644 --- a/src/AgentMemory.Neo4j/Infrastructure/Neo4jTransactionRunner.cs +++ b/src/AgentMemory.Neo4j/Infrastructure/Neo4jTransactionRunner.cs @@ -27,11 +27,12 @@ namespace AgentMemory.Neo4j.Infrastructure; /// the cost is one check per transaction. /// /// -internal sealed class Neo4jTransactionRunner : INeo4jTransactionRunner +internal sealed class Neo4jTransactionRunner : INeo4jTransactionRunner, INeo4jAtomicTransactionRunner { private readonly INeo4jSessionFactory _sessionFactory; private readonly ILogger _logger; + private readonly AsyncLocal _ambientWriteTransaction = new(); public Neo4jTransactionRunner(INeo4jSessionFactory sessionFactory, ILogger logger) { _sessionFactory = sessionFactory; @@ -41,15 +42,20 @@ public Neo4jTransactionRunner(INeo4jSessionFactory sessionFactory, ILogger ReadAsync(Func> work, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (_ambientWriteTransaction.Value is { } ambient) + return await work(ambient).ConfigureAwait(false); + using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.db.tx", ActivityKind.Client); activity?.SetTag("db.mode", "read"); var payload = activity is null ? null : new PayloadAccumulator(); + var transactionEntryStartedAt = activity is null ? 0 : Stopwatch.GetTimestamp(); var session = _sessionFactory.OpenSession(AccessMode.Read); await using var _ = session.ConfigureAwait(false); // ConfigureAwait the disposal without rebinding session's type try { - return await session.ExecuteReadAsync(Instrument(work, "read", activity, payload)).ConfigureAwait(false); + return await session.ExecuteReadAsync( + Instrument(work, "read", activity, payload, transactionEntryStartedAt)).ConfigureAwait(false); } catch (Exception ex) { @@ -75,15 +81,20 @@ await ReadAsync(async tx => public async Task WriteAsync(Func> work, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (_ambientWriteTransaction.Value is { } ambient) + return await work(ambient).ConfigureAwait(false); + using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.db.tx", ActivityKind.Client); activity?.SetTag("db.mode", "write"); var payload = activity is null ? null : new PayloadAccumulator(); + var transactionEntryStartedAt = activity is null ? 0 : Stopwatch.GetTimestamp(); var session = _sessionFactory.OpenSession(AccessMode.Write); await using var _ = session.ConfigureAwait(false); // ConfigureAwait the disposal without rebinding session's type try { - return await session.ExecuteWriteAsync(Instrument(work, "write", activity, payload)).ConfigureAwait(false); + return await session.ExecuteWriteAsync( + Instrument(work, "write", activity, payload, transactionEntryStartedAt)).ConfigureAwait(false); } catch (Exception ex) { @@ -97,6 +108,77 @@ public async Task WriteAsync(Func> work, Cancel } } + public async Task ExecuteAtomicWriteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Nested logical units join the outer unit. This also keeps the transaction boundary + // well-defined when a higher-level persistence workflow composes another one. + if (_ambientWriteTransaction.Value is not null) + return await work(cancellationToken).ConfigureAwait(false); + + using var activity = AgentMemoryDiagnostics.Source.StartActivity("memory.db.tx", ActivityKind.Client); + activity?.SetTag("db.mode", "write"); + activity?.SetTag("db.transaction.logical_unit", true); + var payload = activity is null ? null : new PayloadAccumulator(); + var transactionEntryStartedAt = activity is null ? 0 : Stopwatch.GetTimestamp(); + + var session = _sessionFactory.OpenSession(AccessMode.Write); + await using var _ = session.ConfigureAwait(false); + IAsyncTransaction? transaction = null; + try + { + // Explicit rather than managed transaction: the callback mutates in-memory outcome state + // and must execute exactly once. The caller owns any whole-operation retry after rollback. + transaction = await session.BeginTransactionAsync().ConfigureAwait(false); + activity?.SetTag( + "db.transaction_entry_ms_est", + Stopwatch.GetElapsedTime(transactionEntryStartedAt).TotalMilliseconds); + + IAsyncQueryRunner ambientRunner = activity is null + ? transaction + : new CountingQueryRunner(transaction, "write", activity, payload!); + _ambientWriteTransaction.Value = ambientRunner; + + var result = await work(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + return result; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error); + Exception? rollbackFailure = null; + if (transaction is not null) + { + try + { + await transaction.RollbackAsync().ConfigureAwait(false); + } + catch (Exception rollbackException) + { + rollbackFailure = rollbackException; + _logger.LogWarning(rollbackException, "Failed to roll back atomic memory transaction."); + } + } + + if (rollbackFailure is not null) + throw new AggregateException( + "Atomic memory transaction failed and rollback could not be confirmed.", ex, rollbackFailure); + + _logger.LogError(ex, "Error executing atomic memory transaction."); + throw; + } + finally + { + _ambientWriteTransaction.Value = null; + if (transaction is not null) + await transaction.DisposeAsync().ConfigureAwait(false); + TagPayload(activity, payload); + } + } + public async Task WriteAsync(Func work, CancellationToken cancellationToken = default) { await WriteAsync(async tx => @@ -116,13 +198,25 @@ private static Func> Instrument( Func> work, string mode, Activity? transaction, - PayloadAccumulator? payload) => + PayloadAccumulator? payload, + long transactionEntryStartedAt) => transaction is null ? work - : runner => work(new CountingQueryRunner(runner, mode, transaction, payload!)); + : runner => + { + // The driver's public API exposes acquisition counts and a timeout, but not wait duration. + // This upper-bound estimate starts immediately before ExecuteRead/WriteAsync and stops when + // its transaction callback begins. It therefore includes connection acquisition, routing, + // and transaction begin; the `_est` suffix is permanent and prevents a pure-pool-wait claim. + transaction.SetTag( + "db.transaction_entry_ms_est", + Stopwatch.GetElapsedTime(transactionEntryStartedAt).TotalMilliseconds); + return work(new CountingQueryRunner(runner, mode, transaction, payload!)); + }; private static void TagPayload(Activity? activity, PayloadAccumulator? payload) { + if (activity is null || payload is null) return; activity.SetTag("db.records", payload.RecordCount); activity.SetTag("db.bytes_est", payload.BytesEstimate); diff --git a/src/AgentMemory.Neo4j/Infrastructure/RerankParameters.cs b/src/AgentMemory.Neo4j/Infrastructure/RerankParameters.cs index e74ecec0..82de8f8b 100644 --- a/src/AgentMemory.Neo4j/Infrastructure/RerankParameters.cs +++ b/src/AgentMemory.Neo4j/Infrastructure/RerankParameters.cs @@ -25,6 +25,7 @@ public static void Add( parameters["now"] = DateTimeOffset.UtcNow.ToString("O"); parameters["lambda"] = Math.Log(2) / halfLife; parameters["boostFactor"] = decay.AccessBoostFactor; + parameters["maxBoost"] = decay.MaxAccessBoost; parameters["tmpWeight"] = ranking.EffectiveRecencyWeight; } } diff --git a/src/AgentMemory.Neo4j/Infrastructure/SchemaBootstrapper.cs b/src/AgentMemory.Neo4j/Infrastructure/SchemaBootstrapper.cs index 7de276c2..9fba026c 100644 --- a/src/AgentMemory.Neo4j/Infrastructure/SchemaBootstrapper.cs +++ b/src/AgentMemory.Neo4j/Infrastructure/SchemaBootstrapper.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Core.Memory; using AgentMemory.Neo4j.Queries; using Neo4j.Driver; @@ -14,6 +15,9 @@ internal sealed class SchemaBootstrapper : ISchemaBootstrapper private readonly int _embeddingDimensions; private readonly bool _validateVectorIndexDimensions; + /// Bounded so a large store migrates in pages rather than one transaction. + internal const int CanonicalKeyBackfillBatchSize = 500; + public SchemaBootstrapper( INeo4jTransactionRunner txRunner, IOptions options, @@ -27,6 +31,76 @@ public SchemaBootstrapper( _vectorIndexes = SchemaQueries.BuildVectorIndexes(_embeddingDimensions); } + /// + /// Facts written before canonical identity carry no *_key properties, so a re-extracted + /// triple never MERGEs onto them and predicate expansion cannot see them. Backfills the keys + /// during bootstrap, before any write can occur on an upgraded store. + /// + /// + /// Computed in C# and never in Cypher: toLower() and + /// disagree on U+0130, so a Cypher backfill would write keys the write path never matches. + /// Idempotent by construction — it selects on predicate_key IS NULL, so a re-run over a + /// migrated store does nothing. + /// + internal async Task BackfillCanonicalFactKeysAsync( + int batchSize, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); + var migrated = 0; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var pending = await _txRunner.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + FactQueries.SelectFactsMissingCanonicalKeys, + new { limit = batchSize }).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return records.Select(record => new CanonicalKeyBackfillRow( + record["id"].As(), + record["subject"].As(), + record["predicate"].As(), + record["object"].As())).ToList(); + }, cancellationToken).ConfigureAwait(false); + + if (pending.Count == 0) + break; + + var items = pending.Select(fact => new Dictionary + { + ["id"] = fact.Id, + ["subject_key"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Subject), + ["predicate_key"] = MemoryTripleCanonicalizer.Canonical(fact.Predicate), + ["object_key"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Object) + }).ToList(); + + await _txRunner.WriteAsync(async runner => + { + var cursor = await runner.RunAsync( + FactQueries.ApplyCanonicalKeys, new { items }).ConfigureAwait(false); + await cursor.ConsumeAsync().ConfigureAwait(false); + return true; + }, cancellationToken).ConfigureAwait(false); + + migrated += pending.Count; + + // A short final batch means the last page was reached; anything else would re-query for + // a page that cannot exist. + if (pending.Count < batchSize) + break; + } + + if (migrated > 0) + { + _logger.LogInformation( + "Backfilled canonical identity keys onto {Count} pre-existing facts.", migrated); + } + + return migrated; + } + public async Task BootstrapAsync(CancellationToken cancellationToken = default) { _logger.LogInformation( @@ -63,10 +137,52 @@ public async Task BootstrapAsync(CancellationToken cancellationToken = default) // already exists, so an embedder/dimension change leaves stale indexes that would only fail at // query time. Verify dimensions now and surface an actionable error listing every mismatch. await ValidateVectorIndexDimensionsAsync(cancellationToken).ConfigureAwait(false); + await ValidateNoFailedIndexesAsync(cancellationToken).ConfigureAwait(false); + + // Ordering matters and is the whole point: a fact written between an upgrade and the + // backfill would MERGE onto a fresh node and duplicate anyway. Bootstrap runs before any + // repository write, so this is the only place it is guaranteed to precede them. + await BackfillCanonicalFactKeysAsync(CanonicalKeyBackfillBatchSize, cancellationToken) + .ConfigureAwait(false); _logger.LogInformation("Schema bootstrap complete."); } + /// + /// Surfaces indexes that reached the terminal FAILED state. Only vector dimensions were checked + /// before, so a range index that could not populate — Neo4j caps index keys at roughly 8 KB, and + /// nothing bounds fact property length — degraded invisibly: queries still succeeded through full + /// scans, so the symptom was gradual slowness rather than an error. + /// + private async Task ValidateNoFailedIndexesAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var failed = await _txRunner.ReadAsync( + async runner => + { + var cursor = await runner.RunAsync(SchemaQueries.ShowIndexStates).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + // Filtered in memory rather than in Cypher: a literal in a WHERE clause trips the + // repository's parameterization guard, and POPULATING is a normal transient state. + return records + .Where(record => string.Equals( + record["state"].As(), "FAILED", StringComparison.OrdinalIgnoreCase)) + .Select(record => $"{record["name"].As()} ({record["type"].As()})") + .ToArray(); + }, + cancellationToken).ConfigureAwait(false) ?? []; + + if (failed.Length > 0) + { + throw new InvalidOperationException( + $"Neo4j reports {failed.Length} index(es) in the FAILED state: {string.Join(", ", failed)}. " + + "A failed index does not stop queries — they fall back to full scans — so this would " + + "otherwise surface only as unexplained slowness. Drop and recreate the index; if it " + + "covers long text properties, note that Neo4j limits index keys to roughly 8 KB."); + } + } + private async Task ValidateVectorIndexDimensionsAsync(CancellationToken cancellationToken) { if (!_validateVectorIndexDimensions) diff --git a/src/AgentMemory.Neo4j/Infrastructure/ServiceCollectionExtensions.cs b/src/AgentMemory.Neo4j/Infrastructure/ServiceCollectionExtensions.cs index 05a54846..7f24815f 100644 --- a/src/AgentMemory.Neo4j/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/AgentMemory.Neo4j/Infrastructure/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Neo4j.Driver; using AgentMemory.Abstractions.Repositories; +using AgentMemory.Core.Extraction; using AgentMemory.Abstractions.Services; using AgentMemory.Neo4j.Repositories; using AgentMemory.Neo4j.Services; @@ -36,7 +37,10 @@ public static IServiceCollection AddNeo4jAgentMemory( // INeo4jDriverFactory still owns creation and disposal. services.TryAddSingleton(sp => sp.GetRequiredService().GetDriver()); services.TryAddSingleton(); - services.TryAddTransient(); + // Singleton so repository instances in the same async flow can join the transaction opened + // for one logical extraction-persistence operation. + services.TryAddSingleton(); + services.Replace(ServiceDescriptor.Singleton()); services.TryAddTransient(); services.TryAddTransient(); diff --git a/src/AgentMemory.Neo4j/Queries/CypherQueryRegistry.cs b/src/AgentMemory.Neo4j/Queries/CypherQueryRegistry.cs index 8771dfb8..7d5355d2 100644 --- a/src/AgentMemory.Neo4j/Queries/CypherQueryRegistry.cs +++ b/src/AgentMemory.Neo4j/Queries/CypherQueryRegistry.cs @@ -39,7 +39,18 @@ internal static string FingerprintFor(string? cypher) : "DecayQueries.UpdateAccessTimestamp"; } - if (Has("CALL db.index.vector.queryNodes('message_embedding_idx'") && + if (Has("WITH $messages AS messages") && + Has("[msg IN $messages | msg.id] AS batchIds") && + Has("WITH DISTINCT msg.id AS id")) + { + return "MessageQueries.AddBatchOptimized"; + } + + var isMessageVectorSearch = + Has("CALL db.index.vector.queryNodes('message_embedding_idx'") || + (Has("MATCH (:Conversation {session_id: $sessionId})-[:HAS_MESSAGE]->(node:Message)") && + Has("vector.similarity.cosine(node.embedding, $embedding)")); + if (isMessageVectorSearch && Has("RETURN node, score")) { return "MessageQueries.SearchByVector"; @@ -55,6 +66,12 @@ internal static string FingerprintFor(string? cypher) return "EntityQueries.SearchByVector"; } + if (Has("MATCH (node:Fact)") && + Has("vector.similarity.cosine(node.embedding, $embedding)") && + Has("toLower(node.subject) = toLower($subject)") && + Has("toLower(node.predicate) = toLower($predicate)")) + return "FactQueries.FindDuplicate"; + if (Has("CALL db.index.vector.queryNodes('fact_embedding_idx'")) { if (Has("node.created_at <= datetime($systemAsOf)")) diff --git a/src/AgentMemory.Neo4j/Queries/DecayQueries.cs b/src/AgentMemory.Neo4j/Queries/DecayQueries.cs index 3c35d2a4..c5044c77 100644 --- a/src/AgentMemory.Neo4j/Queries/DecayQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/DecayQueries.cs @@ -98,7 +98,12 @@ private static string BuildPrune(string a, string label, bool hasOwnerFilter, bo // Clamp daysSince to >= 0 so the prune score matches the C# read-path score exactly for nodes // with a future last_accessed_at (a negative exponent would otherwise inflate the score). " WITH " + a + ", conf, ac, CASE WHEN rawDays < 0 THEN 0.0 ELSE rawDays END AS daysSince\n" + - " WHERE (COALESCE(conf, 0.5) * exp(-$lambda * daysSince) + $boostFactor * ac) < $minScore\n" + + // BUG-R7: the access term is damped (log), capped ($maxBoost), and decayed on the same + // curve as confidence, so a single recall can no longer hold a stale node above $minScore + // forever. Must stay identical to MemoryDecayService.ComputeScore and VectorRerank. + " WHERE ((COALESCE(conf, 0.5) + CASE WHEN $boostFactor * log(1 + ac) > $maxBoost" + + " THEN $maxBoost ELSE $boostFactor * log(1 + ac) END)" + + " * exp(-$lambda * daysSince)) < $minScore\n" + " " + action + "\n" + " RETURN count(*) AS pruned"; } diff --git a/src/AgentMemory.Neo4j/Queries/FactQueries.cs b/src/AgentMemory.Neo4j/Queries/FactQueries.cs index 0282649e..894d37e9 100644 --- a/src/AgentMemory.Neo4j/Queries/FactQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/FactQueries.cs @@ -9,12 +9,83 @@ namespace AgentMemory.Neo4j.Queries; /// internal static class FactQueries { + // ── Canonical-key backfill (Phase 1.1) ───────────────────────────── + + /// Facts written before canonical identity, in bounded batches. + /// + /// Selecting on predicate_key IS NULL makes the backfill idempotent by construction: once + /// every fact is keyed, a re-run selects nothing. Bounded so a large store migrates in batches + /// rather than one transaction. + /// + public const string SelectFactsMissingCanonicalKeys = @" + MATCH (f:Fact) + WHERE f.predicate_key IS NULL + RETURN f.id AS id, f.subject AS subject, f.predicate AS predicate, f.object AS object + LIMIT $limit"; + + /// + /// Writes canonical keys computed in C# onto facts identified by id. + /// + /// + /// Deliberately contains no toLower() or string rewriting: Cypher's toLower() and + /// .NET's ToLowerInvariant() disagree on U+0130, so a key computed here would not match + /// the one the write path produces, silently reintroducing the duplication canonical identity + /// exists to remove. That is also why this is not a .cypher migration file. + /// + public const string ApplyCanonicalKeys = @" + UNWIND $items AS item + MATCH (f:Fact {id: item.id}) + SET f.subject_key = item.subject_key, + f.predicate_key = item.predicate_key, + f.object_key = item.object_key + RETURN count(f) AS updated"; + + // ── Predicate expansion (G3B.13) ─────────────────────────────────── + + /// + /// Every fact an owner holds under the given canonical predicates, bounded. + /// + /// + /// Top-K vector search answers "what is most relevant"; it cannot answer "how many", because a + /// relevance cutoff gives no completeness guarantee — miss one of five births and the count is + /// four. This retrieves a relation whole so aggregation questions become answerable, and + /// composes with top-K rather than replacing it: similarity finds which predicate matters, this + /// makes that predicate complete. + /// + /// Matches predicate_key, never the raw predicate: raw text would reinstate the exact + /// fragmentation canonical identity removed, where "were_born_in" and "were born in" fail to + /// find each other. Owner-scoped and explicitly limited — unbounded completeness over a graph of + /// ~1,000 facts would simply exhaust the answer budget. + /// + /// + public static string SearchByCanonicalPredicates(bool hasOwnerFilter, bool includeShared) + { + // Mirrors GetBySubject's owner-conditional shape rather than inventing its own. The first + // version hard-coded `f.owner_key = $ownerKey` with `scope.OwnerId ?? OwnerKeyShared`, which + // (a) never matched shared facts even when IncludeShared was set, silently breaking the + // "relation whole" guarantee, and (b) coerced a null-owner scope to the shared bucket, so it + // returned nothing exactly where top-K returned everything. + var owner = !hasOwnerFilter ? string.Empty + : includeShared ? " AND (f.owner_id = $ownerId OR f.owner_id IS NULL)" + : " AND f.owner_id = $ownerId"; + return $@" + MATCH (f:Fact) + WHERE f.predicate_key IN $predicateKeys + AND f.invalidated_at IS NULL{owner} + RETURN f + ORDER BY f.confidence DESC, f.id ASC + LIMIT $limit"; + } + // ── UpsertAsync ──────────────────────────────────────────────────── /// Merge a fact by subject/predicate/object triple, setting all properties. public const string Upsert = @" - MERGE (f:Fact {subject: $subject, predicate: $predicate, object: $object, owner_key: $ownerKey}) + MERGE (f:Fact {subject_key: $subjectKey, predicate_key: $predicateKey, object_key: $objectKey, owner_key: $ownerKey}) ON CREATE SET + f.subject = $subject, + f.predicate = $predicate, + f.object = $object, f.id = $id, f.owner_id = $ownerId, f.category = $category, @@ -49,8 +120,11 @@ ON MATCH SET /// public const string UpsertBatch = @" UNWIND $items AS item - MERGE (f:Fact {subject: item.subject, predicate: item.predicate, object: item.object, owner_key: item.owner_key}) + MERGE (f:Fact {subject_key: item.subject_key, predicate_key: item.predicate_key, object_key: item.object_key, owner_key: item.owner_key}) ON CREATE SET + f.subject = item.subject, + f.predicate = item.predicate, + f.object = item.object, f.id = item.id, f.owner_id = item.owner_id, f.category = item.category, @@ -90,18 +164,19 @@ public static string GetBySubject(bool hasOwnerFilter, bool includeShared) // ── Dedup-on-create ──────────────────────────────────────────────── /// - /// Finds the most-similar existing fact with the same subject+predicate within the same owner - /// (matched by owner_key) whose cosine score ≥ $threshold — used to reinforce instead - /// of creating a near-duplicate node. Over-fetches candidates, returns top 1. + /// Scopes live candidates to the same owner + case-insensitive subject/predicate before exact cosine + /// scoring, then returns the best match above $threshold. Scoped exact scoring gives a caller + /// holding the process-local dedup lock read-after-commit behavior without vector-index refresh lag. /// - public static string FindDuplicate(int topK) => $@" - CALL db.index.vector.queryNodes('fact_embedding_idx', {topK}, $embedding) - YIELD node, score - WHERE score >= $threshold - AND node.invalidated_at IS NULL + public static string FindDuplicate() => @" + MATCH (node:Fact) + WHERE node.invalidated_at IS NULL + AND node.owner_key = $ownerKey AND toLower(node.subject) = toLower($subject) AND toLower(node.predicate) = toLower($predicate) - AND node.owner_key = $ownerKey + AND node.embedding IS NOT NULL + WITH node, vector.similarity.cosine(node.embedding, $embedding) AS score + WHERE score >= $threshold RETURN node, score ORDER BY score DESC LIMIT 1"; diff --git a/src/AgentMemory.Neo4j/Queries/FusedPersistenceQueries.cs b/src/AgentMemory.Neo4j/Queries/FusedPersistenceQueries.cs new file mode 100644 index 00000000..fa9622ca --- /dev/null +++ b/src/AgentMemory.Neo4j/Queries/FusedPersistenceQueries.cs @@ -0,0 +1,122 @@ +namespace AgentMemory.Neo4j.Queries; + +/// +/// Bounded memory-kind writes used by the coalesced extraction path. Each query folds node mutation, +/// embedding and message provenance into one round trip so an outer transaction does not retain locks +/// across per-item follow-up queries. +/// +internal static class FusedPersistenceQueries +{ + public const string EntityUpsertBatch = @" + UNWIND $items AS item + MERGE (e:Entity {id: item.id}) + ON CREATE SET + e.owner_id = item.owner_id, + e.name = item.name, + e.canonical_name = item.canonical_name, + e.type = item.type, + e.subtype = item.subtype, + e.description = item.description, + e.confidence = item.confidence, + e.aliases = item.aliases, + e.attributes = item.attributes, + e.source_message_ids = item.source_message_ids, + e.created_at = datetime(item.created_at), + e.metadata = item.metadata + ON MATCH SET + e.name = item.name, + e.canonical_name = item.canonical_name, + e.type = item.type, + e.subtype = item.subtype, + e.description = item.description, + e.confidence = item.confidence, + e.aliases = item.aliases, + e.attributes = item.attributes, + e.source_message_ids = item.source_message_ids, + e.metadata = item.metadata, + e.updated_at = datetime() + SET e.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE e.embedding END + FOREACH (_ IN CASE + WHEN item.latitude IS NOT NULL AND item.longitude IS NOT NULL THEN [1] + ELSE [] END | + SET e.location = point({latitude: item.latitude, longitude: item.longitude})) + SET e:$(item.labels) + WITH e, item + CALL (e, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (e)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN e"; + + public const string FactUpsertBatch = @" + UNWIND $items AS item + MERGE (f:Fact {subject_key: item.subject_key, predicate_key: item.predicate_key, object_key: item.object_key, owner_key: item.owner_key}) + ON CREATE SET + f.subject = item.subject, + f.predicate = item.predicate, + f.object = item.object, + f.id = item.id, + f.owner_id = item.owner_id, + f.category = item.category, + f.confidence = item.confidence, + f.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + f.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + f.source_message_ids = item.source_message_ids, + f.created_at = datetime(item.created_at), + f.metadata = item.metadata + ON MATCH SET + f.category = item.category, + f.confidence = item.confidence, + f.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE f.valid_from END, + f.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE f.valid_until END, + f.source_message_ids = item.source_message_ids, + f.updated_at = datetime(item.updated_at), + f.metadata = item.metadata, + f.invalidated_at = null + SET f.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE f.embedding END + WITH f, item + CALL (f, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (f)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN f"; + + public const string PreferenceUpsertBatch = @" + UNWIND $items AS item + MERGE (p:Preference {id: item.id}) + ON CREATE SET + p.owner_id = item.owner_id, + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.created_at = datetime(item.created_at), + p.metadata = item.metadata + ON MATCH SET + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.metadata = item.metadata + SET p.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE p.embedding END + WITH p, item + CALL (p, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (p)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN p"; +} diff --git a/src/AgentMemory.Neo4j/Queries/MessageQueries.cs b/src/AgentMemory.Neo4j/Queries/MessageQueries.cs index fef1f6c8..f3417bb8 100644 --- a/src/AgentMemory.Neo4j/Queries/MessageQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/MessageQueries.cs @@ -11,7 +11,8 @@ internal static class MessageQueries { // ── AddAsync ─────────────────────────────────────────────────────── - /// Create a message and link it to its conversation via HAS_MESSAGE. The conversation + /// Create a message, persist its optional embedding, and maintain its conversation/order + /// links in one query. The conversation /// is MERGE-d so persisting a message never silently no-ops when the conversation was not /// explicitly created first (e.g. from the MAF context/history providers); a thin conversation /// is created and later enriched by ConversationQueries.Upsert. @@ -36,8 +37,26 @@ ON CREATE SET m.timestamp = datetime($timestamp), m.tool_call_ids = $toolCallIds, m.metadata = $metadata + WITH conv, m, m { .* } AS persisted + SET m.embedding = CASE + WHEN $embedding IS NOT NULL THEN $embedding + ELSE m.embedding + END MERGE (conv)-[:HAS_MESSAGE]->(m) - RETURN m"; + WITH conv, m, persisted + OPTIONAL MATCH (conv)-[:FIRST_MESSAGE]->(first:Message) + FOREACH (_ IN CASE WHEN first IS NULL THEN [1] ELSE [] END | + MERGE (conv)-[:FIRST_MESSAGE]->(m) + ) + WITH conv, m, persisted + OPTIONAL MATCH (conv)-[:HAS_MESSAGE]->(prev:Message) + WHERE prev.id <> $id + WITH m, persisted, prev ORDER BY prev.timestamp DESC + WITH m, persisted, head(collect(prev)) AS prev + FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | + MERGE (prev)-[:NEXT_MESSAGE]->(m) + ) + RETURN persisted AS m"; /// Link the first message in a conversation via FIRST_MESSAGE. public const string CreateFirstMessageLink = @" @@ -88,6 +107,62 @@ ON CREATE SET MERGE (conv)-[:HAS_MESSAGE]->(m) RETURN m"; + /// + /// One-query batch write preserving 's behavior: first-write-wins message + /// properties, unconditional overwrite for supplied embeddings, intra-batch ordering, connection to + /// the prior conversation tail, and ordered read-back. The input must already be timestamp ordered. + /// + public static string AddBatchOptimized { get; } = @" + WITH $messages AS messages, + [msg IN $messages | msg.id] AS batchIds + UNWIND messages AS msg + MERGE (conv:Conversation {id: msg.conversation_id}) + ON CREATE SET conv.session_id = msg.session_id, + conv.created_at = datetime(msg.timestamp), + conv.updated_at = datetime(msg.timestamp) + MERGE (m:Message {id: msg.id}) + ON CREATE SET + m.conversation_id = msg.conversation_id, + m.session_id = msg.session_id, + m.role = msg.role, + m.content = msg.content, + m.timestamp = datetime(msg.timestamp), + m.tool_call_ids = msg.tool_call_ids, + m.metadata = msg.metadata + FOREACH (_ IN CASE WHEN msg.embedding IS NULL THEN [] ELSE [1] END | + SET m.embedding = msg.embedding + ) + MERGE (conv)-[:HAS_MESSAGE]->(m) + WITH messages, batchIds + CALL { + WITH messages + UNWIND CASE + WHEN size(messages) > 1 THEN range(1, size(messages) - 1) + ELSE [] + END AS i + MATCH (prev:Message {id: messages[i - 1].id}) + MATCH (next:Message {id: messages[i].id}) + MERGE (prev)-[:NEXT_MESSAGE]->(next) + RETURN count(*) AS linked + } + WITH messages, batchIds + MATCH (conv:Conversation {id: messages[0].conversation_id}) + MATCH (first:Message {id: messages[0].id}) + OPTIONAL MATCH (conv)-[:HAS_MESSAGE]->(prev:Message) + WHERE NOT prev.id IN batchIds + WITH messages, first, prev + ORDER BY prev.timestamp DESC + WITH messages, first, head(collect(prev)) AS prev + FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | + MERGE (prev)-[:NEXT_MESSAGE]->(first) + ) + WITH messages + UNWIND messages AS msg + WITH DISTINCT msg.id AS id + MATCH (m:Message {id: id}) + RETURN m + ORDER BY m.timestamp"; + /// Create NEXT_MESSAGE link between two specific messages. MERGE (not CREATE) for the same /// idempotency guarantee as . public const string CreateNextMessageLink = @@ -142,22 +217,39 @@ RETURN m /// /// Builds a vector similarity search query for messages with optional session and metadata filters. - /// The value is embedded in the CALL as a literal integer. + /// Session-scoped search uses the indexed Conversation session id, traverses HAS_MESSAGE, and + /// calculates exact cosine inside that session; unscoped search uses the global vector index. /// - /// When true, adds an AND clause for node.session_id = $sessionId. + /// When true, scopes traversal through Conversation.session_id. /// /// Optional pre-formatted AND condition lines from . /// - /// Number of candidates to retrieve from the vector index. - public static string SearchByVector(bool hasSessionFilter, string? metadataFilterFragment = null, int topK = 10) => - new CypherBuilder() + /// Number of candidates to retrieve from the unscoped vector index. + public static string SearchByVector(bool hasSessionFilter, string? metadataFilterFragment = null, int topK = 10) + { + if (hasSessionFilter) + { + return $$""" + MATCH (:Conversation {session_id: $sessionId})-[:HAS_MESSAGE]->(node:Message) + WHERE node.embedding IS NOT NULL AND size(node.embedding) = size($embedding) + {{metadataFilterFragment}} + WITH node, vector.similarity.cosine(node.embedding, $embedding) AS score + WHERE score >= $minScore + RETURN node, score + ORDER BY score DESC + LIMIT $limit + """; + } + + return new CypherBuilder() .WithVectorSearch("message_embedding_idx", "$embedding", "node", topK) .Where("score >= $minScore") - .And("node.session_id = $sessionId", when: hasSessionFilter) .AndRawFragment(metadataFilterFragment) .Return("node, score") .OrderBy("score DESC") + .Limit("$limit", when: !string.IsNullOrWhiteSpace(metadataFilterFragment)) .Build(); + } // ── DeleteBySessionAsync ─────────────────────────────────────────── diff --git a/src/AgentMemory.Neo4j/Queries/PreferenceQueries.cs b/src/AgentMemory.Neo4j/Queries/PreferenceQueries.cs index 474a13a9..b98c1d4e 100644 --- a/src/AgentMemory.Neo4j/Queries/PreferenceQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/PreferenceQueries.cs @@ -28,6 +28,27 @@ ON MATCH SET p.metadata = $metadata RETURN p"; + /// Batch upsert preferences by id via UNWIND. + public const string UpsertBatch = @" + UNWIND $items AS item + MERGE (p:Preference {id: item.id}) + ON CREATE SET + p.owner_id = item.owner_id, + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.created_at = datetime(item.created_at), + p.metadata = item.metadata + ON MATCH SET + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.metadata = item.metadata + RETURN p"; /// Set the embedding vector on a Preference node. public const string SetEmbedding = "MATCH (p:Preference {id: $id}) SET p.embedding = $embedding"; diff --git a/src/AgentMemory.Neo4j/Queries/RelationshipQueries.cs b/src/AgentMemory.Neo4j/Queries/RelationshipQueries.cs index 78e9fd25..c050bf4c 100644 --- a/src/AgentMemory.Neo4j/Queries/RelationshipQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/RelationshipQueries.cs @@ -40,6 +40,37 @@ ON MATCH SET r.metadata = $metadata RETURN r"; + /// Batch merge RELATED_TO relationships by id via UNWIND. + public const string UpsertBatch = @" + UNWIND $items AS item + MERGE (s:Entity {id: item.source_entity_id}) + MERGE (t:Entity {id: item.target_entity_id}) + MERGE (s)-[r:RELATED_TO {id: item.id}]->(t) + ON CREATE SET + r.relation_type = item.relation_type, + r.owner_id = item.owner_id, + r.source_entity_id = item.source_entity_id, + r.target_entity_id = item.target_entity_id, + r.confidence = item.confidence, + r.description = item.description, + r.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + r.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + r.attributes = item.attributes, + r.source_message_ids = item.source_message_ids, + r.created_at = datetime(item.created_at), + r.updated_at = datetime(item.updated_at), + r.metadata = item.metadata + ON MATCH SET + r.relation_type = item.relation_type, + r.confidence = item.confidence, + r.description = item.description, + r.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + r.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + r.attributes = item.attributes, + r.source_message_ids = item.source_message_ids, + r.updated_at = datetime(item.updated_at), + r.metadata = item.metadata + RETURN r"; // ── GetByIdAsync ─────────────────────────────────────────────────── /// Get a single RELATED_TO relationship by id. diff --git a/src/AgentMemory.Neo4j/Queries/SchemaQueries.cs b/src/AgentMemory.Neo4j/Queries/SchemaQueries.cs index d795acc1..d1a68c83 100644 --- a/src/AgentMemory.Neo4j/Queries/SchemaQueries.cs +++ b/src/AgentMemory.Neo4j/Queries/SchemaQueries.cs @@ -224,6 +224,17 @@ public static IReadOnlyList BootstrapStatements(int dimensions) "SHOW VECTOR INDEXES YIELD name, options " + "RETURN name AS name, options['indexConfig']['vector.dimensions'] AS dimensions"; + /// + /// Lists indexes in the terminal FAILED state. Bootstrap previously validated only vector-index + /// dimensions, so a range index that failed to populate — for example when a composite key + /// exceeds Neo4j's ~8 KB key-size limit — degraded silently: queries kept working through full + /// scans and nothing ever reported the index missing. POPULATING is deliberately not treated as + /// a failure; it is the normal asynchronous build state. + /// + public const string ShowIndexStates = + "SHOW INDEXES YIELD name, state, type " + + "RETURN name AS name, state AS state, type AS type"; + // ── Schema-conformance introspection (CLI `schema-check`) ──── /// Lists the names of all constraints in the current database. diff --git a/src/AgentMemory.Neo4j/Queries/VectorRerank.cs b/src/AgentMemory.Neo4j/Queries/VectorRerank.cs index abeab423..316a1a95 100644 --- a/src/AgentMemory.Neo4j/Queries/VectorRerank.cs +++ b/src/AgentMemory.Neo4j/Queries/VectorRerank.cs @@ -19,9 +19,16 @@ namespace AgentMemory.Neo4j.Queries; /// internal static class VectorRerank { - // confidence·e^(−λ·daysSince) + boost·accessCount, with COALESCE fallbacks matching the prune. + // (confidence + damped, capped accessBoost)·e^(−λ·daysSince), with COALESCE fallbacks matching the + // prune. BUG-R7: the boost was linear, uncapped, and outside the decay, so at the shipped 0.2 + // factor five accesses drove it to 1.0 — where the clamp below pinned retention at its maximum + // permanently and every frequently-recalled item tied at the ceiling, destroying the confidence + // and recency signal this blend exists to carry. private const string RetentionExpr = - "COALESCE(node.confidence, 0.5) * exp(-$lambda * daysSince) + $boostFactor * COALESCE(node.access_count, 0)"; + "(COALESCE(node.confidence, 0.5) + " + + "CASE WHEN $boostFactor * log(1 + COALESCE(node.access_count, 0)) > $maxBoost " + + "THEN $maxBoost ELSE $boostFactor * log(1 + COALESCE(node.access_count, 0)) END) " + + "* exp(-$lambda * daysSince)"; /// /// Appends the (optional) recency-rerank blend, then RETURN / ORDER BY / LIMIT, and builds the query. diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.Fused.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.Fused.cs new file mode 100644 index 00000000..ce2b0787 --- /dev/null +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.Fused.cs @@ -0,0 +1,59 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Queries; +using Microsoft.Extensions.Logging; +using Neo4j.Driver; +using static AgentMemory.Neo4j.Repositories.Neo4jRecordMapper; + +namespace AgentMemory.Neo4j.Repositories; + +internal sealed partial class Neo4jEntityRepository +{ + public async Task> UpsertFusedBatchAsync( + IReadOnlyList entities, + CancellationToken cancellationToken = default) + { + if (entities.Count == 0) return Array.Empty(); + + _logger.LogDebug("Fused batch upserting {Count} entities", entities.Count); + var items = entities.Select(entity => new Dictionary + { + ["id"] = entity.EntityId, + ["owner_id"] = entity.OwnerId, + ["name"] = entity.Name, + ["canonical_name"] = entity.CanonicalName, + ["type"] = entity.Type, + ["subtype"] = entity.Subtype, + ["description"] = entity.Description, + ["confidence"] = entity.Confidence, + ["aliases"] = entity.Aliases.ToList(), + ["attributes"] = SerializeMetadata(entity.Attributes), + ["source_message_ids"] = entity.SourceMessageIds.ToList(), + ["created_at"] = entity.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(entity.Metadata), + ["embedding"] = entity.Embedding is { Length: > 0 } ? entity.Embedding.ToList() : null, + ["latitude"] = entity.Latitude, + ["longitude"] = entity.Longitude, + ["labels"] = BuildDynamicLabels(entity.Type, entity.Subtype), + }).ToList(); + + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync(FusedPersistenceQueries.EntityUpsertBatch, new { items }) + .ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + var byId = entities.ToDictionary(entity => entity.EntityId, StringComparer.Ordinal); + return records.Select(record => + { + var node = record["e"].As(); + var id = node["id"].As(); + if (!byId.TryGetValue(id, out var source)) + return MapToEntity(node, ReadEmbedding(node)); + return MapToEntity(node, source.Embedding) with + { + Latitude = source.Latitude, + Longitude = source.Longitude, + }; + }).ToList(); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs index 978999d2..95520890 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs @@ -4,6 +4,7 @@ using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; using AgentMemory.Neo4j.Infrastructure; using AgentMemory.Neo4j.Queries; using Neo4j.Driver; @@ -11,7 +12,8 @@ namespace AgentMemory.Neo4j.Repositories; -internal sealed class Neo4jEntityRepository : IEntityRepository +internal sealed partial class Neo4jEntityRepository : IEntityRepository, IUpsertPersistsProvenance, + IBatchMemoryRepository, IFusedBatchMemoryRepository { private const int OwnerOverFetchFactor = Neo4jFactRepository.OwnerOverFetchFactor; private const int OwnerOverFetchFloor = Neo4jFactRepository.OwnerOverFetchFloor; @@ -44,19 +46,19 @@ public async Task UpsertAsync(Entity entity, CancellationToken cancellat { var parameters = new Dictionary { - ["id"] = entity.EntityId, - ["ownerId"] = entity.OwnerId, - ["name"] = entity.Name, - ["canonicalName"] = (object?)entity.CanonicalName, - ["type"] = entity.Type, - ["subtype"] = (object?)entity.Subtype, - ["description"] = (object?)entity.Description, - ["confidence"] = entity.Confidence, - ["aliases"] = entity.Aliases.ToList(), - ["attributes"] = SerializeMetadata(entity.Attributes), + ["id"] = entity.EntityId, + ["ownerId"] = entity.OwnerId, + ["name"] = entity.Name, + ["canonicalName"] = (object?)entity.CanonicalName, + ["type"] = entity.Type, + ["subtype"] = (object?)entity.Subtype, + ["description"] = (object?)entity.Description, + ["confidence"] = entity.Confidence, + ["aliases"] = entity.Aliases.ToList(), + ["attributes"] = SerializeMetadata(entity.Attributes), ["sourceMessageIds"] = entity.SourceMessageIds.ToList(), - ["createdAtUtc"] = entity.CreatedAtUtc.ToString("O"), - ["metadata"] = SerializeMetadata(entity.Metadata) + ["createdAtUtc"] = entity.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(entity.Metadata) }; var cursor = await runner.RunAsync(EntityQueries.Upsert, parameters).ConfigureAwait(false); @@ -178,7 +180,7 @@ public async Task> GetByNameAsync( var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToEntity(node, ReadEmbedding(node)), score); }).ToList(); @@ -270,9 +272,9 @@ await _tx.WriteAsync(async runner => var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["other"].As(); + var node = r["other"].As(); var confidence = r["confidence"].As(); - var matchType = r["matchType"].As(); + var matchType = r["matchType"].As(); return (MapToEntity(node, ReadEmbedding(node)), confidence, matchType); }).ToList(); }, cancellationToken).ConfigureAwait(false); @@ -286,19 +288,19 @@ public async Task> UpsertBatchAsync(IReadOnlyList var items = entities.Select(e => new Dictionary { - ["id"] = e.EntityId, - ["owner_id"] = e.OwnerId, - ["name"] = e.Name, - ["canonical_name"] = (object?)e.CanonicalName, - ["type"] = e.Type, - ["subtype"] = (object?)e.Subtype, - ["description"] = (object?)e.Description, - ["confidence"] = e.Confidence, - ["aliases"] = e.Aliases.ToList(), - ["attributes"] = SerializeMetadata(e.Attributes), + ["id"] = e.EntityId, + ["owner_id"] = e.OwnerId, + ["name"] = e.Name, + ["canonical_name"] = (object?)e.CanonicalName, + ["type"] = e.Type, + ["subtype"] = (object?)e.Subtype, + ["description"] = (object?)e.Description, + ["confidence"] = e.Confidence, + ["aliases"] = e.Aliases.ToList(), + ["attributes"] = SerializeMetadata(e.Attributes), ["source_message_ids"] = e.SourceMessageIds.ToList(), - ["created_at"] = e.CreatedAtUtc.ToString("O"), - ["metadata"] = SerializeMetadata(e.Metadata) + ["created_at"] = e.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(e.Metadata) }).ToList(); return await _tx.WriteAsync(async runner => @@ -349,7 +351,7 @@ await runner.RunAsync( return records.Select(r => { var node = r["e"].As(); - var id = node["id"].As(); + var id = node["id"].As(); if (!byId.TryGetValue(id, out var src)) return MapToEntity(node, null); return MapToEntity(node, src.Embedding) with { Latitude = src.Latitude, Longitude = src.Longitude }; @@ -424,35 +426,35 @@ private static Entity MapToEntity(INode node, float[]? embedding) if (node.Properties.TryGetValue("location", out var locValue) && locValue is Point pt) { // WGS-84: X = longitude, Y = latitude - latitude = pt.Y; + latitude = pt.Y; longitude = pt.X; } return new Entity { - EntityId = node["id"].As(), - OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, - Name = node["name"].As(), - CanonicalName = node.Properties.TryGetValue("canonical_name", out var cn) ? cn.As() : null, - Type = node["type"].As(), - Subtype = node.Properties.TryGetValue("subtype", out var st) ? st.As() : null, - Description = node.Properties.TryGetValue("description", out var desc) ? desc.As() : null, - Confidence = node["confidence"].As(), - Embedding = embedding, - Latitude = latitude, - Longitude = longitude, - Aliases = node.Properties.TryGetValue("aliases", out var al) + EntityId = node["id"].As(), + OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, + Name = node["name"].As(), + CanonicalName = node.Properties.TryGetValue("canonical_name", out var cn) ? cn.As() : null, + Type = node["type"].As(), + Subtype = node.Properties.TryGetValue("subtype", out var st) ? st.As() : null, + Description = node.Properties.TryGetValue("description", out var desc) ? desc.As() : null, + Confidence = node["confidence"].As(), + Embedding = embedding, + Latitude = latitude, + Longitude = longitude, + Aliases = node.Properties.TryGetValue("aliases", out var al) ? al.As>().Select(a => a.ToString()!).ToList() : Array.Empty(), - Attributes = DeserializeMetadata(node.Properties.TryGetValue("attributes", out var attr) ? attr.As() : null), + Attributes = DeserializeMetadata(node.Properties.TryGetValue("attributes", out var attr) ? attr.As() : null), SourceMessageIds = node.Properties.TryGetValue("source_message_ids", out var sm) ? sm.As>().Select(v => v.ToString()!).ToList() : Array.Empty(), - CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), - UpdatedAtUtc = node.Properties.TryGetValue("updated_at", out var ua) && ua is not null + CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), + UpdatedAtUtc = node.Properties.TryGetValue("updated_at", out var ua) && ua is not null ? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(ua) : null, - Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) + Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) }; } @@ -497,7 +499,11 @@ public async Task> SearchByLocationAsync( var cursor = hasOwner ? await runner.RunAsync(cypher, new Dictionary { - ["lat"] = latitude, ["lon"] = longitude, ["radiusMeters"] = radiusKm * 1000.0, ["limit"] = limit, ["ownerId"] = scope!.OwnerId!, + ["lat"] = latitude, + ["lon"] = longitude, + ["radiusMeters"] = radiusKm * 1000.0, + ["limit"] = limit, + ["ownerId"] = scope!.OwnerId!, }).ConfigureAwait(false) : await runner.RunAsync(cypher, new { lat = latitude, lon = longitude, radiusMeters = radiusKm * 1000.0, limit }).ConfigureAwait(false); var records = await cursor.ToListAsync().ConfigureAwait(false); @@ -530,7 +536,12 @@ public async Task> SearchInBoundingBoxAsync( var cursor = hasOwner ? await runner.RunAsync(cypher, new Dictionary { - ["minLat"] = minLat, ["minLon"] = minLon, ["maxLat"] = maxLat, ["maxLon"] = maxLon, ["limit"] = limit, ["ownerId"] = scope!.OwnerId!, + ["minLat"] = minLat, + ["minLon"] = minLon, + ["maxLat"] = maxLat, + ["maxLon"] = maxLon, + ["limit"] = limit, + ["ownerId"] = scope!.OwnerId!, }).ConfigureAwait(false) : await runner.RunAsync(cypher, new { minLat, minLon, maxLat, maxLon, limit }).ConfigureAwait(false); var records = await cursor.ToListAsync().ConfigureAwait(false); @@ -722,9 +733,9 @@ public async Task> GetEntitiesFromMessageAsync( var cypher = TemporalQueries.SearchEntitiesAsOf(hasOwner, includeShared, topK); var parameters = new Dictionary { - ["embedding"] = queryEmbedding.ToList(), - ["limit"] = limit, - ["minScore"] = minScore, + ["embedding"] = queryEmbedding.ToList(), + ["limit"] = limit, + ["minScore"] = minScore, // D6: entities have only the transaction clock, so the AsOf timestamp binds $systemAsOf. ["systemAsOf"] = asOf.UtcDateTime.ToString("O") }; @@ -736,7 +747,7 @@ public async Task> GetEntitiesFromMessageAsync( var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToEntity(node, ReadEmbedding(node)), score); }).ToList(); diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.Fused.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.Fused.cs new file mode 100644 index 00000000..76d0dcaf --- /dev/null +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.Fused.cs @@ -0,0 +1,70 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Core.Memory; +using AgentMemory.Neo4j.Queries; +using Microsoft.Extensions.Logging; +using Neo4j.Driver; +using static AgentMemory.Neo4j.Repositories.Neo4jRecordMapper; + +namespace AgentMemory.Neo4j.Repositories; + +internal sealed partial class Neo4jFactRepository +{ + public async Task> UpsertFusedBatchAsync( + IReadOnlyList facts, + CancellationToken cancellationToken = default) + { + if (facts.Count == 0) return Array.Empty(); + + _logger.LogDebug("Fused batch upserting {Count} facts", facts.Count); + var deduped = facts + .GroupBy(fact => TripleKey( + fact.Subject, fact.Predicate, fact.Object, fact.OwnerId ?? OwnerKeyShared)) + .Select(group => group.Last()) + .ToList(); + var updatedAt = DateTimeOffset.UtcNow.ToString("O"); + var items = deduped.Select(fact => new Dictionary + { + ["id"] = fact.FactId, + ["subject"] = fact.Subject, + ["predicate"] = fact.Predicate, + // The fused batch writer is the path extraction actually uses; the non-fused Upsert + // carried these keys while this one did not, so canonical identity never reached a real + // cold build. + ["subject_key"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Subject), + ["predicate_key"] = MemoryTripleCanonicalizer.Canonical(fact.Predicate), + ["object_key"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Object), + ["object"] = fact.Object, + ["owner_id"] = fact.OwnerId, + ["owner_key"] = fact.OwnerId ?? OwnerKeyShared, + ["category"] = fact.Category, + ["confidence"] = fact.Confidence, + ["valid_from"] = fact.ValidFrom?.ToString("O"), + ["valid_until"] = fact.ValidUntil?.ToString("O"), + ["source_message_ids"] = fact.SourceMessageIds.ToList(), + ["created_at"] = fact.CreatedAtUtc.ToString("O"), + ["updated_at"] = updatedAt, + ["metadata"] = SerializeMetadata(fact.Metadata), + ["embedding"] = fact.Embedding is { Length: > 0 } ? fact.Embedding.ToList() : null, + }).ToList(); + + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync(FusedPersistenceQueries.FactUpsertBatch, new { items }) + .ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + var embeddingByTriple = deduped.ToDictionary( + fact => TripleKey(fact.Subject, fact.Predicate, fact.Object, fact.OwnerId ?? OwnerKeyShared), + fact => fact.Embedding); + return records.Select(record => + { + var node = record["f"].As(); + var key = TripleKey( + node["subject"].As(), + node["predicate"].As(), + node["object"].As(), + node["owner_key"].As()); + return MapToFact(node, embeddingByTriple.TryGetValue(key, out var embedding) ? embedding : null); + }).ToList(); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs index 1866c1cd..e30330a9 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using AgentMemory.Abstractions.Domain; +using AgentMemory.Core.Memory; using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; using AgentMemory.Neo4j.Infrastructure; using AgentMemory.Neo4j.Queries; using Neo4j.Driver; @@ -11,7 +13,8 @@ namespace AgentMemory.Neo4j.Repositories; -internal sealed class Neo4jFactRepository : IFactRepository +internal sealed partial class Neo4jFactRepository : IFactRepository, IUpsertPersistsProvenance, + IBatchMemoryRepository, IFusedBatchMemoryRepository { // Owner-scoped vector search over-fetches candidates (topK > limit) so an owner filter is not // starved by higher-scoring foreign rows; the post-WHERE then LIMITs to the requested count (R1). @@ -50,20 +53,24 @@ public async Task UpsertAsync(Fact fact, CancellationToken cancellationTok { var parameters = new Dictionary { - ["id"] = fact.FactId, - ["subject"] = fact.Subject, - ["predicate"] = fact.Predicate, - ["object"] = fact.Object, - ["ownerId"] = fact.OwnerId, - ["ownerKey"] = fact.OwnerId ?? OwnerKeyShared, - ["category"] = fact.Category, - ["confidence"] = fact.Confidence, - ["validFrom"] = (object?)(fact.ValidFrom?.ToString("O")), - ["validUntil"] = (object?)(fact.ValidUntil?.ToString("O")), + ["id"] = fact.FactId, + ["subject"] = fact.Subject, + ["predicate"] = fact.Predicate, + // Identity is the canonical trio; the raw strings above stay for display and audit. + ["subjectKey"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Subject), + ["predicateKey"] = MemoryTripleCanonicalizer.Canonical(fact.Predicate), + ["objectKey"] = MemoryTripleCanonicalizer.CanonicalValue(fact.Object), + ["object"] = fact.Object, + ["ownerId"] = fact.OwnerId, + ["ownerKey"] = fact.OwnerId ?? OwnerKeyShared, + ["category"] = fact.Category, + ["confidence"] = fact.Confidence, + ["validFrom"] = (object?)(fact.ValidFrom?.ToString("O")), + ["validUntil"] = (object?)(fact.ValidUntil?.ToString("O")), ["sourceMessageIds"] = fact.SourceMessageIds.ToList(), - ["createdAtUtc"] = fact.CreatedAtUtc.ToString("O"), - ["updatedAtUtc"] = DateTimeOffset.UtcNow.ToString("O"), - ["metadata"] = SerializeMetadata(fact.Metadata) + ["createdAtUtc"] = fact.CreatedAtUtc.ToString("O"), + ["updatedAtUtc"] = DateTimeOffset.UtcNow.ToString("O"), + ["metadata"] = SerializeMetadata(fact.Metadata) }; var cursor = await runner.RunAsync(FactQueries.Upsert, parameters).ConfigureAwait(false); @@ -116,20 +123,23 @@ public async Task> UpsertBatchAsync(IReadOnlyList fact var updatedAt = DateTimeOffset.UtcNow.ToString("O"); var items = deduped.Select(f => new Dictionary { - ["id"] = f.FactId, - ["subject"] = f.Subject, - ["predicate"] = f.Predicate, - ["object"] = f.Object, - ["owner_id"] = f.OwnerId, - ["owner_key"] = f.OwnerId ?? OwnerKeyShared, - ["category"] = f.Category, - ["confidence"] = f.Confidence, - ["valid_from"] = (object?)(f.ValidFrom?.ToString("O")), - ["valid_until"] = (object?)(f.ValidUntil?.ToString("O")), + ["id"] = f.FactId, + ["subject"] = f.Subject, + ["predicate"] = f.Predicate, + ["subject_key"] = MemoryTripleCanonicalizer.CanonicalValue(f.Subject), + ["predicate_key"] = MemoryTripleCanonicalizer.Canonical(f.Predicate), + ["object_key"] = MemoryTripleCanonicalizer.CanonicalValue(f.Object), + ["object"] = f.Object, + ["owner_id"] = f.OwnerId, + ["owner_key"] = f.OwnerId ?? OwnerKeyShared, + ["category"] = f.Category, + ["confidence"] = f.Confidence, + ["valid_from"] = (object?)(f.ValidFrom?.ToString("O")), + ["valid_until"] = (object?)(f.ValidUntil?.ToString("O")), ["source_message_ids"] = f.SourceMessageIds.ToList(), - ["created_at"] = f.CreatedAtUtc.ToString("O"), - ["updated_at"] = updatedAt, - ["metadata"] = SerializeMetadata(f.Metadata) + ["created_at"] = f.CreatedAtUtc.ToString("O"), + ["updated_at"] = updatedAt, + ["metadata"] = SerializeMetadata(f.Metadata) }).ToList(); return await _tx.WriteAsync(async runner => @@ -178,7 +188,7 @@ await runner.RunAsync( return records.Select(r => { var node = r["f"].As(); - var key = TripleKey(node["subject"].As(), node["predicate"].As(), + var key = TripleKey(node["subject"].As(), node["predicate"].As(), node["object"].As(), node["owner_key"].As()); return MapToFact(node, embeddingByTriple.TryGetValue(key, out var emb) ? emb : null); }).ToList(); @@ -255,17 +265,13 @@ public async Task> GetBySubjectAsync( var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToFact(node, ReadEmbedding(node)), score); }).ToList(); }, cancellationToken).ConfigureAwait(false); } - // Small candidate set for dedup lookups: a near-duplicate by subject+predicate is rare, so a - // modest over-fetch is enough to find the best match above the (high) similarity threshold. - private const int DedupOverFetch = 10; - public async Task FindDuplicateAsync( string subject, string predicate, float[] embedding, string? ownerId, double threshold, CancellationToken cancellationToken = default) @@ -273,14 +279,14 @@ public async Task> GetBySubjectAsync( // Boundary invariant: a zero-dimension (empty/degraded) embedding can't address the vector index; // there is no duplicate to find, so short-circuit (caller then creates a new node). if (embedding is not { Length: > 0 }) return null; - var cypher = FactQueries.FindDuplicate(DedupOverFetch); + var cypher = FactQueries.FindDuplicate(); var parameters = new Dictionary { - ["embedding"] = embedding.ToList(), - ["threshold"] = threshold, - ["subject"] = subject, - ["predicate"] = predicate, - ["ownerKey"] = ownerId ?? OwnerKeyShared, + ["embedding"] = embedding.ToList(), + ["threshold"] = threshold, + ["subject"] = subject, + ["predicate"] = predicate, + ["ownerKey"] = ownerId ?? OwnerKeyShared, }; return await _tx.ReadAsync(async runner => @@ -354,25 +360,25 @@ private static (string, string, string, string) TripleKey( private static Fact MapToFact(INode node, float[]? embedding) => new() { - FactId = node["id"].As(), - Subject = node["subject"].As(), - Predicate = node["predicate"].As(), - Object = node["object"].As(), - OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, - Category = node.Properties.TryGetValue("category", out var cat) ? cat.As() : null, - Confidence = node["confidence"].As(), - ValidFrom = node.Properties.TryGetValue("valid_from", out var vf) + FactId = node["id"].As(), + Subject = node["subject"].As(), + Predicate = node["predicate"].As(), + Object = node["object"].As(), + OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, + Category = node.Properties.TryGetValue("category", out var cat) ? cat.As() : null, + Confidence = node["confidence"].As(), + ValidFrom = node.Properties.TryGetValue("valid_from", out var vf) ? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(vf) : null, - ValidUntil = node.Properties.TryGetValue("valid_until", out var vu) + ValidUntil = node.Properties.TryGetValue("valid_until", out var vu) ? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(vu) : null, - Embedding = embedding, + Embedding = embedding, SourceMessageIds = node.Properties.TryGetValue("source_message_ids", out var sm) ? sm.As>().Select(v => v.ToString()!).ToList() : Array.Empty(), - CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), - Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) + CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), + Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) }; private static float[]? ReadEmbedding(INode node) @@ -518,10 +524,10 @@ public async Task SupersedeAsync(string loserFactId, string winnerFactId, var cypher = TemporalQueries.SearchFactsAsOf(hasOwner, includeShared, topK); var parameters = new Dictionary { - ["embedding"] = queryEmbedding.ToList(), - ["limit"] = limit, - ["minScore"] = minScore, - ["validAsOf"] = asOf.UtcDateTime.ToString("O"), + ["embedding"] = queryEmbedding.ToList(), + ["limit"] = limit, + ["minScore"] = minScore, + ["validAsOf"] = asOf.UtcDateTime.ToString("O"), ["systemAsOf"] = (systemAsOf ?? asOf).UtcDateTime.ToString("O") }; if (hasOwner) parameters["ownerId"] = scope!.OwnerId; @@ -532,10 +538,49 @@ public async Task SupersedeAsync(string loserFactId, string winnerFactId, var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToFact(node, ReadEmbedding(node)), score); }).ToList(); }, cancellationToken).ConfigureAwait(false); } -} \ No newline at end of file + + /// + public async Task> SearchByCanonicalPredicatesAsync( + IReadOnlyList canonicalPredicates, + int limit, + MemoryScope scope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(canonicalPredicates); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit); + if (canonicalPredicates.Count == 0) + return Array.Empty(); + + // Same scope semantics as every other fact read: an owner filter only when one was asked + // for, and shared facts included unless explicitly excluded. + var hasOwner = scope?.HasOwnerFilter == true; + var includeShared = scope?.IncludeShared ?? true; + var parameters = new Dictionary + { + ["predicateKeys"] = canonicalPredicates.ToArray(), + ["limit"] = limit + }; + if (hasOwner) parameters["ownerId"] = scope!.OwnerId; + + return await _tx.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + FactQueries.SearchByCanonicalPredicates(hasOwner, includeShared), + parameters).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return (IReadOnlyList)records + .Select(record => + { + var node = record["f"].As(); + return MapToFact(node, ReadEmbedding(node)); + }) + .ToList(); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jMessageRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jMessageRepository.cs index 6213dca1..29b6bcf2 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jMessageRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jMessageRepository.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using AgentMemory.Abstractions.Domain; using AgentMemory.Abstractions.Repositories; using AgentMemory.Neo4j.Infrastructure; @@ -10,13 +11,22 @@ namespace AgentMemory.Neo4j.Repositories; internal sealed class Neo4jMessageRepository : IMessageRepository { + // Metadata-only filters run after the global vector candidate pool, so those searches must + // over-fetch before filtering. Session-scoped searches use an exact in-session query instead. + private const int ScopedOverFetchFactor = 5; + private const int ScopedOverFetchFloor = 50; private readonly INeo4jTransactionRunner _tx; private readonly ILogger _logger; + private readonly bool _useOptimizedMessageBatchWrites; - public Neo4jMessageRepository(INeo4jTransactionRunner tx, ILogger logger) + public Neo4jMessageRepository( + INeo4jTransactionRunner tx, + ILogger logger, + IOptions? options = null) { _tx = tx; _logger = logger; + _useOptimizedMessageBatchWrites = options?.Value.UseOptimizedMessageBatchWrites ?? true; } public async Task AddAsync(Message message, CancellationToken cancellationToken = default) @@ -35,30 +45,20 @@ public async Task AddAsync(Message message, CancellationToken cancellat ["content"] = message.Content, ["timestamp"] = message.TimestampUtc.ToString("O"), ["toolCallIds"] = message.ToolCallIds?.ToList() ?? new List(), - ["metadata"] = SerializeMetadata(message.Metadata) + ["metadata"] = SerializeMetadata(message.Metadata), + ["embedding"] = message.Embedding is { Length: > 0 } + ? message.Embedding.ToList() + : null }; var cursor = await runner.RunAsync(MessageQueries.Add, createParams).ConfigureAwait(false); var record = await cursor.SingleAsync().ConfigureAwait(false); - var node = record["m"].As(); + var returned = record["m"]; + var properties = returned is INode node + ? node.Properties + : returned.As>(); - // Only persist a real (non-empty) vector; a degraded empty embedding leaves `embedding` NULL. - if (message.Embedding is { Length: > 0 }) - { - await runner.RunAsync( - SharedFragments.SetMessageEmbedding, - new { id = message.MessageId, embedding = message.Embedding.ToList() }).ConfigureAwait(false); - } - - // Create FIRST_MESSAGE if this is the first message in the conversation - await runner.RunAsync( - MessageQueries.CreateFirstMessageLink, - new { conversationId = message.ConversationId, id = message.MessageId }).ConfigureAwait(false); - - // Establish NEXT_MESSAGE link from the previous last message - await runner.RunAsync(MessageQueries.LinkNextMessage, new { conversationId = message.ConversationId, id = message.MessageId }).ConfigureAwait(false); - - return MapToMessage(node, message.Embedding); + return MapToMessage(properties, message.Embedding); }, cancellationToken).ConfigureAwait(false); } @@ -78,9 +78,32 @@ public async Task> AddBatchAsync(IEnumerable mes ["content"] = m.Content, ["timestamp"] = m.TimestampUtc.ToString("O"), ["tool_call_ids"] = m.ToolCallIds?.ToList() ?? new List(), - ["metadata"] = SerializeMetadata(m.Metadata) + ["metadata"] = SerializeMetadata(m.Metadata), + ["embedding"] = m.Embedding is { Length: > 0 } + ? m.Embedding.ToList() + : null }).ToList(); + var embeddingMap = ordered.ToDictionary(m => m.MessageId, m => m.Embedding); + if (_useOptimizedMessageBatchWrites) + { + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync( + MessageQueries.AddBatchOptimized, + new { messages = msgParams }).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return records.Select(record => + { + var node = record["m"].As(); + var id = node["id"].As(); + return MapToMessage( + node, + embeddingMap.TryGetValue(id, out var embedding) ? embedding : null); + }).ToList(); + }, cancellationToken).ConfigureAwait(false); + } + return await _tx.WriteAsync(async runner => { var cursor = await runner.RunAsync(MessageQueries.AddBatch, new { messages = msgParams }).ConfigureAwait(false); @@ -122,7 +145,6 @@ await runner.RunAsync( new { ids = ordered.Select(m => m.MessageId).ToList() }).ConfigureAwait(false); var records = await readCursor.ToListAsync().ConfigureAwait(false); - var embeddingMap = ordered.ToDictionary(m => m.MessageId, m => m.Embedding); return records.Select(r => { var node = r["m"].As(); @@ -208,13 +230,17 @@ public async Task> GetAllBySessionAsync(string sessionId, _logger.LogDebug("Vector search messages, sessionId={SessionId}, limit={Limit}", sessionId, limit); var (filterClause, filterParams) = MetadataFilterBuilder.Build(metadataFilters, nodeAlias: "node"); - - var cypher = MessageQueries.SearchByVector(sessionId is not null, filterClause, limit); + var hasMetadataFilter = !string.IsNullOrWhiteSpace(filterClause); + var topK = sessionId is null && hasMetadataFilter + ? Math.Max(limit * ScopedOverFetchFactor, limit + ScopedOverFetchFloor) + : limit; + var cypher = MessageQueries.SearchByVector(sessionId is not null, filterClause, topK); var parameters = new Dictionary { ["embedding"] = queryEmbedding.ToList(), - ["minScore"] = minScore + ["minScore"] = minScore, + ["limit"] = limit }; if (sessionId is not null) parameters["sessionId"] = sessionId; foreach (var (k, v) in filterParams) parameters[k] = v; @@ -281,19 +307,22 @@ public async Task> GetRecentBySessionAsOfAsync( } private static Message MapToMessage(INode node, float[]? embedding) => + MapToMessage(node.Properties, embedding); + + private static Message MapToMessage(IReadOnlyDictionary properties, float[]? embedding) => new() { - MessageId = node["id"].As(), - ConversationId = node["conversation_id"].As(), - SessionId = node["session_id"].As(), - Role = node["role"].As(), - Content = node["content"].As(), - TimestampUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["timestamp"]), + MessageId = properties["id"].As(), + ConversationId = properties["conversation_id"].As(), + SessionId = properties["session_id"].As(), + Role = properties["role"].As(), + Content = properties["content"].As(), + TimestampUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(properties["timestamp"]), Embedding = embedding, - ToolCallIds = node.Properties.TryGetValue("tool_call_ids", out var tc) + ToolCallIds = properties.TryGetValue("tool_call_ids", out var tc) ? tc.As>().Select(v => v.ToString()!).ToList() : [], - Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) + Metadata = DeserializeMetadata(properties.TryGetValue("metadata", out var md) ? md.As() : null) }; private static float[]? ReadEmbedding(INode node) diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.Fused.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.Fused.cs new file mode 100644 index 00000000..657f0133 --- /dev/null +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.Fused.cs @@ -0,0 +1,48 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Queries; +using Microsoft.Extensions.Logging; +using Neo4j.Driver; +using static AgentMemory.Neo4j.Repositories.Neo4jRecordMapper; + +namespace AgentMemory.Neo4j.Repositories; + +internal sealed partial class Neo4jPreferenceRepository +{ + public async Task> UpsertFusedBatchAsync( + IReadOnlyList preferences, + CancellationToken cancellationToken = default) + { + if (preferences.Count == 0) return Array.Empty(); + + _logger.LogDebug("Fused batch upserting {Count} preferences", preferences.Count); + var items = preferences.Select(preference => new Dictionary + { + ["id"] = preference.PreferenceId, + ["owner_id"] = preference.OwnerId, + ["category"] = preference.Category, + ["preference"] = preference.PreferenceText, + ["context"] = preference.Context, + ["confidence"] = preference.Confidence, + ["source_message_ids"] = preference.SourceMessageIds.ToList(), + ["created_at"] = preference.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(preference.Metadata), + ["embedding"] = preference.Embedding is { Length: > 0 } + ? preference.Embedding.ToList() : null, + }).ToList(); + + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync(FusedPersistenceQueries.PreferenceUpsertBatch, new { items }) + .ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + var byId = preferences.ToDictionary(preference => preference.PreferenceId, StringComparer.Ordinal); + return records.Select(record => + { + var node = record["p"].As(); + var id = node["id"].As(); + return MapToPreference(node, byId.TryGetValue(id, out var source) + ? source.Embedding : ReadEmbedding(node)); + }).ToList(); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs index a8dd03f0..525c2c3b 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs @@ -4,6 +4,7 @@ using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; using AgentMemory.Neo4j.Infrastructure; using AgentMemory.Neo4j.Queries; using Neo4j.Driver; @@ -11,7 +12,8 @@ namespace AgentMemory.Neo4j.Repositories; -internal sealed class Neo4jPreferenceRepository : IPreferenceRepository +internal sealed partial class Neo4jPreferenceRepository : IPreferenceRepository, IUpsertPersistsProvenance, + IBatchMemoryRepository, IFusedBatchMemoryRepository { private const int OwnerOverFetchFactor = Neo4jFactRepository.OwnerOverFetchFactor; private const int OwnerOverFetchFloor = Neo4jFactRepository.OwnerOverFetchFloor; @@ -44,15 +46,15 @@ public async Task UpsertAsync(Preference preference, CancellationTok { var parameters = new Dictionary { - ["id"] = preference.PreferenceId, - ["ownerId"] = preference.OwnerId, - ["category"] = preference.Category, - ["preferenceText"] = preference.PreferenceText, - ["context"] = (object?)preference.Context, - ["confidence"] = preference.Confidence, + ["id"] = preference.PreferenceId, + ["ownerId"] = preference.OwnerId, + ["category"] = preference.Category, + ["preferenceText"] = preference.PreferenceText, + ["context"] = (object?)preference.Context, + ["confidence"] = preference.Confidence, ["sourceMessageIds"] = preference.SourceMessageIds.ToList(), - ["createdAtUtc"] = preference.CreatedAtUtc.ToString("O"), - ["metadata"] = SerializeMetadata(preference.Metadata) + ["createdAtUtc"] = preference.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(preference.Metadata) }; var cursor = await runner.RunAsync(PreferenceQueries.Upsert, parameters).ConfigureAwait(false); @@ -80,6 +82,55 @@ await runner.RunAsync( }, cancellationToken).ConfigureAwait(false); } + public async Task> UpsertBatchAsync( + IReadOnlyList preferences, + CancellationToken cancellationToken = default) + { + if (preferences.Count == 0) return Array.Empty(); + + _logger.LogDebug("Batch upserting {Count} preferences", preferences.Count); + var items = preferences.Select(preference => new Dictionary + { + ["id"] = preference.PreferenceId, + ["owner_id"] = preference.OwnerId, + ["category"] = preference.Category, + ["preference"] = preference.PreferenceText, + ["context"] = preference.Context, + ["confidence"] = preference.Confidence, + ["source_message_ids"] = preference.SourceMessageIds.ToList(), + ["created_at"] = preference.CreatedAtUtc.ToString("O"), + ["metadata"] = SerializeMetadata(preference.Metadata) + }).ToList(); + + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync(PreferenceQueries.UpsertBatch, new { items }).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + + foreach (var preference in preferences.Where(item => item.Embedding is { Length: > 0 })) + { + await runner.RunAsync( + PreferenceQueries.SetEmbedding, + new { id = preference.PreferenceId, embedding = preference.Embedding!.ToList() }).ConfigureAwait(false); + } + + foreach (var preference in preferences.Where(item => item.SourceMessageIds.Count > 0)) + { + await runner.RunAsync( + PreferenceQueries.CreateExtractedFromMessages, + new { id = preference.PreferenceId, sourceMessageIds = preference.SourceMessageIds.ToList() }) + .ConfigureAwait(false); + } + + var byId = preferences.ToDictionary(item => item.PreferenceId, StringComparer.Ordinal); + return records.Select(record => + { + var node = record["p"].As(); + var id = node["id"].As(); + return MapToPreference(node, byId.TryGetValue(id, out var source) ? source.Embedding : null); + }).ToList(); + }, cancellationToken).ConfigureAwait(false); + } public async Task GetByIdAsync(string preferenceId, CancellationToken cancellationToken = default) { _logger.LogDebug("Getting preference {Id}", preferenceId); @@ -150,7 +201,7 @@ public async Task> GetByCategoryAsync( var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToPreference(node, ReadEmbedding(node)), score); }).ToList(); @@ -172,7 +223,7 @@ public async Task> GetByCategoryAsync( { ["embedding"] = embedding.ToList(), ["threshold"] = threshold, - ["category"] = category, + ["category"] = category, }; if (!ownerIsShared) parameters["ownerId"] = ownerId; @@ -292,18 +343,18 @@ await runner.RunAsync( private static Preference MapToPreference(INode node, float[]? embedding) => new() { - PreferenceId = node["id"].As(), - OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, - Category = node["category"].As(), - PreferenceText = node["preference"].As(), - Context = node.Properties.TryGetValue("context", out var ctx) ? ctx.As() : null, - Confidence = node["confidence"].As(), - Embedding = embedding, + PreferenceId = node["id"].As(), + OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, + Category = node["category"].As(), + PreferenceText = node["preference"].As(), + Context = node.Properties.TryGetValue("context", out var ctx) ? ctx.As() : null, + Confidence = node["confidence"].As(), + Embedding = embedding, SourceMessageIds = node.Properties.TryGetValue("source_message_ids", out var sm) ? sm.As>().Select(v => v.ToString()!).ToList() : Array.Empty(), - CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), - Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) + CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]), + Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As() : null) }; private static float[]? ReadEmbedding(INode node) @@ -372,9 +423,9 @@ await runner.RunAsync( var cypher = TemporalQueries.SearchPreferencesAsOf(hasOwner, includeShared, topK); var parameters = new Dictionary { - ["embedding"] = queryEmbedding.ToList(), - ["limit"] = limit, - ["minScore"] = minScore, + ["embedding"] = queryEmbedding.ToList(), + ["limit"] = limit, + ["minScore"] = minScore, // D6: preferences have only the transaction clock, so the AsOf timestamp binds $systemAsOf. ["systemAsOf"] = asOf.UtcDateTime.ToString("O") }; @@ -386,7 +437,7 @@ await runner.RunAsync( var records = await cursor.ToListAsync().ConfigureAwait(false); return records.Select(r => { - var node = r["node"].As(); + var node = r["node"].As(); var score = r["score"].As(); return (MapToPreference(node, ReadEmbedding(node)), score); }).ToList(); diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jRelationshipRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jRelationshipRepository.cs index dcd725b1..dbeca09c 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jRelationshipRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jRelationshipRepository.cs @@ -2,6 +2,7 @@ using AgentMemory.Abstractions.Domain; using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; +using AgentMemory.Core.Extraction; using AgentMemory.Neo4j.Infrastructure; using AgentMemory.Neo4j.Queries; using Neo4j.Driver; @@ -9,7 +10,7 @@ namespace AgentMemory.Neo4j.Repositories; -internal sealed class Neo4jRelationshipRepository : IRelationshipRepository +internal sealed class Neo4jRelationshipRepository : IRelationshipRepository, IBatchMemoryRepository { private readonly INeo4jTransactionRunner _tx; private readonly ILogger _logger; @@ -29,20 +30,20 @@ public async Task UpsertAsync(Relationship relationship, Cancellat { var parameters = new Dictionary { - ["id"] = relationship.RelationshipId, - ["sourceEntityId"] = relationship.SourceEntityId, - ["targetEntityId"] = relationship.TargetEntityId, - ["relationType"] = relationship.RelationshipType, - ["ownerId"] = (object?)relationship.OwnerId, - ["confidence"] = relationship.Confidence, - ["description"] = (object?)relationship.Description, - ["validFrom"] = (object?)(relationship.ValidFrom?.ToString("O")), - ["validUntil"] = (object?)(relationship.ValidUntil?.ToString("O")), - ["attributes"] = SerializeMetadata(relationship.Attributes), + ["id"] = relationship.RelationshipId, + ["sourceEntityId"] = relationship.SourceEntityId, + ["targetEntityId"] = relationship.TargetEntityId, + ["relationType"] = relationship.RelationshipType, + ["ownerId"] = (object?)relationship.OwnerId, + ["confidence"] = relationship.Confidence, + ["description"] = (object?)relationship.Description, + ["validFrom"] = (object?)(relationship.ValidFrom?.ToString("O")), + ["validUntil"] = (object?)(relationship.ValidUntil?.ToString("O")), + ["attributes"] = SerializeMetadata(relationship.Attributes), ["sourceMessageIds"] = relationship.SourceMessageIds.ToList(), - ["createdAt"] = relationship.CreatedAtUtc.ToString("O"), - ["updatedAt"] = DateTimeOffset.UtcNow.ToString("O"), - ["metadata"] = SerializeMetadata(relationship.Metadata) + ["createdAt"] = relationship.CreatedAtUtc.ToString("O"), + ["updatedAt"] = DateTimeOffset.UtcNow.ToString("O"), + ["metadata"] = SerializeMetadata(relationship.Metadata) }; var cursor = await runner.RunAsync(RelationshipQueries.Upsert, parameters).ConfigureAwait(false); @@ -51,6 +52,39 @@ public async Task UpsertAsync(Relationship relationship, Cancellat }, cancellationToken).ConfigureAwait(false); } + public async Task> UpsertBatchAsync( + IReadOnlyList relationships, + CancellationToken cancellationToken = default) + { + if (relationships.Count == 0) return Array.Empty(); + + _logger.LogDebug("Batch upserting {Count} relationships", relationships.Count); + var updatedAt = DateTimeOffset.UtcNow.ToString("O"); + var items = relationships.Select(relationship => new Dictionary + { + ["id"] = relationship.RelationshipId, + ["source_entity_id"] = relationship.SourceEntityId, + ["target_entity_id"] = relationship.TargetEntityId, + ["relation_type"] = relationship.RelationshipType, + ["owner_id"] = relationship.OwnerId, + ["confidence"] = relationship.Confidence, + ["description"] = relationship.Description, + ["valid_from"] = relationship.ValidFrom?.ToString("O"), + ["valid_until"] = relationship.ValidUntil?.ToString("O"), + ["attributes"] = SerializeMetadata(relationship.Attributes), + ["source_message_ids"] = relationship.SourceMessageIds.ToList(), + ["created_at"] = relationship.CreatedAtUtc.ToString("O"), + ["updated_at"] = updatedAt, + ["metadata"] = SerializeMetadata(relationship.Metadata) + }).ToList(); + + return await _tx.WriteAsync(async runner => + { + var cursor = await runner.RunAsync(RelationshipQueries.UpsertBatch, new { items }).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return records.Select(record => MapToRelationship(record["r"].As())).ToList(); + }, cancellationToken).ConfigureAwait(false); + } public async Task GetByIdAsync(string relationshipId, CancellationToken cancellationToken = default) { _logger.LogDebug("Getting relationship {Id}", relationshipId); @@ -124,24 +158,24 @@ public async Task> GetByTargetEntityAsync( private static Relationship MapToRelationship(IRelationship r) => new() { - RelationshipId = r["id"].As(), - SourceEntityId = r["source_entity_id"].As(), - TargetEntityId = r["target_entity_id"].As(), + RelationshipId = r["id"].As(), + SourceEntityId = r["source_entity_id"].As(), + TargetEntityId = r["target_entity_id"].As(), RelationshipType = r["relation_type"].As(), - OwnerId = r.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, - Confidence = r["confidence"].As(), - Description = r.Properties.TryGetValue("description", out var desc) ? desc.As() : null, - ValidFrom = r.Properties.TryGetValue("valid_from", out var vf) + OwnerId = r.Properties.TryGetValue("owner_id", out var oid) ? oid.As() : null, + Confidence = r["confidence"].As(), + Description = r.Properties.TryGetValue("description", out var desc) ? desc.As() : null, + ValidFrom = r.Properties.TryGetValue("valid_from", out var vf) ? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(vf) : null, - ValidUntil = r.Properties.TryGetValue("valid_until", out var vu) + ValidUntil = r.Properties.TryGetValue("valid_until", out var vu) ? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(vu) : null, - Attributes = DeserializeMetadata(r.Properties.TryGetValue("attributes", out var attr) ? attr.As() : null), + Attributes = DeserializeMetadata(r.Properties.TryGetValue("attributes", out var attr) ? attr.As() : null), SourceMessageIds = r.Properties.TryGetValue("source_message_ids", out var sm) ? sm.As>().Select(v => v.ToString()!).ToList() : Array.Empty(), - CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(r["created_at"]), - Metadata = DeserializeMetadata(r.Properties.TryGetValue("metadata", out var md) ? md.As() : null) + CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(r["created_at"]), + Metadata = DeserializeMetadata(r.Properties.TryGetValue("metadata", out var md) ? md.As() : null) }; } diff --git a/src/AgentMemory.Neo4j/Services/Neo4jMemoryDecayService.cs b/src/AgentMemory.Neo4j/Services/Neo4jMemoryDecayService.cs index 9ac5b648..4eac960e 100644 --- a/src/AgentMemory.Neo4j/Services/Neo4jMemoryDecayService.cs +++ b/src/AgentMemory.Neo4j/Services/Neo4jMemoryDecayService.cs @@ -75,6 +75,7 @@ public async Task PruneExpiredMemoriesAsync( ["now"] = now, ["lambda"] = lambda, ["boostFactor"] = _options.AccessBoostFactor, + ["maxBoost"] = _options.MaxAccessBoost, ["minScore"] = _options.MinRetentionScore, }; if (hasOwner) parameters["ownerId"] = scope!.OwnerId; @@ -208,6 +209,11 @@ internal double ComputeScore( var reference = lastAccessedAt ?? createdAt; double daysSince = Math.Max(0, (_clock.UtcNow - reference).TotalDays); double lambda = Math.Log(2) / _options.DecayHalfLifeDays; - return confidence * Math.Exp(-lambda * daysSince) + _options.AccessBoostFactor * accessCount; + // BUG-R7: damped, capped, and decayed on the same curve as confidence. Must stay identical to + // MemoryDecayService.ComputeScore and to the Cypher prune/rerank expressions. + double boost = Math.Min( + _options.AccessBoostFactor * Math.Log(1 + Math.Max(0, accessCount)), + _options.MaxAccessBoost); + return (confidence + boost) * Math.Exp(-lambda * daysSince); } } diff --git a/src/AgentMemory/ServiceCollectionExtensions.cs b/src/AgentMemory/ServiceCollectionExtensions.cs index 65f0c3df..ebc491a2 100644 --- a/src/AgentMemory/ServiceCollectionExtensions.cs +++ b/src/AgentMemory/ServiceCollectionExtensions.cs @@ -14,6 +14,42 @@ namespace AgentMemory; /// public static class ServiceCollectionExtensions { + /// + /// Registers the full Neo4j-backed memory stack from a fully-constructed + /// . + /// + /// + /// Prefer this over the Action<MemoryOptions> overload, which cannot configure + /// anything: is a record with init-only properties, so a + /// configure lambda can neither assign them nor keep a with expression's result. See + /// . + /// + /// Binary compatibility is unaffected. There is one narrow source-level consequence: an untyped + /// null as the second argument now converts to both this overload and the lambda one, so + /// AddNeo4jAgentMemory(null!, ...) becomes ambiguous and needs an explicit + /// (Action<MemoryOptions>) cast. That shape appears once in this repository, in a + /// null-guard test; it is not something production code writes. + /// + /// + public static IServiceCollection AddNeo4jAgentMemory( + this IServiceCollection services, + MemoryOptions memoryOptions, + Action configureNeo4j, + Action? configureLlm = null, + Action? configureStore = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(memoryOptions); + ArgumentNullException.ThrowIfNull(configureNeo4j); + + services.AddAgentMemoryCore(memoryOptions); + NeoInfra.ServiceCollectionExtensions.AddNeo4jAgentMemory(services, configureNeo4j, configureStore); + if (configureLlm is not null) + services.AddLlmExtraction(configureLlm); + + return services; + } + /// /// Registers all core, Neo4j infrastructure, and LLM extraction services in one call. /// diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4c7c8ad7..689abf93 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -22,5 +22,6 @@ + diff --git a/tests/AgentMemory.Tests.Integration/Extraction/TornWriteRollbackIntegrationTests.cs b/tests/AgentMemory.Tests.Integration/Extraction/TornWriteRollbackIntegrationTests.cs new file mode 100644 index 00000000..497281fe --- /dev/null +++ b/tests/AgentMemory.Tests.Integration/Extraction/TornWriteRollbackIntegrationTests.cs @@ -0,0 +1,400 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using AgentMemory; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Stubs; +using AgentMemory.Neo4j.Infrastructure; +using Neo4j.Driver; +using Testcontainers.Neo4j; + +namespace AgentMemory.Tests.Integration.Extraction; + +/// +/// Destructive live-Neo4j characterization for a connection loss in the middle of one logical +/// extraction persist. It owns its container so stopping Neo4j cannot disturb the shared fixture. +/// +[Trait("Category", "Integration")] +public sealed class TornWriteRollbackIntegrationTests : IAsyncLifetime +{ + private const string Username = "neo4j"; + private const string Password = "testpassword"; + private const string OwnerId = "m31-owner"; + private const int Dimensions = 4; + + private Neo4jContainer _container = null!; + private ServiceProvider _provider = null!; + private DropAfterFirstWriteRunner _faultRunner = null!; + private bool _connectionObservedDown; + + public async Task InitializeAsync() + { + _container = new Neo4jBuilder("neo4j:5.26") + .WithEnvironment("NEO4J_AUTH", $"{Username}/{Password}") + .Build(); + await _container.StartAsync(); + + _provider = BuildProvider(); + await _provider.GetRequiredService().BootstrapAsync(); + } + + public async Task DisposeAsync() + { + if (_provider is not null) + await _provider.DisposeAsync(); + if (_container is not null) + await _container.DisposeAsync(); + } + + private ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddNeo4jAgentMemory( + configureMemory: options => + options.Extraction.FailureMode = IngestionFailureMode.FailFast, + configureNeo4j: options => + { + options.Uri = _container.GetConnectionString(); + options.Username = Username; + options.Password = Password; + options.Database = "neo4j"; + options.EmbeddingDimensions = Dimensions; + }); + services.AddSingleton>>(sp => + new StubEmbeddingGenerator(sp.GetRequiredService>(), Dimensions)); + services.Replace(ServiceDescriptor.Singleton(sp => + { + var inner = new Neo4jTransactionRunner( + sp.GetRequiredService(), + sp.GetRequiredService>()); + _faultRunner = new DropAfterFirstWriteRunner(inner, KillContainerProcessAsync); + return _faultRunner; + })); + return services.BuildServiceProvider(validateScopes: true); + } + + [Fact] + public async Task ConnectionDropMidPersist_RollsBackWholeTurn_ThenExactRetryCreatesNoDuplicates() + { + var transactionServiceType = typeof(StubEmbeddingGenerator).Assembly.GetType( + "AgentMemory.Core.Extraction.IMemoryPersistenceTransaction", throwOnError: true)!; + var transactionService = _provider.GetRequiredService(transactionServiceType); + transactionService.GetType().FullName.Should().Be( + "AgentMemory.Neo4j.Infrastructure.Neo4jMemoryPersistenceTransaction", + "Neo4j registration must replace the portable pass-through coordinator"); + + Message sourceMessage; + var runnerField = transactionService.GetType() + .GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + .Single(field => typeof(INeo4jAtomicTransactionRunner).IsAssignableFrom(field.FieldType)); + var coordinatorRunner = runnerField.GetValue(transactionService); + coordinatorRunner.Should().BeSameAs(_faultRunner, + "the persistence coordinator and repositories must share the same transaction runner instance"); + + var injectedRunner = _faultRunner; + using (var seedScope = _provider.CreateScope()) + { + var shortTerm = seedScope.ServiceProvider.GetRequiredService(); + await shortTerm.AddConversationAsync("m31-conversation", "m31-session", userId: OwnerId); + sourceMessage = await shortTerm.AddMessageAsync(new Message + { + MessageId = "m31-source-message", + ConversationId = "m31-conversation", + SessionId = "m31-session", + Role = "user", + Content = "Ada works at Acme and prefers dark mode.", + TimestampUtc = DateTimeOffset.UtcNow, + }); + } + + var before = await ReadSnapshotAsync(); + before.Should().Be(GraphSnapshot.Empty, + "the isolated owner must start with no long-term memory state"); + + var request = new ExtractionRequest + { + SessionId = "m31-session", + UserId = OwnerId, + Messages = [sourceMessage], + }; + + injectedRunner.Arm(); + Exception? failure = null; + try + { + using var failureScope = _provider.CreateScope(); + var pipeline = failureScope.ServiceProvider.GetRequiredService(); + failure = await Record.ExceptionAsync(() => pipeline.ExtractAsync(request)); + } + finally + { + injectedRunner.Disarm(); + if (injectedRunner.Triggered) + { + await _provider.DisposeAsync(); + await _container.StopAsync(); + await _container.StartAsync(); + await WaitForNeo4jAsync(); + _provider = BuildProvider(); + await _provider.GetRequiredService().BootstrapAsync(); + } + } + injectedRunner.AtomicEntered.Should().BeTrue("the product must open one logical persistence transaction"); + + _connectionObservedDown.Should().BeTrue("a fresh driver must observe Neo4j as unreachable before persistence continues"); + failure.Should().NotBeNull("the injected connection loss must fail the logical turn"); + failure.Should().BeAssignableTo(); + injectedRunner.Triggered.Should().BeTrue("the test must prove the real container was stopped mid-persist"); + + var afterFailure = await ReadSnapshotAsync(); + afterFailure.Should().Be(before, + "a failed logical turn must leave no entities, facts, preferences, relationships, provenance, invalidation, valid-time, or supersession state"); + + using (var retryScope = _provider.CreateScope()) + { + var pipeline = retryScope.ServiceProvider.GetRequiredService(); + var result = await pipeline.ExtractAsync(request); + result.Metadata["entityCount"].Should().Be(2); + result.Metadata["factCount"].Should().Be(1); + result.Metadata["preferenceCount"].Should().Be(1); + result.Metadata["relationshipCount"].Should().Be(1); + } + + var afterRetry = await ReadSnapshotAsync(); + afterRetry.Should().Be(new GraphSnapshot( + TotalNodes: 4, + Entities: 2, + Facts: 1, + Preferences: 1, + OwnerRelationships: 1, + ProvenanceRelationships: 4, + InvalidatedNodes: 0, + ValidUntilNodes: 0, + SupersessionRelationships: 0), + "one exact retry must create each deterministic memory once without destructive or duplicate state"); + } + + private async Task ReadSnapshotAsync() + { + await using var driver = GraphDatabase.Driver( + _container.GetConnectionString(), AuthTokens.Basic(Username, Password)); + await using var session = driver.AsyncSession(); + var cursor = await session.RunAsync( + """ + MATCH (n) + WHERE n.owner_id = $ownerId + OPTIONAL MATCH (n)-[r]->() + RETURN count(DISTINCT n) AS totalNodes, + count(DISTINCT CASE WHEN n:Entity THEN n END) AS entities, + count(DISTINCT CASE WHEN n:Fact THEN n END) AS facts, + count(DISTINCT CASE WHEN n:Preference THEN n END) AS preferences, + count(DISTINCT CASE WHEN r.owner_id = $ownerId THEN r END) AS ownerRelationships, + count(DISTINCT CASE WHEN type(r) = 'EXTRACTED_FROM' THEN r END) AS provenanceRelationships, + count(DISTINCT CASE WHEN n.invalidated = true OR n.invalidated_at IS NOT NULL THEN n END) AS invalidatedNodes, + count(DISTINCT CASE WHEN n.valid_until IS NOT NULL THEN n END) AS validUntilNodes, + count(DISTINCT CASE WHEN type(r) = 'SUPERSEDED_BY' THEN r END) AS supersessionRelationships + """, + new Dictionary { ["ownerId"] = OwnerId }); + var record = await cursor.SingleAsync(); + return new GraphSnapshot( + ValueExtensions.As(record["totalNodes"]), + ValueExtensions.As(record["entities"]), + ValueExtensions.As(record["facts"]), + ValueExtensions.As(record["preferences"]), + ValueExtensions.As(record["ownerRelationships"]), + ValueExtensions.As(record["provenanceRelationships"]), + ValueExtensions.As(record["invalidatedNodes"]), + ValueExtensions.As(record["validUntilNodes"]), + ValueExtensions.As(record["supersessionRelationships"])); + } + + private async Task KillContainerProcessAsync() + { + try + { + // SIGKILL the Neo4j JVM: no graceful-shutdown window in which the active transaction can + // finish. The exec transport is expected to disappear with the process, so its exception + // is intentionally swallowed; the following repository operation proves the socket died. + await _container.ExecAsync(["/bin/sh", "-c", "pkill -9 java"]); + } + catch + { + // Expected when killing the JVM tears down the exec channel before it returns a status. + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!timeout.IsCancellationRequested) + { + try + { + await using var probe = GraphDatabase.Driver( + _container.GetConnectionString(), AuthTokens.Basic(Username, Password)); + await probe.VerifyConnectivityAsync(); + } + catch + { + _connectionObservedDown = true; + return; + } + + await Task.Delay(100, timeout.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + throw new InvalidOperationException("Fault injection failed: Neo4j remained reachable after SIGKILL."); + } + + private async Task WaitForNeo4jAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + Exception? lastFailure = null; + while (!timeout.IsCancellationRequested) + { + try + { + await using var driver = GraphDatabase.Driver( + _container.GetConnectionString(), AuthTokens.Basic(Username, Password)); + await driver.VerifyConnectivityAsync(); + return; + } + catch (Exception ex) + { + lastFailure = ex; + await Task.Delay(250, timeout.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + throw new TimeoutException("Neo4j did not become reachable after the injected restart.", lastFailure); + } + + private sealed record GraphSnapshot( + long TotalNodes, + long Entities, + long Facts, + long Preferences, + long OwnerRelationships, + long ProvenanceRelationships, + long InvalidatedNodes, + long ValidUntilNodes, + long SupersessionRelationships) + { + public static GraphSnapshot Empty { get; } = new(0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + private sealed class DropAfterFirstWriteRunner( + INeo4jTransactionRunner inner, + Func dropConnection) : INeo4jTransactionRunner, INeo4jAtomicTransactionRunner + { + private readonly INeo4jAtomicTransactionRunner _atomicInner = inner as INeo4jAtomicTransactionRunner + ?? throw new InvalidOperationException("The injected runner must support atomic write units."); + private int _writeCount; + private int _insideAtomicUnit; + private int _armed; + + public bool AtomicEntered { get; private set; } + + public bool Triggered { get; private set; } + + public void Arm() + { + AtomicEntered = false; + _writeCount = 0; + Triggered = false; + Volatile.Write(ref _armed, 1); + } + + public void Disarm() => Volatile.Write(ref _armed, 0); + + public Task ReadAsync(Func> work, CancellationToken cancellationToken = default) => + inner.ReadAsync(work, cancellationToken); + + public Task ReadAsync(Func work, CancellationToken cancellationToken = default) => + inner.ReadAsync(work, cancellationToken); + + public async Task WriteAsync(Func> work, CancellationToken cancellationToken = default) + { + var result = await inner.WriteAsync(work, cancellationToken); + await DropIfFirstArmedWriteAsync(); + return result; + } + + public async Task WriteAsync(Func work, CancellationToken cancellationToken = default) + { + await inner.WriteAsync(work, cancellationToken); + await DropIfFirstArmedWriteAsync(); + } + + public async Task ExecuteAtomicWriteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + AtomicEntered = true; + return await _atomicInner.ExecuteAtomicWriteAsync(async token => + { + Volatile.Write(ref _insideAtomicUnit, 1); + try + { + return await work(token); + } + finally + { + Volatile.Write(ref _insideAtomicUnit, 0); + } + }, cancellationToken); + } + + private async Task DropIfFirstArmedWriteAsync() + { + if (Volatile.Read(ref _armed) == 0 || Volatile.Read(ref _insideAtomicUnit) == 0 + || Interlocked.Increment(ref _writeCount) != 1) + return; + + Triggered = true; + await dropConnection(); + } + } + + private sealed class DeterministicEntityExtractor : IEntityExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, CancellationToken cancellationToken = default) => + Task.FromResult> + ([ + new ExtractedEntity { Name = "Ada", Type = "Person", Confidence = 0.95 }, + new ExtractedEntity { Name = "Acme", Type = "Organization", Confidence = 0.95 }, + ]); + } + + private sealed class DeterministicFactExtractor : IFactExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, CancellationToken cancellationToken = default) => + Task.FromResult> + ([new ExtractedFact { Subject = "Ada", Predicate = "works_at", Object = "Acme", Confidence = 0.95 }]); + } + + private sealed class DeterministicPreferenceExtractor : IPreferenceExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, CancellationToken cancellationToken = default) => + Task.FromResult> + ([new ExtractedPreference { Category = "style", PreferenceText = "prefers dark mode", Confidence = 0.95 }]); + } + + private sealed class DeterministicRelationshipExtractor : IRelationshipExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, CancellationToken cancellationToken = default) => + Task.FromResult> + ([new ExtractedRelationship { SourceEntity = "Ada", TargetEntity = "Acme", RelationshipType = "WORKS_AT", Confidence = 0.95 }]); + } +} diff --git a/tests/AgentMemory.Tests.Integration/Repositories/BatchMemoryRepositoryIntegrationTests.cs b/tests/AgentMemory.Tests.Integration/Repositories/BatchMemoryRepositoryIntegrationTests.cs new file mode 100644 index 00000000..f90cd071 --- /dev/null +++ b/tests/AgentMemory.Tests.Integration/Repositories/BatchMemoryRepositoryIntegrationTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Repositories; +using AgentMemory.Tests.Integration.Fixtures; +using Neo4j.Driver; + +namespace AgentMemory.Tests.Integration.Repositories; + +[Collection("Neo4j Integration")] +[Trait("Category", "Integration")] +public sealed class BatchMemoryRepositoryIntegrationTests : IAsyncLifetime +{ + private readonly Neo4jIntegrationFixture _fixture; + private readonly Neo4jPreferenceRepository _preferenceRepository; + private readonly Neo4jRelationshipRepository _relationshipRepository; + + public BatchMemoryRepositoryIntegrationTests(Neo4jIntegrationFixture fixture) + { + _fixture = fixture; + _preferenceRepository = new Neo4jPreferenceRepository( + fixture.TransactionRunner, + NullLogger.Instance); + _relationshipRepository = new Neo4jRelationshipRepository( + fixture.TransactionRunner, + NullLogger.Instance); + } + + public Task InitializeAsync() => _fixture.CleanDatabaseAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task PreferenceBatch_RoundTripsPropertiesEmbeddingsAndProvenance() + { + await using (var session = _fixture.Driver.AsyncSession()) + { + await session.RunAsync( + "UNWIND $ids AS id CREATE (:Message {id: id})", + new { ids = new[] { "message-1", "message-2" } }); + } + + var preferences = new[] + { + Preference("preference-1", "coffee", [0.1f, 0.2f, 0.3f, 0.4f]), + Preference("preference-2", "tea", [0.4f, 0.3f, 0.2f, 0.1f]) + }; + + var persisted = await _preferenceRepository.UpsertBatchAsync(preferences); + + persisted.Select(item => item.PreferenceId).Should() + .BeEquivalentTo("preference-1", "preference-2"); + foreach (var expected in preferences) + { + var actual = await _preferenceRepository.GetByIdAsync(expected.PreferenceId); + actual.Should().NotBeNull(); + actual!.OwnerId.Should().Be("owner-1"); + actual.PreferenceText.Should().Be(expected.PreferenceText); + actual.Embedding.Should().Equal(expected.Embedding!); + actual.Metadata.Should().ContainKey("source"); + } + + await using var verifySession = _fixture.Driver.AsyncSession(); + var cursor = await verifySession.RunAsync( + "MATCH (:Preference)-[r:EXTRACTED_FROM]->(:Message) RETURN count(r) AS count"); + var record = await cursor.SingleAsync(); + global::Neo4j.Driver.ValueExtensions.As(record["count"]).Should().Be(4); + } + + [Fact] + public async Task RelationshipBatch_RoundTripsOwnerTemporalAndMetadataProperties() + { + var validFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var validUntil = DateTimeOffset.Parse("2026-12-31T00:00:00Z"); + var relationships = new[] + { + Relationship("relationship-1", "entity-1", "entity-2", validFrom, validUntil), + Relationship("relationship-2", "entity-2", "entity-1", validFrom, validUntil) + }; + + var persisted = await _relationshipRepository.UpsertBatchAsync(relationships); + + persisted.Select(item => item.RelationshipId).Should() + .BeEquivalentTo("relationship-1", "relationship-2"); + foreach (var expected in relationships) + { + var actual = await _relationshipRepository.GetByIdAsync(expected.RelationshipId); + actual.Should().NotBeNull(); + actual!.OwnerId.Should().Be("owner-1"); + actual.RelationshipType.Should().Be("KNOWS"); + actual.SourceEntityId.Should().Be(expected.SourceEntityId); + actual.TargetEntityId.Should().Be(expected.TargetEntityId); + actual.ValidFrom.Should().Be(validFrom); + actual.ValidUntil.Should().Be(validUntil); + actual.Attributes.Should().ContainKey("strength"); + actual.Metadata.Should().ContainKey("source"); + } + } + + private static Preference Preference(string id, string text, float[] embedding) => new() + { + PreferenceId = id, + Category = "drink", + PreferenceText = text, + Context = "morning", + Confidence = 0.9, + Embedding = embedding, + OwnerId = "owner-1", + SourceMessageIds = ["message-1", "message-2"], + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z"), + Metadata = new Dictionary { ["source"] = "batch-test" } + }; + + private static Relationship Relationship( + string id, + string sourceId, + string targetId, + DateTimeOffset validFrom, + DateTimeOffset validUntil) => new() + { + RelationshipId = id, + SourceEntityId = sourceId, + TargetEntityId = targetId, + RelationshipType = "KNOWS", + Description = "batch relationship", + Confidence = 0.9, + OwnerId = "owner-1", + SourceMessageIds = ["message-1"], + ValidFrom = validFrom, + ValidUntil = validUntil, + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z"), + Attributes = new Dictionary { ["strength"] = "high" }, + Metadata = new Dictionary { ["source"] = "batch-test" } + }; +} diff --git a/tests/AgentMemory.Tests.Integration/Repositories/FusedMemoryRepositoryIntegrationTests.cs b/tests/AgentMemory.Tests.Integration/Repositories/FusedMemoryRepositoryIntegrationTests.cs new file mode 100644 index 00000000..2e979b1c --- /dev/null +++ b/tests/AgentMemory.Tests.Integration/Repositories/FusedMemoryRepositoryIntegrationTests.cs @@ -0,0 +1,134 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Repositories; +using AgentMemory.Tests.Integration.Fixtures; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Neo4j.Driver; + +namespace AgentMemory.Tests.Integration.Repositories; + +[Collection("Neo4j Integration")] +[Trait("Category", "Integration")] +public sealed class FusedMemoryRepositoryIntegrationTests : IAsyncLifetime +{ + private readonly Neo4jIntegrationFixture _fixture; + private readonly Neo4jEntityRepository _entities; + private readonly Neo4jFactRepository _facts; + private readonly Neo4jPreferenceRepository _preferences; + + public FusedMemoryRepositoryIntegrationTests(Neo4jIntegrationFixture fixture) + { + _fixture = fixture; + _entities = new Neo4jEntityRepository( + fixture.TransactionRunner, NullLogger.Instance); + _facts = new Neo4jFactRepository( + fixture.TransactionRunner, NullLogger.Instance); + _preferences = new Neo4jPreferenceRepository( + fixture.TransactionRunner, NullLogger.Instance); + } + + public async Task InitializeAsync() + { + await _fixture.CleanDatabaseAsync(); + await using var session = _fixture.Driver.AsyncSession(); + await session.RunAsync( + "UNWIND $ids AS id CREATE (:Message {id: id})", + new { ids = new[] { "message-1", "message-2" } }); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task EntityFusedBatch_PersistsEmbeddingLocationDynamicLabelsAndProvenance() + { + var persisted = await _entities.UpsertFusedBatchAsync( + [ + new Entity + { + EntityId = "entity-1", + Name = "Zurich office", + Type = "Location", + Subtype = "Office", + Confidence = 0.9, + Embedding = [0.1f, 0.2f, 0.3f, 0.4f], + Latitude = 47.3769, + Longitude = 8.5417, + OwnerId = "owner-1", + SourceMessageIds = ["message-1", "message-2"], + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }, + ]); + + persisted.Should().ContainSingle(); + var read = await _entities.GetByIdAsync("entity-1"); + read.Should().NotBeNull(); + read!.Embedding.Should().Equal(0.1f, 0.2f, 0.3f, 0.4f); + read.Latitude.Should().BeApproximately(47.3769, 1e-6); + read.Longitude.Should().BeApproximately(8.5417, 1e-6); + + await using var session = _fixture.Driver.AsyncSession(); + var cursor = await session.RunAsync(@" + MATCH (e:Entity {id: 'entity-1'}) + OPTIONAL MATCH (e)-[r:EXTRACTED_FROM]->(:Message) + RETURN labels(e) AS labels, count(r) AS provenance"); + var record = await cursor.SingleAsync(); + var expectedLabels = new[] { "Entity" } + .Concat(Neo4jEntityRepository.BuildDynamicLabels("Location", "Office")); + global::Neo4j.Driver.ValueExtensions.As>(record["labels"]) + .Should().Contain(expectedLabels); + global::Neo4j.Driver.ValueExtensions.As(record["provenance"]) + .Should().Be(2); + } + + [Fact] + public async Task FactAndPreferenceFusedSingletons_PreserveNaturalKeyEmbeddingAndProvenance() + { + var first = Fact("fact-1", [0.1f, 0.2f, 0.3f, 0.4f]); + var second = Fact("fact-2", [0.4f, 0.3f, 0.2f, 0.1f]); + + (await _facts.UpsertFusedBatchAsync([first])).Single().FactId.Should().Be("fact-1"); + var merged = (await _facts.UpsertFusedBatchAsync([second])).Single(); + merged.FactId.Should().Be("fact-1", "the natural triple keeps its original stable identifier"); + (await _facts.GetByIdAsync("fact-1"))!.Embedding.Should().Equal(second.Embedding!); + + await _preferences.UpsertFusedBatchAsync( + [ + new Preference + { + PreferenceId = "preference-1", + Category = "drink", + PreferenceText = "coffee", + Confidence = 0.9, + Embedding = [0.2f, 0.4f, 0.6f, 0.8f], + OwnerId = "owner-1", + SourceMessageIds = ["message-1", "message-2"], + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }, + ]); + (await _preferences.GetByIdAsync("preference-1"))!.Embedding.Should() + .Equal(0.2f, 0.4f, 0.6f, 0.8f); + + await using var session = _fixture.Driver.AsyncSession(); + var cursor = await session.RunAsync(@" + MATCH (n)-[r:EXTRACTED_FROM]->(:Message) + WHERE n.id IN ['fact-1', 'preference-1'] + RETURN n.id AS id, count(r) AS provenance ORDER BY id"); + var records = await cursor.ToListAsync(); + records.Should().HaveCount(2); + records.Should().OnlyContain(record => + global::Neo4j.Driver.ValueExtensions.As(record["provenance"]) == 2); + } + + private static Fact Fact(string id, float[] embedding) => new() + { + FactId = id, + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 0.9, + Embedding = embedding, + OwnerId = "owner-1", + SourceMessageIds = ["message-1", "message-2"], + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemory.Tests.Unit.LongMemEval.csproj b/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemory.Tests.Unit.LongMemEval.csproj new file mode 100644 index 00000000..e762c9c0 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemory.Tests.Unit.LongMemEval.csproj @@ -0,0 +1,21 @@ + + + + false + true + + + + + + + + + + + + + + + + diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemoryLongMemEvalAdapterTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemoryLongMemEvalAdapterTests.cs new file mode 100644 index 00000000..94eb60d3 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/AgentMemoryLongMemEvalAdapterTests.cs @@ -0,0 +1,626 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class AgentMemoryLongMemEvalAdapterTests +{ + [Fact] + public async Task InvokeAsync_PersistsInjectedHistoryAndAnswersOnlyFromRecalledMemory() + { + var memory = Substitute.For(); + IReadOnlyList? stored = null; + RecallRequest? recallRequest = null; + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => + { + stored = call.Arg>().ToArray(); + return stored; + }); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + recallRequest = call.Arg(); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = recallRequest.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = + [ + Message( + recallRequest.SessionId, + "assistant", + "Alice moved to Zurich in March.") + ] + } + }, + TotalItemsRetrieved = 1 + }; + }); + + var chat = Substitute.For(); + IReadOnlyList? answerPrompt = null; + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + answerPrompt = call.Arg>().ToArray(); + return new ChatResponse( + new ChatMessage(ChatRole.Assistant, "Alice lives in Zurich.")); + }); + + var adapter = new AgentMemoryLongMemEvalAdapter(memory, chat, "test-run"); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory( + [ + ("Alice moved to Zurich in March.", "Thanks, I will remember that."), + ("Her favorite color is blue.", "Understood.") + ]); + + var response = await adapter.InvokeAsync("Where does Alice live?"); + + response.Text.Should().Be("Alice lives in Zurich."); + stored.Should().HaveCount(4); + stored!.Select(message => message.SessionId).Distinct().Should().ContainSingle(); + recallRequest.Should().NotBeNull(); + recallRequest!.Options.BlendMode.Should().Be(RetrievalBlendMode.MemoryOnly); + recallRequest.Options.MaxRecentMessages.Should().Be(0); + recallRequest.Options.MaxEntities.Should().Be(0); + answerPrompt.Should().NotBeNull(); + answerPrompt!.Select(message => message.Text).Should() + .Contain(text => text!.Contains("Alice moved to Zurich", StringComparison.Ordinal)); + var telemetry = adapter.QuestionTelemetry.Should().ContainSingle().Subject; + telemetry.Should().BeEquivalentTo( + new LongMemEvalQuestionTelemetry(1, 4, 1, false) + { + RawMessagesRetrieved = 1 + }, + options => options + .Excluding(info => info.Path == "StageTimings") + // J5.1 context cost: a real measurement of this run, not a fixed expectation, so + // it is asserted below on its own terms rather than frozen into this shape. + .Excluding(info => info.Path == "AnswerPromptCharacters") + .Excluding(info => info.Path == "EstimatedContextTokens")); + // The arm's actual cost must be recorded and non-zero: a prompt was demonstrably built above. + telemetry.AnswerPromptCharacters.Should().BeGreaterThan(0); + telemetry.EstimatedContextTokens.Should().BeGreaterThan(0); + telemetry.StageTimings.Should().NotBeNull( + "accepted LongMemEval questions must expose a phase waterfall"); + telemetry.StageTimings!.StorageMs.Should().BeGreaterThan(0); + telemetry.StageTimings.RetrievalMs.Should().BeGreaterThan(0); + telemetry.StageTimings.AnswerMs.Should().BeGreaterThan(0); + telemetry.StageTimings.ExtractionPersistenceMs.Should().Be(0); + telemetry.StageTimings.GraphReadBackMs.Should().Be(0); + } + + [Fact] + public async Task InvokeAsync_RejectsAQuestionWithoutInjectedHistory() + { + var adapter = new AgentMemoryLongMemEvalAdapter( + Substitute.For(), + Substitute.For(), + "test-run"); + await adapter.ResetSessionAsync(); + + var act = () => adapter.InvokeAsync("What should I remember?"); + + await act.Should().ThrowAsync() + .WithMessage("*history*"); + } + + [Fact] + public async Task ResetSessionAsync_IsolatesQuestionsWithDistinctSessionAndOwnerScopes() + { + var memory = Substitute.For(); + var requests = new List(); + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var request = call.Arg(); + requests.Add(request); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = request.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [Message(request.SessionId, "user", request.Query)] + } + }, + TotalItemsRetrieved = 1 + }; + }); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse(new ChatMessage(ChatRole.Assistant, "answer"))); + var adapter = new AgentMemoryLongMemEvalAdapter(memory, chat, "test-run"); + + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory([("one", "first")]); + await adapter.InvokeAsync("question one"); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory([("two", "second")]); + await adapter.InvokeAsync("question two"); + + requests.Should().HaveCount(2); + requests.Select(request => request.SessionId).Distinct().Should().HaveCount(2); + requests.Select(request => request.UserId).Distinct().Should().HaveCount(2); + } + + [Fact] + public async Task InvokeAsync_RecordsEmptyRetrievalInTelemetry() + { + var memory = Substitute.For(); + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var request = call.Arg(); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = request.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [] + } + }, + TotalItemsRetrieved = 0 + }; + }); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + Substitute.For(), + "test-run"); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory([("one", "first")]); + + var act = () => adapter.InvokeAsync("question one"); + + await act.Should().ThrowAsync() + .WithMessage("*retrieved no history*"); + adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Should().BeEquivalentTo(new + { + QuestionNumber = 1, + MessagesStored = 2, + ItemsRetrieved = 0, + RecallTruncated = false, + Status = "retrieval-empty" + }); + } + + [Fact] + public async Task InvokeAsync_RecordsSanitizedAnswerFailureInTelemetry() + { + var memory = Substitute.For(); + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var request = call.Arg(); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = request.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [Message(request.SessionId, "user", "remembered detail")] + } + }, + TotalItemsRetrieved = 1 + }; + }); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException( + new InvalidOperationException("provider-secret-detail"))); + var adapter = new AgentMemoryLongMemEvalAdapter(memory, chat, "test-run"); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory([("one", "first")]); + + var act = () => adapter.InvokeAsync("question one"); + + await act.Should().ThrowAsync() + .WithMessage("LongMemEval answer stage failed."); + adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Should().BeEquivalentTo(new + { + QuestionNumber = 1, + MessagesStored = 2, + ItemsRetrieved = 1, + RecallTruncated = false, + Status = "answer-error" + }); + } + + [Fact] + public async Task InvokeAsync_RecordsEvidenceResolutionFailureBeforeStorage() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var options = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter.Format(entry, options); + var memory = Substitute.For(); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + Substitute.For(), + "evidence-error-run", + new LongMemEvalAdapterOptions + { + EvidenceIndex = LongMemEvalEvidenceIndex.Create([entry], options) + }); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + var act = () => adapter.InvokeAsync("wrong prompt"); + + await act.Should().ThrowAsync(); + adapter.QuestionTelemetry.Should().ContainSingle().Which.Status.Should() + .Be("evidence-resolution-error"); + await memory.DidNotReceive() + .AddMessagesAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_EmitsRankedSourceEvidenceWithoutPersistingGoldLabels() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var memory = Substitute.For(); + IReadOnlyList? stored = null; + RecallRequest? recallRequest = null; + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => + { + stored = call.Arg>().ToArray(); + return stored; + }); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + recallRequest = call.Arg(); + var items = stored!; + return new RecallResult + { + Context = new MemoryContext + { + SessionId = recallRequest.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = items, + RankedItems = items.Select((message, index) => + new MemoryContextRankedItem( + message.MessageId, + 0.99 - index / 100d, + index + 1, + index + 1)).ToArray() + } + }, + TotalItemsRetrieved = items.Count + }; + }); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse(new ChatMessage(ChatRole.Assistant, "two weeks"))); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + chat, + "evidence-run", + new LongMemEvalAdapterOptions + { + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers + }); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + var response = await adapter.InvokeAsync(LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + + recallRequest!.Options.IncludeDiagnostics.Should().BeTrue(); + // G3B.9: of this fixture's four injected messages, two are AgentEval's fabricated + // session-boundary turn. Only the real conversation is persisted now, so the count moved + // 4 -> 2. The assertion is strengthened rather than merely relaxed: the fabricated pair must + // be provably absent, not just uncounted. + stored.Should().HaveCount(2); + stored!.Should().NotContain(message => + message.Content.Contains("Understood.", StringComparison.Ordinal) || + message.Content.StartsWith("--- Session", StringComparison.Ordinal)); + stored.Should().OnlyContain(message => + message.Metadata.ContainsKey("sourceSessionId") && + !message.Metadata.ContainsKey("hasAnswer") && + !message.Metadata.ContainsKey("answerSessionIds")); + var telemetry = adapter.QuestionTelemetry.Should().ContainSingle().Subject; + telemetry.QuestionId.Should().Be("q-1"); + telemetry.RetrievalEvidence.Should().NotBeNull(); + telemetry.RetrievalEvidence!.GoldSessionRecallAtK.Should().Be(1); + telemetry.RetrievalEvidence.GoldTurnHitAtK.Should().BeTrue(); + telemetry.RetrievalEvidence.RankedItems.Should() + .OnlyContain(item => item.Content == null); + var evidenceKey = + AgentEval.Memory.External.Models.QuestionEvidenceEnvelope.AdditionalPropertiesKey; + response.AdditionalProperties.Should().ContainKey(evidenceKey); + var normalized = response.AdditionalProperties![evidenceKey].Should() + .BeOfType().Subject; + // Follows the storage change: the stubbed recall echoes what was persisted, and the + // fabricated boundary turn is no longer persisted. + normalized.Retrieved.Should().HaveCount(2); + normalized.AnswerContext.Should().HaveCount(2); + normalized.Retrieved.Should().OnlyContain(item => item.Content == null); + normalized.AnswerContext.Should().OnlyContain(item => item.Content == null); + normalized.AnswerContext.Select(item => item.AnswerContextOrder).Should().Equal(1, 2); + } + + [Fact] + public async Task InvokeAsync_StructuredModeExtractsBySourceSessionAndExcludesRawRecall() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var memory = Substitute.For(); + RecallRequest? recallRequest = null; + ExtractionRequest? extractionRequest = null; + var extractionProgress = new List<(int Completed, int Total)>(); + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(call => + { + extractionRequest = call.Arg(); + return new ExtractionResult(); + }); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + recallRequest = call.Arg(); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = recallRequest.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantFacts = new MemoryContextSection + { + Items = + [ + new Fact + { + FactId = "fact-1", + Subject = "user", + Predicate = "stayed_in", + Object = "Japan for two weeks", + Confidence = 0.95, + CreatedAtUtc = DateTimeOffset.UnixEpoch + } + ] + } + }, + TotalItemsRetrieved = 1 + }; + }); + var chat = Substitute.For(); + IReadOnlyList? answerMessages = null; + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + answerMessages = call.Arg>().ToArray(); + return new ChatResponse( + new ChatMessage(ChatRole.Assistant, "The stay lasted two weeks.")); + }); + var adapterOptions = new LongMemEvalAdapterOptions + { + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + ExtractionProgress = (completed, total) => extractionProgress.Add((completed, total)) + }; + var modeProperty = typeof(LongMemEvalAdapterOptions).GetProperty("MemoryMode"); + modeProperty.Should().NotBeNull( + "G3A requires an explicit raw/structured/hybrid operating-mode switch"); + modeProperty!.SetValue( + adapterOptions, + Enum.Parse(modeProperty.PropertyType, "Structured")); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, chat, "structured-run", adapterOptions); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + await adapter.InvokeAsync(LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + + await memory.Received(1).ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()); + extractionProgress.Should().Equal((0, 1), (1, 1)); + extractionRequest.Should().NotBeNull(); + extractionRequest!.UserId.Should().NotBeNull(); + extractionRequest.Messages.Should().HaveCount(2); + var syntheticBoundaries = extractionRequest.Messages.Select(message => + message.Metadata.TryGetValue("sourceSyntheticBoundary", out var boundary) && + Equals(boundary, true)); + syntheticBoundaries.Should().OnlyContain(isSynthetic => !isSynthetic); + recallRequest.Should().NotBeNull(); + recallRequest!.Options.MaxRelevantMessages.Should().Be(0); + recallRequest.Options.MaxEntities.Should().Be(10); + recallRequest.Options.MaxFacts.Should().Be(10); + recallRequest.Options.MaxPreferences.Should().Be(10); + answerMessages.Should().Contain(message => + message.Text != null && + message.Text.Contains("[fact] user stayed_in Japan for two weeks", StringComparison.Ordinal)); + var telemetry = adapter.QuestionTelemetry.Should().ContainSingle().Subject; + var extractionUnitsProperty = telemetry.GetType().GetProperty("ExtractionUnits"); + extractionUnitsProperty.Should().NotBeNull( + "structured-mode telemetry must expose the extraction work performed"); + extractionUnitsProperty!.GetValue(telemetry).Should().Be(1); + } + + [Fact] + public async Task InvokeAsync_PreparedStructuredModeSkipsWritesAndExtraction() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var invocationPrompt = LongMemEvalEvidenceIndexTests.InvocationPrompt(entry); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var evidenceQuestion = evidenceIndex.GetByQuestionId(entry.QuestionId); + var sourceSessions = evidenceQuestion.Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Distinct() + .Count(); + var graphSnapshot = new LongMemEvalGraphSnapshot(1, 1, 1, 1, 1, 3, 3, 6, 2); + var manifest = LongMemEvalPreparationManifest.Create( + "prepared-test", + "dataset-sha256", + "agenteval-revision", + "prepared-run", + "answer-model", + "judge-model", + "extraction-model", + "embedding-model", + 1536, + 30, + "source-message-time", + [ + new LongMemEvalPreparedQuestion( + 1, evidenceQuestion.QuestionId, LongMemEvalEvidenceIndex.Fingerprint(history), + LongMemEvalPreparationManifest.Hash( + "prepared-run-session-0001|prepared-run-owner-0001"), + evidenceQuestion.Messages.Count(m => + !m.IsSyntheticBoundary && !m.IsSyntheticFormatterPadding), + sourceSessions, sourceSessions, graphSnapshot) + ], + sourceSessions * 4); + var memory = Substitute.For(); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => new RecallResult + { + Context = new MemoryContext + { + SessionId = call.Arg().SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantFacts = new MemoryContextSection + { + Items = + [ + new Fact + { + FactId = "fact-prepared", + Subject = "user", + Predicate = "stayed_in", + Object = "Japan for two weeks", + Confidence = 0.95, + CreatedAtUtc = DateTimeOffset.UnixEpoch + } + ] + } + }, + TotalItemsRetrieved = 1 + }); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, "The stay lasted two weeks."))); + var graphProbe = new PreparedGraphProbe(graphSnapshot); + var adapterOptions = new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + RequireGraphReadBack = true, + GraphProbe = graphProbe, + ModelId = "answer-model", + PreparedState = new LongMemEvalPreparedState(manifest, "prepared-run") + }; + var preparedProperty = typeof(LongMemEvalAdapterOptions).GetProperty("PreparedMemory"); + preparedProperty.Should().NotBeNull( + "prepared evaluation must be an explicit, reportable operating mode"); + preparedProperty!.SetValue(adapterOptions, true); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, chat, "prepared-run", adapterOptions); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + await adapter.InvokeAsync(invocationPrompt); + + await memory.DidNotReceive().AddMessagesAsync( + Arg.Any>(), Arg.Any()); + await memory.DidNotReceive().ExtractAndPersistAsync( + Arg.Any(), Arg.Any()); + await memory.Received(1).RecallAsync( + Arg.Any(), Arg.Any()); + var telemetry = adapter.QuestionTelemetry.Should().ContainSingle().Subject; + telemetry.MessagesStored.Should().Be(0); + telemetry.ExtractionUnits.Should().Be(0); + // Preparation persists real conversation only; the fabricated boundary turns are excluded. + telemetry.MessagesPrepared.Should().Be(evidenceQuestion.Messages.Count(m => + !m.IsSyntheticBoundary && !m.IsSyntheticFormatterPadding)); + telemetry.ExtractionUnitsPrepared.Should().Be(sourceSessions); + telemetry.PreparedMemory.Should().BeTrue(); + telemetry.StageTimings.Should().NotBeNull(); + telemetry.StageTimings!.StorageMs.Should().Be(0); + telemetry.StageTimings.ExtractionPersistenceMs.Should().Be(0); + } + + private sealed class PreparedGraphProbe(LongMemEvalGraphSnapshot snapshot) : ILongMemEvalGraphProbe + { + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } + private static Message Message(string sessionId, string role, string content) => new() + { + MessageId = Guid.NewGuid().ToString("N"), + SessionId = sessionId, + ConversationId = sessionId, + Role = role, + Content = content, + TimestampUtc = DateTimeOffset.UnixEpoch + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/BatchAccountingGuardTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/BatchAccountingGuardTests.cs new file mode 100644 index 00000000..55eab0d5 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/BatchAccountingGuardTests.cs @@ -0,0 +1,82 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// The cost guard after it was refined from "nothing went wrong" to "nothing is unaccounted for". +/// +/// +/// The original guard required exactly the planned number of provider calls and zero failures. That +/// is not a property a 614-call run over a network can hold, and it is incompatible with the +/// extractor's own recovery paths — a parse retry re-prompts, a batch split re-sends the halves, and +/// both legitimately add calls. Three consecutive 15–40 minute preparations died on it. +/// +/// Refining a guard is only defensible if it still catches what it was for. These tests exist to +/// show that it does: is the case the +/// guard really protects, and it must fail. Correctness is not this guard's job — the session-set +/// comparison beside it proves every planned session persisted, in order, and is unchanged. +/// +/// +public sealed class BatchAccountingGuardTests +{ + private const int Planned = 12; + + [Fact] + public void ExactlyThePlannedCallsIsAccepted() + { + Accept(successful: 12, unified: 12).Should().BeTrue(); + } + + [Fact] + public void AnExcessWithNoRecordedRecoveryIsStillRejected() + { + // The load-bearing case. Four calls appeared that no split and no retry accounts for: that + // is unexplained provider work against a sealed manifest, and it is exactly what this guard + // exists to catch. If refining it had removed this, the guard would be decoration. + Accept(successful: 16, unified: 16).Should().BeFalse(); + } + + [Fact] + public void AnExcessExplainedByARecordedSplitIsAccepted() + { + // The failure that motivated the refinement: a genuine parse-or-format split, doing what it + // is designed to do, produced 16 successful calls against 12 planned. + Accept(successful: 16, unified: 16, splits: 1).Should().BeTrue(); + } + + [Fact] + public void AnExcessExplainedByARecordedRetryIsAccepted() + { + Accept(successful: 14, unified: 14, retries: 2).Should().BeTrue(); + } + + [Fact] + public void FewerCallsThanPlannedIsRejected() + { + // Under-running is never explainable: a batch that never ran cannot have been recovered. + Accept(successful: 11, unified: 11, splits: 1, retries: 5).Should().BeFalse(); + } + + [Fact] + public void ACallOfAnUnexpectedPurposeIsRejectedEvenWhenRecoveryIsRecorded() + { + // Purpose is not something recovery explains. A split re-sends unified batches; it never + // produces a call of some other kind, so this stays a hard failure. + Accept(successful: 12, unified: 12, other: 1, splits: 1).Should().BeFalse(); + } + + [Fact] + public void MissingSplitDiagnosticsFailClosed() + { + // BatchSplitCount is optional on the adapter options, so a harness that never wired it + // reports zero splits. An excess must then read as unexplained rather than as innocent. + Accept(successful: 16, unified: 16, splits: 0, retries: 0).Should().BeFalse(); + } + + private static bool Accept( + long successful, long unified, long other = 0, long splits = 0, long retries = 0) => + AgentMemoryLongMemEvalAdapter.IsBatchAccountingAcceptable( + successful, unified, other, splits, retries, Planned); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagDuplicationTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagDuplicationTests.cs new file mode 100644 index 00000000..16d8bc68 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagDuplicationTests.cs @@ -0,0 +1,72 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// K6. The duplication counter that decides whether GraphRAG adds evidence or re-fetches it. +/// +/// +/// The measurement this supports is the whole point of giving GraphRAG a non-zero budget: pointed at +/// the memory layer's own fact index, does it return rows the structured surface already has? A +/// counter that silently over- or under-reports would produce a confident answer to that question +/// with nothing behind it, so the boundaries are pinned here rather than trusted. +/// +public sealed class GraphRagDuplicationTests +{ + [Fact] + public void AnItemNamingARetrievedFactCounts() + { + var count = AgentMemoryLongMemEvalAdapter.CountGraphRagFactsAlreadyRetrieved( + [Item("f-1"), Item("f-2")], [FactWith("f-1"), FactWith("f-2")]); + + count.Should().Be(2); + } + + [Fact] + public void AnItemNamingAFactTheStructuredSurfaceMissedDoesNotCount() + { + var count = AgentMemoryLongMemEvalAdapter.CountGraphRagFactsAlreadyRetrieved( + [Item("f-1"), Item("f-9")], [FactWith("f-1")]); + + count.Should().Be(1); + } + + [Fact] + public void AnItemWithNoFactIdIsNotCountedAsDuplicated() + { + // The load-bearing boundary. Without the harness's explicit retrieval query there is no node + // identity at all (K10), and an unidentifiable item must read as "cannot tell", never as + // "distinct evidence" - which would understate duplication exactly where it matters. + var count = AgentMemoryLongMemEvalAdapter.CountGraphRagFactsAlreadyRetrieved( + [new GraphRagContextItem { Text = "Alice likes coffee" }], [FactWith("f-1")]); + + count.Should().Be(0); + } + + [Fact] + public void NothingRetrievedMeansNothingDuplicated() + { + AgentMemoryLongMemEvalAdapter + .CountGraphRagFactsAlreadyRetrieved([], [FactWith("f-1")]) + .Should().Be(0); + } + + private static GraphRagContextItem Item(string factId) => new() + { + Text = "some passage", + Metadata = new Dictionary { ["fact_id"] = factId } + }; + + private static Fact FactWith(string factId) => new() + { + FactId = factId, + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 1, + CreatedAtUtc = DateTimeOffset.UnixEpoch + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagWiringTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagWiringTests.cs new file mode 100644 index 00000000..e4fa069d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/GraphRagWiringTests.cs @@ -0,0 +1,97 @@ +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// K6. Is GraphRAG actually reachable when the harness asks for it? +/// +/// +/// The first K6 run measured GraphRAG returning zero items across sixty questions and was one step +/// from reporting that as a property of the surface. It was not: the harness pinned +/// BlendMode = MemoryOnly, which the assembler documents as "GraphRAG suppressed even when +/// enabled" and checks before the budget. A non-zero MaxGraphRagItems alone retrieves +/// nothing. +/// +/// Three separate things must line up before a single item can come back — the flag, the registered +/// source, and the blend mode — and two of them are unreachable through the paths a reader would +/// naturally check (K9). Each one is asserted here, so the next zero is evidence about the surface +/// rather than about the wiring. +/// +/// +public sealed class GraphRagWiringTests +{ + [Fact] + public void AskingForGraphRagEnablesIt() + { + // K9: this cannot be done through the configureMemory action at all, so the profile replaces + // the registered IOptions. If that override ever stops winning over the open + // generic, GraphRAG silently returns nothing again. + Resolve(graphRagIndexName: "fact_embedding_idx") + .GetRequiredService>().Value + .EnableGraphRag.Should().BeTrue(); + } + + [Fact] + public void AskingForGraphRagRegistersASource() + { + Resolve(graphRagIndexName: "fact_embedding_idx") + .GetService().Should().NotBeNull(); + } + + [Fact] + public void TheConfiguredIndexAndProjectionSurvive() + { + // K10: without an explicit retrieval query a Fact node has no `text` property, so the prompt + // would receive the driver's dump of the whole node, embedding included. + var options = Resolve(graphRagIndexName: "fact_embedding_idx") + .GetRequiredService>().Value; + + options.IndexName.Should().Be("fact_embedding_idx"); + options.RetrievalQuery.Should().Contain("fact_id"); + } + + [Fact] + public void NotAskingForGraphRagLeavesEverythingOff() + { + // The default for every run this track has produced, and the state prior runs are comparable + // against. Nothing about K6 may change it. + var provider = Resolve(graphRagIndexName: null); + + provider.GetRequiredService>().Value.EnableGraphRag.Should().BeFalse(); + provider.GetService().Should().BeNull(); + } + + [Theory] + [InlineData(0, RetrievalBlendMode.MemoryOnly)] + [InlineData(5, RetrievalBlendMode.Blended)] + public void TheBlendModeStopsSuppressingGraphRagOnlyWhenABudgetIsAskedFor( + int graphRagBudget, RetrievalBlendMode expected) + { + // The defect the first K6 run actually hit. MemoryOnly is checked before the budget, so this + // is what decides whether any of the wiring above matters. + AgentMemoryLongMemEvalAdapter.BlendModeFor(graphRagBudget).Should().Be(expected); + } + + private static ServiceProvider Resolve(string? graphRagIndexName) => + LongMemEvalMemoryProfile.ConfigureServices( + "bolt://localhost:7687", + Substitute.For>>(), + Substitute.For(), + LongMemEvalMemoryMode.Structured, + "gpt-4o-mini", + embeddingDimensions: 1536, + enableBatchedPreparation: true, + maxConcurrentBatchesPerExtraction: 1, + maxConcurrentExtractionBatches: 6, + usePredicateVocabulary: true, + graphRagIndexName) + .BuildServiceProvider(); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/JudgeVerdictParsingTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/JudgeVerdictParsingTests.cs new file mode 100644 index 00000000..c34f7dac --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/JudgeVerdictParsingTests.cs @@ -0,0 +1,70 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// Reading the judge's verdict, including prefixes nobody hardcoded. +/// +/// +/// The parser stripped exactly two prefixes — "Judge said:" and "Judge outcome:" — and required the +/// next letter-token to be yes or no. A judge that phrases its verdict any other way is reported as +/// "returned no valid yes/no verdict", which rejects the whole arm and discards a run. +/// +/// That is not hypothetical and it is not the judge being wrong. Question dad224aa was +/// rejected in 2 of 5 identical n=50 repeats, and the diagnostic captured +/// FailureKind=unparseable, RejectedToken="Judge" — the judge produced a verdict in a third +/// "Judge…:" shape and our parser could not read it. On one of those runs the retry recovered the +/// same question with a valid verdict, which is the clearest possible evidence the judgement was +/// fine and the parsing was not. +/// +/// +/// The fix stays conservative: a leading prefix is only stripped when doing so actually yields a +/// yes/no verdict, so tolerance cannot manufacture a verdict out of a hedge. +/// +/// +public sealed class JudgeVerdictParsingTests +{ + [Theory] + [InlineData("yes")] + [InlineData("Yes, the answer matches the reference.")] + [InlineData("Judge said: yes")] + [InlineData("Judge outcome: yes")] + [InlineData("Judge verdict: yes")] // the shape that cost two runs + [InlineData("Judgement: yes")] + [InlineData("Judgment: YES — the times agree.")] + public void ACorrectVerdictIsReadWhateverPrefixTheJudgeUses(string explanation) + { + LongMemEvalRunValidator.TryParseJudgeVerdict(explanation, out var correct) + .Should().BeTrue($"'{explanation}' states a verdict"); + correct.Should().BeTrue(); + } + + [Theory] + [InlineData("no")] + [InlineData("Judge verdict: no")] + [InlineData("Judge outcome: No, the answer omits the amount.")] + public void AnIncorrectVerdictIsReadWhateverPrefixTheJudgeUses(string explanation) + { + LongMemEvalRunValidator.TryParseJudgeVerdict(explanation, out var correct) + .Should().BeTrue(); + correct.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Partially correct.")] + [InlineData("Judge verdict: partially correct")] + [InlineData("Judge could not determine an answer")] + [InlineData("The answer is correct.")] + [InlineData("maybe: yes")] + public void AnythingThatIsNotAYesOrNoStaysInvalid(string explanation) + { + // The guard the tolerance must not defeat. Widening the prefix handling must never turn a + // hedge into a verdict — an unreadable judgement has to stay unreadable, because inventing + // one silently scores a question nobody judged. + LongMemEvalRunValidator.TryParseJudgeVerdict(explanation, out _).Should().BeFalse(); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAgentEvalEvidenceTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAgentEvalEvidenceTests.cs new file mode 100644 index 00000000..4255545d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAgentEvalEvidenceTests.cs @@ -0,0 +1,173 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using MemoryFact = AgentMemory.Abstractions.Domain.Fact; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalAgentEvalEvidenceTests +{ + [Xunit.Fact] + public void Build_RawMessagePreservesExactTurnScoreTimestampAndPromptOrder() + { + var message = new Message + { + MessageId = "message-1", + SessionId = "run-session", + ConversationId = "run-session", + Role = "user", + Content = "I stayed in Japan for two weeks.", + TimestampUtc = DateTimeOffset.UnixEpoch + }; + var context = new MemoryContext + { + SessionId = "run-session", + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [message], + RankedItems = [new MemoryContextRankedItem("message-1", 0.875, 1, 1)] + } + }; + var origins = new Dictionary + { + ["message-1"] = Origin( + ordinal: 0, + sessionId: "source-session-1", + turn: 3, + timestamp: "2024/01/01 (Mon) 10:00") + }; + + var envelope = LongMemEvalAgentEvalEvidence.Build( + context, origins, LongMemEvalEvidenceDetail.Identifiers); + + var retrieved = envelope.Retrieved.Should().ContainSingle().Subject; + retrieved.Id.Should().Be("message-1"); + retrieved.Rank.Should().Be(1); + retrieved.SimilarityScore.Should().Be(0.875); + retrieved.SourceSessionId.Should().Be("source-session-1"); + retrieved.SourceTurnIndex.Should().Be(3); + retrieved.SourceTimestamp.Should().Be( + new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero)); + retrieved.AnswerContextOrder.Should().BeNull(); + retrieved.Content.Should().BeNull(); + var answer = envelope.AnswerContext.Should().ContainSingle().Subject; + answer.AnswerContextOrder.Should().Be(1); + answer.Content.Should().BeNull(); + } + + [Xunit.Fact] + public void Build_StructuredWholeSessionReportsSessionButDoesNotInventDecisiveTurn() + { + var context = new MemoryContext + { + SessionId = "run-session", + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantFacts = new MemoryContextSection + { + Items = + [ + new MemoryFact + { + FactId = "fact-1", + Subject = "user", + Predicate = "stayed_in", + Object = "Japan for two weeks", + Confidence = 0.9, + SourceMessageIds = ["message-1", "message-2"], + CreatedAtUtc = DateTimeOffset.UnixEpoch + } + ], + RankedItems = [new MemoryContextRankedItem("fact-1", 0.75, 1, 1)] + } + }; + var origins = new Dictionary + { + ["message-1"] = Origin( + ordinal: 0, + sessionId: "source-session-1", + turn: 0, + timestamp: "2024/01/01 (Mon) 10:00"), + ["message-2"] = Origin( + ordinal: 1, + sessionId: "source-session-1", + turn: 1, + timestamp: "2024/01/01 (Mon) 10:00") + }; + + var envelope = LongMemEvalAgentEvalEvidence.Build( + context, origins, LongMemEvalEvidenceDetail.Identifiers); + + var reference = envelope.Retrieved.Should().ContainSingle().Subject; + reference.Id.Should().Be("fact:fact-1"); + reference.SimilarityScore.Should().Be(0.75); + reference.SourceSessionId.Should().Be("source-session-1"); + reference.SourceTurnIndex.Should().BeNull( + "the extractor assigns the whole source session to each learned item"); + reference.SourceTimestamp.Should().BeNull( + "no single source turn is attributable without using evaluator gold labels"); + } + + + [Xunit.Fact] + public void Build_SyntheticBoundaryRetainsContextButCannotSatisfyGoldSessionEvidence() + { + var message = new Message + { + MessageId = "boundary-1", + SessionId = "run-session", + ConversationId = "run-session", + Role = "user", + Content = "--- Session 1 ---", + TimestampUtc = DateTimeOffset.UnixEpoch + }; + var context = new MemoryContext + { + SessionId = "run-session", + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [message] + } + }; + var origins = new Dictionary + { + ["boundary-1"] = new( + MessageOrdinal: 0, + SourceSessionId: "source-session-1", + SourceSessionOrdinal: 0, + SourceTurnOrdinal: null, + SourceTimestamp: "2024/01/01 (Mon) 10:00", + Role: "user", + FormattedContent: "--- Session 1 ---", + IsSyntheticBoundary: true, + IsSyntheticFormatterPadding: false, + HasAnswer: false) + }; + + var envelope = LongMemEvalAgentEvalEvidence.Build( + context, origins, LongMemEvalEvidenceDetail.Identifiers); + + var reference = envelope.Retrieved.Should().ContainSingle().Subject; + reference.Id.Should().Be("boundary-1"); + reference.SourceSessionId.Should().BeNull(); + reference.SourceTurnIndex.Should().BeNull(); + reference.SourceTimestamp.Should().BeNull(); + } + private static LongMemEvalMessageOrigin Origin( + int ordinal, + string sessionId, + int turn, + string timestamp) => + new( + MessageOrdinal: ordinal, + SourceSessionId: sessionId, + SourceSessionOrdinal: 0, + SourceTurnOrdinal: turn, + SourceTimestamp: timestamp, + Role: turn % 2 == 0 ? "user" : "assistant", + FormattedContent: $"content-{ordinal}", + IsSyntheticBoundary: false, + IsSyntheticFormatterPadding: false, + HasAnswer: false); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAnswerPromptTimeTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAnswerPromptTimeTests.cs new file mode 100644 index 00000000..d2ed6cf4 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalAnswerPromptTimeTests.cs @@ -0,0 +1,110 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G3B.2. LongMemEval session dates live only in AgentEval's boundary markers, which G3B.1 drops as +/// formatter boilerplate — so the filtered arms carried no time information at all and every +/// temporal question became unanswerable. These guard the restored signal. +/// +public sealed class LongMemEvalAnswerPromptTimeTests +{ + private const string QuestionDate = "2023/06/03 (Sat) 15:47"; + + [Fact] + public void RecalledMessagesCarryTheirSourceTimestampIntoTheAnswerPrompt() + { + // Without this, "20 titles" and "currently 25" are indistinguishable and knowledge-update + // questions cannot be answered even when both turns were retrieved. + var context = ContextWith( + Message("m1", "2023/05/20 (Sat) 10:19", "I have 20 titles to watch."), + Message("m2", "2023/05/22 (Mon) 03:27", "My to-watch list is currently 25.")); + + var prompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt(context, "How many?", QuestionDate); + + Assert.Contains("2023/05/20 (Sat) 10:19", prompt, StringComparison.Ordinal); + Assert.Contains("2023/05/22 (Mon) 03:27", prompt, StringComparison.Ordinal); + } + + [Fact] + public void TheCurrentDateReachesTheAnswerPrompt() + { + // "How many days ago did I attend a networking event?" is unanswerable without it, however + // good retrieval is. + var prompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + ContextWith(Message("m1", "2022/03/09 (Wed) 12:08", "Just back from a networking event.")), + "How many days ago?", + QuestionDate); + + Assert.Contains(QuestionDate, prompt, StringComparison.Ordinal); + } + + [Fact] + public void AMessageWithoutASourceTimestampStillRendersRatherThanBeingDropped() + { + // Absent provenance must degrade to the stored clock, never silently remove evidence. + var message = new Message + { + MessageId = "m1", + SessionId = "s", + ConversationId = "s", + Role = "user", + Content = "no provenance here", + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(7) + }; + + var prompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + ContextWith(message), "Question?", QuestionDate); + + Assert.Contains("no provenance here", prompt, StringComparison.Ordinal); + } + + [Fact] + public void TheTimestampComesFromRecalledMetadataNotFromEvaluatorSideKnowledge() + { + // The point of the fix: AgentMemory already returns this through recall, so the harness is + // restoring product data rather than injecting answers it happens to know. + var message = Message("m1", "2023/05/22 (Mon) 03:27", "content"); + Assert.True(message.Metadata.ContainsKey("sourceTimestamp")); + + var prompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + ContextWith(message), "Question?", QuestionDate); + + Assert.Contains("2023/05/22 (Mon) 03:27", prompt, StringComparison.Ordinal); + } + + [Fact] + public void TheReferenceHistoryOverloadCarriesTimestampsToo() + { + // The fairness rule: the full-history baseline is fixed in the same commit, or the + // comparison measures the fix rather than the memory system. + var prompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + [("user", "2023/05/20 (Sat) 10:19", "twenty"), ("user", "2023/05/22 (Mon) 03:27", "twenty five")], + "How many?", + QuestionDate); + + Assert.Contains("2023/05/20 (Sat) 10:19", prompt, StringComparison.Ordinal); + Assert.Contains("2023/05/22 (Mon) 03:27", prompt, StringComparison.Ordinal); + Assert.Contains(QuestionDate, prompt, StringComparison.Ordinal); + } + + private static Message Message(string id, string sourceTimestamp, string content) => new() + { + MessageId = id, + SessionId = "s", + ConversationId = "s", + Role = "user", + Content = content, + TimestampUtc = DateTimeOffset.UnixEpoch, + Metadata = new Dictionary { ["sourceTimestamp"] = sourceTimestamp } + }; + + private static MemoryContext ContextWith(params Message[] messages) => new() + { + SessionId = "s", + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection { Items = messages } + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCallDetailSafetyTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCallDetailSafetyTests.cs new file mode 100644 index 00000000..bd4ff8d1 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCallDetailSafetyTests.cs @@ -0,0 +1,45 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalCallDetailSafetyTests +{ + [Fact] + public async Task AllCallDetailsAreBoundedAndContainNoPromptOrResponseText() + { + var provider = Substitute.For(); + provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, "sensitive response"))); + using var meter = new LongMemEvalChatCallMeter(provider); + + for (var index = 0; index < 65; index++) + { + await meter.GetResponseAsync( + [ + new ChatMessage( + ChatRole.System, + "You are an entity extraction assistant. sensitive prompt") + ]); + } + + var snapshot = meter.Snapshot(); + snapshot.Calls.Should().Be(65); + snapshot.CallDetails.Should().HaveCount(64); + snapshot.CallDetails.Select(detail => detail.CallOrdinal) + .Should().Equal(Enumerable.Range(2, 64).Select(value => (long)value)); + snapshot.CallDetails.Should().OnlyContain(detail => + detail.Purpose == "entity" && + detail.ExceptionType == null && + detail.ProviderStatus == null); + snapshot.DroppedCallDetails.Should().Be(1); + snapshot.ToString().Should().NotContain("sensitive"); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCheckpointFingerprintTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCheckpointFingerprintTests.cs new file mode 100644 index 00000000..58a94b17 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalCheckpointFingerprintTests.cs @@ -0,0 +1,61 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// Phase 1.4. The checkpoint fingerprint identifies the configuration whose cold-build wall time the +/// checkpoint projects. It carried PreparationWorkers but neither provider-concurrency knob, so two +/// runs with materially different concurrency shared a fingerprint and their projections could be +/// compared as though equivalent. +/// +public sealed class LongMemEvalCheckpointFingerprintTests +{ + [Theory] + [InlineData("MaxConcurrentBatchesPerExtraction")] + [InlineData("MaxConcurrentExtractionBatches")] + [InlineData("PreparationWorkers")] + public void EveryConcurrencyKnobIsPartOfTheCheckpointIdentity(string knob) + { + // Asserted against the source because the fingerprint is computed inline from an anonymous + // object; the property must appear inside the hashed payload, not merely exist on options. + var source = File.ReadAllText(SourcePath()); + var start = source.IndexOf("var checkpointFingerprint", StringComparison.Ordinal); + start.Should().BeGreaterThan(0, "the checkpoint fingerprint must exist"); + var end = source.IndexOf("Console.WriteLine", start, StringComparison.Ordinal); + var payload = source[start..end]; + + payload.Should().Contain(knob, + $"{knob} changes the wall time the checkpoint projects, so it must change its identity"); + } + + [Fact] + public void TheProjectionInputsAreAlsoPartOfTheIdentity() + { + // A projection compared against one computed under a different batch budget would be + // meaningless, so these must be pinned too. + var source = File.ReadAllText(SourcePath()); + var start = source.IndexOf("var checkpointFingerprint", StringComparison.Ordinal); + var end = source.IndexOf("Console.WriteLine", start, StringComparison.Ordinal); + var payload = source[start..end]; + + payload.Should().Contain("MaxSessionsPerBatch"); + payload.Should().Contain("MaxInputTokens"); + } + + private static string SourcePath() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && + !Directory.Exists(Path.Combine(directory.FullName, "tools"))) + { + directory = directory.Parent; + } + + directory.Should().NotBeNull("the repository root must be locatable from the test binary"); + return Path.Combine( + directory!.FullName, "tools", "AgentMemory.LongMemEval", + "LongMemEvalPreparedPairProgram.cs"); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalContextSizeTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalContextSizeTests.cs new file mode 100644 index 00000000..986ecc61 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalContextSizeTests.cs @@ -0,0 +1,52 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// J5.1. Every quality number this track produces is half a result without its cost. +/// +/// +/// The recorded band gives Structured 673 tokens against Hybrid's 2,143, and that ratio is the +/// load-bearing premise of the whole tier ladder — light exists because structured-only was +/// cheap. Those figures predate predicate expansion, which now adds up to 100 facts, so the premise +/// is very likely false and nothing in the report can settle it: item counts are recorded, context +/// size is not. +/// +public sealed class LongMemEvalContextSizeTests +{ + [Fact] + public void AnEmptyContextCostsNothing() => + LongMemEvalContextSize.Estimate(null).Should().Be(0); + + [Fact] + public void SizeGrowsWithContent() + { + var small = LongMemEvalContextSize.Estimate("a short line"); + var large = LongMemEvalContextSize.Estimate(string.Concat(Enumerable.Repeat("a short line ", 50))); + + large.Should().BeGreaterThan(small); + } + + [Fact] + public void TheEstimateIsAboutFourCharactersPerToken() + { + // Deliberately an estimate, not a tokenizer: the answer needed is "is Structured still three + // times cheaper than Hybrid", and a ratio survives a consistent approximation. Naming it + // Estimate keeps that visible rather than implying a real token count. + LongMemEvalContextSize.Estimate(new string('x', 400)).Should().BeInRange(90, 110); + } + + [Fact] + public void TheEstimateIsStable() + { + // It becomes recorded run metadata, so it must not drift between calls. + const string Text = "the blue sofa was bought in March"; + LongMemEvalContextSize.Estimate(Text).Should().Be(LongMemEvalContextSize.Estimate(Text)); + } + + [Fact] + public void WhitespaceOnlyContentCostsNothing() => + LongMemEvalContextSize.Estimate(" \n\t ").Should().Be(0); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalDiagnosticCliTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalDiagnosticCliTests.cs new file mode 100644 index 00000000..500eda67 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalDiagnosticCliTests.cs @@ -0,0 +1,147 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalDiagnosticCliTests +{ + [Fact] + public async Task DiagnosticOnlyExecutionRejectsAnOutputPathBeforeProviderWork() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"agentmemory-lme-diagnostic-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var dataset = Path.Combine(directory, "dataset.json"); + var output = Path.Combine(directory, "forbidden-report.json"); + await File.WriteAllTextAsync(dataset, "[]"); + + try + { + var exitCode = await LongMemEvalPreparedPairProgram.RunAsync( + [ + "--dataset", dataset, + "--questions", "10", + "--diagnostic-question", "3", + "--diagnostic-source-session", "14", + "--output", output + ]); + + exitCode.Should().Be(1); + File.Exists(output).Should().BeFalse( + "diagnostic-only extraction can never create or accept a report"); + } + finally + { + if (File.Exists(output)) + File.Delete(output); + if (File.Exists(dataset)) + File.Delete(dataset); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } + [Fact] + public async Task PreflightOnlyExecutionRejectsAnOutputPathBeforeProviderWork() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"agentmemory-lme-preflight-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var dataset = Path.Combine(directory, "dataset.json"); + var output = Path.Combine(directory, "forbidden-report.json"); + await File.WriteAllTextAsync(dataset, "[]"); + + try + { + var exitCode = await LongMemEvalPreparedPairProgram.RunAsync( + [ + "--dataset", dataset, + "--questions", "10", + "--preflight-only", + "--output", output + ]); + + exitCode.Should().Be(1); + File.Exists(output).Should().BeFalse( + "preflight-only execution can never create or accept a report"); + } + finally + { + if (File.Exists(output)) + File.Delete(output); + if (File.Exists(dataset)) + File.Delete(dataset); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } + + [Fact] + public async Task CheckpointExecutionRejectsAnOutputPathBeforeProviderWork() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"agentmemory-lme-checkpoint-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var dataset = Path.Combine(directory, "dataset.json"); + var output = Path.Combine(directory, "forbidden-report.json"); + await File.WriteAllTextAsync(dataset, "[]"); + + try + { + var exitCode = await LongMemEvalPreparedPairProgram.RunAsync( + [ + "--dataset", dataset, + "--questions", "10", + "--checkpoint-questions", "3", + "--output", output + ]); + + exitCode.Should().Be(1); + File.Exists(output).Should().BeFalse( + "checkpoint execution can never create or accept a report"); + } + finally + { + if (File.Exists(output)) + File.Delete(output); + if (File.Exists(dataset)) + File.Delete(dataset); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } + + [Fact] + public async Task CheckpointExecutionRejectsPreflightModeBeforeProviderWork() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"agentmemory-lme-checkpoint-mode-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var dataset = Path.Combine(directory, "dataset.json"); + await File.WriteAllTextAsync(dataset, "[]"); + + try + { + var exitCode = await LongMemEvalPreparedPairProgram.RunAsync( + [ + "--dataset", dataset, + "--questions", "10", + "--checkpoint-questions", "3", + "--preflight-only" + ]); + + exitCode.Should().Be(1); + } + finally + { + if (File.Exists(dataset)) + File.Delete(dataset); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceIndexTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceIndexTests.cs new file mode 100644 index 00000000..1359cf21 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceIndexTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalEvidenceIndexTests +{ + [Fact] + public void Resolve_AlignsAgentEvalBoundaryAndSourceTurnsWithoutGoldLeakage() + { + var entry = Entry(); + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + var index = LongMemEvalEvidenceIndex.Create([entry], options); + + var resolved = index.Resolve(formatted, InvocationPrompt(entry)); + + resolved.QuestionId.Should().Be("q-1"); + resolved.Messages.Should().HaveCount(4); + resolved.Messages.Take(2).Should().OnlyContain(message => message.IsSyntheticBoundary); + resolved.Messages[2].Should().BeEquivalentTo(new + { + SourceSessionId = "session-1", + SourceTurnOrdinal = (int?)0, + SourceTimestamp = "2024/01/01 (Mon) 10:00", + HasAnswer = true + }); + resolved.AnswerSessionIds.Should().ContainSingle().Which.Should().Be("session-1"); + index.GetByQuestionId("q-1").Should().BeSameAs(resolved); + } + + [Fact] + public void Create_AlignsOddSessionWhenAgentEvalDropsLeadingAssistantTurn() + { + var entry = Entry(); + entry.HaystackSessions = + [ + [ + new LongMemEvalTurn + { + Role = "assistant", + Content = "Prior assistant-only preamble.", + HasAnswer = false + }, + new LongMemEvalTurn + { + Role = "user", + Content = "I stayed in Japan for two weeks.", + HasAnswer = true + }, + new LongMemEvalTurn + { + Role = "assistant", + Content = "That sounds memorable.", + HasAnswer = false + } + ] + ]; + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + + var resolved = LongMemEvalEvidenceIndex.Create([entry], options) + .Resolve(formatted, InvocationPrompt(entry)); + + resolved.Messages.Should().HaveCount(4); + resolved.Messages.Select(message => message.SourceTurnOrdinal).Should() + .Equal(null, null, 1, 2); + } + + [Fact] + public void Create_AlignsTrailingUserTurnWithAgentEvalSyntheticAssistant() + { + var entry = Entry(); + entry.HaystackSessions = + [ + [ + new LongMemEvalTurn + { + Role = "user", + Content = "I stayed in Japan for two weeks.", + HasAnswer = true + }, + new LongMemEvalTurn + { + Role = "assistant", + Content = "That sounds memorable.", + HasAnswer = false + }, + new LongMemEvalTurn + { + Role = "user", + Content = "I returned home yesterday.", + HasAnswer = false + } + ] + ]; + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + + var resolved = LongMemEvalEvidenceIndex.Create([entry], options) + .Resolve(formatted, InvocationPrompt(entry)); + + resolved.Messages.Should().HaveCount(6); + resolved.Messages.Select(message => message.SourceTurnOrdinal).Should() + .Equal(null, null, 0, 1, 2, null); + resolved.Messages[^1].IsSyntheticFormatterPadding.Should().BeTrue(); + } + + [Fact] + public void Resolve_MatchesAgentEvalCurrentDateInvocationPrompt() + { + var entry = Entry(); + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + var index = LongMemEvalEvidenceIndex.Create([entry], options); + var invocationPrompt = $"Current Date: {entry.QuestionDate}\n\n{entry.Question}"; + + var resolved = index.Resolve(formatted, invocationPrompt); + + resolved.Question.Should().Be(entry.Question); + } + + [Fact] + public void Resolve_RejectsMutatedHistoryBeforeItCanBePersisted() + { + var entry = Entry(); + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options).ToArray(); + formatted[^1] = (formatted[^1].UserMessage + " mutated", formatted[^1].AssistantResponse); + var index = LongMemEvalEvidenceIndex.Create([entry], options); + + var act = () => index.Resolve(formatted, InvocationPrompt(entry)); + + act.Should().Throw() + .WithMessage("*does not match*"); + } + + [Fact] + public void Build_ComputesGoldRecallRanksAndOmitsContentByDefault() + { + var entry = Entry(); + var options = Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + var question = LongMemEvalEvidenceIndex.Create([entry], options) + .Resolve(formatted, InvocationPrompt(entry)); + var recalled = question.Messages.Select((origin, index) => new Message + { + MessageId = $"m-{index}", + SessionId = "evaluation-session", + ConversationId = "evaluation-session", + Role = origin.Role, + Content = origin.FormattedContent, + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(index) + }).ToArray(); + var ranked = recalled.Select((message, index) => new MemoryContextRankedItem( + message.MessageId, + Score: 1d - index / 10d, + RetrievalRank: index + 1, + ContextRank: index + 1)).ToArray(); + var origins = recalled.Select((message, index) => (message.MessageId, question.Messages[index])) + .ToDictionary(item => item.MessageId, item => item.Item2, StringComparer.Ordinal); + + var evidence = LongMemEvalRetrievalEvidence.Build( + question, recalled, ranked, origins, LongMemEvalEvidenceDetail.Identifiers, + answerPromptCharacters: 400, configuredMessageBudget: 30); + + evidence.GoldAttributionObservable.Should().BeTrue(); + evidence.K.Should().Be(4); + evidence.AnswerPromptCharacters.Should().Be(400); + evidence.EstimatedAnswerPromptTokens.Should().Be(100); + evidence.GoldSessionRecallAtK.Should().Be(1); + evidence.GoldTurnHitAtK.Should().BeTrue(); + evidence.FirstGoldSessionRank.Should().Be(3); + evidence.FirstGoldTurnRank.Should().Be(3); + evidence.ReciprocalRank.Should().BeApproximately(1d / 3d, 0.000001); + evidence.DistinctSourceSessions.Should().Be(1); + evidence.RankedItems.Should().HaveCount(4) + .And.OnlyContain(item => item.Content == null); + } + + internal static LongMemEvalEntry Entry() => new() + { + QuestionId = "q-1", + QuestionType = "temporal-reasoning", + Question = "How long was the trip?", + AnswerRaw = JsonDocument.Parse("\"two weeks\"").RootElement.Clone(), + QuestionDate = "2024/02/01 (Thu) 10:00", + HaystackSessionIds = ["session-1"], + HaystackDates = ["2024/01/01 (Mon) 10:00"], + AnswerSessionIds = ["session-1"], + HaystackSessions = + [ + [ + new LongMemEvalTurn + { + Role = "user", + Content = "I stayed in Japan for two weeks.", + HasAnswer = true + }, + new LongMemEvalTurn + { + Role = "assistant", + Content = "That sounds memorable.", + HasAnswer = false + } + ] + ] + }; + + internal static string InvocationPrompt(LongMemEvalEntry entry) => + string.IsNullOrEmpty(entry.QuestionDate) + ? entry.Question + : $"Current Date: {entry.QuestionDate}\n\n{entry.Question}"; + internal static ExternalBenchmarkOptions Options() => new() + { + MaxQuestions = 1, + StratifiedSampling = false, + PreserveSessionBoundaries = true, + IncludeTimestamps = true, + HistoryInjectionMode = HistoryInjectionMode.StructuredChatHistory, + DatasetMode = "S" + }; +} \ No newline at end of file diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceProjectionTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceProjectionTests.cs new file mode 100644 index 00000000..580d829c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalEvidenceProjectionTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using AgentEval.Memory.External.Models; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalEvidenceProjectionTests +{ + [Fact] + public void CreateAcceptedResult_IdentifierModeRetainsSafeNormalizedEvidence() + { + var result = new ExternalBenchmarkResult + { + BenchmarkId = "benchmark-id", + BenchmarkName = "benchmark-name", + PerTypeResults = new Dictionary(), + OverallAccuracy = 100, + TaskAveragedAccuracy = 100, + Duration = TimeSpan.FromSeconds(1), + Options = new ExternalBenchmarkOptions(), + QuestionResults = + [ + new QuestionResult + { + QuestionId = "q-1", + QuestionType = "multi-session", + Question = "question-sentinel", + GoldAnswer = "gold-sentinel", + AgentResponse = "answer-sentinel", + Correct = true, + RawScore = 100, + Evidence = new QuestionEvidenceEnvelope + { + SchemaVersion = QuestionEvidenceEnvelope.CurrentSchemaVersion, + Retrieved = + [ + new EvidenceReference + { + Id = "fact:safe-id", + Rank = 1, + SimilarityScore = 0.75, + SourceSessionId = "safe-session", + Content = "evidence-content-sentinel" + } + ] + }, + EvidenceDiagnostics = new QuestionEvidenceDiagnostics + { + Status = EvidenceObservationStatus.Observed, + RetrievedReferenceCount = 1, + AnswerContextReferenceCount = 1, + DistinctSourceSessionCount = 1 + }, + Duration = TimeSpan.FromSeconds(1) + } + ] + }; + + var projection = LongMemEvalReportProjection.CreateAcceptedResult( + result, LongMemEvalEvidenceDetail.Identifiers); + var json = JsonSerializer.Serialize(projection); + + json.Should().Contain("\"Evidence\":") + .And.Contain("fact:safe-id") + .And.Contain("safe-session") + .And.Contain("\"EvidenceDiagnostics\":") + .And.NotContain("evidence-content-sentinel") + .And.NotContain("question-sentinel") + .And.NotContain("gold-sentinel") + .And.NotContain("answer-sentinel"); + } + + [Fact] + public void RankedEvidence_IdentifierModeOmitsNullContentProperty() + { + var evidence = new LongMemEvalRankedEvidence( + MessageId: "message-1", + RetrievalRank: 1, + ContextRank: 1, + SimilarityScore: 0.75, + SourceSessionId: "session-1", + SourceSessionOrdinal: 0, + SourceTurnOrdinal: 0, + SourceTimestamp: "2026-01-01T00:00:00Z", + Role: "user", + IsSyntheticBoundary: false, + IsSyntheticFormatterPadding: false, + GoldSessionHit: true, + GoldTurnHit: true, + Content: null); + + var json = JsonSerializer.Serialize(evidence); + + json.Should().NotContain("\"Content\":"); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpandedFactEvidenceTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpandedFactEvidenceTests.cs new file mode 100644 index 00000000..d7ea72b4 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpandedFactEvidenceTests.cs @@ -0,0 +1,82 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; +using MemoryFact = AgentMemory.Abstractions.Domain.Fact; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G5-BUG1. Predicate expansion returns a relation across the whole owner, so an expanded fact may +/// carry provenance outside the current question's message window. That is expected; a fact whose +/// source genuinely cannot be resolved is corruption. Conflating them made every question fail with +/// retrieval-diagnostics-error before a single answer call. +/// +public sealed class LongMemEvalExpandedFactEvidenceTests +{ + [Fact] + public void AnExpandedFactWithOutOfWindowProvenanceIsAccepted() + { + var context = ContextWith(Fact("expanded", "outside-window", expanded: true)); + + var act = () => LongMemEvalAgentEvalEvidence.Build( + context, Origins(), LongMemEvalEvidenceDetail.Identifiers); + + act.Should().NotThrow(); + } + + [Fact] + public void AnUnmarkedFactWithUnresolvableProvenanceStillThrows() + { + // The guard exists to catch provenance corruption and is deliberately untouched: only the + // new, legitimate category is exempted. + var context = ContextWith(Fact("ordinary", "outside-window", expanded: false)); + + var act = () => LongMemEvalAgentEvalEvidence.Build( + context, Origins(), LongMemEvalEvidenceDetail.Identifiers); + + act.Should().Throw().WithMessage("*could not map source message*"); + } + + [Fact] + public void AnExpandedFactWhoseProvenanceIsInWindowIsStillAccepted() + { + var context = ContextWith(Fact("expanded-inside", "known-message", expanded: true)); + + var act = () => LongMemEvalAgentEvalEvidence.Build( + context, Origins(), LongMemEvalEvidenceDetail.Identifiers); + + act.Should().NotThrow(); + } + + private static MemoryFact Fact(string id, string sourceMessageId, bool expanded) => new() + { + FactId = id, + Subject = "s", + Predicate = "was_born", + Object = "o", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.UnixEpoch, + SourceMessageIds = [sourceMessageId], + Metadata = expanded + ? new Dictionary + { + [MemoryFact.RetrievalSourceMetadataKey] = MemoryFact.RetrievalSourcePredicateExpansion + } + : new Dictionary() + }; + + private static MemoryContext ContextWith(params MemoryFact[] facts) => new() + { + SessionId = "s", + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantFacts = new MemoryContextSection { Items = facts } + }; + + private static IReadOnlyDictionary Origins() => + new Dictionary(StringComparer.Ordinal) + { + ["known-message"] = new(0, "session-1", 0, 0, "2023/05/20 (Sat) 10:19", "user", + "known-message", false, false, false) + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionBudgetTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionBudgetTests.cs new file mode 100644 index 00000000..a163f91b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionBudgetTests.cs @@ -0,0 +1,82 @@ +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G5-BUG1, real cause. AgentEval rejects an evidence envelope above 100 references, and that cap +/// counts entities, facts and preferences — not the fact budget alone. A structured arm +/// already spends ~30 before expansion, so the old 100-fact default guaranteed overflow: nine of ten +/// questions failed mid-run with an opaque diagnostics error, and the one that survived was simply +/// the one whose expansion returned few enough facts. +/// +public sealed class LongMemEvalExpansionBudgetTests +{ + [Fact] + public void AnExpansionBudgetThatWouldOverflowTheEvidenceEnvelopeIsRejectedAtConstruction() + { + // The point of failing here: the overflow previously surfaced only after a 121-call, + // 22-minute rebuild had already been paid for. + var act = () => Create(expandedFacts: 100); + + act.Should().Throw() + .WithMessage("*evidence references*maximum*"); + } + + [Fact] + public void TheDefaultExpansionBudgetFitsTheEnvelope() + { + var act = () => Create(expandedFacts: null); + + act.Should().NotThrow(); + } + + [Fact] + public void ABudgetThatExactlyFillsTheEnvelopeIsAccepted() + { + // Structured at 30 spends 10 + 10 + 10; 70 more reaches exactly the 100-reference cap. + var act = () => Create(expandedFacts: 70); + + act.Should().NotThrow(); + } + + [Fact] + public void OneOverTheEnvelopeIsRejected() + { + var act = () => Create(expandedFacts: 71); + + act.Should().Throw(); + } + + [Fact] + public void NoLimitIsImposedWhenExpansionIsOff() + { + // Without expansion the envelope cannot overflow, so the budget is irrelevant and must not + // block an otherwise valid configuration. + var act = () => Create(expandedFacts: 1_000, expand: false); + + act.Should().NotThrow(); + } + + private static AgentMemoryLongMemEvalAdapter Create(int? expandedFacts, bool expand = true) + { + var options = new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + MaxRelevantMessages = 30, + ExpandFactsByPredicate = expand + }; + if (expandedFacts is { } value) + options = options with { MaxExpandedFacts = value }; + + return new AgentMemoryLongMemEvalAdapter( + Substitute.For(), + Substitute.For(), + "budget-run", + options); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionWiringTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionWiringTests.cs new file mode 100644 index 00000000..71c97f39 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExpansionWiringTests.cs @@ -0,0 +1,77 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G5. Guards that the option reaches the actual recall request. Twice today a mechanism shipped +/// green — BuildSystemPrompt and the fused write path — while nothing called it, and only a live run +/// exposed it. This asserts the call site, not the capability. +/// +public sealed class LongMemEvalExpansionWiringTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TheExpansionOptionReachesTheRecallRequest(bool expand) + { + var memory = Substitute.For(); + RecallRequest? captured = null; + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + captured = call.Arg(); + return new RecallResult + { + Context = new MemoryContext + { + SessionId = captured.SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantMessages = new MemoryContextSection + { + Items = [new Message + { + MessageId = "m", + SessionId = captured.SessionId, + ConversationId = captured.SessionId, + Role = "user", + Content = "recalled", + TimestampUtc = DateTimeOffset.UnixEpoch + }] + } + }, + TotalItemsRetrieved = 1 + }; + }); + + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse(new ChatMessage(ChatRole.Assistant, "an answer"))); + + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, chat, "expansion-run", + new LongMemEvalAdapterOptions + { + ExpandFactsByPredicate = expand, + MaxExpandedFacts = 50 + }); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory([("a question was asked", "an answer was given")]); + await adapter.InvokeAsync("What happened?"); + + captured.Should().NotBeNull(); + captured!.Options.ExpandFactsByPredicate.Should().Be(expand); + captured.Options.MaxExpandedFacts.Should().Be(50); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtraCallDiagnosticTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtraCallDiagnosticTests.cs new file mode 100644 index 00000000..aeae6c34 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtraCallDiagnosticTests.cs @@ -0,0 +1,218 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalExtraCallDiagnosticTests +{ + [Fact] + public async Task PreparationExtraCallsIdentifyTheRepeatedSuccessfulPurpose() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = LongMemEvalHistoryFormatter.Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var provider = SuccessfulProvider(); + using var meter = new LongMemEvalChatCallMeter(provider); + var memory = MemoryWithExtraction(async () => + { + await CallAsync(meter, "entity"); + await CallAsync(meter, "fact"); + await CallAsync(meter, "preference"); + await CallAsync(meter, "relationship"); + await CallAsync(meter, "relationship"); + await CallAsync(meter, "relationship"); + }); + var adapter = Adapter(memory, meter, evidenceIndex); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + var act = () => adapter.InvokeAsync( + LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + + var failure = await act.Should().ThrowAsync(); + failure.Which.Message.Should().Contain( + "Call purposes: entity=1, fact=1, preference=1, relationship=3."); + failure.Which.Message.Should().NotContain("sensitive"); + } + + [Fact] + public async Task DiagnosticSourceSessionSelectorRunsExactlyOneUnit() + { + var entry = ThreeSessionEntry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = LongMemEvalHistoryFormatter.Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + using var meter = new LongMemEvalChatCallMeter(SuccessfulProvider()); + ExtractionRequest? request = null; + var memory = Substitute.For(); + memory.AddMessagesAsync( + Arg.Any>(), + Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(async call => + { + request = call.Arg(); + await CallAsync(meter, "entity"); + await CallAsync(meter, "fact"); + await CallAsync(meter, "preference"); + await CallAsync(meter, "relationship"); + return new ExtractionResult(); + }); + var progress = new List<(int Completed, int Total)>(); + var options = Options(evidenceIndex) with + { + ExtractionProgress = (completed, total) => + progress.Add((completed, total)) + }; + var selector = typeof(LongMemEvalAdapterOptions) + .GetProperty( + "DiagnosticSourceSessionOrdinal", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + selector.Should().NotBeNull( + "the locked diagnostic must select one source-session unit without changing benchmark acceptance"); + selector!.SetValue(options, 1); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, meter, "single-unit-red", options); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + await adapter.InvokeAsync(LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + + await memory.Received(1).ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()); + request.Should().NotBeNull(); + request!.SessionId.Should().EndWith("-source-0001"); + request.Messages.Should().HaveCount(2); + request.Messages.Should().OnlyContain(message => + message.Content.Contains("session two", StringComparison.Ordinal)); + progress.Should().Equal((0, 1), (1, 1)); + adapter.QuestionTelemetry.Should().ContainSingle() + .Which.ExtractionUnits.Should().Be(1); + } + + private static AgentMemoryLongMemEvalAdapter Adapter( + IMemoryService memory, + LongMemEvalChatCallMeter meter, + LongMemEvalEvidenceIndex evidenceIndex) => + new(memory, meter, "extra-call-red", Options(evidenceIndex)); + + private static LongMemEvalAdapterOptions Options( + LongMemEvalEvidenceIndex evidenceIndex) => + new() + { + MemoryMode = LongMemEvalMemoryMode.Structured, + ModelId = "answer-model", + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + PreparationOnly = true, + RequireGraphReadBack = true, + GraphProbe = new Probe() + }; + + private static IMemoryService MemoryWithExtraction(Func extraction) + { + var memory = Substitute.For(); + memory.AddMessagesAsync( + Arg.Any>(), + Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(async _ => + { + await extraction(); + return new ExtractionResult(); + }); + return memory; + } + + private static IChatClient SuccessfulProvider() + { + var provider = Substitute.For(); + provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, """{"entities":[]}"""))); + return provider; + } + + private static Task CallAsync( + IChatClient client, + string purpose) => + client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.System, + $"You are {(purpose == "entity" ? "an" : "a")} {purpose} extraction assistant. sensitive prompt") + ]); + + private static LongMemEvalEntry ThreeSessionEntry() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + entry.HaystackSessionIds = ["session-1", "session-2", "session-3"]; + entry.HaystackDates = + [ + "2024/01/01 (Mon) 10:00", + "2024/01/02 (Tue) 10:00", + "2024/01/03 (Wed) 10:00" + ]; + entry.AnswerSessionIds = ["session-2"]; + entry.HaystackSessions = + [ + Session("session one"), + Session("session two"), + Session("session three") + ]; + return entry; + } + + private static List Session(string label) => + [ + new LongMemEvalTurn + { + Role = "user", + Content = $"{label} user message", + HasAnswer = label == "session two" + }, + new LongMemEvalTurn + { + Role = "assistant", + Content = $"{label} assistant message", + HasAnswer = false + } + ]; + + private sealed class Probe : ILongMemEvalGraphProbe + { + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) => + Task.FromResult(new LongMemEvalGraphSnapshot( + Entities: 1, + Facts: 0, + Preferences: 0, + Relationships: 0, + RelationshipsWithProvenance: 0, + LearnedItems: 1, + LearnedItemsWithProvenance: 1, + ProvenanceEdges: 1, + SourceMessages: 1)); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtractionFailureDiagnosticTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtractionFailureDiagnosticTests.cs new file mode 100644 index 00000000..38e8b68a --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalExtractionFailureDiagnosticTests.cs @@ -0,0 +1,123 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalExtractionFailureDiagnosticTests +{ + private const string ProtectedFailureText = + "PROTECTED provider response and request identifier must never escape"; + + [Fact] + public async Task PreparationRejectsProviderFailureAtItsSourceSessionWithoutProtectedText() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var provider = Substitute.For(); + var providerCall = 0; + provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(_ => + { + providerCall++; + if (providerCall == 2) + throw new InvalidOperationException(ProtectedFailureText); + + return new ChatResponse( + new ChatMessage(ChatRole.Assistant, """{"entities":[]}""")); + }); + using var meter = new LongMemEvalChatCallMeter(provider); + + var memory = Substitute.For(); + memory.AddMessagesAsync( + Arg.Any>(), + Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(async callInfo => + { + var purposes = new[] + { + "You are an entity extraction assistant.", + "You are a fact extraction assistant.", + "You are a preference extraction assistant.", + "You are a relationship extraction assistant." + }; + foreach (var purpose in purposes) + { + try + { + _ = await meter.GetResponseAsync( + [new ChatMessage(ChatRole.System, purpose)]); + } + catch (InvalidOperationException) + { + // Mirrors ExtractorBase: the provider exception becomes an empty + // extraction result and the pipeline can still report Succeeded. + } + } + + return new ExtractionResult(); + }); + + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + meter, + "failure-diagnostic-red", + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + ModelId = "answer-model", + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + PreparationOnly = true, + RequireGraphReadBack = true, + GraphProbe = new Probe() + }); + await adapter.ResetSessionAsync(); + adapter.InjectConversationHistory(history); + + var act = () => adapter.InvokeAsync( + LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + + var failure = await act.Should().ThrowAsync(); + failure.Which.Message.Should().Contain("question 1"); + failure.Which.Message.Should().Contain("source session 0"); + failure.Which.Message.Should().Contain("4 calls"); + failure.Which.Message.Should().Contain("1 failures"); + failure.Which.Message.Should().Contain("fact"); + failure.Which.Message.Should().Contain(nameof(InvalidOperationException)); + failure.Which.Message.Should().NotContain(ProtectedFailureText); + adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Status.Should().Be("extraction-provider-accounting-error"); + } + + private sealed class Probe : ILongMemEvalGraphProbe + { + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) => + Task.FromResult(new LongMemEvalGraphSnapshot( + Entities: 1, + Facts: 0, + Preferences: 0, + Relationships: 0, + RelationshipsWithProvenance: 0, + LearnedItems: 1, + LearnedItemsWithProvenance: 1, + ProvenanceEdges: 1, + SourceMessages: 1)); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldAttributionObservabilityTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldAttributionObservabilityTests.cs new file mode 100644 index 00000000..e041ef6c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldAttributionObservabilityTests.cs @@ -0,0 +1,98 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// BUG-E1. Gold attribution is computed only over recalled raw messages, but Structured mode +/// allocates a zero message budget. The metric must say "not observable", never a fabricated 0.0 +/// that downstream diagnostics then report as a product retrieval defect. +/// +public sealed class LongMemEvalGoldAttributionObservabilityTests +{ + private static LongMemEvalEvidenceQuestion ResolvedQuestion() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var options = LongMemEvalEvidenceIndexTests.Options(); + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + return LongMemEvalEvidenceIndex.Create([entry], options) + .Resolve(formatted, LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + } + + /// + /// The Structured shape: gold sessions exist, but no message was recalled because none could be. + /// Reporting 0.0 here asserts that retrieval looked and failed; it never looked. + /// + [Fact] + public void Build_WithNoMessageBudget_ReportsGoldMetricsAsNotObservable() + { + var question = ResolvedQuestion(); + + var evidence = LongMemEvalRetrievalEvidence.Build( + question, + recalled: Array.Empty(), + rankedItems: Array.Empty(), + originsByMessageId: new Dictionary(StringComparer.Ordinal), + detail: LongMemEvalEvidenceDetail.Identifiers, + answerPromptCharacters: 400, + configuredMessageBudget: 0); + + question.AnswerSessionIds.Should().NotBeEmpty( + "the fixture must have gold sessions, or this test proves nothing"); + evidence.GoldAttributionObservable.Should().BeFalse(); + evidence.GoldSessionRecallAtK.Should().BeNull( + "a zero message budget means gold attribution was never attempted"); + evidence.GoldTurnHitAtK.Should().BeNull(); + evidence.ReciprocalRank.Should().BeNull(); + } + + /// + /// Raw mode with a real budget that returned nothing is a genuine miss and must stay observable — + /// the fix must not blanket-null the metric and hide real retrieval failures. + /// + [Fact] + public void Build_WithMessageBudgetButNoHits_RemainsObservableAndReportsAMiss() + { + var question = ResolvedQuestion(); + + var evidence = LongMemEvalRetrievalEvidence.Build( + question, + recalled: Array.Empty(), + rankedItems: Array.Empty(), + originsByMessageId: new Dictionary(StringComparer.Ordinal), + detail: LongMemEvalEvidenceDetail.Identifiers, + answerPromptCharacters: 400, + configuredMessageBudget: 30); + + evidence.GoldAttributionObservable.Should().BeTrue(); + evidence.GoldSessionRecallAtK.Should().Be(0d, + "a budget of 30 that returned nothing is a real retrieval miss, not an unobservable one"); + } + + /// + /// The consequence that matters: an unobservable metric must not be classified as a product + /// retrieval defect, and must not silently fall through to an answer-synthesis verdict either. + /// + [Fact] + public void Classify_UnobservableGoldAttribution_IsNotReportedAsRetrievalMiss() + { + var question = ResolvedQuestion(); + var evidence = LongMemEvalRetrievalEvidence.Build( + question, + recalled: Array.Empty(), + rankedItems: Array.Empty(), + originsByMessageId: new Dictionary(StringComparer.Ordinal), + detail: LongMemEvalEvidenceDetail.Identifiers, + answerPromptCharacters: 400, + configuredMessageBudget: 0); + + var classification = LongMemEvalPostRunDiagnostics.ClassifyForTest(evidence); + + classification.Should().Be("retrieval-not-observable"); + classification.Should().NotBe("retrieval-miss"); + classification.Should().NotBe("answer-synthesis-failure"); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldEvidenceCoverageTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldEvidenceCoverageTests.cs new file mode 100644 index 00000000..8a351047 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGoldEvidenceCoverageTests.cs @@ -0,0 +1,104 @@ +using AgentMemory.LongMemEval; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G3B.5. The prepared path already proves the cold build is sound — non-empty, fully +/// provenanced, and bit-identical to the sealed snapshot. None of that proves it is adequate: +/// three facts learned from 474 sessions passes every existing guard. These cover the adequacy check. +/// +public sealed class LongMemEvalGoldEvidenceCoverageTests +{ + [Fact] + public void LearningNothingFromTheAnswerSessionsIsAnExtractionFailureNotARetrievalOne() + { + // The question was unanswerable before recall ever ran, so blaming retrieval would hide an + // extraction defect behind a retrieval label. + var coverage = new LongMemEvalGoldEvidenceCoverage( + GoldLearnedItems: 0, GoldSourceMessagesCovered: 0, GoldSourceMessages: 12); + + Assert.False(coverage.EvidenceLearned); + Assert.Equal( + "extraction-lost-evidence", + LongMemEvalPostRunDiagnostics.ClassifyForTest(evidence: null, goldCoverage: coverage)); + } + + [Fact] + public void LostEvidenceOutranksTheNotObservableVerdictItWouldOtherwiseGet() + { + // BUG-E1 reports Structured failures as "not observable" because gold attribution resolves + // only through messages. That is honest but uninformative; a proven extraction loss is a + // stronger, more specific finding and must win. + var evidence = Evidence(goldSessionRecall: null, goldTurnHit: null, observable: false); + + Assert.Equal( + "extraction-lost-evidence", + LongMemEvalPostRunDiagnostics.ClassifyForTest( + evidence, + new LongMemEvalGoldEvidenceCoverage(0, 0, 12))); + } + + [Fact] + public void AGraphThatDidLearnFromTheAnswerSessionsFallsThroughToTheExistingVerdicts() + { + // The new check must not swallow the attribution it sits in front of. + var evidence = Evidence(goldSessionRecall: 1d, goldTurnHit: true); + + Assert.Equal( + "answer-synthesis-failure", + LongMemEvalPostRunDiagnostics.ClassifyForTest( + evidence, + new LongMemEvalGoldEvidenceCoverage(7, 3, 12))); + } + + [Fact] + public void AbsentCoverageLeavesEveryExistingVerdictUnchanged() + { + // Raw mode has no extraction, so it must classify exactly as it did before this change. + var evidence = Evidence(goldSessionRecall: 0.5d, goldTurnHit: true); + + Assert.Equal( + "retrieval-miss", + LongMemEvalPostRunDiagnostics.ClassifyForTest(evidence, goldCoverage: null)); + } + + private static LongMemEvalRetrievalEvidence Evidence( + double? goldSessionRecall, + bool? goldTurnHit, + bool observable = true) => new( + K: 30, + AnswerPromptCharacters: 10_000, + EstimatedAnswerPromptTokens: 2_500, + DistinctSourceSessions: 10, + MaxItemsFromSingleSession: 4, + GoldSessionsRequired: 1, + GoldSessionsHit: goldSessionRecall == 1 ? 1 : 0, + GoldSessionRecallAtK: goldSessionRecall, + AnnotatedGoldTurns: 1, + GoldTurnsHit: goldTurnHit is true ? 1 : 0, + GoldTurnHitAtK: goldTurnHit, + FirstGoldSessionRank: goldSessionRecall == 1 ? 5 : null, + FirstGoldTurnRank: goldTurnHit is true ? 5 : null, + ReciprocalRank: goldSessionRecall == 1 ? 0.2 : null, + RankedItems: [], + GoldAttributionObservable: observable); + + [Fact] + public void SourceMessageCoverageReportsTheFractionThatContributedAnything() + { + var coverage = new LongMemEvalGoldEvidenceCoverage( + GoldLearnedItems: 9, GoldSourceMessagesCovered: 3, GoldSourceMessages: 12); + + Assert.True(coverage.EvidenceLearned); + Assert.Equal(0.25d, coverage.SourceMessageCoverage); + } + + [Fact] + public void NoAnswerBearingMessagesAtAllDoesNotDivideByZero() + { + var coverage = new LongMemEvalGoldEvidenceCoverage(0, 0, 0); + + Assert.Equal(0d, coverage.SourceMessageCoverage); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGraphReadBackTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGraphReadBackTests.cs new file mode 100644 index 00000000..4931b96e --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalGraphReadBackTests.cs @@ -0,0 +1,162 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using MemoryFact = AgentMemory.Abstractions.Domain.Fact; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalGraphReadBackTests +{ + [Xunit.Fact] + public async Task StructuredMode_CompleteGraphReadBackPermitsRecallAndEmitsSnapshot() + { + var expected = new LongMemEvalGraphSnapshot( + Entities: 1, + Facts: 1, + Preferences: 1, + Relationships: 1, + RelationshipsWithProvenance: 1, + LearnedItems: 3, + LearnedItemsWithProvenance: 3, + ProvenanceEdges: 6, + SourceMessages: 2); + var harness = CreateHarness(expected); + + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + await harness.Adapter.InvokeAsync(harness.Prompt); + + harness.GraphProbe.CallCount.Should().Be(1); + harness.GraphProbe.OwnerId.Should().NotBeNullOrWhiteSpace(); + await harness.Memory.Received(1).RecallAsync( + Arg.Any(), + Arg.Any()); + var telemetry = harness.Adapter.QuestionTelemetry.Should().ContainSingle().Subject; + telemetry.Status.Should().Be("completed"); + telemetry.GraphReadBack.Should().Be(expected); + } + + [Xunit.Fact] + public async Task StructuredMode_EmptyGraphReadBackFailsBeforeRecall() + { + var harness = CreateHarness(new LongMemEvalGraphSnapshot( + Entities: 0, + Facts: 0, + Preferences: 0, + Relationships: 0, + RelationshipsWithProvenance: 0, + LearnedItems: 0, + LearnedItemsWithProvenance: 0, + ProvenanceEdges: 0, + SourceMessages: 0)); + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + + var act = () => harness.Adapter.InvokeAsync(harness.Prompt); + + await act.Should().ThrowAsync() + .WithMessage("*did not prove non-empty learned memory with complete provenance*"); + await harness.Memory.DidNotReceive().RecallAsync( + Arg.Any(), + Arg.Any()); + var telemetry = harness.Adapter.QuestionTelemetry.Should().ContainSingle().Subject; + telemetry.Status.Should().Be("graph-readback-empty"); + telemetry.GraphReadBack.Should().NotBeNull(); + telemetry.GraphReadBack!.TotalLearned.Should().Be(0); + } + + private static Harness CreateHarness(LongMemEvalGraphSnapshot snapshot) + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var memory = Substitute.For(); + memory.AddMessagesAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(new ExtractionResult()); + memory.RecallAsync(Arg.Any(), Arg.Any()) + .Returns(call => new RecallResult + { + Context = new MemoryContext + { + SessionId = call.Arg().SessionId, + AssembledAtUtc = DateTimeOffset.UnixEpoch, + RelevantFacts = new MemoryContextSection + { + Items = + [ + new MemoryFact + { + FactId = "fact-1", + Subject = "user", + Predicate = "visited", + Object = "Japan", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.UnixEpoch + } + ] + } + }, + TotalItemsRetrieved = 1 + }); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, "The user visited Japan."))); + var graphProbe = new FakeGraphProbe(snapshot); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + chat, + "graph-readback-run", + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + RequireGraphReadBack = true, + GraphProbe = graphProbe + }); + return new Harness( + adapter, + memory, + graphProbe, + history, + LongMemEvalEvidenceIndexTests.InvocationPrompt(entry)); + } + + private sealed record Harness( + AgentMemoryLongMemEvalAdapter Adapter, + IMemoryService Memory, + FakeGraphProbe GraphProbe, + IReadOnlyList<(string UserMessage, string AssistantResponse)> History, + string Prompt); + + private sealed class FakeGraphProbe(LongMemEvalGraphSnapshot snapshot) + : ILongMemEvalGraphProbe + { + public int CallCount { get; private set; } + + public string? OwnerId { get; private set; } + + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + CallCount++; + OwnerId = ownerId; + return Task.FromResult(snapshot); + } + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalOrphanSweepTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalOrphanSweepTests.cs new file mode 100644 index 00000000..b3981e2b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalOrphanSweepTests.cs @@ -0,0 +1,148 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// Retention keeps a cold build on disk so it can be reused, and a killed run never gets to clean up +/// after itself. Measured before this was written: 25 orphaned volumes holding ~17.2 GB. The removal +/// decision is kept pure so it can be tested without a Docker daemon. +/// +public sealed class LongMemEvalOrphanSweepTests +{ + private static readonly DateTimeOffset Now = + new(2026, 8, 8, 15, 0, 0, TimeSpan.Zero); + + [Fact] + public void OldUnreferencedClonesAreRemoved() + { + // Clones are regenerable from a base in seconds; they are the bulk of the leak. The base + // must be present in the fixture, because a clone without one is the only copy of its graph + // and is deliberately protected. + var decision = Select( + Volume("am-lme-run-a-structured-1111", hoursAgo: 6), + Volume("am-lme-run-a-hybrid-1111", hoursAgo: 6), + Volume("am-lme-run-a-base-1111", hoursAgo: 6)); + + decision.Removable.Should().BeEquivalentTo( + "am-lme-run-a-structured-1111", "am-lme-run-a-hybrid-1111"); + } + + [Fact] + public void TheVolumeNamedForReuseIsNeverRemoved() + { + var decision = Select( + protectedVolumeName: "am-lme-run-a-base-1111", + Volume("am-lme-run-a-base-1111", hoursAgo: 6), + Volume("am-lme-run-b-base-2222", hoursAgo: 7)); + + decision.Removable.Should().NotContain("am-lme-run-a-base-1111"); + decision.Skipped.Should().ContainSingle(skip => + skip.Name == "am-lme-run-a-base-1111" && skip.Reason.Contains("reuse")); + } + + [Fact] + public void CloneTargetsOfTheReusedVolumeAreNeverRemoved() + { + // AdoptAsync names its clone targets after the adopted base, so a prefix match protects the + // in-flight clones of the very run performing the sweep. + var decision = Select( + protectedVolumeName: "am-lme-run-a-base-1111", + Volume("am-lme-run-a-base-1111-reuse-structured-abcd", hoursAgo: 9)); + + decision.Removable.Should().BeEmpty(); + } + + [Fact] + public void VolumesYoungerThanTheMinimumAgeAreNeverRemoved() + { + // The load-bearing guard: a concurrently running evaluation creates its clone volumes long + // before it mounts them, so a fresh unreferenced volume may belong to a live run. + var decision = Select( + Volume("am-lme-run-a-structured-1111", hoursAgo: 0.25), + Volume("am-lme-run-a-base-1111", hoursAgo: 0.25), + Volume("am-lme-run-old-hybrid-9999", hoursAgo: 40), + Volume("am-lme-run-old-base-9999", hoursAgo: 40)); + + decision.Removable.Should().BeEquivalentTo( + "am-lme-run-old-hybrid-9999", "am-lme-run-old-base-9999"); + decision.Skipped.Should().Contain(skip => + skip.Name == "am-lme-run-a-structured-1111" && skip.Reason.Contains("age")); + decision.Skipped.Should().Contain(skip => + skip.Name == "am-lme-run-a-base-1111" && skip.Reason.Contains("age")); + } + + [Fact] + public void TheNewestBaseIsKeptBecauseItRepresentsAPaidColdBuild() + { + // A base is 121 provider calls and ~22 minutes. Older ones are garbage; the newest is the + // one a retrieval-only experiment would want to adopt. + var decision = Select( + Volume("am-lme-run-old-base-1111", hoursAgo: 9), + Volume("am-lme-run-new-base-2222", hoursAgo: 6), + Volume("am-lme-run-new-structured-2222", hoursAgo: 6)); + + decision.Removable.Should().BeEquivalentTo( + "am-lme-run-old-base-1111", "am-lme-run-new-structured-2222"); + decision.Skipped.Should().Contain(skip => + skip.Name == "am-lme-run-new-base-2222" && skip.Reason.Contains("newest")); + } + + [Fact] + public void ACloneWithNoSurvivingBaseIsKeptBecauseItCannotBeRegenerated() + { + // This rule exists because its absence destroyed a real artifact: a lone retained + // pre-vocabulary Structured clone whose base had already been removed was swept as a + // "regenerable clone". Cheap-to-recreate is only true while the base it was cloned from + // still exists. + var decision = Select( + Volume("am-lme-orphaned-structured-1111", hoursAgo: 20), + Volume("am-lme-paired-structured-2222", hoursAgo: 20), + Volume("am-lme-paired-base-2222", hoursAgo: 20), + Volume("am-lme-newest-base-3333", hoursAgo: 5)); + + decision.Removable.Should().BeEquivalentTo( + "am-lme-paired-structured-2222", "am-lme-paired-base-2222"); + decision.Skipped.Should().Contain(skip => + skip.Name == "am-lme-orphaned-structured-1111" && skip.Reason.Contains("regenerated")); + } + + [Fact] + public void APinnedVolumeIsNeverRemovedHoweverOldItIs() + { + var decision = LongMemEvalOrphanSweep.Select( + [Volume("am-lme-run-a-hybrid-1111", hoursAgo: 900)], + protectedVolumeName: null, + Now, + minimumAge: null, + pinned: ["am-lme-run-a-hybrid-1111"]); + + decision.Removable.Should().BeEmpty(); + decision.Skipped.Should().ContainSingle(skip => skip.Reason.Contains("pinned")); + } + + [Fact] + public void VolumesOutsideTheLongMemEvalNamespaceAreNeverConsidered() + { + // The sweep runs on a developer machine that has unrelated Docker volumes on it. + var decision = Select( + Volume("postgres-data", hoursAgo: 500), + Volume("277e3702a0f44f437072b60fd4d26f1d15c51f96fb12e17ddd6cc16711cc677d", hoursAgo: 500)); + + decision.Removable.Should().BeEmpty(); + decision.Skipped.Should().BeEmpty(); + } + + private static LongMemEvalOrphanSweepDecision Select( + params LongMemEvalVolumeCandidate[] candidates) => + LongMemEvalOrphanSweep.Select(candidates, protectedVolumeName: null, Now); + + private static LongMemEvalOrphanSweepDecision Select( + string? protectedVolumeName, + params LongMemEvalVolumeCandidate[] candidates) => + LongMemEvalOrphanSweep.Select(candidates, protectedVolumeName, Now); + + private static LongMemEvalVolumeCandidate Volume(string name, double hoursAgo) => + new(name, Now.AddHours(-hoursAgo)); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPostRunDiagnosticsTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPostRunDiagnosticsTests.cs new file mode 100644 index 00000000..06efa5c8 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPostRunDiagnosticsTests.cs @@ -0,0 +1,155 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPostRunDiagnosticsTests +{ + [Fact] + public void OracleDiagnosticContract_IsAvailable() + { + typeof(LongMemEvalRunValidator).Assembly + .GetType("AgentMemory.LongMemEval.LongMemEvalPostRunDiagnostics") + .Should().NotBeNull( + "M-27-V2 G2 requires an evaluator-side judge-retry and oracle diagnostic arm"); + } + + [Fact] + public async Task RunAsync_RetriesInvalidJudgeWithoutRewritingBenchmarkResult() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var options = LongMemEvalEvidenceIndexTests.Options(); + var index = LongMemEvalEvidenceIndex.Create([entry], options); + var question = Result(judgeExplanation: "Judge said: "); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse(new ChatMessage(ChatRole.Assistant, "yes"))); + + var diagnostics = await LongMemEvalPostRunDiagnostics.RunAsync( + chat, + index, + [question], + telemetry: [], + LongMemEvalOracleMode.None, + judgeRetryAttempts: 1, + retainContent: false); + + diagnostics.DiagnosticLlmCalls.Should().Be(1); + diagnostics.JudgeRetries.Should().ContainSingle().Which.Should().BeEquivalentTo(new + { + QuestionId = "q-1", + Status = "recovered", + Attempts = 1, + ValidVerdict = true, + Correct = true, + LlmCalls = 1 + }); + question.JudgeExplanation.Should().Be("Judge said: "); + question.Correct.Should().BeFalse(); + } + + [Fact] + public async Task RunAsync_OracleUsesTheControlAnswerPromptContract() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var options = LongMemEvalEvidenceIndexTests.Options(); + var index = LongMemEvalEvidenceIndex.Create([entry], options); + var calls = new List>(); + var chat = Substitute.For(); + chat.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + calls.Add(call.Arg>().ToArray()); + var response = calls.Count == 1 ? "two weeks" : "yes"; + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + }); + + var diagnostics = await LongMemEvalPostRunDiagnostics.RunAsync( + chat, + index, + [Result()], + telemetry: [], + LongMemEvalOracleMode.All, + judgeRetryAttempts: 0, + retainContent: false); + + diagnostics.DiagnosticLlmCalls.Should().Be(2); + calls.Should().HaveCount(2); + calls[0][0].Text.Should().Be( + "Answer the question using only the retrieved memory below. " + + "Be concise and do not claim information that is absent from memory."); + calls[0][1].Text.Should().StartWith("Retrieved memory:\n") + .And.NotContain("Oracle memory:") + .And.Contain($"\nQuestion: Current Date: {entry.QuestionDate}\n\n{entry.Question}\nAnswer:"); + } + + [Fact] + public void Attribute_ReportsRetrievalMissWhenOraclePassesWithoutGoldEvidence() + { + var attribution = LongMemEvalPostRunDiagnostics.Attribute( + Result(), + retry: null, + oracle: Oracle(correct: true), + evidence: Evidence(goldSessionRecall: 0, goldTurnHit: false)); + + attribution.Should().Be("retrieval-miss"); + } + + [Fact] + public void Attribute_ReportsAnswerSynthesisWhenGoldEvidenceReachedPrompt() + { + var attribution = LongMemEvalPostRunDiagnostics.Attribute( + Result(), + retry: null, + oracle: Oracle(correct: true), + evidence: Evidence(goldSessionRecall: 1, goldTurnHit: true)); + + attribution.Should().Be("answer-synthesis-failure"); + } + + private static QuestionResult Result(string judgeExplanation = "Judge said: no") => new() + { + QuestionId = "q-1", + QuestionType = "temporal-reasoning", + Question = "How long was the trip?", + GoldAnswer = "two weeks", + AgentResponse = "I do not know", + Correct = false, + RawScore = 0, + JudgeExplanation = judgeExplanation, + Duration = TimeSpan.FromSeconds(1) + }; + + private static LongMemEvalOracleResult Oracle(bool correct) => new( + "q-1", "completed", "two weeks", true, correct, correct ? 100 : 0, 2); + + private static LongMemEvalRetrievalEvidence Evidence( + double goldSessionRecall, + bool goldTurnHit) => new( + K: 30, + AnswerPromptCharacters: 10_000, + EstimatedAnswerPromptTokens: 2_500, + DistinctSourceSessions: 10, + MaxItemsFromSingleSession: 4, + GoldSessionsRequired: 1, + GoldSessionsHit: goldSessionRecall == 1 ? 1 : 0, + GoldSessionRecallAtK: goldSessionRecall, + AnnotatedGoldTurns: 1, + GoldTurnsHit: goldTurnHit ? 1 : 0, + GoldTurnHitAtK: goldTurnHit, + FirstGoldSessionRank: goldSessionRecall == 1 ? 5 : null, + FirstGoldTurnRank: goldTurnHit ? 5 : null, + ReciprocalRank: goldSessionRecall == 1 ? 0.2 : null, + RankedItems: []); +} \ No newline at end of file diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPredicateDistributionTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPredicateDistributionTests.cs new file mode 100644 index 00000000..c97d4084 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPredicateDistributionTests.cs @@ -0,0 +1,94 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// J1.2. The observed predicate distribution is the objective anchor for the vocabulary's +/// "completeness" axis, and the held-out slice is what proves the vocabulary generalises rather than +/// fitting the predicates we happened to look at. Both properties depend on the split being +/// deterministic and total, so the split is pure and tested here rather than buried in a query. +/// +public sealed class LongMemEvalPredicateDistributionTests +{ + private static readonly IReadOnlyList Observed = + [ + new("bought", 42, 7), new("was_born", 31, 5), new("likes", 28, 6), + new("visited", 20, 4), new("completed", 17, 4), new("owns", 15, 3), + new("sold", 11, 3), new("fixed", 9, 2), new("attended", 8, 2), + new("married", 6, 2), new("moved_to", 5, 2), new("planned", 4, 1), + new("rated", 3, 1), new("borrowed", 2, 1), new("lent", 1, 1) + ]; + + [Fact] + public void TheSplitIsTotalAndDisjoint() + { + // Every observed predicate must land in exactly one slice, or coverage arithmetic is wrong. + var split = LongMemEvalPredicateDistribution.Split(Observed, heldOutFraction: 0.2, seed: 42); + + split.Build.Concat(split.HeldOut).Select(item => item.Predicate) + .Should().BeEquivalentTo(Observed.Select(item => item.Predicate)); + split.Build.Select(item => item.Predicate).Intersect( + split.HeldOut.Select(item => item.Predicate)).Should().BeEmpty(); + } + + [Fact] + public void TheSplitIsDeterministicForAGivenSeed() + { + // A split that moved between runs would make held-out coverage unreproducible, which is the + // same defect as an unrepeatable score. + var first = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 42); + var second = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 42); + + second.HeldOut.Select(item => item.Predicate) + .Should().Equal(first.HeldOut.Select(item => item.Predicate)); + } + + [Fact] + public void TheSplitDependsOnTheSeed() + { + var first = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 42); + var second = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 7); + + second.HeldOut.Select(item => item.Predicate) + .Should().NotEqual(first.HeldOut.Select(item => item.Predicate)); + } + + [Fact] + public void TheHeldOutSliceIsNeitherEmptyNorEverything() + { + // An empty held-out slice would silently turn the generalisation gate into a no-op. + var split = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 42); + + split.HeldOut.Should().NotBeEmpty(); + split.Build.Should().NotBeEmpty(); + split.HeldOut.Count.Should().BeLessThan(Observed.Count / 2); + } + + [Fact] + public void FrequencyMassIsReportedForBothSlices() + { + // A split by predicate can put a rare or a dominant relation in the held-out slice. Reporting + // the mass makes that visible instead of letting it silently distort the coverage number. + var split = LongMemEvalPredicateDistribution.Split(Observed, 0.2, seed: 42); + + (split.BuildFactCount + split.HeldOutFactCount).Should() + .Be(Observed.Sum(item => item.FactCount)); + split.HeldOutFactCount.Should().Be(split.HeldOut.Sum(item => item.FactCount)); + } + + [Fact] + public void ConsolidationIsReportedAsRawVersusCanonical() + { + // The vocabulary's whole claim is that many surface predicates collapse to few canonical ones. + var summary = new LongMemEvalPredicateDistributionSummary( + RawPredicateCount: 421, + CanonicalPredicateCount: 97, + TotalFactCount: 700, + OwnerCount: 10, + Predicates: Observed); + + summary.ConsolidationRatio.Should().BeApproximately(421d / 97d, 0.001); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationManifestTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationManifestTests.cs new file mode 100644 index 00000000..9b7d3c45 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationManifestTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparationManifestTests +{ + [Fact] + public void Create_IsDeterministicAndContentFree() + { + var first = Manifest(); + var second = Manifest(); + + first.Fingerprint.Should().Be(second.Fingerprint); + var json = JsonSerializer.Serialize( + first, + LongMemEvalPreparationManifest.JsonOptions); + json.Should().NotContain("secret question text"); + json.Should().NotContain("secret gold answer"); + json.Should().NotContain("secret model answer"); + json.Should().NotContain("secret recalled content"); + json.Should().NotContain("credential"); + json.Should().NotContain("endpoint"); + } + + [Fact] + public void VerifyIntegrity_RejectsChangedBudget() + { + var tampered = Manifest() with { MaxRelevantMessages = 31 }; + + var act = tampered.VerifyIntegrity; + + act.Should().Throw() + .WithMessage("*fingerprint*"); + } + + [Theory] + [InlineData("dataset")] + [InlineData("model")] + [InlineData("budget")] + [InlineData("response-contract")] + [InlineData("response-format")] + [InlineData("unified")] + [InlineData("multi-session")] + [InlineData("workers")] + [InlineData("sessions-per-batch")] + [InlineData("input-tokens")] + public void PreparedState_RejectsChangedConfiguration(string field) + { + var manifest = Manifest(); + var expected = Expectation(); + expected = field switch + { + "dataset" => expected with { DatasetSha256 = "different-dataset" }, + "model" => expected with { ExtractionModelId = "different-model" }, + "budget" => expected with { MaxRelevantMessages = 31 }, + "response-contract" => expected with { ExtractionResponseContract = "different-contract" }, + "response-format" => expected with { UseJsonResponseFormat = false }, + "unified" => expected with { UseUnifiedExtraction = false }, + "multi-session" => expected with { UseMultiSessionBatchExtraction = false }, + "workers" => expected with { PreparationWorkers = 9 }, + "sessions-per-batch" => expected with { MaxSessionsPerBatch = 3 }, + "input-tokens" => expected with { MaxInputTokens = 99_999 }, + _ => throw new ArgumentOutOfRangeException(nameof(field)) + }; + + var act = () => new LongMemEvalPreparedState( + manifest, + "prepared-run", + expected); + + act.Should().Throw() + .WithMessage("*configuration*"); + } + + [Fact] + public void PreparedState_AcceptsExactConfiguration() + { + var act = () => new LongMemEvalPreparedState( + Manifest(), + "prepared-run", + Expectation()); + + act.Should().NotThrow(); + } + + private static LongMemEvalPreparationManifest Manifest() => + LongMemEvalPreparationManifest.Create( + "preparation-1", + "dataset-sha256", + "agenteval-revision", + "prepared-run", + "answer-model", + "judge-model", + "extraction-model", + "embedding-model", + 1536, + 30, + "metadata-only-not-in-extraction-prompt", + [ + new LongMemEvalPreparedQuestion( + 1, + "q-1", + "history-sha256", + LongMemEvalPreparationManifest.Hash( + "prepared-run-session-0001|prepared-run-owner-0001"), + 614, + 52, + 52, + new LongMemEvalGraphSnapshot(2, 3, 4, 1, 9, 9, 20, 6, 1)) + ], + 208, + useJsonResponseFormat: true, + useUnifiedExtraction: true, + useMultiSessionBatchExtraction: true, + preparationWorkers: 10, + maxSessionsPerBatch: 4, + maxInputTokens: 100_000); + + private static LongMemEvalPreparationExpectation Expectation() => + LongMemEvalPreparationFingerprint.Expect( + "dataset-sha256", + "agenteval-revision", + "answer-model", + "judge-model", + "extraction-model", + "embedding-model", + 1536, + 30, + useJsonResponseFormat: true, + useUnifiedExtraction: true, + useMultiSessionBatchExtraction: true, + preparationWorkers: 10, + maxSessionsPerBatch: 4, + maxInputTokens: 100_000); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationWatchdogTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationWatchdogTests.cs new file mode 100644 index 00000000..7778641f --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparationWatchdogTests.cs @@ -0,0 +1,79 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparationWatchdogTests +{ + [Fact] + public async Task RunAsync_CompletesWhenExpectedProviderProgressFinishes() + { + var provider = Substitute.For(); + provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, "{}"))); + using var meter = new LongMemEvalChatCallMeter(provider); + + var result = await LongMemEvalPreparationWatchdog.RunAsync( + async cancellationToken => + { + _ = await meter.GetResponseAsync( + [new ChatMessage(ChatRole.System, "bounded test")], + cancellationToken: cancellationToken); + return 42; + }, + meter, + expectedProviderCalls: 1, + overallTimeout: TimeSpan.FromSeconds(1), + noProviderProgressTimeout: TimeSpan.FromMilliseconds(100), + phase: "test", + output: TextWriter.Null); + + result.Should().Be(42); + meter.Snapshot().CompletedCalls.Should().Be(1); + } + + [Fact] + public async Task RunAsync_NoProviderProgressFailsWithBoundedDiagnostics() + { + var provider = Substitute.For(); + using var meter = new LongMemEvalChatCallMeter(provider); + + var act = () => LongMemEvalPreparationWatchdog.RunAsync( + async cancellationToken => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + }, + meter, + expectedProviderCalls: 1, + overallTimeout: TimeSpan.FromSeconds(1), + noProviderProgressTimeout: TimeSpan.FromMilliseconds(50), + phase: "test", + output: TextWriter.Null); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("no-provider-progress"); + exception.Which.Message.Should().Contain("started/completed=0/0"); + exception.Which.Message.Should().Contain("first_failure_type=none"); + exception.Which.Message.Should().NotContain("bounded test"); + } + + [Fact] + public async Task RunAsync_RejectsNoProgressWindowBeyondOverallTimeout() + { + using var meter = new LongMemEvalChatCallMeter(Substitute.For()); + var act = () => LongMemEvalPreparationWatchdog.RunAsync( + _ => Task.FromResult(0), meter, 1, + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20), + "test", TextWriter.Null); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedAdapterFailureTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedAdapterFailureTests.cs new file mode 100644 index 00000000..8bd6b36b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedAdapterFailureTests.cs @@ -0,0 +1,140 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparedAdapterFailureTests +{ + [Fact] + public async Task ManifestHistoryMismatchFailsBeforeAnyMemoryCall() + { + var fixture = Fixture( + historySha256: "deliberately-wrong-history-fingerprint", + probeSnapshot: Snapshot()); + + var act = () => fixture.Adapter.InvokeAsync(fixture.Prompt); + + await act.Should().ThrowAsync() + .WithMessage("*sealed manifest*"); + fixture.Adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Status.Should().Be("prepared-manifest-mismatch"); + await AssertNoMemoryCalls(fixture.Memory); + } + + [Fact] + public async Task GraphMutationFailsBeforeRecall() + { + var fixture = Fixture( + historySha256: null, + probeSnapshot: Snapshot() with { Facts = 2 }); + + var act = () => fixture.Adapter.InvokeAsync(fixture.Prompt); + + await act.Should().ThrowAsync() + .WithMessage("*graph state*sealed snapshot*"); + fixture.Adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Status.Should().Be("prepared-graph-mismatch"); + await AssertNoMemoryCalls(fixture.Memory); + } + + private static PreparedFixture Fixture( + string? historySha256, + LongMemEvalGraphSnapshot probeSnapshot) + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = AgentEval.Memory.External.LongMemEval.LongMemEvalHistoryFormatter + .Format(entry, benchmarkOptions); + var prompt = LongMemEvalEvidenceIndexTests.InvocationPrompt(entry); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var evidenceQuestion = evidenceIndex.GetByQuestionId(entry.QuestionId); + var sourceSessions = evidenceQuestion.Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Distinct() + .Count(); + var manifest = LongMemEvalPreparationManifest.Create( + "prepared-failure-test", + "dataset-sha256", + "agenteval-revision", + "prepared-run", + "answer-model", + "judge-model", + "extraction-model", + "embedding-model", + 1536, + 30, + "metadata-only-not-in-extraction-prompt", + [ + new LongMemEvalPreparedQuestion( + 1, + evidenceQuestion.QuestionId, + historySha256 ?? LongMemEvalEvidenceIndex.Fingerprint(history), + LongMemEvalPreparationManifest.Hash( + "prepared-run-session-0001|prepared-run-owner-0001"), + evidenceQuestion.Messages.Count(m => + !m.IsSyntheticBoundary && !m.IsSyntheticFormatterPadding), + sourceSessions, + sourceSessions, + Snapshot()) + ], + sourceSessions * 4); + var memory = Substitute.For(); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + Substitute.For(), + "prepared-run", + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + PreparedMemory = true, + PreparedState = new LongMemEvalPreparedState(manifest, "prepared-run"), + MaxRelevantMessages = 30, + ModelId = "answer-model", + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + RequireGraphReadBack = true, + GraphProbe = new Probe(probeSnapshot) + }); + adapter.ResetSessionAsync().GetAwaiter().GetResult(); + adapter.InjectConversationHistory(history); + return new PreparedFixture(adapter, memory, prompt); + } + + private static async Task AssertNoMemoryCalls(IMemoryService memory) + { + await memory.DidNotReceive().AddMessagesAsync( + Arg.Any>(), + Arg.Any()); + await memory.DidNotReceive().ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()); + await memory.DidNotReceive().RecallAsync( + Arg.Any(), + Arg.Any()); + } + + private static LongMemEvalGraphSnapshot Snapshot() => + new(1, 1, 1, 1, 1, 3, 3, 6, 2); + + private sealed class Probe(LongMemEvalGraphSnapshot snapshot) : ILongMemEvalGraphProbe + { + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } + + private sealed record PreparedFixture( + AgentMemoryLongMemEvalAdapter Adapter, + IMemoryService Memory, + string Prompt); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBehaviorTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBehaviorTests.cs new file mode 100644 index 00000000..db3d5da7 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBehaviorTests.cs @@ -0,0 +1,442 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparedBatchBehaviorTests +{ + [Fact] + public void CheckpointSelection_PicksHighestTokenQuestionsWithStableTies() + { + var plans = new[] + { + Plan(100), + Plan(300), + Plan(300), + Plan(200) + }; + + var selected = LongMemEvalPreparedBatchExecutor + .SelectCheckpointQuestionIndexes(plans, 2); + + selected.Should().Equal(1, 2); + } + + [Fact] + public void CheckpointProjection_UsesWorstScaleAndSafetyMargin() + { + var projected = LongMemEvalPreparedBatchExecutor + .ProjectFullPreparationMilliseconds( + fullCalls: 12, + fullSourceSessions: 48, + fullEstimatedInputTokens: 1_200, + checkpointCalls: 3, + checkpointSourceSessions: 12, + checkpointEstimatedInputTokens: 300, + checkpointWallMilliseconds: 10_000, + profileStartupMilliseconds: 2_000); + + projected.Should().Be(52_000); + } + + private static MultiSessionExtractionPlan Plan(int tokens) => + new( + [ + new MultiSessionExtractionBatchPlan([Guid.NewGuid().ToString("N")], tokens) + ]); + + [Fact] + public async Task BatchedPreparation_UsesOneUnifiedCallForThreeSourceSessions() + { + var harness = CreateHarness(extraProviderCalls: 0); + + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + await harness.Adapter.InvokeAsync(harness.Question.InvocationPrompt); + + harness.Pipeline.BatchInvocations.Should().Be(1); + harness.Pipeline.LastRequests.Should().HaveCount(3); + await harness.Memory.DidNotReceive().ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()); + harness.Meter.Snapshot().Calls.Should().Be(1); + harness.Adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Should().Match(item => + item.Status == "prepared" && + item.ExtractionUnits == 3 && + item.ExtractionCallsPlanned == 1 && + item.GraphReadBack != null && + item.GraphReadBack.CompleteProvenance); + } + + [Fact] + public async Task BatchedPreparation_FailsClosedOnAnUnplannedProviderCall() + { + var harness = CreateHarness(extraProviderCalls: 1); + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + + var act = () => harness.Adapter.InvokeAsync(harness.Question.InvocationPrompt); + + // Still fails closed on an unexplained extra call. The guard was refined from "exactly the + // planned calls and zero failures" to "no unaccounted work" -- excess is now acceptable only + // when a split or retry was recorded, and this harness records neither. Only the wording + // moved; the behaviour this test pins did not. + await act.Should().ThrowAsync() + .WithMessage("*observed 2 calls*excess=1*"); + harness.Adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Status.Should().Be("extraction-provider-accounting-error"); + } + [Fact] + public async Task BatchedPreparation_RejectsMissingSessionAcknowledgement() + { + var harness = CreateHarness(extraProviderCalls: 0); + harness.Pipeline.DropLastResult = true; + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + + var act = () => harness.Adapter.InvokeAsync(harness.Question.InvocationPrompt); + + await act.Should().ThrowAsync() + .WithMessage("*did not persist every planned source session*"); + } + + [Fact] + public async Task BatchedPreparation_RejectsOwnerOrderDeviation() + { + var harness = CreateHarness(extraProviderCalls: 0); + harness.Pipeline.ReverseResults = true; + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + + var act = () => harness.Adapter.InvokeAsync(harness.Question.InvocationPrompt); + + await act.Should().ThrowAsync() + .WithMessage("*did not persist every planned source session*"); + } + + [Fact] + public async Task BatchedPreparation_PropagatesProviderFailureAndRecordsIt() + { + var harness = CreateHarness(extraProviderCalls: 0, providerFailure: true); + await harness.Adapter.ResetSessionAsync(); + harness.Adapter.InjectConversationHistory(harness.History); + + var act = () => harness.Adapter.InvokeAsync(harness.Question.InvocationPrompt); + + await act.Should().ThrowAsync(); + harness.Meter.Snapshot().Failures.Should().Be(1); + harness.Adapter.QuestionTelemetry.Should().ContainSingle() + .Which.Status.Should().Be("extraction-error"); + } + + + [Fact] + public async Task ScopedMeter_SeparatesConcurrentUnifiedBatchQuestions() + { + var provider = SuccessfulProvider(); + using var meter = new LongMemEvalChatCallMeter(provider); + + await Task.WhenAll(Enumerable.Range(1, 4).Select(async question => + { + using (meter.BeginScope($"question-{question}")) + { + await UnifiedBatchCallAsync(meter); + } + })); + + meter.Snapshot().Calls.Should().Be(4); + foreach (var question in Enumerable.Range(1, 4)) + { + var scope = meter.SnapshotScope($"question-{question}"); + scope.Calls.Should().Be(1); + scope.Failures.Should().Be(0); + scope.Purposes.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair("unified_batch", 1)); + } + } + + [Fact] + public async Task Meter_RecordsMaximumConcurrentProviderCalls() + { + const int expectedConcurrency = 4; + var provider = Substitute.For(); + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var entered = 0; + provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(async _ => + { + if (Interlocked.Increment(ref entered) == expectedConcurrency) + release.TrySetResult(); + await release.Task; + return new ChatResponse( + new ChatMessage(ChatRole.Assistant, "{\"sessions\":[]}")); + }); + using var meter = new LongMemEvalChatCallMeter(provider); + + await Task.WhenAll( + Enumerable.Range(0, expectedConcurrency) + .Select(_ => UnifiedBatchCallAsync(meter))); + + var snapshot = meter.Snapshot(); + snapshot.Calls.Should().Be(expectedConcurrency); + snapshot.CompletedCalls.Should().Be(expectedConcurrency); + snapshot.Failures.Should().Be(0); + snapshot.RetryCalls.Should().Be(0); + snapshot.MaximumConcurrency.Should().Be(expectedConcurrency); + snapshot.CallDetails.Should().HaveCount(expectedConcurrency); + snapshot.CallDetails.Should().OnlyContain(detail => + detail.EstimatedInputTokens > 0 && detail.DurationMilliseconds >= 0); + } + + [Fact] + public async Task Meter_AttributesOnlyTheExactParseRetryInstruction() + { + using var meter = new LongMemEvalChatCallMeter(SuccessfulProvider()); + await meter.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, "You extract structured long-term memory from multiple independent source sessions."), + new ChatMessage(ChatRole.User, "extract"), + new ChatMessage(ChatRole.Assistant, "not-json"), + new ChatMessage(ChatRole.User, "That response was not valid JSON. Reply with ONLY the JSON object — no markdown fences, no prose.") + ]); + + var snapshot = meter.Snapshot(); + snapshot.Calls.Should().Be(1); + snapshot.CompletedCalls.Should().Be(1); + snapshot.RetryCalls.Should().Be(1); + snapshot.CallDetails.Should().ContainSingle() + .Which.Retry.Should().BeTrue(); + } + + + private static Harness CreateHarness( + int extraProviderCalls, bool providerFailure = false) + { + const string runId = "batched-preparation"; + var entry = ThreeSessionEntry(); + var benchmarkOptions = LongMemEvalEvidenceIndexTests.Options(); + var history = LongMemEvalHistoryFormatter.Format(entry, benchmarkOptions); + var evidenceIndex = LongMemEvalEvidenceIndex.Create([entry], benchmarkOptions); + var question = evidenceIndex.Questions.Single(); + var provider = SuccessfulProvider(providerFailure); + var meter = new LongMemEvalChatCallMeter(provider); + var planner = new DeterministicPlanner(); + var pipeline = new RecordingBatchPipeline(meter, planner, extraProviderCalls); + var memory = Substitute.For(); + memory.AddMessagesAsync( + Arg.Any>(), + Arg.Any()) + .Returns(call => call.Arg>().ToArray()); + memory.ExtractAndPersistAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException( + new InvalidOperationException( + "Legacy extraction must not run."))); + + var messages = AgentMemoryLongMemEvalAdapter.BuildMessages( + runId, + history, + runId + "-session-0001", + runId + "-owner-0001", + 1, + question, + new Dictionary(StringComparer.Ordinal)); + var requests = AgentMemoryLongMemEvalAdapter.BuildExtractionRequests( + messages, + question, + runId + "-session-0001", + runId + "-owner-0001"); + var plan = planner.Plan(requests, 4, 100_000); + var adapter = new AgentMemoryLongMemEvalAdapter( + memory, + meter, + runId, + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + ModelId = "extraction-model", + EvidenceIndex = evidenceIndex, + EvidenceDetail = LongMemEvalEvidenceDetail.Identifiers, + PreparationOnly = true, + RequireGraphReadBack = true, + GraphProbe = new CompleteGraphProbe(), + UseBatchedPreparation = true, + BatchExtractionPipeline = pipeline, + BatchPlanner = planner, + MaxSessionsPerBatch = 4, + MaxInputTokens = 100_000, + ExpectedExtractionPlan = plan + }); + return new Harness(adapter, memory, meter, pipeline, history, question); + } + + private static IChatClient SuccessfulProvider(bool fail = false) + { + var provider = Substitute.For(); + var call = provider.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + if (fail) + { + call.Returns(Task.FromException( + new HttpRequestException("provider failure"))); + } + else + { + call.Returns(new ChatResponse( + new ChatMessage(ChatRole.Assistant, """{"sessions":[]}"""))); + } + return provider; + } + + private static Task UnifiedBatchCallAsync(IChatClient client) => + client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.System, + "You extract structured long-term memory from multiple independent source sessions. content-free test") + ]); + + private static LongMemEvalEntry ThreeSessionEntry() + { + var entry = LongMemEvalEvidenceIndexTests.Entry(); + entry.HaystackSessionIds = ["session-1", "session-2", "session-3"]; + entry.HaystackDates = + [ + "2024/01/01 (Mon) 10:00", + "2024/01/02 (Tue) 10:00", + "2024/01/03 (Wed) 10:00" + ]; + entry.AnswerSessionIds = ["session-2"]; + entry.HaystackSessions = + [ + Session("session one"), + Session("session two"), + Session("session three") + ]; + return entry; + } + + private static List Session(string label) => + [ + new LongMemEvalTurn + { + Role = "user", + Content = label + " user message", + HasAnswer = label == "session two" + }, + new LongMemEvalTurn + { + Role = "assistant", + Content = label + " assistant message", + HasAnswer = false + } + ]; + + private sealed class DeterministicPlanner : IMultiSessionUnifiedMemoryExtractor + { + public bool IsEnabled => true; + + public MultiSessionExtractionPlan Plan( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens) => + new( + [ + new MultiSessionExtractionBatchPlan( + requests.Select(request => request.SessionId).ToArray(), + 500) + ]); + + public Task> ExtractAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class RecordingBatchPipeline( + LongMemEvalChatCallMeter meter, + DeterministicPlanner planner, + int extraProviderCalls) : IMemoryExtractionPipeline + { + public int BatchInvocations { get; private set; } + public IReadOnlyList LastRequests { get; private set; } = []; + public bool DropLastResult { get; set; } + public bool ReverseResults { get; set; } + + public Task ExtractAsync( + ExtractionRequest request, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Legacy extraction must not run."); + + public async Task> ExtractBatchAsync( + IReadOnlyList requests, + int maxSessionsPerBatch, + int maxInputTokens, + CancellationToken cancellationToken = default) + { + BatchInvocations++; + LastRequests = requests; + var plan = planner.Plan(requests, maxSessionsPerBatch, maxInputTokens); + for (var call = 0; call < plan.BatchCount + extraProviderCalls; call++) + await UnifiedBatchCallAsync(meter); + var results = plan.Batches + .SelectMany(batch => batch.SourceSessionIds) + .Select(sessionId => new ExtractionResult + { + Status = IngestionStatus.Succeeded, + Metadata = new Dictionary + { + ["sessionId"] = sessionId + } + }) + .ToArray(); + if (DropLastResult) + results = results.Take(results.Length - 1).ToArray(); + if (ReverseResults) + results = results.AsEnumerable().Reverse().ToArray(); + return results; + } + } + + private sealed class CompleteGraphProbe : ILongMemEvalGraphProbe + { + public Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) => + Task.FromResult(new LongMemEvalGraphSnapshot( + Entities: 1, + Facts: 1, + Preferences: 1, + Relationships: 1, + RelationshipsWithProvenance: 1, + LearnedItems: 4, + LearnedItemsWithProvenance: 4, + ProvenanceEdges: 4, + SourceMessages: 6)); + } + + private sealed record Harness( + AgentMemoryLongMemEvalAdapter Adapter, + IMemoryService Memory, + LongMemEvalChatCallMeter Meter, + RecordingBatchPipeline Pipeline, + IReadOnlyList<(string UserMessage, string AssistantResponse)> History, + LongMemEvalEvidenceQuestion Question); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBridgeContractTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBridgeContractTests.cs new file mode 100644 index 00000000..801046de --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedBatchBridgeContractTests.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparedBatchBridgeContractTests +{ + [Fact] + public void PreparedBridge_ExposesExplicitExecutionAndAccountingContract() + { + var optionProperties = typeof(LongMemEvalAdapterOptions) + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Select(property => property.Name) + .ToHashSet(StringComparer.Ordinal); + optionProperties.Should().Contain( + [ + "UseBatchedPreparation", + "BatchExtractionPipeline", + "BatchPlanner", + "MaxSessionsPerBatch", + "MaxInputTokens", + "InitialQuestionNumber", + "ExpectedExtractionPlan" + ], "G3A.3 must explicitly consume the accepted deterministic batch path"); + + typeof(LongMemEvalQuestionTelemetry) + .GetProperty("ExtractionCallsPlanned") + .Should().NotBeNull( + "each frozen question must retain its exact preflight provider-call count"); + + var manifestProperties = typeof(LongMemEvalPreparationManifest) + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Select(property => property.Name) + .ToHashSet(StringComparer.Ordinal); + manifestProperties.Should().Contain( + [ + "ExtractionResponseContract", + "UseUnifiedExtraction", + "UseMultiSessionBatchExtraction", + "PreparationWorkers", + "MaxSessionsPerBatch", + "MaxInputTokens", + "MaxConcurrentBatchesPerExtraction", + "MaxConcurrentExtractionBatches" + ], "the prepared artifact fingerprint must identify the execution path"); + var preparedPairOptions = typeof(LongMemEvalPreparedPairProgram) + .GetNestedType( + "PreparedPairOptions", + BindingFlags.NonPublic); + preparedPairOptions.Should().NotBeNull(); + var preparedPairOptionProperties = preparedPairOptions!.GetProperties( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Select(property => property.Name) + .ToHashSet(StringComparer.Ordinal); + preparedPairOptionProperties.Should().Contain( + "PreflightOnly", + "a full live preparation cannot begin before a zero-provider-call frozen-plan gate"); + preparedPairOptionProperties.Should().Contain( + [ + "MaxConcurrentBatchesPerExtraction", + "MaxConcurrentExtractionBatches" + ], + "P1 concurrency must be explicit and fingerprinted by the prepared-pair driver"); + preparedPairOptionProperties.Should().Contain( + [ + "CheckpointTimeoutSeconds", + "ProviderNoProgressTimeoutSeconds" + ], + "the 60-minute/no-progress watchdog policy must be explicit"); + + preparedPairOptionProperties.Should().Contain( + [ + "CheckpointQuestions", + "CheckpointTimeoutSeconds" + ], + "the bounded live checkpoint must be explicit and time-limited"); + + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedMemoryTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedMemoryTests.cs new file mode 100644 index 00000000..1ea2ec6d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedMemoryTests.cs @@ -0,0 +1,18 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparedMemoryTests +{ + [Fact] + public void PreparedEvaluation_RequiresAnExplicitSealedStateAuthority() + { + var preparedState = typeof(LongMemEvalAdapterOptions) + .GetProperty("PreparedState"); + + preparedState.Should().NotBeNull( + "skipping storage and extraction is safe only after a sealed manifest validates the exact prepared state"); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedVolumeLifecycleTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedVolumeLifecycleTests.cs new file mode 100644 index 00000000..f5908478 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalPreparedVolumeLifecycleTests.cs @@ -0,0 +1,48 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalPreparedVolumeLifecycleTests +{ + [Fact] + public void CloneCannotBeginBeforeBaseContainerStops() + { + var lifecycle = new LongMemEvalPreparedVolumeLifecycle(); + lifecycle.BeginBasePreparation(); + + var act = lifecycle.BeginClone; + + act.Should().Throw() + .WithMessage("*BaseMounted*Frozen*"); + } + + [Fact] + public void FrozenBaseCanBeClonedExactlyOnce() + { + var lifecycle = new LongMemEvalPreparedVolumeLifecycle(); + lifecycle.BeginBasePreparation(); + lifecycle.MarkBaseContainerStopped(); + lifecycle.BeginClone(); + lifecycle.CompleteClone(); + + var secondClone = lifecycle.BeginClone; + + lifecycle.State.Should().Be(LongMemEvalPreparedVolumeState.Ready); + secondClone.Should().Throw(); + } + + [Fact] + public void FailedCloneReturnsToFrozenStateForSafeCleanupOrRetry() + { + var lifecycle = new LongMemEvalPreparedVolumeLifecycle(); + lifecycle.BeginBasePreparation(); + lifecycle.MarkBaseContainerStopped(); + lifecycle.BeginClone(); + + lifecycle.FailClone(); + + lifecycle.State.Should().Be(LongMemEvalPreparedVolumeState.Frozen); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReferenceArmTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReferenceArmTests.cs new file mode 100644 index 00000000..bc41b4ce --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReferenceArmTests.cs @@ -0,0 +1,306 @@ +using AgentEval.Memory.External.Models; +using AgentMemory.LongMemEval; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G4-REF. The two reference arms that make every other LongMemEval number interpretable: a +/// no-memory floor and a full-history ceiling. Neither touches AgentMemory, so the guards here are +/// the arm's own exact contract — they are not, and must never become, a relaxation of the +/// AgentMemory validator. +/// +public sealed class LongMemEvalReferenceArmTests +{ + private const string Question = "What degree did I graduate with?"; + + [Fact] + public async Task NoMemoryArmSendsTheQuestionWithoutAnyHistory() + { + var client = new RecordingChatClient(); + var agent = CreateAgent(LongMemEvalReferenceArm.NoMemory, client); + agent.InjectConversationHistory(History(3)); + + _ = await agent.InvokeAsync(Question); + + var prompt = Assert.Single(client.UserPrompts); + Assert.Contains(Question, prompt, StringComparison.Ordinal); + Assert.DoesNotContain("user-turn", prompt, StringComparison.Ordinal); + Assert.DoesNotContain("assistant-turn", prompt, StringComparison.Ordinal); + var telemetry = Assert.Single(agent.QuestionTelemetry); + Assert.Equal(0, telemetry.HistoryMessagesProvided); + Assert.Equal("completed", telemetry.Status); + } + + [Fact] + public void NoMemoryArmDoesNotInstructTheModelThatMemoryWasRetrieved() + { + // The shipped prompt says "using only the retrieved memory below". With no memory block that + // manufactures abstentions and understates the parametric floor, so the floor arm must not + // inherit it. + Assert.DoesNotContain( + "retrieved memory", + LongMemEvalReferenceArm.NoMemory.SystemPrompt(), + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FullHistoryArmSendsEveryRealTurnAndDropsFormatterBoilerplate() + { + var client = new RecordingChatClient(); + var agent = CreateAgent(LongMemEvalReferenceArm.FullHistory, client); + agent.InjectConversationHistory(History(3)); + + _ = await agent.InvokeAsync(Question); + + var prompt = Assert.Single(client.UserPrompts); + Assert.Contains("user-turn-0", prompt, StringComparison.Ordinal); + Assert.Contains("assistant-turn-2", prompt, StringComparison.Ordinal); + Assert.DoesNotContain("SYNTHETIC-BOUNDARY", prompt, StringComparison.Ordinal); + + var telemetry = Assert.Single(agent.QuestionTelemetry); + // 3 turns => 6 injected messages, of which the stub marks 2 synthetic. + Assert.Equal(4, telemetry.HistoryMessagesProvided); + Assert.Equal(2, telemetry.SyntheticMessagesDropped); + Assert.Equal( + 6, + telemetry.HistoryMessagesProvided + telemetry.SyntheticMessagesDropped); + } + + [Fact] + public async Task FullHistoryArmIsNotCappedByAnyItemBudget() + { + // The arm's whole point is that it is the unbounded-context strategy; a cap would silently + // turn it into a different experiment. + var client = new RecordingChatClient(); + var agent = CreateAgent(LongMemEvalReferenceArm.FullHistory, client); + agent.InjectConversationHistory(History(5)); + + _ = await agent.InvokeAsync(Question); + + var telemetry = Assert.Single(agent.QuestionTelemetry); + Assert.Equal(8, telemetry.HistoryMessagesProvided); + Assert.Equal(2, telemetry.SyntheticMessagesDropped); + } + + [Fact] + public async Task FullHistoryArmRecordsAContextWindowRejectionAsASkipRatherThanFailingTheRun() + { + var client = new RecordingChatClient + { + Throw = new Azure.RequestFailedException( + 400, + "This model's maximum context length is 128000 tokens.", + "context_length_exceeded", + innerException: null) + }; + var agent = CreateAgent(LongMemEvalReferenceArm.FullHistory, client); + agent.InjectConversationHistory(History(3)); + + var response = await agent.InvokeAsync(Question); + + var telemetry = Assert.Single(agent.QuestionTelemetry); + Assert.Equal("skipped-context-window", telemetry.Status); + Assert.StartsWith("[REFERENCE-ARM-SKIPPED", response.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task AnyOtherProviderFailureStillFailsTheRun() + { + // A skip is only ever a context-window verdict. Everything else must stay fatal, or the arm + // would quietly convert real outages into "the ceiling was not measurable". + var client = new RecordingChatClient + { + Throw = new Azure.RequestFailedException(429, "Too Many Requests", "rate_limit", null) + }; + var agent = CreateAgent(LongMemEvalReferenceArm.FullHistory, client); + agent.InjectConversationHistory(History(3)); + + await Assert.ThrowsAsync( + () => agent.InvokeAsync(Question)); + } + + [Fact] + public void ArmsCarryDistinctFingerprintsThatCannotCollideWithAMemoryMode() + { + var fingerprints = new[] + { + LongMemEvalReferenceArm.NoMemory.Fingerprint(), + LongMemEvalReferenceArm.FullHistory.Fingerprint(), + LongMemEvalMemoryMode.Raw.Fingerprint(), + LongMemEvalMemoryMode.Structured.Fingerprint(), + LongMemEvalMemoryMode.Hybrid.Fingerprint() + }; + + Assert.Equal(fingerprints.Length, fingerprints.Distinct(StringComparer.Ordinal).Count()); + Assert.StartsWith("reference-", LongMemEvalReferenceArm.NoMemory.Fingerprint(), StringComparison.Ordinal); + Assert.StartsWith("reference-", LongMemEvalReferenceArm.FullHistory.Fingerprint(), StringComparison.Ordinal); + } + + [Fact] + public void ValidatorAcceptsAnExactArmAndReportsFittedAccuracySeparately() + { + var telemetry = new[] + { + Completed(1), + Completed(2), + Skipped(3) + }; + + var validation = LongMemEvalReferenceArmValidator.Validate( + questionCount: 3, + llmCalls: 6, + telemetry: telemetry, + questionResults: [Result("q1", true), Result("q2", false), Result("q3", false)], + answerCalls: Snapshot(calls: 3, failures: 1), + judgeCalls: Snapshot(calls: 3, failures: 0), + diagnosticJudgeCalls: 0); + + Assert.True(validation.Accepted, string.Join(" | ", validation.Issues)); + Assert.Equal(1, validation.SkippedQuestions); + // Fitted accuracy excludes the skip: 1 correct of 2 answerable, not 1 of 3. + Assert.Equal(50d, validation.FittedAccuracyPercent); + } + + [Fact] + public void ValidatorRejectsAProviderFailureThatIsNotAnAccountedSkip() + { + var validation = LongMemEvalReferenceArmValidator.Validate( + questionCount: 2, + llmCalls: 4, + telemetry: [Completed(1), Completed(2)], + questionResults: [Result("q1", true), Result("q2", true)], + answerCalls: Snapshot(calls: 2, failures: 1), + judgeCalls: Snapshot(calls: 2, failures: 0), + diagnosticJudgeCalls: 0); + + Assert.False(validation.Accepted); + Assert.Contains( + validation.Issues, + issue => issue.Contains("failed answer calls", StringComparison.OrdinalIgnoreCase) && + issue.Contains("context window", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ValidatorRejectsAnInexactAnswerCallCount() + { + var validation = LongMemEvalReferenceArmValidator.Validate( + questionCount: 2, + llmCalls: 4, + telemetry: [Completed(1), Completed(2)], + questionResults: [Result("q1", true), Result("q2", true)], + answerCalls: Snapshot(calls: 1, failures: 0), + judgeCalls: Snapshot(calls: 2, failures: 0), + diagnosticJudgeCalls: 0); + + Assert.False(validation.Accepted); + } + + [Fact] + public void EveryQuestionSkippingIsANullResultNotAZeroScore() + { + var validation = LongMemEvalReferenceArmValidator.Validate( + questionCount: 2, + llmCalls: 4, + telemetry: [Skipped(1), Skipped(2)], + questionResults: [Result("q1", false), Result("q2", false)], + answerCalls: Snapshot(calls: 2, failures: 2), + judgeCalls: Snapshot(calls: 2, failures: 0), + diagnosticJudgeCalls: 0); + + Assert.True(validation.Accepted, string.Join(" | ", validation.Issues)); + Assert.Equal(2, validation.SkippedQuestions); + Assert.Null(validation.FittedAccuracyPercent); + } + + private static LongMemEvalReferenceTelemetry Completed(int number) => + new(number, $"q{number}", "completed", 4, 2, 1_000, 250); + + private static LongMemEvalReferenceTelemetry Skipped(int number) => + new(number, $"q{number}", "skipped-context-window", 4, 2, 1_000, 250); + + private static QuestionResult Result(string id, bool correct) => new() + { + QuestionId = id, + QuestionType = "single-session-user", + Question = Question, + GoldAnswer = "Business Administration", + AgentResponse = correct ? "Business Administration" : "I do not know", + Correct = correct, + RawScore = correct ? 100 : 0, + JudgeExplanation = correct ? "Judge said: yes" : "Judge said: no", + Duration = TimeSpan.FromSeconds(1) + }; + + private static LongMemEvalChatCallSnapshot Snapshot(int calls, int failures) => + new(calls, failures, TimeSpan.Zero); + + private static LongMemEvalReferenceAgent CreateAgent( + LongMemEvalReferenceArm arm, + IChatClient client) => + new(client, arm, "reference-run", "test-model", new StubOriginResolver()); + + /// Turn 1 is formatter boilerplate, so 2 of every history's messages are synthetic. + private static IReadOnlyList<(string UserMessage, string AssistantResponse)> History(int turns) => + Enumerable.Range(0, turns) + .Select(index => index == 1 + ? ($"SYNTHETIC-BOUNDARY-{index}", $"SYNTHETIC-BOUNDARY-{index}") + : ($"user-turn-{index}", $"assistant-turn-{index}")) + .ToArray(); + + /// Marks the middle turn's two messages synthetic, mirroring formatter boilerplate. + private sealed class StubOriginResolver : ILongMemEvalReferenceOriginResolver + { + public LongMemEvalReferenceOrigins Resolve( + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string prompt) + { + var contents = history + .SelectMany(turn => new[] { turn.UserMessage, turn.AssistantResponse }) + .ToArray(); + var flags = contents + .Select(content => content.StartsWith("SYNTHETIC", StringComparison.Ordinal)) + .ToArray(); + var timestamps = contents + .Select((_, index) => $"2023/05/{index + 1:D2} (Mon) 10:00") + .ToArray(); + return new LongMemEvalReferenceOrigins( + "stub-question", flags, timestamps, "2023/06/03 (Sat) 15:47"); + } + } + + private sealed class RecordingChatClient : IChatClient + { + private readonly List _userPrompts = []; + + public Exception? Throw { get; init; } + + public IReadOnlyList UserPrompts => _userPrompts; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _userPrompts.Add(string.Join( + "\n", + messages.Where(message => message.Role == ChatRole.User).Select(message => message.Text))); + if (Throw is not null) + throw Throw; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "an answer"))); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReportProjectionTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReportProjectionTests.cs new file mode 100644 index 00000000..20c299b2 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReportProjectionTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using AgentEval.Memory.External.Models; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalReportProjectionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateAcceptedResult_SafeModeRemovesContentAndPreservesMetrics( + bool useIdentifiers) + { + var evidenceDetail = useIdentifiers + ? LongMemEvalEvidenceDetail.Identifiers + : LongMemEvalEvidenceDetail.None; + var result = new ExternalBenchmarkResult + { + BenchmarkId = "benchmark-id", + BenchmarkName = "benchmark-name", + OverallAccuracy = 70, + TaskAveragedAccuracy = 69.44, + PerTypeResults = new Dictionary(), + QuestionResults = + [ + new QuestionResult + { + QuestionId = "q-1", + QuestionType = "multi-session", + Question = "question-sentinel", + GoldAnswer = "gold-sentinel", + AgentResponse = "answer-sentinel", + Correct = true, + RawScore = 100, + JudgeExplanation = "judge-sentinel", + Duration = TimeSpan.FromSeconds(1) + } + ], + Duration = TimeSpan.FromSeconds(2), + TotalLlmCalls = 2, + Options = new ExternalBenchmarkOptions() + }; + + var projection = LongMemEvalReportProjection.CreateAcceptedResult( + result, evidenceDetail); + var json = JsonSerializer.Serialize(projection); + + json.Should().NotContain("question-sentinel") + .And.NotContain("gold-sentinel") + .And.NotContain("answer-sentinel") + .And.NotContain("judge-sentinel"); + json.Should().Contain("\"QuestionId\":\"q-1\"") + .And.Contain("\"OverallAccuracy\":70") + .And.Contain("\"TotalLlmCalls\":2"); + } + + [Fact] + public void CreateAcceptedResult_ContentModeRetainsNativeForensicResult() + { + var result = new ExternalBenchmarkResult + { + BenchmarkId = "benchmark-id", + BenchmarkName = "benchmark-name", + OverallAccuracy = 0, + TaskAveragedAccuracy = 0, + PerTypeResults = new Dictionary(), + QuestionResults = + [ + new QuestionResult + { + QuestionId = "q-1", + QuestionType = "multi-session", + Question = "question-sentinel", + GoldAnswer = "gold-sentinel", + AgentResponse = "answer-sentinel", + Correct = false, + RawScore = 0, + JudgeExplanation = "judge-sentinel", + Duration = TimeSpan.FromSeconds(1) + } + ], + Duration = TimeSpan.FromSeconds(2), + TotalLlmCalls = 2, + Options = new ExternalBenchmarkOptions() + }; + + var projection = LongMemEvalReportProjection.CreateAcceptedResult( + result, LongMemEvalEvidenceDetail.Content); + var json = JsonSerializer.Serialize(projection); + + json.Should().Contain("question-sentinel") + .And.Contain("gold-sentinel") + .And.Contain("answer-sentinel") + .And.Contain("judge-sentinel") + .And.Contain("\"Options\":"); + } + + [Fact] + public void CreatePreparationSection_ReusedRunReportsUnperformedWorkAsNullNotZero() + { + // A run started with --reuse-prepared-volumes performs no preparation, so it has no batch + // execution. This is the exact shape that crashed a live reused run: the report dereferenced + // it through the null-forgiving operator after both evaluation arms had already succeeded. + var section = LongMemEvalReportProjection.CreatePreparationSection( + Manifest(), + batchExecution: null, + Array.Empty(), + new { Calls = 0 }, + extractionCalls: 0, + new LongMemEvalPreparationTimings(1_000, null, 20, 30, 40), + reusedPreparedVolume: "am-lme-retained-base"); + + var json = JsonSerializer.Serialize(section); + + json.Should().Contain("\"performedByThisRun\":false") + .And.Contain("\"reusedPreparedVolume\":\"am-lme-retained-base\""); + // Null, never 0: a zero here would be a fabricated measurement of work never performed, + // and would let a reused run be read as a cold build that happened to be instant. + json.Should().Contain("\"plannedEstimatedInputTokens\":null") + .And.Contain("\"maximumObservedConcurrency\":null") + .And.Contain("\"manifestSealAndReadBackMs\":null"); + json.Should().NotContain("\"plannedEstimatedInputTokens\":0") + .And.NotContain("\"maximumObservedConcurrency\":0"); + } + + [Fact] + public void CreatePreparationSection_ColdRunStillReportsRealMeasuredPreparation() + { + // The reuse fix must not hollow out the cold path it shares. + var section = LongMemEvalReportProjection.CreatePreparationSection( + Manifest(), + new LongMemEvalPreparedBatchExecution( + Array.Empty(), 121, 5_145_407, 9), + Array.Empty(), + new { Calls = 121 }, + extractionCalls: 121, + new LongMemEvalPreparationTimings(1_000, 55.5, 20, 30, 40), + reusedPreparedVolume: null); + + var json = JsonSerializer.Serialize(section); + + json.Should().Contain("\"performedByThisRun\":true") + .And.Contain("\"reusedPreparedVolume\":null") + .And.Contain("\"plannedEstimatedInputTokens\":5145407") + .And.Contain("\"maximumObservedConcurrency\":9") + .And.Contain("\"manifestSealAndReadBackMs\":55.5"); + } + + private static LongMemEvalPreparationManifest Manifest() => + LongMemEvalPreparationManifest.Create( + "preparation-1", + "dataset-sha256", + "agenteval-revision", + "prepared-run", + "answer-model", + "judge-model", + "extraction-model", + "embedding-model", + 1536, + 30, + "metadata-only-not-in-extraction-prompt", + [ + new LongMemEvalPreparedQuestion( + 1, + "q-1", + "history-sha256", + LongMemEvalPreparationManifest.Hash( + "prepared-run-session-0001|prepared-run-owner-0001"), + 614, + 52, + 52, + new LongMemEvalGraphSnapshot(2, 3, 4, 1, 9, 9, 20, 6, 1)) + ], + 208, + useJsonResponseFormat: true, + useUnifiedExtraction: true, + useMultiSessionBatchExtraction: true, + preparationWorkers: 10, + maxSessionsPerBatch: 4, + maxInputTokens: 100_000); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReusedRunIdentityTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReusedRunIdentityTests.cs new file mode 100644 index 00000000..76860b91 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalReusedRunIdentityTests.cs @@ -0,0 +1,47 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// A reused run adopts the sealed preparationId of the build it attaches to, because the +/// per-question scope hashes derive from it. That identity must not also become the run's own +/// identity: the report path is keyed on it, so a reused run would overwrite the accepted report of +/// the very cold build it reused - destroying the evidence that justified reusing it. +/// +public sealed class LongMemEvalReusedRunIdentityTests +{ + private const string PreparationId = "longmemeval-prepared-20260808T124308Z"; + + private static readonly DateTimeOffset Now = + new(2026, 8, 8, 16, 30, 15, TimeSpan.Zero); + + [Fact] + public void AReusedRunGetsItsOwnIdentitySoItCannotOverwriteTheBuildItReused() + { + var runId = LongMemEvalPreparedPairProgram.ResolveRunId(PreparationId, reusing: true, Now); + + runId.Should().NotBe(PreparationId); + // Still traceable back to the build it measured. + runId.Should().StartWith(PreparationId).And.Contain("reuse"); + } + + [Fact] + public void TwoReusedRunsOfTheSameBuildDoNotCollide() + { + var first = LongMemEvalPreparedPairProgram.ResolveRunId(PreparationId, reusing: true, Now); + var second = LongMemEvalPreparedPairProgram.ResolveRunId( + PreparationId, reusing: true, Now.AddSeconds(1)); + + first.Should().NotBe(second); + } + + [Fact] + public void AColdRunKeepsThePreparationIdAsItsRunId() + { + // The existing artifact layout for cold builds is unchanged. + LongMemEvalPreparedPairProgram.ResolveRunId(PreparationId, reusing: false, Now) + .Should().Be(PreparationId); + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRunValidatorTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRunValidatorTests.cs new file mode 100644 index 00000000..2c57c339 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRunValidatorTests.cs @@ -0,0 +1,215 @@ +using AgentEval.Memory.External.Models; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalRunValidatorTests +{ + [Fact] + public void Validate_AcceptsCompleteRunWithoutEmbeddedErrors() + { + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: [new LongMemEvalQuestionTelemetry(1, 20, 10, false)], + questionResults: [Result("q-1")]); + + validation.Accepted.Should().BeTrue(); + validation.Issues.Should().BeEmpty(); + } + + [Fact] + public void Validate_RejectsAgentFailureAndIncompleteCallAccounting() + { + var results = Enumerable.Range(1, 10) + .Select(index => index == 5 + ? Result("q-5", "[ERROR: HTTP 429]", "Skipped due to error: HTTP 429") + : Result($"q-{index}")) + .ToArray(); + var telemetry = Enumerable.Range(1, 9) + .Select(index => new LongMemEvalQuestionTelemetry(index, 20, 10, false)) + .ToArray(); + + var validation = LongMemEvalRunValidator.Validate( + questionCount: 10, + llmCalls: 18, + telemetry, + results); + + validation.Accepted.Should().BeFalse(); + validation.Issues.Should().Contain(issue => issue.Contains("20", StringComparison.Ordinal)); + validation.Issues.Should().Contain(issue => issue.Contains("q-5", StringComparison.Ordinal)); + validation.Issues.Should().Contain(issue => issue.Contains("telemetry", StringComparison.OrdinalIgnoreCase)); + validation.Issues.Should().NotContain(issue => issue.Contains("429", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_RejectsJudgeErrorEvenWhenCallCountIsComplete() + { + var result = Result("q-judge", judgeExplanation: "Judge error: HTTP 400 unsupported temperature"); + + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: [new LongMemEvalQuestionTelemetry(1, 20, 10, false)], + questionResults: [result]); + + validation.Accepted.Should().BeFalse(); + validation.Issues.Should().ContainSingle() + .Which.Should().Contain("q-judge"); + } + + [Fact] + public void Validate_RejectsEmptyJudgeVerdictInsteadOfCountingItIncorrect() + { + var result = Result( + "q-empty-judge", + judgeExplanation: "Judge said: ", + correct: false, + rawScore: 0); + + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: [new LongMemEvalQuestionTelemetry(1, 20, 10, false)], + questionResults: [result]); + + validation.Accepted.Should().BeFalse(); + validation.Issues.Should().ContainSingle() + .Which.Should().Contain("q-empty-judge"); + } + [Fact] + public void Validate_RejectsObservedPurposeAndExtractionCallMismatches() + { + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: [new LongMemEvalQuestionTelemetry(1, 20, 10, false)], + questionResults: [Result("q-meter")], + answerCalls: new LongMemEvalChatCallSnapshot( + Calls: 0, + Failures: 0, + Duration: TimeSpan.Zero), + judgeCalls: new LongMemEvalChatCallSnapshot( + Calls: 1, + Failures: 1, + Duration: TimeSpan.Zero), + extractionCalls: new LongMemEvalChatCallSnapshot( + Calls: 3, + Failures: 0, + Duration: TimeSpan.Zero), + expectedInitialExtractionCalls: 4); + + validation.Accepted.Should().BeFalse(); + validation.Issues.Should().Contain(issue => issue.Contains( + "answer calls", StringComparison.Ordinal)); + validation.Issues.Should().Contain(issue => issue.Contains( + "extraction calls", StringComparison.Ordinal)); + validation.Issues.Should().Contain(issue => issue.Contains( + "failed", StringComparison.Ordinal)); + } + + + [Fact] + public void Validate_AcceptsSealedPreparedRunWithZeroEvaluationWrites() + { + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: + [ + new LongMemEvalQuestionTelemetry(1, 0, 10, false) + { + PreparedMemory = true, + MessagesPrepared = 614, + ExtractionUnitsPrepared = 52, + ExtractionUnits = 0 + } + ], + questionResults: [Result("q-prepared")], + answerCalls: new LongMemEvalChatCallSnapshot( + Calls: 1, + Failures: 0, + Duration: TimeSpan.Zero), + judgeCalls: new LongMemEvalChatCallSnapshot( + Calls: 1, + Failures: 0, + Duration: TimeSpan.Zero), + extractionCalls: LongMemEvalChatCallSnapshot.Zero, + expectedInitialExtractionCalls: 0); + + validation.Accepted.Should().BeTrue(); + validation.Issues.Should().BeEmpty(); + } + + [Fact] + public void Validate_RejectsPreparedRunThatWritesOrExtractsDuringEvaluation() + { + var validation = LongMemEvalRunValidator.Validate( + questionCount: 1, + llmCalls: 2, + telemetry: + [ + new LongMemEvalQuestionTelemetry(1, 1, 10, false) + { + PreparedMemory = true, + MessagesPrepared = 614, + ExtractionUnitsPrepared = 52, + ExtractionUnits = 1 + } + ], + questionResults: [Result("q-mutated-prepared")], + answerCalls: new LongMemEvalChatCallSnapshot( + Calls: 1, + Failures: 0, + Duration: TimeSpan.Zero), + judgeCalls: new LongMemEvalChatCallSnapshot( + Calls: 1, + Failures: 0, + Duration: TimeSpan.Zero), + extractionCalls: new LongMemEvalChatCallSnapshot( + Calls: 4, + Failures: 0, + Duration: TimeSpan.Zero), + expectedInitialExtractionCalls: 0); + + validation.Accepted.Should().BeFalse(); + validation.Issues.Should().Contain(issue => + issue.Contains("prepared", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Classify_ReportsSanitizedAdapterStage() + { + var result = Result("q-stage", "[ERROR: LongMemEval storage stage failed.]"); + LongMemEvalRunValidator.Classify(result).Should().Be("storage-error"); + } + + [Fact] + public void Classify_PrefersDirectAdapterStageOverGenericAgentError() + { + var result = Result("q-stage", "[ERROR: Agent invocation failed.]"); + var telemetry = new LongMemEvalQuestionTelemetry(1, 20, 10, false, "answer-error"); + + LongMemEvalRunValidator.Classify(result, telemetry).Should().Be("answer-error"); + } + private static QuestionResult Result( + string questionId, + string agentResponse = "answer", + string judgeExplanation = "Judge said: yes", + bool correct = true, + double rawScore = 100) => new() + { + QuestionId = questionId, + QuestionType = "multi-session", + Question = "question", + GoldAnswer = "answer", + AgentResponse = agentResponse, + Correct = correct, + RawScore = rawScore, + JudgeExplanation = judgeExplanation, + Duration = TimeSpan.FromSeconds(1) + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRuntimeTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRuntimeTests.cs new file mode 100644 index 00000000..3040474c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalRuntimeTests.cs @@ -0,0 +1,187 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Microsoft.Extensions.AI; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +public sealed class LongMemEvalRuntimeTests +{ + [Fact] + public async Task CreateCompatibleChatClient_NormalizesOnlyTheExactAgentEvalJudgeOptions() + { + var seen = new List<(float? Temperature, int? MaxOutputTokens)>(); + var inner = Substitute.For(); + inner.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + var options = call.Arg(); + seen.Add((options?.Temperature, options?.MaxOutputTokens)); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok")); + }); + var client = LongMemEvalRuntime.CreateCompatibleChatClient(inner); + + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "AgentEval judge")], + new ChatOptions { Temperature = 0, MaxOutputTokens = 30 }); + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "non-judge request")], + new ChatOptions { Temperature = 0.25f, MaxOutputTokens = 30 }); + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "different zero-temperature request")], + new ChatOptions { Temperature = 0, MaxOutputTokens = 128 }); + + seen.Should().Equal( + (null, 512), + (0.25f, 30), + (0f, 128)); + } + + [Fact] + public async Task ChatCallMeter_RecordsCallsFailuresAndElapsedTimeWithoutContent() + { + var invocation = 0; + var inner = Substitute.For(); + inner.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(_ => + { + invocation++; + return invocation == 1 + ? Task.FromResult(new ChatResponse( + new ChatMessage(ChatRole.Assistant, "sensitive response"))) + : Task.FromException( + new InvalidOperationException("sensitive provider failure")); + }); + using var meter = new LongMemEvalChatCallMeter(inner); + + await meter.GetResponseAsync( + [new ChatMessage(ChatRole.User, "sensitive request")]); + Func fail = async () => await meter.GetResponseAsync( + [new ChatMessage(ChatRole.User, "another sensitive request")]); + await fail.Should().ThrowAsync(); + + var snapshot = meter.Snapshot(); + snapshot.Calls.Should().Be(2); + snapshot.Failures.Should().Be(1); + snapshot.Duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero); + snapshot.FailureDetails.Should().ContainSingle().Which.Should().BeEquivalentTo(new + { + CallOrdinal = 2, + Purpose = "other", + ExceptionType = typeof(InvalidOperationException).FullName, + ProviderStatus = (int?)null + }); + snapshot.DroppedFailureDetails.Should().Be(0); + snapshot.ToString().Should().NotContain("sensitive"); + } + + [Fact] + public async Task ChatCallMeter_CapsContentFreeFailureDetails() + { + var inner = Substitute.For(); + inner.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(_ => Task.FromException( + new InvalidOperationException("sensitive provider failure"))); + using var meter = new LongMemEvalChatCallMeter(inner); + + for (var index = 0; index < 33; index++) + { + Func fail = async () => await meter.GetResponseAsync( + [ + new ChatMessage( + ChatRole.System, + "You are an entity extraction assistant. sensitive prompt") + ]); + await fail.Should().ThrowAsync(); + } + + var snapshot = meter.Snapshot(); + snapshot.Calls.Should().Be(33); + snapshot.Failures.Should().Be(33); + snapshot.FailureDetails.Should().HaveCount(32); + snapshot.FailureDetails.Should().OnlyContain(detail => + detail.Purpose == "entity" && + detail.ExceptionType == typeof(InvalidOperationException).FullName && + detail.ProviderStatus == null); + snapshot.DroppedFailureDetails.Should().Be(1); + snapshot.ToString().Should().NotContain("sensitive"); + } + + [Fact] + public async Task ProbeEmbeddingDimensionsAsync_ReturnsRealProviderVectorLength() + { + var generator = new FixedEmbeddingGenerator(1536); + + var dimensions = await LongMemEvalRuntime.ProbeEmbeddingDimensionsAsync(generator); + + dimensions.Should().Be(1536); + generator.Inputs.Should().Equal("AgentMemory LongMemEval embedding dimension probe"); + } + + [Fact] + public async Task ProbeEmbeddingDimensionsAsync_RejectsAnEmptyProviderResponse() + { + var generator = new EmptyEmbeddingGenerator(); + + Func act = async () => await LongMemEvalRuntime.ProbeEmbeddingDimensionsAsync(generator); + + await act.Should().ThrowAsync() + .WithMessage("*embedding*"); + } + + [Fact] + public async Task ExecuteStageAsync_SanitizesProviderFailure() + { + Func act = async () => await LongMemEvalRuntime.ExecuteStageAsync( + "storage", + () => Task.FromException(new InvalidOperationException("provider-secret-detail"))); + + await act.Should().ThrowAsync() + .WithMessage("LongMemEval storage stage failed."); + } + + private sealed class FixedEmbeddingGenerator(int dimensions) + : IEmbeddingGenerator> + { + public List Inputs { get; } = []; + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + Inputs.AddRange(values); + return Task.FromResult>>( + [new Embedding(new float[dimensions])]); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() { } + } + + private sealed class EmptyEmbeddingGenerator + : IEmbeddingGenerator> + { + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new GeneratedEmbeddings>()); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSessionDiversityTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSessionDiversityTests.cs new file mode 100644 index 00000000..be283eea --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSessionDiversityTests.cs @@ -0,0 +1,122 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G3B.3. A per-source-session cap on how much of the answer budget one session may occupy. +/// Measured target: `gpt4_7abb270c` needs six gold sessions and gets five, because two sessions take +/// 14 of 30 slots while the sixth gold session gets none — the item is in the candidate pool, it just +/// never receives a slot. +/// +public sealed class LongMemEvalSessionDiversityTests +{ + [Fact] + public void TheCapFreesSlotsForASessionThatWouldOtherwiseBeCrowdedOut() + { + // Ranked pool: session A monopolises the top, session F sits below the budget line. + var section = Section( + Ranked("a1", "A"), Ranked("a2", "A"), Ranked("a3", "A"), Ranked("a4", "A"), + Ranked("b1", "B"), Ranked("f1", "F")); + + var capped = LongMemEvalRecallBudget.SelectRealSourceTurns( + section, Origins(section), finalCap: 5, maxPerSession: 2); + + // f1 would have been crowded out entirely without the cap; that is the whole point. + Assert.Contains(capped.Items, m => m.MessageId == "f1"); + // A's share shrinks, but not necessarily to the cap: once the capped pass leaves slots + // spare, refill returns skipped items so the budget stays exactly full (M1). + Assert.True( + capped.Items.Count(m => m.MessageId.StartsWith('a')) < 4, + "the cap must reallocate at least one slot away from the monopolising session"); + Assert.Equal(5, capped.Items.Count); + } + + [Fact] + public void TheBudgetIsStillFilledExactlyWhenTheCapWouldUnderfillIt() + { + // Refill is what stops the cap from silently shrinking the context: after the capped pass + // only 2 of 4 slots are taken, so the skipped items come back, uncapped. + var section = Section( + Ranked("a1", "A"), Ranked("a2", "A"), Ranked("a3", "A"), Ranked("a4", "A"), + Ranked("b1", "B")); + + var capped = LongMemEvalRecallBudget.SelectRealSourceTurns( + section, Origins(section), finalCap: 4, maxPerSession: 1); + + Assert.Equal(4, capped.Items.Count); + } + + [Fact] + public void RetrievalOrderIsPreservedAmongTheSurvivors() + { + var section = Section( + Ranked("a1", "A"), Ranked("b1", "B"), Ranked("a2", "A"), Ranked("c1", "C")); + + var capped = LongMemEvalRecallBudget.SelectRealSourceTurns( + section, Origins(section), finalCap: 3, maxPerSession: 1); + + Assert.Equal(["a1", "b1", "c1"], capped.Items.Select(m => m.MessageId).ToArray()); + } + + [Fact] + public void ACapOfZeroLeavesSelectionExactlyAsItWas() + { + // The accepted control must be bit-identical with the feature off. + var section = Section( + Ranked("a1", "A"), Ranked("a2", "A"), Ranked("a3", "A"), Ranked("b1", "B")); + + var uncapped = LongMemEvalRecallBudget.SelectRealSourceTurns( + section, Origins(section), finalCap: 3, maxPerSession: 0); + + Assert.Equal(["a1", "a2", "a3"], uncapped.Items.Select(m => m.MessageId).ToArray()); + } + + [Fact] + public void AnItemWithNoKnownOriginIsNeverCappedAway() + { + // "Keep what we cannot classify" — dropping unknowns would silently shrink the budget. + var section = Section(Ranked("a1", "A"), Ranked("a2", "A"), Ranked("x1", null), Ranked("x2", null)); + + var capped = LongMemEvalRecallBudget.SelectRealSourceTurns( + section, Origins(section), finalCap: 4, maxPerSession: 1); + + Assert.Contains(capped.Items, m => m.MessageId == "x1"); + Assert.Contains(capped.Items, m => m.MessageId == "x2"); + } + + private static (string Id, string? Session) Ranked(string id, string? session) => (id, session); + + private static (string Id, string? Session)[] _lastItems = []; + + private static MemoryContextSection Section(params (string Id, string? Session)[] items) + { + _lastItems = items; + return new MemoryContextSection + { + Items = items.Select(item => new Message + { + MessageId = item.Id, + SessionId = "s", + ConversationId = "s", + Role = "user", + Content = item.Id, + TimestampUtc = DateTimeOffset.UnixEpoch + }).ToArray(), + RankedItems = items.Select((item, index) => new MemoryContextRankedItem( + item.Id, 1.0 - index * 0.01, index + 1, index + 1)).ToArray() + }; + } + + private static IReadOnlyDictionary Origins( + MemoryContextSection section) => + _lastItems + .Where(item => item.Session is not null) + .ToDictionary( + item => item.Id, + item => new LongMemEvalMessageOrigin( + 0, item.Session!, 0, 0, "2023/05/20 (Sat) 10:19", "user", + item.Id, false, false, false), + StringComparer.Ordinal); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticExclusionTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticExclusionTests.cs new file mode 100644 index 00000000..c8ac6d9b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticExclusionTests.cs @@ -0,0 +1,138 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G3B.1. AgentEval's formatter injects per-session boilerplate that our bridge stores as ordinary +/// embedded messages. In the accepted r8 run those artifacts were 240 of 300 final items, and both +/// questions reported as retrieval failures returned 30 of 30 — the budget was consumed before a real +/// source turn could rank. Selection must be able to drop them without a second query. +/// +public sealed class LongMemEvalSyntheticExclusionTests +{ + private const int FinalCap = 30; + + /// Ranks 1-30 are formatter artifacts; ranks 31-60 are real source turns. + private static (MemoryContextSection Section, + Dictionary Origins) Candidates() + { + var items = new List(); + var ranked = new List(); + var origins = new Dictionary(StringComparer.Ordinal); + + for (var index = 0; index < 60; index++) + { + var synthetic = index < 30; + var id = $"m-{index:D2}"; + items.Add(new Message + { + MessageId = id, + SessionId = "evaluation-session", + ConversationId = "evaluation-session", + Role = "user", + Content = synthetic ? "--- Session 2026-01-01 ---" : $"real source turn {index}", + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(index) + }); + ranked.Add(new MemoryContextRankedItem( + id, Score: 1d - index / 100d, RetrievalRank: index + 1, ContextRank: index + 1)); + origins[id] = new LongMemEvalMessageOrigin( + MessageOrdinal: index, + SourceSessionId: $"s-{index}", + SourceSessionOrdinal: index, + SourceTurnOrdinal: synthetic ? null : index, + SourceTimestamp: "2026-01-01T00:00:00Z", + Role: "user", + FormattedContent: items[^1].Content, + IsSyntheticBoundary: synthetic, + IsSyntheticFormatterPadding: false, + HasAnswer: false); + } + + return (new MemoryContextSection { Items = items, RankedItems = ranked }, origins); + } + + /// + /// The control, and the reason this work exists: with the flag off, the unfiltered top-30 is + /// entirely formatter boilerplate and contains no real source turn at all. This is the r8 + /// 30-of-30 observation reproduced deterministically. + /// + [Fact] + public void UnfilteredSelection_ReturnsOnlyFormatterArtifacts() + { + var (section, origins) = Candidates(); + + var unfiltered = section.RankedItems + .OrderBy(item => item.ContextRank) + .Take(FinalCap) + .Select(item => item.ItemId) + .ToArray(); + + unfiltered.Should().OnlyContain(id => origins[id].IsSyntheticBoundary); + unfiltered.Should().NotContain(id => origins[id].SyntheticBoundaryIsFalse(), + "the budget is consumed before a real source turn can rank"); + } + + [Fact] + public void SelectRealSourceTurns_DropsFormatterArtifactsAndFillsTheBudget() + { + var (section, origins) = Candidates(); + + var selected = LongMemEvalRecallBudget.SelectRealSourceTurns(section, origins, FinalCap); + + selected.Items.Should().HaveCount(FinalCap, "the over-fetch must still fill the budget"); + selected.Items.Select(m => m.MessageId) + .Should().OnlyContain(id => origins[id].SyntheticBoundaryIsFalse()); + selected.Items.Select(m => m.MessageId).Should().Equal( + Enumerable.Range(30, 30).Select(index => $"m-{index:D2}"), + "the surviving real turns must keep the provider's retrieval order"); + } + + [Fact] + public void SelectRealSourceTurns_PreservesRetrievalRankAndRenumbersContextRank() + { + var (section, origins) = Candidates(); + + var selected = LongMemEvalRecallBudget.SelectRealSourceTurns(section, origins, FinalCap); + + selected.RankedItems.Select(item => item.ContextRank) + .Should().Equal(Enumerable.Range(1, 30), "context rank is renumbered over survivors"); + selected.RankedItems.Select(item => item.RetrievalRank) + .Should().Equal(Enumerable.Range(31, 30), + "the provider's own rank must survive so the candidate ceiling stays reportable"); + selected.RankedItems.Should().HaveCount(selected.Items.Count); + } + + [Fact] + public void SelectRealSourceTurns_KeepsItemsWithNoKnownOrigin() + { + var (section, origins) = Candidates(); + origins.Remove("m-00"); + + var selected = LongMemEvalRecallBudget.SelectRealSourceTurns(section, origins, FinalCap); + + selected.Items.Select(m => m.MessageId).Should().Contain("m-00", + "dropping what cannot be classified would silently shrink the budget"); + } + + [Fact] + public void SelectRealSourceTurns_WithoutDiagnostics_StillFiltersInItemOrder() + { + var (section, origins) = Candidates(); + var noDiagnostics = section with { RankedItems = [] }; + + var selected = LongMemEvalRecallBudget.SelectRealSourceTurns(noDiagnostics, origins, FinalCap); + + selected.Items.Should().HaveCount(FinalCap); + selected.Items.Select(m => m.MessageId).Should().Equal( + Enumerable.Range(30, 30).Select(index => $"m-{index:D2}")); + } +} + +internal static class SyntheticExclusionTestExtensions +{ + internal static bool SyntheticBoundaryIsFalse(this LongMemEvalMessageOrigin origin) => + !origin.IsSyntheticBoundary && !origin.IsSyntheticFormatterPadding; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticStorageTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticStorageTests.cs new file mode 100644 index 00000000..28fd5fd6 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/LongMemEvalSyntheticStorageTests.cs @@ -0,0 +1,91 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// G3B.9 root fix. AgentEval's LongMemEvalHistoryFormatter cannot express session structure +/// through an injection API that accepts only (user, assistant) pairs, so it fabricates a turn per +/// session boundary: the user "says" --- Session 12 (date) --- and the assistant "replies" +/// Understood. Starting a new conversation session. Persisting those put 21% redundant corpus +/// into memory whose identical content produced identical embeddings — 46 byte-identical +/// copies in one question — which tie on cosine and monopolise top-K together. +/// +public sealed class LongMemEvalSyntheticStorageTests +{ + [Fact] + public void FabricatedSessionBoundaryTurnsAreNotPersisted() + { + var messages = Messages("real-user", "boundary", "ack", "real-assistant"); + var origins = Origins( + ("m0", false, false), + ("m1", true, false), // the fabricated session marker + ("m2", false, true), // its fabricated acknowledgement + ("m3", false, false)); + + var persisted = AgentMemoryLongMemEvalAdapter.SelectPersistableMessages(messages, origins); + + Assert.Equal(["m0", "m3"], persisted.Select(m => m.MessageId).ToArray()); + } + + [Fact] + public void RealConversationIsNeverDropped() + { + // Removing fabrication is the goal; losing a real turn would be far worse than the flooding + // this prevents. + var messages = Messages("a", "b", "c", "d"); + var origins = Origins( + ("m0", false, false), ("m1", false, false), + ("m2", false, false), ("m3", false, false)); + + var persisted = AgentMemoryLongMemEvalAdapter.SelectPersistableMessages(messages, origins); + + Assert.Equal(4, persisted.Count); + } + + [Fact] + public void AnUnclassifiableMessageIsKept() + { + // Dropping what we cannot identify would silently lose evidence. Keep it and let retrieval + // decide, exactly as the retrieval-side filter already does. + var messages = Messages("a", "b"); + var origins = Origins(("m0", false, false)); + + var persisted = AgentMemoryLongMemEvalAdapter.SelectPersistableMessages(messages, origins); + + Assert.Contains(persisted, m => m.MessageId == "m1"); + } + + [Fact] + public void WithNoProvenanceAtAllEverythingIsPersisted() + { + // No evidence index means no way to tell fabrication from conversation, so nothing is removed. + var messages = Messages("a", "b"); + + var persisted = AgentMemoryLongMemEvalAdapter.SelectPersistableMessages( + messages, new Dictionary(StringComparer.Ordinal)); + + Assert.Equal(2, persisted.Count); + } + + private static List Messages(params string[] contents) => + contents.Select((content, index) => new Message + { + MessageId = $"m{index}", + SessionId = "s", + ConversationId = "s", + Role = index % 2 == 0 ? "user" : "assistant", + Content = content, + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(index) + }).ToList(); + + private static Dictionary Origins( + params (string Id, bool Boundary, bool Padding)[] items) => + items.ToDictionary( + item => item.Id, + item => new LongMemEvalMessageOrigin( + 0, "session-1", 0, 0, "2023/05/20 (Sat) 10:19", "user", + item.Id, item.Boundary, item.Padding, false), + StringComparer.Ordinal); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/PredicateCoverageBandTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/PredicateCoverageBandTests.cs new file mode 100644 index 00000000..f21ace97 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/PredicateCoverageBandTests.cs @@ -0,0 +1,101 @@ +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// J1.5 gate 1. The banded held-out coverage statistic, and proof it can fail. +/// +/// +/// The first version of this gate failed at 15.4 points and was root-caused to skew rather than to a +/// real generalisation gap — coverage over a long tail of one-fact predicates is dominated by the +/// tail, so whichever slice draws more singletons looks worse regardless of the vocabulary. +/// +/// A gate refined after it failed is exactly the kind that needs proof it still bites. These tests +/// exist so "the gate passes" is a claim about the vocabulary and not about a statistic that cannot +/// return anything else. +/// +/// +public sealed class PredicateCoverageBandTests +{ + + [Fact] + public void ADeliberateQueryStopFormCountsAsKnownVocabulary() + { + // The mistake the first run of this gate made. `has` and `is` are stop forms: the lexicon + // refuses to RESOLVE them so a question mentioning "is" cannot expand into the whole graph. + // They are still legitimate STORED predicates - 2,701 facts between them in the measured + // graph - and counting them as unknown vocabulary read the gate down to 81.5%. + var bands = LongMemEvalPredicateDistribution.CoverageBands( + [Count("has", 1583), Count("is", 1118), Count("was", 33)]); + + var bound = bands.Single(band => band.Band == "10+"); + bound.Coverage.Should().Be(1d); + bound.Unresolved.Should().BeEmpty(); + } + + [Fact] + public void AnUnknownHighFrequencyPredicateFailsTheBoundBand() + { + // The load-bearing case. If this band could not drop below 100%, the gate would be + // decoration. + var bands = LongMemEvalPredicateDistribution.CoverageBands( + [Count("defenestrated", 40), Count("bought", 30)]); + + var bound = bands.Single(band => band.Band == "10+"); + bound.PredicateCount.Should().Be(2); + bound.ResolvedCount.Should().Be(1); + bound.Coverage.Should().BeApproximately(0.5, 1e-9); + bound.Unresolved.Should().ContainSingle().Which.Should().Be("defenestrated"); + } + + [Fact] + public void AKnownHighFrequencyPredicatePassesTheBoundBand() + { + var bands = LongMemEvalPredicateDistribution.CoverageBands( + [Count("bought", 40), Count("sold", 30)]); + + bands.Single(band => band.Band == "10+").Coverage.Should().Be(1d); + } + + [Fact] + public void TailMissesDoNotTouchTheBoundBand() + { + // The whole point of banding. An unknown singleton is reported, never allowed to drag the + // bound band down — that conflation is what produced the false 15.4-point failure. + var bands = LongMemEvalPredicateDistribution.CoverageBands( + [Count("bought", 40), Count("defenestrated", 1)]); + + bands.Single(band => band.Band == "10+").Coverage.Should().Be(1d); + bands.Single(band => band.Band == "1").Coverage.Should().Be(0d); + bands.Single(band => band.Band == "1").Unresolved.Should().Contain("defenestrated"); + } + + [Fact] + public void AnEmptyBandCountsAsCoveredRatherThanZero() + { + // A band with no members must not read as a failure: "nothing to cover" and "covered + // nothing" are different, and only one of them should stop a release. + var bands = LongMemEvalPredicateDistribution.CoverageBands([Count("bought", 40)]); + + bands.Single(band => band.Band == "2").PredicateCount.Should().Be(0); + bands.Single(band => band.Band == "2").Coverage.Should().Be(1d); + } + + [Fact] + public void EveryPredicateLandsInExactlyOneBand() + { + // Bands must partition. A gap would silently exempt a predicate from the gate; an overlap + // would let one failure be masked by another band's pass. + LongMemEvalPredicateCount[] predicates = + [Count("a", 1), Count("b", 2), Count("c", 3), Count("d", 9), Count("e", 10), Count("f", 99)]; + + LongMemEvalPredicateDistribution.CoverageBands(predicates) + .Sum(band => band.PredicateCount) + .Should().Be(predicates.Length); + } + + private static LongMemEvalPredicateCount Count(string predicate, int facts) => + new(predicate, facts, OwnerCount: 1); +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/RelationCompletenessTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/RelationCompletenessTests.cs new file mode 100644 index 00000000..bf5e5114 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/RelationCompletenessTests.cs @@ -0,0 +1,126 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// L2. Did the context receive EVERY fact under the relation the question names? +/// +/// +/// The metric Phase L exists to build. Message coverage could not answer this — it saturated at 6/7 +/// on 30 of 50 questions — and an accuracy score cannot either, because at sd 9.3 cold-build it +/// cannot see a moderate extraction regression at all. +/// +/// The two numbers do different jobs and must never be collapsed into the ratio alone. +/// D counts the graph and is a deterministic extraction-quality signal: if a change stops +/// learning a needed relation, D drops with no judge involved. N counts the context and is a +/// retrieval signal. So D = 0 with a non-empty key set means "the relation was never +/// extracted", while N < D means "it was extracted and retrieval left some behind" — an +/// extraction bug and a retrieval bug that a single ratio would render identical. +/// +/// +public sealed class RelationCompletenessTests +{ + [Fact] + public void EverythingRetrievedIsComplete() + { + var r = Compute(["serviced"], graph: new() { ["serviced"] = 3 }, retrieved: 3); + + r.Denominator.Should().Be(3); + r.Numerator.Should().Be(3); + r.Ratio.Should().Be(1d); + r.Complete.Should().BeTrue(); + } + + [Fact] + public void RetrievalLeavingSomeBehindIsIncomplete() + { + // The retrieval defect: the relation exists in the graph, the context has only part of it. + var r = Compute(["serviced"], graph: new() { ["serviced"] = 10 }, retrieved: 4); + + r.Ratio.Should().Be(0.4); + r.Complete.Should().BeFalse(); + } + + [Fact] + public void ARelationAbsentFromTheGraphIsNullNotZero() + { + // The load-bearing distinction. D = 0 means the relation was never extracted -- an + // extraction or vocabulary miss. Reporting it as 0.0 completeness would file it as a + // retrieval failure and send the next effort to entirely the wrong place. + var r = Compute(["serviced"], graph: new(), retrieved: 0); + + r.Denominator.Should().Be(0); + r.Ratio.Should().BeNull(); + r.Complete.Should().BeNull(); + r.RelationAbsentFromGraph.Should().BeTrue(); + } + + [Fact] + public void NoResolvedRelationsIsNullThroughout() + { + // Expansion had nothing to expand; there is no completeness question to answer. + var r = Compute([], graph: new() { ["serviced"] = 5 }, retrieved: 0); + + r.Ratio.Should().BeNull(); + r.RelationAbsentFromGraph.Should().BeFalse("there was no relation to be absent"); + } + + [Fact] + public void AnUnmeasuredProbeIsNullRatherThanComplete() + { + // A probe that could not answer must not report completeness it never checked. + var r = AgentMemoryLongMemEvalAdapter.ComputeRelationCompleteness( + ["serviced"], graphCounts: null, retrievedFacts: []); + + r.Ratio.Should().BeNull(); + r.Denominator.Should().BeNull(); + } + + [Fact] + public void TheExpansionLimitBeingBindingIsReportedSeparately() + { + // If the graph holds more facts than the single shared LIMIT can return, completeness is + // arithmetically impossible and that is not a retrieval defect. Reporting it lets the two + // be told apart instead of inferred. + var r = Compute(["serviced"], graph: new() { ["serviced"] = 140 }, retrieved: 100, + expansionLimit: 100); + + r.LimitBinding.Should().BeTrue(); + r.Complete.Should().BeFalse(); + } + + [Fact] + public void FactsUnderOtherRelationsDoNotCountTowardsTheNumerator() + { + // Retrieving plenty of facts is not the same as retrieving the right ones. + var facts = new[] { Fact("serviced"), Fact("bought"), Fact("bought"), Fact("bought") }; + var r = AgentMemoryLongMemEvalAdapter.ComputeRelationCompleteness( + ["serviced"], new Dictionary { ["serviced"] = 2 }, facts); + + r.Numerator.Should().Be(1); + r.Complete.Should().BeFalse(); + } + + private static LongMemEvalRelationCompleteness Compute( + string[] keys, Dictionary graph, int retrieved, int expansionLimit = 100) + { + var facts = Enumerable.Range(0, retrieved) + .Select(i => Fact(keys.Length > 0 ? keys[0] : "other", i)) + .ToArray(); + return AgentMemoryLongMemEvalAdapter.ComputeRelationCompleteness( + keys, graph, facts, expansionLimit); + } + + private static Fact Fact(string predicate, int i = 0) => new() + { + FactId = $"{predicate}-{i}", + Subject = "Alice", + Predicate = predicate, + Object = "bike", + Confidence = 1, + CreatedAtUtc = DateTimeOffset.UnixEpoch, + }; +} diff --git a/tests/AgentMemory.Tests.Unit.LongMemEval/RetrievedGoldCoverageTests.cs b/tests/AgentMemory.Tests.Unit.LongMemEval/RetrievedGoldCoverageTests.cs new file mode 100644 index 00000000..528e1eb1 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit.LongMemEval/RetrievedGoldCoverageTests.cs @@ -0,0 +1,77 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.LongMemEval; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.LongMemEval; + +/// +/// Retrieval-side gold coverage: was the evidence in the context, or only in the graph? +/// +/// +/// The n=50 comparison found Structured losing multi-session questions to Hybrid (53.8% vs 84.6%) +/// while retrieving an indistinguishable volume — 61.8 facts on the losses against 59.0 on the wins. +/// Volume was not the problem, and the existing gold-coverage probe could not say what was, because +/// it measures what was learned and runs only during preparation. +/// +/// This measures what was retrieved, which separates three failures a score cannot: never +/// extracted, extracted but not retrieved, retrieved but misread. +/// +/// +public sealed class RetrievedGoldCoverageTests +{ + [Fact] + public void FullCoverageWhenEveryGoldMessageBackedARetrievedFact() + { + AgentMemoryLongMemEvalAdapter + .RetrievedGoldCoverage([FactFrom("m-1"), FactFrom("m-2")], ["m-1", "m-2"]) + .Should().Be(1d); + } + + [Fact] + public void PartialCoverageIsReportedAsAFraction() + { + AgentMemoryLongMemEvalAdapter + .RetrievedGoldCoverage([FactFrom("m-1")], ["m-1", "m-2", "m-3", "m-4"]) + .Should().Be(0.25); + } + + [Fact] + public void FactsFromOtherMessagesDoNotCount() + { + // The load-bearing case: retrieving plenty of facts is not the same as retrieving the right + // ones, which is precisely the distinction the n=50 telemetry could not draw. + AgentMemoryLongMemEvalAdapter + .RetrievedGoldCoverage([FactFrom("m-9"), FactFrom("m-8"), FactFrom("m-7")], ["m-1"]) + .Should().Be(0d); + } + + [Fact] + public void OneFactCoveringSeveralGoldMessagesCountsForEach() + { + AgentMemoryLongMemEvalAdapter + .RetrievedGoldCoverage([FactFrom("m-1", "m-2")], ["m-1", "m-2"]) + .Should().Be(1d); + } + + [Fact] + public void NoGoldMessagesIsNullRatherThanZero() + { + // Zero coverage of nothing is not a miss. Reporting it as 0.0 would drag any average down + // with questions that never had gold evidence to find. + AgentMemoryLongMemEvalAdapter + .RetrievedGoldCoverage([FactFrom("m-1")], []) + .Should().BeNull(); + } + + private static Fact FactFrom(params string[] sourceMessageIds) => new() + { + FactId = string.Join('-', sourceMessageIds), + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 1, + CreatedAtUtc = DateTimeOffset.UnixEpoch, + SourceMessageIds = sourceMessageIds, + }; +} diff --git a/tests/AgentMemory.Tests.Unit/AgentFramework/AggregationRouteTests.cs b/tests/AgentMemory.Tests.Unit/AgentFramework/AggregationRouteTests.cs new file mode 100644 index 00000000..55dbc709 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/AgentFramework/AggregationRouteTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using AgentMemory.AgentFramework.Recall; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentMemory.Tests.Unit.AgentFramework; + +/// +/// J4.1. A deterministic lexical route for aggregation questions, with no model call. +/// +/// +/// Top-K is a relevance cutoff and carries no completeness guarantee, so "how many" questions are +/// unanswerable from it — miss one of five matching facts and the count is four, with nothing +/// signalling the loss. Relation completeness is the fix, and it is measured: turning it on took +/// Structured from 73.3% to 90.0% in a controlled A/B, and it is what flipped the furniture question. +/// +/// It stays off by default because it widens the context — expansion tripled Structured's context +/// from 649 to 2,027 tokens — so it must be routed to, not enabled globally. The two questions this +/// track has spent the most effort on, 2e6d26dc and gpt4_15e38248, both open with +/// "How many", which is what makes a lexical route sufficient here rather than a model call. +/// +/// +public sealed class AggregationRouteTests +{ + private static readonly HeuristicAutomaticRecallPolicy Policy = new(); + + [Theory] + // The two measured failures this whole track was built around. + [InlineData("How many babies were born to friends and family members in the last few months?")] + [InlineData("How many pieces of furniture did I buy, assemble, sell, or fix this year?")] + // The rest of the G5 routing table. + [InlineData("List all the books I read last year")] + [InlineData("What is the total I spent on the kitchen?")] + [InlineData("Count the trips I took to Lisbon")] + public async Task AggregationQuestionsRequireRelationCompleteness(string question) => + (await DecideAsync(question).ConfigureAwait(true)) + .RequiresRelationCompleteness.Should().BeTrue(); + + [Theory] + // Ordinary recall must not pay the cost. Expansion roughly triples the context. + [InlineData("What did I buy last week?")] + [InlineData("Where do my parents live?")] + [InlineData("Did I like the restaurant in Lisbon?")] + [InlineData("When did I travel to Japan?")] + public async Task OrdinaryQuestionsDoNotRequireIt(string question) => + (await DecideAsync(question).ConfigureAwait(true)) + .RequiresRelationCompleteness.Should().BeFalse(); + + [Fact] + public async Task TheRouteIsDeterministicAndCostsNoModelCall() + { + // The whole argument for a lexical route: it is free, so it can run on every turn. + var first = await DecideAsync("How many books did I read?").ConfigureAwait(true); + var second = await DecideAsync("How many books did I read?").ConfigureAwait(true); + + first.RequiresRelationCompleteness.Should().Be(second.RequiresRelationCompleteness); + } + + [Fact] + public async Task ADecisionThatSkipsRecallNeverRequestsCompleteness() + { + // A greeting must not trigger the most expensive retrieval mode in the system. + var decision = await DecideAsync("hi").ConfigureAwait(true); + + decision.ShouldRecall.Should().BeFalse(); + decision.RequiresRelationCompleteness.Should().BeFalse(); + } + + private static async Task DecideAsync(string question) => + await Policy.DecideAsync( + new AutomaticRecallContext + { + ConversationId = "c", + SessionId = "s", + Messages = [new ChatMessage(ChatRole.User, question)] + }) + .ConfigureAwait(true); +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/BoundedWorkSchedulerTests.cs b/tests/AgentMemory.Tests.Unit/Cli/BoundedWorkSchedulerTests.cs new file mode 100644 index 00000000..8f8f4078 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/BoundedWorkSchedulerTests.cs @@ -0,0 +1,58 @@ +using AgentMemory.Cli.Perf; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class BoundedWorkSchedulerTests +{ + [Theory] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task RunAsync_IsBoundedAndReturnsResultsInInputOrder(int workers) + { + const int count = 10; + var allWorkersAdmitted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var admitted = 0; + + var work = Enumerable.Range(0, count) + .Select(index => (Func>)(async cancellationToken => + { + if (Interlocked.Increment(ref admitted) == workers) + allWorkersAdmitted.TrySetResult(); + await allWorkersAdmitted.Task.WaitAsync(cancellationToken); + return index; + })) + .ToArray(); + + var result = await BoundedWorkScheduler.RunAsync( + work, + workers, + CancellationToken.None); + + result.MaxConcurrency.Should().Be(workers); + result.Results.Should().Equal(Enumerable.Range(0, count)); + } + + [Fact] + public async Task RunAsync_CancellationStopsAdmission() + { + using var cancellation = new CancellationTokenSource(); + var entered = 0; + Func> work = async token => + { + Interlocked.Increment(ref entered); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return 0; + }; + + var running = BoundedWorkScheduler.RunAsync( + Enumerable.Repeat(work, 10).ToArray(), 2, cancellation.Token); + await Task.Delay(20); + await cancellation.CancelAsync(); + + await FluentActions.Awaiting(() => running).Should().ThrowAsync(); + entered.Should().BeLessThanOrEqualTo(2); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/CountingEmbeddingGeneratorTests.cs b/tests/AgentMemory.Tests.Unit/Cli/CountingEmbeddingGeneratorTests.cs new file mode 100644 index 00000000..60151bd3 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/CountingEmbeddingGeneratorTests.cs @@ -0,0 +1,23 @@ +using AgentMemory.Cli.Perf; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class CountingEmbeddingGeneratorTests +{ + [Fact] + public async Task GenerateAsync_RecordsOneProviderSpanAndExactInputs() + { + using var collector = new PerfCollector(); + using var turn = collector.BeginTurn("test", 0, "measure"); + using var sut = new CountingEmbeddingGenerator(new DeterministicEmbeddingGenerator(8)); + + var result = await sut.GenerateAsync(["alpha", "beta"]); + + result.Should().HaveCount(2); + turn.Record.Counter("embed.requests").Should().Be(1); + turn.Record.Counter("embed.items").Should().Be(2); + turn.Record.SpanCounts.GetValueOrDefault("provider.embedding").Should().Be(1); + turn.Record.SpanMilliseconds["provider.embedding"].Should().BeGreaterThanOrEqualTo(0); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/FrozenExtractionOverridesTests.cs b/tests/AgentMemory.Tests.Unit/Cli/FrozenExtractionOverridesTests.cs new file mode 100644 index 00000000..6de1afec --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/FrozenExtractionOverridesTests.cs @@ -0,0 +1,90 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.Cli.Perf; +using FluentAssertions; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class FrozenExtractionOverridesTests +{ + [Fact] + public async Task FrozenMarker_ReturnsExactTypedShape_WithoutCallingDelegates() + { + var entityInner = Substitute.For(); + var factInner = Substitute.For(); + var preferenceInner = Substitute.For(); + var relationshipInner = Substitute.For(); + var messages = FrozenMessages(); + + var entities = await new FrozenExtractionOverrides.FrozenEntityExtractor(entityInner) + .ExtractAsync(messages); + var facts = await new FrozenExtractionOverrides.FrozenFactExtractor(factInner) + .ExtractAsync(messages); + var preferences = await new FrozenExtractionOverrides.FrozenPreferenceExtractor(preferenceInner) + .ExtractAsync(messages); + var relationships = await new FrozenExtractionOverrides.FrozenRelationshipExtractor(relationshipInner) + .ExtractAsync(messages); + + entities.Select(item => (item.Name, item.Type)).Should().Equal( + ("Northstar P0 Labs", "ORGANIZATION"), + ("Rowan Vale", "PERSON")); + facts.Select(item => item.Predicate).Should().Equal("works_at", "leads"); + preferences.Should().ContainSingle() + .Which.PreferenceText.Should().Be("prefers terse status notes"); + relationships.Should().ContainSingle() + .Which.RelationshipType.Should().Be("LAB_P0_WORKS_AT"); + + await entityInner.DidNotReceiveWithAnyArgs() + .ExtractAsync(default!, default); + await factInner.DidNotReceiveWithAnyArgs() + .ExtractAsync(default!, default); + await preferenceInner.DidNotReceiveWithAnyArgs() + .ExtractAsync(default!, default); + await relationshipInner.DidNotReceiveWithAnyArgs() + .ExtractAsync(default!, default); + } + + [Fact] + public async Task NonFrozenInput_DelegatesUnchanged() + { + var expected = new[] + { + new ExtractedEntity { Name = "delegate-result", Type = "TEST" }, + }; + var inner = Substitute.For(); + var messages = new[] + { + new Message + { + MessageId = "ordinary", + ConversationId = "ordinary-conversation", + SessionId = "ordinary-session", + Role = "user", + Content = "ordinary source", + TimestampUtc = DateTimeOffset.UnixEpoch, + }, + }; + inner.ExtractAsync(messages, Arg.Any()) + .Returns(expected); + var sut = new FrozenExtractionOverrides.FrozenEntityExtractor(inner); + + var actual = await sut.ExtractAsync(messages); + + actual.Should().BeSameAs(expected); + await inner.Received(1).ExtractAsync(messages, Arg.Any()); + } + + private static IReadOnlyList FrozenMessages() => + [ + new() + { + MessageId = "p0-source", + ConversationId = "p0-conversation", + SessionId = "p0-session", + Role = "user", + Content = FrozenExtractionOverrides.SourceMarker, + TimestampUtc = DateTimeOffset.UnixEpoch, + }, + ]; +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/Neo4jContainerTelemetryTests.cs b/tests/AgentMemory.Tests.Unit/Cli/Neo4jContainerTelemetryTests.cs new file mode 100644 index 00000000..bbf7839e --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/Neo4jContainerTelemetryTests.cs @@ -0,0 +1,49 @@ +using AgentMemory.Cli.Perf; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class Neo4jContainerTelemetryTests +{ + [Fact] + public void Parser_ReadsDockerStatsJson_AndNormalizesCapacity() + { + const string json = """ + {"BlockIO":"296MB / 346MB","CPUPerc":"152.29%","Container":"probe","ID":"abc","MemPerc":"4.92%","MemUsage":"781.1MiB / 15.51GiB","Name":"probe","NetIO":"1.34kB / 248B","PIDs":"104"} + """; + + Neo4jContainerStatsParser.TryParse(json, 20, out var sample).Should().BeTrue(); + + sample.CpuRawPercent.Should().BeApproximately(152.29, 0.001); + sample.CpuCapacityPercent.Should().BeApproximately(7.6145, 0.001); + sample.MemoryUsedBytes.Should().Be(819_042_713); + sample.MemoryLimitBytes.Should().Be(16_653_735_690); + sample.MemoryPercent.Should().BeApproximately(4.92, 0.001); + sample.BlockReadBytes.Should().Be(296_000_000); + sample.BlockWriteBytes.Should().Be(346_000_000); + sample.ProcessCount.Should().Be(104); + } + + [Theory] + [InlineData("0B", 0)] + [InlineData("1kB", 1_000)] + [InlineData("1MB", 1_000_000)] + [InlineData("1GB", 1_000_000_000)] + [InlineData("1KiB", 1_024)] + [InlineData("1MiB", 1_048_576)] + [InlineData("1GiB", 1_073_741_824)] + public void Parser_ReadsDockerByteUnits(string text, long expected) + { + Neo4jContainerStatsParser.TryParseBytes(text, out var bytes).Should().BeTrue(); + bytes.Should().Be(expected); + } + + [Theory] + [InlineData("")] + [InlineData("not-json")] + [InlineData("{\"CPUPerc\":\"?\"}")] + public void Parser_RejectsIncompleteOrMalformedSamples(string text) + { + Neo4jContainerStatsParser.TryParse(text, 20, out _).Should().BeFalse(); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/PerfConcurrencyAnalysisTests.cs b/tests/AgentMemory.Tests.Unit/Cli/PerfConcurrencyAnalysisTests.cs new file mode 100644 index 00000000..f3933e5f --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/PerfConcurrencyAnalysisTests.cs @@ -0,0 +1,90 @@ +using AgentMemory.Cli.Perf; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class PerfConcurrencyAnalysisTests +{ + [Fact] + public void Percentiles_IncludeP99_UsingTheHarnessInterpolationConvention() + { + var distribution = ConcurrencyAnalysis.Percentiles([1, 2, 3, 4, 100]); + + distribution.P50.Should().Be(3); + distribution.P95.Should().BeApproximately(80.8, 0.000001); + distribution.P99.Should().BeApproximately(96.16, 0.000001); + distribution.Min.Should().Be(1); + distribution.Max.Should().Be(100); + } + + [Fact] + public void Validate_AcceptsExactConcurrentCorrectnessShape() + { + var snapshot = ValidSnapshot(); + + ConcurrencyRunValidator.Validate(snapshot).Should().BeEmpty(); + } + + [Fact] + public void Validate_RejectsEveryReliabilityAndTelemetryViolation() + { + var snapshot = ValidSnapshot() with + { + OperationErrors = 2, + OwnerLeaks = 1, + OwnerMisses = 1, + DedupLiveFacts = 3, + SupersessionLosersPresent = 9, + SupersessionLosersClosed = 8, + SupersessionEdges = 11, + SupersessionWinnersLive = 7, + CrossOwnerEdges = 1, + TransactionEntryEstimateSamples = 0, + }; + + ConcurrencyRunValidator.Validate(snapshot).Should().BeEquivalentTo( + [ + "operation-errors", + "owner-leak", + "owner-miss", + "dedup-live-count", + "supersession-loser-presence", + "supersession-loser-closure", + "supersession-edge-count", + "supersession-winner-live", + "supersession-cross-owner-edge", + "transaction-entry-estimate-missing", + ], options => options.WithStrictOrdering()); + } + + [Fact] + public void Analyze_ReportsExactErrorRateAndAchievedThroughput() + { + var result = ConcurrencyAnalysis.Analyze( + concurrency: 10, + elapsedMilliseconds: 250, + requestMilliseconds: [10, 20, 30, 40], + transactionEntryEstimateMilliseconds: [1, 2, 3], + operationErrors: 1); + + result.Requests.Should().Be(4); + result.ErrorRate.Should().Be(0.25); + result.AchievedOperationsPerSecond.Should().Be(16); + result.RequestMilliseconds.P99.Should().BeApproximately(39.7, 0.000001); + result.TransactionEntryDelayEstimateMilliseconds.P99.Should().BeApproximately(2.98, 0.000001); + } + + private static ConcurrencyCorrectnessSnapshot ValidSnapshot() => + new( + Concurrency: 10, + OperationErrors: 0, + OwnerLeaks: 0, + OwnerMisses: 0, + DedupLiveFacts: 1, + SupersessionLosersPresent: 10, + SupersessionLosersClosed: 10, + SupersessionEdges: 10, + SupersessionWinnersLive: 10, + CrossOwnerEdges: 0, + TransactionEntryEstimateSamples: 30); +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/PerfLatencyPresetTests.cs b/tests/AgentMemory.Tests.Unit/Cli/PerfLatencyPresetTests.cs new file mode 100644 index 00000000..c226b2ab --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/PerfLatencyPresetTests.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using AgentMemory.Cli.Commands; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class PerfLatencyPresetTests +{ + [Fact] + public void ModelRemote_IsolatesModelDelayFromEmbeddingDelay() + { + var method = typeof(PerfCommand).GetMethod( + "ResolveLatency", + BindingFlags.Static | BindingFlags.NonPublic); + + method.Should().NotBeNull(); + var result = ((TimeSpan Embedding, TimeSpan Model))method!.Invoke(null, ["model-remote"])!; + + result.Embedding.Should().Be(TimeSpan.Zero); + result.Model.Should().Be(TimeSpan.FromMilliseconds(900)); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/PerfLedgerTests.cs b/tests/AgentMemory.Tests.Unit/Cli/PerfLedgerTests.cs new file mode 100644 index 00000000..9342dfb2 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/PerfLedgerTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json.Nodes; +using AgentMemory.Cli.Commands; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class PerfLedgerTests +{ + [Fact] + public async Task Add_DerivesEntryFromSummaryAndAssignsContiguousSequence() + { + var root = NewTempDirectory(); + try + { + var ledgerPath = WriteLedger(root); + var original = JsonNode.Parse(await File.ReadAllTextAsync(ledgerPath))!; + var firstRun = WriteRun(root, "candidate-one", 384); + var output = new StringWriter(); + + var firstExit = await new PerfLedgerCommand(output).ExecuteAsync( + firstRun, "0", "improvement", ledgerPath); + var secondRun = WriteRun(root, "candidate-two", 384); + var secondExit = await new PerfLedgerCommand(output).ExecuteAsync( + secondRun, "0", "no-effect", ledgerPath); + + firstExit.Should().Be(0); + secondExit.Should().Be(0); + var updated = JsonNode.Parse(await File.ReadAllTextAsync(ledgerPath))!.AsObject(); + var entries = updated["entries"]!.AsArray(); + entries.Should().HaveCount(3); + JsonNode.DeepEquals(entries[0], original["entries"]![0]).Should().BeTrue(); + + var first = entries[1]!.AsObject(); + first["seq"]!.GetValue().Should().Be(1); + first["label"]!.GetValue().Should().Be("candidate-one"); + first["comparedTo"]!.GetValue().Should().Be(0); + first["verdict"]!.GetValue().Should().Be("improvement"); + first["commit"]!.GetValue().Should().Be("abc123-dirty"); + first["sourceSummarySha256"]!.GetValue().Should().HaveLength(64); + first["counters"]!["PERF-R-04"]!["neo4j.queries"]! + .GetValue().Should().Be(9); + first["fingerprint"]!["embeddingDimensions"]! + .GetValue().Should().Be(384); + entries[2]!["seq"]!.GetValue().Should().Be(2); + output.ToString().Should().Contain("seq 1").And.Contain("seq 2"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Add_RejectsIncomparableFingerprintWithoutChangingLedger() + { + var root = NewTempDirectory(); + try + { + var ledgerPath = WriteLedger(root); + var before = await File.ReadAllBytesAsync(ledgerPath); + var incompatibleRun = WriteRun(root, "wrong-dimensions", 768); + + var act = () => new PerfLedgerCommand(TextWriter.Null).ExecuteAsync( + incompatibleRun, "0", "improvement", ledgerPath); + + await act.Should().ThrowAsync() + .WithMessage("*embedding dimensions*"); + (await File.ReadAllBytesAsync(ledgerPath)).Should().Equal(before); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Add_RejectsDuplicateSourceWithoutChangingLedger() + { + var root = NewTempDirectory(); + try + { + var ledgerPath = WriteLedger(root); + var run = WriteRun(root, "candidate", 384); + var command = new PerfLedgerCommand(TextWriter.Null); + (await command.ExecuteAsync(run, "0", "improvement", ledgerPath)).Should().Be(0); + var beforeDuplicate = await File.ReadAllBytesAsync(ledgerPath); + + var act = () => command.ExecuteAsync(run, "0", "improvement", ledgerPath); + + await act.Should().ThrowAsync() + .WithMessage("*already exists*"); + (await File.ReadAllBytesAsync(ledgerPath)).Should().Equal(beforeDuplicate); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string NewTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"agentmemory-ledger-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static string WriteLedger(string root) + { + var path = Path.Combine(root, "ledger.json"); + File.WriteAllText( + path, + """ + { + "schemaVersion": 1, + "entries": [ + { + "seq": 0, + "label": "baseline", + "fingerprint": { + "profile": "hermetic", + "scale": "S", + "embeddingDimensions": 384, + "embeddingLatencyMs": 0, + "modelLatencyMs": 0, + "neo4jImage": "neo4j:5.26", + "scenarios": [ "PERF-R-04" ] + }, + "counters": { + "PERF-R-04": { + "neo4j.queries": 9 + } + } + } + ] + } + """); + return path; + } + + private static string WriteRun(string root, string label, int dimensions) + { + var run = Path.Combine(root, label); + Directory.CreateDirectory(run); + File.WriteAllText( + Path.Combine(run, "summary.json"), + $$""" + { + "manifest": { + "runId": "run-{{label}}", + "label": "{{label}}", + "startedAtUtc": "2026-07-27T18:00:00Z", + "profile": "hermetic", + "scale": "S", + "scenarios": [ "PERF-R-04" ], + "environment": { + "commit": "abc123-dirty", + "embeddingDimensions": {{dimensions}}, + "embeddingLatencyMs": 0, + "modelLatencyMs": 0, + "neo4jImage": "neo4j:5.26" + } + }, + "qualityGate": { + "tolerance": 0 + }, + "quality": { + "recallAtK": 1, + "mrr": 1, + "casesWithViolations": 0 + }, + "extractionQuality": { + "entityPrecision": 1, + "entityRecall": 1, + "factPrecision": 1, + "factRecall": 1, + "preferencePrecision": 1, + "preferenceRecall": 1, + "falsePositiveRate": 0 + }, + "scenarios": [ + { + "scenario": "PERF-R-04", + "counters": { + "neo4j.queries": { + "min": 9, + "max": 9, + "deterministic": true + }, + "items.retrieved": { + "min": 43, + "max": 43, + "deterministic": true + } + } + } + ] + } + """); + return run; + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/PerfPoolSizeTests.cs b/tests/AgentMemory.Tests.Unit/Cli/PerfPoolSizeTests.cs new file mode 100644 index 00000000..c39f6121 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/PerfPoolSizeTests.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using AgentMemory.Cli.Commands; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Cli; + +/// +/// D1 pool-curve contract: the perf command must resolve an explicit, fingerprinted pool-size +/// override for the integrated cold-build lab only, and must retain the existing 16/100 defaults +/// byte-for-byte when no override is supplied. +/// +public sealed class PerfPoolSizeTests +{ + private static MethodInfo Resolver() + { + var method = typeof(PerfCommand).GetMethod( + "ResolveMaxConnectionPoolSize", + BindingFlags.Static | BindingFlags.NonPublic); + method.Should().NotBeNull( + "the D1 pool curve requires an explicit pool-size resolution seam on PerfCommand"); + return method!; + } + + private static object Invoke(string? value, bool unified, string[] ids) + { + try + { + return Resolver().Invoke(null, [value, unified, ids])!; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + [Fact] + public void NoOverride_RetainsUnifiedLabDefault16() + => Invoke(null, true, ["PERF-W-12-X10"]).Should().Be(16); + + [Fact] + public void NoOverride_RetainsDefaultCatalog100() + => Invoke(null, false, ["PERF-R-04", "PERF-W-02"]).Should().Be(100); + + [Fact] + public void Override_AppliesToIntegratedColdBuildScenariosOnly() + => Invoke("24", true, ["PERF-W-12-X10"]).Should().Be(24); + + [Fact] + public void Override_AppliesAcrossAllIntegratedArms() + => Invoke("32", true, ["PERF-W-12-X01", "PERF-W-12-X05", "PERF-W-12-X10"]).Should().Be(32); + + [Fact] + public void Override_IsRejectedForDefaultCatalogScenarios() + { + var act = () => Invoke("24", false, ["PERF-R-04"]); + act.Should().Throw(); + } + + [Fact] + public void Override_IsRejectedForNonIntegratedUnifiedLabScenarios() + { + var act = () => Invoke("24", true, ["PERF-W-10-C10"]); + act.Should().Throw(); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("abc")] + public void Override_RejectsNonPositiveOrMalformedValues(string value) + { + var act = () => Invoke(value, true, ["PERF-W-12-X10"]); + act.Should().Throw(); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Cli/PerfScenarioCatalogTests.cs b/tests/AgentMemory.Tests.Unit/Cli/PerfScenarioCatalogTests.cs index a8528b7e..6834e777 100644 --- a/tests/AgentMemory.Tests.Unit/Cli/PerfScenarioCatalogTests.cs +++ b/tests/AgentMemory.Tests.Unit/Cli/PerfScenarioCatalogTests.cs @@ -106,4 +106,208 @@ public void Select_WholeSessionExtractionScenario_ReturnsOnlyThatScenario() selected.Should().ContainSingle(); selected[0].Id.Should().Be("PERF-W-05"); } + + [Fact] + public void Catalog_ContainsRawBatchStorageScenario_WithStableContract() + { + var scenario = PerfScenarios.All.Single(item => item.Id == "PERF-W-06"); + + scenario.Description.Should().ContainEquivalentOf("50"); + scenario.Description.Should().ContainEquivalentOf("raw"); + scenario.Description.Should().ContainEquivalentOf("embedding"); + scenario.SupportsInterleavedAb.Should().BeFalse( + "the scenario persists messages and cannot share mutable state between A/B arms"); + scenario.SetupAsync.Should().BeNull( + "the measured operation must include raw message embedding and persistence"); + scenario.VerifyAsync.Should().NotBeNull( + "the scenario must read the stored messages back outside the measured turn"); + } + + [Fact] + public void Select_RawBatchStorageScenario_ReturnsOnlyThatScenario() + { + var selected = PerfScenarios.Select("PERF-W-06"); + + selected.Should().ContainSingle(); + selected[0].Id.Should().Be("PERF-W-06"); + } + + [Fact] + public void Catalog_ContainsExtractionOnlyScenario_WithStableContract() + { + var scenario = PerfScenarios.All.Single(item => item.Id == "PERF-W-07"); + + scenario.Description.Should().ContainEquivalentOf("four"); + scenario.Description.Should().ContainEquivalentOf("extraction"); + scenario.Description.Should().ContainEquivalentOf("persistence"); + scenario.SupportsInterleavedAb.Should().BeTrue( + "the arm invokes pure extractor calls and creates no mutable graph state"); + scenario.SetupAsync.Should().BeNull( + "the fixed source session is in-memory and must not add setup storage"); + scenario.VerifyAsync.Should().BeNull( + "the measured body self-asserts exact results and every excluded dependency"); + } + + [Fact] + public void Select_ExtractionOnlyScenario_ReturnsOnlyThatScenario() + { + var selected = PerfScenarios.Select("PERF-W-07"); + + selected.Should().ContainSingle(); + selected[0].Id.Should().Be("PERF-W-07"); + } + + [Fact] + public void Catalog_ContainsFrozenPersistenceScenario_WithStableContract() + { + var scenario = PerfScenarios.All.Single(item => item.Id == "PERF-W-08"); + + scenario.Description.Should().ContainEquivalentOf("frozen"); + scenario.Description.Should().ContainEquivalentOf("persistence"); + scenario.SupportsInterleavedAb.Should().BeFalse( + "the arm persists learned graph state under a unique owner/session"); + scenario.SetupAsync.Should().NotBeNull( + "the one source message must be stored outside the measured turn"); + scenario.VerifyAsync.Should().NotBeNull( + "learned graph shape and provenance must be read back outside the measured turn"); + } + + [Fact] + public void Select_FrozenPersistenceScenario_ReturnsOnlyThatScenario() + { + var selected = PerfScenarios.Select("PERF-W-08"); + + selected.Should().ContainSingle(); + selected[0].Id.Should().Be("PERF-W-08"); + } + + [Fact] + public void Catalog_ContainsUnifiedExtractionScenario_WithStableContract() + { + var scenario = PerfScenarios.All.Single(item => item.Id == "PERF-W-09"); + + scenario.Description.Should().ContainEquivalentOf("one"); + scenario.Description.Should().ContainEquivalentOf("unified"); + scenario.Description.Should().ContainEquivalentOf("extraction"); + scenario.Description.Should().ContainEquivalentOf("persistence"); + scenario.SupportsInterleavedAb.Should().BeTrue( + "the arm invokes one pure extractor call and creates no mutable graph state"); + scenario.SetupAsync.Should().BeNull( + "the fixed source session is in-memory and must not add setup storage"); + scenario.VerifyAsync.Should().BeNull( + "the measured body self-asserts exact results and every excluded dependency"); + } + + [Fact] + public void Select_UnifiedExtractionScenario_ReturnsOnlyThatScenario() + { + var selected = PerfScenarios.Select("PERF-W-09"); + + selected.Should().ContainSingle(); + selected[0].Id.Should().Be("PERF-W-09"); + } + + [Fact] + public void Catalog_ContainsFullPathColdBuildConcurrencyArms_WithStableContracts() + { + var expected = new[] + { + ("PERF-W-10-C01", 1), + ("PERF-W-10-C05", 5), + ("PERF-W-10-C10", 10), + }; + + foreach (var (id, workers) in expected) + { + var scenario = PerfScenarios.All.Single(item => item.Id == id); + + scenario.Description.Should().ContainEquivalentOf("cold-build"); + scenario.Description.Should().ContainEquivalentOf($"{workers} worker"); + scenario.SupportsInterleavedAb.Should().BeFalse( + "each arm persists ten owner-isolated full-path units"); + scenario.SetupAsync.Should().BeNull(); + scenario.VerifyAsync.Should().NotBeNull( + "graph shape, provenance, and owner isolation must be read back after the measured wave"); + scenario.IncludeInDefaultRun.Should().BeFalse( + "lab-only full-path waves must not alter the committed default scenario catalog"); + scenario.RequiresUnifiedExtraction.Should().BeTrue( + "the full-path lab measures the accepted one-call extraction candidate"); + } + } + + [Fact] + public void Catalog_ContainsTokenBoundedMultiSessionBatchArms_WithStableContracts() + { + var expected = new[] + { + ("PERF-W-11-B01", 1), + ("PERF-W-11-B02", 2), + ("PERF-W-11-B04", 4), + }; + + foreach (var (id, batchSize) in expected) + { + var scenario = PerfScenarios.All.Single(item => item.Id == id); + + scenario.Description.Should().ContainEquivalentOf("multi-session"); + scenario.Description.Should().ContainEquivalentOf($"batch size {batchSize}"); + scenario.SupportsInterleavedAb.Should().BeFalse( + "each arm persists eight owner-isolated source sessions"); + scenario.SetupAsync.Should().BeNull(); + scenario.VerifyAsync.Should().NotBeNull( + "graph shape, per-session provenance, ordering, and isolation must be read back"); + scenario.IncludeInDefaultRun.Should().BeFalse( + "lab-only batching arms must not alter the committed default scenario catalog"); + scenario.RequiresUnifiedExtraction.Should().BeTrue(); + } + } + [Fact] + public void Catalog_ContainsIntegratedColdBuildArms_WithStableContracts() + { + var expected = new[] + { + ("PERF-W-12-X01", 1), + ("PERF-W-12-X05", 5), + ("PERF-W-12-X10", 10), + }; + + foreach (var (id, workers) in expected) + { + var scenario = PerfScenarios.All.Single(item => item.Id == id); + + scenario.Description.Should().ContainEquivalentOf("integrated cold-build"); + scenario.Description.Should().ContainEquivalentOf($"{workers} worker"); + scenario.SupportsInterleavedAb.Should().BeFalse(); + scenario.SetupAsync.Should().BeNull(); + scenario.VerifyAsync.Should().NotBeNull( + "all messages, graph shape, provenance, order, and owner isolation must be read back"); + scenario.IncludeInDefaultRun.Should().BeFalse(); + scenario.RequiresUnifiedExtraction.Should().BeTrue(); + } + } + + [Fact] + public void Catalog_ContainsNeo4jCapacityWidthAndDepthDoublingArms() + { + var expected = new[] + { + "PERF-W-13-W01", "PERF-W-13-W02", "PERF-W-13-W04", "PERF-W-13-W08", + "PERF-W-13-D01", "PERF-W-13-D02", "PERF-W-13-D04", "PERF-W-13-D08", + }; + + foreach (var id in expected) + { + var scenario = PerfScenarios.All.Single(item => item.Id == id); + + scenario.Description.Should().ContainEquivalentOf("Neo4j capacity"); + scenario.Description.Should().MatchRegex("(width|depth)"); + scenario.SupportsInterleavedAb.Should().BeFalse(); + scenario.SetupAsync.Should().BeNull(); + scenario.VerifyAsync.Should().NotBeNull(); + scenario.IncludeInDefaultRun.Should().BeFalse(); + scenario.RequiresUnifiedExtraction.Should().BeTrue(); + } + } + + } diff --git a/tests/AgentMemory.Tests.Unit/Cli/QualityGateTests.cs b/tests/AgentMemory.Tests.Unit/Cli/QualityGateTests.cs index 1bddba50..c645d195 100644 --- a/tests/AgentMemory.Tests.Unit/Cli/QualityGateTests.cs +++ b/tests/AgentMemory.Tests.Unit/Cli/QualityGateTests.cs @@ -91,10 +91,31 @@ public void Evaluate_FixtureCaseCountChanged_Fails( result.Violations.Should().NotBeEmpty(); } + [Fact] + public void Evaluate_RejectsRetrievalBaselineThatClaimsSemanticQuality() + { + var baseline = Baseline(); + baseline = baseline with + { + Retrieval = baseline.Retrieval with + { + Measurement = "semantic-retrieval", + SemanticQualityClaim = true + } + }; + + var act = () => QualityGate.Evaluate(baseline, Retrieval(), Extraction()); + + act.Should().Throw() + .WithMessage("*deterministic-plumbing*"); + } + private static QualityBaseline Baseline() => new( SchemaVersion: 1, Tolerance: 0, Retrieval: new RetrievalQualityBaseline( + Measurement: "deterministic-plumbing", + SemanticQualityClaim: false, RecallAtK: 1, Mrr: 1, Cases: 19, diff --git a/tests/AgentMemory.Tests.Unit/Cli/ScriptedChatClientTests.cs b/tests/AgentMemory.Tests.Unit/Cli/ScriptedChatClientTests.cs new file mode 100644 index 00000000..da32607a --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Cli/ScriptedChatClientTests.cs @@ -0,0 +1,144 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using AgentMemory.Cli.Perf; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace AgentMemory.Tests.Unit.Cli; + +public sealed class ScriptedChatClientTests +{ + [Fact] + public async Task GetResponseAsync_RuleWithSecondMatch_RequiresBothMarkers() + { + using var client = new ScriptedChatClient( + TimeSpan.Zero, + rules: [new ScriptedChatClient.Rule("entity extraction", "matched", "LAB-E0 source")]); + + var response = await client.GetResponseAsync( + [new ChatMessage(ChatRole.System, "entity extraction only")]); + + response.Text.Should().Be(ScriptedChatClient.EmptyPayload); + } + + [Fact] + public async Task GetResponseAsync_RuleWithSecondMatch_SelectsPayloadWhenBothMarkersExist() + { + using var client = new ScriptedChatClient( + TimeSpan.Zero, + rules: [new ScriptedChatClient.Rule("entity extraction", "matched", "LAB-E0 source")]); + + var response = await client.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, "entity extraction only"), + new ChatMessage(ChatRole.User, "LAB-E0 source"), + ]); + + response.Text.Should().Be("matched"); + } + + /// + /// The product sends deterministic short aliases (`s1`…`sN`) inside the batch request under + /// `batch-source-alias-schema-v1`; the key therefore carries no session id. The stand-in must + /// recover identity from each block's own content, exactly as a real model does, and must echo + /// the alias back unchanged. + /// + private static string AliasBatch(string lab, int digits, params int[] units) + => string.Join( + "\n", + units.Select((unit, index) => + $"" + + $"LAB-{lab} source {unit.ToString($"D{digits}")}: Person " + + $"{unit.ToString($"D{digits}")} works at Company " + + $"{unit.ToString($"D{digits}")} and prefers tea.")); + + private static JsonDocument Respond(string prompt) + { + using var client = new ScriptedChatClient( + TimeSpan.Zero, + rules: [new ScriptedChatClient.Rule("never-match", "unused")]); + var response = client.GetResponseAsync([new ChatMessage(ChatRole.User, prompt)]) + .GetAwaiter().GetResult(); + return JsonDocument.Parse(response.Text!); + } + + [Fact] + public void AliasKeyedIntegratedBatch_EchoesAliasesAndDerivesIdentityFromContent() + { + using var document = Respond(AliasBatch("X1", 2, 0, 1, 2, 3)); + var root = document.RootElement; + + root.GetProperty("processed_source_sessions") + .EnumerateArray().Select(value => value.GetString()) + .Should().Equal("s1", "s2", "s3", "s4"); + root.GetProperty("entities").EnumerateArray() + .Select(entity => entity.GetProperty("source_session").GetString()) + .Distinct().Should().BeEquivalentTo(["s1", "s2", "s3", "s4"]); + // IntegratedLabels[0..3] — identity must equal what the pre-alias implementation produced. + root.GetProperty("entities").EnumerateArray() + .Select(entity => entity.GetProperty("name").GetString()!) + .Should().Contain(["Person amber", "Company amber", "Person dahlia", "Company dahlia"]); + root.GetProperty("facts").EnumerateArray() + .Select(fact => fact.GetProperty("subject").GetString()!) + .Should().Equal("Person amber", "Person birch", "Person cobalt", "Person dahlia"); + } + + [Fact] + public void AliasKeyedBatchBatch_UsesUnitDigitsIdentity() + { + using var document = Respond(AliasBatch("B1", 2, 6, 7)); + var root = document.RootElement; + + root.GetProperty("processed_source_sessions") + .EnumerateArray().Select(value => value.GetString()) + .Should().Equal("s1", "s2"); + root.GetProperty("facts").EnumerateArray() + .Select(fact => fact.GetProperty("subject").GetString()!) + .Should().Equal("Person 06", "Person 07"); + } + + [Fact] + public void AliasKeyedBatch_WithoutRecoverableMarker_FailsClosed() + { + var act = () => Respond( + "LAB-X1 source: no ordinal here"); + + act.Should().Throw( + "a stand-in that silently returns an empty payload would read as 'learned nothing'"); + } + + [Fact] + public async Task GetResponseAsync_IntegratedCapacityLabels_AreVectorDistinctAtFixedDimensions() + { + using var client = new ScriptedChatClient( + TimeSpan.Zero, + rules: [new ScriptedChatClient.Rule("never-match", "unused")]); + var sourceSessions = string.Join( + "\n", + Enumerable.Range(0, 320).Select(index => + $"" + + $"LAB-N1 source {index:D3}")); + + var response = await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, sourceSessions)]); + + using var document = JsonDocument.Parse(response.Text!); + var personNames = document.RootElement + .GetProperty("entities") + .EnumerateArray() + .Select(entity => entity.GetProperty("name").GetString()!) + .Where(name => name.StartsWith("Person ", StringComparison.Ordinal)) + .ToArray(); + var vectorKeys = personNames + .Select(name => DeterministicEmbeddingGenerator.Vector(name, 384)) + .Select(vector => Convert.ToHexString(MemoryMarshal.AsBytes(vector.AsSpan()))) + .ToArray(); + var maxFuzzyScore = personNames + .SelectMany((left, index) => personNames.Skip(index + 1) + .Select(right => FuzzySharp.Fuzz.TokenSortRatio(left, right))) + .Max(); + + personNames.Should().HaveCount(320); + vectorKeys.Distinct(StringComparer.Ordinal).Should().HaveCount(320); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/ExtractionPredicateVocabularyPromptTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionPredicateVocabularyPromptTests.cs new file mode 100644 index 00000000..2973bb4e --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionPredicateVocabularyPromptTests.cs @@ -0,0 +1,77 @@ +using AgentMemory.Core.Memory; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Extraction; + +/// +/// G3B.14 wiring. The extractor invents a predicate per sentence because nothing tells it which +/// relations exist: 700 facts under 421 distinct predicates, with one birth expressed five different +/// ways. Offering the established vocabulary in the prompt is the root fix — normalise at generation +/// rather than trying to reconcile phrasings afterwards, which cannot be done safely +/// (bought/sold). +/// +public sealed class ExtractionPredicateVocabularyPromptTests +{ + [Fact] + public void TheSeedVocabularyIsOfferedToTheExtractor() + { + var prompt = LlmMultiSessionUnifiedMemoryExtractor.BuildSystemPrompt( + MemoryPredicateSeedVocabulary.Create()); + + // Asserted in the canonical space form rather than the former `was_born` spelling. The seed is + // now derived from the one shared relation table, whose keys are written in stored + // predicate_key form. This is a deliberate change and not merely a test edit: both spellings + // fold to the identical predicate_key, so what reaches the graph is unchanged, and the natural + // phrase is the better thing to put in front of a language model. + prompt.Should().Contain("was born"); + prompt.Should().Contain("predicate"); + // The invariant the test actually exists for: every offered relation appears in the prompt. + foreach (var relation in MemoryPredicateSeedVocabulary.Create().Snapshot()) + prompt.Should().Contain(relation); + } + + [Fact] + public void ThePromptIsUnchangedWhenNoVocabularyIsOffered() + { + // The frozen plan's token totals depend on prompt size, so an empty vocabulary must not + // silently alter the contract for callers that do not use this. + var withoutVocabulary = LlmMultiSessionUnifiedMemoryExtractor.BuildSystemPrompt( + new MemoryPredicateVocabulary()); + + withoutVocabulary.Should().NotContain("Established relation"); + } + + [Fact] + public void ThePromptIsReproducibleForAGivenVocabulary() + { + // Injected text that reordered per call would make extraction irreproducible for reasons + // unrelated to the model - the exact failure that made an earlier score sequence + // unattributable. + var vocabulary = MemoryPredicateSeedVocabulary.Create(); + + LlmMultiSessionUnifiedMemoryExtractor.BuildSystemPrompt(vocabulary).Should() + .Be(LlmMultiSessionUnifiedMemoryExtractor.BuildSystemPrompt(vocabulary)); + } + + [Fact] + public void TheSeedIsCuratedAndDoesNotFoldOpposites() + { + // The seed is reviewed, not mined, precisely so opposite relations both survive. + var seed = MemoryPredicateSeedVocabulary.Create().Snapshot(); + + seed.Should().Contain("bought").And.Contain("sold"); + seed.Should().Contain("likes").And.Contain("dislikes"); + } + + [Fact] + public void TheExtractorIsInstructedToReuseRatherThanReplace() + { + // A model told to use *only* these relations would drop facts that genuinely need a new one. + var prompt = LlmMultiSessionUnifiedMemoryExtractor.BuildSystemPrompt( + MemoryPredicateSeedVocabulary.Create()); + + prompt.Should().MatchRegex("(?i)(reuse|prefer)"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageDeferredResolutionTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageDeferredResolutionTests.cs new file mode 100644 index 00000000..98dfcd1c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageDeferredResolutionTests.cs @@ -0,0 +1,103 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Resolution; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class ExtractionStageDeferredResolutionTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task BestEffort_UsesDeferredResolutionOnlyWhenCoalescingEnabled(bool enabled) + { + var extracted = new ExtractedEntity + { + Name = "Alice", + Type = "Person", + Confidence = 0.99, + }; + var entity = new Entity + { + EntityId = "entity-1", + Name = extracted.Name, + Type = extracted.Type, + Confidence = extracted.Confidence, + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }; + var extractor = Substitute.For(); + extractor + .ExtractAsync(Arg.Any>(), Arg.Any()) + .Returns([extracted]); + var resolver = Substitute.For(); + resolver + .ResolveEntityAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(entity); + ((IExtractionEntityResolver)resolver) + .ResolveForPersistenceAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(entity); + var stage = new ExtractionStage( + [extractor], + [], + [], + [], + [], + resolver, + Options.Create(new ExtractionOptions + { + FailureMode = IngestionFailureMode.BestEffort, + UseCoalescedPersistenceTransactions = enabled, + }), + NullLogger.Instance); + var messages = new[] + { + new Message + { + MessageId = "message-1", + SessionId = "session-1", + ConversationId = "conversation-1", + Role = "user", + Content = "Alice joined the team.", + TimestampUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }, + }; + + var result = await stage.ExtractAsync(messages, ExtractionTypes.Entities); + + result.ResolvedEntityMap.Should().ContainKey("Alice"); + if (enabled) + { + await ((IExtractionEntityResolver)resolver).Received(1) + .ResolveForPersistenceAsync( + extracted, Arg.Any>(), null, Arg.Any()); + await resolver.DidNotReceive() + .ResolveEntityAsync( + Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()); + } + else + { + await resolver.Received(1) + .ResolveEntityAsync( + extracted, Arg.Any>(), null, Arg.Any()); + await ((IExtractionEntityResolver)resolver).DidNotReceive() + .ResolveForPersistenceAsync( + Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()); + } + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageTests.cs index 9fffdc84..eac3f76f 100644 --- a/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageTests.cs +++ b/tests/AgentMemory.Tests.Unit/Extraction/ExtractionStageTests.cs @@ -62,6 +62,7 @@ private ExtractionStage CreateSut( factExtractors ?? Array.Empty(), prefExtractors ?? Array.Empty(), relExtractors ?? Array.Empty(), + Array.Empty(), _resolver, Options.Create(options ?? new ExtractionOptions()), NullLogger.Instance); diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmExtractionTransportRetryTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmExtractionTransportRetryTests.cs new file mode 100644 index 00000000..7d98df36 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmExtractionTransportRetryTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +/// +/// A transient provider failure must not end an extraction on its first occurrence. +/// +/// +/// MaxRetries was honoured only for parse failures: the runner re-prompted when the +/// response was unparseable JSON, but its provider call sat outside any catch, so a transport +/// exception propagated on the first attempt. Nothing else retried — the call meter counts retries +/// without performing any. +/// +/// The cost was measured, twice. Two n=50 preparations — 614 provider calls each — died mid-run on a +/// single transient, at 37 and 26 minutes. At that call volume, "no transport retry" means "a long +/// measurement cannot finish". +/// +/// +/// The policy mirrors the batch splitter's, deliberately: a is caused +/// by the request's own shape and re-sending it unchanged cannot help, so it is not retried here — +/// the parse loop already handles it, and the splitter handles the batch-level version. Everything +/// else is treated as transient. +/// +/// +public sealed class LlmExtractionTransportRetryTests +{ + [Fact] + public async Task ATransientTransportFailureIsRetriedAndTheExtractionSucceeds() + { + // The load-bearing case: one failure then success must yield a result, not an exception. + var client = Substitute.For(); + var calls = 0; + client.GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(_ => + { + calls++; + if (calls == 1) + throw new HttpRequestException("503 Service Unavailable"); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, ValidJson))); + }); + + var result = await Sut(client) + .ExtractAsync([Request("session-00")], maxSessionsPerBatch: 1, maxInputTokens: 100_000) + .ConfigureAwait(true); + + result.Should().NotBeNull(); + calls.Should().Be(2, "the first attempt failed in transport and the second succeeded"); + } + + [Fact] + public async Task APersistentTransportFailureStillGivesUp() + { + // Bounded, not infinite. A provider that is genuinely down must end the run rather than + // retry forever inside a measurement that has a watchdog waiting on it. + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns>(_ => throw new HttpRequestException("503 Service Unavailable")); + + var act = () => Sut(client).ExtractAsync( + [Request("session-00")], maxSessionsPerBatch: 1, maxInputTokens: 100_000); + + await act.Should().ThrowAsync().ConfigureAwait(true); + await client.Received(3).GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CancellationIsNeverRetried() + { + // A cancelled run must stop immediately. Retrying a cancellation would make the watchdog's + // timeout unenforceable, which is the opposite of what it is for. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync().ConfigureAwait(true); + var client = Substitute.For(); + + var act = () => Sut(client).ExtractAsync( + [Request("session-00")], maxSessionsPerBatch: 1, maxInputTokens: 100_000, + cancellationToken: cts.Token); + + await act.Should().ThrowAsync().ConfigureAwait(true); + await client.DidNotReceive().GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()); + } + + + [Theory] + [InlineData(408, true)] + [InlineData(429, true)] + [InlineData(500, true)] + [InlineData(503, true)] + [InlineData(400, false)] // the one that cost a 60-minute preparation + [InlineData(401, false)] + [InlineData(404, false)] + public void OnlyTransientStatusesAreRetried(int status, bool transient) + { + // A 400 says the request is wrong, usually too large, and it will be just as wrong the third + // time. An n=50 preparation spent its whole 60-minute budget re-sending requests the provider + // had already rejected with 400; the watchdog fired at 544 of 614 calls with 7 failures. + AgentMemory.Extraction.Llm.Internal.LlmExtractionRunner + .IsTransient(new HttpRequestException("provider", null, (HttpStatusCode)status)) + .Should().Be(transient); + } + + [Fact] + public void AFailureThatNeverReachedTheServiceIsTransient() + { + // No status at all: a connection reset, a DNS failure, a socket timeout. The request may + // never have been seen, so re-sending it is exactly right. + AgentMemory.Extraction.Llm.Internal.LlmExtractionRunner + .IsTransient(new HttpRequestException("connection reset")) + .Should().BeTrue(); + } + + // The alias, not the session id: the contract acknowledges sources as s1..sN. + private const string ValidJson = + """{"processed_source_sessions":["s1"],"entities":[],"facts":[],"preferences":[]}"""; + + private static LlmMultiSessionUnifiedMemoryExtractor Sut(IChatClient client) => + new(client, + Options.Create(new LlmExtractionOptions + { + UseUnifiedExtraction = true, + UseMultiSessionBatchExtraction = true, + MaxRetries = 2, + }), + NullLogger.Instance); + + private static ExtractionRequest Request(string sessionId) => new() + { + SessionId = sessionId, + Messages = + [ + new Message + { + MessageId = $"message-{sessionId}", + ConversationId = "conversation-00", + SessionId = sessionId, + Role = "user", + Content = "Person works at a company and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + }, + ], + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmFactExtractorTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmFactExtractorTests.cs index e1d8f3e4..57573006 100644 --- a/tests/AgentMemory.Tests.Unit/Extraction/LlmFactExtractorTests.cs +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmFactExtractorTests.cs @@ -70,6 +70,57 @@ public async Task ExtractAsync_ValidJson_ReturnsFacts() result[1].Subject.Should().Be("Acme Corp"); } + [Fact] + public async Task ExtractAsync_DefaultRequest_RequiresJsonResponseFormat() + { + const string json = + """{"facts": [{"subject": "Alice", "predicate": "works_at", "object": "Acme Corp", "confidence": 0.95}]}"""; + ChatOptions? captured = null; + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + captured = call.ArgAt(1); + return Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, json))); + }); + + var sut = CreateSut(client); + var result = await sut.ExtractAsync(new[] { SampleMessage }); + + result.Should().ContainSingle(); + captured.Should().NotBeNull(); + captured!.ResponseFormat.Should().BeSameAs(ChatResponseFormat.Json); + } + + [Fact] + public async Task ExtractAsync_JsonResponseFormatDisabled_LeavesRequestUnspecified() + { + const string json = + """{"facts": [{"subject": "Alice", "predicate": "works_at", "object": "Acme Corp", "confidence": 0.95}]}"""; + ChatOptions? captured = null; + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + captured = call.ArgAt(1); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, json))); + }); + + var sut = CreateSut(client, options => options.UseJsonResponseFormat = false); + var result = await sut.ExtractAsync(new[] { SampleMessage }); + + result.Should().ContainSingle(); + captured.Should().NotBeNull(); + captured!.ResponseFormat.Should().BeNull(); + } + [Fact] public async Task ExtractAsync_MalformedJson_ReturnsEmpty() { diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionBatchSplitPolicyTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionBatchSplitPolicyTests.cs new file mode 100644 index 00000000..e9d9a43f --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionBatchSplitPolicyTests.cs @@ -0,0 +1,102 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +/// +/// What a multi-session batch may be split for, and what it may not. +/// +/// +/// Splitting is a recovery for a batch that is itself the problem — too many input tokens, an +/// incomplete acknowledgement, an unusable source-session key, an unparseable response. All of those +/// arrive as , and halving the batch is a genuine remedy for each. +/// +/// A provider transport failure is not that. Halving the batch and re-sending puts the same request +/// shape at the same endpoint that just failed, so a split neither diagnoses nor fixes it — and it +/// doubles the call count, which breaks the strict per-question call accounting the prepared-pair +/// harness relies on to certify a sealed graph. +/// +/// +/// This is not hypothetical: an n=50 preparation ran 37 minutes and then aborted at question 20 with +/// "observed 14 calls ... expected exactly 12", caused by one +/// System.ClientModel.ClientResultException classified as split reason other. Transport +/// failures belong to the configured retry policy, not to the splitter. +/// +/// +public sealed class LlmMultiSessionBatchSplitPolicyTests +{ + [Fact] + public async Task AProviderTransportFailureIsNotTreatedAsAnOversizedBatch() + { + // The load-bearing case. Two sessions, one transport failure: the exception must reach the + // caller rather than being answered with a split. + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns>(_ => throw new HttpRequestException("429 Too Many Requests")); + + var act = () => Sut(client).ExtractAsync( + [Request("session-00"), Request("session-01")], maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + await act.Should().ThrowAsync().ConfigureAwait(true); + + // Exactly one attempt at the batch. A split would have re-sent each half. + await client.Received(1).GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AnUnparseableResponseStillSplits() + { + // The control: batch-shape failures arrive as FormatException and splitting genuinely helps, + // so this behaviour must survive the fix. Two halves are attempted after the whole fails. + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "not json")))); + + var act = () => Sut(client).ExtractAsync( + [Request("session-00"), Request("session-01")], maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + await act.Should().ThrowAsync().ConfigureAwait(true); + + // More than the single whole-batch attempt: the splitter tried a half too. The exact count + // is deliberately not asserted - it is an implementation detail of how far the recursion + // gets before the halves fail as well. That it splits at all is the property. + client.ReceivedCalls() + .Count(call => call.GetMethodInfo().Name == nameof(IChatClient.GetResponseAsync)) + .Should().BeGreaterThan(1); + } + + private static LlmMultiSessionUnifiedMemoryExtractor Sut(IChatClient client) => + new(client, + Options.Create(new LlmExtractionOptions + { + UseUnifiedExtraction = true, + UseMultiSessionBatchExtraction = true, + MaxRetries = 0, + }), + NullLogger.Instance); + + private static ExtractionRequest Request(string sessionId) => new() + { + SessionId = sessionId, + Messages = + [ + new Message + { + MessageId = $"message-{sessionId}", + ConversationId = "conversation-00", + SessionId = sessionId, + Role = "user", + Content = "Person works at a company and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + }, + ], + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTests.cs new file mode 100644 index 00000000..758302f4 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTests.cs @@ -0,0 +1,348 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class LlmMultiSessionUnifiedMemoryExtractorTests +{ + [Fact] + public async Task ExtractAsync_ConcurrentBatchesOverlapAndRestorePlanOrder() + { + const int expectedConcurrency = 4; + var requests = Requests(8); + var client = Substitute.For(); + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var entered = 0; + var active = 0; + var maximumActive = 0; + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(async call => + { + var nowActive = Interlocked.Increment(ref active); + maximumActive = Math.Max(maximumActive, nowActive); + if (Interlocked.Increment(ref entered) == expectedConcurrency) + release.TrySetResult(); + await release.Task; + Interlocked.Decrement(ref active); + return Response(PayloadForPrompt( + call.Arg>(), requests)); + }); + var sut = CreateSut(client, maxConcurrentBatches: expectedConcurrency); + + var results = await sut.ExtractAsync( + requests, maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + maximumActive.Should().Be(expectedConcurrency); + results.Keys.Should().Equal(requests.Select(request => request.SessionId)); + await client.Received(expectedConcurrency).GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_GlobalLimiterCapsConcurrentBatches() + { + const int expectedGlobalConcurrency = 2; + var requests = Requests(8); + var client = Substitute.For(); + var active = 0; + var maximumActive = 0; + var sync = new object(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(async call => + { + var nowActive = Interlocked.Increment(ref active); + lock (sync) + { + maximumActive = Math.Max(maximumActive, nowActive); + } + await Task.Delay(25); + Interlocked.Decrement(ref active); + return Response(PayloadForPrompt( + call.Arg>(), requests)); + }); + var sut = CreateSut( + client, + maxConcurrentBatches: 4, + maxConcurrentExtractionBatches: expectedGlobalConcurrency); + + var results = await sut.ExtractAsync( + requests, maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + maximumActive.Should().Be(expectedGlobalConcurrency); + results.Keys.Should().Equal(requests.Select(request => request.SessionId)); + await client.Received(4).GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_EightSessionsAtBatchFour_UsesTwoCallsAndKeepsKeysExact() + { + var requests = Requests(8); + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => Task.FromResult(Response(PayloadForPrompt( + call.Arg>(), requests)))); + var sut = CreateSut(client); + + var results = await sut.ExtractAsync(requests, maxSessionsPerBatch: 4, maxInputTokens: 100_000); + + results.Keys.Should().BeEquivalentTo(requests.Select(request => request.SessionId)); + results.Values.Should().AllSatisfy(result => + { + result.Entities.Should().HaveCount(2); + result.Facts.Should().ContainSingle(); + result.Preferences.Should().ContainSingle(); + result.Relationships.Should().ContainSingle(); + }); + await client.Received(2).GetResponseAsync( + Arg.Any>(), + Arg.Is(options => options.ResponseFormat != null && + options.ResponseFormat.GetType() == typeof(ChatResponseFormatJson) && + ((ChatResponseFormatJson)options.ResponseFormat).Schema.HasValue), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_BatchRequestUsesShortAliasesAndConstrainedSchema() + { + var requests = Requests(2); + var client = Substitute.For(); + ChatOptions? capturedOptions = null; + string? capturedPrompt = null; + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + capturedOptions = call.Arg(); + capturedPrompt = string.Join('\n', call.Arg>().Select(message => message.Text)); + return Task.FromResult(Response( + "{\"processed_source_sessions\":[\"s1\",\"s2\"],\"entities\":[],\"facts\":[],\"preferences\":[],\"relations\":[]}")); + }); + var sut = CreateSut(client); + + var results = await sut.ExtractAsync( + requests, maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + results.Keys.Should().Equal(requests.Select(request => request.SessionId)); + capturedPrompt.Should().Contain("") + .And.Contain(""); + capturedPrompt.Should().NotContain(requests[0].SessionId).And.NotContain(requests[1].SessionId); + var format = capturedOptions!.ResponseFormat.Should().BeOfType().Which; + format.Schema.Should().NotBeNull(); + var schema = format.Schema!.Value; + var allowed = schema.GetProperty("properties") + .GetProperty("entities") + .GetProperty("items") + .GetProperty("properties") + .GetProperty("source_session") + .GetProperty("enum") + .EnumerateArray() + .Select(item => item.GetString()); + allowed.Should().Equal("s1", "s2"); + schema.GetRawText().Should().NotContain(requests[0].SessionId).And.NotContain(requests[1].SessionId); + } + [Fact] + public async Task ExtractAsync_JsonResponseFormatDisabledLeavesRequestUnspecified() + { + var requests = Requests(2); + var client = Substitute.For(); + ChatOptions? capturedOptions = null; + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + capturedOptions = call.Arg(); + return Task.FromResult(Response( + "{\"processed_source_sessions\":[\"s1\",\"s2\"],\"entities\":[],\"facts\":[],\"preferences\":[],\"relations\":[]}")); + }); + var sut = CreateSut(client, useJsonResponseFormat: false); + + var results = await sut.ExtractAsync( + requests, maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + results.Keys.Should().Equal(requests.Select(request => request.SessionId)); + capturedOptions!.ResponseFormat.Should().BeNull(); + } + + + [Fact] + public async Task ExtractAsync_MissingAcknowledgement_RecursivelySplitsAndLosesNothing() + { + var requests = Requests(2); + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns( + Task.FromResult(Response(Payload([requests[0]], acknowledged: []))), + Task.FromResult(Response(Payload([requests[0]]))), + Task.FromResult(Response(Payload([requests[1]])))); + var diagnostics = new LlmExtractionBatchDiagnostics(); + var sut = CreateSut(client, diagnostics: diagnostics); + + var results = await sut.ExtractAsync(requests, maxSessionsPerBatch: 2, maxInputTokens: 100_000); + + results.Should().HaveCount(2); + results[requests[0].SessionId].Facts.Should().ContainSingle(); + results[requests[1].SessionId].Facts.Should().ContainSingle(); + await client.Received(3).GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + var diagnostic = diagnostics.Snapshot(); + diagnostic.Splits.Should().Be(1); + diagnostic.DroppedDetails.Should().Be(0); + var detail = diagnostic.Details.Should().ContainSingle().Which; + detail.Reason.Should().Be("acknowledgement"); + detail.SourceSessions.Should().Be(2); + detail.ExceptionType.Should().EndWith("+BatchValidationException"); + } + + [Fact] + public async Task ExtractAsync_SingleSessionOverTokenBudget_FailsBeforeProviderCall() + { + var client = Substitute.For(); + var sut = CreateSut(client); + + var act = () => sut.ExtractAsync(Requests(1), maxSessionsPerBatch: 1, maxInputTokens: 1); + + await act.Should().ThrowAsync() + .WithMessage("*exceeds*token budget*"); + await client.DidNotReceive().GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void IsEnabled_RequiresUnifiedAndMultiSessionSwitches() + { + var client = Substitute.For(); + + CreateSut(client, unified: false, batched: true).IsEnabled.Should().BeFalse(); + CreateSut(client, unified: true, batched: false).IsEnabled.Should().BeFalse(); + CreateSut(client, unified: true, batched: true).IsEnabled.Should().BeTrue(); + } + + private static LlmMultiSessionUnifiedMemoryExtractor CreateSut( + IChatClient client, + bool unified = true, + bool batched = true, + int maxConcurrentBatches = 1, + int maxConcurrentExtractionBatches = 0, + LlmExtractionBatchDiagnostics? diagnostics = null, + bool useJsonResponseFormat = true) + { + var options = Options.Create(new LlmExtractionOptions + { + UseJsonResponseFormat = useJsonResponseFormat, + UseUnifiedExtraction = unified, + UseMultiSessionBatchExtraction = batched, + MaxConcurrentBatchesPerExtraction = maxConcurrentBatches, + MaxConcurrentExtractionBatches = maxConcurrentExtractionBatches, + MaxRetries = 0, + }); + var limiter = new LlmExtractionBatchConcurrencyLimiter(options); + return new LlmMultiSessionUnifiedMemoryExtractor( + client, + options, + NullLogger.Instance, + limiter, + diagnostics); + } + + private static IReadOnlyList Requests(int count) => + Enumerable.Range(0, count).Select(index => + { + var session = $"session-{index:D2}"; + return new ExtractionRequest + { + SessionId = session, + UserId = $"owner-{index:D2}", + Messages = + [ + new Message + { + MessageId = $"{session}-message", + ConversationId = $"{session}-conversation", + SessionId = session, + Role = "user", + Content = $"Person {index:D2} works at Company {index:D2} and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, index, 0, TimeSpan.Zero), + }, + ], + }; + }).ToArray(); + + private static string PayloadForPrompt( + IEnumerable messages, + IReadOnlyList requests) + { + var prompt = string.Join('\n', messages.Select(message => message.Text)); + var selected = requests.Where(request => + prompt.Contains(request.Messages[0].Content, StringComparison.Ordinal)).ToArray(); + return Payload(selected); + } + + private static string Payload( + IReadOnlyList requests, + IReadOnlyList? acknowledged = null) + { + var keyed = requests.Select((request, index) => new + { + Request = request, + SourceKey = LlmMultiSessionExtractionResponseContract.Alias(index) + }).ToArray(); + acknowledged ??= keyed.Select(item => item.SourceKey).ToArray(); + var acks = string.Join(',', acknowledged.Select(key => $"\"{key}\"")); + var entities = string.Join(',', keyed.SelectMany(item => + { + var index = item.Request.SessionId[^2..]; + return new[] + { + $"{{\"source_session\":\"{item.SourceKey}\",\"name\":\"Person {index}\",\"type\":\"PERSON\",\"confidence\":0.95}}", + $"{{\"source_session\":\"{item.SourceKey}\",\"name\":\"Company {index}\",\"type\":\"ORGANIZATION\",\"confidence\":0.95}}", + }; + })); + var facts = string.Join(',', keyed.Select(item => + { + var index = item.Request.SessionId[^2..]; + return $"{{\"source_session\":\"{item.SourceKey}\",\"subject\":\"Person {index}\",\"predicate\":\"works_at\",\"object\":\"Company {index}\",\"confidence\":0.9}}"; + })); + var preferences = string.Join(',', keyed.Select(item => + $"{{\"source_session\":\"{item.SourceKey}\",\"category\":\"drink\",\"preference\":\"tea\",\"confidence\":0.9}}")); + var relations = string.Join(',', keyed.Select(item => + { + var index = item.Request.SessionId[^2..]; + return $"{{\"source_session\":\"{item.SourceKey}\",\"source\":\"Person {index}\",\"target\":\"Company {index}\",\"relation_type\":\"WORKS_AT\",\"confidence\":0.9}}"; + })); + return $"{{\"processed_source_sessions\":[{acks}],\"entities\":[{entities}],\"facts\":[{facts}],\"preferences\":[{preferences}],\"relations\":[{relations}]}}"; + } + + private static ChatResponse Response(string text) => + new(new ChatMessage(ChatRole.Assistant, text)); +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTokenBudgetTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTokenBudgetTests.cs new file mode 100644 index 00000000..25ea136b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmMultiSessionUnifiedMemoryExtractorTokenBudgetTests.cs @@ -0,0 +1,100 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class LlmMultiSessionUnifiedMemoryExtractorTokenBudgetTests +{ + [Fact] + public async Task ExtractAsync_ConservativeBudgetRejectsBeforeProviderCall() + { + var client = Substitute.For(); + var sut = new LlmMultiSessionUnifiedMemoryExtractor( + client, + Options.Create(new LlmExtractionOptions + { + UseUnifiedExtraction = true, + UseMultiSessionBatchExtraction = true, + MaxRetries = 0, + }), + NullLogger.Instance); + + var request = new ExtractionRequest + { + SessionId = "session-00", + Messages = + [ + new Message + { + MessageId = "message-00", + ConversationId = "conversation-00", + SessionId = "session-00", + Role = "user", + Content = "Person 00 works at Company 00 and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + }, + ], + }; + + var act = () => sut.ExtractAsync([request], maxSessionsPerBatch: 1, maxInputTokens: 500); + + await act.Should().ThrowAsync() + .WithMessage("*exceeds*token budget*"); + await client.DidNotReceive().GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + [Fact] + public void Plan_UsesTheSameStableSessionAndTokenBoundariesAsExecution() + { + var client = Substitute.For(); + var sut = new LlmMultiSessionUnifiedMemoryExtractor( + client, + Options.Create(new LlmExtractionOptions + { + UseUnifiedExtraction = true, + UseMultiSessionBatchExtraction = true, + MaxRetries = 0, + }), + NullLogger.Instance); + var requests = Enumerable.Range(0, 5) + .Select(index => new ExtractionRequest + { + SessionId = $"session-{index:D2}", + Messages = + [ + new Message + { + MessageId = $"message-{index:D2}", + ConversationId = $"conversation-{index:D2}", + SessionId = $"session-{index:D2}", + Role = "user", + Content = $"Person {index:D2} works at Company {index:D2} and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) + .AddMinutes(index), + }, + ], + }) + .ToArray(); + + var plan = sut.Plan(requests, maxSessionsPerBatch: 4, maxInputTokens: 100_000); + + plan.SourceSessionCount.Should().Be(5); + plan.BatchCount.Should().Be(2); + plan.Batches.Select(batch => batch.SourceSessionIds).Should().BeEquivalentTo( + new[] + { + new[] { "session-00", "session-01", "session-02", "session-03" }, + new[] { "session-04" }, + }, + options => options.WithStrictOrdering()); + plan.Batches.Should().OnlyContain(batch => batch.EstimatedInputTokens > 0); + } + +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedExtractionContractTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedExtractionContractTests.cs new file mode 100644 index 00000000..594a584c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedExtractionContractTests.cs @@ -0,0 +1,19 @@ +using AgentMemory.Extraction.Llm; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class LlmUnifiedExtractionContractTests +{ + [Fact] + public void Options_ExposeExplicitReversibleUnifiedExtractionSwitch() + { + var property = typeof(LlmExtractionOptions).GetProperty("UseUnifiedExtraction"); + + property.Should().NotBeNull( + "LAB-U1 must be reversible and the four-call compatibility path must remain explicit"); + property!.PropertyType.Should().Be(typeof(bool)); + property.GetValue(new LlmExtractionOptions()).Should().Be(false, + "the compatibility path remains the default until live quality acceptance promotes it"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedMemoryExtractorTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedMemoryExtractorTests.cs new file mode 100644 index 00000000..b951a1c0 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/LlmUnifiedMemoryExtractorTests.cs @@ -0,0 +1,152 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Extraction.Llm; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class LlmUnifiedMemoryExtractorTests +{ + private static readonly Message Message = new() + { + MessageId = "message-1", + ConversationId = "conversation-1", + SessionId = "session-1", + Role = "user", + Content = "Alice knows Bob, works at Acme, and prefers tea.", + TimestampUtc = DateTimeOffset.UtcNow, + }; + + private const string CompleteJson = + """ + { + "entities": [ + {"name":"Alice","type":"PERSON","confidence":0.95,"aliases":[]}, + {"name":"Bob","type":"PERSON","confidence":0.94,"aliases":[]} + ], + "facts": [ + {"subject":"Alice","predicate":"knows","object":"Bob","confidence":0.93}, + {"subject":"Alice","predicate":"works_at","object":"Acme","confidence":0.92} + ], + "preferences": [ + {"category":"drink","preference":"tea","confidence":0.91} + ], + "relations": [ + {"source":"Alice","target":"Bob","relation_type":"KNOWS","confidence":0.90} + ] + } + """; + + [Fact] + public async Task ExtractAsync_CompleteResponse_MapsEveryCategoryInOneCall() + { + var client = ClientReturning(CompleteJson); + var sut = CreateSut(client, enabled: true); + + var result = await sut.ExtractAsync([Message]); + + result.Entities.Should().HaveCount(2); + result.Facts.Should().HaveCount(2); + result.Preferences.Should().ContainSingle(); + result.Relationships.Should().ContainSingle(); + await client.Received(1).GetResponseAsync( + Arg.Any>(), + Arg.Is(options => options.ResponseFormat == ChatResponseFormat.Json), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_ParseRetryThenSuccess_UsesExactlyTwoCalls() + { + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns( + Task.FromResult(Response("{invalid}")), + Task.FromResult(Response(CompleteJson))); + var sut = CreateSut(client, maxRetries: 1); + + var result = await sut.ExtractAsync([Message]); + + result.Entities.Should().HaveCount(2); + await client.Received(2).GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_ParseRetriesExhausted_Throws() + { + var client = ClientReturning("{invalid}"); + var sut = CreateSut(client, maxRetries: 1); + + var act = () => sut.ExtractAsync([Message]); + + await act.Should().ThrowAsync() + .WithMessage("*exhausted*valid JSON*"); + await client.Received(2).GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExtractAsync_EmptyInput_DoesNotCallProvider() + { + var client = Substitute.For(); + var sut = CreateSut(client); + + var result = await sut.ExtractAsync([]); + + result.Should().Be(new UnifiedExtractionResult()); + await client.DidNotReceive().GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void IsEnabled_ReflectsExplicitOption() + { + var client = Substitute.For(); + + CreateSut(client, enabled: false).IsEnabled.Should().BeFalse(); + CreateSut(client, enabled: true).IsEnabled.Should().BeTrue(); + } + + private static LlmUnifiedMemoryExtractor CreateSut( + IChatClient client, + bool enabled = false, + int maxRetries = 0) + { + var options = new LlmExtractionOptions + { + UseUnifiedExtraction = enabled, + MaxRetries = maxRetries, + }; + return new LlmUnifiedMemoryExtractor( + client, + Options.Create(options), + NullLogger.Instance); + } + + private static IChatClient ClientReturning(string text) + { + var client = Substitute.For(); + client.GetResponseAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(Response(text))); + return client; + } + + private static ChatResponse Response(string text) => + new(new ChatMessage(ChatRole.Assistant, text)); +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchFailureTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchFailureTests.cs new file mode 100644 index 00000000..2b1bd4a4 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchFailureTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageBatchFailureTests +{ + [Fact] + public async Task PersistAsync_BatchFailure_ReplaysItemPathAndPreservesOutcomes() + { + var entityRepository = Substitute.For>(); + var batch = (IBatchMemoryRepository)entityRepository; + batch.UpsertBatchAsync(Arg.Any>(), Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("batch failed")); + entityRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + var sut = CreateSut(entityRepository, new ExtractionOptions()); + var result = await sut.PersistAsync(TwoEntities()); + + await batch.Received(1).UpsertBatchAsync( + Arg.Is>(items => items.Count == 2), + Arg.Any()); + await entityRepository.Received(2).UpsertAsync( + Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + result.Outcomes.Count(outcome => + outcome.Kind == MemoryItemKind.Entity && + outcome.Status == IngestionItemStatus.Succeeded).Should().Be(2); + } + + [Fact] + public async Task PersistAsync_BatchOptionDisabled_UsesItemPath() + { + var entityRepository = Substitute.For>(); + var batch = (IBatchMemoryRepository)entityRepository; + entityRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + var sut = CreateSut(entityRepository, new ExtractionOptions + { + EnableBatchMemoryUpserts = false + }); + var result = await sut.PersistAsync(TwoEntities()); + + await batch.DidNotReceive().UpsertBatchAsync( + Arg.Any>(), Arg.Any()); + await entityRepository.Received(2).UpsertAsync( + Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + } + + [Fact] + public async Task PersistAsync_FailFastMode_UsesItemPathForExactFailureAttribution() + { + var entityRepository = Substitute.For>(); + var batch = (IBatchMemoryRepository)entityRepository; + entityRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + var sut = CreateSut(entityRepository, new ExtractionOptions + { + FailureMode = IngestionFailureMode.FailFast + }); + var result = await sut.PersistAsync(TwoEntities()); + + await batch.DidNotReceive().UpsertBatchAsync( + Arg.Any>(), Arg.Any()); + await entityRepository.Received(2).UpsertAsync( + Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + } + + private static PersistenceStage CreateSut( + IEntityRepository entityRepository, + ExtractionOptions options) + { + var embeddingOrchestrator = Substitute.For(); + embeddingOrchestrator.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(new float[4]); + + return new PersistenceStage( + embeddingOrchestrator, + entityRepository, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance, + new PassThroughMemoryPersistenceTransaction(), + Options.Create(options)); + } + + private static ExtractionStageResult TwoEntities() => new() + { + SourceMessageIds = ["message-1"], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = Entity("entity-1", "Alice"), + ["Bob"] = Entity("entity-2", "Bob") + } + }; + + private static Entity Entity(string id, string name) => new() + { + EntityId = id, + Name = name, + Type = "Person", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z") + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchTests.cs new file mode 100644 index 00000000..a067ce7b --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageBatchTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageBatchTests +{ + [Fact] + public async Task PersistAsync_BatchCapableRepositories_UseOneBatchPerMemoryKind() + { + var entityRepository = Substitute.For>(); + var factRepository = Substitute.For>(); + var preferenceRepository = Substitute.For>(); + var relationshipRepository = Substitute.For>(); + var embeddingOrchestrator = Substitute.For(); + var clock = Substitute.For(); + var idGenerator = Substitute.For(); + + clock.UtcNow.Returns(DateTimeOffset.Parse("2026-07-29T00:00:00Z")); + idGenerator.GenerateId().Returns( + "fact-1", "fact-2", + "preference-1", "preference-2", + "relationship-1", "relationship-2"); + embeddingOrchestrator.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(new float[4]); + + var entityBatch = (IBatchMemoryRepository)entityRepository; + entityBatch.UpsertBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + var factBatch = (IBatchMemoryRepository)factRepository; + factBatch.UpsertBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + var preferenceBatch = (IBatchMemoryRepository)preferenceRepository; + preferenceBatch.UpsertBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + var relationshipBatch = (IBatchMemoryRepository)relationshipRepository; + relationshipBatch.UpsertBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + + var sut = new PersistenceStage( + embeddingOrchestrator, + entityRepository, + factRepository, + preferenceRepository, + relationshipRepository, + clock, + idGenerator, + NullLogger.Instance, + new PassThroughMemoryPersistenceTransaction(), + Options.Create(new ExtractionOptions())); + + var extraction = new ExtractionStageResult + { + SourceMessageIds = ["message-1"], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = Entity("entity-1", "Alice"), + ["Bob"] = Entity("entity-2", "Bob") + }, + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 0.9 + }, + new ExtractedFact + { + Subject = "Bob", + Predicate = "likes", + Object = "tea", + Confidence = 0.8 + } + ], + FilteredPreferences = + [ + new ExtractedPreference { Category = "drink", PreferenceText = "coffee", Confidence = 0.9 }, + new ExtractedPreference { Category = "drink", PreferenceText = "tea", Confidence = 0.8 } + ], + FilteredRelationships = + [ + new ExtractedRelationship + { + SourceEntity = "Alice", + TargetEntity = "Bob", + RelationshipType = "KNOWS", + Confidence = 0.9 + }, + new ExtractedRelationship + { + SourceEntity = "Bob", + TargetEntity = "Alice", + RelationshipType = "WORKS_WITH", + Confidence = 0.8 + } + ] + }; + + var result = await sut.PersistAsync(extraction, ownerId: "owner-1"); + + await entityBatch.Received(1).UpsertBatchAsync( + Arg.Is>(items => items.Count == 2), + Arg.Any()); + await factBatch.Received(1).UpsertBatchAsync( + Arg.Is>(items => items.Count == 2), + Arg.Any()); + await preferenceBatch.Received(1).UpsertBatchAsync( + Arg.Is>(items => items.Count == 2), + Arg.Any()); + await relationshipBatch.Received(1).UpsertBatchAsync( + Arg.Is>(items => items.Count == 2), + Arg.Any()); + await entityRepository.DidNotReceive().UpsertAsync( + Arg.Any(), Arg.Any()); + await factRepository.DidNotReceive().UpsertAsync( + Arg.Any(), Arg.Any()); + await preferenceRepository.DidNotReceive().UpsertAsync( + Arg.Any(), Arg.Any()); + await relationshipRepository.DidNotReceive().UpsertAsync( + Arg.Any(), Arg.Any()); + + result.EntityCount.Should().Be(2); + result.FactCount.Should().Be(2); + result.PreferenceCount.Should().Be(2); + result.RelationshipCount.Should().Be(2); + result.Outcomes.Count(outcome => + outcome.Status == IngestionItemStatus.Succeeded).Should().Be(8); + } + + private static Entity Entity(string id, string name) => new() + { + EntityId = id, + Name = name, + Type = "Person", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z") + }; +} \ No newline at end of file diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageEmbeddingBatchTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageEmbeddingBatchTests.cs new file mode 100644 index 00000000..683f03ac --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageEmbeddingBatchTests.cs @@ -0,0 +1,250 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageEmbeddingBatchTests +{ + private static readonly string[] ExpectedTexts = + [ + "Alice", + "Bob", + "Alice likes coffee", + "Bob likes tea", + "Prefers concise answers" + ]; + + private readonly IEmbeddingOrchestrator _orchestrator = Substitute.For(); + private readonly IEntityRepository _entityRepository = Substitute.For(); + private readonly IFactRepository _factRepository = Substitute.For(); + private readonly IPreferenceRepository _preferenceRepository = Substitute.For(); + private readonly IRelationshipRepository _relationshipRepository = Substitute.For(); + private readonly IClock _clock = Substitute.For(); + private readonly IIdGenerator _idGenerator = Substitute.For(); + + public PersistenceStageEmbeddingBatchTests() + { + _clock.UtcNow.Returns(DateTimeOffset.Parse("2026-08-03T00:00:00Z")); + _idGenerator.GenerateId().Returns("fact-1", "fact-2", "preference-1"); + _orchestrator.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(call => [(float)call.Arg().Length]); + + _entityRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + _factRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + _preferenceRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + _relationshipRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + } + + [Fact] + public void ExtractionOptions_DefaultsLearnedEmbeddingBatchingOn() + { + new ExtractionOptions().UseBatchEmbeddingRequests.Should().BeTrue(); + } + + [Fact] + public async Task PersistAsync_Default_BatchesMissingLearnedEmbeddingsInStableOrder() + { + _orchestrator.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Vectors(1, 2, 3, 4, 5)); + + var result = await CreateSut().PersistAsync(CreateExtraction()); + + await _orchestrator.Received(1).EmbedBatchAsync( + Arg.Is>(texts => texts.SequenceEqual(ExpectedTexts)), + Arg.Any()); + await _orchestrator.DidNotReceive().EmbedAsync( + Arg.Any(), Arg.Any()); + + await _entityRepository.Received(1).UpsertAsync( + Arg.Is(item => item.Name == "Alice" && item.Embedding![0] == 1), + Arg.Any()); + await _entityRepository.Received(1).UpsertAsync( + Arg.Is(item => item.Name == "Bob" && item.Embedding![0] == 2), + Arg.Any()); + await _factRepository.Received(1).UpsertAsync( + Arg.Is(item => item.Subject == "Alice" && item.Embedding != null && item.Embedding.Length > 0 && item.Embedding[0] == 3), + Arg.Any()); + await _factRepository.Received(1).UpsertAsync( + Arg.Is(item => item.Subject == "Bob" && item.Embedding != null && item.Embedding.Length > 0 && item.Embedding[0] == 4), + Arg.Any()); + await _preferenceRepository.Received(1).UpsertAsync( + Arg.Is(item => item.PreferenceText == "Prefers concise answers" && item.Embedding != null && item.Embedding.Length > 0 && item.Embedding[0] == 5), + Arg.Any()); + + result.EntityCount.Should().Be(2); + result.FactCount.Should().Be(2); + result.PreferenceCount.Should().Be(1); + } + + [Fact] + public async Task PersistAsync_OptionOff_PreservesFiveSingleRequests() + { + await CreateSut(new ExtractionOptions { UseBatchEmbeddingRequests = false }) + .PersistAsync(CreateExtraction()); + + await _orchestrator.DidNotReceive().EmbedBatchAsync( + Arg.Any>(), Arg.Any()); + await _orchestrator.Received(5).EmbedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_BatchCountMismatch_ReplaysWholeBatch() + { + _orchestrator.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Vectors(99)); + + var result = await CreateSut().PersistAsync(CreateExtraction()); + + await _orchestrator.Received(5).EmbedAsync( + Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + result.FactCount.Should().Be(2); + result.PreferenceCount.Should().Be(1); + } + + [Fact] + public async Task PersistAsync_AlignedEmptySlot_ReplaysOnlyThatSlot() + { + _orchestrator.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new List { new[] { 1f }, new[] { 2f }, Array.Empty(), new[] { 4f }, new[] { 5f } }); + + await CreateSut().PersistAsync(CreateExtraction()); + + await _orchestrator.Received(1).EmbedAsync( + "Alice likes coffee", Arg.Any()); + await _orchestrator.Received(1).EmbedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_ThrowingBatch_ReplaysWholeBatch() + { + _orchestrator.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("batch failed")); + + var result = await CreateSut().PersistAsync(CreateExtraction()); + + await _orchestrator.Received(5).EmbedAsync( + Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + result.FactCount.Should().Be(2); + result.PreferenceCount.Should().Be(1); + } + + [Fact] + public async Task PersistAsync_CancelledBatch_PropagatesCancellationWithoutFallback() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + _orchestrator.EmbedBatchAsync(Arg.Any>(), cts.Token) + .Returns>(_ => throw new OperationCanceledException(cts.Token)); + + var act = () => CreateSut().PersistAsync(CreateExtraction(), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + await _orchestrator.DidNotReceive().EmbedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_PreEmbeddedEntity_IsExcludedFromBatchAndRetained() + { + var extraction = CreateExtraction() with + { + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = Entity("entity-1", "Alice") with { Embedding = [42] }, + ["Bob"] = Entity("entity-2", "Bob") + } + }; + _orchestrator.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Vectors(2, 3, 4, 5)); + + await CreateSut().PersistAsync(extraction); + + await _orchestrator.Received(1).EmbedBatchAsync( + Arg.Is>(texts => + texts.SequenceEqual(ExpectedTexts.Skip(1))), + Arg.Any()); + await _entityRepository.Received(1).UpsertAsync( + Arg.Is(item => item.Name == "Alice" && item.Embedding![0] == 42), + Arg.Any()); + } + + private PersistenceStage CreateSut(ExtractionOptions? options = null) => + new( + _orchestrator, + _entityRepository, + _factRepository, + _preferenceRepository, + _relationshipRepository, + _clock, + _idGenerator, + NullLogger.Instance, + new PassThroughMemoryPersistenceTransaction(), + Options.Create(options ?? new ExtractionOptions())); + + private static ExtractionStageResult CreateExtraction() => + new() + { + SourceMessageIds = [], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = Entity("entity-1", "Alice"), + ["Bob"] = Entity("entity-2", "Bob") + }, + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 0.9 + }, + new ExtractedFact + { + Subject = "Bob", + Predicate = "likes", + Object = "tea", + Confidence = 0.8 + } + ], + FilteredPreferences = + [ + new ExtractedPreference + { + Category = "style", + PreferenceText = "Prefers concise answers", + Confidence = 0.9 + } + ], + FilteredRelationships = [] + }; + + private static Entity Entity(string id, string name) => + new() + { + EntityId = id, + Name = name, + Type = "Person", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.Parse("2026-08-03T00:00:00Z") + }; + + private static IReadOnlyList Vectors(params float[] values) => + values.Select(value => new[] { value }).ToArray(); +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFactBatchSemanticsTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFactBatchSemanticsTests.cs new file mode 100644 index 00000000..0b284639 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFactBatchSemanticsTests.cs @@ -0,0 +1,102 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageFactBatchSemanticsTests +{ + [Fact] + public async Task PersistAsync_CaseInsensitiveDuplicateFacts_KeepSequentialReadWriteOrder() + { + var calls = new List(); + var factRepository = Substitute.For>(); + var batch = (IBatchMemoryRepository)factRepository; + var findCall = 0; + factRepository.FindByTripleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + var subject = call.ArgAt(0); + calls.Add($"find:{subject}"); + findCall++; + return findCall == 1 + ? null + : new Fact + { + FactId = "fact-1", + Subject = "Alice", + Predicate = "likes", + Object = "Coffee", + Confidence = 0.9, + OwnerId = "owner-1", + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z") + }; + }); + factRepository.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var fact = call.Arg(); + calls.Add($"upsert:{fact.Subject}"); + return fact; + }); + + var embeddingOrchestrator = Substitute.For(); + embeddingOrchestrator.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(new float[4]); + var idGenerator = Substitute.For(); + idGenerator.GenerateId().Returns("fact-1", "fact-2"); + + var sut = new PersistenceStage( + embeddingOrchestrator, + Substitute.For(), + factRepository, + Substitute.For(), + Substitute.For(), + Substitute.For(), + idGenerator, + NullLogger.Instance, + new PassThroughMemoryPersistenceTransaction(), + Options.Create(new ExtractionOptions())); + + var extraction = new ExtractionStageResult + { + SourceMessageIds = ["message-1"], + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "likes", + Object = "Coffee", + Confidence = 0.9 + }, + new ExtractedFact + { + Subject = "alice", + Predicate = "LIKES", + Object = "coffee", + Confidence = 0.8 + } + ] + }; + + var result = await sut.PersistAsync(extraction, ownerId: "owner-1"); + + calls.Should().Equal("find:Alice", "upsert:Alice", "find:alice", "upsert:Alice"); + await batch.DidNotReceive().UpsertBatchAsync( + Arg.Any>(), Arg.Any()); + result.FactCount.Should().Be(2); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFusedBatchTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFusedBatchTests.cs new file mode 100644 index 00000000..6550dbe8 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageFusedBatchTests.cs @@ -0,0 +1,165 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageFusedBatchTests +{ + [Fact] + public async Task PersistAsync_CoalescingEnabled_UsesFusedBatchForEverySupportedKindIncludingSingletons() + { + var entities = Substitute.For>(); + var facts = Substitute.For>(); + var preferences = Substitute.For>(); + var relationships = Substitute.For(); + + var entityFused = (IFusedBatchMemoryRepository)entities; + var factFused = (IFusedBatchMemoryRepository)facts; + var preferenceFused = (IFusedBatchMemoryRepository)preferences; + entityFused.UpsertFusedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + factFused.UpsertFusedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + preferenceFused.UpsertFusedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>()); + relationships.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + var result = await CreateStage(entities, facts, preferences, relationships, enabled: true) + .PersistAsync(Extraction(), ownerId: "owner-1"); + + await entityFused.Received(1).UpsertFusedBatchAsync( + Arg.Is>(items => items.Count == 2), Arg.Any()); + await factFused.Received(1).UpsertFusedBatchAsync( + Arg.Is>(items => items.Count == 1), Arg.Any()); + await preferenceFused.Received(1).UpsertFusedBatchAsync( + Arg.Is>(items => items.Count == 1), Arg.Any()); + await entities.DidNotReceive().UpsertAsync(Arg.Any(), Arg.Any()); + await facts.DidNotReceive().UpsertAsync(Arg.Any(), Arg.Any()); + await preferences.DidNotReceive().UpsertAsync(Arg.Any(), Arg.Any()); + result.EntityCount.Should().Be(2); + result.FactCount.Should().Be(1); + result.PreferenceCount.Should().Be(1); + result.RelationshipCount.Should().Be(1); + } + + [Fact] + public async Task PersistAsync_CoalescingDisabled_DoesNotUseFusedCapability() + { + var entities = Substitute.For>(); + var facts = Substitute.For>(); + var preferences = Substitute.For>(); + var relationships = Substitute.For(); + entities.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + facts.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + preferences.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + relationships.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + await CreateStage(entities, facts, preferences, relationships, enabled: false) + .PersistAsync(Extraction(), ownerId: "owner-1"); + + await ((IFusedBatchMemoryRepository)entities).DidNotReceiveWithAnyArgs() + .UpsertFusedBatchAsync(default!, default); + await ((IFusedBatchMemoryRepository)facts).DidNotReceiveWithAnyArgs() + .UpsertFusedBatchAsync(default!, default); + await ((IFusedBatchMemoryRepository)preferences).DidNotReceiveWithAnyArgs() + .UpsertFusedBatchAsync(default!, default); + } + + private static PersistenceStage CreateStage( + IEntityRepository entities, + IFactRepository facts, + IPreferenceRepository preferences, + IRelationshipRepository relationships, + bool enabled) + { + var embeddings = Substitute.For(); + embeddings.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(new float[] { 1.0f, 0.0f }); + embeddings.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>() + .Select(_ => new float[] { 1.0f, 0.0f }).ToArray()); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.Parse("2026-08-04T12:00:00Z")); + var ids = Substitute.For(); + ids.GenerateId().Returns("fact-1", "preference-1", "relationship-1"); + + return new PersistenceStage( + embeddings, + entities, + facts, + preferences, + relationships, + clock, + ids, + NullLogger.Instance, + new PassThroughMemoryPersistenceTransaction(), + Options.Create(new ExtractionOptions + { + FailureMode = IngestionFailureMode.BestEffort, + EnableBatchMemoryUpserts = true, + UseCoalescedPersistenceTransactions = enabled, + })); + } + + private static ExtractionStageResult Extraction() => new() + { + SourceMessageIds = [], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = Entity("entity-1", "Alice"), + ["Bob"] = Entity("entity-2", "Bob"), + }, + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 0.9, + }, + ], + FilteredPreferences = + [ + new ExtractedPreference + { + Category = "drink", + PreferenceText = "coffee", + Confidence = 0.9, + }, + ], + FilteredRelationships = + [ + new ExtractedRelationship + { + SourceEntity = "Alice", + TargetEntity = "Bob", + RelationshipType = "KNOWS", + Confidence = 0.9, + }, + ], + }; + + private static Entity Entity(string id, string name) => new() + { + EntityId = id, + Name = name, + Type = "Person", + Confidence = 0.9, + Embedding = [1.0f, 0.0f], + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageProvenanceCapabilityTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageProvenanceCapabilityTests.cs new file mode 100644 index 00000000..959edfbf --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageProvenanceCapabilityTests.cs @@ -0,0 +1,86 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageProvenanceCapabilityTests +{ + [Fact] + public async Task PersistAsync_RepositoriesPersistProvenanceOnUpsert_DoesNotWriteEdgesTwice() + { + var embeddings = Substitute.For(); + embeddings.EmbedAsync(Arg.Any(), Arg.Any()) + .Returns(new float[384]); + var entityRepo = Substitute.For(); + var factRepo = Substitute.For(); + var preferenceRepo = Substitute.For(); + var relationshipRepo = Substitute.For(); + var clock = Substitute.For(); + var ids = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + ids.GenerateId().Returns("memory-1"); + entityRepo.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + factRepo.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + preferenceRepo.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + + var extraction = new ExtractionStageResult + { + SourceMessageIds = ["message-1", "message-2"], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = new Entity + { + EntityId = "entity-1", + Name = "Alice", + Type = "Person", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.UtcNow, + }, + }, + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "likes", + Object = "coffee", + Confidence = 0.9, + }, + ], + FilteredPreferences = + [ + new ExtractedPreference + { + Category = "drink", + PreferenceText = "Prefers coffee", + Confidence = 0.9, + }, + ], + }; + var sut = new PersistenceStage( + embeddings, entityRepo, factRepo, preferenceRepo, relationshipRepo, clock, ids, + NullLogger.Instance, new PassThroughMemoryPersistenceTransaction()); + + var result = await sut.PersistAsync(extraction); + + result.EntityCount.Should().Be(1); + result.FactCount.Should().Be(1); + result.PreferenceCount.Should().Be(1); + await entityRepo.DidNotReceive().CreateExtractedFromRelationshipAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + await factRepo.DidNotReceive().CreateExtractedFromRelationshipAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + await preferenceRepo.DidNotReceive().CreateExtractedFromRelationshipAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTests.cs index bba59737..0255fe6d 100644 --- a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTests.cs +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTests.cs @@ -6,6 +6,7 @@ using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; using AgentMemory.Core.Extraction; using NSubstitute; @@ -49,9 +50,11 @@ public PersistenceStageTests() .Returns(ci => Task.FromResult(ci.Arg())); } - private PersistenceStage CreateSut(ExtractionOptions? options = null) => + private PersistenceStage CreateSut( + ExtractionOptions? options = null, IMemoryPersistenceTransaction? transaction = null) => new(_orchestrator, _entityRepo, _factRepo, _prefRepo, _relRepo, _clock, _idGen, - NullLogger.Instance, Options.Create(options ?? new ExtractionOptions())); + NullLogger.Instance, + transaction ?? new PassThroughMemoryPersistenceTransaction(), Options.Create(options ?? new ExtractionOptions())); private static ExtractionStageResult EmptyResult(IReadOnlyList? sourceIds = null) => new() @@ -1145,9 +1148,89 @@ public async Task PersistAsync_BestEffortIsDefault_DoesNotThrowOnPersistenceFail ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Alice"] = entity } }; - var sut = CreateSut(); // default options — BestEffort + var transaction = new RecordingPersistenceTransaction(); + var sut = CreateSut(transaction: transaction); // default options — BestEffort var act = () => sut.PersistAsync(extraction); await act.Should().NotThrowAsync(); + transaction.ExecutionCount.Should().Be(0, "BestEffort preserves its independent-write behavior"); + } + + [Fact] + public async Task PersistAsync_PreparesEveryEmbeddingBeforeOpeningPersistenceTransaction() + { + var transaction = new RecordingPersistenceTransaction(); + _orchestrator + .When(x => x.EmbedAsync(Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeFalse( + "external embedding providers must never run while the database transaction is open")); + _entityRepo + .When(x => x.UpsertAsync(Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeTrue()); + _factRepo + .When(x => x.UpsertAsync(Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeTrue()); + _prefRepo + .When(x => x.UpsertAsync(Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeTrue()); + + var extraction = EmptyResult(Array.Empty()) with + { + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = new Entity + { + EntityId = "e-1", + Name = "Alice", + Type = "Person", + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.UtcNow, + }, + }, + FilteredFacts = + [ + new ExtractedFact + { + Subject = "Alice", + Predicate = "works_at", + Object = "Contoso", + }, + ], + FilteredPreferences = + [ + new ExtractedPreference + { + Category = "style", + PreferenceText = "Prefers concise answers", + }, + ], + }; + + var result = await CreateSut( + new ExtractionOptions { FailureMode = IngestionFailureMode.FailFast }, transaction) + .PersistAsync(extraction, ownerId: "owner-a"); + + result.EntityCount.Should().Be(1); + result.FactCount.Should().Be(1); + result.PreferenceCount.Should().Be(1); + transaction.ExecutionCount.Should().Be(1); + transaction.IsOpen.Should().BeFalse(); + await _orchestrator.Received(3).EmbedAsync(Arg.Any(), Arg.Any()); + } + + private sealed class RecordingPersistenceTransaction : IMemoryPersistenceTransaction + { + public bool IsOpen { get; private set; } + public int ExecutionCount { get; private set; } + + public async Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + ExecutionCount++; + IsOpen = true; + try { return await work(cancellationToken); } + finally { IsOpen = false; } + } } } diff --git a/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTransactionCoalescingTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTransactionCoalescingTests.cs new file mode 100644 index 00000000..cac83344 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/PersistenceStageTransactionCoalescingTests.cs @@ -0,0 +1,297 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class PersistenceStageTransactionCoalescingTests +{ + [Fact] + public async Task PersistAsync_BestEffortSuccess_CoalescesLogicalOperation() + { + var entityRepository = Substitute.For(); + entityRepository + .UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + var transaction = new RecordingPersistenceTransaction(); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions + { + FailureMode = IngestionFailureMode.BestEffort, + UseCoalescedPersistenceTransactions = true, + }); + var extraction = EntityExtraction(withEmbedding: true); + + var result = await stage.PersistAsync(extraction, ownerId: "owner-1"); + + result.EntityCount.Should().Be(1); + transaction.ExecutionCount.Should().Be(1); + transaction.IsOpen.Should().BeFalse(); + await entityRepository.Received(1) + .UpsertAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void Coalescing_DefaultsOn() + { + new ExtractionOptions().UseCoalescedPersistenceTransactions.Should().BeTrue(); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task PersistAsync_DisabledOrUnsupported_RetainsLegacyPath( + bool optionEnabled, + bool supportsAtomicRollback) + { + var entityRepository = SuccessfulEntityRepository(); + var transaction = new RecordingPersistenceTransaction(supportsAtomicRollback); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions + { + FailureMode = IngestionFailureMode.BestEffort, + UseCoalescedPersistenceTransactions = optionEnabled, + }); + + var result = await stage.PersistAsync(EntityExtraction(withEmbedding: true)); + + result.EntityCount.Should().Be(1); + transaction.ExecutionCount.Should().Be(0); + await entityRepository.Received(1) + .UpsertAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_PreparesEmbeddingBeforeCoalescedTransaction() + { + var transaction = new RecordingPersistenceTransaction(); + var embedding = Substitute.For(); + embedding + .EmbedEntityAsync(Arg.Any(), Arg.Any()) + .Returns(new float[] { 1.0f, 0.0f }); + embedding + .When(provider => provider.EmbedEntityAsync( + Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeFalse()); + var entityRepository = SuccessfulEntityRepository(); + entityRepository + .When(repository => repository.UpsertAsync( + Arg.Any(), Arg.Any())) + .Do(_ => transaction.IsOpen.Should().BeTrue()); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions { FailureMode = IngestionFailureMode.BestEffort }, + embedding); + + await stage.PersistAsync(EntityExtraction(withEmbedding: false)); + + transaction.ExecutionCount.Should().Be(1); + await embedding.Received(1) + .EmbedEntityAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_ItemFailure_RollsBackThenReplaysLegacyPath() + { + var attempts = 0; + var entityRepository = Substitute.For(); + entityRepository + .UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("injected first-attempt failure"); + return call.Arg(); + }); + var transaction = new RecordingPersistenceTransaction(); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions { FailureMode = IngestionFailureMode.BestEffort }); + + var result = await stage.PersistAsync(EntityExtraction(withEmbedding: true)); + + result.Statuses().Should().NotContain(IngestionItemStatus.Failed); + result.EntityCount.Should().Be(1); + attempts.Should().Be(2); + transaction.ExecutionCount.Should().Be(1); + transaction.RollbackCount.Should().Be(1); + } + + [Fact] + public async Task PersistAsync_FailFastTransactionBoundaryFailure_PreservesIngestionContract() + { + var entityRepository = SuccessfulEntityRepository(); + var transaction = new FailingAfterWorkPersistenceTransaction(); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions { FailureMode = IngestionFailureMode.FailFast }); + + var act = () => stage.PersistAsync(EntityExtraction(withEmbedding: true)); + + var assertion = await act.Should().ThrowAsync(); + assertion.Which.InnerException.Should().BeOfType() + .Which.Message.Should().Contain("rollback could not be confirmed"); + transaction.ExecutionCount.Should().Be(1); + transaction.WorkExecutionCount.Should().Be(1); + await entityRepository.Received(1) + .UpsertAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PersistAsync_UncertainRollback_FailsClosedWithoutReplay() + { + var attempts = 0; + var entityRepository = Substitute.For(); + entityRepository + .UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + attempts++; + return Task.FromException(new InvalidOperationException("injected write failure")); + }); + var transaction = new RecordingPersistenceTransaction(failRollback: true); + var stage = CreateStage( + entityRepository, + transaction, + new ExtractionOptions { FailureMode = IngestionFailureMode.BestEffort }); + + var act = () => stage.PersistAsync(EntityExtraction(withEmbedding: true)); + + await act.Should().ThrowAsync() + .WithMessage("*rollback could not be confirmed*"); + attempts.Should().Be(1, "an uncertain transaction must never be replayed"); + transaction.ExecutionCount.Should().Be(1); + } + + private static IEntityRepository SuccessfulEntityRepository() + { + var repository = Substitute.For(); + repository + .UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + return repository; + } + + private static ExtractionStageResult EntityExtraction(bool withEmbedding) => new() + { + SourceMessageIds = [], + ResolvedEntityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Alice"] = new() + { + EntityId = "entity-1", + Name = "Alice", + Type = "Person", + Confidence = 0.99, + Embedding = withEmbedding ? [1.0f, 0.0f] : null, + CreatedAtUtc = DateTimeOffset.Parse("2026-08-04T12:00:00Z"), + }, + }, + }; + + private static PersistenceStage CreateStage( + IEntityRepository entityRepository, + IMemoryPersistenceTransaction transaction, + ExtractionOptions options, + IEmbeddingOrchestrator? embedding = null) + { + embedding ??= Substitute.For(); + var facts = Substitute.For(); + var preferences = Substitute.For(); + var relationships = Substitute.For(); + var clock = Substitute.For(); + var ids = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.Parse("2026-08-04T12:00:00Z")); + return new PersistenceStage( + embedding!, + entityRepository, + facts, + preferences, + relationships, + clock, + ids, + NullLogger.Instance, + transaction, + Options.Create(options)); + } + + private sealed class FailingAfterWorkPersistenceTransaction : IMemoryPersistenceTransaction + { + public bool SupportsAtomicRollback => true; + public int ExecutionCount { get; private set; } + public int WorkExecutionCount { get; private set; } + + public async Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ExecutionCount++; + _ = await work(cancellationToken); + WorkExecutionCount++; + throw new AggregateException( + "Atomic persistence failed and rollback could not be confirmed."); + } + } + + private sealed class RecordingPersistenceTransaction : IMemoryPersistenceTransaction + { + private readonly bool _failRollback; + + public RecordingPersistenceTransaction( + bool supportsAtomicRollback = true, + bool failRollback = false) + { + SupportsAtomicRollback = supportsAtomicRollback; + _failRollback = failRollback; + } + + public bool SupportsAtomicRollback { get; } + public bool IsOpen { get; private set; } + public int ExecutionCount { get; private set; } + public int RollbackCount { get; private set; } + + public async Task ExecuteAsync( + Func> work, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ExecutionCount++; + IsOpen = true; + try + { + return await work(cancellationToken); + } + catch (Exception ex) + { + RollbackCount++; + if (_failRollback) + throw new AggregateException( + "Atomic persistence failed and rollback could not be confirmed.", ex); + throw; + } + finally { IsOpen = false; } + } + } +} + +file static class PersistenceResultAssertions +{ + public static IEnumerable Statuses(this PersistenceResult result) => + result.Outcomes.Select(outcome => outcome.Status); +} diff --git a/tests/AgentMemory.Tests.Unit/Extraction/UnifiedExtractionStageTests.cs b/tests/AgentMemory.Tests.Unit/Extraction/UnifiedExtractionStageTests.cs new file mode 100644 index 00000000..a8010647 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Extraction/UnifiedExtractionStageTests.cs @@ -0,0 +1,204 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Resolution; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace AgentMemory.Tests.Unit.Extraction; + +public sealed class UnifiedExtractionStageTests +{ + private static readonly IReadOnlyList Messages = + [ + new Message + { + MessageId = "message-1", + ConversationId = "conversation-1", + SessionId = "session-1", + Role = "user", + Content = "Alice knows Bob and prefers tea.", + TimestampUtc = DateTimeOffset.UtcNow, + }, + ]; + + [Fact] + public async Task EnabledUnifiedExtractor_ReplacesAllCategoryExtractors() + { + var entity = Substitute.For(); + var fact = Substitute.For(); + var preference = Substitute.For(); + var relationship = Substitute.For(); + var unified = Substitute.For(); + unified.IsEnabled.Returns(true); + unified.ExtractAsync(Arg.Any>(), Arg.Any()) + .Returns(CompleteResult()); + + var sut = CreateSut( + unified, + entityExtractors: [entity], + factExtractors: [fact], + preferenceExtractors: [preference], + relationshipExtractors: [relationship]); + + var result = await sut.ExtractAsync(Messages, ExtractionTypes.All); + + await unified.Received(1).ExtractAsync(Messages, Arg.Any()); + await entity.DidNotReceive().ExtractAsync(Arg.Any>(), Arg.Any()); + await fact.DidNotReceive().ExtractAsync(Arg.Any>(), Arg.Any()); + await preference.DidNotReceive().ExtractAsync(Arg.Any>(), Arg.Any()); + await relationship.DidNotReceive().ExtractAsync(Arg.Any>(), Arg.Any()); + result.RawEntities.Should().HaveCount(2); + result.RawFacts.Should().HaveCount(2); + result.RawPreferences.Should().ContainSingle(); + result.RawRelationships.Should().ContainSingle(); + result.FilteredRelationships.Should().ContainSingle(); + } + + [Fact] + public async Task DisabledUnifiedExtractor_PreservesCategoryPath() + { + var entity = Substitute.For(); + entity.ExtractAsync(Arg.Any>(), Arg.Any()) + .Returns([Entity("Alice")]); + var unified = Substitute.For(); + unified.IsEnabled.Returns(false); + var sut = CreateSut(unified, entityExtractors: [entity]); + + var result = await sut.ExtractAsync(Messages, ExtractionTypes.Entities); + + await unified.DidNotReceive().ExtractAsync(Arg.Any>(), Arg.Any()); + await entity.Received(1).ExtractAsync(Messages, Arg.Any()); + result.RawEntities.Should().ContainSingle(); + } + + [Fact] + public async Task UnifiedExtractor_RespectsRequestedTypes() + { + var unified = Substitute.For(); + unified.IsEnabled.Returns(true); + unified.ExtractAsync(Arg.Any>(), Arg.Any()) + .Returns(CompleteResult()); + var sut = CreateSut(unified); + + var result = await sut.ExtractAsync(Messages, ExtractionTypes.Facts); + + result.RawEntities.Should().BeEmpty(); + result.RawFacts.Should().HaveCount(2); + result.RawPreferences.Should().BeEmpty(); + result.RawRelationships.Should().BeEmpty(); + } + + [Fact] + public async Task UnifiedFailure_BestEffortRecordsEveryRequestedCategory() + { + var unified = Substitute.For(); + unified.IsEnabled.Returns(true); + unified.ExtractAsync(Arg.Any>(), Arg.Any()) + .ThrowsAsync(new FormatException("invalid unified response")); + var sut = CreateSut(unified); + + var result = await sut.ExtractAsync(Messages, ExtractionTypes.All); + + result.Outcomes.Should().HaveCount(4); + result.Outcomes.Should().OnlyContain(outcome => + outcome.Stage == IngestionStage.Extraction && + outcome.Status == IngestionItemStatus.Failed && + outcome.Retryable); + result.Outcomes.Should().ContainSingle(outcome => outcome.Kind == MemoryItemKind.Entity); + result.Outcomes.Should().ContainSingle(outcome => outcome.Kind == MemoryItemKind.Fact); + result.Outcomes.Should().ContainSingle(outcome => outcome.Kind == MemoryItemKind.Preference); + result.Outcomes.Should().ContainSingle(outcome => outcome.Kind == MemoryItemKind.Relationship); + } + + [Fact] + public async Task UnifiedFailure_FailFastCarriesAllRequestedOutcomes() + { + var unified = Substitute.For(); + unified.IsEnabled.Returns(true); + unified.ExtractAsync(Arg.Any>(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("provider unavailable")); + var sut = CreateSut(unified, new ExtractionOptions { FailureMode = IngestionFailureMode.FailFast }); + + var act = () => sut.ExtractAsync(Messages, ExtractionTypes.All); + + var exception = await act.Should().ThrowAsync(); + exception.Which.CompletedOutcomes.Should().HaveCount(4); + } + + private static ExtractionStage CreateSut( + IUnifiedMemoryExtractor unified, + ExtractionOptions? options = null, + IEnumerable? entityExtractors = null, + IEnumerable? factExtractors = null, + IEnumerable? preferenceExtractors = null, + IEnumerable? relationshipExtractors = null) + { + var resolver = Substitute.For(); + resolver.ResolveEntityAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + var extracted = call.Arg(); + return new Entity + { + EntityId = $"entity-{extracted.Name.ToLowerInvariant()}", + Name = extracted.Name, + Type = extracted.Type, + Confidence = extracted.Confidence, + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + }); + + return new ExtractionStage( + entityExtractors ?? [], + factExtractors ?? [], + preferenceExtractors ?? [], + relationshipExtractors ?? [], + [unified], + resolver, + Options.Create(options ?? new ExtractionOptions()), + NullLogger.Instance); + } + + private static UnifiedExtractionResult CompleteResult() => + new() + { + Entities = [Entity("Alice"), Entity("Bob")], + Facts = + [ + new ExtractedFact { Subject = "Alice", Predicate = "knows", Object = "Bob", Confidence = 0.9 }, + new ExtractedFact { Subject = "Alice", Predicate = "likes", Object = "tea", Confidence = 0.9 }, + ], + Preferences = + [ + new ExtractedPreference { Category = "drink", PreferenceText = "tea", Confidence = 0.9 }, + ], + Relationships = + [ + new ExtractedRelationship + { + SourceEntity = "Alice", + TargetEntity = "Bob", + RelationshipType = "KNOWS", + Confidence = 0.9, + }, + ], + }; + + private static ExtractedEntity Entity(string name) => + new() + { + Name = name, + Type = "PERSON", + Confidence = 0.95, + }; +} diff --git a/tests/AgentMemory.Tests.Unit/GraphRagAdapter/DefaultProjectionTests.cs b/tests/AgentMemory.Tests.Unit/GraphRagAdapter/DefaultProjectionTests.cs new file mode 100644 index 00000000..e383440d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/GraphRagAdapter/DefaultProjectionTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using AgentMemory.Neo4j.Retrieval.Internal; +using Neo4j.Driver; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.GraphRagAdapter; + +/// +/// K10. What does GraphRAG put in the prompt when pointed at a memory-native index? +/// +/// +/// takes its display text from the node's +/// text property, falls back to content, and finally to node.ToString(). None of +/// the memory layer's own node kinds carry text or content: a Fact has +/// subject/predicate/object and, like every embedded kind, an embedding. +/// The last-resort branch is therefore the only branch reachable for a Fact. +/// +/// These tests pin what this code decides — which branch is taken, and what survives the mapping. +/// They deliberately do not assert what the serialised node looks like: that is the Neo4j +/// driver's Node.ToString(), not ours, and a fake node's rendering would only be evidence +/// about the fake. The real rendering is observed in the live K6 run. +/// +/// +public sealed class DefaultProjectionTests +{ + private const string NodeRendering = "<>"; + + [Fact] + public void AFactNodeFallsBackToSerialisingTheWholeNode() + { + var item = RetrieverRecordMapper.FromNodeScore(FactRecord()); + + // Not the readable triple a reader would expect from a "context passage" - the prompt gets + // the driver's dump of the entire node, embedding property included. + item.Content.Should().NotContain("Alice likes coffee"); + item.Content.Should().Be(NodeRendering); + } + + [Fact] + public void APropertyNamedTextWouldHaveBeenPreferred() + { + // The control: the fallback above is a consequence of Fact's property set, not of the mapper + // being unable to find text. Nothing needs fixing in the mapper. + var item = RetrieverRecordMapper.FromNodeScore(FactRecord(("text", "Alice likes coffee"))); + + item.Content.Should().Be("Alice likes coffee"); + } + + [Fact] + public void NoNodeIdentitySurvivesTheMapping() + { + // Metadata carries score and nothing else, so an item cannot be traced back to the node it + // came from. This is why GraphRagContextItem.SourceNodeIds stays empty on the real Neo4j + // path however carefully the assembler preserves the items (K4). + var item = RetrieverRecordMapper.FromNodeScore(FactRecord()); + + item.Metadata.Should().ContainKey("score"); + item.Metadata!.Keys.Should().NotContain("id"); + } + + private static IRecord FactRecord(params (string Key, object Value)[] extraProperties) + { + var properties = new Dictionary + { + ["id"] = "fact-1", + ["subject"] = "Alice", + ["predicate"] = "likes", + ["object"] = "coffee", + ["embedding"] = new List { 0.101d, 0.202d, 0.303d } + }; + foreach (var (key, value) in extraProperties) + properties[key] = value; + + var record = Substitute.For(); + record["node"].Returns(new StubNode(properties)); + record["score"].Returns(0.87d); + return record; + } + + /// A hand-written node, because ToString() cannot be stubbed on a substitute. + private sealed class StubNode(IReadOnlyDictionary properties) : INode + { + public IReadOnlyDictionary Properties { get; } = properties; + public object this[string key] => Properties[key]; + public IReadOnlyList Labels { get; } = ["Fact"]; + public long Id => 1; + public string ElementId => "4:x:1"; + public bool Equals(INode? other) => ReferenceEquals(this, other); + public T Get(string key) => (T)Properties[key]; + + public bool TryGet(string key, out T value) + { + if (Properties.TryGetValue(key, out var raw) && raw is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + + public override string ToString() => NodeRendering; + } +} diff --git a/tests/AgentMemory.Tests.Unit/GraphRagAdapter/GraphRagOwnerIsolationTests.cs b/tests/AgentMemory.Tests.Unit/GraphRagAdapter/GraphRagOwnerIsolationTests.cs new file mode 100644 index 00000000..e5766098 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/GraphRagAdapter/GraphRagOwnerIsolationTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Exceptions; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using AgentMemory.Neo4j.Infrastructure; +using AgentMemory.Neo4j.Retrieval; +using AgentMemory.Neo4j.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.GraphRagAdapter; + +/// +/// K7. Owner isolation on a retrieval path that has never been exercised end to end. +/// +/// +/// GraphRAG has carried a recall budget of zero in every quality measurement, and a path nothing ever +/// runs is where an isolation gap survives longest. Two specifics make it worth checking rather than +/// assuming: IMemoryIsolationPolicy is an optional dependency here, so a composition +/// registering the Neo4j package without Core has no policy at all; and an unscoped owner produces a +/// query that is byte-for-byte the legacy unscoped one, which returns every owner's nodes. +/// +/// The load-bearing property is therefore that StrictMultiTenant throws rather than quietly +/// falling back to an unscoped query — and that the throw is not swallowed by the source's +/// best-effort error handling, which deliberately converts genuine retrieval failures into empty +/// results. +/// +/// +public sealed class GraphRagOwnerIsolationTests +{ + private readonly IRetriever _retriever = Substitute.For(); + + public GraphRagOwnerIsolationTests() => + _retriever.SearchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new RetrieverResult([])); + + [Fact] + public async Task TheRequestingOwnerReachesTheRetriever() + { + await CreateSut(Strict()).GetContextAsync(Request("owner-a")).ConfigureAwait(true); + + await _retriever.Received(1).SearchAsync( + Arg.Any(), Arg.Any(), "owner-a", Arg.Any()); + } + + [Fact] + public async Task OneOwnerIdIsNeverSubstitutedForAnother() + { + await CreateSut(Strict()).GetContextAsync(Request("owner-b")).ConfigureAwait(true); + + await _retriever.DidNotReceive().SearchAsync( + Arg.Any(), Arg.Any(), "owner-a", Arg.Any()); + } + + [Fact] + public async Task StrictMultiTenantFailsClosedOnAnUnscopedRead() + { + // The property that matters most: an unscoped tenant read must throw, not silently become a + // query that returns every owner's nodes. + var act = () => CreateSut(Strict()).GetContextAsync(Request(userId: null)); + + await act.Should().ThrowAsync().ConfigureAwait(true); + } + + [Fact] + public async Task TheIsolationFailureIsNotSwallowedIntoAnEmptyResult() + { + // The source deliberately converts retrieval failures into a best-effort empty result. An + // isolation violation must not be laundered through that path: returning nothing would look + // identical to a legitimate miss, and the caller would never learn it was unscoped. + var act = () => CreateSut(Strict()).GetContextAsync(Request(userId: null)); + + await act.Should().NotThrowAsync().ConfigureAwait(true); + await _retriever.DidNotReceive().SearchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + private Neo4jGraphRagContextSource CreateSut(IMemoryIsolationPolicy? policy) => + new(_retriever, + new GraphRagOptions { IndexName = "idx", TopK = 5 }, + NullLogger.Instance, + policy); + + private static IMemoryIsolationPolicy Strict() => + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions { Mode = MemoryIsolationMode.StrictMultiTenant }), + NullLogger.Instance); + + private static GraphRagContextRequest Request(string? userId) => new() + { + SessionId = "s", + Query = "q", + UserId = userId + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Infrastructure/AbstractionsContractGuardTests.cs b/tests/AgentMemory.Tests.Unit/Infrastructure/AbstractionsContractGuardTests.cs index 60551ec9..34722d04 100644 --- a/tests/AgentMemory.Tests.Unit/Infrastructure/AbstractionsContractGuardTests.cs +++ b/tests/AgentMemory.Tests.Unit/Infrastructure/AbstractionsContractGuardTests.cs @@ -16,9 +16,9 @@ public sealed class AbstractionsContractGuardTests private static readonly Assembly Abstractions = typeof(IMemoryService).Assembly; // Counts mirrored in docs/architecture.md §3.1 and docs/design.md §5/§6. - private const int DocumentedServiceInterfaces = 39; // R1b store + IC8 owner contexts, +IConsolidationService (PR#113), +IConflictDetectionService, +IMemoryRankingContext/+IWritable (D3), +IMemoryIsolationPolicy (#100) + private const int DocumentedServiceInterfaces = 41; // +IMultiSessionUnifiedMemoryExtractor (M-27-V2 LAB-B1) private const int DocumentedRepositoryInterfaces = 11; - private const int DocumentedDomainRecords = 49; // +ToolCallStats (PR2), +IngestionItemOutcome (#101) + private const int DocumentedDomainRecords = 51; // +UnifiedExtractionResult (M-27-V2 LAB-U1) private const int DocumentedEnums = 24; // +MemoryProfile, +RankingIntent, +DuplicateStatus, +EntityMatchType, +MemoryNodeKind, +MemoryOperationAccess, +MemoryIsolationMode (#100); +IngestionStatus, +IngestionStage, +IngestionItemStatus, +MemoryItemKind, +IngestionFailureMode (#101); +MemoryTrustLevel (#92 Phase 3) private static IEnumerable PublicTypes() => diff --git a/tests/AgentMemory.Tests.Unit/Infrastructure/CanonicalKeyBackfillWiringTests.cs b/tests/AgentMemory.Tests.Unit/Infrastructure/CanonicalKeyBackfillWiringTests.cs new file mode 100644 index 00000000..42432d63 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Infrastructure/CanonicalKeyBackfillWiringTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Infrastructure; + +/// +/// Phase 1.1 wiring. The backfill queries existed for a commit without anything calling them — the +/// failure mode that shipped twice this session (the fused write path, and BuildSystemPrompt). This +/// asserts the call site and its ordering, not the capability. +/// +public sealed class CanonicalKeyBackfillWiringTests +{ + [Fact] + public void BootstrapInvokesTheBackfill() + { + Source().Should().Contain("BackfillCanonicalFactKeysAsync(CanonicalKeyBackfillBatchSize"); + } + + [Fact] + public void TheBackfillRunsBeforeBootstrapReportsCompletion() + { + // Ordering is the defect's whole substance: a fact written between upgrade and backfill + // MERGEs onto a fresh node and duplicates regardless. + var source = Source(); + var call = source.IndexOf("await BackfillCanonicalFactKeysAsync", StringComparison.Ordinal); + var complete = source.IndexOf("Schema bootstrap complete.", StringComparison.Ordinal); + + call.Should().BeGreaterThan(0); + call.Should().BeLessThan(complete, "the backfill must precede any repository write"); + } + + [Fact] + public void TheBackfillIsBounded() + { + // An unbounded migration would attempt one transaction over an entire store. + Source().Should().Contain("CanonicalKeyBackfillBatchSize = "); + } + + [Fact] + public void CanonicalFormsAreComputedInDotNetNotInCypher() + { + // toLower() and ToLowerInvariant() disagree on U+0130, so a Cypher-side computation would + // write keys the write path never matches — silently reintroducing the duplication. + var source = Source(); + var start = source.IndexOf("BackfillCanonicalFactKeysAsync", StringComparison.Ordinal); + var body = source[start..]; + + body.Should().Contain("MemoryTripleCanonicalizer.Canonical("); + body.Should().Contain("MemoryTripleCanonicalizer.CanonicalValue("); + } + + private static string Source() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src"))) + directory = directory.Parent; + + directory.Should().NotBeNull(); + return File.ReadAllText(Path.Combine( + directory!.FullName, "src", "AgentMemory.Neo4j", "Infrastructure", "SchemaBootstrapper.cs")); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jPersistenceTransactionFallbackTests.cs b/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jPersistenceTransactionFallbackTests.cs new file mode 100644 index 00000000..7e188bd2 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jPersistenceTransactionFallbackTests.cs @@ -0,0 +1,80 @@ +using AgentMemory.Core.Extraction; +using AgentMemory.Neo4j.Infrastructure; +using FluentAssertions; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Infrastructure; + +/// +/// A host that substitutes its own transaction runner must not be broken by an upgrade. +/// +/// +/// INeo4jTransactionRunner is public and registered with TryAddSingleton — the +/// standard signal that a host may replace it. Neo4jMemoryPersistenceTransaction is then +/// registered with Replace, so it receives whatever the host supplied, and it used to hard-cast +/// that to the later-added INeo4jAtomicTransactionRunner and throw on failure. A substitution +/// that was legal when written became a startup crash on upgrade, with no compile-time warning. +/// +/// Atomicity is optional by design — that is why SupportsAtomicRollback exists and why +/// PersistenceStage branches on it — so the correct behaviour is honest degradation, not a +/// refusal to start and not a false claim of rollback. +/// +/// +public sealed class Neo4jPersistenceTransactionFallbackTests +{ + public interface IAtomicRunner : INeo4jTransactionRunner, INeo4jAtomicTransactionRunner; + + [Fact] + public async Task ANonAtomicRunnerStillConstructsAndRunsTheWork() + { + // The load-bearing case: this threw before, taking down startup for a host that had legally + // replaced a public, TryAdd-registered seam. + var sut = new Neo4jMemoryPersistenceTransaction(Substitute.For()); + + var ran = false; + var result = await sut.ExecuteAsync(_ => { ran = true; return Task.FromResult(42); }) + .ConfigureAwait(true); + + ran.Should().BeTrue(); + result.Should().Be(42); + } + + [Fact] + public void ANonAtomicRunnerReportsNoRollbackRatherThanClaimingIt() + { + // Degrading silently while still advertising atomicity would be worse than throwing: + // PersistenceStage would skip its own compensation path believing the store had it covered. + new Neo4jMemoryPersistenceTransaction(Substitute.For()) + .SupportsAtomicRollback.Should().BeFalse(); + } + + [Fact] + public async Task AnAtomicRunnerIsStillUsedForTheTransaction() + { + // The capability must not be lost in the process of making it optional. + var runner = Substitute.For(); + runner.ExecuteAtomicWriteAsync(Arg.Any>>(), + Arg.Any()) + .Returns(Task.FromResult(7)); + + var sut = new Neo4jMemoryPersistenceTransaction(runner); + + sut.SupportsAtomicRollback.Should().BeTrue(); + (await sut.ExecuteAsync(_ => Task.FromResult(1)).ConfigureAwait(true)).Should().Be(7); + await runner.Received(1).ExecuteAtomicWriteAsync( + Arg.Any>>(), Arg.Any()); + } + + [Fact] + public async Task CancellationIsHonouredOnThePassThroughPath() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync().ConfigureAwait(true); + var sut = new Neo4jMemoryPersistenceTransaction(Substitute.For()); + + var act = () => sut.ExecuteAsync(_ => Task.FromResult(1), cts.Token); + + await act.Should().ThrowAsync().ConfigureAwait(true); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jTransactionRunnerTests.cs b/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jTransactionRunnerTests.cs index e551006a..25d6725e 100644 --- a/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jTransactionRunnerTests.cs +++ b/tests/AgentMemory.Tests.Unit/Infrastructure/Neo4jTransactionRunnerTests.cs @@ -169,4 +169,98 @@ await runner.ReadAsync(async tx => because: "raw Cypher can contain parameters and must never enter telemetry artifacts"); } } + + [Fact] + public async Task ReadAsync_TransactionSpanReportsLabelledEntryDelayEstimate() + { + var (runner, factory) = Create(); + var session = Substitute.For(); + var driverRunner = Substitute.For(); + factory.OpenSession(AccessMode.Read).Returns(session); + session + .ExecuteReadAsync(Arg.Any>>()) + .Returns(async call => + { + await Task.Delay(25); + return await call.Arg>>()(driverRunner); + }); + + Activity? transaction = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == AgentMemoryDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (activity.OperationName == "memory.db.tx") + transaction = activity; + }, + }; + ActivitySource.AddActivityListener(listener); + + await runner.ReadAsync(_ => Task.FromResult(42)); + + transaction.Should().NotBeNull(); + transaction!.GetTagItem("db.transaction_entry_ms_est") + .Should().BeOfType().Which.Should().BeGreaterThan(10); + } + + [Fact] + public async Task ExecuteAtomicWriteAsync_RepositoryCallsJoinOneExplicitTransaction() + { + var (runner, factory) = Create(); + var session = Substitute.For(); + var transaction = Substitute.For(); + factory.OpenSession(AccessMode.Write).Returns(session); + session.BeginTransactionAsync().Returns(transaction); + + var result = await runner.ExecuteAtomicWriteAsync(async cancellationToken => + { + var writeResult = await runner.WriteAsync(queryRunner => + { + queryRunner.Should().BeSameAs(transaction); + return Task.FromResult(20); + }, cancellationToken); + var readResult = await runner.ReadAsync(queryRunner => + { + queryRunner.Should().BeSameAs(transaction); + return Task.FromResult(22); + }, cancellationToken); + return writeResult + readResult; + }); + + result.Should().Be(42); + factory.Received(1).OpenSession(AccessMode.Write); + await session.Received(1).BeginTransactionAsync(); + await transaction.Received(1).CommitAsync(); + await transaction.DidNotReceive().RollbackAsync(); + await session.DidNotReceive().ExecuteWriteAsync(Arg.Any>>()); + await session.DidNotReceive().ExecuteReadAsync(Arg.Any>>()); + } + + [Fact] + public async Task ExecuteAtomicWriteAsync_CallbackFailure_RollsBackAndDoesNotCommit() + { + var (runner, factory) = Create(); + var session = Substitute.For(); + var transaction = Substitute.For(); + factory.OpenSession(AccessMode.Write).Returns(session); + session.BeginTransactionAsync().Returns(transaction); + + var act = async () => await runner.ExecuteAtomicWriteAsync(async cancellationToken => + { + await runner.WriteAsync(queryRunner => + { + queryRunner.Should().BeSameAs(transaction); + return Task.CompletedTask; + }, cancellationToken); + throw new InvalidOperationException("injected persistence failure"); + }); + + await act.Should().ThrowAsync() + .WithMessage("injected persistence failure"); + await transaction.Received(1).RollbackAsync(); + await transaction.DidNotReceive().CommitAsync(); + } } diff --git a/tests/AgentMemory.Tests.Unit/Infrastructure/SchemaBootstrapperTests.cs b/tests/AgentMemory.Tests.Unit/Infrastructure/SchemaBootstrapperTests.cs index e054bafa..ba165018 100644 --- a/tests/AgentMemory.Tests.Unit/Infrastructure/SchemaBootstrapperTests.cs +++ b/tests/AgentMemory.Tests.Unit/Infrastructure/SchemaBootstrapperTests.cs @@ -25,9 +25,24 @@ private static SchemaBootstrapper CreateBootstrapper( } private static void StubWriteRunner(INeo4jTransactionRunner txRunner) - => txRunner + { + txRunner .WriteAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); + } + + /// + /// Bootstrap now also backfills canonical fact keys, which reads a page of unkeyed facts. An + /// already-migrated store returns none, which is the state every one of these DDL tests assumes. + /// + private static void StubEmptyCanonicalKeyBackfill(INeo4jTransactionRunner txRunner) + { + txRunner + .ReadAsync( + Arg.Any>>>(), + Arg.Any()) + .Returns(Task.FromResult(new List())); + } private static void StubVectorIndexRead( INeo4jTransactionRunner txRunner, params VectorIndexDimension[] indexes) @@ -41,6 +56,7 @@ private static void StubVectorIndexRead( public async Task BootstrapAsync_ExecutesExpectedTotalNumberOfStatements() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner @@ -70,6 +86,7 @@ public async Task BootstrapAsync_ExecutesExpectedTotalNumberOfStatements() public async Task BootstrapAsync_ExecutesAllConstraints() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner @@ -109,6 +126,7 @@ public async Task BootstrapAsync_ExecutesAllConstraints() public async Task BootstrapAsync_ExecutesAllFulltextIndexes() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner @@ -139,6 +157,7 @@ public async Task BootstrapAsync_ExecutesAllFulltextIndexes() public async Task BootstrapAsync_ExecutesAllVectorIndexes() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner @@ -172,6 +191,7 @@ public async Task BootstrapAsync_ExecutesAllVectorIndexes() public async Task BootstrapAsync_ExecutesAllPropertyIndexes() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner @@ -275,6 +295,7 @@ public void Neo4jOptions_ValidateVectorIndexDimensions_DefaultsToTrue() public async Task BootstrapAsync_WhenExistingVectorIndexDimensionsMatch_DoesNotThrow() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); StubWriteRunner(txRunner); StubVectorIndexRead(txRunner, new VectorIndexDimension("fact_embedding_idx", 1536), @@ -289,6 +310,7 @@ public async Task BootstrapAsync_WhenExistingVectorIndexDimensionsMatch_DoesNotT public async Task BootstrapAsync_WhenExistingVectorIndexDimensionsMismatch_Throws() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); StubWriteRunner(txRunner); StubVectorIndexRead(txRunner, new VectorIndexDimension("fact_embedding_idx", 1536), @@ -306,6 +328,7 @@ public async Task BootstrapAsync_WhenExistingVectorIndexDimensionsMismatch_Throw public async Task BootstrapAsync_WhenValidationDisabled_SkipsTheReadAndDoesNotThrow() { var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); StubWriteRunner(txRunner); // Even with a mismatch staged, validation is off, so it must not be consulted. StubVectorIndexRead(txRunner, new VectorIndexDimension("fact_embedding_idx", 3072)); @@ -325,6 +348,7 @@ public async Task BootstrapAsync_VectorIndexesUseConfiguredDimensions() { const int customDimensions = 3072; var txRunner = Substitute.For(); + StubEmptyCanonicalKeyBackfill(txRunner); var executedStatements = new List(); txRunner diff --git a/tests/AgentMemory.Tests.Unit/Memory/MemoryPredicateVocabularyTests.cs b/tests/AgentMemory.Tests.Unit/Memory/MemoryPredicateVocabularyTests.cs new file mode 100644 index 00000000..4c592537 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/MemoryPredicateVocabularyTests.cs @@ -0,0 +1,93 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// G3B.14. Canonicalization collapses spelling; it cannot collapse phrasing. Measured on a live +/// graph, one real-world event — a birth — was expressed as was born, was born in, +/// were born in, had and welcomed, so retrieving any single relation gathered at +/// most 3 of 5. The extractor invents a predicate per sentence because nothing tells it which +/// relations already exist. This is that vocabulary. +/// +public sealed class MemoryPredicateVocabularyTests +{ + [Fact] + public void AFamiliarRelationReusesTheEstablishedPredicate() + { + var vocabulary = new MemoryPredicateVocabulary(); + + var first = vocabulary.Admit("was_born"); + var second = vocabulary.Admit("Was Born"); + + second.Should().Be(first, "spelling variants must resolve to one established relation"); + vocabulary.Count.Should().Be(1); + } + + [Fact] + public void TheFirstSpellingWinsSoTheVocabularyIsStable() + { + // Later arrivals must not rewrite an established predicate, or the graph's relation names + // would drift between runs and no query could rely on them. + var vocabulary = new MemoryPredicateVocabulary(); + + vocabulary.Admit("was_born").Should().Be("was_born"); + vocabulary.Admit("WAS BORN").Should().Be("was_born"); + } + + [Fact] + public void GenuinelyDifferentRelationsAreBothAdmitted() + { + // Deterministic only. "bought" and "sold" are one embedding threshold apart and opposite in + // meaning; nothing here may fold them together. + var vocabulary = new MemoryPredicateVocabulary(); + + vocabulary.Admit("bought"); + vocabulary.Admit("sold"); + vocabulary.Admit("likes"); + vocabulary.Admit("dislikes"); + + vocabulary.Count.Should().Be(4); + } + + [Fact] + public void TheVocabularyIsOfferedToTheExtractorInAStableOrder() + { + // It is injected into a prompt, so a set whose order changed per call would make extraction + // non-reproducible for reasons unrelated to the model. + var vocabulary = new MemoryPredicateVocabulary(); + vocabulary.Admit("welcomed"); + vocabulary.Admit("was_born"); + vocabulary.Admit("had"); + + vocabulary.Snapshot().Should().Equal(vocabulary.Snapshot()); + vocabulary.Snapshot().Should().BeInAscendingOrder(); + } + + [Fact] + public void GrowthIsBoundedSoAPathologicalRunCannotExhaustThePrompt() + { + // Without a cap the vocabulary grows toward one predicate per fact — measured at 421 for 700 + // facts — and injecting that would consume the extraction budget it is meant to improve. + var vocabulary = new MemoryPredicateVocabulary(maximumSize: 3); + + vocabulary.Admit("one"); + vocabulary.Admit("two"); + vocabulary.Admit("three"); + var overflow = vocabulary.Admit("four"); + + vocabulary.Count.Should().Be(3); + overflow.Should().Be("four", "an unadmitted predicate is still usable, just not established"); + vocabulary.Snapshot().Should().NotContain("four"); + } + + [Fact] + public void BlankPredicatesAreRejectedRatherThanEstablished() + { + var vocabulary = new MemoryPredicateVocabulary(); + + vocabulary.Admit(" ").Should().BeEmpty(); + vocabulary.Count.Should().Be(0); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Memory/MemoryRelationLexiconTests.cs b/tests/AgentMemory.Tests.Unit/Memory/MemoryRelationLexiconTests.cs new file mode 100644 index 00000000..31d6e9cb --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/MemoryRelationLexiconTests.cs @@ -0,0 +1,252 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// J2.1. A query-side map from the verbs a question uses to the canonical predicates a graph stores. +/// +/// +/// Read-side only, and that asymmetry is the whole safety argument: predicate clustering over stored +/// facts was rejected because merging bought onto sold corrupts meaning irreversibly, +/// whereas a wrong entry here costs precision on one query and never alters a stored fact. +/// +public sealed class MemoryRelationLexiconTests +{ + private static MemoryRelationLexicon Lexicon => MemoryRelationLexicon.Default; + + [Fact] + public void TheFourVerbsOfTheKnownFailingQuestionResolveToFourDistinctRelations() + { + // gpt4_15e38248: "How many pieces of furniture did I buy, assemble, sell, or fix". Expansion + // only ever pulled predicates that similarity happened to surface, so three of the four were + // never expanded. This is the J1.5 acceptance case, checked at the layer that resolves them. + var resolved = Lexicon.ResolveQuestion( + "How many pieces of furniture did I buy, assemble, sell, or fix this year?"); + + resolved.Should().HaveCount(4); + resolved.Should().OnlyHaveUniqueItems(); + } + + [Theory] + [InlineData("buy", "bought")] + [InlineData("buys", "bought")] + [InlineData("buying", "bought")] + [InlineData("bought", "bought")] + [InlineData("purchased", "bought")] + [InlineData("assemble", "assembled")] + [InlineData("assembling", "assembled")] + [InlineData("fix", "fixed")] + [InlineData("repaired", "fixed")] + [InlineData("sell", "sold")] + public void InflectionsAndSynonymsResolveToOneCanonicalRelation(string surface, string canonical) => + Lexicon.Resolve(surface).Should().Be(canonical); + + [Theory] + // The safety property that must never regress. These are one embedding threshold apart and mean + // opposite things; collapsing them would invert a fact at read time. + [InlineData("bought", "sold")] + [InlineData("likes", "dislikes")] + [InlineData("borrowed", "lent")] + [InlineData("gave", "received")] + public void OpposingRelationsNeverResolveTogether(string left, string right) => + Lexicon.Resolve(left).Should().NotBe(Lexicon.Resolve(right)); + + [Fact] + public void AnUnknownVerbResolvesToNothingSoTheCallerCanFallBack() + { + // The fallback to today's top-K-derived predicates is what makes this change unable to be + // worse than current behaviour, and it depends on an honest miss. + Lexicon.Resolve("defenestrated").Should().BeNull(); + } + + [Fact] + public void NoSurfaceFormMapsToTwoCanonicalRelations() + { + // Ambiguity is dropped, never guessed. The shipped table must contain none at all, so this + // asserts the drop list is empty rather than merely that lookups are single-valued. + MemoryRelationLexicon.Default.AmbiguousSurfaceForms.Should().BeEmpty(); + } + + [Fact] + public void EveryCanonicalRelationIsAlreadyInStoredPredicateKeyForm() + { + // Resolution is worthless if it produces keys the graph cannot match. Stored predicate_key is + // lowercase with separators folded to single spaces. + foreach (var canonical in MemoryRelationLexicon.Default.CanonicalRelations) + { + canonical.Should().Be(MemoryTripleCanonicalizer.Canonical(canonical)); + canonical.Should().NotContain("_").And.NotContain(" "); + } + } + + [Fact] + public void MultiWordRelationsAreResolved() + { + // Measured in the real graph: "is interested in" (50 facts), "asked about" (38), + // "works at" (16). A single-token harvest would miss all of them. + Lexicon.Resolve("is interested in").Should().Be("is interested in"); + Lexicon.ResolveQuestion("What did I ask about and what am I interested in?") + .Should().Contain("asked about").And.Contain("is interested in"); + } + + [Fact] + public void ShortWordsAreNotDestroyedByStemming() + { + // "is" is the single most common predicate in the measured graph at 1,213 facts, 26% of all + // of them. Naive -s stripping would reduce it to "i" and lose a quarter of the graph. + MemoryTripleCanonicalizer.Canonical("is").Should().Be("is"); + Lexicon.StoredFormsOf("is").Should().Contain("is").And.Contain("was"); + } + + [Theory] + // Independent review finding, verified against 50 natural questions: these fire on almost every + // question a person asks, and each one expands `is` - 1,213 facts, 26% of the measured graph - + // consuming the shared expansion budget before any correct relation is reached. They cannot + // simply be deleted, because expansion still needs them as stored predicate keys. + [InlineData("is")] + [InlineData("was")] + [InlineData("were")] + [InlineData("have")] + [InlineData("had")] + [InlineData("been")] + public void CopulasDoNotTriggerRetrievalFromAQuestion(string form) => + Lexicon.Resolve(form).Should().BeNull(); + + [Fact] + public void CopulasAreStillReachableAsStoredKeys() + { + // The other half of the same requirement: a fact stored under "was" must still be fetched + // when `is` is expanded, or the split would lose data rather than protect the budget. + Lexicon.StoredFormsOf("is").Should().Contain("was").And.Contain("were"); + Lexicon.StoredFormsOf("has").Should().Contain("had"); + } + + [Theory] + // Independent review, verified: the stop list was checked against the raw form only, then the + // stemmer looked up its result WITHOUT re-checking it. So a suppressed bare verb was handed + // straight back through its own inflections - "Let me know if that works" resolved `works at`, + // pulling 28 predicate keys into a 100-fact budget. This silently weakened every suppression + // decision in the vocabulary, including any made later. + [InlineData("works")] + [InlineData("working")] + [InlineData("needing")] + public void SuppressionSurvivesTheStemmer(string form) => + Lexicon.Resolve(form).Should().BeNull(); + + [Fact] + public void SuppressedVerbsAreStillReachableThroughTheirQuestionAnchor() + { + // The other half: suppression must cost no recall. The anchored phrase wins by + // longest-phrase-first before the bare verb is ever tried. + Lexicon.ResolveQuestion("Where do i work?").Should().Contain("works at"); + Lexicon.ResolveQuestion("What did i plan for the weekend?").Should().Contain("planned"); + } + + [Fact] + public void AssistantBoilerplateResolvesToNothing() + { + // The probes that motivated the whole storedOnly mechanism. + Lexicon.ResolveQuestion("Let me know if that works").Should().BeEmpty(); + Lexicon.ResolveQuestion("Give me the top five items").Should().BeEmpty(); + Lexicon.ResolveQuestion("Order the results by date").Should().BeEmpty(); + } + + [Theory] + // The imperative-instruction shape, which per-form suppression provably cannot reach: every one + // of these verbs is a legitimate relation that cannot be deleted, yet none of these sentences is + // a question about the user's memory. Two independent reviews measured this as the bound on + // precision - 25 of 40 boilerplate probes still fired after all per-form work was done. + [InlineData("Create a summary of this document")] + [InlineData("Build the project and run the tests")] + [InlineData("Save the file to disk")] + [InlineData("Read the contents of that page")] + [InlineData("Complete the form below")] + [InlineData("Start the server on port 8080")] + [InlineData("Choose the best option")] + public void ImperativeInstructionsRetrieveNothing(string instruction) => + Lexicon.ResolveQuestion(instruction).Should().BeEmpty(); + + [Theory] + // The other half. A memory question must still resolve, and these are the shapes that carry one: + // an explicit first person, or a possessive. + [InlineData("What did I create last year?", "created")] + [InlineData("Which books did I read?", "read")] + [InlineData("How much have I saved?", "saved")] + [InlineData("When did my subscription start?", "started")] + public void MemoryQuestionsStillResolve(string question, string expected) => + Lexicon.ResolveQuestion(question).Should().Contain(expected); + + [Fact] + public void TheKnownFailingBenchmarkQuestionSurvivesTheGate() + { + // gpt4_15e38248 is the measured case this whole mechanism exists for. A precision gate that + // broke it would be trading the only win we have. + Lexicon.ResolveQuestion( + "How many pieces of furniture did I buy, assemble, sell, or fix this year?") + .Should().HaveCount(4); + } + + [Fact] + public void AQuestionAboutABirthDoesNotExpandTheCopula() + { + // The reviewer's worked example: this previously resolved `is` and pulled a quarter of the + // graph into a fixed budget. + Lexicon.ResolveQuestion("When was my daughter born?") + .Should().NotContain("is").And.Contain("was born"); + } + + [Fact] + public void ResolutionIsCaseAndSeparatorInsensitive() + { + Lexicon.Resolve(" BOUGHT ").Should().Be("bought"); + Lexicon.Resolve("travelled_to").Should().Be("travelled to"); + } + + [Fact] + public void AQuestionWithNoRecognisableRelationResolvesToNothing() + { + Lexicon.ResolveQuestion("What is the airspeed velocity of an unladen swallow?") + .Should().NotContain("bought"); + } + + [Fact] + public void ARelationExpandsToEveryStoredFormOfItself() + { + // Measured in the real graph: "planned" holds 839 facts and "plans" holds 14, as SEPARATE + // predicate keys, because the write-side canonicalizer folds case and separators but never + // morphology. Expanding on the canonical name alone would silently miss the smaller bucket, + // which is the exact completeness failure expansion exists to prevent. + var forms = Lexicon.StoredFormsOf("planned"); + + forms.Should().Contain("planned").And.Contain("plans").And.Contain("plan"); + } + + [Theory] + // Every one of these is a real stored predicate key in the measured graph that is an inflection + // of a bigger bucket. Each must be reachable from its canonical relation. + [InlineData("is", "was")] + [InlineData("wants", "wanted")] + [InlineData("uses", "used")] + [InlineData("considered", "is considering")] + [InlineData("has", "had")] + public void InflectedStoredKeysAreReachableFromTheirCanonicalRelation( + string canonical, string storedVariant) => + Lexicon.StoredFormsOf(canonical).Should().Contain(storedVariant); + + [Fact] + public void ExpandingAnUnknownRelationYieldsOnlyItself() + { + // So a caller can expand uniformly without special-casing relations the table never saw. + Lexicon.StoredFormsOf("defenestrated").Should().Equal("defenestrated"); + } + + [Fact] + public void ResolvedRelationsAreDistinctAndOrderedByFirstAppearance() + { + var resolved = Lexicon.ResolveQuestion("Did I sell or buy or sell anything?"); + + resolved.Should().Equal("sold", "bought"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Memory/MemoryTripleCanonicalizerTests.cs b/tests/AgentMemory.Tests.Unit/Memory/MemoryTripleCanonicalizerTests.cs new file mode 100644 index 00000000..a70070d9 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/MemoryTripleCanonicalizerTests.cs @@ -0,0 +1,96 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// Facts already deduplicate on {subject, predicate, object, owner_key}, but that key uses raw +/// strings, so surface differences defeat it. These cover the canonical form that repairs it. +/// +public sealed class MemoryTripleCanonicalizerTests +{ + [Theory] + // The exact pairs measured on a real extracted graph as separate nodes for one fact. + [InlineData("were_born_in", "were born in")] + [InlineData("User", "user")] + [InlineData("was_born", "Was Born")] + [InlineData(" recently_had ", "recently had")] + [InlineData("is-planning", "is planning")] + public void SurfaceVariantsOfOneRelationCollapseToOneKey(string left, string right) => + MemoryTripleCanonicalizer.Canonical(left).Should() + .Be(MemoryTripleCanonicalizer.Canonical(right)); + + [Theory] + // Deterministic only: relations that differ in meaning must never merge, however similar they + // look or read. `bought`/`sold` is the case that would silently invert a fact. + [InlineData("bought", "sold")] + [InlineData("was born in", "was born after")] + [InlineData("likes", "dislikes")] + [InlineData("welcomed", "welcomes")] + public void RelationsThatDifferInMeaningStayDistinct(string left, string right) => + MemoryTripleCanonicalizer.Canonical(left).Should() + .NotBe(MemoryTripleCanonicalizer.Canonical(right)); + + [Fact] + public void RunsOfSeparatorsCollapseToASingleSpace() => + MemoryTripleCanonicalizer.Canonical("was___born \t in").Should().Be("was born in"); + + [Fact] + public void LeadingAndTrailingSeparatorsAreRemoved() => + MemoryTripleCanonicalizer.Canonical("__was born__").Should().Be("was born"); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("___")] + public void EmptyInputYieldsAnEmptyKeyRatherThanThrowing(string? value) => + MemoryTripleCanonicalizer.Canonical(value).Should().BeEmpty(); + + [Fact] + public void TheCanonicalFormIsStable() + { + // It becomes a persisted merge key, so it must never drift between calls or releases. + var once = MemoryTripleCanonicalizer.Canonical("Were_Born_In"); + var twice = MemoryTripleCanonicalizer.Canonical(once); + + twice.Should().Be(once); + once.Should().Be("were born in"); + } + + [Fact] + public void DistinctObjectsAreNotFoldedTogether() + { + // Near-duplicate objects ("a few weeks before the session" vs "before the potluck") are one + // event phrased twice. Collapsing them is a judgement call, so it is deliberately NOT done + // here — that belongs to retrieval-time capping, where it is reversible. + MemoryTripleCanonicalizer.Canonical("a few weeks before the session").Should() + .NotBe(MemoryTripleCanonicalizer.Canonical("a few weeks before the potluck")); + } + + [Theory] + // Audit finding: separator folding is right for predicates and CORRUPTING for values. A fact + // recording minus five would have MERGEd onto five, silently merging a quantity with its + // negation — from a helper written to prevent silent duplication. + [InlineData("-5", "5")] + [InlineData("-1", "1")] + [InlineData("well-being", "well being")] + [InlineData("e-mail", "e mail")] + public void ValueCanonicalizationNeverRewritesPunctuation(string left, string right) => + MemoryTripleCanonicalizer.CanonicalValue(left).Should() + .NotBe(MemoryTripleCanonicalizer.CanonicalValue(right)); + + [Fact] + public void ValueCanonicalizationStillFoldsCaseAndWhitespace() + { + // It must still collapse the differences that carry no meaning, or dedup stops working. + MemoryTripleCanonicalizer.CanonicalValue(" The Blue Sofa ").Should() + .Be(MemoryTripleCanonicalizer.CanonicalValue("the blue sofa")); + } + + [Fact] + public void PredicateCanonicalizationStillFoldsSeparators() => + MemoryTripleCanonicalizer.Canonical("was_born").Should() + .Be(MemoryTripleCanonicalizer.Canonical("was born")); +} diff --git a/tests/AgentMemory.Tests.Unit/Memory/MemoryVocabularyFingerprintTests.cs b/tests/AgentMemory.Tests.Unit/Memory/MemoryVocabularyFingerprintTests.cs new file mode 100644 index 00000000..814e8346 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/MemoryVocabularyFingerprintTests.cs @@ -0,0 +1,93 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// The extraction vocabulary decides what gets stored and the query lexicon decides what gets +/// retrieved, so two runs built or measured under different tables are not comparable. Nothing +/// recorded which table produced a given graph — the same defect as the retrieval flags that were +/// missing from the run fingerprint. +/// +public sealed class MemoryVocabularyFingerprintTests +{ + [Fact] + public void TheFingerprintIsOrderIndependentBecauseAVocabularyIsASet() + { + // Authoring order is not meaning. If reordering the table changed the fingerprint, every + // cosmetic edit would look like a vocabulary change and invalidate comparisons for nothing. + MemoryVocabularyFingerprint.Of(["bought", "sold", "likes"]).Should() + .Be(MemoryVocabularyFingerprint.Of(["likes", "bought", "sold"])); + } + + [Fact] + public void OneAddedEntryChangesTheFingerprint() + { + // The case that matters: adding `assembled` changes what the extractor will store, so a graph + // built before it must never be mistaken for one built after. + MemoryVocabularyFingerprint.Of(["bought", "sold"]).Should() + .NotBe(MemoryVocabularyFingerprint.Of(["bought", "sold", "assembled"])); + } + + [Fact] + public void OneRemovedEntryChangesTheFingerprint() => + MemoryVocabularyFingerprint.Of(["bought", "sold", "likes"]).Should() + .NotBe(MemoryVocabularyFingerprint.Of(["bought", "sold"])); + + [Fact] + public void DuplicatesDoNotChangeTheFingerprint() => + MemoryVocabularyFingerprint.Of(["bought", "bought", "sold"]).Should() + .Be(MemoryVocabularyFingerprint.Of(["bought", "sold"])); + + [Fact] + public void TheFingerprintIsLowercaseHexAndFullLength() => + MemoryVocabularyFingerprint.Of(["bought"]).Should() + .HaveLength(64).And.MatchRegex("^[0-9a-f]{64}$"); + + [Fact] + public void TheShippedVocabularyFingerprintsAreStable() + { + // They become recorded run metadata, so they must not drift between calls or processes. + MemoryPredicateSeedVocabulary.Fingerprint.Should() + .Be(MemoryPredicateSeedVocabulary.Fingerprint) + .And.MatchRegex("^[0-9a-f]{64}$"); + MemoryRelationSeedTable.Fingerprint.Should() + .Be(MemoryRelationSeedTable.Fingerprint) + .And.MatchRegex("^[0-9a-f]{64}$"); + } + + [Fact] + public void TheExtractionVocabularyAndQueryLexiconHaveDistinctFingerprints() + { + // They are two different artifacts pointing in opposite directions: one decides what is + // written, the other what is read. A single shared fingerprint would hide a change to either. + MemoryPredicateSeedVocabulary.Fingerprint.Should() + .NotBe(MemoryRelationSeedTable.Fingerprint); + } + + [Fact] + public void AddingASurfaceFormChangesTheQueryLexiconFingerprint() + { + // Surface forms never enter a prompt, but they change what a question resolves to and + // therefore what is retrieved, so they belong in the fingerprint too. + var before = MemoryVocabularyFingerprint.OfTable( + new Dictionary { ["bought"] = ["buy", "purchased"] }); + var after = MemoryVocabularyFingerprint.OfTable( + new Dictionary { ["bought"] = ["buy", "purchased", "acquired"] }); + + after.Should().NotBe(before); + } + + [Fact] + public void MovingASurfaceFormBetweenRelationsChangesTheFingerprint() + { + // Same entry count, different meaning. A fingerprint over counts alone would miss this. + var before = MemoryVocabularyFingerprint.OfTable( + new Dictionary { ["bought"] = ["got"], ["received"] = [] }); + var after = MemoryVocabularyFingerprint.OfTable( + new Dictionary { ["bought"] = [], ["received"] = ["got"] }); + + after.Should().NotBe(before); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyCoherenceTests.cs b/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyCoherenceTests.cs new file mode 100644 index 00000000..165f541e --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyCoherenceTests.cs @@ -0,0 +1,213 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// The write vocabulary and the read lexicon must describe the same set of relations. +/// +/// +/// Two hand-maintained lists drift, and drift here is not cosmetic: a relation present only on the +/// read side is one the system will look for and can never have stored, which is a guaranteed miss +/// with no error anywhere. That is exactly how `assembled` came to be resolvable at query time while +/// the extractor, never having been offered the word, filed assembly under `completed` instead. +/// +/// The canonical key set is therefore shared. Surface forms stay read-side only, deliberately: they +/// never enter an extraction prompt, where they would cost tokens across every call and invite the +/// extractor to choose inconsistently between `buy`, `buys` and `purchased` — the opposite of the +/// consolidation the vocabulary exists to produce. +/// +/// +public sealed class RelationVocabularyCoherenceTests +{ + [Fact] + public void EveryRelationOfferedToExtractionIsResolvableAtQueryTime() + { + // The invariant in one line: anything we can write, we can find again. A relation the + // extractor may store but the lexicon cannot resolve is unreachable by any question. + // + // The one exception is deliberate and explicit. A handful of relations are declared query + // stop forms because they fire on almost every question and expand a bucket large enough to + // exhaust the retrieval budget - `is` alone is 26% of the measured graph. Those are still + // written, and still fetched by similarity and by expansion; they simply never TRIGGER an + // expansion. The exception is enumerated in the artifact, not inferred here. + foreach (var predicate in MemoryPredicateSeedVocabulary.Create().Snapshot()) + { + var canonical = MemoryTripleCanonicalizer.Canonical(predicate); + if (MemoryRelationSeedTable.QueryStopForms.Contains(canonical)) + continue; + MemoryRelationLexicon.Default.Resolve(predicate).Should() + .Be(canonical, $"'{predicate}' is offered to extraction and must resolve to itself"); + } + } + + [Fact] + public void EveryResolvableRelationIsOfferedToExtractionUnlessExplicitlyRetired() + { + // The other direction, and the one that was broken: 13 relations were resolvable but never + // offered, so the graph could not contain them however well retrieval worked. + var offered = MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .ToHashSet(StringComparer.Ordinal); + var resolvable = MemoryRelationLexicon.Default.CanonicalRelations + .Except(MemoryRelationSeedTable.RetiredRelations, StringComparer.Ordinal); + + resolvable.Should().BeSubsetOf(offered); + } + + [Fact] + public void TheTwoSidesShareOneSourceOfTruth() + { + var offered = MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .ToHashSet(StringComparer.Ordinal); + var expected = MemoryRelationSeedTable.Table.Keys + .Select(MemoryTripleCanonicalizer.Canonical) + .Except(MemoryRelationSeedTable.RetiredRelations, StringComparer.Ordinal) + .ToHashSet(StringComparer.Ordinal); + + offered.Should().BeEquivalentTo(expected); + } + + [Fact] + public void AssembledIsOfferedToExtraction() + { + // The named case. Assembly was stored as `completed` because the extractor was never offered + // this word, which is half of why gpt4_15e38248 cannot be answered from the graph. + MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .Should().Contain("assembled"); + } + + [Fact] + public void RetiredRelationsStayResolvableSoOlderGraphsRemainReadable() + { + // A relation removed from the vocabulary does not vanish from graphs already written under + // it. Retiring must stop new writes without making the existing facts unreachable, so the + // retired name survives as a surface form of the relation it merged into — reachable through + // the survivor rather than through a canonical key that no longer exists. + // Independent review caught this test passing VACUOUSLY: asserting that a retired name is + // absent from the canonical set is trivially true once it has been demoted, and proves + // nothing about reachability. The real property is that it resolves to a DIFFERENT, live + // relation — the survivor it merged into. + MemoryRelationSeedTable.RetiredRelations.Should().NotBeEmpty( + "a vacuous loop over an empty set would assert nothing at all"); + + foreach (var retired in MemoryRelationSeedTable.RetiredRelations) + { + var survivor = MemoryRelationLexicon.Default.Resolve(retired); + + survivor.Should().NotBeNullOrEmpty( + $"facts stored under '{retired}' must stay reachable after retirement"); + survivor.Should().NotBe(retired, "retirement merges a relation into another"); + MemoryRelationLexicon.Default.CanonicalRelations.Should().Contain(survivor!); + // And expansion of the survivor must actually fetch the retired key, or the facts are + // resolvable in name only. + MemoryRelationLexicon.Default.StoredFormsOf(survivor).Should().Contain(retired); + MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .Should().NotContain(retired); + } + } + + [Fact] + public void NoInflectionOfAListedFormResolvesToADifferentRelation() + { + // Reviewer finding 4. The stemmer strips suffixes, so a listed form's siblings can land on a + // DIFFERENT relation by accident. This is the mechanical gate that catches such a collision at + // build time instead of as a wrong retrieval in production. + var lexicon = MemoryRelationLexicon.Default; + var collisions = new List(); + + foreach (var relation in lexicon.CanonicalRelations) + { + foreach (var form in lexicon.StoredFormsOf(relation)) + { + foreach (var inflection in new[] { form + "s", form + "ing", form + "ed" }) + { + var resolved = lexicon.Resolve(inflection); + // A miss is fine - not every inflection is real English. Landing on another + // relation is not. + if (resolved is not null && resolved != relation) + collisions.Add($"'{inflection}' (from '{relation}') resolves to '{resolved}'"); + } + } + } + + collisions.Should().BeEmpty(); + } + + [Fact] + public void TheFamilyOfEveryRelationIsDeclaredAndUsable() + { + // Reviewer finding 3: the field was internally inconsistent, marking durable dispositions as + // events. A field a reviewer cannot trust is worse than no field. + var document = RelationVocabularyDocument.Load(); + + foreach (var stative in new[] { "likes", "prefers", "wants", "owns", "knows", "is" }) + document.Canonical[stative].Family.Should().Be("state"); + foreach (var episodic in new[] { "bought", "sold", "travelled to", "called" }) + document.Canonical[episodic].Family.Should().Be("event"); + } + + [Fact] + public void NoRelationIsOfferedToExtractionWhileBeingUnreachableFromEveryQuestion() + { + // Measured, not theorised. The J1.6 cold build showed `has` absorbing 489 extra facts while + // every one of its trigger forms was stop-listed, so 518 facts became unexpandable — a + // write-only sink. `storedOnly` quietly reintroduced the asymmetry the one-table rule exists + // to remove: a relation can be attractive to the extractor and invisible to every question. + var document = RelationVocabularyDocument.Load(); + var offered = MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .ToHashSet(StringComparer.Ordinal); + + // Corrected after measurement: such a relation is still reachable by top-K similarity, so it + // is un-EXPANDABLE rather than unreachable. The rule is therefore that every one must be a + // DECLARED exemption carrying a reason, not that none may exist — the copulas legitimately + // qualify, and expanding them wholesale would flood a fixed budget with near-meaningless facts. + var sinks = document.Canonical + .Where(entry => offered.Contains(entry.Key)) + .Where(entry => entry.Value.SurfaceForms.Count == 0) + .Select(entry => entry.Key) + .ToList(); + + sinks.Should().BeSubsetOf(document.ExpansionExempt.Keys); + foreach (var exempt in sinks) + { + document.ExpansionExempt[exempt].Should().NotBeNullOrWhiteSpace( + $"'{exempt}' collects facts no question can expand, so its reason must be recorded"); + } + } + + [Fact] + public void WelcomedIsOfferedToExtraction() + { + // Retiring it was a WRITE-side change made on READ-side reasoning, and it cost the one + // question predicate expansion is proven to flip: `welcomed` went 7 facts to 0, births + // scattered, and 2e6d26dc regressed from correct to incorrect on the rebuilt graph. + MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .Should().Contain("welcomed"); + } + + [Fact] + public void OpposingRelationsSurviveTheSharedSource() + { + // Carried over from the seed, where it is load-bearing: offering only one side of an opposing + // pair invites the extractor to collapse them and invert facts. + var offered = MemoryPredicateSeedVocabulary.Create().Snapshot() + .Select(MemoryTripleCanonicalizer.Canonical) + .ToHashSet(StringComparer.Ordinal); + + foreach (var (left, right) in new[] + { + ("bought", "sold"), ("likes", "dislikes"), + ("borrowed", "lent"), ("gave", "received") + }) + { + offered.Should().Contain(left).And.Contain(right); + } + } +} diff --git a/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyDocumentTests.cs b/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyDocumentTests.cs new file mode 100644 index 00000000..5b47c7b4 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Memory/RelationVocabularyDocumentTests.cs @@ -0,0 +1,110 @@ +using AgentMemory.Core.Memory; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Memory; + +/// +/// The vocabulary is authored as JSON and shipped as an embedded resource. +/// +/// +/// JSON because it is diffable in review, is what the unifier emits, and can carry per-relation source +/// and licence provenance that a C# array cannot express — this artifact ships inside a NuGet package +/// and draws on schema.org and Wikidata. Embedded rather than a file on disk for the same reason: a +/// file dependency is a deployment hazard and adds a startup I/O failure mode to a library. +/// +/// These tests are the gate. A malformed table must fail CI here rather than surface as a +/// inside a consumer's process on first use. +/// +/// +public sealed class RelationVocabularyDocumentTests +{ + [Fact] + public void TheEmbeddedDocumentLoads() => + RelationVocabularyDocument.Load().Canonical.Should().NotBeEmpty(); + + [Fact] + public void EveryRelationDeclaresItsProvenance() + { + // Licence provenance is part of the artifact, not a footnote: this ships in a package and + // draws on third-party sources. + foreach (var (relation, entry) in RelationVocabularyDocument.Load().Canonical) + { + entry.Sources.Should().NotBeEmpty($"'{relation}' must record where it came from"); + entry.Family.Should().BeOneOf("event", "state"); + } + } + + [Fact] + public void CanonicalKeysAreInStoredPredicateKeyForm() + { + // Resolution that produced keys the graph cannot match would be worthless. + foreach (var relation in RelationVocabularyDocument.Load().Canonical.Keys) + relation.Should().Be(MemoryTripleCanonicalizer.Canonical(relation)); + } + + [Fact] + public void NoSurfaceFormIsClaimedByTwoRelations() + { + // Authoring mistakes must fail the build, not be silently dropped at runtime. + var document = RelationVocabularyDocument.Load(); + var owners = new Dictionary(StringComparer.Ordinal); + var conflicts = new List(); + foreach (var (relation, entry) in document.Canonical) + { + foreach (var form in entry.SurfaceForms) + { + if (owners.TryGetValue(form, out var existing) && existing != relation) + conflicts.Add($"'{form}' claimed by both '{existing}' and '{relation}'"); + else + owners[form] = relation; + } + } + + conflicts.Should().BeEmpty(); + } + + [Fact] + public void ASurfaceFormNeverCollidesWithADifferentCanonicalKey() + { + var document = RelationVocabularyDocument.Load(); + foreach (var (relation, entry) in document.Canonical) + { + foreach (var form in entry.SurfaceForms) + { + if (document.Canonical.ContainsKey(form)) + form.Should().Be(relation, $"'{form}' is itself a canonical relation"); + } + } + } + + [Fact] + public void TheDocumentIsTheSourceTheLexiconAndVocabularyBothUse() + { + // One relation known to two layers, one definition. This is the invariant whose absence let + // `assembled` become resolvable at query time while the extractor was never offered it. + var document = RelationVocabularyDocument.Load(); + + MemoryRelationSeedTable.Table.Keys.Should() + .BeEquivalentTo(document.Canonical.Keys); + } + + [Fact] + public void OpposingRelationsAreBothPresent() + { + var canonical = RelationVocabularyDocument.Load().Canonical.Keys; + + foreach (var (left, right) in new[] + { + ("bought", "sold"), ("likes", "dislikes"), + ("borrowed", "lent"), ("gave", "received") + }) + { + canonical.Should().Contain(left).And.Contain(right); + } + } + + [Fact] + public void LoadingIsCachedSoTheParseHappensOnce() => + RelationVocabularyDocument.Load().Should().BeSameAs(RelationVocabularyDocument.Load()); +} diff --git a/tests/AgentMemory.Tests.Unit/MetaPackage/MetaPackageDiRegistrationTests.cs b/tests/AgentMemory.Tests.Unit/MetaPackage/MetaPackageDiRegistrationTests.cs index 44f53aac..5c04cf04 100644 --- a/tests/AgentMemory.Tests.Unit/MetaPackage/MetaPackageDiRegistrationTests.cs +++ b/tests/AgentMemory.Tests.Unit/MetaPackage/MetaPackageDiRegistrationTests.cs @@ -203,6 +203,7 @@ public void AddNeo4jAgentMemory_WithConfigureLlm_RegistersLlmExtractorsOverStubs services.Should().Contain(d => d.ServiceType == typeof(IPreferenceExtractor) && d.ImplementationType == typeof(LlmPreferenceExtractor)); services.Should().Contain(d => d.ServiceType == typeof(IRelationshipExtractor) && d.ImplementationType == typeof(LlmRelationshipExtractor)); + services.Should().Contain(d => d.ServiceType == typeof(IUnifiedMemoryExtractor) && d.ImplementationType == typeof(LlmUnifiedMemoryExtractor)); // The stub must NOT remain registered: Replace removed it, so the IEnumerable // the ExtractionStage receives contains only the real extractor, not the empty-returning stub. services.Should().NotContain(d => d.ServiceType == typeof(IEntityExtractor) && d.ImplementationType == typeof(AgentMemory.Core.Stubs.StubEntityExtractor)); @@ -252,7 +253,11 @@ public void AddNeo4jAgentMemory_NullServices_ThrowsArgumentNull() public void AddNeo4jAgentMemory_NullConfigureMemory_ThrowsArgumentNull() { var services = new ServiceCollection(); - var act = () => services.AddNeo4jAgentMemory(null!, _ => { }); + // The cast is load-bearing, not noise. K9.1 added an overload taking a MemoryOptions + // instance, and an untyped null converts to both that and Action. This is the + // one call site in the whole solution affected, and only because passing a bare null is a + // null-guard test idiom rather than something production code does. + var act = () => services.AddNeo4jAgentMemory((Action)null!, _ => { }); act.Should().Throw(); } diff --git a/tests/AgentMemory.Tests.Unit/Options/BatchEntityResolutionOptionsTests.cs b/tests/AgentMemory.Tests.Unit/Options/BatchEntityResolutionOptionsTests.cs new file mode 100644 index 00000000..9c165a40 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Options/BatchEntityResolutionOptionsTests.cs @@ -0,0 +1,13 @@ +using AgentMemory.Abstractions.Options; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.OptionsTests; + +public sealed class BatchEntityResolutionOptionsTests +{ + [Fact] + public void UseBatchEntityResolutionSnapshots_DefaultsOn() + { + new ExtractionOptions().UseBatchEntityResolutionSnapshots.Should().BeTrue(); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Options/ConfigurationValidationTests.cs b/tests/AgentMemory.Tests.Unit/Options/ConfigurationValidationTests.cs index f7bd336d..dbc620a8 100644 --- a/tests/AgentMemory.Tests.Unit/Options/ConfigurationValidationTests.cs +++ b/tests/AgentMemory.Tests.Unit/Options/ConfigurationValidationTests.cs @@ -322,6 +322,21 @@ public void LlmExtractionOptions_Default_MaxRetriesIs2() new LlmExtractionOptions().MaxRetries.Should().Be(2); } + [Fact] + public void LlmExtractionOptions_Default_MultiSessionBatchConcurrencyPreservesCompatibility() + { + var options = new LlmExtractionOptions(); + + options.MaxConcurrentBatchesPerExtraction.Should().Be(1); + options.MaxConcurrentExtractionBatches.Should().Be(0); + } + + [Fact] + public void LlmExtractionOptions_Default_UseJsonResponseFormatIsTrue() + { + new LlmExtractionOptions().UseJsonResponseFormat.Should().BeTrue(); + } + [Fact] public void LlmExtractionOptions_Default_ModelIdIsNull() { @@ -395,6 +410,12 @@ public void Neo4jOptions_Default_EmbeddingDimensionsIs1536() new Neo4jOptions().EmbeddingDimensions.Should().Be(1536); } + [Fact] + public void Neo4jOptions_Default_UseOptimizedMessageBatchWritesIsTrue() + { + new Neo4jOptions().UseOptimizedMessageBatchWrites.Should().BeTrue(); + } + [Fact] public void GraphRagOptions_Default_TopKIsPositive() { diff --git a/tests/AgentMemory.Tests.Unit/Options/RecallOptionsTests.cs b/tests/AgentMemory.Tests.Unit/Options/RecallOptionsTests.cs index b547437d..ac372bc4 100644 --- a/tests/AgentMemory.Tests.Unit/Options/RecallOptionsTests.cs +++ b/tests/AgentMemory.Tests.Unit/Options/RecallOptionsTests.cs @@ -91,4 +91,14 @@ public void Default_AllMaxValuesArePositive() options.MaxTraces.Should().BePositive(); options.MaxGraphRagItems.Should().BePositive(); } + + [Fact] + public void DiagnosticsContract_IsAvailableAndDefaultOff() + { + var property = typeof(RecallOptions).GetProperty("IncludeDiagnostics"); + + property.Should().NotBeNull( + "ranked retrieval evidence must be explicitly opt-in on each recall"); + property!.GetValue(new RecallOptions()).Should().Be(false); + } } diff --git a/tests/AgentMemory.Tests.Unit/Options/RegistrationOptionsReachabilityTests.cs b/tests/AgentMemory.Tests.Unit/Options/RegistrationOptionsReachabilityTests.cs new file mode 100644 index 00000000..5b0c6cbf --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Options/RegistrationOptionsReachabilityTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Options; +using AgentMemory.Core; +using Xunit; + +// Namespace is OptionsTests, not Options: a sibling namespace named Options shadows +// Microsoft.Extensions.Options for every other file under AgentMemory.Tests.Unit. +namespace AgentMemory.Tests.Unit.OptionsTests; + +/// +/// K9. Can the public registration API configure the memory system at all? +/// +/// +/// takes an +/// Action<MemoryOptions> and hands it to .Configure(...), which mutates one +/// instance in place. But is a record whose properties are all +/// init-only, so the lambda body cannot assign them — options.EnableGraphRag = true is +/// a compile error (CS8852). The only shape that compiles is options = options with { ... }, +/// which rebinds the parameter local and discards the result the moment the lambda returns. +/// +/// That is the shape the BlendedAgent sample ships, in both its Program.cs and its README. +/// It compiles, runs, and configures nothing. These tests exist to state which of the two readings +/// is true, because "the sample is wrong" and "the API is unusable" are not the same finding and +/// reading the code cannot separate them. +/// +/// +/// Isolation is included as the control: it is a mutable class rather than an init record — +/// the shape adopted for issue #100 — so if the mechanism itself worked, that knob would move while +/// the record-backed ones did not. +/// +/// +public sealed class RegistrationOptionsReachabilityTests +{ + [Fact] + public void TheSamplesConfigureLambdaLeavesGraphRagOff() + { + // Verbatim the shape in samples/AgentMemory.Sample.BlendedAgent/Program.cs. + var options = Resolve(o => + { + o = o with + { + EnableGraphRag = true, + Recall = new RecallOptions { MaxGraphRagItems = 5, MaxFacts = 10 } + }; + }); + + options.EnableGraphRag.Should().BeFalse( + "the lambda rebinds its own parameter; the registered instance is never touched"); + } + + [Fact] + public void NoRecordBackedRecallKnobIsReachable() + { + var options = Resolve(o => o = o with { Recall = new RecallOptions { MaxFacts = 999 } }); + + options.Recall.MaxFacts.Should().Be(RecallOptions.Default.MaxFacts); + } + + [Fact] + public void TheMutableClassOptionIsReachable() + { + // The control. Isolation is a class with a settable property, so it configures normally. + // This is what separates "records are unconfigurable" from "the whole mechanism is broken". + var options = Resolve(o => o.Isolation.Mode = MemoryIsolationMode.StrictMultiTenant); + + options.Isolation.Mode.Should().Be(MemoryIsolationMode.StrictMultiTenant); + } + + + [Fact] + public void TheInstanceOverloadDeliversEveryRecordBackedKnob() + { + // K9.1. The fix. Same values the configure lambda silently dropped above. + var options = new ServiceCollection() + .AddAgentMemoryCore(new MemoryOptions + { + EnableGraphRag = true, + Recall = new RecallOptions { MaxFacts = 999, MaxGraphRagItems = 5 } + }) + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.EnableGraphRag.Should().BeTrue(); + options.Recall.MaxFacts.Should().Be(999); + options.Recall.MaxGraphRagItems.Should().Be(5); + } + + [Fact] + public void TheInstanceOverloadStillValidates() + { + // A supplied instance is checked, not trusted. Without this the overload would be a hole + // straight through the validator chain the lambda path registers - a caller could hand over + // an out-of-range Isolation.Mode and get the most permissive behaviour with no error until + // the first affected call, which is precisely what that chain exists to prevent. + var act = () => new ServiceCollection() + .AddAgentMemoryCore(new MemoryOptions + { + Isolation = { Mode = (MemoryIsolationMode)999 } + }) + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + act.Should().Throw(); + } + + [Fact] + public void TheInstanceOverloadStillRegistersTheServices() + { + // The overload must be a way to supply options, not a second, thinner registration path. + // AddLogging is the caller's job either way - AddAgentMemoryCore has never registered it. + new ServiceCollection() + .AddLogging() + .AddAgentMemoryCore(new MemoryOptions()) + .BuildServiceProvider() + .GetService() + .Should().NotBeNull(); + } + + private static MemoryOptions Resolve(Action configure) => + new ServiceCollection() + .AddAgentMemoryCore(configure) + .BuildServiceProvider() + .GetRequiredService>() + .Value; +} diff --git a/tests/AgentMemory.Tests.Unit/Options/ShortTermMemoryOptionsTests.cs b/tests/AgentMemory.Tests.Unit/Options/ShortTermMemoryOptionsTests.cs index 9aa565cf..cc235cb1 100644 --- a/tests/AgentMemory.Tests.Unit/Options/ShortTermMemoryOptionsTests.cs +++ b/tests/AgentMemory.Tests.Unit/Options/ShortTermMemoryOptionsTests.cs @@ -19,6 +19,13 @@ public void Default_GenerateEmbeddingsIsTrue() options.GenerateEmbeddings.Should().BeTrue(); } + [Fact] + public void Default_UseBatchEmbeddingRequestsIsTrue() + { + var options = new ShortTermMemoryOptions(); + options.UseBatchEmbeddingRequests.Should().BeTrue(); + } + [Fact] public void Default_DefaultRecentMessageLimitIs10() { diff --git a/tests/AgentMemory.Tests.Unit/Queries/CypherQueryRegistryTests.cs b/tests/AgentMemory.Tests.Unit/Queries/CypherQueryRegistryTests.cs index 04162065..6fe8f3e7 100644 --- a/tests/AgentMemory.Tests.Unit/Queries/CypherQueryRegistryTests.cs +++ b/tests/AgentMemory.Tests.Unit/Queries/CypherQueryRegistryTests.cs @@ -148,7 +148,7 @@ public void FingerprintFor_CentralizedMethodBuiltQueries_ReturnsStableSourceName "ReasoningQueries.SearchByTaskVector"), (DecayQueries.UpdateAccessTimestampBatch("Entity"), "DecayQueries.UpdateAccessTimestampBatch"), - (FactQueries.FindDuplicate(10), + (FactQueries.FindDuplicate(), "FactQueries.FindDuplicate"), (PreferenceQueries.FindDuplicate(10, ownerIsShared: false), "PreferenceQueries.FindDuplicate"), diff --git a/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshot.snap b/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshot.snap index c717ebac..22e2f8d7 100644 --- a/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshot.snap +++ b/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshot.snap @@ -1,4 +1,4 @@ -# Cypher Query Snapshot — 143 queries +# Cypher Query Snapshot — 151 queries # Auto-generated by CypherQuerySnapshotTests. # To regenerate: set UPDATE_CYPHER_SNAPSHOTS=1 and re-run the test. @@ -282,6 +282,14 @@ MERGE (ex:Extractor {name: $name}) ON MATCH SET ex.version = COALESCE($version, ex.version), ex.config = COALESCE($config, ex.config) RETURN ex +## FactQueries.ApplyCanonicalKeys +UNWIND $items AS item + MATCH (f:Fact {id: item.id}) + SET f.subject_key = item.subject_key, + f.predicate_key = item.predicate_key, + f.object_key = item.object_key + RETURN count(f) AS updated + ## FactQueries.CreateAbout MATCH (f:Fact {id: $factId}), (e:Entity {id: $entityId}) MERGE (f)-[:ABOUT]->(e) @@ -303,12 +311,21 @@ MATCH (f:Fact) WHERE f.embedding IS NULL RETURN f LIMIT $limit ## FactQueries.MarkDeduplicated MATCH (f:Fact {id: $id}) SET f.confidence = $confidence RETURN f +## FactQueries.SelectFactsMissingCanonicalKeys +MATCH (f:Fact) + WHERE f.predicate_key IS NULL + RETURN f.id AS id, f.subject AS subject, f.predicate AS predicate, f.object AS object + LIMIT $limit + ## FactQueries.UpdateEmbedding MATCH (f:Fact {id: $id}) SET f.embedding = $embedding ## FactQueries.Upsert -MERGE (f:Fact {subject: $subject, predicate: $predicate, object: $object, owner_key: $ownerKey}) +MERGE (f:Fact {subject_key: $subjectKey, predicate_key: $predicateKey, object_key: $objectKey, owner_key: $ownerKey}) ON CREATE SET + f.subject = $subject, + f.predicate = $predicate, + f.object = $object, f.id = $id, f.owner_id = $ownerId, f.category = $category, @@ -331,8 +348,11 @@ MERGE (f:Fact {subject: $subject, predicate: $predicate, object: $object, owner_ ## FactQueries.UpsertBatch UNWIND $items AS item - MERGE (f:Fact {subject: item.subject, predicate: item.predicate, object: item.object, owner_key: item.owner_key}) + MERGE (f:Fact {subject_key: item.subject_key, predicate_key: item.predicate_key, object_key: item.object_key, owner_key: item.owner_key}) ON CREATE SET + f.subject = item.subject, + f.predicate = item.predicate, + f.object = item.object, f.id = item.id, f.owner_id = item.owner_id, f.category = item.category, @@ -353,6 +373,119 @@ UNWIND $items AS item f.invalidated_at = null RETURN f +## FusedPersistenceQueries.EntityUpsertBatch +UNWIND $items AS item + MERGE (e:Entity {id: item.id}) + ON CREATE SET + e.owner_id = item.owner_id, + e.name = item.name, + e.canonical_name = item.canonical_name, + e.type = item.type, + e.subtype = item.subtype, + e.description = item.description, + e.confidence = item.confidence, + e.aliases = item.aliases, + e.attributes = item.attributes, + e.source_message_ids = item.source_message_ids, + e.created_at = datetime(item.created_at), + e.metadata = item.metadata + ON MATCH SET + e.name = item.name, + e.canonical_name = item.canonical_name, + e.type = item.type, + e.subtype = item.subtype, + e.description = item.description, + e.confidence = item.confidence, + e.aliases = item.aliases, + e.attributes = item.attributes, + e.source_message_ids = item.source_message_ids, + e.metadata = item.metadata, + e.updated_at = datetime() + SET e.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE e.embedding END + FOREACH (_ IN CASE + WHEN item.latitude IS NOT NULL AND item.longitude IS NOT NULL THEN [1] + ELSE [] END | + SET e.location = point({latitude: item.latitude, longitude: item.longitude})) + SET e:$(item.labels) + WITH e, item + CALL (e, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (e)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN e + +## FusedPersistenceQueries.FactUpsertBatch +UNWIND $items AS item + MERGE (f:Fact {subject_key: item.subject_key, predicate_key: item.predicate_key, object_key: item.object_key, owner_key: item.owner_key}) + ON CREATE SET + f.subject = item.subject, + f.predicate = item.predicate, + f.object = item.object, + f.id = item.id, + f.owner_id = item.owner_id, + f.category = item.category, + f.confidence = item.confidence, + f.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + f.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + f.source_message_ids = item.source_message_ids, + f.created_at = datetime(item.created_at), + f.metadata = item.metadata + ON MATCH SET + f.category = item.category, + f.confidence = item.confidence, + f.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE f.valid_from END, + f.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE f.valid_until END, + f.source_message_ids = item.source_message_ids, + f.updated_at = datetime(item.updated_at), + f.metadata = item.metadata, + f.invalidated_at = null + SET f.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE f.embedding END + WITH f, item + CALL (f, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (f)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN f + +## FusedPersistenceQueries.PreferenceUpsertBatch +UNWIND $items AS item + MERGE (p:Preference {id: item.id}) + ON CREATE SET + p.owner_id = item.owner_id, + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.created_at = datetime(item.created_at), + p.metadata = item.metadata + ON MATCH SET + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.metadata = item.metadata + SET p.embedding = CASE + WHEN item.embedding IS NOT NULL AND size(item.embedding) > 0 THEN item.embedding + ELSE p.embedding END + WITH p, item + CALL (p, item) { + UNWIND item.source_message_ids AS msgId + MATCH (m:Message {id: msgId}) + MERGE (p)-[:EXTRACTED_FROM]->(m) + RETURN count(*) AS linked + } + RETURN p + ## MessageQueries.Add MERGE (conv:Conversation {id: $conversationId}) ON CREATE SET conv.session_id = $sessionId, @@ -367,8 +500,26 @@ MERGE (conv:Conversation {id: $conversationId}) m.timestamp = datetime($timestamp), m.tool_call_ids = $toolCallIds, m.metadata = $metadata + WITH conv, m, m { .* } AS persisted + SET m.embedding = CASE + WHEN $embedding IS NOT NULL THEN $embedding + ELSE m.embedding + END MERGE (conv)-[:HAS_MESSAGE]->(m) - RETURN m + WITH conv, m, persisted + OPTIONAL MATCH (conv)-[:FIRST_MESSAGE]->(first:Message) + FOREACH (_ IN CASE WHEN first IS NULL THEN [1] ELSE [] END | + MERGE (conv)-[:FIRST_MESSAGE]->(m) + ) + WITH conv, m, persisted + OPTIONAL MATCH (conv)-[:HAS_MESSAGE]->(prev:Message) + WHERE prev.id <> $id + WITH m, persisted, prev ORDER BY prev.timestamp DESC + WITH m, persisted, head(collect(prev)) AS prev + FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | + MERGE (prev)-[:NEXT_MESSAGE]->(m) + ) + RETURN persisted AS m ## MessageQueries.AddBatch UNWIND $messages AS msg @@ -498,6 +649,27 @@ MERGE (p:Preference {id: $id}) p.metadata = $metadata RETURN p +## PreferenceQueries.UpsertBatch +UNWIND $items AS item + MERGE (p:Preference {id: item.id}) + ON CREATE SET + p.owner_id = item.owner_id, + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.created_at = datetime(item.created_at), + p.metadata = item.metadata + ON MATCH SET + p.category = item.category, + p.preference = item.preference, + p.context = item.context, + p.confidence = item.confidence, + p.source_message_ids = item.source_message_ids, + p.metadata = item.metadata + RETURN p + ## ReasoningQueries.AddStep MATCH (t:ReasoningTrace {id: $traceId}) CREATE (s:ReasoningStep { @@ -610,6 +782,37 @@ MERGE (s:Entity {id: $sourceEntityId}) r.metadata = $metadata RETURN r +## RelationshipQueries.UpsertBatch +UNWIND $items AS item + MERGE (s:Entity {id: item.source_entity_id}) + MERGE (t:Entity {id: item.target_entity_id}) + MERGE (s)-[r:RELATED_TO {id: item.id}]->(t) + ON CREATE SET + r.relation_type = item.relation_type, + r.owner_id = item.owner_id, + r.source_entity_id = item.source_entity_id, + r.target_entity_id = item.target_entity_id, + r.confidence = item.confidence, + r.description = item.description, + r.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + r.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + r.attributes = item.attributes, + r.source_message_ids = item.source_message_ids, + r.created_at = datetime(item.created_at), + r.updated_at = datetime(item.updated_at), + r.metadata = item.metadata + ON MATCH SET + r.relation_type = item.relation_type, + r.confidence = item.confidence, + r.description = item.description, + r.valid_from = CASE WHEN item.valid_from IS NOT NULL THEN datetime(item.valid_from) ELSE null END, + r.valid_until = CASE WHEN item.valid_until IS NOT NULL THEN datetime(item.valid_until) ELSE null END, + r.attributes = item.attributes, + r.source_message_ids = item.source_message_ids, + r.updated_at = datetime(item.updated_at), + r.metadata = item.metadata + RETURN r + ## SchemaPersistenceQueries.DeactivateByName MATCH (s:Schema {name: $name}) SET s.is_active = false @@ -768,6 +971,9 @@ SHOW CONSTRAINTS YIELD name RETURN name ## SchemaQueries.ShowIndexNames SHOW INDEXES YIELD name RETURN name +## SchemaQueries.ShowIndexStates +SHOW INDEXES YIELD name, state, type RETURN name AS name, state AS state, type AS type + ## SchemaQueries.ShowVectorIndexDimensions SHOW VECTOR INDEXES YIELD name, options RETURN name AS name, options['indexConfig']['vector.dimensions'] AS dimensions diff --git a/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshotTests.cs b/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshotTests.cs index c241b078..94c10c08 100644 --- a/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshotTests.cs +++ b/tests/AgentMemory.Tests.Unit/Queries/CypherQuerySnapshotTests.cs @@ -37,10 +37,13 @@ private static string ResolveSnapshotPath([CallerFilePath] string? sourceFile = // ── Expected query inventory count ──────────────────────────────────────── // Update this constant whenever queries are deliberately added or removed. - private const int ExpectedQueryCount = 143; // base + ConsolidationQueries/SchemaQueries/TOUCHED/ConflictQueries consts. +MessageQueries.GetAllBySession (cycle-3); +SchemaQueries.ShowConstraintNames/ShowIndexNames (schema-check CLI); +SchemaPersistenceQueries.Save/DeactivateByName/LoadActiveByName/LoadByNameVersion/List/Exists/DeleteById (G4 schema-node CRUD, 7); +MemoryReadAudit constraint/index. Owner-conditional queries are *methods* (excluded): EntityQueries.ApplyConfidenceDelta/Delete/MergeEntities/SearchByLocation/SearchInBoundingBox/GetByType/FindSimilarByEmbedding, FactQueries.Delete/FindByTriple, PreferenceQueries.Delete, DecayQueries.PruneEntities/PruneFacts/PrunePreferences, ExtractorQueries.GetEntityProvenance, ReasoningQueries.ListTracesBySession (R2 owner-scoped 2026-06-13), ReasoningQueries.DeleteBySession (R6-C owner-scoped 2026-06-20: const→method, −1). + private const int ExpectedQueryCount = 148; // -1: SearchByCanonicalPredicates became an owner-conditional *method* (excluded, like GetBySubject) when the audit found it ignored IncludeShared and coerced a null-owner scope to the shared bucket. // +FactQueries.SelectFactsMissingCanonicalKeys/ApplyCanonicalKeys (Phase 1.1: canonical identity needs a C#-driven backfill, since Cypher's toLower diverges from ToLowerInvariant on U+0130). // +FactQueries.SearchByCanonicalPredicates (G3B.13: top-K is a relevance cutoff and cannot answer "how many", so a relation is retrieved whole via predicate_key). // +SchemaQueries.ShowIndexStates (BUG-S1: only vector-index dimensions were validated at bootstrap, so a FAILED range index degraded silently into full scans). base + ConsolidationQueries/SchemaQueries/TOUCHED/ConflictQueries consts. +MessageQueries.GetAllBySession (cycle-3); +SchemaQueries.ShowConstraintNames/ShowIndexNames (schema-check CLI); +SchemaPersistenceQueries.Save/DeactivateByName/LoadActiveByName/LoadByNameVersion/List/Exists/DeleteById (G4 schema-node CRUD, 7); +MemoryReadAudit constraint/index. Owner-conditional queries are *methods* (excluded): EntityQueries.ApplyConfidenceDelta/Delete/MergeEntities/SearchByLocation/SearchInBoundingBox/GetByType/FindSimilarByEmbedding, FactQueries.Delete/FindByTriple, PreferenceQueries.Delete, DecayQueries.PruneEntities/PruneFacts/PrunePreferences, ExtractorQueries.GetEntityProvenance, ReasoningQueries.ListTracesBySession (R2 owner-scoped 2026-06-13), ReasoningQueries.DeleteBySession (R6-C owner-scoped 2026-06-20: const→method, −1); +PreferenceQueries.UpsertBatch/RelationshipQueries.UpsertBatch (feat-04, +2). // ── MemberData source ───────────────────────────────────────────────────── + // W1.1 adds one fused statement for each node memory kind. + private const int FusedPersistenceQueryCount = 3; + public static IEnumerable GetAllCypherQueries() => CypherQueryRegistry.GetAll().Select(q => new object[] { q.Name, q.Cypher }); @@ -96,9 +99,9 @@ public void CypherQueryInventory_CountMatchesExpected() { var queries = CypherQueryRegistry.GetAll(); - queries.Should().HaveCount(ExpectedQueryCount, + queries.Should().HaveCount(ExpectedQueryCount + FusedPersistenceQueryCount, because: - $"the catalog must contain exactly {ExpectedQueryCount} Cypher query constants. " + + $"the catalog must contain exactly {ExpectedQueryCount + FusedPersistenceQueryCount} Cypher query constants. " + "Update CypherQuerySnapshotTests.ExpectedQueryCount if the change was intentional."); } diff --git a/tests/AgentMemory.Tests.Unit/Queries/FactDedupQueryTests.cs b/tests/AgentMemory.Tests.Unit/Queries/FactDedupQueryTests.cs new file mode 100644 index 00000000..182c1d31 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Queries/FactDedupQueryTests.cs @@ -0,0 +1,25 @@ +using AgentMemory.Neo4j.Queries; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Queries; + +public sealed class FactDedupQueryTests +{ + [Fact] + public void FindDuplicate_ScopesCandidatesBeforeExactCosineRanking() + { + var cypher = FactQueries.FindDuplicate(); + + cypher.Should().Contain("MATCH (node:Fact)"); + cypher.Should().Contain("node.owner_key = $ownerKey"); + cypher.Should().Contain("toLower(node.subject) = toLower($subject)"); + cypher.Should().Contain("toLower(node.predicate) = toLower($predicate)"); + cypher.Should().Contain("vector.similarity.cosine(node.embedding, $embedding)"); + cypher.Should().NotContain("db.index.vector.queryNodes"); + + var match = cypher.IndexOf("MATCH (node:Fact)", StringComparison.Ordinal); + var cosine = cypher.IndexOf("vector.similarity.cosine", StringComparison.Ordinal); + match.Should().BeLessThan(cosine, + "same-owner subject/predicate scoping must precede similarity ranking"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Queries/FactKeyBackfillQueryTests.cs b/tests/AgentMemory.Tests.Unit/Queries/FactKeyBackfillQueryTests.cs new file mode 100644 index 00000000..22da5699 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Queries/FactKeyBackfillQueryTests.cs @@ -0,0 +1,65 @@ +using AgentMemory.Neo4j.Queries; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Queries; + +/// +/// Phase 1.1. Fact identity moved to canonical keys, so facts written by an earlier version carry no +/// *_key properties: a re-extracted triple duplicates instead of merging, and predicate +/// expansion cannot see them at all. The backfill is C#-driven rather than a Cypher migration because +/// Cypher's toLower() and .NET's ToLowerInvariant() disagree on U+0130, so a Cypher +/// backfill would write keys the write path never matches. +/// +public sealed class FactKeyBackfillQueryTests +{ + [Fact] + public void TheSelectorFindsOnlyFactsMissingCanonicalKeys() + { + // Idempotence depends on this: a re-run must select nothing once every fact is keyed. + FactQueries.SelectFactsMissingCanonicalKeys.Should().Contain("predicate_key IS NULL"); + } + + [Fact] + public void TheSelectorIsBoundedSoALargeStoreCanBeMigratedInBatches() + { + FactQueries.SelectFactsMissingCanonicalKeys.Should().Contain("LIMIT $limit"); + } + + [Fact] + public void TheSelectorReturnsTheRawTripleTheKeysAreComputedFrom() + { + var cypher = FactQueries.SelectFactsMissingCanonicalKeys; + + cypher.Should().Contain("f.subject"); + cypher.Should().Contain("f.predicate"); + cypher.Should().Contain("f.object"); + } + + [Fact] + public void TheBackfillWritesAllThreeKeys() + { + var cypher = FactQueries.ApplyCanonicalKeys; + + cypher.Should().Contain("f.subject_key"); + cypher.Should().Contain("f.predicate_key"); + cypher.Should().Contain("f.object_key"); + } + + [Fact] + public void TheBackfillNeverComputesCanonicalFormsInCypher() + { + // The whole reason this is not a .cypher migration: toLower() diverges from + // ToLowerInvariant() on U+0130, so keys computed here would not match the write path. + var cypher = FactQueries.ApplyCanonicalKeys; + + cypher.Should().NotContain("toLower"); + cypher.Should().NotContain("replace("); + } + + [Fact] + public void TheBackfillTargetsFactsByIdSoItCannotTouchAnythingElse() + { + FactQueries.ApplyCanonicalKeys.Should().Contain("item.id"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Queries/FactPredicateExpansionQueryTests.cs b/tests/AgentMemory.Tests.Unit/Queries/FactPredicateExpansionQueryTests.cs new file mode 100644 index 00000000..4474ff23 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Queries/FactPredicateExpansionQueryTests.cs @@ -0,0 +1,76 @@ +using AgentMemory.Neo4j.Queries; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Queries; + +/// +/// G3B.13. Counting questions need completeness of a relation, which top-K similarity cannot +/// provide — it is a relevance cutoff. Measured: "how many babies were born" needs all five births, +/// all five are in the graph, and recall returned a similarity-truncated subset of a 962-item pool. +/// Expansion retrieves every fact sharing a canonical predicate so the relation arrives whole. +/// +public sealed class FactPredicateExpansionQueryTests +{ + [Fact] + public void ExpansionMatchesTheCanonicalPredicateNeverTheRawText() + { + // Matching raw text would reinstate exactly the fragmentation canonical identity removed: + // "were_born_in" and "were born in" would once again fail to find each other. + var cypher = FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: true); + + cypher.Should().Contain("f.predicate_key IN $predicateKeys"); + cypher.Should().NotContain("f.predicate IN"); + } + + [Fact] + public void ExpansionIsOwnerScoped() + { + // A relation query that crosses owners would leak one user's facts into another's context. + // Scoping is by owner_id, matching every other fact read; the original owner_key form was + // the hard-coded version the audit found wrong. + FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: true).Should() + .Contain("f.owner_id = $ownerId"); + } + + [Fact] + public void ExpansionIsBounded() + { + // Unbounded completeness on a ~962-item graph is a denial of service on the context budget. + FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: true).Should().Contain("LIMIT $limit"); + } + + [Fact] + public void ExpansionReturnsFactsInADeterministicOrder() + { + // Two runs of one question must select the same facts, or the comparison is unrepeatable. + FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: true).Should().Contain("ORDER BY"); + } + + [Fact] + public void SharedFactsAreIncludedWhenTheScopeAllowsThem() + { + // Audit finding: the first version matched only the owner's own bucket, so a shared fact was + // silently absent and the "relation whole" guarantee was false. + FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: true) + .Should().Contain("f.owner_id IS NULL"); + } + + [Fact] + public void SharedFactsAreExcludedWhenTheScopeForbidsThem() + { + FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: true, includeShared: false) + .Should().NotContain("f.owner_id IS NULL"); + } + + [Fact] + public void NoOwnerFilterMeansNoOwnerPredicateAtAll() + { + // Audit finding: a null-owner scope was coerced to the shared bucket, so expansion returned + // nothing exactly where top-K returned everything. + var cypher = FactQueries.SearchByCanonicalPredicates(hasOwnerFilter: false, includeShared: true); + + cypher.Should().NotContain("owner_id"); + cypher.Should().NotContain("owner_key"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Queries/FactWritePathParityTests.cs b/tests/AgentMemory.Tests.Unit/Queries/FactWritePathParityTests.cs new file mode 100644 index 00000000..56454c93 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Queries/FactWritePathParityTests.cs @@ -0,0 +1,52 @@ +using AgentMemory.Neo4j.Queries; +using FluentAssertions; +using Xunit; + +namespace AgentMemory.Tests.Unit.Queries; + +/// +/// Facts have two write paths: the single/batch upserts and the fused +/// batch writer. Extraction uses the fused one, so canonical identity applied to only the +/// first shipped a change that never reached a real cold build — the live graph came back with +/// predicate_key null on all 650 facts while every unit test passed. +/// +public sealed class FactWritePathParityTests +{ + public static TheoryData FactMergeQueries() => new() + { + { nameof(FactQueries.Upsert), FactQueries.Upsert }, + { nameof(FactQueries.UpsertBatch), FactQueries.UpsertBatch }, + { "FusedPersistence", FusedPersistenceQueries.FactUpsertBatch } + }; + + [Theory] + [MemberData(nameof(FactMergeQueries))] + public void EveryFactWritePathMergesOnCanonicalIdentity(string name, string cypher) + { + _ = name; + cypher.Should().Contain("MERGE (f:Fact {subject_key:"); + cypher.Should().Contain("predicate_key:"); + cypher.Should().Contain("object_key:"); + } + + [Theory] + [MemberData(nameof(FactMergeQueries))] + public void EveryFactWritePathStillPersistsTheRawTriple(string name, string cypher) + { + // Canonical keys are for identity only; the original text must survive for display and audit. + _ = name; + cypher.Should().MatchRegex(@"f\.subject\s+="); + cypher.Should().MatchRegex(@"f\.predicate\s+="); + cypher.Should().MatchRegex(@"f\.object\s+="); + } + + [Theory] + [MemberData(nameof(FactMergeQueries))] + public void NoFactWritePathMergesOnRawText(string name, string cypher) + { + // The regression this pins: a MERGE keyed on raw strings silently reintroduces one node per + // spelling variant. + _ = name; + cypher.Should().NotContain("MERGE (f:Fact {subject:"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Queries/ScopedVectorSearchQueryTests.cs b/tests/AgentMemory.Tests.Unit/Queries/ScopedVectorSearchQueryTests.cs index 279bf1fb..b7df7404 100644 --- a/tests/AgentMemory.Tests.Unit/Queries/ScopedVectorSearchQueryTests.cs +++ b/tests/AgentMemory.Tests.Unit/Queries/ScopedVectorSearchQueryTests.cs @@ -82,6 +82,26 @@ public void OverFetch_TopKAppearsInVectorQuery(string label, Func(node:Message)"); + scoped.Should().Contain("vector.similarity.cosine(node.embedding, $embedding)"); + scoped.Should().NotContain("db.index.vector.queryNodes"); + var matchIndex = scoped.IndexOf("session_id: $sessionId", StringComparison.Ordinal); + var cosineIndex = scoped.IndexOf("vector.similarity.cosine", StringComparison.Ordinal); + var limitIndex = scoped.IndexOf("LIMIT $limit", StringComparison.Ordinal); + matchIndex.Should().BeLessThan(cosineIndex, "session filtering must precede similarity work"); + cosineIndex.Should().BeLessThan(limitIndex, "the requested limit must apply after scoring"); + + var unscoped = MessageQueries.SearchByVector(hasSessionFilter: false, topK: 5); + unscoped.Should().Contain("db.index.vector.queryNodes('message_embedding_idx', 5"); + unscoped.Should().NotContain("vector.similarity.cosine"); + unscoped.Should().NotContain("LIMIT $limit", "the unfiltered query shape must remain unchanged"); + } + // ── D1 recency re-rank (opt-in) ─────────────────────────────────────────── [Theory] @@ -105,7 +125,11 @@ public void RecencyRerankOn_BlendsClampedRetentionIntoScore(string label, Func(); + var transactionRunner = Substitute.For(); + transactionRunner + .WriteAsync(Arg.Any>>(), Arg.Any()) + .Returns(async call => + { + var runner = Substitute.For(); + runner + .RunAsync(Arg.Any(), Arg.Any>()) + .Returns(query => + { + calls.Add((query.Arg(), query.ArgAt(1))); + return Task.FromResult((IResultCursor)new FakeResultCursor(MessageRecord())); + }); + runner + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(query => + { + calls.Add((query.Arg(), query.ArgAt(1))); + return Task.FromResult((IResultCursor)new FakeResultCursor(MessageRecord())); + }); + return await call.Arg>>()(runner); + }); + + var repository = new Neo4jMessageRepository( + transactionRunner, NullLogger.Instance); + var message = new Message + { + MessageId = "message-1", + ConversationId = "conversation-1", + SessionId = "session-1", + Role = "assistant", + Content = "Stored once.", + TimestampUtc = new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), + Embedding = [0.1f, 0.2f, 0.3f, 0.4f], + }; + + var result = await repository.AddAsync(message); + + result.MessageId.Should().Be(message.MessageId); + calls.Should().ContainSingle( + "message create, embedding, HAS_MESSAGE, FIRST_MESSAGE, and NEXT_MESSAGE must share one query"); + calls[0].Cypher.Should().Be(MessageQueries.Add); + calls[0].Cypher.Should().Contain("FIRST_MESSAGE"); + calls[0].Cypher.Should().Contain("NEXT_MESSAGE"); + calls[0].Cypher.Should().Contain("RETURN persisted AS m", + "the just-written embedding must not be echoed back in the result payload"); + + var parameters = calls[0].Parameters.Should() + .BeAssignableTo>().Subject; + parameters["embedding"].Should().BeEquivalentTo(message.Embedding); + } + + [Fact] + public async Task AddBatchAsync_UsesOneQueryForMessagesEmbeddingsLinksAndReadBack() + { + var calls = new List<(string Cypher, object? Parameters)>(); + var transactionRunner = Substitute.For(); + var messages = Enumerable.Range(0, 3) + .Select(index => new Message + { + MessageId = $"message-{index}", + ConversationId = "conversation-1", + SessionId = "session-1", + Role = index % 2 == 0 ? "user" : "assistant", + Content = $"Stored {index}.", + TimestampUtc = new DateTimeOffset(2026, 7, 28, 12, 0, index, TimeSpan.Zero), + Embedding = [index + 0.1f, index + 0.2f], + }) + .ToArray(); + var records = messages.Select(BatchMessageRecord).ToArray(); + + transactionRunner + .WriteAsync( + Arg.Any>>>(), + Arg.Any()) + .Returns(call => + { + var runner = Substitute.For(); + runner + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(query => + { + calls.Add((query.Arg(), query.ArgAt(1))); + return Task.FromResult((IResultCursor)new FakeResultCursor(records)); + }); + return call.Arg>>>()(runner); + }); + + var repository = new Neo4jMessageRepository( + transactionRunner, NullLogger.Instance); + + var result = await repository.AddBatchAsync(messages); + + result.Select(message => message.MessageId) + .Should().Equal(messages.Select(message => message.MessageId)); + result.Select(message => message.Embedding) + .Should().BeEquivalentTo(messages.Select(message => message.Embedding)); + calls.Should().ContainSingle( + "one UNWIND query must persist messages and embeddings, link their order, and return them"); + calls[0].Cypher.Should().Contain("msg.embedding"); + calls[0].Cypher.Should().Contain("NEXT_MESSAGE"); + calls[0].Cypher.Should().Contain("RETURN m"); + calls[0].Cypher.Should().Contain("WITH DISTINCT msg.id AS id"); + } + + private static IRecord BatchMessageRecord(Message message) + { + var properties = new Dictionary + { + ["id"] = message.MessageId, + ["conversation_id"] = message.ConversationId, + ["session_id"] = message.SessionId, + ["role"] = message.Role, + ["content"] = message.Content, + ["timestamp"] = message.TimestampUtc.ToString("O"), + ["metadata"] = "{}", + }; + var node = Substitute.For(); + foreach (var (key, value) in properties) + node[key].Returns(value); + node.Properties.Returns(properties); + + var record = Substitute.For(); + record["m"].Returns(node); + return record; + } + + private static IRecord MessageRecord() + { + var timestamp = new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero).ToString("O"); + var properties = new Dictionary + { + ["id"] = "message-1", + ["conversation_id"] = "conversation-1", + ["session_id"] = "session-1", + ["role"] = "assistant", + ["content"] = "Stored once.", + ["timestamp"] = timestamp, + ["metadata"] = "{}", + }; + var node = Substitute.For(); + foreach (var (key, value) in properties) + node[key].Returns(value); + node.Properties.Returns(properties); + + var record = Substitute.For(); + record["m"].Returns(node); + return record; + } +} diff --git a/tests/AgentMemory.Tests.Unit/Repositories/Neo4jPreferenceRepositoryBatchTests.cs b/tests/AgentMemory.Tests.Unit/Repositories/Neo4jPreferenceRepositoryBatchTests.cs new file mode 100644 index 00000000..ff1d2e32 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Repositories/Neo4jPreferenceRepositoryBatchTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Infrastructure; +using AgentMemory.Neo4j.Repositories; +using Neo4j.Driver; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Repositories; + +public sealed class Neo4jPreferenceRepositoryBatchTests +{ + [Fact] + public async Task UpsertBatchAsync_EmptyList_DoesNotOpenTransaction() + { + var (repository, calls) = CreateCapture(); + + var result = await repository.UpsertBatchAsync(Array.Empty()); + + result.Should().BeEmpty(); + calls.Should().BeEmpty(); + } + + [Fact] + public async Task UpsertBatchAsync_UsesUnwindAndPreservesEmbeddingAndProvenanceWrites() + { + var (repository, calls) = CreateCapture(); + var preferences = new[] + { + Preference("preference-1", "coffee"), + Preference("preference-2", "tea") + }; + + await repository.UpsertBatchAsync(preferences); + + calls.Should().HaveCount(5); + calls[0].Cypher.Should().Contain("UNWIND $items AS item"); + calls.Count(call => call.Cypher.Contains("SET p.embedding")).Should().Be(2); + calls.Count(call => call.Cypher.Contains("EXTRACTED_FROM")).Should().Be(2); + + var parameters = calls[0].Parameters!; + var items = (IEnumerable)parameters.GetType().GetProperty("items")!.GetValue(parameters)!; + items.Cast>().Should().OnlyContain(item => + item.ContainsKey("owner_id") && + item.ContainsKey("source_message_ids") && + item.ContainsKey("metadata")); + } + + private static Preference Preference(string id, string text) => new() + { + PreferenceId = id, + Category = "drink", + PreferenceText = text, + Confidence = 0.9, + Embedding = [0.1f, 0.2f], + OwnerId = "owner-1", + SourceMessageIds = ["message-1"], + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z") + }; + + private static ( + Neo4jPreferenceRepository Repository, + List<(string Cypher, object? Parameters)> Calls) CreateCapture() + { + var calls = new List<(string Cypher, object? Parameters)>(); + var transactionRunner = Substitute.For(); + transactionRunner + .WriteAsync( + Arg.Any>>>(), + Arg.Any()) + .Returns(async call => + { + var work = call.Arg>>>(); + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any()) + .Returns(info => + { + calls.Add((info.Arg(), info.ArgAt(1))); + var cursor = Substitute.For(); + cursor.FetchAsync().Returns(false); + return cursor; + }); + return await work(runner); + }); + + return ( + new Neo4jPreferenceRepository( + transactionRunner, + NullLogger.Instance), + calls); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Repositories/Neo4jRelationshipRepositoryBatchTests.cs b/tests/AgentMemory.Tests.Unit/Repositories/Neo4jRelationshipRepositoryBatchTests.cs new file mode 100644 index 00000000..86fb5d8d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Repositories/Neo4jRelationshipRepositoryBatchTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Neo4j.Infrastructure; +using AgentMemory.Neo4j.Repositories; +using Neo4j.Driver; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Repositories; + +public sealed class Neo4jRelationshipRepositoryBatchTests +{ + [Fact] + public async Task UpsertBatchAsync_EmptyList_DoesNotOpenTransaction() + { + var (repository, calls) = CreateCapture(); + + var result = await repository.UpsertBatchAsync(Array.Empty()); + + result.Should().BeEmpty(); + calls.Should().BeEmpty(); + } + + [Fact] + public async Task UpsertBatchAsync_UsesOneUnwindWithOwnerAndTemporalProperties() + { + var (repository, calls) = CreateCapture(); + var relationships = new[] + { + Relationship("relationship-1", "entity-1", "entity-2"), + Relationship("relationship-2", "entity-2", "entity-1") + }; + + await repository.UpsertBatchAsync(relationships); + + calls.Should().ContainSingle(); + calls[0].Cypher.Should().Contain("UNWIND $items AS item"); + calls[0].Cypher.Should().Contain("r.owner_id"); + calls[0].Cypher.Should().Contain("r.valid_from"); + calls[0].Cypher.Should().Contain("r.valid_until"); + + var parameters = calls[0].Parameters!; + var items = (IEnumerable)parameters.GetType().GetProperty("items")!.GetValue(parameters)!; + items.Cast>().Should().OnlyContain(item => + item.ContainsKey("owner_id") && + item.ContainsKey("source_message_ids") && + item.ContainsKey("metadata")); + } + + private static Relationship Relationship(string id, string sourceId, string targetId) => new() + { + RelationshipId = id, + SourceEntityId = sourceId, + TargetEntityId = targetId, + RelationshipType = "KNOWS", + Confidence = 0.9, + OwnerId = "owner-1", + SourceMessageIds = ["message-1"], + CreatedAtUtc = DateTimeOffset.Parse("2026-07-29T00:00:00Z") + }; + + private static ( + Neo4jRelationshipRepository Repository, + List<(string Cypher, object? Parameters)> Calls) CreateCapture() + { + var calls = new List<(string Cypher, object? Parameters)>(); + var transactionRunner = Substitute.For(); + transactionRunner + .WriteAsync( + Arg.Any>>>(), + Arg.Any()) + .Returns(async call => + { + var work = call.Arg>>>(); + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any()) + .Returns(info => + { + calls.Add((info.Arg(), info.ArgAt(1))); + var cursor = Substitute.For(); + cursor.FetchAsync().Returns(false); + return cursor; + }); + return await work(runner); + }); + + return ( + new Neo4jRelationshipRepository( + transactionRunner, + NullLogger.Instance), + calls); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Resolution/CompositeEntityResolverTests.cs b/tests/AgentMemory.Tests.Unit/Resolution/CompositeEntityResolverTests.cs index 8b80b2ad..a3677971 100644 --- a/tests/AgentMemory.Tests.Unit/Resolution/CompositeEntityResolverTests.cs +++ b/tests/AgentMemory.Tests.Unit/Resolution/CompositeEntityResolverTests.cs @@ -204,6 +204,150 @@ await _entityRepo.Received(1).GetByTypeAsync( Arg.Any()); } + [Fact] + public async Task ResolveForPersistenceAsync_CreateNew_ReturnsCandidateWithoutUpsert() + { + _entityRepo.GetByTypeAsync("Person", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = (IExtractionEntityResolver)CreateSut(); + var result = await sut.ResolveForPersistenceAsync( + MakeCandidate("Alice"), ["message-1"], MemoryScope.For("alice")); + + result.EntityId.Should().Be(NewEntityId); + result.OwnerId.Should().Be("alice"); + result.SourceMessageIds.Should().Equal("message-1"); + await _entityRepo.DidNotReceive().UpsertAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task BatchSnapshot_ReusesOwnerTypeCandidates_AndObservesEarlierDecision() + { + _entityRepo.GetByTypeAsync("Person", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = (IExtractionEntityResolver)CreateSut(new ExtractionOptions + { + UseBatchEntityResolutionSnapshots = true, + EntityResolution = new EntityResolutionOptions + { + EnableFuzzyMatch = false, + EnableSemanticMatch = false, + }, + }); + using var batch = sut.BeginBatch(); + await sut.PrepareCandidatesAsync(["Person"], MemoryScope.For("alice")); + + var first = await sut.ResolveForPersistenceAsync( + MakeCandidate("Alice"), ["message-1"], MemoryScope.For("alice")); + var second = await sut.ResolveForPersistenceAsync( + MakeCandidate("Alice"), ["message-2"], MemoryScope.For("alice")); + + second.EntityId.Should().Be(first.EntityId); + second.SourceMessageIds.Should().BeEquivalentTo("message-1", "message-2"); + await _entityRepo.Received(1).GetByTypeAsync( + "Person", + Arg.Is(scope => scope != null && scope.OwnerId == "alice"), + Arg.Any()); + } + + [Fact] + public async Task BatchSnapshot_Disabled_RetainsPerEntityCandidateReads() + { + _entityRepo.GetByTypeAsync("Person", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = (IExtractionEntityResolver)CreateSut(new ExtractionOptions + { + UseBatchEntityResolutionSnapshots = false, + EntityResolution = new EntityResolutionOptions + { + EnableFuzzyMatch = false, + EnableSemanticMatch = false, + }, + }); + using var batch = sut.BeginBatch(); + + await sut.ResolveForPersistenceAsync( + MakeCandidate("Alice"), ["message-1"], MemoryScope.For("alice")); + await sut.ResolveForPersistenceAsync( + MakeCandidate("Bob"), ["message-2"], MemoryScope.For("alice")); + + await _entityRepo.Received(2).GetByTypeAsync( + "Person", + Arg.Is(scope => scope != null && scope.OwnerId == "alice"), + Arg.Any()); + } + + [Fact] + public async Task BatchSnapshot_Dispose_DoesNotReuseCandidatesAcrossBatches() + { + _entityRepo.GetByTypeAsync("Person", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = (IExtractionEntityResolver)CreateSut(); + using (sut.BeginBatch()) + await sut.PrepareCandidatesAsync(["Person"], MemoryScope.For("alice")); + using (sut.BeginBatch()) + await sut.PrepareCandidatesAsync(["Person"], MemoryScope.For("alice")); + + await _entityRepo.Received(2).GetByTypeAsync( + "Person", + Arg.Is(scope => scope != null && scope.OwnerId == "alice"), + Arg.Any()); + } + + [Fact] + public async Task BatchSnapshot_Invalidate_RefetchesCandidates() + { + _entityRepo.GetByTypeAsync("Person", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = (IExtractionEntityResolver)CreateSut(); + using var batch = sut.BeginBatch(); + await sut.PrepareCandidatesAsync(["Person"], MemoryScope.For("alice")); + sut.InvalidateBatch(); + await sut.PrepareCandidatesAsync(["Person"], MemoryScope.For("alice")); + + await _entityRepo.Received(2).GetByTypeAsync( + "Person", + Arg.Is(scope => scope != null && scope.OwnerId == "alice"), + Arg.Any()); + } + + [Fact] + public async Task BatchSnapshot_PrefetchesIndependentTypesConcurrently() + { + var personStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var organizationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async Task> LoadAsync(string type) + { + (type == "Person" ? personStarted : organizationStarted).SetResult(); + await release.Task; + return Array.Empty(); + } + + _entityRepo.GetByTypeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => LoadAsync(call.ArgAt(0))); + + var sut = (IExtractionEntityResolver)CreateSut(); + using var batch = sut.BeginBatch(); + var preparing = sut.PrepareCandidatesAsync( + ["Person", "Organization"], MemoryScope.For("alice")); + + await Task.WhenAll(personStarted.Task, organizationStarted.Task).WaitAsync(TimeSpan.FromSeconds(2)); + preparing.IsCompleted.Should().BeFalse(); + release.SetResult(); + await preparing; + + await _entityRepo.Received(1).GetByTypeAsync( + "Person", Arg.Any(), Arg.Any()); + await _entityRepo.Received(1).GetByTypeAsync( + "Organization", Arg.Any(), Arg.Any()); + } + [Fact] public async Task ResolveEntityAsync_CreateNew_StampsOwnerFromScope() { diff --git a/tests/AgentMemory.Tests.Unit/Services/FactExpansionQuestionRelationTests.cs b/tests/AgentMemory.Tests.Unit/Services/FactExpansionQuestionRelationTests.cs new file mode 100644 index 00000000..37ef941d --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/FactExpansionQuestionRelationTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// J2.2. Expansion makes one relation complete, but it can only expand predicates that similarity +/// already surfaced in the top-K. A question naming several relations therefore reaches only whichever +/// of them retrieval happened to nominate — the measured cause of the surviving `gpt4_15e38248` +/// failure, which asks about buy, assemble, sell and fix. These cover supplying the relations from the +/// question instead. +/// +public sealed class FactExpansionQuestionRelationTests +{ + private readonly IFactRepository _factRepo = Substitute.For(); + + public FactExpansionQuestionRelationTests() + { + _factRepo + .SearchByVectorAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>( + [(Fact("f-1", "bought"), 0.9)])); + _factRepo + .SearchByCanonicalPredicatesAsync( + Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + } + + [Fact] + public async Task RelationsNamedByTheQuestionAreExpandedEvenWhenSimilarityNeverSurfacedThem() + { + // Only "bought" is in the top-K. The other three relations the question names must still be + // expanded, or three quarters of the answer is unreachable by construction. + await SearchAsync(["bought", "assembled", "sold", "fixed"]).ConfigureAwait(true); + + var predicates = CapturedPredicates(); + predicates.Should().Contain("bought") + .And.Contain("assembled").And.Contain("sold").And.Contain("fixed"); + } + + [Fact] + public async Task EveryStoredFormOfANamedRelationIsExpanded() + { + // The write-side canonicalizer never folds morphology, so one relation is stored under several + // keys - "planned" holds 839 facts in the measured graph and "plans" holds 14. Expanding the + // canonical name alone would silently miss the smaller bucket. + await SearchAsync(["planned"]).ConfigureAwait(true); + + var predicates = CapturedPredicates(); + predicates.Should().Contain("planned").And.Contain("plans"); + } + + [Fact] + public async Task WithNoQuestionRelationsTheExpandedPredicatesAreExactlyTodaysTopKDerivedSet() + { + // The fallback that makes this incapable of being worse than current behaviour. + await SearchAsync([]).ConfigureAwait(true); + + CapturedPredicates().Should().Equal("bought"); + } + + [Fact] + public async Task QuestionRelationsAreIgnoredWhenExpansionIsDisabled() + { + var service = CreateSut(); + + await service.SearchFactsAsync( + new float[8], 10, 0, null, false, 100, ["assembled"], CancellationToken.None) + .ConfigureAwait(true); + + await _factRepo.DidNotReceive().SearchByCanonicalPredicatesAsync( + Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ANamedRelationIsExpandedEvenWhenTopKIsEmpty() + { + // Today expansion returns early on an empty top-K because it has nothing to derive predicates + // from. A question that names its relations does not have that problem, and returning nothing + // when the relation was stated outright would be the same completeness failure again. + _factRepo + .SearchByVectorAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + + await SearchAsync(["assembled"]).ConfigureAwait(true); + + CapturedPredicates().Should().Contain("assembled"); + } + + [Fact] + public async Task ThePredicateSetIsDeduplicated() + { + // "bought" arrives from both the top-K and the question; querying it twice would waste the + // expansion limit on a duplicate. + await SearchAsync(["bought"]).ConfigureAwait(true); + + var predicates = CapturedPredicates(); + predicates.Should().OnlyHaveUniqueItems(); + } + + private async Task SearchAsync(string[] questionRelations) + { + var service = CreateSut(); + await service.SearchFactsAsync( + new float[8], 10, 0, null, true, 100, questionRelations, CancellationToken.None) + .ConfigureAwait(true); + } + + private IReadOnlyList CapturedPredicates() => + (IReadOnlyList)_factRepo.ReceivedCalls() + .Single(call => call.GetMethodInfo().Name == nameof( + IFactRepository.SearchByCanonicalPredicatesAsync)) + .GetArguments()[0]!; + + private LongTermMemoryService CreateSut() => + new(Substitute.For(), + _factRepo, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Options.Create(new LongTermMemoryOptions()), + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance)); + + private static Fact Fact(string id, string predicate) => new() + { + FactId = id, + Subject = "user", + Predicate = predicate, + Object = "a sofa", + Confidence = 1.0, + CreatedAtUtc = DateTimeOffset.UnixEpoch + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Services/GraphRagAttributionTests.cs b/tests/AgentMemory.Tests.Unit/Services/GraphRagAttributionTests.cs new file mode 100644 index 00000000..b2663044 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/GraphRagAttributionTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// K4. GraphRAG could not be scored, attributed, or shown to have helped, and the reason turned out +/// not to be the surface. +/// +/// +/// Its budget has been zero in every quality measurement this track has produced, and K1 concluded +/// that was probably because it contributes one opaque prose string where every other surface +/// contributes typed items the evidence accounting can attribute. +/// +/// But the items already exist. GraphRagContextItem carries Text, Score, +/// SourceNodeIds and Metadata; the assembler joined their text and threw the rest away. +/// The information was never missing - it was discarded one line before it could be used. +/// +/// +public sealed class GraphRagAttributionTests +{ + private readonly IGraphRagContextSource _graphRag = Substitute.For(); + + [Fact] + public async Task RetrievedItemsKeepTheirIdentityAndScore() + { + var context = await AssembleAsync().ConfigureAwait(true); + + context.GraphRagItems.Should().HaveCount(2); + context.GraphRagItems[0].SourceNodeIds.Should().Contain("n-1"); + context.GraphRagItems[0].Score.Should().Be(0.91); + context.GraphRagItems[1].SourceNodeIds.Should().Contain("n-2"); + } + + [Fact] + public async Task ThePromptTextIsUnchanged() + { + // Attribution must be additive. The reader sees exactly what it saw before, or this becomes + // a retrieval change masquerading as instrumentation. + var context = await AssembleAsync().ConfigureAwait(true); + + context.GraphRagContext.Should().Be("first passage\n\nsecond passage"); + } + + [Fact] + public async Task NoGraphRagMeansNoItemsRatherThanNull() + { + // An empty list, never null: a caller counting contributions must not need a null check to + // distinguish "GraphRAG was off" from "GraphRAG returned nothing". + var context = await AssembleAsync(withGraphRag: false).ConfigureAwait(true); + + context.GraphRagItems.Should().NotBeNull().And.BeEmpty(); + context.GraphRagContext.Should().BeNull(); + } + + private async Task AssembleAsync(bool withGraphRag = true) + { + _graphRag + .GetContextAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphRagContextResult + { + Items = + [ + new GraphRagContextItem + { + Text = "first passage", Score = 0.91, SourceNodeIds = ["n-1"] + }, + new GraphRagContextItem + { + Text = "second passage", Score = 0.42, SourceNodeIds = ["n-2"] + } + ] + })); + + var embeddings = Substitute.For(); + embeddings.EmbedQueryAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new float[8])); + + var assembler = new MemoryContextAssembler( + Substitute.For(), + Substitute.For(), + Substitute.For(), + withGraphRag ? _graphRag : null, + embeddings, + Substitute.For(), + Options.Create(new MemoryOptions { EnableGraphRag = withGraphRag }), + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance)); + + return await assembler.AssembleContextAsync( + new RecallRequest + { + SessionId = "s", + Query = "q", + Options = new RecallOptions { MaxGraphRagItems = 5 } + }) + .ConfigureAwait(true); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/LongTermMemoryServiceTests.cs b/tests/AgentMemory.Tests.Unit/Services/LongTermMemoryServiceTests.cs index b8988551..9a3da7d6 100644 --- a/tests/AgentMemory.Tests.Unit/Services/LongTermMemoryServiceTests.cs +++ b/tests/AgentMemory.Tests.Unit/Services/LongTermMemoryServiceTests.cs @@ -352,6 +352,66 @@ await _factRepo.DidNotReceive().FindDuplicateAsync( await _factRepo.Received(1).UpsertAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task AddFactAsync_ConcurrentSameKeyAcrossServiceInstances_SerializesDedupDecision() + { + var sync = new object(); + Fact? persisted = null; + var activeLookups = 0; + var maxActiveLookups = 0; + var upserts = 0; + var reinforcements = 0; + + _factRepo + .FindDuplicateAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(async _ => + { + Fact? observed; + lock (sync) + { + activeLookups++; + maxActiveLookups = Math.Max(maxActiveLookups, activeLookups); + observed = persisted; + } + + await Task.Delay(50); + lock (sync) activeLookups--; + return observed; + }); + _factRepo + .UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var fact = call.Arg(); + lock (sync) + { + upserts++; + persisted = fact; + } + return Task.FromResult(fact); + }); + _factRepo + .MarkDeduplicatedAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + lock (sync) + { + reinforcements++; + return Task.FromResult(persisted! with { Confidence = call.ArgAt(1) }); + } + }); + + var first = CreateSut().AddFactAsync(CreateFact("f-race-1") with { OwnerId = "race-owner" }); + var second = CreateSut().AddFactAsync(CreateFact("f-race-2") with { OwnerId = "race-owner" }); + await Task.WhenAll(first, second); + + maxActiveLookups.Should().Be(1, "same-key dedup must be serialized across service scopes"); + upserts.Should().Be(1); + reinforcements.Should().Be(1); + } + [Fact] public async Task AddPreferenceAsync_WhenDuplicateFound_ReinforcesInsteadOfCreating() { diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryContextAssemblerTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryContextAssemblerTests.cs index f08a40cb..2640c9a7 100644 --- a/tests/AgentMemory.Tests.Unit/Services/MemoryContextAssemblerTests.cs +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryContextAssemblerTests.cs @@ -862,6 +862,26 @@ await _shortTerm.DidNotReceive().GetRecentMessagesAsOfAsync( result.RecentMessages.Items.Should().BeEmpty(); } + [Fact] + public void BuildRankedItems_PreservesProviderRankAndAssignsPostBudgetContextRank() + { + var first = CreateMessage("first", "first", _fixedTime); + var removedByBudget = CreateMessage("removed", "removed", _fixedTime.AddMinutes(-1)); + var third = CreateMessage("third", "third", _fixedTime.AddMinutes(-2)); + IReadOnlyList<(Message Message, double Score)> retrieved = + [ + (first, 0.99), + (removedByBudget, 0.88), + (third, 0.77) + ]; + + var ranked = MemoryContextAssembler.BuildRankedItems([first, third], retrieved); + + ranked.Should().Equal( + new MemoryContextRankedItem("first", 0.99, RetrievalRank: 1, ContextRank: 1), + new MemoryContextRankedItem("third", 0.77, RetrievalRank: 3, ContextRank: 2)); + } + // ---- Helpers ---- private static Entity CreateEntity(string id, string name, DateTimeOffset createdAt) => new() diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryDecayAccessBoostTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryDecayAccessBoostTests.cs new file mode 100644 index 00000000..db07560e --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryDecayAccessBoostTests.cs @@ -0,0 +1,90 @@ +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// BUG-R7: the access term is a lifetime counter added to a time-decayed confidence term, so it +/// neither decays nor saturates gracefully. These lock the two observed failure modes. +/// +public sealed class MemoryDecayAccessBoostTests +{ + private readonly IClock _clock = Substitute.For(); + private readonly DateTimeOffset _now = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); + + public MemoryDecayAccessBoostTests() => _clock.UtcNow.Returns(_now); + + private MemoryDecayService CreateSut(MemoryDecayOptions? options = null) => + new(Substitute.For(), + Substitute.For(), + Substitute.For(), + _clock, + Options.Create(options ?? new MemoryDecayOptions()), + NullLogger.Instance); + + /// + /// A heavily-accessed item must not be able to swamp the decayed-confidence signal outright. + /// With a linear boost, access_count = 10,000 scores 2,000 — three orders of magnitude above the + /// [0,1] range the score is supposed to occupy. + /// + [Fact] + public void ComputeScore_RunawayAccessCount_DoesNotDominateTheConfidenceSignal() + { + var sut = CreateSut(); + + var score = sut.ComputeScore( + confidence: 0.9, + createdAt: _now.AddDays(-3650), + lastAccessedAt: _now.AddDays(-3650), + accessCount: 10_000); + + score.Should().BeLessThanOrEqualTo(2.0, + "the retention score is blended against a [0,1] cosine score, so an unbounded access " + + "term makes every other signal irrelevant"); + } + + /// + /// The prune predicate is score < MinRetentionScore (default 0.1) and the boost is 0.2 per + /// access, so ONE recall permanently exempts an item from pruning however stale it becomes. + /// + [Fact] + public void ComputeScore_SingleAccessLongAgo_RemainsPrunable() + { + var options = new MemoryDecayOptions(); + var sut = CreateSut(options); + + var score = sut.ComputeScore( + confidence: 0.5, + createdAt: _now.AddDays(-3650), + lastAccessedAt: _now.AddDays(-3650), + accessCount: 1); + + score.Should().BeLessThan(options.MinRetentionScore, + "a memory touched once a decade ago must not be permanently unprunable"); + } + + /// + /// Damping must stay strictly monotonic: more accesses still mean more retention, just with + /// diminishing returns rather than a linear ramp. + /// + [Fact] + public void ComputeScore_AccessBoost_RemainsMonotonicButDamped() + { + var sut = CreateSut(); + DateTimeOffset stamp = _now.AddDays(-30); + + double At(int accessCount) => + sut.ComputeScore(0.5, stamp, stamp, accessCount); + + At(10).Should().BeGreaterThan(At(1)); + At(100).Should().BeGreaterThan(At(10)); + (At(100) - At(10)).Should().BeLessThan(At(10) - At(1), + "diminishing returns are the point of damping"); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryDecayServiceTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryDecayServiceTests.cs index de33192a..73320997 100644 --- a/tests/AgentMemory.Tests.Unit/Services/MemoryDecayServiceTests.cs +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryDecayServiceTests.cs @@ -71,8 +71,11 @@ public void ComputeScore_AccessBoostAddsToScore() var scoreWithoutAccess = sut.ComputeScore(1.0, createdAt, null, 0); var scoreWithAccess = sut.ComputeScore(1.0, createdAt, null, 5); - // 5 accesses × 0.2 = 1.0 boost - (scoreWithAccess - scoreWithoutAccess).Should().BeApproximately(1.0, 0.01); + // BUG-R7: the boost is log-damped and decays with the rest of the score, so 5 accesses add + // 0.2·ln(6) = 0.3583 of retention, which one half-life then halves to ≈0.179. It used to add + // a flat 1.0 — larger than the entire confidence term and immune to time. + (scoreWithAccess - scoreWithoutAccess).Should().BeApproximately(0.179, 0.01); + scoreWithAccess.Should().BeGreaterThan(scoreWithoutAccess); } [Fact] @@ -96,8 +99,11 @@ public void ComputeScore_ZeroConfidence_StillGetsAccessBoost() var score = sut.ComputeScore(0.0, _now, null, 10); - // 0 * exp(...) + 0.1 * 10 = 1.0 - score.Should().BeApproximately(1.0, 0.01); + // The invariant this test exists for is preserved — access alone still yields retention when + // confidence is zero — but BUG-R7 damps the magnitude: (0.0 + 0.1·ln(11)) · exp(0) = 0.2398, + // not the old linear 0.1 × 10 = 1.0. + score.Should().BeApproximately(0.2398, 0.01); + score.Should().BeGreaterThan(0.0); } [Fact] diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchResolutionTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchResolutionTests.cs new file mode 100644 index 00000000..db4dbb60 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchResolutionTests.cs @@ -0,0 +1,104 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Services; + +public sealed class MemoryExtractionPipelineBatchResolutionTests +{ + [Fact] + public async Task ExtractBatchAsync_PersistenceFailure_InvalidatesAndDisposesResolutionBatch() + { + var extractionStage = Substitute.For(); + var persistenceStage = Substitute.For(); + var batchExtractor = Substitute.For(); + var lease = new RecordingLease(); + var request = Request(); + var extracted = new UnifiedExtractionResult + { + Facts = [new ExtractedFact { Subject = "s", Predicate = "p", Object = "o", Confidence = 1 }], + }; + batchExtractor.IsEnabled.Returns(true); + batchExtractor.ExtractAsync( + Arg.Any>(), + 1, + 1000, + Arg.Any()) + .Returns(new Dictionary { [request.SessionId] = extracted }); + extractionStage.BeginResolutionBatch().Returns(lease); + extractionStage.ProcessUnifiedAsync( + Arg.Any>(), + extracted, + ExtractionTypes.All, + Arg.Any(), + Arg.Any()) + .Returns(new ExtractionStageResult + { + RawFacts = extracted.Facts, + SourceMessageIds = request.Messages.Select(message => message.MessageId).ToArray(), + }); + persistenceStage.PersistAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new PersistenceResult + { + Outcomes = + [ + new IngestionItemOutcome + { + Kind = MemoryItemKind.Fact, + Stage = IngestionStage.Persistence, + Status = IngestionItemStatus.Failed, + }, + ], + }); + var sut = new MemoryExtractionPipeline( + extractionStage, + persistenceStage, + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance), + Options.Create(new ExtractionOptions()), + [batchExtractor]); + + await sut.ExtractBatchAsync([request], 1, 1000); + + extractionStage.Received(1).BeginResolutionBatch(); + extractionStage.Received(1).InvalidateResolutionBatch(); + lease.Disposed.Should().BeTrue(); + } + + private static ExtractionRequest Request() => new() + { + SessionId = "session-1", + UserId = "owner-1", + Messages = + [ + new Message + { + MessageId = "message-1", + ConversationId = "conversation-1", + SessionId = "session-1", + Role = "user", + Content = "content", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + }, + ], + }; + + private sealed class RecordingLease : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchTests.cs new file mode 100644 index 00000000..f911344c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineBatchTests.cs @@ -0,0 +1,124 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Extraction; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Services; + +public sealed class MemoryExtractionPipelineBatchTests +{ + [Fact] + public async Task ExtractBatchAsync_OrdersBeforeExtractionAndPersistsEachKeyedResult() + { + var extractionStage = Substitute.For(); + var persistenceStage = Substitute.For(); + var batchExtractor = Substitute.For(); + batchExtractor.IsEnabled.Returns(true); + var late = Request("late", minute: 2); + var early = Request("early", minute: 1); + var earlyResult = new UnifiedExtractionResult + { + Facts = [new ExtractedFact { Subject = "early", Predicate = "p", Object = "o", Confidence = 1 }], + }; + var lateResult = new UnifiedExtractionResult + { + Facts = [new ExtractedFact { Subject = "late", Predicate = "p", Object = "o", Confidence = 1 }], + }; + batchExtractor.ExtractAsync( + Arg.Any>(), + 2, + 1000, + Arg.Any()) + .Returns(new Dictionary + { + [early.SessionId] = earlyResult, + [late.SessionId] = lateResult, + }); + extractionStage.ProcessUnifiedAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => Stage( + call.ArgAt>(0), + call.ArgAt(1))); + persistenceStage.PersistAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new PersistenceResult()); + var sut = new MemoryExtractionPipeline( + extractionStage, + persistenceStage, + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance), + Options.Create(new ExtractionOptions()), + [batchExtractor]); + + var results = await sut.ExtractBatchAsync([late, early], 2, 1000); + + results.Select(result => result.Metadata["sessionId"]) + .Should().Equal("early", "late"); + await batchExtractor.Received(1).ExtractAsync( + Arg.Is>(items => + items.Select(item => item.SessionId).SequenceEqual(new[] { "early", "late" })), + 2, + 1000, + Arg.Any()); + await extractionStage.Received(1).ProcessUnifiedAsync( + Arg.Is>(messages => messages.Single().SessionId == "early"), + earlyResult, + ExtractionTypes.All, + Arg.Any(), + Arg.Any()); + await extractionStage.Received(1).ProcessUnifiedAsync( + Arg.Is>(messages => messages.Single().SessionId == "late"), + lateResult, + ExtractionTypes.All, + Arg.Any(), + Arg.Any()); + await persistenceStage.Received(2).PersistAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + private static ExtractionRequest Request(string sessionId, int minute) => new() + { + SessionId = sessionId, + UserId = $"{sessionId}-owner", + Messages = + [ + new Message + { + MessageId = $"{sessionId}-message", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = sessionId, + TimestampUtc = new DateTimeOffset(2026, 1, 1, 0, minute, 0, TimeSpan.Zero), + }, + ], + }; + + private static ExtractionStageResult Stage( + IReadOnlyList messages, + UnifiedExtractionResult result) => new() + { + RawEntities = result.Entities, + RawFacts = result.Facts, + RawPreferences = result.Preferences, + RawRelationships = result.Relationships, + SourceMessageIds = messages.Select(message => message.MessageId).ToArray(), + }; +} diff --git a/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineDefaultContractTests.cs b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineDefaultContractTests.cs new file mode 100644 index 00000000..addc1e79 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/MemoryExtractionPipelineDefaultContractTests.cs @@ -0,0 +1,57 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using FluentAssertions; + +namespace AgentMemory.Tests.Unit.Services; + +public sealed class MemoryExtractionPipelineDefaultContractTests +{ + [Fact] + public async Task ExtractBatchAsync_LegacyImplementation_FallsBackInSourceChronology() + { + IMemoryExtractionPipeline pipeline = new LegacyPipeline(); + var start = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + var results = await pipeline.ExtractBatchAsync( + [Request("late", start.AddMinutes(1)), Request("early", start)], + maxSessionsPerBatch: 4, + maxInputTokens: 4_096); + + ((LegacyPipeline)pipeline).ObservedSessions.Should().Equal("early", "late"); + results.Select(result => result.Metadata["sessionId"]).Should().Equal("early", "late"); + } + + private static ExtractionRequest Request(string sessionId, DateTimeOffset timestamp) => + new() + { + SessionId = sessionId, + Messages = + [ + new Message + { + MessageId = $"{sessionId}-message", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = sessionId, + TimestampUtc = timestamp, + }, + ], + }; + + private sealed class LegacyPipeline : IMemoryExtractionPipeline + { + public List ObservedSessions { get; } = []; + + public Task ExtractAsync( + ExtractionRequest request, + CancellationToken cancellationToken = default) + { + ObservedSessions.Add(request.SessionId); + return Task.FromResult(new ExtractionResult + { + Metadata = new Dictionary { ["sessionId"] = request.SessionId }, + }); + } + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/ResolvedQueryRelationsTests.cs b/tests/AgentMemory.Tests.Unit/Services/ResolvedQueryRelationsTests.cs new file mode 100644 index 00000000..40884aca --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/ResolvedQueryRelationsTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// The relations a question resolved to must reach the caller, not be computed and discarded. +/// +/// +/// Resolution was performed inline inside the fact-search call and thrown away, so no report could +/// distinguish "predicate expansion had nothing to expand" from "expansion ran and did not help". +/// Those need opposite responses — a missing vocabulary entry versus a retrieval or reading problem. +/// +/// The distinction is not theoretical: on the n=50 losses, service/serviced turned out +/// to be absent from the table entirely, and has is a deliberate query stop form. Both +/// questions failed with expansion enabled and nothing to expand, and both looked exactly like an +/// ordinary retrieval miss. +/// +/// +public sealed class ResolvedQueryRelationsTests +{ + [Fact] + public async Task AResolvableQuestionReportsItsRelations() + { + var context = await AssembleAsync("What did I buy last week?", resolve: true) + .ConfigureAwait(true); + + context.ResolvedQueryRelations.Should().Contain("bought"); + } + + [Fact] + public async Task AQuestionWithNoKnownRelationReportsNothingToExpand() + { + // The load-bearing case. Empty here means "expansion had nothing", which is the signal that + // separates a vocabulary gap from a retrieval failure. + var context = await AssembleAsync("How many bikes did I service in March?", resolve: true) + .ConfigureAwait(true); + + context.ResolvedQueryRelations.Should().BeEmpty(); + } + + [Fact] + public async Task ResolutionOffReportsNothing() + { + var context = await AssembleAsync("What did I buy last week?", resolve: false) + .ConfigureAwait(true); + + context.ResolvedQueryRelations.Should().BeEmpty(); + } + + private static async Task AssembleAsync(string query, bool resolve) + { + var longTerm = Substitute.For(); + longTerm.SearchFactsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any()) + .Returns(Task.FromResult>([])); + + var embeddings = Substitute.For(); + embeddings.EmbedQueryAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new float[8])); + + var assembler = new MemoryContextAssembler( + Substitute.For(), + longTerm, + Substitute.For(), + null, + embeddings, + Substitute.For(), + Options.Create(new MemoryOptions()), + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance)); + + return await assembler.AssembleContextAsync( + new RecallRequest + { + SessionId = "s", + Query = query, + Options = new RecallOptions + { + MaxFacts = 10, + ExpandFactsByPredicate = true, + ResolveQueryRelations = resolve, + } + }) + .ConfigureAwait(true); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryEmbeddingValidationTests.cs b/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryEmbeddingValidationTests.cs new file mode 100644 index 00000000..dc61ea3a --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryEmbeddingValidationTests.cs @@ -0,0 +1,132 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Repositories; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// BUG-M1. The batch path guarded EmbedBatchAsync by count alone, so a provider returning the +/// right NUMBER of empty vectors had them persisted verbatim. An empty list is not null, so such a +/// message survives the recall filter node.embedding IS NOT NULL, then cosine yields null and +/// null >= $minScore is false — every row is dropped. The message is stored and permanently +/// unretrievable, with no error anywhere. That is the LongMemEval "596 stored / 0 recalled" signature. +/// +public sealed class ShortTermMemoryEmbeddingValidationTests +{ + private readonly IMessageRepository _messageRepo = Substitute.For(); + private readonly IEmbeddingOrchestrator _embeddings = Substitute.For(); + + private ShortTermMemoryService CreateSut() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(new DateTimeOffset(2026, 8, 7, 12, 0, 0, TimeSpan.Zero)); + var ids = Substitute.For(); + ids.GenerateId().Returns(_ => Guid.NewGuid().ToString("n")); + _messageRepo.AddBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => Task.FromResult(call.Arg>())); + + return new ShortTermMemoryService( + Substitute.For(), + _messageRepo, + Substitute.For(), + _embeddings, + clock, + ids, + Options.Create(new ShortTermMemoryOptions()), + NullLogger.Instance); + } + + private static Message[] Messages(int count) => + Enumerable.Range(0, count).Select(index => new Message + { + MessageId = $"m-{index}", + SessionId = "s-1", + ConversationId = "c-1", + Role = "user", + Content = $"message {index}", + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(index) + }).ToArray(); + + /// The exact defect: correct count, unusable contents, silently persisted. + [Fact] + public async Task AddMessagesAsync_BatchReturnsCorrectlyCountedEmptyVectors_DoesNotPersistUnsearchableMessages() + { + var sut = CreateSut(); + var messages = Messages(3); + _embeddings.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>([[], [], []])); + _embeddings.EmbedMessageAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Array.Empty())); + + var act = async () => await sut.AddMessagesAsync(messages); + + await act.Should().ThrowAsync( + "persisting a message that can never be retrieved is worse than failing the write"); + await _messageRepo.DidNotReceive().AddBatchAsync( + Arg.Any>(), Arg.Any()); + } + + /// One bad slot must be replayed individually, not fail the whole batch. + [Fact] + public async Task AddMessagesAsync_SingleEmptySlot_ReplaysThatSlotAndPersists() + { + var sut = CreateSut(); + var messages = Messages(3); + _embeddings.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>([[1f, 0f], [], [0f, 1f]])); + _embeddings.EmbedMessageAsync("message 1", Arg.Any()) + .Returns(Task.FromResult(new[] { 0.5f, 0.5f })); + + var stored = await sut.AddMessagesAsync(messages); + + stored.Should().HaveCount(3); + stored.Should().OnlyContain(m => m.Embedding != null && m.Embedding.Length > 0, + "every persisted message must carry a usable vector"); + stored[1].Embedding.Should().Equal([0.5f, 0.5f]); + } + + /// + /// Blank content has no embedding by definition — the orchestrator returns an empty vector for it + /// on purpose. That is a property of the input, not a provider failure, so it must not fail the + /// write; it simply never matches a semantic search. + /// + [Fact] + public async Task AddMessagesAsync_BlankContent_IsStoredWithoutFailingTheWrite() + { + var sut = CreateSut(); + var messages = Messages(2); + messages[1] = messages[1] with { Content = " " }; + _embeddings.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>([[1f, 0f], []])); + + var stored = await sut.AddMessagesAsync(messages); + + stored.Should().HaveCount(2); + await _embeddings.DidNotReceive().EmbedMessageAsync( + Arg.Any(), Arg.Any()); + } + + /// A healthy batch must be untouched — no extra provider calls, no behaviour change. + [Fact] + public async Task AddMessagesAsync_HealthyBatch_PersistsUnchangedWithNoIndividualReplay() + { + var sut = CreateSut(); + var messages = Messages(2); + _embeddings.EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>([[1f, 0f], [0f, 1f]])); + + var stored = await sut.AddMessagesAsync(messages); + + stored.Should().HaveCount(2); + stored[0].Embedding.Should().Equal(1f, 0f); + stored[1].Embedding.Should().Equal(0f, 1f); + await _embeddings.DidNotReceive().EmbedMessageAsync( + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryServiceTests.cs b/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryServiceTests.cs index efedebc8..9931f568 100644 --- a/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryServiceTests.cs +++ b/tests/AgentMemory.Tests.Unit/Services/ShortTermMemoryServiceTests.cs @@ -35,6 +35,12 @@ public ShortTermMemoryServiceTests() .EmbedAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new float[1536])); + _embeddingOrchestrator + .EmbedBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => Task.FromResult>( + call.Arg>() + .Select((_, index) => new[] { (float)(index + 1) }).ToArray())); + _conversationRepo .UpsertAsync(Arg.Any(), Arg.Any()) .Returns(ci => Task.FromResult(ci.Arg())); @@ -138,7 +144,7 @@ public async Task AddMessageAsync_DelegatesToRepository() } [Fact] - public async Task AddMessagesAsync_EmbedsEachMessage() + public async Task AddMessagesAsync_UsesOneAlignedBatchEmbeddingByDefault() { var sut = CreateSut(Options.Create(new ShortTermMemoryOptions { GenerateEmbeddings = true })); var messages = new[] @@ -151,10 +157,86 @@ public async Task AddMessagesAsync_EmbedsEachMessage() await sut.AddMessagesAsync(messages); await _embeddingOrchestrator - .Received(3) + .Received(1) + .EmbedBatchAsync( + Arg.Is>(texts => + texts.SequenceEqual(messages.Select(message => message.Content))), + Arg.Any()); + await _embeddingOrchestrator + .DidNotReceive() .EmbedAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task AddMessagesAsync_BatchEmbeddingOptionOff_PreservesLegacyCalls() + { + var sut = CreateSut(Options.Create(new ShortTermMemoryOptions + { + GenerateEmbeddings = true, + UseBatchEmbeddingRequests = false, + })); + var messages = new[] + { + CreateMessage("msg-1"), + CreateMessage("msg-2"), + CreateMessage("msg-3"), + }; + + await sut.AddMessagesAsync(messages); + + await _embeddingOrchestrator.Received(3).EmbedAsync( + Arg.Any(), Arg.Any()); + await _embeddingOrchestrator.DidNotReceive().EmbedBatchAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task AddMessagesAsync_BatchEmbedding_PreservesProvidedVectorsAndInputOrder() + { + var provided = new[] { 42f }; + var messages = new[] + { + CreateMessage("msg-1", withEmbedding: false), + CreateMessage("msg-2", withEmbedding: false) with { Embedding = provided }, + CreateMessage("msg-3", withEmbedding: false), + }; + IReadOnlyList? persisted = null; + _messageRepo + .AddBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(call => + { + persisted = call.Arg>().ToList(); + return Task.FromResult(persisted); + }); + var sut = CreateSut(Options.Create(new ShortTermMemoryOptions { GenerateEmbeddings = true })); + + await sut.AddMessagesAsync(messages); + + await _embeddingOrchestrator.Received(1).EmbedBatchAsync( + Arg.Is>(texts => + texts.SequenceEqual(new[] { messages[0].Content, messages[2].Content })), + Arg.Any()); + persisted.Should().NotBeNull(); + persisted!.Select(message => message.MessageId).Should().Equal("msg-1", "msg-2", "msg-3"); + persisted[0].Embedding.Should().Equal(1f); + persisted[1].Embedding.Should().BeSameAs(provided); + persisted[2].Embedding.Should().Equal(2f); + } + + [Fact] + public async Task AddMessagesAsync_DisabledEmbeddings_MakesNoEmbeddingCalls() + { + var sut = CreateSut(Options.Create(new ShortTermMemoryOptions { GenerateEmbeddings = false })); + var messages = new[] { CreateMessage("msg-1"), CreateMessage("msg-2") }; + + await sut.AddMessagesAsync(messages); + + await _embeddingOrchestrator.DidNotReceive().EmbedBatchAsync( + Arg.Any>(), Arg.Any()); + await _embeddingOrchestrator.DidNotReceive().EmbedAsync( + Arg.Any(), Arg.Any()); + } + [Fact] public async Task GetRecentMessagesAsync_DelegatesToRepository() { diff --git a/tests/AgentMemory.Tests.Unit/Services/SurfaceBudgetEnforcementTests.cs b/tests/AgentMemory.Tests.Unit/Services/SurfaceBudgetEnforcementTests.cs new file mode 100644 index 00000000..5646f89c --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/SurfaceBudgetEnforcementTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// K5. Verification, not repair: are the reasoning-trace and GraphRAG budgets actually enforced? +/// +/// +/// These surfaces have carried a budget of zero in every quality measurement, so "zero" has never +/// been checked to mean zero. It matters more than it sounds, because +/// Neo4jGraphRagContextSource deliberately treats a TopK of 0 as "use the configured +/// default" rather than "return nothing" — pinned by its own test. The composed system is safe only +/// because the assembler skips the call entirely instead of passing zero through, which is a +/// property worth holding with a test rather than a comment. +/// +/// A direct consumer of that asks for zero items still receives +/// the configured default. That is recorded as a finding rather than changed here: it is deliberate, +/// tested, and on a SemVer-locked public interface. +/// +/// +public sealed class SurfaceBudgetEnforcementTests +{ + private readonly IGraphRagContextSource _graphRag = Substitute.For(); + private readonly IReasoningMemoryService _reasoning = Substitute.For(); + + [Fact] + public async Task AZeroGraphRagBudgetReachesTheSourceAsNoCallAtAll() + { + // The load-bearing property. If the assembler passed zero through, the source would answer + // with its configured default and a caller asking for nothing would get five passages. + await AssembleAsync(new RecallOptions { MaxGraphRagItems = 0 }).ConfigureAwait(true); + + await _graphRag.DidNotReceive().GetContextAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ANonZeroGraphRagBudgetIsPassedThroughVerbatim() + { + await AssembleAsync(new RecallOptions { MaxGraphRagItems = 3 }).ConfigureAwait(true); + + await _graphRag.Received(1).GetContextAsync( + Arg.Is(request => request.TopK == 3), + Arg.Any()); + } + + [Fact] + public async Task AZeroTraceBudgetSkipsTheTraceSearch() + { + await AssembleAsync(new RecallOptions { MaxTraces = 0 }).ConfigureAwait(true); + + await _reasoning.DidNotReceive().SearchSimilarTracesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ANonZeroTraceBudgetIsPassedThroughVerbatim() + { + await AssembleAsync(new RecallOptions { MaxTraces = 4 }).ConfigureAwait(true); + + await _reasoning.Received(1).SearchSimilarTracesAsync( + Arg.Any(), Arg.Any(), 4, Arg.Any(), + Arg.Any(), Arg.Any()); + } + + private async Task AssembleAsync(RecallOptions options) + { + _graphRag.GetContextAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphRagContextResult { Items = [] })); + _reasoning.SearchSimilarTracesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + + var embeddings = Substitute.For(); + embeddings.EmbedQueryAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new float[8])); + + var assembler = new MemoryContextAssembler( + Substitute.For(), + Substitute.For(), + _reasoning, + _graphRag, + embeddings, + Substitute.For(), + Options.Create(new MemoryOptions { EnableGraphRag = true }), + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance)); + + return await assembler.AssembleContextAsync( + new RecallRequest { SessionId = "s", Query = "q", Options = options }) + .ConfigureAwait(true); + } +} diff --git a/tests/AgentMemory.Tests.Unit/Services/TraceSuccessFilterWiringTests.cs b/tests/AgentMemory.Tests.Unit/Services/TraceSuccessFilterWiringTests.cs new file mode 100644 index 00000000..18d44046 --- /dev/null +++ b/tests/AgentMemory.Tests.Unit/Services/TraceSuccessFilterWiringTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Core.Services; +using NSubstitute; +using Xunit; + +namespace AgentMemory.Tests.Unit.Services; + +/// +/// K5. Automatic recall could surface the reasoning of a trace that FAILED, and present it as +/// precedent with nothing marking it as a failure. +/// +/// +/// The capability was already there and unreachable: SearchSimilarTracesAsync takes a +/// bool? successFilter, the Cypher honours it as node.success = $successFilter, and the +/// assembler passed a hardcoded null. That is the dead-option shape this repository has fixed +/// before — an option built, plumbed, and never set by anything. +/// +/// Upstream neo4j-labs/agent-memory defaults its equivalent to success_only=True, +/// treating it as a correctness property rather than a tuning knob: imitating reasoning that did not +/// work is worse than retrieving nothing. Our default is left at today's behaviour because nothing +/// here becomes a default before it is measured, and traces have never been measured. +/// +/// +public sealed class TraceSuccessFilterWiringTests +{ + private readonly IReasoningMemoryService _reasoning = Substitute.For(); + + [Fact] + public async Task TheSuccessFilterIsPassedThroughWhenRequested() + { + await AssembleAsync(new RecallOptions { MaxTraces = 5, SuccessfulTracesOnly = true }) + .ConfigureAwait(true); + + await _reasoning.Received(1).SearchSimilarTracesAsync( + Arg.Any(), true, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TheDefaultIsUnchangedFromTodaysBehaviour() + { + // Byte-identical to the current call. Nothing becomes a default before it is measured, and + // the trace surface has never been measured at all. + await AssembleAsync(new RecallOptions { MaxTraces = 5 }).ConfigureAwait(true); + + await _reasoning.Received(1).SearchSimilarTracesAsync( + Arg.Any(), null, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FailedTracesCanBeRequestedExplicitlyForDiagnostics() + { + // Retrieving failures on purpose is a legitimate diagnostic; retrieving them by accident and + // presenting them as precedent is the defect. + await AssembleAsync(new RecallOptions { MaxTraces = 5, SuccessfulTracesOnly = false }) + .ConfigureAwait(true); + + await _reasoning.Received(1).SearchSimilarTracesAsync( + Arg.Any(), false, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + private async Task AssembleAsync(RecallOptions options) + { + _reasoning + .SearchSimilarTracesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + + var embeddings = Substitute.For(); + embeddings.EmbedQueryAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new float[8])); + + var assembler = new MemoryContextAssembler( + Substitute.For(), + Substitute.For(), + _reasoning, + graphRag: null, + embeddings, + Substitute.For(), + Options.Create(new MemoryOptions()), + NullLogger.Instance, + new DefaultMemoryIsolationPolicy( + Options.Create(new MemoryIsolationOptions()), + NullLogger.Instance)); + + await assembler.AssembleContextAsync( + new RecallRequest { SessionId = "s", Query = "q", Options = options }) + .ConfigureAwait(true); + } +} diff --git a/tools/AgentMemory.Cli/CliArgs.cs b/tools/AgentMemory.Cli/CliArgs.cs index 8f24e9fb..c9974648 100644 --- a/tools/AgentMemory.Cli/CliArgs.cs +++ b/tools/AgentMemory.Cli/CliArgs.cs @@ -104,6 +104,8 @@ write a JSON report under artifacts/evaluation by default. perf [--label ] [--scenarios ] [--iterations ] [--warmup ] [--scale ] [--latency ] [--embedding-dimensions ] [--output ] [--quality-gate ] + [--batch-resolution-snapshots ] + [--coalesced-persistence ] Measure a complete agent TURN: database round trips, embedding requests, model calls, and per-stage timing. Provisions its own Neo4j via Testcontainers (Docker required) with deterministic @@ -116,6 +118,17 @@ perf cold [--label ] [--scenarios ] [--samples ] [--warmup ] warm reference. Reports ordered cold samples, cold median, warm median, and the cold-penalty ratio. Records exactly which caches were and were not reset. Default scenario: PERF-R-04; samples: 5. + perf concurrency [--label ] [--levels <1,10,100>] [--pool-size ] + [--embedding-dimensions ] [--output ] + Opt-in concurrent correctness and local saturation characterization. + Proves owner isolation, dedup-on-create, and non-destructive + supersession while reporting request p50/p95/p99, operations/s, + error rate, and transaction-entry-delay estimates. Timings are not + deployment performance. Default fixed product-driver pool: 16. + perf ledger add --run --compared-to --verdict + [--ledger ] + Append a summary-derived entry with automatic seq assignment. + Verdict: improvement, no-effect, or reverted. perf ab --control --candidate [--scenarios ] [--iterations ] [--warmup ] [--latency ] Run counterbalanced control/candidate pairs in one process/database. @@ -158,6 +171,9 @@ agentmemory evaluate --iterations 3 --output artifacts/evaluation/local.json agentmemory perf --label baseline --iterations 10 agentmemory perf --label scale-m --scale M --scenarios PERF-R-04 agentmemory perf cold --label cold-r04 --scenarios PERF-R-04 --samples 5 + agentmemory perf concurrency --label m18 --levels 1,10,100 --pool-size 16 + agentmemory perf ledger add --run artifacts/perf/run --compared-to 1 \ + --verdict improvement agentmemory perf --label feat-01-access-tracking --latency remote agentmemory perf ab --control default --candidate default --scenarios PERF-R-04 agentmemory perf ab --control default --candidate Recall.MaxEntities=2 diff --git a/tools/AgentMemory.Cli/Commands/MemoryCommands.cs b/tools/AgentMemory.Cli/Commands/MemoryCommands.cs index 594becd2..a86dcc12 100644 --- a/tools/AgentMemory.Cli/Commands/MemoryCommands.cs +++ b/tools/AgentMemory.Cli/Commands/MemoryCommands.cs @@ -71,14 +71,36 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = defaul return names; }, cancellationToken) ?? new HashSet(StringComparer.Ordinal); + // A store written by 1.3.0 or earlier has no canonical fact keys, because the *_key + // properties did not exist. Facts are now MERGEd on {subject_key, predicate_key, object_key, + // owner_key}, so until BootstrapAsync has backfilled them, an upsert of an existing triple + // matches nothing and silently creates a DUPLICATE. BootstrapAsync is the documented startup + // step and does run the backfill - but a host that skips it gets no signal at all, and + // schema-check is exactly where an operator looks for that signal. + var legacyFacts = await txRunner.ReadAsync(async runner => + { + var cursor = await runner.RunAsync(FactQueries.SelectFactsMissingCanonicalKeys, new { limit = 1 }); + var records = await cursor.ToListAsync(); + return records.Count; + }, cancellationToken); + var missing = SchemaConformance.MissingObjects(expected, existing); - if (missing.Count == 0) + if (missing.Count == 0 && legacyFacts == 0) { output.WriteLine( $"schema-check: OK — all {expected.Count} expected constraints/indexes are present in database '{database}'."); return 0; } + if (legacyFacts > 0) + { + output.WriteLine( + $"schema-check: facts in database '{database}' are missing canonical keys (pre-1.4 data). " + + "Run ISchemaBootstrapper.BootstrapAsync() before writing, or upserts will create duplicates " + + "instead of matching the existing facts."); + if (missing.Count == 0) return 1; + } + output.WriteLine( $"schema-check: FAILED — {missing.Count} of {expected.Count} expected schema objects are missing from database '{database}':"); foreach (var name in missing) diff --git a/tools/AgentMemory.Cli/Commands/PerfAbCommand.cs b/tools/AgentMemory.Cli/Commands/PerfAbCommand.cs index efd107bf..bdd3424c 100644 --- a/tools/AgentMemory.Cli/Commands/PerfAbCommand.cs +++ b/tools/AgentMemory.Cli/Commands/PerfAbCommand.cs @@ -493,8 +493,8 @@ private static string RenderReport( sb.AppendLine(); sb.AppendLine("| Metric | Control | Candidate | Delta |"); sb.AppendLine("|---|---:|---:|---:|"); - QualityRow(sb, "Retrieval Recall@K", controlQuality.RecallAtK, candidateQuality.RecallAtK); - QualityRow(sb, "Retrieval MRR", controlQuality.Mrr, candidateQuality.Mrr); + QualityRow(sb, "Deterministic-plumbing Recall@K", controlQuality.RecallAtK, candidateQuality.RecallAtK); + QualityRow(sb, "Deterministic-plumbing MRR", controlQuality.Mrr, candidateQuality.Mrr); sb.AppendLine(CultureInfo.InvariantCulture, $"| Forbidden-retrieval cases | {controlQuality.CasesWithViolations} " + $"| {candidateQuality.CasesWithViolations} " + diff --git a/tools/AgentMemory.Cli/Commands/PerfCommand.cs b/tools/AgentMemory.Cli/Commands/PerfCommand.cs index 911a4744..add4e068 100644 --- a/tools/AgentMemory.Cli/Commands/PerfCommand.cs +++ b/tools/AgentMemory.Cli/Commands/PerfCommand.cs @@ -45,6 +45,9 @@ public async Task ExecuteAsync( string? outputRoot, string? qualityGateValue, string? singleShotValue, + string? batchResolutionSnapshotsValue, + string? coalescedPersistenceValue, + string? poolSizeValue = null, CancellationToken cancellationToken = default) { var runLabel = Sanitize(label) ?? "baseline"; @@ -55,6 +58,10 @@ public async Task ExecuteAsync( var qualityGateEnabled = ParseDefaultTrue(qualityGateValue, "quality-gate"); var qualityBaseline = qualityGateEnabled ? QualityGate.LoadBaseline() : null; var singleShot = ParseDefaultFalse(singleShotValue, "single-shot"); + var batchResolutionSnapshots = ParseDefaultTrue( + batchResolutionSnapshotsValue, "batch-resolution-snapshots"); + var coalescedPersistence = ParseDefaultTrue( + coalescedPersistenceValue, "coalesced-persistence"); var (embeddingLatency, modelLatency) = ResolveLatency(latency); @@ -69,6 +76,31 @@ public async Task ExecuteAsync( return 1; } + var extractionModes = scenarios + .Select(scenario => scenario.RequiresUnifiedExtraction) + .Distinct() + .ToArray(); + if (extractionModes.Length != 1) + { + _output.WriteLine( + "error: unified-extraction cold-build labs cannot share one perf run with default-path " + + "scenarios. Select only PERF-W-10-C01/C05/C10, or run the default catalog separately."); + return 1; + } + var useUnifiedExtraction = extractionModes[0]; + int maxConnectionPoolSize; + try + { + maxConnectionPoolSize = ResolveMaxConnectionPoolSize( + poolSizeValue, useUnifiedExtraction, scenarios.Select(s => s.Id).ToArray()); + } + catch (ArgumentException ex) + { + _output.WriteLine($"error: {ex.Message}"); + return 1; + } + var poolSizeExplicitlyConfigured = poolSizeValue is not null; + if (singleShot && (scenarios.Count != 1 || iterations != 1 || warmup != 0 || qualityGateEnabled)) { @@ -91,7 +123,8 @@ public async Task ExecuteAsync( using var trace = new TraceLogWriter(Path.Combine(runDir, "trace.ndjson")); var manifest = BuildManifest(runId, runLabel, startedAt, iterations, warmup, dimensions, scaleName, - embeddingLatency, modelLatency, scenarios, singleShot); + embeddingLatency, modelLatency, scenarios, singleShot, useUnifiedExtraction, + maxConnectionPoolSize, batchResolutionSnapshots, coalescedPersistence); trace.RunStart(runId, manifest); await File.WriteAllTextAsync( Path.Combine(runDir, "run.json"), JsonSerializer.Serialize(manifest, Json), cancellationToken) @@ -107,7 +140,9 @@ await File.WriteAllTextAsync( await using var profile = await HermeticProfile .StartAsync(dimensions, embeddingLatency, modelLatency, _output, scale, - scriptedRules, cancellationToken) + scriptedRules, cancellationToken, maxConnectionPoolSize, + useUnifiedExtraction, batchResolutionSnapshots, coalescedPersistence, + poolSizeExplicitlyConfigured) .ConfigureAwait(false); await PerfFixture.SeedAsync(profile, _output, cancellationToken).ConfigureAwait(false); @@ -283,10 +318,40 @@ await scenario.ValidateAsync(new ScenarioVerificationContext( profile, record, 0, "measure", null, cancellationToken)).ConfigureAwait(false); } + /// + /// Resolves the fingerprinted Neo4j driver pool size. Without an override the historical + /// defaults hold exactly: 16 for the unified cold-build laboratory, 100 for the default catalog. + /// An explicit override exists only for the D1 pool-curve arms and is therefore restricted to + /// integrated cold-build (PERF-W-12-*) selections, where the scenario self-assertion and + /// the manifest both record the deliberate value. + /// + private static int ResolveMaxConnectionPoolSize( + string? poolSizeValue, bool useUnifiedExtraction, IReadOnlyList scenarioIds) + { + var defaultPoolSize = useUnifiedExtraction ? 16 : 100; + if (poolSizeValue is null) + return defaultPoolSize; + if (!int.TryParse(poolSizeValue, NumberStyles.None, CultureInfo.InvariantCulture, out var poolSize) || + poolSize <= 0) + { + throw new ArgumentException( + $"--pool-size must be a positive integer; got '{poolSizeValue}'."); + } + if (scenarioIds.Count == 0 || + scenarioIds.Any(id => !id.StartsWith("PERF-W-12-", StringComparison.Ordinal))) + { + throw new ArgumentException( + "--pool-size is a D1 pool-curve override and requires selecting only the " + + "integrated cold-build scenarios (PERF-W-12-*)."); + } + return poolSize; + } + private static object BuildManifest( string runId, string label, DateTimeOffset startedAt, int iterations, int warmup, int dimensions, string scale, TimeSpan embeddingLatency, TimeSpan modelLatency, IReadOnlyList scenarios, - bool singleShot) => new + bool singleShot, bool useUnifiedExtraction, int maxConnectionPoolSize, + bool batchResolutionSnapshots, bool coalescedPersistence) => new { runId, label, @@ -316,6 +381,11 @@ private static object BuildManifest( embeddingDimensions = dimensions, embeddingLatencyMs = embeddingLatency.TotalMilliseconds, modelLatencyMs = modelLatency.TotalMilliseconds, + unifiedExtraction = useUnifiedExtraction, + batchEntityResolutionSnapshots = batchResolutionSnapshots, + coalescedPersistenceTransactions = coalescedPersistence, + learnedEmbeddingBatching = true, + neo4jMaxConnectionPoolSize = maxConnectionPoolSize, neo4jImage = "neo4j:5.26", os = Environment.OSVersion.ToString(), processorCount = Environment.ProcessorCount, @@ -394,6 +464,8 @@ private static object BuildSummary( }, quality = new { + retrievalMeasurement = QualityGate.DeterministicPlumbingMeasurement, + semanticQualityClaim = false, recallAtK = quality.RecallAtK, mrr = quality.Mrr, cases = quality.Cases, @@ -536,6 +608,7 @@ private static async Task WriteSamplesAsync( counters = r.Counters, spansMs = r.SpanMilliseconds, queryFingerprints = r.QueryFingerprints, + samples = r.Samples, })); await File.WriteAllLinesAsync(Path.Combine(runDir, "samples.ndjson"), lines, cancellationToken) .ConfigureAwait(false); @@ -561,6 +634,7 @@ private static async Task WriteSingleShotArtifactsAsync( counters = sample.Counters, spansMs = sample.SpanMilliseconds, queryFingerprints = sample.QueryFingerprints, + samples = sample.Samples, }, }; await File.WriteAllTextAsync( @@ -637,14 +711,18 @@ private static string RenderReport( sb.AppendLine(); } - sb.AppendLine("## Retrieval quality (deterministic — no model involved)"); + sb.AppendLine("## Retrieval guard (deterministic plumbing — FNV-1a embedder, not semantic quality)"); + sb.AppendLine(); + sb.AppendLine( + "**Scope:** these scores self-assert retrieval wiring, ranking and forbidden-result handling. " + + "They are not a real-embedding semantic-retrieval claim; sampled real-model quality belongs to M-27."); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, - $"**Recall@K {quality.RecallAtK:F3}** · **MRR {quality.Mrr:F3}** · {quality.Cases} judged cases · " + + $"**Deterministic-plumbing Recall@K {quality.RecallAtK:F3}** · **deterministic-plumbing MRR {quality.Mrr:F3}** · {quality.Cases} judged cases · " + $"{quality.CasesWithViolations} with forbidden retrievals · " + $"{(quality.Clean ? "✅ clean" : "⚠️ **see failures below**")}"); sb.AppendLine(); - sb.AppendLine("| Category | Recall@K |"); + sb.AppendLine("| Category | Deterministic-plumbing Recall@K |"); sb.AppendLine("|---|---:|"); foreach (var (category, recall) in quality.RecallByCategory.OrderBy(kv => kv.Key, StringComparer.Ordinal)) sb.AppendLine(CultureInfo.InvariantCulture, $"| {category} | {recall:F3} |"); @@ -657,7 +735,7 @@ private static string RenderReport( { sb.AppendLine("Cases not scoring perfectly — these are the rows a quality-risk change moves:"); sb.AppendLine(); - sb.AppendLine("| Case | Kind | Recall@K | 1/rank | Retrieved | Forbidden retrieved |"); + sb.AppendLine("| Case | Kind | Deterministic-plumbing Recall@K | 1/rank | Retrieved | Forbidden retrieved |"); sb.AppendLine("|---|---|---:|---:|---:|---|"); foreach (var c in imperfect) { @@ -844,7 +922,10 @@ private static (TimeSpan Embedding, TimeSpan Model) ResolveLatency(string? laten // Reproduces the shape of a same-region remote deployment, so ordering and overlap // optimizations are measurable without a network dependency. "remote" => (TimeSpan.FromMilliseconds(120), TimeSpan.FromMilliseconds(900)), - _ => throw new ArgumentException($"unknown --latency '{latency}'. Use 'zero' or 'remote'."), + // Isolates model fan-out changes from an unchanged embedding/persistence path. + "model-remote" => (TimeSpan.Zero, TimeSpan.FromMilliseconds(900)), + _ => throw new ArgumentException( + $"unknown --latency '{latency}'. Use 'zero', 'model-remote', or 'remote'."), }; private static string LatencyName(string? latency) => diff --git a/tools/AgentMemory.Cli/Commands/PerfConcurrencyCommand.cs b/tools/AgentMemory.Cli/Commands/PerfConcurrencyCommand.cs new file mode 100644 index 00000000..aaaaa140 --- /dev/null +++ b/tools/AgentMemory.Cli/Commands/PerfConcurrencyCommand.cs @@ -0,0 +1,709 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Cli.Perf; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Commands; + +/// +/// Opt-in M-18 concurrent-correctness and saturation characterization. +/// +public sealed class PerfConcurrencyCommand +{ + private const string Neo4jImage = "neo4j:5.26"; + private const string EntryEstimateSample = "neo4j.transaction_entry_ms_est"; + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly TextWriter _output; + + public PerfConcurrencyCommand(TextWriter output) => _output = output; + + public async Task ExecuteAsync( + string? label, + string? levelsValue, + string? poolSizeValue, + string? dimensionsValue, + string? outputRoot, + CancellationToken cancellationToken = default) + { + var levels = ParseLevels(levelsValue); + var poolSize = ParsePositive(poolSizeValue, 16, "pool-size"); + var dimensions = ParsePositive(dimensionsValue, 384, "embedding-dimensions"); + var runLabel = Sanitize(label) ?? "baseline"; + var startedAt = DateTimeOffset.UtcNow; + var runId = + $"{startedAt:yyyyMMdd'T'HHmmss'Z'}__{runLabel}__hermetic-concurrency-pool-{poolSize}"; + var runDirectory = Path.Combine( + outputRoot ?? Path.Combine("artifacts", "perf-concurrency"), + runId); + Directory.CreateDirectory(runDirectory); + + var manifest = new + { + schemaVersion = 1, + runId, + profile = "hermetic-concurrency", + startedAtUtc = startedAt, + gitCommit = GitCommit(), + runtime = Environment.Version.ToString(), + os = Environment.OSVersion.ToString(), + neo4jImage = Neo4jImage, + embedding = "deterministic-fnv1a", + embeddingDimensions = dimensions, + connectionPoolSize = poolSize, + concurrencyLevels = levels, + workloads = new[] + { + "owner-isolation-read", + "dedup-on-create-race", + "owner-scoped-supersession", + }, + transactionEntryDelayEstimate = new + { + name = EntryEstimateSample, + unit = "milliseconds", + exactPoolQueueWait = false, + definition = + "memory.db.tx start to transaction callback entry; upper bound includes " + + "connection acquisition, routing, and transaction begin", + }, + timingScope = + "Hermetic local characterization only; timings are not deployment performance.", + }; + + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "run.json"), + JsonSerializer.Serialize(manifest, Json), + cancellationToken).ConfigureAwait(false); + + _output.WriteLine($"perf concurrency: run {runId}"); + using var trace = new TraceLogWriter(Path.Combine(runDirectory, "trace.ndjson")); + trace.RunStart(runId, manifest); + var runStopwatch = Stopwatch.StartNew(); + using var collector = new PerfCollector(trace); + + await using var profile = await HermeticProfile.StartAsync( + dimensions, + TimeSpan.Zero, + TimeSpan.Zero, + _output, + PerfScale.Small, + scriptedRules: null, + cancellationToken, + maxConnectionPoolSize: poolSize).ConfigureAwait(false); + + await WarmProductPoolAsync(profile, poolSize).ConfigureAwait(false); + var longTerm = profile.Services.GetRequiredService(); + await SeedOwnerIsolationAsync(longTerm, levels.Max(), dimensions, cancellationToken) + .ConfigureAwait(false); + + var levelOutcomes = new List(); + foreach (var concurrency in levels) + { + _output.WriteLine($"perf concurrency: level {concurrency}"); + var owner = await MeasureWaveAsync( + collector, + $"PERF-C-{LevelId(concurrency)}/owner-isolation-read", + concurrency, + index => RunOwnerIsolationReadAsync(longTerm, index, cancellationToken)) + .ConfigureAwait(false); + + var dedupOwner = $"m18-dedup-owner-{concurrency}"; + var dedupSubject = $"m18 dedup subject {concurrency}"; + var dedupEmbedding = DeterministicEmbeddingGenerator.Vector( + $"m18 dedup semantic equivalence {concurrency}", dimensions); + var dedup = await MeasureWaveAsync( + collector, + $"PERF-C-{LevelId(concurrency)}/dedup-on-create-race", + concurrency, + index => RunDedupCreateAsync( + longTerm, + concurrency, + index, + dedupOwner, + dedupSubject, + dedupEmbedding, + cancellationToken)) + .ConfigureAwait(false); + var dedupLiveFacts = await CountLiveDedupFactsAsync( + profile.Driver, dedupOwner, dedupSubject).ConfigureAwait(false); + + var supersessionPrefix = $"m18-super-{concurrency}"; + await SeedSupersessionAsync( + longTerm, concurrency, supersessionPrefix, dimensions, cancellationToken) + .ConfigureAwait(false); + var crossOwnerAttemptErrors = await RunCrossOwnerSupersessionProbesAsync( + longTerm, concurrency, supersessionPrefix, cancellationToken).ConfigureAwait(false); + var supersession = await MeasureWaveAsync( + collector, + $"PERF-C-{LevelId(concurrency)}/owner-scoped-supersession", + concurrency, + index => RunSupersessionAsync( + longTerm, concurrency, index, supersessionPrefix, cancellationToken)) + .ConfigureAwait(false); + var supersessionShape = await InspectSupersessionAsync( + profile.Driver, supersessionPrefix).ConfigureAwait(false); + + var allRecords = owner.Records.Concat(dedup.Records).Concat(supersession.Records).ToList(); + var entrySampleCount = allRecords.Sum(record => + record.Samples.TryGetValue(EntryEstimateSample, out var samples) ? samples.Count : 0); + var correctness = new ConcurrencyCorrectnessSnapshot( + concurrency, + owner.FailedRequests + dedup.FailedRequests + supersession.FailedRequests + + crossOwnerAttemptErrors, + owner.OwnerLeaks, + owner.OwnerMisses, + dedupLiveFacts, + supersessionShape.LosersPresent, + supersessionShape.LosersClosed, + supersessionShape.Edges, + supersessionShape.WinnersLive, + supersessionShape.CrossOwnerEdges, + entrySampleCount); + var issues = ConcurrencyRunValidator.Validate(correctness); + levelOutcomes.Add(new LevelOutcome( + concurrency, + Analyze(owner), + Analyze(dedup), + Analyze(supersession), + correctness, + issues)); + } + + runStopwatch.Stop(); + var accepted = levelOutcomes.All(level => level.ValidationIssues.Count == 0); + trace.RunEnd(collector.Records.Count, runStopwatch.Elapsed.TotalMilliseconds); + + var summary = new + { + schemaVersion = 1, + runId, + accepted, + manifest, + durationMilliseconds = runStopwatch.Elapsed.TotalMilliseconds, + levels = levelOutcomes.Select(ToArtifact), + validationIssues = levelOutcomes.SelectMany(level => + level.ValidationIssues.Select(issue => new + { + concurrency = level.Concurrency, + code = issue, + })), + }; + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "summary.json"), + JsonSerializer.Serialize(summary, Json), + cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "report.md"), + RenderReport(runId, poolSize, levelOutcomes, accepted), + cancellationToken).ConfigureAwait(false); + + _output.WriteLine( + accepted + ? "perf concurrency: PASS" + : "perf concurrency: FAIL"); + foreach (var level in levelOutcomes) + { + _output.WriteLine( + $" c={level.Concurrency}: errors={level.Correctness.OperationErrors}, " + + $"leaks={level.Correctness.OwnerLeaks}, misses={level.Correctness.OwnerMisses}, " + + $"dedup-live={level.Correctness.DedupLiveFacts}, " + + $"supersession={level.Correctness.SupersessionEdges}/{level.Concurrency}, " + + $"cross-owner-edges={level.Correctness.CrossOwnerEdges}"); + foreach (var issue in level.ValidationIssues) + _output.WriteLine($" error: c={level.Concurrency} {issue}"); + } + + _output.WriteLine($"perf concurrency: wrote {runDirectory}"); + return accepted ? 0 : 1; + } + + private static async Task SeedOwnerIsolationAsync( + ILongTermMemoryService longTerm, + int count, + int dimensions, + CancellationToken cancellationToken) + { + for (var index = 0; index < count; index++) + { + await longTerm.AddFactAsync(new Fact + { + FactId = $"m18-owner-fact-{index}", + Subject = "m18 shared owner-isolation subject", + Predicate = "belongs_to", + Object = $"owner-marker-{index}", + OwnerId = Owner(index), + Confidence = 0.9, + CreatedAtUtc = DateTimeOffset.UtcNow, + Embedding = DeterministicEmbeddingGenerator.Vector( + $"m18 owner isolation marker {index}", dimensions), + }, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task RunOwnerIsolationReadAsync( + ILongTermMemoryService longTerm, + int index, + CancellationToken cancellationToken) + { + var facts = await longTerm.GetFactsBySubjectAsync( + "m18 shared owner-isolation subject", + MemoryScope.For(Owner(index)), + cancellationToken).ConfigureAwait(false); + var leaks = facts.Count(fact => + !string.Equals(fact.OwnerId, Owner(index), StringComparison.Ordinal)); + var own = facts.Count(fact => + string.Equals(fact.OwnerId, Owner(index), StringComparison.Ordinal) && + string.Equals(fact.Object, $"owner-marker-{index}", StringComparison.Ordinal)); + return new RequestCorrectness(leaks, own == 1 ? 0 : 1); + } + + private static async Task RunDedupCreateAsync( + ILongTermMemoryService longTerm, + int concurrency, + int index, + string owner, + string subject, + float[] embedding, + CancellationToken cancellationToken) + { + await longTerm.AddFactAsync(new Fact + { + FactId = $"m18-dedup-{concurrency}-{index}", + Subject = subject, + Predicate = "semantically_equivalent_to", + Object = $"wording-{index}", + OwnerId = owner, + Confidence = 0.8, + CreatedAtUtc = DateTimeOffset.UtcNow, + Embedding = embedding, + }, cancellationToken).ConfigureAwait(false); + return RequestCorrectness.Clean; + } + + private static async Task SeedSupersessionAsync( + ILongTermMemoryService longTerm, + int concurrency, + string prefix, + int dimensions, + CancellationToken cancellationToken) + { + for (var index = 0; index < concurrency; index++) + { + var owner = $"{prefix}-owner-{index}"; + await longTerm.AddFactAsync(new Fact + { + FactId = $"{prefix}-loser-{index}", + Subject = $"{prefix}-subject-{index}", + Predicate = "status_old", + Object = "old", + OwnerId = owner, + Confidence = 0.7, + CreatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1), + Embedding = DeterministicEmbeddingGenerator.Vector( + $"{prefix} old {index}", dimensions), + }, cancellationToken).ConfigureAwait(false); + await longTerm.AddFactAsync(new Fact + { + FactId = $"{prefix}-winner-{index}", + Subject = $"{prefix}-subject-{index}", + Predicate = "status_new", + Object = "new", + OwnerId = owner, + Confidence = 0.95, + CreatedAtUtc = DateTimeOffset.UtcNow, + Embedding = DeterministicEmbeddingGenerator.Vector( + $"{prefix} new {index}", dimensions), + }, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task RunCrossOwnerSupersessionProbesAsync( + ILongTermMemoryService longTerm, + int concurrency, + string prefix, + CancellationToken cancellationToken) + { + if (concurrency < 2) return 0; + var probes = Enumerable.Range(0, concurrency).Select(async index => + { + try + { + var foreignWinner = (index + 1) % concurrency; + var changed = await longTerm.SupersedeFactAsync( + $"{prefix}-loser-{index}", + $"{prefix}-winner-{foreignWinner}", + MemoryScope.For($"{prefix}-owner-{index}"), + cancellationToken).ConfigureAwait(false); + return changed ? 1 : 0; + } + catch + { + return 1; + } + }); + return (await Task.WhenAll(probes).ConfigureAwait(false)).Sum(); + } + + private static async Task RunSupersessionAsync( + ILongTermMemoryService longTerm, + int concurrency, + int index, + string prefix, + CancellationToken cancellationToken) + { + _ = concurrency; + var changed = await longTerm.SupersedeFactAsync( + $"{prefix}-loser-{index}", + $"{prefix}-winner-{index}", + MemoryScope.For($"{prefix}-owner-{index}"), + cancellationToken).ConfigureAwait(false); + if (!changed) + throw new InvalidOperationException("Owner-scoped supersession matched no pair."); + return RequestCorrectness.Clean; + } + + private static async Task MeasureWaveAsync( + PerfCollector collector, + string scenario, + int concurrency, + Func> operation) + { + var ready = new CountdownEvent(concurrency); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var outcomes = new ConcurrentBag(); + var tasks = Enumerable.Range(0, concurrency).Select(async index => + { + ready.Signal(); + await release.Task.ConfigureAwait(false); + + TurnRecord record; + var failed = false; + var correctness = RequestCorrectness.Clean; + using (var turn = collector.BeginTurn(scenario, index, "measure")) + { + record = turn.Record; + try + { + correctness = await operation(index).ConfigureAwait(false); + } + catch + { + failed = true; + } + } + + outcomes.Add(new MeasuredRequest(record, failed, correctness)); + }).ToArray(); + + if (!ready.Wait(TimeSpan.FromSeconds(30))) + throw new TimeoutException($"Concurrency wave '{scenario}' did not become ready."); + var stopwatch = Stopwatch.StartNew(); + release.SetResult(); + await Task.WhenAll(tasks).ConfigureAwait(false); + stopwatch.Stop(); + + var ordered = outcomes.OrderBy(outcome => outcome.Record.Iteration).ToList(); + return new WaveOutcome( + scenario, + concurrency, + stopwatch.Elapsed.TotalMilliseconds, + ordered.Select(outcome => outcome.Record).ToList(), + ordered.Count(outcome => outcome.Failed), + ordered.Sum(outcome => outcome.Correctness.OwnerLeaks), + ordered.Sum(outcome => outcome.Correctness.OwnerMisses)); + } + + private static ConcurrencyLevelAnalysis Analyze(WaveOutcome wave) + { + var entrySamples = wave.Records.SelectMany(record => + record.Samples.TryGetValue(EntryEstimateSample, out var values) + ? values + : Array.Empty()).ToList(); + return ConcurrencyAnalysis.Analyze( + wave.Concurrency, + wave.ElapsedMilliseconds, + wave.Records.Select(record => record.DurationMs).ToList(), + entrySamples, + wave.FailedRequests); + } + + private static async Task WarmProductPoolAsync(HermeticProfile profile, int poolSize) + { + var driver = profile.Services.GetRequiredService(); + var tasks = Enumerable.Range(0, poolSize).Select(async poolIndex => + { + _ = poolIndex; + await using var session = driver.AsyncSession(config => + config.WithDatabase("neo4j").WithDefaultAccessMode(AccessMode.Read)); + var cursor = await session.RunAsync("RETURN 1 AS warmed").ConfigureAwait(false); + _ = await cursor.SingleAsync().ConfigureAwait(false); + }); + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + private static async Task CountLiveDedupFactsAsync( + IDriver driver, + string owner, + string subject) + { + const string cypher = """ + MATCH (f:Fact {owner_id: $owner, subject: $subject, predicate: 'semantically_equivalent_to'}) + WHERE f.invalidated_at IS NULL + RETURN count(f) AS count + """; + await using var session = driver.AsyncSession(config => config.WithDatabase("neo4j")); + var cursor = await session.RunAsync(cypher, new { owner, subject }).ConfigureAwait(false); + return (await cursor.SingleAsync().ConfigureAwait(false))["count"].As(); + } + + private static async Task InspectSupersessionAsync( + IDriver driver, + string prefix) + { + const string cypher = """ + CALL { + MATCH (loser:Fact) + WHERE loser.id STARTS WITH $loserPrefix + RETURN count(loser) AS losersPresent, + count(CASE WHEN loser.invalidated_at IS NOT NULL + AND loser.valid_until IS NOT NULL THEN 1 END) AS losersClosed + } + CALL { + MATCH (winner:Fact) + WHERE winner.id STARTS WITH $winnerPrefix + AND winner.invalidated_at IS NULL + AND winner.valid_until IS NULL + RETURN count(winner) AS winnersLive + } + CALL { + MATCH (loser:Fact)-[:SUPERSEDED_BY]->(winner:Fact) + WHERE loser.id STARTS WITH $loserPrefix + RETURN count(*) AS edges, + count(CASE WHEN loser.owner_id <> winner.owner_id THEN 1 END) AS crossOwnerEdges + } + RETURN losersPresent, losersClosed, winnersLive, edges, crossOwnerEdges + """; + await using var session = driver.AsyncSession(config => config.WithDatabase("neo4j")); + var cursor = await session.RunAsync( + cypher, + new + { + loserPrefix = $"{prefix}-loser-", + winnerPrefix = $"{prefix}-winner-", + }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + return new SupersessionShape( + record["losersPresent"].As(), + record["losersClosed"].As(), + record["winnersLive"].As(), + record["edges"].As(), + record["crossOwnerEdges"].As()); + } + + private static object ToArtifact(LevelOutcome level) => new + { + concurrency = level.Concurrency, + ownerIsolationRead = level.OwnerIsolationRead, + dedupOnCreateRace = level.DedupOnCreateRace, + ownerScopedSupersession = level.OwnerScopedSupersession, + correctness = new + { + operationErrors = level.Correctness.OperationErrors, + ownerLeaks = level.Correctness.OwnerLeaks, + ownerMisses = level.Correctness.OwnerMisses, + dedupLiveFacts = level.Correctness.DedupLiveFacts, + supersessionLosersPresent = level.Correctness.SupersessionLosersPresent, + supersessionLosersClosed = level.Correctness.SupersessionLosersClosed, + supersessionEdges = level.Correctness.SupersessionEdges, + supersessionWinnersLive = level.Correctness.SupersessionWinnersLive, + crossOwnerEdges = level.Correctness.CrossOwnerEdges, + transactionEntryEstimateSamples = + level.Correctness.TransactionEntryEstimateSamples, + }, + validationIssues = level.ValidationIssues, + }; + + private static string RenderReport( + string runId, + int poolSize, + IReadOnlyList levels, + bool accepted) + { + var builder = new StringBuilder(); + builder.AppendLine(CultureInfo.InvariantCulture, $"# Concurrency characterization — `{runId}`"); + builder.AppendLine(); + builder.AppendLine(accepted ? "**PASS ✅**" : "**FAIL ❌**"); + builder.AppendLine(); + builder.AppendLine(CultureInfo.InvariantCulture, $"Fixed product-driver pool: **{poolSize} connections**."); + builder.AppendLine( + "`transaction_entry_ms_est` is an upper-bound estimate from transaction-span start to " + + "the first query callback. It includes acquisition, routing, and transaction begin; it is " + + "not exact pool queue time. All timings are local hermetic characterization, not deployment performance."); + builder.AppendLine(); + builder.AppendLine( + "| Workload | Sessions | req p50 ms | req p95 ms | req p99 ms | entry-est p99 ms | ops/s | errors |"); + builder.AppendLine("|---|---:|---:|---:|---:|---:|---:|---:|"); + foreach (var level in levels) + { + AppendWorkload(builder, "owner isolation", level.OwnerIsolationRead); + AppendWorkload(builder, "dedup race", level.DedupOnCreateRace); + AppendWorkload(builder, "supersession", level.OwnerScopedSupersession); + } + + builder.AppendLine(); + builder.AppendLine("| Sessions | leaks | misses | dedup live | losers present/closed | edges | winners live | cross-owner edges |"); + builder.AppendLine("|---:|---:|---:|---:|---:|---:|---:|---:|"); + foreach (var level in levels) + { + var c = level.Correctness; + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {level.Concurrency} | {c.OwnerLeaks} | {c.OwnerMisses} | {c.DedupLiveFacts} | " + + $"{c.SupersessionLosersPresent}/{c.SupersessionLosersClosed} | " + + $"{c.SupersessionEdges} | {c.SupersessionWinnersLive} | {c.CrossOwnerEdges} |"); + } + + if (!accepted) + { + builder.AppendLine(); + builder.AppendLine("## Validation failures"); + foreach (var level in levels) + foreach (var issue in level.ValidationIssues) + builder.AppendLine(CultureInfo.InvariantCulture, $"- c={level.Concurrency}: `{issue}`"); + } + + return builder.ToString(); + } + + private static void AppendWorkload( + StringBuilder builder, + string workload, + ConcurrencyLevelAnalysis result) + { + builder.AppendLine(CultureInfo.InvariantCulture, + $"| {workload} | {result.Concurrency} | {result.RequestMilliseconds.P50:F3} | " + + $"{result.RequestMilliseconds.P95:F3} | {result.RequestMilliseconds.P99:F3} | " + + $"{result.TransactionEntryDelayEstimateMilliseconds.P99:F3} | " + + $"{result.AchievedOperationsPerSecond:F2} | {result.ErrorRate:P2} |"); + } + + private static IReadOnlyList ParseLevels(string? value) + { + var raw = string.IsNullOrWhiteSpace(value) ? "1,10,100" : value; + var parsed = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => ParsePositive(token, 0, "levels")) + .Distinct() + .Order() + .ToArray(); + if (parsed.Length == 0) + throw new ArgumentException("--levels must contain at least one positive integer."); + return parsed; + } + + private static int ParsePositive(string? value, int fallback, string name) + { + if (string.IsNullOrWhiteSpace(value)) + { + if (fallback > 0) return fallback; + throw new ArgumentException($"--{name} must be a positive integer."); + } + + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || + parsed <= 0) + throw new ArgumentException($"--{name} must be a positive integer."); + return parsed; + } + + private static string? Sanitize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var sanitized = new string(value.Trim().Select(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '-').ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? null : sanitized; + } + + private static string? GitCommit() + { + try + { + var sha = RunGit("rev-parse HEAD"); + if (sha is null) return null; + var dirty = !string.IsNullOrWhiteSpace(RunGit("status --porcelain")); + return dirty ? $"{sha}-dirty" : sha; + } + catch + { + return null; + } + } + + private static string? RunGit(string arguments) + { + using var process = Process.Start(new ProcessStartInfo("git", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }); + if (process is null) return null; + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5_000); + return process.ExitCode == 0 ? output.Trim() : null; + } + + private static string Owner(int index) => $"m18-owner-{index}"; + + private static string LevelId(int concurrency) => concurrency switch + { + 1 => "01", + 10 => "02", + 100 => "03", + _ => $"X{concurrency}", + }; + + private sealed record RequestCorrectness(int OwnerLeaks, int OwnerMisses) + { + internal static RequestCorrectness Clean { get; } = new(0, 0); + } + + private sealed record MeasuredRequest( + TurnRecord Record, + bool Failed, + RequestCorrectness Correctness); + + private sealed record WaveOutcome( + string Workload, + int Concurrency, + double ElapsedMilliseconds, + IReadOnlyList Records, + int FailedRequests, + int OwnerLeaks, + int OwnerMisses); + + private sealed record SupersessionShape( + long LosersPresent, + long LosersClosed, + long WinnersLive, + long Edges, + long CrossOwnerEdges); + + private sealed record LevelOutcome( + int Concurrency, + ConcurrencyLevelAnalysis OwnerIsolationRead, + ConcurrencyLevelAnalysis DedupOnCreateRace, + ConcurrencyLevelAnalysis OwnerScopedSupersession, + ConcurrencyCorrectnessSnapshot Correctness, + IReadOnlyList ValidationIssues); +} diff --git a/tools/AgentMemory.Cli/Commands/PerfLedgerCommand.cs b/tools/AgentMemory.Cli/Commands/PerfLedgerCommand.cs new file mode 100644 index 00000000..ebc80077 --- /dev/null +++ b/tools/AgentMemory.Cli/Commands/PerfLedgerCommand.cs @@ -0,0 +1,336 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentMemory.Cli.Perf; + +namespace AgentMemory.Cli.Commands; + +/// Appends one summary-derived entry to the curated performance ledger. +public sealed class PerfLedgerCommand(TextWriter output) +{ + public const string DefaultLedgerPath = "strategy/performance/ledger.json"; + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + private static readonly HashSet Verdicts = new(StringComparer.Ordinal) + { + "improvement", + "no-effect", + "reverted", + }; + + public async Task ExecuteAsync( + string? runDirectory, + string? comparedToValue, + string? verdictValue, + string? ledgerPathValue, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(runDirectory)) + { + output.WriteLine("error: perf ledger add requires --run ."); + return 1; + } + + var runPath = Path.GetFullPath(runDirectory); + var summaryPath = Path.Combine(runPath, "summary.json"); + if (!File.Exists(summaryPath)) + { + output.WriteLine($"error: performance summary not found: {summaryPath}"); + return 1; + } + + if (!int.TryParse( + comparedToValue, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var comparedTo) || + comparedTo < 0) + { + output.WriteLine("error: perf ledger add requires non-negative --compared-to ."); + return 1; + } + + var verdict = verdictValue?.Trim().ToLowerInvariant(); + if (verdict is null || !Verdicts.Contains(verdict)) + { + output.WriteLine( + "error: --verdict must be improvement, no-effect, or reverted."); + return 1; + } + + var ledgerPath = Path.GetFullPath(ledgerPathValue ?? DefaultLedgerPath); + if (!File.Exists(ledgerPath)) + { + output.WriteLine($"error: performance ledger not found: {ledgerPath}"); + return 1; + } + + var summaryText = await File.ReadAllTextAsync( + summaryPath, cancellationToken).ConfigureAwait(false); + using var summaryDocument = JsonDocument.Parse(summaryText); + var summary = summaryDocument.RootElement; + var baseline = PerfBaselineDocument.FromSummary(summary); + var fingerprint = PerfLedgerFingerprint.FromSummary(summary, baseline); + var sourceHash = Convert.ToHexStringLower( + SHA256.HashData(Encoding.UTF8.GetBytes(summaryText))); + + var ledgerDirectory = Path.GetDirectoryName(ledgerPath) + ?? throw new InvalidDataException("Ledger path has no parent directory."); + Directory.CreateDirectory(ledgerDirectory); + var lockPath = ledgerPath + ".lock"; + await using var ledgerLock = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + + var ledgerText = await File.ReadAllTextAsync( + ledgerPath, cancellationToken).ConfigureAwait(false); + var ledger = JsonNode.Parse(ledgerText)?.AsObject() + ?? throw new InvalidDataException("Performance ledger is empty."); + if (ledger["schemaVersion"]?.GetValue() != 1) + throw new InvalidDataException("Performance ledger schemaVersion must be 1."); + + var entries = ledger["entries"]?.AsArray() + ?? throw new InvalidDataException("Performance ledger is missing entries."); + ValidateContiguousSequence(entries); + if (comparedTo >= entries.Count) + { + throw new InvalidDataException( + $"Compared-to seq {comparedTo} does not exist; ledger ends at {entries.Count - 1}."); + } + + var target = entries[comparedTo]?.AsObject() + ?? throw new InvalidDataException($"Ledger seq {comparedTo} is not an object."); + var targetFingerprintNode = target["fingerprint"] + ?? throw new InvalidDataException( + $"Ledger seq {comparedTo} has no fingerprint and cannot be compared safely."); + var targetFingerprint = targetFingerprintNode.Deserialize(Json) + ?? throw new InvalidDataException($"Ledger seq {comparedTo} has an invalid fingerprint."); + ValidateComparable(fingerprint, targetFingerprint, target, comparedTo); + + foreach (var existing in entries.OfType()) + { + if (string.Equals( + existing["sourceSummarySha256"]?.GetValue(), + sourceHash, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"A ledger entry for summary SHA-256 {sourceHash} already exists."); + } + } + + var nextSequence = entries.Count; + var entry = BuildEntry( + nextSequence, + comparedTo, + verdict, + runPath, + sourceHash, + summary, + baseline, + fingerprint); + entries.Add(entry); + + var temporaryPath = ledgerPath + $".{Guid.NewGuid():N}.tmp"; + try + { + await File.WriteAllTextAsync( + temporaryPath, + ledger.ToJsonString(Json) + Environment.NewLine, + cancellationToken).ConfigureAwait(false); + File.Move(temporaryPath, ledgerPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + + output.WriteLine( + $"perf ledger: appended seq {nextSequence} from {summaryPath}"); + return 0; + } + + private static JsonObject BuildEntry( + int sequence, + int comparedTo, + string verdict, + string runPath, + string sourceHash, + JsonElement summary, + PerfBaselineDocument baseline, + PerfLedgerFingerprint fingerprint) + { + var manifest = Required(summary, "manifest"); + var label = Required(manifest, "label").GetString() + ?? throw new InvalidDataException("Summary label is null."); + var startedAt = Required(manifest, "startedAtUtc").GetString() + ?? throw new InvalidDataException("Summary startedAtUtc is null."); + var environment = Required(manifest, "environment"); + var commit = Required(environment, "commit").ValueKind == JsonValueKind.Null + ? null + : Required(environment, "commit").GetString(); + var runId = Required(manifest, "runId").GetString() + ?? throw new InvalidDataException("Summary runId is null."); + + var counters = new JsonObject(); + foreach (var (scenario, scenarioBaseline) in baseline.Scenarios) + { + var values = new JsonObject(); + foreach (var (name, value) in scenarioBaseline.Counters) + values[name] = value; + counters[scenario] = values; + } + + var quality = new JsonObject + { + ["recallAtK"] = baseline.Quality.RecallAtK, + ["mrr"] = baseline.Quality.Mrr, + ["casesWithViolations"] = baseline.Quality.CasesWithViolations, + ["entityPrecision"] = baseline.Quality.EntityPrecision, + ["entityRecall"] = baseline.Quality.EntityRecall, + ["factPrecision"] = baseline.Quality.FactPrecision, + ["factRecall"] = baseline.Quality.FactRecall, + ["preferencePrecision"] = baseline.Quality.PreferencePrecision, + ["preferenceRecall"] = baseline.Quality.PreferenceRecall, + ["extractionFalsePositiveRate"] = baseline.Quality.ExtractionFalsePositiveRate, + }; + + return new JsonObject + { + ["seq"] = sequence, + ["label"] = label, + ["slug"] = label, + ["date"] = startedAt, + ["commit"] = commit, + ["runId"] = runId, + ["sourceRun"] = PortableRunPath(runPath), + ["sourceSummarySha256"] = sourceHash, + ["comparedTo"] = comparedTo, + ["verdict"] = verdict, + ["accepted"] = verdict == "improvement", + ["fingerprint"] = JsonSerializer.SerializeToNode(fingerprint, Json), + ["counters"] = counters, + ["quality"] = quality, + }; + } + + private static void ValidateContiguousSequence(JsonArray entries) + { + for (var index = 0; index < entries.Count; index++) + { + var entry = entries[index]?.AsObject() + ?? throw new InvalidDataException($"Ledger entry {index} is not an object."); + var sequence = entry["seq"]?.GetValue() + ?? throw new InvalidDataException($"Ledger entry {index} has no seq."); + if (sequence != index) + { + throw new InvalidDataException( + $"Ledger sequence is not contiguous at index {index}: found seq {sequence}."); + } + } + } + + private static void ValidateComparable( + PerfLedgerFingerprint candidate, + PerfLedgerFingerprint target, + JsonObject targetEntry, + int targetSequence) + { + Equal("profile", target.Profile, candidate.Profile); + Equal("scale", target.Scale, candidate.Scale); + Equal( + "embedding dimensions", + target.EmbeddingDimensions, + candidate.EmbeddingDimensions); + Equal( + "embedding latency", + target.EmbeddingLatencyMs, + candidate.EmbeddingLatencyMs); + Equal("model latency", target.ModelLatencyMs, candidate.ModelLatencyMs); + Equal("Neo4j image", target.Neo4jImage, candidate.Neo4jImage); + + var targetScenarios = target.Scenarios.ToHashSet(StringComparer.Ordinal); + var targetCounters = targetEntry["counters"]?.AsObject() + ?? throw new InvalidDataException( + $"Ledger seq {targetSequence} has no scenario counters."); + foreach (var scenario in candidate.Scenarios) + { + if (!targetScenarios.Contains(scenario) || !targetCounters.ContainsKey(scenario)) + { + throw new InvalidDataException( + $"Scenario '{scenario}' is absent from compared-to seq {targetSequence}."); + } + } + } + + private static void Equal(string field, T expected, T actual) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidDataException( + $"Incomparable {field}: compared-to={expected}, run={actual}."); + } + } + + private static string PortableRunPath(string runPath) + { + var relative = Path.GetRelativePath(Environment.CurrentDirectory, runPath); + return relative.Replace('\\', '/'); + } + + private static JsonElement Required(JsonElement parent, string property) + { + if (!parent.TryGetProperty(property, out var value)) + throw new InvalidDataException($"Performance summary is missing '{property}'."); + return value; + } +} + +internal sealed record PerfLedgerFingerprint( + string Profile, + string Scale, + int EmbeddingDimensions, + double EmbeddingLatencyMs, + double ModelLatencyMs, + string Neo4jImage, + IReadOnlyList Scenarios) +{ + public static PerfLedgerFingerprint FromSummary( + JsonElement summary, + PerfBaselineDocument baseline) + { + var manifest = Required(summary, "manifest"); + var environment = Required(manifest, "environment"); + return new PerfLedgerFingerprint( + Required(manifest, "profile").GetString() + ?? throw new InvalidDataException("Summary profile is null."), + Required(manifest, "scale").GetString() + ?? throw new InvalidDataException("Summary scale is null."), + Required(environment, "embeddingDimensions").GetInt32(), + Required(environment, "embeddingLatencyMs").GetDouble(), + Required(environment, "modelLatencyMs").GetDouble(), + Required(environment, "neo4jImage").GetString() + ?? throw new InvalidDataException("Summary Neo4j image is null."), + baseline.Scenarios.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray()); + } + + private static JsonElement Required(JsonElement parent, string property) + { + if (!parent.TryGetProperty(property, out var value)) + throw new InvalidDataException($"Performance summary is missing '{property}'."); + return value; + } +} diff --git a/tools/AgentMemory.Cli/Perf/BoundedWorkScheduler.cs b/tools/AgentMemory.Cli/Perf/BoundedWorkScheduler.cs new file mode 100644 index 00000000..9a399569 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/BoundedWorkScheduler.cs @@ -0,0 +1,85 @@ +namespace AgentMemory.Cli.Perf; + +internal sealed record BoundedWorkResult( + IReadOnlyList Results, + int MaxConcurrency); + +/// +/// Executes a fixed ordered cohort through a bounded number of workers. +/// Admission stops on cancellation or the first failure; already admitted work is cancelled and awaited. +/// +internal static class BoundedWorkScheduler +{ + public static async Task> RunAsync( + IReadOnlyList>> work, + int maxConcurrency, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(work); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrency); + + if (work.Count == 0) + return new BoundedWorkResult([], 0); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var results = new T[work.Count]; + var nextIndex = -1; + var active = 0; + var observedMaximum = 0; + var workerCount = Math.Min(maxConcurrency, work.Count); + + async Task WorkerAsync() + { + while (true) + { + linked.Token.ThrowIfCancellationRequested(); + var index = Interlocked.Increment(ref nextIndex); + if (index >= work.Count) + return; + + var current = Interlocked.Increment(ref active); + UpdateMaximum(ref observedMaximum, current); + try + { + results[index] = await work[index](linked.Token).ConfigureAwait(false); + } + catch + { + await linked.CancelAsync().ConfigureAwait(false); + throw; + } + finally + { + Interlocked.Decrement(ref active); + } + } + } + + var workers = Enumerable.Range(0, workerCount) + .Select(_ => WorkerAsync()) + .ToArray(); + + try + { + await Task.WhenAll(workers).ConfigureAwait(false); + } + catch when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + return new BoundedWorkResult(results, observedMaximum); + } + + private static void UpdateMaximum(ref int target, int candidate) + { + while (true) + { + var current = Volatile.Read(ref target); + if (candidate <= current) + return; + if (Interlocked.CompareExchange(ref target, candidate, current) == current) + return; + } + } +} diff --git a/tools/AgentMemory.Cli/Perf/ConcurrencyAnalysis.cs b/tools/AgentMemory.Cli/Perf/ConcurrencyAnalysis.cs new file mode 100644 index 00000000..da381d8b --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/ConcurrencyAnalysis.cs @@ -0,0 +1,116 @@ +namespace AgentMemory.Cli.Perf; + +/// A percentile distribution serialized into M-18 artifacts. +internal sealed record ConcurrencyDistribution( + double P50, + double P95, + double P99, + double Min, + double Max); + +/// Timing and load aggregates for one workload at one concurrency level. +internal sealed record ConcurrencyLevelAnalysis( + int Concurrency, + int Requests, + double ElapsedMilliseconds, + double ErrorRate, + double AchievedOperationsPerSecond, + ConcurrencyDistribution RequestMilliseconds, + ConcurrencyDistribution TransactionEntryDelayEstimateMilliseconds); + +/// +/// Safe, content-free correctness totals for one M-18 concurrency level. +/// +internal sealed record ConcurrencyCorrectnessSnapshot( + int Concurrency, + int OperationErrors, + int OwnerLeaks, + int OwnerMisses, + long DedupLiveFacts, + long SupersessionLosersPresent, + long SupersessionLosersClosed, + long SupersessionEdges, + long SupersessionWinnersLive, + long CrossOwnerEdges, + int TransactionEntryEstimateSamples); + +internal static class ConcurrencyAnalysis +{ + internal static ConcurrencyLevelAnalysis Analyze( + int concurrency, + double elapsedMilliseconds, + IReadOnlyList requestMilliseconds, + IReadOnlyList transactionEntryEstimateMilliseconds, + int operationErrors) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(concurrency); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(elapsedMilliseconds); + ArgumentNullException.ThrowIfNull(requestMilliseconds); + ArgumentNullException.ThrowIfNull(transactionEntryEstimateMilliseconds); + ArgumentOutOfRangeException.ThrowIfNegative(operationErrors); + + var requests = requestMilliseconds.Count; + return new ConcurrencyLevelAnalysis( + concurrency, + requests, + elapsedMilliseconds, + requests == 0 ? 0 : (double)operationErrors / requests, + requests * 1000.0 / elapsedMilliseconds, + Percentiles(requestMilliseconds), + Percentiles(transactionEntryEstimateMilliseconds)); + } + + internal static ConcurrencyDistribution Percentiles(IEnumerable source) + { + ArgumentNullException.ThrowIfNull(source); + var values = source.Order().ToList(); + if (values.Count == 0) + return new ConcurrencyDistribution(0, 0, 0, 0, 0); + + return new ConcurrencyDistribution( + Quantile(values, 0.50), + Quantile(values, 0.95), + Quantile(values, 0.99), + values[0], + values[^1]); + } + + private static double Quantile(IReadOnlyList sorted, double quantile) + { + if (sorted.Count == 1) return sorted[0]; + var position = (sorted.Count - 1) * quantile; + var lower = (int)Math.Floor(position); + var upper = (int)Math.Ceiling(position); + if (lower == upper) return sorted[lower]; + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); + } +} + +internal static class ConcurrencyRunValidator +{ + internal static IReadOnlyList Validate(ConcurrencyCorrectnessSnapshot snapshot) + { + var issues = new List(); + if (snapshot.OperationErrors != 0) + issues.Add("operation-errors"); + if (snapshot.OwnerLeaks != 0) + issues.Add("owner-leak"); + if (snapshot.OwnerMisses != 0) + issues.Add("owner-miss"); + if (snapshot.DedupLiveFacts != 1) + issues.Add("dedup-live-count"); + if (snapshot.SupersessionLosersPresent != snapshot.Concurrency) + issues.Add("supersession-loser-presence"); + if (snapshot.SupersessionLosersClosed != snapshot.Concurrency) + issues.Add("supersession-loser-closure"); + if (snapshot.SupersessionEdges != snapshot.Concurrency) + issues.Add("supersession-edge-count"); + if (snapshot.SupersessionWinnersLive != snapshot.Concurrency) + issues.Add("supersession-winner-live"); + if (snapshot.CrossOwnerEdges != 0) + issues.Add("supersession-cross-owner-edge"); + if (snapshot.TransactionEntryEstimateSamples == 0) + issues.Add("transaction-entry-estimate-missing"); + return issues; + } +} diff --git a/tools/AgentMemory.Cli/Perf/CountingClients.cs b/tools/AgentMemory.Cli/Perf/CountingClients.cs index d2e5fa15..2d753d6b 100644 --- a/tools/AgentMemory.Cli/Perf/CountingClients.cs +++ b/tools/AgentMemory.Cli/Perf/CountingClients.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.Extensions.AI; namespace AgentMemory.Cli.Perf; @@ -16,7 +17,7 @@ public sealed class CountingEmbeddingGenerator : IEmbeddingGenerator> inner) => _inner = inner; - public Task>> GenerateAsync( + public async Task>> GenerateAsync( IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) @@ -33,7 +34,13 @@ public Task>> GenerateAsync( turn.Add("embed.chars", materialized.Sum(v => (long)(v?.Length ?? 0))); } - return _inner.GenerateAsync(materialized, options, cancellationToken); + var startedAt = Stopwatch.GetTimestamp(); + var response = await _inner.GenerateAsync(materialized, options, cancellationToken) + .ConfigureAwait(false); + turn?.RecordSpan( + "provider.embedding", + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds); + return response; } public object? GetService(Type serviceType, object? serviceKey = null) => @@ -57,8 +64,10 @@ public async Task GetResponseAsync( ChatOptions? options = null, CancellationToken cancellationToken = default) { + var purpose = ExtractionPurpose(Activity.Current?.OperationName); + var startedAt = Stopwatch.GetTimestamp(); var response = await _inner.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - Record(response); + Record(response, purpose, Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds); return response; } @@ -67,7 +76,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { - PerfCollector.Current?.Add("llm.calls"); + var turn = PerfCollector.Current; + var purpose = ExtractionPurpose(Activity.Current?.OperationName); + turn?.Add("llm.calls"); + if (purpose is not null) + turn?.Add($"llm.{purpose}.calls"); await foreach (var update in _inner .GetStreamingResponseAsync(messages, options, cancellationToken) .WithCancellation(cancellationToken) @@ -77,19 +90,43 @@ public async IAsyncEnumerable GetStreamingResponseAsync( } } - private static void Record(ChatResponse response) + private static void Record(ChatResponse response, string? purpose, double durationMs) { var turn = PerfCollector.Current; if (turn is null) return; turn.Add("llm.calls"); + if (purpose is not null) + { + turn.Add($"llm.{purpose}.calls"); + turn.RecordSpan($"provider.llm.{purpose}", durationMs); + } + if (response.Usage is { } usage) { - turn.Add("llm.tokens_in", usage.InputTokenCount ?? 0); - turn.Add("llm.tokens_out", usage.OutputTokenCount ?? 0); + var inputTokens = usage.InputTokenCount ?? 0; + var outputTokens = usage.OutputTokenCount ?? 0; + turn.Add("llm.tokens_in", inputTokens); + turn.Add("llm.tokens_out", outputTokens); + if (purpose is not null) + { + turn.Add($"llm.{purpose}.tokens_in", inputTokens); + turn.Add($"llm.{purpose}.tokens_out", outputTokens); + } } } + private static string? ExtractionPurpose(string? operationName) => operationName switch + { + "memory.extraction.entities" or "lab.extraction.entity" => "entity", + "memory.extraction.facts" or "lab.extraction.fact" => "fact", + "memory.extraction.preferences" or "lab.extraction.preference" => "preference", + "memory.extraction.relationships" or "lab.extraction.relationship" => "relationship", + "memory.extract.unified" or "lab.extraction.unified" => "unified", + "memory.extract.unified_batch" => "unified_batch", + _ => null, + }; + public object? GetService(Type serviceType, object? serviceKey = null) => serviceType.IsInstanceOfType(this) ? this : _inner.GetService(serviceType, serviceKey); diff --git a/tools/AgentMemory.Cli/Perf/Fixtures/retrieval-quality.json b/tools/AgentMemory.Cli/Perf/Fixtures/retrieval-quality.json index 508ccafe..ca22b5c9 100644 --- a/tools/AgentMemory.Cli/Perf/Fixtures/retrieval-quality.json +++ b/tools/AgentMemory.Cli/Perf/Fixtures/retrieval-quality.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Judged retrieval-quality fixture. Five topics with deliberately disjoint vocabulary, so a query about one topic should retrieve that topic's memories and not another's. Scored with Recall@K and MRR against hand-labelled relevant ids. Deterministic: no model is involved anywhere in scoring.", + "description": "Deterministic-plumbing retrieval fixture. Five topics have deliberately disjoint vocabulary so the FNV-1a test embedder makes expected neighbors construction-stable. Recall@K/MRR assert wiring, ranking, and forbidden-result handling; they are explicitly not a real-embedding semantic-quality claim. Sampled real-model quality belongs to M-27.", "ownerId": "perf-quality-owner", "sessionId": "perf-quality-session", diff --git a/tools/AgentMemory.Cli/Perf/FrozenExtractionOverrides.cs b/tools/AgentMemory.Cli/Perf/FrozenExtractionOverrides.cs new file mode 100644 index 00000000..f3ff2cc8 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/FrozenExtractionOverrides.cs @@ -0,0 +1,149 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentMemory.Cli.Perf; + +/// +/// Harness-only extractor overrides for LAB-P0. They intercept one explicit source marker and +/// delegate every other request to the real registered extractor. +/// +public static class FrozenExtractionOverrides +{ + public const string SourceMarker = + "LAB-P0 frozen source: Rowan Vale works at Northstar P0 Labs and prefers terse status notes."; + + public static void Decorate(IServiceCollection services) + { + Decorate(services, inner => new FrozenEntityExtractor(inner)); + Decorate(services, inner => new FrozenFactExtractor(inner)); + Decorate(services, inner => new FrozenPreferenceExtractor(inner)); + Decorate(services, inner => new FrozenRelationshipExtractor(inner)); + } + + public sealed class FrozenEntityExtractor(IEntityExtractor inner) : IEntityExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default) => + IsFrozen(messages) + ? Task.FromResult>( + [ + new() + { + Name = "Northstar P0 Labs", + Type = "ORGANIZATION", + Confidence = 0.92, + }, + new() + { + Name = "Rowan Vale", + Type = "PERSON", + Confidence = 0.95, + }, + ]) + : inner.ExtractAsync(messages, cancellationToken); + } + + public sealed class FrozenFactExtractor(IFactExtractor inner) : IFactExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default) => + IsFrozen(messages) + ? Task.FromResult>( + [ + new() + { + Subject = "Rowan Vale", + Predicate = "works_at", + Object = "Northstar P0 Labs", + Confidence = 0.90, + }, + new() + { + Subject = "Rowan Vale", + Predicate = "leads", + Object = "cold-build acceleration", + Confidence = 0.85, + }, + ]) + : inner.ExtractAsync(messages, cancellationToken); + } + + public sealed class FrozenPreferenceExtractor(IPreferenceExtractor inner) : IPreferenceExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default) => + IsFrozen(messages) + ? Task.FromResult>( + [ + new() + { + Category = "communication", + PreferenceText = "prefers terse status notes", + Confidence = 0.88, + }, + ]) + : inner.ExtractAsync(messages, cancellationToken); + } + + public sealed class FrozenRelationshipExtractor(IRelationshipExtractor inner) : IRelationshipExtractor + { + public Task> ExtractAsync( + IReadOnlyList messages, + CancellationToken cancellationToken = default) => + IsFrozen(messages) + ? Task.FromResult>( + [ + new() + { + SourceEntity = "Rowan Vale", + TargetEntity = "Northstar P0 Labs", + RelationshipType = "LAB_P0_WORKS_AT", + Confidence = 0.90, + }, + ]) + : inner.ExtractAsync(messages, cancellationToken); + } + + private static bool IsFrozen(IReadOnlyList messages) => + messages.Any(message => + string.Equals(message.Content, SourceMarker, StringComparison.Ordinal)); + + private static void Decorate( + IServiceCollection services, + Func wrap) + where TService : class + { + var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TService)) + ?? throw new InvalidOperationException( + $"{typeof(TService).Name} was not registered before the LAB-P0 decorator."); + + services.Remove(descriptor); + services.Add(new ServiceDescriptor( + typeof(TService), + provider => wrap(CreateService(provider, descriptor)), + descriptor.Lifetime)); + } + + private static TService CreateService( + IServiceProvider provider, + ServiceDescriptor descriptor) + where TService : class + { + if (descriptor.ImplementationInstance is TService instance) + return instance; + + if (descriptor.ImplementationFactory is not null) + return (TService)descriptor.ImplementationFactory(provider); + + if (descriptor.ImplementationType is not null) + return (TService)ActivatorUtilities.CreateInstance( + provider, descriptor.ImplementationType); + + throw new InvalidOperationException( + $"{typeof(TService).Name} registration has no implementation."); + } +} diff --git a/tools/AgentMemory.Cli/Perf/HermeticProfile.cs b/tools/AgentMemory.Cli/Perf/HermeticProfile.cs index ce5460e4..2a021195 100644 --- a/tools/AgentMemory.Cli/Perf/HermeticProfile.cs +++ b/tools/AgentMemory.Cli/Perf/HermeticProfile.cs @@ -1,4 +1,5 @@ using AgentMemory; +using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Services; using AgentMemory.Neo4j.Infrastructure; using Microsoft.Extensions.AI; @@ -38,16 +39,43 @@ public sealed class HermeticProfile : IAsyncDisposable private AsyncServiceScope _scope; private bool _scopeCreated; - private HermeticProfile(int dimensions, PerfScale scale, ScaleMRunVolume? scaleRunVolume) + private HermeticProfile( + int dimensions, + PerfScale scale, + ScaleMRunVolume? scaleRunVolume, + int maxConnectionPoolSize, + bool useBatchEntityResolutionSnapshots, + bool useCoalescedPersistenceTransactions, + bool poolSizeExplicitlyConfigured) { Dimensions = dimensions; Scale = scale; _scaleRunVolume = scaleRunVolume; + MaxConnectionPoolSize = maxConnectionPoolSize; + UseBatchEntityResolutionSnapshots = useBatchEntityResolutionSnapshots; + UseCoalescedPersistenceTransactions = useCoalescedPersistenceTransactions; + PoolSizeExplicitlyConfigured = poolSizeExplicitlyConfigured; } /// Embedding dimensionality. Small by design — vector width is not what is being measured. public int Dimensions { get; } + /// Fixed product-driver pool size fingerprinted by concurrency artifacts. + public int MaxConnectionPoolSize { get; } + + /// + /// True only when an operator explicitly selected the pool size (the D1 pool-curve arms). + /// Lab self-assertions treat any non-16 pool without this deliberate, fingerprinted selection + /// as a wiring error. + /// + public bool PoolSizeExplicitlyConfigured { get; } + + /// Whether batch-scoped owner/type entity candidate snapshots are enabled. + public bool UseBatchEntityResolutionSnapshots { get; } + + /// Whether successful logical persistence operations share one atomic transaction. + public bool UseCoalescedPersistenceTransactions { get; } + /// Scoped service provider for resolving memory services. public IServiceProvider Services => _scope.ServiceProvider; @@ -60,6 +88,9 @@ private HermeticProfile(int dimensions, PerfScale scale, ScaleMRunVolume? scaleR /// Raw driver, for bulk fixture seeding that would be pointlessly slow through the services. public IDriver Driver { get; private set; } = null!; + /// Container identifier exposed only to explicit resource-capacity laboratory scenarios. + internal string ContainerId => _container?.Id ?? throw new InvalidOperationException("Neo4j is not running."); + /// Scenario-scoped dependency latency; unset outside an explicitly degraded scenario. public PerfDependencyLatency DependencyLatency { get; } = new(); @@ -86,15 +117,27 @@ public static async Task StartAsync( TextWriter log, PerfScale scale, IReadOnlyList? scriptedRules = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + int maxConnectionPoolSize = 100, + bool useUnifiedExtraction = false, + bool useBatchEntityResolutionSnapshots = true, + bool useCoalescedPersistenceTransactions = true, + bool poolSizeExplicitlyConfigured = false) { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConnectionPoolSize); var scaleRunVolume = scale == PerfScale.Medium ? await ScaleMDataset.PrepareRunVolumeAsync(dimensions, log, cancellationToken).ConfigureAwait(false) : null; - var profile = new HermeticProfile(dimensions, scale, scaleRunVolume); + var profile = new HermeticProfile( + dimensions, scale, scaleRunVolume, maxConnectionPoolSize, + useBatchEntityResolutionSnapshots, useCoalescedPersistenceTransactions, + poolSizeExplicitlyConfigured); try { - await profile.InitializeAsync(embeddingLatency, modelLatency, log, scriptedRules, cancellationToken) + await profile.InitializeAsync( + embeddingLatency, modelLatency, log, scriptedRules, useUnifiedExtraction, + useBatchEntityResolutionSnapshots, useCoalescedPersistenceTransactions, + cancellationToken) .ConfigureAwait(false); return profile; } @@ -107,7 +150,10 @@ await profile.InitializeAsync(embeddingLatency, modelLatency, log, scriptedRules private async Task InitializeAsync( TimeSpan embeddingLatency, TimeSpan modelLatency, TextWriter log, - IReadOnlyList? scriptedRules, CancellationToken cancellationToken) + IReadOnlyList? scriptedRules, bool useUnifiedExtraction, + bool useBatchEntityResolutionSnapshots, + bool useCoalescedPersistenceTransactions, + CancellationToken cancellationToken) { log.WriteLine($"perf: starting {Image} (Testcontainers)…"); var builder = new Neo4jBuilder(Image) @@ -125,7 +171,13 @@ private async Task InitializeAsync( services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); services.AddNeo4jAgentMemory( - memory => { /* shipped defaults — measuring anything else would measure a strawman */ }, + memory => + { + memory.Extraction.UseBatchEmbeddingRequests = true; + memory.Extraction.UseBatchEntityResolutionSnapshots = useBatchEntityResolutionSnapshots; + memory.Extraction.UseCoalescedPersistenceTransactions = + useCoalescedPersistenceTransactions; + }, neo4j => { neo4j.Uri = uri; @@ -133,10 +185,19 @@ private async Task InitializeAsync( neo4j.Password = ContainerPassword; neo4j.Database = "neo4j"; neo4j.EmbeddingDimensions = Dimensions; + neo4j.MaxConnectionPoolSize = MaxConnectionPoolSize; }, // A non-null delegate is what opts the LLM extractors in; without it the Core no-op stubs // stay registered and a post-turn scenario would measure extraction that never happens. - llm => { }); + llm => + { + llm.UseUnifiedExtraction = useUnifiedExtraction; + llm.UseMultiSessionBatchExtraction = useUnifiedExtraction; + }); + + // LAB-P0 intercepts only its explicit source marker and delegates every other extraction. + // Register before the provider is built so the real pipeline still owns resolution/persistence. + FrozenExtractionOverrides.Decorate(services); // Registered exactly as a host source would be, but the shared MemoryOptions keep GraphRAG // disabled. PERF-R-08 builds an isolated production assembler with EnableGraphRag=true; every @@ -276,10 +337,11 @@ public async Task>> GenerateAsync( /// Inserts deterministic waiting inside the real transaction span. The original work delegate still /// receives the product's instrumented query runner, so query counting and behavior are unchanged. /// - private sealed class LatencyInjectingTransactionRunner : INeo4jTransactionRunner + private sealed class LatencyInjectingTransactionRunner : INeo4jTransactionRunner, INeo4jAtomicTransactionRunner { private readonly INeo4jTransactionRunner _inner; private readonly PerfDependencyLatency _dependencyLatency; + private readonly INeo4jAtomicTransactionRunner _atomicInner; public LatencyInjectingTransactionRunner( INeo4jTransactionRunner inner, @@ -287,6 +349,8 @@ public LatencyInjectingTransactionRunner( { _inner = inner; _dependencyLatency = dependencyLatency; + _atomicInner = inner as INeo4jAtomicTransactionRunner + ?? throw new InvalidOperationException("The decorated Neo4j runner must support atomic write units."); } public Task ReadAsync( @@ -333,6 +397,11 @@ public Task WriteAsync( }, cancellationToken); + public Task ExecuteAtomicWriteAsync( + Func> work, + CancellationToken cancellationToken = default) => + _atomicInner.ExecuteAtomicWriteAsync(work, cancellationToken); + private async Task DelayAsync(CancellationToken cancellationToken) { var delay = _dependencyLatency.Current?.DatabaseDelay ?? TimeSpan.Zero; diff --git a/tools/AgentMemory.Cli/Perf/Neo4jContainerStatsParser.cs b/tools/AgentMemory.Cli/Perf/Neo4jContainerStatsParser.cs new file mode 100644 index 00000000..a6e9f8b2 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/Neo4jContainerStatsParser.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Text.Json; + +namespace AgentMemory.Cli.Perf; + +internal readonly record struct Neo4jContainerStatsSample( + double CpuRawPercent, + double CpuCapacityPercent, + long MemoryUsedBytes, + long MemoryLimitBytes, + double MemoryPercent, + long BlockReadBytes, + long BlockWriteBytes, + long ProcessCount); + +internal static class Neo4jContainerStatsParser +{ + public static bool TryParse( + string json, + double effectiveCpuCount, + out Neo4jContainerStatsSample sample) + { + sample = default; + if (string.IsNullOrWhiteSpace(json) || effectiveCpuCount <= 0) + return false; + + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (!TryPercent(root, "CPUPerc", out var cpuRawPercent) || + !TrySplitBytes(root, "MemUsage", out var memoryUsedBytes, out var memoryLimitBytes) || + !TryPercent(root, "MemPerc", out var memoryPercent) || + !TrySplitBytes(root, "BlockIO", out var blockReadBytes, out var blockWriteBytes) || + !root.TryGetProperty("PIDs", out var pidsElement) || + !long.TryParse( + pidsElement.GetString(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var processCount)) + { + return false; + } + + sample = new Neo4jContainerStatsSample( + cpuRawPercent, + cpuRawPercent / effectiveCpuCount, + memoryUsedBytes, + memoryLimitBytes, + memoryPercent, + blockReadBytes, + blockWriteBytes, + processCount); + return true; + } + catch (JsonException) + { + return false; + } + } + + public static bool TryParseBytes(string text, out long bytes) + { + bytes = 0; + if (string.IsNullOrWhiteSpace(text)) + return false; + + var index = 0; + while (index < text.Length && + (char.IsDigit(text[index]) || text[index] is '.' or ',' or '+' or '-')) + { + index++; + } + + if (index == 0 || + !double.TryParse( + text[..index].Replace(',', '.'), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var value) || + value < 0) + { + return false; + } + + var unit = text[index..].Trim(); + var multiplier = unit switch + { + "B" => 1d, + "kB" => 1_000d, + "MB" => 1_000_000d, + "GB" => 1_000_000_000d, + "TB" => 1_000_000_000_000d, + "KiB" => 1_024d, + "MiB" => 1_048_576d, + "GiB" => 1_073_741_824d, + "TiB" => 1_099_511_627_776d, + _ => double.NaN, + }; + if (double.IsNaN(multiplier) || value > long.MaxValue / multiplier) + return false; + + bytes = (long)(value * multiplier); + return true; + } + + private static bool TryPercent(JsonElement root, string property, out double value) + { + value = 0; + return root.TryGetProperty(property, out var element) && + element.ValueKind == JsonValueKind.String && + double.TryParse( + element.GetString()?.TrimEnd('%'), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out value); + } + + private static bool TrySplitBytes( + JsonElement root, + string property, + out long first, + out long second) + { + first = 0; + second = 0; + if (!root.TryGetProperty(property, out var element) || + element.ValueKind != JsonValueKind.String) + { + return false; + } + + var parts = element.GetString()?.Split('/', StringSplitOptions.TrimEntries); + return parts is { Length: 2 } && + TryParseBytes(parts[0], out first) && + TryParseBytes(parts[1], out second); + } +} diff --git a/tools/AgentMemory.Cli/Perf/Neo4jResourceTelemetry.cs b/tools/AgentMemory.Cli/Perf/Neo4jResourceTelemetry.cs new file mode 100644 index 00000000..372b101f --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/Neo4jResourceTelemetry.cs @@ -0,0 +1,334 @@ +using System.Diagnostics; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +/// +/// Samples numeric, content-free Neo4j container and JVM resource evidence for explicit capacity labs. +/// The raw driver keeps monitoring queries out of product query/transaction counters. +/// +internal sealed class Neo4jResourceTelemetry : IAsyncDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3); + private static readonly TimeSpan ColdStaticProbeTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan DockerCommandTimeout = TimeSpan.FromSeconds(10); + + private readonly HermeticProfile _profile; + private readonly TurnRecord _turn; + private readonly double _effectiveCpuCount; + private readonly CancellationTokenSource _stop; + private readonly Task _dockerLoop; + private readonly Task _neo4jLoop; + private bool _disposed; + + private Neo4jResourceTelemetry( + HermeticProfile profile, + TurnRecord turn, + double effectiveCpuCount, + CancellationToken cancellationToken) + { + _profile = profile; + _turn = turn; + _effectiveCpuCount = effectiveCpuCount; + _stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _dockerLoop = SampleDockerStatsAsync(_stop.Token); + _neo4jLoop = SampleNeo4jAsync(_stop.Token); + } + + public static async Task StartAsync( + HermeticProfile profile, + TurnRecord turn, + CancellationToken cancellationToken) + { + var cpuCount = await ReadDockerCpuCountAsync(cancellationToken).ConfigureAwait(false); + turn.RecordSample("neo4j.container.effective_cpu_count", cpuCount); + turn.Add("neo4j.telemetry.page_cache_global_supported", 0); + + var telemetry = new Neo4jResourceTelemetry(profile, turn, cpuCount, cancellationToken); + await telemetry.RecordStaticSettingsAsync(cancellationToken).ConfigureAwait(false); + return telemetry; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _stop.Cancel(); + + await ObserveAsync(_dockerLoop).ConfigureAwait(false); + await ObserveAsync(_neo4jLoop).ConfigureAwait(false); + _stop.Dispose(); + } + + + private async Task SampleDockerStatsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var dockerStats = StartDockerStats(_profile.ContainerId); + var outputTask = dockerStats.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = dockerStats.StandardError.ReadToEndAsync(cancellationToken); + var line = await outputTask + .WaitAsync(DockerCommandTimeout, cancellationToken) + .ConfigureAwait(false); + await errorTask.WaitAsync(DockerCommandTimeout, cancellationToken).ConfigureAwait(false); + await dockerStats.WaitForExitAsync(cancellationToken) + .WaitAsync(DockerCommandTimeout, cancellationToken) + .ConfigureAwait(false); + if (dockerStats.ExitCode != 0) + { + _turn.Add("neo4j.telemetry.docker_errors"); + } + else if (!Neo4jContainerStatsParser.TryParse(line, _effectiveCpuCount, out var sample)) + { + _turn.Add("neo4j.telemetry.docker_parse_errors"); + } + else + { + _turn.Add("neo4j.telemetry.docker_samples"); + _turn.RecordSample("neo4j.container.cpu_raw_percent", sample.CpuRawPercent); + _turn.RecordSample("neo4j.container.cpu_capacity_percent", sample.CpuCapacityPercent); + _turn.RecordSample("neo4j.container.memory_used_bytes", sample.MemoryUsedBytes); + _turn.RecordSample("neo4j.container.memory_limit_bytes", sample.MemoryLimitBytes); + _turn.RecordSample("neo4j.container.memory_percent", sample.MemoryPercent); + _turn.RecordSample("neo4j.container.block_read_bytes", sample.BlockReadBytes); + _turn.RecordSample("neo4j.container.block_write_bytes", sample.BlockWriteBytes); + _turn.RecordSample("neo4j.container.pids", sample.ProcessCount); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch + { + _turn.Add("neo4j.telemetry.docker_errors"); + } + + try + { + await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task SampleNeo4jAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await RecordJvmSampleAsync(cancellationToken).ConfigureAwait(false); + await RecordTransactionSampleAsync(cancellationToken).ConfigureAwait(false); + _turn.Add("neo4j.telemetry.neo4j_samples"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch + { + _turn.Add("neo4j.telemetry.neo4j_errors"); + } + + try + { + await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task RecordStaticSettingsAsync(CancellationToken cancellationToken) + { + const string cypher = """ + SHOW SETTINGS YIELD name, value + WHERE name IN ['server.memory.pagecache.size', + 'server.memory.heap.initial_size', + 'server.memory.heap.max_size', + 'server.bolt.thread_pool_min_size', + 'server.bolt.thread_pool_max_size', + 'db.memory.transaction.total.max'] + RETURN name, value + """; + + var records = await QueryAsync(cypher, cancellationToken, ColdStaticProbeTimeout) + .ConfigureAwait(false); + foreach (var record in records) + { + var name = record["name"].As(); + var value = record["value"].As(); + switch (name) + { + case "server.memory.pagecache.size" when + Neo4jContainerStatsParser.TryParseBytes(value ?? string.Empty, out var bytes): + _turn.RecordSample("neo4j.page_cache.configured_bytes", bytes); + break; + case "server.memory.heap.initial_size" when + Neo4jContainerStatsParser.TryParseBytes(value ?? string.Empty, out var bytes): + _turn.RecordSample("neo4j.heap.configured_initial_bytes", bytes); + break; + case "server.memory.heap.max_size" when + Neo4jContainerStatsParser.TryParseBytes(value ?? string.Empty, out var bytes): + _turn.RecordSample("neo4j.heap.configured_max_bytes", bytes); + break; + case "server.bolt.thread_pool_min_size" when long.TryParse(value, out var count): + _turn.RecordSample("neo4j.bolt.thread_pool_min", count); + break; + case "server.bolt.thread_pool_max_size" when long.TryParse(value, out var count): + _turn.RecordSample("neo4j.bolt.thread_pool_max", count); + break; + case "db.memory.transaction.total.max" when + Neo4jContainerStatsParser.TryParseBytes(value ?? string.Empty, out var bytes): + _turn.RecordSample("neo4j.transaction_memory.configured_max_bytes", bytes); + break; + } + } + } + + private async Task RecordJvmSampleAsync(CancellationToken cancellationToken) + { + const string cypher = """ + CALL dbms.queryJmx('java.lang:type=Memory') YIELD attributes + RETURN attributes.HeapMemoryUsage.value.properties.used AS heapUsed, + attributes.HeapMemoryUsage.value.properties.committed AS heapCommitted, + attributes.HeapMemoryUsage.value.properties.max AS heapMax, + attributes.NonHeapMemoryUsage.value.properties.used AS nonHeapUsed + """; + var record = (await QueryAsync(cypher, cancellationToken).ConfigureAwait(false)).Single(); + _turn.RecordSample("neo4j.jvm.heap_used_bytes", record["heapUsed"].As()); + _turn.RecordSample("neo4j.jvm.heap_committed_bytes", record["heapCommitted"].As()); + _turn.RecordSample("neo4j.jvm.heap_max_bytes", record["heapMax"].As()); + _turn.RecordSample("neo4j.jvm.non_heap_used_bytes", record["nonHeapUsed"].As()); + } + + private async Task RecordTransactionSampleAsync(CancellationToken cancellationToken) + { + const string cypher = """ + SHOW TRANSACTIONS YIELD currentQuery, currentQueryWaitTime, currentQueryCpuTime, + currentQueryAllocatedBytes, currentQueryPageHits, + currentQueryPageFaults, currentQueryActiveLockCount + WHERE currentQuery IS NOT NULL + AND NOT currentQuery STARTS WITH 'SHOW TRANSACTIONS' + RETURN currentQueryWaitTime AS waitTime, + currentQueryCpuTime AS cpuTime, + currentQueryAllocatedBytes AS allocatedBytes, + currentQueryPageHits AS pageHits, + currentQueryPageFaults AS pageFaults, + currentQueryActiveLockCount AS activeLockCount + """; + var records = await QueryAsync(cypher, cancellationToken).ConfigureAwait(false); + _turn.RecordSample("neo4j.transactions.active", records.Count); + foreach (var record in records) + { + if (record["waitTime"] is Duration wait) + _turn.RecordSample("neo4j.transaction.wait_ms", Milliseconds(wait)); + if (record["cpuTime"] is Duration cpu) + _turn.RecordSample("neo4j.transaction.cpu_ms", Milliseconds(cpu)); + if (record["allocatedBytes"] is long allocated) + _turn.RecordSample("neo4j.transaction.allocated_bytes", allocated); + if (record["pageHits"] is long pageHits) + _turn.RecordSample("neo4j.transaction.page_hits", pageHits); + if (record["pageFaults"] is long pageFaults) + _turn.RecordSample("neo4j.transaction.page_faults", pageFaults); + if (record["activeLockCount"] is long activeLocks) + _turn.RecordSample("neo4j.transaction.active_locks", activeLocks); + } + } + + private async Task> QueryAsync( + string cypher, + CancellationToken cancellationToken, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? ProbeTimeout; + await using var session = _profile.Driver.AsyncSession(); + var cursor = await session.RunAsync(cypher) + .WaitAsync(effectiveTimeout, cancellationToken) + .ConfigureAwait(false); + return await cursor.ToListAsync() + .WaitAsync(effectiveTimeout, cancellationToken) + .ConfigureAwait(false); + } + + private static double Milliseconds(object value) + { + var duration = value.As(); + return duration.Days * TimeSpan.FromDays(1).TotalMilliseconds + + duration.Seconds * 1_000d + duration.Nanos / 1_000_000d; + } + + private static Process StartDockerStats(string containerId) + { + var startInfo = new ProcessStartInfo("docker") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("stats"); + startInfo.ArgumentList.Add("--no-stream"); + startInfo.ArgumentList.Add("--format"); + startInfo.ArgumentList.Add("{{json .}}"); + startInfo.ArgumentList.Add("--no-trunc"); + startInfo.ArgumentList.Add(containerId); + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start Docker resource sampler."); + } + + private static async Task ReadDockerCpuCountAsync(CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo("docker") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("info"); + startInfo.ArgumentList.Add("--format"); + startInfo.ArgumentList.Add("{{.NCPU}}"); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to inspect Docker CPU capacity."); + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken) + .WaitAsync(DockerCommandTimeout, cancellationToken) + .ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken) + .WaitAsync(DockerCommandTimeout, cancellationToken) + .ConfigureAwait(false); + if (process.ExitCode != 0 || + !double.TryParse( + output.Trim(), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var cpuCount) || + cpuCount <= 0) + { + throw new InvalidOperationException("Docker did not report a positive CPU capacity."); + } + + return cpuCount; + } + + private static async Task ObserveAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } +} diff --git a/tools/AgentMemory.Cli/Perf/PerfCollector.cs b/tools/AgentMemory.Cli/Perf/PerfCollector.cs index 8ad10e4d..a7bd4682 100644 --- a/tools/AgentMemory.Cli/Perf/PerfCollector.cs +++ b/tools/AgentMemory.Cli/Perf/PerfCollector.cs @@ -92,6 +92,8 @@ private void OnActivityStopped(Activity activity) turn.Add("neo4j.records", records); if (activity.GetTagItem("db.bytes_est") is long bytesEstimate) turn.Add("neo4j.bytes_est", bytesEstimate); + if (activity.GetTagItem("db.transaction_entry_ms_est") is double entryEstimate) + turn.RecordSample("neo4j.transaction_entry_ms_est", entryEstimate); break; case "memory.db.query": diff --git a/tools/AgentMemory.Cli/Perf/PerfFixture.cs b/tools/AgentMemory.Cli/Perf/PerfFixture.cs index d50fe9e3..2fd464e3 100644 --- a/tools/AgentMemory.Cli/Perf/PerfFixture.cs +++ b/tools/AgentMemory.Cli/Perf/PerfFixture.cs @@ -280,6 +280,44 @@ public sealed record SessionExtractionShape( long Preferences, long ProvenanceRelationships); + public sealed record RawBatchStorageShape( + long Messages, + long MessagesWithExpectedEmbedding, + long DistinctIds, + IReadOnlyList Ids); + + /// + /// Reads raw messages after the measured turn to prove the batch and every expected-size embedding + /// reached Neo4j. The raw driver keeps verification work out of the measured product counters. + /// + public static async Task InspectRawBatchStorageAsync( + HermeticProfile profile, + string sessionId, + int dimensions) + { + const string cypher = """ + MATCH (m:Message {session_id: $sessionId}) + WITH m ORDER BY m.id + RETURN count(m) AS messages, + count(CASE WHEN m.embedding IS NOT NULL + AND size(m.embedding) = $dimensions THEN 1 END) + AS messagesWithExpectedEmbedding, + count(DISTINCT m.id) AS distinctIds, + collect(m.id) AS ids + """; + + await using var session = profile.Driver.AsyncSession(); + var cursor = await session.RunAsync( + cypher, + new { sessionId, dimensions }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + return new RawBatchStorageShape( + record["messages"].As(), + record["messagesWithExpectedEmbedding"].As(), + record["distinctIds"].As(), + record["ids"].As>().Select(value => value.As()).ToArray()); + } + /// /// Reads the graph after the measured turn to prove extraction actually learned the expected items. /// Raw-driver verification is intentional: it runs outside the turn and must not inflate product cost. diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.ConcurrentColdBuild.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.ConcurrentColdBuild.cs new file mode 100644 index 00000000..f9d22605 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.ConcurrentColdBuild.cs @@ -0,0 +1,286 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + private const int ColdBuildUnitCount = 10; + private const int ColdBuildEntitiesPerUnit = 2; + private const int ColdBuildFactsPerUnit = 2; + private const int ColdBuildPreferencesPerUnit = 1; + private const int ColdBuildRelationshipsPerUnit = 1; + private const int ColdBuildEmbeddingsPerUnit = 8; + private const int ColdBuildReadsPerUnit = 4; + private const int ColdBuildWritesPerUnit = 7; + private const int ColdBuildQueriesPerUnit = 27; + + private static async Task RunConcurrentColdBuildAsync(ScenarioContext context, int workers) + { + var work = Enumerable.Range(0, ColdBuildUnitCount) + .Select(unit => (Func>)(token => + RunColdBuildUnitAsync(context, workers, unit, token))) + .ToArray(); + + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var processorTimeBefore = process.TotalProcessorTime; + var waveStartedAt = Stopwatch.GetTimestamp(); + var result = await BoundedWorkScheduler + .RunAsync(work, workers, context.CancellationToken) + .ConfigureAwait(false); + var waveDuration = Stopwatch.GetElapsedTime(waveStartedAt).TotalMilliseconds; + + process.Refresh(); + var processorTimeMs = (process.TotalProcessorTime - processorTimeBefore).TotalMilliseconds; + + context.Turn.Add("cold_build.units", result.Results.Count); + context.Turn.Add("cold_build.workers", workers); + context.Turn.Add("cold_build.max_concurrency", result.MaxConcurrency); + context.Turn.RecordSample("cold_build.wave_ms", waveDuration); + context.Turn.RecordSample("cold_build.process_cpu_ms", processorTimeMs); + foreach (var unit in result.Results) + context.Turn.RecordSample("cold_build.unit_ms", unit.DurationMs); + + var calls = context.Turn.Counter("llm.unified.calls"); + context.Turn.Add("llm.unified.retries", Math.Max(0, calls - ColdBuildUnitCount)); + var expectedReadsPerUnit = context.Profile.UseCoalescedPersistenceTransactions + ? 2 : ColdBuildReadsPerUnit; + var expectedWritesPerUnit = context.Profile.UseCoalescedPersistenceTransactions + ? 2 : ColdBuildWritesPerUnit; + var expectedQueriesPerUnit = context.Profile.UseCoalescedPersistenceTransactions + ? 11 : ColdBuildQueriesPerUnit; + + var outputsExact = result.Results.All(unit => + unit.Status == IngestionStatus.Succeeded && + unit.EntityCount == ColdBuildEntitiesPerUnit && + unit.FactCount == ColdBuildFactsPerUnit && + unit.PreferenceCount == ColdBuildPreferencesPerUnit && + unit.RelationshipCount == ColdBuildRelationshipsPerUnit && + unit.SourceMessageCount == 1); + var countersExact = + context.Turn.Counter("llm.calls") == ColdBuildUnitCount && + calls == ColdBuildUnitCount && + context.Turn.Counter("llm.unified.retries") == 0 && + context.Turn.Counter("store.messages") == ColdBuildUnitCount && + context.Turn.Counter("persist.entities") == + ColdBuildUnitCount * ColdBuildEntitiesPerUnit && + context.Turn.Counter("persist.facts") == + ColdBuildUnitCount * ColdBuildFactsPerUnit && + context.Turn.Counter("persist.preferences") == + ColdBuildUnitCount * ColdBuildPreferencesPerUnit && + context.Turn.Counter("persist.relationships") == + ColdBuildUnitCount * ColdBuildRelationshipsPerUnit && + context.Turn.Counter("embed.requests") == + ColdBuildUnitCount * ColdBuildEmbeddingsPerUnit && + context.Turn.Counter("embed.items") == + ColdBuildUnitCount * ColdBuildEmbeddingsPerUnit && + context.Turn.Counter("neo4j.tx.read") == + ColdBuildUnitCount * expectedReadsPerUnit && + context.Turn.Counter("neo4j.tx.write") == + ColdBuildUnitCount * expectedWritesPerUnit && + context.Turn.Counter("neo4j.queries") == + ColdBuildUnitCount * expectedQueriesPerUnit; + + if (!outputsExact || + !countersExact || + result.MaxConcurrency != workers || + context.Profile.MaxConnectionPoolSize != 16) + { + throw new InvalidOperationException( + $"PERF-W-10-C{workers:D2} cold-build contract failed (outputs_exact={outputsExact}, " + + $"max_concurrency={result.MaxConcurrency}/{workers}, pool=" + + $"{context.Profile.MaxConnectionPoolSize}/16, llm/unified/retries=" + + $"{context.Turn.Counter("llm.calls")}/{calls}/" + + $"{context.Turn.Counter("llm.unified.retries")}, expected 10/10/0; " + + $"stored={context.Turn.Counter("store.messages")}/10; persisted=" + + $"{context.Turn.Counter("persist.entities")}/" + + $"{context.Turn.Counter("persist.facts")}/" + + $"{context.Turn.Counter("persist.preferences")}/" + + $"{context.Turn.Counter("persist.relationships")}, expected 20/20/10/10; " + + $"embed requests/items={context.Turn.Counter("embed.requests")}/" + + $"{context.Turn.Counter("embed.items")}, expected 80/80; reads/writes/queries=" + + $"{context.Turn.Counter("neo4j.tx.read")}/" + + $"{context.Turn.Counter("neo4j.tx.write")}/" + + $"{context.Turn.Counter("neo4j.queries")}, expected " + + $"{10 * expectedReadsPerUnit}/{10 * expectedWritesPerUnit}/" + + $"{10 * expectedQueriesPerUnit})."); + } + } + + private static async Task RunColdBuildUnitAsync( + ScenarioContext context, + int workers, + int unit, + CancellationToken cancellationToken) + { + var sessionId = ColdBuildSessionId(workers, context.Phase, context.Iteration, unit); + var ownerId = ColdBuildOwnerId(workers, context.Phase, context.Iteration, unit); + var message = new Message + { + MessageId = $"{sessionId}-msg-00", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = $"{UnifiedExtractionProbeMessage} Unit {unit:D2}.", + TimestampUtc = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero) + .AddMinutes(unit), + }; + + await using var scope = context.Profile.Services.CreateAsyncScope(); + var memory = scope.ServiceProvider.GetRequiredService(); + var startedAt = Stopwatch.GetTimestamp(); + var stored = await memory.AddMessagesAsync([message], cancellationToken).ConfigureAwait(false); + context.Turn.Add("store.messages", stored.Count); + if (stored.Count != 1 || + stored[0].MessageId != message.MessageId || + stored[0].Embedding is not { Length: > 0 } embedding || + embedding.Length != context.Profile.Dimensions) + { + throw new InvalidOperationException( + $"Cold-build unit {unit} did not store its exact embedded source message."); + } + + var extracted = await memory.ExtractAndPersistAsync( + new ExtractionRequest + { + Messages = [message], + SessionId = sessionId, + UserId = ownerId, + TypesToExtract = ExtractionTypes.All, + }, + cancellationToken).ConfigureAwait(false); + + return new ColdBuildUnitResult( + extracted.Status, + extracted.Entities.Count, + extracted.Facts.Count, + extracted.Preferences.Count, + extracted.Relationships.Count, + extracted.SourceMessageIds.Count, + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds); + } + + private static async Task VerifyConcurrentColdBuildAsync( + ScenarioVerificationContext context, + int workers) + { + for (var unit = 0; unit < ColdBuildUnitCount; unit++) + { + var sessionId = ColdBuildSessionId(workers, context.Phase, context.Iteration, unit); + var ownerId = ColdBuildOwnerId(workers, context.Phase, context.Iteration, unit); + var messageId = $"{sessionId}-msg-00"; + const string cypher = """ + CALL { + MATCH (m:Message {session_id: $sessionId}) + RETURN count(m) AS messages, + count(CASE WHEN size(m.embedding) = $dimensions THEN 1 END) AS messageVectors + } + CALL { MATCH (e:Entity {owner_id: $ownerId}) RETURN count(e) AS entities } + CALL { MATCH (f:Fact {owner_id: $ownerId}) RETURN count(f) AS facts } + CALL { MATCH (p:Preference {owner_id: $ownerId}) RETURN count(p) AS preferences } + CALL { + MATCH (:Entity {owner_id: $ownerId})-[r:RELATED_TO]->(:Entity {owner_id: $ownerId}) + WHERE r.owner_id = $ownerId AND r.relation_type = 'WORKS_AT' + RETURN count(r) AS relationships, + count(CASE WHEN $messageId IN r.source_message_ids THEN 1 END) + AS relationshipSources + } + CALL { + MATCH (memory)-[:EXTRACTED_FROM]->(:Message {id: $messageId}) + WHERE memory.owner_id = $ownerId + RETURN count(*) AS provenance + } + CALL { + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND (source.owner_id <> $ownerId OR target.owner_id <> $ownerId) + RETURN count(r) AS crossOwnerEdges + } + RETURN messages, messageVectors, entities, facts, preferences, relationships, + relationshipSources, provenance, crossOwnerEdges + """; + + await using var session = context.Profile.Driver.AsyncSession(); + var cursor = await session.RunAsync( + cypher, + new + { + sessionId, + ownerId, + messageId, + dimensions = context.Profile.Dimensions, + }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + + var graphExact = + record["messages"].As() == 1 && + record["messageVectors"].As() == 1 && + record["entities"].As() == ColdBuildEntitiesPerUnit && + record["facts"].As() == ColdBuildFactsPerUnit && + record["preferences"].As() == ColdBuildPreferencesPerUnit && + record["relationships"].As() == ColdBuildRelationshipsPerUnit && + record["relationshipSources"].As() == ColdBuildRelationshipsPerUnit && + record["provenance"].As() == + ColdBuildEntitiesPerUnit + ColdBuildFactsPerUnit + ColdBuildPreferencesPerUnit && + record["crossOwnerEdges"].As() == 0; + if (!graphExact) + { + throw new InvalidOperationException( + $"PERF-W-10-C{workers:D2} graph verification failed for unit {unit}; exact " + + "message/vector, 2/2/1/1 learned shape, provenance, and owner isolation are required."); + } + + const string cleanupCypher = """ + MATCH (n) + WHERE n.owner_id = $ownerId + OR n.session_id = $sessionId + OR n.id = $conversationId + DETACH DELETE n + WITH count(n) AS deleted + OPTIONAL MATCH (remaining) + WHERE remaining.owner_id = $ownerId + OR remaining.session_id = $sessionId + OR remaining.id = $conversationId + RETURN deleted, count(remaining) AS remaining + """; + var cleanup = await session.RunAsync( + cleanupCypher, + new + { + sessionId, + ownerId, + conversationId = $"{sessionId}-conversation", + }).ConfigureAwait(false); + var cleanupRecord = await cleanup.SingleAsync().ConfigureAwait(false); + if (cleanupRecord["deleted"].As() == 0 || cleanupRecord["remaining"].As() != 0) + throw new InvalidOperationException($"PERF-W-10-C{workers:D2} did not clean unit {unit}."); + } + } + + private static string ColdBuildSessionId( + int workers, + string phase, + int iteration, + int unit) => + $"perf-w10-c{workers:D2}-{phase}-{iteration}-session-{unit:D2}"; + + private static string ColdBuildOwnerId( + int workers, + string phase, + int iteration, + int unit) => + $"perf-w10-c{workers:D2}-{phase}-{iteration}-owner-{unit:D2}"; + + private sealed record ColdBuildUnitResult( + IngestionStatus Status, + int EntityCount, + int FactCount, + int PreferenceCount, + int RelationshipCount, + int SourceMessageCount, + double DurationMs); +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.FrozenPersistence.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.FrozenPersistence.cs new file mode 100644 index 00000000..0ebf8d05 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.FrozenPersistence.cs @@ -0,0 +1,193 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + private const int FrozenEntityCount = 2; + private const int FrozenFactCount = 2; + private const int FrozenPreferenceCount = 1; + private const int FrozenRelationshipCount = 1; + private const int FrozenLearnedEmbeddingCount = + FrozenEntityCount + FrozenFactCount + FrozenPreferenceCount; + private const int FrozenEmbeddingRequestCount = FrozenResolutionEmbeddingCount + 1; + private const int FrozenResolutionEmbeddingCount = FrozenEntityCount; + private const int FrozenEmbeddingCount = FrozenLearnedEmbeddingCount + FrozenResolutionEmbeddingCount; + + private static async Task PrepareFrozenPersistenceAsync(ScenarioSetupContext ctx) + { + var message = FrozenPersistenceMessage(ctx.Phase, ctx.Iteration); + const string cypher = """ + MERGE (m:Message {id: $id}) + SET m.session_id = $sessionId, + m.conversation_id = $conversationId, + m.role = $role, + m.content = $content, + m.timestamp = datetime($timestamp), + m.tool_call_ids = [], + m.metadata = '{}' + RETURN count(m) AS seeded + """; + + await using var session = ctx.Profile.Driver.AsyncSession(); + var cursor = await session.RunAsync( + cypher, + new + { + id = message.MessageId, + sessionId = message.SessionId, + conversationId = message.ConversationId, + role = message.Role, + content = message.Content, + timestamp = message.TimestampUtc.ToString("O"), + }).ConfigureAwait(false); + var seeded = (await cursor.SingleAsync().ConfigureAwait(false))["seeded"].As(); + if (seeded != 1) + throw new InvalidOperationException($"PERF-W-08 setup seeded {seeded} messages, expected 1."); + } + + private static async Task PersistFrozenExtractionAsync(ScenarioContext ctx) + { + var sessionId = FrozenPersistenceSessionId(ctx.Phase, ctx.Iteration); + var ownerId = FrozenPersistenceOwnerId(ctx.Phase, ctx.Iteration); + var message = FrozenPersistenceMessage(ctx.Phase, ctx.Iteration); + var memory = ctx.Profile.Services.GetRequiredService(); + + var result = await memory.ExtractAndPersistAsync( + new ExtractionRequest + { + Messages = [message], + SessionId = sessionId, + UserId = ownerId, + TypesToExtract = ExtractionTypes.All, + }, + ctx.CancellationToken).ConfigureAwait(false); + + var resultExact = + result.Status == IngestionStatus.Succeeded && + result.Entities.Count == FrozenEntityCount && + result.Facts.Count == FrozenFactCount && + result.Preferences.Count == FrozenPreferenceCount && + result.Relationships.Count == FrozenRelationshipCount && + result.SourceMessageIds.SequenceEqual([message.MessageId], StringComparer.Ordinal); + var persistedExact = + ctx.Turn.Counter("persist.entities") == FrozenEntityCount && + ctx.Turn.Counter("persist.facts") == FrozenFactCount && + ctx.Turn.Counter("persist.preferences") == FrozenPreferenceCount && + ctx.Turn.Counter("persist.relationships") == FrozenRelationshipCount; + var spansPresent = + ctx.Turn.SpanCounts.GetValueOrDefault("memory.extract.resolution") == 1 && + ctx.Turn.SpanCounts.GetValueOrDefault("memory.persist.total") == 1 && + ctx.Turn.SpanCounts.GetValueOrDefault("provider.embedding") == FrozenEmbeddingRequestCount; + var excludedWork = + ctx.Turn.Counter("llm.calls") + + ctx.Turn.Counter("store.messages") + + ctx.Turn.Counter("items.retrieved"); + + if (!resultExact || + !persistedExact || + ctx.Turn.Counter("embed.requests") != FrozenEmbeddingRequestCount || + ctx.Turn.Counter("embed.items") != FrozenEmbeddingCount || + !spansPresent || + excludedWork != 0) + { + throw new InvalidOperationException( + $"PERF-W-08 frozen persistence contract failed (result_exact={resultExact}, " + + $"persisted_exact={persistedExact}, embed.requests/items=" + + $"{ctx.Turn.Counter("embed.requests")}/{ctx.Turn.Counter("embed.items")}, expected " + + $"{FrozenEmbeddingRequestCount}/{FrozenEmbeddingCount}; resolution/persistence/provider spans=" + + $"{ctx.Turn.SpanCounts.GetValueOrDefault("memory.extract.resolution")}/" + + $"{ctx.Turn.SpanCounts.GetValueOrDefault("memory.persist.total")}/" + + $"{ctx.Turn.SpanCounts.GetValueOrDefault("provider.embedding")}, expected " + + $"1/1/{FrozenEmbeddingRequestCount}; excluded_work={excludedWork}/0)."); + } + } + + private static async Task VerifyFrozenPersistenceAsync(ScenarioVerificationContext ctx) + { + var sessionId = FrozenPersistenceSessionId(ctx.Phase, ctx.Iteration); + var ownerId = FrozenPersistenceOwnerId(ctx.Phase, ctx.Iteration); + var messageId = FrozenPersistenceMessage(ctx.Phase, ctx.Iteration).MessageId; + const string cypher = """ + CALL { MATCH (m:Message {session_id: $sessionId}) RETURN count(m) AS messages } + CALL { MATCH (e:Entity {owner_id: $ownerId}) RETURN count(e) AS entities } + CALL { MATCH (f:Fact {owner_id: $ownerId}) RETURN count(f) AS facts } + CALL { MATCH (p:Preference {owner_id: $ownerId}) RETURN count(p) AS preferences } + CALL { + MATCH (:Entity {owner_id: $ownerId})-[r:RELATED_TO]->(:Entity {owner_id: $ownerId}) + WHERE r.owner_id = $ownerId AND r.relation_type = 'LAB_P0_WORKS_AT' + RETURN count(r) AS relationships, + count(CASE WHEN $messageId IN r.source_message_ids THEN 1 END) + AS relationshipSources + } + CALL { + MATCH (memory)-[:EXTRACTED_FROM]->(:Message {id: $messageId}) + WHERE memory.owner_id = $ownerId + RETURN count(*) AS provenance + } + CALL { + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND (source.owner_id <> $ownerId OR target.owner_id <> $ownerId) + RETURN count(r) AS crossOwnerEdges + } + RETURN messages, entities, facts, preferences, relationships, + relationshipSources, provenance, crossOwnerEdges + """; + + await using var session = ctx.Profile.Driver.AsyncSession(); + var cursor = await session.RunAsync( + cypher, + new { sessionId, ownerId, messageId }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + var messages = record["messages"].As(); + var entities = record["entities"].As(); + var facts = record["facts"].As(); + var preferences = record["preferences"].As(); + var relationships = record["relationships"].As(); + var relationshipSources = record["relationshipSources"].As(); + var provenance = record["provenance"].As(); + var crossOwnerEdges = record["crossOwnerEdges"].As(); + + if (messages != 1 || + entities != FrozenEntityCount || + facts != FrozenFactCount || + preferences != FrozenPreferenceCount || + relationships != FrozenRelationshipCount || + relationshipSources != FrozenRelationshipCount || + provenance != FrozenLearnedEmbeddingCount || + crossOwnerEdges != 0) + { + throw new InvalidOperationException( + $"PERF-W-08 graph read-back failed (messages={messages}/1, entities/facts/preferences/" + + $"relationships={entities}/{facts}/{preferences}/{relationships}, expected " + + $"{FrozenEntityCount}/{FrozenFactCount}/{FrozenPreferenceCount}/" + + $"{FrozenRelationshipCount}; provenance={provenance}/{FrozenLearnedEmbeddingCount}, " + + $"relationship_sources={relationshipSources}/{FrozenRelationshipCount}, " + + $"cross_owner_edges={crossOwnerEdges}/0)."); + } + } + + private static Message FrozenPersistenceMessage(string phase, int iteration) + { + var sessionId = FrozenPersistenceSessionId(phase, iteration); + return new Message + { + MessageId = $"{sessionId}-msg-00", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = FrozenExtractionOverrides.SourceMarker, + TimestampUtc = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + }; + } + + private static string FrozenPersistenceSessionId(string phase, int iteration) => + $"perf-w08-{phase}-{iteration}"; + + private static string FrozenPersistenceOwnerId(string phase, int iteration) => + $"{FrozenPersistenceSessionId(phase, iteration)}-owner"; +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.IntegratedColdBuild.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.IntegratedColdBuild.cs new file mode 100644 index 00000000..2b562808 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.IntegratedColdBuild.cs @@ -0,0 +1,445 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + private const int IntegratedOwnerCount = 10; + private const int IntegratedSessionsPerOwner = 4; + private const int IntegratedMessagesPerSession = 12; + private const int IntegratedSourceSessionCount = + IntegratedOwnerCount * IntegratedSessionsPerOwner; + private const int IntegratedMessageCount = + IntegratedSourceSessionCount * IntegratedMessagesPerSession; + private const int IntegratedBatchTokenBudget = 100_000; + private const int IntegratedEmbeddingRequestCount = 130; + private const int IntegratedEmbeddingItemCount = 720; + private const int IntegratedLegacyQueryCount = 930; + private const int IntegratedSnapshotQueryCount = 870; + private const int IntegratedCoalescedLegacyQueryCount = 290; + private const int IntegratedCoalescedSnapshotQueryCount = 230; + private const int IntegratedLegacyReadTransactionCount = 120; + private const int IntegratedSnapshotReadTransactionCount = 60; + private const int IntegratedCoalescedLegacyReadTransactionCount = 80; + private const int IntegratedCoalescedSnapshotReadTransactionCount = 20; + private const int IntegratedLegacyWriteTransactionCount = 250; + private const int IntegratedCoalescedWriteTransactionCount = 50; + + private static async Task RunIntegratedColdBuildAsync(ScenarioContext context, int workers) + { + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var processorTimeBefore = process.TotalProcessorTime; + var totalStartedAt = Stopwatch.GetTimestamp(); + + var rawWork = Enumerable.Range(0, IntegratedOwnerCount) + .Select(owner => (Func>)(token => + StoreIntegratedOwnerAsync(context, workers, owner, token))) + .ToArray(); + var rawStartedAt = Stopwatch.GetTimestamp(); + var raw = await BoundedWorkScheduler + .RunAsync(rawWork, workers, context.CancellationToken) + .ConfigureAwait(false); + var rawWaveMs = Stopwatch.GetElapsedTime(rawStartedAt).TotalMilliseconds; + context.Turn.Add("store.messages", raw.Results.Sum(result => result.Messages.Count)); + + var extractionWork = raw.Results + .Select(input => (Func>)(token => + ExtractIntegratedOwnerAsync(context, input, token))) + .ToArray(); + var extractionStartedAt = Stopwatch.GetTimestamp(); + var extracted = await BoundedWorkScheduler + .RunAsync(extractionWork, workers, context.CancellationToken) + .ConfigureAwait(false); + var extractionWaveMs = Stopwatch.GetElapsedTime(extractionStartedAt).TotalMilliseconds; + var totalWaveMs = Stopwatch.GetElapsedTime(totalStartedAt).TotalMilliseconds; + + process.Refresh(); + var processorTimeMs = (process.TotalProcessorTime - processorTimeBefore).TotalMilliseconds; + + context.Turn.Add("integrated.owners", IntegratedOwnerCount); + context.Turn.Add("integrated.source_sessions", IntegratedSourceSessionCount); + context.Turn.Add("integrated.messages", IntegratedMessageCount); + context.Turn.Add("integrated.workers", workers); + context.Turn.Add("integrated.raw.max_concurrency", raw.MaxConcurrency); + context.Turn.Add("integrated.extract.max_concurrency", extracted.MaxConcurrency); + context.Turn.Add( + "integrated.plan_batches", + extracted.Results.Sum(result => result.Plan.BatchCount)); + context.Turn.Add( + "integrated.plan_sessions", + extracted.Results.Sum(result => result.Plan.SourceSessionCount)); + context.Turn.Add( + "integrated.plan_tokens_est", + extracted.Results.Sum(result => result.Plan.TotalEstimatedInputTokens)); + context.Turn.RecordSample("integrated.raw_wave_ms", rawWaveMs); + context.Turn.RecordSample("integrated.extract_wave_ms", extractionWaveMs); + context.Turn.RecordSample("integrated.total_wave_ms", totalWaveMs); + context.Turn.RecordSample("integrated.process_cpu_ms", processorTimeMs); + foreach (var owner in raw.Results) + context.Turn.RecordSample("integrated.owner_raw_ms", owner.DurationMs); + foreach (var owner in extracted.Results) + context.Turn.RecordSample("integrated.owner_extract_ms", owner.DurationMs); + + var expectedSessions = raw.Results + .SelectMany(input => input.ChronologicalRequests) + .Select(request => request.SessionId) + .ToArray(); + var returnedSessions = extracted.Results + .SelectMany(result => result.Results) + .Select(result => result.Metadata.TryGetValue("sessionId", out var value) ? value as string : null) + .ToArray(); + var outputsExact = extracted.Results + .SelectMany(result => result.Results) + .All(result => + result.Status == IngestionStatus.Succeeded && + result.Entities.Count == 2 && + result.Facts.Count == 1 && + result.Preferences.Count == 1 && + result.Relationships.Count == 1 && + result.SourceMessageIds.Count == IntegratedMessagesPerSession); + var orderExact = returnedSessions.SequenceEqual(expectedSessions, StringComparer.Ordinal); + var calls = context.Turn.Counter("llm.unified_batch.calls"); + var retries = Math.Max(0, calls - IntegratedOwnerCount); + context.Turn.Add("llm.unified_batch.retries", retries); + + var expectedQueries = context.Profile.UseCoalescedPersistenceTransactions + ? context.Profile.UseBatchEntityResolutionSnapshots + ? IntegratedCoalescedSnapshotQueryCount + : IntegratedCoalescedLegacyQueryCount + : context.Profile.UseBatchEntityResolutionSnapshots + ? IntegratedSnapshotQueryCount + : IntegratedLegacyQueryCount; + var expectedReads = context.Profile.UseCoalescedPersistenceTransactions + ? context.Profile.UseBatchEntityResolutionSnapshots + ? IntegratedCoalescedSnapshotReadTransactionCount + : IntegratedCoalescedLegacyReadTransactionCount + : context.Profile.UseBatchEntityResolutionSnapshots + ? IntegratedSnapshotReadTransactionCount + : IntegratedLegacyReadTransactionCount; + var expectedWrites = context.Profile.UseCoalescedPersistenceTransactions + ? IntegratedCoalescedWriteTransactionCount + : IntegratedLegacyWriteTransactionCount; + var countersExact = + context.Turn.Counter("llm.calls") == IntegratedOwnerCount && + calls == IntegratedOwnerCount && + context.Turn.Counter("llm.unified.calls") == 0 && + retries == 0 && + context.Turn.Counter("store.messages") == IntegratedMessageCount && + context.Turn.Counter("embed.requests") == IntegratedEmbeddingRequestCount && + context.Turn.Counter("embed.items") == IntegratedEmbeddingItemCount && + context.Turn.SpanCounts.GetValueOrDefault("provider.embedding") == + IntegratedEmbeddingRequestCount && + context.Turn.SpanCounts.GetValueOrDefault("provider.llm.unified_batch") == + IntegratedOwnerCount && + context.Turn.SpanCounts.GetValueOrDefault("memory.extract.unified_batch") == + IntegratedOwnerCount && + context.Turn.Counter("neo4j.queries") == expectedQueries && + context.Turn.Counter("neo4j.tx.read") == expectedReads && + context.Turn.Counter("neo4j.tx.write") == expectedWrites && + context.Turn.Counter("persist.entities") == IntegratedSourceSessionCount * 2 && + context.Turn.Counter("persist.facts") == IntegratedSourceSessionCount && + context.Turn.Counter("persist.preferences") == IntegratedSourceSessionCount && + context.Turn.Counter("persist.relationships") == IntegratedSourceSessionCount && + context.Turn.SpanCounts.GetValueOrDefault("memory.persist.total") == + IntegratedSourceSessionCount; + + if (!outputsExact || + !orderExact || + !countersExact || + raw.MaxConcurrency != workers || + extracted.MaxConcurrency != workers || + extracted.Results.Any(result => + result.Plan.BatchCount != 1 || + result.Plan.SourceSessionCount != IntegratedSessionsPerOwner) || + (context.Profile.MaxConnectionPoolSize != 16 && + !context.Profile.PoolSizeExplicitlyConfigured)) + { + throw new InvalidOperationException( + $"PERF-W-12-X{workers:D2} integrated contract failed (outputs/order=" + + $"{outputsExact}/{orderExact}; raw/extract concurrency=" + + $"{raw.MaxConcurrency}/{extracted.MaxConcurrency}, expected {workers}/{workers}; " + + $"plan batches/sessions={context.Turn.Counter("integrated.plan_batches")}/" + + $"{context.Turn.Counter("integrated.plan_sessions")}, expected 10/40; " + + $"llm/batch/retries={context.Turn.Counter("llm.calls")}/{calls}/{retries}, " + + "expected 10/10/0; " + + $"stored={context.Turn.Counter("store.messages")}/{IntegratedMessageCount}; " + + $"embed requests/items={context.Turn.Counter("embed.requests")}/" + + $"{context.Turn.Counter("embed.items")}, expected " + + $"{IntegratedEmbeddingRequestCount}/{IntegratedEmbeddingItemCount}; " + + $"queries/read/write={context.Turn.Counter("neo4j.queries")}/" + + $"{context.Turn.Counter("neo4j.tx.read")}/" + + $"{context.Turn.Counter("neo4j.tx.write")}, expected " + + $"{expectedQueries}/{expectedReads}/{expectedWrites})."); + } + } + + private static async Task StoreIntegratedOwnerAsync( + ScenarioContext context, + int workers, + int owner, + CancellationToken cancellationToken) + { + var messages = Enumerable.Range(0, IntegratedSessionsPerOwner) + .SelectMany(session => Enumerable.Range(0, IntegratedMessagesPerSession) + .Select(message => IntegratedMessage( + workers, + context.Phase, + context.Iteration, + owner, + session, + message))) + .ToArray(); + + await using var scope = context.Profile.Services.CreateAsyncScope(); + var memory = scope.ServiceProvider.GetRequiredService(); + var startedAt = Stopwatch.GetTimestamp(); + var stored = await memory.AddMessagesAsync(messages, cancellationToken).ConfigureAwait(false); + var durationMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + + if (stored.Count != messages.Length || + stored.Where((message, index) => + message.MessageId != messages[index].MessageId || + message.Embedding is not { Length: > 0 } embedding || + embedding.Length != context.Profile.Dimensions).Any()) + { + throw new InvalidOperationException( + $"Integrated owner {owner} did not store all exact embedded source messages."); + } + + var chronologicalRequests = messages + .GroupBy(message => message.SessionId, StringComparer.Ordinal) + .Select(group => new ExtractionRequest + { + Messages = group.OrderBy(message => message.TimestampUtc).ToArray(), + SessionId = group.Key, + UserId = IntegratedOwnerId(workers, context.Phase, context.Iteration, owner), + TypesToExtract = ExtractionTypes.All, + }) + .OrderBy(request => request.Messages[0].TimestampUtc) + .ToArray(); + + return new IntegratedOwnerInput( + owner, + messages, + chronologicalRequests, + chronologicalRequests.AsEnumerable().Reverse().ToArray(), + durationMs); + } + + private static async Task ExtractIntegratedOwnerAsync( + ScenarioContext context, + IntegratedOwnerInput input, + CancellationToken cancellationToken) + { + await using var scope = context.Profile.Services.CreateAsyncScope(); + var planner = scope.ServiceProvider + .GetServices() + .Single(extractor => extractor.IsEnabled); + var plan = planner.Plan( + input.ChronologicalRequests, + IntegratedSessionsPerOwner, + IntegratedBatchTokenBudget); + var pipeline = scope.ServiceProvider.GetRequiredService(); + var startedAt = Stopwatch.GetTimestamp(); + var results = await pipeline.ExtractBatchAsync( + input.ExecutionRequests, + IntegratedSessionsPerOwner, + IntegratedBatchTokenBudget, + cancellationToken).ConfigureAwait(false); + var durationMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + + var plannedSessions = plan.Batches.SelectMany(batch => batch.SourceSessionIds); + var returnedSessions = results.Select(result => + result.Metadata.TryGetValue("sessionId", out var value) ? value as string : null); + if (!returnedSessions.SequenceEqual(plannedSessions, StringComparer.Ordinal)) + throw new InvalidOperationException( + $"Integrated owner {input.Owner} execution did not match its preflight partition."); + + return new IntegratedOwnerResult(input.Owner, plan, results, durationMs); + } + + private static async Task VerifyIntegratedColdBuildAsync( + ScenarioVerificationContext context, + int workers) + { + for (var owner = 0; owner < IntegratedOwnerCount; owner++) + { + var ownerId = IntegratedOwnerId(workers, context.Phase, context.Iteration, owner); + for (var session = 0; session < IntegratedSessionsPerOwner; session++) + { + var unit = owner * IntegratedSessionsPerOwner + session; + var sessionId = IntegratedSessionId( + workers, + context.Phase, + context.Iteration, + unit); + var messageIds = Enumerable.Range(0, IntegratedMessagesPerSession) + .Select(message => $"{sessionId}-message-{message:D2}") + .ToArray(); + const string verifyCypher = """ + CALL { + MATCH (m:Message {session_id: $sessionId}) + RETURN count(m) AS messages, + count(CASE WHEN size(m.embedding) = $dimensions THEN 1 END) + AS messageVectors + } + CALL { + MATCH (e:Entity {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: $sessionId}) + RETURN count(DISTINCT e) AS entities, count(*) AS entityProvenance + } + CALL { + MATCH (f:Fact {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: $sessionId}) + RETURN count(DISTINCT f) AS facts, count(*) AS factProvenance + } + CALL { + MATCH (p:Preference {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: $sessionId}) + RETURN count(DISTINCT p) AS preferences, count(*) AS preferenceProvenance + } + CALL { + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND source.owner_id = $ownerId + AND target.owner_id = $ownerId + AND r.relation_type = 'WORKS_AT' + AND all(messageId IN $messageIds + WHERE messageId IN coalesce(r.source_message_ids, [])) + AND EXISTS { + MATCH (source)-[:EXTRACTED_FROM]-> + (:Message {session_id: $sessionId}) + } + RETURN count(DISTINCT r) AS relationships + } + CALL { + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND (source.owner_id <> $ownerId OR target.owner_id <> $ownerId) + RETURN count(r) AS crossOwnerEdges + } + RETURN messages, messageVectors, entities, facts, preferences, relationships, + entityProvenance + factProvenance + preferenceProvenance AS provenance, + crossOwnerEdges + """; + + await using var sessionHandle = context.Profile.Driver.AsyncSession(); + var cursor = await sessionHandle.RunAsync( + verifyCypher, + new + { + sessionId, + ownerId, + messageIds, + dimensions = context.Profile.Dimensions, + }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + var exact = + record["messages"].As() == IntegratedMessagesPerSession && + record["messageVectors"].As() == IntegratedMessagesPerSession && + record["entities"].As() == 2 && + record["facts"].As() == 1 && + record["preferences"].As() == 1 && + record["relationships"].As() == 1 && + record["provenance"].As() == 4L * IntegratedMessagesPerSession && + record["crossOwnerEdges"].As() == 0; + if (!exact) + throw new InvalidOperationException( + $"PERF-W-12-X{workers:D2} graph/provenance/isolation failed for source " + + $"session {unit}."); + } + + var sessionIds = Enumerable.Range(0, IntegratedSessionsPerOwner) + .Select(session => IntegratedSessionId( + workers, + context.Phase, + context.Iteration, + owner * IntegratedSessionsPerOwner + session)) + .ToArray(); + var conversationIds = sessionIds + .Select(sessionId => $"{sessionId}-conversation") + .ToArray(); + const string cleanupCypher = """ + MATCH (n) + WHERE n.owner_id = $ownerId + OR n.session_id IN $sessionIds + OR n.id IN $conversationIds + DETACH DELETE n + WITH count(n) AS deleted + OPTIONAL MATCH (remaining) + WHERE remaining.owner_id = $ownerId + OR remaining.session_id IN $sessionIds + OR remaining.id IN $conversationIds + RETURN deleted, count(remaining) AS remaining + """; + await using var cleanupSession = context.Profile.Driver.AsyncSession(); + var cleanup = await cleanupSession.RunAsync( + cleanupCypher, + new { ownerId, sessionIds, conversationIds }).ConfigureAwait(false); + var cleanupRecord = await cleanup.SingleAsync().ConfigureAwait(false); + if (cleanupRecord["deleted"].As() == 0 || + cleanupRecord["remaining"].As() != 0) + { + throw new InvalidOperationException( + $"PERF-W-12-X{workers:D2} did not clean owner lane {owner}."); + } + } + } + + private static Message IntegratedMessage( + int workers, + string phase, + int iteration, + int owner, + int session, + int message) + { + var unit = owner * IntegratedSessionsPerOwner + session; + var sessionId = IntegratedSessionId(workers, phase, iteration, unit); + return new Message + { + MessageId = $"{sessionId}-message-{message:D2}", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = + $"LAB-X1 source {unit:D2}: Person {unit:D2} works at Company {unit:D2} and " + + $"prefers tea. Supporting turn {message:D2}.", + TimestampUtc = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero) + .AddMinutes(unit) + .AddSeconds(message), + }; + } + + private static string IntegratedSessionId( + int workers, + string phase, + int iteration, + int unit) => + $"perf-w12-x{workers:D2}-{phase}-{iteration}-session-{unit:D2}"; + + private static string IntegratedOwnerId( + int workers, + string phase, + int iteration, + int owner) => + $"perf-w12-x{workers:D2}-{phase}-{iteration}-owner-{owner:D2}"; + + private sealed record IntegratedOwnerInput( + int Owner, + IReadOnlyList Messages, + IReadOnlyList ChronologicalRequests, + IReadOnlyList ExecutionRequests, + double DurationMs); + + private sealed record IntegratedOwnerResult( + int Owner, + MultiSessionExtractionPlan Plan, + IReadOnlyList Results, + double DurationMs); +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.MultiSessionBatch.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.MultiSessionBatch.cs new file mode 100644 index 00000000..5aadef3b --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.MultiSessionBatch.cs @@ -0,0 +1,223 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + private const int MultiSessionBatchUnitCount = 8; + private const int MultiSessionBatchTokenBudget = 100_000; + private const int MultiSessionEmbeddingRequestCount = 25; + private const int MultiSessionEmbeddingItemCount = 56; + private const int MultiSessionQueryCount = 185; + private const int MultiSessionReadTransactionCount = 24; + private const int MultiSessionWriteTransactionCount = 49; + private const int MultiSessionPersistedItemCount = 8; + + private static async Task RunMultiSessionBatchAsync(ScenarioContext context, int batchSize) + { + var messages = Enumerable.Range(0, MultiSessionBatchUnitCount) + .Select(unit => MultiSessionMessage(batchSize, context.Phase, context.Iteration, unit)) + .ToArray(); + + await using var scope = context.Profile.Services.CreateAsyncScope(); + var memory = scope.ServiceProvider.GetRequiredService(); + var stored = await memory.AddMessagesAsync(messages, context.CancellationToken).ConfigureAwait(false); + context.Turn.Add("store.messages", stored.Count); + if (stored.Count != MultiSessionBatchUnitCount || + stored.Where((message, index) => + message.MessageId != messages[index].MessageId || + message.Embedding is not { Length: > 0 } embedding || + embedding.Length != context.Profile.Dimensions).Any()) + { + throw new InvalidOperationException( + $"PERF-W-11-B{batchSize:D2} did not store all eight exact embedded source messages."); + } + + // Deliberately reverse the requests. The product batch pipeline must restore source chronology + // before model batching and before the sequential resolution/persistence commits. + var requests = messages.AsEnumerable().Reverse().Select((message, reverseIndex) => + { + var unit = MultiSessionBatchUnitCount - reverseIndex - 1; + return new ExtractionRequest + { + Messages = [message], + SessionId = message.SessionId, + UserId = MultiSessionOwnerId(batchSize, context.Phase, context.Iteration, unit), + TypesToExtract = ExtractionTypes.All, + }; + }).ToArray(); + + var pipeline = scope.ServiceProvider.GetRequiredService(); + var results = await pipeline.ExtractBatchAsync( + requests, + batchSize, + MultiSessionBatchTokenBudget, + context.CancellationToken).ConfigureAwait(false); + + var expectedCalls = MultiSessionBatchUnitCount / batchSize; + var chronologicalSessions = messages.Select(message => message.SessionId).ToArray(); + var returnedSessions = results + .Select(result => result.Metadata.TryGetValue("sessionId", out var value) ? value as string : null) + .ToArray(); + var outputsExact = results.Count == MultiSessionBatchUnitCount && results.All(result => + result.Status == IngestionStatus.Succeeded && + result.Entities.Count == 2 && + result.Facts.Count == 1 && + result.Preferences.Count == 1 && + result.Relationships.Count == 1 && + result.SourceMessageIds.Count == 1); + var orderExact = returnedSessions.SequenceEqual(chronologicalSessions, StringComparer.Ordinal); + var expectedReads = context.Profile.UseCoalescedPersistenceTransactions + ? 16 : MultiSessionReadTransactionCount; + var expectedWrites = context.Profile.UseCoalescedPersistenceTransactions + ? 9 : MultiSessionWriteTransactionCount; + var expectedQueries = context.Profile.UseCoalescedPersistenceTransactions + ? 57 : MultiSessionQueryCount; + var callsExact = + context.Turn.Counter("llm.calls") == expectedCalls && + context.Turn.Counter("llm.unified_batch.calls") == expectedCalls && + context.Turn.Counter("llm.unified.calls") == 0; + var fixedWorkExact = + context.Turn.Counter("embed.requests") == MultiSessionEmbeddingRequestCount && + context.Turn.Counter("embed.items") == MultiSessionEmbeddingItemCount && + context.Turn.SpanCounts.GetValueOrDefault("provider.embedding") == MultiSessionEmbeddingRequestCount && + context.Turn.Counter("neo4j.queries") == expectedQueries && + context.Turn.Counter("neo4j.tx.read") == expectedReads && + context.Turn.Counter("neo4j.tx.write") == expectedWrites && + context.Turn.Counter("persist.entities") == MultiSessionBatchUnitCount * 2 && + context.Turn.Counter("persist.facts") == MultiSessionPersistedItemCount && + context.Turn.Counter("persist.preferences") == MultiSessionPersistedItemCount && + context.Turn.Counter("persist.relationships") == MultiSessionPersistedItemCount && + context.Turn.Counter("store.messages") == MultiSessionBatchUnitCount && + context.Turn.SpanCounts.GetValueOrDefault("memory.persist.total") == MultiSessionBatchUnitCount; + + context.Turn.Add("batch.source_sessions", MultiSessionBatchUnitCount); + context.Turn.Add("batch.max_sessions", batchSize); + context.Turn.Add("batch.expected_calls", expectedCalls); + context.Turn.Add("batch.output_exact", outputsExact ? 1 : 0); + context.Turn.Add("batch.commit_order_exact", orderExact ? 1 : 0); + + if (!outputsExact || !orderExact || !callsExact || !fixedWorkExact) + { + throw new InvalidOperationException( + $"PERF-W-11-B{batchSize:D2} batch contract failed (outputs/order=" + + $"{outputsExact}/{orderExact}; llm/batch/single=" + + $"{context.Turn.Counter("llm.calls")}/" + + $"{context.Turn.Counter("llm.unified_batch.calls")}/" + + $"{context.Turn.Counter("llm.unified.calls")}, expected {expectedCalls}/{expectedCalls}/0; " + + $"embed.requests/items/provider={context.Turn.Counter("embed.requests")}/" + + $"{context.Turn.Counter("embed.items")}/{context.Turn.SpanCounts.GetValueOrDefault("provider.embedding")}, expected " + + $"{MultiSessionEmbeddingRequestCount}/{MultiSessionEmbeddingItemCount}/{MultiSessionEmbeddingRequestCount}; " + + $"queries/read/write={context.Turn.Counter("neo4j.queries")}/" + + $"{context.Turn.Counter("neo4j.tx.read")}/{context.Turn.Counter("neo4j.tx.write")}, expected " + + $"{expectedQueries}/{expectedReads}/{expectedWrites}; " + + $"fixed_work_exact={fixedWorkExact})."); + } + } + + private static async Task VerifyMultiSessionBatchAsync( + ScenarioVerificationContext context, + int batchSize) + { + for (var unit = 0; unit < MultiSessionBatchUnitCount; unit++) + { + var sessionId = MultiSessionSessionId(batchSize, context.Phase, context.Iteration, unit); + var ownerId = MultiSessionOwnerId(batchSize, context.Phase, context.Iteration, unit); + var messageId = $"{sessionId}-message"; + const string verifyCypher = """ + CALL { + MATCH (m:Message {session_id: $sessionId}) + RETURN count(m) AS messages, + count(CASE WHEN size(m.embedding) = $dimensions THEN 1 END) AS messageVectors + } + CALL { MATCH (e:Entity {owner_id: $ownerId}) RETURN count(e) AS entities } + CALL { MATCH (f:Fact {owner_id: $ownerId}) RETURN count(f) AS facts } + CALL { MATCH (p:Preference {owner_id: $ownerId}) RETURN count(p) AS preferences } + CALL { + MATCH (:Entity {owner_id: $ownerId})-[r:RELATED_TO]->(:Entity {owner_id: $ownerId}) + WHERE r.owner_id = $ownerId AND r.relation_type = 'WORKS_AT' + RETURN count(r) AS relationships, + count(CASE WHEN $messageId IN r.source_message_ids THEN 1 END) AS relationshipSources + } + CALL { + MATCH (memory)-[:EXTRACTED_FROM]->(:Message {id: $messageId}) + WHERE memory.owner_id = $ownerId + RETURN count(*) AS provenance + } + CALL { + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND (source.owner_id <> $ownerId OR target.owner_id <> $ownerId) + RETURN count(r) AS crossOwnerEdges + } + RETURN messages, messageVectors, entities, facts, preferences, relationships, + relationshipSources, provenance, crossOwnerEdges + """; + + await using var session = context.Profile.Driver.AsyncSession(); + var cursor = await session.RunAsync( + verifyCypher, + new { sessionId, ownerId, messageId, dimensions = context.Profile.Dimensions }) + .ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + var exact = + record["messages"].As() == 1 && + record["messageVectors"].As() == 1 && + record["entities"].As() == 2 && + record["facts"].As() == 1 && + record["preferences"].As() == 1 && + record["relationships"].As() == 1 && + record["relationshipSources"].As() == 1 && + record["provenance"].As() == 4 && + record["crossOwnerEdges"].As() == 0; + if (!exact) + throw new InvalidOperationException( + $"PERF-W-11-B{batchSize:D2} graph/provenance/isolation failed for source session {unit}."); + + const string cleanupCypher = """ + MATCH (n) + WHERE n.owner_id = $ownerId + OR n.session_id = $sessionId + OR n.id = $conversationId + DETACH DELETE n + WITH count(n) AS deleted + OPTIONAL MATCH (remaining) + WHERE remaining.owner_id = $ownerId + OR remaining.session_id = $sessionId + OR remaining.id = $conversationId + RETURN deleted, count(remaining) AS remaining + """; + var cleanup = await session.RunAsync( + cleanupCypher, + new { sessionId, ownerId, conversationId = $"{sessionId}-conversation" }) + .ConfigureAwait(false); + var cleanupRecord = await cleanup.SingleAsync().ConfigureAwait(false); + if (cleanupRecord["deleted"].As() == 0 || cleanupRecord["remaining"].As() != 0) + throw new InvalidOperationException( + $"PERF-W-11-B{batchSize:D2} did not clean source session {unit}."); + } + } + + private static Message MultiSessionMessage(int batchSize, string phase, int iteration, int unit) + { + var sessionId = MultiSessionSessionId(batchSize, phase, iteration, unit); + return new Message + { + MessageId = $"{sessionId}-message", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = $"LAB-B1 source {unit:D2}: Person {unit:D2} works at Company {unit:D2} and prefers tea.", + TimestampUtc = new DateTimeOffset(2026, 2, 1, 12, 0, 0, TimeSpan.Zero).AddMinutes(unit), + }; + } + + private static string MultiSessionSessionId(int batchSize, string phase, int iteration, int unit) => + $"perf-w11-b{batchSize:D2}-{phase}-{iteration}-session-{unit:D2}"; + + private static string MultiSessionOwnerId(int batchSize, string phase, int iteration, int unit) => + $"perf-w11-b{batchSize:D2}-{phase}-{iteration}-owner-{unit:D2}"; +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.Neo4jCapacity.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.Neo4jCapacity.cs new file mode 100644 index 00000000..1b65cc77 --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.Neo4jCapacity.cs @@ -0,0 +1,476 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + private const int CapacityBaseOwnerCount = 10; + private const int CapacityBaseSessionsPerOwner = 4; + private const int CapacityMessagesPerSession = 12; + private const int CapacityWorkers = 10; + private const int CapacityBatchTokenBudget = 100_000; + + private static async Task RunNeo4jCapacityAsync( + ScenarioContext context, + string axis, + int factor) + { + var workload = CapacityWorkload.Create(axis, factor); + await using var telemetry = await Neo4jResourceTelemetry + .StartAsync(context.Profile, context.Turn, context.CancellationToken) + .ConfigureAwait(false); + + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var processorTimeBefore = process.TotalProcessorTime; + var totalStartedAt = Stopwatch.GetTimestamp(); + + var rawWork = Enumerable.Range(0, workload.OwnerCount) + .Select(owner => (Func>)(token => + StoreCapacityOwnerAsync(context, workload, owner, token))) + .ToArray(); + var rawStartedAt = Stopwatch.GetTimestamp(); + var raw = await BoundedWorkScheduler + .RunAsync(rawWork, workload.Workers, context.CancellationToken) + .ConfigureAwait(false); + var rawWaveMs = Stopwatch.GetElapsedTime(rawStartedAt).TotalMilliseconds; + context.Turn.Add("store.messages", raw.Results.Sum(result => result.Messages.Count)); + + var extractionWork = raw.Results + .Select(input => (Func>)(token => + ExtractCapacityOwnerAsync(context, workload, input, token))) + .ToArray(); + var extractionStartedAt = Stopwatch.GetTimestamp(); + var extracted = await BoundedWorkScheduler + .RunAsync(extractionWork, workload.Workers, context.CancellationToken) + .ConfigureAwait(false); + var extractionWaveMs = Stopwatch.GetElapsedTime(extractionStartedAt).TotalMilliseconds; + var totalWaveMs = Stopwatch.GetElapsedTime(totalStartedAt).TotalMilliseconds; + + process.Refresh(); + var processorTimeMs = (process.TotalProcessorTime - processorTimeBefore).TotalMilliseconds; + + context.Turn.Add("capacity.factor", workload.Factor); + context.Turn.Add("capacity.axis.width", workload.Axis == "width" ? 1 : 0); + context.Turn.Add("capacity.axis.depth", workload.Axis == "depth" ? 1 : 0); + context.Turn.Add("capacity.owners", workload.OwnerCount); + context.Turn.Add("capacity.source_sessions", workload.SourceSessionCount); + context.Turn.Add("capacity.messages", workload.MessageCount); + context.Turn.Add("capacity.workers", workload.Workers); + context.Turn.Add("capacity.raw.max_concurrency", raw.MaxConcurrency); + context.Turn.Add("capacity.extract.max_concurrency", extracted.MaxConcurrency); + context.Turn.Add( + "capacity.plan_batches", + extracted.Results.Sum(result => result.Plan.BatchCount)); + context.Turn.Add( + "capacity.plan_sessions", + extracted.Results.Sum(result => result.Plan.SourceSessionCount)); + context.Turn.RecordSample("capacity.raw_wave_ms", rawWaveMs); + context.Turn.RecordSample("capacity.extract_wave_ms", extractionWaveMs); + context.Turn.RecordSample("capacity.total_wave_ms", totalWaveMs); + context.Turn.RecordSample("capacity.process_cpu_ms", processorTimeMs); + foreach (var owner in raw.Results) + context.Turn.RecordSample("capacity.owner_raw_ms", owner.DurationMs); + foreach (var owner in extracted.Results) + context.Turn.RecordSample("capacity.owner_extract_ms", owner.DurationMs); + + var expectedSessions = raw.Results + .SelectMany(input => input.ChronologicalRequests) + .Select(request => request.SessionId) + .ToArray(); + var returnedSessions = extracted.Results + .SelectMany(result => result.Results) + .Select(result => result.Metadata.TryGetValue("sessionId", out var value) ? value as string : null) + .ToArray(); + var outputsExact = extracted.Results + .SelectMany(result => result.Results) + .All(result => + result.Status == IngestionStatus.Succeeded && + result.Entities.Count == 2 && + result.Facts.Count == 1 && + result.Preferences.Count == 1 && + result.Relationships.Count == 1 && + result.SourceMessageIds.Count == workload.MessagesPerSession); + var orderExact = returnedSessions.SequenceEqual(expectedSessions, StringComparer.Ordinal); + var calls = context.Turn.Counter("llm.unified_batch.calls"); + var retries = Math.Max(0, calls - workload.OwnerCount); + context.Turn.Add("llm.unified_batch.retries", retries); + + await telemetry.DisposeAsync().ConfigureAwait(false); + + var expectedEmbeddingRequests = workload.OwnerCount + 3L * workload.SourceSessionCount; + var expectedEmbeddingItems = workload.MessageCount + 6L * workload.SourceSessionCount; + var legacyQueries = workload.MessageCount + workload.OwnerCount + 11L * workload.SourceSessionCount; + var legacyReads = 3L * workload.SourceSessionCount; + var savedCandidateReads = context.Profile.UseBatchEntityResolutionSnapshots + ? 2L * (workload.SourceSessionCount - workload.OwnerCount) + : 0L; + var removedDuplicateResolutionQueries = context.Profile.UseCoalescedPersistenceTransactions + ? 6L * workload.SourceSessionCount + : 0L; + var fusedFollowUpQueries = context.Profile.UseCoalescedPersistenceTransactions + ? 10L * workload.SourceSessionCount + : 0L; + var expectedQueries = legacyQueries - savedCandidateReads - removedDuplicateResolutionQueries - fusedFollowUpQueries; + var joinedFactReads = context.Profile.UseCoalescedPersistenceTransactions + ? workload.SourceSessionCount + : 0L; + var expectedReads = legacyReads - savedCandidateReads - joinedFactReads; + var expectedWrites = workload.OwnerCount + + (context.Profile.UseCoalescedPersistenceTransactions ? 1L : 6L) * workload.SourceSessionCount; + var samples = context.Turn.Samples; + var telemetryExact = + context.Turn.Counter("neo4j.telemetry.docker_samples") > 0 && + context.Turn.Counter("neo4j.telemetry.neo4j_samples") > 0 && + context.Turn.Counter("neo4j.telemetry.docker_parse_errors") == 0 && + context.Turn.Counter("neo4j.telemetry.docker_errors") == 0 && + context.Turn.Counter("neo4j.telemetry.neo4j_errors") == 0 && + samples.ContainsKey("neo4j.container.cpu_capacity_percent") && + samples.ContainsKey("neo4j.container.memory_used_bytes") && + samples.ContainsKey("neo4j.container.block_read_bytes") && + samples.ContainsKey("neo4j.jvm.heap_used_bytes") && + samples.ContainsKey("neo4j.transactions.active") && + samples.ContainsKey("neo4j.page_cache.configured_bytes") && + samples.ContainsKey("neo4j.transaction_entry_ms_est"); + var countersExact = + context.Turn.Counter("llm.calls") == workload.OwnerCount && + calls == workload.OwnerCount && + context.Turn.Counter("llm.unified.calls") == 0 && + retries == 0 && + context.Turn.Counter("store.messages") == workload.MessageCount && + context.Turn.Counter("embed.requests") == expectedEmbeddingRequests && + context.Turn.Counter("embed.items") == expectedEmbeddingItems && + context.Turn.SpanCounts.GetValueOrDefault("provider.embedding") == expectedEmbeddingRequests && + context.Turn.SpanCounts.GetValueOrDefault("provider.llm.unified_batch") == workload.OwnerCount && + context.Turn.SpanCounts.GetValueOrDefault("memory.extract.unified_batch") == workload.OwnerCount && + context.Turn.Counter("neo4j.queries") == expectedQueries && + context.Turn.Counter("neo4j.tx.read") == expectedReads && + context.Turn.Counter("neo4j.tx.write") == expectedWrites && + context.Turn.Counter("persist.entities") == workload.SourceSessionCount * 2L && + context.Turn.Counter("persist.facts") == workload.SourceSessionCount && + context.Turn.Counter("persist.preferences") == workload.SourceSessionCount && + context.Turn.Counter("persist.relationships") == workload.SourceSessionCount && + context.Turn.SpanCounts.GetValueOrDefault("memory.persist.total") == workload.SourceSessionCount; + + if (!outputsExact || + !orderExact || + !countersExact || + !telemetryExact || + raw.MaxConcurrency != workload.Workers || + extracted.MaxConcurrency != workload.Workers || + extracted.Results.Any(result => + result.Plan.BatchCount != 1 || + result.Plan.SourceSessionCount != workload.SessionsPerOwner) || + context.Profile.MaxConnectionPoolSize != 16) + { + throw new InvalidOperationException( + $"{workload.ScenarioId} capacity contract failed (outputs/order/telemetry=" + + $"{outputsExact}/{orderExact}/{telemetryExact}; raw/extract concurrency=" + + $"{raw.MaxConcurrency}/{extracted.MaxConcurrency}, expected " + + $"{workload.Workers}/{workload.Workers}; plan batches/sessions=" + + $"{context.Turn.Counter("capacity.plan_batches")}/" + + $"{context.Turn.Counter("capacity.plan_sessions")}, expected " + + $"{workload.OwnerCount}/{workload.SourceSessionCount}; llm/batch/retries=" + + $"{context.Turn.Counter("llm.calls")}/{calls}/{retries}, expected " + + $"{workload.OwnerCount}/{workload.OwnerCount}/0; stored=" + + $"{context.Turn.Counter("store.messages")}/{workload.MessageCount}; " + + $"embed requests/items={context.Turn.Counter("embed.requests")}/" + + $"{context.Turn.Counter("embed.items")}, expected " + + $"{expectedEmbeddingRequests}/{expectedEmbeddingItems}; queries/read/write=" + + $"{context.Turn.Counter("neo4j.queries")}/" + + $"{context.Turn.Counter("neo4j.tx.read")}/" + + $"{context.Turn.Counter("neo4j.tx.write")}, expected " + + $"{expectedQueries}/{expectedReads}/{expectedWrites})."); + } + } + + private static async Task StoreCapacityOwnerAsync( + ScenarioContext context, + CapacityWorkload workload, + int owner, + CancellationToken cancellationToken) + { + var messages = Enumerable.Range(0, workload.SessionsPerOwner) + .SelectMany(session => Enumerable.Range(0, workload.MessagesPerSession) + .Select(message => CapacityMessage( + workload, + context.Phase, + context.Iteration, + owner, + session, + message))) + .ToArray(); + + await using var scope = context.Profile.Services.CreateAsyncScope(); + var memory = scope.ServiceProvider.GetRequiredService(); + var startedAt = Stopwatch.GetTimestamp(); + var stored = await memory.AddMessagesAsync(messages, cancellationToken).ConfigureAwait(false); + var durationMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + + if (stored.Count != messages.Length || + stored.Where((message, index) => + message.MessageId != messages[index].MessageId || + message.Embedding is not { Length: > 0 } embedding || + embedding.Length != context.Profile.Dimensions).Any()) + { + throw new InvalidOperationException( + $"{workload.ScenarioId} owner {owner} did not store exact embedded source messages."); + } + + var chronologicalRequests = messages + .GroupBy(message => message.SessionId, StringComparer.Ordinal) + .Select(group => new ExtractionRequest + { + Messages = group.OrderBy(message => message.TimestampUtc).ToArray(), + SessionId = group.Key, + UserId = CapacityOwnerId(workload, context.Phase, context.Iteration, owner), + TypesToExtract = ExtractionTypes.All, + }) + .OrderBy(request => request.Messages[0].TimestampUtc) + .ToArray(); + + return new CapacityOwnerInput( + owner, + messages, + chronologicalRequests, + chronologicalRequests.AsEnumerable().Reverse().ToArray(), + durationMs); + } + + private static async Task ExtractCapacityOwnerAsync( + ScenarioContext context, + CapacityWorkload workload, + CapacityOwnerInput input, + CancellationToken cancellationToken) + { + await using var scope = context.Profile.Services.CreateAsyncScope(); + var planner = scope.ServiceProvider + .GetServices() + .Single(extractor => extractor.IsEnabled); + var plan = planner.Plan( + input.ChronologicalRequests, + workload.SessionsPerOwner, + CapacityBatchTokenBudget); + var pipeline = scope.ServiceProvider.GetRequiredService(); + var startedAt = Stopwatch.GetTimestamp(); + var results = await pipeline.ExtractBatchAsync( + input.ExecutionRequests, + workload.SessionsPerOwner, + CapacityBatchTokenBudget, + cancellationToken).ConfigureAwait(false); + var durationMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + + var plannedSessions = plan.Batches.SelectMany(batch => batch.SourceSessionIds); + var returnedSessions = results.Select(result => + result.Metadata.TryGetValue("sessionId", out var value) ? value as string : null); + if (!returnedSessions.SequenceEqual(plannedSessions, StringComparer.Ordinal)) + throw new InvalidOperationException( + $"{workload.ScenarioId} owner {input.Owner} did not match its preflight order."); + + return new CapacityOwnerResult(input.Owner, plan, results, durationMs); + } + + private static async Task VerifyNeo4jCapacityAsync( + ScenarioVerificationContext context, + string axis, + int factor) + { + var workload = CapacityWorkload.Create(axis, factor); + for (var owner = 0; owner < workload.OwnerCount; owner++) + { + var ownerId = CapacityOwnerId(workload, context.Phase, context.Iteration, owner); + var sessionIds = Enumerable.Range(0, workload.SessionsPerOwner) + .Select(session => CapacitySessionId( + workload, + context.Phase, + context.Iteration, + owner * workload.SessionsPerOwner + session)) + .ToArray(); + const string verifyCypher = """ + UNWIND $sessionIds AS sessionId + CALL { + WITH sessionId + MATCH (m:Message {session_id: sessionId}) + RETURN count(m) AS messages, + count(CASE WHEN size(m.embedding) = $dimensions THEN 1 END) AS messageVectors + } + CALL { + WITH sessionId + MATCH (e:Entity {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: sessionId}) + RETURN count(DISTINCT e) AS entities, count(*) AS entityProvenance + } + CALL { + WITH sessionId + MATCH (f:Fact {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: sessionId}) + RETURN count(DISTINCT f) AS facts, count(*) AS factProvenance + } + CALL { + WITH sessionId + MATCH (p:Preference {owner_id: $ownerId})-[:EXTRACTED_FROM]-> + (:Message {session_id: sessionId}) + RETURN count(DISTINCT p) AS preferences, count(*) AS preferenceProvenance + } + CALL { + WITH sessionId + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND source.owner_id = $ownerId + AND target.owner_id = $ownerId + AND r.relation_type = 'WORKS_AT' + AND EXISTS { + MATCH (source)-[:EXTRACTED_FROM]->(:Message {session_id: sessionId}) + } + RETURN count(DISTINCT r) AS relationships + } + RETURN sessionId, messages, messageVectors, entities, facts, preferences, + relationships, entityProvenance + factProvenance + preferenceProvenance AS provenance + ORDER BY sessionId + """; + + await using var sessionHandle = context.Profile.Driver.AsyncSession(); + var cursor = await sessionHandle.RunAsync( + verifyCypher, + new { sessionIds, ownerId, dimensions = context.Profile.Dimensions }).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + var exact = records.Count == workload.SessionsPerOwner && records.All(record => + record["messages"].As() == workload.MessagesPerSession && + record["messageVectors"].As() == workload.MessagesPerSession && + record["entities"].As() == 2 && + record["facts"].As() == 1 && + record["preferences"].As() == 1 && + record["relationships"].As() == 1 && + record["provenance"].As() == 4L * workload.MessagesPerSession); + if (!exact) + throw new InvalidOperationException( + $"{workload.ScenarioId} graph/provenance verification failed for owner {owner}."); + + const string isolationCypher = """ + MATCH (source:Entity)-[r:RELATED_TO]->(target:Entity) + WHERE r.owner_id = $ownerId + AND (source.owner_id <> $ownerId OR target.owner_id <> $ownerId) + RETURN count(r) AS crossOwnerEdges + """; + var isolationCursor = await sessionHandle.RunAsync(isolationCypher, new { ownerId }) + .ConfigureAwait(false); + var isolation = await isolationCursor.SingleAsync().ConfigureAwait(false); + if (isolation["crossOwnerEdges"].As() != 0) + throw new InvalidOperationException( + $"{workload.ScenarioId} owner isolation failed for owner {owner}."); + + var conversationIds = sessionIds.Select(id => $"{id}-conversation").ToArray(); + const string cleanupCypher = """ + MATCH (n) + WHERE n.owner_id = $ownerId + OR n.session_id IN $sessionIds + OR n.id IN $conversationIds + DETACH DELETE n + WITH count(n) AS deleted + OPTIONAL MATCH (remaining) + WHERE remaining.owner_id = $ownerId + OR remaining.session_id IN $sessionIds + OR remaining.id IN $conversationIds + RETURN deleted, count(remaining) AS remaining + """; + var cleanupCursor = await sessionHandle.RunAsync( + cleanupCypher, + new { ownerId, sessionIds, conversationIds }).ConfigureAwait(false); + var cleanup = await cleanupCursor.SingleAsync().ConfigureAwait(false); + if (cleanup["deleted"].As() == 0 || cleanup["remaining"].As() != 0) + throw new InvalidOperationException( + $"{workload.ScenarioId} did not clean owner lane {owner}."); + } + } + + private static Message CapacityMessage( + CapacityWorkload workload, + string phase, + int iteration, + int owner, + int session, + int message) + { + var unit = owner * workload.SessionsPerOwner + session; + var sessionId = CapacitySessionId(workload, phase, iteration, unit); + return new Message + { + MessageId = $"{sessionId}-message-{message:D2}", + ConversationId = $"{sessionId}-conversation", + SessionId = sessionId, + Role = "user", + Content = + $"LAB-N1 source {unit:D3}: Person {unit:D3} works at Company {unit:D3} and " + + $"prefers tea. Supporting turn {message:D2}.", + TimestampUtc = new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero) + .AddMinutes(unit) + .AddSeconds(message), + }; + } + + private static string CapacitySessionId( + CapacityWorkload workload, + string phase, + int iteration, + int unit) => + $"perf-w13-{workload.Axis[0]}{workload.Factor:D2}-{phase}-{iteration}-session-{unit:D3}"; + + private static string CapacityOwnerId( + CapacityWorkload workload, + string phase, + int iteration, + int owner) => + $"perf-w13-{workload.Axis[0]}{workload.Factor:D2}-{phase}-{iteration}-owner-{owner:D3}"; + + private sealed record CapacityOwnerInput( + int Owner, + IReadOnlyList Messages, + IReadOnlyList ChronologicalRequests, + IReadOnlyList ExecutionRequests, + double DurationMs); + + private sealed record CapacityOwnerResult( + int Owner, + MultiSessionExtractionPlan Plan, + IReadOnlyList Results, + double DurationMs); + + private sealed record CapacityWorkload( + string ScenarioId, + string Axis, + int Factor, + int OwnerCount, + int SessionsPerOwner, + int MessagesPerSession, + int Workers) + { + public int SourceSessionCount => OwnerCount * SessionsPerOwner; + public int MessageCount => SourceSessionCount * MessagesPerSession; + + public static CapacityWorkload Create(string axis, int factor) + { + if (factor is not (1 or 2 or 4 or 8)) + throw new ArgumentOutOfRangeException(nameof(factor)); + return axis switch + { + "width" => new( + $"PERF-W-13-W{factor:D2}", axis, factor, + CapacityBaseOwnerCount * factor, + CapacityBaseSessionsPerOwner, + CapacityMessagesPerSession, + CapacityWorkers), + "depth" => new( + $"PERF-W-13-D{factor:D2}", axis, factor, + CapacityBaseOwnerCount, + CapacityBaseSessionsPerOwner * factor, + CapacityMessagesPerSession, + CapacityWorkers), + _ => throw new ArgumentOutOfRangeException(nameof(axis)), + }; + } + } +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.UnifiedExtraction.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.UnifiedExtraction.cs new file mode 100644 index 00000000..58c7c8da --- /dev/null +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.UnifiedExtraction.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentMemory.Cli.Perf; + +public static partial class PerfScenarios +{ + internal const string UnifiedExtractionProbeMessage = + "LAB-U1 source: Alice Martin works at Acme Corporation and prefers concise written summaries."; + + internal const string UnifiedExtractionPayload = + """ + { + "entities": [ + {"name":"Acme Corporation","type":"ORGANIZATION","confidence":0.92,"aliases":[]}, + {"name":"Alice Martin","type":"PERSON","confidence":0.95,"aliases":[]} + ], + "facts": [ + {"subject":"Alice Martin","predicate":"works_at","object":"Acme Corporation","confidence":0.90}, + {"subject":"Alice Martin","predicate":"leads","object":"platform team","confidence":0.85} + ], + "preferences": [ + {"category":"communication","preference":"prefers concise written summaries","confidence":0.88} + ], + "relations": [ + {"source":"Alice Martin","target":"Acme Corporation","relation_type":"WORKS_AT","confidence":0.90} + ] + } + """; + + /// + /// PERF-W-09 — isolates one typed unified extraction call over the same shape as PERF-W-07. + /// Storage, resolution, embeddings, persistence, recall, answer, and judge remain excluded. + /// + private static async Task ExtractUnifiedOnlyAsync(ScenarioContext context) + { + IReadOnlyList messages = + [ + new Message + { + MessageId = "perf-w09-source-00", + ConversationId = "perf-w09-conversation", + SessionId = "perf-w09-session", + Role = "user", + Content = UnifiedExtractionProbeMessage, + TimestampUtc = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + }, + ]; + + var extractor = context.Profile.Services.GetRequiredService(); + using var activity = new Activity("lab.extraction.unified").Start(); + var startedAt = Stopwatch.GetTimestamp(); + UnifiedExtractionResult result; + try + { + result = await extractor.ExtractAsync(messages, context.CancellationToken).ConfigureAwait(false); + } + finally + { + context.Turn.RecordSpan( + "lab.extractor.unified", + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds); + } + + context.Turn.Add("extract.input_messages", messages.Count); + context.Turn.Add("extract.entities", result.Entities.Count); + context.Turn.Add("extract.facts", result.Facts.Count); + context.Turn.Add("extract.preferences", result.Preferences.Count); + context.Turn.Add("extract.relationships", result.Relationships.Count); + + var calls = context.Turn.Counter("llm.unified.calls"); + context.Turn.Add("llm.unified.retries", Math.Max(0, calls - 1)); + var outputsExact = + result.Entities.Count == 2 && + result.Entities[0].Name == "Acme Corporation" && + result.Entities[1].Name == "Alice Martin" && + result.Facts.Count == 2 && + result.Facts[0].Predicate == "works_at" && + result.Facts[1].Predicate == "leads" && + result.Preferences.Count == 1 && + result.Preferences[0].Category == "communication" && + result.Relationships.Count == 1 && + result.Relationships[0].RelationshipType == "WORKS_AT"; + var purposeMetricsExact = + calls == 1 && + context.Turn.Counter("llm.unified.tokens_in") > 0 && + context.Turn.Counter("llm.unified.tokens_out") > 0 && + context.Turn.SpanCounts.GetValueOrDefault("provider.llm.unified") == 1 && + context.Turn.SpanCounts.GetValueOrDefault("lab.extractor.unified") == 1; + var excludedWork = + context.Turn.Counter("embed.requests") + + context.Turn.Counter("embed.items") + + context.Turn.Counter("neo4j.queries") + + context.Turn.Counter("neo4j.tx.read") + + context.Turn.Counter("neo4j.tx.write") + + context.Turn.Counter("store.messages") + + context.Turn.Counter("persist.entities") + + context.Turn.Counter("persist.facts") + + context.Turn.Counter("persist.preferences") + + context.Turn.Counter("persist.relationships") + + context.Turn.Counter("items.retrieved"); + + if (context.Turn.Counter("llm.calls") != 1 || + !purposeMetricsExact || + !outputsExact || + excludedWork != 0) + { + throw new InvalidOperationException( + $"PERF-W-09 unified extraction contract failed (llm.calls=" + + $"{context.Turn.Counter("llm.calls")}/1, purpose_metrics_exact=" + + $"{purposeMetricsExact}, outputs={result.Entities.Count}/{result.Facts.Count}/" + + $"{result.Preferences.Count}/{result.Relationships.Count}, expected 2/2/1/1, " + + $"excluded_work={excludedWork}/0). This arm must measure one non-empty typed call " + + "without storage, resolution, embedding, persistence, recall, answer, or judge work."); + } + } +} diff --git a/tools/AgentMemory.Cli/Perf/PerfScenarios.cs b/tools/AgentMemory.Cli/Perf/PerfScenarios.cs index 8a182002..d098e096 100644 --- a/tools/AgentMemory.Cli/Perf/PerfScenarios.cs +++ b/tools/AgentMemory.Cli/Perf/PerfScenarios.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using AgentMemory.Abstractions.Domain; using AgentMemory.Abstractions.Options; using AgentMemory.Abstractions.Repositories; using AgentMemory.Abstractions.Services; @@ -23,7 +25,9 @@ public sealed record PerfScenario( bool SupportsInterleavedAb = true, PerfDependencyLatencyPreset? DependencyLatency = null, Func? SetupAsync = null, - Func? VerifyAsync = null) + Func? VerifyAsync = null, + bool IncludeInDefaultRun = true, + bool RequiresUnifiedExtraction = false) { public async Task ExecuteAsync(ScenarioContext context) { @@ -75,7 +79,7 @@ public sealed record ScenarioVerificationContext( /// defaults. Together they replace estimates with facts about recall cost before the model runs and /// ingestion cost after it, including turns that exercise policy and workload extremes. /// -public static class PerfScenarios +public static partial class PerfScenarios { public static IReadOnlyList All { get; } = [ @@ -112,12 +116,177 @@ public static class PerfScenarios SupportsInterleavedAb: false, SetupAsync: PrepareWholeSessionAsync, VerifyAsync: VerifyWholeSessionAsync), + new( + "PERF-W-06", + "50-message raw storage with message embedding and extraction disabled", + StoreRawBatchAsync, + SupportsInterleavedAb: false, + VerifyAsync: VerifyRawBatchAsync), + new( + "PERF-W-07", + "Four category extraction calls over one fixed session with no persistence", + ExtractOnlyAsync), + new( + "PERF-W-08", + "Frozen extraction output through resolution, embeddings, and learned-memory persistence", + PersistFrozenExtractionAsync, + SupportsInterleavedAb: false, + SetupAsync: PrepareFrozenPersistenceAsync, + VerifyAsync: VerifyFrozenPersistenceAsync), + new( + "PERF-W-09", + "One typed unified extraction call over one fixed session with no persistence", + ExtractUnifiedOnlyAsync), + new( + "PERF-W-10-C01", + "Full cold-build wave over ten isolated owners with 1 worker", + ctx => RunConcurrentColdBuildAsync(ctx, 1), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyConcurrentColdBuildAsync(ctx, 1), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-10-C05", + "Full cold-build wave over ten isolated owners with 5 workers", + ctx => RunConcurrentColdBuildAsync(ctx, 5), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyConcurrentColdBuildAsync(ctx, 5), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-10-C10", + "Full cold-build wave over ten isolated owners with 10 workers", + ctx => RunConcurrentColdBuildAsync(ctx, 10), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyConcurrentColdBuildAsync(ctx, 10), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-11-B01", + "Full cold-build over eight multi-session sources at batch size 1", + ctx => RunMultiSessionBatchAsync(ctx, 1), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyMultiSessionBatchAsync(ctx, 1), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-11-B02", + "Full cold-build over eight multi-session sources at batch size 2", + ctx => RunMultiSessionBatchAsync(ctx, 2), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyMultiSessionBatchAsync(ctx, 2), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-11-B04", + "Full cold-build over eight multi-session sources at batch size 4", + ctx => RunMultiSessionBatchAsync(ctx, 4), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyMultiSessionBatchAsync(ctx, 4), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-12-X01", + "Integrated cold-build over ten owner lanes with 1 worker", + ctx => RunIntegratedColdBuildAsync(ctx, 1), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyIntegratedColdBuildAsync(ctx, 1), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-12-X05", + "Integrated cold-build over ten owner lanes with 5 workers", + ctx => RunIntegratedColdBuildAsync(ctx, 5), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyIntegratedColdBuildAsync(ctx, 5), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-12-X10", + "Integrated cold-build over ten owner lanes with 10 workers", + ctx => RunIntegratedColdBuildAsync(ctx, 10), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyIntegratedColdBuildAsync(ctx, 10), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-W01", + "Neo4j capacity width 1x: 10 owners, 40 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "width", 1), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "width", 1), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-W02", + "Neo4j capacity width 2x: 20 owners, 80 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "width", 2), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "width", 2), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-W04", + "Neo4j capacity width 4x: 40 owners, 160 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "width", 4), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "width", 4), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-W08", + "Neo4j capacity width 8x: 80 owners, 320 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "width", 8), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "width", 8), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-D01", + "Neo4j capacity depth 1x: 10 owners, 40 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "depth", 1), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "depth", 1), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-D02", + "Neo4j capacity depth 2x: 10 owners, 80 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "depth", 2), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "depth", 2), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-D04", + "Neo4j capacity depth 4x: 10 owners, 160 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "depth", 4), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "depth", 4), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), + new( + "PERF-W-13-D08", + "Neo4j capacity depth 8x: 10 owners, 320 source sessions, 10 workers", + ctx => RunNeo4jCapacityAsync(ctx, "depth", 8), + SupportsInterleavedAb: false, + VerifyAsync: ctx => VerifyNeo4jCapacityAsync(ctx, "depth", 8), + IncludeInDefaultRun: false, + RequiresUnifiedExtraction: true), ]; internal const string StoreProbeUserMessage = "Alice Martin just moved to the Acme Corporation platform team and prefers concise updates."; + internal const string ExtractionOnlyProbeMessage = + "LAB-E0 source: Alice Martin works at Acme Corporation and prefers concise written summaries."; + + private const string ExtractionOnlyEntityPayload = """{"entities":[{"name":"Acme Corporation","type":"ORGANIZATION","confidence":0.92},{"name":"Alice Martin","type":"PERSON","confidence":0.95}]}"""; + private const string ExtractionOnlyFactPayload = """{"facts":[{"subject":"Alice Martin","predicate":"works_at","object":"Acme Corporation","confidence":0.9},{"subject":"Alice Martin","predicate":"leads","object":"platform team","confidence":0.85}]}"""; + private const string ExtractionOnlyPreferencePayload = """{"preferences":[{"category":"communication","preference":"prefers concise written summaries","confidence":0.88}]}"""; + private const string ExtractionOnlyRelationshipPayload = """{"relations":[{"source":"Alice Martin","target":"Acme Corporation","relation_type":"WORKS_AT","confidence":0.9}]}"""; private const int SessionExtractionMessageCount = 50; + private const int RawBatchMessageCount = 50; /// /// Input-keyed model responses required by cost scenarios. Kept separate from judged fixture rules: @@ -125,12 +294,22 @@ public static class PerfScenarios /// into a no-op that its self-assertion rejects. /// internal static IReadOnlyList ScriptedRules { get; } = - [new(StoreProbeUserMessage, ScriptedChatClient.ExtractionPayload)]; + [ + new("structured long-term memory", UnifiedExtractionPayload, UnifiedExtractionProbeMessage), + new("entity extraction assistant", ExtractionOnlyEntityPayload, ExtractionOnlyProbeMessage), + new("fact extraction assistant", ExtractionOnlyFactPayload, ExtractionOnlyProbeMessage), + new("preference extraction assistant", ExtractionOnlyPreferencePayload, ExtractionOnlyProbeMessage), + new("relationship extraction assistant", ExtractionOnlyRelationshipPayload, ExtractionOnlyProbeMessage), + new(StoreProbeUserMessage, ScriptedChatClient.ExtractionPayload), + ]; public static IReadOnlyList Select(string? filter) { - if (string.IsNullOrWhiteSpace(filter) || filter.Equals("all", StringComparison.OrdinalIgnoreCase)) - return All; + if (string.IsNullOrWhiteSpace(filter) || + filter.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + return All.Where(scenario => scenario.IncludeInDefaultRun).ToList(); + } var wanted = filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var selected = All.Where(s => wanted.Contains(s.Id, StringComparer.OrdinalIgnoreCase)).ToList(); @@ -572,6 +751,216 @@ private static string SessionExtractionSessionId(string phase, int iteration) => private static string SessionExtractionOwnerId(string phase, int iteration) => $"{SessionExtractionSessionId(phase, iteration)}-owner"; + /// + /// PERF-W-06 — isolates the raw message-storage path that LongMemEval preparation pays before any + /// extraction. The product API embeds each message and persists the batch; extraction is not invoked. + /// + private static async Task StoreRawBatchAsync(ScenarioContext ctx) + { + var sessionId = RawBatchSessionId(ctx.Phase, ctx.Iteration); + var conversationId = $"{sessionId}-conv"; + var startedAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + var messages = Enumerable.Range(0, RawBatchMessageCount) + .Select(index => new Message + { + MessageId = $"{sessionId}-msg-{index:D2}", + ConversationId = conversationId, + SessionId = sessionId, + Role = index % 2 == 0 ? "user" : "assistant", + Content = $"Raw storage fixture message {index:D2}: Alice Martin works on the " + + "Acme Corporation platform team and prefers concise written updates.", + TimestampUtc = startedAt.AddSeconds(index), + }) + .ToList(); + + var memory = ctx.Profile.Services.GetRequiredService(); + var stored = await memory.AddMessagesAsync(messages, ctx.CancellationToken).ConfigureAwait(false); + ctx.Turn.Add("store.messages", stored.Count); + + var storedIds = stored.Select(message => message.MessageId).ToArray(); + var expectedIds = messages.Select(message => message.MessageId).ToArray(); + var embeddingsComplete = stored.All(message => + message.Embedding is { Length: > 0 } embedding && + embedding.Length == ctx.Profile.Dimensions); + var idsInOrder = storedIds.SequenceEqual(expectedIds, StringComparer.Ordinal); + var embeddingRequests = ctx.Turn.Counter("embed.requests"); + var embeddedItems = ctx.Turn.Counter("embed.items"); + var modelCalls = ctx.Turn.Counter("llm.calls"); + var queries = ctx.Turn.Counter("neo4j.queries"); + var writeTransactions = ctx.Turn.Counter("neo4j.tx.write"); + + if (stored.Count != RawBatchMessageCount || + !idsInOrder || + !embeddingsComplete || + embeddingRequests != 1 || + embeddedItems != RawBatchMessageCount || + modelCalls != 0 || + queries != 1 || + writeTransactions != 1) + { + throw new InvalidOperationException( + $"PERF-W-06 did not exercise its raw-storage contract (stored={stored.Count}/" + + $"{RawBatchMessageCount}, ids_in_order={idsInOrder}, " + + $"embeddings_complete={embeddingsComplete}, embed.requests/items=" + + $"{embeddingRequests}/{embeddedItems}, expected 1/{RawBatchMessageCount}; " + + $"llm.calls={modelCalls}/0, neo4j.queries/write tx={queries}/{writeTransactions}, " + + "expected 1/1). This scenario must measure " + + "message embedding and persistence without extraction."); + } + } + + private static async Task VerifyRawBatchAsync(ScenarioVerificationContext ctx) + { + var sessionId = RawBatchSessionId(ctx.Phase, ctx.Iteration); + var expectedIds = Enumerable.Range(0, RawBatchMessageCount) + .Select(index => $"{sessionId}-msg-{index:D2}") + .ToArray(); + var shape = await PerfFixture.InspectRawBatchStorageAsync( + ctx.Profile, + sessionId, + ctx.Profile.Dimensions).ConfigureAwait(false); + var idsInOrder = shape.Ids.SequenceEqual(expectedIds, StringComparer.Ordinal); + + if (shape.Messages != RawBatchMessageCount || + shape.MessagesWithExpectedEmbedding != RawBatchMessageCount || + shape.DistinctIds != RawBatchMessageCount || + !idsInOrder) + { + throw new InvalidOperationException( + $"PERF-W-06 graph read-back failed (messages={shape.Messages}/" + + $"{RawBatchMessageCount}, expected-dimension embeddings=" + + $"{shape.MessagesWithExpectedEmbedding}/{RawBatchMessageCount}, distinct ids=" + + $"{shape.DistinctIds}/{RawBatchMessageCount}, ids_in_order={idsInOrder}). Counters " + + "alone cannot prove that the raw messages and embeddings were persisted."); + } + } + + /// + /// PERF-W-07 — isolates the four shipped LLM category extractors over one fixed in-memory source + /// session. It deliberately bypasses resolution, embeddings, persistence, recall, answer, and judge. + /// + private static async Task ExtractOnlyAsync(ScenarioContext ctx) + { + var messages = new[] + { + new Message + { + MessageId = "perf-w07-source-00", + ConversationId = "perf-w07-conversation", + SessionId = "perf-w07-session", + Role = "user", + Content = ExtractionOnlyProbeMessage, + TimestampUtc = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + }, + }; + + var entityExtractor = ctx.Profile.Services.GetRequiredService(); + var factExtractor = ctx.Profile.Services.GetRequiredService(); + var preferenceExtractor = ctx.Profile.Services.GetRequiredService(); + var relationshipExtractor = ctx.Profile.Services.GetRequiredService(); + + var entityTask = MeasureExtractorAsync("entity", + () => entityExtractor.ExtractAsync(messages, ctx.CancellationToken), ctx.Turn); + var factTask = MeasureExtractorAsync("fact", + () => factExtractor.ExtractAsync(messages, ctx.CancellationToken), ctx.Turn); + var preferenceTask = MeasureExtractorAsync("preference", + () => preferenceExtractor.ExtractAsync(messages, ctx.CancellationToken), ctx.Turn); + var relationshipTask = MeasureExtractorAsync("relationship", + () => relationshipExtractor.ExtractAsync(messages, ctx.CancellationToken), ctx.Turn); + + await Task.WhenAll(entityTask, factTask, preferenceTask, relationshipTask).ConfigureAwait(false); + + var entities = await entityTask.ConfigureAwait(false); + var facts = await factTask.ConfigureAwait(false); + var preferences = await preferenceTask.ConfigureAwait(false); + var relationships = await relationshipTask.ConfigureAwait(false); + + ctx.Turn.Add("extract.input_messages", messages.Length); + ctx.Turn.Add("extract.entities", entities.Count); + ctx.Turn.Add("extract.facts", facts.Count); + ctx.Turn.Add("extract.preferences", preferences.Count); + ctx.Turn.Add("extract.relationships", relationships.Count); + + var purposeMetricsComplete = true; + foreach (var purpose in new[] { "entity", "fact", "preference", "relationship" }) + { + var calls = ctx.Turn.Counter($"llm.{purpose}.calls"); + ctx.Turn.Add($"llm.{purpose}.retries", Math.Max(0, calls - 1)); + purposeMetricsComplete &= + calls == 1 && + ctx.Turn.Counter($"llm.{purpose}.tokens_in") > 0 && + ctx.Turn.Counter($"llm.{purpose}.tokens_out") > 0 && + ctx.Turn.SpanCounts.GetValueOrDefault($"provider.llm.{purpose}") == 1; + } + + var outputsExact = + entities.Count == 2 && + entities[0].Name == "Acme Corporation" && + entities[1].Name == "Alice Martin" && + facts.Count == 2 && + facts[0].Predicate == "works_at" && + facts[1].Predicate == "leads" && + preferences.Count == 1 && + preferences[0].Category == "communication" && + relationships.Count == 1 && + relationships[0].RelationshipType == "WORKS_AT"; + + var extractionSpansExact = + ctx.Turn.SpanCounts.GetValueOrDefault("lab.extractor.entity") == 1 && + ctx.Turn.SpanCounts.GetValueOrDefault("lab.extractor.fact") == 1 && + ctx.Turn.SpanCounts.GetValueOrDefault("lab.extractor.preference") == 1 && + ctx.Turn.SpanCounts.GetValueOrDefault("lab.extractor.relationship") == 1; + + var excludedWork = + ctx.Turn.Counter("embed.requests") + + ctx.Turn.Counter("embed.items") + + ctx.Turn.Counter("neo4j.queries") + + ctx.Turn.Counter("neo4j.tx.read") + + ctx.Turn.Counter("neo4j.tx.write") + + ctx.Turn.Counter("store.messages") + + ctx.Turn.Counter("persist.entities") + + ctx.Turn.Counter("persist.facts") + + ctx.Turn.Counter("persist.preferences") + + ctx.Turn.Counter("persist.relationships") + + ctx.Turn.Counter("items.retrieved"); + + if (ctx.Turn.Counter("llm.calls") != 4 || + !purposeMetricsComplete || + !outputsExact || + !extractionSpansExact || + excludedWork != 0) + { + throw new InvalidOperationException( + $"PERF-W-07 extraction-only contract failed (llm.calls={ctx.Turn.Counter("llm.calls")}/4, " + + $"purpose_metrics_complete={purposeMetricsComplete}, outputs=" + + $"{entities.Count}/{facts.Count}/{preferences.Count}/{relationships.Count}, expected 2/2/1/1, " + + $"extraction_spans_exact={extractionSpansExact}, excluded_work={excludedWork}/0). " + + "This arm must measure four non-empty category calls without storage, resolution, " + + "embedding, persistence, recall, answer, or judge work."); + } + } + + private static async Task> MeasureExtractorAsync( + string purpose, + Func>> extractAsync, + TurnRecord turn) + { + using var activity = new Activity($"lab.extraction.{purpose}").Start(); + var startedAt = Stopwatch.GetTimestamp(); + try + { + return await extractAsync().ConfigureAwait(false); + } + finally + { + turn.RecordSpan($"lab.extractor.{purpose}", + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds); + } + } + + private static string RawBatchSessionId(string phase, int iteration) => + $"perf-w06-{phase}-{iteration}"; + private static void AssertScriptedExtraction(ScenarioContext ctx, string scenarioId) { // The mirror of the recall self-check: a scripted model returning unparseable output would make diff --git a/tools/AgentMemory.Cli/Perf/QualityGate.cs b/tools/AgentMemory.Cli/Perf/QualityGate.cs index 8e6c6018..d7927d37 100644 --- a/tools/AgentMemory.Cli/Perf/QualityGate.cs +++ b/tools/AgentMemory.Cli/Perf/QualityGate.cs @@ -13,6 +13,8 @@ internal sealed record QualityBaseline( ExtractionQualityBaseline Extraction); internal sealed record RetrievalQualityBaseline( + string Measurement, + bool SemanticQualityClaim, double RecallAtK, double Mrr, int Cases, @@ -51,6 +53,8 @@ public static QualityGateResult Disabled() => /// internal static class QualityGate { + internal const string DeterministicPlumbingMeasurement = "deterministic-plumbing"; + internal const string DefaultBaselinePath = "eng/perf/baselines/quality.json"; private static readonly JsonSerializerOptions Json = new() @@ -175,6 +179,15 @@ private static void Validate(QualityBaseline baseline) if (baseline.SchemaVersion != 1) throw new InvalidOperationException( $"Unsupported quality baseline schemaVersion {baseline.SchemaVersion}; expected 1."); + if (!string.Equals( + baseline.Retrieval.Measurement, + DeterministicPlumbingMeasurement, + StringComparison.Ordinal) || + baseline.Retrieval.SemanticQualityClaim) + { + throw new InvalidOperationException( + "Retrieval quality baseline must identify Recall@K/MRR as deterministic-plumbing metrics with semanticQualityClaim=false."); + } if (!double.IsFinite(baseline.Tolerance) || baseline.Tolerance < 0) throw new InvalidOperationException("Quality baseline tolerance must be finite and non-negative."); if (baseline.Retrieval.Cases <= 0 || baseline.Extraction.Cases <= 0 || diff --git a/tools/AgentMemory.Cli/Perf/ScriptedChatClient.cs b/tools/AgentMemory.Cli/Perf/ScriptedChatClient.cs index ce91aff0..98459df9 100644 --- a/tools/AgentMemory.Cli/Perf/ScriptedChatClient.cs +++ b/tools/AgentMemory.Cli/Perf/ScriptedChatClient.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.Extensions.AI; namespace AgentMemory.Cli.Perf; @@ -45,9 +47,9 @@ public sealed class ScriptedChatClient : IChatClient } """; - /// A per-input scripted answer: when appears in the prompt, return - /// . - public sealed record Rule(string MatchOn, string Payload); + /// A scripted answer selected when and, when supplied, + /// both appear in the prompt. + public sealed record Rule(string MatchOn, string Payload, string? MatchAlsoOn = null); private readonly TimeSpan _delay; private readonly string _payload; @@ -99,9 +101,18 @@ private string SelectPayload(IEnumerable messages) if (_rules.Count == 0) return _payload; var prompt = string.Join("\n", messages.Select(m => m.Text ?? string.Empty)); + if (prompt.Contains("LAB-N1 source", StringComparison.Ordinal)) + return MultiSessionPayload(prompt, useLexicalIdentity: true, useCapacityLabels: true); + if (prompt.Contains("LAB-X1 source", StringComparison.Ordinal)) + return MultiSessionPayload(prompt, useLexicalIdentity: true); + if (prompt.Contains("LAB-B1 source", StringComparison.Ordinal)) + return MultiSessionPayload(prompt, useLexicalIdentity: false); + foreach (var rule in _rules) { - if (prompt.Contains(rule.MatchOn, StringComparison.OrdinalIgnoreCase)) + if (prompt.Contains(rule.MatchOn, StringComparison.OrdinalIgnoreCase) && + (rule.MatchAlsoOn is null || + prompt.Contains(rule.MatchAlsoOn, StringComparison.OrdinalIgnoreCase))) return rule.Payload; } @@ -115,6 +126,164 @@ private string SelectPayload(IEnumerable messages) public const string EmptyPayload = """{"entities": [], "facts": [], "preferences": [], "relations": []}"""; + private static readonly string[] IntegratedLabels = + [ + "amber", "birch", "cobalt", "dahlia", "ember", "fjord", "garnet", "harbor", + "indigo", "juniper", "kelp", "lilac", "maple", "nectar", "onyx", "pebble", + "quartz", "raven", "saffron", "thistle", "umber", "violet", "willow", "xenon", + "yarrow", "zephyr", "acorn", "breeze", "cedar", "drift", "elm", "fern", + "glacier", "hazel", "iris", "jade", "lotus", "moss", "opal", "pine", + ]; + + private const int CapacityLabelCount = 320; + private const int CapacityEmbeddingDimensions = 384; + private static readonly string[] CapacityLabels = CreateCapacityLabels(); + + private static string[] CreateCapacityLabels() + { + var labels = new List(CapacityLabelCount); + var usedSlots = new HashSet + { + LabelSlot("person"), LabelSlot("company"), + }; + + for (var candidate = 0; labels.Count < CapacityLabelCount; candidate++) + { + var label = PseudoWord(candidate); + if (usedSlots.Add(LabelSlot(label))) + labels.Add(label); + } + + return labels.ToArray(); + } + + private static string PseudoWord(int value) + { + Span characters = stackalloc char[12]; + var state = unchecked((uint)value * 747_796_405u + 2_891_336_453u); + for (var index = 0; index < characters.Length; index++) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + characters[index] = (char)('a' + state % 26); + } + // Prevent the conservative harness stemmer from trimming a generated suffix. + characters[^1] = 'q'; + return new string(characters); + } + + private static int LabelSlot(string text) + { + const uint offset = 2166136261; + const uint prime = 16777619; + var hash = offset; + foreach (var character in text) + { + hash ^= character; + hash *= prime; + } + return (int)(hash % CapacityEmbeddingDimensions); + } + + /// + /// One batched source session as the provider sees it: an opaque request-local alias key plus the + /// session's own transcript. + /// + /// + /// Under batch-source-alias-schema-v1 the key is a deterministic short alias (s1… + /// sN) that the product maps back to the immutable real session id after the response, so it + /// carries no session identity at all. A stand-in must therefore recover identity the way a real + /// model does — by reading the block's own content — and echo the alias back unchanged. + /// + private static readonly Regex SourceSessionBlock = new( + "(.*?)", + RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SourceUnitMarker = new( + @"LAB-[A-Z0-9]+ source (\d+)", + RegexOptions.Compiled); + + private static string MultiSessionPayload( + string prompt, bool useLexicalIdentity, bool useCapacityLabels = false) + { + var blocks = SourceSessionBlock.Matches(prompt) + .Select(match => (Key: match.Groups[1].Value, Body: match.Groups[2].Value)) + .DistinctBy(block => block.Key, StringComparer.Ordinal) + .ToArray(); + if (blocks.Length == 0) + throw new InvalidOperationException( + "Multi-session stand-in found no block; returning an empty payload " + + "would measure a no-op that looks healthy."); + + var keys = blocks.Select(block => block.Key).ToArray(); + var identities = blocks.ToDictionary( + block => block.Key, + block => + { + var marker = SourceUnitMarker.Match(block.Body); + if (!marker.Success) + throw new InvalidOperationException( + "Multi-session stand-in could not recover a source ordinal from a batched " + + "session body; the laboratory fixture and alias contract disagree."); + var digits = marker.Groups[1].Value; + if (!useLexicalIdentity) + return digits; + var index = int.Parse(digits, System.Globalization.CultureInfo.InvariantCulture); + var labels = useCapacityLabels ? CapacityLabels : IntegratedLabels; + return index < labels.Length + ? labels[index] + : throw new InvalidOperationException("Integrated capacity label range exceeded."); + }, + StringComparer.Ordinal); + string Identity(string key) => identities[key]; + + return JsonSerializer.Serialize(new + { + processed_source_sessions = keys, + entities = keys.SelectMany(key => + { + var identity = Identity(key); + return new[] + { + new { source_session = key, name = $"Person {identity}", type = "PERSON", confidence = 0.95 }, + new { source_session = key, name = $"Company {identity}", type = "ORGANIZATION", confidence = 0.95 }, + }; + }), + facts = keys.Select(key => + { + var identity = Identity(key); + return new + { + source_session = key, + subject = $"Person {identity}", + predicate = "works_at", + @object = $"Company {identity}", + confidence = 0.9, + }; + }), + preferences = keys.Select(key => new + { + source_session = key, + category = "drink", + preference = useLexicalIdentity ? $"prefers {Identity(key)} tea" : "prefers tea", + confidence = 0.9, + }), + relations = keys.Select(key => + { + var identity = Identity(key); + return new + { + source_session = key, + source = $"Person {identity}", + target = $"Company {identity}", + relation_type = "WORKS_AT", + confidence = 0.9, + }; + }), + }); + } + public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, diff --git a/tools/AgentMemory.Cli/Perf/TraceLogWriter.cs b/tools/AgentMemory.Cli/Perf/TraceLogWriter.cs index d56187b8..b131c61c 100644 --- a/tools/AgentMemory.Cli/Perf/TraceLogWriter.cs +++ b/tools/AgentMemory.Cli/Perf/TraceLogWriter.cs @@ -34,6 +34,7 @@ public sealed class TraceLogWriter : IDisposable "db.query.fingerprint", "db.records", "db.bytes_est", + "db.transaction_entry_ms_est", "memory.access_tracking.items", "memory.store.message_count", "memory.extract.source_messages", @@ -86,6 +87,7 @@ public void TurnEnd(TurnRecord turn) => phase = turn.Phase, durUs = (long)(turn.DurationMs * 1000), counters = turn.Counters, + samples = turn.Samples, }); public void RunEnd(int turns, double durationMs) => diff --git a/tools/AgentMemory.Cli/Perf/TurnRecord.cs b/tools/AgentMemory.Cli/Perf/TurnRecord.cs index 002d0085..3e34ad27 100644 --- a/tools/AgentMemory.Cli/Perf/TurnRecord.cs +++ b/tools/AgentMemory.Cli/Perf/TurnRecord.cs @@ -16,6 +16,7 @@ public sealed class TurnRecord private readonly Dictionary _queryFingerprints = new(StringComparer.Ordinal); private readonly Dictionary _spanMs = new(StringComparer.Ordinal); private readonly Dictionary _spanCount = new(StringComparer.Ordinal); + private readonly Dictionary> _samples = new(StringComparer.Ordinal); public TurnRecord(string scenario, int iteration, string phase) { @@ -69,6 +70,17 @@ public void RecordSpan(string name, double milliseconds) } } + /// Records one raw numeric sample for a distribution derived after the turn. + public void RecordSample(string name, double value) + { + lock (_gate) + { + if (!_samples.TryGetValue(name, out var values)) + _samples[name] = values = []; + values.Add(value); + } + } + /// Reads a counter, or 0 when it never fired. Used by scenario self-assertions. public long Counter(string name) { @@ -94,4 +106,18 @@ public IReadOnlyDictionary SpanCounts { get { lock (_gate) return new Dictionary(_spanCount, StringComparer.Ordinal); } } + + public IReadOnlyDictionary> Samples + { + get + { + lock (_gate) + { + return _samples.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value.ToArray(), + StringComparer.Ordinal); + } + } + } } diff --git a/tools/AgentMemory.Cli/Program.cs b/tools/AgentMemory.Cli/Program.cs index afdba23b..bff1dca3 100644 --- a/tools/AgentMemory.Cli/Program.cs +++ b/tools/AgentMemory.Cli/Program.cs @@ -72,6 +72,22 @@ cli.Get("output")); } + if (string.Equals(cli.Subcommand, "ledger", StringComparison.OrdinalIgnoreCase)) + { + if (cli.Positionals.Count < 2 || + !string.Equals(cli.Positionals[1], "add", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine("error: perf ledger requires the 'add' operation."); + return 1; + } + + return await new AgentMemory.Cli.Commands.PerfLedgerCommand(Console.Out).ExecuteAsync( + cli.Get("run"), + cli.Get("compared-to"), + cli.Get("verdict"), + cli.Get("ledger")); + } + if (string.Equals(cli.Subcommand, "cold", StringComparison.OrdinalIgnoreCase)) { return await new AgentMemory.Cli.Commands.PerfColdCommand(Console.Out).ExecuteAsync( @@ -85,11 +101,22 @@ cli.Get("output")); } + if (string.Equals(cli.Subcommand, "concurrency", StringComparison.OrdinalIgnoreCase)) + { + return await new AgentMemory.Cli.Commands.PerfConcurrencyCommand(Console.Out).ExecuteAsync( + cli.Get("label"), + cli.Get("levels"), + cli.Get("pool-size"), + cli.Get("embedding-dimensions"), + cli.Get("output")); + } + + if (cli.Subcommand is not null && !string.Equals(cli.Subcommand, "run", StringComparison.OrdinalIgnoreCase)) { Console.Error.WriteLine( - $"error: unknown perf subcommand '{cli.Subcommand}'. Use 'run', 'cold', 'ab', 'baseline', or 'gate'."); + $"error: unknown perf subcommand '{cli.Subcommand}'. Use 'run', 'cold', 'concurrency', 'ab', 'ledger', 'baseline', or 'gate'."); return 1; } @@ -105,7 +132,10 @@ cli.Get("quality-gate"), cli.HasFlag("single-shot") ? cli.Get("single-shot") ?? bool.TrueString - : null); + : null, + cli.Get("batch-resolution-snapshots"), + cli.Get("coalesced-persistence"), + cli.Get("pool-size")); } catch (Exception ex) { diff --git a/tools/AgentMemory.LongMemEval/AgentMemory.LongMemEval.csproj b/tools/AgentMemory.LongMemEval/AgentMemory.LongMemEval.csproj new file mode 100644 index 00000000..6e52e012 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/AgentMemory.LongMemEval.csproj @@ -0,0 +1,41 @@ + + + + Exe + AgentMemory.LongMemEval + false + + + + + + + + + + + + + + + + + + + + diff --git a/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.BatchPreparation.cs b/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.BatchPreparation.cs new file mode 100644 index 00000000..df031a2f --- /dev/null +++ b/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.BatchPreparation.cs @@ -0,0 +1,234 @@ +using System.Globalization; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; + +namespace AgentMemory.LongMemEval; + +public sealed partial class AgentMemoryLongMemEvalAdapter +{ + private async Task ExecuteBatchedPreparationAsync( + IReadOnlyList messages, + LongMemEvalEvidenceQuestion evidenceQuestion, + string sessionId, + string ownerId, + int questionNumber, + LongMemEvalStageTimingCollector timings, + CancellationToken cancellationToken) + { + var requests = BuildExtractionRequests( + messages, + evidenceQuestion, + sessionId, + ownerId); + var planner = _options.BatchPlanner!; + var plan = planner.Plan( + requests, + _options.MaxSessionsPerBatch, + _options.MaxInputTokens); + if (!PlansMatch(plan, _options.ExpectedExtractionPlan!)) + { + throw new InvalidOperationException( + $"LongMemEval question {questionNumber} batch plan changed after preflight."); + } + + _options.ExtractionProgress?.Invoke(0, requests.Count); + if (_chatClient is not LongMemEvalChatCallMeter callMeter) + { + throw new InvalidOperationException( + "Batched LongMemEval preparation requires scoped provider-call accounting."); + } + + var callScope = $"prepared-question-{questionNumber:D4}"; + var callsBefore = callMeter.SnapshotScope(callScope); + IReadOnlyList results; + using (callMeter.BeginScope(callScope)) + { + results = await timings.MeasureAsync( + LongMemEvalStage.ExtractionPersistence, + () => LongMemEvalRuntime.ExecuteStageAsync( + "batched extraction", + () => _options.BatchExtractionPipeline!.ExtractBatchAsync( + requests, + _options.MaxSessionsPerBatch, + _options.MaxInputTokens, + cancellationToken))).ConfigureAwait(false); + } + + var callsAfter = callMeter.SnapshotScope(callScope); + var callDelta = callsAfter.Calls - callsBefore.Calls; + var failureDelta = callsAfter.Failures - callsBefore.Failures; + var purposeDelta = callsAfter.Purposes.ToDictionary( + pair => pair.Key, + pair => pair.Value - callsBefore.Purposes.GetValueOrDefault(pair.Key), + StringComparer.Ordinal); + var retryDelta = callsAfter.RetryCalls - callsBefore.RetryCalls; + var unifiedBatchCalls = purposeDelta.GetValueOrDefault("unified_batch"); + var otherCalls = purposeDelta + .Where(pair => !string.Equals(pair.Key, "unified_batch", StringComparison.Ordinal)) + .Sum(pair => pair.Value); + // The invariant is "every batch produced exactly one SUCCESSFUL unified-batch call", not "no + // provider error ever occurred". The latter is not something a 614-call run over a network + // can promise, and requiring it made two n=50 preparations abort mid-run on one transient. + // A recovered transport retry is exactly one extra call plus one failure, so subtracting + // failures recovers the successful count without loosening what is actually being checked: + // an unrecovered failure still throws before reaching here, and a spurious extra call still + // trips the comparison. Failures are reported rather than required to be zero. + var successfulCalls = callDelta - failureDelta; + var successfulUnifiedBatchCalls = unifiedBatchCalls - failureDelta; + // Excess calls must be EXPLAINED, not absent. The previous equality demanded that nothing + // ever went wrong, which made the harness incompatible with the recovery paths it ships: + // a parse retry re-prompts, and a batch split re-sends the halves, and both legitimately + // add calls. Three consecutive 15-40 minute preparations died on that, the last one on a + // genuine parse-or-format split doing exactly what it is designed to do. + // + // Correctness is not what this checks and never was - the session-set comparison below is, + // and it is unchanged: every planned source session must be persisted, in chronological + // order, all succeeded. This is a COST guard, so the invariant it should express is "no + // unaccounted work": at least the planned calls happened, nothing of an unexpected purpose + // ran, and any excess is attributable to a recorded split or retry. + var recordedSplits = _options.BatchSplitCount?.Invoke() ?? 0; + var excessCalls = successfulUnifiedBatchCalls - plan.BatchCount; + if (!IsBatchAccountingAcceptable( + successfulCalls, successfulUnifiedBatchCalls, otherCalls, + recordedSplits, retryDelta, plan.BatchCount)) + { + // The provider status is what separates "we are being rate limited" from "the request + // was malformed" or "the service failed", and they need opposite responses: lower + // concurrency, fix the request, or retry. Without it a 37-minute preparation aborts with + // an exception type and no way to choose. Status codes carry no content. + var failureSummary = string.Join( + ',', + callsAfter.Failures > callsBefore.Failures + ? callMeter.Snapshot().FailureDetails + .Select(failure => + $"{failure.Purpose}:{failure.ExceptionType}" + + $":status={failure.ProviderStatus?.ToString(CultureInfo.InvariantCulture) ?? "none"}") + .Distinct(StringComparer.Ordinal) + : []); + throw new LongMemEvalExtractionAccountingException( + $"LongMemEval batched extraction accounting mismatch at question {questionNumber}: " + + $"observed {callDelta} calls ({successfulCalls} successful), {failureDelta} " + + $"recovered failures, {unifiedBatchCalls} unified-batch calls, and {otherCalls} " + + $"other calls; expected at least {plan.BatchCount} SUCCESSFUL unified-batch calls, " + + $"no other calls, and any excess explained by a recorded split or retry " + + $"(splits={recordedSplits}, retries={retryDelta}, excess={excessCalls})." + + (failureSummary.Length == 0 ? "" : $" Provider failures: {failureSummary}.")); + } + + var plannedSessions = plan.Batches + .SelectMany(batch => batch.SourceSessionIds) + .ToArray(); + var returnedSessions = results + .Select(result => + result.Metadata.TryGetValue("sessionId", out var value) + ? value as string + : null) + .ToArray(); + if (results.Count != requests.Count || + results.Any(result => result.Status != IngestionStatus.Succeeded) || + !returnedSessions.SequenceEqual(plannedSessions, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"LongMemEval question {questionNumber} did not persist every planned source session in chronological order."); + } + + _options.ExtractionProgress?.Invoke(results.Count, requests.Count); + return new LongMemEvalBatchedPreparationResult( + results.Count, + plan.BatchCount); + } + + internal static IReadOnlyList BuildExtractionRequests( + IReadOnlyList messages, + LongMemEvalEvidenceQuestion evidenceQuestion, + string sessionId, + string ownerId) + { + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(evidenceQuestion); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); + if (messages.Count != evidenceQuestion.Messages.Count) + { + throw new InvalidOperationException( + "LongMemEval extraction messages do not match source provenance."); + } + + return messages + .Select((message, index) => + (Message: message, Origin: evidenceQuestion.Messages[index])) + .Where(item => + !item.Origin.IsSyntheticBoundary && + !item.Origin.IsSyntheticFormatterPadding) + .GroupBy(item => item.Origin.SourceSessionOrdinal) + .OrderBy(group => group.Key) + .Select(group => new ExtractionRequest + { + Messages = group.Select(item => item.Message).ToArray(), + SessionId = $"{sessionId}-source-{group.Key:D4}", + UserId = ownerId, + TypesToExtract = ExtractionTypes.All + }) + .OrderBy(request => request.Messages + .Select(message => message.TimestampUtc) + .DefaultIfEmpty(DateTimeOffset.MinValue) + .Min()) + .ThenBy(request => request.SessionId, StringComparer.Ordinal) + .ToArray(); + } + + + /// + /// Whether a question's provider-call accounting is acceptable: no unaccounted work. + /// + /// + /// This is a cost guard, not a correctness one — the session-set comparison that follows + /// it is what proves every planned source session was persisted, in order, successfully, and that + /// check is unchanged. + /// + /// It previously demanded exactly the planned number of calls and zero failures, which is + /// to say it demanded that nothing ever went wrong. That made it incompatible with the recovery + /// paths the extractor ships: a parse retry re-prompts and a batch split re-sends the halves, and + /// both legitimately add calls. Three consecutive 15–40 minute preparations died on it, the last + /// on a parse-or-format split doing precisely what it exists to do. + /// + /// + /// The invariant it should express is "every extra call is attributable": at least the + /// planned work happened, nothing of an unexpected purpose ran, and any excess coincides with a + /// recorded split or retry. Excess with no recorded recovery is still rejected — that is the case + /// worth catching, and it is the one the guard was really for. + /// + /// + internal static bool IsBatchAccountingAcceptable( + long successfulCalls, + long successfulUnifiedBatchCalls, + long otherCalls, + long recordedSplits, + long recordedRetries, + int plannedBatchCount) + { + if (otherCalls != 0) + return false; + if (successfulCalls < plannedBatchCount || successfulUnifiedBatchCalls < plannedBatchCount) + return false; + + var excess = successfulUnifiedBatchCalls - plannedBatchCount; + return excess == 0 || recordedSplits > 0 || recordedRetries > 0; + } + + private static bool PlansMatch( + MultiSessionExtractionPlan left, + MultiSessionExtractionPlan right) => + left.BatchCount == right.BatchCount && + left.SourceSessionCount == right.SourceSessionCount && + left.TotalEstimatedInputTokens == right.TotalEstimatedInputTokens && + left.Batches.Zip(right.Batches).All(pair => + pair.First.EstimatedInputTokens == pair.Second.EstimatedInputTokens && + pair.First.SourceSessionIds.SequenceEqual( + pair.Second.SourceSessionIds, + StringComparer.Ordinal)); + + private sealed record LongMemEvalBatchedPreparationResult( + int ExtractionUnits, + int PlannedCalls); +} diff --git a/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.cs b/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.cs new file mode 100644 index 00000000..5d001407 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/AgentMemoryLongMemEvalAdapter.cs @@ -0,0 +1,1649 @@ +using AgentMemory.Core.Memory; +using System.Collections.ObjectModel; +using System.Text; +using AgentEval.Core; +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// Adapts AgentMemory to AgentEval's LongMemEval runner without leaving the injected history in the +/// answer model's context. History is buffered by the synchronous AgentEval capability method, then +/// batch-persisted and semantically recalled before the question is sent to the answer model. +/// +public sealed partial class AgentMemoryLongMemEvalAdapter : + IEvaluableAgent, + IHistoryInjectableAgent, + ISessionResettableAgent +{ + internal const string SystemPrompt = + "Answer the question using only the retrieved memory below. " + + "Be concise and do not claim information that is absent from memory."; + + private readonly IMemoryService _memory; + private readonly IChatClient _chatClient; + private readonly string _runId; + private readonly LongMemEvalAdapterOptions _options; + private readonly object _stateLock = new(); + private readonly List _telemetry = []; + private IReadOnlyList<(string UserMessage, string AssistantResponse)>? _pendingHistory; + private int _questionNumber; + private string _sessionId; + private string _ownerId; + + public AgentMemoryLongMemEvalAdapter( + IMemoryService memory, + IChatClient chatClient, + string runId, + LongMemEvalAdapterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + + _memory = memory; + _chatClient = chatClient; + _runId = Sanitize(runId); + _options = options ?? new LongMemEvalAdapterOptions(); + if (_options.PreparedMemory && + (!_options.MemoryMode.UsesExtraction() || + !_options.RequireGraphReadBack || + _options.GraphProbe is null || + _options.EvidenceIndex is null || + _options.PreparedState is null)) + { + throw new ArgumentException( + "Prepared LongMemEval evaluation requires structured memory, sealed state, evidence, and graph read-back verification.", + nameof(options)); + } + if (_options.PreparedMemory && + (!string.Equals( + _options.PreparedState!.Manifest.AnswerModelId, + _options.ModelId, + StringComparison.Ordinal) || + _options.PreparedState.Manifest.MaxRelevantMessages != _options.MaxRelevantMessages)) + { + throw new ArgumentException( + "Prepared LongMemEval adapter configuration does not match the sealed manifest.", + nameof(options)); + } + if (_options.PreparationOnly && + (!_options.MemoryMode.UsesExtraction() || + !_options.RequireGraphReadBack || + _options.GraphProbe is null || + _options.EvidenceIndex is null || + _options.PreparedMemory)) + { + throw new ArgumentException( + "LongMemEval preparation requires unprepared structured memory, evidence, and graph read-back verification.", + nameof(options)); + } + + if (_options.DiagnosticSourceSessionOrdinal is < 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "The diagnostic source-session ordinal must be non-negative."); + } + if (_options.DiagnosticSourceSessionOrdinal is not null && + !_options.PreparationOnly) + { + throw new ArgumentException( + "A diagnostic source-session selector is valid only for preparation-only execution.", + nameof(options)); + } + if (_options.InitialQuestionNumber < 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "The initial question number must be non-negative."); + } + if (_options.UseBatchedPreparation && + (!_options.PreparationOnly || + _options.DiagnosticSourceSessionOrdinal is not null || + _options.BatchExtractionPipeline is null || + _options.BatchPlanner is null || + _options.ExpectedExtractionPlan is null || + _options.MaxSessionsPerBatch <= 0 || + _options.MaxInputTokens <= 0)) + { + throw new ArgumentException( + "Batched LongMemEval preparation requires an ordinary preparation run, the batch pipeline, deterministic planner, expected plan, and positive batch limits.", + nameof(options)); + } + if (_options.ExpandFactsByPredicate) + { + // Checked here rather than discovered mid-run: this exact overflow cost two full + // 121-call rebuilds to surface as an opaque diagnostics error. Worst case is every + // category filled plus every expanded fact, and AgentEval rejects the envelope above + // MaximumReferences. + var budget = LongMemEvalRecallBudget.For( + _options.MemoryMode, _options.MaxRelevantMessages) with + { + GraphRag = _options.GraphRagItems + }; + var worstCaseReferences = + budget.Messages + budget.Entities + budget.Facts + budget.Preferences + + _options.MaxExpandedFacts; + if (worstCaseReferences > AgentEval.Memory.External.Models.QuestionEvidenceEnvelope.MaximumReferences) + { + throw new ArgumentException( + $"Predicate expansion would produce up to {worstCaseReferences} evidence " + + $"references, exceeding AgentEval's maximum of " + + $"{AgentEval.Memory.External.Models.QuestionEvidenceEnvelope.MaximumReferences}. " + + $"Lower MaxExpandedFacts (currently {_options.MaxExpandedFacts}) or the recall " + + "budget so the total fits.", + nameof(options)); + } + } + + _questionNumber = _options.InitialQuestionNumber; + _sessionId = ScopeId("session", _questionNumber); + _ownerId = ScopeId("owner", _questionNumber); + } + + public string Name => "AgentMemory.LongMemEval"; + + public IReadOnlyList QuestionTelemetry + { + get + { + lock (_stateLock) + return new ReadOnlyCollection(_telemetry.ToArray()); + } + } + + public void InjectConversationHistory( + IEnumerable<(string UserMessage, string AssistantResponse)> conversationTurns) + { + ArgumentNullException.ThrowIfNull(conversationTurns); + var materialized = conversationTurns.ToArray(); + lock (_stateLock) + { + if (_pendingHistory is not null) + { + throw new InvalidOperationException( + "LongMemEval history was injected more than once for the same question."); + } + + _pendingHistory = materialized; + } + } + + public Task ResetSessionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_stateLock) + { + _questionNumber++; + _sessionId = ScopeId("session", _questionNumber); + _ownerId = ScopeId("owner", _questionNumber); + _pendingHistory = null; + } + + return Task.CompletedTask; + } + + public async Task InvokeAsync( + string prompt, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + var timings = new LongMemEvalStageTimingCollector(); + + IReadOnlyList<(string UserMessage, string AssistantResponse)> history; + string sessionId; + string ownerId; + int questionNumber; + lock (_stateLock) + { + history = _pendingHistory + ?? throw new InvalidOperationException( + "LongMemEval question cannot run before conversation history is injected."); + if (history.Count == 0) + { + throw new InvalidOperationException( + "LongMemEval question cannot run with empty conversation history."); + } + + _pendingHistory = null; + sessionId = _sessionId; + ownerId = _ownerId; + questionNumber = _questionNumber; + } + + LongMemEvalEvidenceQuestion? evidenceQuestion = null; + try + { + if (_options.EvidenceIndex is not null) + evidenceQuestion = _options.EvidenceIndex.Resolve(history, prompt); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry(questionNumber, 0, 0, false, "evidence-resolution-error"); + throw; + } + + var originsByMessageId = new Dictionary(StringComparer.Ordinal); + var messages = BuildMessages( + _runId, history, sessionId, ownerId, questionNumber, evidenceQuestion, originsByMessageId); + + LongMemEvalPreparedQuestion? preparedQuestion = null; + if (_options.PreparedMemory) + { + try + { + preparedQuestion = _options.PreparedState!.ValidateQuestion( + questionNumber, evidenceQuestion!, history, sessionId, ownerId); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry( + questionNumber, 0, 0, false, "prepared-manifest-mismatch", + evidenceQuestion?.QuestionId); + throw; + } + } + + var messagesStored = 0; + if (!_options.PreparedMemory) + { + try + { + // G3B.9 root fix. AgentEval's formatter has no channel for session structure in an + // API that only accepts (user, assistant) pairs, so it fabricates a turn per session + // boundary — `LongMemEvalHistoryFormatter.cs:39`. The assistant half + // ("Understood. Starting a new conversation session.") is pure fabrication carrying + // zero information, and the user half's session id and date are already attached to + // every real message as provenance metadata. Persisting them was therefore storing + // 21% redundant corpus whose *identical* content produced identical embeddings, tying + // and monopolising top-K — 46 byte-identical copies in one question. + // + // Excluding them at the write boundary removes the flood at its source rather than + // filtering it back out at retrieval. `messages` itself is left whole because the + // extraction path indexes it positionally against the evidence origins. + var persisted = SelectPersistableMessages(messages, originsByMessageId); + _ = await timings.MeasureAsync( + LongMemEvalStage.Storage, + () => LongMemEvalRuntime.ExecuteStageAsync( + "storage", + () => _memory.AddMessagesAsync(persisted, cancellationToken))).ConfigureAwait(false); + messagesStored = persisted.Count; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry(questionNumber, 0, 0, false, "storage-error"); + throw; + } + } + + var extractionUnits = 0; + var extractionCallsPlanned = 0; + LongMemEvalGraphSnapshot? graphSnapshot = null; + LongMemEvalGoldEvidenceCoverage? goldCoverage = null; + if (_options.MemoryMode.UsesExtraction()) + { + if (evidenceQuestion is null) + { + RecordTelemetry( + questionNumber, messages.Count, 0, false, "extraction-provenance-missing"); + throw new InvalidOperationException( + "Structured LongMemEval modes require source-session provenance."); + } + + if (!_options.PreparedMemory) + { + if (_options.UseBatchedPreparation) + { + try + { + var batch = await ExecuteBatchedPreparationAsync( + messages, + evidenceQuestion, + sessionId, + ownerId, + questionNumber, + timings, + cancellationToken).ConfigureAwait(false); + extractionUnits = batch.ExtractionUnits; + extractionCallsPlanned = batch.PlannedCalls; + } + catch (LongMemEvalExtractionAccountingException) + { + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + "extraction-provider-accounting-error", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits, + extractionCallsPlanned: + _options.ExpectedExtractionPlan!.BatchCount); + throw; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + "extraction-error", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits); + throw; + } + } + else + { + var allExtractionGroups = messages + .Select((message, index) => + (Message: message, Origin: evidenceQuestion.Messages[index])) + .Where(item => + !item.Origin.IsSyntheticBoundary && + !item.Origin.IsSyntheticFormatterPadding) + .GroupBy(item => item.Origin.SourceSessionOrdinal) + .OrderBy(group => group.Key) + .ToArray(); + var extractionGroups = + _options.DiagnosticSourceSessionOrdinal is { } selected + ? allExtractionGroups + .Where(group => group.Key == selected) + .ToArray() + : allExtractionGroups; + if (_options.DiagnosticSourceSessionOrdinal is not null && + extractionGroups.Length != 1) + throw new InvalidOperationException( + "The diagnostic source-session ordinal does not exist in the selected question."); + _options.ExtractionProgress?.Invoke(0, extractionGroups.Length); + + + foreach (var group in extractionGroups) + { + var sourceMessages = group.Select(item => item.Message).ToArray(); + if (sourceMessages.Length == 0) + continue; + + var callsBefore = _options.PreparationOnly && + _chatClient is LongMemEvalChatCallMeter callMeter + ? callMeter.Snapshot() : null; + try + { + var extraction = await timings.MeasureAsync( + LongMemEvalStage.ExtractionPersistence, + () => LongMemEvalRuntime.ExecuteStageAsync( + "extraction", + () => _memory.ExtractAndPersistAsync( + new ExtractionRequest + { + Messages = sourceMessages, + SessionId = $"{sessionId}-source-{group.Key:D4}", + UserId = ownerId + }, + cancellationToken))).ConfigureAwait(false); + extractionUnits++; + _options.ExtractionProgress?.Invoke(extractionUnits, extractionGroups.Length); + if (callsBefore is not null && + _chatClient is LongMemEvalChatCallMeter extractionCallMeter) + { + var callsAfter = extractionCallMeter.Snapshot(); + var callDelta = callsAfter.Calls - callsBefore.Calls; + var failureDelta = callsAfter.Failures - callsBefore.Failures; + if (callDelta != 4 || failureDelta != 0) + { + var callDetails = callsAfter.CallDetails + .Where(detail => detail.CallOrdinal > callsBefore.Calls) + .ToArray(); + var purposeSummary = string.Join( + ", ", + callDetails + .GroupBy(detail => detail.Purpose) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => $"{group.Key}={group.Count()}")); + var callDetailSuffix = purposeSummary.Length == 0 + ? string.Empty + : $" Call purposes: {purposeSummary}."; + var missingCallDetails = + Math.Max(0, callDelta - callDetails.LongLength); + var failureDetails = callsAfter.FailureDetails + .Where(detail => detail.CallOrdinal > callsBefore.Calls) + .Select(detail => + $"call {detail.CallOrdinal}, purpose {detail.Purpose}, " + + $"exception {detail.ExceptionType}, status " + + $"{detail.ProviderStatus?.ToString() ?? "none"}") + .ToArray(); + var detailSuffix = failureDetails.Length == 0 + ? string.Empty + : $" Failure details: {string.Join("; ", failureDetails)}."; + var droppedDelta = + callsAfter.DroppedFailureDetails - + callsBefore.DroppedFailureDetails; + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + "extraction-provider-accounting-error", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits); + throw new LongMemEvalExtractionAccountingException( + $"LongMemEval extraction provider accounting mismatch at " + + $"question {questionNumber}, source session {group.Key}: " + + $"observed {callDelta} calls and {failureDelta} failures; " + + $"expected exactly 4 calls and zero failures.{callDetailSuffix}" + + $"{detailSuffix} Missing unit call details: {missingCallDetails}. " + + $"Dropped call details total: {callsAfter.DroppedCallDetails}. " + + $"Dropped failure details: {droppedDelta}."); + } + } + if (extraction.Status != IngestionStatus.Succeeded) + { + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + "extraction-incomplete", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits); + throw new InvalidOperationException( + $"LongMemEval extraction unit {group.Key} did not complete successfully."); + } + } + catch (LongMemEvalExtractionAccountingException) + { + throw; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + "extraction-error", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits); + throw; + } + } + } + } + + if (_options.RequireGraphReadBack) + { + if (_options.GraphProbe is null) + { + throw new InvalidOperationException( + "Structured LongMemEval modes require a graph read-back probe."); + } + + graphSnapshot = await timings.MeasureAsync( + LongMemEvalStage.GraphReadBack, + () => LongMemEvalRuntime.ExecuteStageAsync( + "graph read-back", + () => _options.GraphProbe.ReadAsync(ownerId, cancellationToken))) + .ConfigureAwait(false); + if (graphSnapshot.TotalLearned == 0 || !graphSnapshot.CompleteProvenance) + { + RecordTelemetry( + questionNumber, + messages.Count, + 0, + false, + graphSnapshot.TotalLearned == 0 + ? "graph-readback-empty" + : "graph-provenance-incomplete", + evidenceQuestion.QuestionId, + extractionUnits: extractionUnits, + graphSnapshot: graphSnapshot); + throw new InvalidOperationException( + "LongMemEval graph read-back did not prove non-empty learned memory with complete provenance."); + } + + // G3B.5. Soundness is proven above; this asks whether the build is *adequate* — + // whether anything was learned from the sessions that actually hold the answer. + // Checked here, before any evaluation call is spent on this graph. + var goldSourceMessageIds = originsByMessageId + .Where(entry => + evidenceQuestion.AnswerSessionIds.Contains(entry.Value.SourceSessionId)) + .Select(entry => entry.Key) + .ToArray(); + goldCoverage = await _options.GraphProbe + .ReadGoldCoverageAsync(ownerId, goldSourceMessageIds, cancellationToken) + .ConfigureAwait(false); + + if (preparedQuestion is not null && + !Equals(graphSnapshot, preparedQuestion.GraphSnapshot)) + { + RecordTelemetry( + questionNumber, + 0, + 0, + false, + "prepared-graph-mismatch", + evidenceQuestion.QuestionId, + graphSnapshot: graphSnapshot, + messagesPrepared: preparedQuestion.MessagesPrepared, + extractionUnitsPrepared: preparedQuestion.ExtractionUnitsPrepared, + preparedMemory: true); + throw new InvalidOperationException( + $"Prepared LongMemEval graph state does not match the sealed snapshot for question {questionNumber}."); + } + } + } + + if (_options.PreparationOnly) + { + RecordTelemetry( + questionNumber, messagesStored, 0, false, "prepared", + evidenceQuestion!.QuestionId, extractionUnits: extractionUnits, + graphSnapshot: graphSnapshot, stageTimings: timings.Snapshot(), + extractionCallsPlanned: extractionCallsPlanned, + goldCoverage: goldCoverage); + return new AgentResponse { Text = string.Empty, ModelId = _options.ModelId }; + } + + // Hoisted out of the try: the retrieval-evidence build below needs the message allowance to + // tell "retrieval missed" apart from "retrieval was never given a message budget" (BUG-E1). + var budget = LongMemEvalRecallBudget.For( + _options.MemoryMode, _options.MaxRelevantMessages) with + { + GraphRag = _options.GraphRagItems + }; + // G3B.1 over-fetches candidates so that dropping formatter artifacts still fills the budget. + // The final cap stays `budget.Messages`; only the request widens. + var requestedMessages = _options.ExcludeSyntheticFormatterMessages + ? budget.Messages * _options.SyntheticExclusionCandidateMultiplier + : budget.Messages; + RecallResult recall; + try + { + recall = await timings.MeasureAsync( + LongMemEvalStage.Retrieval, + () => LongMemEvalRuntime.ExecuteStageAsync( + "retrieval", + () => _memory.RecallAsync( + new RecallRequest + { + SessionId = sessionId, + UserId = ownerId, + Query = prompt, + Options = new RecallOptions + { + MaxRecentMessages = 0, + MaxRelevantMessages = requestedMessages, + MaxEntities = budget.Entities, + MaxPreferences = budget.Preferences, + MaxFacts = budget.Facts, + MaxTraces = 0, + // G5 "hard" tier: a relation returned whole, for the aggregation + // questions top-K structurally cannot answer. + ExpandFactsByPredicate = _options.ExpandFactsByPredicate, + // J2.2: also expand on the relations the question itself names, for + // the multi-relation case top-K structurally cannot nominate. + ResolveQueryRelations = _options.ResolveQueryRelations, + MaxExpandedFacts = _options.MaxExpandedFacts, + MaxGraphRagItems = budget.GraphRag, + MinSimilarityScore = _options.MinSimilarityScore, + BlendMode = BlendModeFor(budget.GraphRag), + IncludeDiagnostics = evidenceQuestion is not null + } + }, + cancellationToken))).ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry(questionNumber, messages.Count, 0, false, "retrieval-exception"); + throw; + } + + if (recall.TotalItemsRetrieved == 0) + { + RecordTelemetry(questionNumber, messages.Count, 0, recall.Truncated, "retrieval-empty"); + throw new InvalidOperationException( + $"AgentMemory retrieved no history for LongMemEval question {questionNumber}; refusing to manufacture a score."); + } + + if (_options.ExcludeSyntheticFormatterMessages) + { + recall = recall with + { + Context = recall.Context with + { + RelevantMessages = LongMemEvalRecallBudget.SelectRealSourceTurns( + recall.Context.RelevantMessages, + originsByMessageId, + budget.Messages, + _options.MaxItemsPerSourceSession) + } + }; + } + + var recalled = recall.Context.RelevantMessages.Items; + var structuredItems = + recall.Context.RelevantEntities.Items.Count + + recall.Context.RelevantFacts.Items.Count + + recall.Context.RelevantPreferences.Items.Count; + if (_options.MemoryMode == LongMemEvalMemoryMode.Raw && recalled.Count == 0) + { + RecordTelemetry(questionNumber, messages.Count, recall.TotalItemsRetrieved, recall.Truncated, "retrieval-messages-empty"); + throw new InvalidOperationException( + $"AgentMemory reported recalled items but no relevant messages for LongMemEval question {questionNumber}."); + } + + if (_options.MemoryMode == LongMemEvalMemoryMode.Structured && structuredItems == 0) + { + RecordTelemetry( + questionNumber, + messages.Count, + recall.TotalItemsRetrieved, + recall.Truncated, + "retrieval-structured-empty", + evidenceQuestion?.QuestionId, + extractionUnits: extractionUnits); + throw new InvalidOperationException( + $"AgentMemory retrieved no structured memory for LongMemEval question {questionNumber}."); + } + + if (_options.ChronologicalAnswerContext) + { + // The stored clock is epoch + injection ordinal, so ordering by it reproduces the + // conversation's own sequence exactly. Selection is unchanged; only the order differs. + recall = recall with + { + Context = recall.Context with + { + RelevantMessages = recall.Context.RelevantMessages with + { + Items = recall.Context.RelevantMessages.Items + .OrderBy(message => message.TimestampUtc) + .ToArray() + } + } + }; + } + + var answerPrompt = BuildAnswerPrompt( + recall.Context, prompt, evidenceQuestion?.QuestionDate, originsByMessageId); + LongMemEvalRetrievalEvidence? retrievalEvidence = null; + AgentEval.Memory.External.Models.QuestionEvidenceEnvelope? normalizedEvidence = null; + if (evidenceQuestion is not null) + { + try + { + retrievalEvidence = LongMemEvalRetrievalEvidence.Build( + evidenceQuestion, + recalled, + recall.Context.RelevantMessages.RankedItems, + originsByMessageId, + _options.EvidenceDetail, + answerPrompt.Length, + budget.Messages); + if (_options.EvidenceDetail != LongMemEvalEvidenceDetail.None) + { + normalizedEvidence = LongMemEvalAgentEvalEvidence.Build( + recall.Context, originsByMessageId, _options.EvidenceDetail); + } + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + // Content-free but specific: the type and message locate the failing builder, which + // a bare status cannot. Diagnosing this by inspection previously cost two full + // 121-call rebuilds. + RecordTelemetry( + questionNumber, + messages.Count, + recall.TotalItemsRetrieved, + recall.Truncated, + $"retrieval-diagnostics-error:{exception.GetType().Name}:{exception.Message}", + evidenceQuestion.QuestionId); + throw; + } + } + + ChatResponse response; + try + { + response = await timings.MeasureAsync( + LongMemEvalStage.Answer, + () => LongMemEvalRuntime.ExecuteStageAsync( + "answer", + () => _chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, SystemPrompt), + new ChatMessage(ChatRole.User, answerPrompt) + ], + cancellationToken: cancellationToken))).ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + RecordTelemetry(questionNumber, messages.Count, recall.TotalItemsRetrieved, recall.Truncated, "answer-error"); + throw; + } + + // L2. Ask the graph how many live facts exist under the relation(s) this question named. + // Null when no probe is wired or nothing resolved - "not measured", never "complete". + var relationStoredKeys = (recall.Context.ResolvedQueryRelations ?? []) + .SelectMany(MemoryRelationLexicon.Default.StoredFormsOf) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + IReadOnlyDictionary? relationGraphCounts = null; + if (_options.GraphProbe is not null && relationStoredKeys.Length > 0) + { + relationGraphCounts = await _options.GraphProbe + .ReadRelationFactCountsAsync(ownerId, relationStoredKeys, cancellationToken) + .ConfigureAwait(false); + } + + RecordTelemetry( + questionNumber, + messagesStored, + recall.TotalItemsRetrieved, + recall.Truncated, + "completed", + evidenceQuestion?.QuestionId, + retrievalEvidence, + extractionUnits, + recall.Context, + graphSnapshot, + timings.Snapshot(), + preparedQuestion?.MessagesPrepared ?? 0, + preparedQuestion?.ExtractionUnitsPrepared ?? 0, + preparedQuestion is not null, + goldCoverage: goldCoverage, + // Computed here rather than inside RecordTelemetry, which has neither the gold message + // origins nor the evidence question in scope. + // L2. The stored predicate keys expansion would have searched, widened exactly as + // LongTermMemoryService does - canonical relation -> every stored form - or the metric + // would measure a different query than the one that ran. + // Absent, not an all-null object, when nothing was measured - the same convention + // ReadGoldCoverageAsync uses, and what keeps "no probe wired" distinguishable from + // "measured and found nothing". + relationCompleteness: relationGraphCounts is null + ? null + : ComputeRelationCompleteness( + relationStoredKeys, + relationGraphCounts, + recall.Context.RelevantFacts.Items, + _options.MaxExpandedFacts), + retrievedGoldCoverage: RetrievedGoldCoverage( + recall.Context.RelevantFacts.Items, + originsByMessageId + .Where(entry => evidenceQuestion is not null && + evidenceQuestion.AnswerSessionIds.Contains( + entry.Value.SourceSessionId)) + .Select(entry => entry.Key) + .ToArray()), + answerPromptText: answerPrompt); + + var additionalProperties = new Dictionary + { + ["agentMemory.sessionId"] = sessionId, + ["agentMemory.ownerId"] = ownerId, + ["agentMemory.messagesStored"] = messagesStored, + ["agentMemory.itemsRetrieved"] = recall.TotalItemsRetrieved, + ["agentMemory.truncated"] = recall.Truncated + }; + if (normalizedEvidence is not null) + additionalProperties[AgentEval.Memory.External.Models.QuestionEvidenceEnvelope.AdditionalPropertiesKey] = + normalizedEvidence; + + return new AgentResponse + { + Text = response.Text ?? string.Empty, + ModelId = _options.ModelId, + AdditionalProperties = additionalProperties + }; + } + + private void RecordTelemetry( + int questionNumber, + int messagesStored, + int itemsRetrieved, + bool recallTruncated, + string status, + string? questionId = null, + LongMemEvalRetrievalEvidence? retrievalEvidence = null, + int extractionUnits = 0, + MemoryContext? context = null, + LongMemEvalGraphSnapshot? graphSnapshot = null, + LongMemEvalStageTimings? stageTimings = null, + int messagesPrepared = 0, + int extractionUnitsPrepared = 0, + bool preparedMemory = false, + int extractionCallsPlanned = 0, + LongMemEvalGoldEvidenceCoverage? goldCoverage = null, + double? retrievedGoldCoverage = null, + LongMemEvalRelationCompleteness? relationCompleteness = null, + string? answerPromptText = null) + { + lock (_stateLock) + { + _telemetry.Add(new LongMemEvalQuestionTelemetry( + questionNumber, messagesStored, itemsRetrieved, recallTruncated, status) + { + QuestionId = questionId, + RetrievalEvidence = retrievalEvidence, + ExtractionUnits = extractionUnits, + MessagesPrepared = messagesPrepared, + ExtractionUnitsPrepared = extractionUnitsPrepared, + PreparedMemory = preparedMemory, + RawMessagesRetrieved = context?.RelevantMessages.Items.Count ?? 0, + EntitiesRetrieved = context?.RelevantEntities.Items.Count ?? 0, + ExtractionCallsPlanned = extractionCallsPlanned, + FactsRetrieved = context?.RelevantFacts.Items.Count ?? 0, + PreferencesRetrieved = context?.RelevantPreferences.Items.Count ?? 0, + GraphRagIncluded = !string.IsNullOrWhiteSpace(context?.GraphRagContext), + // K6. "Included" only ever said the string was non-empty. These two say how many + // passages came back and how many of them the structured surface had already + // retrieved - the difference between a surface that adds evidence and one that + // re-fetches it. + // Runs on the PREPARED path, where the existing gold probe never does - it sits + // inside `if (!PreparedMemory)`, so every prepared-pair report has a null coverage + // and the n=50 result could not say why Structured loses multi-session questions. + RetrievedGoldCoverage = retrievedGoldCoverage, + RelationCompleteness = relationCompleteness, + // Makes "expansion had nothing to expand" visible per question, instead of + // requiring the lexicon to be consulted by hand after a run. + ResolvedQueryRelations = context?.ResolvedQueryRelations ?? [], + GraphRagItemsRetrieved = context?.GraphRagItems.Count ?? 0, + GraphRagFactsAlreadyRetrieved = context is null + ? 0 + : CountGraphRagFactsAlreadyRetrieved( + context.GraphRagItems, context.RelevantFacts.Items), + // J5.1. The real cost of this arm: the assembled prompt the reader actually sees. + // Every quality number here was half a result without it, and the band's cost column + // predates predicate expansion entirely. + AnswerPromptCharacters = answerPromptText?.Length ?? 0, + EstimatedContextTokens = LongMemEvalContextSize.Estimate(answerPromptText), + GraphReadBack = graphSnapshot, + GoldEvidenceCoverage = goldCoverage, + StageTimings = stageTimings + }); + } + } + + + + /// + /// K6. The blend mode a GraphRAG budget requires, and the reason a budget alone is not enough. + /// + /// + /// MemoryOnly means "GraphRAG suppressed even when enabled", and the assembler checks the + /// blend mode before the budget - so a non-zero MaxGraphRagItems under + /// MemoryOnly retrieves nothing at all. The first K6 run measured exactly that and came + /// within one step of reporting a confident zero as a property of the surface. + /// + /// Every arm keeps MemoryOnly unless a GraphRAG budget was actually asked for, so every + /// run this track has already produced stays comparable. + /// + /// + internal static RetrievalBlendMode BlendModeFor(int graphRagBudget) => + graphRagBudget > 0 ? RetrievalBlendMode.Blended : RetrievalBlendMode.MemoryOnly; + + + + /// + /// L2. Whether the context received every live fact under the relation(s) the question names. + /// + /// + /// The instrument Phase L exists to build, and the two numbers do different jobs. + /// + /// Denominator counts the graph and is a deterministic extraction-quality signal: + /// a Cypher count(), immune to answer-model and judge non-determinism. If a change stops + /// learning a relation the questions need, it drops and says so. Nothing else in this repository + /// can see that — the deterministic fixture sits at 1.000 by construction, and the LongMemEval + /// channel carries sd 9.3 cold-build. + /// + /// + /// Numerator counts the context and is a retrieval signal. Keeping them apart is the + /// point: Denominator = 0 with a non-empty key set means the relation was never + /// extracted, while Numerator < Denominator means it was extracted and retrieval left + /// some behind. A single ratio renders an extraction bug and a retrieval bug identical, which is + /// how the saturated message-coverage metric misled this track once already. + /// + /// + internal static LongMemEvalRelationCompleteness ComputeRelationCompleteness( + IReadOnlyList storedPredicateKeys, + IReadOnlyDictionary? graphCounts, + IReadOnlyCollection retrievedFacts, + int expansionLimit = 0) + { + ArgumentNullException.ThrowIfNull(storedPredicateKeys); + ArgumentNullException.ThrowIfNull(retrievedFacts); + + // No relation resolved, or the probe could not answer: null throughout. "Not measured" must + // never be reported as complete, and it must never be reported as zero either. + if (storedPredicateKeys.Count == 0 || graphCounts is null) + return new LongMemEvalRelationCompleteness { StoredPredicateKeys = storedPredicateKeys }; + + var keys = storedPredicateKeys.ToHashSet(StringComparer.Ordinal); + var denominator = graphCounts + .Where(pair => keys.Contains(pair.Key)) + .Sum(pair => pair.Value); + var numerator = retrievedFacts + .Where(fact => keys.Contains(MemoryTripleCanonicalizer.Canonical(fact.Predicate))) + .Select(fact => fact.FactId) + .Distinct(StringComparer.Ordinal) + .Count(); + + return new LongMemEvalRelationCompleteness + { + StoredPredicateKeys = storedPredicateKeys, + PerKeyGraphCounts = graphCounts.Where(p => keys.Contains(p.Key)) + .ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal), + Denominator = denominator, + Numerator = numerator, + // A relation the graph does not hold is an extraction miss, not a retrieval one, so the + // ratio stays null rather than becoming a misleading 0.0. + Ratio = denominator == 0 ? null : (double)numerator / denominator, + Complete = denominator == 0 ? null : numerator >= denominator, + RelationAbsentFromGraph = denominator == 0, + // Completeness is arithmetically impossible when the graph holds more than the single + // shared LIMIT can return. That is a budget fact, not a retrieval defect. + LimitBinding = expansionLimit > 0 && denominator > expansionLimit, + ExpansionLimit = expansionLimit, + }; + } + + /// + /// How much of a question's gold evidence the retrieved facts actually carry. + /// + /// + /// The existing gold-coverage probe asks whether anything was learned from the gold + /// sessions, and it runs only during preparation — so every prepared-pair report to date has a + /// null coverage figure, and the n=50 result could say Structured loses multi-session questions + /// without being able to say why. + /// + /// This is the retrieval-side half. A fact covers a gold message when its + /// SourceMessageIds contains it, so intersecting the retrieved facts' provenance with the + /// gold message set separates three very different failures that look identical in a score: + /// the evidence was never extracted, it was extracted but not retrieved, or it was retrieved and + /// the reader still got the answer wrong. Only the second is a retrieval problem. + /// + /// + /// Returns null when the question has no gold messages, because zero coverage of nothing is not + /// a miss and must not be averaged in as one. + /// + /// + internal static double? RetrievedGoldCoverage( + IReadOnlyCollection retrievedFacts, + IReadOnlyCollection goldSourceMessageIds) + { + ArgumentNullException.ThrowIfNull(retrievedFacts); + ArgumentNullException.ThrowIfNull(goldSourceMessageIds); + if (goldSourceMessageIds.Count == 0) + return null; + + var covered = retrievedFacts + .SelectMany(fact => fact.SourceMessageIds) + .ToHashSet(StringComparer.Ordinal); + return (double)goldSourceMessageIds.Count(covered.Contains) / goldSourceMessageIds.Count; + } + + /// + /// K6. How many GraphRAG items name a fact the structured surface already retrieved. + /// + /// + /// The locked prediction for K6 is that a GraphRAG budget pointed at the memory layer's own fact + /// index returns the same rows the Structured arm already has - the same data fetched twice under + /// a second budget. This counts that directly rather than inferring it from a score. + /// + /// Identity comes from the fact_id the harness projects in its retrieval query. An item + /// without one is counted as not duplicated: with the default projection there is no node + /// identity at all (K10), and guessing by text would quietly turn "cannot tell" into "distinct". + /// + /// + internal static int CountGraphRagFactsAlreadyRetrieved( + IReadOnlyList graphRagItems, + IEnumerable retrievedFacts) + { + ArgumentNullException.ThrowIfNull(graphRagItems); + ArgumentNullException.ThrowIfNull(retrievedFacts); + + var retrievedIds = retrievedFacts + .Select(fact => fact.FactId) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(StringComparer.Ordinal); + + return graphRagItems.Count(item => + item.Metadata is not null && + item.Metadata.TryGetValue("fact_id", out var id) && + id?.ToString() is { Length: > 0 } factId && + retrievedIds.Contains(factId)); + } + + /// + /// G3B.9. The messages that represent actual conversation, excluding AgentEval's fabricated + /// session-boundary turns. An unclassifiable message is kept: dropping what we cannot identify + /// would silently lose real evidence, which is far worse than the flooding this prevents. + /// + internal static List SelectPersistableMessages( + IReadOnlyList messages, + IReadOnlyDictionary originsByMessageId) + { + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(originsByMessageId); + if (originsByMessageId.Count == 0) + return messages.ToList(); + + return messages + .Where(message => + !originsByMessageId.TryGetValue(message.MessageId, out var origin) || + (!origin.IsSyntheticBoundary && !origin.IsSyntheticFormatterPadding)) + .ToList(); + } + + internal static List BuildMessages( + string runId, + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string sessionId, + string ownerId, + int questionNumber, + LongMemEvalEvidenceQuestion? evidenceQuestion, + IDictionary originsByMessageId) + { + var expectedCount = history.Count * 2; + if (evidenceQuestion is not null && evidenceQuestion.Messages.Count != expectedCount) + { + throw new InvalidOperationException( + $"LongMemEval evidence contained {evidenceQuestion.Messages.Count} origins for {expectedCount} injected messages."); + } + + var result = new List(expectedCount); + var ordinal = 0; + foreach (var (user, assistant) in history) + { + result.Add(Message("user", user)); + result.Add(Message("assistant", assistant)); + } + + return result; + + Message Message(string role, string content) + { + var current = ordinal++; + var messageId = $"{runId}-q{questionNumber:D4}-m{current:D6}"; + var metadata = new Dictionary + { + ["ownerId"] = ownerId, + ["longMemEval"] = true, + ["questionNumber"] = questionNumber + }; + + if (evidenceQuestion is not null) + { + var origin = evidenceQuestion.Messages[current]; + if (!string.Equals(origin.Role, role, StringComparison.OrdinalIgnoreCase) || + !string.Equals(origin.FormattedContent, content, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"LongMemEval source provenance did not align at message ordinal {current}."); + } + + // These are source coordinates, not evaluation labels. In particular, HasAnswer and + // AnswerSessionIds remain evaluator-side and are never persisted or sent to the answer model. + metadata["sourceSessionId"] = origin.SourceSessionId; + metadata["sourceSessionOrdinal"] = origin.SourceSessionOrdinal; + metadata["sourceTimestamp"] = origin.SourceTimestamp; + metadata["sourceSyntheticBoundary"] = origin.IsSyntheticBoundary; + metadata["sourceSyntheticFormatterPadding"] = origin.IsSyntheticFormatterPadding; + if (origin.SourceTurnOrdinal is int sourceTurnOrdinal) + metadata["sourceTurnOrdinal"] = sourceTurnOrdinal; + originsByMessageId.Add(messageId, origin); + } + + return new Message + { + MessageId = messageId, + SessionId = sessionId, + ConversationId = sessionId, + Role = role, + Content = content, + TimestampUtc = DateTimeOffset.UnixEpoch.AddSeconds(current), + Metadata = metadata + }; + } + } + + /// + /// G3B.2. The source timestamp AgentMemory persisted with the message and returns through recall. + /// + /// + /// LongMemEval session dates reach us only inside AgentEval's --- Session N (date) --- + /// boundary markers, which G3B.1 correctly drops as formatter boilerplate — taking every date + /// with them. The date survives on each real message as sourceTimestamp provenance, so it + /// is restored from there rather than from the evaluator-side index: this must be data the + /// product actually returns, not knowledge the harness happens to hold. Falls back to the stored + /// clock so a message with no provenance is still rendered rather than silently dropped. + /// + internal static string DisplayTimestamp(Message message) + { + ArgumentNullException.ThrowIfNull(message); + return message.Metadata is not null && + message.Metadata.TryGetValue("sourceTimestamp", out var source) && + source?.ToString() is { Length: > 0 } text + ? text + : message.TimestampUtc.ToString("O"); + } + + internal static string BuildAnswerPrompt( + IEnumerable<(string Role, string Timestamp, string Content)> recalled, + string question, + string? currentDate = null) + { + ArgumentNullException.ThrowIfNull(recalled); + ArgumentException.ThrowIfNullOrWhiteSpace(question); + + var builder = new StringBuilder("Retrieved memory:\n"); + foreach (var (role, timestamp, content) in recalled) + AppendMessage(builder, role, timestamp, content); + return AppendQuestion(builder, question, currentDate); + } + + internal static string BuildAnswerPrompt( + MemoryContext context, + string question, + string? currentDate = null, + IReadOnlyDictionary? originsByMessageId = null) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentException.ThrowIfNullOrWhiteSpace(question); + + // G3B.7. Structured items carried no date at all, which is the same defect G3B.2 fixed for + // messages, left live on the structured channel: Structured mode lost precisely the temporal + // questions. Entities, facts and preferences all carry SourceMessageIds, and those messages + // carry the real conversation date, so the item can be dated from data recall already + // returns - no extra query and nothing evaluator-side. + string SourceDates(IReadOnlyList sourceMessageIds) + { + if (originsByMessageId is null || sourceMessageIds.Count == 0) + return string.Empty; + var dates = sourceMessageIds + .Select(id => originsByMessageId.TryGetValue(id, out var origin) + ? origin.SourceTimestamp + : string.Empty) + .Where(date => !string.IsNullOrWhiteSpace(date)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + return dates.Length switch + { + 0 => string.Empty, + 1 => dates[0], + // A learned item can be evidenced across several dates; collapsing that to one would + // hide exactly the supersession a knowledge-update question turns on. + _ => $"{dates[0]} .. {dates[^1]}" + }; + } + + var builder = new StringBuilder("Retrieved memory:\n"); + foreach (var message in context.RelevantMessages.Items) + AppendMessage(builder, message.Role, DisplayTimestamp(message), message.Content); + foreach (var entity in context.RelevantEntities.Items) + { + builder.Append("[entity"); + if (SourceDates(entity.SourceMessageIds) is { Length: > 0 } entityDates) + builder.Append(" @ ").Append(entityDates); + builder.Append("] ").Append(entity.Name).Append(" (").Append(entity.Type).Append(')'); + if (!string.IsNullOrWhiteSpace(entity.Description)) + builder.Append(": ").Append(entity.Description); + builder.AppendLine(); + } + foreach (var fact in context.RelevantFacts.Items) + { + builder.Append("[fact"); + if (SourceDates(fact.SourceMessageIds) is { Length: > 0 } factDates) + builder.Append(" @ ").Append(factDates); + builder.Append("] ") + .Append(fact.Subject).Append(' ') + .Append(fact.Predicate).Append(' ') + .Append(fact.Object); + if (fact.ValidFrom is not null || fact.ValidUntil is not null) + { + builder.Append(" [valid ") + .Append(fact.ValidFrom?.ToString("O") ?? "?") + .Append(" to ") + .Append(fact.ValidUntil?.ToString("O") ?? "?") + .Append(']'); + } + builder.AppendLine(); + } + foreach (var preference in context.RelevantPreferences.Items) + { + builder.Append("[preference"); + if (SourceDates(preference.SourceMessageIds) is { Length: > 0 } preferenceDates) + builder.Append(" @ ").Append(preferenceDates); + builder.Append("] ").Append(preference.PreferenceText); + if (!string.IsNullOrWhiteSpace(preference.Context)) + builder.Append(" (").Append(preference.Context).Append(')'); + builder.AppendLine(); + } + if (!string.IsNullOrWhiteSpace(context.GraphRagContext)) + builder.Append("[graphrag]\n").AppendLine(context.GraphRagContext); + return AppendQuestion(builder, question, currentDate); + } + + private static void AppendMessage( + StringBuilder builder, string role, string timestamp, string content) + { + builder.Append('[').Append(role); + if (!string.IsNullOrWhiteSpace(timestamp)) + builder.Append(" @ ").Append(timestamp); + builder.Append("] ").AppendLine(content); + } + + private static string AppendQuestion( + StringBuilder builder, string question, string? currentDate) + { + // Without "now", a relative-time question such as "how many days ago did I ..." is + // unanswerable no matter how good retrieval was. + if (!string.IsNullOrWhiteSpace(currentDate)) + builder.Append("\nCurrent date: ").AppendLine(currentDate); + builder.Append("\nQuestion: ").Append(question).Append("\nAnswer:"); + return builder.ToString(); + } + + private string ScopeId(string kind, int question) => $"{_runId}-{kind}-{question:D4}"; + + private static string Sanitize(string value) => + string.Concat(value.Select(character => + char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-')); +} + +internal sealed class LongMemEvalExtractionAccountingException + : InvalidOperationException +{ + public LongMemEvalExtractionAccountingException(string message) + : base(message) + { + } +} + +public sealed record LongMemEvalAdapterOptions +{ + public LongMemEvalMemoryMode MemoryMode { get; init; } = LongMemEvalMemoryMode.Raw; + + public bool PreparedMemory { get; init; } + + public LongMemEvalPreparedState? PreparedState { get; init; } + + internal bool PreparationOnly { get; init; } + + + internal bool UseBatchedPreparation { get; init; } + + internal IMemoryExtractionPipeline? BatchExtractionPipeline { get; init; } + + internal IMultiSessionUnifiedMemoryExtractor? BatchPlanner { get; init; } + + internal int MaxSessionsPerBatch { get; init; } = 4; + + internal int MaxInputTokens { get; init; } = 100_000; + + internal int InitialQuestionNumber { get; init; } + + internal MultiSessionExtractionPlan? ExpectedExtractionPlan { get; init; } + + internal int? DiagnosticSourceSessionOrdinal { get; init; } + /// + /// Total non-GraphRAG answer-context item budget. Raw uses it entirely for messages; Structured + /// divides it across entities/facts/preferences; Hybrid gives half to messages and divides the + /// remainder across structured categories. + /// + + public int MaxRelevantMessages { get; init; } = 30; + + /// + /// G3B.1. When enabled, message recall over-fetches + /// × the message budget, drops only the items + /// AgentEval's formatter injected (session boundaries and padding), preserves the provider's + /// retrieval order, and selects the first real source turns. + /// + /// + /// Default-off, because the raw arm is the immutable comparison control. In the accepted r8 run + /// 240 of 300 final items were formatter boilerplate, and both questions reported as retrieval + /// failures returned 30 of 30 — so the control never got the chance to rank a real turn. This + /// changes selection only: storage, embeddings, the vector query and the item budget are + /// untouched. + /// + public bool ExcludeSyntheticFormatterMessages { get; init; } + + /// + /// G3B.4. Presents the recalled messages in chronological order instead of similarity-rank + /// order. + /// + /// + /// Retrieval rank answers "how relevant"; it says nothing about "when". A question such as + /// "the order of the six museums I visited from earliest to latest" forces the reader to sort + /// scattered dates itself. Selection is untouched - the same items in a different order - so this + /// isolates presentation from retrieval. + /// + public bool ChronologicalAnswerContext { get; init; } + + /// + /// G3B.3. Maximum answer-context items any one source session may occupy. 0 disables the cap. + /// + /// + /// Measured motivation: on `gpt4_7abb270c` two sessions took 14 of 30 slots while a required + /// sixth gold session — present in the candidate pool — received none. The cap reallocates slots + /// only; the query, candidate pool, ranking and final item count are unchanged, and unused slots + /// are refilled uncapped so the context is never left short. + /// + public int MaxItemsPerSourceSession { get; init; } + + + /// + /// Recorded batch splits, used to decide whether excess provider calls are accounted for. + /// + /// + /// A split is a designed recovery that legitimately adds calls, so the cost guard needs to tell + /// "the splitter ran" apart from "calls appeared that nobody can explain". Null means the + /// harness has no split diagnostics wired, in which case any excess is treated as unexplained - + /// failing closed rather than assuming innocence. + /// + public Func? BatchSplitCount { get; init; } + + /// + /// K6. Adds a GraphRAG item budget on top of the mode's own budget. + /// + /// + /// Zero everywhere else, and zero by default here: every quality measurement this track has + /// produced asked GraphRAG for nothing, so it has never been observed returning anything. This + /// budget is additive, not a reallocation - the total context grows by up to this many + /// items, so a score difference against a run without it is confounded with the larger budget + /// and must not be read as GraphRAG's contribution. What the flag is for is the mechanism and + /// the duplication rate, both of which are readable at any budget. + /// + public int GraphRagItems { get; init; } + + /// G5. Returns every fact sharing a retrieved fact's canonical predicate. + public bool ExpandFactsByPredicate { get; init; } + + /// J2.2. Also expands on relations resolved from the question text itself. + public bool ResolveQueryRelations { get; init; } + + /// Cap on expanded facts. + /// + /// Defaulted well below AgentEval's 100-reference evidence cap, which counts entities, + /// facts and preferences — not the fact budget alone. A structured arm already spends ~30 + /// references before expansion adds any, so a 100-fact expansion guarantees the envelope + /// overflows and the run is rejected mid-flight. + /// + public int MaxExpandedFacts { get; init; } = 60; + + /// Candidate over-fetch factor used only when synthetic exclusion is enabled. + /// + /// Raised from 3 to 5 by measurement: the first filtered run found formatter boilerplate still + /// occupied 69% of the candidate pool at K = 90, yielding ~27.9 real turns against a + /// 30-item budget. Filling the budget needs ≈97 candidates, so 5× (150) leaves headroom rather + /// than sitting on the boundary. + /// + public int SyntheticExclusionCandidateMultiplier { get; init; } = 5; + + public double MinSimilarityScore { get; init; } = 0; + + public string? ModelId { get; init; } + + internal LongMemEvalEvidenceIndex? EvidenceIndex { get; init; } + + internal LongMemEvalEvidenceDetail EvidenceDetail { get; init; } = + LongMemEvalEvidenceDetail.Identifiers; + + + internal Action? ExtractionProgress { get; init; } + + internal bool RequireGraphReadBack { get; init; } + + internal ILongMemEvalGraphProbe? GraphProbe { get; init; } +} + +public sealed record LongMemEvalQuestionTelemetry( + int QuestionNumber, + int MessagesStored, + int ItemsRetrieved, + bool RecallTruncated, + string Status = "completed") +{ + /// Length of the assembled answer prompt, the arm's actual context cost. + public int AnswerPromptCharacters { get; init; } + + /// Approximate tokens in that prompt. An estimate, and named one. + public int EstimatedContextTokens { get; init; } + + public string? QuestionId { get; init; } + + public LongMemEvalRetrievalEvidence? RetrievalEvidence { get; init; } + + public int ExtractionUnits { get; init; } + + public int MessagesPrepared { get; init; } + + public int ExtractionCallsPlanned { get; init; } + + public int ExtractionUnitsPrepared { get; init; } + + public bool PreparedMemory { get; init; } + + public int RawMessagesRetrieved { get; init; } + + public int EntitiesRetrieved { get; init; } + + public int FactsRetrieved { get; init; } + + public int PreferencesRetrieved { get; init; } + + public bool GraphRagIncluded { get; init; } + + /// + /// Fraction of this question's gold source messages backed by a retrieved fact, or null when the + /// question has no gold messages. + /// + public double? RetrievedGoldCoverage { get; init; } + + /// L2. Graph truth vs context for the relation(s) this question named. + public LongMemEvalRelationCompleteness? RelationCompleteness { get; init; } + + /// Canonical relations this question resolved to; empty means expansion had nothing. + public IReadOnlyList ResolvedQueryRelations { get; init; } = Array.Empty(); + + /// K6. Passages GraphRAG actually returned. + public int GraphRagItemsRetrieved { get; init; } + + /// K6. Of those, how many name a fact the structured surface already retrieved. + public int GraphRagFactsAlreadyRetrieved { get; init; } + + public LongMemEvalGraphSnapshot? GraphReadBack { get; init; } + + /// + /// G3B.5. Whether the cold build learned anything from the answer-bearing sessions. Null outside + /// extraction modes. Zero GoldLearnedItems means Structured cannot answer this question at + /// any recall quality — an extraction finding, never a retrieval one. + /// + public LongMemEvalGoldEvidenceCoverage? GoldEvidenceCoverage { get; init; } + + /// + /// G3B.5 volume plausibility: learned items per contributing source message. A build that is + /// sound and fully provenanced can still be far too thin, and "3 facts from 474 sessions" must + /// be loud rather than silently green. + /// + public double LearnedItemsPerSourceMessage => + GraphReadBack is null || GraphReadBack.SourceMessages == 0 + ? 0d + : (double)GraphReadBack.LearnedItems / GraphReadBack.SourceMessages; + + public LongMemEvalStageTimings? StageTimings { get; init; } +} + +internal sealed record LongMemEvalRecallBudget( + int Messages, + int Entities, + int Facts, + int Preferences, + int GraphRag) +{ + internal static LongMemEvalRecallBudget For(LongMemEvalMemoryMode mode, int total) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total); + return mode switch + { + LongMemEvalMemoryMode.Raw => new(total, 0, 0, 0, 0), + LongMemEvalMemoryMode.Structured => Structured(total), + LongMemEvalMemoryMode.Hybrid => Hybrid(total), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; + } + + /// + /// G3B.1. Keeps only real source turns from an over-fetched candidate set, in the provider's own + /// retrieval order, then takes the first . + /// + /// + /// Exclusion is driven solely by the formatter-supplied origin flags — no scoring, deduplication, + /// diversity, recency, or second query. RetrievalRank is preserved exactly as the provider + /// returned it so the candidate-set ceiling stays reportable; only ContextRank is renumbered + /// over the survivors. An item with no known origin is kept: dropping what we cannot classify + /// would silently shrink the budget. + /// + internal static MemoryContextSection SelectRealSourceTurns( + MemoryContextSection section, + IReadOnlyDictionary originsByMessageId, + int finalCap, + int maxPerSession = 0) + { + ArgumentNullException.ThrowIfNull(section); + ArgumentNullException.ThrowIfNull(originsByMessageId); + ArgumentOutOfRangeException.ThrowIfNegative(finalCap); + ArgumentOutOfRangeException.ThrowIfNegative(maxPerSession); + + bool IsRealSourceTurn(string messageId) => + !originsByMessageId.TryGetValue(messageId, out var origin) || + (!origin.IsSyntheticBoundary && !origin.IsSyntheticFormatterPadding); + + /// + /// G3B.3. Fills the budget in ranked order while no source session exceeds + /// , then refills any unused slots from the skipped items, + /// uncapped, so the context is never left short. An item with no known origin is keyed by its + /// own id and therefore never capped. + /// + string[] Diversify(string[] candidates) + { + if (maxPerSession == 0 || candidates.Length <= finalCap) + return candidates.Take(finalCap).ToArray(); + + var perSession = new Dictionary(StringComparer.Ordinal); + var selected = new HashSet(StringComparer.Ordinal); + var skipped = new List(); + foreach (var id in candidates) + { + if (selected.Count == finalCap) break; + var session = originsByMessageId.TryGetValue(id, out var origin) + ? origin.SourceSessionId + : id; + var taken = perSession.GetValueOrDefault(session); + if (taken >= maxPerSession) + { + skipped.Add(id); + continue; + } + + perSession[session] = taken + 1; + selected.Add(id); + } + + foreach (var id in skipped) + { + if (selected.Count == finalCap) break; + selected.Add(id); + } + + // Emit in the provider's own retrieval order, not selection order. + return candidates.Where(selected.Contains).ToArray(); + } + + var ranked = section.RankedItems.Count > 0 + ? section.RankedItems.OrderBy(item => item.ContextRank).ToArray() + : []; + if (ranked.Length == 0) + { + // No diagnostics were requested, so retrieval order is only observable through Items. + return section with + { + Items = Diversify( + section.Items.Where(m => IsRealSourceTurn(m.MessageId)) + .Select(m => m.MessageId).ToArray()) + .Select(id => section.Items.First(m => m.MessageId == id)) + .ToArray() + }; + } + + var itemsById = section.Items.ToDictionary(m => m.MessageId, StringComparer.Ordinal); + var keptIds = Diversify(ranked + .Select(item => item.ItemId) + .Where(IsRealSourceTurn) + .Where(itemsById.ContainsKey) + .ToArray()); + var keptSet = keptIds.ToHashSet(StringComparer.Ordinal); + + return section with + { + Items = keptIds.Select(id => itemsById[id]).ToArray(), + RankedItems = ranked + .Where(item => keptSet.Contains(item.ItemId)) + .Select((item, index) => item with { ContextRank = index + 1 }) + .ToArray() + }; + } + + private static LongMemEvalRecallBudget Structured(int total) + { + var each = total / 3; + return new(0, each, total - each * 2, each, 0); + } + + private static LongMemEvalRecallBudget Hybrid(int total) + { + var messages = total / 2; + var remaining = total - messages; + var each = remaining / 3; + return new(messages, each, remaining - each * 2, each, 0); + } +} + +/// L2. Relation completeness for one question: graph truth vs what reached the context. +public sealed record LongMemEvalRelationCompleteness +{ + /// The stored predicate keys expansion would have searched, widened from the question. + public IReadOnlyList StoredPredicateKeys { get; init; } = Array.Empty(); + + /// Per-key graph counts, reported raw so a partial miss is attributable to a key. + public IReadOnlyDictionary PerKeyGraphCounts { get; init; } = + new Dictionary(StringComparer.Ordinal); + + /// Live facts in the graph under those keys. Null when not measured. + public int? Denominator { get; init; } + + /// Distinct facts in the context under those keys. Null when not measured. + public int? Numerator { get; init; } + + /// Numerator / Denominator, or null when there is nothing to divide. + public double? Ratio { get; init; } + + /// Whether the context held every one. Null when not measured or nothing to hold. + public bool? Complete { get; init; } + + /// The relation resolved but the graph holds none of it — an EXTRACTION miss. + public bool RelationAbsentFromGraph { get; init; } + + /// The graph holds more than the expansion budget could ever return. + public bool LimitBinding { get; init; } + + /// The MaxExpandedFacts in force, recorded so LimitBinding is checkable. + public int ExpansionLimit { get; init; } +} diff --git a/tools/AgentMemory.LongMemEval/DefaultTemperatureChatClient.cs b/tools/AgentMemory.LongMemEval/DefaultTemperatureChatClient.cs new file mode 100644 index 00000000..c0854c7c --- /dev/null +++ b/tools/AgentMemory.LongMemEval/DefaultTemperatureChatClient.cs @@ -0,0 +1,52 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// Narrow compatibility adapter for AgentEval 0.16's reasoning-model judge request. It removes the +/// unsupported explicit zero temperature and raises only the exact 30-token judge ceiling so hidden +/// reasoning cannot consume the entire allowance before emitting the required yes/no verdict. +/// +internal sealed class DefaultTemperatureChatClient(IChatClient inner) : IChatClient +{ + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + Normalize(options); + return inner.GetResponseAsync(messages, options, cancellationToken); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Normalize(options); + await foreach (var update in inner + .GetStreamingResponseAsync(messages, options, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) + ? this + : inner.GetService(serviceType, serviceKey); + + public void Dispose() => inner.Dispose(); + + private static void Normalize(ChatOptions? options) + { + if (options?.Temperature != 0 || options.MaxOutputTokens != 30) + return; + + options.Temperature = null; + options.MaxOutputTokens = 512; + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalAgentEvalEvidence.cs b/tools/AgentMemory.LongMemEval/LongMemEvalAgentEvalEvidence.cs new file mode 100644 index 00000000..426b423c --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalAgentEvalEvidence.cs @@ -0,0 +1,250 @@ +using System.Globalization; +using AgentEval.Memory.External.Models; +using AgentMemory.Abstractions.Domain; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalAgentEvalEvidence +{ + internal static QuestionEvidenceEnvelope Build( + MemoryContext context, + IReadOnlyDictionary originsByMessageId, + LongMemEvalEvidenceDetail detail) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(originsByMessageId); + + var candidates = new List(); + AddMessages(context, originsByMessageId, candidates); + AddEntities(context, originsByMessageId, candidates); + AddFacts(context, originsByMessageId, candidates); + AddPreferences(context, originsByMessageId, candidates); + + if (candidates.Count > QuestionEvidenceEnvelope.MaximumReferences) + { + throw new InvalidOperationException( + $"LongMemEval answer context produced {candidates.Count} normalized references; maximum is {QuestionEvidenceEnvelope.MaximumReferences}."); + } + + var retrieved = candidates + .Select((candidate, index) => Reference( + candidate, index + 1, answerContextOrder: null, content: null)) + .ToArray(); + var answerContext = new List(candidates.Count); + var remainingContent = detail == LongMemEvalEvidenceDetail.Content + ? QuestionEvidenceEnvelope.MaximumTotalContentLength + : 0; + for (var index = 0; index < candidates.Count; index++) + { + var candidate = candidates[index]; + string? content = null; + if (remainingContent > 0) + { + var length = Math.Min( + Math.Min(candidate.Content.Length, EvidenceReference.MaximumContentLength), + remainingContent); + content = candidate.Content[..length]; + remainingContent -= length; + } + + answerContext.Add(Reference( + candidate, + index + 1, + answerContextOrder: index + 1, + content)); + } + + return new QuestionEvidenceEnvelope + { + SchemaVersion = QuestionEvidenceEnvelope.CurrentSchemaVersion, + Retrieved = retrieved, + AnswerContext = answerContext + }; + } + + private static void AddMessages( + MemoryContext context, + IReadOnlyDictionary origins, + ICollection output) + { + var scores = Scores(context.RelevantMessages.RankedItems); + foreach (var message in context.RelevantMessages.Items) + { + if (!origins.TryGetValue(message.MessageId, out var origin)) + { + throw new InvalidOperationException( + $"Normalized LongMemEval evidence could not map message {message.MessageId} to a source origin."); + } + + var observableSource = !origin.IsSyntheticBoundary && + !origin.IsSyntheticFormatterPadding; + output.Add(new EvidenceCandidate( + message.MessageId, + scores.GetValueOrDefault(message.MessageId), + observableSource ? origin.SourceSessionId : null, + observableSource ? origin.SourceTurnOrdinal : null, + observableSource ? ParseTimestamp(origin.SourceTimestamp) : null, + $"[{message.Role}] {message.Content}")); + } + } + + private static void AddEntities( + MemoryContext context, + IReadOnlyDictionary origins, + ICollection output) + { + var scores = Scores(context.RelevantEntities.RankedItems); + foreach (var entity in context.RelevantEntities.Items) + { + var source = StructuredOrigin(entity.SourceMessageIds, origins); + output.Add(new EvidenceCandidate( + $"entity:{entity.EntityId}", + scores.GetValueOrDefault(entity.EntityId), + source.SessionId, + source.TurnIndex, + source.Timestamp, + string.IsNullOrWhiteSpace(entity.Description) + ? $"[entity] {entity.Name} ({entity.Type})" + : $"[entity] {entity.Name} ({entity.Type}): {entity.Description}")); + } + } + + private static void AddFacts( + MemoryContext context, + IReadOnlyDictionary origins, + ICollection output) + { + var scores = Scores(context.RelevantFacts.RankedItems); + foreach (var fact in context.RelevantFacts.Items) + { + // A predicate-expanded fact is a relation drawn from the whole owner, so its provenance + // may sit outside this question's message window. That is expected, not corruption, and + // must not be resolved against a map that only covers this question — the throw in + // StructuredOrigin stays intact for every fact that is *not* so marked. + var expanded = + fact.Metadata.TryGetValue(Fact.RetrievalSourceMetadataKey, out var retrievalSource) && + string.Equals( + retrievalSource?.ToString(), + Fact.RetrievalSourcePredicateExpansion, + StringComparison.Ordinal); + var source = expanded + ? new StructuredSource(null, null, null) + : StructuredOrigin(fact.SourceMessageIds, origins); + output.Add(new EvidenceCandidate( + $"fact:{fact.FactId}", + scores.GetValueOrDefault(fact.FactId), + source.SessionId, + source.TurnIndex, + source.Timestamp, + $"[fact] {fact.Subject} {fact.Predicate} {fact.Object}")); + } + } + + private static void AddPreferences( + MemoryContext context, + IReadOnlyDictionary origins, + ICollection output) + { + var scores = Scores(context.RelevantPreferences.RankedItems); + foreach (var preference in context.RelevantPreferences.Items) + { + var source = StructuredOrigin(preference.SourceMessageIds, origins); + output.Add(new EvidenceCandidate( + $"preference:{preference.PreferenceId}", + scores.GetValueOrDefault(preference.PreferenceId), + source.SessionId, + source.TurnIndex, + source.Timestamp, + string.IsNullOrWhiteSpace(preference.Context) + ? $"[preference] {preference.PreferenceText}" + : $"[preference] {preference.PreferenceText} ({preference.Context})")); + } + } + + private static StructuredSource StructuredOrigin( + IReadOnlyList sourceMessageIds, + IReadOnlyDictionary origins) + { + if (sourceMessageIds.Count == 0) + return new StructuredSource(null, null, null); + + var mapped = new List(sourceMessageIds.Count); + foreach (var messageId in sourceMessageIds.Distinct(StringComparer.Ordinal)) + { + if (!origins.TryGetValue(messageId, out var origin)) + { + throw new InvalidOperationException( + $"Structured LongMemEval evidence could not map source message {messageId}."); + } + + if (!origin.IsSyntheticBoundary && !origin.IsSyntheticFormatterPadding) + mapped.Add(origin); + } + + if (mapped.Count == 0) + return new StructuredSource(null, null, null); + var sessions = mapped + .Select(origin => origin.SourceSessionId) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (sessions.Length != 1) + return new StructuredSource(null, null, null); + + // Extraction assigns every message in the source session to every learned item. A decisive + // turn is observable only when exactly one real source message exists; never invent one. + var exactTurn = mapped.Count == 1 ? mapped[0] : null; + return new StructuredSource( + sessions[0], + exactTurn?.SourceTurnOrdinal, + exactTurn is null ? null : ParseTimestamp(exactTurn.SourceTimestamp)); + } + + private static Dictionary Scores( + IReadOnlyList rankedItems) => + rankedItems + .GroupBy(item => item.ItemId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.OrderBy(item => item.ContextRank).First().Score, + StringComparer.Ordinal); + + private static DateTimeOffset? ParseTimestamp(string value) => + DateTimeOffset.TryParseExact( + value, + "yyyy/MM/dd (ddd) HH:mm", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed) + ? parsed + : null; + + private static EvidenceReference Reference( + EvidenceCandidate candidate, + int rank, + int? answerContextOrder, + string? content) => + new() + { + Id = candidate.Id, + Rank = rank, + SimilarityScore = candidate.Score, + SourceSessionId = candidate.SourceSessionId, + SourceTurnIndex = candidate.SourceTurnIndex, + SourceTimestamp = candidate.SourceTimestamp, + AnswerContextOrder = answerContextOrder, + Content = content + }; + + private sealed record EvidenceCandidate( + string Id, + double? Score, + string? SourceSessionId, + int? SourceTurnIndex, + DateTimeOffset? SourceTimestamp, + string Content); + + private sealed record StructuredSource( + string? SessionId, + int? TurnIndex, + DateTimeOffset? Timestamp); +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalBenchmarkProtocol.cs b/tools/AgentMemory.LongMemEval/LongMemEvalBenchmarkProtocol.cs new file mode 100644 index 00000000..398758c4 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalBenchmarkProtocol.cs @@ -0,0 +1,68 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalBenchmarkProtocol +{ + internal static ExternalBenchmarkOptions CreateOptions( + string datasetPath, + int questions, + int seed, + int judgeRetryAttempts, + LongMemEvalEvidenceDetail evidenceDetail, + int maxRelevantMessages) => + new() + { + DatasetPath = datasetPath, + MaxQuestions = questions, + StratifiedSampling = true, + RandomSeed = seed, + PreserveSessionBoundaries = true, + IncludeTimestamps = true, + HistoryInjectionMode = HistoryInjectionMode.StructuredChatHistory, + DatasetMode = "S", + JudgeFailurePolicy = JudgeFailurePolicy.RetryThenInconclusive, + MaxJudgeRetries = judgeRetryAttempts, + JudgeTemperature = null, + JudgeMaxOutputTokens = 256, + JudgeEvidenceMode = JudgeEvidenceMode.Outcome, + EvidenceCaptureMode = evidenceDetail switch + { + LongMemEvalEvidenceDetail.None => EvidenceCaptureMode.None, + LongMemEvalEvidenceDetail.Identifiers => EvidenceCaptureMode.References, + LongMemEvalEvidenceDetail.Content => EvidenceCaptureMode.Full, + _ => throw new ArgumentOutOfRangeException(nameof(evidenceDetail)) + }, + EvidenceTopK = maxRelevantMessages + }; + + internal static IReadOnlyList<(string UserMessage, string AssistantResponse)> History( + LongMemEvalEvidenceQuestion question) + { + ArgumentNullException.ThrowIfNull(question); + if (question.Messages.Count == 0 || question.Messages.Count % 2 != 0) + { + throw new InvalidOperationException( + $"LongMemEval question {question.QuestionId} has an invalid formatted-message count."); + } + + var result = new List<(string UserMessage, string AssistantResponse)>( + question.Messages.Count / 2); + for (var index = 0; index < question.Messages.Count; index += 2) + { + var user = question.Messages[index]; + var assistant = question.Messages[index + 1]; + if (!string.Equals(user.Role, "user", StringComparison.OrdinalIgnoreCase) || + !string.Equals(assistant.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"LongMemEval question {question.QuestionId} has invalid formatted role ordering."); + } + + result.Add((user.FormattedContent, assistant.FormattedContent)); + } + + return result.AsReadOnly(); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalChatCallMeter.cs b/tools/AgentMemory.LongMemEval/LongMemEvalChatCallMeter.cs new file mode 100644 index 00000000..16313a58 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalChatCallMeter.cs @@ -0,0 +1,424 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// Content-free provider-call accounting for one explicit LongMemEval purpose. +/// It records only counts, failures, and elapsed provider time. +/// +internal sealed class LongMemEvalChatCallMeter(IChatClient inner) : IChatClient +{ + private const int MaxFailureDetails = 32; + private const int MaxCallDetails = 64; + private readonly ConcurrentQueue _failureDetails = new(); + private readonly ConcurrentQueue _callDetails = new(); + private readonly ConcurrentDictionary _scopeCounters = new(StringComparer.Ordinal); + private readonly AsyncLocal _currentScope = new(); + private long _calls; + private readonly ConditionalWeakTable _activityCalls = new(); + private long _completedCalls; + private long _retryCalls; + private int _activeCalls; + private int _maximumConcurrency; + private long _failures; + private long _elapsedTimestampTicks; + private long _failureDetailSlots; + private long _droppedFailureDetails; + private long _droppedCallDetails; + internal IDisposable BeginScope(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + var previous = _currentScope.Value; + _currentScope.Value = scope; + _scopeCounters.GetOrAdd(scope, static _ => new ScopeCounter()); + return new ScopeLease(this, previous); + } + + internal LongMemEvalChatCallScopeSnapshot SnapshotScope(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + return _scopeCounters.TryGetValue(scope, out var counter) + ? counter.Snapshot() + : LongMemEvalChatCallScopeSnapshot.Zero; + } + + + public LongMemEvalChatCallSnapshot Snapshot() + { + var elapsedTicks = Interlocked.Read(ref _elapsedTimestampTicks); + return new LongMemEvalChatCallSnapshot( + Calls: Interlocked.Read(ref _calls), + Failures: Interlocked.Read(ref _failures), + Duration: TimeSpan.FromSeconds( + (double)elapsedTicks / Stopwatch.Frequency)) + { + CompletedCalls = Interlocked.Read(ref _completedCalls), + RetryCalls = Interlocked.Read(ref _retryCalls), + MaximumConcurrency = Volatile.Read(ref _maximumConcurrency), + FailureDetails = _failureDetails.ToArray(), + DroppedFailureDetails = Interlocked.Read(ref _droppedFailureDetails), + CallDetails = _callDetails.OrderBy(detail => detail.CallOrdinal).ToArray(), + DroppedCallDetails = Interlocked.Read(ref _droppedCallDetails) + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var materializedMessages = + messages as IReadOnlyList ?? messages.ToArray(); + var purpose = ClassifyPurpose(materializedMessages); + var activity = Activity.Current; + var estimatedInputTokens = EstimatedInputTokens(activity) ?? + EstimateInputTokens(materializedMessages, purpose); + var retry = RecordActivityCall(activity, purpose) || IsParseRetry(materializedMessages, purpose); + if (retry) + Interlocked.Increment(ref _retryCalls); + var nowActive = Interlocked.Increment(ref _activeCalls); + UpdateMaximum(ref _maximumConcurrency, nowActive); + var callOrdinal = Interlocked.Increment(ref _calls); + var started = Stopwatch.GetTimestamp(); + var scopeCounter = CurrentScopeCounter(); + scopeCounter?.RecordCall(purpose, retry); + Exception? failure = null; + try + { + return await inner.GetResponseAsync( + materializedMessages, options, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) + { + failure = exception; + Interlocked.Increment(ref _failures); + scopeCounter?.RecordFailure(); + RecordFailure(callOrdinal, purpose, exception); + throw; + } + finally + { + var elapsed = Stopwatch.GetTimestamp() - started; + Interlocked.Add( + ref _elapsedTimestampTicks, + elapsed); + Interlocked.Increment(ref _completedCalls); + Interlocked.Decrement(ref _activeCalls); + scopeCounter?.RecordCompleted(elapsed); + RecordCall(callOrdinal, purpose, failure, elapsed, estimatedInputTokens, retry); + } + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _calls); + var nowActive = Interlocked.Increment(ref _activeCalls); + UpdateMaximum(ref _maximumConcurrency, nowActive); + var started = Stopwatch.GetTimestamp(); + var scopeCounter = CurrentScopeCounter(); + scopeCounter?.RecordCall("streaming", retry: false); + try + { + await foreach (var update in inner + .GetStreamingResponseAsync(messages, options, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + finally + { + var elapsed = Stopwatch.GetTimestamp() - started; + Interlocked.Add( + ref _elapsedTimestampTicks, + elapsed); + Interlocked.Increment(ref _completedCalls); + Interlocked.Decrement(ref _activeCalls); + scopeCounter?.RecordCompleted(elapsed); + } + } + + private ScopeCounter? CurrentScopeCounter() => + _currentScope.Value is { } scope + ? _scopeCounters.GetOrAdd(scope, static _ => new ScopeCounter()) + : null; + + private void RecordFailure( + long callOrdinal, + string purpose, + Exception exception) + { + var slot = Interlocked.Increment(ref _failureDetailSlots); + if (slot > MaxFailureDetails) + { + Interlocked.Increment(ref _droppedFailureDetails); + return; + } + + _failureDetails.Enqueue(new LongMemEvalChatCallFailure( + callOrdinal, + purpose, + exception.GetType().FullName ?? exception.GetType().Name, + ProviderStatus(exception))); + } + + private void RecordCall( + long callOrdinal, + string purpose, + Exception? exception, + long elapsedTimestampTicks, + int? estimatedInputTokens, + bool retry) + { + _callDetails.Enqueue(new LongMemEvalChatCallDetail( + callOrdinal, + purpose, + exception?.GetType().FullName ?? exception?.GetType().Name, + exception is null ? null : ProviderStatus(exception), + 1_000d * elapsedTimestampTicks / Stopwatch.Frequency, + estimatedInputTokens, + retry)); + while (_callDetails.Count > MaxCallDetails && + _callDetails.TryDequeue(out _)) + { + Interlocked.Increment(ref _droppedCallDetails); + } + } + + private static int? ProviderStatus(Exception exception) => + exception switch + { + Azure.RequestFailedException requestFailed => requestFailed.Status, + System.ClientModel.ClientResultException clientResult => + clientResult.Status, + HttpRequestException { StatusCode: not null } http => + (int)http.StatusCode.Value, + _ => null + }; + + private bool RecordActivityCall(Activity? activity, string purpose) + { + if (activity is null || + !string.Equals(purpose, "unified_batch", StringComparison.Ordinal)) + return false; + var counter = _activityCalls.GetValue( + activity, + static _ => new ActivityCallCounter()); + return Interlocked.Increment(ref counter.Calls) > 1; + } + + private static int? EstimatedInputTokens(Activity? activity) => + activity?.GetTagItem("memory.extract.estimated_input_tokens") switch + { + int value => value, + long value when value is >= 0 and <= int.MaxValue => (int)value, + _ => null + }; + + private static int? EstimateInputTokens( + IReadOnlyList messages, + string purpose) + { + if (!string.Equals(purpose, "unified_batch", StringComparison.Ordinal)) + return null; + return checked( + messages.Sum(message => Encoding.UTF8.GetByteCount(message.Text ?? string.Empty)) + + 33); + } + + private static bool IsParseRetry( + IReadOnlyList messages, + string purpose) => + string.Equals(purpose, "unified_batch", StringComparison.Ordinal) && + messages.Count >= 4 && + messages[^1].Role == ChatRole.User && + string.Equals( + messages[^1].Text, + "That response was not valid JSON. Reply with ONLY the JSON object — " + + "no markdown fences, no prose.", + StringComparison.Ordinal); + + private static void UpdateMaximum(ref int maximum, int candidate) + { + var observed = Volatile.Read(ref maximum); + while (candidate > observed) + { + var previous = Interlocked.CompareExchange(ref maximum, candidate, observed); + if (previous == observed) + return; + observed = previous; + } + } + + private static string ClassifyPurpose( + IReadOnlyList messages) + { + var systemPrompt = messages + .FirstOrDefault(message => message.Role == ChatRole.System) + ?.Text; + if (systemPrompt is null) + return "other"; + if (systemPrompt.StartsWith( + "You are an entity extraction assistant.", + StringComparison.Ordinal)) + return "entity"; + if (systemPrompt.StartsWith( + "You are a fact extraction assistant.", + StringComparison.Ordinal)) + return "fact"; + if (systemPrompt.StartsWith( + "You are a preference extraction assistant.", + StringComparison.Ordinal)) + return "preference"; + if (systemPrompt.StartsWith( + "You are a relationship extraction assistant.", + StringComparison.Ordinal)) + return "relationship"; + if (systemPrompt.StartsWith( + "You extract structured long-term memory from multiple independent source sessions.", + StringComparison.Ordinal)) + return "unified_batch"; + if (systemPrompt.StartsWith( + "You extract structured long-term memory from a conversation.", + StringComparison.Ordinal)) + return "unified"; + return "other"; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) + ? this + : inner.GetService(serviceType, serviceKey); + + public void Dispose() => inner.Dispose(); + + + private sealed class ScopeLease(LongMemEvalChatCallMeter owner, string? previous) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + owner._currentScope.Value = previous; + } + } + + private sealed class ActivityCallCounter + { + internal long Calls; + } + + private sealed class ScopeCounter + { + private readonly ConcurrentDictionary _purposes = new(StringComparer.Ordinal); + private long _calls; + private long _failures; + private long _elapsedTimestampTicks; + private long _completedCalls; + private long _retryCalls; + private int _activeCalls; + private int _maximumConcurrency; + + internal void RecordCall(string purpose, bool retry) + { + Interlocked.Increment(ref _calls); + if (retry) + Interlocked.Increment(ref _retryCalls); + var nowActive = Interlocked.Increment(ref _activeCalls); + UpdateMaximum(ref _maximumConcurrency, nowActive); + _purposes.AddOrUpdate(purpose, 1, static (_, count) => count + 1); + } + + internal void RecordFailure() => Interlocked.Increment(ref _failures); + + internal void RecordCompleted(long timestampTicks) + { + Interlocked.Add(ref _elapsedTimestampTicks, timestampTicks); + Interlocked.Increment(ref _completedCalls); + Interlocked.Decrement(ref _activeCalls); + + } + internal LongMemEvalChatCallScopeSnapshot Snapshot() => + new( + Interlocked.Read(ref _calls), + Interlocked.Read(ref _failures), + TimeSpan.FromSeconds( + (double)Interlocked.Read(ref _elapsedTimestampTicks) / + Stopwatch.Frequency), + _purposes.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal)) + { + CompletedCalls = Interlocked.Read(ref _completedCalls), + RetryCalls = Interlocked.Read(ref _retryCalls), + MaximumConcurrency = Volatile.Read(ref _maximumConcurrency) + }; + } +} +public sealed record LongMemEvalChatCallSnapshot( + long Calls, + long Failures, + TimeSpan Duration) +{ + public long CompletedCalls { get; init; } + + public long RetryCalls { get; init; } + + public int MaximumConcurrency { get; init; } + + public IReadOnlyList FailureDetails { get; init; } = + Array.Empty(); + + + public long DroppedFailureDetails { get; init; } + + public IReadOnlyList CallDetails { get; init; } = + Array.Empty(); + + public long DroppedCallDetails { get; init; } + + public static LongMemEvalChatCallSnapshot Zero { get; } = + new(0, 0, TimeSpan.Zero); +} + +internal sealed record LongMemEvalChatCallScopeSnapshot( + long Calls, + long Failures, + TimeSpan Duration, + IReadOnlyDictionary Purposes) +{ + internal long CompletedCalls { get; init; } + + internal long RetryCalls { get; init; } + + internal int MaximumConcurrency { get; init; } + + internal static LongMemEvalChatCallScopeSnapshot Zero { get; } = + new(0, 0, TimeSpan.Zero, new Dictionary(StringComparer.Ordinal)); +} + +public sealed record LongMemEvalChatCallFailure( + long CallOrdinal, + string Purpose, + string ExceptionType, + int? ProviderStatus); + +public sealed record LongMemEvalChatCallDetail( + long CallOrdinal, + string Purpose, + string? ExceptionType, + int? ProviderStatus, + double DurationMilliseconds, + int? EstimatedInputTokens, + bool Retry); diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalContextSize.cs b/tools/AgentMemory.LongMemEval/LongMemEvalContextSize.cs new file mode 100644 index 00000000..a0e3d548 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalContextSize.cs @@ -0,0 +1,37 @@ +namespace AgentMemory.LongMemEval; + +/// +/// J5.1. Approximate size, in tokens, of the memory context handed to the reader. +/// +/// +/// +/// Every quality number here is half a result without its cost, and the cost half had no measurement +/// at all: the prepared-pair report records how many items each category contributed but never how +/// large the resulting context was. The band's Structured-673-versus-Hybrid-2,143 figures predate +/// predicate expansion, which adds up to 100 further facts, so the "structured is the cheap rung" +/// premise underneath the tier ladder cannot currently be checked. +/// +/// +/// An estimate, and named one. Four characters per token is the usual English approximation +/// and is deliberately not a real tokenizer: the question this has to answer is whether one arm costs +/// several times another, and a ratio survives a consistent approximation. Calling it +/// Estimate keeps that visible instead of implying a exact count that a downstream reader might +/// compare against a provider's billing. +/// +/// +internal static class LongMemEvalContextSize +{ + private const double CharactersPerToken = 4d; + + internal static int Estimate(string? content) => + string.IsNullOrWhiteSpace(content) + ? 0 + : (int)Math.Round(content.Length / CharactersPerToken, MidpointRounding.AwayFromZero); + + /// Estimated size of a whole assembled context, section by section. + internal static int EstimateAll(params string?[] sections) + { + ArgumentNullException.ThrowIfNull(sections); + return sections.Sum(Estimate); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalEvidenceIndex.cs b/tools/AgentMemory.LongMemEval/LongMemEvalEvidenceIndex.cs new file mode 100644 index 00000000..b04d1c50 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalEvidenceIndex.cs @@ -0,0 +1,457 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.Abstractions.Domain; + +namespace AgentMemory.LongMemEval; + +internal enum LongMemEvalEvidenceDetail +{ + None, + Identifiers, + Content +} + +/// +/// Evaluator-side index that aligns AgentEval's sampled/formatted history with the original LongMemEval +/// session and turn identifiers. Gold labels never leave this object and are never persisted to AgentMemory. +/// +internal sealed class LongMemEvalEvidenceIndex +{ + private readonly object _gate = new(); + private readonly Dictionary> _questionsByHistory; + private readonly IReadOnlyDictionary _questionsById; + private readonly IReadOnlyList _questions; + + private LongMemEvalEvidenceIndex( + Dictionary> questionsByHistory, + IReadOnlyDictionary questionsById, + IReadOnlyList questions) + { + _questionsByHistory = questionsByHistory; + _questionsById = questionsById; + _questions = questions; + } + + public IReadOnlyList Questions => _questions; + + public static LongMemEvalEvidenceIndex Load( + string datasetPath, + ExternalBenchmarkOptions options) => + Create(LongMemEvalDataLoader.LoadFromFile(datasetPath, options), options); + + internal static LongMemEvalEvidenceIndex Create( + IReadOnlyList entries, + ExternalBenchmarkOptions options) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(options); + + var byHistory = new Dictionary>(StringComparer.Ordinal); + var byId = new Dictionary(StringComparer.Ordinal); + var questions = new List(entries.Count); + foreach (var entry in entries) + { + var formatted = LongMemEvalHistoryFormatter.Format(entry, options); + var question = BuildQuestion(entry, formatted, options); + var fingerprint = Fingerprint(formatted); + if (!byHistory.TryGetValue(fingerprint, out var matching)) + { + matching = []; + byHistory.Add(fingerprint, matching); + } + + matching.Add(question); + if (!byId.TryAdd(question.QuestionId, question)) + { + throw new InvalidOperationException( + $"LongMemEval evidence contains duplicate question id {question.QuestionId}."); + } + questions.Add(question); + } + + return new LongMemEvalEvidenceIndex(byHistory, byId, questions.AsReadOnly()); + } + + public LongMemEvalEvidenceQuestion Resolve( + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string prompt) + { + ArgumentNullException.ThrowIfNull(history); + ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + + var fingerprint = Fingerprint(history); + lock (_gate) + { + if (!_questionsByHistory.TryGetValue(fingerprint, out var candidates)) + { + throw new InvalidOperationException( + "Injected LongMemEval history does not match the evaluator-side evidence index."); + } + + var matches = candidates + .Where(candidate => string.Equals(candidate.InvocationPrompt, prompt, StringComparison.Ordinal)) + .ToArray(); + if (matches.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one indexed LongMemEval question for the injected history and prompt; found {matches.Length}."); + } + + candidates.Remove(matches[0]); + if (candidates.Count == 0) + _questionsByHistory.Remove(fingerprint); + return matches[0]; + } + } + + public LongMemEvalEvidenceQuestion GetByQuestionId(string questionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(questionId); + if (!_questionsById.TryGetValue(questionId, out var question)) + { + throw new InvalidOperationException( + $"No indexed LongMemEval question has id {questionId}."); + } + + return question; + } + + private static LongMemEvalEvidenceQuestion BuildQuestion( + LongMemEvalEntry entry, + IReadOnlyList<(string UserMessage, string AssistantResponse)> formatted, + ExternalBenchmarkOptions options) + { + var sessions = entry.HaystackSessions + ?? throw new InvalidOperationException($"LongMemEval question {entry.QuestionId} has no sessions."); + var sessionIds = entry.HaystackSessionIds + ?? throw new InvalidOperationException($"LongMemEval question {entry.QuestionId} has no session ids."); + var sessionDates = entry.HaystackDates + ?? throw new InvalidOperationException($"LongMemEval question {entry.QuestionId} has no session dates."); + + if (sessions.Count != sessionIds.Count || + sessions.Count != sessionDates.Count) + { + throw new InvalidOperationException( + $"LongMemEval question {entry.QuestionId} has misaligned session ids, dates, or content."); + } + + if (!options.PreserveSessionBoundaries) + { + throw new InvalidOperationException( + "LongMemEval evidence tracing requires PreserveSessionBoundaries so source sessions remain unambiguous."); + } + + var origins = new List(formatted.Count * 2); + var formattedIndex = 0; + var messageOrdinal = 0; + + for (var sessionIndex = 0; sessionIndex < sessions.Count; sessionIndex++) + { + var session = sessions[sessionIndex]; + var sessionId = sessionIds[sessionIndex]; + var sourceTimestamp = sessionDates[sessionIndex]; + + if (formattedIndex >= formatted.Count || !IsSessionBoundary(formatted[formattedIndex])) + throw AlignmentFailure(entry.QuestionId); + var boundary = formatted[formattedIndex++]; + origins.Add(Origin(boundary.UserMessage, "user", null, true, false, false)); + origins.Add(Origin(boundary.AssistantResponse, "assistant", null, true, false, false)); + + // Segment the exact AgentEval output by its synthetic session boundaries, then map each + // non-empty side back to the original source turn. AgentEval drops a leading assistant-only + // continuation and pads a trailing user-only turn with a synthetic assistant acknowledgment. + // Both are legitimate structured-history shapes and must retain unambiguous provenance. + var usedSourceTurns = new HashSet(); + // The source turn most recently matched, so a padding "I understand." can be validated + // against what it actually follows rather than against the end of the session. + var lastConsumedTurnIndex = -1; + while (formattedIndex < formatted.Count && !IsSessionBoundary(formatted[formattedIndex])) + { + var formattedTurn = formatted[formattedIndex++]; + AddFormattedSide(formattedTurn.UserMessage, "user"); + AddFormattedSide(formattedTurn.AssistantResponse, "assistant"); + } + + void AddFormattedSide(string content, string role) + { + for (var turnIndex = 0; turnIndex < session.Count; turnIndex++) + { + var source = session[turnIndex]; + if (usedSourceTurns.Contains(turnIndex) || + !string.Equals(source.Role, role, StringComparison.OrdinalIgnoreCase) || + !string.Equals(source.Content, content, StringComparison.Ordinal)) + { + continue; + } + + usedSourceTurns.Add(turnIndex); + lastConsumedTurnIndex = turnIndex; + origins.Add(Origin( + source.Content, + source.Role, + turnIndex, + false, + false, + source.HasAnswer is true)); + return; + } + + // The formatter pads an unanswered user turn with "I understand." in TWO places, not + // one: at the end of a session, and mid-session whenever two user turns are + // consecutive (LongMemEvalHistoryFormatter flushes the pending user on the next user + // turn). This previously accepted only the trailing case, so any question containing + // back-to-back user turns failed alignment outright - 8 of the 500 dataset questions, + // which is why the fixed ten never hit it. + // + // This is not a relaxation of the provenance guard: a mid-session pad has exactly the + // same provenance as a trailing one - synthetic formatter output following a user + // turn - so it is recorded as synthetic padding either way. What changes is that the + // check now matches the formatter's real contract instead of a subset of it. + if (string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase) && + string.Equals(content, "I understand.", StringComparison.Ordinal) && + lastConsumedTurnIndex >= 0 && + string.Equals( + session[lastConsumedTurnIndex].Role, "user", StringComparison.OrdinalIgnoreCase)) + { + origins.Add(Origin(content, role, null, false, true, false)); + return; + } + + throw AlignmentFailure(entry.QuestionId); + } + + LongMemEvalMessageOrigin Origin( + string content, + string role, + int? sourceTurnOrdinal, + bool syntheticBoundary, + bool syntheticFormatterPadding, + bool hasAnswer) => new( + MessageOrdinal: messageOrdinal++, + SourceSessionId: sessionId, + SourceSessionOrdinal: sessionIndex, + SourceTurnOrdinal: sourceTurnOrdinal, + SourceTimestamp: sourceTimestamp, + Role: role, + FormattedContent: content, + IsSyntheticBoundary: syntheticBoundary, + IsSyntheticFormatterPadding: syntheticFormatterPadding, + HasAnswer: hasAnswer); + } + if (formattedIndex != formatted.Count || origins.Count != formatted.Count * 2) + throw AlignmentFailure(entry.QuestionId); + + return new LongMemEvalEvidenceQuestion( + entry.QuestionId, + entry.QuestionType, + entry.Question, + BuildInvocationPrompt(entry), + entry.Answer, + entry.QuestionDate ?? string.Empty, + entry.IsAbstention, + (entry.AnswerSessionIds ?? []).ToHashSet(StringComparer.Ordinal), + sessions.Sum(session => session.Count(turn => turn.HasAnswer is true)), + origins.AsReadOnly()); + } + + private static string BuildInvocationPrompt(LongMemEvalEntry entry) => + string.IsNullOrEmpty(entry.QuestionDate) + ? entry.Question + : $"Current Date: {entry.QuestionDate}\n\n{entry.Question}"; + + private static bool IsSessionBoundary((string UserMessage, string AssistantResponse) turn) => + turn.UserMessage.StartsWith("--- Session ", StringComparison.Ordinal) && + turn.UserMessage.EndsWith(" ---", StringComparison.Ordinal) && + string.Equals( + turn.AssistantResponse, + "Understood. Starting a new conversation session.", + StringComparison.Ordinal); + + private static InvalidOperationException AlignmentFailure(string questionId) => new( + $"AgentEval formatted history could not be aligned to source turns for LongMemEval question {questionId}."); + + internal static string Fingerprint( + IReadOnlyList<(string UserMessage, string AssistantResponse)> history) + { + var builder = new StringBuilder(); + foreach (var (user, assistant) in history) + { + Append(user); + Append(assistant); + } + + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()))); + + void Append(string value) => builder.Append(value.Length).Append(':').Append(value).Append('|'); + } +} + +internal sealed record LongMemEvalEvidenceQuestion( + string QuestionId, + string QuestionType, + string Question, + string InvocationPrompt, + string GoldAnswer, + string QuestionDate, + bool IsAbstention, + IReadOnlySet AnswerSessionIds, + int AnnotatedGoldTurnCount, + IReadOnlyList Messages) +; + +internal sealed record LongMemEvalMessageOrigin( + int MessageOrdinal, + string SourceSessionId, + int SourceSessionOrdinal, + int? SourceTurnOrdinal, + string SourceTimestamp, + string Role, + string FormattedContent, + bool IsSyntheticBoundary, + bool IsSyntheticFormatterPadding, + bool HasAnswer); + +public sealed record LongMemEvalRankedEvidence( + string MessageId, + int RetrievalRank, + int ContextRank, + double SimilarityScore, + string SourceSessionId, + int SourceSessionOrdinal, + int? SourceTurnOrdinal, + string SourceTimestamp, + string Role, + bool IsSyntheticBoundary, + bool IsSyntheticFormatterPadding, + bool GoldSessionHit, + bool GoldTurnHit, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Content); + +public sealed record LongMemEvalRetrievalEvidence( + int K, + int AnswerPromptCharacters, + int EstimatedAnswerPromptTokens, + int DistinctSourceSessions, + int MaxItemsFromSingleSession, + int GoldSessionsRequired, + int GoldSessionsHit, + double? GoldSessionRecallAtK, + int AnnotatedGoldTurns, + int GoldTurnsHit, + bool? GoldTurnHitAtK, + int? FirstGoldSessionRank, + int? FirstGoldTurnRank, + double? ReciprocalRank, + IReadOnlyList RankedItems, + bool GoldAttributionObservable = true) +{ + /// + /// The recall budget's message allowance. Gold attribution is resolved through recalled raw + /// messages, so when this is zero — as in Structured mode — retrieval was never given the chance + /// to hit a gold turn and the gold metrics are not observable rather than zero. Reporting + /// 0.0 here previously caused every failed structured question to be classified a + /// retrieval-miss, manufacturing a product defect out of a harness limitation. + /// + internal static LongMemEvalRetrievalEvidence Build( + LongMemEvalEvidenceQuestion question, + IReadOnlyList recalled, + IReadOnlyList rankedItems, + IReadOnlyDictionary originsByMessageId, + LongMemEvalEvidenceDetail detail, + int answerPromptCharacters, + int configuredMessageBudget) + { + ArgumentNullException.ThrowIfNull(question); + ArgumentNullException.ThrowIfNull(recalled); + ArgumentNullException.ThrowIfNull(rankedItems); + ArgumentNullException.ThrowIfNull(originsByMessageId); + if (answerPromptCharacters < 0) + throw new ArgumentOutOfRangeException(nameof(answerPromptCharacters)); + + if (rankedItems.Count != recalled.Count) + { + throw new InvalidOperationException( + $"Ranked retrieval evidence contained {rankedItems.Count} entries for {recalled.Count} recalled messages."); + } + + var recalledById = recalled.ToDictionary(message => message.MessageId, StringComparer.Ordinal); + var evidence = new List(rankedItems.Count); + foreach (var ranked in rankedItems.OrderBy(item => item.ContextRank)) + { + if (!recalledById.TryGetValue(ranked.ItemId, out var message) || + !originsByMessageId.TryGetValue(ranked.ItemId, out var origin)) + { + throw new InvalidOperationException( + $"Ranked LongMemEval item {ranked.ItemId} could not be mapped to its source turn."); + } + + evidence.Add(new LongMemEvalRankedEvidence( + ranked.ItemId, + ranked.RetrievalRank, + ranked.ContextRank, + ranked.Score, + origin.SourceSessionId, + origin.SourceSessionOrdinal, + origin.SourceTurnOrdinal, + origin.SourceTimestamp, + origin.Role, + origin.IsSyntheticBoundary, + origin.IsSyntheticFormatterPadding, + question.AnswerSessionIds.Contains(origin.SourceSessionId) && + !origin.IsSyntheticBoundary && + !origin.IsSyntheticFormatterPadding, + origin.HasAnswer, + detail == LongMemEvalEvidenceDetail.Content ? message.Content : null)); + } + + var sourceSessionCounts = evidence + .GroupBy(item => item.SourceSessionId, StringComparer.Ordinal) + .Select(group => group.Count()) + .ToArray(); + var goldSessionsHit = evidence + .Where(item => item.GoldSessionHit) + .Select(item => item.SourceSessionId) + .Distinct(StringComparer.Ordinal) + .Count(); + var annotatedGoldTurns = question.AnnotatedGoldTurnCount; + var goldTurnsHit = evidence.Count(item => item.GoldTurnHit); + var firstGoldSessionRank = evidence + .Where(item => item.GoldSessionHit) + .Select(item => (int?)item.ContextRank) + .FirstOrDefault(); + var firstGoldTurnRank = evidence + .Where(item => item.GoldTurnHit) + .Select(item => (int?)item.ContextRank) + .FirstOrDefault(); + + // Gold attribution rides entirely on recalled raw messages. Without a message budget there is + // nothing it could ever have matched, so every gold metric is unobservable, not zero. + var observable = configuredMessageBudget > 0; + + return new LongMemEvalRetrievalEvidence( + K: recalled.Count, + AnswerPromptCharacters: answerPromptCharacters, + EstimatedAnswerPromptTokens: (answerPromptCharacters + 3) / 4, + DistinctSourceSessions: sourceSessionCounts.Length, + MaxItemsFromSingleSession: sourceSessionCounts.DefaultIfEmpty(0).Max(), + GoldSessionsRequired: question.AnswerSessionIds.Count, + GoldSessionsHit: goldSessionsHit, + GoldSessionRecallAtK: !observable || question.AnswerSessionIds.Count == 0 + ? null + : (double)goldSessionsHit / question.AnswerSessionIds.Count, + AnnotatedGoldTurns: annotatedGoldTurns, + GoldTurnsHit: goldTurnsHit, + GoldTurnHitAtK: !observable || annotatedGoldTurns == 0 ? null : goldTurnsHit > 0, + FirstGoldSessionRank: firstGoldSessionRank, + FirstGoldTurnRank: firstGoldTurnRank, + ReciprocalRank: observable && firstGoldSessionRank is int rank ? 1d / rank : null, + RankedItems: detail == LongMemEvalEvidenceDetail.None + ? Array.Empty() + : evidence.AsReadOnly(), + GoldAttributionObservable: observable); + } +} \ No newline at end of file diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalGraphProbe.cs b/tools/AgentMemory.LongMemEval/LongMemEvalGraphProbe.cs new file mode 100644 index 00000000..39ea166c --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalGraphProbe.cs @@ -0,0 +1,239 @@ +using Neo4j.Driver; + +namespace AgentMemory.LongMemEval; + +internal interface ILongMemEvalGraphProbe +{ + Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default); + + /// + /// G3B.5. Whether the cold build actually learned anything from the sessions that hold the + /// answer, checked before any evaluation call is spent on the graph. + /// + /// + /// The existing read-back proves the graph is sound — non-empty, fully provenanced, and + /// bit-identical to the sealed snapshot. It cannot prove it is adequate: three facts + /// learned from 474 sessions would pass every current guard. This asks the question that decides + /// whether Structured mode can work at all, and separates "extraction lost the fact" from + /// "retrieval missed it" — a distinction BUG-E1 left unattributable. + /// + /// + /// Defaults to meaning not measured — deliberately not "fine". A + /// probe that cannot answer must not be able to assert coverage it never checked, so the absent + /// case falls through to the pre-existing verdicts rather than silently reporting a clean build. + /// + Task ReadGoldCoverageAsync( + string ownerId, + IReadOnlyList goldSourceMessageIds, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + /// + /// L2. How many live facts the owner's graph holds under each given stored predicate key. + /// + /// + /// The denominator of relation completeness, and the reason Phase L exists. It is a + /// deterministic count() over the graph, so unlike an accuracy score it is not subject to + /// answer-model or judge non-determinism: if an extraction change stops learning a relation the + /// questions need, this number drops and says so. That is the extraction-quality signal the + /// deterministic fixture (pinned at 1.000 by construction) and the LongMemEval channel + /// (sd 9.3 cold-build) both fail to provide. + /// + /// + /// Defaults to meaning not measured, matching + /// . A probe that cannot answer must not be able to assert a + /// completeness it never checked. An empty returns an empty + /// dictionary instead — "nothing to count" is a measured answer, not an absent one. + /// + Task?> ReadRelationFactCountsAsync( + string ownerId, + IReadOnlyList predicateKeys, + CancellationToken cancellationToken = default) => + Task.FromResult?>(null); +} + + +internal sealed class Neo4jLongMemEvalGraphProbe(IDriver driver) : ILongMemEvalGraphProbe +{ + + /// + /// Mirrors FactQueries.SearchByCanonicalPredicates' WHERE clause exactly, minus + /// ORDER BY/LIMIT, so the denominator counts precisely the rows expansion could have returned. + /// + /// + /// Deliberately NOT coalesce(f.predicate_key, toLower(f.predicate)), which the predicate + /// distribution program uses: a fact whose predicate_key is null is invisible to + /// expansion, so counting it here would report an unreachable fact as a retrieval miss. + /// + private const string RelationFactCountQuery = + """ + MATCH (f:Fact) + WHERE f.predicate_key IN $predicateKeys + AND f.invalidated_at IS NULL + AND (f.owner_id = $ownerId OR f.owner_id IS NULL) + RETURN f.predicate_key AS predicateKey, count(f) AS factCount + """; + + public async Task?> ReadRelationFactCountsAsync( + string ownerId, + IReadOnlyList predicateKeys, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(predicateKeys); + if (predicateKeys.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var (records, _, _) = await driver.ExecutableQuery(RelationFactCountQuery) + .WithParameters(new Dictionary + { + ["ownerId"] = ownerId, + ["predicateKeys"] = predicateKeys.ToList() + }) + .WithConfig(new QueryConfig(routing: RoutingControl.Readers)) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + + return records.ToDictionary( + record => record["predicateKey"].As(), + record => record["factCount"].As(), + StringComparer.Ordinal); + } + + private const string SnapshotQuery = + """ + CALL { + MATCH (e:Entity {owner_id: $ownerId}) + RETURN count(e) AS entities + } + CALL { + MATCH (f:Fact {owner_id: $ownerId}) + RETURN count(f) AS facts + } + CALL { + MATCH (p:Preference {owner_id: $ownerId}) + RETURN count(p) AS preferences + } + CALL { + MATCH ()-[r:RELATED_TO]->() + WHERE r.owner_id = $ownerId + RETURN count(r) AS relationships, + count(CASE WHEN size(coalesce(r.source_message_ids, [])) > 0 THEN 1 END) + AS relationshipsWithProvenance + } + CALL { + MATCH (n) + WHERE n.owner_id = $ownerId AND (n:Entity OR n:Fact OR n:Preference) + OPTIONAL MATCH (n)-[:EXTRACTED_FROM]->(m:Message) + RETURN count(DISTINCT n) AS learnedItems, + count(DISTINCT CASE WHEN m IS NOT NULL THEN n END) AS learnedItemsWithProvenance, + count(m) AS provenanceEdges, + count(DISTINCT m) AS sourceMessages + } + RETURN entities, facts, preferences, relationships, + relationshipsWithProvenance, learnedItems, learnedItemsWithProvenance, + provenanceEdges, sourceMessages + """; + + private const string GoldCoverageQuery = + """ + MATCH (n)-[:EXTRACTED_FROM]->(m:Message) + WHERE n.owner_id = $ownerId + AND (n:Entity OR n:Fact OR n:Preference) + AND m.id IN $goldSourceMessageIds + RETURN count(DISTINCT n) AS goldLearnedItems, + count(DISTINCT m) AS goldSourceMessagesCovered + """; + + public async Task ReadGoldCoverageAsync( + string ownerId, + IReadOnlyList goldSourceMessageIds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); + ArgumentNullException.ThrowIfNull(goldSourceMessageIds); + if (goldSourceMessageIds.Count == 0) + return new LongMemEvalGoldEvidenceCoverage(0, 0, 0); + + + await using var session = driver.AsyncSession(); + return await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync( + GoldCoverageQuery, + new { ownerId, goldSourceMessageIds = goldSourceMessageIds.ToArray() }) + .ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new LongMemEvalGoldEvidenceCoverage( + record["goldLearnedItems"].As(), + record["goldSourceMessagesCovered"].As(), + goldSourceMessageIds.Count); + }).ConfigureAwait(false); + } + + public async Task ReadAsync( + string ownerId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); + await using var session = driver.AsyncSession(); + return await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync( + SnapshotQuery, + new { ownerId }).ConfigureAwait(false); + var record = await cursor.SingleAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new LongMemEvalGraphSnapshot( + record["entities"].As(), + record["facts"].As(), + record["preferences"].As(), + record["relationships"].As(), + record["relationshipsWithProvenance"].As(), + record["learnedItems"].As(), + record["learnedItemsWithProvenance"].As(), + record["provenanceEdges"].As(), + record["sourceMessages"].As()); + }).ConfigureAwait(false); + } +} + +/// +/// G3B.5. How much of the cold build traces back to the sessions that hold the answer. +/// +public sealed record LongMemEvalGoldEvidenceCoverage( + int GoldLearnedItems, + int GoldSourceMessagesCovered, + int GoldSourceMessages) +{ + /// + /// Zero means Structured mode cannot answer this question however good recall is: nothing + /// the extractor learned came from a session containing the answer. That is an extraction + /// finding, and must never be reported as a retrieval failure. + /// + public bool EvidenceLearned => GoldLearnedItems > 0; + + /// Fraction of answer-bearing source messages that contributed any learned item. + public double SourceMessageCoverage => + GoldSourceMessages == 0 ? 0d : (double)GoldSourceMessagesCovered / GoldSourceMessages; +} + +public sealed record LongMemEvalGraphSnapshot( + int Entities, + int Facts, + int Preferences, + int Relationships, + int RelationshipsWithProvenance, + int LearnedItems, + int LearnedItemsWithProvenance, + int ProvenanceEdges, + int SourceMessages) +{ + public int TotalLearned => Entities + Facts + Preferences + Relationships; + + public bool CompleteProvenance => + LearnedItemsWithProvenance == LearnedItems && + RelationshipsWithProvenance == Relationships; +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalMemoryMode.cs b/tools/AgentMemory.LongMemEval/LongMemEvalMemoryMode.cs new file mode 100644 index 00000000..621ad9c4 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalMemoryMode.cs @@ -0,0 +1,31 @@ +namespace AgentMemory.LongMemEval; + +/// Explicit AgentMemory operating mode used by the LongMemEval adapter. +public enum LongMemEvalMemoryMode +{ + /// Persist and semantically recall raw messages only. + Raw, + + /// Extract structured graph memory and exclude raw messages from answer recall. + Structured, + + /// Recall both raw messages and extracted structured graph memory. + Hybrid +} + +internal static class LongMemEvalMemoryModeExtensions +{ + public static string Fingerprint(this LongMemEvalMemoryMode mode) => mode switch + { + LongMemEvalMemoryMode.Raw => "raw-message-vector-control", + LongMemEvalMemoryMode.Structured => "structured-graph", + LongMemEvalMemoryMode.Hybrid => "hybrid-message-and-graph", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; + + public static bool UsesExtraction(this LongMemEvalMemoryMode mode) => + mode is LongMemEvalMemoryMode.Structured or LongMemEvalMemoryMode.Hybrid; + + public static bool UsesRawRecall(this LongMemEvalMemoryMode mode) => + mode is LongMemEvalMemoryMode.Raw or LongMemEvalMemoryMode.Hybrid; +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalMemoryProfile.cs b/tools/AgentMemory.LongMemEval/LongMemEvalMemoryProfile.cs new file mode 100644 index 00000000..f94096a1 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalMemoryProfile.cs @@ -0,0 +1,223 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Abstractions.Services; +using AgentMemory.Neo4j.Infrastructure; +using AgentMemory.Extraction.Llm; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Testcontainers.Neo4j; + +namespace AgentMemory.LongMemEval; + +/// A disposable, pinned Neo4j profile for public LongMemEval characterization runs. +internal sealed class LongMemEvalMemoryProfile : IAsyncDisposable +{ + private const string Image = "neo4j:5.26"; + private const string User = "neo4j"; + private const string Password = "longmemeval-password"; + + private Neo4jContainer? _container; + private ServiceProvider? _provider; + private AsyncServiceScope _scope; + private bool _scopeCreated; + + public IServiceProvider Services => _scope.ServiceProvider; + + public static async Task StartAsync( + IEmbeddingGenerator> embeddingGenerator, + IChatClient? extractionChatClient, + LongMemEvalMemoryMode memoryMode, + string? extractionModelId, + int embeddingDimensions, + TextWriter log, + CancellationToken cancellationToken, + string? volumeName = null, + bool enableBatchedPreparation = false, + int maxConcurrentBatchesPerExtraction = 1, + int maxConcurrentExtractionBatches = 0, + bool usePredicateVocabulary = false, + string? graphRagIndexName = null) + { + ArgumentNullException.ThrowIfNull(embeddingGenerator); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(embeddingDimensions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero( + maxConcurrentBatchesPerExtraction); + ArgumentOutOfRangeException.ThrowIfNegative(maxConcurrentExtractionBatches); + + if (memoryMode.UsesExtraction() && extractionChatClient is null) + { + throw new ArgumentNullException( + nameof(extractionChatClient), "Structured and hybrid modes require a real extraction chat client."); + } + var profile = new LongMemEvalMemoryProfile(); + try + { + await profile.InitializeAsync( + embeddingGenerator, + extractionChatClient, + memoryMode, + extractionModelId, + embeddingDimensions, + log, + volumeName, + enableBatchedPreparation, + maxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches, + usePredicateVocabulary, + graphRagIndexName, + cancellationToken) + .ConfigureAwait(false); + return profile; + } + catch + { + await profile.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private async Task InitializeAsync( + IEmbeddingGenerator> embeddingGenerator, + IChatClient? extractionChatClient, + LongMemEvalMemoryMode memoryMode, + string? extractionModelId, + int embeddingDimensions, + TextWriter log, + string? volumeName, + bool enableBatchedPreparation, + int maxConcurrentBatchesPerExtraction, + int maxConcurrentExtractionBatches, + bool usePredicateVocabulary, + string? graphRagIndexName, + CancellationToken cancellationToken) + { + log.WriteLine($"longmemeval: starting {Image}..."); + var builder = new Neo4jBuilder(Image) + .WithEnvironment("NEO4J_AUTH", $"{User}/{Password}"); + if (!string.IsNullOrWhiteSpace(volumeName)) + builder = builder.WithVolumeMount(volumeName, "/data"); + + _container = builder.Build(); + await _container.StartAsync(cancellationToken).ConfigureAwait(false); + + var services = ConfigureServices( + _container.GetConnectionString(), + embeddingGenerator, + extractionChatClient, + memoryMode, + extractionModelId, + embeddingDimensions, + enableBatchedPreparation, + maxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches, + usePredicateVocabulary, + graphRagIndexName); + + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateAsyncScope(); + _scopeCreated = true; + + await Services.GetRequiredService() + .BootstrapAsync(cancellationToken) + .ConfigureAwait(false); + log.WriteLine("longmemeval: schema ready."); + } + + /// + /// The profile's DI wiring, separated from container startup so it can be asserted on without a + /// live Neo4j. + /// + /// + /// K6 measured GraphRAG returning zero items and very nearly reported that as a property of the + /// surface. It was a wiring fault, and it cost a full evaluation run to find. Registration that + /// can only be exercised by paying for a run is registration that gets verified by spending + /// money, so this is reachable from a test instead. + /// + internal static ServiceCollection ConfigureServices( + string neo4jUri, + IEmbeddingGenerator> embeddingGenerator, + IChatClient? extractionChatClient, + LongMemEvalMemoryMode memoryMode, + string? extractionModelId, + int embeddingDimensions, + bool enableBatchedPreparation, + int maxConcurrentBatchesPerExtraction, + int maxConcurrentExtractionBatches, + bool usePredicateVocabulary, + string? graphRagIndexName) + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Warning)); + Action? configureLlm = memoryMode.UsesExtraction() + ? options => + { + options.ModelId = extractionModelId; + options.Temperature = 0; + options.MaxRetries = 2; + options.UseJsonResponseFormat = true; + options.UseUnifiedExtraction = enableBatchedPreparation; + options.UseMultiSessionBatchExtraction = enableBatchedPreparation; + options.MaxConcurrentBatchesPerExtraction = maxConcurrentBatchesPerExtraction; + options.MaxConcurrentExtractionBatches = maxConcurrentExtractionBatches; + options.UsePredicateVocabulary = usePredicateVocabulary; + } + : null; + services.AddNeo4jAgentMemory( + // K9.1: the instance overload. The Action one cannot set anything - + // MemoryOptions is an init-only record, so a configure lambda can neither assign its + // properties nor keep a `with` expression's result. + new MemoryOptions { EnableGraphRag = graphRagIndexName is not null }, + neo4j => + { + neo4j.Uri = neo4jUri; + neo4j.Username = User; + neo4j.Password = Password; + neo4j.Database = "neo4j"; + neo4j.EmbeddingDimensions = embeddingDimensions; + }, configureLlm); + + if (graphRagIndexName is not null) + { + // Deliberately pointed at one of the memory layer's own vector indexes, because this + // corpus contains no separate knowledge graph - which is the setting upstream actually + // targets. That limits what the result can mean, and the limit is recorded rather than + // discovered afterwards. + // + // The retrieval query is not optional decoration. K10: with the default projection, a + // Fact node has no `text` or `content` property, so every item's prompt text becomes the + // driver's dump of the whole node - embedding vector included. Projecting the triple + // explicitly also carries fact_id through into metadata, which is what makes the + // duplication measurement possible at all. + services.AddGraphRagAdapter(graphRag => + { + graphRag.IndexName = graphRagIndexName; + graphRag.SearchMode = GraphRagSearchMode.Vector; + graphRag.RetrievalQuery = + "RETURN node.subject + ' ' + node.predicate + ' ' + node.object AS text, " + + "node.id AS fact_id, score"; + }); + } + + services.RemoveAll>>(); + services.AddSingleton>>( + embeddingGenerator); + + if (extractionChatClient is not null) + services.AddSingleton(extractionChatClient); + + return services; + } + + public async ValueTask DisposeAsync() + { + if (_scopeCreated) + await _scope.DisposeAsync().ConfigureAwait(false); + if (_provider is not null) + await _provider.DisposeAsync().ConfigureAwait(false); + if (_container is not null) + await _container.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalOrphanSweep.cs b/tools/AgentMemory.LongMemEval/LongMemEvalOrphanSweep.cs new file mode 100644 index 00000000..3c84373a --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalOrphanSweep.cs @@ -0,0 +1,309 @@ +using System.Diagnostics; +using System.Globalization; + +namespace AgentMemory.LongMemEval; + +internal sealed record LongMemEvalVolumeCandidate(string Name, DateTimeOffset CreatedAt); + +internal sealed record LongMemEvalOrphanSkip(string Name, string Reason); + +internal sealed record LongMemEvalOrphanSweepDecision( + IReadOnlyList Removable, + IReadOnlyList Skipped); + +/// +/// G3B.12-R. Removes prepared volumes left behind by runs that were killed before they could clean +/// up after themselves. +/// +/// +/// Written against a measurement rather than an assumption: the leak was specified as abandoned +/// Neo4j containers holding retained volumes, but docker ps -a was empty and every volume +/// reported zero links, so Testcontainers' reaper had in fact removed every container. The real leak +/// was 25 orphaned volumes holding about 17.2 GB. This therefore sweeps volumes, not containers. +/// +/// Attachment is not checked by listing containers first: that answer can go stale between the check +/// and the delete. The daemon is asked to remove the volume and its own "volume is in use" refusal is +/// reported as a skip, which is atomic. --force is never passed. +/// +/// +internal static class LongMemEvalOrphanSweep +{ + /// The prefix gives every volume it creates. + internal const string Prefix = "am-lme-"; + + /// + /// A volume younger than this may belong to a run that is executing right now: clone targets are + /// created up front and only mounted once a ~22 minute preparation finishes, so for that whole + /// window a live run owns volumes that nothing is attached to yet. + /// + internal static readonly TimeSpan DefaultMinimumAge = TimeSpan.FromMinutes(120); + + internal static LongMemEvalOrphanSweepDecision Select( + IReadOnlyList candidates, + string? protectedVolumeName, + DateTimeOffset now, + TimeSpan? minimumAge = null, + IReadOnlyCollection? pinned = null) + { + ArgumentNullException.ThrowIfNull(candidates); + var age = minimumAge ?? DefaultMinimumAge; + var pins = pinned is null + ? new HashSet(StringComparer.Ordinal) + : new HashSet(pinned, StringComparer.Ordinal); + + // Unrelated volumes on a developer machine are not this tool's property to delete, and are + // not reported either - listing them as skips would bury the real output in noise. + var ours = candidates + .Where(candidate => candidate.Name.StartsWith(Prefix, StringComparison.Ordinal)) + .ToArray(); + + var newestBase = ours + .Where(candidate => IsBase(candidate.Name)) + .OrderByDescending(candidate => candidate.CreatedAt) + .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) + .FirstOrDefault(); + + var surviving = new HashSet( + ours.Select(candidate => candidate.Name), StringComparer.Ordinal); + + var removable = new List(); + var skipped = new List(); + foreach (var candidate in ours) + { + if (IsProtected(candidate.Name, protectedVolumeName)) + { + skipped.Add(new LongMemEvalOrphanSkip( + candidate.Name, "named for reuse by this run")); + continue; + } + + if (pins.Contains(candidate.Name)) + { + skipped.Add(new LongMemEvalOrphanSkip(candidate.Name, "pinned by the operator")); + continue; + } + + // A clone is only cheap to recreate while the base it was cloned from still exists. + // Treating "clone" as a synonym for "worthless" destroyed a deliberately retained + // pre-vocabulary baseline whose base had already been removed - it was the only copy. + if (BaseNameOf(candidate.Name) is { } baseName && !surviving.Contains(baseName)) + { + skipped.Add(new LongMemEvalOrphanSkip( + candidate.Name, + "its base volume is gone, so it cannot be regenerated and is the only copy")); + continue; + } + + if (now - candidate.CreatedAt < age) + { + skipped.Add(new LongMemEvalOrphanSkip( + candidate.Name, + $"below the minimum age of {age.TotalMinutes:F0} minutes; a concurrent run may own it")); + continue; + } + + if (newestBase is not null && + string.Equals(candidate.Name, newestBase.Name, StringComparison.Ordinal)) + { + skipped.Add(new LongMemEvalOrphanSkip( + candidate.Name, "newest cold build; it cost 121 provider calls")); + continue; + } + + removable.Add(candidate.Name); + } + + return new LongMemEvalOrphanSweepDecision(removable, skipped); + } + + /// + /// Lists prepared volumes, removes the ones the policy selects, and reports what it did. + /// + /// + /// Never throws and never fails the run: a housekeeping step must not be able to stop an + /// evaluation that would otherwise succeed. + /// + internal static async Task RunAsync( + string? protectedVolumeName, + TextWriter log, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(log); + try + { + var listing = await DockerAsync( + ["volume", "ls", "--format", "{{.Name}}"], cancellationToken) + .ConfigureAwait(false); + if (listing.ExitCode != 0) + { + log.WriteLine("longmemeval: orphan sweep skipped; could not list Docker volumes."); + return; + } + + var names = listing.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(name => name.StartsWith(Prefix, StringComparison.Ordinal)) + .ToArray(); + if (names.Length == 0) + return; + + var candidates = await InspectAsync(names, cancellationToken).ConfigureAwait(false); + var decision = Select( + candidates, + protectedVolumeName, + DateTimeOffset.UtcNow, + minimumAge: null, + pinned: ReadPins()); + if (decision.Removable.Count == 0) + { + log.WriteLine( + $"longmemeval: orphan sweep found {candidates.Count} prepared volumes and removed none."); + return; + } + + var removed = 0; + foreach (var name in decision.Removable) + { + var removal = await DockerAsync(["volume", "rm", name], cancellationToken) + .ConfigureAwait(false); + if (removal.ExitCode == 0) + { + removed++; + continue; + } + + // The daemon's own refusal is the authoritative in-use answer. + log.WriteLine( + $"longmemeval: orphan sweep kept {name}: {Summarize(removal.StandardError)}"); + } + + log.WriteLine( + $"longmemeval: orphan sweep removed {removed} of {candidates.Count} prepared volumes; " + + $"kept {candidates.Count - removed}."); + foreach (var skip in decision.Skipped) + log.WriteLine($"longmemeval: orphan sweep kept {skip.Name}: {skip.Reason}."); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + log.WriteLine($"longmemeval: orphan sweep skipped: {exception.Message}"); + } + } + + /// + /// Volumes the operator has deliberately kept, one name per line, # for comments. + /// + /// + /// An explicit, inspectable pin exists because a document-level note that a volume was "kept + /// deliberately" is invisible to a sweep, and one was destroyed for exactly that reason. + /// + internal static string PinFilePath { get; } = + Path.Combine("artifacts", "evaluation", "pinned-volumes.txt"); + + private static IReadOnlyCollection ReadPins() + { + if (!File.Exists(PinFilePath)) + return []; + return File.ReadAllLines(PinFilePath) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith('#')) + .ToArray(); + } + + /// + /// The base volume a clone was produced from, or null when the name is not a clone. + /// + private static string? BaseNameOf(string name) + { + // AdoptAsync names its targets "{base}-reuse-structured-{suffix}". + var reuse = name.IndexOf("-reuse-", StringComparison.Ordinal); + if (reuse > 0) + return name[..reuse]; + + foreach (var kind in new[] { "-structured-", "-hybrid-" }) + { + var index = name.IndexOf(kind, StringComparison.Ordinal); + if (index > 0) + return string.Concat(name.AsSpan(0, index), "-base-", name.AsSpan(index + kind.Length)); + } + + return null; + } + + private static bool IsBase(string name) => + name.Contains("-base-", StringComparison.Ordinal); + + private static bool IsProtected(string name, string? protectedVolumeName) => + !string.IsNullOrWhiteSpace(protectedVolumeName) && + // Clone targets are named after the base they were adopted from, so one prefix test covers + // the adopted build and the in-flight clones this run just created beside it. + name.StartsWith(protectedVolumeName, StringComparison.Ordinal); + + private static async Task> InspectAsync( + IReadOnlyList names, + CancellationToken cancellationToken) + { + var arguments = new List(names.Count + 3) + { + "volume", "inspect", "--format", "{{.Name}}\t{{.CreatedAt}}" + }; + arguments.AddRange(names); + var inspection = await DockerAsync(arguments, cancellationToken).ConfigureAwait(false); + + var candidates = new List(names.Count); + foreach (var line in inspection.StandardOutput.Split( + '\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = line.IndexOf('\t', StringComparison.Ordinal); + if (separator <= 0) + continue; + var name = line[..separator]; + if (!DateTimeOffset.TryParse( + line[(separator + 1)..], + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var createdAt)) + { + // An unparseable creation time means the age guard cannot be evaluated, so the + // volume is simply not a candidate for removal. + continue; + } + + candidates.Add(new LongMemEvalVolumeCandidate(name, createdAt)); + } + + return candidates; + } + + private static string Summarize(string standardError) + { + var first = standardError + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault() ?? "removal failed"; + return first.Length > 200 ? first[..200] : first; + } + + private static async Task<(int ExitCode, string StandardOutput, string StandardError)> DockerAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo("docker") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the Docker CLI."); + var standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardError = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + return ( + process.ExitCode, + await standardOutput.ConfigureAwait(false), + await standardError.ConfigureAwait(false)); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPostRunDiagnostics.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPostRunDiagnostics.cs new file mode 100644 index 00000000..dc98be70 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPostRunDiagnostics.cs @@ -0,0 +1,406 @@ +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AgentMemory.LongMemEval; + +internal enum LongMemEvalOracleMode +{ + None, + Failed, + All +} + +public sealed record LongMemEvalJudgeRetryResult( + string QuestionId, + string Status, + int Attempts, + bool ValidVerdict, + bool? Correct, + double? RawScore, + int LlmCalls, + /// + /// Why the verdict was not usable: threw:<ExceptionType> or unparseable. + /// + /// + /// The hybrid arm has been rejected three times on one question's judge verdict, and every time + /// the reason was unknowable from the artifact: a bare catch made a provider failure look + /// identical to a badly-shaped answer. Both are recorded now. Neither carries provider detail or + /// user content — a type name and a single leading token are enough to tell the two apart. + /// + string? FailureKind = null, + /// + /// The leading letter-token the parser rejected, which is the judge's own verdict word (e.g. + /// "Partially"). Never the explanation body. + /// + string? RejectedToken = null); + +public sealed record LongMemEvalOracleResult( + string QuestionId, + string Status, + string? Answer, + bool ValidVerdict, + bool? Correct, + double? RawScore, + int LlmCalls); + +public sealed record LongMemEvalFailureAttribution( + string QuestionId, + string Attribution, + double? GoldSessionRecallAtK, + bool? GoldTurnHitAtK, + int? FirstGoldSessionRank, + int? FirstGoldTurnRank); + +public sealed record LongMemEvalPostRunDiagnosticsResult( + int DiagnosticLlmCalls, + IReadOnlyList JudgeRetries, + IReadOnlyList OracleResults, + IReadOnlyList Attributions); + +/// +/// Runs diagnostics after AgentEval has produced the immutable benchmark result. Results from retries and +/// oracle evidence are reported separately and never rewrite the benchmark score or call count. +/// +internal static class LongMemEvalPostRunDiagnostics +{ + internal static async Task RunAsync( + IChatClient chatClient, + LongMemEvalEvidenceIndex evidenceIndex, + IReadOnlyList questionResults, + IReadOnlyList telemetry, + LongMemEvalOracleMode oracleMode, + int judgeRetryAttempts, + bool retainContent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentNullException.ThrowIfNull(evidenceIndex); + ArgumentNullException.ThrowIfNull(questionResults); + ArgumentNullException.ThrowIfNull(telemetry); + if (judgeRetryAttempts < 0) + throw new ArgumentOutOfRangeException(nameof(judgeRetryAttempts)); + + var judge = new LongMemEvalJudge( + chatClient, + NullLogger.Instance); + var retries = new List(); + var oracleResults = new List(); + var diagnosticCalls = 0; + + foreach (var question in questionResults.Where(NeedsJudgeRetry)) + { + var indexed = evidenceIndex.GetByQuestionId(question.QuestionId); + var retry = await RetryJudgeAsync( + judge, indexed, question.AgentResponse, judgeRetryAttempts, cancellationToken) + .ConfigureAwait(false); + retries.Add(retry); + diagnosticCalls += retry.LlmCalls; + } + + foreach (var question in questionResults.Where(question => + ShouldRunOracle(question, oracleMode))) + { + var indexed = evidenceIndex.GetByQuestionId(question.QuestionId); + var oracle = await RunOracleAsync( + chatClient, judge, indexed, retainContent, cancellationToken).ConfigureAwait(false); + oracleResults.Add(oracle); + diagnosticCalls += oracle.LlmCalls; + } + + var retriesByQuestion = retries.ToDictionary(result => result.QuestionId, StringComparer.Ordinal); + var oracleByQuestion = oracleResults.ToDictionary(result => result.QuestionId, StringComparer.Ordinal); + var evidenceByQuestion = telemetry + .Where(item => item.QuestionId is not null) + .ToDictionary(item => item.QuestionId!, item => item.RetrievalEvidence, StringComparer.Ordinal); + var coverageByQuestion = telemetry + .Where(item => item.QuestionId is not null) + .ToDictionary(item => item.QuestionId!, item => item.GoldEvidenceCoverage, StringComparer.Ordinal); + var attributions = questionResults.Select(question => + { + retriesByQuestion.TryGetValue(question.QuestionId, out var retry); + oracleByQuestion.TryGetValue(question.QuestionId, out var oracle); + evidenceByQuestion.TryGetValue(question.QuestionId, out var evidence); + coverageByQuestion.TryGetValue(question.QuestionId, out var coverage); + return new LongMemEvalFailureAttribution( + question.QuestionId, + Attribute(question, retry, oracle, evidence, coverage), + evidence?.GoldSessionRecallAtK, + evidence?.GoldTurnHitAtK, + evidence?.FirstGoldSessionRank, + evidence?.FirstGoldTurnRank); + }).ToArray(); + + return new LongMemEvalPostRunDiagnosticsResult( + diagnosticCalls, + retries.AsReadOnly(), + oracleResults.AsReadOnly(), + attributions); + } + + /// + /// G4-REF. The judge-retry pass alone, without oracle or gold-attribution. A reference arm has no + /// retrieval, so running against it would label every failure + /// retrieval-evidence-missing — inventing a retrieval cause for an arm that has no + /// retrieval. Retries still matter, because BUG-J1's base-call accounting depends on them. + /// + internal static async Task> RetryInvalidJudgeVerdictsAsync( + IChatClient chatClient, + LongMemEvalEvidenceIndex evidenceIndex, + IReadOnlyList questionResults, + int judgeRetryAttempts, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentNullException.ThrowIfNull(evidenceIndex); + ArgumentNullException.ThrowIfNull(questionResults); + if (judgeRetryAttempts < 0) + throw new ArgumentOutOfRangeException(nameof(judgeRetryAttempts)); + + var judge = new LongMemEvalJudge(chatClient, NullLogger.Instance); + var retries = new List(); + foreach (var question in questionResults.Where(NeedsJudgeRetry)) + { + retries.Add(await RetryJudgeAsync( + judge, + evidenceIndex.GetByQuestionId(question.QuestionId), + question.AgentResponse, + judgeRetryAttempts, + cancellationToken) + .ConfigureAwait(false)); + } + + return retries.AsReadOnly(); + } + + internal static string Attribute( + QuestionResult question, + LongMemEvalJudgeRetryResult? retry, + LongMemEvalOracleResult? oracle, + LongMemEvalRetrievalEvidence? evidence, + LongMemEvalGoldEvidenceCoverage? goldCoverage = null) + { + ArgumentNullException.ThrowIfNull(question); + + if (!LongMemEvalRunValidator.TryParseJudgeVerdict( + question.JudgeExplanation, out var baseVerdict)) + { + if (retry is { ValidVerdict: true, Correct: true }) + return "judge-invalid-retry-correct"; + if (retry is { ValidVerdict: true, Correct: false }) + return "judge-invalid-retry-incorrect"; + return "judge-invalid"; + } + + if (question.Correct is true && baseVerdict) + return "passed"; + if (question.Correct is null) + return "judge-inconclusive"; + if (question.Correct.Value != baseVerdict) + return "judge-result-mismatch"; + if (oracle is null) + return "incorrect-needs-oracle"; + if (!oracle.ValidVerdict) + return "oracle-inconclusive"; + if (oracle.Correct is not true) + return "oracle-answer-or-benchmark-inconclusive"; + return ClassifyRetrievalEvidence(evidence, goldCoverage); + } + + /// + /// The evidence-dependent tail of , reached only once judge and oracle + /// states are resolved. Shared with so the two cannot drift. + /// + private static string ClassifyRetrievalEvidence( + LongMemEvalRetrievalEvidence? evidence, + LongMemEvalGoldEvidenceCoverage? goldCoverage = null) + { + // G3B.5: if the cold build learned nothing from the answer-bearing sessions, the question was + // unanswerable before recall ever ran. Blaming retrieval - or calling it merely "not + // observable" - would hide an extraction defect behind a retrieval label. + if (goldCoverage is { EvidenceLearned: false }) + return "extraction-lost-evidence"; + if (evidence is null) + return "retrieval-evidence-missing"; + // BUG-E1: gold attribution resolves only through recalled raw messages, so a mode with no + // message budget (Structured) never gave retrieval a chance to hit. Blaming retrieval — or + // falling through to an answer-synthesis verdict — would both be inventing a cause. + if (!evidence.GoldAttributionObservable) + return "retrieval-not-observable"; + if (evidence.GoldSessionRecallAtK is double sessionRecall && sessionRecall < 1d) + return "retrieval-miss"; + if (evidence.GoldTurnHitAtK is false) + return "retrieval-miss"; + return "answer-synthesis-failure"; + } + + /// Test seam for the gold-attribution branches. + internal static string ClassifyForTest( + LongMemEvalRetrievalEvidence? evidence, + LongMemEvalGoldEvidenceCoverage? goldCoverage = null) => + ClassifyRetrievalEvidence(evidence, goldCoverage); + + private static bool NeedsJudgeRetry(QuestionResult question) => + !IsAgentFailure(question) && + !LongMemEvalRunValidator.TryParseJudgeVerdict(question.JudgeExplanation, out _); + + private static bool ShouldRunOracle( + QuestionResult question, + LongMemEvalOracleMode oracleMode) => + !IsAgentFailure(question) && oracleMode switch + { + LongMemEvalOracleMode.All => true, + LongMemEvalOracleMode.Failed => question.Correct is not true || + !LongMemEvalRunValidator.TryParseJudgeVerdict(question.JudgeExplanation, out _), + _ => false + }; + + private static bool IsAgentFailure(QuestionResult question) + { + var response = question.AgentResponse ?? string.Empty; + return response.StartsWith("[ERROR:", StringComparison.OrdinalIgnoreCase) || + response.StartsWith("[CONTENT_FILTER]", StringComparison.OrdinalIgnoreCase) || + (question.JudgeExplanation ?? string.Empty).StartsWith( + "Skipped due to error:", StringComparison.OrdinalIgnoreCase); + } + + private static async Task RetryJudgeAsync( + LongMemEvalJudge judge, + LongMemEvalEvidenceQuestion indexed, + string agentResponse, + int attempts, + CancellationToken cancellationToken) + { + if (attempts == 0) + { + return new LongMemEvalJudgeRetryResult( + indexed.QuestionId, "disabled", 0, false, null, null, 0); + } + + string? failureKind = null; + string? rejectedToken = null; + for (var attempt = 1; attempt <= attempts; attempt++) + { + try + { + var judgment = await judge.JudgeAsync( + agentResponse, + Question(indexed), + cancellationToken).ConfigureAwait(false); + if (!LongMemEvalRunValidator.TryParseJudgeVerdict( + judgment.Explanation, out var parsed)) + { + rejectedToken = LeadingToken(judgment.Explanation); + failureKind = "unparseable"; + } + else if (parsed != judgment.Correct) + { + failureKind = "verdict-disagrees-with-score"; + } + + if (LongMemEvalRunValidator.TryParseJudgeVerdict( + judgment.Explanation, out parsed) && + parsed == judgment.Correct) + { + return new LongMemEvalJudgeRetryResult( + indexed.QuestionId, + "recovered", + attempt, + true, + judgment.Correct, + judgment.RawScore, + attempt); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // Provider details are intentionally excluded from the durable artifact; the type + // name is not a provider detail and is the difference between "the judge refused" + // and "the judge answered in a shape we do not parse". + failureKind = "threw:" + exception.GetType().Name; + } + } + + return new LongMemEvalJudgeRetryResult( + indexed.QuestionId, "invalid", attempts, false, null, null, attempts, + failureKind ?? "unparseable", + rejectedToken); + } + + + /// The leading letter-token of a judge explanation, capped, for diagnostics only. + private static string LeadingToken(string? explanation) + { + if (string.IsNullOrWhiteSpace(explanation)) + return ""; + var trimmed = explanation.TrimStart(); + var token = new string(trimmed.TakeWhile(char.IsLetter).ToArray()); + return token.Length == 0 ? "" : token[..Math.Min(token.Length, 24)]; + } + + private static async Task RunOracleAsync( + IChatClient chatClient, + LongMemEvalJudge judge, + LongMemEvalEvidenceQuestion indexed, + bool retainContent, + CancellationToken cancellationToken) + { + var calls = 0; + try + { + // G3B.2: the oracle gets the same time signal as every other arm, or "perfect retrieval" + // would be measured against a strictly worse prompt than the thing it bounds. + var answerPrompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + indexed.Messages + .Where(message => indexed.AnswerSessionIds.Contains(message.SourceSessionId)) + .Select(message => (message.Role, message.SourceTimestamp, message.FormattedContent)), + indexed.InvocationPrompt, + indexed.QuestionDate); + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, AgentMemoryLongMemEvalAdapter.SystemPrompt), + new ChatMessage(ChatRole.User, answerPrompt) + ], cancellationToken: cancellationToken).ConfigureAwait(false); + calls++; + var answer = response.Text ?? string.Empty; + var judgment = await judge.JudgeAsync( + answer, Question(indexed), cancellationToken).ConfigureAwait(false); + calls++; + var valid = LongMemEvalRunValidator.TryParseJudgeVerdict( + judgment.Explanation, out var parsed) && + parsed == judgment.Correct; + return new LongMemEvalOracleResult( + indexed.QuestionId, + valid ? "completed" : "judge-invalid", + retainContent ? answer : null, + valid, + valid ? judgment.Correct : null, + valid ? judgment.RawScore : null, + calls); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return new LongMemEvalOracleResult( + indexed.QuestionId, "error", null, false, null, null, calls); + } + } + + private static ExternalBenchmarkQuestion Question(LongMemEvalEvidenceQuestion indexed) => new() + { + QuestionId = indexed.QuestionId, + QuestionType = indexed.QuestionType, + Question = indexed.Question, + GoldAnswer = indexed.GoldAnswer, + QuestionDate = indexed.QuestionDate, + IsAbstention = indexed.IsAbstention + }; +} \ No newline at end of file diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistribution.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistribution.cs new file mode 100644 index 00000000..cb13d309 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistribution.cs @@ -0,0 +1,173 @@ +using System.Text; +using AgentMemory.Core.Memory; + +namespace AgentMemory.LongMemEval; + +/// One canonical predicate as it actually occurs in an extracted graph. +/// +/// Deliberately carries no subject or object. A predicate is vocabulary; a subject or object is user +/// content, and this table is written to an artifact. +/// +internal sealed record LongMemEvalPredicateCount(string Predicate, int FactCount, int OwnerCount); + +internal sealed record LongMemEvalPredicateDistributionSummary( + int RawPredicateCount, + int CanonicalPredicateCount, + int TotalFactCount, + int OwnerCount, + IReadOnlyList Predicates) +{ + /// + /// How many surface predicates collapsed onto each canonical one. This measures the + /// canonicalizer, which folds case and separators only, and is expected to sit near 1.00. + /// It is not the vocabulary consolidation figure, which is measured per owner. + /// + internal double ConsolidationRatio => CanonicalPredicateCount == 0 + ? 0 + : (double)RawPredicateCount / CanonicalPredicateCount; + + internal int MinPredicatesPerOwner { get; init; } + + internal int MaxPredicatesPerOwner { get; init; } + + internal double AveragePredicatesPerOwner { get; init; } +} + +internal sealed record LongMemEvalPredicateSplit( + IReadOnlyList Build, + IReadOnlyList HeldOut) +{ + internal int BuildFactCount => Build.Sum(item => item.FactCount); + + internal int HeldOutFactCount => HeldOut.Sum(item => item.FactCount); +} + +/// +/// J1.2. Produces the observed predicate distribution and its build / held-out split. +/// +/// +/// The split is by predicate, not by fact: the question the held-out slice answers is "does the +/// vocabulary cover relations it was not built against", which requires holding out whole relations. +/// It is deterministic for a seed so that a coverage number is reproducible - an irreproducible gate +/// is the same defect as an irreproducible score. +/// +internal static class LongMemEvalPredicateDistribution +{ + internal static LongMemEvalPredicateSplit Split( + IReadOnlyList predicates, + double heldOutFraction, + int seed) + { + ArgumentNullException.ThrowIfNull(predicates); + if (heldOutFraction is <= 0 or >= 1) + { + throw new ArgumentOutOfRangeException( + nameof(heldOutFraction), "The held-out fraction must be between 0 and 1 exclusive."); + } + + // Rank by a seeded stable hash and take a prefix, rather than testing each predicate against a + // probability. A per-item probability makes the slice size vary with the seed, and an empty + // held-out slice would silently turn the generalisation gate into a no-op. + var ordered = predicates + .OrderBy(item => StableHash(item.Predicate, seed)) + .ThenBy(item => item.Predicate, StringComparer.Ordinal) + .ToArray(); + + var heldOutCount = Math.Clamp( + (int)Math.Round(ordered.Length * heldOutFraction, MidpointRounding.AwayFromZero), + 1, + Math.Max(1, ordered.Length - 1)); + + return new LongMemEvalPredicateSplit( + ordered.Skip(heldOutCount).ToArray(), + ordered.Take(heldOutCount).ToArray()); + } + + /// + /// FNV-1a over the seed and the predicate. Hand-rolled because + /// is randomized per process, which would make the split differ between runs of the same command. + /// + private static uint StableHash(string value, int seed) + { + unchecked + { + var hash = 2166136261u ^ (uint)seed; + foreach (var b in Encoding.UTF8.GetBytes(value)) + { + hash ^= b; + hash *= 16777619u; + } + + return hash; + } + } + + /// + /// J1.5 gate 1. Coverage of a predicate slice by the shipped relation lexicon, split into bands + /// by how many facts each predicate carries. + /// + /// + /// The unbanded statistic failed at 15.4 points and was root-caused to skew, not to a real + /// generalisation gap: coverage over a long tail of one-fact predicates is dominated by the tail, + /// so a slice that happens to draw more singletons looks worse regardless of the vocabulary. The + /// banded form asks the question that actually matters — are the predicates carrying real + /// weight covered? — and it is checked per band in both slices rather than averaged into one + /// number that hides exactly the skew that broke the first version. + /// + /// Membership goes through the shipped , not a reimplementation + /// of it. A gate scored against a replica of the thing under test measures the replica. + /// + /// + /// It asks , not Resolve. Resolve is + /// the query-side method and rejects stop forms by design, so scoring storage coverage with it + /// counts has and is — 2,701 facts between them — as unknown vocabulary. The first + /// run of this gate made exactly that mistake and read 81.5%. + /// + /// + internal static IReadOnlyList CoverageBands( + IReadOnlyList predicates) + { + ArgumentNullException.ThrowIfNull(predicates); + + // Chosen to separate "carries the graph" from "appeared once": the >=10 band is the one the + // gate binds on, and the singleton band is reported rather than dropped so a regression that + // hides in the tail is still visible. + (string Label, int Lower, int Upper)[] bands = + [ + ("10+", 10, int.MaxValue), + ("3-9", 3, 9), + ("2", 2, 2), + ("1", 1, 1) + ]; + + return bands.Select(band => + { + var members = predicates + .Where(entry => entry.FactCount >= band.Lower && entry.FactCount <= band.Upper) + .ToArray(); + var resolved = members + .Where(entry => MemoryRelationLexicon.Default.IsKnownStoredForm(entry.Predicate)) + .ToArray(); + return new PredicateCoverageBand( + band.Label, + members.Length, + resolved.Length, + members.Length == 0 ? 1d : (double)resolved.Length / members.Length, + members + .Where(entry => !MemoryRelationLexicon.Default.IsKnownStoredForm(entry.Predicate)) + .OrderByDescending(entry => entry.FactCount) + .ThenBy(entry => entry.Predicate, StringComparer.Ordinal) + .Select(entry => entry.Predicate) + .ToArray()); + }).ToArray(); + } + +} + +/// One fact-count band of a predicate slice and how much of it the lexicon resolves. +internal sealed record PredicateCoverageBand( + string Band, + int PredicateCount, + int ResolvedCount, + double Coverage, + IReadOnlyList Unresolved); diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistributionProgram.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistributionProgram.cs new file mode 100644 index 00000000..0416b550 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPredicateDistributionProgram.cs @@ -0,0 +1,294 @@ +using System.Globalization; +using System.Text.Json; +using Neo4j.Driver; +using Testcontainers.Neo4j; + +namespace AgentMemory.LongMemEval; + +/// +/// J1.2. Reports the predicate distribution of an existing prepared volume. +/// +/// +/// Read-only, and deliberately built without the AgentMemory service graph: it needs no embedding +/// generator, no chat client, and therefore no Azure credentials, which keeps a diagnostic that +/// only counts relation names from requiring the keys of a run that costs money. +/// +/// The output is the objective anchor for the vocabulary's completeness axis. Without a measured +/// figure to cap it, "complete" is whatever a judge is willing to assert. +/// +/// +internal static class LongMemEvalPredicateDistributionProgram +{ + private const string Image = "neo4j:5.26"; + private const string User = "neo4j"; + private const string Password = "longmemeval-password"; + + public static async Task RunAsync(string[] args) + { + try + { + var volume = Value(args, "--volume") + ?? throw new ArgumentException("--volume is required."); + var heldOutFraction = double.TryParse(Value(args, "--held-out-fraction"), out var parsed) + ? parsed + : 0.2d; + var seed = int.TryParse(Value(args, "--seed"), out var parsedSeed) ? parsedSeed : 42; + // J1.5c. A single split cannot decide a generalisation gate. The bound band holds ~16 + // held-out predicates, so one miss is 6.25 points - already past the 5-point tolerance, + // which makes a one-seed verdict binary and hostage to which predicates the split happened + // to draw. Measured: over five seeds the same vocabulary scored PASS four times and FAIL + // once. Additionally, a split whose BUILD slice was used to author a vocabulary edit is no + // longer held-out for that edit - which is exactly what the single failing seed was. + var seeds = (Value(args, "--seeds") ?? seed.ToString(CultureInfo.InvariantCulture)) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => int.Parse(value, CultureInfo.InvariantCulture)) + .ToArray(); + var destination = Path.GetFullPath(Value(args, "--output") + ?? Path.Combine("artifacts", "evaluation", "predicate-distribution.json")); + + Console.WriteLine($"longmemeval: reading predicate distribution from {volume} (read-only)."); + var container = new Neo4jBuilder(Image) + .WithEnvironment("NEO4J_AUTH", $"{User}/{Password}") + .WithVolumeMount(volume, "/data") + .Build(); + await container.StartAsync().ConfigureAwait(false); + try + { + await using var driver = GraphDatabase.Driver( + container.GetConnectionString(), AuthTokens.Basic(User, Password)); + var summary = await ReadAsync(driver).ConfigureAwait(false); + var split = LongMemEvalPredicateDistribution.Split( + summary.Predicates, heldOutFraction, seed); + // J1.5 gate 1, re-run after J1.6. Per-band and per-slice, because the unbanded form + // failed at 15.4 points on tail skew rather than on a real generalisation gap. + var buildBands = LongMemEvalPredicateDistribution.CoverageBands(split.Build); + var heldOutBands = LongMemEvalPredicateDistribution.CoverageBands(split.HeldOut); + // Gate 1 is a GENERALISATION criterion — "held-out coverage at least build-slice + // coverage minus 5 points, proving the artifact generalises rather than fitting the + // predicates we happened to look at". The banded refinement that followed the 15.4- + // point skew failure silently replaced that relative test with an absolute 100%, + // which is self-defeating: any absolute coverage bar can be met by adding the + // observed predicates to the vocabulary, held-out ones included, which is precisely + // what holding them out exists to detect. Banding kills the skew; the relative + // comparison is what makes it a generalisation test. Both are kept. + var buildBound = buildBands.Single(band => band.Band == "10+").Coverage; + var heldOutBound = heldOutBands.Single(band => band.Band == "10+").Coverage; + var gatePasses = heldOutBound >= buildBound - 0.05; + + // Every requested seed, so the verdict is a distribution rather than one draw. + var perSeed = seeds.Select(candidate => + { + var seedSplit = LongMemEvalPredicateDistribution.Split( + summary.Predicates, heldOutFraction, candidate); + var build = LongMemEvalPredicateDistribution + .CoverageBands(seedSplit.Build).Single(band => band.Band == "10+"); + var held = LongMemEvalPredicateDistribution + .CoverageBands(seedSplit.HeldOut).Single(band => band.Band == "10+"); + return new + { + seed = candidate, + buildCoverage = build.Coverage, + heldOutCoverage = held.Coverage, + heldOutPredicateCount = held.PredicateCount, + // Reported because it sets the gate's resolution: with ~16 held-out + // predicates, one miss is 6.25 points and the 5-point tolerance can never be + // exercised. A verdict from a single seed is binary whether or not it says so. + onePredicateInPoints = + held.PredicateCount == 0 ? 0d : 100d / held.PredicateCount, + passes = held.Coverage >= build.Coverage - 0.05, + heldOutUnresolved = held.Unresolved + }; + }).ToArray(); + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await File.WriteAllTextAsync(destination, JsonSerializer.Serialize(new + { + schemaVersion = 1, + generatedAtUtc = DateTimeOffset.UtcNow, + sourceVolume = volume, + seed, + heldOutFraction, + // Recorded so a reader knows this table is relation names only, by construction. + contentPolicy = "predicate-keys-only-no-subjects-or-objects", + summary.RawPredicateCount, + summary.CanonicalPredicateCount, + summary.TotalFactCount, + summary.OwnerCount, + // Near 1.00 by design: the canonicalizer folds case and separators only, because + // folding synonyms at write time would merge bought onto sold irreversibly. + canonicalizerFoldRatio = summary.ConsolidationRatio, + perOwner = new + { + minPredicates = summary.MinPredicatesPerOwner, + maxPredicates = summary.MaxPredicatesPerOwner, + averagePredicates = summary.AveragePredicatesPerOwner + }, + buildSlice = new + { + predicateCount = split.Build.Count, + factCount = split.BuildFactCount, + predicates = split.Build + }, + heldOutSlice = new + { + predicateCount = split.HeldOut.Count, + factCount = split.HeldOutFactCount, + predicates = split.HeldOut + }, + heldOutCoverageGate = new + { + // The gate binds on the 10+ band only. Every other band is reported, never + // asserted on - a regression that lives in the tail must stay visible even + // though it does not fail the gate. + criterion = + "held-out 10+ band coverage >= build 10+ band coverage - 5 points", + buildBoundBandCoverage = buildBound, + heldOutBoundBandCoverage = heldOutBound, + perSeed, + seedsPassed = perSeed.Count(result => result.passes), + seedsEvaluated = perSeed.Length, + // A split whose BUILD slice was used to author a vocabulary edit is no longer + // held out for that edit. Recorded, not silently excluded. + interpretation = + "a seed whose build slice was used to author a vocabulary change is not " + + "a valid held-out evaluation of that change", + // Reported, never gated. Absolute coverage is worth watching, but gating on + // it would reward fitting the vocabulary to the observed predicates. + absoluteCoverageIsReportedNotGated = true, + passes = gatePasses, + buildSlice = buildBands, + heldOutSlice = heldOutBands + } + }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine) + .ConfigureAwait(false); + + Console.WriteLine( + $"longmemeval: {summary.TotalFactCount} facts over {summary.OwnerCount} owners; " + + $"{summary.RawPredicateCount} raw predicates, {summary.CanonicalPredicateCount} " + + $"canonical globally (canonicalizer fold {summary.ConsolidationRatio:F2}x, " + + "expected near 1.00 - it folds case and separators only)."); + Console.WriteLine( + $"longmemeval: per owner {summary.MinPredicatesPerOwner}-" + + $"{summary.MaxPredicatesPerOwner} canonical predicates " + + $"(mean {summary.AveragePredicatesPerOwner:F1}) - this is the figure comparable to " + + "the recorded pre/post-vocabulary baseline."); + Console.WriteLine( + $"longmemeval: build slice {split.Build.Count} predicates / {split.BuildFactCount} facts; " + + $"held-out {split.HeldOut.Count} predicates / {split.HeldOutFactCount} facts."); + foreach (var (label, bands) in new[] + { ("build", buildBands), ("held-out", heldOutBands) }) + { + foreach (var band in bands) + { + Console.WriteLine( + $"longmemeval: {label} band {band.Band}: " + + $"{band.ResolvedCount}/{band.PredicateCount} resolved " + + $"({band.Coverage:P1})"); + } + } + foreach (var result in perSeed) + { + Console.WriteLine( + $"longmemeval: seed {result.seed}: held-out {result.heldOutCoverage:P1} vs " + + $"build {result.buildCoverage:P1} " + + $"(n={result.heldOutPredicateCount}, 1 miss = {result.onePredicateInPoints:F1} pts): " + + $"{(result.passes ? "PASS" : "FAIL")}"); + } + Console.WriteLine( + $"longmemeval: J1.5 generalisation gate: " + + $"{perSeed.Count(result => result.passes)}/{perSeed.Length} seeds pass."); + Console.WriteLine($"longmemeval: report {destination}"); + return 0; + } + finally + { + await container.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"longmemeval: predicate distribution failed: {exception.Message}"); + return 1; + } + } + + private static async Task ReadAsync(IDriver driver) + { + await using var session = driver.AsyncSession(); + + // coalesce, because a fact written before canonical identity shipped has no predicate_key and + // must still be counted rather than silently dropped from its own distribution. + const string PerPredicate = """ + MATCH (f:Fact) + WITH coalesce(f.predicate_key, toLower(f.predicate)) AS predicate, f + RETURN predicate, + count(f) AS factCount, + count(DISTINCT f.owner_key) AS ownerCount + ORDER BY factCount DESC, predicate ASC + """; + const string Totals = """ + MATCH (f:Fact) + RETURN count(f) AS totalFacts, + count(DISTINCT f.predicate) AS rawPredicates, + count(DISTINCT coalesce(f.predicate_key, toLower(f.predicate))) AS canonicalPredicates, + count(DISTINCT f.owner_key) AS owners + """; + + // Per owner as well as globally. The recorded pre/post-vocabulary baseline (421 -> 79-107) is a + // PER-OWNER figure, and comparing a global distinct count against it would look like a + // catastrophic regression while measuring an entirely different quantity. + const string PerOwner = """ + MATCH (f:Fact) + WITH f.owner_key AS owner, + count(DISTINCT coalesce(f.predicate_key, toLower(f.predicate))) AS predicates + RETURN min(predicates) AS minPerOwner, + max(predicates) AS maxPerOwner, + avg(predicates) AS avgPerOwner + """; + + var totals = await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync(Totals).ConfigureAwait(false); + return await cursor.SingleAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + + var perOwner = await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync(PerOwner).ConfigureAwait(false); + return await cursor.SingleAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + + var predicates = await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync(PerPredicate).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return records + .Select(record => new LongMemEvalPredicateCount( + record["predicate"].As(), + record["factCount"].As(), + record["ownerCount"].As())) + .ToArray(); + }).ConfigureAwait(false); + + return new LongMemEvalPredicateDistributionSummary( + totals["rawPredicates"].As(), + totals["canonicalPredicates"].As(), + totals["totalFacts"].As(), + totals["owners"].As(), + predicates) + { + MinPredicatesPerOwner = perOwner["minPerOwner"].As(), + MaxPredicatesPerOwner = perOwner["maxPerOwner"].As(), + AveragePredicatesPerOwner = perOwner["avgPerOwner"].As() + }; + } + + private static string? Value(string[] args, string name) + { + var index = Array.IndexOf(args, name); + if (index < 0) return null; + if (index + 1 >= args.Length) + throw new ArgumentException($"{name} requires a value."); + return args[index + 1]; + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPreparationManifest.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPreparationManifest.cs new file mode 100644 index 00000000..32d81785 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPreparationManifest.cs @@ -0,0 +1,518 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Neo4j.Driver; + +namespace AgentMemory.LongMemEval; + +internal sealed record LongMemEvalPreparedQuestion( + int QuestionNumber, + string QuestionId, + string HistorySha256, + string ScopeSha256, + int MessagesPrepared, + int SourceSessions, + int ExtractionUnitsPrepared, + LongMemEvalGraphSnapshot GraphSnapshot); + +internal sealed record LongMemEvalPreparationManifest( + int SchemaVersion, + string PreparationId, + string DatasetSha256, + string AgentEvalRevision, + string ScopeRunIdSha256, + string AnswerModelId, + string JudgeModelId, + string ExtractionModelId, + string EmbeddingModelId, + int EmbeddingDimensions, + int MaxRelevantMessages, + string ExtractionSourceTime, + string ExtractionResponseContract, + bool UseJsonResponseFormat, + bool UseUnifiedExtraction, + bool UseMultiSessionBatchExtraction, + int PreparationWorkers, + int MaxSessionsPerBatch, + int MaxInputTokens, + int MaxConcurrentBatchesPerExtraction, + int MaxConcurrentExtractionBatches, + IReadOnlyList Questions, + long InitialExtractionCalls, + string Fingerprint) +{ + public const int CurrentSchemaVersion = 5; + + internal int MessagesPrepared => Questions.Sum(question => question.MessagesPrepared); + + internal int ExtractionUnitsPrepared => + Questions.Sum(question => question.ExtractionUnitsPrepared); + + internal static LongMemEvalPreparationManifest Create( + string preparationId, + string datasetSha256, + string agentEvalRevision, + string scopeRunId, + string answerModelId, + string judgeModelId, + string extractionModelId, + string embeddingModelId, + int embeddingDimensions, + int maxRelevantMessages, + string extractionSourceTime, + IReadOnlyList questions, + long initialExtractionCalls, + bool useJsonResponseFormat = true, + string extractionResponseContract = "json-object", + bool useUnifiedExtraction = false, + bool useMultiSessionBatchExtraction = false, + int preparationWorkers = 1, + int maxSessionsPerBatch = 1, + int maxInputTokens = 100_000, + int maxConcurrentBatchesPerExtraction = 1, + int maxConcurrentExtractionBatches = 0) + { + ArgumentException.ThrowIfNullOrWhiteSpace(preparationId); + ArgumentException.ThrowIfNullOrWhiteSpace(datasetSha256); + ArgumentException.ThrowIfNullOrWhiteSpace(agentEvalRevision); + ArgumentException.ThrowIfNullOrWhiteSpace(scopeRunId); + ArgumentException.ThrowIfNullOrWhiteSpace(answerModelId); + ArgumentException.ThrowIfNullOrWhiteSpace(judgeModelId); + ArgumentException.ThrowIfNullOrWhiteSpace(extractionModelId); + ArgumentException.ThrowIfNullOrWhiteSpace(embeddingModelId); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(embeddingDimensions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRelevantMessages); + ArgumentException.ThrowIfNullOrWhiteSpace(extractionSourceTime); + ArgumentException.ThrowIfNullOrWhiteSpace(extractionResponseContract); + ArgumentNullException.ThrowIfNull(questions); + ArgumentOutOfRangeException.ThrowIfNegative(initialExtractionCalls); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(preparationWorkers); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxSessionsPerBatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxInputTokens); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero( + maxConcurrentBatchesPerExtraction); + ArgumentOutOfRangeException.ThrowIfNegative(maxConcurrentExtractionBatches); + if (useMultiSessionBatchExtraction && !useUnifiedExtraction) + { + throw new ArgumentException( + "Multi-session extraction requires unified extraction."); + } + + var materialized = questions.ToArray(); + if (materialized.Length == 0) + throw new ArgumentException("A preparation manifest requires at least one question.", nameof(questions)); + if (materialized.Select(question => question.QuestionNumber).Distinct().Count() != materialized.Length || + materialized.Select(question => question.QuestionId).Distinct(StringComparer.Ordinal).Count() != materialized.Length) + { + throw new ArgumentException( + "A preparation manifest requires unique question numbers and ids.", + nameof(questions)); + } + + var manifest = new LongMemEvalPreparationManifest( + CurrentSchemaVersion, + preparationId, + datasetSha256, + agentEvalRevision, + Hash(scopeRunId), + answerModelId, + judgeModelId, + extractionModelId, + embeddingModelId, + embeddingDimensions, + maxRelevantMessages, + extractionSourceTime, + extractionResponseContract, + useJsonResponseFormat, + useUnifiedExtraction, + useMultiSessionBatchExtraction, + preparationWorkers, + maxSessionsPerBatch, + maxInputTokens, + maxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches, + materialized, + initialExtractionCalls, + Fingerprint: string.Empty); + return manifest with { Fingerprint = ComputeFingerprint(manifest) }; + } + + internal void VerifyIntegrity() + { + if (SchemaVersion != CurrentSchemaVersion) + { + throw new InvalidOperationException( + $"Unsupported LongMemEval preparation manifest schema {SchemaVersion}."); + } + + var expected = ComputeFingerprint(this); + if (!string.Equals(Fingerprint, expected, StringComparison.Ordinal)) + throw new InvalidOperationException("LongMemEval preparation manifest fingerprint mismatch."); + } + + internal static string ComputeFingerprint(LongMemEvalPreparationManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + var canonical = new + { + manifest.SchemaVersion, + manifest.PreparationId, + manifest.DatasetSha256, + manifest.AgentEvalRevision, + manifest.ScopeRunIdSha256, + manifest.AnswerModelId, + manifest.JudgeModelId, + manifest.ExtractionModelId, + manifest.EmbeddingModelId, + manifest.EmbeddingDimensions, + manifest.MaxRelevantMessages, + manifest.ExtractionSourceTime, + manifest.UseJsonResponseFormat, + manifest.ExtractionResponseContract, + manifest.UseUnifiedExtraction, + manifest.UseMultiSessionBatchExtraction, + manifest.PreparationWorkers, + manifest.MaxSessionsPerBatch, + manifest.MaxInputTokens, + manifest.MaxConcurrentBatchesPerExtraction, + manifest.MaxConcurrentExtractionBatches, + Questions = manifest.Questions.Select(question => new + { + question.QuestionNumber, + question.QuestionId, + question.HistorySha256, + question.ScopeSha256, + question.MessagesPrepared, + question.SourceSessions, + question.ExtractionUnitsPrepared, + question.GraphSnapshot + }), + manifest.InitialExtractionCalls + }; + return Hash(JsonSerializer.Serialize(canonical, JsonOptions)); + } + + internal static string Hash(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +} + +internal sealed record LongMemEvalPreparationExpectation( + string DatasetSha256, + string AgentEvalRevision, + string AnswerModelId, + string JudgeModelId, + string ExtractionModelId, + string EmbeddingModelId, + int EmbeddingDimensions, + int MaxRelevantMessages, + string ExtractionSourceTime, + bool UseJsonResponseFormat = true, + string ExtractionResponseContract = "json-object", + bool UseUnifiedExtraction = false, + bool UseMultiSessionBatchExtraction = false, + int PreparationWorkers = 1, + int MaxSessionsPerBatch = 1, + int MaxInputTokens = 100_000, + int MaxConcurrentBatchesPerExtraction = 1, + int MaxConcurrentExtractionBatches = 0) +{ + internal void Validate(LongMemEvalPreparationManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + if (!string.Equals(manifest.DatasetSha256, DatasetSha256, StringComparison.Ordinal) || + !string.Equals(manifest.AgentEvalRevision, AgentEvalRevision, StringComparison.Ordinal) || + !string.Equals(manifest.AnswerModelId, AnswerModelId, StringComparison.Ordinal) || + !string.Equals(manifest.JudgeModelId, JudgeModelId, StringComparison.Ordinal) || + !string.Equals(manifest.ExtractionModelId, ExtractionModelId, StringComparison.Ordinal) || + !string.Equals(manifest.EmbeddingModelId, EmbeddingModelId, StringComparison.Ordinal) || + manifest.EmbeddingDimensions != EmbeddingDimensions || + manifest.MaxRelevantMessages != MaxRelevantMessages || + manifest.UseJsonResponseFormat != UseJsonResponseFormat || + !string.Equals(manifest.ExtractionResponseContract, ExtractionResponseContract, StringComparison.Ordinal) || + !string.Equals(manifest.ExtractionSourceTime, ExtractionSourceTime, StringComparison.Ordinal) || + manifest.UseUnifiedExtraction != UseUnifiedExtraction || + manifest.UseMultiSessionBatchExtraction != UseMultiSessionBatchExtraction || + manifest.PreparationWorkers != PreparationWorkers || + manifest.MaxSessionsPerBatch != MaxSessionsPerBatch || + manifest.MaxInputTokens != MaxInputTokens || + manifest.MaxConcurrentBatchesPerExtraction != MaxConcurrentBatchesPerExtraction || + manifest.MaxConcurrentExtractionBatches != MaxConcurrentExtractionBatches) + { + throw new InvalidOperationException( + "Prepared LongMemEval configuration does not match the sealed manifest."); + } + } +} + +internal static class LongMemEvalPreparationFingerprint +{ + internal static LongMemEvalPreparationExpectation Expect( + string datasetSha256, + string agentEvalRevision, + string answerModelId, + string judgeModelId, + string extractionModelId, + string embeddingModelId, + int embeddingDimensions, + int maxRelevantMessages, + bool useJsonResponseFormat = true, + string extractionResponseContract = "json-object", + bool useUnifiedExtraction = false, + bool useMultiSessionBatchExtraction = false, + int preparationWorkers = 1, + int maxSessionsPerBatch = 1, + int maxInputTokens = 100_000, + int maxConcurrentBatchesPerExtraction = 1, + int maxConcurrentExtractionBatches = 0) => + new( + datasetSha256, + agentEvalRevision, + answerModelId, + judgeModelId, + extractionModelId, + embeddingModelId, + embeddingDimensions, + maxRelevantMessages, + "metadata-only-not-in-extraction-prompt", + useJsonResponseFormat, + extractionResponseContract, + useUnifiedExtraction, + useMultiSessionBatchExtraction, + preparationWorkers, + maxSessionsPerBatch, + maxInputTokens, + maxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches); +} +public sealed class LongMemEvalPreparedState +{ + private readonly IReadOnlyDictionary _byNumber; + + internal LongMemEvalPreparedState( + LongMemEvalPreparationManifest manifest, + string scopeRunId) + : this(manifest, scopeRunId, expectation: null) + { + } + + internal LongMemEvalPreparedState( + LongMemEvalPreparationManifest manifest, + string scopeRunId, + LongMemEvalPreparationExpectation? expectation) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentException.ThrowIfNullOrWhiteSpace(scopeRunId); + manifest.VerifyIntegrity(); + if (!string.Equals( + manifest.ScopeRunIdSha256, + LongMemEvalPreparationManifest.Hash(scopeRunId), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Prepared LongMemEval scope does not match the sealed manifest."); + } + + expectation?.Validate(manifest); + Manifest = manifest; + _byNumber = manifest.Questions.ToDictionary(question => question.QuestionNumber); + } + + internal LongMemEvalPreparationManifest Manifest { get; } + + internal LongMemEvalPreparedQuestion ValidateQuestion( + int questionNumber, + LongMemEvalEvidenceQuestion evidenceQuestion, + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string sessionId, + string ownerId) + { + ArgumentNullException.ThrowIfNull(evidenceQuestion); + ArgumentNullException.ThrowIfNull(history); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); + + if (!_byNumber.TryGetValue(questionNumber, out var prepared)) + { + throw new InvalidOperationException( + $"Prepared LongMemEval manifest has no question position {questionNumber}."); + } + + var historySha256 = LongMemEvalEvidenceIndex.Fingerprint(history); + var scopeSha256 = LongMemEvalPreparationManifest.Hash($"{sessionId}|{ownerId}"); + var sourceSessions = evidenceQuestion.Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Distinct() + .Count(); + // G3B.9 stopped persisting AgentEval's fabricated session-boundary turns, so the sealed + // count is of real conversation only. Comparing it against every injected message would + // reject every question. The guard stays exact — it is the expectation that was stale. + var persistableMessages = evidenceQuestion.Messages + .Count(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding); + + if (!string.Equals(prepared.QuestionId, evidenceQuestion.QuestionId, StringComparison.Ordinal) || + !string.Equals(prepared.HistorySha256, historySha256, StringComparison.Ordinal) || + !string.Equals(prepared.ScopeSha256, scopeSha256, StringComparison.Ordinal) || + prepared.MessagesPrepared != persistableMessages || + prepared.SourceSessions != sourceSessions || + prepared.ExtractionUnitsPrepared != sourceSessions) + { + throw new InvalidOperationException( + $"Prepared LongMemEval question {questionNumber} does not match the sealed manifest."); + } + + return prepared; + } +} + +internal sealed class Neo4jLongMemEvalPreparationStore(IDriver driver) +{ + private const string Label = "LongMemEvalPreparation"; + + internal async Task SealAsync( + LongMemEvalPreparationManifest manifest, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + manifest.VerifyIntegrity(); + var json = JsonSerializer.Serialize( + manifest, + LongMemEvalPreparationManifest.JsonOptions); + + await using var session = driver.AsyncSession( + options => options.WithDefaultAccessMode(AccessMode.Write)); + await session.ExecuteWriteAsync(async transaction => + { + var existingCursor = await transaction.RunAsync( + $"MATCH (m:{Label} {{id: $id}}) RETURN count(m) AS count", + new { id = manifest.PreparationId }).ConfigureAwait(false); + var existing = await existingCursor.SingleAsync().ConfigureAwait(false); + if (existing["count"].As() != 0) + { + throw new InvalidOperationException( + "LongMemEval preparation id is already sealed."); + } + + var createCursor = await transaction.RunAsync( + $$""" + CREATE (m:{{Label}} { + id: $id, + schema_version: $schemaVersion, + fingerprint: $fingerprint, + manifest_json: $manifestJson, + sealed_at: datetime() + }) + RETURN m.fingerprint AS fingerprint + """, + new + { + id = manifest.PreparationId, + schemaVersion = manifest.SchemaVersion, + fingerprint = manifest.Fingerprint, + manifestJson = json + }).ConfigureAwait(false); + var created = await createCursor.SingleAsync().ConfigureAwait(false); + if (!string.Equals( + created["fingerprint"].As(), + manifest.Fingerprint, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "LongMemEval preparation manifest was not sealed exactly."); + } + }).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + /// + /// The preparation id sealed into this store, so an adopted volume describes itself. + /// + /// + /// G3B.12-R. Reuse only receives a volume name; the run identity it needs to reproduce session + /// and owner scopes lives inside the graph. Reading it back is what lets a retained build be + /// evaluated without a rebuild — and every question would otherwise trip + /// prepared-manifest-mismatch, since scope hashes are derived from that id. + /// + /// Exactly one manifest per store is required: more than one means volumes were mixed, which + /// would silently evaluate one graph against another's sealed expectations. + /// + /// + internal async Task ReadSealedPreparationIdAsync( + CancellationToken cancellationToken = default) + { + await using var session = driver.AsyncSession( + options => options.WithDefaultAccessMode(AccessMode.Read)); + var ids = await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync($"MATCH (m:{Label}) RETURN m.id AS id"); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return records.Select(record => record["id"].As()).ToList(); + }).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return ids.Count switch + { + 1 => ids[0], + 0 => throw new InvalidOperationException( + "The reused volume holds no sealed LongMemEval preparation; it was never prepared, " + + "or preparation did not complete."), + _ => throw new InvalidOperationException( + $"The reused volume holds {ids.Count} sealed preparations; exactly one is required.") + }; + } + + internal async Task ReadAsync( + string preparationId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(preparationId); + await using var session = driver.AsyncSession( + options => options.WithDefaultAccessMode(AccessMode.Read)); + var records = await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync( + $$""" + MATCH (m:{{Label}} {id: $id}) + RETURN m.schema_version AS schemaVersion, + m.fingerprint AS fingerprint, + m.manifest_json AS manifestJson + """, + new { id = preparationId }).ConfigureAwait(false); + return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + + if (records.Count != 1) + { + throw new InvalidOperationException( + $"Expected one sealed LongMemEval preparation manifest; found {records.Count}."); + } + + var record = records[0]; + var manifest = JsonSerializer.Deserialize( + record["manifestJson"].As(), + LongMemEvalPreparationManifest.JsonOptions) + ?? throw new InvalidOperationException( + "LongMemEval preparation manifest could not be deserialized."); + if (record["schemaVersion"].As() != manifest.SchemaVersion || + !string.Equals( + record["fingerprint"].As(), + manifest.Fingerprint, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "LongMemEval preparation marker does not match its manifest."); + } + + manifest.VerifyIntegrity(); + return manifest; + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPreparationWatchdog.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPreparationWatchdog.cs new file mode 100644 index 00000000..3d899745 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPreparationWatchdog.cs @@ -0,0 +1,127 @@ +using System.Diagnostics; +using System.Globalization; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalPreparationWatchdog +{ + internal static async Task RunAsync( + Func> operation, + LongMemEvalChatCallMeter meter, + long expectedProviderCalls, + TimeSpan overallTimeout, + TimeSpan noProviderProgressTimeout, + string phase, + TextWriter output, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(meter); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(expectedProviderCalls); + if (overallTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(overallTimeout)); + if (noProviderProgressTimeout <= TimeSpan.Zero || + noProviderProgressTimeout > overallTimeout) + { + throw new ArgumentOutOfRangeException(nameof(noProviderProgressTimeout)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(phase); + ArgumentNullException.ThrowIfNull(output); + + var initialCompleted = meter.Snapshot().CompletedCalls; + var targetCompleted = checked(initialCompleted + expectedProviderCalls); + using var overallCancellation = new CancellationTokenSource(overallTimeout); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + overallCancellation.Token); + using var executionFinished = new CancellationTokenSource(); + var watchdogReason = 0; + var monitor = MonitorAsync(); + + try + { + return await operation(linkedCancellation.Token).ConfigureAwait(false); + } + catch (Exception exception) when ( + Volatile.Read(ref watchdogReason) != 0 || + overallCancellation.IsCancellationRequested) + { + var reason = Volatile.Read(ref watchdogReason) == 2 + ? "no-provider-progress" + : "overall-timeout"; + throw new TimeoutException( + Diagnostic(phase, reason, meter.Snapshot()), + exception); + } + finally + { + executionFinished.Cancel(); + try + { + await monitor.ConfigureAwait(false); + } + catch (OperationCanceledException) + when (executionFinished.IsCancellationRequested) + { + } + } + + async Task MonitorAsync() + { + var lastCompleted = initialCompleted; + var lastProgress = Stopwatch.StartNew(); + var poll = TimeSpan.FromSeconds(Math.Min( + 5d, + Math.Max(0.01d, noProviderProgressTimeout.TotalSeconds / 4d))); + while (!executionFinished.IsCancellationRequested) + { + await Task.Delay(poll, executionFinished.Token).ConfigureAwait(false); + if (overallCancellation.IsCancellationRequested) + { + Interlocked.CompareExchange(ref watchdogReason, 1, 0); + linkedCancellation.Cancel(); + return; + } + + var snapshot = meter.Snapshot(); + if (snapshot.CompletedCalls > lastCompleted) + { + lastCompleted = snapshot.CompletedCalls; + lastProgress.Restart(); + output.WriteLine( + $"longmemeval: {phase} provider progress " + + $"{lastCompleted - initialCompleted}/{expectedProviderCalls}; " + + $"maximum concurrency {snapshot.MaximumConcurrency}."); + } + else if (snapshot.CompletedCalls < targetCompleted && + lastProgress.Elapsed >= noProviderProgressTimeout) + { + Interlocked.CompareExchange(ref watchdogReason, 2, 0); + linkedCancellation.Cancel(); + return; + } + } + } + } + + private static string Diagnostic( + string phase, + string reason, + LongMemEvalChatCallSnapshot snapshot) + { + var firstFailure = snapshot.FailureDetails.FirstOrDefault(); + var slowest = snapshot.CallDetails + .OrderByDescending(detail => detail.DurationMilliseconds) + .FirstOrDefault(); + return $"LongMemEval {phase} watchdog fired ({reason}); provider calls " + + $"started/completed={snapshot.Calls}/{snapshot.CompletedCalls}, " + + $"failures={snapshot.Failures}, retries={snapshot.RetryCalls}, " + + $"aggregate_provider_ms={snapshot.Duration.TotalMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}, " + + $"maximum_provider_concurrency={snapshot.MaximumConcurrency}, " + + $"first_failure_type={firstFailure?.ExceptionType ?? "none"}, " + + $"first_failure_status={firstFailure?.ProviderStatus?.ToString(CultureInfo.InvariantCulture) ?? "none"}, " + + $"slowest_call={slowest?.CallOrdinal.ToString(CultureInfo.InvariantCulture) ?? "none"}, " + + $"slowest_provider_ms={slowest?.DurationMilliseconds.ToString("F2", CultureInfo.InvariantCulture) ?? "none"}, " + + $"slowest_input={slowest?.EstimatedInputTokens?.ToString(CultureInfo.InvariantCulture) ?? "none"}."; + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPreparedBatchExecutor.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedBatchExecutor.cs new file mode 100644 index 00000000..833bfc65 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedBatchExecutor.cs @@ -0,0 +1,271 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Services; +using AgentMemory.Extraction.Llm; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalPreparedBatchExecutor +{ + internal static IReadOnlyList SelectCheckpointQuestionIndexes( + IReadOnlyList plans, + int checkpointQuestions) + { + ArgumentNullException.ThrowIfNull(plans); + if (checkpointQuestions <= 0 || checkpointQuestions > plans.Count) + throw new ArgumentOutOfRangeException(nameof(checkpointQuestions)); + + return Enumerable.Range(0, plans.Count) + .OrderByDescending(index => plans[index].TotalEstimatedInputTokens) + .ThenBy(index => index) + .Take(checkpointQuestions) + .OrderBy(index => index) + .ToArray(); + } + + internal static double ProjectFullPreparationMilliseconds( + long fullCalls, + long fullSourceSessions, + long fullEstimatedInputTokens, + long checkpointCalls, + long checkpointSourceSessions, + long checkpointEstimatedInputTokens, + double checkpointWallMilliseconds, + double profileStartupMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fullCalls); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fullSourceSessions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fullEstimatedInputTokens); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(checkpointCalls); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(checkpointSourceSessions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(checkpointEstimatedInputTokens); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(checkpointWallMilliseconds); + ArgumentOutOfRangeException.ThrowIfNegative(profileStartupMilliseconds); + + var scale = Math.Max( + (double)fullCalls / checkpointCalls, + Math.Max( + (double)fullSourceSessions / checkpointSourceSessions, + (double)fullEstimatedInputTokens / checkpointEstimatedInputTokens)); + return profileStartupMilliseconds + (1.25d * checkpointWallMilliseconds * scale); + } + + internal static IReadOnlyList Preflight( + IServiceProvider services, + string preparationId, + LongMemEvalEvidenceIndex evidenceIndex, + IReadOnlyList questions, + int maxSessionsPerBatch, + int maxInputTokens) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(preparationId); + ArgumentNullException.ThrowIfNull(evidenceIndex); + ArgumentNullException.ThrowIfNull(questions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxSessionsPerBatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxInputTokens); + + var planner = services + .GetServices() + .Single(extractor => extractor.IsEnabled); + var plans = new MultiSessionExtractionPlan[questions.Count]; + for (var index = 0; index < questions.Count; index++) + { + var questionNumber = index + 1; + var question = questions[index]; + var history = LongMemEvalBenchmarkProtocol.History(question); + var origins = new Dictionary( + StringComparer.Ordinal); + var messages = AgentMemoryLongMemEvalAdapter.BuildMessages( + preparationId, + history, + ScopeId(preparationId, "session", questionNumber), + ScopeId(preparationId, "owner", questionNumber), + questionNumber, + question, + origins); + var requests = AgentMemoryLongMemEvalAdapter.BuildExtractionRequests( + messages, + question, + ScopeId(preparationId, "session", questionNumber), + ScopeId(preparationId, "owner", questionNumber)); + var plan = planner.Plan( + requests, + maxSessionsPerBatch, + maxInputTokens); + if (plan.SourceSessionCount != requests.Count || + plan.BatchCount <= 0 || + plan.Batches.Any(batch => + batch.SourceSessionIds.Count == 0 || + batch.SourceSessionIds.Count > maxSessionsPerBatch || + batch.EstimatedInputTokens <= 0 || + batch.EstimatedInputTokens > maxInputTokens)) + { + throw new InvalidOperationException( + $"LongMemEval question {questionNumber} produced an invalid preflight batch plan."); + } + + plans[index] = plan; + } + + return plans; + } + + internal static async Task ExecuteAsync( + IServiceProvider services, + LongMemEvalChatCallMeter extractionCalls, + string preparationId, + LongMemEvalEvidenceIndex evidenceIndex, + IReadOnlyList questions, + IReadOnlyList plans, + string modelId, + LongMemEvalEvidenceDetail evidenceDetail, + int maxRelevantMessages, + int preparationWorkers, + int maxSessionsPerBatch, + int maxInputTokens, + IReadOnlyList? questionIndexes, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(extractionCalls); + ArgumentException.ThrowIfNullOrWhiteSpace(preparationId); + ArgumentNullException.ThrowIfNull(evidenceIndex); + ArgumentNullException.ThrowIfNull(questions); + ArgumentNullException.ThrowIfNull(plans); + ArgumentException.ThrowIfNullOrWhiteSpace(modelId); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(preparationWorkers); + if (questions.Count != plans.Count) + throw new ArgumentException("Every LongMemEval question requires one frozen batch plan."); + + var executionIndexes = questionIndexes?.ToArray() ?? + Enumerable.Range(0, questions.Count).ToArray(); + if (executionIndexes.Length == 0 || + executionIndexes.Distinct().Count() != executionIndexes.Length || + executionIndexes.Any(index => index < 0 || index >= questions.Count)) + { + throw new ArgumentException( + "Checkpoint question indexes must be nonempty, unique, and in range.", + nameof(questionIndexes)); + } + + var telemetry = new LongMemEvalQuestionTelemetry[executionIndexes.Length]; + var active = 0; + var maximumActive = 0; + var completed = 0; + var driver = services.GetRequiredService(); + await Parallel.ForEachAsync( + Enumerable.Range(0, executionIndexes.Length), + new ParallelOptions + { + MaxDegreeOfParallelism = preparationWorkers, + CancellationToken = cancellationToken + }, + async (executionPosition, itemCancellationToken) => + { + var index = executionIndexes[executionPosition]; + var nowActive = Interlocked.Increment(ref active); + UpdateMaximum(ref maximumActive, nowActive); + try + { + await using var scope = services.CreateAsyncScope(); + var scoped = scope.ServiceProvider; + var planner = scoped + .GetServices() + .Single(extractor => extractor.IsEnabled); + var adapter = new AgentMemoryLongMemEvalAdapter( + scoped.GetRequiredService(), + extractionCalls, + preparationId, + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + MaxRelevantMessages = maxRelevantMessages, + MinSimilarityScore = 0, + ModelId = modelId, + EvidenceIndex = evidenceIndex, + EvidenceDetail = evidenceDetail, + RequireGraphReadBack = true, + GraphProbe = new Neo4jLongMemEvalGraphProbe(driver), + PreparationOnly = true, + UseBatchedPreparation = true, + BatchExtractionPipeline = + scoped.GetRequiredService(), + // Lets the cost guard tell a designed recovery apart from unexplained + // work: a split legitimately adds provider calls, so excess calls are + // acceptable only when the splitter actually ran. + BatchSplitCount = () => scoped + .GetRequiredService() + .Snapshot().Splits, + BatchPlanner = planner, + MaxSessionsPerBatch = maxSessionsPerBatch, + MaxInputTokens = maxInputTokens, + InitialQuestionNumber = index, + ExpectedExtractionPlan = plans[index] + }); + + await adapter.ResetSessionAsync(itemCancellationToken) + .ConfigureAwait(false); + adapter.InjectConversationHistory( + LongMemEvalBenchmarkProtocol.History(questions[index])); + _ = await adapter.InvokeAsync( + questions[index].InvocationPrompt, + itemCancellationToken) + .ConfigureAwait(false); + var questionTelemetry = adapter.QuestionTelemetry; + if (questionTelemetry.Count != 1 || + questionTelemetry[0].QuestionNumber != index + 1 || + questionTelemetry[0].ExtractionCallsPlanned != + plans[index].BatchCount) + { + throw new InvalidOperationException( + $"LongMemEval question {index + 1} did not record its exact frozen batch plan."); + } + + telemetry[executionPosition] = questionTelemetry[0]; + var completedNow = Interlocked.Increment(ref completed); + Console.WriteLine( + $"longmemeval: prepared question {completedNow}/{executionIndexes.Length} " + + $"(source question {index + 1})."); + } + finally + { + Interlocked.Decrement(ref active); + } + }).ConfigureAwait(false); + + if (telemetry.Any(item => item is null)) + throw new InvalidOperationException( + "LongMemEval concurrent preparation did not produce telemetry for every question."); + return new LongMemEvalPreparedBatchExecution( + telemetry, + executionIndexes.Sum(index => (long)plans[index].BatchCount), + executionIndexes.Sum(index => (long)plans[index].TotalEstimatedInputTokens), + maximumActive); + } + + private static string ScopeId(string runId, string kind, int questionNumber) => + $"{runId}-{kind}-{questionNumber:D4}"; + + private static void UpdateMaximum(ref int maximum, int candidate) + { + var observed = Volatile.Read(ref maximum); + while (candidate > observed) + { + var previous = Interlocked.CompareExchange( + ref maximum, + candidate, + observed); + if (previous == observed) + return; + observed = previous; + } + } +} + +internal sealed record LongMemEvalPreparedBatchExecution( + IReadOnlyList Telemetry, + long PlannedCalls, + long EstimatedInputTokens, + int MaximumConcurrency); diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPreparedPairProgram.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedPairProgram.cs new file mode 100644 index 00000000..7da804b9 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedPairProgram.cs @@ -0,0 +1,1376 @@ +using System.Diagnostics; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentMemory.Core.Memory; +using AgentMemory.Extraction.Llm; +using AgentEval.Memory.Models; +using AgentMemory.Abstractions.Services; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Neo4j.Driver; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalPreparedPairProgram +{ + private const int DefaultQuestions = 10; + private const int DefaultSeed = 42; + private const int DefaultMaxRelevant = 30; + private const int DefaultPreparationWorkers = 10; + private const int DefaultMaxSessionsPerBatch = 4; + private const int DefaultMaxInputTokens = 100_000; + private const int DefaultMaxConcurrentBatchesPerExtraction = 4; + private const int DefaultMaxConcurrentExtractionBatches = 12; + private const int DefaultCheckpointTimeoutSeconds = 3_600; + private const int DefaultProviderNoProgressTimeoutSeconds = 600; + private const double ColdBuildSpeedTargetMilliseconds = 900_000d; + + private const int FixedTenExpectedSourceSessions = 474; + internal static async Task RunAsync(string[] args) + { + try + { + var options = Parse(args); + Validate(options); + var diagnosticEvidenceIndex = PreflightDiagnosticSelection(options); + if (options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + { + Console.Error.WriteLine( + "longmemeval: warning: content evidence retains public dataset questions, recalled text, and model answers; keep the output gitignored."); + } + + var endpoint = RequiredEnvironment("AZURE_OPENAI_ENDPOINT"); + var apiKey = RequiredEnvironment("AZURE_OPENAI_API_KEY"); + var deployment = RequiredEnvironment("AZURE_OPENAI_DEPLOYMENT"); + var embeddingDeployment = + RequiredEnvironment("AZURE_OPENAI_EMBEDDING_DEPLOYMENT"); + var extractionDeployment = + Environment.GetEnvironmentVariable("AZURE_OPENAI_EXTRACTION_DEPLOYMENT") + ?? deployment; + var azureClient = new AzureOpenAIClient( + new Uri(endpoint), + new AzureKeyCredential(apiKey)); + var embeddingGenerator = azureClient + .GetEmbeddingClient(embeddingDeployment) + .AsIEmbeddingGenerator(); + var embeddingDimensions = await LongMemEvalRuntime + .ProbeEmbeddingDimensionsAsync(embeddingGenerator) + .ConfigureAwait(false); + var benchmarkOptions = LongMemEvalBenchmarkProtocol.CreateOptions( + options.DatasetPath, + options.Questions, + options.Seed, + options.JudgeRetryAttempts, + options.EvidenceDetail, + options.MaxRelevantMessages); + var datasetSha256 = Convert.ToHexStringLower( + SHA256.HashData( + await File.ReadAllBytesAsync(options.DatasetPath).ConfigureAwait(false))); + var agentEvalRevision = AgentEvalRevision(); + var expectation = LongMemEvalPreparationFingerprint.Expect( + datasetSha256, + agentEvalRevision, + deployment, + deployment, + extractionDeployment, + embeddingDeployment, + embeddingDimensions, + options.MaxRelevantMessages, + extractionResponseContract: options.IsDiagnostic + ? "json-object" + : LlmMultiSessionExtractionResponseContract.Version, + useUnifiedExtraction: !options.IsDiagnostic, + useMultiSessionBatchExtraction: !options.IsDiagnostic, + preparationWorkers: options.IsDiagnostic ? 1 : options.PreparationWorkers, + maxSessionsPerBatch: options.MaxSessionsPerBatch, + maxInputTokens: options.MaxInputTokens, + maxConcurrentBatchesPerExtraction: + options.IsDiagnostic ? 1 : options.MaxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches: + options.IsDiagnostic ? 0 : options.MaxConcurrentExtractionBatches); + var preparationId = + $"longmemeval-prepared-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}"; + var overall = Stopwatch.StartNew(); + + // G3B.12-R. Reuse attaches to a retained cold build instead of paying 121 provider calls + // to rebuild one — and because extraction is non-deterministic, a rebuild would not + // reproduce the graph being investigated anyway. + var reusing = !string.IsNullOrWhiteSpace(options.ReusePreparedVolume); + + // Before anything is created or adopted, so this run's own volumes can never be + // candidates. Retaining a build without ever sweeping is a disk leak, and a killed run + // never gets to clean up after itself. + if (!options.NoOrphanSweep) + { + await LongMemEvalOrphanSweep + .RunAsync(options.ReusePreparedVolume, Console.Out, CancellationToken.None) + .ConfigureAwait(false); + } + + await using var volumes = reusing + ? await LongMemEvalPreparedVolumes + .AdoptAsync( + options.ReusePreparedVolume!, + CancellationToken.None, + retain: options.RetainPreparedVolumes) + .ConfigureAwait(false) + : await LongMemEvalPreparedVolumes + .CreateAsync( + preparationId, + CancellationToken.None, + retain: options.RetainPreparedVolumes) + .ConfigureAwait(false); + if (options.RetainPreparedVolumes) + { + // Printed so the retained build can be re-attached and inspected, and so the operator + // knows cleanup is now theirs. + Console.WriteLine( + "longmemeval: retaining prepared volumes (cleanup is now manual): " + + $"{volumes.BaseVolumeName}, {volumes.StructuredVolumeName}, {volumes.HybridVolumeName}"); + } + using var extractionCalls = new LongMemEvalChatCallMeter( + new ProviderCompatibleExtractionChatClient( + azureClient.GetChatClient(extractionDeployment).AsIChatClient())); + LongMemEvalPreparationManifest manifest; + IReadOnlyList preparationTelemetry; + LongMemEvalPreparedBatchExecution? batchExecution = null; + var profileStartup = Stopwatch.StartNew(); + var baseStopMilliseconds = 0d; + var manifestSealMilliseconds = 0d; + var baseVolumeName = volumes.BeginBasePreparation(); + LongMemEvalMemoryProfile? baseProfile = null; + try + { + baseProfile = await LongMemEvalMemoryProfile.StartAsync( + embeddingGenerator, + extractionCalls, + LongMemEvalMemoryMode.Structured, + extractionDeployment, + embeddingDimensions, + Console.Out, + CancellationToken.None, + baseVolumeName, + enableBatchedPreparation: !options.IsDiagnostic, + maxConcurrentBatchesPerExtraction: + options.IsDiagnostic ? 1 : options.MaxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches: + options.IsDiagnostic ? 0 : options.MaxConcurrentExtractionBatches, + usePredicateVocabulary: options.UsePredicateVocabulary) + .ConfigureAwait(false); + profileStartup.Stop(); + + var evidenceIndex = diagnosticEvidenceIndex ?? + LongMemEvalEvidenceIndex.Load( + options.DatasetPath, benchmarkOptions); + var questions = evidenceIndex.Questions.ToArray(); + if (questions.Length != options.Questions) + { + throw new InvalidOperationException( + $"Prepared LongMemEval selected {questions.Length} questions; expected {options.Questions}."); + } + + var driver = baseProfile.Services.GetRequiredService(); + if (reusing) + { + // Reuse: the retained volume describes itself. preparationId MUST come from the + // sealed manifest and never be generated - the per-question scope hashes derive + // from it, so a generated one makes every question trip + // prepared-manifest-mismatch. Preparation is skipped entirely; the clone and + // both evaluation arms below are unchanged. + var reuseStore = new Neo4jLongMemEvalPreparationStore(driver); + preparationId = await reuseStore + .ReadSealedPreparationIdAsync(CancellationToken.None) + .ConfigureAwait(false); + manifest = await reuseStore + .ReadAsync(preparationId).ConfigureAwait(false); + + // A reused run performs no preparation, so its preparation timings are genuinely + // empty rather than zeroed-out real work. Reporting them as empty keeps a reused + // run from being mistaken for a cold build that happened to be instant. + preparationTelemetry = Array.Empty(); + Console.WriteLine( + $"longmemeval: reusing prepared build {preparationId}; " + + $"fingerprint {manifest.Fingerprint}; no extraction will run."); + } + else + { + var adapter = new AgentMemoryLongMemEvalAdapter( + baseProfile.Services.GetRequiredService(), + extractionCalls, + preparationId, + new LongMemEvalAdapterOptions + { + MemoryMode = LongMemEvalMemoryMode.Structured, + MaxRelevantMessages = options.MaxRelevantMessages, + MinSimilarityScore = 0, + ModelId = deployment, + EvidenceIndex = evidenceIndex, + EvidenceDetail = options.EvidenceDetail, + RequireGraphReadBack = true, + GraphProbe = new Neo4jLongMemEvalGraphProbe(driver), + PreparationOnly = true, + DiagnosticSourceSessionOrdinal = options.DiagnosticSourceSessionOrdinal, + ExtractionProgress = (completed, total) => Console.WriteLine( + $"longmemeval: preparation extraction units {completed}/{total}.") + }); + + var questionIndexes = options.IsDiagnostic + ? new[] { options.DiagnosticQuestionPosition!.Value - 1 } + : Array.Empty(); + foreach (var index in questionIndexes) + { + var question = questions[index]; + await adapter.ResetSessionAsync().ConfigureAwait(false); + adapter.InjectConversationHistory( + LongMemEvalBenchmarkProtocol.History(question)); + _ = await adapter.InvokeAsync(question.InvocationPrompt) + .ConfigureAwait(false); + Console.WriteLine( + $"longmemeval: prepared question {index + 1}/{questions.Length}."); + } + + if (options.IsDiagnostic) + { + var diagnosticSnapshot = extractionCalls.Snapshot(); + if (diagnosticSnapshot.Calls != 4 || + diagnosticSnapshot.Failures != 0) + { + throw new InvalidOperationException( + $"Diagnostic extraction accounting mismatch: observed " + + $"{diagnosticSnapshot.Calls} calls and " + + $"{diagnosticSnapshot.Failures} failures; expected exactly " + + "4 calls and zero failures."); + } + var purposes = string.Join( + ", ", + diagnosticSnapshot.CallDetails + .GroupBy(detail => detail.Purpose) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => $"{group.Key}={group.Count()}")); + Console.WriteLine( + $"longmemeval: diagnostic-only extraction completed for question " + + $"{options.DiagnosticQuestionPosition}, source session " + + $"{options.DiagnosticSourceSessionOrdinal}: 4 calls / 0 failures; " + + $"purposes {purposes}; no report, clone, recall, answer, or judge executed."); + return 0; + } + var plans = LongMemEvalPreparedBatchExecutor.Preflight( + baseProfile.Services, + preparationId, + evidenceIndex, + questions, + options.MaxSessionsPerBatch, + options.MaxInputTokens); + var plannedCalls = plans.Sum(plan => (long)plan.BatchCount); + var plannedSourceSessions = + plans.Sum(plan => plan.SourceSessionCount); + var plannedInputTokens = + plans.Sum(plan => plan.TotalEstimatedInputTokens); + if (options.Questions == DefaultQuestions && + options.Seed == DefaultSeed && + plannedSourceSessions != FixedTenExpectedSourceSessions) + { + throw new InvalidOperationException( + $"Canonical fixed-ten preflight produced {plannedSourceSessions} " + + $"source sessions; expected exactly {FixedTenExpectedSourceSessions}."); + } + Console.WriteLine( + $"longmemeval: frozen preparation preflight {plannedCalls} calls for " + + $"{plannedSourceSessions} source sessions and " + + $"{plannedInputTokens} estimated input tokens."); + if (options.PreflightOnly) + { + var preflightSnapshot = extractionCalls.Snapshot(); + if (preflightSnapshot.Calls != 0 || + preflightSnapshot.Failures != 0) + { + throw new InvalidOperationException( + "Preflight-only execution performed provider work."); + } + Console.WriteLine( + "longmemeval: preflight-only accepted; zero provider calls, " + + "zero graph writes, no report, clone, recall, answer, or judge executed."); + return 0; + } + if (options.CheckpointQuestions is int checkpointQuestions) + { + var checkpointIndexes = LongMemEvalPreparedBatchExecutor + .SelectCheckpointQuestionIndexes(plans, checkpointQuestions); + var checkpointCalls = checkpointIndexes.Sum( + index => (long)plans[index].BatchCount); + var checkpointSourceSessions = checkpointIndexes.Sum( + index => (long)plans[index].SourceSessionCount); + var checkpointInputTokens = checkpointIndexes.Sum( + index => plans[index].TotalEstimatedInputTokens); + var checkpointFingerprint = Convert.ToHexStringLower( + SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new + { + schema = 1, + datasetSha256, + agentEvalRevision, + answerModelId = deployment, + extractionModelId = extractionDeployment, + embeddingModelId = embeddingDeployment, + embeddingDimensions, + options.MaxRelevantMessages, + options.PreparationWorkers, + // Both provider-concurrency knobs belong here: they determine the wall + // time this checkpoint projects, so omitting them let two runs with + // different concurrency share a fingerprint and have their projections + // compared as though equivalent. + options.MaxConcurrentBatchesPerExtraction, + options.MaxConcurrentExtractionBatches, + options.MaxSessionsPerBatch, + options.MaxInputTokens, + options.CheckpointTimeoutSeconds, + projectionSafetyMargin = 1.25d, + coldBuildSpeedTargetMilliseconds = + ColdBuildSpeedTargetMilliseconds, + questions = plans.Select((plan, index) => new + { + questionNumber = index + 1, + sourceSessions = plan.SourceSessionCount, + calls = plan.BatchCount, + estimatedInputTokens = plan.TotalEstimatedInputTokens + }).ToArray(), + selectedQuestionNumbers = checkpointIndexes + .Select(index => index + 1) + .ToArray() + }))); + Console.WriteLine( + $"longmemeval: checkpoint {checkpointFingerprint}; questions " + + $"{string.Join(',', checkpointIndexes.Select(index => index + 1))}; " + + $"{checkpointCalls} calls, {checkpointSourceSessions} source sessions, " + + $"{checkpointInputTokens} estimated input tokens; " + + $"deadline {options.CheckpointTimeoutSeconds}s."); + + var checkpointWall = Stopwatch.StartNew(); + var checkpointExecution = await RunPreparedWithDiagnosticsAsync( + cancellationToken => LongMemEvalPreparedBatchExecutor.ExecuteAsync( + baseProfile.Services, + extractionCalls, + preparationId, + evidenceIndex, + questions, + plans, + deployment, + options.EvidenceDetail, + options.MaxRelevantMessages, + options.PreparationWorkers, + options.MaxSessionsPerBatch, + options.MaxInputTokens, + checkpointIndexes, + cancellationToken), + extractionCalls, + checkpointCalls, + TimeSpan.FromSeconds(options.CheckpointTimeoutSeconds), + baseProfile.Services, + TimeSpan.FromSeconds(options.ProviderNoProgressTimeoutSeconds), + "checkpoint", + Console.Out) + .ConfigureAwait(false); + checkpointWall.Stop(); + ValidateCheckpointTelemetry( + checkpointExecution.Telemetry, questions, plans, checkpointIndexes); + var checkpointSnapshot = extractionCalls.Snapshot(); + if (checkpointExecution.PlannedCalls != checkpointCalls || + checkpointExecution.EstimatedInputTokens != checkpointInputTokens || + checkpointSnapshot.Calls != checkpointCalls || + checkpointSnapshot.CompletedCalls != checkpointCalls || + checkpointSnapshot.RetryCalls != 0 || + checkpointSnapshot.MaximumConcurrency <= 1 || + checkpointSnapshot.MaximumConcurrency > + options.MaxConcurrentExtractionBatches || + checkpointSnapshot.Failures != 0 || + checkpointExecution.MaximumConcurrency <= 0 || + checkpointExecution.MaximumConcurrency > + Math.Min(checkpointQuestions, options.PreparationWorkers)) + { + throw new InvalidOperationException( + "LongMemEval checkpoint accounting or concurrency guard failed."); + } + + var projectedMilliseconds = LongMemEvalPreparedBatchExecutor + .ProjectFullPreparationMilliseconds( + plannedCalls, + plannedSourceSessions, + plannedInputTokens, + checkpointCalls, + checkpointSourceSessions, + checkpointInputTokens, + checkpointWall.Elapsed.TotalMilliseconds, + profileStartup.Elapsed.TotalMilliseconds); + Console.WriteLine( + $"longmemeval: checkpoint completed in " + + $"{checkpointWall.Elapsed.TotalMilliseconds:F2} ms wall; " + + $"{checkpointSnapshot.Duration.TotalMilliseconds:F2} ms aggregate provider; " + + $"maximum provider concurrency {checkpointSnapshot.MaximumConcurrency}; " + + $"maximum preparation concurrency {checkpointExecution.MaximumConcurrency}; " + + $"conservative full cold-build projection {projectedMilliseconds:F2} ms."); + Console.WriteLine( + $"longmemeval: cold-build speed target met: " + + $"{projectedMilliseconds <= ColdBuildSpeedTargetMilliseconds}."); + Console.WriteLine( + "longmemeval: checkpoint accepted; no manifest, clone, recall, " + + "answer, judge, or report executed."); + return 0; + } + batchExecution = await RunPreparedWithDiagnosticsAsync( + cancellationToken => LongMemEvalPreparedBatchExecutor.ExecuteAsync( + baseProfile.Services, + extractionCalls, + preparationId, + evidenceIndex, + questions, + plans, + deployment, + options.EvidenceDetail, + options.MaxRelevantMessages, + options.PreparationWorkers, + options.MaxSessionsPerBatch, + options.MaxInputTokens, + questionIndexes: null, + cancellationToken), + extractionCalls, + plannedCalls, + TimeSpan.FromSeconds(options.CheckpointTimeoutSeconds), + baseProfile.Services, + TimeSpan.FromSeconds(options.ProviderNoProgressTimeoutSeconds), + "fixed-ten preparation", + Console.Out) + .ConfigureAwait(false); + preparationTelemetry = batchExecution.Telemetry; + ValidatePreparationTelemetry(preparationTelemetry, questions.Length); + var initialExtractionCalls = batchExecution.PlannedCalls; + var extractionSnapshot = extractionCalls.Snapshot(); + if (extractionSnapshot.Calls != initialExtractionCalls || + extractionSnapshot.CompletedCalls != initialExtractionCalls || + extractionSnapshot.Failures != 0 || + extractionSnapshot.RetryCalls != 0 || + extractionSnapshot.MaximumConcurrency <= 1 || + extractionSnapshot.MaximumConcurrency > options.MaxConcurrentExtractionBatches) + { + throw new InvalidOperationException( + $"Prepared LongMemEval extraction accounting mismatch: started/completed " + + $"{extractionSnapshot.Calls}/{extractionSnapshot.CompletedCalls}, failures " + + $"{extractionSnapshot.Failures}, retries {extractionSnapshot.RetryCalls}, maximum " + + $"provider concurrency {extractionSnapshot.MaximumConcurrency}; expected exactly " + + $"{initialExtractionCalls} completed calls, zero failures/retries, and concurrency 2..{options.MaxConcurrentExtractionBatches}."); + } + + var preparedQuestions = questions.Select((question, index) => + { + var telemetry = preparationTelemetry[index]; + var history = LongMemEvalBenchmarkProtocol.History(question); + var sourceSessions = question.Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Distinct() + .Count(); + if (telemetry.ExtractionUnits != sourceSessions) + { + throw new InvalidOperationException( + $"Prepared LongMemEval source-session count mismatch at question {index + 1}."); + } + + return new LongMemEvalPreparedQuestion( + index + 1, + question.QuestionId, + LongMemEvalEvidenceIndex.Fingerprint(history), + LongMemEvalPreparationManifest.Hash( + $"{preparationId}-session-{index + 1:D4}|{preparationId}-owner-{index + 1:D4}"), + telemetry.MessagesStored, + sourceSessions, + telemetry.ExtractionUnits, + telemetry.GraphReadBack + ?? throw new InvalidOperationException( + $"Prepared LongMemEval question {index + 1} has no graph snapshot.")); + }).ToArray(); + manifest = LongMemEvalPreparationManifest.Create( + preparationId, + datasetSha256, + agentEvalRevision, + preparationId, + deployment, + deployment, + extractionDeployment, + embeddingDeployment, + embeddingDimensions, + options.MaxRelevantMessages, + expectation.ExtractionSourceTime, + preparedQuestions, + initialExtractionCalls, + useJsonResponseFormat: expectation.UseJsonResponseFormat, + extractionResponseContract: expectation.ExtractionResponseContract, + useUnifiedExtraction: true, + useMultiSessionBatchExtraction: true, + preparationWorkers: options.PreparationWorkers, + maxSessionsPerBatch: options.MaxSessionsPerBatch, + maxInputTokens: options.MaxInputTokens, + maxConcurrentBatchesPerExtraction: options.MaxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches: options.MaxConcurrentExtractionBatches); + + var seal = Stopwatch.StartNew(); + var store = new Neo4jLongMemEvalPreparationStore(driver); + await store.SealAsync(manifest).ConfigureAwait(false); + var sealedManifest = await store.ReadAsync(preparationId).ConfigureAwait(false); + seal.Stop(); + manifestSealMilliseconds = seal.Elapsed.TotalMilliseconds; + if (!string.Equals( + sealedManifest.Fingerprint, + manifest.Fingerprint, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Prepared LongMemEval manifest read-back did not match the sealed fingerprint."); + } + } + } + finally + { + var stop = Stopwatch.StartNew(); + if (baseProfile is not null) + await baseProfile.DisposeAsync().ConfigureAwait(false); + stop.Stop(); + baseStopMilliseconds = stop.Elapsed.TotalMilliseconds; + volumes.MarkBaseContainerStopped(); + } + + var cloneTimings = await volumes.CloneFrozenBaseAsync(CancellationToken.None) + .ConfigureAwait(false); + var structured = await RunArmAsync( + LongMemEvalMemoryMode.Structured, + volumes.StructuredVolumeName, + manifest, + expectation, + preparationId, + options, + benchmarkOptions, + azureClient, + embeddingGenerator, + extractionDeployment, + deployment, + embeddingDimensions) + .ConfigureAwait(false); + var hybrid = await RunArmAsync( + LongMemEvalMemoryMode.Hybrid, + volumes.HybridVolumeName, + manifest, + expectation, + preparationId, + options, + benchmarkOptions, + azureClient, + embeddingGenerator, + extractionDeployment, + deployment, + embeddingDimensions) + .ConfigureAwait(false); + overall.Stop(); + + var accepted = + structured.Validation.Accepted && + hybrid.Validation.Accepted && + string.Equals( + structured.ManifestFingerprint, + hybrid.ManifestFingerprint, + StringComparison.Ordinal) && + string.Equals( + structured.ManifestFingerprint, + manifest.Fingerprint, + StringComparison.Ordinal); + var issues = structured.Validation.Issues + .Select(issue => $"structured: {issue}") + .Concat(hybrid.Validation.Issues.Select(issue => $"hybrid: {issue}")) + .ToList(); + if (!string.Equals( + structured.ManifestFingerprint, + hybrid.ManifestFingerprint, + StringComparison.Ordinal) || + !string.Equals( + structured.ManifestFingerprint, + manifest.Fingerprint, + StringComparison.Ordinal)) + { + issues.Add("Prepared clone manifest fingerprints do not match the sealed base."); + } + + var runId = ResolveRunId(preparationId, reusing, DateTimeOffset.UtcNow); + var destination = ResolveOutput(options.OutputPath, runId); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var extractionSnapshotFinal = extractionCalls.Snapshot(); + var report = new + { + schemaVersion = 3, + runId, + // The preparation this run measured, which for a reused run is an earlier run's. + scopeRunId = preparationId, + generatedAtUtc = DateTimeOffset.UtcNow, + accepted, + validationIssues = issues, + fingerprint = new + { + dataset = Path.GetFileName(options.DatasetPath), + datasetSha256, + questions = options.Questions, + seed = options.Seed, + stratified = true, + answerModel = deployment, + judgeModel = deployment, + extractionModel = extractionDeployment, + embeddingModel = embeddingDeployment, + embeddingDimensions, + maxRelevantMessages = options.MaxRelevantMessages, + operatingModes = new[] + { + LongMemEvalMemoryMode.Structured.Fingerprint(), + LongMemEvalMemoryMode.Hybrid.Fingerprint() + }, + extractionSourceTime = expectation.ExtractionSourceTime, + extractionResponseFormat = expectation.UseJsonResponseFormat + ? expectation.ExtractionResponseContract + : "unspecified", + extractionExecution = "unified-multi-session-batch", + preparationWorkers = options.PreparationWorkers, + // Null on a reused run: this run observed no preparation concurrency because it + // performed no preparation. The nullable type is compiler-verified. + maximumObservedPreparationConcurrency = + batchExecution?.MaximumConcurrency, + maxSessionsPerBatch = options.MaxSessionsPerBatch, + maxInputTokens = options.MaxInputTokens, + maxConcurrentBatchesPerExtraction = options.MaxConcurrentBatchesPerExtraction, + maxConcurrentExtractionBatches = options.MaxConcurrentExtractionBatches, + preparationWatchdogSeconds = options.CheckpointTimeoutSeconds, + providerNoProgressWatchdogSeconds = options.ProviderNoProgressTimeoutSeconds, + // Retrieval-side settings belong in the fingerprint: they change the score, and + // without them two runs over the same frozen graph are indistinguishable in the + // artifact - which is precisely the comparison reuse exists to make. + expandFactsByPredicate = options.ExpandFactsByPredicate, + resolveQueryRelations = options.ResolveQueryRelations, + usePredicateVocabulary = options.UsePredicateVocabulary, + maxItemsPerSourceSession = options.MaxItemsPerSourceSession, + // K6. Additive on top of the mode budget, so a score compared against a run + // without it is confounded with the larger context. Recorded here so no later + // reader can mistake the two runs for a controlled comparison. + graphRagItems = options.GraphRagItems, + // The vocabulary decides what is stored and the lexicon decides what is + // retrieved, so a run under a different table is not comparable to this one. + // Without these the artifact would not record which tables produced it. + extractionVocabularySha256 = MemoryPredicateSeedVocabulary.Fingerprint, + queryRelationLexiconSha256 = MemoryRelationSeedTable.Fingerprint, + evidenceDetail = options.EvidenceDetail.ToString().ToLowerInvariant(), + oracleMode = options.OracleMode.ToString().ToLowerInvariant(), + judgeRetryAttempts = options.JudgeRetryAttempts, + neo4jImage = "neo4j:5.26", + agentEval = agentEvalRevision, + agentEvalDependency = "source-project:AgentEval.Memory" + }, + preparation = LongMemEvalReportProjection.CreatePreparationSection( + manifest, + batchExecution, + preparationTelemetry, + Project(extractionSnapshotFinal), + extractionSnapshotFinal.Calls, + new LongMemEvalPreparationTimings( + profileStartup.Elapsed.TotalMilliseconds, + reusing ? null : manifestSealMilliseconds, + baseStopMilliseconds, + cloneTimings.StructuredMilliseconds, + cloneTimings.HybridMilliseconds), + options.ReusePreparedVolume), + arms = new + { + structured = ProjectArm(structured, options.EvidenceDetail), + hybrid = ProjectArm(hybrid, options.EvidenceDetail) + }, + totalWallMs = overall.Elapsed.TotalMilliseconds, + timingScope = + "Local Docker and provider characterization only; not deployment latency. Provider aggregate duration is reported separately and is not added to wall time." + }; + await File.WriteAllTextAsync( + destination, + JsonSerializer.Serialize( + report, + new JsonSerializerOptions { WriteIndented = true }) + + Environment.NewLine).ConfigureAwait(false); + + if (!accepted) + { + foreach (var issue in issues) + Console.Error.WriteLine($"longmemeval: validation: {issue}"); + Console.Error.WriteLine( + $"longmemeval: rejected prepared-pair diagnostic report {destination}"); + return 1; + } + + Console.WriteLine( + $"longmemeval: prepared pair accepted; structured={structured.Result.OverallAccuracy:F1}% hybrid={hybrid.Result.OverallAccuracy:F1}%."); + Console.WriteLine($"longmemeval: report {destination}"); + return 0; + } + catch (Exception exception) + { + // The whole chain, not just the outermost message. A stage wrapper says "LongMemEval + // batched extraction stage failed." and nothing else, so a 26-minute run reported its + // own death with no cause attached. Types and messages only - no stack traces. + var chain = new List(); + for (var current = exception; current is not null; current = current.InnerException) + chain.Add($"{current.GetType().Name}: {current.Message}"); + Console.Error.WriteLine( + $"longmemeval: prepared pair failed: {string.Join(" <- ", chain)}"); + return 1; + } + } + + private static async Task RunArmAsync( + LongMemEvalMemoryMode mode, + string volumeName, + LongMemEvalPreparationManifest expectedManifest, + LongMemEvalPreparationExpectation expectation, + string scopeRunId, + PreparedPairOptions options, + ExternalBenchmarkOptions benchmarkOptions, + AzureOpenAIClient azureClient, + IEmbeddingGenerator> embeddingGenerator, + string extractionDeployment, + string deployment, + int embeddingDimensions) + { + using var answerCalls = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var judgeCalls = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var diagnosticCalls = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var evaluationExtractionCalls = new LongMemEvalChatCallMeter( + new ProviderCompatibleExtractionChatClient( + azureClient.GetChatClient(extractionDeployment).AsIChatClient())); + var total = Stopwatch.StartNew(); + var profileStartup = Stopwatch.StartNew(); + await using var profile = await LongMemEvalMemoryProfile.StartAsync( + embeddingGenerator, + evaluationExtractionCalls, + mode, + extractionDeployment, + embeddingDimensions, + Console.Out, + CancellationToken.None, + volumeName, + // K6. Pointed at the memory layer's own fact index: this corpus has no separate + // knowledge graph, which is the setting GraphRAG was designed for. Retrieving the + // same Fact nodes the Structured arm already retrieves is the whole question - does + // a second budget over the same data add anything? + graphRagIndexName: options.GraphRagItems > 0 ? "fact_embedding_idx" : null) + .ConfigureAwait(false); + profileStartup.Stop(); + + var validationTiming = Stopwatch.StartNew(); + var driver = profile.Services.GetRequiredService(); + var manifest = await new Neo4jLongMemEvalPreparationStore(driver) + .ReadAsync(expectedManifest.PreparationId) + .ConfigureAwait(false); + var state = new LongMemEvalPreparedState( + manifest, + scopeRunId, + expectation); + validationTiming.Stop(); + var evidenceIndex = LongMemEvalEvidenceIndex.Load( + options.DatasetPath, + benchmarkOptions); + var adapter = new AgentMemoryLongMemEvalAdapter( + profile.Services.GetRequiredService(), + answerCalls, + scopeRunId, + new LongMemEvalAdapterOptions + { + MemoryMode = mode, + PreparedMemory = true, + PreparedState = state, + MaxRelevantMessages = options.MaxRelevantMessages, + MinSimilarityScore = 0, + ModelId = deployment, + EvidenceIndex = evidenceIndex, + EvidenceDetail = options.EvidenceDetail, + // Every G3B.1-.4 correction previously reached the Raw arm only, so Structured and + // Hybrid were being measured through the uncorrected message pipeline - an unfair + // comparison against our own product. Hybrid was the visible casualty: 66% of its + // message slots were formatter boilerplate and its two failing multi-session + // questions received 0 and 2 real turns out of 15. + ExcludeSyntheticFormatterMessages = true, + ExpandFactsByPredicate = options.ExpandFactsByPredicate, + ResolveQueryRelations = options.ResolveQueryRelations, + MaxItemsPerSourceSession = options.MaxItemsPerSourceSession, + GraphRagItems = options.GraphRagItems, + ChronologicalAnswerContext = true, + RequireGraphReadBack = true, + GraphProbe = new Neo4jLongMemEvalGraphProbe(driver) + }); + var runner = LongMemEvalBenchmarkRunner.Create( + judgeCalls, + options.DatasetPath); + var result = await runner.RunAsync( + adapter, + new AgentBenchmarkConfig + { + AgentName = adapter.Name, + ModelId = deployment, + ReducerStrategy = + $"AgentMemory prepared {mode.ToString().ToLowerInvariant()} recall", + MemoryProvider = "AgentMemory .NET / Neo4j 5.26 frozen clone" + }, + benchmarkOptions) + .ConfigureAwait(false); + var diagnostics = await LongMemEvalPostRunDiagnostics.RunAsync( + diagnosticCalls, + evidenceIndex, + result.QuestionResults, + adapter.QuestionTelemetry, + options.OracleMode, + options.JudgeRetryAttempts, + retainContent: options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + .ConfigureAwait(false); + var answerSnapshot = answerCalls.Snapshot(); + var judgeSnapshot = judgeCalls.Snapshot(); + var diagnosticSnapshot = diagnosticCalls.Snapshot(); + var extractionSnapshot = evaluationExtractionCalls.Snapshot(); + var validation = LongMemEvalRunValidator.Validate( + options.Questions, + result.TotalLlmCalls, + adapter.QuestionTelemetry, + result.QuestionResults, + answerSnapshot, + judgeSnapshot, + extractionSnapshot, + expectedInitialExtractionCalls: 0, + diagnosticJudgeCalls: diagnostics.JudgeRetries.Count, + agentEvalJudgeRetryAllowance: options.JudgeRetryAttempts); + total.Stop(); + return new PreparedArmExecution( + mode, + manifest.Fingerprint, + adapter.QuestionTelemetry, + result, + diagnostics, + validation, + answerSnapshot, + judgeSnapshot, + diagnosticSnapshot, + extractionSnapshot, + new PreparedArmTimings( + profileStartup.Elapsed.TotalMilliseconds, + validationTiming.Elapsed.TotalMilliseconds, + adapter.QuestionTelemetry.Sum(item => + item.StageTimings?.RetrievalMs ?? 0), + adapter.QuestionTelemetry.Sum(item => + item.StageTimings?.AnswerMs ?? 0), + total.Elapsed.TotalMilliseconds)); + } + + private static object ProjectArm( + PreparedArmExecution arm, + LongMemEvalEvidenceDetail evidenceDetail) => + new + { + mode = arm.Mode.ToString().ToLowerInvariant(), + arm.ManifestFingerprint, + accepted = arm.Validation.Accepted, + validationIssues = arm.Validation.Issues, + messagesPrepared = arm.Telemetry.Sum(item => item.MessagesPrepared), + messagesStoredDuringEvaluation = arm.Telemetry.Sum(item => item.MessagesStored), + extractionUnitsPrepared = arm.Telemetry.Sum(item => item.ExtractionUnitsPrepared), + extractionUnitsDuringEvaluation = arm.Telemetry.Sum(item => item.ExtractionUnits), + itemsRetrieved = arm.Telemetry.Sum(item => item.ItemsRetrieved), + // J5.1. The cost half of the comparison. Item counts alone cannot say whether an arm is + // cheaper, and the band's recorded token figures predate predicate expansion entirely. + meanEstimatedContextTokens = arm.Telemetry.Count == 0 + ? 0 + : arm.Telemetry.Average(item => item.EstimatedContextTokens), + maxEstimatedContextTokens = arm.Telemetry.Count == 0 + ? 0 + : arm.Telemetry.Max(item => item.EstimatedContextTokens), + rawMessagesRetrieved = arm.Telemetry.Sum(item => item.RawMessagesRetrieved), + entitiesRetrieved = arm.Telemetry.Sum(item => item.EntitiesRetrieved), + factsRetrieved = arm.Telemetry.Sum(item => item.FactsRetrieved), + preferencesRetrieved = arm.Telemetry.Sum(item => item.PreferencesRetrieved), + // K6. Zero on every run before this flag existed, because the budget was zero. Reported + // as a pair: the count says whether the mechanism works at all, and the overlap says + // whether what came back was already in the structured context. + graphRagItemsRetrieved = arm.Telemetry.Sum(item => item.GraphRagItemsRetrieved), + graphRagFactsAlreadyRetrieved = + arm.Telemetry.Sum(item => item.GraphRagFactsAlreadyRetrieved), + questions = arm.Telemetry, + timings = new + { + arm.Timings.ProfileStartupMs, + arm.Timings.PreparedStateValidationMs, + arm.Timings.RecallMs, + arm.Timings.AnswerMs, + judgeProviderMs = arm.JudgeCalls.Duration.TotalMilliseconds, + arm.Timings.TotalEvaluationMs + }, + callAccounting = new + { + benchmarkLlmCalls = arm.Result.TotalLlmCalls, + diagnosticLlmCalls = arm.Diagnostics.DiagnosticLlmCalls, + observed = new + { + answer = Project(arm.AnswerCalls), + judge = Project(arm.JudgeCalls), + extraction = Project(arm.ExtractionCalls), + diagnostics = Project(arm.DiagnosticCalls) + } + }, + postRunDiagnostics = arm.Diagnostics, + result = arm.Validation.Accepted + ? LongMemEvalReportProjection.CreateAcceptedResult( + arm.Result, + evidenceDetail) + : null, + // A rejected arm previously discarded all thirty questions' results, so one unjudgeable + // question destroyed the evidence for the other twenty-nine. The hybrid arm has now been + // rejected three times on the same question's judge verdict, each time taking a complete + // run's data with it. The acceptance guard is unchanged - `accepted` is still false and + // `result` is still null - but the measurements are kept under a name no reader can + // mistake for an accepted result. + unacceptedResult = arm.Validation.Accepted + ? null + : LongMemEvalReportProjection.CreateAcceptedResult(arm.Result, evidenceDetail) + }; + + private static async Task RunPreparedWithDiagnosticsAsync( + Func> operation, + LongMemEvalChatCallMeter meter, + long expectedProviderCalls, + TimeSpan overallTimeout, + IServiceProvider services, + TimeSpan noProviderProgressTimeout, + string phase, + TextWriter output) + { + try + { + return await LongMemEvalPreparationWatchdog.RunAsync( + operation, + meter, + expectedProviderCalls, + overallTimeout, + noProviderProgressTimeout, + phase, + output) + .ConfigureAwait(false); + } + catch (Exception exception) + { + var snapshot = services.GetRequiredService().Snapshot(); + if (snapshot.Splits == 0) + throw; + var reasons = string.Join(',', snapshot.Details.GroupBy(item => item.Reason).OrderBy(group => group.Key, StringComparer.Ordinal).Select(group => $"{group.Key}={group.Count()}")); + var sizes = string.Join(',', snapshot.Details.GroupBy(item => item.SourceSessions).OrderBy(group => group.Key).Select(group => $"{group.Key}={group.Count()}")); + var types = string.Join(',', snapshot.Details.Select(item => item.ExceptionType).Distinct(StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal)); + throw new InvalidOperationException( + $"{exception.Message} Content-free batch-split diagnostics: " + + $"splits={snapshot.Splits}; reasons={reasons}; " + + $"source_session_counts={sizes}; " + + $"exception_types={types}; dropped_details={snapshot.DroppedDetails}.", + exception); + } + } + private static object Project(LongMemEvalChatCallSnapshot snapshot) => new + { + snapshot.Calls, + snapshot.CompletedCalls, + snapshot.Failures, + snapshot.RetryCalls, + snapshot.MaximumConcurrency, + durationMs = snapshot.Duration.TotalMilliseconds, + snapshot.DroppedCallDetails, + batches = snapshot.CallDetails + .Where(detail => string.Equals(detail.Purpose, "unified_batch", StringComparison.Ordinal)) + .Select(detail => new + { + detail.CallOrdinal, + detail.DurationMilliseconds, + detail.EstimatedInputTokens, + detail.Retry, + detail.ExceptionType, + detail.ProviderStatus + }) + .ToArray() + }; + + private static void ValidateCheckpointTelemetry( + IReadOnlyList telemetry, + IReadOnlyList questions, + IReadOnlyList plans, + IReadOnlyList questionIndexes) + { + if (telemetry.Count != questionIndexes.Count) + throw new InvalidOperationException( + "LongMemEval checkpoint telemetry count did not match its frozen selection."); + + for (var position = 0; position < questionIndexes.Count; position++) + { + var questionIndex = questionIndexes[position]; + var item = telemetry[position]; + var plan = plans[questionIndex]; + if (item.QuestionNumber != questionIndex + 1 || + !string.Equals(item.Status, "prepared", StringComparison.Ordinal) || + item.MessagesStored <= 0 || + item.ExtractionUnits != plan.SourceSessionCount || + item.ExtractionCallsPlanned != plan.BatchCount || + item.ItemsRetrieved != 0 || + item.GraphReadBack is null || + item.GraphReadBack.TotalLearned == 0 || + !item.GraphReadBack.CompleteProvenance || + item.StageTimings is null || + item.StageTimings.StorageMs <= 0 || + item.StageTimings.ExtractionPersistenceMs <= 0 || + item.StageTimings.GraphReadBackMs <= 0) + { + throw new InvalidOperationException( + $"LongMemEval checkpoint question {questionIndex + 1} failed " + + "storage, extraction, graph, provenance, or timing guards."); + } + + var sourceSessions = questions[questionIndex].Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Distinct() + .Count(); + if (sourceSessions != plan.SourceSessionCount) + throw new InvalidOperationException( + $"LongMemEval checkpoint question {questionIndex + 1} source-session guard failed."); + } + } + + private static void ValidatePreparationTelemetry( + IReadOnlyList telemetry, + int expectedQuestions) + { + if (telemetry.Count != expectedQuestions || + telemetry.Any(item => + !string.Equals(item.Status, "prepared", StringComparison.Ordinal) || + item.MessagesStored <= 0 || + item.ExtractionUnits <= 0 || + item.ExtractionCallsPlanned <= 0 || + item.ItemsRetrieved != 0 || + item.GraphReadBack is null || + item.GraphReadBack.TotalLearned == 0 || + !item.GraphReadBack.CompleteProvenance)) + { + throw new InvalidOperationException( + "LongMemEval preparation did not prove nonzero storage, extraction, and complete graph read-back for every question."); + } + } + + private static string AgentEvalRevision() + { + var assembly = typeof(ExternalBenchmarkOptions).Assembly; + return assembly.GetCustomAttribute() + ?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + } + + private static PreparedPairOptions Parse(string[] args) + { + string? Value(string name) + { + var index = Array.IndexOf(args, name); + if (index < 0) return null; + if (index + 1 >= args.Length) + throw new ArgumentException($"{name} requires a value."); + return args[index + 1]; + } + bool Has(string name) => + Array.IndexOf(args, name) >= 0; + + return new PreparedPairOptions( + Value("--dataset") ?? string.Empty, + ParsePositive(Value("--questions"), DefaultQuestions, "--questions"), + ParsePositive(Value("--seed"), DefaultSeed, "--seed"), + ParsePositive(Value("--max-relevant"), DefaultMaxRelevant, "--max-relevant"), + ParseEvidenceDetail(Value("--evidence-detail")), + ParseOracleMode(Value("--oracle")), + ParseNonNegative(Value("--judge-retries"), 2, "--judge-retries"), + Value("--output"), + ParseOptionalPositive(Value("--diagnostic-question"), "--diagnostic-question"), + ParseOptionalNonNegative( + Value("--diagnostic-source-session"), "--diagnostic-source-session"), + ParsePositive(Value("--preparation-workers"), DefaultPreparationWorkers, "--preparation-workers"), + ParsePositive(Value("--max-sessions-per-batch"), DefaultMaxSessionsPerBatch, "--max-sessions-per-batch"), + ParsePositive(Value("--max-input-tokens"), DefaultMaxInputTokens, "--max-input-tokens"), + ParsePositive( + Value("--max-concurrent-batches-per-extraction"), + DefaultMaxConcurrentBatchesPerExtraction, + "--max-concurrent-batches-per-extraction"), + ParsePositive(Value("--max-concurrent-extraction-batches"), + DefaultMaxConcurrentExtractionBatches, "--max-concurrent-extraction-batches"), + Has("--preflight-only"), + Has("--retain-prepared-volumes"), + Has("--use-predicate-vocabulary"), + Has("--expand-facts-by-predicate"), + Has("--resolve-query-relations"), + Value("--reuse-prepared-volumes"), + ParseNonNegative(Value("--max-items-per-session"), 0, "--max-items-per-session"), + ParseOptionalPositive(Value("--checkpoint-questions"), "--checkpoint-questions"), + ParsePositive( + Value("--checkpoint-timeout-seconds"), + DefaultCheckpointTimeoutSeconds, + "--checkpoint-timeout-seconds"), + ParsePositive(Value("--provider-no-progress-timeout-seconds"), + DefaultProviderNoProgressTimeoutSeconds, + "--provider-no-progress-timeout-seconds"), + Has("--no-orphan-sweep"), + ParseNonNegative(Value("--graphrag-items"), 0, "--graphrag-items")); + } + + private static void Validate(PreparedPairOptions options) + { + if (!string.IsNullOrWhiteSpace(options.ReusePreparedVolume) && + (options.IsDiagnostic || options.PreflightOnly || options.CheckpointQuestions is not null)) + { + // Every one of these exists to exercise the preparation path, which reuse skips + // entirely; combining them would report on work that never ran. + throw new ArgumentException( + "--reuse-prepared-volumes cannot be combined with diagnostic, preflight-only or " + + "checkpoint execution: those measure preparation, and reuse performs none."); + } + + if (options.ProviderNoProgressTimeoutSeconds > options.CheckpointTimeoutSeconds) + { + throw new ArgumentException( + "--provider-no-progress-timeout-seconds cannot exceed --checkpoint-timeout-seconds."); + } + + if (string.IsNullOrWhiteSpace(options.DatasetPath)) + throw new ArgumentException("--dataset is required."); + if (!File.Exists(options.DatasetPath)) + throw new FileNotFoundException("LongMemEval dataset not found.", options.DatasetPath); + if ((options.DiagnosticQuestionPosition is null) != + (options.DiagnosticSourceSessionOrdinal is null)) + { + throw new ArgumentException( + "--diagnostic-question and --diagnostic-source-session must be supplied together."); + } + if (options.DiagnosticQuestionPosition > options.Questions) + throw new ArgumentException( + "--diagnostic-question must be within the frozen selected-question count."); + if (options.IsDiagnostic && options.OutputPath is not null) + throw new ArgumentException( + "--output is forbidden for diagnostic-only extraction because it cannot emit an accepted report."); + if (options.IsDiagnostic && + options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + { + throw new ArgumentException( + "Content evidence is forbidden for diagnostic-only extraction."); + } + if (options.PreflightOnly && options.IsDiagnostic) + { + throw new ArgumentException( + "--preflight-only cannot be combined with diagnostic-only extraction."); + } + if (options.PreflightOnly && options.OutputPath is not null) + { + throw new ArgumentException( + "--output is forbidden for preflight-only execution."); + } + if (options.CheckpointQuestions > options.Questions) + { + throw new ArgumentException( + "--checkpoint-questions cannot exceed --questions."); + } + if (options.CheckpointQuestions is not null && + (options.IsDiagnostic || options.PreflightOnly)) + { + throw new ArgumentException( + "--checkpoint-questions cannot be combined with diagnostic or preflight-only execution."); + } + if (options.CheckpointQuestions is not null && options.OutputPath is not null) + { + throw new ArgumentException( + "--output is forbidden for checkpoint execution."); + } + if (options.CheckpointQuestions is not null && + options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + { + throw new ArgumentException( + "Content evidence is forbidden for checkpoint execution."); + } + } + + private static int ParsePositive(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed <= 0) + throw new ArgumentException($"{option} must be a positive integer."); + return parsed; + } + + private static int ParseNonNegative(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed < 0) + throw new ArgumentException($"{option} must be a non-negative integer."); + return parsed; + } + + private static int? ParseOptionalPositive(string? value, string option) + { + if (value is null) return null; + if (!int.TryParse(value, out var parsed) || parsed <= 0) + throw new ArgumentException($"{option} must be a positive integer."); + return parsed; + } + + private static int? ParseOptionalNonNegative(string? value, string option) + { + if (value is null) return null; + if (!int.TryParse(value, out var parsed) || parsed < 0) + throw new ArgumentException($"{option} must be a non-negative integer."); + return parsed; + } + + private static LongMemEvalEvidenceIndex? PreflightDiagnosticSelection( + PreparedPairOptions options) + { + if (!options.IsDiagnostic) + return null; + + var benchmarkOptions = LongMemEvalBenchmarkProtocol.CreateOptions( + options.DatasetPath, + options.Questions, + options.Seed, + options.JudgeRetryAttempts, + options.EvidenceDetail, + options.MaxRelevantMessages); + var evidenceIndex = LongMemEvalEvidenceIndex.Load( + options.DatasetPath, + benchmarkOptions); + var questions = evidenceIndex.Questions.ToArray(); + var questionIndex = options.DiagnosticQuestionPosition!.Value - 1; + if (questionIndex >= questions.Length) + throw new ArgumentException( + "The diagnostic question position does not exist in the frozen sample."); + var sourceSessionExists = questions[questionIndex].Messages + .Where(message => + !message.IsSyntheticBoundary && + !message.IsSyntheticFormatterPadding) + .Select(message => message.SourceSessionOrdinal) + .Contains(options.DiagnosticSourceSessionOrdinal!.Value); + if (!sourceSessionExists) + throw new ArgumentException( + "The diagnostic source-session ordinal does not exist in the selected question."); + return evidenceIndex; + } + + private static LongMemEvalEvidenceDetail ParseEvidenceDetail(string? value) => + value?.ToLowerInvariant() switch + { + null or "identifiers" => LongMemEvalEvidenceDetail.Identifiers, + "none" => LongMemEvalEvidenceDetail.None, + "content" => LongMemEvalEvidenceDetail.Content, + _ => throw new ArgumentException( + "--evidence-detail must be one of: none, identifiers, content.") + }; + + private static LongMemEvalOracleMode ParseOracleMode(string? value) => + value?.ToLowerInvariant() switch + { + null or "none" => LongMemEvalOracleMode.None, + "failed" => LongMemEvalOracleMode.Failed, + "all" => LongMemEvalOracleMode.All, + _ => throw new ArgumentException("--oracle must be one of: none, failed, all.") + }; + + private static string RequiredEnvironment(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"{name} is required; refusing to create a synthetic LongMemEval score."); + + /// + /// The identity of the run itself, which is not the identity of the preparation it measured. + /// + /// + /// A reused run must keep the sealed preparationId as its scope run id - the per-question + /// scope hashes derive from it - but it must not inherit it as its own run id, because the report + /// path is keyed on that and the reused run would overwrite the accepted report of the cold build + /// it attached to. + /// + internal static string ResolveRunId(string preparationId, bool reusing, DateTimeOffset now) => + reusing + ? $"{preparationId}-reuse-{now:yyyyMMddTHHmmssZ}" + : preparationId; + + private static string ResolveOutput(string? requested, string runId) => + Path.GetFullPath(requested ?? + Path.Combine( + "artifacts", + "evaluation", + runId, + "prepared-pair-report.json")); + + internal sealed record PreparedPairOptions( + string DatasetPath, + int Questions, + int Seed, + int MaxRelevantMessages, + LongMemEvalEvidenceDetail EvidenceDetail, + LongMemEvalOracleMode OracleMode, + int JudgeRetryAttempts, + string? OutputPath, + int? DiagnosticQuestionPosition, + int? DiagnosticSourceSessionOrdinal, + int PreparationWorkers, + int MaxSessionsPerBatch, + int MaxInputTokens, + int MaxConcurrentBatchesPerExtraction, + int MaxConcurrentExtractionBatches, + bool PreflightOnly, + bool RetainPreparedVolumes, + bool UsePredicateVocabulary, + bool ExpandFactsByPredicate, + bool ResolveQueryRelations, + string? ReusePreparedVolume, + int MaxItemsPerSourceSession, + int? CheckpointQuestions, + int CheckpointTimeoutSeconds, + int ProviderNoProgressTimeoutSeconds, + bool NoOrphanSweep, + int GraphRagItems) + { + internal bool IsDiagnostic => + DiagnosticQuestionPosition is not null && + DiagnosticSourceSessionOrdinal is not null; + } + + private sealed record PreparedArmTimings( + double ProfileStartupMs, + double PreparedStateValidationMs, + double RecallMs, + double AnswerMs, + double TotalEvaluationMs); + + private sealed record PreparedArmExecution( + LongMemEvalMemoryMode Mode, + string ManifestFingerprint, + IReadOnlyList Telemetry, + ExternalBenchmarkResult Result, + LongMemEvalPostRunDiagnosticsResult Diagnostics, + LongMemEvalRunValidation Validation, + LongMemEvalChatCallSnapshot AnswerCalls, + LongMemEvalChatCallSnapshot JudgeCalls, + LongMemEvalChatCallSnapshot DiagnosticCalls, + LongMemEvalChatCallSnapshot ExtractionCalls, + PreparedArmTimings Timings); +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalPreparedVolumes.cs b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedVolumes.cs new file mode 100644 index 00000000..ce49b154 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalPreparedVolumes.cs @@ -0,0 +1,294 @@ +using System.Diagnostics; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Volumes; + +namespace AgentMemory.LongMemEval; + +internal sealed record LongMemEvalVolumeCloneTimings( + double StructuredMilliseconds, + double HybridMilliseconds); + +internal sealed class LongMemEvalPreparedVolumes : IAsyncDisposable +{ + private const string Image = "neo4j:5.26"; + private readonly IVolume _baseVolume; + private readonly IVolume _structuredVolume; + private readonly IVolume _hybridVolume; + private readonly LongMemEvalPreparedVolumeLifecycle _lifecycle = new(); + private readonly bool _retain; + private readonly bool _adoptedBase; + + private LongMemEvalPreparedVolumes( + string baseVolumeName, + IVolume baseVolume, + string structuredVolumeName, + IVolume structuredVolume, + string hybridVolumeName, + IVolume hybridVolume, + bool retain, + bool adoptedBase = false) + { + _retain = retain; + _adoptedBase = adoptedBase; + BaseVolumeName = baseVolumeName; + _baseVolume = baseVolume; + StructuredVolumeName = structuredVolumeName; + _structuredVolume = structuredVolume; + HybridVolumeName = hybridVolumeName; + _hybridVolume = hybridVolume; + } + + internal string BaseVolumeName { get; } + + internal string StructuredVolumeName { get; } + + internal string HybridVolumeName { get; } + + /// + /// Adopts an existing retained base volume and creates fresh clone targets beside it. + /// + /// + /// G3B.12-R. A cold build costs 121 provider calls and ~22 minutes; a killed run forfeits all of + /// it, and because extraction is non-deterministic (575 → 650 → 700 facts across builds of one + /// frozen plan) two runs are never a controlled comparison. Adopting a retained build makes a + /// retrieval-only change - which alters no stored fact - cost only its evaluation. + /// + /// The base volume is never disposed here regardless of : this object + /// did not create it, and destroying an input a caller supplied would be a surprising side + /// effect. Clone targets follow the usual retention rule. + /// + /// + internal static async Task AdoptAsync( + string baseVolumeName, + CancellationToken cancellationToken, + bool retain = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseVolumeName); + var suffix = Guid.NewGuid().ToString("N"); + var structuredName = $"{baseVolumeName}-reuse-structured-{suffix}"; + var hybridName = $"{baseVolumeName}-reuse-hybrid-{suffix}"; + + // Referenced, not created: WithCleanUp(false) so disposal can never delete the adopted build. + var baseVolume = new VolumeBuilder().WithName(baseVolumeName).WithCleanUp(false).Build(); + var structuredVolume = Build(structuredName, retain); + var hybridVolume = Build(hybridName, retain); + var volumes = new LongMemEvalPreparedVolumes( + baseVolumeName, baseVolume, structuredName, structuredVolume, hybridName, hybridVolume, + retain, adoptedBase: true); + try + { + await structuredVolume.CreateAsync(cancellationToken).ConfigureAwait(false); + await hybridVolume.CreateAsync(cancellationToken).ConfigureAwait(false); + return volumes; + } + catch + { + await volumes.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + internal static async Task CreateAsync( + string preparationId, + CancellationToken cancellationToken, + bool retain = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(preparationId); + var suffix = Guid.NewGuid().ToString("N"); + var prefix = string.Concat(preparationId.Select(character => + char.IsLetterOrDigit(character) || character == '-' + ? char.ToLowerInvariant(character) + : '-')); + if (prefix.Length > 32) + prefix = prefix[..32]; + + var baseName = $"am-lme-{prefix}-base-{suffix}"; + var structuredName = $"am-lme-{prefix}-structured-{suffix}"; + var hybridName = $"am-lme-{prefix}-hybrid-{suffix}"; + var baseVolume = Build(baseName, retain); + var structuredVolume = Build(structuredName, retain); + var hybridVolume = Build(hybridName, retain); + var volumes = new LongMemEvalPreparedVolumes( + baseName, + baseVolume, + structuredName, + structuredVolume, + hybridName, + hybridVolume, + retain); + try + { + await baseVolume.CreateAsync(cancellationToken).ConfigureAwait(false); + await structuredVolume.CreateAsync(cancellationToken).ConfigureAwait(false); + await hybridVolume.CreateAsync(cancellationToken).ConfigureAwait(false); + return volumes; + } + catch + { + await volumes.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + internal string BeginBasePreparation() + { + _lifecycle.BeginBasePreparation(); + return BaseVolumeName; + } + + internal void MarkBaseContainerStopped() => _lifecycle.MarkBaseContainerStopped(); + + internal async Task CloneFrozenBaseAsync( + CancellationToken cancellationToken) + { + _lifecycle.BeginClone(); + try + { + var structured = Stopwatch.StartNew(); + await CloneAsync( + BaseVolumeName, + _structuredVolume, + cancellationToken).ConfigureAwait(false); + structured.Stop(); + + var hybrid = Stopwatch.StartNew(); + await CloneAsync( + BaseVolumeName, + _hybridVolume, + cancellationToken).ConfigureAwait(false); + hybrid.Stop(); + _lifecycle.CompleteClone(); + return new LongMemEvalVolumeCloneTimings( + structured.Elapsed.TotalMilliseconds, + hybrid.Elapsed.TotalMilliseconds); + } + catch + { + _lifecycle.FailClone(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + _lifecycle.Dispose(); + if (_retain) + { + // Deliberate: the volumes outlive the run so the graph can be inspected and re-attached. + // Cleanup becomes the operator's responsibility and the names are printed for that. + return; + } + + List? failures = null; + // An adopted base belongs to the caller and is never destroyed by this object. + var disposable = _adoptedBase + ? new[] { _hybridVolume, _structuredVolume } + : new[] { _hybridVolume, _structuredVolume, _baseVolume }; + foreach (var volume in disposable) + { + try + { + await volume.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + (failures ??= []).Add(exception); + } + } + + if (failures is not null) + throw new AggregateException("Failed to dispose LongMemEval volumes.", failures); + } + + /// + /// G3B.6. keeps the cold build on disk after the run so a failure can + /// be analysed against the exact graph that produced it, instead of paying for another + /// non-deterministic 121-call rebuild that would not reproduce it anyway. + /// + private static IVolume Build(string name, bool retain) => + new VolumeBuilder() + .WithName(name) + .WithCleanUp(!retain) + .Build(); + + private static async Task CloneAsync( + string sourceVolumeName, + IVolume targetVolume, + CancellationToken cancellationToken) + { + await using var helper = new ContainerBuilder(Image) + .WithEntrypoint("tail") + .WithCommand("-f", "/dev/null") + .WithVolumeMount(sourceVolumeName, "/source", AccessMode.ReadOnly) + .WithVolumeMount(targetVolume, "/target") + .Build(); + await helper.StartAsync(cancellationToken).ConfigureAwait(false); + var result = await helper.ExecAsync( + ["/bin/sh", "-c", "cp -a /source/. /target/"], + cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to clone frozen LongMemEval volume: {result.Stderr}"); + } + } +} + +internal enum LongMemEvalPreparedVolumeState +{ + Created, + BaseMounted, + Frozen, + Cloning, + Ready, + Disposed +} + +internal sealed class LongMemEvalPreparedVolumeLifecycle +{ + internal LongMemEvalPreparedVolumeState State { get; private set; } = + LongMemEvalPreparedVolumeState.Created; + + internal void BeginBasePreparation() + { + Require(LongMemEvalPreparedVolumeState.Created); + State = LongMemEvalPreparedVolumeState.BaseMounted; + } + + internal void MarkBaseContainerStopped() + { + Require(LongMemEvalPreparedVolumeState.BaseMounted); + State = LongMemEvalPreparedVolumeState.Frozen; + } + + internal void BeginClone() + { + Require(LongMemEvalPreparedVolumeState.Frozen); + State = LongMemEvalPreparedVolumeState.Cloning; + } + + internal void CompleteClone() + { + Require(LongMemEvalPreparedVolumeState.Cloning); + State = LongMemEvalPreparedVolumeState.Ready; + } + + internal void FailClone() + { + Require(LongMemEvalPreparedVolumeState.Cloning); + State = LongMemEvalPreparedVolumeState.Frozen; + } + + internal void Dispose() => State = LongMemEvalPreparedVolumeState.Disposed; + + private void Require(LongMemEvalPreparedVolumeState required) + { + if (State != required) + { + throw new InvalidOperationException( + $"LongMemEval volume lifecycle is {State}; expected {required}."); + } + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalReferenceAgent.cs b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceAgent.cs new file mode 100644 index 00000000..42bb0b63 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceAgent.cs @@ -0,0 +1,211 @@ +using System.Collections.ObjectModel; +using AgentEval.Core; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// G4-REF. Drives a LongMemEval question through the answer model with either no context at all +/// (the floor) or the entire de-contaminated conversation (the ceiling). AgentMemory is never +/// constructed, so there is no container, no embedding, no extraction, and no recall. +/// +internal sealed class LongMemEvalReferenceAgent( + IChatClient chatClient, + LongMemEvalReferenceArm arm, + string runId, + string? modelId, + ILongMemEvalReferenceOriginResolver originResolver) + : IEvaluableAgent, IHistoryInjectableAgent, ISessionResettableAgent +{ + /// + /// Returned instead of an answer when the provider rejects the prompt for exceeding its context + /// window. It is a recorded outcome, not an error: the judge still runs, and the arm's validator + /// excludes the question from fitted accuracy rather than scoring it wrong. + /// + internal const string SkippedAnswer = + "[REFERENCE-ARM-SKIPPED: the conversation history exceeds this deployment's context window]"; + + private readonly object _stateLock = new(); + private readonly List _telemetry = []; + private IReadOnlyList<(string UserMessage, string AssistantResponse)>? _pendingHistory; + private int _questionNumber; + + public string Name => $"AgentMemory.LongMemEval.Reference.{arm}"; + + public IReadOnlyList QuestionTelemetry + { + get + { + lock (_stateLock) + return new ReadOnlyCollection(_telemetry.ToArray()); + } + } + + public void InjectConversationHistory( + IEnumerable<(string UserMessage, string AssistantResponse)> conversationTurns) + { + ArgumentNullException.ThrowIfNull(conversationTurns); + var materialized = conversationTurns.ToArray(); + lock (_stateLock) + { + if (_pendingHistory is not null) + { + throw new InvalidOperationException( + "LongMemEval history was injected more than once for the same question."); + } + + _pendingHistory = materialized; + } + } + + public Task ResetSessionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_stateLock) + { + _questionNumber++; + _pendingHistory = null; + } + + return Task.CompletedTask; + } + + public async Task InvokeAsync( + string prompt, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + + IReadOnlyList<(string UserMessage, string AssistantResponse)> history; + int questionNumber; + lock (_stateLock) + { + history = _pendingHistory + ?? throw new InvalidOperationException( + "LongMemEval question cannot run before conversation history is injected."); + if (history.Count == 0) + { + throw new InvalidOperationException( + "LongMemEval question cannot run with empty conversation history."); + } + + _pendingHistory = null; + questionNumber = _questionNumber; + } + + // Resolved for both arms: the floor does not use the turns, but resolving keeps the evidence + // index consumed in lockstep with the runner and proves the same question set was sampled. + var origins = originResolver.Resolve(history, prompt); + + var messages = new List<(string Role, string Timestamp, string Content)>(); + var dropped = 0; + if (arm.UsesHistory()) + { + var ordinal = 0; + foreach (var (user, assistant) in history) + { + Add("user", user); + Add("assistant", assistant); + + void Add(string role, string content) + { + var current = ordinal++; + if (current < origins.IsSynthetic.Count && origins.IsSynthetic[current]) + { + dropped++; + return; + } + + // G3B.2: the session date travels on the message, since the boundary marker that + // used to carry it is exactly what this arm drops. + var timestamp = current < origins.SourceTimestamps.Count + ? origins.SourceTimestamps[current] + : string.Empty; + messages.Add((role, timestamp, content)); + } + } + } + + // The floor is the question and nothing else — not even "today's date". Only the history + // arms receive the time signal, because only they have history for it to date. + var answerPrompt = AgentMemoryLongMemEvalAdapter.BuildAnswerPrompt( + messages, prompt, arm.UsesHistory() ? origins.QuestionDate : null); + + ChatResponse response; + try + { + response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, arm.SystemPrompt()), + new ChatMessage(ChatRole.User, answerPrompt) + ], + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when ( + !cancellationToken.IsCancellationRequested && IsContextWindowRejection(exception)) + { + // The provider is the authority on whether the history fits. Recording its verdict is + // exactly the measurement this arm exists to take, so it is not a failure. + Record(questionNumber, origins.QuestionId, "skipped-context-window", messages.Count, dropped, answerPrompt); + return new AgentResponse { Text = SkippedAnswer, ModelId = modelId }; + } + + Record(questionNumber, origins.QuestionId, "completed", messages.Count, dropped, answerPrompt); + return new AgentResponse + { + Text = response.Text ?? string.Empty, + ModelId = modelId, + AdditionalProperties = new Dictionary + { + ["referenceArm"] = arm.Fingerprint(), + ["referenceArm.runId"] = runId, + ["referenceArm.historyMessagesProvided"] = messages.Count + } + }; + } + + /// + /// Narrow on purpose. Only a context-length verdict may become a skip; a rate limit, an outage, + /// or an auth failure must stay fatal, or the arm would quietly report real breakage as + /// "the ceiling was not measurable". + /// + internal static bool IsContextWindowRejection(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is Azure.RequestFailedException { Status: 400 } failed && + (string.Equals(failed.ErrorCode, "context_length_exceeded", StringComparison.Ordinal) || + failed.Message.Contains("maximum context length", StringComparison.OrdinalIgnoreCase) || + failed.Message.Contains("context_length_exceeded", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } + + private void Record( + int questionNumber, + string questionId, + string status, + int messagesProvided, + int dropped, + string answerPrompt) + { + lock (_stateLock) + { + _telemetry.Add(new LongMemEvalReferenceTelemetry( + questionNumber, + questionId, + status, + messagesProvided, + dropped, + answerPrompt.Length, + // Labelled an estimate and reported only. It is never used to decide whether the + // prompt fits — the provider decides that, because at 113k-128k every question in + // this dataset sits inside the estimator's own error bar. + (int)Math.Ceiling(answerPrompt.Length / 4.0))); + } + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArm.cs b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArm.cs new file mode 100644 index 00000000..b455000e --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArm.cs @@ -0,0 +1,144 @@ +namespace AgentMemory.LongMemEval; + +/// +/// G4-REF. A reference arm that deliberately uses no AgentMemory at all, so that an +/// AgentMemory score has something to be measured against. +/// +/// +/// Every LongMemEval number accepted before these arms existed compared one AgentMemory +/// configuration against another, which cannot answer the question a prospective adopter asks first: +/// does this beat simply handing the model the chat history? The floor and the ceiling bracket that +/// question. Neither arm stores, extracts, embeds, or recalls anything. +/// +public enum LongMemEvalReferenceArm +{ + /// + /// The question alone. + /// + /// + /// This is not a degenerate configuration — it is the realistic one. In LongMemEval the question + /// arrives in a fresh session, and an ordinary agent carries no chat history across + /// sessions, so "nothing" is exactly what an agent without a memory layer has. The gap between + /// this arm and AgentMemory is therefore the product's actual value, not a strawman. + /// + NoMemory, + + /// + /// Every real message in the conversation, in order, in the answer model's context. + /// + /// + /// Deliberately not called a ceiling. It is a competing strategy — "no memory + /// layer, replay the entire transcript into every prompt" — not an upper bound, and a memory + /// system that distils better context could in principle beat it. It is also only available + /// while the transcript still fits the window, which is a property of the dataset rather than of + /// the strategy. + /// + FullHistory +} + +internal static class LongMemEvalReferenceArmExtensions +{ + /// + /// Identity recorded in the report. Deliberately prefixed so it can never be mistaken for one of + /// the three fingerprints in a ledger or a comparison. + /// + public static string Fingerprint(this LongMemEvalReferenceArm arm) => arm switch + { + LongMemEvalReferenceArm.NoMemory => "reference-no-memory", + // The de-contamination is part of every history arm's definition, not an option: AgentEval's + // formatter boilerplate is an artifact of the harness, not conversation, and G3B.1 measured + // it at 80% of the recalled context. A contaminated baseline would understate itself. + LongMemEvalReferenceArm.FullHistory => "reference-full-history-decontaminated", + _ => throw new ArgumentOutOfRangeException(nameof(arm), arm, null) + }; + + /// + /// The system prompt for the arm, recorded verbatim in the report. + /// + /// + /// These necessarily differ from the shipped memory prompt, which instructs the model to answer + /// "using only the retrieved memory below". With no memory block that instruction manufactures + /// abstentions and would understate the floor, so a neutral variant is used instead. The + /// anti-hallucination clause is preserved in both, and the difference is a stated limitation of + /// the comparison rather than a hidden one. + /// + public static string SystemPrompt(this LongMemEvalReferenceArm arm) => arm switch + { + LongMemEvalReferenceArm.NoMemory => + "Answer the question. Be concise and do not claim information you do not have.", + LongMemEvalReferenceArm.FullHistory => + "Answer the question using only the conversation history below. " + + "Be concise and do not claim information that is absent from the history.", + _ => throw new ArgumentOutOfRangeException(nameof(arm), arm, null) + }; + + public static bool UsesHistory(this LongMemEvalReferenceArm arm) => + arm is LongMemEvalReferenceArm.FullHistory; +} + +/// Per-question accounting for a reference arm. Content-free by construction. +/// +/// Counts are messages, not conversation turns — two messages per turn — because AgentMemory's +/// own MaxRelevantMessages budget is denominated in messages, and the equal-budget comparison +/// is only exact if both sides count the same unit. +/// +public sealed record LongMemEvalReferenceTelemetry( + int QuestionNumber, + string? QuestionId, + string Status, + int HistoryMessagesProvided, + int SyntheticMessagesDropped, + int PromptCharacters, + int EstimatedPromptTokens); + +/// +/// Which of a question's injected messages are AgentEval formatter artifacts rather than real +/// conversation. Abstracted so the arm can be tested without loading the 264 MB dataset. +/// +internal interface ILongMemEvalReferenceOriginResolver +{ + LongMemEvalReferenceOrigins Resolve( + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string prompt); +} + +/// +/// and are parallel to the +/// flattened injected message list: two entries per history turn, user first. +/// +/// +/// G3B.2 carries the timestamps because the session dates otherwise exist only in the boundary +/// markers this arm drops. The baseline is fixed in the same change as the memory arm, so the +/// comparison measures the memory system rather than which side received the fix. +/// +internal sealed record LongMemEvalReferenceOrigins( + string QuestionId, + IReadOnlyList IsSynthetic, + IReadOnlyList SourceTimestamps, + string? QuestionDate); + +/// Resolves origins through the real evaluator-side evidence index. +internal sealed class LongMemEvalEvidenceOriginResolver(LongMemEvalEvidenceIndex index) + : ILongMemEvalReferenceOriginResolver +{ + public LongMemEvalReferenceOrigins Resolve( + IReadOnlyList<(string UserMessage, string AssistantResponse)> history, + string prompt) + { + var question = index.Resolve(history, prompt); + var expected = history.Count * 2; + if (question.Messages.Count != expected) + { + throw new InvalidOperationException( + $"LongMemEval evidence contained {question.Messages.Count} origins for {expected} injected messages."); + } + + return new LongMemEvalReferenceOrigins( + question.QuestionId, + question.Messages + .Select(origin => origin.IsSyntheticBoundary || origin.IsSyntheticFormatterPadding) + .ToArray(), + question.Messages.Select(origin => origin.SourceTimestamp).ToArray(), + question.QuestionDate); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmProgram.cs b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmProgram.cs new file mode 100644 index 00000000..28702e4c --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmProgram.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentEval.Memory.Models; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// G4-REF. Runs a no-memory floor or a full-history ceiling on the identical sample, seed, answer +/// deployment and judge as the AgentMemory arms, so the three numbers bracket each other. Dispatched +/// separately from so the accepted AgentMemory path is untouched. +/// +internal static class LongMemEvalReferenceArmProgram +{ + public static async Task RunAsync(string[] args) + { + try + { + var options = Parse(args); + + var endpoint = RequiredEnvironment("AZURE_OPENAI_ENDPOINT"); + var apiKey = RequiredEnvironment("AZURE_OPENAI_API_KEY"); + var deployment = RequiredEnvironment("AZURE_OPENAI_DEPLOYMENT"); + var azureClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey)); + using var answerChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var judgeChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var diagnosticChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + + var benchmarkOptions = LongMemEvalBenchmarkProtocol.CreateOptions( + options.DatasetPath, + options.Questions, + options.Seed, + options.JudgeRetryAttempts, + LongMemEvalEvidenceDetail.Identifiers, + options.MaxRelevantMessages); + var evidenceIndex = LongMemEvalEvidenceIndex.Load(options.DatasetPath, benchmarkOptions); + + var runId = $"longmemeval-reference-{options.Arm.ToString().ToLowerInvariant()}-" + + $"{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}"; + var agent = new LongMemEvalReferenceAgent( + answerChatClient, + options.Arm, + runId, + deployment, + new LongMemEvalEvidenceOriginResolver(evidenceIndex)); + + Console.WriteLine( + $"longmemeval: reference arm {options.Arm.Fingerprint()}, {options.Questions} stratified questions, seed {options.Seed}. " + + "No Neo4j container, no embeddings, no extraction, no recall."); + + var runner = LongMemEvalBenchmarkRunner.Create(judgeChatClient, options.DatasetPath); + var result = await runner.RunAsync( + agent, + new AgentBenchmarkConfig + { + AgentName = agent.Name, + ModelId = deployment, + ReducerStrategy = options.Arm.Fingerprint(), + MemoryProvider = "none (reference arm)" + }, + benchmarkOptions).ConfigureAwait(false); + + var judgeRetries = await LongMemEvalPostRunDiagnostics.RetryInvalidJudgeVerdictsAsync( + diagnosticChatClient, + evidenceIndex, + result.QuestionResults, + options.JudgeRetryAttempts).ConfigureAwait(false); + var diagnosticJudgeCalls = judgeRetries.Sum(retry => retry.LlmCalls); + + var answerCalls = answerChatClient.Snapshot(); + var judgeCalls = judgeChatClient.Snapshot(); + var telemetry = agent.QuestionTelemetry; + var validation = LongMemEvalReferenceArmValidator.Validate( + options.Questions, + result.TotalLlmCalls, + telemetry, + result.QuestionResults, + answerCalls, + judgeCalls, + judgeRetries.Count, + options.JudgeRetryAttempts); + + var destination = Path.GetFullPath(options.OutputPath ?? + Path.Combine("artifacts", "evaluation", runId, "report.json")); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var report = new + { + schemaVersion = 2, + runId, + generatedAtUtc = DateTimeOffset.UtcNow, + accepted = validation.Accepted, + validationIssues = validation.Issues, + fingerprint = new + { + dataset = Path.GetFileName(options.DatasetPath), + datasetSha256 = Convert.ToHexStringLower( + System.Security.Cryptography.SHA256.HashData( + await File.ReadAllBytesAsync(options.DatasetPath).ConfigureAwait(false))), + questions = options.Questions, + seed = options.Seed, + stratified = true, + answerModel = deployment, + judgeModel = deployment, + // Deliberately prefixed: a reference arm must never be comparable to an + // AgentMemory arm by accident in a ledger. + operatingMode = options.Arm.Fingerprint(), + memoryProvider = "none", + // Recorded verbatim because it necessarily differs from the shipped memory + // prompt, and that difference is a limitation of the comparison. + systemPrompt = options.Arm.SystemPrompt(), + contextFitDecidedBy = "provider-context-window-rejection-not-estimated", + judgeRequest = "AgentEval-source-native-null-temperature-256-tokens", + judgeRetryAttempts = options.JudgeRetryAttempts, + agentEval = typeof(ExternalBenchmarkOptions).Assembly.GetName().Version?.ToString(), + agentEvalDependency = "source-project:AgentEval.Memory" + }, + referenceArm = new + { + arm = options.Arm.ToString(), + questions = telemetry, + skippedQuestions = validation.SkippedQuestions, + answeredQuestions = validation.AnsweredQuestions, + correctQuestions = validation.CorrectQuestions, + // The headline. Overall accuracy counts a skip as wrong; fitted accuracy + // excludes it, because "did not fit" is not "answered incorrectly". + fittedAccuracyPercent = validation.FittedAccuracyPercent, + totalHistoryMessagesProvided = telemetry.Sum(item => item.HistoryMessagesProvided), + totalSyntheticMessagesDropped = telemetry.Sum(item => item.SyntheticMessagesDropped) + }, + callAccounting = new + { + benchmarkLlmCalls = result.TotalLlmCalls, + diagnosticLlmCalls = diagnosticJudgeCalls, + totalLlmCalls = result.TotalLlmCalls + diagnosticJudgeCalls, + diagnosticCallsAffectScore = false, + observed = new + { + answer = Project(answerCalls), + judge = Project(judgeCalls), + diagnostics = Project(diagnosticChatClient.Snapshot()) + } + }, + judgeRetries, + result = validation.Accepted + ? LongMemEvalReportProjection.CreateAcceptedResult( + result, LongMemEvalEvidenceDetail.Identifiers) + : null + }; + await File.WriteAllTextAsync( + destination, + JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }) + + Environment.NewLine).ConfigureAwait(false); + + if (!validation.Accepted) + { + foreach (var issue in validation.Issues) + Console.Error.WriteLine($"longmemeval: validation: {issue}"); + Console.Error.WriteLine($"longmemeval: rejected diagnostic report {destination}"); + return 1; + } + + // A null fitted accuracy is a real outcome, not a zero: it means the history did not fit + // this deployment for any question, so the ceiling is simply not measurable here. + Console.WriteLine(validation.FittedAccuracyPercent is { } fitted + ? $"longmemeval: arm={options.Arm.Fingerprint()} fitted_accuracy={fitted:F1}% " + + $"answered={validation.AnsweredQuestions}/{options.Questions} " + + $"skipped_context_window={validation.SkippedQuestions} " + + $"overall_including_skips={result.OverallAccuracy:F1}% llm_calls={result.TotalLlmCalls}" + : $"longmemeval: arm={options.Arm.Fingerprint()} NOT MEASURABLE on this deployment — " + + $"all {validation.SkippedQuestions}/{options.Questions} questions exceeded the context window."); + Console.WriteLine($"longmemeval: report {destination}"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine($"longmemeval: {exception.Message}"); + return 1; + } + } + + private static ReferenceOptions Parse(string[] args) + { + string? Value(string name) + { + var index = Array.IndexOf(args, name); + if (index < 0) return null; + if (index + 1 >= args.Length) + throw new ArgumentException($"{name} requires a value."); + return args[index + 1]; + } + + // Fail closed on a flag that has no defined meaning for an arm with no memory, rather than + // accepting it and silently doing something else. + foreach (var incompatible in new[] { "--prepared-pair", "--memory-mode", "--exclude-synthetic-messages" }) + { + if (Array.IndexOf(args, incompatible) >= 0) + { + throw new ArgumentException( + $"{incompatible} cannot be combined with --reference-arm: a reference arm uses no AgentMemory."); + } + } + + if (Value("--oracle") is { } oracle && + !string.Equals(oracle, "none", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "--oracle is a memory-arm diagnostic and cannot be combined with --reference-arm."); + } + + var arm = Value("--reference-arm")?.ToLowerInvariant() switch + { + "no-memory" => LongMemEvalReferenceArm.NoMemory, + "full-history" => LongMemEvalReferenceArm.FullHistory, + _ => throw new ArgumentException("--reference-arm must be one of: no-memory, full-history.") + }; + + var datasetPath = Value("--dataset") ?? string.Empty; + if (string.IsNullOrWhiteSpace(datasetPath)) + throw new ArgumentException("--dataset is required."); + if (!File.Exists(datasetPath)) + throw new FileNotFoundException("LongMemEval dataset not found.", datasetPath); + + return new ReferenceOptions( + arm, + datasetPath, + ParsePositive(Value("--questions"), 10, "--questions"), + ParsePositive(Value("--seed"), 42, "--seed"), + ParsePositive(Value("--max-relevant"), 30, "--max-relevant"), + ParseNonNegative(Value("--judge-retries"), 2, "--judge-retries"), + Value("--output")); + } + + private static object Project(LongMemEvalChatCallSnapshot snapshot) => new + { + snapshot.Calls, + snapshot.Failures, + durationMs = snapshot.Duration.TotalMilliseconds + }; + + private static int ParsePositive(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed <= 0) + throw new ArgumentException($"{option} must be a positive integer."); + return parsed; + } + + private static int ParseNonNegative(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed < 0) + throw new ArgumentException($"{option} must be a non-negative integer."); + return parsed; + } + + private static string RequiredEnvironment(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"{name} is required; refusing to create a synthetic LongMemEval score."); + + private sealed record ReferenceOptions( + LongMemEvalReferenceArm Arm, + string DatasetPath, + int Questions, + int Seed, + int MaxRelevantMessages, + int JudgeRetryAttempts, + string? OutputPath); +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmValidator.cs b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmValidator.cs new file mode 100644 index 00000000..22bc1ceb --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalReferenceArmValidator.cs @@ -0,0 +1,169 @@ +using AgentEval.Memory.External.Models; + +namespace AgentMemory.LongMemEval; + +internal sealed record LongMemEvalReferenceArmValidation( + bool Accepted, + IReadOnlyList Issues, + int SkippedQuestions, + int AnsweredQuestions, + int CorrectQuestions, + double? FittedAccuracyPercent); + +/// +/// G4-REF acceptance. A reference arm has zero storage and zero recall by design, so it gets +/// its own exact contract rather than an exemption carved into +/// — which stays untouched and un-relaxed. +/// +internal static class LongMemEvalReferenceArmValidator +{ + internal static LongMemEvalReferenceArmValidation Validate( + int questionCount, + int llmCalls, + IReadOnlyList telemetry, + IReadOnlyList questionResults, + LongMemEvalChatCallSnapshot? answerCalls = null, + LongMemEvalChatCallSnapshot? judgeCalls = null, + int diagnosticJudgeCalls = 0, + int agentEvalJudgeRetryAllowance = 0) + { + ArgumentNullException.ThrowIfNull(telemetry); + ArgumentNullException.ThrowIfNull(questionResults); + var issues = new List(); + + if (questionCount == 0) + issues.Add("AgentEval returned no LongMemEval questions."); + + if (questionResults.Count != questionCount) + { + issues.Add( + $"AgentEval returned {questionResults.Count} question results for {questionCount} questions."); + } + + if (telemetry.Count != questionCount) + { + issues.Add( + $"The reference arm recorded {telemetry.Count} question telemetry entries for {questionCount} AgentEval results."); + } + + // Same contract as the memory arms — and it must track them. AgentEval retries an + // unparseable judge verdict internally under JudgeFailurePolicy.RetryThenInconclusive without + // reporting how many times, so an exact count is unachievable from outside the library. This + // validator kept the exact form after the memory-arm validator moved to a range, and a real + // no-memory floor run was discarded for it (12 judge calls over 10 questions). Correctness + // stays exact below: one answer call per question, one valid verdict per question. + var minimumCalls = questionCount * 2; + var maximumCalls = questionCount * (2 + agentEvalJudgeRetryAllowance); + var baseLlmCalls = llmCalls - diagnosticJudgeCalls; + if (baseLlmCalls < minimumCalls || baseLlmCalls > maximumCalls) + { + issues.Add( + $"AgentEval reported {llmCalls} LLM calls ({baseLlmCalls} base after excluding " + + $"{diagnosticJudgeCalls} diagnostic judge retries) for {questionCount} questions; " + + $"expected between {minimumCalls} and {maximumCalls} base calls " + + $"({agentEvalJudgeRetryAllowance} internal judge retries permitted per question)."); + } + + if (answerCalls is not null && answerCalls.Calls != questionCount) + { + issues.Add( + $"Observed {answerCalls.Calls} answer calls for {questionCount} questions; expected exactly {questionCount}."); + } + + var baseJudgeCalls = (judgeCalls?.Calls ?? 0) - diagnosticJudgeCalls; + if (judgeCalls is not null && + (baseJudgeCalls < questionCount || + baseJudgeCalls > questionCount * (1 + agentEvalJudgeRetryAllowance))) + { + issues.Add( + $"Observed {judgeCalls.Calls} judge calls ({baseJudgeCalls} base " + + $"after excluding {diagnosticJudgeCalls} diagnostic retries) for {questionCount} questions; " + + $"expected between {questionCount} and {questionCount * (1 + agentEvalJudgeRetryAllowance)} " + + "base judge calls."); + } + + var skipped = telemetry.Count(item => + string.Equals(item.Status, "skipped-context-window", StringComparison.Ordinal)); + + foreach (var unexpected in telemetry.Where(item => + !string.Equals(item.Status, "completed", StringComparison.Ordinal) && + !string.Equals(item.Status, "skipped-context-window", StringComparison.Ordinal))) + { + issues.Add( + $"The reference arm recorded {unexpected.Status} at question position {unexpected.QuestionNumber}."); + } + + // The only provider failure a reference arm may carry is a context-window rejection, and + // exactly as many as it recorded as skips. One extra means something else broke. + if (answerCalls is not null && answerCalls.Failures != skipped) + { + issues.Add( + $"Observed {answerCalls.Failures} failed answer calls against {skipped} recorded " + + "context-window skips; a reference arm may only fail by exceeding the context window."); + } + + if (judgeCalls is not null && judgeCalls.Failures != 0) + issues.Add($"Observed {judgeCalls.Failures} failed judge provider calls; expected zero."); + + var correct = 0; + var answered = 0; + var skippedIds = telemetry + .Where(item => string.Equals(item.Status, "skipped-context-window", StringComparison.Ordinal)) + .Select(item => item.QuestionId) + .Where(id => id is not null) + .ToHashSet(StringComparer.Ordinal); + + foreach (var question in questionResults) + { + var response = question.AgentResponse ?? string.Empty; + var explanation = question.JudgeExplanation ?? string.Empty; + var isSkipped = + response.StartsWith(LongMemEvalReferenceAgent.SkippedAnswer, StringComparison.Ordinal) || + skippedIds.Contains(question.QuestionId); + + if (response.StartsWith("[ERROR:", StringComparison.OrdinalIgnoreCase) || + response.StartsWith("[CONTENT_FILTER]", StringComparison.OrdinalIgnoreCase) || + explanation.StartsWith("Skipped due to error:", StringComparison.OrdinalIgnoreCase)) + { + issues.Add($"Agent invocation failed before judging question {question.QuestionId}."); + continue; + } + + if (explanation.StartsWith("Judge error:", StringComparison.OrdinalIgnoreCase)) + { + issues.Add($"AgentEval judge failed for question {question.QuestionId}."); + continue; + } + + if (!LongMemEvalRunValidator.TryParseJudgeVerdict(explanation, out var judgedCorrect)) + { + issues.Add( + $"AgentEval judge returned no valid yes/no verdict for question {question.QuestionId}."); + continue; + } + + if (question.Correct != judgedCorrect) + { + issues.Add( + $"AgentEval judge verdict and recorded correctness disagree for question {question.QuestionId}."); + } + + // A skipped question is excluded from the score rather than counted wrong. Scoring it + // zero would report "the ceiling is low" when the truth is "the ceiling was not + // measurable on this deployment". + if (isSkipped) + continue; + answered++; + if (judgedCorrect) + correct++; + } + + return new LongMemEvalReferenceArmValidation( + Accepted: issues.Count == 0, + Issues: issues.AsReadOnly(), + SkippedQuestions: skipped, + AnsweredQuestions: answered, + CorrectQuestions: correct, + FittedAccuracyPercent: answered == 0 ? null : 100d * correct / answered); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalReportProjection.cs b/tools/AgentMemory.LongMemEval/LongMemEvalReportProjection.cs new file mode 100644 index 00000000..25a99a94 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalReportProjection.cs @@ -0,0 +1,154 @@ +using AgentEval.Memory.External.Models; + +namespace AgentMemory.LongMemEval; + +/// +/// Wall-clock stages of one prepared-pair run. is nullable +/// because a reused run seals nothing, and reporting an unperformed stage as 0 ms would read as a +/// measurement of instant work rather than of work that never happened. +/// +internal sealed record LongMemEvalPreparationTimings( + double ProfileStartupMs, + double? ManifestSealAndReadBackMs, + double BaseVolumeStopMs, + double StructuredCloneMs, + double HybridCloneMs); + +internal static class LongMemEvalReportProjection +{ + public static object CreateAcceptedResult( + ExternalBenchmarkResult result, + LongMemEvalEvidenceDetail evidenceDetail) + { + ArgumentNullException.ThrowIfNull(result); + + if (evidenceDetail == LongMemEvalEvidenceDetail.Content) + return result; + + return new + { + result.BenchmarkId, + result.BenchmarkName, + result.OverallAccuracy, + result.TaskAveragedAccuracy, + result.PerTypeResults, + QuestionResults = result.QuestionResults.Select(question => new + { + question.QuestionId, + question.QuestionType, + question.Correct, + question.RawScore, + question.Duration, + Evidence = evidenceDetail == LongMemEvalEvidenceDetail.Identifiers && + question.Evidence is not null + ? ProjectEvidence(question.Evidence) : null, + EvidenceDiagnostics = evidenceDetail == LongMemEvalEvidenceDetail.Identifiers && + question.EvidenceDiagnostics is not null + ? ProjectDiagnostics(question.EvidenceDiagnostics) : null + }), + result.Duration, + result.TotalLlmCalls, + result.EstimatedCostUsd + }; + } + + private static object ProjectEvidence(QuestionEvidenceEnvelope evidence) => new + { + evidence.SchemaVersion, + Retrieved = evidence.Retrieved.Select(ProjectReference), + AnswerContext = evidence.AnswerContext.Select(ProjectReference) + }; + + private static object ProjectReference(EvidenceReference reference) => new + { + reference.Id, + reference.Rank, + reference.SimilarityScore, + reference.SourceSessionId, + reference.SourceTurnIndex, + reference.SourceTimestamp, + reference.AnswerContextOrder + }; + + /// + /// The prepared-pair report's preparation section. + /// + /// + /// Extracted from the inline report so the reuse path can be covered by a test. A run started + /// with --reuse-prepared-volumes performs no preparation at all, so + /// is null for it. + /// + internal static object CreatePreparationSection( + LongMemEvalPreparationManifest manifest, + LongMemEvalPreparedBatchExecution? batchExecution, + IReadOnlyList preparationTelemetry, + object extractionObserved, + long extractionCalls, + LongMemEvalPreparationTimings timings, + string? reusedPreparedVolume) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(preparationTelemetry); + ArgumentNullException.ThrowIfNull(timings); + + return new + { + count = 1, + manifest.SchemaVersion, + manifest.PreparationId, + manifest.Fingerprint, + manifest.DatasetSha256, + manifest.AgentEvalRevision, + manifest.MessagesPrepared, + manifest.ExtractionUnitsPrepared, + manifest.InitialExtractionCalls, + manifest.UseUnifiedExtraction, + manifest.UseMultiSessionBatchExtraction, + manifest.PreparationWorkers, + manifest.MaxSessionsPerBatch, + manifest.MaxInputTokens, + manifest.MaxConcurrentBatchesPerExtraction, + manifest.MaxConcurrentExtractionBatches, + performedByThisRun = batchExecution is not null, + reusedPreparedVolume, + plannedEstimatedInputTokens = + batchExecution?.EstimatedInputTokens, + maximumObservedConcurrency = + batchExecution?.MaximumConcurrency, + questions = manifest.Questions, + extractionObserved, + extractionRetryCalls = + Math.Max(0, extractionCalls - manifest.InitialExtractionCalls), + timings = new + { + profileStartupMs = timings.ProfileStartupMs, + storageAndEmbeddingMs = preparationTelemetry.Sum(item => + item.StageTimings?.StorageMs ?? 0), + extractionAndPersistenceMs = preparationTelemetry.Sum(item => + item.StageTimings?.ExtractionPersistenceMs ?? 0), + graphReadBackMs = preparationTelemetry.Sum(item => + item.StageTimings?.GraphReadBackMs ?? 0), + manifestSealAndReadBackMs = timings.ManifestSealAndReadBackMs, + baseVolumeStopMs = timings.BaseVolumeStopMs, + structuredCloneMs = timings.StructuredCloneMs, + hybridCloneMs = timings.HybridCloneMs + } + }; + } + + private static object ProjectDiagnostics( + QuestionEvidenceDiagnostics diagnostics) => new + { + diagnostics.Status, + diagnostics.SafeFailureCode, + diagnostics.RetrievedReferenceCount, + diagnostics.AnswerContextReferenceCount, + diagnostics.GoldSessionPresent, + diagnostics.HasAnswerTurnPresent, + diagnostics.FirstGoldRank, + diagnostics.DistinctSourceSessionCount, + diagnostics.SourceSessionDiversityRatio, + diagnostics.AnswerContextOrders, + diagnostics.AnswerContextTimestampCount + }; +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalRunValidator.cs b/tools/AgentMemory.LongMemEval/LongMemEvalRunValidator.cs new file mode 100644 index 00000000..e3427294 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalRunValidator.cs @@ -0,0 +1,264 @@ +using AgentEval.Memory.External.Models; + +namespace AgentMemory.LongMemEval; + +internal sealed record LongMemEvalRunValidation( + bool Accepted, + IReadOnlyList Issues); + +internal static class LongMemEvalRunValidator +{ + internal static LongMemEvalRunValidation Validate( + int questionCount, + int llmCalls, + IReadOnlyList telemetry, + IReadOnlyList questionResults, + LongMemEvalChatCallSnapshot? answerCalls = null, + LongMemEvalChatCallSnapshot? judgeCalls = null, + LongMemEvalChatCallSnapshot? extractionCalls = null, + long expectedInitialExtractionCalls = 0, + int diagnosticJudgeCalls = 0, + int agentEvalJudgeRetryAllowance = 0) + { + ArgumentNullException.ThrowIfNull(telemetry); + ArgumentNullException.ThrowIfNull(questionResults); + var issues = new List(); + + if (questionCount == 0) + issues.Add("AgentEval returned no LongMemEval questions."); + + if (questionResults.Count != questionCount) + { + issues.Add( + $"AgentEval returned {questionResults.Count} question results for {questionCount} questions."); + } + + // Diagnostic judge retries are deliberately additional calls that never rewrite a base + // verdict (the report records diagnosticCallsAffectScore = false), so they are excluded from + // the exact 2N base-call contract rather than being allowed to reject an otherwise valid run. + // The guard itself is unchanged: base calls must still be exactly 2N. + // AgentEval retries an unparseable judge verdict *internally* under + // JudgeFailurePolicy.RetryThenInconclusive and does not report how many times, so an exact + // call count is not achievable from outside the library. The correctness property is kept + // exact instead — one answer call per question, and one valid verdict per question, both + // asserted below — while the call count becomes a bounded cost signal. A run that exceeds + // the configured retry allowance still rejects, so runaway judging cannot pass. + var minimumCalls = questionCount * 2; + var maximumCalls = questionCount * (2 + agentEvalJudgeRetryAllowance); + var baseLlmCalls = llmCalls - diagnosticJudgeCalls; + if (baseLlmCalls < minimumCalls || baseLlmCalls > maximumCalls) + { + issues.Add( + $"AgentEval reported {llmCalls} LLM calls ({baseLlmCalls} base after excluding " + + $"{diagnosticJudgeCalls} diagnostic judge retries) for {questionCount} questions; " + + $"expected between {minimumCalls} and {maximumCalls} base calls " + + $"({agentEvalJudgeRetryAllowance} internal judge retries permitted per question)."); + } + + if (telemetry.Count != questionCount) + { + issues.Add( + $"AgentMemory recorded {telemetry.Count} question telemetry entries for {questionCount} AgentEval results."); + } + + if (answerCalls is not null && answerCalls.Calls != questionCount) + { + issues.Add( + $"Observed {answerCalls.Calls} answer calls for {questionCount} questions; expected exactly {questionCount}."); + } + + var baseJudgeCalls = (judgeCalls?.Calls ?? 0) - diagnosticJudgeCalls; + if (judgeCalls is not null && + (baseJudgeCalls < questionCount || + baseJudgeCalls > questionCount * (1 + agentEvalJudgeRetryAllowance))) + { + issues.Add( + $"Observed {judgeCalls.Calls} judge calls ({baseJudgeCalls} base " + + $"after excluding {diagnosticJudgeCalls} diagnostic retries) for {questionCount} questions; " + + $"expected between {questionCount} and {questionCount * (1 + agentEvalJudgeRetryAllowance)} " + + "base judge calls."); + } + + if (answerCalls is not null && judgeCalls is not null && + answerCalls.Calls + judgeCalls.Calls != llmCalls + diagnosticJudgeCalls) + { + issues.Add( + $"Observed answer and judge calls total {answerCalls.Calls + judgeCalls.Calls}, but AgentEval reported {llmCalls}."); + } + + if (extractionCalls is not null && extractionCalls.Calls < expectedInitialExtractionCalls) + { + issues.Add( + $"Observed {extractionCalls.Calls} extraction calls; expected at least {expectedInitialExtractionCalls} initial calls."); + } + + var providerFailures = + (answerCalls?.Failures ?? 0) + + (judgeCalls?.Failures ?? 0) + + (extractionCalls?.Failures ?? 0); + if (providerFailures != 0) + { + issues.Add( + $"Observed {providerFailures} failed answer, judge, or extraction provider calls."); + } + + + var preparedQuestions = telemetry.Count(item => item.PreparedMemory); + if (preparedQuestions != 0 && preparedQuestions != telemetry.Count) + { + issues.Add( + "Prepared and independently ingested LongMemEval questions cannot be mixed in one arm."); + } + + if (preparedQuestions == telemetry.Count && telemetry.Count != 0) + { + if (telemetry.Any(item => + item.MessagesStored != 0 || + item.MessagesPrepared <= 0 || + item.ExtractionUnits != 0 || + item.ExtractionUnitsPrepared <= 0 || + item.ItemsRetrieved == 0)) + { + issues.Add( + "At least one prepared LongMemEval question wrote during evaluation, lacks sealed preparation work, or retrieved no items."); + } + + if (extractionCalls is not null && extractionCalls.Calls != 0) + { + issues.Add( + $"Observed {extractionCalls.Calls} extraction calls during prepared evaluation; expected zero."); + } + } + else if (telemetry.Any(item => + item.MessagesStored == 0 || item.ItemsRetrieved == 0)) + { + issues.Add( + "At least one LongMemEval question bypassed AgentMemory storage or retrieved no items."); + } + + foreach (var failedStage in telemetry.Where(item => + !string.Equals(item.Status, "completed", StringComparison.Ordinal))) + { + issues.Add( + $"AgentMemory recorded {failedStage.Status} at question position {failedStage.QuestionNumber}."); + } + + foreach (var question in questionResults) + { + var response = question.AgentResponse ?? string.Empty; + var explanation = question.JudgeExplanation ?? string.Empty; + if (response.StartsWith("[ERROR:", StringComparison.OrdinalIgnoreCase) || + response.StartsWith("[CONTENT_FILTER]", StringComparison.OrdinalIgnoreCase) || + explanation.StartsWith("Skipped due to error:", StringComparison.OrdinalIgnoreCase)) + { + issues.Add( + $"Agent invocation failed before judging question {question.QuestionId}."); + continue; + } + + if (explanation.StartsWith("Judge error:", StringComparison.OrdinalIgnoreCase)) + { + issues.Add( + $"AgentEval judge failed for question {question.QuestionId}."); + continue; + } + + if (!TryParseJudgeVerdict(explanation, out var judgedCorrect)) + { + issues.Add( + $"AgentEval judge returned no valid yes/no verdict for question {question.QuestionId}."); + continue; + } + + if (question.Correct != judgedCorrect) + { + issues.Add( + $"AgentEval judge verdict and recorded correctness disagree for question {question.QuestionId}."); + } + } + + return new LongMemEvalRunValidation( + Accepted: issues.Count == 0, + Issues: issues.AsReadOnly()); + } + + internal static string Classify( + QuestionResult question, + LongMemEvalQuestionTelemetry? telemetry = null) + { + ArgumentNullException.ThrowIfNull(question); + if (telemetry is not null && + !string.Equals(telemetry.Status, "completed", StringComparison.Ordinal)) + return telemetry.Status; + + var response = question.AgentResponse ?? string.Empty; + var explanation = question.JudgeExplanation ?? string.Empty; + + foreach (var stage in new[] { "storage", "retrieval", "answer" }) + { + if (response.Contains($"LongMemEval {stage} stage failed.", StringComparison.OrdinalIgnoreCase)) + return $"{stage}-error"; + } + + if (response.StartsWith("[ERROR:", StringComparison.OrdinalIgnoreCase) || + response.StartsWith("[CONTENT_FILTER]", StringComparison.OrdinalIgnoreCase) || + explanation.StartsWith("Skipped due to error:", StringComparison.OrdinalIgnoreCase)) + return "agent-error"; + if (explanation.StartsWith("Judge error:", StringComparison.OrdinalIgnoreCase)) + return "judge-error"; + if (!TryParseJudgeVerdict(explanation, out _)) + return "judge-invalid"; + return "completed"; + } + + internal static bool TryParseJudgeVerdict(string? explanation, out bool correct) + { + correct = false; + if (string.IsNullOrWhiteSpace(explanation)) + return false; + + var value = explanation.Trim(); + if (TryReadLeadingVerdict(value, out correct)) + return true; + + // The judge does not always phrase the verdict the same way. Two prefixes were hardcoded - + // "Judge said:" and "Judge outcome:" - and a third shape beginning "Judge" cost two of five + // identical n=50 repeats, each rejecting a whole arm over one question. The diagnostic caught + // it as FailureKind=unparseable, RejectedToken="Judge", and on one of those runs the retry + // recovered the same question with a valid verdict: the judgement was fine, the parsing was + // not. + // + // So: if the text opens with a short label ending in a colon, try again after it. The + // tolerance is deliberately one-way - the prefix is only accepted when what follows is + // ACTUALLY a yes or no, so this can never manufacture a verdict from a hedge like + // "Judge verdict: partially correct". Bounded length, and only the first colon, so a + // sentence that merely contains a colon cannot be mined for a verdict. The label itself must + // begin "judg" (Judge / Judgement / Judgment / "Judge verdict"), which is what keeps + // "maybe: yes" invalid - a guard test caught exactly that over-reach in the first attempt. + var colon = value.IndexOf(':', StringComparison.Ordinal); + if (colon > 0 && colon <= 32 && + value.AsSpan(0, colon).TrimStart().StartsWith("judg", StringComparison.OrdinalIgnoreCase)) + { + return TryReadLeadingVerdict(value[(colon + 1)..].Trim(), out correct); + } + + return false; + } + + /// Reads a verdict from the leading letter-token, or fails. + private static bool TryReadLeadingVerdict(string value, out bool correct) + { + correct = false; + var tokenLength = value.TakeWhile(char.IsLetter).Count(); + if (tokenLength == 0) + return false; + var token = value[..tokenLength]; + if (string.Equals(token, "yes", StringComparison.OrdinalIgnoreCase)) + { + correct = true; + return true; + } + + return string.Equals(token, "no", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalRuntime.cs b/tools/AgentMemory.LongMemEval/LongMemEvalRuntime.cs new file mode 100644 index 00000000..81b0b65f --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalRuntime.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +internal static class LongMemEvalRuntime +{ + internal const string DimensionProbe = + "AgentMemory LongMemEval embedding dimension probe"; + + internal static IChatClient CreateCompatibleChatClient(IChatClient inner) + { + ArgumentNullException.ThrowIfNull(inner); + return new DefaultTemperatureChatClient(inner); + } + + internal static async Task ProbeEmbeddingDimensionsAsync( + IEmbeddingGenerator> generator, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(generator); + var generated = await generator + .GenerateAsync([DimensionProbe], cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (generated.Count != 1) + { + throw new InvalidOperationException( + $"The real embedding provider returned {generated.Count} vectors for the dimension probe; expected exactly one embedding."); + } + + var dimensions = generated[0].Vector.Length; + if (dimensions <= 0) + { + throw new InvalidOperationException( + "The real embedding provider returned an empty embedding for the dimension probe."); + } + + return dimensions; + } + + internal static async Task ExecuteStageAsync( + string stage, + Func> operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stage); + ArgumentNullException.ThrowIfNull(operation); + try + { + return await operation().ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new InvalidOperationException( + $"LongMemEval {stage} stage failed.", + exception); + } + } +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalStageTiming.cs b/tools/AgentMemory.LongMemEval/LongMemEvalStageTiming.cs new file mode 100644 index 00000000..37f2ace7 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalStageTiming.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; + +namespace AgentMemory.LongMemEval; + +public sealed record LongMemEvalStageTimings( + double StorageMs, + double ExtractionPersistenceMs, + double GraphReadBackMs, + double RetrievalMs, + double AnswerMs) +{ + public static LongMemEvalStageTimings Zero { get; } = new(0, 0, 0, 0, 0); +} + +internal sealed class LongMemEvalStageTimingCollector +{ + private readonly object _lock = new(); + private TimeSpan _storage; + private TimeSpan _extractionPersistence; + private TimeSpan _graphReadBack; + private TimeSpan _retrieval; + private TimeSpan _answer; + + public async Task MeasureAsync( + LongMemEvalStage stage, + Func> operation) + { + ArgumentNullException.ThrowIfNull(operation); + var stopwatch = Stopwatch.StartNew(); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + stopwatch.Stop(); + Add(stage, stopwatch.Elapsed); + } + } + + public LongMemEvalStageTimings Snapshot() + { + lock (_lock) + { + return new LongMemEvalStageTimings( + _storage.TotalMilliseconds, + _extractionPersistence.TotalMilliseconds, + _graphReadBack.TotalMilliseconds, + _retrieval.TotalMilliseconds, + _answer.TotalMilliseconds); + } + } + + private void Add(LongMemEvalStage stage, TimeSpan elapsed) + { + lock (_lock) + { + switch (stage) + { + case LongMemEvalStage.Storage: + _storage += elapsed; + break; + case LongMemEvalStage.ExtractionPersistence: + _extractionPersistence += elapsed; + break; + case LongMemEvalStage.GraphReadBack: + _graphReadBack += elapsed; + break; + case LongMemEvalStage.Retrieval: + _retrieval += elapsed; + break; + case LongMemEvalStage.Answer: + _answer += elapsed; + break; + default: + throw new ArgumentOutOfRangeException(nameof(stage), stage, null); + } + } + } +} + +internal enum LongMemEvalStage +{ + Storage, + ExtractionPersistence, + GraphReadBack, + Retrieval, + Answer +} diff --git a/tools/AgentMemory.LongMemEval/LongMemEvalSurfaceProbeProgram.cs b/tools/AgentMemory.LongMemEval/LongMemEvalSurfaceProbeProgram.cs new file mode 100644 index 00000000..ce75ba46 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/LongMemEvalSurfaceProbeProgram.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Neo4j.Driver; +using Testcontainers.Neo4j; + +namespace AgentMemory.LongMemEval; + +/// +/// K2. Reports whether the reasoning-trace and GraphRAG surfaces have anything to return. +/// +/// +/// Read-only, and needs no Azure credentials. It exists because both surfaces have carried a recall +/// budget of zero in every quality measurement, so "they return nothing" has never been distinguished +/// from "they were never asked". +/// +/// K1 fixed the order of questions deliberately: a FAILED or missing vector index produces the exact +/// same symptom as an empty corpus, and this repository has already shipped a fix for indexes left in +/// the FAILED state. Index health is therefore checked before any count is interpreted. +/// +/// +internal static class LongMemEvalSurfaceProbeProgram +{ + private const string Image = "neo4j:5.26"; + private const string User = "neo4j"; + private const string Password = "longmemeval-password"; + + public static async Task RunAsync(string[] args) + { + try + { + var volume = Value(args, "--volume") + ?? throw new ArgumentException("--volume is required."); + var destination = Path.GetFullPath(Value(args, "--output") + ?? Path.Combine("artifacts", "evaluation", "surface-probe.json")); + + var container = new Neo4jBuilder(Image) + .WithEnvironment("NEO4J_AUTH", $"{User}/{Password}") + .WithVolumeMount(volume, "/data") + .Build(); + await container.StartAsync().ConfigureAwait(false); + try + { + await using var driver = GraphDatabase.Driver( + container.GetConnectionString(), AuthTokens.Basic(User, Password)); + await using var session = driver.AsyncSession(); + + var indexes = await ReadAsync(session, """ + SHOW INDEXES YIELD name, type, state, entityType, labelsOrTypes, properties + RETURN name, type, state, entityType, labelsOrTypes, properties + """).ConfigureAwait(false); + var counts = await ReadAsync(session, """ + MATCH (t:ReasoningTrace) WITH count(t) AS traces + OPTIONAL MATCH (s:ReasoningStep) WITH traces, count(s) AS steps + OPTIONAL MATCH (e:Entity) WITH traces, steps, count(e) AS entities + OPTIONAL MATCH (m:Message) RETURN traces, steps, entities, count(m) AS messages + """).ConfigureAwait(false); + var traceShape = await ReadAsync(session, """ + MATCH (t:ReasoningTrace) + RETURN count(t) AS total, + count(t.task_embedding) AS withEmbedding, + sum(CASE WHEN t.success = true THEN 1 ELSE 0 END) AS successful, + sum(CASE WHEN t.success = false THEN 1 ELSE 0 END) AS failed + """).ConfigureAwait(false); + + var report = new + { + schemaVersion = 1, + generatedAtUtc = DateTimeOffset.UtcNow, + sourceVolume = volume, + // K1: a FAILED or absent index looks exactly like an empty corpus from outside. + indexes, + counts, + traceShape, + }; + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await File.WriteAllTextAsync( + destination, + JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }) + + Environment.NewLine).ConfigureAwait(false); + + var c = counts.FirstOrDefault(); + var traces = c is not null && c.TryGetValue("traces", out var t) ? t : 0; + var vectorIndex = indexes.FirstOrDefault(i => + i.TryGetValue("name", out var n) && + string.Equals(n?.ToString(), "task_embedding_idx", StringComparison.Ordinal)); + Console.WriteLine( + $"longmemeval: task_embedding_idx {(vectorIndex is null ? "ABSENT" : "state=" + vectorIndex["state"])}"); + Console.WriteLine($"longmemeval: ReasoningTrace nodes = {traces}"); + if (Equals(traces, 0L) || Equals(traces, 0)) + { + // A real result, and it is about the corpus rather than the code: nothing in the + // LongMemEval ingestion path writes traces. + Console.WriteLine( + "longmemeval: the trace surface cannot be measured on this graph - it holds no " + + "traces at all. That is a property of the fixture, not of the surface."); + } + + Console.WriteLine($"longmemeval: report {destination}"); + return 0; + } + finally + { + await container.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"longmemeval: surface probe failed: {exception.Message}"); + return 1; + } + } + + private static async Task>> ReadAsync( + IAsyncSession session, string cypher) => + await session.ExecuteReadAsync(async transaction => + { + var cursor = await transaction.RunAsync(cypher).ConfigureAwait(false); + var records = await cursor.ToListAsync().ConfigureAwait(false); + return (IReadOnlyList>)records + .Select(record => record.Keys.ToDictionary( + key => key, + key => record[key] is null ? null : (object?)record[key].ToString())) + .ToList(); + }).ConfigureAwait(false); + + private static string? Value(string[] args, string name) + { + var index = Array.IndexOf(args, name); + if (index < 0) return null; + if (index + 1 >= args.Length) + throw new ArgumentException($"{name} requires a value."); + return args[index + 1]; + } +} diff --git a/tools/AgentMemory.LongMemEval/Program.cs b/tools/AgentMemory.LongMemEval/Program.cs new file mode 100644 index 00000000..cff8d171 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/Program.cs @@ -0,0 +1,490 @@ +using System.Text.Json; +using AgentEval.Memory.External.LongMemEval; +using AgentEval.Memory.External.Models; +using AgentEval.Memory.Models; +using AgentMemory.Abstractions.Services; +using AgentMemory.LongMemEval; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +return await LongMemEvalProgram.RunAsync(args); + +internal static class LongMemEvalProgram +{ + private const int DefaultQuestions = 10; + private const int DefaultSeed = 42; + private const int DefaultMaxRelevant = 30; + + public static async Task RunAsync(string[] args) + { + if (args.Any(argument => argument is "--help" or "-h")) + { + PrintHelp(); + return 0; + } + if (args.Contains("--reference-arm", StringComparer.Ordinal)) + { + // G4-REF. Dispatched before everything else so no AgentMemory service, container, or + // embedding client is ever constructed for an arm that by definition has no memory. + return await LongMemEvalReferenceArmProgram.RunAsync(args) + .ConfigureAwait(false); + } + if (args.Contains("--surface-probe", StringComparer.Ordinal)) + { + // K2. Read-only, credential-free: reports whether the reasoning-trace and GraphRAG + // surfaces have anything to return, and checks index health first because a FAILED index + // is indistinguishable from an empty corpus from the outside. + return await LongMemEvalSurfaceProbeProgram.RunAsync(args).ConfigureAwait(false); + } + if (args.Contains("--predicate-distribution", StringComparer.Ordinal)) + { + // J1.2. Read-only, and dispatched before any Azure environment is required: counting + // relation names in an existing volume must not need the credentials of a paid run. + return await LongMemEvalPredicateDistributionProgram.RunAsync(args) + .ConfigureAwait(false); + } + if (args.Contains("--prepared-pair", StringComparer.Ordinal)) + { + return await LongMemEvalPreparedPairProgram.RunAsync(args) + .ConfigureAwait(false); + } + + + try + { + var options = Parse(args); + ValidateInputs(options); + if (options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + { + Console.Error.WriteLine( + "longmemeval: warning: content evidence retains public dataset questions, recalled text, and model answers; keep the output gitignored."); + } + + var endpoint = RequiredEnvironment("AZURE_OPENAI_ENDPOINT"); + var apiKey = RequiredEnvironment("AZURE_OPENAI_API_KEY"); + var deployment = RequiredEnvironment("AZURE_OPENAI_DEPLOYMENT"); + var embeddingDeployment = + RequiredEnvironment("AZURE_OPENAI_EMBEDDING_DEPLOYMENT"); + var extractionDeployment = + Environment.GetEnvironmentVariable("AZURE_OPENAI_EXTRACTION_DEPLOYMENT") + ?? deployment; + var azureClient = new AzureOpenAIClient( + new Uri(endpoint), + new AzureKeyCredential(apiKey)); + using var answerChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var judgeChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var diagnosticChatClient = new LongMemEvalChatCallMeter( + azureClient.GetChatClient(deployment).AsIChatClient()); + using var extractionChatClient = options.MemoryMode.UsesExtraction() + ? new LongMemEvalChatCallMeter(new ProviderCompatibleExtractionChatClient( + azureClient.GetChatClient(extractionDeployment).AsIChatClient())) + : null; + var embeddingGenerator = azureClient + .GetEmbeddingClient(embeddingDeployment) + .AsIEmbeddingGenerator(); + var embeddingDimensions = await LongMemEvalRuntime + .ProbeEmbeddingDimensionsAsync(embeddingGenerator) + .ConfigureAwait(false); + + var benchmarkOptions = LongMemEvalBenchmarkProtocol.CreateOptions( + options.DatasetPath, + options.Questions, + options.Seed, + options.JudgeRetryAttempts, + options.EvidenceDetail, + options.MaxRelevantMessages); + var evidenceIndex = LongMemEvalEvidenceIndex.Load( + options.DatasetPath, benchmarkOptions); + + var runId = $"longmemeval-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}"; + await using var profile = await LongMemEvalMemoryProfile + .StartAsync( + embeddingGenerator, + extractionChatClient, + options.MemoryMode, + extractionDeployment, + embeddingDimensions, + Console.Out, + CancellationToken.None) + .ConfigureAwait(false); + var adapter = new AgentMemoryLongMemEvalAdapter( + profile.Services.GetRequiredService(), + answerChatClient, + runId, + new LongMemEvalAdapterOptions + { + MaxRelevantMessages = options.MaxRelevantMessages, + MemoryMode = options.MemoryMode, + MinSimilarityScore = 0, + ModelId = deployment, + ExcludeSyntheticFormatterMessages = options.ExcludeSyntheticMessages, + MaxItemsPerSourceSession = options.MaxItemsPerSourceSession, + ChronologicalAnswerContext = options.ChronologicalAnswerContext, + EvidenceIndex = evidenceIndex, + EvidenceDetail = options.EvidenceDetail, + RequireGraphReadBack = options.MemoryMode.UsesExtraction(), + GraphProbe = options.MemoryMode.UsesExtraction() + ? new Neo4jLongMemEvalGraphProbe( + profile.Services.GetRequiredService()) + : null, + ExtractionProgress = (completed, total) => Console.WriteLine( + $"longmemeval: extraction units {completed}/{total}.") + }); + + var runner = LongMemEvalBenchmarkRunner.Create( + judgeChatClient, options.DatasetPath); + var benchmarkConfig = new AgentBenchmarkConfig + { + AgentName = adapter.Name, + ModelId = deployment, + ReducerStrategy = $"AgentMemory {options.MemoryMode.ToString().ToLowerInvariant()} recall", + MemoryProvider = "AgentMemory .NET / Neo4j 5.26" + }; + + Console.WriteLine( + $"longmemeval: running {options.Questions} stratified questions, seed {options.Seed}, mode {options.MemoryMode.ToString().ToLowerInvariant()}, context cap {options.MaxRelevantMessages}."); + var result = await runner + .RunAsync(adapter, benchmarkConfig, benchmarkOptions) + .ConfigureAwait(false); + var postRunDiagnostics = await LongMemEvalPostRunDiagnostics.RunAsync( + diagnosticChatClient, + evidenceIndex, + result.QuestionResults, + adapter.QuestionTelemetry, + options.OracleMode, + options.JudgeRetryAttempts, + retainContent: options.EvidenceDetail == LongMemEvalEvidenceDetail.Content) + .ConfigureAwait(false); + var answerCalls = answerChatClient.Snapshot(); + var judgeCalls = judgeChatClient.Snapshot(); + var diagnosticCalls = diagnosticChatClient.Snapshot(); + var extractionCalls = extractionChatClient?.Snapshot() ?? LongMemEvalChatCallSnapshot.Zero; + var initialExtractionCalls = + adapter.QuestionTelemetry.Sum(item => item.ExtractionUnits) * 4L; + + + var validation = LongMemEvalRunValidator.Validate( + options.Questions, + result.TotalLlmCalls, + adapter.QuestionTelemetry, + result.QuestionResults, + answerCalls, + judgeCalls, + extractionCalls, + initialExtractionCalls, + postRunDiagnostics.JudgeRetries.Count, + options.JudgeRetryAttempts); + var destination = ResolveOutput(options.OutputPath, runId); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var report = new + { + schemaVersion = 2, + runId, + generatedAtUtc = DateTimeOffset.UtcNow, + accepted = validation.Accepted, + validationIssues = validation.Issues, + fingerprint = new + { + dataset = Path.GetFileName(options.DatasetPath), + datasetSha256 = Convert.ToHexStringLower( + System.Security.Cryptography.SHA256.HashData( + await File.ReadAllBytesAsync(options.DatasetPath).ConfigureAwait(false))), + questions = options.Questions, + seed = options.Seed, + stratified = true, + answerModel = deployment, + judgeModel = deployment, + maxRelevantMessages = options.MaxRelevantMessages, + operatingMode = options.MemoryMode.Fingerprint(), + // G3B.1 changes which items fill the budget, so a filtered run must never be + // comparable to the control by accident. + syntheticFormatterExclusion = options.ExcludeSyntheticMessages + ? "excluded-candidate-x3" + : "control-unfiltered", + // G3B.3 reallocates the budget across sessions, so a capped run must never be + // comparable to an uncapped one by accident. + answerContextOrder = options.ChronologicalAnswerContext + ? "chronological" + : "retrieval-rank", + sessionBudgetCap = options.MaxItemsPerSourceSession == 0 + ? "uncapped" + : $"max-{options.MaxItemsPerSourceSession}-items-per-source-session", + extractionModel = options.MemoryMode.UsesExtraction() ? extractionDeployment : null, + extractionTemperatureCompatibility = options.MemoryMode.UsesExtraction() + ? "explicit-zero-to-provider-default" : null, + extractionResponseFormat = options.MemoryMode.UsesExtraction() + ? "json-object" : null, + extractionSourceTime = options.MemoryMode.UsesExtraction() + ? "metadata-only-not-in-extraction-prompt" : null, + evidenceDetail = options.EvidenceDetail.ToString().ToLowerInvariant(), + oracleMode = options.OracleMode.ToString().ToLowerInvariant(), + judgeRetryAttempts = options.JudgeRetryAttempts, + embedding = new + { + provider = "Azure OpenAI", + deployment = embeddingDeployment, + dimensions = embeddingDimensions + }, + judgeRequest = "AgentEval-source-native-null-temperature-256-tokens", + neo4jImage = "neo4j:5.26", + agentEval = typeof(ExternalBenchmarkOptions).Assembly.GetName().Version?.ToString(), + agentEvalDependency = "source-project:AgentEval.Memory" + }, + agentMemory = new + { + questions = adapter.QuestionTelemetry, + totalMessagesStored = adapter.QuestionTelemetry.Sum(item => item.MessagesStored), + totalItemsRetrieved = adapter.QuestionTelemetry.Sum(item => item.ItemsRetrieved), + totalExtractionUnits = adapter.QuestionTelemetry.Sum(item => item.ExtractionUnits), + totalRawMessagesRetrieved = adapter.QuestionTelemetry.Sum(item => item.RawMessagesRetrieved), + totalEntitiesRetrieved = adapter.QuestionTelemetry.Sum(item => item.EntitiesRetrieved), + totalFactsRetrieved = adapter.QuestionTelemetry.Sum(item => item.FactsRetrieved), + totalPreferencesRetrieved = adapter.QuestionTelemetry.Sum(item => item.PreferencesRetrieved), + graphRagQuestions = adapter.QuestionTelemetry.Count(item => item.GraphRagIncluded), + zeroStoreQuestions = adapter.QuestionTelemetry.Count(item => item.MessagesStored == 0), + zeroRecallQuestions = adapter.QuestionTelemetry.Count(item => item.ItemsRetrieved == 0) + }, + callAccounting = new + { + benchmarkLlmCalls = result.TotalLlmCalls, + diagnosticLlmCalls = postRunDiagnostics.DiagnosticLlmCalls, + totalLlmCalls = result.TotalLlmCalls + postRunDiagnostics.DiagnosticLlmCalls, + diagnosticCallsAffectScore = false, + observed = new + { + answer = Project(answerCalls), + judge = Project(judgeCalls), + extraction = Project(extractionCalls), + diagnostics = Project(diagnosticCalls) + }, + extractionInitialExpectedCalls = initialExtractionCalls, + extractionRetryCalls = Math.Max( + 0, extractionCalls.Calls - initialExtractionCalls) + }, + postRunDiagnostics, + result = validation.Accepted + ? LongMemEvalReportProjection.CreateAcceptedResult( + result, options.EvidenceDetail) + : null, + diagnostic = validation.Accepted ? null : new + { + result.BenchmarkId, + result.BenchmarkName, + result.Duration, + result.TotalLlmCalls, + questions = result.QuestionResults.Select((question, index) => new + { + question.QuestionId, + question.QuestionType, + question = options.EvidenceDetail == LongMemEvalEvidenceDetail.Content + ? question.Question + : null, + goldAnswer = options.EvidenceDetail == LongMemEvalEvidenceDetail.Content + ? question.GoldAnswer + : null, + agentResponse = options.EvidenceDetail == LongMemEvalEvidenceDetail.Content + ? question.AgentResponse + : null, + question.Correct, + question.RawScore, + judgeExplanation = options.EvidenceDetail == LongMemEvalEvidenceDetail.Content + ? question.JudgeExplanation + : null, + status = LongMemEvalRunValidator.Classify( + question, + adapter.QuestionTelemetry.FirstOrDefault(item => + item.QuestionNumber == index + 1)), + question.Duration + }) + } + }; + await File.WriteAllTextAsync( + destination, + JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }) + + Environment.NewLine).ConfigureAwait(false); + + if (!validation.Accepted) + { + foreach (var issue in validation.Issues) + Console.Error.WriteLine($"longmemeval: validation: {issue}"); + Console.Error.WriteLine($"longmemeval: rejected diagnostic report {destination}"); + return 1; + } + + Console.WriteLine( + $"longmemeval: accuracy={result.OverallAccuracy:F1}% task_average={result.TaskAveragedAccuracy:F1}% questions={result.QuestionResults.Count} llm_calls={result.TotalLlmCalls}"); + Console.WriteLine($"longmemeval: report {destination}"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine($"longmemeval: {exception.Message}"); + return 1; + } + } + + private static Options Parse(string[] args) + { + string? Value(string name) + { + var index = Array.IndexOf(args, name); + if (index < 0) return null; + if (index + 1 >= args.Length) + throw new ArgumentException($"{name} requires a value."); + return args[index + 1]; + } + + return new Options( + Value("--dataset") ?? string.Empty, + ParsePositive(Value("--questions"), DefaultQuestions, "--questions"), + ParsePositive(Value("--seed"), DefaultSeed, "--seed"), + ParsePositive(Value("--max-relevant"), DefaultMaxRelevant, "--max-relevant"), + ParseEvidenceDetail(Value("--evidence-detail")), + ParseOracleMode(Value("--oracle")), + ParseMemoryMode(Value("--memory-mode")), + ParseNonNegative(Value("--judge-retries"), 2, "--judge-retries"), + Value("--output"), + Array.IndexOf(args, "--exclude-synthetic-messages") >= 0, + ParseNonNegative(Value("--max-items-per-session"), 0, "--max-items-per-session"), + Array.IndexOf(args, "--chronological-context") >= 0); + } + + private static object Project(LongMemEvalChatCallSnapshot snapshot) => new + { + snapshot.Calls, + snapshot.Failures, + durationMs = snapshot.Duration.TotalMilliseconds + }; + + + private static int ParsePositive(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed <= 0) + throw new ArgumentException($"{option} must be a positive integer."); + return parsed; + } + + private static LongMemEvalEvidenceDetail ParseEvidenceDetail(string? value) => + value?.ToLowerInvariant() switch + { + null or "identifiers" => LongMemEvalEvidenceDetail.Identifiers, + "none" => LongMemEvalEvidenceDetail.None, + "content" => LongMemEvalEvidenceDetail.Content, + _ => throw new ArgumentException( + "--evidence-detail must be one of: none, identifiers, content.") + }; + + private static LongMemEvalOracleMode ParseOracleMode(string? value) => + value?.ToLowerInvariant() switch + { + null or "none" => LongMemEvalOracleMode.None, + "failed" => LongMemEvalOracleMode.Failed, + "all" => LongMemEvalOracleMode.All, + _ => throw new ArgumentException("--oracle must be one of: none, failed, all.") + }; + + private static LongMemEvalMemoryMode ParseMemoryMode(string? value) => + value?.ToLowerInvariant() switch + { + null or "raw" => LongMemEvalMemoryMode.Raw, + "structured" => LongMemEvalMemoryMode.Structured, + "hybrid" => LongMemEvalMemoryMode.Hybrid, + _ => throw new ArgumentException( + "--memory-mode must be one of: raw, structured, hybrid.") + }; + + private static int ParseNonNegative(string? value, int defaultValue, string option) + { + if (value is null) return defaultValue; + if (!int.TryParse(value, out var parsed) || parsed < 0) + throw new ArgumentException($"{option} must be a non-negative integer."); + return parsed; + } + + private static void ValidateInputs(Options options) + { + if (string.IsNullOrWhiteSpace(options.DatasetPath)) + throw new ArgumentException("--dataset is required."); + if (!File.Exists(options.DatasetPath)) + throw new FileNotFoundException("LongMemEval dataset not found.", options.DatasetPath); + } + + private static string RequiredEnvironment(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"{name} is required; refusing to create a synthetic LongMemEval score."); + + private static string ResolveOutput(string? requested, string runId) => + Path.GetFullPath(requested ?? + Path.Combine("artifacts", "evaluation", runId, "report.json")); + + private static void PrintHelp() => Console.WriteLine( + """ + AgentMemory LongMemEval (AgentEval.Memory local source) + + dotnet run --project tools/AgentMemory.LongMemEval -- \ + --dataset [--questions 10] [--seed 42] \ + [--max-relevant 30] [--memory-mode raw|structured|hybrid] \ + [--reference-arm no-memory|full-history] \ + [--prepared-pair] [--preflight-only] \ + [--preparation-workers 10] [--max-sessions-per-batch 4] \ + [--max-input-tokens 100000] \ + [--max-concurrent-batches-per-extraction 4] \ + [--max-concurrent-extraction-batches 12] \ + [--checkpoint-questions 3] [--checkpoint-timeout-seconds 3600] \ + [--diagnostic-question N --diagnostic-source-session N] \ + [--provider-no-progress-timeout-seconds 600] \ + [--evidence-detail none|identifiers|content] \ + [--exclude-synthetic-messages] [--max-items-per-session N] [--chronological-context] \ + [--oracle none|failed|all] [--judge-retries 2] [--output ] + + --exclude-synthetic-messages over-fetches 3x the message budget, drops only AgentEval's + formatter boilerplate (session boundaries and padding), keeps retrieval order, and selects + the first --max-relevant real source turns. Default off: unfiltered recall is the control. + + --reference-arm runs a control that uses no AgentMemory at all, on the identical sample, seed, + answer deployment and judge, so an AgentMemory score has something to be measured against: + no-memory the question alone - the model's parametric floor. + full-history every real source turn in context, formatter boilerplate dropped - the ceiling. + It starts no container and makes no embedding, extraction, storage or recall call. Whether the + history fits is decided by the provider rejecting the prompt, never by a token estimate: in this + dataset every question is 113k-128k estimated tokens, inside any estimator's own error bar. + A question that does not fit is reported as skipped and excluded from fitted accuracy, never + scored as wrong. Cannot be combined with --memory-mode, --prepared-pair, + --exclude-synthetic-messages, or a non-none --oracle. + + --prepared-pair prepares structured memory once, freezes it, clones it, and evaluates isolated Structured and Hybrid arms. + Supplying both diagnostic selectors with --prepared-pair runs exactly one extraction unit and can never emit a report or execute recall/judging. + --preflight-only freezes the exact prepared-pair batch plan, proves zero provider calls/writes, + prints source-session/call/token totals, cleans up, and emits no accepted report. + --checkpoint-questions selects the highest-token frozen questions, executes the identical + preparation path under a hard deadline, projects full cold-build time, cleans up, and emits no report. + + + Requires AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT, + and AZURE_OPENAI_EMBEDDING_DEPLOYMENT. + Uses real LongMemEval data, a pinned Neo4j 5.26 container, real Azure OpenAI embeddings, + and the same Azure deployment for answers and AgentEval's type-specific judge. + Structured/hybrid extraction may use AZURE_OPENAI_EXTRACTION_DEPLOYMENT; it defaults to the answer deployment. + """); + + private sealed record Options( + string DatasetPath, + int Questions, + int Seed, + int MaxRelevantMessages, + LongMemEvalEvidenceDetail EvidenceDetail, + LongMemEvalOracleMode OracleMode, + LongMemEvalMemoryMode MemoryMode, + int JudgeRetryAttempts, + string? OutputPath, + bool ExcludeSyntheticMessages, + int MaxItemsPerSourceSession, + bool ChronologicalAnswerContext); +} diff --git a/tools/AgentMemory.LongMemEval/ProviderCompatibleExtractionChatClient.cs b/tools/AgentMemory.LongMemEval/ProviderCompatibleExtractionChatClient.cs new file mode 100644 index 00000000..43a53688 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/ProviderCompatibleExtractionChatClient.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentMemory.LongMemEval; + +/// +/// Extraction-only provider adapter. Some reasoning deployments reject an explicit zero +/// temperature, so the harness uses the provider default and fingerprints that behavior. +/// Answer and judge requests do not pass through this adapter. +/// +internal sealed class ProviderCompatibleExtractionChatClient(IChatClient inner) : IChatClient +{ + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + Normalize(options); + return inner.GetResponseAsync(messages, options, cancellationToken); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Normalize(options); + await foreach (var update in inner + .GetStreamingResponseAsync(messages, options, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) + ? this + : inner.GetService(serviceType, serviceKey); + + public void Dispose() => inner.Dispose(); + + private static void Normalize(ChatOptions? options) + { + if (options?.Temperature == 0) + options.Temperature = null; + } +} diff --git a/tools/AgentMemory.LongMemEval/README.md b/tools/AgentMemory.LongMemEval/README.md new file mode 100644 index 00000000..7921ef00 --- /dev/null +++ b/tools/AgentMemory.LongMemEval/README.md @@ -0,0 +1,211 @@ +# AgentMemory LongMemEval + +Opt-in operator tooling for a public, sampled LongMemEval-S memory-quality characterization through +[AgentEval](https://agenteval.dev/). The runner uses the official question and type-specific judge +methodology, but a 10-question sample is not presented as the paper's full-dataset score. +It is deliberately a separate, non-packable project; AgentEval is preview software, and none of its +dependencies enter the published AgentMemory libraries or the +main CLI. + +## What the adapter measures + +AgentEval selects real LongMemEval-S questions, injects each question's multi-session history, asks +the agent, and applies its type-specific binary judge. AgentEval/LongMemEval do not select an +AgentMemory storage or retrieval mode: **our tool-local benchmark bridge** makes that choice. The +bridge exposes explicit `raw`, `structured`, and `hybrid` modes. None gives the injected history +directly to the answer model. The raw-message vector control performs this bounded sequence: + +1. buffer AgentEval's injected `(user, assistant)` turns; +2. batch-persist them as AgentMemory messages in a question-specific owner/session scope; +3. semantically recall only through AgentMemory; +4. give the answer model the recalled messages plus the question; +5. refuse the question if storage or recall produced zero items. + +**Mode boundary:** `raw` persists messages with `AddMessagesAsync` and bypasses the extraction +prompts. `structured` additionally runs entity, fact, preference, and relationship extraction once +per real source session and answers from graph-derived memory without raw-message recall. `hybrid` +runs the same extraction and combines graph-derived memory with raw-message recall. + +The raw arm was chosen first as a bounded control, not as the predicted highest-quality configuration. +The fixed 10-question seed-42 sample contains 474 source sessions and 4,958 source turns. Extracting +all four categories once per source session with today's fan-out would add 1,896 LLM completions before +retries; flattening each roughly 500-turn question into one extraction request would instead risk +context overflow and erase the session/time boundaries under test. `--prepared-pair` therefore +prepares the structured graph once, freezes it, and evaluates isolated Structured and Hybrid clones. + +Prepared-pair extraction runs up to four planned batches concurrently within one question and caps +all extraction provider calls from the process at 12. Both controls are explicit through +`--max-concurrent-batches-per-extraction` and `--max-concurrent-extraction-batches`, are recorded in +the preparation fingerprint, and fail closed when provider calls retry, fail, exceed the cap, or do +not all complete. Per-call telemetry is content-free and bounded to call ordinal, estimated input +size, provider duration, retry state, exception type, and numeric provider status. +Each provider batch uses deterministic short source-session aliases (`s1` through `s4`) and a +batch-specific JSON schema that constrains acknowledgements and learned-item source keys to those +aliases. AgentMemory maps aliases back to the immutable source-session ids before persistence. The +sealed preparation fingerprint records this contract as `batch-source-alias-schema-v1`. + +The report contains AgentEval's overall, task-averaged, per-type and per-question results alongside +per-question AgentMemory stored/retrieved counts and opt-in ranked evidence. The evaluator aligns each +retrieved message with its source session/turn/timestamp after recall and reports gold-session recall, +gold-turn hit, first-gold ranks, reciprocal rank, session diversity, similarity scores, and answer-prompt +size. `has_answer` and `answer_session_ids` remain evaluator-side; they are never persisted, embedded, +queried, or sent to the answer model. This proves that a score was produced through the memory system +instead of by silently leaving the full history in model context. + +## Prerequisites + +- Docker +- the real `longmemeval_s_cleaned.json` dataset from + +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_API_KEY` +- `AZURE_OPENAI_DEPLOYMENT` +- `AZURE_OPENAI_EMBEDDING_DEPLOYMENT` + +No embedded or synthetic dataset fallback exists. The tool exits nonzero when data or credentials +are missing. + +## Reproduce + +```powershell +dotnet run -c Release --project tools/AgentMemory.LongMemEval -- ` + --dataset C:\path\to\longmemeval_s_cleaned.json ` + --questions 10 ` + --seed 42 ` + --max-relevant 30 ` + --evidence-detail identifiers ` + --oracle failed ` + --judge-retries 2 ` + --output artifacts\evaluation\longmemeval\report.json +``` + +For content-free extraction accounting diagnostics, `--prepared-pair` can select exactly one frozen +question position and source-session ordinal: + +```powershell +dotnet run -c Release --project tools/AgentMemory.LongMemEval -- ` + --prepared-pair ` + --dataset C:\path\to\longmemeval_s_cleaned.json ` + --questions 10 ` + --seed 42 ` + --evidence-detail identifiers ` + --diagnostic-question 3 ` + --diagnostic-source-session 14 +``` + +Diagnostic-only execution forbids `--output` and content evidence. It never seals or clones prepared +state and never runs recall, answer generation, or judging; it can therefore never be accepted as a +LongMemEval score. +Defaults are 10 questions, seed 42 and 30 recalled messages. The profile pins Neo4j 5.26 and uses +the configured real Azure OpenAI embedding deployment for both persisted history and recall queries. +The tool probes the provider's vector dimension before creating the Neo4j index and records the +embedding deployment and dimension in the report fingerprint. The configured chat deployment answers +questions and acts as AgentEval's judge. AgentEval 0.16 explicitly requests judge temperature zero and caps judge output at 30 tokens. For the +configured reasoning deployment, the tool narrowly translates the exact judge option signature +`temperature=0, maxOutputTokens=30` to provider-default temperature and a 512-token ceiling. Other +requests are unchanged, and AgentEval's prompt and binary scoring remain authoritative. The policy is +recorded in the report fingerprint; empty or invalid output still rejects the run. + +A valid base run requires exactly two LLM calls per question (one answer and one judge), one AgentMemory +telemetry record per question, nonzero stored messages, nonzero recalled items, and a valid explicit +yes/no judge verdict. Empty, invalid, provider-failed, or internally inconsistent verdicts reject the +base score instead of becoming ordinary incorrect answers. + +`--evidence-detail` is `identifiers` by default; `none` keeps only aggregate evidence and `content` +explicitly retains recalled/question/answer text for local forensic work. Default accepted and rejected +reports are content-free; accepted safe-mode reports preserve scores, question identifiers/types/outcomes, +durations, counters, and evidence without serializing AgentEval's native content-bearing result or options. +`--judge-retries` and `--oracle none|failed|all` run after the immutable AgentEval result. +Their calls and outcomes are reported separately and never alter AgentEval's score or its required `2N` +base call count. Oracle mode gives the answer model only labelled source sessions and uses the same +answer deployment and type-specific judge to distinguish retrieval failure from reader/judge limits. + +## Reference arms — what a score is measured *against* + +A LongMemEval percentage means nothing on its own, because it silently compares one AgentMemory +configuration against another. `--reference-arm` supplies the two ends of the band, on the identical +sample, seed, answer deployment and judge: + +```powershell +dotnet run -c Release --project tools/AgentMemory.LongMemEval -- ` + --reference-arm no-memory ` # the question alone: the model's parametric floor + --dataset --questions 10 --seed 42 --judge-retries 2 + +dotnet run -c Release --project tools/AgentMemory.LongMemEval -- ` + --reference-arm full-history ` # every real turn in context: the ceiling retrieval aims at + --dataset --questions 10 --seed 42 --judge-retries 2 +``` + +Neither arm starts a container or makes an embedding, extraction, storage or recall call, so neither +needs Docker or an embedding deployment; each costs ~20 provider calls. They cannot be combined with +`--memory-mode`, `--prepared-pair`, `--exclude-synthetic-messages`, or a non-`none` `--oracle` — +those are rejected rather than ignored, because they have no meaning for an arm with no memory. + +**Measured band (seed 42, ten questions, 2026-08-07):** + +| Arm | Overall | Mean context (est. tokens/question) | +|---|---:|---:| +| no memory layer (fresh session, nothing) | 0.0% | 0 | +| **AgentMemory raw** | **90.0%** (task-avg 94.4%) | **~4,300** | +| full chat history in context | 100.0% | ~120,500 | + +**90% of the quality on 3.5% of the context.** State that as a cost-and-scale result, not a quality +win: on a sample where the whole transcript fits the window, replaying it still scores higher. The +memory system's case is what happens when it stops fitting. + +Both history arms must always be measured with the **same** prompt treatment. An earlier version of +this table read 70.0% / 80.0% purely because the answer prompt discarded session dates; restoring +them moved AgentMemory to 90.0% *and* the full-history arm to 100.0%. Fixing one side only would +have produced a flattering and completely false comparison. + +The single remaining failure is diagnosed as embedding **granularity**, not ranking: its evidence is +a passing aside inside a long turn about another topic, so the turn-level embedding never surfaces +it. + +Whether the history fits is decided by **the provider rejecting the prompt**, never by a token +estimate — every question in this dataset is 113,750–128,489 estimated tokens against a 128k window, +so an estimate would be deciding inside its own error bar. A question that does not fit is reported +as `skipped-context-window` and excluded from fitted accuracy rather than scored wrong; if every +question skips, the arm reports "not measurable on this deployment" instead of 0%. The arms' +system prompts necessarily differ from the shipped memory prompt (instructing a model to use +"retrieved memory" when there is none would manufacture abstentions) and are recorded verbatim in +each report. + +## Reading a score + +The first run is a characterization baseline, not a product-quality pass/fail gate. A small sample +has high variance. Compare two implementations only when all fingerprint fields match: + +The accepted fixed-evaluator seed-42 diagnostic control (`r8`) scored **70.0% overall** and +**69.44% task-averaged** with 20 base calls, 5,878 messages stored, 300 ranked recalls, and three +valid failed-question oracle arms. It exactly repeated `r7`'s ten outcomes after `r7` was rejected as a +checkpoint for unsafe default content retention. This is not a measured product improvement over the +earlier 60.0% / 52.78% characterization: the raw storage/retrieval mode was unchanged, and the comparison +also spans corrected judge output compatibility plus non-deterministic model execution. Use `r8` as the +diagnostic control for subsequent paired candidates. + +- exact dataset SHA-256; +- selected question count and seed; +- answer and judge model deployment; +- retrieval cap; +- embedding implementation and dimensions; +- Neo4j image; +- AgentEval version. + +This raw-message control cannot grade optimization rank 4 by itself because our bridge bypasses the +extraction prompts that rank 4 changes. The implemented operating-mode comparison runs the same sampled +questions as explicit `raw`, `structured` (derived graph only), and `hybrid` (raw plus derived graph) +arms. The accepted raw r8 remains the fixed-ten control while guarded Structured/Hybrid +characterization is in progress; do not call the raw score the full AgentMemory LongMemEval score. +Preserve the deterministic extraction-quality guard: sampled model evidence complements the zero-noise +pipeline fixture; it does not replace it. + +## Verification + +```powershell +dotnet test tests/AgentMemory.Tests.Unit.LongMemEval +dotnet build AgentMemory.slnx -c Release +``` + +The adapter tests verify persistence-before-recall, no-history rejection, and distinct owner/session +scopes across questions.