amberfork_model/diff.rs
1//! The `DiffResult` output schema — the public `--json` contract and the shape the UI reads.
2//!
3//! Where [`crate`]'s [`Run`](crate::Run) is the *input* seam, `DiffResult` is the *output*
4//! seam: what `amberfork-align` fills in and the `amberfork` CLI / the Leptos UI render. It is transcribed
5//! from the design doc's result schema (`docs/design/design-run-diff-debugger.md`, "Result
6//! schema").
7//!
8//! Two invariants are encoded in the types rather than left to convention:
9//! - A converged diff (the self-align case: a run against itself) has **no fork** — hence
10//! [`DiffResult::fork`] is an `Option`, and its `None` state is the designed converged state.
11//! - A [`Move`] is well-formed only in three shapes (synchronous / log-only / model-only); the
12//! [`Move::sync`], [`Move::log`], and [`Move::model`] constructors are the way to build one so
13//! an aligner can't accidentally emit an illegal index combination.
14
15use crate::{Outcome, SchemaVersion};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// The two runs a diff is over. `a` and `b` are neutral sides; which one failed is carried by
20/// each ref's [`Outcome`], and step indices in [`Move`]/[`Fork`] are relative to these.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct RunPair {
23 pub a: RunRef,
24 pub b: RunRef,
25}
26
27/// A lightweight handle to a run — enough to label a side in the UI without re-embedding the
28/// whole trajectory.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct RunRef {
31 pub id: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub task: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub outcome: Option<Outcome>,
36 /// Number of steps in the run.
37 pub n_steps: usize,
38}
39
40/// The class of an alignment move, in the process-mining sense the aligner uses: a synchronous
41/// move pairs a step from each run; a log/model move is a gap present on only one side. By
42/// convention run `b` is the "log" (observed/failing) and run `a` is the "model" (reference),
43/// so a [`MoveKind::Log`] step is extra in `b` and a [`MoveKind::Model`] step is missing from
44/// `b`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum MoveKind {
48 Sync,
49 Log,
50 Model,
51}
52
53/// One move in the alignment. The invariant tying `kind` to the indices — sync has both, log
54/// has only `b_idx`, model has only `a_idx` — is guaranteed by the [`Move::sync`]/[`Move::log`]
55/// /[`Move::model`] constructors.
56#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
57pub struct Move {
58 pub kind: MoveKind,
59 /// Step index in run `a`, present unless this is a log-only move.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub a_idx: Option<usize>,
62 /// Step index in run `b`, present unless this is a model-only move.
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub b_idx: Option<usize>,
65 /// Alignment cost of this move (lower = more similar). Domain of the cost model.
66 pub cost: f64,
67 /// Confidence in this move, in `[0, 1]`.
68 pub confidence: f64,
69}
70
71impl Move {
72 /// A synchronous move: step `a_idx` in run `a` aligned to step `b_idx` in run `b`.
73 #[must_use]
74 pub fn sync(a_idx: usize, b_idx: usize, cost: f64, confidence: f64) -> Self {
75 Self {
76 kind: MoveKind::Sync,
77 a_idx: Some(a_idx),
78 b_idx: Some(b_idx),
79 cost,
80 confidence,
81 }
82 }
83
84 /// A log-only move: a step present in run `b` with no counterpart in run `a`.
85 #[must_use]
86 pub fn log(b_idx: usize, cost: f64, confidence: f64) -> Self {
87 Self {
88 kind: MoveKind::Log,
89 a_idx: None,
90 b_idx: Some(b_idx),
91 cost,
92 confidence,
93 }
94 }
95
96 /// A model-only move: a step present in run `a` with no counterpart in run `b`.
97 #[must_use]
98 pub fn model(a_idx: usize, cost: f64, confidence: f64) -> Self {
99 Self {
100 kind: MoveKind::Model,
101 a_idx: Some(a_idx),
102 b_idx: None,
103 cost,
104 confidence,
105 }
106 }
107}
108
109/// The divergence point: the first non-sync block the alignment does not recover from
110/// (resync-k rule; see the 2026-07-08 amendment). Absent on a converged diff.
111#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
112pub struct Fork {
113 /// Index into [`DiffResult::alignment`] where the unrecovered divergence begins.
114 pub index: usize,
115 /// Diverging step in run `a`, if the fork has an `a` side.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub a_step: Option<usize>,
118 /// Diverging step in run `b`, if the fork has a `b` side.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub b_step: Option<usize>,
121 /// Confidence in the fork localization, in `[0, 1]`.
122 pub confidence: f64,
123}
124
125/// How a single field changed between two aligned steps.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum FieldDiffKind {
129 /// Present in run `b` but not run `a`.
130 Added,
131 /// Present in run `a` but not run `b`.
132 Removed,
133 /// Present in both, with a different value.
134 Changed,
135}
136
137/// A field-level difference within an aligned step pair (the object/text diff inside a sync
138/// move). Emitted only where the payloads actually differ.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct FieldDiff {
141 /// Index into [`DiffResult::alignment`] identifying the aligned pair this diff refines.
142 pub step: usize,
143 /// Path into the payload, rooted at the slot: `outputs.status` for an object key, or just
144 /// the slot name (`inputs`/`outputs`) when the whole payload is the diff unit (a text
145 /// body, a one-sided slot).
146 pub path: String,
147 /// Value on run `a`'s side; `None` when the field was [`FieldDiffKind::Added`].
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub before: Option<Value>,
150 /// Value on run `b`'s side; `None` when the field was [`FieldDiffKind::Removed`].
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub after: Option<Value>,
153 pub kind: FieldDiffKind,
154}
155
156/// Whether attribution was computed structurally or by counterfactual re-execution.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum AttributionMode {
160 Static,
161 Counterfactual,
162}
163
164/// Outcome of counterfactual re-execution: did patching the origin step recover the run? The
165/// tri-state (rather than a bare `bool`) makes "we did not verify" a first-class, honest value.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum Recovery {
169 Recovered,
170 NotRecovered,
171 Unverified,
172}
173
174/// The counterfactual evidence behind an attribution.
175#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
176pub struct Counterfactual {
177 pub recovered: Recovery,
178 /// Number of counterfactual re-execution runs performed.
179 pub runs: u32,
180}
181
182/// Where the regression is attributed and how far it propagated.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct Attribution {
185 pub mode: AttributionMode,
186 /// The step the regression originated at, if localized.
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub origin_step: Option<usize>,
189 /// Steps the error propagated through, in order.
190 #[serde(default)]
191 pub propagation: Vec<usize>,
192 /// Counterfactual evidence, present only in [`AttributionMode::Counterfactual`].
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub counterfactual: Option<Counterfactual>,
195 /// Optional human-readable cause name from the judge. Semantic naming only — never
196 /// localization.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub cause_label: Option<String>,
199 /// Confidence in the attribution, in `[0, 1]`.
200 pub confidence: f64,
201}
202
203/// A non-fatal diagnostic emitted while building the diff (e.g. unmapped attributes, content
204/// absent from a metadata-only trace).
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct Warning {
207 pub code: WarningCode,
208 pub msg: String,
209}
210
211/// Known warning codes. A closed set so the vocabulary stays single-sourced across crates;
212/// adding a code is a deliberate, versioned change to this contract.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum WarningCode {
216 /// Attributes present on the source span that did not map onto the canonical model.
217 UnmappedAttributes,
218 /// A step carried no input/output content (metadata-only trace).
219 ContentAbsent,
220 /// The trace declares a schema version this build does not treat as native; fields may be
221 /// read under the wrong contract. Surfaced, not fatal — the input seam stays permissive.
222 SchemaVersionMismatch,
223}
224
225/// Which execution path produced the inputs: passively aligning existing traces, or an
226/// `amberfork record` capture session.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "lowercase")]
229pub enum Source {
230 Passive,
231 Record,
232}
233
234/// Envelope metadata for a result.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct Meta {
237 pub schema_version: SchemaVersion,
238 pub source: Source,
239}
240
241impl Meta {
242 /// Metadata stamped with the current schema version.
243 #[must_use]
244 pub fn current(source: Source) -> Self {
245 Self {
246 schema_version: SchemaVersion::current(),
247 source,
248 }
249 }
250}
251
252/// The complete result of diffing two runs — the frozen output contract.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct DiffResult {
255 pub runs: RunPair,
256 /// The full move-typed alignment, in order.
257 #[serde(default)]
258 pub alignment: Vec<Move>,
259 /// The divergence point, or `None` on a converged diff.
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub fork: Option<Fork>,
262 /// Field-level differences within aligned pairs.
263 #[serde(default)]
264 pub field_diffs: Vec<FieldDiff>,
265 /// Regression attribution, if computed.
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub attribution: Option<Attribution>,
268 /// Non-fatal diagnostics.
269 #[serde(default)]
270 pub warnings: Vec<Warning>,
271 pub meta: Meta,
272}
273
274impl DiffResult {
275 /// The observed-run (side `b`) step the fork points at, or `None` on a converged diff.
276 ///
277 /// A fork whose move touches the observed run names its step directly ([`Fork::b_step`]).
278 /// A model-only fork — the observed run is missing steps the reference has — has no `b`
279 /// side; the nearest observed step is the first one not yet consumed when the gap opens
280 /// (the count of `b`-consuming moves before the fork), clamped to the last real step.
281 /// Every consumer answering "where did MY run go wrong" (bench scoring, the parity test)
282 /// reads this one rule instead of re-deriving it.
283 #[must_use]
284 pub fn fork_step_observed(&self) -> Option<usize> {
285 let fork = self.fork?;
286 if fork.b_step.is_some() {
287 return fork.b_step;
288 }
289 // A deserialized result is not validated, so don't trust `fork.index` to be in
290 // bounds; an out-of-range index just counts the whole alignment.
291 let before_fork = self.alignment.len().min(fork.index);
292 let consumed = self.alignment[..before_fork]
293 .iter()
294 .filter(|m| m.b_idx.is_some())
295 .count();
296 Some(consumed.min(self.runs.b.n_steps.saturating_sub(1)))
297 }
298}