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}