Skip to main content

supercode/
audit.rs

1//! Corpus coverage audit.
2//!
3//! Walks a directory of session logs, parses every line through the typed
4//! [`crate::schema`], and reports — with real counts — exactly what we model,
5//! what we model-but-drop on normalization, and what we don't model at all.
6//! This is the machine that turns "what's missing?" into an enumerated answer
7//! rather than a guess.
8//!
9//! ```no_run
10//! use std::path::Path;
11//! use supercode::audit::{audit_dir, Corpus};
12//!
13//! let report = audit_dir(Path::new("/home/me/.codex/sessions"), Corpus::Codex, None);
14//! report.print();
15//! ```
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde_json::Value;
21
22use crate::schema::{claude_code::*, codex::*, raw_block_tag, ContentBlock};
23use crate::session::{opencode_file_image_part, pi_content_has_unknown_image_shape};
24
25/// Which corpus a directory holds.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Corpus {
28    /// `~/.claude/projects`
29    ClaudeCode,
30    /// `~/.codex/sessions`
31    Codex,
32    /// `~/.pi/agent/sessions`
33    Pi,
34    /// `~/.local/share/opencode` (envelope-form fixtures/corpus — see
35    /// `docs/interop/opencode-pi-spec.md` §1.2/§4.1).
36    OpenCode,
37    /// `~/.grok/sessions` (`chat_history.jsonl` files only; companion
38    /// `updates.jsonl` streams are live protocol events, not transcripts).
39    Grok,
40}
41
42/// How a given discriminant is handled by the loader.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum Coverage {
45    /// Parsed and normalized into the canonical conversation.
46    Normalized,
47    /// PARITY-12/PARITY-13 (P012/P013): parsed and its content/provenance IS
48    /// captured by the loader — into `Session.meta` (Codex `session_meta`'s
49    /// id/cwd/model/base_instructions, `turn_context`'s model), replay
50    /// semantics (`thread_rolled_back` actually removes the rolled-back
51    /// turns, `exited_review_mode`'s `review_output.overall_explanation`
52    /// becomes a message with `review_output.findings` AND
53    /// `overall_correctness`/`overall_confidence_score` (N4) captured onto
54    /// that message's metadata (D4/N4), `thread_goal_updated`'s
55    /// `goal.objective` becomes a message with `goal.status`/
56    /// `goal.tokenBudget` captured onto that message's metadata too (D4),
57    /// `agent_message` can become a message when its text has no
58    /// `response_item` twin) — just not 1:1 into a `ChatMessage` the way
59    /// `Normalized` records are.
60    /// This is the "retained, not dropped" bucket the two audits were
61    /// missing: before this variant existed, every one of these landed in
62    /// `Dropped` indistinguishably from truly-inert UI noise (`token_count`,
63    /// `task_started`, …), which is exactly the false "silently dropped"
64    /// signal both items' dev/03 ACs flag.
65    ///
66    /// `response_item/reasoning` also belongs here (D5, PARITY-12): its
67    /// `summary` text, raw `content` chain-of-thought text when non-null
68    /// (N2 — previously dropped despite this very label claiming otherwise;
69    /// `content` is `null` on the vast majority of real turns, so this was
70    /// easy to miss until fixtures carried the key at all), and the
71    /// `encrypted_content` presence flag (N1: only a genuinely non-null
72    /// value counts — `serde_json` returns `Some(&Value::Null)` for a
73    /// present-but-null key, which is what EVERY real rollout's reasoning
74    /// item carries per upstream `codex-rs/protocol/src/models.rs:970-983`,
75    /// so a naive `.is_some()` false-flagged every reasoning item as
76    /// "encrypted" on real data) are captured by `Session::from_codex_str`
77    /// onto the *next* assistant `ChatMessage`'s metadata (`reasoning`/
78    /// `reasoning_content`/`reasoning_encrypted`) — see `audit_codex_item`'s
79    /// `Reasoning` arm. When no following assistant turn exists to attach to
80    /// (a non-assistant item interrupts, or the reasoning is dangling at
81    /// EOF — an aborted-turn shape, N3), it is flushed as its own synthesized
82    /// `[reasoning] (turn ended without a reply)` message instead of being
83    /// silently discarded, so this label stays honest for that shape too.
84    /// It isn't `Normalized` (no canonical "reasoning" `ChatMessage`), but it
85    /// is provably not a blind drop either.
86    ///
87    /// DISCLOSURE: every metadata key mentioned above (`review_findings`,
88    /// `review_overall_correctness`, `review_overall_confidence_score`,
89    /// `goal_status`, `goal_token_budget`, `reasoning`, `reasoning_content`,
90    /// `reasoning_encrypted`) is LOADER-CAPTURE ONLY. It survives the
91    /// verbatim Codex→Codex diagonal (raw bytes, untouched) and reads back
92    /// out of the native supercode format, but it does NOT survive a
93    /// cross-format writer or the `--session-id` re-serialized diagonal:
94    /// `ChatMessage.metadata` is never serialized (`message.rs:50-55`) and no
95    /// writer reads it back out. Don't misread `Retained` here as
96    /// cross-format-durable — it means "captured in-process", not "written
97    /// back out".
98    Retained,
99    /// Parsed and understood, but intentionally dropped (e.g. `token_count`,
100    /// `task_started`/`task_complete`, UI echoes of content already captured
101    /// elsewhere as `Normalized`/`Retained`). See `event_msg_coverage`'s doc
102    /// comment for the few real, currently-unrecovered exceptions (D1) —
103    /// e.g. `patch_apply_end`'s `changes[path].unified_diff` — where
104    /// `Dropped` means genuine, asserted content loss, not "duplicated
105    /// elsewhere".
106    Dropped,
107    /// Not modeled at all — falls into an `Unknown` typed bucket.
108    Unmodeled,
109}
110
111impl Coverage {
112    fn symbol(self) -> &'static str {
113        match self {
114            Coverage::Normalized => "✅ normalized",
115            Coverage::Retained => "◆ retained  ",
116            Coverage::Dropped => "➖ dropped   ",
117            Coverage::Unmodeled => "❌ UNMODELED ",
118        }
119    }
120}
121
122/// A tally for one discriminant value.
123#[derive(Debug, Clone, Default)]
124#[non_exhaustive]
125pub struct Tally {
126    /// How many times it occurred.
127    pub count: u64,
128    /// Field keys seen in `extra` (fields we didn't model), with counts.
129    pub unmodeled_fields: BTreeMap<String, u64>,
130}
131
132/// The full audit result.
133#[derive(Debug, Default)]
134#[non_exhaustive]
135pub struct Report {
136    /// Which corpus this is.
137    pub corpus: Option<&'static str>,
138    /// Files scanned.
139    pub files: u64,
140    /// Lines parsed.
141    pub lines: u64,
142    /// Lines that failed to deserialize even into the typed schema.
143    pub parse_errors: u64,
144    /// Per record/payload discriminant: (coverage, tally). Keyed by a readable
145    /// path like `response_item/custom_tool_call`.
146    pub records: BTreeMap<String, (Coverage, Tally)>,
147    /// Content block discriminants seen, with counts SPLIT by the coverage
148    /// each instance actually got (N1, Fable-5 review). Keyed by
149    /// `(tag, coverage)` rather than `tag` alone: D5 made `image` coverage
150    /// PER-INSTANCE (a `base64`/`url` source is `Normalized`, a Files-API/
151    /// `file` source is `Dropped`), so a single `tag -> (Coverage, count)`
152    /// entry — last-write-wins on `Coverage` — silently collapsed a mixed
153    /// corpus's genuinely-`Dropped` instances into whatever coverage the
154    /// LAST-seen instance of that tag happened to have, over- or
155    /// under-claiming fidelity depending on file order. Splitting the bucket
156    /// keeps every instance's actual coverage and never collapses counts.
157    pub blocks: BTreeMap<(String, Coverage), u64>,
158    /// Tool names seen, with counts.
159    pub tools: BTreeMap<String, u64>,
160    /// Structural notes discovered while scanning (e.g. sidechain lines).
161    pub notes: BTreeMap<String, u64>,
162}
163
164impl Report {
165    fn bump(&mut self, key: String, cov: Coverage, extra: &crate::schema::ExtraFields) {
166        let entry = self.records.entry(key).or_insert((cov, Tally::default()));
167        entry.0 = cov;
168        entry.1.count += 1;
169        for k in extra.keys() {
170            *entry.1.unmodeled_fields.entry(k.clone()).or_insert(0) += 1;
171        }
172    }
173
174    fn bump_block(&mut self, block: &ContentBlock, raw: &Value) {
175        let (tag, cov) = match block.tag() {
176            Some(t) => (t.to_string(), block_coverage(block)),
177            None => (
178                raw_block_tag(raw).unwrap_or_else(|| "<no-type>".into()),
179                Coverage::Unmodeled,
180            ),
181        };
182        // N1: bucket on (tag, coverage), not tag alone — see the `blocks`
183        // field doc. Each instance is counted under its OWN actual coverage
184        // instead of one shared, last-write-wins `Coverage` per tag.
185        *self.blocks.entry((tag, cov)).or_insert(0) += 1;
186    }
187
188    fn note(&mut self, key: &str) {
189        *self.notes.entry(key.to_string()).or_insert(0) += 1;
190    }
191
192    /// Serialize the report as structured JSON (for CI/dashboards).
193    pub fn to_json(&self) -> serde_json::Value {
194        let cov = |c: Coverage| match c {
195            Coverage::Normalized => "normalized",
196            Coverage::Retained => "retained",
197            Coverage::Dropped => "dropped",
198            Coverage::Unmodeled => "unmodeled",
199        };
200        let records: serde_json::Map<String, serde_json::Value> = self
201            .records
202            .iter()
203            .map(|(k, (c, t))| {
204                (
205                    k.clone(),
206                    serde_json::json!({
207                        "coverage": cov(*c),
208                        "count": t.count,
209                        "unmodeled_fields": t.unmodeled_fields.keys().collect::<Vec<_>>(),
210                    }),
211                )
212            })
213            .collect();
214        // N1: a tag can now have MULTIPLE coverage buckets (e.g. `image` ->
215        // Normalized:1, Dropped:1 on a mixed corpus), so each tag maps to a
216        // list of `{coverage, count}` entries rather than a single one.
217        let mut blocks_by_tag: BTreeMap<&str, Vec<serde_json::Value>> = BTreeMap::new();
218        for ((tag, c), n) in &self.blocks {
219            blocks_by_tag
220                .entry(tag.as_str())
221                .or_default()
222                .push(serde_json::json!({"coverage": cov(*c), "count": n}));
223        }
224        let blocks: serde_json::Map<String, serde_json::Value> = blocks_by_tag
225            .into_iter()
226            .map(|(k, v)| (k.to_string(), serde_json::Value::Array(v)))
227            .collect();
228        serde_json::json!({
229            "corpus": self.corpus,
230            "files": self.files,
231            "lines": self.lines,
232            "parse_errors": self.parse_errors,
233            "records": records,
234            "blocks": blocks,
235            "tools": self.tools,
236            "notes": self.notes,
237        })
238    }
239
240    /// Print a human-readable report to stdout.
241    pub fn print(&self) {
242        println!("# Coverage audit: {}", self.corpus.unwrap_or("?"));
243        println!(
244            "files={} lines={} parse_errors={}\n",
245            self.files, self.lines, self.parse_errors
246        );
247
248        println!("## Records (discriminant → coverage, count, unmodeled fields)");
249        for (key, (cov, tally)) in &self.records {
250            print!("  {}  {:<40} {:>9}", cov.symbol(), key, tally.count);
251            if !tally.unmodeled_fields.is_empty() {
252                let mut fields: Vec<_> = tally.unmodeled_fields.keys().cloned().collect();
253                fields.sort();
254                print!("   unmodeled fields: {}", fields.join(", "));
255            }
256            println!();
257        }
258
259        if !self.blocks.is_empty() {
260            println!("\n## Content blocks");
261            // N1: one row per (tag, coverage) bucket — a tag with mixed
262            // coverage (e.g. `image` seen both Normalized and Dropped) now
263            // prints as two distinct, honestly-counted rows instead of one
264            // row whose coverage was whichever instance was seen last.
265            for ((tag, cov), count) in &self.blocks {
266                println!("  {}  {:<28} {:>9}", cov.symbol(), tag, count);
267            }
268        }
269
270        if !self.notes.is_empty() {
271            println!("\n## Structural notes");
272            for (k, v) in &self.notes {
273                println!("  {k}: {v}");
274            }
275        }
276
277        if !self.tools.is_empty() {
278            println!("\n## Tools observed (top 30 by frequency)");
279            let mut tools: Vec<_> = self.tools.iter().collect();
280            tools.sort_by(|a, b| b.1.cmp(a.1));
281            for (name, count) in tools.into_iter().take(30) {
282                println!("  {count:>9}  {name}");
283            }
284        }
285
286        println!("\n## Summary of gaps (UNMODELED or dropped, non-UI)");
287        for (key, (cov, tally)) in &self.records {
288            if *cov == Coverage::Unmodeled {
289                println!("  ❌ {key} ({} occurrences) — not modeled", tally.count);
290            }
291        }
292        for ((tag, cov), count) in &self.blocks {
293            if *cov == Coverage::Unmodeled {
294                println!("  ❌ content block `{tag}` ({count}) — not modeled");
295            }
296        }
297    }
298}
299
300fn block_coverage(block: &ContentBlock) -> Coverage {
301    match block {
302        ContentBlock::Text { .. }
303        | ContentBlock::InputText { .. }
304        | ContentBlock::OutputText { .. }
305        | ContentBlock::ToolUse { .. }
306        | ContentBlock::ToolResult { .. } => Coverage::Normalized,
307        // D5 (Fable-5 review, confirmed): `image` used to be blanket-marked
308        // `Normalized` regardless of its `source` shape, but
309        // `claude_image_block_to_part` (session.rs) only actually converts
310        // `base64`/`url` sources into a replayable `content_parts` image —
311        // anything else (a Files-API `{"source":{"type":"file",...}}`
312        // reference, most commonly) is NOT carried through; the loader now
313        // emits a bracketed marker so the record survives (see
314        // `UNCONVERTIBLE_IMAGE_MARKER`), but the actual image content is
315        // still lost, so this must not claim full fidelity. Codex's
316        // `input_image` has no `source` sub-object (a bare, always-
317        // convertible `image_url` string via `codex_extract_images`) and
318        // `fallback` (folded into a text marker) are both still genuinely
319        // `Normalized`.
320        // N3 (Fable-5 review, ticket, fixed inline since it's the same
321        // `Image{source}` inspection N1 already touches): a well-typed but
322        // EMPTY `base64`/`url` source — e.g. `{"type":"base64","data":""}`
323        // — used to blanket-audit as `Normalized` just like a genuinely
324        // convertible one, but `claude_image_block_to_part` (session.rs)
325        // treats it as UNCONVERTIBLE (its own non-empty `mime`/`data`/`url`
326        // check returns `None`, same `UNCONVERTIBLE_IMAGE_MARKER` fallback
327        // path as a Files-API reference) — audit and loader must agree.
328        ContentBlock::Image { source } => image_source_coverage(source),
329        ContentBlock::InputImage { .. } | ContentBlock::Fallback { .. } => Coverage::Normalized,
330        // Provider-private reasoning: retained verbatim in
331        // (skip-serialized) `ChatMessage` metadata (`push_claude_assistant`)
332        // so a same-model continuation can replay it, but it has no slot in
333        // the canonical replayable conversation itself — "understood, not
334        // silently lost" rather than "normalized into the conversation".
335        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => Coverage::Dropped,
336        ContentBlock::Unknown => Coverage::Unmodeled, // future blocks
337    }
338}
339
340/// Score a Claude `image` block's `source` object — factored out of
341/// `block_coverage`'s `Image` arm (D5/N3 discipline: `base64`/`url` with a
342/// non-empty payload is `Normalized`, anything else is `Dropped`) so PARITY-11
343/// can reuse the EXACT same test for an `image` block nested inside a
344/// `tool_result`'s own `content` array, not just a top-level one.
345fn image_source_coverage(source: &Value) -> Coverage {
346    match source.get("type").and_then(Value::as_str) {
347        Some("base64") => {
348            let mime = source
349                .get("media_type")
350                .and_then(Value::as_str)
351                .unwrap_or("");
352            let data = source.get("data").and_then(Value::as_str).unwrap_or("");
353            if mime.is_empty() || data.is_empty() {
354                Coverage::Dropped
355            } else {
356                Coverage::Normalized
357            }
358        }
359        Some("url") => {
360            let url = source.get("url").and_then(Value::as_str).unwrap_or("");
361            if url.is_empty() {
362                Coverage::Dropped
363            } else {
364                Coverage::Normalized
365            }
366        }
367        _ => Coverage::Dropped,
368    }
369}
370
371/// PARITY-11 (nested images, skeptic-confirmed on a real session): a Claude
372/// `tool_result` block's OWN `content` array can carry `image` blocks — the
373/// everyday "Read a PNG / screenshot tool output" shape. `block_coverage`
374/// blanket-labels the enclosing `tool_result` `Normalized` (true for its text
375/// portion), which used to be the ONLY signal `audit` gave — so a session
376/// whose `tool_result` held nothing but a dropped image still reported zero
377/// `image` blocks and a clean `tool_result: Normalized` line, i.e. coverage
378/// said "retained" while the loader silently dropped the bytes. This censuses
379/// each nested `image` block individually, under its own `tool_result/image`
380/// discriminant, scored with the SAME [`image_source_coverage`] test
381/// `session.rs`'s `extract_tool_result_content` uses to decide whether it
382/// actually captures the block into `content_parts` — so a genuinely
383/// unconvertible nested image (Files-API reference, empty payload, …) shows
384/// up here as `Dropped`, not folded invisibly into the outer `Normalized`
385/// tally.
386fn audit_nested_tool_result_images(content: &Value, report: &mut Report) {
387    let Some(items) = content.as_array() else {
388        return;
389    };
390    for item in items {
391        if item.get("type").and_then(Value::as_str) != Some("image") {
392            continue;
393        }
394        let cov = image_source_coverage(item.get("source").unwrap_or(&Value::Null));
395        *report
396            .blocks
397            .entry(("tool_result/image".to_string(), cov))
398            .or_insert(0) += 1;
399    }
400}
401
402/// Audit a directory. `limit` caps the number of files scanned (None = all).
403///
404/// `Corpus::OpenCode` (PARITY-4) is special-cased: a real OpenCode data root
405/// (`~/.local/share/opencode`) holds no `.jsonl` files at all — sessions live
406/// in `opencode*.db` (current installs) or a JSON-file tree (legacy). When
407/// [`crate::session::detect_opencode_storage_surface`] resolves `dir` to the
408/// SQLite surface, this routes through
409/// [`crate::session::opencode_sqlite_corpus_envelope_text`] (up to `limit`
410/// SESSIONS, not files — `report.files` counts sessions scanned in that
411/// case) instead of the `jsonl_files` walk below, so a real store actually
412/// gets audited rather than silently reporting zero files/lines. A directory
413/// with no detected SQLite surface (e.g. a fixture dir of committed
414/// envelope-form `.jsonl` files, or a not-yet-implemented legacy JSON tree)
415/// falls back to the original file-walk unchanged.
416pub fn audit_dir(dir: &Path, corpus: Corpus, limit: Option<usize>) -> Report {
417    let mut report = Report {
418        corpus: Some(match corpus {
419            Corpus::ClaudeCode => "claude-code",
420            Corpus::Codex => "codex",
421            Corpus::Pi => "pi",
422            Corpus::OpenCode => "opencode",
423            Corpus::Grok => "grok",
424        }),
425        ..Default::default()
426    };
427
428    if corpus == Corpus::OpenCode {
429        if let Some((crate::session::OpenCodeStorageSurface::Sqlite, db_path)) =
430            crate::session::detect_opencode_storage_surface(dir)
431        {
432            return audit_opencode_sqlite(&db_path, limit, report);
433        }
434    }
435
436    let mut files = jsonl_files(dir);
437    if corpus == Corpus::Grok {
438        files.retain(|path| {
439            path.file_name().and_then(|name| name.to_str()) == Some("chat_history.jsonl")
440        });
441    }
442    let files = match limit {
443        Some(n) => &files[..files.len().min(n)],
444        None => &files[..],
445    };
446
447    for path in files {
448        report.files += 1;
449        let Ok(text) = std::fs::read_to_string(path) else {
450            continue;
451        };
452        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
453            report.lines += 1;
454            match corpus {
455                Corpus::Codex => audit_codex_line(line, &mut report),
456                Corpus::ClaudeCode => audit_claude_line(line, &mut report),
457                Corpus::Pi => audit_pi_line(line, &mut report),
458                Corpus::OpenCode => audit_opencode_line(line, &mut report),
459                Corpus::Grok => audit_grok_line(line, &mut report),
460            }
461        }
462    }
463    report
464}
465
466/// The `Corpus::OpenCode` + SQLite branch of [`audit_dir`] (PARITY-4): reads
467/// every session's `session`/`message`/`part`/`todo` records out of
468/// `db_path` as envelope lines
469/// ([`crate::session::opencode_sqlite_corpus_envelope_text`]) and scores each
470/// one exactly like a line from a committed envelope-form fixture
471/// (`audit_opencode_line` — same classifier, same coverage buckets, so a
472/// SQLite corpus and a JSON-tree/fixture corpus are held to the identical
473/// bar). `report.files` counts SESSIONS scanned (the natural unit for a
474/// single-DB corpus), not `.jsonl` files. A store that fails to open (bad
475/// path, corrupt DB, wrong schema) does not panic or silently return an
476/// empty report — the failure is recorded in `report.notes` so it is visible
477/// in both the text and `--json` renderings.
478fn audit_opencode_sqlite(db_path: &Path, limit: Option<usize>, mut report: Report) -> Report {
479    match crate::session::opencode_sqlite_corpus_envelope_text(db_path, limit) {
480        Ok(text) => {
481            for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
482                report.lines += 1;
483                // A `session` envelope line is one-per-scanned-session — use
484                // it to derive `report.files` (sessions, not `.jsonl` files)
485                // without a second SQL pass.
486                if let Ok(v) = serde_json::from_str::<Value>(line) {
487                    if v.get("key")
488                        .and_then(Value::as_array)
489                        .and_then(|k| k.first())
490                        .and_then(Value::as_str)
491                        == Some("session")
492                    {
493                        report.files += 1;
494                    }
495                }
496                audit_opencode_line(line, &mut report);
497            }
498        }
499        Err(e) => {
500            report.note(&format!("opencode_sqlite_error: {e}"));
501        }
502    }
503    report
504}
505
506fn audit_codex_line(line: &str, report: &mut Report) {
507    let raw: Value = match serde_json::from_str(line) {
508        Ok(v) => v,
509        Err(_) => {
510            report.parse_errors += 1;
511            return;
512        }
513    };
514    let parsed: Result<CodexLine, _> = serde_json::from_str(line);
515    let Ok(parsed) = parsed else {
516        report.parse_errors += 1;
517        return;
518    };
519
520    match &parsed.record {
521        CodexRecord::Unknown => {
522            let tag = raw
523                .get("type")
524                .and_then(Value::as_str)
525                .unwrap_or("<no-type>");
526            report.bump(
527                format!("<line>/{tag}"),
528                Coverage::Unmodeled,
529                &Default::default(),
530            );
531        }
532        CodexRecord::ResponseItem { payload } => audit_codex_item(payload, &raw, report),
533        CodexRecord::EventMsg { payload } => {
534            let sub = payload.kind.clone().unwrap_or_else(|| "?".into());
535            report.bump(
536                format!("event_msg/{sub}"),
537                event_msg_coverage(&sub),
538                &payload.extra,
539            );
540        }
541        // PARITY-13 (P013): `session_meta` isn't discarded — `from_codex_str`
542        // (via `capture_codex_session_meta`) threads its id/cwd/model/
543        // base_instructions/lineage fields onto `Session.meta`, and the whole
544        // record is kept verbatim in `meta.codex_headers` so a same-format
545        // re-export (`to_codex_jsonl`) replays it byte-for-byte. `Dropped`
546        // read as a silent, untracked loss; it's retained, just not folded
547        // into a `ChatMessage`.
548        CodexRecord::SessionMeta { payload } => {
549            report.bump("session_meta".into(), Coverage::Retained, &payload.extra);
550        }
551        // Same reasoning as `SessionMeta` above: `turn_context`'s `model` is
552        // threaded onto `Session.meta.model` (first occurrence) and the
553        // record itself is kept verbatim in `meta.codex_headers` (P013).
554        CodexRecord::TurnContext { payload } => {
555            report.bump("turn_context".into(), Coverage::Retained, &payload.extra);
556        }
557        CodexRecord::Compacted { .. } => {
558            // replacement_history now replaces prior turns on load.
559            report.bump(
560                "compacted".into(),
561                Coverage::Normalized,
562                &Default::default(),
563            );
564        }
565    }
566}
567
568/// PARITY-12/PARITY-13 (P012/P013): `event_msg` coverage, mirroring EXACTLY
569/// the subset of `payload.type` values `Session::from_codex_str` special-cases
570/// (see its `Some("event_msg") if payload.get("type") == Some(...)` arms) —
571/// keep these two lists in lockstep; a subtype added there without a match
572/// here regresses to a false `Dropped` again.
573///
574/// - `agent_message`: real assistant narration with no `response_item`
575///   counterpart becomes a message (the sole source of truth in some
576///   collaboration/multi-agent sessions); a duplicate of an already-normalized
577///   `response_item/message` is skipped as a no-op. Either way the loader
578///   parses and acts on it — not a blind, untracked drop.
579/// - `thread_rolled_back`: directly mutates the canonical conversation
580///   (removes the rolled-back turns) — collaboration/undo provenance that's
581///   applied, not discarded.
582/// - `thread_goal_updated`: its `goal.objective` becomes a synthesized
583///   system message when present; `goal.status`/`goal.tokenBudget` (when
584///   present) are captured onto that message's metadata too (D4) —
585///   `goal.tokensUsed`/`timeUsedSeconds`/timestamps are still real, minor
586///   residue, not claimed as retained.
587/// - `exited_review_mode`: its `review_output.overall_explanation` becomes a
588///   synthesized assistant message; `review_output.findings` (verbatim JSON:
589///   `title`/`body`/`confidence_score`/`priority`/`code_location`) AND the
590///   review verdict itself, `overall_correctness`/`overall_confidence_score`
591///   (N4 — previously neither captured nor disclosed, unlike the
592///   `thread_goal_updated` arm above which already disclosed its own
593///   residue), are captured onto that message's metadata too (D4/N4) — the
594///   only place review-mode findings/verdict live.
595///
596/// `token_count`, `task_started`/`task_complete`, `user_message` (a
597/// duplicate of the already-normalized `response_item/message[user]`),
598/// `entered_review_mode` has no canonical chat turn, but PARITY-13's portable
599/// Codex provenance envelope now retains its exact target/hint record across
600/// every foreign-format hop, so it is `Retained` rather than an untracked
601/// drop. Other UI-only echoes with no unique replayable content stay
602/// `Dropped` honestly.
603///
604/// D1 correction — this used to also claim `exec_command_begin`/`end` and
605/// `mcp_tool_call_begin`/`patch_apply_begin` were safe to drop because
606/// "already captured via the paired `response_item/function_call*`". That
607/// framing was FALSE for what those events would carry if they were ever
608/// actually present: verified against upstream `openai/codex`'s
609/// `codex-rs/rollout/src/policy.rs` `should_persist_event_msg`, all four of
610/// `EventMsg::ExecCommandBegin`, `EventMsg::ExecCommandEnd`,
611/// `EventMsg::McpToolCallBegin`, and `EventMsg::PatchApplyBegin` hit that
612/// function's `=> false` arm — **codex never writes these event kinds to a
613/// real rollout file at all.** So in a genuine `~/.codex/sessions` corpus
614/// this isn't "content safely captured elsewhere"; it's a branch that is
615/// simply never reached. `Dropped` below is defensive (a hand-edited or
616/// legacy-schema file could still carry one, and the typed schema should
617/// keep parsing it rather than falling into `Unmodeled`), not a claim that
618/// real sessions lose this content on every turn.
619///
620/// `patch_apply_end` and `mcp_tool_call_end`, by contrast, ARE persisted by
621/// real Codex (`should_persist_event_msg` `=> true` for both) — and here the
622/// old "already captured" framing is mostly right but not entirely: their
623/// short `stdout`/`result` text does duplicate the paired
624/// `response_item/function_call_output` or
625/// `response_item/custom_tool_call_output`. `patch_apply_end`'s
626/// `changes[path]`'s `unified_diff` is NOT duplicated there — the paired
627/// `function_call_output` only carries the apply summary text, never the
628/// diff body — and the loader does not capture it, so this is genuine,
629/// currently-real content loss on cross-format export. (N5: the diff body's
630/// raw hunk TEXT does have a counterpart — the paired `response_item/
631/// function_call.arguments` for the preceding `apply_patch` call carries the
632/// same added/removed lines in its own `*** Begin Patch` format, since
633/// that's literally what was applied. What's genuinely unique to
634/// `unified_diff` and absent from `function_call.arguments` is its
635/// standard-diff framing — the `--- a/<path>`/`+++ b/<path>`/`@@ …@@` header
636/// lines `apply_patch`'s custom patch format never emits. The dev/02 test
637/// below keys its residue assertion on those header lines specifically, not
638/// on the shared hunk body, so it proves the part that's actually
639/// unrecovered rather than merely re-finding text that was never at risk.)
640/// `Dropped` is the honest label for it, not "already captured" — and
641/// `parity12_cross_format_export_retains_tool_outputs_as_transcript_content`
642/// (dev/02, `crates/cli/tests/codex_fidelity_cli.rs`) now asserts this
643/// residue explicitly instead of staying silent about it.
644fn event_msg_coverage(sub: &str) -> Coverage {
645    match sub {
646        "agent_message"
647        | "thread_rolled_back"
648        | "thread_goal_updated"
649        | "entered_review_mode"
650        | "exited_review_mode" => Coverage::Retained,
651        _ => Coverage::Dropped,
652    }
653}
654
655fn audit_codex_item(item: &ResponseItem, raw: &Value, report: &mut Report) {
656    let raw_payload = raw.get("payload").cloned().unwrap_or(Value::Null);
657    let cov = if item.is_normalized() {
658        Coverage::Normalized
659    } else if matches!(item, ResponseItem::Reasoning { .. }) {
660        // D5/N1/N2/N3: `Session::from_codex_str` captures `summary` text,
661        // the raw `content` chain-of-thought text when genuinely present
662        // (N2), and a correctly-computed `encrypted_content` presence flag
663        // (N1: only a non-null value counts, not merely a present-but-null
664        // key) onto the NEXT assistant `ChatMessage`'s metadata
665        // (`reasoning`/`reasoning_content`/`reasoning_encrypted`) — or, when
666        // there is no following assistant turn to attach to, flushes it as
667        // its own synthesized message instead of discarding it (N3). Not a
668        // blind drop. The opaque `encrypted_content` blob itself isn't
669        // replayed cross-model, so this is an honest `Retained`, not
670        // `Normalized` (there's no 1:1 canonical "reasoning" `ChatMessage`).
671        // See `Coverage::Retained`'s doc comment for the LOADER-CAPTURE-ONLY
672        // disclosure that applies to all of this.
673        Coverage::Retained
674    } else {
675        // custom_tool_call, web_search_call, tool_search_*, image_generation,
676        // and any future Unknown — all not yet normalized.
677        Coverage::Unmodeled
678    };
679
680    let tag = item.tag().map(str::to_string).unwrap_or_else(|| {
681        raw_payload
682            .get("type")
683            .and_then(Value::as_str)
684            .unwrap_or("<no-type>")
685            .to_string()
686    });
687
688    match item {
689        ResponseItem::Message {
690            content,
691            extra,
692            role,
693        } => {
694            report.bump(format!("response_item/message[{role}]"), cov, extra);
695            audit_blocks(content, &raw_payload, report);
696        }
697        ResponseItem::FunctionCall { name, extra, .. } => {
698            *report.tools.entry(name.clone()).or_insert(0) += 1;
699            report.bump("response_item/function_call".into(), cov, extra);
700        }
701        ResponseItem::FunctionCallOutput { extra, .. } => {
702            report.bump("response_item/function_call_output".into(), cov, extra);
703        }
704        ResponseItem::CustomToolCall { name, extra, .. } => {
705            if let Some(n) = name {
706                *report.tools.entry(n.clone()).or_insert(0) += 1;
707            }
708            report.bump("response_item/custom_tool_call".into(), cov, extra);
709        }
710        other => {
711            let extra = item_extra(other);
712            report.bump(format!("response_item/{tag}"), cov, extra);
713        }
714    }
715}
716
717fn item_extra(item: &ResponseItem) -> &crate::schema::ExtraFields {
718    match item {
719        ResponseItem::CustomToolCallOutput { extra, .. }
720        | ResponseItem::Reasoning { extra }
721        | ResponseItem::WebSearchCall { extra }
722        | ResponseItem::ToolSearchCall { extra }
723        | ResponseItem::ToolSearchOutput { extra }
724        | ResponseItem::ImageGenerationCall { extra } => extra,
725        _ => EMPTY_EXTRA.get_or_init(Default::default),
726    }
727}
728
729static EMPTY_EXTRA: std::sync::OnceLock<crate::schema::ExtraFields> = std::sync::OnceLock::new();
730
731fn audit_claude_line(line: &str, report: &mut Report) {
732    let raw: Value = match serde_json::from_str(line) {
733        Ok(v) => v,
734        Err(_) => {
735            report.parse_errors += 1;
736            return;
737        }
738    };
739    let parsed: Result<ClaudeRecord, _> = serde_json::from_str(line);
740    let Ok(parsed) = parsed else {
741        report.parse_errors += 1;
742        return;
743    };
744
745    match &parsed {
746        ClaudeRecord::Unknown => {
747            let tag = raw
748                .get("type")
749                .and_then(Value::as_str)
750                .unwrap_or("<no-type>");
751            report.bump(
752                format!("<line>/{tag}"),
753                Coverage::Unmodeled,
754                &Default::default(),
755            );
756        }
757        ClaudeRecord::User { message, meta } | ClaudeRecord::Assistant { message, meta } => {
758            let role = match &parsed {
759                ClaudeRecord::Assistant { .. } => "assistant",
760                _ => "user",
761            };
762            report.bump(role.to_string(), Coverage::Normalized, &meta.extra);
763            if meta.is_sidechain {
764                report.note("sidechain (subagent) lines — flattened, not separated");
765            }
766            match &message.content {
767                MessageContent::Text(_) => {
768                    report.bump_block(
769                        &ContentBlock::Text {
770                            text: String::new(),
771                        },
772                        &Value::Null,
773                    );
774                }
775                MessageContent::Blocks(blocks) => {
776                    let raw_blocks = raw
777                        .get("message")
778                        .and_then(|m| m.get("content"))
779                        .cloned()
780                        .unwrap_or(Value::Null);
781                    audit_blocks(blocks, &Value::Null, report);
782                    let _ = raw_blocks;
783                    // capture tool names
784                    for b in blocks {
785                        if let ContentBlock::ToolUse { name, .. } = b {
786                            *report.tools.entry(name.clone()).or_insert(0) += 1;
787                        }
788                    }
789                }
790            }
791        }
792        ClaudeRecord::System { subtype, extra } => {
793            let sub = subtype.clone().unwrap_or_else(|| "?".into());
794            // Content-bearing system subtypes are now folded into the conversation.
795            let cov = match sub.as_str() {
796                "scheduled_task_fire" | "local_command" | "away_summary" => Coverage::Normalized,
797                _ => Coverage::Dropped,
798            };
799            report.bump(format!("system/{sub}"), cov, extra);
800        }
801        other => {
802            let tag = other.tag().unwrap_or("?");
803            let cov = match tag {
804                // Metadata/UI we deliberately skip.
805                "permission-mode" | "mode" | "last-prompt" | "queue-operation" | "ai-title"
806                | "pr-link" | "frame-link" | "agent-name" | "worktree-state" => Coverage::Dropped,
807                // Content-bearing attachment subtypes are now folded into the
808                // conversation (regenerable ones are still skipped).
809                "attachment" => Coverage::Normalized,
810                // PARITY-10: captured into `Session::meta.lineage` on load
811                // (`capture_claude_meta` in `session.rs`) and re-emitted
812                // verbatim by the Claude Code writer — no longer silently
813                // dropped, even though (like `attachment`) it has no slot in
814                // the OpenAI-shaped canonical message conversation itself.
815                "fork-context-ref" => Coverage::Normalized,
816                // These still carry real content/structure we don't yet use:
817                // file-history-snapshot / file-history-delta (undo state),
818                // started/result (subagent task lifecycle).
819                _ => Coverage::Unmodeled,
820            };
821            report.bump(tag.to_string(), cov, record_extra(other));
822        }
823    }
824}
825
826fn record_extra(rec: &ClaudeRecord) -> &crate::schema::ExtraFields {
827    match rec {
828        ClaudeRecord::Attachment { extra }
829        | ClaudeRecord::FileHistorySnapshot { extra }
830        | ClaudeRecord::FileHistoryDelta { extra }
831        | ClaudeRecord::AiTitle { extra }
832        | ClaudeRecord::PermissionMode { extra }
833        | ClaudeRecord::Mode { extra }
834        | ClaudeRecord::LastPrompt { extra }
835        | ClaudeRecord::QueueOperation { extra }
836        | ClaudeRecord::PrLink { extra }
837        | ClaudeRecord::FrameLink { extra }
838        | ClaudeRecord::AgentName { extra }
839        | ClaudeRecord::Started { extra }
840        | ClaudeRecord::Result { extra }
841        | ClaudeRecord::WorktreeState { extra }
842        | ClaudeRecord::ForkContextRef { extra } => extra,
843        _ => EMPTY_EXTRA.get_or_init(Default::default),
844    }
845}
846
847fn audit_blocks(blocks: &[ContentBlock], raw_payload: &Value, report: &mut Report) {
848    let raw_blocks = raw_payload.get("content").and_then(Value::as_array);
849    for (i, b) in blocks.iter().enumerate() {
850        let raw = raw_blocks
851            .and_then(|arr| arr.get(i))
852            .cloned()
853            .unwrap_or(Value::Null);
854        report.bump_block(b, &raw);
855        // PARITY-11: census any `image` block nested inside this
856        // `tool_result`'s own `content` array separately — see
857        // `audit_nested_tool_result_images`'s doc comment.
858        if let ContentBlock::ToolResult { content, .. } = b {
859            audit_nested_tool_result_images(content, report);
860        }
861    }
862}
863
864/// Audit one line of a pi session file (`docs/interop/opencode-pi-spec.md`
865/// §1.1/§4.1, `pi-fields.md`). Unlike the Claude/Codex auditors this walks
866/// raw [`Value`]s rather than a typed `crate::schema` module — Wave A scopes
867/// the typed-schema mirror to a later pass; the tally/Unknown-bucket
868/// machinery this function drives is the same [`Report`] used everywhere
869/// else, so the coverage guard test reads identically.
870///
871/// `message.role` is tallied as a **second-level discriminant** under its own
872/// `message/…` keys, with an `message/UnknownRole:<role>` bucket for any role
873/// outside pi's five modeled ones — pi's `message.role` is an OPEN,
874/// extension-mergeable union (§1.1 S6), so a role the loader doesn't
875/// recognize must surface here as a scored `Unmodeled` entry, not vanish.
876///
877/// A second, orthogonal second-level bucket — `message/UnknownImageShape` —
878/// covers FIX #2: `user`/`toolResult`/`custom` content can carry an
879/// `ImageContent` block whose `{mimeType, data}` shape is an unverified guess
880/// (`pi-fields.md` never enumerates `ImageContent`'s own fields). A block
881/// that doesn't match that shape must score `Unmodeled` here too, instead of
882/// letting the loader silently synthesize an empty/corrupt `image_url` part.
883fn audit_pi_line(line: &str, report: &mut Report) {
884    let raw: Value = match serde_json::from_str(line) {
885        Ok(v) => v,
886        Err(_) => {
887            report.parse_errors += 1;
888            return;
889        }
890    };
891    let extra = EMPTY_EXTRA.get_or_init(Default::default);
892    let Some(ty) = raw.get("type").and_then(Value::as_str) else {
893        report.bump("<line>/<no-type>".to_string(), Coverage::Unmodeled, extra);
894        return;
895    };
896    match ty {
897        "session" => report.bump("session".to_string(), Coverage::Normalized, extra),
898        "message" => {
899            let message = raw.get("message");
900            let role = message.and_then(|m| m.get("role")).and_then(Value::as_str);
901            // FIX #2: `user`/`toolResult`/`custom` all carry the shared
902            // `(TextContent|ImageContent)[]` content union (`pi-fields.md`
903            // §3a/§3c/§3e) — an `ImageContent` block that doesn't match the
904            // loader's assumed (and unverified) `{mimeType, data}` shape
905            // must score as `message/UnknownImageShape`, never silently
906            // `Normalized`, mirroring `UnknownRole`'s "surface it, don't
907            // vanish" rule exactly.
908            let content = message.and_then(|m| m.get("content"));
909            let unknown_image = matches!(role, Some("user") | Some("toolResult") | Some("custom"))
910                && pi_content_has_unknown_image_shape(content);
911            match role {
912                _ if unknown_image => report.bump(
913                    "message/UnknownImageShape".to_string(),
914                    Coverage::Unmodeled,
915                    extra,
916                ),
917                Some("user") => {
918                    report.bump("message/user".to_string(), Coverage::Normalized, extra)
919                }
920                Some("assistant") => {
921                    report.bump("message/assistant".to_string(), Coverage::Normalized, extra)
922                }
923                Some("toolResult") => report.bump(
924                    "message/toolResult".to_string(),
925                    Coverage::Normalized,
926                    extra,
927                ),
928                Some("bashExecution") => report.bump(
929                    "message/bashExecution".to_string(),
930                    Coverage::Normalized,
931                    extra,
932                ),
933                Some("custom") => {
934                    report.bump("message/custom".to_string(), Coverage::Normalized, extra)
935                }
936                Some(other) => report.bump(
937                    format!("message/UnknownRole:{other}"),
938                    Coverage::Unmodeled,
939                    extra,
940                ),
941                None => report.bump(
942                    "message/UnknownRole:<none>".to_string(),
943                    Coverage::Unmodeled,
944                    extra,
945                ),
946            }
947        }
948        "custom_message" => report.bump("custom_message".to_string(), Coverage::Normalized, extra),
949        "compaction" => report.bump("compaction".to_string(), Coverage::Normalized, extra),
950        "branch_summary" => report.bump("branch_summary".to_string(), Coverage::Normalized, extra),
951        "thinking_level_change" => report.bump(
952            "thinking_level_change".to_string(),
953            Coverage::Dropped,
954            extra,
955        ),
956        "model_change" => report.bump("model_change".to_string(), Coverage::Normalized, extra),
957        "custom" => report.bump("custom".to_string(), Coverage::Dropped, extra),
958        "label" => report.bump("label".to_string(), Coverage::Dropped, extra),
959        "session_info" => report.bump("session_info".to_string(), Coverage::Normalized, extra),
960        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
961    }
962}
963
964/// Score one Grok `chat_history.jsonl` record against the same shapes the
965/// native loader actually consumes. Companion `updates.jsonl` streams are
966/// excluded by [`audit_dir`], because they are ACP/runtime evidence rather
967/// than the resumable transcript.
968fn audit_grok_line(line: &str, report: &mut Report) {
969    let raw: Value = match serde_json::from_str(line) {
970        Ok(value) => value,
971        Err(_) => {
972            report.parse_errors += 1;
973            return;
974        }
975    };
976    let extra = EMPTY_EXTRA.get_or_init(Default::default);
977    let Some(kind) = raw.get("type").and_then(Value::as_str) else {
978        report.bump("<line>/<no-type>".to_string(), Coverage::Unmodeled, extra);
979        return;
980    };
981    match kind {
982        "system" => {
983            let coverage = if raw.get("content").is_some_and(Value::is_string) {
984                Coverage::Retained
985            } else {
986                Coverage::Unmodeled
987            };
988            report.bump("system".to_string(), coverage, extra);
989        }
990        "user" => {
991            let (key, coverage) = classify_grok_user(&raw);
992            report.bump(key.clone(), coverage, extra);
993            audit_grok_content(raw.get("content"), &key, coverage, report);
994        }
995        "assistant" => {
996            let content_supported = raw
997                .get("content")
998                .is_none_or(|content| content.is_null() || content.is_string());
999            report.bump(
1000                "assistant".to_string(),
1001                if content_supported {
1002                    Coverage::Normalized
1003                } else {
1004                    Coverage::Unmodeled
1005                },
1006                extra,
1007            );
1008            if let Some(calls) = raw.get("tool_calls").and_then(Value::as_array) {
1009                for call in calls {
1010                    let modeled = call.get("id").is_some_and(Value::is_string)
1011                        && call.get("name").is_some_and(Value::is_string);
1012                    report.bump(
1013                        if modeled {
1014                            "assistant/tool_call".to_string()
1015                        } else {
1016                            "assistant/tool_call:invalid".to_string()
1017                        },
1018                        if modeled {
1019                            Coverage::Normalized
1020                        } else {
1021                            Coverage::Unmodeled
1022                        },
1023                        extra,
1024                    );
1025                    if let Some(name) = call.get("name").and_then(Value::as_str) {
1026                        *report.tools.entry(name.to_string()).or_insert(0) += 1;
1027                    }
1028                }
1029            } else if raw.get("tool_calls").is_some() {
1030                report.bump(
1031                    "assistant/tool_calls:non-array".to_string(),
1032                    Coverage::Unmodeled,
1033                    extra,
1034                );
1035            }
1036        }
1037        "tool_result" => {
1038            let modeled = raw.get("tool_call_id").is_some_and(Value::is_string);
1039            report.bump(
1040                "tool_result".to_string(),
1041                if modeled {
1042                    Coverage::Normalized
1043                } else {
1044                    Coverage::Unmodeled
1045                },
1046                extra,
1047            );
1048            audit_grok_content(
1049                raw.get("content"),
1050                "tool_result",
1051                Coverage::Normalized,
1052                report,
1053            );
1054        }
1055        // These are understood native records but deliberately remain in
1056        // the byte-exact raw prefix instead of becoming replayable messages.
1057        "reasoning" | "backend_tool_call" => {
1058            report.bump(kind.to_string(), Coverage::Dropped, extra)
1059        }
1060        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1061    }
1062}
1063
1064/// Classify a Grok `user` record using the exact replay boundary enforced by
1065/// `Session::from_grok_str`: generated context wrappers are native session
1066/// state, not human turns, and therefore remain raw-only. Separate record
1067/// keys are deliberate — [`Report::records`] stores one coverage value per
1068/// key, so mixing replayed and discarded users under a single `user` key
1069/// would make the last line scanned overwrite the truth for the whole corpus.
1070fn classify_grok_user(raw: &Value) -> (String, Coverage) {
1071    if raw.get("synthetic_reason").and_then(Value::as_str) == Some("supercode_system_event") {
1072        return ("user/system_event".to_string(), Coverage::Normalized);
1073    }
1074
1075    let content = grok_audit_text(raw.get("content"));
1076    let text = content.trim();
1077    if text.starts_with("<user_info>") {
1078        return (
1079            "user/injected_context:user_info".to_string(),
1080            Coverage::Dropped,
1081        );
1082    }
1083    if text.starts_with("<system-reminder>") {
1084        return (
1085            "user/injected_context:system-reminder".to_string(),
1086            Coverage::Dropped,
1087        );
1088    }
1089    if text.is_empty() {
1090        return ("user/empty".to_string(), Coverage::Dropped);
1091    }
1092    if text
1093        .strip_prefix("<user_query>")
1094        .and_then(|value| value.strip_suffix("</user_query>"))
1095        .is_some_and(|value| value.trim().is_empty())
1096    {
1097        return ("user/empty_query".to_string(), Coverage::Dropped);
1098    }
1099    ("user".to_string(), Coverage::Normalized)
1100}
1101
1102/// Mirror the loader's text extraction for the two organic Grok shapes:
1103/// direct string content and arrays containing either `{text: ...}` blocks
1104/// or string entries. Other scalar/object values stringify exactly as the
1105/// loader does, making the audit a behavioral classification rather than a
1106/// narrower invented schema.
1107fn grok_audit_text(content: Option<&Value>) -> String {
1108    match content {
1109        Some(Value::String(text)) => text.clone(),
1110        Some(Value::Array(items)) => items
1111            .iter()
1112            .filter_map(|item| {
1113                item.get("text")
1114                    .and_then(Value::as_str)
1115                    .or_else(|| item.as_str())
1116            })
1117            .collect::<Vec<_>>()
1118            .join("\n"),
1119        Some(other) => other.to_string(),
1120        None => String::new(),
1121    }
1122}
1123
1124fn audit_grok_content(
1125    content: Option<&Value>,
1126    prefix: &str,
1127    record_coverage: Coverage,
1128    report: &mut Report,
1129) {
1130    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1131    match content {
1132        Some(Value::Array(items)) => {
1133            for item in items {
1134                let tag = item
1135                    .get("type")
1136                    .and_then(Value::as_str)
1137                    .unwrap_or("<no-type>");
1138                let modeled = item.is_string() || item.get("text").is_some_and(Value::is_string);
1139                let coverage = if record_coverage == Coverage::Dropped {
1140                    Coverage::Dropped
1141                } else if modeled {
1142                    Coverage::Normalized
1143                } else {
1144                    Coverage::Unmodeled
1145                };
1146                report.bump(format!("{prefix}/content/{tag}"), coverage, extra);
1147            }
1148        }
1149        Some(_) => report.bump(format!("{prefix}/content"), record_coverage, extra),
1150        None => report.bump(
1151            format!("{prefix}/content:<missing>"),
1152            if record_coverage == Coverage::Dropped {
1153                Coverage::Dropped
1154            } else {
1155                Coverage::Unmodeled
1156            },
1157            extra,
1158        ),
1159    }
1160}
1161
1162/// The frozen 12-part union discriminant values
1163/// (`docs/interop/research/opencode-fields.md` §3, `v1/session.ts:357-370`).
1164/// Anything outside this set is an UNKNOWN part type — never silently
1165/// dropped, always scored `Unmodeled` (§4.1's "no record/part discriminant
1166/// falls into an Unknown bucket" completeness guard).
1167const OPENCODE_KNOWN_PART_TYPES: &[&str] = &[
1168    "text",
1169    "reasoning",
1170    "tool",
1171    "file",
1172    "step-start",
1173    "step-finish",
1174    "snapshot",
1175    "patch",
1176    "agent",
1177    "subtask",
1178    "retry",
1179    "compaction",
1180];
1181
1182/// `ToolState`'s frozen discriminant values (`opencode-fields.md` §3.3,
1183/// `v1/session.ts:259-313`).
1184const OPENCODE_KNOWN_TOOL_STATUSES: &[&str] = &["pending", "running", "completed", "error"];
1185
1186/// Audit one envelope line of an OpenCode session
1187/// (`docs/interop/opencode-pi-spec.md` §1.2/§4.1): `{"key":[...],"value":...}`,
1188/// classified by the envelope `key`'s first component exactly like
1189/// [`crate::session::Session::from_opencode_str`]. Two second-level
1190/// discriminants get their own `Unknown*` buckets, mirroring pi's
1191/// `UnknownRole`/`UnknownImageShape` discipline (S6): `message/UnknownRole:*`
1192/// for a `message` record whose `role` isn't `user`/`assistant`, and
1193/// `part/UnknownType:*` for a `part` record whose `type` isn't one of the
1194/// frozen 12 — plus a THIRD level for `tool` parts specifically,
1195/// `part/tool/UnknownStatus:*`, for a `state.status` outside the frozen
1196/// four. All three must be empty over the committed fixture + real corpus.
1197fn audit_opencode_line(line: &str, report: &mut Report) {
1198    let raw: Value = match serde_json::from_str(line) {
1199        Ok(v) => v,
1200        Err(_) => {
1201            report.parse_errors += 1;
1202            return;
1203        }
1204    };
1205    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1206    let Some(key) = raw.get("key").and_then(Value::as_array) else {
1207        report.bump("<line>/<no-key>".to_string(), Coverage::Unmodeled, extra);
1208        return;
1209    };
1210    let value = raw.get("value").cloned().unwrap_or(Value::Null);
1211    let kind = key.first().and_then(Value::as_str).unwrap_or("<no-kind>");
1212    match kind {
1213        "session" => report.bump("session".to_string(), Coverage::Normalized, extra),
1214        "message" => match value.get("role").and_then(Value::as_str) {
1215            Some("user") => report.bump("message/user".to_string(), Coverage::Normalized, extra),
1216            Some("assistant") => {
1217                report.bump("message/assistant".to_string(), Coverage::Normalized, extra)
1218            }
1219            Some(other) => report.bump(
1220                format!("message/UnknownRole:{other}"),
1221                Coverage::Unmodeled,
1222                extra,
1223            ),
1224            None => report.bump(
1225                "message/UnknownRole:<none>".to_string(),
1226                Coverage::Unmodeled,
1227                extra,
1228            ),
1229        },
1230        "part" => match value.get("type").and_then(Value::as_str) {
1231            Some(t) if OPENCODE_KNOWN_PART_TYPES.contains(&t) => {
1232                if t == "tool" {
1233                    // D5: tally the tool NAME (`tool`, e.g. "bash"/"edit"),
1234                    // not just the call-status bucket — previously
1235                    // `report.tools` was always empty for opencode corpora.
1236                    if let Some(name) = value.get("tool").and_then(Value::as_str) {
1237                        *report.tools.entry(name.to_string()).or_insert(0) += 1;
1238                    }
1239                    match value
1240                        .get("state")
1241                        .and_then(|s| s.get("status"))
1242                        .and_then(Value::as_str)
1243                    {
1244                        Some(s) if OPENCODE_KNOWN_TOOL_STATUSES.contains(&s) => {
1245                            report.bump(format!("part/tool/{s}"), Coverage::Normalized, extra)
1246                        }
1247                        Some(other) => report.bump(
1248                            format!("part/tool/UnknownStatus:{other}"),
1249                            Coverage::Unmodeled,
1250                            extra,
1251                        ),
1252                        None => report.bump(
1253                            "part/tool/UnknownStatus:<none>".to_string(),
1254                            Coverage::Unmodeled,
1255                            extra,
1256                        ),
1257                    }
1258                } else if t == "text" {
1259                    // D5: an `ignored:true` text part is EXCLUDED from
1260                    // replay by design (§2.2: "must not be re-emitted to
1261                    // the model") — it is recognized and preserved in
1262                    // `raw`, but never lands in canonical `messages`, so it
1263                    // is Dropped, not Normalized. A separate discriminant
1264                    // key keeps the two counted (and displayed) apart
1265                    // rather than one overwriting the other's coverage.
1266                    let ignored = value.get("ignored").and_then(Value::as_bool) == Some(true);
1267                    if ignored {
1268                        report.bump("part/text:ignored".to_string(), Coverage::Dropped, extra);
1269                    } else {
1270                        report.bump("part/text".to_string(), Coverage::Normalized, extra);
1271                    }
1272                } else if t == "file" {
1273                    // D5: the loader only canonicalizes a `data:`-URI
1274                    // `image/*` file part into `content_parts` (the SAME
1275                    // test `opencode_file_image_part` uses, reused here so
1276                    // audit can never drift from what convert actually
1277                    // replays). An `https:` link, a bare path, a PDF, or
1278                    // any other non-image/non-data-URI file is raw-only
1279                    // residue — Dropped, not Normalized.
1280                    if opencode_file_image_part(&value).is_some() {
1281                        report.bump("part/file".to_string(), Coverage::Normalized, extra);
1282                    } else {
1283                        report.bump("part/file:residue".to_string(), Coverage::Dropped, extra);
1284                    }
1285                } else {
1286                    // compaction drives the `compacted_out` boundary —
1287                    // Normalized. reasoning feeds `metadata["thinking"]`
1288                    // (recognized, deliberately not canonical content —
1289                    // Dropped, same label Claude/Codex `thinking` blocks
1290                    // get). step-start/step-finish/snapshot/patch/agent/
1291                    // subtask/retry are recognized but have NO clean home
1292                    // at all (§2.3) — also Dropped. Only a truly
1293                    // unrecognized type is Unmodeled.
1294                    let cov = match t {
1295                        "compaction" => Coverage::Normalized,
1296                        _ => Coverage::Dropped,
1297                    };
1298                    report.bump(format!("part/{t}"), cov, extra);
1299                }
1300            }
1301            Some(other) => report.bump(
1302                format!("part/UnknownType:{other}"),
1303                Coverage::Unmodeled,
1304                extra,
1305            ),
1306            None => report.bump(
1307                "part/UnknownType:<none>".to_string(),
1308                Coverage::Unmodeled,
1309                extra,
1310            ),
1311        },
1312        "session_diff" => report.bump("session_diff".to_string(), Coverage::Normalized, extra),
1313        "todo" => report.bump("todo".to_string(), Coverage::Normalized, extra),
1314        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1315    }
1316}
1317
1318fn jsonl_files(dir: &Path) -> Vec<PathBuf> {
1319    let mut out = Vec::new();
1320    let walker = ignore::WalkBuilder::new(dir)
1321        .standard_filters(false)
1322        .build();
1323    for entry in walker.flatten() {
1324        let p = entry.into_path();
1325        if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1326            out.push(p);
1327        }
1328    }
1329    out.sort();
1330    out
1331}