Skip to main content

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}