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