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