Files
AILang/docs/specs/0015-roundtrip-invariant.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

11 KiB
Raw Blame History

Roundtrip Invariant — Design Spec

Date: 2026-05-12 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Establish that .ail.json.ailx form a structurally lossless bijection on the module level, and lift this property from a Decision-6-internal constraint to a prominently anchored top-level invariant of the language. Enforce it workspace-wide by a coordinated test suite that collects fixtures dynamically (no hardcoded fixture lists) and verifies that every AST schema variant is exercised by at least one fixture.

Two motivating uses:

  1. Independent value. Per Decision 6, .ailx is "the AI authoring projection". If programs are expressible in one form but not the other, the projection claim is partial — a first-class bug class that the language design admits no allowance for.
  2. Prerequisite for the cross-model authoring-form test (P2 roadmap entry). Measured form-preference across multiple downstream LLM subjects is only interpretable if both forms are exactly equipotent in expressive power. Without this milestone the cross-model test confounds expressive-power gaps with authoring ergonomics.

Architecture

Three coordinated test layers plus a DESIGN.md anchor:

  1. DESIGN.md anchor. A new top-level section "Roundtrip Invariant", positioned between the Decisions block and the schema spec block, on the same visibility level as the existing "Feature-acceptance criterion" section. It states both directions of the bijection explicitly, names the Float bits-hex encoding as a mechanism inside the invariant (not an exception to it), and references the test files below as the enforcement mechanism. Decision 6 Constraint 2 stays in place and gains an upward reference to the new section.

  2. Machine roundtrip (existing, unchanged): the current print_then_parse_round_trips_every_fixture in crates/ailang-surface/tests/round_trip.rs already iterates dynamically over every .ail.json fixture under examples/ and verifies JSON → print → parse → canonical-bytes-equal. Provides the JSON-anchored half of the bijection.

  3. Hand cross-check (replacing the 3-pair static test): a new dynamic test every_ailx_fixture_matches_its_json_counterpart replaces handwritten_exhibits_match_json_fixtures. It iterates over every .ailx file in examples/, locates the same-stem .ail.json counterpart, and asserts canonical_bytes(parse(ailx_text)) == canonical_bytes(load_module(json_path)). For a .ailx file without a counterpart (today: none; reserved for future), only parse success is required — drift detection needs both sides.

  4. Schema-coverage audit (new): a new test crates/ailang-core/tests/schema_coverage.rs iterates over every module loaded from examples/, runs an exhaustive AST visitor that — via match arms without a wildcard _ => ... — populates a HashSet<VariantTag> of observed variants. The test asserts that every expected variant (one per enum case in Def / Term / Pattern / Literal / Type / ParamMode) appears at least once. The Rust compiler enforces synchronization between the AST definition and the visitor: any new AST enum variant will not compile until the visitor and VariantTag are extended, guaranteeing that the coverage table cannot silently fall behind the schema.

  5. Workspace CLI roundtrip (new): a new integration test crates/ail/tests/roundtrip_cli.rs iterates over every .ail.json fixture in examples/, runs the fixture through the public ail render → tempfile → ail parse CLI flow (two subprocesses, tempfile-mediated because ail parse reads from a file path, not stdin), and verifies BLAKE3 hash identity between the original canonicalised JSON and the round-tripped canonical JSON captured from ail parse's stdout. This is the user-facing- surface defence line: it catches drift in the CLI layer that crate-internal tests miss.

Components

DESIGN.md edit

A new top-level section approximately 4070 lines long. Outline:

  • Statement (both directions, explicit). Every valid AILang module has both a .ail.json and a .ailx representation; print and parse are mutual inverses on the canonical-bytes level.
  • Float-bits-hex encoding as the mechanism that keeps Float literals (including NaN, ±Inf, subnormals) inside the invariant rather than outside it.
  • Enforcement points — the four test files below, by path.
  • Why this is top-level: the property is load-bearing on the language identity, not just on Decision 6's surface-design rationale.
  • Cross-reference from Decision 6 Constraint 2 up to this section, replacing the inline form of the same claim.

Test files

Path Status Purpose
crates/ailang-surface/tests/round_trip.rs::print_then_parse_round_trips_every_fixture Unchanged Machine roundtrip (.ail.json → .ailx → .ail.json)
crates/ailang-surface/tests/round_trip.rs::every_ailx_fixture_matches_its_json_counterpart Replaces hardcoded-pair test; dynamic Hand cross-check (.ailx → .ail.json ≡ static JSON counterpart)
crates/ailang-core/tests/schema_coverage.rs New Schema-variant coverage audit
crates/ail/tests/roundtrip_cli.rs New CLI roundtrip — BLAKE3 hash identity through ail render → tempfile → ail parse

Data flow

Machine roundtrip (existing):

<stem>.ail.json
  → ailang_core::load_module
  → ailang_surface::print
  → ailang_surface::parse
  → ailang_core::canonical::to_bytes
  ≡ canonical::to_bytes(<original>)

Hand cross-check (new, dynamic):

for every <stem>.ailx where <stem>.ail.json exists:
    <stem>.ailx (file-read)
      → ailang_surface::parse
      → ailang_core::canonical::to_bytes
      ≡ canonical::to_bytes(ailang_core::load_module(<stem>.ail.json))

Schema coverage (new):

for every <stem>.ail.json:
    load_module
    visit AST exhaustively (no `_` wildcard arms)
    accumulate observed: HashSet<VariantTag>
assert: expected_set ⊆ observed_set

The expected_set is computed at runtime by enumerating one VariantTag per AST enum variant. The visitor's exhaustive matches guarantee the two stay synchronized via the compiler.

CLI roundtrip (new):

for every <stem>.ail.json:
    bytes_orig = canonical::to_bytes(load_module(<stem>.ail.json))
    h_orig = blake3(bytes_orig)

    # subprocess 1: render JSON fixture to .ailx text on stdout
    ailx_text = run("ail render <stem>.ail.json")
    write(<tmpdir>/round.ailx, ailx_text)

    # subprocess 2: parse the .ailx back to canonical JSON bytes
    # (ail parse writes canonical bytes + a trailing newline)
    raw_stdout = run("ail parse <tmpdir>/round.ailx")
    bytes_round = strip_trailing_newline(raw_stdout)
    h_round = blake3(bytes_round)

    assert h_orig == h_round

The ail parse subcommand emits the canonical bytes via stdout.write_all(&bytes) followed by a println!(); the test strips the trailing newline before hashing.

Error handling

  • Each test failure prints the offending fixture path and a concrete diff: canonical-byte diff for tests 1 and 2; missing-variant list for test 3; hex-hash mismatch plus captured stderr for test 4.
  • Failures are aggregated per test: a single cargo test run lists all failing fixtures, not just the first. Existing test 1 already follows this pattern; the new tests adopt it.
  • Subprocess errors in the CLI test (non-zero exit) propagate the captured stderr verbatim into the panic message.

Testing strategy

  • Hard-fail mode from iteration 1. No allowlist, no aspirational state. The first iteration that adds the new tests will surface every existing roundtrip gap as a test failure. Each gap is a bug to fix in subsequent iterations; the milestone closes only when all four tests are green.
  • Dynamic fixture collection. Every test uses read_dir over examples/. No hardcoded fixture list. New fixtures are picked up automatically.
  • Float coverage. The schema coverage test includes Literal::Float as a required variant, so the corpus must contain at least one Float-bearing fixture (it already does: examples/floats.ail.json and others). The roundtrip tests then exercise the bits-hex encoding path on those fixtures. NaN / ±Inf are not currently in the corpus; if the audit reveals no fixture carries these bit patterns, a minimal Float-edge-case fixture is added.
  • Independence. The four tests are intentionally overlapping in places (e.g. test 1 and test 4 both cover the JSON-anchored direction, at crate-internal vs CLI-surface levels). This redundancy is the point: each test guards a different drift source.

Acceptance criteria

  1. docs/DESIGN.md carries a top-level section "Roundtrip Invariant" with the structure outlined above; Decision 6 Constraint 2 carries an upward cross-reference to it.
  2. crates/ailang-surface/tests/round_trip.rs has every_ailx_fixture_matches_its_json_counterpart in place of the hardcoded-pair handwritten_exhibits_match_json_fixtures, sourcing all .ailx/.ail.json pairs dynamically.
  3. crates/ailang-core/tests/schema_coverage.rs exists; its visitor matches each of Def / Term / Pattern / Literal / Type / ParamMode exhaustively without wildcard arms; every variant appears in at least one fixture.
  4. crates/ail/tests/roundtrip_cli.rs exists; iterates dynamically over .ail.json fixtures; verifies BLAKE3 hash identity through the ail render | ail parse subprocess pipeline.
  5. cargo test --workspace is green with all four tests active.
  6. Any roundtrip gaps surfaced by the new tests are closed in render or parse code, not by relaxing the tests.
  7. Tests are pure readers of the repo. None of the four tests writes a file inside the workspace, regenerates a fixture, patches the AST, or otherwise mutates committed content. The only filesystem side effect permitted is the use of a temp directory (e.g. tempfile::TempDir) for the CLI roundtrip's intermediate .ailx file, which lives outside the repo and is cleaned up by the test harness. If a test surfaces a gap, the diagnostic is fail-loud; the fix lands in render/parse code via a separate iteration, never inline by the test.

Scope notes

  • In scope: roundtrip property in both directions; DESIGN.md anchor; dynamic test coverage; schema-variant coverage; fixing any gaps surfaced.
  • Out of scope: the cross-model authoring-form test (P2 roadmap, gated on this milestone). Property-based generation of random AST instances (the schema-coverage test plus the existing fixture corpus provides equivalent assurance without the generator-authoring burden). The two open P2 todos around .ailx-aware CLI (ail check foo.ailx) and workspace-search beyond the entry module's directory — those are orthogonal and remain on the roadmap.