Compare commits

..

1 Commits

Author SHA1 Message Date
will.anderson edcec3bdf4 engram: add /api/nodes/reseed so a node body can be repaired at its own id
El SDK Release / build-and-release (pull_request) Failing after 11m24s
Two write paths could put a node in the graph and neither could put a body
on an id that already exists. POST /api/nodes mints a fresh id via
engram_node_full; POST /api/load-merge honors a declared id but skips
anything already present. That is right for the additive case and leaves a
hole: a node resident with a truncated body cannot be repaired.

Forge's genesis seed sits in that hole. Two of Neuron's identity nodes
carry only their own label as content -- 30 and 22 bytes against 4263 and
2590 declared. Their ids are load-bearing (is_protected_node keys on them
and 214 declared edges reference them), so recreating them under a new id
is not a repair, it is a second break.

Engram has no in-place node update, so a replace is forget-then-merge, and
engram_forget also drops every incident edge -- 85 and 93 on those two
nodes, nearly all tag edges and accumulated hebbian associations the seed
does not declare and could not restore. preserve_edges (default true)
therefore snapshots before the forget and re-merges after: the replaced
node is back by then so it is skipped, and every dropped edge returns
through the (from_id,to_id,relation) dedup. The same re-merge is the
failure path -- if the seed merge does not produce the node, the backup
puts the original back. Rollback, not data loss.

With no replace list the route is exactly /api/load-merge.

Verified on a sandbox engram seeded to mirror the live graph's state for
this seed (15 resident nodes, 694 incident edges): 87 nodes created at
their declared ids, 2 replaced in place, 214/214 edges laid, 682/682
non-seed incident edges preserved, and a second run reports 0 added.
2026-08-10 16:44:17 -05:00
5 changed files with 96 additions and 148 deletions
+89
View File
@@ -272,6 +272,89 @@ fn route_load_merge(method: String, path: String, body: String) -> String {
"{\"ok\":true,\"nodes_added\":" + int_to_str(added_n) + ",\"edges_added\":" + int_to_str(added_e) + ",\"node_count\":" + int_to_str(engram_node_count()) + "}"
}
// route_reseed_nodes POST /api/nodes/reseed
// {"path": "<snapshot-format file>", "replace": ["<id>", ...],
// "preserve_edges": true, "_auth": "<key>"}
//
// ID-PRESERVING install/repair for declarative seed graphs.
//
// WHY THIS EXISTS (2026-08-10). Two write paths could put a node into the
// graph and neither can put a BODY onto an id that already exists:
// POST /api/nodes mints a fresh id via engram_node_full, and
// POST /api/load-merge honors the declared id but SKIPS anything already
// present. That is exactly right for the additive case and leaves one hole:
// a node that exists with a truncated body. Forge's genesis seed hit it
// two identity nodes (Voice, Voice Craft) sat in the graph carrying only
// their own label as content, 30 and 22 bytes against 4263 and 2590 in the
// seed. Their ids are load-bearing (is_protected_node keys on them and 214
// declared edges reference them), so "delete and recreate with a new id" is
// not a repair, it is a second break.
//
// Mechanism: engram has no in-place node update, so a replace is
// forget-then-merge. engram_forget also drops every INCIDENT EDGE for
// those two nodes that is 85 and 93 edges, almost all of them tag edges and
// accumulated hebbian associations that the seed does not declare and could
// not restore. preserve_edges (default true) therefore snapshots the graph
// before the forget and re-merges that snapshot afterwards: the replaced
// node is back by then so it is skipped, and every dropped incident edge
// returns through load_merge's (from_id,to_id,relation) dedup. The same
// re-merge is the failure path if the seed merge does not produce the
// node, the backup puts the original back. Rollback, not data loss.
//
// preserve_edges=false skips the two snapshot round-trips (cheap, lossy);
// use it only on a graph whose edges are fully declared by the seed.
// With no "replace" list this route is exactly /api/load-merge.
fn route_reseed_nodes(method: String, path: String, body: String) -> String {
let p: String = json_get_string(body, "path")
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 }
let backup: String = dir + "/.reseed-backup.json"
let replace_raw: String = json_get_raw(body, "replace")
let n_replace: Int = json_array_len(replace_raw)
// Presence-aware: absent key means "preserve", only an explicit false opts out.
let pe_raw: String = json_get_raw(body, "preserve_edges")
let preserve: Bool = !str_eq(pe_raw, "false")
let before_n: Int = engram_node_count()
let before_e: Int = engram_edge_count()
let replaced: Int = 0
if n_replace > 0 {
if preserve { engram_save(backup) }
let i: Int = 0
while i < n_replace {
let rid: String = json_array_get_string(replace_raw, i)
if !str_eq(rid, "") {
// engram_get_node_json returns "{}" for a miss only forget
// ids that are actually resident, so a typo in the replace
// list is a no-op rather than a silent partial run.
let existing: String = engram_get_node_json(rid)
if !str_eq(existing, "{}") {
engram_forget(rid)
let replaced = replaced + 1
}
}
let i = i + 1
}
}
engram_load_merge(p)
if replaced > 0 {
if preserve { engram_load_merge(backup) }
}
let saved: Int = persist_canonical()
"{\"ok\":true,\"replaced\":" + int_to_str(replaced) +
",\"nodes_added\":" + int_to_str(engram_node_count() - before_n) +
",\"edges_added\":" + int_to_str(engram_edge_count() - before_e) +
",\"node_count\":" + int_to_str(engram_node_count()) +
",\"edge_count\":" + int_to_str(engram_edge_count()) + "}"
}
// route_emit_ise write an InternalStateEvent node from the soul daemon.
//
// Endpoint: POST /api/neuron/state-events
@@ -419,6 +502,12 @@ fn handle_request(method: String, path: String, body: String) -> String {
}
// Nodes
// Reseed must be tested before the exact "/api/nodes" match below reads
// as the general create path order is not load-bearing (the match is
// exact) but keeping them adjacent keeps them from drifting apart.
if str_eq(method, "POST") && (str_eq(clean, "/api/nodes/reseed") || str_eq(clean, "/nodes/reseed")) {
return route_reseed_nodes(method, path, body)
}
if str_eq(method, "POST") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/nodes")) {
return route_create_node(method, path, body)
}
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -5327,8 +5327,8 @@ el_val_t str_to_float(el_val_t s) {
/* ── Math (Float-aware) ──────────────────────────────────────────────────── */
el_val_t math_sqrt(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
el_val_t math_log(el_val_t f) { return el_from_float(log10(el_to_float(f))); } /* base-10, per runtime/math.el */
el_val_t math_ln(el_val_t f) { return el_from_float(log(el_to_float(f))); } /* natural log */
el_val_t math_log(el_val_t f) { return el_from_float(log(el_to_float(f))); }
el_val_t math_ln(el_val_t f) { return el_from_float(log(el_to_float(f))); }
el_val_t math_sin(el_val_t f) { return el_from_float(sin(el_to_float(f))); }
el_val_t math_cos(el_val_t f) { return el_from_float(cos(el_to_float(f))); }
el_val_t math_pi(void) { return el_from_float(3.141592653589793238462643383279502884); }
-1
View File
@@ -49,7 +49,6 @@
#include <stdint.h>
#include <stdlib.h>
#include <math.h> /* fmod, sin, sqrt, ... — used by codegen'd float arithmetic */
typedef int64_t el_val_t;
+5 -145
View File
@@ -731,41 +731,6 @@ fn cg_expr(expr: Map<String, Any>) -> String {
// because both share the int64 slot).
}
// -- Float arithmetic / comparison dispatch -------------------------
// When either operand is provably Float, promote BOTH operands to a C
// double and emit real double arithmetic. Arithmetic results are
// re-wrapped into the el_val_t float slot via el_from_float(); the
// integer % becomes fmod(); comparisons yield a bare 0/1. Without this
// path, float `+` fell to el_str_concat (segfault) and `- * / %` fell
// to integer ops on the raw IEEE-754 bit pattern (garbage). Mixed
// Int/Float promotes the Int side with a (double) cast. This runs
// AFTER temporal dispatch (a Float is never temporal) and BEFORE the
// Int/String paths, and is skipped when either side is a bare Str so
// string concat/equality is never disturbed.
if !str_eq(left_kind, "Str") {
if !str_eq(right_kind, "Str") {
let any_float: Bool = false
if is_float_expr(left) { let any_float = true }
if is_float_expr(right) { let any_float = true }
if any_float {
let l_d: String = float_operand_c(left, left_c)
let r_d: String = float_operand_c(right, right_c)
if str_eq(op, "Plus") { return "el_from_float(" + l_d + " + " + r_d + ")" }
if str_eq(op, "Minus") { return "el_from_float(" + l_d + " - " + r_d + ")" }
if str_eq(op, "Star") { return "el_from_float(" + l_d + " * " + r_d + ")" }
if str_eq(op, "Slash") { return "el_from_float(" + l_d + " / " + r_d + ")" }
if str_eq(op, "Percent") { return "el_from_float(fmod(" + l_d + ", " + r_d + "))" }
if str_eq(op, "Lt") { return "(" + l_d + " < " + r_d + ")" }
if str_eq(op, "Gt") { return "(" + l_d + " > " + r_d + ")" }
if str_eq(op, "LtEq") { return "(" + l_d + " <= " + r_d + ")" }
if str_eq(op, "GtEq") { return "(" + l_d + " >= " + r_d + ")" }
if str_eq(op, "EqEq") { return "(" + l_d + " == " + r_d + ")" }
if str_eq(op, "NotEq") { return "(" + l_d + " != " + r_d + ")" }
// And/Or with a float operand is ill-typed fall through.
}
}
}
if op == "Plus" {
// If either side is a string literal, always concat
if left_kind == "Str" {
@@ -1441,9 +1406,6 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if str_eq(ltype, "Int") {
add_int_name(name)
}
if str_eq(ltype, "Float") {
add_float_name(name)
}
// Temporal type annotations register the name with the matching
// typed-set so BinOp / comparison codegen routes through the
// typed wrappers and forbids cross-type ops.
@@ -1503,12 +1465,6 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if is_zone_expr(val) {
add_zone_name(name)
}
// Float inference: an unannotated `let` whose RHS is provably Float
// (literal, math builtin, or float arithmetic) registers the name so
// later `name <op> ...` routes through the double path.
if is_float_expr(val) {
add_float_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
@@ -1828,15 +1784,6 @@ fn is_int_name(name: String) -> Bool {
return str_contains(csv, "," + name + ",")
}
// Float-typed binding set parallel to is_int_name. Populated from `let x:
// Float = ...` annotations, `: Float` parameters, and inference from a Float
// RHS. Consulted by is_float_expr to route arithmetic through the double path.
fn is_float_name(name: String) -> Bool {
let csv: String = state_get("__float_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
// Same shape as is_int_name, for Instant- and Duration-typed bindings.
// Used by the BinOp/comparison codegen to dispatch arithmetic through the
// typed runtime wrappers (el_instant_add_dur, el_duration_lt, -) and to
@@ -1900,28 +1847,6 @@ fn is_int_call(call_expr: Map<String, Any>) -> Bool {
return false
}
// Known runtime builtins that return Float. Parallel to is_int_call lets a
// Call participate in float arithmetic (and get inferred into __float_names on
// an unannotated `let`). New Float-returning builtins must be added here.
fn is_float_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "int_to_float") { return true }
if str_eq(name, "str_to_float") { return true }
if str_eq(name, "json_get_float") { return true }
if str_eq(name, "decimal_round") { return true }
if str_eq(name, "math_pi") { return true }
if str_eq(name, "math_sin") { return true }
if str_eq(name, "math_cos") { return true }
if str_eq(name, "math_sqrt") { return true }
if str_eq(name, "math_log") { return true }
if str_eq(name, "math_ln") { return true }
if str_eq(name, "el_from_float") { return true }
return false
}
// Builtins that return an Instant. Used by is_instant_expr and the BinOp
// dispatch - `now() + 5.seconds` types as Instant only because we can see
// that now() is an Instant-returning Call.
@@ -2330,71 +2255,19 @@ fn is_int_expr(expr: Map<String, Any>) -> Bool {
return false
}
// is_float_expr true when expr is (or provably evaluates to) a Float value.
// Mirrors is_int_expr: Float literal, Neg of a Float, a Float-typed Ident
// (registered in __float_names), a Float-returning builtin Call, or a Float
// arithmetic BinOp (float propagates: if either operand is Float the result
// is Float). Drives the float-arithmetic dispatch in cg_expr and the EqEq/
// NotEq float comparison. Kept conservative (only provably-Float) so it never
// intercepts a value the Int paths must own the two predicates are mutually
// exclusive on well-typed source.
// is_float_expr true when expr is (or evaluates to) a Float-typed value.
// Used in EqEq/NotEq codegen to avoid str_eq on float values.
fn is_float_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Float") { return true }
if str_eq(k, "Neg") {
return is_float_expr(expr["inner"])
}
if str_eq(k, "Ident") {
let name: String = expr["name"]
return is_float_name(name)
}
if str_eq(k, "Call") {
return is_float_call(expr)
}
if str_eq(k, "BinOp") {
let op: String = expr["op"]
if str_eq(op, "Plus") {
if is_float_expr(expr["left"]) { return true }
if is_float_expr(expr["right"]) { return true }
return false
}
if str_eq(op, "Minus") {
if is_float_expr(expr["left"]) { return true }
if is_float_expr(expr["right"]) { return true }
return false
}
if str_eq(op, "Star") {
if is_float_expr(expr["left"]) { return true }
if is_float_expr(expr["right"]) { return true }
return false
}
if str_eq(op, "Slash") {
if is_float_expr(expr["left"]) { return true }
if is_float_expr(expr["right"]) { return true }
return false
}
if str_eq(op, "Percent") {
if is_float_expr(expr["left"]) { return true }
if is_float_expr(expr["right"]) { return true }
return false
}
return false
let inner = expr["inner"]
let ik: String = inner["expr"]
if str_eq(ik, "Float") { return true }
}
false
}
// float_operand_c render a BinOp operand as a C double for float arithmetic.
// A provably-Float operand round-trips through el_to_float() (recovering the
// double from the int64 bit-slot); anything else is treated as Int-like and
// numerically converted with (double). This is why Float locals/params must
// be annotated `: Float` an unannotated var holding float bits would be
// mis-converted by the (double) cast, exactly as the Int system requires
// annotations to dispatch arithmetic vs concat.
fn float_operand_c(expr: Map<String, Any>, expr_c: String) -> String {
if is_float_expr(expr) { return "el_to_float(" + expr_c + ")" }
return "(double)(" + expr_c + ")"
}
// -- Capability-kind enforcement ----------------------------------------------
//
// A program's top-level block (cgi / service / none) determines which
@@ -2911,15 +2784,6 @@ fn add_int_name(name: String) -> Bool {
return true
}
fn add_float_name(name: String) -> Bool {
let csv: String = state_get("__float_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__float_names", csv + name + ",")
return true
}
fn add_instant_name(name: String) -> Bool {
let csv: String = state_get("__instant_names")
if str_eq(csv, "") { csv = "," }
@@ -3003,7 +2867,6 @@ fn add_zone_name(name: String) -> Bool {
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
state_set("__float_names", ",")
state_set("__instant_names", ",")
state_set("__duration_names", ",")
state_set("__calendar_names", ",")
@@ -3022,9 +2885,6 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
if str_eq(ptype, "Int") {
add_int_name(pname)
}
if str_eq(ptype, "Float") {
add_float_name(pname)
}
if str_eq(ptype, "Instant") {
add_instant_name(pname)
}