Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions calibration/moral_overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/moral_tension_impl_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
21 changes: 15 additions & 6 deletions engine/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions tests/test_moral_guilt_weight.py
Original file line number Diff line number Diff line change
@@ -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
Loading