Commit Graph

628 Commits

Author SHA1 Message Date
bigmerge daad2fa2a0 log the fifth measurement defect: a count that was not counting
El SDK CI - dev / build-and-test (pull_request) Failing after 10m9s
git diff errored to stderr on a malformed revision while wc -l counted empty
stdout, producing three confident IDENTICAL results that meant nothing. Had the
promoted trees actually differed, I would have reported the promotion clean.

The correct check is not 'how many files differ' but 'is the tree object the
same object' -- all three share hash 2acd9374.

All five defects are now visibly one shape: reading a PROXY instead of the
thing. One file instead of the operation, a variable name instead of the shape,
a scope instead of the whole, a pipe's exit instead of the program's, a line
count instead of object identity.
2026-08-17 11:10:22 -05:00
will.anderson 9e4160f279 Merge pull request 'promote stage → main: chain-of-custody repair' (#169) from stage into main
El SDK Release / build-and-release (push) Failing after 26s
2026-08-17 16:06:01 +00:00
will.anderson 4aadd9300e Merge pull request 'promote dev → stage: chain-of-custody repair' (#168) from dev into stage
El SDK Release / build-and-release (pull_request) Failing after 23s
El SDK CI - stage / build-and-test (push) Failing after 12m50s
2026-08-17 16:05:31 +00:00
will.anderson 44e2e17973 Merge pull request 'repair a broken chain link: rerun cycle 18 rather than reconstruct it' (#167) from experiment/async-future-replication into dev
El SDK CI - stage / build-and-test (pull_request) Failing after 27s
El SDK CI - dev / build-and-test (push) Failing after 14m1s
2026-08-17 16:05:02 +00:00
bigmerge 511db25230 rerun cycle 18 rather than reconstruct it
El SDK CI - dev / build-and-test (pull_request) Failing after 14m31s
The async/future measurements were produced by a C stub in /tmp, and that
artifact was destroyed when the session worktrees were removed. The log then
asserted results with nothing behind them -- a claim inside an evidence record,
which is exactly what turns a chain of custody into a pile.

Rerun, not reconstructed. Rebuilding the missing file would have been a
fabrication with a fresh timestamp; rerunning produces new evidence with its own.

  lang/tests/integration/fixtures/future.c   the future, as a tagged heap object
  lang/tests/integration/async_future.sh     the harness, 6/6

  ok  unbound: synchronous, correct result
  ok  unbound: el_await on a non-future passes through, no crash
  ok  bound: does not crash
  ok  bound: the awaited result is correct
  ok  bound: the caller continues BEFORE the body finishes
  ok  bound: wrap returns in <10ms while the body takes 50ms

LABELLED AS A REPLICATION. The outcomes were already known when this harness was
written, so its expectations are NOT predictions committed in advance. Its
evidentiary value is that a third party can reproduce it, not that it was called
ahead of time. Recording it as anything stronger would corrupt the record it is
meant to repair.

The fixture also carries the P5 defect and its fix in a comment: the first
el_await dereferenced ->magic off an unvalidated slot and SIGSEGV'd on the
unbound path, sixty seconds after the same defect was diagnosed elsewhere in the
runtime.
2026-08-17 11:03:59 -05:00
will.anderson e9eac46be1 Merge pull request 'promote stage → main: iteration-1 (the compiler stops adjudicating)' (#166) from stage into main
El SDK Release / build-and-release (push) Failing after 11m31s
2026-08-17 15:57:06 +00:00
will.anderson aa570b6899 Merge pull request 'promote dev → stage: iteration-1 (the compiler stops adjudicating) + accumulated dev' (#165) from dev into stage
El SDK CI - stage / build-and-test (push) Failing after 11m53s
El SDK Release / build-and-release (pull_request) Failing after 12m5s
2026-08-17 15:56:24 +00:00
will.anderson 98da70f650 Merge pull request 'iteration-1: the compiler stops adjudicating' (#164) from iteration-1 into dev
El SDK CI - dev / build-and-test (push) Failing after 35s
El SDK CI - stage / build-and-test (pull_request) Failing after 12m12s
2026-08-17 15:55:20 +00:00
bigmerge d1489a2568 Merge remote-tracking branch 'origin/dev' into iteration-1
El SDK CI - dev / build-and-test (pull_request) Failing after 23s
2026-08-17 10:54:00 -05:00
bigmerge 923f6a4bed land annotation checking: the declared type is finally verified 2026-08-17 10:53:01 -05:00
bigmerge f1a7e224a7 verify the annotation against what it annotates
ISHIKAWA: three silent miscompilations 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 and no type CHECKING. The annotation
               feeds dispatch and is never itself verified.

MEASURED, and it is not merely a wrong answer

  let x: Int = "hello" ; x + 1   -> printed 4343631981, a string POINTER
                                    interpreted as an integer
  let s: String = 42   ; println -> dereferenced address 42

The first leaks a raw memory address into program output. The second is an
arbitrary-read primitive if the integer is ever attacker-influenced.

PREDICTIONS AND RESULTS
  P1 let x: Int = "hello" compiles clean               TRUE
  P2 let s: String = 42 compiles clean                 TRUE
  P3 the annotation drives dispatch, unverified        TRUE
  P4 same root cause as all three bugs found today     TRUE
  P5 checking literal-vs-annotation catches both       TRUE
  P6 zero false positives across the compiler's source TRUE

The emitter only RECORDS the mismatch; tools/check/annotations.sh decides,
consistent with every other check landed today.

INCOMPLETE, stated rather than hidden: only literals are checked.
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.

105/105 native, 5/5 annotation_query.sh, fixpoint ok.
2026-08-17 10:53:01 -05:00
bigmerge c6ba0677f0 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.
2026-08-17 10:52:17 -05:00
bigmerge 3049a70837 make the guard a gate: sha256_hex(50000) no longer segfaults 2026-08-17 10:49:49 -05:00
bigmerge 9a6c161ba9 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(<int>) 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.
2026-08-17 10:49:49 -05:00
bigmerge cb7289f065 thread provenance end to end: a diagnostic can finally name a place 2026-08-17 10:07:27 -05:00
bigmerge 6c975b1d50 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 <fn> defines_at:<line>
  resolve_imports  publishes <file> spans <start> <end> 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.
2026-08-17 10:07:27 -05:00
bigmerge 1086ac9658 record the module answer: the partition is a path, not a neighbourhood
All four questions in the Open section are now answered by measurement rather
than by argument. Concurrency, error handling, parsing, numeric literals, and
the module system.
2026-08-17 09:54:39 -05:00
bigmerge f23cb2b948 answer the module question: the partition is a path, and there is no namespacing 2026-08-17 09:54:23 -05:00
bigmerge 79f6cb7985 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__:<path> 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.
2026-08-17 09:54:23 -05:00
bigmerge bb040ad2c8 record the numeric literals answer: a bare number has no axis 2026-08-17 09:50:06 -05:00
bigmerge 97f741e9c2 answer the numeric literals question: a bare number is a magnitude with no axis 2026-08-17 09:49:51 -05:00
bigmerge c48db6c2a8 ANSWER: is 3 a position, or a convention we agreed on?
Both, at different layers, and the split is the same as everywhere else. The
NUMERAL is convention -- int_to_str was already form 1, because no position
determines that twelve is written 1 then 2 in base ten. The NUMBER is a
position: three things are three things regardless of notation.

But the sharper answer follows from `love = 0`. A bare `3` is a MAGNITUDE WITH
NO AXIS. It is not a position until something gives it a direction, which is
exactly why 3.days needs a calendar and why time_add(t, n, "min") had to carry
its axis as a string.

PREDICTIONS AND RESULTS
  P1 numeral = convention, number = position                TRUE
  P2 a bare literal is dimensionless until context types it TRUE
  P3 there is a measurable place where El guesses           TRUE
  P4 Instant + Int is not caught though Duration + Int is   TRUE
  P5 the rule catches it                                    TRUE
  P6 nothing legitimate in the tree relies on it            TRUE

P3/P4 IS THE DEFECT, and it was found by reasoning from the philosophy and then
measured. Duration + Int was refused -- "an Int carries no unit" -- while

    let t: Instant = now()
    let u: Instant = t + 3

compiled to raw (t + 3) and reported CLEAN. Adding a dimensionless number to a
point is worse than adding it to a displacement: it silently moves the instant
by an unspecified amount. 3 of what? Whatever the representation happens to be,
which is the leak itself. The asymmetry had no justification; the rule was
simply never written.

P6 MATTERED. Two calendar tests looked like Instant + Int:

    let later: Instant = i + 1.hour
    let later: Instant = base + 15.hours

They are not. `1.hour` lexes to a Duration -- el_duration_from_nanos(1LL *
3600000000000LL) -- and both stay clean. That is the whole answer demonstrated
in one line: t + 3 is refused because 3 has no axis; t + 1.hour is accepted
because .hour supplies one.

104/104 native + 2 new, integration green, fixpoint ok.
2026-08-17 09:49:50 -05:00
bigmerge 93aa96cfaf record the parsing answer: a grammar is a basis, and the should gate refused the obvious move 2026-08-17 09:44:14 -05:00
bigmerge 067dd40317 answer the parsing question: a grammar is a basis, and five keywords reserved nothing 2026-08-17 09:43:58 -05:00
bigmerge 0143cc458a 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.
2026-08-17 09:43:58 -05:00
bigmerge 15d4352bac make the capability table match its own status note
The note said serialization, text encoding, storage, network, concurrency and
emission had collapsed; the table still listed all six as live capabilities.
A document that contradicts itself one screen apart is worse than one that is
merely out of date.

Also renames 27 from Secrecy to Concealment. 'Secrecy' covered one of the three
things in that row and got the other two backwards: a hash is public and a
signature exists to be read. Integrity and authenticity are grounding under
adversarial conditions, which is row 16. Only concealment stands alone.
2026-08-17 09:37:35 -05:00
bigmerge 2fcc1c287c correct the architecture docs against what was measured
capabilities.md cited '== lowering to str_eq unless both operand names are in a
hardcoded int-name set' as the paradigm defect. That is wrong: __int_names comes
from type annotations, which is legitimate propagation. The real defect was 35
hardcoded builtin return types one layer down, and mislocating it hid a live
miscompilation of unannotated lets.

geometry-vs-code.md listed concurrency and error handling as open. Both are
answered: ordering is a partial order and coordination is the price of
forgetting; standing is signed, so not-known and known-false are opposite
directions rather than one boolean. Added the fourth proof form (adversarial
exactness) and recorded that form 1 no longer survives as a verdict -- every row
it justified was a basis, not a capability.

Also marked cross-cutting concerns as implemented rather than predicted.
2026-08-17 09:37:13 -05:00
bigmerge 505e5e74d9 land int signatures, and repair a silent miscompilation they exposed 2026-08-17 09:32:13 -05:00
bigmerge cbef1c1ebb 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.
2026-08-17 09:32:13 -05:00
bigmerge 50425f375d land temporal adjudication as a query: the emitter records, the rules are data 2026-08-17 09:27:42 -05:00
bigmerge e8e25a07b4 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.
2026-08-17 09:27:42 -05:00
bigmerge e01e079bda land temporal signatures as data: the type table leaves, the dispatch stays 2026-08-17 09:25:18 -05:00
bigmerge d2d89fcb60 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.
2026-08-17 09:25:18 -05:00
bigmerge d9e301be6d land arity-from-header: the runtime declares its own surface 2026-08-17 09:21:10 -05:00
bigmerge 9cc6040df2 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.
2026-08-17 09:21:10 -05:00
bigmerge 29f78f9f67 land capability-as-policy: eighteen literals become a data file 2026-08-17 09:16:36 -05:00
bigmerge c2d9596e76 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.
2026-08-17 09:16:36 -05:00
bigmerge 60c07ad784 land prohibition-as-query: the emitter records, it no longer adjudicates 2026-08-17 09:11:48 -05:00
bigmerge c741cfe928 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.
2026-08-17 09:11:48 -05:00
bigmerge c04d68f9ce land runtime invocation control: only prohibition remains compiled 2026-08-17 09:06:16 -05:00
bigmerge bc2f26ddfc 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.
2026-08-17 09:06:05 -05:00
bigmerge b40754f07b land unconditional wrapper: exit crossings resolve at runtime 2026-08-17 09:01:55 -05:00
bigmerge 285166c25c 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.
2026-08-17 09:01:55 -05:00
bigmerge 24f7fb5143 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.
2026-08-17 08:56:41 -05:00
bigmerge 8bbb750c2c 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.
2026-08-17 08:48:57 -05:00
bigmerge 28d19da7f1 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.
2026-08-17 08:43:57 -05:00
bigmerge 886626a64e 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.
2026-08-17 08:40:10 -05:00
bigmerge 82e998273b self-review 2026-08-17: bound the off-graph ISE log — moving telemetry off-graph moved the leak, it did not close it
The 2026-07-16 review fixed telemetry growth in the GRAPH by calling
engram_prune_telemetry(48h) on every ISE insert. The 2026-08-xx move to
ENGRAM_ISE_OFFGRAPH=1 then routed every state event to a flat append-only
log instead — and that path had no retention of any kind. The prune call
still exists in server.el, but it now sits in the branch that production
never takes, so the fix reads as present while being inert.

Measured on the live store: 17.1 MB / 14,305 events over 3.56 days =
4.81 MB/day, unbounded (~1.76 GB/year).

engram_ise_log_append now compacts to a byte bound after append. Byte- and
not time-bounded on purpose: this is a flat file with no index, so size is
the property that has to be bounded, and ftell on the handle already held
is O(1) versus an O(file) timestamp scan per append. Default 64 MB retains
~13 days at the measured rate — more history than the 48h the on-graph path
kept. Override with ENGRAM_ISE_LOG_MAX_BYTES.

Compaction keeps the TAIL, never the head: engram_dreams_json reads the
last ~2 MB of this file for dream-recall, so the recent end is the end with
a reader, and KEEP (16 MB) stays well clear of that window. Resumes at the
first line boundary so the tail never starts mid-record, and only renames
over the live log when the tail was written in full — a short write must
not destroy history.

The honesty rail is unchanged: rotated-out remains "I don't remember",
never a synthesized dream. This only makes the forgetting bounded and
explicit instead of deferred forever.

Verified against a 4,000-event harness at a 200 KB cap: file bounded,
newest record retained, oldest dropped, 883 lines with zero malformed
records, tail contiguous, no .tmp residue.
2026-08-17 08:39:48 -05:00
bigmerge 35b07bade2 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.
2026-08-17 08:37:47 -05:00
bigmerge 1b324a071f let a construct declare what may not cross it
The other half of a boundary: not what runs when something crosses, but what
may not cross at all. It was two string literals in vbd_is_restricted_name and
one #error in cg_fn — one prohibition, uneditable without a compiler release.

    @decorator("prohibits_outside", "raw_sql")
    fn repository() {}

    fn sneaky() -> Int { raw_sql("DROP") }
    // #error "boundary violation: raw_sql may only be called from an
    //          @repository fn, but 'sneaky' is not one"

The recursive matcher is parameterised through a state key rather than by
threading an argument through every branch of the walk — the mechanism codegen
already uses for __match_counter and __if_expr_counter. Each prohibition is
checked in its own turn, so the owning construct is known by construction and
the diagnostic names it instead of hardcoding one rule's wording.

PREDICTIONS AND RESULTS
  1 the 3 duplicated uniqueness rules are textually identical    TRUE
  2 a declared prohibition reproduces @manager's #error          TRUE
  3 existing output byte-identical                               TRUE
  4 a program can declare its own prohibition                    TRUE
  5 fixpoint holds                                               TRUE

I misread result 2 on first pass: a @manager fn calling dharma_emit still
emitted one #error, which looked like a failure. It is the CAPABILITY-tier rule
at codegen.el:2578, a separate prohibition system, and it fires identically on
the pre-change compiler.

MEASURED DEFECTS STILL OPEN
  - two independent prohibition systems (VBD constructs, capability tiers);
    only the first is declarable
  - 3 uniqueness rules written 6 times, once per codegen path, kept in sync by
    hand and identical today

102/102 native compiler tests pass, compiler self-hosts byte-identically.
2026-08-17 08:15:27 -05:00