spec: schema-camelcase-fix — paramTypes/retType → param-types/ret-type (closes #30)
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.
This commit is contained in:
@@ -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/<each>.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<String>,
|
||||||
|
#[serde(rename = "paramTypes")]
|
||||||
|
param_tys: Vec<Type>,
|
||||||
|
#[serde(rename = "retType")]
|
||||||
|
ret_ty: Box<Type>,
|
||||||
|
#[serde(default)]
|
||||||
|
effects: Vec<String>,
|
||||||
|
body: Box<Term>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// after
|
||||||
|
Lam {
|
||||||
|
params: Vec<String>,
|
||||||
|
#[serde(rename = "param-types")]
|
||||||
|
param_tys: Vec<Type>,
|
||||||
|
#[serde(rename = "ret-type")]
|
||||||
|
ret_ty: Box<Type>,
|
||||||
|
#[serde(default)]
|
||||||
|
effects: Vec<String>,
|
||||||
|
body: Box<Term>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
Reference in New Issue
Block a user