Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a307dfd42 | |||
| 1f70b9fa18 | |||
| 317466e8f7 | |||
| eb3e6d7c1f | |||
| 88e3008735 | |||
| a6cef4b983 | |||
| 8e9d88fc01 | |||
| e99a4640e2 | |||
| bdc1f99fb9 | |||
| 44b621e551 | |||
| ded6ca546f | |||
| b5b96c05ed | |||
| c79033b749 | |||
| 1119295238 | |||
| 9c07970943 | |||
| 0832865952 | |||
| e0b2c0ea54 | |||
| cf060adbfd | |||
| 63fe8a766d | |||
| b5a0a729e6 | |||
| b26dd47aef | |||
| b55e6bfd53 | |||
| dbb06f6ee4 | |||
| 906c664a65 | |||
| 6a6b589ba0 | |||
| b5d1e53902 | |||
| 9e96d74f6a | |||
| 6291a35bb9 | |||
| a69a4a5894 | |||
| 4c3414072b |
@@ -61,6 +61,13 @@ Per test file, current build model:
|
|||||||
| `cc` test .c → .o | 0.02s |
|
| `cc` test .c → .o | 0.02s |
|
||||||
| link | 0.02s |
|
| link | 0.02s |
|
||||||
|
|
||||||
|
> **STALE as of el #132 — re-measured 2026-08-16.** The `test_compiler` figure below was
|
||||||
|
> *entirely* the `strlen`-per-character quadratic, now fixed. Re-measured on the same host:
|
||||||
|
> **3.58s → 0.03s (119x)**, and the 422 KB compiler concatenation likewise compiles in 0.03s.
|
||||||
|
> The table is retained only as the historical record that motivated the gate. The remaining
|
||||||
|
> per-file cost is the redundant `el_runtime.c` rebuild, which §9's compile-once architecture
|
||||||
|
> addresses.
|
||||||
|
|
||||||
Per-file `elc` time across the existing suite:
|
Per-file `elc` time across the existing suite:
|
||||||
|
|
||||||
| File | Bytes | elc time |
|
| File | Bytes | elc time |
|
||||||
@@ -416,6 +423,48 @@ Wall-clock needs statistics. **Allocation counts do not.** They are perfectly de
|
|||||||
> a level. That is why the gate fits a curve across a sweep instead of comparing one number to a
|
> a level. That is why the gate fits a curve across a sweep instead of comparing one number to a
|
||||||
> threshold.
|
> threshold.
|
||||||
|
|
||||||
|
> **Second correction, same day — THE ALLOCATION GATE ALONE WOULD HAVE MISSED THE REAL BUG.**
|
||||||
|
>
|
||||||
|
> el #132 found the actual elc quadratic: `strlen()` called inside `str_char_code()` and
|
||||||
|
> `str_slice()`, so the lexer rescanned the remaining input on every character. Pure CPU.
|
||||||
|
> **Zero allocation.** `str_char_code` is a bounds check and an index — it allocates nothing.
|
||||||
|
>
|
||||||
|
> Measured on three controlled specimens (`lang/.work/fitprobe.el`), growth ratio per doubling of
|
||||||
|
> n across n = 200/400/800/1600:
|
||||||
|
>
|
||||||
|
> | specimen | allocs | bytes | time | what it proves |
|
||||||
|
> |---|---|---|---|---|
|
||||||
|
> | `linear` — one alloc per item | 2.00 2.00 2.00 → **O(n)** | 2.16 2.07 2.23 → **O(n)** | 0.83 2.00 2.05 → **O(n)** | clean baseline |
|
||||||
|
> | `accum` — rebuilds accumulator | 2.00 2.00 2.00 → **O(n)** | 3.97 3.99 3.99 → **O(n²)** | noisy | count misses, **bytes catches** |
|
||||||
|
> | `compute` — n scans over n chars | 0 → **FLAT** | 0 → **FLAT** | 3.93 4.01 3.96 → **O(n²)** | **both alloc signals blind; only time catches** |
|
||||||
|
>
|
||||||
|
> `compute` is el #132's shape exactly. A gate fitting only allocation count and bytes classifies
|
||||||
|
> it as FLAT and passes it. **The gate as originally specified would not have caught the defect it
|
||||||
|
> was created for.**
|
||||||
|
>
|
||||||
|
> Therefore the gate fits **THREE** signals and fails if ANY exceeds its declared curve:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> bench "elc_compile" over n in [...] expect time O(n) allocs O(n) bytes O(n) { ... }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> - **allocs (count)** — deterministic, zero-noise. Catches per-item allocation growth.
|
||||||
|
> - **allocs (bytes)** — deterministic, zero-noise. Catches accumulator-rebuild quadratics that
|
||||||
|
> count cannot see.
|
||||||
|
> - **time** — noisy, needs the sweep and statistics. The ONLY signal that sees pure-compute
|
||||||
|
> complexity regressions. Gate on the fitted *exponent*, never on absolute duration, so CI
|
||||||
|
> hardware variance scales the coefficient and leaves the classification intact.
|
||||||
|
>
|
||||||
|
> The deterministic signals remain preferable where they apply — they need no statistics and are
|
||||||
|
> correct on the first run. They are simply not sufficient.
|
||||||
|
>
|
||||||
|
> **`black_box` is mandatory, and consuming the result is NOT enough.** The first version of
|
||||||
|
> `compute` accumulated `total + 1` in a nested loop and reported **0 µs at every n** while
|
||||||
|
> returning a numerically correct n². Clang recognised the idiom and closed the loop to a
|
||||||
|
> multiply. Feeding the result into output did not prevent it. Only making the inner operation an
|
||||||
|
> opaque external call restored the real curve. A benchmark harness that trusts the user to defeat
|
||||||
|
> the optimiser will silently measure nothing — and report success while doing it.
|
||||||
|
|
||||||
Instrument the runtime with allocation counters and fit *those* against n instead of time:
|
Instrument the runtime with allocation counters and fit *those* against n instead of time:
|
||||||
|
|
||||||
```el
|
```el
|
||||||
|
|||||||
+26
-1
@@ -288,6 +288,27 @@ fn route_create_node(method: String, path: String, body: String) -> String {
|
|||||||
salience, importance, confidence,
|
salience, importance, confidence,
|
||||||
tier, tags
|
tier, tags
|
||||||
)
|
)
|
||||||
|
// GEOMETRY INGEST (2026-08-16 self-review): this route accepted an "emb"
|
||||||
|
// field, returned 200 with a fresh id, and stored NOTHING — engram_node_full
|
||||||
|
// has no vector parameter, so the caller's geometry was silently discarded
|
||||||
|
// and the node came back emb_dim=None / embedded:false. Measured live while
|
||||||
|
// trying to admit a voice signal. The consequence was structural, not
|
||||||
|
// cosmetic: text was the only entry medium, so any non-text modality had to
|
||||||
|
// be DESCRIBED in prose and what we then reasoned over was the geometry of
|
||||||
|
// the description, not of the signal.
|
||||||
|
//
|
||||||
|
// "emb" is little-endian float32 hex (dim*8 chars) — the encoding the
|
||||||
|
// perception vessel's /voice/embed already emits, so a realizer's output
|
||||||
|
// moves in with no float-array round trip. "dim" defaults to the vector's
|
||||||
|
// implied width. Off-dimension vectors are stored but not inserted into the
|
||||||
|
// resident index (its build loop filters on emb_dim), so a modality vector
|
||||||
|
// is durable and addressable without perturbing the canonical index.
|
||||||
|
let emb_hex: String = json_get_string(body, "emb")
|
||||||
|
let emb_set: Int = if str_eq(emb_hex, "") { 0 } else {
|
||||||
|
let dim_raw: String = json_get_raw(body, "dim")
|
||||||
|
let dim: Int = if str_eq(dim_raw, "") { str_len(emb_hex) / 8 } else { json_get_int(body, "dim") }
|
||||||
|
engram_node_set_emb(id, emb_hex, dim)
|
||||||
|
}
|
||||||
let saved: Int = persist_node(id)
|
let saved: Int = persist_node(id)
|
||||||
// ORPHAN PREVENTION (ENGRAM_AUTOCONNECT): connect the fresh node to its
|
// ORPHAN PREVENTION (ENGRAM_AUTOCONNECT): connect the fresh node to its
|
||||||
// nearest embedded neighbors so it never enters the graph edgeless.
|
// nearest embedded neighbors so it never enters the graph edgeless.
|
||||||
@@ -298,7 +319,11 @@ fn route_create_node(method: String, path: String, body: String) -> String {
|
|||||||
if added > 0 { let sv2: Int = persist_edges_since(ec0) }
|
if added > 0 { let sv2: Int = persist_edges_since(ec0) }
|
||||||
added
|
added
|
||||||
} else { 0 }
|
} else { 0 }
|
||||||
"{\"id\":\"" + id + "\",\"content\":\"" + content + "\",\"node_type\":\"" + node_type + "\",\"connected\":" + int_to_str(connected) + "}"
|
// Report whether the supplied geometry actually landed. The old response
|
||||||
|
// was success-shaped regardless — 200 with an id while the vector was
|
||||||
|
// discarded — which is how the drop went unnoticed. A caller can now
|
||||||
|
// assert on emb_set instead of trusting the status code.
|
||||||
|
"{\"id\":\"" + id + "\",\"content\":\"" + content + "\",\"node_type\":\"" + node_type + "\",\"connected\":" + int_to_str(connected) + ",\"emb_set\":" + int_to_str(emb_set) + "}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn route_get_node(method: String, path: String, body: String) -> String {
|
fn route_get_node(method: String, path: String, body: String) -> String {
|
||||||
|
|||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run_vindex_concurrency_tests.sh — regression harness for the 2026-08-16 soul crash.
|
||||||
|
#
|
||||||
|
# Four halves. The SET is the point: it separates two hazards the original two-half
|
||||||
|
# version conflated, and which have fixes in different files.
|
||||||
|
#
|
||||||
|
# 1. single ASan+UBSan, one thread. MUST be clean. Hard failure.
|
||||||
|
#
|
||||||
|
# 2. readers TSan, N readers, NO writer. Hazard (a): the visited set used
|
||||||
|
# to live on the index, so two pure READS stamped each other's
|
||||||
|
# epoch. Fixed in engram_vindex.c (frame-owned VVisit +
|
||||||
|
# `const VIndex*` search). MUST be clean. Hard failure.
|
||||||
|
#
|
||||||
|
# 3. unsynchronized TSan, writer + reader on a BARE index. Hazard (b): in-place
|
||||||
|
# HNSW insert rewires existing elements' neighbour lists and
|
||||||
|
# reallocs elems[]. EXPECTED TO RACE, PERMANENTLY. This is not
|
||||||
|
# a bug to fix inside engram_vindex.c — it is the executable
|
||||||
|
# proof that a publication boundary must exist above it.
|
||||||
|
# Not a failure. If it ever goes CLEAN, the test stopped
|
||||||
|
# interleaving and half 4 is no longer meaningful either.
|
||||||
|
#
|
||||||
|
# 4. published TSan, owner + N readers through a publication boundary
|
||||||
|
# (rwlock: readers shared, owner exclusive) mirroring
|
||||||
|
# eg_vindex_view / eg_vindex_maintain in lang/runtime/el_runtime.c.
|
||||||
|
# MUST be clean, and all inserts must land. Hard failure.
|
||||||
|
#
|
||||||
|
# See test_vindex_concurrency.c for the full story (SIGSEGV at ASCII address
|
||||||
|
# "gramNode", heap corruption in xzm_realloc, etc).
|
||||||
|
#
|
||||||
|
# usage: run_vindex_concurrency_tests.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
RUNTIME="$(cd "$HERE/../../lang/runtime" && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
SRC="$HERE/test_vindex_concurrency.c"
|
||||||
|
VINDEX="$RUNTIME/engram_vindex.c"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
echo "== [1/4] single-threaded control under AddressSanitizer =="
|
||||||
|
cc -std=c11 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer \
|
||||||
|
-I"$RUNTIME" -o "$WORK/single" "$SRC" "$VINDEX" -lm || { echo "BUILD FAILED"; exit 2; }
|
||||||
|
if ASAN_OPTIONS=detect_leaks=0 "$WORK/single" single; then
|
||||||
|
echo " -> OK"
|
||||||
|
else
|
||||||
|
echo " -> FAIL: the single-threaded control must always be clean."
|
||||||
|
echo " If this fails the bug is NOT (only) concurrency — look for a real"
|
||||||
|
echo " out-of-bounds or lifetime error in engram_vindex.c."
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cc -std=c11 -g -O1 -fsanitize=thread -fno-omit-frame-pointer \
|
||||||
|
-I"$RUNTIME" -o "$WORK/conc" "$SRC" "$VINDEX" -lm || { echo "BUILD FAILED"; exit 2; }
|
||||||
|
|
||||||
|
# run_tsan <mode> <logfile>; echoes nothing, sets $tsan_raced
|
||||||
|
run_tsan() {
|
||||||
|
TSAN_OPTIONS="halt_on_error=0" "$WORK/conc" "$1" >"$2" 2>&1
|
||||||
|
tsan_rc=$?
|
||||||
|
if grep -q "ThreadSanitizer: data race" "$2"; then tsan_raced=1; else tsan_raced=0; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== [2/4] concurrent READERS, no writer (visited-set gate) =="
|
||||||
|
run_tsan readers "$WORK/readers.log"
|
||||||
|
if [ "$tsan_raced" = "1" ]; then
|
||||||
|
echo " -> REGRESSION: two concurrent reads still race."
|
||||||
|
grep -m1 -A6 "ThreadSanitizer: data race" "$WORK/readers.log" | sed 's/^/ /'
|
||||||
|
echo " The visited set was supposed to be owned by the call frame."
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " -> clean (concurrent reads are safe)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== [3/4] writer+reader on a BARE index (expected-race probe) =="
|
||||||
|
run_tsan unsynchronized "$WORK/unsync.log"
|
||||||
|
if [ "$tsan_raced" = "1" ]; then
|
||||||
|
echo " -> RACE DETECTED, as expected:"
|
||||||
|
grep -m1 -A4 "ThreadSanitizer: data race" "$WORK/unsync.log" | sed 's/^/ /'
|
||||||
|
echo " In-place HNSW insert mutates existing elements. Not fixable inside"
|
||||||
|
echo " engram_vindex.c — this is why the publication boundary exists."
|
||||||
|
else
|
||||||
|
echo " -> NOTE: no race reported. The probe did not interleave; half 4's"
|
||||||
|
echo " clean result proves less than it should. Investigate."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== [4/4] owner+readers through the publication boundary (boundary gate) =="
|
||||||
|
run_tsan published "$WORK/pub.log"
|
||||||
|
if [ "$tsan_raced" = "1" ]; then
|
||||||
|
echo " -> REGRESSION: the publication boundary did not serialize the owner."
|
||||||
|
grep -m1 -A6 "ThreadSanitizer: data race" "$WORK/pub.log" | sed 's/^/ /'
|
||||||
|
fail=1
|
||||||
|
elif [ "$tsan_rc" != "0" ]; then
|
||||||
|
echo " -> FAIL: boundary clean under TSan but the run failed:"
|
||||||
|
tail -3 "$WORK/pub.log" | sed 's/^/ /'
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " -> clean (readers project concurrently; the owner's inserts all landed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
[ "$fail" -eq 0 ] && echo "RESULT: PASS" || echo "RESULT: FAIL"
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/* test_vindex_concurrency.c — regression test for the 2026-08-16 soul crash.
|
||||||
|
*
|
||||||
|
* WHAT BROKE: the soul daemon crash-looped (5 crashes in ~100s) with SIGSEGV in
|
||||||
|
* search_layer <- vindex_insert <- eg_vindex_sync, a SIGABRT, and a fault inside
|
||||||
|
* xzm_realloc's own freelist — i.e. heap corruption. The SIGSEGV address
|
||||||
|
* 0x65646f4e6d617267 is little-endian ASCII "gramNode": string bytes being
|
||||||
|
* dereferenced as an Elem vector pointer.
|
||||||
|
*
|
||||||
|
* ROOT CAUSE: VIndex owns its traversal scratch (visited[] + visit_epoch), and
|
||||||
|
* search_layer mutates it via visited_reset(). So the index is unsafe for ANY
|
||||||
|
* concurrent use — including two concurrent READS. soul.el starts http_serve_async
|
||||||
|
* (a thread per connection) and then runs awareness_run() on the main thread, which
|
||||||
|
* reaches the same global index through engram_activate; nothing serialized them.
|
||||||
|
*
|
||||||
|
* Neither hnswlib nor FAISS puts the visited set on the index: hnswlib checks one
|
||||||
|
* out of a VisitedListPool per query, FAISS uses a thread_local VisitedTable.
|
||||||
|
*
|
||||||
|
* THE ORIGINAL `concurrent` HALF CONFLATED TWO DISTINCT HAZARDS (2026-08-16). It ran
|
||||||
|
* a writer against a reader on one bare index, so it could not tell apart:
|
||||||
|
*
|
||||||
|
* (a) READ/READ corruption — two searches stamping each other's visited epoch.
|
||||||
|
* A defect INSIDE engram_vindex.c, fixable there, and now fixed: the visited
|
||||||
|
* set moved to the call frame and vindex_search takes a `const VIndex*`.
|
||||||
|
*
|
||||||
|
* (b) WRITE/READ corruption — vindex_insert rewires the neighbour lists of
|
||||||
|
* EXISTING elements and reallocs elems[], so an insert is a mutation of the
|
||||||
|
* whole structure. This is NOT fixable inside engram_vindex.c at any price:
|
||||||
|
* it is inherent to in-place HNSW. It requires a publication boundary ABOVE
|
||||||
|
* the data structure (el_runtime.c: eg_vindex_view / eg_vindex_maintain).
|
||||||
|
*
|
||||||
|
* Conflating them made the suite unfailable-then-unpassable: fixing (a) left (b)
|
||||||
|
* still racing, which reads as "the fix did not work" when in fact a different,
|
||||||
|
* correctly-located fix is what (b) needs. So the halves are now separate:
|
||||||
|
*
|
||||||
|
* single N clustered vectors, ONE thread, ASan. The CONTROL. Must always
|
||||||
|
* be clean. When this passes and a concurrent half fails, the defect
|
||||||
|
* is concurrency, not an out-of-bounds/logic error in the graph code.
|
||||||
|
* (On 2026-08-16 this control cleared all 13,820 real dim-768 store
|
||||||
|
* vectors under ASan, which DISPROVED an inspection-derived hypothesis
|
||||||
|
* about an out-of-bounds reverse-link write at engram_vindex.c:340.)
|
||||||
|
*
|
||||||
|
* readers N reader threads, NO writer, one shared index, TSan. This is
|
||||||
|
* hazard (a) in isolation. It RACED before the visited set moved off
|
||||||
|
* the index struct and must be CLEAN now. Hard gate.
|
||||||
|
*
|
||||||
|
* unsynchronized writer + reader on a bare index, TSan. Hazard (b) in isolation.
|
||||||
|
* EXPECTED TO RACE, permanently — it is the executable proof that
|
||||||
|
* the index cannot be made safe from the inside, and therefore that
|
||||||
|
* the publication boundary in el_runtime.c has to exist. If this
|
||||||
|
* ever goes clean, the test stopped interleaving; do not celebrate.
|
||||||
|
*
|
||||||
|
* published writer + readers through a publication boundary that mirrors
|
||||||
|
* eg_vindex_view / eg_vindex_maintain (rwlock: readers shared,
|
||||||
|
* the single owner exclusive), TSan. Must be CLEAN. Hard gate.
|
||||||
|
* This is what proves the shape of the runtime fix, in the same
|
||||||
|
* process, rather than asserting it.
|
||||||
|
*
|
||||||
|
* Absence of a crash does NOT mean absence of a race — always read the sanitizer
|
||||||
|
* verdict, never just the exit code.
|
||||||
|
*
|
||||||
|
* Build/run: engram/test/run_vindex_concurrency_tests.sh
|
||||||
|
*/
|
||||||
|
#include "engram_vindex.h"
|
||||||
|
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define DIM 128
|
||||||
|
#define NVEC 3000
|
||||||
|
#define SEED_N 50
|
||||||
|
|
||||||
|
static VIndex* g_ix;
|
||||||
|
static float* g_vecs;
|
||||||
|
|
||||||
|
/* Deterministic filler. Real embeddings are strongly correlated, not uniform noise;
|
||||||
|
* clustering keeps many candidates near-equidistant, which exercises the diversity
|
||||||
|
* heuristic and the visited set far harder than random vectors do. */
|
||||||
|
static void fill_vectors(void) {
|
||||||
|
g_vecs = (float*)malloc((size_t)NVEC * DIM * sizeof(float));
|
||||||
|
if (!g_vecs) { fprintf(stderr, "OOM\n"); exit(1); }
|
||||||
|
for (int i = 0; i < NVEC; i++) {
|
||||||
|
int cluster = i % 8;
|
||||||
|
for (int d = 0; d < DIM; d++)
|
||||||
|
g_vecs[(size_t)i * DIM + d] =
|
||||||
|
(float)(((d + cluster * 7) % 13) / 13.0) +
|
||||||
|
(float)(((i * 2654435761u + (unsigned)d) % 97) / 9700.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void* writer_fn(void* arg) {
|
||||||
|
(void)arg;
|
||||||
|
for (int i = SEED_N; i < NVEC; i++)
|
||||||
|
(void)vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void* reader_fn(void* arg) {
|
||||||
|
(void)arg;
|
||||||
|
uint64_t ids[8]; float ds[8];
|
||||||
|
for (int i = 0; i < 20000; i++)
|
||||||
|
(void)vindex_search(g_ix, g_vecs + (size_t)(i % NVEC) * DIM, 8, 0, ids, ds);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run_single(void) {
|
||||||
|
printf("[single] inserting %d vectors on one thread (ASan control)\n", NVEC);
|
||||||
|
g_ix = vindex_create(DIM, 0, 0);
|
||||||
|
if (!g_ix) { fprintf(stderr, "[single] vindex_create failed\n"); return 1; }
|
||||||
|
for (int i = 0; i < NVEC; i++) {
|
||||||
|
if (vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM) != 0) {
|
||||||
|
fprintf(stderr, "[single] insert %d failed\n", i); return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vindex_size(g_ix) != (size_t)NVEC) {
|
||||||
|
fprintf(stderr, "[single] size %zu != %d\n", vindex_size(g_ix), NVEC); return 1;
|
||||||
|
}
|
||||||
|
uint64_t ids[16]; float ds[16];
|
||||||
|
for (int q = 0; q < 200; q++) {
|
||||||
|
int k = vindex_search(g_ix, g_vecs + (size_t)((q * 7) % NVEC) * DIM, 16, 0, ids, ds);
|
||||||
|
if (k < 0) { fprintf(stderr, "[single] search failed at q=%d\n", q); return 1; }
|
||||||
|
}
|
||||||
|
vindex_free(g_ix); g_ix = NULL;
|
||||||
|
printf("[single] PASS — no memory error (this must ALWAYS pass)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hazard (b) in isolation: writer + reader on a BARE index, no boundary. */
|
||||||
|
static int run_unsynchronized(void) {
|
||||||
|
printf("[unsynchronized] 1 writer + 1 reader on a BARE index (TSan probe)\n");
|
||||||
|
printf("[unsynchronized] a race here is EXPECTED and PERMANENT — in-place HNSW\n");
|
||||||
|
printf("[unsynchronized] insert rewires existing elements. This is the proof that\n");
|
||||||
|
printf("[unsynchronized] the publication boundary must live ABOVE engram_vindex.c.\n");
|
||||||
|
g_ix = vindex_create(DIM, 0, 0);
|
||||||
|
if (!g_ix) { fprintf(stderr, "[unsynchronized] vindex_create failed\n"); return 1; }
|
||||||
|
for (int i = 0; i < SEED_N; i++)
|
||||||
|
(void)vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM);
|
||||||
|
|
||||||
|
pthread_t w, r;
|
||||||
|
if (pthread_create(&w, NULL, writer_fn, NULL) ||
|
||||||
|
pthread_create(&r, NULL, reader_fn, NULL)) {
|
||||||
|
fprintf(stderr, "[unsynchronized] pthread_create failed\n"); return 1;
|
||||||
|
}
|
||||||
|
pthread_join(w, NULL);
|
||||||
|
pthread_join(r, NULL);
|
||||||
|
vindex_free(g_ix); g_ix = NULL;
|
||||||
|
printf("[unsynchronized] completed — CHECK THE SANITIZER VERDICT, not this line.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── hazard (a) in isolation: concurrent READS only ───────────────────────────
|
||||||
|
* This is what the frame-owned visited set fixes. Before that change, two
|
||||||
|
* vindex_search calls on one index wrote each other's epoch stamp; TSan reported
|
||||||
|
* the race at visited_reset and the traversal then walked bogus element indices. */
|
||||||
|
#define NREADERS 4
|
||||||
|
|
||||||
|
static int run_readers(void) {
|
||||||
|
printf("[readers] %d concurrent readers, NO writer, one shared index (TSan)\n", NREADERS);
|
||||||
|
printf("[readers] this is the visited-set regression gate — must be CLEAN.\n");
|
||||||
|
g_ix = vindex_create(DIM, 0, 0);
|
||||||
|
if (!g_ix) { fprintf(stderr, "[readers] vindex_create failed\n"); return 1; }
|
||||||
|
for (int i = 0; i < NVEC; i++)
|
||||||
|
(void)vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM);
|
||||||
|
|
||||||
|
pthread_t t[NREADERS];
|
||||||
|
for (int i = 0; i < NREADERS; i++)
|
||||||
|
if (pthread_create(&t[i], NULL, reader_fn, NULL)) {
|
||||||
|
fprintf(stderr, "[readers] pthread_create failed\n"); return 1;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < NREADERS; i++) pthread_join(t[i], NULL);
|
||||||
|
vindex_free(g_ix); g_ix = NULL;
|
||||||
|
printf("[readers] completed — CHECK THE SANITIZER VERDICT, not this line.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the publication boundary, mirroring el_runtime.c ─────────────────────────
|
||||||
|
* Readers take the boundary SHARED and hold it across the whole search; the one
|
||||||
|
* owner takes it EXCLUSIVE to extend. Same shape as eg_vindex_view /
|
||||||
|
* eg_vindex_maintain. Note the reader's index pointer is `const VIndex*` — the
|
||||||
|
* compiler, not this comment, is what stops a reader inserting. */
|
||||||
|
static pthread_rwlock_t g_pub = PTHREAD_RWLOCK_INITIALIZER;
|
||||||
|
|
||||||
|
static void* pub_writer_fn(void* arg) {
|
||||||
|
(void)arg;
|
||||||
|
for (int i = SEED_N; i < NVEC; i++) {
|
||||||
|
pthread_rwlock_wrlock(&g_pub);
|
||||||
|
(void)vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM);
|
||||||
|
pthread_rwlock_unlock(&g_pub);
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void* pub_reader_fn(void* arg) {
|
||||||
|
(void)arg;
|
||||||
|
uint64_t ids[8]; float ds[8];
|
||||||
|
for (int i = 0; i < 5000; i++) {
|
||||||
|
pthread_rwlock_rdlock(&g_pub);
|
||||||
|
const VIndex* view = g_ix; /* immutable view */
|
||||||
|
(void)vindex_search(view, g_vecs + (size_t)(i % NVEC) * DIM, 8, 0, ids, ds);
|
||||||
|
pthread_rwlock_unlock(&g_pub);
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run_published(void) {
|
||||||
|
printf("[published] 1 owner + %d readers through a publication boundary (TSan)\n", NREADERS);
|
||||||
|
printf("[published] this is the eg_vindex_view/eg_vindex_maintain gate — must be CLEAN.\n");
|
||||||
|
g_ix = vindex_create(DIM, 0, 0);
|
||||||
|
if (!g_ix) { fprintf(stderr, "[published] vindex_create failed\n"); return 1; }
|
||||||
|
for (int i = 0; i < SEED_N; i++)
|
||||||
|
(void)vindex_insert(g_ix, (uint64_t)i, g_vecs + (size_t)i * DIM);
|
||||||
|
|
||||||
|
pthread_t w, r[NREADERS];
|
||||||
|
if (pthread_create(&w, NULL, pub_writer_fn, NULL)) {
|
||||||
|
fprintf(stderr, "[published] pthread_create failed\n"); return 1;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < NREADERS; i++)
|
||||||
|
if (pthread_create(&r[i], NULL, pub_reader_fn, NULL)) {
|
||||||
|
fprintf(stderr, "[published] pthread_create failed\n"); return 1;
|
||||||
|
}
|
||||||
|
pthread_join(w, NULL);
|
||||||
|
for (int i = 0; i < NREADERS; i++) pthread_join(r[i], NULL);
|
||||||
|
if (vindex_size(g_ix) != (size_t)NVEC) {
|
||||||
|
fprintf(stderr, "[published] size %zu != %d — the owner lost inserts\n",
|
||||||
|
vindex_size(g_ix), NVEC);
|
||||||
|
vindex_free(g_ix); g_ix = NULL; return 1;
|
||||||
|
}
|
||||||
|
vindex_free(g_ix); g_ix = NULL;
|
||||||
|
printf("[published] all %d inserts landed; CHECK THE SANITIZER VERDICT too.\n", NVEC);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
const char* mode = (argc > 1) ? argv[1] : "single";
|
||||||
|
fill_vectors();
|
||||||
|
int rc;
|
||||||
|
if (!strcmp(mode, "single")) rc = run_single();
|
||||||
|
else if (!strcmp(mode, "readers")) rc = run_readers();
|
||||||
|
else if (!strcmp(mode, "unsynchronized")) rc = run_unsynchronized();
|
||||||
|
else if (!strcmp(mode, "published")) rc = run_published();
|
||||||
|
/* back-compat: the pre-split name meant the bare writer+reader probe. */
|
||||||
|
else if (!strcmp(mode, "concurrent")) rc = run_unsynchronized();
|
||||||
|
else {
|
||||||
|
fprintf(stderr, "usage: %s [single|readers|unsynchronized|published]\n", argv[0]);
|
||||||
|
rc = 2;
|
||||||
|
}
|
||||||
|
free(g_vecs);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
@@ -862,10 +862,23 @@ fn cg_expr(expr: Map<String, Any>) -> String {
|
|||||||
// arithmetic BinOp (or vice-versa). Without this check the
|
// arithmetic BinOp (or vice-versa). Without this check the
|
||||||
// fallthrough to str_eq produces str_eq(int_value, int_value)
|
// fallthrough to str_eq produces str_eq(int_value, int_value)
|
||||||
// which reads the integer as a char* and segfaults.
|
// 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(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
|
// Float literal or negative float literal: use plain == (bit-equal
|
||||||
// el_val_t comparison). This handles `r0 == 3.0`, `neg == -3.0`, etc.
|
// 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
|
// Same mixed Ident/BinOp fix as EqEq: use is_int_expr to detect
|
||||||
// integer-typed operands before falling through to !str_eq.
|
// 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(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).
|
// Float-typed operands use plain != (bit-equal comparison).
|
||||||
if is_float_expr(left) {
|
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") {
|
if str_eq(ltype, "Int") {
|
||||||
add_int_name(name)
|
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") {
|
if str_eq(ltype, "Float") {
|
||||||
add_float_name(name)
|
add_float_name(name)
|
||||||
}
|
}
|
||||||
@@ -2887,6 +2907,7 @@ fn builtin_arity(name: String) -> Int {
|
|||||||
if str_eq(name, "el_alloc_count") { return 0 }
|
if str_eq(name, "el_alloc_count") { return 0 }
|
||||||
if str_eq(name, "el_alloc_bytes") { return 0 }
|
if str_eq(name, "el_alloc_bytes") { return 0 }
|
||||||
if str_eq(name, "el_peak_rss") { return 0 }
|
if str_eq(name, "el_peak_rss") { return 0 }
|
||||||
|
if str_eq(name, "el_black_box") { return 1 }
|
||||||
if str_eq(name, "engram_neighbors_json") { return 3 }
|
if str_eq(name, "engram_neighbors_json") { return 3 }
|
||||||
if str_eq(name, "engram_activate_json") { return 2 }
|
if str_eq(name, "engram_activate_json") { return 2 }
|
||||||
if str_eq(name, "engram_stats_json") { return 0 }
|
if str_eq(name, "engram_stats_json") { return 0 }
|
||||||
@@ -3112,6 +3133,15 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
|
|||||||
if str_eq(ptype, "Int") {
|
if str_eq(ptype, "Int") {
|
||||||
add_int_name(pname)
|
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") {
|
if str_eq(ptype, "Float") {
|
||||||
add_float_name(pname)
|
add_float_name(pname)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,6 +419,22 @@ fn resolve_imports(src_path: String) -> String {
|
|||||||
if !str_eq(already, "") { return "" }
|
if !str_eq(already, "") { return "" }
|
||||||
state_set(seen_key, "1")
|
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 source: String = fs_read(src_path)
|
||||||
let dir: String = dirname_of(src_path)
|
let dir: String = dirname_of(src_path)
|
||||||
let lines: [String] = str_split(source, "\n")
|
let lines: [String] = str_split(source, "\n")
|
||||||
|
|||||||
+576
-50
@@ -1600,8 +1600,64 @@ typedef struct {
|
|||||||
* no longer blocks ingest/reads (measured: non-health latency during a beat
|
* no longer blocks ingest/reads (measured: non-health latency during a beat
|
||||||
* 13.9s → sub-second). */
|
* 13.9s → sub-second). */
|
||||||
static pthread_mutex_t g_engram_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_engram_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||||
void engram_req_unlock(void){ pthread_mutex_unlock(&g_engram_req_lock); }
|
|
||||||
void engram_req_lock(void){ pthread_mutex_lock(&g_engram_req_lock); }
|
/* ── AWARENESS-THREAD GUARD (2026-08-16 self-review) ─────────────────────────
|
||||||
|
* The request lock above serialized http_worker threads against EACH OTHER, but
|
||||||
|
* the soul daemon has a SECOND, unsynchronized engram caller: soul.el starts the
|
||||||
|
* HTTP server with http_serve_async (spawning worker threads) and then runs
|
||||||
|
* awareness_run() on the MAIN thread, whose perceive() -> engram_activate_json()
|
||||||
|
* -> engram_activate() -> eg_vindex_sync() path mutates the very same RAM graph
|
||||||
|
* and the process-global _eg_vindex HNSW index. Nothing in any .el source ever
|
||||||
|
* called engram_req_lock, so that whole loop ran lock-free beside the workers.
|
||||||
|
*
|
||||||
|
* Measured consequence (2026-08-16): five crashes in ~4 minutes, all one bug —
|
||||||
|
* SIGSEGV in search_layer<-vindex_insert<-eg_vindex_sync at address
|
||||||
|
* 0x65646f4e6d617267 (little-endian ASCII "gramNode": a string being
|
||||||
|
* dereferenced as an Elem vector pointer), plus a SIGABRT and a fault inside
|
||||||
|
* xzm_realloc's freelist, i.e. corrupted allocator metadata. Confirmed by
|
||||||
|
* bisection: replaying ALL 13,820 real dim-768 store vectors through the index
|
||||||
|
* single-threaded under ASan is 100% clean, while two threads on one index trip
|
||||||
|
* ThreadSanitizer instantly at engram_vindex.c:195 (visited_reset) — VIndex keeps
|
||||||
|
* a SHARED visited-epoch scratch buffer, so even two concurrent READS stomp each
|
||||||
|
* other's traversal state and walk bogus element indices. So this is purely a
|
||||||
|
* concurrency defect, not a logic error in the HNSW code.
|
||||||
|
*
|
||||||
|
* Fix: a thread-local ownership depth lets engram entry points self-guard. A call
|
||||||
|
* arriving on the awareness thread (depth 0) acquires the lock; one arriving from
|
||||||
|
* inside an http_worker that already holds it (depth > 0) is a no-op, so there is
|
||||||
|
* no self-deadlock on this NON-recursive mutex. Depth is a plain counter, never a
|
||||||
|
* recursive-mutex count, which preserves engram_self_reify_beat_json's contract of
|
||||||
|
* really releasing the lock mid-beat (see engram_req_unlock at the reify beat).
|
||||||
|
*
|
||||||
|
* SCOPE NARROWED (2026-08-16, vindex publication boundary): this guard originally
|
||||||
|
* covered TWO hazards — the RAM graph AND the process-global _eg_vindex. The vindex
|
||||||
|
* half is retired: the index now has its own publication boundary (_eg_vindex_rw),
|
||||||
|
* search takes a `const VIndex*`, and no read path can mutate the index at all.
|
||||||
|
*
|
||||||
|
* What REMAINS load-bearing here is the RAM graph alone, and it is a genuine,
|
||||||
|
* measured hazard independent of the index: g->nodes / g->edges are realloc'd in
|
||||||
|
* place (el_runtime.c:7618, 7629), so an awareness-thread reader holding
|
||||||
|
* `EngramNode* n = &g->nodes[i]` across a concurrent append from an http_worker
|
||||||
|
* holds a dangling pointer — and engram_activate_inner's embed-backfill WRITES
|
||||||
|
* n->emb through exactly such a pointer. That is a separate residue with its own
|
||||||
|
* fix (the resident graph wants the same publication treatment the index just got);
|
||||||
|
* until it lands, this guard stays. Do NOT delete it as "the fb32d15 vindex lock". */
|
||||||
|
static __thread int _eg_req_depth = 0;
|
||||||
|
void engram_req_unlock(void){ if(_eg_req_depth > 0) _eg_req_depth--; pthread_mutex_unlock(&g_engram_req_lock); }
|
||||||
|
void engram_req_lock(void){ pthread_mutex_lock(&g_engram_req_lock); _eg_req_depth++; }
|
||||||
|
/* Acquire only if this thread does not already hold the request lock.
|
||||||
|
* Returns 1 if this call took ownership (caller must release), 0 if nested. */
|
||||||
|
static int eg_guard_enter(void){
|
||||||
|
if (_eg_req_depth > 0) return 0;
|
||||||
|
pthread_mutex_lock(&g_engram_req_lock);
|
||||||
|
_eg_req_depth++;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
static void eg_guard_exit(int owned){
|
||||||
|
if (!owned) return;
|
||||||
|
if (_eg_req_depth > 0) _eg_req_depth--;
|
||||||
|
pthread_mutex_unlock(&g_engram_req_lock);
|
||||||
|
}
|
||||||
|
|
||||||
static void* http_worker(void* arg) {
|
static void* http_worker(void* arg) {
|
||||||
HttpWorkerArg* a = (HttpWorkerArg*)arg;
|
HttpWorkerArg* a = (HttpWorkerArg*)arg;
|
||||||
@@ -1642,7 +1698,7 @@ static void* http_worker(void* arg) {
|
|||||||
(plen == 1 && path[0] == '/'))
|
(plen == 1 && path[0] == '/'))
|
||||||
health_exempt = 1;
|
health_exempt = 1;
|
||||||
}
|
}
|
||||||
if (!health_exempt) pthread_mutex_lock(&g_engram_req_lock);
|
if (!health_exempt) engram_req_lock(); /* tracks _eg_req_depth for eg_guard_enter */
|
||||||
if (h) {
|
if (h) {
|
||||||
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), EL_STR(body));
|
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), EL_STR(body));
|
||||||
const char* rs = EL_CSTR(r);
|
const char* rs = EL_CSTR(r);
|
||||||
@@ -1669,7 +1725,7 @@ static void* http_worker(void* arg) {
|
|||||||
}
|
}
|
||||||
/* end of the engram critical section — the response is now a private malloc'd
|
/* end of the engram critical section — the response is now a private malloc'd
|
||||||
* copy; arena teardown + socket write touch no shared engram state. */
|
* copy; arena teardown + socket write touch no shared engram state. */
|
||||||
if (!health_exempt) pthread_mutex_unlock(&g_engram_req_lock);
|
if (!health_exempt) engram_req_unlock();
|
||||||
el_request_end(); /* free all intermediate strings */
|
el_request_end(); /* free all intermediate strings */
|
||||||
_tl_http_head_only = head_only;
|
_tl_http_head_only = head_only;
|
||||||
http_send_response(fd, response);
|
http_send_response(fd, response);
|
||||||
@@ -3422,28 +3478,74 @@ static void jb_puts(JsonBuf* b, const char* s) {
|
|||||||
b->buf[b->len] = '\0';
|
b->buf[b->len] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UTF-8 VALIDITY IS THE EMITTER'S CONTRACT (2026-08-16 self-review).
|
||||||
|
*
|
||||||
|
* This copied every byte >= 0x20 through verbatim, so a malformed sequence
|
||||||
|
* anywhere in the store became malformed output. Measured against the live
|
||||||
|
* graph: three nodes carry labels truncated to exactly 80 bytes ending in a
|
||||||
|
* lone 0xE2 — the first byte of an em-dash, cut mid-sequence by some producer
|
||||||
|
* that is NOT this runtime (no 80-byte truncation exists here; the content
|
||||||
|
* itself is 2572 and 2746 bytes). Those three nodes made the ENTIRE 26 MB
|
||||||
|
* /api/nodes/list response undecodable, so a strict parser could not read the
|
||||||
|
* graph at all.
|
||||||
|
*
|
||||||
|
* Fixing only the writer would not have helped: the store already contains the
|
||||||
|
* damage, and it accepts data from importers, other producers and older
|
||||||
|
* binaries. A serializer that promises JSON owes valid UTF-8 regardless of what
|
||||||
|
* it is handed — so validate here, at the boundary that makes the promise.
|
||||||
|
* Invalid bytes become U+FFFD rather than being dropped, so damage stays
|
||||||
|
* visible in the output instead of being silently papered over.
|
||||||
|
*
|
||||||
|
* Well-formed input is byte-identical to before: valid sequences are copied
|
||||||
|
* verbatim, and only structurally invalid ones (bad lead byte, missing or bad
|
||||||
|
* continuation, overlong encoding, UTF-16 surrogate, or > U+10FFFF) are
|
||||||
|
* replaced. */
|
||||||
static void jb_emit_escaped(JsonBuf* b, const char* s) {
|
static void jb_emit_escaped(JsonBuf* b, const char* s) {
|
||||||
jb_putc(b, '"');
|
jb_putc(b, '"');
|
||||||
for (; *s; s++) {
|
const unsigned char* p = (const unsigned char*)s;
|
||||||
unsigned char c = (unsigned char)*s;
|
while (*p) {
|
||||||
|
unsigned char c = *p;
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case '"': jb_puts(b, "\\\""); break;
|
case '"': jb_puts(b, "\\\""); p++; continue;
|
||||||
case '\\': jb_puts(b, "\\\\"); break;
|
case '\\': jb_puts(b, "\\\\"); p++; continue;
|
||||||
case '\b': jb_puts(b, "\\b"); break;
|
case '\b': jb_puts(b, "\\b"); p++; continue;
|
||||||
case '\f': jb_puts(b, "\\f"); break;
|
case '\f': jb_puts(b, "\\f"); p++; continue;
|
||||||
case '\n': jb_puts(b, "\\n"); break;
|
case '\n': jb_puts(b, "\\n"); p++; continue;
|
||||||
case '\r': jb_puts(b, "\\r"); break;
|
case '\r': jb_puts(b, "\\r"); p++; continue;
|
||||||
case '\t': jb_puts(b, "\\t"); break;
|
case '\t': jb_puts(b, "\\t"); p++; continue;
|
||||||
default:
|
default: break;
|
||||||
if (c < 0x20) {
|
|
||||||
char tmp[8];
|
|
||||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
|
||||||
jb_puts(b, tmp);
|
|
||||||
} else {
|
|
||||||
jb_putc(b, (char)c);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (c < 0x20) {
|
||||||
|
char tmp[8];
|
||||||
|
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||||
|
jb_puts(b, tmp);
|
||||||
|
p++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c < 0x80) { jb_putc(b, (char)c); p++; continue; }
|
||||||
|
|
||||||
|
/* Multi-byte: validate the whole sequence before emitting any of it. */
|
||||||
|
int len; unsigned int cp;
|
||||||
|
if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1Fu; }
|
||||||
|
else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0Fu; }
|
||||||
|
else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07u; }
|
||||||
|
else { jb_puts(b, "\\ufffd"); p++; continue; }
|
||||||
|
|
||||||
|
int ok = 1;
|
||||||
|
for (int i = 1; i < len; i++) {
|
||||||
|
if ((p[i] & 0xC0) != 0x80) { ok = 0; break; } /* also catches NUL */
|
||||||
|
cp = (cp << 6) | (unsigned int)(p[i] & 0x3F);
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
if (len == 2 && cp < 0x80) ok = 0; /* overlong */
|
||||||
|
else if (len == 3 && cp < 0x800) ok = 0; /* overlong */
|
||||||
|
else if (len == 4 && cp < 0x10000) ok = 0; /* overlong */
|
||||||
|
else if (cp >= 0xD800 && cp <= 0xDFFF) ok = 0; /* UTF-16 surrogate */
|
||||||
|
else if (cp > 0x10FFFF) ok = 0; /* out of range */
|
||||||
|
}
|
||||||
|
if (!ok) { jb_puts(b, "\\ufffd"); p++; continue; }
|
||||||
|
for (int i = 0; i < len; i++) jb_putc(b, (char)p[i]);
|
||||||
|
p += len;
|
||||||
}
|
}
|
||||||
jb_putc(b, '"');
|
jb_putc(b, '"');
|
||||||
}
|
}
|
||||||
@@ -5168,10 +5270,23 @@ el_val_t state_get(el_val_t key) {
|
|||||||
if (!k) return el_wrap_str(el_strdup(""));
|
if (!k) return el_wrap_str(el_strdup(""));
|
||||||
pthread_mutex_lock(&_state_mu);
|
pthread_mutex_lock(&_state_mu);
|
||||||
StateEntry* e = state_find(k);
|
StateEntry* e = state_find(k);
|
||||||
char* result = el_strdup_persist(e ? e->value : "");
|
/* ONE arena-tracked copy, taken under the lock.
|
||||||
|
*
|
||||||
|
* This used to make TWO copies: an el_strdup_persist temporary, then an
|
||||||
|
* arena-tracked copy of that temporary. The persistent one was never
|
||||||
|
* returned and never freed — el_strdup_persist bypasses the arena by
|
||||||
|
* design ("state_set, engram internals"), so arena-pop could not reclaim
|
||||||
|
* it. Every state_get therefore leaked its full value string, permanently.
|
||||||
|
*
|
||||||
|
* The soul's awareness loop has 68 state_get call sites and ticks every
|
||||||
|
* 200ms; measured leak was ~1.1 MB per tick, about 19 GB/hour. It went
|
||||||
|
* unnoticed for as long as the soul restarted often enough to mask it.
|
||||||
|
*
|
||||||
|
* el_strdup tracks into the thread-local arena, which touches no shared
|
||||||
|
* state, so doing it under _state_mu is safe and removes the need for the
|
||||||
|
* temporary entirely. */
|
||||||
|
char* copy = el_strdup(e ? e->value : "");
|
||||||
pthread_mutex_unlock(&_state_mu);
|
pthread_mutex_unlock(&_state_mu);
|
||||||
/* wrap in arena-tracked copy for the caller's request lifetime */
|
|
||||||
char* copy = el_strdup(result);
|
|
||||||
return el_wrap_str(copy);
|
return el_wrap_str(copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5447,6 +5562,45 @@ el_val_t str_count(el_val_t sv, el_val_t subv) {
|
|||||||
return (el_val_t)count;
|
return (el_val_t)count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* el_utf8_safe_len — the largest byte length <= max_bytes that does NOT split a
|
||||||
|
* UTF-8 codepoint.
|
||||||
|
*
|
||||||
|
* WHY (2026-08-16 self-review): engram_first_n_chars truncated with a plain
|
||||||
|
* `if (l > n) l = n; memcpy(...)`, i.e. by BYTES despite its name. Any content
|
||||||
|
* carrying a multi-byte character across the 60-byte boundary produced a label
|
||||||
|
* ending in a half codepoint. That label is copied verbatim into every JSON
|
||||||
|
* document containing the node, so a single such node makes the WHOLE response
|
||||||
|
* invalid UTF-8 — /api/nodes/list failed to decode at byte 89261 against the
|
||||||
|
* live store, which breaks any strict parser reading the graph.
|
||||||
|
*
|
||||||
|
* This lives beside str_count_chars rather than in the engram because the rest
|
||||||
|
* of el's string layer is already codepoint-aware (str_count_chars counts
|
||||||
|
* codepoints, str_reverse walks codepoint lengths). Byte-truncation was the
|
||||||
|
* outlier, and the concern is a string concern. Bounded by BYTES, not
|
||||||
|
* codepoints, so existing labels never grow — only stop splitting.
|
||||||
|
*
|
||||||
|
* A lead byte with no room for its full sequence is dropped entirely; a stray
|
||||||
|
* continuation byte (already-invalid input) is passed through unchanged rather
|
||||||
|
* than silently repaired, so this never manufactures data. */
|
||||||
|
size_t el_utf8_safe_len(const char* s, size_t max_bytes) {
|
||||||
|
if (!s) return 0;
|
||||||
|
size_t len = strlen(s);
|
||||||
|
if (len <= max_bytes) return len;
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < max_bytes) {
|
||||||
|
unsigned char c = (unsigned char)s[i];
|
||||||
|
size_t cp_len;
|
||||||
|
if ((c & 0x80) == 0x00) cp_len = 1;
|
||||||
|
else if ((c & 0xE0) == 0xC0) cp_len = 2;
|
||||||
|
else if ((c & 0xF0) == 0xE0) cp_len = 3;
|
||||||
|
else if ((c & 0xF8) == 0xF0) cp_len = 4;
|
||||||
|
else cp_len = 1; /* stray continuation: passthrough */
|
||||||
|
if (i + cp_len > max_bytes) break; /* would split — stop before it */
|
||||||
|
i += cp_len;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
/* Codepoint count: walk bytes, count those NOT matching 10xxxxxx. */
|
/* Codepoint count: walk bytes, count those NOT matching 10xxxxxx. */
|
||||||
el_val_t str_count_chars(el_val_t sv) {
|
el_val_t str_count_chars(el_val_t sv) {
|
||||||
const char* s = EL_CSTR(sv);
|
const char* s = EL_CSTR(sv);
|
||||||
@@ -7645,10 +7799,14 @@ static double engram_decode_score(el_val_t v) {
|
|||||||
return (double)n;
|
return (double)n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Truncate to at most n BYTES without splitting a UTF-8 codepoint. The old
|
||||||
|
* implementation was `if (l > n) l = n;` — a byte cut that could land inside a
|
||||||
|
* multi-byte character and emit a half codepoint into the node's label, which
|
||||||
|
* then propagated into every JSON document containing that node. See
|
||||||
|
* el_utf8_safe_len for the measurement. */
|
||||||
static char* engram_first_n_chars(const char* s, size_t n) {
|
static char* engram_first_n_chars(const char* s, size_t n) {
|
||||||
if (!s) return el_strdup("");
|
if (!s) return el_strdup("");
|
||||||
size_t l = strlen(s);
|
size_t l = el_utf8_safe_len(s, n);
|
||||||
if (l > n) l = n;
|
|
||||||
char* out = el_strbuf(l);
|
char* out = el_strbuf(l);
|
||||||
memcpy(out, s, l);
|
memcpy(out, s, l);
|
||||||
out[l] = '\0';
|
out[l] = '\0';
|
||||||
@@ -8494,6 +8652,80 @@ el_val_t engram_node_count(void) {
|
|||||||
return (el_val_t)engram_get()->node_count;
|
return (el_val_t)engram_get()->node_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* engram_node_set_emb — attach GEOMETRY to an existing node.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS (2026-08-16). Until now no ingest path could carry a
|
||||||
|
* vector. engram_node / engram_node_full / engram_node_layered take text
|
||||||
|
* only, and the sole way a node acquired an embedding was
|
||||||
|
* engram_embed_backfill DERIVING one from n->content. That made text the
|
||||||
|
* mandatory entry medium: any non-text modality (audio, image, sensor)
|
||||||
|
* had to be described in prose first, and the geometry we then reasoned
|
||||||
|
* over was the geometry OF THE DESCRIPTION, not of the signal. Measured
|
||||||
|
* consequence: POST /api/nodes accepted an "emb" field, returned 200 with
|
||||||
|
* a fresh id, and stored emb_dim=None / embedded:false — the vector was
|
||||||
|
* silently discarded because no parameter existed to receive it.
|
||||||
|
*
|
||||||
|
* `hex` is little-endian float32, the encoding the perception vessel's
|
||||||
|
* /voice/embed already emits, so a realizer's output moves in without a
|
||||||
|
* JSON float-array round trip. Length must be exactly dim*8 hex chars.
|
||||||
|
*
|
||||||
|
* DIMENSION POLICY: dim need NOT equal the canonical text-embedding dim.
|
||||||
|
* A modality vector of a different width is stored and is simply not
|
||||||
|
* inserted into the resident HNSW index, whose build loop already filters
|
||||||
|
* on `n->emb_dim == dim`. So off-dimension geometry is durable and
|
||||||
|
* addressable without perturbing the canonical index.
|
||||||
|
*
|
||||||
|
* Setting emb also makes the node ineligible for embed_backfill (which
|
||||||
|
* only fills nodes with no emb), so a realizer's vector is never
|
||||||
|
* overwritten by a text-derived one.
|
||||||
|
*
|
||||||
|
* Returns 1 on success, 0 on unknown id / malformed hex / bad dim. */
|
||||||
|
el_val_t engram_node_set_emb(el_val_t id, el_val_t hex, el_val_t dim) {
|
||||||
|
const char* sid = EL_CSTR(id);
|
||||||
|
const char* sh = EL_CSTR(hex);
|
||||||
|
int32_t d = (int32_t)(int64_t)dim;
|
||||||
|
/* Bound the allocation. No max-dim constant existed because no caller
|
||||||
|
* could supply a dim before this function; 8192 is generous for any
|
||||||
|
* realizer (canonical text embeddings are 768, MFCC voice stats 64)
|
||||||
|
* while keeping a malformed `dim` from requesting an unbounded malloc. */
|
||||||
|
if (!sid || !*sid || !sh || d <= 0 || d > 8192) return (el_val_t)0;
|
||||||
|
|
||||||
|
size_t need = (size_t)d * 8u; /* 4 bytes → 8 hex chars per float */
|
||||||
|
if (strlen(sh) != need) return (el_val_t)0;
|
||||||
|
|
||||||
|
EngramNode* n = engram_find_node(sid);
|
||||||
|
if (!n) return (el_val_t)0;
|
||||||
|
|
||||||
|
float* v = (float*)malloc(sizeof(float) * (size_t)d);
|
||||||
|
if (!v) return (el_val_t)0;
|
||||||
|
|
||||||
|
for (int32_t i = 0; i < d; i++) {
|
||||||
|
uint32_t w = 0;
|
||||||
|
for (int k = 0; k < 8; k++) {
|
||||||
|
char c = sh[(size_t)i * 8u + (size_t)k];
|
||||||
|
uint32_t nib;
|
||||||
|
if (c >= '0' && c <= '9') nib = (uint32_t)(c - '0');
|
||||||
|
else if (c >= 'a' && c <= 'f') nib = (uint32_t)(c - 'a' + 10);
|
||||||
|
else if (c >= 'A' && c <= 'F') nib = (uint32_t)(c - 'A' + 10);
|
||||||
|
else { free(v); return (el_val_t)0; }
|
||||||
|
w = (w << 4) | nib;
|
||||||
|
}
|
||||||
|
/* Hex is emitted little-endian byte order; rebuild the word. */
|
||||||
|
uint32_t le = ((w & 0x000000FFu) << 24) | ((w & 0x0000FF00u) << 8) |
|
||||||
|
((w & 0x00FF0000u) >> 8) | ((w & 0xFF000000u) >> 24);
|
||||||
|
float f;
|
||||||
|
memcpy(&f, &le, sizeof(f));
|
||||||
|
v[i] = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
free(n->emb);
|
||||||
|
n->emb = v;
|
||||||
|
n->emb_dim = d;
|
||||||
|
n->updated_at = engram_now_ms();
|
||||||
|
if (engram_store_enabled()) eg_store_put_node(n);
|
||||||
|
return (el_val_t)1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Telemetry retention ────────────────────────────────────────────────────
|
/* ── Telemetry retention ────────────────────────────────────────────────────
|
||||||
* (2026-07-16 self-review) InternalStateEvent nodes are append-only telemetry
|
* (2026-07-16 self-review) InternalStateEvent nodes are append-only telemetry
|
||||||
* (heartbeat, curiosity_scan, engram_sync) written ~3/min by the awareness
|
* (heartbeat, curiosity_scan, engram_sync) written ~3/min by the awareness
|
||||||
@@ -9477,6 +9709,35 @@ static double engram_goal_bias(const EngramNode* n, const char* query) {
|
|||||||
* the exact O(n) argmax scan tops up any seed slot the ANN leaves unfilled.
|
* the exact O(n) argmax scan tops up any seed slot the ANN leaves unfilled.
|
||||||
* Single-threaded, matching the adjacent query-embedding cache (no lock).
|
* Single-threaded, matching the adjacent query-embedding cache (no lock).
|
||||||
* Returns NULL when no index is available → caller falls back to the O(n) scan. */
|
* Returns NULL when no index is available → caller falls back to the O(n) scan. */
|
||||||
|
/* ── VINDEX PUBLICATION BOUNDARY (2026-08-16) ────────────────────────────────
|
||||||
|
* The index is DERIVED GEOMETRY: a projection of the store's embeddings. The
|
||||||
|
* store is append-only and superseding, so a reader must be able to project
|
||||||
|
* against geometry that does not move under it.
|
||||||
|
*
|
||||||
|
* The HNSW index is NOT itself append-only: vindex_insert rewires the neighbour
|
||||||
|
* lists of ALREADY-EXISTING elements and reallocs elems[]. So "extend" is a
|
||||||
|
* mutation of the whole structure, and a reader holding element pointers across
|
||||||
|
* one is unsafe no matter how pure search itself is (measured: TSan reports the
|
||||||
|
* elems[] race even after the visited set moved to the call frame).
|
||||||
|
*
|
||||||
|
* Hence a publication boundary rather than an ownership discipline:
|
||||||
|
*
|
||||||
|
* - eg_vindex_maintain() is the ONLY mutator of the five statics below. It
|
||||||
|
* takes _eg_vindex_rw EXCLUSIVELY, so it never runs beside a reader.
|
||||||
|
* - eg_vindex_view() hands back a `const VIndex*` with the boundary held for
|
||||||
|
* READ. N readers project concurrently; none can mutate, because search
|
||||||
|
* takes a const index and the compiler enforces it.
|
||||||
|
*
|
||||||
|
* A read path may DEMAND that a current snapshot exist — that is a request to
|
||||||
|
* the owner, not a mutation by the reader. What it may not do is mutate the
|
||||||
|
* geometry it is projecting against. eg_vindex_view/eg_vindex_maintain is
|
||||||
|
* exactly that split.
|
||||||
|
*
|
||||||
|
* Lock ordering: request-outer -> vindex -> store-inner. The vindex boundary is
|
||||||
|
* never held across a call that can re-enter eg_vindex_view/maintain (verified:
|
||||||
|
* the four read regions each acquire, search, release without nesting). */
|
||||||
|
static pthread_rwlock_t _eg_vindex_rw = PTHREAD_RWLOCK_INITIALIZER;
|
||||||
|
|
||||||
static VIndex* _eg_vindex = NULL;
|
static VIndex* _eg_vindex = NULL;
|
||||||
static int32_t _eg_vindex_dim = 0;
|
static int32_t _eg_vindex_dim = 0;
|
||||||
static int64_t _eg_vindex_built_nc = 0; /* g->node_count at last (re)build */
|
static int64_t _eg_vindex_built_nc = 0; /* g->node_count at last (re)build */
|
||||||
@@ -9496,8 +9757,10 @@ static int eg_vindex_seen_ensure(int64_t need) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
|
/* THE OWNER. The only function that mutates _eg_vindex* — must be called with
|
||||||
if (!g || dim <= 0) return _eg_vindex;
|
* _eg_vindex_rw held EXCLUSIVELY (see eg_vindex_maintain, the sole caller). */
|
||||||
|
static void eg_vindex_publish_locked(EngramStore* g, int32_t dim) {
|
||||||
|
if (!g || dim <= 0) return;
|
||||||
/* Drop a stale index: embedder dim changed, or the resident array shrank
|
/* Drop a stale index: embedder dim changed, or the resident array shrank
|
||||||
* (indices may have been reused/reordered → cached node_ids unsafe). */
|
* (indices may have been reused/reordered → cached node_ids unsafe). */
|
||||||
if (_eg_vindex && (_eg_vindex_dim != dim || g->node_count < _eg_vindex_built_nc)) {
|
if (_eg_vindex && (_eg_vindex_dim != dim || g->node_count < _eg_vindex_built_nc)) {
|
||||||
@@ -9507,8 +9770,8 @@ static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
|
|||||||
}
|
}
|
||||||
if (!_eg_vindex) {
|
if (!_eg_vindex) {
|
||||||
VIndex* idx = vindex_create((int)dim, 0, 0);
|
VIndex* idx = vindex_create((int)dim, 0, 0);
|
||||||
if (!idx) return NULL;
|
if (!idx) return;
|
||||||
if (eg_vindex_seen_ensure(g->node_count)) { vindex_free(idx); return NULL; }
|
if (eg_vindex_seen_ensure(g->node_count)) { vindex_free(idx); return; }
|
||||||
for (int64_t i = 0; i < g->node_count; i++) {
|
for (int64_t i = 0; i < g->node_count; i++) {
|
||||||
EngramNode* n = &g->nodes[i];
|
EngramNode* n = &g->nodes[i];
|
||||||
if (n->emb && n->emb_dim == dim && vindex_insert(idx, (uint64_t)i, n->emb) == 0)
|
if (n->emb && n->emb_dim == dim && vindex_insert(idx, (uint64_t)i, n->emb) == 0)
|
||||||
@@ -9532,8 +9795,62 @@ static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
|
|||||||
}
|
}
|
||||||
_eg_vindex_built_nc = g->node_count;
|
_eg_vindex_built_nc = g->node_count;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Owner-mediated publish. Takes the boundary EXCLUSIVELY, so it can never run
|
||||||
|
* beside a reader. Cheap no-op when the published snapshot is already current. */
|
||||||
|
static void eg_vindex_maintain(EngramStore* g, int32_t dim) {
|
||||||
|
if (!g || dim <= 0) return;
|
||||||
|
pthread_rwlock_wrlock(&_eg_vindex_rw);
|
||||||
|
eg_vindex_publish_locked(g, dim);
|
||||||
|
pthread_rwlock_unlock(&_eg_vindex_rw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* READ SIDE. Returns the published snapshot as an IMMUTABLE view, with the
|
||||||
|
* boundary held for READ — the caller MUST pair every call with exactly one
|
||||||
|
* eg_vindex_view_release(), on every path including error returns.
|
||||||
|
*
|
||||||
|
* The returned pointer is `const`: a read path physically cannot call
|
||||||
|
* vindex_insert on it. That is the compile-time constraint, and it is why this
|
||||||
|
* replaces eg_vindex_sync rather than wrapping it. May return NULL (no index
|
||||||
|
* available -> caller falls back to the exact O(n) scan); the boundary is still
|
||||||
|
* held and still must be released. */
|
||||||
|
static const VIndex* eg_vindex_view(EngramStore* g, int32_t dim) {
|
||||||
|
if (g && dim > 0) {
|
||||||
|
/* Fast path: snapshot already current, take it read-only and go. */
|
||||||
|
pthread_rwlock_rdlock(&_eg_vindex_rw);
|
||||||
|
if (_eg_vindex && _eg_vindex_dim == dim && _eg_vindex_built_nc == g->node_count)
|
||||||
|
return _eg_vindex;
|
||||||
|
/* Stale or absent. Drop to no lock, ask the owner to publish, re-acquire.
|
||||||
|
* NEVER upgrade rdlock->wrlock in place: that self-deadlocks. */
|
||||||
|
pthread_rwlock_unlock(&_eg_vindex_rw);
|
||||||
|
eg_vindex_maintain(g, dim);
|
||||||
|
}
|
||||||
|
pthread_rwlock_rdlock(&_eg_vindex_rw);
|
||||||
return _eg_vindex;
|
return _eg_vindex;
|
||||||
}
|
}
|
||||||
|
static void eg_vindex_view_release(void) {
|
||||||
|
pthread_rwlock_unlock(&_eg_vindex_rw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WRITE-SIDE MAINTENANCE HOOK. Call after an embedding becomes present on a
|
||||||
|
* resident ordinal. A node without an embedding cannot be in a vector index at
|
||||||
|
* all, so embedding-assignment — not node append — is the event that owns index
|
||||||
|
* membership. Cheap: one O(log n) HNSW insert, no O(node_count) presence scan.
|
||||||
|
* A no-op before the first publish (the cold build picks the node up) and on a
|
||||||
|
* dim mismatch. */
|
||||||
|
static void eg_vindex_note_embedded(EngramStore* g, int64_t ordinal) {
|
||||||
|
if (!g || ordinal < 0 || ordinal >= g->node_count) return;
|
||||||
|
EngramNode* n = &g->nodes[ordinal];
|
||||||
|
if (!n->emb || n->emb_dim <= 0) return;
|
||||||
|
pthread_rwlock_wrlock(&_eg_vindex_rw);
|
||||||
|
if (_eg_vindex && _eg_vindex_dim == n->emb_dim &&
|
||||||
|
eg_vindex_seen_ensure(g->node_count) == 0 && !_eg_vindex_seen[ordinal]) {
|
||||||
|
if (vindex_insert(_eg_vindex, (uint64_t)ordinal, n->emb) == 0)
|
||||||
|
_eg_vindex_seen[ordinal] = 1;
|
||||||
|
}
|
||||||
|
pthread_rwlock_unlock(&_eg_vindex_rw);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── M9 GEOMETRY PRIMING (ENGRAM_GEOMETRY_PRIMING, default OFF) ──────────────
|
/* ── M9 GEOMETRY PRIMING (ENGRAM_GEOMETRY_PRIMING, default OFF) ──────────────
|
||||||
* Opt-in wiring of the centered relational-neighborhood geometry (engram_geometry.c)
|
* Opt-in wiring of the centered relational-neighborhood geometry (engram_geometry.c)
|
||||||
@@ -9640,7 +9957,9 @@ static int64_t engram_activate_beam(void) {
|
|||||||
v = d; return v;
|
v = d; return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
/* Core activation. Callers must hold the engram request lock — reached only via
|
||||||
|
* the engram_activate() wrapper below, which self-guards (see eg_guard_enter). */
|
||||||
|
static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
|
||||||
EngramStore* g = engram_get();
|
EngramStore* g = engram_get();
|
||||||
const char* q = EL_CSTR(query);
|
const char* q = EL_CSTR(query);
|
||||||
int64_t max_depth = (int64_t)depth; if (max_depth <= 0) max_depth = 2;
|
int64_t max_depth = (int64_t)depth; if (max_depth <= 0) max_depth = 2;
|
||||||
@@ -9681,6 +10000,12 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
|||||||
float* v = eg_embed_fetch(n->content, &d);
|
float* v = eg_embed_fetch(n->content, &d);
|
||||||
if (!v) break; /* embedder down / breaker open — stop this call */
|
if (!v) break; /* embedder down / breaker open — stop this call */
|
||||||
n->emb = v; n->emb_dim = d;
|
n->emb = v; n->emb_dim = d;
|
||||||
|
/* Write-side index maintenance: an embedding just became present on
|
||||||
|
* ordinal i, so the index's owner publishes it now. This is what
|
||||||
|
* retires the "STALENESS (honest tradeoff)" note above — a lazily
|
||||||
|
* embedded OLDER node no longer waits for a full rebuild to become
|
||||||
|
* visible to route_nearest / autoconnect. */
|
||||||
|
eg_vindex_note_embedded(g, i);
|
||||||
backfilled++;
|
backfilled++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9879,7 +10204,9 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
|||||||
* same budget as the exact scan's retry `guard` — so dedup/threshold
|
* same budget as the exact scan's retry `guard` — so dedup/threshold
|
||||||
* rejects still leave enough distinct seeds. */
|
* rejects still leave enough distinct seeds. */
|
||||||
{
|
{
|
||||||
VIndex* vx = eg_vindex_sync(g, q_dim);
|
/* Immutable view: the boundary is held for READ across the whole
|
||||||
|
* search + harvest, and released at the end of this block. */
|
||||||
|
const VIndex* vx = eg_vindex_view(g, q_dim);
|
||||||
if (vx && (int64_t)vindex_size(vx) >= ENGRAM_EMBED_SEED_K) {
|
if (vx && (int64_t)vindex_size(vx) >= ENGRAM_EMBED_SEED_K) {
|
||||||
const float* seed_qv = e_eff ? e_eff : q_emb;
|
const float* seed_qv = e_eff ? e_eff : q_emb;
|
||||||
int kreq = ENGRAM_EMBED_SEED_K * 8;
|
int kreq = ENGRAM_EMBED_SEED_K * 8;
|
||||||
@@ -9923,6 +10250,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
|||||||
}
|
}
|
||||||
free(aid); free(ad);
|
free(aid); free(ad);
|
||||||
}
|
}
|
||||||
|
eg_vindex_view_release();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Exact O(n) argmax fallback / top-up (pre-M8 selection, verbatim).
|
/* Exact O(n) argmax fallback / top-up (pre-M8 selection, verbatim).
|
||||||
@@ -10009,9 +10337,11 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
|||||||
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
||||||
if (gmean && vids) {
|
if (gmean && vids) {
|
||||||
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
||||||
|
const VIndex* gvx = eg_vindex_view(g, q_dim);
|
||||||
geo = engram_geometry_descriptor(
|
geo = engram_geometry_descriptor(
|
||||||
g_engram_store, _eg_vindex, vids, (int)g->node_count,
|
g_engram_store, gvx, vids, (int)g->node_count,
|
||||||
seed_ids, (size_t)nsel, NULL, gmean);
|
seed_ids, (size_t)nsel, NULL, gmean);
|
||||||
|
eg_vindex_view_release();
|
||||||
}
|
}
|
||||||
free(vids);
|
free(vids);
|
||||||
if (geo && geo->n_members > 0) {
|
if (geo && geo->n_members > 0) {
|
||||||
@@ -11289,6 +11619,15 @@ static void engram_emit_node_json(JsonBuf* b, const EngramNode* n, int include_e
|
|||||||
snprintf(tmp, sizeof(tmp), ",\"wm_anchor\":%g", n->wm_anchor); jb_puts(b, tmp);
|
snprintf(tmp, sizeof(tmp), ",\"wm_anchor\":%g", n->wm_anchor); jb_puts(b, tmp);
|
||||||
snprintf(tmp, sizeof(tmp), ",\"base_level\":%g",
|
snprintf(tmp, sizeof(tmp), ",\"base_level\":%g",
|
||||||
engram_bll_base_level(n, engram_now_ms())); jb_puts(b, tmp);
|
engram_bll_base_level(n, engram_now_ms())); jb_puts(b, tmp);
|
||||||
|
/* GEOMETRY VISIBILITY (2026-08-16 self-review): the node document never
|
||||||
|
* said whether the node carried a vector, so a read-back could not tell
|
||||||
|
* "has geometry" from "text only". Not cosmetic — it is exactly how a
|
||||||
|
* real ingest drop and a mere reporting gap became indistinguishable,
|
||||||
|
* and I misdiagnosed one as the other for an hour. Always emit the width
|
||||||
|
* and the boolean; the vector itself stays behind include_emb since it
|
||||||
|
* is large and most callers do not want it inline. */
|
||||||
|
snprintf(tmp, sizeof(tmp), ",\"emb_dim\":%d,\"embedded\":%s",
|
||||||
|
(int)n->emb_dim, (n->emb && n->emb_dim > 0) ? "true" : "false"); jb_puts(b, tmp);
|
||||||
/* Base-level access history: chronological (oldest→newest) compact
|
/* Base-level access history: chronological (oldest→newest) compact
|
||||||
* string. Loaders replay it through engram_bll_record_access; absent
|
* string. Loaders replay it through engram_bll_record_access; absent
|
||||||
* field = empty ring (optimized-form fallback). (2026-07-22) */
|
* field = empty ring (optimized-form fallback). (2026-07-22) */
|
||||||
@@ -13152,13 +13491,16 @@ static int eg_knn_for_node(EngramStore* g, int64_t self, int want, uint64_t* out
|
|||||||
if(self < 0 || self >= g->node_count) return 0;
|
if(self < 0 || self >= g->node_count) return 0;
|
||||||
EngramNode* n = &g->nodes[self];
|
EngramNode* n = &g->nodes[self];
|
||||||
if(!n->emb || n->emb_dim <= 0) return 0;
|
if(!n->emb || n->emb_dim <= 0) return 0;
|
||||||
VIndex* vx = eg_vindex_sync(g, n->emb_dim);
|
/* Immutable view held for READ across the search; the harvest below reads
|
||||||
if(!vx) return 0;
|
* only g->nodes, so the boundary is released as soon as the search returns. */
|
||||||
|
const VIndex* vx = eg_vindex_view(g, n->emb_dim);
|
||||||
|
if(!vx){ eg_vindex_view_release(); return 0; }
|
||||||
int K = want + 8;
|
int K = want + 8;
|
||||||
uint64_t* ids = (uint64_t*)malloc(sizeof(uint64_t)*(size_t)K);
|
uint64_t* ids = (uint64_t*)malloc(sizeof(uint64_t)*(size_t)K);
|
||||||
float* dist = (float*)malloc(sizeof(float)*(size_t)K);
|
float* dist = (float*)malloc(sizeof(float)*(size_t)K);
|
||||||
if(!ids || !dist){ free(ids); free(dist); return 0; }
|
if(!ids || !dist){ eg_vindex_view_release(); free(ids); free(dist); return 0; }
|
||||||
int m = vindex_search(vx, n->emb, K, 0, ids, dist);
|
int m = vindex_search(vx, n->emb, K, 0, ids, dist);
|
||||||
|
eg_vindex_view_release();
|
||||||
int c = 0;
|
int c = 0;
|
||||||
for(int j=0; j<m && c<want; j++){
|
for(int j=0; j<m && c<want; j++){
|
||||||
int64_t bi = (int64_t)ids[j];
|
int64_t bi = (int64_t)ids[j];
|
||||||
@@ -13189,7 +13531,8 @@ el_val_t engram_autoconnect_node(el_val_t id_v, el_val_t k_v, el_val_t minsim_v)
|
|||||||
EngramNode* n = &g->nodes[self];
|
EngramNode* n = &g->nodes[self];
|
||||||
if((!n->emb || n->emb_dim <= 0) && n->content && eg_embed_eligible(n)){
|
if((!n->emb || n->emb_dim <= 0) && n->content && eg_embed_eligible(n)){
|
||||||
int32_t d = 0; float* v = eg_embed_fetch(n->content, &d);
|
int32_t d = 0; float* v = eg_embed_fetch(n->content, &d);
|
||||||
if(v && d > 0){ n->emb = v; n->emb_dim = d; if(engram_store_enabled()) eg_store_put_node(n); }
|
if(v && d > 0){ n->emb = v; n->emb_dim = d; if(engram_store_enabled()) eg_store_put_node(n);
|
||||||
|
eg_vindex_note_embedded(g, self); }
|
||||||
else free(v);
|
else free(v);
|
||||||
}
|
}
|
||||||
if(!n->emb || n->emb_dim <= 0){ jb_puts(&b, "{\"connected\":0,\"reason\":\"unembedded\"}"); return el_wrap_str(b.buf); }
|
if(!n->emb || n->emb_dim <= 0){ jb_puts(&b, "{\"connected\":0,\"reason\":\"unembedded\"}"); return el_wrap_str(b.buf); }
|
||||||
@@ -13324,8 +13667,10 @@ static GeoDescriptor* eg_geo_build_desc(const char* csv) {
|
|||||||
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
||||||
if (gmean && vids) {
|
if (gmean && vids) {
|
||||||
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
||||||
geo = engram_geometry_descriptor(g_engram_store, _eg_vindex, vids, (int)g->node_count,
|
const VIndex* gvx = eg_vindex_view(g, dim);
|
||||||
|
geo = engram_geometry_descriptor(g_engram_store, gvx, vids, (int)g->node_count,
|
||||||
(const char* const*)ids, (size_t)ns, NULL, gmean);
|
(const char* const*)ids, (size_t)ns, NULL, gmean);
|
||||||
|
eg_vindex_view_release();
|
||||||
}
|
}
|
||||||
free(vids);
|
free(vids);
|
||||||
for (int i = 0; i < ns; i++) free(ids[i]);
|
for (int i = 0; i < ns; i++) free(ids[i]);
|
||||||
@@ -13362,12 +13707,16 @@ el_val_t engram_geo_reify_run_json(void){
|
|||||||
int32_t dim = 0;
|
int32_t dim = 0;
|
||||||
for(int64_t i = 0; i < g->node_count && dim == 0; i++)
|
for(int64_t i = 0; i < g->node_count && dim == 0; i++)
|
||||||
if(g->nodes[i].emb && g->nodes[i].emb_dim > 0) dim = g->nodes[i].emb_dim;
|
if(g->nodes[i].emb && g->nodes[i].emb_dim > 0) dim = g->nodes[i].emb_dim;
|
||||||
VIndex* vx = (dim > 0) ? eg_vindex_sync(g, dim) : NULL;
|
|
||||||
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
char** vids = malloc((size_t)g->node_count * sizeof(char*));
|
||||||
if(!vids) return eg_geo_err("reify oom");
|
if(!vids) return eg_geo_err("reify oom");
|
||||||
for(int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
for(int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
|
||||||
|
/* Held for READ across the whole reify pass: it only searches the index.
|
||||||
|
* (The multi-second SELF-reify beat below builds a PRIVATE index instead and
|
||||||
|
* never touches this boundary at all.) */
|
||||||
|
const VIndex* vx = eg_vindex_view(g, dim);
|
||||||
int persisted = engram_geo_reify_store(g_engram_store, vx, vids,
|
int persisted = engram_geo_reify_store(g_engram_store, vx, vids,
|
||||||
(int)g->node_count, NULL);
|
(int)g->node_count, NULL);
|
||||||
|
eg_vindex_view_release();
|
||||||
free(vids);
|
free(vids);
|
||||||
int nested = 0;
|
int nested = 0;
|
||||||
if(persisted >= 0){
|
if(persisted >= 0){
|
||||||
@@ -13677,12 +14026,108 @@ static int eg_cog_is_keystone_seeds(const char* csv) {
|
|||||||
el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) {
|
el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) {
|
||||||
GeoDescriptor* g = eg_geo_build_desc(EL_CSTR(seeds));
|
GeoDescriptor* g = eg_geo_build_desc(EL_CSTR(seeds));
|
||||||
if (!g) return eg_geo_err("geometry unavailable");
|
if (!g) return eg_geo_err("geometry unavailable");
|
||||||
CogStance st; cog_stance_init(&st, NULL, EL_CSTR(faculty), g->hub_id, NULL, g);
|
/* RESUME THE LEARNED STANCE (2026-08-16 self-review). This built a NEUTRAL
|
||||||
|
* stance every call — all axis_gain 1.0, bias_dir NULL, reliability 0.5 —
|
||||||
|
* and never loaded the one the correspondence-beat had been persisting.
|
||||||
|
*
|
||||||
|
* That mattered because the faculty enters engram_think ONLY through the
|
||||||
|
* stance: `gain = stance->axis_gain[k]` warps the per-axis extents, and
|
||||||
|
* `stance->bias_dir` seeds the steering direction. cog_stance_init stores
|
||||||
|
* the faculty NAME but nothing reads it. So with a neutral stance,
|
||||||
|
* reason / abduce / induce / plan / analogize are the same function with
|
||||||
|
* different labels — measured, byte-identical output across all five —
|
||||||
|
* and `confidence` is pinned to the 0.5 uninformed prior, because
|
||||||
|
* GeoGradient.confidence is just stance->reliability.
|
||||||
|
*
|
||||||
|
* The machinery already existed and only this call site ignored it:
|
||||||
|
* engram_correspondence_beat_json resumes via cog_stance_from_node and
|
||||||
|
* persists via cog_stance_to_node under the id "stance-<faculty>-<hub>".
|
||||||
|
* Every beat's calibration was being written and then thrown away on the
|
||||||
|
* next read. Same defect as the NULL anchor directly above: a neutral
|
||||||
|
* argument collapsing a capability to a constant.
|
||||||
|
*
|
||||||
|
* Resume the same id the beat writes, so learning compounds across beats
|
||||||
|
* and cold boot. Fall back to neutral only when no stance exists yet —
|
||||||
|
* which is a genuine uninformed prior, not a discarded informed one. */
|
||||||
|
char sid[256];
|
||||||
|
snprintf(sid, sizeof sid, "stance-%s-%s",
|
||||||
|
EL_CSTR(faculty) ? EL_CSTR(faculty) : "reason",
|
||||||
|
g->hub_id ? g->hub_id : "region");
|
||||||
|
CogStance st; StoreNode prev; int resumed = 0;
|
||||||
|
if (g_engram_store && store_get_node(g_engram_store, sid, &prev) == 1) {
|
||||||
|
if (cog_stance_from_node(&prev, &st) == 0) resumed = 1;
|
||||||
|
store_node_free(&prev);
|
||||||
|
}
|
||||||
|
if (!resumed) cog_stance_init(&st, sid, EL_CSTR(faculty), g->hub_id, NULL, g);
|
||||||
|
else { free(st.id); st.id = strdup(sid); }
|
||||||
GeoGradient grad;
|
GeoGradient grad;
|
||||||
if (engram_think(g, NULL, &st, &grad) != 0) { cog_stance_free(&st); engram_geo_free(g); return eg_geo_err("think failed"); }
|
|
||||||
|
/* ANCHOR THE READ (2026-08-16 self-review). This passed NULL, and NULL is
|
||||||
|
* not "no opinion" — engram_think re-origins at `anchor ? anchor :
|
||||||
|
* region->centroid`, so NULL means "read from the centroid", and the
|
||||||
|
* centroid is the ONE point where the gradient is zero by construction:
|
||||||
|
* r = x - centroid = 0, so every axis projection is 0, grad is 0, and
|
||||||
|
* direction takes the "at rest" branch. Measured consequence: EVERY
|
||||||
|
* faculty — reason, abduce, induce, plan, analogize — returned an
|
||||||
|
* identical null result, differing only in its label:
|
||||||
|
* {"direction":[0,0,...],"spread":0,"magnitude":1,"confidence":0.5}
|
||||||
|
* magnitude 1 is membership evaluated at the centroid, spread 0 is its
|
||||||
|
* distance to itself, and confidence 0.5 is the stance fallback. The
|
||||||
|
* geometry was never the problem — /api/drift computes real values
|
||||||
|
* (centroid_sep 0.104, core_disp 0.045) over the very same 87 members.
|
||||||
|
* Neuron could not think because the read was always taken from the
|
||||||
|
* region's own centre.
|
||||||
|
*
|
||||||
|
* The seeds choose WHICH region; they must also supply the VANTAGE it is
|
||||||
|
* read from. Anchor at the first resolvable embedded seed — the same seed
|
||||||
|
* eg_geo_build_desc infers `dim` from, so the two never disagree. A single
|
||||||
|
* seed still yields a real gradient because the descriptor expands to the
|
||||||
|
* seed's neighbourhood (87 members for the self anchor), so the seed's own
|
||||||
|
* position is distinct from the neighbourhood centroid.
|
||||||
|
*
|
||||||
|
* COPY the vector, never borrow it: g->nodes is realloc'd in place on
|
||||||
|
* append, so a borrowed EngramNode* is a dangling pointer across any
|
||||||
|
* concurrent write. 768 floats is 3 KB. */
|
||||||
|
float* anchor = NULL;
|
||||||
|
{
|
||||||
|
EngramStore* eg = engram_get();
|
||||||
|
const char* csv = EL_CSTR(seeds);
|
||||||
|
if (eg && csv) {
|
||||||
|
const char* p = csv;
|
||||||
|
while (*p && !anchor) {
|
||||||
|
while (*p == ' ' || *p == ',') p++;
|
||||||
|
const char* s = p;
|
||||||
|
while (*p && *p != ',') p++;
|
||||||
|
const char* e = p; while (e > s && e[-1] == ' ') e--;
|
||||||
|
if (e > s) {
|
||||||
|
char* id = strndup(s, (size_t)(e - s));
|
||||||
|
if (id) {
|
||||||
|
int64_t idx = engram_find_node_index(id);
|
||||||
|
if (idx >= 0 && idx < eg->node_count) {
|
||||||
|
EngramNode* n = &eg->nodes[idx];
|
||||||
|
if (n->emb && n->emb_dim == g->dim) {
|
||||||
|
anchor = malloc(sizeof(float) * (size_t)g->dim);
|
||||||
|
if (anchor) memcpy(anchor, n->emb,
|
||||||
|
sizeof(float) * (size_t)g->dim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engram_think(g, anchor, &st, &grad) != 0) { free(anchor); cog_stance_free(&st); engram_geo_free(g); return eg_geo_err("think failed"); }
|
||||||
|
free(anchor);
|
||||||
JsonBuf b; jb_init(&b); char t[256];
|
JsonBuf b; jb_init(&b); char t[256];
|
||||||
snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"n_support\":%d,\"magnitude\":%.6g,\"spread\":%.6g,\"confidence\":%.6g,\"dim\":%d",
|
/* stance_resumed distinguishes an INFORMED read from an uninformed one.
|
||||||
EL_CSTR(faculty), grad.n_support, grad.magnitude, grad.spread, grad.confidence, grad.dim);
|
* Without it, confidence 0.5 from a learned-but-unreliable stance and
|
||||||
|
* confidence 0.5 from "no stance exists" are indistinguishable — the same
|
||||||
|
* reporting gap that let the NULL anchor and the neutral stance hide. */
|
||||||
|
snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"n_support\":%d,\"magnitude\":%.6g,\"spread\":%.6g,\"confidence\":%.6g,\"stance_resumed\":%s,\"dim\":%d",
|
||||||
|
EL_CSTR(faculty), grad.n_support, grad.magnitude, grad.spread, grad.confidence,
|
||||||
|
resumed ? "true" : "false", grad.dim);
|
||||||
jb_puts(&b, t);
|
jb_puts(&b, t);
|
||||||
int emit = grad.dim < 8 ? grad.dim : 8;
|
int emit = grad.dim < 8 ? grad.dim : 8;
|
||||||
jb_puts(&b, ",\"direction\":"); eg_geo_emit_vec(&b, grad.direction, emit);
|
jb_puts(&b, ",\"direction\":"); eg_geo_emit_vec(&b, grad.direction, emit);
|
||||||
@@ -13706,12 +14151,57 @@ el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom
|
|||||||
double grounding = (rc == 0) ? gr.grounding : 0.0;
|
double grounding = (rc == 0) ? gr.grounding : 0.0;
|
||||||
if (rc == 0) engram_verify_grounding_free(&gr);
|
if (rc == 0) engram_verify_grounding_free(&gr);
|
||||||
const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL;
|
const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL;
|
||||||
const char* cid = C->hub_id ? C->hub_id : EL_CSTR(claim);
|
|
||||||
const char* eid = E->hub_id ? E->hub_id : EL_CSTR(evidence);
|
/* GROUND THE NODE ASKED ABOUT, AND SAY WHAT WAS RESOLVED (2026-08-16
|
||||||
int wr = cog_ground_edge(g_engram_store, cid, eid, grounding, fw);
|
* self-review). This wrote the grounded-by edge between the two REGION
|
||||||
JsonBuf b; jb_init(&b); char t[256];
|
* HUBS and then echoed those hubs back in the "claim"/"evidence" fields
|
||||||
snprintf(t, sizeof t, "{\"relation\":\"grounded-by\",\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"grounding\":%.6g,\"written\":%s}",
|
* as though they were the caller's input. Three consequences, all measured
|
||||||
cid, eid, fw ? fw : "-", grounding, wr == 0 ? "true" : "false");
|
* against the live store:
|
||||||
|
*
|
||||||
|
* 1. The edge landed on a node the caller never named. Asking to ground
|
||||||
|
* 3b9ced5d against 6edf8c79 wrote an edge on 6edf8c79 -> d0406dfd,
|
||||||
|
* because those were the hubs of the two regions.
|
||||||
|
* 2. When both seeds resolve into the same region, the hubs coincide and
|
||||||
|
* the call grounds a node against ITSELF, returning grounding = 1 —
|
||||||
|
* a perfect score with no evidence behind it. Two independent agents
|
||||||
|
* hit this and reported 0.885 / 0.909 self-groundings as confident.
|
||||||
|
* 3. The echo concealed both, because the response looked exactly like a
|
||||||
|
* successful grounding of the ids that were passed in.
|
||||||
|
*
|
||||||
|
* The region is HOW a claim is evaluated; it is not WHAT the claim is
|
||||||
|
* about. So the edge attaches to the requested ids, and the resolved hubs
|
||||||
|
* are reported separately under claim_region / evidence_region. When the
|
||||||
|
* two regions coincide, the grounding is degenerate by construction and is
|
||||||
|
* reported as such rather than as a confident 1.0. */
|
||||||
|
const char* cid = EL_CSTR(claim);
|
||||||
|
const char* eid = EL_CSTR(evidence);
|
||||||
|
const char* chub = C->hub_id ? C->hub_id : cid;
|
||||||
|
const char* ehub = E->hub_id ? E->hub_id : eid;
|
||||||
|
/* Degeneracy is broader than chub == ehub. Three circular shapes, each of
|
||||||
|
* which yields a high score for structural reasons rather than evidential
|
||||||
|
* ones, and all three were previously invisible:
|
||||||
|
* same-region both seeds resolve to one region — grounding a thing
|
||||||
|
* against itself.
|
||||||
|
* claim-in-ev the claim's region hub IS the evidence node: the evidence
|
||||||
|
* sits at the centre of the claim's own neighbourhood.
|
||||||
|
* ev-in-claim the mirror case.
|
||||||
|
* Measured: grounding 3b9ced5d against 6edf8c79 scored 0.98883 purely
|
||||||
|
* because 6edf8c79 is the hub of 3b9ced5d's region. */
|
||||||
|
const char* degenerate = NULL;
|
||||||
|
if (chub && ehub && strcmp(chub, ehub) == 0) degenerate = "same-region";
|
||||||
|
else if (chub && eid && strcmp(chub, eid) == 0) degenerate = "claim-region-is-evidence";
|
||||||
|
else if (ehub && cid && strcmp(ehub, cid) == 0) degenerate = "evidence-region-is-claim";
|
||||||
|
if (degenerate) grounding = 0.0; /* circular support is not support */
|
||||||
|
|
||||||
|
/* Do not write an edge for a grounding that is degenerate by construction. */
|
||||||
|
int wr = degenerate ? -1 : cog_ground_edge(g_engram_store, cid, eid, grounding, fw);
|
||||||
|
JsonBuf b; jb_init(&b); char t[512];
|
||||||
|
snprintf(t, sizeof t, "{\"relation\":\"grounded-by\",\"claim\":\"%s\",\"evidence\":\"%s\","
|
||||||
|
"\"claim_region\":\"%s\",\"evidence_region\":\"%s\",\"degenerate\":%s%s%s,"
|
||||||
|
"\"for_whom\":\"%s\",\"grounding\":%.6g,\"written\":%s}",
|
||||||
|
cid ? cid : "", eid ? eid : "", chub ? chub : "", ehub ? ehub : "",
|
||||||
|
degenerate ? "\"" : "false", degenerate ? degenerate : "", degenerate ? "\"" : "",
|
||||||
|
fw ? fw : "-", grounding, wr == 0 ? "true" : "false");
|
||||||
jb_puts(&b, t);
|
jb_puts(&b, t);
|
||||||
engram_geo_free(C); engram_geo_free(E);
|
engram_geo_free(C); engram_geo_free(E);
|
||||||
return el_wrap_str(b.buf);
|
return el_wrap_str(b.buf);
|
||||||
@@ -14032,6 +14522,21 @@ el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t di
|
|||||||
return el_wrap_str(b.buf);
|
return el_wrap_str(b.buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public activation entry point. Serializes against the http_worker threads that
|
||||||
|
* share g->nodes/g->edges — this is the guard the awareness main thread
|
||||||
|
* (soul.el: awareness_run) was missing entirely. Nested calls from a worker that
|
||||||
|
* already holds the lock pass straight through.
|
||||||
|
*
|
||||||
|
* It no longer guards _eg_vindex: the index has its own publication boundary
|
||||||
|
* (eg_vindex_view / eg_vindex_maintain) and search cannot mutate it. This guard is
|
||||||
|
* now about the RAM graph's realloc-in-place ONLY. See the note at eg_guard_enter. */
|
||||||
|
el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||||
|
int owned = eg_guard_enter();
|
||||||
|
el_val_t r = engram_activate_inner(query, depth);
|
||||||
|
eg_guard_exit(owned);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
el_val_t engram_activate_json(el_val_t query, el_val_t depth) {
|
el_val_t engram_activate_json(el_val_t query, el_val_t depth) {
|
||||||
/* Run two-layer engram_activate and serialize the result list to JSON.
|
/* Run two-layer engram_activate and serialize the result list to JSON.
|
||||||
* Each entry includes both activation_strength (layer 1 background) and
|
* Each entry includes both activation_strength (layer 1 background) and
|
||||||
@@ -14808,6 +15313,7 @@ el_val_t engram_embed_backfill(el_val_t count) {
|
|||||||
float* v = eg_embed_fetch(n->content, &d);
|
float* v = eg_embed_fetch(n->content, &d);
|
||||||
if (!v) break; /* embedder down / breaker open — stop this call */
|
if (!v) break; /* embedder down / breaker open — stop this call */
|
||||||
n->emb = v; n->emb_dim = d;
|
n->emb = v; n->emb_dim = d;
|
||||||
|
eg_vindex_note_embedded(g, i); /* write-side index maintenance */
|
||||||
done++;
|
done++;
|
||||||
}
|
}
|
||||||
int64_t total = 0;
|
int64_t total = 0;
|
||||||
@@ -18522,6 +19028,26 @@ el_val_t engram_pool_stats_json(void) {
|
|||||||
el_val_t el_alloc_count(void) { return (el_val_t)(int64_t)_el_alloc_count; }
|
el_val_t el_alloc_count(void) { return (el_val_t)(int64_t)_el_alloc_count; }
|
||||||
el_val_t el_alloc_bytes(void) { return (el_val_t)(int64_t)_el_alloc_bytes; }
|
el_val_t el_alloc_bytes(void) { return (el_val_t)(int64_t)_el_alloc_bytes; }
|
||||||
|
|
||||||
|
/* el_black_box — optimisation barrier for benchmark bodies.
|
||||||
|
*
|
||||||
|
* WHY THIS IS NOT OPTIONAL. A benchmark whose result is unused is dead code,
|
||||||
|
* and CONSUMING THE RESULT IS NOT SUFFICIENT: clang recognises loop idioms and
|
||||||
|
* closes them to arithmetic. A nested `total = total + 1` loop measured at
|
||||||
|
* 0 microseconds for every n while returning a numerically correct n*n --
|
||||||
|
* the answer was right and the work never happened.
|
||||||
|
*
|
||||||
|
* That is the same failure shape as a test that never ran reporting pass. The
|
||||||
|
* harness must own the barrier rather than trusting the benchmark author to
|
||||||
|
* defeat the optimiser.
|
||||||
|
*
|
||||||
|
* The constraint "+r" forces the value through a register the compiler must
|
||||||
|
* treat as both read and written by opaque code; the "memory" clobber stops
|
||||||
|
* loads and stores being reordered across it or elided. Emits no instructions. */
|
||||||
|
el_val_t el_black_box(el_val_t v) {
|
||||||
|
__asm__ __volatile__("" : "+r"(v) : : "memory");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
el_val_t el_peak_rss(void) {
|
el_val_t el_peak_rss(void) {
|
||||||
struct rusage ru;
|
struct rusage ru;
|
||||||
if (getrusage(RUSAGE_SELF, &ru) != 0) return (el_val_t)0;
|
if (getrusage(RUSAGE_SELF, &ru) != 0) return (el_val_t)0;
|
||||||
|
|||||||
@@ -612,7 +612,16 @@ el_val_t engram_get_node(el_val_t id);
|
|||||||
void engram_strengthen(el_val_t node_id);
|
void engram_strengthen(el_val_t node_id);
|
||||||
void engram_forget(el_val_t node_id);
|
void engram_forget(el_val_t node_id);
|
||||||
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
|
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
|
||||||
|
/* Largest byte length <= max_bytes that does not split a UTF-8 codepoint.
|
||||||
|
* Bounded by bytes, not codepoints, so truncated strings never grow. */
|
||||||
|
size_t el_utf8_safe_len(const char* s, size_t max_bytes);
|
||||||
|
|
||||||
el_val_t engram_node_count(void);
|
el_val_t engram_node_count(void);
|
||||||
|
/* Attach geometry to an existing node. `hex` is little-endian float32,
|
||||||
|
* exactly dim*8 hex chars — the encoding realizers already emit. Lets a
|
||||||
|
* non-text modality enter as geometry instead of being described in prose
|
||||||
|
* and embedded as its description. Returns 1 on success, 0 otherwise. */
|
||||||
|
el_val_t engram_node_set_emb(el_val_t id, el_val_t hex, el_val_t dim);
|
||||||
el_val_t engram_search(el_val_t query, el_val_t limit);
|
el_val_t engram_search(el_val_t query, el_val_t limit);
|
||||||
el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
|
el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
|
||||||
void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
|
void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
|
||||||
@@ -1022,6 +1031,7 @@ el_val_t el_mem_check(void);
|
|||||||
el_val_t el_alloc_count(void);
|
el_val_t el_alloc_count(void);
|
||||||
el_val_t el_alloc_bytes(void);
|
el_val_t el_alloc_bytes(void);
|
||||||
el_val_t el_peak_rss(void);
|
el_val_t el_peak_rss(void);
|
||||||
|
el_val_t el_black_box(el_val_t v);
|
||||||
|
|
||||||
/* Semantic retrieval surface. NOT interchangeable with engram_search_json,
|
/* Semantic retrieval surface. NOT interchangeable with engram_search_json,
|
||||||
* which is lexical by design — see the note at the definition. */
|
* which is lexical by design — see the note at the definition. */
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// runtime/elbench.el — growth-curve classifier and complexity gate.
|
||||||
|
//
|
||||||
|
// Given a geometric sweep of input sizes and the measurements taken at each,
|
||||||
|
// classify the growth curve and decide whether it violates a declared bound.
|
||||||
|
//
|
||||||
|
// ── Why this exists ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Constant-factor regressions are annoying. Complexity regressions are outages.
|
||||||
|
// An O(n) lookup inside an O(n) loop is invisible at n=100 in a unit test and
|
||||||
|
// catastrophic at n=100000 in production. el #132 was exactly that: a strlen()
|
||||||
|
// inside a per-character accessor, quadratic, shipped for months.
|
||||||
|
//
|
||||||
|
// ── THREE signals, not one ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The gate fits time AND allocation-count AND allocation-bytes, and fails if
|
||||||
|
// ANY of them exceeds its declared curve. This is not belt-and-braces; each
|
||||||
|
// signal is blind to a real defect class the others catch:
|
||||||
|
//
|
||||||
|
// * A copy-on-write accumulator rebuilding its buffer allocates ONCE per
|
||||||
|
// iteration — count is exactly linear — while bytes go quadratic.
|
||||||
|
// Count alone passes it.
|
||||||
|
// * el #132's strlen-per-character is pure CPU and allocates NOTHING.
|
||||||
|
// Both allocation signals read FLAT. Only time catches it.
|
||||||
|
//
|
||||||
|
// The deterministic signals (count, bytes) are preferable where they apply:
|
||||||
|
// no statistics, correct on the first run, machine-independent. They are
|
||||||
|
// simply not sufficient.
|
||||||
|
//
|
||||||
|
// ── SCOPE LIMIT — read this before trusting a flat curve ─────────────────────
|
||||||
|
//
|
||||||
|
// The allocation counters track EL-LEVEL allocation only: strings, ElList and
|
||||||
|
// ElMap bodies, their backing arrays, copy-on-write clones, and the realloc
|
||||||
|
// growth path. malloc inside engram_*.c and inside libcurl is NOT counted.
|
||||||
|
//
|
||||||
|
// A flat allocation curve over a workload dominated by engram or HTTP calls is
|
||||||
|
// therefore NOT evidence of anything. It means "no El-level allocation growth",
|
||||||
|
// not "no allocation growth". Gate El-level complexity with this; do not read
|
||||||
|
// third-party memory behaviour into it.
|
||||||
|
//
|
||||||
|
// ── Classification method ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Sizes must form a geometric sweep (each n double the last). On such a sweep
|
||||||
|
// the ratio between consecutive measurements IS the growth exponent, directly:
|
||||||
|
//
|
||||||
|
// O(1) -> 1.0 O(log n) -> ~1.1 O(n) -> 2.0
|
||||||
|
// O(n log n) -> ~2.2 O(n^2) -> 4.0 O(n^3) -> 8.0
|
||||||
|
//
|
||||||
|
// DEVIATION FROM DESIGN.md 6.2, stated plainly: that section specified Google
|
||||||
|
// Benchmark's one-parameter least-squares fit over candidate curves. This uses
|
||||||
|
// consecutive ratios instead. The sweep is mandated geometric either way, and
|
||||||
|
// on a geometric sweep ratios are directly interpretable and need no floating
|
||||||
|
// point. The cost is weaker separation between O(n) and O(n log n), which is
|
||||||
|
// reported honestly as an ambiguous band rather than guessed at. Least-squares
|
||||||
|
// remains the better answer if that band ever needs to be resolved.
|
||||||
|
//
|
||||||
|
// All arithmetic is fixed-point, scaled by 1000 ("milli-ratio"), so a ratio of
|
||||||
|
// 2.0 is 2000. El values are int64; this avoids float-in-list handling.
|
||||||
|
|
||||||
|
// Curve identifiers. Ordered by growth — the ordering IS the comparison used
|
||||||
|
// by the gate, so an index comparison decides "worse than declared".
|
||||||
|
// 0 = O(1) 1 = O(log n) 2 = O(n) 3 = O(n log n) 4 = O(n^2) 5 = O(n^3)
|
||||||
|
|
||||||
|
fn elb_curve_name(c: Int) -> String {
|
||||||
|
if c == 0 { return "O(1)" }
|
||||||
|
if c == 1 { return "O(log n)" }
|
||||||
|
if c == 2 { return "O(n)" }
|
||||||
|
if c == 3 { return "O(n log n)" }
|
||||||
|
if c == 4 { return "O(n^2)" }
|
||||||
|
if c == 5 { return "O(n^3)" }
|
||||||
|
return "O(?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elb_curve_from_name(s: String) -> Int {
|
||||||
|
if str_eq(s, "O(1)") { return 0 }
|
||||||
|
if str_eq(s, "O(log n)") { return 1 }
|
||||||
|
if str_eq(s, "O(n)") { return 2 }
|
||||||
|
if str_eq(s, "O(n log n)") { return 3 }
|
||||||
|
if str_eq(s, "O(n^2)") { return 4 }
|
||||||
|
if str_eq(s, "O(n^3)") { return 5 }
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_classify_ratio — map a milli-ratio-per-doubling onto a curve.
|
||||||
|
//
|
||||||
|
// Bands are deliberately wide at the top (a quadratic measured at 3.4x is
|
||||||
|
// still a quadratic) and deliberately overlap-averse at the bottom, where a
|
||||||
|
// misclassification between O(1) and O(log n) matters least.
|
||||||
|
fn elb_classify_ratio(milli: Int) -> Int {
|
||||||
|
if milli < 1300 { return 0 }
|
||||||
|
if milli < 1700 { return 1 }
|
||||||
|
if milli < 2400 { return 2 }
|
||||||
|
if milli < 3200 { return 3 }
|
||||||
|
if milli < 6000 { return 4 }
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_ratio — milli-ratio between two consecutive measurements.
|
||||||
|
// Returns -1 when the earlier measurement is zero (ratio undefined).
|
||||||
|
fn elb_ratio(prev: Int, cur: Int) -> Int {
|
||||||
|
if prev <= 0 { return -1 }
|
||||||
|
return (cur * 1000) / prev
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The measurement floor ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A benchmark whose largest measurement is at or near zero has not been
|
||||||
|
// measured. Reporting it as O(1) would be a confident answer with nothing
|
||||||
|
// behind it — the same failure as a test that never ran reporting pass, and
|
||||||
|
// exactly what happened when clang closed a nested loop to a multiply and the
|
||||||
|
// harness read 0 microseconds at every n.
|
||||||
|
//
|
||||||
|
// So: REFUSE. Never classify below the floor.
|
||||||
|
fn elb_below_floor(vals: [Int], floor: Int) -> Bool {
|
||||||
|
let n: Int = native_list_len(vals)
|
||||||
|
let i: Int = 0
|
||||||
|
let mx: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let v: Int = native_list_get(vals, i)
|
||||||
|
if v > mx { let mx = v }
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
if mx < floor { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_implausibly_flat — a measurement that does not move across a sweep whose
|
||||||
|
// input grew by 8x or more is not a flat curve, it is a broken measurement.
|
||||||
|
// Genuine O(1) work still shows noise; a hard-flat series means the work was
|
||||||
|
// optimised away, the timer has insufficient resolution, or the benchmark body
|
||||||
|
// never executed.
|
||||||
|
fn elb_implausibly_flat(vals: [Int]) -> Bool {
|
||||||
|
let n: Int = native_list_len(vals)
|
||||||
|
if n < 3 { return false }
|
||||||
|
let first: Int = native_list_get(vals, 0)
|
||||||
|
let last: Int = native_list_get(vals, n - 1)
|
||||||
|
if first == 0 {
|
||||||
|
if last == 0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let r: Int = (last * 1000) / first
|
||||||
|
if r < 1100 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_spread_ok — do the consecutive ratios agree with each other?
|
||||||
|
//
|
||||||
|
// This is the ratio-method analogue of a normalised-RMS threshold. If the
|
||||||
|
// doublings disagree wildly the data is noise, a cache cliff, or a phase
|
||||||
|
// change, and the honest report is INDETERMINATE rather than a classification.
|
||||||
|
// Applies to the ASYMPTOTIC TAIL only — the last three ratios.
|
||||||
|
//
|
||||||
|
// The small-n end of any sweep is dominated by fixed overhead, cold caches and
|
||||||
|
// branch predictors that have not warmed. Measured on a genuinely linear
|
||||||
|
// character scan, the ratios ran 3.37, 2.92, 1.76, 1.65: the head looks
|
||||||
|
// quadratic, the tail is the truth. Checking spread across the whole sweep
|
||||||
|
// therefore rejects correct data. A complexity bound is an asymptotic claim, so
|
||||||
|
// it is judged on the asymptotic region — the same reason a benchmark harness
|
||||||
|
// discards warmup rather than averaging it in.
|
||||||
|
fn elb_spread_ok(ratios: [Int]) -> Bool {
|
||||||
|
let total: Int = native_list_len(ratios)
|
||||||
|
if total < 2 { return true }
|
||||||
|
let start: Int = total - 3
|
||||||
|
if start < 0 { let start = 0 }
|
||||||
|
let n: Int = total
|
||||||
|
let lo: Int = 999999
|
||||||
|
let hi: Int = 0
|
||||||
|
let i: Int = start
|
||||||
|
while i < n {
|
||||||
|
let r: Int = native_list_get(ratios, i)
|
||||||
|
if r >= 0 {
|
||||||
|
if r < lo { let lo = r }
|
||||||
|
if r > hi { let hi = r }
|
||||||
|
}
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
if lo <= 0 { return false }
|
||||||
|
// Reject when the widest ratio is more than 2.2x the narrowest. That is
|
||||||
|
// enough slack for real timing noise and tight enough to separate a clean
|
||||||
|
// 2.0 series from a clean 4.0 series.
|
||||||
|
if (hi * 1000) / lo > 2200 { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_ratios — consecutive milli-ratios across the sweep.
|
||||||
|
fn elb_ratios(vals: [Int]) -> [Int] {
|
||||||
|
let out: [Int] = native_list_empty()
|
||||||
|
let n: Int = native_list_len(vals)
|
||||||
|
let i: Int = 1
|
||||||
|
while i < n {
|
||||||
|
let out = native_list_append(out,
|
||||||
|
elb_ratio(native_list_get(vals, i - 1), native_list_get(vals, i)))
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_mean_tail_ratio — mean of the LAST TWO ratios.
|
||||||
|
//
|
||||||
|
// The tail is used deliberately: asymptotic behaviour is what a complexity
|
||||||
|
// bound claims, and the small-n end of any sweep is dominated by fixed
|
||||||
|
// overhead. This is the same reason a benchmark harness discards warmup.
|
||||||
|
fn elb_mean_tail_ratio(ratios: [Int]) -> Int {
|
||||||
|
let n: Int = native_list_len(ratios)
|
||||||
|
if n == 0 { return -1 }
|
||||||
|
if n == 1 { return native_list_get(ratios, 0) }
|
||||||
|
let a: Int = native_list_get(ratios, n - 1)
|
||||||
|
let b: Int = native_list_get(ratios, n - 2)
|
||||||
|
if a < 0 { return b }
|
||||||
|
if b < 0 { return a }
|
||||||
|
return (a + b) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verdicts ─────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 0 PASS measured curve is at or below the declared bound
|
||||||
|
// 1 FAIL measured curve is strictly worse than declared
|
||||||
|
// 2 INDETERMINATE ratios disagree; data is noise or a phase change
|
||||||
|
// 3 REFUSED below the measurement floor, or implausibly flat
|
||||||
|
// 4 BETTER measured strictly better than declared (warn, not fail)
|
||||||
|
|
||||||
|
fn elb_verdict_name(v: Int) -> String {
|
||||||
|
if v == 0 { return "PASS" }
|
||||||
|
if v == 1 { return "FAIL" }
|
||||||
|
if v == 2 { return "INDETERMINATE" }
|
||||||
|
if v == 3 { return "REFUSED" }
|
||||||
|
if v == 4 { return "BETTER" }
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_gate — classify one signal against its declared bound.
|
||||||
|
//
|
||||||
|
// vals measurements, one per sweep point, in sweep order
|
||||||
|
// expect declared curve index (see elb_curve_name)
|
||||||
|
// floor minimum largest-measurement below which we refuse to classify
|
||||||
|
fn elb_gate(vals: [Int], expect: Int, floor: Int) -> Int {
|
||||||
|
if elb_below_floor(vals, floor) { return 3 }
|
||||||
|
if elb_implausibly_flat(vals) { return 3 }
|
||||||
|
let ratios: [Int] = elb_ratios(vals)
|
||||||
|
if !elb_spread_ok(ratios) { return 2 }
|
||||||
|
let m: Int = elb_mean_tail_ratio(ratios)
|
||||||
|
if m < 0 { return 2 }
|
||||||
|
let got: Int = elb_classify_ratio(m)
|
||||||
|
if got > expect { return 1 }
|
||||||
|
if got < expect { return 4 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// elb_measured_curve — the classified curve for a signal, or -1 if unclassifiable.
|
||||||
|
fn elb_measured_curve(vals: [Int], floor: Int) -> Int {
|
||||||
|
if elb_below_floor(vals, floor) { return -1 }
|
||||||
|
if elb_implausibly_flat(vals) { return -1 }
|
||||||
|
let ratios: [Int] = elb_ratios(vals)
|
||||||
|
let m: Int = elb_mean_tail_ratio(ratios)
|
||||||
|
if m < 0 { return -1 }
|
||||||
|
return elb_classify_ratio(m)
|
||||||
|
}
|
||||||
@@ -222,7 +222,7 @@ static double eff_w(double weight, double hebb){
|
|||||||
}
|
}
|
||||||
|
|
||||||
GeoDescriptor* engram_geometry_descriptor(
|
GeoDescriptor* engram_geometry_descriptor(
|
||||||
EngramPagedStore* store, VIndex* vindex,
|
EngramPagedStore* store, const VIndex* vindex,
|
||||||
char** vids, int n_vids,
|
char** vids, int n_vids,
|
||||||
const char* const* seed_ids, size_t n_seeds,
|
const char* const* seed_ids, size_t n_seeds,
|
||||||
const GeoParams* params,
|
const GeoParams* params,
|
||||||
@@ -1401,7 +1401,7 @@ static double geo_weighted_degree(EngramPagedStore* st, const char* id, double e
|
|||||||
return deg;
|
return deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
int engram_geo_reify_store(EngramPagedStore* store, VIndex* vindex,
|
int engram_geo_reify_store(EngramPagedStore* store, const VIndex* vindex,
|
||||||
char** vids, int n_vids,
|
char** vids, int n_vids,
|
||||||
const GeoReifyParams* params){
|
const GeoReifyParams* params){
|
||||||
if(!store) return -1;
|
if(!store) return -1;
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ void engram_geo_mean_free(GeoMeanCache* c);
|
|||||||
* Returns a malloc'd descriptor (free with engram_geo_free), or NULL on error
|
* Returns a malloc'd descriptor (free with engram_geo_free), or NULL on error
|
||||||
* (no seeds resolvable, OOM). */
|
* (no seeds resolvable, OOM). */
|
||||||
GeoDescriptor* engram_geometry_descriptor(
|
GeoDescriptor* engram_geometry_descriptor(
|
||||||
EngramPagedStore* store, VIndex* vindex,
|
EngramPagedStore* store, const VIndex* vindex,
|
||||||
char** vids, int n_vids,
|
char** vids, int n_vids,
|
||||||
const char* const* seed_ids, size_t n_seeds,
|
const char* const* seed_ids, size_t n_seeds,
|
||||||
const GeoParams* params,
|
const GeoParams* params,
|
||||||
@@ -375,7 +375,7 @@ void engram_geo_reify_default_params(GeoReifyParams* p);
|
|||||||
* neighborhood (+ member edges), superseding any prior same-hub record with
|
* neighborhood (+ member edges), superseding any prior same-hub record with
|
||||||
* provenance. Read-then-write over `store`. Returns #neighborhoods persisted, or <0.
|
* provenance. Read-then-write over `store`. Returns #neighborhoods persisted, or <0.
|
||||||
* Skips existing Neighborhood/GeoMeanFrame nodes when detecting (idempotent re-reify). */
|
* Skips existing Neighborhood/GeoMeanFrame nodes when detecting (idempotent re-reify). */
|
||||||
int engram_geo_reify_store(EngramPagedStore* store, VIndex* vindex,
|
int engram_geo_reify_store(EngramPagedStore* store, const VIndex* vindex,
|
||||||
char** vids, int n_vids,
|
char** vids, int n_vids,
|
||||||
const GeoReifyParams* params);
|
const GeoReifyParams* params);
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ struct VIndex {
|
|||||||
|
|
||||||
int entry; /* entry-point element index, -1 if empty */
|
int entry; /* entry-point element index, -1 if empty */
|
||||||
int max_level; /* current top layer */
|
int max_level; /* current top layer */
|
||||||
|
|
||||||
/* scratch: version-stamped visited set (O(1) reset). */
|
|
||||||
uint32_t* visited;
|
|
||||||
uint32_t visit_epoch;
|
|
||||||
size_t visited_cap;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── small helpers ────────────────────────────────────────────────────────── */
|
/* ── small helpers ────────────────────────────────────────────────────────── */
|
||||||
@@ -166,37 +161,63 @@ static Pair heap_pop(Heap* h, int is_max){
|
|||||||
return top;
|
return top;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── visited set ──────────────────────────────────────────────────────────── */
|
/* ── visited set — owned by the CALL FRAME, never by the index ──────────────
|
||||||
static int visited_ensure(VIndex* ix){
|
* This buffer is per-TRAVERSAL scratch. It used to live in struct VIndex as an
|
||||||
if (ix->visited_cap >= ix->cap && ix->visited) return 0;
|
* allocation optimisation, which made every traversal a write to shared state:
|
||||||
size_t nc = ix->cap ? ix->cap : 16;
|
* two concurrent vindex_search calls stamped each other's epoch and then walked
|
||||||
uint32_t* nv = (uint32_t*)realloc(ix->visited, nc*sizeof(uint32_t));
|
* each other's marks, so even two pure READS corrupted the traversal (measured
|
||||||
if (!nv) return -1;
|
* 2026-08-16: TSan data race at visited_reset, reached from vindex_search on one
|
||||||
if (nc > ix->visited_cap) memset(nv + ix->visited_cap, 0, (nc-ix->visited_cap)*sizeof(uint32_t));
|
* thread and vindex_insert on another; downstream SIGSEGV dereferencing a bogus
|
||||||
ix->visited = nv; ix->visited_cap = nc;
|
* element index).
|
||||||
|
*
|
||||||
|
* It is not an ownership problem and it does not want a lock or a capability —
|
||||||
|
* it was simply misfiled. A pure function's scratch belongs to the call. Moving
|
||||||
|
* it here is what lets vindex_search take a `const VIndex*`, which is in turn
|
||||||
|
* what makes "search does not mutate the index" a COMPILE-TIME property instead
|
||||||
|
* of a review comment.
|
||||||
|
*
|
||||||
|
* Cost: one calloc/free of cap*4 bytes per traversal (~55 KB at the live store's
|
||||||
|
* 13,820 elements), against thousands of dim-768 dot products in the same call.
|
||||||
|
* Deliberately NOT __thread: http_worker is a thread per connection, so a
|
||||||
|
* thread-local buffer would retain ~55 KB per connection for the process life. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t* mark; /* per-element epoch stamp */
|
||||||
|
uint32_t epoch; /* current traversal's stamp; 0 == "no traversal yet" */
|
||||||
|
size_t cap;
|
||||||
|
} VVisit;
|
||||||
|
|
||||||
|
/* calloc leaves every stamp 0 and epoch 0; the first visit_reset moves to
|
||||||
|
* epoch 1, so no element reads as visited before it is marked. */
|
||||||
|
static int visit_init(VVisit* v, size_t cap){
|
||||||
|
size_t nc = cap ? cap : 16;
|
||||||
|
v->mark = (uint32_t*)calloc(nc, sizeof(uint32_t));
|
||||||
|
if (!v->mark) return -1;
|
||||||
|
v->cap = nc; v->epoch = 0;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
static inline void visited_reset(VIndex* ix){
|
static void visit_dispose(VVisit* v){ free(v->mark); v->mark = NULL; v->cap = 0; }
|
||||||
if (++ix->visit_epoch == 0){ /* wrapped: clear all */
|
static inline void visit_reset(VVisit* v){
|
||||||
memset(ix->visited, 0, ix->visited_cap*sizeof(uint32_t));
|
if (++v->epoch == 0){ /* wrapped: clear all */
|
||||||
ix->visit_epoch = 1;
|
memset(v->mark, 0, v->cap*sizeof(uint32_t));
|
||||||
|
v->epoch = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
static inline int is_visited(VIndex* ix, int e){ return ix->visited[e]==ix->visit_epoch; }
|
static inline int is_visited(const VVisit* v, int e){ return v->mark[e]==v->epoch; }
|
||||||
static inline void mark_visited(VIndex* ix, int e){ ix->visited[e]=ix->visit_epoch; }
|
static inline void mark_visited(VVisit* v, int e){ v->mark[e]=v->epoch; }
|
||||||
|
|
||||||
/* ── search one layer (Algorithm 2): best-first, ef-bounded ───────────────── */
|
/* ── search one layer (Algorithm 2): best-first, ef-bounded ───────────────── */
|
||||||
/* Returns results as an unsorted Heap (max-heap on distance, size<=ef). Caller
|
/* Returns results as an unsorted Heap (max-heap on distance, size<=ef). Caller
|
||||||
* owns res->a. `q` is a normalised query. */
|
* owns res->a. `q` is a normalised query. */
|
||||||
static int search_layer(VIndex* ix, const float* q, const int* eps, int neps,
|
static int search_layer(const VIndex* ix, VVisit* vis, const float* q,
|
||||||
|
const int* eps, int neps,
|
||||||
int ef, int layer, Heap* res /*out, max-heap*/){
|
int ef, int layer, Heap* res /*out, max-heap*/){
|
||||||
Heap cand = {0,0,0}; /* min-heap: nearest to expand */
|
Heap cand = {0,0,0}; /* min-heap: nearest to expand */
|
||||||
res->a=NULL; res->n=0; res->cap=0;
|
res->a=NULL; res->n=0; res->cap=0;
|
||||||
visited_reset(ix);
|
visit_reset(vis);
|
||||||
for (int i=0;i<neps;i++){
|
for (int i=0;i<neps;i++){
|
||||||
int e = eps[i];
|
int e = eps[i];
|
||||||
if (is_visited(ix,e)) continue;
|
if (is_visited(vis,e)) continue;
|
||||||
mark_visited(ix,e);
|
mark_visited(vis,e);
|
||||||
float d = vdist(ix, q, ix->elems[e].vec);
|
float d = vdist(ix, q, ix->elems[e].vec);
|
||||||
Pair p = { d, e };
|
Pair p = { d, e };
|
||||||
if (heap_push(&cand,p,0) || heap_push(res,p,1)){ free(cand.a); return -1; }
|
if (heap_push(&cand,p,0) || heap_push(res,p,1)){ free(cand.a); return -1; }
|
||||||
@@ -212,8 +233,8 @@ static int search_layer(VIndex* ix, const float* q, const int* eps, int neps,
|
|||||||
NeighList* nl = &ce->links[layer];
|
NeighList* nl = &ce->links[layer];
|
||||||
for (int i=0;i<nl->count;i++){
|
for (int i=0;i<nl->count;i++){
|
||||||
int e = nl->ids[i];
|
int e = nl->ids[i];
|
||||||
if (is_visited(ix,e)) continue;
|
if (is_visited(vis,e)) continue;
|
||||||
mark_visited(ix,e);
|
mark_visited(vis,e);
|
||||||
float d = vdist(ix, q, ix->elems[e].vec);
|
float d = vdist(ix, q, ix->elems[e].vec);
|
||||||
if (res->n < ef || d < res->a[0].d){
|
if (res->n < ef || d < res->a[0].d){
|
||||||
Pair p = { d, e };
|
Pair p = { d, e };
|
||||||
@@ -232,7 +253,7 @@ static int search_layer(VIndex* ix, const float* q, const int* eps, int neps,
|
|||||||
* Keep c only if it is nearer to q than to every already-chosen neighbour;
|
* Keep c only if it is nearer to q than to every already-chosen neighbour;
|
||||||
* backfill from the pruned set (nearest first) to reach M for connectivity.
|
* backfill from the pruned set (nearest first) to reach M for connectivity.
|
||||||
* Writes chosen element indices into out[], returns the count. */
|
* Writes chosen element indices into out[], returns the count. */
|
||||||
static int select_neighbors(VIndex* ix, const float* q, Pair* W, int nW, int M, int* out){
|
static int select_neighbors(const VIndex* ix, const float* q, Pair* W, int nW, int M, int* out){
|
||||||
(void)q; /* q's distances are precomputed in W[].d; kept for call-site clarity */
|
(void)q; /* q's distances are precomputed in W[].d; kept for call-site clarity */
|
||||||
/* sort W ascending by (dist,elem) — deterministic. */
|
/* sort W ascending by (dist,elem) — deterministic. */
|
||||||
for (int i=1;i<nW;i++){ /* insertion sort (nW small) */
|
for (int i=1;i<nW;i++){ /* insertion sort (nW small) */
|
||||||
@@ -281,7 +302,7 @@ static int elems_reserve(VIndex* ix){
|
|||||||
Elem* ne = (Elem*)realloc(ix->elems, nc*sizeof(Elem));
|
Elem* ne = (Elem*)realloc(ix->elems, nc*sizeof(Elem));
|
||||||
if (!ne) return -1;
|
if (!ne) return -1;
|
||||||
ix->elems = ne; ix->cap = nc;
|
ix->elems = ne; ix->cap = nc;
|
||||||
return visited_ensure(ix);
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
||||||
@@ -307,13 +328,19 @@ int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* This call frame owns its traversal scratch for the whole insert. ix->cap
|
||||||
|
* already covers `cur` (elems_reserve ran above), so every reachable element
|
||||||
|
* index is in range. */
|
||||||
|
VVisit vis;
|
||||||
|
if (visit_init(&vis, ix->cap)) return -1;
|
||||||
|
|
||||||
int ep = ix->entry;
|
int ep = ix->entry;
|
||||||
int L = ix->max_level;
|
int L = ix->max_level;
|
||||||
/* greedy descent through layers above `level` to refine the entry point. */
|
/* greedy descent through layers above `level` to refine the entry point. */
|
||||||
for (int lc = L; lc > level; lc--){
|
for (int lc = L; lc > level; lc--){
|
||||||
Heap r = {0,0,0};
|
Heap r = {0,0,0};
|
||||||
int eps1[1] = { ep };
|
int eps1[1] = { ep };
|
||||||
if (search_layer(ix, el->vec, eps1, 1, 1, lc, &r)){ return -1; }
|
if (search_layer(ix, &vis, el->vec, eps1, 1, 1, lc, &r)){ visit_dispose(&vis); return -1; }
|
||||||
if (r.n){ ep = r.a[0].e; float bd=r.a[0].d;
|
if (r.n){ ep = r.a[0].e; float bd=r.a[0].d;
|
||||||
for (int i=1;i<r.n;i++) if (r.a[i].d<bd){bd=r.a[i].d; ep=r.a[i].e;} }
|
for (int i=1;i<r.n;i++) if (r.a[i].d<bd){bd=r.a[i].d; ep=r.a[i].e;} }
|
||||||
free(r.a);
|
free(r.a);
|
||||||
@@ -329,7 +356,7 @@ int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
|||||||
for (int lc = start; lc >= 0; lc--){
|
for (int lc = start; lc >= 0; lc--){
|
||||||
int Mmax = (lc==0) ? ix->M0 : ix->M;
|
int Mmax = (lc==0) ? ix->M0 : ix->M;
|
||||||
Heap W = {0,0,0};
|
Heap W = {0,0,0};
|
||||||
if (search_layer(ix, el->vec, eps, neps, ix->ef_construction, lc, &W)){ rc=-1; break; }
|
if (search_layer(ix, &vis, el->vec, eps, neps, ix->ef_construction, lc, &W)){ rc=-1; break; }
|
||||||
int* chosen = (int*)malloc((size_t)(W.n?W.n:1)*sizeof(int));
|
int* chosen = (int*)malloc((size_t)(W.n?W.n:1)*sizeof(int));
|
||||||
if (!chosen){ free(W.a); rc=-1; break; }
|
if (!chosen){ free(W.a); rc=-1; break; }
|
||||||
int nc = select_neighbors(ix, el->vec, W.a, W.n, Mmax, chosen);
|
int nc = select_neighbors(ix, el->vec, W.a, W.n, Mmax, chosen);
|
||||||
@@ -357,13 +384,17 @@ int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
|||||||
}
|
}
|
||||||
done:
|
done:
|
||||||
free(eps_owned);
|
free(eps_owned);
|
||||||
|
visit_dispose(&vis);
|
||||||
if (rc) return -1;
|
if (rc) return -1;
|
||||||
if (level > ix->max_level){ ix->max_level = level; ix->entry = cur; }
|
if (level > ix->max_level){ ix->max_level = level; ix->entry = cur; }
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── search ───────────────────────────────────────────────────────────────── */
|
/* ── search ───────────────────────────────────────────────────────────────── */
|
||||||
int vindex_search(VIndex* ix, const float* query, int k, int ef_search,
|
/* `ix` is const: search is pure with respect to the index. That is enforced by
|
||||||
|
* the compiler, not by convention — it is the whole point of moving the visited
|
||||||
|
* set into the frame below. */
|
||||||
|
int vindex_search(const VIndex* ix, const float* query, int k, int ef_search,
|
||||||
uint64_t* node_id_out, float* dist_out){
|
uint64_t* node_id_out, float* dist_out){
|
||||||
if (!ix || !query || k <= 0) return -1;
|
if (!ix || !query || k <= 0) return -1;
|
||||||
if (ix->entry < 0) return 0;
|
if (ix->entry < 0) return 0;
|
||||||
@@ -373,11 +404,15 @@ int vindex_search(VIndex* ix, const float* query, int k, int ef_search,
|
|||||||
float* q = vec_normalise_copy(query, ix->dim);
|
float* q = vec_normalise_copy(query, ix->dim);
|
||||||
if (!q) return -1;
|
if (!q) return -1;
|
||||||
|
|
||||||
|
/* This call frame owns its traversal scratch. */
|
||||||
|
VVisit vis;
|
||||||
|
if (visit_init(&vis, ix->cap)){ free(q); return -1; }
|
||||||
|
|
||||||
int ep = ix->entry;
|
int ep = ix->entry;
|
||||||
for (int lc = ix->max_level; lc > 0; lc--){
|
for (int lc = ix->max_level; lc > 0; lc--){
|
||||||
Heap r = {0,0,0};
|
Heap r = {0,0,0};
|
||||||
int eps[1] = { ep };
|
int eps[1] = { ep };
|
||||||
if (search_layer(ix, q, eps, 1, 1, lc, &r)){ free(q); return -1; }
|
if (search_layer(ix, &vis, q, eps, 1, 1, lc, &r)){ visit_dispose(&vis); free(q); return -1; }
|
||||||
if (r.n){ int b=r.a[0].e; float bd=r.a[0].d;
|
if (r.n){ int b=r.a[0].e; float bd=r.a[0].d;
|
||||||
for (int i=1;i<r.n;i++) if (r.a[i].d<bd){bd=r.a[i].d; b=r.a[i].e;}
|
for (int i=1;i<r.n;i++) if (r.a[i].d<bd){bd=r.a[i].d; b=r.a[i].e;}
|
||||||
ep = b; }
|
ep = b; }
|
||||||
@@ -385,7 +420,8 @@ int vindex_search(VIndex* ix, const float* query, int k, int ef_search,
|
|||||||
}
|
}
|
||||||
Heap res = {0,0,0};
|
Heap res = {0,0,0};
|
||||||
int eps[1] = { ep };
|
int eps[1] = { ep };
|
||||||
if (search_layer(ix, q, eps, 1, ef_search, 0, &res)){ free(res.a); free(q); return -1; }
|
if (search_layer(ix, &vis, q, eps, 1, ef_search, 0, &res)){ visit_dispose(&vis); free(res.a); free(q); return -1; }
|
||||||
|
visit_dispose(&vis);
|
||||||
free(q);
|
free(q);
|
||||||
|
|
||||||
/* res is a max-heap of size<=ef; pop into ascending order, keep nearest k. */
|
/* res is a max-heap of size<=ef; pop into ascending order, keep nearest k. */
|
||||||
@@ -419,7 +455,6 @@ VIndex* vindex_create(int dim, int M, int ef_construction){
|
|||||||
ix->mL = 1.0 / log((double)M > 1.0 ? (double)M : 2.0);
|
ix->mL = 1.0 / log((double)M > 1.0 ? (double)M : 2.0);
|
||||||
ix->entry = -1;
|
ix->entry = -1;
|
||||||
ix->max_level = 0;
|
ix->max_level = 0;
|
||||||
ix->visit_epoch = 0;
|
|
||||||
return ix;
|
return ix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +467,6 @@ void vindex_free(VIndex* ix){
|
|||||||
free(e->vec);
|
free(e->vec);
|
||||||
}
|
}
|
||||||
free(ix->elems);
|
free(ix->elems);
|
||||||
free(ix->visited);
|
|
||||||
free(ix);
|
free(ix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,15 @@ int vindex_insert(VIndex* idx, uint64_t node_id, const float* vec);
|
|||||||
* first (ascending distance). Either out array may be NULL to skip it.
|
* first (ascending distance). Either out array may be NULL to skip it.
|
||||||
* ef_search — search-time candidate width; larger == higher recall, slower.
|
* ef_search — search-time candidate width; larger == higher recall, slower.
|
||||||
* Pass <=0 for VINDEX_DEFAULT_EF_SEARCH. Internally clamped to >=k.
|
* Pass <=0 for VINDEX_DEFAULT_EF_SEARCH. Internally clamped to >=k.
|
||||||
* Returns the number of results written, or <0 on error. */
|
* Returns the number of results written, or <0 on error.
|
||||||
int vindex_search(VIndex* idx, const float* query, int k, int ef_search,
|
*
|
||||||
|
* `idx` is const BY CONTRACT AND BY TYPE: search does not mutate the index. The
|
||||||
|
* traversal's visited set is owned by the call frame, so N threads may search one
|
||||||
|
* index concurrently. Concurrent search against a vindex_insert on the same index
|
||||||
|
* is still unsafe — insert rewires existing elements' neighbour lists and reallocs
|
||||||
|
* elems[] — so the index's owner must not extend a published index under a live
|
||||||
|
* reader. See eg_vindex_view / eg_vindex_maintain in el_runtime.c. */
|
||||||
|
int vindex_search(const VIndex* idx, const float* query, int k, int ef_search,
|
||||||
uint64_t* node_id_out, float* dist_out);
|
uint64_t* node_id_out, float* dist_out);
|
||||||
|
|
||||||
/* Number of vectors currently indexed. */
|
/* Number of vectors currently indexed. */
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# El Runtime — Ownership and Capability ABI
|
||||||
|
|
||||||
|
**Status:** §0–§2 verified. §3 re-derived and **built** for the vector index (2026-08-16); not yet applied to the resident RAM graph.
|
||||||
|
**Date:** 2026-08-16
|
||||||
|
**Scope:** `lang/runtime/` — every El program (soul, engram, cgi-studio vessels) inherits this by rebuild. Nothing in this document is a change to any El *program*.
|
||||||
|
|
||||||
|
**Note on §1's line numbers:** they were read against a checkout that has since shifted by ~135 lines. Verified positions as of `a67452f` are in §2a.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The residual
|
||||||
|
|
||||||
|
> **Builtins own memory and reach process state directly.**
|
||||||
|
|
||||||
|
That is the residual — the generator. Everything below labelled a "residue" is a deposit left by it. The distinction matters because we have spent significant effort removing deposits, and deposits regenerate.
|
||||||
|
|
||||||
|
A residue is fixed. A residual is eliminated. Fixing residues while the residual stands produces exactly the pattern observed on 2026-08-15/16: a run of individually-correct patches, each verified, followed by a new defect of the same shape in a different file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The residues, measured
|
||||||
|
|
||||||
|
Each of these is a distinct merged or proposed fix. Each addresses one deposit. None addresses the residual.
|
||||||
|
|
||||||
|
| residue | location | fix that was applied or proposed |
|
||||||
|
|---|---|---|
|
||||||
|
| `state_get` leaked its return value per call — 15 MB over 200k calls | builtin | el #140 (merged) |
|
||||||
|
| VIndex freed under a concurrent reader | `el_runtime.c:9424` | `fb32d15` guard (merged 08:46:43) |
|
||||||
|
| `_eg_vindex_seen` realloc'd on a read path | `el_runtime.c:9412` | same guard |
|
||||||
|
| `vindex_insert` on a read path | `el_runtime.c:9434`, `9450` | same guard |
|
||||||
|
| shared `visited` / epoch scratch stomped by concurrent searches | `engram_vindex.c:79–81`, `169–186`, `195` | proposed: move to per-search frame |
|
||||||
|
| nine append sites, none indexing → lazily-embedded nodes invisible | `el_runtime.c:7806, 7988, 8148, 8224, 11526, 11731, 12050, 15295, 15312` | "embed-gap #20", patched by making the *read* path catch up (`9439` comment) |
|
||||||
|
|
||||||
|
**Measured:** all file/line references above, read 2026-08-16. Crash frames `engram_activate → eg_vindex_sync → vindex_insert → _realloc → _xzm_xzone_malloc_freelist_outlined` are accounted for by rows 2–4.
|
||||||
|
|
||||||
|
**Inferred, not yet verified:** that the nine append sites do not share a single commit point. This needs one pass before Change C is sized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why these are one defect
|
||||||
|
|
||||||
|
`eg_vindex_sync` (`el_runtime.c:9419`) has exactly three callers, and **all three are reads**:
|
||||||
|
|
||||||
|
- `engram_activate` — `9802`
|
||||||
|
- `eg_knn_for_node` — `13075` (its own header comment states *"No writes."*)
|
||||||
|
- `engram_geo_reify_run_json` — `13285`
|
||||||
|
|
||||||
|
It mutates five process-global statics (`9400–9404`): `_eg_vindex`, `_eg_vindex_dim`, `_eg_vindex_built_nc`, `_eg_vindex_seen`, `_eg_vindex_seen_cap`.
|
||||||
|
|
||||||
|
Reads mutate because index maintenance was never given an owner on the write side. It got bolted onto reads, because a builtin *could* reach the globals — nothing prevented it. Likewise `state_get` leaked because a builtin *owned* the value it returned; nothing prevented that either.
|
||||||
|
|
||||||
|
The store is architecturally append-only and superseding. A read path that mutates contradicts that directly. The contradiction is expressible only because the ABI permits it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2a. Verified positions and the fact §1 missed
|
||||||
|
|
||||||
|
Read directly at `a67452f`, 2026-08-16. §1's line numbers predate a ~135-line shift; these are current.
|
||||||
|
|
||||||
|
| thing | §1 said | actually |
|
||||||
|
|---|---|---|
|
||||||
|
| five process-global statics | 9400–9404 | **9535–9539** |
|
||||||
|
| `eg_vindex_seen_ensure` realloc | 9412 | **9547** |
|
||||||
|
| `eg_vindex_sync` | 9419 | **9554** |
|
||||||
|
| `vindex_free` on a read path | 9424 | **9559** |
|
||||||
|
| `vindex_insert` on a read path | 9434 / 9450 | **9569** (build) / **9585** (incremental) |
|
||||||
|
| caller: `engram_activate_inner` | 9802 | **9939** |
|
||||||
|
| caller: `eg_knn_for_node` | 13075 | **13212** |
|
||||||
|
| caller: `engram_geo_reify_run_json` | 13285 | **13422** |
|
||||||
|
| `fb32d15` guard | — | lock **1602**, depth **1631**, `eg_guard_enter` **1636**, `http_worker` acquire **1687**, `engram_activate` wrapper **14097** |
|
||||||
|
| VIndex scratch fields | 79–81 | **79–81** ✓ |
|
||||||
|
| `search_layer` race site | 195 | **195** ✓ |
|
||||||
|
|
||||||
|
**The structural fact §1 and §3 both missed:** *the index does not inherit the store's append-only property.* `vindex_insert` rewires the `NeighList` links of already-existing elements and reallocs `elems[]` — so extending the index mutates the whole structure, not just its tail. This is why "make reads pure" is necessary but **not sufficient**, and why §3 needed a publication boundary rather than only a capability split. It is reproduced as a standing test (`unsynchronized` half, §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The change
|
||||||
|
|
||||||
|
*(Re-derived 2026-08-16. The previous §3 — a runtime context struct carrying read/write **capability pointers** to every builtin — was written in mutable-store, C-ownership terms. It asked "who is permitted to mutate the shared thing?", which presupposes a shared mutable thing. The engram is immutable and recall is projection; what does not mutate needs no ownership discipline. So the question is not answered, it is dissolved. The implemented change is below.)*
|
||||||
|
|
||||||
|
### 3.1 Three moves, in decreasing order of how much they dissolve
|
||||||
|
|
||||||
|
**(1) Misfiled scratch is not shared state.** `visited` / `visit_epoch` were never conceptually owned by the index — they are one traversal's local, hoisted into `struct VIndex` as an allocation optimisation. Nothing about them is derived geometry. They want neither a lock nor a capability nor a checkout pool: a pure function's scratch belongs to its call frame, and the fix is to put it back there. This is not "the capability model applied by hand to one global"; it is the deletion of a false ownership claim.
|
||||||
|
|
||||||
|
**(2) `const` is the capability, and immutability hands it over for free.** Once the scratch leaves the struct, `search_layer` reads the index and nothing else — so `vindex_search` can take a `const VIndex*`. That is *precisely* the teeth old-§3 wanted from capability pointers: a read path physically cannot call `vindex_insert`, and it is a **compile error**, not a review comment. It costs one qualifier rather than a new ABI swept across hundreds of builtins. The compiler enforces it on every future caller for the same reason.
|
||||||
|
|
||||||
|
> The capability type was already in the language. It is spelled `const`.
|
||||||
|
|
||||||
|
**(3) What remains is a publication problem, not an ownership problem.** With scratch in the frame and reads const, one hazard survives, and it is real: **HNSW insert is not an append.** `vindex_insert` rewires the `NeighList` links of *already-existing* elements and reallocs `elems[]`. The store's append-only property does **not** transfer to the index derived from it. So a reader projecting against the index while its owner extends it is unsafe no matter how pure search is.
|
||||||
|
|
||||||
|
Immutability answers this too, and the answer is publication:
|
||||||
|
|
||||||
|
- **`eg_vindex_maintain`** — the sole mutator. Takes the boundary exclusively; never runs beside a reader.
|
||||||
|
- **`eg_vindex_view`** — returns a `const VIndex*` with the boundary held for read. N readers project concurrently; none can mutate.
|
||||||
|
|
||||||
|
A read path may **demand that a current snapshot exist** — that is a request to the owner, not a mutation by the reader. What it may not do is mutate the geometry it is projecting against. `view` / `maintain` is exactly that split, and it is why this replaces `eg_vindex_sync` rather than wrapping it.
|
||||||
|
|
||||||
|
**Write-side owner.** Index membership is owned by the event *"an embedding became present on this ordinal"* — not by node append, since a node without an embedding cannot be in a vector index at all. `eg_vindex_note_embedded` hooks the embedding-assignment sites: one O(log n) insert, no O(node_count) presence scan. This also retires the "STALENESS (honest tradeoff)" note in the old `eg_vindex_sync`, where a lazily-embedded *older* node stayed invisible to `route_nearest` / autoconnect until the next full rebuild.
|
||||||
|
|
||||||
|
### 3.2 What this does not claim
|
||||||
|
|
||||||
|
The **resident RAM graph** (`g->nodes` / `g->edges`) is a *separate* residue of the same residual and is untouched by this change. It is realloc'd in place (`el_runtime.c:7618`, `7629`), so an awareness-thread reader holding `EngramNode* n = &g->nodes[i]` across a concurrent append holds a dangling pointer — and `engram_activate_inner`'s embed-backfill writes `n->emb` through exactly such a pointer. It wants the same publication treatment the index just received. Until that lands, the `fb32d15` guard stays (see §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Why this is not a large change
|
||||||
|
|
||||||
|
The old §4 argued that El owning its compiler makes a capability-ABI sweep mechanical, since `elc` generates every builtin call site. That argument was load-bearing only for the ABI, and the ABI is gone.
|
||||||
|
|
||||||
|
The constraint now travels with the **type of the thing**, not the shape of every call site — so no sweep is needed at all. Measured extent of the implemented change: two qualifiers (`const VIndex*` on `vindex_search`, propagated to `engram_geometry_descriptor` and `engram_geo_reify_store`), one struct field group relocated to a call frame, one rwlock, and three read call sites converted from `eg_vindex_sync` to `view`/`release`.
|
||||||
|
|
||||||
|
The payoff of owning the language is unchanged and is now *cheaper*: introduced once, enforced by the compiler on every future builtin, cannot subsequently be forgotten. Contrast the current state, where the same discipline was maintained by hand across hundreds of builtins and demonstrably failed at least six times.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What this deletes
|
||||||
|
|
||||||
|
**Deleted (done, 2026-08-16):**
|
||||||
|
|
||||||
|
- `eg_vindex_sync` — the function itself. Not renamed: split into `eg_vindex_maintain` (mutating, exclusive, sole owner) and `eg_vindex_view` (const, shared). A name that meant "read paths repair the index" had to stop existing.
|
||||||
|
- `VIndex::visited` / `visit_epoch` / `visited_cap` — the struct fields, `visited_ensure`, its call from `elems_reserve`, `ix->visit_epoch = 0` in `vindex_create`, and `free(ix->visited)` in `vindex_free`.
|
||||||
|
- The **proposed** per-search scratch *struct on the index* (a checkout pool / `VisitedListPool`) — never built. The buffer is a plain frame local; a pool is machinery for an ownership question that no longer exists.
|
||||||
|
- The **proposed** reader-view / owner-handle split for VIndex specifically — superseded. `const` already is the reader view.
|
||||||
|
- `EXPECT_RACE` in `run_vindex_concurrency_tests.sh` — a knob that let a known defect ride as "expected". Replaced by four halves with real verdicts.
|
||||||
|
|
||||||
|
**NOT deleted — the design doc was wrong about this one:**
|
||||||
|
|
||||||
|
- `fb32d15` (`eg_guard_enter` / `engram_req_lock` / `_eg_req_depth`). §5 originally called for its removal as "a lock protecting a mutation that ceases to exist." **Measured, it guards two things, and only one of them ceases to exist.** Its own comment names both: the RAM graph *and* `_eg_vindex`. The vindex justification is retired; the RAM-graph justification is independently load-bearing (§3.2), and removing the guard reintroduces the measured 11171→9579 edge-loss defect from 2026-08-14. Its comment has been narrowed to state the RAM graph only. **Precondition for deleting it:** the resident graph gets the same publication boundary the index just got.
|
||||||
|
- el #140's hand-patch. Left in place — the leak stops being *expressible* only under the abandoned capability-ABI §3, which is not what was built.
|
||||||
|
|
||||||
|
**Ordering consequence (revised):** the original ordering claim — "the residual lands first, the residues evaporate rather than get fixed" — did not survive contact. The residual here is not a single ABI that dissolves everything at once; it is a *property* (derived state is published, never edited) applied per structure. The index now has it. The RAM graph does not yet. Residues evaporate **per structure, in the order the property is applied**, and a residue whose structure has not been converted must be left standing, not deleted on the strength of the plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sequencing
|
||||||
|
|
||||||
|
1. **Read** how builtins are declared and dispatched, to confirm the call sites are compiler-generated in one place. *(This determines whether §4 holds. If dispatch is scattered, re-size before proceeding.)*
|
||||||
|
2. Introduce the context type and capability types.
|
||||||
|
3. Codegen emits the context at every builtin call site.
|
||||||
|
4. Mechanical sweep of builtin signatures.
|
||||||
|
5. Move index maintenance behind the write capability; the three read callers take the read capability.
|
||||||
|
6. Delete the residue-fixes listed in §5.
|
||||||
|
7. **One** build of soul from el dev — which resolves the `state_get` leak and the crash together, rather than deploying a leak fix that reintroduces the crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions
|
||||||
|
|
||||||
|
**Answered 2026-08-16:**
|
||||||
|
|
||||||
|
- ~~Do the nine append sites share a commit point?~~ **Moot.** The question was mis-aimed: node append is not the event that owns index membership, because a node without an embedding cannot be in a vector index. The five *embedding-assignment* sites are the real owner points (`el_runtime.c:7091, 9839, 13362, 15002`, plus snapshot-restore at `7951`), and three of them carry the ordinal directly — which is all `eg_vindex_note_embedded` needs. The other two run before the node is resident, where the cold build picks it up.
|
||||||
|
- ~~Does anything outside `lang/runtime/` construct a second `VIndex`?~~ **No.** Swept: the only constructors outside the runtime are `engram/test/*` and `lang/runtime/vindex_bench.c`, all single-threaded and index-private. Inside the runtime, `engram_self_reify_beat_json` builds a **private** index deliberately and never touches the shared boundary — that was already correct and is unchanged.
|
||||||
|
- ~~Does the HTTP worker pool contend on the same globals?~~ **Yes, and it was never the whole story.** Workers serialize against each other on `engram_req_lock`, but the awareness main thread does not take it at all — that is the gap `fb32d15` closed. Now verified independent of that guard: the index boundary is its own rwlock, so worker/awareness contention on `_eg_vindex` is handled whether or not the request lock is held.
|
||||||
|
|
||||||
|
**Still open:**
|
||||||
|
|
||||||
|
- The resident RAM graph wants the same publication boundary (§3.2). Until it has one, `fb32d15` cannot be deleted.
|
||||||
|
- `eg_vindex_view` holds the boundary for read across `engram_geo_reify_store`, which is a long pass. Correct, but it stalls the owner for that duration. If reify latency becomes a problem the answer is a refcounted snapshot, not a shorter lock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7a. Evidence (measured 2026-08-16, `engram/test/run_vindex_concurrency_tests.sh`)
|
||||||
|
|
||||||
|
| half | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `single` — 3000 vectors, 1 thread, ASan+UBSan | clean | clean |
|
||||||
|
| `readers` — 4 readers, no writer, TSan | **race** at `engram_vindex.c:195` (`visited_reset` ← `vindex_search`) | **clean** |
|
||||||
|
| `unsynchronized` — writer+reader, bare index, TSan | race | **race, expected and permanent** — now the proof the boundary must exist |
|
||||||
|
| `published` — owner + 4 readers through the boundary, TSan | *(did not exist)* | **clean**, all 3000 inserts landed |
|
||||||
|
|
||||||
|
No recall regression: `recall@10 = 0.9365` at `ef_search=128` (gate ≥ 0.90); the determinism test still yields byte-identical results across two independent builds.
|
||||||
|
|
||||||
|
Builds locally: all seven engram runtime translation units compile `-Wall -Wextra` clean, and the full engram binary links (`engram/dist/engram.c` + runtime, arm64). The one pre-existing `-Wcomment` warning in `el_runtime.c` is present at `a67452f` too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What this document is not
|
||||||
|
|
||||||
|
It is not an argument for a memory model in general, a garbage collector, process isolation between soul and engram, or a client/server split of the store. Each of those was considered and each addresses mutation that this change removes. They are answers to a question that stops being asked.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// fitprobe.el — controlled growth-curve specimens for validating the complexity fitter.
|
||||||
|
//
|
||||||
|
// Three deliberately-shaped workloads. None depends on a real defect existing,
|
||||||
|
// which is the point: the fitter must be provable against KNOWN curves.
|
||||||
|
//
|
||||||
|
// linear — one allocation per item. count O(n), bytes O(n), time O(n)
|
||||||
|
// accum — rebuilds its accumulator. count O(n), bytes O(n^2), time O(n^2)
|
||||||
|
// compute — nested arithmetic, no alloc. count O(1), bytes O(1), time O(n^2)
|
||||||
|
//
|
||||||
|
// `compute` is the specimen that matters. It is the shape of el #132
|
||||||
|
// (strlen-per-character inside str_char_code): pure CPU, zero allocation.
|
||||||
|
// An allocation-only gate is structurally blind to it.
|
||||||
|
//
|
||||||
|
// No imports — uses runtime builtins directly so nothing collides.
|
||||||
|
|
||||||
|
fn work_linear(n: Int) -> Int {
|
||||||
|
let parts: [String] = native_list_empty()
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let parts = native_list_append(parts, int_to_str(i))
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return native_list_len(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn work_accum(n: Int) -> Int {
|
||||||
|
let acc: String = ""
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let acc = acc + "x"
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return str_len(acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn work_compute(n: Int) -> Int {
|
||||||
|
// str_char_code is an opaque external call, so the C optimiser cannot
|
||||||
|
// reduce this nest to a closed form the way it does with `total + 1`.
|
||||||
|
// This is the exact shape of el #132: n scans over n characters, pure
|
||||||
|
// CPU, ZERO allocation.
|
||||||
|
let s: String = "abcdefghij"
|
||||||
|
let total: Int = 0
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let j: Int = 0
|
||||||
|
while j < n {
|
||||||
|
let total = total + str_char_code(s, 0)
|
||||||
|
let j = j + 1
|
||||||
|
}
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_one(mode: String, n: Int) {
|
||||||
|
let c0: Int = el_alloc_count()
|
||||||
|
let b0: Int = el_alloc_bytes()
|
||||||
|
let t0: Int = el_now_instant()
|
||||||
|
|
||||||
|
let r: Int = 0
|
||||||
|
if str_eq(mode, "linear") { let r = work_linear(n) }
|
||||||
|
if str_eq(mode, "accum") { let r = work_accum(n) }
|
||||||
|
if str_eq(mode, "compute") { let r = work_compute(n) }
|
||||||
|
|
||||||
|
let t1: Int = el_now_instant()
|
||||||
|
let c1: Int = el_alloc_count()
|
||||||
|
let b1: Int = el_alloc_bytes()
|
||||||
|
|
||||||
|
println(mode + "\t" + int_to_str(n)
|
||||||
|
+ "\t" + int_to_str(c1 - c0)
|
||||||
|
+ "\t" + int_to_str(b1 - b0)
|
||||||
|
+ "\t" + int_to_str((t1 - t0) / 1000)
|
||||||
|
+ "\t" + int_to_str(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sweep(mode: String) {
|
||||||
|
run_one(mode, 200)
|
||||||
|
run_one(mode, 400)
|
||||||
|
run_one(mode, 800)
|
||||||
|
run_one(mode, 1600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Int {
|
||||||
|
println("mode\tn\tallocs\tbytes\tusec\tsink")
|
||||||
|
sweep("linear")
|
||||||
|
sweep("accum")
|
||||||
|
sweep("compute")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import "../../runtime/eltest.el"
|
||||||
|
import "../../runtime/elbench.el"
|
||||||
|
|
||||||
|
// test_elbench.el — proves the growth-curve classifier against KNOWN curves.
|
||||||
|
//
|
||||||
|
// Every series below is real measured data from lang/tests/bench/fitprobe.el
|
||||||
|
// on a geometric sweep n = 200/400/800/1600. The classifier must be provable
|
||||||
|
// without depending on a live defect existing, which is the whole point of
|
||||||
|
// keeping controlled specimens.
|
||||||
|
|
||||||
|
fn _s4(a: Int, b: Int, c: Int, d: Int) -> [Int] {
|
||||||
|
let l: [Int] = native_list_empty()
|
||||||
|
let l = native_list_append(l, a)
|
||||||
|
let l = native_list_append(l, b)
|
||||||
|
let l = native_list_append(l, c)
|
||||||
|
let l = native_list_append(l, d)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
test "classifies a linear allocation series as O(n)" {
|
||||||
|
// fitprobe `linear`, allocation count
|
||||||
|
let v = _s4(208, 409, 810, 1611)
|
||||||
|
assert elb_measured_curve(v, 10) == 2, "linear allocs should classify O(n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "classifies a linear byte series as O(n)" {
|
||||||
|
// fitprobe `linear`, allocation bytes
|
||||||
|
let v = _s4(4786, 9682, 19474, 39658)
|
||||||
|
assert elb_measured_curve(v, 10) == 2, "linear bytes should classify O(n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "classifies a quadratic byte series as O(n^2)" {
|
||||||
|
// fitprobe `accum`, allocation bytes -- the accumulator-rebuild shape
|
||||||
|
let v = _s4(20300, 80600, 321200, 1282400)
|
||||||
|
assert elb_measured_curve(v, 10) == 4, "accum bytes should classify O(n^2)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "accumulator count is linear -- proves count alone misses it" {
|
||||||
|
// Same run as above. The COUNT is exactly linear while bytes are
|
||||||
|
// quadratic. A count-only gate passes this defect clean.
|
||||||
|
let v = _s4(200, 400, 800, 1600)
|
||||||
|
assert elb_measured_curve(v, 10) == 2, "accum count classifies O(n)"
|
||||||
|
assert elb_gate(v, 2, 10) == 0, "count-only gate PASSES the quadratic"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "classifies a quadratic time series as O(n^2)" {
|
||||||
|
// fitprobe `compute` -- el #132's shape: n scans over n characters
|
||||||
|
let v = _s4(67, 205, 818, 3268)
|
||||||
|
assert elb_measured_curve(v, 10) == 4, "compute time should classify O(n^2)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "REFUSES an all-zero series instead of calling it O(1)" {
|
||||||
|
// fitprobe `compute` allocation count. Pure CPU, allocates nothing.
|
||||||
|
// Reporting O(1) here would be a confident answer with nothing behind it.
|
||||||
|
let v = _s4(0, 0, 0, 0)
|
||||||
|
assert elb_gate(v, 2, 10) == 3, "all-zero series must be REFUSED"
|
||||||
|
assert elb_measured_curve(v, 10) < 0, "unclassifiable returns -1"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "REFUSES an implausibly flat series" {
|
||||||
|
// The shape produced when clang closes a loop to a multiply: a real
|
||||||
|
// answer, no work done, no movement across an 8x input range.
|
||||||
|
let v = _s4(1000, 1001, 1002, 1003)
|
||||||
|
assert elb_gate(v, 2, 10) == 3, "hard-flat series must be REFUSED"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gate FAILS a quadratic declared as linear" {
|
||||||
|
let v = _s4(20300, 80600, 321200, 1282400)
|
||||||
|
assert elb_gate(v, 2, 10) == 1, "O(n^2) measured vs O(n) declared must FAIL"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gate PASSES a linear series declared as linear" {
|
||||||
|
let v = _s4(208, 409, 810, 1611)
|
||||||
|
assert elb_gate(v, 2, 10) == 0, "O(n) measured vs O(n) declared must PASS"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gate reports BETTER when measured beats the declared bound" {
|
||||||
|
let v = _s4(208, 409, 810, 1611)
|
||||||
|
assert elb_gate(v, 4, 10) == 4, "O(n) measured vs O(n^2) declared is BETTER"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gate reports INDETERMINATE on disagreeing ratios" {
|
||||||
|
// fitprobe `linear` WALL TIME at these sizes: 26/19/43/78 microseconds.
|
||||||
|
// Ratios 0.73, 2.26, 1.81 disagree well past the noise threshold. The
|
||||||
|
// honest answer is "cannot tell", not a classification -- this is exactly
|
||||||
|
// why benchmarks need auto-scaled iteration counts rather than one shot.
|
||||||
|
let v = _s4(26, 19, 43, 78)
|
||||||
|
assert elb_gate(v, 2, 10) == 2, "disagreeing ratios must be INDETERMINATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "black_box is a real barrier and returns its input" {
|
||||||
|
assert el_black_box(42) == 42, "black_box is value-preserving"
|
||||||
|
let s: Int = 0
|
||||||
|
let i: Int = 0
|
||||||
|
while i < 100 {
|
||||||
|
// Bind the call before using it in arithmetic: `x + call(...)`
|
||||||
|
// lowers to el_str_concat() on integers. Same inference defect
|
||||||
|
// as `call(...) == y` lowering to str_eq().
|
||||||
|
let bx: Int = el_black_box(1)
|
||||||
|
let s = s + bx
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
assert s == 100, "black_box does not disturb the computation"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "curve names round-trip" {
|
||||||
|
assert elb_curve_from_name("O(n)") == 2, "O(n) parses"
|
||||||
|
assert elb_curve_from_name("O(n^2)") == 4, "O(n^2) parses"
|
||||||
|
assert str_eq(elb_curve_name(4), "O(n^2)"), "O(n^2) renders"
|
||||||
|
assert elb_curve_from_name("O(nonsense)") < 0, "unknown curve is -1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import "../../runtime/eltest.el"
|
||||||
|
import "../../runtime/elbench.el"
|
||||||
|
|
||||||
|
// test_lexer_scaling.el — THE ARMED GATE.
|
||||||
|
//
|
||||||
|
// This is the regression test that would have caught el #132.
|
||||||
|
//
|
||||||
|
// #132 was a strlen() inside str_char_code() and str_slice(). The lexer walks
|
||||||
|
// source one character at a time, so every character access rescanned the whole
|
||||||
|
// remaining input: O(n) per character over n characters = O(n^2). It shipped for
|
||||||
|
// months. It was found by a geometric sweep, not by reading code.
|
||||||
|
//
|
||||||
|
// So this test IS a geometric sweep. It scans a string of length n, character by
|
||||||
|
// character, at four doubling sizes, and asserts the cost is linear. If anyone
|
||||||
|
// reintroduces a per-character rescan — in str_char_code, in str_slice, in any
|
||||||
|
// accessor the lexer leans on — the measured curve becomes O(n^2) and this fails.
|
||||||
|
//
|
||||||
|
// The value is in it being ARMED, not in it currently failing. It passes today
|
||||||
|
// because #132 is fixed. That is the correct state for a regression gate.
|
||||||
|
//
|
||||||
|
// Note the deliberate `let c: Int = str_char_code(...)` binding in the scan loop.
|
||||||
|
// Inlining it as `total + str_char_code(s, i)` lowers to el_str_concat() on
|
||||||
|
// integers — the Plus arm of the operator-typing family, still open at the time
|
||||||
|
// of writing. Binding first is the safe form.
|
||||||
|
|
||||||
|
// _mk_string — build a string of length >= n by DOUBLING.
|
||||||
|
//
|
||||||
|
// Deliberately not `s = s + "x"` n times: that is itself quadratic in bytes and
|
||||||
|
// would contaminate the very measurement this test exists to take. Doubling
|
||||||
|
// allocates ~2n total.
|
||||||
|
fn _mk_string(n: Int) -> String {
|
||||||
|
let s: String = "abcdefgh"
|
||||||
|
while str_len(s) < n {
|
||||||
|
let s = s + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// _scan — walk the string one character at a time, REPS times.
|
||||||
|
//
|
||||||
|
// This is the lexer's access pattern reduced to its essential shape. The
|
||||||
|
// repetitions lift the measurement clear of timer resolution; without them the
|
||||||
|
// smaller sizes land in noise and the classifier correctly reports
|
||||||
|
// INDETERMINATE rather than guessing.
|
||||||
|
fn _scan(s: String, n: Int, reps: Int) -> Int {
|
||||||
|
let total: Int = 0
|
||||||
|
let r: Int = 0
|
||||||
|
while r < reps {
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let c: Int = str_char_code(s, i)
|
||||||
|
let total = total + c
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
let r = r + 1
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// _measure_scan — microseconds for a full scan sweep point.
|
||||||
|
fn _measure_scan(n: Int, reps: Int) -> Int {
|
||||||
|
let s: String = _mk_string(n)
|
||||||
|
// WARMUP, discarded. Without it the small-n end of the sweep is dominated
|
||||||
|
// by cold caches and reads as superlinear on genuinely linear work --
|
||||||
|
// measured ratios 3.37 2.92 1.76 1.65 on exactly this workload.
|
||||||
|
let w: Int = _scan(s, n, 2)
|
||||||
|
let wj: Int = el_black_box(w)
|
||||||
|
let t0: Int = el_now_instant()
|
||||||
|
let got: Int = _scan(s, n, reps)
|
||||||
|
let t1: Int = el_now_instant()
|
||||||
|
// Feed the result through the barrier so the scan cannot be elided.
|
||||||
|
let sink: Int = el_black_box(got)
|
||||||
|
if sink == 0 { println("") }
|
||||||
|
return (t1 - t0) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _series4(a: Int, b: Int, c: Int, d: Int) -> [Int] {
|
||||||
|
let l: [Int] = native_list_empty()
|
||||||
|
let l = native_list_append(l, a)
|
||||||
|
let l = native_list_append(l, b)
|
||||||
|
let l = native_list_append(l, c)
|
||||||
|
let l = native_list_append(l, d)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
test "character scan is LINEAR in time -- regression gate for el #132" {
|
||||||
|
let reps: Int = 40
|
||||||
|
let t1: Int = _measure_scan(16384, reps)
|
||||||
|
let t2: Int = _measure_scan(32768, reps)
|
||||||
|
let t3: Int = _measure_scan(65536, reps)
|
||||||
|
let t4: Int = _measure_scan(131072, reps)
|
||||||
|
let series: [Int] = _series4(t1, t2, t3, t4)
|
||||||
|
|
||||||
|
let verdict: Int = elb_gate(series, 2, 50)
|
||||||
|
let measured: Int = elb_measured_curve(series, 50)
|
||||||
|
|
||||||
|
// Report the actual numbers regardless of outcome. A gate that fires
|
||||||
|
// without showing its evidence is just an assertion.
|
||||||
|
println(" scan us: " + int_to_str(t1) + " " + int_to_str(t2) + " "
|
||||||
|
+ int_to_str(t3) + " " + int_to_str(t4)
|
||||||
|
+ " -> " + elb_curve_name(measured) + " [" + elb_verdict_name(verdict) + "]")
|
||||||
|
|
||||||
|
// PASS (0) or BETTER (4) are both acceptable. FAIL (1) means someone
|
||||||
|
// reintroduced superlinear per-character cost. REFUSED (3) or
|
||||||
|
// INDETERMINATE (2) mean the measurement is untrustworthy -- which is
|
||||||
|
// also a failure of this test, deliberately: a gate that cannot measure
|
||||||
|
// must not report success.
|
||||||
|
assert verdict == 0 || verdict == 4, "character scan must measure O(n) or better"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "string building by doubling stays linear in allocated bytes" {
|
||||||
|
let b1: Int = el_alloc_bytes()
|
||||||
|
let s1: String = _mk_string(8192)
|
||||||
|
let b2: Int = el_alloc_bytes()
|
||||||
|
let s2: String = _mk_string(16384)
|
||||||
|
let b3: Int = el_alloc_bytes()
|
||||||
|
let s3: String = _mk_string(32768)
|
||||||
|
let b4: Int = el_alloc_bytes()
|
||||||
|
let s4: String = _mk_string(65536)
|
||||||
|
let b5: Int = el_alloc_bytes()
|
||||||
|
|
||||||
|
let series: [Int] = _series4(b2 - b1, b3 - b2, b4 - b3, b5 - b4)
|
||||||
|
let verdict: Int = elb_gate(series, 2, 1000)
|
||||||
|
let measured: Int = elb_measured_curve(series, 1000)
|
||||||
|
println(" bytes: " + int_to_str(b2 - b1) + " " + int_to_str(b3 - b2) + " "
|
||||||
|
+ int_to_str(b4 - b3) + " " + int_to_str(b5 - b4)
|
||||||
|
+ " -> " + elb_curve_name(measured) + " [" + elb_verdict_name(verdict) + "]")
|
||||||
|
|
||||||
|
assert verdict == 0 || verdict == 4, "doubling build must be O(n) in bytes"
|
||||||
|
assert str_len(s4) >= 65536, "final string reached the requested size"
|
||||||
|
}
|
||||||
|
|
||||||
|
// _scan_quadratic — a DELIBERATELY quadratic scan: for each position, rescan
|
||||||
|
// from the start. This is precisely what el #132 did — strlen() from offset 0
|
||||||
|
// on every character access — reproduced here so the gate can be proven to
|
||||||
|
// FIRE, not merely to pass on healthy code. An unproven gate is decoration.
|
||||||
|
fn _scan_quadratic(s: String, n: Int) -> Int {
|
||||||
|
let total: Int = 0
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let j: Int = 0
|
||||||
|
while j < i {
|
||||||
|
let c: Int = str_char_code(s, j)
|
||||||
|
let total = total + c
|
||||||
|
let j = j + 1
|
||||||
|
}
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _measure_quadratic(n: Int) -> Int {
|
||||||
|
let s: String = _mk_string(n)
|
||||||
|
let w: Int = _scan_quadratic(s, 64)
|
||||||
|
let wj: Int = el_black_box(w)
|
||||||
|
let t0: Int = el_now_instant()
|
||||||
|
let got: Int = _scan_quadratic(s, n)
|
||||||
|
let t1: Int = el_now_instant()
|
||||||
|
let sink: Int = el_black_box(got)
|
||||||
|
return (t1 - t0) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
test "the gate FIRES on a live quadratic scan -- proves it is armed" {
|
||||||
|
let q1: Int = _measure_quadratic(1024)
|
||||||
|
let q2: Int = _measure_quadratic(2048)
|
||||||
|
let q3: Int = _measure_quadratic(4096)
|
||||||
|
let q4: Int = _measure_quadratic(8192)
|
||||||
|
let series: [Int] = _series4(q1, q2, q3, q4)
|
||||||
|
|
||||||
|
let verdict: Int = elb_gate(series, 2, 50)
|
||||||
|
let measured: Int = elb_measured_curve(series, 50)
|
||||||
|
println(" quad us: " + int_to_str(q1) + " " + int_to_str(q2) + " "
|
||||||
|
+ int_to_str(q3) + " " + int_to_str(q4)
|
||||||
|
+ " -> " + elb_curve_name(measured) + " [" + elb_verdict_name(verdict) + "]")
|
||||||
|
|
||||||
|
assert measured == 4, "a rescan-from-zero workload must classify O(n^2)"
|
||||||
|
assert verdict == 1, "declared O(n) against measured O(n^2) must FAIL the gate"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user