Skip to main content

vela_protocol/
provenance_compute.rs

1//! Compute provenance polynomials and Belnap status from an
2//! event log.
3//!
4//! Bridges the algebraic primitives in
5//! [`crate::provenance_poly`] and [`crate::status_provenance`] to
6//! the running substrate's `StateEvent` log. Implements the
7//! computational side of `docs/THEORY.md` Section 7
8//! ("status derivation from provenance") in read-only form: this
9//! module does not mutate any on-disk state, it only computes a
10//! derived view from existing events.
11//!
12//! ## Mapping
13//!
14//! For a target claim-context pair (in v0.84 we identify this with
15//! a finding id; per-context attribution lands when the formal
16//! context category does, target v0.8), each event contributes to
17//! either the supporting or refuting polynomial:
18//!
19//! | Event kind          | Status payload         | Effect                        |
20//! |---------------------|------------------------|-------------------------------|
21//! | `finding.asserted`  | n/a                    | support += singleton(event_id) |
22//! | `finding.reviewed`  | accepted               | support += singleton(event_id) |
23//! | `finding.reviewed`  | needs_revision         | support += singleton(event_id) |
24//! | `finding.reviewed`  | contested              | refute  += singleton(event_id) |
25//! | `finding.reviewed`  | rejected               | refute  += singleton(event_id) |
26//! | `finding.rejected`  | n/a                    | refute  += singleton(event_id) |
27//! | `finding.retracted` | n/a                    | retract event-id from both     |
28//!
29//! The retraction case interprets `finding.retracted` as: the
30//! finding-level retraction event removes the finding's previous
31//! supporting derivations from scope. In the polynomial layer
32//! this is the homomorphism `rho` over the prior support set.
33//!
34//! ## Scope
35//!
36//! - This module only handles finding-level events. Replication
37//!   and prediction events are not yet mapped; that mapping rides
38//!   on a richer Carina-payload type system (target v0.85+).
39//! - The result is purely a function of the events passed in.
40//!   Callers control which events are in scope; this lets the
41//!   substrate compute the polynomial under different review
42//!   policies without baking policy into the algebra.
43
44use serde_json::Value;
45
46use crate::provenance_poly::ProvenancePoly;
47use crate::status_provenance::{BelnapStatus, StatusProvenance};
48
49/// A minimal projection of a [`crate::events::StateEvent`] needed
50/// for provenance computation.
51///
52/// Decoupled from the concrete `StateEvent` type so this module
53/// can be used in tests, against synthetic event logs, and across
54/// future event-shape changes without rippling.
55#[derive(Debug, Clone)]
56pub struct ProvenanceEventRef<'a> {
57    /// Content-addressed event id (`vev_*`). Becomes a variable in
58    /// the polynomial.
59    pub id: &'a str,
60    /// Event kind string (e.g. `finding.asserted`).
61    pub kind: &'a str,
62    /// Target finding id (`vf_*`). Filters events to a single
63    /// claim-context pair.
64    pub finding_id: &'a str,
65    /// Event payload, used to read review status fields.
66    pub payload: &'a Value,
67}
68
69/// Compute the support/refute provenance polynomials for a single
70/// finding from a sequence of events targeting that finding.
71///
72/// The caller is responsible for filtering events to the right
73/// finding id; this function does not re-filter. The returned
74/// [`StatusProvenance`] yields a [`BelnapStatus`] under
75/// `derive_status()` per `docs/THEORY.md` Section 7.
76pub fn compute_status_provenance<'a, I>(events: I) -> StatusProvenance
77where
78    I: IntoIterator<Item = ProvenanceEventRef<'a>>,
79{
80    let mut sp = StatusProvenance::empty();
81
82    // Track retractions in canonical order so we can apply them
83    // after the polynomials have been built. A retraction event
84    // removes its target finding's prior derivations from scope by
85    // mapping the prior event ids to zero. We model this by
86    // collecting the set of pre-retraction event ids as the
87    // retraction target.
88    let mut prior_event_ids: Vec<String> = Vec::new();
89    let mut retract_pending: bool = false;
90
91    for ev in events {
92        let kind = ev.kind;
93        let event_id = ev.id;
94
95        match kind {
96            "finding.asserted" => {
97                sp.add_support(&ProvenancePoly::singleton(event_id));
98                prior_event_ids.push(event_id.to_string());
99            }
100            "finding.reviewed" => {
101                let status = ev
102                    .payload
103                    .get("status")
104                    .and_then(Value::as_str)
105                    .unwrap_or("");
106                match status {
107                    "accepted" | "needs_revision" => {
108                        sp.add_support(&ProvenancePoly::singleton(event_id));
109                    }
110                    "contested" | "rejected" => {
111                        sp.add_refute(&ProvenancePoly::singleton(event_id));
112                    }
113                    _ => {
114                        // Unknown status string: do nothing rather
115                        // than fabricate polarity.
116                    }
117                }
118                prior_event_ids.push(event_id.to_string());
119            }
120            "finding.rejected" => {
121                sp.add_refute(&ProvenancePoly::singleton(event_id));
122                prior_event_ids.push(event_id.to_string());
123            }
124            "finding.retracted" => {
125                // Apply at the end so prior events are accounted
126                // for first.
127                retract_pending = true;
128            }
129            _ => {
130                // Other event kinds (entity_added, span_repaired,
131                // etc.) do not change support polarity in v0.84.
132                // They may be wired in later cycles via Carina
133                // payload typing.
134            }
135        }
136    }
137
138    if retract_pending {
139        let retracted: std::collections::BTreeSet<String> = prior_event_ids.into_iter().collect();
140        sp = sp.retract(&retracted);
141    }
142
143    sp
144}
145
146/// Compute the [`BelnapStatus`] of a finding directly from its
147/// event stream. This is the substrate's status rule per
148/// `docs/THEORY.md` Section 7, applied to the live event log.
149pub fn compute_belnap_status<'a, I>(events: I) -> BelnapStatus
150where
151    I: IntoIterator<Item = ProvenanceEventRef<'a>>,
152{
153    compute_status_provenance(events).derive_status()
154}
155
156/// Convenience helper: compute the [`StatusProvenance`] for a
157/// single finding from the live `StateEvent` log of a `Project`.
158///
159/// Filters events to those targeting `finding_id` and projects
160/// each into a [`ProvenanceEventRef`] before delegating to
161/// [`compute_status_provenance`].
162///
163/// This is the bridge layer the Workbench API uses to surface
164/// derived BelnapStatus alongside each finding without changing
165/// any on-disk state. The status field on the finding remains
166/// authoritative; the Belnap value is a computed view.
167pub fn status_provenance_for_finding(
168    project: &crate::project::Project,
169    finding_id: &str,
170) -> StatusProvenance {
171    let refs: Vec<ProvenanceEventRef<'_>> = project
172        .events
173        .iter()
174        .filter(|e| e.target.id == finding_id && e.target.r#type == "finding")
175        .map(|e| ProvenanceEventRef {
176            id: &e.id,
177            kind: &e.kind,
178            finding_id: &e.target.id,
179            payload: &e.payload,
180        })
181        .collect();
182    compute_status_provenance(refs)
183}
184
185/// Convenience helper: compute the [`BelnapStatus`] of a finding
186/// in a `Project` directly. Equivalent to
187/// `status_provenance_for_finding(project, id).derive_status()`.
188pub fn belnap_status_for_finding(
189    project: &crate::project::Project,
190    finding_id: &str,
191) -> BelnapStatus {
192    status_provenance_for_finding(project, finding_id).derive_status()
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use serde_json::json;
199
200    fn ev<'a>(
201        id: &'a str,
202        kind: &'a str,
203        finding_id: &'a str,
204        payload: &'a Value,
205    ) -> ProvenanceEventRef<'a> {
206        ProvenanceEventRef {
207            id,
208            kind,
209            finding_id,
210            payload,
211        }
212    }
213
214    #[test]
215    fn empty_event_log_yields_n() {
216        let events: Vec<ProvenanceEventRef> = vec![];
217        assert_eq!(compute_belnap_status(events), BelnapStatus::None);
218    }
219
220    #[test]
221    fn finding_asserted_yields_t() {
222        let null = json!(null);
223        let events = vec![ev("vev_001", "finding.asserted", "vf_x", &null)];
224        assert_eq!(compute_belnap_status(events), BelnapStatus::True);
225    }
226
227    #[test]
228    fn accepted_review_keeps_t() {
229        let null = json!(null);
230        let accepted = json!({"status": "accepted"});
231        let events = vec![
232            ev("vev_001", "finding.asserted", "vf_x", &null),
233            ev("vev_002", "finding.reviewed", "vf_x", &accepted),
234        ];
235        let sp = compute_status_provenance(events);
236        assert_eq!(sp.derive_status(), BelnapStatus::True);
237        assert_eq!(sp.support.term_count(), 2);
238        assert!(sp.refute.is_zero());
239    }
240
241    #[test]
242    fn contested_review_promotes_to_b() {
243        let null = json!(null);
244        let contested = json!({"status": "contested"});
245        let events = vec![
246            ev("vev_001", "finding.asserted", "vf_x", &null),
247            ev("vev_002", "finding.reviewed", "vf_x", &contested),
248        ];
249        assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
250    }
251
252    #[test]
253    fn rejected_review_promotes_to_b() {
254        let null = json!(null);
255        let rejected = json!({"status": "rejected"});
256        let events = vec![
257            ev("vev_001", "finding.asserted", "vf_x", &null),
258            ev("vev_002", "finding.reviewed", "vf_x", &rejected),
259        ];
260        assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
261    }
262
263    #[test]
264    fn finding_rejected_event_adds_refute() {
265        let null = json!(null);
266        let events = vec![
267            ev("vev_001", "finding.asserted", "vf_x", &null),
268            ev("vev_002", "finding.rejected", "vf_x", &null),
269        ];
270        assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
271    }
272
273    #[test]
274    fn retraction_drops_all_prior_support_to_n() {
275        let null = json!(null);
276        let events = vec![
277            ev("vev_001", "finding.asserted", "vf_x", &null),
278            ev("vev_002", "finding.retracted", "vf_x", &null),
279        ];
280        // After retraction, no support derivations remain.
281        // No refutation either, so status falls to N.
282        assert_eq!(compute_belnap_status(events), BelnapStatus::None);
283    }
284
285    #[test]
286    fn retraction_drops_refute_too() {
287        // Theorem-2-aware: retraction is a homomorphism over both
288        // polynomials. If the only refute came from a now-retracted
289        // event, refute also empties.
290        let null = json!(null);
291        let rejected = json!({"status": "rejected"});
292        let events = vec![
293            ev("vev_001", "finding.asserted", "vf_x", &null),
294            ev("vev_002", "finding.reviewed", "vf_x", &rejected),
295            ev("vev_003", "finding.retracted", "vf_x", &null),
296        ];
297        // All three event ids are retracted. Both polynomials empty.
298        assert_eq!(compute_belnap_status(events), BelnapStatus::None);
299    }
300
301    #[test]
302    fn needs_revision_keeps_t_not_b() {
303        let null = json!(null);
304        let nr = json!({"status": "needs_revision"});
305        let events = vec![
306            ev("vev_001", "finding.asserted", "vf_x", &null),
307            ev("vev_002", "finding.reviewed", "vf_x", &nr),
308        ];
309        // needs_revision is a flag, not a polarity flip.
310        assert_eq!(compute_belnap_status(events), BelnapStatus::True);
311    }
312
313    #[test]
314    fn unknown_review_status_is_ignored() {
315        let null = json!(null);
316        let weird = json!({"status": "potato"});
317        let events = vec![
318            ev("vev_001", "finding.asserted", "vf_x", &null),
319            ev("vev_002", "finding.reviewed", "vf_x", &weird),
320        ];
321        // The asserted event keeps support; the review with an
322        // unknown status string contributes nothing rather than
323        // fabricating polarity.
324        let sp = compute_status_provenance(events);
325        assert_eq!(sp.derive_status(), BelnapStatus::True);
326        assert_eq!(sp.support.term_count(), 1);
327    }
328
329    #[test]
330    fn support_polynomial_records_all_supporting_event_ids() {
331        let null = json!(null);
332        let accepted = json!({"status": "accepted"});
333        let events = vec![
334            ev("vev_001", "finding.asserted", "vf_x", &null),
335            ev("vev_002", "finding.reviewed", "vf_x", &accepted),
336            ev("vev_003", "finding.reviewed", "vf_x", &accepted),
337        ];
338        let sp = compute_status_provenance(events);
339        assert_eq!(sp.support.term_count(), 3);
340        let support_vars = sp.support.support();
341        assert!(support_vars.contains("vev_001"));
342        assert!(support_vars.contains("vev_002"));
343        assert!(support_vars.contains("vev_003"));
344    }
345
346    /// Build a synthetic Project with the given events targeting
347    /// the given finding id. Used to test the Project-level
348    /// helpers that bridge to the live event log.
349    fn synthetic_project(
350        _finding_id: &str,
351        events: Vec<crate::events::StateEvent>,
352    ) -> crate::project::Project {
353        // Use the canonical factory (assemble) with no findings and
354        // then override events. The genesis event is preserved
355        // because the factory emits one; we filter it out of our
356        // tests by targeting unrelated finding ids.
357        let mut p = crate::project::assemble("test-frontier", vec![], 0, 0, "test");
358        // Drop the genesis event; tests want a clean event list.
359        p.events.clear();
360        p.events = events;
361        p
362    }
363
364    fn synthetic_event(
365        id: &str,
366        kind: &str,
367        finding_id: &str,
368        status: Option<&str>,
369    ) -> crate::events::StateEvent {
370        use crate::events::{StateActor, StateEvent, StateTarget};
371        let payload = match status {
372            Some(s) => json!({"status": s}),
373            None => json!(null),
374        };
375        StateEvent {
376            schema: "vela.event.v0.1".into(),
377            id: id.to_string(),
378            kind: kind.to_string(),
379            target: StateTarget {
380                r#type: "finding".into(),
381                id: finding_id.to_string(),
382            },
383            actor: StateActor {
384                id: "reviewer:test".into(),
385                r#type: "human".into(),
386            },
387            timestamp: "2026-05-09T00:00:00Z".into(),
388            reason: "test".into(),
389            before_hash: String::new(),
390            after_hash: String::new(),
391            payload,
392            caveats: vec![],
393            signature: None,
394            schema_artifact_id: None,
395        }
396    }
397
398    #[test]
399    fn project_level_helper_filters_by_finding_id() {
400        // Two events: one targets vf_x, one targets vf_y.
401        // The helper should only consider events targeting vf_x.
402        let events = vec![
403            synthetic_event("vev_001", "finding.asserted", "vf_x", None),
404            synthetic_event("vev_002", "finding.asserted", "vf_y", None),
405        ];
406        let p = synthetic_project("vf_x", events);
407        let sp = status_provenance_for_finding(&p, "vf_x");
408        assert_eq!(sp.derive_status(), BelnapStatus::True);
409        // Only one event contributed — the vf_x one.
410        assert_eq!(sp.support.term_count(), 1);
411        assert!(sp.support.support().contains("vev_001"));
412        assert!(!sp.support.support().contains("vev_002"));
413    }
414
415    #[test]
416    fn project_level_helper_handles_full_chain() {
417        // asserted -> reviewed(contested) on vf_x
418        let events = vec![
419            synthetic_event("vev_001", "finding.asserted", "vf_x", None),
420            synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("contested")),
421        ];
422        let p = synthetic_project("vf_x", events);
423        let belnap = belnap_status_for_finding(&p, "vf_x");
424        assert_eq!(belnap, BelnapStatus::Both);
425    }
426
427    #[test]
428    fn project_level_helper_with_no_events_yields_n() {
429        let p = synthetic_project("vf_x", vec![]);
430        assert_eq!(belnap_status_for_finding(&p, "vf_x"), BelnapStatus::None);
431    }
432
433    #[test]
434    fn unrelated_event_kinds_do_not_affect_status() {
435        let null = json!(null);
436        let events = vec![
437            ev("vev_001", "finding.asserted", "vf_x", &null),
438            ev("vev_002", "finding.entity_added", "vf_x", &null),
439            ev("vev_003", "finding.span_repaired", "vf_x", &null),
440        ];
441        let sp = compute_status_provenance(events);
442        assert_eq!(sp.derive_status(), BelnapStatus::True);
443        assert_eq!(sp.support.term_count(), 1);
444    }
445}