Skip to main content

vela_protocol/
status_provenance.rs

1//! Belnap contextual status derived from support/refute provenance.
2//!
3//! Implements `docs/THEORY.md` Section 7 and Theorem 3
4//! (status-provenance soundness).
5//!
6//! For each claim-context pair `(q, c)`, the substrate maintains
7//! two provenance polynomials:
8//!
9//! - `support`: polynomial of derivations that support the claim.
10//! - `refute`: polynomial of derivations that refute the claim.
11//!
12//! Belnap status is derived from non-empty support:
13//!
14//! ```text
15//! T  if  supp(support) is nonempty  and  supp(refute) is empty
16//! F  if  supp(support) is empty     and  supp(refute) is nonempty
17//! B  if  supp(support) is nonempty  and  supp(refute) is nonempty
18//! N  if  supp(support) is empty     and  supp(refute) is empty
19//! ```
20//!
21//! Status is not truth. It is substrate-visible evidence polarity
22//! under a review policy. Review policy decides which evidence is
23//! admitted into `support` and `refute`. The substrate then
24//! propagates consequences.
25//!
26//! ## Theorem 3 invariant
27//!
28//! If `status == T` and a retraction removes every monomial in
29//! `support`, then after deterministic recomputation the status
30//! cannot remain `T`. It becomes `N`, `F`, or another non-`T`
31//! state under policy. This is "no zombie findings."
32//!
33//! This module enforces the invariant by deriving status from
34//! support sets at every read. Status is never persisted
35//! independently of the polynomials that justify it.
36
37use std::collections::BTreeSet;
38
39use serde::{Deserialize, Serialize};
40
41use crate::provenance_poly::ProvenancePoly;
42
43/// Belnap four-valued status.
44///
45/// Status records evidence polarity. It is orthogonal to Bayesian
46/// confidence (the strength of that evidence), per
47/// `docs/THEORY.md` Section 2.1 and counterexample 11.4.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum BelnapStatus {
51    /// Neither supported nor refuted.
52    None,
53    /// Supported and not refuted.
54    True,
55    /// Refuted and not supported.
56    False,
57    /// Both supported and refuted.
58    Both,
59}
60
61impl BelnapStatus {
62    /// One-letter substrate-display form: N, T, F, B.
63    #[must_use]
64    pub fn letter(&self) -> char {
65        match self {
66            Self::None => 'N',
67            Self::True => 'T',
68            Self::False => 'F',
69            Self::Both => 'B',
70        }
71    }
72
73    /// Whether this status admits at least one supporting derivation.
74    #[must_use]
75    pub fn has_support(&self) -> bool {
76        matches!(self, Self::True | Self::Both)
77    }
78
79    /// Whether this status admits at least one refuting derivation.
80    #[must_use]
81    pub fn has_refute(&self) -> bool {
82        matches!(self, Self::False | Self::Both)
83    }
84}
85
86impl std::fmt::Display for BelnapStatus {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}", self.letter())
89    }
90}
91
92/// Support and refute provenance polynomials for a single
93/// claim-context pair.
94///
95/// The status field is *derived*, not persisted. Reading
96/// `derive_status()` computes the Belnap status from the current
97/// support sets, which guarantees Theorem 3 by construction:
98/// status cannot drift out of sync with the polynomials.
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100pub struct StatusProvenance {
101    /// Polynomial of supporting derivations (`pi_T(q, c)` in the
102    /// theory doc).
103    #[serde(default)]
104    pub support: ProvenancePoly,
105    /// Polynomial of refuting derivations (`pi_F(q, c)` in the
106    /// theory doc).
107    #[serde(default)]
108    pub refute: ProvenancePoly,
109}
110
111impl StatusProvenance {
112    /// Empty: no supporting or refuting derivations recorded.
113    /// Derives to `BelnapStatus::None`.
114    #[must_use]
115    pub fn empty() -> Self {
116        Self::default()
117    }
118
119    /// Build with given support and refute polynomials.
120    #[must_use]
121    pub fn new(support: ProvenancePoly, refute: ProvenancePoly) -> Self {
122        Self { support, refute }
123    }
124
125    /// Add a supporting derivation polynomial.
126    pub fn add_support(&mut self, derivation: &ProvenancePoly) {
127        self.support += derivation;
128    }
129
130    /// Add a refuting derivation polynomial.
131    pub fn add_refute(&mut self, derivation: &ProvenancePoly) {
132        self.refute += derivation;
133    }
134
135    /// Derive the Belnap status from the current support sets.
136    ///
137    /// This is the substrate status rule from
138    /// `docs/THEORY.md` Section 7. Status is a function of the
139    /// polynomials, not an independently-stored field, so Theorem 3
140    /// holds by construction.
141    pub fn derive_status(&self) -> BelnapStatus {
142        let has_support = !self.support.is_zero();
143        let has_refute = !self.refute.is_zero();
144        match (has_support, has_refute) {
145            (false, false) => BelnapStatus::None,
146            (true, false) => BelnapStatus::True,
147            (false, true) => BelnapStatus::False,
148            (true, true) => BelnapStatus::Both,
149        }
150    }
151
152    /// Retract a set of source/event identifiers from both
153    /// support and refute polynomials.
154    ///
155    /// Operationally: any derivation path involving a retracted
156    /// source is dropped. The remaining polynomials may then yield
157    /// a different Belnap status under `derive_status()`.
158    pub fn retract<S: AsRef<str>>(&self, retracted: &BTreeSet<S>) -> Self {
159        Self {
160            support: self.support.retract(retracted),
161            refute: self.refute.retract(retracted),
162        }
163    }
164
165    /// Whether the support set contains the given variable.
166    pub fn support_contains(&self, var: &str) -> bool {
167        self.support.support().contains(var)
168    }
169
170    /// Whether the refute set contains the given variable.
171    pub fn refute_contains(&self, var: &str) -> bool {
172        self.refute.support().contains(var)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn vars(names: &[&str]) -> BTreeSet<String> {
181        names.iter().map(|s| (*s).to_string()).collect()
182    }
183
184    #[test]
185    fn empty_derives_to_none() {
186        assert_eq!(
187            StatusProvenance::empty().derive_status(),
188            BelnapStatus::None
189        );
190    }
191
192    #[test]
193    fn support_only_derives_to_t() {
194        let sp = StatusProvenance::new(ProvenancePoly::singleton("p1"), ProvenancePoly::zero());
195        assert_eq!(sp.derive_status(), BelnapStatus::True);
196    }
197
198    #[test]
199    fn refute_only_derives_to_f() {
200        let sp = StatusProvenance::new(ProvenancePoly::zero(), ProvenancePoly::singleton("r1"));
201        assert_eq!(sp.derive_status(), BelnapStatus::False);
202    }
203
204    #[test]
205    fn both_derives_to_b() {
206        let sp = StatusProvenance::new(
207            ProvenancePoly::singleton("p1"),
208            ProvenancePoly::singleton("r1"),
209        );
210        assert_eq!(sp.derive_status(), BelnapStatus::Both);
211    }
212
213    #[test]
214    fn theorem_3_t_with_full_retract_cannot_stay_t() {
215        // sigma(q, c) = T because support has {p1*d3, r7} and refute is empty.
216        let support = &(&ProvenancePoly::singleton("p1") * &ProvenancePoly::singleton("d3"))
217            + &ProvenancePoly::singleton("r7");
218        let sp = StatusProvenance::new(support, ProvenancePoly::zero());
219        assert_eq!(sp.derive_status(), BelnapStatus::True);
220
221        // Retract every variable in support: {p1, d3, r7}.
222        let retracted = sp.retract(&vars(&["d3", "p1", "r7"]));
223        // Theorem 3: status is no longer T.
224        assert_ne!(retracted.derive_status(), BelnapStatus::True);
225        // No refute, so it is N.
226        assert_eq!(retracted.derive_status(), BelnapStatus::None);
227    }
228
229    #[test]
230    fn theorem_3_t_with_partial_retract_keeps_t_if_alternate_path() {
231        // Two derivation paths support the claim; only one is retracted.
232        let support = &(&ProvenancePoly::singleton("p1") * &ProvenancePoly::singleton("d3"))
233            + &(&ProvenancePoly::singleton("r7") * &ProvenancePoly::singleton("e2"));
234        let sp = StatusProvenance::new(support, ProvenancePoly::zero());
235        assert_eq!(sp.derive_status(), BelnapStatus::True);
236
237        // Retract p1: the p1*d3 monomial drops; r7*e2 remains.
238        let retracted = sp.retract(&vars(&["p1"]));
239        assert_eq!(retracted.derive_status(), BelnapStatus::True);
240        assert_eq!(retracted.support.term_count(), 1);
241    }
242
243    #[test]
244    fn theorem_3_t_to_f_when_refute_remains() {
245        // sigma starts at T (only supporting path).
246        let mut sp = StatusProvenance::new(ProvenancePoly::singleton("p1"), ProvenancePoly::zero());
247        assert_eq!(sp.derive_status(), BelnapStatus::True);
248        // Add a refuting derivation. Status becomes B.
249        sp.add_refute(&ProvenancePoly::singleton("r1"));
250        assert_eq!(sp.derive_status(), BelnapStatus::Both);
251        // Retract p1: support empty, refute remains. Status: F.
252        let retracted = sp.retract(&vars(&["p1"]));
253        assert_eq!(retracted.derive_status(), BelnapStatus::False);
254    }
255
256    #[test]
257    fn b_to_n_when_both_polynomials_retracted_to_zero() {
258        let sp = StatusProvenance::new(
259            ProvenancePoly::singleton("p1"),
260            ProvenancePoly::singleton("r1"),
261        );
262        assert_eq!(sp.derive_status(), BelnapStatus::Both);
263        let retracted = sp.retract(&vars(&["p1", "r1"]));
264        assert_eq!(retracted.derive_status(), BelnapStatus::None);
265    }
266
267    #[test]
268    fn add_support_accumulates() {
269        let mut sp = StatusProvenance::empty();
270        sp.add_support(&ProvenancePoly::singleton("p1"));
271        sp.add_support(&ProvenancePoly::singleton("d3"));
272        // Both terms recorded; status is T.
273        assert_eq!(sp.derive_status(), BelnapStatus::True);
274        assert_eq!(sp.support.term_count(), 2);
275    }
276
277    #[test]
278    fn add_refute_accumulates() {
279        let mut sp = StatusProvenance::empty();
280        sp.add_refute(&ProvenancePoly::singleton("r1"));
281        sp.add_refute(&ProvenancePoly::singleton("r2"));
282        assert_eq!(sp.derive_status(), BelnapStatus::False);
283        assert_eq!(sp.refute.term_count(), 2);
284    }
285
286    #[test]
287    fn belnap_status_predicates() {
288        assert!(BelnapStatus::True.has_support());
289        assert!(BelnapStatus::Both.has_support());
290        assert!(!BelnapStatus::False.has_support());
291        assert!(!BelnapStatus::None.has_support());
292
293        assert!(BelnapStatus::False.has_refute());
294        assert!(BelnapStatus::Both.has_refute());
295        assert!(!BelnapStatus::True.has_refute());
296        assert!(!BelnapStatus::None.has_refute());
297    }
298
299    #[test]
300    fn belnap_status_letters() {
301        assert_eq!(BelnapStatus::None.letter(), 'N');
302        assert_eq!(BelnapStatus::True.letter(), 'T');
303        assert_eq!(BelnapStatus::False.letter(), 'F');
304        assert_eq!(BelnapStatus::Both.letter(), 'B');
305    }
306
307    #[test]
308    fn serde_round_trip() {
309        let sp = StatusProvenance::new(
310            ProvenancePoly::singleton("p1"),
311            ProvenancePoly::singleton("r1"),
312        );
313        let json = serde_json::to_string(&sp).expect("serialize");
314        let restored: StatusProvenance = serde_json::from_str(&json).expect("deserialize");
315        assert_eq!(restored, sp);
316        assert_eq!(restored.derive_status(), BelnapStatus::Both);
317    }
318
319    #[test]
320    fn status_is_pure_function_of_polynomials() {
321        // Theorem 3 holds by construction: status is derived,
322        // never persisted independently. Any two StatusProvenance
323        // instances with equal support and refute polynomials yield
324        // the same status.
325        let sp1 = StatusProvenance::new(ProvenancePoly::singleton("p1"), ProvenancePoly::zero());
326        let sp2 = StatusProvenance::new(ProvenancePoly::singleton("p1"), ProvenancePoly::zero());
327        assert_eq!(sp1.derive_status(), sp2.derive_status());
328    }
329
330    #[test]
331    fn retract_does_not_invent_support() {
332        // Theorem 2 + Theorem 3 composition: retraction never adds
333        // derivations, so it cannot move N or F into T or B.
334        let sp = StatusProvenance::new(ProvenancePoly::zero(), ProvenancePoly::singleton("r1"));
335        assert_eq!(sp.derive_status(), BelnapStatus::False);
336        let retracted = sp.retract(&vars(&["r1"]));
337        // Cannot become T from F by retraction alone.
338        assert_ne!(retracted.derive_status(), BelnapStatus::True);
339        assert_eq!(retracted.derive_status(), BelnapStatus::None);
340    }
341}