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 single latency/token measurement, always `b − a` (observed minus reference) — positive
204/// means `b` used more. Cost is deliberately absent: no adapter in this workspace attributes a
205/// dollar figure to a step yet (issue #40's own follow-up, not this one).
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207pub struct ResourceDelta {
208    /// Wall-clock delta in milliseconds, when both sides carry the timestamps to compute it.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub latency_ms: Option<i64>,
211    /// Token-count delta, when both sides carry an extractable `outputs.usage`.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub tokens: Option<i64>,
214}
215
216/// Resource deltas between the two runs, at two granularities: the whole-run totals, and —
217/// when the fork lands on a synchronous pair — the delta at just that one diverging step.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub struct ResourceDeltas {
220    pub total: ResourceDelta,
221    /// `None` when there is no fork, the fork has no counterpart step on one side
222    /// (log/model-only), or neither step carries any of the underlying data.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub at_fork: Option<ResourceDelta>,
225}
226
227/// A non-fatal diagnostic emitted while building the diff (e.g. unmapped attributes, content
228/// absent from a metadata-only trace).
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct Warning {
231    pub code: WarningCode,
232    pub msg: String,
233}
234
235/// Known warning codes. A closed set so the vocabulary stays single-sourced across crates;
236/// adding a code is a deliberate, versioned change to this contract.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "kebab-case")]
239pub enum WarningCode {
240    /// Attributes present on the source span that did not map onto the canonical model.
241    UnmappedAttributes,
242    /// A step carried no input/output content (metadata-only trace).
243    ContentAbsent,
244    /// The trace declares a schema version this build does not treat as native; fields may be
245    /// read under the wrong contract. Surfaced, not fatal — the input seam stays permissive.
246    SchemaVersionMismatch,
247}
248
249/// Which execution path produced the inputs: passively aligning existing traces, or an
250/// `amberfork record` capture session.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "lowercase")]
253pub enum Source {
254    Passive,
255    Record,
256}
257
258/// Envelope metadata for a result.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct Meta {
261    pub schema_version: SchemaVersion,
262    pub source: Source,
263}
264
265impl Meta {
266    /// Metadata stamped with the current schema version.
267    #[must_use]
268    pub fn current(source: Source) -> Self {
269        Self {
270            schema_version: SchemaVersion::current(),
271            source,
272        }
273    }
274}
275
276/// The complete result of diffing two runs — the frozen output contract.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct DiffResult {
279    pub runs: RunPair,
280    /// The full move-typed alignment, in order.
281    #[serde(default)]
282    pub alignment: Vec<Move>,
283    /// The divergence point, or `None` on a converged diff.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub fork: Option<Fork>,
286    /// Field-level differences within aligned pairs.
287    #[serde(default)]
288    pub field_diffs: Vec<FieldDiff>,
289    /// Regression attribution, if computed.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub attribution: Option<Attribution>,
292    /// Latency / token deltas between the two runs (issue #40). Additive field: `None` when
293    /// neither run carries any of the underlying data — omitted, never fabricated as zero.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub deltas: Option<ResourceDeltas>,
296    /// Non-fatal diagnostics.
297    #[serde(default)]
298    pub warnings: Vec<Warning>,
299    pub meta: Meta,
300}
301
302impl DiffResult {
303    /// The observed-run (side `b`) step the fork points at, or `None` on a converged diff.
304    ///
305    /// A fork whose move touches the observed run names its step directly ([`Fork::b_step`]).
306    /// A model-only fork — the observed run is missing steps the reference has — has no `b`
307    /// side; the nearest observed step is the first one not yet consumed when the gap opens
308    /// (the count of `b`-consuming moves before the fork), clamped to the last real step.
309    /// Every consumer answering "where did MY run go wrong" (bench scoring, the parity test)
310    /// reads this one rule instead of re-deriving it.
311    #[must_use]
312    pub fn fork_step_observed(&self) -> Option<usize> {
313        let fork = self.fork?;
314        if fork.b_step.is_some() {
315            return fork.b_step;
316        }
317        // A deserialized result is not validated, so don't trust `fork.index` to be in
318        // bounds; an out-of-range index just counts the whole alignment.
319        let before_fork = self.alignment.len().min(fork.index);
320        let consumed = self.alignment[..before_fork]
321            .iter()
322            .filter(|m| m.b_idx.is_some())
323            .count();
324        Some(consumed.min(self.runs.b.n_steps.saturating_sub(1)))
325    }
326}