amberfork_model/lib.rs
1//! Canonical trajectory model for amberfork — the frozen contract every crate reads.
2//!
3//! A [`Run`] is one agent trajectory: an ordered list of [`Step`]s plus optional DAG
4//! [`Edge`]s. This is the *input* seam — `amberfork-ingest` produces it from OTel/plain JSON,
5//! and `amberfork-align` consumes it. It mirrors the public wire format documented in
6//! `docs/trace-format.md`; once this crate exists, these types are the source of truth and
7//! that document tracks them.
8//!
9//! [`DiffResult`] is the matching *output* seam: what `amberfork-align` fills in and the CLI's
10//! `--json` and the Leptos UI render.
11//!
12//! Design rules baked into the types (see `docs/design/design-run-diff-debugger.md`):
13//! - `outcome` is a run-level verdict supplied by the user, **never inferred from span
14//! status**.
15//! - Timing (`t_start`/`t_end`) is display-only and is **never** an alignment signal, so it
16//! stays a raw string here rather than a parsed timestamp.
17//! - `inputs`/`outputs` distinguish text from structured payloads in the type itself, because
18//! the diff engine text-diffs strings but field-diffs objects.
19
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22
23mod diff;
24pub use diff::{
25 Attribution, AttributionMode, Counterfactual, DiffResult, FieldDiff, FieldDiffKind, Fork, Meta,
26 Move, MoveKind, Recovery, ResourceDelta, ResourceDeltas, RunPair, RunRef, Source, Warning,
27 WarningCode,
28};
29
30// Test-only fixture builders; the `test-support` feature is enabled exclusively from
31// `[dev-dependencies]` across the workspace, so this module never ships in a normal build.
32#[cfg(any(test, feature = "test-support"))]
33pub mod test_support;
34
35/// Version of the trace-format / model contract. Breaking changes (renames, removals,
36/// semantic shifts) bump it; additive optional fields do not. Kept as a newtype so the
37/// version is a first-class, self-documenting value across the workspace rather than a
38/// bare string.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(transparent)]
41pub struct SchemaVersion(pub String);
42
43impl SchemaVersion {
44 /// The version this build emits and treats as native.
45 pub const CURRENT: &'static str = "0.1";
46
47 /// The current contract version.
48 #[must_use]
49 pub fn current() -> Self {
50 Self(Self::CURRENT.to_string())
51 }
52
53 /// Whether this run declares the version this build emits natively.
54 #[must_use]
55 pub fn is_current(&self) -> bool {
56 self.0 == Self::CURRENT
57 }
58}
59
60impl Default for SchemaVersion {
61 fn default() -> Self {
62 Self::current()
63 }
64}
65
66/// Run-level verdict, if known. Deliberately supplied by the user (an assertion, a label, a
67/// gold answer) — amberfork never derives it from trace/span status.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum Outcome {
71 Pass,
72 Fail,
73 Unknown,
74}
75
76/// The structural class of a step. Part of the identity the aligner keys on. This is the
77/// canonical, post-normalization vocabulary: `amberfork-ingest` is responsible for mapping the
78/// many framework-specific span kinds down onto these four.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum StepKind {
82 Llm,
83 Tool,
84 Agent,
85 Other,
86}
87
88/// A step's `inputs` or `outputs`. The variant is the semantic seam the diff engine reads:
89/// [`Payload::Text`] gets text diffing, [`Payload::Object`] gets field-level diffing. The
90/// untagged catch-all [`Payload::Other`] keeps the "any log massaged into this shape" promise
91/// alive — an array/number/bool payload is preserved verbatim instead of failing the parse.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(untagged)]
94pub enum Payload {
95 Text(String),
96 Object(Map<String, Value>),
97 Other(Value),
98}
99
100/// A directed DAG edge between step indices, `[from, to]` on the wire. A tuple struct so it
101/// serializes as a two-element array, matching `docs/trace-format.md`.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Edge(pub usize, pub usize);
104
105/// One node in a trajectory.
106///
107/// Minimal valid step: `idx`, `kind`, `name`, and at least one of `inputs`/`outputs`. All
108/// other fields are optional and omitted from serialization when empty, so re-emitted traces
109/// stay compact without losing information.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct Step {
112 /// 0-based position in the trajectory.
113 pub idx: usize,
114 /// Structural class of the step.
115 pub kind: StepKind,
116 /// Agent or tool name — part of the structural identity the aligner keys on.
117 pub name: String,
118 /// Step input, if captured.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub inputs: Option<Payload>,
121 /// Step output, if captured.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub outputs: Option<Payload>,
124 /// Anything else worth keeping (model, tokens, cost, …).
125 #[serde(default, skip_serializing_if = "Map::is_empty")]
126 pub attrs: Map<String, Value>,
127 /// RFC3339 start time. Display-only — never an alignment signal.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub t_start: Option<String>,
130 /// RFC3339 end time. Display-only — never an alignment signal.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub t_end: Option<String>,
133 /// Caller step index; absent/null on every step means a linear chain.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub parent_idx: Option<usize>,
136}
137
138/// One agent trajectory: the unit `amberfork diff` aligns against another.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct Run {
141 /// Version of the contract this run was written against.
142 pub schema_version: SchemaVersion,
143 /// Unique run id (any string).
144 pub id: String,
145 /// Human label of what the run attempted.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub task: Option<String>,
148 /// Run-level verdict, if known.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub outcome: Option<Outcome>,
151 /// The trajectory, in order.
152 pub steps: Vec<Step>,
153 /// Explicit DAG edges. If absent, the graph is derived from `parent_idx`, else linear.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub edges: Option<Vec<Edge>>,
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn schema_version_current_is_native() {
164 let v = SchemaVersion::current();
165 assert!(v.is_current());
166 assert_eq!(v.0, SchemaVersion::CURRENT);
167 assert_eq!(SchemaVersion::default(), v);
168 }
169
170 #[test]
171 fn schema_version_serializes_transparently() {
172 // The newtype must be indistinguishable from a bare string on the wire.
173 let json = serde_json::to_string(&SchemaVersion::current()).unwrap();
174 assert_eq!(json, "\"0.1\"");
175 let back: SchemaVersion = serde_json::from_str("\"0.1\"").unwrap();
176 assert_eq!(back, SchemaVersion::current());
177 }
178}