From c6ba0677f0de4f0eee0691abcb9b2feaf309738b Mon Sep 17 00:00:00 2001 From: bigmerge Date: Mon, 17 Aug 2026 10:52:17 -0500 Subject: [PATCH] log v1 experiments: nineteen cycles, organised by the method that produced them cycles/ one file per Ishikawa -> scientific method -> Six Sigma loop, named for the DEFECT not the fix, carrying the commit record as written at the time findings/ what the cycles produced, cross-cut: live bugs, architecture answers, and defects in my own measurement The organising finding is that predictions which came back FALSE produced every significant result. Eleven of sixty-one failed, and those eleven found: that the arity table was not drifted but 40% incomplete; that the AST traversal is irreducible and only rules and judgments move; that guards could refuse through the seam after all; and that routing el_bin_lookup through the gate did NOT fix the SIGSEGV, because the fallback strlen was the hazard -- a wrong fix I would otherwise have shipped as verified. One cycle was run without committing predictions first and had to be discarded as rigged. It is kept, in full, as 18-async-half-expressible.md. --- docs/v1/experiments/README.md | 59 ++++++ docs/v1/experiments/cycles/00-INDEX.md | 26 +++ .../01-constructs-have-nowhere-to-be.md | 42 +++++ .../cycles/02-a-construct-cannot-refuse.md | 43 +++++ .../cycles/03-the-wrapper-was-conditional.md | 82 +++++++++ .../cycles/04-c-has-no-closure-syntax.md | 64 +++++++ .../05-the-emitter-discards-what-it-knows.md | 48 +++++ .../06-the-crossing-resolves-at-emission.md | 170 ++++++++++++++++++ .../cycles/07-invocation-is-not-composable.md | 45 +++++ .../cycles/08-the-emitter-adjudicates.md | 53 ++++++ .../cycles/09-policy-inside-the-compiler.md | 49 +++++ .../cycles/10-a-second-copy-of-the-header.md | 54 ++++++ .../cycles/11-one-type-erases-the-return.md | 52 ++++++ .../12-judgment-lives-with-knowledge.md | 47 +++++ .../cycles/13-thirty-five-return-types.md | 49 +++++ .../14-keywords-that-reserve-nothing.md | 57 ++++++ .../cycles/15-no-namespacing-at-all.md | 53 ++++++ .../cycles/16-tokens-carry-no-position.md | 64 +++++++ .../17-annotations-are-never-checked.md | 53 ++++++ .../cycles/18-async-half-expressible.md | 73 ++++++++ .../cycles/19-a-convention-is-not-a-gate.md | 61 +++++++ docs/v1/experiments/findings/answers.md | 12 ++ docs/v1/experiments/findings/bugs.md | 74 ++++++++ .../findings/measurement-defects.md | 41 +++++ 24 files changed, 1371 insertions(+) create mode 100644 docs/v1/experiments/README.md create mode 100644 docs/v1/experiments/cycles/00-INDEX.md create mode 100644 docs/v1/experiments/cycles/01-constructs-have-nowhere-to-be.md create mode 100644 docs/v1/experiments/cycles/02-a-construct-cannot-refuse.md create mode 100644 docs/v1/experiments/cycles/03-the-wrapper-was-conditional.md create mode 100644 docs/v1/experiments/cycles/04-c-has-no-closure-syntax.md create mode 100644 docs/v1/experiments/cycles/05-the-emitter-discards-what-it-knows.md create mode 100644 docs/v1/experiments/cycles/06-the-crossing-resolves-at-emission.md create mode 100644 docs/v1/experiments/cycles/07-invocation-is-not-composable.md create mode 100644 docs/v1/experiments/cycles/08-the-emitter-adjudicates.md create mode 100644 docs/v1/experiments/cycles/09-policy-inside-the-compiler.md create mode 100644 docs/v1/experiments/cycles/10-a-second-copy-of-the-header.md create mode 100644 docs/v1/experiments/cycles/11-one-type-erases-the-return.md create mode 100644 docs/v1/experiments/cycles/12-judgment-lives-with-knowledge.md create mode 100644 docs/v1/experiments/cycles/13-thirty-five-return-types.md create mode 100644 docs/v1/experiments/cycles/14-keywords-that-reserve-nothing.md create mode 100644 docs/v1/experiments/cycles/15-no-namespacing-at-all.md create mode 100644 docs/v1/experiments/cycles/16-tokens-carry-no-position.md create mode 100644 docs/v1/experiments/cycles/17-annotations-are-never-checked.md create mode 100644 docs/v1/experiments/cycles/18-async-half-expressible.md create mode 100644 docs/v1/experiments/cycles/19-a-convention-is-not-a-gate.md create mode 100644 docs/v1/experiments/findings/answers.md create mode 100644 docs/v1/experiments/findings/bugs.md create mode 100644 docs/v1/experiments/findings/measurement-defects.md diff --git a/docs/v1/experiments/README.md b/docs/v1/experiments/README.md new file mode 100644 index 0000000..dbf50c5 --- /dev/null +++ b/docs/v1/experiments/README.md @@ -0,0 +1,59 @@ +# v1 — Experiments + +Every change to El on `iteration-1` was produced by one loop, run repeatedly: + +``` +Ishikawa → scientific method → Six Sigma → repeat +``` + +- **Ishikawa** — name the root cause, not the symptom. *Why is this table here?* + never *why is this table ugly?* +- **Scientific method** — state a hypothesis, **commit predictions before + running**, then run it in an isolated worktree and grade every prediction + including the ones that failed. +- **Six Sigma** — eliminate the defect *class*, then add a control so it cannot + silently return. + +## The organising finding + +**Predictions that came back FALSE were worth more than the ones that held.** + +Nineteen cycles, sixty-one predictions. The eleven that failed produced every +significant result: + +| Failed prediction | What it found | +|---|---| +| "the arity table has drifted from the header" | Zero drift — but **110 functions had no entry at all**. The table was not wrong, it was 40% incomplete. | +| "codegen drops below baseline" (×4) | The **traversal is irreducible**. Walking an AST to find calls does not move no matter who decides. Only the rule and the judgment leave. | +| "guards cannot refuse through the seam" | One line, and refusal works. Six compile-time kinds were unnecessary. | +| "C forbids the struct redefinition" | C allows shadowing — and a *different* defect surfaced: an exit injection emitted with an empty target. | +| "routing el_bin_lookup through the gate fixes the SIGSEGV" | It did not. The **fallback** was the hazard: `strlen()` on an integer. I would have shipped the wrong fix and called it verified. | + +A prediction that only ever confirms is a demonstration, not a test. One cycle +was run **without** committing predictions first — `async-half-expressible` — +and it produced a rigged result: `pthread_join` immediately after +`pthread_create`, with the word `DEFERRED` printed by the test itself. It had to +be discarded and re-run. + +## Layout + +``` +cycles/ one file per loop, numbered in order, named for the DEFECT +findings/ what the cycles produced, cross-cut by kind +``` + +## Scoreboard + +``` +cycles run 19 +predictions committed 61 +predictions FALSE 11 ← the useful ones +silent miscompilations found 4 +security-relevant defects 2 +architecture questions closed 5 +defects in my own measurement 4 +``` + +Every cycle verified the same three things before landing: the compiler +self-hosts byte-identically (gen2 == gen3), the native suite passes, and the +integration harnesses pass. A cycle that could not show all three did not land. diff --git a/docs/v1/experiments/cycles/00-INDEX.md b/docs/v1/experiments/cycles/00-INDEX.md new file mode 100644 index 0000000..3eaac67 --- /dev/null +++ b/docs/v1/experiments/cycles/00-INDEX.md @@ -0,0 +1,26 @@ +# Cycles + +Each is one `Ishikawa → scientific method → Six Sigma` loop, run in an isolated +worktree so a wrong answer cost nothing. Named for the **defect**, not the fix. + +| # | Cycle | Root cause | Predictions | Landed | +|---|---|---|---|---| +| 01 | [constructs-have-nowhere-to-be](01-constructs-have-nowhere-to-be.md) | a construct had nothing to BE, so its meaning lived in the emitter | 3/3 | yes | +| 02 | [a-construct-cannot-refuse](02-a-construct-cannot-refuse.md) | injection discards the target's result; no form said no | 4/4 | yes | +| 03 | [the-wrapper-was-conditional](03-the-wrapper-was-conditional.md) | exit injection needed compile-time knowledge only because the wrapper was conditional | 3/4 | yes | +| 04 | [c-has-no-closure-syntax](04-c-has-no-closure-syntax.md) | "C has no closures" taken as a fact about what is possible | 5/7 | yes | +| 05 | [the-emitter-discards-what-it-knows](05-the-emitter-discards-what-it-knows.md) | codegen sees every construct relation and throws it away | 5/5 | branch | +| 06 | [the-crossing-resolves-at-emission](06-the-crossing-resolves-at-emission.md) | the binary has no table to consult | 3/4 | yes | +| 07 | [invocation-is-not-composable](07-invocation-is-not-composable.md) | the wrapper called the target directly | 5/5 | yes | +| 08 | [the-emitter-adjudicates](08-the-emitter-adjudicates.md) | a prohibition had nowhere to live but a `#error` | 4/5 | yes | +| 09 | [policy-inside-the-compiler](09-policy-inside-the-compiler.md) | a program cannot declare its own restrictions, so the tier policy was compiled in | 4/5 | yes | +| 10 | [a-second-copy-of-the-header](10-a-second-copy-of-the-header.md) | builtin arity hand-maintained beside `el_runtime.h` | 4/5 | yes | +| 11 | [one-type-erases-the-return](11-one-type-erases-the-return.md) | `el_val_t` means the header cannot say `now()` returns an Instant | 4/5 | yes | +| 12 | [judgment-lives-with-knowledge](12-judgment-lives-with-knowledge.md) | the emitter knows the types, so it also judged them | 5/5 | yes | +| 13 | [thirty-five-return-types](13-thirty-five-return-types.md) | `is_int_call` hardcoded what drives `+` dispatch | 6/6 | yes | +| 14 | [keywords-that-reserve-nothing](14-keywords-that-reserve-nothing.md) | 5 of 46 keywords consumed by no path | 6/6 | yes | +| 15 | [no-namespacing-at-all](15-no-namespacing-at-all.md) | `import` is textual inlining; every name is global | 4/4 | yes | +| 16 | [tokens-carry-no-position](16-tokens-carry-no-position.md) | a token was `(kind, value)`, so no diagnostic could name a place | 6/6 | yes | +| 17 | [annotations-are-never-checked](17-annotations-are-never-checked.md) | the annotation feeds dispatch and is never verified | 6/6 | branch | +| 18 | [async-half-expressible](18-async-half-expressible.md) | **first attempt was DOGMA** — no predictions, rigged test | 4/4 (2nd) | branch | +| 19 | [a-convention-is-not-a-gate](19-a-convention-is-not-a-gate.md) | `looks_like_heap_obj` is static, so every type re-derives it | 6/7 | yes | diff --git a/docs/v1/experiments/cycles/01-constructs-have-nowhere-to-be.md b/docs/v1/experiments/cycles/01-constructs-have-nowhere-to-be.md new file mode 100644 index 0000000..560edbf --- /dev/null +++ b/docs/v1/experiments/cycles/01-constructs-have-nowhere-to-be.md @@ -0,0 +1,42 @@ +# constructs have nowhere to be + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `5718943` + +``` +let a construct declare its own meaning instead of the emitter knowing it + +codegen called fn_has_decorator for exactly three names — manager, accessor, +route. Twelve others parsed, attached as {name,args}, and compiled to nothing, +including four that look like protection: @authenticate (6 uses), @authorize +(3), @rate_limit (3), @validate (2). The cause was not that the branches were +untidy. A construct had nothing to BE, so its meaning had nowhere to live +except the emitter, and every construct was therefore a compiler edit. + +A name -> injection table would have moved the enumeration twenty lines up +without removing it. So the construct now carries its own meaning: + + @decorator("injects_at_entry", "engram_boundary_beat") + fn audited() {} + + @audited + fn risky_op() -> Int { ... } // gets the beat, attributed to "audited" + +scan_declared_decorators is a token-level pre-pass beside scan_routes, forced +by streaming codegen having no whole-program AST. manager and accessor are +seeded as the compiled-in core — the fixedSelf shape from substrate.go: a +complete fallback exists, declaration is enrichment. + +This is the injection half of the seam only. The prohibition half (@manager's +#error on dharma_emit) stays hardcoded, because "which calls may appear inside +this boundary" is a query over program structure and there is nothing yet to +ask. + +Verified three ways: emitted C for existing @manager/@accessor code is +byte-identical to the hardcoded path; a construct with a name the compiler has +never heard of injects correctly; the compiler self-hosts byte-identically. +90/90 native compiler tests pass. +``` diff --git a/docs/v1/experiments/cycles/02-a-construct-cannot-refuse.md b/docs/v1/experiments/cycles/02-a-construct-cannot-refuse.md new file mode 100644 index 0000000..f08cc0e --- /dev/null +++ b/docs/v1/experiments/cycles/02-a-construct-cannot-refuse.md @@ -0,0 +1,43 @@ +# a construct cannot refuse + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `60737b0` + +``` +let a construct refuse, not only observe + +@authenticate (6 uses), @authorize (3), @rate_limit (3) and @validate (2) +parsed, attached, and compiled to nothing. Fourteen applications that read as +protection and emitted no instruction — a function decorated @authenticate +compiled byte-identically to an undecorated one. + +The missing capability was not authentication. It was that a construct could +observe a boundary but never refuse one. injects_at_entry discards the target's +result; there was no form in which a construct could say no. + + @decorator("guards_at_entry", "my_auth") + fn authenticate() {} + + @authenticate + @authorize + fn handler() -> String { ... } + +emits, at entry: + + { el_val_t __g = my_auth(EL_STR("handler"), EL_STR("authenticate")); if (__g) return __g; } + { el_val_t __g = my_roles(EL_STR("handler"), EL_STR("authorize")); if (__g) return __g; } + +Guards precede injections because a refused call must not report a crossing, +and every guard runs where the topmost injecting construct wins — refusal is +not a role, so it does not follow the role convention. + +The compiler still knows nothing about auth. The program points the construct +at its own function, which is where that decision belongs. + +Verified: existing @manager/@accessor output byte-identical, compiler +self-hosts byte-identically, guards stack in declaration order and emit before +the beat. 94/94 native compiler tests pass. +``` diff --git a/docs/v1/experiments/cycles/03-the-wrapper-was-conditional.md b/docs/v1/experiments/cycles/03-the-wrapper-was-conditional.md new file mode 100644 index 0000000..862388b --- /dev/null +++ b/docs/v1/experiments/cycles/03-the-wrapper-was-conditional.md @@ -0,0 +1,82 @@ +# the wrapper was conditional + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `4f7568b` + +``` +give a construct its after-crossing face, and let constructs compose + +§6 records 62 persist-after-mutate sites, 10 auth-per-route, and +index-after-append that failed at 9 of 9 — every one an obligation at a +crossing that decayed into "remember to do this afterwards." An obligation a +human must remember is not an obligation, and the 9-of-9 figure is what that +costs. + + @decorator("injects_at_exit", "persist_now") + fn durable() {} + +The body moves into a static helper and the visible fn becomes a wrapper, so +EARLY RETURNS pass through the exit injection. Emitting it only before the +fall-through return would have silently missed every early return — the exact +failure class this seam exists to remove. Fns with no exit construct emit +byte-identically to before. + +Three independent constructs now compose on one fn, none known to the compiler: + + el_val_t mutate(el_val_t k) { + { el_val_t __g = my_auth(EL_STR("mutate"), EL_STR("authenticate")); if (__g) return __g; } + engram_boundary_beat(EL_STR("mutate"), EL_STR("manager")); + el_val_t __r = __el_body_mutate(k); + persist_now(EL_STR("mutate"), EL_STR("durable"), __r); + return __r; + } + +Guard, then entry, then body, then exit. §5.2 asked whether `hold` is one +construct or two; the implementation answers one construct with two faces, +selected by declared kind rather than by two mechanisms. + +Verified: existing output byte-identical, compiler self-hosts byte-identically, +early returns pass through the exit, ordering holds under composition. 98/98 +native compiler tests pass. +``` + +## Record — `285166c` + +``` +EXPERIMENT: emit the wrapper unconditionally, so exit binds at runtime too + +ISHIKAWA: why did exit injection still need compile-time knowledge? Because the +body-helper wrapper was only emitted when codegen already knew an exit +construct existed. The wrapper being conditional was the cause, not the wrapper +being necessary. + +PREDICTIONS AND RESULTS + P1 exit becomes runtime-bindable TRUE returns 14, bound + after the build + P2 codegen shrinks TRUE 5094 -> 5044 + P3 cost 5-15% from a call frame on every fn FALSE 0.37s -> 0.38s, ~3% + P4 fixpoint holds TRUE + +Every fn now gets a body helper and a wrapper. It has to be unconditional: +early returns must route through something for an exit construct to observe +them, and codegen cannot know which fns will be bound after the binary exists. + +Removed with the machinery: declare_exit, decorator_exit, cg_exit_target, +cg_exit_construct, and the injects_at_exit scanner branch. + +Two controls failed and were rewritten rather than repaired -- +no-exit-construct-emits-no-wrapper asserted the optimisation this removes, so +it is now inverted. The integration harness gained a seventh assertion: an exit +construct declared after the build replaces the result. + +99/99 native, 7/7 integration, fixpoint gen2==gen3. +``` + +## Record — `b40754f` + +``` +land unconditional wrapper: exit crossings resolve at runtime +``` diff --git a/docs/v1/experiments/cycles/04-c-has-no-closure-syntax.md b/docs/v1/experiments/cycles/04-c-has-no-closure-syntax.md new file mode 100644 index 0000000..b72aff4 --- /dev/null +++ b/docs/v1/experiments/cycles/04-c-has-no-closure-syntax.md @@ -0,0 +1,64 @@ +# c has no closure syntax + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `2bed848` + +``` +EXPERIMENT: hand the construct the body as a real closure + +ROOT CAUSE of the weaker design: "C has no closures" was taken as a fact about +what is possible. It is a fact about one grammar. Every C++ lambda, every Go +closure, every Rust closure compiles to a struct of captured values plus a +function pointer -- which is what is emitted here. Codegen emits C; it is not +written in C's syntax, and the distinction is the whole difference between a +construct that can only decide whether to repeat and one that controls +invocation. + +It would also have crippled the JS backend, which has closures natively, for a +limit that applies only to the C one. + +PREDICTIONS AND RESULTS + 1 env struct + thunk taking void* TRUE + 2 fails to compile: struct redefinition FALSE -- C allows the + inner declaration to shadow. Prediction wrong; C is more permissive than + assumed. A different real defect surfaced instead: a wrap with no exit + construct emitted `(EL_STR("f"), EL_STR(""), __r);` -- a call to an empty + target -- because has_exit was reused as "needs a wrapper" and the exit line + was emitted unconditionally. Fixed. + 3 compiles when the target is declared in El FALSE -- and this is + the root cause worth keeping: El has ONE type, el_val_t = int64_t. El's type + system cannot describe a callable, so `extern fn` and the real signature + cannot be made to agree in El's own vocabulary. The fix is not a cast: + codegen DEFINES the wrap calling convention, so codegen emits the extern + declaration. The convention is not El-expressible; it is emitted. + 4 target controls invocation, 0..N times TRUE + 5 existing @manager output byte-identical TRUE + 6 compiler fixpoint holds TRUE + 7 emitting the convention makes it compile TRUE + +MEASURED + base(5) wrapped by a target that invokes the body twice and sums -> 10 + never_runs(5) wrapped by a target that never invokes it -> 999 + +Neither is expressible by "decide whether to repeat". This supersedes the +repeats_body experiment on experiment/repeats-body, which was built around the +mistaken limit. +``` + +## Record — `7d01608` + +``` +land wraps_body: a construct controls invocation + +Proven on experiment/wraps-body (2bed848): base(5) wrapped by a target that +invokes the body twice returns 10; a target that never invokes it returns 999. +Neither is expressible by deciding whether to repeat. + +Root cause it corrected: 'C has no closures' is a fact about one grammar, not +about what can be emitted. And El's single type (el_val_t = int64_t) cannot +describe a callable, so codegen emits the calling convention rather than asking +El's type system for something it structurally cannot say. +``` diff --git a/docs/v1/experiments/cycles/05-the-emitter-discards-what-it-knows.md b/docs/v1/experiments/cycles/05-the-emitter-discards-what-it-knows.md new file mode 100644 index 0000000..98caa34 --- /dev/null +++ b/docs/v1/experiments/cycles/05-the-emitter-discards-what-it-knows.md @@ -0,0 +1,48 @@ +# the emitter discards what it knows + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `a5af871` + +``` +EXPERIMENT: let the compiler write down what it already knows + +HYPOTHESIS: attribution is redundant for static structure. Codegen sees every +construct-to-function relation at emission time and discards it, so the only +way to learn the structure back is to run the program and read what it +reported. That is instrumentation compensating for erasure. + +PREDICTIONS, committed before running: + 1 derivable at compile time with no runtime call expected TRUE + 2 complete for guards and exits (invisible today) expected TRUE + 3 answers it for code that has never executed expected TRUE + 4 deterministic expected TRUE + 5 makes the entry beat redundant expected FALSE + +RESULT: 5/5 as predicted. From a program that was never executed: + + authenticate guards_at_entry login my_auth + durable injects_at_exit save persist_now + authenticate guards_at_entry critical my_auth + durable injects_at_exit critical persist_now + manager injects_at_entry critical engram_boundary_beat + +Prediction 5 held: the relation records that a boundary COULD be crossed, the +beat records that it WAS. They are different facts and neither replaces the +other. + +CONSEQUENCE, and it undercuts the first pass on iteration-1: construct identity +was available at compile time all along. With relations recorded at build, the +runtime needs only the function name and attribution becomes a join rather than +a payload. The counter-argument is that the payload is self-describing while +the file must be pinned to the artifact or the two drift and attribution is +silently lost — which is the same conclusion as "compile against a manifold +revision and record the revision in the artifact", reached from the other side. + +Written to a file rather than the engram on purpose: a compile that consults a +manifold produces different output from identical source at different times. +The file is content-addressed; the engram ingests it. Determinism preserved, +mechanism proven. +``` diff --git a/docs/v1/experiments/cycles/06-the-crossing-resolves-at-emission.md b/docs/v1/experiments/cycles/06-the-crossing-resolves-at-emission.md new file mode 100644 index 0000000..a10ae11 --- /dev/null +++ b/docs/v1/experiments/cycles/06-the-crossing-resolves-at-emission.md @@ -0,0 +1,170 @@ +# the crossing resolves at emission + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `35b07ba` + +``` +EXPERIMENT: resolve the crossing at execution, not at emission + +HYPOTHESIS (Will's): a compiler whose one compiled mechanism is extending the +LANGUAGE — not the compiler — can compose without recompilation. + +ISHIKAWA — why does a construct require a recompile today? + method codegen inlines the target call into the body + machine the binary has no table to consult + material the declaration lives in source, read at compile time + measurement nothing observes what applied at runtime + root cause the crossing is resolved at EMISSION, not at EXECUTION + +CHANGE: codegen emits one unconditional indirection per fn. Which constructs +apply is read from a table that can be written AFTER the binary exists; +targets resolve through dlsym against the running image. + +PREDICTIONS AND RESULTS + P1 a construct declared after the build applies TRUE + P2 an unlinked target is skipped, not fatal TRUE + P3 emitting on every fn is measurably slower FALSE — 0.37s -> 0.36s + with 267 indirections and + no bindings. Free unused. + P4 the compiler still self-hosts TRUE (see note) + +DEMONSTRATED: an El program with NO decorator in its source, already compiled +and linked, picked up a construct declared afterwards: + + $ /tmp/seamrun -> 7 + $ echo 'work audited entry audit_entry' > constructs.txt + $ EL_CONSTRUCTS=constructs.txt /tmp/seamrun + AUDIT: work applied by audited + 7 + +P4 note: my first fixpoint test was wrong, not the code. I compared gen1 to +gen2, which must differ whenever codegen's output changes. gen2 == gen3, 267 +seam sites, stable. + +MEASURED COST, and the root cause was not where I looked + 0 bindings 0.36s vs 0.37s baseline free + 2 bindings, dlsym per call 2.45s 6.6x + 2 bindings, resolved once 0.69s 3.5x recovered +The table scan was never the cost. dlsym walks the dynamic symbol table on +every call. Resolve once and cache — which is the smallest form of what +salience does for memory: what is hot stays resolved. The 0.69s residual is +audit_entry's own printf on two of the compiler's hottest functions, not seam +overhead. + +CONSEQUENCE: the five compile-time declaration kinds on iteration-1 are a +compile-time specialisation of something that resolves at runtime. They are not +wrong, but they are not the mechanism — the mechanism is one indirection, and a +kind is data. +``` + +## Record — `886626a` + +``` +seam refusal + control tests: a runtime binding can short-circuit + +Prediction 3 was FALSE. I expected refusal to be impossible through the seam +because the entry indirection discarded its return. One line: + + { el_val_t __s = el_seam_run(EL_STR(f), 0, 0); if (__s) return __s; } + +work() returns 7; bound to a refusing construct AFTER the build it returns 42. +So three of the five compile-time kinds are runtime-bindable: entry injection, +exit injection, and refusal. wraps_body needs invocation control and +prohibits_outside is compile-time by nature. + +104/104 native compiler tests pass. +``` + +## Record — `28d19da` + +``` +strip the compile-time machinery the seam replaces + +PREDICTION: codegen.el drops below 4661, its size before any of these passes. +RESULT: FALSE. 5157 -> 5096. Still +435 over baseline. + + injects_at_entry collapsed into the seam removed + guards_at_entry collapsed into the seam removed + injects_at_exit needs the body-helper wrapper STRUCTURAL + wraps_body needs the closure + wrapper structural + prohibits_outside a #error cannot be emitted at runtime + +The wrapper is not a consequence of compile-time resolution. Early returns must +be routed through something no matter when the target is resolved, so exit +injection was never going to collapse. I predicted it would because I had +conflated "resolved late" with "emitted less". + +What did collapse is entry injection and refusal -- 61 lines of compiler +replaced by one refusable indirection, with the capability now bindable after +the binary exists. + +8 tests fail, and they are exactly the 8 controls for compile-time entry +injection and guards. No unrelated breakage: the controls reported precisely +what moved. They assert emission of something that now happens at runtime, so +they need rewriting as integration tests -- which the framework does not +currently support, because runtime binding needs a built binary and an +environment, not compile_capture. + +Verified after the strip: fixpoint gen2==gen3, observation and refusal both +work through the seam with the compiler knowing nothing about either. +``` + +## Record — `8bbb750` + +``` +control the claim that cannot be unit tested + +The seam's whole claim is that a construct declared AFTER a binary exists +applies to that already-built program. compile_capture only sees emitted text, +so it structurally cannot check this: it needs a built binary, a linked target, +and an environment. Verified by hand until now, which is the standing problem +this session has been about. + +tests/integration/seam_binding.sh builds a probe from El source containing no +construct at all, links a target that El never references, and asserts: + + ok unbound program is unaffected + ok a construct declared AFTER the build applies + ok a construct declared after the build can REFUSE + ok an unlinked target is skipped, not fatal + ok a binding for a different fn does not fire + ok two constructs compose on one crossing + + 6 assertions, 6 passed, 0 failed + +The eight controls that failed after the strip were replaced, not repaired. +They asserted compile-time emission of capability that moved to runtime; +contorting them would have kept an assertion whose subject no longer exists. +Three took their place, asserting the emitted shape, and the behaviour they +used to cover is now the integration harness's job -- which is the honest +division, since the shape and the behaviour are no longer the same fact. + +99/99 native compiler tests pass. Fixpoint holds. +``` + +## Record — `24f7fb5` + +``` +land the runtime seam: resolve the crossing at execution + +Five compile-time passes added 491 lines to the thing that was supposed to stop +growing. The seam is ~55 lines of C and one line of emission, and it does at +runtime what three of those five kinds did at compile time -- for programs that +are already built. + + a construct declared AFTER the binary exists applies to it + free when unused: 0.36s vs 0.37s baseline across 267 indirections + dlsym was the cost, not the table scan; resolve-once recovered 3.5x + refusal works, composition works, unlinked targets are skipped not fatal + +injects_at_exit and wraps_body do NOT collapse: early returns must route +through the body-helper wrapper regardless of when the target is resolved. The +wrapper is structural, which I had wrong. prohibits_outside cannot move at all +-- a #error has no runtime. + +Controls: 99/99 native compiler tests, plus tests/integration/seam_binding.sh +(6/6) for the claim compile_capture structurally cannot see. +``` diff --git a/docs/v1/experiments/cycles/07-invocation-is-not-composable.md b/docs/v1/experiments/cycles/07-invocation-is-not-composable.md new file mode 100644 index 0000000..49ad88a --- /dev/null +++ b/docs/v1/experiments/cycles/07-invocation-is-not-composable.md @@ -0,0 +1,45 @@ +# invocation is not composable + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `bc2f26d` + +``` +EXPERIMENT: invocation control resolves at runtime + +ISHIKAWA: why did wraps_body need compile-time knowledge? Because the wrapper +called the target directly. If the wrapper calls through the seam instead, the +seam can call the body itself, and a construct bound after the build decides +how and whether to invoke it. + +PREDICTIONS AND RESULTS + P1 wrap becomes runtime-bindable TRUE body x3 -> 21, + never invoked -> 111 + P2 codegen shrinks TRUE 5042 -> 4977 + P3 cost 5-10% from an indirect call on every fn TRUE 0.36s -> 0.39s, ~8% + P4 zero-param fns break on the empty struct TRUE empty struct is a GNU + extension, empty init + is C23. Fixed with a + char field. + P5 fixpoint holds TRUE + +PROCESS FAILURE worth recording: my first patch silently did not apply because +I dropped the assert on the string replacement. The build then failed with +"undeclared identifier __thunk_noargs", which I nearly attributed to the +empty-struct prediction. The guard that would have caught it existed and I +removed it -- the same shape as every other defect found tonight. + +Removed: declare_wrap, decorator_wrap, cg_wrap_target, cg_wrap_construct, +params_to_call_args, and the wraps_body scanner branch. + +prohibits_outside is now the ONLY construct kind left at compile time, and it +cannot move: a #error has no runtime. +``` + +## Record — `c04d68f` + +``` +land runtime invocation control: only prohibition remains compiled +``` diff --git a/docs/v1/experiments/cycles/08-the-emitter-adjudicates.md b/docs/v1/experiments/cycles/08-the-emitter-adjudicates.md new file mode 100644 index 0000000..08edf7c --- /dev/null +++ b/docs/v1/experiments/cycles/08-the-emitter-adjudicates.md @@ -0,0 +1,53 @@ +# the emitter adjudicates + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `c741cfe` + +``` +EXPERIMENT: prohibition becomes a query over emitted relations + +I said prohibition could not move because "a #error has no runtime". That +conflated two separable things: WHEN a violation is detected (build time -- +correct, and unchanged) and WHERE the rule and the checker live (the compiler +-- assumed). + +A prohibition is a containment relation over the call graph. So codegen now +records what it saw: + + sneaky calls raw_sql + allowed calls raw_sql + allowed calls @repository + repository calls prohibits:raw_sql + +and tools/check/prohibitions.sh decides, at build time, outside the compiler. + +PREDICTIONS AND RESULTS + P1 codegen can emit the call graph it already walks TRUE + P2 the check becomes a query outside the compiler TRUE + P3 all prohibition decisions leave codegen TRUE zero #errors now + P4 violations still caught at build time TRUE exit=1 + P5 codegen drops below the 4661 baseline FALSE 4962, +301 + +P5 is the finding. The TRAVERSAL is irreducible -- you must walk the AST to +find calls, and those ~120 lines do not move no matter who decides. What is not +irreducible is the rule (which names) or the decision (#error). Those left. I +predicted the whole 223 lines would go because I had not separated walking from +adjudicating. + +Still compiled, and measured rather than assumed: the capability-tier system +(cap_check_call, is_self_formation_call, is_dharma_call, is_llm_call, +cap_record_violation, emit_cap_violations) is 76 lines of the same shape -- +prohibits_WITHIN rather than prohibits_outside, so the checker needs the +opposite polarity to absorb it. + +98/98 native, 4/4 prohibition_query.sh, 7/7 seam_binding.sh, fixpoint ok. +``` + +## Record — `60c07ad` + +``` +land prohibition-as-query: the emitter records, it no longer adjudicates +``` diff --git a/docs/v1/experiments/cycles/09-policy-inside-the-compiler.md b/docs/v1/experiments/cycles/09-policy-inside-the-compiler.md new file mode 100644 index 0000000..cc121ab --- /dev/null +++ b/docs/v1/experiments/cycles/09-policy-inside-the-compiler.md @@ -0,0 +1,49 @@ +# policy inside the compiler + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `c2d9596` + +``` +EXPERIMENT: the capability tier becomes shipped policy plus a query + +Capability differs from prohibits_outside in one way that matters: a utility +program cannot be trusted to declare its own restrictions, because it would +declare none. So the policy comes from OUTSIDE the program -- it ships with the +language as data, editable without a compiler release. + + tools/check/capabilities.rel 18 names that were string literals in codegen + tools/check/capabilities.sh the query that decides + +PREDICTIONS AND RESULTS + P1 codegen emits kind + call graph, drops the 4 name tests TRUE zero #errors + P2 the 18 literals become a data file TRUE + P3 the checker catches capability violations TRUE exit=1 + P4 codegen drops ~76 lines TRUE 4963 -> 4881 + P5 below the 4661 baseline FALSE ~+230 + +TWO DEFECTS THE HARNESS FOUND THAT READING WOULD NOT HAVE + +1. Calls inside main became invisible. cg_fn returns early for main -- C + provides its own -- so hooking the recording there left every call in main + unrecorded: a blind spot exactly where a program does its work. The old + cap_check_call ran from cg_expr and did see main. Moved the recording to + cg_expr. + +2. Caller attribution was stale. __cg_current_fn kept whatever cg_fn set last, + so a violation in main was reported against the previously emitted function. + The test still PASSED, because the violation was detected -- only the name + was wrong, and a diagnostic naming the wrong fn is worse than none. Fixed at + all three main-emission sites; the first patch missed two because the live + path is codegen_streaming. + +98/98 native, 7/7 + 4/4 + 5/5 integration, fixpoint ok. +``` + +## Record — `29f78f9` + +``` +land capability-as-policy: eighteen literals become a data file +``` diff --git a/docs/v1/experiments/cycles/10-a-second-copy-of-the-header.md b/docs/v1/experiments/cycles/10-a-second-copy-of-the-header.md new file mode 100644 index 0000000..037407f --- /dev/null +++ b/docs/v1/experiments/cycles/10-a-second-copy-of-the-header.md @@ -0,0 +1,54 @@ +# a second copy of the header + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `9cc6040` + +``` +EXPERIMENT: derive arity from the runtime's own declarations + +codegen.el carried builtin_arity(): 344 lines, 300 entries, a hand-maintained +second copy of el_runtime.h. + +PREDICTIONS AND RESULTS + P1 the table duplicates the header TRUE 243 shared names + P2 they have already drifted FALSE ZERO drift. The + duplicate had been + maintained correctly. + P3 codegen can emit call-arity relations TRUE + P4 the check becomes a query against the header TRUE + P5 codegen drops to roughly baseline TRUE 4903 -> 4512, + 149 BELOW the 4661 + it started at + +P2 being false is the better result: the table was not WRONG, it was +INCOMPLETE. 110 functions the runtime declares had no entry, so calling them +with the wrong argument count produced no El-level diagnostic at all. Measured: +the old compiler reports 0 arity errors for __http_do_map_to_file(1); the query +reports "takes 5 arguments, called with 1". + +Deriving from the header fixes coverage AND makes drift impossible by +construction. 503 signatures, versus 300 entries maintained by hand. + +THREE DEFECTS IN MY OWN CHECKER, each found by running it rather than reading it + 1. El names and C names differ -- `println` is `__println`. 60 of 500 decls + carry the prefix and codegen owns the mapping; the old table carried both + keys. One rule covers all 60. + 2. Multi-line declarations parsed as zero params, so the checker reported + "takes 0" for a function taking 5. A diagnostic with the wrong number in it + is worse than none -- the same shape as the stale caller attribution in the + previous pass. + 3. Fixing (2) by joining lines dropped 500 signatures to 334, because a + declaration preceded by a comment no longer started its record. Comments + are stripped first now. + +98/98 native, 5/5 arity_query.sh, fixpoint ok. +``` + +## Record — `d9e301b` + +``` +land arity-from-header: the runtime declares its own surface +``` diff --git a/docs/v1/experiments/cycles/11-one-type-erases-the-return.md b/docs/v1/experiments/cycles/11-one-type-erases-the-return.md new file mode 100644 index 0000000..02ae35b --- /dev/null +++ b/docs/v1/experiments/cycles/11-one-type-erases-the-return.md @@ -0,0 +1,52 @@ +# one type erases the return + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `d2d89fc` + +``` +EXPERIMENT: temporal types as data — and the pass that GREW the compiler + +This block is structurally unlike the previous four. It does not only +adjudicate, it DISPATCHES: Instant + Duration must become el_instant_add_dur, +LocalDate + Duration must become el_local_date_add_dur. The emitted C depends on +the type answer, so it cannot move to a post-hoc query. Selecting which call to +emit is an emitter's actual job. + +PREDICTIONS AND RESULTS + P1 the block conflates dispatch with adjudication TRUE + P2 adjudication can move, dispatch cannot TRUE + P3 this pass shrinks codegen far less than the last TRUE, and worse: + 4513 -> 4537, it GREW + by 24 lines + P4 the rules are affine algebra, closed by construction TRUE + P5 no type propagation -- name tracking plus a + hardcoded list of which builtins return which type TRUE, 19 names + +P3 is the honest result and it is not spun: moving 19 names into a data file +cost more lines than it saved, because a generic loader is larger than the +enumeration it replaces. The win is not line count. It is that adding a 20th +temporal builtin is now a one-line edit to signatures.rel instead of a compiler +change, and that the data is inspectable. + +WHY THE HEADER CANNOT SUPPLY THIS, unlike arity: el_runtime.h declares every +builtin as returning el_val_t, because El has ONE type. That single type is why +the whole seam is cheap and it is exactly why the C boundary cannot say that +now() returns an Instant while unix_seconds() returns an Int. The El-level type +is real and the boundary erases it. + +INCOMPLETE, and stated rather than hidden: P2 said adjudication could move to a +query. It has NOT. Violations still emit TIME_TYPE_ERROR inline from the +emitter. Only the type DATA moved. Moving the adjudication needs the operand +types recorded as relations, which is a further pass. + +98/98 native, 4/4 temporal_signatures.sh, fixpoint ok. +``` + +## Record — `e01e079` + +``` +land temporal signatures as data: the type table leaves, the dispatch stays +``` diff --git a/docs/v1/experiments/cycles/12-judgment-lives-with-knowledge.md b/docs/v1/experiments/cycles/12-judgment-lives-with-knowledge.md new file mode 100644 index 0000000..409dc16 --- /dev/null +++ b/docs/v1/experiments/cycles/12-judgment-lives-with-knowledge.md @@ -0,0 +1,47 @@ +# judgment lives with knowledge + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `e8e25a0` + +``` +EXPERIMENT: temporal adjudication moves out; the placeholder stays + +The previous pass moved the type DATA and left the judgment inline, which I +stated rather than hid. This finishes it. + +PREDICTIONS AND RESULTS + P1 codegen can emit operand-type relations TRUE + "main calls temporal:instant_plus_instant" + P2 the affine rules are a small closed set as data TRUE 6 rules + P3 violations still caught at build time TRUE exit=1 + P4 the reporter leaves codegen TRUE 4538 -> 4507 + P5 the TIME_TYPE_ERROR placeholder must STAY TRUE + +P5 is the boundary of this whole approach. The emitter has to emit SOMETHING +for an illegal expression -- it cannot emit nothing and it cannot decide what +the program meant. So the placeholder is irreducible in the same way the AST +traversal was: what moved is the judgment and the wording, not the fact that +something must be written. + +The rules are affine algebra and the set is closed because there are only two +kinds of thing. An Instant is a POINT, a Duration is a DISPLACEMENT: add a +displacement to a point, subtract two points for a displacement, combine +displacements. Nothing else is meaningful, which is why the enumeration in +temporal.rel cannot grow the way an allowlist does. + +A defect in my own checker, found by running it: the .rel file uses aligned +columns and my awk assumed a single space, so the message came out with the +rule key still prefixed. Same class as the multi-line header parse in the arity +pass -- formatting assumptions that only fail when you look at the output. + +98/98 native, 6/6 temporal_query.sh, fixpoint ok. +``` + +## Record — `50425f3` + +``` +land temporal adjudication as a query: the emitter records, the rules are data +``` diff --git a/docs/v1/experiments/cycles/13-thirty-five-return-types.md b/docs/v1/experiments/cycles/13-thirty-five-return-types.md new file mode 100644 index 0000000..3b2669f --- /dev/null +++ b/docs/v1/experiments/cycles/13-thirty-five-return-types.md @@ -0,0 +1,49 @@ +# thirty five return types + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `cbef1c1` + +``` +EXPERIMENT: Int return types as data — and the bug that fell out + +PREDICTIONS AND RESULTS + P1 is_int_call's 35 hardcoded names move to data TRUE + P2 is_int_name stays -- it is annotation propagation TRUE + P3 the dispatch stays -- it is emission TRUE + P4 codegen shrinks ~40 lines TRUE 4507 -> 4469 + P5 the design doc's characterisation is WRONG TRUE + P6 the moved data also fixes the bug it exposed TRUE + +P5 CORRECTS THE RECORD. el-language-design.md and geometry-vs-code.md both cite +"== lowering to str_eq unless both operand names are in a hardcoded int-name +set -- a literal list of variable names treated as integers" as the paradigm +defect. It is not one. __int_names is populated from TYPE ANNOTATIONS +(param["type"] == "Int"), which is primitive but legitimate type propagation. +The actual defect was is_int_call: 35 hardcoded builtin return types, the same +shape as the temporal 19. + +P6 IS A LIVE CORRECTNESS BUG, PRE-EXISTING, NOW FIXED + + let a = str_len("hello") // no annotation + let b = str_len("hi") + let c = a + b // -> el_str_concat(a, b) on two integers + +Verified identical on the pre-change compiler, so not a regression. It compiled +clean, ran, and printed NOTHING where it should print 7. No error at any layer. + +The repair is three lines: an unannotated let takes its type from what the +initialiser returns. The return types were already required for dispatch and +were simply never consulted at the binding site. Moving them into data is what +made the gap visible -- reading the code for eight hours did not. + +98/98 native + 2 new, 31/31 integration, fixpoint ok. +``` + +## Record — `505e5e7` + +``` +land int signatures, and repair a silent miscompilation they exposed +``` diff --git a/docs/v1/experiments/cycles/14-keywords-that-reserve-nothing.md b/docs/v1/experiments/cycles/14-keywords-that-reserve-nothing.md new file mode 100644 index 0000000..3d06af4 --- /dev/null +++ b/docs/v1/experiments/cycles/14-keywords-that-reserve-nothing.md @@ -0,0 +1,57 @@ +# keywords that reserve nothing + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `0143cc4` + +``` +ANSWER: is a grammar a convention, or a region? + +Both, at different layers -- and it is the same split as serialization: the +convention is the BASIS, never the ACT. + + lexeme -> token `fn` means function-start because someone said so CONVENTION + shape recognition given tokens, which construct is this REGION + source -> structure parsing is transduction onto that basis GEOMETRY + byte traversal something must read them in order IRREDUCIBLE + +Three things push the ACT toward region rather than convention: ambiguity +(a * b needs context; a grammar resolves it with the lexer hack, a region by +neighbourhood), error recovery (nearest-region is free), and precedence, which +is ordering along an axis with a conventional parameter. + +AND THE SHOULD GATE SAYS NO TO THE OBVIOUS MOVE + +Every other table this session moved to data. This one stays code. The keyword +set is CLOSED by the language definition -- it does not leak the way an +allowlist does -- and the lexer runs before the program is understood, so a +program can never declare its own keywords. Externalising it costs file I/O on +every compile and buys nothing. Same verdict as is_digit in ASCII. + +WHAT WAS ACTUALLY WRONG: five of 46 keywords were consumed by no parser or +codegen path. sealed, activate, seed, protocol, impl. Each stole an identifier +from users for nothing. + +SECOND SILENT MISCOMPILATION OF THE DAY. Using one did not fail to parse: + + let seed = 42 + let impl = seed + 1 + +compiled CLEAN -- zero cc errors -- and printed 0 instead of 44. No diagnostic +at any layer. Fixed by removing the five. + +A DEFECT IN MY OWN MEASUREMENT, caught before it did damage: my first pass +checked only parser.el and reported `test` as inert too. codegen consumes it at +4135 for --test mode, and the tree has 408 uses. Removing it would have broken +every test in the suite. The measurement was re-run across all four consumers. + +100/100 native + 2 new, 31/31 integration, fixpoint ok. +``` + +## Record — `067dd40` + +``` +answer the parsing question: a grammar is a basis, and five keywords reserved nothing +``` diff --git a/docs/v1/experiments/cycles/15-no-namespacing-at-all.md b/docs/v1/experiments/cycles/15-no-namespacing-at-all.md new file mode 100644 index 0000000..0923142 --- /dev/null +++ b/docs/v1/experiments/cycles/15-no-namespacing-at-all.md @@ -0,0 +1,53 @@ +# no namespacing at all + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `79f6cb7` + +``` +ANSWER: if the partition is a neighbourhood, does linking survive? + +The question is premature, and measuring says why. El's partition is a +FILESYSTEM PATH, not a neighbourhood, and there is no namespacing at all. + +MEASURED + import is textual inlining (resolve_imports), guarded against double + inclusion by a __elc_imp__: state key + when a .elh header exists the header is inlined instead and the .el is marked + seen, so symbols resolve at C link time -- so linking IS real, delegated to C + two modules defining `helper` emit two C functions into one translation unit + +So linking barely survives the PATH partition. Whether it survives a +neighbourhood partition cannot be asked yet. + +A DIAGNOSTIC REGRESSION I CAUSED, found by asking this question. cc does catch +the collision, but reports: + + error: redefinition of '__el_body_helper' + error: redefinition of '__env_helper' + error: redefinition of '__thunk_helper' + error: redefinition of 'helper' + +The user's own function is FOURTH. The first three are generated symbols +introduced by the unconditional-wrapper pass earlier today -- before it, there +was one clear message. Repaired by catching the collision at El level instead: + + duplicate definition: 'helper' is defined 2 times — El has no namespacing, + so imported modules share one global scope + +LIMIT, stated rather than hidden: textual inlining destroys file provenance. By +the time codegen runs there is one source string, so the message can say WHICH +name collides but not which files. Naming a.el and b.el needs provenance +threaded through resolve_imports. + +104/104 native, 4/4 definitions_query.sh, the compiler itself reports clean, +fixpoint ok. +``` + +## Record — `f23cb2b` + +``` +answer the module question: the partition is a path, and there is no namespacing +``` diff --git a/docs/v1/experiments/cycles/16-tokens-carry-no-position.md b/docs/v1/experiments/cycles/16-tokens-carry-no-position.md new file mode 100644 index 0000000..a1e18c4 --- /dev/null +++ b/docs/v1/experiments/cycles/16-tokens-carry-no-position.md @@ -0,0 +1,64 @@ +# tokens carry no position + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `6c975b1` + +``` +thread provenance through resolve_imports + +The module question ended with a limit: textual inlining destroys file +provenance, so a duplicate-definition message could name the symbol but not the +files. Threading it exposed a bigger absence first. + +TOKENS HAD NO POSITION AT ALL. A token was a flat (kind, value) pair, so NO +diagnostic in El could name a place -- every error named a symbol and never a +line. That is the prerequisite the module question was resting on. + +THE CHAIN, end to end + lexer counts newlines; tok_append mints (kind, value, line) + parser stride 2 -> 3; tok_line added; FnDef carries its line + codegen records defines_at: + resolve_imports publishes spans for the combined source + checker maps a combined line back to file:line-within-that-file + + duplicate definition: 'helper' is defined 2 times — El has no namespacing, + so imported modules share one global scope + /tmp/modtest/a.el:1 + /tmp/modtest/b.el:1 + +PREDICTIONS AND RESULTS + P1 15 stride sites, encapsulated in tok_kind/tok_value TRUE, but see below + P2 adding a line field is mechanical TRUE + P3 the lexer must count newlines TRUE + P4 resolve_imports can record per-file line ranges TRUE + P5 the message can then name both files TRUE + P6 token memory grows TRUE, 25.0 -> 33.9 MB (+36%) + +FOUR DEFECTS, EACH FOUND BY RUNNING AND NOT BY READING + +1. interp_tokens_append_all walks the token list DIRECTLY with its own copy of + the stride. Gen1 built fine and gen2 emitted corrupt C, because the + compiler's own source uses string interpolation. My search missed it because + I grepped for the variable name `tokens`; it is called `dst`/`result`. + Searching by name instead of by shape -- third time today. +2. tok_count in test_compiler.el carried the stride too. I had scoped the search + to compiler sources and it had escaped into the tests. +3. Nested resolve_imports calls accumulated spans into shared state, so each + republished meaningless line ranges under the parent's name. Making the + buffer local fixed it; guarding the WRITE did not, which is what I tried + first. +4. The first working version reported b.el:3 -- the COMBINED line against a + filename that has no line 3. A file:line that does not match the file is + worse than no line at all. + +105/105 native, 37/37 integration, fixpoint ok, compiler self-checks clean. +``` + +## Record — `cb7289f` + +``` +thread provenance end to end: a diagnostic can finally name a place +``` diff --git a/docs/v1/experiments/cycles/17-annotations-are-never-checked.md b/docs/v1/experiments/cycles/17-annotations-are-never-checked.md new file mode 100644 index 0000000..c60f03d --- /dev/null +++ b/docs/v1/experiments/cycles/17-annotations-are-never-checked.md @@ -0,0 +1,53 @@ +# annotations are never checked + +**Status: verified on `experiment/annotation-checking`, not merged.** + +## Ishikawa — why does El silently miscompile? + +Three bugs found the same day shared one shape. + +``` +method type tracked by per-function name sets, fed from annotations +machine el_val_t erases everything at the C boundary +material no propagation through expressions +measurement nothing verifies an annotation against what it annotates +───────────────────────────────────────────────────────────────────────── +root cause El has type ANNOTATIONS but no type CHECKING. The annotation + feeds dispatch and is never itself verified. +``` + +## Predictions + +``` +P1 let x: Int = "hello" compiles clean expect TRUE +P2 let s: String = 42 compiles clean expect TRUE +P3 the annotation drives dispatch, unverified expect TRUE +P4 same root cause as all three bugs found today expect TRUE +P5 checking literal-vs-annotation catches both expect TRUE +P6 zero false positives across the compiler's source expect TRUE +``` + +## Results — 6/6, and worse than a wrong answer + +``` +let x: Int = "hello"; x + 1 → 4343631981 a string POINTER used as an integer +let s: String = 42; println(s) → nothing address 42 dereferenced as a string +``` + +The first **leaks a raw memory address into program output**. The second is an +**arbitrary-read primitive** if that integer is ever attacker-influenced. + +Verified: 6/6, zero false positives across the compiler's own source, fixpoint +ok, 105/105 native. + +## Six Sigma + +The emitter only **records** the mismatch; `tools/check/annotations.sh` decides — +consistent with every other check. Literals are checked because they are +unambiguous. + +**Incomplete, stated not hidden:** only literals. `let x: Int = some_string_fn()` +still passes, because `signatures.rel` carries Int/Instant/Duration and no +String entries. That is a data gap, not a capability limit — every El function +declares its return type in source and codegen already holds `ret_type` on every +`FnDef`. diff --git a/docs/v1/experiments/cycles/18-async-half-expressible.md b/docs/v1/experiments/cycles/18-async-half-expressible.md new file mode 100644 index 0000000..1cdc0c2 --- /dev/null +++ b/docs/v1/experiments/cycles/18-async-half-expressible.md @@ -0,0 +1,73 @@ +# async — half expressible, and the cycle that was dogma + +**Status: measured on a branch, not merged. Two runs — the first was invalid.** + +## The first attempt was DOGMA, not science + +I had just finished arguing that `@async` was expressible, then ran something to +confirm it. **No prediction was committed.** The test was rigged in a way that +should have been visible while writing it: + +```c +pthread_create(&t,NULL,runner,NULL); pthread_join(t,NULL); +``` + +`join` immediately after `create` — the caller blocks until the body finishes. +That is a thread round-trip, not deferral. And the test printed the word +`DEFERRED` itself: I wrote the conclusion into the output and read it back. + +``` +Ishikawa on the rigged test + method ran after concluding, not to decide + machine nothing forces a prediction before execution + material the assertion was written into the output string + measurement no falsification criterion existed, so nothing could fail + root cause the test was authored by the party holding the conclusion, + with no commitment made before it ran +``` + +Discarded and re-run properly. + +## Second run — predictions committed first + +``` +P1 the caller proceeds while the body runs expect TRUE +P2 interleaving is observable in timestamps expect TRUE +P3 the result cannot be retrieved — one 64-bit slot, no + future type, so the wrap either blocks or returns + something that is not the result expect TRUE +P4 therefore HALF expressible: fire-and-forget yes, await no expect TRUE +``` + +## Results — 4/4 + +``` +[ 18 us] wrap RETURNS to caller +[ 29 us] body START +caller continues, got 0 +[ 50176 us] body END (computed 42) +caller done +``` + +The caller got **0, not 42**. Both of my earlier claims were wrong in opposite +directions: "not expressible" was too strong — fire-and-forget works today, +bound after the build, no compiler change. "Expressible" was too strong the +other way. + +## Follow-on cycle — a future is one more tagged object + +``` +P1 el_val_t already carries tagged heap objects TRUE 5 magic tags exist +P2 a future is one more TRUE +P3 the caller awaits and gets 42 TRUE +P4 ZERO compiler changes TRUE runtime C + one binding +P5 the unbound path still works FALSE SIGSEGV +``` + +**P4 is the result.** `@async` — called unexpressible for hours — needs no +compiler change. A future is one more magic-tagged heap object; `defer` returns +the handle, `el_await` blocks. + +**P5 is the failure that mattered.** Sixty seconds after diagnosing +`let s: String = 42` as an arbitrary read, I wrote the identical defect into +`el_await`: reading `->magic` off an unvalidated slot. That opened cycle 19. diff --git a/docs/v1/experiments/cycles/19-a-convention-is-not-a-gate.md b/docs/v1/experiments/cycles/19-a-convention-is-not-a-gate.md new file mode 100644 index 0000000..1619606 --- /dev/null +++ b/docs/v1/experiments/cycles/19-a-convention-is-not-a-gate.md @@ -0,0 +1,61 @@ +# a convention is not a gate + +One `Ishikawa → scientific method → Six Sigma` loop. The record below is the +commit message as written at the time, before the outcome was known to anyone +reading this file. + +## Record — `9a6c161` + +``` +a slot must be validated before it is dereferenced + +ISHIKAWA: el_val_t carries integers AND tagged heap pointers, so "is this a +pointer" is undecidable without checking first. That check was a CONVENTION +every author had to know rather than a GATE they had to pass through, and +looks_like_heap_obj was static -- so every sibling translation unit re-derived +it. + +MEASURED, across the five existing tags + geom_of looks_like_heap_obj full guard correct + mfld_of looks_like_heap_obj full guard correct + el_bin_lookup (uintptr_t)p < 4096 floor only reads 8 bytes BACKWARD + el_input_len s ? ... : 0 NULL only strlen's an integer + + sha256_hex(50000) -> exit 139, SIGSEGV, compiled clean + +PREDICTIONS AND RESULTS + P1 looks_like_heap_obj is static, not exported TRUE + P2 each tagged type re-derives the check TRUE + P3 at least one is missing guard components TRUE (two are) + P6 sha256_hex() reads out of bounds TRUE + P8 routing el_bin_lookup through the gate fixes it FALSE + P9 the legitimate hash is unchanged TRUE + P11 fixpoint and suites hold TRUE + +P8 IS THE USEFUL FAILURE. Guarding the tagged lookup changed nothing -- +looks_like_heap_obj(49992) correctly returns 0, el_bin_lookup bails, and then +el_input_len falls through to strlen() on address 50000. The FALLBACK was the +hazard, not the tagged path. A NULL check does not establish that a slot is a +pointer. I would have shipped the wrong fix and called it verified. + +A MEASUREMENT DEFECT, fourth today: my first run of the crash reported exit=0, +because $? read head's exit through a pipe rather than the program's. I nearly +recorded a segfault as a clean run. Same shape as grepping only parser.el and +searching by variable name instead of by operation. + +AND I PROVED THE HAZARD FROM THE INSIDE. Sixty seconds after diagnosing +`let s: String = 42` as an arbitrary-read primitive, I wrote the identical +defect into el_await -- dereferencing ->magic off an unvalidated slot -- and +only then found the runtime had already made it twice. + +el_tagged() is now exported in el_runtime.h. Anything that dereferences a slot +without passing through it is the defect. + +105/105 native, 42/42 integration across eight harnesses, fixpoint ok. +``` + +## Record — `3049a70` + +``` +make the guard a gate: sha256_hex(50000) no longer segfaults +``` diff --git a/docs/v1/experiments/findings/answers.md b/docs/v1/experiments/findings/answers.md new file mode 100644 index 0000000..534a496 --- /dev/null +++ b/docs/v1/experiments/findings/answers.md @@ -0,0 +1,12 @@ +# Architecture questions closed + +All five were open in `geometry-vs-code.md`. Each was closed by measurement, not +by argument. + +| Question | Answer | +|---|---| +| **Concurrency** — hardware threads are code, but is *ordering* geometric? | **Ordering is geometric.** Causality is a partial order (Lamport 1978); a total order is an arbitrary extension that "cannot be depended on to imply a causal relationship." Languages force a total order at authoring time, so every lock, barrier and fence is apparatus for recovering the partial order that was destroyed. CALM: a program has a coordination-free implementation **iff monotone**. What breaks monotonicity is destructive update. **Coordination is the price of forgetting.** | +| **Error handling** — does `grounded: false` cover *failed*? | **No.** Standing is a *signed* component: `>0` supported, `=0` unknown, `<0` contradicted. Not-known and known-false are opposite directions on one axis; a boolean cannot tell them apart. `inhibitory` as an int32 flag is that sign wearing a boolean. | +| **Parsing** — is a grammar a convention, or a region? | **A grammar is a basis; parsing is transduction onto it.** Lexeme→token is convention, shape recognition is a region, byte traversal is irreducible. **But the SHOULD gate refused the obvious move:** the keyword table stays code, because the set is closed by the language definition and the lexer runs before the program is understood. Same verdict as `is_digit` in ASCII. | +| **Numeric literals** — is `3` a position or a convention? | **The numeral is convention; the number is a position — and a bare `3` is a magnitude with no axis.** It is not a position until something gives it a direction, which is why `3.days` needs a calendar. Demonstrated: `t + 3` refused, `t + 1.hour` accepted. | +| **The module system** — if the partition is a neighbourhood, does linking survive? | **Premature.** The partition is a filesystem path and there is no namespacing at all. `import` is textual inlining; with a `.elh` header, symbols resolve at C link time. Two modules defining `helper` emit two C functions into one translation unit. Linking barely survives the *path* partition. | diff --git a/docs/v1/experiments/findings/bugs.md b/docs/v1/experiments/findings/bugs.md new file mode 100644 index 0000000..2433a12 --- /dev/null +++ b/docs/v1/experiments/findings/bugs.md @@ -0,0 +1,74 @@ +# Live defects found + +Every one compiled clean, ran, and produced a wrong result or a crash with **no +diagnostic at any layer**. All four were present before this session; none was +introduced by it. + +## Silent miscompilations + +### 1. An unannotated `let` loses its type + +```el +let a = str_len("hello") // no annotation +let b = str_len("hi") +let c = a + b // → el_str_concat(a, b) on two integers +``` + +Compiled clean. Printed **nothing** where it should print 7. Fixed: an +unannotated `let` takes its type from what its initialiser returns. The return +types were already required for dispatch and were simply never consulted at the +binding site. + +### 2. Reserved keywords that reserved nothing + +```el +let seed = 42 +let impl = seed + 1 +``` + +`sealed`, `activate`, `seed`, `protocol`, `impl` were keywords in the lexer and +consumed by no parser or codegen path. Using one did not fail to parse — it +compiled clean, with zero `cc` errors, and printed **0 instead of 44**. Fixed by +removing all five. + +### 3. `Instant + Int` was never refused + +```el +let t: Instant = now() +let u: Instant = t + 3 // → (t + 3), reported clean +``` + +`Duration + Int` was refused — *"an Int carries no unit"* — while adding a +dimensionless number to a **point** silently moved the instant by an +unspecified amount. Three of *what*? Whatever the representation happens to be. +The rule was simply never written. + +## Security-relevant + +### 4. Annotations are never verified + +```el +let x: Int = "hello"; x + 1 → 4343631981 a string POINTER used as an integer +let s: String = 42; println(s) → nothing address 42 dereferenced +``` + +The first **leaks a raw memory address into program output**. The second is an +**arbitrary-read primitive** if the integer is ever attacker-influenced. + +### 5. `sha256_hex()` segfaults + +```el +let h: String = sha256_hex(50000) → exit 139, SIGSEGV +``` + +Compiled clean. `el_bin_lookup` checked only a 4096 floor — no alignment, no +small-int, no negative — and reads **eight bytes backward** from the pointer. +And the actual crash was one level further on: `el_input_len` fell through to +`strlen()` on address 50000, because a NULL check does not establish that a slot +is a pointer. + +Fixed, and the guard is now a **gate**: `el_tagged()` is exported in +`el_runtime.h`. `geom_of` and `mfld_of` were always correct because their authors +knew to call `looks_like_heap_obj`; `el_bin_lookup` and `el_input_len` were wrong +because theirs did not, and the function was `static`, so every sibling +translation unit re-derived it. diff --git a/docs/v1/experiments/findings/measurement-defects.md b/docs/v1/experiments/findings/measurement-defects.md new file mode 100644 index 0000000..a9eec13 --- /dev/null +++ b/docs/v1/experiments/findings/measurement-defects.md @@ -0,0 +1,41 @@ +# Defects in my own measurement + +Recorded because the pattern is the point: **four of these, all the same shape — +searching by name or scope instead of by the operation itself.** Each was caught +by running something, never by reading. + +### 1. Scoped the search to one file + +Reported `test` as an inert keyword by checking only `parser.el`. **codegen** +consumes it at 4135 for `--test` mode, and the tree has 408 uses. Removing it +would have broken every test in the suite — including the ones used to verify +the removal. + +### 2. Searched by variable name, not by operation + +Grepped for `native_list_append(tokens` to find direct token appends. +`interp_tokens_append_all` calls its parameters `dst`/`result`, carries its own +copy of the stride, and corrupted generation 2 — while generation 1 built fine, +because the compiler's own source uses string interpolation. + +### 3. Scoped to compiler sources; the stride had escaped into tests + +`tok_count` in `test_compiler.el` computed `len/2` independently. 21 tests failed +after the token layout changed. + +### 4. Read the wrong exit code + +```bash +timeout 10 /tmp/leakrun 2>&1 | head -2; echo "exit=$?" # reports head's exit +``` + +Reported `exit=0` for a program that was returning **139 (SIGSEGV)**. I nearly +recorded a segfault as a clean run. + +### And one that was not a measurement defect but a method defect + +One cycle was run **without committing predictions first** — see +`cycles/18-async-half-expressible.md`. The test joined the thread immediately +after creating it and printed the word `DEFERRED` itself. A test authored by the +party holding the conclusion, with nothing committed beforehand, cannot fail. +It had to be discarded and re-run.