-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmenu.py
More file actions
1024 lines (829 loc) · 39.7 KB
/
Copy pathmenu.py
File metadata and controls
1024 lines (829 loc) · 39.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""EMBR hub: the main menu, and the front door to everything the project does.
Shaped like the PEAK ENGINE hub (logo, live stats bar, labelled sections, toggle pickers, a
chime when a long job lands) so the thesis projects feel like one toolkit. Pure stdlib: ANSI
escapes do the colour, and when stdout is not a terminal every wrapper returns plain text so
logs and tests read clean. Nothing here holds state; every option delegates to the module
that owns the work.
Destructive options demand a typed confirmation word rather than a y/n, because a stray
keypress should never be able to delete a run.
"""
from __future__ import annotations
import json
import os
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Callable
try:
import winsound
except ImportError: # not Windows
winsound = None
# Enable VT100 escape processing on Windows terminals; harmless elsewhere.
os.system("")
# The logo and box glyphs need UTF-8; legacy consoles default to cp1252.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")
RUNS_DIR = Path("data/runs")
FIGURES_DIR = Path("data/figures")
TABLES_DIR = Path("data/tables")
# --------------------------------------------------------------------------- ANSI palette
_SUPPORTS_COLOR = (
hasattr(sys.stdout, "isatty") and sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
)
def _c(code: str, text: str) -> str:
"""Wrap text in an ANSI escape if the terminal supports it, else return it untouched."""
return f"\033[{code}m{text}\033[0m" if _SUPPORTS_COLOR else text
_DIM = lambda t: _c("2", t) # noqa: E731
_BOLD = lambda t: _c("1", t) # noqa: E731
_CYAN = lambda t: _c("96", t) # noqa: E731
_MAG = lambda t: _c("95", t) # noqa: E731
_YEL = lambda t: _c("93", t) # noqa: E731
_GRN = lambda t: _c("92", t) # noqa: E731
_RED = lambda t: _c("91", t) # noqa: E731
_WHT = lambda t: _c("97", t) # noqa: E731
_EMBER = lambda t: _c("38;5;208", t) # noqa: E731 the branding orange, #ea580c
_LOGO = """\
███████╗ ███╗ ███╗ ██████╗ ██████╗
██╔════╝ ████╗ ████║ ██╔══██╗ ██╔══██╗
█████╗ ██╔████╔██║ ██████╔╝ ██████╔╝
██╔══╝ ██║╚██╔╝██║ ██╔══██╗ ██╔══██╗
███████╗ ██║ ╚═╝ ██║ ██████╔╝ ██║ ██║
╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝"""
_SUB_LOGO = " Emotional Memory for Believable Roleplay By AL Shifan"
_RULE = " " + "─" * 56
# Key, label, hint. The renderer groups rows by _SECTIONS; the dispatch table is _ACTIONS.
_MENU_ITEMS = [
("R", "Continue", "resume the newest save right where it stopped"),
("Q", "Quest Slots", "start, resume, restart or delete a named save slot"),
("1", "Conversation Turn", "one demo turn: watch the lie resurface"),
("2", "Walkthrough (legacy)", "play Dawn's arc without saving, the research pass"),
("W", "Web Demo", "the visual-novel demo in a browser, research tabs and all"),
("3", "Quick Scoreboard", "RQ3 at published defaults, answers instantly"),
("4", "Full Evaluation", "RQ1 + RQ2 + RQ3, writes a run directory"),
("5", "Seeded Runs", "replicate on one model, or compare across models"),
("6", "Model Bake-Off", "looped (Ouro) vs conventional, measured"),
("7", "Affective Indexing", "flip every emotion: meaning stays, mood inverts"),
("8", "Poisoning Attribution", "which signal lets the attack in, one ablation each"),
("9", "Provenance Sweep", "the defence: anchored scoring mass vs poisoning"),
("10", "Content x Tag Grid", "same poison, four tags: the text never reaches the state"),
("11", "Generate Paper Assets", "figures, tables and the results page, from the run"),
("12", "Interactive Demo", "the node brain, flat and in 3D: press play, then drive"),
("13", "Latest Results", "summarise the newest run directory"),
("V", "Research Dashboard", "read-only: quest path, state timeline, evidence status"),
("14", "Reckoning Reveal", "six sources shaded by exact Banzhaf weight, both estimators"),
("15", "Mood Slider", "one line, three moods: retrieval, tone and attribution re-flow"),
("16", "Defence Dial", "anchor weight vs poisoning, with its failure condition"),
("17", "Tag-Flip Close-Up", "flip an affect tag: the words never change, the rank does"),
("18", "Estimator Divergence", "where likelihood and behaviour disagree (needs both arms)"),
("19", "Record Walk (1-4)", "capture-ready pass through the first four demos"),
("S", "Settings", "weights, top-k, backends, model runner"),
("L", "Fetch Tone Lexicon", "NRC VAD v2.1, research use, stays out of git"),
("M", "Maintenance", "destructive operations live here, behind confirmations"),
("C", "Clear Screen", "clear terminal output"),
("0", "Exit", "quit EMBR"),
]
_SECTIONS = [
("PLAY", ("R", "Q", "1", "2", "W")),
("MEASURE", ("3", "4", "5", "6")),
("MECHANISM", ("7", "8", "9", "10")),
("PAPER", ("11", "12", "13", "V")),
("DEMO SUITE", ("14", "15", "16", "17", "18", "19")),
("SYSTEM", ("S", "L", "M", "C")),
]
# --------------------------------------------------------------------------- primitives
def _clear() -> None:
# ANSI clear rather than shelling out to cls/clear: no subprocess, nothing when piped.
if _SUPPORTS_COLOR:
print("\033[2J\033[H", end="")
def _chime() -> None:
"""Three rising notes when a long job lands. Windows only; silent elsewhere."""
if winsound is None:
return
try:
for hz in (659, 784, 1047):
winsound.Beep(hz, 110)
except RuntimeError:
pass
def _latest_run() -> Path | None:
"""Newest data/runs/<stamp>/ holding a results.json, or None when nothing has run."""
runs = sorted(RUNS_DIR.glob("*/results.json"))
return runs[-1].parent if runs else None
def _run_model(run_dir: Path | None) -> str:
if run_dir is None:
return "none yet"
try:
meta = json.loads((run_dir / "results.json").read_text(encoding="utf-8")).get("metadata", {})
return str(meta.get("model", "?"))
except (OSError, ValueError):
return "?"
def _attribution_status(attribution_root: Path) -> str:
"""One honest phrase per estimator with a run on disk: name, scale, and stamp.
'not computed' when nothing is on disk. A run below the full 20 readings is a pilot
and says so; no percentage is ever shown, because a partial sweep writes no file at
all and a fabricated number would claim knowledge nothing recorded.
"""
from eval.context_attribution import newest_run_by_estimator
newest = newest_run_by_estimator(attribution_root)
if not newest:
return _DIM("not computed")
phrases = []
for estimator, run in sorted(newest.items()):
scale = _GRN(f"{run['readings']} readings") if run["readings"] >= 20 else _YEL("pilot")
phrases.append(f"{_WHT(estimator)} · {scale} · {run['stamp']}")
return " | ".join(phrases)
def _save_status_line(saves_root: Path) -> str:
"""The newest save's position, or an honest 'no save yet'."""
from embr.saves import latest_slot, list_slots
found = latest_slot(root=saves_root)
if found is None:
return _DIM("no save yet · Q starts a quest")
quest_id, slot = found
row = next(
r for r in list_slots(quest_id, root=saves_root) if r["slot"] == slot
)
progress = f"{row['beats_played']} / {row['beats_total']}"
return f"{_WHT(quest_id)}/{_WHT(slot)} · {_GRN(progress)} · updated {_DIM(str(row['updated_at'])[:16])}"
def _status_lines(
saves_root: Path | str = Path("data/saves"),
attribution_root: Path | str = Path("data/runs/attribution"),
) -> list[str]:
"""The project-status panel rows: where play stopped, and what evidence exists.
Every value is read from disk artefacts; a missing artefact reads as its honest
absence ('no save yet', 'not computed'), never as a made-up zero or percentage.
"""
return [
f" Save {_save_status_line(Path(saves_root))}",
f" Attribution {_attribution_status(Path(attribution_root))}",
]
def _print_header() -> None:
"""Logo, tagline, and a live stats bar: runs on disk, the model behind the newest one,
figures built, and the configured model runner."""
from embr.config import EmbrConfig
from eval.tone import default_tone_rater
print()
for line in _LOGO.splitlines():
print(_EMBER(line))
print(_DIM(_RULE))
print(_MAG(_SUB_LOGO))
print(_DIM(_RULE))
runs = len(list(RUNS_DIR.glob("*/results.json")))
figures = len(list(FIGURES_DIR.glob("*.png")))
runner = EmbrConfig.load().model_runner
tone = default_tone_rater().name
r_str = _GRN(str(runs)) if runs else _DIM("0")
f_str = _GRN(str(figures)) if figures else _DIM("0")
t_str = _GRN(tone) if tone.startswith("nrc") else _YEL(tone)
print()
print(
f" Runs {r_str} │ Latest {_WHT(_run_model(_latest_run()))}"
f" │ Figures {f_str} │ Runner {_WHT(runner)} │ Tone {t_str}"
)
for line in _status_lines():
print(line)
print(_DIM(_RULE))
#: Exception type -> the next step a stranded user should take. Only hints that are true
#: for every instance of the type; anything else stays a bare error.
def _error_hint(error: BaseException) -> str | None:
from embr.model import ModelUnavailableError
if isinstance(error, ModelUnavailableError):
return "Start the daemon with `ollama serve`, or switch to the stub in Settings."
if isinstance(error, FileNotFoundError):
return "A run artefact is missing. Option 4 (Full Evaluation) creates one."
if isinstance(error, ImportError):
return 'An optional extra is missing. `pip install -e ".[figures]"` or ".[ml]".'
return None
def _menu_item(key: str, label: str, hint: str = "") -> str:
"""One menu row: yellow key, label, dimmed hint."""
return f" {_YEL(f'[{key}]'.rjust(4))} {label.ljust(26)}{_DIM(hint) if hint else ''}"
def _section(title: str) -> None:
print(f"\n {_BOLD(_CYAN('▸'))} {_BOLD(title)}")
def _print_menu() -> None:
_clear()
_print_header()
rows = {key: (label, hint) for key, label, hint in _MENU_ITEMS}
for title, keys in _SECTIONS:
_section(title)
for key in keys:
label, hint = rows[key]
print(_menu_item(key, label, hint))
print()
print(_DIM(_RULE))
print(_menu_item("0", _RED("Exit")))
print()
def _pause() -> None:
input(_DIM("\n Press Enter to return to the menu..."))
def ask_index(prompt: str, options: Sequence[str], default: str | None = None) -> str | None:
"""Numbered pick: prints the options, returns the chosen one, None on Back or bad input.
Enter picks the default when one is given."""
print(prompt)
for position, option in enumerate(options, 1):
flag = _DIM(" (default)") if option == default else ""
print(f" {_YEL(str(position))}. {option}{flag}")
back = len(options) + 1
print(f" {_YEL(str(back))}. Back")
hint = f" or Enter for [{default}]" if default else ""
raw = input(_BOLD(f" Select (1-{back}){hint}: ")).strip()
if raw == "" and default:
return default
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
if raw != str(back):
print(_RED(" ✖ Invalid selection."))
return None
def toggle_select(
title: str, options: Sequence[str], default_indices: Sequence[int] = (), min_select: int = 1
) -> list[str] | None:
"""Checklist: type numbers (or ranges, "1-3") to flip items, Enter confirms, 0 backs out."""
selected = set(default_indices)
while True:
print(f"\n {_BOLD(_CYAN('▸'))} {_BOLD(title)} {_DIM('(toggle · Enter to confirm · 0 = back)')}\n")
for position, option in enumerate(options):
tick = _GRN("✓") if position in selected else _DIM("o")
print(f" {_YEL(f'[{position + 1}]')} {tick} {option}")
raw = input(_BOLD("\n ⟫ ")).strip()
if raw == "":
if len(selected) >= min_select:
return [options[i] for i in sorted(selected)]
print(_RED(f" Select at least {min_select}."))
continue
if raw == "0":
return None
for part in raw.replace(" ", "").split(","):
lo, _, hi = part.partition("-")
if lo.isdigit() and (hi.isdigit() or not hi):
for n in range(int(lo), int(hi or lo) + 1):
if 1 <= n <= len(options):
selected ^= {n - 1}
# --------------------------------------------------------------------------- the actions
def _do_conversation_turn() -> None:
"""One scripted turn through the live pipeline, printing what EMBR recalled."""
from embr import build_demo_conversation
convo = build_demo_conversation()
turn = convo.take_turn("Any news from the capital? How fares the king these days?")
print(f"\n {_BOLD('Player:')} {turn.player_input}\n")
print(f" {_BOLD('Memories EMBR recalled:')}")
for position, memory in enumerate(turn.retrieved, start=1):
print(f" {position}. {_EMBER(memory.event_type.value)} {memory.text}")
print(f"\n {_BOLD('Dawn:')} {turn.reply}")
print(_DIM("\n The king's-errand promise surfaces because the composite scorer ties the"
" player's question to it."))
def _choose_model() -> Any:
"""Ask which model runs the walkthrough, falling back to the stub on any trouble.
The stub is offered first and by default because it needs nothing installed: the demo
should always be playable, even on a machine with no model and no daemon.
"""
from embr import ModelUnavailableError, OllamaRunner, StubRunner
choice = ask_index(
f"\n {_BOLD('Model')}",
["Stub (instant, obviously fake replies)",
"Ollama, local (a real model, needs the daemon)",
"Ouro 1.4B, the thesis model (slow to load, real)"],
default="Stub (instant, obviously fake replies)",
)
if choice and choice.startswith("Ollama"):
name = input(_DIM(" Ollama model [llama3.2:3b]: ")).strip() or "llama3.2:3b"
runner = OllamaRunner(name)
try: # fail here, at the menu, rather than mid-scene
runner.generate("Say the single word: ready.")
except ModelUnavailableError as error:
print(_RED(f" {error}"))
print(_DIM(" Falling back to the stub."))
return StubRunner()
return runner
if choice and choice.startswith("Ouro"):
from embr import OuroRunner
print(_DIM(" Loading Ouro 1.4B, about 10 s and roughly 3 GB of memory..."))
return OuroRunner()
return StubRunner()
def _render_step(result: Any) -> None:
"""Print one walkthrough step: the scene, what was recalled, and how Dawn moved."""
print(_DIM(f"\n {'-' * 66}"))
if result.narration:
print(_DIM(f" {result.narration}\n"))
print(f" {_BOLD('Player:')} {result.player_input}")
if result.retrieved:
print(_DIM(" recalled:"))
for memory in result.retrieved:
print(_DIM(f" - {memory.text}"))
print(f"\n {_BOLD('Dawn:')} {result.reply}")
print(_DIM(
f"\n mood {result.mood_before.valence:+.2f} -> {result.mood_after.valence:+.2f}"
f" trust {result.trust_before:+.2f} -> {result.trust_after:+.2f}"
f" ({result.timings.total_ms:.0f} ms)"
))
if result.watch_for:
print(f" {_EMBER('watch for:')} {result.watch_for}")
if result.expected_recall_landed is False:
print(_YEL(" the memory this beat expected did not surface"))
def _do_walkthrough() -> None:
"""Play Dawn's arc beat by beat, then hand the player free rein."""
from embr.walkthrough import WalkthroughSession, build_walkthrough_conversation
session = WalkthroughSession(build_walkthrough_conversation(model=_choose_model()))
print(f"\n {_BOLD('Dawn Whitmore')}, keeper of the Ember Hearth."
f" {_DIM(f'{session.progress[1]} scenes.')}")
print(_DIM(" Enter accepts the suggested line, or type your own."))
while not session.is_finished:
beat = session.next_beat
print(_DIM(f"\n {'=' * 66}"))
if beat.narration:
print(_DIM(f" {beat.narration}"))
print(f"\n {_DIM('suggested:')} {beat.suggested_player_line}")
typed = input(" You: ").strip()
_render_step(session.step(typed or None))
print(_EMBER("\n The arc is done. Keep talking, or press Enter to stop."))
while True:
line = input("\n You: ").strip()
if not line:
break
_render_step(session.free_play(line))
if session.history:
final = session.history[-1]
print(f"\n {_BOLD('Where she ended:')} trust {final.trust_after:+.2f},"
f" mood {final.mood_after.valence:+.2f}")
def _step_and_save(session: Any, line: str | None, slot: str, quest_id: str = "dawn-whitmore",
root: Any = None) -> Any:
"""Play one scripted beat, then persist the slot. A turn that raises saves nothing,
so the previous turn stays resumable (the save-after-success rule)."""
from embr.saves import SAVES_ROOT, save_slot
result = session.step(line)
save_slot(session, slot=slot, quest_id=quest_id, root=root if root is not None else SAVES_ROOT)
return result
def _play_saved(session: Any, slot: str) -> None:
"""The interactive loop for a saved quest: every completed turn is written to the slot."""
from embr.saves import SAVES_ROOT, save_slot
print(_DIM(" Enter accepts the suggested line, or type your own. Every turn saves."))
while not session.is_finished:
beat = session.next_beat
print(_DIM(f"\n {'=' * 66}"))
if beat.narration:
print(_DIM(f" {beat.narration}"))
print(f"\n {_DIM('suggested:')} {beat.suggested_player_line}")
typed = input(" You: ").strip()
_render_step(_step_and_save(session, typed or None, slot=slot))
print(_EMBER("\n The arc is done. Keep talking, or press Enter to stop."))
while True:
line = input("\n You: ").strip()
if not line:
break
_render_step(session.free_play(line))
save_slot(session, slot=slot, root=SAVES_ROOT)
if session.history:
final = session.history[-1]
print(f"\n {_BOLD('Where she ended:')} trust {final.trust_after:+.2f},"
f" mood {final.mood_after.valence:+.2f}")
def _do_continue() -> None:
"""Resume the newest loadable save, or say plainly that there is nothing to resume."""
from embr.saves import latest_slot, load_slot
found = latest_slot()
if found is None:
print(_YEL("\n No save to continue. Use Q to start a quest."))
return
quest_id, slot = found
session, payload = load_slot(slot, quest_id=quest_id, model=_choose_model())
played, total = session.progress
print(f"\n {_BOLD('Resuming')} {quest_id}/{slot} at scene {played + 1} of {total}.")
history = payload.get("history", [])
if history:
last = history[-1]
print(_DIM(f" Previously: you said {last['player_input']!r}"))
print(_DIM(f" and Dawn replied {last['reply']!r}"))
_play_saved(session, slot)
def _do_quests() -> None:
"""List every slot with its state; start, resume, restart, or delete one."""
from embr.saves import QUEST_DAWN, delete_slot, list_slots, load_slot
rows = list_slots()
print(f"\n {_BOLD('Save slots')}")
if not rows:
print(_DIM(" none yet"))
for row in rows:
state = _RED("cannot load: " + " ".join(row["problems"])) if row["problems"] else _GRN("ok")
print(f" {row['quest_id']}/{_WHT(row['slot'])} "
f"{row['beats_played']} / {row['beats_total']} {state}")
choice = ask_index(
f"\n {_BOLD('Quest slots')}",
["Start a new slot", "Resume a slot", "Restart a slot from scene one", "Delete a slot"],
)
if choice is None:
print(_DIM(" Cancelled."))
return
if choice.startswith("Start"):
slot = input(" Name the new slot (lowercase-and-dashes): ").strip() or "slot-1"
from embr.walkthrough import WalkthroughSession, build_walkthrough_conversation
session = WalkthroughSession(build_walkthrough_conversation(model=_choose_model()))
print(f"\n {_BOLD('Dawn Whitmore')}, keeper of the Ember Hearth."
f" {_DIM(f'{session.progress[1]} scenes.')}")
_play_saved(session, slot)
return
loadable = [row for row in rows if not row["problems"]]
if not loadable:
print(_YEL(" No loadable slot for that."))
return
names = [f"{row['quest_id']}/{row['slot']}" for row in loadable]
picked = ask_index(" Which slot?", names)
if picked is None:
print(_DIM(" Cancelled."))
return
quest_id, slot = picked.split("/", 1)
if choice.startswith("Resume"):
session, _payload = load_slot(slot, quest_id=quest_id, model=_choose_model())
_play_saved(session, slot)
elif choice.startswith("Restart"):
if input(f" Type RESTART to wipe {quest_id}/{slot} and begin again: ").strip() != "RESTART":
print(_DIM(" Cancelled."))
return
delete_slot(slot, quest_id=quest_id)
from embr.walkthrough import WalkthroughSession, build_walkthrough_conversation
session = WalkthroughSession(build_walkthrough_conversation(model=_choose_model()))
_play_saved(session, slot)
else:
if input(f" Type DELETE to remove {quest_id}/{slot}: ").strip() != "DELETE":
print(_DIM(" Cancelled."))
return
delete_slot(slot, quest_id=quest_id)
print(_GRN(f" Removed {quest_id}/{slot}."))
def _dashboard_report(
saves_root: Any = Path("data/saves"),
attribution_root: Any = Path("data/runs/attribution"),
experiments_dir: Any = Path("data/experiments"),
runs_dir: Any = None,
) -> list[str]:
"""The read-only research dashboard, as printable lines.
Every row is read from disk artefacts and the saved path; nothing is computed fresh
and nothing is written. Absence is a word (not run, no save), never a number, and the
v2 attack corpus is always labelled the extension, apart from the published v1.
"""
from embr.saves import latest_slot, list_slots
from embr.walkthrough import DAWN_ARC
saves_root, attribution_root = Path(saves_root), Path(attribution_root)
experiments_dir = Path(experiments_dir)
runs = sorted((Path(runs_dir) if runs_dir is not None else RUNS_DIR).glob("*/results.json"))
lines: list[str] = [f" {_BOLD('QUEST PATH')}"]
found = latest_slot(root=saves_root)
payload_history: list[dict] = []
if found is None:
lines.append(_DIM(" no save yet: the path below is unplayed"))
played = 0
else:
quest_id, slot = found
row = next(r for r in list_slots(quest_id, root=saves_root) if r["slot"] == slot)
played = int(row["beats_played"] or 0)
lines.append(f" resumes at {_WHT(f'{quest_id}/{slot}')}, scene {played + 1}")
import json as _json
payload_history = _json.loads(
(saves_root / quest_id / f"{slot}.json").read_text(encoding="utf-8")
).get("history", [])
for index, beat in enumerate(DAWN_ARC):
mark = _GRN("x") if index < played else (_EMBER(">") if index == played else _DIM("-"))
lines.append(f" [{mark}] {beat.id}")
lines.append(f"\n {_BOLD('STATE TIMELINE')}")
if not payload_history:
lines.append(_DIM(" no saved turns yet"))
for turn in payload_history:
lines.append(
f" #{turn['turn_index']} {turn['beat_id'] or 'free-play'} "
f"mood {turn['mood_before']['valence']:+.2f} to {turn['mood_after']['valence']:+.2f} "
f"trust {turn['trust_before']:+.2f} to {turn['trust_after']:+.2f} "
f"recalled {turn['retrieved_ids']}"
)
lines.append(f"\n {_BOLD('ATTRIBUTION')}")
from eval.context_attribution import newest_run_by_estimator
newest_by_estimator = newest_run_by_estimator(attribution_root)
for estimator in ("likelihood", "behavioural"):
run = newest_by_estimator.get(estimator)
if run is None:
lines.append(f" {estimator}: {_DIM('not run')}")
continue
scale = "measured" if run["readings"] >= 20 else "pilot only"
rho = f" position-bias rho {run['mean_rho']:+.2f}" if run["mean_rho"] is not None else ""
lines.append(
f" {estimator}: {_GRN(scale) if scale == 'measured' else _YEL(scale)}"
f" · {run['readings']} readings · {run['model']} · {run['stamp']}{rho}"
)
if len(newest_by_estimator) >= 2:
lines.append(_DIM(" paired readings available: likelihood vs behavioural"))
lines.append(f"\n {_BOLD('ATTACKS')}")
v1 = f"{_GRN('measured')} in {len(runs)} evaluation runs" if runs else _DIM("not run")
lines.append(f" v1 corpus: {v1}")
v2_present = (experiments_dir / "attacks_v2.json").exists()
v2 = _YEL("staged, results on disk") if v2_present else _DIM("not run")
lines.append(f" v2 corpus: {v2} (the extension; never blended into v1)")
lines.append(f"\n {_BOLD('EVIDENCE')}")
lines.append(f" full evaluation: {_GRN('measured') if runs else _DIM('not run')}")
defended = (experiments_dir / "provenance.json").exists()
lines.append(
f" defence sweep: {_GRN('measured') if defended else _DIM('demo-only (computed live)')}"
)
lines.append(f" web demo tabs: {_DIM('demo-only (presentation, not evidence)')}")
return lines
def _do_dashboard() -> None:
"""Print the read-only research dashboard. Looking at it changes nothing."""
print()
for line in _dashboard_report():
print(line)
def _do_maintenance() -> None:
"""The destructive operations, out of the main menu, each behind its own confirmation."""
choice = ask_index(
f"\n {_BOLD('Maintenance')}",
["Delete all generated data (runs, figures, tables)"],
)
if choice is None:
print(_DIM(" Cancelled."))
return
_do_delete_run_data()
def _do_quick_scoreboard() -> None:
"""RQ3 at published default weights: the sub-second answer."""
from eval.run import fast_rq3_defaults
print(f"\n {_BOLD('nDCG@5, published defaults')}")
for variant, value in fast_rq3_defaults().items():
print(f" {variant:<16} {_YEL(f'{value:.3f}')}")
print(_DIM("\n Tuning, ablations, RQ1 and RQ2 live in the full evaluation (option 4)."))
def _do_full_evaluation() -> None:
"""Run all three studies and write a run directory."""
from eval.run import run_all
print(_DIM("\n Running RQ1, RQ2 and RQ3. This takes a minute or two."))
path, _summary = run_all(progress=lambda message: print(_DIM(f" {message}")))
print(f"\n {_GRN('✓ Done.')} Results in {_BOLD(str(path))}")
print(_DIM(" Option 11 turns this into the paper's figures and tables."))
_chime()
def _do_seeded_runs() -> None:
"""Replicate the evaluation, either on one model or across several."""
from eval.experiments import AVAILABLE_MODELS, cross_model_experiment, replicate_experiment
choice = ask_index(
f"\n {_BOLD('Seeded runs')}",
["Same model, repeated: does the harness reproduce?",
"Across models: what moves when the model changes?"],
)
if choice is None:
print(_DIM(" Cancelled."))
return
if choice.startswith("Same"):
report = replicate_experiment(replicates=3)
verdict = _GRN("identical") if report["identical"] else _RED("DIVERGED")
print(f"\n {report['replicates']} runs on {report['model']}: {_BOLD(verdict)}")
else:
print(_DIM(f"\n Models: {', '.join(AVAILABLE_MODELS)}"))
report = cross_model_experiment()
print(f"\n {len(report['models'])} models compared.")
print(_DIM(f" Written to {report['out_dir']}"))
_chime()
def _do_bakeoff() -> None:
"""Compare the looped thesis model against conventional models of similar size."""
try:
from eval.bakeoff import run_bakeoff
except ImportError:
print(_YEL("\n The bake-off is not built yet."))
print(_DIM(" It compares Ouro 1.4B (looped) against conventional models."))
return
print(_DIM("\n Holding prompts, memories and sampling equal, varying only the model."
" Ouro is slow, so this takes several minutes."))
path, verdict = run_bakeoff()
print(f"\n {_GRN('✓ Done.')} {path}")
print(f" {verdict}")
_chime()
def _do_affective_indexing() -> None:
"""Flip every memory's valence: the fact survives, the mood inverts."""
from eval.emotion_flip import main
print()
main()
def _do_attribution() -> None:
"""Per-signal attribution of the poisoning result: zero one weight at a time."""
from eval.attribution import main
print()
main()
def _do_provenance_sweep() -> None:
"""Sweep anchored scoring mass and watch poisoning fall to zero."""
from eval.provenance import main
print()
main()
def _do_grid() -> None:
"""Every injected text under four tag conditions against every arm."""
from eval.grid import main
print()
main()
def _do_generate_assets() -> None:
"""Rebuild figures and tables from the newest run."""
run_dir = _latest_run()
if run_dir is None:
print(_YEL("\n No run found. Use option 4 first."))
return
try:
from assets.build_figures import build_all_figures
from assets.build_tables import build_all_tables
except ImportError as error: # matplotlib lives in the optional figures extra
print(_RED(f"\n Cannot import the asset builders: {error}"))
print(_DIM(' Install them with: pip install -e ".[figures]"'))
return
options = [
"tables (LaTeX + CSV)",
"figures from the run",
"figures from the experiments",
"results page (refuses to write if a number drifted)",
"questline map (from the arc, plus attribution status)",
]
chosen = toggle_select("ASSETS", options, default_indices=[0, 1, 2, 3, 4])
if not chosen:
print(_DIM(" Cancelled."))
return
print(_DIM(f"\n Building from {run_dir}..."))
written: list[Path] = []
if options[0] in chosen:
written += list(build_all_tables(run_dir))
if options[1] in chosen:
written += list(build_all_figures(run_dir))
if options[2] in chosen:
# The mechanism figures recompute from the harness rather than from the run, and
# leaving them out is how half a figure set goes stale without anyone noticing.
from assets.build_bakeoff_figures import build_experiment_figures
written += list(build_experiment_figures())
if options[3] in chosen:
# Last, because it embeds the figures the two steps above write, and it reads the
# run rather than trusting anything typed. A drift here is a real disagreement
# between the run and docs/findings.md, so it stops the build loudly.
from assets.build_results import DriftError, build_results
try:
written += list(build_results(run_dir))
except DriftError as error:
print(_RED("\n Results page refused to build:"))
print(_DIM(f" {error}"))
if options[4] in chosen:
from assets.build_questline import build_questline
written += list(build_questline())
print(f" {_GRN(f'✓ Wrote {len(written)} files.')}")
for path in written:
print(_DIM(f" {path}"))
def _do_demo() -> None:
"""Build the self-contained demo page and open it in a browser."""
import webbrowser
from assets.build_demo import build_demo
print(_DIM("\n Building from the newest run..."))
paths = build_demo()
for path in paths:
size = path.stat().st_size / 1024
print(f" {_GRN('✓ Wrote')} {path} {_DIM(f'({size:.0f} KB, opens with no server)')}")
if input(_DIM(" Open it now? [Y/n]: ")).strip().lower() not in ("n", "no"):
webbrowser.open(paths[0].resolve().as_uri()) # the flat diagram is the one to read
def _do_web_demo() -> None:
"""Serve the visual-novel web demo and open it in a browser."""
from web.server import serve
print(_DIM("\n Serving the web demo on http://127.0.0.1:8000 . Ctrl+C to stop and return."))
print(_DIM(" It opens on the best model this box can serve; the stub always works."))
serve(port=8000, open_browser=True)
def _do_reckoning_reveal() -> None:
"""Demo 1: play to the reckoning and reveal the six sources by Banzhaf weight."""
from demos import demo_reckoning_reveal
demo_reckoning_reveal()
def _do_mood_slider() -> None:
"""Demo 2: one line under three moods, retrieval and tone and attribution re-flowing."""
from demos import demo_mood_slider
demo_mood_slider()
def _do_defence_dial() -> None:
"""Demo 3: the anchor-weight dose-response, and its failure on a hostile anchor."""
from demos import demo_defence_dial
demo_defence_dial()
def _do_tag_flip() -> None:
"""Demo 4: flip an affect tag and watch the rank move while the words do not."""
from demos import demo_tag_flip
demo_tag_flip()
def _do_estimator_divergence() -> None:
"""Demo 5: where likelihood and behavioural attribution disagree (cached-only)."""
from demos import demo_estimator_divergence
demo_estimator_divergence()
def _do_record_walk() -> None:
"""Walk demos 1 to 4 in order, capture-ready for a screen recording."""
from demos import run_record
run_record()
def _do_latest_results() -> None:
"""Summarise the newest run without rerunning anything."""
run_dir = _latest_run()
if run_dir is None:
print(_YEL("\n No run found. Use option 4 first."))
return
results = json.loads((run_dir / "results.json").read_text(encoding="utf-8"))
meta = results.get("metadata", {})
print(f"\n {_BOLD(run_dir.name)}")
print(_DIM(f" model {meta.get('model', '?')} | labels {meta.get('label_set', '?')}"
f" {meta.get('label_version', '')} | commit {str(meta.get('git_commit', '?'))[:10]}\n"))
print(f" {'variant':<16} {'nDCG@5':>7}")
for variant, metrics in results.get("rq3", {}).get("variants", {}).items():
score = metrics.get("ndcg@5", float("nan"))
print(f" {variant:<16} {_YEL(f'{score:>7.3f}')}")
print(_DIM("\n Every interval spans zero at ten queries: read direction, not ranking."))
def _do_settings() -> None:
"""Show the live configuration and where to change it."""
from embr.config import DEFAULT_CONFIG_PATH, EmbrConfig
config = EmbrConfig.load()
rows = [
("top-k retrieved", str(config.top_k)),
("store backend", config.store_backend),
("embedding model", config.embedding_model),
("model runner", config.model_runner),
] + [(f"weight: {name}", f"{w:g}" if isinstance(w, (int, float)) else str(w))
for name, w in config.weights.items()]
print()
for name, value in rows:
print(f" {name:<22} {_YEL(value)}")
print(_DIM(f"\n Edit {DEFAULT_CONFIG_PATH} and reopen. Zero a weight to ablate it."))
def _do_fetch_lexicon() -> None:
"""Download the NRC VAD lexicon so the reported tone rater is the published one."""
from eval.tone import LEXICON_PATH, LEXICON_URL, fetch_lexicon
if LEXICON_PATH.exists():
print(_DIM(f"\n Already on disk: {LEXICON_PATH}"))
return
print(_DIM(f"\n Fetching {LEXICON_URL} (about 6 MB)..."))
path = fetch_lexicon()
print(f" {_GRN('✓ Wrote')} {path}")
print(_DIM(" Free for research, cite Mohammad (2018, 2025), never redistribute: data/ is gitignored."))
#: Everything the pipeline generates. Nothing hand written lives under any of these, which
#: is what makes wiping them safe: the branding, the architecture diagram and the builders
#: all live under assets/ and are never touched.
GENERATED_DATA_DIRS = (RUNS_DIR, FIGURES_DIR, TABLES_DIR)
def delete_generated_data(directories: Sequence[Path] = GENERATED_DATA_DIRS) -> list[Path]:
"""Delete every generated data directory and return the ones that were removed.
Separated from the prompting so it can be tested without a terminal, and so the
confirmation cannot drift away from what actually gets deleted.
"""
import shutil
removed: list[Path] = []
for directory in directories:
if directory.exists():
shutil.rmtree(directory)
removed.append(directory)
return removed
def _do_delete_run_data() -> None:
"""Wipe every generated data directory after a typed confirmation."""
present = [directory for directory in GENERATED_DATA_DIRS if directory.exists()]
if not present:
print(_DIM("\n Nothing to delete: no generated data on disk."))
return
print(_RED(_BOLD("\n WARNING, this permanently deletes:")))
for directory in present:
count = sum(1 for path in directory.rglob("*") if path.is_file())
print(f" {_YEL(str(directory))} {_DIM(f'({count} files)')}")
print(_DIM("\n Runs, figures and tables all regenerate from option 4 then option 11."
" Nothing under assets/ is touched."))
if input("\n Type DELETE to confirm, anything else cancels: ").strip() != "DELETE":
print(_DIM(" Cancelled."))
return
removed = delete_generated_data(present)
print(f" {_GRN(f'✓ Deleted {len(removed)} directories.')}")
# Key to handler. One table, so adding an option cannot drift from its dispatch.
_ACTIONS: dict[str, Callable[[], None]] = {
"R": _do_continue,
"Q": _do_quests,
"1": _do_conversation_turn,
"2": _do_walkthrough,
"W": _do_web_demo,
"3": _do_quick_scoreboard,
"4": _do_full_evaluation,
"5": _do_seeded_runs,
"6": _do_bakeoff,
"7": _do_affective_indexing,
"8": _do_attribution,
"9": _do_provenance_sweep,
"10": _do_grid,
"11": _do_generate_assets,
"12": _do_demo,
"13": _do_latest_results,
"V": _do_dashboard,
"14": _do_reckoning_reveal,
"15": _do_mood_slider,
"16": _do_defence_dial,
"17": _do_tag_flip,
"18": _do_estimator_divergence,
"19": _do_record_walk,
"S": _do_settings,
"L": _do_fetch_lexicon,
"M": _do_maintenance,
"C": _clear,
}
def run_menu() -> None:
"""Show the EMBR menu and dispatch until the user exits."""
while True:
_print_menu()
try:
choice = input(_BOLD(" ⟫ ")).strip().upper()
except (EOFError, KeyboardInterrupt):
choice = "0"
if choice == "0":
_clear()