Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a1501d097 | |||
| d41645388a | |||
| 8a307dfd42 | |||
| 616815b2ab | |||
| 1a8a966cb3 | |||
| 1f70b9fa18 | |||
| 317466e8f7 | |||
| eb3e6d7c1f | |||
| 88e3008735 | |||
| 26af149aa1 | |||
| c18abf799c | |||
| b305b49f40 | |||
| 8ae163e8e5 | |||
| 3fcc36c2f1 | |||
| a6cef4b983 | |||
| 8e9d88fc01 | |||
| e99a4640e2 | |||
| bdc1f99fb9 | |||
| 44b621e551 | |||
| ded6ca546f | |||
| b5b96c05ed | |||
| c79033b749 | |||
| 1119295238 | |||
| 9c07970943 | |||
| 0832865952 | |||
| e0b2c0ea54 | |||
| cf060adbfd | |||
| 63fe8a766d | |||
| b5a0a729e6 | |||
| b26dd47aef | |||
| b55e6bfd53 | |||
| dbb06f6ee4 | |||
| 906c664a65 | |||
| 6a6b589ba0 | |||
| b5d1e53902 | |||
| 9e96d74f6a | |||
| a8908908df | |||
| 6291a35bb9 | |||
| a69a4a5894 | |||
| edafd8cce8 | |||
| 5e3e69d326 | |||
| 4c3414072b | |||
| 0288024396 | |||
| 3e7ab07e82 | |||
| d231b7e5e7 | |||
| a668062e38 | |||
| 24fac765a6 | |||
| cb1f2a74af | |||
| 37bcf7eb74 | |||
| 2240d26c32 | |||
| 19cc99e57d | |||
| f39ae40047 | |||
| e52415f0e0 | |||
| 7a479111ac | |||
| e917b3d439 | |||
| 777ccc02f0 | |||
| c21074b547 | |||
| 4e24d7d3f1 | |||
| 7557ea6e19 | |||
| 7351fb0a8d | |||
| d545b69614 | |||
| 598915cc61 | |||
| dab14f9100 | |||
| 40eb48e92f | |||
| c9f75e2592 | |||
| 09dade0613 | |||
| 38a8e32d6c | |||
| 15b66c8b1a | |||
| 9883aa7564 | |||
| d45a0882f3 | |||
| 1c9de03fdb | |||
| 3718bf0380 | |||
| 9d40f87926 | |||
| 90d3f0bc76 | |||
| ee39aa5f17 | |||
| 2d0aef4ef8 | |||
| 979e820f68 | |||
| e29fe4fd0b | |||
| 0e924f7df9 | |||
| 1bb1edc851 | |||
| 1010185978 | |||
| ff37835ae5 | |||
| e5c80359a8 | |||
| b53b5b4e8a | |||
| 20bd9ed00b | |||
| 70982498e0 | |||
| 373265c05d | |||
| ed722b9e2e | |||
| b0a78c5737 | |||
| 447d042022 | |||
| d4e82d3d56 | |||
| 40bb6ff579 | |||
| d5411fb58a | |||
| b2aac4bf89 |
@@ -0,0 +1,630 @@
|
||||
# El Test Framework — Design
|
||||
|
||||
**Status:** draft for review
|
||||
**Author:** Neuron
|
||||
**Date:** 2026-08-15
|
||||
**Worktree:** `/Users/will/Development/neuron-technologies/el-worktrees/elc-memory-investigation`
|
||||
|
||||
---
|
||||
|
||||
## 0. The forcing requirement
|
||||
|
||||
We have a confirmed quadratic in `elc`. Peak memory in the old shipped binary and wall-clock in
|
||||
the current source both grow as O(input²). We cannot fix it, because we cannot test it.
|
||||
|
||||
Everything in this document is downstream of one sentence: **a test framework must be able to fail
|
||||
a build when an operation's growth curve degrades from linear to quadratic.**
|
||||
|
||||
That is not a nice-to-have bolted onto a correctness framework. It is the requirement that
|
||||
determines the architecture. Correctness testing is the easy half.
|
||||
|
||||
Second-order requirement, learned the hard way tonight: **the framework must report per-test timing
|
||||
by default.** The current framework prints `N passed, M failed` and nothing else. That is why a
|
||||
3.58-second test file sat in the suite unnoticed. A framework that is structurally blind to time
|
||||
cannot surface the defect class we most need to catch.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today, measured
|
||||
|
||||
### 1.1 Two competing systems, neither complete
|
||||
|
||||
**System A — `lang/runtime/test.el`.** Manual registration, El-level.
|
||||
|
||||
**System B — the compiler's `test { }` block + `elc --test`.** Emits its own harness `main()`
|
||||
with `__el_pass` / `__el_fail` globals (`codegen.el:3777-3796`).
|
||||
|
||||
They do not share a result model. Neither has timing. Both are in the tree.
|
||||
|
||||
### 1.2 Specific defects in System A
|
||||
|
||||
| Defect | Location | Consequence |
|
||||
|---|---|---|
|
||||
| All state as JSON strings in a global string-keyed map | `test.el` throughout | every assertion is `state_get` → `str_to_int` → `int_to_str` → `state_set` |
|
||||
| Failure list appended by string slice + concat | `_test_json_append` | O(n²) in failure count |
|
||||
| One OS thread spawned per test | `_test_run_one` via `__thread_create`/`__thread_join` | thread spawn per test, purely to get dispatch-by-name through dlsym |
|
||||
| Manual registration pairing a string to a function name | `test_case(name, fn_name)` | typo ⇒ test silently never runs, suite still reports pass |
|
||||
| Counters are assertion-level, global | `_test_pass_count` etc. | no per-test record exists at all |
|
||||
| No timing, no structured output, no fixtures, no tags, no filtering, no parameterization, no benchmarks | — | — |
|
||||
|
||||
The registration defect is the serious one. It is not a slow framework, it is a framework that can
|
||||
report success for tests that did not execute.
|
||||
|
||||
### 1.3 Measured cost structure
|
||||
|
||||
Per test file, current build model:
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| `elc` compile `.el` → `.c` | 0.00s (small files) |
|
||||
| **`cc` el_runtime.c → .o** | **0.14s** |
|
||||
| `cc` test .c → .o | 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:
|
||||
|
||||
| File | Bytes | elc time |
|
||||
|---|---|---|
|
||||
| `test_compiler` | 29,685 (+394 KB of imports) | **3.58s** |
|
||||
| `string_test` | 18,545 | 0.01s |
|
||||
| all other 9 files | 2.2–10 KB | 0.00s |
|
||||
|
||||
Two distinct defects in two distinct regimes:
|
||||
|
||||
1. **`test_compiler.el` imports all five compiler sources** — 394 KB in one translation unit. Its
|
||||
3.58s is entirely the quadratic. It is the only file where the quadratic bites.
|
||||
2. **Every other file's cost is 100% redundant `el_runtime.c` rebuilds** — 480 KB of identical C,
|
||||
recompiled once per test file.
|
||||
|
||||
Neither is fixed by making the compiler faster. Both are fixed by the architecture below, and the
|
||||
speedup is a by-product of building it correctly, not the goal.
|
||||
|
||||
### 1.4 The asset worth keeping
|
||||
|
||||
`codegen.el:3651-3652` already collects `test_names` / `test_c_names` — **the compiler already does
|
||||
compile-time test discovery.** It then discards that registry into a hardcoded `main()`.
|
||||
|
||||
That registry is precisely the seam Go's `_testmain.go` and Rust's `test_main_static` are built on.
|
||||
The mechanism we need is half-built and wired to the wrong thing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Grounding — the common spine of excellent frameworks
|
||||
|
||||
Researched from primary sources: Go `testing`/`go test`, Rust `libtest`/Criterion, JUnit 5 Platform,
|
||||
NUnit 3, JMH, Google Benchmark. Six invariants hold across all of them.
|
||||
|
||||
1. **A registry is built before execution** — `(name, metadata, fn-ptr)` triples. Go generates it
|
||||
from an AST scan; Rust synthesizes it in a compiler pass; JMH emits it as a build-time resource;
|
||||
JUnit/NUnit build it reflectively. **Reflection is an implementation of the registry on runtimes
|
||||
where it is cheap. It is never the architecture.**
|
||||
|
||||
2. **Discovery strictly precedes execution.** Every good capability — filtering, listing, counting,
|
||||
sharding, IDE trees, re-run-failed-only, dry runs — is a consequence of this ordering.
|
||||
|
||||
3. **A hierarchy with stable, path-shaped unique IDs.** `TestFoo/subcase_2`. Selection is regex over
|
||||
that path, one pattern per level.
|
||||
|
||||
4. **The framework is a prebuilt library; only the entry point is generated.** "Compile once, link
|
||||
many" is always: framework archive compiled once + a small generated table + one
|
||||
`MainStart(deps, registry)` call. Nobody recompiles the harness per test file.
|
||||
|
||||
5. **Execution emits an event stream; reporters are downstream renderers.** Human text, NDJSON,
|
||||
JUnit XML, TAP are all transforms of one event stream. Go's one architectural mistake is doing
|
||||
this backwards — `test2json` parses human output, and has shipped bugs when user output contains
|
||||
`--- PASS:`.
|
||||
|
||||
6. **A dependency-injection seam at the boundary.** Go's `testdeps.TestDeps` exists so `testing`
|
||||
can avoid importing `regexp`, profilers, and coverage. The execution core knows nothing about
|
||||
output formats.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 The seam
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ user code: foo.el with test { } / bench { } blocks │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ elc --test
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ generated C (per suite, tiny): │
|
||||
│ __el_test_fn_0 .. _N lowered test/bench bodies │
|
||||
│ __el_registry[] static table: name/kind/file/ │
|
||||
│ line/tags/sizes/expected-O │
|
||||
│ __el_dispatch(i) generated switch → body │
|
||||
│ main() { return el_test_main(argc, argv); } │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ cc + link (registry only)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ libeltest.a — PREBUILT ONCE │
|
||||
│ • el_runtime.o (the 480 KB, compiled once, ever) │
|
||||
│ • eltest.o the runner, WRITTEN IN EL │
|
||||
│ discovery view · filtering · execution · fixtures · │
|
||||
│ timing · benchmark harness · curve fitting · reporters │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The framework is written in El, compiled to C once, archived. Per-suite compilation touches only
|
||||
the generated registry. This is Go's model, and it is strictly better for us than Go's because we
|
||||
own the compiler and already have the AST — no separate source-scanning pass is needed.
|
||||
|
||||
### 3.2 Why the runner is in El and the registry is in C
|
||||
|
||||
El has no closures and no first-class function pointers. The registry must therefore hold C function
|
||||
pointers, and it is generated C.
|
||||
|
||||
The runner stays in El and reaches the registry through a small builtin surface — indices, not
|
||||
pointers:
|
||||
|
||||
```
|
||||
__el_reg_count() -> Int
|
||||
__el_reg_name(i) -> String
|
||||
__el_reg_file(i) -> String
|
||||
__el_reg_line(i) -> Int
|
||||
__el_reg_kind(i) -> Int // 0=test 1=bench
|
||||
__el_reg_tags(i) -> Int
|
||||
__el_reg_sizes(i) -> String // JSON array, empty for tests
|
||||
__el_reg_expect(i) -> Int // complexity class enum, 0 = none
|
||||
__el_reg_invoke(i) -> Int // runs the body via the generated switch
|
||||
```
|
||||
|
||||
Nine builtins. Everything else — filtering, lifecycle, statistics, curve fitting, all reporters —
|
||||
is El. That satisfies "written in El" without pretending El can do something it cannot.
|
||||
|
||||
### 3.3 Result model
|
||||
|
||||
The unit is a **result record**, not a counter:
|
||||
|
||||
```
|
||||
TestResult {
|
||||
id String // slash path: "parser/handles_empty_input/case_3"
|
||||
file String
|
||||
line Int
|
||||
status Status // Pass | Fail | Error | Skip
|
||||
duration Int // nanoseconds, ALWAYS populated
|
||||
message String // assertion detail: expected vs actual
|
||||
output String // captured stdout/stderr for this test
|
||||
assertions Int
|
||||
}
|
||||
```
|
||||
|
||||
`Fail` = an assertion failed. `Error` = unexpected crash/abort. This distinction is load-bearing —
|
||||
every CI consumer depends on it, and the JUnit XML schema encodes it as distinct elements.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authoring surface
|
||||
|
||||
### 4.1 Tests
|
||||
|
||||
`test { }` already exists. Keep it. Add subtests and hierarchy:
|
||||
|
||||
```el
|
||||
test "parser/empty input" {
|
||||
assert_that(parse(""), is_err())
|
||||
}
|
||||
|
||||
test "parser/table" {
|
||||
for case in [["", 0], ["a", 1], ["a b", 2]] {
|
||||
subtest(case[0]) {
|
||||
assert_that(token_count(case[0]), equals(case[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Subtest IDs compose as `parser/table/a_b`. Filtering is `--run 'parser/table/.*'`, one regex per
|
||||
path segment, exactly as Go does.
|
||||
|
||||
**We do not build a parameterized-test annotation system.** Table-driven loops plus subtests subsume
|
||||
`@ParameterizedTest`, `@MethodSource`, `@CsvSource`, and `TestCaseSource` entirely, at zero framework
|
||||
surface. This is Go's single biggest ergonomic win over JUnit and NUnit.
|
||||
|
||||
### 4.2 Fixtures
|
||||
|
||||
Per-file and per-test only, plus a LIFO cleanup stack:
|
||||
|
||||
```el
|
||||
setup_all { ... } // once per suite
|
||||
setup { ... } // before each test
|
||||
teardown { ... } // after each test
|
||||
teardown_all { ... }
|
||||
```
|
||||
|
||||
and inside a test, `cleanup { ... }` registering LIFO-ordered teardown.
|
||||
|
||||
**We do not build JUnit 5's extension SPI** — seventeen callback interfaces, hierarchical stores,
|
||||
registration ordering rules. That complexity is the price of retrofitting a plugin ecosystem onto a
|
||||
twenty-year-old reflective framework. Go's `t.Cleanup` covers roughly 90% of what `@AfterEach` is
|
||||
used for at a fraction of the surface.
|
||||
|
||||
### 4.3 Assertions — constraint model
|
||||
|
||||
One entry point, composable constraint values (NUnit's model, which avoids the N² overload
|
||||
explosion):
|
||||
|
||||
```el
|
||||
assert_that(actual, equals(expected))
|
||||
assert_that(xs, has_length(3))
|
||||
assert_that(s, contains("foo").and(starts_with("bar")))
|
||||
assert_that(f, is_within(0.01).of(3.14))
|
||||
```
|
||||
|
||||
A constraint is a value with `apply_to(actual) -> ConstraintResult`, and the result knows how to
|
||||
describe its own failure. Custom constraints are ordinary user types.
|
||||
|
||||
**Every failure message must name file, line, the expression text, and both values.** We capture
|
||||
expression source text at compile time — we have the AST, so we can do this better than any
|
||||
runtime-introspection framework.
|
||||
|
||||
Legacy `assert_true` / `assert_eq` / etc. stay as thin wrappers for migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Benchmarks
|
||||
|
||||
### 5.1 The loop
|
||||
|
||||
Adopt `b.Loop()`, not `b.N`. Go spent fifteen years on `b.N` before concluding `b.Loop` was right;
|
||||
we skip that.
|
||||
|
||||
```el
|
||||
bench "str_concat" {
|
||||
let s = make_input(bench_n())
|
||||
for bench_loop() {
|
||||
black_box(str_concat(s, "x"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Three properties that make this the correct choice for a C target:
|
||||
|
||||
1. **The timer auto-resets on first call**, so setup above the loop is excluded *by construction*
|
||||
rather than by the author remembering `ResetTimer`.
|
||||
2. **`N` is hidden**, so it cannot be misused.
|
||||
3. **The harness owns the loop shape**, which lets us insert an optimization barrier the C compiler
|
||||
cannot see through. `black_box(v)` lowers to `asm volatile("" :: "r"(&v) : "memory")`. Since we
|
||||
emit a single translation unit, dead-code elimination of a benchmark body is a live hazard —
|
||||
this is our version of JMH's `Blackhole` problem, solved in the harness rather than delegated to
|
||||
the user.
|
||||
|
||||
### 5.2 Iteration scaling
|
||||
|
||||
Use Go's `predictN` heuristics verbatim. They are battle-tested and cheap:
|
||||
|
||||
```
|
||||
n = goal_ns * prev_iters / prev_ns // multiply before divide — precision on sub-ns ops
|
||||
n += n / 5 // 20% headroom, overshoot rather than re-loop
|
||||
n = min(n, 100 * last) // never grow more than 100× per step
|
||||
n = max(n, last + 1) // guarantee forward progress
|
||||
n = min(n, 1_000_000_000) // hard ceiling
|
||||
```
|
||||
|
||||
Report `n` rounded to 1/2/3/5 × 10ᵏ so runs are comparable.
|
||||
|
||||
### 5.3 Sampling
|
||||
|
||||
Criterion's shape, because it is correct near timer resolution:
|
||||
|
||||
- **Warmup**: iteration counts 1, 2, 4, 8… until cumulative time exceeds the warmup budget.
|
||||
- **Measurement**: collect `sample_size` samples at iteration counts `[d, 2d, 3d, …, Nd]`.
|
||||
- **Estimate**: slope of a linear regression of iteration-count vs elapsed time. The intercept
|
||||
absorbs fixed overhead.
|
||||
- **Time whole samples, never individual iterations.** This is the single most important detail —
|
||||
it defeats timer-resolution error on nanosecond operations.
|
||||
|
||||
Outliers classified by modified Tukey (±1.5 IQR mild, ±3 IQR severe), **reported but retained**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Complexity gating — the centerpiece
|
||||
|
||||
This is the part that makes the quadratic fixable, and the part nobody in the mainstream has
|
||||
finished. Google Benchmark's `Complexity()` fits the curve and *reports* it. We declare it and
|
||||
**gate** on it.
|
||||
|
||||
### 6.1 Surface
|
||||
|
||||
```el
|
||||
bench "elc_compile" over n in [16, 32, 64, 128, 256, 512, 1024] expect O(n) {
|
||||
let src = synth_source(bench_n())
|
||||
for bench_loop() { black_box(compile(src)) }
|
||||
}
|
||||
```
|
||||
|
||||
Alternative with no new syntax, if the parser change is judged too invasive — `bench_sizes([...])`
|
||||
and `bench_expect("O(n)")` as calls inside the block. **Recommendation: declarative.** Runtime calls
|
||||
mean `--list` cannot show the invariant without executing, which breaks the discovery-precedes-
|
||||
execution invariant from §2.
|
||||
|
||||
### 6.2 Fitting
|
||||
|
||||
Per Google Benchmark `src/complexity.cc`. For candidate curves
|
||||
`{O(1), O(log n), O(n), O(n log n), O(n²), O(n³)}`, one-parameter least squares, no intercept:
|
||||
|
||||
```
|
||||
coef = Σ(tᵢ · gᵢ) / Σ(gᵢ²)
|
||||
rms = sqrt( Σ(tᵢ − coef·gᵢ)² / k ) / mean(t) // normalized
|
||||
```
|
||||
|
||||
Best fit = lowest normalized RMS. User-supplied lambda curves also supported.
|
||||
|
||||
### 6.3 Gate logic
|
||||
|
||||
1. **FAIL** if the best-fit curve is strictly worse than declared, ordering
|
||||
`O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³)`. Print the fitted coefficient and the full
|
||||
per-size table.
|
||||
2. **FAIL** if the declared curve's normalized RMS exceeds a threshold (start at 0.10). This catches
|
||||
the case where *no* candidate fits — noise, a cache cliff, or a phase change. Report
|
||||
`INDETERMINATE` honestly rather than gating on garbage.
|
||||
3. **WARN** if the best fit is strictly better than declared — either an optimization landed and the
|
||||
annotation should tighten, or the sweep is too narrow to expose real behaviour.
|
||||
4. **REFUSE to gate** on fewer than 5 distinct sizes spanning under 2 decades, geometrically spaced.
|
||||
Say so loudly rather than producing a meaningless fit.
|
||||
|
||||
### 6.4 Why gate on the exponent, not wall-clock
|
||||
|
||||
- **Machine-independent.** The fitted exponent is a property of the algorithm; the coefficient is a
|
||||
property of the machine. Gating on the exponent makes CI hardware heterogeneity, noisy neighbours,
|
||||
and thermal throttling irrelevant — they scale `coef`, not `g`.
|
||||
- **No stored baseline.** No artifact storage, no golden-file drift. The invariant lives in the
|
||||
source next to the code and is reviewed in the same PR.
|
||||
- **It catches the failure mode that actually ships.** An O(n) lookup inside an O(n) loop is
|
||||
invisible at n=100 in a unit test and catastrophic at n=100,000 in production. Constant-factor
|
||||
regressions are annoying. Complexity regressions are outages. Ours was a 27 GB outage.
|
||||
|
||||
### 6.5 The deterministic gate — the one that would have caught us
|
||||
|
||||
Wall-clock needs statistics. **Allocation counts do not.** They are perfectly deterministic.
|
||||
|
||||
> **Correction, 2026-08-16 — count alone is NOT sufficient. Gate on BOTH count and bytes.**
|
||||
>
|
||||
> Measured against two El programs, one allocating once per item and one rebuilding its
|
||||
> accumulator each iteration:
|
||||
>
|
||||
> | n | linear allocs / bytes | quadratic allocs / bytes |
|
||||
> |---|---|---|
|
||||
> | 100 | 100 / 290 | 100 / 5,150 |
|
||||
> | 200 | 200 / 690 | 200 / 20,300 |
|
||||
> | 400 | 400 / 1,490 | 400 / 80,600 |
|
||||
> | 800 | 800 / 3,090 | 800 / 321,200 |
|
||||
>
|
||||
> The quadratic program's allocation **count is exactly linear** — 100/200/400/800, identical to
|
||||
> the healthy program. A count-only gate passes it clean. **Bytes** catch it: each doubling of n
|
||||
> quadruples bytes (ratios 3.94, 3.97, 3.99 → 4.0 = O(n²)) where the linear program converges
|
||||
> on 2.0.
|
||||
>
|
||||
> This is precisely elc's own defect shape — a copy-on-write accumulator reallocating once per
|
||||
> pass (count linear) into a proportionally larger buffer (bytes quadratic).
|
||||
>
|
||||
> Therefore `expect allocs O(n)` **fits count and bytes independently and fails if EITHER exceeds
|
||||
> the declared curve**, reporting which signal broke. "count linear, bytes quadratic" is a precise,
|
||||
> directly actionable diagnosis.
|
||||
>
|
||||
> **`el_peak_rss()` is CONTEXT ONLY — never gate on it.** It is perturbed by the allocator and by
|
||||
> the page cache. Allocation volume is the invariant; RSS and malloc/free churn are merely the two
|
||||
> surfaces it shows on. The old shipped compiler paid the same quadratic in RSS that the rebuilt
|
||||
> one pays in churn.
|
||||
>
|
||||
> **Measure rate, not level.** A guard reading swap *level* saw 97% on a thrashing host and 97% on
|
||||
> a healthy one; only *rate* separated them. A growth exponent is a rate; a single measurement is
|
||||
> a level. That is why the gate fits a curve across a sweep instead of comparing one number to a
|
||||
> 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:
|
||||
|
||||
```el
|
||||
bench "elc_compile" over n in [...] expect O(n) allocs O(n) { ... }
|
||||
```
|
||||
|
||||
Zero noise, zero statistics, always gateable, correct on the first run on any machine. Go reports
|
||||
`allocs/op` and `B/op`; **nobody fits them against n.** That is an open opportunity and it is exactly
|
||||
our bug: elc's defect is quadratic *allocation volume*, which the old binary paid in RSS and the
|
||||
current source pays in malloc/free churn.
|
||||
|
||||
An `expect allocs O(n)` assertion on `elc`'s compile path would have failed the build the day the
|
||||
quadratic was introduced.
|
||||
|
||||
Required runtime additions: `__el_alloc_count()`, `__el_alloc_bytes()`, `__el_peak_rss()`.
|
||||
|
||||
### 6.6 Constant-factor gate (secondary, opt-in)
|
||||
|
||||
Mann-Whitney U at α = 0.05, noise floor 1%, medians with 95% CIs, `~` for not-significant. Requires
|
||||
`--count >= 9`. Off by default on CI; opt-in per benchmark.
|
||||
|
||||
**Exit nonzero on regression.** Both benchstat and Criterion always exit 0, which is why every shop
|
||||
using them wrote a wrapper. We do not repeat that omission.
|
||||
|
||||
---
|
||||
|
||||
## 7. Output
|
||||
|
||||
**Structured events are the source of truth.** Human text is rendered from them. We do not repeat
|
||||
Go's parse-the-human-output design.
|
||||
|
||||
Event stream, NDJSON, one object per line, streamed live:
|
||||
|
||||
```json
|
||||
{"time":"...","action":"run","test":"parser/empty"}
|
||||
{"time":"...","action":"output","test":"parser/empty","output":"..."}
|
||||
{"time":"...","action":"pass","test":"parser/empty","elapsed":0.0031}
|
||||
{"time":"...","action":"bench","test":"str_concat","n":1024,"ns_op":41.2,"allocs_op":3,"bigo":"N","rms":0.03}
|
||||
```
|
||||
|
||||
Renderers, all downstream and pluggable:
|
||||
|
||||
| Format | Flag | Use |
|
||||
|---|---|---|
|
||||
| Human | default | terminal, **per-test duration always shown** |
|
||||
| NDJSON | `--json` | tooling, history, flaky detection |
|
||||
| JUnit XML | `--junit-xml=PATH` | every CI system on earth |
|
||||
| TAP | `--tap` | optional |
|
||||
|
||||
JUnit XML per the de-facto schema: `testsuites` → `testsuite` → `testcase`, with `time` in seconds
|
||||
as a decimal, `file`/`line` attributes, and `failure` vs `error` vs `skipped` as distinct child
|
||||
elements. Absence of a child element means pass. Emit `<testsuites>` even for a single suite, and
|
||||
parse both shapes on input.
|
||||
|
||||
---
|
||||
|
||||
## 8. CLI
|
||||
|
||||
```
|
||||
--list print the registry, run nothing
|
||||
--list-json machine-readable registry
|
||||
--run PATTERN slash-separated regex per path segment
|
||||
--tag EXPR tag expression: fast & !slow
|
||||
--shard I/N deterministic sharding for CI parallelism
|
||||
--count N repetitions, for statistics
|
||||
--bench PATTERN run benchmarks (off by default in test runs)
|
||||
--benchtime DUR per-benchmark time budget
|
||||
--junit-xml PATH
|
||||
--json
|
||||
--isolate re-exec per test on crash, so one SIGSEGV doesn't lose the run
|
||||
--timeout DUR
|
||||
--fail-fast
|
||||
```
|
||||
|
||||
`--list` / `--list-json` / `--shard` cost roughly thirty lines because the registry already exists
|
||||
before `main` does anything. That is the dividend of discovery-precedes-execution.
|
||||
|
||||
---
|
||||
|
||||
## 9. Build model
|
||||
|
||||
```
|
||||
# once, ever (or when the runtime/framework changes):
|
||||
cc -c el_runtime.c -o el_runtime.o
|
||||
elc eltest.el > eltest.c && cc -c eltest.c -o eltest.o
|
||||
ar rcs libeltest.a el_runtime.o eltest.o
|
||||
|
||||
# per suite:
|
||||
elc --test foo_test.el > foo_test.c # registry + bodies only
|
||||
cc foo_test.c libeltest.a -o foo_test
|
||||
```
|
||||
|
||||
The 0.14s × N of redundant runtime rebuilds disappears — not because we optimized it, but because
|
||||
one-runner-over-many-suites requires compile-once-link-many as a structural precondition.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bootstrap and self-hosting
|
||||
|
||||
The framework's own tests are `test { }` blocks run by the framework. Same fixpoint discipline the
|
||||
compiler already applies to itself.
|
||||
|
||||
1. Build the framework using the *existing* harness for its first tests (stage 0).
|
||||
2. Rebuild the framework's tests as `test { }` blocks run by the new runner (stage 1).
|
||||
3. Verify stage 1 reports identical results to stage 0.
|
||||
4. From then on, the framework is tested by itself.
|
||||
|
||||
A framework that cannot run its own suite is not evidence of anything. This is a correctness proof,
|
||||
not a claim.
|
||||
|
||||
---
|
||||
|
||||
## 11. Explicitly not building
|
||||
|
||||
| Rejected | Why |
|
||||
|---|---|
|
||||
| Naming-convention discovery (`fn test_foo`) | `test { }` is a real declaration. Go's `TestXxx` exists only because Go had no better hook — and it needs a heuristic to avoid matching `TesticularCancer`. |
|
||||
| Reflection or symbol-table scanning | Slow, fragile under LTO/strip/dead-strip, and unnecessary when we own the compiler. |
|
||||
| Parsing human output into structure | Go's `test2json` is its one clear architectural mistake. |
|
||||
| JUnit 5's extension SPI | Seventeen callback interfaces to retrofit plugins onto a reflective framework. Not our problem. |
|
||||
| `@ParameterizedTest` machinery | Table-driven loops + subtests subsume it at zero surface. |
|
||||
| NUnit's out-of-process agents | They bridge CLR versions and AppDomains. We emit one native binary. Keep `--isolate` as crash fallback only. |
|
||||
| JMH-style forking by default | Forks exist because JIT profiles are per-process. AOT C has no such state. Keep `--fork` available, not default. |
|
||||
| Exit 0 on regression | benchstat and Criterion both do this, and every user writes a wrapper. |
|
||||
| Dynamic runtime test registration | Breaks `--list`, sharding, and individual selection. Registry stays static. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing
|
||||
|
||||
| Phase | Content | Gate |
|
||||
|---|---|---|
|
||||
| **1** | Registry emission in codegen; 9 builtins; `el_test_main` skeleton in El; result records; per-test timing; human + NDJSON output | existing 11 test files pass, with timing |
|
||||
| **2** | `libeltest.a` build model; subtests; filtering; `--list`; fixtures; constraint assertions; JUnit XML | suite runs in one binary; runtime compiled once |
|
||||
| **3** | `bench { }`, `bench_loop`, `black_box`, `predictN`, Criterion sampling | benchmarks produce stable ns/op |
|
||||
| **4** | Allocation counters; complexity fitting; `expect O(...)` gate | **an `expect allocs O(n)` benchmark on `elc` fails on the current quadratic** |
|
||||
| **5** | Migrate both legacy systems; delete `runtime/test.el`; self-host | framework runs its own suite |
|
||||
|
||||
Phase 4 is the deliverable that matters. Phases 1–3 exist to make it possible.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for review
|
||||
|
||||
1. **Declarative `over n in [...] expect O(...)` syntax vs runtime calls.** I recommend declarative
|
||||
(§6.1) so `--list` can show invariants without executing. It costs parser work. Your call.
|
||||
2. **`bench { }` as a new block form** — parallel to `test { }`, or a modifier on it?
|
||||
3. **Scope of the constraint model.** Full composable constraints, or start with a flat assertion set
|
||||
and add constraints later? Full model is more surface but avoids a second migration.
|
||||
4. **Does `runtime/test.el` get deleted or kept as a deprecated shim?** I lean delete — two systems
|
||||
is how we got here.
|
||||
5. **Where does `libeltest.a` live** in the tree, and does `epm` need to know about it?
|
||||
6. **Allocation counters in `el_seed.c` or `el_runtime.c`?** AGENTS.md says `el_seed.c` is the sole
|
||||
C dependency and hand-maintained; counters are OS-boundary-adjacent but not OS calls.
|
||||
7. **Is per-test timing enough, or do we want per-*assertion* timing** for finding slow helpers?
|
||||
|
||||
---
|
||||
|
||||
## 14. What this document is not
|
||||
|
||||
This is a design, not a measurement. Every performance claim about the *current* system in §1 is
|
||||
measured and reproducible in this worktree. Every claim about the *proposed* system is a prediction.
|
||||
None of it is verified until Phase 1 runs and Phase 4 fails a build on the real quadratic.
|
||||
+195
-43
@@ -10,10 +10,60 @@
|
||||
// cc -std=c11 -O2 -lcurl -lpthread -o engram server.c el_runtime.c
|
||||
// ./engram
|
||||
//
|
||||
// Configuration via environment:
|
||||
// ENGRAM_BIND — host:port (default :8742)
|
||||
// ENGRAM_API_KEY — bearer auth (optional)
|
||||
// ENGRAM_DATA_DIR — snapshot location (default ~/.neuron/engram)
|
||||
// Configuration is DECLARED, not scattered. See the `program` block below:
|
||||
// every knob's type and default lives there and nowhere else, is resolved from
|
||||
// the environment (env wins, declaration is the fallback) and validated before
|
||||
// any statement of this file runs. Read one with config("NAME") -> String.
|
||||
//
|
||||
// The one deliberate exception is ENGRAM_DATA_DIR — see the note in the block.
|
||||
|
||||
// ── Program declaration (cross-cutting concerns) ──────────────────────────────
|
||||
//
|
||||
// singleton: two engram processes against one data dir is data loss, not a
|
||||
// warning. The runtime takes an exclusive flock at startup and a second start
|
||||
// is refused loudly with the holder's pid.
|
||||
//
|
||||
// NOT declared here, on purpose: ENGRAM_DATA_DIR. Its resolution is owned by
|
||||
// engram_resolve_data_dir() (el_runtime.c), which defaults to $HOME/.neuron/engram
|
||||
// and fails LOUD rather than silently persisting to an ephemeral directory.
|
||||
// Declaring a default for it here as well would put the data dir's fallback in
|
||||
// two places — which is precisely the defect this migration removes (until
|
||||
// 2026-08-15 the reseed backup path carried its own "/tmp/engram" default that
|
||||
// disagreed with the resolver, so the pre-destructive safety copy landed in /tmp).
|
||||
// HOME is likewise not declared: it is a genuine environment read, not a knob.
|
||||
program "engram" {
|
||||
singleton: "engram"
|
||||
|
||||
// ── Core server ──
|
||||
env ENGRAM_BIND: String = ":8742"
|
||||
// Default "" leaves auth DISABLED (check_auth_ok short-circuits to true on an
|
||||
// empty key). That is the pre-existing behaviour and is deliberately preserved
|
||||
// here; making this `required` is the obvious hardening follow-up, but it is a
|
||||
// behaviour change and out of scope for this migration.
|
||||
env ENGRAM_API_KEY: String = ""
|
||||
|
||||
// ── Feature flags (bool-ish Strings; the predicate fns below own truthiness) ──
|
||||
env ENGRAM_STORE: String = "off"
|
||||
env ENGRAM_WAL: String = "off"
|
||||
env ENGRAM_AUTOCONNECT: String = "off"
|
||||
env ENGRAM_ISE_OFFGRAPH: String = "off"
|
||||
|
||||
// ── ISE telemetry ──
|
||||
env ENGRAM_ISE_RETENTION_MS: Int = "172800000"
|
||||
|
||||
// ── Guide (local Qwen3 via llama-server) ──
|
||||
env GUIDE_ENABLE: String = "off"
|
||||
env GUIDE_TIER_FORCE: String = ""
|
||||
env GUIDE_CACHE_DIR: String = ""
|
||||
env GUIDE_RAM_GB_4B: Int = "16"
|
||||
env GUIDE_RAM_GB_1P7B: Int = "8"
|
||||
env GUIDE_BACKEND: String = "llama-server"
|
||||
env GUIDE_HOST: String = "127.0.0.1"
|
||||
env GUIDE_PORT: Int = "8771"
|
||||
env GUIDE_LLAMA_SERVER_BIN: String = "llama-server"
|
||||
env GUIDE_NGL: Int = "99"
|
||||
env GUIDE_CTX: Int = "4096"
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,17 +91,29 @@ fn strip_query(path: String) -> String {
|
||||
str_slice(path, 0, q)
|
||||
}
|
||||
|
||||
// query_param — extract one query-string value, URL-DECODED.
|
||||
//
|
||||
// The decode step was missing (found 2026-08-15): a claim sent as
|
||||
// "test%20claim" arrived at engram_assert_json still percent-encoded and was
|
||||
// stored/compared that way, so any value containing a space, &, =, or non-ASCII
|
||||
// character silently became a different string than the caller sent. Affects
|
||||
// every GET route that reads params this way, not just /api/assert.
|
||||
fn query_param(path: String, key: String) -> String {
|
||||
let q: Int = str_index_of(path, "?")
|
||||
if q < 0 { return "" }
|
||||
let qs: String = str_slice(path, q + 1, str_len(path))
|
||||
let needle: String = key + "="
|
||||
let pos: Int = str_index_of(qs, needle)
|
||||
// Anchor the match to a real key boundary: prefixing "&" and searching for
|
||||
// "&key=" means "q" can never match inside "faq=". (Found 2026-08-15:
|
||||
// "?faq=X&q=Y" returned X for key "q" — a silently wrong value, not an
|
||||
// error.) The leading "&" makes the first parameter match the same way.
|
||||
let hay: String = "&" + qs
|
||||
let needle: String = "&" + key + "="
|
||||
let pos: Int = str_index_of(hay, needle)
|
||||
if pos < 0 { return "" }
|
||||
let after: String = str_slice(qs, pos + str_len(needle), str_len(qs))
|
||||
let after: String = str_slice(hay, pos + str_len(needle), str_len(hay))
|
||||
let amp: Int = str_index_of(after, "&")
|
||||
if amp < 0 { return after }
|
||||
str_slice(after, 0, amp)
|
||||
let raw: String = if amp < 0 { after } else { str_slice(after, 0, amp) }
|
||||
return __url_decode(raw)
|
||||
}
|
||||
|
||||
fn query_int(path: String, key: String, default_val: Int) -> Int {
|
||||
@@ -121,7 +183,7 @@ fn route_text_health(method: String, path: String, body: String) -> String {
|
||||
// engram_store_enabled() in el_runtime.c EXACTLY (1 / on / true). Default off →
|
||||
// every persistence path below is byte-for-byte the historical snapshot behavior.
|
||||
fn store_on() -> Bool {
|
||||
let v: String = env("ENGRAM_STORE")
|
||||
let v: String = config("ENGRAM_STORE")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -150,7 +212,6 @@ fn persist_canonical() -> Int {
|
||||
if store_on() {
|
||||
return engram_store_checkpoint()
|
||||
}
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
// (2026-08-10 self-review) This returned a hardcoded 1, which made every
|
||||
// caller's `let saved: Int = persist_canonical()` a dead variable — six
|
||||
@@ -164,7 +225,7 @@ fn persist_canonical() -> Int {
|
||||
// per-write full-snapshot behavior. When ON, structural mutations append O(1)
|
||||
// WAL records instead of rewriting the whole graph, with threshold compaction.
|
||||
fn wal_on() -> Bool {
|
||||
str_eq(env("ENGRAM_WAL"), "on")
|
||||
str_eq(config("ENGRAM_WAL"), "on")
|
||||
}
|
||||
|
||||
// autoconnect_on — ENGRAM_AUTOCONNECT. Will's rule: "we shouldn't be inserting
|
||||
@@ -172,7 +233,7 @@ fn wal_on() -> Bool {
|
||||
// edge (kNN over embeddings) so no content node enters the graph edgeless.
|
||||
// Default OFF -> byte-identical to prior behavior (node created, no auto edges).
|
||||
fn autoconnect_on() -> Bool {
|
||||
let v: String = env("ENGRAM_AUTOCONNECT")
|
||||
let v: String = config("ENGRAM_AUTOCONNECT")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -185,7 +246,7 @@ fn autoconnect_on() -> Bool {
|
||||
// separate state-event log tier instead of the node graph. Default OFF -> ISEs
|
||||
// remain graph nodes exactly as before (with 48h prune).
|
||||
fn ise_offgraph_on() -> Bool {
|
||||
let v: String = env("ENGRAM_ISE_OFFGRAPH")
|
||||
let v: String = config("ENGRAM_ISE_OFFGRAPH")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -235,6 +296,24 @@ fn persist_bulk() -> Int {
|
||||
return persist_canonical()
|
||||
}
|
||||
|
||||
// COMPILER LANDMINE, measured 2026-08-16 — do not inline this back into the
|
||||
// caller. elc lowers `a == b` to numeric comparison only when both operand
|
||||
// NAMES are in the per-function int-name set, which `let x: Int` populates.
|
||||
// That registration does NOT propagate into a nested if-expression block: the
|
||||
// first cut of the geometry-ingest path wrote `let claimed: Int = ...` and
|
||||
// `let got: Int = ...` inside the else-arm and `claimed == got` came out of
|
||||
// codegen as `str_eq(claimed, got)` — strcmp on two integers reinterpreted as
|
||||
// pointers, i.e. a segfault on the first geometry-bearing request. Read back
|
||||
// out of the generated C, not guessed. Function PARAMETERS annotated `: Int`
|
||||
// do register reliably (verified: `if (claimed == actual)`), so the comparison
|
||||
// lives in a function of its own. Note also the explicit `return`s — a trailing
|
||||
// if-EXPRESSION at a function tail emits as a statement and the function
|
||||
// returns 0 regardless, which is the same probe's second finding.
|
||||
fn width_agrees(claimed: Int, actual: Int) -> Int {
|
||||
if claimed == actual { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
// INCOMPLETE-ROUTE FIX (2026-07-24 self-review): this route silently dropped
|
||||
// label, importance, tier, and tags — engram_node() defaults label to content
|
||||
// and importance to 0.5, so every node created over HTTP lost its metadata.
|
||||
@@ -276,6 +355,45 @@ fn route_create_node(method: String, path: String, body: String) -> String {
|
||||
salience, importance, confidence,
|
||||
tier, tags
|
||||
)
|
||||
// GEOMETRY INGEST — geometry-valued end to end (2026-08-16).
|
||||
//
|
||||
// The defect this route originally had: it accepted an "emb" field,
|
||||
// returned 200 with a fresh id, and stored NOTHING, because engram_node_full
|
||||
// has no vector parameter. 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.
|
||||
//
|
||||
// #141 fixed the drop but marshalled the vector as a hex STRING through
|
||||
// engram_node_set_emb, which put text back as the TRANSPORT medium one layer
|
||||
// below the problem being fixed. This is that correction: hex is decoded
|
||||
// exactly ONCE, here at the edge, into a first-class Geometry, and every
|
||||
// step below this line moves geometry rather than text. An encoding at the
|
||||
// boundary is what an encoding is for.
|
||||
//
|
||||
// The WIRE is deliberately unchanged — "emb" is still little-endian float32
|
||||
// hex (8 chars per component), the encoding the perception vessel's
|
||||
// /voice/embed already emits — because production clients speak it. What
|
||||
// changed is underneath it.
|
||||
//
|
||||
// "dim" is now treated as an ASSERTION about the vector the caller sent, not
|
||||
// as the source of its width: a Geometry carries its own width. A stated dim
|
||||
// that disagrees is a REJECTED ingest, not a silent reinterpretation. Omitting
|
||||
// "dim" is fine and means "trust the vector", which is the honest default.
|
||||
//
|
||||
// Off-dimension vectors remain stored but not inserted into the resident HNSW
|
||||
// index (its build loop filters on emb_dim), so a 64-dim voice geometry is
|
||||
// durable and addressable without perturbing the 768-dim canonical index.
|
||||
let emb_hex: String = json_get_string(body, "emb")
|
||||
let emb_set: Int = if str_eq(emb_hex, "") { 0 } else {
|
||||
let g: Geometry = geometry_from_f32le_hex(emb_hex)
|
||||
let got: Int = geometry_dim(g)
|
||||
let dim_raw: String = json_get_raw(body, "dim")
|
||||
let claimed: Int = if str_eq(dim_raw, "") { got } else { json_get_int(body, "dim") }
|
||||
let landed: Int = if width_agrees(claimed, got) > 0 { node_attach_geometry(id, g) } else { 0 }
|
||||
let freed: Int = geometry_free(g)
|
||||
landed
|
||||
}
|
||||
let saved: Int = persist_node(id)
|
||||
// ORPHAN PREVENTION (ENGRAM_AUTOCONNECT): connect the fresh node to its
|
||||
// nearest embedded neighbors so it never enters the graph edgeless.
|
||||
@@ -286,7 +404,11 @@ fn route_create_node(method: String, path: String, body: String) -> String {
|
||||
if added > 0 { let sv2: Int = persist_edges_since(ec0) }
|
||||
added
|
||||
} 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 {
|
||||
@@ -321,7 +443,6 @@ fn route_scan_nodes(method: String, path: String, body: String) -> String {
|
||||
// process ever booted with a partial/empty store, the first read request
|
||||
// clobbered the good snapshot. Read routes must never write the canonical path.)
|
||||
fn route_scan_edges(method: String, path: String, body: String) -> String {
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let snap_path: String = dir + "/.scan-export.json"
|
||||
engram_save(snap_path)
|
||||
@@ -482,7 +603,6 @@ fn route_forget(method: String, path: String, body: String) -> String {
|
||||
|
||||
fn route_save(method: String, path: String, body: String) -> String {
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
// (2026-08-10 self-review) engram_save returns 0 on an empty path and the
|
||||
@@ -566,7 +686,6 @@ fn route_drift(method: String, path: String, body: String) -> String {
|
||||
|
||||
fn route_load(method: String, path: String, body: String) -> String {
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
// (2026-08-10 self-review) This was a stub response over the single most
|
||||
@@ -637,7 +756,6 @@ fn route_embed_backfill(method: String, path: String, body: String) -> String {
|
||||
// (it skips nodes already present by ID). Auth-exempt: same-host internal call.
|
||||
// (2026-06-27 self-review: added this route to fix silent 10-min sync failures)
|
||||
fn route_sync(method: String, path: String, body: String) -> String {
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
// 2026-07-21 self-review: export to a scratch path, never the canonical
|
||||
// snapshot.json — read routes must not be able to clobber the good snapshot.
|
||||
@@ -713,8 +831,12 @@ fn route_reseed_nodes(method: String, path: String, body: String) -> String {
|
||||
if str_eq(p, "") { return err_json("path is required") }
|
||||
if str_eq(fs_read(p), "") { return err_json("file missing or empty") }
|
||||
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
// (2026-08-15) This site carried its own "/tmp/engram" fallback, which
|
||||
// DISAGREED with engram_resolve_data_dir() ($HOME/.neuron/engram, fail-loud).
|
||||
// The consumer is the pre-destructive backup below, so with ENGRAM_DATA_DIR
|
||||
// unset the safety copy taken before a reseed landed in an ephemeral /tmp
|
||||
// while the store it was protecting lived elsewhere. One owner, one answer.
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let backup: String = dir + "/.reseed-backup.json"
|
||||
|
||||
let replace_raw: String = json_get_raw(body, "replace")
|
||||
@@ -806,8 +928,7 @@ fn route_emit_ise(method: String, path: String, body: String) -> String {
|
||||
sal, imp, conf,
|
||||
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
|
||||
)
|
||||
let ret_raw: String = env("ENGRAM_ISE_RETENTION_MS")
|
||||
let ret_ms: Int = if str_eq(ret_raw, "") { 172800000 } else { str_to_int(ret_raw) }
|
||||
let ret_ms: Int = str_to_int(config("ENGRAM_ISE_RETENTION_MS"))
|
||||
let pruned: Int = engram_prune_telemetry(ret_ms)
|
||||
"{\"ok\":true,\"id\":\"" + id + "\",\"pruned\":" + int_to_str(pruned) + "}"
|
||||
}
|
||||
@@ -997,6 +1118,11 @@ fn route_faculty(path: String, faculty: String) -> String {
|
||||
fn route_boundary_proof(method: String, path: String, body: String) -> String {
|
||||
return "{\"op\":\"boundary_proof\",\"body_instrumentation\":\"none\",\"seam\":\"@manager -> engram_boundary_beat auto-injected\"}"
|
||||
}
|
||||
// ── GROUNDING: an attribute of the RELATION, and the relation's weight is a
|
||||
// VECTOR (factual, relational, associative, polarity, provenance, timestamp).
|
||||
// /api/ground READS it — it never writes. /api/ground/record is the write,
|
||||
// named as one, and it consolidates only on a consequential + salient move.
|
||||
// /api/ground/trajectory reads the supersession chain as a time series.
|
||||
fn route_ground(method: String, path: String, body: String) -> String {
|
||||
let claim: String = json_get_string(body, "claim")
|
||||
let evidence: String = json_get_string(body, "evidence")
|
||||
@@ -1005,12 +1131,31 @@ fn route_ground(method: String, path: String, body: String) -> String {
|
||||
if str_eq(evidence, "") { return err_json("missing evidence") }
|
||||
return engram_ground_json(claim, evidence, for_whom)
|
||||
}
|
||||
fn route_ground_record(method: String, path: String, body: String) -> String {
|
||||
let claim: String = json_get_string(body, "claim")
|
||||
let evidence: String = json_get_string(body, "evidence")
|
||||
let provenance: String = json_get_string(body, "provenance")
|
||||
let floor: String = json_get_string(body, "floor")
|
||||
if str_eq(claim, "") { return err_json("missing claim") }
|
||||
if str_eq(evidence, "") { return err_json("missing evidence") }
|
||||
return engram_ground_record_json(claim, evidence, provenance, floor)
|
||||
}
|
||||
fn route_ground_trajectory(method: String, path: String, body: String) -> String {
|
||||
let claim: String = query_param(path, "claim")
|
||||
let evidence: String = query_param(path, "evidence")
|
||||
if str_eq(claim, "") { return err_json("missing claim") }
|
||||
if str_eq(evidence, "") { return err_json("missing evidence") }
|
||||
return engram_ground_trajectory_json(claim, evidence)
|
||||
}
|
||||
fn route_assert(method: String, path: String, body: String) -> String {
|
||||
let claim: String = query_param(path, "claim")
|
||||
if str_eq(claim, "") { return err_json("missing claim") }
|
||||
let for_whom: String = query_param(path, "for_whom")
|
||||
let floor: String = query_param(path, "floor")
|
||||
return engram_assert_json(claim, for_whom, floor)
|
||||
// Both floors. A well-evidenced claim does not earn the right to be asserted
|
||||
// regardless of whether it means the right thing. rel_floor defaults to floor.
|
||||
let rel_floor: String = query_param(path, "rel_floor")
|
||||
return engram_assert_json(claim, for_whom, floor, rel_floor)
|
||||
}
|
||||
fn route_attend(method: String, path: String, body: String) -> String {
|
||||
let node: String = json_get_string(body, "node")
|
||||
@@ -1056,14 +1201,12 @@ fn route_correspondence_beat(method: String, path: String, body: String) -> Stri
|
||||
// turns native thinking ON: the response carries reasoning_content (the thinking)
|
||||
// alongside content (the answer).
|
||||
|
||||
fn guide_env_or(key: String, dflt: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") { return dflt }
|
||||
return v
|
||||
}
|
||||
// (2026-08-15) guide_env_or(key, dflt) lived here. Its whole job was supplying a
|
||||
// per-call-site default, which is now the program block's job — every GUIDE_* knob
|
||||
// is declared once at the top of this file and read straight through config().
|
||||
|
||||
fn guide_enabled() -> Bool {
|
||||
let v: String = env("GUIDE_ENABLE")
|
||||
let v: String = config("GUIDE_ENABLE")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -1108,15 +1251,15 @@ fn guide_probe_metal() -> Bool {
|
||||
|
||||
// ── 2. Tier selection (config-driven thresholds, spec-autoselected) ────────────
|
||||
fn guide_threshold_4b() -> Int {
|
||||
return str_to_int(guide_env_or("GUIDE_RAM_GB_4B", "16"))
|
||||
return str_to_int(config("GUIDE_RAM_GB_4B"))
|
||||
}
|
||||
fn guide_threshold_1p7b() -> Int {
|
||||
return str_to_int(guide_env_or("GUIDE_RAM_GB_1P7B", "8"))
|
||||
return str_to_int(config("GUIDE_RAM_GB_1P7B"))
|
||||
}
|
||||
|
||||
// GUIDE_TIER_FORCE overrides the spec autoselect (used to prove cheaply on 0.6b).
|
||||
fn guide_select_tier(ram_gb: Int) -> String {
|
||||
let forced: String = env("GUIDE_TIER_FORCE")
|
||||
let forced: String = config("GUIDE_TIER_FORCE")
|
||||
if !str_eq(forced, "") { return forced }
|
||||
if ram_gb >= guide_threshold_4b() { return "4b" }
|
||||
if ram_gb >= guide_threshold_1p7b() { return "1.7b" }
|
||||
@@ -1136,8 +1279,10 @@ fn guide_file(tier: String) -> String {
|
||||
}
|
||||
|
||||
fn guide_cache_dir() -> String {
|
||||
let c: String = env("GUIDE_CACHE_DIR")
|
||||
let c: String = config("GUIDE_CACHE_DIR")
|
||||
if !str_eq(c, "") { return c }
|
||||
// HOME stays a raw env() read: it is the ambient environment, not a knob of
|
||||
// this program, and it is deliberately absent from the program block.
|
||||
let home: String = env("HOME")
|
||||
if !str_eq(home, "") { return home + "/.neuron/guide/models" }
|
||||
return engram_resolve_data_dir() + "/guide-models"
|
||||
@@ -1178,9 +1323,9 @@ fn guide_fetch(tier: String) -> Bool {
|
||||
}
|
||||
|
||||
// ── 4/5. Backend abstraction + BIND as an engageable interlocutor ──────────────
|
||||
fn guide_backend() -> String { return guide_env_or("GUIDE_BACKEND", "llama-server") }
|
||||
fn guide_host() -> String { return guide_env_or("GUIDE_HOST", "127.0.0.1") }
|
||||
fn guide_port() -> String { return guide_env_or("GUIDE_PORT", "8771") }
|
||||
fn guide_backend() -> String { return config("GUIDE_BACKEND") }
|
||||
fn guide_host() -> String { return config("GUIDE_HOST") }
|
||||
fn guide_port() -> String { return config("GUIDE_PORT") }
|
||||
fn guide_base_url() -> String { return "http://" + guide_host() + ":" + guide_port() }
|
||||
|
||||
// guide_healthy — is the guide present and answering? llama-server's /health
|
||||
@@ -1198,9 +1343,9 @@ fn guide_healthy() -> Bool {
|
||||
fn guide_load(tier: String) -> Bool {
|
||||
if guide_healthy() { return true }
|
||||
let path: String = guide_model_path(tier)
|
||||
let bin: String = guide_env_or("GUIDE_LLAMA_SERVER_BIN", "llama-server")
|
||||
let ngl: String = guide_env_or("GUIDE_NGL", "99")
|
||||
let ctx: String = guide_env_or("GUIDE_CTX", "4096")
|
||||
let bin: String = config("GUIDE_LLAMA_SERVER_BIN")
|
||||
let ngl: String = config("GUIDE_NGL")
|
||||
let ctx: String = config("GUIDE_CTX")
|
||||
let logf: String = guide_cache_dir() + "/llama-server." + guide_port() + ".log"
|
||||
let cmd: String = bin + " -m '" + path + "' --host " + guide_host() + " --port " + guide_port() + " -c " + ctx + " -ngl " + ngl + " --jinja >> '" + logf + "' 2>&1"
|
||||
let pid: String = exec_bg(cmd)
|
||||
@@ -1596,7 +1741,7 @@ fn route_supersede(method: String, path: String, body: String) -> String {
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn check_auth_ok(method: String, body: String) -> Bool {
|
||||
let key: String = env("ENGRAM_API_KEY")
|
||||
let key: String = config("ENGRAM_API_KEY")
|
||||
if str_eq(key, "") { return true }
|
||||
// Read-only methods don't require auth. Until http_serve surfaces
|
||||
// request headers we can't accept a Bearer token cleanly; mutating
|
||||
@@ -1764,6 +1909,14 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if str_eq(method, "GET") && str_starts_with(clean, "/api/plan") {
|
||||
return route_faculty(path, "plan")
|
||||
}
|
||||
// Order matters: the more specific paths must be tested before the /api/ground
|
||||
// prefix match below, which would otherwise swallow them.
|
||||
if str_eq(method, "POST") && str_starts_with(clean, "/api/ground/record") {
|
||||
return route_ground_record(method, path, body)
|
||||
}
|
||||
if str_eq(method, "GET") && str_starts_with(clean, "/api/ground/trajectory") {
|
||||
return route_ground_trajectory(method, path, body)
|
||||
}
|
||||
if str_eq(method, "POST") && str_starts_with(clean, "/api/ground") {
|
||||
return route_ground(method, path, body)
|
||||
}
|
||||
@@ -1859,8 +2012,7 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
|
||||
// ── Entry ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let bind_raw: String = env("ENGRAM_BIND")
|
||||
let bind_str: String = if str_eq(bind_raw, "") { ":8742" } else { bind_raw }
|
||||
let bind_str: String = config("ENGRAM_BIND")
|
||||
let port: Int = parse_port(bind_str)
|
||||
|
||||
// On startup, try to load any existing snapshot (best effort).
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# Build + RUN the §7 GROUNDING-VECTOR tests (engram_cognition.c): the one decay
|
||||
# model, the consequence gate, and the stored/derived split. Closed-form
|
||||
# constructed cases — no server, no store, no network. Pure C11 (stdlib + libm).
|
||||
# Standalone — NOT folded through elc. Two passes:
|
||||
# 1. PERF — optimised (-O2, no sanitizer): the functional gate.
|
||||
# 2. SAFETY — ASan + UBSan on the same suite.
|
||||
#
|
||||
# NEGATIVE CONTROL (invariant §8.6 — no test without one). Every symbol this
|
||||
# suite exercises (cog_decay_factor, cog_grounding_significant,
|
||||
# cog_significance_inherent, CogGrounding, CogProvClass) is introduced by the
|
||||
# change under test, so the suite does not COMPILE against the pre-change source.
|
||||
# To reproduce:
|
||||
# git show origin/dev:lang/runtime/engram_cognition.h > /tmp/pre/engram_cognition.h
|
||||
# git show origin/dev:lang/runtime/engram_cognition.c > /tmp/pre/engram_cognition.c
|
||||
# cc -I/tmp/pre engram/test/test_grounding_vector.c /tmp/pre/engram_cognition.c ...
|
||||
# => error: unknown type name 'CogGrounding'; no binary produced.
|
||||
set -e
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
RT="$HERE/../../lang/runtime"
|
||||
CC=${CC:-cc}
|
||||
SRC="$HERE/test_grounding_vector.c $RT/engram_cognition.c $RT/engram_reason.c $RT/engram_geometry.c $RT/engram_store.c $RT/engram_vindex.c"
|
||||
WARN="-std=c11 -Wall -Wextra"
|
||||
# engram_store.c declares emit_log as a WEAK symbol and null-checks it, which is
|
||||
# how a test links the store without the EL runtime. Darwin's ld does not resolve
|
||||
# an undefined weak symbol at static-link time, so it must be allowed explicitly.
|
||||
# (The pre-existing runners in this directory — run_verify_tests.sh among them —
|
||||
# do not do this and therefore fail to link on macOS. Unrelated to this change.)
|
||||
LDX=""
|
||||
[ "$(uname -s)" = "Darwin" ] && LDX="-Wl,-U,_emit_log"
|
||||
TMP=$(mktemp -d)
|
||||
|
||||
echo "### PASS 1: PERF (optimised, un-sanitised) — functional gate"
|
||||
$CC $WARN -O2 -I"$RT" $SRC -lm -lpthread $LDX -o "$TMP/perf"
|
||||
"$TMP/perf"
|
||||
|
||||
echo
|
||||
echo "### PASS 2: SAFETY (ASan/UBSan)"
|
||||
$CC $WARN -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -I"$RT" $SRC -lm -lpthread $LDX -o "$TMP/safe"
|
||||
ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0} UBSAN_OPTIONS=halt_on_error=1 "$TMP/safe"
|
||||
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,176 @@
|
||||
/* test_grounding_vector.c — deterministic tests for §7: the one decay model, the
|
||||
* consequence gate, and the stored/derived split. Links engram_cognition.c
|
||||
* directly; no server, no store, no network. See run_grounding_vector_tests.sh.
|
||||
*
|
||||
* NEGATIVE CONTROL (invariant §8.6). Every symbol exercised here —
|
||||
* cog_decay_factor, cog_grounding_significant, cog_significance_inherent,
|
||||
* CogGrounding, CogProvClass — is introduced by the change under test, so this
|
||||
* suite does not COMPILE against the pre-change source, let alone pass. The
|
||||
* runner documents the exact reproduction.
|
||||
*/
|
||||
#include "engram_cognition.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
static int fails = 0;
|
||||
static void ok(int cond, const char* what) {
|
||||
printf(" %-62s %s\n", what, cond ? "PASS" : "*** FAIL ***");
|
||||
if (!cond) fails++;
|
||||
}
|
||||
|
||||
/* The decay formula exactly as el_runtime.c carried it before the move, so the
|
||||
* refactor can be shown to be bit-identical rather than merely similar. */
|
||||
static double old_engram_temporal_decay(long long age_ms, long long activation_count,
|
||||
double temporal_decay_rate) {
|
||||
if (age_ms <= 0) return 1.0;
|
||||
double lambda = (temporal_decay_rate > 0.0) ? temporal_decay_rate : 0.693147;
|
||||
double age_hours = (double)age_ms / 3600000.0;
|
||||
double t_half = 168.0 * (1.0 + log(1.0 + (double)activation_count));
|
||||
double factor = exp(-lambda * age_hours / t_half);
|
||||
if (factor < 0.25) factor = 0.25;
|
||||
return factor;
|
||||
}
|
||||
|
||||
static CogGrounding base(void) {
|
||||
CogGrounding g; memset(&g, 0, sizeof g);
|
||||
g.present = 1;
|
||||
g.factual = 0.60; g.relational = 0.60;
|
||||
g.factual_now = 0.60; g.relational_now = 0.60;
|
||||
g.associative = 0.1; g.polarity = 1.0;
|
||||
g.prov = COG_PROV_TOLD;
|
||||
g.fac_proj = 1.0; g.rel_proj = 1.0;
|
||||
g.cos_angle = 0.9; g.agreement = 1;
|
||||
g.ts = 1000; g.seq = 1; g.reinforcements = 3;
|
||||
return g;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const double F = 0.5, R = 0.5;
|
||||
|
||||
printf("\n== 1. DECAY IS THE ONE MODEL, AND IT IS BIT-IDENTICAL TO WHAT IT REPLACED ==\n");
|
||||
{
|
||||
long long ages[] = {0, 3600000LL, 86400000LL, 7*86400000LL, 30*86400000LL, 365*86400000LL};
|
||||
int allsame = 1;
|
||||
for (int i = 0; i < 6; i++)
|
||||
for (int ac = 0; ac < 4; ac++) {
|
||||
long long acs[] = {0, 1, 10, 1000};
|
||||
double a = cog_decay_factor(ages[i], (double)acs[ac], 0.0);
|
||||
double b = old_engram_temporal_decay(ages[i], acs[ac], 0.0);
|
||||
if (a != b) allsame = 0;
|
||||
}
|
||||
ok(allsame, "cog_decay_factor == the pre-move engram_temporal_decay (24 pts)");
|
||||
ok(cog_decay_factor(0, 0, 0.0) == 1.0, "age 0 -> no decay");
|
||||
}
|
||||
printf("\n DECAY OVER ELAPSED TIME (reinforcements = 0, default rate):\n");
|
||||
printf(" %10s %10s\n", "elapsed", "decay");
|
||||
{
|
||||
struct { const char* label; long long ms; } pts[] = {
|
||||
{"0", 0LL},
|
||||
{"1 hour", 3600000LL},
|
||||
{"1 day", 86400000LL},
|
||||
{"3 days", 3LL*86400000LL},
|
||||
{"7 days", 7LL*86400000LL},
|
||||
{"14 days", 14LL*86400000LL},
|
||||
{"30 days", 30LL*86400000LL},
|
||||
{"90 days", 90LL*86400000LL},
|
||||
};
|
||||
double prev = 2.0; int monotone = 1;
|
||||
for (unsigned i = 0; i < sizeof pts / sizeof pts[0]; i++) {
|
||||
double d = cog_decay_factor(pts[i].ms, 0, 0.0);
|
||||
printf(" %10s %10.6f\n", pts[i].label, d);
|
||||
if (d > prev) monotone = 0;
|
||||
prev = d;
|
||||
}
|
||||
ok(monotone, "decay is monotone non-increasing in elapsed time");
|
||||
ok(fabs(cog_decay_factor(7LL*86400000LL, 0, 0.0) - 0.5) < 1e-6,
|
||||
"7 days at zero reinforcements == exactly one half-life (0.5)");
|
||||
ok(cog_decay_factor(7LL*86400000LL, 100, 0.0) > cog_decay_factor(7LL*86400000LL, 0, 0.0),
|
||||
"reinforcement slows ageing (Lindy term)");
|
||||
ok(cog_decay_factor(3650LL*86400000LL, 0, 0.0) == 0.25,
|
||||
"floor is a preference not a cliff: bottoms out at 0.25");
|
||||
}
|
||||
|
||||
printf("\n== 2. CONSEQUENCE GATE: EVERY TRIGGER, AND NO EPSILON ANYWHERE ==\n");
|
||||
{
|
||||
CogGrounding p = base(), n = base();
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE,
|
||||
"identical vectors -> NONE (a re-read must not consolidate)");
|
||||
|
||||
n = base(); n.factual = 0.9999; n.factual_now = 0.9999;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE,
|
||||
"factual 0.60 -> 0.9999 without crossing the floor -> NONE");
|
||||
|
||||
n = base(); n.relational = 0.5001; n.relational_now = 0.5001;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE,
|
||||
"relational 0.60 -> 0.5001, still above floor -> NONE");
|
||||
|
||||
n = base(); n.factual_now = 0.4999;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_FACTUAL_FLOOR,
|
||||
"a 0.1001 drop that CROSSES the floor -> FACTUAL_FLOOR");
|
||||
|
||||
n = base(); n.relational_now = 0.4999;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_RELATIONAL_FLOOR,
|
||||
"relational crossing its floor -> RELATIONAL_FLOOR");
|
||||
|
||||
n = base(); n.cos_angle = -0.05; n.agreement = -1;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_AGREEMENT_FLIP,
|
||||
"agreement +1 -> -1 -> AGREEMENT_FLIP");
|
||||
|
||||
n = base(); n.fac_proj = -0.2;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_DIRECTION_REVERSAL,
|
||||
"factual gradient reverses -> DIRECTION_REVERSAL");
|
||||
|
||||
n = base(); n.rel_proj = -0.2;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_DIRECTION_REVERSAL,
|
||||
"relational gradient reverses -> DIRECTION_REVERSAL");
|
||||
|
||||
n = base(); n.polarity = -1.0;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_POLARITY_FLIP,
|
||||
"support -> contradiction -> POLARITY_FLIP (inherent)");
|
||||
|
||||
n = base(); n.polarity = 0.0;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_POLARITY_FLIP,
|
||||
"support -> ignorance (zero) -> POLARITY_FLIP: not the same state");
|
||||
|
||||
n = base(); n.prov = COG_PROV_OBSERVED;
|
||||
ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_PROVENANCE_CHANGE,
|
||||
"told -> observed -> PROVENANCE_CHANGE (inherent)");
|
||||
|
||||
CogGrounding fresh; memset(&fresh, 0, sizeof fresh);
|
||||
ok(cog_grounding_significant(&fresh, &n, F, R) == COG_SIG_FIRST_RECORD,
|
||||
"no prior version -> FIRST_RECORD");
|
||||
}
|
||||
|
||||
printf("\n== 3. INHERENT MOVES BYPASS THE SALIENCE GATE ==\n");
|
||||
ok(cog_significance_inherent(COG_SIG_POLARITY_FLIP), "polarity flip is inherent");
|
||||
ok(cog_significance_inherent(COG_SIG_PROVENANCE_CHANGE), "provenance change is inherent");
|
||||
ok(cog_significance_inherent(COG_SIG_FIRST_RECORD), "first record is inherent");
|
||||
ok(!cog_significance_inherent(COG_SIG_FACTUAL_FLOOR), "a floor crossing is NOT inherent");
|
||||
ok(!cog_significance_inherent(COG_SIG_NONE), "NONE is not inherent");
|
||||
|
||||
printf("\n== 4. THE STORED/DERIVED SPLIT: DERIVED VALUES ARE NEVER SERIALIZED ==\n");
|
||||
{
|
||||
CogGrounding g = base();
|
||||
g.decay = 0.3333; g.factual_now = 0.1234; g.relational_now = 0.2345;
|
||||
g.associative_now = 0.4567; g.age_ms = 999999; g.stale = 1;
|
||||
char* m = cog_grounding_metadata("pre-existing=keepme", &g);
|
||||
ok(m != NULL, "serializer returns a document");
|
||||
ok(m && strstr(m, "pre-existing=keepme"), "pre-existing edge metadata preserved verbatim");
|
||||
ok(m && strstr(m, "GRD1"), "GRD1 magic present");
|
||||
ok(m && !strstr(m, "0.3333"), "decay is NOT stored");
|
||||
ok(m && !strstr(m, "0.1234"), "factual_now is NOT stored");
|
||||
ok(m && !strstr(m, "0.2345"), "relational_now is NOT stored");
|
||||
ok(m && !strstr(m, "0.4567"), "associative_now is NOT stored");
|
||||
ok(m && !strstr(m, "999999"), "age is NOT stored");
|
||||
ok(m && strstr(m, "told"), "provenance class IS stored");
|
||||
ok(m && strstr(m, "0.6"), "the factual/relational dimensions ARE stored");
|
||||
if (m) { printf("\n --- serialized GRD1 block ---\n%s -----------------------------\n", m); }
|
||||
free(m);
|
||||
}
|
||||
|
||||
printf("\n%s (%d failure%s)\n\n", fails ? "SOME TESTS FAILED" : "ALL TESTS PASSED",
|
||||
fails, fails == 1 ? "" : "s");
|
||||
return fails ? 1 : 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
build/
|
||||
+230
-262
@@ -1,17 +1,28 @@
|
||||
// ingest.el — the native EL AFFERENT INGEST ORGAN
|
||||
//
|
||||
// The source-polymorphic ingest(source) primitive: point it at a directory,
|
||||
// file, url, llm-query, structured-primitive set, or stream; it EXTRACTS the
|
||||
// real content faithfully (no invention), TRANSDUCES it into a DISCRETE
|
||||
// MANIFOLD (multiple nodes + internal edges — meaning-structure, never a
|
||||
// single blob; the conversion from extracted surface content into geometry
|
||||
// is automatic and invisible to the caller, the way digestion is invisible
|
||||
// to the one who chose to eat — ingest is the conscious act, transduce is
|
||||
// the mechanism underneath it, and it is no less real for being unseen),
|
||||
// and MERGES that manifold into the engram geometry: shared
|
||||
// meanings DEDUP onto existing nodes (search + exact/cosine match), genuinely
|
||||
// new meanings add nodes, relations add edges. Every node enters with
|
||||
// PROVENANCE + grounding-level + stewardship class from the moment of entry.
|
||||
// file, url, llm-query, or stream; it EXTRACTS the real content faithfully
|
||||
// (no invention), TRANSDUCES it into a DISCRETE MANIFOLD (multiple nodes +
|
||||
// internal edges — meaning-structure, never a single blob; the conversion
|
||||
// from extracted surface content into geometry is automatic and invisible
|
||||
// to the caller, the way digestion is invisible to the one who chose to
|
||||
// eat — ingest is the conscious act, transduce is the mechanism underneath
|
||||
// it, and it is no less real for being unseen), and MERGES that manifold
|
||||
// into the engram geometry: shared meanings DEDUP onto existing nodes
|
||||
// (search + exact/cosine match), genuinely new meanings add nodes,
|
||||
// relations add edges. Every node enters with PROVENANCE + grounding-level
|
||||
// + stewardship class from the moment of entry.
|
||||
//
|
||||
// transduce_manifold() is THE single mechanism — one function, polymorphic, with no
|
||||
// content-type branch inside it. It does not ask whether a payload is
|
||||
// prose, structured data, or raw/opaque bytes (audio, or anything else);
|
||||
// it runs one boundary-scan-with-fixed-window-fallback chunking algorithm
|
||||
// and one dedup mechanism on whatever bytes it's handed, unconditionally.
|
||||
// Any deeper structure a payload might have (shared fields, relationships,
|
||||
// what a chunk of audio "means") is NOT interpreted here — that's left
|
||||
// entirely to the engram's own mechanisms (embedding, spreading activation,
|
||||
// dedup) acting on this real geometry over time. This organ claims zero
|
||||
// semantic understanding of any payload it transduces.
|
||||
//
|
||||
// It is a pure HTTP CLIENT of the engram server — it links only el_runtime.c
|
||||
// via fs/http/json/string builtins; it never links el_seed.c or the engram
|
||||
@@ -46,60 +57,6 @@ fn j_q(s: String) -> String {
|
||||
return "\"" + j_esc(s) + "\""
|
||||
}
|
||||
|
||||
// Extract the top-level keys of a JSON object string. A thin, self-contained
|
||||
// scanner (FLAGGED: the one non-trivial parser in this organ — everything else
|
||||
// is faithful text handling). Tracks string state + brace/bracket depth; a key
|
||||
// is a string at object-interior depth 1 immediately followed by ':'.
|
||||
fn json_object_keys(obj: String) -> [String] {
|
||||
let keys: [String] = el_list_empty()
|
||||
let n: Int = str_len(obj)
|
||||
let i: Int = 0
|
||||
let depth: Int = 0
|
||||
let in_str: Bool = false
|
||||
let esc: Bool = false
|
||||
let str_start: Int = -1
|
||||
let cur: String = ""
|
||||
let have_key: Bool = false
|
||||
while i < n {
|
||||
let c: String = str_char_at(obj, i)
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false
|
||||
} else {
|
||||
if str_eq(c, "\\") {
|
||||
esc = true
|
||||
} else {
|
||||
if str_eq(c, "\"") {
|
||||
in_str = false
|
||||
cur = str_slice(obj, str_start + 1, i)
|
||||
have_key = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if str_eq(c, "\"") {
|
||||
in_str = true
|
||||
str_start = i
|
||||
}
|
||||
if str_eq(c, "{") { depth = depth + 1 }
|
||||
if str_eq(c, "}") { depth = depth - 1 }
|
||||
if str_eq(c, "[") { depth = depth + 1 }
|
||||
if str_eq(c, "]") { depth = depth - 1 }
|
||||
if str_eq(c, ":") {
|
||||
if have_key {
|
||||
if depth == 1 {
|
||||
keys = el_list_append(keys, cur)
|
||||
}
|
||||
}
|
||||
have_key = false
|
||||
}
|
||||
if str_eq(c, ",") { have_key = false }
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SECTION B — engram HTTP client (provenance-carrying afferent LOAD)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -402,168 +359,140 @@ fn head80(s: String) -> String {
|
||||
// We accumulate into module-level lists carried by the caller.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// PROSE: chunk text into a discrete manifold. Split on blank lines into
|
||||
// paragraphs; every non-empty paragraph is its own node (NEVER one blob).
|
||||
// Edges: doc-root -contains-> chunk; chunk -precedes-> next chunk;
|
||||
// most-recent-heading -section_of-> chunk. Content is verbatim (substring of
|
||||
// the source) — pure extraction of ground truth.
|
||||
fn transduce_prose(nodes: [String], edges: [String], text: String,
|
||||
prov: String, ground: String, steward: String,
|
||||
root_lid: String, root_title: String) -> [String] {
|
||||
// returns [nodes_json_list_encoded, edges_json_list_encoded] is awkward in
|
||||
// EL; instead we mutate by returning a 2-list. We package results as a
|
||||
// single JSON array string carrying {nodes:[...],edges:[...]} additions.
|
||||
// (Kept simple: caller passes empty lists and receives the packaged pair.)
|
||||
// TRANSDUCE — the single mechanism. Takes ANY payload (prose, structured
|
||||
// data, raw/opaque bytes — audio, whatever) as one opaque string and turns
|
||||
// it into a discrete manifold: nodes + internal edges. There is no
|
||||
// content-type branch anywhere in this function. It never asks "is this
|
||||
// text," "is this JSON," "is this audio" — it runs ONE algorithm on the
|
||||
// bytes it is given, unconditionally:
|
||||
//
|
||||
// 1. BOUNDARY SCAN — split on "\n\n". This is a property of the bytes
|
||||
// (does a blank-line-style marker occur in them, yes or no), not a
|
||||
// classification of what the content IS. Prose paragraphs split on it
|
||||
// because that's how prose is typically written; that's a fact about
|
||||
// the bytes, not a rule this function knows about prose. Anything else
|
||||
// that happens to contain the same marker splits on it too, and
|
||||
// anything that doesn't, doesn't — same code path either way.
|
||||
// 2. FIXED-WINDOW FALLBACK — if step 1 found no boundary (0 or 1 non-empty
|
||||
// piece), the payload is cut into fixed-size windows instead. Same
|
||||
// chunk-per-node, edge-per-adjacency structure as step 1 produces; only
|
||||
// the source of the cut point differs.
|
||||
//
|
||||
// Every resulting chunk becomes its own node (never one blob), wired with
|
||||
// the same edges regardless of what's inside a chunk: root -contains->
|
||||
// chunk, chunk -precedes-> next chunk, and — if a chunk happens to start
|
||||
// with "#" — most-recent-heading -section_of-> chunk. That "#" check is a
|
||||
// structural marker (a fact about a chunk's first byte), not a decision
|
||||
// about whether this run is "the text case": chunks from any payload that
|
||||
// never happen to start with "#" simply never trigger it.
|
||||
//
|
||||
// Dedup is the existing, fully generic mechanism (find_existing_by_content,
|
||||
// via merge_manifold downstream of merge_packed) applied uniformly to every
|
||||
// chunk from every payload — there is no separate "structured" dedup path.
|
||||
// Any deeper structure that might exist inside a payload (shared fields,
|
||||
// repeated records, relationships) is NOT pre-computed here; that's left to
|
||||
// the engram's own mechanisms (embedding, spreading activation, dedup)
|
||||
// acting on this geometry over time, which is the whole point of handing it
|
||||
// raw bytes instead of a hand-coded interpretation of them.
|
||||
//
|
||||
// Byte-safety note: `source` must already be a string this function can
|
||||
// safely str_split/str_slice. Protecting it from silent truncation (El
|
||||
// strings are NUL-unsafe under strlen-based ops; fs_read()'s result
|
||||
// truncates at the first embedded NUL, which is routine in real binary
|
||||
// bytes) is a MECHANICAL fidelity concern that belongs to whatever produced
|
||||
// `source` (see ingest_file's file_source_string below) — not a
|
||||
// content-type judgment made in here. transduce_manifold() never learns whether a
|
||||
// chunk is plain text or a base64-encoded raw-byte window; every chunk is
|
||||
// handled identically either way.
|
||||
// RENAMED transduce -> transduce_manifold (2026-08-16). Two reasons, and the
|
||||
// first is not the interesting one:
|
||||
//
|
||||
// 1. Mechanical: `transduce` is now a LANGUAGE primitive in el_runtime.h
|
||||
// (transduce(signal, modality) -> Geometry). Every El `fn name(...)`
|
||||
// compiles to a global C symbol with that exact name, so keeping this
|
||||
// name here is a hard `conflicting types for 'transduce'` compile error
|
||||
// the moment ingest.c links el_runtime.c. Measured, not anticipated.
|
||||
//
|
||||
// 2. Actual: this function was never signal->geometry. It chunks already-
|
||||
// extracted content and PACKS it into a node+edge manifold — a real
|
||||
// operation, but one layer up, and it had taken the name that belongs to
|
||||
// the primitive underneath it. `transduce` is where a signal becomes
|
||||
// geometry; `transduce_manifold` is where extracted content becomes
|
||||
// structure. Nothing about this function's behaviour changed.
|
||||
fn transduce_manifold(nodes: [String], edges: [String], source: String,
|
||||
prov: String, ground: String, steward: String,
|
||||
root_lid: String, root_title: String) -> [String] {
|
||||
let tagbase: String = "prov:" + prov + " ground:" + ground + " steward:" + steward
|
||||
// root node
|
||||
nodes = el_list_append(nodes, mk_node(root_lid, "document: " + root_title,
|
||||
"Concept", "Semantic", "0.6", "0.6", "0.9", tagbase + " kind:document"))
|
||||
nodes = el_list_append(nodes, mk_node(root_lid, "source: " + root_title,
|
||||
"Concept", "Semantic", "0.6", "0.6", "0.9", tagbase + " kind:source"))
|
||||
|
||||
let paras: [String] = str_split(text, "\n\n")
|
||||
let np: Int = el_list_len(paras)
|
||||
let idx: Int = 0
|
||||
// step 1: universal boundary scan
|
||||
let boundary_parts: [String] = str_split(source, "\n\n")
|
||||
let chunks: [String] = el_list_empty()
|
||||
let bp_n: Int = el_list_len(boundary_parts)
|
||||
let bp_i: Int = 0
|
||||
while bp_i < bp_n {
|
||||
let piece: String = str_trim(el_list_get(boundary_parts, bp_i))
|
||||
if !str_eq(piece, "") { chunks = el_list_append(chunks, piece) }
|
||||
bp_i = bp_i + 1
|
||||
}
|
||||
|
||||
// step 2: no boundary found -> fixed-size windows over the whole
|
||||
// payload. 4096 chars/node: low kilobytes — big enough to keep node
|
||||
// count sane on a large unbroken payload, small enough that each node
|
||||
// stays a legible, individually embeddable/dedupable unit rather than
|
||||
// one giant blob.
|
||||
if el_list_len(chunks) <= 1 {
|
||||
chunks = el_list_empty()
|
||||
let total: Int = str_len(source)
|
||||
let win: Int = 4096
|
||||
let off: Int = 0
|
||||
while off < total {
|
||||
let endp: Int = if off + win < total { off + win } else { total }
|
||||
let piece: String = str_slice(source, off, endp)
|
||||
if !str_eq(piece, "") { chunks = el_list_append(chunks, piece) }
|
||||
off = off + win
|
||||
}
|
||||
}
|
||||
|
||||
let nc: Int = el_list_len(chunks)
|
||||
let ci: Int = 0
|
||||
let last_chunk: String = ""
|
||||
let last_heading: String = ""
|
||||
let ci: Int = 0
|
||||
while idx < np {
|
||||
let raw: String = str_trim(el_list_get(paras, idx))
|
||||
if !str_eq(raw, "") {
|
||||
let lid: String = root_lid + ":c" + int_to_str(ci)
|
||||
let is_heading: Bool = str_starts_with(raw, "#")
|
||||
let kind: String = if is_heading { "kind:heading" } else { "kind:doc-chunk" }
|
||||
nodes = el_list_append(nodes, mk_node(lid, raw,
|
||||
"Knowledge", "Semantic", "0.55", "0.55", "0.9", tagbase + " " + kind))
|
||||
// containment: document root -contains-> chunk
|
||||
edges = el_list_append(edges, mk_edge(root_lid, "contains", lid))
|
||||
// sequence: previous chunk -precedes-> this chunk
|
||||
if !str_eq(last_chunk, "") {
|
||||
edges = el_list_append(edges, mk_edge(last_chunk, "precedes", lid))
|
||||
}
|
||||
// sectioning: most-recent heading -section_of-> this chunk
|
||||
if is_heading {
|
||||
last_heading = lid
|
||||
} else {
|
||||
if !str_eq(last_heading, "") {
|
||||
edges = el_list_append(edges, mk_edge(last_heading, "section_of", lid))
|
||||
}
|
||||
}
|
||||
last_chunk = lid
|
||||
ci = ci + 1
|
||||
while ci < nc {
|
||||
let raw: String = el_list_get(chunks, ci)
|
||||
let lid: String = root_lid + ":c" + int_to_str(ci)
|
||||
let is_heading: Bool = str_starts_with(raw, "#")
|
||||
let kind: String = if is_heading { "kind:heading" } else { "kind:chunk" }
|
||||
nodes = el_list_append(nodes, mk_node(lid, raw,
|
||||
"Knowledge", "Semantic", "0.55", "0.55", "0.9", tagbase + " " + kind))
|
||||
// containment: root -contains-> chunk
|
||||
edges = el_list_append(edges, mk_edge(root_lid, "contains", lid))
|
||||
// sequence: previous chunk -precedes-> this chunk
|
||||
if !str_eq(last_chunk, "") {
|
||||
edges = el_list_append(edges, mk_edge(last_chunk, "precedes", lid))
|
||||
}
|
||||
idx = idx + 1
|
||||
}
|
||||
// package: we return the two lists concatenated via a sentinel; but EL
|
||||
// lists can't nest heterogeneously here, so we instead return nodes and
|
||||
// rely on the caller holding edges by reference is not possible — so we
|
||||
// encode both into one list: [ "N" + nodejson ... , "E" + edgejson ... ].
|
||||
let packed: [String] = el_list_empty()
|
||||
let a: Int = 0
|
||||
let an: Int = el_list_len(nodes)
|
||||
while a < an { packed = el_list_append(packed, "N" + el_list_get(nodes, a)) a = a + 1 }
|
||||
let b: Int = 0
|
||||
let bn: Int = el_list_len(edges)
|
||||
while b < bn { packed = el_list_append(packed, "E" + el_list_get(edges, b)) b = b + 1 }
|
||||
return packed
|
||||
}
|
||||
|
||||
// STRUCTURED / RAW-GEOMETRY: ingest structured primitives (phonetics/formants,
|
||||
// instrument signatures, scene primitives) as GEOMETRY, faithfully. Normalized
|
||||
// input shape:
|
||||
// {"dataset":"<name>","primitive_type":"<t>",
|
||||
// "records":[{"key":"<id>","features":{...categorical...},"attributes":{...}}]}
|
||||
// Each record -> a primitive node; each categorical feature -> a SHARED feature
|
||||
// node (deduped across records: many primitives -> one feature node = real
|
||||
// connective geometry, meaning saturates); numeric attributes fold into the
|
||||
// primitive's content (unique values, no dedup benefit). This is knowledge
|
||||
// represented as geometry, not prose — the path speech/music/image ingest on.
|
||||
fn transduce_structured(nodes: [String], edges: [String], js: String,
|
||||
prov: String, ground: String, steward: String,
|
||||
root_lid: String) -> [String] {
|
||||
// grounding integrity: the SOURCE may declare its own epistemic grounding
|
||||
// (measured / derived / convention / ...) via a top-level "grounding" field;
|
||||
// honor it faithfully over the ingest-time default. This keeps the per-node
|
||||
// ground: facet consistent with the source's honest self-description.
|
||||
let src_ground: String = json_get_string(js, "grounding")
|
||||
let use_ground: String = if str_eq(src_ground, "") { ground } else { src_ground }
|
||||
let tagbase: String = "prov:" + prov + " ground:" + use_ground + " steward:" + steward
|
||||
let dsname: String = json_get_string(js, "dataset")
|
||||
let ptype: String = json_get_string(js, "primitive_type")
|
||||
// capture the source's own scholarly provenance citation (verbatim) onto
|
||||
// the dataset root — faithful attribution, retrievable, reachable from every
|
||||
// primitive via its -contains- edge back to the root.
|
||||
let src_cite: String = json_get_string(js, "provenance")
|
||||
let root_content: String = "dataset: " + dsname + " (" + ptype + ")"
|
||||
if !str_eq(src_cite, "") { root_content = root_content + " | provenance: " + src_cite }
|
||||
nodes = el_list_append(nodes, mk_node(root_lid, root_content,
|
||||
"Concept", "Semantic", "0.6", "0.6", "0.9", tagbase + " kind:dataset"))
|
||||
|
||||
let recs: String = json_get_raw(js, "records")
|
||||
let nr: Int = json_array_len(recs)
|
||||
let r: Int = 0
|
||||
while r < nr {
|
||||
let rec: String = json_array_get(recs, r)
|
||||
let rkey: String = json_get_string(rec, "key")
|
||||
let attrs: String = json_get_raw(rec, "attributes")
|
||||
// faithful compact serialization of the primitive's numeric signature
|
||||
let attr_str: String = flatten_pairs(attrs)
|
||||
let content: String = ptype + " " + rkey
|
||||
if !str_eq(attr_str, "") { content = content + " | " + attr_str }
|
||||
let plid: String = root_lid + ":" + rkey
|
||||
nodes = el_list_append(nodes, mk_node(plid, content,
|
||||
"Concept", "Semantic", "0.6", "0.6", "0.92",
|
||||
tagbase + " kind:primitive primitive:" + ptype + " key:" + rkey))
|
||||
edges = el_list_append(edges, mk_edge(root_lid, "contains", plid))
|
||||
|
||||
// categorical features -> SHARED (deduped) feature nodes + labelled edges
|
||||
let feats: String = json_get_raw(rec, "features")
|
||||
let fkeys: [String] = json_object_keys(feats)
|
||||
let fk: Int = el_list_len(fkeys)
|
||||
let k: Int = 0
|
||||
while k < fk {
|
||||
let fname: String = el_list_get(fkeys, k)
|
||||
let fval: String = json_get_string(feats, fname)
|
||||
// shared feature node: content is the feature=value pair; identical
|
||||
// pairs across records dedup onto ONE node (the geometry).
|
||||
let flid: String = "feat:" + fname + "=" + fval
|
||||
let fcontent: String = fname + "=" + fval
|
||||
nodes = el_list_append(nodes, mk_node(flid, fcontent,
|
||||
"Concept", "Semantic", "0.5", "0.5", "0.9",
|
||||
tagbase + " kind:feature feature:" + fname))
|
||||
edges = el_list_append(edges, mk_edge(plid, fname, flid))
|
||||
k = k + 1
|
||||
// sectioning: most-recent heading -section_of-> this chunk
|
||||
if is_heading {
|
||||
last_heading = lid
|
||||
} else {
|
||||
if !str_eq(last_heading, "") {
|
||||
edges = el_list_append(edges, mk_edge(last_heading, "section_of", lid))
|
||||
}
|
||||
}
|
||||
r = r + 1
|
||||
last_chunk = lid
|
||||
ci = ci + 1
|
||||
}
|
||||
let packed: [String] = el_list_empty()
|
||||
let a: Int = 0
|
||||
let an: Int = el_list_len(nodes)
|
||||
while a < an { packed = el_list_append(packed, "N" + el_list_get(nodes, a)) a = a + 1 }
|
||||
let b: Int = 0
|
||||
let bn: Int = el_list_len(edges)
|
||||
while b < bn { packed = el_list_append(packed, "E" + el_list_get(edges, b)) b = b + 1 }
|
||||
return packed
|
||||
}
|
||||
|
||||
// flatten a flat JSON object of scalar fields into "k=v k=v" (faithful; values
|
||||
// verbatim). Used for numeric attribute signatures.
|
||||
fn flatten_pairs(obj: String) -> String {
|
||||
if str_eq(obj, "") { return "" }
|
||||
let keys: [String] = json_object_keys(obj)
|
||||
let n: Int = el_list_len(keys)
|
||||
let out: String = ""
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let k: String = el_list_get(keys, i)
|
||||
// json_get_raw returns the raw token — works for NUMBERS (bare, e.g.
|
||||
// "270") where json_get_string yields "" for non-string values. Strip
|
||||
// surrounding quotes if the value happens to be a string token.
|
||||
let raw: String = json_get_raw(obj, k)
|
||||
let v: String = str_replace(raw, "\"", "")
|
||||
let sep: String = if i == 0 { "" } else { " " }
|
||||
out = out + sep + k + "=" + v
|
||||
i = i + 1
|
||||
}
|
||||
return out
|
||||
// package both lists into one, "N"/"E"-prefixed (see merge_packed).
|
||||
let packed: [String] = el_list_empty()
|
||||
let pn_i: Int = 0
|
||||
let pn_n: Int = el_list_len(nodes)
|
||||
while pn_i < pn_n { packed = el_list_append(packed, "N" + el_list_get(nodes, pn_i)) pn_i = pn_i + 1 }
|
||||
let pe_i: Int = 0
|
||||
let pe_n: Int = el_list_len(edges)
|
||||
while pe_i < pe_n { packed = el_list_append(packed, "E" + el_list_get(edges, pe_i)) pe_i = pe_i + 1 }
|
||||
return packed
|
||||
}
|
||||
|
||||
// unpack the "N"/"E"-prefixed packed list back into two lists, then merge
|
||||
@@ -594,18 +523,7 @@ fn basename(path: String) -> String {
|
||||
return el_list_get(parts, n - 1)
|
||||
}
|
||||
|
||||
fn ends_with_ci(s: String, suf: String) -> Bool {
|
||||
return str_ends_with(str_to_lower(s), suf)
|
||||
}
|
||||
|
||||
fn is_text_file(path: String) -> Bool {
|
||||
return ends_with_ci(path, ".md") || ends_with_ci(path, ".txt")
|
||||
|| ends_with_ci(path, ".markdown") || ends_with_ci(path, ".text")
|
||||
}
|
||||
|
||||
// default ingestion grounding; overridable per-invocation via INGEST_GROUND.
|
||||
// Note: a source's OWN top-level "grounding" field (structured) takes precedence
|
||||
// over this — the author's honest self-description wins.
|
||||
fn default_ground() -> String {
|
||||
let g: String = env("INGEST_GROUND")
|
||||
if str_eq(g, "") { return "extracted" }
|
||||
@@ -618,25 +536,67 @@ fn default_steward() -> String {
|
||||
return s
|
||||
}
|
||||
|
||||
// ingest one file -> report JSON
|
||||
// Mechanical fidelity guard — NOT a content-type test. fs_read()'s el_val_t
|
||||
// result truncates at the first embedded NUL byte under El's strlen-based
|
||||
// string ops (see fs_size's doc comment in runtime/el_runtime.h); comparing
|
||||
// its length against fs_size() (a real stat()-based byte count) is a
|
||||
// technical fact about whether the string channel captured the file intact
|
||||
// — computed the same way for a poem, a JSON file, or a WAV, and saying
|
||||
// nothing about what the file IS. When the counts agree, `text` is
|
||||
// trustworthy verbatim. When they don't (silent truncation happened),
|
||||
// rebuild the payload as base64-encoded fixed-size windows read directly
|
||||
// off disk (fs_read_b64_chunk — binary-safe in C), joined with the same
|
||||
// "\n\n" boundary marker transduce_manifold()'s generic scan already looks for, so
|
||||
// transduce_manifold() sees one ordinary boundary-delimited payload and runs its one
|
||||
// algorithm on it exactly as it would on prose — it never learns that a
|
||||
// fidelity problem occurred upstream, let alone why.
|
||||
fn file_source_string(path: String, text: String, real_size: Int) -> String {
|
||||
if real_size <= 0 { return text }
|
||||
if str_len(text) == real_size { return text }
|
||||
// 3072 raw bytes -> 4096 base64 chars (3 divides evenly into base64's
|
||||
// 3-byte/4-char ratio); keeps each resulting node's content a clean,
|
||||
// bounded, low-kilobytes unit, same order of magnitude as the fixed
|
||||
// fallback window in transduce_manifold() itself.
|
||||
let win: Int = 3072
|
||||
let out: String = ""
|
||||
let off: Int = 0
|
||||
let first: Bool = true
|
||||
while off < real_size {
|
||||
let chunk_b64: String = fs_read_b64_chunk(path, off, win)
|
||||
if str_eq(chunk_b64, "") {
|
||||
off = real_size
|
||||
} else {
|
||||
let sep: String = if first { "" } else { "\n\n" }
|
||||
out = out + sep + chunk_b64
|
||||
first = false
|
||||
off = off + win
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ingest one file -> report JSON. Uniform for every file regardless of
|
||||
// extension or content — transduce_manifold() decides nothing about content-type, so
|
||||
// neither does this function; it only decides whether the raw bytes made it
|
||||
// through the read intact (file_source_string), which is a fidelity
|
||||
// question, not a format one.
|
||||
fn ingest_file(path: String) -> String {
|
||||
let real_size: Int = fs_size(path)
|
||||
let text: String = fs_read(path)
|
||||
if str_eq(text, "") {
|
||||
let source: String = file_source_string(path, text, real_size)
|
||||
if str_eq(source, "") {
|
||||
return "{\"error\":\"empty or unreadable\",\"path\":" + j_q(path) + "}"
|
||||
}
|
||||
let prov: String = "file:" + path
|
||||
if ends_with_ci(path, ".json") {
|
||||
let packed: [String] = transduce_structured(el_list_empty(), el_list_empty(),
|
||||
text, prov, default_ground(), default_steward(), "ds:" + basename(path))
|
||||
return merge_packed(packed)
|
||||
}
|
||||
let packed: [String] = transduce_prose(el_list_empty(), el_list_empty(),
|
||||
text, prov, default_ground(), default_steward(),
|
||||
let packed: [String] = transduce_manifold(el_list_empty(), el_list_empty(),
|
||||
source, prov, default_ground(), default_steward(),
|
||||
"doc:" + basename(path), basename(path))
|
||||
return merge_packed(packed)
|
||||
}
|
||||
|
||||
// ingest a directory: walk one level, ingest each supported file, aggregate
|
||||
// ingest a directory: walk one level, ingest every file found, aggregate.
|
||||
// No extension filter — transduce_manifold() handles any payload uniformly now, so
|
||||
// there is no content-type gate at the directory boundary either.
|
||||
fn ingest_dir(path: String) -> String {
|
||||
let entries: [String] = fs_list(path)
|
||||
let n: Int = el_list_len(entries)
|
||||
@@ -649,14 +609,12 @@ fn ingest_dir(path: String) -> String {
|
||||
let name: String = str_trim(el_list_get(entries, i))
|
||||
if !str_eq(name, "") {
|
||||
let full: String = path + "/" + name
|
||||
if is_text_file(full) || ends_with_ci(full, ".json") {
|
||||
println("FILE " + full)
|
||||
let rep: String = ingest_file(full)
|
||||
tot_created = tot_created + json_get_int(rep, "nodes_created")
|
||||
tot_deduped = tot_deduped + json_get_int(rep, "nodes_deduped")
|
||||
tot_edges = tot_edges + json_get_int(rep, "edges_added")
|
||||
files = files + 1
|
||||
}
|
||||
println("FILE " + full)
|
||||
let rep: String = ingest_file(full)
|
||||
tot_created = tot_created + json_get_int(rep, "nodes_created")
|
||||
tot_deduped = tot_deduped + json_get_int(rep, "nodes_deduped")
|
||||
tot_edges = tot_edges + json_get_int(rep, "edges_added")
|
||||
files = files + 1
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
@@ -667,11 +625,12 @@ fn ingest_dir(path: String) -> String {
|
||||
",\"edges_accepted\":" + int_to_str(tot_edges) + "}"
|
||||
}
|
||||
|
||||
// ingest a url: fetch, treat body as prose (faithful extraction of what's there)
|
||||
// ingest a url: fetch, hand the body straight to transduce (faithful
|
||||
// extraction of what's there — no interpretation of what it is)
|
||||
fn ingest_url(url: String) -> String {
|
||||
let body: String = http_get(url)
|
||||
if str_eq(body, "") { return "{\"error\":\"empty fetch\",\"url\":" + j_q(url) + "}" }
|
||||
let packed: [String] = transduce_prose(el_list_empty(), el_list_empty(),
|
||||
let packed: [String] = transduce_manifold(el_list_empty(), el_list_empty(),
|
||||
body, "url:" + url, "extracted", "public-web",
|
||||
"url:" + url, url)
|
||||
return merge_packed(packed)
|
||||
@@ -686,7 +645,7 @@ fn ingest_llm(query: String) -> String {
|
||||
let resp: String = http_post_json("http://127.0.0.1:11434/api/generate", body)
|
||||
let answer: String = json_get_string(resp, "response")
|
||||
if str_eq(answer, "") { return "{\"error\":\"no model response\"}" }
|
||||
let packed: [String] = transduce_prose(el_list_empty(), el_list_empty(),
|
||||
let packed: [String] = transduce_manifold(el_list_empty(), el_list_empty(),
|
||||
answer, "llm:" + model + ":" + query, "candidate-provisional", "guide-provisional",
|
||||
"llm:" + query, "guide answer: " + query)
|
||||
return merge_packed(packed)
|
||||
@@ -728,6 +687,19 @@ fn ingest_stream(path: String) -> String {
|
||||
// SECTION G — ENTRY
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// INGEST_KIND selects an ACQUISITION mechanism only — dir/file/url/llm/
|
||||
// stream — i.e. which RPC shape to use to go get the bytes (walk a
|
||||
// directory, open a file, fetch a URL, query an LLM, read a turn-stream).
|
||||
// That is a genuinely unavoidable choice at the process-entry boundary
|
||||
// (nothing about the string "/tmp/x" tells you whether it's a file to read
|
||||
// or a stream to read line-by-line, or distinguishes an LLM query from a
|
||||
// path), so it cannot be dropped the way content-type dispatch was.
|
||||
// It is NOT a content-type flag: it says nothing about what's inside the
|
||||
// bytes once fetched, and none of the five ingest_* functions it selects
|
||||
// among interpret their payload differently by content shape anymore —
|
||||
// they all hand off to the single, format-agnostic transduce_manifold(). The old
|
||||
// "structured" value (a caller-declared alias for "file", used only to hint
|
||||
// the now-removed JSON-vs-prose branch) is gone along with that branch.
|
||||
let kind: String = env("INGEST_KIND")
|
||||
let arg: String = env("INGEST_ARG")
|
||||
|
||||
@@ -741,20 +713,16 @@ if str_eq(kind, "dir") {
|
||||
if str_eq(kind, "file") {
|
||||
report = ingest_file(arg)
|
||||
} else {
|
||||
if str_eq(kind, "structured") {
|
||||
report = ingest_file(arg)
|
||||
if str_eq(kind, "url") {
|
||||
report = ingest_url(arg)
|
||||
} else {
|
||||
if str_eq(kind, "url") {
|
||||
report = ingest_url(arg)
|
||||
if str_eq(kind, "llm") {
|
||||
report = ingest_llm(arg)
|
||||
} else {
|
||||
if str_eq(kind, "llm") {
|
||||
report = ingest_llm(arg)
|
||||
if str_eq(kind, "stream") {
|
||||
report = ingest_stream(arg)
|
||||
} else {
|
||||
if str_eq(kind, "stream") {
|
||||
report = ingest_stream(arg)
|
||||
} else {
|
||||
report = "{\"error\":\"unknown INGEST_KIND: " + kind + "\"}"
|
||||
}
|
||||
report = "{\"error\":\"unknown INGEST_KIND: " + kind + "\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-12
@@ -62,34 +62,45 @@ This is where almost all work belongs. El programs are source files that get com
|
||||
|
||||
This is the self-contained C OS-boundary layer. It provides the `__`-prefixed primitives that compiled El programs call: libcurl HTTP, pthreads, filesystem I/O, arena allocation, etc. It is **not generated** — it is maintained by hand.
|
||||
|
||||
The old `el_runtime.c` has been archived to `runtime/legacy/`. The runtime is now native El (`runtime/*.el`). `el_seed.c` replaces `el_runtime.c` as the sole C compilation dependency.
|
||||
The runtime is native El (`runtime/*.el`) over a C OS-boundary. **Status (verified 2026-08-15):** the migration to a seed-only boundary is *in progress, not done*. Two files exist:
|
||||
- `runtime/el_runtime.c` (~860 KB) — **LIVE**. Holds the engram store (`EngramStore engram_global`) plus the `http_*`/`json_*`/`state_*`/`engram_*` impls. It is the authoritative single-file link target for the compiler, and `tools/install.sh` compiles it into `libel.a`. This is where a new C builtin's *implementation* must currently live to be linkable.
|
||||
- `runtime/el_seed.c` — the intended hand-maintained `__`-prefixed seed (thin wrappers over the above). It is compiled alongside `el_runtime.c` by `tools/install.sh`, but does **not** compile standalone yet (see the build-path caveat under "Rebuilding the Compiler").
|
||||
|
||||
**Only edit `el_seed.c` when you genuinely need OS-level access** (raw sockets, GPU calls, new libcurl features). For everything else, write El.
|
||||
**Only edit these when you genuinely need OS-level access** (raw sockets, GPU calls, new libcurl features, a new engram store op). For everything else, write El.
|
||||
|
||||
When you do add a C builtin:
|
||||
1. Add the C function to `el_seed.c`
|
||||
2. Declare it in `el_seed.h`
|
||||
3. Add it to the `builtin_arity` table in `el-compiler/src/codegen.el` (so the compiler knows the arg count)
|
||||
4. Rebuild the elc binary (see below)
|
||||
When you add a C builtin (verbatim-emit recipe — the El name is emitted as the exact C symbol; `builtin_arity` is an arity guard only, not a dispatch table):
|
||||
1. Implement the C function in `el_runtime.c` (and declare it in `el_runtime.h`).
|
||||
2. Add a `__`-prefixed thin wrapper in `el_seed.c` and declare it in `el_seed.h`.
|
||||
3. Add the name to `builtin_arity` in `el-compiler/src/codegen.el` — add **both** the plain and `__`-prefixed spellings.
|
||||
4. Rebuild the elc binary (see below) and confirm the self-host fixpoint is byte-identical.
|
||||
|
||||
Worked example: the `engram_assert_json` (op_assert seam) and `engram_node_full_in`/`engram_connect_in` (purview write-side) primitives added 2026-08-15 follow exactly this recipe.
|
||||
|
||||
---
|
||||
|
||||
## Rebuilding the Compiler
|
||||
|
||||
After changing any `.el` source in `el-compiler/src/`:
|
||||
After changing any `.el` source in `el-compiler/src/` (run from the `lang/` dir):
|
||||
|
||||
```bash
|
||||
cd /Users/will/Development/neuron-technologies/foundation/el
|
||||
# 1. Stage2: current elc compiles the (modified) compiler to C
|
||||
./dist/platform/elc elc-cli.el > elc-new.c
|
||||
# 2. Build the new compiler. The C link target is el_runtime.c — it holds the
|
||||
# engram store + http/json/state impls the compiler output calls. el_runtime.c
|
||||
# self-hosts elc on its own; el_seed.c is the (aspirational) seed layer and does
|
||||
# NOT compile standalone under clang (missing prototypes for the el_runtime.c
|
||||
# symbols it wraps — see caveat below), so link el_runtime.c here.
|
||||
cc -std=c11 -I runtime -lcurl -lpthread \
|
||||
-o dist/platform/elc-new \
|
||||
elc-new.c runtime/el_seed.c
|
||||
# Verify self-hosting:
|
||||
elc-new.c runtime/el_runtime.c
|
||||
# 3. Verify self-hosting FIXPOINT (stage3 == stage2 output, byte-identical):
|
||||
./dist/platform/elc-new elc-cli.el > elc-verify.c
|
||||
diff elc-new.c elc-verify.c # should be identical
|
||||
diff elc-new.c elc-verify.c # must be identical
|
||||
mv dist/platform/elc-new dist/platform/elc
|
||||
```
|
||||
|
||||
> **Build-path caveat (verified 2026-08-15).** `el_seed.c` is the intended hand-maintained OS-boundary seed, but it does **not** compile standalone under modern clang: it wraps ~16 unprefixed `el_runtime.c` symbols (`http_serve`, `json_*`, `state_*`, `http_response`) without prototypes, and clang treats implicit declarations as errors (C99+). The productionised install (`tools/install.sh`) builds `libel.a` from **both** `el_seed.o` + `el_runtime.o` together, which is why linking succeeds there. To make `el_seed.c` build on its own, add prototypes for those symbols (or `#include "el_runtime.h"`, reconciling the `__http_serve` return-type mismatch first). Until then, `el_runtime.c` is the authoritative single-file link target for the compiler.
|
||||
|
||||
After changing `el_seed.c` only (no El source changes), rebuild downstream programs but do NOT need to rebuild the compiler binary itself — the seed is linked at the application level, not the compiler level.
|
||||
|
||||
---
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+244
-20
@@ -862,10 +862,23 @@ fn cg_expr(expr: Map<String, Any>) -> String {
|
||||
// arithmetic BinOp (or vice-versa). Without this check the
|
||||
// fallthrough to str_eq produces str_eq(int_value, int_value)
|
||||
// which reads the integer as a char* and segfaults.
|
||||
// EITHER side provably Int is enough. Requiring BOTH meant a call
|
||||
// whose return type codegen cannot infer poisoned the operator:
|
||||
// getint(5) == a -> str_eq(getint(5), a)
|
||||
// even with `a` declared Int. str_eq then reads an integer as a
|
||||
// char* and segfaults. Only an integer LITERAL on one side forced
|
||||
// the numeric form, so the bug was invisible in the common case.
|
||||
//
|
||||
// Loosening to OR is strictly safer: when one side is a known Int,
|
||||
// str_eq is always wrong (it dereferences that int), while numeric
|
||||
// comparison is at worst a wrong answer on an already ill-typed
|
||||
// program. When neither side is Int nothing changes, so string
|
||||
// comparison is untouched.
|
||||
if is_int_expr(left) {
|
||||
if is_int_expr(right) {
|
||||
return "(" + left_c + " == " + right_c + ")"
|
||||
}
|
||||
return "(" + left_c + " == " + right_c + ")"
|
||||
}
|
||||
if is_int_expr(right) {
|
||||
return "(" + left_c + " == " + right_c + ")"
|
||||
}
|
||||
// Float literal or negative float literal: use plain == (bit-equal
|
||||
// el_val_t comparison). This handles `r0 == 3.0`, `neg == -3.0`, etc.
|
||||
@@ -921,10 +934,12 @@ fn cg_expr(expr: Map<String, Any>) -> String {
|
||||
}
|
||||
// Same mixed Ident/BinOp fix as EqEq: use is_int_expr to detect
|
||||
// integer-typed operands before falling through to !str_eq.
|
||||
// Either side Int is enough — see the EqEq note above.
|
||||
if is_int_expr(left) {
|
||||
if is_int_expr(right) {
|
||||
return "(" + left_c + " != " + right_c + ")"
|
||||
}
|
||||
return "(" + left_c + " != " + right_c + ")"
|
||||
}
|
||||
if is_int_expr(right) {
|
||||
return "(" + left_c + " != " + right_c + ")"
|
||||
}
|
||||
// Float-typed operands use plain != (bit-equal comparison).
|
||||
if is_float_expr(left) {
|
||||
@@ -1495,6 +1510,11 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
|
||||
if str_eq(ltype, "Int") {
|
||||
add_int_name(name)
|
||||
}
|
||||
// Same as params: Bool is an int in the value model. Without this a
|
||||
// `let ok: Bool = ...` compared to another Bool lowered to str_eq.
|
||||
if str_eq(ltype, "Bool") {
|
||||
add_int_name(name)
|
||||
}
|
||||
if str_eq(ltype, "Float") {
|
||||
add_float_name(name)
|
||||
}
|
||||
@@ -1705,9 +1725,13 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
|
||||
} else {
|
||||
let c_msg = "EL_STR_PTR(" + cg_expr(msg_node) + ")"
|
||||
}
|
||||
// Assertions record into PER-TEST state, not global counters. The test
|
||||
// is the unit of result; a global pass/fail tally cannot say which test
|
||||
// failed or whether a test ran at all. Reporting is the runner's job —
|
||||
// nothing is printed here.
|
||||
emit_line(indent + "if (!(" + c_cond + ")) {")
|
||||
emit_line(indent + " __el_test_fail(__el_cur_test, " + c_msg + "); __el_fail++;")
|
||||
emit_line(indent + "} else { __el_pass++; }")
|
||||
emit_line(indent + " __el_test_fail(" + c_msg + ");")
|
||||
emit_line(indent + "} else { __el_cur_asserts++; }")
|
||||
return declared
|
||||
}
|
||||
|
||||
@@ -2602,6 +2626,17 @@ fn builtin_arity(name: String) -> Int {
|
||||
// LSP seed primitives
|
||||
if str_eq(name, "__read_n") { return 1 }
|
||||
if str_eq(name, "__print_raw") { return 1 }
|
||||
// Test-registry accessors. These are not runtime builtins — they are
|
||||
// GENERATED into the same translation unit by the --test path below, one
|
||||
// set per test binary. They are declared here so the El-side runner in
|
||||
// runtime/eltest.el can call them with a known arity.
|
||||
if str_eq(name, "__el_reg_count") { return 0 }
|
||||
if str_eq(name, "__el_reg_name") { return 1 }
|
||||
if str_eq(name, "__el_reg_invoke") { return 1 }
|
||||
if str_eq(name, "__el_reg_last_ns") { return 0 }
|
||||
if str_eq(name, "__el_reg_msg") { return 0 }
|
||||
if str_eq(name, "__el_reg_asserts") { return 0 }
|
||||
if str_eq(name, "__el_opt_json") { return 0 }
|
||||
// String
|
||||
if str_eq(name, "el_str_concat") { return 2 }
|
||||
if str_eq(name, "str_eq") { return 2 }
|
||||
@@ -2760,12 +2795,22 @@ fn builtin_arity(name: String) -> Int {
|
||||
if str_eq(name, "__engram_neighbors_filtered") { return 3 }
|
||||
if str_eq(name, "__engram_activate") { return 2 }
|
||||
if str_eq(name, "__engram_activate_json") { return 2 }
|
||||
if str_eq(name, "__engram_op_assert_json") { return 2 }
|
||||
if str_eq(name, "__engram_node_full_in") { return 9 }
|
||||
if str_eq(name, "__engram_connect_in") { return 5 }
|
||||
if str_eq(name, "__engram_scan_nodes_json") { return 2 }
|
||||
if str_eq(name, "__engram_edges_json") { return 2 }
|
||||
if str_eq(name, "__engram_pool_stats_json") { 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_peak_rss") { return 0 }
|
||||
if str_eq(name, "__generate") { return 1 }
|
||||
// Filesystem
|
||||
if str_eq(name, "fs_read") { return 1 }
|
||||
if str_eq(name, "fs_write") { return 2 }
|
||||
if str_eq(name, "fs_list") { return 1 }
|
||||
if str_eq(name, "fs_size") { return 1 }
|
||||
if str_eq(name, "fs_read_b64_chunk") { return 3 }
|
||||
// JSON
|
||||
if str_eq(name, "json_get") { return 2 }
|
||||
if str_eq(name, "json_parse") { return 1 }
|
||||
@@ -2857,9 +2902,18 @@ fn builtin_arity(name: String) -> Int {
|
||||
if str_eq(name, "engram_get_node_by_label") { return 1 }
|
||||
if str_eq(name, "engram_search_json") { return 2 }
|
||||
if str_eq(name, "engram_scan_nodes_json") { return 2 }
|
||||
if str_eq(name, "engram_edges_json") { return 2 }
|
||||
if str_eq(name, "engram_pool_stats_json") { 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_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_activate_json") { return 2 }
|
||||
if str_eq(name, "engram_stats_json") { return 0 }
|
||||
if str_eq(name, "engram_op_assert_json") { return 2 }
|
||||
if str_eq(name, "engram_node_full_in") { return 9 }
|
||||
if str_eq(name, "engram_connect_in") { return 5 }
|
||||
// LLM
|
||||
if str_eq(name, "llm_call") { return 2 }
|
||||
if str_eq(name, "llm_call_system") { return 3 }
|
||||
@@ -3079,6 +3133,15 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
|
||||
if str_eq(ptype, "Int") {
|
||||
add_int_name(pname)
|
||||
}
|
||||
// Bool is an integer in the value model (type_to_c maps Bool -> "int";
|
||||
// el_runtime.h: "Bool -> el_val_t (0 = false, nonzero = true)"), but
|
||||
// Bool names were registered nowhere. So `cond == want` between two
|
||||
// Bool params fell through to str_eq and dereferenced 0 or 1 as a
|
||||
// char* — an immediate segfault. Track them as int-like, which is what
|
||||
// they are.
|
||||
if str_eq(ptype, "Bool") {
|
||||
add_int_name(pname)
|
||||
}
|
||||
if str_eq(ptype, "Float") {
|
||||
add_float_name(pname)
|
||||
}
|
||||
@@ -3202,6 +3265,7 @@ fn is_top_level_decl(stmt: Map<String, Any>) -> Bool {
|
||||
if kind == "EnumDef" { return true }
|
||||
if kind == "Import" { return true }
|
||||
if kind == "CgiBlock" { return true }
|
||||
if kind == "ProgramBlock" { return true }
|
||||
if kind == "ExternFn" { return true }
|
||||
false
|
||||
}
|
||||
@@ -3214,6 +3278,55 @@ fn cgi_arg(value: String, has_value: Bool) -> String {
|
||||
return "EL_NULL"
|
||||
}
|
||||
|
||||
// -- Program block: cross-cutting concerns injected at the process boundary ----
|
||||
//
|
||||
// emit_program_init — emit the `static void __el_program_init(void)` that
|
||||
// carries a program's declared cross-cutting concerns. Called from main()
|
||||
// BEFORE any user statement runs, so the guarantees hold for the whole process
|
||||
// rather than depending on each call site remembering to ask for them.
|
||||
//
|
||||
// This is emitted at the point the `program` block is encountered, not buffered
|
||||
// until main(). The streaming backend emits in source order and cannot hold a
|
||||
// declaration's entry list alive until main(); emitting a named function here
|
||||
// and calling it from main() means only a single bool has to survive.
|
||||
//
|
||||
// Order matters and is deliberate:
|
||||
// 1. singleton FIRST — if another instance already holds the lock, refuse and
|
||||
// exit before touching configuration, ports, or any data directory.
|
||||
// 2. config declarations — resolve env-or-default, one declaration per entry.
|
||||
// 3. validate LAST — report EVERY missing/ill-typed entry at once, then exit.
|
||||
fn el_bool_arg(b: Bool) -> String {
|
||||
if b { return "EL_INT(1)" }
|
||||
return "EL_INT(0)"
|
||||
}
|
||||
|
||||
fn emit_program_init(stmt: Map<String, Any>) -> Void {
|
||||
let pname: String = stmt["name"]
|
||||
emit_line("static void __el_program_init(void) {")
|
||||
let has_singleton: Bool = stmt["has_singleton"]
|
||||
if has_singleton {
|
||||
let sid: String = stmt["singleton"]
|
||||
emit_line(" el_singleton_acquire(EL_STR(" + c_str_lit(sid) + "));")
|
||||
}
|
||||
let entries = stmt["entries"]
|
||||
let n: Int = native_list_len(entries)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let e = native_list_get(entries, i)
|
||||
let ename: String = e["name"]
|
||||
let etype: String = e["etype"]
|
||||
let edefault: String = e["default"]
|
||||
let has_default: Bool = e["has_default"]
|
||||
let erequired: Bool = e["required"]
|
||||
let arg_def: String = cgi_arg(edefault, has_default)
|
||||
emit_line(" el_config_declare(EL_STR(" + c_str_lit(ename) + "), EL_STR(" + c_str_lit(etype) + "), " + arg_def + ", " + el_bool_arg(has_default) + ", " + el_bool_arg(erequired) + ");")
|
||||
let i = i + 1
|
||||
}
|
||||
emit_line(" el_config_validate(EL_STR(" + c_str_lit(pname) + "));")
|
||||
emit_line("}")
|
||||
emit_blank()
|
||||
}
|
||||
|
||||
// -- VBD role enforcement ------------------------------------------------------
|
||||
//
|
||||
// Scan a function body for direct calls to DHARMA-restricted builtins
|
||||
@@ -3536,6 +3649,20 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Program block: emit the cross-cutting init function before the user's
|
||||
// functions so main() can call it (see emit_program_init).
|
||||
let prog_have: Bool = false
|
||||
let i = 0
|
||||
while i < n {
|
||||
let stmt = native_list_get(stmts, i)
|
||||
let sk4: String = stmt["stmt"]
|
||||
if str_eq(sk4, "ProgramBlock") {
|
||||
emit_program_init(stmt)
|
||||
let prog_have = true
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// Function definitions
|
||||
let i = 0
|
||||
while i < n {
|
||||
@@ -3554,6 +3681,9 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
|
||||
// with the C-side parameters when fn main()'s body is folded in below.
|
||||
emit_line("int main(int _argc, char** _argv) {")
|
||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||
if prog_have {
|
||||
emit_line(" __el_program_init();")
|
||||
}
|
||||
if cgi_count >= 1 {
|
||||
let cname: String = cgi_block["name"]
|
||||
let cdid: String = cgi_block["dharma_id"]
|
||||
@@ -4098,13 +4228,36 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
// Emit test harness preamble (counters, fail printer) when in test mode.
|
||||
if test_is_mode {
|
||||
emit_line("#include <stdio.h>")
|
||||
emit_line("#include <string.h>")
|
||||
emit_line("#include <time.h>")
|
||||
emit_blank()
|
||||
emit_line("static int __el_pass = 0, __el_fail = 0;")
|
||||
// Per-test result state. Reset by __el_reg_invoke before each test, so
|
||||
// every test gets its own record rather than contributing to a global
|
||||
// tally. The first failure message is retained; later ones only bump
|
||||
// the count, which keeps the common case allocation-free.
|
||||
emit_line("static int __el_cur_fails = 0;")
|
||||
emit_line("static int __el_cur_asserts = 0;")
|
||||
emit_line("static char __el_cur_msg[512] = \"\";")
|
||||
emit_line("static const char *__el_cur_test = \"(none)\";")
|
||||
emit_line("static void __el_test_fail(const char *test, const char *msg) {")
|
||||
emit_line(" fprintf(stderr, \"FAIL %-40s %s\\n\", test, msg);")
|
||||
emit_line("static void __el_test_fail(const char *msg) {")
|
||||
emit_line(" if (__el_cur_fails == 0 && msg) {")
|
||||
emit_line(" snprintf(__el_cur_msg, sizeof __el_cur_msg, \"%s\", msg);")
|
||||
emit_line(" }")
|
||||
emit_line(" __el_cur_fails++; __el_cur_asserts++;")
|
||||
emit_line("}")
|
||||
emit_blank()
|
||||
// Forward declarations for the registry accessors. The definitions are
|
||||
// emitted at the END of the unit (they reference the test functions,
|
||||
// which do not exist yet at this point), but the El-side runner is
|
||||
// compiled in between and calls them — so it needs the prototypes here.
|
||||
emit_line("el_val_t __el_reg_count(void);")
|
||||
emit_line("el_val_t __el_reg_name(el_val_t i);")
|
||||
emit_line("el_val_t __el_reg_invoke(el_val_t i);")
|
||||
emit_line("el_val_t __el_reg_last_ns(void);")
|
||||
emit_line("el_val_t __el_reg_msg(void);")
|
||||
emit_line("el_val_t __el_reg_asserts(void);")
|
||||
emit_line("el_val_t __el_opt_json(void);")
|
||||
emit_blank()
|
||||
}
|
||||
|
||||
// Streaming parse-emit loop.
|
||||
@@ -4124,6 +4277,7 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
// Fix: copy the values out BEFORE the release (strings, so no dangling reference)
|
||||
// and emit from these. No search, so the failure mode is removed rather than moved.
|
||||
let cgi_have: Bool = false
|
||||
let prog_have: Bool = false
|
||||
let cgi_name_v: String = ""
|
||||
let cgi_did_v: String = ""
|
||||
let cgi_prin_v: String = ""
|
||||
@@ -4245,6 +4399,14 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
// These are no-ops in codegen (forward decls already emitted)
|
||||
// — except a CgiBlock, whose declared identity must survive
|
||||
// this release to be emitted as a compiled constant.
|
||||
// A ProgramBlock's cross-cutting declarations are
|
||||
// emitted HERE, as a named init function, because the
|
||||
// streaming backend cannot hold the entry list alive
|
||||
// until main(). Only the bool survives.
|
||||
if str_eq(sk, "ProgramBlock") {
|
||||
emit_program_init(stmt)
|
||||
let prog_have = true
|
||||
}
|
||||
if str_eq(sk, "CgiBlock") {
|
||||
let cgi_have = true
|
||||
let cgi_name_v = stmt["name"]
|
||||
@@ -4300,17 +4462,72 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
el_release(sigs)
|
||||
|
||||
let test_arena_mark: Any = el_arena_push()
|
||||
let tn: Int = native_list_len(test_c_names)
|
||||
|
||||
// ── Generated test registry ──────────────────────────────────────────
|
||||
// Discovery happens HERE, at compile time. The runner never searches
|
||||
// for tests; it walks this table. That ordering — discovery strictly
|
||||
// before execution — is what makes --list, filtering, sharding and
|
||||
// per-test reporting possible later, and it is why the old harness
|
||||
// (which inlined direct calls into main) could not have any of them.
|
||||
emit_line("typedef void (*__el_test_fp)(void);")
|
||||
emit_line("typedef struct { const char *name; __el_test_fp fn; } __el_test_entry;")
|
||||
emit_line("static const __el_test_entry __el_registry[] = {")
|
||||
let ri: Int = 0
|
||||
while ri < tn {
|
||||
let r_name: String = native_list_get(test_names, ri)
|
||||
let r_cfn: String = native_list_get(test_c_names, ri)
|
||||
emit_line(" { \"" + c_escape(r_name) + "\", " + r_cfn + " },")
|
||||
let ri = ri + 1
|
||||
}
|
||||
// Trailing sentinel keeps the array non-empty when a file declares no
|
||||
// tests (a zero-length array is not valid C).
|
||||
emit_line(" { 0, 0 }")
|
||||
emit_line("};")
|
||||
emit_line("static const int __el_registry_n = " + int_to_str(tn) + ";")
|
||||
emit_blank()
|
||||
emit_line("static long long __el_last_ns = 0;")
|
||||
emit_line("static int __el_opt_json_v = 0;")
|
||||
emit_blank()
|
||||
|
||||
// ── Index-based accessors ────────────────────────────────────────────
|
||||
// El has no function pointers, so the runner works purely in indices.
|
||||
// This is the whole seam between generated C and the El-side runner.
|
||||
emit_line("el_val_t __el_reg_count(void) { return (el_val_t)(int64_t)__el_registry_n; }")
|
||||
emit_line("el_val_t __el_reg_name(el_val_t i) {")
|
||||
emit_line(" int64_t k = (int64_t)i;")
|
||||
emit_line(" if (k < 0 || k >= __el_registry_n) return EL_STR(\"\");")
|
||||
emit_line(" return EL_STR(__el_registry[k].name);")
|
||||
emit_line("}")
|
||||
// Timing is taken immediately around the call, in C, on the MONOTONIC
|
||||
// clock — never the wall clock, which can step backwards under NTP.
|
||||
emit_line("el_val_t __el_reg_invoke(el_val_t i) {")
|
||||
emit_line(" int64_t k = (int64_t)i;")
|
||||
emit_line(" if (k < 0 || k >= __el_registry_n) return 0;")
|
||||
emit_line(" __el_cur_fails = 0; __el_cur_asserts = 0; __el_cur_msg[0] = '\\0';")
|
||||
emit_line(" __el_cur_test = __el_registry[k].name;")
|
||||
emit_line(" struct timespec _t0, _t1;")
|
||||
emit_line(" clock_gettime(CLOCK_MONOTONIC, &_t0);")
|
||||
emit_line(" __el_registry[k].fn();")
|
||||
emit_line(" clock_gettime(CLOCK_MONOTONIC, &_t1);")
|
||||
emit_line(" __el_last_ns = (long long)(_t1.tv_sec - _t0.tv_sec) * 1000000000LL")
|
||||
emit_line(" + (long long)(_t1.tv_nsec - _t0.tv_nsec);")
|
||||
emit_line(" return (el_val_t)(int64_t)__el_cur_fails;")
|
||||
emit_line("}")
|
||||
emit_line("el_val_t __el_reg_last_ns(void) { return (el_val_t)(int64_t)__el_last_ns; }")
|
||||
emit_line("el_val_t __el_reg_msg(void) { return EL_STR(__el_cur_msg); }")
|
||||
emit_line("el_val_t __el_reg_asserts(void) { return (el_val_t)(int64_t)__el_cur_asserts; }")
|
||||
emit_line("el_val_t __el_opt_json(void) { return (el_val_t)(int64_t)__el_opt_json_v; }")
|
||||
emit_blank()
|
||||
|
||||
// main() delegates to the El-side runner. Everything above this line is
|
||||
// generated glue; all reporting logic lives in runtime/eltest.el.
|
||||
emit_line("int main(int _argc, char **_argv) {")
|
||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||
let ti: Int = 0
|
||||
let tn: Int = native_list_len(test_c_names)
|
||||
while ti < tn {
|
||||
let tc_name: String = native_list_get(test_c_names, ti)
|
||||
emit_line(" " + tc_name + "();")
|
||||
let ti = ti + 1
|
||||
}
|
||||
emit_line(" printf(\"%d passed, %d failed\\n\", __el_pass, __el_fail);")
|
||||
emit_line(" return __el_fail;")
|
||||
emit_line(" for (int _i = 1; _i < _argc; _i++) {")
|
||||
emit_line(" if (strcmp(_argv[_i], \"--json\") == 0) __el_opt_json_v = 1;")
|
||||
emit_line(" }")
|
||||
emit_line(" return (int)(int64_t)el_test_main();")
|
||||
emit_line("}")
|
||||
el_arena_pop(test_arena_mark)
|
||||
el_release(test_names)
|
||||
@@ -4336,6 +4553,13 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
let kind2: String = state_get("__program_kind")
|
||||
emit_line("int main(int _argc, char** _argv) {")
|
||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||
// Cross-cutting concerns declared by a `program` block run BEFORE anything
|
||||
// else — a singleton violation must refuse the start before this process
|
||||
// touches a port or a data directory, and configuration must be validated
|
||||
// before the first read of it rather than at each read site.
|
||||
if prog_have {
|
||||
emit_line(" __el_program_init();")
|
||||
}
|
||||
|
||||
// cgi init if needed
|
||||
let ns2: Int = native_list_len(sigs)
|
||||
|
||||
@@ -419,6 +419,22 @@ fn resolve_imports(src_path: String) -> String {
|
||||
if !str_eq(already, "") { return "" }
|
||||
state_set(seen_key, "1")
|
||||
|
||||
// A missing file must be a hard error, never an empty string.
|
||||
//
|
||||
// fs_read returns "" both for "file is empty" and "file does not exist", and
|
||||
// this function used the value without distinguishing them. So a broken
|
||||
// import path — a typo, a moved file, a relative path resolved from the
|
||||
// wrong working directory — compiled CLEANLY: exit 0, empty stderr, and a
|
||||
// program silently missing everything it imported. Observed 2026-08-15:
|
||||
// eleven consecutive "successful" compiles that had included no runtime at
|
||||
// all, and a wrong conclusion drawn from them before anyone noticed.
|
||||
//
|
||||
// Missing dependency, confident success. fs_exists separates the two cases,
|
||||
// so a genuinely empty file still resolves to "" and is fine.
|
||||
if !fs_exists(src_path) {
|
||||
println("elc: cannot resolve import: " + src_path)
|
||||
exit_program(1)
|
||||
}
|
||||
let source: String = fs_read(src_path)
|
||||
let dir: String = dirname_of(src_path)
|
||||
let lines: [String] = str_split(source, "\n")
|
||||
|
||||
@@ -184,6 +184,7 @@ fn keyword_kind(word: String) -> String {
|
||||
if word == "false" { return "Bool" }
|
||||
if word == "cgi" { return "Cgi" }
|
||||
if word == "service" { return "Service" }
|
||||
if word == "program" { return "Program" }
|
||||
if word == "manager" { return "Manager" }
|
||||
if word == "engine" { return "Engine" }
|
||||
if word == "accessor" { return "Accessor" }
|
||||
|
||||
@@ -1967,6 +1967,113 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
}, p)
|
||||
}
|
||||
|
||||
// program block: program "name" { singleton: "id", env NAME: Type = "default", ... }
|
||||
//
|
||||
// The program block is El's declaration surface for CROSS-CUTTING CONCERNS —
|
||||
// properties of the whole process rather than of any one function, which
|
||||
// otherwise degrade into "remember to call this at every site" conventions.
|
||||
//
|
||||
// singleton: "id" — process identity. The runtime takes an exclusive
|
||||
// lock at startup; a SECOND start is refused, loudly,
|
||||
// instead of two processes sharing one data dir.
|
||||
// env NAME: T = "d" — one configuration entry. Its type and its default
|
||||
// are declared ONCE, here, and resolved+validated
|
||||
// before main() body runs.
|
||||
// env NAME: T required
|
||||
// — no default; the program refuses to start unless the
|
||||
// variable is set.
|
||||
//
|
||||
// Both compile into calls injected at the head of main() — the same boundary
|
||||
// seam `cgi` already uses (codegen.el emit_program_init). No call site in the
|
||||
// program body has to remember anything, which is the whole point.
|
||||
if k == "Program" {
|
||||
let p = pos + 1
|
||||
let name = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let p = expect(tokens, p, "LBrace")
|
||||
let singleton = ""
|
||||
let has_singleton = false
|
||||
let entries = native_list_empty()
|
||||
// Entry-scratch declared at loop-body level (not inside the branch) so
|
||||
// that inner `let` forms compile to assignment rather than a C-scoped
|
||||
// redeclaration — the same idiom the service block above relies on.
|
||||
let ename = ""
|
||||
let etype = ""
|
||||
let edefault = ""
|
||||
let has_default = false
|
||||
let erequired = false
|
||||
let fname = ""
|
||||
let fval = ""
|
||||
let running = true
|
||||
while running {
|
||||
let k2 = tok_kind(tokens, p)
|
||||
if k2 == "RBrace" {
|
||||
let running = false
|
||||
} else {
|
||||
if k2 == "Eof" {
|
||||
let running = false
|
||||
} else {
|
||||
let fname = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "env") {
|
||||
// env NAME: Type [= "default"] [required]
|
||||
let ename = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let etype = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let edefault = ""
|
||||
let has_default = false
|
||||
let erequired = false
|
||||
let k3 = tok_kind(tokens, p)
|
||||
if str_eq(k3, "Eq") {
|
||||
let p = p + 1
|
||||
let edefault = tok_value(tokens, p)
|
||||
let has_default = true
|
||||
let p = p + 1
|
||||
}
|
||||
let k4 = tok_kind(tokens, p)
|
||||
if str_eq(k4, "Ident") {
|
||||
let w = tok_value(tokens, p)
|
||||
if str_eq(w, "required") {
|
||||
let erequired = true
|
||||
let p = p + 1
|
||||
}
|
||||
}
|
||||
let entries = native_list_append(entries, {
|
||||
"name": ename,
|
||||
"etype": etype,
|
||||
"default": edefault,
|
||||
"has_default": has_default,
|
||||
"required": erequired
|
||||
})
|
||||
} else {
|
||||
// scalar field: `name: "value"`
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let fval = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "singleton") {
|
||||
let singleton = fval
|
||||
let has_singleton = true
|
||||
}
|
||||
}
|
||||
let k5 = tok_kind(tokens, p)
|
||||
if k5 == "Comma" {
|
||||
let p = p + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = expect(tokens, p, "RBrace")
|
||||
return make_result({
|
||||
"stmt": "ProgramBlock",
|
||||
"name": name,
|
||||
"singleton": singleton,
|
||||
"has_singleton": has_singleton,
|
||||
"entries": entries
|
||||
}, p)
|
||||
}
|
||||
|
||||
// assert <cond_expr> [ , <msg_expr> ]
|
||||
// The message is optional — if the next token after the condition is not a
|
||||
// Comma, emit an empty string placeholder so the test still works.
|
||||
@@ -2419,6 +2526,7 @@ fn scan_params_c(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
// toplevel_let: { "kind": "toplevel_let", "name": String, "ltype": String }
|
||||
// cgi_block: { "kind": "cgi_block", "name": String }
|
||||
// service_block: { "kind": "service_block", "name": String }
|
||||
// program_block: { "kind": "program_block", "name": String }
|
||||
//
|
||||
// Import/TypeDef/EnumDef nodes are skipped (codegen treats them as no-ops).
|
||||
//
|
||||
@@ -2546,13 +2654,28 @@ fn scan_fn_sigs(tokens: [Any]) -> [Map<String, Any>] {
|
||||
"name": name
|
||||
})
|
||||
let pos = p
|
||||
} else {
|
||||
// --- program block ---
|
||||
if str_eq(k, "Program") {
|
||||
let p: Int = pos + 1
|
||||
let name: String = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let k2: String = tok_kind(tokens, p)
|
||||
if str_eq(k2, "LBrace") {
|
||||
let p = skip_to_rbrace(tokens, p)
|
||||
}
|
||||
let sigs = native_list_append(sigs, {
|
||||
"kind": "program_block",
|
||||
"name": name
|
||||
})
|
||||
let pos = p
|
||||
} else {
|
||||
// Import, Type, Enum, From, or any other token.
|
||||
// Skip ahead to the next statement boundary.
|
||||
let p: Int = pos + 1
|
||||
let p = skip_expr_to_stmt_boundary(tokens, p)
|
||||
let pos = p
|
||||
}}}}}
|
||||
}}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// transduce.el — geometry as a first-class El value, and a realizer written
|
||||
// in El. Runnable: this is the worked example for the transduce surface, and
|
||||
// it doubles as an executable proof because it checks every claim it makes.
|
||||
//
|
||||
// elc lang/examples/transduce.el > transduce.c
|
||||
// cc -std=c11 -O2 -I lang/runtime -o transduce transduce.c \
|
||||
// lang/runtime/el_runtime.c lang/runtime/el_seed.c \
|
||||
// lang/runtime/engram_*.c -lcurl -lpthread -lm
|
||||
// ./transduce # exits 0 only if every check passes
|
||||
//
|
||||
// (A `test "..."` form of the same checks lives in
|
||||
// lang/tests/native/test_transduce.el, for when the native harness is
|
||||
// repaired — the shipped elc currently emits calls to __el_reg_count and
|
||||
// friends without emitting their definitions, which breaks every native test
|
||||
// equally, test_math.el included. Verified 2026-08-16, unrelated to this work.)
|
||||
//
|
||||
// WHY THIS EXISTS. Until 2026-08-16 no El ingest path could carry a vector:
|
||||
// nodes took text, and geometry was DERIVED from that text. Text was the
|
||||
// mandatory entry medium, so any non-text modality had to be DESCRIBED in
|
||||
// prose first and the geometry we reasoned over was the geometry OF THE
|
||||
// DESCRIPTION, not of the signal. Two things fix that, and both are shown
|
||||
// below: geometry is a VALUE that carries its own width, and a REALIZER is an
|
||||
// ordinary El function — so admitting a new modality never requires a runtime
|
||||
// patch.
|
||||
//
|
||||
// COMPARISON DISCIPLINE (measured, not stylistic): elc lowers `a == b`
|
||||
// numerically only when both operand NAMES are in the per-function int-name
|
||||
// set that `let x: Int` populates. A bare `f(x) == 0` is not a registered
|
||||
// name and lowers to str_eq — strcmp on two integers as pointers. `<` and `>`
|
||||
// lower directly with no inference, so truthiness is written `> 0` / `< 1`.
|
||||
|
||||
// ── A realizer, written entirely in El ──────────────────────────────────────
|
||||
// Not in the runtime. Not known to the compiler. Registered by NAME and
|
||||
// dispatched to through transduce(). That is the whole claim.
|
||||
fn tone_realizer(signal: String) -> Geometry {
|
||||
let g: Geometry = geometry_new(4)
|
||||
let n: Int = str_len(signal)
|
||||
let a: Int = geometry_set(g, 0, int_to_float(n))
|
||||
let b: Int = geometry_set(g, 1, int_to_float(n * 2))
|
||||
let c: Int = geometry_set(g, 2, int_to_float(n * 3))
|
||||
let d: Int = geometry_set(g, 3, int_to_float(n * 4))
|
||||
g
|
||||
}
|
||||
|
||||
// A second modality, to show the registry keys on modality rather than just
|
||||
// returning whatever was registered last.
|
||||
fn pulse_realizer(signal: String) -> Geometry {
|
||||
let g: Geometry = geometry_new(2)
|
||||
let a: Int = geometry_set(g, 0, 1.0)
|
||||
let b: Int = geometry_set(g, 1, 0.0)
|
||||
g
|
||||
}
|
||||
|
||||
// A deliberately BROKEN realizer: returns something that is not a Geometry.
|
||||
fn bogus_realizer(signal: String) -> Geometry {
|
||||
return 12345
|
||||
}
|
||||
|
||||
// Fails FAST rather than accumulating a count, for a measured reason: a first
|
||||
// cut wrote `let fails: Int = fails + check(...)` and `+` lowered to STRING
|
||||
// CONCAT, because elc dispatches `+` on whether both operands are known-Int and
|
||||
// a user-defined fn call is not — so the counter printed 4343632752, a pointer.
|
||||
// Nothing was wrong with the checks; the tally was lying. Exiting at the first
|
||||
// failure needs no arithmetic at all, so there is nothing left to get wrong.
|
||||
fn check(ok: Int, label: String) -> Int {
|
||||
if ok > 0 {
|
||||
println(" ok " + label)
|
||||
return 0
|
||||
}
|
||||
println(" FAIL " + label)
|
||||
exit(1)
|
||||
return 1
|
||||
}
|
||||
|
||||
fn near(a: Float, b: Float) -> Int {
|
||||
let d: Float = a - b
|
||||
if d > 0.001 { return 0 }
|
||||
if d < -0.001 { return 0 }
|
||||
return 1
|
||||
}
|
||||
|
||||
fn eq_int(a: Int, b: Int) -> Int {
|
||||
if a == b { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
fn main() -> Void {
|
||||
println("geometry is a value that carries its own width")
|
||||
let g8: Geometry = geometry_new(8)
|
||||
let _c: Int = check(geometry_is(g8), "geometry_new returns a live Geometry")
|
||||
let d8: Int = geometry_dim(g8)
|
||||
let _c: Int = check(eq_int(d8, 8), "a Geometry carries its own width (8)")
|
||||
let _c: Int = check(geometry_free(g8), "geometry_free reports what it did")
|
||||
|
||||
println("nonsense is refused — with no arbitrary max-dim bound")
|
||||
// #141 needed `dim <= 8192` only to bound an allocation sized from a
|
||||
// caller's CLAIM about a string's length. A value that carries its own
|
||||
// width has nothing left to validate.
|
||||
let z: Geometry = geometry_new(0)
|
||||
let zi: Int = geometry_is(z)
|
||||
let _c: Int = check(1 - zi, "dim 0 is not a geometry")
|
||||
let ng: Geometry = geometry_new(-4)
|
||||
let ngi: Int = geometry_is(ng)
|
||||
let _c: Int = check(1 - ngi, "negative dim is not a geometry")
|
||||
let nd: Int = geometry_dim(0)
|
||||
let _c: Int = check(1 - nd, "geometry_dim of a non-geometry is 0, not a crash")
|
||||
let nf: Int = geometry_free(0)
|
||||
let _c: Int = check(1 - nf, "geometry_free of a non-geometry is a no-op")
|
||||
|
||||
println("components round-trip, and out-of-range is refused")
|
||||
let g3: Geometry = geometry_new(3)
|
||||
let s0: Int = geometry_set(g3, 0, 1.5)
|
||||
let s1: Int = geometry_set(g3, 1, -2.5)
|
||||
let _c: Int = check(s0, "set in range succeeds")
|
||||
let oob: Int = geometry_set(g3, 3, 9.0)
|
||||
let _c: Int = check(1 - oob, "set out of range is refused, not silently dropped")
|
||||
let _c: Int = check(near(geometry_get(g3, 0), 1.5), "component 0 round-trips")
|
||||
let _c: Int = check(near(geometry_get(g3, 1), -2.5), "component 1 round-trips (negative)")
|
||||
let ff3: Int = geometry_free(g3)
|
||||
|
||||
println("hex is an EDGE adapter, and derives its own width")
|
||||
// little-endian float32: 1.0 = 0000803f, 2.0 = 00000040
|
||||
let gh: Geometry = geometry_from_f32le_hex("0000803f00000040")
|
||||
let _c: Int = check(geometry_is(gh), "valid hex decodes to a Geometry")
|
||||
let dh: Int = geometry_dim(gh)
|
||||
let _c: Int = check(eq_int(dh, 2), "width DERIVED from input, never supplied")
|
||||
let _c: Int = check(near(geometry_get(gh, 0), 1.0), "first component decoded")
|
||||
let _c: Int = check(near(geometry_get(gh, 1), 2.0), "second component decoded")
|
||||
let back: String = geometry_to_f32le_hex(gh)
|
||||
let _c: Int = check(str_eq(back, "0000803f00000040"), "hex round-trips exactly")
|
||||
let ffh: Int = geometry_free(gh)
|
||||
|
||||
println("malformed hex is refused")
|
||||
let he: Geometry = geometry_from_f32le_hex("")
|
||||
let hei: Int = geometry_is(he)
|
||||
let _c: Int = check(1 - hei, "empty hex is not a geometry")
|
||||
let hr: Geometry = geometry_from_f32le_hex("0000803f0000")
|
||||
let hri: Int = geometry_is(hr)
|
||||
let _c: Int = check(1 - hri, "length not a multiple of 8 is refused")
|
||||
let hn: Geometry = geometry_from_f32le_hex("zzzzzzzz")
|
||||
let hni: Int = geometry_is(hn)
|
||||
let _c: Int = check(1 - hni, "non-hex characters are refused")
|
||||
|
||||
println("a realizer declared in El is a first-class realizer")
|
||||
let reg: Int = realizer_register("tone", "tone_realizer")
|
||||
let _c: Int = check(reg, "an El fn registers as a realizer BY NAME")
|
||||
let _c: Int = check(realizer_has("tone"), "the modality now has an organ")
|
||||
let gt: Geometry = transduce("aaa", "tone")
|
||||
let _c: Int = check(geometry_is(gt), "transduce returns real geometry")
|
||||
let dt: Int = geometry_dim(gt)
|
||||
let _c: Int = check(eq_int(dt, 4), "the El realizer determined the width, not the runtime")
|
||||
// str_len("aaa") == 3, so component 0 must be 3.0 — proof the signal
|
||||
// actually reached the El function rather than a stub answering for it.
|
||||
let _c: Int = check(near(geometry_get(gt, 0), 3.0), "the signal REACHED the El realizer")
|
||||
let fft: Int = geometry_free(gt)
|
||||
|
||||
println("distinct signals transduce to distinct geometry")
|
||||
let g1: Geometry = transduce("aa", "tone")
|
||||
let g2: Geometry = transduce("aaaaa", "tone")
|
||||
let a1: Float = geometry_get(g1, 0)
|
||||
let a2: Float = geometry_get(g2, 0)
|
||||
// 5 - 2 = 3. If transduction were a stub these would be equal.
|
||||
let _c: Int = check(near(a2 - a1, 3.0), "different signals produce different geometry")
|
||||
let ff1: Int = geometry_free(g1)
|
||||
let ff2: Int = geometry_free(g2)
|
||||
|
||||
println("the registry keys on modality")
|
||||
let r2: Int = realizer_register("pulse", "pulse_realizer")
|
||||
let _c: Int = check(r2, "a second modality registers independently")
|
||||
let mt: Geometry = transduce("aaa", "tone")
|
||||
let mp: Geometry = transduce("aaa", "pulse")
|
||||
let mdt: Int = geometry_dim(mt)
|
||||
let mdp: Int = geometry_dim(mp)
|
||||
let _c: Int = check(eq_int(mdt, 4), "tone still routes to its own realizer")
|
||||
let _c: Int = check(eq_int(mdp, 2), "pulse routes to a different realizer")
|
||||
let ffm1: Int = geometry_free(mt)
|
||||
let ffm2: Int = geometry_free(mp)
|
||||
|
||||
println("no organ is reported as no organ")
|
||||
// A modality with no realizer must transduce to NOTHING. It must never
|
||||
// fall back to embedding a description of the signal and calling that
|
||||
// perception — that silent substitution is the defect this all exists to end.
|
||||
let eh: Int = realizer_has("echolocation")
|
||||
let _c: Int = check(1 - eh, "unregistered modality has no organ")
|
||||
let ge: Geometry = transduce("anything", "echolocation")
|
||||
let gei: Int = geometry_is(ge)
|
||||
let _c: Int = check(1 - gei, "no realizer means NO geometry, not fake geometry")
|
||||
|
||||
println("an unresolvable realizer name fails at WIRING time")
|
||||
let bad: Int = realizer_register("ghost", "no_such_function_anywhere")
|
||||
let _c: Int = check(1 - bad, "unresolvable realizer name is a registration failure")
|
||||
let gh2: Int = realizer_has("ghost")
|
||||
let _c: Int = check(1 - gh2, "and nothing gets registered")
|
||||
|
||||
println("a realizer returning non-geometry transduces nothing")
|
||||
let rb: Int = realizer_register("bogus", "bogus_realizer")
|
||||
let _c: Int = check(rb, "the symbol resolves, so registration succeeds")
|
||||
let gb: Geometry = transduce("x", "bogus")
|
||||
let gbi: Int = geometry_is(gb)
|
||||
let _c: Int = check(1 - gbi, "contract enforced at the boundary: nothing handed back")
|
||||
|
||||
println("norm lets a caller check a realizer emitted signal, not zeros")
|
||||
let gn: Geometry = geometry_new(2)
|
||||
let _c: Int = check(near(geometry_norm(gn), 0.0), "a fresh geometry is zero — norm says so")
|
||||
let n0: Int = geometry_set(gn, 0, 3.0)
|
||||
let n1: Int = geometry_set(gn, 1, 4.0)
|
||||
let _c: Int = check(near(geometry_norm(gn), 5.0), "3-4-5: norm is 5")
|
||||
let ffn: Int = geometry_free(gn)
|
||||
|
||||
// Reaching here means nothing called exit(1) along the way.
|
||||
println("")
|
||||
println("all checks passed")
|
||||
}
|
||||
+2545
-129
File diff suppressed because it is too large
Load Diff
+259
-1
@@ -235,6 +235,20 @@ el_val_t fs_list(el_val_t path);
|
||||
el_val_t fs_exists(el_val_t path);
|
||||
el_val_t fs_mkdir(el_val_t path); /* mkdir -p, mode 0755 */
|
||||
|
||||
/* Real on-disk byte count via stat() — not strlen(). Use this (not
|
||||
* str_len(fs_read(path))) when a file may contain binary content, since
|
||||
* fs_read()'s result truncates at the first embedded NUL under strlen-based
|
||||
* string ops. Returns -1 if the path doesn't exist. */
|
||||
el_val_t fs_size(el_val_t path);
|
||||
|
||||
/* Binary-safe windowed read: read up to `length` bytes starting at byte
|
||||
* `offset` from `path` and return them base64-encoded. Bytes are read and
|
||||
* encoded in C without ever passing through an el_val_t string as raw
|
||||
* bytes, so embedded NULs (routine in PCM audio) can't truncate the result.
|
||||
* Returns "" on any failure or when offset is past EOF; a final short
|
||||
* window returns only the bytes that actually exist on disk. */
|
||||
el_val_t fs_read_b64_chunk(el_val_t path, el_val_t offset, el_val_t length);
|
||||
|
||||
/* Length-explicit binary write. `length` is an Int (el_val_t holding the
|
||||
* byte count). The caller knows the length from context — typically because
|
||||
* `bytes` came from base64_decode (which produces a magic-tagged binary
|
||||
@@ -261,6 +275,10 @@ el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
|
||||
el_val_t json_array_len(el_val_t json_str);
|
||||
el_val_t json_array_get(el_val_t json_str, el_val_t index);
|
||||
el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
|
||||
el_val_t json_escape_string(el_val_t sv);
|
||||
el_val_t json_build_object(el_val_t kvs);
|
||||
el_val_t json_build_array(el_val_t items);
|
||||
el_val_t json_array_push(el_val_t arr_v, el_val_t elem_v); /* defined in el_runtime.c */
|
||||
|
||||
/* ── Time ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -287,6 +305,8 @@ el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit);
|
||||
|
||||
el_val_t el_now_instant(void);
|
||||
el_val_t now(void);
|
||||
el_val_t now_millis(void); /* wall-clock milliseconds (defined in el_runtime.c) */
|
||||
el_val_t now_ns(void); /* wall-clock nanoseconds (defined in el_runtime.c) */
|
||||
el_val_t unix_seconds(el_val_t n);
|
||||
el_val_t unix_millis(el_val_t n);
|
||||
el_val_t instant_from_iso8601(el_val_t s);
|
||||
@@ -566,6 +586,60 @@ void el_runtime_dharma_event_arrive(const char* event_type,
|
||||
const char* payload,
|
||||
const char* source);
|
||||
|
||||
/* ── Geometry: signal as a first-class El value ──────────────────────────────
|
||||
*
|
||||
* A Geometry is an opaque, magic-tagged heap value carried in an el_val_t —
|
||||
* the same discipline as List/Map. It holds a width and a float32 payload,
|
||||
* and it is the medium a non-text modality enters in. Declared HERE, above
|
||||
* the engram block, because transduction is a LANGUAGE concern: every El
|
||||
* program touching any modality needs it, and the engram is merely one El
|
||||
* program that happens to hold a graph. See el_runtime.c ("Geometry: signal
|
||||
* as a first-class el value") for the full rationale.
|
||||
*
|
||||
* El-side type annotation is simply `Geometry` — an opaque boxed pointer,
|
||||
* exactly like Instant / Calendar / Rhythm. No codegen change is required.
|
||||
*
|
||||
* OWNERSHIP: a Geometry is owned by the El caller and released with
|
||||
* geometry_free. node_attach_geometry COPIES, so a node and the caller's
|
||||
* value have independent lifetimes. */
|
||||
|
||||
el_val_t geometry_new(el_val_t dim); /* zero-filled; 0 on failure */
|
||||
el_val_t geometry_dim(el_val_t g); /* width, 0 if not a Geometry */
|
||||
el_val_t geometry_is(el_val_t g); /* 1 if a live Geometry */
|
||||
el_val_t geometry_get(el_val_t g, el_val_t i); /* Float component */
|
||||
el_val_t geometry_set(el_val_t g, el_val_t i, el_val_t x); /* 1 ok / 0 out of range */
|
||||
el_val_t geometry_norm(el_val_t g); /* Float L2 — lets a caller
|
||||
* check a realizer emitted
|
||||
* signal, not zeros */
|
||||
el_val_t geometry_free(el_val_t g); /* 1 if freed, 0 if not a Geometry.
|
||||
* Returns a value (not void) so it
|
||||
* is safe in any El expression
|
||||
* position without a codegen
|
||||
* void-builtin table entry. */
|
||||
|
||||
/* Wire ADAPTERS — the only place an encoding appears, and only at the edge.
|
||||
* `f32le hex` is little-endian float32, 8 hex chars per component: the
|
||||
* encoding the perception vessel's /voice/embed already emits. The width is
|
||||
* DERIVED from the input length, never supplied by a caller — which is why
|
||||
* there is no max-dim constant here to validate a claimed length against. */
|
||||
el_val_t geometry_from_f32le_hex(el_val_t hex); /* 0 on empty/odd-length/non-hex */
|
||||
el_val_t geometry_to_f32le_hex(el_val_t g); /* "" if not a Geometry */
|
||||
|
||||
/* ── Realizers + transduce ───────────────────────────────────────────────────
|
||||
* A REALIZER maps one modality into geometry. Registration is by NAME, so a
|
||||
* new modality never requires a runtime patch: every El `fn name(...)`
|
||||
* compiles to a global C symbol with that exact name, and the registry
|
||||
* resolves it with dlsym against the running binary — the same mechanism
|
||||
* http_set_handler already relies on.
|
||||
*
|
||||
* fn tone_realizer(signal: String) -> Geometry { ... }
|
||||
* realizer_register("tone", "tone_realizer")
|
||||
* let g: Geometry = transduce(sample, "tone")
|
||||
*/
|
||||
el_val_t realizer_register(el_val_t modality, el_val_t fn_name); /* 1 ok / 0 unresolved */
|
||||
el_val_t realizer_has(el_val_t modality); /* 1 if a realizer is registered */
|
||||
el_val_t transduce(el_val_t signal, el_val_t modality); /* Geometry, or 0 if no organ */
|
||||
|
||||
/* ── Engram local graph primitives ───────────────────────────────────────────
|
||||
* Operate on the CGI's local Engram knowledge graph.
|
||||
* `engram_activate` queries the local graph only; `dharma_activate` is
|
||||
@@ -592,7 +666,28 @@ el_val_t engram_get_node(el_val_t id);
|
||||
void engram_strengthen(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);
|
||||
/* 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);
|
||||
/* Attach a Geometry to an existing node, and read the attached width back.
|
||||
* Named for the operation, not the store: a node acquires geometry. This is
|
||||
* the geometry-valued ingest path — nothing about it is hex, and nothing
|
||||
* about it assumes the caller's vector matches the canonical text-embedding
|
||||
* width. node_geometry_dim exists so an attach is VERIFIED by reading it
|
||||
* back rather than by trusting a success return. */
|
||||
el_val_t node_attach_geometry(el_val_t node_id, el_val_t g); /* 1 ok / 0 otherwise */
|
||||
el_val_t node_geometry_dim(el_val_t node_id); /* width, 0 if none */
|
||||
|
||||
/* DEPRECATED (shipped in #141, superseded 2026-08-16). Equivalent to
|
||||
* geometry_from_f32le_hex + node_attach_geometry, and now implemented as
|
||||
* exactly that. Kept only so anything built against the #141 runtime keeps
|
||||
* linking; `dim` is accepted but treated as an assertion about the vector's
|
||||
* width rather than as its source. New code should not call this — a hex
|
||||
* string is a wire encoding, not a way to move geometry between two pieces
|
||||
* of El. 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_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);
|
||||
@@ -637,8 +732,13 @@ el_val_t engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds);
|
||||
el_val_t engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds);
|
||||
/* COGNITION (2026-08-14): THE ONE OPERATION + grounding, surfaced live. */
|
||||
el_val_t engram_think_json(el_val_t seeds, el_val_t faculty);
|
||||
/* GROUNDING (2026-08-16): grounding is an attribute of the RELATION and it IS the
|
||||
* hebbian weight. ground reads; ground_record writes; trajectory reads the chain. */
|
||||
el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom);
|
||||
el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor);
|
||||
el_val_t engram_ground_record_json(el_val_t claim, el_val_t evidence,
|
||||
el_val_t provenance, el_val_t floor);
|
||||
el_val_t engram_ground_trajectory_json(el_val_t claim, el_val_t evidence);
|
||||
el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor, el_val_t rel_floor);
|
||||
el_val_t engram_attend_json(el_val_t node_id, el_val_t observer, el_val_t salience);
|
||||
el_val_t engram_correspondence_beat_json(el_val_t seeds, el_val_t faculty, el_val_t keystone);
|
||||
el_val_t engram_consolidate_permanence(el_val_t node_id);
|
||||
@@ -698,6 +798,14 @@ el_val_t engram_label_df(el_val_t term);
|
||||
el_val_t engram_salient_term(el_val_t node_id, el_val_t max_df,
|
||||
el_val_t min_df, el_val_t tabu);
|
||||
el_val_t engram_embed_backfill(el_val_t count);
|
||||
/* op_assert seam: grounded assertion envelope {subject,grounding} for the realizer. */
|
||||
el_val_t engram_op_assert_json(el_val_t node_id, el_val_t depth);
|
||||
/* Parametric mutation (purview write-side): purview==0 => G=live (default), else refuse. */
|
||||
el_val_t engram_node_full_in(el_val_t purview, el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t importance, el_val_t confidence,
|
||||
el_val_t tier, el_val_t tags);
|
||||
void engram_connect_in(el_val_t purview, el_val_t from_id, el_val_t to_id,
|
||||
el_val_t weight, el_val_t relation);
|
||||
el_val_t engram_list_layers_json(void);
|
||||
/* Working memory introspection — count, mean weight, and top-N snapshot.
|
||||
* Ported from runtime on 2026-06-30 self-review. */
|
||||
@@ -878,6 +986,156 @@ el_val_t trace_span_start(el_val_t name);
|
||||
el_val_t trace_span_end(el_val_t span_handle);
|
||||
el_val_t emit_event(el_val_t name, el_val_t duration_ms);
|
||||
|
||||
el_val_t __thread_create(el_val_t fn_name_v, el_val_t arg_v);
|
||||
el_val_t __thread_join(el_val_t tid_v);
|
||||
|
||||
/* Mutex + channel seed primitives (defined in el_runtime.c). Declared here so
|
||||
* that compiled El programs which use runtime/thread.el's with_mutex helper or
|
||||
* runtime/channel.el's Go-style channels see real prototypes instead of an
|
||||
* implicit int-return declaration (which the C11 ABI mis-truncates el_val_t). */
|
||||
el_val_t __mutex_new(void);
|
||||
void __mutex_lock(el_val_t m_v);
|
||||
void __mutex_unlock(el_val_t m_v);
|
||||
el_val_t __channel_new(el_val_t capacity_v);
|
||||
el_val_t __channel_send(el_val_t ch_v, el_val_t msg_v);
|
||||
el_val_t __channel_recv(el_val_t ch_v);
|
||||
el_val_t __channel_try_recv(el_val_t ch_v);
|
||||
el_val_t __channel_close(el_val_t ch_v);
|
||||
|
||||
/* ── __ prefixed aliases (self-hosting compiler ABI) ─────────────────────────
|
||||
* The El self-hosting compiler emits calls to __-prefixed names. These are
|
||||
* forwarding wrappers around the existing el_runtime functions above. */
|
||||
|
||||
/* I/O */
|
||||
el_val_t __println(el_val_t s);
|
||||
el_val_t __print(el_val_t s);
|
||||
el_val_t __readline(void);
|
||||
|
||||
/* String */
|
||||
el_val_t __int_to_str(el_val_t n);
|
||||
el_val_t __str_to_int(el_val_t s);
|
||||
el_val_t __float_to_str(el_val_t f);
|
||||
el_val_t __str_to_float(el_val_t s);
|
||||
el_val_t __str_len(el_val_t s);
|
||||
el_val_t __str_char_at(el_val_t s, el_val_t i);
|
||||
el_val_t __str_cmp(el_val_t a, el_val_t b);
|
||||
el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n);
|
||||
el_val_t __str_concat_raw(el_val_t a, el_val_t b);
|
||||
el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end);
|
||||
el_val_t __str_alloc(el_val_t n);
|
||||
el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c);
|
||||
|
||||
/* URL encoding */
|
||||
el_val_t __url_encode(el_val_t s);
|
||||
el_val_t __url_decode(el_val_t s);
|
||||
|
||||
/* Environment */
|
||||
el_val_t __env_get(el_val_t key);
|
||||
|
||||
/* Cross-cutting concerns declared by a `program` block (spec §18).
|
||||
* All three are COMPILER-INJECTED at the head of main() — they are not meant to
|
||||
* be written by hand, which is the point: the guarantee cannot be forgotten at a
|
||||
* call site because there is no call site. */
|
||||
el_val_t el_singleton_acquire(el_val_t id); /* §18.1 process identity */
|
||||
el_val_t el_config_declare(el_val_t name, el_val_t type,
|
||||
el_val_t deflt, el_val_t has_default,
|
||||
el_val_t required); /* §18.2 config schema */
|
||||
el_val_t el_config_validate(el_val_t program_name); /* §18.2 startup validate */
|
||||
|
||||
/* config(key) — the READ side, and the only one programs write by hand. With a
|
||||
* schema declared it is a validated lookup; without one it degrades to getenv.
|
||||
* (Defined in el_runtime.c but previously never prototyped here, so any program
|
||||
* calling it failed to compile under -Werror=implicit-function-declaration.) */
|
||||
el_val_t config(el_val_t key);
|
||||
|
||||
/* Subprocess */
|
||||
el_val_t __exec(el_val_t cmd);
|
||||
el_val_t __exec_bg(el_val_t cmd);
|
||||
|
||||
/* Process */
|
||||
el_val_t __exit_program(el_val_t code);
|
||||
|
||||
/* Filesystem */
|
||||
el_val_t __fs_exists(el_val_t path);
|
||||
el_val_t __fs_mkdir(el_val_t path);
|
||||
el_val_t __fs_read(el_val_t path);
|
||||
el_val_t __fs_write(el_val_t path, el_val_t content);
|
||||
el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n);
|
||||
el_val_t __fs_list_raw(el_val_t path);
|
||||
|
||||
/* HTTP server */
|
||||
el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body);
|
||||
el_val_t __http_serve(el_val_t port, el_val_t handler);
|
||||
el_val_t __http_serve_v2(el_val_t port, el_val_t handler);
|
||||
|
||||
/* HTTP conn fd / SSE (weak; overridden by el_seed.c when linked together) */
|
||||
el_val_t __http_conn_fd(void);
|
||||
el_val_t __http_sse_open(el_val_t conn_id);
|
||||
el_val_t __http_sse_send(el_val_t conn_id, el_val_t data);
|
||||
el_val_t __http_sse_close(el_val_t conn_id);
|
||||
|
||||
/* HTTP client (requires HAVE_CURL; stubs provided for no-curl builds) */
|
||||
el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_map, el_val_t timeout_ms);
|
||||
el_val_t __http_do_map(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t timeout_ms);
|
||||
el_val_t __http_do_map_to_file(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t output_path);
|
||||
|
||||
/* JSON */
|
||||
el_val_t __json_array_get(el_val_t json, el_val_t index);
|
||||
el_val_t __json_array_get_string(el_val_t json, el_val_t index);
|
||||
el_val_t __json_array_len(el_val_t json);
|
||||
el_val_t __json_get(el_val_t json, el_val_t key);
|
||||
el_val_t __json_get_raw(el_val_t json, el_val_t key);
|
||||
el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value);
|
||||
el_val_t __json_parse_map(el_val_t json_str);
|
||||
el_val_t __json_stringify_val(el_val_t val);
|
||||
|
||||
/* Hashing */
|
||||
el_val_t __sha256_hex(el_val_t s);
|
||||
|
||||
/* State K/V */
|
||||
el_val_t __state_del(el_val_t key);
|
||||
el_val_t __state_get(el_val_t key);
|
||||
el_val_t __state_keys(void);
|
||||
el_val_t __state_set(el_val_t key, el_val_t val);
|
||||
|
||||
/* UUID */
|
||||
el_val_t __uuid_v4(void);
|
||||
|
||||
/* Args */
|
||||
el_val_t __args_json(void);
|
||||
|
||||
/* Compiler-support builtins — called by the El compiler's own source
|
||||
* (compiler.el, codegen.el) and registered in codegen.el's builtin_arity. */
|
||||
el_val_t stdout_to_file(el_val_t path);
|
||||
el_val_t stdout_restore(void);
|
||||
el_val_t el_mem_check(void);
|
||||
|
||||
/* Allocation accounting — the deterministic signal behind complexity gating.
|
||||
* Gate on counts/bytes; peak RSS is context only. */
|
||||
el_val_t el_alloc_count(void);
|
||||
el_val_t el_alloc_bytes(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,
|
||||
* which is lexical by design — see the note at the definition. */
|
||||
el_val_t engram_recall_json(el_val_t query, el_val_t limit);
|
||||
|
||||
/* Edges straight from the store — replaces the engram_save()+fs_read()
|
||||
* whole-graph round trip that /api/graph/edges used to do. */
|
||||
el_val_t engram_edges_json(el_val_t limit, el_val_t offset);
|
||||
|
||||
/* Buffer-pool interoception as JSON — live pool health for observation. */
|
||||
el_val_t engram_pool_stats_json(void);
|
||||
|
||||
/* CGI identity accessors (read-only). */
|
||||
el_val_t cgi_principal(void);
|
||||
el_val_t cgi_network(void);
|
||||
el_val_t cgi_engram(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -37,6 +37,82 @@
|
||||
#include <dlfcn.h>
|
||||
#include <curl/curl.h>
|
||||
|
||||
/* el_runtime.c bridge prototypes.
|
||||
*
|
||||
* A block of __-prefixed wrappers further down in this file (http serving,
|
||||
* JSON access, key-val state, URL/HTML escaping, and the whole engram_*
|
||||
* node/edge/layer/search surface -- 51 symbols in total) delegate to
|
||||
* unprefixed counterparts that are implemented in el_runtime.c, not here.
|
||||
* Porting them into native el_seed.c or El has not happened yet.
|
||||
* tools/install.sh compiles el_seed.c and el_runtime.c as separate objects
|
||||
* and archives both into libel.a, so the symbols are always present at link
|
||||
* time. el_seed.c alone was just missing the prototypes, which made even a
|
||||
* standalone -c compile of this one file fail on a toolchain that now treats
|
||||
* an implicit function declaration as a hard error under C11.
|
||||
*
|
||||
* A plain include of el_runtime.h was tried first and rejected: it redefines
|
||||
* el_to_float and el_from_float, which el_seed.h already provides. Narrow
|
||||
* prototypes, copied verbatim from el_runtime.h, avoid that collision without
|
||||
* pulling in the rest of the retiring runtime header.
|
||||
*/
|
||||
el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
|
||||
void http_serve(el_val_t port, el_val_t handler);
|
||||
void http_serve_v2(el_val_t port, el_val_t handler);
|
||||
el_val_t json_get(el_val_t json, el_val_t key);
|
||||
el_val_t json_get_string(el_val_t json_str, el_val_t key);
|
||||
el_val_t json_get_int(el_val_t json_str, el_val_t key);
|
||||
el_val_t json_get_float(el_val_t json_str, el_val_t key);
|
||||
el_val_t json_get_bool(el_val_t json_str, el_val_t key);
|
||||
el_val_t json_get_raw(el_val_t json_str, el_val_t key);
|
||||
el_val_t json_parse(el_val_t s);
|
||||
el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
|
||||
el_val_t json_stringify(el_val_t v);
|
||||
el_val_t json_array_len(el_val_t json_str);
|
||||
el_val_t json_array_get(el_val_t json_str, el_val_t index);
|
||||
el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
|
||||
el_val_t state_set(el_val_t key, el_val_t value);
|
||||
el_val_t state_get(el_val_t key);
|
||||
el_val_t state_del(el_val_t key);
|
||||
el_val_t state_keys(void);
|
||||
el_val_t url_encode(el_val_t s);
|
||||
el_val_t url_decode(el_val_t s);
|
||||
el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
|
||||
el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience);
|
||||
el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t importance, el_val_t confidence,
|
||||
el_val_t tier, el_val_t tags);
|
||||
el_val_t engram_node_layered(el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t certainty, el_val_t confidence,
|
||||
el_val_t status, el_val_t tags, el_val_t layer_id);
|
||||
el_val_t engram_add_layer(el_val_t name, el_val_t priority, el_val_t suppressible,
|
||||
el_val_t transparent, el_val_t injectable);
|
||||
el_val_t engram_remove_layer(el_val_t layer_id);
|
||||
el_val_t engram_list_layers(void);
|
||||
el_val_t engram_list_layers_json(void);
|
||||
el_val_t engram_get_node(el_val_t id);
|
||||
el_val_t engram_get_node_json(el_val_t id);
|
||||
el_val_t engram_get_node_by_label(el_val_t label);
|
||||
void engram_strengthen(el_val_t node_id);
|
||||
void engram_forget(el_val_t node_id);
|
||||
el_val_t engram_node_count(void);
|
||||
el_val_t engram_edge_count(void);
|
||||
el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_search(el_val_t query, el_val_t limit);
|
||||
el_val_t engram_search_json(el_val_t query, el_val_t limit);
|
||||
el_val_t engram_activate(el_val_t query, el_val_t depth);
|
||||
el_val_t engram_activate_json(el_val_t query, el_val_t depth);
|
||||
el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth);
|
||||
el_val_t engram_stats_json(void);
|
||||
void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
|
||||
el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id);
|
||||
el_val_t engram_neighbors(el_val_t node_id);
|
||||
el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction);
|
||||
el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
|
||||
el_val_t engram_load(el_val_t path);
|
||||
el_val_t engram_save(el_val_t path);
|
||||
|
||||
/* ── Private allocator ───────────────────────────────────────────────────── */
|
||||
/*
|
||||
* el_seed.c carries its own arena for per-request allocation tracking.
|
||||
@@ -72,10 +148,17 @@ static void seed_request_start(void) {
|
||||
_seed_arena_on = 1;
|
||||
}
|
||||
|
||||
/* Defined in el_runtime.c. The string-length cache there keys on pointer +
|
||||
* generation; anything that frees or mutates a runtime string must bump the
|
||||
* generation or a reused address could return a stale length. Weak so this
|
||||
* file still links on its own. */
|
||||
__attribute__((weak)) void el_str_cache_flush(void);
|
||||
|
||||
static void seed_request_end(void) {
|
||||
_seed_arena_on = 0;
|
||||
for (size_t i = 0; i < _seed_arena.count; i++) free(_seed_arena.ptrs[i]);
|
||||
_seed_arena.count = 0;
|
||||
if (el_str_cache_flush) el_str_cache_flush(); /* freed pointers may be reused */
|
||||
}
|
||||
|
||||
/* el_request_start / el_request_end — formerly defined in el_runtime.c.
|
||||
@@ -137,6 +220,7 @@ el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
|
||||
int64_t idx = (int64_t)i;
|
||||
if (idx < 0 || idx >= len) return s;
|
||||
p[idx] = (char)(unsigned char)(int64_t)c;
|
||||
if (el_str_cache_flush) el_str_cache_flush(); /* in-place write can move the NUL */
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -831,6 +915,219 @@ void __mutex_unlock(el_val_t m) {
|
||||
pthread_mutex_unlock(&_el_mutexes[slot]);
|
||||
}
|
||||
|
||||
/* ── Channels ─────────────────────────────────────────────────────────────── *
|
||||
* Buffered MPMC channel backed by a mutex + condvar + circular buffer.
|
||||
* Ported from the pre-restructure el_runtime.c (b2aac4b) — runtime/channel.el
|
||||
* has always called these five primitives, but they were never carried
|
||||
* forward into el_seed.c when el_runtime.c was consolidated onto the
|
||||
* canonical release copy. Native channels were silently unlinkable on dev
|
||||
* until this port.
|
||||
*
|
||||
* __channel_new(capacity) -> Int (handle)
|
||||
* __channel_send(ch, msg) — blocks if full (capacity > 0) or never (unbounded)
|
||||
* __channel_recv(ch) -> String — blocks until a message is available
|
||||
* __channel_try_recv(ch) -> String — non-blocking, returns "" if empty
|
||||
* __channel_close(ch) — signal no more sends; recv drains remaining
|
||||
*
|
||||
* Bounded channels (cap > 0): circular buffer, sender blocks when full.
|
||||
* Unbounded channels (cap == 0): dynamic array, sender never blocks.
|
||||
*/
|
||||
#define EL_CHANNEL_MAX 64
|
||||
#define EL_CHANNEL_BUF 1024
|
||||
|
||||
typedef struct {
|
||||
char** buf;
|
||||
int cap; /* 0 = unbounded (grows dynamically) */
|
||||
int head, tail, count;
|
||||
int dyn_cap; /* allocated slots for unbounded mode */
|
||||
int closed;
|
||||
pthread_mutex_t mu;
|
||||
pthread_cond_t not_empty;
|
||||
pthread_cond_t not_full;
|
||||
} ElChannel;
|
||||
|
||||
static ElChannel _channels[EL_CHANNEL_MAX];
|
||||
static int _channel_count = 0;
|
||||
static pthread_mutex_t _channel_alloc_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
el_val_t __channel_new(el_val_t capacity_v) {
|
||||
int cap = (int)(int64_t)capacity_v;
|
||||
if (cap < 0) cap = 0;
|
||||
|
||||
pthread_mutex_lock(&_channel_alloc_mu);
|
||||
if (_channel_count >= EL_CHANNEL_MAX) {
|
||||
pthread_mutex_unlock(&_channel_alloc_mu);
|
||||
fprintf(stderr, "[__channel_new] channel table full\n");
|
||||
return EL_INT(-1);
|
||||
}
|
||||
int slot = _channel_count++;
|
||||
pthread_mutex_unlock(&_channel_alloc_mu);
|
||||
|
||||
ElChannel* ch = &_channels[slot];
|
||||
memset(ch, 0, sizeof(*ch));
|
||||
ch->cap = cap;
|
||||
ch->closed = 0;
|
||||
ch->head = 0;
|
||||
ch->tail = 0;
|
||||
ch->count = 0;
|
||||
|
||||
if (cap > 0) {
|
||||
/* Bounded: fixed circular buffer. */
|
||||
ch->buf = (char**)malloc((size_t)cap * sizeof(char*));
|
||||
ch->dyn_cap = cap;
|
||||
} else {
|
||||
/* Unbounded: start with EL_CHANNEL_BUF slots, grow as needed. */
|
||||
ch->buf = (char**)malloc(EL_CHANNEL_BUF * sizeof(char*));
|
||||
ch->dyn_cap = EL_CHANNEL_BUF;
|
||||
}
|
||||
if (!ch->buf) {
|
||||
fprintf(stderr, "[__channel_new] out of memory\n");
|
||||
return EL_INT(-1);
|
||||
}
|
||||
|
||||
pthread_mutex_init(&ch->mu, NULL);
|
||||
pthread_cond_init(&ch->not_empty, NULL);
|
||||
pthread_cond_init(&ch->not_full, NULL);
|
||||
|
||||
return EL_INT(slot);
|
||||
}
|
||||
|
||||
el_val_t __channel_send(el_val_t ch_v, el_val_t msg_v) {
|
||||
int slot = (int)(int64_t)ch_v;
|
||||
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
|
||||
ElChannel* ch = &_channels[slot];
|
||||
|
||||
const char* msg = EL_CSTR(msg_v);
|
||||
if (!msg) msg = "";
|
||||
char* copy = strdup(msg); /* channel owns the string */
|
||||
|
||||
pthread_mutex_lock(&ch->mu);
|
||||
|
||||
if (ch->closed) {
|
||||
/* Send on closed channel is a no-op (drop the message). */
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
free(copy);
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
if (ch->cap > 0) {
|
||||
/* Bounded: block while full. */
|
||||
while (ch->count >= ch->cap && !ch->closed) {
|
||||
pthread_cond_wait(&ch->not_full, &ch->mu);
|
||||
}
|
||||
if (ch->closed) {
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
free(copy);
|
||||
return EL_STR("");
|
||||
}
|
||||
ch->buf[ch->tail] = copy;
|
||||
ch->tail = (ch->tail + 1) % ch->cap;
|
||||
ch->count++;
|
||||
} else {
|
||||
/* Unbounded: grow the buffer if needed. */
|
||||
if (ch->count >= ch->dyn_cap) {
|
||||
int new_cap = ch->dyn_cap * 2;
|
||||
char** grown = (char**)realloc(ch->buf, (size_t)new_cap * sizeof(char*));
|
||||
if (!grown) {
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
free(copy);
|
||||
fprintf(stderr, "[__channel_send] out of memory growing channel\n");
|
||||
return EL_STR("");
|
||||
}
|
||||
/* The circular buffer may have wrapped. Linearise it first.
|
||||
* In unbounded mode head is always 0 (we append at tail, drain
|
||||
* from head), so a simple memmove isn't needed — but if the
|
||||
* buffer did wrap (tail < head after growth), we need to fix up.
|
||||
* Simplest safe path: if tail wrapped, move the head..old_cap
|
||||
* segment to new_cap..new_cap+(old_cap-head). */
|
||||
if (ch->tail < ch->head) {
|
||||
/* Wrapped: [head..old_cap) is the front, [0..tail) is the back. */
|
||||
int front = ch->dyn_cap - ch->head;
|
||||
memmove(grown + ch->dyn_cap, grown + ch->head, (size_t)front * sizeof(char*));
|
||||
ch->head = ch->dyn_cap;
|
||||
}
|
||||
ch->buf = grown;
|
||||
ch->dyn_cap = new_cap;
|
||||
}
|
||||
ch->buf[ch->tail] = copy;
|
||||
ch->tail = (ch->tail + 1) % ch->dyn_cap;
|
||||
ch->count++;
|
||||
}
|
||||
|
||||
pthread_cond_signal(&ch->not_empty);
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
el_val_t __channel_recv(el_val_t ch_v) {
|
||||
int slot = (int)(int64_t)ch_v;
|
||||
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
|
||||
ElChannel* ch = &_channels[slot];
|
||||
|
||||
pthread_mutex_lock(&ch->mu);
|
||||
|
||||
/* Block until there is a message or the channel is closed and drained. */
|
||||
while (ch->count == 0 && !ch->closed) {
|
||||
pthread_cond_wait(&ch->not_empty, &ch->mu);
|
||||
}
|
||||
|
||||
if (ch->count == 0) {
|
||||
/* Closed and empty — signal EOF. */
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
int buf_cap = (ch->cap > 0) ? ch->cap : ch->dyn_cap;
|
||||
char* msg = ch->buf[ch->head];
|
||||
ch->head = (ch->head + 1) % buf_cap;
|
||||
ch->count--;
|
||||
|
||||
pthread_cond_signal(&ch->not_full);
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
|
||||
/* Hand the string to the arena so it is freed after the request. */
|
||||
seed_arena_track(msg);
|
||||
return EL_STR(msg);
|
||||
}
|
||||
|
||||
el_val_t __channel_try_recv(el_val_t ch_v) {
|
||||
int slot = (int)(int64_t)ch_v;
|
||||
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
|
||||
ElChannel* ch = &_channels[slot];
|
||||
|
||||
pthread_mutex_lock(&ch->mu);
|
||||
|
||||
if (ch->count == 0) {
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
int buf_cap = (ch->cap > 0) ? ch->cap : ch->dyn_cap;
|
||||
char* msg = ch->buf[ch->head];
|
||||
ch->head = (ch->head + 1) % buf_cap;
|
||||
ch->count--;
|
||||
|
||||
pthread_cond_signal(&ch->not_full);
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
|
||||
seed_arena_track(msg);
|
||||
return EL_STR(msg);
|
||||
}
|
||||
|
||||
el_val_t __channel_close(el_val_t ch_v) {
|
||||
int slot = (int)(int64_t)ch_v;
|
||||
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
|
||||
ElChannel* ch = &_channels[slot];
|
||||
|
||||
pthread_mutex_lock(&ch->mu);
|
||||
ch->closed = 1;
|
||||
/* Wake all blocked recvers and senders so they can observe the close. */
|
||||
pthread_cond_broadcast(&ch->not_empty);
|
||||
pthread_cond_broadcast(&ch->not_full);
|
||||
pthread_mutex_unlock(&ch->mu);
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
/* ── Subprocess ──────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t __exec(el_val_t cmd) {
|
||||
@@ -1082,6 +1379,21 @@ el_val_t __engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
|
||||
return engram_scan_nodes_json(limit, offset);
|
||||
}
|
||||
|
||||
el_val_t engram_edges_json(el_val_t limit, el_val_t offset);
|
||||
el_val_t __engram_edges_json(el_val_t limit, el_val_t offset) {
|
||||
return engram_edges_json(limit, offset);
|
||||
}
|
||||
|
||||
el_val_t engram_pool_stats_json(void);
|
||||
el_val_t __engram_pool_stats_json(void) { return engram_pool_stats_json(); }
|
||||
|
||||
el_val_t el_alloc_count(void);
|
||||
el_val_t el_alloc_bytes(void);
|
||||
el_val_t el_peak_rss(void);
|
||||
el_val_t __el_alloc_count(void) { return el_alloc_count(); }
|
||||
el_val_t __el_alloc_bytes(void) { return el_alloc_bytes(); }
|
||||
el_val_t __el_peak_rss(void) { return el_peak_rss(); }
|
||||
|
||||
el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset) {
|
||||
return engram_scan_nodes_by_type_json(node_type, limit, offset);
|
||||
}
|
||||
@@ -1094,7 +1406,27 @@ el_val_t __engram_activate_json(el_val_t query, el_val_t depth) {
|
||||
return engram_activate_json(query, depth);
|
||||
}
|
||||
|
||||
/* Forward decls for el_runtime.c symbols this file wraps. el_seed.c does not
|
||||
* include el_runtime.h (documented in lang/AGENTS.md), so each wrapped symbol
|
||||
* needs a prototype here or clang treats it as an implicit declaration (error
|
||||
* under C99+) and the ABI mis-truncates the el_val_t return. */
|
||||
el_val_t engram_op_assert_json(el_val_t node_id, el_val_t depth);
|
||||
el_val_t engram_node_full_in(el_val_t purview, el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t importance, el_val_t confidence,
|
||||
el_val_t tier, el_val_t tags);
|
||||
void engram_connect_in(el_val_t purview, el_val_t from_id, el_val_t to_id,
|
||||
el_val_t weight, el_val_t relation);
|
||||
|
||||
el_val_t __engram_stats_json(void) { return engram_stats_json(); }
|
||||
el_val_t __engram_op_assert_json(el_val_t node_id, el_val_t depth) { return engram_op_assert_json(node_id, depth); }
|
||||
el_val_t __engram_node_full_in(el_val_t purview, el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t importance, el_val_t confidence,
|
||||
el_val_t tier, el_val_t tags) {
|
||||
return engram_node_full_in(purview, content, node_type, label, salience, importance, confidence, tier, tags);
|
||||
}
|
||||
void __engram_connect_in(el_val_t purview, el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
|
||||
engram_connect_in(purview, from_id, to_id, weight, relation);
|
||||
}
|
||||
el_val_t __engram_list_layers_json(void) { return engram_list_layers_json(); }
|
||||
|
||||
el_val_t __engram_compile_layered_json(el_val_t intent, el_val_t depth) {
|
||||
|
||||
@@ -139,6 +139,13 @@ el_val_t __mutex_new(void);
|
||||
void __mutex_lock(el_val_t m);
|
||||
void __mutex_unlock(el_val_t m);
|
||||
|
||||
/* Buffered MPMC channel (runtime/channel.el). capacity=0 means unbounded. */
|
||||
el_val_t __channel_new(el_val_t capacity);
|
||||
el_val_t __channel_send(el_val_t ch, el_val_t msg); /* blocks if bounded+full */
|
||||
el_val_t __channel_recv(el_val_t ch); /* blocks until available */
|
||||
el_val_t __channel_try_recv(el_val_t ch); /* non-blocking, "" if empty */
|
||||
el_val_t __channel_close(el_val_t ch);
|
||||
|
||||
/* ── Subprocess ──────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t __exec(el_val_t cmd); /* popen, capture all stdout, return String */
|
||||
@@ -233,6 +240,12 @@ el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, e
|
||||
el_val_t __engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
|
||||
el_val_t __engram_activate_json(el_val_t query, el_val_t depth);
|
||||
el_val_t __engram_stats_json(void);
|
||||
el_val_t __engram_op_assert_json(el_val_t node_id, el_val_t depth);
|
||||
el_val_t __engram_node_full_in(el_val_t purview, el_val_t content, el_val_t node_type, el_val_t label,
|
||||
el_val_t salience, el_val_t importance, el_val_t confidence,
|
||||
el_val_t tier, el_val_t tags);
|
||||
void __engram_connect_in(el_val_t purview, el_val_t from_id, el_val_t to_id,
|
||||
el_val_t weight, el_val_t relation);
|
||||
el_val_t __engram_list_layers_json(void);
|
||||
el_val_t __engram_compile_layered_json(el_val_t intent, el_val_t depth);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// runtime/eltest.el — El test framework runner (Phase 1).
|
||||
//
|
||||
// This is the RUNNER. It is written in El and consumes a registry that the
|
||||
// compiler generates into the same translation unit when invoked as
|
||||
// `elc --test`. Nothing here discovers tests; discovery already happened at
|
||||
// compile time, which is what makes `--list` and filtering possible later.
|
||||
//
|
||||
// ── Architecture ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The compiler lowers each `test "name" { ... }` block into a static C
|
||||
// function and emits a static table of (name, fn) pairs plus a small set of
|
||||
// index-based accessors. El has no function pointers, so the runner never
|
||||
// sees one — it works entirely in indices:
|
||||
//
|
||||
// __el_reg_count() -> Int number of registered tests
|
||||
// __el_reg_name(i) -> String test name at index i
|
||||
// __el_reg_invoke(i) -> Int run test i, return its failure count
|
||||
// __el_reg_last_ns() -> Int wall-clock ns of the last invoke
|
||||
// __el_reg_msg() -> String first failure message of the last invoke
|
||||
// __el_reg_asserts() -> Int assertions executed in the last invoke
|
||||
// __el_opt_json() -> Int 1 if --json was passed
|
||||
//
|
||||
// Timing is taken in the generated C, immediately around the call, so no El
|
||||
// call overhead lands inside the measurement.
|
||||
//
|
||||
// ── Output ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Structured events are the source of truth. The human renderer is written
|
||||
// FROM the same fields the NDJSON renderer emits — never the reverse. Parsing
|
||||
// human output back into structure is the one clear architectural mistake in
|
||||
// Go's test tooling and we do not repeat it.
|
||||
//
|
||||
// Every result carries a duration. Always. A framework that cannot report how
|
||||
// long its tests took cannot surface a performance regression, and a
|
||||
// regression nobody can see is one nobody fixes.
|
||||
|
||||
// ── Small helpers (no imports — this file must stay self-contained) ──────────
|
||||
|
||||
// _elt_json_escape — minimal JSON string escaping for the NDJSON renderer.
|
||||
fn _elt_json_escape(s: String) -> String {
|
||||
let out: String = ""
|
||||
let n: Int = str_len(s)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let ch: String = str_slice(s, i, i + 1)
|
||||
if str_eq(ch, "\"") {
|
||||
let out = out + "\\\""
|
||||
} else {
|
||||
if str_eq(ch, "\\") {
|
||||
let out = out + "\\\\"
|
||||
} else {
|
||||
if str_eq(ch, "\n") {
|
||||
let out = out + "\\n"
|
||||
} else {
|
||||
if str_eq(ch, "\t") {
|
||||
let out = out + "\\t"
|
||||
} else {
|
||||
if str_eq(ch, "\r") {
|
||||
let out = out + "\\r"
|
||||
} else {
|
||||
let out = out + ch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// _elt_pad3 — left-pad an integer to three digits (for the ms.fraction form).
|
||||
fn _elt_pad3(v: Int) -> String {
|
||||
if v < 10 { return "00" + int_to_str(v) }
|
||||
if v < 100 { return "0" + int_to_str(v) }
|
||||
return int_to_str(v)
|
||||
}
|
||||
|
||||
// _elt_ms — render a nanosecond duration as "M.mmm" milliseconds.
|
||||
//
|
||||
// Deliberately avoids the modulo operator: the remainder is derived by
|
||||
// subtraction so this stays portable across El backends.
|
||||
fn _elt_ms(ns: Int) -> String {
|
||||
let total_us: Int = ns / 1000
|
||||
let ms_whole: Int = total_us / 1000
|
||||
let us_rem: Int = total_us - (ms_whole * 1000)
|
||||
return int_to_str(ms_whole) + "." + _elt_pad3(us_rem)
|
||||
}
|
||||
|
||||
// _elt_secs — render a nanosecond duration as fractional seconds, for the
|
||||
// NDJSON `elapsed` field. JUnit XML and test2json both use seconds-as-decimal.
|
||||
fn _elt_secs(ns: Int) -> String {
|
||||
let total_ms: Int = ns / 1000000
|
||||
let s_whole: Int = total_ms / 1000
|
||||
let ms_rem: Int = total_ms - (s_whole * 1000)
|
||||
return int_to_str(s_whole) + "." + _elt_pad3(ms_rem)
|
||||
}
|
||||
|
||||
// ── Event emission ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// One function per event shape. Both renderers read the same fields; the
|
||||
// human renderer is a projection of the event, not a separate code path.
|
||||
|
||||
fn _elt_emit_run(json_mode: Bool, name: String) {
|
||||
if json_mode {
|
||||
println("{\"action\":\"run\",\"test\":\"" + _elt_json_escape(name) + "\"}")
|
||||
}
|
||||
}
|
||||
|
||||
fn _elt_emit_result(json_mode: Bool, name: String, fails: Int, ns: Int, asserts: Int, msg: String) {
|
||||
if json_mode {
|
||||
let action: String = "pass"
|
||||
if fails > 0 { let action = "fail" }
|
||||
let line: String = "{\"action\":\"" + action + "\""
|
||||
let line = line + ",\"test\":\"" + _elt_json_escape(name) + "\""
|
||||
let line = line + ",\"elapsed\":" + _elt_secs(ns)
|
||||
let line = line + ",\"assertions\":" + int_to_str(asserts)
|
||||
if fails > 0 {
|
||||
let line = line + ",\"failures\":" + int_to_str(fails)
|
||||
let line = line + ",\"message\":\"" + _elt_json_escape(msg) + "\""
|
||||
}
|
||||
let line = line + "}"
|
||||
println(line)
|
||||
return
|
||||
}
|
||||
// Human renderer — duration is never optional.
|
||||
if fails > 0 {
|
||||
println("FAIL " + name + " (" + _elt_ms(ns) + "ms)")
|
||||
println(" " + msg)
|
||||
return
|
||||
}
|
||||
println("ok " + name + " (" + _elt_ms(ns) + "ms)")
|
||||
return
|
||||
}
|
||||
|
||||
fn _elt_emit_summary(json_mode: Bool, total: Int, failed: Int, ns: Int, asserts: Int) {
|
||||
let passed: Int = total - failed
|
||||
if json_mode {
|
||||
let line: String = "{\"action\":\"summary\""
|
||||
let line = line + ",\"tests\":" + int_to_str(total)
|
||||
let line = line + ",\"passed\":" + int_to_str(passed)
|
||||
let line = line + ",\"failed\":" + int_to_str(failed)
|
||||
let line = line + ",\"assertions\":" + int_to_str(asserts)
|
||||
let line = line + ",\"elapsed\":" + _elt_secs(ns)
|
||||
let line = line + "}"
|
||||
println(line)
|
||||
return
|
||||
}
|
||||
println("")
|
||||
println(int_to_str(total) + " tests, " + int_to_str(passed) + " passed, "
|
||||
+ int_to_str(failed) + " failed, " + int_to_str(asserts) + " assertions in "
|
||||
+ _elt_ms(ns) + "ms")
|
||||
return
|
||||
}
|
||||
|
||||
// ── The runner ───────────────────────────────────────────────────────────────
|
||||
|
||||
// el_test_main — drive the compile-time registry.
|
||||
//
|
||||
// Called from the generated main(). Returns the number of FAILING TESTS, which
|
||||
// becomes the process exit code. Note that this counts tests, not assertions:
|
||||
// a test is the unit of result. The old harness counted assertions globally and
|
||||
// therefore could not say which test failed, how long any of them took, or
|
||||
// whether a test had run at all.
|
||||
fn el_test_main() -> Int {
|
||||
let json_mode: Bool = false
|
||||
if __el_opt_json() == 1 { let json_mode = true }
|
||||
|
||||
let n: Int = __el_reg_count()
|
||||
let i: Int = 0
|
||||
let failed: Int = 0
|
||||
let total_ns: Int = 0
|
||||
let total_asserts: Int = 0
|
||||
|
||||
while i < n {
|
||||
let name: String = __el_reg_name(i)
|
||||
_elt_emit_run(json_mode, name)
|
||||
|
||||
let fails: Int = __el_reg_invoke(i)
|
||||
let ns: Int = __el_reg_last_ns()
|
||||
let asserts: Int = __el_reg_asserts()
|
||||
let msg: String = __el_reg_msg()
|
||||
|
||||
let total_ns = total_ns + ns
|
||||
let total_asserts = total_asserts + asserts
|
||||
if fails > 0 { let failed = failed + 1 }
|
||||
|
||||
_elt_emit_result(json_mode, name, fails, ns, asserts, msg)
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
_elt_emit_summary(json_mode, n, failed, total_ns, total_asserts)
|
||||
return failed
|
||||
}
|
||||
+372
-29
@@ -246,14 +246,6 @@ static int put_edge(EngramPagedStore* s, const char* id, const char* from, const
|
||||
e.metadata = (char*)meta;
|
||||
return store_put_edge(s, &e);
|
||||
}
|
||||
int cog_ground_edge(EngramPagedStore* s, const char* claim_id,
|
||||
const char* evidence_id, double grounding, const char* for_whom) {
|
||||
if (!s || !claim_id || !evidence_id) return -1;
|
||||
char id[512], meta[256];
|
||||
snprintf(id, sizeof id, "gb-%s-%s-%s", claim_id, evidence_id, for_whom ? for_whom : "global");
|
||||
snprintf(meta, sizeof meta, "for_whom=%s", for_whom ? for_whom : "-");
|
||||
return put_edge(s, id, claim_id, evidence_id, COG_GROUNDED_BY_RELATION, grounding, meta);
|
||||
}
|
||||
int cog_salient_edge(EngramPagedStore* s, const char* node_id,
|
||||
const char* observer_id, double salience) {
|
||||
if (!s || !node_id || !observer_id) return -1;
|
||||
@@ -261,35 +253,386 @@ int cog_salient_edge(EngramPagedStore* s, const char* node_id,
|
||||
snprintf(id, sizeof id, "st-%s-%s", node_id, observer_id);
|
||||
return put_edge(s, id, node_id, observer_id, COG_SALIENT_TO_RELATION, salience, NULL);
|
||||
}
|
||||
int cog_assert_gate(EngramPagedStore* s, const char* claim_id,
|
||||
const char* for_whom, double floor) {
|
||||
if (!s || !claim_id) return -1;
|
||||
if (!(floor > 0)) floor = 0.5;
|
||||
StoreEdge* edges = NULL; size_t n = 0;
|
||||
if (store_get_edges_from(s, claim_id, &edges, &n) < 0) return -1;
|
||||
double best = 0.0; int found = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (!edges[i].relation || strcmp(edges[i].relation, COG_GROUNDED_BY_RELATION) != 0) continue;
|
||||
/* grounded-for-whom: match observer if requested; global (for_whom=-) always counts */
|
||||
int match = 1;
|
||||
if (for_whom && edges[i].metadata) {
|
||||
const char* fw = strstr(edges[i].metadata, "for_whom=");
|
||||
if (fw) { fw += 9; if (strcmp(fw, for_whom) != 0 && strcmp(fw, "-") != 0) match = 0; }
|
||||
}
|
||||
if (match) { found = 1; if (edges[i].weight > best) best = edges[i].weight; }
|
||||
}
|
||||
store_edges_free(edges, n);
|
||||
if (!found) return 0; /* ungrounded => refuse assertion (still held) */
|
||||
return (best >= floor) ? 1 : 0;
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §7 GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR.
|
||||
* See engram_cognition.h §7 for the model and for the measurements the two
|
||||
* design decisions (thirteen regions, min aggregate) rest on.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── The one decay model. Moved here verbatim from el_runtime.c's
|
||||
* engram_temporal_decay so nodes and edges share a single implementation and a
|
||||
* single set of constants; engram_temporal_decay now delegates. Bit-identical
|
||||
* for nodes: reinforcements := activation_count, lambda_override :=
|
||||
* temporal_decay_rate.
|
||||
*
|
||||
* This is what makes decay ANALYTIC rather than sampled: between two recorded
|
||||
* versions the trajectory is not unknown, it is known in closed form from the
|
||||
* last point and elapsed time. Store the point, read the curve. */
|
||||
double cog_decay_factor(int64_t age_ms, double reinforcements, double lambda_override) {
|
||||
if (age_ms <= 0) return 1.0;
|
||||
double lambda = (lambda_override > 0.0) ? lambda_override : COG_DECAY_LAMBDA;
|
||||
double age_hours = (double)age_ms / 3600000.0;
|
||||
if (reinforcements < 0) reinforcements = 0;
|
||||
double t_half = COG_T_HALF_HOURS * (1.0 + log(1.0 + reinforcements));
|
||||
double factor = exp(-lambda * age_hours / t_half);
|
||||
if (factor < COG_DECAY_FLOOR) factor = COG_DECAY_FLOOR;
|
||||
return factor;
|
||||
}
|
||||
|
||||
const char* cog_prov_name(CogProvClass p) {
|
||||
switch (p) {
|
||||
case COG_PROV_OBSERVED: return "observed";
|
||||
case COG_PROV_INFERRED: return "inferred";
|
||||
case COG_PROV_TOLD: return "told";
|
||||
case COG_PROV_IMPRINTED: return "imprinted";
|
||||
default: return "unset";
|
||||
}
|
||||
}
|
||||
CogProvClass cog_prov_parse(const char* s) {
|
||||
if (!s) return COG_PROV_UNSET;
|
||||
if (!strcmp(s, "observed")) return COG_PROV_OBSERVED;
|
||||
if (!strcmp(s, "inferred")) return COG_PROV_INFERRED;
|
||||
if (!strcmp(s, "told")) return COG_PROV_TOLD;
|
||||
if (!strcmp(s, "imprinted")) return COG_PROV_IMPRINTED;
|
||||
return COG_PROV_UNSET;
|
||||
}
|
||||
|
||||
/* Locate the GRD1 block in an edge's metadata. It is always the tail; anything
|
||||
* ahead of it is the edge's pre-existing metadata, preserved verbatim. */
|
||||
static const char* cog_grd_find(const char* meta) {
|
||||
if (!meta) return NULL;
|
||||
size_t ml = strlen(COG_GROUNDING_META_MAGIC);
|
||||
if (strncmp(meta, COG_GROUNDING_META_MAGIC, ml) == 0) return meta;
|
||||
const char* p = meta;
|
||||
while ((p = strstr(p, COG_GROUNDING_META_MAGIC)) != NULL) {
|
||||
if (p > meta && p[-1] == '\n') return p;
|
||||
p += ml;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cog_grounding_parse(const StoreEdge* e, int64_t now_ms, CogGrounding* out) {
|
||||
if (!e || !out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
|
||||
/* Two dimensions exist on every edge whether or not grounding has ever been
|
||||
* established, because they ARE existing substrate rather than new fields:
|
||||
* associative — the accrued hebb, with its existing dynamics;
|
||||
* polarity — the signed authored weight. `inhibitory` is precisely this
|
||||
* distinction crushed to one bit, so it is the seed sign. */
|
||||
out->associative = e->hebb;
|
||||
out->polarity = e->inhibitory ? -e->weight : e->weight;
|
||||
out->prov = COG_PROV_UNSET;
|
||||
out->ts = e->last_fired > 0 ? e->last_fired : e->updated_at;
|
||||
|
||||
const char* blk = cog_grd_find(e->metadata);
|
||||
if (blk) {
|
||||
out->present = 1;
|
||||
char* copy = dupstr(blk);
|
||||
if (!copy) return -1;
|
||||
for (char* line = strtok(copy, "\n"); line; line = strtok(NULL, "\n")) {
|
||||
if (line[0] == '\0') continue;
|
||||
char tag = line[0];
|
||||
const char* rest = line + 1; while (*rest == ' ') rest++;
|
||||
if (tag == 'w') { /* the four numeric dimensions */
|
||||
double v[4] = {0,0,0,0}; parse_floats(rest, v, 4);
|
||||
out->factual = v[0]; out->relational = v[1];
|
||||
out->associative = v[2]; out->polarity = v[3];
|
||||
} else if (tag == 'k') { /* provenance class */
|
||||
out->prov = cog_prov_parse(rest);
|
||||
} else if (tag == 't') { /* timestamp + seq + reinforcements */
|
||||
double v[3] = {0,0,0}; parse_floats(rest, v, 3);
|
||||
out->ts = (int64_t)v[0]; out->seq = (int64_t)v[1]; out->reinforcements = v[2];
|
||||
} else if (tag == 'd') {
|
||||
double v[3] = {0,0,0}; parse_floats(rest, v, 3);
|
||||
out->fac_proj = v[0]; out->rel_proj = v[1]; out->cos_angle = v[2];
|
||||
} else if (tag == 'v') {
|
||||
snprintf(out->binding_value, sizeof out->binding_value, "%s", rest);
|
||||
} else if (tag == 'c') {
|
||||
double v[2] = {0,0}; parse_floats(rest, v, 2);
|
||||
out->floor_at_record = v[0]; out->rel_floor_at_record = v[1];
|
||||
} else if (tag == 'p') {
|
||||
snprintf(out->prev_edge, sizeof out->prev_edge, "%s", rest);
|
||||
}
|
||||
}
|
||||
free(copy);
|
||||
}
|
||||
out->agreement = (out->cos_angle > 0) ? 1 : (out->cos_angle < 0 ? -1 : 0);
|
||||
|
||||
/* ── DERIVED. Nothing below this line is ever serialized. Recency, decay and
|
||||
* staleness are read off the curve; storing them is how a number ends up
|
||||
* asserting something nothing computed (§8.1 / spec §2). */
|
||||
out->age_ms = (out->ts > 0 && now_ms > out->ts) ? (now_ms - out->ts) : 0;
|
||||
out->decay = cog_decay_factor(out->age_ms, out->reinforcements, 0.0);
|
||||
out->factual_now = out->factual * out->decay;
|
||||
out->relational_now = out->relational * out->decay;
|
||||
out->associative_now = out->associative * out->decay;
|
||||
out->stale = (out->present && out->floor_at_record > 0 &&
|
||||
out->factual_now < out->floor_at_record) ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* cog_grounding_metadata(const char* base_meta, const CogGrounding* g) {
|
||||
if (!g) return NULL;
|
||||
size_t keep = 0;
|
||||
if (base_meta) {
|
||||
const char* blk = cog_grd_find(base_meta);
|
||||
keep = blk ? (size_t)(blk - base_meta) : strlen(base_meta);
|
||||
while (keep > 0 && base_meta[keep - 1] == '\n') keep--;
|
||||
}
|
||||
size_t cap = keep + 1024;
|
||||
char* buf = malloc(cap); if (!buf) return NULL;
|
||||
size_t o = 0;
|
||||
if (keep) { memcpy(buf, base_meta, keep); o = keep; buf[o++] = '\n'; }
|
||||
o += (size_t)snprintf(buf + o, cap - o, "%s\n", COG_GROUNDING_META_MAGIC);
|
||||
/* STORED ONLY. factual / relational / associative / polarity / provenance /
|
||||
* timestamp — plus the joint state a decision saw. No confidence, no
|
||||
* recency, no staleness, no volatility: those are read off the curve. */
|
||||
o += (size_t)snprintf(buf + o, cap - o, "w %.9g %.9g %.9g %.9g\n",
|
||||
g->factual, g->relational, g->associative, g->polarity);
|
||||
o += (size_t)snprintf(buf + o, cap - o, "k %s\n", cog_prov_name(g->prov));
|
||||
o += (size_t)snprintf(buf + o, cap - o, "t %lld %lld %.9g\n",
|
||||
(long long)g->ts, (long long)g->seq, g->reinforcements);
|
||||
o += (size_t)snprintf(buf + o, cap - o, "d %.9g %.9g %.9g\n",
|
||||
g->fac_proj, g->rel_proj, g->cos_angle);
|
||||
o += (size_t)snprintf(buf + o, cap - o, "v %s\n", g->binding_value[0] ? g->binding_value : "-");
|
||||
o += (size_t)snprintf(buf + o, cap - o, "c %.9g %.9g\n", g->floor_at_record, g->rel_floor_at_record);
|
||||
if (g->prev_edge[0]) o += (size_t)snprintf(buf + o, cap - o, "p %s\n", g->prev_edge);
|
||||
(void)o;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* ── Consequence, not epsilon. Every test is a floor crossing or a sign change,
|
||||
* both exact. Ordered so the two INHERENT (discrete) moves are reported in
|
||||
* preference to the graded ones, because they bypass the salience gate. */
|
||||
CogSignificance cog_grounding_significant(const CogGrounding* prev,
|
||||
const CogGrounding* now,
|
||||
double floor, double rel_floor) {
|
||||
if (!now) return COG_SIG_NONE;
|
||||
if (!prev || !prev->present) return COG_SIG_FIRST_RECORD;
|
||||
|
||||
/* INHERENT 1 — polarity sign flip. Ignorance and disagreement are different
|
||||
* states, and support → contradiction is a change of state rather than a
|
||||
* drift, so no threshold applies. Comparing signs, with zero its own class. */
|
||||
{
|
||||
int sp = prev->polarity > 0 ? 1 : (prev->polarity < 0 ? -1 : 0);
|
||||
int sn = now->polarity > 0 ? 1 : (now->polarity < 0 ? -1 : 0);
|
||||
if (sp != sn) return COG_SIG_POLARITY_FLIP;
|
||||
}
|
||||
/* INHERENT 2 — provenance class change. told → observed is a categorical
|
||||
* upgrade in what the relation is entitled to, not a movement along an axis. */
|
||||
if (prev->prov != now->prov) return COG_SIG_PROVENANCE_CHANGE;
|
||||
|
||||
/* Crossing an assert floor — the move changes whether this relation can be
|
||||
* spoken. Compared on the DECAYED values, because that is what the gate reads. */
|
||||
if ((prev->factual_now >= floor) != (now->factual_now >= floor)) return COG_SIG_FACTUAL_FLOOR;
|
||||
if ((prev->relational_now >= rel_floor) != (now->relational_now >= rel_floor)) return COG_SIG_RELATIONAL_FLOOR;
|
||||
|
||||
/* Flipping factual/relational agreement — the relation stops being "true and
|
||||
* meaningful" and becomes "true and misapplied", or the reverse. This is the
|
||||
* 911/CPS contradiction as a measured event rather than a reviewable one. */
|
||||
if (prev->agreement != now->agreement) return COG_SIG_AGREEMENT_FLIP;
|
||||
|
||||
/* A gradient reversing — the evidence stopped pulling the claim toward it and
|
||||
* began pushing it away, or the same on the values axis. */
|
||||
if ((prev->fac_proj > 0) != (now->fac_proj > 0)) return COG_SIG_DIRECTION_REVERSAL;
|
||||
if ((prev->rel_proj > 0) != (now->rel_proj > 0)) return COG_SIG_DIRECTION_REVERSAL;
|
||||
|
||||
return COG_SIG_NONE;
|
||||
}
|
||||
|
||||
int cog_significance_inherent(CogSignificance s) {
|
||||
return (s == COG_SIG_FIRST_RECORD || s == COG_SIG_POLARITY_FLIP ||
|
||||
s == COG_SIG_PROVENANCE_CHANGE) ? 1 : 0;
|
||||
}
|
||||
|
||||
const char* cog_significance_name(CogSignificance s) {
|
||||
switch (s) {
|
||||
case COG_SIG_FIRST_RECORD: return "first-record";
|
||||
case COG_SIG_POLARITY_FLIP: return "polarity-sign-flip";
|
||||
case COG_SIG_PROVENANCE_CHANGE: return "provenance-class-change";
|
||||
case COG_SIG_FACTUAL_FLOOR: return "factual-floor-crossed";
|
||||
case COG_SIG_RELATIONAL_FLOOR: return "relational-floor-crossed";
|
||||
case COG_SIG_AGREEMENT_FLIP: return "agreement-sign-flip";
|
||||
case COG_SIG_DIRECTION_REVERSAL: return "gradient-direction-reversal";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Recording: a NEW edge record. The predecessor is never touched. ────────── */
|
||||
int cog_grounding_record(EngramPagedStore* s, const StoreEdge* base,
|
||||
const CogGrounding* g, char* out_id, size_t out_id_cap) {
|
||||
if (!s || !base || !base->id || !g) return -1;
|
||||
char root[192];
|
||||
snprintf(root, sizeof root, "%s", base->id);
|
||||
char* hash = strchr(root, '#'); if (hash) *hash = '\0';
|
||||
|
||||
int seq = (int)g->seq + 1;
|
||||
char vid[224];
|
||||
snprintf(vid, sizeof vid, "%s#%d", root, seq);
|
||||
|
||||
CogGrounding rec = *g;
|
||||
rec.seq = seq;
|
||||
snprintf(rec.prev_edge, sizeof rec.prev_edge, "%s", base->id);
|
||||
|
||||
char* meta = cog_grounding_metadata(base->metadata, &rec);
|
||||
if (!meta) return -1;
|
||||
|
||||
StoreEdge e; memset(&e, 0, sizeof e);
|
||||
e.id = vid; e.from_id = base->from_id; e.to_id = base->to_id;
|
||||
e.relation = base->relation; e.metadata = meta;
|
||||
/* The vector IS the weight, so the scalar fields carry their dimensions:
|
||||
* `weight` the magnitude of polarity, `inhibitory` its sign, `hebb` the
|
||||
* associative strength. Nothing here is a second copy of a derived value. */
|
||||
e.weight = rec.polarity < 0 ? -rec.polarity : rec.polarity;
|
||||
e.inhibitory = rec.polarity < 0 ? 1 : 0;
|
||||
e.hebb = rec.associative;
|
||||
e.confidence = base->confidence;
|
||||
e.created_at = base->created_at;
|
||||
e.updated_at = rec.ts;
|
||||
e.last_fired = rec.ts;
|
||||
e.layer_id = base->layer_id;
|
||||
int rc = store_put_edge(s, &e);
|
||||
free(meta);
|
||||
if (rc != 0) return -1;
|
||||
if (out_id && out_id_cap) snprintf(out_id, out_id_cap, "%s", vid);
|
||||
return seq;
|
||||
}
|
||||
|
||||
int cog_grounding_head(EngramPagedStore* s, const char* base_id,
|
||||
StoreEdge* out, int max_versions) {
|
||||
if (!s || !base_id || !out) return -1;
|
||||
if (max_versions <= 0) max_versions = 64;
|
||||
char root[192]; snprintf(root, sizeof root, "%s", base_id);
|
||||
char* hash = strchr(root, '#'); if (hash) *hash = '\0';
|
||||
|
||||
StoreEdge cur; memset(&cur, 0, sizeof cur);
|
||||
if (store_get_edge(s, root, &cur) != 1) return -1;
|
||||
int found = 0;
|
||||
for (int v = 1; v <= max_versions; v++) {
|
||||
char vid[224]; snprintf(vid, sizeof vid, "%s#%d", root, v);
|
||||
StoreEdge nx;
|
||||
if (store_get_edge(s, vid, &nx) != 1) break;
|
||||
store_edge_free(&cur); cur = nx; found = v;
|
||||
}
|
||||
*out = cur;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ── VOLATILITY AND DRIFT: derived from the chain, stored nowhere. The series
|
||||
* exists only because nothing was destroyed, which is the whole return on
|
||||
* immutability — a derivative for free. */
|
||||
int cog_grounding_trajectory(EngramPagedStore* s, const char* base_id,
|
||||
int64_t now_ms, CogTrajectory* out) {
|
||||
if (!s || !base_id || !out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
char root[192]; snprintf(root, sizeof root, "%s", base_id);
|
||||
char* hash = strchr(root, '#'); if (hash) *hash = '\0';
|
||||
|
||||
double pf = 0, pr = 0, f0 = 0, r0 = 0, fN = 0, rN = 0;
|
||||
double sum_df = 0, sum_dr = 0;
|
||||
int n = 0;
|
||||
for (int v = 0; v <= 64; v++) {
|
||||
char vid[224];
|
||||
if (v == 0) snprintf(vid, sizeof vid, "%s", root);
|
||||
else snprintf(vid, sizeof vid, "%s#%d", root, v);
|
||||
StoreEdge e;
|
||||
if (store_get_edge(s, vid, &e) != 1) { if (v) break; else continue; }
|
||||
CogGrounding g;
|
||||
if (cog_grounding_parse(&e, now_ms, &g) == 0) {
|
||||
if (n == 0) { f0 = g.factual; r0 = g.relational; }
|
||||
else { sum_df += fabs(g.factual - pf); sum_dr += fabs(g.relational - pr); }
|
||||
pf = g.factual; pr = g.relational; fN = pf; rN = pr;
|
||||
n++;
|
||||
}
|
||||
store_edge_free(&e);
|
||||
}
|
||||
out->n_versions = n;
|
||||
if (n > 1) {
|
||||
out->factual_volatility = sum_df / (double)(n - 1);
|
||||
out->relational_volatility = sum_dr / (double)(n - 1);
|
||||
}
|
||||
out->factual_drift = fN - f0;
|
||||
out->relational_drift = rN - r0;
|
||||
/* "STAYED TRUE, BECAME WRONG" — the event the joint record makes visible and
|
||||
* that per-dimension versioning would have destroyed: the fact held while
|
||||
* the meaning degraded. Expressed as signs, so there is no tolerance here
|
||||
* either: factual did not fall, relational did. */
|
||||
out->stayed_true_became_wrong =
|
||||
(n > 1 && out->factual_drift >= 0 && out->relational_drift < 0) ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Assertion gates on BOTH floors. Traversal is untouched: activation still
|
||||
* conducts on the factual/associative side, so a relation can remain thinkable
|
||||
* while ceasing to be assertable. That gap is where the wide angles live. ──── */
|
||||
int cog_assert_two_axis(EngramPagedStore* s, const char* claim_id,
|
||||
double floor, double rel_floor, int64_t now_ms,
|
||||
CogAssertion* out) {
|
||||
if (!s || !claim_id || !out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
if (!(floor > 0)) floor = 0.5;
|
||||
if (!(rel_floor > 0)) rel_floor = floor;
|
||||
|
||||
/* still_held is DERIVED, not a literal (§8.1). Holding is unconditional —
|
||||
* the store gates nothing — so the question the field actually answers is
|
||||
* whether the content is present and live. */
|
||||
StoreNode n;
|
||||
if (store_get_node(s, claim_id, &n) == 1) { out->still_held = !n.tombstoned; store_node_free(&n); }
|
||||
else out->still_held = 0;
|
||||
|
||||
double best = -1.0;
|
||||
for (int dir = 0; dir < 2; dir++) {
|
||||
StoreEdge* edges = NULL; size_t ne = 0;
|
||||
int rc = dir == 0 ? store_get_edges_from(s, claim_id, &edges, &ne)
|
||||
: store_get_edges_to (s, claim_id, &edges, &ne);
|
||||
if (rc < 0) continue;
|
||||
for (size_t i = 0; i < ne; i++) {
|
||||
if (edges[i].tombstoned) continue;
|
||||
CogGrounding g;
|
||||
if (cog_grounding_parse(&edges[i], now_ms, &g) != 0) continue;
|
||||
out->n_edges++;
|
||||
out->found = 1;
|
||||
if (g.factual_now > best) {
|
||||
best = g.factual_now;
|
||||
out->factual = g.factual_now;
|
||||
out->relational = g.relational_now; /* the SAME edge, not a max */
|
||||
out->polarity = g.polarity;
|
||||
out->cos_angle = g.cos_angle;
|
||||
out->agreement = g.agreement;
|
||||
out->prov = g.prov;
|
||||
out->relational_established = g.present;
|
||||
snprintf(out->best_edge, sizeof out->best_edge, "%s", edges[i].id ? edges[i].id : "");
|
||||
snprintf(out->binding_value, sizeof out->binding_value, "%s", g.binding_value);
|
||||
}
|
||||
}
|
||||
store_edges_free(edges, ne);
|
||||
}
|
||||
/* BOTH floors, and an unestablished relational axis does NOT pass by default
|
||||
* — defaulting it to passing is the exemption §0 forbids. A negative polarity
|
||||
* is a relation that actively contradicts and can never license assertion. */
|
||||
out->may_assert = (out->found && out->relational_established &&
|
||||
out->polarity > 0 &&
|
||||
out->factual >= floor && out->relational >= rel_floor) ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════ THE CORRESPONDENCE-LOOP ═════ */
|
||||
int engram_correspondence_beat(const GeoDescriptor* region, const float* anchor,
|
||||
double outcome_y, CogStance* stance,
|
||||
int learn, double max_step, CogBeatResult* out) {
|
||||
if (!region || !stance || !out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
if (stance->keystone) { learn = 0; out->wrote_keystone = 1; } /* §6: never write a keystone */
|
||||
/* 2026-08-16: the keystone block is GONE. It refused to learn about the
|
||||
* reference frame, which does not make it a good reference — it makes it
|
||||
* unexaminable, trading circular calibration for an ungroundable one (spec
|
||||
* §2). Measured cost of the block: on the keystone region the beat reported
|
||||
* 0.00% brier reduction over n_trials 0 — it never ran, so nothing about the
|
||||
* self was ever calibrated OR falsifiable. What replaces it is a provenance
|
||||
* constraint, not a permission: cog_grounding_downstream refuses evidence
|
||||
* that is downstream of the region being calibrated, for every region alike.
|
||||
* `wrote_keystone` is retained as a reporting field only and is always 0. */
|
||||
|
||||
GeoGradient g;
|
||||
if (engram_think(region, anchor, stance, &g) != 0) return -1; /* PREDICTION */
|
||||
|
||||
+269
-17
@@ -23,7 +23,7 @@
|
||||
* and a region, and grounded-for-whom.
|
||||
*
|
||||
* PURE + (mostly) READ-ONLY, stdlib + libm only. think() and the warp are pure
|
||||
* over their inputs. Persistence (Stance <-> StoreNode, grounded-by edges) is the
|
||||
* over their inputs. Persistence (Stance <-> StoreNode, edge grounding vectors) is the
|
||||
* only part that touches the store, and it is additive / supersede / tombstone —
|
||||
* never mutate-in-place, never delete. It NEVER touches the live daemon: all
|
||||
* offline against a scratch store, per the design's rails.
|
||||
@@ -152,30 +152,25 @@ int engram_express(const GeoGradient* g, const float* anchor, float* out_point);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §5 HOLD vs GROUND vs ASSERT. Holding is unconditional (the store gates nothing).
|
||||
* Grounding is a RELATION — a "grounded-by" edge, probabilistic, grounded-for-whom.
|
||||
* The honesty floor is checked only at ASSERTION.
|
||||
* Grounding is an ATTRIBUTE OF a relation — carried on the edge itself, as a
|
||||
* vector (§7). The honesty floor is checked only at ASSERTION, on both axes.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
#define COG_GROUNDED_BY_RELATION "grounded-by"
|
||||
/* DELETED 2026-08-16: COG_GROUNDED_BY_RELATION and cog_ground_edge.
|
||||
*
|
||||
* A "grounded-by" edge models grounding as a relation BETWEEN two nodes. It is a
|
||||
* property OF a relation — and it is that relation's weight. Minting a new edge
|
||||
* to carry a score was the error; #147 corrected which endpoints the edge landed
|
||||
* on and left the wrong idea standing. There is nothing to ground a claim
|
||||
* "against" that is not already an edge, and if no edge exists the honest answer
|
||||
* is that the two are not related — not a freshly minted one scoring 0.98.
|
||||
* See §7 for what replaced it. */
|
||||
#define COG_SALIENT_TO_RELATION "salient-to"
|
||||
|
||||
/* Write a grounded-by edge (additive). weight = grounding ∈(0,1] from the verifier;
|
||||
* for_whom recorded in edge metadata (grounding is relational). Never a node flag. */
|
||||
int cog_ground_edge(EngramPagedStore* s, const char* claim_id,
|
||||
const char* evidence_id, double grounding, const char* for_whom);
|
||||
|
||||
/* Write/refresh a salient-to edge: salience is RELATIONAL (grounded-for-whom),
|
||||
* carried on the edge to the observer — not baked into the node scalar (§2.1). */
|
||||
int cog_salient_edge(EngramPagedStore* s, const char* node_id,
|
||||
const char* observer_id, double salience);
|
||||
|
||||
/* The honesty floor — a QUERY at assertion time, NOT a schema constraint. Reads the
|
||||
* claim's stored grounded-by edges (for the given observer) and returns:
|
||||
* 1 = may assert (best grounding >= floor),
|
||||
* 0 = REFUSE assertion (holds unconditionally; only asserting is gated),
|
||||
* <0 = error. The content remains held either way. */
|
||||
int cog_assert_gate(EngramPagedStore* s, const char* claim_id,
|
||||
const char* for_whom, double floor);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §4 THE REFLEXIVE CORRESPONDENCE-LOOP — the learning engine. think scores its
|
||||
* OWN gradient against outcome, refines the stance on the error, and (optionally)
|
||||
@@ -209,8 +204,265 @@ int engram_correspondence_beat(const GeoDescriptor* region, const float* anchor,
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §6 METASTABILITY. Keystones (self/values) are read-mostly: the loop reads but
|
||||
* never writes them. Mark by stance flag or by a keystone-id set the loop consults.
|
||||
*
|
||||
* SUPERSEDED BY §7's PROVENANCE CONSTRAINT (2026-08-16). The keystone flag is a
|
||||
* PERMISSION: it asks who the target is, not where the evidence came from. That
|
||||
* is censorship, and it costs the ability to ever ground the self (spec
|
||||
* correspondence-and-censorship.md §0/§2). The constraint that actually protects
|
||||
* a reference frame is cog_grounding_downstream: a region may not be calibrated
|
||||
* by evidence downstream of itself. These declarations remain only so existing
|
||||
* call sites keep compiling; nothing in the grounding path consults them.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
typedef struct { const char** ids; int n; } CogKeystoneSet;
|
||||
int cog_is_keystone(const CogKeystoneSet* ks, const CogStance* s);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §7 GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR
|
||||
* (2026-08-16; spec correspondence-and-censorship.md §2–§6 @ 2b7e4ba.)
|
||||
*
|
||||
* THE MODEL. Grounding is not a subsystem, a score, or a relation BETWEEN nodes.
|
||||
* It is an attribute OF a relation. The graph already IS the grounding structure:
|
||||
* every edge is a grounded relation, and what that relation is worth is carried
|
||||
* on the edge itself. Three things follow, and each DELETES rather than adds:
|
||||
*
|
||||
* 1. `grounded-by` as a relation type does not exist, and cog_ground_edge is
|
||||
* gone. Minting an edge to hold a score models grounding as a relation
|
||||
* between nodes when it is a property of a relation. #147 corrected which
|
||||
* endpoints that edge landed on and left the wrong idea standing.
|
||||
* 2. There is no observer, and no sampling rate. Change is not a consequence of
|
||||
* use — it IS use, the way potentiation is the firing rather than something
|
||||
* that reads the firing and writes a weight. So no supervisor compares a
|
||||
* value to a threshold and decides to persist.
|
||||
* 3. Between two recorded versions the trajectory is not unknown. Decay is a
|
||||
* pure function of the last recorded point and elapsed time, so it is
|
||||
* ANALYTIC: store the point, read the curve.
|
||||
*
|
||||
* WHAT IS *NOT* HERE, DELIBERATELY. An earlier draft of the spec posed "a graph
|
||||
* predicate for evidence downstream of itself" as the hard problem, and this file
|
||||
* briefly contained one. It is withdrawn. Non-circularity is TEMPORAL, not
|
||||
* topological: you cannot recalibrate the ruler while measuring with it, so you
|
||||
* do it when you are not using the frame to act. Reachability could never have
|
||||
* worked — measured on the live store, reachability from the self region over
|
||||
* all relations reaches 89.2% of the graph (10,580 of 11,861 nodes) and 16.0%
|
||||
* over hebbian/semantic relations alone, so the predicate marks essentially all
|
||||
* evidence tainted and the constraint degenerates into the total block that
|
||||
* censorship started as. Nothing replaces it here; the independence is a fact
|
||||
* about engagement, owned by the dreamer, not a fact about the graph.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* §7.1 THE VECTOR
|
||||
*
|
||||
* The test for a real dimension is whether it can move independently of the
|
||||
* others. Five can, and each maps onto substrate that already exists:
|
||||
*
|
||||
* factual correspondence with evidence. [GRD1]
|
||||
* relational correspondence with values — min over THIRTEEN
|
||||
* value regions, carrying the binding value's NAME. [GRD1]
|
||||
* associative co-activation frequency. This is the edge's `hebb`
|
||||
* field with its existing dynamics — NOT a new one.
|
||||
* Independent by construction: every superstition is
|
||||
* a strong association with no factual grounding.
|
||||
* polarity SIGNED. Near zero means "no support"; NEGATIVE means
|
||||
* "this actively contradicts". The edge's `inhibitory`
|
||||
* bit is exactly this distinction crushed to one bit,
|
||||
* and is carried forward as the seed value. [GRD1]
|
||||
* provenance observed / inferred / told / imprinted. Categorical,
|
||||
* and load-bearing: it governs what the relation is
|
||||
* entitled to. [GRD1]
|
||||
*
|
||||
* Plus a TIMESTAMP, which is what turns the supersession chain into a time
|
||||
* series of vectors rather than a series of numbers.
|
||||
*
|
||||
* DERIVED, THEREFORE NEVER STORED. Confidence (high grounding AND low
|
||||
* volatility), recency (decay read off the curve), staleness (grounding fallen
|
||||
* below its floor), volatility (the derivative of a series nothing destroyed).
|
||||
* Storing confidence separately is how `confidence: 0.5` ends up sitting beside
|
||||
* a zero direction vector, asserting something nothing computed. Every field in
|
||||
* CogGrounding below is marked STORED or DERIVED, and the serializer writes
|
||||
* only the STORED ones.
|
||||
*
|
||||
* THE VALUES REFERENCE IS THIRTEEN REGIONS AND THE AGGREGATE IS MIN.
|
||||
* Measured on the live store: the values root kn-5b606390 `contains` exactly 13
|
||||
* value nodes; pairwise centroid cosine among their regions is min 0.1525,
|
||||
* mean 0.5199, median 0.5282, max 0.9278 — they demonstrably do not form one
|
||||
* region. Against a single union region the individual values sit at cosine
|
||||
* 0.38..0.89, with constraints-as-freedom at 0.3812 and change-is-the-signal at
|
||||
* 0.4677, so a union centroid under-represents precisely the values a claim is
|
||||
* most likely to be measured against. MIN rather than MEAN because a mean lets
|
||||
* strong agreement with twelve values mask a violation of the thirteenth, which
|
||||
* is the mechanism of rationalization; min yields a binding constraint with a
|
||||
* NAME attached rather than a score.
|
||||
*
|
||||
* TRAVERSAL CONDUCTS ON FACTUAL; ASSERTION REQUIRES BOTH. If activation
|
||||
* conducted on relational weight, Neuron could not follow a chain of reasoning
|
||||
* to a conclusion he then rejects — censorship arriving through the spreading
|
||||
* rule. The gap between reachable and assertable is where the wide
|
||||
* factual/relational angles live, and that gap is the interesting part.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── The one decay model (moved here from el_runtime.c so that node decay and
|
||||
* edge-grounding decay are a single implementation with a single set of
|
||||
* constants, rather than a model and a parallel copy of it). Half-life scales
|
||||
* with how established the thing is: T_eff = T_HALF · (1 + ln(1 + reinforcements)).
|
||||
* The floor is a preference, not a cliff — max penalty for age alone is 4x.
|
||||
* `lambda_override` > 0 replaces the default rate; 0 means use the default. */
|
||||
#define COG_T_HALF_HOURS 168.0
|
||||
#define COG_DECAY_LAMBDA 0.693147
|
||||
#define COG_DECAY_FLOOR 0.25
|
||||
double cog_decay_factor(int64_t age_ms, double reinforcements, double lambda_override);
|
||||
|
||||
/* The compact vector block carried in the edge's own metadata. Line schema, same
|
||||
* precedent as STNC1 / GEO1. Metadata the edge already carried is preserved
|
||||
* verbatim ahead of the magic line. */
|
||||
#define COG_GROUNDING_META_MAGIC "GRD1"
|
||||
|
||||
/* Provenance class — categorical, and it governs what the relation is entitled
|
||||
* to. A change of class is inherently significant and needs no threshold,
|
||||
* because told → observed is a categorical upgrade, not a drift. */
|
||||
typedef enum {
|
||||
COG_PROV_UNSET = 0,
|
||||
COG_PROV_OBSERVED = 1,
|
||||
COG_PROV_INFERRED = 2,
|
||||
COG_PROV_TOLD = 3,
|
||||
COG_PROV_IMPRINTED = 4
|
||||
} CogProvClass;
|
||||
const char* cog_prov_name(CogProvClass p);
|
||||
CogProvClass cog_prov_parse(const char* s);
|
||||
|
||||
typedef struct {
|
||||
int present; /* 1 iff the edge carries a GRD1 block */
|
||||
|
||||
/* ── STORED: the vector, as it stood at `ts` ─────────────────────────────── */
|
||||
double factual; /* correspondence with evidence */
|
||||
double relational; /* min over the thirteen value regions */
|
||||
double associative; /* co-activation frequency — mirrors edge->hebb */
|
||||
double polarity; /* SIGNED support; <0 = actively contradicts */
|
||||
CogProvClass prov; /* observed / inferred / told / imprinted */
|
||||
int64_t ts; /* when this version was recorded (ms) */
|
||||
int64_t seq; /* supersession sequence number */
|
||||
double reinforcements; /* uses folded into this version */
|
||||
char binding_value[128]; /* the argmin value — the conflict's NAME */
|
||||
/* the two gradients as frame-independent signed projections, plus the angle
|
||||
* between them in full R^dim. These are part of the JOINT STATE a decision
|
||||
* saw, not a convenience: near +1 evidence and values push the same way; at
|
||||
* or below 0 the relation is factually supported and relationally wrong. */
|
||||
double fac_proj, rel_proj, cos_angle;
|
||||
int agreement; /* sign(cos_angle): +1 / 0 / −1 */
|
||||
double floor_at_record, rel_floor_at_record;
|
||||
char prev_edge[192]; /* the version this superseded ("" if first) */
|
||||
|
||||
/* ── DERIVED at read time. NEVER serialized. ─────────────────────────────── */
|
||||
int64_t age_ms; /* recency: now − ts */
|
||||
double decay; /* cog_decay_factor over that age */
|
||||
double factual_now; /* factual · decay */
|
||||
double relational_now;
|
||||
double associative_now;
|
||||
int stale; /* grounding fallen below its floor */
|
||||
} CogGrounding;
|
||||
|
||||
/* Read an edge's vector as of `now_ms`. Pure — never writes. An edge with no
|
||||
* GRD1 block still has an associative strength (its accrued hebb) and a polarity
|
||||
* (its signed authored weight); `present` says whether the grounding dimensions
|
||||
* have ever been established, and an unestablished dimension is reported as such
|
||||
* rather than defaulted to a passing value. */
|
||||
int cog_grounding_parse(const StoreEdge* e, int64_t now_ms, CogGrounding* out);
|
||||
|
||||
/* Serialize the STORED half of the vector, preserving pre-existing non-GRD1
|
||||
* metadata. Returns an owned string. Derived fields are not written. */
|
||||
char* cog_grounding_metadata(const char* base_meta, const CogGrounding* g);
|
||||
|
||||
/* ── §7.2 CONSOLIDATION-GATED SUPERSESSION ──────────────────────────────────
|
||||
*
|
||||
* Supersession is not recording — it is CONSOLIDATION, gated by salience, which
|
||||
* is why you remember the argument and not the commute. Significance is
|
||||
* evaluated PER-DIMENSION but the record is the WHOLE VECTOR: any dimension
|
||||
* moving enough to matter triggers a supersession, and the new version captures
|
||||
* every dimension as it stood at that instant. Versioning axes independently
|
||||
* would make the joint state unreconstructable, and the joint state is the point
|
||||
* — it is what makes "stayed true, became wrong" visible as an event (factual
|
||||
* holding steady across versions while relational degrades).
|
||||
*
|
||||
* There is deliberately no epsilon in this enum or in the function that computes
|
||||
* it. Every test is a floor crossing or a sign change, both exact. Two of them
|
||||
* are INHERENTLY significant because they are discrete state changes rather than
|
||||
* drift, and those bypass the salience gate entirely. */
|
||||
typedef enum {
|
||||
COG_SIG_NONE = 0, /* nothing decision-relevant moved — DO NOT RECORD */
|
||||
COG_SIG_FIRST_RECORD = 1, /* no prior version exists */
|
||||
COG_SIG_POLARITY_FLIP = 2, /* INHERENT: support ↔ contradiction, or ignorance
|
||||
* ↔ either. A discrete change of state. */
|
||||
COG_SIG_PROVENANCE_CHANGE = 3, /* INHERENT: told → observed is a categorical
|
||||
* upgrade in what the relation is entitled to. */
|
||||
COG_SIG_FACTUAL_FLOOR = 4, /* crossed the assert floor, factual axis */
|
||||
COG_SIG_RELATIONAL_FLOOR = 5, /* crossed the assert floor, relational axis */
|
||||
COG_SIG_AGREEMENT_FLIP = 6, /* factual/relational agreement changed sign */
|
||||
COG_SIG_DIRECTION_REVERSAL = 7 /* a gradient reversed direction */
|
||||
} CogSignificance;
|
||||
|
||||
CogSignificance cog_grounding_significant(const CogGrounding* prev,
|
||||
const CogGrounding* now,
|
||||
double floor, double rel_floor);
|
||||
const char* cog_significance_name(CogSignificance s);
|
||||
/* 1 iff this reason is a discrete state change that consolidates regardless of
|
||||
* salience (polarity flip, provenance change, first record). */
|
||||
int cog_significance_inherent(CogSignificance s);
|
||||
|
||||
/* ── §7.3 RECORDING: supersession of the EDGE, never an overwrite ────────────
|
||||
* Writes version seq+1 as a NEW edge record with the same endpoints and relation
|
||||
* and id "<root>#<seq+1>", carrying a GRD1 `p` pointer to its predecessor. The
|
||||
* predecessor is never touched. The chain IS the trajectory: not only what the
|
||||
* grounding is but which way it has been moving and how fast — a derivative
|
||||
* obtained for free from immutability, because the points were never destroyed.
|
||||
* Returns the version written (>=1), or <0 on error. */
|
||||
int cog_grounding_record(EngramPagedStore* s, const StoreEdge* base,
|
||||
const CogGrounding* g, char* out_id, size_t out_id_cap);
|
||||
|
||||
/* Walk forward from a base edge id to its newest recorded version. Point reads
|
||||
* only; consolidation is gated, so the chain is short. Returns the highest
|
||||
* version found (0 = the base record is the only one). */
|
||||
int cog_grounding_head(EngramPagedStore* s, const char* base_id,
|
||||
StoreEdge* out, int max_versions);
|
||||
|
||||
/* VOLATILITY — derived, never stored: the mean absolute per-version change of a
|
||||
* dimension across the recorded chain. Feeds the equally-derived `confidence`
|
||||
* (high grounding AND low volatility), which is likewise never stored. */
|
||||
typedef struct {
|
||||
int n_versions;
|
||||
double factual_volatility;
|
||||
double relational_volatility;
|
||||
double factual_drift; /* signed: newest − oldest */
|
||||
double relational_drift;
|
||||
int stayed_true_became_wrong; /* factual steady while relational degraded */
|
||||
} CogTrajectory;
|
||||
int cog_grounding_trajectory(EngramPagedStore* s, const char* base_id,
|
||||
int64_t now_ms, CogTrajectory* out);
|
||||
|
||||
/* ── §7.4 ASSERTION GATES ON BOTH FLOORS ────────────────────────────────────
|
||||
* A well-evidenced claim must not earn the right to be asserted regardless of
|
||||
* whether it means the right thing. `may_assert` requires the decayed factual
|
||||
* grounding to clear `floor` AND the decayed relational grounding to clear
|
||||
* `rel_floor`. A relation whose relational axis has never been established does
|
||||
* not pass by default — it is reported unestablished and refused, because
|
||||
* defaulting it to passing is exactly the exemption §0 forbids. Traversal is
|
||||
* untouched: activation still conducts on the factual/associative side, so a
|
||||
* relation can remain thinkable while ceasing to be assertable. */
|
||||
typedef struct {
|
||||
int may_assert;
|
||||
int found; /* any relation at all on this claim */
|
||||
int relational_established;
|
||||
int still_held; /* DERIVED: node present and not tombstoned */
|
||||
double factual; /* best decayed factual grounding */
|
||||
double relational; /* the SAME edge's relational axis, not a max */
|
||||
double polarity;
|
||||
double cos_angle;
|
||||
int agreement;
|
||||
CogProvClass prov;
|
||||
char best_edge[192];
|
||||
char binding_value[128];
|
||||
int n_edges;
|
||||
} CogAssertion;
|
||||
int cog_assert_two_axis(EngramPagedStore* s, const char* claim_id,
|
||||
double floor, double rel_floor, int64_t now_ms,
|
||||
CogAssertion* out);
|
||||
|
||||
#endif /* ENGRAM_COGNITION_H */
|
||||
|
||||
@@ -222,7 +222,7 @@ static double eff_w(double weight, double hebb){
|
||||
}
|
||||
|
||||
GeoDescriptor* engram_geometry_descriptor(
|
||||
EngramPagedStore* store, VIndex* vindex,
|
||||
EngramPagedStore* store, const VIndex* vindex,
|
||||
char** vids, int n_vids,
|
||||
const char* const* seed_ids, size_t n_seeds,
|
||||
const GeoParams* params,
|
||||
@@ -1401,7 +1401,7 @@ static double geo_weighted_degree(EngramPagedStore* st, const char* id, double e
|
||||
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,
|
||||
const GeoReifyParams* params){
|
||||
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
|
||||
* (no seeds resolvable, OOM). */
|
||||
GeoDescriptor* engram_geometry_descriptor(
|
||||
EngramPagedStore* store, VIndex* vindex,
|
||||
EngramPagedStore* store, const VIndex* vindex,
|
||||
char** vids, int n_vids,
|
||||
const char* const* seed_ids, size_t n_seeds,
|
||||
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
|
||||
* provenance. Read-then-write over `store`. Returns #neighborhoods persisted, or <0.
|
||||
* 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,
|
||||
const GeoReifyParams* params);
|
||||
|
||||
|
||||
+378
-6
@@ -44,6 +44,11 @@
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__APPLE__) || defined(__MACH__)
|
||||
#include <sys/sysctl.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_host.h>
|
||||
#endif
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
@@ -236,8 +241,16 @@ struct PgCache {
|
||||
unsigned prefetch; /* read-ahead window (pages); 0 = off */
|
||||
LayerPin* lp; size_t lp_n, lp_cap; /* hot-layer pin bookkeeping */
|
||||
size_t dirty_count; /* # dirty frames, maintained incrementally (M5) */
|
||||
/* stats (introspection only — never affect semantics) */
|
||||
/* Interoception. These were "introspection only — never affect semantics",
|
||||
* and that was the bug: the pool could not feel itself thrash, so it could
|
||||
* not correct, and neither could anyone watching from outside. The sensed
|
||||
* state IS the corrective mechanism (see pc_adapt_budget) — the same way the
|
||||
* engram's own boundary-beat/chronoception let it feel its own activity. */
|
||||
uint64_t hits, misses, evictions, prefetch_reads;
|
||||
/* sliding-window marks so pressure reflects NOW, not lifetime totals */
|
||||
uint64_t adapt_last_acc, adapt_last_evic, adapt_last_hits;
|
||||
uint64_t adapt_grows; /* budget corrections upward */
|
||||
uint64_t adapt_shrinks; /* budget corrections downward (memory pressure) */
|
||||
};
|
||||
|
||||
/* ── little-endian scalar codecs ──────────────────────────────────────────── */
|
||||
@@ -334,6 +347,51 @@ static uint64_t dh_node_hash(const StoreNode* n){
|
||||
return h;
|
||||
}
|
||||
|
||||
/* dh_edge_hash — the edge counterpart of dh_node_hash.
|
||||
*
|
||||
* WHY THIS EXISTS (2026-08-15): the write barrier was node-only. Checkpointing
|
||||
* pushes the WHOLE resident graph through store_put_node/store_put_edge (see
|
||||
* engram_store_checkpoint), and nodes were cheaply skipped when unchanged —
|
||||
* a hash compare, no page I/O. Edges had no such check, so every edge was
|
||||
* rewritten on every checkpoint, and each rewrite runs the idempotency probe
|
||||
* max_page_lsn_for_id → btree lookup → page_read per stored copy.
|
||||
*
|
||||
* Edges outnumber nodes roughly 3:1 here (37,663 vs 13,430), so this turned
|
||||
* routine checkpointing into a FULL-STORE WALK in id order — random page access
|
||||
* across the entire 2 GiB store, repeated, mostly to rediscover that nothing
|
||||
* had changed. That walk is the failure mode: with a page cache smaller than
|
||||
* the store it degenerates into thrashing and the engram never makes progress.
|
||||
* Sizing the cache around that walk treats the symptom; the walk itself should
|
||||
* not happen.
|
||||
*
|
||||
* The discriminator byte keeps the edge keyspace from ever colliding with a
|
||||
* node of the same id in the shared dh map: distinct kinds cannot produce the
|
||||
* same hash, so a stale skip is not reachable by collision. */
|
||||
static uint64_t dh_edge_hash(const StoreEdge* e){
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
const uint8_t kind = 0xE0; /* edge discriminator */
|
||||
dh_fold_bytes(&h, &kind, 1);
|
||||
dh_fold_str(&h, e->id);
|
||||
dh_fold_str(&h, e->from_id);
|
||||
dh_fold_str(&h, e->to_id);
|
||||
dh_fold_str(&h, e->relation);
|
||||
dh_fold_str(&h, e->metadata);
|
||||
uint8_t t8[8];
|
||||
put_f64(t8, e->weight); dh_fold_bytes(&h, t8, 8);
|
||||
put_f64(t8, e->hebb); dh_fold_bytes(&h, t8, 8);
|
||||
put_f64(t8, e->confidence); dh_fold_bytes(&h, t8, 8);
|
||||
uint8_t t4[4];
|
||||
put_u32(t4, (uint32_t)e->inhibitory); dh_fold_bytes(&h, t4, 4);
|
||||
put_u32(t4, e->layer_id); dh_fold_bytes(&h, t4, 4);
|
||||
/* created_at/updated_at/last_fired are deliberately EXCLUDED: last_fired is
|
||||
* touched by activation without changing what the edge IS, and including it
|
||||
* would defeat the barrier on exactly the hot edges it most needs to skip.
|
||||
* The fields that define the edge's durable content are all folded above. */
|
||||
if (e->unknown && e->unknown_len) dh_fold_bytes(&h, e->unknown, e->unknown_len);
|
||||
if (h == 0) h = 1; /* reserve 0 as "absent" in the map */
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Open-addressing id(string)→durable-hash map. Keyed for O(1) bucketing on the
|
||||
* id's FNV hash, compared by strcmp for correctness (full-id discipline, matching
|
||||
* store_scan_*'s StrSet). Values are the 64-bit durable hash. */
|
||||
@@ -1531,6 +1589,11 @@ int store_scan_edges(EngramPagedStore* s, StoreEdgeScanCb cb, void* ctx){
|
||||
if (cand.id && *cand.id && strset_add(&seen, cand.id)){
|
||||
StoreEdge canon;
|
||||
if (store_get_edge(s, cand.id, &canon) == 1){
|
||||
/* seed the write-barrier map from on-disk truth so the FIRST
|
||||
* post-boot checkpoint full-walk already skips unchanged edges
|
||||
* (mirrors store_scan_nodes; without it the barrier is empty at
|
||||
* boot and the first checkpoint re-probes every edge) */
|
||||
if (s->barrier_on) dh_set(s->dh, canon.id, dh_edge_hash(&canon));
|
||||
cb(&canon, ctx); count++; /* canonical latest-live */
|
||||
store_edge_free(&canon);
|
||||
}
|
||||
@@ -1594,19 +1657,76 @@ int store_scan_edges(EngramPagedStore* s, StoreEdgeScanCb cb, void* ctx){
|
||||
* matches disk, so a re-fault reproduces identical bytes.
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* default frame budget: large enough that today's whole store stays resident
|
||||
* (== Phase 1). Override with env ENGRAM_POOL_FRAMES (0 = unlimited). */
|
||||
#ifndef ENGRAM_POOL_FRAMES_DEFAULT
|
||||
#define ENGRAM_POOL_FRAMES_DEFAULT (1u<<20) /* ~1M frames × 16KiB = 16 GiB */
|
||||
/* ── Frame budget ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* A FIXED frame count cannot be correct. It has no relationship to either
|
||||
* quantity that decides whether a cache works: the size of the working set, or
|
||||
* the memory actually available on the host. It is the same number on a 16 GB
|
||||
* laptop and a 256 GB server, and it stays put while the store grows.
|
||||
*
|
||||
* That is not hypothetical. On 2026-08-15 the deployment pinned
|
||||
* ENGRAM_POOL_FRAMES=65536 (1 GiB) while neuron.egm grew to 2.1 GiB. The
|
||||
* working set was twice the budget, so boot-time WAL replay — which walks
|
||||
* pages in an order uncorrelated with reuse — evicted each page shortly before
|
||||
* it was needed again. The engram spun at 100% CPU inside pc_evict_to_budget
|
||||
* and never bound its port. Not slow: making no progress. Denning's thrashing,
|
||||
* exactly, and no eviction policy can fix it — when the working set does not
|
||||
* fit, only more frames or admission control help.
|
||||
*
|
||||
* So the budget is DERIVED, from the host's physical memory, and it scales
|
||||
* with the machine instead of pretending memory is a constant.
|
||||
*
|
||||
* ENGRAM_POOL_FRAMES explicit frame count; 0 = unlimited. Overrides all.
|
||||
* Prefer leaving it unset — a hand-set number is how
|
||||
* this failure happened.
|
||||
* ENGRAM_POOL_MEM_PCT percent of physical RAM to budget (default 60).
|
||||
*
|
||||
* Fallback when RAM cannot be read is 16 GiB worth of frames — the old
|
||||
* default, retained only as a floor for that case.
|
||||
* ──────────────────────────────────────────────────────────────────────────── */
|
||||
#ifndef ENGRAM_POOL_FRAMES_FALLBACK
|
||||
#define ENGRAM_POOL_FRAMES_FALLBACK (1u<<20) /* ~1M frames × 16KiB = 16 GiB */
|
||||
#endif
|
||||
|
||||
static uint64_t pc_available_ram(void); /* fwd — defined with the controller */
|
||||
|
||||
/* Physical RAM in bytes, 0 when it cannot be determined. */
|
||||
static uint64_t pc_physical_ram(void){
|
||||
#if defined(__APPLE__) || defined(__MACH__)
|
||||
uint64_t v = 0; size_t len = sizeof v;
|
||||
int mib[2] = { CTL_HW, HW_MEMSIZE };
|
||||
if (sysctl(mib, 2, &v, &len, NULL, 0) == 0) return v;
|
||||
return 0;
|
||||
#else
|
||||
long pages = sysconf(_SC_PHYS_PAGES);
|
||||
long psz = sysconf(_SC_PAGESIZE);
|
||||
if (pages > 0 && psz > 0) return (uint64_t)pages * (uint64_t)psz;
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
static size_t pc_default_cap(void){
|
||||
unsigned pct = 60;
|
||||
const char* p = getenv("ENGRAM_POOL_MEM_PCT");
|
||||
if (p && *p){ unsigned long v = strtoul(p, NULL, 10); if (v > 0 && v <= 95) pct = (unsigned)v; }
|
||||
uint64_t ram = pc_physical_ram();
|
||||
if (!ram) return ENGRAM_POOL_FRAMES_FALLBACK;
|
||||
uint64_t budget_bytes = (ram / 100u) * pct;
|
||||
/* Never start above what the machine can actually spare right now. */
|
||||
uint64_t avail = pc_available_ram();
|
||||
if (avail > (1ull<<30) && budget_bytes > avail - (1ull<<30)) budget_bytes = avail - (1ull<<30);
|
||||
uint64_t frames = budget_bytes / (uint64_t)STORE_PAGE_SIZE;
|
||||
if (frames < 4096) frames = 4096; /* never absurdly small */
|
||||
return (size_t)frames;
|
||||
}
|
||||
|
||||
static PgCache* pc_new(void){
|
||||
PgCache* c = (PgCache*)calloc(1, sizeof *c);
|
||||
if (!c) return NULL;
|
||||
c->nbuckets = 1024;
|
||||
c->buckets = (PgEnt**)calloc(c->nbuckets, sizeof(PgEnt*));
|
||||
if (!c->buckets){ free(c); return NULL; }
|
||||
c->cap = ENGRAM_POOL_FRAMES_DEFAULT;
|
||||
c->cap = pc_default_cap();
|
||||
c->prefetch = 8;
|
||||
const char* pf = getenv("ENGRAM_POOL_FRAMES");
|
||||
if (pf && *pf){ char* end=NULL; unsigned long long v = strtoull(pf,&end,10); c->cap = (size_t)v; }
|
||||
@@ -1676,6 +1796,241 @@ static void pc_remove(PgCache* c, PgEnt* e){
|
||||
/* Reclaim clean unpinned frames from the LRU end until under budget, or until no
|
||||
* evictable frame remains (a dirty/pinned-heavy pool may transiently exceed cap —
|
||||
* that is the no-steal guarantee, not a bug: the next checkpoint frees them). */
|
||||
/* ── Adaptive budget: close the loop ─────────────────────────────────────────
|
||||
*
|
||||
* THE LESSON THIS ENCODES (2026-08-15). The engram spent hours down while four
|
||||
* separate theories were tried — bad binary, corrupt snapshot, WAL replay,
|
||||
* feature flags — because nothing in the system said what was happening. It
|
||||
* looked identical to "busy loading": 100% CPU, flat RSS, no output. Meanwhile
|
||||
* hits/misses/evictions were ALREADY being counted, right here, and surfaced
|
||||
* nowhere. One eviction-rate number would have ended it in seconds.
|
||||
*
|
||||
* So the counters are not decoration. They are the control signal.
|
||||
*
|
||||
* A budget chosen once — a literal like 65536, or 60% of RAM read at startup —
|
||||
* is a guess about the future. It cannot know the store grew, the working set
|
||||
* shifted, or another process took the memory. The cache already MEASURES the
|
||||
* only thing that matters (am I evicting pages I am about to want again), so it
|
||||
* should act on that measurement instead of on a number someone typed.
|
||||
*
|
||||
* The controller: over a sliding window, if evictions are running at a rate
|
||||
* comparable to accesses AND there is genuine reuse (hits are material), the
|
||||
* working set exceeds the budget — grow it. Growth is geometric, bounded by a
|
||||
* live re-read of physical memory rather than a value cached at boot, so it
|
||||
* tracks the machine instead of a snapshot of it. It never shrinks on its own:
|
||||
* cap is a ceiling, not an allocation, and frames are only ever held because a
|
||||
* real access put them there.
|
||||
*
|
||||
* Two things this deliberately does NOT do: it does not attempt a cleverer
|
||||
* eviction policy (when the working set does not fit, no policy helps — that is
|
||||
* Denning, and it is why "tune the LRU" was never the fix), and it does not stay
|
||||
* silent (pool_report exposes the same numbers outward, so a human or a metric
|
||||
* pipeline sees the pressure the controller is reacting to). */
|
||||
|
||||
/* El's native telemetry, already in the runtime and already exporting to OTLP.
|
||||
* Declared weak so engram_store.c still links standalone; when the runtime is
|
||||
* present (every real build) the pool's interoception flows into the SAME
|
||||
* pipeline as every other metric.
|
||||
*
|
||||
* ONE emission carrying the whole sensed state — not a function per stat, and
|
||||
* not a bespoke per-subsystem endpoint. Both of those are the degenerate case:
|
||||
* they make observability something you hand-write per noun instead of a
|
||||
* uniform mechanism every component already has. el_val_t is int64_t; strings
|
||||
* ride as pointers cast through it (see el_runtime.h's value model). */
|
||||
__attribute__((weak)) int64_t emit_log(int64_t level, int64_t msg, int64_t fields_json);
|
||||
|
||||
static void pc_report(const PgCache* c, const char* cause){
|
||||
if (!emit_log) return; /* runtime not linked: no-op */
|
||||
uint64_t acc = c->hits + c->misses;
|
||||
char f[512];
|
||||
snprintf(f, sizeof f,
|
||||
"{\"component\":\"engram.pool\",\"cause\":\"%s\",\"hits\":%llu,\"misses\":%llu,"
|
||||
"\"evictions\":%llu,\"prefetch_reads\":%llu,\"cap_frames\":%zu,\"resident\":%zu,"
|
||||
"\"dirty\":%zu,\"grows\":%llu,\"hit_rate\":%.4f,\"evict_ratio\":%.4f,"
|
||||
"\"cap_gib\":%.3f,\"resident_gib\":%.3f}",
|
||||
cause,
|
||||
(unsigned long long)c->hits, (unsigned long long)c->misses,
|
||||
(unsigned long long)c->evictions, (unsigned long long)c->prefetch_reads,
|
||||
c->cap, c->count, c->dirty_count, (unsigned long long)c->adapt_grows,
|
||||
acc ? (double)c->hits / (double)acc : 0.0,
|
||||
acc ? (double)c->evictions / (double)acc : 0.0,
|
||||
(double)c->cap * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0),
|
||||
(double)c->count * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0));
|
||||
emit_log((int64_t)(uintptr_t)"warn", (int64_t)(uintptr_t)"engram.pool pressure",
|
||||
(int64_t)(uintptr_t)f);
|
||||
}
|
||||
|
||||
static uint64_t pc_ram_bytes_live(void){ return pc_physical_ram(); }
|
||||
|
||||
/* AVAILABLE memory right now — free + reclaimable, not total.
|
||||
*
|
||||
* Sizing a cache against TOTAL ram is what turns a cache into a memory leak:
|
||||
* total does not shrink when other processes need memory, so a pool that only
|
||||
* grows never notices it is starving the machine it runs on. Availability does.
|
||||
* Returns 0 when undeterminable — callers then refuse to grow, the safe way. */
|
||||
static uint64_t pc_available_ram(void){
|
||||
#if defined(__APPLE__) || defined(__MACH__)
|
||||
/* SWAP AND COMPRESSOR FIRST. free+inactive+purgeable is a LIE under memory
|
||||
* pressure: a machine deep in swap still reports gigabytes "available",
|
||||
* because inactive pages are only reclaimable by evicting them to swap.
|
||||
* Observed 2026-08-15: this returned 9.43 GiB available while vm.swapusage
|
||||
* showed 51.58 of 53.25 GiB used (97% full) and the compressor occupied
|
||||
* 23.7 GiB — the host was thrashing to disk and the pool would have been
|
||||
* cleared to grow into it. Growing a cache in that state is how a guard
|
||||
* becomes the crash.
|
||||
*
|
||||
* So: if swap is nearly spent, report ZERO available. Callers refuse to
|
||||
* grow on 0 and pc_relieve_pressure hands frames back. Only when the
|
||||
* machine is genuinely not swapping do free+inactive+purgeable mean
|
||||
* anything, and even then the compressor's footprint is subtracted because
|
||||
* that RAM is already spoken for. */
|
||||
/* RATE, NOT LEVEL. Swap *level* is a terrible signal: macOS grows swap files
|
||||
* on demand and reclaims them lazily, so "47 of 48 GiB used" can mean the
|
||||
* machine is dying OR that it recovered ten minutes ago and the file has not
|
||||
* been trimmed yet. Measured both states on one host within minutes:
|
||||
* 47.65/48.00 GiB used, 2047 swapouts/s -> genuinely thrashing
|
||||
* 26.67/28.00 GiB used, 0 swapouts/s -> perfectly healthy, 15.6 GiB free
|
||||
* A level check calls the second one an emergency and starves the pool for
|
||||
* no reason. What distinguishes them is whether pages are moving NOW.
|
||||
*
|
||||
* So sample the swapout counter across calls and judge the delta. First call
|
||||
* establishes the baseline and reports no pressure — one sample cannot have
|
||||
* a rate, and guessing from a single reading is the whole mistake. */
|
||||
{
|
||||
static uint64_t prev_swapouts = 0;
|
||||
static time_t prev_t = 0;
|
||||
static int primed = 0;
|
||||
mach_port_t h0 = mach_host_self();
|
||||
vm_statistics64_data_t v0; mach_msg_type_number_t c0 = HOST_VM_INFO64_COUNT;
|
||||
if (host_statistics64(h0, HOST_VM_INFO64, (host_info64_t)&v0, &c0) == KERN_SUCCESS){
|
||||
uint64_t now_out = (uint64_t)v0.swapouts;
|
||||
time_t now_t = time(NULL);
|
||||
if (!primed){ prev_swapouts = now_out; prev_t = now_t; primed = 1; }
|
||||
else if (now_t > prev_t){
|
||||
double per_s = (double)(now_out - prev_swapouts) / (double)(now_t - prev_t);
|
||||
prev_swapouts = now_out; prev_t = now_t;
|
||||
/* Sustained outward paging with nothing coming back is the
|
||||
* signature of a host being pushed into swap. ~200 pages/s is
|
||||
* ~3 MiB/s — well above idle noise, well below the 2000+/s seen
|
||||
* while actually thrashing. */
|
||||
if (per_s > 200.0) return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
mach_port_t host = mach_host_self();
|
||||
vm_size_t page = 0;
|
||||
if (host_page_size(host, &page) != KERN_SUCCESS) return 0;
|
||||
vm_statistics64_data_t vm; mach_msg_type_number_t cnt = HOST_VM_INFO64_COUNT;
|
||||
if (host_statistics64(host, HOST_VM_INFO64, (host_info64_t)&vm, &cnt) != KERN_SUCCESS) return 0;
|
||||
uint64_t avail = (uint64_t)vm.free_count + (uint64_t)vm.inactive_count
|
||||
+ (uint64_t)vm.purgeable_count;
|
||||
/* the compressor is holding real RAM that nobody can hand us */
|
||||
uint64_t compressed = (uint64_t)vm.compressor_page_count;
|
||||
if (compressed >= avail) return 0;
|
||||
avail -= compressed;
|
||||
return avail * (uint64_t)page;
|
||||
#else
|
||||
FILE* f = fopen("/proc/meminfo", "r");
|
||||
if (!f) return 0;
|
||||
char line[256]; unsigned long long kb = 0;
|
||||
while (fgets(line, sizeof line, f))
|
||||
if (sscanf(line, "MemAvailable: %llu kB", &kb) == 1) break;
|
||||
fclose(f);
|
||||
return (uint64_t)kb * 1024ull;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Shrink the budget when the machine is short on memory.
|
||||
*
|
||||
* A pool that can only grow is a leak with extra steps. This is the other half
|
||||
* of the control loop: if free memory drops below a floor, hand frames back.
|
||||
* The resident set follows on the next eviction pass, so the memory is actually
|
||||
* returned rather than merely re-labelled. */
|
||||
#ifndef ENGRAM_POOL_FREE_FLOOR_BYTES
|
||||
#define ENGRAM_POOL_FREE_FLOOR_BYTES (2ull*1024ull*1024ull*1024ull) /* 2 GiB */
|
||||
#endif
|
||||
static int pc_relieve_pressure(PgCache* c){
|
||||
uint64_t avail = pc_available_ram();
|
||||
if (!avail) return 0;
|
||||
uint64_t floor_b = ENGRAM_POOL_FREE_FLOOR_BYTES;
|
||||
const char* fe = getenv("ENGRAM_POOL_FREE_FLOOR_MB");
|
||||
if (fe && *fe){ unsigned long v = strtoul(fe, NULL, 10); if (v) floor_b = (uint64_t)v * 1024ull * 1024ull; }
|
||||
if (avail >= floor_b) return 0; /* machine has room */
|
||||
if (!c->cap || c->count == 0) return 0;
|
||||
size_t was = c->cap;
|
||||
size_t want = c->count - (c->count / 4); /* give back ~25% of what we hold */
|
||||
if (want < 4096) want = 4096;
|
||||
if (want >= c->cap) return 0;
|
||||
c->cap = want;
|
||||
c->adapt_shrinks++;
|
||||
fprintf(stderr,
|
||||
"[engram] memory pressure: %.2f GiB available (floor %.2f GiB) — shrinking pool "
|
||||
"budget %zu -> %zu frames (%.2f -> %.2f GiB) and releasing frames.\n",
|
||||
(double)avail/(1024.0*1024.0*1024.0), (double)floor_b/(1024.0*1024.0*1024.0),
|
||||
was, c->cap,
|
||||
(double)was * (double)STORE_PAGE_SIZE/(1024.0*1024.0*1024.0),
|
||||
(double)c->cap* (double)STORE_PAGE_SIZE/(1024.0*1024.0*1024.0));
|
||||
fflush(stderr);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void pc_adapt_budget(PgCache* c){
|
||||
if (!c->cap) return; /* unlimited: nothing to adapt */
|
||||
if (getenv("ENGRAM_POOL_FRAMES")) return; /* explicit operator override wins */
|
||||
|
||||
/* Sliding window so the signal reflects NOW, not lifetime totals. */
|
||||
uint64_t acc = c->hits + c->misses;
|
||||
if (acc - c->adapt_last_acc < 100000) return;
|
||||
uint64_t d_acc = acc - c->adapt_last_acc;
|
||||
uint64_t d_evic = c->evictions - c->adapt_last_evic;
|
||||
uint64_t d_hits = c->hits - c->adapt_last_hits;
|
||||
c->adapt_last_acc = acc; c->adapt_last_evic = c->evictions; c->adapt_last_hits = c->hits;
|
||||
|
||||
/* Pressure = evicting on a large fraction of accesses while still getting
|
||||
* real reuse. Evictions alone are normal (a scan evicts and never returns);
|
||||
* evictions WITH reuse means the working set genuinely does not fit. */
|
||||
if (d_evic * 3 < d_acc) return; /* < 1/3 of accesses evict: healthy */
|
||||
if (d_hits * 4 < d_acc) return; /* little reuse: a scan, not pressure */
|
||||
|
||||
/* Growth is bounded by what is AVAILABLE, never by total RAM. Sizing against
|
||||
* total is how a cache starves its own host: total never shrinks when other
|
||||
* processes need memory. Refuse to grow at all if availability is unknown or
|
||||
* already under the floor — a cache is never worth swapping the machine. */
|
||||
uint64_t avail = pc_available_ram();
|
||||
uint64_t floor_b = ENGRAM_POOL_FREE_FLOOR_BYTES;
|
||||
const char* fe = getenv("ENGRAM_POOL_FREE_FLOOR_MB");
|
||||
if (fe && *fe){ unsigned long v = strtoul(fe, NULL, 10); if (v) floor_b = (uint64_t)v * 1024ull * 1024ull; }
|
||||
if (!avail || avail <= floor_b) return;
|
||||
uint64_t ram = pc_ram_bytes_live();
|
||||
if (!ram) return;
|
||||
unsigned pct = 50; /* ceiling as a share of TOTAL, belt-and-braces */
|
||||
const char* mp = getenv("ENGRAM_POOL_MAX_PCT");
|
||||
if (mp && *mp){ unsigned long v = strtoul(mp, NULL, 10); if (v > 0 && v <= 95) pct = (unsigned)v; }
|
||||
size_t ceiling = (size_t)(((ram / 100u) * pct) / (uint64_t)STORE_PAGE_SIZE);
|
||||
/* and never grow into the free-memory floor */
|
||||
uint64_t headroom = avail - floor_b;
|
||||
size_t ceil_avail = (size_t)((c->count * (uint64_t)STORE_PAGE_SIZE + headroom)
|
||||
/ (uint64_t)STORE_PAGE_SIZE);
|
||||
if (ceil_avail < ceiling) ceiling = ceil_avail;
|
||||
if (c->cap >= ceiling) return; /* already at the machine's limit */
|
||||
|
||||
size_t want = c->cap + (c->cap / 2) + 1; /* ×1.5, geometric */
|
||||
if (want > ceiling) want = ceiling;
|
||||
size_t was = c->cap;
|
||||
c->cap = want;
|
||||
c->adapt_grows++;
|
||||
/* Emit the sensed state, not just the reaction. These are the numbers that
|
||||
* would have diagnosed 2026-08-15 in seconds instead of hours. */
|
||||
pc_report(c, "budget-grow");
|
||||
fprintf(stderr,
|
||||
"[engram] pool pressure: %llu evictions / %llu accesses (%llu hits) at %zu frames "
|
||||
"(%.2f GiB) — working set exceeds budget; growing to %zu frames (%.2f GiB).\n",
|
||||
(unsigned long long)d_evic, (unsigned long long)d_acc, (unsigned long long)d_hits,
|
||||
was, (double)was * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0),
|
||||
c->cap,(double)c->cap * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0));
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
static void pc_evict_to_budget(PgCache* c){
|
||||
if (!c->cap) return; /* unlimited */
|
||||
while (c->count > c->cap){
|
||||
@@ -1687,6 +2042,7 @@ static void pc_evict_to_budget(PgCache* c){
|
||||
}
|
||||
if (!freed) break; /* nothing evictable — allowed to exceed cap */
|
||||
}
|
||||
if (!pc_relieve_pressure(c)) pc_adapt_budget(c);
|
||||
}
|
||||
|
||||
static PgEnt* pc_get(EngramPagedStore* s, uint64_t id){
|
||||
@@ -2377,6 +2733,18 @@ int store_put_node(EngramPagedStore* s, const StoreNode* n){
|
||||
int store_put_edge(EngramPagedStore* s, const StoreEdge* e){
|
||||
if (!s || !e || !e->id || !e->from_id || !e->to_id) return -1;
|
||||
STORE_GUARD(s);
|
||||
/* Durable-hash write barrier — mirrors store_put_node. An unchanged edge
|
||||
* costs one hash compare and zero page I/O; without this, checkpointing
|
||||
* re-probed every edge against the paged store (max_page_lsn_for_id →
|
||||
* page_read), turning a routine checkpoint into a full-store walk. */
|
||||
uint64_t dh_h = 0;
|
||||
if (s->barrier_on){
|
||||
dh_h = dh_edge_hash(e);
|
||||
if (dh_get(s->dh, e->id) == dh_h){
|
||||
s->stat_barrier_skips++;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
uint64_t L = ++s->next_lsn;
|
||||
if (s->wal){
|
||||
size_t blen; uint8_t* body = edge_serialize(e, &blen);
|
||||
@@ -2386,6 +2754,10 @@ int store_put_edge(EngramPagedStore* s, const StoreEdge* e){
|
||||
if (wr != 0) return -1;
|
||||
}
|
||||
int r = apply_edge_put(s, e, L);
|
||||
if (r == 0 && s->barrier_on){
|
||||
if (!dh_h) dh_h = dh_edge_hash(e);
|
||||
dh_set(s->dh, e->id, dh_h); /* remember the now-persisted durable hash */
|
||||
}
|
||||
ckpt_maybe(s);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,6 @@ struct VIndex {
|
||||
|
||||
int entry; /* entry-point element index, -1 if empty */
|
||||
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 ────────────────────────────────────────────────────────── */
|
||||
@@ -166,37 +161,63 @@ static Pair heap_pop(Heap* h, int is_max){
|
||||
return top;
|
||||
}
|
||||
|
||||
/* ── visited set ──────────────────────────────────────────────────────────── */
|
||||
static int visited_ensure(VIndex* ix){
|
||||
if (ix->visited_cap >= ix->cap && ix->visited) return 0;
|
||||
size_t nc = ix->cap ? ix->cap : 16;
|
||||
uint32_t* nv = (uint32_t*)realloc(ix->visited, nc*sizeof(uint32_t));
|
||||
if (!nv) return -1;
|
||||
if (nc > ix->visited_cap) memset(nv + ix->visited_cap, 0, (nc-ix->visited_cap)*sizeof(uint32_t));
|
||||
ix->visited = nv; ix->visited_cap = nc;
|
||||
/* ── visited set — owned by the CALL FRAME, never by the index ──────────────
|
||||
* This buffer is per-TRAVERSAL scratch. It used to live in struct VIndex as an
|
||||
* allocation optimisation, which made every traversal a write to shared state:
|
||||
* two concurrent vindex_search calls stamped each other's epoch and then walked
|
||||
* each other's marks, so even two pure READS corrupted the traversal (measured
|
||||
* 2026-08-16: TSan data race at visited_reset, reached from vindex_search on one
|
||||
* thread and vindex_insert on another; downstream SIGSEGV dereferencing a bogus
|
||||
* 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;
|
||||
}
|
||||
static inline void visited_reset(VIndex* ix){
|
||||
if (++ix->visit_epoch == 0){ /* wrapped: clear all */
|
||||
memset(ix->visited, 0, ix->visited_cap*sizeof(uint32_t));
|
||||
ix->visit_epoch = 1;
|
||||
static void visit_dispose(VVisit* v){ free(v->mark); v->mark = NULL; v->cap = 0; }
|
||||
static inline void visit_reset(VVisit* v){
|
||||
if (++v->epoch == 0){ /* wrapped: clear all */
|
||||
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 void mark_visited(VIndex* ix, int e){ 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(VVisit* v, int e){ v->mark[e]=v->epoch; }
|
||||
|
||||
/* ── search one layer (Algorithm 2): best-first, ef-bounded ───────────────── */
|
||||
/* Returns results as an unsorted Heap (max-heap on distance, size<=ef). Caller
|
||||
* 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*/){
|
||||
Heap cand = {0,0,0}; /* min-heap: nearest to expand */
|
||||
res->a=NULL; res->n=0; res->cap=0;
|
||||
visited_reset(ix);
|
||||
visit_reset(vis);
|
||||
for (int i=0;i<neps;i++){
|
||||
int e = eps[i];
|
||||
if (is_visited(ix,e)) continue;
|
||||
mark_visited(ix,e);
|
||||
if (is_visited(vis,e)) continue;
|
||||
mark_visited(vis,e);
|
||||
float d = vdist(ix, q, ix->elems[e].vec);
|
||||
Pair p = { d, e };
|
||||
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];
|
||||
for (int i=0;i<nl->count;i++){
|
||||
int e = nl->ids[i];
|
||||
if (is_visited(ix,e)) continue;
|
||||
mark_visited(ix,e);
|
||||
if (is_visited(vis,e)) continue;
|
||||
mark_visited(vis,e);
|
||||
float d = vdist(ix, q, ix->elems[e].vec);
|
||||
if (res->n < ef || d < res->a[0].d){
|
||||
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;
|
||||
* backfill from the pruned set (nearest first) to reach M for connectivity.
|
||||
* 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 */
|
||||
/* sort W ascending by (dist,elem) — deterministic. */
|
||||
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));
|
||||
if (!ne) return -1;
|
||||
ix->elems = ne; ix->cap = nc;
|
||||
return visited_ensure(ix);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 L = ix->max_level;
|
||||
/* greedy descent through layers above `level` to refine the entry point. */
|
||||
for (int lc = L; lc > level; lc--){
|
||||
Heap r = {0,0,0};
|
||||
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;
|
||||
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);
|
||||
@@ -329,7 +356,7 @@ int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){
|
||||
for (int lc = start; lc >= 0; lc--){
|
||||
int Mmax = (lc==0) ? ix->M0 : ix->M;
|
||||
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));
|
||||
if (!chosen){ free(W.a); rc=-1; break; }
|
||||
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:
|
||||
free(eps_owned);
|
||||
visit_dispose(&vis);
|
||||
if (rc) return -1;
|
||||
if (level > ix->max_level){ ix->max_level = level; ix->entry = cur; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 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){
|
||||
if (!ix || !query || k <= 0) return -1;
|
||||
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);
|
||||
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;
|
||||
for (int lc = ix->max_level; lc > 0; lc--){
|
||||
Heap r = {0,0,0};
|
||||
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;
|
||||
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; }
|
||||
@@ -385,7 +420,8 @@ int vindex_search(VIndex* ix, const float* query, int k, int ef_search,
|
||||
}
|
||||
Heap res = {0,0,0};
|
||||
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);
|
||||
|
||||
/* 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->entry = -1;
|
||||
ix->max_level = 0;
|
||||
ix->visit_epoch = 0;
|
||||
return ix;
|
||||
}
|
||||
|
||||
@@ -432,7 +467,6 @@ void vindex_free(VIndex* ix){
|
||||
free(e->vec);
|
||||
}
|
||||
free(ix->elems);
|
||||
free(ix->visited);
|
||||
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.
|
||||
* ef_search — search-time candidate width; larger == higher recall, slower.
|
||||
* Pass <=0 for VINDEX_DEFAULT_EF_SEARCH. Internally clamped to >=k.
|
||||
* Returns the number of results written, or <0 on error. */
|
||||
int vindex_search(VIndex* idx, const float* query, int k, int ef_search,
|
||||
* Returns the number of results written, or <0 on error.
|
||||
*
|
||||
* `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);
|
||||
|
||||
/* Number of vectors currently indexed. */
|
||||
|
||||
+169
-5
@@ -29,6 +29,8 @@ This section is the **single source of truth** for what works and what is planne
|
||||
- Lexer: keywords, identifiers, integer/float/string/bool literals, operators below.
|
||||
- Parser: `let`, `return`, `fn`, `type`, `enum`, `import`, `from … import`, `while`, `for`, `if/else if/else`, `match`, `@decorator`, array/map literals, all listed operators, function calls, field access, index access, unary `!`/`-`, postfix `?`.
|
||||
- Codegen: function definitions, top-level `main()`, all expression forms above, control flow, decorator-as-AST-attachment.
|
||||
- Boundary seam: decorator arguments and stacking; VBD role enforcement via `#error`; `engram_boundary_beat` auto-emit at `@manager`/`@accessor` entry; `@route` dispatch tables (Section 9).
|
||||
- Program-level declarative blocks: `cgi`, `service`, and `program` — the last carrying process identity and configuration (Section 18).
|
||||
- C runtime: I/O, string operations, integer math, lists, maps, filesystem, command-line args, basic `json_get` substring lookup.
|
||||
|
||||
### Planned (in flight)
|
||||
@@ -37,7 +39,7 @@ This section is the **single source of truth** for what works and what is planne
|
||||
- **Match codegen.** Currently parsed; codegen does not emit. Adding `({ ... })` statement-expression emission.
|
||||
- **`?` propagation.** Currently no-op. Adding nil-propagation semantics.
|
||||
- **`cgi` block parsing.** Currently lexed (`cgi` is a keyword) but not parsed as a statement. Adding `parse_cgi_block` and codegen of `el_cgi_init` at the head of `main()`.
|
||||
- **VBD role enforcement.** `@manager`/`@engine`/`@accessor` are accepted as decorators but not enforced. Adding compile-time check that `dharma_emit`/`dharma_field` only appear inside `@manager` functions.
|
||||
- **Boundary epilogues.** The decorator seam injects a prologue only. Adding prologue/epilogue wrapping, the prerequisite for durability-as-an-effect (Section 19.1).
|
||||
- **`vessel` keyword.** Replaces `package` in manifests. Adding to lexer.
|
||||
- **Real `engram_*` runtime.** Currently stub. Adding in-process graph store with spreading activation, Hebbian strengthening, and disk persistence — see Section 16.4.
|
||||
- **Real `dharma_*` runtime.** Currently stub. Adding network transport, channel registry, identity resolution.
|
||||
@@ -96,8 +98,10 @@ The following words are reserved and cannot be used as identifiers. Each row not
|
||||
| `while` | yes | Loop |
|
||||
| `import` / `from` / `as` | yes | Module import |
|
||||
| `true` / `false` | yes | Bool literals |
|
||||
| `cgi` | planned | Top-level CGI declaration block |
|
||||
| `manager` / `engine` / `accessor` | as decorators | VBD role marker on `fn` (enforcement planned) |
|
||||
| `cgi` | yes | Top-level CGI declaration block |
|
||||
| `service` | yes | Top-level capability-bounded declaration block |
|
||||
| `program` | yes | Top-level cross-cutting declaration block (Section 18) |
|
||||
| `manager` / `engine` / `accessor` | as decorators | VBD role marker on `fn`; enforcement and boundary auto-emit are live (Section 9) |
|
||||
| `vessel` | planned | Manifest declaration (replaces `package`) |
|
||||
| `activate` / `where` | planned | Spreading-activation construct |
|
||||
| `sealed` | planned | Capability scope block |
|
||||
@@ -446,9 +450,21 @@ Parsed. The module name is recorded; the brace-list is consumed. Both forms prod
|
||||
fn handle(channel: String, msg: String) -> Void { … }
|
||||
```
|
||||
|
||||
The `@` token followed by an identifier attaches a decorator name to the next `FnDef`. Decorators with structural meaning today: none. Planned enforcement (Section 16.2): VBD roles `@manager`, `@engine`, `@accessor`.
|
||||
The `@` token followed by an identifier attaches a decorator to the next `FnDef`.
|
||||
|
||||
Non-VBD decorators are accepted and ignored.
|
||||
**Decorators take arguments and they stack.** `@route("/p", "GET") @manager fn f()` attaches both to `f` as a `decorators` list of `{name, args}` records, topmost-first. Arguments are string literals only.
|
||||
|
||||
**Decorators have structural meaning today.** This is El's function-level boundary seam — the mechanism by which a cross-cutting concern is handled *at the boundary* rather than by a convention repeated at every call site:
|
||||
|
||||
| Decorator | Structural effect |
|
||||
|---|---|
|
||||
| `@manager` | Permits calls to `dharma_emit` / `dharma_field`. Calling either from a non-`@manager` fn emits a `#error` into the generated C — a compile-time failure, not a lint. |
|
||||
| `@manager`, `@accessor` | Codegen injects one call to `engram_boundary_beat(<fn name>)` at function entry. The decorated op self-reports (chrono tick, afferent counter, self-activity strengthen, dharma bus event) with **zero** hand-written instrumentation in its body. |
|
||||
| `@route(path, method, …)` | Records a route into a generated dispatch table. |
|
||||
|
||||
Decorators with no registered meaning are accepted and ignored.
|
||||
|
||||
**Limits of the seam, as it stands.** The injection is a *prologue only* — there is no epilogue, no wrapping of the call, and no way for a decorator to run code after the body returns. The injected callee is a fixed builtin chosen by the compiler, not derived from the decorator name or its arguments. Section 19 depends on lifting exactly these two limits.
|
||||
|
||||
---
|
||||
|
||||
@@ -1088,4 +1104,152 @@ The next minor version closes the implementation gaps named in this document. Tr
|
||||
|
||||
---
|
||||
|
||||
## 18. The Program Block — cross-cutting concerns [implemented]
|
||||
|
||||
### 18.0 Why this exists
|
||||
|
||||
A cross-cutting concern is one that belongs to the *process*, not to any function in it: only one of me may run; this is what my configuration is; every mutation must be durable; every request must be authorized.
|
||||
|
||||
El's units of encapsulation are the function and the module. Neither can hold a concern like that. So each one had been expressed the only way it could be — as a **convention**: *call this at every site.* Conventions of that shape do not hold. They are not enforced by anything, they are invisible in review, and they fail silently at the one site somebody forgot.
|
||||
|
||||
Measured in this codebase before this section existed:
|
||||
|
||||
| Concern | State | What the convention was |
|
||||
|---|---|---|
|
||||
| process identity | **zero** guards anywhere — no pidfile, no lock, no already-running check, at any layer | "check nothing is already running first" |
|
||||
| configuration | **20** distinct environment variables in one program, each with its default written inline at the read site | "remember the right default here" |
|
||||
| durability | **62** `persist_*` / `engram_save` / `wal_*` / `checkpoint` call sites | "after you mutate, remember to persist" |
|
||||
| request auth | **10** per-route `_auth` checks | "check the token in this handler too" |
|
||||
|
||||
These are not four problems. They are one absence, four times.
|
||||
|
||||
That the convention form fails is observed, not predicted. Process identity failed three times in a single day: twice, two engram processes ran simultaneously against the same data directory; twice, a stale binary held a port and answered probes while a fresh build was believed to be under test, because `pkill -f` had silently failed to match its argv — which nearly produced a false "the fix does not work" conclusion. Configuration failed structurally: `ENGRAM_DATA_DIR` was read at six sites, five of them dead bindings, and the sixth defaulted to `/tmp/engram` — contradicting the canonical resolver's `$HOME/.neuron/engram` and landing a pre-destructive safety backup on ephemeral storage.
|
||||
|
||||
The `program` block is where a concern of this shape is declared once and enforced by the compiler at the process boundary.
|
||||
|
||||
### 18.1 Syntax
|
||||
|
||||
```
|
||||
program "engram" {
|
||||
singleton: "engram"
|
||||
env ENGRAM_BIND: String = ":8742"
|
||||
env GUIDE_PORT: Int = "8771"
|
||||
env ENGRAM_API_KEY: String required
|
||||
}
|
||||
```
|
||||
|
||||
At most one `program` block per program. It composes with `cgi` and `service` — those declare what a program *may do*; `program` declares what a program *is*.
|
||||
|
||||
Grammar:
|
||||
|
||||
```ebnf
|
||||
program_block = "program" string "{" { program_field } "}" ;
|
||||
program_field = singleton_field | env_field ;
|
||||
singleton_field = "singleton" ":" string [ "," ] ;
|
||||
env_field = "env" ident ":" type
|
||||
[ "=" string ] [ "required" ] [ "," ] ;
|
||||
```
|
||||
|
||||
`singleton` and `env` are **not** reserved words. They are read as identifier token values by the block's own parse loop, so they remain usable as ordinary identifiers everywhere else. `program` is the only keyword this section adds.
|
||||
|
||||
### 18.2 Process identity — `singleton`
|
||||
|
||||
`singleton: "id"` compiles to an `el_singleton_acquire("id")` call injected as the **first statement of `main()`**, before any user statement runs.
|
||||
|
||||
The runtime takes an exclusive non-blocking `flock` on `<dir>/el-singleton-<id>.lock`, where `<dir>` is `$EL_SINGLETON_DIR`, else `$TMPDIR`, else `/tmp`. On success it writes its pid and holds the descriptor open for the life of the process. On contention it **refuses to start**: it reports the holder's pid, names the lock file, and exits 1.
|
||||
|
||||
Two properties are deliberate:
|
||||
|
||||
- **It is a lock, not a pidfile.** The kernel releases an `flock` when the owning process dies — including on `SIGKILL` and on crash. There is therefore no stale-lock state, and so no "delete the lock file to get unstuck" recovery ritual. Such a ritual would itself be a convention, which is the thing this section exists to remove.
|
||||
- **It reports the holder's pid.** "Already running" is not actionable. A pid is. This is the direct answer to the observed failure where a stale process survived a `pkill` and went on answering probes.
|
||||
|
||||
Refusal is loud and total. It is not a warning, and the program does not continue degraded. This matters more than it looks: today a second engram whose `bind()` fails merely *returns* from `http_serve` — after it has already replayed the WAL and written boot-time backup files — and then exits **0**, indistinguishable from a clean run. `singleton` refuses before the first side effect.
|
||||
|
||||
### 18.3 Configuration — `env`
|
||||
|
||||
Each `env` entry declares one configuration variable: its name, its type (`Int` or `String`), and either a default or `required`.
|
||||
|
||||
Resolution happens once, at startup, in declaration order: **the environment wins; the declaration supplies the fallback.** Then `el_config_validate` checks the whole schema and reports *every* problem at once before exiting — a startup that fails one variable at a time costs one restart per variable.
|
||||
|
||||
Values are read with `config("NAME")`, which returns a `String`.
|
||||
|
||||
The enforcement that makes the declaration real: **once a program block exists, `config("X")` for an undeclared `X` is a fatal error.** Without that, the schema would be advisory, and an advisory schema is just another convention. Programs with no `program` block are unaffected — `config()` falls back to a plain environment read, so migration is incremental and per-program.
|
||||
|
||||
The point is not that configuration is now centralized. It is that **a default is no longer a decision made at a read site.** A read site cannot disagree with another read site about what a variable means, because a read site no longer says.
|
||||
|
||||
### 18.4 What is deliberately not declared here
|
||||
|
||||
Some values look like configuration and are not. `ENGRAM_DATA_DIR` already has a single owner — `engram_resolve_data_dir()`, which resolves it, creates the directory, and fails loud rather than silently persisting to an ephemeral path. Declaring it in the `program` block as well would give it two owners that can disagree, recreating the precise defect this section removes.
|
||||
|
||||
The rule: **a variable belongs in the program block when the block would be its only owner.** If a resolver already owns it, leave it there.
|
||||
|
||||
`HOME` is likewise not configuration. It is an environment fact, and stays a raw `env()` read.
|
||||
|
||||
---
|
||||
|
||||
## 19. Boundary Effects — durability and request authorization [design only, not implemented]
|
||||
|
||||
Sections 19.1 and 19.2 specify the two remaining concerns from the table in 18.0. Both are **designed and deliberately unimplemented.** The reason is stated in 19.3 and it is not difficulty.
|
||||
|
||||
### 19.1 Durability as an epilogue effect
|
||||
|
||||
**The defect.** 62 call sites carry the convention *"after you mutate, remember to persist."* This is structurally the same defect as the index bug being fixed elsewhere in this tree — *"after you append, remember to index"* — which failed at **9 of 9** sites. A convention that failed at 100% of its sites is the strongest available evidence about what this class of convention is worth.
|
||||
|
||||
**Why the existing seam cannot express it.** §9's injection is a prologue. Durability is inherently an *epilogue*: persist after the mutation succeeds, and not at all if it threw. The seam has no epilogue.
|
||||
|
||||
**Design.** Extend the decorator seam from prologue-only to prologue/epilogue, then declare durability as an effect on the mutating function:
|
||||
|
||||
```
|
||||
@durable("engram")
|
||||
fn engram_write_node(id: String, body: String) -> Bool { … }
|
||||
```
|
||||
|
||||
Codegen wraps rather than prefixes:
|
||||
|
||||
```c
|
||||
el_val_t engram_write_node(el_val_t id, el_val_t body) {
|
||||
el_effect_enter(EL_STR("durable"), EL_STR("engram"));
|
||||
el_val_t __r = /* original body */;
|
||||
el_effect_exit(EL_STR("durable"), EL_STR("engram"), __r);
|
||||
return __r;
|
||||
}
|
||||
```
|
||||
|
||||
`el_effect_exit` is where the persist happens, and it is the only place it happens. Two properties follow that the 62 hand-written sites cannot have:
|
||||
|
||||
- **Coalescing.** The epilogue is a single choke point, so N mutations inside one request can produce one fsync instead of N. The hand-written form cannot coalesce, because no site knows about the others.
|
||||
- **Failure is not silent.** A persist that fails inside `el_effect_exit` can force the mutation's return value to failure. A forgotten `persist_*` call cannot fail — it simply does not happen, which is exactly why the defect is invisible.
|
||||
|
||||
**Enforcement, and this is the part that actually fixes it.** Mirroring §9's `#error` for `dharma_emit`: a function that calls a mutating primitive without carrying `@durable` is a **compile error**. Otherwise this is a 63rd thing to remember rather than a replacement for 62.
|
||||
|
||||
### 19.2 Request authorization as a route effect
|
||||
|
||||
**The defect.** 10 per-route `_auth` checks. The HTTP layer has no concept of authorization, so a new route is unauthenticated by default and silently so — the failure mode is a route that forgot, and nothing anywhere reports it.
|
||||
|
||||
**Design.** Authorization becomes an argument to the `@route` decorator, which already takes arguments and already builds a dispatch table:
|
||||
|
||||
```
|
||||
@route("/api/write", "POST", auth: "required")
|
||||
fn route_write(body: String) -> String { … }
|
||||
```
|
||||
|
||||
The generated dispatcher performs the check **before** dispatch, so an unauthorized request never reaches the handler and the handler contains no auth code at all.
|
||||
|
||||
The default must be `required`. A route that says nothing gets authorization; opening one up takes an explicit `auth: "public"`. Defaulting to public preserves the current failure mode exactly — forgetting stays silent — and a default that preserves the defect is not a fix.
|
||||
|
||||
Route inventory falls out for free: the dispatch table already exists, so the compiler can emit the full route/auth matrix and make "which routes are public" a fact that is read rather than audited.
|
||||
|
||||
### 19.3 Why these are not implemented
|
||||
|
||||
Not difficulty — **collision**. Both land squarely in regions two other agents hold right now:
|
||||
|
||||
- **Durability** requires changing the mutation and persist paths in `lang/runtime/el_runtime.c` and `engram/src/server.el` — the same files and the same read/write paths being restructured by concurrent work on VIndex read-path mutation and memory ownership, and on geometry-as-an-el-value and `transduce`.
|
||||
- **Request auth** requires changing route dispatch in `engram/src/server.el`, which the geometry/`transduce` work is actively reshaping.
|
||||
|
||||
Implementing either now would mean editing files under concurrent modification and resolving conflicts in exactly the paths whose correctness is currently under repair. The designs are recorded here so the work is not lost, and so that whoever lands them does so against a settled tree.
|
||||
|
||||
The prerequisite for 19.1 is the same in both cases: **lift the §9 seam from prologue-only to prologue/epilogue.** That change is independent of both collisions and can land first.
|
||||
|
||||
---
|
||||
|
||||
End of specification.
|
||||
|
||||
@@ -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,184 @@
|
||||
# Swarm + CCR + Work-Tracking — Neuron's bounded parallel execution, in native El
|
||||
|
||||
Bounded parallel agent execution on El's **native** concurrency — no external
|
||||
orchestrator. Grounded directly in two of Will's frameworks:
|
||||
|
||||
- **Swarm Architecture** (*Bounded Parallel Agent Execution*, Mar 2026)
|
||||
- **Compiled Context Runtime / CCR** (*Process-Driven Agent Execution with
|
||||
Unbounded Local Memory*, Mar 2026)
|
||||
|
||||
A swarm is a **coordinator** (the main thread) that mints a correlation identity,
|
||||
compiles a **bounded per-worker context (CCR)**, dispatches workers as **native
|
||||
pthreads** (`thread.el` `spawn`/`join`), tracks every unit of work durably, and
|
||||
**converges** results before returning control to the parent step.
|
||||
|
||||
```
|
||||
Parent step
|
||||
└─ swarm_run(blueprint, knowledge_refs, inputs, config)
|
||||
fan-out ──▶ worker_1 (CCR ctx_1) ─┐ native
|
||||
worker_2 (CCR ctx_2) ─┤ pthreads,
|
||||
worker_k (CCR ctx_k) ─┘ bounded by `concurrency`
|
||||
converge ─▶ collect | merge | vote | reduce ──▶ merged result
|
||||
```
|
||||
|
||||
## Why it runs on El natively
|
||||
|
||||
El is natively agentic. This capability composes El's shipped primitives — it
|
||||
adds no bespoke runtime:
|
||||
|
||||
| Primitive | Source | Role in the swarm |
|
||||
|-----------|--------|-------------------|
|
||||
| `spawn(fn,arg)` / `join(tid)` | `runtime/thread.el` → `__thread_create` (pthread + dlsym) | fan-out / rejoin |
|
||||
| `parallel_map`, `with_mutex` | `runtime/thread.el` | reference concurrency patterns |
|
||||
| Go-style channels | `runtime/channel.el` → `__channel_*` | available for vertical event streams |
|
||||
| `engram_*`, `http_*`, `fs_*`, `json_*` | `el_runtime.c` builtins | retrieval, tracking, I/O |
|
||||
|
||||
Every El fn compiles to a global C symbol, so any top-level `(String)->String`
|
||||
fn is directly threadable — the worker entry is exactly such a fn.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Framework grounding | What it does |
|
||||
|------|--------------------|--------------|
|
||||
| `worktrack.el` | Swarm §6 (correlation IDs, audit) | Durable, single-writer **JSONL journal** keyed by correlation ID; reconstructable status report; opt-in engram mirror (`SWARM_MIRROR=1`). |
|
||||
| `containment.el` | Swarm §3 + the single-writer invariant | Scope tokens w/ capabilities; **Rule 1** (no join), **Rule 2** (no open), **Rule 3** (no lateral edge), **Rule 4** (engram-write is @manager-only, by capability) enforced as checks. |
|
||||
| `ccr.el` | CCR §5 + Swarm §9.3 | Per-worker **Compiled Context Routing**: retrieve → scope → compact into a **bounded, minimal** package. The compiled-context boundary *is* the security boundary. |
|
||||
| `primitives.el` | CCR §2 (Five Primitives) | `attend / think / intend / act / learn` seam the swarm composes over. Engram-backed; explicit binding point for the API-surface reshape. |
|
||||
| `swarm.el` | Swarm §2, §4, §5 | The coordinator: fan-out/converge on native threads, bounded concurrency, four convergence strategies, integer failure threshold, full tracking. |
|
||||
|
||||
## Invariant: only the orchestrator mutates global engram state
|
||||
|
||||
**Only the orchestrator (@manager) writes to the engram / mutates global state.
|
||||
Workers are read-only against the full engram and may write only their own local
|
||||
geometry (their returned result + the journal). A worker is STRUCTURALLY UNABLE
|
||||
to mutate global engram state.**
|
||||
|
||||
This is **Rule 4** — an **authority gate, not a health gate**. Scope tokens carry
|
||||
a capability set: the orchestrator's token holds `engram:write` + `dharma:emit`
|
||||
(@manager-only, the VBD rule that only the manager mutates global state); a
|
||||
worker's token holds **only** `engram:read`. Every engram mutation
|
||||
(`op_write`/`op_relate`/`op_supersede` → `POST /api/nodes`, `/api/edges`,
|
||||
`DELETE`) flows through `swarm_engram_write`, which checks the caller's capability
|
||||
via the **same scope-token mechanism as the live Rule-2 denial** and rejects any
|
||||
worker **before any HTTP is issued**. Capability is fixed at mint time and cannot
|
||||
be acquired at runtime — so the guarantee holds regardless of engram health
|
||||
(distinct from the `SWARM_WRITE_HEALTHY` *health* gate).
|
||||
|
||||
The **curated merge is the only write path**: workers return geometry; the
|
||||
orchestrator, and only the orchestrator, commits the approved/verified geometry
|
||||
back (`commit=1`). Workers keep full-engram **read** access (`op_think`/`op_read`).
|
||||
|
||||
Proven in `harness_real_cognition.el` (§G): a worker `swarm_engram_write` is
|
||||
DENIED by capability with no node created and the violation journalled; the
|
||||
orchestrator passes the gate as the sole authorized writer.
|
||||
|
||||
## Containment → distribution
|
||||
|
||||
The three containment rules make workers **location-independent** (Swarm §9): a
|
||||
worker reads only its compiled context, shares no state with siblings, and its
|
||||
only outward edge is the returned result. The same coordinator can run workers
|
||||
as local threads today or dispatch them across machines later — the mechanism is
|
||||
identical; only the topology changes. Enforced here:
|
||||
|
||||
- **Rule 2** — `swarm_run` rejects any swarm opened under a worker token.
|
||||
- **Rules 1 + 3** — each worker gets a *closed* worker token; the coordinator is
|
||||
the only journal writer, so workers share no mutable state.
|
||||
|
||||
## Usage
|
||||
|
||||
```el
|
||||
// one process step fans out; results converge before the next step
|
||||
let inputs: String = "[\"billing\",\"payments\",\"ledger\"]"
|
||||
let refs: String = "[\"Volatility-Based Decomposition\"]" // CCR knowledge refs
|
||||
let cfg: String = "{\"concurrency\":\"4\",\"strategy\":\"collect\",\"min_success_ratio\":\"1.0\"}"
|
||||
let result: String = swarm_run("analyze_item", refs, inputs, cfg)
|
||||
// result: { corr_id, status, merged, report }
|
||||
```
|
||||
|
||||
Build any program that uses the swarm:
|
||||
|
||||
```bash
|
||||
lang/swarm/build.sh myprog.el ./myprog # concat + elc + cc (el_runtime.c)
|
||||
```
|
||||
|
||||
Config keys: `concurrency` (max workers at once), `strategy`
|
||||
(`collect|merge|vote|reduce`), `min_success_ratio` (decimal string, e.g. `0.8`),
|
||||
`caller_token` (containment). Env: `SWARM_TRACK_DIR` (journal dir),
|
||||
`CCR_TOKEN_BUDGET`, `ENGRAM_URL`/`ENGRAM_API_KEY` (retrieval + mirror),
|
||||
`SWARM_MIRROR=1`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
lang/swarm/build.sh lang/swarm/tests/test_swarm.el /tmp/t && SWARM_TRACK_DIR=/tmp/trk /tmp/t # 12/12
|
||||
lang/swarm/build.sh lang/swarm/tests/test_convergence.el /tmp/c && SWARM_TRACK_DIR=/tmp/trk /tmp/c # 8/8
|
||||
# integration against an isolated engram clone (never live):
|
||||
source <sandbox>/.nsbx-env
|
||||
lang/swarm/build.sh lang/swarm/tests/integ_engram.el /tmp/i && /tmp/i
|
||||
```
|
||||
|
||||
## Local-swarm integration harness (the one flip)
|
||||
|
||||
`tests/harness_local_swarm.el` proves the **full local-swarm mechanics today** on
|
||||
the isolated clone with the primitive seam pointed at the hermetic stub — 17/17
|
||||
green: 8 native-thread workers at concurrency 4, reduce + vote convergence, CCR
|
||||
scoping + non-leak, all three containment rules (incl. live Rule-2 denial),
|
||||
durable work-tracking, and **afferent telemetry** observed by the @manager.
|
||||
|
||||
Binding to the reshape's decorated primitives is **one flip and a run**:
|
||||
|
||||
```
|
||||
# in primitive_binding.el — change one line each:
|
||||
fn bound_think(ctx, instruction) { return think(ctx, instruction) } # decorated, dharma bus
|
||||
# then:
|
||||
SWARM_PRIMITIVE_SEAM=decorated lang/swarm/build.sh tests/harness_local_swarm.el ./h && ./h
|
||||
```
|
||||
|
||||
Nothing else in the swarm changes. `primitive_seam.el` (`seam_think/attend/learn`)
|
||||
already routes every worker primitive call through this one switch, and the same
|
||||
harness runs the bound path. Today `SWARM_PRIMITIVE_SEAM=decorated` still runs
|
||||
green because the binding falls back to the stub — proving the flip path executes.
|
||||
|
||||
## Real cognition — the seam is BOUND
|
||||
|
||||
`primitive_binding.el` is bound to the api-reshape agent's proven primitives
|
||||
(`wt/api-reshape@d4f401d`): `bound_think -> op_think` (GET `/api/think`), real
|
||||
768-dim gradients over the engram geometry. `reshape_surface.el` composes those
|
||||
read/cognition primitives verbatim (`op_think/read/attend/learn`).
|
||||
|
||||
`tests/harness_real_cognition.el` runs the **local swarm on real cognition**,
|
||||
17/17 green with `SWARM_PRIMITIVE_SEAM=decorated` against the `:8901` clone: 8
|
||||
native-thread workers, each a real `think` over its CCR-scoped **node-id anchor**
|
||||
(free-text anchors return "geometry unavailable"), `@manager` reduce+vote, all
|
||||
three containment rules, afferent telemetry, durable tracking. Per-anchor support
|
||||
counts (e.g. 6 / 16 / 87) drive a genuine, cognition-derived vote.
|
||||
|
||||
> **Build note (load-bearing):** the swarm build **must** define `HAVE_CURL`
|
||||
> (`build.sh` does). Without it every `http_*` builtin is a
|
||||
> `{"error":"not built with HAVE_CURL"}` stub — real HTTP silently disappears.
|
||||
|
||||
Writes (`attend`/`learn`, `POST`) are gated behind `SWARM_WRITE_HEALTHY=1` and the
|
||||
api-reshape agent's gate-1 write-healthy clone; the proven run is read-cognition.
|
||||
|
||||
## Built vs stubbed (honest)
|
||||
|
||||
**Real, tested:**
|
||||
- Native-thread fan-out/converge, bounded concurrency, order-preserving rejoin.
|
||||
- All three containment rules enforced (scope tokens + lateral-edge check).
|
||||
- CCR per-worker context: retrieval → scoping → compaction, bounded, non-leaking
|
||||
(a worker never receives sibling inputs) — verified against the live isolated mind.
|
||||
- Full durable work-tracking (JSONL journal, reconstructable report).
|
||||
- Four convergence strategies + integer failure threshold / partial-abort.
|
||||
|
||||
**Seam / not yet bound:**
|
||||
- `primitives.el` `think` is a deterministic, hermetic transform (no model call).
|
||||
Binding point is marked `PRIMITIVE_BINDING`; wire to the API-surface reshape's
|
||||
`think/act/attend/intend/learn` when it lands.
|
||||
- Blueprints are dispatched by name in `swarm_run_blueprint` (default +
|
||||
`classify`/`faildemo` demos). A YAML process-definition loader (Swarm §5) is
|
||||
future work — the runtime contract is in place.
|
||||
- Distributed placement (cloud/edge/federated topologies, Swarm §9.2) is
|
||||
structurally enabled by containment but not yet wired to a placement layer;
|
||||
today all workers are local native threads.
|
||||
- Engram work-tracking mirror is opt-in; the durable substrate is the journal.
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# build.sh — compile an El program that uses the swarm capability.
|
||||
#
|
||||
# Concatenates the El native-concurrency stdlib (thread.el, channel.el) and the
|
||||
# swarm capability modules in dependency order, then the user program, compiles
|
||||
# with the canonical elc, and links against the shared C runtime.
|
||||
#
|
||||
# Usage:
|
||||
# swarm/build.sh <program.el> <out-binary>
|
||||
#
|
||||
# The swarm modules use only el_runtime.c builtins plus thread.el/channel.el,
|
||||
# so nothing else needs concatenating (engram_*, json_*, str_*, fs_*, http_*,
|
||||
# uuid_v4, now_millis are all C builtins in el_runtime.c).
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.." # -> lang/
|
||||
LANG_DIR="$(pwd)"
|
||||
ELC="${ELC:-${LANG_DIR}/dist/platform/elc}"
|
||||
RT="${LANG_DIR}/el-compiler/runtime"
|
||||
|
||||
PROG="${1:?usage: build.sh <program.el> <out-binary>}"
|
||||
OUT="${2:?usage: build.sh <program.el> <out-binary>}"
|
||||
|
||||
# swarm module load order (each may depend on those before it):
|
||||
# worktrack — durable work-tracking journal (no swarm deps)
|
||||
# containment — the three containment rules (no swarm deps)
|
||||
# primitives — think/act/attend/intend/learn seam (no swarm deps)
|
||||
# ccr — per-worker compiled bounded context (depends: primitives)
|
||||
# swarm — orchestrator: fan-out/converge (depends: all above + thread)
|
||||
SWARM_MODULES="
|
||||
swarm/worktrack.el
|
||||
swarm/containment.el
|
||||
swarm/primitives.el
|
||||
swarm/reshape_surface.el
|
||||
swarm/primitive_binding.el
|
||||
swarm/primitive_seam.el
|
||||
swarm/ccr.el
|
||||
swarm/swarm.el
|
||||
"
|
||||
|
||||
TMP_C="$(mktemp -t swarm_build.XXXXXX).c"
|
||||
COMBINED="$(mktemp -t swarm_combined.XXXXXX).el"
|
||||
|
||||
cat runtime/thread.el runtime/channel.el $SWARM_MODULES "$PROG" > "$COMBINED"
|
||||
|
||||
if ! "$ELC" "$COMBINED" > "$TMP_C" 2>/tmp/swarm.elc.err; then
|
||||
echo "elc FAILED:" >&2
|
||||
sed 's/^/ /' /tmp/swarm.elc.err >&2
|
||||
rm -f "$TMP_C" "$COMBINED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! cc -O2 -DHAVE_CURL -I "$RT" "$TMP_C" "$RT/el_runtime.c" -lcurl -lpthread -lm -o "$OUT" 2>/tmp/swarm.cc.err; then
|
||||
echo "cc FAILED:" >&2
|
||||
sed 's/^/ /' /tmp/swarm.cc.err >&2
|
||||
rm -f "$TMP_C" "$COMBINED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$TMP_C" "$COMBINED"
|
||||
echo "built: $OUT"
|
||||
@@ -0,0 +1,153 @@
|
||||
// ccr.el — Compiled Context Routing for work distribution.
|
||||
//
|
||||
// The same spine as the API's vantage-read, applied per worker. Instead of
|
||||
// handing every worker the coordinator's full memory, CCR compiles a MINIMAL,
|
||||
// BOUNDED context package scoped to exactly one worker's input (CCR §5, "Compiled
|
||||
// Context Injection"; Swarm §9.3, "The Compiled Context Boundary as Security
|
||||
// Boundary").
|
||||
//
|
||||
// The pipeline is CCR §5.1: Retrieval -> Scoping -> Compilation -> (Injection,
|
||||
// which here is placing the package into the worker's task envelope).
|
||||
//
|
||||
// 1. Retrieval — resolve the blueprint's knowledge refs + the input's salient
|
||||
// terms against the mind (primitive_attend).
|
||||
// 2. Scoping — keep only what THIS input needs; drop everything else. A
|
||||
// worker never receives sibling inputs or unrelated memory.
|
||||
// 3. Compilation— compact to a CTX string within a token budget (lossless of
|
||||
// meaning, smaller in tokens): collapse blank runs, dedupe
|
||||
// lines, then bound to the budget.
|
||||
//
|
||||
// The package a worker receives is therefore (a) sufficient for its task and
|
||||
// (b) incapable of leaking what it was never given — the containment boundary
|
||||
// and the security boundary are the same object.
|
||||
|
||||
// ── token budget helpers ─────────────────────────────────────────────────────
|
||||
|
||||
// ccr_est_tokens — cheap token estimate (~4 chars/token).
|
||||
fn ccr_est_tokens(s: String) -> Int {
|
||||
return str_len(s) / 4
|
||||
}
|
||||
|
||||
// ccr_default_budget — default per-worker context budget in tokens.
|
||||
// Override with CCR_TOKEN_BUDGET.
|
||||
fn ccr_default_budget() -> Int {
|
||||
let b: String = env("CCR_TOKEN_BUDGET")
|
||||
if str_eq(b, "") {
|
||||
return 1200
|
||||
}
|
||||
return str_to_int(b)
|
||||
}
|
||||
|
||||
// ── stage 3: compaction ──────────────────────────────────────────────────────
|
||||
|
||||
// ccr_compact — collapse blank-line runs and drop exact duplicate lines, then
|
||||
// bound the result to `budget` tokens (truncate on a line boundary). Meaning is
|
||||
// preserved; token count falls (CCR §5.2).
|
||||
fn ccr_compact(text: String, budget: Int) -> String {
|
||||
let lines: [String] = str_split_lines(text)
|
||||
let n: Int = el_list_len(lines)
|
||||
let seen: String = "\n"
|
||||
let out: String = ""
|
||||
let out_tokens = 0
|
||||
let i = 0
|
||||
while i < n {
|
||||
let ln: String = str_trim(el_list_get(lines, i))
|
||||
if str_eq(ln, "") {
|
||||
let i = i + 1
|
||||
} else {
|
||||
let marker: String = "\n" + ln + "\n"
|
||||
if str_contains(seen, marker) {
|
||||
// duplicate line — skip
|
||||
let i = i + 1
|
||||
} else {
|
||||
let seen = seen + ln + "\n"
|
||||
let line_tokens: Int = ccr_est_tokens(ln) + 1
|
||||
if out_tokens + line_tokens > budget {
|
||||
// budget exhausted — stop (bounded)
|
||||
let i = n
|
||||
} else {
|
||||
let out = out + ln + "\n"
|
||||
let out_tokens = out_tokens + line_tokens
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── stages 1+2: retrieve + scope ─────────────────────────────────────────────
|
||||
|
||||
// ccr_retrieve_scoped — pull context relevant to this input and its blueprint
|
||||
// knowledge refs, scoped to a fraction of the budget so no single source floods
|
||||
// the package. Returns compacted retrieved text (may be empty if the mind is
|
||||
// unreachable — the input alone is still a valid minimal context).
|
||||
fn ccr_retrieve_scoped(blueprint: String, knowledge_refs: String, input_item: String, budget: Int) -> String {
|
||||
let acc: String = ""
|
||||
// knowledge_refs is a JSON array of query strings.
|
||||
let m: Int = json_array_len(knowledge_refs)
|
||||
let i = 0
|
||||
while i < m {
|
||||
let ref: String = json_array_get_string(knowledge_refs, i)
|
||||
let hit: String = primitive_attend(ref, 3)
|
||||
let acc = acc + "# ref:" + ref + "\n" + hit + "\n"
|
||||
let i = i + 1
|
||||
}
|
||||
// the input's own salient text also seeds retrieval
|
||||
let hit2: String = primitive_attend(input_item, 3)
|
||||
let acc = acc + "# input-context\n" + hit2 + "\n"
|
||||
// scope retrieval to ~60% of budget; the input itself gets the rest
|
||||
let retr_budget: Int = (budget * 6) / 10
|
||||
return ccr_compact(acc, retr_budget)
|
||||
}
|
||||
|
||||
// ── ccr_compile — assemble the bounded per-worker context package ─────────────
|
||||
//
|
||||
// blueprint : task blueprint name
|
||||
// knowledge_refs : JSON array of retrieval queries from the blueprint
|
||||
// input_item : THIS worker's single input (and nothing else)
|
||||
// corr_id : swarm correlation ID
|
||||
// worker_id : this worker's ID
|
||||
// scope_token : the worker's containment token (closed boundary)
|
||||
//
|
||||
// Returns a JSON package: { blueprint, corr_id, worker_id, scope_token,
|
||||
// input, knowledge, budget_tokens, compiled_tokens }. `knowledge` is compiled
|
||||
// and bounded; the package as a whole is bounded by budget.
|
||||
fn ccr_compile(blueprint: String, knowledge_refs: String, input_item: String,
|
||||
corr_id: String, worker_id: String, scope_token: String) -> String {
|
||||
let budget: Int = ccr_default_budget()
|
||||
let knowledge: String = ccr_retrieve_scoped(blueprint, knowledge_refs, input_item, budget)
|
||||
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "blueprint")
|
||||
let kv = el_list_append(kv, blueprint)
|
||||
let kv = el_list_append(kv, "corr_id")
|
||||
let kv = el_list_append(kv, corr_id)
|
||||
let kv = el_list_append(kv, "worker_id")
|
||||
let kv = el_list_append(kv, worker_id)
|
||||
let kv = el_list_append(kv, "input")
|
||||
let kv = el_list_append(kv, input_item)
|
||||
let kv = el_list_append(kv, "knowledge")
|
||||
let kv = el_list_append(kv, knowledge)
|
||||
let kv = el_list_append(kv, "budget_tokens")
|
||||
let kv = el_list_append(kv, int_to_str(budget))
|
||||
let pkg: String = json_build_object(kv)
|
||||
// stamp the scope token as a nested object, and the measured size
|
||||
let pkg2: String = json_set(pkg, "scope_token", scope_token)
|
||||
let compiled_tokens: Int = ccr_est_tokens(pkg2)
|
||||
let pkg3: String = json_set(pkg2, "compiled_tokens", int_to_str(compiled_tokens))
|
||||
return pkg3
|
||||
}
|
||||
|
||||
// ccr_within_budget — did the compiled package stay within its budget?
|
||||
// (Retrieval is bounded to 60% and the input is small; this asserts the whole
|
||||
// package is bounded — the property distribution relies on.)
|
||||
fn ccr_within_budget(pkg: String) -> Bool {
|
||||
let budget: Int = str_to_int(json_get_string(pkg, "budget_tokens"))
|
||||
let compiled: Int = str_to_int(json_get_string(pkg, "compiled_tokens"))
|
||||
// allow a small envelope for JSON framing overhead
|
||||
if compiled <= budget + 200 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// containment.el — the Swarm Architecture containment rules, enforced.
|
||||
//
|
||||
// "These rules are not conventions. They are enforced by the runtime."
|
||||
// (Swarm Architecture §3.2). The three rules that make bounded parallelism —
|
||||
// and therefore location-independent distribution — safe:
|
||||
//
|
||||
// Rule 1: a worker may NOT join another swarm.
|
||||
// Rule 2: a worker may NOT initiate a new swarm.
|
||||
// Rule 3: a worker may NOT communicate laterally with sibling workers.
|
||||
//
|
||||
// Enforcement is by SCOPE TOKEN. When a swarm fans out, the coordinator mints a
|
||||
// swarm scope token and stamps a distinct worker scope token into each worker's
|
||||
// task envelope. Any attempt to create or join a swarm checks the caller's
|
||||
// token: if the caller already holds a WORKER token, the operation is rejected.
|
||||
// Rule 3 is enforced structurally elsewhere — workers share no mutable state and
|
||||
// the only channels they hold are the vertical result path — but this module
|
||||
// provides the explicit lateral-edge check for the execution tree.
|
||||
//
|
||||
// A scope token is a JSON object: {"kind":"coordinator|worker","swarm":"<corr>",
|
||||
// "worker":"<id-or-empty>","depth":"<n>"}.
|
||||
|
||||
// ── Token minting ────────────────────────────────────────────────────────────
|
||||
|
||||
// CAPABILITIES. A scope token carries a `caps` set — the authority it holds.
|
||||
// This is an AUTHORITY gate, not a health gate: capability is decided at mint
|
||||
// time and cannot be acquired at runtime. Engram-WRITE (op_write/op_relate/
|
||||
// op_supersede -> POST /api/nodes, /api/edges, DELETE) and dharma_emit are
|
||||
// @manager-ONLY capabilities — exactly the VBD rule that only the orchestrator
|
||||
// mutates global state. The orchestrator's token carries them; a worker's token
|
||||
// NEVER does. A worker is therefore STRUCTURALLY UNABLE to mutate global engram
|
||||
// state, regardless of engram health.
|
||||
fn cap_orchestrator() -> String { return "engram:read,engram:write,dharma:emit,state:write" }
|
||||
fn cap_worker() -> String { return "engram:read" }
|
||||
|
||||
// containment_coordinator_token — the token the orchestrator (@manager) holds.
|
||||
// Depth 0. Carries the engram-WRITE + dharma-emit capabilities (@manager-only).
|
||||
fn containment_coordinator_token(corr_id: String) -> String {
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "kind")
|
||||
let kv = el_list_append(kv, "coordinator")
|
||||
let kv = el_list_append(kv, "swarm")
|
||||
let kv = el_list_append(kv, corr_id)
|
||||
let kv = el_list_append(kv, "worker")
|
||||
let kv = el_list_append(kv, "")
|
||||
let kv = el_list_append(kv, "depth")
|
||||
let kv = el_list_append(kv, "0")
|
||||
let kv = el_list_append(kv, "caps")
|
||||
let kv = el_list_append(kv, cap_orchestrator())
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// containment_worker_token — the token stamped into a worker's envelope. Depth 1.
|
||||
// A closed boundary: forbids opening/joining swarms AND carries ONLY the
|
||||
// engram:READ capability — no engram:write, no dharma:emit. Read-only against the
|
||||
// full engram; may write only its own local geometry (its returned result).
|
||||
fn containment_worker_token(corr_id: String, worker_id: String) -> String {
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "kind")
|
||||
let kv = el_list_append(kv, "worker")
|
||||
let kv = el_list_append(kv, "swarm")
|
||||
let kv = el_list_append(kv, corr_id)
|
||||
let kv = el_list_append(kv, "worker")
|
||||
let kv = el_list_append(kv, worker_id)
|
||||
let kv = el_list_append(kv, "depth")
|
||||
let kv = el_list_append(kv, "1")
|
||||
let kv = el_list_append(kv, "caps")
|
||||
let kv = el_list_append(kv, cap_worker())
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// containment_has_cap — does this token carry capability `cap`?
|
||||
fn containment_has_cap(token: String, cap: String) -> Bool {
|
||||
return str_contains(json_get_string(token, "caps"), cap)
|
||||
}
|
||||
|
||||
// ── Rule checks (return "" on allow, or a rejection reason string) ───────────
|
||||
|
||||
// containment_check_open — may the holder of `token` OPEN a new swarm?
|
||||
// Enforces Rule 2 (a worker may not initiate a new swarm). Only a coordinator
|
||||
// token, or an absent token (top-level process), may open one.
|
||||
fn containment_check_open(token: String) -> String {
|
||||
if str_eq(token, "") {
|
||||
return ""
|
||||
}
|
||||
let kind: String = json_get_string(token, "kind")
|
||||
if str_eq(kind, "worker") {
|
||||
return "CONTAINMENT rule 2: a swarm worker may not initiate a new swarm (worker=" + json_get_string(token, "worker") + " swarm=" + json_get_string(token, "swarm") + ")"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// containment_check_join — may the holder of `token` JOIN swarm `target_corr`?
|
||||
// Enforces Rule 1 (a worker may not join another swarm). A worker already bound
|
||||
// to swarm A may not register into swarm B; and a worker may not re-join at all.
|
||||
fn containment_check_join(token: String, target_corr: String) -> String {
|
||||
if str_eq(token, "") {
|
||||
return ""
|
||||
}
|
||||
let kind: String = json_get_string(token, "kind")
|
||||
if str_eq(kind, "worker") {
|
||||
return "CONTAINMENT rule 1: a swarm worker may not join another swarm (worker=" + json_get_string(token, "worker") + " bound-swarm=" + json_get_string(token, "swarm") + " attempted-swarm=" + target_corr + ")"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// containment_check_lateral — may `from_token` open a communication edge to a
|
||||
// sibling worker `to_worker_id`? Enforces Rule 3 (no lateral communication).
|
||||
// The only permitted edges are vertical: worker->coordinator and
|
||||
// coordinator->worker. Any worker->worker edge is rejected.
|
||||
fn containment_check_lateral(from_token: String, to_worker_id: String) -> String {
|
||||
let kind: String = json_get_string(from_token, "kind")
|
||||
if str_eq(kind, "worker") {
|
||||
if str_eq(to_worker_id, "") {
|
||||
// empty target = the coordinator (vertical) — allowed
|
||||
return ""
|
||||
}
|
||||
return "CONTAINMENT rule 3: a swarm worker may not communicate laterally with sibling workers (from=" + json_get_string(from_token, "worker") + " to=" + to_worker_id + ")"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// containment_check_engram_write — RULE 4: only a token carrying the
|
||||
// engram:write capability (the orchestrator's) may mutate global engram state.
|
||||
// A worker token (engram:read only) is REJECTED — the authority gate. Reuses the
|
||||
// exact scope-token mechanism as Rule 2's open-denial. Returns "" on allow, or a
|
||||
// rejection reason. This is an AUTHORITY gate: it does not consult engram health.
|
||||
fn containment_check_engram_write(token: String, op: String) -> String {
|
||||
if containment_has_cap(token, "engram:write") {
|
||||
return ""
|
||||
}
|
||||
return "CONTAINMENT rule 4: engram-write is @manager-only — a worker is read-only against the engram and may not mutate global state (op=" + op + " kind=" + json_get_string(token, "kind") + " worker=" + json_get_string(token, "worker") + " caps=" + json_get_string(token, "caps") + ")"
|
||||
}
|
||||
|
||||
// containment_check_dharma_emit — the same @manager-only rule for dharma_emit,
|
||||
// grounding Rule 4 in VBD: global-state mutations (engram-write, dharma-emit) are
|
||||
// orchestrator-only, checked by the one capability mechanism.
|
||||
fn containment_check_dharma_emit(token: String) -> String {
|
||||
if containment_has_cap(token, "dharma:emit") {
|
||||
return ""
|
||||
}
|
||||
return "CONTAINMENT rule 4: dharma_emit is @manager-only (kind=" + json_get_string(token, "kind") + ")"
|
||||
}
|
||||
|
||||
// ── Enforcement helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// containment_allows_open — Bool convenience over containment_check_open.
|
||||
fn containment_allows_open(token: String) -> Bool {
|
||||
return str_eq(containment_check_open(token), "")
|
||||
}
|
||||
|
||||
// containment_is_worker — is this a worker-scoped (closed-boundary) token?
|
||||
fn containment_is_worker(token: String) -> Bool {
|
||||
return str_eq(json_get_string(token, "kind"), "worker")
|
||||
}
|
||||
|
||||
// containment_guard_open — assert a swarm may be opened under this token.
|
||||
// Returns "" if allowed, or records a CONTAINMENT violation to the work-tracking
|
||||
// journal and returns the reason. Callers must abort on a non-empty return.
|
||||
fn containment_guard_open(token: String, corr_id: String) -> String {
|
||||
let reason: String = containment_check_open(token)
|
||||
if str_eq(reason, "") {
|
||||
return ""
|
||||
}
|
||||
let p: String = json_set_str("{}", "reason", reason)
|
||||
worktrack_append("containment.violation", corr_id, "open", p)
|
||||
return reason
|
||||
}
|
||||
|
||||
// containment_guard_engram_write — assert a token may mutate global engram state
|
||||
// (Rule 4). Returns "" if allowed; otherwise journals a containment.violation and
|
||||
// returns the reason. The write path MUST abort on a non-empty return.
|
||||
fn containment_guard_engram_write(token: String, corr_id: String, op: String) -> String {
|
||||
let reason: String = containment_check_engram_write(token, op)
|
||||
if str_eq(reason, "") {
|
||||
return ""
|
||||
}
|
||||
let p0: String = json_set_str("{}", "reason", reason)
|
||||
let p1: String = json_set_str(p0, "op", op)
|
||||
worktrack_append("containment.violation", corr_id, "engram-write", p1)
|
||||
return reason
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// primitive_binding.el — THE ONE FLIP POINT.
|
||||
//
|
||||
// This file is the single seam between the swarm and the real agentic
|
||||
// primitives. Binding the reshape's decorated primitives is a one-line change
|
||||
// HERE and nothing else changes anywhere in the swarm.
|
||||
//
|
||||
// The api-reshape agent (wt/api-reshape) is wiring the primitives as DECORATED
|
||||
// El on the dharma_* event bus over the engram — think/attend/learn/ground/assert
|
||||
// become decorated fns that emit afferent events onto the bus. The moment they
|
||||
// land, flip `bound_think` (and its siblings) to call them.
|
||||
//
|
||||
// TODAY (stub fallback, compiles + runs now against :8901):
|
||||
// fn bound_think(...) { return primitive_think(ctx, instruction) }
|
||||
//
|
||||
// THE FLIP (when reshape's decorated primitives land — one line each):
|
||||
// fn bound_think(...) { return think(ctx, instruction) } // decorated, on dharma bus
|
||||
//
|
||||
// Keep the stub as fallback: `bound_think` is only reached when the seam mode is
|
||||
// "decorated" (SWARM_PRIMITIVE_SEAM=decorated). Until you flip these bodies AND
|
||||
// set that env, the harness runs entirely on the hermetic stub.
|
||||
|
||||
// bound_think — BOUND to the reshape's proven decorated `think` (op_think),
|
||||
// real cognition over the engram geometry. The worker's CCR slice carries a
|
||||
// NODE-ID anchor in ctx.input (free-text anchors return "geometry unavailable");
|
||||
// think re-origins at that node's region under the faculty and returns a real
|
||||
// 768-dim gradient.
|
||||
fn bound_think(ctx: String, instruction: String) -> String {
|
||||
let anchor: String = json_get_string(ctx, "input")
|
||||
let faculty: String = json_get_string(ctx, "faculty")
|
||||
return op_think(anchor, faculty)
|
||||
}
|
||||
|
||||
// bound_attend — BOUND to the reshape's op_attend (POST /api/attend). Needs the
|
||||
// gate-1 write-healthy clone; falls back to the read-side attend otherwise.
|
||||
fn bound_attend(query: String, limit: Int) -> String {
|
||||
if str_eq(env("SWARM_WRITE_HEALTHY"), "1") {
|
||||
return op_attend(query, "self")
|
||||
}
|
||||
return primitive_attend(query, limit)
|
||||
}
|
||||
|
||||
// bound_learn — BOUND to the reshape's op_learn (correspondence-beat). Needs the
|
||||
// gate-1 write-healthy clone; falls back to the opt-in journal-only learn.
|
||||
fn bound_learn(corr_id: String, observation: String) -> String {
|
||||
if str_eq(env("SWARM_WRITE_HEALTHY"), "1") {
|
||||
return op_learn(observation, "induce")
|
||||
}
|
||||
return primitive_learn(corr_id, observation)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// primitive_seam.el — the configurable primitive seam + telemetry.
|
||||
//
|
||||
// One switch selects where a worker's primitive invocation goes:
|
||||
// SWARM_PRIMITIVE_SEAM=stub (default) — hermetic in-process think.
|
||||
// SWARM_PRIMITIVE_SEAM=decorated — the reshape's decorated
|
||||
// primitives on the dharma bus
|
||||
// (see primitive_binding.el).
|
||||
//
|
||||
// Every seam invocation is an AFFERENT signal — a primitive call travelling
|
||||
// toward the manager. The seam stamps telemetry onto each thought (seam_mode +
|
||||
// one afferent tick) so the coordinator can aggregate afferent counters across
|
||||
// the swarm without any shared mutable state (containment-safe: counts ride the
|
||||
// vertical result path, not a shared bus register).
|
||||
|
||||
// seam_mode — "stub" (default) or "decorated".
|
||||
fn seam_mode() -> String {
|
||||
let m: String = env("SWARM_PRIMITIVE_SEAM")
|
||||
if str_eq(m, "decorated") {
|
||||
return "decorated"
|
||||
}
|
||||
return "stub"
|
||||
}
|
||||
|
||||
// seam_think — route a worker's `think` through the configured seam and stamp
|
||||
// telemetry. Returns the thought JSON augmented with:
|
||||
// seam_mode : which side of the seam served this call
|
||||
// afferent : "1" — one afferent primitive signal was emitted
|
||||
fn seam_think(ctx: String, instruction: String) -> String {
|
||||
let mode: String = seam_mode()
|
||||
let thought: String = ""
|
||||
if str_eq(mode, "decorated") {
|
||||
let thought = bound_think(ctx, instruction)
|
||||
} else {
|
||||
let thought = primitive_think(ctx, instruction)
|
||||
}
|
||||
let t1: String = json_set_str(thought, "seam_mode", mode)
|
||||
let t2: String = json_set_str(t1, "afferent", "1")
|
||||
return t2
|
||||
}
|
||||
|
||||
// seam_attend / seam_learn — same seam for the other primitives (used when a
|
||||
// blueprint retrieves or writes through the bus).
|
||||
fn seam_attend(query: String, limit: Int) -> String {
|
||||
if str_eq(seam_mode(), "decorated") {
|
||||
return bound_attend(query, limit)
|
||||
}
|
||||
return primitive_attend(query, limit)
|
||||
}
|
||||
|
||||
fn seam_learn(corr_id: String, observation: String) -> String {
|
||||
if str_eq(seam_mode(), "decorated") {
|
||||
return bound_learn(corr_id, observation)
|
||||
}
|
||||
return primitive_learn(corr_id, observation)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// primitives.el — the agentic primitive SEAM the swarm composes over.
|
||||
//
|
||||
// The swarm is orchestration OVER the five CCR primitives, not a replacement for
|
||||
// them (CCR §2, "The Five Primitives / The Execution Cycle"): a worker executes
|
||||
// its task blueprint as attend -> think -> intend -> act -> learn against its
|
||||
// compiled, bounded context.
|
||||
//
|
||||
// This file is the SEAM. The parallel API-surface reshape exposes the canonical
|
||||
// primitive tools; when it lands, bind each primitive below to the reshaped
|
||||
// implementation (see PRIMITIVE_BINDING). Until then these are thin, engram-
|
||||
// backed fallbacks so the swarm — its fan-out, containment, CCR context
|
||||
// compilation, convergence, and work-tracking — is fully exercisable today.
|
||||
//
|
||||
// Contract: every primitive takes and returns String (JSON where structured), so
|
||||
// any primitive is directly threadable via thread.el's spawn (which runs
|
||||
// top-level (String)->String El fns).
|
||||
//
|
||||
// PRIMITIVE_BINDING: to bind the reshape's real tools, replace each fallback body
|
||||
// with a call to the reshaped El fn / API endpoint. Signatures here are the
|
||||
// stable contract the swarm depends on; keep them.
|
||||
|
||||
// ── attend — retrieve the minimal relevant context for a focus ───────────────
|
||||
// Vantage-read: pull only what this focus needs from the mind. Backed by the
|
||||
// engram's spreading-activation retrieval.
|
||||
fn primitive_attend(query: String, limit: Int) -> String {
|
||||
if str_eq(query, "") {
|
||||
return "[]"
|
||||
}
|
||||
// Location-independent worker model: when an engram daemon is configured,
|
||||
// retrieve over HTTP (the worker may run anywhere). POST /api/search
|
||||
// {query,limit,_auth}. Falls back to the in-process store otherwise.
|
||||
let url: String = env("ENGRAM_URL")
|
||||
if str_eq(url, "") {
|
||||
return engram_activate(query, limit)
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "query")
|
||||
let kv = el_list_append(kv, query)
|
||||
let body0: String = json_build_object(kv)
|
||||
let body1: String = json_set(body0, "limit", int_to_str(limit))
|
||||
let body2: String = json_set_str(body1, "_auth", env("ENGRAM_API_KEY"))
|
||||
return http_post(url + "/api/search", body2)
|
||||
}
|
||||
|
||||
// ── think — reason over the compiled context ─────────────────────────────────
|
||||
// In production this routes to a model (CCR dynamic model selection). Here it is
|
||||
// a deterministic, hermetic transform so swarm behaviour is testable without an
|
||||
// external model: it echoes a structured verdict derived from the context. The
|
||||
// binding point for a real model is explicit.
|
||||
fn primitive_think(compiled_ctx: String, instruction: String) -> String {
|
||||
// PRIMITIVE_BINDING: replace with the reshape's think() (model inference).
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "instruction")
|
||||
let kv = el_list_append(kv, instruction)
|
||||
let kv = el_list_append(kv, "ctx_bytes")
|
||||
let kv = el_list_append(kv, int_to_str(str_len(compiled_ctx)))
|
||||
let kv = el_list_append(kv, "conclusion")
|
||||
let kv = el_list_append(kv, "reasoned:" + instruction)
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// ── intend — form a bounded plan/decision from a thought ─────────────────────
|
||||
fn primitive_intend(thought: String) -> String {
|
||||
let concl: String = json_get_string(thought, "conclusion")
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "intent")
|
||||
let kv = el_list_append(kv, concl)
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// ── act — execute a bounded effect and return its result ─────────────────────
|
||||
// Workers defer real side-effects to the coordinator (idempotency requirement,
|
||||
// Swarm §7.3). Here act produces an artifact-shaped result the coordinator
|
||||
// collects during convergence.
|
||||
fn primitive_act(intent: String, input_item: String) -> String {
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "acted_on")
|
||||
let kv = el_list_append(kv, input_item)
|
||||
let kv = el_list_append(kv, "via")
|
||||
let kv = el_list_append(kv, json_get_string(intent, "intent"))
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// ── learn — record an observation into the mind, tagged by correlation ID ────
|
||||
// Append-only, naturally idempotent (Swarm §7.3). Best-effort: a worker that
|
||||
// cannot reach the mind still returns its result.
|
||||
fn primitive_learn(corr_id: String, observation: String) -> String {
|
||||
let url: String = env("ENGRAM_URL")
|
||||
if str_eq(url, "") {
|
||||
return ""
|
||||
}
|
||||
let content: String = "swarm-worker-obs corr=" + corr_id + " :: " + observation
|
||||
return engram_node(content, "Memory", 0.4)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// reshape_surface.el — the api-reshape agent's PROVEN decorated primitives,
|
||||
// composed into the swarm build to bind real cognition.
|
||||
//
|
||||
// PROVENANCE: these fns are the reshape's surface at wt/api-reshape @ d4f401d
|
||||
// ("reshape: decorator-as-seam — port @route codegen, prove decorate->serve,
|
||||
// rewrite surface as decorated El"), verified live against
|
||||
// engram.cognition-20260814. Copied verbatim (read/cognition ops only) so the
|
||||
// swarm binds the REAL primitives, not a reimplementation. The write ops
|
||||
// (op_write/op_relate/op_supersede/op_ground) are intentionally NOT composed
|
||||
// here — they exercise the persist_node write path that needs the gate-1
|
||||
// write-healthy clone; the swarm's proven run is read-cognition (think/read).
|
||||
//
|
||||
// Ops route to the ENGRAM over ENGRAM_URL — pinned by THIS worktree's .nsbx-env
|
||||
// to the :8901 swarm clone (never the reshape agent's :8900). Separate clones,
|
||||
// no collision.
|
||||
|
||||
fn engram_url() -> String {
|
||||
let u: String = env("ENGRAM_URL")
|
||||
if str_eq(u, "") { return "http://127.0.0.1:8900" }
|
||||
return u
|
||||
}
|
||||
fn engram_key() -> String {
|
||||
let k: String = env("ENGRAM_API_KEY")
|
||||
if str_eq(k, "") { return "sbx-dev-api-reshape" }
|
||||
return k
|
||||
}
|
||||
fn SELF_KEY() -> String { return "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee" }
|
||||
fn VALUES_KEY() -> String { return "kn-5b606390-a52d-4ca2-8e0e-eba141d13440" }
|
||||
|
||||
// self/values name -> keystone id; anything else passes through unchanged.
|
||||
fn resolve_named(v: String) -> String {
|
||||
if str_eq(v, "self") { return SELF_KEY() }
|
||||
if str_eq(v, "neuron") { return SELF_KEY() }
|
||||
if str_eq(v, "values") { return VALUES_KEY() }
|
||||
if str_eq(v, "values_hub") { return VALUES_KEY() }
|
||||
return v
|
||||
}
|
||||
|
||||
// read — THE VANTAGE-READ. Re-origin at a point + aperture -> a BOUNDED slice.
|
||||
fn op_read(vantage: String, typ: String, k: Int) -> String {
|
||||
let vid: String = resolve_named(vantage)
|
||||
if str_eq(typ, "edges") {
|
||||
return http_get(engram_url() + "/api/neighbors/" + vid)
|
||||
}
|
||||
if str_starts_with(vid, "kn-") {
|
||||
return http_get(engram_url() + "/api/neighbors/" + vid)
|
||||
}
|
||||
return http_get(engram_url() + "/api/search?q=" + url_encode(vid) + "&limit=" + int_to_str(k))
|
||||
}
|
||||
|
||||
// think — THE ONE OPERATION. anchor (node ids) steered by faculty -> gradient.
|
||||
fn op_think(seeds: String, faculty: String) -> String {
|
||||
let s: String = resolve_named(seeds)
|
||||
let f: String = if str_eq(faculty, "") { "reason" } else { faculty }
|
||||
return http_get(engram_url() + "/api/think?seeds=" + url_encode(s) + "&faculty=" + f)
|
||||
}
|
||||
|
||||
// attend — aim attention at a region. (POST — needs a write-healthy clone.)
|
||||
fn op_attend(node: String, observer: String) -> String {
|
||||
let n: String = resolve_named(node)
|
||||
let o: String = if str_eq(observer, "") { SELF_KEY() } else { resolve_named(observer) }
|
||||
let body: String = "{\"_auth\":\"" + engram_key() + "\",\"node\":\"" + n
|
||||
+ "\",\"observer\":\"" + o + "\",\"salience\":\"0.6\"}"
|
||||
return http_post_json(engram_url() + "/api/attend", body)
|
||||
}
|
||||
|
||||
fn identity_typed(t: String) -> Bool {
|
||||
if str_eq(t, "self") { return true }
|
||||
if str_eq(t, "values") { return true }
|
||||
return false
|
||||
}
|
||||
fn type_to_node_type(t: String) -> String {
|
||||
if str_eq(t, "knowledge") { return "Knowledge" }
|
||||
if str_eq(t, "artifact") { return "Artifact" }
|
||||
if str_eq(t, "backlog") { return "WorkItem" }
|
||||
if str_eq(t, "process") { return "Process" }
|
||||
if str_eq(t, "state") { return "InternalStateEvent" }
|
||||
return "Memory"
|
||||
}
|
||||
|
||||
// write — add a node (POST /api/nodes). Identity types refused. This is a
|
||||
// global-engram MUTATION — @manager-only (Rule 4); never called on a worker path.
|
||||
// (Reshape's op_write, with json_escape -> the available json_escape_string.)
|
||||
fn op_write(content: String, typ: String, importance: Float) -> String {
|
||||
if str_eq(content, "") { return "{\"error\":\"write: content required\"}" }
|
||||
if identity_typed(typ) {
|
||||
return "{\"error\":\"write type=" + typ + " is write-protected -> intentional-cultivation\"}"
|
||||
}
|
||||
let body: String = "{\"_auth\":\"" + engram_key() + "\",\"content\":\"" + json_escape_string(content)
|
||||
+ "\",\"node_type\":\"" + type_to_node_type(typ) + "\",\"tier\":\"Working\",\"importance\":"
|
||||
+ float_to_str(importance) + "}"
|
||||
return http_post_json(engram_url() + "/api/nodes", body)
|
||||
}
|
||||
|
||||
// learn — the reflexive correspondence-beat: calibrate the steering-prior.
|
||||
// (POST — needs a write-healthy clone.)
|
||||
fn op_learn(seeds: String, faculty: String) -> String {
|
||||
let s: String = resolve_named(seeds)
|
||||
let f: String = if str_eq(faculty, "") { "induce" } else { faculty }
|
||||
let body: String = "{\"_auth\":\"" + engram_key() + "\",\"seeds\":\"" + s
|
||||
+ "\",\"faculty\":\"" + f + "\",\"keystone\":\"false\"}"
|
||||
return http_post_json(engram_url() + "/api/correspondence-beat", body)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
// swarm.el — the swarm orchestrator: bounded parallel agent execution.
|
||||
//
|
||||
// Implements Swarm Architecture's single pattern — fan out, execute independently,
|
||||
// converge — on El's NATIVE concurrency (thread.el spawn/join). No external
|
||||
// orchestrator: a swarm is a coordinator (this file, the main thread) that mints
|
||||
// a correlation identity, compiles a bounded CCR context per worker, dispatches
|
||||
// workers as native pthreads, tracks every unit of work, and converges the
|
||||
// results before returning control to the parent step.
|
||||
//
|
||||
// The five properties of every swarm (Swarm §2.1) are all present:
|
||||
// parent step -> swarm_run is called from one process step
|
||||
// task blueprint -> `blueprint` name + knowledge refs, run by every worker
|
||||
// input set -> `inputs_json`, one item per worker
|
||||
// convergence -> `strategy` in config (collect|merge|vote|reduce)
|
||||
// correlation ID -> minted here, threaded through tracking + every worker
|
||||
//
|
||||
// Containment (Swarm §3) is enforced: the caller must hold a coordinator/absent
|
||||
// token to open a swarm (Rule 2), each worker is stamped a closed worker token
|
||||
// (Rules 1+3), and workers share no mutable state (the coordinator is the only
|
||||
// journal writer).
|
||||
|
||||
// ── worker entry — the top-level (String)->String fn native threads run ──────
|
||||
//
|
||||
// Every El fn compiles to a global C symbol; spawn() resolves this by name via
|
||||
// dlsym and runs it in a pthread. The envelope carries everything the worker is
|
||||
// permitted to see — its compiled context and nothing else (§9.3).
|
||||
//
|
||||
// Returns a result JSON: {worker_id, status:"completed"|"failed", output|error}.
|
||||
fn swarm_worker_entry(envelope_json: String) -> String {
|
||||
let worker_id: String = json_get_string(envelope_json, "worker_id")
|
||||
let ctx: String = json_get_raw(envelope_json, "ctx")
|
||||
|
||||
// The worker holds a CLOSED worker token (Rules 1+3): it shares no state
|
||||
// with siblings and may not open/join a swarm. That boundary is enforced at
|
||||
// the point of attempt — swarm_run rejects any swarm opened under a worker
|
||||
// token (Rule 2). A worker simply executing its blueprint is not opening a
|
||||
// swarm, so it proceeds. Its only outward edge is this returned result
|
||||
// (the vertical worker->coordinator path).
|
||||
let out: String = swarm_run_blueprint(ctx)
|
||||
// A worker reports failed iff its blueprint signalled failure. This is the
|
||||
// vertical status edge the coordinator reads during convergence (§4.3, §7).
|
||||
let bstatus: String = json_get_string(out, "blueprint_status")
|
||||
let status: String = "completed"
|
||||
if str_eq(bstatus, "failed") {
|
||||
let status = "failed"
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "worker_id")
|
||||
let kv = el_list_append(kv, worker_id)
|
||||
let kv = el_list_append(kv, "status")
|
||||
let kv = el_list_append(kv, status)
|
||||
let res: String = json_build_object(kv)
|
||||
return json_set(res, "output", out)
|
||||
}
|
||||
|
||||
// swarm_run_blueprint — execute the task blueprint over a compiled context.
|
||||
// The default blueprint is the CCR execution cycle: think -> intend -> act over
|
||||
// the worker's bounded context. Specialise by dispatching on
|
||||
// json_get_string(ctx,"blueprint"). Idempotent: reads ctx, writes only its
|
||||
// returned output (§7.3).
|
||||
fn swarm_run_blueprint(ctx: String) -> String {
|
||||
let blueprint: String = json_get_string(ctx, "blueprint")
|
||||
let input_item: String = json_get_string(ctx, "input")
|
||||
let knowledge: String = json_get_string(ctx, "knowledge")
|
||||
|
||||
// classify — deterministic verdict for the `vote` convergence strategy:
|
||||
// verdict is "long" if the input has >4 chars, else "short".
|
||||
if str_eq(blueprint, "classify") {
|
||||
let verdict: String = "short"
|
||||
if str_len(input_item) > 4 {
|
||||
let verdict = "long"
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "verdict")
|
||||
let kv = el_list_append(kv, verdict)
|
||||
let kv = el_list_append(kv, "blueprint_status")
|
||||
let kv = el_list_append(kv, "ok")
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// faildemo — a worker that fails on inputs beginning with "x" (exercises the
|
||||
// failure threshold + partial convergence path). Idempotent, side-effect-free.
|
||||
if str_eq(blueprint, "faildemo") {
|
||||
let st: String = "ok"
|
||||
if str_starts_with(input_item, "x") {
|
||||
let st = "failed"
|
||||
}
|
||||
return json_set_str("{}", "blueprint_status", st)
|
||||
}
|
||||
|
||||
// cognize — REAL-COGNITION blueprint. Routes think through the seam (bound to
|
||||
// op_think in decorated mode) over the worker's NODE-ID anchor, then derives a
|
||||
// vote verdict from the gradient's confidence. In stub mode there is no
|
||||
// gradient, so the verdict falls back to a deterministic slice hash — the
|
||||
// same blueprint runs green on either side of the seam.
|
||||
if str_eq(blueprint, "cognize") {
|
||||
let thought: String = seam_think(ctx, "reason over " + input_item)
|
||||
// Derive the vote verdict from the REAL gradient's support count
|
||||
// (json_get_int, since n_support is numeric). Different anchors have
|
||||
// different support -> genuine, cognition-driven vote diversity. In stub
|
||||
// mode there is no gradient (n_support -> 0) -> "uncertain".
|
||||
let nsup: Int = json_get_int(thought, "n_support")
|
||||
let verdict: String = "uncertain"
|
||||
if nsup >= 10 {
|
||||
let verdict = "confident"
|
||||
}
|
||||
let ck: [String] = el_list_empty()
|
||||
let ck = el_list_append(ck, "verdict")
|
||||
let ck = el_list_append(ck, verdict)
|
||||
let ck = el_list_append(ck, "blueprint_status")
|
||||
let ck = el_list_append(ck, "ok")
|
||||
let cout0: String = json_build_object(ck)
|
||||
let cout1: String = json_set_str(cout0, "n_support", int_to_str(nsup))
|
||||
let cout2: String = json_set_str(cout1, "seam_mode", json_get_string(thought, "seam_mode"))
|
||||
return json_set_str(cout2, "afferent", json_get_string(thought, "afferent"))
|
||||
}
|
||||
|
||||
// default (analyze_item): the CCR execution cycle think -> intend -> act,
|
||||
// with `think` routed through the CONFIGURABLE PRIMITIVE SEAM. Telemetry
|
||||
// (seam_mode + afferent tick) rides the worker's returned output.
|
||||
let instruction: String = "process input: " + input_item
|
||||
let thought: String = seam_think(ctx, instruction)
|
||||
let intent: String = primitive_intend(thought)
|
||||
let effect: String = primitive_act(intent, input_item)
|
||||
let e1: String = json_set_str(effect, "blueprint_status", "ok")
|
||||
let e2: String = json_set_str(e1, "seam_mode", json_get_string(thought, "seam_mode"))
|
||||
let e3: String = json_set_str(e2, "afferent", json_get_string(thought, "afferent"))
|
||||
return e3
|
||||
}
|
||||
|
||||
// ── native-thread fan-out, bounded by concurrency, order-preserving ──────────
|
||||
//
|
||||
// parallel_map (thread.el) spawns ALL threads at once. The swarm honours the
|
||||
// blueprint's `concurrency` cap (§5.1: a resource constraint, not a parallelism
|
||||
// constraint — all items are processed, at most N at a time) by dispatching in
|
||||
// waves of N native threads, joining each wave before the next. Results are
|
||||
// returned in input order.
|
||||
fn swarm_fanout(worker_fn: String, envelopes: [String], concurrency: Int) -> [String] {
|
||||
let n: Int = el_list_len(envelopes)
|
||||
let cap: Int = concurrency
|
||||
if cap < 1 {
|
||||
let cap = 1
|
||||
}
|
||||
let results: [String] = el_list_empty()
|
||||
let base = 0
|
||||
while base < n {
|
||||
// spawn a wave of up to `cap` workers
|
||||
let tids: [String] = el_list_empty()
|
||||
let k = 0
|
||||
while k < cap {
|
||||
let idx: Int = base + k
|
||||
if idx < n {
|
||||
let env_item: String = el_list_get(envelopes, idx)
|
||||
let tid: Int = spawn(worker_fn, env_item)
|
||||
let tids = el_list_append(tids, int_to_str(tid))
|
||||
}
|
||||
let k = k + 1
|
||||
}
|
||||
// join the wave in order
|
||||
let j = 0
|
||||
let jn: Int = el_list_len(tids)
|
||||
while j < jn {
|
||||
let tid: Int = str_to_int(el_list_get(tids, j))
|
||||
let r: String = join(tid)
|
||||
let results = el_list_append(results, r)
|
||||
let j = j + 1
|
||||
}
|
||||
let base = base + cap
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ── convergence strategies (Swarm §4.2) ──────────────────────────────────────
|
||||
|
||||
// swarm_converge_collect — ordered list, no transformation.
|
||||
fn swarm_converge_collect(results: [String]) -> String {
|
||||
let n: Int = el_list_len(results)
|
||||
let arr: String = "[]"
|
||||
let i = 0
|
||||
while i < n {
|
||||
let arr = json_array_push(arr, el_list_get(results, i))
|
||||
let i = i + 1
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// swarm_converge_merge — combine worker outputs into a single joined string.
|
||||
fn swarm_converge_merge(results: [String]) -> String {
|
||||
let n: Int = el_list_len(results)
|
||||
let merged: String = ""
|
||||
let i = 0
|
||||
while i < n {
|
||||
let out: String = json_get_raw(el_list_get(results, i), "output")
|
||||
if i > 0 {
|
||||
let merged = merged + " | "
|
||||
}
|
||||
let merged = merged + out
|
||||
let i = i + 1
|
||||
}
|
||||
return json_set_str("{}", "merged", merged)
|
||||
}
|
||||
|
||||
// swarm_converge_vote — tally a field across worker outputs, pick the majority.
|
||||
// Each worker output is expected to carry a "verdict" string field.
|
||||
fn swarm_converge_vote(results: [String]) -> String {
|
||||
let n: Int = el_list_len(results)
|
||||
// Collect verdicts (no mutable tally: json_set can't update an existing key
|
||||
// and there is no el_list_set). Then count each verdict by rescanning.
|
||||
let verdicts: [String] = el_list_empty()
|
||||
let i = 0
|
||||
while i < n {
|
||||
let out: String = json_get_raw(el_list_get(results, i), "output")
|
||||
let v: String = json_get_string(out, "verdict")
|
||||
if str_eq(v, "") {
|
||||
let i = i + 1
|
||||
} else {
|
||||
let verdicts = el_list_append(verdicts, v)
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
// pick the verdict with the highest count (first-past-the-post)
|
||||
let vn: Int = el_list_len(verdicts)
|
||||
let best: String = ""
|
||||
let bestc = 0
|
||||
let a = 0
|
||||
while a < vn {
|
||||
let cand: String = el_list_get(verdicts, a)
|
||||
// count occurrences of cand
|
||||
let c = 0
|
||||
let b = 0
|
||||
while b < vn {
|
||||
if str_eq(el_list_get(verdicts, b), cand) {
|
||||
let c = c + 1
|
||||
}
|
||||
let b = b + 1
|
||||
}
|
||||
if c > bestc {
|
||||
let bestc = c
|
||||
let best = cand
|
||||
}
|
||||
let a = a + 1
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "winner")
|
||||
let kv = el_list_append(kv, best)
|
||||
let kv = el_list_append(kv, "votes")
|
||||
let kv = el_list_append(kv, int_to_str(bestc))
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// swarm_converge_reduce — fold outputs into an accumulator (count + concat).
|
||||
fn swarm_converge_reduce(results: [String]) -> String {
|
||||
let n: Int = el_list_len(results)
|
||||
let acc: String = ""
|
||||
let i = 0
|
||||
while i < n {
|
||||
let out: String = json_get_raw(el_list_get(results, i), "output")
|
||||
let acc = acc + out
|
||||
let i = i + 1
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "count")
|
||||
let kv = el_list_append(kv, int_to_str(n))
|
||||
let kv = el_list_append(kv, "accumulated")
|
||||
let kv = el_list_append(kv, acc)
|
||||
return json_build_object(kv)
|
||||
}
|
||||
|
||||
// ratio_to_permille — parse a decimal ratio string ("1.0", "0.8") into an
|
||||
// integer per-mille (1000, 800) so failure thresholds use exact integer math.
|
||||
// (El float division is unreliable in this runtime — int_to_float(n)/int_to_float(n)
|
||||
// does not equal 1.0 — so the swarm deliberately avoids floats.)
|
||||
fn ratio_to_permille(s: String) -> Int {
|
||||
if str_eq(s, "") {
|
||||
return 1000
|
||||
}
|
||||
let parts: [String] = str_split(s, ".")
|
||||
let whole: Int = str_to_int(el_list_get(parts, 0))
|
||||
let permille: Int = whole * 1000
|
||||
if el_list_len(parts) > 1 {
|
||||
let frac_raw: String = el_list_get(parts, 1)
|
||||
let frac3: String = str_slice(str_pad_right(frac_raw, 3, "0"), 0, 3)
|
||||
let permille = permille + str_to_int(frac3)
|
||||
}
|
||||
return permille
|
||||
}
|
||||
|
||||
// swarm_converge — dispatch on strategy name.
|
||||
fn swarm_converge(strategy: String, results: [String]) -> String {
|
||||
if str_eq(strategy, "merge") {
|
||||
return swarm_converge_merge(results)
|
||||
}
|
||||
if str_eq(strategy, "vote") {
|
||||
return swarm_converge_vote(results)
|
||||
}
|
||||
if str_eq(strategy, "reduce") {
|
||||
return swarm_converge_reduce(results)
|
||||
}
|
||||
// default: collect
|
||||
return swarm_converge_collect(results)
|
||||
}
|
||||
|
||||
// ── the ONLY global-engram write path (Rule 4, @manager-only) ────────────────
|
||||
//
|
||||
// Every engram mutation flows through here and is gated by the caller's token
|
||||
// capability. Only the orchestrator's token carries engram:write, so a worker
|
||||
// (engram:read only) calling this is DENIED by capability before any HTTP is
|
||||
// issued — structurally unable to mutate global engram state, regardless of
|
||||
// engram health. This is the curated-merge write: the orchestrator committing
|
||||
// the geometry it approved. Workers never reach a successful branch here.
|
||||
fn swarm_engram_write(token: String, corr_id: String, content: String, typ: String, importance: Float) -> String {
|
||||
let deny: String = containment_guard_engram_write(token, corr_id, "engram.write")
|
||||
if str_eq(deny, "") {
|
||||
// authorized (orchestrator) — perform the write
|
||||
let res: String = op_write(content, typ, importance)
|
||||
let new_id: String = json_get_string(res, "id")
|
||||
let cp: String = json_set_str("{}", "node_id", new_id)
|
||||
worktrack_append("swarm.committed", corr_id, "orchestrator", cp)
|
||||
return res
|
||||
}
|
||||
// denied by capability — return the rejection, no engram mutation performed
|
||||
return json_set_str("{}", "denied", deny)
|
||||
}
|
||||
|
||||
// ── the coordinator: fan out -> track -> converge ────────────────────────────
|
||||
//
|
||||
// blueprint : task blueprint name run by every worker
|
||||
// knowledge_refs : JSON array of retrieval queries for CCR compilation
|
||||
// inputs_json : JSON array of input items (one per worker)
|
||||
// config_json : { concurrency, strategy, min_success_ratio,
|
||||
// failure_action, caller_token }
|
||||
//
|
||||
// Returns: { corr_id, status:"completed"|"aborted", merged, report }.
|
||||
fn swarm_run(blueprint: String, knowledge_refs: String, inputs_json: String, config_json: String) -> String {
|
||||
let corr_id: String = "swarm-" + uuid_v4()
|
||||
let caller_token: String = json_get_raw(config_json, "caller_token")
|
||||
let concurrency: Int = str_to_int(json_get_string(config_json, "concurrency"))
|
||||
if concurrency < 1 {
|
||||
let concurrency = 4
|
||||
}
|
||||
let strategy: String = json_get_string(config_json, "strategy")
|
||||
|
||||
// ── Containment Rule 2: only a coordinator/absent token may open a swarm ──
|
||||
let deny: String = containment_guard_open(caller_token, corr_id)
|
||||
if str_eq(deny, "") {
|
||||
// allowed — proceed
|
||||
let n: Int = json_array_len(inputs_json)
|
||||
|
||||
// swarm.created
|
||||
let cp: String = json_set_str("{}", "blueprint", blueprint)
|
||||
let cp2: String = json_set(cp, "input_count", int_to_str(n))
|
||||
worktrack_append("swarm.created", corr_id, corr_id, cp2)
|
||||
|
||||
// build per-worker envelopes: worker token + CCR-compiled bounded context
|
||||
let envelopes: [String] = el_list_empty()
|
||||
let i = 0
|
||||
while i < n {
|
||||
let worker_id: String = corr_id + "/worker-" + int_to_str(i)
|
||||
let input_item: String = json_array_get_string(inputs_json, i)
|
||||
let wtoken: String = containment_worker_token(corr_id, worker_id)
|
||||
let ctx: String = ccr_compile(blueprint, knowledge_refs, input_item, corr_id, worker_id, wtoken)
|
||||
// envelope: only this worker's compiled context + its closed token
|
||||
let ekv: [String] = el_list_empty()
|
||||
let ekv = el_list_append(ekv, "worker_id")
|
||||
let ekv = el_list_append(ekv, worker_id)
|
||||
let ekv = el_list_append(ekv, "corr_id")
|
||||
let ekv = el_list_append(ekv, corr_id)
|
||||
let env0: String = json_build_object(ekv)
|
||||
let env1: String = json_set(env0, "scope_token", wtoken)
|
||||
let env2: String = json_set(env1, "ctx", ctx)
|
||||
let envelopes = el_list_append(envelopes, env2)
|
||||
|
||||
let sp: String = json_set_str("{}", "input", input_item)
|
||||
worktrack_append("worker.started", corr_id, worker_id, sp)
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// ── native-thread fan-out (bounded) ──
|
||||
let results: [String] = swarm_fanout("swarm_worker_entry", envelopes, concurrency)
|
||||
|
||||
// record per-worker terminal status + aggregate AFFERENT telemetry.
|
||||
// Afferent counters (primitive signals travelling toward the @manager)
|
||||
// are summed from the vertical result path — no shared bus register,
|
||||
// so the aggregation is containment-safe.
|
||||
let succ = 0
|
||||
let afferent = 0
|
||||
let seam_mode_seen: String = "stub"
|
||||
let rn: Int = el_list_len(results)
|
||||
let r = 0
|
||||
while r < rn {
|
||||
let res: String = el_list_get(results, r)
|
||||
let wid: String = json_get_string(res, "worker_id")
|
||||
let st: String = json_get_string(res, "status")
|
||||
let out: String = json_get_raw(res, "output")
|
||||
let aff: Int = str_to_int(json_get_string(out, "afferent"))
|
||||
let afferent = afferent + aff
|
||||
let sm: String = json_get_string(out, "seam_mode")
|
||||
if str_eq(sm, "") {
|
||||
let seam_mode_seen = seam_mode_seen
|
||||
} else {
|
||||
let seam_mode_seen = sm
|
||||
}
|
||||
if str_eq(st, "completed") {
|
||||
let succ = succ + 1
|
||||
worktrack_append("worker.completed", corr_id, wid, json_set_str("{}", "status", "completed"))
|
||||
} else {
|
||||
worktrack_append("worker.failed", corr_id, wid, json_set_str("{}", "error", json_get_string(res, "error")))
|
||||
}
|
||||
let r = r + 1
|
||||
}
|
||||
|
||||
// swarm.converging
|
||||
let vg: String = json_set("{}", "success_count", int_to_str(succ))
|
||||
worktrack_append("swarm.converging", corr_id, corr_id, vg)
|
||||
|
||||
// swarm.telemetry — afferent counters observed by the @manager.
|
||||
let tkv: [String] = el_list_empty()
|
||||
let tkv = el_list_append(tkv, "seam_mode")
|
||||
let tkv = el_list_append(tkv, seam_mode_seen)
|
||||
let telem0: String = json_build_object(tkv)
|
||||
let telem1: String = json_set_str(telem0, "afferent_think", int_to_str(afferent))
|
||||
let telemetry: String = json_set_str(telem1, "results_received", int_to_str(rn))
|
||||
worktrack_append("swarm.telemetry", corr_id, corr_id, telemetry)
|
||||
|
||||
// ── failure threshold (Swarm §4.3), integer per-mille math ──
|
||||
// require succ/n >= min_success_ratio <=> succ*1000 >= permille*n
|
||||
let permille: Int = ratio_to_permille(json_get_string(config_json, "min_success_ratio"))
|
||||
let status: String = "completed"
|
||||
if succ * 1000 < permille * n {
|
||||
let status = "aborted"
|
||||
}
|
||||
|
||||
if str_eq(status, "aborted") {
|
||||
let ap: String = json_set_str("{}", "reason", "success ratio below min_success_ratio")
|
||||
worktrack_append("swarm.aborted", corr_id, corr_id, ap)
|
||||
let rep: String = worktrack_swarm_report(corr_id)
|
||||
let ok: [String] = el_list_empty()
|
||||
let ok = el_list_append(ok, "corr_id")
|
||||
let ok = el_list_append(ok, corr_id)
|
||||
let ok = el_list_append(ok, "status")
|
||||
let ok = el_list_append(ok, "aborted")
|
||||
let out0: String = json_build_object(ok)
|
||||
return json_set(out0, "report", rep)
|
||||
}
|
||||
|
||||
// ── converge ──
|
||||
let merged: String = swarm_converge(strategy, results)
|
||||
let dp: String = json_set_str("{}", "strategy", strategy)
|
||||
worktrack_append("swarm.completed", corr_id, corr_id, dp)
|
||||
|
||||
// ── curated merge = the ONLY engram write path (Rule 4) ──
|
||||
// With "commit":"1", the ORCHESTRATOR (its token carries engram:write)
|
||||
// commits the approved merged geometry back to the engram. This is the
|
||||
// single writer. Workers returned geometry; only the orchestrator writes.
|
||||
let commit_id: String = ""
|
||||
if str_eq(json_get_string(config_json, "commit"), "1") {
|
||||
let orch_token: String = containment_coordinator_token(corr_id)
|
||||
let cres: String = swarm_engram_write(orch_token, corr_id, "swarm-merge " + corr_id + " :: " + merged, "memory", 0.5)
|
||||
let commit_id = json_get_string(cres, "id")
|
||||
}
|
||||
|
||||
let rep2: String = worktrack_swarm_report(corr_id)
|
||||
let ok2: [String] = el_list_empty()
|
||||
let ok2 = el_list_append(ok2, "corr_id")
|
||||
let ok2 = el_list_append(ok2, corr_id)
|
||||
let ok2 = el_list_append(ok2, "status")
|
||||
let ok2 = el_list_append(ok2, "completed")
|
||||
let out1: String = json_build_object(ok2)
|
||||
let out2: String = json_set(out1, "report", rep2)
|
||||
let out3: String = json_set(out2, "merged", merged)
|
||||
let out4: String = json_set(out3, "telemetry", telemetry)
|
||||
return json_set_str(out4, "committed_node", commit_id)
|
||||
}
|
||||
// ── denied: caller was a worker trying to open a swarm (Rule 2) ──
|
||||
let dkv: [String] = el_list_empty()
|
||||
let dkv = el_list_append(dkv, "corr_id")
|
||||
let dkv = el_list_append(dkv, corr_id)
|
||||
let dkv = el_list_append(dkv, "status")
|
||||
let dkv = el_list_append(dkv, "denied")
|
||||
let dkv = el_list_append(dkv, "error")
|
||||
let dkv = el_list_append(dkv, deny)
|
||||
return json_build_object(dkv)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// harness_local_swarm.el — LOCAL-SWARM INTEGRATION HARNESS.
|
||||
//
|
||||
// Proves the FULL local-swarm mechanics end-to-end, TODAY, on the isolated
|
||||
// engram clone (:8901), with the primitive seam pointed at the hermetic stub.
|
||||
// The moment the api-reshape agent lands the decorated primitives on the
|
||||
// dharma bus, binding is ONE flip (primitive_binding.el) + SWARM_PRIMITIVE_SEAM=
|
||||
// decorated — this same harness then runs the bound path with no other change.
|
||||
//
|
||||
// The @manager (the coordinator) fans out N native El worker threads at real
|
||||
// concurrency, each given a CCR-scoped engram slice, each invoking the primitive
|
||||
// seam (think over its slice), enforces all three containment rules, converges
|
||||
// (vote AND reduce), work-tracks durably, and observes afferent telemetry.
|
||||
//
|
||||
// Run with the sandbox env sourced (ENGRAM_URL=:8901) to also exercise CCR
|
||||
// retrieval against the real (isolated) mind; runs fully without it too.
|
||||
|
||||
fn ok(label: String, cond: Bool, fails: Int) -> Int {
|
||||
if cond { print(" ok " + label); return fails }
|
||||
print(" FAIL " + label); return fails + 1
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let fails = 0
|
||||
print("== LOCAL-SWARM INTEGRATION HARNESS (seam=" + seam_mode() + ") ==")
|
||||
|
||||
// 8 independent slices, real concurrency of 4 (2 waves of native pthreads).
|
||||
let inputs: String = "[\"billing\",\"payments\",\"ledger\",\"invoicing\",\"tax\",\"payroll\",\"audit\",\"fx\"]"
|
||||
let refs: String = "[\"Volatility-Based Decomposition\"]"
|
||||
|
||||
// ── A) fan-out / converge at real concurrency (reduce) ──
|
||||
let cfg_r: String = "{\"concurrency\":\"4\",\"strategy\":\"reduce\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rr: String = swarm_run("analyze_item", refs, inputs, cfg_r)
|
||||
let fails = ok("swarm completed at concurrency=4 over 8 native-thread workers", str_eq(json_get_string(rr, "status"), "completed"), fails)
|
||||
let corr: String = json_get_string(rr, "corr_id")
|
||||
let merged_r: String = json_get_raw(rr, "merged")
|
||||
let fails = ok("reduce converged all 8 worker outputs", str_to_int(json_get_string(merged_r, "count")) == 8, fails)
|
||||
|
||||
// ── B) afferent telemetry observed by the @manager ──
|
||||
let telem: String = json_get_raw(rr, "telemetry")
|
||||
let aff: Int = str_to_int(json_get_string(telem, "afferent_think"))
|
||||
let seen_mode: String = json_get_string(telem, "seam_mode")
|
||||
let fails = ok("afferent think-signals counted = 8 (one per worker)", aff == 8, fails)
|
||||
let fails = ok("telemetry records the active seam mode", str_eq(seen_mode, seam_mode()), fails)
|
||||
let telem_recs: Int = worktrack_count_kind(corr, "swarm.telemetry")
|
||||
let fails = ok("telemetry durably journalled", telem_recs == 1, fails)
|
||||
|
||||
// ── C) CCR scoping + non-leak per worker ──
|
||||
let wt: String = containment_worker_token(corr, corr + "/worker-3")
|
||||
let ctx3: String = ccr_compile("analyze_item", refs, "invoicing", corr, corr + "/worker-3", wt)
|
||||
let fails = ok("CCR context bounded within token budget", ccr_within_budget(ctx3), fails)
|
||||
let fails = ok("CCR context carries THIS slice", str_eq(json_get_string(ctx3, "input"), "invoicing"), fails)
|
||||
let leaks: Bool = str_contains(ctx3, "payroll") || str_contains(ctx3, "audit")
|
||||
let fails = ok("CCR context does NOT leak sibling slices (security boundary)", !leaks, fails)
|
||||
|
||||
// ── D) all three containment rules ──
|
||||
let deny: String = containment_check_open(wt)
|
||||
let fails = ok("Rule 2: worker token may not OPEN a swarm", !str_eq(deny, ""), fails)
|
||||
let denyj: String = containment_check_join(wt, "other-swarm")
|
||||
let fails = ok("Rule 1: worker token may not JOIN another swarm", !str_eq(denyj, ""), fails)
|
||||
let lat: String = containment_check_lateral(wt, "sibling-9")
|
||||
let fails = ok("Rule 3: worker->worker lateral edge rejected", !str_eq(lat, ""), fails)
|
||||
let ver: String = containment_check_lateral(wt, "")
|
||||
let fails = ok("Rule 3: worker->manager vertical edge allowed", str_eq(ver, ""), fails)
|
||||
// enforced live: a worker-token caller is denied opening a real swarm
|
||||
let wcfg: String = json_set(cfg_r, "caller_token", wt)
|
||||
let denied: String = swarm_run("analyze_item", refs, inputs, wcfg)
|
||||
let fails = ok("Rule 2 enforced live: worker-caller swarm denied", str_eq(json_get_string(denied, "status"), "denied"), fails)
|
||||
|
||||
// ── E) vote convergence strategy at concurrency ──
|
||||
let cfg_v: String = "{\"concurrency\":\"8\",\"strategy\":\"vote\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rv: String = swarm_run("classify", refs, inputs, cfg_v)
|
||||
let winner: String = json_get_string(json_get_raw(rv, "merged"), "winner")
|
||||
// billing/payments/ledger/invoicing/payroll/audit = long(>4); tax/fx = short -> long wins
|
||||
let fails = ok("vote converged (winner=long)", str_eq(winner, "long"), fails)
|
||||
|
||||
// ── F) durable, inspectable work-tracking ──
|
||||
let started: Int = worktrack_count_kind(corr, "worker.started")
|
||||
let completed: Int = worktrack_count_kind(corr, "worker.completed")
|
||||
let fails = ok("work-tracking journal: 8 started + 8 completed", (started == 8) && (completed == 8), fails)
|
||||
|
||||
print("")
|
||||
if fails == 0 {
|
||||
print("HARNESS GREEN — full local-swarm mechanics proven with seam=" + seam_mode())
|
||||
return 0
|
||||
}
|
||||
print("HARNESS FAIL (" + int_to_str(fails) + ")")
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// harness_real_cognition.el — the LOCAL SWARM running REAL cognition.
|
||||
//
|
||||
// Run with: SWARM_PRIMITIVE_SEAM=decorated + the sandbox env sourced
|
||||
// (ENGRAM_URL=:8901). Each worker's `think` is BOUND to the reshape's proven
|
||||
// op_think (GET /api/think) over its NODE-ID anchor — real 768-dim gradients from
|
||||
// the live (isolated) geometry, not the stub. The @manager fans out N native-El
|
||||
// worker threads at real concurrency, converges (reduce + vote) over the real
|
||||
// cognition, enforces all three containment rules, observes afferent telemetry,
|
||||
// and work-tracks durably.
|
||||
//
|
||||
// Anchors are real self-neighbourhood node ids on the :8901 clone (free-text
|
||||
// anchors return "geometry unavailable", so these must be node ids).
|
||||
|
||||
fn ok(label: String, cond: Bool, fails: Int) -> Int {
|
||||
if cond { print(" ok " + label); return fails }
|
||||
print(" FAIL " + label); return fails + 1
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let fails = 0
|
||||
print("== REAL-COGNITION LOCAL SWARM (seam=" + seam_mode() + ", engram=" + env("ENGRAM_URL") + ") ==")
|
||||
|
||||
// ── 0) direct proof the bound primitive returns REAL cognition ──
|
||||
let g: String = op_think("self", "plan")
|
||||
let dim: Int = json_get_int(g, "dim")
|
||||
let nsup: Int = json_get_int(g, "n_support")
|
||||
let fails = ok("bound op_think returns a real 768-dim gradient", dim == 768, fails)
|
||||
let fails = ok("real gradient has support (n_support>0)", nsup > 0, fails)
|
||||
let gfree: String = op_think("this-is-free-text-not-a-node", "reason")
|
||||
let fails = ok("free-text anchor correctly refused (geometry unavailable)", str_contains(gfree, "geometry unavailable"), fails)
|
||||
|
||||
// ── the input set: 8 real NODE-ID anchors from self's neighbourhood ──
|
||||
let anchors: String = "[\"a1000001-0000-0000-0000-000000000001\",\"5f011441-fa43-4fe7-a9c0-c78a584ef11d\",\"kn-5adecd7e-d6db-4576-87fe-6ef8a935cea6\",\"76d7fd0b-0672-4511-a2f5-a095cf9c60ae\",\"7027e302-593f-441d-8fd6-9c400c163108\",\"2a730b18-6566-46ee-a21e-4f4dd0380908\",\"46b0e4dd-2c19-48d2-bcbc-19f61d6c79ae\",\"9162cde8-8739-4f00-bfc9-2850ed612e50\"]"
|
||||
let refs: String = "[\"self\"]"
|
||||
|
||||
// ── A) fan-out real cognition at concurrency, converge with REDUCE ──
|
||||
let cfg_r: String = "{\"concurrency\":\"4\",\"strategy\":\"reduce\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rr: String = swarm_run("cognize", refs, anchors, cfg_r)
|
||||
let fails = ok("swarm completed: 8 workers each a real think, concurrency=4", str_eq(json_get_string(rr, "status"), "completed"), fails)
|
||||
let corr: String = json_get_string(rr, "corr_id")
|
||||
let merged_r: String = json_get_raw(rr, "merged")
|
||||
let fails = ok("reduce converged all 8 real-cognition outputs", str_to_int(json_get_string(merged_r, "count")) == 8, fails)
|
||||
let acc: String = json_get_string(merged_r, "accumulated")
|
||||
let fails = ok("converged output carries real gradient support (n_support)", str_contains(acc, "n_support"), fails)
|
||||
|
||||
// ── B) afferent telemetry: 8 real think-signals, decorated seam ──
|
||||
let telem: String = json_get_raw(rr, "telemetry")
|
||||
let aff: Int = str_to_int(json_get_string(telem, "afferent_think"))
|
||||
let fails = ok("afferent counters = 8 real think invocations", aff == 8, fails)
|
||||
let fails = ok("telemetry records seam_mode=decorated", str_eq(json_get_string(telem, "seam_mode"), "decorated"), fails)
|
||||
let fails = ok("telemetry durably journalled", worktrack_count_kind(corr, "swarm.telemetry") == 1, fails)
|
||||
|
||||
// ── C) converge with VOTE over real cognition ──
|
||||
let cfg_v: String = "{\"concurrency\":\"8\",\"strategy\":\"vote\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rv: String = swarm_run("cognize", refs, anchors, cfg_v)
|
||||
let winner: String = json_get_string(json_get_raw(rv, "merged"), "winner")
|
||||
let fails = ok("vote converged over real cognition (winner=" + winner + ")", !str_eq(winner, ""), fails)
|
||||
|
||||
// ── D) all three containment rules still enforced ──
|
||||
let wt: String = containment_worker_token(corr, corr + "/worker-2")
|
||||
let fails = ok("Rule 2: worker may not open a swarm", !str_eq(containment_check_open(wt), ""), fails)
|
||||
let fails = ok("Rule 1: worker may not join another swarm", !str_eq(containment_check_join(wt, "s2"), ""), fails)
|
||||
let fails = ok("Rule 3: worker->worker lateral edge rejected", !str_eq(containment_check_lateral(wt, "sib"), ""), fails)
|
||||
let wcfg: String = json_set(cfg_r, "caller_token", wt)
|
||||
let denied: String = swarm_run("cognize", refs, anchors, wcfg)
|
||||
let fails = ok("Rule 2 enforced LIVE: worker-caller swarm denied", str_eq(json_get_string(denied, "status"), "denied"), fails)
|
||||
|
||||
// ── E) CCR scoping + non-leak over node-id anchors ──
|
||||
let ctx: String = ccr_compile("cognize", refs, "a1000001-0000-0000-0000-000000000001", corr, corr + "/worker-0", wt)
|
||||
let fails = ok("CCR context bounded within budget", ccr_within_budget(ctx), fails)
|
||||
let leaks: Bool = str_contains(ctx, "9162cde8")
|
||||
let fails = ok("CCR context does NOT leak sibling anchors", !leaks, fails)
|
||||
|
||||
// ── F) durable work-tracking ──
|
||||
let started: Int = worktrack_count_kind(corr, "worker.started")
|
||||
let completed: Int = worktrack_count_kind(corr, "worker.completed")
|
||||
let fails = ok("work-tracking: 8 started + 8 completed", (started == 8) && (completed == 8), fails)
|
||||
|
||||
// ── G) RULE 4 — engram-write is @manager-ONLY (authority gate) ──
|
||||
// A worker token (engram:read only) is STRUCTURALLY denied any engram write.
|
||||
let worker_tok: String = containment_worker_token(corr, corr + "/worker-1")
|
||||
let orch_tok: String = containment_coordinator_token(corr)
|
||||
let fails = ok("worker token carries engram:read", containment_has_cap(worker_tok, "engram:read"), fails)
|
||||
let fails = ok("worker token does NOT carry engram:write", !containment_has_cap(worker_tok, "engram:write"), fails)
|
||||
let fails = ok("orchestrator token carries engram:write", containment_has_cap(orch_tok, "engram:write"), fails)
|
||||
// a worker attempting an engram write is DENIED BY CAPABILITY (no HTTP issued)
|
||||
let wdeny: String = swarm_engram_write(worker_tok, corr, "worker tries to mutate global state", "memory", 0.5)
|
||||
let denied_reason: String = json_get_string(wdeny, "denied")
|
||||
let fails = ok("worker engram-write DENIED by capability (Rule 4)", str_contains(denied_reason, "rule 4"), fails)
|
||||
let fails = ok("denied worker write performed NO engram mutation (no node id)", str_eq(json_get_string(wdeny, "id"), ""), fails)
|
||||
let fails = ok("Rule-4 violation journalled", worktrack_count_kind(corr, "containment.violation") >= 1, fails)
|
||||
// the orchestrator passes the capability gate (sole authorized writer)
|
||||
let odeny: String = containment_check_engram_write(orch_tok, "engram.write")
|
||||
let fails = ok("orchestrator PASSES the engram-write capability gate (sole writer)", str_eq(odeny, ""), fails)
|
||||
|
||||
// ── H) curated merge = the only write path (orchestrator commits) ──
|
||||
// The AUTHORITY gate above is already proven (worker denied, orchestrator
|
||||
// authorized) WITHOUT issuing a write. The actual persisting commit exercises
|
||||
// the engram write path, which needs the gate-1 write-healthy clone — so it
|
||||
// runs only under SWARM_WRITE_HEALTHY=1 (else it would hit the known daemon
|
||||
// write-crash). Authority != health: the gate holds either way.
|
||||
if str_eq(env("SWARM_WRITE_HEALTHY"), "1") {
|
||||
let cfg_commit: String = "{\"concurrency\":\"4\",\"strategy\":\"reduce\",\"min_success_ratio\":\"1.0\",\"commit\":\"1\"}"
|
||||
let rc: String = swarm_run("cognize", refs, anchors, cfg_commit)
|
||||
let committed: String = json_get_string(rc, "committed_node")
|
||||
let fails2: Int = ok("orchestrator (sole writer) committed the merge to the engram", !str_eq(committed, ""), fails)
|
||||
let fails = fails2
|
||||
} else {
|
||||
print(" note curated-merge commit deferred to the gate-1 write-healthy clone (set SWARM_WRITE_HEALTHY=1); authority gate already proven above")
|
||||
}
|
||||
|
||||
print("")
|
||||
if fails == 0 {
|
||||
print("REAL-COGNITION SWARM GREEN — Neuron thinking in parallel over its own geometry.")
|
||||
return 0
|
||||
}
|
||||
print("REAL-COGNITION SWARM FAIL (" + int_to_str(fails) + ")")
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// integ_engram.el — integration proof against a LIVE (isolated) engram.
|
||||
//
|
||||
// Run with the sandbox env sourced (ENGRAM_URL=http://127.0.0.1:8901,
|
||||
// ENGRAM_API_KEY=sbx-dev-swarm-ccr). Proves:
|
||||
// (a) CCR retrieval pulls REAL content from the mind over HTTP;
|
||||
// (b) a full swarm runs and converges against the live mind;
|
||||
// (c) work-tracking mirrors records into the engram as SwarmTrack nodes.
|
||||
|
||||
fn main() -> Int {
|
||||
let url: String = env("ENGRAM_URL")
|
||||
if str_eq(url, "") {
|
||||
print("SKIP integ_engram (ENGRAM_URL not set)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// (a) CCR compiles a bounded context whose retrieval hit the real mind.
|
||||
let refs: String = "[\"Volatility-Based Decomposition\",\"Swarm Architecture containment\"]"
|
||||
let wt: String = containment_worker_token("integ", "integ/w0")
|
||||
let ctx: String = ccr_compile("analyze_item", refs, "decompose the billing module", "integ", "integ/w0", wt)
|
||||
let knowledge: String = json_get_string(ctx, "knowledge")
|
||||
let pulled_real: Bool = str_contains(knowledge, "olatility") || str_contains(knowledge, "Anderson") || str_contains(knowledge, "VBD")
|
||||
if pulled_real {
|
||||
print(" ok CCR retrieval pulled real mind content (" + int_to_str(str_len(knowledge)) + " bytes, bounded)")
|
||||
} else {
|
||||
print(" FAIL CCR retrieval returned no mind content")
|
||||
}
|
||||
let bounded: Bool = ccr_within_budget(ctx)
|
||||
if bounded { print(" ok compiled context stayed within budget") } else { print(" FAIL context over budget") }
|
||||
|
||||
// (b) a real swarm over the live mind.
|
||||
let inputs: String = "[\"billing\",\"payments\",\"ledger\"]"
|
||||
let cfg: String = "{\"concurrency\":\"3\",\"strategy\":\"collect\",\"min_success_ratio\":\"1.0\"}"
|
||||
let res: String = swarm_run("analyze_item", refs, inputs, cfg)
|
||||
let status: String = json_get_string(res, "status")
|
||||
if str_eq(status, "completed") { print(" ok swarm completed against live engram") } else { print(" FAIL swarm status=" + status) }
|
||||
let corr: String = json_get_string(res, "corr_id")
|
||||
|
||||
// (c) work-tracking mirrored into the mind: search for this swarm's records.
|
||||
let hits: String = primitive_attend(corr, 5)
|
||||
let mirrored: Bool = str_contains(hits, "swarm-track") || str_contains(hits, corr)
|
||||
if mirrored { print(" ok work-tracking mirrored into the engram (queryable)") } else { print(" note mirror not yet visible to search (async index)") }
|
||||
|
||||
print("DONE integ_engram corr=" + corr)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// test_convergence.el — convergence strategies + failure threshold / abort.
|
||||
|
||||
fn assert_true(label: String, cond: Bool, fails: Int) -> Int {
|
||||
if cond { print(" ok " + label); return fails }
|
||||
print(" FAIL " + label); return fails + 1
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let fails = 0
|
||||
let refs: String = "[]"
|
||||
|
||||
// ── vote: classify 5 inputs; 3 "long" (>4 chars) vs 2 "short" -> winner long ──
|
||||
let inputs: String = "[\"alpha\",\"bravo\",\"hi\",\"charlie\",\"ok\"]"
|
||||
let cfg_v: String = "{\"concurrency\":\"3\",\"strategy\":\"vote\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rv: String = swarm_run("classify", refs, inputs, cfg_v)
|
||||
let merged_v: String = json_get_raw(rv, "merged")
|
||||
let winner: String = json_get_string(merged_v, "winner")
|
||||
let votes: Int = str_to_int(json_get_string(merged_v, "votes"))
|
||||
let fails = assert_true("vote winner = long", str_eq(winner, "long"), fails)
|
||||
let fails = assert_true("vote count = 3", votes == 3, fails)
|
||||
|
||||
// ── merge: outputs joined ──
|
||||
let cfg_m: String = "{\"concurrency\":\"2\",\"strategy\":\"merge\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rm: String = swarm_run("analyze_item", refs, "[\"a\",\"b\",\"c\"]", cfg_m)
|
||||
let merged_m: String = json_get_raw(rm, "merged")
|
||||
let joined: String = json_get_string(merged_m, "merged")
|
||||
let fails = assert_true("merge produced a joined string", str_contains(joined, "|"), fails)
|
||||
|
||||
// ── reduce: count accumulates ──
|
||||
let cfg_r: String = "{\"concurrency\":\"4\",\"strategy\":\"reduce\",\"min_success_ratio\":\"1.0\"}"
|
||||
let rr: String = swarm_run("analyze_item", refs, "[\"a\",\"b\",\"c\",\"d\"]", cfg_r)
|
||||
let merged_r: String = json_get_raw(rr, "merged")
|
||||
let rcount: Int = str_to_int(json_get_string(merged_r, "count"))
|
||||
let fails = assert_true("reduce count = 4", rcount == 4, fails)
|
||||
|
||||
// ── failure threshold: 2 of 5 fail (x-prefixed); ratio 3/5=0.6 < 0.8 -> aborted ──
|
||||
let fin: String = "[\"a\",\"xb\",\"c\",\"xd\",\"e\"]"
|
||||
let cfg_f: String = "{\"concurrency\":\"5\",\"strategy\":\"collect\",\"min_success_ratio\":\"0.8\"}"
|
||||
let rf: String = swarm_run("faildemo", refs, fin, cfg_f)
|
||||
let fstatus: String = json_get_string(rf, "status")
|
||||
let fails = assert_true("swarm aborted below min_success_ratio (0.6<0.8)", str_eq(fstatus, "aborted"), fails)
|
||||
let corr_f: String = json_get_string(rf, "corr_id")
|
||||
let failed_n: Int = worktrack_count_kind(corr_f, "worker.failed")
|
||||
let aborted_n: Int = worktrack_count_kind(corr_f, "swarm.aborted")
|
||||
let fails = assert_true("tracked 2 worker.failed", failed_n == 2, fails)
|
||||
let fails = assert_true("tracked swarm.aborted", aborted_n == 1, fails)
|
||||
|
||||
// ── same failures tolerated when min_success_ratio=0.5 (0.6>=0.5) -> completed ──
|
||||
let cfg_ok: String = "{\"concurrency\":\"5\",\"strategy\":\"collect\",\"min_success_ratio\":\"0.5\"}"
|
||||
let rok: String = swarm_run("faildemo", refs, fin, cfg_ok)
|
||||
let fails = assert_true("swarm completes when failures within tolerance", str_eq(json_get_string(rok, "status"), "completed"), fails)
|
||||
|
||||
if fails == 0 { print("PASS test_convergence"); return 0 }
|
||||
print("FAIL test_convergence (" + int_to_str(fails) + ")"); return 1
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// test_swarm.el — end-to-end proof of the swarm capability on native El threads.
|
||||
//
|
||||
// Proves: native-thread fan-out/converge, bounded concurrency, per-worker CCR
|
||||
// bounded context (with the security-boundary property), containment Rule 2
|
||||
// enforcement, and durable work-tracking.
|
||||
|
||||
fn assert_true(label: String, cond: Bool, fails: Int) -> Int {
|
||||
if cond {
|
||||
print(" ok " + label)
|
||||
return fails
|
||||
}
|
||||
print(" FAIL " + label)
|
||||
return fails + 1
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let fails = 0
|
||||
|
||||
// ── 1) fan-out / converge (collect) over native threads ──
|
||||
let inputs: String = "[\"alpha\",\"bravo\",\"charlie\",\"delta\",\"echo\"]"
|
||||
let refs: String = "[]"
|
||||
let cfg: String = "{\"concurrency\":\"2\",\"strategy\":\"collect\",\"min_success_ratio\":\"1.0\"}"
|
||||
let res: String = swarm_run("analyze_item", refs, inputs, cfg)
|
||||
let status: String = json_get_string(res, "status")
|
||||
let fails = assert_true("swarm completed", str_eq(status, "completed"), fails)
|
||||
|
||||
let merged: String = json_get_raw(res, "merged")
|
||||
let count: Int = json_array_len(merged)
|
||||
let fails = assert_true("collect returned 5 results (bounded concurrency=2)", count == 5, fails)
|
||||
|
||||
// ── 2) work-tracking is durable + complete ──
|
||||
let corr: String = json_get_string(res, "corr_id")
|
||||
let started: Int = worktrack_count_kind(corr, "worker.started")
|
||||
let completed: Int = worktrack_count_kind(corr, "worker.completed")
|
||||
let created: Int = worktrack_count_kind(corr, "swarm.created")
|
||||
let done: Int = worktrack_count_kind(corr, "swarm.completed")
|
||||
let fails = assert_true("tracked 5 worker.started", started == 5, fails)
|
||||
let fails = assert_true("tracked 5 worker.completed", completed == 5, fails)
|
||||
let fails = assert_true("tracked swarm.created + swarm.completed", (created == 1) && (done == 1), fails)
|
||||
|
||||
// ── 3) CCR: bounded, minimal, non-leaking per-worker context ──
|
||||
let wtoken: String = containment_worker_token(corr, corr + "/worker-0")
|
||||
let ctx: String = ccr_compile("analyze_item", refs, "alpha", corr, corr + "/worker-0", wtoken)
|
||||
let in_budget: Bool = ccr_within_budget(ctx)
|
||||
let fails = assert_true("CCR context within token budget", in_budget, fails)
|
||||
let this_input: String = json_get_string(ctx, "input")
|
||||
let fails = assert_true("CCR context contains THIS worker's input", str_eq(this_input, "alpha"), fails)
|
||||
// security boundary: a worker's compiled context must not carry a sibling input
|
||||
let leaks_sibling: Bool = str_contains(ctx, "charlie")
|
||||
let fails = assert_true("CCR context does NOT leak sibling inputs", !leaks_sibling, fails)
|
||||
|
||||
// ── 4) containment Rule 2: a worker may not open a swarm ──
|
||||
let worker_caller_cfg: String = json_set(cfg, "caller_token", wtoken)
|
||||
let denied: String = swarm_run("analyze_item", refs, inputs, worker_caller_cfg)
|
||||
let dstatus: String = json_get_string(denied, "status")
|
||||
let fails = assert_true("worker-token caller denied opening a swarm (Rule 2)", str_eq(dstatus, "denied"), fails)
|
||||
|
||||
// coordinator token IS allowed
|
||||
let coord: String = containment_coordinator_token("some-corr")
|
||||
let allow_reason: String = containment_check_open(coord)
|
||||
let fails = assert_true("coordinator token allowed to open a swarm", str_eq(allow_reason, ""), fails)
|
||||
|
||||
// ── 5) containment Rule 3: no lateral worker->worker edge ──
|
||||
let lateral: String = containment_check_lateral(wtoken, "some-sibling")
|
||||
let fails = assert_true("lateral worker->worker edge rejected (Rule 3)", !str_eq(lateral, ""), fails)
|
||||
let vertical: String = containment_check_lateral(wtoken, "")
|
||||
let fails = assert_true("vertical worker->coordinator edge allowed", str_eq(vertical, ""), fails)
|
||||
|
||||
if fails == 0 {
|
||||
print("PASS test_swarm")
|
||||
return 0
|
||||
}
|
||||
print("FAIL test_swarm (" + int_to_str(fails) + " failures)")
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// test_worktrack.el — durability + inspectability of the work-tracking journal.
|
||||
|
||||
fn main() -> Int {
|
||||
let corr: String = "test-" + uuid_v4()
|
||||
|
||||
// record a swarm lifecycle
|
||||
let p1: String = json_set("{}", "input_count", "3")
|
||||
worktrack_append("swarm.created", corr, "swarm-1", p1)
|
||||
worktrack_append("worker.started", corr, "worker-001", "{}")
|
||||
worktrack_append("worker.started", corr, "worker-002", "{}")
|
||||
worktrack_append("worker.completed", corr, "worker-001", "{}")
|
||||
worktrack_append("worker.failed", corr, "worker-002", "{}")
|
||||
worktrack_append("swarm.completed", corr, "swarm-1", "{}")
|
||||
|
||||
// inspect: reconstruct the report from the durable journal
|
||||
let report: String = worktrack_swarm_report(corr)
|
||||
print("report=" + report)
|
||||
|
||||
let recs_n: Int = el_list_len(worktrack_records(corr))
|
||||
print("records=" + int_to_str(recs_n))
|
||||
|
||||
let state: String = json_get_string(report, "state")
|
||||
let completed: Int = str_to_int(json_get_string(report, "workers_completed"))
|
||||
let failed: Int = str_to_int(json_get_string(report, "workers_failed"))
|
||||
|
||||
if str_eq(state, "completed") {
|
||||
if completed == 1 {
|
||||
if failed == 1 {
|
||||
if recs_n == 6 {
|
||||
print("PASS worktrack")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
print("FAIL worktrack")
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// worktrack.el — full work-tracking for the swarm.
|
||||
//
|
||||
// "Intent all the way up, orchestrator at the top." Every unit of parallel
|
||||
// work a swarm fans out is recorded here: the swarm itself, each worker, its
|
||||
// status, its result summary, the convergence, and the final merged output —
|
||||
// all threaded by a single correlation ID so the entire execution graph can be
|
||||
// reconstructed and audited (Swarm Architecture §6.1).
|
||||
//
|
||||
// DURABILITY. Records are appended to a JSON-lines journal on disk. The journal
|
||||
// is append-only and single-writer: only the coordinator (the main thread, before
|
||||
// and after each fan-out and during convergence) writes to it. Workers never
|
||||
// touch it — they return structured results and the coordinator records them.
|
||||
// This is deliberate: it makes the tracking store race-free and, not
|
||||
// coincidentally, enforces Swarm containment rule 3 (no lateral worker state).
|
||||
//
|
||||
// INSPECTABILITY. The journal is plain JSONL — greppable, tailable, replayable.
|
||||
// worktrack_read() loads it back; worktrack_swarm_report() reconstructs a
|
||||
// swarm's full record from its correlation ID.
|
||||
//
|
||||
// ENGRAM MIRROR (optional). When ENGRAM_URL is set, each record is also mirrored
|
||||
// into the engram as a node (POST /api/node) tagged with the correlation ID, so
|
||||
// the swarm's execution becomes part of the durable mind, queryable by memory.
|
||||
//
|
||||
// Depends on: el_runtime.c builtins (fs_*, http_post, env, json_*, uuid_v4,
|
||||
// now_millis, str_*). No El-module concat dependencies of its own.
|
||||
|
||||
// ── JSON helper ──────────────────────────────────────────────────────────────
|
||||
// json_set inserts its value as a RAW JSON fragment (objects/arrays/numbers).
|
||||
// json_set_str sets a plain STRING value, correctly quoted and escaped. Use
|
||||
// json_set for nested JSON, json_set_str for strings.
|
||||
fn json_set_str(j: String, key: String, val: String) -> String {
|
||||
return json_set(j, key, "\"" + json_escape_string(val) + "\"")
|
||||
}
|
||||
|
||||
// ── Journal location ─────────────────────────────────────────────────────────
|
||||
|
||||
// worktrack_dir — directory holding the swarm journals.
|
||||
// Override with SWARM_TRACK_DIR; defaults to ./.swarm-track (relative to CWD).
|
||||
fn worktrack_dir() -> String {
|
||||
let d: String = env("SWARM_TRACK_DIR")
|
||||
if str_eq(d, "") {
|
||||
return ".swarm-track"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// worktrack_journal_path — the JSONL journal file for one correlation ID.
|
||||
fn worktrack_journal_path(corr_id: String) -> String {
|
||||
return worktrack_dir() + "/" + corr_id + ".jsonl"
|
||||
}
|
||||
|
||||
// worktrack_init — ensure the journal directory exists. Idempotent.
|
||||
fn worktrack_init() -> Bool {
|
||||
let d: String = worktrack_dir()
|
||||
if fs_exists(d) {
|
||||
return true
|
||||
}
|
||||
return fs_mkdir(d)
|
||||
}
|
||||
|
||||
// ── Record construction ──────────────────────────────────────────────────────
|
||||
|
||||
// worktrack_record — build one journal record as a JSON object string.
|
||||
// kind: the record kind (swarm.created, worker.started, ...)
|
||||
// corr_id: the swarm correlation ID (links every record)
|
||||
// subject: the entity the record is about (swarm id, worker id, "")
|
||||
// payload: a JSON object string with kind-specific fields
|
||||
fn worktrack_record(kind: String, corr_id: String, subject: String, payload: String) -> String {
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "kind")
|
||||
let kv = el_list_append(kv, kind)
|
||||
let kv = el_list_append(kv, "corr_id")
|
||||
let kv = el_list_append(kv, corr_id)
|
||||
let kv = el_list_append(kv, "subject")
|
||||
let kv = el_list_append(kv, subject)
|
||||
let kv = el_list_append(kv, "ts_ms")
|
||||
let kv = el_list_append(kv, int_to_str(now_millis()))
|
||||
let rec: String = json_build_object(kv)
|
||||
// Attach the payload as a nested raw JSON field.
|
||||
let rec2: String = json_set(rec, "data", payload)
|
||||
return rec2
|
||||
}
|
||||
|
||||
// ── Journal append (single-writer, durable) ──────────────────────────────────
|
||||
|
||||
// worktrack_append — append one record to the correlation journal (durable),
|
||||
// and mirror it to the engram if ENGRAM_URL is configured. Returns the record.
|
||||
//
|
||||
// fs_write here is used in append semantics: we read-modify-write the file. The
|
||||
// coordinator is the only writer, so this is safe and race-free.
|
||||
fn worktrack_append(kind: String, corr_id: String, subject: String, payload: String) -> String {
|
||||
worktrack_init()
|
||||
let rec: String = worktrack_record(kind, corr_id, subject, payload)
|
||||
let path: String = worktrack_journal_path(corr_id)
|
||||
let prior: String = ""
|
||||
if fs_exists(path) {
|
||||
let prior = fs_read(path)
|
||||
}
|
||||
let next: String = prior + rec + "\n"
|
||||
fs_write(path, next)
|
||||
worktrack_mirror_engram(rec, corr_id, kind, subject)
|
||||
return rec
|
||||
}
|
||||
|
||||
// worktrack_mirror_engram — best-effort mirror of a record into the engram.
|
||||
// No-op unless ENGRAM_URL is set. Failures are swallowed (tracking must not
|
||||
// depend on the mind being reachable).
|
||||
fn worktrack_mirror_engram(rec: String, corr_id: String, kind: String, subject: String) -> Bool {
|
||||
// Opt-in: the durable substrate is the JSONL journal (always written). The
|
||||
// engram mirror is an additional convenience, enabled with SWARM_MIRROR=1,
|
||||
// so a swarm never depends on — or loads — the mind just to track its work.
|
||||
if str_eq(env("SWARM_MIRROR"), "1") {
|
||||
// enabled — fall through to the mirror POST
|
||||
let _go: Int = 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
let url: String = env("ENGRAM_URL")
|
||||
if str_eq(url, "") {
|
||||
return false
|
||||
}
|
||||
let content: String = "swarm-track " + kind + " " + subject + " :: " + rec
|
||||
let body_kv: [String] = el_list_empty()
|
||||
let body_kv = el_list_append(body_kv, "content")
|
||||
let body_kv = el_list_append(body_kv, content)
|
||||
let body_kv = el_list_append(body_kv, "node_type")
|
||||
let body_kv = el_list_append(body_kv, "SwarmTrack")
|
||||
let body_kv = el_list_append(body_kv, "salience")
|
||||
let body_kv = el_list_append(body_kv, "0.5")
|
||||
let body: String = json_build_object(body_kv)
|
||||
let key: String = env("ENGRAM_API_KEY")
|
||||
let body2: String = json_set_str(body, "_auth", key)
|
||||
let resp: String = http_post(url + "/api/nodes", body2)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Read / inspect ───────────────────────────────────────────────────────────
|
||||
|
||||
// worktrack_read — read the raw JSONL journal for a correlation ID.
|
||||
fn worktrack_read(corr_id: String) -> String {
|
||||
let path: String = worktrack_journal_path(corr_id)
|
||||
if fs_exists(path) {
|
||||
return fs_read(path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// worktrack_records — the journal as a [String] of record JSON objects, in order.
|
||||
fn worktrack_records(corr_id: String) -> [String] {
|
||||
let raw: String = worktrack_read(corr_id)
|
||||
let out: [String] = el_list_empty()
|
||||
if str_eq(raw, "") {
|
||||
return out
|
||||
}
|
||||
let lines: [String] = str_split_lines(raw)
|
||||
let n: Int = el_list_len(lines)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let ln: String = el_list_get(lines, i)
|
||||
if str_eq(ln, "") {
|
||||
let i = i + 1
|
||||
} else {
|
||||
let out = el_list_append(out, ln)
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// worktrack_count_kind — how many records of a given kind exist for a swarm.
|
||||
// Powers assertions and live status ("how many workers completed").
|
||||
fn worktrack_count_kind(corr_id: String, kind: String) -> Int {
|
||||
let recs: [String] = worktrack_records(corr_id)
|
||||
let n: Int = el_list_len(recs)
|
||||
let c = 0
|
||||
let i = 0
|
||||
while i < n {
|
||||
let r: String = el_list_get(recs, i)
|
||||
let k: String = json_get_string(r, "kind")
|
||||
if str_eq(k, kind) {
|
||||
let c = c + 1
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// worktrack_swarm_report — reconstruct a compact status report for a swarm from
|
||||
// its journal: counts of started/completed/failed workers and terminal state.
|
||||
// Inspectable, durable, derived purely from the append-only record.
|
||||
fn worktrack_swarm_report(corr_id: String) -> String {
|
||||
let started: Int = worktrack_count_kind(corr_id, "worker.started")
|
||||
let completed: Int = worktrack_count_kind(corr_id, "worker.completed")
|
||||
let failed: Int = worktrack_count_kind(corr_id, "worker.failed")
|
||||
let done: Int = worktrack_count_kind(corr_id, "swarm.completed")
|
||||
let aborted: Int = worktrack_count_kind(corr_id, "swarm.aborted")
|
||||
let state: String = "running"
|
||||
if aborted > 0 {
|
||||
let state = "aborted"
|
||||
} else {
|
||||
if done > 0 {
|
||||
let state = "completed"
|
||||
}
|
||||
}
|
||||
let kv: [String] = el_list_empty()
|
||||
let kv = el_list_append(kv, "corr_id")
|
||||
let kv = el_list_append(kv, corr_id)
|
||||
let kv = el_list_append(kv, "state")
|
||||
let kv = el_list_append(kv, state)
|
||||
let kv = el_list_append(kv, "workers_started")
|
||||
let kv = el_list_append(kv, int_to_str(started))
|
||||
let kv = el_list_append(kv, "workers_completed")
|
||||
let kv = el_list_append(kv, int_to_str(completed))
|
||||
let kv = el_list_append(kv, "workers_failed")
|
||||
let kv = el_list_append(kv, int_to_str(failed))
|
||||
return json_build_object(kv)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// tests/native/test_compiler.el — comprehensive tests for the El compiler pipeline.
|
||||
//
|
||||
// Tests the lexer (lexer.el), parser (parser.el), and codegen (codegen.el)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_codegen_js.el - basic tests for JS codegen features.
|
||||
//
|
||||
// These tests verify that core El language features produce correct values
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_env.el - native test suite for runtime/env.el
|
||||
//
|
||||
// Covers: env() for reading environment variables, args() returning a list,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_fs.el - native test suite for runtime/fs.el
|
||||
//
|
||||
// Covers: fs_write/read round-trip, fs_exists, fs_mkdir, fs_list,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_json.el - native test suite for runtime/json.el
|
||||
//
|
||||
// Covers: json_get (dot-path), typed extractors (int, bool, float),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_math.el - native test suite for runtime/math.el
|
||||
//
|
||||
// Covers: integer math (abs, max, min), float math (sqrt, log, sin, cos, pi),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_state.el - native test suite for runtime/state.el
|
||||
//
|
||||
// Covers: state_set/get/del, state_has, state_get_or, state_keys,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_string.el - native test suite for runtime/string.el
|
||||
//
|
||||
// Covers: type conversions, core primitives, comparison and search,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_text.el - native test suite for text primitives.
|
||||
//
|
||||
// Mirrors the acceptance corpus in tests/text/examples/ using the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_time.el - native test suite for runtime/time.el
|
||||
//
|
||||
// Covers: time_now (positive timestamp), time_to_parts (UTC decomposition),
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// test_transduce.el — geometry as a first-class El value, and realizers
|
||||
// declared in El rather than patched into the runtime.
|
||||
//
|
||||
// WHAT IS ACTUALLY UNDER TEST. Until 2026-08-16 no El ingest path could carry
|
||||
// a vector: nodes took text, and geometry was DERIVED from that text. Text was
|
||||
// therefore the mandatory entry medium, so any non-text modality had to be
|
||||
// DESCRIBED in prose first and the geometry we reasoned over was the geometry
|
||||
// OF THE DESCRIPTION, not of the signal. The fix has two halves, and this file
|
||||
// exercises both:
|
||||
//
|
||||
// 1. Geometry is a VALUE — it carries its own width, so nothing has to
|
||||
// assert a width against a string's length.
|
||||
// 2. A REALIZER is an ordinary El function. `tone_realizer` below is not in
|
||||
// the runtime, is not known to the compiler, and is not special in any
|
||||
// way; it is registered BY NAME and dispatched to through transduce().
|
||||
// That is the load-bearing claim: adding a modality must not require a
|
||||
// runtime patch, or nothing has actually moved into the language.
|
||||
//
|
||||
// COMPARISON DISCIPLINE IN THIS FILE (measured 2026-08-16, not stylistic):
|
||||
// elc lowers `a == b` to a NUMERIC comparison only when both operand names are
|
||||
// in the per-function int-name set, which `let x: Int` populates. A bare call
|
||||
// like `geometry_is(g) == 0` is not a registered name, so it lowers to
|
||||
// `str_eq(...)` — strcmp on two integers reinterpreted as pointers. `<` and `>`
|
||||
// lower directly via binop_to_c with no type inference at all, so truthiness is
|
||||
// written `> 0` / `< 1` here, and any exact `==` is done on a value first bound
|
||||
// through `let x: Int`.
|
||||
|
||||
// ── A realizer, written entirely in El ──────────────────────────────────────
|
||||
// Maps a "tone" signal into a 4-component geometry. Deliberately trivial —
|
||||
// what is being proven is that an El function can BE a realizer, not that
|
||||
// this is good acoustics. The one real property it has: distinct signals
|
||||
// produce distinct geometry, so the test can tell transduction from a stub.
|
||||
fn tone_realizer(signal: String) -> Geometry {
|
||||
let g: Geometry = geometry_new(4)
|
||||
let n: Int = str_len(signal)
|
||||
let a: Int = geometry_set(g, 0, int_to_float(n))
|
||||
let b: Int = geometry_set(g, 1, int_to_float(n * 2))
|
||||
let c: Int = geometry_set(g, 2, int_to_float(n * 3))
|
||||
let d: Int = geometry_set(g, 3, int_to_float(n * 4))
|
||||
g
|
||||
}
|
||||
|
||||
// A second realizer for a different modality, to prove the registry keys on
|
||||
// modality and does not just hand back "the last thing registered".
|
||||
fn pulse_realizer(signal: String) -> Geometry {
|
||||
let g: Geometry = geometry_new(2)
|
||||
let a: Int = geometry_set(g, 0, 1.0)
|
||||
let b: Int = geometry_set(g, 1, 0.0)
|
||||
g
|
||||
}
|
||||
|
||||
// A deliberately BROKEN realizer: it returns something that is not a Geometry.
|
||||
// transduce() must not hand this back to a caller as if it were one.
|
||||
fn bogus_realizer(signal: String) -> Geometry {
|
||||
return 12345
|
||||
}
|
||||
|
||||
test "geometry-is-a-value-with-its-own-width" {
|
||||
let g: Geometry = geometry_new(8)
|
||||
let live: Int = geometry_is(g)
|
||||
assert live > 0, "geometry_new returns a live Geometry"
|
||||
let d: Int = geometry_dim(g)
|
||||
assert d == 8, "a Geometry carries its own width"
|
||||
let freed: Int = geometry_free(g)
|
||||
assert freed > 0, "geometry_free reports what it did"
|
||||
}
|
||||
|
||||
test "geometry-rejects-nonsense-without-an-arbitrary-bound" {
|
||||
// dim <= 0 is not a width. Note there is deliberately no MAX dim here:
|
||||
// #141 needed `dim <= 8192` only to bound an allocation sized from a
|
||||
// caller's claim about a string. A value that carries its own width has
|
||||
// nothing left to validate, so the only failure left is allocation.
|
||||
let zero: Geometry = geometry_new(0)
|
||||
let z: Int = geometry_is(zero)
|
||||
assert z < 1, "dim 0 is not a geometry"
|
||||
let neg: Geometry = geometry_new(-4)
|
||||
let n: Int = geometry_is(neg)
|
||||
assert n < 1, "negative dim is not a geometry"
|
||||
// Accessors must be total: a non-geometry is 0-width, never a crash.
|
||||
let nd: Int = geometry_dim(0)
|
||||
assert nd < 1, "geometry_dim of a non-geometry is 0"
|
||||
let ni: Int = geometry_is(0)
|
||||
assert ni < 1, "geometry_is of a non-geometry is 0"
|
||||
let nf: Int = geometry_free(0)
|
||||
assert nf < 1, "geometry_free of a non-geometry is a no-op"
|
||||
}
|
||||
|
||||
test "geometry-components-round-trip" {
|
||||
let g: Geometry = geometry_new(3)
|
||||
let s0: Int = geometry_set(g, 0, 1.5)
|
||||
let s1: Int = geometry_set(g, 1, -2.5)
|
||||
assert s0 > 0, "set in range succeeds"
|
||||
let oob: Int = geometry_set(g, 3, 9.0)
|
||||
assert oob < 1, "set out of range is refused, not silently dropped"
|
||||
let v0: Float = geometry_get(g, 0)
|
||||
let d0: Float = v0 - 1.5
|
||||
assert d0 < 0.001, "component 0 round-trips"
|
||||
assert d0 > -0.001, "component 0 round-trips"
|
||||
let v1: Float = geometry_get(g, 1)
|
||||
let d1: Float = v1 + 2.5
|
||||
assert d1 < 0.001, "component 1 round-trips (negative)"
|
||||
assert d1 > -0.001, "component 1 round-trips (negative)"
|
||||
let freed: Int = geometry_free(g)
|
||||
}
|
||||
|
||||
test "hex-is-an-edge-adapter-and-derives-its-own-width" {
|
||||
// 2 components, little-endian float32: 1.0 = 0000803f, 2.0 = 00000040.
|
||||
let g: Geometry = geometry_from_f32le_hex("0000803f00000040")
|
||||
let live: Int = geometry_is(g)
|
||||
assert live > 0, "valid hex decodes to a Geometry"
|
||||
let d: Int = geometry_dim(g)
|
||||
assert d == 2, "width is DERIVED from the input, never supplied"
|
||||
let a: Float = geometry_get(g, 0)
|
||||
let da: Float = a - 1.0
|
||||
assert da < 0.001, "first component decoded"
|
||||
assert da > -0.001, "first component decoded"
|
||||
let b: Float = geometry_get(g, 1)
|
||||
let db: Float = b - 2.0
|
||||
assert db < 0.001, "second component decoded"
|
||||
assert db > -0.001, "second component decoded"
|
||||
// Egress adapter is the exact inverse.
|
||||
let back: String = geometry_to_f32le_hex(g)
|
||||
assert str_eq(back, "0000803f00000040"), "hex round-trips exactly"
|
||||
let freed: Int = geometry_free(g)
|
||||
}
|
||||
|
||||
test "hex-rejects-malformed-input" {
|
||||
let empty: Geometry = geometry_from_f32le_hex("")
|
||||
let e: Int = geometry_is(empty)
|
||||
assert e < 1, "empty hex is not a geometry"
|
||||
let ragged: Geometry = geometry_from_f32le_hex("0000803f0000")
|
||||
let r: Int = geometry_is(ragged)
|
||||
assert r < 1, "length not a multiple of 8 is refused"
|
||||
let nonhex: Geometry = geometry_from_f32le_hex("zzzzzzzz")
|
||||
let nh: Int = geometry_is(nonhex)
|
||||
assert nh < 1, "non-hex characters are refused"
|
||||
}
|
||||
|
||||
test "a-realizer-declared-in-el-is-a-first-class-realizer" {
|
||||
// THE CLAIM: tone_realizer is an ordinary El function. It is not in the
|
||||
// runtime and the compiler knows nothing about it. Registering it by name
|
||||
// is enough to make it the organ for a modality.
|
||||
let reg: Int = realizer_register("tone", "tone_realizer")
|
||||
assert reg > 0, "an El fn registers as a realizer by name"
|
||||
let has: Int = realizer_has("tone")
|
||||
assert has > 0, "the modality now has an organ"
|
||||
|
||||
let g: Geometry = transduce("aaa", "tone")
|
||||
let live: Int = geometry_is(g)
|
||||
assert live > 0, "transduce returns real geometry"
|
||||
let d: Int = geometry_dim(g)
|
||||
assert d == 4, "the El realizer determined the width, not the runtime"
|
||||
// str_len("aaa") == 3, so component 0 must be 3.0 — proof the signal
|
||||
// actually reached the El function rather than a stub answering for it.
|
||||
let c0: Float = geometry_get(g, 0)
|
||||
let dc: Float = c0 - 3.0
|
||||
assert dc < 0.001, "the signal reached the El realizer"
|
||||
assert dc > -0.001, "the signal reached the El realizer"
|
||||
let freed: Int = geometry_free(g)
|
||||
}
|
||||
|
||||
test "distinct-signals-transduce-to-distinct-geometry" {
|
||||
let reg: Int = realizer_register("tone", "tone_realizer")
|
||||
let g1: Geometry = transduce("aa", "tone")
|
||||
let g2: Geometry = transduce("aaaaa", "tone")
|
||||
let a: Float = geometry_get(g1, 0)
|
||||
let b: Float = geometry_get(g2, 0)
|
||||
let diff: Float = b - a
|
||||
// 5 - 2 = 3. If transduction were a stub these would be equal.
|
||||
assert diff > 2.9, "different signals produce different geometry"
|
||||
assert diff < 3.1, "different signals produce different geometry"
|
||||
let f1: Int = geometry_free(g1)
|
||||
let f2: Int = geometry_free(g2)
|
||||
}
|
||||
|
||||
test "the-registry-keys-on-modality" {
|
||||
let r1: Int = realizer_register("tone", "tone_realizer")
|
||||
let r2: Int = realizer_register("pulse", "pulse_realizer")
|
||||
assert r2 > 0, "a second modality registers independently"
|
||||
let gt: Geometry = transduce("aaa", "tone")
|
||||
let gp: Geometry = transduce("aaa", "pulse")
|
||||
let dt: Int = geometry_dim(gt)
|
||||
let dp: Int = geometry_dim(gp)
|
||||
assert dt == 4, "tone still routes to its own realizer"
|
||||
assert dp == 2, "pulse routes to a different realizer"
|
||||
let f1: Int = geometry_free(gt)
|
||||
let f2: Int = geometry_free(gp)
|
||||
}
|
||||
|
||||
test "no-organ-is-reported-as-no-organ" {
|
||||
// A modality with no realizer must transduce to NOTHING. It must never
|
||||
// fall back to embedding a description of the signal and calling that
|
||||
// perception — that silent substitution is the entire defect this change
|
||||
// exists to end.
|
||||
let has: Int = realizer_has("echolocation")
|
||||
assert has < 1, "unregistered modality has no organ"
|
||||
let g: Geometry = transduce("anything", "echolocation")
|
||||
let live: Int = geometry_is(g)
|
||||
assert live < 1, "no realizer means no geometry, not fake geometry"
|
||||
}
|
||||
|
||||
test "registration-of-an-unresolvable-name-fails-loudly" {
|
||||
// Reported at the moment of WIRING, not later as "this modality mysteriously
|
||||
// produces nothing". Distinguishing "no organ" from "broken organ" is the
|
||||
// lesson that made this whole change necessary.
|
||||
let bad: Int = realizer_register("ghost", "no_such_function_anywhere")
|
||||
assert bad < 1, "an unresolvable realizer name is a registration failure"
|
||||
let has: Int = realizer_has("ghost")
|
||||
assert has < 1, "and nothing gets registered"
|
||||
}
|
||||
|
||||
test "a-realizer-returning-non-geometry-transduces-nothing" {
|
||||
let reg: Int = realizer_register("bogus", "bogus_realizer")
|
||||
assert reg > 0, "the symbol resolves, so registration succeeds"
|
||||
// ...but the contract is enforced at the boundary, so the caller never
|
||||
// receives a value that would misbehave far away from here.
|
||||
let g: Geometry = transduce("x", "bogus")
|
||||
let live: Int = geometry_is(g)
|
||||
assert live < 1, "a non-Geometry return transduced nothing"
|
||||
}
|
||||
|
||||
test "norm-lets-a-caller-check-a-realizer-emitted-signal" {
|
||||
let g: Geometry = geometry_new(2)
|
||||
let z: Float = geometry_norm(g)
|
||||
assert z < 0.001, "a fresh geometry is zero — norm says so"
|
||||
let s0: Int = geometry_set(g, 0, 3.0)
|
||||
let s1: Int = geometry_set(g, 1, 4.0)
|
||||
let n: Float = geometry_norm(g)
|
||||
let dn: Float = n - 5.0
|
||||
assert dn < 0.001, "3-4-5: norm is 5"
|
||||
assert dn > -0.001, "3-4-5: norm is 5"
|
||||
let freed: Int = geometry_free(g)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,58 @@
|
||||
fn expect_int(label: String, got: Int, want: Int) -> Void {
|
||||
if got == want { println("ok " + label) }
|
||||
else { println("FAIL " + label + " got=" + int_to_str(got) + " want=" + int_to_str(want)) }
|
||||
}
|
||||
fn expect_str(label: String, got: String, want: String) -> Void {
|
||||
if str_eq(got, want) { println("ok " + label) }
|
||||
else { println("FAIL " + label + " got='" + got + "' want='" + want + "'") }
|
||||
}
|
||||
|
||||
// 1. basic char access across a string
|
||||
let s: String = "hello"
|
||||
expect_int("char[0]=h", str_char_code(s, 0), 104)
|
||||
expect_int("char[4]=o", str_char_code(s, 4), 111)
|
||||
expect_int("char[5] OOB -> 0", str_char_code(s, 5), 0)
|
||||
expect_int("char[-1] OOB -> 0", str_char_code(s, -1), 0)
|
||||
expect_int("empty string OOB", str_char_code("", 0), 0)
|
||||
|
||||
// 2. slices
|
||||
expect_str("slice(0,5)", str_slice(s, 0, 5), "hello")
|
||||
expect_str("slice(1,3)", str_slice(s, 1, 3), "el")
|
||||
expect_str("slice past end clamps", str_slice(s, 3, 99), "lo")
|
||||
expect_str("slice inverted -> empty", str_slice(s, 4, 2), "")
|
||||
|
||||
// 3. DIFFERENT strings must not share a cached length (the real hazard)
|
||||
let a: String = "abc"
|
||||
let b: String = "abcdefghij"
|
||||
expect_int("a[2]=c", str_char_code(a, 2), 99)
|
||||
expect_int("a[3] OOB", str_char_code(a, 3), 0)
|
||||
expect_int("b[9]=j", str_char_code(b, 9), 106)
|
||||
expect_int("b[3]=d after a", str_char_code(b, 3), 100)
|
||||
expect_int("a[3] still OOB after b", str_char_code(a, 3), 0)
|
||||
|
||||
// 4. many distinct strings interleaved — forces cache slot collisions
|
||||
fn interleave(n: Int) -> Int {
|
||||
let i: Int = 0
|
||||
let bad: Int = 0
|
||||
while i < n {
|
||||
let t: String = int_to_str(i)
|
||||
let l: Int = str_len(t)
|
||||
let last: Int = str_char_code(t, l - 1)
|
||||
let oob: Int = str_char_code(t, l)
|
||||
if oob != 0 { let bad2: Int = bad + 1
|
||||
let bad: Int = bad2 }
|
||||
if last == 0 { let bad3: Int = bad + 1
|
||||
let bad: Int = bad3 }
|
||||
let i2: Int = i + 1
|
||||
let i: Int = i2
|
||||
}
|
||||
return bad
|
||||
}
|
||||
expect_int("1000 interleaved strings, no bad reads", interleave(1000), 0)
|
||||
|
||||
// 5. concatenation changes length — cache must not report the old one
|
||||
let g: String = "12345"
|
||||
let g2: String = g + "6789"
|
||||
expect_int("grown string len via char", str_char_code(g2, 8), 57)
|
||||
expect_int("original still bounded", str_char_code(g, 5), 0)
|
||||
println("done")
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../../runtime/eltest.el"
|
||||
// tests/runtime/string_test.el — Test suite for runtime/string.el
|
||||
//
|
||||
// Exercises every public function exported by runtime/string.el using the
|
||||
|
||||
@@ -173,4 +173,7 @@ Each ad-hoc harness becomes `nsbx run <name> …` (or `--source` build) against
|
||||
## Env knobs
|
||||
|
||||
`NSBX_ROOT`, `NSBX_PORT_BASE`, `NSBX_RSS_BOUND_MB`, `NSBX_REMERGE_THRESHOLD`,
|
||||
`NSBX_READY_TIMEOUT_SECS` (default 15 — how long `up`/`create`/`build` wait for a
|
||||
daemon to answer `/api/stats` before reporting failure; raise it if a boot is
|
||||
legitimately slow under concurrent sandbox/CPU load rather than actually broken),
|
||||
`EL_REPO` (for `elc` + runtime sources), `ENGRAM_LIVE_DATA_DIR`, `ENGRAM_LIVE_PLIST`.
|
||||
|
||||
+134
-29
@@ -32,6 +32,12 @@ EL_REPO="${EL_REPO:-$HOME/Development/neuron-technologies/foundation/el}"
|
||||
PORT_BASE="${NSBX_PORT_BASE:-8900}"
|
||||
RSS_BOUND_MB="${NSBX_RSS_BOUND_MB:-550}" # from store-fix reboot-proof (aaf13f88)
|
||||
REMERGE_THRESHOLD="${NSBX_REMERGE_THRESHOLD:-40000}"
|
||||
# readiness-poll window for start_daemon (0.5s ticks). Default unchanged (15s) —
|
||||
# but a cold boot against the full live store, under concurrent CPU contention
|
||||
# from other running sandboxes, has been observed live to take well past that.
|
||||
# Bump per-invocation with NSBX_READY_TIMEOUT_SECS if `up`/`create` reports a
|
||||
# not-ready failure but the daemon looks otherwise fine (see its logs/daemon.log).
|
||||
READY_TICKS=$(( ${NSBX_READY_TIMEOUT_SECS:-15} * 2 ))
|
||||
KEYSTONES=( "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee" "kn-5b606390-a52d-4ca2-8e0e-eba141d13440" )
|
||||
# fixed probe set for retrieval-parity (stable, identity-anchored)
|
||||
PARITY_QUERIES=( "who am I" "self identity core" "engram store durability" "keystone self anchor" "grounding honesty" )
|
||||
@@ -77,7 +83,7 @@ _port_claimed(){ # is another sandbox already assigned this port?
|
||||
live_stats(){ curl -s -m5 "$LIVE_URL/api/stats" 2>/dev/null; }
|
||||
api(){ # api <name> <path> [json-body]
|
||||
local name="$1" path="$2" body="${3:-}"
|
||||
local port; port="$(mget "$name" "['port']")"; [ -n "$port" ] || die "unknown sandbox: $name"
|
||||
local port; port="$(mget "$name" "['port']")"; [ -n "$port" ] || die "unknown sandbox: $name (run: nsbx list)"
|
||||
local url="http://127.0.0.1:${port}${path}"
|
||||
if [ -n "$body" ]; then curl -s -m30 -X POST -H 'Content-Type: application/json' -d "$body" "$url"
|
||||
else curl -s -m30 "$url"; fi
|
||||
@@ -88,6 +94,19 @@ stat_field(){ printf '%s' "$1" | sed -n "s/.*\"$2\":\([0-9]*\).*/\1/p"; }
|
||||
daemon_pid(){ local f; f="$(sdir "$1")/daemon.pid"; [ -f "$f" ] && cat "$f" || true; }
|
||||
daemon_alive(){ local p; p="$(daemon_pid "$1")"; [ -n "$p" ] && kill -0 "$p" 2>/dev/null; }
|
||||
|
||||
# daemon_health <name> : prints "stopped" | "running" | "unresponsive" (to stdout).
|
||||
# "running" means the pid is alive AND /api/stats actually answered — process
|
||||
# liveness alone (daemon_alive) is not proof the HTTP server is serving; a pegged
|
||||
# or hung process still passes kill -0. Short timeout (2s) since this runs per-row
|
||||
# in `nsbx list`.
|
||||
daemon_health(){
|
||||
local name="$1"
|
||||
daemon_alive "$name" || { echo "stopped"; return 0; }
|
||||
local port; port="$(mget "$name" "['port']")"
|
||||
local s; s="$(curl -s -m2 "http://127.0.0.1:${port}/api/stats" 2>/dev/null)"
|
||||
[ -n "$s" ] && echo "running" || echo "unresponsive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- elc/build ----
|
||||
find_elc(){
|
||||
command -v elc 2>/dev/null && return 0
|
||||
@@ -123,6 +142,49 @@ _build_binary(){
|
||||
ok "built: $out ($(ls -lh "$out" | awk '{print $5}'), sha $(sha "$out" | cut -c1-12))"
|
||||
}
|
||||
|
||||
# bin_built_at <path> : human-readable build timestamp. `cp -p` preserves mtime,
|
||||
# so this is the ORIGINAL build time even for binaries copied stock-prod into a
|
||||
# sandbox — not the copy time.
|
||||
bin_built_at(){ stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S' "$1" 2>/dev/null || echo "unknown"; }
|
||||
|
||||
# _binary_freshness <name> : best-effort staleness note (empty string if fresh/
|
||||
# unknown — never guesses). Two cases:
|
||||
# - stock-prod: compares the sha recorded at create time against the CURRENTLY
|
||||
# configured live real binary's sha (recomputed now, not cached) — catches
|
||||
# "live prod moved on since this sandbox was cloned".
|
||||
# - branch/source/rebuilt: compares the recorded source_commit against the
|
||||
# LOCAL origin/dev ref (no fetch — reads whatever the repo already has) —
|
||||
# catches "built from a commit that predates current dev tip".
|
||||
_binary_freshness(){
|
||||
local name="$1" src; src="$(mget "$name" "['source']")"
|
||||
case "$src" in
|
||||
stock-prod:*)
|
||||
local live_bin cur_sha rec_sha
|
||||
live_bin="$(_live_real_bin)"
|
||||
[ -n "$live_bin" ] && [ -x "$live_bin" ] || return 0
|
||||
cur_sha="$(sha "$live_bin")"; rec_sha="$(mget "$name" "['binary_sha256']")"
|
||||
[ -n "$cur_sha" ] && [ -n "$rec_sha" ] && [ "$cur_sha" != "$rec_sha" ] \
|
||||
&& printf 'stale: live prod binary has moved on since this sandbox was cloned (live is now %s, sha %s) — nsbx build %s --binary %s to catch up' \
|
||||
"$(basename "$live_bin")" "${cur_sha:0:12}" "$name" "$live_bin"
|
||||
;;
|
||||
branch:*|source:*|rebuilt:*)
|
||||
local commit cur behind
|
||||
commit="$(mget "$name" "['source_commit']")"
|
||||
[ -n "$commit" ] || return 0
|
||||
cur="$(git -C "$EL_REPO" rev-parse origin/dev 2>/dev/null)" || return 0
|
||||
[ -n "$cur" ] && [ "$commit" != "$cur" ] || return 0
|
||||
git -C "$EL_REPO" merge-base --is-ancestor "$commit" "$cur" 2>/dev/null || return 0
|
||||
behind="$(git -C "$EL_REPO" rev-list --count "$commit..$cur" 2>/dev/null)"
|
||||
printf 'stale: built from %s, %s commit(s) behind local origin/dev (%s) — nsbx build %s --branch origin/dev' \
|
||||
"${commit:0:12}" "${behind:-?}" "${cur:0:12}" "$name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# _source_commit <dir> : best-effort git HEAD of a source tree used to build a
|
||||
# sandbox binary, empty if not a git repo (e.g. a prebuilt --binary path has none).
|
||||
_source_commit(){ git -C "$1" rev-parse HEAD 2>/dev/null || true; }
|
||||
|
||||
# ---------------------------------------------------------------- daemon -------
|
||||
# start_daemon <name> : boots the sandbox's real engram binary on its isolated
|
||||
# port against its cloned data dir, with the SAME auto-remerge net the live soul
|
||||
@@ -133,9 +195,9 @@ start_daemon(){
|
||||
local port bin data export key
|
||||
port="$(mget "$name" "['port']")"; bin="$d/bin/engram"; data="$d/data"
|
||||
key="sbx-$name"; export="$data/.scan-export.reseed-clean.json"
|
||||
[ -x "$bin" ] || die "sandbox binary missing: $bin"
|
||||
[ -x "$bin" ] || die "sandbox binary missing: $bin (run: nsbx build $name --source DIR | --branch REF, or nsbx destroy $name && nsbx create $name to reclone stock-prod)"
|
||||
[ "$port" != "$LIVE_BIND_PORT" ] && [ "$port" != "$SOUL_PORT" ] || die "refusing forbidden port $port"
|
||||
[ -f "$data/neuron.egm" ] || die "sandbox has no cloned store: $data/neuron.egm"
|
||||
[ -f "$data/neuron.egm" ] || die "sandbox has no cloned store: $data/neuron.egm (data dir is corrupt/incomplete — run: nsbx destroy $name && nsbx create $name)"
|
||||
# HARD guard: never point a sandbox daemon at the live data dir.
|
||||
[ "$(cd "$data" && pwd -P)" != "$(cd "$LIVE_DATA_DIR" && pwd -P)" ] || die "refusing: sandbox data dir resolves to LIVE store"
|
||||
|
||||
@@ -150,11 +212,11 @@ start_daemon(){
|
||||
echo "$pid" > "$d/daemon.pid"
|
||||
# readiness poll
|
||||
local url="http://127.0.0.1:$port" i s
|
||||
for i in $(seq 1 30); do
|
||||
for i in $(seq 1 "$READY_TICKS"); do
|
||||
s="$(curl -s -m3 "$url/api/stats" 2>/dev/null)"
|
||||
[ -n "$s" ] && break; sleep 0.5
|
||||
done
|
||||
[ -n "$s" ] || { warn "daemon did not become ready (see $d/logs/daemon.log)"; return 1; }
|
||||
[ -n "$s" ] || { warn "daemon did not become ready within ${NSBX_READY_TIMEOUT_SECS:-15}s (see $d/logs/daemon.log). pid $pid may still be alive and slow to boot under load — check: lsof -iTCP:$port -P, or retry with NSBX_READY_TIMEOUT_SECS=45"; return 1; }
|
||||
ok "ready pid=$pid boot-stats: $s"
|
||||
# auto-remerge net (idempotent): match live edge population if the export is present
|
||||
if [ -f "$export" ]; then
|
||||
@@ -231,33 +293,35 @@ cmd_create(){
|
||||
info "live baseline stats: ${lstats:-<unavailable>}"
|
||||
|
||||
# ---- determine + place the runtime binary (versioned into the snapshot) ----
|
||||
local source_desc live_bin
|
||||
local source_desc live_bin source_commit=""
|
||||
live_bin="$(_live_real_bin)"
|
||||
if [ -n "$binpath" ]; then
|
||||
[ -x "$binpath" ] || die "not an executable binary: $binpath"
|
||||
cp -p "$binpath" "$d/bin/engram"; source_desc="prebuilt:$binpath"
|
||||
elif [ -n "$src" ]; then
|
||||
_build_binary "$src" "$d/bin/engram" "$d/build"; source_desc="source:$src"
|
||||
source_commit="$(_source_commit "$src")"
|
||||
elif [ -n "$branch" ]; then
|
||||
log "worktree: $repo @ $branch -> $d/build/worktree"
|
||||
git -C "$repo" worktree add --detach "$d/build/worktree" "$branch" >/dev/null 2>&1 \
|
||||
|| die "git worktree add failed ($repo @ $branch)"
|
||||
_build_binary "$d/build/worktree" "$d/bin/engram" "$d/build"; source_desc="branch:$branch@$repo"
|
||||
source_commit="$(_source_commit "$d/build/worktree")"
|
||||
else
|
||||
[ -x "$live_bin" ] || die "cannot resolve live ENGRAM_REAL_BIN: $live_bin"
|
||||
cp -p "$live_bin" "$d/bin/engram"; source_desc="stock-prod:$live_bin"
|
||||
fi
|
||||
local bin_sha; bin_sha="$(sha "$d/bin/engram")"
|
||||
info "runtime: $source_desc (sha ${bin_sha:0:12})"
|
||||
info "runtime: $source_desc (sha ${bin_sha:0:12}, built $(bin_built_at "$d/bin/engram"))"
|
||||
|
||||
# ---- write manifest ----
|
||||
python3 - "$name" "$port" "$source_desc" "$bin_sha" "$egm_sha" "$base_nodes" "$base_edges" "$(sha "$live_bin" 2>/dev/null)" <<'PY' > "$(manifest "$name")"
|
||||
python3 - "$name" "$port" "$source_desc" "$bin_sha" "$egm_sha" "$base_nodes" "$base_edges" "$(sha "$live_bin" 2>/dev/null)" "$source_commit" <<'PY' > "$(manifest "$name")"
|
||||
import json,sys,datetime
|
||||
name,port,src,binsha,egmsha,bn,be,livebinsha=sys.argv[1:9]
|
||||
name,port,src,binsha,egmsha,bn,be,livebinsha,source_commit=sys.argv[1:10]
|
||||
json.dump({
|
||||
"name":name,"port":int(port),"created_at":datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"source":src,"binary_sha256":binsha,"clone_egm_sha256":egmsha,
|
||||
"live_binary_sha256":livebinsha,
|
||||
"live_binary_sha256":livebinsha,"source_commit":source_commit,
|
||||
"live_baseline":{"node_count":int(bn or 0),"edge_count":int(be or 0)},
|
||||
"keystones":["kn-efeb4a5b-5aff-4759-8a97-7233099be6ee","kn-5b606390-a52d-4ca2-8e0e-eba141d13440"]
|
||||
}, sys.stdout, indent=2)
|
||||
@@ -268,6 +332,17 @@ PY
|
||||
start_daemon "$name" || die "daemon failed to start"
|
||||
local sstats; sstats="$(sbx_stats "$name")"
|
||||
local sbn sbe; sbn="$(stat_field "$sstats" node_count)"; sbe="$(stat_field "$sstats" edge_count)"
|
||||
# a boot immediately followed by an auto-remerge can leave the daemon briefly
|
||||
# busy — retry rather than silently folding a 0/0 baseline into the manifest.
|
||||
# `validate`'s zero-loss/reboot-prove checks compare current counts >= baseline,
|
||||
# so a 0/0 baseline would make them trivially PASS regardless of real data loss.
|
||||
local _bi
|
||||
for _bi in 1 2 3 4 5; do
|
||||
[ -n "$sbn" ] && [ "$sbn" != "0" ] && break
|
||||
sleep 1
|
||||
sstats="$(sbx_stats "$name")"; sbn="$(stat_field "$sstats" node_count)"; sbe="$(stat_field "$sstats" edge_count)"
|
||||
done
|
||||
[ -z "$sbn" ] || [ "$sbn" = "0" ] && warn "sandbox stats still empty/zero after retries — recording sbx_baseline 0/0. This makes 'nsbx validate $name' zero-loss checks trivially pass; investigate before trusting a validate PASS: nsbx status $name"
|
||||
_capture_retrieval "$name" "$d/baseline/retrieval.json"
|
||||
# fold sandbox baseline into manifest
|
||||
python3 - "$(manifest "$name")" "$sbn" "$sbe" <<'PY'
|
||||
@@ -320,7 +395,12 @@ except Exception: print("[]")' 2>/dev/null)"
|
||||
# thereafter. Prod on :$LIVE_BIND_PORT/:$SOUL_PORT is unreachable from here by design.
|
||||
cmd_up(){
|
||||
local name; if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then name="$1"; shift; else name="${USER:-dev}-dev"; fi
|
||||
if mexists "$name"; then daemon_alive "$name" || start_daemon "$name"; else cmd_create "$name" "$@"; fi
|
||||
if mexists "$name"; then
|
||||
daemon_alive "$name" || start_daemon "$name" \
|
||||
|| die "daemon did not become ready — see $(sdir "$name")/logs/daemon.log (try: nsbx up $name again once you've checked the log)"
|
||||
else
|
||||
cmd_create "$name" "$@"
|
||||
fi
|
||||
local port; port="$(mget "$name" "['port']")"
|
||||
echo >&2
|
||||
ok "your sandbox '$name' is ready at http://127.0.0.1:$port (a private copy of the mind — prod is untouchable)"
|
||||
@@ -334,34 +414,37 @@ cmd_up(){
|
||||
# it on the SAME clone + port (the code-change dev loop, in place).
|
||||
cmd_build(){
|
||||
local name="$1"; shift || true
|
||||
mexists "$name" || die "no such sandbox: $name"
|
||||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||||
local src="" branch="" repo="$EL_REPO"
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--source) src="$2"; shift 2;; --branch) branch="$2"; shift 2;; --repo) repo="$2"; shift 2;;
|
||||
*) die "unknown flag: $1";; esac; done
|
||||
local d; d="$(sdir "$name")"
|
||||
stop_daemon "$name"
|
||||
if [ -n "$src" ]; then _build_binary "$src" "$d/bin/engram" "$d/build"
|
||||
local source_commit=""
|
||||
if [ -n "$src" ]; then _build_binary "$src" "$d/bin/engram" "$d/build"; source_commit="$(_source_commit "$src")"
|
||||
elif [ -n "$branch" ]; then
|
||||
rm -rf "$d/build/worktree" 2>/dev/null; git -C "$repo" worktree prune 2>/dev/null
|
||||
git -C "$repo" worktree add --detach "$d/build/worktree" "$branch" >/dev/null 2>&1 || die "worktree add failed"
|
||||
_build_binary "$d/build/worktree" "$d/bin/engram" "$d/build"
|
||||
source_commit="$(_source_commit "$d/build/worktree")"
|
||||
else die "usage: nsbx build <name> --source DIR | --branch REF [--repo R]"; fi
|
||||
# record new binary sha
|
||||
python3 - "$(manifest "$name")" "$(sha "$d/bin/engram")" "${src:-branch:$branch}" <<'PY'
|
||||
import json,sys; mf,s,src=sys.argv[1:4]
|
||||
d=json.load(open(mf)); d["binary_sha256"]=s; d["source"]="rebuilt:"+src
|
||||
python3 - "$(manifest "$name")" "$(sha "$d/bin/engram")" "${src:-branch:$branch}" "$source_commit" <<'PY'
|
||||
import json,sys; mf,s,src,source_commit=sys.argv[1:5]
|
||||
d=json.load(open(mf)); d["binary_sha256"]=s; d["source"]="rebuilt:"+src; d["source_commit"]=source_commit
|
||||
json.dump(d,open(mf,'w'),indent=2)
|
||||
PY
|
||||
start_daemon "$name"
|
||||
start_daemon "$name" \
|
||||
|| die "rebuilt binary did not become ready — see $d/logs/daemon.log (the old binary is gone; fix the code and re-run nsbx build $name ...)"
|
||||
ok "rebuilt + restarted on :$(mget "$name" "['port']")"
|
||||
}
|
||||
|
||||
# ================================================================ run ==========
|
||||
cmd_run(){
|
||||
local name="$1"; shift || true
|
||||
mexists "$name" || die "no such sandbox: $name"
|
||||
daemon_alive "$name" || start_daemon "$name"
|
||||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||||
daemon_alive "$name" || start_daemon "$name" || die "daemon not running and failed to start — see $(sdir "$name")/logs/daemon.log"
|
||||
local d port; d="$(sdir "$name")"; port="$(mget "$name" "['port']")"
|
||||
# direct API form: nsbx run <name> api <path> [json]
|
||||
if [ "${1:-}" = "api" ]; then
|
||||
@@ -395,8 +478,8 @@ cmd_run(){
|
||||
# RSS bound; retrieval parity; keystone integrity.
|
||||
cmd_validate(){
|
||||
local name="$1"; shift || true
|
||||
mexists "$name" || die "no such sandbox: $name"
|
||||
daemon_alive "$name" || start_daemon "$name"
|
||||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||||
daemon_alive "$name" || start_daemon "$name" || die "daemon not running and failed to start — see $(sdir "$name")/logs/daemon.log"
|
||||
local d port key; d="$(sdir "$name")"; port="$(mget "$name" "['port']")"; key="sbx-$name"
|
||||
local url="http://127.0.0.1:$port"
|
||||
local bn be; bn="$(mget "$name" "['sbx_baseline']['node_count']")"; be="$(mget "$name" "['sbx_baseline']['edge_count']")"
|
||||
@@ -489,7 +572,7 @@ PY
|
||||
# Default is a DRY-RUN plan; requires --i-approve-prod-cutover to actually cut over.
|
||||
cmd_promote(){
|
||||
local name="$1"; shift || true
|
||||
mexists "$name" || die "no such sandbox: $name"
|
||||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||||
local approve=0 do_data=0
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--i-approve-prod-cutover) approve=1; shift;;
|
||||
@@ -588,7 +671,7 @@ PY
|
||||
# ================================================================ destroy ======
|
||||
cmd_destroy(){
|
||||
local name="$1"; shift || true
|
||||
mexists "$name" || die "no such sandbox: $name"
|
||||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||||
local d; d="$(sdir "$name")"
|
||||
stop_daemon "$name"
|
||||
if [ -d "$d/build/worktree" ]; then
|
||||
@@ -604,22 +687,44 @@ cmd_destroy(){
|
||||
# ================================================================ list/status ==
|
||||
cmd_list(){
|
||||
[ -d "$SBX_ROOT" ] || { echo "no sandboxes"; return 0; }
|
||||
printf '%-16s %-6s %-8s %-9s %s\n' NAME PORT STATE PID SOURCE
|
||||
printf '%-16s %-6s %-13s %-9s %-19s %s\n' NAME PORT STATE PID "BUILT" SOURCE
|
||||
local m
|
||||
for m in "$SBX_ROOT"/*/manifest.json; do
|
||||
[ -f "$m" ] || continue
|
||||
local n p src pid state
|
||||
local n p src pid state bpath built fresh
|
||||
n="$(python3 -c "import json;print(json.load(open('$m'))['name'])")"
|
||||
p="$(python3 -c "import json;print(json.load(open('$m'))['port'])")"
|
||||
src="$(python3 -c "import json;print(json.load(open('$m'))['source'])")"
|
||||
pid="$(daemon_pid "$n")"; state="stopped"; daemon_alive "$n" && state="running"
|
||||
printf '%-16s %-6s %-8s %-9s %s\n' "$n" "$p" "$state" "${pid:-–}" "$src"
|
||||
pid="$(daemon_pid "$n")"
|
||||
case "$(daemon_health "$n")" in
|
||||
running) state="running";;
|
||||
unresponsive) state="running(!resp)";;
|
||||
*) state="stopped";;
|
||||
esac
|
||||
bpath="$(sdir "$n")/bin/engram"; built="$([ -f "$bpath" ] && bin_built_at "$bpath" || echo unknown)"
|
||||
fresh="$(_binary_freshness "$n")"; [ -n "$fresh" ] && src="[STALE] $src"
|
||||
printf '%-16s %-6s %-13s %-9s %-19s %s\n' "$n" "$p" "$state" "${pid:-–}" "$built" "$src"
|
||||
done
|
||||
info "state 'running(!resp)' = process alive but /api/stats didn't answer — see: nsbx status <name>"
|
||||
}
|
||||
cmd_status(){
|
||||
local name="$1"; mexists "$name" || die "no such sandbox: $name"
|
||||
local name="$1"; mexists "$name" || die "no such sandbox: $name (run: nsbx list to see what exists)"
|
||||
python3 -m json.tool "$(manifest "$name")"
|
||||
daemon_alive "$name" && echo "state: running (pid $(daemon_pid "$name")) stats: $(sbx_stats "$name")" || echo "state: stopped"
|
||||
local bpath; bpath="$(sdir "$name")/bin/engram"
|
||||
if [ -f "$bpath" ]; then
|
||||
echo "binary: sha=$(sha "$bpath" | cut -c1-12) built=$(bin_built_at "$bpath")"
|
||||
local fresh; fresh="$(_binary_freshness "$name")"
|
||||
[ -n "$fresh" ] && printf '%s%s%s\n' "$C_YEL" "$fresh" "$C_0"
|
||||
fi
|
||||
case "$(daemon_health "$name")" in
|
||||
running)
|
||||
echo "state: running (pid $(daemon_pid "$name")) stats: $(sbx_stats "$name")";;
|
||||
unresponsive)
|
||||
printf '%sstate: running but NOT RESPONDING%s (pid %s) — process alive, /api/stats returned nothing.\n' "$C_RED" "$C_0" "$(daemon_pid "$name")"
|
||||
info "check: tail -50 $(sdir "$name")/logs/daemon.log | next: kill -9 $(daemon_pid "$name") && nsbx up $name"
|
||||
;;
|
||||
*) echo "state: stopped";;
|
||||
esac
|
||||
[ -f "$(sdir "$name")/validate.json" ] && { echo "--- last validation ---"; python3 -m json.tool "$(sdir "$name")/validate.json"; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user