Spaces:
Running on Zero
Running on Zero
| """Memory is a budget: assembled context must respect it, and compaction must trigger when | |
| recent turns pile up.""" | |
| from __future__ import annotations | |
| from visualnovel import config, memory | |
| from visualnovel.schemas import Character, GameState, Scene, Turn | |
| def _state(n_turns: int = 0) -> GameState: | |
| s = GameState( | |
| seed=1, | |
| vibe="v", | |
| style_guide="s", | |
| scene=Scene(id="a", place="A", description="d", present=["moth"]), | |
| characters={"moth": Character(id="moth", name="Moth", one_line="a moth")}, | |
| ) | |
| for i in range(n_turns): | |
| s.recent_turns.append(Turn(player=f"p{i}", speaker="moth", dialogue=f"d{i}")) | |
| return s | |
| def test_context_includes_present_and_input(): | |
| ctx = memory.assemble_context(_state(2), "where am I?") | |
| assert "Moth" in ctx | |
| assert "where am I?" in ctx | |
| assert "SPIRITS ON STAGE" in ctx | |
| def test_context_respects_budget(monkeypatch): | |
| monkeypatch.setattr(config, "CONTEXT_TOKEN_BUDGET", 50) # 50 tokens ≈ 200 chars | |
| ctx = memory.assemble_context(_state(40), "x" * 500) | |
| assert len(ctx) <= 50 * 4 + len("…(earlier dream trimmed)…\n\n") | |
| def test_player_name_in_context(): | |
| s = _state(1) | |
| s.flags["player_name"] = "Will" | |
| ctx = memory.assemble_context(s, "hello") | |
| assert 'The player is called "Will"' in ctx | |
| assert 'WILL NOW SAYS: "hello"' in ctx | |
| # without a name, the classic label is kept | |
| ctx_anon = memory.assemble_context(_state(1), "hello") | |
| assert 'THE WANDERER NOW SAYS: "hello"' in ctx_anon | |
| def test_internal_flags_hidden_from_facts(): | |
| s = _state(1) | |
| s.flags.update( | |
| { | |
| "player_name": "Will", | |
| "beat_started_turn": "3", | |
| "milestone50_moth": "done", | |
| "situation_intro": "intro", | |
| "found_key": "true", # a real story fact stays visible | |
| } | |
| ) | |
| ctx = memory.assemble_context(s, "x") | |
| assert "FACTS: found_key=true" in ctx | |
| for hidden in ("beat_started_turn", "milestone50_moth", "situation_intro=", "player_name="): | |
| assert hidden not in ctx | |
| def test_pacing_nudge_after_lingering_beat(): | |
| s = _state(1) | |
| s.turn_index = 8 # 8 turns since beat_started_turn=0 | |
| assert "PACING:" in memory.assemble_context(s, "x") | |
| s.turn_index = 3 | |
| assert "PACING:" not in memory.assemble_context(s, "x") | |
| s.turn_index = 12 | |
| s.beat = "ended" | |
| assert "PACING:" not in memory.assemble_context(s, "x") | |
| def test_should_compact(): | |
| assert memory.should_compact(_state(2 * config.RECENT_TURNS_K + 1)) is True | |
| assert memory.should_compact(_state(2 * config.RECENT_TURNS_K)) is False | |
| assert memory.should_compact(_state(1)) is False | |
| def test_trim_drops_oldest_recent_first(monkeypatch): | |
| # Budget large enough for the head + a few turns, too small for all 30 turns: | |
| # the oldest exchanges must go first, the head and the player line must survive. | |
| monkeypatch.setattr(config, "CONTEXT_TOKEN_BUDGET", 300) # ≈ 1200 chars | |
| ctx = memory.assemble_context(_state(30), "where am I?") | |
| assert "WORLD STYLE" in ctx | |
| assert "SPIRITS ON STAGE" in ctx | |
| assert "where am I?" in ctx | |
| assert "…(earlier exchange trimmed)…" in ctx | |
| assert '"d0"' not in ctx # oldest turn dropped | |
| assert '"d29"' in ctx # newest turn kept | |