Memory planner¶
Architecture
Normative spec
This page documents .ai/specs/core-engine/2026-07-02-memory-planner.md, Status: Approved (F7 math review complete 2026-07-04, conditions incorporated in v0.2; v0.3 is the as-built record). The implementation is core-engine/src/planner/ (mod.rs, select.rs, tokens.rs, plan.rs).
Generation quality is bounded by evidence selection. The memory planner is the subsystem between a query and a generated answer: it decides which source spans an answer may cite, under a hard token budget, deterministically enough that the same inputs always produce the same plan. This page documents its two phases, the token accounting seam, and the plan model that generation and claim verification both consume unmodified.
Overview & purpose¶
The spec frames the problem in four failure modes a naive "grab the top-k similar chunks" approach hits: recall too narrow misses facts; packing without budget discipline blows context windows and cost caps; nondeterministic selection makes traces unreproducible; and mixing verified source spans with derived summaries without labels poisons claim verification downstream.
The design splits into two phases with very different character:
- Phase A (recall) is a union of three legs (structured query, semantic search, hop expansion), each contributing nodes with a fusion score
S(n). Recall is generous by design: it is fine to over-recall. - Phase B (selection) is a pure, deterministic function: dedup, then greedy pack by density under a budget and a hard per-source diversity cap. Everything nondeterministic (network calls, storage reads, wall-clock time) happens in Phase A; Phase B is a
Vec<PlannedSpan> -> (Vec<PlannedSpan>, u64)function that can be proptested in isolation.
core-engine/src/planner/mod.rs's own module doc states the shape plainly: Phase A explodes recalled nodes to their provenance spans, weights each span w(sp) = S(node) × kind_prior(sp.kind), and Phase B packs greedily by w(sp) / max(1, tokens(sp)).
Design¶
Phase A: the three recall legs¶
pub struct PlanRequest {
pub query: Option<Query>, // structured DSL leg
pub semantic: Option<SemanticRecall>, // vector leg
pub expand_depth: u8, // 0, 1, or 2 hops
pub at_valid: Option<Ts>,
pub at_tx: Option<Ts>,
pub budget_tokens: Option<u64>,
pub model: Option<String>,
}
All three legs merge into one BTreeMap<Uuid, f64> (node id to score), using max-of-scores when a node is reached by more than one leg. Every leg is optional except that at least one of query/semantic must be present (plan rejects a request with neither).
| Leg | Score S(n) |
|---|---|
| Structured query, primary results | 1.0, or the executor's own vector-ranked score when the query used vector scoring. |
| Structured query, related (expand) results | The node's fused score if present, else its decay score, else 1.0. |
| Semantic search roots | fuse(sim, 1.0, alpha), roots decay at 1.0 (no traversal distance yet). |
| Hop expansion (1-2 hops from the union so far) | fuse(sim, decay, alpha) when the node also has a similarity signal from leg 2; otherwise the path decay alone (the alpha = 0 face of the fusion model). |
expand_depth is capped at 2 by plan() itself (PlanError::InvalidRequest above that). The semantic leg's window is (0, valid_t]: any vector bucket with an earlier validStart can hold a still-live vector, the same point semantics the query executor's own vector stage uses. fusion::fuse(sim, decay, alpha) is the confidence-decay subsystem's blend function (sim.powf(alpha) * decay.powf(1.0 - alpha), with fast paths at alpha 0 and 1); see Confidence decay.
Exploding nodes to spans¶
Every unioned node is re-read as-of (valid_t, tx_t) (NodeReads::get_as_of) and exploded to its provenance.spans. A node with zero provenance spans (an API-written record with no ingestion origin) is dropped and counted in stats.recall.nodes_without_spans, it contributes to recall but not to the evidence pool. For each span, the planner looks up its ledger row (Ingestor::span_row) for its structural kind, defaulting to "body" (prior 1.0) and counting stats.recall.kind_missing when the row is absent, then slices its text via Ingestor::canonical_slice; a span whose text cannot be sliced is skipped and counted in stats.recall.slices_missing rather than failing the whole plan.
Weight is computed in one fixed association order, required for cross-platform hash determinism (F7 review condition 4):
fn weigh(s: f64, kind: &str, class: SpanClass, config: &PlannerConfig) -> f64 {
let prior = config.kind_priors.get(kind).copied().unwrap_or(1.0);
let base = s * prior;
match class {
SpanClass::Truth => base,
SpanClass::Cache => base * config.cache_discount,
}
}
Nodes may be reached by more than one leg (or expose the same span through more than one path); the pool dedups on the exact span coordinate (source_id, source_version_tx, start, end) and keeps the max-weight candidate, itself resolved deterministically through BTreeMap iteration order.
Truth vs. cache: what verification is allowed to see¶
pub enum SpanClass {
Truth, // verbatim span of a source's canonical text
Cache, // derived artifact; discounted, non-evidentiary
}
Truth spans are exactly what canonical_slice returned: verbatim source text. Cache spans (cached summaries, Phase-4 materializations) are the same struct shape, weight-discounted by planner.cache_discount (default 0.5), and per the spec are never offered to claim verification as supporting evidence, only Truth spans are. As built (PR-050), every span produced by plan() is SpanClass::Truth; the comment at the assignment site notes "derived artifacts arrive in Phase 4," meaning the Cache branch of weigh exists and is tested, but nothing in the current recall path yet constructs a Cache-class candidate.
Data model¶
EvidencePlan (spec §5)¶
pub struct EvidencePlan {
pub plan_model: String, // "v1" (PLAN_MODEL)
pub spans: Vec<PlannedSpan>, // canonical order: (source_id, source_version_tx, start, end) asc
pub budget: u64, // effective B after ceiling + estimate-mode factor
pub used: u64, // tokens actually packed; used <= budget always
pub stats: PlanStats,
pub plan_hash: String, // BLAKE3 hex of the canonical serialization
}
pub struct PlannedSpan {
pub span: SpanRef, // (source_id, source_version_tx, start, end)
pub kind: String, // paragraph | heading | cell | code | body
pub class: SpanClass,
pub weight: f64,
pub tokens: u64,
pub text: String, // the span's canonical text slice
}
The text field is the load-bearing as-built decision (spec §14, as-built clarification 6): the plan carries each span's text, so prompt assembly and claim verification operate on the plan alone. Neither downstream consumer re-reads the sources CF; both trust the plan's own text field, which was sliced once, at plan time, under one storage snapshot.
{
"planModel": "v1",
"budget": 6000,
"used": 5312,
"spans": [
{
"span": {"sourceId": "...", "sourceVersionTx": 1751500000000000, "start": 128, "end": 512},
"kind": "paragraph",
"class": "truth",
"weight": 0.842,
"tokens": 94,
"text": "..."
}
],
"stats": { "poolSpans": 41, "selectedSpans": 18, "deduped": 6, "budgetStrandedByCap": 0 },
"planHash": "b3:..."
}
PlanStats¶
| Field | Meaning |
|---|---|
pool_spans | Candidate spans surviving dedup, before packing. |
selected_spans | Spans actually selected into the plan. |
deduped | Same-source spans dropped as near-identical duplicates. |
budget_skipped | Spans that no longer fit the remaining budget at their turn in the scan (scanning continues past them). |
cap_skipped | Spans blocked by the hard per-source diversity cap. |
unpackable | Spans whose tokens(sp) > B: unpackable at any point in the walk. |
budget_stranded_by_cap | Final unused budget, reported only when at least one cap-skip would otherwise have fit. |
token_estimate | True when any token count came from the heuristic estimator (triggers the 0.9 safety factor). |
recall | Nested RecallStats: query/vector/expanded node counts, nodes without spans, missing ledger kinds, unsliceable spans. |
Algorithms & invariants¶
Phase B: dedup, then greedy packing by density¶
select::select is a pure function: (pool, budget, max_frac_per_source, dedup_overlap, stats) -> (selected spans, tokens used).
Dedup processes candidates in weight-descending order (ties broken by (source_id, source_version_tx, start, end)) so "keep the higher-weight one" falls directly out of insertion order:
let overlap = intersection_len as f64 / min(len_a, len_b) as f64;
let duplicate = overlap > dedup_overlap || overlap == 1.0;
Containment always dedups, even at the threshold
Dedup keys on (source_id, source_version_tx), strictly finer than "same source" (spec, as-built clarification 1): byte offsets are only comparable within one version's canonical text. The rule is strictly-above-threshold (overlap > dedup_overlap) except full containment (overlap == 1.0), which always dedups even when dedup_overlap is configured at exactly 1.0 (as-built clarification 2): a threshold of 1.0 would otherwise exempt containment from dedup entirely, which the spec does not intend.
Greedy packing sorts survivors by density w(sp) / max(1, tokens(sp)), descending, with the same coordinate tie-break. The max(1, ...) floor is F7 review condition 1: without it, a zero-token-estimate span would have infinite density and dominate packing regardless of weight.
stateDiagram-v2
[*] --> Scanning
Scanning --> Packed: tokens(sp) <= B - used\nAND per-source spend + tokens(sp) <= cap
Scanning --> Skipped_Unpackable: tokens(sp) > B
Scanning --> Skipped_BudgetFull: used + tokens(sp) > B\n(but tokens(sp) <= B)
Scanning --> Skipped_CapBlocked: per-source spend + tokens(sp) > cap
Packed --> Scanning: continue to next candidate
Skipped_Unpackable --> Scanning
Skipped_BudgetFull --> Scanning
Skipped_CapBlocked --> Scanning
Scanning --> [*]: pool exhausted Three distinct "did not pack" outcomes are counted separately, and in every case the scan continues rather than stopping: a span that does not fit does not block a smaller span later in the density order from packing.
The hard per-source cap and stranded budget¶
let cap_tokens = (max_frac_per_source * budget as f64).floor() as u64;
if spent_for_source + candidate.tokens > cap_tokens {
stats.cap_skipped += 1;
cap_blocked_a_fitting_span = true; // remembered across the whole scan
continue;
}
// ...after the scan:
stats.budget_stranded_by_cap = if cap_blocked_a_fitting_span { budget - used } else { 0 };
The cap (default max_frac_per_source = 0.4, so no more than 40% of B from one source_id) is hard and never relaxes, even when a source-dominated pool leaves budget unused. As-built clarification 3 pins the exact definition: budget_stranded_by_cap is the final unused budget, and it is only reported nonzero when at least one span was cap-blocked at a moment that span would otherwise have fit the then-remaining budget. This distinguishes "budget unused because the pool ran out of eligible spans" (zero stranding) from "budget unused because the diversity guarantee refused to spend it" (nonzero, visible in stats rather than silently traded away), per the spec's explicit review decision not to make the cap conditional.
Determinism and the plan hash¶
Every ordering decision in the selection path goes through f64::total_cmp, never a partial-order comparison, and every tie breaks on the same (weight desc, source_id, source_version_tx, start, end) tuple. The plan hash (EvidencePlan::hash_canonical) is BLAKE3 over a fixed byte encoding:
Weights are hashed as raw bit patterns specifically so a value that renders identically as a decimal string but differs by one ULP still changes the hash (F7 review condition 4); EvidencePlan's own unit tests assert this directly. Span text is deliberately not part of the hash: the coordinates already pin it, and hashing text would make the hash sensitive to storage-layer slicing behavior rather than to the plan's actual decisions. select's own proptest (selection_is_deterministic_under_input_permutation) confirms the selection outcome (span set and used) is invariant to input ordering, reversal, and rotation.
Budget derivation and the token-estimate fallback¶
let ceilinged = accounting
.zip(model)
.and_then(|(acc, model)| acc.context_window(model))
.map(|ctx| requested.min(ctx / 2))
.unwrap_or(requested);
let budget = if estimate_mode { (ceilinged as f64 * 0.9).floor() as u64 } else { ceilinged };
B = min(configured, floor(model_ctx * 0.5)) when the provider reports a context window (spec OQ-1, resolved at F7 review): the 0.5 factor is a deliberately coarse ceiling, not a target, that must leave room for system prompt, instructions, and output at any context size. Config can only lower the effective budget, never raise it past that ceiling.
When no exact tokenizer is available for the requested span text, the planner falls back to a script-aware heuristic (F7 review condition 2):
A flat bytes / 3.6 underestimates CJK text by roughly 3x (about 3 UTF-8 bytes per character at about 1 token per character), which would silently break the used <= B property; counting every non-ASCII character as one token instead slightly overestimates accented European text, the safe direction. Whenever any span in the plan used the estimator, stats.token_estimate = true and the effective budget is multiplied by 0.9 as a safety margin. A CJK fixture is mandated by the review conditions and present in the module's test suite.
TokenAccounting: the adapter seam¶
pub trait TokenAccounting: Send + Sync {
fn count(&self, model: &str, text: &str) -> Option<u64>;
fn context_window(&self, model: &str) -> Option<u64>;
}
Planner::plan takes accounting: Option<&dyn TokenAccounting> so the planner crate never depends on the generation/LLM stack directly. core-engine/src/llm/mod.rs implements this trait for ProviderAccounting, adapting whatever tokenizer the configured LlmProvider exposes (PR-051). When accounting is None, or the provider has no tokenizer for the requested model, every span falls into estimate mode.
Configuration¶
| Key | Default | Validation |
|---|---|---|
planner.default_budget | 6_000 | Must be positive. |
planner.max_frac_per_source | 0.4 | (0, 1]. |
planner.cache_discount | 0.5 | (0, 1). |
planner.dedup_overlap | 0.8 | (0.5, 1]. |
planner.kind_priors | {paragraph: 1.0, heading: 0.6, cell: 0.8, code: 0.9} | Every value in (0, 1]. |
All ratios are validated at load (TelhaConfig::validate) and rejected outright rather than clamped, per F7 review condition 6. kind_priors is documented as the single source of truth shared with the embedding pipeline's own span-kind priors; the two must not drift apart (cross-spec design note, spec §8).
AppState wiring¶
planner: Arc::new(crate::planner::Planner::new(
Arc::clone(&engine),
Arc::clone(&executor),
Arc::clone(&ingestor),
Some(Arc::clone(&partitions)),
config,
)),
Built once in AppState::new (core-engine/src/api/mod.rs) alongside the query executor, the traversal engine (constructed internally with the configured decay parameters), the ingestor, and the vector partition manager. Passing partitions: None disables the semantic recall leg entirely: a request that supplies semantic against a Planner built without partitions fails loudly (PlanError::Partition) rather than silently skipping the leg. Both the REST /v1/generate handler (api/v1.rs) and the MCP server (mcp/server.rs) call planner.plan(...) and handle PlanError::InsufficientEvidence the same way: generation proceeds only when the request explicitly sets allow_ungrounded, in which case an empty EvidencePlan (EvidencePlan::empty_hash()) is substituted.
As-built notes¶
From spec §14 (Changelog v0.3, the as-built record for PR-050), reconciled against core-engine/src/planner/:
- Module layout matches the spec exactly: recall orchestration in
mod.rs, pure Phase-B inselect.rs, token accounting intokens.rs, the plan model and canonical hash inplan.rs. Ingestor::canonical_slice/span_rowwere added as the named slice API (spec §7 consumers) and are exactly whatmod.rscalls during span exposition; see Ingestion & provenance.- All six F7 review conditions from v0.2 are present in the code, not just described in prose: the density floor (
tokens.max(1)inselect.rs), the script-aware CJK-safe estimator with its mandated fixture, same-source dedup keyed finer than the spec's plain-English description (on(source_id, source_version_tx)),total_cmpordering with the documented tie-break tuple and raw-bit-pattern hashing, the never-relaxed per-source cap withbudget_stranded_by_capreporting, and config validation that rejects rather than clamps. SpanClass::Cacheis implemented but not yet produced. Theweighfunction andEvidencePlan's hash both handle theCachebranch correctly (tested directly inplan.rs's unit tests), but no code path inmod.rs's recall currently constructs aCache-classPlannedSpan; the comment at the relevant call site notes derived artifacts are Phase-4 territory.- Not directly verifiable from
core-engine/src/planner/alone: the spec's success-metric recall-regression fixtures against "the reference corpus" depend on the eval harness (PR-057), which lives outside this module and was not inspected for this page. The budget-adherence and cap-adherence proptests (budget_and_cap_always_hold) and the determinism-under-permutation test are present and readable directly inselect.rs.
Related¶
- Ingestion & provenance · spans as the evidence unit, and the slice API this planner calls.
- Confidence decay (the math) · the
fuse(sim, decay, alpha)function behind every leg's S(n). - Generation orchestration · the consumer that assembles a prompt from
EvidencePlan.spans. - Claim verification · the consumer that scores claims against
Truth-class spans only.