Skip to main content

amberfork_record/
normalize.rs

1//! Normalizing a captured cassette into the canonical [`amberfork_model::Run`].
2//!
3//! The record path's second half. [`crate::proxy`] captures provider exchanges into a
4//! [`Cassette`]; this turns that cassette into the one shape the aligner reads, so a recorded
5//! run diffs through exactly the same engine as a passively-ingested trace.
6//! `docs/cassette-format.md` states the promise this keeps: *"A cassette becomes a `Run` by
7//! normalization; the trace format stays the one seam the aligner reads."*
8//!
9//! The mapping is deliberately provider-agnostic. Each captured exchange becomes one
10//! [`StepKind::Llm`] step whose `inputs` is the full request body and whose `outputs` is the
11//! full response body — carried structurally when they are JSON, verbatim text otherwise. That
12//! is the record path's whole reason to exist: the passive path's *full content guaranteed*
13//! cell is **no** and this one's is **yes**, so a normalized cassette never yields a
14//! content-absent step. What this stage does *not* do is re-parse one provider's message schema
15//! (pulling the last user turn out of an OpenAI `messages` array, expanding `tool_calls` into
16//! [`StepKind::Tool`] steps): the field-level diff already compares the full bodies, so that
17//! extraction is a later refinement, not a prerequisite for the payoff.
18
19use crate::cassette::{Body, Cassette, Exchange};
20use amberfork_model::{Payload, Run, SchemaVersion, Step, StepKind};
21use serde_json::{Map, Value};
22
23/// Step name for an exchange whose request body does not name a model. A recorded exchange is
24/// always an LLM call, so the fallback stays in that vocabulary.
25const UNNAMED_LLM: &str = "llm";
26
27/// Parse a cassette from JSON and normalize it into a [`Run`].
28///
29/// The string entry to the record path's normalizer — what the CLI reaches for once it has
30/// sniffed a `cassette_version` and routed a file here, paralleling
31/// [`amberfork_ingest::from_json_str`] on the passive path. Kept separate from [`normalize`] so
32/// the in-memory mapping stays a pure `&Cassette -> Run` function with no serde in its signature.
33///
34/// # Errors
35/// Returns the [`serde_json::Error`] if the input is not a well-formed cassette (malformed JSON,
36/// or valid JSON whose shape does not match the cassette contract). The caller owns turning that
37/// into a file-attributed, doc-linked message.
38pub fn normalize_str(s: &str) -> Result<Run, serde_json::Error> {
39    let cassette: Cassette = serde_json::from_str(s)?;
40    Ok(normalize(&cassette))
41}
42
43/// Normalize a captured cassette into the canonical [`Run`] the aligner consumes.
44///
45/// Each [`Exchange`] becomes one step, in capture order, as a linear chain (no `parent_idx`, no
46/// explicit edges — a boundary recording observes sequence, not causality). The run carries no
47/// `outcome`: a verdict is user-supplied and never derived from a recording, exactly as on the
48/// passive path.
49#[must_use]
50pub fn normalize(cassette: &Cassette) -> Run {
51    let steps = cassette
52        .exchanges
53        .iter()
54        .enumerate()
55        .map(|(idx, exchange)| step_from_exchange(idx, exchange))
56        .collect();
57    Run {
58        schema_version: SchemaVersion::current(),
59        id: cassette.id.clone(),
60        task: None,
61        outcome: None,
62        steps,
63        edges: None,
64    }
65}
66
67/// Map one captured exchange onto one canonical step.
68///
69/// `idx` is the trajectory position taken from enumeration, not the cassette's own
70/// [`Exchange::idx`]: the model requires a contiguous 0-based trajectory, so a cassette with a
71/// gap in its capture indices still yields a valid run.
72fn step_from_exchange(idx: usize, exchange: &Exchange) -> Step {
73    let mut attrs = Map::new();
74    // The status is a genuine divergence signal (a 200 vs a 429 is a real fork) and the one
75    // piece of the round trip that lives outside the bodies, so it is worth carrying across.
76    attrs.insert("status".to_string(), exchange.response.status.into());
77    Step {
78        idx,
79        kind: StepKind::Llm,
80        name: model_name(&exchange.request.body),
81        inputs: Some(payload_from_body(&exchange.request.body)),
82        outputs: Some(payload_from_body(&exchange.response.body)),
83        attrs,
84        t_start: None,
85        t_end: None,
86        parent_idx: None,
87    }
88}
89
90/// The model named in a request body, or [`UNNAMED_LLM`]. The model is an LLM call's most
91/// telling structural identity, and the aligner keys on `name`, so surface it when the provider
92/// sent it.
93fn model_name(body: &Body) -> String {
94    let Body::Json(Value::Object(map)) = body else {
95        return UNNAMED_LLM.to_string();
96    };
97    map.get("model")
98        .and_then(Value::as_str)
99        .unwrap_or(UNNAMED_LLM)
100        .to_string()
101}
102
103/// Carry a captured body into the payload the diff engine reads: a JSON object gets field-level
104/// diffing, a non-object JSON value is preserved verbatim, and a non-JSON body degrades to text
105/// — content shape is lost, content never is.
106fn payload_from_body(body: &Body) -> Payload {
107    match body {
108        Body::Json(Value::Object(map)) => Payload::Object(map.clone()),
109        Body::Json(value) => Payload::Other(value.clone()),
110        Body::Text(text) => Payload::Text(text.clone()),
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::cassette::{CapturedRequest, CapturedResponse, CassetteVersion};
118    use serde_json::json;
119
120    fn exchange(idx: usize, request: Body, status: u16, response: Body) -> Exchange {
121        Exchange {
122            idx,
123            request: CapturedRequest {
124                method: "POST".to_string(),
125                path: "/v1/chat/completions".to_string(),
126                headers: Vec::new(),
127                body: request,
128            },
129            response: CapturedResponse {
130                status,
131                headers: Vec::new(),
132                body: response,
133            },
134        }
135    }
136
137    fn cassette(exchanges: Vec<Exchange>) -> Cassette {
138        Cassette {
139            cassette_version: CassetteVersion::current(),
140            id: "test-run".to_string(),
141            exchanges,
142        }
143    }
144
145    #[test]
146    fn each_exchange_becomes_one_llm_step_in_capture_order() {
147        let cass = cassette(vec![
148            exchange(
149                0,
150                Body::Json(json!({"model": "claude-sonnet-5"})),
151                200,
152                Body::Json(json!({"n": 1})),
153            ),
154            exchange(
155                1,
156                Body::Json(json!({"model": "claude-sonnet-5"})),
157                200,
158                Body::Json(json!({"n": 2})),
159            ),
160        ]);
161        let run = normalize(&cass);
162        assert_eq!(run.id, "test-run");
163        assert_eq!(run.steps.len(), 2);
164        assert_eq!(run.steps[0].idx, 0);
165        assert_eq!(run.steps[1].idx, 1);
166        assert!(run.steps.iter().all(|s| s.kind == StepKind::Llm));
167        // A boundary recording is a linear chain: sequence is observed, causality is not.
168        assert!(run.edges.is_none());
169        assert!(run.steps.iter().all(|s| s.parent_idx.is_none()));
170    }
171
172    #[test]
173    fn every_recorded_step_carries_both_inputs_and_outputs() {
174        // The record path's reason to exist. A passive trace can be metadata-only (a
175        // content-absent step); a cassette guarantees full content, so no normalized step is
176        // ever missing its inputs or its outputs. This is the invariant the +76% number rests on.
177        let cass = cassette(vec![exchange(
178            0,
179            Body::Json(
180                json!({"messages": [{"role": "user", "content": "Handle refund for 8841"}]}),
181            ),
182            200,
183            Body::Json(json!({"choices": [{"message": {"content": "Looking up the order."}}]})),
184        )]);
185        let run = normalize(&cass);
186        assert!(
187            run.steps
188                .iter()
189                .all(|s| s.inputs.is_some() && s.outputs.is_some())
190        );
191    }
192
193    #[test]
194    fn json_object_body_becomes_a_field_diffable_object_payload() {
195        let cass = cassette(vec![exchange(
196            0,
197            Body::Json(json!({"model": "claude-sonnet-5", "temperature": 0})),
198            200,
199            Body::Json(json!({"choices": []})),
200        )]);
201        let run = normalize(&cass);
202        let inputs = run.steps[0].inputs.as_ref().expect("inputs present");
203        let Payload::Object(map) = inputs else {
204            panic!("a JSON object request must map to Payload::Object, got {inputs:?}");
205        };
206        assert_eq!(map["temperature"], json!(0));
207    }
208
209    #[test]
210    fn non_json_response_degrades_to_text_not_silence() {
211        // A provider's HTML 502 is exactly the run worth having recorded. The fidelity loss must
212        // show up as shape (text, not object), never as a dropped output.
213        let cass = cassette(vec![exchange(
214            0,
215            Body::Json(json!({"model": "claude-sonnet-5"})),
216            502,
217            Body::Text("<html>502 Bad Gateway</html>".to_string()),
218        )]);
219        let run = normalize(&cass);
220        let outputs = run.steps[0].outputs.as_ref().expect("output present");
221        assert_eq!(
222            *outputs,
223            Payload::Text("<html>502 Bad Gateway</html>".to_string())
224        );
225    }
226
227    #[test]
228    fn step_name_is_the_model_when_the_request_names_one() {
229        // The model is an LLM call's most telling structural identity, and the aligner keys on
230        // `name`: a run that swaps models has genuinely diverged.
231        let cass = cassette(vec![exchange(
232            0,
233            Body::Json(json!({"model": "claude-opus-4-8"})),
234            200,
235            Body::Json(json!({})),
236        )]);
237        assert_eq!(normalize(&cass).steps[0].name, "claude-opus-4-8");
238    }
239
240    #[test]
241    fn step_name_falls_back_when_no_model_is_named() {
242        let cass = cassette(vec![exchange(
243            0,
244            Body::Text("not json".to_string()),
245            200,
246            Body::Json(json!({})),
247        )]);
248        assert_eq!(normalize(&cass).steps[0].name, UNNAMED_LLM);
249    }
250
251    #[test]
252    fn normalize_str_parses_then_normalizes_a_well_formed_cassette() {
253        let json = r#"{
254            "cassette_version": "0.1",
255            "id": "from-str",
256            "exchanges": [
257                {
258                    "idx": 0,
259                    "request": { "method": "POST", "path": "/v1/messages",
260                        "body": { "model": "claude-sonnet-5" } },
261                    "response": { "status": 200, "body": { "ok": true } }
262                }
263            ]
264        }"#;
265        let run = normalize_str(json).expect("well-formed cassette normalizes");
266        assert_eq!(run.id, "from-str");
267        assert_eq!(run.steps.len(), 1);
268        assert_eq!(run.steps[0].name, "claude-sonnet-5");
269    }
270
271    #[test]
272    fn normalize_str_errors_on_a_broken_cassette_shape() {
273        // Valid JSON, but `exchanges` is the wrong type — the string entry surfaces the serde
274        // error rather than silently yielding a run, so the caller can attribute it to a file.
275        let json = r#"{"cassette_version": "0.1", "id": "broken", "exchanges": "not-an-array"}"#;
276        assert!(normalize_str(json).is_err());
277    }
278
279    #[test]
280    fn response_status_is_kept_as_a_step_attribute() {
281        let cass = cassette(vec![exchange(
282            0,
283            Body::Json(json!({"model": "claude-sonnet-5"})),
284            429,
285            Body::Json(json!({"error": "rate_limited"})),
286        )]);
287        let run = normalize(&cass);
288        assert_eq!(run.steps[0].attrs["status"], json!(429));
289    }
290}