Skip to main content

vela_protocol/
discord.rs

1//! Discord assignment and frontier support.
2//!
3//! Implements `docs/THEORY.md` Section 4 and Theorem 4
4//! (detector monotonicity implies frontier support monotonicity).
5//!
6//! Discord is the substrate's representation of where local
7//! scientific state fails to assemble into stable global
8//! knowledge. It comes in finitely many kinds. A frontier is the
9//! set of contexts where any discord kind fires.
10//!
11//! ## What this module ships
12//!
13//! - [`DiscordKind`]: the enum of discord kinds named in the theory
14//!   doc Section 4 (`Conflict`, `ConflictingConfidence`,
15//!   `MissingOverlap`, ...).
16//! - [`DiscordSet`]: a subset of `DiscordKind` (an element of the
17//!   lattice `L = P(K)`).
18//! - [`Detector`]: the trait that detector implementations satisfy.
19//!   Each detector checks whether a single discord kind fires at a
20//!   given context.
21//! - [`DiscordAssignment`]: the context-indexed map `D_A` that
22//!   records which discord kinds fire at each context.
23//! - [`FrontierSupport`]: the set of contexts where `D_A(c)` is
24//!   non-empty.
25//!
26//! ## What this module does NOT do
27//!
28//! Real detectors require the Atlas-as-presheaf primitive (target
29//! v0.8). This module ships the algebraic substrate and the
30//! Theorem 4 invariant test scaffolding. Domain-specific detector
31//! implementations that read live Atlas state come in subsequent
32//! cycles.
33//!
34//! For the purposes of Theorem 4, contexts are modeled as strings
35//! and the refinement relation is supplied externally as a
36//! [`ContextRefinement`] trait. This lets us test Theorem 4 over
37//! arbitrary refinement orders without committing to a specific
38//! context-category implementation.
39
40use std::collections::{BTreeMap, BTreeSet};
41
42use serde::{Deserialize, Serialize};
43
44/// A discord kind from `docs/THEORY.md` Section 4.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DiscordKind {
48    /// Polarity disagreement: support and refute both populated.
49    Conflict,
50    /// Confidence disagreement without polarity disagreement.
51    ConflictingConfidence,
52    /// Local sections without overlap evidence to relate them.
53    MissingOverlap,
54    /// Cross-context translation cannot be made consistent.
55    TranslationFail,
56    /// No evidence in scope for a context that requires it.
57    EvidenceGap,
58    /// A claimed result has not replicated under repeat conditions.
59    ReplicationFail,
60    /// Provenance polynomial is fragile under realistic retractions.
61    ProvenanceFragile,
62    /// Status diverges between Atlas projections that should agree.
63    StatusDivergent,
64    /// Methods differ in ways that prevent direct evidence
65    /// comparison.
66    MethodMismatch,
67}
68
69impl DiscordKind {
70    /// All discord kinds, in ordering.
71    pub const ALL: &'static [DiscordKind] = &[
72        DiscordKind::Conflict,
73        DiscordKind::ConflictingConfidence,
74        DiscordKind::MissingOverlap,
75        DiscordKind::TranslationFail,
76        DiscordKind::EvidenceGap,
77        DiscordKind::ReplicationFail,
78        DiscordKind::ProvenanceFragile,
79        DiscordKind::StatusDivergent,
80        DiscordKind::MethodMismatch,
81    ];
82
83    /// Substrate-display string form (snake_case).
84    #[must_use]
85    pub fn as_str(&self) -> &'static str {
86        match self {
87            Self::Conflict => "conflict",
88            Self::ConflictingConfidence => "conflicting_confidence",
89            Self::MissingOverlap => "missing_overlap",
90            Self::TranslationFail => "translation_fail",
91            Self::EvidenceGap => "evidence_gap",
92            Self::ReplicationFail => "replication_fail",
93            Self::ProvenanceFragile => "provenance_fragile",
94            Self::StatusDivergent => "status_divergent",
95            Self::MethodMismatch => "method_mismatch",
96        }
97    }
98}
99
100impl std::fmt::Display for DiscordKind {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{}", self.as_str())
103    }
104}
105
106/// A subset of `DiscordKind`. An element of the lattice `L = P(K)`.
107///
108/// Empty means agreement / no detected discord at this context.
109#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
110pub struct DiscordSet {
111    kinds: BTreeSet<DiscordKind>,
112}
113
114impl DiscordSet {
115    /// Empty set: no discord detected.
116    #[must_use]
117    pub fn empty() -> Self {
118        Self::default()
119    }
120
121    /// Single-kind discord set.
122    #[must_use]
123    pub fn singleton(kind: DiscordKind) -> Self {
124        let mut s = Self::default();
125        s.kinds.insert(kind);
126        s
127    }
128
129    /// Build from any iterable of kinds.
130    pub fn from_kinds(kinds: impl IntoIterator<Item = DiscordKind>) -> Self {
131        Self {
132            kinds: kinds.into_iter().collect(),
133        }
134    }
135
136    /// Whether this set is empty (no discord detected).
137    #[must_use]
138    pub fn is_empty(&self) -> bool {
139        self.kinds.is_empty()
140    }
141
142    /// Number of kinds in this set.
143    #[must_use]
144    pub fn len(&self) -> usize {
145        self.kinds.len()
146    }
147
148    /// Whether `kind` is in this set.
149    #[must_use]
150    pub fn contains(&self, kind: DiscordKind) -> bool {
151        self.kinds.contains(&kind)
152    }
153
154    /// Insert a kind. Returns `true` if newly inserted.
155    pub fn insert(&mut self, kind: DiscordKind) -> bool {
156        self.kinds.insert(kind)
157    }
158
159    /// Pointwise union (the lattice join).
160    pub fn join(&self, other: &Self) -> Self {
161        Self {
162            kinds: self.kinds.union(&other.kinds).copied().collect(),
163        }
164    }
165
166    /// Pointwise intersection (the lattice meet).
167    pub fn meet(&self, other: &Self) -> Self {
168        Self {
169            kinds: self.kinds.intersection(&other.kinds).copied().collect(),
170        }
171    }
172
173    /// Whether `self` is a subset of `other`.
174    #[must_use]
175    pub fn is_subset(&self, other: &Self) -> bool {
176        self.kinds.is_subset(&other.kinds)
177    }
178
179    /// Iterate kinds in canonical (sorted) order.
180    pub fn iter(&self) -> impl Iterator<Item = DiscordKind> + '_ {
181        self.kinds.iter().copied()
182    }
183}
184
185impl std::fmt::Display for DiscordSet {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        if self.kinds.is_empty() {
188            return write!(f, "{{}}");
189        }
190        write!(f, "{{")?;
191        let mut first = true;
192        for kind in &self.kinds {
193            if !first {
194                write!(f, ", ")?;
195            }
196            first = false;
197            write!(f, "{kind}")?;
198        }
199        write!(f, "}}")
200    }
201}
202
203/// Context identifier. Modeled as a string so this module does not
204/// commit to a specific Atlas context-category implementation.
205pub type ContextId = String;
206
207/// External refinement relation: which contexts refine which.
208///
209/// `refines(c_prime, c)` returns true iff `c_prime -> c` in the
210/// context category. The relation must be reflexive and transitive
211/// for Theorem 4 to apply.
212pub trait ContextRefinement {
213    /// Whether `c_prime` refines `c` (i.e., `c_prime -> c`).
214    fn refines(&self, c_prime: &str, c: &str) -> bool;
215}
216
217/// A detector for a single discord kind. Implementations check
218/// whether the kind fires at a given context.
219///
220/// **Monotonicity obligation.** A detector is *monotone* if
221///
222/// ```text
223/// c_prime -> c  and  fires(c_prime) = true  =>  fires(c) = true
224/// ```
225///
226/// Theorem 4 only applies if every detector in the registry is
227/// monotone. Detector authors must establish this property at
228/// design time. The substrate cannot prove monotonicity for
229/// arbitrary detector implementations; it can only assume the
230/// property and report frontier support accordingly.
231pub trait Detector {
232    /// The discord kind this detector reports.
233    fn kind(&self) -> DiscordKind;
234
235    /// Whether the kind fires at `context`.
236    fn fires(&self, context: &str) -> bool;
237}
238
239/// Discord assignment `D_A : C -> P(K)`.
240///
241/// In v0.83 this is a materialized map from context id to discord
242/// set. Production builds will compute this lazily from a registry
243/// of detectors against an Atlas; that wiring lands when the Atlas
244/// presheaf does (target v0.8).
245#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct DiscordAssignment {
247    by_context: BTreeMap<ContextId, DiscordSet>,
248}
249
250impl DiscordAssignment {
251    /// Empty assignment: no discord at any context.
252    #[must_use]
253    pub fn empty() -> Self {
254        Self::default()
255    }
256
257    /// Build from a registry of detectors evaluated over a finite
258    /// set of contexts.
259    ///
260    /// Each detector is invoked once per context. If `fires`
261    /// returns true, the corresponding kind is added to that
262    /// context's discord set.
263    pub fn build_from_detectors<D: Detector>(detectors: &[D], contexts: &[ContextId]) -> Self {
264        let mut by_context = BTreeMap::new();
265        for context in contexts {
266            let mut set = DiscordSet::default();
267            for detector in detectors {
268                if detector.fires(context) {
269                    set.insert(detector.kind());
270                }
271            }
272            by_context.insert(context.clone(), set);
273        }
274        Self { by_context }
275    }
276
277    /// Set the discord set at a single context.
278    pub fn set(&mut self, context: impl Into<ContextId>, kinds: DiscordSet) {
279        self.by_context.insert(context.into(), kinds);
280    }
281
282    /// Get the discord set at a context, or empty if absent.
283    pub fn get(&self, context: &str) -> DiscordSet {
284        self.by_context.get(context).cloned().unwrap_or_default()
285    }
286
287    /// Iterate `(context, discord_set)` pairs.
288    pub fn iter(&self) -> impl Iterator<Item = (&ContextId, &DiscordSet)> {
289        self.by_context.iter()
290    }
291
292    /// Frontier support: the set of contexts where any discord
293    /// kind fires.
294    pub fn frontier_support(&self) -> FrontierSupport {
295        let contexts = self
296            .by_context
297            .iter()
298            .filter(|(_, set)| !set.is_empty())
299            .map(|(c, _)| c.clone())
300            .collect();
301        FrontierSupport { contexts }
302    }
303
304    /// Number of contexts in this assignment.
305    pub fn context_count(&self) -> usize {
306        self.by_context.len()
307    }
308}
309
310/// Frontier support: the set of contexts where the discord
311/// assignment is non-empty.
312///
313/// Per Theorem 4, this set is upward closed under context
314/// refinement when every detector in the underlying registry is
315/// monotone.
316#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
317pub struct FrontierSupport {
318    contexts: BTreeSet<ContextId>,
319}
320
321impl FrontierSupport {
322    /// Whether `context` is in the support.
323    pub fn contains(&self, context: &str) -> bool {
324        self.contexts.contains(context)
325    }
326
327    /// Number of contexts.
328    pub fn len(&self) -> usize {
329        self.contexts.len()
330    }
331
332    /// Whether the support is empty.
333    #[must_use]
334    pub fn is_empty(&self) -> bool {
335        self.contexts.is_empty()
336    }
337
338    /// Iterate contexts in canonical order.
339    pub fn iter(&self) -> impl Iterator<Item = &ContextId> {
340        self.contexts.iter()
341    }
342
343    /// Whether this support is upward closed under the given
344    /// refinement relation.
345    ///
346    /// Returns `true` iff for every `c_prime` in the support and
347    /// every `c` in `universe` with `c_prime -> c`, `c` is also in
348    /// the support.
349    ///
350    /// This is the empirical Theorem 4 check: given a refinement
351    /// relation and the set of contexts the assignment was built
352    /// over, verify that the support set has the upward-closed
353    /// property the theorem predicts.
354    pub fn is_upward_closed<R: ContextRefinement>(
355        &self,
356        refinement: &R,
357        universe: &[ContextId],
358    ) -> bool {
359        for c_prime in &self.contexts {
360            for c in universe {
361                if c_prime != c && refinement.refines(c_prime, c) && !self.contexts.contains(c) {
362                    return false;
363                }
364            }
365        }
366        true
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    /// Mock context refinement: a poset given as a list of edges.
375    /// The transitive closure is computed at construction.
376    struct PosetRefinement {
377        // Map from context to all contexts it refines (transitively).
378        refines_map: BTreeMap<ContextId, BTreeSet<ContextId>>,
379    }
380
381    impl PosetRefinement {
382        fn new(direct_edges: &[(&str, &str)], universe: &[&str]) -> Self {
383            // direct_edges: (c_prime, c) means c_prime -> c (c_prime
384            // is a refinement of c).
385            let mut refines_map: BTreeMap<ContextId, BTreeSet<ContextId>> = BTreeMap::new();
386            // Reflexive
387            for c in universe {
388                refines_map
389                    .entry(c.to_string())
390                    .or_default()
391                    .insert(c.to_string());
392            }
393            // Direct edges
394            for (cp, c) in direct_edges {
395                refines_map
396                    .entry(cp.to_string())
397                    .or_default()
398                    .insert(c.to_string());
399            }
400            // Transitive closure (Floyd-Warshall style)
401            let all: Vec<ContextId> = universe.iter().map(|s| s.to_string()).collect();
402            for k in &all {
403                for i in &all {
404                    for j in &all {
405                        if refines_map.get(i).is_some_and(|s| s.contains(k))
406                            && refines_map.get(k).is_some_and(|s| s.contains(j))
407                        {
408                            refines_map.entry(i.clone()).or_default().insert(j.clone());
409                        }
410                    }
411                }
412            }
413            Self { refines_map }
414        }
415    }
416
417    impl ContextRefinement for PosetRefinement {
418        fn refines(&self, c_prime: &str, c: &str) -> bool {
419            self.refines_map
420                .get(c_prime)
421                .is_some_and(|set| set.contains(c))
422        }
423    }
424
425    /// A mock detector that fires on a fixed set of contexts.
426    struct FixedDetector {
427        kind: DiscordKind,
428        fires_on: BTreeSet<String>,
429    }
430
431    impl FixedDetector {
432        fn new(kind: DiscordKind, fires_on: &[&str]) -> Self {
433            Self {
434                kind,
435                fires_on: fires_on.iter().map(|s| (*s).to_string()).collect(),
436            }
437        }
438    }
439
440    impl Detector for FixedDetector {
441        fn kind(&self) -> DiscordKind {
442            self.kind
443        }
444        fn fires(&self, context: &str) -> bool {
445            self.fires_on.contains(context)
446        }
447    }
448
449    fn ctxs(names: &[&str]) -> Vec<ContextId> {
450        names.iter().map(|s| (*s).to_string()).collect()
451    }
452
453    #[test]
454    fn discord_kinds_round_trip_serde() {
455        for kind in DiscordKind::ALL {
456            let json = serde_json::to_string(kind).unwrap();
457            let back: DiscordKind = serde_json::from_str(&json).unwrap();
458            assert_eq!(*kind, back);
459        }
460    }
461
462    #[test]
463    fn discord_set_join_is_union() {
464        let a = DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]);
465        let b = DiscordSet::from_kinds([DiscordKind::EvidenceGap, DiscordKind::MethodMismatch]);
466        let joined = a.join(&b);
467        assert_eq!(joined.len(), 3);
468        assert!(joined.contains(DiscordKind::Conflict));
469        assert!(joined.contains(DiscordKind::EvidenceGap));
470        assert!(joined.contains(DiscordKind::MethodMismatch));
471    }
472
473    #[test]
474    fn discord_set_meet_is_intersection() {
475        let a = DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]);
476        let b = DiscordSet::from_kinds([DiscordKind::EvidenceGap, DiscordKind::MethodMismatch]);
477        let met = a.meet(&b);
478        assert_eq!(met.len(), 1);
479        assert!(met.contains(DiscordKind::EvidenceGap));
480    }
481
482    #[test]
483    fn empty_assignment_has_empty_support() {
484        let assignment = DiscordAssignment::empty();
485        assert!(assignment.frontier_support().is_empty());
486    }
487
488    #[test]
489    fn theorem_4_monotone_detector_yields_upward_closed_support() {
490        // Universe: c1 -> c2 -> c3.
491        // c1 is the most refined, c3 is the broadest.
492        let universe = ctxs(&["c1", "c2", "c3"]);
493        let refinement = PosetRefinement::new(&[("c1", "c2"), ("c2", "c3")], &["c1", "c2", "c3"]);
494
495        // A monotone detector that fires at c1 must fire at c2 and
496        // c3 (upward propagation). Build that detector explicitly.
497        let monotone_conflict = FixedDetector::new(DiscordKind::Conflict, &["c1", "c2", "c3"]);
498        let assignment = DiscordAssignment::build_from_detectors(&[monotone_conflict], &universe);
499
500        let support = assignment.frontier_support();
501        assert!(support.is_upward_closed(&refinement, &universe));
502        assert!(support.contains("c1"));
503        assert!(support.contains("c2"));
504        assert!(support.contains("c3"));
505    }
506
507    #[test]
508    fn theorem_4_violation_when_detector_is_not_monotone() {
509        // A non-monotone detector fires only at the refined context
510        // c1 but not at the broader c2 or c3. This violates the
511        // detector-design obligation, so frontier support is NOT
512        // upward closed.
513        let universe = ctxs(&["c1", "c2", "c3"]);
514        let refinement = PosetRefinement::new(&[("c1", "c2"), ("c2", "c3")], &["c1", "c2", "c3"]);
515
516        let buggy = FixedDetector::new(DiscordKind::Conflict, &["c1"]);
517        let assignment = DiscordAssignment::build_from_detectors(&[buggy], &universe);
518        let support = assignment.frontier_support();
519
520        // The support is {c1}; it does NOT contain c2 or c3 even
521        // though c1 -> c2 and c1 -> c3. Theorem 4 requires monotone
522        // detectors; this one fails the obligation, so the
523        // theorem's conclusion does not hold.
524        assert!(!support.is_upward_closed(&refinement, &universe));
525        assert!(support.contains("c1"));
526        assert!(!support.contains("c2"));
527        assert!(!support.contains("c3"));
528    }
529
530    #[test]
531    fn theorem_4_holds_for_pointwise_union_of_monotone_detectors() {
532        // Two monotone detectors. Their pointwise union should
533        // produce an upward-closed support set.
534        let universe = ctxs(&["c1", "c2", "c3", "c4"]);
535        let refinement = PosetRefinement::new(
536            &[("c1", "c2"), ("c2", "c4"), ("c3", "c4")],
537            &["c1", "c2", "c3", "c4"],
538        );
539        // Monotone: fires at c1, c2, c4 (consistent with c1->c2->c4).
540        let det1 = FixedDetector::new(DiscordKind::Conflict, &["c1", "c2", "c4"]);
541        // Monotone: fires at c3, c4 (consistent with c3->c4).
542        let det2 = FixedDetector::new(DiscordKind::EvidenceGap, &["c3", "c4"]);
543
544        let assignment = DiscordAssignment::build_from_detectors(&[det1, det2], &universe);
545        let support = assignment.frontier_support();
546        assert!(support.is_upward_closed(&refinement, &universe));
547        assert_eq!(support.len(), 4);
548    }
549
550    #[test]
551    fn frontier_support_contains_only_nonempty_contexts() {
552        let universe = ctxs(&["c1", "c2", "c3"]);
553        let det = FixedDetector::new(DiscordKind::Conflict, &["c1", "c3"]);
554        let assignment = DiscordAssignment::build_from_detectors(&[det], &universe);
555        let support = assignment.frontier_support();
556        assert!(support.contains("c1"));
557        assert!(!support.contains("c2"));
558        assert!(support.contains("c3"));
559        assert_eq!(support.len(), 2);
560    }
561
562    #[test]
563    fn manual_assignment_set_and_get() {
564        let mut a = DiscordAssignment::empty();
565        a.set("c1", DiscordSet::singleton(DiscordKind::ReplicationFail));
566        assert_eq!(a.get("c1").len(), 1);
567        assert!(a.get("c1").contains(DiscordKind::ReplicationFail));
568        assert!(a.get("c2").is_empty());
569    }
570
571    #[test]
572    fn poset_refinement_is_reflexive() {
573        let r = PosetRefinement::new(&[("c1", "c2")], &["c1", "c2"]);
574        assert!(r.refines("c1", "c1"));
575        assert!(r.refines("c2", "c2"));
576    }
577
578    #[test]
579    fn poset_refinement_is_transitive() {
580        let r = PosetRefinement::new(&[("a", "b"), ("b", "c")], &["a", "b", "c"]);
581        assert!(r.refines("a", "b"));
582        assert!(r.refines("b", "c"));
583        assert!(r.refines("a", "c"));
584    }
585
586    #[test]
587    fn discord_kind_display() {
588        assert_eq!(DiscordKind::Conflict.to_string(), "conflict");
589        assert_eq!(
590            DiscordKind::ConflictingConfidence.to_string(),
591            "conflicting_confidence"
592        );
593        assert_eq!(DiscordKind::MethodMismatch.to_string(), "method_mismatch");
594    }
595
596    #[test]
597    fn assignment_serde_round_trip() {
598        let mut a = DiscordAssignment::empty();
599        a.set(
600            "c1",
601            DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]),
602        );
603        let json = serde_json::to_string(&a).unwrap();
604        let back: DiscordAssignment = serde_json::from_str(&json).unwrap();
605        assert_eq!(back, a);
606    }
607}