Compare commits

...

8 Commits

Author SHA1 Message Date
bigmerge b5a0a729e6 codegen: Bool is int-like, so Bool comparisons stop lowering to str_eq
El SDK CI - dev / build-and-test (pull_request) Failing after 14m49s
fn check(label: String, cond: Bool, want: Bool) -> Void {
        if cond == want { ... }        ->  if (str_eq(cond, want))   SIGSEGV
    }

Bool has always been an integer in the value model — type_to_c maps Bool to
"int", and el_runtime.h states "Bool -> el_val_t (0 = false, nonzero = true)".
But Bool names were registered NOWHERE: build_int_names_for_params tracked Int
and Float params, and the `let` path tracked Int and Float bindings. Neither
knew about Bool.

So comparing two Bools fell through to str_eq, which dereferenced 0 or 1 as a
char* and segfaulted immediately.

This is the third instance of one family found tonight, after el #137 (a call
on either side of == poisoned the operator) and el #136 (a missing import
compiled clean). All three are the same shape: something the compiler could not
type, silently handled as a string.

Found while writing #137's own test harness — the first version of that harness
crashed on exactly this, on both the old and new compiler, which is how it
surfaced. A test harness that cannot compare two Bools is a good way to notice.

VERIFIED:
  - the harness that segfaulted on every prior compiler (exit 139, no output)
    now runs clean: 14 passed, 0 failed
  - self-hosting fixpoint byte-identical
  - the compiler's own generated C differs by 8 lines — only the intended
    registration
  - neuron's full soul amalgam regenerates in 424ms, exit 0, BYTE-IDENTICAL
  - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12

Adds tests/runtime/operator_typing_test.el, the 15-case suite from #137, so
this family is covered going forward rather than rediscovered.
2026-08-15 21:54:10 -05:00
will.anderson b26dd47aef Merge pull request 'codegen: either side Int is enough for == and !=, not both' (#137) from fix/eq-operand-inference into dev
El SDK CI - dev / build-and-test (push) Failing after 12m0s
2026-08-16 02:52:02 +00:00
bigmerge b55e6bfd53 codegen: either side Int is enough for == and !=, not both
El SDK CI - dev / build-and-test (pull_request) Failing after 12m20s
let a: Int = 5
    getint(5) == a      ->  str_eq(getint(5), a)      SIGSEGV
    getint(5) == 5      ->  getint(5) == 5            fine

A function call whose return type codegen cannot infer poisoned the operator,
and a declared Int on the other side did not save it. str_eq then read an
integer as a char* and segfaulted. Only an integer LITERAL on one side forced
the numeric form, which is why the bug stayed invisible: the common case
happened to be safe.

The check required BOTH operands to be provably Int:

    if is_int_expr(left) { if is_int_expr(right) { numeric } }

Loosening to OR is strictly safer, not a trade:
  - when one side is a known Int, str_eq is ALWAYS wrong — it dereferences
    that integer — while numeric comparison is at worst a wrong answer on a
    program that was already ill-typed;
  - when neither side is Int nothing changes at all, so string comparison is
    untouched.

Found by the test-framework agent while building the benchmark harness; it
correctly declined to fix it mid-phase since it is a codegen semantics change.

VERIFIED, because a semantics change earns more than an assertion:
  - 15/15 on a dedicated operator suite covering string literals, string vars,
    string-returning calls, mixed var/call, and != in every combination. The
    pre-change compiler scores 0/15 on the same file: it segfaults before
    printing anything.
  - self-hosting fixpoint byte-identical
  - the ONLY difference in the compiler's own generated C is the intended one:
    a nested if becoming two sequential ifs, in EqEq and NotEq. Nothing else
    moved.
  - neuron's full soul amalgam regenerates in 400ms, exit 0, output
    BYTE-IDENTICAL at 1,270,212 bytes
  - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12 —
    62 tests, 190 assertions, zero failures

NOT fixed here, same family, flagged for a decision: Bool PARAMETERS are not
tracked as int-like, so `cond == want` between two Bool params still lowers to
str_eq and segfaults. Found while writing this commit's own test harness — the
first version of it crashed on exactly that, on both the old and new compiler.
It needs the same treatment, and it wants its own change.
2026-08-15 21:51:35 -05:00
will.anderson dbb06f6ee4 Merge pull request 'compiler: a missing import is an error, not an empty string' (#136) from fix/missing-import-is-an-error into dev
El SDK CI - dev / build-and-test (push) Failing after 11m0s
2026-08-16 02:48:01 +00:00
bigmerge 906c664a65 compiler: a missing import is an error, not an empty string
El SDK CI - dev / build-and-test (pull_request) Failing after 11m23s
import "../../NOPE/does_not_exist.el"

compiled CLEANLY — exit 0, empty stderr, and a program silently missing
everything it imported.

resolve_imports did `fs_read(src_path)` and used the result without checking.
fs_read returns "" both for "file is empty" and "file does not exist", so a
typo, a moved file, or a relative path resolved from the wrong working
directory all produced a successful build of nothing.

It caused a real wrong conclusion during test-framework work: a bisection run
from a subdirectory where ../../runtime/ did not resolve produced ELEVEN
consecutive "successful" compiles that had included no runtime at all, and the
results were believed before anyone noticed.

Missing dependency, confident success — the same shape as a test suite
reporting pass for tests that never ran, and as a benchmark reporting 0us
because the optimiser deleted the loop.

fs_exists separates the two cases, so a legitimately empty file still resolves
to "" and is fine. A path that does not exist now prints the resolved path and
exits 1, which is what build scripts check.

Verified:
  - bad import: exit 1 (was 0), message names the resolved path
  - elc-cli.el still compiles, self-hosting fixpoint byte-identical
  - neuron's full soul amalgam regeneration: exit 0, 405ms, output
    byte-identical at 1,270,212 bytes
2026-08-15 21:47:32 -05:00
will.anderson 9e96d74f6a Merge pull request 'runtime: count container allocations too, not just strings' (#135) from feat/alloc-accounting-containers into dev
El SDK CI - dev / build-and-test (push) Failing after 11m49s
2026-08-16 02:37:14 +00:00
bigmerge a8908908df runtime: count container allocations too, not just strings
El SDK CI - dev / build-and-test (pull_request) Failing after 12m11s
el #131 instrumented the four string allocators, which meant list- and map-heavy
code reported ZERO allocations — a benchmark over lists would have been fitted
against a flat line and passed anything. Caught during framework work: a
"linear" specimen read 0 allocs until it was rewritten to allocate strings.

A gate is only as good as its blind spots are small, and a signal that silently
reads zero is worse than no signal: it produces a confident pass.

Now counted at every container allocation — ElList and ElMap bodies, their
backing arrays, the copy-on-write clones, and the realloc growth path.

Verified on an append loop (n = 100..800):
    allocs  7, 8, 9, 10          +1 per doubling = O(log n) reallocations
    bytes   2048, 4096, 8192, 16384   exactly 2x per doubling = O(n)

Both curves are what correct amortized growth should look like, and both read
zero before this change.

Known remaining scope, stated rather than left implicit: these counters cover
the runtime's own allocations. They do not see malloc inside engram_*.c or
libcurl, which is correct — the gate is for El-level complexity, not for
third-party memory behaviour.
2026-08-15 21:36:52 -05:00
will.anderson a69a4a5894 Merge pull request 'runtime: math_log is base-10, not natural log' (#134) from fix/math-log-base10 into dev
El SDK CI - dev / build-and-test (push) Failing after 14m48s
2026-08-16 02:34:09 +00:00
4 changed files with 88 additions and 6 deletions
+35 -6
View File
@@ -862,10 +862,23 @@ fn cg_expr(expr: Map<String, Any>) -> String {
// arithmetic BinOp (or vice-versa). Without this check the
// fallthrough to str_eq produces str_eq(int_value, int_value)
// which reads the integer as a char* and segfaults.
// EITHER side provably Int is enough. Requiring BOTH meant a call
// whose return type codegen cannot infer poisoned the operator:
// getint(5) == a -> str_eq(getint(5), a)
// even with `a` declared Int. str_eq then reads an integer as a
// char* and segfaults. Only an integer LITERAL on one side forced
// the numeric form, so the bug was invisible in the common case.
//
// Loosening to OR is strictly safer: when one side is a known Int,
// str_eq is always wrong (it dereferences that int), while numeric
// comparison is at worst a wrong answer on an already ill-typed
// program. When neither side is Int nothing changes, so string
// comparison is untouched.
if is_int_expr(left) {
if is_int_expr(right) {
return "(" + left_c + " == " + right_c + ")"
}
return "(" + left_c + " == " + right_c + ")"
}
if is_int_expr(right) {
return "(" + left_c + " == " + right_c + ")"
}
// Float literal or negative float literal: use plain == (bit-equal
// el_val_t comparison). This handles `r0 == 3.0`, `neg == -3.0`, etc.
@@ -921,10 +934,12 @@ fn cg_expr(expr: Map<String, Any>) -> String {
}
// Same mixed Ident/BinOp fix as EqEq: use is_int_expr to detect
// integer-typed operands before falling through to !str_eq.
// Either side Int is enough see the EqEq note above.
if is_int_expr(left) {
if is_int_expr(right) {
return "(" + left_c + " != " + right_c + ")"
}
return "(" + left_c + " != " + right_c + ")"
}
if is_int_expr(right) {
return "(" + left_c + " != " + right_c + ")"
}
// Float-typed operands use plain != (bit-equal comparison).
if is_float_expr(left) {
@@ -1495,6 +1510,11 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if str_eq(ltype, "Int") {
add_int_name(name)
}
// Same as params: Bool is an int in the value model. Without this a
// `let ok: Bool = ...` compared to another Bool lowered to str_eq.
if str_eq(ltype, "Bool") {
add_int_name(name)
}
if str_eq(ltype, "Float") {
add_float_name(name)
}
@@ -3112,6 +3132,15 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
if str_eq(ptype, "Int") {
add_int_name(pname)
}
// Bool is an integer in the value model (type_to_c maps Bool -> "int";
// el_runtime.h: "Bool -> el_val_t (0 = false, nonzero = true)"), but
// Bool names were registered nowhere. So `cond == want` between two
// Bool params fell through to str_eq and dereferenced 0 or 1 as a
// char* an immediate segfault. Track them as int-like, which is what
// they are.
if str_eq(ptype, "Bool") {
add_int_name(pname)
}
if str_eq(ptype, "Float") {
add_float_name(pname)
}
+16
View File
@@ -419,6 +419,22 @@ fn resolve_imports(src_path: String) -> String {
if !str_eq(already, "") { return "" }
state_set(seen_key, "1")
// A missing file must be a hard error, never an empty string.
//
// fs_read returns "" both for "file is empty" and "file does not exist", and
// this function used the value without distinguishing them. So a broken
// import path a typo, a moved file, a relative path resolved from the
// wrong working directory compiled CLEANLY: exit 0, empty stderr, and a
// program silently missing everything it imported. Observed 2026-08-15:
// eleven consecutive "successful" compiles that had included no runtime at
// all, and a wrong conclusion drawn from them before anyone noticed.
//
// Missing dependency, confident success. fs_exists separates the two cases,
// so a genuinely empty file still resolves to "" and is fine.
if !fs_exists(src_path) {
println("elc: cannot resolve import: " + src_path)
exit_program(1)
}
let source: String = fs_read(src_path)
let dir: String = dirname_of(src_path)
let lines: [String] = str_split(source, "\n")
+9
View File
@@ -476,12 +476,14 @@ typedef struct {
static ElList* list_alloc(int64_t cap) {
if (cap < 4) cap = 4;
ElList* lst = malloc(sizeof(ElList));
_el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
if (!lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
lst->hdr.magic = EL_MAGIC_LIST;
lst->hdr.refcount = 1;
lst->length = 0;
lst->capacity = cap;
lst->elems = malloc((size_t)cap * sizeof(el_val_t));
_el_alloc_count++; _el_alloc_bytes += (size_t)cap * sizeof(el_val_t);
if (!lst->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
return lst;
}
@@ -531,6 +533,7 @@ el_val_t el_list_append(el_val_t listv, el_val_t elem) {
if (old->length >= old->capacity) {
int64_t new_cap = old->capacity > 0 ? old->capacity * 2 : 4;
el_val_t* grown = realloc(old->elems, (size_t)new_cap * sizeof(el_val_t));
_el_alloc_count++; _el_alloc_bytes += (size_t)new_cap * sizeof(el_val_t);
if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
old->elems = grown;
old->capacity = new_cap;
@@ -543,12 +546,14 @@ el_val_t el_list_append(el_val_t listv, el_val_t elem) {
int64_t new_cap = old->length + 1;
if (new_cap < 4) new_cap = 4;
ElList* fresh = malloc(sizeof(ElList));
_el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length + 1;
fresh->capacity = new_cap;
fresh->elems = malloc((size_t)new_cap * sizeof(el_val_t));
_el_alloc_count++; _el_alloc_bytes += (size_t)new_cap * sizeof(el_val_t);
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
@@ -570,12 +575,14 @@ el_val_t el_list_clone(el_val_t listv) {
if (cap < old->length) cap = old->length;
if (cap < 4) cap = 4;
ElList* fresh = malloc(sizeof(ElList));
_el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length;
fresh->capacity = cap;
fresh->elems = malloc((size_t)cap * sizeof(el_val_t));
_el_alloc_count++; _el_alloc_bytes += (size_t)cap * sizeof(el_val_t);
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
@@ -596,6 +603,7 @@ typedef struct {
static ElMap* map_alloc(int64_t cap) {
if (cap < 4) cap = 4;
ElMap* m = malloc(sizeof(ElMap));
_el_alloc_count++; _el_alloc_bytes += sizeof(ElMap);
if (!m) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
m->hdr.magic = EL_MAGIC_MAP;
m->hdr.refcount = 1;
@@ -671,6 +679,7 @@ el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
int64_t new_cap = m->count + 1;
if (new_cap < 4) new_cap = 4;
ElMap* fresh = malloc(sizeof(ElMap));
_el_alloc_count++; _el_alloc_bytes += sizeof(ElMap);
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_MAP;
fresh->hdr.refcount = 1;
@@ -0,0 +1,28 @@
fn getstr(x: String) -> String { return x }
fn getint(x: Int) -> Int { return x }
fn ok(label: String) -> Void { println("ok " + label) }
fn bad(label: String) -> Void { println("FAIL " + label) }
let s1: String = "hello"
let s2: String = "hello"
let s3: String = "world"
let i1: Int = 5
let i2: Int = 5
let i3: Int = 9
if "abc" == "abc" { ok("str literal eq") } else { bad("str literal eq") }
if "abc" == "xyz" { bad("str literal ne") } else { ok("str literal ne") }
if s1 == s2 { ok("str var eq") } else { bad("str var eq") }
if s1 == s3 { bad("str var ne") } else { ok("str var ne") }
if getstr("hi") == "hi" { ok("str call vs literal") } else { bad("str call vs literal") }
if s1 == getstr("hello") { ok("str var vs call") } else { bad("str var vs call") }
if s1 == getstr("nope") { bad("str var vs call ne") } else { ok("str var vs call ne") }
if i1 == i2 { ok("int var eq") } else { bad("int var eq") }
if i1 == i3 { bad("int var ne") } else { ok("int var ne") }
if getint(5) == i1 { ok("int call vs var") } else { bad("int call vs var") }
if getint(9) == i1 { bad("int call vs var ne") } else { ok("int call vs var ne") }
if s1 != s3 { ok("str NOTEQ") } else { bad("str NOTEQ") }
if s1 != s2 { bad("str NOTEQ same") } else { ok("str NOTEQ same") }
if i1 != i3 { ok("int NOTEQ") } else { bad("int NOTEQ") }
if getint(9) != i1 { ok("int call NOTEQ") } else { bad("int call NOTEQ") }
println("done")