Spaces:
Running on Zero
Running on Zero
Commit ·
68906df
1
Parent(s): cf0e74a
feat: decide_ending three-way gate replaces should_end
Browse filesCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +2 -2
- memory.py +16 -7
- 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,
|
| 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
|
| 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
|
| 64 |
-
"""
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"""
|
| 69 |
if state.get("ended"):
|
| 70 |
-
return
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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":
|
| 180 |
-
"claimed":
|
| 181 |
-
"
|
| 182 |
-
"turn": 20,
|
| 183 |
-
"last_recall_turn": 17,
|
| 184 |
"ended": ended,
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
|
| 187 |
|
| 188 |
-
class
|
| 189 |
-
|
| 190 |
-
|
|
|
|
| 191 |
|
| 192 |
-
def
|
| 193 |
-
assert
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
|
|
|
| 197 |
|
| 198 |
-
def
|
| 199 |
-
assert
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
def test_fresh_session_does_not_fire(self):
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
assert
|
| 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:
|