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