Files
AILang/docs/plans/0061-iter-form-a.tidy.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

18 KiB

form-a.tidy — Implementation Plan

Parent specs:

  • docs/specs/0026-fieldtest-form-a.md §Findings 2-3 (spec_gap: form_a.md missing class/instance/constraint sections + canonical-form qualifier rule)
  • docs/journals/2026-05-13-audit-form-a.md §"Drift items" (3 of 4 documentary items; item #2 dropped per recon — see Architecture)

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Tighten crates/ailang-core/specs/form_a.md with three new sections (Class, Instance, Constraints on polymorphic fns) anchored against the schema and the live corpus, and close 3 of the 4 documentary drift items from audit-form-a (the 4th — "plan seven-orphans" — was found by recon to not exist).

Architecture: Pure documentation + comment tidy. No production-code behaviour changes. Six tasks. Tasks 1-3 land form_a.md additions one logical section at a time so each section is its own reviewable unit. Task 4 sweeps 5 contradictory seven carve-outs sites in the form-a spec; the analogous plan-file sweep is deliberately NOT scheduled because recon verified the plan's four seven mentions are all internally scoped (§C4(a) scope, arithmetic, or future state) and never contradict the §C4(b) amendment. Task 5 deletes the empty mod tests {} placeholder + its 6-line explanatory comment in hash.rs (form-a.1 T5 relocated the unit tests to crates/ailang-core/tests/hash_pin.rs; the placeholder serves no purpose and there is no doctest depending on it). Task 6 rewrites the round_trip.rs module docstring + inner test docstring to use the post-T10 four-property framing (parse-determinism + idempotency + CLI-pipeline-idempotency + carve-out-anchor) instead of the retired Direction-1/Direction-2 language.

Tech Stack: Markdown (form_a.md, the form-a spec). Rust comment edits (hash.rs, round_trip.rs). No code logic changes, no test additions, no test retirements.


Files this plan creates or modifies:

  • Modify: crates/ailang-core/specs/form_a.md — three new sections (Tasks 1-3) + line 66 intro update (Task 1)
  • Modify: docs/specs/0025-form-a-default-authoring.md — 5 site edits (Task 4)
  • Modify: crates/ailang-core/src/hash.rs:50-57 — delete 8 lines (Task 5)
  • Modify: crates/ailang-surface/tests/round_trip.rs — module docstring + test inner docstring rewrite (Task 6)

Task 1: form_a.md — Class declarations section

Files:

  • Modify: crates/ailang-core/specs/form_a.md:66 (intro line)

  • Modify: crates/ailang-core/specs/form_a.md:113 (insertion point after ### Constant — (const ...))

  • Step 1: Update §Definitions intro from "Three kinds" to "Five kinds"

Replace at crates/ailang-core/specs/form_a.md:66:

Three kinds, matched on the leading keyword:

with:

Five kinds, matched on the leading keyword:
  • Step 2: Insert ### Class — (class ...) subsection after Constant

After the existing ### Constant — (const ...) block (ends at crates/ailang-core/specs/form_a.md:113), insert:


### Class — `(class ...)`

(class NAME (param TYVAR) (doc STRING)? (superclass (class CLASS-REF) (type TYVAR))? (method NAME (type FN-TYPE) (default TERM)?)*)


A class declaration introduces a typeclass with one type parameter (`param`)
and a list of method signatures.

`CLASS-REF` in the optional `superclass` clause follows the canonical-form
rule (see *Types* below): bare for same-module, `MODULE.CLASS` for
cross-module. The `superclass` slot is at most one — multi-superclass
chains are not yet supported.

Each `method` carries a function-typed signature. The bound type variable
named in `param` is in scope throughout the method's `(type ...)`. A
`(default ...)` clause provides a fallback implementation; absent means
the method is abstract-required (every instance MUST implement it).

Example (`examples/test_22c_user_class_e2e.ail`):

(class Foo (param a) (method foo (type (fn-type (params (borrow a)) (ret (con Int))))))

  • Step 3: Verify the form_a.md sample example parses + checks

Run: ./target/debug/ail check examples/test_22c_user_class_e2e.ail Expected output: ok (24 symbols across 2 modules) (per recon report Scope A4).

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed (matches baseline post-bugfix-instance-body-unbound-var).

Task 2: form_a.md — Instance declarations section

Files:

  • Modify: crates/ailang-core/specs/form_a.md — insertion point after the ### Class block landed by Task 1

  • Step 1: Insert ### Instance — (instance ...) subsection after Class

Immediately after the ### Class — (class ...) block landed in Task 1, insert:


### Instance — `(instance ...)`

(instance (class CLASS-REF) (type TYPE) (doc STRING)? (method NAME (body LAM-TERM))*)


An instance declaration provides method implementations of `CLASS-REF` at
the concrete `TYPE`.

`CLASS-REF` follows the canonical-form rule: bare for same-module-to-
class (the instance and the class live in the same module), `MODULE.CLASS`
for cross-module (the class lives in another module — most commonly
`prelude.Show`, `prelude.Eq`, etc.).

Each `method` body is a `(lam ...)` term. The class's type parameter is
substituted for `TYPE` throughout the method body's parameter types and
return type; method bodies are type-checked under that substitution and
walk through the same identifier-resolution path as `(fn ...)` bodies,
so an unbound name inside a method body fires `[unbound-var]` at
`ail check`.

Two examples.

Same-module class + instance (`examples/mq3_class_eq_vs_fn_eq_classmod.ail`,
abbreviated):

(class MyEq (param a) (method myeq (type (fn-type (params (borrow a) (borrow a)) (ret (con Bool)))))) (instance (class MyEq) (type (con Int)) (method myeq (body (lam (params (typed x (con Int)) (typed y (con Int))) (ret (con Bool)) (body true)))))


Cross-module qualified class (`examples/show_user_adt.ail`, abbreviated):

(instance (class prelude.Show) (type (con IntBox)) (method show (body (lam (params (typed x (con IntBox))) (ret (con Str)) (body (match x (case (pat-ctor MkIntBox n) (app int_to_str n))))))))


The `prelude.Show` qualifier is required here because `Show` is declared
in the `prelude` module, not the entry module. Writing `(class Show)`
bare would fail with `bare-cross-module-class-ref`.
  • Step 2: Verify the two cited fixtures ail check cleanly

Run: ./target/debug/ail check examples/mq3_class_eq_vs_fn_eq_classmod.ail Expected output: ok (22 symbols across 2 modules).

Run: ./target/debug/ail check examples/show_user_adt.ail Expected output: ok (23 symbols across 2 modules).

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed.

Task 3: form_a.md — Constraints on polymorphic fns

Files:

  • Modify: crates/ailang-core/specs/form_a.md:117-126 (the "Four shapes" EBNF block) and surrounding examples

  • Step 1: Extend the (forall ...) line in the four-shapes EBNF to carry the optional (constraints ...) clause

Replace at crates/ailang-core/specs/form_a.md:125:

(forall (vars TYVAR+) BODY-TYPE)        ; polymorphic schema

with:

(forall (vars TYVAR+)
        (constraints (constraint CLASS-REF TYPE)+)?
        BODY-TYPE)                      ; polymorphic schema with optional constraints
  • Step 2: Append a paragraph after the "Built-in type-constructors" line explaining constraints

After crates/ailang-core/specs/form_a.md:141 (the line ending use the name from the (data ...) def.), insert a new paragraph immediately before the Examples: block at line 143:


A `(forall ...)` may carry an optional `(constraints ...)` clause whose
inner items are `(constraint CLASS-REF TYPE)` pairs. Each constraint
requires the named class to have an instance at the given type; `TYPE`
is typically a type variable bound by the same `forall`. `CLASS-REF`
follows the canonical-form rule (bare for same-module, `MODULE.CLASS`
for cross-module). At a call site, every constraint must discharge — by
a matching instance in the workspace or by another constraint in the
caller's own schema. An undischargeable constraint fires `no-instance`
at `ail check`.
  • Step 3: Add one example to the existing Examples block at line 145-150

After the existing fourth example at crates/ailang-core/specs/form_a.md:149 (the line beginning (forall (vars a) (fn-type ...))), add a fifth example showing a constrained forall:

(forall (vars a) (constraints (constraint prelude.Ord a))
                 (fn-type (params a a) (ret a)))

The block should now have five example lines, the new one anchored to examples/cmp_max_smoke.ail (fn cmp_max).

  • Step 4: Verify the cited fixture ail check clean

Run: ./target/debug/ail check examples/cmp_max_smoke.ail Expected output: ok (22 symbols across 2 modules) (Boss-verified empirically at plan-write time, 2026-05-13).

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed.

Task 4: form-a spec — fix 5 contradictory "seven carve-outs" sites

Files:

  • Modify: docs/specs/0025-form-a-default-authoring.md at five line ranges

The audit-form-a journal flagged three sections (§C1, §C2, §"Data flow") contradicting the §C4(b) amendment. Recon expanded the find to 5 sites total (the additional sites are the preamble at line 11 and §C3 at line 218, which the audit summary missed). Sites verified by recon are listed below.

Per recon, the §C2 line 191 "The seven carve-outs continue to use the .ail.json path" is ambiguous — it can be read as "the §C4(a) seven fixtures continue to use the .ail.json path" (correct) OR as "the seven total carve-outs" (incorrect post-amendment). Treat it as defective and tighten to "the eight carve-outs" to match AC #1 and the §"Data flow" diagram.

  • Step 1: Fix preamble line 11

Replace at docs/specs/0025-form-a-default-authoring.md:11:

that remain in the repository are seven specific negative-test

with:

that remain in the repository are eight specific negative-test
  • Step 2: Fix §C1 line 170

Replace at docs/specs/0025-form-a-default-authoring.md:170:

walks examples/*.ail.json, skips the seven carve-outs (named

with:

walks examples/*.ail.json, skips the eight carve-outs (named
  • Step 3: Fix §C2 line 191

Replace at docs/specs/0025-form-a-default-authoring.md:191:

`ailang_surface::parse_module_from_str`. The seven carve-outs

with:

`ailang_surface::parse_module_from_str`. The eight carve-outs
  • Step 4: Fix §C3 line 218

Replace at docs/specs/0025-form-a-default-authoring.md:218:

**Carve-out anchor.** The seven .ail.json-only fixtures are

with:

**Carve-out anchor.** The eight .ail.json-only fixtures are
  • Step 5: Fix §"Data flow" lines 363, 374

Replace at docs/specs/0025-form-a-default-authoring.md:363:

except for the seven carve-outs:

with:

except for the eight carve-outs:

Replace at docs/specs/0025-form-a-default-authoring.md:374:

only by the seven carve-outs (and by any test that explicitly mixes

with:

only by the eight carve-outs (and by any test that explicitly mixes
  • Step 6: Sanity-check no other "seven" → "eight" candidates were missed

Run: grep -n 'seven' docs/specs/0025-form-a-default-authoring.md Expected output (post-edit; verbatim — these surviving "seven" sites are correct under §C4(a) scope or are arithmetic / future-state, not defects):

233:and (b) **compile-time-embed** carve-outs (one
238:**(a) Subject-matter carve-outs (immutable, seven files):**
463:examples/*.ail.json after milestone close (seven from §C4 (a) +
469:drops to seven once the prelude-embed-refactor milestone retires

(Line 233 doesn't contain "seven" by itself — it's the §C4 header context. The recon-mapped survivors are lines 238, 463, 469 — three remaining seven mentions, all correctly scoped. If grep -n 'seven' surfaces more than three matches post-edit, an edit missed a site.)

  • Step 7: Run cargo test --workspace as a no-regression smoke

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed.

Task 5: hash.rs — delete empty mod tests {} placeholder

Files:

  • Modify: crates/ailang-core/src/hash.rs:50-57 (delete 8 lines)

Recon verified: the #[cfg(test)] mod tests {} is literally empty, gated by #[cfg(test)], with a 6-line comment block above explaining the form-a.1 T5 relocation. No doctest depends on the tests module (the file's only /// doc-example sits much earlier and does not reference tests). No external code references ailang_core::hash::tests. The placeholder is deletable.

  • Step 1: Delete lines 50-57

Read the file first to confirm the exact current contents at those lines, then use Edit to delete the 8-line block. The block to delete (exactly as on disk per recon Scope B3):

// `#[cfg(test)] mod tests` block relocated to
// `crates/ailang-core/tests/hash_pin.rs` in iter form-a.1 Task 5. The
// integration-test crate has `ailang-surface` as a dev-dependency so
// the schema-stability pins can load `.ail` fixtures via
// `ailang_surface::load_module`, eliminating the production-source
// dependency on `.ail.json` fixture reads.
#[cfg(test)]
mod tests {}

After deletion, the file should end at the line that previously preceded the comment block (no trailing blank line if the file didn't have one before; Rust's formatter would normalise this anyway, but explicit).

  • Step 2: Verify the file still compiles

Run: cargo build -p ailang-core --quiet Expected: build succeeds with no errors.

  • Step 3: Verify the relocated integration tests still pass

Run: cargo test --manifest-path Cargo.toml -p ailang-core --test hash_pin --quiet 2>&1 | tail -3 Expected: test result: ok. <N> passed.

  • Step 4: Full workspace test as a no-regression smoke

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed.

Task 6: round_trip.rs — replace Direction-2 framing with four-property framing

Files:

  • Modify: crates/ailang-surface/tests/round_trip.rs:1-26 (module-level //! docstring)
  • Modify: crates/ailang-surface/tests/round_trip.rs:63-72 (inner /// docstring on parse_then_print_then_parse_is_idempotent_on_every_ail_fixture)

The post-T10 Roundtrip Invariant has four named properties (DESIGN.md §"Roundtrip Invariant"): parse-determinism, idempotency-under-print, CLI-pipeline-idempotency, carve-out-anchor. The first two are pinned by tests in this file; the latter two live in sibling test crates (crates/ail/tests/roundtrip_cli.rs for CLI-pipeline-idempotency; crates/ailang-core/tests/carve_out_inventory.rs for carve-out-anchor). The current docstring still uses the retired Direction-1/Direction-2 framing.

  • Step 1: Rewrite the module docstring at lines 1-26

Replace the entire block from line 1 through the end of the retirement note at line 26 with:

//! Roundtrip Invariant gate (in-process) for the form-(A) projection.
//!
//! Two complementary checks over `examples/*.ail`, each gathering
//! fixtures dynamically via `read_dir` (no hardcoded lists). The
//! tests are pure readers — they do not write into the working
//! tree.
//!
//! 1. `parse_is_deterministic_over_every_ail_fixture` — pins
//!    **parse-determinism** (property 1 of the post-form-a Roundtrip
//!    Invariant, DESIGN.md §"Roundtrip Invariant"). For every
//!    well-formed `.ail` text `t`, `parse(t)` produces the same
//!    canonical bytes every invocation. The parser is a pure
//!    function of input — no randomness, no time dependence, no
//!    environment leak. Hashing `canonical::to_bytes(parse(t))` is
//!    therefore well-defined for any `.ail` source.
//!
//! 2. `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
//!    — pins **idempotency-under-print** (property 2). For every
//!    well-formed `.ail` text `t`, asserts
//!    `canonical_bytes(parse(t))` equals
//!    `canonical_bytes(parse(print(parse(t))))`. The printer is a
//!    left-inverse of the parser modulo canonical form.
//!
//! The other two properties of the post-form-a Roundtrip Invariant
//! live in sibling test crates: **CLI-pipeline-idempotency** is
//! pinned by `crates/ail/tests/roundtrip_cli.rs::cli_parse_then_render_then_parse_is_idempotent`,
//! and **carve-out-anchor** is pinned by
//! `crates/ailang-core/tests/carve_out_inventory.rs::examples_ail_json_inventory_matches_carve_outs`.
//!
//! Retired iter form-a.1 T9: `print_then_parse_round_trips_every_fixture`
//! (corpus shrunk to 8 carve-outs post-iter) and
//! `every_ail_fixture_matches_its_json_counterpart` (no longer
//! expressible: only one form is hand-authored post-iter).
  • Step 2: Rewrite the inner test docstring at lines 63-72

Replace the inner /// block immediately preceding parse_then_print_then_parse_is_idempotent_on_every_ail_fixture (lines 63-72) with:

/// Pins **idempotency-under-print** (property 2 of the post-form-a
/// Roundtrip Invariant, DESIGN.md §"Roundtrip Invariant"): for every
/// well-formed `.ail` text `t`, the composition `parse → print → parse`
/// is idempotent on the AST.
///
/// Direct enforcement complements the parse-determinism gate above
/// (which alone does not constrain the printer). Together they pin
/// `parse → print` as a left-inverse modulo canonical form for every
/// `.ail` fixture in the corpus.
  • Step 3: Verify the file still compiles and the tests still pass

Run: cargo test --manifest-path Cargo.toml -p ailang-surface --test round_trip --quiet 2>&1 | tail -3 Expected: test result: ok. 2 passed; 0 failed.

  • Step 4: Full workspace test as a no-regression smoke

Run: cargo test --workspace --quiet 2>&1 | tail -5 Expected: total 559 passed.

  • Step 5: Verify no surviving "Direction 1" or "Direction 2" references in round_trip.rs

Run: grep -n 'Direction [12]' crates/ailang-surface/tests/round_trip.rs Expected output: empty (zero hits).


Decision recorded — Drift item B2 (plan-file "seven carve-outs" orphans)

Recon verified that docs/plans/0060-iter-form-a.1.md contains zero defective seven mentions; the four hits are all internally scoped to §C4(a) (which IS seven), arithmetic, or future-state. The audit-form-a journal's "two sites" claim against this file does not match the file's current contents. No plan-file edit is included in this iter. The form-a.tidy journal entry must record this finding for future-reader visibility.