NagaYu commited on
Commit
13b9d08
·
verified ·
1 Parent(s): ad6aa7c

Cairn: browser-only demo (Pyodide) — leave, come back, same world

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. README.md +73 -4
  3. browser_app.py +307 -0
  4. build_space.py +364 -0
  5. cairn-0.1.0-py3-none-any.whl +3 -0
  6. index.html +280 -17
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ cairn-0.1.0-py3-none-any.whl filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,79 @@
1
  ---
2
- title: Cairn
3
- emoji: 🚀
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: static
7
  pinned: false
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Cairn — Leave, Come Back, Same World
3
+ emoji: 🗿
4
  colorFrom: blue
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
+ license: mit
9
+ tags:
10
+ - world-models
11
+ - video-generation
12
+ - long-term-consistency
13
+ - object-permanence
14
+ short_description: Leave, come back, same world — and edit it
15
  ---
16
 
17
+ # Cairn leave, come back, same world
18
+
19
+ Video world models forget. Turn the camera away from a chair for a few seconds and turn
20
+ back, and it is a different chair, somewhere else, or gone. The usual fixes give the model
21
+ *more implicit memory* — a longer context window, or a compressed latent. Both decay with
22
+ how long you looked away.
23
+
24
+ **Cairn takes the world out of the weights.** Objects live in an explicit external ledger —
25
+ persistent id, pose, appearance, provenance — written by perception on the model's *own
26
+ generated frames*, and read back to **coerce** generation when the camera returns. A table
27
+ lookup costs the same whether you looked away for 4 frames or 400.
28
+
29
+ Over 5 seeds, at 128 frames away, every baseline returns a broken world **0%** of the time
30
+ and Cairn **100%**, with a flat 3.6 cm position error.
31
+
32
+ ## What this Space does
33
+
34
+ Three things you can check yourself:
35
+
36
+ 1. **Leave & return** — drive the camera away from an object for *t* frames and come back.
37
+ Cairn OFF and Cairn ON generate from the same scene, the same trajectory and the same
38
+ seed, so the only difference is the memory.
39
+ 2. **Edit the world** — issue `move` / `remove` / `recolour` on an object **while it is off
40
+ screen**, turn back, and see whether the instruction stuck. The baselines cannot express
41
+ the command at all — there is no row to write to.
42
+ 3. **The ledger** — the actual table, its transaction log, and a rewind to any earlier frame.
43
+
44
+ ## How it runs
45
+
46
+ Entirely **in your browser**, via [Pyodide](https://pyodide.org) — no server, no GPU, no
47
+ account, nothing uploaded. The first load fetches Python, numpy, scipy and the `cairn`
48
+ wheel (~40 MB, cached afterwards); each run then takes a few seconds.
49
+
50
+ Pyodide has no ffmpeg, so instead of video you get a **filmstrip**: the opening shot, the
51
+ last frame before the camera leaves, a frame from while it is away, and the frames after it
52
+ comes back. For this claim a strip is arguably the better medium — both halves of the
53
+ comparison sit in one glance.
54
+
55
+ *Not using Gradio here on purpose: gradio-lite imports `gradio` before installing the page's
56
+ requirements, and gradio 5.x currently cannot be resolved against `huggingface-hub` 1.x
57
+ inside Pyodide. Driving Pyodide directly fixes the ordering and drops gradio's dependency
58
+ stack from the download.*
59
+
60
+ ## What you are watching
61
+
62
+ A **surrogate generator** that reproduces how autoregressive video drifts (random walk +
63
+ prior pull + salience decay) — not a real video backbone. That is a deliberate trade: exact
64
+ ground truth, and a benchmark that runs on a laptop in three minutes, in exchange for not
65
+ being LTX-Video. The same `cairn` library wraps a real diffusers video pipeline in one line:
66
+
67
+ ```python
68
+ cairn = CairnPipeline.from_pipeline(pipe)
69
+ ```
70
+
71
+ Everything scientific — the generator, perception, the ledger, the forcing path, the
72
+ metrics — is the same code the benchmark measures, installed here from the same wheel.
73
+
74
+ ## Links
75
+
76
+ - **Code, full benchmark, ablation and honest limitations** — <https://github.com/NagaYu/cairn>
77
+ - **Dataset** — <https://huggingface.co/datasets/NagaYu/cairn-departure-return>
78
+
79
+ MIT.
browser_app.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cairn's compute layer for the browser build (Pyodide, no server, no gradio).
2
+
3
+ Why this exists instead of Gradio-lite: gradio-lite imports ``gradio`` *before*
4
+ it installs the page's requirements, and gradio 5.x currently cannot be resolved
5
+ against ``huggingface-hub`` 1.x inside Pyodide -- the capped builds fail to
6
+ install, the uncapped ones import and then die on a missing ``httpcore``. Since
7
+ the boot order is not ours to change there, we skip the framework and drive
8
+ Pyodide directly. The page ends up lighter too: no pandas, pydantic or orjson,
9
+ just numpy, scipy, pillow and the ``cairn`` wheel.
10
+
11
+ Every function here returns plain JSON-able data (HTML fragments and base64 PNG
12
+ data URIs) which ``index.html`` drops into the DOM. All of the science is
13
+ imported unchanged from the ``cairn`` package; nothing is reimplemented for the
14
+ browser.
15
+
16
+ Claim: R/E -- lets anyone check the two headline claims themselves, for free,
17
+ with no GPU, no install and no account.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import io
24
+ from typing import Any, Dict, List, Tuple
25
+
26
+ import numpy as np
27
+ from PIL import Image
28
+
29
+ from cairn.runner import CONDITION_NAMES, RunConfig, run_condition
30
+ from cairn.world import (
31
+ make_departure_return_trajectory,
32
+ make_scene,
33
+ render,
34
+ schedule_edit,
35
+ usable_targets,
36
+ )
37
+
38
+ GAP = 6
39
+ BASELINES = {"A": "Vanilla", "B": "Context-window", "C": "Compressed-memory"}
40
+
41
+
42
+ # --------------------------------------------------------------------------
43
+ # rendering helpers
44
+ # --------------------------------------------------------------------------
45
+
46
+
47
+ def _png(arr: np.ndarray) -> str:
48
+ """``(H, W, 3)`` float image -> base64 PNG data URI for an ``<img>`` tag."""
49
+ a = (np.clip(np.asarray(arr), 0.0, 1.0) * 255).astype(np.uint8)
50
+ buf = io.BytesIO()
51
+ Image.fromarray(a).save(buf, format="PNG", optimize=True)
52
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
53
+
54
+
55
+ def _filmstrip(frames: np.ndarray, idx: List[int]) -> str:
56
+ """Selected frames side by side, separated by pale gutters.
57
+
58
+ Pyodide has no ffmpeg, so there is no video. A strip is arguably the better
59
+ medium for this claim anyway: "before leaving" and "after returning" sit in
60
+ one glance instead of several seconds apart in a loop.
61
+
62
+ Claim: R -- the experiment, in one picture.
63
+ """
64
+ idx = [i for i in idx if 0 <= i < len(frames)]
65
+ if not idx:
66
+ return _png(np.zeros((8, 8, 3)))
67
+ h = frames[0].shape[0]
68
+ gutter = np.full((h, GAP, 3), 0.93, dtype=np.float32)
69
+ panels: List[np.ndarray] = []
70
+ for k, i in enumerate(idx):
71
+ if k:
72
+ panels.append(gutter)
73
+ panels.append(frames[i])
74
+ return _png(np.concatenate(panels, axis=1))
75
+
76
+
77
+ def _windows(traj) -> List[int]:
78
+ return [
79
+ 0,
80
+ max(0, traj.observe_frames[1] - 1),
81
+ (traj.departure_frame + traj.return_frame) // 2,
82
+ traj.return_frame + 1,
83
+ min(len(traj) - 1, traj.return_frame + 7),
84
+ ]
85
+
86
+
87
+ PANEL_CAPTION = (
88
+ "opening shot &middot; last frame before leaving &middot; "
89
+ "<b>looking away</b> &middot; just back &middot; settled after return"
90
+ )
91
+
92
+
93
+ def _ledger_html(res) -> str:
94
+ rows = []
95
+ for e in res.ledger.entries(include_absent=True):
96
+ p = e.pose.position
97
+ state = "present" if e.present else '<b style="color:#b3261e">REMOVED</b>'
98
+ rows.append(
99
+ f"<tr><td>{e.object_id}</td><td>{p[0]:.2f}</td><td>{p[2]:.2f}</td>"
100
+ f"<td>{e.pose.yaw:+.2f}</td>"
101
+ f"<td><span class='sw' style='background:rgb("
102
+ f"{int(e.appearance[0]*255)},{int(e.appearance[1]*255)},{int(e.appearance[2]*255)})'></span>"
103
+ f"{e.appearance[0]:.2f}, {e.appearance[1]:.2f}, {e.appearance[2]:.2f}</td>"
104
+ f"<td>{e.n_observations}</td><td>{e.confidence:.2f}</td><td>{state}</td></tr>"
105
+ )
106
+ return (
107
+ "<table><thead><tr><th>id</th><th>x</th><th>z</th><th>yaw</th><th>rgb</th>"
108
+ "<th>seen</th><th>conf</th><th>state</th></tr></thead><tbody>"
109
+ + "".join(rows)
110
+ + "</tbody></table>"
111
+ )
112
+
113
+
114
+ def _cfg(cond: str, seed: int) -> RunConfig:
115
+ # reference_video=False skips rendering a second full clip that only the
116
+ # FVD-proxy consumes, and this page never shows that number. Roughly halves
117
+ # the work per click in the browser.
118
+ return RunConfig(condition=cond, seed=1000 + int(seed), reference_video=False)
119
+
120
+
121
+ def _resolve_target(scene, requested: int, seed: int) -> Tuple[int, str]:
122
+ """Snap the slider to an object that can actually host an episode.
123
+
124
+ Claim: R -- the demo shows the same well-posed episodes the benchmark scores.
125
+ """
126
+ ok = usable_targets(scene, seed=int(seed))
127
+ if not ok:
128
+ raise ValueError("No object in this room can be left and returned to — try another seed.")
129
+ if int(requested) in ok:
130
+ return int(requested), ""
131
+ chosen = min(ok, key=lambda o: abs(o - int(requested)))
132
+ return chosen, (
133
+ f"<p class='note'>Object #{int(requested)} is permanently hidden behind another object "
134
+ f"in this room, so it cannot host a leave-and-return episode. Showing object "
135
+ f"#{chosen} instead.</p>"
136
+ )
137
+
138
+
139
+ # --------------------------------------------------------------------------
140
+ # public entry points (called from JavaScript)
141
+ # --------------------------------------------------------------------------
142
+
143
+
144
+ def scene_preview(seed: int, n_objects: int) -> str:
145
+ """Four views of the room, so you can see what you are about to test."""
146
+ from cairn.types import CameraPose
147
+
148
+ scene = make_scene(int(n_objects), seed=int(seed))
149
+ cams = [
150
+ CameraPose(np.array([5.0, 1.55, 5.0]), a)
151
+ for a in np.linspace(-np.pi, np.pi, 4, endpoint=False)
152
+ ]
153
+ frames = np.stack([render(scene.states(), c, scene.settings).rgb for c in cams])
154
+ return _filmstrip(frames, list(range(4)))
155
+
156
+
157
+ def _verdict_html(res, label: str) -> str:
158
+ m = res.metrics
159
+ drawn = m["return_observed"] > 0.5
160
+ ok = m["return_success"] > 0.5
161
+ err = "not drawn at all" if not drawn else f"{m['return_self_trans']:.2f} m"
162
+ badge = (
163
+ "<span class='ok'>consistent</span>" if ok else "<span class='bad'>inconsistent</span>"
164
+ )
165
+ return (
166
+ f"<div class='verdict'><h4>{label} &nbsp;{badge}</h4><ul>"
167
+ f"<li>moved on return: <b>{err}</b></li>"
168
+ f"<li>identity preserved: {'yes' if m['return_identity_preserved'] else '<b>no</b>'}</li>"
169
+ f"<li>yaw error {m['return_self_yaw']:.2f} rad &middot; "
170
+ f"appearance error {m['return_self_appearance']:.3f}</li>"
171
+ f"<li>integrated consistency debt {m['debt_area']:.1f} (peak {m['debt_peak']:.2f})</li>"
172
+ f"</ul></div>"
173
+ )
174
+
175
+
176
+ def compare(seed: int, n_objects: int, absence: int, target: int, baseline: str) -> Dict[str, Any]:
177
+ """Same scene, same trajectory, same generator seed — only the memory differs.
178
+
179
+ Claim: R -- the interactive form of the headline experiment.
180
+ """
181
+ seed, n_objects, absence = int(seed), int(n_objects), int(absence)
182
+ scene = make_scene(n_objects, seed=seed)
183
+ target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)
184
+ traj = make_departure_return_trajectory(scene, target, absence, seed=seed)
185
+
186
+ off = run_condition(scene, traj, _cfg(baseline, seed))
187
+ on = run_condition(scene, traj, _cfg("D", seed))
188
+ idx = _windows(traj)
189
+
190
+ header = (
191
+ f"<p>Camera left object <b>#{target}</b> at frame {traj.departure_frame} and came back "
192
+ f"at frame {traj.return_frame} — <b>{traj.absence_frames} frames away</b>. "
193
+ f"The return viewpoint is deliberately <i>not</i> the departure viewpoint, so neither "
194
+ f"method can win by replaying its last frame.</p>"
195
+ f"<p class='caption'>Panels: {PANEL_CAPTION}</p>{note}"
196
+ )
197
+ return {
198
+ "header": header,
199
+ "off_img": _filmstrip(off.frames, idx),
200
+ "on_img": _filmstrip(on.frames, idx),
201
+ "off_label": f"Cairn OFF — ({baseline}) {CONDITION_NAMES[baseline]}",
202
+ "on_label": "Cairn ON — (D) explicit world ledger",
203
+ "off_verdict": _verdict_html(off, f"Cairn OFF — ({baseline}) {BASELINES[baseline]}"),
204
+ "on_verdict": _verdict_html(on, "Cairn ON — (D) explicit world ledger"),
205
+ "ledger": _ledger_html(on),
206
+ }
207
+
208
+
209
+ def edit(seed: int, n_objects: int, absence: int, target: int, kind: str) -> Dict[str, Any]:
210
+ """Issue an edit while the object is off screen, then score what came back.
211
+
212
+ Claim: E -- the operation conditions A–C cannot express at all.
213
+ """
214
+ seed, n_objects, absence = int(seed), int(n_objects), int(absence)
215
+ scene = make_scene(n_objects, seed=seed)
216
+ target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)
217
+ traj = make_departure_return_trajectory(scene, target, absence, seed=seed)
218
+ ev = schedule_edit(traj, scene, kind, seed=seed)
219
+ traj.edits = [ev]
220
+
221
+ on = run_condition(scene, traj, _cfg("D", seed))
222
+ off = run_condition(scene, traj, _cfg("A", seed))
223
+ idx = _windows(traj)
224
+
225
+ if kind == "move":
226
+ d = float(np.linalg.norm(np.asarray(ev.payload["position"]) - scene.get(target).pose.position))
227
+ what = f"move object #{target} {d:.1f} m and turn it"
228
+ elif kind == "remove":
229
+ what = f"delete object #{target} from the world"
230
+ else:
231
+ v = np.asarray(ev.payload["value"], dtype=float)
232
+ what = (
233
+ f"recolour object #{target} to "
234
+ f"<span class='sw' style='background:rgb({int(v[0]*255)},{int(v[1]*255)},{int(v[2]*255)})'></span>"
235
+ f"RGB {np.round(v, 2).tolist()}"
236
+ )
237
+
238
+ rows = []
239
+ for res, cond in ((off, "A"), (on, "D")):
240
+ for s in res.edit_scores:
241
+ expressible = (
242
+ "yes" if cond == "D" else "<b>no</b> — no addressable world state"
243
+ )
244
+ if s.complied:
245
+ v = "<span class='ok'>yes</span>"
246
+ elif s.ledger_correct:
247
+ v = "<span class='warn'>written, not confirmable</span>"
248
+ else:
249
+ v = "<span class='bad'>no</span>"
250
+ rows.append(
251
+ f"<tr><td>({cond}) {CONDITION_NAMES[cond]}</td><td>{expressible}</td>"
252
+ f"<td>{v}</td><td><code>{s.detail}</code></td></tr>"
253
+ )
254
+
255
+ header = (
256
+ f"<p><b>Command:</b> {what}</p>"
257
+ f"<p>Issued at frame <b>{ev.frame}</b>, while the object is off screen "
258
+ f"(frames {traj.departure_frame}–{traj.return_frame}).</p>"
259
+ f"<p class='caption'>Panels: {PANEL_CAPTION}</p>{note}"
260
+ )
261
+ table = (
262
+ "<table><thead><tr><th>condition</th><th>can express it?</th><th>obeyed?</th>"
263
+ "<th>evidence</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
264
+ f"<p class='note'>Conditions A–C hold the world implicitly, in activations. There is no "
265
+ f'row named "object #{target}" to write to, so <code>move</code>/<code>remove</code>/'
266
+ f"<code>set_attr</code> are not merely hard for them — they are undefined.</p>"
267
+ )
268
+ return {
269
+ "header": header,
270
+ "off_img": _filmstrip(off.frames, idx),
271
+ "on_img": _filmstrip(on.frames, idx),
272
+ "table": table,
273
+ "ledger": _ledger_html(on),
274
+ }
275
+
276
+
277
+ def ledger_view(seed: int, n_objects: int, absence: int, target: int, rewind_to: int) -> Dict[str, Any]:
278
+ """Show the ledger, its transaction log, and the effect of a rewind.
279
+
280
+ Claim: E -- the world is a table with an audit trail; rewinding is one call.
281
+ """
282
+ seed, n_objects, absence = int(seed), int(n_objects), int(absence)
283
+ scene = make_scene(n_objects, seed=seed)
284
+ tgt, _ = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)
285
+ traj = make_departure_return_trajectory(scene, tgt, absence, seed=seed)
286
+ res = run_condition(scene, traj, _cfg("D", seed))
287
+ led = res.ledger
288
+
289
+ before = _ledger_html(res)
290
+ log_rows = "".join(
291
+ f"<tr><td>{t.index}</td><td>{t.t}</td><td><code>{t.op}</code></td>"
292
+ f"<td>{t.object_id}</td><td>{t.source}</td><td>{t.note or ''}</td></tr>"
293
+ for t in led.log[-40:]
294
+ )
295
+ log = (
296
+ "<table><thead><tr><th>#</th><th>frame</th><th>op</th><th>object</th>"
297
+ "<th>source</th><th>note</th></tr></thead><tbody>" + log_rows + "</tbody></table>"
298
+ )
299
+ v0 = led.version
300
+ undone = led.rollback_to_time(int(rewind_to))
301
+ note = (
302
+ f"<p><b>Rewound to frame {int(rewind_to)}</b>: undid {undone} of {v0} transactions. "
303
+ f"The ledger is now exactly as it stood at that instant, object for object. No learned "
304
+ f"memory offers this operation — its state is entangled across every object and every "
305
+ f"timestep at once.</p>"
306
+ )
307
+ return {"before": before, "log": log, "after": _ledger_html(res), "note": note}
build_space.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Assemble the static browser Space: ``index.html`` + the ``cairn`` wheel.
3
+
4
+ The Space is a plain Pyodide page, not a Gradio-lite one. That was not the first
5
+ choice; it is the choice that works. Gradio-lite imports ``gradio`` *before* it
6
+ installs the page's requirements, and gradio 5.x currently cannot be resolved
7
+ against ``huggingface-hub`` 1.x inside Pyodide:
8
+
9
+ * 5.42+ pin ``huggingface-hub<1.0`` while their bundled ``gradio_client`` asks
10
+ only for ``>=0.19.3``; micropip gathers the two concurrently, resolves the
11
+ unbounded one to 1.x, then the capped one raises
12
+ ``Requested 'huggingface-hub<1.0,>=0.33.5', but huggingface-hub==1.27.0 is
13
+ already installed``.
14
+ * Older builds (<= 5.38.2) leave it uncapped, so the install succeeds and the
15
+ *import* fails instead: ``huggingface_hub`` 1.x does ``import httpcore``, and
16
+ Pyodide's patched ``httpx`` does not bring httpcore with it.
17
+
18
+ Neither is reachable from ``<gradio-requirements>``, because that is installed
19
+ after the import. Driving Pyodide directly fixes the ordering, and drops
20
+ gradio's whole dependency stack (pandas, pydantic, orjson, ...) from the
21
+ download, so the page loads faster as a side effect.
22
+
23
+ python space/build_space.py # writes space/index.html
24
+ python space/build_space.py --serve # ...and serves it for local testing
25
+
26
+ ``browser_app.py`` stays a real, importable, compile-checked Python file; this
27
+ script embeds it and refuses to emit HTML if it does not parse.
28
+
29
+ Claim: O -- the free, zero-install path has to be maintainable, or it rots and
30
+ "anyone can check this" quietly stops being true.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import os
37
+ import shutil
38
+
39
+ HERE = os.path.dirname(os.path.abspath(__file__))
40
+ ROOT = os.path.dirname(HERE)
41
+ WHEEL = "cairn-0.1.0-py3-none-any.whl"
42
+
43
+ # Pinned: a silent bump in a CDN dependency would break the Space with no commit
44
+ # to point at. Pyodide 0.27.x ships numpy, scipy and pillow as prebuilt wasm.
45
+ PYODIDE = "0.27.3"
46
+
47
+ TEMPLATE = r"""<!doctype html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="utf-8" />
51
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
52
+ <title>Cairn — leave, come back, same world</title>
53
+ <meta name="description" content="An explicit world ledger makes video generation return-consistent and editable. Runs entirely in your browser." />
54
+ <style>
55
+ :root {{ --fg:#1a1a1a; --mut:#666; --line:#e3e3e3; --accent:#2f7ab8; --bg:#fff; --soft:#f7f9fb; }}
56
+ * {{ box-sizing: border-box; }}
57
+ body {{ margin:0; font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
58
+ color:var(--fg); background:var(--bg); }}
59
+ .wrap {{ max-width: 62rem; margin: 0 auto; padding: 2rem 1.25rem 5rem; }}
60
+ h1 {{ font-size:1.9rem; margin:.2rem 0 .2rem; letter-spacing:-.01em; }}
61
+ .tag {{ color:var(--mut); font-size:1.05rem; margin:0 0 1.2rem; }}
62
+ h3 {{ margin:1.6rem 0 .5rem; font-size:1.05rem; }}
63
+ h4 {{ margin:0 0 .4rem; font-size:.95rem; }}
64
+ p {{ margin:.5rem 0; }}
65
+ a {{ color:var(--accent); }}
66
+ code {{ background:var(--soft); padding:.08em .35em; border-radius:3px; font-size:.86em; }}
67
+ .lede {{ background:var(--soft); border-left:3px solid var(--accent); padding:.9rem 1.1rem;
68
+ border-radius:0 6px 6px 0; }}
69
+ .controls {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));
70
+ gap:.9rem 1.4rem; margin:1.2rem 0; padding:1rem; border:1px solid var(--line);
71
+ border-radius:8px; }}
72
+ .ctl label {{ display:block; font-size:.8rem; color:var(--mut); margin-bottom:.25rem; }}
73
+ .ctl input[type=range] {{ width:100%; }}
74
+ .val {{ font-variant-numeric:tabular-nums; color:var(--fg); font-weight:600; }}
75
+ select, button {{ font:inherit; }}
76
+ button.run {{ background:var(--accent); color:#fff; border:0; border-radius:6px;
77
+ padding:.6rem 1.1rem; cursor:pointer; font-weight:600; }}
78
+ button.run:disabled {{ background:#9bb8cd; cursor:progress; }}
79
+ .tabs {{ display:flex; gap:.35rem; border-bottom:1px solid var(--line); margin:1.6rem 0 1rem;
80
+ flex-wrap:wrap; }}
81
+ .tabs button {{ background:none; border:0; border-bottom:2px solid transparent; cursor:pointer;
82
+ padding:.6rem .9rem; color:var(--mut); font-weight:600; }}
83
+ .tabs button.on {{ color:var(--accent); border-bottom-color:var(--accent); }}
84
+ .panel {{ display:none; }} .panel.on {{ display:block; }}
85
+ img.strip {{ width:100%; border:1px solid var(--line); border-radius:6px;
86
+ display:block; margin:.3rem 0 1rem; }}
87
+ .caption, .note {{ color:var(--mut); font-size:.87rem; }}
88
+ .verdict {{ border:1px solid var(--line); border-radius:6px; padding:.7rem .9rem; margin:.6rem 0 1.2rem; }}
89
+ .verdict ul {{ margin:.3rem 0 0; padding-left:1.1rem; }}
90
+ .verdict li {{ margin:.15rem 0; }}
91
+ .ok {{ color:#136f3b; font-weight:700; }} .bad {{ color:#b3261e; font-weight:700; }}
92
+ .warn {{ color:#8a6100; font-weight:700; }}
93
+ table {{ border-collapse:collapse; width:100%; font-size:.85rem; margin:.5rem 0 1rem;
94
+ display:block; overflow-x:auto; }}
95
+ th,td {{ border-bottom:1px solid var(--line); padding:.35rem .5rem; text-align:left;
96
+ white-space:nowrap; }}
97
+ th {{ color:var(--mut); font-weight:600; }}
98
+ .sw {{ display:inline-block; width:.7em; height:.7em; border-radius:2px; margin-right:.4em;
99
+ border:1px solid rgba(128,128,128,.4); }}
100
+ #boot {{ padding:1rem 1.2rem; border:1px solid var(--line); border-radius:8px;
101
+ background:var(--soft); color:var(--mut); }}
102
+ #boot .bar {{ height:4px; background:rgba(128,128,128,.2); border-radius:2px; overflow:hidden;
103
+ margin-top:.7rem; }}
104
+ #boot .bar i {{ display:block; height:100%; width:30%; background:var(--accent);
105
+ animation:slide 1.4s ease-in-out infinite; }}
106
+ @keyframes slide {{ 0%{{margin-left:-30%}} 100%{{margin-left:100%}} }}
107
+ footer {{ margin-top:2.5rem; padding-top:1.2rem; border-top:1px solid var(--line);
108
+ color:var(--mut); font-size:.88rem; }}
109
+ @media (prefers-color-scheme: dark) {{
110
+ :root {{ --fg:#e8e8e8; --mut:#9aa0a6; --line:#333; --bg:#141414; --soft:#1d1f21;
111
+ --accent:#5aa9e6; }}
112
+ }}
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div class="wrap">
117
+
118
+ <h1>Cairn — leave, come back, same world</h1>
119
+ <p class="tag">An explicit world ledger makes video generation return-consistent, and editable.</p>
120
+
121
+ <div class="lede">
122
+ <p style="margin-top:0">Video world models forget. Turn the camera away from a chair for a few
123
+ seconds and turn back, and it is a different chair, somewhere else, or gone. The usual fixes
124
+ give the model <i>more implicit memory</i> — a longer context window, or a compressed latent.
125
+ Both decay with how long you looked away.</p>
126
+ <p><b>Cairn takes the world out of the weights.</b> Objects live in an explicit external
127
+ ledger — persistent id, pose, appearance, provenance — written by perception on the model's
128
+ <i>own generated frames</i>, and read back to <b>coerce</b> generation when the camera returns.
129
+ A table lookup costs the same whether you looked away for 4 frames or 400.</p>
130
+ <p style="margin-bottom:0">Over 5 seeds, at 128 frames away, every baseline returns a broken
131
+ world <b>0%</b> of the time and Cairn <b>100%</b>, with a flat 3.6&nbsp;cm error. Try to break
132
+ it below.</p>
133
+ </div>
134
+
135
+ <div id="boot">
136
+ <b>Starting Python in your browser…</b>
137
+ <div id="bootmsg">Loading Pyodide, numpy, scipy and the <code>cairn</code> package. The first
138
+ load takes roughly 30 seconds and is cached afterwards. Nothing is sent to a server — the whole
139
+ benchmark runs on your machine.</div>
140
+ <div class="bar"><i></i></div>
141
+ </div>
142
+
143
+ <div id="app" hidden>
144
+ <div class="controls">
145
+ <div class="ctl"><label>scene seed <span class="val" id="seedv">0</span></label>
146
+ <input type="range" id="seed" min="0" max="40" step="1" value="0"></div>
147
+ <div class="ctl"><label>objects in the room <span class="val" id="nobjv">8</span></label>
148
+ <input type="range" id="nobj" min="4" max="8" step="1" value="8"></div>
149
+ <div class="ctl"><label>object to leave &amp; return to <span class="val" id="tgtv">0</span></label>
150
+ <input type="range" id="tgt" min="0" max="7" step="1" value="0"></div>
151
+ <div class="ctl"><label>frames to look away <span class="val" id="absv">48</span></label>
152
+ <input type="range" id="abs" min="4" max="128" step="4" value="48"></div>
153
+ </div>
154
+
155
+ <img class="strip" id="preview" alt="the room, from four directions">
156
+ <p class="caption">The room, from four directions.</p>
157
+
158
+ <div class="tabs">
159
+ <button class="on" data-tab="t1">1 · Leave &amp; return</button>
160
+ <button data-tab="t2">2 · Edit the world</button>
161
+ <button data-tab="t3">3 · The ledger</button>
162
+ </div>
163
+
164
+ <section class="panel on" id="t1">
165
+ <p><label>compare Cairn against
166
+ <select id="baseline">
167
+ <option value="B" selected>(B) Context-window</option>
168
+ <option value="A">(A) Vanilla</option>
169
+ <option value="C">(C) Compressed-memory</option>
170
+ </select></label>
171
+ &nbsp; <button class="run" id="go1">Look away, then look back</button></p>
172
+ <div id="out1"></div>
173
+ </section>
174
+
175
+ <section class="panel" id="t2">
176
+ <p><label>command to issue while the object is off screen
177
+ <select id="kind">
178
+ <option value="move" selected>move it somewhere else</option>
179
+ <option value="remove">delete it</option>
180
+ <option value="set_attr">recolour it</option>
181
+ </select></label>
182
+ &nbsp; <button class="run" id="go2">Issue the command, then look back</button></p>
183
+ <div id="out2"></div>
184
+ </section>
185
+
186
+ <section class="panel" id="t3">
187
+ <p class="caption">Cairn's entire memory is this table plus its transaction log. It is
188
+ written by perception running on the generated frames — never from ground truth — and every
189
+ mutation is invertible, so the world rewinds to any instant.</p>
190
+ <p><label>rewind the world to frame <span class="val" id="rwv">20</span></label>
191
+ <input type="range" id="rw" min="0" max="160" step="1" value="20" style="width:16rem">
192
+ &nbsp; <button class="run" id="go3">Run, then rewind</button></p>
193
+ <div id="out3"></div>
194
+ </section>
195
+ </div>
196
+
197
+ <footer>
198
+ <p><b>Reading the numbers.</b> <i>Moved on return</i> compares where the object is when the
199
+ camera comes back against where <b>the same run</b> showed it before leaving —
200
+ self-consistency, measured from generated pixels by the same detector for every condition,
201
+ never from the ledger. <i>Consistency debt</i> is the per-frame divergence between the video
202
+ and the committed record; Cairn closes a control loop on it, the baselines can only be
203
+ measured by it.</p>
204
+ <p><b>What you are watching.</b> A surrogate generator that reproduces how autoregressive
205
+ video drifts (random walk + prior pull + salience decay), not a real video backbone — that is
206
+ the trade that buys exact ground truth and a benchmark you can run on a laptop. The same
207
+ <code>cairn</code> library wraps a real diffusers video pipeline in one line:
208
+ <code>CairnPipeline.from_pipeline(pipe)</code>.</p>
209
+ <p><a href="https://github.com/NagaYu/cairn">Code</a> &middot;
210
+ <a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> &middot;
211
+ full benchmark, ablation and honest limitations are in the README.</p>
212
+ </footer>
213
+ </div>
214
+
215
+ <script type="module">
216
+ import {{ loadPyodide }} from "https://cdn.jsdelivr.net/pyodide/v{pyodide}/full/pyodide.mjs";
217
+
218
+ const $ = (id) => document.getElementById(id);
219
+ const boot = $("boot"), bootmsg = $("bootmsg");
220
+ const say = (m) => {{ bootmsg.innerHTML = m; }};
221
+ let py = null;
222
+
223
+ const APP_SRC = {app!r};
224
+
225
+ async function start() {{
226
+ try {{
227
+ say("Loading Pyodide…");
228
+ py = await loadPyodide({{ indexURL: "https://cdn.jsdelivr.net/pyodide/v{pyodide}/full/" }});
229
+ say("Loading numpy, scipy and pillow…");
230
+ await py.loadPackage(["numpy", "scipy", "pillow", "micropip"]);
231
+ say("Installing the <code>cairn</code> package…");
232
+ const micropip = py.pyimport("micropip");
233
+ // Absolute URL: micropip resolves relative paths against its own base, not
234
+ // the page's, so a bare filename 404s both locally and on the Space.
235
+ const wheelUrl = new URL("{wheel}", window.location.href).href;
236
+ await micropip.install(wheelUrl);
237
+ say("Starting…");
238
+ py.runPython(APP_SRC);
239
+ boot.hidden = true;
240
+ $("app").hidden = false;
241
+ preview();
242
+ }} catch (e) {{
243
+ boot.innerHTML = "<b>Could not start.</b><p>" + String(e).slice(0, 900) +
244
+ "</p><p class='note'>This page needs WebAssembly and about 40 MB of downloads. " +
245
+ "Everything here also runs locally — see the " +
246
+ "<a href='https://github.com/NagaYu/cairn'>repository</a>.</p>";
247
+ throw e;
248
+ }}
249
+ }}
250
+
251
+ // Call a Python function with JSON-able args, get a plain JS object back.
252
+ function call(fn, ...args) {{
253
+ const f = py.globals.get(fn);
254
+ const r = f(...args);
255
+ const out = (r && r.toJs) ? r.toJs({{ dict_converter: Object.fromEntries }}) : r;
256
+ if (r && r.destroy) r.destroy();
257
+ if (f && f.destroy) f.destroy();
258
+ return out;
259
+ }}
260
+
261
+ async function busy(btn, work) {{
262
+ const label = btn.textContent;
263
+ btn.disabled = true;
264
+ btn.textContent = "running…";
265
+ // Yield so the browser paints the disabled state before the synchronous Python
266
+ // call blocks the main thread. Deliberately a timer and not
267
+ // requestAnimationFrame: rAF is paused in background and hidden tabs, so a
268
+ // user who switches away mid-click would never get their result back.
269
+ await new Promise(r => setTimeout(r, 30));
270
+ try {{ work(); }}
271
+ catch (e) {{ alert(String(e).slice(0, 700)); }}
272
+ finally {{ btn.disabled = false; btn.textContent = label; }}
273
+ }}
274
+
275
+ const v = (id) => parseInt($(id).value, 10);
276
+ const strip = (label, src) => `<h4>${{label}}</h4><img class="strip" src="${{src}}" alt="${{label}}">`;
277
+
278
+ function preview() {{ $("preview").src = call("scene_preview", v("seed"), v("nobj")); }}
279
+
280
+ for (const [id, out] of [["seed","seedv"],["nobj","nobjv"],["tgt","tgtv"],["abs","absv"],["rw","rwv"]]) {{
281
+ $(id).addEventListener("input", () => {{ $(out).textContent = $(id).value; }});
282
+ }}
283
+ let timer = null;
284
+ for (const id of ["seed", "nobj"]) {{
285
+ $(id).addEventListener("change", () => {{
286
+ $("tgt").max = String(v("nobj") - 1);
287
+ if (v("tgt") > v("nobj") - 1) {{
288
+ $("tgt").value = String(v("nobj") - 1);
289
+ $("tgtv").textContent = $("tgt").value;
290
+ }}
291
+ clearTimeout(timer);
292
+ timer = setTimeout(preview, 120);
293
+ }});
294
+ }}
295
+ for (const b of document.querySelectorAll(".tabs button")) {{
296
+ b.addEventListener("click", () => {{
297
+ document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
298
+ document.querySelectorAll(".panel").forEach(p => p.classList.toggle("on", p.id === b.dataset.tab));
299
+ }});
300
+ }}
301
+
302
+ $("go1").addEventListener("click", () => busy($("go1"), () => {{
303
+ const r = call("compare", v("seed"), v("nobj"), v("abs"), v("tgt"), $("baseline").value);
304
+ $("out1").innerHTML = r.header
305
+ + strip(r.off_label, r.off_img) + r.off_verdict
306
+ + strip(r.on_label, r.on_img) + r.on_verdict
307
+ + "<h3>The ledger Cairn built from its own generated frames</h3>" + r.ledger;
308
+ }}));
309
+
310
+ $("go2").addEventListener("click", () => busy($("go2"), () => {{
311
+ const r = call("edit", v("seed"), v("nobj"), v("abs"), v("tgt"), $("kind").value);
312
+ $("out2").innerHTML = r.header
313
+ + strip("Vanilla — command not expressible", r.off_img)
314
+ + strip("Cairn — command written to the ledger", r.on_img)
315
+ + r.table + "<h3>Ledger after the edit</h3>" + r.ledger;
316
+ }}));
317
+
318
+ $("go3").addEventListener("click", () => busy($("go3"), () => {{
319
+ const r = call("ledger_view", v("seed"), v("nobj"), v("abs"), v("tgt"), v("rw"));
320
+ $("out3").innerHTML = "<h3>Ledger at end of run</h3>" + r.before
321
+ + "<h3>Transaction log (last 40)</h3>" + r.log
322
+ + "<h3>Ledger after rewind</h3>" + r.after + r.note;
323
+ }}));
324
+
325
+ start();
326
+ </script>
327
+ </body>
328
+ </html>
329
+ """
330
+
331
+
332
+ def build(serve: bool = False, port: int = 8000) -> str:
333
+ app_path = os.path.join(HERE, "browser_app.py")
334
+ with open(app_path) as f:
335
+ app_src = f.read()
336
+ compile(app_src, app_path, "exec") # fail loudly rather than shipping broken HTML
337
+
338
+ html = TEMPLATE.format(pyodide=PYODIDE, wheel=WHEEL, app=app_src)
339
+ out = os.path.join(HERE, "index.html")
340
+ with open(out, "w") as f:
341
+ f.write(html)
342
+
343
+ wheel_src = os.path.join(ROOT, "dist", WHEEL)
344
+ if not os.path.exists(wheel_src):
345
+ raise SystemExit(f"missing {wheel_src}; run `python -m build --wheel -o dist` first")
346
+ shutil.copy2(wheel_src, os.path.join(HERE, WHEEL))
347
+ print(f"wrote {out} ({len(html) / 1024:.0f} KB) and {WHEEL}")
348
+
349
+ if serve:
350
+ import http.server
351
+ import socketserver
352
+
353
+ os.chdir(HERE)
354
+ with socketserver.TCPServer(("", port), http.server.SimpleHTTPRequestHandler) as httpd:
355
+ print(f"serving {HERE} at http://localhost:{port}/ (ctrl-c to stop)")
356
+ httpd.serve_forever()
357
+ return out
358
+
359
+
360
+ if __name__ == "__main__":
361
+ ap = argparse.ArgumentParser(description=__doc__)
362
+ ap.add_argument("--serve", action="store_true")
363
+ ap.add_argument("--port", type=int, default=8000)
364
+ build(**vars(ap.parse_args()))
cairn-0.1.0-py3-none-any.whl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:55df3b81528fa33de8d7bc8129d254419467fd48af7e6a3766f29c7570148d9d
3
+ size 111218
index.html CHANGED
@@ -1,19 +1,282 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Cairn leave, come back, same world</title>
7
+ <meta name="description" content="An explicit world ledger makes video generation return-consistent and editable. Runs entirely in your browser." />
8
+ <style>
9
+ :root { --fg:#1a1a1a; --mut:#666; --line:#e3e3e3; --accent:#2f7ab8; --bg:#fff; --soft:#f7f9fb; }
10
+ * { box-sizing: border-box; }
11
+ body { margin:0; font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
12
+ color:var(--fg); background:var(--bg); }
13
+ .wrap { max-width: 62rem; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
14
+ h1 { font-size:1.9rem; margin:.2rem 0 .2rem; letter-spacing:-.01em; }
15
+ .tag { color:var(--mut); font-size:1.05rem; margin:0 0 1.2rem; }
16
+ h3 { margin:1.6rem 0 .5rem; font-size:1.05rem; }
17
+ h4 { margin:0 0 .4rem; font-size:.95rem; }
18
+ p { margin:.5rem 0; }
19
+ a { color:var(--accent); }
20
+ code { background:var(--soft); padding:.08em .35em; border-radius:3px; font-size:.86em; }
21
+ .lede { background:var(--soft); border-left:3px solid var(--accent); padding:.9rem 1.1rem;
22
+ border-radius:0 6px 6px 0; }
23
+ .controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));
24
+ gap:.9rem 1.4rem; margin:1.2rem 0; padding:1rem; border:1px solid var(--line);
25
+ border-radius:8px; }
26
+ .ctl label { display:block; font-size:.8rem; color:var(--mut); margin-bottom:.25rem; }
27
+ .ctl input[type=range] { width:100%; }
28
+ .val { font-variant-numeric:tabular-nums; color:var(--fg); font-weight:600; }
29
+ select, button { font:inherit; }
30
+ button.run { background:var(--accent); color:#fff; border:0; border-radius:6px;
31
+ padding:.6rem 1.1rem; cursor:pointer; font-weight:600; }
32
+ button.run:disabled { background:#9bb8cd; cursor:progress; }
33
+ .tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--line); margin:1.6rem 0 1rem;
34
+ flex-wrap:wrap; }
35
+ .tabs button { background:none; border:0; border-bottom:2px solid transparent; cursor:pointer;
36
+ padding:.6rem .9rem; color:var(--mut); font-weight:600; }
37
+ .tabs button.on { color:var(--accent); border-bottom-color:var(--accent); }
38
+ .panel { display:none; } .panel.on { display:block; }
39
+ img.strip { width:100%; border:1px solid var(--line); border-radius:6px;
40
+ display:block; margin:.3rem 0 1rem; }
41
+ .caption, .note { color:var(--mut); font-size:.87rem; }
42
+ .verdict { border:1px solid var(--line); border-radius:6px; padding:.7rem .9rem; margin:.6rem 0 1.2rem; }
43
+ .verdict ul { margin:.3rem 0 0; padding-left:1.1rem; }
44
+ .verdict li { margin:.15rem 0; }
45
+ .ok { color:#136f3b; font-weight:700; } .bad { color:#b3261e; font-weight:700; }
46
+ .warn { color:#8a6100; font-weight:700; }
47
+ table { border-collapse:collapse; width:100%; font-size:.85rem; margin:.5rem 0 1rem;
48
+ display:block; overflow-x:auto; }
49
+ th,td { border-bottom:1px solid var(--line); padding:.35rem .5rem; text-align:left;
50
+ white-space:nowrap; }
51
+ th { color:var(--mut); font-weight:600; }
52
+ .sw { display:inline-block; width:.7em; height:.7em; border-radius:2px; margin-right:.4em;
53
+ border:1px solid rgba(128,128,128,.4); }
54
+ #boot { padding:1rem 1.2rem; border:1px solid var(--line); border-radius:8px;
55
+ background:var(--soft); color:var(--mut); }
56
+ #boot .bar { height:4px; background:rgba(128,128,128,.2); border-radius:2px; overflow:hidden;
57
+ margin-top:.7rem; }
58
+ #boot .bar i { display:block; height:100%; width:30%; background:var(--accent);
59
+ animation:slide 1.4s ease-in-out infinite; }
60
+ @keyframes slide { 0%{margin-left:-30%} 100%{margin-left:100%} }
61
+ footer { margin-top:2.5rem; padding-top:1.2rem; border-top:1px solid var(--line);
62
+ color:var(--mut); font-size:.88rem; }
63
+ @media (prefers-color-scheme: dark) {
64
+ :root { --fg:#e8e8e8; --mut:#9aa0a6; --line:#333; --bg:#141414; --soft:#1d1f21;
65
+ --accent:#5aa9e6; }
66
+ }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div class="wrap">
71
+
72
+ <h1>Cairn — leave, come back, same world</h1>
73
+ <p class="tag">An explicit world ledger makes video generation return-consistent, and editable.</p>
74
+
75
+ <div class="lede">
76
+ <p style="margin-top:0">Video world models forget. Turn the camera away from a chair for a few
77
+ seconds and turn back, and it is a different chair, somewhere else, or gone. The usual fixes
78
+ give the model <i>more implicit memory</i> — a longer context window, or a compressed latent.
79
+ Both decay with how long you looked away.</p>
80
+ <p><b>Cairn takes the world out of the weights.</b> Objects live in an explicit external
81
+ ledger — persistent id, pose, appearance, provenance — written by perception on the model's
82
+ <i>own generated frames</i>, and read back to <b>coerce</b> generation when the camera returns.
83
+ A table lookup costs the same whether you looked away for 4 frames or 400.</p>
84
+ <p style="margin-bottom:0">Over 5 seeds, at 128 frames away, every baseline returns a broken
85
+ world <b>0%</b> of the time and Cairn <b>100%</b>, with a flat 3.6&nbsp;cm error. Try to break
86
+ it below.</p>
87
+ </div>
88
+
89
+ <div id="boot">
90
+ <b>Starting Python in your browser…</b>
91
+ <div id="bootmsg">Loading Pyodide, numpy, scipy and the <code>cairn</code> package. The first
92
+ load takes roughly 30 seconds and is cached afterwards. Nothing is sent to a server — the whole
93
+ benchmark runs on your machine.</div>
94
+ <div class="bar"><i></i></div>
95
+ </div>
96
+
97
+ <div id="app" hidden>
98
+ <div class="controls">
99
+ <div class="ctl"><label>scene seed <span class="val" id="seedv">0</span></label>
100
+ <input type="range" id="seed" min="0" max="40" step="1" value="0"></div>
101
+ <div class="ctl"><label>objects in the room <span class="val" id="nobjv">8</span></label>
102
+ <input type="range" id="nobj" min="4" max="8" step="1" value="8"></div>
103
+ <div class="ctl"><label>object to leave &amp; return to <span class="val" id="tgtv">0</span></label>
104
+ <input type="range" id="tgt" min="0" max="7" step="1" value="0"></div>
105
+ <div class="ctl"><label>frames to look away <span class="val" id="absv">48</span></label>
106
+ <input type="range" id="abs" min="4" max="128" step="4" value="48"></div>
107
+ </div>
108
+
109
+ <img class="strip" id="preview" alt="the room, from four directions">
110
+ <p class="caption">The room, from four directions.</p>
111
+
112
+ <div class="tabs">
113
+ <button class="on" data-tab="t1">1 · Leave &amp; return</button>
114
+ <button data-tab="t2">2 · Edit the world</button>
115
+ <button data-tab="t3">3 · The ledger</button>
116
+ </div>
117
+
118
+ <section class="panel on" id="t1">
119
+ <p><label>compare Cairn against
120
+ <select id="baseline">
121
+ <option value="B" selected>(B) Context-window</option>
122
+ <option value="A">(A) Vanilla</option>
123
+ <option value="C">(C) Compressed-memory</option>
124
+ </select></label>
125
+ &nbsp; <button class="run" id="go1">Look away, then look back</button></p>
126
+ <div id="out1"></div>
127
+ </section>
128
+
129
+ <section class="panel" id="t2">
130
+ <p><label>command to issue while the object is off screen
131
+ <select id="kind">
132
+ <option value="move" selected>move it somewhere else</option>
133
+ <option value="remove">delete it</option>
134
+ <option value="set_attr">recolour it</option>
135
+ </select></label>
136
+ &nbsp; <button class="run" id="go2">Issue the command, then look back</button></p>
137
+ <div id="out2"></div>
138
+ </section>
139
+
140
+ <section class="panel" id="t3">
141
+ <p class="caption">Cairn's entire memory is this table plus its transaction log. It is
142
+ written by perception running on the generated frames — never from ground truth — and every
143
+ mutation is invertible, so the world rewinds to any instant.</p>
144
+ <p><label>rewind the world to frame <span class="val" id="rwv">20</span></label>
145
+ <input type="range" id="rw" min="0" max="160" step="1" value="20" style="width:16rem">
146
+ &nbsp; <button class="run" id="go3">Run, then rewind</button></p>
147
+ <div id="out3"></div>
148
+ </section>
149
+ </div>
150
+
151
+ <footer>
152
+ <p><b>Reading the numbers.</b> <i>Moved on return</i> compares where the object is when the
153
+ camera comes back against where <b>the same run</b> showed it before leaving —
154
+ self-consistency, measured from generated pixels by the same detector for every condition,
155
+ never from the ledger. <i>Consistency debt</i> is the per-frame divergence between the video
156
+ and the committed record; Cairn closes a control loop on it, the baselines can only be
157
+ measured by it.</p>
158
+ <p><b>What you are watching.</b> A surrogate generator that reproduces how autoregressive
159
+ video drifts (random walk + prior pull + salience decay), not a real video backbone — that is
160
+ the trade that buys exact ground truth and a benchmark you can run on a laptop. The same
161
+ <code>cairn</code> library wraps a real diffusers video pipeline in one line:
162
+ <code>CairnPipeline.from_pipeline(pipe)</code>.</p>
163
+ <p><a href="https://github.com/NagaYu/cairn">Code</a> &middot;
164
+ <a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> &middot;
165
+ full benchmark, ablation and honest limitations are in the README.</p>
166
+ </footer>
167
+ </div>
168
+
169
+ <script type="module">
170
+ import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.27.3/full/pyodide.mjs";
171
+
172
+ const $ = (id) => document.getElementById(id);
173
+ const boot = $("boot"), bootmsg = $("bootmsg");
174
+ const say = (m) => { bootmsg.innerHTML = m; };
175
+ let py = null;
176
+
177
+ const APP_SRC = '"""Cairn\'s compute layer for the browser build (Pyodide, no server, no gradio).\n\nWhy this exists instead of Gradio-lite: gradio-lite imports ``gradio`` *before*\nit installs the page\'s requirements, and gradio 5.x currently cannot be resolved\nagainst ``huggingface-hub`` 1.x inside Pyodide -- the capped builds fail to\ninstall, the uncapped ones import and then die on a missing ``httpcore``. Since\nthe boot order is not ours to change there, we skip the framework and drive\nPyodide directly. The page ends up lighter too: no pandas, pydantic or orjson,\njust numpy, scipy, pillow and the ``cairn`` wheel.\n\nEvery function here returns plain JSON-able data (HTML fragments and base64 PNG\ndata URIs) which ``index.html`` drops into the DOM. All of the science is\nimported unchanged from the ``cairn`` package; nothing is reimplemented for the\nbrowser.\n\nClaim: R/E -- lets anyone check the two headline claims themselves, for free,\nwith no GPU, no install and no account.\n"""\n\nfrom __future__ import annotations\n\nimport base64\nimport io\nfrom typing import Any, Dict, List, Tuple\n\nimport numpy as np\nfrom PIL import Image\n\nfrom cairn.runner import CONDITION_NAMES, RunConfig, run_condition\nfrom cairn.world import (\n make_departure_return_trajectory,\n make_scene,\n render,\n schedule_edit,\n usable_targets,\n)\n\nGAP = 6\nBASELINES = {"A": "Vanilla", "B": "Context-window", "C": "Compressed-memory"}\n\n\n# --------------------------------------------------------------------------\n# rendering helpers\n# --------------------------------------------------------------------------\n\n\ndef _png(arr: np.ndarray) -> str:\n """``(H, W, 3)`` float image -> base64 PNG data URI for an ``<img>`` tag."""\n a = (np.clip(np.asarray(arr), 0.0, 1.0) * 255).astype(np.uint8)\n buf = io.BytesIO()\n Image.fromarray(a).save(buf, format="PNG", optimize=True)\n return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")\n\n\ndef _filmstrip(frames: np.ndarray, idx: List[int]) -> str:\n """Selected frames side by side, separated by pale gutters.\n\n Pyodide has no ffmpeg, so there is no video. A strip is arguably the better\n medium for this claim anyway: "before leaving" and "after returning" sit in\n one glance instead of several seconds apart in a loop.\n\n Claim: R -- the experiment, in one picture.\n """\n idx = [i for i in idx if 0 <= i < len(frames)]\n if not idx:\n return _png(np.zeros((8, 8, 3)))\n h = frames[0].shape[0]\n gutter = np.full((h, GAP, 3), 0.93, dtype=np.float32)\n panels: List[np.ndarray] = []\n for k, i in enumerate(idx):\n if k:\n panels.append(gutter)\n panels.append(frames[i])\n return _png(np.concatenate(panels, axis=1))\n\n\ndef _windows(traj) -> List[int]:\n return [\n 0,\n max(0, traj.observe_frames[1] - 1),\n (traj.departure_frame + traj.return_frame) // 2,\n traj.return_frame + 1,\n min(len(traj) - 1, traj.return_frame + 7),\n ]\n\n\nPANEL_CAPTION = (\n "opening shot &middot; last frame before leaving &middot; "\n "<b>looking away</b> &middot; just back &middot; settled after return"\n)\n\n\ndef _ledger_html(res) -> str:\n rows = []\n for e in res.ledger.entries(include_absent=True):\n p = e.pose.position\n state = "present" if e.present else \'<b style="color:#b3261e">REMOVED</b>\'\n rows.append(\n f"<tr><td>{e.object_id}</td><td>{p[0]:.2f}</td><td>{p[2]:.2f}</td>"\n f"<td>{e.pose.yaw:+.2f}</td>"\n f"<td><span class=\'sw\' style=\'background:rgb("\n f"{int(e.appearance[0]*255)},{int(e.appearance[1]*255)},{int(e.appearance[2]*255)})\'></span>"\n f"{e.appearance[0]:.2f}, {e.appearance[1]:.2f}, {e.appearance[2]:.2f}</td>"\n f"<td>{e.n_observations}</td><td>{e.confidence:.2f}</td><td>{state}</td></tr>"\n )\n return (\n "<table><thead><tr><th>id</th><th>x</th><th>z</th><th>yaw</th><th>rgb</th>"\n "<th>seen</th><th>conf</th><th>state</th></tr></thead><tbody>"\n + "".join(rows)\n + "</tbody></table>"\n )\n\n\ndef _cfg(cond: str, seed: int) -> RunConfig:\n # reference_video=False skips rendering a second full clip that only the\n # FVD-proxy consumes, and this page never shows that number. Roughly halves\n # the work per click in the browser.\n return RunConfig(condition=cond, seed=1000 + int(seed), reference_video=False)\n\n\ndef _resolve_target(scene, requested: int, seed: int) -> Tuple[int, str]:\n """Snap the slider to an object that can actually host an episode.\n\n Claim: R -- the demo shows the same well-posed episodes the benchmark scores.\n """\n ok = usable_targets(scene, seed=int(seed))\n if not ok:\n raise ValueError("No object in this room can be left and returned to — try another seed.")\n if int(requested) in ok:\n return int(requested), ""\n chosen = min(ok, key=lambda o: abs(o - int(requested)))\n return chosen, (\n f"<p class=\'note\'>Object #{int(requested)} is permanently hidden behind another object "\n f"in this room, so it cannot host a leave-and-return episode. Showing object "\n f"#{chosen} instead.</p>"\n )\n\n\n# --------------------------------------------------------------------------\n# public entry points (called from JavaScript)\n# --------------------------------------------------------------------------\n\n\ndef scene_preview(seed: int, n_objects: int) -> str:\n """Four views of the room, so you can see what you are about to test."""\n from cairn.types import CameraPose\n\n scene = make_scene(int(n_objects), seed=int(seed))\n cams = [\n CameraPose(np.array([5.0, 1.55, 5.0]), a)\n for a in np.linspace(-np.pi, np.pi, 4, endpoint=False)\n ]\n frames = np.stack([render(scene.states(), c, scene.settings).rgb for c in cams])\n return _filmstrip(frames, list(range(4)))\n\n\ndef _verdict_html(res, label: str) -> str:\n m = res.metrics\n drawn = m["return_observed"] > 0.5\n ok = m["return_success"] > 0.5\n err = "not drawn at all" if not drawn else f"{m[\'return_self_trans\']:.2f} m"\n badge = (\n "<span class=\'ok\'>consistent</span>" if ok else "<span class=\'bad\'>inconsistent</span>"\n )\n return (\n f"<div class=\'verdict\'><h4>{label} &nbsp;{badge}</h4><ul>"\n f"<li>moved on return: <b>{err}</b></li>"\n f"<li>identity preserved: {\'yes\' if m[\'return_identity_preserved\'] else \'<b>no</b>\'}</li>"\n f"<li>yaw error {m[\'return_self_yaw\']:.2f} rad &middot; "\n f"appearance error {m[\'return_self_appearance\']:.3f}</li>"\n f"<li>integrated consistency debt {m[\'debt_area\']:.1f} (peak {m[\'debt_peak\']:.2f})</li>"\n f"</ul></div>"\n )\n\n\ndef compare(seed: int, n_objects: int, absence: int, target: int, baseline: str) -> Dict[str, Any]:\n """Same scene, same trajectory, same generator seed — only the memory differs.\n\n Claim: R -- the interactive form of the headline experiment.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, target, absence, seed=seed)\n\n off = run_condition(scene, traj, _cfg(baseline, seed))\n on = run_condition(scene, traj, _cfg("D", seed))\n idx = _windows(traj)\n\n header = (\n f"<p>Camera left object <b>#{target}</b> at frame {traj.departure_frame} and came back "\n f"at frame {traj.return_frame} — <b>{traj.absence_frames} frames away</b>. "\n f"The return viewpoint is deliberately <i>not</i> the departure viewpoint, so neither "\n f"method can win by replaying its last frame.</p>"\n f"<p class=\'caption\'>Panels: {PANEL_CAPTION}</p>{note}"\n )\n return {\n "header": header,\n "off_img": _filmstrip(off.frames, idx),\n "on_img": _filmstrip(on.frames, idx),\n "off_label": f"Cairn OFF — ({baseline}) {CONDITION_NAMES[baseline]}",\n "on_label": "Cairn ON — (D) explicit world ledger",\n "off_verdict": _verdict_html(off, f"Cairn OFF — ({baseline}) {BASELINES[baseline]}"),\n "on_verdict": _verdict_html(on, "Cairn ON — (D) explicit world ledger"),\n "ledger": _ledger_html(on),\n }\n\n\ndef edit(seed: int, n_objects: int, absence: int, target: int, kind: str) -> Dict[str, Any]:\n """Issue an edit while the object is off screen, then score what came back.\n\n Claim: E -- the operation conditions A–C cannot express at all.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, target, absence, seed=seed)\n ev = schedule_edit(traj, scene, kind, seed=seed)\n traj.edits = [ev]\n\n on = run_condition(scene, traj, _cfg("D", seed))\n off = run_condition(scene, traj, _cfg("A", seed))\n idx = _windows(traj)\n\n if kind == "move":\n d = float(np.linalg.norm(np.asarray(ev.payload["position"]) - scene.get(target).pose.position))\n what = f"move object #{target} {d:.1f} m and turn it"\n elif kind == "remove":\n what = f"delete object #{target} from the world"\n else:\n v = np.asarray(ev.payload["value"], dtype=float)\n what = (\n f"recolour object #{target} to "\n f"<span class=\'sw\' style=\'background:rgb({int(v[0]*255)},{int(v[1]*255)},{int(v[2]*255)})\'></span>"\n f"RGB {np.round(v, 2).tolist()}"\n )\n\n rows = []\n for res, cond in ((off, "A"), (on, "D")):\n for s in res.edit_scores:\n expressible = (\n "yes" if cond == "D" else "<b>no</b> — no addressable world state"\n )\n if s.complied:\n v = "<span class=\'ok\'>yes</span>"\n elif s.ledger_correct:\n v = "<span class=\'warn\'>written, not confirmable</span>"\n else:\n v = "<span class=\'bad\'>no</span>"\n rows.append(\n f"<tr><td>({cond}) {CONDITION_NAMES[cond]}</td><td>{expressible}</td>"\n f"<td>{v}</td><td><code>{s.detail}</code></td></tr>"\n )\n\n header = (\n f"<p><b>Command:</b> {what}</p>"\n f"<p>Issued at frame <b>{ev.frame}</b>, while the object is off screen "\n f"(frames {traj.departure_frame}–{traj.return_frame}).</p>"\n f"<p class=\'caption\'>Panels: {PANEL_CAPTION}</p>{note}"\n )\n table = (\n "<table><thead><tr><th>condition</th><th>can express it?</th><th>obeyed?</th>"\n "<th>evidence</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"\n f"<p class=\'note\'>Conditions A–C hold the world implicitly, in activations. There is no "\n f\'row named "object #{target}" to write to, so <code>move</code>/<code>remove</code>/\'\n f"<code>set_attr</code> are not merely hard for them — they are undefined.</p>"\n )\n return {\n "header": header,\n "off_img": _filmstrip(off.frames, idx),\n "on_img": _filmstrip(on.frames, idx),\n "table": table,\n "ledger": _ledger_html(on),\n }\n\n\ndef ledger_view(seed: int, n_objects: int, absence: int, target: int, rewind_to: int) -> Dict[str, Any]:\n """Show the ledger, its transaction log, and the effect of a rewind.\n\n Claim: E -- the world is a table with an audit trail; rewinding is one call.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n tgt, _ = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, tgt, absence, seed=seed)\n res = run_condition(scene, traj, _cfg("D", seed))\n led = res.ledger\n\n before = _ledger_html(res)\n log_rows = "".join(\n f"<tr><td>{t.index}</td><td>{t.t}</td><td><code>{t.op}</code></td>"\n f"<td>{t.object_id}</td><td>{t.source}</td><td>{t.note or \'\'}</td></tr>"\n for t in led.log[-40:]\n )\n log = (\n "<table><thead><tr><th>#</th><th>frame</th><th>op</th><th>object</th>"\n "<th>source</th><th>note</th></tr></thead><tbody>" + log_rows + "</tbody></table>"\n )\n v0 = led.version\n undone = led.rollback_to_time(int(rewind_to))\n note = (\n f"<p><b>Rewound to frame {int(rewind_to)}</b>: undid {undone} of {v0} transactions. "\n f"The ledger is now exactly as it stood at that instant, object for object. No learned "\n f"memory offers this operation — its state is entangled across every object and every "\n f"timestep at once.</p>"\n )\n return {"before": before, "log": log, "after": _ledger_html(res), "note": note}\n';
178
+
179
+ async function start() {
180
+ try {
181
+ say("Loading Pyodide…");
182
+ py = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.3/full/" });
183
+ say("Loading numpy, scipy and pillow…");
184
+ await py.loadPackage(["numpy", "scipy", "pillow", "micropip"]);
185
+ say("Installing the <code>cairn</code> package…");
186
+ const micropip = py.pyimport("micropip");
187
+ // Absolute URL: micropip resolves relative paths against its own base, not
188
+ // the page's, so a bare filename 404s both locally and on the Space.
189
+ const wheelUrl = new URL("cairn-0.1.0-py3-none-any.whl", window.location.href).href;
190
+ await micropip.install(wheelUrl);
191
+ say("Starting…");
192
+ py.runPython(APP_SRC);
193
+ boot.hidden = true;
194
+ $("app").hidden = false;
195
+ preview();
196
+ } catch (e) {
197
+ boot.innerHTML = "<b>Could not start.</b><p>" + String(e).slice(0, 900) +
198
+ "</p><p class='note'>This page needs WebAssembly and about 40 MB of downloads. " +
199
+ "Everything here also runs locally — see the " +
200
+ "<a href='https://github.com/NagaYu/cairn'>repository</a>.</p>";
201
+ throw e;
202
+ }
203
+ }
204
+
205
+ // Call a Python function with JSON-able args, get a plain JS object back.
206
+ function call(fn, ...args) {
207
+ const f = py.globals.get(fn);
208
+ const r = f(...args);
209
+ const out = (r && r.toJs) ? r.toJs({ dict_converter: Object.fromEntries }) : r;
210
+ if (r && r.destroy) r.destroy();
211
+ if (f && f.destroy) f.destroy();
212
+ return out;
213
+ }
214
+
215
+ async function busy(btn, work) {
216
+ const label = btn.textContent;
217
+ btn.disabled = true;
218
+ btn.textContent = "running…";
219
+ // Yield so the browser paints the disabled state before the synchronous Python
220
+ // call blocks the main thread. Deliberately a timer and not
221
+ // requestAnimationFrame: rAF is paused in background and hidden tabs, so a
222
+ // user who switches away mid-click would never get their result back.
223
+ await new Promise(r => setTimeout(r, 30));
224
+ try { work(); }
225
+ catch (e) { alert(String(e).slice(0, 700)); }
226
+ finally { btn.disabled = false; btn.textContent = label; }
227
+ }
228
+
229
+ const v = (id) => parseInt($(id).value, 10);
230
+ const strip = (label, src) => `<h4>${label}</h4><img class="strip" src="${src}" alt="${label}">`;
231
+
232
+ function preview() { $("preview").src = call("scene_preview", v("seed"), v("nobj")); }
233
+
234
+ for (const [id, out] of [["seed","seedv"],["nobj","nobjv"],["tgt","tgtv"],["abs","absv"],["rw","rwv"]]) {
235
+ $(id).addEventListener("input", () => { $(out).textContent = $(id).value; });
236
+ }
237
+ let timer = null;
238
+ for (const id of ["seed", "nobj"]) {
239
+ $(id).addEventListener("change", () => {
240
+ $("tgt").max = String(v("nobj") - 1);
241
+ if (v("tgt") > v("nobj") - 1) {
242
+ $("tgt").value = String(v("nobj") - 1);
243
+ $("tgtv").textContent = $("tgt").value;
244
+ }
245
+ clearTimeout(timer);
246
+ timer = setTimeout(preview, 120);
247
+ });
248
+ }
249
+ for (const b of document.querySelectorAll(".tabs button")) {
250
+ b.addEventListener("click", () => {
251
+ document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
252
+ document.querySelectorAll(".panel").forEach(p => p.classList.toggle("on", p.id === b.dataset.tab));
253
+ });
254
+ }
255
+
256
+ $("go1").addEventListener("click", () => busy($("go1"), () => {
257
+ const r = call("compare", v("seed"), v("nobj"), v("abs"), v("tgt"), $("baseline").value);
258
+ $("out1").innerHTML = r.header
259
+ + strip(r.off_label, r.off_img) + r.off_verdict
260
+ + strip(r.on_label, r.on_img) + r.on_verdict
261
+ + "<h3>The ledger Cairn built from its own generated frames</h3>" + r.ledger;
262
+ }));
263
+
264
+ $("go2").addEventListener("click", () => busy($("go2"), () => {
265
+ const r = call("edit", v("seed"), v("nobj"), v("abs"), v("tgt"), $("kind").value);
266
+ $("out2").innerHTML = r.header
267
+ + strip("Vanilla — command not expressible", r.off_img)
268
+ + strip("Cairn — command written to the ledger", r.on_img)
269
+ + r.table + "<h3>Ledger after the edit</h3>" + r.ledger;
270
+ }));
271
+
272
+ $("go3").addEventListener("click", () => busy($("go3"), () => {
273
+ const r = call("ledger_view", v("seed"), v("nobj"), v("abs"), v("tgt"), v("rw"));
274
+ $("out3").innerHTML = "<h3>Ledger at end of run</h3>" + r.before
275
+ + "<h3>Transaction log (last 40)</h3>" + r.log
276
+ + "<h3>Ledger after rewind</h3>" + r.after + r.note;
277
+ }));
278
+
279
+ start();
280
+ </script>
281
+ </body>
282
  </html>