Skip to content

M-J: moral tension / secrecy dynamics subsystem (opt-in overlay) - #12

Merged
RobertMalczyk merged 29 commits into
mainfrom
feature/m-j-moral-tension
Jun 25, 2026
Merged

RobertMalczyk merged 29 commits into
mainfrom
feature/m-j-moral-tension

Conversation

@RobertMalczyk

Copy link
Copy Markdown
Owner

M-J — moral tension / secrecy dynamics

A deterministic, config-backed opt-in overlay for guilt, lies, secrecy, accusation, and suspicion, where visible behavior emerges from internal state dynamics coupled into the existing states. No LLM in the loop, no RNG. Every magnitude is a calibration placeholder (topology fixed; constants are M-J.4.4 calibration-owned).

Core invariant: legacy is byte-identical

Every moral state/relation-dim/ledger field is opt-in (gated on the overlay supplying its half-life / on secrets: authoring). A legacy persona carries exactly the canonical set → all golden traces stay byte-identical (tests/test_tick_golden.py). Trace fields for moral data are emitted sparsely (only when non-empty).

Slices (each: spec + synced diagram + litmus tests + Jury-stable)

  • M-J.0 guilt core (guilt, exposure_anxiety; confess/remain_silent)
  • M-J.1 lie loop (cognitive_load_from_lies; lie/deflect, self-limiting)
  • M-J.2 repair/rumination + confide safe-vs-gossip split + apologize reparation
  • M-J.3 accusation (perceived_injustice/avoidance_drive, opt-in suspicion relation dim, grievance switch injustice→anger(+)/→guilt(−), blame_other/avoid, suspicion_cue) + M-J.3.3 witness fan-out & false-accusation discovery (built on M-MEM)
  • M-J.4 the MoralLedger (Secret + LieRecord): .0 plumbing → .1 lie lifecycle/consistency_debt → .2 detection/betrayal + detected_risk + felt consequence → .3 secret lifecycle (salience/known_by/hidden_from/inactivation)
  • M-J.4.4 (deterministic) validation gates: Gate A byte-identical, Gate B zero-gain equivalence, Gate C §9.2 invariants

Architecture discipline

  • Ledger mutated only in post_effects (the sanctioned second mutation site), read-only in update, deep-copied into the frozen Snapshot.
  • Couplings can't be trait-modulated → trait dependence routes through gain-modulators on event-gains; ledger→state via gated post_effects writes.
  • Post-review audit (engine/architecture/test-coverage) + corrections applied: confession discharges cognitive_load (spec §3.5), ledger writes run on every tick (ENGAGE-branch fix), deterministic sorted iteration.

Tests

Full suite 396 passed, 4 skipped; goldens byte-identical; ruff format + check clean; CI-gated. ~80 moral-specific tests across 14 files.

Remaining (out of this PR)

M-J.4.4 calibration — the LLM-judge stage (grid over half-lives/gains + judged 700+700 corpus). Plan: docs/moral_calibration_plan.md. Placeholders stay until that runs.

🤖 Generated with Claude Code

Robakk84 and others added 29 commits June 23, 2026 11:07
…outcome)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent mapper) to deferred roadmap §13.1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fold into 2800, not +1400) + risk table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ations/coupling/trace/tests/build-order)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ounded 1400+1400 corpus, empathy trait, secret/lie lifecycle, rumor_by, trace_v2 migration, engine/inn boundary

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…k register + phased slices

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ianism (strategic, guilt-suppressed); literature-checked psychopathy distinction + machiavellian calibration target (guilt-no/anxiety-yes/cold-anger); stage traits per-slice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion + control_interpretation.md + state-response diagnostic

Moral-tension wording (docs only):
- new impl_spec 2bis: events = finite single-tick deposits (NOT Dirac); fast rise=high gain /
  slow fall=long half-life; x[n+1] recurrence + one-time vs repeated(step) + x_inf; distinguish
  exposure_anxiety (state) vs source_threat/expected_punishment(target) (relational modifier) vs
  suspicion[target] (relation dim) vs moral_tension (emergent normative conflict, NOT fear);
  half-life policy + no ultra-fast startle state; event->state example table
- rename ambiguous fear(target)->expected_punishment(target); shame/fear->shame/exposure_anxiety;
  relation-modifier fear->source_threat (review + test_plan)

Control interpretation + diagnostics:
- docs/control_interpretation.md: bounded discrete first-order system, transfer fn
  G(z)=b z^-1/(1-a z^-1), impulse vs step, x_inf=drift/(1-decay), why no true Dirac
- docs/diagrams/update.md: units note (decay is the only time-scaled term; drift/coupling/per_tick
  are per-tick, NOT auto per_second*dt; time_scale caveat)
- engine/stability.py: decay_from/impulse_response/steady_state_drift/exceeds_clamp/state_response/
  state_response_report + anger_stress_loop_report (jury margin, dominant pole/eigenvalue, tail
  half-time, knife-edge/near-unit-pole warnings)
- eval/state_response_report.py: deterministic LLM-free markdown generator + committed artifact
- tests/test_state_response.py: 12 tests (doc contracts, decay formula, x_inf, clamp flag,
  anger-stress jury margin + dominant pole, unstable-flag)

No runtime behavior changed (offline analysis only; goldens untouched). Suite 297 passed +4 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_tension observable formula

- docs/diagrams/moral_tension.md: REQUIRED pre-code artifact (CLAUDE.md diagram directive). Both forms;
  full moral topology drawn (topology-now), M-J.0 guilt-core slice highlighted; events=finite deposits
  (not Dirac); two positive loops flagged for Jury/spectral check; derived moral_tension node; action
  potentials as selector biases (no if-then); ledger read-only-in-update; one-tick latency.
- impl_spec 2bis.2: computable derived moral_tension = clamp01(g_mt·CONFLICT(P_confess,P_conceal)
  + g_inj·P_defend·guilt), CONFLICT=2ab/(a+b+eps) (high only when opposing pulls BOTH high; not
  fear/guilt/stress). Read-only observable, weights are calibration placeholders.
- corpus labels reconciled: M-J-ONE-DAY-700 / M-J-MULTI-DAY-700 = short labels for the OVERLAY names.

DOCS ONLY. No engine code, no chosen numbers (topology-now/constants-from-calibration). M-J.0 code is
the next slice and starts from this diagram + a spec delta, pending the remaining 8 sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ety + 4 traits + confess/remain_silent), byte-identical

Appends the M-J moral vocabulary to the frozen schema and makes it OPT-IN so every legacy persona
loads/traces byte-identically (Gate A). 'Enabled' = the moral overlay supplies the moral half_lives;
absent -> moral states/actions omitted everywhere.

- schema.py: GLOBAL_STATES += guilt,exposure_anxiety; TRAIT_NAMES += empathy,guilt_proneness,
  shame_sensitivity,honesty_humility; POTENTIAL_NAMES += confess,remain_silent (all appended last so
  canonical tie-break/order is unchanged); MORAL_STATES/MORAL_TRAITS/MORAL_POTENTIALS subsets.
- 7 conditional-presence guards (all neutral for legacy personas):
  * yaml_io: moral states not required to have a half_life; initial_global_state includes a moral state
    ONLY if its half_life is present; moral traits default 0.0 when omitted.
  * runtime: copy config.initial_global_state instead of re-iterating GLOBAL_STATES (no re-injection).
  * update: skip a GLOBAL_STATES entry absent from the frozen snapshot.
  * potentials: emit a moral action ONLY when the persona configures weights for it (else omitted).
  * metrics + stability.state_response_report: skip states absent for the persona.
- fixed test_state_response_report_covers_all_global_states to expect the persona's PRESENT states.

No moral behavior yet (no overlay/cues) — pure plumbing. Full suite 297 passed +4 skipped; all 10
golden traces byte-identical (proven), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…overlay, byte-identical

The first moral vertical slice, wired and litmus-proven. Guilt-prone persona confesses, low-guilt one
stays silent on the SAME scenario — contrast from guilt_proneness alone (topology, not magic numbers).

- mapper: wrongdoing (SELF -> guilt) + probe (RELATIONAL, sourced -> exposure_anxiety + tiny interrogation
  frustration that opens the reactive reply window). Inert unless overlay gains are present.
- calibration/moral_overlay.yaml: guilt/exposure_anxiety half-lives, gains (+ guilt_proneness modulator),
  feed-forward couplings guilt->stress/anger(-) & exposure_anxiety->stress, confess/remain_silent
  potential terms+weights+react thresholds, confess post_effects relief. All placeholders.
- eval/moral.py: moral_overrides() loads the overlay as deep-merged param_overrides (burst-overlay pattern).
- data/scenarios/moral_probe.yaml: stylized guilt-core micro-scenario (wrongdoing t0-2, sourced probe t3+;
  one event/tick = the deferred M-MEM).
- tests/test_moral_guilt_core.py (7): opt-in/inertness, finite-deposit+slow-decay guilt (not Dirac),
  exposure from probe, the confess-vs-silent litmus, confess relief, anger<->stress still Jury-stable.
- spec build order + diagram marked M-J.0 IMPLEMENTED (DoD: diagram synced with code).

Full suite 304 passed +4 skipped; all goldens byte-identical (moral layer absent by default); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… self-limiting noose

The second moral vertical slice (internal cognitive-load loop). On the SAME probing scenario a habitual
liar (low honesty_humility, low guilt_proneness) LIES while a guilt-prone honest persona CONFESSES;
lying deposits a finite, bounded cognitive load that couples to stress/fatigue and self-limits.

- schema: GLOBAL_STATES += cognitive_load_from_lies; POTENTIAL_NAMES += lie,deflect; extend MORAL_STATES
  + MORAL_POTENTIALS. Opt-in like M-J.0 (byte-identical preserved).
- moral_overlay.yaml: cognitive_load half-life; lie potential = exposure_anxiety x (1-honesty_humility),
  self-limited by -cognitive_load and -guilt; deflect = cognitive_load + exposure; lie/deflect post_effects
  (finite load/guilt/exposure deposits); couplings cognitive_load->stress(+)/fatigue(+); react thresholds.
- tests/test_moral_lie_loop.py (6): opt-in cognitive_load; liar lies-not-confesses; honest confesses-not-
  lies; lie-vs-confess contrast; bounded load coupled to stress (no runaway); honest carries no load.
- isolated the M-J.0 guilt litmus from the lie axis (hold honesty_humility high there).
- spec build order + diagram marked M-J.1 IMPLEMENTED; LieRecord ledger + cross-agent detection DEFERRED.

Full suite 310 passed +4 skipped; goldens byte-identical (moral layer opt-in); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rumination replay loop

The third moral vertical slice. On the SAME scenario an EMPATHIC guilt-prone persona APOLOGIZES (makes
amends) while a DETACHED (low-empathy) one merely CONFESSES; rumination builds from guilt as a bounded
integrator coupled to stress/fatigue (the moral burden replayed).

- schema: GLOBAL_STATES += repair_drive, rumination; POTENTIAL_NAMES += apologize; extend MORAL_STATES +
  MORAL_POTENTIALS. Opt-in (byte-identical preserved).
- moral_overlay.yaml: repair_drive(24h)/rumination(12h) half-lives; couplings guilt->repair_drive(+),
  guilt->rumination(+), rumination->stress(+)/fatigue(+); apologize = guilt x empathy + repair_drive
  (the empathic-repair impulse, beats bare confess for the empathic); apologize post_effects relieve
  guilt/repair_drive/exposure; react thresholds.
- tests/test_moral_repair.py (6): opt-in states; empathic-apologizes-vs-detached-confesses litmus;
  apologize relief; rumination builds+bounded; repair_drive builds; anger<->stress still Jury-stable.
- spec build order + diagram marked M-J.2 IMPLEMENTED; confide safe-vs-gossip split + reparation DEFERRED.

Full suite 316 passed +4 skipped; goldens byte-identical (moral layer opt-in); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gize reparation

Closes the two pieces M-J.2 (b4270a7) shipped as topology-only.

- CONFIDE SAFE-VS-GOSSIP SPLIT. New trait gossip_tendency + action confide, driven (spec 5.5 shape) by
  guilt x trust[confidant] x (1-gossip_tendency): a DISCREET persona unburdens the replayed conflict to a
  TRUSTED ear present this tick -> rumination relieved (post_effects); a GOSSIP-PRONE persona collapses the
  (1-gossip) gate (can't confide safely -> the secret keeps weighing) AND its gossip_tendency modulates the
  probe->exposure_anxiety gain UP (the LEAK -- event-gains are the only trait-modulated state input the
  engine has; couplings are not). Gated on trust -> inert in the legacy moral_probe runs (interrogator
  trust 0), so the existing M-J.0/.1/.2 litmus is untouched. New benign mapper channel confide_opportunity
  (RELATIONAL, no deposit) marks who is present; the reply fires while the interrogation reactive_window
  is still open.
- REPARATION. apologize now books a RELATIONAL source_relations amends (trust up, resentment down) on the
  wronged target -- the moral loop closes through the RELATIONSHIP, not only inside the persona.
- tests/test_moral_confide.py (6): opt-in plumbing; discreet-confides-relieves-rumination; gossip-can't-
  confide + stays-more-exposed; inert-without-trusted-confidant; apologize-reparation; anger<->stress Jury
  margin unchanged. New data/scenarios/moral_confide.yaml.
- spec build order + diagram marked M-J.2 completions IMPLEMENTED.

Full suite 322 passed +4 skipped; goldens byte-identical (moral layer opt-in); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…sation vocab, byte-identical

Topology vocab for the accusation/scapegoat slice, all opt-in so legacy goldens stay byte-identical.

- schema: GLOBAL_STATES += avoidance_drive, perceived_injustice (-> MORAL_STATES). RELATION_DIMS +=
  suspicion, gated by a new MORAL_RELATION_DIMS frozenset (the relation-dim analogue of MORAL_STATES).
  TRAIT_NAMES += injustice_sensitivity, conflict_avoidance (-> MORAL_TRAITS). POTENTIAL_NAMES += avoid,
  blame_other (-> MORAL_POTENTIALS). All unwired (no overlay weights yet).
- suspicion opt-in gates (mirror the MORAL_STATES pattern): yaml_io exempts MORAL_RELATION_DIMS from the
  required-half_life check and builds suspicion into an initial relation row ONLY when its half_life is
  present; runtime gates override-row seeding on config.decay membership; update skips a relation dim absent
  from decay (legacy persona -> no KeyError, never decayed, never traced). debug.py already omits absent
  dims (`if d in rel[src]`) -> legacy relation rows carry exactly trust/respect/resentment.
- moral_overlay.yaml: half_lives for avoidance_drive (60min), perceived_injustice (24h), suspicion (48h)
  placeholders -- the overlay's presence is the enable signal; legacy personas never load it.

Full suite 322 passed +4 skipped; goldens byte-identical (golden traces unchanged); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…d (accused+accuser)

The fourth moral vertical slice (accused+accuser only; multi-agent witness fan-out stays blocked on M-MEM).
On the SAME accusation a SENSITIVE persona reads it as UNFAIR and BLAMES the accuser back, while an AVOIDANT
one DODGES -- emergent from the grievance topology, not scripted.

- mapper: `accusation` cue (RELATIONAL, source=accuser; the small frustration deposit opens the reply window).
- moral_overlay.yaml: accusation gains -> perceived_injustice (x injustice_sensitivity, steep modulator),
  avoidance_drive (x conflict_avoidance), stress, resentment[accuser]; couplings perceived_injustice->anger(+)
  and the GRIEVANCE SWITCH perceived_injustice->guilt(-) ("I was justified"); actions blame_other (grievance-
  driven; post_effects cast suspicion[accuser] + vent perceived_injustice) and avoid (avoidance-driven;
  discharges avoidance_drive/exposure). react/interrupt thresholds.
- tests/test_moral_accusation.py (7): opt-in states + suspicion dim; sensitive-blames-vs-avoidant-avoids
  litmus; perceived_injustice builds + bounded; grievance switch suppresses guilt; blame casts suspicion+
  resentment on the accuser; avoidant builds avoidance_drive; anger<->stress Jury margin unchanged. New
  data/scenarios/moral_accusation.yaml.
- spec build order + diagram marked M-J.3 IMPLEMENTED; false_accusation/blame_shift + witness fan-out DEFERRED.

Full suite 329 passed +4 skipped; goldens byte-identical (moral layer opt-in); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…ievable M-J.3 deferral)

Closes the one accused-side M-J.3 deferral that does NOT need the multi-agent driver (M-MEM). A watcher
signals suspicion of an INNOCENT persona; per spec section 3 the cue raises PRESSURE without revealing or
creating truth -- the persona feels watched and grows wary, but no guilt is ever made ("looks suspicious
from avoidance without being guilty").

- mapper: suspicion_raised -> suspicion_cue channel (RELATIONAL, source = suspecter). false_accusation wired
  as the same accused-side `accusation` channel (vocab alias; the distinct accuser-discovery half is M-MEM).
- moral_overlay.yaml: suspicion_cue gains -> suspicion[suspecter] (the relation dim), exposure_anxiety
  (feeling watched), avoidance_drive (conflict_avoidance-gated), tiny frustration (opens the reply window).
  The diagram's exposure->avoidance edge is realized as a trait-gated cue GAIN, NOT a standing coupling:
  couplings can't be trait-modulated and an always-on edge fired `avoid` in the probe litmus.
- tests/test_moral_suspicion.py (4): suspicion+exposure rise WITHOUT guilt (the pressure!=truth invariant);
  suspicion drives a conflict-avoidant persona to avoid; false_accusation == accusation accused-side; Jury
  margin unchanged. New data/scenarios/moral_suspicion.yaml.
- spec/diagram updated; witness fan-out + false-accusation accuser-discovery + blame_shift(lie_type) DEFERRED.

Fixed a duplicate-key shadow in the overlay (a second `exposure_anxiety:` under gains had dropped probe:0.05).
Full suite 333 passed +4 skipped; goldens byte-identical (moral layer opt-in); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…our (R7)

Pin the sequencing decision so it is not lost: the remaining M-J.3 work (multi-agent witness fan-out and
false_accusation accuser-after-discovery guilt) is architecturally blocked on a multi-event-per-tick mapper
(M-MEM), not deferrable by effort -- the mapper maps exactly one RawEvent per tick today. M-MEM is built on
its own branch feature/m-mem-multi-event (cut from main, reusable, mergeable independently); re-entry is
M-J.3.3 (witness fan-out + false-accusation discovery) rebased on the updated main. M-J.4 ledger stays
self-contained and can proceed without M-MEM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…ilt on M-MEM)

The remaining M-J.3 work, unblocked by the merged M-MEM multi-event tick (review R7). Two pieces:

- PUBLIC-ACCUSATION FAN-OUT (accused side). A public charge lands as ONE tick carrying the accuser's
  `accusation` + each witness's `suspicion_raised`; the accused grows wary of EVERY witness at once
  (suspicion[witness_i] all rise on the same tick) while perceived_injustice builds. No new engine code --
  the cues exist; M-MEM delivers them simultaneously. (data/scenarios/moral_public_accusation.yaml.)
- FALSE-ACCUSATION DISCOVERY (accuser side). New SELF cue `false_accusation_discovered` -> guilt
  (x guilt_proneness: a guilt-prone accuser feels remorse, a callous one barely does) + exposure_anxiety
  (exposed as a false accuser); the crowd turning is the witnesses' suspicion_raised fanned onto the accuser
  on the discovery tick. (mapper cue + overlay gains/modulator; moral_false_accusation_discovered.yaml.)
- tests/test_moral_witness_fanout.py (5): both witnesses' suspicion lands on the FIRST public tick (fan-out,
  neither dropped) + injustice + resentment-toward-accuser; bounded; discovery guilt scaled by
  guilt_proneness (remorse litmus); crowd fans suspicion onto the accuser + exposure; Jury margin unchanged.
- spec build order + diagram marked M-J.3.3 IMPLEMENTED; blame_shift stays an M-J.4 lie_type.

Full suite 362 passed +4 skipped; goldens byte-identical (cue opt-in); ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…eeze + trace, byte-identical

The ledger (Secret + LieRecord) is the one genuinely-new structure (spec section 3). This slice wires it in
WITHOUT lifecycle: opt-in, deep-copied into the frozen snapshot, serialized only when non-empty.

- schema: Secret (authored constants + dynamic mini-integrators), LieRecord (consistency_debt/maintenance_
  load/detected_risk), MoralLedger{secrets, lies} with is_empty()/copy(). `moral_ledger` field on
  PersonaRuntime + Snapshot (default empty); freeze() DEEP-copies it -> update/potentials get a read-only
  view (the synchronous-snapshot discipline).
- debug: _ledger_dict serializes the ledger in canonical sorted-id / fixed-field order; emitted SPARSELY in
  TickTrace.to_dict ONLY when the end-of-tick ledger is non-empty -> legacy goldens byte-identical (the
  field roster gains a `moral_ledger` key only for runs that actually carry secrets/lies).
- tests/test_moral_ledger_plumbing.py (4): dataclasses default empty; legacy run carries an empty ledger and
  no trace key; freeze deep-copies (mutating the live ledger doesn't touch a prior snapshot); a seeded ledger
  serializes canonically (sorted ids, JSON-round-trippable).
- spec build order: M-J.4 decomposed into .0 plumbing (done) -> .1 lie lifecycle -> .2 detection -> .3 secret
  lifecycle -> .4 calibration grid + corpus.

Full suite 366 passed +4 skipped; goldens byte-identical; ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…acking for the lie loop)

The lie/deflect/blame_other actions now BOOK a structured LieRecord in the post_effects phase (the
sanctioned second mutation site), backing the M-J.1 cognitive-load state with an auditable ledger.

- schema/yaml_io: PersonaConfig.ledger_params (MoralLedger tunables); empty for legacy.
- simulation._update_ledger (called after _commit, post_effects phase): decays existing LieRecords'
  mini-integrators (consistency_debt/maintenance_load/detected_risk) by ledger_params.lie_decay each tick,
  then creates/REINFORCES a record keyed by the lie's target (lie:<target>) from action_params[action].
  ledger. Repeated lies to the same target accrue debt on the EXISTING record -- never one-per-lie (spec
  3.5). Inert for legacy personas (no ledger config) -> byte-identical.
- moral_overlay: ledger_params.lie_decay=0.96; ledger blocks on lie (denial), deflect (omission),
  blame_other (blame_shift) with consistency_debt/maintenance_load/complexity placeholders.
- tests/test_moral_lie_ledger.py (5): a lie creates ONE record for the target (lie_type denial); repeated
  lie-type actions accumulate debt on the SAME record (not one-per-lie); blame_other records blame_shift;
  records are mini-integrators (lie_decay<1); legacy persona books no ledger.
- spec build order: M-J.4.1 marked IMPLEMENTED.

Full suite 371 passed +4 skipped; goldens byte-identical (ledger opt-in); ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…sk (liar)

The lie_detected event, mapped to the `betrayal` channel (spec section 4/5.6), with two halves:

- TARGET SIDE (relationship damage). New `betrayal` mapper channel (RELATIONAL, source = the other party);
  overlay gains anger + frustration (opens the window) + resentment[liar](+) + trust[liar](-). Discovering a
  lie COLLAPSES trust in the liar and breeds a grudge.
- LIAR SIDE (ledger). simulation._book_detection raises detected_risk on the persona's matching LieRecord
  (lie:<detector>) by ledger_params.detected_risk_on_detect, booked in the post_effects phase. A betrayed
  target (no record) is a ledger no-op.
- moral_overlay: ledger_params.detected_risk_on_detect=0.40; betrayal gains (anger/frustration/resentment/
  trust). Fixed a would-be duplicate `frustration` gains key (folded betrayal into the existing block).
- scenarios: moral_betrayal.yaml (trusted-then-betrayed), moral_lie_caught.yaml (lie under probing, then caught).
- tests/test_moral_lie_detection.py (6): betrayal collapses trust + builds resentment + anger; bounded;
  caught lie raises detected_risk on the record; detection without a matching record is a ledger no-op;
  legacy persona ignores lie_detected (empty ledger); Jury margin unchanged.
- spec build order: M-J.4.2 marked IMPLEMENTED.

Full suite 377 passed +4 skipped; goldens byte-identical (channel + ledger opt-in); ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…nxiety + guilt spike)

Round out lie detection: M-J.4.2 booked detected_risk on the record but left the liar's FELT consequence
unmodeled. _book_detection now also spikes exposure_anxiety + guilt on the caught liar, GATED on holding the
matching LieRecord -- so a betrayed target (no record) gets neither, only the relational damage.

- simulation._book_detection: + detected_exposure / detected_guilt bumps to the liar's global_state, gated
  on the record existing (the caught liar, not the betrayed target).
- moral_overlay: ledger_params.detected_exposure=0.20, detected_guilt=0.12.
- tests/test_moral_lie_detection.py (+2): caught liar's exposure_anxiety + guilt jump on the detection
  ticks; a betrayed target picks up neither (gated on the record).

Full suite 379 passed +4 skipped; goldens byte-identical; ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…by/hidden_from + inactivation

Secrets (the other half of the MoralLedger) now have a lifecycle. Authored in the scenario, they weigh while
hidden and go quiet once exposed.

- runtime.init_runtime: seed authored Secrets from initial_overrides.secrets into the ledger (unknown keys
  ignored). Empty for legacy / golden scenarios -> ledger empty -> byte-identical.
- simulation._update_secrets (post_effects phase): decays salience by ledger_params.salience_decay; a
  `secret_cued` reminder raises salience (GATED to 0 for an inactive secret); `secret_exposed` fills known_by
  with the context witnesses and EMPTIES hidden_from (public -> no longer hiding); an ACTIVE secret's salience
  weighs as stress (secret_salience_to_stress). _secret_active = hidden_from non-empty OR unresolvedness >=
  inactive_unresolved_floor (spec 3.4) -- once inactive it is neither raised nor weighs.
- moral_overlay: ledger_params salience_decay/secret_cue_salience/inactive_unresolved_floor/
  secret_salience_to_stress.
- scenarios: moral_secret.yaml (hidden secret weighs), moral_secret_exposed.yaml (exposure -> inactivation).
- tests/test_moral_secret_lifecycle.py (5): authored secret seeds; salience builds + weighs as stress;
  exposure fills known_by/empties hidden_from + gates salience; inactive secret stops weighing; the secret is
  inert without the overlay.
- spec build order: M-J.4.3 marked IMPLEMENTED.

Full suite 384 passed +4 skipped; goldens byte-identical; ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
Audit of the M-J subsystem (engine/architecture/test-coverage) found mostly green; this addresses the real
findings.

Corrections:
- confess now DISCHARGES cognitive_load_from_lies and RESOLVES the target's LieRecord (action_params.confess
  + ledger {resolves: true}; _update_ledger handles the resolve branch) -- spec 3.5 lie_confessed, previously
  unimplemented.
- ledger writes (_update_ledger/_book_detection/_update_secrets) moved OUT of the selector else-branch so they
  run on EVERY tick, including the SEEKING->BUSY ENGAGE branch (decay/detection/secret-weight were skipped on
  an activity-start tick).
- ledger iteration now sorted-by-id (deterministic) in _update_ledger/_update_secrets.
- schema: documented RESERVED fields (LieRecord.{plausibility,witnesses,secret_id}, Secret.{exposure_risk,
  confession_threshold}) -- authored/serialized, engine-driven later (M-J.4.4); they default inert.

Test coverage (tests/test_moral_ledger_gaps.py, 7): lies to DIFFERENT targets make separate records;
consistency_debt actually DECAYS after lying stops (trajectory, not config<1); complexity is booked;
unresolvedness>=floor keeps a no-longer-hidden secret ACTIVE (and resolved+unhidden -> inactive); two active
secrets stack stress weight; confession discharges cognitive_load.

Full suite 391 passed +4 skipped; goldens byte-identical; ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
…nce + Gate C invariants

The deterministic half of M-J.4.4 (the validation gates, spec section 9.1). The calibration grid + judged
700+700 corpus (section 10) is LLM-judge/eval work, left PENDING -- all overlay magnitudes stay placeholders
until it runs.

- eval.moral.zero_gain_overrides(): the moral overlay ENABLED but with every gain zeroed (modulators dropped),
  so moral states never rise -> the topology is behaviorally inert.
- tests/test_moral_gates.py (5):
  * GATE B zero-gain equivalence -- a zero-gain moral persona matches the legacy persona on a non-moral
    scenario: same per-tick actions, identical non-moral global-state curves, identical non-moral relation
    curves, no moral action selected, empty ledger, moral states flat at 0.
  * GATE C consolidated invariants (spec 9.2): outburst != guilt alone; moral states stay bounded over a run.
  (Gate A byte-identical is covered by test_tick_golden.py; stability section 9.3 is per-slice jury_margin.)
- spec build order: M-J.4.4 split into deterministic gates (done) + calibration/judged-corpus (pending).

Full suite 396 passed +4 skipped; goldens byte-identical; ruff format + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
The non-deterministic half of M-J.4.4: turn the moral_overlay placeholders into calibrated values and score
the moral-on corpus with the blind judge. Staged cheapest-first: deterministic pre-filter (Gate B + Jury +
litmus invariants, free) -> small judged sample -> full judged 700+700 within the 1400+1400 budget. Reuses
the burst-style overlay calibrator + existing judge harness; objective per spec section 10. Budgeted work,
run deliberately (not CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rWVvvhWijSuLMEhzjiUhu
@RobertMalczyk
RobertMalczyk merged commit 5fc9417 into main Jun 25, 2026
3 checks passed
@RobertMalczyk
RobertMalczyk deleted the feature/m-j-moral-tension branch June 25, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants