1use 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
16pub 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#[inline]
136pub fn graph_revision() -> u64 {
137 GRAPH_REVISION.load(Ordering::Acquire)
138}
139
140pub fn subscribe_graph_revisions() -> broadcast::Receiver<u64> {
142 revision_tx().subscribe()
143}
144
145pub 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
172fn 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
220static 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
241pub 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
250fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct InitGraphOptions {
317 pub seed_defaults: bool,
319 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
332pub fn init_daemon_graph(storage_path: &str) {
334 init_daemon_graph_with_options(storage_path, InitGraphOptions::default());
335}
336
337pub fn init_daemon_graph_with_options(storage_path: &str, opts: InitGraphOptions) {
339 reset_graph_lexicon();
343 load_graph_lexicon_from_index(storage_path);
344
345 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
366pub fn graph_quin_count() -> usize {
368 graph_lock().read().map(|g| g.len()).unwrap_or(0)
369}
370
371pub fn graph_read_guard() -> std::sync::RwLockReadGuard<'static, DaemonGraphStore> {
373 graph_lock().read().expect("daemon graph poisoned")
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub struct UpdateOutcome {
379 pub inserted: u64,
381 pub deleted: u64,
383 pub persisted: bool,
386}
387
388pub 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 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 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
457fn snapshot_path(storage_path: &str) -> std::path::PathBuf {
459 Path::new(storage_path).join("daemon_graph.snapshot")
460}
461
462pub 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
479pub 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#[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 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
534pub fn extend_with_ontology_quins(quins: Vec<crate::NQuin>) {
536 extend_with_ontology_quins_slice(&quins);
537}
538
539pub 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
556pub 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
578pub 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 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 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 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}