diff --git a/calibration/moral_overlay.yaml b/calibration/moral_overlay.yaml index 1e2b157..d29de28 100644 --- a/calibration/moral_overlay.yaml +++ b/calibration/moral_overlay.yaml @@ -31,6 +31,8 @@ ledger_params: secret_cue_salience: 0.30 # a `secret_cued` reminder raises salience (gated to 0 for an inactive secret). PLACEHOLDER. inactive_unresolved_floor: 0.10 # a secret is ACTIVE while hidden_from is non-empty OR unresolvedness >= this. PLACEHOLDER. secret_salience_to_stress: 0.10 # an ACTIVE secret's salience weighs as stress each tick; inactive -> 0 (relief). PLACEHOLDER. + secret_weight_to_guilt: 0.10 # A3 minor/serious split: an ACTIVE secret RE-INJECTS guilt by moral_weight x salience + # each tick -> a SERIOUS unconfessed wrong lingers (the 72h feel), a MINOR one fades. PLACEHOLDER. gains: guilt: diff --git a/docs/moral_tension_impl_spec.md b/docs/moral_tension_impl_spec.md index e0f63b2..0eb01bf 100644 --- a/docs/moral_tension_impl_spec.md +++ b/docs/moral_tension_impl_spec.md @@ -695,6 +695,11 @@ byte-identical in legacy mode; Gate B equivalent when moral-enabled at zero gain secret's salience weighs as `stress`. INACTIVATION (spec §3.4): active iff `hidden_from` non-empty OR `unresolvedness ≥ inactive_unresolved_floor` — once inactive, salience is neither raised nor weighs. Opt-in (ledger empty / no ledger_params → legacy byte-identical). + **A3 — minor/serious guilt split (`tests/test_moral_guilt_weight.py`, spec §11):** rather than two guilt + states, an ACTIVE secret RE-INJECTS guilt each tick by `secret_weight_to_guilt · moral_weight · salience` + (in `_update_secrets`). A SERIOUS unconfessed wrong (high `moral_weight`) keeps guilt alive against decay + → it lingers (the judge-validated 72h feel) while the base half-life stays minor-appropriate (18h); a + minor wrong fades; confession/exposure inactivates the secret → the drip stops → relief. Opt-in. - **M-J.4.4 calibration grid + scoped corpus.** Two parts: - *Deterministic gates* — ✅ IMPLEMENTED (`tests/test_moral_gates.py`): **Gate A** (legacy byte-identical, via `test_tick_golden.py`); **Gate B** zero-gain behavioral equivalence (`eval.moral.zero_gain_overrides`: diff --git a/engine/simulation.py b/engine/simulation.py index 1372220..98cb018 100644 --- a/engine/simulation.py +++ b/engine/simulation.py @@ -148,6 +148,9 @@ def _update_secrets(runtime: PersonaRuntime, events: list[RawEvent], t: int) -> cue_gain = float(lp.get("secret_cue_salience", 0.0)) floor = float(lp.get("inactive_unresolved_floor", 0.0)) stress_gain = float(lp.get("secret_salience_to_stress", 0.0)) + guilt_gain = float( + lp.get("secret_weight_to_guilt", 0.0) + ) # A3: moral_weight sustains guilt gs = runtime.global_state ids = sorted( led.secrets @@ -171,13 +174,19 @@ def _update_secrets(runtime: PersonaRuntime, events: list[RawEvent], t: int) -> if w not in s.known_by: s.known_by.append(w) s.hidden_from = [] # public knowledge -> no longer ACTIVELY hiding -> inactivation - if ( - stress_gain and "stress" in gs - ): # an ACTIVE secret weighs on the persona; inactive -> no weight - for sid in ids: - s = led.secrets[sid] - if _secret_active(s, floor): + for sid in ids: # an ACTIVE secret weighs (stress) AND, by its moral_weight, keeps GUILT alive (A3: + s = led.secrets[ + sid + ] # the minor/serious split -- a serious unconfessed wrong lingers, a minor fades; + if _secret_active( + s, floor + ): # confession/exposure inactivates it -> both drips stop -> relief. + if stress_gain and "stress" in gs: gs["stress"] = clamp01(gs["stress"] + stress_gain * s.salience) + if guilt_gain and "guilt" in gs: + gs["guilt"] = clamp01( + gs["guilt"] + guilt_gain * s.moral_weight * s.salience + ) def _commit(runtime: PersonaRuntime, delta: StateDelta) -> None: diff --git a/tests/test_moral_guilt_weight.py b/tests/test_moral_guilt_weight.py new file mode 100644 index 0000000..15c9782 --- /dev/null +++ b/tests/test_moral_guilt_weight.py @@ -0,0 +1,113 @@ +"""M-J.4.4 follow-up (A3) -- minor vs serious guilt via the Secret's moral_weight. + +The engine has ONE `guilt` state with ONE half-life, but the spec (section 11) wants a minor(18h)/serious(72h) +split and the blind multi-day judge confirmed it (18h reads "too tidy for a serious wrong"). Rather than two +guilt states, an ACTIVE secret RE-INJECTS guilt each tick proportional to its authored `moral_weight` x +`salience`: a SERIOUS unconfessed secret (high moral_weight) keeps the guilt alive against decay -> it lingers +(the 72h feel); a MINOR one barely does -> it fades (the 18h feel). Confession/exposure inactivates the secret +-> the drip stops -> relief. Magnitudes are calibration placeholders; only the ORDERING is asserted. +""" + +from __future__ import annotations + +from pathlib import Path + +from engine.schema import RawEvent, Scenario +from engine.simulation import run_scenario +from engine.yaml_io import load_persona +from eval.moral import moral_overrides +from eval.observe import trajectory + +ROOT = Path(__file__).resolve().parents[1] +DEFAULTS = ROOT / "calibration" / "defaults.yaml" +HALGRIM = ROOT / "data" / "personas" / "halgrim.yaml" +MORAL = {"guilt_proneness": 0.6} +CALM = { + "fatigue": 0.05, + "boredom": 0.05, + "stress": 0.05, + "frustration": 0.05, + "anger": 0.05, +} + + +def _run(moral_weight: float, n: int = 14, expose_at: int | None = None): + secret = [ + { + "id": "s_theft", + "owner_id": "self", + "topic": "theft", + "category": "crime", + "hidden_from": ["guard"], + "moral_weight": moral_weight, + } + ] + events = [ + RawEvent(type="secret_cued", t=t, topic="theft", intensity=1.0) + for t in range(n) + ] + if expose_at is not None: + events.append( + RawEvent( + type="secret_exposed", + t=expose_at, + topic="theft", + intensity=1.0, + context={"witnesses": ["guard", "warden"]}, + ) + ) + cfg = load_persona(HALGRIM, DEFAULTS, param_overrides=moral_overrides(MORAL)) + sc = Scenario( + id="gw", + persona="halgrim", + initial_overrides={"global_state": dict(CALM), "secrets": secret}, + events=tuple(events), + ) + return run_scenario(cfg, sc, n_ticks=n) + + +def test_serious_secret_sustains_guilt_minor_fades(): + """The minor/serious split: a SERIOUS secret (high moral_weight) keeps guilt weighing; a MINOR one barely.""" + g_serious = trajectory(_run(0.9)[1], "guilt") + g_minor = trajectory(_run(0.1)[1], "guilt") + assert ( + g_serious[-1] > g_minor[-1] + ) # the weight of the wrong decides how heavily it sits + assert g_serious[-1] > 0.1 # a serious unconfessed wrong keeps weighing + assert all(0.0 <= v <= 1.0 for v in g_serious) # bounded + + +def test_resolved_secret_stops_the_guilt_drip(): + """Exposure inactivates the secret -> the moral_weight guilt drip stops -> guilt no longer sustained.""" + g_hidden = trajectory(_run(0.9)[1], "guilt") # stays hidden -> sustained + g_exposed = trajectory( + _run(0.9, expose_at=4)[1], "guilt" + ) # exposed at t4 -> drip stops + assert g_exposed[-1] < g_hidden[-1] # once it's out, the weight stops pressing + + +def test_guilt_weight_drip_is_opt_in(): + """A legacy persona (no overlay -> no ledger_params) gets no guilt drip even with a seeded secret.""" + secret = [ + { + "id": "s", + "owner_id": "self", + "topic": "x", + "category": "crime", + "hidden_from": ["g"], + "moral_weight": 0.9, + } + ] + events = [ + RawEvent(type="secret_cued", t=t, topic="x", intensity=1.0) for t in range(10) + ] + cfg = load_persona(HALGRIM, DEFAULTS) + sc = Scenario( + id="gw", + persona="halgrim", + initial_overrides={"global_state": dict(CALM), "secrets": secret}, + events=tuple(events), + ) + rt, _ = run_scenario(cfg, sc, n_ticks=10) + assert "secret_weight_to_guilt" not in cfg.ledger_params + assert rt.moral_ledger.secrets["s"].salience == 0.0 # inert without the overlay