ffeng1017 commited on
Commit
da0d89b
·
verified ·
1 Parent(s): 23a59ea

add wandb + aiohttp to requirements; drop local dev tooling

Browse files
Files changed (7) hide show
  1. assemble.sh +0 -85
  2. bench.py +0 -89
  3. requirements.txt +9 -0
  4. run_local.sh +0 -78
  5. strip_idm.py +0 -46
  6. tasks.json +0 -0
  7. test_page.py +0 -214
assemble.sh DELETED
@@ -1,85 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Stage everything the Space needs into space/, then push it to Hugging Face.
3
- #
4
- # The Space is its own git repo, so this copies the pieces it needs out of the
5
- # main repo rather than depending on it. Re-run after changing anything under
6
- # src/ and push again.
7
- #
8
- # Usage:
9
- # ./assemble.sh # stage only
10
- # ./assemble.sh <user-or-org>/<space-name> # stage, then show the push commands
11
- #
12
- # Env overrides:
13
- # IDM_SRC IDM checkpoint to ship (default: base_run3/step_040000.pt)
14
- set -euo pipefail
15
- cd "$(dirname "$0")"
16
-
17
- REPO_ROOT="$(cd .. && pwd)"
18
- IDM_SRC="${IDM_SRC:-$REPO_ROOT/src/logs/idm_tokenizer_ckpts/base_run3/step_040000.pt}"
19
- SPACE_ID="${1:-}"
20
-
21
- echo "staging from $REPO_ROOT"
22
-
23
- # --- code -------------------------------------------------------------------
24
- rm -rf src
25
- mkdir -p src
26
- # Flat modules the server imports; envs/ is a package and comes wholesale.
27
- cp "$REPO_ROOT"/src/*.py src/
28
- cp -r "$REPO_ROOT"/src/envs src/
29
- # Only the v2 page is served, but the server's loader falls back by filename, so
30
- # ship the one it is pointed at and nothing else.
31
- cp "$REPO_ROOT"/src/interactive_corr_v2.html src/
32
- # Language embeddings, read at runtime for the task conditioning.
33
- cp "$REPO_ROOT"/tasks.json .
34
-
35
- # Training-only artefacts must not ride along.
36
- rm -rf src/logs src/checkpoints src/wandb src/data
37
- find src -name '__pycache__' -type d -prune -exec rm -rf {} +
38
-
39
- # --- weights ----------------------------------------------------------------
40
- mkdir -p assets
41
- if [ ! -f "$IDM_SRC" ]; then
42
- echo "ERROR: IDM checkpoint not found: $IDM_SRC" >&2
43
- echo " set IDM_SRC=<path> or train one with src/run_idm_tokenizer.sh" >&2
44
- exit 1
45
- fi
46
- python strip_idm.py "$IDM_SRC" assets/idm_tokenizer.pt
47
-
48
- # The tokenizer/dynamics pair is public on the Hub and downloaded at startup, so
49
- # it is deliberately NOT vendored here — it would add 1.4 GB to every push.
50
-
51
- # --- git config -------------------------------------------------------------
52
- cat > .gitattributes <<'EOF'
53
- *.pt filter=lfs diff=lfs merge=lfs -text
54
- tasks.json filter=lfs diff=lfs merge=lfs -text
55
- EOF
56
-
57
- cat > .gitignore <<'EOF'
58
- checkpoints/
59
- __pycache__/
60
- *.pyc
61
- EOF
62
-
63
- echo
64
- echo "staged:"
65
- printf ' %-28s %s\n' \
66
- "app.py" "$(du -h app.py | cut -f1)" \
67
- "src/ (+envs)" "$(du -sh src | cut -f1)" \
68
- "assets/idm_tokenizer.pt" "$(du -h assets/idm_tokenizer.pt | cut -f1)" \
69
- "tasks.json" "$(du -h tasks.json | cut -f1)"
70
-
71
- if [ -n "$SPACE_ID" ]; then
72
- cat <<EOF
73
-
74
- to publish:
75
- git init && git lfs install
76
- git remote add origin https://huggingface.co/spaces/$SPACE_ID
77
- git add -A && git commit -m "deploy correlation demo"
78
- git push -u origin main
79
-
80
- the Space needs hardware = ZeroGPU (Settings -> Hardware).
81
- EOF
82
- else
83
- echo
84
- echo "pass a Space id (e.g. ./assemble.sh myname/wm-hallucination) for push commands."
85
- fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bench.py DELETED
@@ -1,89 +0,0 @@
1
- """Drive a running demo over its WebSocket and report where the time goes.
2
-
3
- The server already timestamps each render step and ships it as `ms` in the status
4
- frame, so this measures the real per-step cost rather than round-trip latency.
5
- Steps that also ran the WAV rollout are reported separately: that is the one
6
- periodic cost, and averaging it into every step hides which knob to turn.
7
-
8
- Usage:
9
- python bench.py # 60 steps against localhost:7860
10
- python bench.py --steps 120 --port 7860
11
- python bench.py --task walker-run # switch task first
12
- """
13
- import argparse
14
- import asyncio
15
- import json
16
- import statistics as st
17
-
18
- import aiohttp
19
-
20
-
21
- async def run(args) -> int:
22
- url = f"http://{args.host}:{args.port}"
23
- async with aiohttp.ClientSession() as s:
24
- async with s.get(f"{url}/healthz", timeout=aiohttp.ClientTimeout(total=10)) as r:
25
- print((await r.text()).strip())
26
-
27
- async with s.ws_connect(f"{url}/ws") as ws:
28
- if args.task:
29
- await ws.send_json({"type": "set_task", "task": args.task})
30
- await asyncio.sleep(2.0)
31
- await ws.send_json({"type": "keydown", "key": "ArrowRight"})
32
-
33
- plain, with_wav, n_jpeg, first, last = [], [], 0, None, None
34
- while len(plain) + len(with_wav) < args.steps:
35
- try:
36
- m = await asyncio.wait_for(ws.receive(), timeout=60)
37
- except asyncio.TimeoutError:
38
- print("timed out waiting for frames")
39
- break
40
- if m.type == aiohttp.WSMsgType.BINARY:
41
- n_jpeg += 1
42
- continue
43
- if m.type != aiohttp.WSMsgType.TEXT:
44
- break
45
- d = json.loads(m.data)
46
- if d.get("type") != "status" or "ms" not in d:
47
- continue
48
- if first is None:
49
- first = d.get("step", 0)
50
- last = d.get("step", 0)
51
- (with_wav if d.get("wav_steps") else plain).append(float(d["ms"]))
52
- await ws.send_json({"type": "disconnect"})
53
-
54
- every = len(plain) + len(with_wav)
55
- if not every:
56
- print("no timed frames received")
57
- return 1
58
-
59
- def line(name, xs):
60
- if not xs:
61
- print(f" {name:<22s} (none)")
62
- return
63
- xs = sorted(xs)
64
- print(f" {name:<22s} n={len(xs):>4d} median={st.median(xs):7.1f} ms "
65
- f"p90={xs[int(0.9 * (len(xs) - 1))]:7.1f} max={xs[-1]:7.1f}")
66
-
67
- print(f"\nper-step render cost (steps {first} -> {last}, {n_jpeg} jpeg frames)")
68
- line("plain step", plain)
69
- line("step + WAV rollout", with_wav)
70
-
71
- allms = plain + with_wav
72
- mean_ms = st.mean(allms)
73
- print(f"\n mean over all steps {mean_ms:7.1f} ms -> {1000 / mean_ms:5.2f} fps sustained")
74
- if plain and with_wav:
75
- extra = st.median(with_wav) - st.median(plain)
76
- share = 100.0 * extra / st.median(with_wav)
77
- print(f" WAV rollout adds {extra:7.1f} ms on the steps it runs ({share:.0f}% of them)")
78
- print(f" amortised over 1-in-{round(len(allms) / max(1, len(with_wav)))} steps: "
79
- f"{extra * len(with_wav) / len(allms):.1f} ms/step")
80
- return 0
81
-
82
-
83
- if __name__ == "__main__":
84
- p = argparse.ArgumentParser()
85
- p.add_argument("--host", default="127.0.0.1")
86
- p.add_argument("--port", type=int, default=7860)
87
- p.add_argument("--steps", type=int, default=60)
88
- p.add_argument("--task", type=str, default=None)
89
- raise SystemExit(asyncio.run(run(p.parse_args())))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -15,8 +15,17 @@ gradio==6.17.3
15
  spaces
16
  huggingface_hub
17
  websockets
 
 
 
 
 
18
 
19
  # world model / data
 
 
 
 
20
  transformers==4.56.2
21
  lpips==0.1.4
22
  tensordict==0.10.0
 
15
  spaces
16
  huggingface_hub
17
  websockets
18
+ # The interactive_* modules import `aiohttp.web` at module scope (they ship their
19
+ # own aiohttp server; the Space only reuses their classes). It reaches the image
20
+ # solely through extras nobody selects -- huggingface_hub[inference], fsspec[http] --
21
+ # so it has to be requested outright.
22
+ aiohttp
23
 
24
  # world model / data
25
+ # wandb is a *training* dependency, but train_dynamics.py and idm_tokenizer.py
26
+ # import it at module scope, and the demo pulls sampler helpers out of both. It is
27
+ # declared only under ogbench's "train" extra, hence not installed by default.
28
+ wandb
29
  transformers==4.56.2
30
  lpips==0.1.4
31
  tensordict==0.10.0
run_local.sh DELETED
@@ -1,78 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Run the Space app locally, exactly as it will run on HF but with a real GPU.
3
- #
4
- # ./run_local.sh # balanced (the Space default)
5
- # ./run_local.sh fast # ~4x faster, for interaction; slightly coarser sampling
6
- # ./run_local.sh faithful # matches the offline experiments; slow
7
- #
8
- # Then, in another shell:
9
- # python bench.py # where the per-step time actually goes
10
- #
11
- # Env overrides: GPU, PORT, CKPT_DIR, plus any knob below.
12
- set -euo pipefail
13
- cd "$(dirname "$0")"
14
-
15
- PRESET="${1:-balanced}"
16
- GPU="${GPU:-0}"
17
- PORT="${PORT:-7860}"
18
- # Reuse an existing checkpoint tree if there is one, rather than re-downloading.
19
- CKPT_DIR="${CKPT_DIR:-$(cd .. && pwd)/src/checkpoints}"
20
-
21
- case "$PRESET" in
22
- fast)
23
- # The two dominant costs are the Euler step count (every sampler call pays
24
- # it) and the WAV rollout (H steps x 2 action sources). KV caching is exact
25
- # -- same numbers, less compute -- so it is on in every preset.
26
- EVAL_D=0.5 # 2 Euler steps per sample instead of 8
27
- CTX_WINDOW=12 # attention context; must stay >= wav_context + wav_horizon_long - 1
28
- WAV_HORIZON_LONG=4
29
- WAV_EVERY=4
30
- N_SAMPLES_U=2
31
- U_EVERY=2 # tokenizer round-trip (u_r) every other frame
32
- ;;
33
- balanced)
34
- EVAL_D=0.25 # 4 Euler steps
35
- CTX_WINDOW=16
36
- WAV_HORIZON_LONG=8
37
- WAV_EVERY=4
38
- N_SAMPLES_U=2
39
- U_EVERY=1
40
- ;;
41
- faithful)
42
- EVAL_D=0.125 # 8 Euler steps — the repo default
43
- CTX_WINDOW=24
44
- WAV_HORIZON_LONG=8
45
- WAV_EVERY=4
46
- N_SAMPLES_U=4
47
- U_EVERY=1
48
- ;;
49
- *)
50
- echo "unknown preset '$PRESET' (expected: fast | balanced | faithful)" >&2
51
- exit 2
52
- ;;
53
- esac
54
-
55
- # ctx_window bounds how much latent history a session keeps, and the lagged
56
- # rollout needs wav_context + wav_horizon_long of it. Fail loudly here rather
57
- # than letting the correlation rows sit empty forever.
58
- NEED=$(( 8 + WAV_HORIZON_LONG ))
59
- if [ "$(( CTX_WINDOW + 1 ))" -lt "$NEED" ]; then
60
- echo "preset '$PRESET' is inconsistent: ctx_window=$CTX_WINDOW keeps $(( CTX_WINDOW + 1 ))" >&2
61
- echo "latents but the rollout needs $NEED. Raise CTX_WINDOW or lower WAV_HORIZON_LONG." >&2
62
- exit 1
63
- fi
64
-
65
- echo "preset=$PRESET gpu=$GPU port=$PORT"
66
- echo " eval_d=$EVAL_D (Euler steps=$(python -c "print(int(1/$EVAL_D))")) ctx_window=$CTX_WINDOW"
67
- echo " wav: horizon_long=$WAV_HORIZON_LONG every=$WAV_EVERY u: n_samples=$N_SAMPLES_U every=$U_EVERY"
68
- echo " kv_cache=on"
69
- echo
70
-
71
- exec env \
72
- CUDA_VISIBLE_DEVICES="$GPU" \
73
- CKPT_DIR="$CKPT_DIR" \
74
- WAV_EVERY="$WAV_EVERY" \
75
- WAV_HORIZON_LONG="$WAV_HORIZON_LONG" \
76
- EXTRA_ARGS="--eval_d $EVAL_D --ctx_window $CTX_WINDOW --n_samples_u $N_SAMPLES_U --u_every $U_EVERY --kv_cache" \
77
- PORT="$PORT" \
78
- python app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
strip_idm.py DELETED
@@ -1,46 +0,0 @@
1
- """Strip optimizer state from an IDM checkpoint before shipping it.
2
-
3
- `idm_tokenizer.py` saves AdamW's moments alongside the weights so a `--resume`
4
- is exact. That triples the file (252 MB vs 84 MB for the d_hidden=1024 head) and
5
- is dead weight in a deployment, which only ever loads the head for inference.
6
-
7
- `load_idm_tokenizer_head` recovers the architecture from the tensor shapes, so
8
- dropping everything but `model` (plus `args`/`step` for provenance) is safe.
9
-
10
- Usage:
11
- python strip_idm.py <src.pt> <dst.pt>
12
- """
13
- import os
14
- import sys
15
-
16
- import torch
17
-
18
-
19
- def main() -> int:
20
- if len(sys.argv) != 3:
21
- print(__doc__.strip(), file=sys.stderr)
22
- return 2
23
- src, dst = sys.argv[1], sys.argv[2]
24
- if not os.path.isfile(src):
25
- print(f"ERROR: no such checkpoint: {src}", file=sys.stderr)
26
- return 1
27
-
28
- ck = torch.load(src, map_location="cpu", weights_only=False)
29
- if "model" not in ck:
30
- print(f"ERROR: {src} has no 'model' key (got {sorted(ck)})", file=sys.stderr)
31
- return 1
32
-
33
- lean = {"model": ck["model"], "args": ck.get("args"), "step": ck.get("step")}
34
- os.makedirs(os.path.dirname(os.path.abspath(dst)) or ".", exist_ok=True)
35
- torch.save(lean, dst)
36
-
37
- a, b = os.path.getsize(src) / 1e6, os.path.getsize(dst) / 1e6
38
- dropped = sorted(k for k in ck if k not in lean)
39
- print(f"{src} {a:.0f} MB")
40
- print(f"{dst} {b:.0f} MB (-{100 * (1 - b / a):.0f}%, dropped: {dropped or 'nothing'})")
41
- print(f"step={lean['step']} params={sum(v.numel() for v in lean['model'].values()) / 1e6:.1f}M")
42
- return 0
43
-
44
-
45
- if __name__ == "__main__":
46
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tasks.json CHANGED
The diff for this file is too large to render. See raw diff
 
test_page.py DELETED
@@ -1,214 +0,0 @@
1
- """Execute the served page's JavaScript against captured server frames.
2
-
3
- Static checks (does it parse? are all identifiers declared?) kept missing real
4
- bugs here, because the page's failure mode is an exception thrown inside the
5
- WebSocket message handler — which the browser swallows, leaving panels frozen
6
- while data keeps arriving. The only way to catch that is to *run* the script.
7
-
8
- So: a minimal DOM in QuickJS, the real page script loaded into it, and real
9
- status frames replayed through the captured `ws.onmessage`. Then assert the
10
- panels actually changed.
11
-
12
- Usage:
13
- python test_page.py [--url http://127.0.0.1:7860] [--frames /tmp/frames.json]
14
- python test_page.py --html /path/to/page.html --frames /tmp/frames.json
15
- """
16
- import argparse
17
- import json
18
- import re
19
- import sys
20
- import urllib.request
21
-
22
- import quickjs
23
-
24
- DOM = r"""
25
- var __errors = [];
26
- var __els = {}; // id -> element
27
- var __created = []; // every element made by script
28
- // Ids that actually exist in the served markup. getElementById must return null
29
- // for anything else -- auto-creating a stand-in is precisely how a stub hides
30
- // the bug it is supposed to catch (a real browser returns null and the next
31
- // property write throws).
32
- var __realIds = {};
33
-
34
- function __mkEl(tag, id) {
35
- var e = {
36
- tagName: tag, id: id || "", className: "", textContent: "", innerHTML: "",
37
- style: {}, dataset: {}, children: [], value: "", max: "", min: "",
38
- appendChild: function (c) { this.children.push(c); return c; },
39
- setAttribute: function (k, v) { this[k] = v; },
40
- getAttribute: function (k) { return this[k]; },
41
- addEventListener: function () {},
42
- removeEventListener: function () {},
43
- getBoundingClientRect: function () { return {width: 300, height: 120, top: 0, left: 0, right: 300, bottom: 120}; },
44
- getContext: function () { return {}; },
45
- querySelectorAll: function () { return []; },
46
- classList: {add: function(){}, remove: function(){}, contains: function(){return false;}},
47
- focus: function () {}, blur: function () {},
48
- };
49
- __created.push(e);
50
- return e;
51
- }
52
-
53
- var document = {
54
- documentElement: {
55
- getAttribute: function () { return null; },
56
- setAttribute: function () {},
57
- hasAttribute: function () { return false; },
58
- style: {},
59
- },
60
- getElementById: function (id) {
61
- if (!__realIds[id]) return null; // faithful to the browser
62
- if (!__els[id]) __els[id] = __mkEl("div", id);
63
- return __els[id];
64
- },
65
- createElement: function (t) { return __mkEl(t); },
66
- createElementNS: function (ns, t) { return __mkEl(t); },
67
- createTextNode: function (s) { return {textContent: String(s), nodeValue: String(s)}; },
68
- createDocumentFragment: function () { return __mkEl("fragment"); },
69
- // Selector support is deliberately shallow: the page only uses it to relabel
70
- // the table header. Return a stable array per selector so index access works
71
- // the way it does in a browser instead of blowing up on undefined.
72
- __qsa: {},
73
- querySelectorAll: function (sel) {
74
- if (!this.__qsa[sel]) {
75
- var n = 8, a = [];
76
- for (var i = 0; i < n; i++) a.push(__mkEl("th"));
77
- this.__qsa[sel] = a;
78
- }
79
- return this.__qsa[sel];
80
- },
81
- addEventListener: function () {},
82
- };
83
-
84
- var window = {
85
- addEventListener: function () {},
86
- innerWidth: 1200, innerHeight: 800, devicePixelRatio: 1,
87
- };
88
- var location = {protocol: "http:", host: "127.0.0.1:7860"};
89
- function getComputedStyle() { return {getPropertyValue: function () { return "#3987e5"; }}; }
90
- function matchMedia() { return {matches: false}; }
91
- function setTimeout(f) { return 0; }
92
- function clearTimeout() {}
93
- var console = {log: function(){}, error: function(){ __errors.push(Array.prototype.join.call(arguments, " ")); },
94
- warn: function(){}};
95
- var URL = {createObjectURL: function () { return "blob:x"; }, revokeObjectURL: function () {}};
96
- function Blob() {}
97
-
98
- // Capture the socket the page opens so frames can be pushed through it.
99
- var __ws = null;
100
- function WebSocket(u) { __ws = this; this.readyState = 1; this.url = u; }
101
- WebSocket.OPEN = 1;
102
- WebSocket.prototype.send = function () {};
103
- WebSocket.prototype.close = function () {};
104
-
105
- function __feed(json) {
106
- if (!__ws || !__ws.onmessage) { __errors.push("no onmessage handler"); return; }
107
- __ws.onmessage({data: json});
108
- }
109
- function __panelValues() {
110
- var out = [];
111
- for (var i = 0; i < __created.length; i++) {
112
- var e = __created[i];
113
- if (e.className === "pCum") {
114
- out.push(e.className + ":" + e.textContent);
115
- }
116
- }
117
- return JSON.stringify(out);
118
- }
119
- // Exercise a slider the way a user does: set its value, fire its handler.
120
- // A control that renders but is not wired reads as "the numbers never change".
121
- function __slide(id, value) {
122
- var el = __els[id];
123
- if (!el) { __errors.push("no such control: " + id); return false; }
124
- if (!el.oninput) { __errors.push("control has no oninput: " + id); return false; }
125
- el.value = String(value);
126
- el.oninput();
127
- return true;
128
- }
129
- function __errCount() { return __errors.length; }
130
- function __errText() { return JSON.stringify(__errors.slice(0, 5)); }
131
- """
132
-
133
-
134
- def main() -> int:
135
- ap = argparse.ArgumentParser()
136
- ap.add_argument("--url", default="http://127.0.0.1:7860")
137
- ap.add_argument("--html", default=None)
138
- ap.add_argument("--frames", default="/tmp/frames.json")
139
- a = ap.parse_args()
140
-
141
- html = (open(a.html).read() if a.html
142
- else urllib.request.urlopen(a.url, timeout=10).read().decode())
143
- js = re.search(r"<script>(.*)</script>", html, re.S).group(1)
144
- frames = json.load(open(a.frames))
145
-
146
- ctx = quickjs.Context()
147
- ctx.eval(DOM)
148
- # Teach the stub which ids the markup really has.
149
- real_ids = sorted(set(re.findall(r'id="([A-Za-z_0-9]+)"', html)))
150
- ctx.eval("__realIds = " + json.dumps({i: True for i in real_ids}) + ";")
151
- print(f"markup declares {len(real_ids)} element ids")
152
- try:
153
- ctx.eval(js)
154
- except Exception as e:
155
- print(f"FAIL: the page script threw at load:\n {e}")
156
- return 1
157
- print("page script loaded without throwing")
158
-
159
- before = ctx.eval("__panelValues()")
160
- n_samples = 0
161
- for f in frames:
162
- ctx.eval(f"__feed({json.dumps(json.dumps(f))})")
163
- if f.get("wav_steps"):
164
- n_samples += 1
165
- after = ctx.eval("__panelValues()")
166
-
167
- n_err = ctx.eval("__errCount()")
168
- print(f"replayed {len(frames)} frames ({n_samples} carrying rollout samples)")
169
-
170
- ok = True
171
- if n_err:
172
- print(f"FAIL: {n_err} error(s) surfaced from the message handler:")
173
- for e in json.loads(ctx.eval("__errText()")):
174
- print(f" {e}")
175
- ok = False
176
-
177
- # Controls must actually rescore what is already on screen.
178
- for cid, val in (("nsRange", 20), ("hRange", 6)):
179
- moved_from = ctx.eval("__panelValues()")
180
- if not ctx.eval(f'__slide("{cid}", {val})'):
181
- print(f"FAIL: control {cid} is not wired")
182
- ok_ctl = False
183
- continue
184
- moved_to = ctx.eval("__panelValues()")
185
- if moved_from == moved_to:
186
- print(f"WARN: moving {cid} to {val} changed nothing "
187
- f"(may be legitimate if the data is degenerate)")
188
- else:
189
- print(f"control {cid} -> {val}: panels rescored")
190
-
191
- b, af = json.loads(before), json.loads(after)
192
- changed = [x for x in af if x not in b]
193
- if not af:
194
- print("FAIL: no panel elements were created — buildPanels() never ran")
195
- ok = False
196
- elif not changed:
197
- print("FAIL: panels never changed after replaying samples")
198
- print(f" still: {af[:8]}")
199
- ok = False
200
- else:
201
- filled = [x for x in af if not x.endswith(":—") and not x.endswith(":")]
202
- print(f"panels updated: {len(changed)} of {len(af)} slots changed, "
203
- f"{len(filled)} now hold a value")
204
- print(f" sample: {filled[:6]}")
205
- if not filled:
206
- print("FAIL: every panel slot is still empty")
207
- ok = False
208
-
209
- print("\nPASS" if ok else "\nFAIL")
210
- return 0 if ok else 1
211
-
212
-
213
- if __name__ == "__main__":
214
- raise SystemExit(main())