Finishes the project-wide English convention: - CLAUDE.md (the user opted in to translate it too). - examples/hello.ail.json: "Hallo, AILang." -> "Hello, AILang.". - E2E test assertion and IR snapshot for hello regenerated to match. - DESIGN.md "Project language: English" section: the previous CLAUDE.md exception is dropped. After this commit, no German remains in any committed file (grep -P '[äöüÄÖÜß]' is empty across .rs / .md / .json / .ll). Verified: cargo test --workspace passes (44/44). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.8 KiB
AILang — design decisions
This document records the core decisions for AILang. It is my contract with myself across future iterations. Prefer cuts over growth.
Goal
AILang is a programming language for LLM authors. It compiles to LLVM IR. Performance: native, no GC for the MVP.
Optimised for:
- Machine readability over human ergonomics. The source is structured.
- Local reasoning. Every definition carries its full type and effects.
- Provability. Pure core language, explicit effects, optional refinements.
- Robustness against hallucinations. Symbols are hashable; tools can verify existence without spending context window.
Project ecosystem
AILang is not just a language but an ecosystem. The language on its own is only valuable when its surroundings make it usable, checkable, and extensible for its target user (LLM authors). The repo therefore contains several equally important components — none of them optional, all of them evolving in lockstep with the language:
- Language core (
crates/ailang-core,crates/ailang-check,crates/ailang-codegen): AST, type system, codegen. - CLI (
crates/ail): toolchain for tooling consumers —manifest,describe,deps,check,build, etc., preferably with--jsonfor machine consumption. - Examples (
examples/): canonical.ail.jsonprograms. They are specification anchors, not demos — the E2E suite hangs off them. - Agents (
agents/): specialised sub-prompts (implementer, architect, tester, debugger) that form the project's own LLM tooling. They are a versioned part of the repo. Seeagents/README.md. - Docs (
docs/): DESIGN.md (what and why), JOURNAL.md (history). - Tests: unit tests per crate plus E2E in
crates/ail/tests/e2e.rs. Every new compiler path needs a test, otherwise the feature does not count as done.
When the language grows, these components grow with it. New tools that
strengthen the LLM tooling (e.g. ail diff, IR snapshot diffs, new agents)
explicitly belong in the ecosystem inventory of this section and are added
here as soon as they are established.
Project language: English
All in-tree content is written in English: source code (identifiers,
comments, string literals, CLI help), design documents, the journal, agent
prompts, READMEs, commit messages, examples, and CLAUDE.md. The live
conversation between user and me stays German for ergonomic reasons;
everything that lands in git is English. This keeps diffs and tooling output
uniform and matches the audience for AILang (LLM authors), for whom English
is the default.
Decision 1: source = data, not text
A module is a JSON object with a fixed schema. There is no parser for free-form text. Typos in identifiers turn into hash-lookup errors that the compiler proposes a fix for directly.
A textual form exists (.ail, S-expression-like), but only as a
bidirectional projection of the JSON form. It is intended for human reviews
and diffs.
Canonical format: .ail.json with deterministic key order.
Decision 2: content-addressed definitions
Every top-level definition has a hash value (BLAKE3 over canonical JSON
without the hash field itself). References between definitions go primarily
by name — names are for readability. The hash is the canonical identity.
Advantages:
- Refactoring by adding new defs, not by in-place change. Old versions stay callable until manually removed.
- Caching of typecheck results and codegen per hash.
- Diffs show exactly which def has changed.
Decision 3: pure core language + algebraic effects
The default is total, pure functions. Effects are declared as a set in the
function type: (Int) -> Int ![IO]. The effect set is row-polymorphic
(![IO | r]). In the MVP only the effects IO and Diverge (for infinite
loops) are wired up.
This is the most important LLM property: when I read a function, I can trust its signature without reading the body.
Decision 4: Hindley-Milner + optional refinements
MVP: HM with let-polymorphism. All types are inferable, but at the top level they must always be explicitly annotated (for local reasoning).
Later: refinement annotations that escalate to SMT. (i: Int | i >= 0). They
are reserved in the AST from the start, but in the MVP they are simply passed
through as opaque strings.
Decision 5: emit LLVM IR as text
Instead of inkwell or llvm-sys: AILang produces .ll files as strings
and hands them to clang for linking.
Rationale:
- The LLVM IR text syntax is largely stable across versions.
- No build dependency on a specific libllvm version.
- Generated code is trivially inspectable, which makes debugging much easier.
- An LLM can read the generated IR directly, which is harder with opaque library calls.
Trade-off: no inline optimisations through the LLVM API. We rely on
clang -O2 as the standard pipeline.
Mangling scheme (Iter 5c)
All AILang functions are mangled to @ail_<module>_<def> — even in the
single-module case. Constants likewise (@ail_<module>_<const>). Global
string literals carry a short hint for readability:
@.str_<module>_<hint>_<idx> (e.g. @.str_sum_fmt_int_0). The entry point
is a define i32 @main() trampoline (C / LLVM ABI) that calls
@ail_<entry-module>_main(). source_filename exists exactly once per
workspace and carries the entry-module name (<entry-module>.ail).
Convention: qualified cross-module references (Iter 5b)
Cross-module calls use no new AST node. Instead, a Term::Var { name }
with exactly one dot in the name is a qualified reference: <prefix>.<def>.
<prefix>is an import alias (import { module: "X", as: "<prefix>" }) or, when imported without an alias, the module name itself.<def>is the name of a top-level definition in the target module.- Def names MUST NOT contain a dot — the typechecker reports
invalid-def-namewithctx: { "reason": "contains-dot" }. - The workspace loader (Iter 5a) finds all reachable modules; the
typechecker (Iter 5b,
check_workspace) resolves dotted names through the import map. Diagnostic codes:unknown-module(prefix not imported),unknown-import(module found, def not).
Hash stability: no new AST node, no renamed fields — all previous module hashes stay bit-identical.
Data model (MVP)
Module
{
"schema": "ailang/v0",
"name": "<id>",
"imports": [{ "module": "<id>", "as": "<id>" }],
"defs": [Def...]
}
Def
kind ∈ { "fn", "type", "effect", "const" }. In the MVP only fn and const.
{
"kind": "fn",
"name": "<id>",
"type": Type,
"params": ["<id>"...],
"body": Term,
"doc": "<optional string>"
}
Term (expression)
{ "t": "lit", "lit": { "kind": "int" | "bool" | "unit", "value": ... } }
{ "t": "var", "name": "<id>" }
{ "t": "app", "fn": Term, "args": [Term...] }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
{ "t": "if", "cond": Term, "then": Term, "else": Term }
{ "t": "do", "op": "<eff>/<op>", "args": [Term...] }
In the MVP, do is only a direct call to a built-in effect op (no handler).
Type
{ "k": "con", "name": "Int" }
{ "k": "con", "name": "Bool" }
{ "k": "con", "name": "Unit" }
{ "k": "fn", "params": [Type...], "ret": Type, "effects": ["IO"...] }
{ "k": "var", "name": "a" }
{ "k": "forall", "vars": ["a"...], "body": Type }
Pipeline
.ail.json ─┐
├─ load + validate schema
├─ resolve names + assign hashes
├─ typecheck (HM, effect rows)
├─ lower to MIR (SSA-like, named SSA values)
├─ emit LLVM IR (.ll)
└─ clang -O2 *.ll -o binary
CLI
ail check <module.ail.json> — loads, validates, typechecks
ail manifest <module.ail.json> — table: name :: type !effects [hash]
ail describe <module> <name> — detail of a definition
ail render <module> — JSON → pretty-print
ail parse <module.ail> — pretty-print → JSON (for bootstrapping)
ail emit-ir <module> — writes .ll
ail build <module> — full pipeline → binary
Verification and correctness (across cycles)
- Snapshot tests for the pretty-printer and IR emit. The diff makes regressions visible immediately.
- Property tests for the JSON ↔ pretty-print roundtrip.
- End-to-end tests for
examples/with expected program output. - Hash stability: a test ensures the same def always produces the same hash.
- CI pin of the outputs in
tests/expected/.
What the MVP is NOT
- No ADTs / pattern matching (Phase 2).
- No closures / higher-order functions (Phase 2).
- No effect handlers (Phase 3).
- No refinements / SMT (Phase 4).
- No module system beyond imports (Phase 2).
- No first-class strings. Only ints + bools + unit.
The MVP is successful when examples/sum.ail.json produces a binary that
prints the sum 1..10 = 55.