Skip to main content

turnframe_test/explore/
report.rs

1//! What an exploration found.
2
3use std::fmt::Write as _;
4
5use serde::{Deserialize, Serialize};
6use turnframe_core::error::{DomainRejection, InteractionSpecError, InvariantViolation};
7use turnframe_core::ids::{OperationKey, WorkflowKey, WorkflowVersion};
8
9use crate::explore::ExplorationLimits;
10
11/// One rule an explored state or transition broke.
12///
13/// The §8.4 rules about phase ownership, terminal outcomes and duplicate
14/// obligation identifiers are reported as [`Self::Projection`]: they come from
15/// [`turnframe_core::flow::check_view`], which is the same check the runtime
16/// runs in production. The other variants are exploration-only rules that need
17/// two projections or a transition to observe.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
19#[serde(tag = "kind", rename_all = "snake_case")]
20#[non_exhaustive]
21pub enum ExplorationViolationKind {
22    /// A projection invariant of spec §8.4 was broken.
23    #[error("projection invariant: {0}")]
24    Projection(InvariantViolation),
25    /// Two projections of the same state produced different obligation
26    /// identifiers, so an obligation cannot be addressed twice in a row.
27    #[error("obligation identifiers are not stable across two projections")]
28    UnstableObligationIds {
29        /// Identifiers of the first projection.
30        first: Vec<String>,
31        /// Identifiers of the second projection.
32        second: Vec<String>,
33    },
34    /// Two projections of the same state differed. Projection must be pure (I2).
35    #[error("projection is not deterministic for this state")]
36    NonDeterministicProjection {
37        /// The first erased view.
38        first: serde_json::Value,
39        /// The second erased view.
40        second: serde_json::Value,
41    },
42    /// The same state projected differently at two case revisions.
43    ///
44    /// A view is a function of the *state*: the revision on the case reference
45    /// says which state was read, and travels into the cards built from the
46    /// view, but it may not change the phase, the obligations, the blocking
47    /// requirement, the notices or the outcome. A projector that reads it — to
48    /// hide an obligation on a fresh case, say, or to phrase a notice
49    /// differently after an edit — makes the map depend on how often the case
50    /// was written, which no caller can reason about.
51    #[error(
52        "the projection of this state changes between revision {left} and revision {right}: {detail}"
53    )]
54    ProjectionVariesWithRevision {
55        /// The first revision projected at.
56        left: u64,
57        /// The second revision projected at.
58        right: u64,
59        /// Which part of the view differs, as a stable label.
60        detail: String,
61    },
62    /// A refused command changed the state it was given.
63    #[error("refused command {command_type} mutated the state it was given")]
64    RejectedCommandMutatedState {
65        /// Serialized command.
66        command: serde_json::Value,
67        /// Stable label of the command, for logs.
68        command_type: String,
69    },
70    /// An applied command changed the state it was given instead of returning a
71    /// new one.
72    #[error("applied command {command_type} mutated the state it was given")]
73    AppliedCommandMutatedInputState {
74        /// Serialized command.
75        command: serde_json::Value,
76        /// Stable label of the command, for logs.
77        command_type: String,
78    },
79    /// `validate_command` refused a command the model then applied. Validation
80    /// and execution disagree, so the executor would commit something the
81    /// runtime believes it blocked.
82    #[error("command {command_type} was applied although validation refused it ({})", rejection.code)]
83    RejectedCommandApplied {
84        /// Serialized command.
85        command: serde_json::Value,
86        /// Stable label of the command, for logs.
87        command_type: String,
88        /// What validation said.
89        rejection: DomainRejection,
90    },
91    /// A state's catalogue offers an operation its own `compile_act` does not
92    /// recognise.
93    ///
94    /// The declaration is in one function and the translation in another, and
95    /// nothing else relates them — so an operation added to the catalogue and
96    /// forgotten in the compiler fails as far downstream as a mistake can. The
97    /// catalogue offers it, the interpreter proposes it correctly with the right
98    /// arguments, and the user is told his request could not be carried out, on
99    /// a sentence that was understood perfectly.
100    ///
101    /// Only a workflow returning
102    /// [`turnframe_core::error::UNKNOWN_OPERATION`] is reported. A refusal for
103    /// any other reason is a domain refusal and is left alone: an act whose
104    /// arguments the explorer could not invent is refused honestly, and a check
105    /// that demanded good arguments would be testing the wrong thing.
106    #[error("the catalogue offers {operation}, which compile_act does not recognise")]
107    CatalogedOperationDoesNotCompile {
108        /// The operation the catalogue offered.
109        operation: OperationKey,
110    },
111    /// The blocking requirement of the phase could not be turned into a card.
112    #[error("the blocking requirement could not be built into a card ({})", rejection.code)]
113    BlockingInteractionNotBuildable {
114        /// Why the workflow refused to build it.
115        rejection: DomainRejection,
116    },
117    /// The card built from the blocking requirement cannot be answered, so the
118    /// phase would block its case forever (I6).
119    #[error("the blocking card cannot be answered: {error}")]
120    BlockingInteractionNotAnswerable {
121        /// What is wrong with the card.
122        error: InteractionSpecError,
123    },
124    /// A non-terminal state offers no candidate command and no blocking
125    /// interaction: the conversation cannot move on from here.
126    #[error("dead end: a non-terminal state with no candidate command and no blocking interaction")]
127    DeadEnd,
128    /// The projector gave an *absent* state a terminal phase or an outcome, so
129    /// it describes a case that ends by disappearing.
130    ///
131    /// A case's identity outlives its content: removal is a status, never an
132    /// absence, and an absent state therefore means *not yet* and never *no
133    /// longer*. A projector that reads absence as completion is indistinguishable
134    /// from one that reads it as a case nobody has started, because the executor
135    /// returns the same `None` for both — so the same view has to serve a
136    /// finished case and a fresh one, and the assistant congratulates the user
137    /// and then asks them to start over. Give the terminal step a status in the
138    /// state instead, the way the traveler sample's `Deleted` does.
139    #[error(
140        "an absent state projects to a terminal phase or an outcome: a case's identity outlives \
141         its content, so removal is a status and an absent state means not yet, never no longer"
142    )]
143    CaseEndsByDisappearing {
144        /// The phase the absent state projected to.
145        phase: serde_json::Value,
146        /// The outcome it carried, when it carried one.
147        outcome: Option<serde_json::Value>,
148    },
149    /// A simulated transition dropped the case: it was given a state and
150    /// returned none.
151    ///
152    /// The other half of [`Self::CaseEndsByDisappearing`], seen from the model
153    /// rather than from the projector. A domain whose working document is
154    /// consumed on success — a draft that becomes a record, an application that
155    /// becomes an account — keeps the case and moves it to a terminal status;
156    /// the record it produced is a different case, in its own workflow.
157    #[error("applied command {command_type} removed the case instead of moving it to a status")]
158    TransitionRemovesCase {
159        /// Serialized command.
160        command: serde_json::Value,
161        /// Stable label of the command, for logs.
162        command_type: String,
163    },
164    /// An outcome the model declares reachable was never projected. Only
165    /// reported when the search ran to completion: see
166    /// [`ExplorationReport::truncated`].
167    #[error("declared outcome was never reached")]
168    UnreachableOutcome {
169        /// The outcome that was never projected.
170        outcome: serde_json::Value,
171    },
172    /// A state could not be serialized, so it cannot be deduplicated or shown.
173    #[error("state could not be serialized")]
174    UnserializableState,
175    /// A command could not be serialized, so it cannot be shown in a path.
176    #[error("command could not be serialized")]
177    UnserializableCommand,
178}
179
180/// A violation together with where it was found.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct ExplorationViolation {
183    /// The rule that was broken.
184    pub kind: ExplorationViolationKind,
185    /// Canonical JSON of the state, `null` when the case does not exist.
186    pub state: serde_json::Value,
187    /// Depth at which the state was first reached.
188    pub depth: usize,
189    /// Shortest command path from an initial state, as canonical JSON.
190    pub path: Vec<serde_json::Value>,
191}
192
193impl ExplorationViolation {
194    /// Renders the violation with its state and path, for a test failure
195    /// message. Unlike [`Display`](std::fmt::Display) on
196    /// [`ExplorationViolationKind`], this includes domain values.
197    #[must_use]
198    pub fn describe(&self) -> String {
199        let mut out = format!("{}\n  state: {}\n  path:", self.kind, self.state);
200        if self.path.is_empty() {
201            out.push_str(" <initial state>");
202        } else {
203            for (step, command) in self.path.iter().enumerate() {
204                let _ = write!(out, "\n    {}. {command}", step + 1);
205            }
206        }
207        out
208    }
209}
210
211/// Everything one bounded exploration observed.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct ExplorationReport {
214    /// The workflow explored.
215    pub workflow: WorkflowKey,
216    /// The version that produced the projections.
217    pub workflow_version: WorkflowVersion,
218    /// The limits the search ran under.
219    pub limits: ExplorationLimits,
220    /// Number of distinct states visited.
221    pub states_explored: usize,
222    /// Number of candidate commands simulated.
223    pub transitions_simulated: usize,
224    /// Greatest depth reached.
225    pub max_depth_reached: usize,
226    /// `true` when a limit stopped the search before the frontier was empty.
227    /// A truncated report proves that nothing it *visited* is broken, not that
228    /// nothing is; the unreachable-outcome check is skipped for it.
229    pub truncated: bool,
230    /// Canonical JSON of every phase projected by a reachable state, in the
231    /// order first reached.
232    ///
233    /// # Why a count of states was not enough
234    ///
235    /// `states_explored` and `truncated` say how much was looked at, never
236    /// **where**. A workflow's eleven required fields are eleven steps of a
237    /// breadth-first search, and the standard budget ran out at depth eight:
238    /// four phases of the workflow — the optional question, the summary, its
239    /// rejection and the promotion — were never projected at all. Every
240    /// invariant reported clean over half a workflow for months, and the
241    /// catalogue check found nothing because it never reached the phase where
242    /// something was wrong.
243    ///
244    /// `truncated` did say so, and it is a blunt instrument: a workflow with a
245    /// large collection half truncates every time, so the flag stops carrying
246    /// information. The question worth asking is whether the search saw every
247    /// phase, and that is [`Self::reached_phase`].
248    pub reached_phases: Vec<serde_json::Value>,
249    /// Canonical JSON of every outcome projected by a reachable state, in the
250    /// order first reached.
251    pub reached_outcomes: Vec<serde_json::Value>,
252    /// Every rule broken, in the order found.
253    pub violations: Vec<ExplorationViolation>,
254}
255
256impl ExplorationReport {
257    /// Returns `true` when nothing was violated.
258    #[must_use]
259    pub fn is_clean(&self) -> bool {
260        self.violations.is_empty()
261    }
262
263    /// Returns `true` when a reachable state projected `phase`.
264    ///
265    /// What turns "no violations found" into "no violations found, and here is
266    /// where I looked". Assert it for every phase a workflow declares, and a
267    /// budget that stops short becomes a failing test instead of a clean one.
268    pub fn reached_phase<T: serde::Serialize + ?Sized>(&self, phase: &T) -> bool {
269        turnframe_core::hash::canonical_value(phase)
270            .is_ok_and(|value| self.reached_phases.contains(&value))
271    }
272
273    /// Returns `true` when a reachable state projected `outcome`.
274    pub fn reached_outcome<T: serde::Serialize + ?Sized>(&self, outcome: &T) -> bool {
275        turnframe_core::hash::canonical_value(outcome)
276            .is_ok_and(|value| self.reached_outcomes.contains(&value))
277    }
278
279    /// A multi-line summary suitable for an assertion message: the counters,
280    /// then every violation with its state and shortest path.
281    #[must_use]
282    pub fn describe(&self) -> String {
283        let mut out = format!(
284            "{} {}: {} states, {} transitions, depth {}{}, {} phase(s) reached, {} violation(s)",
285            self.workflow,
286            self.workflow_version,
287            self.states_explored,
288            self.transitions_simulated,
289            self.max_depth_reached,
290            if self.truncated { " (truncated)" } else { "" },
291            self.reached_phases.len(),
292            self.violations.len(),
293        );
294        for violation in &self.violations {
295            let _ = write!(out, "\n- {}", violation.describe().replace('\n', "\n  "));
296        }
297        out
298    }
299}