Skip to main content

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}