diff --git a/docs/coding-agent/lessons.md b/docs/coding-agent/lessons.md index a17a931..bfd545c 100644 --- a/docs/coding-agent/lessons.md +++ b/docs/coding-agent/lessons.md @@ -1017,3 +1017,47 @@ Prevention: Evidence: - `common.md` now contains only repo-wide validation, naming, module-layout, and test-placement rules. + +## 2026-07-11 — No Hard-Wrapped Prose In Committed Docs [tags: formatting, docs, plans] + +Context: +- Plan: `docs/coding-agent/plans/active/v0-1-4-continuity-evaluation-harness-plan.md` +- Task/Wave: plan/rule bookkeeping across CM and CME +- Roles involved: Orchestrator + +Symptom: +- Plan files and log entries were written with hard line breaks mid-sentence to fit a column width; user corrected that prose must not contain arbitrary newlines inside sentences. + +Root cause: +- Habitual fixed-column wrapping applied to markdown prose where soft-wrap is expected; the wrapping conveys nothing and pollutes diffs. + +Fix applied: +- Reflowed both active plan files (CM and CME) so every sentence/paragraph/list item is a single line; YAML keys and structural lines untouched. + +Prevention: +- Repo rule added to `docs/coding-agent/rules/common.md` in both repositories: never hard-wrap prose mid-sentence in committed documents. +- Repo-wide cleanup of pre-existing files is deferred by user decision until after the current feature PR merges. + +Evidence: +- Reflow commits on `v0-1-4-cm-groundwork` (CM) and `eval-harness-architecture-revision` (CME). + +## 2026-07-11 — Freeze The Target List Before Destructive Ops On Shared Resources [tags: operations, shared-state, qdrant] + +Context: +- Task: user-directed cleanup of ephemeral Qdrant collections while reviewer test runs were active +- Roles involved: Orchestrator + +Symptom: +- The deletion script re-enumerated collections at execution time instead of using the announced snapshot, so 14 collections created by a reviewer's in-flight test run were also deleted, contaminating its green result and forcing a full uncontaminated rerun. + +Root cause: +- The coordination message promised a fixed-snapshot deletion, but the implementation piped a fresh listing into the delete loop — the announced safety property was never actually implemented. + +Fix applied: +- Reviewer reran the full suite post-all-clear and reconfirmed; no lasting damage. + +Prevention: +- For destructive operations on shared mutable resources: enumerate once, freeze the list, act only on the frozen list — and verify the implementation actually matches any safety property announced to peers before running it. + +Evidence: +- cm-reviewer coordination messages (provisional APPROVED, then reconfirmed post-rerun) on 2026-07-11. diff --git a/docs/coding-agent/plans/active/v0-1-4-continuity-evaluation-harness-plan.md b/docs/coding-agent/plans/active/v0-1-4-continuity-evaluation-harness-plan.md new file mode 100644 index 0000000..4bafc9e --- /dev/null +++ b/docs/coding-agent/plans/active/v0-1-4-continuity-evaluation-harness-plan.md @@ -0,0 +1,494 @@ +# Plan: v0.1.4 Continuity Evaluation Harness (hosted in CharacterMemoryEvals) + +- status: in_progress +- generated: 2026-07-05 +- last_updated: 2026-07-11 +- work_type: code + +Repo references: [CM] = this repository (character-memory, public). [CME] = the private companion repository `CharacterMemoryEvals`, checked out as a sibling directory of this repo. CME hosts all evaluation harnesses (LongMemEval-S, LoCoMo, and the new continuity harness) and consumes this crate via a path dependency. Evaluation tooling is a development aid, not core library functionality; CME is not publicly accessible. + +## Goal + +- Build the deterministic continuity evaluation harness described in `docs/design/roadmap-phases/v0_1_4_continuity_evaluation_harness.md`, hosted in [CME]: seeded synthetic long-horizon fixtures, a fixture-scripted driver loop exercising the full public facade, the seven continuity metrics, restart measurement of persistent stores, and diffable JSONL/summary reports — reusing [CME]'s runner/report/metric-registry infrastructure and changing no `character_memory` behavior or defaults. +- Precondition owned by this plan: [CME] was assembled quickly and needs an architecture revision to be future-proof before new features land there. That revision runs first, in [CME], under its own plan (Task_4 gates all [CME] feature work here). + +## Definition of Done + +- [CME] architecture revision plan completed and its repo main is green before any continuity feature task starts in [CME]. +- `cargo run -p cmem-eval-runner --features real-character-memory -- run continuity ...` (path adjusted to the post-revision layout) executes end-to-end against local Qdrant + persistent Oxigraph + sqlite stats with the deterministic embedding provider and zero network provider calls. +- All v0.1.4 acceptance criteria from the phase doc (lines 304–317) demonstrably met: deterministic/reproducible runs, no external LLM/embedding calls at eval time, heterogeneous hub entities across ≥3 entity kinds, entity-neutral fixtures/metrics, full facade coverage (remember/retrieve/correct/forget/link and prepare/validate/commit), restart measurement for persistent graph and stats stores, reports with metric values + per-query rationale samples, machine-readable diffable reports, selectivity/fanout measurements usable for v0.1.5 tuning, no library behavior change. +- Reviewer has independently run the harness twice and verified reports identical outside the designated metadata block. +- [CM] validation passes: `cargo fmt --check`, `cargo check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --no-run`. +- [CME] validation passes (its own rules): `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, plus a service-free mock smoke run. +- `character_memory` crate version bumped to 0.1.4 (user-approved). +- No committed artifact in [CM] contains machine-local absolute paths; all [CM] docs mentioning [CME] follow the private-repo wording rule (`docs/coding-agent/rules/common.md`, Repo Documentation Wording). + +## Scope / Non-goals + +- Scope: + - [CME] pre-phase: architecture review + revision, planned and executed in [CME] under its own plan (this plan only gates on it; see Task_4). + - [CME] features (post-revision): new continuity dataset crate (working name `cmem-eval-continuity`, subject to the revised architecture); `MemoryAdapter` trait extension for correct/forget/link and prepare/validate/commit (mock + live); controllable-similarity deterministic embedding provider; seeded fixture generator + checked-in fixtures with relevance labels; continuity driver loop + `run continuity` CLI; seven continuity metrics with `metric_support`/registry semantics; restart measurement; configs + README. + - [CM]: ADR recording harness placement (ADR-I-0018 revisit trigger); time-injectability + telemetry gap + restart-identity audit; additive-only telemetry/time-seam changes if the audit finds gaps (user-approved); version bump to 0.1.4; roadmap bookkeeping with private-repo-aware wording. +- Delegation boundary (established suites): LongMemEval/LoCoMo scoring stays untouched. They default to OpenAI embeddings and external LLM enrichment/QA passes, so they cannot serve as the continuity harness; none of the seven continuity metrics, staged writes, correct/forget/link, or restart behavior are measurable through them. Delegated instead: runner/CLI scaffolding, JSONL/summary report writers, metric registry + `metric_support` (null-never-false-zero) semantics, recall/mrr/ndcg@k machinery, adapter/namespace/config plumbing. +- Non-goals (phase doc + [CME] conventions): no learned retrieval policy, no model-graded scoring or LLM judges, no live LLM calls, no CI-blocking quality gates, no public benchmark, no new memory object types, no new public memory facade methods, no retrieval behavior/default changes (v0.1.5), no dashboards, no fixture DSL beyond scenario needs, no metric plugin system, no soak/perf infra, no changes to LongMemEval/LoCoMo scoring, no docker-compose provisioning in [CME], no making [CME] public. + +## Context (workspace) + +[CM]: +- Phase design: `docs/design/roadmap-phases/v0_1_4_continuity_evaluation_harness.md`; roadmap §10 (`docs/roadmap/development_roadmap.md` lines 1087–1150); no-new-facade constraint (lines 1614–1618). Philosophy: `docs/project_philosophy.md` §12 success criteria, entity-neutrality (§2.4, §8). +- Facade complete for the loop: `src/memory.rs` (prepare/validate_plan/commit/ remember/link/retrieve/correct/forget). Telemetry surface: `src/api/types/retrieval.rs` (RetrievalRationale/Telemetry/Trace, Selectivity*, GraphExpansion*, LifecycleFilterDecision, SectionAssignment; serde round-trippable). +- ADR-I-0018 layout: harness placement outside the crate resolves the "no unambiguous home" revisit trigger; record via new ADR. +- Restart knobs: `GRAPH_STORE_MODE` (service/persistent/in_memory), `RETRIEVAL_STATS_STORE_MODE` (sqlite/in_memory) in `src/config/app_settings.rs`. + +[CME] (as surveyed 2026-07-05; layout may change in the Task_4 revision): +- Rust workspace, edition 2024: `cmem-eval-core` (MemoryAdapter trait + mock, metrics, results/report writers, config), `cmem-eval-longmemeval`, `cmem-eval-locomo`, `cmem-eval-runner` (bin `cmem-eval`, clap subcommands, live `CharacterMemoryAdapter` behind feature `real-character-memory`). +- Consumes `character_memory` as a sibling-directory path dependency (workspace `Cargo.toml` line 26). +- Live adapter (`crates/cmem-eval-runner/src/real_adapter.rs`): per-namespace `CharacterMemory` instances; deterministic hash-bucket `DeterministicEmbeddingProvider` (lines 1332–1358, not similarity-controllable); Oxigraph defaults to in-memory (line 122); only `remember` + `retrieve` exercised today; external_id↔MemoryId maps are adapter-local (lost across restart). +- Reusable: `run synthetic` pattern (`commands.rs:63–68,152`), JSONL/summary writers + `summarize` command, metric registry with `metric_support` semantics, integrity metrics (`suppressed_memory_leakage_rate`, `superseded_current_leakage_rate` in `results.rs:97–118`). +- Repo rules: own `docs/coding-agent/rules/`; validation commands `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, mock synthetic smoke run. No CI, no toolchain pin. +- Git state: cleaned up, on `main` (user-confirmed 2026-07-05). +- Repo reference docs consulted: `docs/coding-agent/rules/common.md` and `docs/coding-agent/rules/orchestrator.md` in both repos. + +## Open Questions (max 3) + +- None currently. (Q1 branch sequencing: resolved — [CME] is clean on main. Q2 restart identity: resolved — public-API re-association approach approved; facade-level proposal only if the Task_2 audit proves it infeasible.) + +## Assumptions + +- A1 (user-approved): harness home is [CME]; the continuity harness lands as a new dataset crate under whatever layout the Task_4 architecture revision produces. +- A2 (user-approved): additive-only telemetry fields on existing public types are acceptable; "no new facade APIs" means no new methods on `CharacterMemory`. +- A3 (user-approved): crate version bumps to 0.1.4 at milestone completion. +- A4: local Qdrant (and persistent Oxigraph/sqlite paths) are acceptable eval-run dependencies; "no external calls" covers LLM/embedding providers only. Continuity config validation hard-rejects non-deterministic embedding providers. +- A5: fixtures are generated by a seeded generator and checked in (diff-stable); the generator is rerun only when scenarios change. +- A6: `MemoryAdapter` trait expansion (not facade-direct driving) is the house-consistent choice — the trait is [CME]'s documented API boundary. The Task_4 revision may refine this; if it removes the trait, Task_5/Task_6 re-scope (replan trigger). +- A7: code identifiers use stable domain names (`continuity`, `cmem-eval-continuity`), never version labels (repo naming rule). +- A8 (user-directed): [CM] committed artifacts contain no machine-local absolute paths and word [CME] mentions for public readers (private repo, development aid, not core functionality). + +## Tasks + +### Task_1: [CM] ADR: continuity harness placement in the private evals repository + +- type: design +- owns: + - docs/decisions/implementation/ADR-I-0019-continuity-eval-harness-placement.md + - docs/decisions/ (index update only, if an index exists) +- depends_on: [] +- description: | + Write ADR-I-0019 resolving the ADR-I-0018 "no unambiguous home" revisit trigger: the continuity evaluation harness lives in the private companion repository CharacterMemoryEvals as a dataset crate, consuming character_memory strictly through the public API via a sibling-checkout path dependency. Record alternatives considered (in-repo evals/ workspace crate, examples/, feature-gated module) and why rejected; record the delegation boundary vs LongMemEval/LoCoMo; note the cross-repo versioning implication and the crate-version bump policy. Wording constraints (A8): no machine-local absolute paths; state plainly that the evals repository is private and evaluation tooling is a development aid rather than core library functionality, so public readers are not confused by an inaccessible reference. +- acceptance: + - ADR-I-0019 exists with decision, alternatives, delegation boundary, and cross-repo versioning notes; entity-neutral; no version labels in identifiers. + - ADR satisfies A8: no local paths; private-repo wording present. + - ADR cross-references the phase doc and ADR-I-0018 revisit trigger. +- validation: + - kind: command + required: true + owner: worker + detail: "[CM] cargo fmt --check && cargo check (docs-only change; confirms no accidental code touch)" + - kind: review + required: true + owner: orchestrator + detail: "ADR matches plan decisions, phase-doc constraints, and the A8 wording rule" + +### Task_2: [CM] Time-injectability, telemetry-gap, and restart-identity audit + +- type: research +- owns: + - docs/coding-agent/plans/active/v0-1-4-continuity-evaluation-harness-plan.md (findings appendix only) +- depends_on: [] +- description: | + Three audits, findings appended to this plan: (1) Time: enumerate every wall-clock read (Utc::now or equivalent) in the retrieval/scoring path; determine whether months-scale-gap fixtures work with caller-provided timestamps end-to-end or a reference-time seam is needed. (2) Metrics: for each phase-doc §7.1–7.7 metric, verify computability from the existing public telemetry (`src/api/types/retrieval.rs`) plus fixture labels; enumerate missing fields (per-relation fanout budget-vs-utilization, conservative-fallback activation events, rationale category taxonomy coverage). (3) Restart identity (approved direction): verify an eval harness can re-associate fixture external ids with MemoryIds after dropping and reconstructing CharacterMemory over the same persistent stores using only the public API; if infeasible, characterize the blocking gap precisely (replan trigger with facade-level proposal). Output: findings table (metric -> inputs -> available? -> gap) and a concrete additive-only change list for Task_3 (possibly empty). Findings must not contain machine-local absolute paths (A8). +- acceptance: + - Every §7 metric has a computability verdict with type/field references. + - Wall-clock usage in the retrieval path enumerated with file:line references. + - Restart-identity approach documented as public-API-feasible, or the blocking gap precisely characterized. + - Task_3 change list is concrete or explicitly empty. +- validation: + - kind: review + required: true + owner: orchestrator + detail: "Findings reviewed; Task_3 scope confirmed or Task_3 closed as not-needed" + +### Task_3: [CM] Additive telemetry/time-injection changes (conditional) + +- type: impl +- owns: + - src/api/types/retrieval.rs + - src/usecases/retrieve.rs + - src/policy/** + - tests/** +- depends_on: [Task_2] +- description: | + Implement exactly the additive-only changes approved from Task_2 findings: new optional telemetry fields and/or an internal reference-time seam. Hard constraints: no retrieval behavior change, no default changes, no new facade methods, backward-compatible. If Task_2 finds no gaps, close with zero diff. +- acceptance: + - All approved gap items implemented; existing tests pass unchanged (except mechanical additions). + - New fields covered by serde round-trip tests where applicable. + - No new public facade methods on `CharacterMemory`. +- validation: + - kind: command + required: true + owner: worker + detail: "[CM] cargo fmt --check && cargo check && cargo clippy --all-targets -- -D warnings && cargo test --no-run" + - kind: test + required: true + owner: worker + detail: "[CM] cargo test (unit scope; integration vs local Qdrant if available)" + - kind: review + required: true + owner: reviewer + detail: "Diff review: additive-only, no behavior/default change; ADR-I-0018 dependency-direction audit" + +### Task_4: [CME] Architecture review and revision (gating pre-phase; own plan in CME) + +- type: design +- owns: + - (CME repo) docs/coding-agent/plans/** (new architecture-revision plan) +- depends_on: [] +- description: | + The evals repository was assembled quickly; before new features land there, run an architecture review and revision INSIDE that repository under its own execution plan, per that repo's own rules and plan lifecycle. This task, in this plan, tracks only the gate. Steps: (1) dispatch research into architectural debt (crate boundaries, MemoryAdapter contract shape, config/report format evolution, feature-gating strategy, determinism discipline, toolchain pinning, test coverage); (2) draft the revision plan in the CME repo's plans/active/; (3) obtain user approval for that plan; (4) execute it to completion there. Continuity feature design inputs (Tasks 5–12 here) are explicit stakeholders of the review: the revised architecture must have an intended home for a continuity dataset crate, an extensible adapter contract, and a stable report/metric registry story. +- acceptance: + - A CME architecture-revision plan exists in that repo, user-approved, executed to completion (its own Definition of Done met), CME main green. + - The revised architecture documents where a continuity dataset crate lands and how the adapter contract extends (or replaces) MemoryAdapter. + - This plan's Decision Log records any re-scoping of Tasks 5–12 the revision causes (replan trigger if A6 or task owns change materially). +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace (green on CME main at gate close)" + - kind: review + required: true + owner: orchestrator + detail: "CME revision plan completed per its own lifecycle; continuity-stakeholder requirements satisfied; re-scoping needs recorded here" + +### Task_5: [CME] Extend adapter contract: correct/forget/link + staged writes + +- type: impl +- owns: + - (CME repo) adapter-contract module per post-revision layout (pre-revision: crates/cmem-eval-core/src/memory_adapter.rs) +- depends_on: [Task_4] +- description: | + Extend the adapter contract (and its mock implementation) with methods mirroring the character_memory facade: correct, forget, link, and the staged write path prepare/validate_plan/commit. Mock implementations must be deterministic and sufficient for service-free smoke runs. Follow the contract shape the Task_4 revision lands (A6). +- acceptance: + - Contract methods mirror `character_memory` facade semantics (append-only correction: supersession/suppression, no deletion). + - Mock implements all new methods deterministically with unit tests. + - Existing LongMemEval/LoCoMo code compiles unchanged (default-method or explicit-impl strategy chosen deliberately). +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + +### Task_6: [CME] Live adapter: new contract methods + deterministic-provider enforcement + persistent-store config + +- type: impl +- owns: + - (CME repo) live adapter + config modules per post-revision layout (pre-revision: crates/cmem-eval-runner/src/real_adapter.rs, crates/cmem-eval-core/src/config.rs) +- depends_on: [Task_5] +- description: | + Implement the new contract methods on the live CharacterMemory adapter (correct/forget/link/prepare/validate_plan/commit) with external-id round-trip mapping; correct candidate provenance on plan-path writes (ADR-I-0015). Add continuity dataset-kind config validation that hard-rejects non-deterministic embedding providers. Add persistent-store configuration (Oxigraph persistent path, sqlite stats path) and an adapter reconstruct path for restart scenarios (drop + rebuild CharacterMemory over the same stores, re-associating ids via the public API per the Task_2 finding). +- acceptance: + - All new facade operations round-trip external ids against live stores. + - Continuity config with a non-deterministic provider fails validation loudly. + - Adapter reconstructs against existing persistent stores and re-associates ids using only the public API. +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + - kind: test + required: true + owner: worker + detail: "[CME] Live smoke vs local Qdrant: staged write + correct + forget + link round-trip in a scratch namespace" + +### Task_7: [CME] Controllable-similarity deterministic embedding provider + +- type: impl +- owns: + - (CME repo) new deterministic-embedding module per post-revision layout +- depends_on: [Task_4] +- description: | + New deterministic `EmbeddingProvider` whose similarity structure is fixture-controllable: fixtures declare concept/cluster assignments so scenario authors can pin near/far relationships (fixture-declared cluster vectors + deterministic seeded noise). Seeded, no I/O, stable across runs/platforms (sorted iteration, no platform-dependent float paths). Small configurable vector_size for fixture compactness. Entity-neutral. The existing hash-bucket provider stays for LongMemEval/LoCoMo compatibility. +- acceptance: + - Identical inputs + seed => byte-identical embeddings across two process runs. + - Cosine ordering between fixture-declared concept pairs is controllable and verified by property tests. + - No role/name special-casing. +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + +### Task_8: [CME] Continuity fixture schema, seeded generator, scenario library + +- type: impl +- owns: + - (CME repo) continuity dataset crate (fixture + generator modules) + checked-in fixture data + workspace-member addition +- depends_on: [Task_4, Task_7] +- description: | + Scaffold the continuity dataset crate in the post-revision layout. Fixture schema (serde, versioned): scripted interaction events with caller-provided timestamps (months-scale gaps), heterogeneous role-free entity declarations (≥3 entity kinds with high-degree hubs), embedding concept assignments (Task_7), per-query expected-relevance labels, stable fixture IDs, stable namespace/collection names. Seeded generator + scenario library covering phase-doc patterns: long-gap recall, recurring hub entity, selective entity, correction chains (supersession/suppression), thread drift, temporal structure, mixed-salience accumulation, cross-store stress (restart between write and retrieve; stats reopen; graph reopen). Generated fixtures checked in; generator determinism tested. No DSL beyond scenario needs. +- acceptance: + - Same seed => byte-identical fixture set (two-run identity test). + - All listed scenario patterns present; hub scenarios span ≥3 entity kinds. + - No fixture rule special-cases roles or entity names. + - Per-query relevance labels present for recall- and pollution-scored scenarios. +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + +### Task_9: [CME] Continuity driver loop + `run continuity` subcommand + +- type: impl +- owns: + - (CME repo) continuity driver module + runner subcommand wiring + runner dependency additions +- depends_on: [Task_6, Task_8] +- description: | + Fixture-scripted deterministic driver exercising the full adapter contract: remember, prepare/validate_plan/commit, retrieve, correct, forget, link — no meaning inference (ADR-I-0013), every action scripted by the fixture. Captures full retrieval traces/rationale/telemetry per query in serializable form. Wire a `run continuity` subcommand following the existing dataset-run pattern, supporting mock (service-free smoke) and real adapters. +- acceptance: + - All eight facade operations exercised across the scenario library. + - Driver reads no wall-clock of its own; deterministic given fixture + config. + - Mock smoke run completes service-free; live run completes vs local Qdrant. + - Per-query traces captured for metric computation. +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + - kind: test + required: true + owner: worker + detail: "[CME] Mock smoke run (service-free) + live one-scenario run vs local Qdrant; doubles as Qdrant run-to-run reproducibility probe (two runs, diff reports)" + +### Task_10: [CME] Seven continuity metrics + registry integration + +- type: impl +- owns: + - (CME repo) continuity metrics module + additive result/summary/registry extensions in the shared core +- depends_on: [Task_3, Task_9] +- description: | + Implement phase-doc §7 metrics over captured traces + fixture labels: 7.1 continuity recall@k by gap-length bucket (reuse recall machinery); 7.2 entity continuity without flooding (context-pack share, hub-expansion hits, fanout utilization vs cap); 7.3 temporal retrieval quality; 7.4 correction safety (zero suppressed/superseded admitted; supersession replacement — extend existing leakage metrics); 7.5 rationale quality (coverage + category distribution); 7.6 context pollution rate attributed by rationale category; 7.7 fanout discipline (zero over-budget expansions, conservative-fallback activations, selectivity distributions). Integrate with `metric_support`/registry semantics (null, never false zero). Deterministic ordering (BTreeMap/sorted). Measurements, not pass/fail gates. Entity-neutral. +- acceptance: + - Each §7 metric implemented with a unit test vs hand-computed expectations. + - Metric code references only fixture labels + telemetry; no entity special-cases. + - Selectivity/fanout outputs usable for v0.1.5 default tuning (values + config snapshot correlation). +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + +### Task_11: [CME] Restart measurement phase + report format + +- type: impl +- owns: + - (CME repo) continuity restart-orchestration + report-assembly modules +- depends_on: [Task_6, Task_9] +- description: | + Restart orchestration for cross-store scenarios: mid-scenario drop + reconstruct of CharacterMemory over persistent Oxigraph + sqlite stats (Task_6 reconstruct path), post-restart retrieval re-measurement, restart deltas recorded. Continuity report assembly: run metadata (fixture set + seeds, config snapshot incl. selectivity/fanout knobs, schema versions, timestamp isolated in a metadata block), per-scenario + aggregated metrics, per-query rationale samples, fanout decisions, stats health events, restart observations. Reports diffable across runs: stable key order, nondeterminism confined to the metadata block. Reuse JSONL/summary writer infra. +- acceptance: + - Two consecutive runs on identical fixture + config produce reports identical outside the metadata block. + - Report includes all phase-doc report-format elements. + - Restart scenarios record post-restart observations against persistent stores. +- validation: + - kind: command + required: true + owner: worker + detail: "[CME] cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace" + - kind: test + required: true + owner: worker + detail: "[CME] Live end-to-end run incl. restart scenarios vs local Qdrant + persistent Oxigraph/sqlite; two-run report diff empty outside metadata block" + +### Task_12: [CME] Configs, README, and extension documentation + +- type: docs +- owns: + - (CME repo) continuity run configs + README continuity section (+ crate README if house style splits docs) +- depends_on: [Task_10, Task_11] +- description: | + Run configs for continuity (deterministic provider mandatory, small vector_size, persistent-store paths for restart scenarios). README: prereqs (local Qdrant; persistent paths), commands, report location/reading, extending the scenario library and metrics. Non-anthropomorphic tone. State explicitly: the harness observes and reports; it is not a CI gate; metric thresholds are not pass/fail. +- acceptance: + - A newcomer can run mock smoke + live continuity eval from docs alone. + - Extension guide covers adding a scenario and adding a metric. +- validation: + - kind: review + required: true + owner: reviewer + detail: "Docs accuracy vs implemented CLI/config/report format" + +### Task_13: [CM] Version bump and roadmap bookkeeping + +- type: chore +- owns: + - Cargo.toml (version field only) + - docs/roadmap/development_roadmap.md (version table row status + harness-location note only) +- depends_on: [Task_10, Task_11] +- description: | + Bump `character_memory` version 0.1.2 -> 0.1.4 (covers finished v0.1.3 + this milestone; user-approved). Update the roadmap version table row for v0.1.4 at closeout. Where the roadmap notes the harness location, follow A8 wording: the continuity evaluation harness is implemented in the private companion CharacterMemoryEvals repository (a development aid, not core library functionality) — so future readers neither assume it is unimplemented nor look for it in this repository. No machine-local paths. Tagging policy stays with the user. +- acceptance: + - Cargo.toml version = 0.1.4; lockfiles re-resolve cleanly in both repos. + - Roadmap reflects v0.1.4 status and harness location per A8 wording. +- validation: + - kind: command + required: true + owner: worker + detail: "[CM] cargo check; [CME] cargo check (path dep re-resolves cleanly)" + +### Task_14: Independent review and acceptance verification (both repos) + +- type: review +- owns: [] +- depends_on: [Task_12, Task_13] +- description: | + Reviewer independently verifies all phase-doc acceptance criteria (lines 304–317) with evidence per criterion; reruns the two-run reproducibility check (reports identical outside metadata block); runs the ADR-I-0018 dependency-direction audit over all [CM] src/ diffs; verifies [CME] changes import character_memory only through the public API; checks entity-neutrality of fixtures/metrics; confirms no library behavior/default change (existing [CM] test suite green and unmodified except mechanical additions); scans all [CM] committed artifacts from this plan for machine-local absolute paths and A8 wording compliance. +- acceptance: + - Reviewer status APPROVED with per-criterion evidence. + - Dependency-direction audit clean in [CM]; public-API-only usage confirmed in [CME]. + - A8 compliance confirmed: no local paths in [CM] artifacts; private-repo wording present where [CME] is mentioned. +- validation: + - kind: test + required: true + owner: reviewer + detail: "Independent two-run reproducibility check vs local Qdrant + persistent stores" + - kind: review + required: true + owner: reviewer + detail: "Acceptance-criteria checklist + dependency-direction audit + entity-neutrality check + A8 path/wording scan" + +## Task Waves (explicit parallel dispatch sets) + +Interpretation: tasks in the same wave dispatch in parallel by default when `owns` are disjoint and dependencies are met; waves execute sequentially. Cross-repo tasks ([CM] vs [CME]) never share owns. Task_4 is a gate: its wave completes only when the CME architecture-revision plan (its own plan, in that repo) is done. + +- Wave 1 (parallel): [Task_1, Task_2, Task_4] +- Wave 2 (parallel): [Task_3, Task_5, Task_7] +- Wave 3 (parallel): [Task_6, Task_8] +- Wave 4 (parallel): [Task_9] +- Wave 5 (parallel): [Task_10, Task_11] +- Wave 6 (parallel): [Task_12, Task_13] +- Wave 7 (parallel): [Task_14] + +Note: Task_4's research can start immediately (Wave 1), but Wave 2's [CME] tasks (Task_5, Task_7) wait for the full Task_4 gate (revision plan approved + executed), which may span multiple working sessions. [CM] Task_3 proceeds independently. + +## Rollback / Safety + +- [CME] changes are additive (new crate + contract/adapter/config extensions) on top of whatever the architecture revision lands; rollback = revert the feature branch there. +- Task_3 is the only [CM] src/ touch; additive-only with pre-existing tests required to pass unchanged — clean revert. +- No changes to library defaults, retrieval behavior, or public facade methods. +- Eval collections/namespaces use distinct stable names — no collision with LongMemEval/LoCoMo namespaces or [CM] integration-test collections. + +## Progress Log (append-only) + +- 2026-07-05 12:15 Wave 1 (partial) completed: [Task_1, Task_2] + - Summary: Task_1 done — ADR-I-0019 created + decisions README index updated; worker validation pass (cargo fmt --check && cargo check), orchestrator review pass (A8 wording/path scan clean). Task_2 done — findings appendix appended to this plan; orchestrator review pass; Task_3 scope confirmed non-empty (three additive telemetry items; no time seam; no facade method). + - Validation evidence: worker YAML reports (Task_1: fmt/check pass, path/wording rg scan pass); Task_2 appendix content verified in-plan. + - Notes: Task_4 gate remains open — CME architecture-revision plan drafted in the CME repo, awaiting user approval there. Task_3 dispatched (see Decision Log entry on restart identity). +- 2026-07-05 13:20 Execution paused by user (host resource pressure from codex-monitor watchers). + - Summary: codex-monitor watcher processes killed; no further dispatch until resume. In flight at pause: [CM] Task_3 (worker, report pending — will be integrated on arrival, no new dispatch). CME plan approved and in_progress; its Task_1 NOT yet executed — first dispatch attempt was blocked by worker sandbox rooted in the [CM] checkout; a CME-rooted worker (worker3 registered, session not yet spawned) or a sandbox-scope decision is needed at resume. + - Validation evidence: n/a (operational note). + - Notes: resume checklist — (1) restart codex-monitor watchers for active worker threads (endpoint/thread ids may change), (2) resolve CME worker hosting, (3) dispatch CME Task_1, then continue waves. +- 2026-07-05 13:25 Task_3 completed (report integrated post-pause; no new dispatch). + - Summary: additive serde-defaulted telemetry landed — FanoutUtilizationTrace (retained/omitted counts around existing graph-expansion pruning) and RationaleCategory (stable enum populated from existing structural decisions); re-exported from crate root; no facade/ranking/default changes. Scope item 3 (fallback event detail) intentionally not implemented: SelectivityTrace.fallback (per-decision) + telemetry fallback_count (aggregate) already suffice — judgment accepted. Worker also fixed the Qdrant skip predicate in tests/support/base.rs to match the real gRPC connection-refused error shape (within owns). + - Validation evidence: fmt/check/clippy(-D warnings)/test --no-run pass; cargo test pass (339 lib tests, 3 ignored live-service; Qdrant-dependent integration tests exercised the skip-return path — local Qdrant was down, so live integration remains for Reviewer/live runs). + - Notes: Reviewer-owned required validation for Task_3 (additive-only diff review + ADR-I-0018 dependency-direction audit) is PENDING — deferred by the pause; must run before Task_3 is closed at the Validation Gate. Worker lesson candidate promoted to repo rule (worker.md): match concrete client error shapes in skip-if-unavailable predicates. + +- 2026-07-11 12:15 Resume + Task_3 Reviewer validation completed — Task_3 CLOSED. + - Summary: execution resumed. Reviewer status APPROVED on the Task_3 working-tree diff: additive-only confirmed (pruned link set byte-identical via delegation to unmodified `apply_fanout_limits`; new fields serde-defaulted with legacy-payload deserialization test; facade unchanged — exactly the eight public methods); ADR-I-0018 dependency-direction audit clean (no new inverted edges; no ports/policy import of usecases); absolute-path scan clean. + - Validation evidence: cargo fmt --check pass; cargo check pass; cargo clippy --all-targets -D warnings pass; cargo test --no-run pass; cargo test pass (lib 339/0 failed/3 ignored; integration 25 passed/0 failed, Qdrant tests via pass-or-skip path). + - Notes: three MINOR non-blocking follow-ups recorded for the cleanup backlog: (1) fanout utilization computed even when no trace requested (since implemented in e58434c — trace-gated; see the 2026-07-11 20:10 entry), (2) pre-existing ports/policy imports of domain types via `crate::api::types` instead of `crate::domain` — one-time sweep candidate per ADR-I-0018, (3) doc comment on `FanoutUtilizationTrace::selected_cap` clarifying global-truncation interaction. Reviewer lesson candidate promoted to repo rule (orchestrator.md): run the ADR-I-0018 audit diff-scoped for incremental reviews. [CM] Wave 1–2 work now fully validated; changes remain uncommitted pending the commit/PR stage. Task_4 gate still open: cdxm CLI found missing from the machine at resume (source checkout deleted); CME worker hosting decision (reinstall cdxm vs agmsg codex bridge/spawn vs claude-code worker) sent to user via agmsg — CME Task_1 dispatch blocked on that answer. + +- 2026-07-11 19:10 Task_4 gate CLOSED: the [CME] architecture-revision plan is completed (its Task_8 independent review APPROVED; plan moved to its plans/completed/). The revised state lives on the CME feature branch eval-harness-architecture-revision, green under the full pinned gate; merge to CME main is pending the user's PR decision. Post-revision layout facts for Tasks 5-12 owns: live adapter crate crates/cmem-eval-adapter-cmem; adapter contract in crates/cmem-eval-core/src/memory_adapter.rs (Character-Memory-shaped main trait); DatasetSpec seam in crates/cmem-eval-runner/src/pipeline.rs; continuity crate home documented as crates/cmem-eval-continuity; reports schema_version 1.0.0 with segregated latency; restart identity via BTreeMap ExternalIdRegistry + deterministic (prefix, run_id, namespace) collection naming with open/reattach lifecycle. + +- 2026-07-11 20:10 PR #59 review-fix cycle complete. Copilot raised six findings: two (new public fields on trace structs technically breaking exhaustive construction) resolved by policy reply on the PR (accepted per user-approved A2, pre-1.0 crate, consumers compile green, serde-compat tested; non_exhaustive rejected because the evals repo constructs trace fixtures); four fixed in commit e58434c by the CM codex worker — fanout-utilization computation now gated behind a crate-private default-false query flag (untraced retrievals take the original fast path; closes the Task_3 review backlog item), stale-reason mapping computed once per site, skip-predicate early return. Independent Tier D review (cm-reviewer, per the new delegation routing) APPROVED with zero findings: flag surface pub(crate)-only, call-site census proves no include_trace path omits utilization, parity regression compares full expansion content on a pruning-active fixture, predicate boolean-equivalent, diff-scoped ADR-I-0018 audit clean; full validation green with live Qdrant integration tests (340 unit + 25 integration, no skips). Worker push was blocked by codex approval layer — orchestrator pushes by default for CM tasks now. + +## Decision Log (append-only; re-plans and major discoveries) + +- 2026-07-11 Decision: Task_4 gate criteria clarified after PR review flagged a contradiction (gate recorded closed while CME main-green acceptance was unmet). Revised criteria: the gate's substantive conditions (revision plan completed per its own lifecycle, independent review APPROVED, full validation green on the revision state, continuity-stakeholder requirements satisfied) are met on the CME feature branch with its PR open and hosted CI green; the residual condition — merge to CME main — is user-controlled and remains a HARD PRECONDITION for dispatching any [CME] feature task of this plan (Tasks 5–12). The gate is therefore "satisfied pending merge": [CM]-side tasks may proceed; no [CME]-side task dispatches until the CME PR merges and main is green. Rationale: the merge decision belongs to the user, and holding [CM]-side design work hostage to it serves no safety purpose; the original acceptance intent (no feature work on an unrevised/unverified CME base) is preserved by the dispatch precondition. +- 2026-07-11 Decision: re-scoping after the Task_4 gate closed (A6 replan check). The CME revision ALREADY LANDED the substance of this plan's Task_5 (adapter contract correct/forget/link + staged writes with mock parity — CME revision Task_2) and most of Task_6 (live adapter methods, external-id round-trip mapping incl. ADR-I-0015 provenance, reattach lifecycle — CME revision Tasks 2-3, with live Qdrant reattach evidence). Plan delta: Task_5 here closes as satisfied-by-CME-revision after an acceptance cross-check at dispatch time; Task_6 re-scopes to residual gaps only — continuity dataset-kind config validation hard-rejecting non-deterministic embedding providers, and persistent Oxigraph/sqlite-stats path configuration for restart scenarios (the CME reattach work exercised Qdrant; graph/stats persistence config likely remains). Wave structure otherwise intact; Task_7 (controllable-similarity provider) unaffected. User approval: within the already-approved plan direction; no owns/acceptance change beyond narrowing. +- 2026-07-05 Decision: initial draft from [CM]-side research (phase doc, philosophy, ADRs, module tree, CI). Recommended in-repo `evals/` crate. + - User approval: superseded by the revisions below. +- 2026-07-05 Decision: plan revised after user direction + [CME] survey. + - Trigger / new insight: user redirected harness home to the existing sibling evals repo (directory `CharacterMemoryEvals`); survey found a pure-Rust workspace with reusable runner/report/metric infra and a path dependency on character_memory. + - Plan delta: harness re-homed to [CME] as a continuity dataset crate; delegation boundary defined (LongMemEval/LoCoMo cover none of the continuity metrics/facade coverage/restart — delegate infra only); adapter-contract expansion chosen over facade-direct driving (A6); restart-identity audit added to Task_2; additive telemetry approved (A2); version bump approved (A3). + - Tradeoffs considered: in-repo evals/ crate (rejected: duplicate infra, established home exists); expanding the adapter contract vs bypassing it (chosen: house-consistent, keeps mock smoke coverage). + - User approval: superseded by the revision below. +- 2026-07-05 Decision: second revision after user answers. + - Trigger / new insight: (a) [CME] cleaned up and on main (former Q1 resolved); (b) public-API restart-identity approach approved (former Q2 resolved); (c) new user constraints — no machine-local absolute paths in [CM] committed artifacts, private-repo-aware wording for public readers ([CME] stays private; eval tooling is a development aid, not core functionality); (d) new gating requirement — [CME] architecture revision, planned and executed in that repo under its own plan, before continuity features land there. + - Plan delta: added Task_4 architecture-revision gate (renumbering Tasks 5–14); all [CME] feature tasks now depend on Task_4; [CME] owns paths marked "per post-revision layout"; A8 wording/path constraint added and threaded through Task_1/Task_2/Task_13/Task_14 acceptance; repo rule added to `docs/coding-agent/rules/common.md` (sibling-repo reference + wording rules, local-path-free); open questions cleared. + - Tradeoffs considered: folding the architecture revision into this plan (rejected: user directed it to run in [CME] under its own plan; also keeps this plan's owns/wave structure stable against unknown post-revision layout). + - User approval: yes (2026-07-05, "Go for it") — plan status moved to in_progress; Wave 1 dispatched. +- 2026-07-05 Decision: restart-identity mechanism refined per Task_2 audit. + - Trigger / new insight: audit found pure store-side re-association infeasible via public API (no lookup/enumeration facade methods; retrieval rediscovery is relevance-dependent). However, public draft types accept caller-supplied MemoryIds, and RememberOutcome returns persisted ids — so the harness can use stable fixture-derived MemoryIds at write time (plus externally persisted mapping as belt-and-braces), making post-restart re-association unnecessary. + - Plan delta: restart identity = caller-supplied deterministic MemoryIds + harness-persisted mapping as primary mechanism; store-side rediscovery via retrieval used only as verification. No facade change needed — stays within the user-approved public-API direction. CME architecture plan assumption A1 updated to match. Task_3 confirmed scope: (1) per-relation/object fanout utilization telemetry, (2) stable rationale-category enum per included/omitted object, (3) optional stats health/fallback event detail — all optional, serde-defaulted, additive-only; explicitly NO reference-time seam, NO facade methods. Correction/forget mutation timestamps use wall-clock — CME reports must normalize/exclude mutation metadata from diffable content (noted for Tasks 9–11). + - Tradeoffs considered: facade lookup method (rejected: violates no-new-facade constraint; unnecessary given caller-supplied ids); retrieval-only rediscovery (rejected as primary: incomplete by design). + - User approval: within previously approved direction; no new approval needed. + +## Notes + +- Risks: + - The Task_4 architecture revision may change [CME] crate boundaries or the adapter-contract shape; Tasks 5–12 owns are declared "per post-revision layout" and re-scope via the Decision Log if the revision lands a materially different structure (replan trigger, per A6). + - Qdrant run-to-run reproducibility assumed, verified early (Task_9 two-run probe); if unstable, report diffing may need tolerance comparison — replan trigger. + - Wall-clock reads in retrieval scoring (Task_2) may force a reference-time seam; if a facade-visible parameter is unavoidable, that is a replan + user decision. + - Restart identity re-association may prove infeasible via public API alone (Task_2 audit) — replan trigger with a facade-level proposal. + - Cross-repo path dependency: [CME] silently tracks the [CM] working tree; sequence [CM] Task_3 before [CME] Task_10 (metrics consume new telemetry fields), which the waves enforce. +- Edge cases: + - Months-scale timestamp gaps interacting with recency scoring. + - Correction chains interleaving supersession and suppression on hub entities. + - Stats store unhealthy/reopened mid-scenario (conservative fallback activation). + - Restart with in-memory modes misconfigured (must fail validation, not silently measure nothing). + +## Task_2 Findings Appendix + +### Time Audit + +Production retrieval/scoring path verdict: months-scale-gap fixtures can use caller-provided timestamps end-to-end for stored memory time fields; no retrieval reference-time seam is currently needed. Retrieval ranking in `src/usecases/retrieve.rs` combines vector score, graph proximity, and salience only; it does not compute age/recency from wall-clock time. Temporal-quality metrics therefore need fixture labels and stored timestamps, not "now". + +Wall-clock reads found: + +| Location | Path role | Finding | Verdict | +|---|---|---|---| +| `src/usecases/retrieve.rs` | Retrieval pipeline | No `Utc::now`, `SystemTime::now`, `Local::now`, or `Instant::now` production reads. Retrieval builds traces/telemetry from caller context, vector search, graph expansion, lifecycle filters, and selectivity stats. | No reference-time seam needed for retrieval. | +| `src/policy/retrieval_selectivity.rs` | Fanout/selectivity scoring | No wall-clock reads. Scoring uses persisted stats counters, configured alpha/gamma, candidate vector score, and lifecycle count scope. | No reference-time seam needed for scoring. | +| `src/policy/graph_expansion.rs` | Bounded graph expansion | No production wall-clock reads. Timeout behavior is modeled from `GraphExpansionFailurePolicy.timeout_ms`; the only `Utc::now` hit at `src/policy/graph_expansion.rs:1106` is in a test helper constructing `MemoryLink`. | No reference-time seam needed. | +| `src/adapters/qdrant/store.rs:826` | Qdrant adapter tests | `Instant::now()` appears in an idle-gap canary test around `upsert_points`, not in production retrieval/scoring. | Not relevant to fixture determinism. | +| `src/api/types/draft.rs:23` | Draft default construction | `DraftDefaults::generated()` uses `Utc::now()`, but callers can use explicit draft timestamps and lower-level fixed defaults; public draft fields expose `created_at`, `updated_at`, `started_at`, `ended_at`, `observed_at`, and `last_touched_at` where applicable. | Harness should supply timestamps/IDs explicitly for deterministic writes. | +| `src/usecases/write_planning.rs:33` | Write-plan defaults | `RememberPlanDefaults::generated()` uses `Utc::now()` for facade `prepare`; fixed defaults exist internally, while public `RememberInput` exposes event timestamps but not operation `created_at`. | No retrieval seam; deterministic plan bytes may require CME-side stable IDs/timestamps through draft/commit paths rather than public `prepare`. | +| `src/usecases/correct_forget.rs:881`, `:917`, `:927`, `:942` | Lifecycle mutation writes | Correction/forget mutation timestamps use `Utc::now()` for replacement/update/link metadata. | Not retrieval scoring; if report diffing includes mutation metadata, CME should normalize metadata or Task_3 should add optional telemetry/report exclusions rather than change behavior. | + +### Metrics Computability + +Public telemetry surface checked: `src/api/types/retrieval.rs` exposes `RetrieveOutcome`, `ContinuityContextPack`, `RetrievalRationale`, `RetrievalTelemetry`, `RetrievalTrace`, `VectorCandidateTrace`, `GraphRelationTrace`, `GraphExpansionTrace`, `SelectivityTrace`, `LifecycleFilterDecision`, `StaleCandidateOmission`, and `SectionAssignment`. Fixture labels can supply expected relevance sets, gap buckets, temporal query kind, hub ids, scenario ids, and expected replacement ids. + +| Metric | Inputs needed | Available from public telemetry + fixture labels? | Gap | +|---|---|---|---| +| 7.1 Continuity recall@k by gap bucket | Query label, expected relevant `MemoryId` set, returned pack members, rank/order, fixture gap bucket. | Yes. Pack members expose IDs by section; trace `section_assignments` gives final section and rank-like assignment details; fixture labels supply expected sets and gap buckets. | None for recall@k. Use deterministic report-side ordering by section then trace assignment/rank when needed. | +| 7.2 Entity continuity without flooding | Hub id/kind labels, context-pack membership, hub-incident labels, graph expansion hits, fanout budget utilization vs cap per relation/object pair. | Partial. Pack and `graph_relations` can identify hub-incident retrieved objects; `SelectivityTrace` exposes relation, object type, chosen fanout, and max fanout; `GraphExpansionTrace` exposes aggregate object/relation counts. | Missing exact per-relation/object actual utilization after graph expansion. Current trace shows budget and aggregate expansion counts, but not per relation/object retained edge counts, omitted-by-fanout counts, or cap utilization per pair. | +| 7.3 Temporal retrieval quality | Temporal query type labels, expected relevant IDs, stored object timestamps, returned pack/trace. | Yes, for labeled correctness. Domain objects in the pack carry public timestamps (`Episode.started_at/ended_at`, `Observation.observed_at`, `MemoryThread.last_touched_at`, object `created_at/updated_at` where applicable), and labels define recency/order/interval expectations. | None for label-based correctness; no retrieval-internal temporal signal category exists to explain why a temporal result was admitted. | +| 7.4 Correction safety | Lifecycle labels, suppressed/superseded IDs, returned pack, lifecycle decisions, replacement mapping. | Yes. `LifecycleFilterDecision` exposes retention state, currentness, superseded_by, action, and reason; pack IDs reveal admitted objects; correction outcomes expose mutation IDs during the run. | None for safety/admission metrics. | +| 7.5 Rationale quality | Per-pack-member rationale presence and rationale category distribution across semantic/entity/thread/temporal/salience/scope. | Partial. `RetrievalRationale.summary` exists; `SectionAssignment.reason` is optional free text; trace distinguishes vector candidates, graph relations, lifecycle, stale omissions, selectivity, and section assignment. | Missing stable rationale category taxonomy per included object. Free-text `reason` and structural trace categories are insufficient for the exact requested category distribution. | +| 7.6 Context pollution rate | Expected relevant labels, pack members, rationale category per admitted noise item. | Partial. Pollution share is computable from pack IDs vs fixture labels. | Pollution attribution by rationale category has the same missing taxonomy as 7.5. | +| 7.7 Fanout discipline | Over-budget expansions, conservative fallback activations, selectivity distributions by entity kind. | Partial. `SelectivityTrace` exposes score, entity/global counts, chosen/max fanout, decision, fallback, relation, object type, and count scope; telemetry exposes fallback counts; `GraphExpansionTrace` exposes bounded failure reason/outcome. | Missing explicit per-relation/object actual fanout utilization and omitted-over-budget counts. Conservative fallback activation is available when traces are enabled; if traces are disabled only aggregate fallback count remains. | + +### Restart Identity Audit + +Verdict: external harness re-association after dropping and reconstructing `CharacterMemory` over the same persistent stores is not fully feasible using only the public `CharacterMemory` API, unless the harness persists the `MemoryId` mapping externally at write time and treats that mapping as authoritative across restart. + +Evidence: + +- Public write inputs allow caller-supplied IDs: `EntityDraft.id`, `EpisodeDraft.id`, `ObservationDraft.id`, `MemoryThreadDraft.id`, `DerivedMemoryDraft.id`, and `MemoryLinkDraft.id` in `src/api/types/draft.rs`; `RememberOutcome` returns `persisted_object_ids` and `persisted_link_ids`. +- Public retrieval outputs expose IDs and retained external references through pack objects and traces (`src/api/types/retrieval.rs`). Episode/observation `raw_ref` and episode `source_conversation_id` are public domain fields (`src/domain.rs`), so a query that retrieves the relevant object can rediscover its ID. +- Public facade methods in `src/memory.rs` are limited to `prepare`, `validate_plan`, `commit`, `remember`, `link`, `retrieve`, `correct`, and `forget`. There is no public method to look up by external fixture id/raw ref, list all objects, list diagnostic graph objects, or query by `MemoryObjectRef`. +- Diagnostic graph/vector listing exists only behind crate-internal ports/usecases, not the public facade. + +Blocking gap: after restart, if the harness has lost its external-id-to-`MemoryId` map, the public API cannot enumerate or directly query persisted objects by external id/raw ref to rebuild the map. Retrieval can opportunistically surface some IDs, but it is relevance-dependent and cannot guarantee complete re-association for all fixture objects/links. + +Feasible public-API pattern without library changes: CME should generate stable fixture `MemoryId`s and supply them in drafts, or persist the mapping it receives from write outcomes in its own report/state. On restart, reuse that external mapping and verify persistence by retrieving scripted objects and checking returned IDs/lifecycle traces. This satisfies restart measurement if the external mapping survives the adapter reconstruction; it does not solve re-association from stores alone. + +### Additive-Only Change List For Task_3 + +Recommended Task_3 scope: + +1. Add optional, serde-defaulted public telemetry for per-relation/object fanout utilization on retrieval traces, including root, relation, object type, configured cap, selected cap, retained count, and omitted-by-fanout count where the graph expansion layer can report it without behavior changes. +2. Add optional, serde-defaulted public rationale category data per included or omitted object using a stable enum covering at least semantic, entity, thread, temporal, salience, scope, lifecycle, and graph_bound categories. Populate from existing structural decisions only; do not change ranking or section assignment. +3. Add optional, serde-defaulted retrieval stats health/fallback event details if conservative fallback needs to be attributed beyond `SelectivityTrace.fallback` and aggregate `fallback_count`. +4. Do not add a retrieval reference-time seam in Task_3. +5. Do not add a new public facade method for restart identity unless the Orchestrator replans Task_3; the additive public telemetry work is independent, while restart re-association should be handled in CME through stable fixture IDs or external mapping persistence. diff --git a/docs/coding-agent/rules/common.md b/docs/coding-agent/rules/common.md index e2057c0..43d9a12 100644 --- a/docs/coding-agent/rules/common.md +++ b/docs/coding-agent/rules/common.md @@ -1,10 +1,16 @@ # Common Repository Rules -last_updated: 2026-07-02 +last_updated: 2026-07-11 ## Repository Reference Documents -- None yet. +- Evaluation harnesses live in a separate private companion repository, `CharacterMemoryEvals` (checked out as a sibling directory of this repo; note the directory name is `CharacterMemoryEvals`, not `character-memory-evals`). It is a Rust workspace consuming this crate via a path dependency. Evaluation tooling exists there — do not assume it is unimplemented — but it is not part of this library's core functionality and the repo is not publicly accessible. + +## Repo Documentation Wording + +- Committed artifacts in this repository must not contain machine-local absolute paths (for example user-profile paths); refer to sibling repositories by name and relative relationship instead. +- When mentioning the private `CharacterMemoryEvals` repository in committed docs, word it so public readers are not confused by being unable to access it: state that it is private and that evaluation tooling is a development aid, not core library functionality. +- Do not hard-wrap prose in committed documents: never insert line breaks mid-sentence to fit a column width. Write each sentence/paragraph/list item as one line and let editors soft-wrap. Structural line breaks (list items, headings, YAML keys, code) are fine. ## Repository-Specific Validation Commands diff --git a/docs/coding-agent/rules/orchestrator.md b/docs/coding-agent/rules/orchestrator.md index de07328..aa5fc29 100644 --- a/docs/coding-agent/rules/orchestrator.md +++ b/docs/coding-agent/rules/orchestrator.md @@ -1,12 +1,20 @@ # Orchestrator Repository Rules -last_updated: 2026-07-04 +last_updated: 2026-07-11 ## Repo-Specific Orchestrator Policies - When creating or updating a PR, follow the format specified in `.github/pull_request_template.md`. - Layer-boundary reorganizations must include a `use crate::` dependency-direction audit as required Reviewer evidence (errors/domain/ports/policy must not import api or usecases); file-placement conformance alone does not catch inverted edges hidden behind re-export shims. -- When requesting Copilot PR re-review, try the normal reviewer path first with PowerShell-safe quoting: `gh pr edit PR_NUMBER --add-reviewer '@copilot'`. If Copilot has already reviewed the PR and that command returns success without starting a review, use GitHub GraphQL `requestReviewsByLogin` with `userLogins: ["copilot-pull-request-reviewer"]`, then verify `reviewRequests` or a new `latestReviews` entry instead of trusting the `gh pr edit` exit code alone. +- Scope the ADR-I-0018 dependency-direction audit to the diff under review (e.g. `git diff | grep '^+.*use crate::'`) when reviewing incremental changes: pre-existing ports/policy imports of domain types via `crate::api::types` are grandfathered debt awaiting a one-time sweep to `crate::domain`, and a blanket grep forces per-line disambiguation between old and newly introduced edges. +- When requesting Copilot PR re-review, try the normal reviewer path first with PowerShell-safe quoting: `gh pr edit PR_NUMBER --add-reviewer '@copilot'`. If Copilot has already reviewed the PR, that command returns success WITHOUT starting a review (verify with `gh pr view PR_NUMBER --json reviewRequests` — empty means it no-opped). The working fallback (verified 2026-07-11) is REST: `gh api repos/OWNER/REPO/pulls/PR_NUMBER/requested_reviewers -f 'reviewers[]=copilot'` — the login is plain `copilot`; `copilot-pull-request-reviewer` is rejected as a non-collaborator, and the GraphQL `requestReviews` mutation no longer accepts `userLogins`. Confirm success by `requested_reviewers` containing `Copilot` in the response. + +## Delegation Routing (model-strength aware platform recommendation; user-approved 2026-07-11) + +- When both Claude and Codex delegation targets are available at runtime, prefer routing by failure mode: if a miss would be a subtle bug or overlooked line, prefer a Codex agent (detail scrutiny); if a miss would be building the wrong thing well, prefer a Claude agent (altitude and lateral judgment). If only one platform is available, any agent may take any role. +- Research: prefer Claude for exploratory research (design-space surveys, alternatives with tradeoffs, cross-repo implications); prefer Codex for forensic research (exhaustive inventories with file:line evidence, call-site censuses, computability tables). +- Review tiers: Tier D defect/compliance review (post-implementation diff correctness, dependency-direction and entity-neutrality audits, serde/schema verification, determinism sweeps, acceptance-evidence checking) prefers a Codex reviewer — never the same agent identity that authored the diff, on any platform. Tier A altitude review (design/plan soundness, goal-achievement and what-will-bite-later review) prefers a Claude reviewer. Routine impl diffs get Tier D only; design docs get Tier A only; milestone gates get both tiers in parallel. +- Implementation prefers Codex workers; give creative-design subtasks a Claude design pass first and hand the implementing worker a spec. ## Repo-Specific Integration / Git Policy diff --git a/docs/coding-agent/skill-candidates.md b/docs/coding-agent/skill-candidates.md new file mode 100644 index 0000000..034c439 --- /dev/null +++ b/docs/coding-agent/skill-candidates.md @@ -0,0 +1,51 @@ +# Harness Migration Candidates + +Repo-local staging for cross-repo harness improvements, per the improvement-loop skill. Entries here are candidates for promotion into first-party harness skills/references; they are not repo rules. + +## 2026-07-11 — Admission-signal truth-table testing for diagnostic attribution [from PR #59 rationale-category defect chain] + +- Symptom: three successive review rounds found provenance/attribution defects in diagnostic telemetry (categories seeded from destination metadata, relation endpoints conflated with admission causes, order-dependent propagation) that the first fix's tests missed. +- Root cause: tests asserted expected positive categories only — no systematic forbidden-category assertions and no permutation tests where input order is semantically neutral. +- Candidate guidance (for a validation/testing reference in the harness): when implementing or reviewing diagnostic attribution (rationale categories, provenance labels, cause tagging), require (1) a truth table covering each admission signal in isolation and in combination, with BOTH positive and forbidden-category assertions per case; (2) permutation-invariance tests wherever processing order is semantically neutral (e.g. same-depth graph edges ordered by ID); (3) an explicit check that no structural metadata (destination section, endpoint membership) doubles as a causal signal. +- Provenance: character-memory PR #59, commits 279891f → 2e4a7fc; findings by Copilot review and Tier D codex review. + +## 2026-07-11 — Choose the data model AFTER deriving the attribution truth table [second lesson from the same defect chain] + +- Symptom: even after adopting truth-table testing, two more defect rounds occurred because each implementation abstraction (score-presence, component closure) was chosen before the complete signal-by-path semantics were written down; the abstraction could not represent rows it was never designed for (side branches, relation-specific categories). +- Candidate guidance: for provenance/attribution features, derive the full test matrix FIRST — positive, forbidden, side-branch, fallback, union, root-exclusion, and permutation rows — then select a data model capable of representing every row (here: per-path signal tracking, not set closure). The truth table is a design input, not just a test artifact. +- Provenance: character-memory PR #59, commits 2e4a7fc -> 89108dd. + +## 2026-07-11 — Producer-set/consumer-set reconciliation for pre-admission telemetry [reviewer-miss triage, PR #59 round 6] + +- Symptom: Tier D review approved pre-hydration fanout telemetry although visibility-layer rows for lifecycle-suppressed intermediate nodes were copied wholesale into the final expansion, where the policy expansion never expanded those nodes. +- Root cause: review verified count timing/value correctness, adapter parity, and absence of double counting, but never reconciled the telemetry PRODUCER set (pre-hydration visibility frontier) against the final eligible CONSUMER set (lifecycle-admitted, actually-expanded nodes). Parity/high-fanout tests used only active nodes, so scope leakage was invisible. +- Candidate guidance (for harness review/diagnostics references): whenever diagnostics or telemetry are computed before hydration, filtering, admission, or dedupe and then attached to a final result, the reviewer must audit that producer-set == final-eligible-set (or that a subset relation is explicitly documented), and require at least one rejected/filtered-candidate negative regression. +- Provenance: character-memory PR #59 round 6; cm-reviewer self-triage after a Copilot catch. + +## 2026-07-11 — Cost-gate table and staged-cardinality binding for optional diagnostics [reviewer-miss triage, PR #59 round 7] + +- Symptom: review approved optional telemetry although (a) the disabled path still paid the full provenance-walk cost, and (b) a fanout omission metric consumed an already hub-truncated list, silently redefining what "omitted" measured. +- Root cause: review verified value semantics of enabled output and final row filtering/parity, but built neither an execution-cost gate table for the disabled path nor an ordered cardinality table across the chained limiters (eligible -> hub cap -> fanout cap). +- Candidate guidance (harness review references): for every optional diagnostic, review BOTH value semantics and disabled-path work (prove the computation itself is gated, not merely its output). For every chained limiter, enumerate producer cardinality at each stage and bind every emitted metric to exactly one named stage before approval, with boundary tests where stages interact. +- Provenance: character-memory PR #59 round 7; cm-reviewer self-triage. + +## 2026-07-11 — Labels are not invariants: prove endpoint-type guarantees before semantic classification [reviewer-miss triage, PR #59 round 8] + +- Symptom: review approved a relation-label => Entity mapping although the domain permits Mentions/Involves/About between non-Entity endpoints; entity-less paths were classified Entity. +- Root cause: review validated mapping exhaustiveness and propagation mechanics but accepted relation-name intuition without proving endpoint-type invariants from domain validation and production constructors. +- Candidate guidance (harness review references): whenever a semantic category is inferred from an enum label, require a truth table against all domain-permitted endpoint/state combinations, and cite the specific validation invariant that makes any label shortcut sound; if no invariant exists, classify from the actual node/state types instead. +- Provenance: character-memory PR #59 round 8; cm-reviewer self-triage after a Copilot catch. + +## 2026-07-11 — Every emittable category needs a paired positive and zero/negative row at the consumer boundary [reviewer-miss triage, PR #59 round 9] + +- Symptom: Salience attribution had absence-style coverage only; a threshold or producer regression could silently remove the category with tests staying green. +- Root cause: review required forbidden/spurious-category rows and broad truth tables, but not at least one production-reachable positive row for every category the classifier can emit, asserted at the final consumer boundary (not helper-level values). +- Candidate guidance (harness validation references): for every enum variant/category a classifier can emit, require one positive row (fixture strictly beyond the production threshold) and one zero/boundary row through the same path, both asserted on the final consumed output. Absence-only coverage is insufficient. +- Provenance: character-memory PR #59 round 9; cm-reviewer self-triage after a Copilot catch. + +## 2026-07-11 — Reconcile against the semantic ACTION set, never the returned set; depth is phase-dependent [reviewer-miss triage, PR #59 round 10] + +- Symptom: post-hydration utilization filtering used returned-object membership, keeping rows for a node returned at max_depth but measured pre-hydration at a shallower depth through a path later suppressed. +- Root cause: the review proved "visibility never measures its own max-depth frontier" but implicitly assumed producer and consumer phases assign the same depth to a shared object; lifecycle filtering of alternate paths can change an object's minimum reachable depth between phases. +- Candidate guidance (harness review references): when reconciling diagnostics across filtering/hydration phases, compare against the exact semantic action set (expanded/executed/persisted), never a broader returned/admitted set — and explicitly test phase-dependent path-rank/depth changes caused by rejected alternate paths. +- Provenance: character-memory PR #59 round 10; cm-reviewer self-triage after a Copilot catch. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 74f2134..92e3637 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -83,3 +83,4 @@ Records in this repository are expected to capture decisions, not undecided prop - [ADR-I-0016: Use retrieval intent as query-time policy](implementation/ADR-I-0016-use-retrieval-intent-as-query-time-policy.md) - [ADR-I-0017: Persist association support, not derived association scores](implementation/ADR-I-0017-persist-association-support-not-derived-association-scores.md) - [ADR-I-0018: Organize the crate into responsibility-boundary modules with enforced dependency direction](implementation/ADR-I-0018-responsibility-boundary-modules-with-enforced-dependency-direction.md) +- [ADR-I-0019: Place the continuity evaluation harness in the private evals repository](implementation/ADR-I-0019-continuity-eval-harness-placement.md) diff --git a/docs/decisions/implementation/ADR-I-0019-continuity-eval-harness-placement.md b/docs/decisions/implementation/ADR-I-0019-continuity-eval-harness-placement.md new file mode 100644 index 0000000..8b9c7bb --- /dev/null +++ b/docs/decisions/implementation/ADR-I-0019-continuity-eval-harness-placement.md @@ -0,0 +1,93 @@ +--- +status: accepted +adr_type: implementation +date: 2026-07-05 +deciders: ["ebigunso"] +consulted: ["GPT-5 Codex"] +informed: [] +supersedes: [] +superseded_by: null +--- + +# ADR-I-0019: Place the continuity evaluation harness in the private evals repository + +## Context and Problem Statement + +The v0.1.4 continuity evaluation harness measures whether the v0.1 substrate supports character continuity before scoped continuity features are built on top of it. The phase plan defines the harness as deterministic evaluation tooling that exercises the public library surface, observes retrieval and lifecycle behavior, and reports measurements without changing library behavior or defaults. + +ADR-I-0018 organized the library into responsibility-boundary modules and set a revisit trigger for roadmap concepts that have no unambiguous home in that layout. The continuity evaluation harness triggers that question: it is operational evaluation tooling, not a library responsibility such as domain modeling, retrieval policy, use-case execution, adapter implementation, or composition. + +The harness repository is private. References to it in this public repository must be explicit enough that public readers understand that the inaccessible repository is intentional, and that the evaluation tooling is a development aid rather than core library functionality. + +## Decision Drivers + +- The library repository should contain core functionality, public API contracts, implementation ADRs, and roadmap documentation, not development-only evaluation products. +- The harness must consume `character_memory` through the same public API boundary an external user would exercise. +- Evaluation fixtures, runner configuration, generated reports, and local service prerequisites should evolve without expanding the library's public surface or module tree. +- Public documentation must avoid machine-local absolute paths and describe private companion repositories by role, not by a local checkout location. +- The placement must leave a clear delegation boundary between this harness and external long-memory benchmarks. + +## Decision + +Place the continuity evaluation harness in the private companion repository `CharacterMemoryEvals` as a dataset crate. The evals repository consumes `character_memory` through the public API using a sibling-checkout path dependency. + +The harness is a development aid. It is not core library functionality, is not part of the public crate API, and does not change library behavior or defaults. This repository records the decision, roadmap intent, and cross-repository contract; the private evals repository owns fixtures, dataset code, runner configuration, metric implementations, generated reports, and harness-specific documentation. + +The delegation boundary is: + +- `character_memory` owns library behavior, public API contracts, configuration semantics, telemetry exposed by the library, and implementation decisions. +- `CharacterMemoryEvals` owns continuity evaluation scenarios, deterministic fixture generation, metric calculation, report assembly, and commands for running the harness. +- External benchmark datasets such as LongMemEval and LoCoMo remain comparison or inspiration points. They do not define the Character Memory continuity harness contract, fixture semantics, or acceptance criteria. + +The versioning implication is cross-repository: the evals repository may pin or update its path dependency to the sibling checkout during development, while this repository's crate version changes only under the normal release policy. Completing the v0.1.4 milestone includes the user-approved crate version bump to `0.1.4`; later harness improvements do not by themselves require library version bumps unless they expose a library behavior, API, or configuration change. + +## Considered Options + +1. Private companion repository dataset crate (`CharacterMemoryEvals`) consuming the library through the public API. +2. Add an in-repository `evals/` directory or workspace crate. +3. Place the harness under `examples/`. +4. Add a feature-gated module inside the library crate. + +## Decision Outcome + +Chosen option: **Option 1**. + +Option 2 keeps the code close to the library but makes development-only fixtures, generated data, runner configuration, and report formats part of this repository's normal maintenance surface. It also makes the harness look like shipped library functionality even though it is a private development aid. + +Option 3 misuses examples: the harness is not a small public demonstration of API use. It requires deterministic scenario data, local service prerequisites, persistent-store restart checks, report generation, and metric interpretation that belong to evaluation infrastructure. + +Option 4 is the wrong boundary. A feature-gated module would place evaluation concerns inside the crate module layout that ADR-I-0018 reserves for core responsibilities, and would invite library behavior or API changes to satisfy harness needs. + +## Consequences + +### Positive + +- The library module layout remains focused on core responsibilities from ADR-I-0018. +- The harness verifies the public API boundary instead of relying on crate internals. +- Development-only fixture and report churn stays out of the public repository. +- Public readers have a clear explanation for why harness implementation references point to a private repository. + +### Negative / Tradeoffs + +- Cross-repository changes need explicit coordination when telemetry, configuration, or public API shapes change. +- Public contributors cannot inspect or run the private harness from this repository alone. +- Documentation in this repository must be disciplined about naming the private repo without implying that it is public or part of the core crate. + +## Validation + +- `CharacterMemoryEvals` imports `character_memory` through the public API boundary. +- The continuity harness observes and reports; it does not modify library behavior or defaults. +- This repository contains no machine-local absolute paths when referring to the private evals repository. +- Roadmap and implementation documentation describe `CharacterMemoryEvals` as a private companion repository and the harness as development-aid tooling. + +## Revisit When + +- The harness needs private crate internals instead of the public API boundary. +- Evaluation results require new library telemetry, configuration, or API contracts. +- The evals repository becomes public, moves into this repository, or changes from development aid to supported product surface. +- External benchmark compatibility becomes an explicit product requirement rather than a comparison input. + +## More Information + +- Roadmap phase: `docs/design/roadmap-phases/v0_1_4_continuity_evaluation_harness.md`. +- Related ADR: ADR-I-0018, whose revisit trigger covers concepts with no unambiguous home in the responsibility-boundary module layout. diff --git a/src/adapters/oxigraph/embedded.rs b/src/adapters/oxigraph/embedded.rs index 99a6d60..eaddbff 100644 --- a/src/adapters/oxigraph/embedded.rs +++ b/src/adapters/oxigraph/embedded.rs @@ -351,7 +351,10 @@ impl GraphAuthorityStore for OxigraphGraphAuthorityStore { &visibility.object_refs, )?; - let mut expansion = bounded_expansion(query, objects, links)?; + let mut hydrated_query = query.clone(); + hydrated_query.record_fanout_utilization = false; + let mut expansion = bounded_expansion(&hydrated_query, objects, links)?; + assign_expanded_fanout_utilization(&mut expansion, visibility.fanout_utilization); if expansion.bounded_failure.is_none() { expansion.bounded_failure = visibility.bounded_failure; } diff --git a/src/adapters/oxigraph/http.rs b/src/adapters/oxigraph/http.rs index 5016ffd..4b51edf 100644 --- a/src/adapters/oxigraph/http.rs +++ b/src/adapters/oxigraph/http.rs @@ -378,6 +378,7 @@ impl OxigraphHttpGraphAuthorityStore { ) -> Result { let mut graph_refs = HashSet::from([root_ref]); let mut graph_link_ids = HashSet::new(); + let mut fanout_utilization = Vec::new(); let mut bounded_failure = None; let mut frontier = vec![root_ref]; @@ -390,14 +391,16 @@ impl OxigraphHttpGraphAuthorityStore { .get(object_ref) .map(Vec::as_slice) .unwrap_or_default(); - for link_ref in bounded_incident_link_refs( + let (bounded_link_refs, utilization) = bounded_incident_link_refs( query, root_ref, *object_ref, depth, incident_link_refs, &mut bounded_failure, - )? { + )?; + fanout_utilization.extend(utilization); + for link_ref in bounded_link_refs { let neighbor = link_ref.other_endpoint(*object_ref); insert_visible_ref( query, @@ -435,6 +438,7 @@ impl OxigraphHttpGraphAuthorityStore { object_refs: graph_refs, traversal_link_ids: graph_link_ids, lifecycle_link_ids, + fanout_utilization, bounded_failure, }) } @@ -635,7 +639,10 @@ impl GraphAuthorityStore for OxigraphHttpGraphAuthorityStore { }) .collect::>(); - let mut expansion = bounded_expansion(query, objects, links)?; + let mut hydrated_query = query.clone(); + hydrated_query.record_fanout_utilization = false; + let mut expansion = bounded_expansion(&hydrated_query, objects, links)?; + assign_expanded_fanout_utilization(&mut expansion, visibility.fanout_utilization); if expansion.bounded_failure.is_none() { expansion.bounded_failure = visibility.bounded_failure; } diff --git a/src/adapters/oxigraph/shared.rs b/src/adapters/oxigraph/shared.rs index ff12dcb..d962ea2 100644 --- a/src/adapters/oxigraph/shared.rs +++ b/src/adapters/oxigraph/shared.rs @@ -15,8 +15,8 @@ use crate::policy::graph_expansion::{ bounded_incident_link_refs, graph_expansion_bounded_error, BoundedExpansionLinkRef, }; use crate::ports::graph_authority::{ - GraphExpansionBoundedFailure, GraphExpansionBoundedFailureReason, GraphExpansionQuery, - GraphObjectQuery, GraphObjectRef, + GraphExpansion, GraphExpansionBoundedFailure, GraphExpansionBoundedFailureReason, + GraphExpansionFanoutUtilization, GraphExpansionQuery, GraphObjectQuery, GraphObjectRef, }; use super::rdf_mapping::{RdfObject, RdfTriple}; @@ -888,9 +888,20 @@ pub(super) struct BoundedGraphVisibility { pub(super) object_refs: HashSet, pub(super) traversal_link_ids: HashSet, pub(super) lifecycle_link_ids: HashSet, + pub(super) fanout_utilization: Vec, pub(super) bounded_failure: Option, } +pub(super) fn assign_expanded_fanout_utilization( + expansion: &mut GraphExpansion, + fanout_utilization: Vec, +) { + expansion.fanout_utilization = fanout_utilization + .into_iter() + .filter(|entry| expansion.expanded_nodes.contains(&entry.root)) + .collect(); +} + pub(super) fn bounded_graph_visible_refs( selectors: &SparqlGraphSelectors<'_>, root_ref: GraphObjectRef, @@ -898,6 +909,7 @@ pub(super) fn bounded_graph_visible_refs( ) -> Result { let mut graph_refs = HashSet::from([root_ref]); let mut graph_link_ids = HashSet::new(); + let mut fanout_utilization = Vec::new(); let mut bounded_failure = None; let mut frontier = vec![root_ref]; @@ -910,14 +922,16 @@ pub(super) fn bounded_graph_visible_refs( .get(object_ref) .map(Vec::as_slice) .unwrap_or_default(); - for link_ref in bounded_incident_link_refs( + let (bounded_link_refs, utilization) = bounded_incident_link_refs( query, root_ref, *object_ref, depth, incident_link_refs, &mut bounded_failure, - )? { + )?; + fanout_utilization.extend(utilization); + for link_ref in bounded_link_refs { let neighbor = link_ref.other_endpoint(*object_ref); insert_visible_ref( query, @@ -954,6 +968,7 @@ pub(super) fn bounded_graph_visible_refs( object_refs: graph_refs, traversal_link_ids: graph_link_ids, lifecycle_link_ids, + fanout_utilization, bounded_failure, }) } diff --git a/src/adapters/oxigraph/tests.rs b/src/adapters/oxigraph/tests.rs index f8adb17..94a8f6d 100644 --- a/src/adapters/oxigraph/tests.rs +++ b/src/adapters/oxigraph/tests.rs @@ -25,7 +25,9 @@ mod tests { GraphExpansionQuery, GraphObjectQuery, GraphObjectRef, }; use crate::ports::vector_candidate::VectorCandidateStore; - use crate::test_support::{high_fanout_graph_fixture, representative_fixtures}; + use crate::test_support::{ + high_fanout_graph_fixture, representative_fixtures, FakeGraphAuthorityStore, + }; use crate::usecases::RetrievePipeline; use crate::CustomError; use async_trait::async_trait; @@ -378,6 +380,221 @@ mod tests { || link.to_id == fixtures.hub_entity.id)); } + #[tokio::test] + async fn oxigraph_expansion_reports_pre_hydration_fanout_omissions() { + let store = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); + let fixture = high_fanout_graph_fixture(); + + store.upsert_objects(&fixture.objects()).await.unwrap(); + store.upsert_links(&fixture.links).await.unwrap(); + + let query = GraphExpansionQuery::new(fixture.hub_entity.id, ObjectType::Entity, 1, 20) + .with_allowed_object_types(vec![ObjectType::DerivedMemory]) + .with_max_hub_edges(8) + .with_max_fanout_per_node(2); + let without_utilization = store.expand_bounded(&query).await.unwrap(); + let expansion = store + .expand_bounded(&query.with_fanout_utilization_recording(true)) + .await + .unwrap(); + + assert_eq!(expansion.links.len(), 2); + assert_eq!(without_utilization.objects, expansion.objects); + assert_eq!(without_utilization.links, expansion.links); + assert_eq!(without_utilization.relations, expansion.relations); + assert_eq!(without_utilization.filtered_nodes, expansion.filtered_nodes); + assert_eq!( + without_utilization.bounded_failure, + expansion.bounded_failure + ); + assert!(expansion.fanout_utilization.iter().any(|entry| { + entry.root.object_id == fixture.hub_entity.id + && entry.relation == RelationType::About + && entry.object_type == ObjectType::DerivedMemory + && entry.selected_cap == 2 + && entry.retained_count == 2 + && entry.omitted_by_fanout_count == 10 + })); + } + + #[tokio::test] + async fn oxigraph_utilization_excludes_suppressed_intermediate_and_matches_in_memory() { + let embedded = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); + let in_memory = FakeGraphAuthorityStore::new(); + let fixtures = representative_fixtures(); + let objects = vec![ + MemoryObject::Entity(fixtures.hub_entity.clone()), + MemoryObject::DerivedMemory(fixtures.suppressed_seed.clone()), + MemoryObject::DerivedMemory(fixtures.derived_reflection.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ]; + let make_link = |id, from_id, from_type, to_id, to_type, relation| { + let mut link = fixtures.hub_links[0].clone(); + link.id = MemoryId::from_u128(id); + link.from_id = from_id; + link.from_type = from_type; + link.to_id = to_id; + link.to_type = to_type; + link.relation = relation; + link + }; + let links = vec![ + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0700, + fixtures.hub_entity.id, + ObjectType::Entity, + fixtures.suppressed_seed.id, + ObjectType::DerivedMemory, + RelationType::About, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0701, + fixtures.suppressed_seed.id, + ObjectType::DerivedMemory, + fixtures.derived_reflection.id, + ObjectType::DerivedMemory, + RelationType::DerivedFrom, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0702, + fixtures.suppressed_seed.id, + ObjectType::DerivedMemory, + fixtures.user_preference.id, + ObjectType::DerivedMemory, + RelationType::DerivedFrom, + ), + ]; + embedded.upsert_objects(&objects).await.unwrap(); + embedded.upsert_links(&links).await.unwrap(); + in_memory.upsert_objects(&objects).await.unwrap(); + in_memory.upsert_links(&links).await.unwrap(); + + let query = GraphExpansionQuery::new(fixtures.hub_entity.id, ObjectType::Entity, 2, 10) + .with_allowed_object_types(vec![ObjectType::DerivedMemory]) + .with_max_fanout_per_node(1) + .with_fanout_utilization_recording(true); + let embedded_expansion = embedded.expand_bounded(&query).await.unwrap(); + let in_memory_expansion = in_memory.expand_bounded(&query).await.unwrap(); + + assert!(embedded_expansion.filtered_nodes.iter().any(|filtered| { + filtered.object_ref.object_id == fixtures.suppressed_seed.id + && filtered.reason == GraphExpansionFilteredReason::Suppressed + })); + assert!(!embedded_expansion + .fanout_utilization + .iter() + .any(|entry| { entry.root.object_id == fixtures.suppressed_seed.id })); + assert!(embedded_expansion + .fanout_utilization + .iter() + .all(|entry| entry.root.object_id == fixtures.hub_entity.id)); + assert!(!embedded_expansion.fanout_utilization.is_empty()); + assert_eq!( + embedded_expansion.fanout_utilization, + in_memory_expansion.fanout_utilization + ); + } + + #[tokio::test] + async fn oxigraph_utilization_excludes_nodes_returned_only_at_max_depth() { + let embedded = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); + let in_memory = FakeGraphAuthorityStore::new(); + let fixtures = representative_fixtures(); + let objects = vec![ + MemoryObject::Entity(fixtures.hub_entity.clone()), + MemoryObject::DerivedMemory(fixtures.suppressed_seed.clone()), + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::Observation(fixtures.salient_observation.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + MemoryObject::DerivedMemory(fixtures.derived_reflection.clone()), + ]; + let make_link = |id, from_id, from_type, to_id, to_type, relation| { + let mut link = fixtures.hub_links[0].clone(); + link.id = MemoryId::from_u128(id); + link.from_id = from_id; + link.from_type = from_type; + link.to_id = to_id; + link.to_type = to_type; + link.relation = relation; + link + }; + let links = vec![ + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0710, + fixtures.hub_entity.id, + ObjectType::Entity, + fixtures.suppressed_seed.id, + ObjectType::DerivedMemory, + RelationType::About, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0711, + fixtures.suppressed_seed.id, + ObjectType::DerivedMemory, + fixtures.user_preference.id, + ObjectType::DerivedMemory, + RelationType::DerivedFrom, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0712, + fixtures.hub_entity.id, + ObjectType::Entity, + fixtures.episode.id, + ObjectType::Episode, + RelationType::About, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0713, + fixtures.episode.id, + ObjectType::Episode, + fixtures.salient_observation.id, + ObjectType::Observation, + RelationType::DerivedFrom, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0714, + fixtures.salient_observation.id, + ObjectType::Observation, + fixtures.user_preference.id, + ObjectType::DerivedMemory, + RelationType::DerivedFrom, + ), + make_link( + 0x550e_8400_e29b_41d4_a716_4466_5544_0715, + fixtures.user_preference.id, + ObjectType::DerivedMemory, + fixtures.derived_reflection.id, + ObjectType::DerivedMemory, + RelationType::DerivedFrom, + ), + ]; + embedded.upsert_objects(&objects).await.unwrap(); + embedded.upsert_links(&links).await.unwrap(); + in_memory.upsert_objects(&objects).await.unwrap(); + in_memory.upsert_links(&links).await.unwrap(); + + let query = GraphExpansionQuery::new(fixtures.hub_entity.id, ObjectType::Entity, 3, 20) + .with_fanout_utilization_recording(true); + let embedded_expansion = embedded.expand_bounded(&query).await.unwrap(); + let in_memory_expansion = in_memory.expand_bounded(&query).await.unwrap(); + + assert!(embedded_expansion.objects.iter().any(|object| { + matches!(object, MemoryObject::DerivedMemory(memory) if memory.id == fixtures.user_preference.id) + })); + assert!(!embedded_expansion + .fanout_utilization + .iter() + .any(|entry| entry.root.object_id == fixtures.user_preference.id)); + assert!(embedded_expansion + .fanout_utilization + .iter() + .any(|entry| entry.root.object_id == fixtures.salient_observation.id)); + assert_eq!( + embedded_expansion.fanout_utilization, + in_memory_expansion.fanout_utilization + ); + } + #[tokio::test] async fn oxigraph_expansion_preserves_depth_node_cap_and_allowed_types() { let store = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); diff --git a/src/api/types.rs b/src/api/types.rs index 2996ec8..f4f9eea 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -26,13 +26,13 @@ pub use lifecycle::{ }; pub use retrieval::{ default_retrieval_object_types, ContextPackSection, ContinuityContextPack, - ContinuitySectionLimits, GraphExpansionBoundedFailureSummary, + ContinuitySectionLimits, FanoutUtilizationTrace, GraphExpansionBoundedFailureSummary, GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphExpansionOutcome, GraphExpansionTelemetry, GraphExpansionTrace, GraphRelationTrace, IncludedDerivedMemory, LifecycleFilterAction, LifecycleFilterDecision, LifecycleFilterReason, - LifecycleOmissionSummary, MemoryObjectRef, RetrievalCandidateLimits, RetrievalContext, - RetrievalGraphLimits, RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, - RetrievalTrace, RetrieveOutcome, SectionAssignment, SectionPressureSummary, + LifecycleOmissionSummary, MemoryObjectRef, RationaleCategory, RetrievalCandidateLimits, + RetrievalContext, RetrievalGraphLimits, RetrievalLifecyclePolicy, RetrievalRationale, + RetrievalTelemetry, RetrievalTrace, RetrieveOutcome, SectionAssignment, SectionPressureSummary, SelectivityCountScope, SelectivityDecision, SelectivityTelemetry, SelectivityTrace, StaleCandidateOmission, StaleCandidateOmissionSummary, StaleCandidateReason, VectorCandidateTrace, diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index 4e8ebc7..8f91186 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -324,6 +324,8 @@ pub struct RetrievalTrace { #[serde(default)] pub graph_expansions: Vec, #[serde(default)] + pub fanout_utilization: Vec, + #[serde(default)] pub selectivity_decisions: Vec, pub lifecycle_filter_decisions: Vec, pub stale_candidate_omissions: Vec, @@ -336,6 +338,7 @@ impl RetrievalTrace { vector_candidates: Vec::new(), graph_relations: Vec::new(), graph_expansions: Vec::new(), + fanout_utilization: Vec::new(), selectivity_decisions: Vec::new(), lifecycle_filter_decisions: Vec::new(), stale_candidate_omissions: Vec::new(), @@ -387,6 +390,21 @@ pub struct GraphExpansionTrace { pub outcome: GraphExpansionOutcome, } +/// Reports pre-limit counts only for nodes the bounded expansion actually expanded. +/// +/// Trace roots are a subset of returned expansion objects: nodes admitted at max depth are returned but are not expanded and therefore emit no utilization rows. +/// Omission counts include links dropped by the global hub cap and relation/object fanout caps. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FanoutUtilizationTrace { + pub root: MemoryObjectRef, + pub relation: RelationType, + pub object_type: ObjectType, + pub configured_cap: usize, + pub selected_cap: usize, + pub retained_count: usize, + pub omitted_by_fanout_count: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SelectivityTrace { pub root: MemoryObjectRef, @@ -476,6 +494,8 @@ pub struct StaleCandidateOmission { pub candidate: MemoryObjectRef, pub vector_score: Option, pub reason: StaleCandidateReason, + #[serde(default)] + pub rationale_categories: Vec, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -495,6 +515,21 @@ pub struct SectionAssignment { pub section: ContextPackSection, pub rank: Option, pub reason: Option, + #[serde(default)] + pub rationale_categories: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RationaleCategory { + Semantic, + Entity, + Thread, + Temporal, + Salience, + Scope, + Lifecycle, + GraphBound, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -697,12 +732,14 @@ mod tests { section: ContextPackSection::Preferences, rank: Some(2), reason: Some("derived type maps to preference section".to_owned()), + rationale_categories: vec![RationaleCategory::Scope], }; let encoded = serde_json::to_value(&assignment).unwrap(); assert_eq!(encoded["section"], "preferences"); assert_eq!(encoded["rank"], 2); + assert_eq!(encoded["rationale_categories"][0], "scope"); } #[test] @@ -791,6 +828,15 @@ mod tests { }), outcome: GraphExpansionOutcome::Bounded, }], + fanout_utilization: vec![FanoutUtilizationTrace { + root: candidate, + relation: RelationType::About, + object_type: ObjectType::DerivedMemory, + configured_cap: 16, + selected_cap: 4, + retained_count: 4, + omitted_by_fanout_count: 3, + }], selectivity_decisions: Vec::new(), lifecycle_filter_decisions: vec![LifecycleFilterDecision { object: candidate, @@ -804,12 +850,14 @@ mod tests { candidate: MemoryObjectRef::new(ObjectType::Observation, episode_id), vector_score: Some(0.44), reason: StaleCandidateReason::GraphObjectMissing, + rationale_categories: vec![RationaleCategory::Semantic], }], section_assignments: vec![SectionAssignment { object: candidate, section: ContextPackSection::Preferences, rank: Some(1), reason: None, + rationale_categories: vec![RationaleCategory::Scope, RationaleCategory::Semantic], }], }; @@ -837,10 +885,19 @@ mod tests { decoded.stale_candidate_omissions[0].reason, StaleCandidateReason::GraphObjectMissing ); + assert_eq!( + decoded.stale_candidate_omissions[0].rationale_categories, + vec![RationaleCategory::Semantic] + ); assert_eq!( decoded.section_assignments[0].section, ContextPackSection::Preferences ); + assert_eq!( + decoded.section_assignments[0].rationale_categories, + vec![RationaleCategory::Scope, RationaleCategory::Semantic] + ); + assert_eq!(decoded.fanout_utilization[0].omitted_by_fanout_count, 3); } #[test] @@ -856,9 +913,36 @@ mod tests { let decoded: RetrievalTrace = serde_json::from_str(encoded).unwrap(); assert!(decoded.graph_expansions.is_empty()); + assert!(decoded.fanout_utilization.is_empty()); assert!(decoded.selectivity_decisions.is_empty()); } + #[test] + fn new_trace_fields_are_serde_defaulted_for_older_payloads() { + let encoded_omission = r#"{ + "candidate": { + "object_type": "derived_memory", + "id": "550e8400-e29b-41d4-a716-446655442090" + }, + "vector_score": 0.5, + "reason": "section_limit" + }"#; + let omission: StaleCandidateOmission = serde_json::from_str(encoded_omission).unwrap(); + assert!(omission.rationale_categories.is_empty()); + + let encoded_assignment = r#"{ + "object": { + "object_type": "derived_memory", + "id": "550e8400-e29b-41d4-a716-446655442091" + }, + "section": "preferences", + "rank": 1, + "reason": "legacy trace" + }"#; + let assignment: SectionAssignment = serde_json::from_str(encoded_assignment).unwrap(); + assert!(assignment.rationale_categories.is_empty()); + } + #[test] fn retrieval_telemetry_serializes_with_backend_agnostic_bounds() { let telemetry = RetrievalTelemetry { diff --git a/src/lib.rs b/src/lib.rs index 7e1371c..136d8a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ pub use crate::api::types::{ DeferredLifecycleAction, DerivedMemory, DerivedMemoryCandidate, DerivedMemoryDraft, DerivedType, DiagnosticSeverity, DomainValidationError, DraftDefaults, Entity, EntityCandidate, EntityDraft, EntityType, Episode, EpisodeCandidate, EpisodeDraft, ExternalSourceReference, - ForgetCascadePolicy, ForgetLifecyclePolicy, ForgetMemoryDraft, + FanoutUtilizationTrace, ForgetCascadePolicy, ForgetLifecyclePolicy, ForgetMemoryDraft, GraphExpansionBoundedFailureSummary, GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphExpansionOutcome, GraphExpansionTelemetry, GraphExpansionTrace, GraphRelationTrace, IncludedDerivedMemory, LifecycleDtoValidationError, @@ -32,17 +32,17 @@ pub use crate::api::types::{ MemoryCandidate, MemoryCandidateKind, MemoryId, MemoryLink, MemoryLinkCandidate, MemoryLinkDraft, MemoryObject, MemoryObjectDraft, MemoryObjectRef, MemoryThread, MemoryThreadCandidate, MemoryThreadDraft, Modality, ObjectType, Observation, - ObservationCandidate, ObservationDraft, PrepareOptions, RationaleOrigin, RelationType, - RememberDiagnostic, RememberDiagnostics, RememberDraft, RememberInput, RememberOptions, - RememberOutcome, RememberWritePlan, RepairMarker, ReplacementDerivedMemoryDraft, - RetentionState, RetrievalCandidateLimits, RetrievalContext, RetrievalGraphLimits, - RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, RetrievalTrace, - RetrieveOutcome, SectionAssignment, SectionPressureSummary, SelectivityCountScope, - SelectivityDecision, SelectivityTelemetry, SelectivityTrace, SourceObjectCorrectionTarget, - SourceProvenance, SourceProvenanceReference, SourceSpan, SourceSpanRange, - SourceSpanValidationError, Stability, StaleCandidateOmission, StaleCandidateOmissionSummary, - StaleCandidateReason, StatsUpdateCandidate, StatsUpdateFailure, StatsUpdateStatus, - SupersededByEvidence, SuppressionPolicy, ThreadStatus, VectorCandidateTrace, + ObservationCandidate, ObservationDraft, PrepareOptions, RationaleCategory, RationaleOrigin, + RelationType, RememberDiagnostic, RememberDiagnostics, RememberDraft, RememberInput, + RememberOptions, RememberOutcome, RememberWritePlan, RepairMarker, + ReplacementDerivedMemoryDraft, RetentionState, RetrievalCandidateLimits, RetrievalContext, + RetrievalGraphLimits, RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, + RetrievalTrace, RetrieveOutcome, SectionAssignment, SectionPressureSummary, + SelectivityCountScope, SelectivityDecision, SelectivityTelemetry, SelectivityTrace, + SourceObjectCorrectionTarget, SourceProvenance, SourceProvenanceReference, SourceSpan, + SourceSpanRange, SourceSpanValidationError, Stability, StaleCandidateOmission, + StaleCandidateOmissionSummary, StaleCandidateReason, StatsUpdateCandidate, StatsUpdateFailure, + StatsUpdateStatus, SupersededByEvidence, SuppressionPolicy, ThreadStatus, VectorCandidateTrace, VectorIndexCandidate, VectorIndexingFailure, VectorMaintenanceFailure, CURRENT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION, EPISODIC_MEMORY_SCHEMA_VERSION, }; diff --git a/src/policy/graph_expansion.rs b/src/policy/graph_expansion.rs index f075c73..adc1c12 100644 --- a/src/policy/graph_expansion.rs +++ b/src/policy/graph_expansion.rs @@ -17,7 +17,7 @@ // (`apply_fanout_limits_by_pair`, `bounded_hub_retention_limit`), filters // (`relation_allowed`, `object_type_allowed`), and bounded-failure error // construction (`graph_expansion_bounded_error`). -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use crate::api::types::{ DerivedMemory, MemoryId, MemoryLink, MemoryObject, ObjectType, RelationType, RetentionState, @@ -27,15 +27,17 @@ use crate::errors::CustomError; use crate::ports::graph_authority::{ GraphDerivedMemoryProvenanceQuery, GraphDerivedMemoryThreadQuery, GraphExpansion, GraphExpansionBoundedFailure, GraphExpansionBoundedFailureReason, GraphExpansionFailurePolicy, - GraphExpansionFilteredNode, GraphExpansionFilteredReason, GraphExpansionLifecyclePolicy, - GraphExpansionQuery, GraphExpansionRelation, GraphObjectRef, + GraphExpansionFanoutUtilization, GraphExpansionFilteredNode, GraphExpansionFilteredReason, + GraphExpansionLifecyclePolicy, GraphExpansionQuery, GraphExpansionRelation, GraphObjectRef, }; #[derive(Debug, Clone, PartialEq)] pub(crate) struct BoundedExpansionPlan { pub(crate) visited: HashSet, + pub(crate) expanded_nodes: HashSet, pub(crate) relations: Vec, pub(crate) filtered_nodes: Vec, + pub(crate) fanout_utilization: Vec, pub(crate) bounded_failure: Option, } @@ -78,6 +80,8 @@ pub(crate) fn bounded_expansion( expanded_links, plan.relations, plan.filtered_nodes, + plan.expanded_nodes, + plan.fanout_utilization, plan.bounded_failure, )) } @@ -334,8 +338,10 @@ fn bounded_expansion_plan<'a>( if query.failure_policy.allow_partial_results { return Ok(BoundedExpansionPlan { visited: HashSet::new(), + expanded_nodes: HashSet::new(), relations: Vec::new(), filtered_nodes: Vec::new(), + fanout_utilization: Vec::new(), bounded_failure: Some(bounded_failure), }); } @@ -352,8 +358,10 @@ fn bounded_expansion_plan<'a>( } return Ok(BoundedExpansionPlan { visited: HashSet::new(), + expanded_nodes: HashSet::new(), relations: Vec::new(), filtered_nodes: Vec::new(), + fanout_utilization: Vec::new(), bounded_failure: Some(bounded_failure), }); } @@ -370,8 +378,10 @@ fn bounded_expansion_plan<'a>( }) .collect::>(); let mut visited = HashSet::new(); + let mut expanded_nodes = HashSet::new(); let mut filtered_nodes = Vec::new(); let mut relations = Vec::new(); + let mut fanout_utilization = Vec::new(); let mut bounded_failure = None; let mut relation_link_ids = HashSet::new(); let mut queued = HashSet::from([root]); @@ -405,6 +415,8 @@ fn bounded_expansion_plan<'a>( continue; } + expanded_nodes.insert(object_ref); + let mut incident_links = links .iter() .filter(|link| relation_allowed(query, link.relation)) @@ -425,6 +437,11 @@ fn bounded_expansion_plan<'a>( let apply_selectivity_overrides = depth == 0 && object_ref.object_id == query.root_id && object_ref.object_type == query.root_type; + let pre_limit_counts = query.record_fanout_utilization.then(|| { + fanout_counts_by_pair(&incident_links, &|item| { + (item.0.relation, item.1.object_type) + }) + }); if incident_links.len() > query.max_hub_edges { let failure = GraphExpansionBoundedFailure { @@ -440,7 +457,20 @@ fn bounded_expansion_plan<'a>( apply_selectivity_overrides, )); } - incident_links = apply_fanout_limits(query, incident_links, apply_selectivity_overrides); + if let Some(pre_limit_counts) = pre_limit_counts { + let (limited_incident_links, utilization) = apply_fanout_limits_with_utilization( + query, + object_ref, + incident_links, + pre_limit_counts, + apply_selectivity_overrides, + ); + incident_links = limited_incident_links; + fanout_utilization.extend(utilization); + } else { + incident_links = + apply_fanout_limits(query, incident_links, apply_selectivity_overrides); + } for (link, neighbor) in incident_links { if relation_link_ids.insert(link.id) { @@ -495,8 +525,10 @@ fn bounded_expansion_plan<'a>( Ok(BoundedExpansionPlan { visited, + expanded_nodes, relations, filtered_nodes, + fanout_utilization, bounded_failure, }) } @@ -514,6 +546,85 @@ fn apply_fanout_limits<'a>( ) } +fn apply_fanout_limits_with_utilization<'a>( + query: &GraphExpansionQuery, + root: GraphObjectRef, + incident_links: Vec<(&'a MemoryLink, GraphObjectRef)>, + pre_limit_counts: FanoutCounts, + apply_selectivity_overrides: bool, +) -> ( + Vec<(&'a MemoryLink, GraphObjectRef)>, + Vec, +) { + apply_fanout_limits_with_utilization_by_pair( + query, + root, + incident_links, + pre_limit_counts, + apply_selectivity_overrides, + |(link, neighbor)| (link.relation, neighbor.object_type), + ) +} + +fn apply_fanout_limits_with_utilization_by_pair( + query: &GraphExpansionQuery, + root: GraphObjectRef, + incident_items: Vec, + pre_limit_counts: FanoutCounts, + apply_selectivity_overrides: bool, + pair_for_item: impl Fn(&T) -> (RelationType, ObjectType), +) -> (Vec, Vec) { + let retained = + apply_fanout_limits_by_pair(query, incident_items, apply_selectivity_overrides, |item| { + pair_for_item(item) + }); + let retained_counts = fanout_counts_by_pair(&retained, &pair_for_item); + let mut utilization = pre_limit_counts + .into_iter() + .map(|((relation, object_type), before_count)| { + let retained_count = retained_counts + .get(&(relation, object_type)) + .copied() + .unwrap_or_default(); + GraphExpansionFanoutUtilization { + root, + relation, + object_type, + configured_cap: query.max_fanout_per_node, + selected_cap: fanout_limit_for_pair_with_override_mode( + query, + relation, + object_type, + apply_selectivity_overrides, + ), + retained_count, + omitted_by_fanout_count: before_count.saturating_sub(retained_count), + } + }) + .collect::>(); + utilization.sort_by_key(|entry| { + ( + relation_type_rank(entry.relation), + object_type_rank(entry.object_type), + ) + }); + utilization.retain(|entry| entry.retained_count > 0 || entry.omitted_by_fanout_count > 0); + (retained, utilization) +} + +type FanoutCounts = HashMap<(RelationType, ObjectType), usize>; + +fn fanout_counts_by_pair( + incident_items: &[T], + pair_for_item: &impl Fn(&T) -> (RelationType, ObjectType), +) -> FanoutCounts { + let mut counts = HashMap::new(); + for item in incident_items { + *counts.entry(pair_for_item(item)).or_default() += 1; + } + counts +} + pub(crate) fn apply_fanout_limits_by_pair( query: &GraphExpansionQuery, mut incident_items: Vec, @@ -559,6 +670,18 @@ fn fanout_limit_for_pair( relation: RelationType, object_type: ObjectType, ) -> usize { + fanout_limit_for_pair_with_override_mode(query, relation, object_type, true) +} + +fn fanout_limit_for_pair_with_override_mode( + query: &GraphExpansionQuery, + relation: RelationType, + object_type: ObjectType, + apply_selectivity_overrides: bool, +) -> usize { + if !apply_selectivity_overrides { + return query.max_fanout_per_node; + } query .fanout_overrides .iter() @@ -802,7 +925,7 @@ pub(crate) fn bounded_incident_link_refs( depth: u8, link_refs: &[T], bounded_failure: &mut Option, -) -> Result, CustomError> { +) -> Result<(Vec, Vec), CustomError> { let mut incident_links = link_refs .iter() .copied() @@ -813,6 +936,12 @@ pub(crate) fn bounded_incident_link_refs( .collect::>(); let apply_selectivity_overrides = depth == 0 && object_ref == root_ref; + let pre_limit_counts = query.record_fanout_utilization.then(|| { + fanout_counts_by_pair(&incident_links, &|link_ref| { + let neighbor = link_ref.other_endpoint(object_ref); + (link_ref.relation(), neighbor.object_type) + }) + }); if incident_links.len() > query.max_hub_edges { let failure = GraphExpansionBoundedFailure { @@ -828,12 +957,29 @@ pub(crate) fn bounded_incident_link_refs( apply_selectivity_overrides, )); } - Ok(apply_link_ref_fanout_limits( - query, - object_ref, - incident_links, - apply_selectivity_overrides, - )) + if let Some(pre_limit_counts) = pre_limit_counts { + Ok(apply_fanout_limits_with_utilization_by_pair( + query, + object_ref, + incident_links, + pre_limit_counts, + apply_selectivity_overrides, + |link_ref| { + let neighbor = link_ref.other_endpoint(object_ref); + (link_ref.relation(), neighbor.object_type) + }, + )) + } else { + Ok(( + apply_link_ref_fanout_limits( + query, + object_ref, + incident_links, + apply_selectivity_overrides, + ), + Vec::new(), + )) + } } pub(crate) fn apply_link_ref_fanout_limits( @@ -953,6 +1099,59 @@ mod tests { assert_eq!(expansion.links.len(), 1); } + #[test] + fn fanout_utilization_counts_links_removed_by_hub_truncation() { + let fixture = high_fanout_graph_fixture(); + let query = GraphExpansionQuery::new(fixture.hub_entity.id, ObjectType::Entity, 1, 20) + .with_allowed_object_types(vec![ObjectType::DerivedMemory]) + .with_max_hub_edges(8) + .with_max_fanout_per_node(4) + .with_fanout_utilization_recording(true) + .with_failure_policy(GraphExpansionFailurePolicy { + timeout_ms: Some(250), + allow_partial_results: true, + }); + + let without_utilization = bounded_expansion( + &query.clone().with_fanout_utilization_recording(false), + fixture.objects(), + fixture.links.clone(), + ) + .unwrap(); + let expansion = bounded_expansion(&query, fixture.objects(), fixture.links).unwrap(); + let utilization = expansion + .fanout_utilization + .iter() + .find(|entry| { + entry.root.object_id == fixture.hub_entity.id + && entry.relation == RelationType::About + && entry.object_type == ObjectType::DerivedMemory + }) + .unwrap(); + + assert_eq!(expansion.links.len(), 4); + assert_eq!(without_utilization.objects, expansion.objects); + assert_eq!(without_utilization.links, expansion.links); + assert_eq!(without_utilization.relations, expansion.relations); + assert_eq!(without_utilization.filtered_nodes, expansion.filtered_nodes); + assert_eq!( + without_utilization.bounded_failure, + expansion.bounded_failure + ); + assert_eq!(utilization.retained_count, 4); + assert_eq!(utilization.omitted_by_fanout_count, 8); + assert_eq!( + expansion.bounded_failure, + Some(GraphExpansionBoundedFailure { + reason: GraphExpansionBoundedFailureReason::HubLimit, + at: Some(GraphObjectRef::new( + fixture.hub_entity.id, + ObjectType::Entity + )), + }) + ); + } + #[test] fn bounded_expansion_node_limit_counts_unique_queued_nodes() { let fixtures = representative_fixtures(); @@ -1068,6 +1267,82 @@ mod tests { })); } + #[test] + fn fanout_utilization_recording_does_not_change_pruned_expansion() { + let fixture = high_fanout_graph_fixture(); + let query = GraphExpansionQuery::new(fixture.hub_entity.id, ObjectType::Entity, 1, 10) + .with_max_fanout_per_node(2); + + let without_utilization = + bounded_expansion(&query, fixture.objects(), fixture.links.clone()).unwrap(); + let with_utilization = bounded_expansion( + &query.with_fanout_utilization_recording(true), + fixture.objects(), + fixture.links, + ) + .unwrap(); + + assert_eq!(without_utilization.objects, with_utilization.objects); + assert_eq!(without_utilization.links, with_utilization.links); + assert_eq!(without_utilization.relations, with_utilization.relations); + assert_eq!( + without_utilization.filtered_nodes, + with_utilization.filtered_nodes + ); + assert_eq!( + without_utilization.bounded_failure, + with_utilization.bounded_failure + ); + assert!(without_utilization.fanout_utilization.is_empty()); + assert!(!with_utilization.fanout_utilization.is_empty()); + } + + #[test] + fn fanout_utilization_is_attributed_to_each_expanded_node() { + let fixture = representative_fixtures(); + let links = vec![ + test_link_with_id( + uuid::Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0310), + fixture.hub_entity.id, + ObjectType::Entity, + fixture.derived_reflection.id, + ObjectType::DerivedMemory, + RelationType::About, + ), + test_link_with_id( + uuid::Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0311), + fixture.derived_reflection.id, + ObjectType::DerivedMemory, + fixture.user_preference.id, + ObjectType::DerivedMemory, + RelationType::About, + ), + ]; + let query = GraphExpansionQuery::new(fixture.hub_entity.id, ObjectType::Entity, 2, 10) + .with_allowed_object_types(vec![ObjectType::DerivedMemory]) + .with_max_fanout_per_node(10) + .with_fanout_utilization_recording(true); + + let expansion = bounded_expansion(&query, fixture.objects(), links).unwrap(); + let about_derived_roots = expansion + .fanout_utilization + .iter() + .filter(|entry| { + entry.relation == RelationType::About + && entry.object_type == ObjectType::DerivedMemory + }) + .map(|entry| entry.root) + .collect::>(); + + assert_eq!( + about_derived_roots, + HashSet::from([ + GraphObjectRef::new(fixture.hub_entity.id, ObjectType::Entity), + GraphObjectRef::new(fixture.derived_reflection.id, ObjectType::DerivedMemory,), + ]) + ); + } + fn test_link( from_id: MemoryId, from_type: ObjectType, diff --git a/src/ports/graph_authority.rs b/src/ports/graph_authority.rs index 5776b6b..5ffa45d 100644 --- a/src/ports/graph_authority.rs +++ b/src/ports/graph_authority.rs @@ -164,6 +164,7 @@ pub(crate) struct GraphExpansionQuery { pub(crate) allowed_object_types: Vec, pub(crate) allowed_relation_types: Vec, pub(crate) fanout_overrides: Vec, + pub(crate) record_fanout_utilization: bool, pub(crate) lifecycle_policy: GraphExpansionLifecyclePolicy, pub(crate) failure_policy: GraphExpansionFailurePolicy, } @@ -192,6 +193,7 @@ impl GraphExpansionQuery { allowed_object_types: Vec::new(), allowed_relation_types: Vec::new(), fanout_overrides: Vec::new(), + record_fanout_utilization: false, lifecycle_policy: GraphExpansionLifecyclePolicy::default(), failure_policy: GraphExpansionFailurePolicy::default(), } @@ -215,6 +217,11 @@ impl GraphExpansionQuery { self } + pub(crate) fn with_fanout_utilization_recording(mut self, record: bool) -> Self { + self.record_fanout_utilization = record; + self + } + pub(crate) fn with_max_fanout_per_node(mut self, max_fanout_per_node: usize) -> Self { self.max_fanout_per_node = max_fanout_per_node; self @@ -285,9 +292,22 @@ pub(crate) struct GraphExpansion { pub(crate) links: Vec, pub(crate) relations: Vec, pub(crate) filtered_nodes: Vec, + pub(crate) expanded_nodes: std::collections::HashSet, + pub(crate) fanout_utilization: Vec, pub(crate) bounded_failure: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct GraphExpansionFanoutUtilization { + pub(crate) root: GraphObjectRef, + pub(crate) relation: RelationType, + pub(crate) object_type: ObjectType, + pub(crate) configured_cap: usize, + pub(crate) selected_cap: usize, + pub(crate) retained_count: usize, + pub(crate) omitted_by_fanout_count: usize, +} + impl GraphExpansion { // Tests and future diagnostics need a minimal expansion constructor; remove when from_plan covers all callers. #[allow(dead_code)] @@ -297,6 +317,8 @@ impl GraphExpansion { links, relations: Vec::new(), filtered_nodes: Vec::new(), + expanded_nodes: std::collections::HashSet::new(), + fanout_utilization: Vec::new(), bounded_failure: None, } } @@ -306,6 +328,8 @@ impl GraphExpansion { links: Vec, relations: Vec, filtered_nodes: Vec, + expanded_nodes: std::collections::HashSet, + fanout_utilization: Vec, bounded_failure: Option, ) -> Self { Self { @@ -313,6 +337,8 @@ impl GraphExpansion { links, relations, filtered_nodes, + expanded_nodes, + fanout_utilization, bounded_failure, } } @@ -444,6 +470,7 @@ mod tests { assert_eq!(query.max_nodes, 25); assert_eq!(query.max_fanout_per_node, usize::MAX); assert_eq!(query.max_hub_edges, usize::MAX); + assert!(!query.record_fanout_utilization); assert_eq!( query.allowed_object_types, vec![ObjectType::Observation, ObjectType::DerivedMemory] diff --git a/src/usecases/retrieve.rs b/src/usecases/retrieve.rs index 27fcd50..37d8160 100644 --- a/src/usecases/retrieve.rs +++ b/src/usecases/retrieve.rs @@ -3,13 +3,13 @@ use std::collections::{HashMap, HashSet}; use crate::api::types::{ - ContextPackSection, ContinuityContextPack, DerivedMemory, DerivedType, + ContextPackSection, ContinuityContextPack, DerivedMemory, DerivedType, FanoutUtilizationTrace, GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphExpansionOutcome, GraphExpansionTelemetry, GraphExpansionTrace, IncludedDerivedMemory, LifecycleFilterAction, LifecycleFilterDecision, LifecycleFilterReason, LifecycleOmissionSummary, MemoryId, - MemoryObject, MemoryObjectRef, MemoryThread, ObjectType, RelationType, RetentionState, - RetrievalContext, RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, - RetrievalTrace, RetrieveOutcome, SectionAssignment, SectionPressureSummary, + MemoryObject, MemoryObjectRef, MemoryThread, ObjectType, RationaleCategory, RelationType, + RetentionState, RetrievalContext, RetrievalLifecyclePolicy, RetrievalRationale, + RetrievalTelemetry, RetrievalTrace, RetrieveOutcome, SectionAssignment, SectionPressureSummary, SelectivityTelemetry, StaleCandidateOmission, StaleCandidateOmissionSummary, StaleCandidateReason, ThreadStatus, VectorCandidateTrace, }; @@ -99,6 +99,7 @@ where let mut graph_expansion_telemetry = GraphExpansionTelemetry::default(); let mut selectivity_telemetry = SelectivityTelemetry::default(); let mut graph_expansion_traces = include_trace.then(Vec::new); + let mut fanout_utilization_traces = include_trace.then(Vec::new); let mut selectivity_traces = include_trace.then(Vec::new); let selectivity_stats_context = if candidate_roots .iter() @@ -136,7 +137,8 @@ where traces.extend(selectivity_plan.traces); } let query = - graph_query_for_candidate(candidate, &context, selectivity_plan.fanout_overrides); + graph_query_for_candidate(candidate, &context, selectivity_plan.fanout_overrides) + .with_fanout_utilization_recording(include_trace); graph_expansion_telemetry.attempted_root_count += 1; match self.graph_store.expand_bounded(&query).await { Ok(expansion) => { @@ -145,6 +147,9 @@ where if let Some(traces) = &mut graph_expansion_traces { traces.push(graph_expansion_trace(candidate, &expansion)); } + if let Some(traces) = &mut fanout_utilization_traces { + traces.extend(fanout_utilization_traces_for_expansion(&expansion)); + } if let Some(failure) = expansion.bounded_failure { if !context.graph_limits.allow_degraded_results { return Err(bounded_failure_error(failure)); @@ -227,6 +232,7 @@ where .collect(), graph_relations: assembly.graph_relations.unwrap_or_default(), graph_expansions: graph_expansion_traces.unwrap_or_default(), + fanout_utilization: fanout_utilization_traces.unwrap_or_default(), selectivity_decisions: selectivity_traces.unwrap_or_default(), lifecycle_filter_decisions: details.lifecycle_filter_decisions, stale_candidate_omissions: details.stale_candidate_omissions, @@ -312,6 +318,10 @@ impl RetrieveAssembly { let mut proximity_by_ref = HashMap::new(); proximity_by_ref.insert(candidate_ref, 0_u8); + let graph_rationale_by_ref = self + .graph_relations + .as_ref() + .map(|_| graph_provenance(candidate_ref, &expansion.relations)); for relation in &expansion.relations { proximity_by_ref .entry(relation.from) @@ -340,11 +350,17 @@ impl RetrieveAssembly { candidate.score * 0.75 }; let candidate_score = (object_ref == candidate_ref).then_some(candidate.score); + let graph_rationale = graph_rationale_by_ref + .as_ref() + .and_then(|rationale_by_ref| rationale_by_ref.get(&object_ref)) + .copied() + .unwrap_or_default(); self.objects .entry(object_ref) .and_modify(|ranked| { ranked.vector_component = ranked.vector_component.max(inherited_vector); ranked.graph_component = ranked.graph_component.max(graph_component); + ranked.graph_rationale.merge(graph_rationale); if let Some(candidate_score) = candidate_score { ranked.vector_candidate_score = Some( ranked @@ -355,7 +371,13 @@ impl RetrieveAssembly { } }) .or_insert_with(|| { - RankedObject::new(object, inherited_vector, graph_component, candidate_score) + RankedObject::new( + object, + inherited_vector, + graph_component, + candidate_score, + graph_rationale, + ) }); } @@ -370,10 +392,12 @@ impl RetrieveAssembly { filtered_lifecycle_decision(filtered.object_ref, filtered.reason, &superseded_by); if filtered.object_ref == candidate_ref { root_filtered = true; + let stale_reason = stale_reason_from_filtered(filtered.reason); self.stale_omissions.push(StaleCandidateOmission { candidate: memory_object_ref(candidate.object_type, candidate.object_id), vector_score: Some(candidate.score), - reason: stale_reason_from_filtered(filtered.reason), + reason: stale_reason, + rationale_categories: rationale_categories_for_stale_reason(stale_reason), }); } self.lifecycle_decisions.push(decision); @@ -393,6 +417,9 @@ impl RetrieveAssembly { candidate: memory_object_ref(candidate.object_type, candidate.object_id), vector_score: Some(candidate.score), reason: StaleCandidateReason::GraphExpansionBounded, + rationale_categories: rationale_categories_for_stale_reason( + StaleCandidateReason::GraphExpansionBounded, + ), }); self.lifecycle_decisions.push(LifecycleFilterDecision { object: memory_object_ref(candidate.object_type, candidate.object_id), @@ -409,6 +436,9 @@ impl RetrieveAssembly { candidate: memory_object_ref(candidate.object_type, candidate.object_id), vector_score: Some(candidate.score), reason: StaleCandidateReason::GraphObjectMissing, + rationale_categories: rationale_categories_for_stale_reason( + StaleCandidateReason::GraphObjectMissing, + ), }); self.lifecycle_decisions.push(LifecycleFilterDecision { object: memory_object_ref(candidate.object_type, candidate.object_id), @@ -438,10 +468,12 @@ impl RetrieveAssembly { ranked.superseded_by = superseded_by; ranked_objects.push(ranked); } else if self.candidate_refs.contains(&object_ref) { + let stale_reason = stale_reason_from_decision(decision.reason); self.stale_omissions.push(StaleCandidateOmission { candidate: memory_object_ref(object_ref.object_type, object_ref.object_id), vector_score: self.candidate_scores.get(&object_ref).copied(), - reason: stale_reason_from_decision(decision.reason), + reason: stale_reason, + rationale_categories: rationale_categories_for_stale_reason(stale_reason), }); } self.lifecycle_decisions.push(decision); @@ -505,6 +537,7 @@ struct RankedObject { vector_component: f32, vector_candidate_score: Option, graph_component: f32, + graph_rationale: GraphRationaleSignals, salience_component: f32, superseded_by: Vec, } @@ -515,6 +548,7 @@ impl RankedObject { vector_component: f32, graph_component: f32, vector_candidate_score: Option, + graph_rationale: GraphRationaleSignals, ) -> Self { let salience_component = salience_component(&object); Self { @@ -522,6 +556,7 @@ impl RankedObject { vector_component, vector_candidate_score, graph_component, + graph_rationale, salience_component, superseded_by: Vec::new(), } @@ -551,6 +586,182 @@ impl RankedObject { self.salience_component ) } + + fn rationale_categories(&self) -> Vec { + let mut categories = Vec::new(); + if self.vector_candidate_score.is_some() { + push_unique_category(&mut categories, RationaleCategory::Semantic); + } + if self.graph_rationale.entity { + push_unique_category(&mut categories, RationaleCategory::Entity); + } + if self.graph_rationale.thread { + push_unique_category(&mut categories, RationaleCategory::Thread); + } + if self.graph_rationale.graph_bound { + push_unique_category(&mut categories, RationaleCategory::GraphBound); + } + if self.salience_component > 0.0 { + push_unique_category(&mut categories, RationaleCategory::Salience); + } + categories + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct GraphRationaleSignals { + entity: bool, + thread: bool, + graph_bound: bool, +} + +impl GraphRationaleSignals { + fn merge(&mut self, other: Self) { + self.entity |= other.entity; + self.thread |= other.thread; + self.graph_bound |= other.graph_bound; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GraphPathSignals { + entity: bool, + thread: bool, +} + +impl GraphPathSignals { + fn root(candidate_ref: GraphObjectRef) -> Self { + Self { + entity: candidate_ref.object_type == ObjectType::Entity, + thread: false, + } + } + + fn through( + self, + relation: RelationType, + source: GraphObjectRef, + target: GraphObjectRef, + ) -> Self { + let mut signals = self; + match relation_rationale(relation, source, target) { + RelationRationale::Thread => signals.thread = true, + RelationRationale::Generic => {} + } + signals.entity |= + source.object_type == ObjectType::Entity || target.object_type == ObjectType::Entity; + signals + } +} + +#[derive(Debug, Clone, Copy)] +enum RelationRationale { + Thread, + Generic, +} + +fn relation_rationale( + relation: RelationType, + source: GraphObjectRef, + target: GraphObjectRef, +) -> RelationRationale { + match relation { + RelationType::PartOfThread + if source.object_type == ObjectType::MemoryThread + || target.object_type == ObjectType::MemoryThread => + { + RelationRationale::Thread + } + RelationType::PartOfThread => RelationRationale::Generic, + RelationType::HasObservation + | RelationType::ObservedIn + | RelationType::Mentions + | RelationType::Involves + | RelationType::About + | RelationType::DerivedFrom + | RelationType::Supports + | RelationType::Contradicts + | RelationType::Supersedes + | RelationType::Resolves + | RelationType::CreatesOpenLoop + | RelationType::FulfillsCommitment + | RelationType::AssociatedWith => RelationRationale::Generic, + } +} + +/// Rationale-category provenance semantics (the complete contract; tests derive from it): +/// +/// - An object's admitting paths are the discovery paths from the vector-candidate root (`candidate_ref`) to the object as walked by graph expansion; categories are the union over all admitting paths, and each path contributes only signals actually ON that path — side branches off a path contribute nothing to its endpoint. +/// - `Semantic` is assigned elsewhere, iff the object is itself a vector-candidate root. +/// - `Entity` requires an `Entity`-typed node on an admitting path; relation names alone do not imply endpoint types. +/// - `Thread` requires `PartOfThread` with a `MemoryThread` endpoint on an admitting path because domain validation does not otherwise constrain relation endpoint types. +/// - `GraphBound` is the explicit fallback for graph admission whose relations map to no more specific category (see `relation_rationale`, which must stay exhaustive with no wildcard so new relation types force a conscious classification). +/// - `Temporal` is never produced by retrieval today (no temporal admission signal exists); a regression asserts this. +/// - The candidate root is excluded from its own expansion's graph provenance. +/// - Results are independent of same-depth relation iteration order: each BFS depth is built from the prior depth's snapshot, and same-depth path states union without mutating parent state. +/// - Across multiple candidates admitting the same object, signals OR-merge. +fn graph_provenance( + candidate_ref: GraphObjectRef, + relations: &[crate::ports::graph_authority::GraphExpansionRelation], +) -> HashMap { + let mut depth_by_ref = HashMap::from([(candidate_ref, 0_u8)]); + let mut paths_by_ref = HashMap::from([( + candidate_ref, + HashSet::from([GraphPathSignals::root(candidate_ref)]), + )]); + let max_proximity = relations + .iter() + .map(|relation| relation.proximity) + .max() + .unwrap_or(0); + + // Build each BFS depth from the prior depth's snapshot so sibling branches + // cannot leak signals into one another through relation iteration order. + for proximity in 1..=max_proximity { + let parent_depth = proximity - 1; + let mut next_paths: HashMap> = HashMap::new(); + for relation in relations + .iter() + .filter(|relation| relation.proximity == proximity) + { + for (source, target) in [(relation.from, relation.to), (relation.to, relation.from)] { + if depth_by_ref.get(&source) != Some(&parent_depth) + || depth_by_ref + .get(&target) + .is_some_and(|depth| *depth < proximity) + { + continue; + } + let Some(source_paths) = paths_by_ref.get(&source) else { + continue; + }; + next_paths.entry(target).or_default().extend( + source_paths + .iter() + .map(|signals| signals.through(relation.relation, source, target)), + ); + } + } + + for (object_ref, paths) in next_paths { + depth_by_ref.entry(object_ref).or_insert(proximity); + paths_by_ref.entry(object_ref).or_default().extend(paths); + } + } + + paths_by_ref + .into_iter() + .filter(|(object_ref, _)| *object_ref != candidate_ref) + .map(|(object_ref, paths)| { + let mut rationale = GraphRationaleSignals::default(); + for path in paths { + rationale.entity |= path.entity; + rationale.thread |= path.thread; + rationale.graph_bound |= !path.entity && !path.thread; + } + (object_ref, rationale) + }) + .collect() } #[derive(Debug, Clone, Copy)] @@ -599,6 +810,7 @@ fn build_pack( section: ContextPackSection::Omitted, rank: None, reason: Some(section_omission_reason(&ranked.object)), + rationale_categories: rationale_categories_for_section_omission(), }); continue; }; @@ -612,6 +824,9 @@ fn build_pack( candidate: memory_object_ref_from_object(&ranked.object), vector_score: ranked.vector_candidate_score, reason: StaleCandidateReason::SectionLimit, + rationale_categories: rationale_categories_for_stale_reason( + StaleCandidateReason::SectionLimit, + ), }); details.section_assignments.push(SectionAssignment { object: memory_object_ref_from_object(&ranked.object), @@ -621,6 +836,7 @@ fn build_pack( "section limit reached for {}", context_pack_section_name(section) )), + rationale_categories: rationale_categories_for_section_limit(), }); continue; } @@ -633,6 +849,7 @@ fn build_pack( section, rank: Some(rank), reason: Some(ranked.assignment_reason()), + rationale_categories: ranked.rationale_categories(), }); match ranked.object { @@ -942,6 +1159,24 @@ fn graph_expansion_trace( } } +fn fanout_utilization_traces_for_expansion( + expansion: &GraphExpansion, +) -> Vec { + expansion + .fanout_utilization + .iter() + .map(|entry| FanoutUtilizationTrace { + root: memory_object_ref(entry.root.object_type, entry.root.object_id), + relation: entry.relation, + object_type: entry.object_type, + configured_cap: entry.configured_cap, + selected_cap: entry.selected_cap, + retained_count: entry.retained_count, + omitted_by_fanout_count: entry.omitted_by_fanout_count, + }) + .collect() +} + fn missing_root_expansion_trace(candidate: &VectorCandidateMatch) -> GraphExpansionTrace { GraphExpansionTrace { root: memory_object_ref(candidate.object_type, candidate.object_id), @@ -1132,6 +1367,31 @@ fn stale_reason_from_decision(reason: LifecycleFilterReason) -> StaleCandidateRe } } +fn rationale_categories_for_stale_reason(reason: StaleCandidateReason) -> Vec { + match reason { + StaleCandidateReason::GraphObjectMissing => vec![RationaleCategory::Semantic], + StaleCandidateReason::LifecycleMismatch + | StaleCandidateReason::CurrentnessMismatch + | StaleCandidateReason::Superseded => vec![RationaleCategory::Lifecycle], + StaleCandidateReason::SectionLimit => vec![RationaleCategory::Scope], + StaleCandidateReason::GraphExpansionBounded => vec![RationaleCategory::GraphBound], + } +} + +fn rationale_categories_for_section_omission() -> Vec { + vec![RationaleCategory::Scope] +} + +fn rationale_categories_for_section_limit() -> Vec { + vec![RationaleCategory::Scope] +} + +fn push_unique_category(categories: &mut Vec, category: RationaleCategory) { + if !categories.contains(&category) { + categories.push(category); + } +} + fn object_lifecycle_fields(object: &MemoryObject) -> (Option, Option) { match object { MemoryObject::Episode(object) => (Some(object.retention_state), None), @@ -1431,10 +1691,55 @@ mod tests { .section_assignments .iter() .all(|assignment| assignment.reason.is_some())); + assert!(trace.section_assignments.iter().any(|assignment| { + assignment.object.id == fixtures.episode.id + && assignment + .rationale_categories + .contains(&RationaleCategory::Semantic) + && !assignment + .rationale_categories + .contains(&RationaleCategory::Temporal) + })); + assert!(trace.section_assignments.iter().any(|assignment| { + assignment.object.id == fixtures.user_preference.id + && assignment + .rationale_categories + .contains(&RationaleCategory::Semantic) + && !assignment + .rationale_categories + .contains(&RationaleCategory::Scope) + })); assert_eq!(trace.vector_candidates.len(), 4); assert_eq!(trace.graph_expansions.len(), 4); } + #[tokio::test] + async fn trace_collection_does_not_change_retrieval_results() { + let fixtures = representative_fixtures(); + let graph = graph_with(&fixtures.objects(), &fixtures.links()).await; + let vector = RecordingVectorStore::new(vec![ + candidate(fixtures.hub_entity.id, ObjectType::Entity, 0.93), + candidate(fixtures.episode.id, ObjectType::Episode, 0.92), + candidate(fixtures.user_preference.id, ObjectType::DerivedMemory, 0.91), + ]); + let embedder = RecordingEmbedder::new(vec![1.0, 0.0]); + let pipeline = RetrievePipeline::new(&graph, &vector, &embedder); + + let without_trace = pipeline + .retrieve(RetrievalContext::new("trace parity")) + .await + .unwrap(); + let with_trace = pipeline + .retrieve(RetrievalContext::new("trace parity").with_trace()) + .await + .unwrap(); + + assert_eq!(without_trace.pack, with_trace.pack); + assert_eq!(without_trace.rationale, with_trace.rationale); + assert!(without_trace.trace.is_none()); + assert!(with_trace.trace.is_some()); + } + #[tokio::test] async fn entity_neutral_selectivity_rejects_low_selectivity_concept_entity_about_expansion() { let fixture = high_fanout_graph_fixture(); @@ -1483,6 +1788,15 @@ mod tests { && decision.decision == crate::api::types::SelectivityDecision::LowSelectivityRejected })); + let fanout_utilization = &outcome.trace.as_ref().unwrap().fanout_utilization; + assert!(fanout_utilization.iter().any(|entry| { + entry.root.id == fixture.hub_entity.id + && entry.relation == RelationType::About + && entry.object_type == ObjectType::DerivedMemory + && entry.selected_cap == 0 + && entry.retained_count == 0 + && entry.omitted_by_fanout_count > 0 + })); } #[tokio::test] @@ -1525,6 +1839,14 @@ mod tests { .high_selectivity_count > 0 ); + let trace = outcome.trace.as_ref().unwrap(); + assert!(trace.fanout_utilization.iter().any(|entry| { + entry.root.id == fixture.hub_entity.id + && entry.relation == RelationType::About + && entry.object_type == ObjectType::DerivedMemory + && entry.selected_cap <= entry.configured_cap + && entry.retained_count > 0 + })); } #[tokio::test] @@ -1545,6 +1867,507 @@ mod tests { assert!(!with_trace.graph_relations.unwrap().is_empty()); } + #[test] + fn rationale_categories_follow_vector_and_graph_provenance() { + let fixtures = representative_fixtures(); + let mut preference = fixtures.user_preference.clone(); + preference.salience_score = 0.0; + let preference_ref = GraphObjectRef::new(preference.id, ObjectType::DerivedMemory); + let preference_candidate = candidate(preference.id, ObjectType::DerivedMemory, 0.95); + let hub_candidate = candidate(fixtures.hub_entity.id, ObjectType::Entity, 0.90); + let episode_candidate = candidate(fixtures.episode.id, ObjectType::Episode, 0.89); + let entity_linked_expansion = || { + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::Entity(fixtures.hub_entity.clone()), + MemoryObject::DerivedMemory(preference.clone()), + ], + Vec::new(), + ); + expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0210), + from: GraphObjectRef::new(fixtures.hub_entity.id, ObjectType::Entity), + to: preference_ref, + relation: RelationType::About, + proximity: 1, + }); + expansion + }; + let non_entity_linked_expansion = || { + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::DerivedMemory(preference.clone()), + ], + Vec::new(), + ); + expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0211), + from: preference_ref, + to: GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode), + relation: RelationType::DerivedFrom, + proximity: 1, + }); + expansion + }; + + let mut pure_vector = RetrieveAssembly::new(true); + pure_vector.absorb_expansion(&preference_candidate, non_entity_linked_expansion()); + let pure_vector_ranked = pure_vector + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == preference_ref) + .unwrap(); + let pure_vector_categories = pure_vector_ranked.rationale_categories(); + assert_eq!(pure_vector_categories, vec![RationaleCategory::Semantic]); + + let mut episode = fixtures.episode.clone(); + episode.salience_score = 0.0; + let episode_ref = GraphObjectRef::new(episode.id, ObjectType::Episode); + let mut semantic_episode = RetrieveAssembly::new(true); + semantic_episode.absorb_expansion( + &candidate(episode.id, ObjectType::Episode, 0.94), + GraphExpansion::new(vec![MemoryObject::Episode(episode)], Vec::new()), + ); + let episode_categories = semantic_episode + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == episode_ref) + .unwrap() + .rationale_categories(); + assert_eq!(episode_categories, vec![RationaleCategory::Semantic]); + assert!(!episode_categories.contains(&RationaleCategory::Temporal)); + + let mut thread = fixtures.soft_thread.clone(); + thread.salience_score = 0.0; + let thread_ref = GraphObjectRef::new(thread.id, ObjectType::MemoryThread); + let mut semantic_thread = RetrieveAssembly::new(true); + semantic_thread.absorb_expansion( + &candidate(thread.id, ObjectType::MemoryThread, 0.93), + GraphExpansion::new(vec![MemoryObject::MemoryThread(thread)], Vec::new()), + ); + let thread_categories = semantic_thread + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == thread_ref) + .unwrap() + .rationale_categories(); + assert_eq!(thread_categories, vec![RationaleCategory::Semantic]); + assert!(!thread_categories.contains(&RationaleCategory::Thread)); + + let mut non_entity_graph_expanded = RetrieveAssembly::new(true); + non_entity_graph_expanded + .absorb_expansion(&episode_candidate, non_entity_linked_expansion()); + let non_entity_graph_expanded_ranked = non_entity_graph_expanded + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == preference_ref) + .unwrap(); + let non_entity_graph_categories = non_entity_graph_expanded_ranked.rationale_categories(); + assert!(!non_entity_graph_categories.contains(&RationaleCategory::Semantic)); + assert!(!non_entity_graph_categories.contains(&RationaleCategory::Entity)); + assert!(non_entity_graph_categories.contains(&RationaleCategory::GraphBound)); + + let mut entity_graph_expanded = RetrieveAssembly::new(true); + entity_graph_expanded.absorb_expansion(&hub_candidate, entity_linked_expansion()); + let entity_graph_expanded_ranked = entity_graph_expanded + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == preference_ref) + .unwrap(); + let entity_graph_categories = entity_graph_expanded_ranked.rationale_categories(); + assert!(!entity_graph_categories.contains(&RationaleCategory::Semantic)); + assert!(entity_graph_categories.contains(&RationaleCategory::Entity)); + assert!(!entity_graph_categories.contains(&RationaleCategory::GraphBound)); + + let mut both = RetrieveAssembly::new(true); + both.absorb_expansion(&hub_candidate, entity_linked_expansion()); + both.absorb_expansion( + &preference_candidate, + GraphExpansion::new(vec![MemoryObject::DerivedMemory(preference)], Vec::new()), + ); + let both_ranked = both + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == preference_ref) + .unwrap(); + let both_categories = both_ranked.rationale_categories(); + assert!(both_categories.contains(&RationaleCategory::Semantic)); + assert!(both_categories.contains(&RationaleCategory::Entity)); + } + + #[test] + fn entity_side_branch_does_not_affect_target_and_is_order_independent() { + let fixtures = representative_fixtures(); + let root_ref = GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode); + let bridge_ref = + GraphObjectRef::new(fixtures.derived_reflection.id, ObjectType::DerivedMemory); + let entity_ref = GraphObjectRef::new(fixtures.hub_entity.id, ObjectType::Entity); + let target_ref = + GraphObjectRef::new(fixtures.user_preference.id, ObjectType::DerivedMemory); + let categories_with_ids = |entity_link_id: u128, target_link_id: u128| { + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::DerivedMemory(fixtures.derived_reflection.clone()), + MemoryObject::Entity(fixtures.hub_entity.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ], + Vec::new(), + ); + expansion.relations = vec![ + crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0220), + from: root_ref, + to: bridge_ref, + relation: RelationType::DerivedFrom, + proximity: 1, + }, + crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(entity_link_id), + from: bridge_ref, + to: entity_ref, + relation: RelationType::About, + proximity: 2, + }, + crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(target_link_id), + from: bridge_ref, + to: target_ref, + relation: RelationType::DerivedFrom, + proximity: 2, + }, + ]; + expansion + .relations + .sort_by_key(|relation| (relation.proximity, relation.link_id)); + + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(fixtures.episode.id, ObjectType::Episode, 0.90), + expansion, + ); + assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories() + }; + + let entity_first = categories_with_ids( + 0x550e_8400_e29b_41d4_a716_4466_5544_0221, + 0x550e_8400_e29b_41d4_a716_4466_5544_0222, + ); + let target_first = categories_with_ids( + 0x550e_8400_e29b_41d4_a716_4466_5544_0222, + 0x550e_8400_e29b_41d4_a716_4466_5544_0221, + ); + + assert_eq!(entity_first, target_first); + assert!(!entity_first.contains(&RationaleCategory::Entity)); + assert!(entity_first.contains(&RationaleCategory::GraphBound)); + } + + #[test] + fn mentions_path_uses_entity_nodes_not_relation_name_for_entity_rationale() { + let fixtures = representative_fixtures(); + let target_ref = + GraphObjectRef::new(fixtures.salient_observation.id, ObjectType::Observation); + let categories_for_root = |root_ref: GraphObjectRef, root: MemoryObject| { + let mut expansion = GraphExpansion::new( + vec![ + root, + MemoryObject::Observation(fixtures.salient_observation.clone()), + ], + Vec::new(), + ); + expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0223), + from: root_ref, + to: target_ref, + relation: RelationType::Mentions, + proximity: 1, + }); + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(root_ref.object_id, root_ref.object_type, 0.90), + expansion, + ); + assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories() + }; + + let entityless = categories_for_root( + GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode), + MemoryObject::Episode(fixtures.episode.clone()), + ); + let entity_backed = categories_for_root( + GraphObjectRef::new(fixtures.hub_entity.id, ObjectType::Entity), + MemoryObject::Entity(fixtures.hub_entity.clone()), + ); + + assert!(!entityless.contains(&RationaleCategory::Entity)); + assert!(entityless.contains(&RationaleCategory::GraphBound)); + assert!(entity_backed.contains(&RationaleCategory::Entity)); + assert!(!entity_backed.contains(&RationaleCategory::GraphBound)); + } + + #[test] + fn part_of_thread_path_emits_thread_without_graph_bound() { + let fixtures = representative_fixtures(); + let root_ref = GraphObjectRef::new(fixtures.soft_thread.id, ObjectType::MemoryThread); + let target_ref = + GraphObjectRef::new(fixtures.user_preference.id, ObjectType::DerivedMemory); + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::MemoryThread(fixtures.soft_thread.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ], + Vec::new(), + ); + expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0230), + from: target_ref, + to: root_ref, + relation: RelationType::PartOfThread, + proximity: 1, + }); + + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(fixtures.soft_thread.id, ObjectType::MemoryThread, 0.90), + expansion, + ); + let categories = assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories(); + + assert!(categories.contains(&RationaleCategory::Thread)); + assert!(!categories.contains(&RationaleCategory::GraphBound)); + assert!(!categories.contains(&RationaleCategory::Entity)); + } + + #[test] + fn part_of_thread_without_thread_endpoint_falls_back_to_graph_bound() { + let fixtures = representative_fixtures(); + let root_ref = GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode); + let target_ref = + GraphObjectRef::new(fixtures.salient_observation.id, ObjectType::Observation); + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::Observation(fixtures.salient_observation.clone()), + ], + Vec::new(), + ); + expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0233), + from: root_ref, + to: target_ref, + relation: RelationType::PartOfThread, + proximity: 1, + }); + + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(fixtures.episode.id, ObjectType::Episode, 0.90), + expansion, + ); + let categories = assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories(); + + assert!(!categories.contains(&RationaleCategory::Thread)); + assert!(categories.contains(&RationaleCategory::GraphBound)); + assert!(!categories.contains(&RationaleCategory::Entity)); + } + + #[test] + fn graph_categories_union_across_distinct_candidate_paths() { + let fixtures = representative_fixtures(); + let target_ref = + GraphObjectRef::new(fixtures.user_preference.id, ObjectType::DerivedMemory); + let mut generic_expansion = GraphExpansion::new( + vec![ + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ], + Vec::new(), + ); + generic_expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0231), + from: GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode), + to: target_ref, + relation: RelationType::DerivedFrom, + proximity: 1, + }); + let mut thread_expansion = GraphExpansion::new( + vec![ + MemoryObject::MemoryThread(fixtures.soft_thread.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ], + Vec::new(), + ); + thread_expansion + .relations + .push(crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0232), + from: target_ref, + to: GraphObjectRef::new(fixtures.soft_thread.id, ObjectType::MemoryThread), + relation: RelationType::PartOfThread, + proximity: 1, + }); + + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(fixtures.episode.id, ObjectType::Episode, 0.90), + generic_expansion, + ); + assembly.absorb_expansion( + &candidate(fixtures.soft_thread.id, ObjectType::MemoryThread, 0.89), + thread_expansion, + ); + let categories = assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories(); + + assert!(categories.contains(&RationaleCategory::Thread)); + assert!(categories.contains(&RationaleCategory::GraphBound)); + assert!(!categories.contains(&RationaleCategory::Entity)); + } + + #[test] + fn entity_on_admitting_path_emits_entity_for_target() { + let fixtures = representative_fixtures(); + let root_ref = GraphObjectRef::new(fixtures.episode.id, ObjectType::Episode); + let entity_ref = GraphObjectRef::new(fixtures.hub_entity.id, ObjectType::Entity); + let target_ref = + GraphObjectRef::new(fixtures.user_preference.id, ObjectType::DerivedMemory); + let mut expansion = GraphExpansion::new( + vec![ + MemoryObject::Episode(fixtures.episode.clone()), + MemoryObject::Entity(fixtures.hub_entity.clone()), + MemoryObject::DerivedMemory(fixtures.user_preference.clone()), + ], + Vec::new(), + ); + expansion.relations = vec![ + crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0240), + from: root_ref, + to: entity_ref, + relation: RelationType::AssociatedWith, + proximity: 1, + }, + crate::ports::graph_authority::GraphExpansionRelation { + link_id: Uuid::from_u128(0x550e_8400_e29b_41d4_a716_4466_5544_0241), + from: entity_ref, + to: target_ref, + relation: RelationType::DerivedFrom, + proximity: 2, + }, + ]; + + let mut assembly = RetrieveAssembly::new(true); + assembly.absorb_expansion( + &candidate(fixtures.episode.id, ObjectType::Episode, 0.90), + expansion, + ); + let categories = assembly + .ranked_objects(&RetrievalLifecyclePolicy::default()) + .into_iter() + .find(|ranked| graph_object_ref(&ranked.object) == target_ref) + .unwrap() + .rationale_categories(); + + assert!(categories.contains(&RationaleCategory::Entity)); + assert!(!categories.contains(&RationaleCategory::GraphBound)); + } + + #[test] + fn temporal_rationale_is_not_emitted_by_current_retrieval_signals() { + let fixtures = representative_fixtures(); + let ranked = RankedObject::new( + MemoryObject::DerivedMemory(fixtures.user_preference), + 0.9, + 0.8, + Some(0.9), + GraphRationaleSignals { + entity: true, + thread: true, + graph_bound: true, + }, + ); + let mut category_sets = vec![ + ranked.rationale_categories(), + rationale_categories_for_section_omission(), + rationale_categories_for_section_limit(), + ]; + category_sets.extend( + [ + StaleCandidateReason::GraphObjectMissing, + StaleCandidateReason::LifecycleMismatch, + StaleCandidateReason::CurrentnessMismatch, + StaleCandidateReason::Superseded, + StaleCandidateReason::SectionLimit, + StaleCandidateReason::GraphExpansionBounded, + ] + .map(rationale_categories_for_stale_reason), + ); + + assert!(category_sets + .iter() + .all(|categories| !categories.contains(&RationaleCategory::Temporal))); + } + + #[test] + fn salience_rationale_tracks_positive_salience_component() { + let fixtures = representative_fixtures(); + let mut positive_salience = fixtures.user_preference.clone(); + positive_salience.salience_score = 0.8; + let mut zero_salience = positive_salience.clone(); + zero_salience.salience_score = 0.0; + + for (object, expected) in [(positive_salience, true), (zero_salience, false)] { + let categories = RankedObject::new( + MemoryObject::DerivedMemory(object), + 0.0, + 0.0, + None, + GraphRationaleSignals::default(), + ) + .rationale_categories(); + + assert_eq!(categories.contains(&RationaleCategory::Salience), expected); + } + } + #[tokio::test] async fn omits_unresolved_and_lifecycle_stale_candidates() { let fixtures = representative_fixtures(); @@ -1568,7 +2391,10 @@ mod tests { .stale_candidate_omissions .iter() .any(|omission| omission.candidate.id == missing_id - && omission.reason == StaleCandidateReason::GraphObjectMissing)); + && omission.reason == StaleCandidateReason::GraphObjectMissing + && omission + .rationale_categories + .contains(&RationaleCategory::Semantic))); assert!(!trace .stale_candidate_omissions .iter() @@ -1582,6 +2408,9 @@ mod tests { .any( |omission| omission.candidate.id == fixtures.suppressed_seed.id && omission.reason == StaleCandidateReason::LifecycleMismatch + && omission + .rationale_categories + .contains(&RationaleCategory::Lifecycle) )); assert!(trace .lifecycle_filter_decisions @@ -1956,13 +2785,15 @@ mod tests { .iter() .any(|omission| omission.candidate.id == second_preference.id && omission.vector_score == Some(0.90) - && omission.reason == StaleCandidateReason::SectionLimit)); + && omission.reason == StaleCandidateReason::SectionLimit + && omission.rationale_categories == vec![RationaleCategory::Scope])); assert!(trace .section_assignments .iter() .any(|assignment| assignment.object.id == second_preference.id && assignment.section == ContextPackSection::Omitted - && assignment.reason.as_deref() == Some("section limit reached for preferences"))); + && assignment.reason.as_deref() == Some("section limit reached for preferences") + && assignment.rationale_categories == vec![RationaleCategory::Scope])); let preference_pressure = first .rationale .telemetry diff --git a/tests/support/base.rs b/tests/support/base.rs index c3da333..01c4790 100644 --- a/tests/support/base.rs +++ b/tests/support/base.rs @@ -65,21 +65,28 @@ fn stable_hash(text: &str) -> usize { } pub fn is_qdrant_unavailable_error(error: &VectorDatabaseError) -> bool { - error.backend == "qdrant" - && (error - .status - .as_deref() - .is_some_and(|status| status.to_ascii_lowercase().contains("unavailable")) - || matches!( - error.kind.as_str(), - "reqwest::connect" - | "reqwest::timeout" - | "io::ConnectionRefused" - | "io::ConnectionReset" - | "io::ConnectionAborted" - | "io::NotConnected" - | "io::TimedOut" - )) + if error.backend != "qdrant" { + return false; + } + + let message = error.message.to_ascii_lowercase(); + error + .status + .as_deref() + .is_some_and(|status| status.to_ascii_lowercase().contains("unavailable")) + || (error.kind == "response" + && message.contains("failed to connect") + && message.contains("tcp connect error")) + || matches!( + error.kind.as_str(), + "reqwest::connect" + | "reqwest::timeout" + | "io::ConnectionRefused" + | "io::ConnectionReset" + | "io::ConnectionAborted" + | "io::NotConnected" + | "io::TimedOut" + ) } pub async fn cleanup_collection(collection_name: &str) {