hollow / tests /test_memory.py
Pabloler21's picture
feat(pacing): shorten the tester run (end_affinity 35, recall_cooldown 1, bad_min_turn 3)
b8e5756
Raw
History Blame Contribute Delete
19.9 kB
import pytest
from memory import get_tier, apply_update, should_recall, decide_ending, sanitize_name
class TestGetTier:
def test_zero_is_hollow(self):
assert get_tier(0) == "Hollow"
def test_25_is_hollow(self):
assert get_tier(25) == "Hollow"
def test_26_is_curious(self):
assert get_tier(26) == "Curious"
def test_50_is_curious(self):
assert get_tier(50) == "Curious"
def test_51_is_too_human(self):
assert get_tier(51) == "Too Human"
def test_75_is_too_human(self):
assert get_tier(75) == "Too Human"
def test_76_is_almost(self):
assert get_tier(76) == "Almost"
def test_100_is_almost(self):
assert get_tier(100) == "Almost"
class TestApplyUpdate:
def _base_state(self):
return {"affinity": 50, "treasure": [], "claimed": [], "history": [], "turn": 0}
def test_valid_json_updates_affinity(self):
state = self._base_state()
result = apply_update(state, '{"affinity_delta": 5, "new_memories": []}')
assert result["affinity"] == 55
def test_affinity_clamped_at_100(self):
state = self._base_state()
state["affinity"] = 98
result = apply_update(state, '{"affinity_delta": 5, "new_memories": []}')
assert result["affinity"] == 100
def test_affinity_clamped_at_0(self):
state = self._base_state()
state["affinity"] = 2
result = apply_update(state, '{"affinity_delta": -5, "new_memories": []}')
assert result["affinity"] == 0
def test_new_memories_added_to_treasure(self):
state = self._base_state()
result = apply_update(state, '{"affinity_delta": 0, "new_memories": ["has a dog named Nala"]}')
assert "has a dog named Nala" in result["treasure"]
def test_duplicate_memory_not_added_twice(self):
state = self._base_state()
state["treasure"] = ["has a dog named Nala"]
result = apply_update(state, '{"affinity_delta": 0, "new_memories": ["has a dog named Nala"]}')
assert result["treasure"].count("has a dog named Nala") == 1
def test_broken_json_falls_back_to_no_change(self):
state = self._base_state()
result = apply_update(state, "not valid json at all")
assert result["affinity"] == 50
assert result["treasure"] == []
def test_broken_json_with_garbage_prefix(self):
state = self._base_state()
result = apply_update(state, 'Sure! Here you go: {"affinity_delta": 3, "new_memories": []}')
assert result["affinity"] == 53
def test_negative_delta_lowers_affinity(self):
state = self._base_state()
result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}')
assert result["affinity"] == 47
def test_chosen_name_is_sanitized_and_stored(self):
state = self._base_state()
result = apply_update(
state,
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "Mara"}',
)
assert result["chosen_name"] == "Mara"
assert result.get("named") is True
def test_chosen_name_rejected_if_already_named(self):
state = self._base_state()
state["chosen_name"] = "Lila"
state["named"] = True
result = apply_update(
state,
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "Nova"}',
)
assert result["chosen_name"] == "Lila"
def test_invalid_name_falls_back(self):
state = self._base_state()
result = apply_update(
state,
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "123bad"}',
)
assert result.get("chosen_name") is None
assert result.get("named") is not True
class TestSanitizeName:
def test_clean_single_word(self):
assert sanitize_name("Mara") == "Mara"
def test_strips_punctuation(self):
assert sanitize_name('"Lila."') == "Lila"
def test_uses_first_word_of_multiple(self):
assert sanitize_name("Little Ghost") == "Little"
def test_rejects_empty(self):
assert sanitize_name(" ") is None
def test_rejects_digits(self):
assert sanitize_name("X7") is None
def test_rejects_too_long(self):
assert sanitize_name("A" * 25) is None
class TestShouldRecall:
def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None):
return {
"affinity": affinity,
"treasure": treasure if treasure is not None else ["has a dog named Nala"],
"claimed": claimed if claimed is not None else [],
"history": [],
"turn": turn,
"last_recall_turn": last_recall_turn,
}
def test_no_recall_when_nothing_unclaimed(self):
state = self._state(affinity=44, treasure=[])
fired, mem = should_recall(state)
assert fired is False
assert mem is None
def test_no_recall_empty_treasure(self):
state = self._state(affinity=60, treasure=[])
fired, mem = should_recall(state)
assert fired is False
assert mem is None
def test_no_recall_all_claimed(self):
state = self._state(affinity=60, treasure=["x"], claimed=["x"])
fired, mem = should_recall(state)
assert fired is False
assert mem is None
def test_first_recall_at_45(self):
state = self._state(affinity=45, treasure=["has a dog named Nala"], last_recall_turn=None)
fired, mem = should_recall(state)
assert fired is True
assert mem == "has a dog named Nala"
def test_recall_returns_richest_unclaimed(self):
state = self._state(
affinity=55,
treasure=[
"had a grandmother",
"had a red bicycle I loved more than anything, stolen, cried for days",
"kept bees",
],
claimed=[],
last_recall_turn=None,
)
fired, mem = should_recall(state)
assert fired is True
assert mem == "had a red bicycle I loved more than anything, stolen, cried for days"
def test_recall_tie_breaks_to_oldest_when_equal_length(self):
state = self._state(
affinity=55,
treasure=["memory A", "memory B"],
claimed=[],
last_recall_turn=None,
)
fired, mem = should_recall(state)
assert fired is True
assert mem == "memory A"
def test_recall_skips_claimed_memories(self):
state = self._state(
affinity=60,
treasure=["memory A", "memory B"],
claimed=["memory A"],
last_recall_turn=None,
)
fired, mem = should_recall(state)
assert fired is True
assert mem == "memory B"
def test_no_recall_on_the_same_turn_as_last(self):
state = self._state(affinity=60, turn=5, last_recall_turn=5)
fired, mem = should_recall(state)
assert fired is False # 5 - 5 = 0 < 1 (tester cooldown)
def test_recall_one_turn_after_last(self):
state = self._state(affinity=60, turn=6, last_recall_turn=5)
fired, mem = should_recall(state)
assert fired is True # 6 - 5 = 1 >= 1 (tester cooldown)
assert mem == "has a dog named Nala"
def test_no_recall_if_no_unclaimed_after_some_claimed(self):
state = self._state(
affinity=70,
treasure=["x", "y"],
claimed=["x", "y"],
last_recall_turn=None,
)
fired, mem = should_recall(state)
assert fired is False
assert mem is None
def _end_state(affinity=90, claimed_count=3, ended=False, tone=30, turn=12, wounds=None):
return {
"affinity": affinity,
"treasure": [f"memory {i}" for i in range(claimed_count)],
"claimed": [f"memory {i}" for i in range(claimed_count)],
"turn": turn,
"ended": ended,
"tone": tone,
"wounds": wounds if wounds is not None else [],
}
class TestDecideEnding:
# --- good ---
def test_good_at_90_with_3_claimed_and_warm_tone(self):
assert decide_ending(_end_state(tone=30)) == "good"
def test_good_exactly_at_tone_20(self):
assert decide_ending(_end_state(tone=20)) == "good"
def test_just_below_warmth_threshold_is_loop(self):
assert decide_ending(_end_state(tone=19)) == "loop"
# --- loop (fed it plenty, never loved it) ---
def test_loop_when_gate_met_but_tone_flat(self):
assert decide_ending(_end_state(tone=5)) == "loop"
def test_loop_when_tone_mildly_negative(self):
assert decide_ending(_end_state(tone=-10)) == "loop"
# --- tester thresholds lowered for a short, judge-friendly run ---
def test_tester_good_fires_at_lowered_affinity_35(self):
assert decide_ending(_end_state(affinity=35, claimed_count=2,
tone=30, turn=5)) == "good"
def test_tester_good_just_below_35_does_not_fire(self):
assert decide_ending(_end_state(affinity=34, claimed_count=2,
tone=30, turn=5)) is None
def test_tester_bad_fires_at_turn_3(self):
assert decide_ending(_end_state(affinity=12, claimed_count=0, tone=-45,
turn=3, wounds=["w1", "w2"])) == "bad"
# --- dev seed forces a deterministic ending (HOLLOW_FAST_FINALE) ---
def test_force_ending_overrides_the_tone_branch(self):
# a flat-tone state would normally be "loop"; the dev force wins
s = _end_state(tone=0)
s["force_ending"] = "good"
assert decide_ending(s) == "good"
# --- bad ---
def test_bad_on_sustained_cruelty(self):
s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8,
wounds=["w1", "w2"])
assert decide_ending(s) == "bad"
def test_bad_needs_minimum_turns(self):
s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=2,
wounds=["w1", "w2"])
assert decide_ending(s) is None # 2 < bad_min_turn (3)
def test_bad_needs_two_wounds(self):
s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=8,
wounds=["w1"])
assert decide_ending(s) is None
def test_bad_wins_over_good_gate(self):
# pathological but defined: cruelty fires first
s = _end_state(affinity=95, claimed_count=3, tone=-50, turn=10,
wounds=["w1", "w2"])
assert decide_ending(s) == "bad"
# --- none ---
def test_none_below_35(self):
assert decide_ending(_end_state(affinity=34, claimed_count=5)) is None
def test_none_with_fewer_than_2_claimed(self):
assert decide_ending(_end_state(affinity=95, claimed_count=1)) is None
def test_none_when_already_ended(self):
assert decide_ending(_end_state(ended=True)) is None
def test_old_state_without_tone_key_is_loop(self):
s = _end_state()
del s["tone"]
del s["wounds"]
assert decide_ending(s) == "loop"
def test_fresh_session_does_not_fire(self):
s = {"affinity": 20, "treasure": [], "claimed": [], "turn": 0,
"ended": False, "tone": 0, "wounds": []}
assert decide_ending(s) is None
class TestApplyUpdateTone:
def _state(self, tone=0, wounds=None):
return {"affinity": 50, "treasure": [], "claimed": [],
"tone": tone, "wounds": wounds if wounds is not None else []}
def test_tone_accumulates(self):
s = apply_update(self._state(tone=10), '{"affinity_delta": 0, "tone_delta": 5}')
assert s["tone"] == 15
def test_tone_clamped_high(self):
s = apply_update(self._state(tone=98), '{"affinity_delta": 0, "tone_delta": 9}')
assert s["tone"] == 100
def test_tone_clamped_low(self):
s = apply_update(self._state(tone=-95), '{"affinity_delta": 0, "tone_delta": -10}')
assert s["tone"] == -100
def test_old_state_without_tone_key(self):
s = {"affinity": 50, "treasure": [], "claimed": []}
s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -3}')
assert s["tone"] == -3
def test_broken_json_leaves_tone_unchanged(self):
s = apply_update(self._state(tone=7), "not json at all")
assert s["tone"] == 7
class TestApplyUpdateWounds:
def _state(self, wounds=None):
return {"affinity": 50, "treasure": [], "claimed": [],
"tone": 0, "wounds": wounds if wounds is not None else []}
def test_cruel_quote_stored(self):
s = apply_update(self._state(),
'{"affinity_delta": -4, "tone_delta": -7, "cruel_quote": "you bore me"}')
assert s["wounds"] == ["you bore me"]
def test_quote_ignored_on_non_negative_tone(self):
s = apply_update(self._state(),
'{"affinity_delta": 0, "tone_delta": 0, "cruel_quote": "hallucinated"}')
assert s["wounds"] == []
def test_duplicate_quote_not_stored_twice(self):
s = self._state(wounds=["you bore me"])
s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "you bore me"}')
assert s["wounds"] == ["you bore me"]
def test_wounds_capped_at_10_keeping_newest(self):
s = self._state(wounds=[f"insult {i}" for i in range(10)])
s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "the newest"}')
assert len(s["wounds"]) == 10
assert s["wounds"][-1] == "the newest"
assert "insult 0" not in s["wounds"]
class TestApplyUpdatePlusSignTolerance:
def test_plus_signed_integers_parse(self):
s = {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []}
s = apply_update(s, '{"affinity_delta": +6, "new_memories": ["x"], "tone_delta": +3}')
assert s["affinity"] == 56
assert s["tone"] == 3
assert s["treasure"] == ["x"]
def test_plus_inside_memory_text_untouched(self):
s = {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []}
s = apply_update(s, '{"affinity_delta": 2, "new_memories": ["won 1st place at age 10"], "tone_delta": 0}')
assert s["treasure"] == ["won 1st place at age 10"]
class TestSingleGateBranchesByTone:
def test_nothing_fires_below_35(self):
# one gate at 35; no lower threshold can race a warm run
assert decide_ending(_end_state(affinity=34, tone=5)) is None
assert decide_ending(_end_state(affinity=34, tone=30)) is None
def test_gate_at_35_branches_only_by_tone(self):
assert decide_ending(_end_state(affinity=35, tone=20)) == "good"
assert decide_ending(_end_state(affinity=35, tone=19)) == "loop"
class TestHollowWordsFiltered:
def _s(self):
return {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []}
def test_drops_memory_that_echoes_hollows_reply(self):
reply = "i remember a voice, soft like the wind through the trees."
s = apply_update(self._s(),
'{"affinity_delta": 3, "new_memories": ["a voice soft like the wind through the trees"]}',
reply=reply)
assert s["treasure"] == []
def test_keeps_genuine_visitor_memory(self):
reply = "that sounds lovely. tell me more about her."
s = apply_update(self._s(),
'{"affinity_delta": 3, "new_memories": ["had a grandmother who kept bees"]}',
reply=reply)
assert s["treasure"] == ["had a grandmother who kept bees"]
def test_no_reply_keeps_everything(self):
s = apply_update(self._s(),
'{"affinity_delta": 3, "new_memories": ["grew up near the sea"]}')
assert s["treasure"] == ["grew up near the sea"]
class TestStyleSignal:
def test_short_messages_read_as_guarded(self):
from memory import style_signal
assert style_signal([3, 4, 2]) == "short"
def test_long_messages_read_as_pouring_out(self):
from memory import style_signal
assert style_signal([40, 55, 60]) == "long"
def test_mixed_or_empty_is_neutral(self):
from memory import style_signal
assert style_signal([]) is None
assert style_signal([10, 12, 15]) is None
class TestPickAwareMemory:
def test_picks_an_unclaimed_memory_not_the_recall_one(self):
from memory import pick_aware_memory
s = {"treasure": ["a", "b", "c"], "claimed": ["a"], "last_aware_memory": None}
assert pick_aware_memory(s, exclude="b") == "c" # not claimed(a), not excluded(b)
def test_avoids_repeating_the_last_one(self):
from memory import pick_aware_memory
s = {"treasure": ["a", "b"], "claimed": [], "last_aware_memory": "b"}
assert pick_aware_memory(s, exclude=None) == "a"
def test_none_when_nothing_eligible(self):
from memory import pick_aware_memory
s = {"treasure": ["a"], "claimed": ["a"], "last_aware_memory": None}
assert pick_aware_memory(s, exclude=None) is None
def test_first_recall_fires_without_high_affinity():
from memory import should_recall
state = {"affinity": 24, "treasure": ["grew up near the sea"],
"claimed": [], "turn": 1, "last_recall_turn": None}
do, mem = should_recall(state)
assert do is True and mem == "grew up near the sea" # wow by turn ~2, low bond
def test_recall_still_waits_one_turn_between():
from memory import should_recall
state = {"affinity": 60, "treasure": ["a", "bb", "ccc"], "claimed": ["a"],
"turn": 3, "last_recall_turn": 3}
assert should_recall(state)[0] is False # same turn as last recall
state["turn"] = 4
assert should_recall(state)[0] is True # 1 turn gap >= tester cooldown
def test_good_ending_reachable_at_lower_affinity():
from memory import decide_ending
s = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25,
"wounds": [], "ended": False}
assert decide_ending(s) == "good" # warm, ~turn 6 -> redemption
def test_loop_when_warmth_missing_at_gate():
from memory import decide_ending
s = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 8,
"wounds": [], "ended": False}
assert decide_ending(s) == "loop"
def test_bad_ending_fires_by_turn_four():
from memory import decide_ending
s = {"affinity": 12, "claimed": [], "turn": 4, "tone": -32,
"wounds": ["w1", "w2"], "ended": False}
assert decide_ending(s) == "bad"
def test_full_mode_recall_waits_for_two_memories():
from memory import should_recall
one = {"affinity": 30, "treasure": ["a"], "claimed": [], "turn": 1,
"last_recall_turn": None, "mode": "full"}
assert should_recall(one)[0] is False # full needs >=2 before first recall
two = dict(one, treasure=["a", "bb"])
assert should_recall(two)[0] is True
def test_full_mode_ending_gate_is_higher():
from memory import decide_ending
mid = {"affinity": 55, "claimed": ["a", "b"], "turn": 8, "tone": 25,
"wounds": [], "ended": False, "mode": "full"}
assert decide_ending(mid) is None # 55 < full gate 68
hi = dict(mid, affinity=70, claimed=["a", "b", "c"])
assert decide_ending(hi) == "good"
def test_tester_mode_unchanged():
from memory import should_recall, decide_ending
s = {"affinity": 24, "treasure": ["x"], "claimed": [], "turn": 1,
"last_recall_turn": None, "mode": "tester"}
assert should_recall(s)[0] is True # tester: first memory -> recall
e = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25,
"wounds": [], "ended": False, "mode": "tester"}
assert decide_ending(e) == "good"