1use std::collections::HashMap;
21
22use crate::frame_layout::pack_float_object;
23use crate::{q_hash, NQuin};
24
25pub mod processors;
31pub use processors::{AudioSpectralSummary, ImageProcessor, WavProcessor};
32
33pub const HYPERMEDIA_CONTEXT: u64 = q_hash("urn:qualia:context:hypermedia");
35
36const P_RDF_TYPE: u64 = q_hash("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
37const C_CONTAINER: u64 = q_hash("urn:qualia:hypermedia:Container");
38const C_ANALYTICS: u64 = q_hash("urn:qualia:hypermedia:Analytics");
39const P_HAS_PRIMARY: u64 = q_hash("urn:qualia:hypermedia:hasPrimary");
41const P_BUNDLED_WITH: u64 = q_hash("urn:qualia:hypermedia:bundledWith");
43const P_HAS_ROLE: u64 = q_hash("urn:qualia:hypermedia:hasRole");
45const P_WAS_DERIVED_FROM: u64 = q_hash("http://www.w3.org/ns/prov#wasDerivedFrom");
47const P_HAS_PROVENANCE: u64 = q_hash("urn:qualia:hypermedia:hasProvenance");
49const P_ANALYSIS_TARGET: u64 = q_hash("urn:qualia:hypermedia:analysisTarget");
51const P_ANALYSIS_RESULT: u64 = q_hash("urn:qualia:hypermedia:analysisResult");
53const P_ANALYSIS_METHOD: u64 = q_hash("urn:qualia:hypermedia:analysisMethod");
55const P_MEDIA_TYPE: u64 = q_hash("urn:qualia:hypermedia:mediaType");
56const P_DIGEST: u64 = q_hash("urn:qualia:hypermedia:digest");
57const P_LICENCE: u64 = q_hash("http://purl.org/dc/terms/license");
58const P_CREATOR: u64 = q_hash("http://purl.org/dc/terms/creator");
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum AssetRole {
63 Primary,
65 Source,
67 Derivation,
69 Lod,
71 Analysis,
73 Provenance,
75 Related,
77}
78
79impl AssetRole {
80 const fn uri(self) -> &'static str {
81 match self {
82 AssetRole::Primary => "urn:qualia:hypermedia:role:primary",
83 AssetRole::Source => "urn:qualia:hypermedia:role:source",
84 AssetRole::Derivation => "urn:qualia:hypermedia:role:derivation",
85 AssetRole::Lod => "urn:qualia:hypermedia:role:lod",
86 AssetRole::Analysis => "urn:qualia:hypermedia:role:analysis",
87 AssetRole::Provenance => "urn:qualia:hypermedia:role:provenance",
88 AssetRole::Related => "urn:qualia:hypermedia:role:related",
89 }
90 }
91
92 pub fn class(self) -> u64 {
94 q_hash(self.uri())
95 }
96
97 pub fn from_class(class: u64) -> Option<AssetRole> {
99 const ALL: [AssetRole; 7] = [
100 AssetRole::Primary,
101 AssetRole::Source,
102 AssetRole::Derivation,
103 AssetRole::Lod,
104 AssetRole::Analysis,
105 AssetRole::Provenance,
106 AssetRole::Related,
107 ];
108 ALL.into_iter().find(|r| r.class() == class)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct AssetRef {
116 pub uri: String,
117 pub digest: u64,
119 pub media_type: String,
120 pub role: AssetRole,
121 pub derived_from: Vec<String>,
123 pub licence: Option<String>,
125 pub creator: Option<String>,
126}
127
128impl AssetRef {
129 pub fn new(
130 uri: impl Into<String>,
131 digest: u64,
132 media_type: impl Into<String>,
133 role: AssetRole,
134 ) -> Self {
135 Self {
136 uri: uri.into(),
137 digest,
138 media_type: media_type.into(),
139 role,
140 derived_from: Vec::new(),
141 licence: None,
142 creator: None,
143 }
144 }
145
146 pub fn derived_from(mut self, source_uri: impl Into<String>) -> Self {
147 self.derived_from.push(source_uri.into());
148 self
149 }
150 pub fn with_licence(mut self, licence: impl Into<String>) -> Self {
151 self.licence = Some(licence.into());
152 self
153 }
154 pub fn subject(&self) -> u64 {
155 fnv60(self.uri.as_bytes())
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct AnalyticsRef {
162 pub id: String,
163 pub method: String,
165 pub target_uri: String,
167 pub summary: String,
169}
170
171impl AnalyticsRef {
172 pub fn subject(&self) -> u64 {
173 fnv60(self.id.as_bytes())
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct HypermediaContainer {
181 pub uri: String,
182 pub primary: AssetRef,
183 pub related: Vec<AssetRef>,
184 pub analytics: Vec<AnalyticsRef>,
185}
186
187impl HypermediaContainer {
188 pub fn new(uri: impl Into<String>, primary: AssetRef) -> Self {
189 Self {
190 uri: uri.into(),
191 primary,
192 related: Vec::new(),
193 analytics: Vec::new(),
194 }
195 }
196 pub fn with_related(mut self, asset: AssetRef) -> Self {
197 self.related.push(asset);
198 self
199 }
200 pub fn with_analytics(mut self, analytics: AnalyticsRef) -> Self {
201 self.analytics.push(analytics);
202 self
203 }
204 pub fn subject(&self) -> u64 {
205 fnv60(self.uri.as_bytes())
206 }
207}
208
209pub fn fnv60(bytes: &[u8]) -> u64 {
212 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
213 for &b in bytes {
214 h ^= b as u64;
215 h = h.wrapping_mul(0x0000_0100_0000_01b3);
216 }
217 h & 0x0FFF_FFFF_FFFF_FFFF
218}
219
220pub fn content_digest(bytes: &[u8]) -> u64 {
224 fnv60(bytes)
225}
226
227fn edge(subject: u64, predicate: u64, object: u64) -> NQuin {
228 let context = HYPERMEDIA_CONTEXT;
229 let metadata = 0;
230 NQuin {
231 subject,
232 predicate,
233 object,
234 context,
235 metadata,
236 parity: NQuin::calculate_parity(subject, predicate, object, context, metadata),
237 }
238}
239
240pub fn container_to_nquins(c: &HypermediaContainer) -> (Vec<NQuin>, HashMap<u64, String>) {
244 let mut quins = Vec::new();
245 let mut lexicon: HashMap<u64, String> = HashMap::new();
246 let cs = c.subject();
247 lexicon.insert(cs, c.uri.clone());
248
249 quins.push(edge(cs, P_RDF_TYPE, C_CONTAINER));
251 quins.push(edge(cs, P_HAS_PRIMARY, c.primary.subject()));
252
253 let assets = std::iter::once(&c.primary).chain(c.related.iter());
255 for a in assets {
256 let asu = a.subject();
257 lexicon.insert(asu, a.uri.clone());
258 quins.push(edge(cs, P_BUNDLED_WITH, asu));
259 quins.push(edge(asu, P_HAS_ROLE, a.role.class()));
260
261 let mt = fnv60(a.media_type.as_bytes());
262 lexicon.insert(mt, a.media_type.clone());
263 quins.push(edge(asu, P_MEDIA_TYPE, mt));
264 quins.push(edge(asu, P_DIGEST, a.digest));
265
266 for src in &a.derived_from {
267 let ss = fnv60(src.as_bytes());
268 lexicon.insert(ss, src.clone());
269 quins.push(edge(asu, P_WAS_DERIVED_FROM, ss));
270 }
271 if a.role == AssetRole::Provenance {
272 quins.push(edge(c.primary.subject(), P_HAS_PROVENANCE, asu));
273 }
274 if let Some(lic) = &a.licence {
275 let lh = fnv60(lic.as_bytes());
276 lexicon.insert(lh, lic.clone());
277 quins.push(edge(asu, P_LICENCE, lh));
278 }
279 if let Some(cr) = &a.creator {
280 let ch = fnv60(cr.as_bytes());
281 lexicon.insert(ch, cr.clone());
282 quins.push(edge(asu, P_CREATOR, ch));
283 }
284 }
285
286 for an in &c.analytics {
288 let ansu = an.subject();
289 lexicon.insert(ansu, an.id.clone());
290 let target = if an.target_uri.is_empty() {
291 c.primary.subject()
292 } else {
293 fnv60(an.target_uri.as_bytes())
294 };
295 quins.push(edge(ansu, P_RDF_TYPE, C_ANALYTICS));
296 quins.push(edge(ansu, P_ANALYSIS_TARGET, target));
297 quins.push(edge(cs, P_ANALYSIS_RESULT, ansu));
298 let mh = fnv60(an.method.as_bytes());
299 lexicon.insert(mh, an.method.clone());
300 quins.push(edge(ansu, P_ANALYSIS_METHOD, mh));
301 }
302
303 (quins, lexicon)
304}
305
306fn objects(quins: &[NQuin], subject: u64, predicate: u64) -> Vec<u64> {
309 quins
310 .iter()
311 .filter(|q| {
312 q.context == HYPERMEDIA_CONTEXT && q.subject == subject && q.predicate == predicate
313 })
314 .map(|q| q.object)
315 .collect()
316}
317
318pub fn primary_of(quins: &[NQuin], container_subject: u64) -> Option<u64> {
320 objects(quins, container_subject, P_HAS_PRIMARY)
321 .first()
322 .copied()
323}
324
325pub fn bundled(quins: &[NQuin], container_subject: u64) -> Vec<u64> {
327 objects(quins, container_subject, P_BUNDLED_WITH)
328}
329
330pub fn role_of(quins: &[NQuin], asset_subject: u64) -> Option<AssetRole> {
332 objects(quins, asset_subject, P_HAS_ROLE)
333 .first()
334 .copied()
335 .and_then(AssetRole::from_class)
336}
337
338pub fn derived_from(quins: &[NQuin], asset_subject: u64) -> Vec<u64> {
340 objects(quins, asset_subject, P_WAS_DERIVED_FROM)
341}
342
343pub fn provenance_of(quins: &[NQuin], asset_subject: u64) -> Option<u64> {
345 objects(quins, asset_subject, P_HAS_PROVENANCE)
346 .first()
347 .copied()
348}
349
350pub fn analytics_for(quins: &[NQuin], asset_subject: u64) -> Vec<u64> {
352 quins
353 .iter()
354 .filter(|q| {
355 q.context == HYPERMEDIA_CONTEXT
356 && q.predicate == P_ANALYSIS_TARGET
357 && q.object == asset_subject
358 })
359 .map(|q| q.subject)
360 .collect()
361}
362
363const P_TOPIC: u64 = q_hash("urn:qualia:hypermedia:topic");
370const P_DEPICTS: u64 = q_hash("urn:qualia:hypermedia:depicts");
371const P_OCCURRED_AT: u64 = q_hash("urn:qualia:hypermedia:occurredAt");
372const P_OCCURRED_START: u64 = q_hash("urn:qualia:hypermedia:occurredStart");
373const P_OCCURRED_END: u64 = q_hash("urn:qualia:hypermedia:occurredEnd");
374const P_AT_PLACE: u64 = q_hash("urn:qualia:hypermedia:atPlace");
375const P_AT_LAT: u64 = q_hash("urn:qualia:hypermedia:atLat");
376const P_AT_LON: u64 = q_hash("urn:qualia:hypermedia:atLon");
377const P_IN_PROJECT: u64 = q_hash("urn:qualia:hypermedia:inProject");
378const P_DOCUMENT_TYPE: u64 = q_hash("urn:qualia:hypermedia:documentType");
379const P_PURPOSE: u64 = q_hash("urn:qualia:hypermedia:purpose");
380const P_HAS_FLAG: u64 = q_hash("urn:qualia:hypermedia:hasFlag");
381const P_FLAG_KIND: u64 = q_hash("urn:qualia:hypermedia:flagKind");
382const P_FLAG_SEVERITY: u64 = q_hash("urn:qualia:hypermedia:flagSeverity");
383const P_FLAG_DETAIL: u64 = q_hash("urn:qualia:hypermedia:flagDetail");
384const C_FLAG: u64 = q_hash("urn:qualia:hypermedia:Flag");
385
386#[derive(Debug, Clone, PartialEq)]
388pub struct Place {
389 pub label: String,
390 pub lat: f32,
391 pub lon: f32,
392}
393
394#[derive(Debug, Clone, Default, PartialEq)]
397pub struct Descriptors {
398 pub topics: Vec<String>,
399 pub depicts: Vec<String>,
401 pub occurred_at: Option<i64>,
403 pub occurred_interval: Option<(i64, i64)>,
405 pub place: Option<Place>,
406 pub projects: Vec<String>,
407 pub document_type: Option<String>,
408 pub purposes: Vec<String>,
410}
411
412pub fn descriptors_to_nquins(
415 asset_subject: u64,
416 d: &Descriptors,
417) -> (Vec<NQuin>, HashMap<u64, String>) {
418 let mut quins = Vec::new();
419 let mut lex = HashMap::new();
420 let str_edge =
421 |quins: &mut Vec<NQuin>, lex: &mut HashMap<u64, String>, pred: u64, val: &str| {
422 let o = fnv60(val.as_bytes());
423 lex.insert(o, val.to_string());
424 quins.push(edge(asset_subject, pred, o));
425 };
426 for t in &d.topics {
427 str_edge(&mut quins, &mut lex, P_TOPIC, t);
428 }
429 for s in &d.depicts {
430 str_edge(&mut quins, &mut lex, P_DEPICTS, s);
431 }
432 for p in &d.projects {
433 str_edge(&mut quins, &mut lex, P_IN_PROJECT, p);
434 }
435 for p in &d.purposes {
436 str_edge(&mut quins, &mut lex, P_PURPOSE, p);
437 }
438 if let Some(dt) = &d.document_type {
439 str_edge(&mut quins, &mut lex, P_DOCUMENT_TYPE, dt);
440 }
441 if let Some(t) = d.occurred_at {
442 quins.push(edge(asset_subject, P_OCCURRED_AT, t as u64));
443 }
444 if let Some((s, e)) = d.occurred_interval {
445 quins.push(edge(asset_subject, P_OCCURRED_START, s as u64));
446 quins.push(edge(asset_subject, P_OCCURRED_END, e as u64));
447 }
448 if let Some(pl) = &d.place {
449 let lh = fnv60(pl.label.as_bytes());
450 lex.insert(lh, pl.label.clone());
451 quins.push(edge(asset_subject, P_AT_PLACE, lh));
452 quins.push(edge(asset_subject, P_AT_LAT, pack_float_object(pl.lat)));
453 quins.push(edge(asset_subject, P_AT_LON, pack_float_object(pl.lon)));
454 }
455 (quins, lex)
456}
457
458fn subjects_with(quins: &[NQuin], predicate: u64, object: u64) -> Vec<u64> {
459 quins
460 .iter()
461 .filter(|q| {
462 q.context == HYPERMEDIA_CONTEXT && q.predicate == predicate && q.object == object
463 })
464 .map(|q| q.subject)
465 .collect()
466}
467
468pub fn by_topic(quins: &[NQuin], topic: &str) -> Vec<u64> {
470 subjects_with(quins, P_TOPIC, fnv60(topic.as_bytes()))
471}
472pub fn by_depiction(quins: &[NQuin], subject: &str) -> Vec<u64> {
474 subjects_with(quins, P_DEPICTS, fnv60(subject.as_bytes()))
475}
476pub fn by_place(quins: &[NQuin], place_label: &str) -> Vec<u64> {
478 subjects_with(quins, P_AT_PLACE, fnv60(place_label.as_bytes()))
479}
480pub fn in_project(quins: &[NQuin], project: &str) -> Vec<u64> {
482 subjects_with(quins, P_IN_PROJECT, fnv60(project.as_bytes()))
483}
484pub fn for_purpose(quins: &[NQuin], purpose: &str) -> Vec<u64> {
486 subjects_with(quins, P_PURPOSE, fnv60(purpose.as_bytes()))
487}
488pub fn in_time_range(quins: &[NQuin], start: i64, end: i64) -> Vec<u64> {
490 quins
491 .iter()
492 .filter(|q| {
493 q.context == HYPERMEDIA_CONTEXT
494 && q.predicate == P_OCCURRED_AT
495 && (q.object as i64) >= start
496 && (q.object as i64) <= end
497 })
498 .map(|q| q.subject)
499 .collect()
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub enum FlagSeverity {
505 Info,
506 Notice,
507 Concern,
508 Urgent,
509}
510impl FlagSeverity {
511 pub fn level(self) -> u64 {
512 match self {
513 FlagSeverity::Info => 0,
514 FlagSeverity::Notice => 1,
515 FlagSeverity::Concern => 2,
516 FlagSeverity::Urgent => 3,
517 }
518 }
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct Flag {
528 pub kind: String,
529 pub severity: FlagSeverity,
530 pub detail: String,
531}
532
533pub fn flags_to_nquins(
535 asset_subject: u64,
536 asset_uri: &str,
537 flags: &[Flag],
538) -> (Vec<NQuin>, HashMap<u64, String>) {
539 let mut quins = Vec::new();
540 let mut lex = HashMap::new();
541 for f in flags {
542 let fs = fnv60(format!("{asset_uri}#flag:{}", f.kind).as_bytes());
543 lex.insert(fs, format!("{asset_uri}#flag:{}", f.kind));
544 quins.push(edge(asset_subject, P_HAS_FLAG, fs));
545 quins.push(edge(fs, P_RDF_TYPE, C_FLAG));
546 let kh = fnv60(f.kind.as_bytes());
547 lex.insert(kh, f.kind.clone());
548 quins.push(edge(fs, P_FLAG_KIND, kh));
549 quins.push(edge(fs, P_FLAG_SEVERITY, f.severity.level()));
550 if !f.detail.is_empty() {
551 let dh = fnv60(f.detail.as_bytes());
552 lex.insert(dh, f.detail.clone());
553 quins.push(edge(fs, P_FLAG_DETAIL, dh));
554 }
555 }
556 (quins, lex)
557}
558
559pub fn flags_on(quins: &[NQuin], asset_subject: u64) -> Vec<u64> {
561 objects(quins, asset_subject, P_HAS_FLAG)
562}
563pub fn flag_severity(quins: &[NQuin], flag_subject: u64) -> Option<u64> {
565 objects(quins, flag_subject, P_FLAG_SEVERITY)
566 .first()
567 .copied()
568}
569
570#[derive(Debug, Clone, Default, PartialEq)]
581pub struct ProcessorOutput {
582 pub derived: Vec<AssetRef>,
584 pub derived_bytes: HashMap<String, Vec<u8>>,
586 pub descriptors: Descriptors,
588 pub flags: Vec<Flag>,
590}
591
592pub trait Processor {
594 fn handles(&self, media_type: &str) -> bool;
596 fn process(&self, asset_uri: &str, bytes: &[u8], media_type: &str) -> ProcessorOutput;
598}
599
600pub struct TextProcessor {
605 pub topic_keywords: Vec<(String, Vec<String>)>,
607 pub flag_words: Vec<(String, (String, FlagSeverity))>,
609}
610
611impl Default for TextProcessor {
612 fn default() -> Self {
613 let kw = |t: &str, ws: &[&str]| (t.to_string(), ws.iter().map(|s| s.to_string()).collect());
614 Self {
615 topic_keywords: vec![
616 kw(
617 "biology",
618 &[
619 "cell",
620 "organ",
621 "gene",
622 "protein",
623 "hepatocyte",
624 "liver",
625 "anatomy",
626 ],
627 ),
628 kw(
629 "engineering",
630 &["stress", "load", "circuit", "tolerance", "mechanical"],
631 ),
632 kw(
633 "policy",
634 &["policy", "regulation", "governance", "legislation"],
635 ),
636 kw(
637 "software",
638 &["function", "compiler", "api", "struct", "runtime"],
639 ),
640 kw(
641 "law",
642 &["contract", "statute", "liability", "clause", "jurisdiction"],
643 ),
644 kw(
645 "finance",
646 &["invoice", "expense", "tax", "receipt", "deduction"],
647 ),
648 kw(
650 "privacy",
651 &[
652 "personal data",
653 "data subject",
654 "data controller",
655 "data processor",
656 "consent",
657 "gdpr",
658 "privacy",
659 "erasure",
660 "portability",
661 "dpia",
662 "breach",
663 ],
664 ),
665 kw(
666 "human-rights",
667 &[
668 "human rights",
669 "discrimination",
670 "freedom of expression",
671 "due process",
672 "fair trial",
673 ],
674 ),
675 kw(
676 "deontic",
677 &[
678 "must not",
679 "shall not",
680 "is required to",
681 "obligation",
682 "prohibition",
683 ],
684 ),
685 ],
686 flag_words: Vec::new(),
687 }
688 }
689}
690
691impl Processor for TextProcessor {
692 fn handles(&self, media_type: &str) -> bool {
693 media_type.starts_with("text/")
694 }
695
696 fn process(&self, asset_uri: &str, bytes: &[u8], _media_type: &str) -> ProcessorOutput {
697 let text = String::from_utf8_lossy(bytes).to_lowercase();
698 let mut topics = Vec::new();
699 for (topic, words) in &self.topic_keywords {
700 if words.iter().any(|w| text.contains(&w.to_lowercase())) {
701 topics.push(topic.clone());
702 }
703 }
704 let mut flags = Vec::new();
705 for (word, (kind, sev)) in &self.flag_words {
706 if text.contains(&word.to_lowercase()) {
707 flags.push(Flag {
708 kind: kind.clone(),
709 severity: *sev,
710 detail: format!("matched '{word}'"),
711 });
712 }
713 }
714 let text_uri = format!("{asset_uri}#text");
716 let derived =
717 vec![
718 AssetRef::new(&text_uri, fnv60(bytes), "text/plain", AssetRole::Derivation)
719 .derived_from(asset_uri),
720 ];
721 let mut derived_bytes = HashMap::new();
722 derived_bytes.insert(text_uri, bytes.to_vec());
723 ProcessorOutput {
724 derived,
725 derived_bytes,
726 descriptors: Descriptors {
727 topics,
728 ..Default::default()
729 },
730 flags,
731 }
732 }
733}
734
735pub struct IngestResult {
738 pub container: HypermediaContainer,
739 pub quins: Vec<NQuin>,
740 pub lexicon: HashMap<u64, String>,
741 pub flags: Vec<Flag>,
742}
743
744pub fn ingest_with(
748 processor: &dyn Processor,
749 asset_uri: &str,
750 media_type: &str,
751 digest: u64,
752 bytes: &[u8],
753) -> IngestResult {
754 let out = processor.process(asset_uri, bytes, media_type);
755 let primary = AssetRef::new(asset_uri, digest, media_type, AssetRole::Primary);
756 let mut container = HypermediaContainer::new(format!("{asset_uri}#container"), primary.clone());
757 for d in &out.derived {
758 container = container.with_related(d.clone());
759 }
760 let (mut quins, mut lexicon) = container_to_nquins(&container);
761 let (dq, dl) = descriptors_to_nquins(primary.subject(), &out.descriptors);
762 quins.extend(dq);
763 for (k, v) in dl {
764 lexicon.entry(k).or_insert(v);
765 }
766 let (fq, fl) = flags_to_nquins(primary.subject(), asset_uri, &out.flags);
767 quins.extend(fq);
768 for (k, v) in fl {
769 lexicon.entry(k).or_insert(v);
770 }
771 IngestResult {
772 container,
773 quins,
774 lexicon,
775 flags: out.flags,
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
787 fn container_round_trips_as_a_semantic_graph_not_a_directory() {
788 let primary = AssetRef::new(
789 "urn:qualia:organ:liver.10d",
790 0xABCD,
791 "model/qualia-10d",
792 AssetRole::Primary,
793 )
794 .derived_from("urn:hra:ccf:liver.glb");
795 let source = AssetRef::new(
796 "urn:hra:ccf:liver.glb",
797 0x1234,
798 "model/gltf-binary",
799 AssetRole::Source,
800 )
801 .with_licence("CC-BY-4.0");
802 let provenance = AssetRef::new(
803 "urn:qualia:prov:liver",
804 0x5555,
805 "application/ld+json",
806 AssetRole::Provenance,
807 );
808 let analysis = AnalyticsRef {
809 id: "urn:qualia:analysis:liver-burden".into(),
810 method: "wellfare:systemic-burden".into(),
811 target_uri: String::new(), summary: r#"{"digestive":420,"circulatory":180}"#.into(),
813 };
814
815 let c = HypermediaContainer::new("urn:qualia:container:liver", primary.clone())
816 .with_related(source.clone())
817 .with_related(provenance.clone())
818 .with_analytics(analysis.clone());
819
820 let (quins, _lex) = container_to_nquins(&c);
821 assert!(
822 quins.iter().all(|q| q.verify_ecc_parity()),
823 "every emitted quin has valid parity"
824 );
825
826 let cs = c.subject();
827 assert_eq!(primary_of(&quins, cs), Some(primary.subject()));
829 let bundled = bundled(&quins, cs);
831 assert_eq!(bundled.len(), 3);
832 assert!(bundled.contains(&source.subject()) && bundled.contains(&provenance.subject()));
833 assert_eq!(role_of(&quins, primary.subject()), Some(AssetRole::Primary));
835 assert_eq!(role_of(&quins, source.subject()), Some(AssetRole::Source));
836 assert_eq!(
838 derived_from(&quins, primary.subject()),
839 vec![source.subject()]
840 );
841 assert_eq!(
843 provenance_of(&quins, primary.subject()),
844 Some(provenance.subject())
845 );
846 assert_eq!(
848 analytics_for(&quins, primary.subject()),
849 vec![analysis.subject()]
850 );
851 }
852
853 #[test]
854 fn role_class_round_trips() {
855 for r in [
856 AssetRole::Primary,
857 AssetRole::Source,
858 AssetRole::Derivation,
859 AssetRole::Lod,
860 AssetRole::Analysis,
861 AssetRole::Provenance,
862 AssetRole::Related,
863 ] {
864 assert_eq!(AssetRole::from_class(r.class()), Some(r));
865 }
866 }
867
868 #[test]
869 fn subject_hash_matches_the_asset_identity_space() {
870 let a = AssetRef::new(
873 "urn:qualia:organ:heart.10d",
874 1,
875 "model/qualia-10d",
876 AssetRole::Primary,
877 );
878 assert_eq!(
879 a.subject() & 0xF000_0000_0000_0000,
880 0,
881 "subject stays in the 60-bit identity space"
882 );
883 assert_eq!(a.subject(), fnv60(b"urn:qualia:organ:heart.10d"));
884 }
885
886 #[test]
887 fn descriptors_make_assets_findable_by_facet_not_folder() {
888 let liver = fnv60(b"urn:qualia:organ:liver.10d");
889 let heart = fnv60(b"urn:qualia:organ:heart.10d");
890 let d_liver = Descriptors {
891 topics: vec!["biology".into(), "anatomy".into()],
892 projects: vec!["med-course".into()],
893 purposes: vec!["study".into()],
894 occurred_at: Some(1_700_000_000),
895 place: Some(Place {
896 label: "Sydney".into(),
897 lat: -33.87,
898 lon: 151.21,
899 }),
900 ..Default::default()
901 };
902 let d_heart = Descriptors {
903 topics: vec!["biology".into()],
904 occurred_at: Some(1_700_100_000),
905 ..Default::default()
906 };
907 let (mut q, _) = descriptors_to_nquins(liver, &d_liver);
908 let (q2, _) = descriptors_to_nquins(heart, &d_heart);
909 q.extend(q2);
910 assert!(
911 q.iter().all(|x| x.verify_ecc_parity()),
912 "descriptor quins have valid parity"
913 );
914
915 let bio = by_topic(&q, "biology");
917 assert!(bio.contains(&liver) && bio.contains(&heart));
918 assert_eq!(by_topic(&q, "anatomy"), vec![liver]);
919 assert_eq!(in_project(&q, "med-course"), vec![liver]);
921 assert_eq!(by_place(&q, "Sydney"), vec![liver]);
922 assert_eq!(for_purpose(&q, "study"), vec![liver]);
923 assert_eq!(in_time_range(&q, 1_700_050_000, 1_700_200_000), vec![heart]);
925 }
926
927 #[test]
928 fn a_flag_is_bound_to_the_asset_for_the_guardian_path() {
929 let uri = "urn:qualia:doc:xray.10d";
930 let asset = fnv60(uri.as_bytes());
931 let (q, _) = flags_to_nquins(
932 asset,
933 uri,
934 &[Flag {
935 kind: "sensitive-medical".into(),
936 severity: FlagSeverity::Concern,
937 detail: "radiograph".into(),
938 }],
939 );
940 assert!(q.iter().all(|x| x.verify_ecc_parity()));
941 let flags = flags_on(&q, asset);
942 assert_eq!(
943 flags.len(),
944 1,
945 "the flag is bound to the asset (what the guardian-notify path reads)"
946 );
947 assert_eq!(flag_severity(&q, flags[0]), Some(2), "Concern = level 2");
948 }
949
950 #[test]
951 fn text_processor_derives_topics_and_a_searchable_text_derivation() {
952 let proc = TextProcessor::default();
953 let doc = b"The human liver is an organ; hepatocytes secrete bile.";
954 let out = proc.process("urn:doc:liver-notes", doc, "text/markdown");
955 assert!(
956 out.descriptors.topics.contains(&"biology".to_string()),
957 "topic derived from content"
958 );
959 assert_eq!(
960 out.derived.len(),
961 1,
962 "a searchable text derivation is produced"
963 );
964 assert_eq!(out.derived[0].role, AssetRole::Derivation);
965 }
966
967 #[test]
968 fn ingest_makes_the_original_findable_and_raises_a_flag() {
969 let proc = TextProcessor {
971 topic_keywords: vec![("law".into(), vec!["contract".into(), "statute".into()])],
972 flag_words: vec![(
973 "confidential".into(),
974 ("sensitive".into(), FlagSeverity::Concern),
975 )],
976 };
977 let doc = b"This CONFIDENTIAL contract is governed by statute.";
978 let r = ingest_with(&proc, "urn:doc:nda", "text/plain", 0xAA, doc);
979 let primary = r.container.primary.subject();
980 assert!(
982 by_topic(&r.quins, "law").contains(&primary),
983 "original findable by derived topic"
984 );
985 assert_eq!(r.flags.len(), 1);
987 assert!(!flags_on(&r.quins, primary).is_empty());
988 assert!(r.quins.iter().all(|q| q.verify_ecc_parity()));
989 }
990}