RFC-0052 — Maximal per-language coverage fixtures (+ attribute affinity)
- Status: Draft
- Author: Mark Truluck
- Scope: (1) A family of one maximally-complex, runnable, self-asserting
fixture per target language, each exercising every Frame construct that
backend supports — closing the coverage hole that lets real-world programs
(the games) surface bugs the synthetic matrix and the cookbook do not. (2) The
per-language requirements (“what needs to be addressed”) discovered while
building each fixture. (3) An attribute-affinity change surfaced by the work:
@@[persist]defaults to the next single system,@@[*persist]broadcasts to the module. - Companions: RFC-0051 (Erlang structural lowering — the bug class that motivated this), RFC-0012/0015/0017 (persist), RFC-0043 (async).
- Tracking: (issue filed alongside this RFC).
1. Why this exists
Three test corpora, and no single one covers realistic complexity × the hard backend:
| Corpus | Complexity | Backend breadth | Blind spot |
|---|---|---|---|
| Cookbook (111 recipes, 454 conditionals) | High | 1 — python_3 only; validator compiles+runs Python only |
every non-Python backend |
| Matrix (~330 fixtures/lang) | Low — flat, single-transition shapes; migrations trimmed Erlang cases that hit gaps | 17 | control-flow × data-flow interactions |
| Games | High — real handlers | few (Lua/JS/Go) | backends nobody plays in |
The bugs this session found (#119, #125, the over-folding, 37_aggregator) live in
the empty intersection: a non-terminal mutating conditional (if c {
self.x = … } …trailing…) compiled to a backend that must reshape control flow
(Erlang). The cookbook has the complexity but only on Python (where conditionals
pass through); the matrix has the backends but not the shape; the games have both
but only in their own languages — which is exactly why the games keep finding
what the tests miss.
This RFC closes that intersection: realistic, construct-saturated programs, compiled and run with assertions on every backend.
2. The fixture family
One file per language — tests/common/positive/max_coverage/max_<lang>.<ext>
(the coverage/ dir is gitignored) — each:
- Runnable + self-asserting (TAP), executed on the real toolchain. Compile- only would miss the semantic class (over-folding compiles fine, returns wrong answers); runnable is the only configuration that would have caught these bugs.
- Maximal per language — every construct that backend supports. C omits async; Erlang omits async + multi-system (E406, one module per file); each file layers in what its capability row allows. Files are intentionally not behaviorally identical across languages — they are maximal, not comparable.
- Construct-tagged — standalone
# [tag]lines above each construct, mapping to the syntax taxonomy, so coverage is auditable bygrep.
2.1 Construct checklist (every fixture covers, where supported)
Sections (operations/interface/machine/actions/domain); states (initial,
HSM parent, HSM child => $Parent, state-with-typed-param, modal, final);
handlers (event, enter $>()/$>(p), exit <$()/<$(p)); the seven
statements (->, => $^, push$, -> pop$, $.x =, domain assign,
@@:(e)); transition variants (plain, enter args, exit args, exit+enter
(x)->(y)$S, state args); references (state arg, @@:(self.field), $.var);
attributes (target, persist/save/load, create, main, async,
no_persist); native control flow incl. the non-terminal mutating conditional
while;const; persist save/load round-trip; factory both forms; async (@@[async],await, sync-op bypass); multi-system composition.
3. Per-language requirements (“what needs to be addressed”)
Populated as each language is built. The idioms are real and recur — capturing them here is half the point of the RFC.
python_3 — ✅ complete (30/30; 2 critic rounds, clean)
coverage/max_python_3.fpy. Five systems (Job: HSM+stack+persist+const+actions;
Pump: async; Sensor+Controller: multi-system; Probe: reference/transition
constructs). Counter-based TAP, 30 asserting tests.
Authoring requirements / gotchas (these recur — that’s the point of recording them):
- Indent-sensitive splicing: construct tags must be standalone lines, never
trailing
{or leading a handler body — the dedenter mis-indents native Python. - Inline comments containing Frame sigils break the scan: a
# … @@:params.x …comment on a statement line stops the RHS context-ref from being transformed. Keep@@:/->/$out of inline comments on handler-body lines. - State param binds to a dedicated parent:
$Child => $Parent(p: T)putspon$Parent; a shared parent then breaks param-less children’s=> $^. Use a standalone$Active(p: T)(no shared parent) for a per-child param. popis a transition:-> pop$, not barepop$.- Transition arg forms:
(exit) -> (enter) $S; exit args feed the current state’s<$, enter args feed the target’s$>. - Factory:
@@Job()(operator) and, with@@[create(make)],Job.make()(static method) — both exist; exercise both. - Multi-system needs
@@[main]on the entry system (E805 otherwise). - Persist is module-scoped today (§4 / #127) — every system needed the trio.
@@[no_persist]on a field excludes it from the blob (restore → init default). Operations are always instance methods (no static form).constis read viaself.NAMEbut has no Python-side enforcement. - Async:
import asyncioprolog;await x.init()before use;asyncio.run. Syncoperations:bypass the gate. Concurrent calls trip E703 (deterministic because the gate flag is set before the firstawait).
Defects found by the build:
- #128 — FIXED (release/4.6.1). Forward transition with decorations
(
-> => $S(args),-> (e) => $S,(x) -> => $S) dropped all args + falsely flagged W414; now carries every channel + re-dispatches, validated on Python/Rust/Lua + transpile-clean all 17. Follow-up: re-add the decorated- forward construct to the Python + Rust fixtures (currently only the bare-> => $Sis exercised) so the coverage family also locks it. - Bare
@@:return(no value) emits a no-op and does not short-circuit the handler (trailing native code still runs). Still open — flagged for review, not yet filed; not encoded as a passing test.@@:return(e)and@@:(e)both work and are covered.
rust — ✅ complete (43/43; Rust-specialist critic, 2 passes, clean, 0 warnings)
coverage/max_rust.frs. Compile+run via a Cargo project (serde + serde_json +
futures); five systems; fn ck(&mut i32, bool, &str) TAP driver. Notably no
framec codegen defects surfaced for Rust — every construct works (one cosmetic
warning, #129). Requirements:
- Domain fields require a type (E605). Init must be target-valid:
String→String::from("")(a bare""is&str),f64→0.0,bool→false. - String params need
String::from(...)at call sites — no&str→Stringcoercion (self.note(String::from("N;")), not"N;"). - Operations take
&mut self(even pure ones) → callers needlet mut. Operations are callable internally asself.op(...)in a handler (distinct codegen path from the external call). - Composition is a DOMAIN field, not a state var. A sub-system in a state var
forces the generated per-state
Contexttoderive(Clone), which fails on the non-Clonesystem. Usedomain: sensor: Sensor = @@Sensor(); access nativeself.sensor.method(). - String fields: read via
self.s.clone(), mutate viaself.s.push_str(...)/format!— you can’t move out of&mut self. State vars must be declared$.x: T = init(typed Context). @@:(...)is a setter, not a return — a terminal getter must use a fullif/elseso every path’s last statement sets the slot; a fall-through trailing@@:(default)overwrites it.- Persist: module-scoped
@@[persist(String)]+ per-system@@[save(name)]/@@[load(name)](above each@@system); both are&mut selfinstance methods (save_state(&mut self)->String,restore_state(&mut self, String)— not a static constructor).@@[no_persist]field resets to its default on restore. Nested-system persist composes (self.child.save_state()embedded; restore via concretenew— the RFC-0019 E0283 path from 4.6.0).f64round- trips soundly via serde when asserted on exact dyadic values (3.0625,1.5);boolserializes as a JSON bool. - Async:
@@[async]→async fn … -> Result<T, FrameE703Error>+async init(); drive from syncmainviafutures::executor::block_on. Use an executor-agnostic await point (std::future::ready(()).await), nottokio::task::yield_now()(panics off-tokio). Sync operations bypass the gate (plainfn, noasync/Result). Async systems persist (casing+machine); save/restore round-trips (test 43).
Functional-gap verdict (answering “are these gaps?”):
- Async / E703 single-driver gate — NOT a functional gap. The async feature is
fully functional (await, sync-op bypass, persist round-trip all green). The E703
error path is not behaviorally tested because Rust’s borrow checker forbids
two concurrent
&mut selfasync calls — the single-driver contract is compile-enforced, stronger than the runtime gate, making the violation unexpressible in safe single-threaded code. The gate (Result<_, FrameE703Error>) is present and compiles. Documented limitation, nothing to address. - #129 — FIXED (release/4.6.1). Bare
@@:(@@:data.k)as a return no longer emits the redundant-paren rustc warning (the wrapper peels one balanced outer layer). Theformat!workaround in the fixture can be reverted to the bare form. - #128 — FIXED (release/4.6.1). Forward transition with decorations now works;
re-add the decorated-forward construct to this fixture (bare
-> => $Scovered).
erlang, lua, go, javascript, typescript, c, cpp, csharp, dart, gdscript, java, kotlin, php, ruby, swift
To be filled as each is built and critiqued (see §6 process). Known headers from
the idiom catalog: Erlang needs a sidecar .escript driver (headless fixture) and
excludes async + multi-system (E406, one system/file); GDScript must
extends SceneTree + quit(); C must _destroy() + has no async; Go capitalizes
exported methods + has no async; Java/C# need a Main class; indent-style =
{Lua, GDScript, Ruby} (Python done).
4. Attribute affinity — @@[persist] default + @@[*persist]
4.1 Problem (proven)
Frame annotations carry affinity by position + per-attribute convention, not a
sigil (unlike Rust #[]/#![]). Every attribute has a fixed intrinsic affinity
— except @@[persist], which is mis-classified: documented “system-level”
(frame_language.md:88) but listed “Module-Level”
(:1195), and behaving module-wide. Verified: @@[persist]
on system A forces sibling B to declare @@[save]/@@[load] or fail E814,
even when B is independent. So persist’s syntax says “this system” while its
behavior says “the module” — the one attribute whose surface lies.
4.2 Change
- Default
@@[persist(blob)]applies to the next single@@system. This makes persist consistent with every other forward attribute (@@[async],@@[main],@@[create]) — its syntax now tells the truth. @@[*persist(blob)]broadcasts to all systems in the module. The*prefix = “spread across all” (Python-splat / glob). Legal only at module position (with@@[target], before any@@system), so “current scope” is unambiguously the module; a misplaced*-attribute is a parse error. The companion@@[*save(name)]/@@[*load(name)]broadcast likewise.- E814 relaxes from “if any system persists, all must” to “each system opts in independently.” A multi-system file may now persist a subset.
- New composition rule (E828): a persistable system that
holds another system requires the held system to be persistable too — replacing
the blunt “all systems, because one did” with a precise, explained constraint.
@@[*persist]is the one-liner when you want the whole graph.
4.4 Status — implemented (issue #127)
Implemented as a strict relaxation; the matrix and the full in-repo suite are green. Assigned diagnostics:
- E828 — persist-composition-gap: a persistable
@@systemholds a non-persistable@@systemas a domain field (direct or container-wrapped). Message names both systems and points at@@[persist]-the-child or@@[*persist]-the-module. - E829 — broadcast-not-module-position: a broadcast
@@[*persist]/@@[*save]/@@[*load]appears before a specific@@systeminstead of at module position.
Mechanism:
- The broadcast
*is lexed by the dogfoodedAttributeScannerFsm(attribute_scanner.frs):$BracketNameconsumes a leading*and sets anis_broadcastflag (*is not a valid identifier start, so the change is unambiguous and leaves every existing@@[name]form byte-identical). The flag rides onSegment::Pragma { is_broadcast }. - The pipeline (
parse_module_segments) replaced the legacy module-wide persist stamp with per-system affinity: a@@[persist]is a pending attribute that attaches to the next single system and resets;@@[*persist]/@@[*save]/@@[*load]are broadcast buffers applied to every system. A broadcast pragma seen after the first@@systemraises E829. - E814 is unchanged in logic but now only fires on a system that
actually carries
persist_attr— the relaxation comes from no longer force-stamping siblings. - E828 runs as a module-level cross-system pass
(
validate_module_e828_persist_composition), mirroring the E721 sync-composes-async check.
Backward compatibility verified: existing multi-system persist fixtures
stamp the persist trio on each system, so each stamp attaches to its own
system → byte-identical output (the per-backend *_snapshots__persist
snapshots are unchanged). Only previously-E814-erroring partial-persist
files become legal.
Why prefix * (not @@>, @@^, or suffix persist*): @@! is already taken
(@@!Foo() no-init); a leading-* is unambiguous (not a valid identifier start)
and zero attributes use * today; suffix persist*(JSON) collides visually
with the arg list, prefix *persist(JSON) does not; and marking the broadcast
exception (rare) keeps the single-system default (common) unmarked — the
correct ergonomics, the inverse of marking every forward attribute.
4.3 Backward compatibility — a relaxation, not a cut
- Serialization was always per-system in mechanism (
save/loadgenerate per-system methods returning that system’s blob) — the blob format is untouched. - Existing multi-system persist fixtures (e.g.
82_persist_multi_system) already stamp@@[persist]on each system → identical outcome under the new default; they compile unchanged. - The only behavioral delta — persist on one system of a multi-system file — changes from E814 error to legal. Enabling a previously-erroring case breaks nothing that compiles today.
This is the rare syntax change that lands as a strict relaxation. (May be promoted to its own RFC; captured here because the maximal-fixture build surfaced it.)
5. Open / non-goals
- Passthrough backends, loops (none at Frame level), and the front-end grammar are unchanged.
- Whether
*generalizes to other attributes (@@[*async]?) is deferred — no current attribute besides persist needs broadcast (YAGNI).
6. Process (per language, until done)
- Refresh the docs at each language boundary (syntax + that backend’s capability row + driver idiom).
- Build the fixture by hand (contextual knowledge compounds; not delegated).
- Green on the real toolchain (TAP, all assertions pass).
- Native-expert critic loop: spawn an agent trained as an expert in the target language; have it critique the fixture and generated code for correctness, idiom, and construct completeness. Fix; repeat until it finds nothing.
- Next language. Record the language’s requirements in §3.
7. Definition of done
17 fixtures, each green on its toolchain, each construct-complete for its
capability row, each signed off by its native-expert critic; §3 fully populated;
§4 implemented (default + *persist + relaxed E814 + composition E-code) with the
matrix green.