spec: prelude-decouple — retire prelude.ail.json + decouple core from language content

Brainstorm settled on the β.2 loader-split: ailang-core stops
embedding prelude bytes, stops auto-injecting a "prelude" module,
and stops hardcoding the literal "prelude" in cross-module-ref
diagnostics. ailang-surface assumes ownership of the prelude
content and the reservation. Three iters: pd.1 splits the core
API into load_modules_with + build_workspace; pd.2 moves the
embed source from .ail.json to .ail with a cross-form-identity
preflight; pd.3 deletes examples/prelude.ail.json and updates the
form-a §C4 (b) carve-out. Grounding-check PASS twice (initial +
post-self-review re-dispatch).
This commit is contained in:
2026-05-14 11:57:16 +02:00
parent c17bc70487
commit e6298f5950
+446
View File
@@ -0,0 +1,446 @@
# Prelude / core decoupling — Design Spec
**Date:** 2026-05-14
**Status:** Draft — awaiting user spec review
**Authors:** Brummel (orchestrator) + Claude
## Goal
Retire `examples/prelude.ail.json` from the working tree and remove
all knowledge of the literal module name `"prelude"` from
`ailang-core`. After this milestone:
1. The single source of truth for the prelude is
`examples/prelude.ail` (Form A). The compiler reads it via
`include_str!` in `ailang-surface` and parses it with
`ailang_surface::parse` at compile time of the embedding crate.
2. `ailang-core` no longer embeds prelude bytes, no longer defines
the `ReservedModuleName` error, no longer auto-injects a module
named `"prelude"`, and no longer hardcodes the string `"prelude"`
in cross-module-reference diagnostics. core's `Workspace` loader
becomes a pure module-graph + schema-validator with no language-
content awareness.
3. The §C4 (b) compile-time-embed carve-out in
`docs/specs/2026-05-13-form-a-default-authoring.md` becomes empty
and is struck. The seven §C4 (a) subject-matter-rejection
fixtures remain the only `.ail.json` files in the corpus.
The motivation has two parts. First, the **doctrine inconsistency**:
CLAUDE.md states "authors write `.ail`; the build derives the JSON-
AST in-process via `ailang_surface::parse`". The prelude is the
single most-read AILang file in the corpus; that it is the *one*
file embedded into the compiler as JSON contradicts the doctrine.
Second, the **layering inconsistency**: `ailang-core`'s declared
role per CLAUDE.md is "AST, canonicalisation, desugaring, workspace
types, hash, pretty" — neither shipping language content nor
encoding the identity of a privileged module. The literal-`"prelude"`
references in core (8 occurrences across the inject step, the
diagnostic candidate sites, and the test cases) are the symptom of
that drift; this milestone removes them.
Out of scope:
- The implicit-prelude resolution in `ailang-check` (`crates/ailang-check/src/lib.rs:143`-area)
and the codegen-side implicit-prelude behaviour (iter 23.2.4).
Both already consume the prelude module by name from a populated
workspace; they do not embed prelude bytes and do not own the
reservation. They keep working unchanged because surface still
populates the prelude slot in every workspace.
- The `migrate-bare-cross-module-refs` subcommand's defensive
`include_str!` of `prelude.ail.json` at `crates/ail/src/main.rs:475`
— the inserted prelude is immediately skipped in the rewrite loop
(`if mod_name == "prelude" && path.as_os_str().is_empty()
{ continue; }`), so this is a no-op relic. Iter 1 deletes it
along with the core-side embed.
## Architecture
The cut is the β.2 shape from the brainstorm: core becomes fully
prelude-agnostic. The DFS-visitor and the schema-validation pipeline
that today live as one block inside `core::workspace::load_workspace_with`
split into two publicly composable phases. Surface composes them
with a prelude-injection step in between.
### Phase split
**Today (in core):**
```
load_workspace_with(entry, loader)
DFS visit (loader-driven)
insert prelude module (hardcoded inject)
validate_canonical_type_names
validate_classdefs
build_registry
-> Workspace
```
**After the milestone:**
```
core::load_modules_with(entry, loader)
DFS visit (loader-driven)
-> (entry_name, root_dir, BTreeMap<String, Module>)
surface::load_workspace(entry)
let (entry_name, root_dir, mut modules) = core::load_modules_with(entry, load_module)
if modules.contains_key("prelude") return Err(ReservedModuleName)
modules.insert("prelude", parse_prelude())
core::build_workspace(entry_name, root_dir, modules, &["prelude"])
core::build_workspace(entry_name, root_dir, modules, implicit_imports)
validate_canonical_type_names(modules, implicit_imports)
validate_classdefs(modules, implicit_imports)
build_registry(modules)
-> Workspace
```
### Why two phases, not one
A single `core::build_workspace` that takes already-assembled
modules works because every consumer of the validation pipeline
(canonical-type-names, classdefs, registry) needs the modules map
and the workspace shape — they do not need to know how those modules
got there. Splitting at the loader/validator seam lets surface
inject a special module without core knowing what makes it special.
### Why `implicit_imports: &[&str]` parameter
The diagnostic sites in `check_class_ref_qualifier` (line 1084-area)
and `check_type_con_name` (line 1148-area) currently scan
`local_classes.get("prelude")` / `local_types.get("prelude")` as a
fallback candidate when bare cross-module-refs fail to resolve. The
hardcoded `"prelude"` is the last sprach-konzept-as-string in core's
schema-validator path.
Replacing it with an `implicit_imports: &[&str]` parameter on
`validate_canonical_type_names` and `validate_classdefs` parameterises
the concept. core walks both the per-module declared imports AND
the caller-supplied implicit imports without knowing what name
makes one implicit. Surface passes `&["prelude"]`. A future caller
could pass a different set without core needing to change.
This is not speculative generality (rejected in the brainstorm for
β.3): it is the single mechanism that lets core stop hardcoding
`"prelude"`. Without parameterisation, the diagnostic sites would
either keep the hardcoded string (failing the milestone goal) or
the diagnostic candidate-list would lose its prelude-fallback
suggestion (a regression in error quality).
### Why `ReservedModuleName` moves to surface
The reservation `"prelude"` is an implementation choice of the
prelude-owning crate, not of the workspace loader. core has no
opinion on what module names are reserved; it discovers a graph
of named modules and validates their schema. Surface, which
injects a module named `"prelude"`, owns the reservation that
makes the injection safe (a user module of the same name would
shadow). The `WorkspaceLoadError` enum keeps the variant for ABI
stability; the variant moves to `ailang-surface` (or a new error
type composed with core's) — the exact placement is a Step-7
detail, see Components.
## Components
The change touches exactly three crates: `ailang-core`,
`ailang-surface`, `ail` (CLI). Tests across the workspace adapt
mechanically.
core's `src/` retains zero literal-`"prelude"` strings post-milestone.
The `WorkspaceLoadError::ReservedModuleName` variant declaration is
generic (`{ name: String }`) and does not name `"prelude"`; the
literal lives only at the single surface-side construction site.
This is the binding meaning of "core becomes prelude-agnostic" in
this spec.
### `ailang-core` changes
- `crates/ailang-core/src/workspace.rs`:
- **Remove**: `const PRELUDE_JSON` (line 417), `fn load_prelude`
(line 425), the inject step + reserved-name check inside
`load_workspace_with` (lines 493-504), the literal-`"prelude"`
fallbacks in `check_class_ref_qualifier` (lines 1084-1091) and
`check_type_con_name` (lines 1148-1155).
- **Replace** `pub fn load_workspace_with<F>(entry, loader)` with
two new public fns:
- `pub fn load_modules_with<F>(entry, loader) -> Result<(String, PathBuf, BTreeMap<String, Module>), WorkspaceLoadError>`
— returns `(entry_name, root_dir, modules)` with no
validation and no prelude.
- `pub fn build_workspace(entry_name, root_dir, modules, implicit_imports: &[&str]) -> Result<Workspace, WorkspaceLoadError>`
— runs the three-stage validation + registry construction.
`implicit_imports` is forwarded to `validate_canonical_type_names`;
the other two pipeline stages do not use it.
- **Retire** `pub fn load_workspace(entry)` (the JSON-only legacy
wrapper at line 437). It has no production callers; the few
in-core tests that use it migrate to either `surface::load_workspace`
via the existing dev-dep edge or to direct `load_modules_with`
+ `build_workspace` composition.
- **Move** the *production* of `WorkspaceLoadError::ReservedModuleName`
(line 315) out of core. The variant declaration stays in core's
`WorkspaceLoadError` enum — its name and shape
(`ReservedModuleName { name: String }`) reference no literal
`"prelude"`, so it does not by itself violate the
decoupling. core's `src/` contains zero call sites that
*construct* the variant after this milestone; surface's
`load_workspace` is the sole producer, supplying
`name: "prelude".to_string()`. Downstream match-arms in
CLI-diagnostic code are unaffected because the variant tag and
field shape are unchanged.
- **Update** `validate_canonical_type_names`'s signature to take
`implicit_imports: &[&str]`. Internal threading propagates the
slice through the per-def walk to the two diagnostic-site
helpers (`check_class_ref_qualifier` ~ line 1084 area,
`check_type_con_name` ~ line 1148 area). `validate_classdefs`
is **not** modified — it does not call those helpers and so has
no consumer of the literal-`"prelude"` fallback. (Spec's first
draft over-stated the threading scope; corrected here per the
grounding-check agent's finding.)
- `crates/ailang-core/tests/workspace_pin.rs`: the in-tree DFS
expectations that today assume prelude is auto-injected get
rewritten to use `surface::load_workspace` (already imported via
dev-dep) — same expected outputs, different entry point.
- `crates/ailang-core/tests/carve_out_inventory.rs`: remove the
`prelude.ail.json` entry from the §C4 (b) list (line 25). The
comment block at line 8 (`§C4 (b) compile-time-embed: 1 fixture
(prelude.ail.json).`) gets updated to "0 fixtures" and notes the
retirement.
### `ailang-surface` changes
- `crates/ailang-surface/src/loader.rs`:
- **Add** `const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");`
- **Add** `fn parse_prelude() -> Module` returning the parsed
prelude. Like the old `core::load_prelude`, this panics on
parse failure (build-time-validated).
- **Rewrite** `load_workspace(entry)` to compose the new core
primitives:
```rust
pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
let (entry_name, root_dir, mut modules) =
ailang_core::load_modules_with(entry, load_module)?;
if modules.contains_key("prelude") {
return Err(WorkspaceLoadError::ReservedModuleName {
name: "prelude".to_string(),
});
}
modules.insert("prelude".to_string(), parse_prelude());
ailang_core::build_workspace(entry_name, root_dir, modules, &["prelude"])
}
```
- The `load_module` extension-dispatcher (line 37) is unchanged.
### `ail` (CLI) changes
- `crates/ail/src/main.rs`: delete the defensive prelude-include
block in the `migrate-bare-cross-module-refs` subcommand
(lines 472-484). The synthetic prelude entry was inserted only
to satisfy the loop's per-module iteration, then unconditionally
skipped in the rewrite. Removing both the include and the skip
branch is net-zero behaviour with one fewer dependency on
`prelude.ail.json`.
### Working-tree changes
- **Delete** `examples/prelude.ail.json`. After this commit the
prelude exists on disk only as `examples/prelude.ail`.
- After form-a.1 (T9), the per-pair `every_ail_fixture_matches_its_json_counterpart`
test was retired (no production `.ail`/`.ail.json` pairs remain
in the corpus except prelude itself). The deterministic-parse
property (`parse_is_deterministic_over_every_ail_fixture` and
`parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
in `crates/ailang-surface/tests/round_trip.rs`) stays as the
binding invariant for `prelude.ail`'s parse stability.
- **Cross-form-identity preflight** (mandatory before pd.2 swaps the
embed source — see Testing Strategy below for the mechanism):
capture `module_hash(serde_json::from_str(prelude.ail.json))` in
pd.2 BEFORE removing the JSON embed, and assert that
`module_hash(parse(prelude.ail))` equals it. Without this
preflight, the embed swap could silently change the canonical
prelude `Module` (different parse-path producing structurally
different AST) and propagate to every workspace's prelude
identity.
## Data flow
The runtime data flow does not change for any consumer. After the
milestone:
```
[examples/prelude.ail]
|
| include_str! (compile-time, into ailang-surface)
v
[PRELUDE_AIL: &'static str]
|
| parse_prelude() (called at every workspace load)
v
[Module]
|
v
surface::load_workspace
|
+--> core::load_modules_with # DFS + per-module load
| |
| v
| BTreeMap<String, Module> # no prelude yet
| |
| [check ReservedModuleName, insert prelude]
| |
| v
+--> core::build_workspace # validate + registry
|
v
Workspace # ready for check / codegen
```
`parse_prelude()` is called per workspace load (not memoised).
The prelude is ~120 lines; its parse cost is below the noise floor
of the rest of workspace loading (DFS reads, JSON deserialisation
of user modules, validation). If a future profiling pass shows it
as a hot spot, surface can introduce a `OnceLock<Module>` cache
locally without any API change.
## Error handling
### Existing error semantics — preserved
- `WorkspaceLoadError::ReservedModuleName { name: "prelude" }` —
same shape, fired by surface instead of core. Match-arms in
diagnostic-rendering code are unaffected because the enum variant
itself is unchanged.
- `WorkspaceLoadError::BareCrossModuleClassRef` /
`BareCrossModuleTypeRef` candidate lists — same shape, same
contents. The prelude-fallback candidate is now produced by the
generic `implicit_imports` walk, not by the hardcoded string.
### Build-time error: malformed `prelude.ail`
If `examples/prelude.ail` ever becomes unparseable, `parse_prelude()`
panics — same failure mode as the old `load_prelude()`'s
`expect("must parse as a Module")`. This is acceptable because the
prelude is build-time-validated by every test run; a malformed
prelude is a build-correctness bug, not a runtime concern.
### Migration error: stale callers of removed APIs
The retired `core::load_workspace` and the old `core::load_workspace_with`
signature are not behind any deprecation shim. Out-of-tree callers
(if any existed) would break at compile time with a clear "function
not found" / "wrong arity" error, not silently. Per the brainstorm
this milestone is internal-API-only; no stability promise to preserve.
## Testing strategy
Three new pin tests, all in `crates/ailang-surface/tests/`:
1. **`prelude_parse_pin.rs`** — pins that `parse_prelude()` succeeds
and produces a `Module` with `name == "prelude"` and an expected
minimum def-count (e.g. `>= 8` covering the four classes shipped
through milestones 22-24 plus their primitive instances). Guards
against `prelude.ail` being silently emptied.
2. **`prelude_module_hash_pin.rs`** — pins the `module_hash` of the
parsed prelude against a literal hex string captured at iter
close. This is the round-trip-stability anchor: any change to
`prelude.ail` that does not also re-parse to the same canonical
`Module` value flips this test red. The hex updates intentionally
on prelude-content changes, with a JOURNAL entry naming why.
**Cross-form-identity preflight** (added to the SAME test file as
the hash pin, in pd.2): before deleting the JSON embed source,
compute `let expected = module_hash(serde_json::from_str(PRELUDE_JSON))`
from the still-present `prelude.ail.json` bytes, then assert
`module_hash(parse(prelude.ail)) == expected`. This pins the
single load-bearing assumption that pd.2 introduces — that the
`.ail` parse path produces a `Module` structurally identical to
what the `.ail.json` deserialize path produces for the *same
prelude content*. The retired
`every_ail_fixture_matches_its_json_counterpart` test asserted
this for the entire corpus; this pin asserts it for the one file
that survives the corpus flip. The pin runs once (during pd.2),
then the JSON-half of the comparison disappears at pd.3 along
with the JSON file. The pin file itself transitions to
parse-only after pd.3.
3. **`prelude_decouple_carve_out_pin.rs`** — asserts that the file
`examples/prelude.ail.json` does NOT exist. Pins the milestone's
stated artefact retirement; rewriting it back into the tree
surfaces immediately.
Existing tests that continue to pin behaviour:
- `crates/ailang-core/tests/workspace_pin.rs` — DFS + module-graph
shape pins. Updated to call `surface::load_workspace`; outputs
identical.
- `crates/ailang-core/tests/carve_out_inventory.rs` — list updated
to reflect §C4 (b) becoming empty.
- The ~22 `ailang_surface::load_workspace` callers across the
workspace (production code in `crates/ail/src/main.rs` plus tests
in `ailang-check/`, `ailang-codegen/`, `ail/tests/`,
`ailang-core/tests/`) — public entry point unchanged, so no test
body or assertion changes. Verified by per-crate
`cargo test --workspace` green at unchanged baselines.
Regression baselines (`bench/check.py`, `bench/compile_check.py`,
`bench/cross_lang.py`) must stay green. None of them are expected
to regress because the runtime cost is identical (one parse per
workspace load, against a 120-line file).
## Acceptance criteria
The milestone closes when ALL of the following hold:
1. `examples/prelude.ail.json` is removed from the working tree.
2. `cargo build --workspace` succeeds with no `include_str!`
reference to `prelude.ail.json` anywhere in the workspace.
Verified by `git grep -l 'prelude\.ail\.json' -- '*.rs'`
returning only test/comment hits, not `include_str!` sites.
3. `git grep -l '"prelude"' crates/ailang-core/src/` returns no
matches (zero literal-`"prelude"` strings in core's `src/`).
4. `cargo test --workspace` is green, including the three new
pins above.
5. `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py`
are green at unchanged baselines.
6. `docs/specs/2026-05-13-form-a-default-authoring.md` §C4 (b) is
updated: the carve-out list is empty; the section either
deletes the (b) sub-bullet or notes "retired by
prelude-decouple milestone, 2026-05-14".
7. CLAUDE.md and `docs/DESIGN.md` reviewed: no statement still
asserts that core embeds the prelude. (Spot check — the
relevant CLAUDE.md sentence is the "authors write `.ail`" line,
which already aligns with the post-milestone state.)
## Iteration sketch
Tentative iter split (refined by `planner`):
- **iter 1 — `pd.1` core API split.** Introduce `load_modules_with`
and `build_workspace`; refit `load_workspace_with` as a deprecated
alias if needed during the transition (or delete outright).
Thread `implicit_imports: &[&str]` through the two validation
functions and their internal helpers. Move `ReservedModuleName`
off the core construction path. core changes only; no surface
changes yet. Tests stay green using the old wiring (surface
unchanged).
- **iter 2 — `pd.2` surface assumes ownership.** Add `PRELUDE_AIL`
+ `parse_prelude` to `ailang-surface`. Rewrite
`surface::load_workspace` to compose the new core primitives and
inject the parsed prelude. Remove the prelude embed from core.
Add the three new pins. **Critical preflight**: install the
cross-form-identity test (see Testing Strategy above) BEFORE
deleting the core-side JSON embed. The test must be green with
both forms still present, so the swap is justified by an active
green pin and not by hope.
- **iter 3 — `pd.3` retire `prelude.ail.json` + carve-out cleanup.**
Delete `examples/prelude.ail.json`. Delete the defensive include
in the migrate-subcommand. Update `carve_out_inventory.rs` and
the form-a-default-authoring spec §C4 (b). Run all benches.
The split is for plan visibility; the orchestrator may collapse to
two iters if pd.1 and pd.2 turn out small enough that one cohesive
commit covers both. pd.3 is its own iter so the artefact retirement
is auditable in isolation.