tatara_process/classification.rs
1//! The six classification dimensions — CRD-facing with `JsonSchema`,
2//! `From`/`Into` bridges to `tatara_core::domain::classification`.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use tatara_core::domain::classification as core;
8use tatara_core::domain::compliance_binding as core_compl;
9
10/// Lattice position of a Process — six orthogonal axes.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
12#[serde(rename_all = "camelCase")]
13pub struct Classification {
14 pub point_type: ConvergencePointType,
15 pub substrate: SubstrateType,
16 #[serde(default)]
17 pub horizon: Horizon,
18 #[serde(default)]
19 pub calm: CalmClassification,
20 #[serde(default)]
21 pub data_classification: DataClassification,
22}
23
24impl Classification {
25 /// The workspace-baseline classification — a [`ConvergencePointType::Gate`]
26 /// point on the [`SubstrateType::Compute`] substrate with every other axis
27 /// at its [`Default`]. The `(Gate, Compute)` pair names an unremarkable
28 /// barrier point in the Compute plane: no domain-specific structural
29 /// claim (no fan-out / fan-in / broadcast / observation semantics beyond
30 /// the barrier gate) and no domain-specific substrate claim (no
31 /// `Financial` / `Network` / `Storage` / `Security` / `Identity` /
32 /// `Observability` / `Regulatory` plane bringing in its own compliance
33 /// baselines). The three defaulted axes ride at the intentional
34 /// workspace baseline the sibling closed-set primitives already own:
35 /// [`Horizon`] at [`HorizonKind::Bounded`] (terminates naturally, no
36 /// asymptotic metric axes required), [`CalmClassification::Monotone`]
37 /// (no coordination required per CALM), and
38 /// [`DataClassification::Internal`] (access-controlled but not
39 /// externally regulated).
40 ///
41 /// Pre-lift the six-line `Classification { point_type: Gate, substrate:
42 /// Compute, horizon: Default::default(), calm: Default::default(),
43 /// data_classification: Default::default() }` struct-literal recurred
44 /// at TEN sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
45 /// — one production consumer plus nine test-fixture callsites spread
46 /// across four crates, each restating the SAME `(Gate, Compute)`
47 /// baseline verbatim:
48 /// * `crate::ephemeral::default_ephemeral_class` — the substitute
49 /// [`EphemeralSpec::into::<crate::crd::ProcessSpec>`] fills into
50 /// [`crate::crd::ProcessSpec::classification`] when the operator
51 /// omits an explicit `:classification` slot on `(defephemeral …)`.
52 /// The one PRODUCTION consumer of the shape — a regression that
53 /// drifted its point-type or substrate axis silently retargets every
54 /// unadorned ephemeral to a different plane.
55 /// * `crate::crd`'s + `crate::lib`'s + `crate::lifetime_clock`'s +
56 /// `tatara_reconciler::{claim,render}`'s + `tatara_pool_reconciler::
57 /// controller_pool`'s `empty_spec` / `empty_process_spec` /
58 /// `ephemeral_process` / `permanent_process` test-fixture helpers +
59 /// inline `ProcessSpec` literals — nine test-fixture callsites
60 /// restating the SAME six-line struct-literal at the same shape.
61 ///
62 /// Post-lift every callsite reads `Classification::gate_compute()`;
63 /// a future workspace-wide baseline shift (a new [`Horizon`] default,
64 /// a promotion of `Compute` to a compound baseline that pre-fills a
65 /// canonical [`CalmClassification`], a per-baseline compliance overlay
66 /// stamping through the classification, or a rename of either axis
67 /// enum) lands at ONE substrate function here and every downstream
68 /// consumer inherits the upgrade mechanically. The current pin ties
69 /// the three defaulted axes to the sibling closed-set defaults
70 /// ([`HorizonKind::Bounded`], [`CalmClassification::Monotone`],
71 /// [`DataClassification::Internal`]) so a future change to any sibling
72 /// default surfaces at this primitive's tests rather than as silent
73 /// drift across ten independent callsites.
74 ///
75 /// Sibling to the `_or_default` / `_or_placeholder` primitive family on
76 /// [`crate::prelude::Process`] on the (return-form × axis) axis — those
77 /// primitives own the borrow-form projections off a live `Process`;
78 /// this one owns the construction shape for a fresh
79 /// [`crate::crd::ProcessSpec`] whose classification axis is
80 /// unremarkable. A future peer `Classification::observe_observability()`
81 /// or similar named variant lands as a sibling method here when a
82 /// second unremarkable-baseline shape opens.
83 ///
84 /// Theory anchor: THEORY.md §VI.1 (generation over composition — the
85 /// six-line struct-literal shape recurred at TEN hand-authored sites
86 /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger and is lifted
87 /// onto ONE workspace-wide owner here). THEORY.md §II.1 invariant 5
88 /// (composition preserves proofs — a regression that drifted the
89 /// baseline axis choice at only one consumer, or that broke the
90 /// sibling-default correspondence, surfaces at this primitive's tests
91 /// rather than as silent operator-visible skew between the ephemeral
92 /// sugar substitute and the ten downstream test-fixtures whose
93 /// assertions depend on the shape).
94 #[must_use]
95 pub fn gate_compute() -> Self {
96 Self {
97 point_type: ConvergencePointType::Gate,
98 substrate: SubstrateType::Compute,
99 horizon: Horizon::default(),
100 calm: CalmClassification::default(),
101 data_classification: DataClassification::default(),
102 }
103 }
104}
105
106/// Structural type — how data flows through the point.
107///
108/// Closed-set sibling on the classification axis algebra; the `ALL` /
109/// `as_str` / Display / `FromStr` triad mirrors
110/// [`DataClassification::ALL`], [`crate::pool::PoolPhase::ALL`],
111/// [`crate::pool::MemberState::ALL`], [`crate::pool::ReplacementPolicy::ALL`],
112/// [`crate::pool::ReturnPolicy::ALL`],
113/// [`crate::boundary::ConditionKind::ALL`],
114/// [`crate::lifetime::TeardownPolicy::ALL`],
115/// [`crate::lifetime::LifetimeKind::ALL`],
116/// [`crate::intent::IntentKind::ALL`],
117/// [`crate::phase::ProcessPhase::ALL`],
118/// [`crate::signal::ProcessSignal::ALL`]. The
119/// `(input_arity, output_arity)` projection (via [`Arity`]) closes the
120/// graph-topology contract: each variant lands in exactly one of the
121/// three structural buckets — endomorphic (1→1), diffusive (1→N), or
122/// convergent (N→1) — so future DAG composition / edge-cardinality
123/// validators dispatch on a typed projection rather than re-deriving
124/// from variant names.
125#[derive(
126 Clone,
127 Copy,
128 Debug,
129 PartialEq,
130 Eq,
131 Hash,
132 Serialize,
133 Deserialize,
134 JsonSchema,
135 tatara_closed_set::DeriveClosedSet,
136)]
137#[serde(rename_all = "PascalCase")]
138#[closed_set(via = "as_str", generate_unknown, display)]
139pub enum ConvergencePointType {
140 /// 1 input → 1 output (linear conversion).
141 Transform,
142 /// 1 input → N outputs (fan-out, spawns downstream DAGs).
143 Fork,
144 /// N inputs → 1 output (fan-in, merges upstream results).
145 Join,
146 /// N inputs → 1 output (barrier, waits for all inputs).
147 Gate,
148 /// N inputs → 1 output (choice, picks best by policy).
149 Select,
150 /// 1 input → N outputs same type (replicate signal).
151 Broadcast,
152 /// N inputs → 1 output (fold/aggregate).
153 Reduce,
154 /// 1 input → 1 output + side-channel (tap for observation).
155 Observe,
156}
157
158impl ConvergencePointType {
159 /// The closed set of point types — single source of truth that
160 /// drives the `as_str` / Display / `FromStr` triad AND the
161 /// `(input_arity, output_arity)` typed pair (via [`Arity`]) AND the
162 /// `is_endomorphic` / `is_diffusive` / `is_convergent` predicate
163 /// triple. Adding a ninth variant lands at one `ALL` entry + one
164 /// `as_str` arm + one `input_arity` arm + one `output_arity` arm +
165 /// one arm per predicate — exhaustively checked by the compiler
166 /// (the `[Self; 8]` array literal forces the arity) AND by the
167 /// per-variant truth-table contract test (a new variant must
168 /// declare its own `(input, output)` arity pair or any future
169 /// DAG composition validator that dispatches on
170 /// `(input_arity, output_arity)` will silently mis-wire it).
171 /// Closes the load-bearing classification-axis enum that
172 /// `tatara_core::domain::compliance_binding::PointSelector::ByType`
173 /// already dispatches against and that every `Process`'s
174 /// `Classification.point_type` reads as the topological identity
175 /// of the convergence point.
176 pub const ALL: [Self; 8] = [
177 Self::Transform,
178 Self::Fork,
179 Self::Join,
180 Self::Gate,
181 Self::Select,
182 Self::Broadcast,
183 Self::Reduce,
184 Self::Observe,
185 ];
186
187 /// Canonical PascalCase wire-format projection — matches the
188 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
189 /// `enum:` enumeration that the Process schema stamps on
190 /// `spec.classification.pointType`. Pinned by
191 /// `convergence_point_type_as_str_matches_serde` so a variant
192 /// rename can't drift between the typed surface, the CRD enum,
193 /// the YAML wire format AND any future operator-facing
194 /// diagnostic that composes `pointType={kind}` via Display
195 /// rather than a hard-coded literal that would silently rot.
196 /// Display + FromStr triad over `ALL` mirrors `DataClassification`
197 /// / `PoolPhase` / `MemberState` / `ReplacementPolicy` /
198 /// `ReturnPolicy` / `TeardownPolicy` / `ConditionKind` /
199 /// `ProcessPhase` / `ProcessSignal`.
200 pub const fn as_str(self) -> &'static str {
201 match self {
202 Self::Transform => "Transform",
203 Self::Fork => "Fork",
204 Self::Join => "Join",
205 Self::Gate => "Gate",
206 Self::Select => "Select",
207 Self::Broadcast => "Broadcast",
208 Self::Reduce => "Reduce",
209 Self::Observe => "Observe",
210 }
211 }
212
213 /// Cardinality of the input edge into this point — `One` for
214 /// `Transform | Fork | Broadcast | Observe` (single-source
215 /// projections), `Many` for `Join | Gate | Select | Reduce`
216 /// (multi-source convergent reductions). Closed-set match (not
217 /// `matches!`) so a future variant triggers the compiler's
218 /// exhaustiveness check at this site rather than silently
219 /// defaulting to `One`. Paired with [`Self::output_arity`] they
220 /// form the typed `(input, output)` projection that future
221 /// DAG composition validators (edge-cardinality checks: "you
222 /// can't connect a Fork's output to a Transform's input
223 /// without a Join in between") dispatch against — a single
224 /// projection per variant means a future `Demux` / `Mux` /
225 /// `Pipeline` point lands in exactly one cell of the
226 /// `Arity × Arity` topology table rather than rotting against
227 /// open-coded `== ConvergencePointType::Fork` checks.
228 pub const fn input_arity(self) -> Arity {
229 match self {
230 Self::Transform | Self::Fork | Self::Broadcast | Self::Observe => Arity::One,
231 Self::Join | Self::Gate | Self::Select | Self::Reduce => Arity::Many,
232 }
233 }
234
235 /// Cardinality of the output edge from this point — `Many` for
236 /// `Fork | Broadcast` (fan-out), `One` for everything else.
237 /// Closed-set match so a future variant triggers the compiler's
238 /// exhaustiveness check. See [`Self::input_arity`] for the
239 /// arity-pair contract + bucket definitions.
240 pub const fn output_arity(self) -> Arity {
241 match self {
242 Self::Fork | Self::Broadcast => Arity::Many,
243 Self::Transform
244 | Self::Join
245 | Self::Gate
246 | Self::Select
247 | Self::Reduce
248 | Self::Observe => Arity::One,
249 }
250 }
251
252 /// Does this point preserve the single-input single-output
253 /// shape? `(input, output) == (One, One)` — `Transform`
254 /// (identity-shaped reshape) and `Observe` (passthrough +
255 /// side-channel tap). Closed-set match so a future variant
256 /// triggers the compiler's exhaustiveness check. Paired with
257 /// `is_diffusive` and `is_convergent` they form the three-way
258 /// disjoint bucket carving sealed by
259 /// `convergence_point_type_buckets_cover_every_variant` AND
260 /// `convergence_point_type_arity_pair_agrees_with_bucket` —
261 /// the bridge that lets the bucket predicates and the arity
262 /// pair name the same topology partition from two angles.
263 pub const fn is_endomorphic(self) -> bool {
264 match self {
265 Self::Transform | Self::Observe => true,
266 Self::Fork
267 | Self::Join
268 | Self::Gate
269 | Self::Select
270 | Self::Broadcast
271 | Self::Reduce => false,
272 }
273 }
274
275 /// Does this point fan out — single input replicated/split
276 /// across many outputs? `(input, output) == (One, Many)` —
277 /// `Fork` and `Broadcast`. Closed-set match so a future variant
278 /// triggers the compiler's exhaustiveness check. See
279 /// `is_endomorphic` for the bucket-carving contract.
280 pub const fn is_diffusive(self) -> bool {
281 match self {
282 Self::Fork | Self::Broadcast => true,
283 Self::Transform
284 | Self::Join
285 | Self::Gate
286 | Self::Select
287 | Self::Reduce
288 | Self::Observe => false,
289 }
290 }
291
292 /// Does this point reduce — many inputs collapsed to one
293 /// output? `(input, output) == (Many, One)` — `Join`, `Gate`,
294 /// `Select`, `Reduce`. Closed-set match so a future variant
295 /// triggers the compiler's exhaustiveness check. See
296 /// `is_endomorphic` for the bucket-carving contract. The
297 /// impossible `(Many, Many)` topology bucket is pinned empty
298 /// by `convergence_point_type_arity_pair_agrees_with_bucket`
299 /// — a `(Many, Many)` point would mean "many independent
300 /// inputs replicated across many independent outputs", which
301 /// has no convergence semantics: every DAG-composition
302 /// validator would have to special-case it. A future variant
303 /// that wants `(Many, Many)` must first extend the bucket
304 /// carving deliberately.
305 pub const fn is_convergent(self) -> bool {
306 match self {
307 Self::Join | Self::Gate | Self::Select | Self::Reduce => true,
308 Self::Transform | Self::Fork | Self::Broadcast | Self::Observe => false,
309 }
310 }
311}
312
313// `impl FromStr for ConvergencePointType` +
314// `impl tatara_lisp::ClosedSet for ConvergencePointType` +
315// `impl std::fmt::Display for ConvergencePointType` +
316// `pub struct UnknownConvergencePointType(pub String)` are all generated
317// by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
318// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
319// enum declaration above. `label` delegates to the inherent
320// `ConvergencePointType::as_str` — the inherent name (PascalCase
321// `as_str`) stays the load-bearing wire-vocabulary projection that
322// matches the serde `rename_all = "PascalCase"` output AND the CRD
323// `enum:` enumeration the Process schema stamps on
324// `spec.classification.pointType` verbatim, while generic
325// `T: ClosedSet` consumers reach the STABLE workspace-wide name
326// (`label`). The `display` flag emits the
327// `f.write_str(self.as_str())` delegation block at the same
328// proc-macro site rather than a hand-rolled `fmt::Display` block per
329// implementor. The auto-derived carrier label "convergence point
330// type" matches the prior hand-rolled `#[error("unknown convergence
331// point type: {0}")]` annotation byte-for-byte. Symmetric to the
332// other five classification-axis closed-sets in this file
333// (`SubstrateType` / `HorizonKind` / `OptimizationDirection` /
334// `CalmClassification` / `DataClassification`) AND every other
335// `#[derive(DeriveClosedSet)]` implementor across the workspace
336// (`crate::pool::{ReplacementPolicy,MemberState,PoolPhase,ReturnPolicy}`,
337// `crate::export::{ArtifactKind,ReportFormat,ChannelKind,ExportTrigger}`,
338// `crate::allocation::{RequestorKind,AllocationPhase}`).
339
340/// Edge cardinality of a [`ConvergencePointType`]'s input or output.
341///
342/// Typed projection used by [`ConvergencePointType::input_arity`] and
343/// [`ConvergencePointType::output_arity`] so DAG composition validators
344/// reach for a closed-set enum rather than re-deriving the in/out
345/// cardinality from variant names. `Many` is the "≥1, could be N"
346/// cardinality — it carries no upper bound because the convergence
347/// point's variant tag is already the structural identity; the
348/// number itself is a runtime property of the DAG, not the typescape.
349#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
350#[closed_set(via = "as_str", display, generate_unknown)]
351pub enum Arity {
352 /// Single edge — exactly one input or one output.
353 One,
354 /// Multiple edges — any number ≥ 1.
355 Many,
356}
357
358impl Arity {
359 /// The closed set of arities — single source of truth that
360 /// drives `as_str` / Display AND the `is_one` predicate. Adding
361 /// a third variant (e.g. `Arity::Zero` for sinks) lands at one
362 /// `ALL` entry + one `as_str` arm + one predicate arm —
363 /// exhaustively checked by the compiler.
364 pub const ALL: [Self; 2] = [Self::One, Self::Many];
365
366 /// Canonical projection — `"One" | "Many"`. Pinned by
367 /// `arity_display_matches_as_str` so a future Display impl
368 /// can't drift from the canonical string.
369 pub const fn as_str(self) -> &'static str {
370 match self {
371 Self::One => "One",
372 Self::Many => "Many",
373 }
374 }
375
376 /// Is this the single-edge cardinality? Closed-set match (not
377 /// `matches!`) so a future variant triggers the compiler's
378 /// exhaustiveness check.
379 pub const fn is_one(self) -> bool {
380 match self {
381 Self::One => true,
382 Self::Many => false,
383 }
384 }
385}
386
387// `impl fmt::Display for Arity` + `impl std::str::FromStr for Arity` +
388// `impl tatara_lisp::ClosedSet for Arity` + `pub struct UnknownArity(pub
389// String)` are all generated by
390// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
391// `#[closed_set(via = "as_str", display, generate_unknown)]` on the enum
392// declaration above. The inherent `as_str` projection stays load-bearing
393// — the canonical `"One" | "Many"` string every DAG composition
394// validator reads; `via = "as_str"` binds `ClosedSet::label` to the same
395// projection so the substrate-wide `assert_display_matches_label` /
396// `assert_closed_set_well_formed` primitives dispatch through the same
397// byte-identical shape every other closed-set implementor across the
398// crate publishes. Aligns `Arity` with the substrate-wide
399// `#[derive(DeriveClosedSet)]` idiom that every other closed-set enum on
400// this classification axis (`ConvergencePointType`, `SubstrateType`,
401// `HorizonKind`, `OptimizationDirection`, `CalmClassification`,
402// `DataClassification`) already carries — the last hand-rolled
403// `impl fmt::Display` on the axis is closed at ONE substrate site.
404
405/// Operational substrate.
406///
407/// Closed-set sibling on the classification axis algebra; the `ALL` /
408/// `as_str` / Display / `FromStr` triad mirrors
409/// [`ConvergencePointType::ALL`], [`DataClassification::ALL`],
410/// [`crate::pool::PoolPhase::ALL`], [`crate::pool::MemberState::ALL`],
411/// [`crate::pool::ReplacementPolicy::ALL`],
412/// [`crate::pool::ReturnPolicy::ALL`],
413/// [`crate::boundary::ConditionKind::ALL`],
414/// [`crate::lifetime::TeardownPolicy::ALL`],
415/// [`crate::lifetime::LifetimeKind::ALL`],
416/// [`crate::intent::IntentKind::ALL`],
417/// [`crate::phase::ProcessPhase::ALL`],
418/// [`crate::signal::ProcessSignal::ALL`]. The
419/// `is_resource` / `is_policy` / `is_telemetry` predicate triple
420/// carves the eight variants into three structurally-disjoint
421/// substrate planes — resource (you allocate from it), policy (it
422/// gates access for other workloads), telemetry (it observes other
423/// workloads) — so future compliance-baseline selectors that
424/// dispatch on a substrate's plane (resource budgets only apply to
425/// resource substrates; policy substrates inherit baselines from
426/// what they govern; telemetry substrates inherit baselines from
427/// what they observe) read a typed projection rather than
428/// re-deriving from variant names.
429#[derive(
430 Clone,
431 Copy,
432 Debug,
433 PartialEq,
434 Eq,
435 Hash,
436 PartialOrd,
437 Ord,
438 Serialize,
439 Deserialize,
440 JsonSchema,
441 tatara_closed_set::DeriveClosedSet,
442)]
443#[serde(rename_all = "PascalCase")]
444#[closed_set(via = "as_str", generate_unknown, display)]
445pub enum SubstrateType {
446 Financial,
447 Compute,
448 Network,
449 Storage,
450 Security,
451 Identity,
452 Observability,
453 Regulatory,
454}
455
456impl SubstrateType {
457 /// The closed set of substrates — single source of truth that
458 /// drives the `as_str` / Display / `FromStr` triad AND the
459 /// `is_resource` / `is_policy` / `is_telemetry` predicate triple.
460 /// Adding a ninth variant lands at one `ALL` entry + one
461 /// `as_str` arm + one arm per predicate — exhaustively checked
462 /// by the compiler (the `[Self; 8]` array literal forces the
463 /// arity) AND by the per-variant plane-bucket contract test (a
464 /// new variant must declare its own plane or any future
465 /// compliance-baseline selector that dispatches on
466 /// `(is_resource, is_policy, is_telemetry)` will silently
467 /// mis-classify it). Closes the load-bearing classification-axis
468 /// enum that
469 /// `tatara_core::domain::compliance_binding::PointSelector::BySubstrate`
470 /// already dispatches against and that every `Process`'s
471 /// `Classification.substrate` reads as the operational
472 /// substrate the convergence point lives on.
473 pub const ALL: [Self; 8] = [
474 Self::Financial,
475 Self::Compute,
476 Self::Network,
477 Self::Storage,
478 Self::Security,
479 Self::Identity,
480 Self::Observability,
481 Self::Regulatory,
482 ];
483
484 /// Canonical PascalCase wire-format projection — matches the
485 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
486 /// `enum:` enumeration that the Process schema stamps on
487 /// `spec.classification.substrate`. Pinned by
488 /// `substrate_type_as_str_matches_serde` so a variant rename
489 /// can't drift between the typed surface, the CRD enum, the YAML
490 /// wire format AND any future operator-facing diagnostic that
491 /// composes `substrate={kind}` via Display rather than a
492 /// hard-coded literal that would silently rot. Display + FromStr
493 /// triad over `ALL` mirrors `ConvergencePointType` /
494 /// `DataClassification` / `PoolPhase` / `MemberState` /
495 /// `ReplacementPolicy` / `ReturnPolicy` / `TeardownPolicy` /
496 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
497 pub const fn as_str(self) -> &'static str {
498 match self {
499 Self::Financial => "Financial",
500 Self::Compute => "Compute",
501 Self::Network => "Network",
502 Self::Storage => "Storage",
503 Self::Security => "Security",
504 Self::Identity => "Identity",
505 Self::Observability => "Observability",
506 Self::Regulatory => "Regulatory",
507 }
508 }
509
510 /// Is this a resource substrate — one you allocate budgets from
511 /// to run workloads? `Financial | Compute | Network | Storage`.
512 /// Closed-set match (not `matches!`) so a future variant
513 /// triggers the compiler's exhaustiveness check at this site
514 /// rather than silently defaulting to `false`. Paired with
515 /// `is_policy` and `is_telemetry` they form the three-way
516 /// disjoint plane carving sealed by
517 /// `substrate_type_buckets_cover_every_variant` — the bridge
518 /// that lets future compliance-baseline selectors dispatch on
519 /// plane without re-deriving from variant names.
520 pub const fn is_resource(self) -> bool {
521 match self {
522 Self::Financial | Self::Compute | Self::Network | Self::Storage => true,
523 Self::Security | Self::Identity | Self::Observability | Self::Regulatory => false,
524 }
525 }
526
527 /// Is this a policy substrate — one that gates access or
528 /// compliance for other workloads rather than carrying their
529 /// payload? `Security | Identity | Regulatory`. Closed-set match
530 /// so a future variant triggers the compiler's exhaustiveness
531 /// check. See `is_resource` for the bucket-carving contract.
532 pub const fn is_policy(self) -> bool {
533 match self {
534 Self::Security | Self::Identity | Self::Regulatory => true,
535 Self::Financial
536 | Self::Compute
537 | Self::Network
538 | Self::Storage
539 | Self::Observability => false,
540 }
541 }
542
543 /// Is this a telemetry substrate — one that passively observes
544 /// other workloads (metrics, logs, traces) without carrying
545 /// their payload or gating their access? `Observability` only.
546 /// Closed-set match so a future variant triggers the compiler's
547 /// exhaustiveness check. See `is_resource` for the
548 /// bucket-carving contract. A telemetry substrate's compliance
549 /// baseline is inherited from what it observes — the singleton
550 /// bucket is intentional, not a placeholder.
551 pub const fn is_telemetry(self) -> bool {
552 match self {
553 Self::Observability => true,
554 Self::Financial
555 | Self::Compute
556 | Self::Network
557 | Self::Storage
558 | Self::Security
559 | Self::Identity
560 | Self::Regulatory => false,
561 }
562 }
563}
564
565// `impl FromStr for SubstrateType` +
566// `impl tatara_lisp::ClosedSet for SubstrateType` +
567// `impl std::fmt::Display for SubstrateType` +
568// `pub struct UnknownSubstrateType(pub String)` are all generated by
569// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
570// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
571// enum declaration above. The auto-derived carrier label "substrate
572// type" matches the prior hand-rolled `#[error("unknown substrate
573// type: {0}")]` annotation byte-for-byte. See the retrofit comment
574// block on [`ConvergencePointType`] for the canonical narrative.
575
576/// How long the point runs. Flattened struct-of-optionals so the OpenAPI
577/// schema carries a single `kind` discriminator without per-variant merge.
578#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
579#[serde(rename_all = "camelCase")]
580pub struct Horizon {
581 #[serde(default)]
582 pub kind: HorizonKind,
583 /// Metric being optimized (Asymptotic only).
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub metric: Option<String>,
586 /// Whether to minimize or maximize the metric (Asymptotic only).
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub direction: Option<OptimizationDirection>,
589 /// Rate threshold considered healthy (Asymptotic only).
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub healthy_rate_threshold: Option<f64>,
592}
593
594/// The shape of a convergence horizon's lifetime — does the point
595/// run toward a fixed point and terminate, or run in perpetuity with
596/// a rate signal?
597///
598/// Closed-set sibling on the classification axis algebra; the `ALL` /
599/// `as_str` / Display / `FromStr` triad mirrors
600/// [`ConvergencePointType::ALL`], [`SubstrateType::ALL`],
601/// [`DataClassification::ALL`], [`CalmClassification::ALL`],
602/// [`OptimizationDirection::ALL`], [`crate::pool::PoolPhase::ALL`],
603/// [`crate::pool::MemberState::ALL`],
604/// [`crate::pool::ReplacementPolicy::ALL`],
605/// [`crate::pool::ReturnPolicy::ALL`],
606/// [`crate::boundary::ConditionKind::ALL`],
607/// [`crate::lifetime::TeardownPolicy::ALL`],
608/// [`crate::lifetime::LifetimeKind::ALL`],
609/// [`crate::intent::IntentKind::ALL`],
610/// [`crate::phase::ProcessPhase::ALL`],
611/// [`crate::signal::ProcessSignal::ALL`]. The [`Self::terminates`]
612/// predicate is the load-bearing horizon-shape primitive — schedulers
613/// asking "will this Process ever reach `Reaped` via natural
614/// termination?" read it as the typed image of the lattice ordering
615/// (`Bounded ≤ Asymptotic` because the bounded horizon strictly
616/// refines the asymptotic one by also terminating) rather than
617/// re-deriving from the variant name. The
618/// [`Self::requires_metric_axes`] predicate is the typed validity
619/// witness for the [`Horizon`] struct's three `Option<…>` fields
620/// (`metric`, `direction`, `healthy_rate_threshold`) — they're
621/// `Some(_)` iff the kind requires them, so the implicit invariant
622/// the optionality encodes becomes a checkable per-kind predicate
623/// instead of operator folklore.
624#[derive(
625 Clone,
626 Copy,
627 Debug,
628 PartialEq,
629 Eq,
630 Hash,
631 Serialize,
632 Deserialize,
633 JsonSchema,
634 Default,
635 tatara_closed_set::DeriveClosedSet,
636)]
637#[serde(rename_all = "PascalCase")]
638#[closed_set(via = "as_str", generate_unknown, display)]
639pub enum HorizonKind {
640 /// Has a fixed point — distance reaches 0 and terminates.
641 #[default]
642 Bounded,
643 /// Runs in perpetuity — rate is the health signal, not distance.
644 Asymptotic,
645}
646
647impl HorizonKind {
648 /// The closed set of horizon kinds — single source of truth that
649 /// drives the `as_str` / Display / `FromStr` triad AND the
650 /// `terminates` predicate AND the `requires_metric_axes` shape-
651 /// validity witness. Adding a third variant (e.g. a `Periodic`
652 /// sentinel for "terminates on each window boundary then
653 /// re-arms", which neither perpetually-running nor singularly-
654 /// terminating names) lands at one `ALL` entry + one `as_str`
655 /// arm + one `terminates` arm + one `requires_metric_axes` arm —
656 /// exhaustively checked by the compiler (the `[Self; 2]` array
657 /// literal forces the arity) AND by the per-variant truth-table
658 /// tests (a new variant must declare its own termination AND
659 /// metric-axes requirement, or every scheduler / horizon-shape
660 /// validator will silently bucket it). Closes the load-bearing
661 /// classification sub-axis that the `Horizon.kind` field threads
662 /// through every `Classification.horizon` field on every
663 /// Process — the last open sibling on the classification axis
664 /// algebra after `OptimizationDirection` (980a318),
665 /// `CalmClassification` (da3430c), `SubstrateType` (b9d7b3b),
666 /// `ConvergencePointType` (7941527), `Arity`, and
667 /// `DataClassification` (81bffa0).
668 pub const ALL: [Self; 2] = [Self::Bounded, Self::Asymptotic];
669
670 /// Canonical PascalCase wire-format projection — matches the
671 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
672 /// `enum:` enumeration the Process schema stamps on
673 /// `spec.classification.horizon.kind`. Pinned by
674 /// `horizon_kind_as_str_matches_serde` so a variant rename
675 /// can't drift between the typed surface, the CRD enum, the
676 /// YAML wire format AND any future operator-facing diagnostic
677 /// composing `horizon.kind={kind}` via Display rather than a
678 /// hard-coded literal. Display + FromStr triad over `ALL`
679 /// mirrors every sibling closed-set enum in this crate.
680 pub const fn as_str(self) -> &'static str {
681 match self {
682 Self::Bounded => "Bounded",
683 Self::Asymptotic => "Asymptotic",
684 }
685 }
686
687 /// LOAD-BEARING HORIZON-SHAPE PRIMITIVE: does this kind terminate
688 /// naturally — i.e. does it have a fixed point that
689 /// `ConvergenceDistance` can reach? Closed-set match (not
690 /// `matches!`) so a future variant triggers the compiler's
691 /// exhaustiveness check rather than silently defaulting to
692 /// `false` (which would silently mis-route a terminating
693 /// variant through the asymptotic rate-window evaluator) or
694 /// `true` (which would silently invent a fixed point for a
695 /// perpetual variant). `Bounded ⇒ true`, `Asymptotic ⇒ false`
696 /// is the typed image of the documented lattice ordering
697 /// `Bounded ≤ Asymptotic` — the bounded horizon strictly refines
698 /// the asymptotic one BY ALSO TERMINATING. Future schedulers
699 /// asking "will this Process reach `Reaped` via natural
700 /// termination?" read this predicate, and the tatara-lattice
701 /// `Lattice for Horizon` impl (which currently dispatches on
702 /// `self.kind == HorizonKind::Bounded` at three sites) can be
703 /// recast in a future run to read `self.kind.terminates()` so
704 /// the lattice basis is the typed primitive rather than a
705 /// variant-name comparison.
706 pub const fn terminates(self) -> bool {
707 match self {
708 Self::Bounded => true,
709 Self::Asymptotic => false,
710 }
711 }
712
713 /// LOAD-BEARING SHAPE-VALIDITY WITNESS: does this kind require
714 /// the three asymptotic-only [`Horizon`] axes (`metric`,
715 /// `direction`, `healthy_rate_threshold`) to be `Some(_)`?
716 /// Closed-set match (not `matches!`) so a future variant
717 /// triggers the compiler's exhaustiveness check rather than
718 /// silently defaulting to `false` (which would silently let an
719 /// asymptotic-shaped variant ship with missing metric axes and
720 /// trip the rate-window evaluator at runtime). `Bounded ⇒
721 /// false`, `Asymptotic ⇒ true` is the typed image of the
722 /// optionality the [`Horizon`] struct encodes via three
723 /// `Option<…>` fields — the implicit invariant ("Asymptotic
724 /// only" in the field docs) is now a checkable per-kind
725 /// predicate. Future horizon-shape validators (CRD admission,
726 /// `tatara-check` form linter, Lisp authoring-time predicate)
727 /// read this rather than re-deriving from variant names.
728 /// Pinned as the antisymmetric partner of [`Self::terminates`]
729 /// — exactly one of `(terminates, requires_metric_axes)` is
730 /// true per variant — by
731 /// `horizon_kind_terminate_xor_requires_metric_axes`.
732 pub const fn requires_metric_axes(self) -> bool {
733 match self {
734 Self::Bounded => false,
735 Self::Asymptotic => true,
736 }
737 }
738}
739
740// `impl FromStr for HorizonKind` +
741// `impl tatara_lisp::ClosedSet for HorizonKind` +
742// `impl std::fmt::Display for HorizonKind` +
743// `pub struct UnknownHorizonKind(pub String)` are all generated by
744// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
745// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
746// enum declaration above. The auto-derived carrier label "horizon
747// kind" matches the prior hand-rolled `#[error("unknown horizon
748// kind: {0}")]` annotation byte-for-byte. See the retrofit comment
749// block on [`ConvergencePointType`] for the canonical narrative.
750
751impl Horizon {
752 pub fn bounded() -> Self {
753 Self::default()
754 }
755
756 pub fn asymptotic(
757 metric: impl Into<String>,
758 direction: OptimizationDirection,
759 threshold: f64,
760 ) -> Self {
761 Self {
762 kind: HorizonKind::Asymptotic,
763 metric: Some(metric.into()),
764 direction: Some(direction),
765 healthy_rate_threshold: Some(threshold),
766 }
767 }
768}
769
770/// Direction of asymptotic optimization — does the metric trend
771/// downward (cost / latency / error rate) or upward
772/// (throughput / coverage / revenue)?
773///
774/// Closed-set sibling on the classification axis algebra; the `ALL` /
775/// `as_str` / Display / `FromStr` triad mirrors
776/// [`ConvergencePointType::ALL`], [`SubstrateType::ALL`],
777/// [`DataClassification::ALL`], [`CalmClassification::ALL`],
778/// [`crate::pool::PoolPhase::ALL`], [`crate::pool::MemberState::ALL`],
779/// [`crate::pool::ReplacementPolicy::ALL`],
780/// [`crate::pool::ReturnPolicy::ALL`],
781/// [`crate::boundary::ConditionKind::ALL`],
782/// [`crate::lifetime::TeardownPolicy::ALL`],
783/// [`crate::lifetime::LifetimeKind::ALL`],
784/// [`crate::intent::IntentKind::ALL`],
785/// [`crate::phase::ProcessPhase::ALL`],
786/// [`crate::signal::ProcessSignal::ALL`]. The
787/// [`Self::is_improvement`] predicate is the load-bearing
788/// optimization primitive — `Asymptotic` horizons read it as the
789/// typed image of "did this metric sample improve over the last
790/// one?" rather than re-deriving `<` vs `>` from the variant name
791/// at every consumer site (rate-window evaluators, breathe-band
792/// regression detectors, asymptotic-health probes).
793#[derive(
794 Clone,
795 Copy,
796 Debug,
797 PartialEq,
798 Eq,
799 Hash,
800 Serialize,
801 Deserialize,
802 JsonSchema,
803 Default,
804 tatara_closed_set::DeriveClosedSet,
805)]
806#[serde(rename_all = "PascalCase")]
807#[closed_set(via = "as_str", generate_unknown, display)]
808pub enum OptimizationDirection {
809 /// Cost / latency / error rate — lower is better. The default for
810 /// an under-specified `Asymptotic` horizon so an unannotated
811 /// metric can't silently flip the rate-window evaluator's polarity
812 /// (a future `Maximize`-default-via-rename would silently invert
813 /// every existing alert that treats decreasing rate as healthy).
814 #[default]
815 Minimize,
816 /// Throughput / coverage / revenue — higher is better.
817 Maximize,
818}
819
820impl OptimizationDirection {
821 /// The closed set of optimization directions — single source of
822 /// truth that drives the `as_str` / Display / `FromStr` triad AND
823 /// the `prefers_lower` partition AND the `is_improvement`
824 /// load-bearing primitive AND both `From` bridge arms. Adding a
825 /// third variant (e.g. a `Stabilize` sentinel for "drive toward
826 /// a target value", which neither minimization nor maximization
827 /// names) lands at one `ALL` entry + one `as_str` arm + one
828 /// `prefers_lower` arm + one `is_improvement` arm + two bridge
829 /// arms — exhaustively checked by the compiler (the `[Self; 2]`
830 /// array literal forces the arity) AND by the per-variant
831 /// truth-table tests (a new variant must declare its own
832 /// improvement semantics, or every asymptotic-health probe will
833 /// silently bucket it). Closes the load-bearing classification
834 /// sub-axis that the `Horizon.direction` field threads through
835 /// every `Asymptotic` Process.
836 pub const ALL: [Self; 2] = [Self::Minimize, Self::Maximize];
837
838 /// Canonical PascalCase wire-format projection — matches the serde
839 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
840 /// enumeration the Process schema stamps on
841 /// `spec.classification.horizon.direction`. Pinned by
842 /// `optimization_direction_as_str_matches_serde` so a variant
843 /// rename can't drift between the typed surface, the CRD enum, the
844 /// YAML wire format AND any future operator-facing diagnostic
845 /// composed as `direction={kind}` via Display rather than a
846 /// hard-coded literal. Display + `FromStr` triad over `ALL`
847 /// mirrors every sibling closed-set enum in this crate.
848 pub const fn as_str(self) -> &'static str {
849 match self {
850 Self::Minimize => "Minimize",
851 Self::Maximize => "Maximize",
852 }
853 }
854
855 /// Does this direction prefer numerically lower values?
856 /// Closed-set match (not `matches!`) so a future variant triggers
857 /// the compiler's exhaustiveness check at this site rather than
858 /// silently defaulting to `false` (which would mis-bucket a
859 /// `Stabilize`-style variant onto the maximization path). The
860 /// boolean partition is the algebraic shape of an optimization
861 /// direction: `Minimize ⇒ true`, `Maximize ⇒ false`. Mirrors
862 /// [`CalmClassification::requires_coordination`] — a two-variant
863 /// truth-table that any future dispatch on a per-direction policy
864 /// (rate-window evaluator polarity, breathe-band regression
865 /// detector sign, asymptotic-health threshold direction) reads
866 /// once rather than re-deriving from the variant name.
867 pub const fn prefers_lower(self) -> bool {
868 match self {
869 Self::Minimize => true,
870 Self::Maximize => false,
871 }
872 }
873
874 /// LOAD-BEARING OPTIMIZATION PRIMITIVE: under this direction, is
875 /// `after` strictly better than `before`? Closed-set match so a
876 /// future variant triggers the compiler's exhaustiveness check
877 /// rather than silently defaulting to `false` (which would
878 /// silently mark every sample as a regression). For `Minimize`,
879 /// improvement means `after < before`; for `Maximize`, `after >
880 /// before`. Strict inequality so a no-op sample (equal values) is
881 /// NOT counted as improvement — pinned by
882 /// `optimization_direction_no_op_is_not_improvement`, which
883 /// guarantees a flatlined rate-window evaluator doesn't silently
884 /// keep claiming "still improving" forever and skipping the
885 /// healthy-rate-threshold gate. NaN on either operand short-
886 /// circuits to `false` (no improvement claim from indeterminate
887 /// data) via the standard `PartialOrd` behavior — pinned by
888 /// `optimization_direction_nan_is_not_improvement`. The
889 /// asymmetry contract (`is_improvement(a, b)` xor
890 /// `is_improvement(b, a)` for distinct finite samples) is pinned
891 /// by `optimization_direction_is_improvement_is_antisymmetric`,
892 /// the algebraic shape that every asymptotic-health rate-window
893 /// evaluator depends on to avoid double-counting an improvement
894 /// as a regression on the reverse traversal.
895 pub fn is_improvement(self, before: f64, after: f64) -> bool {
896 match self {
897 Self::Minimize => after < before,
898 Self::Maximize => after > before,
899 }
900 }
901}
902
903// `impl FromStr for OptimizationDirection` +
904// `impl tatara_lisp::ClosedSet for OptimizationDirection` +
905// `impl std::fmt::Display for OptimizationDirection` +
906// `pub struct UnknownOptimizationDirection(pub String)` are all
907// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
908// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
909// enum declaration above. The auto-derived carrier label
910// "optimization direction" matches the prior hand-rolled
911// `#[error("unknown optimization direction: {0}")]` annotation
912// byte-for-byte. See the retrofit comment block on
913// [`ConvergencePointType`] for the canonical narrative.
914
915/// CALM theorem classification — determines whether coordination is required.
916///
917/// Closed-set sibling on the classification axis algebra; the `ALL` /
918/// `as_str` / Display / `FromStr` triad mirrors
919/// [`ConvergencePointType::ALL`], [`SubstrateType::ALL`],
920/// [`DataClassification::ALL`], [`crate::pool::PoolPhase::ALL`],
921/// [`crate::pool::MemberState::ALL`], [`crate::pool::ReplacementPolicy::ALL`],
922/// [`crate::pool::ReturnPolicy::ALL`],
923/// [`crate::boundary::ConditionKind::ALL`],
924/// [`crate::lifetime::TeardownPolicy::ALL`],
925/// [`crate::lifetime::LifetimeKind::ALL`],
926/// [`crate::intent::IntentKind::ALL`],
927/// [`crate::phase::ProcessPhase::ALL`],
928/// [`crate::signal::ProcessSignal::ALL`]. The
929/// [`Self::requires_coordination`] predicate is the CALM theorem
930/// keystone — Hellerstein's "Consistency As Logical Monotonicity"
931/// states that a program can be distributed without coordination iff
932/// it computes a monotone function, so `Monotone ⇒ no coordination`
933/// and `NonMonotone ⇒ requires coordination` is a typed image of the
934/// theorem itself rather than a runtime convention. Future reconciler
935/// dispatch on `calm.requires_coordination()` (Raft for non-monotone
936/// writes; gossip for monotone ones) reads this projection rather
937/// than re-deriving from variant names.
938#[derive(
939 Clone,
940 Copy,
941 Debug,
942 PartialEq,
943 Eq,
944 Hash,
945 Serialize,
946 Deserialize,
947 JsonSchema,
948 Default,
949 tatara_closed_set::DeriveClosedSet,
950)]
951#[serde(rename_all = "PascalCase")]
952#[closed_set(via = "as_str", generate_unknown, display)]
953pub enum CalmClassification {
954 /// Can be distributed without coordination (CALM ⇒ the program
955 /// computes a monotone function).
956 #[default]
957 Monotone,
958 /// Requires coordination (CALM ⇒ the program is not monotone).
959 NonMonotone,
960}
961
962impl CalmClassification {
963 /// The closed set of CALM classifications — single source of truth
964 /// that drives the `as_str` / Display / `FromStr` triad AND the
965 /// `requires_coordination` predicate. Adding a third variant
966 /// (e.g. a `ConditionallyMonotone` sentinel for ops that are
967 /// monotone under a witness, like CRDT joins under a fixed
968 /// schema) lands at one `ALL` entry + one `as_str` arm + one
969 /// predicate arm + one bridge-pair arm — exhaustively checked by
970 /// the compiler (the `[Self; 2]` array literal forces the arity)
971 /// AND by the per-variant predicate truth-table test (a new
972 /// variant must declare its own coordination requirement or any
973 /// future reconciler-side dispatch will silently bucket it).
974 /// Closes the load-bearing classification-axis enum that the
975 /// `Classification.calm` field exposes to every Process and that
976 /// [`tatara_lattice`]'s boolean-lattice `Lattice for
977 /// CalmClassification` impl reads via [`Self::requires_coordination`]
978 /// as the lattice's `top()` predicate.
979 pub const ALL: [Self; 2] = [Self::Monotone, Self::NonMonotone];
980
981 /// Canonical PascalCase wire-format projection — matches the
982 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
983 /// `enum:` enumeration that the Process schema stamps on
984 /// `spec.classification.calm`. Pinned by
985 /// `calm_classification_as_str_matches_serde` so a variant rename
986 /// can't drift between the typed surface, the CRD enum, the YAML
987 /// wire format AND any future operator-facing diagnostic that
988 /// composes `calm={kind}` via Display rather than a hard-coded
989 /// literal that would silently rot. Display + FromStr triad over
990 /// `ALL` mirrors every sibling closed-set enum in this crate.
991 pub const fn as_str(self) -> &'static str {
992 match self {
993 Self::Monotone => "Monotone",
994 Self::NonMonotone => "NonMonotone",
995 }
996 }
997
998 /// CALM-THEOREM KEYSTONE: does this classification require
999 /// distributed coordination? Closed-set match (not `matches!`) so
1000 /// a future variant triggers the compiler's exhaustiveness check
1001 /// at this site rather than silently defaulting to `false` and
1002 /// shipping a non-monotone operation onto the no-coordination
1003 /// path. The theorem (Hellerstein 2010) states that a program can
1004 /// be distributed without coordination iff it computes a monotone
1005 /// function — `Monotone ⇒ false` and `NonMonotone ⇒ true` is the
1006 /// typed image of that biconditional. Consumers (future reconciler
1007 /// dispatch between Raft writes and gossip propagation; current
1008 /// `tatara_lattice` boolean-lattice ordering where `Monotone ≤
1009 /// NonMonotone`) read this predicate rather than re-deriving from
1010 /// variant names.
1011 pub const fn requires_coordination(self) -> bool {
1012 match self {
1013 Self::Monotone => false,
1014 Self::NonMonotone => true,
1015 }
1016 }
1017}
1018
1019// `impl FromStr for CalmClassification` +
1020// `impl tatara_lisp::ClosedSet for CalmClassification` +
1021// `impl std::fmt::Display for CalmClassification` +
1022// `pub struct UnknownCalmClassification(pub String)` are all generated
1023// by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
1024// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
1025// enum declaration above. The auto-derived carrier label
1026// "calm classification" matches the prior hand-rolled
1027// `#[error("unknown calm classification: {0}")]` annotation
1028// byte-for-byte. See the retrofit comment block on
1029// [`ConvergencePointType`] for the canonical narrative.
1030
1031/// Data sensitivity, drives compliance baseline selection.
1032///
1033/// Sibling closed-set on the classification axis algebra; the `ALL` /
1034/// `as_str` / Display / `FromStr` triad mirrors
1035/// [`crate::pool::PoolPhase::ALL`], [`crate::pool::MemberState::ALL`],
1036/// [`crate::pool::ReplacementPolicy::ALL`],
1037/// [`crate::pool::ReturnPolicy::ALL`],
1038/// [`crate::boundary::ConditionKind::ALL`],
1039/// [`crate::lifetime::TeardownPolicy::ALL`],
1040/// [`crate::lifetime::LifetimeKind::ALL`],
1041/// [`crate::intent::IntentKind::ALL`],
1042/// [`crate::phase::ProcessPhase::ALL`],
1043/// [`crate::signal::ProcessSignal::ALL`].
1044#[derive(
1045 Clone,
1046 Copy,
1047 Debug,
1048 PartialEq,
1049 Eq,
1050 PartialOrd,
1051 Ord,
1052 Hash,
1053 Serialize,
1054 Deserialize,
1055 JsonSchema,
1056 Default,
1057 tatara_closed_set::DeriveClosedSet,
1058)]
1059#[serde(rename_all = "PascalCase")]
1060#[closed_set(via = "as_str", generate_unknown, display)]
1061pub enum DataClassification {
1062 Public,
1063 #[default]
1064 Internal,
1065 Confidential,
1066 Pii,
1067 Phi,
1068 Pci,
1069}
1070
1071impl DataClassification {
1072 /// The closed set of data classifications — single source of truth
1073 /// that drives the `as_str` / Display / `FromStr` triad AND the
1074 /// `sensitivity_rank` total-order projection AND the
1075 /// `is_restricted` / `is_regulated` predicate pair. Adding a
1076 /// seventh variant lands at one `ALL` entry + one `as_str` arm +
1077 /// one `sensitivity_rank` arm + one arm per predicate —
1078 /// exhaustively checked by the compiler (the `[Self; 6]` array
1079 /// literal forces the arity) AND by the per-variant truth-table
1080 /// contract test (a new variant must declare its own
1081 /// `(is_restricted, is_regulated)` bucket or any future
1082 /// compliance-baseline auto-selector that dispatches on the pair
1083 /// will silently bucket it into the wrong sensitivity column).
1084 /// This closes the sixth classification-axis enum and the closure
1085 /// is consumed by [`tatara_lattice`]'s total-order `Lattice` impl
1086 /// via [`Self::sensitivity_rank`] so the lattice ordering no
1087 /// longer rides silently on declaration order.
1088 pub const ALL: [Self; 6] = [
1089 Self::Public,
1090 Self::Internal,
1091 Self::Confidential,
1092 Self::Pii,
1093 Self::Phi,
1094 Self::Pci,
1095 ];
1096
1097 /// Canonical PascalCase wire-format projection — matches the
1098 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1099 /// `enum:` enumeration that the Process schema stamps on
1100 /// `spec.classification.dataClassification`. Pinned by
1101 /// `data_classification_as_str_matches_serde` so a variant rename
1102 /// can't drift between the typed surface, the CRD enum, the YAML
1103 /// wire format AND any future operator-facing diagnostic that
1104 /// composes `dataClassification={class}` via Display rather than
1105 /// a hard-coded literal that would silently rot. Display +
1106 /// FromStr triad over `ALL` mirrors `PoolPhase` / `MemberState` /
1107 /// `ReplacementPolicy` / `ReturnPolicy` / `TeardownPolicy` /
1108 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
1109 pub const fn as_str(self) -> &'static str {
1110 match self {
1111 Self::Public => "Public",
1112 Self::Internal => "Internal",
1113 Self::Confidential => "Confidential",
1114 Self::Pii => "Pii",
1115 Self::Phi => "Phi",
1116 Self::Pci => "Pci",
1117 }
1118 }
1119
1120 /// Explicit total-order rank, sealed at one site so the lattice
1121 /// ordering stops riding silently on declaration order. Pre-lift
1122 /// the tatara-lattice `Lattice for DataClassification` impl
1123 /// compared variants via `(*self as u8) <= (*other as u8)`, so a
1124 /// future variant inserted in the middle of the enum (say a
1125 /// `Restricted` between `Internal` and `Confidential`) would
1126 /// silently shift every subsequent variant's `as u8` value AND
1127 /// the lattice's `leq` relation — no compile error, no test
1128 /// failure, but every compliance-baseline comparison
1129 /// downstream would have moved by one slot. Post-lift the rank
1130 /// is declared explicitly per variant; an insertion forces the
1131 /// author to pick a rank deliberately (and
1132 /// `data_classification_rank_is_strictly_monotone_over_all`
1133 /// pins the existing six variants at 0..6 so the lattice's
1134 /// total order remains the documented
1135 /// `Public < Internal < Confidential < Pii < Phi < Pci`).
1136 pub const fn sensitivity_rank(self) -> u8 {
1137 match self {
1138 Self::Public => 0,
1139 Self::Internal => 1,
1140 Self::Confidential => 2,
1141 Self::Pii => 3,
1142 Self::Phi => 4,
1143 Self::Pci => 5,
1144 }
1145 }
1146
1147 /// Is this classification subject to external regulatory regime
1148 /// (HIPAA / PCI-DSS / GDPR-style data-subject controls)?
1149 /// Closed-set match (not `matches!`) so a future variant triggers
1150 /// the compiler's exhaustiveness check at this site rather than
1151 /// silently defaulting to `false`. Paired with `is_restricted`
1152 /// they form the two-axis projection that future
1153 /// compliance-baseline auto-selectors dispatch against —
1154 /// `(false, false)` ⇒ freely distributable (`Public`);
1155 /// `(false, true)` ⇒ access-controlled but not regulated
1156 /// (`Internal | Confidential`); `(true, true)` ⇒ regulated data
1157 /// that implies access control (`Pii | Phi | Pci`). The
1158 /// impossible bucket `(true, false)` — regulated data without
1159 /// access control — is pinned empty by
1160 /// `data_classification_regulated_implies_restricted`.
1161 pub const fn is_regulated(self) -> bool {
1162 match self {
1163 Self::Pii | Self::Phi | Self::Pci => true,
1164 Self::Public | Self::Internal | Self::Confidential => false,
1165 }
1166 }
1167
1168 /// Does this classification require access controls beyond
1169 /// freely-distributable? Closed-set match so a future variant
1170 /// triggers the compiler's exhaustiveness check. See
1171 /// `is_regulated` for the predicate-pair contract + bucket
1172 /// definitions.
1173 pub const fn is_restricted(self) -> bool {
1174 match self {
1175 Self::Public => false,
1176 Self::Internal | Self::Confidential | Self::Pii | Self::Phi | Self::Pci => true,
1177 }
1178 }
1179}
1180
1181// `impl FromStr for DataClassification` +
1182// `impl tatara_lisp::ClosedSet for DataClassification` +
1183// `impl std::fmt::Display for DataClassification` +
1184// `pub struct UnknownDataClassification(pub String)` are all generated
1185// by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
1186// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
1187// enum declaration above. The auto-derived carrier label
1188// "data classification" matches the prior hand-rolled
1189// `#[error("unknown data classification: {0}")]` annotation
1190// byte-for-byte. See the retrofit comment block on
1191// [`ConvergencePointType`] for the canonical narrative.
1192
1193// ───────────────────────────── bridges to tatara-core ─────────────────
1194
1195impl From<ConvergencePointType> for core::ConvergencePointType {
1196 fn from(v: ConvergencePointType) -> Self {
1197 use ConvergencePointType::*;
1198 match v {
1199 Transform => Self::Transform,
1200 Fork => Self::Fork,
1201 Join => Self::Join,
1202 Gate => Self::Gate,
1203 Select => Self::Select,
1204 Broadcast => Self::Broadcast,
1205 Reduce => Self::Reduce,
1206 Observe => Self::Observe,
1207 }
1208 }
1209}
1210
1211impl From<core::ConvergencePointType> for ConvergencePointType {
1212 fn from(v: core::ConvergencePointType) -> Self {
1213 use core::ConvergencePointType as C;
1214 match v {
1215 C::Transform => Self::Transform,
1216 C::Fork => Self::Fork,
1217 C::Join => Self::Join,
1218 C::Gate => Self::Gate,
1219 C::Select => Self::Select,
1220 C::Broadcast => Self::Broadcast,
1221 C::Reduce => Self::Reduce,
1222 C::Observe => Self::Observe,
1223 }
1224 }
1225}
1226
1227impl From<SubstrateType> for core::SubstrateType {
1228 fn from(v: SubstrateType) -> Self {
1229 use SubstrateType::*;
1230 match v {
1231 Financial => Self::Financial,
1232 Compute => Self::Compute,
1233 Network => Self::Network,
1234 Storage => Self::Storage,
1235 Security => Self::Security,
1236 Identity => Self::Identity,
1237 Observability => Self::Observability,
1238 Regulatory => Self::Regulatory,
1239 }
1240 }
1241}
1242
1243impl From<core::SubstrateType> for SubstrateType {
1244 fn from(v: core::SubstrateType) -> Self {
1245 use core::SubstrateType as C;
1246 match v {
1247 C::Financial => Self::Financial,
1248 C::Compute => Self::Compute,
1249 C::Network => Self::Network,
1250 C::Storage => Self::Storage,
1251 C::Security => Self::Security,
1252 C::Identity => Self::Identity,
1253 C::Observability => Self::Observability,
1254 C::Regulatory => Self::Regulatory,
1255 }
1256 }
1257}
1258
1259impl From<OptimizationDirection> for core::OptimizationDirection {
1260 fn from(v: OptimizationDirection) -> Self {
1261 match v {
1262 OptimizationDirection::Minimize => Self::Minimize,
1263 OptimizationDirection::Maximize => Self::Maximize,
1264 }
1265 }
1266}
1267
1268impl From<core::OptimizationDirection> for OptimizationDirection {
1269 fn from(v: core::OptimizationDirection) -> Self {
1270 use core::OptimizationDirection as C;
1271 match v {
1272 C::Minimize => Self::Minimize,
1273 C::Maximize => Self::Maximize,
1274 }
1275 }
1276}
1277
1278impl From<Horizon> for core::ConvergenceHorizon {
1279 fn from(v: Horizon) -> Self {
1280 match v.kind {
1281 HorizonKind::Bounded => Self::Bounded,
1282 HorizonKind::Asymptotic => Self::Asymptotic {
1283 metric: v.metric.unwrap_or_default(),
1284 direction: v.direction.unwrap_or_default().into(),
1285 healthy_rate_threshold: v.healthy_rate_threshold.unwrap_or_default(),
1286 },
1287 }
1288 }
1289}
1290
1291impl From<CalmClassification> for core::CalmClassification {
1292 fn from(v: CalmClassification) -> Self {
1293 match v {
1294 CalmClassification::Monotone => Self::Monotone,
1295 CalmClassification::NonMonotone => Self::NonMonotone,
1296 }
1297 }
1298}
1299
1300impl From<core::CalmClassification> for CalmClassification {
1301 fn from(v: core::CalmClassification) -> Self {
1302 use core::CalmClassification as C;
1303 match v {
1304 C::Monotone => Self::Monotone,
1305 C::NonMonotone => Self::NonMonotone,
1306 }
1307 }
1308}
1309
1310impl From<DataClassification> for core_compl::DataClassification {
1311 fn from(v: DataClassification) -> Self {
1312 use DataClassification::*;
1313 match v {
1314 Public => Self::Public,
1315 Internal => Self::Internal,
1316 Confidential => Self::Confidential,
1317 Pii => Self::Pii,
1318 Phi => Self::Phi,
1319 Pci => Self::Pci,
1320 }
1321 }
1322}
1323
1324impl From<core_compl::DataClassification> for DataClassification {
1325 fn from(v: core_compl::DataClassification) -> Self {
1326 use core_compl::DataClassification as C;
1327 match v {
1328 C::Public => Self::Public,
1329 C::Internal => Self::Internal,
1330 C::Confidential => Self::Confidential,
1331 C::Pii => Self::Pii,
1332 C::Phi => Self::Phi,
1333 C::Pci => Self::Pci,
1334 }
1335 }
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340 use super::*;
1341 // The closed-set tests below call `T::from_str(bad)` via the
1342 // derive-generated `FromStr` impls — bring the trait into scope at
1343 // the test module so the lib body doesn't carry an otherwise-unused
1344 // `use std::str::FromStr;` at the file head.
1345 use std::str::FromStr;
1346
1347 #[test]
1348 fn bridges_roundtrip() {
1349 let pt: core::ConvergencePointType = ConvergencePointType::Gate.into();
1350 let back: ConvergencePointType = pt.into();
1351 assert_eq!(back, ConvergencePointType::Gate);
1352
1353 let sub: core::SubstrateType = SubstrateType::Observability.into();
1354 let back: SubstrateType = sub.into();
1355 assert_eq!(back, SubstrateType::Observability);
1356 }
1357
1358 #[test]
1359 fn data_classification_ordering() {
1360 assert!(DataClassification::Public < DataClassification::Pii);
1361 assert!(DataClassification::Internal < DataClassification::Confidential);
1362 }
1363
1364 #[test]
1365 fn horizon_default_is_bounded() {
1366 assert_eq!(Horizon::default().kind, HorizonKind::Bounded);
1367 }
1368
1369 // ── Classification::gate_compute substrate pins ─────────────────────
1370 //
1371 // The six-line `Classification { point_type: Gate, substrate: Compute,
1372 // horizon: Default::default(), calm: Default::default(),
1373 // data_classification: Default::default() }` struct-literal was
1374 // open-coded verbatim at ten hand-authored callsites before the
1375 // primitive closed it. These pins bind the composed shape at
1376 // fail-before-pass-after granularity so a regression that flipped a
1377 // baseline axis, drifted a sibling default, or leaked a non-baseline
1378 // slot into the substrate composer surfaces HERE rather than as
1379 // silent operator-visible drift at every unadorned ephemeral env
1380 // (the one production consumer, `default_ephemeral_class`) AND every
1381 // downstream test fixture that keys assertions on the shape.
1382
1383 #[test]
1384 fn gate_compute_composes_the_five_baseline_axes() {
1385 // Primary shape: every axis parked at the workspace baseline.
1386 // A regression that flipped `point_type` off `Gate` or
1387 // `substrate` off `Compute` — the two axes with no `Default` —
1388 // surfaces here.
1389 let c = Classification::gate_compute();
1390 assert_eq!(c.point_type, ConvergencePointType::Gate);
1391 assert_eq!(c.substrate, SubstrateType::Compute);
1392 assert_eq!(c.horizon, Horizon::default());
1393 assert_eq!(c.calm, CalmClassification::default());
1394 assert_eq!(c.data_classification, DataClassification::default());
1395 }
1396
1397 #[test]
1398 fn gate_compute_defaulted_axes_ride_sibling_closed_set_defaults() {
1399 // Pins the sibling-default correspondence the doc comment
1400 // names — a regression that flipped a sibling default (a new
1401 // `HorizonKind` variant promoted to `#[default]`, a rename of
1402 // `CalmClassification::Monotone`, a promotion of `Pii` above
1403 // `Internal` in the `DataClassification` ordering) would move
1404 // the baseline HERE rather than at every downstream consumer.
1405 let c = Classification::gate_compute();
1406 assert_eq!(c.horizon.kind, HorizonKind::Bounded);
1407 assert_eq!(c.calm, CalmClassification::Monotone);
1408 assert_eq!(c.data_classification, DataClassification::Internal);
1409 }
1410
1411 #[test]
1412 fn gate_compute_matches_hand_authored_pre_lift_bytewise() {
1413 // Byte-identical parity with the pre-lift six-line struct-literal
1414 // that recurred at ten hand-authored sites. A regression that
1415 // reshaped the primitive would diverge from the pre-lift block
1416 // HERE rather than at every downstream fixture that keys on the
1417 // shape.
1418 let composed = Classification::gate_compute();
1419 let hand_authored = Classification {
1420 point_type: ConvergencePointType::Gate,
1421 substrate: SubstrateType::Compute,
1422 horizon: Horizon::default(),
1423 calm: CalmClassification::default(),
1424 data_classification: DataClassification::default(),
1425 };
1426 assert_eq!(composed, hand_authored);
1427 }
1428
1429 #[test]
1430 fn gate_compute_is_call_time_construction_not_a_shared_singleton() {
1431 // Two independent calls produce structurally-equal but distinct
1432 // values — pins that the primitive is a plain constructor
1433 // rather than a `lazy_static` clone (which would leak a shared
1434 // singleton whose in-place mutation at one consumer would
1435 // silently mutate the shape at every other consumer). The `!=`
1436 // check on `&mut _`-obtained pointer addresses is intentional:
1437 // a shared singleton would collide, and the pin catches the
1438 // regression at the primitive rather than at the operator-facing
1439 // shape-drift downstream.
1440 let a = Classification::gate_compute();
1441 let b = Classification::gate_compute();
1442 assert_eq!(a, b);
1443 assert!(!std::ptr::eq(&a, &b));
1444 }
1445
1446 // ── closed-set algebra contracts for DataClassification
1447 // (ALL × as_str × FromStr × rank × predicate pair) ────────────
1448
1449 /// Structural well-formedness of [`DataClassification`] as a
1450 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1451 /// testkit lift that pins all three structural invariants (`ALL`
1452 /// is non-empty, every variant round-trips through
1453 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1454 /// outside the closed set) at ONE call site. Replaces the hand-
1455 /// derived `data_classification_all_is_unique_and_complete` +
1456 /// `data_classification_roundtrip_via_as_str` + the empty-input arm
1457 /// of `unknown_data_classification_errors`. `FromStr` delegates to
1458 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1459 /// exercises the same code path the reconciler hits when parsing a
1460 /// CRD `enum:`-validated `dataClassification` value back to the
1461 /// typed classification.
1462 #[test]
1463 fn data_classification_is_well_formed_closed_set() {
1464 tatara_closed_set::assert_closed_set_well_formed::<DataClassification>();
1465 }
1466
1467 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1468 /// output verbatim for every variant. A future variant rename (or
1469 /// an `as_str` arm typo) lands here at one site, instead of
1470 /// drifting between the typed surface, the CRD enum, and the YAML
1471 /// wire format the reconciler stamps on
1472 /// `spec.classification.dataClassification`.
1473 #[test]
1474 fn data_classification_as_str_matches_serde() {
1475 crate::tagged_union::assert_label_matches_serde_serialization::<DataClassification>();
1476 }
1477
1478 /// The Display impl IS `as_str` — pinning this lets future callers
1479 /// reach for either projection without drift. Any operator-facing
1480 /// "dataClassification={class}" diagnostic that composes through
1481 /// Display inherits the canonical wire-format string automatically.
1482 #[test]
1483 fn data_classification_display_matches_as_str() {
1484 crate::tagged_union::assert_display_matches_label::<DataClassification>();
1485 }
1486
1487 /// `FromStr` rejects strings that aren't in the canonical
1488 /// projection — lowercased / typo / cross-axis-leaked — and the
1489 /// error echoes the input verbatim so the operator-facing
1490 /// diagnostic carries the offending value, not a normalized form.
1491 /// The empty-input arm is pinned by
1492 /// [`data_classification_is_well_formed_closed_set`] via the
1493 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1494 /// verbatim-echo contract on the [`UnknownDataClassification`]
1495 /// newtype, which the trait's `make_unknown` can't see.
1496 #[test]
1497 fn unknown_data_classification_errors() {
1498 for bad in [
1499 "pii", // lowercased
1500 "PII", // uppercased
1501 "PersonalData", // typo
1502 "internal_data",
1503 "Steady", // PoolPhase-axis leak
1504 "Replace", // ReturnPolicy-axis leak
1505 "Attested", // ProcessPhase-axis leak
1506 "Compute", // SubstrateType-axis leak
1507 "Gate", // ConvergencePointType-axis leak
1508 "Monotone", // CalmClassification-axis leak
1509 ] {
1510 let err = DataClassification::from_str(bad).unwrap_err();
1511 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1512 }
1513 }
1514
1515 // `unknown_data_classification_message_matches_substrate_convention`
1516 // removed — clause (5) of
1517 // `tatara_closed_set::assert_closed_set_well_formed::<DataClassification>()`
1518 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
1519 // shape generically (called from
1520 // `data_classification_is_well_formed_closed_set` above); the
1521 // `SET_LABEL` projection is pinned by
1522 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
1523
1524 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1525 /// documented per-variant compliance role. Pinning this table at
1526 /// one site means any future compliance-baseline auto-selector
1527 /// reads the same projection that the reconciler writes.
1528 #[test]
1529 fn data_classification_predicate_truth_tables() {
1530 assert!(!DataClassification::Public.is_restricted());
1531 assert!(!DataClassification::Public.is_regulated());
1532
1533 assert!(DataClassification::Internal.is_restricted());
1534 assert!(!DataClassification::Internal.is_regulated());
1535
1536 assert!(DataClassification::Confidential.is_restricted());
1537 assert!(!DataClassification::Confidential.is_regulated());
1538
1539 assert!(DataClassification::Pii.is_restricted());
1540 assert!(DataClassification::Pii.is_regulated());
1541
1542 assert!(DataClassification::Phi.is_restricted());
1543 assert!(DataClassification::Phi.is_regulated());
1544
1545 assert!(DataClassification::Pci.is_restricted());
1546 assert!(DataClassification::Pci.is_regulated());
1547 }
1548
1549 /// IMPLICATION CONTRACT: every regulated classification is also
1550 /// restricted. The impossible bucket (regulated AND
1551 /// freely-distributable) is pinned empty so a future variant that
1552 /// returned `(true, false)` from the predicate pair would FAIL
1553 /// here, forcing the author to either flip `is_restricted` or
1554 /// extend the consumer dispatch sites (compliance-baseline
1555 /// auto-selector, audit-log mandatory-fields validator)
1556 /// deliberately rather than silently producing a regulated class
1557 /// the API server would accept as freely-distributable. Encoded as
1558 /// material implication `is_regulated → is_restricted` so the
1559 /// boolean reads as the documented contract, not its NAND form.
1560 #[test]
1561 fn data_classification_regulated_implies_restricted() {
1562 for class in DataClassification::ALL {
1563 assert!(
1564 !class.is_regulated() || class.is_restricted(),
1565 "{class:?} is regulated but not restricted — \
1566 regulated data is by definition not freely distributable",
1567 );
1568 }
1569 }
1570
1571 /// COVERAGE CONTRACT: every variant lands in exactly one of three
1572 /// compliance buckets — freely distributable (`Public`),
1573 /// restricted-only (`Internal | Confidential`), or regulated
1574 /// (`Pii | Phi | Pci`). Pins the three buckets at their declared
1575 /// cardinalities (1, 2, 3 — sum to `ALL.len()`) so a future
1576 /// variant lands somewhere deliberately.
1577 #[test]
1578 fn data_classification_buckets_cover_every_variant() {
1579 let mut free = 0u32;
1580 let mut restricted_only = 0u32;
1581 let mut regulated = 0u32;
1582 for class in DataClassification::ALL {
1583 match (class.is_restricted(), class.is_regulated()) {
1584 (false, false) => free += 1,
1585 (true, false) => restricted_only += 1,
1586 (true, true) => regulated += 1,
1587 (false, true) => {
1588 panic!("regulated_implies_restricted already pins this empty for {class:?}")
1589 }
1590 }
1591 }
1592 assert_eq!(free, 1, "free bucket: Public");
1593 assert_eq!(
1594 restricted_only, 2,
1595 "restricted-only bucket: Internal + Confidential"
1596 );
1597 assert_eq!(regulated, 3, "regulated bucket: Pii + Phi + Pci");
1598 assert_eq!(
1599 free + restricted_only + regulated,
1600 DataClassification::ALL.len() as u32
1601 );
1602 }
1603
1604 /// MONOTONE-RANK CONTRACT: `sensitivity_rank` is strictly
1605 /// monotone over `ALL`'s declared order, so the lattice ordering
1606 /// `Public < Internal < Confidential < Pii < Phi < Pci` is sealed
1607 /// at one site (this enum's projection) instead of riding on the
1608 /// silent `as u8` cast in [`tatara_lattice`]. A future variant
1609 /// inserted in the middle would either preserve strict monotonicity
1610 /// here (and the lattice keeps working) or FAIL here at compile or
1611 /// test time (and the author has to renumber deliberately). Also
1612 /// pins the rank codomain at `0..ALL.len()` so no variant can
1613 /// silently outrank the documented top.
1614 #[test]
1615 fn data_classification_rank_is_strictly_monotone_over_all() {
1616 let ranks: Vec<u8> = DataClassification::ALL
1617 .into_iter()
1618 .map(DataClassification::sensitivity_rank)
1619 .collect();
1620 for win in ranks.windows(2) {
1621 assert!(win[0] < win[1], "ranks not strictly monotone: {ranks:?}");
1622 }
1623 assert_eq!(*ranks.first().unwrap(), 0, "bottom rank must be 0");
1624 assert_eq!(
1625 *ranks.last().unwrap(),
1626 (DataClassification::ALL.len() as u8) - 1,
1627 "top rank must be ALL.len() - 1"
1628 );
1629 }
1630
1631 /// RANK-AGREES-WITH-ORD CONTRACT: the typed `sensitivity_rank`
1632 /// projection agrees with the derived `PartialOrd` / `Ord` for
1633 /// every pair in `ALL × ALL`. This is the bridge that lets
1634 /// [`tatara_lattice`]'s total-order `Lattice for DataClassification`
1635 /// impl call `sensitivity_rank` instead of `as u8` without changing
1636 /// any observable lattice behavior — and it lets a future
1637 /// reordering of the enum's variant declarations land at this test
1638 /// site (forcing the rank arms to be renumbered) rather than
1639 /// silently shifting the lattice's `leq` relation.
1640 #[test]
1641 fn data_classification_rank_agrees_with_partial_ord() {
1642 for a in DataClassification::ALL {
1643 for b in DataClassification::ALL {
1644 assert_eq!(
1645 a.sensitivity_rank() <= b.sensitivity_rank(),
1646 a <= b,
1647 "rank vs. PartialOrd drift on ({a:?}, {b:?})"
1648 );
1649 }
1650 }
1651 }
1652
1653 /// DEFAULT-AGREEMENT CONTRACT: `DataClassification::default()`
1654 /// returns `Internal` (the variant tagged `#[default]`), AND that
1655 /// variant lands in the restricted-only bucket — neither freely
1656 /// distributable nor externally regulated. A future `#[default]`
1657 /// rename without flipping the predicates fails here.
1658 #[test]
1659 fn data_classification_default_is_internal_in_restricted_only_bucket() {
1660 let d = DataClassification::default();
1661 assert_eq!(d, DataClassification::Internal);
1662 assert!(d.is_restricted());
1663 assert!(!d.is_regulated());
1664 assert_eq!(d.sensitivity_rank(), 1);
1665 }
1666
1667 /// BRIDGE ROUND-TRIP CONTRACT: every variant survives the
1668 /// CRD-facing (`PascalCase`) ↔ tatara-core (`snake_case`)
1669 /// `From` hop. Today the bridge is two hand-written 6-arm matches
1670 /// in this file; pinning the round-trip over `ALL` means a future
1671 /// variant added without extending the bridge fails here at one
1672 /// site instead of drifting between the CRD wire format and the
1673 /// `core_compl::DataClassification` selector axis.
1674 #[test]
1675 fn data_classification_bridge_roundtrip_over_all() {
1676 for class in DataClassification::ALL {
1677 let core: core_compl::DataClassification = class.into();
1678 let back: DataClassification = core.into();
1679 assert_eq!(back, class, "bridge round-trip failed for {class:?}");
1680 }
1681 }
1682
1683 // ── closed-set algebra contracts for ConvergencePointType
1684 // (ALL × as_str × FromStr × arity-pair × predicate triple) ────
1685
1686 /// Structural well-formedness of [`ConvergencePointType`] as a
1687 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1688 /// testkit lift that pins all three structural invariants (`ALL`
1689 /// is non-empty, every variant round-trips through `label ↔
1690 /// parse_label`, labels are pairwise distinct, `""` is outside
1691 /// the closed set) at ONE call site. Replaces the hand-derived
1692 /// `convergence_point_type_all_is_unique_and_complete` +
1693 /// `convergence_point_type_roundtrip_via_as_str` + the empty-
1694 /// input arm of `unknown_convergence_point_type_errors`.
1695 /// `FromStr` delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
1696 /// so this helper exercises the same code path the reconciler
1697 /// hits when parsing a CRD `enum:`-validated value back to the
1698 /// typed point-type. The forced `[Self; 8]` array literal on
1699 /// `ConvergencePointType::ALL` still pins the cardinality at the
1700 /// declaration site.
1701 #[test]
1702 fn convergence_point_type_is_well_formed_closed_set() {
1703 tatara_closed_set::assert_closed_set_well_formed::<ConvergencePointType>();
1704 }
1705
1706 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1707 /// output verbatim for every variant. A future variant rename (or
1708 /// an `as_str` arm typo) lands here at one site, instead of
1709 /// drifting between the typed surface, the CRD enum, and the YAML
1710 /// wire format the reconciler reads from
1711 /// `spec.classification.pointType`.
1712 #[test]
1713 fn convergence_point_type_as_str_matches_serde() {
1714 crate::tagged_union::assert_label_matches_serde_serialization::<ConvergencePointType>();
1715 }
1716
1717 /// The Display impl IS `as_str` — pinning this lets future callers
1718 /// reach for either projection without drift.
1719 #[test]
1720 fn convergence_point_type_display_matches_as_str() {
1721 crate::tagged_union::assert_display_matches_label::<ConvergencePointType>();
1722 }
1723
1724 /// `FromStr` rejects strings outside the canonical projection —
1725 /// lowercased / typo / cross-axis-leaked — and the error echoes
1726 /// the input verbatim so the operator-facing diagnostic surfaces
1727 /// the bad value, not a normalized form. The empty-input arm is
1728 /// pinned by [`convergence_point_type_is_well_formed_closed_set`]
1729 /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
1730 /// the verbatim-echo contract on the
1731 /// [`UnknownConvergencePointType`] newtype, which the trait's
1732 /// `make_unknown` can't see.
1733 #[test]
1734 fn unknown_convergence_point_type_errors() {
1735 for bad in [
1736 "gate", // lowercased
1737 "GATE", // uppercased
1738 "Transformr", // typo
1739 "Filter",
1740 "Steady", // PoolPhase-axis leak
1741 "Pii", // DataClassification-axis leak
1742 "Attested", // ProcessPhase-axis leak
1743 "Compute", // SubstrateType-axis leak
1744 "Monotone", // CalmClassification-axis leak
1745 "PromQL", // ConditionKind-axis leak
1746 ] {
1747 let err = ConvergencePointType::from_str(bad).unwrap_err();
1748 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1749 }
1750 }
1751
1752 // `unknown_convergence_point_type_message_matches_substrate_convention`
1753 // removed — clause (5) of
1754 // `tatara_closed_set::assert_closed_set_well_formed::<ConvergencePointType>()`
1755 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
1756 // shape generically (called from
1757 // `convergence_point_type_is_well_formed_closed_set` above); the
1758 // `SET_LABEL` projection is pinned by
1759 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
1760
1761 /// TRUTH-TABLE CONTRACT: the predicate triple agrees with the
1762 /// documented per-variant topology role. Pinning this table at
1763 /// one site means any future DAG validator reads the same
1764 /// projection that compliance bindings dispatch against.
1765 #[test]
1766 fn convergence_point_type_predicate_truth_tables() {
1767 // Endomorphic: 1→1
1768 assert!(ConvergencePointType::Transform.is_endomorphic());
1769 assert!(!ConvergencePointType::Transform.is_diffusive());
1770 assert!(!ConvergencePointType::Transform.is_convergent());
1771
1772 assert!(ConvergencePointType::Observe.is_endomorphic());
1773 assert!(!ConvergencePointType::Observe.is_diffusive());
1774 assert!(!ConvergencePointType::Observe.is_convergent());
1775
1776 // Diffusive: 1→N
1777 assert!(!ConvergencePointType::Fork.is_endomorphic());
1778 assert!(ConvergencePointType::Fork.is_diffusive());
1779 assert!(!ConvergencePointType::Fork.is_convergent());
1780
1781 assert!(!ConvergencePointType::Broadcast.is_endomorphic());
1782 assert!(ConvergencePointType::Broadcast.is_diffusive());
1783 assert!(!ConvergencePointType::Broadcast.is_convergent());
1784
1785 // Convergent: N→1
1786 for t in [
1787 ConvergencePointType::Join,
1788 ConvergencePointType::Gate,
1789 ConvergencePointType::Select,
1790 ConvergencePointType::Reduce,
1791 ] {
1792 assert!(!t.is_endomorphic(), "{t:?} should not be endomorphic");
1793 assert!(!t.is_diffusive(), "{t:?} should not be diffusive");
1794 assert!(t.is_convergent(), "{t:?} should be convergent");
1795 }
1796 }
1797
1798 /// COVERAGE CONTRACT: every variant lands in *exactly one* of the
1799 /// three topology buckets — endomorphic, diffusive, or convergent.
1800 /// Pins the three buckets at their declared cardinalities (2, 2, 4
1801 /// — sum to `ALL.len()`) so a future variant lands somewhere
1802 /// deliberately. No variant returns true from more than one
1803 /// predicate; no variant returns false from all three.
1804 #[test]
1805 fn convergence_point_type_buckets_cover_every_variant() {
1806 let mut endomorphic = 0u32;
1807 let mut diffusive = 0u32;
1808 let mut convergent = 0u32;
1809 for t in ConvergencePointType::ALL {
1810 let buckets = [t.is_endomorphic(), t.is_diffusive(), t.is_convergent()];
1811 let hits: u32 = buckets.iter().map(|b| u32::from(*b)).sum();
1812 assert_eq!(
1813 hits, 1,
1814 "{t:?} landed in {hits} buckets: {buckets:?} (must be exactly one)"
1815 );
1816 if t.is_endomorphic() {
1817 endomorphic += 1;
1818 }
1819 if t.is_diffusive() {
1820 diffusive += 1;
1821 }
1822 if t.is_convergent() {
1823 convergent += 1;
1824 }
1825 }
1826 assert_eq!(endomorphic, 2, "endomorphic bucket: Transform + Observe");
1827 assert_eq!(diffusive, 2, "diffusive bucket: Fork + Broadcast");
1828 assert_eq!(
1829 convergent, 4,
1830 "convergent bucket: Join + Gate + Select + Reduce"
1831 );
1832 assert_eq!(
1833 endomorphic + diffusive + convergent,
1834 ConvergencePointType::ALL.len() as u32
1835 );
1836 }
1837
1838 /// ARITY-PAIR ⇔ BUCKET CONTRACT: the `(input_arity, output_arity)`
1839 /// projection names the same topology partition as the
1840 /// `is_endomorphic` / `is_diffusive` / `is_convergent` predicate
1841 /// triple. `(One, One) ⇒ endomorphic`; `(One, Many) ⇒ diffusive`;
1842 /// `(Many, One) ⇒ convergent`. The impossible `(Many, Many)`
1843 /// bucket is pinned empty here — a `(Many, Many)` point would
1844 /// have no convergence semantics (many independent inputs
1845 /// replicated across many independent outputs) and every future
1846 /// DAG-composition validator would have to special-case it. This
1847 /// seal is the bridge that lets a future graph validator dispatch
1848 /// on either projection (arity pair OR bucket predicates) without
1849 /// drift — and a future variant that wants `(Many, Many)` must
1850 /// extend the bucket carving deliberately rather than silently
1851 /// shipping a fourth topology class.
1852 #[test]
1853 fn convergence_point_type_arity_pair_agrees_with_bucket() {
1854 for t in ConvergencePointType::ALL {
1855 match (t.input_arity(), t.output_arity()) {
1856 (Arity::One, Arity::One) => assert!(
1857 t.is_endomorphic(),
1858 "{t:?} has (One, One) arity but is not endomorphic"
1859 ),
1860 (Arity::One, Arity::Many) => assert!(
1861 t.is_diffusive(),
1862 "{t:?} has (One, Many) arity but is not diffusive"
1863 ),
1864 (Arity::Many, Arity::One) => assert!(
1865 t.is_convergent(),
1866 "{t:?} has (Many, One) arity but is not convergent"
1867 ),
1868 (Arity::Many, Arity::Many) => panic!(
1869 "{t:?} has (Many, Many) arity — pinned empty; \
1870 extend the topology carving before adding a variant here"
1871 ),
1872 }
1873 }
1874 }
1875
1876 /// BRIDGE ROUND-TRIP CONTRACT: every variant survives the
1877 /// CRD-facing (`PascalCase`) ↔ tatara-core (`snake_case`)
1878 /// `From` hop. Today the bridge is two hand-written 8-arm
1879 /// matches in this file; pinning the round-trip over `ALL`
1880 /// means a future variant added without extending the bridge
1881 /// fails here at one site instead of drifting between the CRD
1882 /// wire format and the
1883 /// `core::ConvergencePointType` selector axis that
1884 /// `compliance_binding::PointSelector::ByType` already
1885 /// dispatches against.
1886 #[test]
1887 fn convergence_point_type_bridge_roundtrip_over_all() {
1888 for t in ConvergencePointType::ALL {
1889 let core_t: core::ConvergencePointType = t.into();
1890 let back: ConvergencePointType = core_t.into();
1891 assert_eq!(back, t, "bridge round-trip failed for {t:?}");
1892 }
1893 }
1894
1895 // ── closed-set algebra contracts for Arity ───────────────────
1896
1897 /// `ALL` is the source of truth — pin its closure so a variant
1898 /// added without an `ALL` entry fails here. The arity is asserted
1899 /// by the `[Self; 2]` array type itself.
1900 #[test]
1901 fn arity_all_is_unique_and_complete() {
1902 let mut seen = std::collections::HashSet::new();
1903 for a in Arity::ALL {
1904 assert!(seen.insert(a), "duplicate variant in ALL: {a:?}");
1905 }
1906 assert_eq!(seen.len(), Arity::ALL.len());
1907 }
1908
1909 /// The Display impl IS `as_str` — pinning this lets future
1910 /// callers reach for either projection without drift. No serde
1911 /// matching here because `Arity` is a typed projection, not a
1912 /// CRD-facing enum — it never crosses the wire. Routed through
1913 /// the substrate-wide [`crate::tagged_union::assert_display_matches_label`]
1914 /// primitive so the sweep body lives at ONE substrate site rather
1915 /// than restated per-implementor. Also exercised through the
1916 /// substrate-wide `every_production_display_impl_binds_through_the_testkit_primitive`
1917 /// sweep so a per-crate test-site drop cannot silently disable the
1918 /// check.
1919 #[test]
1920 fn arity_display_matches_as_str() {
1921 crate::tagged_union::assert_display_matches_label::<Arity>();
1922 }
1923
1924 /// PREDICATE CONTRACT: `is_one` is true exactly for `Arity::One`.
1925 /// The disjointness against `Many` is structural (only two
1926 /// variants) but pinning the codomain here means a future
1927 /// `Arity::Zero` variant must declare its own `is_one` arm
1928 /// deliberately rather than silently defaulting through a
1929 /// non-closed-set match.
1930 #[test]
1931 fn arity_is_one_predicate_truth_table() {
1932 assert!(Arity::One.is_one());
1933 assert!(!Arity::Many.is_one());
1934 }
1935
1936 // ── closed-set algebra contracts for SubstrateType
1937 // (ALL × as_str × FromStr × predicate triple × bridge) ─────────
1938
1939 /// Structural well-formedness of [`SubstrateType`] as a
1940 /// [`tatara_lisp::ClosedSet`] implementor — see
1941 /// [`convergence_point_type_is_well_formed_closed_set`] for the
1942 /// canonical lift narrative. Replaces
1943 /// `substrate_type_all_is_unique_and_complete` +
1944 /// `substrate_type_roundtrip_via_as_str` + the empty-input arm
1945 /// of `unknown_substrate_type_errors`.
1946 #[test]
1947 fn substrate_type_is_well_formed_closed_set() {
1948 tatara_closed_set::assert_closed_set_well_formed::<SubstrateType>();
1949 }
1950
1951 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1952 /// output verbatim for every variant. A future variant rename
1953 /// (or an `as_str` arm typo) lands here at one site, instead of
1954 /// drifting between the typed surface, the CRD enum, and the
1955 /// YAML wire format the reconciler reads from
1956 /// `spec.classification.substrate`.
1957 #[test]
1958 fn substrate_type_as_str_matches_serde() {
1959 crate::tagged_union::assert_label_matches_serde_serialization::<SubstrateType>();
1960 }
1961
1962 /// The Display impl IS `as_str` — pinning this lets future
1963 /// callers reach for either projection without drift. Any
1964 /// operator-facing `substrate={kind}` diagnostic that composes
1965 /// through Display inherits the canonical wire-format string
1966 /// automatically.
1967 #[test]
1968 fn substrate_type_display_matches_as_str() {
1969 crate::tagged_union::assert_display_matches_label::<SubstrateType>();
1970 }
1971
1972 /// `FromStr` rejects strings outside the canonical projection —
1973 /// lowercased / typo / cross-axis-leaked — and the error echoes
1974 /// the input verbatim so the operator-facing diagnostic surfaces
1975 /// the bad value, not a normalized form. The empty-input arm is
1976 /// pinned by [`substrate_type_is_well_formed_closed_set`] via
1977 /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
1978 /// verbatim-echo contract on the [`UnknownSubstrateType`]
1979 /// newtype, which the trait's `make_unknown` can't see.
1980 #[test]
1981 fn unknown_substrate_type_errors() {
1982 for bad in [
1983 "compute", // lowercased
1984 "COMPUTE", // uppercased
1985 "Computte", // typo
1986 "Database", "Steady", // PoolPhase-axis leak
1987 "Pii", // DataClassification-axis leak
1988 "Attested", // ProcessPhase-axis leak
1989 "Gate", // ConvergencePointType-axis leak
1990 "Monotone", // CalmClassification-axis leak
1991 "PromQL", // ConditionKind-axis leak
1992 ] {
1993 let err = SubstrateType::from_str(bad).unwrap_err();
1994 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1995 }
1996 }
1997
1998 // `unknown_substrate_type_message_matches_substrate_convention`
1999 // removed — clause (5) of
2000 // `tatara_closed_set::assert_closed_set_well_formed::<SubstrateType>()`
2001 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
2002 // shape generically (called from
2003 // `substrate_type_is_well_formed_closed_set` above); the
2004 // `SET_LABEL` projection is pinned by
2005 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
2006
2007 /// TRUTH-TABLE CONTRACT: the predicate triple agrees with the
2008 /// documented per-variant plane role. Pinning this table at one
2009 /// site means any future compliance-baseline selector reads the
2010 /// same projection that the reconciler stamps on the CRD.
2011 #[test]
2012 fn substrate_type_predicate_truth_tables() {
2013 // Resource plane: you allocate budgets from it.
2014 for t in [
2015 SubstrateType::Financial,
2016 SubstrateType::Compute,
2017 SubstrateType::Network,
2018 SubstrateType::Storage,
2019 ] {
2020 assert!(t.is_resource(), "{t:?} should be a resource substrate");
2021 assert!(!t.is_policy(), "{t:?} should not be a policy substrate");
2022 assert!(
2023 !t.is_telemetry(),
2024 "{t:?} should not be a telemetry substrate"
2025 );
2026 }
2027
2028 // Policy plane: it gates access for other workloads.
2029 for t in [
2030 SubstrateType::Security,
2031 SubstrateType::Identity,
2032 SubstrateType::Regulatory,
2033 ] {
2034 assert!(!t.is_resource(), "{t:?} should not be a resource substrate");
2035 assert!(t.is_policy(), "{t:?} should be a policy substrate");
2036 assert!(
2037 !t.is_telemetry(),
2038 "{t:?} should not be a telemetry substrate"
2039 );
2040 }
2041
2042 // Telemetry plane: it observes other workloads.
2043 assert!(!SubstrateType::Observability.is_resource());
2044 assert!(!SubstrateType::Observability.is_policy());
2045 assert!(SubstrateType::Observability.is_telemetry());
2046 }
2047
2048 /// COVERAGE CONTRACT: every variant lands in *exactly one* of
2049 /// the three plane buckets — resource, policy, or telemetry.
2050 /// Pins the three buckets at their declared cardinalities (4,
2051 /// 3, 1 — sum to `ALL.len()`) so a future variant lands
2052 /// somewhere deliberately. No variant returns true from more
2053 /// than one predicate; no variant returns false from all three.
2054 #[test]
2055 fn substrate_type_buckets_cover_every_variant() {
2056 let mut resource = 0u32;
2057 let mut policy = 0u32;
2058 let mut telemetry = 0u32;
2059 for t in SubstrateType::ALL {
2060 let buckets = [t.is_resource(), t.is_policy(), t.is_telemetry()];
2061 let hits: u32 = buckets.iter().map(|b| u32::from(*b)).sum();
2062 assert_eq!(
2063 hits, 1,
2064 "{t:?} landed in {hits} buckets: {buckets:?} (must be exactly one)"
2065 );
2066 if t.is_resource() {
2067 resource += 1;
2068 }
2069 if t.is_policy() {
2070 policy += 1;
2071 }
2072 if t.is_telemetry() {
2073 telemetry += 1;
2074 }
2075 }
2076 assert_eq!(
2077 resource, 4,
2078 "resource bucket: Financial + Compute + Network + Storage"
2079 );
2080 assert_eq!(policy, 3, "policy bucket: Security + Identity + Regulatory");
2081 assert_eq!(telemetry, 1, "telemetry bucket: Observability");
2082 assert_eq!(
2083 resource + policy + telemetry,
2084 SubstrateType::ALL.len() as u32
2085 );
2086 }
2087
2088 /// BRIDGE ROUND-TRIP CONTRACT: every variant survives the
2089 /// CRD-facing (`PascalCase`) ↔ tatara-core (`snake_case`)
2090 /// `From` hop. Today the bridge is two hand-written 8-arm
2091 /// matches in this file; pinning the round-trip over `ALL`
2092 /// means a future variant added without extending the bridge
2093 /// fails here at one site instead of drifting between the CRD
2094 /// wire format and the `core::SubstrateType` selector axis
2095 /// that `compliance_binding::PointSelector::BySubstrate`
2096 /// already dispatches against.
2097 #[test]
2098 fn substrate_type_bridge_roundtrip_over_all() {
2099 for t in SubstrateType::ALL {
2100 let core_t: core::SubstrateType = t.into();
2101 let back: SubstrateType = core_t.into();
2102 assert_eq!(back, t, "bridge round-trip failed for {t:?}");
2103 }
2104 }
2105
2106 // ── closed-set algebra contracts for CalmClassification
2107 // (ALL × as_str × FromStr × requires_coordination × bridge) ─────
2108
2109 /// Structural well-formedness of [`CalmClassification`] as a
2110 /// [`tatara_lisp::ClosedSet`] implementor — see
2111 /// [`convergence_point_type_is_well_formed_closed_set`] for the
2112 /// canonical lift narrative. Replaces
2113 /// `calm_classification_all_is_unique_and_complete` +
2114 /// `calm_classification_roundtrip_via_as_str` + the empty-input
2115 /// arm of `unknown_calm_classification_errors`.
2116 #[test]
2117 fn calm_classification_is_well_formed_closed_set() {
2118 tatara_closed_set::assert_closed_set_well_formed::<CalmClassification>();
2119 }
2120
2121 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2122 /// output verbatim for every variant. A future variant rename
2123 /// (or an `as_str` arm typo) lands here at one site, instead of
2124 /// drifting between the typed surface, the CRD enum, and the
2125 /// YAML wire format the reconciler reads from
2126 /// `spec.classification.calm`.
2127 #[test]
2128 fn calm_classification_as_str_matches_serde() {
2129 crate::tagged_union::assert_label_matches_serde_serialization::<CalmClassification>();
2130 }
2131
2132 /// The Display impl IS `as_str` — pinning this lets future
2133 /// callers reach for either projection without drift. Any
2134 /// operator-facing `calm={kind}` diagnostic that composes
2135 /// through Display inherits the canonical wire-format string
2136 /// automatically.
2137 #[test]
2138 fn calm_classification_display_matches_as_str() {
2139 crate::tagged_union::assert_display_matches_label::<CalmClassification>();
2140 }
2141
2142 /// `FromStr` rejects strings outside the canonical projection —
2143 /// lowercased / typo / cross-axis-leaked — and the error echoes
2144 /// the input verbatim so the operator-facing diagnostic surfaces
2145 /// the bad value, not a normalized form. The empty-input arm is
2146 /// pinned by [`calm_classification_is_well_formed_closed_set`]
2147 /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
2148 /// the verbatim-echo contract on the
2149 /// [`UnknownCalmClassification`] newtype, which the trait's
2150 /// `make_unknown` can't see.
2151 #[test]
2152 fn unknown_calm_classification_errors() {
2153 for bad in [
2154 "monotone", // lowercased
2155 "MONOTONE", // uppercased
2156 "Mono", // typo
2157 "non_monotone", // core's snake_case form (must not cross axes)
2158 "non-monotone", // dashed
2159 "Monotonic", // close-typo
2160 "Steady", // PoolPhase-axis leak
2161 "Pii", // DataClassification-axis leak
2162 "Attested", // ProcessPhase-axis leak
2163 "Compute", // SubstrateType-axis leak
2164 "Gate", // ConvergencePointType-axis leak
2165 "PromQL", // ConditionKind-axis leak
2166 ] {
2167 let err = CalmClassification::from_str(bad).unwrap_err();
2168 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2169 }
2170 }
2171
2172 // `unknown_calm_classification_message_matches_substrate_convention`
2173 // removed — clause (5) of
2174 // `tatara_closed_set::assert_closed_set_well_formed::<CalmClassification>()`
2175 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
2176 // shape generically (called from
2177 // `calm_classification_is_well_formed_closed_set` above); the
2178 // `SET_LABEL` projection is pinned by
2179 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
2180
2181 /// CALM-THEOREM TRUTH-TABLE CONTRACT: `requires_coordination`
2182 /// implements the biconditional half of Hellerstein's CALM
2183 /// theorem — `Monotone ⇒ false` and `NonMonotone ⇒ true`.
2184 /// Pinning this table at one site means any future reconciler
2185 /// dispatch that picks between Raft writes and gossip
2186 /// propagation reads the same projection the lattice ordering
2187 /// (`Monotone ≤ NonMonotone`) does. A future variant that
2188 /// flipped this mapping would have to renumber every consumer
2189 /// deliberately rather than silently shipping a non-monotone
2190 /// operation onto the no-coordination path.
2191 #[test]
2192 fn calm_classification_requires_coordination_truth_table() {
2193 assert!(!CalmClassification::Monotone.requires_coordination());
2194 assert!(CalmClassification::NonMonotone.requires_coordination());
2195 }
2196
2197 /// COVERAGE CONTRACT: every variant lands in exactly one of two
2198 /// coordination buckets — no-coordination (`Monotone`) or
2199 /// requires-coordination (`NonMonotone`). Pins the two buckets
2200 /// at their declared cardinalities (1, 1 — sum to `ALL.len()`)
2201 /// so a future variant lands somewhere deliberately. The
2202 /// biconditional structure of the CALM theorem makes this
2203 /// partition exhaustive by construction.
2204 #[test]
2205 fn calm_classification_buckets_cover_every_variant() {
2206 let mut no_coord = 0u32;
2207 let mut coord = 0u32;
2208 for c in CalmClassification::ALL {
2209 if c.requires_coordination() {
2210 coord += 1;
2211 } else {
2212 no_coord += 1;
2213 }
2214 }
2215 assert_eq!(no_coord, 1, "no-coordination bucket: Monotone");
2216 assert_eq!(coord, 1, "requires-coordination bucket: NonMonotone");
2217 assert_eq!(no_coord + coord, CalmClassification::ALL.len() as u32);
2218 }
2219
2220 /// DEFAULT-AGREEMENT CONTRACT: `CalmClassification::default()`
2221 /// returns `Monotone` (the variant tagged `#[default]`) AND that
2222 /// variant lands in the no-coordination bucket. A future
2223 /// `#[default]` rename without flipping the predicate fails
2224 /// here — the default for an under-specified Process must
2225 /// remain the no-coordination side so that an unannotated
2226 /// Process can't silently demand Raft writes the reconciler
2227 /// isn't configured to provide.
2228 #[test]
2229 fn calm_classification_default_is_monotone_no_coordination() {
2230 let c = CalmClassification::default();
2231 assert_eq!(c, CalmClassification::Monotone);
2232 assert!(!c.requires_coordination());
2233 }
2234
2235 /// BRIDGE ROUND-TRIP CONTRACT: every variant survives the
2236 /// CRD-facing (`PascalCase`) ↔ tatara-core (`snake_case`)
2237 /// `From` hop. Today the bridge is two hand-written 2-arm
2238 /// matches in this file; pinning the round-trip over `ALL`
2239 /// means a future variant added without extending the bridge
2240 /// fails here at one site instead of drifting between the CRD
2241 /// wire format and the `core::CalmClassification` selector
2242 /// axis. Closes the asymmetry that pre-lift had a
2243 /// `From<CalmClassification> for core::CalmClassification`
2244 /// forward bridge but no reverse — symmetric to every other
2245 /// classification-axis bridge in this file.
2246 #[test]
2247 fn calm_classification_bridge_roundtrip_over_all() {
2248 for c in CalmClassification::ALL {
2249 let core_c: core::CalmClassification = c.into();
2250 let back: CalmClassification = core_c.into();
2251 assert_eq!(back, c, "bridge round-trip failed for {c:?}");
2252 }
2253 }
2254
2255 // ── closed-set algebra contracts for OptimizationDirection
2256 // (ALL × as_str × FromStr × prefers_lower × is_improvement) ───
2257
2258 /// Structural well-formedness of [`OptimizationDirection`] as a
2259 /// [`tatara_lisp::ClosedSet`] implementor — see
2260 /// [`convergence_point_type_is_well_formed_closed_set`] for the
2261 /// canonical lift narrative. Replaces
2262 /// `optimization_direction_all_is_unique_and_complete` +
2263 /// `optimization_direction_roundtrip_via_as_str` + the empty-
2264 /// input arm of `unknown_optimization_direction_errors`.
2265 #[test]
2266 fn optimization_direction_is_well_formed_closed_set() {
2267 tatara_closed_set::assert_closed_set_well_formed::<OptimizationDirection>();
2268 }
2269
2270 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2271 /// output verbatim for every variant. A future variant rename
2272 /// (or an `as_str` arm typo) lands here at one site, instead of
2273 /// drifting between the typed surface, the CRD enum, and the
2274 /// YAML wire format the reconciler reads from
2275 /// `spec.classification.horizon.direction`.
2276 #[test]
2277 fn optimization_direction_as_str_matches_serde() {
2278 crate::tagged_union::assert_label_matches_serde_serialization::<OptimizationDirection>();
2279 }
2280
2281 /// The Display impl IS `as_str` — pinning this lets future
2282 /// callers reach for either projection without drift. Any
2283 /// operator-facing `direction={kind}` diagnostic that composes
2284 /// through Display inherits the canonical wire-format string
2285 /// automatically.
2286 #[test]
2287 fn optimization_direction_display_matches_as_str() {
2288 crate::tagged_union::assert_display_matches_label::<OptimizationDirection>();
2289 }
2290
2291 /// `FromStr` rejects strings outside the canonical projection —
2292 /// lowercased / typo / cross-axis-leaked — and the error echoes
2293 /// the input verbatim so the operator-facing diagnostic surfaces
2294 /// the bad value, not a normalized form. The empty-input arm is
2295 /// pinned by [`optimization_direction_is_well_formed_closed_set`]
2296 /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
2297 /// the verbatim-echo contract on the
2298 /// [`UnknownOptimizationDirection`] newtype, which the trait's
2299 /// `make_unknown` can't see.
2300 #[test]
2301 fn unknown_optimization_direction_errors() {
2302 for bad in [
2303 "minimize", // lowercased
2304 "MINIMIZE", // uppercased
2305 "Minimze", // typo
2306 "Lower", // synonym, not canonical
2307 "Higher", // synonym, not canonical
2308 "Asc", // wire-leak from sort-order axis
2309 "Desc", // wire-leak from sort-order axis
2310 "Bounded", // HorizonKind-axis leak
2311 "Monotone", // CalmClassification-axis leak
2312 "Steady", // PoolPhase-axis leak
2313 "Pii", // DataClassification-axis leak
2314 "Attested", // ProcessPhase-axis leak
2315 "Compute", // SubstrateType-axis leak
2316 "Gate", // ConvergencePointType-axis leak
2317 "PromQL", // ConditionKind-axis leak
2318 ] {
2319 let err = OptimizationDirection::from_str(bad).unwrap_err();
2320 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2321 }
2322 }
2323
2324 // `unknown_optimization_direction_message_matches_substrate_convention`
2325 // removed — clause (5) of
2326 // `tatara_closed_set::assert_closed_set_well_formed::<OptimizationDirection>()`
2327 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
2328 // shape generically (called from
2329 // `optimization_direction_is_well_formed_closed_set` above); the
2330 // `SET_LABEL` projection is pinned by
2331 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
2332
2333 /// TRUTH-TABLE CONTRACT: `prefers_lower` is the boolean
2334 /// partition `Minimize ⇒ true`, `Maximize ⇒ false`. Pinning this
2335 /// table at one site means any future dispatch on per-direction
2336 /// polarity (rate-window evaluator, breathe-band regression
2337 /// detector) reads the same projection rather than re-deriving
2338 /// from the variant name. Mirrors
2339 /// [`CalmClassification::requires_coordination`]'s truth-table
2340 /// shape.
2341 #[test]
2342 fn optimization_direction_prefers_lower_truth_table() {
2343 assert!(OptimizationDirection::Minimize.prefers_lower());
2344 assert!(!OptimizationDirection::Maximize.prefers_lower());
2345 }
2346
2347 /// COVERAGE CONTRACT: every variant lands in exactly one of two
2348 /// polarity buckets — prefers-lower (`Minimize`) or
2349 /// prefers-higher (`Maximize`). Pins the two buckets at their
2350 /// declared cardinalities (1, 1 — sum to `ALL.len()`) so a
2351 /// future variant lands somewhere deliberately.
2352 #[test]
2353 fn optimization_direction_buckets_cover_every_variant() {
2354 let mut lower = 0u32;
2355 let mut higher = 0u32;
2356 for d in OptimizationDirection::ALL {
2357 if d.prefers_lower() {
2358 lower += 1;
2359 } else {
2360 higher += 1;
2361 }
2362 }
2363 assert_eq!(lower, 1, "prefers-lower bucket: Minimize");
2364 assert_eq!(higher, 1, "prefers-higher bucket: Maximize");
2365 assert_eq!(lower + higher, OptimizationDirection::ALL.len() as u32);
2366 }
2367
2368 /// LOAD-BEARING TRUTH-TABLE: `is_improvement` answers "is `after`
2369 /// strictly better than `before` under this direction?" for the
2370 /// canonical samples. Pins the strict-improvement semantic at
2371 /// one site so a future rate-window evaluator or breathe-band
2372 /// regression detector reads the same projection that the
2373 /// asymptotic-health probe writes.
2374 #[test]
2375 fn optimization_direction_is_improvement_truth_table() {
2376 // Minimize: lower-is-better
2377 assert!(OptimizationDirection::Minimize.is_improvement(10.0, 5.0));
2378 assert!(!OptimizationDirection::Minimize.is_improvement(5.0, 10.0));
2379
2380 // Maximize: higher-is-better
2381 assert!(OptimizationDirection::Maximize.is_improvement(5.0, 10.0));
2382 assert!(!OptimizationDirection::Maximize.is_improvement(10.0, 5.0));
2383 }
2384
2385 /// NO-OP CONTRACT: a sample equal to the previous one is NOT an
2386 /// improvement under either direction. Pinning this guarantees
2387 /// a flatlined rate-window evaluator doesn't silently keep
2388 /// claiming "still improving" forever and skipping the
2389 /// healthy-rate-threshold gate.
2390 #[test]
2391 fn optimization_direction_no_op_is_not_improvement() {
2392 for d in OptimizationDirection::ALL {
2393 assert!(
2394 !d.is_improvement(7.0, 7.0),
2395 "{d:?}: equal samples must not count as improvement",
2396 );
2397 assert!(
2398 !d.is_improvement(0.0, 0.0),
2399 "{d:?}: zero/zero must not count as improvement",
2400 );
2401 }
2402 }
2403
2404 /// NaN CONTRACT: NaN on either operand short-circuits to `false`
2405 /// (no improvement claim from indeterminate data) via the
2406 /// standard `PartialOrd` behavior. Without this, a rate-window
2407 /// evaluator that sampled a NaN partway through (a transient
2408 /// metric-scrape failure) would either panic on an `Ord`
2409 /// comparison or — worse — silently claim improvement on the
2410 /// next valid sample by treating NaN as the worst case.
2411 #[test]
2412 fn optimization_direction_nan_is_not_improvement() {
2413 let nan = f64::NAN;
2414 for d in OptimizationDirection::ALL {
2415 assert!(
2416 !d.is_improvement(nan, 1.0),
2417 "{d:?}: NaN before must not count as improvement",
2418 );
2419 assert!(
2420 !d.is_improvement(1.0, nan),
2421 "{d:?}: NaN after must not count as improvement",
2422 );
2423 assert!(
2424 !d.is_improvement(nan, nan),
2425 "{d:?}: NaN/NaN must not count as improvement",
2426 );
2427 }
2428 }
2429
2430 /// ANTISYMMETRY CONTRACT: for distinct finite samples,
2431 /// `is_improvement(a, b)` xor `is_improvement(b, a)` —
2432 /// exactly one direction of the pair counts as improvement.
2433 /// This is the algebraic shape every asymptotic-health
2434 /// rate-window evaluator depends on to avoid double-counting
2435 /// an improvement as a regression on the reverse traversal.
2436 /// A future variant that returned `true` for both directions
2437 /// (or `false` for both, the equal-sample case) would FAIL
2438 /// here, forcing the author to extend the consumer dispatch
2439 /// deliberately.
2440 #[test]
2441 fn optimization_direction_is_improvement_is_antisymmetric() {
2442 let pairs = [(1.0_f64, 2.0_f64), (0.0, 100.0), (-3.5, 3.5), (1e9, 1e-9)];
2443 for d in OptimizationDirection::ALL {
2444 for (a, b) in pairs {
2445 assert!(a != b, "test fixture requires distinct samples");
2446 assert!(
2447 d.is_improvement(a, b) ^ d.is_improvement(b, a),
2448 "{d:?}: antisymmetry violated on ({a}, {b})",
2449 );
2450 }
2451 }
2452 }
2453
2454 /// DEFAULT-AGREEMENT CONTRACT:
2455 /// `OptimizationDirection::default()` returns `Minimize` (the
2456 /// variant tagged `#[default]`), AND that variant lands in the
2457 /// prefers-lower bucket. A future `#[default]` rename without
2458 /// flipping the predicate fails here — `Minimize` is the
2459 /// canonical default for distributed-systems asymptotic
2460 /// optimization (cost / latency / error rate), so an
2461 /// unannotated metric must not silently flip the rate-window
2462 /// evaluator's polarity. This is also the same value the
2463 /// `Horizon → ConvergenceHorizon` bridge falls back to when
2464 /// `direction` is unset, so pinning the default here pins the
2465 /// bridge's behavior at one site.
2466 #[test]
2467 fn optimization_direction_default_is_minimize_prefers_lower() {
2468 let d = OptimizationDirection::default();
2469 assert_eq!(d, OptimizationDirection::Minimize);
2470 assert!(d.prefers_lower());
2471 }
2472
2473 /// BRIDGE ROUND-TRIP CONTRACT: every variant survives the
2474 /// CRD-facing (`PascalCase`) ↔ tatara-core (`snake_case`)
2475 /// `From` hop. Pre-lift the bridge was a one-way
2476 /// `From<OptimizationDirection> for core::OptimizationDirection`
2477 /// with no reverse — asymmetric to every other classification-
2478 /// axis bridge in this file. Pinning the round-trip over `ALL`
2479 /// means a future variant added without extending the bridge
2480 /// fails here at one site instead of drifting between the CRD
2481 /// wire format and `core::OptimizationDirection`.
2482 #[test]
2483 fn optimization_direction_bridge_roundtrip_over_all() {
2484 for d in OptimizationDirection::ALL {
2485 let core_d: core::OptimizationDirection = d.into();
2486 let back: OptimizationDirection = core_d.into();
2487 assert_eq!(back, d, "bridge round-trip failed for {d:?}");
2488 }
2489 }
2490
2491 // ── closed-set algebra contracts for HorizonKind
2492 // (ALL × as_str × FromStr × terminates × requires_metric_axes) ──
2493
2494 /// Structural well-formedness of [`HorizonKind`] as a
2495 /// [`tatara_lisp::ClosedSet`] implementor — see
2496 /// [`convergence_point_type_is_well_formed_closed_set`] for the
2497 /// canonical lift narrative. Replaces
2498 /// `horizon_kind_all_is_unique_and_complete` +
2499 /// `horizon_kind_roundtrip_via_as_str` + the empty-input arm of
2500 /// `unknown_horizon_kind_errors`.
2501 #[test]
2502 fn horizon_kind_is_well_formed_closed_set() {
2503 tatara_closed_set::assert_closed_set_well_formed::<HorizonKind>();
2504 }
2505
2506 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2507 /// output verbatim for every variant. A future variant rename
2508 /// (or an `as_str` arm typo) lands here at one site, instead of
2509 /// drifting between the typed surface, the CRD enum, and the
2510 /// YAML wire format the reconciler stamps on
2511 /// `spec.classification.horizon.kind`.
2512 #[test]
2513 fn horizon_kind_as_str_matches_serde() {
2514 crate::tagged_union::assert_label_matches_serde_serialization::<HorizonKind>();
2515 }
2516
2517 /// The Display impl IS `as_str` — pinning this lets future
2518 /// callers reach for either projection without drift. Any
2519 /// operator-facing `horizon.kind={kind}` diagnostic that
2520 /// composes through Display inherits the canonical wire-format
2521 /// string automatically.
2522 #[test]
2523 fn horizon_kind_display_matches_as_str() {
2524 crate::tagged_union::assert_display_matches_label::<HorizonKind>();
2525 }
2526
2527 /// `FromStr` rejects strings outside the canonical projection —
2528 /// lowercased / typo / cross-axis-leaked — and the error echoes
2529 /// the input verbatim so the operator-facing diagnostic surfaces
2530 /// the bad value, not a normalized form. The empty-input arm is
2531 /// pinned by [`horizon_kind_is_well_formed_closed_set`] via the
2532 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
2533 /// verbatim-echo contract on the [`UnknownHorizonKind`] newtype,
2534 /// which the trait's `make_unknown` can't see.
2535 #[test]
2536 fn unknown_horizon_kind_errors() {
2537 for bad in [
2538 "bounded", // lowercased
2539 "BOUNDED", // uppercased
2540 "Boundd", // typo
2541 "Finite", // synonym, not canonical
2542 "Perpetual", // synonym, not canonical
2543 "Infinite", // synonym, not canonical
2544 "Minimize", // OptimizationDirection-axis leak
2545 "Monotone", // CalmClassification-axis leak
2546 "Pii", // DataClassification-axis leak
2547 "Steady", // PoolPhase-axis leak
2548 "Attested", // ProcessPhase-axis leak
2549 "Compute", // SubstrateType-axis leak
2550 "Gate", // ConvergencePointType-axis leak
2551 "PromQL", // ConditionKind-axis leak
2552 ] {
2553 let err = HorizonKind::from_str(bad).unwrap_err();
2554 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2555 }
2556 }
2557
2558 // `unknown_horizon_kind_message_matches_substrate_convention`
2559 // removed — clause (5) of
2560 // `tatara_closed_set::assert_closed_set_well_formed::<HorizonKind>()`
2561 // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
2562 // shape generically (called from
2563 // `horizon_kind_is_well_formed_closed_set` above); the
2564 // `SET_LABEL` projection is pinned by
2565 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
2566
2567 /// LOAD-BEARING TRUTH-TABLE: `terminates` is the boolean
2568 /// partition `Bounded ⇒ true`, `Asymptotic ⇒ false`. Pinning
2569 /// this table at one site means any future scheduler asking
2570 /// "will this Process reach `Reaped` via natural termination?"
2571 /// reads the same projection that the lattice ordering encodes
2572 /// (Bounded ≤ Asymptotic BECAUSE the bounded horizon strictly
2573 /// refines the asymptotic one by also terminating).
2574 #[test]
2575 fn horizon_kind_terminates_truth_table() {
2576 assert!(HorizonKind::Bounded.terminates());
2577 assert!(!HorizonKind::Asymptotic.terminates());
2578 }
2579
2580 /// LOAD-BEARING TRUTH-TABLE: `requires_metric_axes` is the
2581 /// boolean partition `Bounded ⇒ false`, `Asymptotic ⇒ true` —
2582 /// the typed image of the optionality the [`Horizon`] struct
2583 /// encodes via its three `Option<…>` fields (`metric`,
2584 /// `direction`, `healthy_rate_threshold`). The implicit
2585 /// "Asymptotic only" invariant in the field docs is now a
2586 /// checkable per-kind predicate. Pinning this table at one site
2587 /// means any future horizon-shape validator (CRD admission,
2588 /// `tatara-check` form linter, Lisp authoring-time predicate)
2589 /// reads the same projection.
2590 #[test]
2591 fn horizon_kind_requires_metric_axes_truth_table() {
2592 assert!(!HorizonKind::Bounded.requires_metric_axes());
2593 assert!(HorizonKind::Asymptotic.requires_metric_axes());
2594 }
2595
2596 /// COVERAGE CONTRACT: every variant lands in exactly one of two
2597 /// termination buckets — terminating (`Bounded`) or perpetual
2598 /// (`Asymptotic`). Pins the two buckets at their declared
2599 /// cardinalities (1, 1 — sum to `ALL.len()`) so a future variant
2600 /// lands somewhere deliberately.
2601 #[test]
2602 fn horizon_kind_buckets_cover_every_variant() {
2603 let mut terminating = 0u32;
2604 let mut perpetual = 0u32;
2605 for k in HorizonKind::ALL {
2606 if k.terminates() {
2607 terminating += 1;
2608 } else {
2609 perpetual += 1;
2610 }
2611 }
2612 assert_eq!(terminating, 1, "terminating bucket: Bounded");
2613 assert_eq!(perpetual, 1, "perpetual bucket: Asymptotic");
2614 assert_eq!(terminating + perpetual, HorizonKind::ALL.len() as u32);
2615 }
2616
2617 /// ANTISYMMETRY CONTRACT: for every variant, exactly one of
2618 /// `(terminates, requires_metric_axes)` is true — the two
2619 /// predicates carve the variants into complementary buckets
2620 /// (terminating ↔ no metric axes; perpetual ↔ requires metric
2621 /// axes). A future variant that returned `true` for both (a
2622 /// terminating horizon that nonetheless tracks an asymptotic
2623 /// metric) or `false` for both (an inert horizon with no
2624 /// termination AND no metric signal — there'd be nothing to
2625 /// observe) would fail here, forcing the author to extend
2626 /// either the predicates or the [`Horizon`] struct's
2627 /// optionality contract deliberately.
2628 #[test]
2629 fn horizon_kind_terminate_xor_requires_metric_axes() {
2630 for k in HorizonKind::ALL {
2631 assert!(
2632 k.terminates() ^ k.requires_metric_axes(),
2633 "{k:?}: terminates() XOR requires_metric_axes() must hold",
2634 );
2635 }
2636 }
2637
2638 /// DEFAULT-AGREEMENT CONTRACT: `HorizonKind::default()` returns
2639 /// `Bounded` (the variant tagged `#[default]`), AND that
2640 /// variant lands in the terminating bucket. A future
2641 /// `#[default]` rename without flipping the predicate fails
2642 /// here — `Bounded` is the canonical default for a convergence
2643 /// horizon (a point with no asymptotic axes declared should
2644 /// terminate naturally, not silently flip into a perpetual
2645 /// rate-window evaluator with zero threshold). This is also
2646 /// the same value `Horizon::default()` carries, so pinning the
2647 /// default here pins the struct-default behavior at one site.
2648 #[test]
2649 fn horizon_kind_default_is_bounded_terminates() {
2650 let k = HorizonKind::default();
2651 assert_eq!(k, HorizonKind::Bounded);
2652 assert!(k.terminates());
2653 assert!(!k.requires_metric_axes());
2654 }
2655
2656 /// HORIZON ↔ KIND AGREEMENT: every variant in `HorizonKind::ALL`
2657 /// composes with the existing [`Horizon::bounded`] /
2658 /// [`Horizon::asymptotic`] constructors to produce a `Horizon`
2659 /// whose `kind` matches AND whose `Option<…>` fields agree
2660 /// with `requires_metric_axes`. Pins the implicit contract
2661 /// between the kind discriminator and the optionality at one
2662 /// site — a future kind added without extending either the
2663 /// constructors or `requires_metric_axes` fails here before
2664 /// drifting between the typed surface and the documented
2665 /// "Asymptotic only" field invariant.
2666 #[test]
2667 fn horizon_kind_agrees_with_struct_optionality() {
2668 let bounded = Horizon::bounded();
2669 assert_eq!(bounded.kind, HorizonKind::Bounded);
2670 assert!(!bounded.kind.requires_metric_axes());
2671 assert!(bounded.metric.is_none());
2672 assert!(bounded.direction.is_none());
2673 assert!(bounded.healthy_rate_threshold.is_none());
2674
2675 let asymp = Horizon::asymptotic("p99_latency", OptimizationDirection::Minimize, 0.1);
2676 assert_eq!(asymp.kind, HorizonKind::Asymptotic);
2677 assert!(asymp.kind.requires_metric_axes());
2678 assert!(asymp.metric.is_some());
2679 assert!(asymp.direction.is_some());
2680 assert!(asymp.healthy_rate_threshold.is_some());
2681 }
2682}