Pabloler21 Claude Fable 5 commited on
Commit
68906df
·
1 Parent(s): cf0e74a

feat: decide_ending three-way gate replaces should_end

Browse files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +2 -2
  2. memory.py +16 -7
  3. tests/test_memory.py +58 -24
app.py CHANGED
@@ -6,7 +6,7 @@ import gradio as gr
6
  from character import build_system_prompt, OPENING_LINE
7
  from engine import run_turn
8
  from finale import finale_steps
9
- from memory import apply_update, get_tier, should_end, should_recall
10
  from render import render_entity, render_treasure
11
 
12
 
@@ -124,7 +124,7 @@ def chat(user_msg: str, state: dict, history: list):
124
  )
125
  return
126
 
127
- if should_end(state):
128
  state["ended"] = True
129
  yield from _play_finale(state, history)
130
  return
 
6
  from character import build_system_prompt, OPENING_LINE
7
  from engine import run_turn
8
  from finale import finale_steps
9
+ from memory import apply_update, get_tier, decide_ending, should_recall
10
  from render import render_entity, render_treasure
11
 
12
 
 
124
  )
125
  return
126
 
127
+ if decide_ending(state):
128
  state["ended"] = True
129
  yield from _play_finale(state, history)
130
  return
memory.py CHANGED
@@ -60,12 +60,21 @@ def should_recall(state: dict) -> tuple[bool, str | None]:
60
  return False, None
61
 
62
 
63
- def should_end(state: dict) -> bool:
64
- """The Visitor Loop fires once: high bond, enough stolen memories.
65
-
66
- Checked at turn start, before the model runs. The finale turn is fully
67
- scripted no model call, no GPU.
 
 
 
 
68
  """
69
  if state.get("ended"):
70
- return False
71
- return state["affinity"] >= 90 and len(state["claimed"]) >= 3
 
 
 
 
 
 
60
  return False, None
61
 
62
 
63
+ def decide_ending(state: dict) -> str | None:
64
+ """Which finale fires this turn, if any. Checked at turn start, before the
65
+ model runs — the finale turn is fully scripted, no model call, no GPU.
66
+
67
+ bad: sustained cruelty (tone accumulator), a minimum game length, and at
68
+ least 2 captured wounds (the finale recites them; if extraction
69
+ missed the quotes, the game simply continues).
70
+ good/neutral: the original Visitor Loop gate, branched by tone — warm
71
+ play earns the Loop; ambivalent play earns the fog.
72
  """
73
  if state.get("ended"):
74
+ return None
75
+ tone = state.get("tone", 0)
76
+ if tone <= -40 and state["turn"] >= 6 and len(state.get("wounds", [])) >= 2:
77
+ return "bad"
78
+ if state["affinity"] >= 90 and len(state["claimed"]) >= 3:
79
+ return "good" if tone >= 15 else "neutral"
80
+ return None
tests/test_memory.py CHANGED
@@ -1,5 +1,5 @@
1
  import pytest
2
- from memory import get_tier, apply_update, should_recall, should_end
3
 
4
 
5
  class TestGetTier:
@@ -172,41 +172,75 @@ class TestShouldRecall:
172
  assert mem is None
173
 
174
 
175
- def _end_state(affinity=90, claimed_count=3, ended=False):
176
- claimed = [f"memory {i}" for i in range(claimed_count)]
177
  return {
178
  "affinity": affinity,
179
- "treasure": claimed + ["unclaimed extra"],
180
- "claimed": claimed,
181
- "history": [],
182
- "turn": 20,
183
- "last_recall_turn": 17,
184
  "ended": ended,
 
 
185
  }
186
 
187
 
188
- class TestShouldEnd:
189
- def test_fires_at_90_with_3_claimed(self):
190
- assert should_end(_end_state(affinity=90, claimed_count=3)) is True
 
191
 
192
- def test_does_not_fire_below_90(self):
193
- assert should_end(_end_state(affinity=89, claimed_count=5)) is False
194
 
195
- def test_does_not_fire_with_fewer_than_3_claimed(self):
196
- assert should_end(_end_state(affinity=95, claimed_count=2)) is False
 
197
 
198
- def test_does_not_fire_twice(self):
199
- assert should_end(_end_state(ended=True)) is False
200
 
201
- def test_backward_compat_state_without_ended_key(self):
202
- state = _end_state()
203
- del state["ended"]
204
- assert should_end(state) is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
  def test_fresh_session_does_not_fire(self):
207
- state = {"affinity": 20, "treasure": [], "claimed": [],
208
- "history": [], "turn": 0, "last_recall_turn": None}
209
- assert should_end(state) is False
210
 
211
 
212
  class TestApplyUpdateTone:
 
1
  import pytest
2
+ from memory import get_tier, apply_update, should_recall, decide_ending
3
 
4
 
5
  class TestGetTier:
 
172
  assert mem is None
173
 
174
 
175
+ def _end_state(affinity=90, claimed_count=3, ended=False, tone=30, turn=12, wounds=None):
 
176
  return {
177
  "affinity": affinity,
178
+ "treasure": [f"memory {i}" for i in range(claimed_count)],
179
+ "claimed": [f"memory {i}" for i in range(claimed_count)],
180
+ "turn": turn,
 
 
181
  "ended": ended,
182
+ "tone": tone,
183
+ "wounds": wounds if wounds is not None else [],
184
  }
185
 
186
 
187
+ class TestDecideEnding:
188
+ # --- good ---
189
+ def test_good_at_90_with_3_claimed_and_warm_tone(self):
190
+ assert decide_ending(_end_state(tone=30)) == "good"
191
 
192
+ def test_good_exactly_at_tone_15(self):
193
+ assert decide_ending(_end_state(tone=15)) == "good"
194
 
195
+ # --- neutral ---
196
+ def test_neutral_when_gate_met_but_tone_mixed(self):
197
+ assert decide_ending(_end_state(tone=5)) == "neutral"
198
 
199
+ def test_neutral_when_tone_mildly_negative(self):
200
+ assert decide_ending(_end_state(tone=-10)) == "neutral"
201
 
202
+ # --- bad ---
203
+ def test_bad_on_sustained_cruelty(self):
204
+ s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8,
205
+ wounds=["w1", "w2"])
206
+ assert decide_ending(s) == "bad"
207
+
208
+ def test_bad_needs_minimum_turns(self):
209
+ s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=5,
210
+ wounds=["w1", "w2"])
211
+ assert decide_ending(s) is None
212
+
213
+ def test_bad_needs_two_wounds(self):
214
+ s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=8,
215
+ wounds=["w1"])
216
+ assert decide_ending(s) is None
217
+
218
+ def test_bad_wins_over_good_gate(self):
219
+ # pathological but defined: cruelty fires first
220
+ s = _end_state(affinity=95, claimed_count=3, tone=-50, turn=10,
221
+ wounds=["w1", "w2"])
222
+ assert decide_ending(s) == "bad"
223
+
224
+ # --- none ---
225
+ def test_none_below_90(self):
226
+ assert decide_ending(_end_state(affinity=89, claimed_count=5)) is None
227
+
228
+ def test_none_with_fewer_than_3_claimed(self):
229
+ assert decide_ending(_end_state(affinity=95, claimed_count=2)) is None
230
+
231
+ def test_none_when_already_ended(self):
232
+ assert decide_ending(_end_state(ended=True)) is None
233
+
234
+ def test_old_state_without_tone_key_is_neutral(self):
235
+ s = _end_state()
236
+ del s["tone"]
237
+ del s["wounds"]
238
+ assert decide_ending(s) == "neutral"
239
 
240
  def test_fresh_session_does_not_fire(self):
241
+ s = {"affinity": 20, "treasure": [], "claimed": [], "turn": 0,
242
+ "ended": False, "tone": 0, "wounds": []}
243
+ assert decide_ending(s) is None
244
 
245
 
246
  class TestApplyUpdateTone: