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