Redstone catalogue: Horizon probe and the parity harness

redstone_catalog_fixture builds the same contraptions in a map-backed
horizon-world level, clicks the same levers and buttons
(Behaviours::use_without_item) and runs tick_scheduled, run_block_events
and tick_block_entities, printing what VanillaRedstoneCatalogOracle prints.

tools/test_redstone_catalog_parity.py reads the catalogue, downloads the
archive's schematics into .local/lra-catalog/ (pinned to da4dd51, one
sha256 over all 188), turns each entry into a case (switches off except
those the catalogue held on, a neighbour update on every block, then input
steps: every vector up to 8 bits, random ones beyond, random changes for
stateful builds) and compares both sides step by step. verified entries
must match; partial and unverified ones are reported. Entries whose inputs
are analog levels, lecterns or bare wire are listed as unsupported for
now. Three shards; --full runs larger sweeps and every vector up to 16
bits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 authored and Ludovic Marie committed 2026-11-05 22:15:27 +01:00
1 parent 603584ef2e
commit 4d4872cedd
3 files changed
+573

No files matched your search

@@ -147,6 +147,7 @@ mod player_hurt_fixture;
mod player_inventory_fixture;
mod player_travel_fixture;
mod portal_travel_fixture;
mod redstone_catalog_fixture;
mod registered_player_combat_fixture;
mod registry_collision_scan_fixture;
mod registry_fall_ray_fixture;
@@ -320,6 +321,7 @@ probes! {
player_inventory_fixture,
player_travel_fixture,
portal_travel_fixture,
redstone_catalog_fixture,
registered_player_combat_fixture,
registry_collision_scan_fixture,
registry_fall_ray_fixture,
@@ -0,0 +1,297 @@
//! Rust side of `tools/test_redstone_catalog_parity.py`: builds each Logical
//! Redstone Archive contraption in a map-backed level, drives its levers and
//! buttons like a player's click and runs whole level ticks (scheduled ticks,
//! block events, moving pistons), printing what `VanillaRedstoneCatalogOracle`
//! prints. Comparator levels of containers and lecterns come from the oracle
//! (`analog`): nothing here moves items.
use horizon_core::{
geometry::{Direction, Vec3},
position::{BlockPos, ChunkPos},
random::LegacyRandom,
};
use horizon_world::{
level::{Behaviours, LevelAccess, PlayerContext, Ticks, data::load_registry},
registry::BlockStateId,
};
use serde_json::Value;
use std::{
collections::HashMap,
fmt::Write as _,
io::{BufRead, Write},
path::Path,
};
struct Cells {
cells: HashMap<BlockPos, BlockStateId>,
air: BlockStateId,
random: LegacyRandom,
time: i64,
analog: HashMap<BlockPos, i32>,
}
impl LevelAccess for Cells {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.cells.get(&pos).copied().unwrap_or(self.air)
}
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
let old = if state == self.air {
self.cells.remove(&pos)
} else {
self.cells.insert(pos, state)
};
Some(old.unwrap_or(self.air))
}
fn is_loaded(&self, _pos: BlockPos) -> bool {
true
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
self.time
}
fn block_entity_analog_signal(
&self,
pos: BlockPos,
_state: BlockStateId,
_direction: Direction,
) -> i32 {
self.analog.get(&pos).copied().unwrap_or(0)
}
}
/// The oracle's `mix`: one block's share of the box hash.
fn mix(x: i32, y: i32, z: i32, value: u64) -> u64 {
let mut k = (x as i64 as u64)
.wrapping_mul(0x9E3779B97F4A7C15)
.wrapping_add((y as i64 as u64).wrapping_mul(0xC2B2AE3D27D4EB4F))
.wrapping_add((z as i64 as u64).wrapping_mul(0x165667B19E3779F9))
.wrapping_add(value.wrapping_mul(0xD6E8FEB86659FD93));
k = (k ^ (k >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
k = (k ^ (k >> 27)).wrapping_mul(0x94D049BB133111EB);
k ^ (k >> 31)
}
fn ints(value: &Value) -> Vec<i64> {
value
.as_array()
.unwrap()
.iter()
.map(|v| v.as_i64().unwrap())
.collect()
}
fn pos_of(c: &[i64]) -> BlockPos {
BlockPos::new(c[0] as i32, c[1] as i32, c[2] as i32)
}
/// What the registry says about a state: lever (and whether it is on), button, comparator.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Lever(bool),
Button,
Comparator,
Other,
}
struct Run<'a> {
behaviours: &'a Behaviours,
kinds: &'a [Kind],
level: Cells,
ticks: Ticks,
outputs: Vec<BlockPos>,
/// The hashed box: the schematic and a margin of 2.
min: BlockPos,
max: BlockPos,
/// Ticks the contraption may still run (the oracle counts the same).
left: i64,
/// `HORIZON_CATALOG_TRACE`: every block at a step's end, as `# cell` lines.
trace: bool,
out: String,
}
impl Run<'_> {
fn kind(&self, state: BlockStateId) -> Kind {
self.kinds
.get(state.0 as usize)
.copied()
.unwrap_or(Kind::Other)
}
fn comparator(&self, pos: BlockPos) -> Option<i32> {
(self.kind(self.level.block_state(pos)) == Kind::Comparator).then(|| {
self.ticks
.comparator_outputs
.get(&pos)
.copied()
.unwrap_or(0)
})
}
fn outputs(&self) -> String {
let mut s = String::new();
for (i, &pos) in self.outputs.iter().enumerate() {
if i > 0 {
s.push(',');
}
let _ = write!(s, "{}", self.level.block_state(pos).0);
if let Some(out) = self.comparator(pos) {
let _ = write!(s, "/{out}");
}
}
s
}
fn quiet(&self) -> bool {
self.ticks.blocks.count() == 0
&& self.ticks.fluids.count() == 0
&& self.ticks.block_events.is_empty()
&& self.ticks.moving_pistons.is_empty()
}
/// `ServerLevel.tick`'s block work until the level is quiet or `limit` ticks.
fn ticks(&mut self, limit: i64) {
let mut last = self.outputs();
let _ = writeln!(self.out, "t 0 {last}");
let mut t = 0;
while t < limit && self.left > 0 && !self.quiet() {
t += 1;
self.left -= 1;
self.level.time += 1;
let b = self.behaviours;
b.tick_scheduled(&mut self.level, &mut self.ticks);
b.run_block_events(&mut self.level, &mut self.ticks);
b.tick_block_entities(&mut self.level, &mut self.ticks);
let now = self.outputs();
if now != last {
let _ = writeln!(self.out, "t {t} {now}");
}
last = now;
}
let mut hash = 0u64;
for (&pos, &state) in &self.level.cells {
let inside = (self.min.x..=self.max.x).contains(&pos.x)
&& (self.min.y..=self.max.y).contains(&pos.y)
&& (self.min.z..=self.max.z).contains(&pos.z);
if inside {
let extra = self.comparator(pos).map_or(0, |o| ((o + 1) as u64) << 32);
let value = state.0 as u64 + extra;
hash = hash.wrapping_add(mix(pos.x, pos.y, pos.z, value));
if self.trace {
let _ = writeln!(self.out, "# cell {},{},{} {value}", pos.x, pos.y, pos.z);
}
}
}
let quiet = u8::from(self.quiet());
let _ = writeln!(self.out, "end {t} {quiet} {hash:x}");
}
fn act(&mut self, pos: BlockPos, on: bool) {
let use_it = match self.kind(self.level.block_state(pos)) {
Kind::Lever(powered) => powered != on,
Kind::Button => on,
_ => false,
};
if use_it {
let hit = Vec3::new(pos.x as f64 + 0.5, pos.y as f64 + 0.5, pos.z as f64 + 0.5);
let player = PlayerContext {
yaw: 0.0,
pitch: 0.0,
may_build: true,
can_eat: false,
game_master: false,
};
self.behaviours.use_without_item(
&mut self.level,
&mut self.ticks,
pos,
Direction::Up,
hit,
player,
);
}
}
}
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = crate::args().skip(1);
let resources = args.next().expect("resource directory");
let cases = args.next().expect("case file");
let output = args.next().expect("output file");
let resources = Path::new(&resources);
let registry = load_registry(resources)?;
let behaviours = Behaviours::load(&registry, resources)?;
let air = registry.default_state("minecraft:air").unwrap();
let kinds: Vec<Kind> = (0..registry.len() as u32)
.map(|id| match registry.state(BlockStateId(id)) {
Some(s) if s.name == "minecraft:lever" => {
Kind::Lever(s.properties.get("powered").is_some_and(|v| v == "true"))
}
Some(s) if s.name.ends_with("_button") => Kind::Button,
Some(s) if s.name == "minecraft:comparator" => Kind::Comparator,
_ => Kind::Other,
})
.collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for line in std::io::BufReader::new(std::fs::File::open(cases)?).lines() {
let case: Value = serde_json::from_str(&line?)?;
let size = ints(&case["size"]);
let mut ticks = Ticks::new(0);
for x in -1..=(size[0] as i32 >> 4) + 1 {
for z in -1..=(size[2] as i32 >> 4) + 1 {
ticks.add_empty_chunk(ChunkPos::new(x, z))?;
}
}
let cells: Vec<Vec<i64>> = case["cells"].as_array().unwrap().iter().map(ints).collect();
let mut level = Cells {
cells: HashMap::new(),
air,
random: LegacyRandom::new(0),
time: case["time"].as_i64().unwrap(),
analog: HashMap::new(),
};
for c in &cells {
level.set_raw(pos_of(c), BlockStateId(c[3] as u32));
}
for c in case["comparators"].as_array().unwrap().iter().map(ints) {
ticks.comparator_outputs.insert(pos_of(&c), c[3] as i32);
}
for c in case["analog"].as_array().unwrap().iter().map(ints) {
level.analog.insert(pos_of(&c), c[3] as i32);
}
let outputs = case["outputs"]
.as_array()
.unwrap()
.iter()
.map(|v| pos_of(&ints(v)))
.collect();
let mut run = Run {
behaviours: &behaviours,
kinds: &kinds,
level,
ticks,
outputs,
min: BlockPos::new(-2, -2, -2),
max: BlockPos::new(size[0] as i32 + 1, size[1] as i32 + 1, size[2] as i32 + 1),
left: case["budget"].as_i64().unwrap(),
trace: std::env::var_os("HORIZON_CATALOG_TRACE").is_some(),
out: String::new(),
};
let _ = writeln!(run.out, "case {}", case["code"].as_str().unwrap());
for c in &cells {
let pos = pos_of(c);
let block = behaviours.block(run.level.block_state(pos));
behaviours.handle_neighbor_changed(&mut run.level, &mut run.ticks, pos, block);
}
run.out.push_str("step init\n");
run.ticks(case["settle"].as_i64().unwrap());
let limit = case["max"].as_i64().unwrap();
for (index, step) in case["steps"].as_array().unwrap().iter().enumerate() {
if run.left <= 0 {
run.out.push_str("budget spent\n");
break;
}
let _ = writeln!(run.out, "step {index}");
for act in step.as_array().unwrap().iter().map(ints) {
run.act(pos_of(&act), act[3] != 0);
}
run.ticks(limit);
}
out.write_all(run.out.as_bytes())?;
out.flush()?;
}
Ok(())
}
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Run the Logical Redstone Archive's contraptions in vanilla and in Horizon, tick by tick.
The catalogue (`tools/lra-catalog/`, by maybeachromium, WTFPL) lists each archive entry's input and output
positions and what it was checked against. The archive's schematics have no licence and are never committed:
they are downloaded into `.local/lra-catalog/` and checked against the digest pinned here.
Each contraption is built from its schematic in `VanillaRedstoneCatalogOracle` (a real flat-world server)
and in `redstone_catalog_fixture` (horizon-world). Switches start off (except those the catalogue held on),
every block gets a neighbour update, and the level runs until quiet. Then each step sets the input levers
and presses buttons as a player's click would, and runs whole level ticks until quiet. Both sides print the
outputs that changed after every tick, the tick the step ended on, and a hash of every block in the box.
`verified` entries must match; `partial` and `unverified` ones are reported (expected unknown). Inputs the
fixture can't drive yet (analog levels, lecterns, bare wire) are listed as unsupported.
"""
import argparse
import hashlib
import json
import os
import random
import re
import subprocess
import time
import urllib.parse
import urllib.request
from pathlib import Path
try:
from . import lab, lra_schem, test_rust_parity as base
except ImportError:
import lab
import lra_schem
import test_rust_parity as base
ARCHIVE = 'mattbatwings/Logical-Redstone-Archive'
COMMIT = 'da4dd515559458140f78a555600e7e4607ac6b8c'
CATALOG = lab.ROOT / 'tools/lra-catalog/entries.jsonl'
# sha256 of the sorted "<path> <sha256>" lines of every entry's schematic at COMMIT.
SCHEMATICS_SHA256 = 'a02f086c05b4280605db0c992867632c4c5f0f91fc4648a4933a4850f70ed19c'
DATA = lab.LOCAL / 'lra-catalog'
DRIVEN = {'lever', 'stone_button', 'oak_button', 'birch_button'}
# tools/parity.py runs NAME#1..#3 in parallel (PARITY_SHARD=i/3), each with every third contraption.
PARITY_SHARDS = 3
def run(command, full, **kwargs):
"""test_rust_parity.run, without its 15-minute hang guard for --full."""
if not full:
return base.run(command, **kwargs)
subprocess.run(command, cwd=lab.ROOT, check=True, **kwargs)
def load_catalog():
return [json.loads(line) for line in CATALOG.read_text(encoding='utf-8').splitlines() if line]
def fetch_schematics(entries):
"""Each entry's schematic at COMMIT, under DATA/schematics; all of them checked against SCHEMATICS_SHA256."""
root = DATA / 'schematics'
sums = {}
for entry in entries:
rel = entry.get('schematic')
if not rel or entry['status'] == 'no_schematic':
continue
if '..' in Path(rel).parts or rel.startswith('/'):
raise ValueError(f'bad schematic path {rel}')
path = root / rel
if not path.exists():
url = f'https://raw.githubusercontent.com/{ARCHIVE}/{COMMIT}/' + urllib.parse.quote(rel)
path.parent.mkdir(parents=True, exist_ok=True)
request = urllib.request.Request(url, headers={'User-Agent': 'horizon/0.1'})
with urllib.request.urlopen(request, timeout=120) as response:
data = response.read()
path.with_suffix('.part').write_bytes(data)
path.with_suffix('.part').replace(path)
sums[rel] = lab.digest(path, 'sha256')
listing = ''.join(f'{rel} {sums[rel]}\n' for rel in sorted(sums))
combined = hashlib.sha256(listing.encode()).hexdigest()
if combined != SCHEMATICS_SHA256:
(DATA / 'schematics.sha256').write_text(listing)
raise SystemExit(f'schematics digest {combined} is not the pinned {SCHEMATICS_SHA256}: see {DATA / "schematics.sha256"}')
return root
class States:
"""Schematic state strings -> vanilla state ids (`facts.json`), missing properties at their defaults."""
def __init__(self, resources):
facts = json.loads((resources / 'facts.json').read_text())
self.ids, self.props = {}, []
for index, state in enumerate(facts['states']):
name, props = lra_schem.parse_state(state[3].replace('Block{', '').replace('}', '', 1))
self.ids[(name, tuple(sorted(props.items())))] = index
self.props.append((name, props))
self.defaults = facts['defaults']
def id(self, text, **override):
name, props = lra_schem.parse_state(text)
full = dict(self.props[self.defaults[name]][1])
full.update({k: v for k, v in props.items() if k in full})
full.update(override)
return self.ids[(name, tuple(sorted(full.items())))]
def held_on(entry):
"""Switches the catalogue's runs held on (`other_switches`), as positions; 'as saved' keeps saved ones."""
text = ' '.join(str(v.get('other_switches', 'off')) for v in entry['verified'] or [])
held = {tuple(map(int, m)) for m in re.findall(r'\[(\d+), (\d+), (\d+)', text)}
saved = re.search(r'at x=(\d+), y=(\d+) on \(as saved\)', text)
return held, (tuple(map(int, saved.groups())) if saved else None)
def steps(entry, bits, full):
"""Input vectors: every one when few enough (or random ones), random changes for stateful builds."""
rng = random.Random(entry['code'])
groups = [[tuple(p) for p in g['positions']] for g in entry['inputs']]
sequence = any('sequence' in v['method'] for v in entry['verified'] or [])
if sequence:
out = []
for _ in range(512 if full else 24):
group = rng.choice(groups)
out.append([[*p, rng.randrange(2)] for p in group])
return out
exhaustive = len(bits) <= (16 if full else 8)
vectors = range(1 << len(bits)) if exhaustive else [0, (1 << len(bits)) - 1] + [rng.getrandbits(len(bits)) for _ in range(1024 if full else 24)]
return [[[*p, (v >> i) & 1] for i, p in enumerate(bits)] for v in vectors]
def build(entry, schematics, states, full):
"""The case both sides run, or the reason it can't be run."""
kinds = {b for g in entry['inputs'] for b in g['blocks']}
if not kinds:
return None, 'no inputs mapped'
if not kinds <= DRIVEN or any(g['encoding'] != 'binary' for g in entry['inputs']):
return None, 'unsupported inputs: ' + ','.join(sorted(kinds - DRIVEN) or ['analog levels'])
schem = lra_schem.read(schematics / entry['schematic'])
held, saved_row = held_on(entry)
cells, comparators, containers, lecterns = [], [], [], []
for x, y, z, text in schem['cells']:
name, props = lra_schem.parse_state(text)
override = {}
if name == 'minecraft:lever' or name.endswith('_button'):
keep = name == 'minecraft:lever' and saved_row and (x, y) == saved_row and props.get('powered') == 'true'
override['powered'] = 'true' if (x, y, z) in held or keep else 'false'
cells.append([x, y, z, states.id(text, **override)])
for be in schem['block_entities']:
x, y, z = be['pos']
data = be['data']
if 'OutputSignal' in data:
comparators.append([x, y, z, data['OutputSignal']])
if isinstance(data.get('Items'), list):
items = [[i.get('Slot', 0), i.get('id', 'minecraft:air'), i.get('count', i.get('Count', 1))] for i in data['Items']]
containers.append([x, y, z, items])
if isinstance(data.get('Book'), dict):
book = data['Book']
pages = book.get('components', {}).get('minecraft:written_book_content', book.get('components', {}).get('minecraft:writable_book_content', {})).get('pages') or book.get('tag', {}).get('pages') or []
lecterns.append([x, y, z, len(pages), data.get('Page', 0)])
bits = [tuple(p) for g in entry['inputs'] for p in g['positions']]
outputs = [p for g in entry['outputs'] for p in g['positions']]
return {'code': entry['code'], 'size': schem['size'], 'cells': cells, 'comparators': comparators, 'containers': containers,
'lecterns': lecterns, 'outputs': outputs, 'settle': 600, 'max': 300, 'budget': 60000 if full else 2500,
'steps': steps(entry, bits, full)}, None
def split(text):
"""Output text -> {code: (lines compared, analog lines, other lines)}."""
out, code = {}, None
for line in text.splitlines():
if line.startswith('case '):
code = line[5:]
out[code] = ([], [], [])
elif line.startswith('# '):
continue
elif line.startswith('analog '):
out[code][1].append(line[7:])
elif line.startswith(('drift ', 'error ', 'time ')):
out[code][2].append(line)
else:
out[code][0].append(line)
return out
def first_difference(a, b):
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
return i
return min(len(a), len(b))
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--full', action='store_true', help='every entry, larger sweeps (exhaustive up to 16 bits)')
parser.add_argument('--only', help='comma separated codes')
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
shard, shards = map(int, os.environ.get('PARITY_SHARD', '1/1').split('/'))
work = lab.LOCAL / 'redstone-catalog-parity' / (f'shard-{shard}' if shards > 1 else 'all')
work.mkdir(parents=True, exist_ok=True)
entries = load_catalog()
schematics = fetch_schematics(entries)
entries = [e for e in entries if not args.only or e['code'] in args.only.split(',')][shard - 1::shards]
states = States(resources)
cases, skipped = [], {}
for entry in entries:
if entry['status'] == 'no_schematic':
continue
case, why = build(entry, schematics, states, args.full)
if case:
cases.append(case)
else:
skipped[entry['code']] = why
(work / 'vanilla-cases.jsonl').write_text(''.join(json.dumps(c, separators=(',', ':')) + '\n' for c in cases))
classes = work / 'classes'
classes.mkdir(exist_ok=True)
deps = os.pathsep.join(map(str, [jars['server'], *libraries]))
java_root = lab.ROOT / 'mods/horizon/src/test/java'
oracle = java_root / 'dev/horizonlab/parity/VanillaRedstoneCatalogOracle.java'
started = time.time()
run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', str(java_root),
'-cp', deps, '-d', str(classes), str(oracle)], args.full)
run(['java', '-Xmx4G', '-cp', str(classes) + os.pathsep + deps, 'dev.horizonlab.parity.VanillaRedstoneCatalogOracle',
str(work / 'vanilla-cases.jsonl'), str(work / 'vanilla.txt')], args.full, stdout=subprocess.DEVNULL)
vanilla = split((work / 'vanilla.txt').read_text())
for case in cases:
notes = vanilla.get(case['code'], ([], [], []))[2]
case['time'] = next((int(n[5:]) for n in notes if n.startswith('time ')), 0)
case['analog'] = [[*map(int, a.split(' ')[0].split(',')), int(a.split(' ')[1])] for a in vanilla.get(case['code'], ([], [], []))[1]]
(work / 'rust-cases.jsonl').write_text(''.join(json.dumps(c, separators=(',', ':')) + '\n' for c in cases))
run(['cargo', 'build', '--release', '-p', 'horizon-world', '--example', 'world_probe'], args.full)
probe = lab.cargo_target() / 'release/examples' / ('world_probe.exe' if os.name == 'nt' else 'world_probe')
run([str(probe), 'redstone_catalog_fixture', str(resources), str(work / 'rust-cases.jsonl'), str(work / 'rust.txt')], args.full)
rust = split((work / 'rust.txt').read_text())
status = {e['code']: e['status'] for e in entries}
report, failures = {}, []
for case in cases:
code = case['code']
a, _, notes = vanilla.get(code, ([], [], ['error missing']))
b = rust.get(code, ([], [], []))[0]
notes = [n for n in notes if not n.startswith('time ')]
if any(n.startswith('error timeout') for n in notes):
result = {'result': 'oracle timeout', 'notes': notes, 'why': 'vanilla took over 4 minutes: not compared'}
elif any(n.startswith('error') for n in notes):
result = {'result': 'oracle error', 'notes': notes}
elif any(n.startswith('drift') for n in notes):
result = {'result': 'not comparable', 'notes': notes[:5], 'why': 'vanilla moved items: container levels changed'}
elif a == b:
result = {'result': 'match', 'steps': sum(line.startswith('step') for line in a)}
else:
i = first_difference(a, b)
step = next((line for line in reversed(a[:i + 1]) if line.startswith('step')), '?')
result = {'result': 'mismatch', 'at': step, 'vanilla': a[i:i + 4], 'horizon': b[i:i + 4]}
result['status'] = status[code]
report[code] = result
if status[code] == 'verified' and result['result'] in ('mismatch', 'oracle error'):
failures.append(code)
for code, why in skipped.items():
report[code] = {'result': 'skipped', 'why': why, 'status': status[code]}
lab.write_json(work / 'report.json', report)
counts = {}
for r in report.values():
counts.setdefault((r['status'], r['result']), 0)
counts[(r['status'], r['result'])] += 1
for (st, res), n in sorted(counts.items()):
print(f'{st:>10} {res}: {n}')
for code in sorted(report):
r = report[code]
if r['result'] == 'mismatch':
print(f'{code} ({r["status"]}): first difference in {r["at"]}\n vanilla {r["vanilla"]}\n horizon {r["horizon"]}')
print(f'{len(cases)} contraptions run in {time.time() - started:.0f} s; report: {work / "report.json"}')
raise SystemExit(1 if failures else 0)
if __name__ == '__main__':
main()