Skip to content

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:

hasher.update(&s.weight.to_bits().to_be_bytes()); // raw IEEE-754 bits, never a decimal string

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):

pub fn estimate_tokens(text: &str) -> u64 {
    // ascii_bytes / 3.6 + non_ascii_chars * 1.0
}

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 in select.rs, token accounting in tokens.rs, the plan model and canonical hash in plan.rs.
  • Ingestor::canonical_slice/span_row were added as the named slice API (spec §7 consumers) and are exactly what mod.rs calls 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) in select.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_cmp ordering with the documented tie-break tuple and raw-bit-pattern hashing, the never-relaxed per-source cap with budget_stranded_by_cap reporting, and config validation that rejects rather than clamps.
  • SpanClass::Cache is implemented but not yet produced. The weigh function and EvidencePlan's hash both handle the Cache branch correctly (tested directly in plan.rs's unit tests), but no code path in mod.rs's recall currently constructs a Cache-class PlannedSpan; 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 in select.rs.