Verify REPL ↔ mlog equivalence¶
Goal: confirm a
.fssnippet produces the same event sequence on the host REPL and via the in-repo mlog interpreter — the property that makes mforth a teaching compile target.Prerequisites:
- mforth installed from a checkout (
pip install -e ".[dev]"— see Install).- A
.fssnippet plus a matching.world.tomlsidecar (an empty sidecar is fine for sidecar-free programs; arithmetic-only snippets don't need any links).pyteston$PATHif you want to use the existing fixture harness instead of driving the two backends by hand.
The rationale for why this property is non-negotiable (and the
three convergence decisions that pin it — / as float division,
variable-event instrumentation, integer-valued PRINT formatting) is
covered in the project's CLAUDE.md
under "REPL ↔ mlog convergence decisions". This page is the recipe;
read that section first if you want the why.
Steps¶
-
Pick or write your snippet. Save the source and its sidecar side-by-side, sharing a stem:
cat > /tmp/mysnippet.fs <<'EOF' VARIABLE counter 3 counter ! counter @ 1 + counter ! counter @ PRINT display PRINTFLUSH EOF cat > /tmp/mysnippet.world.toml <<'EOF' [links.display] type = "message" target = "message1" [clock] realtime = false EOFThe sidecar's
[links.<name>]entry binds the mforth-name your source references (display) to an in-game block. See Sidecar schema for the full grammar. -
Run the snippet through the host REPL with event capture.
from pathlib import Path from mforth.backend.runner import Runner runner = Runner.from_path(Path("/tmp/mysnippet.fs")) runner.run_once() # one auto-loop iteration events_repl = list(runner.executor.world.events)Runner.from_pathruns the full pipeline (lex → parse → resolve → stackcheck), seeds aMockWorldfrom the sidecar, registers the canonical primitive table, and returns a runner ready to execute.run_once()runsprogram.mainexactly once;runner.executor.world.eventsis the captured event stream. -
Compile the snippet to mlog.
The emitted text is paste-ready mlog. Inspect it if you like — each line maps to one of the instructions catalogued in the mlog lowering reference.
-
Run the compiled mlog through the in-repo interpreter with event capture. The interpreter takes the same
MockWorldshape the REPL uses, plus the set of source-declaredVARIABLEnames so reads and writes of those names emitVariableReadEvent/VariableWriteEvent(sidecar-pre-seeded block handles likedisplayare deliberately excluded — they're not user variables, and the REPL doesn't instrument them either):from pathlib import Path from mforth.backend.mlog.emit import emit from mforth.backend.mlog.finalize import finalize from mforth.backend.mlog.slots import allocate_slots from mforth.backend.runner import build_world from mforth.backend.sidecar import WorldConfig, load_sidecar from mforth.dictionary import UserVariable, resolve, standard_dictionary from mforth.mlog_interp import MlogInterpreter from mforth.parse import SrcLoc, parse from mforth.stackcheck import stackcheck fs_path = Path("/tmp/mysnippet.fs") sidecar_path = fs_path.with_suffix(".world.toml") cfg = load_sidecar(sidecar_path) if sidecar_path.exists() else WorldConfig() # Pre-seed sidecar link names as UserVariable entries (matching # the runner's pipeline). Capture them BEFORE resolve, so the # interpreter can distinguish block handles from real VARIABLEs. dictionary = standard_dictionary() loc = SrcLoc(str(sidecar_path), 1, 1) for spec in cfg.links: if spec.mforth_name not in dictionary: dictionary.add_variable(UserVariable(name=spec.mforth_name, src_loc=loc)) sidecar_link_names = { e.name for e in dictionary._entries.values() if isinstance(e, UserVariable) } program = parse(fs_path.read_text(), file=str(fs_path)) dictionary = resolve(program, dictionary=dictionary) result = stackcheck(program, dictionary=dictionary) slots = allocate_slots(result) instrs = emit(result, slots) mlog_text = finalize( instrs, world_config=cfg, source_path=fs_path, sidecar_path=sidecar_path if sidecar_path.exists() else None, ) user_vars = { e.name for e in dictionary._entries.values() if isinstance(e, UserVariable) and e.name not in sidecar_link_names } world = build_world(cfg) interp = MlogInterpreter(world=world, text=mlog_text, user_variables=user_vars) interp.run(iterations=1) events_mlog = list(world.events) -
Diff the two event sequences. They should be identical modulo
timestamp(clock advances are pinned byMockWorld.waiton both sides, so timestamps match exactly in non-realtime mode):from dataclasses import fields, is_dataclass def payload_eq(a, b): if type(a) is not type(b): return False if not (is_dataclass(a) and is_dataclass(b)): return a == b for f in fields(a): if f.name == "timestamp": continue if getattr(a, f.name) != getattr(b, f.name): return False return True assert len(events_repl) == len(events_mlog), ( f"event count diverges: repl={len(events_repl)} mlog={len(events_mlog)}" ) for i, (r, m) in enumerate(zip(events_repl, events_mlog)): assert payload_eq(r, m), f"event[{i}] diverges:\n repl={r!r}\n mlog={m!r}"The event dataclasses themselves are catalogued in Reference → Events.
-
(Recommended) Use the existing pytest harness instead. The five-line manual diff above is exactly what
tests/integration/test_equivalence.pyalready runs, with a richer payload comparator (Mode Atargetsubstitution, Mode Bgetlinkprologue filtering). To enroll your snippet:cp /tmp/mysnippet.fs tests/integration/fixtures/equivalence/ cp /tmp/mysnippet.world.toml tests/integration/fixtures/equivalence/ pytest tests/integration/test_equivalence.py -k mysnippet -vThe test is parameterised by fixture discovery — dropping the pair into
fixtures/equivalence/is the entire wiring. A passing run is the canonical proof of equivalence.
Troubleshooting¶
-
/produces.0-suffixed text in the REPL but an integer in mlog (or vice versa). This was the symptom that beadmforth-dlrfixed:/now emitsop div(float division) on both surfaces. If you see a fresh case, file a regression — the convergence is pinned bytests/integration/fixtures/equivalence/divide_float.fs. -
A numeric
PRINTshows1.0on one side and1on the other. Integer-valued floats render WITHOUT the trailing.0on both surfaces (beadmforth-05h, matching the in-gameprintinstruction's stringification rule). If you see drift, the host primitive insrc/mforth/backend/primitives.py(_print) and the interpreter's_format_for_printare the convergence point. -
VariableReadEvent/VariableWriteEventappear for a sidecar link name (e.g.display). That's a bug in your harness: sidecar-pre-seeded link names are block handles, NOT user variables, and the REPL never instruments them. Make sure youruser_variablesset excludes anything that came fromcfg.links(thesidecar_link_namessnapshot in step 4 is exactly this filter — beadmforth-0qi). -
Event counts differ but every shared prefix matches. Look for a Mode B (
index = N) link in your sidecar. The compiled backend prepends onegetlink <name> <N>per Mode B link as a runtime prologue, which fires aLinkResolvedEventthe REPL doesn't emit (the REPL binds links at startup via dictionary pre-seeding).test_equivalence.py::_filter_mode_b_prologue_eventsdocuments the filter; mirror it in your own harness if you want the events to line up. -
event[i] divergesonblock_name. Mode A sidecars bindmforth-name → target = "<in-game-name>". The REPL events carry the mforth-name; the compiled mlog binds viatarget, so its events carry the in-game name. Both refer to the same block.test_equivalence.py::_payload_eqaccepts either form via the sidecar's{mforth_name: target}map — copy that pattern.
What to read next¶
- Reference → Events — every event dataclass, its fields, and when each backend emits it.
- Reference → mlog lowering — how
each Forth construct compiles into mlog instructions, so you can
read the
mforth compileoutput. - Sidecar schema — the full
.world.tomlgrammar, including the Mode A / Mode B link binding tradeoff.