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}