Skip to main content

oracle_cases/
oracle_cases.rs

1//! The Rust half of the differential test (`bench/difftest.py`).
2//!
3//! Reads a case file, applies each case through `incise-core`, and writes one
4//! result record per case. `bench/incise_ops.py` runs the identical case list
5//! and the two outputs are compared byte-for-byte — including refusal messages,
6//! which are as much of the contract as the documents are (§5.3).
7//!
8//! Deliberately not JSON. The core crate has no dependencies and this is
9//! test-only plumbing, so cases use ASCII separator characters that cannot
10//! occur in a markdown corpus (US and RS), with backslash escaping for the
11//! newlines that can. A JSON parser here would be a hundred lines of surface
12//! whose bugs would look exactly like a port bug.
13
14use std::collections::BTreeMap;
15use std::fs;
16
17use incise_core::describe::describe_change;
18use incise_core::front::{find_frontmatter, format_path, parse_path, Fmt, Seg};
19use incise_core::heading::{find_sections, inert_headings};
20use incise_core::list::find_lists;
21use incise_core::ops::dispatch::apply_op;
22use incise_core::ops::frontmatter::{
23    describe_frontmatter_change, frontmatter_get, render_frontmatter, render_frontmatter_get,
24};
25use incise_core::ops::list::{
26    list_lists, render_list_summary, resolve_item, resolve_list, ListAddress,
27};
28use incise_core::ops::section::{
29    render_section_outline, resolve_section, section_outline, SectionAddress,
30};
31use incise_core::ops::table::{
32    list_tables, render_table_get, render_table_list, resolve_row, resolve_table, table_add_row,
33    table_delete_row, table_update_cell, TableAddress, Values,
34};
35use incise_core::table::find_tables;
36use incise_core::{args as va, json};
37
38const FS: char = '\u{1f}'; // between fields
39const RS: char = '\u{1e}'; // between args
40const GS: char = '\u{1d}'; // between an arg's key and value
41
42fn esc(s: &str) -> String {
43    s.replace('\\', "\\\\")
44        .replace('\n', "\\n")
45        .replace('\r', "\\r")
46}
47
48/// `"-"` for a field the oracle leaves as `None`, which is how the dumps spell
49/// an absent number, delimiter or checkbox without inventing a second column.
50fn opt(v: Option<String>) -> String {
51    v.unwrap_or_else(|| "-".to_string())
52}
53
54fn main() {
55    let args: Vec<String> = std::env::args().collect();
56    if args.len() != 4 {
57        eprintln!("usage: oracle_cases <cases> <corpus-root> <out>");
58        std::process::exit(2);
59    }
60    let cases = fs::read_to_string(&args[1]).expect("read cases");
61    let root = std::path::Path::new(&args[2]);
62    let mut out = String::new();
63    let mut docs: BTreeMap<String, String> = BTreeMap::new();
64
65    for line in cases.lines() {
66        if line.is_empty() {
67            continue;
68        }
69        let parts: Vec<&str> = line.split(FS).collect();
70        let (id, file, op, argblob) = (
71            parts[0],
72            parts[1],
73            parts[2],
74            parts.get(3).copied().unwrap_or(""),
75        );
76        let content = docs
77            .entry(file.to_string())
78            .or_insert_with(|| fs::read_to_string(root.join(file)).expect("read fixture"))
79            .clone();
80        let a = parse_args(argblob);
81        let (status, payload) = run(&content, op, &a);
82        out.push_str(&format!("{id}{FS}{status}{FS}{}\n", esc(&payload)));
83    }
84    fs::write(&args[3], out).expect("write results");
85}
86
87type Args = BTreeMap<String, String>;
88
89fn parse_args(blob: &str) -> Args {
90    let mut m = BTreeMap::new();
91    for pair in blob.split(RS) {
92        if pair.is_empty() {
93            continue;
94        }
95        let mut it = pair.splitn(2, GS);
96        let k = it.next().unwrap_or("").to_string();
97        let v = it.next().unwrap_or("").to_string();
98        m.insert(k, v);
99    }
100    m
101}
102
103/// Indexed args as a `where` selector. The values are `json::Value` because
104/// that is what the op takes -- `resolve_row` runs `check_cell` over them at the
105/// oracle's position, so handing it pre-stringified cells would skip the check.
106fn where_pairs(a: &Args) -> Vec<(String, json::Value)> {
107    indexed(a, "wk", "wv")
108        .into_iter()
109        .map(|(k, v)| (k, json::Value::Str(v)))
110        .collect()
111}
112
113/// Indexed args: `wk0`/`wv0`, `wk1`/`wv1`, … collected in order.
114///
115/// A repeated key overwrites in place, keeping its original position. Both sides
116/// are standing in for one JSON *object*, and `difftest.py` builds a `dict`, so
117/// `d[k] = v` on an existing key is the semantics to match — insertion order
118/// preserved, value replaced. Keeping both pairs here instead would make a
119/// duplicate-column fixture fail on a difference between the two harnesses
120/// rather than between the two implementations.
121fn indexed(a: &Args, kp: &str, vp: &str) -> Vec<(String, String)> {
122    let mut out: Vec<(String, String)> = Vec::new();
123    for i in 0.. {
124        match (a.get(&format!("{kp}{i}")), a.get(&format!("{vp}{i}"))) {
125            (Some(k), Some(v)) => match out.iter_mut().find(|(ek, _)| ek == k) {
126                Some(slot) => slot.1 = v.clone(),
127                None => out.push((k.clone(), v.clone())),
128            },
129            _ => break,
130        }
131    }
132    out
133}
134
135/// The `filter` argument of the read op, as the op takes it.
136///
137/// Three distinct inputs, and they are not interchangeable: absent (every row),
138/// an empty object (also every row, but by a different route through
139/// `check_filter`), and a populated object. `shape=filter` on the wire is how a
140/// case says "an empty object was sent" rather than "nothing was sent".
141fn filter_arg(a: &Args) -> Option<json::Value> {
142    let pairs = indexed(a, "fk", "fv");
143    if pairs.is_empty() && a.get("shape").map(String::as_str) != Some("filter") {
144        return None;
145    }
146    Some(json::Value::Object(
147        pairs
148            .into_iter()
149            .map(|(k, v)| (k, json::Value::Str(v)))
150            .collect(),
151    ))
152}
153
154fn address(a: &Args) -> TableAddress {
155    TableAddress {
156        heading: a.get("heading").map(|h| json::Value::Str(h.clone())),
157        // `difftest.py` does `int(ordinal)` before it calls, so the address
158        // carries an integer rather than a numeric string.
159        ordinal: a
160            .get("ordinal")
161            .and_then(|s| s.parse::<i64>().ok())
162            .map(json::Value::Int),
163    }
164}
165
166/// The whole argument object, smuggled through as one JSON string.
167///
168/// The flat wire format carries `KEY␝VALUE` pairs and nothing else, so every
169/// value it can express is a string. That left `check_cell`'s refusals on a
170/// boolean, a null or a nested object *inside* a filter reachable by no case at
171/// all — the F-args gap in miniature, and the stated reason `filter-value-typed`
172/// was kept out of `mutate.py`: a mutation known in advance to be unreachable
173/// measures the harness, not the code.
174///
175/// `apply_op` and `describe_change` already use this convention. Adding it here
176/// is FINDINGS' "worth doing once rather than per-op", not a second mechanism.
177fn typed_args(a: &Args) -> Option<json::Value> {
178    a.get("args").and_then(|t| json::parse(t))
179}
180
181/// The `table` field of a [`typed_args`] object as an address.
182///
183/// A bare string is shorthand for the heading, matching `_locate_table`. Any
184/// other type is passed through as the heading so the *check* is what answers
185/// it, rather than this function deciding the answer.
186fn typed_address(v: Option<&json::Value>) -> TableAddress {
187    match v {
188        Some(json::Value::Object(_)) => TableAddress {
189            heading: v.unwrap().get("heading").cloned(),
190            ordinal: v.unwrap().get("ordinal").cloned(),
191        },
192        Some(x) => TableAddress {
193            heading: Some(x.clone()),
194            ordinal: None,
195        },
196        None => TableAddress {
197            heading: None,
198            ordinal: None,
199        },
200    }
201}
202
203/// The `filter` field of a [`typed_args`] object.
204///
205/// `null` and absent collapse to `None`, because they collapse on the other
206/// side: `table_get(content, address, filter=None)` cannot tell a caller who
207/// passed `None` from one who passed nothing. Keeping them apart here would
208/// make the two sides express two different calls that only look alike on the
209/// wire. A *null filter* is `check_filter`'s business and the `check_args`
210/// cross product already reaches it; what this hatch is for is a filter that
211/// exists and holds a value no string can be.
212fn typed_filter(v: &json::Value) -> Option<json::Value> {
213    match v.get("filter") {
214        None | Some(json::Value::Null) => None,
215        Some(x) => Some(x.clone()),
216    }
217}
218
219/// The same two fields as [`address`], for the list family — a separate type
220/// on that side, so a separate builder here.
221fn list_address(a: &Args) -> ListAddress {
222    ListAddress {
223        heading: a.get("heading").map(|h| json::Value::Str(h.clone())),
224        ordinal: a
225            .get("ordinal")
226            .and_then(|s| s.parse::<i64>().ok())
227            .map(json::Value::Int),
228    }
229}
230
231/// The section family's address, off its own wire keys. Three fields rather
232/// than two, and `path` is not a spelling of `heading` — `resolve_section`
233/// reads `path` first and falls back, so a case that sends only `sheading` is
234/// exercising a branch the other two families do not have.
235fn section_address(a: &Args) -> SectionAddress {
236    SectionAddress {
237        path: a.get("spath").map(|p| json::Value::Str(p.clone())),
238        heading: a.get("sheading").map(|h| json::Value::Str(h.clone())),
239        ordinal: a
240            .get("sordinal")
241            .and_then(|s| s.parse::<i64>().ok())
242            .map(json::Value::Int),
243    }
244}
245
246fn values(a: &Args) -> Values {
247    let named = indexed(a, "vk", "vv");
248    if !named.is_empty() || a.get("shape").map(String::as_str) == Some("named") {
249        return Values::named(named);
250    }
251    let mut ordered = Vec::new();
252    for i in 0.. {
253        match a.get(&format!("vo{i}")) {
254            Some(v) => ordered.push(v.clone()),
255            None => break,
256        }
257    }
258    Values::ordered(ordered)
259}
260
261/// The raw `position`, as the op takes it -- `check_position` runs inside
262/// `table_add_row`, after the row's cells, and handing it a pre-checked
263/// `Position` here would move that refusal ahead of a bad cell.
264///
265/// `difftest.py` does `int(position)` before it calls, so a numeric case reaches
266/// the op as an integer rather than a numeric string. Mirrored exactly: the two
267/// happen to agree in `check_position`, but a case that only passes because two
268/// paths agree is not testing the path it names.
269fn position(a: &Args) -> Option<json::Value> {
270    let raw = a.get("position").map(String::as_str).unwrap_or("end");
271    Some(match raw {
272        "start" | "end" => json::Value::Str(raw.to_string()),
273        n => match n.parse::<i64>() {
274            Ok(i) => json::Value::Int(i),
275            Err(_) => json::Value::Str(n.to_string()),
276        },
277    })
278}
279
280/// One validation function against one JSON value (FINDINGS F-args).
281///
282/// The Python side answers with `repr()` of whatever the function returned, so
283/// this side answers with `json::py_repr` of the same thing -- uniformly, even
284/// where the return is already a string, so that `'0'` and `0` cannot compare
285/// equal by accident.
286fn arg_case(fname: &str, text: &str) -> Result<String, incise_core::error::OpError> {
287    let absent = text == "ABSENT";
288    let parsed = if absent { None } else { json::parse(text) };
289    // Every ARG_VALUES entry is valid JSON by construction; a `None` here would
290    // be a `parse` bug, and reporting it as such beats silently testing `None`.
291    if !absent && parsed.is_none() {
292        return Ok(format!("PARSE-FAILED {text}"));
293    }
294    let v = parsed.as_ref();
295    let opt = |name: &str| -> json::Value {
296        match v {
297            None => json::Value::Object(vec![]),
298            Some(x) => json::Value::Object(vec![(name.to_string(), x.clone())]),
299        }
300    };
301    let repr_opt = |o: Option<json::Value>| match o {
302        None => "None".to_string(),
303        Some(x) => json::py_repr(&x),
304    };
305
306    Ok(match fname {
307        "unstring_obj" => repr_opt(va::unstring(v, va::Expect::Object, "table", false)?),
308        "unstring_obj_plain" => repr_opt(va::unstring(v, va::Expect::Object, "table", true)?),
309        "unstring_objarr" => repr_opt(va::unstring(v, va::Expect::ObjectOrArray, "values", false)?),
310        "clean_keys" => repr_opt(va::clean_keys(v.cloned())),
311        "check_address" => repr_opt(va::check_address(v.cloned(), "table")?),
312        "check_heading" => match va::check_heading(v, "table")? {
313            None => "None".to_string(),
314            Some(s) => json::py_repr_str(&s),
315        },
316        "check_ordinal" => match va::check_ordinal(v, "table")? {
317            None => "None".to_string(),
318            Some(n) => n.repr(),
319        },
320        "check_position" => match va::check_position(v)? {
321            va::Position::Start => "'start'".to_string(),
322            va::Position::End => "'end'".to_string(),
323            va::Position::Index(n) => n.repr(),
324        },
325        "check_cell" => json::py_repr_str(&va::check_cell(
326            v.unwrap_or(&json::Value::Null),
327            "Component",
328            "values",
329        )?),
330        "check_where" => repr_opt(va::check_where(v.cloned())?),
331        "check_filter" => repr_opt(va::check_filter(v)?.map(|p| json::Value::Object(p.clone()))),
332        "check_column" => json::py_repr_str(&va::check_column(v)?),
333        "address" => repr_opt(va::address(&opt("table"))?),
334        "values" => repr_opt(va::values(&opt("values"), "values")?),
335        "where_arg" => repr_opt(va::where_arg(&opt("where"))?),
336        other => panic!("unknown arg fn {other}"),
337    })
338}
339
340fn run(content: &str, op: &str, a: &Args) -> (&'static str, String) {
341    match op {
342        "py_repr" => {
343            let text = a.get("text").map(String::as_str).unwrap_or("");
344            match json::parse(text) {
345                None => ("ok", "UNPARSED".to_string()),
346                Some(v) => ("ok", json::py_repr(&v)),
347            }
348        }
349        "check_args" => {
350            let f = a.get("fn").map(String::as_str).unwrap_or("");
351            let text = a.get("text").map(String::as_str).unwrap_or("");
352            match arg_case(f, text) {
353                Ok(s) => ("ok", s),
354                Err(e) => ("err", e.message().to_string()),
355            }
356        }
357        "render_table_list" => (
358            "ok",
359            render_table_list(content, a.get("path").map(String::as_str).unwrap_or("")),
360        ),
361        "list_tables" => {
362            let dump = list_tables(content)
363                .iter()
364                .map(|e| {
365                    format!(
366                        "{}|{}|{}|{}|{}",
367                        e.ordinal,
368                        e.heading,
369                        e.caption,
370                        e.columns.join(" | "),
371                        e.rows
372                    )
373                })
374                .collect::<Vec<_>>()
375                .join("\n");
376            ("ok", dump)
377        }
378        "find_tables" => {
379            let dump = find_tables(content)
380                .iter()
381                .map(|t| format!("{}|{}|{}|{}", t.start, t.end, t.is_aligned(), t.has_tabs()))
382                .collect::<Vec<_>>()
383                .join("\n");
384            ("ok", dump)
385        }
386        "find_sections" => {
387            let dump = find_sections(content)
388                .iter()
389                .map(|s| {
390                    format!(
391                        "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
392                        s.start,
393                        s.heading_end,
394                        s.own_end,
395                        s.end,
396                        s.level,
397                        s.style.as_str(),
398                        s.gap_after,
399                        s.parent.map(|p| p as isize).unwrap_or(-1),
400                        esc(&s.indent),
401                        s.marker,
402                        esc(&s.space),
403                        s.closing,
404                        esc(&s.raw_text),
405                        s.path.join(" > ")
406                    )
407                })
408                .collect::<Vec<_>>()
409                .join("\n");
410            ("ok", dump)
411        }
412        // The list parser, field by field, for the reason `find_sections` is
413        // dumped: an op suite can agree everywhere and still be sitting on a
414        // parser that disagrees about where a list ends. One line per list,
415        // then one indented line per item -- `own_end` and `parent` are in
416        // there because nothing else reads them, so nothing else would notice.
417        "find_lists" => {
418            let mut dump: Vec<String> = Vec::new();
419            for l in find_lists(content) {
420                dump.push(format!(
421                    "{}|{}|{}|{}|{}|{}",
422                    l.start,
423                    l.end,
424                    l.ordered,
425                    l.bullet,
426                    esc(&l.indent),
427                    l.loose
428                ));
429                for it in &l.items {
430                    dump.push(format!(
431                        "  {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
432                        it.start,
433                        it.own_end,
434                        it.end,
435                        it.depth,
436                        esc(&it.indent),
437                        it.marker,
438                        it.ordered,
439                        opt(it.number.map(|n| n.to_string())),
440                        opt(it.delim.map(|c| c.to_string())),
441                        opt(it.checkbox.map(|c| c.to_string())),
442                        it.parent.map(|p| p as isize).unwrap_or(-1),
443                        esc(&it.text)
444                    ));
445                }
446            }
447            ("ok", dump.join("\n"))
448        }
449        // The frontmatter parser, field by field, for the reason the other two
450        // are dumped -- plus `rebuilt()`, which no other dump has an analogue
451        // for. Every edit in the family goes out through it, so comparing it
452        // here catches a gap or padding divergence on a line no op in the suite
453        // happens to rewrite.
454        // `parse_path`, typed and round-tripped. Its own case op because the
455        // parser only ever hands out paths it built itself, so the refusal
456        // branch and the index arithmetic are reachable from no fixture.
457        "parse_path" => match parse_path(a.get("text").map(String::as_str).unwrap_or("")) {
458            None => ("ok", "NONE".to_string()),
459            Some(p) => {
460                let kinds = p
461                    .iter()
462                    .map(|s| match s {
463                        Seg::Index(n) => format!("i:{n}"),
464                        Seg::Key(k) => format!("k:{k}"),
465                    })
466                    .collect::<Vec<_>>()
467                    .join("|");
468                ("ok", format!("{}\n{kinds}", format_path(&p)))
469            }
470        },
471        "find_frontmatter" => {
472            let fm = find_frontmatter(content);
473            if !fm.present {
474                return ("ok", "absent".to_string());
475            }
476            let mut dump = vec![format!(
477                "{}|{}|{}|{}|{}|{}",
478                match fm.fmt {
479                    Some(Fmt::Yaml) => "yaml",
480                    _ => "toml",
481                },
482                fm.start,
483                fm.end,
484                fm.delim,
485                fm.close,
486                esc(&fm.eol)
487            )];
488            for e in &fm.entries {
489                dump.push(format!(
490                    "  {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
491                    format_path(&e.path),
492                    e.line,
493                    e.end,
494                    e.kind.as_str(),
495                    esc(&e.prefix),
496                    esc(&e.key_text),
497                    esc(&e.gap),
498                    esc(&e.value),
499                    esc(&e.pad),
500                    esc(&e.comment),
501                    esc(&e.eol),
502                    esc(&e.rebuilt(None))
503                ));
504            }
505            ("ok", dump.join("\n"))
506        }
507        // `describe_change`'s case, for the family it does not describe. The
508        // same shape deliberately: both sides apply the op and describe
509        // before-vs-after, so a divergence in either half shows here.
510        "describe_front" => {
511            let name = a.get("opname").map(String::as_str).unwrap_or("");
512            let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
513            let parsed = if arg == "ABSENT" {
514                None
515            } else {
516                json::parse(arg)
517            };
518            if arg != "ABSENT" && parsed.is_none() {
519                ("ok", format!("PARSE-FAILED {arg}"))
520            } else {
521                match apply_op(content, name, parsed.as_ref()) {
522                    Ok(after) => ("ok", describe_frontmatter_change(content, &after)),
523                    Err(e) => ("err", e.0),
524                }
525            }
526        }
527        "render_frontmatter" => (
528            "ok",
529            render_frontmatter(content, a.get("path").map(String::as_str).unwrap_or("")),
530        ),
531        // Through the rendered form for `render_table_get`'s reason — it is
532        // what a model receives — and through the structure as well, because
533        // that is what `keys --json` carries and `grade.py` scores. `state` and
534        // `format` appear in neither rendering, so a port that got them wrong
535        // would pass on the text alone.
536        "frontmatter_get" => {
537            let path = a.get("path").map(String::as_str).unwrap_or("");
538            // Absent and an explicit `null` are the same call here, which is
539            // the asymmetry with `frontmatter-set` the oracle spells with a
540            // `key=None` default. Parsing gives `Value::Null` for the second
541            // and the op filters it, so both arrive as no key.
542            let key = match a.get("key") {
543                None => None,
544                Some(text) => match json::parse(text) {
545                    Some(v) => Some(v),
546                    None => return ("ok", format!("PARSE-FAILED {text}")),
547                },
548            };
549            let got = match frontmatter_get(content, key.as_ref()) {
550                Ok(g) => g,
551                Err(e) => return ("err", e.0),
552            };
553            let text = match render_frontmatter_get(content, path, key.as_ref()) {
554                Ok(t) => t,
555                Err(e) => return ("err", e.0),
556            };
557            let mut dump = vec![format!(
558                "{}|{}|{}",
559                got.state,
560                match got.format {
561                    Some(Fmt::Yaml) => "yaml",
562                    Some(Fmt::Toml) => "toml",
563                    // Python's `None`, printed as an f-string prints it: the
564                    // oracle's dict holds the parser's `fmt` unconverted, and
565                    // an absent block has no format at all.
566                    None => "None",
567                },
568                got.keys.len()
569            )];
570            for k in &got.keys {
571                dump.push(format!(
572                    "{}|{}|{}|{}",
573                    k.path,
574                    k.kind,
575                    esc(&k.value),
576                    k.lines
577                ));
578            }
579            dump.push("--".to_string());
580            dump.push(text);
581            ("ok", dump.join("\n"))
582        }
583        // The list summary, field by field. The rendering is compared too, one
584        // case below -- it is the Arm B prompt, so a divergence in it is a
585        // divergence in what the model was measured on, not in a debug format.
586        "list_lists" => {
587            let dump = list_lists(content)
588                .iter()
589                .map(|e| {
590                    format!(
591                        "{}|{}|{}|{}|{}|{}|{}|{}",
592                        e.ordinal, e.heading, e.kind, e.marker, e.items, e.levels, e.loose, e.tasks
593                    )
594                })
595                .collect::<Vec<_>>()
596                .join("\n");
597            ("ok", dump)
598        }
599        "render_list_summary" => (
600            "ok",
601            render_list_summary(content, a.get("path").map(String::as_str).unwrap_or("")),
602        ),
603        // The section outline, field by field, and its rendering one case
604        // below. The render is the section family's prompt context, so a
605        // divergence in it is a divergence in what a model was measured on.
606        "section_outline" => {
607            let dump = section_outline(content)
608                .iter()
609                .map(|e| {
610                    format!(
611                        "{}|{}|{}|{}|{}|{}|{}|{}",
612                        e.level,
613                        e.path,
614                        e.text,
615                        e.style,
616                        e.ordinal,
617                        e.unique,
618                        e.has_body,
619                        e.subsections
620                    )
621                })
622                .collect::<Vec<_>>()
623                .join("\n");
624            ("ok", dump)
625        }
626        "render_section_outline" => (
627            "ok",
628            render_section_outline(content, a.get("path").map(String::as_str).unwrap_or("")),
629        ),
630        // Three ends, not one: `own_end` and `end` are the field the ops
631        // disagree about, so a resolver that found the right section with the
632        // wrong subtree boundary would pass a `start`-only comparison.
633        "resolve_section" => match resolve_section(content, &section_address(a)) {
634            Ok(s) => (
635                "ok",
636                format!(
637                    "{}|{}|{}|{}|{}",
638                    s.start, s.heading_end, s.own_end, s.end, s.level
639                ),
640            ),
641            Err(e) => ("err", e.0),
642        },
643        "resolve_list" => match resolve_list(content, &list_address(a)) {
644            Ok(l) => ("ok", format!("{}|{}", l.start, l.end)),
645            Err(e) => ("err", e.0),
646        },
647        "resolve_item" => match resolve_list(content, &list_address(a))
648            .and_then(|l| resolve_item(&l, a.get("item").map(String::as_str), "item"))
649        {
650            Ok(i) => ("ok", i.to_string()),
651            Err(e) => ("err", e.0),
652        },
653        "inert_headings" => {
654            let dump = inert_headings(content)
655                .iter()
656                .map(|h| format!("{}|{}|{}", h.line, h.reason, h.text))
657                .collect::<Vec<_>>()
658                .join("\n");
659            ("ok", dump)
660        }
661        "resolve_table" => match resolve_table(content, &address(a)) {
662            Ok(t) => ("ok", format!("{}|{}", t.start, t.end)),
663            Err(e) => ("err", e.0),
664        },
665        "resolve_row" => match resolve_table(content, &address(a))
666            .and_then(|t| resolve_row(&t, &where_pairs(a)))
667        {
668            Ok(i) => ("ok", i.to_string()),
669            Err(e) => ("err", e.0),
670        },
671        "add_row" => match table_add_row(content, &address(a), &values(a), position(a).as_ref()) {
672            Ok(s) => ("ok", s),
673            Err(e) => ("err", e.0),
674        },
675        "update_cell" => match table_update_cell(
676            content,
677            &address(a),
678            &where_pairs(a),
679            // Both raw, and both defaulted to "" -- the Python side passes
680            // `args.get("column", "")` and `args.get("value", "")`, so an absent
681            // argument is the empty string on this side too.
682            Some(&json::Value::Str(
683                a.get("column").cloned().unwrap_or_default(),
684            )),
685            Some(&json::Value::Str(
686                a.get("value").cloned().unwrap_or_default(),
687            )),
688        ) {
689            Ok(s) => ("ok", s),
690            Err(e) => ("err", e.0),
691        },
692        // The read op, through its rendered form -- which is the whole result:
693        // heading, matched/total counts, columns and every cell all appear in
694        // the text, so comparing the render compares the struct behind it.
695        // Comparing `TableRows` field by field would test a debug format that
696        // no caller ever sees.
697        "table_get" => {
698            // Two wire formats, one op. The flat one is what every table case
699            // uses; `args` is the typed hatch, and a case carrying it takes the
700            // address from there too, or an untyped address would silently
701            // override the typed one.
702            let (addr, filt) = match typed_args(a) {
703                Some(v) => (typed_address(v.get("table")), typed_filter(&v)),
704                None => (address(a), filter_arg(a)),
705            };
706            match render_table_get(content, &addr, filt.as_ref()) {
707                Ok(s) => ("ok", s),
708                Err(e) => ("err", e.0),
709            }
710        }
711        "delete_row" => match table_delete_row(content, &address(a), &where_pairs(a)) {
712            Ok(s) => ("ok", s),
713            Err(e) => ("err", e.0),
714        },
715        // The whole dispatch, arguments and all -- the op name and the raw
716        // argument text, exactly as `difftest.py` hands them to `F.apply_op`.
717        // What is compared is the message a model would actually receive, so
718        // the argument-checking *order* (dispatch.rs's whole reason to exist)
719        // is under test here and nowhere else.
720        "apply_op" => {
721            let name = a.get("opname").map(String::as_str).unwrap_or("");
722            let text = a.get("args").map(String::as_str).unwrap_or("ABSENT");
723            let parsed = if text == "ABSENT" {
724                None
725            } else {
726                json::parse(text)
727            };
728            // Every DISPATCH_ARGS entry but `ABSENT` is valid JSON by
729            // construction, so a `None` here is a `parse` bug. Saying so beats
730            // passing `None` on and quietly testing the absent-argument path.
731            if text != "ABSENT" && parsed.is_none() {
732                ("ok", format!("PARSE-FAILED {text}"))
733            } else {
734                match apply_op(content, name, parsed.as_ref()) {
735                    Ok(s) => ("ok", s),
736                    Err(e) => ("err", e.0),
737                }
738            }
739        }
740        // `describe_change` needs two documents, and a case carries one
741        // fixture. So it carries the *op* that produces the second: both sides
742        // apply it and describe before-vs-after, which keeps the wire cheap and
743        // reuses a document the corpus already vouches for. A refusal is
744        // returned as itself — those messages are compared by the `apply_op`
745        // family anyway, so nothing is lost and no case is wasted.
746        //
747        // `after` is the escape hatch for the branches no op reaches. It rides
748        // as a JSON string because a raw newline would break the line-framed
749        // case file, which is the same reason `apply_op` passes its arguments
750        // that way.
751        "describe_change" => {
752            if let Some(text) = a.get("after") {
753                return match json::parse(text) {
754                    Some(json::Value::Str(s)) => ("ok", describe_change(content, &s)),
755                    _ => ("ok", format!("PARSE-FAILED {text}")),
756                };
757            }
758            let name = a.get("opname").map(String::as_str).unwrap_or("");
759            let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
760            let parsed = if arg == "ABSENT" {
761                None
762            } else {
763                json::parse(arg)
764            };
765            if arg != "ABSENT" && parsed.is_none() {
766                ("ok", format!("PARSE-FAILED {arg}"))
767            } else {
768                match apply_op(content, name, parsed.as_ref()) {
769                    Ok(after) => ("ok", describe_change(content, &after)),
770                    Err(e) => ("err", e.0),
771                }
772            }
773        }
774        other => ("err", format!("unknown op {other}")),
775    }
776}