Skip to content

Commit 4a4bc85

Browse files
PipFowerakerclaude
andcommitted
fix(ADR-0015): save/load determinism + migrate doom-assertion tests
- Quantize doom-adjacent live floats (streams, stocks, level, momentum) to a BINARY-EXACT grid (2^-20). Godot's JSON parse is not correctly-rounded (calibration section 7.2); power-of-two-grid doubles round-trip losslessly, so save/load + the "next turn identical" replay stay bit-stable. Sweep medians are byte-identical (grid ~1e-6, far below any gameplay scale). - Migrate the doom-assertion unit tests to ADR-0015: safety_research/audit/ team_building/desperation_lever assert intermediary writes (absorption), not a printed doom delta. test_liability_ledger detaches doom_system so the ledger's isolated synchronous-doom soak keeps its hand-rolled arithmetic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2c237d4 commit 4a4bc85

6 files changed

Lines changed: 140 additions & 62 deletions

File tree

godot/scripts/core/doom_system.gd

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,16 @@ func calculate_doom_change(state: GameState) -> Dictionary:
142142

143143
# (6) Integrate: the LEVEL accumulates the RATE. May FALL on net-negative ticks (legal —
144144
# "pulled something impressive off"). Clamp 0..100 is a display/lethality bound.
145-
doom_rate = total_rate
146-
current_doom += total_rate
147-
current_doom = clamp(current_doom, 0.0, 100.0)
145+
# Snap the persisted scalars to the binary-exact grid (SAVE_QUANTUM) so save/load is
146+
# LOSSLESS (the "next turn identical" replay must match bit-for-bit — see SAVE_QUANTUM).
147+
doom_rate = _snap(total_rate)
148+
current_doom = _snap(clamp(current_doom + total_rate, 0.0, 100.0))
149+
doom_velocity = _snap(doom_velocity)
150+
doom_momentum = _snap(doom_momentum)
148151

149152
# (7) Publish the streams for L6 chips / dev overlay / sweep, and run the trend invariant.
150153
doom_sources = streams
151-
rate_history.append(total_rate)
154+
rate_history.append(doom_rate)
152155
_check_trend_invariant(state, streams)
153156

154157
return {
@@ -226,8 +229,20 @@ func _advance_intermediaries(state: GameState) -> void:
226229
state.global_alarm *= _w("alarm_decay", 0.985)
227230
state.global_panic *= _w("panic_decay", 0.97)
228231

232+
# --- Snap every persisted intermediary to the binary-exact grid (SAVE_QUANTUM). The decay
233+
# multiplies produce arbitrary-precision doubles that fail Godot's JSON parse round-trip
234+
# (§7.2); keeping the stocks on a power-of-two grid makes save/load LOSSLESS (the
235+
# "next turn identical" replay must match bit-for-bit). Gameplay-invisible (grid ~1e-6).
236+
state.frontier_capability["player"] = _snap(float(state.frontier_capability["player"]))
237+
state.safety_absorption = _snap(state.safety_absorption)
238+
state.general_capability = _snap(state.general_capability)
239+
state.dedicated_ai_compute = _snap(state.dedicated_ai_compute)
240+
state.ambient_risk = _snap(state.ambient_risk)
241+
state.global_alarm = _snap(state.global_alarm)
242+
state.global_panic = _snap(state.global_panic)
243+
229244
# --- political_pressure tracks the alarm/panic balance (signed disposition; gate only).
230-
state.political_pressure = state.global_alarm - state.global_panic
245+
state.political_pressure = _snap(state.global_alarm - state.global_panic)
231246

232247
func _compute_streams(state: GameState) -> Dictionary:
233248
"""Read the intermediaries and produce the named hazard/relief streams (all Balance-priced).
@@ -463,29 +478,52 @@ func set_doom_multiplier(source: String, multiplier: float) -> void:
463478
# SERIALIZATION (save/load)
464479
# ============================================================================
465480

481+
## Doom-adjacent float quantum — a BINARY-EXACT grid (2^-20 ≈ 9.5e-7). Godot's JSON parse
482+
## is not correctly-rounded (L1 calibration §7.2): a full-precision double can come back
483+
## from a save 1 ulp off, breaking save/load deep-equality. The fix is to keep every
484+
## doom-adjacent LIVE value on a power-of-two grid: snappedf(v, 2^-20) yields N·2^-20, which
485+
## is an exactly-representable double whose decimal string round-trips through Godot's parser
486+
## intact (0.25 survives, 0.008 does not — §7.2). A decimal quantum (1e-6) does NOT work: it
487+
## sits between representable doubles, so a 1-ulp parse drift can cross a snap boundary and
488+
## AMPLIFY into a full-quantum divergence. The grid is far below any gameplay scale (doom
489+
## deaths at 100, streams ~0.01–10), so the live dynamics are unaffected (sweep-verified).
490+
const SAVE_QUANTUM := 1.0 / 1048576.0 # == 2^-20, binary-exact (computed so the const is precise)
491+
492+
static func _snap(v: float) -> float:
493+
return snappedf(v, SAVE_QUANTUM)
494+
495+
static func _snap_dict(d: Dictionary) -> Dictionary:
496+
var out := {}
497+
for k in d.keys():
498+
out[k] = snappedf(float(d[k]), SAVE_QUANTUM)
499+
return out
500+
466501
func to_dict() -> Dictionary:
502+
var hist: Array = []
503+
for v in rate_history:
504+
hist.append(_snap(v))
467505
return {
468-
"current_doom": current_doom,
469-
"doom_rate": doom_rate,
470-
"doom_velocity": doom_velocity,
471-
"doom_momentum": doom_momentum,
472-
"pending_rival_doom": _pending_rival_doom,
473-
"pending_stream_inputs": _pending_stream_inputs.duplicate(),
474-
"doom_sources": doom_sources.duplicate(),
475-
"rate_history": rate_history.duplicate(),
506+
"current_doom": _snap(current_doom),
507+
"doom_rate": _snap(doom_rate),
508+
"doom_velocity": _snap(doom_velocity),
509+
"doom_momentum": _snap(doom_momentum),
510+
"pending_rival_doom": _snap(_pending_rival_doom),
511+
"pending_stream_inputs": _snap_dict(_pending_stream_inputs),
512+
"doom_sources": _snap_dict(doom_sources),
513+
"rate_history": hist,
476514
}
477515

478516
func from_dict(data: Dictionary) -> void:
479-
current_doom = float(data.get("current_doom", 50.0))
480-
doom_rate = float(data.get("doom_rate", 0.0))
481-
doom_velocity = float(data.get("doom_velocity", 0.0))
482-
doom_momentum = float(data.get("doom_momentum", 0.0))
483-
_pending_rival_doom = float(data.get("pending_rival_doom", 0.0))
517+
current_doom = _snap(float(data.get("current_doom", 50.0)))
518+
doom_rate = _snap(float(data.get("doom_rate", 0.0)))
519+
doom_velocity = _snap(float(data.get("doom_velocity", 0.0)))
520+
doom_momentum = _snap(float(data.get("doom_momentum", 0.0)))
521+
_pending_rival_doom = _snap(float(data.get("pending_rival_doom", 0.0)))
484522
if data.has("pending_stream_inputs"):
485-
_pending_stream_inputs = data["pending_stream_inputs"].duplicate()
523+
_pending_stream_inputs = _snap_dict(data["pending_stream_inputs"])
486524
if data.has("doom_sources"):
487-
doom_sources = data["doom_sources"].duplicate()
525+
doom_sources = _snap_dict(data["doom_sources"])
488526
if data.has("rate_history"):
489527
rate_history.clear()
490528
for v in data["rate_history"]:
491-
rate_history.append(float(v))
529+
rate_history.append(_snap(float(v)))

godot/scripts/core/game_state.gd

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,14 @@ static func format_score(turns: int, integral: int) -> String:
813813
# fidelity for mid-game save/load (and later DQ-11 fork/divergence).
814814
# ============================================================================
815815

816+
static func _snap_array(arr) -> Array:
817+
## Serialization-boundary float snap for arrays (see DoomSystem.SAVE_QUANTUM).
818+
var out: Array = []
819+
for v in arr:
820+
out.append(DoomSystem._snap(float(v)))
821+
return out
822+
823+
816824
func to_dict() -> Dictionary:
817825
"""Serialize state for UI + save/load (see convention block above)"""
818826
var rival_summaries = []
@@ -831,7 +839,7 @@ func to_dict() -> Dictionary:
831839
var doom_data = {}
832840
if doom_system:
833841
doom_data = doom_system.to_dict()
834-
doom_data["doom"] = doom
842+
doom_data["doom"] = DoomSystem._snap(doom)
835843
doom_data["doom_trend"] = doom_system._get_doom_trend()
836844
doom_data["doom_status"] = doom_system.get_doom_status()
837845
doom_data["momentum_description"] = doom_system.get_momentum_description()
@@ -874,18 +882,23 @@ func to_dict() -> Dictionary:
874882
"ledger": ledger.to_dict() if ledger else {},
875883
"month_plan": month_plan.to_dict() if month_plan else {}, # L1/ADR-0009 Attention + reserve + WIP
876884
"cause_log": cause_log.duplicate(true), # EE-8 attribution trail
877-
"doom": doom,
878-
"doom_history": doom_history.duplicate(), # #512 trend graph
885+
# Doom-adjacent floats are SNAPPED at the serialization boundary (both directions,
886+
# same quantum as DoomSystem.SAVE_QUANTUM): the stream model produces full-precision
887+
# doubles, and Godot's JSON parse is not correctly-rounded (calibration §7.2) — a
888+
# 1-ulp corrupted parse re-snaps to the identical double, keeping save/load
889+
# deep-equality byte-stable. Live dynamics never see the quantum.
890+
"doom": DoomSystem._snap(doom),
891+
"doom_history": _snap_array(doom_history), # #512 trend graph
879892
# ADR-0015 / DQ-21 world-state intermediaries (doom is computed from these)
880-
"ambient_risk": ambient_risk,
881-
"frontier_capability": frontier_capability.duplicate(),
882-
"general_capability": general_capability,
883-
"global_compute": global_compute,
884-
"dedicated_ai_compute": dedicated_ai_compute,
885-
"safety_absorption": safety_absorption,
886-
"global_alarm": global_alarm,
887-
"global_panic": global_panic,
888-
"political_pressure": political_pressure,
893+
"ambient_risk": DoomSystem._snap(ambient_risk),
894+
"frontier_capability": DoomSystem._snap_dict(frontier_capability),
895+
"general_capability": DoomSystem._snap(general_capability),
896+
"global_compute": DoomSystem._snap(global_compute),
897+
"dedicated_ai_compute": DoomSystem._snap(dedicated_ai_compute),
898+
"safety_absorption": DoomSystem._snap(safety_absorption),
899+
"global_alarm": DoomSystem._snap(global_alarm),
900+
"global_panic": DoomSystem._snap(global_panic),
901+
"political_pressure": DoomSystem._snap(political_pressure),
889902
"doom_dampers": doom_dampers.duplicate(true),
890903
"doom_pulses": doom_pulses.duplicate(true),
891904
"sacred_chain_log": sacred_chain_log.duplicate(true),
@@ -910,7 +923,7 @@ func to_dict() -> Dictionary:
910923
"management_capacity": get_management_capacity(),
911924
"unmanaged_count": get_unmanaged_count(),
912925
"turn": turn,
913-
"doom_integral": doom_integral,
926+
"doom_integral": DoomSystem._snap(doom_integral),
914927
"turn_display": get_turn_display(),
915928
"calendar": get_current_date(),
916929
"game_over": game_over,
@@ -955,22 +968,24 @@ func from_dict(data: Dictionary) -> void:
955968
papers = float(data.get("papers", 0.0))
956969
reputation = float(data.get("reputation", 10.0))
957970
governance = float(data.get("governance", 50.0)) # was forgotten pre-L7
958-
doom = float(data.get("doom", 50.0))
971+
# Doom-adjacent floats re-snap on load (idempotent under the 1-ulp JSON parse
972+
# corruption — see the to_dict comment + DoomSystem.SAVE_QUANTUM).
973+
doom = DoomSystem._snap(float(data.get("doom", 50.0)))
959974
doom_history.clear()
960975
for d in data.get("doom_history", []):
961-
doom_history.append(float(d))
976+
doom_history.append(DoomSystem._snap(float(d)))
962977
# ADR-0015 / DQ-21 world-state intermediaries
963-
ambient_risk = float(data.get("ambient_risk", Balance.num("doom.base_per_turn", 0.06)))
964-
frontier_capability = (data.get("frontier_capability", {"player": 0.0}) as Dictionary).duplicate()
978+
ambient_risk = DoomSystem._snap(float(data.get("ambient_risk", Balance.num("doom.base_per_turn", 0.06))))
979+
frontier_capability = DoomSystem._snap_dict(data.get("frontier_capability", {"player": 0.0}) as Dictionary)
965980
if not frontier_capability.has("player"):
966981
frontier_capability["player"] = 0.0
967-
general_capability = float(data.get("general_capability", 0.0))
968-
global_compute = float(data.get("global_compute", 0.0))
969-
dedicated_ai_compute = float(data.get("dedicated_ai_compute", 0.0))
970-
safety_absorption = float(data.get("safety_absorption", 0.0))
971-
global_alarm = float(data.get("global_alarm", 0.0))
972-
global_panic = float(data.get("global_panic", 0.0))
973-
political_pressure = float(data.get("political_pressure", 0.0))
982+
general_capability = DoomSystem._snap(float(data.get("general_capability", 0.0)))
983+
global_compute = DoomSystem._snap(float(data.get("global_compute", 0.0)))
984+
dedicated_ai_compute = DoomSystem._snap(float(data.get("dedicated_ai_compute", 0.0)))
985+
safety_absorption = DoomSystem._snap(float(data.get("safety_absorption", 0.0)))
986+
global_alarm = DoomSystem._snap(float(data.get("global_alarm", 0.0)))
987+
global_panic = DoomSystem._snap(float(data.get("global_panic", 0.0)))
988+
political_pressure = DoomSystem._snap(float(data.get("political_pressure", 0.0)))
974989
doom_dampers = (data.get("doom_dampers", []) as Array).duplicate(true)
975990
doom_pulses = (data.get("doom_pulses", []) as Array).duplicate(true)
976991
sacred_chain_log = (data.get("sacred_chain_log", []) as Array).duplicate(true)
@@ -990,7 +1005,7 @@ func from_dict(data: Dictionary) -> void:
9901005

9911006
# Game state
9921007
turn = int(data.get("turn", 0))
993-
doom_integral = float(data.get("doom_integral", 0.0))
1008+
doom_integral = DoomSystem._snap(float(data.get("doom_integral", 0.0)))
9941009
game_over = bool(data.get("game_over", false))
9951010
victory = bool(data.get("victory", false))
9961011
has_cat = bool(data.get("has_cat", false))

godot/tests/unit/test_actions.gd

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,19 @@ func test_hire_alignment_researcher_execution():
9595
assert_eq(state.get_researcher_count_by_spec("alignment"), before + 1, "Alignment researcher should join")
9696

9797
func test_safety_research_execution():
98-
# Test safety research action - doom reduction scales with safety_researchers count
99-
# Need at least 1 safety researcher for any doom reduction
98+
# ADR-0015: no action writes doom. Safety research raises the safety_absorption
99+
# intermediary (Balance-scaled); the overhang stream converts it on the doom tick.
100100
state.safety_researchers = 1
101101
state.research = 20.0 # Ensure enough research to pay cost
102102
var initial_doom = state.doom
103+
var initial_absorb = state.safety_absorption
103104

104105
var result = GameActions.execute_action("safety_research", state)
105106

106-
assert_lt(state.doom, initial_doom, "Doom should decrease when safety researchers > 0")
107+
assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)")
108+
assert_almost_eq(state.safety_absorption,
109+
initial_absorb + 1.0 * Balance.num("doom.streams.action_safety_absorb", 0.0), 0.0001,
110+
"Safety research raises safety_absorption by the Balance-priced amount")
107111

108112
func test_team_building_execution():
109113
# Test team building action
@@ -113,7 +117,7 @@ func test_team_building_execution():
113117
var result = GameActions.execute_action("team_building", state)
114118

115119
assert_gt(state.reputation, initial_reputation, "Reputation should increase")
116-
assert_lt(state.doom, initial_doom, "Doom should decrease")
120+
assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)")
117121

118122
func test_media_campaign_execution():
119123
# Test media campaign action
@@ -170,8 +174,8 @@ func test_safety_audit_execution():
170174

171175
var result = GameActions.execute_action("audit_safety", state)
172176

173-
# Safety audit should reduce doom and increase reputation
174-
assert_lt(state.doom, initial_doom, "Doom should decrease")
177+
# ADR-0015: the audit raises safety_absorption (Balance-priced), never doom directly
178+
assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)")
175179
assert_gt(state.reputation, initial_reputation, "Reputation should increase")
176180

177181
func test_action_execution_returns_result():

godot/tests/unit/test_ledger_actions.gd

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ func test_funding_strings_bills_governance():
2323
assert_eq(s.money, before + 40000, "funding pays out")
2424
assert_eq(s.ledger.entries[0].currency, "governance", "strings bill in governance")
2525

26-
func test_desperation_lever_suppresses_doom_and_plants_secret():
26+
func test_desperation_lever_plants_secret_without_printed_doom():
27+
# ADR-0015: no action writes doom. The lever's catch-up benefit is now a
28+
# safety_absorption reprieve (Balance doom.streams.action_desperation_absorb,
29+
# priced 0 in v1); the SECRET compounding liability (the real teeth) is unchanged.
2730
var s = _fresh_state()
2831
var before_doom = s.doom
32+
var before_absorb = s.safety_absorption
2933
GameActions.execute_action("desperation_lever", s)
30-
assert_lt(s.doom, before_doom, "desperation suppresses doom now")
34+
assert_eq(s.doom, before_doom, "no printed doom delta (ADR-0015)")
35+
assert_almost_eq(s.safety_absorption,
36+
before_absorb + Balance.num("doom.streams.action_desperation_absorb", 0.0), 0.0001,
37+
"the catch-up benefit is a Balance-priced absorption reprieve")
3138
assert_eq(s.ledger.secret_entries().size(), 1, "desperation plants a SECRET liability")
3239

3340
func test_staff_rider_grants_ap_and_rider():

godot/tests/unit/test_liability_ledger.gd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ func _fresh_state(seed_str: String):
1212
s.money = 245000.0
1313
s.governance = 50.0
1414
s.reputation = 50.0
15+
# ADR-0015: these tests exercise the LEDGER's own conversion contract in isolation
16+
# (synchronous doom arithmetic, controlled soak). With a doom_system attached the
17+
# ledger routes doom as a buffered STREAM INPUT (applied on the next doom tick) —
18+
# correct in the real loop, but it would decouple the soak's hand-rolled doom math.
19+
# Detach the authority so Ledger._add_doom takes its documented lightweight-double
20+
# fallback (state.doom +=). Stream routing is covered by test_doom_system + the sweep.
21+
if s.doom_system:
22+
s.doom_system.free()
23+
s.doom_system = null
1524
return s
1625

1726

godot/tests/unit/test_turn_manager.gd

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -434,21 +434,26 @@ func test_insider_threat_key_resignation_removes_a_researcher():
434434
assert_string_contains(joined, "Fickle Fran", "Departure is named in the risk-event log line")
435435

436436
func test_insider_threat_key_resignation_safe_with_no_researchers():
437-
# No researchers on staff -> the scalar effects (research/reputation/doom)
438-
# still apply, but the staffing loss is a safe no-op (no crash, no phantom note).
439-
# Doom for risk events routes through state.doom_system (synced to state.doom
440-
# later in the turn pipeline, not within _step_process_risk_pools itself), so
441-
# assert on doom_system.current_doom directly rather than state.doom.
437+
# No researchers on staff -> the scalar effects (research/reputation/doom) still apply,
438+
# but the staffing loss is a safe no-op (no crash, no phantom note).
439+
# ADR-0015: a risk shock's doom is not a direct level write — it routes as a buffered
440+
# `panic` STREAM INPUT (add_event_doom -> add_stream_input) that the DoomSystem folds into
441+
# the rate on the next tick. So it lands as a pending panic input here, not on current_doom.
442442
assert_eq(state.researchers.size(), 0, "Start empty")
443443
var initial_reputation = state.reputation
444-
var initial_doom: float = state.doom_system.current_doom if state.doom_system else state.doom
445444

446445
state.risk_system.pools["insider_threat"] = 55.0
447446

448447
var results: Array = []
449448
turn_manager._step_process_risk_pools(results)
450449

451450
assert_eq(state.researchers.size(), 0, "Nobody to remove, nobody removed")
452-
var after_doom: float = state.doom_system.current_doom if state.doom_system else state.doom
453-
assert_gt(after_doom, initial_doom, "Scalar doom effect still applies")
451+
var pending_panic: float = float(state.doom_system._pending_stream_inputs.get("panic", 0.0)) if state.doom_system else 0.0
452+
assert_gt(pending_panic, 0.0, "Risk-shock doom routes to a buffered panic stream input (ADR-0015)")
453+
# Confirm it actually LANDS on the next doom tick (raising the level).
454+
var before_level: float = state.doom_system.current_doom if state.doom_system else state.doom
455+
if state.doom_system:
456+
state.doom_system.calculate_doom_change(state)
457+
var after_level: float = state.doom_system.current_doom if state.doom_system else state.doom
458+
assert_gt(after_level, before_level, "the buffered risk shock lands on the next tick")
454459
assert_lt(state.reputation, initial_reputation, "Scalar reputation effect still applies")

0 commit comments

Comments
 (0)