qualia_core_db/query/graph_index.rs
1//! Revision-cached graph index — the per-cell index lifecycle (#22 step 3).
2//!
3//! Building a `QuinIndex` over the daemon-graph snapshot on every resolve would be O(n)
4//! per call. Instead we memoize it by `daemon_graph::graph_revision()`: the index is
5//! rebuilt LAZILY only when the graph has actually changed, so resolution is O(1)
6//! amortized. The cache is host-side (separate from the 42 MB SlgArena).
7//!
8//! "Per-cell" in the Fractal-Shard model means one cache per 512 MB cell; for the single
9//! daemon graph today this is that one cache. Streaming a huge graph via BIDX/demand-
10//! paging — so the index need not copy the whole snapshot — remains future work.
11
12#[cfg(not(target_arch = "wasm32"))]
13use crate::indexing::QuinIndex;
14use std::sync::RwLock;
15
16/// Memoizes a value `T` by a monotonically-advancing `revision`, rebuilding via `build`
17/// only when the supplied revision differs from the cached one.
18pub struct RevisionCache<T> {
19 inner: RwLock<Option<(u64, T)>>,
20}
21
22impl<T> RevisionCache<T> {
23 pub const fn new() -> Self {
24 Self {
25 inner: RwLock::new(None),
26 }
27 }
28
29 /// Run `f` against the value cached at `revision`, building it via `build` first if
30 /// the cache is empty or stale. `build` runs at most once per distinct revision, and
31 /// only when a caller actually needs the value (lazy).
32 pub fn with<R>(&self, revision: u64, build: impl FnOnce() -> T, f: impl FnOnce(&T) -> R) -> R {
33 // Fast path: cache present and fresh.
34 {
35 let guard = self.inner.read().unwrap();
36 if let Some((rev, value)) = guard.as_ref() {
37 if *rev == revision {
38 return f(value);
39 }
40 }
41 }
42 // Slow path: (re)build and store, then serve.
43 let value = build();
44 {
45 let mut w = self.inner.write().unwrap();
46 *w = Some((revision, value));
47 }
48 let guard = self.inner.read().unwrap();
49 let (_, value) = guard.as_ref().expect("cache just populated");
50 f(value)
51 }
52}
53
54impl<T> Default for RevisionCache<T> {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60#[cfg(not(target_arch = "wasm32"))]
61static GRAPH_INDEX: RevisionCache<QuinIndex> = RevisionCache::new();
62
63/// Run `f` against a `QuinIndex` over the current daemon graph, rebuilt only when the
64/// graph revision has changed since the last build. This is what `graph_resolve` (and
65/// other index consumers) route through, so a burst of resolves between graph changes
66/// shares a single O(n) build.
67// Routes through `daemon_graph` (the native daemon's in-memory graph), which does not exist on
68// wasm32; the only caller (mcp_tool_impls) is native-only too.
69#[cfg(not(target_arch = "wasm32"))]
70pub fn with_graph_index<R>(f: impl FnOnce(&QuinIndex) -> R) -> R {
71 let revision = crate::daemon_graph::graph_revision();
72 GRAPH_INDEX.with(
73 revision,
74 || {
75 let guard = crate::daemon_graph::graph_read_guard();
76 QuinIndex::from_slice(guard.as_slice())
77 },
78 f,
79 )
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use std::sync::atomic::{AtomicUsize, Ordering};
86
87 #[test]
88 fn rebuilds_only_on_revision_change() {
89 let cache: RevisionCache<usize> = RevisionCache::new();
90 let builds = AtomicUsize::new(0);
91
92 // First touch at rev 1 builds.
93 let v = cache.with(
94 1,
95 || {
96 builds.fetch_add(1, Ordering::SeqCst);
97 42
98 },
99 |v| *v,
100 );
101 assert_eq!(v, 42);
102 // Same rev: served from cache, no rebuild.
103 cache.with(
104 1,
105 || {
106 builds.fetch_add(1, Ordering::SeqCst);
107 0
108 },
109 |_| (),
110 );
111 assert_eq!(
112 builds.load(Ordering::SeqCst),
113 1,
114 "same revision must not rebuild"
115 );
116
117 // New rev: rebuild.
118 cache.with(
119 2,
120 || {
121 builds.fetch_add(1, Ordering::SeqCst);
122 99
123 },
124 |_| (),
125 );
126 assert_eq!(
127 builds.load(Ordering::SeqCst),
128 2,
129 "changed revision must rebuild"
130 );
131 // And stays cached at the new rev.
132 cache.with(
133 2,
134 || {
135 builds.fetch_add(1, Ordering::SeqCst);
136 0
137 },
138 |_| (),
139 );
140 assert_eq!(builds.load(Ordering::SeqCst), 2);
141 }
142}