a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
285 lines
15 KiB
Markdown
285 lines
15 KiB
Markdown
# Plan: Making the Memory Graph Earn Its Keep
|
|
|
|
> Status: Proposal. Companion to MEMORY_ARCHITECTURE.md. Goal: best recall accuracy.
|
|
|
|
## Current reality (verified in code)
|
|
|
|
- **Live automatic recall** (`memory_agent::process_context`) uses
|
|
`find_similar_with_embedding` -> `score_and_filter`: flat cosine over all
|
|
active memories, threshold 0.5, gap filter, top 10, then per-candidate binary
|
|
sidecar relevance, max 5 surfaced.
|
|
- The **graph traversal** (`cascade_retrieve`, BFS over tags/clusters/RelatesTo)
|
|
is only called by the manual `memory { action: search }` tool
|
|
(`tool/memory.rs:216`). It contributes **nothing** to per-turn surfacing.
|
|
- Maintenance (`post_retrieval_maintenance`) WRITES graph structure every turn
|
|
(RelatesTo links, auto-clusters + sidecar naming, inferred tags, confidence
|
|
boost/decay, pruning) but the live path never READS most of it back.
|
|
|
|
So today the graph's edges/clusters/tags are write-mostly. The parts that
|
|
matter (supersede/contradiction -> `active`, reinforcement strength, confidence
|
|
gating the active set) help data hygiene, not ranking.
|
|
|
|
## Goal: maximize recall accuracy in BOTH modes
|
|
|
|
Both modes are first-class targets. They share Stage 1 (candidate generation)
|
|
and the graph layer, but diverge at the quality/judgment stage. Strategy:
|
|
push as much accuracy as possible into the **shared local stack** (better
|
|
embedder, hybrid, query construction, graph rerank, priors) so Mode 1 gets
|
|
strong on its own, then let Mode 2's LLM add a final precision layer on top of
|
|
an already-good candidate set rather than compensating for a weak one.
|
|
|
|
### Shared local stack (lifts both modes)
|
|
- recall-2 better embedder + asymmetric prefixes
|
|
- recall-3 focused query construction (current intent, not 8k blob)
|
|
- recall-4 hybrid dense + BM25 + RRF
|
|
- recall-6 recency/confidence/strength/scope priors in the score
|
|
- Phases A-C graph: supersede-authoritative, 1-hop expansion, dedup
|
|
- A local cross-encoder reranker (small, on-device) as the Mode 1 top stage
|
|
|
|
### Mode 1 (no LLM) - get it as close to Mode 2 as possible
|
|
- Replace raw-cosine top-5 with: hybrid recall -> graph expansion -> local
|
|
cross-encoder rerank -> priors -> calibrated cutoff. No LLM needed for any of
|
|
this; a cross-encoder is the single biggest precision lever available offline.
|
|
- Tune the calibrated cutoff per-mode (Mode 1 can afford slightly higher recall
|
|
since there's no LLM filter downstream; rely on the cross-encoder for precision).
|
|
- Optional: cheap local query expansion (synonyms/identifier splitting) since
|
|
there's no LLM to do query rewriting.
|
|
|
|
### Mode 2 (LLM) - add precision, don't redo recall
|
|
- Feed the SAME strong candidate set (hybrid + graph + cross-encoder top ~15-20)
|
|
into one **listwise** LLM rerank, replacing today's independent binary calls
|
|
(binary calls can't compare candidates and waste the LLM's judgment).
|
|
- Use the LLM for query rewriting / HyDE at Stage 0 to fix hard recall misses
|
|
that the local stack can't reach.
|
|
- Keep the LLM as the final arbiter for contradictions and ambiguous relevance.
|
|
|
|
### Why this converges both
|
|
Mode 1 ceiling rises to "best offline retriever + cross-encoder" (very high).
|
|
Mode 2 starts from that same ceiling and adds LLM rewriting + listwise judgment,
|
|
so it's strictly >= Mode 1. The harness (recall-1) tracks both columns each
|
|
change so neither regresses.
|
|
|
|
## Two operating modes (gate: `agents.memory_sidecar_enabled`, env `JCODE_MEMORY_SIDECAR_ENABLED`, default off)
|
|
|
|
The system runs in two distinct modes; recall behavior differs substantially.
|
|
|
|
### Mode 1 - Sidecar OFF (embedding-only, no LLM)
|
|
- Surfacing (`evaluate_candidates`): skips LLM, takes top candidates by **raw
|
|
cosine** (up to 5). No relevance verification, no listwise judgment.
|
|
- Extraction: `extract_from_context` + final extraction are **skipped**. Memories
|
|
are created ONLY via the explicit `memory` tool, not auto-learned.
|
|
- Cluster naming: falls back to `infer_candidate_tag` (heuristic, no LLM).
|
|
- Net: fully local, zero LLM cost; weakest precision (no filtering) and no auto
|
|
memory growth.
|
|
|
|
### Mode 2 - Sidecar ON (LLM-assisted)
|
|
- Surfacing: per-candidate binary relevance check by sidecar LLM (parallel, max 5).
|
|
- Extraction: auto-extract on topic change + every 12 turns + session end, with
|
|
LLM dedup/contradiction checks.
|
|
- Maintenance: LLM-named clusters, contradiction detection.
|
|
|
|
### Implications for this plan
|
|
- Every recall improvement must be evaluated in BOTH modes (recall-1 harness
|
|
should report two columns).
|
|
- Phases A-C (graph as reranker/dedup/expansion) are **pure local** and benefit
|
|
Mode 1 the most, since Mode 1 currently has no quality layer beyond cosine.
|
|
- recall-5 (rerank) has two implementations: a local cross-encoder path for
|
|
Mode 1, and the listwise LLM rerank for Mode 2 (replacing today's binary calls).
|
|
- Phase D maintenance trimming primarily affects Mode 2 cost (cluster naming is
|
|
the LLM line item); Mode 1 already uses the heuristic fallback.
|
|
|
|
## Edge types and what each is good for
|
|
|
|
| Edge | Source of truth | Use in recall |
|
|
|---------------|-----------------|----------------|
|
|
| `Supersedes` | contradiction/dedup on write | Keep ONLY newest version in results; demote/hide superseded |
|
|
| `Contradicts` | sidecar on write | Surface both + flag conflict; never silently pick one |
|
|
| `RelatesTo` | co-relevance maintenance | 1-hop expansion to rescue near-misses |
|
|
| `DerivedFrom` | co-extraction | 1-hop expansion (procedures <-> facts) |
|
|
| `HasTag` | user + inference | Lexical/filter signal, scope narrowing |
|
|
| `InCluster` | auto clustering | Weakest; diversity/dedup at best |
|
|
|
|
## Design principle
|
|
|
|
Use the graph as a **structural reranker / recall-rescue layer**, NOT as the
|
|
primary retriever. Embeddings (+ future hybrid) generate candidates; the graph
|
|
re-scores and expands them. This is where graphs reliably help in RAG: relating,
|
|
deduping, and rescuing, not first-stage recall.
|
|
|
|
## Target live pipeline
|
|
|
|
```
|
|
Stage 1 Candidate generation (existing + future hybrid)
|
|
dense cosine (and later BM25 + RRF), generous top-N (~40)
|
|
|
|
Stage 2 Graph expansion (NEW, 1-hop only)
|
|
for each seed, pull neighbors via Supersedes / RelatesTo / DerivedFrom
|
|
score_neighbor = seed_score * edge_weight * depth_decay
|
|
this rescues relevant memories that embedding alone missed
|
|
|
|
Stage 3 Graph-aware dedup/canonicalize (NEW)
|
|
collapse Supersedes chains -> keep newest active only
|
|
group near-duplicate cluster members -> representative + count
|
|
|
|
Stage 4 Rerank + priors (ties into recall-5 / recall-6)
|
|
listwise rerank, then fold confidence / strength / recency / scope
|
|
apply calibrated cutoff
|
|
```
|
|
|
|
## Phased plan
|
|
|
|
### Phase A - Make supersede/contradiction authoritative in live recall (cheap, high value)
|
|
- In `score_and_filter` / `process_context`, post-filter results through the
|
|
graph: drop any memory whose `superseded_by` is set or that has an incoming
|
|
`Supersedes` edge from an active memory.
|
|
- Surface `Contradicts` pairs together with a conflict flag instead of letting
|
|
raw cosine arbitrarily pick one.
|
|
- Verifiable: unit test with a superseded chain; assert only newest surfaces.
|
|
|
|
### Phase B - Wire 1-hop graph expansion into the live path
|
|
- Add a `cascade=true` mode to the live retrieval (reuse `cascade_retrieve` but
|
|
cap depth=1 and restrict edges to Supersedes/RelatesTo/DerivedFrom; exclude
|
|
InCluster/HasTag fan-out which explode candidate count).
|
|
- Feed expanded set into the reranker, not directly to output.
|
|
- Verifiable (needs recall-1 harness): recall@5 with vs without expansion.
|
|
|
|
### Phase C - Graph-aware dedup before surfacing
|
|
- Collapse Supersedes/near-dup cluster members so the 5 surfaced slots aren't
|
|
wasted on restatements of one fact. Improves effective precision and recall.
|
|
|
|
### Phase D - Decide the fate of expensive maintenance
|
|
- Auto-clustering + sidecar cluster-naming + tag inference currently cost LLM
|
|
calls + full graph save per cycle and feed nothing into live recall.
|
|
- Options:
|
|
1. Repurpose clusters for Phase C dedup/diversity (keeps them, drops naming).
|
|
2. Cut cluster-naming + tag-inference entirely, redirect budget to embedder
|
|
upgrade + hybrid + rerank (recall-2/4/5).
|
|
- Recommended: cut naming + tag-inference now; keep cluster centroids only if
|
|
Phase C uses them. Keep confidence boost/decay, supersede, reinforcement.
|
|
|
|
### Phase E - Feedback loop closes via graph
|
|
- On inject + actual use, reinforce surfaced memories and strengthen the
|
|
RelatesTo edges among co-used memories (already partly there). Once Phase B
|
|
reads those edges, this feedback finally affects future recall.
|
|
|
|
## Cost to quantify first (before Phase D decision)
|
|
- Per maintenance cycle: # sidecar LLM calls (cluster naming), # graph load+save
|
|
round-trips, bytes rewritten. Add a counter / log and measure on the real
|
|
`~/.jcode/memory` graphs.
|
|
|
|
## Dependencies / ordering
|
|
- recall-1 (eval harness) gates B/C/D measurement.
|
|
- Phase A is independent and safe to do first (pure correctness win).
|
|
- Phases B/C should be measured against the harness; otherwise we're guessing.
|
|
```mermaid
|
|
graph LR
|
|
A[A: supersede authoritative] --> B[B: 1-hop expansion]
|
|
H[recall-1 harness] --> B
|
|
B --> C[C: graph dedup]
|
|
H --> D[D: trim/repurpose maintenance]
|
|
C --> E[E: feedback via edges]
|
|
```
|
|
|
|
## Implementation status (2026-06-14)
|
|
|
|
Benchmark (Mode 1, private ~/jcode-memory-bench, Sonnet judge):
|
|
- DONE: harness `memory_recall_bench` (queries/pool/judge/metrics), committed.
|
|
- Baseline: production dense (0.5 thr) = 0.0 recall@5; hybrid = 0.53.
|
|
|
|
Shipped to live path:
|
|
- DONE recall-0 + recall-4: memory agent uses `find_similar_hybrid`
|
|
(dense + BM25 + RRF, no cosine floor). Removed the recall-killing 0.5 threshold
|
|
and added lexical signal. Unit tests added. Bench: 0.0 -> 0.53 recall@5.
|
|
|
|
Evaluated, NOT shipped:
|
|
- recall-6 priors: roughly neutral (+1.8pt r@5 / -1.8pt r@10). Held back; bench
|
|
config `hybrid_priors` retained for re-evaluation after embedder upgrade.
|
|
|
|
Next (high value, larger change):
|
|
- recall-2: embedder upgrade (dense half is weak at 0.17 unthresholded).
|
|
- recall-3: focused query construction (window concatenates up to 12 msgs +
|
|
tool output; ~19% carry system-reminder boilerplate).
|
|
- recall-5: rerank stage. graph A-D: graph utilization.
|
|
|
|
## Update 2026-06-14 (rerank breakthrough, multi-agent)
|
|
|
|
Benchmark-driven results (Sonnet judge, 28 judged queries, jcode self-dev corpus):
|
|
| Config | recall@5 | recall@10 | precision@5 | MRR |
|
|
|--------------|----------|-----------|-------------|-------|
|
|
| baseline (prod dense, 0.5 thr) | 0.000 | 0.000 | 0.000 | 0.000 |
|
|
| hybrid (SHIPPED) | 0.530 | 0.679 | 0.229 | 0.504 |
|
|
| ce_rerank (local CE, rejected) | 0.325 | 0.420 | 0.129 | 0.322 |
|
|
| llm_rerank (listwise Sonnet) | 0.754 | 0.832 | 0.346 | 0.762 |
|
|
| oracle ceiling | 0.990 | 1.000 | 0.443 | 1.000 |
|
|
|
|
- Hybrid (dense+BM25+RRF) shipped: 0.0 -> 0.53 recall@5.
|
|
- Local cross-encoder REJECTED (out-of-distribution, 0.325).
|
|
- Listwise LLM reranker over the hybrid top-50 with a FOCUSED query: 0.53 -> 0.75
|
|
recall@5, captures most of the oracle headroom. This is the Mode-2 path.
|
|
- Embedder upgrade de-prioritized (pool recall already ~99%; bge anisotropic).
|
|
|
|
Implementation split (turtle + crocodile):
|
|
- Shared: jcode-base/src/memory_rerank.rs (prompt + parse + rerank_candidates),
|
|
used by both bench and memory_agent (single source of truth).
|
|
- memory_agent process_context: Mode-2 reranks hybrid candidates with the focused
|
|
query before surfacing; Mode-1 unchanged (no adequate local reranker).
|
|
- Focused query builder (focus_query_text) lands in memory_prompt.rs.
|
|
|
|
## Deferred follow-ups (2026-06-14, after the rerank pipeline shipped)
|
|
|
|
The two-stage pipeline (hybrid retrieve -> focused-query listwise LLM rerank ->
|
|
top-5) is live and committed; production recall@5 went 0.0 -> 0.53 -> ~0.75.
|
|
These remain as future work, each blocked or deliberately deprioritized:
|
|
|
|
1. **Remote embedding adapter** (low value, measure first). EmbeddingBackend
|
|
trait + LocalOnnxBackend scaffolding is shipped (embedding_backend.rs). A
|
|
remote openai/openai-compatible adapter + auto-select-on-embeddings-key +
|
|
re-embed migration would plug in via `active_backend()`. Deprioritized because
|
|
the oracle-ceiling analysis showed the embedder is a *capped* lever (the
|
|
candidate pool already contains ~99% of relevant memories; ranking, not
|
|
recall of the pool, was the bottleneck). Only revisit if a future change makes
|
|
the base embedder the bottleneck again, and A/B it in the bench first.
|
|
|
|
2. **Live reload + Mode-2 verification** (user action). Build+reload onto the new
|
|
binary and confirm the rerank fires in a real session (memory logs should show
|
|
the single listwise rerank instead of per-candidate sidecar checks). Pending
|
|
only because the shared worktree currently has an unrelated agent's
|
|
uncommitted changes; not a code issue.
|
|
|
|
3. **GPT-5.5 judge re-run** (blocked ~18 days). Re-run the bench LLM judge with
|
|
GPT-5.5 (`--backend=openai --reasoning=none`) once the OpenAI account quota
|
|
resets, and compare judge agreement against the current Claude-Sonnet gold
|
|
labels. Infrastructure is already in place (Sidecar::with_openai_model).
|
|
|
|
## Fork-the-judge / KV-reuse reranker (validated design, future)
|
|
|
|
Idea (user, 2026-06-14): instead of a separate tiny sidecar call for the memory
|
|
rerank, reuse the main agent's warm transcript KV cache and run the reranker as
|
|
a branch off it, so the judge's marginal cost is just the rerank suffix.
|
|
|
|
Benchmark findings (claude-sonnet-4-6, 28 judged queries, see
|
|
~/jcode-memory-bench/results/BASELINE_SUMMARY.md):
|
|
- Naive (full transcript as the rerank query): QUALITY REGRESSION. recall@5
|
|
0.81 -> 0.58, precision@5 0.34 -> 0.25. Noise dilutes even a frontier model.
|
|
- prefix_suffix (full transcript as prefix + focused intent appended as a
|
|
suffix with a "focus on THIS" marker): FULLY RECOVERS quality. recall@5 0.811,
|
|
precision@5 0.351, MRR 0.784 (>= the shipped focused-query rerank).
|
|
|
|
Conclusion:
|
|
- The cache-friendly structure (transcript-as-prefix for KV reuse) does NOT cost
|
|
accuracy *if* the focused rerank instruction is appended as a suffix.
|
|
- SELF-HOSTED (vLLM/SGLang/Ollama): viable + high-quality. Fork the rerank
|
|
sequence off the agent's warm transcript KV (SGLang fork / RadixAttention),
|
|
append the focused rerank suffix + candidate list, decode a short ranked list.
|
|
Near-free, full-model-quality reranking. Good basis for a local/premium memory
|
|
path. Requires a server that exposes prefix sharing/forking.
|
|
- PROVIDER APIs (default): NOT a cost win (cached-read on a ~50k-token transcript
|
|
prefix still costs ~10-20x a ~1k focused sidecar prompt, because the big
|
|
model's per-token rate dominates), but no longer a quality regression. Could be
|
|
exposed as an opt-in config "rerank with main model + prompt caching" for users
|
|
who prioritize rerank quality and have caching enabled. Default stays the cheap
|
|
focused-query sidecar, which wins on both cost and quality on the API path.
|
|
|
|
Bench repro: `memory_recall_bench metrics --config=llm_rerank
|
|
--query_view=focused|full|prefix_suffix --model=<model>`.
|