Skip to main content

plugmem_core/memory/
recall.rs

1//! Hybrid recall: source ranking, RRF fusion, budgeted selection and the
2//! rendered prompt block (–7).
3//!
4//! The pipeline (scratch buffers reused, the zero-alloc invariant):
5//!
6//! 1. the tag filter builds a sorted allow-set (intersection of tag
7//!    lists); an unknown tag empties it — and the result;
8//! 2. every source admits a candidate only through the shared rule:
9//!    not tombstoned, `recorded_at ≤ as_of`, inside its validity
10//!    interval (`include_closed` drops the upper bound), in the
11//!    allow-set when tags are present;
12//! 3. sources produce ranked lists of ≤ 128: BM25 over the query text,
13//!    graph expansion from entity anchors (breadth-first over the edge
14//!    arenas, weight `decay^depth`, hard caps on entities, edges and
15//!    candidates), temporal range scan ranked by recency;
16//! 4. **RRF**: `score(f) = Σ_s w_s / (rrf_k + rank_s(f))` — rank-based,
17//!    so sources need no score calibration against each other;
18//! 5. recency boost `× (1 + w_rec · 2^(-age / half_life))`;
19//! 6. greedy selection by fused score under `k` and the token budget
20//!    (`len(text)/4 + 8` tokens per fact);
21//! 7. rendering into the compact prompt block (format fixed by golden
22//!    tests).
23//!
24//! Revision chains need no extra dedup here: closing a fact bounds its
25//! validity at the successor's start, so the `as_of` rule keeps at most
26//! one live version of a chain (with `include_closed` the whole chain is
27//! shown by design, intervals marking who is who).
28
29use alloc::string::String;
30use alloc::vec::Vec;
31use core::fmt::Write as _;
32
33use plugmem_arena::TermId;
34
35use crate::error::Error;
36use crate::id::{EntityId, FactId};
37use crate::index::bm25::Bm25Scratch;
38use crate::index::hnsw::HnswScratch;
39use crate::index::vecpool::{VecScratch, dot_i8};
40use crate::index::{IntersectScratch, intersect};
41use crate::model::{FactRecord, VALID_TO_OPEN};
42use crate::tokenizer::Tokenizer;
43
44use super::Memory;
45
46/// Source bits of [`RecalledFact::sources`].
47pub mod source {
48    /// The lexical (BM25) source.
49    pub const BM25: u8 = 1;
50    /// The graph-expansion source.
51    pub const GRAPH: u8 = 1 << 1;
52    /// The temporal-range source.
53    pub const TIME: u8 = 1 << 2;
54    /// The vector (quantized flat) source.
55    pub const VEC: u8 = 1 << 3;
56}
57
58/// Per-source candidate cap.
59const SOURCE_CAP: usize = 128;
60
61/// Graph expansion caps.
62const GRAPH_ENTITY_CAP: usize = 64;
63const GRAPH_FACT_CAP: usize = 256;
64const GRAPH_EDGE_CAP: usize = 128;
65/// Hard budget on posting entries the graph source may *examine* — a hub
66/// entity with tens of thousands of facts must not turn expansion into a
67/// full decode of its list (the "hub super-node" guard applies
68/// to work, not only to the candidate count).
69const GRAPH_EXAMINE_CAP: usize = 2048;
70
71/// Stop-frequency guard of the lexical source: a query term present in
72/// more than 1/8 of the corpus (and in over [`STOP_DF_FLOOR`] documents)
73/// is dropped from the query — its idf makes it nearly rank-neutral
74/// while its posting list dominates the decode cost (querying "the" must
75/// not cost O(corpus)). When *every* term is stop-frequent the least
76/// frequent one is kept, so such a query still answers.
77const STOP_DF_DIVISOR: u64 = 8;
78/// Below this document frequency a term is never considered
79/// stop-frequent (small corpora skip nothing).
80const STOP_DF_FLOOR: u64 = 1024;
81
82/// A recall request. `Default`-like construction via
83/// [`RecallQuery::text`] plus field overrides.
84#[derive(Clone, Copy, Debug)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize))]
86pub struct RecallQuery<'a> {
87    /// Host timestamp, unix milliseconds.
88    pub now: u64,
89    /// Free-text query for the lexical source.
90    pub text: Option<&'a str>,
91    /// Query embedding for the vector source (`len == Config::dim`).
92    pub vector: Option<&'a [f32]>,
93    /// Tag filter: a fact must carry *all* of these.
94    pub tags: &'a [&'a str],
95    /// Entity anchors for the graph source.
96    pub entities: &'a [&'a str],
97    /// Validity instant; defaults to `now`.
98    pub as_of: Option<u64>,
99    /// `recorded_at` window `[from, to)` for the temporal source.
100    pub range: Option<(u64, u64)>,
101    /// Result cap; `0` means the default 8, hard ceiling 64.
102    pub k: usize,
103    /// Token budget of the rendered block; defaults to 512.
104    pub token_budget: Option<usize>,
105    /// Show closed revisions too (whole chains, marked by intervals).
106    pub include_closed: bool,
107    /// HNSW beam-width override for the vector source; defaults to
108    /// `Config::hnsw_ef_search`. Ignored while the engine is in the flat
109    /// regime (below `Config::flat_to_hnsw`).
110    pub ef: Option<usize>,
111}
112
113impl<'a> RecallQuery<'a> {
114    /// A plain text query with every other knob at its default.
115    pub fn text(now: u64, text: &'a str) -> Self {
116        Self {
117            now,
118            text: Some(text),
119            vector: None,
120            tags: &[],
121            entities: &[],
122            as_of: None,
123            range: None,
124            k: 0,
125            token_budget: None,
126            include_closed: false,
127            ef: None,
128        }
129    }
130}
131
132/// One recalled fact.
133#[derive(Clone, Copy, Debug, PartialEq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct RecalledFact {
136    /// The fact.
137    pub id: FactId,
138    /// Fused score (RRF + recency boost).
139    pub score: f32,
140    /// Which sources surfaced it (see [`source`]).
141    pub sources: u8,
142    /// Subject entity or [`EntityId::NONE`].
143    pub entity: EntityId,
144    /// Knowledge axis.
145    pub recorded_at: u64,
146    /// Truth axis, start.
147    pub valid_from: u64,
148    /// Truth axis, end ([`VALID_TO_OPEN`] = open).
149    pub valid_to: u64,
150}
151
152/// One edge the graph source walked (: agents want the relations,
153/// not only the facts).
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub struct RecalledEdge {
157    /// Source entity.
158    pub src: EntityId,
159    /// Relation term.
160    pub rel: TermId,
161    /// Destination entity.
162    pub dst: EntityId,
163    /// Provenance fact or [`FactId::NONE`].
164    pub provenance: FactId,
165}
166
167/// A recall response. Reusable: pass to
168/// [`Memory::recall_into`] repeatedly and the buffers are recycled.
169#[derive(Clone, Debug, Default)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
171pub struct RecallResult {
172    /// Selected facts, descending fused score.
173    pub facts: Vec<RecalledFact>,
174    /// Edges walked by the graph source (deduplicated).
175    pub edges: Vec<RecalledEdge>,
176    /// The compact prompt block (empty string when nothing was found).
177    pub rendered: String,
178    /// `true` when selection stopped at `k` or the token budget with
179    /// candidates left over.
180    pub truncated: bool,
181}
182
183/// Reusable recall scratch — **caller-owned**, so [`Memory::recall`] and
184/// [`Memory::recall_into`] take `&self`: many readers can recall the same
185/// engine at once, each threading its own scratch (the host wraps one per
186/// thread). Carries every buffer a recall mutates — the query-side term/score
187/// vectors, the fusion map, *and* its own tokenizer and name-normalization
188/// buffer — so a recall never touches the engine's write-side scratches. Reused
189/// across calls it upholds the zero-alloc invariant.
190///
191/// Opaque: construct with [`RecallScratch::new`] (or `Default`) and pass by
192/// `&mut`; the fields are engine-internal.
193#[derive(Debug, Default)]
194pub struct RecallScratch {
195    /// Read-path tokenizer (query text + entity-name normalization). Kept here,
196    /// not in [`Memory`], so recall stays `&self`; writers use the engine's own.
197    tokenizer: Tokenizer,
198    /// Scratch for one normalized entity name during graph-anchor resolution.
199    name_scratch: String,
200    bm25: Bm25Scratch,
201    intersect: IntersectScratch,
202    allow: Vec<FactId>,
203    tag_terms: Vec<u32>,
204    query_terms: Vec<u32>,
205    bm25_out: Vec<(FactId, f32)>,
206    vec: VecScratch,
207    vec_out: Vec<(FactId, f32)>,
208    hnsw: HnswScratch,
209    hnsw_out: Vec<(u32, f32)>,
210    graph_out: Vec<(FactId, f32)>,
211    time_out: Vec<(FactId, f32)>,
212    visited: Vec<(EntityId, f32)>,
213    edges_tmp: Vec<(EntityId, TermId, bool, FactId)>,
214    fused: hashbrown::HashMap<u32, (f32, u8), xxhash_rust::xxh3::Xxh3Builder>,
215    ranked: Vec<(FactId, f32, u8)>,
216    tags_tmp: Vec<TermId>,
217}
218
219impl RecallScratch {
220    /// An empty recall scratch (all buffers grow on first use). One per
221    /// concurrent reader; reused across that reader's calls for zero-alloc.
222    pub fn new() -> Self {
223        Self::default()
224    }
225}
226
227impl Memory<'_> {
228    /// Runs a recall, allocating a fresh [`RecallScratch`] and
229    /// [`RecallResult`]. Convenience over [`Memory::recall_into`] for one-shot
230    /// callers; a hot loop should own a [`RecallScratch`] and call
231    /// `recall_into` to stay zero-alloc.
232    pub fn recall(&self, q: RecallQuery<'_>) -> Result<RecallResult, Error> {
233        let mut scratch = RecallScratch::default();
234        let mut out = RecallResult::default();
235        self.recall_into(q, &mut scratch, &mut out)?;
236        Ok(out)
237    }
238
239    /// Runs a recall into a reused result and caller-owned scratch (the
240    /// zero-alloc path: after warm-up neither `s` nor `out` allocate).
241    ///
242    /// Takes `&self` — recall never mutates engine data; every mutable buffer
243    /// it needs lives in `s`. This is what lets many readers recall
244    /// one engine concurrently, each with its own [`RecallScratch`].
245    pub fn recall_into(
246        &self,
247        q: RecallQuery<'_>,
248        s: &mut RecallScratch,
249        out: &mut RecallResult,
250    ) -> Result<(), Error> {
251        out.facts.clear();
252        out.edges.clear();
253        out.rendered.clear();
254        out.truncated = false;
255
256        let k = if q.k == 0 { 8 } else { q.k.min(64) };
257        let budget = q.token_budget.unwrap_or(512);
258        let as_of = q.as_of.unwrap_or(q.now);
259
260        // 1. Tag allow-set. An unknown tag can match nothing.
261        s.allow.clear();
262        s.tag_terms.clear();
263        let mut dead_tag = false;
264        for tag in q.tags {
265            match self.terms.lookup(tag) {
266                Some(term) => s.tag_terms.push(term.0),
267                None => dead_tag = true,
268            }
269        }
270        if !dead_tag && !s.tag_terms.is_empty() {
271            intersect(&self.tags_idx, &s.tag_terms, &mut s.intersect, &mut s.allow);
272        }
273        let filtered = !q.tags.is_empty();
274        if filtered && (dead_tag || s.allow.is_empty()) {
275            return Ok(());
276        }
277
278        // 2–3. Sources (each admits through the shared rule).
279        s.bm25_out.clear();
280        if let Some(text) = q.text {
281            s.query_terms.clear();
282            let terms = &self.terms;
283            // Disjoint field borrows of `s`: the tokenizer writes into
284            // `query_terms`, both live in the caller's scratch.
285            let query_terms = &mut s.query_terms;
286            s.tokenizer.tokenize(text, &mut |token| {
287                if let Some(term) = terms.lookup(token) {
288                    query_terms.push(term.0);
289                }
290            });
291            // Stop-frequency filter (see the constants above).
292            let docs = self.bm25.docs();
293            let is_stop = |df: u64| df > STOP_DF_FLOOR && df * STOP_DF_DIVISOR > docs;
294            if s.query_terms
295                .iter()
296                .any(|&t| !is_stop(u64::from(self.bm25.df(t))))
297            {
298                let bm25 = &self.bm25;
299                s.query_terms.retain(|&t| !is_stop(u64::from(bm25.df(t))));
300            } else if let Some(&least) = s.query_terms.iter().min_by_key(|&&t| self.bm25.df(t)) {
301                s.query_terms.clear();
302                s.query_terms.push(least);
303            }
304            let facts = &self.facts;
305            let allow = &s.allow;
306            self.bm25.search(
307                (self.cfg.bm25_k1, self.cfg.bm25_b),
308                &s.query_terms,
309                SOURCE_CAP,
310                &mut |id| admit(facts, allow, filtered, as_of, q.include_closed, id).is_some(),
311                &mut s.bm25,
312                &mut s.bm25_out,
313            );
314        }
315
316        // Vector source: flat quantized search below the HNSW threshold,
317        // graph search plus a flat-tail scan above it.
318        s.vec_out.clear();
319        if let Some(v) = q.vector
320            && self.cfg.dim > 0
321        {
322            let res = if self.hnsw.indexed() == 0 {
323                let facts = &self.facts;
324                let allow = &s.allow;
325                self.vecs.search(
326                    v,
327                    SOURCE_CAP,
328                    &mut |id| admit(facts, allow, filtered, as_of, q.include_closed, id).is_some(),
329                    &mut s.vec,
330                    &mut s.vec_out,
331                )
332            } else {
333                self.vec_graph_source(v, &q, as_of, filtered, s)
334            };
335            res?;
336        }
337
338        // Graph anchors resolve here (name normalization needs the tokenizer
339        // and name buffer — both in the caller's scratch); expansion is
340        // read-only. `tokenizer` and `name_scratch` are disjoint fields of `s`.
341        s.visited.clear();
342        for name in q.entities {
343            super::normalize_name(&mut s.tokenizer, name, &mut s.name_scratch);
344            let found = self.lookup_entity_by_norm(&s.name_scratch);
345            if let Some(id) = found
346                && !s.visited.iter().any(|&(e, _)| e == id)
347            {
348                s.visited.push((id, 1.0));
349            }
350        }
351        self.graph_source(&q, as_of, filtered, s, out);
352        self.time_source(&q, as_of, filtered, s);
353
354        // 4. RRF fusion.
355        s.fused.clear();
356        for (list, weight, bit) in [
357            (&s.bm25_out, self.cfg.w_bm25, source::BM25),
358            (&s.vec_out, self.cfg.w_vec, source::VEC),
359            (&s.graph_out, self.cfg.w_graph, source::GRAPH),
360            (&s.time_out, self.cfg.w_time, source::TIME),
361        ] {
362            for (rank, &(fact, _)) in list.iter().enumerate() {
363                let contribution = weight / (self.cfg.rrf_k as f32 + rank as f32 + 1.0);
364                let entry = s.fused.entry(fact.0).or_insert((0.0, 0));
365                entry.0 += contribution;
366                entry.1 |= bit;
367            }
368        }
369
370        // 5. Recency boost.
371        let half_life_ms = self.cfg.half_life_days as f32 * 86_400_000.0;
372        s.ranked.clear();
373        for (&id, &(score, bits)) in &s.fused {
374            let record = self.facts.get(&id.to_be_bytes()).expect("fused ids exist");
375            let age = q.now.saturating_sub(record.recorded_at) as f32;
376            let boost = 1.0 + self.cfg.w_recency * libm::exp2f(-age / half_life_ms);
377            s.ranked.push((FactId(id), score * boost, bits));
378        }
379        s.ranked
380            .sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
381
382        // 6. Budgeted selection.
383        let mut spent = 0usize;
384        for &(id, score, bits) in &s.ranked {
385            if out.facts.len() == k {
386                out.truncated = true;
387                break;
388            }
389            let record = self
390                .facts
391                .get(&id.0.to_be_bytes())
392                .expect("ranked ids exist");
393            let cost = self.texts.get(record.text).len() / 4 + 8;
394            if spent + cost > budget {
395                out.truncated = true;
396                break;
397            }
398            spent += cost;
399            out.facts.push(RecalledFact {
400                id,
401                score,
402                sources: bits,
403                entity: record.entity,
404                recorded_at: record.recorded_at,
405                valid_from: record.valid_from,
406                valid_to: record.valid_to,
407            });
408        }
409
410        // 7. Render.
411        self.render(out, &mut s.tags_tmp);
412        Ok(())
413    }
414
415    /// The above-threshold vector source (phase 2): an HNSW
416    /// beam search over the graph plus an exact scan of the flat tail
417    /// (vectors appended since the last `maintain` build), merged,
418    /// admission-filtered and capped like every other source.
419    fn vec_graph_source(
420        &self,
421        v: &[f32],
422        q: &RecallQuery<'_>,
423        as_of: u64,
424        filtered: bool,
425        s: &mut RecallScratch,
426    ) -> Result<(), Error> {
427        let RecallScratch {
428            vec,
429            hnsw,
430            hnsw_out,
431            vec_out,
432            allow,
433            ..
434        } = s;
435        self.vecs.quantize_query(v, vec)?;
436        let (q_scale, q_q) = self.vecs.quantized(vec);
437        let ef = q.ef.unwrap_or(self.cfg.hnsw_ef_search).max(1);
438        self.hnsw
439            .search_quantized(&self.vecs, (q_scale, q_q), ef, hnsw, hnsw_out);
440        for slot in self.hnsw.indexed()..self.vecs.len() as u32 {
441            let (s_scale, s_q) = self.vecs.quant(slot as usize);
442            hnsw_out.push((slot, q_scale * s_scale * dot_i8(q_q, s_q) as f32));
443        }
444        vec_out.clear();
445        for &(slot, sim) in hnsw_out.iter() {
446            let fact = FactId(self.vecs.slot_fact(slot as usize));
447            if admit(&self.facts, allow, filtered, as_of, q.include_closed, fact).is_some() {
448                vec_out.push((fact, sim));
449            }
450        }
451        vec_out.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
452        vec_out.truncate(SOURCE_CAP);
453        Ok(())
454    }
455
456    /// Graph expansion: anchors → neighbors (≤ depth), candidate facts of
457    /// every visited entity plus edge provenance, ranked by hop weight.
458    fn graph_source(
459        &self,
460        q: &RecallQuery<'_>,
461        as_of: u64,
462        filtered: bool,
463        s: &mut RecallScratch,
464        out: &mut RecallResult,
465    ) {
466        let RecallScratch {
467            allow,
468            graph_out,
469            visited,
470            edges_tmp,
471            ..
472        } = s;
473        graph_out.clear();
474        if visited.is_empty() {
475            return;
476        }
477
478        // Breadth-first: `frontier` marks where the current depth starts.
479        let mut frontier = 0usize;
480        let mut weight = 1.0f32;
481        for _ in 0..self.cfg.graph_depth {
482            let depth_end = visited.len();
483            weight *= self.cfg.graph_decay;
484            for at in frontier..depth_end {
485                let (entity, _) = visited[at];
486                self.neighbors(entity, edges_tmp);
487                let batch = core::mem::take(edges_tmp);
488                for &(neighbor, rel, this_side_src, provenance) in &batch {
489                    let (src, dst) = if this_side_src {
490                        (entity, neighbor)
491                    } else {
492                        (neighbor, entity)
493                    };
494                    let edge = RecalledEdge {
495                        src,
496                        rel,
497                        dst,
498                        provenance,
499                    };
500                    if out.edges.len() < GRAPH_EDGE_CAP && !out.edges.contains(&edge) {
501                        out.edges.push(edge);
502                    }
503                    if visited.len() < GRAPH_ENTITY_CAP
504                        && !visited.iter().any(|&(e, _)| e == neighbor)
505                    {
506                        visited.push((neighbor, weight));
507                    }
508                }
509                *edges_tmp = batch;
510            }
511            frontier = depth_end;
512        }
513
514        // Candidate facts: every visited entity's facts at that entity's
515        // weight, plus provenance facts at their edge's weight. Both the
516        // candidate count and the *examined* entries are budgeted.
517        let mut examined = 0usize;
518        'entities: for &(entity, weight) in visited.iter() {
519            for (fact, _) in self.entity_facts.entries(entity.0) {
520                examined += 1;
521                if graph_out.len() >= GRAPH_FACT_CAP || examined > GRAPH_EXAMINE_CAP {
522                    break 'entities;
523                }
524                if admit(&self.facts, allow, filtered, as_of, q.include_closed, fact).is_some() {
525                    graph_out.push((fact, weight));
526                }
527            }
528        }
529        for edge in out.edges.iter() {
530            if graph_out.len() >= GRAPH_FACT_CAP {
531                break;
532            }
533            if let Some(fact) = edge.provenance.some()
534                && !graph_out.iter().any(|&(f, _)| f == fact)
535                && admit(&self.facts, allow, filtered, as_of, q.include_closed, fact).is_some()
536            {
537                graph_out.push((fact, self.cfg.graph_decay));
538            }
539        }
540        graph_out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
541        graph_out.truncate(SOURCE_CAP);
542        graph_out.dedup_by_key(|&mut (f, _)| f);
543    }
544
545    /// Temporal range source: facts recorded in `[from, to)`, most recent
546    /// first.
547    fn time_source(&self, q: &RecallQuery<'_>, as_of: u64, filtered: bool, s: &mut RecallScratch) {
548        let RecallScratch {
549            allow, time_out, ..
550        } = s;
551        time_out.clear();
552        let Some((from, to)) = q.range else { return };
553        let mut from_key = [0u8; 12];
554        plugmem_arena::key::write_pair(&mut from_key, from, 0);
555        let mut to_key = [0u8; 12];
556        plugmem_arena::key::write_pair(&mut to_key, to, 0);
557        for slot in self.temporal.range(&from_key, &to_key) {
558            if admit(
559                &self.facts,
560                allow,
561                filtered,
562                as_of,
563                q.include_closed,
564                slot.fact,
565            )
566            .is_some()
567            {
568                time_out.push((slot.fact, slot.recorded_at as f32));
569                // Keep only the most recent SOURCE_CAP without unbounded
570                // growth on huge windows.
571                if time_out.len() > SOURCE_CAP * 2 {
572                    time_out.drain(..SOURCE_CAP);
573                }
574            }
575        }
576        time_out.reverse();
577        time_out.truncate(SOURCE_CAP);
578    }
579
580    /// Collects the edges touching `entity` from both mirrored arenas
581    /// into `out` as `(neighbor, rel, entity_is_src, provenance)`.
582    fn neighbors(&self, entity: EntityId, out: &mut Vec<(EntityId, TermId, bool, FactId)>) {
583        out.clear();
584        let mut from = [0u8; 12];
585        plugmem_arena::key::write_u32(&mut from, entity.0);
586        let mut to = [0u8; 12];
587        plugmem_arena::key::write_u32(&mut to, entity.0 + 1);
588        out.extend(
589            self.edges_out
590                .range(&from, &to)
591                .map(|e| (e.b, e.rel, true, e.fact)),
592        );
593        out.extend(
594            self.edges_in
595                .range(&from, &to)
596                .map(|e| (e.b, e.rel, false, e.fact)),
597        );
598    }
599
600    /// Renders the compact prompt block (format fixed by golden tests).
601    fn render(&self, out: &mut RecallResult, tags_tmp: &mut Vec<TermId>) {
602        if out.facts.is_empty() && out.edges.is_empty() {
603            return; // empty string: don't spend tokens saying "nothing"
604        }
605        out.rendered.push_str("## memory\n");
606        for fact in &out.facts {
607            let record = self
608                .facts
609                .get(&fact.id.0.to_be_bytes())
610                .expect("selected ids exist");
611            // Deferred validation: tolerate invalid text bytes —
612            // an unreadable fact renders with an empty body, never a panic.
613            let text = core::str::from_utf8(self.texts.get(record.text)).unwrap_or("");
614            let _ = write!(out.rendered, "- [f{}] ", fact.id.0);
615            // A corrupt subject name (deferred validation)
616            // renders without the subject prefix rather than panicking.
617            if let Some(entity) = fact.entity.some()
618                && let Some(name) = self.entity_name(entity)
619            {
620                let _ = write!(out.rendered, "{name}: ");
621            }
622            out.rendered.push_str(text);
623            out.rendered.push_str(" (");
624            render_ym(&mut out.rendered, fact.valid_from);
625            if fact.valid_to == VALID_TO_OPEN {
626                out.rendered.push_str("; active)");
627            } else {
628                out.rendered.push_str(" → ");
629                render_ym(&mut out.rendered, fact.valid_to);
630                out.rendered.push_str("; closed)");
631            }
632            tags_tmp.clear();
633            self.tags_of(fact.id, tags_tmp);
634            for &tag in tags_tmp.iter() {
635                let _ = write!(out.rendered, " #{}", self.terms.resolve(tag));
636            }
637            out.rendered.push('\n');
638        }
639        for edge in &out.edges {
640            let _ = writeln!(
641                out.rendered,
642                "- links: {} —{}→ {}",
643                self.entity_name(edge.src)
644                    .expect("edges reference existing entities"),
645                self.terms.resolve(edge.rel),
646                self.entity_name(edge.dst)
647                    .expect("edges reference existing entities"),
648            );
649        }
650    }
651}
652
653/// The shared admission rule of every source. Returns the record so
654/// callers can reuse it.
655fn admit(
656    facts: &plugmem_arena::Arena<'_, FactRecord>,
657    allow: &[FactId],
658    filtered: bool,
659    as_of: u64,
660    include_closed: bool,
661    id: FactId,
662) -> Option<FactRecord> {
663    let record = facts.get(&id.0.to_be_bytes())?;
664    if record.is_tombstone() || record.recorded_at > as_of || record.valid_from > as_of {
665        return None;
666    }
667    if !include_closed && as_of >= record.valid_to {
668        return None;
669    }
670    if filtered && allow.binary_search(&id).is_err() {
671        return None;
672    }
673    Some(record)
674}
675
676/// Writes `year-month` (`2025-11`) of a unix-millisecond timestamp,
677/// proleptic Gregorian (civil-from-days, Hinnant's algorithm).
678fn render_ym(out: &mut String, ms: u64) {
679    let days = (ms / 86_400_000) as i64;
680    let z = days + 719_468;
681    let era = z.div_euclid(146_097);
682    let doe = z.rem_euclid(146_097);
683    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
684    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
685    let mp = (5 * doy + 2) / 153;
686    let month = if mp < 10 { mp + 3 } else { mp - 9 };
687    let year = yoe + era * 400 + i64::from(month <= 2);
688    let _ = write!(out, "{year:04}-{month:02}");
689}