mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
CI runs the three shards at once on an empty .local/lra-catalog: two of them downloaded the same schematic into one `.part` name and one rename found it gone. The part file now carries the process id. Co-Authored-By: Claude Opus 5.5 <[email protected]>
277 lines
14 KiB
Python
277 lines
14 KiB
Python
#!/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()
|
|
# The shards download into the same folder at once: each writes its own part file.
|
|
part = path.with_name(f'{path.name}.{os.getpid()}.part')
|
|
part.write_bytes(data)
|
|
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()
|