fix: end banner starts a new layout; outcome colour
Browse files- lantern/bench.py +104 -65
- web/app.js +7 -2
- web/index.html +1 -1
lantern/bench.py
CHANGED
|
@@ -1,65 +1,104 @@
|
|
| 1 |
-
"""Benchmark series: play one level across seeds, optionally carrying the journal forward."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
from .
|
| 10 |
-
from .
|
| 11 |
-
from .
|
| 12 |
-
from .
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark series: play one level across seeds, optionally carrying the journal forward."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
from .agent import EpisodeConfig, run_episode
|
| 10 |
+
from .env import TASKS, DungeonEnv
|
| 11 |
+
from .journal import merge_lessons
|
| 12 |
+
from .llm import ChatClient
|
| 13 |
+
from .store import EpisodeStore
|
| 14 |
+
|
| 15 |
+
SEED_BASE = 1000 # benchmark seeds stay clear of the low seeds visitors try in the Space
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def bench_seeds(n: int) -> list[int]:
|
| 19 |
+
return [SEED_BASE + i for i in range(n)]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def play_series(task_id: str, seeds: list[int], use_lessons: bool, tag: str,
|
| 23 |
+
client: ChatClient, store: EpisodeStore, source: str = "bench",
|
| 24 |
+
stop: threading.Event | None = None) -> list[dict]:
|
| 25 |
+
"""Play `seeds` in order on one level and save every episode; returns per-episode summaries.
|
| 26 |
+
|
| 27 |
+
Each series starts with an empty journal; with `use_lessons`, lessons written after
|
| 28 |
+
each episode are merged in before the next seed. Setting `stop` (e.g. when a spending
|
| 29 |
+
cap is hit) ends the current episode as "stopped" and skips the remaining seeds.
|
| 30 |
+
"""
|
| 31 |
+
config = EpisodeConfig(use_lessons=use_lessons, reflect=use_lessons)
|
| 32 |
+
lessons: list[str] = []
|
| 33 |
+
summaries = []
|
| 34 |
+
for seed in seeds:
|
| 35 |
+
if stop is not None and stop.is_set():
|
| 36 |
+
break
|
| 37 |
+
env = DungeonEnv(TASKS[task_id], seed=seed)
|
| 38 |
+
try:
|
| 39 |
+
events = list(run_episode(env, client, lessons, config, stop))
|
| 40 |
+
except Exception as exc: # one bad episode must not sink the series
|
| 41 |
+
events = [{"type": "error", "message": f"{type(exc).__name__}: {exc}"}]
|
| 42 |
+
finally:
|
| 43 |
+
env.close()
|
| 44 |
+
end = next((e for e in events if e["type"] == "end"), {})
|
| 45 |
+
lessons_in = len(lessons)
|
| 46 |
+
if use_lessons and end.get("new_lessons"):
|
| 47 |
+
lessons = merge_lessons(lessons, end["new_lessons"], 12)
|
| 48 |
+
record = {"id": uuid.uuid4().hex[:12], "task": task_id, "seed": seed, "source": source,
|
| 49 |
+
"bench": tag, "model": client.model, "use_lessons": use_lessons,
|
| 50 |
+
"finished_at": time.time(), "outcome": end.get("outcome", "error"),
|
| 51 |
+
"steps": end.get("steps", 0), "lessons_in": lessons_in, "events": events}
|
| 52 |
+
store.save(record, wait=True)
|
| 53 |
+
summaries.append({k: record[k] for k in ("id", "task", "seed", "use_lessons", "outcome", "steps")}
|
| 54 |
+
| {"usage": end.get("usage", {})})
|
| 55 |
+
return summaries
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class BudgetedClient:
|
| 59 |
+
"""Wraps a ChatClient and stops all parallel series once shared token spend hits a cap.
|
| 60 |
+
|
| 61 |
+
Each series records only its own running total under `key` in a shared mapping
|
| 62 |
+
(a Modal Dict in production), so concurrent writers never overwrite each other; the
|
| 63 |
+
cap is checked against the sum over `all_keys` after every call.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(self, client: ChatClient, shared, key: str, all_keys: list[str],
|
| 67 |
+
cap_tokens: int, stop: threading.Event):
|
| 68 |
+
self.client, self.shared, self.key, self.all_keys = client, shared, key, all_keys
|
| 69 |
+
self.cap_tokens, self.stop = cap_tokens, stop
|
| 70 |
+
self.used = 0
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def model(self) -> str:
|
| 74 |
+
return self.client.model
|
| 75 |
+
|
| 76 |
+
def total(self) -> int:
|
| 77 |
+
return sum(self.shared.get(k, 0) for k in self.all_keys)
|
| 78 |
+
|
| 79 |
+
def chat(self, *args, **kwargs):
|
| 80 |
+
if self.total() >= self.cap_tokens:
|
| 81 |
+
self.stop.set()
|
| 82 |
+
reply = self.client.chat(*args, **kwargs)
|
| 83 |
+
self.used += reply.usage.get("prompt_tokens", 0) + reply.usage.get("completion_tokens", 0)
|
| 84 |
+
self.shared[self.key] = self.used
|
| 85 |
+
if self.total() >= self.cap_tokens:
|
| 86 |
+
self.stop.set()
|
| 87 |
+
return reply
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def summary_table(results: list[dict], task_ids: list[str], conditions: list[str]) -> str:
|
| 91 |
+
"""Plain-text results table: wins, average steps, and tokens per level and condition."""
|
| 92 |
+
lines = [f"{'level':14}{'journal':>8}{'won':>8}{'avg steps':>11}{'tokens':>12}"]
|
| 93 |
+
for t in task_ids:
|
| 94 |
+
for cond in conditions:
|
| 95 |
+
# Episodes cut off by the spending cap are neither wins nor losses.
|
| 96 |
+
rows = [r for r in results if r["task"] == t and r["use_lessons"] == (cond == "on")
|
| 97 |
+
and r["outcome"] != "stopped"]
|
| 98 |
+
if not rows:
|
| 99 |
+
continue
|
| 100 |
+
won = sum(r["outcome"] == "success" for r in rows)
|
| 101 |
+
steps = sum(r["steps"] for r in rows) / len(rows)
|
| 102 |
+
tok = sum(r["usage"].get("prompt_tokens", 0) + r["usage"].get("completion_tokens", 0) for r in rows)
|
| 103 |
+
lines.append(f"{t:14}{cond:>8}{won:>4}/{len(rows):<3}{steps:>11.1f}{tok:>12,}")
|
| 104 |
+
return "\n".join(lines)
|
web/app.js
CHANGED
|
@@ -272,7 +272,8 @@ function renderEnding() {
|
|
| 272 |
if (!end || !atEnd || state.replayTimer) { box.hidden = true; return; }
|
| 273 |
const [cls, label] = OUTCOMES[end.outcome] || ["", end.outcome];
|
| 274 |
$("ending-title").textContent = label;
|
| 275 |
-
$("ending-title").className = cls;
|
|
|
|
| 276 |
$("ending-detail").textContent = `${end.steps} steps, ${end.usage.calls} Beam calls.`;
|
| 277 |
box.hidden = false;
|
| 278 |
}
|
|
@@ -436,7 +437,11 @@ $("controls").addEventListener("submit", startRun);
|
|
| 436 |
$("stop").addEventListener("click", stopRun);
|
| 437 |
$("follow").addEventListener("click", () => select(state.turns.length - 1, true));
|
| 438 |
$("again").addEventListener("click", () => { if (state.episodeId) openReplay(state.episodeId); });
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
$("task").addEventListener("change", () => { if (!state.live && !startEvent()) showGoal(); });
|
| 441 |
$("timeline").addEventListener("keydown", (e) => {
|
| 442 |
if (e.key === "ArrowLeft") { stopReplay(); select(state.selected - 1, false); e.preventDefault(); }
|
|
|
|
| 272 |
if (!end || !atEnd || state.replayTimer) { box.hidden = true; return; }
|
| 273 |
const [cls, label] = OUTCOMES[end.outcome] || ["", end.outcome];
|
| 274 |
$("ending-title").textContent = label;
|
| 275 |
+
$("ending-title").className = `ending-title ${cls}`;
|
| 276 |
+
$("new-run").disabled = !!state.liveBlocked || !!state.live;
|
| 277 |
$("ending-detail").textContent = `${end.steps} steps, ${end.usage.calls} Beam calls.`;
|
| 278 |
box.hidden = false;
|
| 279 |
}
|
|
|
|
| 437 |
$("stop").addEventListener("click", stopRun);
|
| 438 |
$("follow").addEventListener("click", () => select(state.turns.length - 1, true));
|
| 439 |
$("again").addEventListener("click", () => { if (state.episodeId) openReplay(state.episodeId); });
|
| 440 |
+
// Start right away on the same level with the next layout, so it's a fresh dungeon.
|
| 441 |
+
$("new-run").addEventListener("click", () => {
|
| 442 |
+
$("seed").value = String((Number($("seed").value) || 0) + 1);
|
| 443 |
+
$("controls").requestSubmit();
|
| 444 |
+
});
|
| 445 |
$("task").addEventListener("change", () => { if (!state.live && !startEvent()) showGoal(); });
|
| 446 |
$("timeline").addEventListener("keydown", (e) => {
|
| 447 |
if (e.key === "ArrowLeft") { stopReplay(); select(state.selected - 1, false); e.preventDefault(); }
|
web/index.html
CHANGED
|
@@ -46,7 +46,7 @@
|
|
| 46 |
<p class="ending-detail" id="ending-detail"></p>
|
| 47 |
<div class="ending-actions">
|
| 48 |
<button class="secondary" id="again" type="button">Watch again</button>
|
| 49 |
-
<button class="primary" id="new-run" type="button">
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
<p class="empty" id="empty">No runs yet. Pick a level and press <em>Watch Beam explore!</em></p>
|
|
|
|
| 46 |
<p class="ending-detail" id="ending-detail"></p>
|
| 47 |
<div class="ending-actions">
|
| 48 |
<button class="secondary" id="again" type="button">Watch again</button>
|
| 49 |
+
<button class="primary" id="new-run" type="button">Try a new layout</button>
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
<p class="empty" id="empty">No runs yet. Pick a level and press <em>Watch Beam explore!</em></p>
|