Skip to main content

qualia_core_db/governance/
provenance.rs

1//! Provenance write helpers — labor DID labelling and contestability.
2//!
3//! This module provides functions that write PROV-O quins to `PROVENANCE_CONTEXT`
4//! and wire contestability disputes into the `DagStore` Merkle-DAG.
5//!
6//! Named graph contexts:
7//!   `PROVENANCE_CONTEXT = q_hash("urn:qualia:context:provenance")`
8//!   `CONTEST_CONTEXT    = q_hash("urn:qualia:context:contest")`
9
10use crate::temporal_graph::{
11    P_DC_CREATOR, P_WAS_ATTRIBUTED_TO, P_WAS_GENERATED_BY, P_WAS_INVALIDATED_BY,
12};
13use crate::{q_hash, NQuin};
14
15// ── Named-graph contexts ──────────────────────────────────────────────────────
16pub const PROVENANCE_CONTEXT: u64 = q_hash("urn:qualia:context:provenance");
17pub const CONTEST_CONTEXT: u64 = q_hash("urn:qualia:context:contest");
18
19// ── Provenance predicates ─────────────────────────────────────────────────────
20const P_LABELLED_BY: u64 = q_hash("urn:qualia:prov:labelledBy");
21const P_LABELLED_AT: u64 = q_hash("urn:qualia:prov:labelledAt");
22const P_MODERATED_BY: u64 = q_hash("urn:qualia:prov:moderatedBy");
23const P_MODERATED_AT: u64 = q_hash("urn:qualia:prov:moderatedAt");
24const P_CLEANED_BY: u64 = q_hash("urn:qualia:prov:cleanedBy");
25
26// ── Contestability predicates ─────────────────────────────────────────────────
27const P_CONTESTED_BY: u64 = q_hash("urn:qualia:prov:contestedBy");
28const P_CONTEST_REASON: u64 = q_hash("urn:qualia:prov:contestReason");
29const P_CONTEST_AT: u64 = q_hash("urn:qualia:prov:contestedAt");
30const P_RESOLVED_BY: u64 = q_hash("urn:qualia:prov:resolvedBy");
31const P_RESOLVED_AT: u64 = q_hash("urn:qualia:prov:resolvedAt");
32
33// ── Activity predicates ───────────────────────────────────────────────────────
34const P_ACTIVITY_LABEL: u64 = q_hash("urn:qualia:prov:activityLabel");
35const P_ACTIVITY_START: u64 = q_hash("urn:qualia:prov:activityStart");
36const P_ACTIVITY_END: u64 = q_hash("urn:qualia:prov:activityEnd");
37
38// ── Labor provenance ──────────────────────────────────────────────────────────
39
40/// Record that `data_hash` was labelled by `worker_did` at `ts` (ms since epoch).
41///
42/// Returns 2 quins in `PROVENANCE_CONTEXT`:
43///   - `prov:labelledBy` = worker DID hash
44///   - `prov:labelledAt` = timestamp
45pub fn label_with_worker_did(data_hash: u64, worker_did: u64, ts: u64) -> [NQuin; 2] {
46    [
47        make_prov(data_hash, P_LABELLED_BY, worker_did, ts),
48        make_prov(data_hash, P_LABELLED_AT, ts, ts),
49    ]
50}
51
52/// Record that `data_hash` was moderated (reviewed + approved or rejected) by `moderator_did`.
53pub fn record_moderation(data_hash: u64, moderator_did: u64, ts: u64) -> [NQuin; 2] {
54    [
55        make_prov(data_hash, P_MODERATED_BY, moderator_did, ts),
56        make_prov(data_hash, P_MODERATED_AT, ts, ts),
57    ]
58}
59
60/// Record that `data_hash` was cleaned (normalised/de-duplicated) by `agent_did`.
61pub fn record_cleaning(data_hash: u64, agent_did: u64, ts: u64) -> NQuin {
62    make_prov(data_hash, P_CLEANED_BY, agent_did, ts)
63}
64
65/// Full PROV-O labor attribution: creator + source + generated-at.
66///
67/// Returns 3 quins in `PROVENANCE_CONTEXT`:
68///   - `dcterms:creator` = worker DID hash
69///   - `prov:wasAttributedTo` = worker DID hash
70///   - `prov:wasGeneratedBy` = activity hash
71pub fn full_attribution(
72    data_hash: u64,
73    worker_did: u64,
74    activity_hash: u64,
75    ts: u64,
76) -> [NQuin; 3] {
77    [
78        make_prov(data_hash, P_DC_CREATOR, worker_did, ts),
79        make_prov(data_hash, P_WAS_ATTRIBUTED_TO, worker_did, ts),
80        make_prov(data_hash, P_WAS_GENERATED_BY, activity_hash, ts),
81    ]
82}
83
84// ── Contestability ────────────────────────────────────────────────────────────
85
86/// Record a contestability dispute against `disputed_hash`.
87///
88/// Returns 3 quins in `CONTEST_CONTEXT`:
89///   - `prov:contestedBy` = `agent_did`
90///   - `prov:contestReason` = `reason_hash` (q_hash of the reason string)
91///   - `prov:contestedAt` = `ts`
92///
93/// **Also** marks the disputed quin as invalidated in `PROVENANCE_CONTEXT`.
94/// Callers that have access to a `DagStore` should additionally call
95/// `dag_store.fork_node(disputed_dag_hash, ...)` to register the contestability branch.
96pub fn contest_assertion(
97    disputed_hash: u64,
98    agent_did: u64,
99    reason_hash: u64,
100    ts: u64,
101) -> [NQuin; 4] {
102    [
103        // Contest record in CONTEST_CONTEXT
104        make_contest(disputed_hash, P_CONTESTED_BY, agent_did, ts),
105        make_contest(disputed_hash, P_CONTEST_REASON, reason_hash, ts),
106        make_contest(disputed_hash, P_CONTEST_AT, ts, ts),
107        // Invalidation in PROVENANCE_CONTEXT (SPARQL can check this)
108        make_prov(disputed_hash, P_WAS_INVALIDATED_BY, agent_did, ts),
109    ]
110}
111
112/// Record resolution of a dispute.
113///
114/// Returns 2 quins in `CONTEST_CONTEXT`:
115///   - `prov:resolvedBy` = `resolver_did`
116///   - `prov:resolvedAt` = `ts`
117pub fn resolve_contest(disputed_hash: u64, resolver_did: u64, ts: u64) -> [NQuin; 2] {
118    [
119        make_contest(disputed_hash, P_RESOLVED_BY, resolver_did, ts),
120        make_contest(disputed_hash, P_RESOLVED_AT, ts, ts),
121    ]
122}
123
124// ── Activity records ──────────────────────────────────────────────────────────
125
126/// Write an activity record (labelling session, cleaning run, etc.) to `PROVENANCE_CONTEXT`.
127///
128/// Returns 3 quins:
129///   - activity type label
130///   - activity start time
131///   - activity end time
132pub fn write_activity(
133    activity_hash: u64,
134    label_hash: u64,
135    start_ms: u64,
136    end_ms: u64,
137) -> [NQuin; 3] {
138    [
139        make_prov(activity_hash, P_ACTIVITY_LABEL, label_hash, start_ms),
140        make_prov(activity_hash, P_ACTIVITY_START, start_ms, start_ms),
141        make_prov(activity_hash, P_ACTIVITY_END, end_ms, end_ms),
142    ]
143}
144
145// ── Query helpers ─────────────────────────────────────────────────────────────
146
147/// Return `true` if the quin slice contains any contestability record for `data_hash`.
148///
149/// Scans `CONTEST_CONTEXT` for a `prov:contestedBy` quin with the given subject.
150/// This is an O(n) linear scan — use a proper index in production queries.
151pub fn is_contested(quins: &[NQuin], data_hash: u64) -> bool {
152    quins.iter().any(|q| {
153        q.context == CONTEST_CONTEXT && q.subject == data_hash && q.predicate == P_CONTESTED_BY
154    })
155}
156
157/// Collect all worker DIDs that labelled `data_hash`.
158pub fn labellers(quins: &[NQuin], data_hash: u64) -> Vec<u64> {
159    quins
160        .iter()
161        .filter(|q| {
162            q.context == PROVENANCE_CONTEXT
163                && q.subject == data_hash
164                && q.predicate == P_LABELLED_BY
165        })
166        .map(|q| q.object)
167        .collect()
168}
169
170// ── Internal helpers ──────────────────────────────────────────────────────────
171
172#[inline]
173fn make_prov(subject: u64, predicate: u64, object: u64, lamport: u64) -> NQuin {
174    NQuin {
175        subject,
176        predicate,
177        object,
178        context: PROVENANCE_CONTEXT,
179        metadata: lamport & 0xFFFF_FFFF,
180        parity: 0,
181    }
182}
183
184#[inline]
185fn make_contest(subject: u64, predicate: u64, object: u64, lamport: u64) -> NQuin {
186    NQuin {
187        subject,
188        predicate,
189        object,
190        context: CONTEST_CONTEXT,
191        metadata: lamport & 0xFFFF_FFFF,
192        parity: 0,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    const DATA_HASH: u64 = 0xDA7A_1234_u64;
201    const WORKER_DID: u64 = 0x9999_B0B0_u64;
202    const DISPUTED: u64 = 0xD157_FEED_u64;
203    const AGENT_DID: u64 = 0xA6E4_7777_u64;
204    const REASON: u64 = 0x4EA5_0000_u64;
205    const WORKER1: u64 = 0xB0B1_u64;
206    const WORKER2: u64 = 0xB0B2_u64;
207
208    #[test]
209    fn label_produces_correct_context() {
210        let qs = label_with_worker_did(DATA_HASH, WORKER_DID, 12345);
211        for q in &qs {
212            assert_eq!(q.context, PROVENANCE_CONTEXT);
213            assert_eq!(q.subject, DATA_HASH);
214        }
215        assert_eq!(qs[0].predicate, P_LABELLED_BY);
216        assert_eq!(qs[0].object, WORKER_DID);
217    }
218
219    #[test]
220    fn contest_produces_four_quins() {
221        let qs = contest_assertion(DISPUTED, AGENT_DID, REASON, 99999);
222        assert_eq!(qs.len(), 4);
223        assert_eq!(qs[0].context, CONTEST_CONTEXT);
224        assert_eq!(qs[1].context, CONTEST_CONTEXT);
225        assert_eq!(qs[2].context, CONTEST_CONTEXT);
226        assert_eq!(qs[3].context, PROVENANCE_CONTEXT);
227    }
228
229    #[test]
230    fn is_contested_detects_dispute() {
231        let qs = contest_assertion(DISPUTED, AGENT_DID, REASON, 1);
232        assert!(is_contested(&qs, DISPUTED));
233        assert!(!is_contested(&qs, DATA_HASH));
234    }
235
236    #[test]
237    fn labellers_returns_worker_dids() {
238        let mut all = Vec::new();
239        all.extend_from_slice(&label_with_worker_did(DATA_HASH, WORKER1, 1));
240        all.extend_from_slice(&label_with_worker_did(DATA_HASH, WORKER2, 2));
241        let workers = labellers(&all, DATA_HASH);
242        assert_eq!(workers.len(), 2);
243        assert!(workers.contains(&WORKER1));
244        assert!(workers.contains(&WORKER2));
245    }
246}