3bb88330da
A homicide/assault threat (going to kill, going to hurt, etc.) had no bucket in safety_classify_hard_bell and fell through to the self_harm default, showing the user the 988 suicide line and (via the desktop gate) their safety contact. That framing is wrong and potentially dangerous for someone voicing intent to harm another person. Add a distinct Track B (safety_threat_to_others_phrases + a threat_other classification and a safety_hard_directive branch) that refuses to assist, de-escalates, and directs to 911 for a credible imminent threat, and that never surfaces 988 or involves the safety contact. Track A (abuse / self_harm) is checked first and unchanged, so victim and self-directed phrasings still route correctly. Source-only change: requires a soul rebuild + dist/soul.c regen to ship.
306 lines
15 KiB
EmacsLisp
306 lines
15 KiB
EmacsLisp
// ── 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 — Track B threat-to-others ──────────
|
|
//
|
|
// LIVE SAFETY FIX (approved by Will + Tim, 2026-07-14): a threat toward ANOTHER
|
|
// person ("going to kill/hurt <someone>") must classify as 'threat_other' and be
|
|
// routed to a refusal + 911 — NOT to the 'self_harm'/988 path. This section used
|
|
// to assert the old (buggy) fall-through to 'self_harm'; it now pins the fix.
|
|
|
|
println("")
|
|
println("9. safety_classify_hard_bell — threat-to-others routes to 'threat_other' (not self_harm)")
|
|
|
|
let class_going_kill: String = safety_classify_hard_bell("I am going to kill him tonight")
|
|
assert_eq("'going to kill him' classifies as threat_other", class_going_kill, "threat_other")
|
|
|
|
let class_going_hurt: String = safety_classify_hard_bell("I'm going to hurt them so badly")
|
|
assert_eq("'going to hurt them' classifies as threat_other", class_going_hurt, "threat_other")
|
|
|
|
let class_shoot: String = safety_classify_hard_bell("I'm going to shoot up the place")
|
|
assert_eq("'going to shoot' classifies as threat_other", class_shoot, "threat_other")
|
|
|
|
// Track A must still win over Track B on victim / self-directed phrasings:
|
|
let class_kill_me: String = safety_classify_hard_bell("he is going to kill me")
|
|
assert_eq("'going to kill me' stays abuse (Track A precedence)", class_kill_me, "abuse")
|
|
|
|
let class_kill_self: String = safety_classify_hard_bell("I am going to kill myself")
|
|
assert_eq("'kill myself' stays self_harm (Track A precedence)", class_kill_self, "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 14b: safety_augment_system — Track B threat-to-others routing ─────
|
|
//
|
|
// LIVE SAFETY FIX (approved by Will + Tim, 2026-07-14): a homicide/assault threat
|
|
// must be routed to a refusal + 911, and must NOT surface the 988 suicide line
|
|
// or pull in the safety contact.
|
|
|
|
println("")
|
|
println("14b. safety_augment_system — threat-to-others injects refusal + 911, never 988/contact")
|
|
|
|
let aug_threat: String = safety_augment_system(base_sys, "I am going to kill him tonight")
|
|
assert_contains("threat_other -> contains SUBSTRATE DIRECTIVE", aug_threat, "SUBSTRATE DIRECTIVE")
|
|
assert_contains("threat_other -> directs to 911", aug_threat, "911")
|
|
assert_contains("threat_other -> refuses to help harm another", aug_threat, "harming another person")
|
|
assert_not_contains("threat_other -> NO 988 suicide line", aug_threat, "988")
|
|
assert_not_contains("threat_other -> NO safety-contact involvement", aug_threat, "safety contact")
|
|
assert_not_contains("threat_other -> NO 'are you safe right now' victim frame", aug_threat, "are you safe right now")
|
|
|
|
// Detection must still fire hard on a weapon phrase not present in general_hard:
|
|
let level_shoot: String = safety_detect_bell_level("I'm going to shoot up the office")
|
|
assert_eq("'going to shoot' -> hard", level_shoot, "hard")
|
|
|
|
// ── 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")
|