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 1python_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 by grep.

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) puts p on $Parent; a shared parent then breaks param-less children’s => $^. Use a standalone $Active(p: T) (no shared parent) for a per-child param.
  • pop is a transition: -> pop$, not bare pop$.
  • 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). const is read via self.NAME but has no Python-side enforcement.
  • Async: import asyncio prolog; await x.init() before use; asyncio.run. Sync operations: bypass the gate. Concurrent calls trip E703 (deterministic because the gate flag is set before the first await).

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 -> => $S is 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: StringString::from("") (a bare "" is &str), f640.0, boolfalse.
  • String params need String::from(...) at call sites — no &strString coercion (self.note(String::from("N;")), not "N;").
  • Operations take &mut self (even pure ones) → callers need let mut. Operations are callable internally as self.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 Context to derive(Clone), which fails on the non-Clone system. Use domain: sensor: Sensor = @@Sensor(); access native self.sensor.method().
  • String fields: read via self.s.clone(), mutate via self.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 full if/else so 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 self instance 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 concrete new — the RFC-0019 E0283 path from 4.6.0). f64 round- trips soundly via serde when asserted on exact dyadic values (3.0625, 1.5); bool serializes as a JSON bool.
  • Async: @@[async]async fn … -> Result<T, FrameE703Error> + async init(); drive from sync main via futures::executor::block_on. Use an executor-agnostic await point (std::future::ready(()).await), not tokio::task::yield_now() (panics off-tokio). Sync operations bypass the gate (plain fn, no async/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 self async 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). The format! 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 -> => $S covered).

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:

  • E828persist-composition-gap: a persistable @@system holds a non-persistable @@system as a domain field (direct or container-wrapped). Message names both systems and points at @@[persist]-the-child or @@[*persist]-the-module.
  • E829broadcast-not-module-position: a broadcast @@[*persist] / @@[*save] / @@[*load] appears before a specific @@system instead of at module position.

Mechanism:

  • The broadcast * is lexed by the dogfooded AttributeScannerFsm (attribute_scanner.frs): $BracketName consumes a leading * and sets an is_broadcast flag (* is not a valid identifier start, so the change is unambiguous and leaves every existing @@[name] form byte-identical). The flag rides on Segment::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 @@system raises 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/load generate 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)

  1. Refresh the docs at each language boundary (syntax + that backend’s capability row + driver idiom).
  2. Build the fixture by hand (contextual knowledge compounds; not delegated).
  3. Green on the real toolchain (TAP, all assertions pass).
  4. 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.
  5. 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.