Skip to main content

plugmem_core/memory/
recall.rs

1//! Hybrid recall: source ranking, RRF fusion, budgeted selection and the
2//! rendered prompt block (–7).
3//!
4//! The pipeline (scratch buffers reused, the zero-alloc invariant):
5//!
6//! 1. the tag filter builds a sorted allow-set (intersection of tag
7//!    lists); an unknown tag empties it — and the result;
8//! 2. every source admits a candidate only through the shared rule:
9//!    not tombstoned, `recorded_at ≤ as_of`, inside its validity
10//!    interval (`include_closed` drops the upper bound), in the
11//!    allow-set when tags are present;
12//! 3. sources produce ranked lists of ≤ 128: BM25 over the query text,
13//!    graph expansion from entity anchors (breadth-first over the edge
14//!    arenas, weight `decay^depth`, hard caps on entities, edges and
15//!    candidates), temporal range scan ranked by recency;
16//! 4. **RRF**: `score(f) = Σ_s w_s / (rrf_k + rank_s(f))` — rank-based,
17//!    so sources need no score calibration against each other;
18//! 5. recency boost `× (1 + w_rec · 2^(-age / half_life))`;
19//! 6. greedy selection by fused score under `k` and the token budget
20//!    (`len(text)/4 + 8` tokens per fact);
21//! 7. rendering into the compact prompt block (format fixed by golden
22//!    tests).
23//!
24//! Revision chains need no extra dedup here: closing a fact bounds its
25//! validity at the successor's start, so the `as_of` rule keeps at most
26//! one live version of a chain (with `include_closed` the whole chain is
27//! shown by design, intervals marking who is who).
28
29use alloc::string::String;
30use alloc::vec::Vec;
31use core::fmt::Write as _;
32
33use plugmem_arena::TermId;
34
35use crate::error::Error;
36use crate::id::{EntityId, FactId};
37use crate::index::bm25::Bm25Scratch;
38use crate::index::hnsw::HnswScratch;
39use crate::index::vecpool::{VecScratch, dot_i8};
40use crate::index::{IntersectScratch, intersect};
41use crate::model::{
42    FactRecord, VALID_TO_OPEN, edge_end, edge_floor, edge_history_ceiling, edge_history_floor,
43};
44use crate::tokenizer::Tokenizer;
45
46use super::Memory;
47
48/// Source bits of [`RecalledFact::sources`].
49pub mod source {
50    /// The lexical (BM25) source.
51    pub const BM25: u8 = 1;
52    /// The graph-expansion source.
53    pub const GRAPH: u8 = 1 << 1;
54    /// The temporal-range source.
55    pub const TIME: u8 = 1 << 2;
56    /// The vector (quantized flat) source.
57    pub const VEC: u8 = 1 << 3;
58}
59
60/// Per-source candidate cap.
61const SOURCE_CAP: usize = 128;
62/// Tag allow-sets up to this size are cheaper to inspect directly than to
63/// discover through a broad temporal scan. Larger sets keep the temporal-first
64/// path, which avoids materializing a large tag-side candidate list.
65const TEMPORAL_TAG_FIRST_MAX: usize = SOURCE_CAP * 64;
66
67/// Graph expansion caps.
68const GRAPH_ENTITY_CAP: usize = 64;
69const GRAPH_FACT_CAP: usize = 256;
70const GRAPH_EDGE_CAP: usize = 128;
71/// Hard budget on posting entries the graph source may *examine* — a hub
72/// entity with tens of thousands of facts must not turn expansion into a
73/// full decode of its list (the "hub super-node" guard applies
74/// to work, not only to the candidate count).
75const GRAPH_EXAMINE_CAP: usize = 2048;
76
77/// Stop-frequency guard of the lexical source: a query term present in
78/// more than 1/8 of the corpus (and in over [`STOP_DF_FLOOR`] documents)
79/// is dropped from the query — its idf makes it nearly rank-neutral
80/// while its posting list dominates the decode cost (querying "the" must
81/// not cost O(corpus)). When *every* term is stop-frequent the least
82/// frequent one is kept, so such a query still answers.
83const STOP_DF_DIVISOR: u64 = 8;
84/// Below this document frequency a term is never considered
85/// stop-frequent (small corpora skip nothing).
86const STOP_DF_FLOOR: u64 = 1024;
87
88/// A recall request. `Default`-like construction via
89/// [`RecallQuery::text`] plus field overrides.
90#[derive(Clone, Copy, Debug)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92pub struct RecallQuery<'a> {
93    /// Host timestamp, unix milliseconds.
94    pub now: u64,
95    /// Free-text query for the lexical source.
96    pub text: Option<&'a str>,
97    /// Query embedding for the vector source (`len == Config::dim`).
98    pub vector: Option<&'a [f32]>,
99    /// Tag filter: a fact must carry *all* of these.
100    pub tags: &'a [&'a str],
101    /// Entity anchors for the graph source.
102    pub entities: &'a [&'a str],
103    /// Validity instant; defaults to `now`.
104    pub as_of: Option<u64>,
105    /// `recorded_at` window `[from, to)` for the temporal source.
106    pub range: Option<(u64, u64)>,
107    /// Result cap; `0` means the default 8, hard ceiling 64.
108    pub k: usize,
109    /// Token budget of the rendered block; defaults to 512.
110    pub token_budget: Option<usize>,
111    /// Show closed revisions too (whole chains, marked by intervals).
112    pub include_closed: bool,
113    /// HNSW beam-width override for the vector source; defaults to
114    /// `Config::hnsw_ef_search`. Ignored while the engine is in the flat
115    /// regime (below `Config::flat_to_hnsw`).
116    pub ef: Option<usize>,
117    /// Graph expansion depth for this query; defaults to
118    /// `Config::graph_depth`, and is not capped: the cost of a walk is held by
119    /// the entity and edge caps, not by the hop count.
120    ///
121    /// A per-call knob for the same reason `k` and `token_budget` are: how wide
122    /// a net to cast is a property of the question, not of the memory. "What is
123    /// known around this person" wants more hops than "what is this person's
124    /// stated preference", and one number for the whole database cannot be both.
125    ///
126    /// `Some(0)` asks for no expansion at all: the anchors' own facts, and no
127    /// neighbours.
128    pub graph_depth: Option<u32>,
129}
130
131impl<'a> RecallQuery<'a> {
132    /// A plain text query with every other knob at its default.
133    pub fn text(now: u64, text: &'a str) -> Self {
134        Self {
135            now,
136            text: Some(text),
137            vector: None,
138            tags: &[],
139            entities: &[],
140            as_of: None,
141            range: None,
142            k: 0,
143            token_budget: None,
144            include_closed: false,
145            graph_depth: None,
146            ef: None,
147        }
148    }
149}
150
151/// One recalled fact.
152#[derive(Clone, Copy, Debug, PartialEq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154pub struct RecalledFact {
155    /// The fact.
156    pub id: FactId,
157    /// Fused score (RRF + recency boost).
158    pub score: f32,
159    /// Which sources surfaced it (see [`source`]).
160    pub sources: u8,
161    /// Subject entity or [`EntityId::NONE`].
162    pub entity: EntityId,
163    /// Knowledge axis.
164    pub recorded_at: u64,
165    /// Truth axis, start.
166    pub valid_from: u64,
167    /// Truth axis, end ([`VALID_TO_OPEN`] = open).
168    pub valid_to: u64,
169}
170
171/// One edge the graph source walked (: agents want the relations,
172/// not only the facts).
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
175pub struct RecalledEdge {
176    /// Source entity.
177    pub src: EntityId,
178    /// Relation term.
179    pub rel: TermId,
180    /// Destination entity.
181    pub dst: EntityId,
182    /// Provenance fact or [`FactId::NONE`].
183    pub provenance: FactId,
184}
185
186/// A recall response. Reusable: pass to
187/// [`Memory::recall_into`] repeatedly and the buffers are recycled.
188#[derive(Clone, Debug, Default)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190pub struct RecallResult {
191    /// Selected facts, descending fused score.
192    pub facts: Vec<RecalledFact>,
193    /// Edges walked by the graph source (deduplicated).
194    pub edges: Vec<RecalledEdge>,
195    /// The compact prompt block (empty string when nothing was found).
196    pub rendered: String,
197    /// `true` when selection stopped at `k` or the token budget with
198    /// candidates left over.
199    pub truncated: bool,
200}
201
202/// Reusable recall scratch — **caller-owned**, so [`Memory::recall`] and
203/// [`Memory::recall_into`] take `&self`: many readers can recall the same
204/// engine at once, each threading its own scratch (the host wraps one per
205/// thread). Carries every buffer a recall mutates — the query-side term/score
206/// vectors, the fusion map, *and* its own tokenizer and name-normalization
207/// buffer — so a recall never touches the engine's write-side scratches. Reused
208/// across calls it upholds the zero-alloc invariant.
209///
210/// Opaque: construct with [`RecallScratch::new`] (or `Default`) and pass by
211/// `&mut`; the fields are engine-internal.
212#[derive(Debug, Default)]
213pub struct RecallScratch {
214    /// Read-path tokenizer (query text + entity-name normalization). Kept here,
215    /// not in [`Memory`], so recall stays `&self`; writers use the engine's own.
216    tokenizer: Tokenizer,
217    /// Scratch for one normalized entity name during graph-anchor resolution.
218    name_scratch: String,
219    bm25: Bm25Scratch,
220    intersect: IntersectScratch,
221    allow: Vec<FactId>,
222    allow_bits: AllowFilter,
223    tag_terms: Vec<u32>,
224    query_terms: Vec<u32>,
225    bm25_out: Vec<(FactId, f32)>,
226    vec: VecScratch,
227    vec_out: Vec<(FactId, f32)>,
228    hnsw: HnswScratch,
229    hnsw_out: Vec<(u32, f32)>,
230    graph_out: Vec<(FactId, f32)>,
231    time_out: Vec<(FactId, f32)>,
232    time_tag: Vec<(FactId, u64)>,
233    visited: Vec<(EntityId, f32)>,
234    fused: hashbrown::HashMap<u32, (f32, u8), xxhash_rust::xxh3::Xxh3Builder>,
235    ranked: Vec<(FactId, f32, u8)>,
236    tags_tmp: Vec<TermId>,
237}
238
239impl RecallScratch {
240    /// An empty recall scratch (all buffers grow on first use). One per
241    /// concurrent reader; reused across that reader's calls for zero-alloc.
242    pub fn new() -> Self {
243        Self::default()
244    }
245}
246
247impl Memory<'_> {
248    /// Runs a recall, allocating a fresh [`RecallScratch`] and
249    /// [`RecallResult`]. Convenience over [`Memory::recall_into`] for one-shot
250    /// callers; a hot loop should own a [`RecallScratch`] and call
251    /// `recall_into` to stay zero-alloc.
252    pub fn recall(&self, q: RecallQuery<'_>) -> Result<RecallResult, Error> {
253        let mut scratch = RecallScratch::default();
254        let mut out = RecallResult::default();
255        self.recall_into(q, &mut scratch, &mut out)?;
256        Ok(out)
257    }
258
259    /// Runs a recall into a reused result and caller-owned scratch (the
260    /// zero-alloc path: after warm-up neither `s` nor `out` allocate).
261    ///
262    /// Takes `&self` — recall never mutates engine data; every mutable buffer
263    /// it needs lives in `s`. This is what lets many readers recall
264    /// one engine concurrently, each with its own [`RecallScratch`].
265    pub fn recall_into(
266        &self,
267        q: RecallQuery<'_>,
268        s: &mut RecallScratch,
269        out: &mut RecallResult,
270    ) -> Result<(), Error> {
271        out.facts.clear();
272        out.edges.clear();
273        out.rendered.clear();
274        out.truncated = false;
275
276        let k = if q.k == 0 { 8 } else { q.k.min(64) };
277        let budget = q.token_budget.unwrap_or(512);
278        let as_of = q.as_of.unwrap_or(q.now);
279
280        // 1. Tag allow-set. An unknown tag can match nothing.
281        s.allow.clear();
282        s.tag_terms.clear();
283        let mut dead_tag = false;
284        for tag in q.tags {
285            match self.terms.lookup(tag) {
286                Some(term) => s.tag_terms.push(term.0),
287                None => dead_tag = true,
288            }
289        }
290        if !dead_tag && !s.tag_terms.is_empty() {
291            intersect(&self.tags_idx, &s.tag_terms, &mut s.intersect, &mut s.allow);
292        }
293        let filtered = !q.tags.is_empty();
294        if filtered && (dead_tag || s.allow.is_empty()) {
295            return Ok(());
296        }
297        // Membership filter over the set the sources will test against, built
298        // once per query rather than searched once per candidate.
299        if filtered {
300            s.allow_bits.fill(&s.allow);
301        } else {
302            s.allow_bits.clear();
303        }
304
305        // 2–3. Sources (each admits through the shared rule).
306        s.bm25_out.clear();
307        if let Some(text) = q.text {
308            s.query_terms.clear();
309            let terms = &self.terms;
310            // Disjoint field borrows of `s`: the tokenizer writes into
311            // `query_terms`, both live in the caller's scratch.
312            let query_terms = &mut s.query_terms;
313            s.tokenizer.tokenize(text, &mut |token| {
314                if let Some(term) = terms.lookup(token) {
315                    query_terms.push(term.0);
316                }
317            });
318            // Stop-frequency filter (see the constants above).
319            let docs = self.bm25.docs();
320            let is_stop = |df: u64| df > STOP_DF_FLOOR && df * STOP_DF_DIVISOR > docs;
321            if s.query_terms
322                .iter()
323                .any(|&t| !is_stop(u64::from(self.bm25.df(t))))
324            {
325                let bm25 = &self.bm25;
326                s.query_terms.retain(|&t| !is_stop(u64::from(bm25.df(t))));
327            } else if let Some(&least) = s.query_terms.iter().min_by_key(|&&t| self.bm25.df(t)) {
328                s.query_terms.clear();
329                s.query_terms.push(least);
330            }
331            let facts = &self.facts;
332            let allow = &s.allow;
333            let allow_bits = &s.allow_bits;
334            self.bm25.search(
335                (self.cfg.bm25_k1, self.cfg.bm25_b),
336                &s.query_terms,
337                SOURCE_CAP,
338                &mut |id| {
339                    admit(
340                        facts,
341                        allow,
342                        allow_bits,
343                        filtered,
344                        as_of,
345                        q.include_closed,
346                        id,
347                    )
348                    .is_some()
349                },
350                &mut s.bm25,
351                &mut s.bm25_out,
352            );
353        }
354
355        // Vector source: flat quantized search below the HNSW threshold,
356        // graph search plus a flat-tail scan above it.
357        s.vec_out.clear();
358        if let Some(v) = q.vector
359            && self.cfg.dim > 0
360        {
361            let res = if self.hnsw.indexed() == 0 {
362                let facts = &self.facts;
363                let allow = &s.allow;
364                let allow_bits = &s.allow_bits;
365                self.vecs.search(
366                    v,
367                    SOURCE_CAP,
368                    &mut |id| {
369                        admit(
370                            facts,
371                            allow,
372                            allow_bits,
373                            filtered,
374                            as_of,
375                            q.include_closed,
376                            id,
377                        )
378                        .is_some()
379                    },
380                    &mut s.vec,
381                    &mut s.vec_out,
382                )
383            } else {
384                self.vec_graph_source(v, &q, as_of, filtered, s)
385            };
386            res?;
387        }
388
389        // Graph anchors resolve here (name normalization needs the tokenizer
390        // and name buffer — both in the caller's scratch); expansion is
391        // read-only. `tokenizer` and `name_scratch` are disjoint fields of `s`.
392        s.visited.clear();
393        for name in q.entities {
394            super::normalize_name(&mut s.tokenizer, name, &mut s.name_scratch);
395            let found = self.lookup_entity_by_norm(&s.name_scratch);
396            if let Some(id) = found
397                && !s.visited.iter().any(|&(e, _)| e == id)
398            {
399                s.visited.push((id, 1.0));
400            }
401        }
402        self.graph_source(&q, as_of, filtered, s, out);
403        self.time_source(&q, as_of, filtered, s);
404
405        // 4. RRF fusion.
406        s.fused.clear();
407        for (list, weight, bit) in [
408            (&s.bm25_out, self.cfg.w_bm25, source::BM25),
409            (&s.vec_out, self.cfg.w_vec, source::VEC),
410            (&s.graph_out, self.cfg.w_graph, source::GRAPH),
411            (&s.time_out, self.cfg.w_time, source::TIME),
412        ] {
413            for (rank, &(fact, _)) in list.iter().enumerate() {
414                let contribution = weight / (self.cfg.rrf_k as f32 + rank as f32 + 1.0);
415                let entry = s.fused.entry(fact.0).or_insert((0.0, 0));
416                entry.0 += contribution;
417                entry.1 |= bit;
418            }
419        }
420
421        // 5. Recency boost.
422        let half_life_ms = self.cfg.half_life_days as f32 * 86_400_000.0;
423        s.ranked.clear();
424        for (&id, &(score, bits)) in &s.fused {
425            let record = self.facts.get(&id.to_be_bytes()).expect("fused ids exist");
426            let age = q.now.saturating_sub(record.recorded_at) as f32;
427            let boost = 1.0 + self.cfg.w_recency * libm::exp2f(-age / half_life_ms);
428            s.ranked.push((FactId(id), score * boost, bits));
429        }
430        s.ranked
431            .sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
432
433        // 6. Budgeted selection.
434        let mut spent = 0usize;
435        for &(id, score, bits) in &s.ranked {
436            if out.facts.len() == k {
437                out.truncated = true;
438                break;
439            }
440            let record = self
441                .facts
442                .get(&id.0.to_be_bytes())
443                .expect("ranked ids exist");
444            let cost = self.texts.get(record.text).len() / 4 + 8;
445            if spent + cost > budget {
446                out.truncated = true;
447                break;
448            }
449            spent += cost;
450            out.facts.push(RecalledFact {
451                id,
452                score,
453                sources: bits,
454                entity: record.entity,
455                recorded_at: record.recorded_at,
456                valid_from: record.valid_from,
457                valid_to: record.valid_to,
458            });
459        }
460
461        // 7. Render.
462        self.render(out, &mut s.tags_tmp);
463        Ok(())
464    }
465
466    /// The above-threshold vector source (phase 2): an HNSW
467    /// beam search over the graph plus an exact scan of the flat tail
468    /// (vectors appended since the last `maintain` build), merged,
469    /// admission-filtered and capped like every other source.
470    fn vec_graph_source(
471        &self,
472        v: &[f32],
473        q: &RecallQuery<'_>,
474        as_of: u64,
475        filtered: bool,
476        s: &mut RecallScratch,
477    ) -> Result<(), Error> {
478        let RecallScratch {
479            vec,
480            hnsw,
481            hnsw_out,
482            vec_out,
483            allow,
484            allow_bits,
485            ..
486        } = s;
487        self.vecs.quantize_query(v, vec)?;
488        let (q_scale, q_q) = self.vecs.quantized(vec);
489        let ef = q.ef.unwrap_or(self.cfg.hnsw_ef_search).max(1);
490        self.hnsw
491            .search_quantized(&self.vecs, (q_scale, q_q), ef, hnsw, hnsw_out);
492        for slot in self.hnsw.indexed()..self.vecs.len() as u32 {
493            let (s_scale, s_q) = self.vecs.quant(slot as usize);
494            hnsw_out.push((slot, q_scale * s_scale * dot_i8(q_q, s_q) as f32));
495        }
496        vec_out.clear();
497        for &(slot, sim) in hnsw_out.iter() {
498            let fact = FactId(self.vecs.slot_fact(slot as usize));
499            if admit(
500                &self.facts,
501                allow,
502                allow_bits,
503                filtered,
504                as_of,
505                q.include_closed,
506                fact,
507            )
508            .is_some()
509            {
510                vec_out.push((fact, sim));
511            }
512        }
513        vec_out.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
514        vec_out.truncate(SOURCE_CAP);
515        Ok(())
516    }
517
518    /// Graph expansion: anchors → neighbors (≤ depth), candidate facts of
519    /// every visited entity plus edge provenance, ranked by hop weight.
520    fn graph_source(
521        &self,
522        q: &RecallQuery<'_>,
523        as_of: u64,
524        filtered: bool,
525        s: &mut RecallScratch,
526        out: &mut RecallResult,
527    ) {
528        let RecallScratch {
529            allow,
530            allow_bits,
531            graph_out,
532            visited,
533            ..
534        } = s;
535        graph_out.clear();
536        if visited.is_empty() {
537            return;
538        }
539
540        // Breadth-first: `frontier` marks where the current depth starts.
541        //
542        // Both caps full means every further edge is a no-op — it can neither
543        // enter `out.edges` nor `visited` — so expansion stops there instead of
544        // decoding the rest of a hub's edge list. The visited prefix, and with
545        // it the result, is exactly what an exhaustive walk produced.
546        let full = |edges: &Vec<RecalledEdge>, visited: &Vec<(EntityId, f32)>| {
547            edges.len() >= GRAPH_EDGE_CAP && visited.len() >= GRAPH_ENTITY_CAP
548        };
549        let mut frontier = 0usize;
550        let mut weight = 1.0f32;
551        // The query's depth when it named one, else the configured default.
552        // Unbounded on purpose: what a walk may *cost* is held by the entity and
553        // edge caps above, not by the hop count, so a ceiling here would only
554        // forbid the case it is cheapest in — a sparse chain, where each hop
555        // adds one entity.
556        let depth = q.graph_depth.unwrap_or(self.cfg.graph_depth);
557        'expand: for _ in 0..depth {
558            let depth_end = visited.len();
559            // Nothing new at the last depth means nothing new at any deeper one:
560            // the frontier is empty and every further pass would be a no-op.
561            // Without this, an absurd depth would spin instead of finishing.
562            if depth_end == frontier {
563                break;
564            }
565            weight *= self.cfg.graph_decay;
566            for at in frontier..depth_end {
567                if full(&out.edges, visited) {
568                    break 'expand;
569                }
570                let (entity, _) = visited[at];
571                self.neighbors(
572                    entity,
573                    as_of,
574                    q.as_of.is_some(),
575                    &mut |neighbor, rel, this_side_src, provenance| {
576                        let (src, dst) = if this_side_src {
577                            (entity, neighbor)
578                        } else {
579                            (neighbor, entity)
580                        };
581                        let edge = RecalledEdge {
582                            src,
583                            rel,
584                            dst,
585                            provenance,
586                        };
587                        if out.edges.len() < GRAPH_EDGE_CAP && !out.edges.contains(&edge) {
588                            out.edges.push(edge);
589                        }
590                        if visited.len() < GRAPH_ENTITY_CAP
591                            && !visited.iter().any(|&(e, _)| e == neighbor)
592                        {
593                            visited.push((neighbor, weight));
594                        }
595                        !full(&out.edges, visited)
596                    },
597                );
598            }
599            frontier = depth_end;
600        }
601
602        // Candidate facts: every visited entity's facts at that entity's
603        // weight, plus provenance facts at their edge's weight. Both the
604        // candidate count and the *examined* entries are budgeted.
605        let mut examined = 0usize;
606        'entities: for &(entity, weight) in visited.iter() {
607            for (fact, _) in self.entity_facts.entries(entity.0) {
608                examined += 1;
609                if graph_out.len() >= GRAPH_FACT_CAP || examined > GRAPH_EXAMINE_CAP {
610                    break 'entities;
611                }
612                if admit(
613                    &self.facts,
614                    allow,
615                    allow_bits,
616                    filtered,
617                    as_of,
618                    q.include_closed,
619                    fact,
620                )
621                .is_some()
622                {
623                    graph_out.push((fact, weight));
624                }
625            }
626        }
627        for edge in out.edges.iter() {
628            if graph_out.len() >= GRAPH_FACT_CAP {
629                break;
630            }
631            if let Some(fact) = edge.provenance.some()
632                && !graph_out.iter().any(|&(f, _)| f == fact)
633                && admit(
634                    &self.facts,
635                    allow,
636                    allow_bits,
637                    filtered,
638                    as_of,
639                    q.include_closed,
640                    fact,
641                )
642                .is_some()
643            {
644                graph_out.push((fact, self.cfg.graph_decay));
645            }
646        }
647        graph_out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
648        graph_out.truncate(SOURCE_CAP);
649        graph_out.dedup_by_key(|&mut (f, _)| f);
650    }
651
652    /// Temporal range source: facts recorded in `[from, to)`, most recent
653    /// first.
654    fn time_source(&self, q: &RecallQuery<'_>, as_of: u64, filtered: bool, s: &mut RecallScratch) {
655        s.time_out.clear();
656        let Some((from, to)) = q.range else { return };
657        if filtered && !s.allow.is_empty() && s.allow.len() <= TEMPORAL_TAG_FIRST_MAX {
658            self.time_source_from_tags(from, to, as_of, q.include_closed, s);
659            return;
660        }
661        let RecallScratch {
662            allow,
663            allow_bits,
664            time_out,
665            ..
666        } = s;
667        let mut from_key = [0u8; 12];
668        plugmem_arena::key::write_pair(&mut from_key, from, 0);
669        let mut to_key = [0u8; 12];
670        plugmem_arena::key::write_pair(&mut to_key, to, 0);
671        for slot in self.temporal.range_rev(&from_key, &to_key) {
672            if admit(
673                &self.facts,
674                allow,
675                allow_bits,
676                filtered,
677                as_of,
678                q.include_closed,
679                slot.fact,
680            )
681            .is_some()
682            {
683                time_out.push((slot.fact, slot.recorded_at as f32));
684                // The reverse range starts at the newest record, so once the
685                // source cap is full the remaining entries cannot outrank
686                // these candidates by recency.
687                if time_out.len() == SOURCE_CAP {
688                    break;
689                }
690            }
691        }
692    }
693
694    /// Tag-first temporal source used when the tag allow-set is smaller than
695    /// the recent temporal window. It preserves the temporal source's exact
696    /// newest-first ordering while avoiding a broad temporal scan.
697    fn time_source_from_tags(
698        &self,
699        from: u64,
700        to: u64,
701        as_of: u64,
702        include_closed: bool,
703        s: &mut RecallScratch,
704    ) {
705        let RecallScratch {
706            allow,
707            time_tag,
708            time_out,
709            ..
710        } = s;
711        time_tag.clear();
712        for &fact in allow.iter() {
713            let Some(record) = admit(
714                &self.facts,
715                &[],
716                &AllowFilter::default(),
717                false,
718                as_of,
719                include_closed,
720                fact,
721            ) else {
722                continue;
723            };
724            if record.recorded_at >= from && record.recorded_at < to {
725                time_tag.push((fact, record.recorded_at));
726            }
727        }
728        time_tag.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
729        time_out.extend(
730            time_tag
731                .iter()
732                .take(SOURCE_CAP)
733                .map(|&(fact, recorded_at)| (fact, recorded_at as f32)),
734        );
735    }
736
737    /// Visits the edges touching `entity` in both mirrored arenas as
738    /// `(neighbor, rel, entity_is_src, provenance)` — outgoing in ascending
739    /// key order, then incoming.
740    ///
741    /// The walk is **lazy and interruptible**: `visit` returning `false` stops
742    /// it. A hub entity holds as many edges as the corpus has records, and
743    /// expansion consumes at most a fixed cap of them, so materializing the
744    /// whole list would make graph recall O(edges of the hub) for a bounded
745    /// answer. Stopping is a pure prefix of the same deterministic order, so
746    /// the caller's result is unchanged.
747    fn neighbors(
748        &self,
749        entity: EntityId,
750        as_of: u64,
751        historical: bool,
752        visit: &mut impl FnMut(EntityId, TermId, bool, FactId) -> bool,
753    ) {
754        if historical {
755            // History is keyed `[a | valid_from | edge]`, so walking backwards
756            // from `as_of` yields the entity's versions newest-first: those
757            // that most recently became true, and so the ones most likely to
758            // still be valid. The range already enforces `valid_from <= as_of`;
759            // `as_of < valid_to` is the other half of the validity test.
760            //
761            // The walk stops as soon as the caller has enough valid edges, so
762            // a deep history costs nothing extra for the usual question. The
763            // exception is an instant at which the entity had *no* valid edge
764            // at all: there is nothing to stop at, so proving the absence
765            // reads every version that had already begun. Answering that in
766            // sublinear time needs an interval index, not an ordering.
767            let from = edge_history_floor(entity);
768            let to = edge_history_ceiling(entity, as_of);
769            for (arena, entity_is_src) in
770                [(&self.edges_hist_out, true), (&self.edges_hist_in, false)]
771            {
772                for e in arena.range_rev(&from, &to) {
773                    if as_of < e.valid_to && !visit(e.b, e.rel, entity_is_src, e.fact) {
774                        return;
775                    }
776                }
777            }
778        } else {
779            let from = edge_floor(entity);
780            let to = edge_end(entity);
781            for (arena, entity_is_src) in [(&self.edges_out, true), (&self.edges_in, false)] {
782                for e in arena.range(&from, &to) {
783                    if !visit(e.b, e.rel, entity_is_src, e.fact) {
784                        return;
785                    }
786                }
787            }
788        }
789    }
790
791    /// Renders the compact prompt block (format fixed by golden tests).
792    fn render(&self, out: &mut RecallResult, tags_tmp: &mut Vec<TermId>) {
793        if out.facts.is_empty() && out.edges.is_empty() {
794            return; // empty string: don't spend tokens saying "nothing"
795        }
796        out.rendered.push_str("## memory\n");
797        for fact in &out.facts {
798            let record = self
799                .facts
800                .get(&fact.id.0.to_be_bytes())
801                .expect("selected ids exist");
802            // Deferred validation: tolerate invalid text bytes —
803            // an unreadable fact renders with an empty body, never a panic.
804            let text = core::str::from_utf8(self.texts.get(record.text)).unwrap_or("");
805            let _ = write!(out.rendered, "- [f{}] ", fact.id.0);
806            // A corrupt subject name (deferred validation)
807            // renders without the subject prefix rather than panicking.
808            if let Some(entity) = fact.entity.some()
809                && let Some(name) = self.entity_name(entity)
810            {
811                let _ = write!(out.rendered, "{name}: ");
812            }
813            out.rendered.push_str(text);
814            out.rendered.push_str(" (");
815            render_ym(&mut out.rendered, fact.valid_from);
816            if fact.valid_to == VALID_TO_OPEN {
817                out.rendered.push_str("; active)");
818            } else {
819                out.rendered.push_str(" → ");
820                render_ym(&mut out.rendered, fact.valid_to);
821                out.rendered.push_str("; closed)");
822            }
823            tags_tmp.clear();
824            self.tags_of(fact.id, tags_tmp);
825            for &tag in tags_tmp.iter() {
826                let _ = write!(out.rendered, " #{}", self.terms.resolve(tag));
827            }
828            out.rendered.push('\n');
829        }
830        for edge in &out.edges {
831            // Deferred validation, as for a fact's text and subject name: an
832            // edge whose endpoints do not resolve is rendered as nothing
833            // rather than as a panic. Only a corrupt image reaches this, and
834            // `verify` reports it explicitly.
835            let (Some(src), Some(dst)) = (self.entity_name(edge.src), self.entity_name(edge.dst))
836            else {
837                continue;
838            };
839            let _ = writeln!(
840                out.rendered,
841                "- links: {src} —{}→ {dst}",
842                self.terms.resolve(edge.rel),
843            );
844        }
845    }
846}
847
848/// The tag allow-set, as the sources actually use it.
849///
850/// The set is a sorted vector because [`Memory::time_source_from_tags`]
851/// *enumerates* it. Every other source asks a different question — "is this
852/// one fact in it?" — and asks it once per candidate, which for a graph
853/// expansion under a tag filter is thousands of times. A binary search
854/// answers that in a dozen unpredictable branches over a list sized like the
855/// tag; the filter below answers "no" in one word read, and only "maybe"
856/// costs the search.
857///
858/// The filter is a Bloom filter over the member ids: a member's bit is always
859/// set, so a clear bit is proof of absence and the exact answer is unchanged.
860#[derive(Debug, Default)]
861struct AllowFilter {
862    /// Bit per hashed id; length is a power of two so the hash needs no
863    /// modulo. Empty when no tag filter is active.
864    bits: Vec<u64>,
865    /// `log2(bits.len() * 64)`, the shift that maps a hash onto a bit index.
866    shift: u32,
867}
868
869/// Bits per member. Eight keeps the false-positive rate near 12% while the
870/// whole filter stays small enough to sit in L1 for a realistic tag.
871const ALLOW_BITS_PER_MEMBER: usize = 8;
872/// Smallest and largest filter, in 64-bit words: 64 bytes to 128 KiB.
873const ALLOW_MIN_WORDS: usize = 8;
874const ALLOW_MAX_WORDS: usize = 1 << 14;
875
876impl AllowFilter {
877    /// Rebuilds the filter over `allow`. Reuses the buffer, so a warm scratch
878    /// does not allocate.
879    fn fill(&mut self, allow: &[FactId]) {
880        // Clamped before rounding up: `next_power_of_two` overflows rather
881        // than saturates, and on wasm32 `usize` is 32 bits, so a large
882        // allow-set could reach that edge.
883        let words = allow
884            .len()
885            .saturating_mul(ALLOW_BITS_PER_MEMBER)
886            .div_ceil(64)
887            .clamp(ALLOW_MIN_WORDS, ALLOW_MAX_WORDS)
888            .next_power_of_two();
889        self.bits.clear();
890        self.bits.resize(words, 0);
891        self.shift = 64 - (words * 64).trailing_zeros();
892        for &id in allow {
893            let at = self.index(id);
894            self.bits[at / 64] |= 1u64 << (at % 64);
895        }
896    }
897
898    /// Drops the filter (no tag filter on this query).
899    fn clear(&mut self) {
900        self.bits.clear();
901    }
902
903    /// Bit index of `id`. Fibonacci hashing, the same mixing the arena shards
904    /// with, so ids that differ in their low bits do not collide in runs.
905    fn index(&self, id: FactId) -> usize {
906        (u64::from(id.0).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> self.shift) as usize
907    }
908
909    /// `false` proves `id` is not in the allow-set; `true` means the caller
910    /// must confirm against the set itself.
911    fn maybe_contains(&self, id: FactId) -> bool {
912        let at = self.index(id);
913        self.bits[at / 64] & (1u64 << (at % 64)) != 0
914    }
915}
916
917/// The shared admission rule of every source. Returns the record so
918/// callers can reuse it.
919///
920/// The tag test comes first on purpose. Reading the fact record is an arena
921/// lookup — the most expensive step here — and a candidate outside the
922/// allow-set is rejected whatever the record says, so fetching it would be
923/// work thrown away. The rule itself is unchanged: a candidate is admitted
924/// exactly when it was before.
925fn admit(
926    facts: &plugmem_arena::Arena<'_, FactRecord>,
927    allow: &[FactId],
928    filter: &AllowFilter,
929    filtered: bool,
930    as_of: u64,
931    include_closed: bool,
932    id: FactId,
933) -> Option<FactRecord> {
934    if filtered && (!filter.maybe_contains(id) || allow.binary_search(&id).is_err()) {
935        return None;
936    }
937    let record = facts.get(&id.0.to_be_bytes())?;
938    if record.is_tombstone() || record.recorded_at > as_of || record.valid_from > as_of {
939        return None;
940    }
941    if !include_closed && as_of >= record.valid_to {
942        return None;
943    }
944    Some(record)
945}
946
947/// Writes `year-month` (`2025-11`) of a unix-millisecond timestamp,
948/// proleptic Gregorian (civil-from-days, Hinnant's algorithm).
949fn render_ym(out: &mut String, ms: u64) {
950    let days = (ms / 86_400_000) as i64;
951    let z = days + 719_468;
952    let era = z.div_euclid(146_097);
953    let doe = z.rem_euclid(146_097);
954    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
955    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
956    let mp = (5 * doy + 2) / 153;
957    let month = if mp < 10 { mp + 3 } else { mp - 9 };
958    let year = yoe + era * 400 + i64::from(month <= 2);
959    let _ = write!(out, "{year:04}-{month:02}");
960}