Spaces:
Running on Zero
Running on Zero
add wandb + aiohttp to requirements; drop local dev tooling
Browse files- assemble.sh +0 -85
- bench.py +0 -89
- requirements.txt +9 -0
- run_local.sh +0 -78
- strip_idm.py +0 -46
- tasks.json +0 -0
- 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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|