A map entry is not a place

spec-memory.md drops (set (get m k) v) from the assignable forms: a map has an
upsert of its own, put, which either inserts or replaces, so there is no store
into a lookup - and an absent entry has no location to store into anyway.

The compiler still parsed it into an Ast.Pkey and refused it downstream as
unimplemented, milestone 6, which is the wrong reason for something that is
never arriving. The place form is gone from ast, tast, load, check and emit,
and the parser refuses the shape where it is written, with the reason and a
pointer to put.
This commit is contained in:
Joseph Ferano 2026-09-11 12:23:10 +07:00
parent ca954a47b5
commit 943561e765
9 changed files with 163 additions and 60 deletions

View File

@ -80,7 +80,6 @@ and place =
| Pvar of string
| Pfield of expr * string (* (set (.hp e) v) *)
| Pindex of expr * expr list (* (set (at grid r c) v) *)
| Pkey of expr * expr (* (set (get m k) v) *)
| Pderef of expr (* (set (deref p) v) *)
and arm = { pat : pattern; body : expr list; aloc : Loc.t }

View File

@ -262,7 +262,6 @@ let place_of_expr (e : Ast.expr) : Ast.place option =
| Ast.Field (t, f) -> Some (Ast.Pfield (t, f))
| Ast.Call ({ Ast.e = Ast.Var "at"; _ }, t :: idx) when idx <> [] ->
Some (Ast.Pindex (t, idx))
| Ast.Call ({ Ast.e = Ast.Var "get"; _ }, [ m; k ]) -> Some (Ast.Pkey (m, k))
| Ast.Call ({ Ast.e = Ast.Var "deref"; _ }, [ p ]) -> Some (Ast.Pderef p)
| _ -> None
@ -882,7 +881,6 @@ and check_place ctx loc (p : Ast.place) : Tast.place * Types.t =
let target = check ctx target in
let idx, ty = indexed ctx target idx in
Tast.Pindex (target, idx), ty
| Ast.Pkey _ -> unimplemented loc "(get m k) as a place — Map" 6
| Ast.Pderef target ->
let target = check ctx target in
(match target.Tast.ty with

View File

@ -458,7 +458,6 @@ and place f (p : Tast.place) : string * Types.t =
| Types.Ptr t -> t | t -> failwith ("deref of " ^ Types.to_string t)
in
value f target, t
| Tast.Pkey _ -> failwith "Map places are milestone 6"
(* A struct or fixed-array value, built field by field from zeroinitializer.
The checker already filled the omitted fields in with Zero, so this is

View File

@ -208,7 +208,6 @@ and rename_place owned alias bound (p : Ast.place) : Ast.place =
Ast.Pvar (if List.mem n owned && not (List.mem n bound) then qualify alias n else n)
| Ast.Pfield (t, f) -> Ast.Pfield (go t, f)
| Ast.Pindex (t, idx) -> Ast.Pindex (go t, List.map go idx)
| Ast.Pkey (m, k) -> Ast.Pkey (go m, go k)
| Ast.Pderef t -> Ast.Pderef (go t)
let rename_field owned alias (f : Ast.field) : Ast.field =

View File

@ -357,12 +357,17 @@ and place (f : Form.t) : Ast.place =
| _ -> fail f "field place is (.%s value)" field)
| List ({ v = Sym "at"; _ } :: target :: idx) when idx <> [] ->
Ast.Pindex (expr target, List.map expr idx)
| List [ { v = Sym "get"; _ }; m; k ] -> Ast.Pkey (expr m, expr k)
(* Not a place. spec-memory.md gives a map an upsert of its own — [put]
either inserts or replaces so there is no store into a lookup, and an
entry that is absent has no location to store into. Refused here rather
than parsed into a place form the language does not have. *)
| List ({ v = Sym "get"; _ } :: _) ->
fail f "(get m k) is not a place — a map is written with (put m k v)"
| List [ { v = Sym "deref"; _ }; p ] -> Ast.Pderef (expr p)
| _ ->
fail f
"%s is not assignable. set takes a name, (.field x), (at a i ...), \
(get m k) or (deref p)"
or (deref p)"
(Form.to_string f)
and arms f (items : Form.t list) : Ast.arm list =

View File

@ -87,7 +87,6 @@ and place =
| Pglobal of string
| Pfield of expr * int
| Pindex of expr * expr list
| Pkey of expr * expr
| Pderef of expr
(* A pushed handler: which condition type it matches, and the lifted function

104
plan.org
View File

@ -95,6 +95,12 @@ world.
~Vec~ and ~Map~ are monomorphic on element type and record their allocator. Not
a Lua-style array/hash hybrid — that is what makes Lua's layout and performance
unpredictable.
- Map keys initially use compiler-provided structural equality and hashing for
integers, enums, strings, fixed arrays and value structs; pointers, slices and
owning containers are excluded. A map is homogeneous, and empty construction
is type-directed: ~(defvar enemies (Map string Enemy) (map-new))~. ~get~
returns ~(Option V)~; ~put~ is the `Unit`-returning upsert. See
spec-memory.md for the deferred move-aware operations.
- Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~nth~, ~len~, ~update~.
Copying is explicit: ~(clone m)~, and owning containers move rather than copy on
assignment. No ~!~ convention — nothing is immutable, so it
@ -151,10 +157,11 @@ static typing the tag is paid only where it is asked for, in ~any~.
HKTs). Lowercase type names are variables, Capitalized are concrete — no sigil.
This is what makes ~map~/~filter~/~reduce~ and the monomorphic containers work.
The price, made explicit in spec-memory.md: with no constraints, a type variable
supports only what every type supports. ~=~, ~<~, ~+~, ~hash~ and ~print~ over an
supports only what every type supports. ~=~, ~<~, ~+~ and ~hash~ over an
unconstrained ~a~ are rejected, not silently instantiated — they are passed in as
function values. Compile-time interfaces, if they are ever wanted, come after the
base checker is stable.
function values. ~println~ is the one compiler-provided exception: it selects a
structural printer at each concrete instantiation. Compile-time interfaces, if
they are ever wanted, come after the base checker is stable.
- Function values split three ways (spec-memory.md): ~(Fn [T1 T2] R)~ is a plain
pointer with no environment — the only kind that crosses FFI or sits in a reload
cell; a *non-escaping* ~fn~ captures enclosing locals by value into a stack
@ -164,7 +171,8 @@ static typing the tag is paid only where it is asked for, in ~any~.
conditions plus ~Option~ and ~or-else~. Monadic sequencing, if ever wanted, is a
macro.
- ~any~ is an explicit opt-in tagged union, for heterogeneous containers and debug
printing. It is the only place a tag word is paid.
printing. ~Error~ is a second, specialised erased carrier for fallible results;
neither adds a header to ordinary values.
- Tagged unions, ~distinct~ types, bit sets.
** Consequences
@ -227,20 +235,27 @@ see Compilation.
1. ~Option~ for expected absence: lookup miss, empty collection, end of stream.
2. Conditions for exceptional failure where a caller may have a recovery policy.
3. ~Result~ where failure should be visible in the signature (parsers, fallible
pure functions). Error sets *inferred* from the body (Zig's model) so ~try~
widens callers automatically; explicit sets required on exported functions.
pure functions). The ordinary form is ~(Result T Error)~: an erased built-in
error carrier, not an external ~anyhow~-style library. A precise ~(Result T E)~
remains available where it materially helps.
~Error~ is a small value carrying a type id, pointer to an allocator-owned typed
payload, source location, context and a rendered summary. It preserves structured
fields for debugger/editor inspection while the summary is stable to print and
send over the wire. This makes ~(Vec Error)~ the standard heterogeneous diagnostic
collection. Wrapping an error uses the current implicit allocator; metadata is
out-of-band, so ordinary values keep their header-free C layout.
Rule of thumb: if you can name the one correct recovery at the point of failure,
return a ~Result~. If the answer is "depends who is calling", signal a condition.
Mechanisms — two unwrap operators, because they are two different things (Zig's
split):
- ~try~ — unwrap ~Ok~, else early-return ~Err~, widening the enclosing error set
- ~try~ — unwrap ~Ok~, else early-return ~Err~
- ~some~ — unwrap ~Some~, else early-return ~None~
- ~(ok-or opt err)~ / ~(ok res)~ — conversions, always *explicit*. This is the one
thing Rust got right and ~From~/~anyhow~ got wrong: the noise is not ~?~, it is
the implicit conversion machinery ~?~ demands. ~try~ does *not* accept an
~Option~ in a ~Result~-returning function.
- ~(ok-or opt err)~ / ~(ok res)~ — conversions, always *explicit*. ~try~ does not
accept an ~Option~ in a ~Result~-returning function. Converting a precise error
to ~Error~ is explicit too; there is no trait-based conversion search.
- ~some->~ (short-circuiting thread), ~or-else~, ~if-let~
- ~errdefer~ — cleanup on the failure path only, pairs with arenas
@ -269,7 +284,10 @@ combination, no MOP, no runtime dispatch table — see Types.
- ~&env~ equivalent: macros seeing the lexical environment (names of locals in
scope). No longer urgent — its "decide now" status came from the
instrumentation-based step debugger, which is cut (see Tooling). Milestone 5.
- Hygiene model: open decision.
- Macros are initially deliberately non-hygienic, in the Common Lisp/Clojure
style. A macro uses explicit ~gensym~ for introduced bindings; there is no
syntax-object hygiene system. Lexically local macros (~macrolet~-style) wait
for a concrete use case.
* Host language
*OCaml.* The compiler only; the runtime and stdlib are Flan with a few C
@ -308,9 +326,11 @@ wasm32 target cheap, because a primitive is the only thing implemented twice.
| arithmetic, comparison, casts | per machine type |
Printing is *not* a primitive. ~print-str~, ~print-f64~ and friends are Flan
functions over ~write-stdout~. A single overloaded ~println~ waits for milestone
5 — until then the acceptance programs name the type, because compile-time
overloading before the checker is stable is how a small language stops being one.
functions over ~write-stdout~. At milestone 5, compiler-provided ~println~ emits
or selects a structural printer for every concrete type, including generic
instantiations. This is intentionally not user-defined overload resolution:
ordinary values remain untagged, while ~any~ and ~Error~ carry the metadata their
dynamic printers need.
*Entry point.* ~(defn main [args [string]] i32)~. Both the parameter and the
return type are optional: omitting ~args~ means the program ignores argv,
@ -480,7 +500,9 @@ behaviour. That agreement is what the acceptance programs test.
- Non-local exit lowered *explicitly* (result propagation + branch targets), not
via platform unwinding. Same on both targets, no dependency on the WASM
exception-handling proposal. Escape analysis narrows which functions pay for it.
exception-handling proposal. Every Flan function carries the transfer channel;
uniformity keeps indirect calls and hot reload ABI-safe. A later optimisation may
narrow that cost, but it cannot change the ABI.
- Stdlib written *in Flan*, not the host language. A few hundred primitives per
backend, everything else on top. This is what keeps a second backend cheap.
@ -514,9 +536,17 @@ Deliberately different.
Build and run the release config regularly, not just at ship time.
* Hot reload
Every cross-function call goes through an *indirection cell*; redefinition is one
atomic pointer store. *Old code is never unloaded*, so a thread mid-execution
finishes safely in the old version.
Every cross-function call goes through an *indirection cell*; body redefinition
is one atomic pointer store. A top-level function value is a stable trampoline
over the same cell, never a pointer to a specific body, so callers holding it
observe a body redefinition too. *Old code is never unloaded*, so a thread
mid-execution finishes safely in the old version.
A signature-changing redefinition creates a new internal function version and
trampoline. Newly compiled callers use it; existing callers and stored function
values safely retain the old version. The session immediately warns at each
tracked old caller site, and recompiling that caller either updates it or gives a
normal type error. This preserves live running code without hiding stale calls.
This is the fix for jank issue #947 (segfault redefining a running loop's
function plus its callee — their JIT relinks and unloads under a running thread).
@ -638,8 +668,9 @@ building the whole live environment at once.
6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and
restarts* against spec-conditions.md, with dedicated tests per numbered case.
7. *Hot reload* — free in the interpreter, indirection cells for compiled dev
builds, with the compatibility limits written down and enforced: signature
changes, struct layout changes, live callbacks held by C, captured environments.
builds, with signature generations and stale-caller warnings, plus the
remaining compatibility limits written down and enforced: struct layout
changes, live callbacks held by C, captured environments.
8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor
client (8b).
@ -685,25 +716,34 @@ marked.
1. *Host language: OCaml or Rust* — the only thing blocking the scaffold. See
Host language. /Milestone 2./
2. Macro hygiene model. /Milestone 5./
3. Escape analysis: automatic promotion of escaping frame-arena values, or
explicit. /Milestone 6./
2. Macro hygiene is settled for milestone 5: explicit ~gensym~, deliberately
non-hygienic expansion, no local macros until a concrete use case appears.
3. Borrow checking and escaping frame-arena values. /Deferred; revisit after
Vec/Map are useful in real programs./ The first implementation deliberately
follows Zig/Odin: slices and pointers have explicit lifetime and
reallocation contracts, with dev generation checks. A later lightweight
provenance pass may catch obvious escaping/stale-borrow mistakes, but must
not impose Rust-style lifetime annotations, automatic promotion, or an
ECS-shaped object model.
4. Threads *in the language*: yes or no, decided before the host ABI. (The dev
/runtime/ is multithreaded regardless — it needs a reload thread. Settled; see
Compilation.) /Milestone 3, before the host ABI./
5. /Escaping/ closures. /Milestone 6./ Settled in spec-memory.md:
5. /Escaping/ closures. /Deferred until a concrete use case./ Settled in
spec-memory.md:
~(Fn ...)~ is a bare pointer with no environment (callbacks, reload cells, FFI),
and a *non-escaping* ~fn~ captures by value into a stack environment — which is
what makes ~handler-bind~ handlers able to see enclosing locals, without which
conditions are not worth building. Still open: a closure that is stored,
returned, or pushed into a container. Which allocator owns its environment, and
what happens when the frame arena resets? Decided together with #3, because
the same escape analysis classifies both.
6. Hot-reload compatibility rules. /Milestone 7./ Cells cover a function
body changing. Not covered: a changed signature, a changed struct layout with
live instances, a function pointer already handed to C, a captured environment,
and redefining a ~defvar~. Each needs an answer of the form "rejected",
"accepted with a migration", or "accepted and the old code keeps running".
what happens when the frame arena resets? Do not design this speculatively;
revisit it alongside the deferred provenance work only when it is needed.
6. Hot-reload compatibility rules. /Milestone 7./ A changed function signature
creates a new version: new callers resolve it, old callers keep the old one,
and the session warns at tracked stale caller sites. Still open: a changed
struct layout with live instances, a function pointer already handed to C, a
captured environment, and redefining a ~defvar~. Each needs an answer of the
form "rejected", "accepted with a migration", or "accepted and the old code
keeps running".
7. Does the interpreter survive milestone 3, or is the compiled path the only
backend? /Milestone 3, on measured numbers./ See Compilation.
8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the

View File

@ -24,6 +24,31 @@ Everything else in the design references this vocabulary.
transfers ownership; the source binding is dead afterwards and using it is a
compile error. There is no shallow copy, so there is no double free.
## Maps — first implementation
Every map is homogeneous: `(Map K V)` has one key type and one value type. The
first implementation accepts only built-in structural key types: integers,
enums, strings, fixed arrays, and value structs composed recursively from those
types. Tuples and triples join that set when they are introduced. `Ptr`, slices,
`Vec`, and `Map` are not map keys yet.
Equality and hashing for those keys are compiler-provided structural operations,
not type classes and not operations available to an unconstrained type variable.
An empty map takes its type from its context:
```
(defvar enemies (Map string Enemy) (map-new))
```
`(get m k)` returns `(Option V)`: absence is `None`, not an untyped `nil`.
`(put m k v)` is the upsert operation and returns `Unit`; it either inserts or
replaces. `(set (get m k) v)` is not map syntax.
The first Map implementation admits copyable keys and values only, so `get`
returns a copy. Move-aware lookup, removal, and owned entries are deferred until
`Vec`/`Map` values are supported in maps; the map itself remains an owning,
move-only container.
## Copying is always explicit
`(clone x)` produces an independent deep copy of a `Vec`/`Map` using the current
@ -36,9 +61,17 @@ not declared: a type is a value type iff all of its fields are.
## Borrowing
- `(as-slice v)` / `(as-slice v lo hi)` view a `Vec` or fixed array as `[T]`.
- A slice is invalidated by any operation that may reallocate the owner (`push`,
`put`, `reserve`). This is **not checked** in the first implementation; dev
builds carry a generation word on `Vec` and trap on use of a stale slice.
- **The first implementation follows Zig/Odin's explicit model, not Rust's
borrow checker.** A slice is invalidated by any operation that may reallocate
its owner (`push`, `put`, `reserve`); its user is responsible for respecting
that contract. Dev builds carry a generation word on `Vec` and trap on use of
a stale slice. `Ptr` is the explicit lower-level escape hatch and has the
same lifetime contract.
- A future lightweight provenance pass may reject the obvious mistakes (a
borrow of a local escaping, use after an owner moves, and reallocation with a
live borrow). It must not require Rust-style lifetime annotations or dictate
an ECS-shaped object model. Long-lived graph links use `(Handle a)`; temporary
graphs may use explicitly managed, stable region storage.
- Cross-referencing long-lived objects uses `(Handle a)` into a pool, never a
raw pointer or slice. A stale handle is detectable.
@ -47,12 +80,14 @@ not declared: a type is a value type iff all of its fields are.
`(addr x)` yields `(Ptr T)` for any assignable place `x` — a local, a global, a
field, an element. The pointer is non-owning and does not extend anything's
lifetime, so `addr` of a local is only valid while that frame lives. This is the
same escape question as case 3 below and is checked by the same analysis; until
that analysis exists, `addr` of a local may not be stored or returned.
same escape question as case 3 below. The first implementation leaves it as an
explicit lifetime contract; a future provenance pass can check it.
`addr` is how a value struct is shared mutably without an allocator — recursive
descent over a cursor, an entity passed down a call chain — and it is why
milestone 2 needs no heap at all.
milestone 2 needs no heap at all. In the first implementation its non-escape
rule is an explicit programmer contract, aided by dev checks; the future
provenance pass above may enforce it.
## Places — what `set` accepts
@ -62,7 +97,6 @@ A fixed set of assignable forms, not a `setf`-style extensible place mechanism:
(set x v) ; a local or a defvar
(set (.field x) v) ; struct field; x may be a struct, (Ptr S) or (Handle S)
(set (at a i ...) v) ; fixed array, slice, or Vec element
(set (get m k) v) ; map entry
(set (deref p) v) ; whole-object store through a pointer
```
@ -89,7 +123,7 @@ Parametric polymorphism is monomorphisation, with **no type classes and no
constraints**. The consequence is a hard rule:
> A type variable `a` supports only what every type supports: move, `clone`,
> field-free storage. It does **not** support `=`, `<`, `+`, `hash`, or `print`.
> field-free storage. It does **not** support `=`, `<`, `+`, or `hash`.
Anything else is passed in explicitly as a function value:
@ -102,6 +136,16 @@ instantiated. The alternatives — compile-time interfaces, or intrinsics
restricted to primitives — are deliberately deferred until the base checker is
stable (build sequence milestone 4).
`println` is the deliberate exception. It is a compiler-provided,
type-directed intrinsic: monomorphisation selects or emits a structural printer
for each concrete instantiation, so `(println x)` is legal for `x : a` without
introducing a `Printable` type class. Structs, fixed arrays, options and,
eventually, Vecs and Maps print structurally. `Ptr` and `Handle` print their
address or identity rather than recursively dereferencing, and collection
printers impose depth and length limits. `any` and `Error` use their runtime type
metadata. User generic code still passes an explicit function for every other
operation that depends on a type's structure.
Type arguments are **inferred at call sites** from the argument types; there is
no explicit instantiation syntax in the first implementation. A type variable
that appears only in the return type is therefore an error.
@ -110,12 +154,24 @@ that appears only in the return type is therefore an error.
Three cases, split by whether the value escapes the frame that made it.
**1. `(Fn [T1 T2] R)` — a plain function pointer.** No captured environment, no
allocation, C calling convention plus the implicit allocator argument. This is
what raylib callbacks, hot-reload indirection cells, and function *parameters*
use. A top-level `defn` is one, so `(largest hps >)` passes `>` at `i32`
directly. This is the only function type that may cross an FFI boundary or sit
in a reload cell.
**1. `(Fn [T1 T2] R)` — a plain, stable function pointer.** No captured
environment or allocation. In a dev build, a reference to a top-level `defn`
is the address of a stable trampoline that loads that function version's
indirection cell and calls its current body; it is never the address of a
particular body. Thus stored callbacks and ordinary calls observe a later
*body* redefinition, as in Common Lisp. Release builds may call the body
directly because it cannot be redefined.
A signature-changing redefinition makes a new internal function version and a
new trampoline ABI. Newly compiled code resolves the source name to that new
version. Existing callers and stored `Fn` values keep their old trampoline and
therefore safely call the old version. The session immediately warns at every
tracked caller source location that still targets the old signature; recompiling
one either retargets it successfully or reports an ordinary type error. This is
what raylib callbacks, hot-reload cells, and function parameters use. A
top-level `defn` is one, so `(largest hps >)` passes `>` at `i32` directly. This
is the only function type that may cross an FFI boundary or sit in a reload
cell.
**2. Non-escaping `fn` — captures by value into a stack environment.** A `fn`
whose value provably does not outlive the frame that created it gets an
@ -136,11 +192,11 @@ outlives every call to them.
Captured `Vec`/`Map` are captured **by pointer**, not moved, since the capture
does not outlive the owner. A non-escaping `fn` is therefore not itself an owner.
**3. Escaping closures — still open.** A `fn` stored in a struct, pushed into a
**3. Escaping closures — deferred.** A `fn` stored in a struct, pushed into a
container, or returned needs a heap environment and an answer to "which allocator
owns it, and what happens when the frame arena resets". Not settled; see
plan.org open decisions. Escape analysis (open decision #4) is the same analysis
that classifies cases 2 and 3, so they are decided together.
owns it, and what happens when the frame arena resets". Do not settle this until
a concrete use case requires it; revisit it with the optional lightweight
provenance work.
**Early exit inside a `fn`.** `try`, `some`, and `return` in a `fn` body exit the
`fn`, not the enclosing function — a `fn` is a function. Code that wants to
@ -150,5 +206,6 @@ propagate out of a loop uses an imperative loop form, not a callback.
The allocator is part of the calling convention (`context/allocator`,
`context/temp`). `Vec` and `Map` record the allocator they were created with, so
`free` and `clone` never need it named again. No core operation allocates
implicitly.
`free` and `clone` never need it named again. Allocation uses the current
implicit allocator by default, as in Odin; an operation never falls back to a
hidden global allocator, and an explicit allocator can override the context.

View File

@ -661,6 +661,13 @@ let () =
"(defstruct C [id i32])\n\
(defn f [] i32 (handler-bind [(C [c] (signal c))] (return 1)) 0)"
~needle:"return is not allowed inside handler-bind";
(* spec-memory.md gives a map an upsert of its own, so there is no store
into a lookup and no place form for one. Refused with that reason rather
than as a milestone that will never arrive. *)
rejects_check "a map entry as a place"
"(defn f [] (set (get m 1) 2))"
~needle:"a map is written with (put m k v)";
(* ── restart-case and invoke-restart, §3 to §6 ─────────────────── *)
accepts "restart-case with a clause that transfers into it"