amberfork_ingest/lib.rs
1//! Forgiving loader from plain-JSON trace files into the canonical [`amberfork_model::Run`].
2//!
3//! This is the input edge of the pipeline. `docs/trace-format.md` promises a *deliberately
4//! forgiving* format: a step's unknown fields are preserved into `attrs` and surfaced as an
5//! "unmapped attributes" warning rather than failing the parse. That forgiveness lives here,
6//! not in the frozen model — [`amberfork_model::Step`] is strict and cannot hold unknown fields,
7//! so this crate deserializes into private `Raw*` mirror types that *capture* the extras
8//! (`#[serde(flatten)]`) and folds them into `attrs` during conversion.
9//!
10//! What stays strict: the canonical `kind` vocabulary. A step whose `kind` is not one of
11//! `llm`/`tool`/`agent`/`other` is a [`IngestError::NotATrace`], not a forgiven value — mapping
12//! framework-specific span kinds down onto the canonical four is a normalizer's job, not this
13//! canonical loader's.
14//!
15//! Sync by design: this is `std::fs` only. `tokio` is reserved for the genuine async I/O edge
16//! (record/serve), so the ingest path stays a pure, testable function.
17
18use amberfork_model::{
19 Edge, Outcome, Payload, Run, SchemaVersion, Step, StepKind, Warning, WarningCode,
20};
21use serde::Deserialize;
22use serde::de::IgnoredAny;
23use serde_json::{Map, Value};
24use std::fmt;
25use std::path::{Path, PathBuf};
26
27/// Doc pointers baked into error text (issue #20): the first parse error is the product
28/// surface for a new user, so it must link out instead of dead-ending. Built from the crate's
29/// repository URL so the links can't drift from where the code lives.
30const TRACE_FORMAT_URL: &str = concat!(
31 env!("CARGO_PKG_REPOSITORY"),
32 "/blob/main/docs/trace-format.md"
33);
34const CONVERSION_GUIDE_URL: &str = concat!(
35 env!("CARGO_PKG_REPOSITORY"),
36 "/blob/main/docs/run-on-your-own-agent.md"
37);
38
39/// Framework source adapters. Unlike the canonical loader above, these map a foreign trace shape
40/// onto [`Run`]; each is namespaced so its quirks stay isolated from the canonical format.
41pub mod genai;
42pub mod hal;
43pub mod openinference;
44pub mod tape;
45pub mod trail;
46pub mod whowhen;
47
48/// The shared OTLP/JSON span-export envelope reader (resource/scope flattening, `AnyValue`
49/// decoding, start-time ordering), used by the `openinference` and `genai` adapters — the two
50/// OTLP-envelope vocabularies. Crate-internal — adapters call it, callers use the adapters.
51mod otlp;
52
53/// The OpenInference **vocabulary** layer, shared by the `openinference` (OTLP envelope) and
54/// `trail` (Patronus tree envelope) adapters: the span-attribute → canonical-[`Step`] mapping,
55/// independent of how spans are found on the wire. Crate-internal — adapters call it, callers
56/// use the adapters.
57mod oivocab;
58
59/// The native OTel GenAI **vocabulary** layer (`gen_ai.*`), the [`genai`] adapter's counterpart
60/// to [`oivocab`]. Crate-internal — the adapter calls it, callers use the adapter.
61mod genaivocab;
62
63/// A loaded run together with any non-fatal diagnostics raised while normalizing it. The
64/// warnings flow onward into [`amberfork_model::DiffResult::warnings`].
65#[derive(Debug, Clone, PartialEq)]
66pub struct Ingested {
67 pub run: Run,
68 pub warnings: Vec<Warning>,
69}
70
71/// Everything that can go wrong loading a trace. The library path returns this rather than
72/// panicking.
73///
74/// The parse family carries an optional `path` because `amberfork diff` reads two inputs — an
75/// error that doesn't say which file broke is a dead end. [`load_file`] fills it in;
76/// [`from_json_str`] has no path to give (embedded demo pair, future stdin).
77#[derive(Debug)]
78pub enum IngestError {
79 /// The file could not be read.
80 Io {
81 path: PathBuf,
82 source: std::io::Error,
83 },
84 /// The input was not parseable JSON. The canonical loader reserves this for malformed
85 /// JSON — shape problems classify as [`Self::NotATrace`] — while the format adapters
86 /// ([`tape`], [`whowhen`]) use it for any serde failure on their foreign shapes.
87 Parse {
88 path: Option<PathBuf>,
89 source: serde_json::Error,
90 },
91 /// Valid JSON, but not shaped like a canonical trace (issue #20). The serde detail names
92 /// the field or variant that broke; the display adds what a trace needs and points to the
93 /// format reference so the first error is not a dead end.
94 NotATrace {
95 path: Option<PathBuf>,
96 source: serde_json::Error,
97 },
98 /// The input looks like JSON-Lines — the shape of a raw exporter transcript, the
99 /// likeliest first mistake (issue #20). amberfork takes a single JSON trace per file;
100 /// the display points to the conversion guide.
101 JsonLines { path: Option<PathBuf> },
102}
103
104impl IngestError {
105 /// Attach the originating file to a parse-family error — [`load_file`] knows the path,
106 /// [`from_json_str`] doesn't. `Io` already names its file. Public because a caller that reads
107 /// the bytes itself (the CLI's format-sniffing loader) still wants the file attributed.
108 pub fn with_path(self, path: &Path) -> Self {
109 let path = Some(path.to_path_buf());
110 match self {
111 Self::Io { .. } => self,
112 Self::Parse { source, .. } => Self::Parse { path, source },
113 Self::NotATrace { source, .. } => Self::NotATrace { path, source },
114 Self::JsonLines { .. } => Self::JsonLines { path },
115 }
116 }
117}
118
119impl fmt::Display for IngestError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 // Parse-family messages open with the offending file when known, `path: message`
122 // style, so a two-input `diff` error is attributable at a glance.
123 let name = |path: &Option<PathBuf>| {
124 path.as_ref()
125 .map_or_else(String::new, |p| format!("{}: ", p.display()))
126 };
127 match self {
128 Self::Io { path, source } => {
129 write!(f, "failed to read trace file {}: {source}", path.display())
130 }
131 Self::Parse { path, source } => {
132 write!(f, "{}failed to parse trace JSON: {source}", name(path))
133 }
134 Self::NotATrace { path, source } => write!(
135 f,
136 "{}valid JSON, but not a canonical trace: {source}\n \
137 a canonical trace is a single JSON object with `schema_version`, `id`, and `steps`\n \
138 format reference: {TRACE_FORMAT_URL}\n \
139 conversion guide: {CONVERSION_GUIDE_URL}",
140 name(path)
141 ),
142 Self::JsonLines { path } => write!(
143 f,
144 "{}this looks like JSON-Lines (one JSON value per line) — amberfork takes a \
145 single JSON trace per file\n \
146 conversion guide: {CONVERSION_GUIDE_URL}",
147 name(path)
148 ),
149 }
150 }
151}
152
153impl std::error::Error for IngestError {
154 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
155 match self {
156 Self::Io { source, .. } => Some(source),
157 Self::Parse { source, .. } | Self::NotATrace { source, .. } => Some(source),
158 Self::JsonLines { .. } => None,
159 }
160 }
161}
162
163/// Parse a canonical trace from a JSON string.
164///
165/// # Errors
166/// Returns [`IngestError::Parse`] if the string is not valid JSON, [`IngestError::NotATrace`]
167/// if it is valid JSON that isn't shaped like a canonical trace (including a non-canonical
168/// `kind`), or [`IngestError::JsonLines`] if it looks like a raw JSONL transcript.
169pub fn from_json_str(s: &str) -> Result<Ingested, IngestError> {
170 let raw: RawRun =
171 serde_json::from_str(s).map_err(|source| classify_parse_failure(s, source))?;
172 let (run, warnings) = raw.into_run();
173 Ok(Ingested { run, warnings })
174}
175
176/// Load a canonical trace from a file on disk.
177///
178/// # Errors
179/// Returns [`IngestError::Io`] if the file cannot be read, or a parse-family error (see
180/// [`from_json_str`]) — with the path attached — if its contents are not a canonical trace.
181pub fn load_file(path: impl AsRef<Path>) -> Result<Ingested, IngestError> {
182 let path = path.as_ref();
183 let text = std::fs::read_to_string(path).map_err(|source| IngestError::Io {
184 path: path.to_path_buf(),
185 source,
186 })?;
187 from_json_str(&text).map_err(|err| err.with_path(path))
188}
189
190/// Decide what a failed top-level parse actually means (issue #20). Runs only on the error
191/// path, so the extra whole-input scan costs nothing on a clean load. Order matters: a JSONL
192/// file is not valid JSON as a whole, so its check must precede the valid-JSON one.
193fn classify_parse_failure(text: &str, source: serde_json::Error) -> IngestError {
194 if looks_like_json_lines(text) {
195 return IngestError::JsonLines { path: None };
196 }
197 if serde_json::from_str::<IgnoredAny>(text).is_ok() {
198 return IngestError::NotATrace { path: None, source };
199 }
200 IngestError::Parse { path: None, source }
201}
202
203/// The issue-#20 heuristic, verbatim: the first non-empty line is a complete JSON value and
204/// more lines follow. A pretty-printed JSON document cannot false-positive — its first line
205/// (`{`) is not complete JSON on its own. `IgnoredAny` validates without building a value.
206fn looks_like_json_lines(text: &str) -> bool {
207 let mut lines = text.lines().filter(|line| !line.trim().is_empty());
208 let Some(first) = lines.next() else {
209 return false;
210 };
211 lines.next().is_some() && serde_json::from_str::<IgnoredAny>(first).is_ok()
212}
213
214/// Deserialization mirror of [`Run`] that captures unmapped top-level fields.
215#[derive(Deserialize)]
216struct RawRun {
217 schema_version: SchemaVersion,
218 id: String,
219 #[serde(default)]
220 task: Option<String>,
221 #[serde(default)]
222 outcome: Option<Outcome>,
223 steps: Vec<RawStep>,
224 #[serde(default)]
225 edges: Option<Vec<Edge>>,
226 #[serde(flatten)]
227 extra: Map<String, Value>,
228}
229
230/// Deserialization mirror of [`Step`] that captures unmapped fields into `extra` so the loader
231/// can preserve them into `attrs` instead of dropping them.
232#[derive(Deserialize)]
233struct RawStep {
234 idx: usize,
235 kind: StepKind,
236 name: String,
237 #[serde(default)]
238 inputs: Option<Payload>,
239 #[serde(default)]
240 outputs: Option<Payload>,
241 #[serde(default)]
242 attrs: Map<String, Value>,
243 #[serde(default)]
244 t_start: Option<String>,
245 #[serde(default)]
246 t_end: Option<String>,
247 #[serde(default)]
248 parent_idx: Option<usize>,
249 #[serde(flatten)]
250 extra: Map<String, Value>,
251}
252
253impl RawRun {
254 fn into_run(self) -> (Run, Vec<Warning>) {
255 let mut warnings = Vec::new();
256 if !self.schema_version.is_current() {
257 warnings.push(Warning {
258 code: WarningCode::SchemaVersionMismatch,
259 msg: format!(
260 "trace declares schema_version {:?}; this build is native to {} — fields may be read under the wrong contract",
261 self.schema_version.0,
262 SchemaVersion::CURRENT
263 ),
264 });
265 }
266 if !self.extra.is_empty() {
267 warnings.push(Warning {
268 code: WarningCode::UnmappedAttributes,
269 msg: format!(
270 "run-level unmapped fields (no run-level attrs to hold them, dropped): {}",
271 sorted_keys(&self.extra)
272 ),
273 });
274 }
275 let steps = self
276 .steps
277 .into_iter()
278 .map(|step| step.into_step(&mut warnings))
279 .collect();
280 let run = Run {
281 schema_version: self.schema_version,
282 id: self.id,
283 task: self.task,
284 outcome: self.outcome,
285 steps,
286 edges: self.edges,
287 };
288 (run, warnings)
289 }
290}
291
292impl RawStep {
293 fn into_step(self, warnings: &mut Vec<Warning>) -> Step {
294 let mut attrs = self.attrs;
295 if !self.extra.is_empty() {
296 warnings.push(Warning {
297 code: WarningCode::UnmappedAttributes,
298 msg: format!(
299 "step {} ({}): unmapped fields moved to attrs: {}",
300 self.idx,
301 self.name,
302 sorted_keys(&self.extra)
303 ),
304 });
305 for (key, value) in self.extra {
306 attrs.insert(key, value);
307 }
308 }
309 if self.inputs.is_none() && self.outputs.is_none() {
310 warnings.push(Warning {
311 code: WarningCode::ContentAbsent,
312 msg: format!(
313 "step {} ({}): no input or output content captured",
314 self.idx, self.name
315 ),
316 });
317 }
318 Step {
319 idx: self.idx,
320 kind: self.kind,
321 name: self.name,
322 inputs: self.inputs,
323 outputs: self.outputs,
324 attrs,
325 t_start: self.t_start,
326 t_end: self.t_end,
327 parent_idx: self.parent_idx,
328 }
329 }
330}
331
332/// Comma-joined keys of a map, explicitly sorted so warning text is deterministic — the
333/// workspace's `serde_json` map preserves insertion order (issue #17), so the sort cannot be
334/// inherited from the map type.
335fn sorted_keys(map: &Map<String, Value>) -> String {
336 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
337 keys.sort_unstable();
338 keys.join(", ")
339}