Skip to main content

tatara_lattice/
lib.rs

1//! Lattice algebra over `tatara_process::classification`.
2//!
3//! Replaces `convergence-controller::qualities_match` and the scattered
4//! compliance-baseline comparators with a single `Lattice` trait.
5//!
6//! Laws (proven by `proptest` in tests):
7//!
8//! - idempotent:   `a ⊓ a = a`, `a ⊔ a = a`
9//! - commutative:  `a ⊓ b = b ⊓ a`, `a ⊔ b = b ⊔ a`
10//! - associative:  `a ⊓ (b ⊓ c) = (a ⊓ b) ⊓ c`, similarly for ⊔
11//! - absorption:   `a ⊓ (a ⊔ b) = a`, `a ⊔ (a ⊓ b) = a`
12//! - leq agrees:   `a ≤ b ⇔ a ⊓ b = a ⇔ a ⊔ b = b`
13
14pub mod baseline;
15
16use tatara_process::classification::{
17    CalmClassification, Classification, DataClassification, Horizon, HorizonKind,
18    OptimizationDirection, SubstrateType,
19};
20
21/// The lattice trait.
22pub trait Lattice: Sized + Clone + PartialEq {
23    /// Greatest-lower-bound — strongest common refinement.
24    fn meet(&self, other: &Self) -> Self;
25    /// Least-upper-bound — weakest common relaxation.
26    fn join(&self, other: &Self) -> Self;
27    /// `self ≤ other` — `self` is at least as refined as `other`.
28    fn leq(&self, other: &Self) -> bool {
29        self.meet(other) == *self
30    }
31    /// Bottom element — `⊥ ≤ x` for all `x`.
32    fn bottom() -> Self;
33    /// Top element — `x ≤ ⊤` for all `x`.
34    fn top() -> Self;
35}
36
37// ── DataClassification — total order ────────────────────────────────────
38//
39// Public < Internal < Confidential < Pii < Phi < Pci. The ordering is
40// sealed at one site in `tatara_process::classification` —
41// `DataClassification::sensitivity_rank` — so a future variant inserted
42// in the middle of the enum declaration does not silently shift this
43// lattice's `leq` relation. Pre-lift the comparator was `(*self as u8)
44// <= (*other as u8)`, which rode silently on declaration order; an
45// insertion would have moved every later variant's lattice slot
46// without any compile error or test signal. Post-lift the rank is
47// declared per-variant on the typed projection, pinned by
48// `data_classification_rank_is_strictly_monotone_over_all` and
49// `data_classification_rank_agrees_with_partial_ord` in the source
50// crate, and `data_classification_leq_uses_typed_rank` below pins
51// THIS impl to the typed projection (not the silent cast).
52
53impl Lattice for DataClassification {
54    fn meet(&self, other: &Self) -> Self {
55        if self.leq(other) {
56            self.clone()
57        } else {
58            other.clone()
59        }
60    }
61    fn join(&self, other: &Self) -> Self {
62        if self.leq(other) {
63            other.clone()
64        } else {
65            self.clone()
66        }
67    }
68    fn leq(&self, other: &Self) -> bool {
69        self.sensitivity_rank() <= other.sensitivity_rank()
70    }
71    fn bottom() -> Self {
72        DataClassification::Public
73    }
74    fn top() -> Self {
75        DataClassification::Pci
76    }
77}
78
79// ── SubstrateType — antichain (flat lattice) ────────────────────────────
80// Any two distinct substrates are incomparable; meet is top when distinct.
81
82impl Lattice for SubstrateType {
83    fn meet(&self, other: &Self) -> Self {
84        if self == other {
85            self.clone()
86        } else {
87            Self::top()
88        }
89    }
90    fn join(&self, other: &Self) -> Self {
91        if self == other {
92            self.clone()
93        } else {
94            Self::bottom()
95        }
96    }
97    fn leq(&self, other: &Self) -> bool {
98        self == other || *other == Self::top()
99    }
100    fn bottom() -> Self {
101        SubstrateType::Financial
102    }
103    // Regulatory sits at the top — it absorbs any other substrate's constraints.
104    fn top() -> Self {
105        SubstrateType::Regulatory
106    }
107}
108
109// ── CalmClassification — boolean lattice (Monotone ≤ NonMonotone) ──────
110impl Lattice for CalmClassification {
111    fn meet(&self, other: &Self) -> Self {
112        match (self, other) {
113            (Self::Monotone, _) | (_, Self::Monotone) => Self::Monotone,
114            _ => Self::NonMonotone,
115        }
116    }
117    fn join(&self, other: &Self) -> Self {
118        match (self, other) {
119            (Self::NonMonotone, _) | (_, Self::NonMonotone) => Self::NonMonotone,
120            _ => Self::Monotone,
121        }
122    }
123    fn leq(&self, other: &Self) -> bool {
124        matches!(
125            (self, other),
126            (Self::Monotone, _) | (Self::NonMonotone, Self::NonMonotone)
127        )
128    }
129    fn bottom() -> Self {
130        Self::Monotone
131    }
132    fn top() -> Self {
133        Self::NonMonotone
134    }
135}
136
137// ── Horizon — Bounded ≤ Asymptotic (strength of invariant) ──────────────
138//
139// A Bounded point strictly converges; Asymptotic merely trends. We treat
140// Bounded as the refinement (meet), Asymptotic as the relaxation (join).
141
142impl Lattice for Horizon {
143    fn meet(&self, other: &Self) -> Self {
144        match (self.kind, other.kind) {
145            (HorizonKind::Bounded, _) | (_, HorizonKind::Bounded) => Self::bounded(),
146            _ => self.clone(),
147        }
148    }
149    fn join(&self, other: &Self) -> Self {
150        match (self.kind, other.kind) {
151            (HorizonKind::Asymptotic, _) => self.clone(),
152            (_, HorizonKind::Asymptotic) => other.clone(),
153            _ => Self::bounded(),
154        }
155    }
156    fn leq(&self, other: &Self) -> bool {
157        matches!(
158            (self.kind, other.kind),
159            (HorizonKind::Bounded, _) | (HorizonKind::Asymptotic, HorizonKind::Asymptotic)
160        )
161    }
162    fn bottom() -> Self {
163        Self::bounded()
164    }
165    fn top() -> Self {
166        Self::asymptotic("", OptimizationDirection::Minimize, f64::MIN)
167    }
168}
169
170// ── Classification — pointwise product lattice ──────────────────────────
171// `a ⊓ b` meets each axis independently; same for join.
172// PointType is left alone (caller is responsible — point types are semantic, not comparable).
173
174impl Lattice for Classification {
175    fn meet(&self, other: &Self) -> Self {
176        Self {
177            // PointType is an antichain — leave the caller's choice alone.
178            point_type: self.point_type,
179            substrate: self.substrate.meet(&other.substrate),
180            horizon: self.horizon.meet(&other.horizon),
181            calm: self.calm.meet(&other.calm),
182            data_classification: self.data_classification.meet(&other.data_classification),
183        }
184    }
185    fn join(&self, other: &Self) -> Self {
186        Self {
187            point_type: self.point_type,
188            substrate: self.substrate.join(&other.substrate),
189            horizon: self.horizon.join(&other.horizon),
190            calm: self.calm.join(&other.calm),
191            data_classification: self.data_classification.join(&other.data_classification),
192        }
193    }
194    fn leq(&self, other: &Self) -> bool {
195        self.substrate.leq(&other.substrate)
196            && self.horizon.leq(&other.horizon)
197            && self.calm.leq(&other.calm)
198            && self.data_classification.leq(&other.data_classification)
199    }
200    fn bottom() -> Self {
201        Self {
202            point_type: tatara_process::classification::ConvergencePointType::Transform,
203            substrate: SubstrateType::bottom(),
204            horizon: Horizon::bottom(),
205            calm: CalmClassification::bottom(),
206            data_classification: DataClassification::bottom(),
207        }
208    }
209    fn top() -> Self {
210        Self {
211            point_type: tatara_process::classification::ConvergencePointType::Transform,
212            substrate: SubstrateType::top(),
213            horizon: Horizon::top(),
214            calm: CalmClassification::top(),
215            data_classification: DataClassification::top(),
216        }
217    }
218}
219
220/// Convenience — does a cluster classification satisfy a workload's requirements?
221///
222/// Replaces `convergence_controller::cluster_quality::qualities_match`.
223pub fn satisfies(cluster: &Classification, requires: &Classification) -> bool {
224    // A cluster must be AT LEAST as strict as the workload's requirements on each axis —
225    // i.e., the cluster's class ≤ the requirement's class (more refined).
226    cluster.leq(requires)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use tatara_process::classification::ConvergencePointType;
233
234    #[test]
235    fn data_classification_total_order() {
236        assert!(DataClassification::Public.leq(&DataClassification::Internal));
237        assert!(DataClassification::Internal.leq(&DataClassification::Confidential));
238        assert!(DataClassification::Confidential.leq(&DataClassification::Pii));
239    }
240
241    #[test]
242    fn idempotent_meet() {
243        let c = Classification {
244            point_type: ConvergencePointType::Gate,
245            substrate: SubstrateType::Observability,
246            horizon: Horizon::bounded(),
247            calm: CalmClassification::Monotone,
248            data_classification: DataClassification::Internal,
249        };
250        assert_eq!(c.meet(&c), c);
251    }
252
253    #[test]
254    fn absorption() {
255        let a = Classification {
256            point_type: ConvergencePointType::Gate,
257            substrate: SubstrateType::Observability,
258            horizon: Horizon::bounded(),
259            calm: CalmClassification::Monotone,
260            data_classification: DataClassification::Internal,
261        };
262        let b = Classification {
263            point_type: ConvergencePointType::Gate,
264            substrate: SubstrateType::Observability,
265            horizon: Horizon::bounded(),
266            calm: CalmClassification::NonMonotone,
267            data_classification: DataClassification::Pii,
268        };
269        assert_eq!(a.meet(&a.join(&b)), a);
270    }
271
272    #[test]
273    fn calm_monotone_is_refinement() {
274        assert!(CalmClassification::Monotone.leq(&CalmClassification::NonMonotone));
275        assert!(!CalmClassification::NonMonotone.leq(&CalmClassification::Monotone));
276    }
277
278    #[test]
279    fn substrate_flat_antichain() {
280        let s = SubstrateType::Compute;
281        let t = SubstrateType::Storage;
282        assert!(!s.leq(&t));
283        assert!(!t.leq(&s));
284        // Meet of distinct substrates climbs to top (Regulatory).
285        assert_eq!(s.meet(&t), SubstrateType::Regulatory);
286    }
287
288    // ── satisfies() ────────────────────────────────────────────────────
289
290    fn bounded_classification(data: DataClassification) -> Classification {
291        Classification {
292            point_type: ConvergencePointType::Gate,
293            substrate: SubstrateType::Observability,
294            horizon: Horizon::bounded(),
295            calm: CalmClassification::Monotone,
296            data_classification: data,
297        }
298    }
299
300    #[test]
301    fn satisfies_is_true_when_cluster_is_as_refined_as_requirement() {
302        // cluster.leq(requirement) ⇔ cluster is at least as refined.
303        // Public (bottom) cluster satisfies Public-or-higher requirements.
304        let cluster = bounded_classification(DataClassification::Public);
305        let requirement_public = bounded_classification(DataClassification::Public);
306        let requirement_internal = bounded_classification(DataClassification::Internal);
307        assert!(satisfies(&cluster, &requirement_public));
308        assert!(satisfies(&cluster, &requirement_internal));
309    }
310
311    #[test]
312    fn satisfies_is_false_when_cluster_is_less_refined_than_requirement() {
313        // A Confidential cluster does NOT satisfy a Public requirement —
314        // relaxing a class is a lattice "up" move, not "down".
315        // (The naming is counter-intuitive; the inequality direction is
316        // what the code actually enforces.)
317        let cluster = bounded_classification(DataClassification::Confidential);
318        let requirement = bounded_classification(DataClassification::Public);
319        assert!(!satisfies(&cluster, &requirement));
320    }
321
322    #[test]
323    fn satisfies_equal_always_true() {
324        // x.leq(x) is reflexive — a cluster always satisfies its own
325        // classification requirements.
326        let c = bounded_classification(DataClassification::Pii);
327        assert!(satisfies(&c, &c));
328    }
329
330    // ── DataClassification — total-order lattice laws ──────────────────
331
332    use proptest::prelude::*;
333
334    /// SEAL TEST: this lattice's `leq` agrees with the typed
335    /// `sensitivity_rank` projection in `tatara_process::classification`,
336    /// NOT with a silent `as u8` declaration-order cast. A future
337    /// reordering of the source enum's variant declarations is caught
338    /// by `data_classification_rank_agrees_with_partial_ord` in the
339    /// source crate; THIS test ensures the lattice impl actually
340    /// consumes that typed projection. Removing the
341    /// `sensitivity_rank` call from `Lattice::leq` (back to `as u8`)
342    /// would still pass every lattice law because the rank values
343    /// were chosen to agree with declaration order today — but it
344    /// would re-introduce the silent declaration-order coupling that
345    /// the lift severed. This test fails when the rank arms disagree
346    /// with what `Lattice::leq` returns for any pair in `ALL × ALL`.
347    #[test]
348    fn data_classification_leq_uses_typed_rank() {
349        for a in DataClassification::ALL {
350            for b in DataClassification::ALL {
351                assert_eq!(
352                    a.leq(&b),
353                    a.sensitivity_rank() <= b.sensitivity_rank(),
354                    "Lattice::leq for ({a:?}, {b:?}) disagrees with sensitivity_rank — \
355                     the lattice ordering has drifted away from the typed rank \
356                     projection that seals it",
357                );
358            }
359        }
360    }
361
362    /// Generic closed-set proptest strategy — iterates a static `ALL`
363    /// slice via `prop_oneof! { Just(*v) ... }`. Lifts the hand-rolled
364    /// strategy that previously hard-coded each variant onto the
365    /// closed-set source of truth, so adding a variant to
366    /// `DataClassification::ALL` automatically extends the property
367    /// search space here without touching this strategy.
368    fn from_all<T: Copy + std::fmt::Debug + 'static>(
369        all: &'static [T],
370    ) -> impl Strategy<Value = T> {
371        (0..all.len()).prop_map(move |i| all[i])
372    }
373
374    fn any_data_class() -> impl Strategy<Value = DataClassification> {
375        from_all(&DataClassification::ALL)
376    }
377
378    fn any_calm() -> impl Strategy<Value = CalmClassification> {
379        prop_oneof![
380            Just(CalmClassification::Monotone),
381            Just(CalmClassification::NonMonotone),
382        ]
383    }
384
385    proptest! {
386        // Docstring at the top of this module claims "Laws (proven by
387        // proptest in tests)" — up to now that was aspirational. These
388        // property tests make the claim real for the two axes whose
389        // lattice laws are well-founded (total order + 2-element).
390        //
391        // Deliberately excludes SubstrateType and the Horizon
392        // Asymptotic-Asymptotic case, whose `meet` / `leq` semantics
393        // are intentionally not lattice-law-abiding (see inline doc
394        // comments on those impls — they encode domain-specific
395        // "antichain with distinguished top" semantics, not a pure
396        // lattice).
397
398        #[test]
399        fn data_class_idempotent(a in any_data_class()) {
400            prop_assert_eq!(a.meet(&a), a);
401            prop_assert_eq!(a.join(&a), a);
402        }
403
404        #[test]
405        fn data_class_commutative(a in any_data_class(), b in any_data_class()) {
406            prop_assert_eq!(a.meet(&b), b.meet(&a));
407            prop_assert_eq!(a.join(&b), b.join(&a));
408        }
409
410        #[test]
411        fn data_class_associative(
412            a in any_data_class(),
413            b in any_data_class(),
414            c in any_data_class(),
415        ) {
416            prop_assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c)));
417            prop_assert_eq!(a.join(&b).join(&c), a.join(&b.join(&c)));
418        }
419
420        #[test]
421        fn data_class_absorption(a in any_data_class(), b in any_data_class()) {
422            // a ⊓ (a ⊔ b) = a
423            prop_assert_eq!(a.meet(&a.join(&b)), a);
424            // a ⊔ (a ⊓ b) = a
425            prop_assert_eq!(a.join(&a.meet(&b)), a);
426        }
427
428        #[test]
429        fn data_class_leq_agrees_with_meet(a in any_data_class(), b in any_data_class()) {
430            // a ≤ b ⇔ a ⊓ b = a. The backbone lattice identity that
431            // the top-of-file docstring promises.
432            prop_assert_eq!(a.leq(&b), a.meet(&b) == a);
433        }
434
435        #[test]
436        fn data_class_leq_agrees_with_join(a in any_data_class(), b in any_data_class()) {
437            // a ≤ b ⇔ a ⊔ b = b (dual form).
438            prop_assert_eq!(a.leq(&b), a.join(&b) == b);
439        }
440
441        #[test]
442        fn data_class_bottom_is_universal_min(a in any_data_class()) {
443            // ⊥ ≤ x for every x. Public is bottom.
444            prop_assert!(DataClassification::bottom().leq(&a));
445        }
446
447        #[test]
448        fn data_class_top_is_universal_max(a in any_data_class()) {
449            // x ≤ ⊤ for every x. Pci is top.
450            prop_assert!(a.leq(&DataClassification::top()));
451        }
452
453        // ── CalmClassification — 2-element boolean lattice ─────────
454
455        #[test]
456        fn calm_idempotent(a in any_calm()) {
457            prop_assert_eq!(a.meet(&a), a);
458            prop_assert_eq!(a.join(&a), a);
459        }
460
461        #[test]
462        fn calm_commutative(a in any_calm(), b in any_calm()) {
463            prop_assert_eq!(a.meet(&b), b.meet(&a));
464            prop_assert_eq!(a.join(&b), b.join(&a));
465        }
466
467        #[test]
468        fn calm_associative(a in any_calm(), b in any_calm(), c in any_calm()) {
469            prop_assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c)));
470            prop_assert_eq!(a.join(&b).join(&c), a.join(&b.join(&c)));
471        }
472
473        #[test]
474        fn calm_absorption(a in any_calm(), b in any_calm()) {
475            prop_assert_eq!(a.meet(&a.join(&b)), a);
476            prop_assert_eq!(a.join(&a.meet(&b)), a);
477        }
478
479        #[test]
480        fn calm_leq_agrees_with_meet(a in any_calm(), b in any_calm()) {
481            prop_assert_eq!(a.leq(&b), a.meet(&b) == a);
482        }
483
484        #[test]
485        fn calm_leq_agrees_with_join(a in any_calm(), b in any_calm()) {
486            prop_assert_eq!(a.leq(&b), a.join(&b) == b);
487        }
488
489        #[test]
490        fn calm_bottom_is_monotone(a in any_calm()) {
491            prop_assert!(CalmClassification::bottom().leq(&a));
492            prop_assert_eq!(CalmClassification::bottom(), CalmClassification::Monotone);
493        }
494
495        #[test]
496        fn calm_top_is_nonmonotone(a in any_calm()) {
497            prop_assert!(a.leq(&CalmClassification::top()));
498            prop_assert_eq!(CalmClassification::top(), CalmClassification::NonMonotone);
499        }
500    }
501}