- developer move; all inbound links repointed (root docs, plan/, plan/compiler/, exploration, superpowers plans+specs, in-progress marker), board's own links re-based one level deeper - prose mentions inside landed plans left as historical records Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
109 lines
6 KiB
Markdown
109 lines
6 KiB
Markdown
# Learnings — what attempts taught
|
||
|
||
What the work actually taught, independent of whether it shipped. Recorded so
|
||
the same wall is not hit twice. Newest first within each section.
|
||
|
||
Status board: [`00-status.md`](../stories/00-status.md) · Rejections: [`discarded.md`](discarded.md)
|
||
|
||
## Testing and verification
|
||
|
||
**Plumbed is not enforced — and a status table will happily claim otherwise.**
|
||
`?T` passed every lexer, parser, and dump test while its semantics did not
|
||
exist: `WO-E211`/`E212`/`E213` were declared and never emitted, so
|
||
`fn take_it(b: Box) -> Int { return b.v; }` with `v: ?Int` exited **0**. The
|
||
plan doc had marked the typechecker ✅. Assert on *behavior* — did the
|
||
diagnostic fire, what was the exit code — never on the presence of plumbing.
|
||
(2026-08-10, nullable-types audit.)
|
||
|
||
**A declared error-code constant is not a feature.** Ten `WO-E2xx` constants
|
||
existed in `types.ml` with no emission site anywhere, including
|
||
`unsatisfied_interface` — so structural interface satisfaction was unenforced
|
||
while the module's own doc comment claimed the satisfaction set was produced.
|
||
The error catalog now lists emitted and reserved codes separately.
|
||
|
||
**A golden test can pass vacuously.** The first diagnostic-ordering fixture put
|
||
both errors in the same pipeline stage, so the collector's insertion order
|
||
already equalled the required output order — the fixture would have passed with
|
||
the sort deleted. A fixture must **fail** when its mechanism is removed; prove
|
||
that by removing it once. The replacement splits the errors across stages so
|
||
insertion order is the reverse of output order.
|
||
|
||
**Exit-0-with-wrong-output is the worst failure mode, and only absence-testing
|
||
catches it.** Two instances in one plan: a class field named `on`, `service`, or
|
||
`policy` was silently absorbed by the skip-on-block dispatch (field gone, exit
|
||
0, empty stderr), and a dangling backslash at EOF inside a string was swallowed
|
||
with no diagnostic. Both were found by review, not by the suite, because no test
|
||
asserted that something *should* appear.
|
||
|
||
**Make memory correctness machine-checked.** Test classes given ~130 fields
|
||
exceed the arena's 1024-byte size-class ceiling and take the malloc path, so any
|
||
missed free becomes a hard ASan report instead of an invisible slop. Used
|
||
throughout the VM's drop, RC, cycle-collector, and trap-unwinding tests.
|
||
|
||
**A blind `bless` absorbs regressions.** `WOC_BLESS=1` rewrites every
|
||
`.expected` in every stage, not just the fixture you were thinking about. Prefer
|
||
hand-editing a predictable golden — then a passing test *proves* the prediction
|
||
— and if you do bless, diff the changed-file list against what you intended.
|
||
|
||
## Analysis and design
|
||
|
||
**"The runtime check will catch it" is false if nothing reaches the runtime.**
|
||
A double-`mut` reached through let-bound aliases (`let r = bag.items[i]; let s =
|
||
bag.items[k]; swap(r, s)`) escaped the static check *and* produced no residual
|
||
site — so the VM's borrow word, which only guards sites the table names, was
|
||
never engaged. Fixed by canonicalizing places through borrow bindings before
|
||
asking the aliasing question. Lesson: when a hybrid design defers a check to
|
||
runtime, verify the deferral actually lands in the table that drives it.
|
||
|
||
**Removals that look load-bearing may not be.** `owner.ml`'s
|
||
`is_abstract_type` call sat in a branch returning `Copy` — and its `else` branch
|
||
already returned `Copy` for unknown names, so deleting it was provably
|
||
behavior-neutral. Read the fallthrough before assuming a call site matters.
|
||
|
||
**Conservative joins leak.** Marking a conditionally-moved value `Moved` at an
|
||
`if`-join is safe against double-free but drops it from every later drop set, so
|
||
the not-moved path leaks — which an ASan gate would have caught only in plan 3.
|
||
Normalizing instead (drop at the non-moving branch's end) keeps the table shape
|
||
and costs only an earlier death on that path.
|
||
|
||
**Contract notes must live where the consumer will read them.** Two obligations
|
||
for the bytecode emitter — coalesce borrow guards per operand, and the
|
||
conditional-move drop rule — were first disclosed only in agent report files
|
||
that plan 3 will never open. They now live in `dump.ml`'s format-contract
|
||
comments beside the tables they constrain.
|
||
|
||
## Process and tooling
|
||
|
||
**Check that a new document is actually tracked.** `docs/plan/oop-vm/` was
|
||
swallowed by a blanket `docs/plan/` ignore rule, so the error catalog — a
|
||
normative compiler↔VM contract both plan tracks cite — existed only on local
|
||
disk and appeared in no diff. `.gitignore` now carves that directory back out.
|
||
|
||
**A status board that covers one track hides the other.** The kanban tracked
|
||
only the four Rust-runtime tracks while the entire OOP track (VM core shipped,
|
||
compiler front shipped) was invisible on it — so "what is next?" required
|
||
reading code and ledgers. Hence the six-bucket format and the next-plan pointer
|
||
at the top of the board.
|
||
|
||
**Prose reports are not a handoff.** Agent-written reports and SDD ledgers hold
|
||
the reasoning, but only files a developer opens by habit — the board, the plan,
|
||
the format contract — actually transfer it.
|
||
|
||
## Runtime, from the C proving ground
|
||
|
||
**Reference-implement first in C, then port.** The C proving ground (phases A–F)
|
||
hit 859k reads/s and 618k durable commits/s, and found the ack-ordering and
|
||
fd-ABA bugs the Rust port then avoided entirely. Building the risky thing twice,
|
||
cheaply first, was faster than building it once carefully.
|
||
|
||
**Budget the collector, don't stop the world.** Per-shard heaps plus per-shard
|
||
cycle-candidate buffers mean no global pause can even be expressed — the
|
||
worst case is a bounded slice of one shard's tick. Deferring all frees until
|
||
after the trial-deletion phases removed every dangling-candidate hazard that
|
||
incremental freeing had introduced.
|
||
|
||
**Validate once at the trust boundary, then trust it.** The `.wob` loader
|
||
bounds-checks every index and copies everything out into aligned structures, so
|
||
the interpreter's hot loop carries no static checks at all. Only the checks that
|
||
*cannot* be static — the borrow word, runtime-indexed bounds, map keys — remain,
|
||
and those always trap rather than corrupt.
|