nmbrs_runtime/phase_outcome.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-76 — Phase Outcome Disposition.
5//!
6//! Structured per-phase terminal-state record. One canonical
7//! shape carries both the per-phase status (Completed /
8//! Failed / Skipped / CursorSuspended) and the chronological
9//! error list, so the realtime status surface AND `nmbrs
10//! replay` consume from one projection.
11//!
12//! This module is Push 1 of the SRD-76 migration plan: it
13//! defines the data model and provides the no-side-effects
14//! API. Push 2 wires the executor's per-phase population;
15//! Push 3 adds sqlite persistence; Push 4 adds the new
16//! Readouts; Push 5 wires `nmbrs replay`.
17//!
18//! Cross-refs:
19//! - SRD-03 §"Error scoping" — error-class strings here
20//! match the strings the `errors:` policy routes on.
21//! - SRD-44 §"Phase identity" — `PhaseIdentity` matches the
22//! checkpoint writer's identity tuple.
23//! - SRD-68 §"I-6 Workload-load pre-flight is non-mutating"
24//! — `op_template` is the operator's pristine YAML
25//! verbatim; `op_resolved` is the wire-rendered form.
26//! - SRD-63 — The Readout layer that will render this in
27//! Push 4.
28//! - SRD-75 — Phase-poll; `poll_timeout` is one of the
29//! error classes that lands here.
30
31use serde::{Deserialize, Serialize};
32use std::sync::Arc;
33
34/// SRD-82 Part 1 — **how much** of a shell's work ran. Orthogonal to
35/// [`Validity`]: a result can be `Interrupted` yet `Succeeded`
36/// (re-usable partial progress) or `Completed` yet `Failed` (ran fully
37/// but the result is garbage).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Disposition {
41 /// In flight (not a terminal outcome).
42 Running,
43 /// Ran to its natural end.
44 Completed,
45 /// Stopped before its natural end (a signal, a stop condition).
46 Interrupted,
47 /// Never started (filtered / unreached / resume-skipped).
48 Skipped,
49}
50
51/// SRD-82 Part 1 — whether the produced result is **usable**.
52/// Orthogonal to [`Disposition`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Validity {
56 /// The result is trustworthy.
57 Succeeded,
58 /// The result is not trustworthy — do not rely on it.
59 Failed,
60}
61
62/// SRD-92 / ExecUnification — the opaque return **payload** a unit may carry
63/// on its [`Outcome`], above the leaf-projection boundary.
64///
65/// A scaffold-owned **marker** (no methods yet — the loop/poll read-path is a
66/// later step): the op leaf fills it, aggregates leave it `None`. Kept neutral
67/// (not the adapter's [`crate::adapter::ResultBody`]) so the universal return
68/// type does not depend on the adapter result trait, and a future aggregate
69/// fold-summary can implement `Payload` honestly without faking row-count
70/// methods. `Send + Sync + 'static` keeps [`Outcome`] spawn / `ArcSwap` /
71/// `Mutex` safe; `Debug` keeps `Outcome`'s derive working.
72pub trait Payload: Send + Sync + std::fmt::Debug + 'static {}
73
74/// Every adapter [`crate::adapter::ResultBody`] is a [`Payload`]. A **blanket**
75/// impl (not a subtrait) so it covers `ResultBody` impls in the adapter crates
76/// and tests too with **zero cross-crate churn** — the impl lives here, where
77/// `Payload` is defined. A neutral fold-summary type may still `impl Payload`
78/// directly (no overlap, since it is not a `ResultBody`). Trade-off: a blanket
79/// gives no `dyn`-upcasting, so Step 2 lifts the *erased* `Box<dyn ResultBody>`
80/// via a thin wrapper rather than an `Arc<dyn ResultBody> -> Arc<dyn Payload>`
81/// coercion.
82impl<T: ?Sized + crate::adapter::ResultBody + 'static> Payload for T {}
83
84/// SRD-82 Part 1 — the two-axis result of any execution shell, and the
85/// `effect` of an SRD-83 stop condition. The four meaningful
86/// quadrants: `Completed+Succeeded` (clean), `Completed+Failed` (ran
87/// fully, garbage), `Interrupted+Succeeded` (partial, re-usable),
88/// `Interrupted+Failed` (partial, discard).
89///
90/// `PartialEq` is hand-written (control facts only); `Eq` is dropped — see the
91/// `impl PartialEq` below.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Outcome {
94 pub disposition: Disposition,
95 pub validity: Validity,
96 /// SRD-92 — optional human reason (the first failing child's message),
97 /// absorbing the parallel `(Outcome, Option<String>)` tuple the scenario
98 /// shell carries today. `None` for clean outcomes; set via
99 /// [`Outcome::with_reason`]. Dropping `Copy` (a `String` is not `Copy`)
100 /// is the cost — by-value reuses become moves/clones. Serialized only
101 /// when present, so axis-only `Outcome` JSON round-trips unchanged.
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub reason: Option<String>,
104 /// SRD-92 / ExecUnification — the optional opaque return **payload** a unit
105 /// may carry above the leaf-projection boundary (the op leaf fills it via
106 /// [`Outcome::with_payload`]; aggregates leave it `None`). `Arc` (not `Box`)
107 /// so `Outcome` stays `Clone`; `#[serde(skip)]` because it is runtime-only,
108 /// so axis-only JSON round-trips unchanged. Excluded from `PartialEq` — it
109 /// is data, not control identity. Its *contents* are the deferred data layer.
110 #[serde(skip)]
111 pub payload: Option<Arc<dyn Payload>>,
112}
113
114impl Outcome {
115 pub const fn new(disposition: Disposition, validity: Validity) -> Self {
116 Self {
117 disposition,
118 validity,
119 reason: None,
120 payload: None,
121 }
122 }
123 /// Ran fully, result trustworthy.
124 pub const fn completed() -> Self {
125 Self::new(Disposition::Completed, Validity::Succeeded)
126 }
127 /// Stopped early, result untrustworthy — the SRD-83 `fail` effect
128 /// / a `StopCause::Fault`.
129 pub const fn failed() -> Self {
130 Self::new(Disposition::Interrupted, Validity::Failed)
131 }
132 /// SRD-92 — ran fully but the result is untrustworthy (a ran-and-errored
133 /// op): `Completed+Failed`. Distinct from [`Outcome::failed`]
134 /// (`Interrupted+Failed` — stopped early by a fault).
135 pub const fn completed_failed() -> Self {
136 Self::new(Disposition::Completed, Validity::Failed)
137 }
138 /// Stopped early, partial result usable — the SRD-83 `stop` effect
139 /// / a `StopCause::Interrupt` (e.g. user Ctrl-C, budget met).
140 pub const fn interrupted() -> Self {
141 Self::new(Disposition::Interrupted, Validity::Succeeded)
142 }
143 /// Never started.
144 pub const fn skipped() -> Self {
145 Self::new(Disposition::Skipped, Validity::Succeeded)
146 }
147
148 /// SRD-92 — attach the human reason (the failing child's message). Used
149 /// by the flow-Outcome-up step so the leaf shells / aggregate fold carry
150 /// the message that the `?`-propagation path needs, retiring the parallel
151 /// `(Outcome, Option<String>)` tuple.
152 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
153 self.reason = Some(reason.into());
154 self
155 }
156
157 /// Binary pass/fail projection for session-level counting: the
158 /// only red mark is an untrustworthy result. `Interrupted +
159 /// Succeeded` (Ctrl-C, budget met, cursor-suspend) and `Skipped`
160 /// are non-failures from the operator's perspective.
161 pub fn is_failure(&self) -> bool {
162 matches!(self.validity, Validity::Failed)
163 }
164
165 /// Glyph for compact rendering. One per meaningful axis pair
166 /// (SRD-82 Part 1): ✓ ran-and-trustworthy, ✗ untrustworthy
167 /// (whether it ran fully or not), … re-usable partial progress,
168 /// ~ never started, ⋯ still in flight.
169 pub fn glyph(&self) -> char {
170 match (self.disposition, self.validity) {
171 (Disposition::Running, _) => '⋯',
172 (Disposition::Skipped, _) => '~',
173 (_, Validity::Failed) => '✗',
174 (Disposition::Completed, Validity::Succeeded) => '✓',
175 (Disposition::Interrupted, Validity::Succeeded) => '…',
176 }
177 }
178
179 /// All-lowercase short label, stable for log lines / CI greps /
180 /// the sqlite `status` column. The axes yield five terminal
181 /// labels; `interrupted` subsumes the retired `cursor_suspended`
182 /// (a resume cursor on the outcome carries the resumability
183 /// signal — SRD-82 Part 1).
184 pub fn label(&self) -> &'static str {
185 match (self.disposition, self.validity) {
186 (Disposition::Running, _) => "running",
187 (Disposition::Skipped, _) => "skipped",
188 (Disposition::Completed, Validity::Succeeded) => "completed",
189 (Disposition::Completed, Validity::Failed) => "completed_failed",
190 (Disposition::Interrupted, Validity::Succeeded) => "interrupted",
191 (Disposition::Interrupted, Validity::Failed) => "failed",
192 }
193 }
194
195 /// SRD-92 / ExecUnification — attach the opaque return [`Payload`] (the op
196 /// leaf's body, lifted at the projection boundary in a later step).
197 /// Aggregates never call this — the payload rides the leaf's `Outcome` and
198 /// is ignored by the fold.
199 pub fn with_payload(mut self, payload: Arc<dyn Payload>) -> Self {
200 self.payload = Some(payload);
201 self
202 }
203}
204
205/// SRD-92 / ExecUnification — equality over the CONTROL FACTS only
206/// (`disposition`, `validity`, `reason`). The `payload` is intentionally
207/// excluded: it is data, not control identity, so two outcomes that differ
208/// only in their payload compare equal. Do NOT "fix" this to compare payloads —
209/// `dyn Payload` is not `PartialEq`, and aggregation / tests key on control
210/// facts, never the payload. (`Eq` is dropped: it is unused — no
211/// `HashSet`/`HashMap<Outcome>` exists — and a `dyn` field can't satisfy it.)
212impl PartialEq for Outcome {
213 fn eq(&self, other: &Self) -> bool {
214 self.disposition == other.disposition
215 && self.validity == other.validity
216 && self.reason == other.reason
217 }
218}
219
220/// Session-wide pass/fail disposition. Projected from the
221/// per-phase statuses by walking every populated
222/// [`PhaseOutcome`] on the scene tree.
223///
224/// `SessionDisposition` is the single source of truth for
225/// "what happened?" — drives the process exit code, the
226/// terminating status line, the `nmbrs replay` header, and
227/// the (future) `session_disposition` readout in
228/// SRD-63's `on_session_end` slot.
229///
230/// SRD-76 §"SessionDisposition" — past callers each did
231/// their own ad-hoc walk over phase state with subtly
232/// different rules (was Skipped a pass? was Ctrl-C a
233/// failure?). This enum centralises the answer.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum SessionDisposition {
237 /// Every phase that ran terminated cleanly
238 /// (Completed, Skipped, or CursorSuspended).
239 /// Interrupted-via-signal sessions where no phase
240 /// actually failed land here — interrupted ≠ failed
241 /// from the operator's perspective.
242 Success,
243 /// At least one phase's outcome carries `Validity::Failed`. The
244 /// realtime status surface and `nmbrs replay` render
245 /// this in red; CI / scripted callers observe via the
246 /// process exit code (non-zero) and the JSON output.
247 Failure,
248}
249
250impl SessionDisposition {
251 /// Process exit code for this disposition. 0 for
252 /// success; non-zero (today `1`) for failure.
253 pub fn exit_code(&self) -> i32 {
254 match self {
255 SessionDisposition::Success => 0,
256 SessionDisposition::Failure => 1,
257 }
258 }
259
260 /// Uppercase short label suitable for the terminating
261 /// status line: `session: idx_sweep (SUCCESS in …)`
262 /// vs. `session: idx_sweep (FAILURE: N phases failed)`.
263 pub fn label(&self) -> &'static str {
264 match self {
265 SessionDisposition::Success => "SUCCESS",
266 SessionDisposition::Failure => "FAILURE",
267 }
268 }
269}
270
271/// Phase identity tuple per SRD-44 §"Phase identity" —
272/// `(name, labels)`. The combination is unique within a
273/// session: even sweep cells that share a phase name have
274/// distinct labels (the striated coord path).
275#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
276pub struct PhaseIdentity {
277 pub name: String,
278 /// Striated label path — the `(profile=…), (sm=…), …`
279 /// form the executor produces via
280 /// `format_scope_coordinate_path`. Empty for
281 /// non-iter phases.
282 pub labels: String,
283}
284
285impl PhaseIdentity {
286 pub fn new(name: impl Into<String>, labels: impl Into<String>) -> Self {
287 Self {
288 name: name.into(),
289 labels: labels.into(),
290 }
291 }
292}
293
294/// One error recorded against a phase. Captures the
295/// originating cycle (when an op error), the dispenser's
296/// pristine + resolved op text (per SRD-68), and the
297/// class string the `errors:` policy matches on.
298///
299/// `op_name` / `cycle` / `op_template` / `op_resolved`
300/// are `None` when the error originates outside any
301/// dispenser — workload-load validation failures,
302/// phase-poll deadlines (SRD-75), missing-adapter init
303/// errors, etc.
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct PhaseErrorDetail {
306 /// Error class string (matches the `errors:` policy
307 /// vocabulary): `Timeout`, `cql_error`, `poll_timeout`,
308 /// `validate_failure`, `BindError`, …
309 pub class: String,
310 /// Human-readable message — the detail an operator
311 /// needs to act. Multi-line allowed.
312 pub message: String,
313 /// Op identity (template name) that triggered this
314 /// error. `None` for phase-level errors.
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub op_name: Option<String>,
317 /// Cycle number for op errors. `None` for
318 /// phase-level errors.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub cycle: Option<u64>,
321 /// Pristine op-template text per SRD-68 §"I-6".
322 /// Operator's YAML verbatim, with `{name}` placeholders
323 /// intact.
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub op_template: Option<String>,
326 /// Wire-rendered op text for the failing cycle. What
327 /// the adapter actually sent. `None` when no render
328 /// attempt happened (pre-dispense errors).
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub op_resolved: Option<String>,
331 /// Nanos-since-epoch when the error was recorded.
332 /// Used for chronological replay rendering and
333 /// cross-correlation with metric snapshots.
334 pub at_nanos: u64,
335 /// Whether the underlying error was classified as
336 /// retryable. Useful for diagnostics: a workload with
337 /// 100 retryable errors reads differently from one
338 /// with 1 fatal error.
339 #[serde(default)]
340 pub retryable: bool,
341}
342
343/// SRD-44 cursor-resume state. Stub for Push 1 (the
344/// resume machinery is independent of this SRD); future
345/// pushes populate it with the actual restart-cursor
346/// payload.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
348pub struct ResumeCursor {
349 /// Opaque source-factory state to resume from. The
350 /// concrete shape is owned by SRD-44's checkpoint
351 /// layer; SRD-76 just carries it through. Empty when
352 /// no resume state is recorded.
353 #[serde(default)]
354 pub opaque: Vec<u8>,
355}
356
357/// SRD-76 — complete description of how a single phase
358/// ended. Built once at phase end by the executor,
359/// installed on the scene tree, optionally persisted to
360/// sqlite (Push 3), and rendered via the SRD-76 readouts
361/// (Push 4).
362/// SRD-83 (C3) — the closed vocabulary of failure-reason classes on a
363/// phase outcome. Derived from the first `PhaseErrorDetail.class` (see
364/// [`PhaseOutcome::reason_class`]); the stable strings below are the
365/// sqlite `reason_class` column values and the report-layer grouping
366/// keys.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum ReasonClass {
369 /// A governance `timeout:` (or an SRD-75 poll timeout) expired —
370 /// the protocol OUT-OF-RANGE disposition.
371 Timeout,
372 /// A declared `stop_when` condition with a failing effect tripped
373 /// (includes the synthesized `error_rate_exceeded` guard).
374 StopCondition,
375 /// Op/dispatch errors stopped the phase.
376 Error,
377 /// A panic was caught and routed as an error.
378 Panic,
379 /// An operator action (Ctrl-C / stop request) cut the phase.
380 Operator,
381}
382
383impl ReasonClass {
384 /// The stable serialized token (sqlite column value, report key).
385 pub fn as_str(&self) -> &'static str {
386 match self {
387 ReasonClass::Timeout => "timeout",
388 ReasonClass::StopCondition => "stop_condition",
389 ReasonClass::Error => "error",
390 ReasonClass::Panic => "panic",
391 ReasonClass::Operator => "operator",
392 }
393 }
394
395 /// Classify an error-class string from the trip/error paths. The
396 /// class strings are the established vocabulary: `timeout` (the
397 /// synthesized governance guard), `poll_timeout` (SRD-75),
398 /// `panic` (wrapper-caught), `stop_condition: <when>` /
399 /// `error_rate_exceeded` (SRD-83 trips), `operator` (session
400 /// stop). Anything else is a plain error.
401 pub fn from_error_class(class: &str) -> Self {
402 match class {
403 "timeout" | "poll_timeout" => ReasonClass::Timeout,
404 "panic" => ReasonClass::Panic,
405 "operator" => ReasonClass::Operator,
406 "error_rate_exceeded" => ReasonClass::StopCondition,
407 c if c.starts_with("stop_condition") => ReasonClass::StopCondition,
408 _ => ReasonClass::Error,
409 }
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(from = "PhaseOutcomeWire")]
415pub struct PhaseOutcome {
416 pub phase_id: PhaseIdentity,
417 /// SRD-82 Part 1 — the two axes ARE the stored canonical (the
418 /// storage migration the old `PhaseStatus::to_outcome` doc
419 /// promised). Legacy checkpoint records that carried a single
420 /// `status` string still deserialize via [`PhaseOutcomeWire`].
421 pub disposition: Disposition,
422 pub validity: Validity,
423 /// Wall-clock duration from `phase_starting` to this
424 /// outcome being recorded. `0.0` for skipped phases.
425 pub duration_secs: f64,
426 /// Errors collected during the phase. Empty for
427 /// `Completed` / `Skipped`. Non-empty for `Failed`.
428 /// Chronologically ordered by `at_nanos`.
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub errors: Vec<PhaseErrorDetail>,
431 /// Resume state for the next session. `None` when the
432 /// phase doesn't support cursor-resume.
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub resume_cursor: Option<ResumeCursor>,
435 /// SRD-77 `--scope=changed` — the phase's provenance BASE
436 /// hash (SRD-107: ancestor chain below the session node
437 /// composed with the config digest; param values live in
438 /// [`Self::params_consumed`] instead). Hex-encoded so the
439 /// storage layer can round-trip as TEXT. `None` for
440 /// outcomes recorded before the column was added (legacy
441 /// rows) or for skipped phases that never computed their
442 /// hash.
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub phase_hash: Option<String>,
445 /// SRD-107 — the phase's consumed-params map as canonical
446 /// JSON (`{"name":"<value sha256 hex>",…}`, sorted keys).
447 /// Skip validity's per-param leg: current values of exactly
448 /// these names must digest to these values. `None` on
449 /// legacy rows (whose base hash never matches current
450 /// formulas anyway) and on skipped phases.
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub params_consumed: Option<String>,
453}
454
455/// Deserialization shape for [`PhaseOutcome`]: accepts BOTH the
456/// canonical two-axis form and legacy checkpoint records that carried
457/// the retired single `status` string. The checkpoint JSONL store is
458/// event-sourced and never rewritten (SRD-44a), so records written
459/// before the axes migration must stay readable — resume-skip
460/// identity matching folds over them.
461#[derive(Deserialize)]
462struct PhaseOutcomeWire {
463 phase_id: PhaseIdentity,
464 disposition: Option<Disposition>,
465 validity: Option<Validity>,
466 /// Legacy single-status field (pre-migration records).
467 status: Option<LegacyPhaseStatus>,
468 duration_secs: f64,
469 #[serde(default)]
470 errors: Vec<PhaseErrorDetail>,
471 #[serde(default)]
472 resume_cursor: Option<ResumeCursor>,
473 #[serde(default)]
474 phase_hash: Option<String>,
475 #[serde(default)]
476 params_consumed: Option<String>,
477}
478
479/// The retired conflated status, kept ONLY as a deserialization
480/// target for legacy records. `cursor_suspended` collapses to
481/// `Interrupted + Succeeded` per SRD-82 Part 1 (the record's resume
482/// cursor carries the resumability signal); legacy `failed` maps to
483/// `Interrupted + Failed` (the single status couldn't distinguish it
484/// from `Completed + Failed`).
485#[derive(Deserialize)]
486#[serde(rename_all = "snake_case")]
487enum LegacyPhaseStatus {
488 Completed,
489 Failed,
490 Skipped,
491 CursorSuspended,
492}
493
494impl From<PhaseOutcomeWire> for PhaseOutcome {
495 fn from(w: PhaseOutcomeWire) -> Self {
496 let (disposition, validity) = match (w.disposition, w.validity, w.status) {
497 (Some(d), Some(v), _) => (d, v),
498 (_, _, Some(LegacyPhaseStatus::Completed)) => {
499 (Disposition::Completed, Validity::Succeeded)
500 }
501 (_, _, Some(LegacyPhaseStatus::Failed)) => (Disposition::Interrupted, Validity::Failed),
502 (_, _, Some(LegacyPhaseStatus::Skipped)) => (Disposition::Skipped, Validity::Succeeded),
503 (_, _, Some(LegacyPhaseStatus::CursorSuspended)) => {
504 (Disposition::Interrupted, Validity::Succeeded)
505 }
506 // Neither form present: benign default (a record this
507 // malformed predates both formats).
508 _ => (Disposition::Completed, Validity::Succeeded),
509 };
510 Self {
511 phase_id: w.phase_id,
512 disposition,
513 validity,
514 duration_secs: w.duration_secs,
515 errors: w.errors,
516 resume_cursor: w.resume_cursor,
517 phase_hash: w.phase_hash,
518 params_consumed: w.params_consumed,
519 }
520 }
521}
522
523impl PhaseOutcome {
524 /// Build a Completed outcome. Convenience for the
525 /// happy path; errors must be empty.
526 /// SRD-83 (C3) — machine-readable class of WHY this phase ended
527 /// short of a usable result. DERIVED from the first error's class
528 /// (single source of truth — the trip/error paths already stamp
529 /// it), never stored: legacy outcomes classify identically.
530 /// `None` for any Succeeded validity — a graceful stop or natural
531 /// completion has no failure reason to classify.
532 pub fn reason_class(&self) -> Option<ReasonClass> {
533 match self.validity {
534 Validity::Succeeded => None,
535 Validity::Failed => Some(ReasonClass::from_error_class(
536 self.errors
537 .first()
538 .map(|e| e.class.as_str())
539 .unwrap_or("error"),
540 )),
541 }
542 }
543
544 /// SRD-83 (C3) — the testing-protocol three-way disposition
545 /// (17_/P0.5): COMPLETED (usable result, natural or graceful),
546 /// OUT-OF-RANGE (a governance timeout expired — disqualified at
547 /// this tier), FAILED (anything else), plus SKIPPED for
548 /// resume-skipped phases. Model-level, replacing report-side
549 /// string-matching conventions.
550 pub fn protocol_class(&self) -> &'static str {
551 if matches!(self.disposition, Disposition::Skipped) {
552 return "SKIPPED";
553 }
554 match self.reason_class() {
555 None => "COMPLETED",
556 Some(ReasonClass::Timeout) => "OUT-OF-RANGE",
557 Some(_) => "FAILED",
558 }
559 }
560
561 pub fn completed(phase_id: PhaseIdentity, duration_secs: f64) -> Self {
562 Self {
563 phase_id,
564 disposition: Disposition::Completed,
565 validity: Validity::Succeeded,
566 duration_secs,
567 errors: Vec::new(),
568 resume_cursor: None,
569 phase_hash: None,
570 params_consumed: None,
571 }
572 }
573
574 /// Build a Failed outcome from a non-empty error list.
575 /// Panics if `errors` is empty — `Failed` without an
576 /// error is structurally invalid per SRD-76
577 /// §"Invariants", in every build profile.
578 pub fn failed(
579 phase_id: PhaseIdentity,
580 duration_secs: f64,
581 errors: Vec<PhaseErrorDetail>,
582 ) -> Self {
583 assert!(
584 !errors.is_empty(),
585 "PhaseOutcome::failed requires at least one error"
586 );
587 Self {
588 phase_id,
589 disposition: Disposition::Interrupted,
590 validity: Validity::Failed,
591 duration_secs,
592 errors,
593 resume_cursor: None,
594 phase_hash: None,
595 params_consumed: None,
596 }
597 }
598
599 /// Build a Skipped outcome. Used by SRD-44's
600 /// resume-on-checkpoint skip path. Duration is `0.0`
601 /// because no actual work was done.
602 pub fn skipped(phase_id: PhaseIdentity) -> Self {
603 Self {
604 phase_id,
605 disposition: Disposition::Skipped,
606 validity: Validity::Succeeded,
607 duration_secs: 0.0,
608 errors: Vec::new(),
609 resume_cursor: None,
610 phase_hash: None,
611 params_consumed: None,
612 }
613 }
614
615 /// Build an Interrupted+Succeeded outcome — re-usable partial
616 /// progress (a clean early halt, a cursor suspension). The resume
617 /// cursor, when present, carries the resumability signal that the
618 /// retired `CursorSuspended` status used to encode.
619 pub fn interrupted(
620 phase_id: PhaseIdentity,
621 duration_secs: f64,
622 resume_cursor: Option<ResumeCursor>,
623 ) -> Self {
624 Self {
625 phase_id,
626 disposition: Disposition::Interrupted,
627 validity: Validity::Succeeded,
628 duration_secs,
629 errors: Vec::new(),
630 resume_cursor,
631 phase_hash: None,
632 params_consumed: None,
633 }
634 }
635
636 /// Stamp the GK chain-hash on this outcome. Builder-style
637 /// so existing callers that don't yet have the hash
638 /// available (legacy / partial paths) stay unchanged;
639 /// SRD-77-aware callers chain `.completed(...).with_hash(h)`.
640 pub fn with_phase_hash(mut self, hex_hash: String) -> Self {
641 self.phase_hash = Some(hex_hash);
642 self
643 }
644
645 /// Stamp the SRD-107 consumed-params JSON on this outcome.
646 /// Builder-style, same rationale as [`Self::with_phase_hash`].
647 pub fn with_params_consumed(mut self, json: Option<String>) -> Self {
648 self.params_consumed = json;
649 self
650 }
651
652 /// Convenience: the first error's message, or `None`
653 /// when the phase didn't fail. Used by the compact
654 /// renderer to give an at-a-glance reason.
655 pub fn first_error_message(&self) -> Option<&str> {
656 self.errors.first().map(|e| e.message.as_str())
657 }
658
659 /// SRD-82 Part 1 — the two-axis [`Outcome`], straight from the
660 /// stored axes (they ARE the canonical since the storage
661 /// migration).
662 pub fn outcome(&self) -> Outcome {
663 Outcome::new(self.disposition, self.validity)
664 }
665
666 /// See [`Outcome::is_failure`].
667 pub fn is_failure(&self) -> bool {
668 self.outcome().is_failure()
669 }
670
671 /// See [`Outcome::glyph`].
672 pub fn glyph(&self) -> char {
673 self.outcome().glyph()
674 }
675
676 /// See [`Outcome::label`].
677 pub fn label(&self) -> &'static str {
678 self.outcome().label()
679 }
680
681 /// SRD-76 Push 3 — project this outcome into the
682 /// storage-layer row shape used by
683 /// [`nmbrs_metrics::reporters::sqlite::SqliteReporter::write_phase_outcome`].
684 /// `session` / `exec_id` come from the active session
685 /// (SRD-77); `started_at_nanos` is supplied by the
686 /// caller (the executor captures it at phase entry);
687 /// `ended_at_nanos` is taken from `SystemTime::now()` so
688 /// the on-disk row matches the wall-clock moment the
689 /// outcome was sealed.
690 pub fn to_sqlite_row(
691 &self,
692 session: &str,
693 exec_id: u64,
694 started_at_nanos: i64,
695 ) -> nmbrs_metrics::reporters::sqlite::PhaseOutcomeRow {
696 let ended_at_nanos: i64 = std::time::SystemTime::now()
697 .duration_since(std::time::UNIX_EPOCH)
698 .map(|d| d.as_nanos() as i64)
699 .unwrap_or(started_at_nanos);
700 nmbrs_metrics::reporters::sqlite::PhaseOutcomeRow {
701 session: session.to_string(),
702 exec_id,
703 phase_name: self.phase_id.name.clone(),
704 phase_labels: self.phase_id.labels.clone(),
705 status: self.label().to_string(),
706 duration_secs: self.duration_secs,
707 started_at_nanos,
708 ended_at_nanos,
709 // SRD-83 (C3) — denormalized for report GROUP BY; the
710 // derived accessor is the single source of truth.
711 reason_class: self.reason_class().map(|c| c.as_str().to_string()),
712 phase_hash: self.phase_hash.clone(),
713 params_consumed: self.params_consumed.clone(),
714 errors: self
715 .errors
716 .iter()
717 .map(|e| nmbrs_metrics::reporters::sqlite::PhaseErrorRow {
718 class: e.class.clone(),
719 message: e.message.clone(),
720 op_name: e.op_name.clone(),
721 cycle: e.cycle,
722 op_template: e.op_template.clone(),
723 op_resolved: e.op_resolved.clone(),
724 at_nanos: e.at_nanos as i64,
725 retryable: e.retryable,
726 })
727 .collect(),
728 }
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn is_failure_keys_on_validity_alone() {
738 // SRD-82 Part 1: the red mark is an untrustworthy result,
739 // regardless of how much of the work ran.
740 assert!(!Outcome::completed().is_failure());
741 assert!(Outcome::failed().is_failure());
742 assert!(Outcome::completed_failed().is_failure());
743 assert!(!Outcome::interrupted().is_failure());
744 assert!(!Outcome::skipped().is_failure());
745 }
746
747 #[test]
748 fn glyphs_and_labels_cover_the_axis_pairs() {
749 assert_eq!(Outcome::completed().glyph(), '✓');
750 assert_eq!(Outcome::failed().glyph(), '✗');
751 assert_eq!(Outcome::completed_failed().glyph(), '✗');
752 assert_eq!(Outcome::interrupted().glyph(), '…');
753 assert_eq!(Outcome::skipped().glyph(), '~');
754 assert_eq!(Outcome::completed().label(), "completed");
755 assert_eq!(Outcome::failed().label(), "failed");
756 assert_eq!(Outcome::completed_failed().label(), "completed_failed");
757 assert_eq!(Outcome::interrupted().label(), "interrupted");
758 assert_eq!(Outcome::skipped().label(), "skipped");
759 }
760
761 #[test]
762 fn session_disposition_exit_codes() {
763 assert_eq!(SessionDisposition::Success.exit_code(), 0);
764 assert_ne!(SessionDisposition::Failure.exit_code(), 0);
765 }
766
767 #[test]
768 fn outcome_completed_has_no_errors() {
769 let o = PhaseOutcome::completed(PhaseIdentity::new("p", "x=1"), 1.5);
770 assert_eq!(o.disposition, Disposition::Completed);
771 assert_eq!(o.validity, Validity::Succeeded);
772 assert!(o.errors.is_empty());
773 assert!(o.first_error_message().is_none());
774 }
775
776 #[test]
777 fn outcome_failed_carries_errors() {
778 let errors = vec![PhaseErrorDetail {
779 class: "Timeout".into(),
780 message: "connection timed out".into(),
781 op_name: Some("read_state".into()),
782 cycle: Some(0),
783 op_template: Some("SELECT ...".into()),
784 op_resolved: Some("SELECT * FROM ks.t".into()),
785 at_nanos: 1_000_000_000,
786 retryable: true,
787 }];
788 let o = PhaseOutcome::failed(PhaseIdentity::new("p", "x=1"), 30.0, errors.clone());
789 assert_eq!(o.disposition, Disposition::Interrupted);
790 assert_eq!(o.validity, Validity::Failed);
791 assert_eq!(o.errors, errors);
792 assert_eq!(o.first_error_message(), Some("connection timed out"));
793 }
794
795 #[test]
796 #[should_panic(expected = "at least one error")]
797 fn outcome_failed_requires_non_empty_errors() {
798 let _ = PhaseOutcome::failed(PhaseIdentity::new("p", ""), 1.0, Vec::new());
799 }
800
801 #[test]
802 fn outcome_skipped_has_zero_duration_and_no_errors() {
803 let o = PhaseOutcome::skipped(PhaseIdentity::new("p", ""));
804 assert_eq!(o.disposition, Disposition::Skipped);
805 assert_eq!(o.duration_secs, 0.0);
806 assert!(o.errors.is_empty());
807 }
808
809 /// Round-trip serde so the persistence layer (Push 3
810 /// sqlite, JSON in `nmbrs replay --json`) sees a
811 /// stable shape.
812 #[test]
813 fn outcome_round_trips_through_json() {
814 let original = PhaseOutcome {
815 phase_id: PhaseIdentity::new("ensure_compacted", "(k=10)"),
816 disposition: Disposition::Interrupted,
817 validity: Validity::Failed,
818 duration_secs: 14400.0,
819 errors: vec![PhaseErrorDetail {
820 class: "poll_timeout".into(),
821 message: "deadline reached".into(),
822 op_name: None,
823 cycle: None,
824 op_template: None,
825 op_resolved: None,
826 at_nanos: 0,
827 retryable: false,
828 }],
829 resume_cursor: None,
830 phase_hash: None,
831 params_consumed: None,
832 };
833 let json = serde_json::to_string(&original).expect("serialise");
834 let parsed: PhaseOutcome = serde_json::from_str(&json).expect("deserialise");
835 assert_eq!(parsed, original);
836 }
837
838 #[test]
839 fn two_axis_outcome_projects_to_and_from_status() {
840 // The four constructors land in the right quadrants.
841 assert_eq!(
842 Outcome::completed(),
843 Outcome::new(Disposition::Completed, Validity::Succeeded)
844 );
845 assert_eq!(
846 Outcome::failed(),
847 Outcome::new(Disposition::Interrupted, Validity::Failed)
848 );
849 assert_eq!(
850 Outcome::interrupted(),
851 Outcome::new(Disposition::Interrupted, Validity::Succeeded)
852 );
853
854 // Validity drives is_failure, orthogonal to disposition.
855 assert!(Outcome::failed().is_failure());
856 assert!(!Outcome::interrupted().is_failure());
857 assert!(!Outcome::completed().is_failure());
858
859 // PhaseOutcome surfaces the two-axis view.
860 let oc = PhaseOutcome::completed(PhaseIdentity::new("p", ""), 1.0);
861 assert_eq!(oc.outcome(), Outcome::completed());
862 assert!(!oc.outcome().is_failure());
863 }
864
865 /// The checkpoint JSONL store is event-sourced and never
866 /// rewritten (SRD-44a): records written before the axes
867 /// migration carry a single `status` string and MUST keep
868 /// deserializing — resume-skip identity matching folds over
869 /// them. `cursor_suspended` collapses to Interrupted+Succeeded.
870 #[test]
871 fn legacy_status_records_still_deserialize() {
872 let cases = [
873 ("completed", Disposition::Completed, Validity::Succeeded),
874 ("failed", Disposition::Interrupted, Validity::Failed),
875 ("skipped", Disposition::Skipped, Validity::Succeeded),
876 (
877 "cursor_suspended",
878 Disposition::Interrupted,
879 Validity::Succeeded,
880 ),
881 ];
882 for (status, d, v) in cases {
883 let json = format!(
884 r#"{{"phase_id":{{"name":"p","labels":""}},"status":"{status}","duration_secs":1.0}}"#
885 );
886 let parsed: PhaseOutcome = serde_json::from_str(&json)
887 .unwrap_or_else(|e| panic!("legacy '{status}' must parse: {e}"));
888 assert_eq!(parsed.disposition, d, "disposition for '{status}'");
889 assert_eq!(parsed.validity, v, "validity for '{status}'");
890 }
891 }
892}