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