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}