Skip to main content

qualia_core_db/services/
daemon_graph.rs

1//! In-process graph backing store for the loopback daemon `/query` route.
2//!
3//! The live daemon graph is a fixed-capacity, zero-heap store backed by a
4//! caller-invisible `[NQuin; MAX_GRAPH_QUINS]` buffer. Cold-path ontology and
5//! file ingestion may still allocate while parsing, but the resident graph used
6//! by `/query` no longer relies on `Vec` or `HashSet`.
7
8use crate::{q_hash, NQuin};
9use std::collections::{HashMap, HashSet};
10use std::ops::Index;
11use std::path::Path;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{OnceLock, RwLock};
14use tokio::sync::broadcast;
15
16/// Bench datasets (Schema.org ~18K quins) must fit for browser/native parity.
17pub const MAX_GRAPH_QUINS: usize = 65_536;
18
19#[derive(Debug)]
20pub struct DaemonGraphStore {
21    quins: [NQuin; MAX_GRAPH_QUINS],
22    len: usize,
23}
24
25impl DaemonGraphStore {
26    pub const fn new() -> Self {
27        Self {
28            quins: [NQuin {
29                subject: 0,
30                predicate: 0,
31                object: 0,
32                context: 0,
33                metadata: 0,
34                parity: 0,
35            }; MAX_GRAPH_QUINS],
36            len: 0,
37        }
38    }
39
40    #[inline]
41    pub fn len(&self) -> usize {
42        self.len
43    }
44
45    #[inline]
46    pub fn is_empty(&self) -> bool {
47        self.len == 0
48    }
49
50    #[inline]
51    pub fn as_slice(&self) -> &[NQuin] {
52        &self.quins[..self.len]
53    }
54
55    #[inline]
56    pub fn clear(&mut self) {
57        for quin in &mut self.quins[..self.len] {
58            *quin = NQuin::default();
59        }
60        self.len = 0;
61    }
62
63    #[inline]
64    pub fn push(&mut self, quin: NQuin) -> bool {
65        if self.len >= MAX_GRAPH_QUINS {
66            return false;
67        }
68        self.quins[self.len] = quin;
69        self.len += 1;
70        true
71    }
72
73    #[inline]
74    pub fn extend_from_slice(&mut self, quins: &[NQuin]) -> usize {
75        let remaining = MAX_GRAPH_QUINS.saturating_sub(self.len);
76        let to_copy = quins.len().min(remaining);
77        if to_copy == 0 {
78            return 0;
79        }
80        self.quins[self.len..self.len + to_copy].copy_from_slice(&quins[..to_copy]);
81        self.len += to_copy;
82        to_copy
83    }
84
85    #[inline]
86    fn contains_subject_predicate_context(
87        &self,
88        subject: u64,
89        predicate: u64,
90        context: u64,
91    ) -> bool {
92        self.as_slice()
93            .iter()
94            .any(|q| q.subject == subject && q.predicate == predicate && q.context == context)
95    }
96
97    fn push_unique(&mut self, quin: NQuin) -> bool {
98        if self.contains_subject_predicate_context(quin.subject, quin.predicate, quin.context) {
99            return false;
100        }
101        self.push(quin)
102    }
103}
104
105impl Default for DaemonGraphStore {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl Index<usize> for DaemonGraphStore {
112    type Output = NQuin;
113
114    fn index(&self, index: usize) -> &Self::Output {
115        &self.as_slice()[index]
116    }
117}
118
119static GRAPH: RwLock<DaemonGraphStore> = RwLock::new(DaemonGraphStore::new());
120static GRAPH_REVISION: AtomicU64 = AtomicU64::new(0);
121static REVISION_TX: OnceLock<broadcast::Sender<u64>> = OnceLock::new();
122
123fn graph_lock() -> &'static RwLock<DaemonGraphStore> {
124    &GRAPH
125}
126
127fn revision_tx() -> &'static broadcast::Sender<u64> {
128    REVISION_TX.get_or_init(|| {
129        let (tx, _) = broadcast::channel(64);
130        tx
131    })
132}
133
134/// Monotonic Lamport-style revision counter for daemon graph mutations.
135#[inline]
136pub fn graph_revision() -> u64 {
137    GRAPH_REVISION.load(Ordering::Acquire)
138}
139
140/// Subscribe to graph revision bumps (used by `GET /tensor/events` SSE).
141pub fn subscribe_graph_revisions() -> broadcast::Receiver<u64> {
142    revision_tx().subscribe()
143}
144
145/// Increment revision and notify SSE subscribers (Release ordering).
146pub fn bump_graph_revision() -> u64 {
147    let rev = GRAPH_REVISION.fetch_add(1, Ordering::Release) + 1;
148    let _ = revision_tx().send(rev);
149    rev
150}
151
152#[inline]
153fn triple_quin(subject: &str, predicate: &str, object: &str, context: &str) -> NQuin {
154    let subject = q_hash(subject);
155    let predicate = q_hash(predicate);
156    let object = q_hash(object);
157    let context = q_hash(context) & 0x00FF_FFFF_FFFF_FFFF;
158    NQuin {
159        subject,
160        predicate,
161        object,
162        context,
163        metadata: 0,
164        parity: subject ^ predicate ^ object ^ context,
165    }
166}
167
168fn push_quin(store: &mut DaemonGraphStore, quin: NQuin) {
169    let _ = store.push(quin);
170}
171
172/// Seed representative health-condition triples for Anatomy app development.
173fn seed_anatomy_health_graph(store: &mut DaemonGraphStore) {
174    const BIO: &str = "https://qualia.anatomy.example/ontology/bio#";
175    const ORGAN: &str = "https://qualia.anatomy.example/ontology/organ#";
176    const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
177    const HAS_PRIMARY: &str =
178        "https://qualia.anatomy.example/ontology/impact#hasPrimaryImpactSystem";
179    const IMPACTS: &str = "https://qualia.anatomy.example/ontology/impact#Impacts";
180    const USER_CTX: &str = "did:qualia:user:local-health-graph";
181
182    let seeds: [(&str, &str); 8] = [
183        ("Type2Diabetes", "organ:EndocrineSystem"),
184        ("Hypertension", "organ:CirculatorySystem"),
185        ("ChronicKidneyDisease", "organ:UrinarySystem"),
186        ("HeartFailure", "organ:CirculatorySystem"),
187        ("COPD", "organ:RespiratorySystem"),
188        ("Obesity", "organ:EndocrineSystem"),
189        ("AtrialFibrillation", "organ:CirculatorySystem"),
190        ("Depression", "organ:NervousSystem"),
191    ];
192
193    for (local_name, primary_system) in seeds {
194        let condition = format!("{BIO}{local_name}");
195        push_quin(
196            store,
197            triple_quin(&condition, RDF_TYPE, &format!("{BIO}Condition"), USER_CTX),
198        );
199        push_quin(
200            store,
201            triple_quin(
202                &condition,
203                HAS_PRIMARY,
204                &format!("{ORGAN}{}", primary_system.trim_start_matches("organ:")),
205                USER_CTX,
206            ),
207        );
208        push_quin(
209            store,
210            triple_quin(
211                &condition,
212                IMPACTS,
213                &format!("{ORGAN}{}", primary_system.trim_start_matches("organ:")),
214                USER_CTX,
215            ),
216        );
217    }
218}
219
220/// Merged literal lexicon (`hash -> text`) for the resident graph, built from
221/// the `.q42` volumes' lexicon segments at load. Lets the SPARQL evaluator
222/// resolve ingested literal *text* (for `geof:*`/text extension functions and
223/// correct literal serialisation) rather than only opaque hashes.
224static GRAPH_LEXICON: RwLock<Option<HashMap<u64, String>>> = RwLock::new(None);
225
226fn reset_graph_lexicon() {
227    if let Ok(mut g) = GRAPH_LEXICON.write() {
228        *g = Some(HashMap::new());
229    }
230}
231
232fn merge_graph_lexicon(entries: HashMap<u64, String>) {
233    if let Ok(mut g) = GRAPH_LEXICON.write() {
234        let map = g.get_or_insert_with(HashMap::new);
235        for (k, v) in entries {
236            map.entry(k).or_insert(v);
237        }
238    }
239}
240
241/// Resolve a term hash to its literal text via the resident graph's lexicon.
242/// Used by the query path as the ingested-data resolver for `TextResolver`.
243pub fn graph_lexicon_lookup(hash: u64) -> Option<String> {
244    GRAPH_LEXICON
245        .read()
246        .ok()
247        .and_then(|g| g.as_ref().and_then(|m| m.get(&hash).cloned()))
248}
249
250/// Load and merge the lexicon segments of all `.q42` volumes under
251/// `{storage_path}/Index` into the resident graph lexicon.
252fn load_graph_lexicon_from_index(storage_path: &str) {
253    let index = Path::new(storage_path).join("Index");
254    let Ok(entries) = std::fs::read_dir(&index) else {
255        return;
256    };
257    for entry in entries.filter_map(Result::ok) {
258        let path = entry.path();
259        if path.extension().and_then(|e| e.to_str()) != Some("q42") {
260            continue;
261        }
262        if path
263            .file_name()
264            .map(|n| n.to_string_lossy().contains(".meta."))
265            .unwrap_or(false)
266        {
267            continue;
268        }
269        if let Ok(lex) = crate::q42_lex::Q42Lexicon::load_for_q42(&path) {
270            merge_graph_lexicon(lex.entries);
271        }
272    }
273}
274
275fn try_load_index_dir(store: &mut DaemonGraphStore, storage_path: &str) {
276    let index = Path::new(storage_path).join("Index");
277    let Ok(entries) = std::fs::read_dir(&index) else {
278        return;
279    };
280    let paths: Vec<_> = entries
281        .filter_map(Result::ok)
282        .map(|entry| entry.path())
283        .filter(|path| {
284            path.extension().and_then(|e| e.to_str()) == Some("q42")
285                && !path
286                    .file_name()
287                    .map(|n| n.to_string_lossy().contains(".meta."))
288                    .unwrap_or(false)
289        })
290        .collect();
291    let mut child_paths = HashSet::new();
292    for path in &paths {
293        let Ok(root) = crate::q42_volume::Q42Volume::open(path) else {
294            continue;
295        };
296        let Ok(Some(manifest)) = root.volume_manifest() else {
297            continue;
298        };
299        let parent = path.parent().unwrap_or_else(|| Path::new("."));
300        for segment in manifest.segments {
301            child_paths.insert(parent.join(segment.locator));
302        }
303    }
304    for path in paths {
305        if child_paths.contains(&path) {
306            continue;
307        }
308        if let Ok(quins) = crate::q42_reader::read_q42_quins(&path) {
309            store.extend_from_slice(&quins);
310        }
311    }
312}
313
314/// Controls which resident graph layers are seeded at daemon boot.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct InitGraphOptions {
317    /// Seed the built-in anatomy/health demo triples.
318    pub seed_defaults: bool,
319    /// Load `.q42` volumes from `{storage_path}/Index`.
320    pub load_index: bool,
321}
322
323impl Default for InitGraphOptions {
324    fn default() -> Self {
325        Self {
326            seed_defaults: true,
327            load_index: true,
328        }
329    }
330}
331
332/// Initialise or refresh the daemon graph from storage path.
333pub fn init_daemon_graph(storage_path: &str) {
334    init_daemon_graph_with_options(storage_path, InitGraphOptions::default());
335}
336
337/// Initialise or refresh the daemon graph with explicit seeding policy.
338pub fn init_daemon_graph_with_options(storage_path: &str, opts: InitGraphOptions) {
339    // Always (re)load the literal lexicon from the Index volumes so the query
340    // path can resolve ingested literal text — independent of whether the quins
341    // come from the snapshot or a fresh seed below.
342    reset_graph_lexicon();
343    load_graph_lexicon_from_index(storage_path);
344
345    // A durable snapshot (written after each committed SPARQL Update) is the
346    // authoritative last full state — prefer it so updates survive a restart.
347    if let Ok(n) = load_graph_snapshot(storage_path) {
348        if n > 0 {
349            bump_graph_revision();
350            return;
351        }
352    }
353    let lock = graph_lock();
354    if let Ok(mut guard) = lock.write() {
355        guard.clear();
356        if opts.seed_defaults {
357            seed_anatomy_health_graph(&mut guard);
358        }
359        if opts.load_index {
360            try_load_index_dir(&mut guard, storage_path);
361        }
362    }
363    bump_graph_revision();
364}
365
366/// Number of Quins currently available to `/query`.
367pub fn graph_quin_count() -> usize {
368    graph_lock().read().map(|g| g.len()).unwrap_or(0)
369}
370
371/// Read guard over the live graph (lock is process-static via `OnceLock`).
372pub fn graph_read_guard() -> std::sync::RwLockReadGuard<'static, DaemonGraphStore> {
373    graph_lock().read().expect("daemon graph poisoned")
374}
375
376/// Outcome of applying a SPARQL Update to the daemon graph.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub struct UpdateOutcome {
379    /// Number of quins added to the graph.
380    pub inserted: u64,
381    /// Number of quins removed from the graph.
382    pub deleted: u64,
383    /// Whether the change was signed and persisted to the WAL (true only when a
384    /// real signer callback was supplied). `false` = ephemeral, in-memory only.
385    pub persisted: bool,
386}
387
388/// Apply a parsed SPARQL Update to the resident daemon graph.
389///
390/// The mutation is applied under a single write guard (copy → run
391/// `UpdateExecutor` → write back), then the graph revision is bumped so
392/// subscribers (e.g. WebSocket sessions) are notified.
393///
394/// `on_change`, if supplied, receives the `(inserted, deleted)` quin sets so the
395/// caller can sign and persist them to the WAL with a **real** key (see
396/// `wal::commit_semantic_mutation`); `persisted` is then `true`. If it is
397/// `None`, the change is applied in memory only (ephemeral) and `persisted` is
398/// `false`. A signature is **never fabricated here** — durable, signed mutation
399/// requires the caller to supply a real signer, so an irreversible delete can
400/// never be committed under a placeholder key.
401pub fn apply_sparql_update(
402    op: &crate::sparql_library::sparql_update::UpdateOperation,
403    ctx: &crate::sparql_ast::SparqlQueryContext,
404    on_change: Option<&mut dyn FnMut(&[NQuin], &[NQuin]) -> Result<(), String>>,
405) -> Result<UpdateOutcome, String> {
406    use crate::sparql_library::sparql_update::UpdateExecutor;
407
408    let lock = graph_lock();
409    let mut guard = lock
410        .write()
411        .map_err(|_| "daemon graph poisoned".to_string())?;
412
413    let before: Vec<NQuin> = guard.as_slice().to_vec();
414    let mut working = before.clone();
415    UpdateExecutor::new(&mut working).execute(op, ctx)?;
416
417    // Delta by semantic identity (subject/predicate/object/context), ignoring
418    // parity/metadata noise.
419    let same = |a: &NQuin, b: &NQuin| {
420        a.subject == b.subject
421            && a.predicate == b.predicate
422            && a.object == b.object
423            && a.context == b.context
424    };
425    let inserted: Vec<NQuin> = working
426        .iter()
427        .filter(|w| !before.iter().any(|b| same(w, b)))
428        .copied()
429        .collect();
430    let deleted: Vec<NQuin> = before
431        .iter()
432        .filter(|b| !working.iter().any(|w| same(w, b)))
433        .copied()
434        .collect();
435
436    guard.clear();
437    guard.extend_from_slice(&working);
438    drop(guard);
439    bump_graph_revision();
440
441    let persisted = if let Some(cb) = on_change {
442        // The graph is already updated; the callback makes it durable. If it
443        // fails, the in-memory change stands but is reported as not persisted.
444        cb(&inserted, &deleted).map_err(|e| format!("persist failed: {e}"))?;
445        true
446    } else {
447        false
448    };
449
450    Ok(UpdateOutcome {
451        inserted: inserted.len() as u64,
452        deleted: deleted.len() as u64,
453        persisted,
454    })
455}
456
457/// Path of the durable full-state snapshot under a storage directory.
458fn snapshot_path(storage_path: &str) -> std::path::PathBuf {
459    Path::new(storage_path).join("daemon_graph.snapshot")
460}
461
462/// Write the current full graph state to a flat-quin snapshot file. Called after
463/// a persisted SPARQL Update so the change survives a restart. Returns the quin
464/// count written.
465pub fn persist_graph_snapshot(storage_path: &str) -> std::io::Result<usize> {
466    let lock = graph_lock();
467    let guard = lock
468        .read()
469        .map_err(|_| std::io::Error::new(std::io::ErrorKind::Other, "daemon graph poisoned"))?;
470    let bytes: &[u8] = bytemuck::cast_slice(guard.as_slice());
471    let path = snapshot_path(storage_path);
472    if let Some(parent) = path.parent() {
473        let _ = std::fs::create_dir_all(parent);
474    }
475    std::fs::write(&path, bytes)?;
476    Ok(guard.len())
477}
478
479/// Load the durable snapshot into the graph, if present. Returns the quin count
480/// loaded (0 if there is no snapshot). This is the authoritative last full state
481/// (defaults + index + committed updates) when it exists.
482pub fn load_graph_snapshot(storage_path: &str) -> std::io::Result<usize> {
483    let path = snapshot_path(storage_path);
484    if !path.exists() {
485        return Ok(0);
486    }
487    let bytes = std::fs::read(&path)?;
488    if bytes.is_empty() {
489        return Ok(0);
490    }
491    replace_graph_from_flat_bytes(&bytes)
492        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
493}
494
495/// Apply a SPARQL Update **durably and signed**: each net change is stamped and
496/// signed to the WAL (the append-only audit trail) with the caller's **real**
497/// ed25519 key, then the full graph state is snapshotted for restart durability.
498/// This is the production write path; the key must come from the identity /
499/// key-vault layer — never a placeholder.
500#[allow(clippy::too_many_arguments)]
501pub fn apply_sparql_update_durable(
502    op: &crate::sparql_library::sparql_update::UpdateOperation,
503    ctx: &crate::sparql_ast::SparqlQueryContext,
504    signing_key: &ed25519_dalek::SigningKey,
505    principal_did_hash: u64,
506    agent_did_hash: u64,
507    wal_path: &str,
508    storage_path: &str,
509) -> Result<UpdateOutcome, String> {
510    let mut wal = crate::wal::WriteAheadLog::open(wal_path).map_err(|e| e.to_string())?;
511    let mut suspended = crate::crdt::SuspendedTransactionQueue::new();
512    let mut cb = |inserted: &[NQuin], deleted: &[NQuin]| -> Result<(), String> {
513        // Record every mutation (insert and delete) as a signed WAL entry — the
514        // tamper-evident audit trail. Restart state comes from the snapshot.
515        for q in inserted.iter().chain(deleted.iter()) {
516            let mut qm = *q;
517            crate::wal::commit_semantic_mutation(
518                &mut wal,
519                &mut qm,
520                principal_did_hash,
521                agent_did_hash,
522                signing_key,
523                &mut suspended,
524            )
525            .map_err(|e| e.to_string())?;
526        }
527        Ok(())
528    };
529    let outcome = apply_sparql_update(op, ctx, Some(&mut cb))?;
530    persist_graph_snapshot(storage_path).map_err(|e| e.to_string())?;
531    Ok(outcome)
532}
533
534/// Extend the live graph with ontology quins from `qualia-core-db::ontology_loader`.
535pub fn extend_with_ontology_quins(quins: Vec<crate::NQuin>) {
536    extend_with_ontology_quins_slice(&quins);
537}
538
539/// Zero-heap resident update path for ontology insertion.
540pub fn extend_with_ontology_quins_slice(quins: &[crate::NQuin]) {
541    if quins.is_empty() {
542        return;
543    }
544    let lock = graph_lock();
545    if let Ok(mut guard) = lock.write() {
546        let before = guard.len();
547        for &q in quins {
548            let _ = guard.push_unique(q);
549        }
550        if guard.len() > before {
551            bump_graph_revision();
552        }
553    }
554}
555
556/// Replace the in-memory graph with flat 48-byte NQuin bytes (browser bench_load).
557pub fn replace_graph_from_flat_bytes(bytes: &[u8]) -> Result<usize, &'static str> {
558    let lock = graph_lock();
559    let mut guard = lock.write().map_err(|_| "daemon graph poisoned")?;
560    if bytes.is_empty() {
561        guard.clear();
562        return Ok(0);
563    }
564    if bytes.len() % 48 != 0 {
565        return Err("db_bytes length must be a multiple of 48");
566    }
567    let quin_count = bytes.len() / 48;
568    if quin_count > MAX_GRAPH_QUINS {
569        return Err("graph exceeds daemon MAX_GRAPH_QUINS");
570    }
571    let quins: &[NQuin] = bytemuck::cast_slice(bytes);
572    guard.clear();
573    guard.extend_from_slice(quins);
574    bump_graph_revision();
575    Ok(quin_count)
576}
577
578/// Known condition subject hashes for Anatomy graph -> label mapping.
579pub fn condition_label_for_subject_hash(subject: u64) -> Option<&'static str> {
580    const BIO: &str = "https://qualia.anatomy.example/ontology/bio#";
581    const TABLE: [(&str, &str); 8] = [
582        ("Type2Diabetes", "Type 2 Diabetes Mellitus"),
583        ("Hypertension", "Hypertension"),
584        ("ChronicKidneyDisease", "Chronic Kidney Disease (CKD)"),
585        ("HeartFailure", "Heart Failure"),
586        ("COPD", "Chronic Obstructive Pulmonary Disease (COPD)"),
587        ("Obesity", "Obesity"),
588        ("AtrialFibrillation", "Atrial Fibrillation"),
589        ("Depression", "Major Depressive Disorder"),
590    ];
591
592    for (local, label) in TABLE {
593        if q_hash(&format!("{BIO}{local}")) == subject {
594            return Some(label);
595        }
596    }
597    None
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use serial_test::serial;
604
605    fn reset_graph_for_test() {
606        let lock = graph_lock();
607        let mut guard = lock.write().expect("daemon graph poisoned");
608        guard.clear();
609    }
610
611    #[test]
612    #[serial]
613    fn seed_graph_has_health_quins() {
614        reset_graph_for_test();
615        init_daemon_graph("/tmp/qualia-test-graph");
616        assert!(graph_quin_count() >= 8);
617        reset_graph_for_test();
618    }
619
620    #[test]
621    #[serial]
622    fn replace_graph_from_flat_bytes_round_trip() {
623        reset_graph_for_test();
624        let quin = triple_quin(
625            "http://q.test/s/0",
626            "http://q.test/p/0",
627            "http://q.test/o/0",
628            "did:qualia:test",
629        );
630        let bytes = bytemuck::bytes_of(&quin);
631        let count = replace_graph_from_flat_bytes(bytes).expect("load flat quin");
632        assert_eq!(count, 1);
633        assert_eq!(graph_quin_count(), 1);
634        reset_graph_for_test();
635    }
636
637    #[test]
638    #[serial]
639    fn extend_with_ontology_quins_deduplicates_within_single_batch() {
640        reset_graph_for_test();
641        let quin = triple_quin(
642            "http://q.test/s/duplicate",
643            "http://q.test/p/duplicate",
644            "http://q.test/o/first",
645            "did:qualia:test",
646        );
647
648        extend_with_ontology_quins_slice(&[quin, quin]);
649
650        let guard = graph_read_guard();
651        assert_eq!(guard.len(), 1);
652        assert_eq!(guard[0], quin);
653        drop(guard);
654        reset_graph_for_test();
655    }
656
657    #[test]
658    #[serial]
659    fn init_daemon_graph_bumps_revision() {
660        reset_graph_for_test();
661        let before = graph_revision();
662        init_daemon_graph("/tmp/qualia-test-graph");
663        assert!(graph_revision() > before);
664        reset_graph_for_test();
665    }
666
667    #[test]
668    #[serial]
669    fn extend_with_ontology_quins_bumps_revision_only_when_added() {
670        reset_graph_for_test();
671        let quin = triple_quin(
672            "http://q.test/s/rev",
673            "http://q.test/p/rev",
674            "http://q.test/o/rev",
675            "did:qualia:test",
676        );
677        let before = graph_revision();
678        extend_with_ontology_quins_slice(&[quin]);
679        assert!(graph_revision() > before);
680
681        let unchanged = graph_revision();
682        extend_with_ontology_quins_slice(&[quin]);
683        assert_eq!(graph_revision(), unchanged);
684        reset_graph_for_test();
685    }
686
687    #[test]
688    #[serial]
689    fn replace_graph_from_flat_bytes_bumps_revision() {
690        reset_graph_for_test();
691        let quin = triple_quin(
692            "http://q.test/s/replace",
693            "http://q.test/p/replace",
694            "http://q.test/o/replace",
695            "did:qualia:test",
696        );
697        let before = graph_revision();
698        let bytes = bytemuck::bytes_of(&quin);
699        replace_graph_from_flat_bytes(bytes).expect("load flat quin");
700        assert!(graph_revision() > before);
701        reset_graph_for_test();
702    }
703
704    fn parse_upd(
705        src: &str,
706    ) -> (
707        crate::sparql_ast::SparqlQueryContext,
708        crate::sparql_library::sparql_update::UpdateOperation,
709    ) {
710        let mut ctx = crate::sparql_ast::SparqlQueryContext::new();
711        let op = crate::sparql_library::sparql_grammar::parse_update(
712            src,
713            &mut ctx,
714            &std::collections::HashMap::new(),
715        )
716        .unwrap();
717        (ctx, op)
718    }
719
720    #[test]
721    #[serial]
722    fn apply_update_insert_grows_graph_ephemeral() {
723        reset_graph_for_test();
724        let (ctx, op) =
725            parse_upd("INSERT DATA { <http://q.test/s1> <http://q.test/p1> <http://q.test/o1> }");
726        let before = graph_quin_count();
727        let rev_before = graph_revision();
728        let out = apply_sparql_update(&op, &ctx, None).unwrap();
729        assert_eq!(out.inserted, 1);
730        assert!(
731            !out.persisted,
732            "no signer callback → ephemeral, not persisted"
733        );
734        assert_eq!(graph_quin_count(), before + 1);
735        assert!(
736            graph_revision() > rev_before,
737            "revision bumped for subscribers"
738        );
739        reset_graph_for_test();
740    }
741
742    #[test]
743    #[serial]
744    fn graph_lexicon_merge_and_lookup() {
745        // The resident graph lexicon (merged from .q42 volumes) is what the query
746        // path hands the geo/text resolver for ingested-data literal text.
747        reset_graph_lexicon();
748        let mut m = std::collections::HashMap::new();
749        m.insert(42u64, "POINT(1 2)".to_string());
750        merge_graph_lexicon(m);
751        assert_eq!(graph_lexicon_lookup(42), Some("POINT(1 2)".to_string()));
752        assert_eq!(graph_lexicon_lookup(999), None);
753        reset_graph_lexicon();
754    }
755
756    #[test]
757    #[serial]
758    fn update_snapshot_survives_reinit() {
759        reset_graph_for_test();
760        let dir = std::env::temp_dir().join(format!("qdb_snap_{}", std::process::id()));
761        let _ = std::fs::create_dir_all(&dir);
762        let storage = dir.to_string_lossy().to_string();
763        let _ = std::fs::remove_file(snapshot_path(&storage));
764
765        // Apply an update and snapshot the full state.
766        let (ctx, op) = parse_upd(
767            "INSERT DATA { <http://q.test/durable> <http://q.test/p> <http://q.test/o> }",
768        );
769        apply_sparql_update(&op, &ctx, None).unwrap();
770        let count = graph_quin_count();
771        assert!(count > 0);
772        persist_graph_snapshot(&storage).unwrap();
773
774        // Wipe the in-memory graph, then re-init — the snapshot must restore it.
775        reset_graph_for_test();
776        assert_eq!(graph_quin_count(), 0);
777        init_daemon_graph_with_options(&storage, InitGraphOptions::default());
778        assert_eq!(
779            graph_quin_count(),
780            count,
781            "the durable snapshot must restore the updated graph on restart"
782        );
783
784        let _ = std::fs::remove_file(snapshot_path(&storage));
785        reset_graph_for_test();
786    }
787
788    #[test]
789    #[serial]
790    fn apply_update_delete_removes_and_signer_sees_delta() {
791        reset_graph_for_test();
792        let (ictx, iop) =
793            parse_upd("INSERT DATA { <http://q.test/s2> <http://q.test/p2> <http://q.test/o2> }");
794        apply_sparql_update(&iop, &ictx, None).unwrap();
795        let seeded = graph_quin_count();
796
797        let (dctx, dop) =
798            parse_upd("DELETE DATA { <http://q.test/s2> <http://q.test/p2> <http://q.test/o2> }");
799        let mut captured_deleted = 0usize;
800        let mut cb = |_ins: &[NQuin], del: &[NQuin]| -> Result<(), String> {
801            captured_deleted = del.len();
802            Ok(())
803        };
804        let out = apply_sparql_update(&dop, &dctx, Some(&mut cb)).unwrap();
805        assert_eq!(out.deleted, 1);
806        assert!(out.persisted, "signer callback supplied → persisted");
807        assert_eq!(captured_deleted, 1, "callback received the deleted quin");
808        assert_eq!(graph_quin_count(), seeded - 1);
809        reset_graph_for_test();
810    }
811}