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