Cairn: browser-only demo (Pyodide) — leave, come back, same world
Browse files- .gitattributes +1 -0
- README.md +73 -4
- browser_app.py +307 -0
- build_space.py +364 -0
- cairn-0.1.0-py3-none-any.whl +3 -0
- 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:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · last frame before leaving · "
|
| 89 |
+
"<b>looking away</b> · just back · 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} {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 · "
|
| 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 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 & 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 & 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 |
+
<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 |
+
<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 |
+
<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> ·
|
| 210 |
+
<a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> ·
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 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 & 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 & 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 |
+
<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 |
+
<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 |
+
<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> ·
|
| 164 |
+
<a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> ·
|
| 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 · last frame before leaving · "\n "<b>looking away</b> · just back · 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} {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 · "\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>
|