From 7d086e69ceeff9dd16d4b4bbeee3df5ac0f302b1 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 21 May 2026 12:37:45 +0200 Subject: [PATCH] =?UTF-8?q?spec:=20schema-camelcase-fix=20=E2=80=94=20para?= =?UTF-8?q?mTypes/retType=20=E2=86=92=20param-types/ret-type=20(closes=20#?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm output. Single-iteration milestone: rename the two camelCase JSON tags on `Term::Lam` — the only camelCase outliers in the AST schema — to kebab-case, matching the convention every other compound-key tag (`reuse-as`, etc.) already follows. Scope is verified-tiny: ast.rs (2 serde-rename strings) + workspace.rs in-source test JSON-literal + 2 `.ail.json` fixtures. None of the five hash-pinned `.ail` modules contains a lambda, so the milestone refreshes zero hash pins. Form-A is untouched — the surface uses `(params (typed ...))` / `(ret ...)`, never the camelCase tags. Feature-acceptance gate: passes weakly-but-honestly. Clause 1 is indirect (schema-internal consistency reduces the LLM author's "compound-tag-is-kebab" generalisation failure rate); there is no empirical preference measurement for this axis. Clause 2 holds (one fewer special case to memorise). Clause 3 vacuous (no semantic surface touched). Grounding-check (Step 7.5) PASS: all four load-bearing claims ratified by currently-green tests — round_trip.rs (Form-A invariance), hash_pin.rs (no lambda in pinned modules), workspace.rs in-source ct1_validator test (exhaustive call-site list), design_schema_drift.rs (kebab convention pin). Not bundled with #27 (arith-rename) per the spec preamble: #27 carries its own four open brainstorm questions and is a separate milestone; each gets one re-pin wave with its own rationale, no churn savings from bundling. --- docs/specs/2026-05-21-schema-camelcase-fix.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/specs/2026-05-21-schema-camelcase-fix.md diff --git a/docs/specs/2026-05-21-schema-camelcase-fix.md b/docs/specs/2026-05-21-schema-camelcase-fix.md new file mode 100644 index 0000000..8f2db68 --- /dev/null +++ b/docs/specs/2026-05-21-schema-camelcase-fix.md @@ -0,0 +1,325 @@ +# schema-camelcase-fix — Design Spec + +**Date:** 2026-05-21 +**Status:** Draft — awaiting user spec review +**Authors:** Brummel (orchestrator) + Claude +**Closes:** Gitea #30 + +## Goal + +Replace the two camelCase JSON tags `paramTypes` and `retType` on +`Term::Lam` with the kebab-case `param-types` and `ret-type`. These +are the only camelCase outliers in the entire AST schema; every +other compound-key tag (`reuse-as`, `drop-iterative`, etc.) is +kebab-case and every atomic tag (`type`, `fn`, `else`, `in`, `as`) +is a single lowercase word. The fix is a pure schema-tag rename in +the canonical JSON layer. + +## Architecture + +This is a schema-only milestone. Three layers are affected: + +1. **Canonical JSON tag layer.** Two `#[serde(rename = "...")]` + attributes on `Term::Lam` in `crates/ailang-core/src/ast.rs:492,494` + change literal value: `"paramTypes" → "param-types"`, + `"retType" → "ret-type"`. The Rust field names + (`param_tys`, `ret_ty`) are unaffected. + +2. **Surface (Form-A) layer.** **Unchanged.** Form-A prints lambdas + as `(lam (params (typed NAME TYPE)*) (ret TYPE) (effects ...) + (body TERM))`. There is no Form-A keyword `paramTypes` or + `retType` to migrate — the print/parse code at + `crates/ailang-surface/src/print.rs:502-528` and + `crates/ailang-surface/src/parse.rs:1430-1468` uses positional + sub-forms `(typed ...)` and `(ret ...)`, both of which already + match the kebab/single-word convention. + +3. **JSON source files.** Two `.ail.json` fixtures and one inline + JSON literal in a Rust unit test embed the old tags directly + and must be updated: + - `examples/test_loop_binder_captured_by_lambda.ail.json` + - `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` + - `crates/ailang-core/src/workspace.rs:1925-1926` + (Verified via `grep -rn '"paramTypes"\|"retType"' --include="*.rs" + --include="*.json"` — exhaustive list.) + +**Hash blast radius is zero on pinned modules.** None of the +`.ail` modules pinned in `crates/ailang-core/tests/hash_pin.rs` +(`sum`, `list`, `ordering_match`, `test_22b1_dup_a`, +`test_22b1_dup_classmod`) contains a `(lam ...)` term. Verified +via `grep -c "(lam " examples/.ail` — all zero. The two +`.ail.json` fixtures listed above are not hash-pinned. Therefore +the milestone does not refresh a single hash pin and no Honesty-Rule +hash-comment migration is required. + +## Concrete code shapes + +### The AILang program (Form-A — unchanged) + +The Form-A author surface is unchanged by this milestone. Authors +write lambdas exactly as before. Worked example, lifted from +`examples/closure.ail`: + +``` +(fn main + (doc "captures `n` from the enclosing let, returns 42") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (let n 3 + (app print + (app apply + (lam (params (typed x (con Int))) + (ret (con Int)) + (body (app + x n))) + 39))))) +``` + +The lambda sub-form `(lam (params (typed x (con Int))) (ret (con +Int)) (body (app + x n)))` reads consistently with the surrounding +`(fn-type (params ...) (ret ...))` — Form-A was never the locus of +the inconsistency. The Form-A round-trip +(`crates/ailang-surface/tests/round_trip.rs`) must stay GREEN with +zero diff to the printer/parser. + +### The delivered code shape (canonical JSON — before → after) + +The same lambda's canonical JSON before this milestone: + +```json +{ + "t": "lam", + "params": ["x"], + "paramTypes": [{ "k": "con", "name": "Int" }], + "retType": { "k": "con", "name": "Int" }, + "effects": [], + "body": { "t": "app", + "callee": { "t": "var", "name": "+" }, + "args": [{ "t": "var", "name": "x" }, + { "t": "var", "name": "n" }] } +} +``` + +After: + +```json +{ + "t": "lam", + "params": ["x"], + "param-types": [{ "k": "con", "name": "Int" }], + "ret-type": { "k": "con", "name": "Int" }, + "effects": [], + "body": { "t": "app", + "callee": { "t": "var", "name": "+" }, + "args": [{ "t": "var", "name": "x" }, + { "t": "var", "name": "n" }] } +} +``` + +That is the entirety of the observable change at the canonical-JSON +layer. After this milestone the JSON schema is regular: kebab-case +for every multi-word tag, single-word for every atomic tag, with +no exceptions. + +### The Rust implementation shape (secondary) + +In `crates/ailang-core/src/ast.rs`: + +```rust +// before +Lam { + params: Vec, + #[serde(rename = "paramTypes")] + param_tys: Vec, + #[serde(rename = "retType")] + ret_ty: Box, + #[serde(default)] + effects: Vec, + body: Box, +} + +// after +Lam { + params: Vec, + #[serde(rename = "param-types")] + param_tys: Vec, + #[serde(rename = "ret-type")] + ret_ty: Box, + #[serde(default)] + effects: Vec, + body: Box, +} +``` + +`crates/ailang-core/src/workspace.rs:1925-1926` — JSON-literal in a +test that synthesises a Lam to drive the bare-cross-module-type-ref +rejection path — updates the embedded keys verbatim: + +```rust +// before +"body": { + "t": "lam", + "params": ["x"], + "paramTypes": [{ "k": "con", "name": "Ordering" }], + "retType": { "k": "con", "name": "Unit" }, + ... + +// after +"body": { + "t": "lam", + "params": ["x"], + "param-types": [{ "k": "con", "name": "Ordering" }], + "ret-type": { "k": "con", "name": "Unit" }, + ... +``` + +The two `.ail.json` fixtures get the same key-rename, byte-for-byte; +the surrounding fixture content is untouched. + +## Components + +| Component | Change | +|-----------|--------| +| `crates/ailang-core/src/ast.rs:492,494` | Two `#[serde(rename)]` strings: `"paramTypes" → "param-types"`, `"retType" → "ret-type"`. | +| `crates/ailang-core/src/workspace.rs:1925-1926` | In-source JSON-literal in a test: same two key renames. | +| `examples/test_loop_binder_captured_by_lambda.ail.json` | Same two key renames (verified 3 occurrences combined of the two tags via `grep -c`). | +| `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` | Same two key renames (verified 3 occurrences combined). | +| `crates/ailang-surface/src/{print,parse}.rs` | **No change.** Form-A never used these tag names. | +| `crates/ailang-core/tests/hash_pin.rs` | **No change.** No pinned `.ail` module contains `(lam ...)` (verified per-file). | + +Historic documents (`docs/plans/2026-05-10-23.3-...`, +`docs/plans/2026-05-09-22b3-monomorphisation.md`, +`docs/plans/2026-05-09-22c-user-class-e2e.md`, +`docs/plans/24.2.md`) embed the old tag names inside example JSON +blocks. Per the Honesty Rule +(`design/contracts/honesty-rule.md`), historic plan documents +describe the state at the time of writing — not the present state — +and are NOT migrated. + +## Data flow + +The canonical-JSON ↔ AST bridge is `serde_json` with the +`#[serde(tag = "t")]` discriminator on `Term`. The `rename` +attributes are the only point where the JSON key differs from the +Rust field name. Changing the rename string changes (a) what +`serde_json::to_string` emits for the field, and (b) what +`serde_json::from_str` accepts for the field. Both directions move +together; there is no two-key transitional window — a single commit +ships the rename plus all three call-sites (workspace test + +two fixtures) so no `.ail.json` is observably inconsistent at any +intermediate point. + +The Form-A path (`.ail` author surface → `parse_lam` → AST → canonical +serialisation → hash) does not touch the renamed tags as text: the +parser maps the Form-A keywords `params`/`typed`/`ret` directly to +the Rust fields by struct-position, and `serde_json` then re-emits +them with the new rename. The same module hashes the same way before +and after at the AST level; only the *canonical JSON byte stream* +differs, which is what the renaming targets. + +## Error handling + +Three failure modes possible during the migration: + +1. **A `.ail.json` fixture is missed.** Surfaces as a deserialisation + error from `ailang-core` when the workspace loader hits an + unknown field. Caught by `cargo test --workspace` (every fixture + participates in at least one test). Resolution: the grep used to + build the Components table was exhaustive across `examples/`, + `experiments/`, and `crates/`; if a future fixture is added with + the old tag, the deserialiser rejects it. + +2. **The `workspace.rs` JSON literal is missed.** Same as above, + caught by the same test crate — specifically the + `validate_canonical_type_names` test that owns the synthesised + module. + +3. **A historic `docs/plans/*.md` is "helpfully" updated.** Not a + failure mode, but a rule-violation against the Honesty Rule. + Historic plans are frozen documents; updating them retroactively + would falsify the recorded design intent at the iter close. The + spec explicitly forbids this (see Components above) and the + ratifying test + (`crates/ailang-core/tests/docs_honesty_pin.rs`) does not + police plan files anyway — the rule is editorial discipline. + +There is no compatibility shim and no transitional accept-both +behaviour. The JSON tag rename is atomic in one commit; old fixtures +not migrated by this commit cannot deserialise after this commit. +Any future encounter with a stray `"paramTypes"` is a stale fixture, +not a backward-compat concern. + +## Testing strategy + +The milestone is GREEN if: + +- `cargo test --workspace` passes (this hits every `.ail.json` + fixture transitively and the workspace.rs test directly). +- `cargo test -p ailang-surface --test round_trip` passes — confirms + Form-A is untouched. +- A single targeted assertion is added: a fresh test in + `crates/ailang-core/tests/schema_coverage.rs` (or equivalent + existing schema-shape test) verifies that + `serde_json::to_value(&Term::Lam{...})` emits the keys + `"param-types"` and `"ret-type"` (and NOT the old camelCase). The + test pins the new schema layer concretely so a future regression + cannot silently flip the rename string back. +- `cargo build --workspace` produces no new clippy warnings. + +No bench-regression check is required: the rename is a string +literal swap at compile time with no runtime cost path. + +## Acceptance criteria + +1. Tag-rename change in `crates/ailang-core/src/ast.rs:492,494` is + landed and observable in serialisation output. +2. Three call-sites (`workspace.rs` + two `.ail.json` fixtures) + carry the new tag names verbatim. +3. `cargo test --workspace` is GREEN. +4. `cargo test -p ailang-surface --test round_trip` is GREEN + (Form-A unchanged invariant). +5. A new schema-shape assertion test fires GREEN with the new tag + names and would fire RED with the old ones. +6. The commit body references `closes #30`. No `docs/plans/*.md` + files are mutated. + +## Feature-acceptance gate + +Per `design/contracts/feature-acceptance.md`, three clauses must +hold. This milestone's gate evaluation: + +- **Clause 1 — an LLM author naturally produces code that uses the + feature.** Direct empirical evidence for *kebab-case preference + inside this schema* is absent — the 2026-05-21 naming-A/B run + measured a different naming axis (apply vs call vs classic head + tag, not field-key case). The argument here is indirect but + concrete: a future LLM author derives the lambda's JSON form by + *generalising from the rest of the schema* it has just read. + Every other compound tag in the AST is kebab-case (verified by + exhaustive `grep -n "serde(rename" crates/ailang-core/src/ast.rs` + — 17 sites, only the two on `Term::Lam` use camelCase). The + generalisation an LLM forms reads "compound keys are kebab" — and + it will get `paramTypes` wrong on first attempt with non-trivial + probability. The fix eliminates the one site where the + generalisation diverges from reality. Clause 1 holds **weakly but + honestly**: the gain is consistency-induced lower error + probability, not a measured preference for kebab. + +- **Clause 2 — measurably improves correctness or removes + redundancy.** Redundancy reduction is direct: one less special + case in the "JSON tag spelling convention" the LLM author has to + memorise. The "measurable" side is the LLM-mistake probability + reduction sketched in Clause 1; even without a number it is a + monotonic improvement (the new rule is strictly simpler than the + old). Holds. + +- **Clause 3 — reintroduces no bug class the core constraint exists + to eliminate.** Pure tag rename; no semantic, type-system, + uniqueness-mode, or codegen surface touched. The Form-A round-trip + invariant is structurally untouched (parser doesn't see these + tags). Holds vacuously. + +The gate passes. The honesty here is to name Clause 1 as the +weakest of the three, supported by *schema-internal consistency* +rather than by an experiment. A future schema-touching milestone +(#27 arithmetic-rename) might collect more direct evidence; this +fix does not block on that and does not pretend to.