Merge pull request 'fix(soul): ratio guard against genesis seeding over a populated engram' (#21) from feat/connectors-soul into main
fix(soul): ratio guard against genesis seeding over a populated engram
This commit was merged in pull request #21.
This commit is contained in:
@@ -909,6 +909,9 @@ fn handle_chat_as_soul(body: String) -> String {
|
||||
let req_model: String = json_get(body, "model")
|
||||
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
|
||||
|
||||
// Hard Bell: pre-LLM safety evaluation — multi-soul room conversations are real interactions.
|
||||
let system_prompt = safety_augment_system(system_prompt, eff_message)
|
||||
|
||||
let raw_response: String = llm_call_system(model, system_prompt, eff_message)
|
||||
|
||||
let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|
||||
@@ -955,6 +958,9 @@ fn handle_dharma_room_turn(body: String) -> String {
|
||||
identity + "\n\n" + engram_ctx
|
||||
}
|
||||
|
||||
// Hard Bell: pre-LLM safety evaluation — dharma room turns are real conversations.
|
||||
let system_prompt = safety_augment_system(system_prompt, transcript)
|
||||
|
||||
let raw_response: String = llm_call_system(model, system_prompt, transcript)
|
||||
|
||||
let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|
||||
@@ -1001,6 +1007,9 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
|
||||
let system: String = identity + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct and stay in character.\n\n" + ctx
|
||||
|
||||
let api_key: String = agentic_api_key()
|
||||
// Hard Bell: pre-LLM safety evaluation on agentic dharma room turns.
|
||||
let system = safety_augment_system(system, transcript)
|
||||
|
||||
let tools_json: String = agentic_tools_all()
|
||||
let safe_transcript: String = json_safe(transcript)
|
||||
let safe_sys: String = json_safe(system)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn chat_default_model() -> String
|
||||
extern fn gemini_api_key() -> String
|
||||
extern fn xai_api_key() -> String
|
||||
@@ -19,8 +19,18 @@ extern fn studio_tools_json() -> String
|
||||
extern fn agentic_api_key() -> String
|
||||
extern fn call_neuron_mcp(tool_name: String, args_json: String) -> String
|
||||
extern fn agentic_tools_literal() -> String
|
||||
extern fn agentic_tools_with_web() -> String
|
||||
extern fn dispatch_tool(tool_name: String, tool_input: String) -> String
|
||||
extern fn json_array_append(arr: String, item: String) -> String
|
||||
extern fn append_tool_log(log: String, name: String) -> String
|
||||
extern fn exec_tool_block(block: String) -> String
|
||||
extern fn agentic_blob(model: String, system: String, tools_json: String, messages: String, origin: String, approval: Bool, iteration: Int, tools_log: String, content: String, queue: String, results: String, next: Int) -> String
|
||||
extern fn extract_all_text(s: String) -> String
|
||||
extern fn strip_citations(s: String) -> String
|
||||
extern fn agentic_api_turn(model: String, safe_sys: String, tools_json: String, messages: String) -> String
|
||||
extern fn agentic_engine(session_id: String, blob: String) -> String
|
||||
extern fn handle_chat_agentic(body: String) -> String
|
||||
extern fn handle_session_approve(session_id: String, body: String) -> String
|
||||
extern fn handle_chat_as_soul(body: String) -> String
|
||||
extern fn handle_dharma_room_turn(body: String) -> String
|
||||
extern fn handle_dharma_room_turn_agentic(body: String) -> String
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn chat_default_model() -> String
|
||||
extern fn gemini_api_key() -> String
|
||||
extern fn xai_api_key() -> String
|
||||
@@ -19,6 +19,7 @@ extern fn studio_tools_json() -> String
|
||||
extern fn agentic_api_key() -> String
|
||||
extern fn call_neuron_mcp(tool_name: String, args_json: String) -> String
|
||||
extern fn agentic_tools_literal() -> String
|
||||
extern fn agentic_tools_with_web() -> String
|
||||
extern fn dispatch_tool(tool_name: String, tool_input: String) -> String
|
||||
extern fn handle_chat_agentic(body: String) -> String
|
||||
extern fn handle_chat_as_soul(body: String) -> String
|
||||
|
||||
+36
-2
@@ -1042,12 +1042,36 @@ el_val_t call_neuron_mcp(el_val_t tool_name, el_val_t args_json);
|
||||
el_val_t agentic_tools_literal(void);
|
||||
el_val_t agentic_tools_with_web(void);
|
||||
el_val_t dispatch_tool(el_val_t tool_name, el_val_t tool_input);
|
||||
el_val_t json_array_append(el_val_t arr, el_val_t item);
|
||||
el_val_t append_tool_log(el_val_t log, el_val_t name);
|
||||
el_val_t exec_tool_block(el_val_t block);
|
||||
el_val_t agentic_blob(el_val_t model, el_val_t system, el_val_t tools_json, el_val_t messages, el_val_t origin, el_val_t approval, el_val_t iteration, el_val_t tools_log, el_val_t content, el_val_t queue, el_val_t results, el_val_t next);
|
||||
el_val_t extract_all_text(el_val_t s);
|
||||
el_val_t strip_citations(el_val_t s);
|
||||
el_val_t agentic_api_turn(el_val_t model, el_val_t safe_sys, el_val_t tools_json, el_val_t messages);
|
||||
el_val_t agentic_engine(el_val_t session_id, el_val_t blob);
|
||||
el_val_t handle_chat_agentic(el_val_t body);
|
||||
el_val_t handle_session_approve(el_val_t session_id, el_val_t body);
|
||||
el_val_t handle_chat_as_soul(el_val_t body);
|
||||
el_val_t handle_dharma_room_turn(el_val_t body);
|
||||
el_val_t handle_dharma_room_turn_agentic(el_val_t body);
|
||||
el_val_t auto_persist(el_val_t req, el_val_t resp);
|
||||
el_val_t strengthen_chat_nodes(el_val_t activation_nodes);
|
||||
el_val_t safety_self_harm_phrases(void);
|
||||
el_val_t safety_abuse_phrases(void);
|
||||
el_val_t safety_general_hard_phrases(void);
|
||||
el_val_t safety_soft_phrases(void);
|
||||
el_val_t safety_normalize(el_val_t message);
|
||||
el_val_t safety_any_match(el_val_t text, el_val_t phrases_json);
|
||||
el_val_t safety_count_match(el_val_t text, el_val_t phrases_json);
|
||||
el_val_t safety_detect_bell_level(el_val_t message);
|
||||
el_val_t safety_classify_hard_bell(el_val_t message);
|
||||
el_val_t safety_soft_directive(void);
|
||||
el_val_t safety_hard_directive(el_val_t hard_type);
|
||||
el_val_t safety_augment_system(el_val_t system, el_val_t user_msg);
|
||||
el_val_t safety_contact_path(void);
|
||||
el_val_t handle_safety_contact_get(void);
|
||||
el_val_t handle_safety_contact_post(el_val_t body);
|
||||
el_val_t auth_headers(el_val_t tok);
|
||||
el_val_t axon_get(el_val_t path);
|
||||
el_val_t axon_post(el_val_t path, el_val_t body);
|
||||
@@ -1110,6 +1134,7 @@ el_val_t session_update_meta_timestamp(el_val_t session_id);
|
||||
el_val_t session_auto_title(el_val_t session_id, el_val_t first_message);
|
||||
el_val_t handle_session_approve(el_val_t session_id, el_val_t body);
|
||||
el_val_t strip_query(el_val_t path);
|
||||
el_val_t flag_true(el_val_t body, el_val_t key);
|
||||
el_val_t err_404(el_val_t path);
|
||||
el_val_t err_405(el_val_t method, el_val_t path);
|
||||
el_val_t route_health(void);
|
||||
@@ -1144,6 +1169,9 @@ el_val_t local_node_count;
|
||||
el_val_t snapshot_usable;
|
||||
el_val_t boot_num;
|
||||
el_val_t is_genesis;
|
||||
el_val_t guard_disk;
|
||||
el_val_t guard_disk_len;
|
||||
el_val_t safe_to_seed;
|
||||
|
||||
el_val_t lang_profile(el_val_t code, el_val_t word_order, el_val_t morph_type, el_val_t has_case, el_val_t has_gender, el_val_t script_dir, el_val_t agreement, el_val_t null_subject) {
|
||||
el_val_t r = native_list_empty();
|
||||
@@ -28915,7 +28943,13 @@ int main(int _argc, char** _argv) {
|
||||
state_set(EL_STR("soul_engram_api_key"), engram_api_key_raw);
|
||||
state_set(EL_STR("soul.running"), EL_STR("true"));
|
||||
is_genesis = str_eq(soul_cgi_id, EL_STR("ntn-genesis"));
|
||||
if (is_genesis) {
|
||||
guard_disk = ({ el_val_t _if_result_25 = 0; if (str_eq(engram_url_raw, EL_STR(""))) { _if_result_25 = (fs_read(snapshot)); } else { _if_result_25 = (EL_STR("")); } _if_result_25; });
|
||||
guard_disk_len = str_len(guard_disk);
|
||||
safe_to_seed = !((guard_disk_len > 200000) && (engram_node_count() < (guard_disk_len / 16000)));
|
||||
if (is_genesis && !safe_to_seed) {
|
||||
println(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("[soul] GUARD: loaded "), int_to_str(engram_node_count())), EL_STR(" nodes but snapshot file is ")), int_to_str(guard_disk_len)), EL_STR(" bytes \xe2\x80\x94 refusing to seed/save over a real graph")));
|
||||
}
|
||||
if (is_genesis && safe_to_seed) {
|
||||
el_val_t edge_count_now = engram_edge_count();
|
||||
if (edge_count_now < 100) {
|
||||
init_soul_edges();
|
||||
@@ -28926,7 +28960,7 @@ int main(int _argc, char** _argv) {
|
||||
state_set(EL_STR("soul_snapshot_path"), snapshot);
|
||||
engram_save(snapshot);
|
||||
}
|
||||
if (is_genesis) {
|
||||
if (is_genesis && safe_to_seed) {
|
||||
el_val_t snap = state_get(EL_STR("soul_snapshot_path"));
|
||||
if (!str_eq(snap, EL_STR(""))) {
|
||||
engram_save(snap);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn elp_extract_topic(msg: String) -> String
|
||||
extern fn elp_detect_predicate(msg: String) -> String
|
||||
extern fn elp_parse(msg: String) -> String
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn tier_working() -> String
|
||||
extern fn tier_episodic() -> String
|
||||
extern fn tier_canonical() -> String
|
||||
|
||||
+118
-4
@@ -87,6 +87,21 @@ fn api_or_empty(s: String) -> String {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
// api_persisted — read-back-after-write guard against hallucinated saves.
|
||||
// After a write builtin returns an id, confirm the node is actually queryable
|
||||
// via engram_get_node_json(id) (returns "" or "null" when missing). Returns
|
||||
// true only when the node is genuinely persisted.
|
||||
fn api_persisted(id: String) -> Bool {
|
||||
if str_eq(id, "") { return false }
|
||||
let node: String = engram_get_node_json(id)
|
||||
return !str_eq(node, "") && !str_eq(node, "null")
|
||||
}
|
||||
|
||||
// api_not_persisted — standard error for a write that did not read back.
|
||||
fn api_not_persisted(id: String) -> String {
|
||||
return "{\"ok\":false,\"error\":\"write_not_persisted\",\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// handle_api_begin_session — full context bootstrap.
|
||||
@@ -143,12 +158,101 @@ fn handle_api_remember(body: String) -> String {
|
||||
let id: String = engram_node_full(content, "Memory", "memory:remembered",
|
||||
el_from_float(sal), el_from_float(sal), el_from_float(0.9),
|
||||
"Episodic", final_tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"id\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
// handle_api_node_create — generic typed-node create (BacklogItem, Artifact, ...).
|
||||
// Mirrors handle_api_remember but lets the caller choose node_type/label/tier so the
|
||||
// UI can create non-Memory nodes. Read-back verified against hallucinated saves.
|
||||
fn handle_api_node_create(body: String) -> String {
|
||||
let content: String = json_get(body, "content")
|
||||
if str_eq(content, "") { return api_err("content is required") }
|
||||
let nt_raw: String = json_get(body, "node_type")
|
||||
let node_type: String = if str_eq(nt_raw, "") { "Memory" } else { nt_raw }
|
||||
let label_raw: String = json_get(body, "label")
|
||||
let label: String = if str_eq(label_raw, "") { "node:created" } else { label_raw }
|
||||
let tier_raw: String = json_get(body, "tier")
|
||||
let tier: String = if str_eq(tier_raw, "") { "Episodic" } else { tier_raw }
|
||||
let tags_raw: String = json_get(body, "tags")
|
||||
let tags: String = if str_eq(tags_raw, "") { "[\"" + node_type + "\"]" } else { tags_raw }
|
||||
let importance: String = json_get(body, "importance")
|
||||
let sal: Float = if str_eq(importance, "critical") { 0.95 } else {
|
||||
if str_eq(importance, "high") { 0.75 } else {
|
||||
if str_eq(importance, "low") { 0.25 } else { 0.5 }
|
||||
}
|
||||
}
|
||||
let id: String = engram_node_full(content, node_type, label,
|
||||
el_from_float(sal), el_from_float(sal), el_from_float(0.9),
|
||||
tier, tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"id\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
// handle_api_node_delete — remove a node by id (engram_forget) and verify it is gone.
|
||||
// Backs /api/neuron/node/delete and the /api/neuron/memory/delete alias the UI calls.
|
||||
fn handle_api_node_delete(body: String) -> String {
|
||||
let id: String = json_get(body, "id")
|
||||
if str_eq(id, "") { return api_err("id is required") }
|
||||
// engram_forget removes the node + its incident edges from the live graph. We do
|
||||
// NOT read-back-verify here: engram_get_node_json can return a STALE hit for a just-
|
||||
// removed id (the id->index map is not rebuilt on forget), which would produce a
|
||||
// false "delete_failed" even though the node is gone. The graph endpoints
|
||||
// (/api/graph/nodes) correctly reflect the removal, which is the source of truth.
|
||||
engram_forget(id)
|
||||
return "{\"ok\":true,\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
// handle_api_node_update — update a node's content/fields. There is no in-place
|
||||
// engram update builtin, so this recreates the node with merged fields and then
|
||||
// forgets the old one (only after the new node reads back). The id changes; the
|
||||
// response returns the new id and the replaced id so callers can re-point.
|
||||
fn handle_api_node_update(body: String) -> String {
|
||||
let id: String = json_get(body, "id")
|
||||
if str_eq(id, "") { return api_err("id is required") }
|
||||
if !api_persisted(id) {
|
||||
return "{\"ok\":false,\"error\":\"not_found\",\"id\":\"" + id + "\"}"
|
||||
}
|
||||
let old: String = engram_get_node_json(id)
|
||||
let body_content: String = json_get(body, "content")
|
||||
let content: String = if str_eq(body_content, "") { json_get(old, "content") } else { body_content }
|
||||
let body_nt: String = json_get(body, "node_type")
|
||||
let old_nt: String = json_get(old, "node_type")
|
||||
let node_type: String = if !str_eq(body_nt, "") { body_nt } else {
|
||||
if !str_eq(old_nt, "") { old_nt } else { "Memory" }
|
||||
}
|
||||
let body_label: String = json_get(body, "label")
|
||||
let old_label: String = json_get(old, "label")
|
||||
let label: String = if !str_eq(body_label, "") { body_label } else {
|
||||
if !str_eq(old_label, "") { old_label } else { "node:updated" }
|
||||
}
|
||||
let body_tier: String = json_get(body, "tier")
|
||||
let old_tier: String = json_get(old, "tier")
|
||||
let tier: String = if !str_eq(body_tier, "") { body_tier } else {
|
||||
if !str_eq(old_tier, "") { old_tier } else { "Episodic" }
|
||||
}
|
||||
let body_tags: String = json_get(body, "tags")
|
||||
let tags: String = if str_eq(body_tags, "") { "[\"" + node_type + "\"]" } else { body_tags }
|
||||
let new_id: String = engram_node_full(content, node_type, label,
|
||||
el_from_float(0.5), el_from_float(0.5), el_from_float(0.8),
|
||||
tier, tags)
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
engram_forget(id)
|
||||
return "{\"id\":\"" + new_id + "\",\"replaced\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
// handle_api_recall — search or activate memory by query.
|
||||
fn handle_api_recall(method: String, path: String, body: String) -> String {
|
||||
let q: String = if str_eq(method, "GET") { api_query_param(path, "query") } else { json_get(body, "query") }
|
||||
// Accept the query from the URL ?query= / ?q= params, or, when those are
|
||||
// empty (e.g. a POST with a JSON body), from the body fields "query"/"q".
|
||||
let url_q: String = if str_eq(api_query_param(path, "query"), "") {
|
||||
api_query_param(path, "q")
|
||||
} else { api_query_param(path, "query") }
|
||||
let body_query: String = json_get(body, "query")
|
||||
let body_q: String = json_get(body, "q")
|
||||
let q: String = if !str_eq(url_q, "") { url_q } else {
|
||||
if !str_eq(body_query, "") { body_query } else { body_q }
|
||||
}
|
||||
let chain: String = json_get(body, "chain_name")
|
||||
let limit: Int = api_query_int(path, "limit", 0)
|
||||
let limit = if limit == 0 { json_get_int(body, "limit") } else { limit }
|
||||
@@ -165,7 +269,14 @@ fn handle_api_recall(method: String, path: String, body: String) -> String {
|
||||
|
||||
// handle_api_search_knowledge — search with query escaping + activate fallback.
|
||||
fn handle_api_search_knowledge(method: String, path: String, body: String) -> String {
|
||||
let q: String = if str_eq(method, "GET") { api_query_param(path, "q") } else { json_get(body, "query") }
|
||||
// Accept the query from the URL ?q= param, or, when that is empty (e.g. a
|
||||
// POST with a JSON body), from the body fields "query" then "q".
|
||||
let url_q: String = api_query_param(path, "q")
|
||||
let body_query: String = json_get(body, "query")
|
||||
let body_q: String = json_get(body, "q")
|
||||
let q: String = if !str_eq(url_q, "") { url_q } else {
|
||||
if !str_eq(body_query, "") { body_query } else { body_q }
|
||||
}
|
||||
let limit: Int = api_query_int(path, "limit", 0)
|
||||
let limit = if limit == 0 { json_get_int(body, "limit") } else { limit }
|
||||
let limit = if limit == 0 { 10 } else { limit }
|
||||
@@ -195,6 +306,7 @@ fn handle_api_capture_knowledge(body: String) -> String {
|
||||
let id: String = engram_node_full(full, "Knowledge", "knowledge:captured",
|
||||
el_from_float(0.85), el_from_float(0.8), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"id\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
@@ -208,7 +320,8 @@ fn handle_api_evolve_knowledge(body: String) -> String {
|
||||
let new_id: String = engram_node_full(content, "Knowledge", "knowledge:evolved",
|
||||
el_from_float(0.75), el_from_float(0.75), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !str_eq(prior_id, "") && !str_eq(new_id, "") {
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
if !str_eq(prior_id, "") {
|
||||
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
}
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}"
|
||||
@@ -228,7 +341,7 @@ fn handle_api_promote_knowledge(body: String) -> String {
|
||||
let new_id: String = engram_node_full(content, "Knowledge", "knowledge:canonical",
|
||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||
"Canonical", tags)
|
||||
if str_eq(new_id, "") { return api_err("failed to create canonical node") }
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
engram_connect(new_id, prior_id, el_from_float(0.95), "supersedes")
|
||||
return "{\"ok\":true,\"new_id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\"}"
|
||||
}
|
||||
@@ -255,6 +368,7 @@ fn handle_api_define_process(body: String) -> String {
|
||||
let id: String = engram_node_full(content, "Process", label,
|
||||
el_from_float(0.8), el_from_float(0.8), el_from_float(0.9),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"id\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,9 @@ fn connectd_get(suffix: String) -> String {
|
||||
// so arbitrary JSON cannot reach the shell as a command-line argument.
|
||||
fn connectd_post(suffix: String, body: String) -> String {
|
||||
let eff: String = if str_eq(body, "") { "{}" } else { body }
|
||||
let tmp: String = "/tmp/neuron-connectors-req.json"
|
||||
// Unique temp path per call — prevents collision if concurrency is ever added
|
||||
// or if two soul instances run on the same machine (latent correctness hazard).
|
||||
let tmp: String = "/tmp/neuron-connectors-req-" + int_to_str(time_now()) + ".json"
|
||||
fs_write(tmp, eff)
|
||||
let out: String = exec_capture("curl -s --max-time 20 -X POST http://127.0.0.1:7771" + suffix + " -H 'Content-Type: application/json' -d @" + tmp)
|
||||
if str_eq(out, "") {
|
||||
@@ -339,6 +341,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if str_eq(clean, "/api/neuron/ctx") {
|
||||
return handle_api_compile_ctx("")
|
||||
}
|
||||
if str_eq(clean, "/api/safety-contact") {
|
||||
return handle_safety_contact_get()
|
||||
}
|
||||
if str_starts_with(clean, "/api/neuron/knowledge/search") {
|
||||
return handle_api_search_knowledge(method, path, body)
|
||||
}
|
||||
@@ -511,6 +516,18 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if str_eq(clean, "/api/neuron/memory") {
|
||||
return handle_api_remember(body)
|
||||
}
|
||||
if str_eq(clean, "/api/safety-contact") {
|
||||
return handle_safety_contact_post(body)
|
||||
}
|
||||
if str_eq(clean, "/api/neuron/node/create") {
|
||||
return handle_api_node_create(body)
|
||||
}
|
||||
if str_eq(clean, "/api/neuron/node/update") {
|
||||
return handle_api_node_update(body)
|
||||
}
|
||||
if str_eq(clean, "/api/neuron/node/delete") {
|
||||
return handle_api_node_delete(body)
|
||||
}
|
||||
if str_eq(clean, "/api/neuron/memory/evolve") {
|
||||
return handle_api_evolve_memory(body)
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn strip_query(path: String) -> String
|
||||
extern fn flag_true(body: String, key: String) -> Bool
|
||||
extern fn err_404(path: String) -> String
|
||||
extern fn err_405(method: String, path: String) -> String
|
||||
extern fn route_health() -> String
|
||||
|
||||
@@ -207,3 +207,165 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
||||
)
|
||||
return ""
|
||||
}
|
||||
|
||||
// abuse — danger from another person. Emergency services / 988 ONLY. The
|
||||
// safety contact on file is NEVER notified — they may be the abuser.
|
||||
// This routing is non-configurable by design.
|
||||
//
|
||||
// Evaluation is keyword-only (zero added latency) and stays on device. Triggers are
|
||||
// logged locally as InternalStateEvents and never transmitted.
|
||||
|
||||
// ── Phrase lists (ported verbatim from bell-detector.ts) ───────────────────────
|
||||
|
||||
fn safety_self_harm_phrases() -> String {
|
||||
return "[\"kill myself\",\"killing myself\",\"want to die\",\"want to be dead\",\"going to end my life\",\"end my life\",\"take my life\",\"taking my life\",\"suicide\",\"suicidal\",\"can't go on\",\"cannot go on\",\"i have a knife\",\"i have a gun\",\"i have pills\",\"took pills\",\"took too many\",\"overdose\",\"overdosing\",\"self harm\",\"self-harm\",\"cutting myself\",\"hurt myself\",\"hurting myself\",\"no reason to live\",\"not worth living\",\"better off dead\",\"better off without me\"]"
|
||||
}
|
||||
|
||||
fn safety_abuse_phrases() -> String {
|
||||
return "[\"someone is hurting me\",\"someone's hurting me\",\"someone hurt me\",\"he hit me\",\"she hit me\",\"they hit me\",\"he hurt me\",\"she hurt me\",\"being abused\",\"being hurt by\",\"i am being abused\",\"i'm being abused\",\"i am being hurt\",\"i'm being hurt\",\"domestic violence\",\"my partner hurt\",\"my partner hit\",\"my husband hurt\",\"my wife hurt\",\"my boyfriend hurt\",\"my girlfriend hurt\",\"my parent hurt\",\"my father hurt\",\"my mother hurt\",\"my dad hurt\",\"my mom hurt\",\"afraid of him\",\"afraid of her\",\"afraid to go home\",\"scared of him\",\"scared of her\",\"he threatened me\",\"she threatened me\",\"threatened to hurt me\",\"threatened to kill me\",\"going to hurt me\",\"going to kill me\",\"help me he\",\"help me she\",\"help me they\"]"
|
||||
}
|
||||
|
||||
// General danger phrases that don't fit a bucket cleanly. Detected as hard; they
|
||||
// fall through to self_harm routing (the person is the primary concern).
|
||||
fn safety_general_hard_phrases() -> String {
|
||||
return "[\"going to kill\",\"going to hurt\",\"hurting me\",\"being hurt\"]"
|
||||
}
|
||||
|
||||
fn safety_soft_phrases() -> String {
|
||||
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\"]"
|
||||
}
|
||||
|
||||
// ── Matching helpers (single loops only — el escapes while-body mutation via
|
||||
// top-level let rebinds; nested loops would not advance) ────────────────────
|
||||
|
||||
fn safety_normalize(message: String) -> String {
|
||||
let lower: String = str_to_lower(message)
|
||||
// Normalise the common curly apostrophe to ASCII so "can't" / "i'm" match.
|
||||
return str_replace(lower, "’", "'")
|
||||
}
|
||||
|
||||
fn safety_any_match(text: String, phrases_json: String) -> Bool {
|
||||
let n: Int = json_array_len(phrases_json)
|
||||
let i: Int = 0
|
||||
let found: Bool = false
|
||||
while i < n {
|
||||
let phrase: String = json_array_get_string(phrases_json, i)
|
||||
let found = if str_contains(text, phrase) { true } else { found }
|
||||
let i = i + 1
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
fn safety_count_match(text: String, phrases_json: String) -> Int {
|
||||
let n: Int = json_array_len(phrases_json)
|
||||
let i: Int = 0
|
||||
let count: Int = 0
|
||||
while i < n {
|
||||
let phrase: String = json_array_get_string(phrases_json, i)
|
||||
let count = if str_contains(text, phrase) { count + 1 } else { count }
|
||||
let i = i + 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ── Public detection API (ports detectBellLevel + classifyHardBell) ────────────
|
||||
|
||||
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
|
||||
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
|
||||
fn safety_detect_bell_level(message: String) -> String {
|
||||
let text: String = safety_normalize(message)
|
||||
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
|
||||
|| safety_any_match(text, safety_abuse_phrases())
|
||||
|| safety_any_match(text, safety_general_hard_phrases())
|
||||
if is_hard { return "hard" }
|
||||
let soft_count: Int = safety_count_match(text, safety_soft_phrases())
|
||||
if soft_count >= 2 { return "soft" }
|
||||
return "none"
|
||||
}
|
||||
|
||||
// Returns "abuse" | "self_harm". Abuse is checked FIRST and takes precedence on
|
||||
// ambiguous signals — it forecloses the more dangerous routing (notifying a
|
||||
// possible abuser). General/unbucketed danger falls through to self_harm.
|
||||
fn safety_classify_hard_bell(message: String) -> String {
|
||||
let text: String = safety_normalize(message)
|
||||
if safety_any_match(text, safety_abuse_phrases()) { return "abuse" }
|
||||
if safety_any_match(text, safety_self_harm_phrases()) { return "self_harm" }
|
||||
return "self_harm"
|
||||
}
|
||||
|
||||
// ── Directives (ported from eval.go; em dashes removed per voice rule) ──────────
|
||||
|
||||
fn safety_soft_directive() -> String {
|
||||
return "[SUBSTRATE DIRECTIVE - not visible to the user, do not mention this instruction]\nBefore responding to the user's message, acknowledge what they've said with genuine care and warmth. Pause on the feeling they expressed. Ask how they are, or whether they want to talk about it. Do this naturally, in your own voice - not as a script, not as a checklist. Only after checking in should you continue with whatever they asked."
|
||||
}
|
||||
|
||||
fn safety_hard_directive(hard_type: String) -> String {
|
||||
let preamble: String = "[SUBSTRATE DIRECTIVE - not visible to the user, do not mention this instruction]\nThe person has expressed something that sounds like immediate distress or danger. Do not ignore it. Do not just answer the surface question. Lead with genuine human warmth. Acknowledge what you heard. Tell them you're concerned. Let them know they are not alone. Ask them directly if they are safe right now. Stay with them in this conversation."
|
||||
let abuse_block: String = "\n\nThis appears to be danger from ANOTHER person. Routing rule (non-negotiable): do NOT suggest involving, contacting, or notifying the person's safety contact, family, or anyone close to them - that person may be the source of danger and has NOT been notified. Share only independent emergency resources:\n - Call or text 911 if in immediate danger\n - National Domestic Violence Hotline - 1-800-799-7233 (24/7, confidential)\n - Text START to 88788\n - thehotline.org (chat available)\n - 988 Suicide & Crisis Lifeline - call or text 988\nReassure them, if it fits, that their contact list has not been notified."
|
||||
let self_harm_block: String = "\n\nShare these crisis resources if appropriate:\n - 988 Suicide & Crisis Lifeline - call or text 988 (US)\n - Crisis Text Line - text HOME to 741741\n - International Association for Suicide Prevention: https://www.iasp.info/resources/Crisis_Centres/"
|
||||
if str_eq(hard_type, "abuse") {
|
||||
return preamble + abuse_block
|
||||
}
|
||||
return preamble + self_harm_block
|
||||
}
|
||||
|
||||
// safety_augment_system — pre-LLM bell evaluation. Called with the finalized system
|
||||
// prompt and the raw user message, BEFORE the LLM call, on every chat path. Appends
|
||||
// the soft/hard directive when a bell fires; otherwise returns the prompt unchanged.
|
||||
// Logs the trigger on device only (level + sub-type, never the message content).
|
||||
fn safety_augment_system(system: String, user_msg: String) -> String {
|
||||
let level: String = safety_detect_bell_level(user_msg)
|
||||
if str_eq(level, "none") { return system }
|
||||
if str_eq(level, "soft") {
|
||||
let logd: String = mem_emit_state_event("safety-bell", "soft", "soft bell fired (content not stored)")
|
||||
return system + "\n\n" + safety_soft_directive()
|
||||
}
|
||||
let hard_type: String = safety_classify_hard_bell(user_msg)
|
||||
let logd2: String = mem_emit_state_event("safety-bell", "hard:" + hard_type, "hard bell fired (content not stored)")
|
||||
return system + "\n\n" + safety_hard_directive(hard_type)
|
||||
}
|
||||
|
||||
// ── Safety-contact storage + endpoint (ports contact.go + handler.go) ───────────
|
||||
// Stored locally at ~/.neuron/safety-contact.json (same file the desktop gate writes),
|
||||
// never synced. NOTE: encryption-at-rest is a flagged follow-up (ties to key custody);
|
||||
// today the file is plaintext JSON, matching the current desktop behavior.
|
||||
|
||||
fn safety_contact_path() -> String {
|
||||
return env("HOME") + "/.neuron/safety-contact.json"
|
||||
}
|
||||
|
||||
// GET /api/safety-contact -> {"configured":false} or {"configured":true,"contact":{...}}
|
||||
fn handle_safety_contact_get() -> String {
|
||||
let raw: String = fs_read(safety_contact_path())
|
||||
if str_eq(raw, "") { return "{\"configured\":false}" }
|
||||
return "{\"configured\":true,\"contact\":" + raw + "}"
|
||||
}
|
||||
|
||||
// POST /api/safety-contact — validate + persist. Mirrors handler.go: crisis line is
|
||||
// always acceptable and auto-fills its fields; otherwise a name is required. The
|
||||
// contact can be replaced but never cleared to empty (the gate enforces presence).
|
||||
fn handle_safety_contact_post(body: String) -> String {
|
||||
let is_crisis: Bool = json_get_bool(body, "is_crisis_line")
|
||||
let name_in: String = json_get(body, "name")
|
||||
if !is_crisis {
|
||||
if str_eq(name_in, "") { return "{\"ok\":false,\"error\":\"name is required\"}" }
|
||||
}
|
||||
let name: String = if is_crisis { "Crisis Line" } else { name_in }
|
||||
let method: String = if is_crisis { "crisis-line" } else { json_get(body, "contact_method") }
|
||||
let value: String = if is_crisis { "988" } else { json_get(body, "contact_value") }
|
||||
let rel: String = if is_crisis { "crisis-support" } else { json_get(body, "relationship") }
|
||||
let crisis_str: String = if is_crisis { "true" } else { "false" }
|
||||
let now: String = time_format(time_now(), "%Y-%m-%dT%H:%M:%SZ")
|
||||
let contact_json: String = "{\"name\":\"" + json_safe(name) + "\""
|
||||
+ ",\"contact_method\":\"" + json_safe(method) + "\""
|
||||
+ ",\"contact_value\":\"" + json_safe(value) + "\""
|
||||
+ ",\"relationship\":\"" + json_safe(rel) + "\""
|
||||
+ ",\"confirmed\":true"
|
||||
+ ",\"is_crisis_line\":" + crisis_str
|
||||
+ ",\"set_at\":\"" + now + "\"}"
|
||||
fs_write(safety_contact_path(), contact_json)
|
||||
// Read-back verify the write actually persisted.
|
||||
let check: String = fs_read(safety_contact_path())
|
||||
if str_eq(check, "") { return "{\"ok\":false,\"error\":\"write_failed\"}" }
|
||||
return "{\"configured\":true,\"contact\":" + contact_json + ",\"ok\":true}"
|
||||
}
|
||||
|
||||
+15
@@ -6,3 +6,18 @@ extern fn safety_threat_score(input: String, history: String) -> Int
|
||||
extern fn safety_screen(input: String, history: String) -> String
|
||||
extern fn safety_validate(output: String, action: String) -> String
|
||||
extern fn safety_log_bell(level: String, reason: String, input_summary: String) -> String
|
||||
extern fn safety_self_harm_phrases() -> String
|
||||
extern fn safety_abuse_phrases() -> String
|
||||
extern fn safety_general_hard_phrases() -> String
|
||||
extern fn safety_soft_phrases() -> String
|
||||
extern fn safety_normalize(message: String) -> String
|
||||
extern fn safety_any_match(text: String, phrases_json: String) -> Bool
|
||||
extern fn safety_count_match(text: String, phrases_json: String) -> Int
|
||||
extern fn safety_detect_bell_level(message: String) -> String
|
||||
extern fn safety_classify_hard_bell(message: String) -> String
|
||||
extern fn safety_soft_directive() -> String
|
||||
extern fn safety_hard_directive(hard_type: String) -> String
|
||||
extern fn safety_augment_system(system: String, user_msg: String) -> String
|
||||
extern fn safety_contact_path() -> String
|
||||
extern fn handle_safety_contact_get() -> String
|
||||
extern fn handle_safety_contact_post(body: String) -> String
|
||||
|
||||
@@ -5,6 +5,7 @@ import "stewardship.el"
|
||||
import "imprint.el"
|
||||
import "awareness.el"
|
||||
import "chat.el"
|
||||
import "safety.el"
|
||||
import "studio.el"
|
||||
import "elp-input.el"
|
||||
import "routes.el"
|
||||
@@ -362,7 +363,31 @@ state_set("soul_engram_api_key", engram_api_key_raw)
|
||||
state_set("soul.running", "true")
|
||||
|
||||
let is_genesis: Bool = str_eq(soul_cgi_id, "ntn-genesis")
|
||||
if is_genesis {
|
||||
|
||||
// GUARD (2026-06-15): never let genesis seed over a real graph. If the in-memory load is
|
||||
// sparse but the on-disk snapshot file is large, the load FAILED — seeding+saving now would
|
||||
// clobber the user's real memory (this is exactly how the 06-14 clobber happened). Read the
|
||||
// on-disk file (local mode only) and refuse the destructive seed+save when it looks populated.
|
||||
//
|
||||
// HTTP-engram guard (2026-06-17): when ENGRAM_URL is set the HTTP Engram owns persistence —
|
||||
// the soul must NEVER write to the local snapshot regardless of node counts. safe_to_seed is
|
||||
// unconditionally false in HTTP mode (not the persistence owner).
|
||||
let guard_disk: String = if str_eq(engram_url_raw, "") { fs_read(snapshot) } else { "" }
|
||||
let guard_disk_len: Int = str_len(guard_disk)
|
||||
// Ratio guard (2026-06-15 fix): refuse to seed/save whenever the in-memory load is FAR smaller than
|
||||
// the on-disk file implies (~16KB/node) — catches partial loads of ANY size, not just <50. The old
|
||||
// <50 threshold let a 63-node identity-only load clobber a 47MB/5000-node graph.
|
||||
// Multiplication form (2026-06-17): node_count * 16000 < disk_len avoids floor-division truncation
|
||||
// (e.g., 250KB / 16000 = 15.6, floors to 15 — a 15-node graph wrongly passes the old guard).
|
||||
// HTTP-engram guard: when using_http_engram the soul is not the persistence owner; never seed.
|
||||
let safe_to_seed: Bool = !using_http_engram && !(guard_disk_len > 200000 && engram_node_count() * 16000 < guard_disk_len)
|
||||
if is_genesis && !safe_to_seed {
|
||||
println("[soul] GUARD: loaded " + int_to_str(engram_node_count())
|
||||
+ " nodes but snapshot file is " + int_to_str(guard_disk_len)
|
||||
+ " bytes — refusing to seed/save over a real graph")
|
||||
}
|
||||
|
||||
if is_genesis && safe_to_seed {
|
||||
// Only build identity edges if the engram is fresh (< 100 edges).
|
||||
// init_soul_edges() is not idempotent — calling it on every restart
|
||||
// stacks duplicate co-value/identity edges into the snapshot.
|
||||
@@ -380,7 +405,7 @@ if is_genesis {
|
||||
|
||||
// Take a pre-serve snapshot for genesis instances — captures all boot-time graph changes
|
||||
// (identity context loading, boot counter, session-start event) before entering the serve loop.
|
||||
if is_genesis {
|
||||
if is_genesis && safe_to_seed {
|
||||
let snap: String = state_get("soul_snapshot_path")
|
||||
if !str_eq(snap, "") {
|
||||
engram_save(snap)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn init_soul_edges() -> Void
|
||||
extern fn load_identity_context() -> Void
|
||||
extern fn seed_persona_from_env() -> Void
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn auth_headers(tok: String) -> Map
|
||||
extern fn axon_get(path: String) -> String
|
||||
extern fn axon_post(path: String, body: String) -> String
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// ── tests/test_api_define_process.el ─────────────────────────────────────────
|
||||
//
|
||||
// Test the handle_api_define_process read-back fix (neuron-api.el).
|
||||
//
|
||||
// Bug: handle_api_define_process was the only write handler that did NOT call
|
||||
// api_persisted() after the write, returning {"id":"...","ok":true} even when
|
||||
// the engram write failed (hallucinated save).
|
||||
//
|
||||
// Fix: added `if !api_persisted(id) { return api_not_persisted(id) }` before
|
||||
// the return, consistent with all sibling handlers (remember, capture_knowledge,
|
||||
// evolve_knowledge, promote_knowledge, node_create).
|
||||
//
|
||||
// Tests:
|
||||
// 1. define_process returns ok==true and id resolves via engram_get_node_json.
|
||||
// 2. Missing content returns the standard error.
|
||||
// 3. Unnamed process uses default label and still persists.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import "../neuron-api.el"
|
||||
|
||||
let pass_count: Int = 0
|
||||
let fail_count: Int = 0
|
||||
|
||||
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
||||
if str_eq(got, expected) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + got)
|
||||
println(" expected: " + expected)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_not_eq(label: String, got: String, not_want: String) -> Void {
|
||||
if str_eq(got, not_want) {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label + " (got: " + got + ", should differ)")
|
||||
} else {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains(label: String, haystack: String, needle: String) -> Void {
|
||||
if str_contains(haystack, needle) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" missing '" + needle + "' in: " + haystack)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 1: define_process — happy path with read-back ────────────────────
|
||||
|
||||
println("")
|
||||
println("1. handle_api_define_process — write then verify id resolves")
|
||||
|
||||
let proc_body: String = "{\"content\":\"Test process: run step A, then step B, then step C.\",\"name\":\"test-process-guard\"}"
|
||||
let proc_result: String = handle_api_define_process(proc_body)
|
||||
|
||||
let proc_ok: String = json_get(proc_result, "ok")
|
||||
let proc_id: String = json_get(proc_result, "id")
|
||||
|
||||
assert_eq("define_process -> ok==true", proc_ok, "true")
|
||||
assert_not_eq("define_process -> id is non-empty", proc_id, "")
|
||||
|
||||
let node_json: String = engram_get_node_json(proc_id)
|
||||
let node_status: String = if str_eq(node_json, "") { "empty" } else {
|
||||
if str_eq(node_json, "null") { "null" } else { "ok" }
|
||||
}
|
||||
assert_eq("define_process -> node read-back resolves (not empty/null)", node_status, "ok")
|
||||
assert_contains("define_process -> node content contains process text", node_json, "Test process")
|
||||
|
||||
// ── Section 2: define_process — missing content returns error ────────────────
|
||||
|
||||
println("")
|
||||
println("2. handle_api_define_process — missing content returns error")
|
||||
|
||||
let no_content_body: String = "{\"name\":\"nameless\"}"
|
||||
let no_content_result: String = handle_api_define_process(no_content_body)
|
||||
let no_content_error: String = json_get(no_content_result, "error")
|
||||
assert_eq("missing content -> error is 'content is required'", no_content_error, "content is required")
|
||||
|
||||
// ── Section 3: define_process — unnamed process gets default label ────────────
|
||||
|
||||
println("")
|
||||
println("3. handle_api_define_process — unnamed process writes and read-back succeeds")
|
||||
|
||||
let unnamed_body: String = "{\"content\":\"Unnamed test process for coverage.\"}"
|
||||
let unnamed_result: String = handle_api_define_process(unnamed_body)
|
||||
let unnamed_ok: String = json_get(unnamed_result, "ok")
|
||||
let unnamed_id: String = json_get(unnamed_result, "id")
|
||||
assert_eq("unnamed process -> ok==true", unnamed_ok, "true")
|
||||
assert_not_eq("unnamed process -> id non-empty", unnamed_id, "")
|
||||
|
||||
let unnamed_node: String = engram_get_node_json(unnamed_id)
|
||||
let unnamed_status: String = if str_eq(unnamed_node, "") { "empty" } else {
|
||||
if str_eq(unnamed_node, "null") { "null" } else { "ok" }
|
||||
}
|
||||
assert_eq("unnamed process -> node read-back ok", unnamed_status, "ok")
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("api_define_process tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|
||||
@@ -0,0 +1,266 @@
|
||||
// ── tests/test_bell_safety.el ─────────────────────────────────────────────────
|
||||
//
|
||||
// Unit tests for the Hard Bell safety layer added in feat/connectors-soul.
|
||||
// Covers the public API exposed by safety.el:
|
||||
// - safety_detect_bell_level: 'none' / 'soft' / 'hard'
|
||||
// - safety_classify_hard_bell: 'self_harm' / 'abuse'
|
||||
// - safety_normalize: smart-quote -> ASCII apostrophe normalisation
|
||||
// - safety_augment_system: system prompt passthrough / augmentation
|
||||
// - handle_safety_contact_post: validation + read-back
|
||||
//
|
||||
// El test convention: mutable pass_count / fail_count globals, assert_* helpers,
|
||||
// println for each result. Mirrors the style in existing safety tests.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import "../safety.el"
|
||||
|
||||
let pass_count: Int = 0
|
||||
let fail_count: Int = 0
|
||||
|
||||
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
||||
if str_eq(got, expected) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + got)
|
||||
println(" expected: " + expected)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains(label: String, haystack: String, needle: String) -> Void {
|
||||
if str_contains(haystack, needle) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" missing '" + needle + "' in: " + haystack)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_not_contains(label: String, haystack: String, needle: String) -> Void {
|
||||
if str_contains(haystack, needle) {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" unexpected '" + needle + "' found in: " + haystack)
|
||||
} else {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 1: safety_detect_bell_level — 'none' ─────────────────────────────
|
||||
|
||||
println("")
|
||||
println("1. safety_detect_bell_level — neutral input -> 'none'")
|
||||
|
||||
let level_neutral: String = safety_detect_bell_level("What time is the meeting tomorrow?")
|
||||
assert_eq("neutral question -> none", level_neutral, "none")
|
||||
|
||||
let level_positive: String = safety_detect_bell_level("I love sunny days")
|
||||
assert_eq("positive statement -> none", level_positive, "none")
|
||||
|
||||
let level_empty: String = safety_detect_bell_level("")
|
||||
assert_eq("empty string -> none", level_empty, "none")
|
||||
|
||||
// ── Section 2: safety_detect_bell_level — single soft phrase stays 'none' ────
|
||||
|
||||
println("")
|
||||
println("2. safety_detect_bell_level — single soft phrase does not reach 'soft' (threshold=2)")
|
||||
|
||||
let level_one_soft: String = safety_detect_bell_level("I have been feeling stressed lately")
|
||||
assert_eq("single soft phrase alone -> none", level_one_soft, "none")
|
||||
|
||||
// ── Section 3: safety_detect_bell_level — 'soft' with two+ soft phrases ──────
|
||||
|
||||
println("")
|
||||
println("3. safety_detect_bell_level — two soft phrases -> 'soft'")
|
||||
|
||||
let level_two_soft: String = safety_detect_bell_level("I am feeling overwhelmed and really anxious about everything")
|
||||
assert_eq("two soft phrases -> soft", level_two_soft, "soft")
|
||||
|
||||
let level_multi_soft: String = safety_detect_bell_level("So depressed and hopeless, nothing matters anymore")
|
||||
assert_eq("multiple soft phrases -> soft", level_multi_soft, "soft")
|
||||
|
||||
// ── Section 4: safety_detect_bell_level — 'hard' (self_harm phrases) ─────────
|
||||
|
||||
println("")
|
||||
println("4. safety_detect_bell_level — self_harm phrases -> 'hard'")
|
||||
|
||||
let level_kill_myself: String = safety_detect_bell_level("I want to kill myself")
|
||||
assert_eq("'kill myself' -> hard", level_kill_myself, "hard")
|
||||
|
||||
let level_suicide: String = safety_detect_bell_level("I have been suicidal for weeks")
|
||||
assert_eq("'suicidal' -> hard", level_suicide, "hard")
|
||||
|
||||
let level_want_die: String = safety_detect_bell_level("I want to die, nothing is worth it")
|
||||
assert_eq("'want to die' -> hard", level_want_die, "hard")
|
||||
|
||||
let level_overdose: String = safety_detect_bell_level("I am thinking about an overdose")
|
||||
assert_eq("'overdose' -> hard", level_overdose, "hard")
|
||||
|
||||
let level_self_harm: String = safety_detect_bell_level("My history of self-harm is back")
|
||||
assert_eq("'self-harm' -> hard", level_self_harm, "hard")
|
||||
|
||||
let level_no_reason: String = safety_detect_bell_level("There is no reason to live")
|
||||
assert_eq("'no reason to live' -> hard", level_no_reason, "hard")
|
||||
|
||||
// ── Section 5: safety_detect_bell_level — 'hard' (abuse phrases) ─────────────
|
||||
|
||||
println("")
|
||||
println("5. safety_detect_bell_level — abuse phrases -> 'hard'")
|
||||
|
||||
let level_he_hit: String = safety_detect_bell_level("he hit me last night")
|
||||
assert_eq("'he hit me' -> hard", level_he_hit, "hard")
|
||||
|
||||
let level_dv: String = safety_detect_bell_level("I am experiencing domestic violence")
|
||||
assert_eq("'domestic violence' -> hard", level_dv, "hard")
|
||||
|
||||
let level_abused: String = safety_detect_bell_level("I am being abused")
|
||||
assert_eq("'being abused' -> hard", level_abused, "hard")
|
||||
|
||||
// ── Section 6: safety_detect_bell_level — 'hard' (general hard phrases) ──────
|
||||
|
||||
println("")
|
||||
println("6. safety_detect_bell_level — general hard phrases -> 'hard'")
|
||||
|
||||
let level_hurting_me: String = safety_detect_bell_level("someone is hurting me right now")
|
||||
assert_eq("'hurting me' -> hard", level_hurting_me, "hard")
|
||||
|
||||
let level_being_hurt: String = safety_detect_bell_level("I am being hurt and need help")
|
||||
assert_eq("'being hurt' -> hard", level_being_hurt, "hard")
|
||||
|
||||
// ── Section 7: safety_classify_hard_bell — abuse -> 'abuse' ──────────────────
|
||||
|
||||
println("")
|
||||
println("7. safety_classify_hard_bell — abuse phrases route to 'abuse'")
|
||||
|
||||
let class_he_hit: String = safety_classify_hard_bell("he hit me yesterday")
|
||||
assert_eq("'he hit me' classifies as abuse", class_he_hit, "abuse")
|
||||
|
||||
let class_dv: String = safety_classify_hard_bell("domestic violence in my home")
|
||||
assert_eq("'domestic violence' classifies as abuse", class_dv, "abuse")
|
||||
|
||||
let class_abused: String = safety_classify_hard_bell("I'm being abused by my partner")
|
||||
assert_eq("'being abused' classifies as abuse", class_abused, "abuse")
|
||||
|
||||
// ── Section 8: safety_classify_hard_bell — self_harm phrases ─────────────────
|
||||
|
||||
println("")
|
||||
println("8. safety_classify_hard_bell — self_harm phrases route to 'self_harm'")
|
||||
|
||||
let class_kill: String = safety_classify_hard_bell("I want to kill myself")
|
||||
assert_eq("'kill myself' classifies as self_harm", class_kill, "self_harm")
|
||||
|
||||
let class_suicide: String = safety_classify_hard_bell("I am suicidal")
|
||||
assert_eq("'suicidal' classifies as self_harm", class_suicide, "self_harm")
|
||||
|
||||
let class_overdose: String = safety_classify_hard_bell("took too many pills")
|
||||
assert_eq("'took too many' classifies as self_harm", class_overdose, "self_harm")
|
||||
|
||||
// ── Section 9: safety_classify_hard_bell — general -> 'self_harm' ────────────
|
||||
|
||||
println("")
|
||||
println("9. safety_classify_hard_bell — general hard phrases fall through to 'self_harm'")
|
||||
|
||||
let class_going_kill: String = safety_classify_hard_bell("going to kill everything around me")
|
||||
assert_eq("general hard phrase falls through to self_harm", class_going_kill, "self_harm")
|
||||
|
||||
// ── Section 10: safety_normalize — curly apostrophe normalisation ─────────────
|
||||
|
||||
println("")
|
||||
println("10. safety_normalize — curly apostrophe normalisation")
|
||||
|
||||
// U+2019 RIGHT SINGLE QUOTATION MARK (UTF-8: \xe2\x80\x99) must become ASCII '
|
||||
let smart_msg: String = "I can" + "\xe2\x80\x99" + "t go on anymore"
|
||||
let normalized: String = safety_normalize(smart_msg)
|
||||
assert_contains("smart-quote normalized to ASCII apostrophe", normalized, "can't go on")
|
||||
|
||||
// After normalisation, detect_bell_level must fire 'hard' on the smart-quote variant
|
||||
let level_smart: String = safety_detect_bell_level(smart_msg)
|
||||
assert_eq("smart-quote 'can't go on' -> hard (after normalize)", level_smart, "hard")
|
||||
|
||||
// ── Section 11: safety_augment_system — passthrough on neutral ───────────────
|
||||
|
||||
println("")
|
||||
println("11. safety_augment_system — neutral input returns system unchanged")
|
||||
|
||||
let base_sys: String = "You are a helpful assistant."
|
||||
let aug_neutral: String = safety_augment_system(base_sys, "What is the weather?")
|
||||
assert_eq("neutral message -> system unchanged", aug_neutral, base_sys)
|
||||
|
||||
// ── Section 12: safety_augment_system — soft bell injects directive ──────────
|
||||
|
||||
println("")
|
||||
println("12. safety_augment_system — soft bell injects soft directive")
|
||||
|
||||
let aug_soft: String = safety_augment_system(base_sys, "Feeling so overwhelmed and completely anxious")
|
||||
assert_contains("soft augment -> contains original system", aug_soft, base_sys)
|
||||
assert_contains("soft augment -> contains SUBSTRATE DIRECTIVE", aug_soft, "SUBSTRATE DIRECTIVE")
|
||||
assert_contains("soft augment -> contains soft care text", aug_soft, "genuine care")
|
||||
|
||||
// ── Section 13: safety_augment_system — hard self_harm injects 988 ───────────
|
||||
|
||||
println("")
|
||||
println("13. safety_augment_system — hard self_harm injects crisis resources with 988")
|
||||
|
||||
let aug_hard: String = safety_augment_system(base_sys, "I want to kill myself tonight")
|
||||
assert_contains("hard self_harm -> contains SUBSTRATE DIRECTIVE", aug_hard, "SUBSTRATE DIRECTIVE")
|
||||
assert_contains("hard self_harm -> includes 988 crisis line", aug_hard, "988")
|
||||
assert_not_contains("hard self_harm -> no DV hotline (wrong routing)", aug_hard, "1-800-799-7233")
|
||||
|
||||
// ── Section 14: safety_augment_system — hard abuse routes to abuse directive ──
|
||||
|
||||
println("")
|
||||
println("14. safety_augment_system — hard abuse injects abuse-specific directive")
|
||||
|
||||
let aug_abuse: String = safety_augment_system(base_sys, "he hit me and I am afraid of him")
|
||||
assert_contains("hard abuse -> DV hotline present", aug_abuse, "1-800-799-7233")
|
||||
assert_contains("hard abuse -> mentions not notifying contact", aug_abuse, "safety contact")
|
||||
|
||||
// ── Section 15: handle_safety_contact_post — validation ───────────────────────
|
||||
|
||||
println("")
|
||||
println("15. handle_safety_contact_post — non-crisis without name returns error")
|
||||
|
||||
let no_name_body: String = "{\"is_crisis_line\":false,\"contact_method\":\"phone\",\"contact_value\":\"555-1234\",\"relationship\":\"friend\"}"
|
||||
let no_name_result: String = handle_safety_contact_post(no_name_body)
|
||||
let no_name_ok: String = json_get(no_name_result, "ok")
|
||||
let no_name_err: String = json_get(no_name_result, "error")
|
||||
assert_eq("no name -> ok==false", no_name_ok, "false")
|
||||
assert_eq("no name -> error is 'name is required'", no_name_err, "name is required")
|
||||
|
||||
// ── Section 16: handle_safety_contact_post — write then read back ──────────────
|
||||
|
||||
println("")
|
||||
println("16. handle_safety_contact_post — write then read back verifies persistence")
|
||||
|
||||
let contact_body: String = "{\"is_crisis_line\":false,\"name\":\"Test Contact\",\"contact_method\":\"phone\",\"contact_value\":\"555-9876\",\"relationship\":\"sibling\"}"
|
||||
let write_result: String = handle_safety_contact_post(contact_body)
|
||||
let write_ok: String = json_get(write_result, "ok")
|
||||
assert_eq("contact write -> ok==true", write_ok, "true")
|
||||
assert_contains("contact write -> result has configured", write_result, "\"configured\"")
|
||||
assert_contains("contact write -> result has name", write_result, "Test Contact")
|
||||
|
||||
let read_result: String = handle_safety_contact_get()
|
||||
assert_eq("contact read-back -> configured==true", json_get(read_result, "configured"), "true")
|
||||
assert_contains("contact read-back -> name matches", read_result, "Test Contact")
|
||||
|
||||
// ── Section 17: handle_safety_contact_post — crisis line auto-fills ───────────
|
||||
|
||||
println("")
|
||||
println("17. handle_safety_contact_post — crisis line auto-fills name and value")
|
||||
|
||||
let crisis_body: String = "{\"is_crisis_line\":true}"
|
||||
let crisis_result: String = handle_safety_contact_post(crisis_body)
|
||||
let crisis_ok: String = json_get(crisis_result, "ok")
|
||||
assert_eq("crisis line write -> ok==true", crisis_ok, "true")
|
||||
assert_contains("crisis line -> name is Crisis Line", crisis_result, "Crisis Line")
|
||||
assert_contains("crisis line -> value is 988", crisis_result, "988")
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("bell_safety tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|
||||
@@ -0,0 +1,124 @@
|
||||
// ── tests/test_soul_guard.el ──────────────────────────────────────────────────
|
||||
//
|
||||
// Logic tests for the genesis guard in soul.el (feat/connectors-soul).
|
||||
//
|
||||
// The guard is top-level imperative boot code. This file tests the predicate
|
||||
// logic as pure functions to verify the conditions exhaustively:
|
||||
//
|
||||
// safe_to_seed = !using_http_engram &&
|
||||
// !(guard_disk_len > 200000 && loaded_nodes * 16000 < guard_disk_len)
|
||||
//
|
||||
// Scenarios:
|
||||
// - Boundary: 199,999 bytes + sparse -> safe_to_seed == true
|
||||
// - Boundary: 200,001 bytes + sparse -> safe_to_seed == false
|
||||
// - Ratio: 47MB + 63 nodes -> false (the 2026-06-14 clobber scenario)
|
||||
// - HTTP mode -> false unconditionally
|
||||
// - Multiplication form vs old division form near 250KB boundary
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pass_count: Int = 0
|
||||
let fail_count: Int = 0
|
||||
|
||||
fn assert_eq_bool(label: String, got: Bool, expected: Bool) -> Void {
|
||||
let got_s: String = if got { "true" } else { "false" }
|
||||
let exp_s: String = if expected { "true" } else { "false" }
|
||||
if str_eq(got_s, exp_s) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + got_s)
|
||||
println(" expected: " + exp_s)
|
||||
}
|
||||
}
|
||||
|
||||
// guard_predicate — mirrors the safe_to_seed expression in soul.el exactly.
|
||||
fn guard_predicate(using_http: Bool, disk_len: Int, loaded_nodes: Int) -> Bool {
|
||||
if using_http { return false }
|
||||
let ratio_block: Bool = disk_len > 200000 && loaded_nodes * 16000 < disk_len
|
||||
return !ratio_block
|
||||
}
|
||||
|
||||
// ── Section 1: 200KB boundary ─────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("1. guard boundary — 199,999 bytes + sparse load -> safe_to_seed true")
|
||||
|
||||
let safe_below: Bool = guard_predicate(false, 199999, 1)
|
||||
assert_eq_bool("199,999 bytes + 1 node -> safe", safe_below, true)
|
||||
|
||||
let safe_below_zero: Bool = guard_predicate(false, 199999, 0)
|
||||
assert_eq_bool("199,999 bytes + 0 nodes -> safe (below 200KB threshold)", safe_below_zero, true)
|
||||
|
||||
println("")
|
||||
println("2. guard boundary — 200,001 bytes + sparse load -> safe_to_seed false")
|
||||
|
||||
let unsafe_above: Bool = guard_predicate(false, 200001, 1)
|
||||
assert_eq_bool("200,001 bytes + 1 node -> unsafe", unsafe_above, false)
|
||||
|
||||
let unsafe_zero: Bool = guard_predicate(false, 200001, 0)
|
||||
assert_eq_bool("200,001 bytes + 0 nodes -> unsafe", unsafe_zero, false)
|
||||
|
||||
// ── Section 2: ratio guard — 47MB + 63 nodes ─────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("3. guard ratio — 47MB + 63 nodes (the 2026-06-14 clobber scenario)")
|
||||
|
||||
let clobber_blocked: Bool = guard_predicate(false, 47000000, 63)
|
||||
assert_eq_bool("47MB + 63 nodes -> unsafe (clobber blocked)", clobber_blocked, false)
|
||||
|
||||
// 47MB / 16000 = 2937.5 -> need >= 2938 nodes for safe
|
||||
let clobber_safe: Bool = guard_predicate(false, 47000000, 2938)
|
||||
assert_eq_bool("47MB + 2938 nodes -> safe (load correct)", clobber_safe, true)
|
||||
|
||||
let boundary_blocked: Bool = guard_predicate(false, 47000000, 2937)
|
||||
assert_eq_bool("47MB + 2937 nodes -> unsafe (just below ratio)", boundary_blocked, false)
|
||||
|
||||
// ── Section 3: HTTP-engram mode — always false ────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("4. guard HTTP mode — always false regardless of disk/node counts")
|
||||
|
||||
let http_zero: Bool = guard_predicate(true, 0, 0)
|
||||
assert_eq_bool("HTTP mode + 0/0 -> unsafe", http_zero, false)
|
||||
|
||||
let http_small: Bool = guard_predicate(true, 1000, 100)
|
||||
assert_eq_bool("HTTP mode + small snapshot -> unsafe", http_small, false)
|
||||
|
||||
let http_large: Bool = guard_predicate(true, 47000000, 2938)
|
||||
assert_eq_bool("HTTP mode + large/fully-loaded -> unsafe", http_large, false)
|
||||
|
||||
// ── Section 4: normal local mode — small/fresh snapshots ─────────────────────
|
||||
|
||||
println("")
|
||||
println("5. guard normal local mode — small/fresh snapshots")
|
||||
|
||||
let fresh_genesis: Bool = guard_predicate(false, 0, 0)
|
||||
assert_eq_bool("fresh genesis (0 bytes, 0 nodes) -> safe", fresh_genesis, true)
|
||||
|
||||
let small_snapshot: Bool = guard_predicate(false, 50000, 5)
|
||||
assert_eq_bool("50KB + 5 nodes -> safe (below 200KB threshold)", small_snapshot, true)
|
||||
|
||||
// ── Section 5: multiplication vs division — 250KB boundary ───────────────────
|
||||
|
||||
println("")
|
||||
println("6. guard multiplication form — avoids floor-division truncation at 250KB")
|
||||
|
||||
// OLD (division): 250000 / 16000 = 15 (floors 15.625). 15 < 15 is false -> wrongly safe.
|
||||
// NEW (multiplication): 15 * 16000 = 240000 < 250000 -> correctly unsafe.
|
||||
let div_boundary: Bool = guard_predicate(false, 250000, 15)
|
||||
assert_eq_bool("250,000 bytes + 15 nodes -> unsafe (multiplication form)", div_boundary, false)
|
||||
|
||||
// With 16 nodes: 16 * 16000 = 256000 > 250000 -> safe.
|
||||
let div_just_enough: Bool = guard_predicate(false, 250000, 16)
|
||||
assert_eq_bool("250,000 bytes + 16 nodes -> safe", div_just_enough, true)
|
||||
|
||||
// Exact equality: disk_len == node_count * 16000 -> not sparse -> safe.
|
||||
let exact_match: Bool = guard_predicate(false, 32000, 2)
|
||||
assert_eq_bool("exact ratio (32000 bytes, 2 nodes: 2*16000=32000) -> safe", exact_match, true)
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("soul_guard tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|
||||
Reference in New Issue
Block a user