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        // Resolving the slot to its fact is a byte read; *admitting* it is an
497        // arena lookup, and the flat tail can be tens of thousands of entries
498        // long. Admission cannot reorder a ranking, only thin it — the same
499        // property `Bm25Index::search` relies on — so rank first and ask about
500        // the band actually in contention, not about every scanned vector.
501        vec_out.clear();
502        vec_out.extend(
503            hnsw_out
504                .iter()
505                .map(|&(slot, sim)| (FactId(self.vecs.slot_fact(slot as usize)), sim)),
506        );
507
508        // Rank, then admit. The flat tail is unbounded — it holds every vector
509        // written since the last `maintain` folded one into the graph — so
510        // sorting all of it to keep `SOURCE_CAP` was O(tail log tail) for a
511        // constant-size answer. Partitioning is O(tail) and, the ordering being
512        // total, leaves the same prefix in the same order.
513        let order = |a: &(FactId, f32), b: &(FactId, f32)| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0));
514        let band = SOURCE_CAP.min(vec_out.len());
515        if band < vec_out.len() {
516            vec_out.select_nth_unstable_by(band, order);
517        }
518        vec_out[..band].sort_unstable_by(order);
519
520        // Compact the admitted survivors of the band to the front. `write` only
521        // ever trails `read`, so the two indices never cross and no candidate is
522        // overwritten before it is examined.
523        let mut write = 0usize;
524        let mut read = 0usize;
525        while write < SOURCE_CAP && read < vec_out.len() {
526            // Past the band the entries are still unordered — order them once,
527            // the price a query pays only when tombstones or a filter thinned
528            // the band below `SOURCE_CAP`.
529            if read == band && band < vec_out.len() {
530                vec_out[band..].sort_unstable_by(order);
531            }
532            let candidate = vec_out[read];
533            read += 1;
534            if admit(
535                &self.facts,
536                allow,
537                allow_bits,
538                filtered,
539                as_of,
540                q.include_closed,
541                candidate.0,
542            )
543            .is_some()
544            {
545                vec_out[write] = candidate;
546                write += 1;
547            }
548        }
549        vec_out.truncate(write);
550        Ok(())
551    }
552
553    /// Graph expansion: anchors → neighbors (≤ depth), candidate facts of
554    /// every visited entity plus edge provenance, ranked by hop weight.
555    fn graph_source(
556        &self,
557        q: &RecallQuery<'_>,
558        as_of: u64,
559        filtered: bool,
560        s: &mut RecallScratch,
561        out: &mut RecallResult,
562    ) {
563        let RecallScratch {
564            allow,
565            allow_bits,
566            graph_out,
567            visited,
568            ..
569        } = s;
570        graph_out.clear();
571        if visited.is_empty() {
572            return;
573        }
574
575        // Breadth-first: `frontier` marks where the current depth starts.
576        //
577        // Both caps full means every further edge is a no-op — it can neither
578        // enter `out.edges` nor `visited` — so expansion stops there instead of
579        // decoding the rest of a hub's edge list. The visited prefix, and with
580        // it the result, is exactly what an exhaustive walk produced.
581        let full = |edges: &Vec<RecalledEdge>, visited: &Vec<(EntityId, f32)>| {
582            edges.len() >= GRAPH_EDGE_CAP && visited.len() >= GRAPH_ENTITY_CAP
583        };
584        let mut frontier = 0usize;
585        let mut weight = 1.0f32;
586        // The query's depth when it named one, else the configured default.
587        // Unbounded on purpose: what a walk may *cost* is held by the entity and
588        // edge caps above, not by the hop count, so a ceiling here would only
589        // forbid the case it is cheapest in — a sparse chain, where each hop
590        // adds one entity.
591        let depth = q.graph_depth.unwrap_or(self.cfg.graph_depth);
592        'expand: for _ in 0..depth {
593            let depth_end = visited.len();
594            // Nothing new at the last depth means nothing new at any deeper one:
595            // the frontier is empty and every further pass would be a no-op.
596            // Without this, an absurd depth would spin instead of finishing.
597            if depth_end == frontier {
598                break;
599            }
600            weight *= self.cfg.graph_decay;
601            for at in frontier..depth_end {
602                if full(&out.edges, visited) {
603                    break 'expand;
604                }
605                let (entity, _) = visited[at];
606                self.neighbors(
607                    entity,
608                    as_of,
609                    q.as_of.is_some(),
610                    &mut |neighbor, rel, this_side_src, provenance| {
611                        let (src, dst) = if this_side_src {
612                            (entity, neighbor)
613                        } else {
614                            (neighbor, entity)
615                        };
616                        let edge = RecalledEdge {
617                            src,
618                            rel,
619                            dst,
620                            provenance,
621                        };
622                        if out.edges.len() < GRAPH_EDGE_CAP && !out.edges.contains(&edge) {
623                            out.edges.push(edge);
624                        }
625                        if visited.len() < GRAPH_ENTITY_CAP
626                            && !visited.iter().any(|&(e, _)| e == neighbor)
627                        {
628                            visited.push((neighbor, weight));
629                        }
630                        !full(&out.edges, visited)
631                    },
632                );
633            }
634            frontier = depth_end;
635        }
636
637        // Candidate facts: every visited entity's facts at that entity's
638        // weight, plus provenance facts at their edge's weight. Both the
639        // candidate count and the *examined* entries are budgeted.
640        let mut examined = 0usize;
641        'entities: for &(entity, weight) in visited.iter() {
642            for (fact, _) in self.entity_facts.entries(entity.0) {
643                examined += 1;
644                if graph_out.len() >= GRAPH_FACT_CAP || examined > GRAPH_EXAMINE_CAP {
645                    break 'entities;
646                }
647                if admit(
648                    &self.facts,
649                    allow,
650                    allow_bits,
651                    filtered,
652                    as_of,
653                    q.include_closed,
654                    fact,
655                )
656                .is_some()
657                {
658                    graph_out.push((fact, weight));
659                }
660            }
661        }
662        for edge in out.edges.iter() {
663            if graph_out.len() >= GRAPH_FACT_CAP {
664                break;
665            }
666            if let Some(fact) = edge.provenance.some()
667                && !graph_out.iter().any(|&(f, _)| f == fact)
668                && admit(
669                    &self.facts,
670                    allow,
671                    allow_bits,
672                    filtered,
673                    as_of,
674                    q.include_closed,
675                    fact,
676                )
677                .is_some()
678            {
679                graph_out.push((fact, self.cfg.graph_decay));
680            }
681        }
682        graph_out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
683        graph_out.truncate(SOURCE_CAP);
684        graph_out.dedup_by_key(|&mut (f, _)| f);
685    }
686
687    /// Temporal range source: facts recorded in `[from, to)`, most recent
688    /// first.
689    fn time_source(&self, q: &RecallQuery<'_>, as_of: u64, filtered: bool, s: &mut RecallScratch) {
690        s.time_out.clear();
691        let Some((from, to)) = q.range else { return };
692        if filtered && !s.allow.is_empty() && s.allow.len() <= TEMPORAL_TAG_FIRST_MAX {
693            self.time_source_from_tags(from, to, as_of, q.include_closed, s);
694            return;
695        }
696        let RecallScratch {
697            allow,
698            allow_bits,
699            time_out,
700            ..
701        } = s;
702        let mut from_key = [0u8; 12];
703        plugmem_arena::key::write_pair(&mut from_key, from, 0);
704        let mut to_key = [0u8; 12];
705        plugmem_arena::key::write_pair(&mut to_key, to, 0);
706        for slot in self.temporal.range_rev(&from_key, &to_key) {
707            if admit(
708                &self.facts,
709                allow,
710                allow_bits,
711                filtered,
712                as_of,
713                q.include_closed,
714                slot.fact,
715            )
716            .is_some()
717            {
718                time_out.push((slot.fact, slot.recorded_at as f32));
719                // The reverse range starts at the newest record, so once the
720                // source cap is full the remaining entries cannot outrank
721                // these candidates by recency.
722                if time_out.len() == SOURCE_CAP {
723                    break;
724                }
725            }
726        }
727    }
728
729    /// Tag-first temporal source used when the tag allow-set is smaller than
730    /// the recent temporal window. It preserves the temporal source's exact
731    /// newest-first ordering while avoiding a broad temporal scan.
732    fn time_source_from_tags(
733        &self,
734        from: u64,
735        to: u64,
736        as_of: u64,
737        include_closed: bool,
738        s: &mut RecallScratch,
739    ) {
740        let RecallScratch {
741            allow,
742            time_tag,
743            time_out,
744            ..
745        } = s;
746        time_tag.clear();
747        for &fact in allow.iter() {
748            let Some(record) = admit(
749                &self.facts,
750                &[],
751                &AllowFilter::default(),
752                false,
753                as_of,
754                include_closed,
755                fact,
756            ) else {
757                continue;
758            };
759            if record.recorded_at >= from && record.recorded_at < to {
760                time_tag.push((fact, record.recorded_at));
761            }
762        }
763        time_tag.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
764        time_out.extend(
765            time_tag
766                .iter()
767                .take(SOURCE_CAP)
768                .map(|&(fact, recorded_at)| (fact, recorded_at as f32)),
769        );
770    }
771
772    /// Visits the edges touching `entity` in both mirrored arenas as
773    /// `(neighbor, rel, entity_is_src, provenance)` — outgoing in ascending
774    /// key order, then incoming.
775    ///
776    /// The walk is **lazy and interruptible**: `visit` returning `false` stops
777    /// it. A hub entity holds as many edges as the corpus has records, and
778    /// expansion consumes at most a fixed cap of them, so materializing the
779    /// whole list would make graph recall O(edges of the hub) for a bounded
780    /// answer. Stopping is a pure prefix of the same deterministic order, so
781    /// the caller's result is unchanged.
782    fn neighbors(
783        &self,
784        entity: EntityId,
785        as_of: u64,
786        historical: bool,
787        visit: &mut impl FnMut(EntityId, TermId, bool, FactId) -> bool,
788    ) {
789        if historical {
790            // History is keyed `[a | valid_from | edge]`, so walking backwards
791            // from `as_of` yields the entity's versions newest-first: those
792            // that most recently became true, and so the ones most likely to
793            // still be valid. The range already enforces `valid_from <= as_of`;
794            // `as_of < valid_to` is the other half of the validity test.
795            //
796            // The walk stops as soon as the caller has enough valid edges, so
797            // a deep history costs nothing extra for the usual question. The
798            // exception is an instant at which the entity had *no* valid edge
799            // at all: there is nothing to stop at, so proving the absence
800            // reads every version that had already begun. Answering that in
801            // sublinear time needs an interval index, not an ordering.
802            let from = edge_history_floor(entity);
803            let to = edge_history_ceiling(entity, as_of);
804            for (arena, entity_is_src) in
805                [(&self.edges_hist_out, true), (&self.edges_hist_in, false)]
806            {
807                for e in arena.range_rev(&from, &to) {
808                    if as_of < e.valid_to && !visit(e.b, e.rel, entity_is_src, e.fact) {
809                        return;
810                    }
811                }
812            }
813        } else {
814            let from = edge_floor(entity);
815            let to = edge_end(entity);
816            for (arena, entity_is_src) in [(&self.edges_out, true), (&self.edges_in, false)] {
817                for e in arena.range(&from, &to) {
818                    if !visit(e.b, e.rel, entity_is_src, e.fact) {
819                        return;
820                    }
821                }
822            }
823        }
824    }
825
826    /// Renders the compact prompt block (format fixed by golden tests).
827    fn render(&self, out: &mut RecallResult, tags_tmp: &mut Vec<TermId>) {
828        if out.facts.is_empty() && out.edges.is_empty() {
829            return; // empty string: don't spend tokens saying "nothing"
830        }
831        out.rendered.push_str("## memory\n");
832        for fact in &out.facts {
833            let record = self
834                .facts
835                .get(&fact.id.0.to_be_bytes())
836                .expect("selected ids exist");
837            // Deferred validation: tolerate invalid text bytes —
838            // an unreadable fact renders with an empty body, never a panic.
839            let text = core::str::from_utf8(self.texts.get(record.text)).unwrap_or("");
840            let _ = write!(out.rendered, "- [f{}] ", fact.id.0);
841            // A corrupt subject name (deferred validation)
842            // renders without the subject prefix rather than panicking.
843            if let Some(entity) = fact.entity.some()
844                && let Some(name) = self.entity_name(entity)
845            {
846                let _ = write!(out.rendered, "{name}: ");
847            }
848            out.rendered.push_str(text);
849            out.rendered.push_str(" (");
850            render_ym(&mut out.rendered, fact.valid_from);
851            if fact.valid_to == VALID_TO_OPEN {
852                out.rendered.push_str("; active)");
853            } else {
854                out.rendered.push_str(" → ");
855                render_ym(&mut out.rendered, fact.valid_to);
856                out.rendered.push_str("; closed)");
857            }
858            tags_tmp.clear();
859            self.tags_of(fact.id, tags_tmp);
860            for &tag in tags_tmp.iter() {
861                let _ = write!(out.rendered, " #{}", self.terms.resolve(tag));
862            }
863            out.rendered.push('\n');
864        }
865        for edge in &out.edges {
866            // Deferred validation, as for a fact's text and subject name: an
867            // edge whose endpoints do not resolve is rendered as nothing
868            // rather than as a panic. Only a corrupt image reaches this, and
869            // `verify` reports it explicitly.
870            let (Some(src), Some(dst)) = (self.entity_name(edge.src), self.entity_name(edge.dst))
871            else {
872                continue;
873            };
874            let _ = writeln!(
875                out.rendered,
876                "- links: {src} —{}→ {dst}",
877                self.terms.resolve(edge.rel),
878            );
879        }
880    }
881}
882
883/// The tag allow-set, as the sources actually use it.
884///
885/// The set is a sorted vector because [`Memory::time_source_from_tags`]
886/// *enumerates* it. Every other source asks a different question — "is this
887/// one fact in it?" — and asks it once per candidate, which for a graph
888/// expansion under a tag filter is thousands of times. A binary search
889/// answers that in a dozen unpredictable branches over a list sized like the
890/// tag; the filter below answers "no" in one word read, and only "maybe"
891/// costs the search.
892///
893/// The filter is a Bloom filter over the member ids: a member's bit is always
894/// set, so a clear bit is proof of absence and the exact answer is unchanged.
895#[derive(Debug, Default)]
896struct AllowFilter {
897    /// Bit per hashed id; length is a power of two so the hash needs no
898    /// modulo. Empty when no tag filter is active.
899    bits: Vec<u64>,
900    /// `log2(bits.len() * 64)`, the shift that maps a hash onto a bit index.
901    shift: u32,
902}
903
904/// Bits per member. Eight keeps the false-positive rate near 12% while the
905/// whole filter stays small enough to sit in L1 for a realistic tag.
906const ALLOW_BITS_PER_MEMBER: usize = 8;
907/// Smallest and largest filter, in 64-bit words: 64 bytes to 128 KiB.
908const ALLOW_MIN_WORDS: usize = 8;
909const ALLOW_MAX_WORDS: usize = 1 << 14;
910
911impl AllowFilter {
912    /// Rebuilds the filter over `allow`. Reuses the buffer, so a warm scratch
913    /// does not allocate.
914    fn fill(&mut self, allow: &[FactId]) {
915        // Clamped before rounding up: `next_power_of_two` overflows rather
916        // than saturates, and on wasm32 `usize` is 32 bits, so a large
917        // allow-set could reach that edge.
918        let words = allow
919            .len()
920            .saturating_mul(ALLOW_BITS_PER_MEMBER)
921            .div_ceil(64)
922            .clamp(ALLOW_MIN_WORDS, ALLOW_MAX_WORDS)
923            .next_power_of_two();
924        self.bits.clear();
925        self.bits.resize(words, 0);
926        self.shift = 64 - (words * 64).trailing_zeros();
927        for &id in allow {
928            let at = self.index(id);
929            self.bits[at / 64] |= 1u64 << (at % 64);
930        }
931    }
932
933    /// Drops the filter (no tag filter on this query).
934    fn clear(&mut self) {
935        self.bits.clear();
936    }
937
938    /// Bit index of `id`. Fibonacci hashing, the same mixing the arena shards
939    /// with, so ids that differ in their low bits do not collide in runs.
940    fn index(&self, id: FactId) -> usize {
941        (u64::from(id.0).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> self.shift) as usize
942    }
943
944    /// `false` proves `id` is not in the allow-set; `true` means the caller
945    /// must confirm against the set itself.
946    fn maybe_contains(&self, id: FactId) -> bool {
947        let at = self.index(id);
948        self.bits[at / 64] & (1u64 << (at % 64)) != 0
949    }
950}
951
952/// The shared admission rule of every source. Returns the record so
953/// callers can reuse it.
954///
955/// The tag test comes first on purpose. Reading the fact record is an arena
956/// lookup — the most expensive step here — and a candidate outside the
957/// allow-set is rejected whatever the record says, so fetching it would be
958/// work thrown away. The rule itself is unchanged: a candidate is admitted
959/// exactly when it was before.
960fn admit(
961    facts: &plugmem_arena::Arena<'_, FactRecord>,
962    allow: &[FactId],
963    filter: &AllowFilter,
964    filtered: bool,
965    as_of: u64,
966    include_closed: bool,
967    id: FactId,
968) -> Option<FactRecord> {
969    if filtered && (!filter.maybe_contains(id) || allow.binary_search(&id).is_err()) {
970        return None;
971    }
972    let record = facts.get(&id.0.to_be_bytes())?;
973    if record.is_tombstone() || record.recorded_at > as_of || record.valid_from > as_of {
974        return None;
975    }
976    if !include_closed && as_of >= record.valid_to {
977        return None;
978    }
979    Some(record)
980}
981
982/// Writes `year-month` (`2025-11`) of a unix-millisecond timestamp,
983/// proleptic Gregorian (civil-from-days, Hinnant's algorithm).
984fn render_ym(out: &mut String, ms: u64) {
985    let days = (ms / 86_400_000) as i64;
986    let z = days + 719_468;
987    let era = z.div_euclid(146_097);
988    let doe = z.rem_euclid(146_097);
989    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
990    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
991    let mp = (5 * doy + 2) / 153;
992    let month = if mp < 10 { mp + 3 } else { mp - 9 };
993    let year = yoe + era * 400 + i64::from(month <= 2);
994    let _ = write!(out, "{year:04}-{month:02}");
995}