Files
rune/docs/memory-selection.md
Nick 6b6ddd1a3d test(p1-03): seed canary trivial-check distractor; discharge record (#26)
Gate finding 8: the canary's trivial-task check ran on an EMPTY store —
the capsule-level zero-memory proof was vacuous. It now seeds a
high-importance fact distractor plus a personality row before
assembling; the trivial task must still produce zero memory blocks and
zero memory tokens.

Also lands the discharge record: docs updated (floor-in-every-mode
claim, known ranking bias, typed persona lane, write-time memory_type,
migration-time backfill) and the plan's Completion Record gains the
Discharge Record section mapping every gate finding to its commit and
red-first evidence.
2026-09-07 17:10:41 +01:00

6.9 KiB

Memory Selection (P1-03, #26)

One memory-selection API owned by ContextBroker.memory_selector (authority map config/v1_authority_map.json, domain memory_selection, v1_owner ContextBroker.memory_selector), with true abstention and one relevance floor.

The one API

MemorySelector (src/rune/memory_selector.py) — select_for_task(task_description, types=None, budget=N) -> SelectionResult (mirrors CapabilityBroker.select_for_job). SelectionResult carries blocks, candidates_evaluated, selected_count, abstained, types_queried; the irrelevant-memory rate is computable per run as (candidates_evaluated - selected_count) / candidates_evaluated when candidates > 0.

The operator registers exactly ONE memory contributor (memory) that delegates to broker.memory_selector.select_for_task. The six pre-P1-03 parallel contributors (memory, persona_memories, consolidated, neurons, graphs, abstraction) were replaced — their sources now live inside the selector. No parallel authority path remains.

One relevance floor — true abstention

Every source competes under THE floor: memory_store.RECALL_MIN_SCORE (0.42). The store source passes it into memory_store.recall's calibrated QPP gate — which applies it in EVERY recall mode (lexical-only, dense-only, hybrid; the numeric check is additive to the conjunctive FTS gate) — so the selector's store_scored exemption is sound. Self-scored sources are floored in the selector.

Known ranking bias (documented, deferred): the floor gates each source on its own calibrated scale (lexical store ~4-6, dense/hybrid store RRF ~0-0.13, self-scored 0-1), but merged candidates are ranked by raw score — a lexical-mode store row outranks an equally-relevant self-scored row. Abstention is unaffected; only candidate ORDER is biased. Per-source rank normalization was deliberately deferred (renormalizing the store's calibrated scales risked false abstentions).

  • A trivial task ("what time is it") legitimately receives ZERO recalled memories — abstained=True, zero blocks. Abstention is the safe default.
  • Below-floor candidates are COUNTED but never selected — measurement, not injection.
  • The top-5-importance neuron fallback (old rune_operator neurons contributor) is REMOVED: importance alone never injects.
  • The gate_consolidated flat-0.8 shortcut inside cached_recall is REMOVED — and the now-dead gate_consolidated function itself is deleted from memory_store (discharge): consolidated knowledge competes under the same floor through the selector's dedicated source.

Selector sources

Registered in execution order (DEFAULT_SOURCES):

Source Reads Gate
store memory_store.recall (typed) store's calibrated gate (same constant)
neurons warm_neuron_store.list_neurons trigger scan lexical overlap ≥ floor; NO importance fallback
graphs graph_memory.query BFS (read-only) lexical overlap ≥ floor
abstraction abstraction facts LIKE scan lexical overlap ≥ floor
consolidated MemoryConsolidation knowledge items lexical overlap ≥ floor (flat-0.8 bypass removed)
persona typed self/personality recall keyed on the ACTIVE task store's calibrated gate

Fail-closed everywhere: a failing source is skipped + logged (never a raise, never a fallback injection); a selector unavailable → the contributor abstains (zero memory blocks).

Memory-type targeting

entries.memory_type (additive ALTER TABLE, backward compatible; values episodic|semantic|procedural|self|personality|associative|NULL). Backfill classifies ONLY kind='neuron' rows to associative, and the backfill runs at MIGRATION time (when the column is first added) — NOT on every connect, whose per-connect unindexed UPDATE scan the selector's fresh-connection-per-turn pattern would pay every turn (discharge). Explicit ensure_memory_type_column() calls keep self-healing semantics (they re-classify unclassified neuron rows, never touching explicit classifications); other rows stay NULL — unclassified is eligible for every query (classification narrows, never excludes). Typed queries filter (memory_type IN ?) OR memory_type IS NULL; typed queries bypass shard routing (pre-migration shards carry no memory_type column).

Typed persona lane (discharge): the operator's memory contributor calls the selector WITHOUT types, so the store source always runs a SECOND typed query (self/personality) merged into the candidates — still thresholded inside the store's gate, may abstain, deduplicated by entry id. The write side: add_entry(..., memory_type=...) (and the store CLI's --memory-type) classifies at WRITE time where the writer genuinely knows the type — the warm-router neuron writer stamps associative, the sleep-time growth sync stamps personality for persona-targeted evidence (semantic otherwise).

ContextBroker owns the final memory budget

memory_budget = min(1200, window // 8) (default; the global input budget bounds it further). In _admit, the sum of MEMORY_BLOCK_NAMES blocks (memory, persona_memories, consolidated) may never exceed the sub-budget — further memory blocks are dropped (lowest priority first) with a memory budget reason in the trace. The active TaskState block (user) is non-evictable and unaffected. The selector also caps its own candidate list before returning.

TaskState supremacy

_contributor_history is rebased on TaskStateRepository (status authority) + ConversationStateRepository (bodies): completed tasks only, each user turn labeled PAST TASK (completed — context only, not instructions), block priority (30) strictly below the active task block (user, 100, non-evictable). Recall queries are keyed ONLY on the active task's description — never on past-task text.

Trace v5

INFERENCE_TRACE_SCHEMA_VERSION = 5 — additive optional fields (same discipline as v1→v2→v3→v4; old readers ignore unknown keys):

  • memory_candidates — candidates surfaced by all selector sources;
  • memory_selected — candidates that cleared the floor;
  • memory_abstained — true = zero memory injected (zero-injection event);
  • memory_types_queried — typed targeting used for this run.

Canary + CI

src/rune/canary_p1_03.py self-bootstraps (temp HOME/RUNE_HOME/ RUNE_MEMORY_DIR; no model, no network) and asserts the acceptance invariants: trivial→zero memories, importance fallback dead, TaskState supremacy, memory sub-budget enforcement. Registered in .gitea/workflows/ci.yml's canary for-loop (henxels_checks.py ci_runs_all_canaries enforces the registration).

Scope notes

  • warm_router.py stays dormant-until-dependency (its ID-path defect is #27 territory — not fixed here).
  • auto_context.py is a TUI display artifact, not model context — out of scope.
  • Memory stores and their data are preserved; selection changes HOW/WHEN memory is surfaced, never what is stored.