Skip to main content

qualia_core_db/hypermedia/
mod.rs

1//! **Hypermedia semantic library** — an asset ⊕ its analytics ⊕ its related/associated assets, bound as a
2//! **semantic graph (NQuins), not a directory structure.**
3//!
4//! The Qualia line is *"context is the asset"*: an asset is never a bare file; it is a first-class entity
5//! that carries, inseparably, what it was derived *from* (`prov:wasDerivedFrom`), the analytics computed
6//! *about* it (`analysisTarget`/`analysisResult`), the related assets bound *with* it (`bundledWith` + a
7//! role), and its provenance (`hasProvenance`). Because the binding is a **graph of edges over the one
8//! identity space** (`q_hash`/`fnv` subjects, shared with [`crate::render::assets`]), you browse and query it
9//! by **meaning and lineage** — "what was this derived from / what analytics belong to it / what's related" —
10//! never by folder path.
11//!
12//! P0 (this module): the semantic model + [`container_to_nquins`] (emit the whole edge-graph) + the
13//! query helpers that read the relationships back out. It composes the real primitives — [`NQuin`],
14//! `q_hash`, and the same 60-bit FNV subject-hashing that `render/assets.rs::mesh_to_nquins` uses, so a
15//! container's reference to a mesh asset resolves to *the same subject* that asset's own manifest emits.
16//! P1 adds the in-`.10d` provenance sidecar + a validate-before-use gate; P2 re-points the anatomy pipeline
17//! through this (an organ = mesh ⊕ systemic-burden analytics ⊕ source-GLB / provenance). See
18//! `docs/plans/hypermedia-semantic-library.md`.
19
20use std::collections::HashMap;
21
22use crate::frame_layout::pack_float_object;
23use crate::{q_hash, NQuin};
24
25/// Content-processor implementations that derive searchability at ingest. The
26/// model-free [`TextProcessor`] lives in this file (it is the framework
27/// reference); the heavier [`ImageProcessor`] (EXIF time/place → the
28/// timeline/map facets) and [`WavProcessor`] (STFT spectral summary) live in
29/// [`processors`] as their own units (§11: split as the library grows).
30pub mod processors;
31pub use processors::{AudioSpectralSummary, ImageProcessor, WavProcessor};
32
33/// The named graph the hypermedia relationship edges live in.
34pub 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");
39/// container → its primary asset.
40const P_HAS_PRIMARY: u64 = q_hash("urn:qualia:hypermedia:hasPrimary");
41/// container → each asset it bundles (primary + related).
42const P_BUNDLED_WITH: u64 = q_hash("urn:qualia:hypermedia:bundledWith");
43/// asset → its role class within the container.
44const P_HAS_ROLE: u64 = q_hash("urn:qualia:hypermedia:hasRole");
45/// asset → the source asset it was derived from (W3C PROV).
46const P_WAS_DERIVED_FROM: u64 = q_hash("http://www.w3.org/ns/prov#wasDerivedFrom");
47/// asset → its provenance record asset.
48const P_HAS_PROVENANCE: u64 = q_hash("urn:qualia:hypermedia:hasProvenance");
49/// analytics → the asset it is *about*.
50const P_ANALYSIS_TARGET: u64 = q_hash("urn:qualia:hypermedia:analysisTarget");
51/// container → an analytics result it carries.
52const P_ANALYSIS_RESULT: u64 = q_hash("urn:qualia:hypermedia:analysisResult");
53/// analytics → the method that produced it.
54const 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/// The role a bundled asset plays relative to the container's primary asset.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum AssetRole {
63    /// The container's principal asset (the thing it is *about*).
64    Primary,
65    /// An immutable original source the primary/derivations came from.
66    Source,
67    /// A derivation of the primary (e.g. the compiled `.10d`, a transcode).
68    Derivation,
69    /// A level-of-detail variant.
70    Lod,
71    /// An analysis-result asset.
72    Analysis,
73    /// A provenance record (source bytes / licence / VC).
74    Provenance,
75    /// Any other associated asset.
76    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    /// The `q_hash` of this role's class IRI — the object of a `hasRole` edge.
93    pub fn class(self) -> u64 {
94        q_hash(self.uri())
95    }
96
97    /// Map a role-class hash back to the role (for reading edges).
98    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/// A content-addressed reference to an asset (its stable subject is `fnv60(uri)`, the same subject its own
113/// geometry manifest emits, so container edges join to the asset's facts in the one identity space).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct AssetRef {
116    pub uri: String,
117    /// Content digest (CRC-32C or SHA-256 truncated) — the anti-tamper / dedup anchor.
118    pub digest: u64,
119    pub media_type: String,
120    pub role: AssetRole,
121    /// URIs of the source asset(s) this one was derived from (emits `prov:wasDerivedFrom`).
122    pub derived_from: Vec<String>,
123    /// Optional licence / creator (dcterms) — the never-strip-context fields.
124    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/// An analytics result *about* an asset — the derived data bound back to the geometry it concerns.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct AnalyticsRef {
162    pub id: String,
163    /// The method/tool that produced it (e.g. `wellfare:systemic-burden`).
164    pub method: String,
165    /// The URI of the asset this analysis is *about* (defaults to the container primary if empty).
166    pub target_uri: String,
167    /// A short serialized summary of the result (e.g. a JSON burden roll-up).
168    pub summary: String,
169}
170
171impl AnalyticsRef {
172    pub fn subject(&self) -> u64 {
173        fnv60(self.id.as_bytes())
174    }
175}
176
177/// A hypermedia container: a primary asset bundled with its related assets and its analytics, as one
178/// addressable semantic unit.
179#[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
209/// The same 60-bit FNV-1a subject hash `render/assets.rs` uses — so a container's reference to a mesh asset
210/// resolves to the *identical* subject that asset's own `mesh_to_nquins` manifest emits (one identity space).
211pub 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
220/// A content digest for an asset's bytes, in the same 60-bit identity space as
221/// asset subjects — the anti-tamper / dedup anchor a caller stores as
222/// [`AssetRef::digest`]. (Public wrapper so client-core ingest can compute it.)
223pub 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
240/// Emit the **whole relationship edge-graph** of a container as NQuins (+ a lexicon of the string values),
241/// in the [`HYPERMEDIA_CONTEXT`] named graph. This is the semantic library's core: the edges *are* the
242/// container. Every emitted quin carries valid field parity.
243pub 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    // The container node.
250    quins.push(edge(cs, P_RDF_TYPE, C_CONTAINER));
251    quins.push(edge(cs, P_HAS_PRIMARY, c.primary.subject()));
252
253    // Every asset (primary first, then related) is bundled and described.
254    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    // Analytics bound back to the asset they are *about*.
287    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
306// ── Query the graph by *meaning and lineage* (not by path) ──────────────────────────────────────
307
308fn 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
318/// The container's primary asset subject.
319pub fn primary_of(quins: &[NQuin], container_subject: u64) -> Option<u64> {
320    objects(quins, container_subject, P_HAS_PRIMARY)
321        .first()
322        .copied()
323}
324
325/// Every asset subject a container bundles (primary + related).
326pub fn bundled(quins: &[NQuin], container_subject: u64) -> Vec<u64> {
327    objects(quins, container_subject, P_BUNDLED_WITH)
328}
329
330/// The role of an asset within the container.
331pub 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
338/// The source asset subjects an asset was derived from — its lineage.
339pub fn derived_from(quins: &[NQuin], asset_subject: u64) -> Vec<u64> {
340    objects(quins, asset_subject, P_WAS_DERIVED_FROM)
341}
342
343/// The provenance-record subject bound to an asset, if any.
344pub fn provenance_of(quins: &[NQuin], asset_subject: u64) -> Option<u64> {
345    objects(quins, asset_subject, P_HAS_PROVENANCE)
346        .first()
347        .copied()
348}
349
350/// The analytics subjects that are *about* a given asset — the derived data belonging to it.
351pub 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
363// ── Descriptors (facets): find assets by meaning / time / place / project / content, not by path ──
364//
365// Ingest *derives* searchability: a processor attaches these facets to an asset, and search is a query over
366// the edges — "files about a topic", "what an image depicts", "events in a period" (timeline), "photos at a
367// place" (map), "everything for a project", "documents that support a tax/expenses claim". None is a folder.
368
369const 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/// A place an asset is bound to — a human label plus coordinates (for the map view).
387#[derive(Debug, Clone, PartialEq)]
388pub struct Place {
389    pub label: String,
390    pub lat: f32,
391    pub lon: f32,
392}
393
394/// The semantic facets that make an asset findable — topic, what it depicts, when/where it happened, which
395/// project it belongs to, and its document-type / purpose (e.g. tax-support). All edges; none a folder.
396#[derive(Debug, Clone, Default, PartialEq)]
397pub struct Descriptors {
398    pub topics: Vec<String>,
399    /// Subjects depicted in an image (the "representation of something in the image").
400    pub depicts: Vec<String>,
401    /// A single event instant (unix seconds) — the timeline anchor.
402    pub occurred_at: Option<i64>,
403    /// A period the asset covers (unix seconds).
404    pub occurred_interval: Option<(i64, i64)>,
405    pub place: Option<Place>,
406    pub projects: Vec<String>,
407    pub document_type: Option<String>,
408    /// Purposes the asset serves (e.g. `tax-return-2025`, `expenses-claim`).
409    pub purposes: Vec<String>,
410}
411
412/// Emit descriptor edges for an asset subject. Each string facet's object is `fnv60(value)` (so a search for
413/// that value matches); event times are stored as their `u64` bit pattern for range scans.
414pub 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
468/// Assets *about* a topic. (biology / engineering / policy / software / law / …)
469pub fn by_topic(quins: &[NQuin], topic: &str) -> Vec<u64> {
470    subjects_with(quins, P_TOPIC, fnv60(topic.as_bytes()))
471}
472/// Assets whose image *depicts* a subject (the "representation in the image").
473pub fn by_depiction(quins: &[NQuin], subject: &str) -> Vec<u64> {
474    subjects_with(quins, P_DEPICTS, fnv60(subject.as_bytes()))
475}
476/// Assets at a place (map view).
477pub fn by_place(quins: &[NQuin], place_label: &str) -> Vec<u64> {
478    subjects_with(quins, P_AT_PLACE, fnv60(place_label.as_bytes()))
479}
480/// Assets collected under a project.
481pub fn in_project(quins: &[NQuin], project: &str) -> Vec<u64> {
482    subjects_with(quins, P_IN_PROJECT, fnv60(project.as_bytes()))
483}
484/// Assets serving a purpose (e.g. `tax-return-2025`, `expenses-claim`).
485pub fn for_purpose(quins: &[NQuin], purpose: &str) -> Vec<u64> {
486    subjects_with(quins, P_PURPOSE, fnv60(purpose.as_bytes()))
487}
488/// Assets whose event instant falls within `[start, end]` (unix seconds) — the timeline query.
489pub 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/// Severity of an ingest flag. Higher = more likely to warrant a guardian notification.
503#[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/// A flag raised while processing an ingested asset (e.g. concerning content). The flag is a **semantic
522/// descriptor** on the asset; if the principal is under a guardianship relation, the ingest path
523/// (client-core / host) reads these and notifies the guardian (and records who was notified — the
524/// accountability fabric). Defining flags here keeps them queryable; the notification wiring lives where
525/// guardianship + notifications do.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct Flag {
528    pub kind: String,
529    pub severity: FlagSeverity,
530    pub detail: String,
531}
532
533/// Emit flag edges bound to an asset subject.
534pub 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
559/// The flag subjects raised on an asset — what the guardian-notify path reads.
560pub fn flags_on(quins: &[NQuin], asset_subject: u64) -> Vec<u64> {
561    objects(quins, asset_subject, P_HAS_FLAG)
562}
563/// A flag's severity level (0 Info .. 3 Urgent), if present.
564pub 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// ── Ingest processors: ingest DERIVES searchability (P3) ─────────────────────────────────────────
571//
572// A document/image/asset goes in; a processor produces the derived searchable files (text / transcript /
573// depicted-subjects / thumbnail) + descriptor facets + any flags — which fold into the asset's container so
574// the *original* becomes findable. Heavy content processors (image→depicted-subjects/OCR, audio→transcript)
575// compose the parked `qualia-vision` / `qualia-audio` engines; this is the framework + a real model-free
576// text processor. (§11: this module is ~800 lines — split `hypermedia.rs` → `hypermedia/{container,descriptors,
577// processors}.rs` in a dedicated library-ization pass.)
578
579/// What a processor derives from an ingested asset — the searchable representations + facets + flags.
580#[derive(Debug, Clone, Default, PartialEq)]
581pub struct ProcessorOutput {
582    /// Derived searchable assets (role Derivation / Analysis) to bundle into the container.
583    pub derived: Vec<AssetRef>,
584    /// The bytes of each derived asset, keyed by its uri (e.g. the extracted plain text).
585    pub derived_bytes: HashMap<String, Vec<u8>>,
586    /// Descriptor facets extracted (topics, depicts, …) — bound to the primary asset.
587    pub descriptors: Descriptors,
588    /// Flags raised (→ the guardian-notify path when the principal is under guardianship).
589    pub flags: Vec<Flag>,
590}
591
592/// A processor: ingest an asset (bytes + media-type) → derive its searchable representations + facets.
593pub trait Processor {
594    /// Whether this processor handles the given media type.
595    fn handles(&self, media_type: &str) -> bool;
596    /// Derive searchable content + descriptors + flags from the asset.
597    fn process(&self, asset_uri: &str, bytes: &[u8], media_type: &str) -> ProcessorOutput;
598}
599
600/// A real, **model-free text / markdown** processor: derives a plain-text representation, assigns **topics**
601/// from a keyword map (biology / engineering / policy / software / law / finance-for-tax-&-expenses), and
602/// raises a **flag** for any watch-word present. Proves "ingest derives searchability" with no model runtime;
603/// the semantic-content processors compose `qualia-vision` / `qualia-audio`.
604pub struct TextProcessor {
605    /// topic → trigger words (any present ⇒ the topic is assigned).
606    pub topic_keywords: Vec<(String, Vec<String>)>,
607    /// watch-word → (flag kind, severity) (any present ⇒ a flag is raised).
608    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                // Privacy / GDPR-family (aligned with wellfair::cml_context extractors).
649                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        // A plain-text derivation of the original (what makes it searchable), derived from the primary.
715        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
735/// The result of ingesting an asset through a processor: the container, its quin graph (edges + descriptors +
736/// flags), the lexicon, and the flags (which the caller checks against a guardianship relation).
737pub struct IngestResult {
738    pub container: HypermediaContainer,
739    pub quins: Vec<NQuin>,
740    pub lexicon: HashMap<u64, String>,
741    pub flags: Vec<Flag>,
742}
743
744/// **Ingest an asset through a processor** and fold its output into a fresh container — the original plus its
745/// derived searchable representations, its facets, and any flags, all as edges. `digest` is the primary
746/// asset's content digest.
747pub 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    /// Build a real anatomy-shaped container (an organ mesh ⊕ its source GLB ⊕ a systemic-burden analysis ⊕
784    /// a provenance record) and read every relationship back out of the quin graph — proving the container is
785    /// a **semantic graph of edges**, not a directory.
786    #[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(), // → the primary
812            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        // The primary edge resolves to the SAME subject the asset's own manifest would use.
828        assert_eq!(primary_of(&quins, cs), Some(primary.subject()));
829        // The container bundles all three assets.
830        let bundled = bundled(&quins, cs);
831        assert_eq!(bundled.len(), 3);
832        assert!(bundled.contains(&source.subject()) && bundled.contains(&provenance.subject()));
833        // Roles are readable per asset.
834        assert_eq!(role_of(&quins, primary.subject()), Some(AssetRole::Primary));
835        assert_eq!(role_of(&quins, source.subject()), Some(AssetRole::Source));
836        // Lineage: the primary was derived from the source GLB.
837        assert_eq!(
838            derived_from(&quins, primary.subject()),
839            vec![source.subject()]
840        );
841        // Provenance is bound to the primary.
842        assert_eq!(
843            provenance_of(&quins, primary.subject()),
844            Some(provenance.subject())
845        );
846        // The analysis is bound *back to the mesh it is about* — not a sibling file, an edge.
847        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        // A container's reference to a URI hashes to the same 60-bit FNV subject that render/assets uses,
871        // so container edges join to the asset's own geometry facts.
872        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        // By topic: both are biology; only the liver is anatomy — search by MEANING, not path.
916        let bio = by_topic(&q, "biology");
917        assert!(bio.contains(&liver) && bio.contains(&heart));
918        assert_eq!(by_topic(&q, "anatomy"), vec![liver]);
919        // By project / place / purpose.
920        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        // Timeline: a window that excludes the liver's instant catches only the heart.
924        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        // A processor with a topic (law) and a watch-word that raises a flag (for the guardian path).
970        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        // The original is now findable by meaning.
981        assert!(
982            by_topic(&r.quins, "law").contains(&primary),
983            "original findable by derived topic"
984        );
985        // The flag is raised AND bound to the asset — the guardian-notify path reads it.
986        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}