Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions WorldModel/EXPERIMENT_LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,18 @@ Two reviewers verified the delivered synthetic A/Bs are sound (symlog threaded i
- Decision: **REJECT node 16's "systematically pointing it away" reframing, and REPLACE it.** The planner is not adversarial, it is uninformative — and an uninformative planner that still ACTS scores worse than the zero policy, because uncorrelated displacement added to a position increases expected distance. That fully accounts for node 16's "worse than doing nothing" with no directional defect. Node 16's claim that "an uninformative planner scores like the zero policy" was wrong: that holds only for a planner emitting zero actions. The MEASUREMENT in node 16 stands; the inference drawn from it does not.
- This also reconciles the whole nodes 6-16 arc. Every representation-side change was measured against metrics dominated by the per-trajectory factors (`chi`, `peak_amp`, `offset`), which are constants and easy to encode. `pos` — the only variable control acts on — is the one the encoder represents weakly (node 13: latent-to-position r ~ 0.63). Improving `chi` recovery while `pos` stays weakly encoded improves the panel and cannot improve control. The planner was never the bottleneck (node 11, confirmed in node 16), the objective was not the bottleneck (node 14), and the frame offset was not sufficient (node 14) — the ACTION CHANNEL is.
- Next question: this is a training-signal problem, not a planner, objective or benchmark problem. The one-step JEPA target gives the action a 6% footprint in a latent whose variance is dominated by frozen per-trajectory factors. Bounded, ordered candidates: (a) train the predictor on MULTI-STEP transitions so the action's compounded effect is visible in the target; (b) scale the action's authority per step (`DT`, or an action gain) so one step carries a larger footprint; (c) an action-conditioned auxiliary loss that requires the latent to predict the action from a (pre, post) pair — a direct measurement of how much action information the representation retains, and a candidate for the panel regardless. Test (c) FIRST: it is diagnostic rather than corrective, it needs no retraining of anything else, and it converts "the action is 6% of the signal" into a number the panel reports every run.

## 18 — Action-conditioned probe: the representation retains NO recoverable action information (2026-08-02, commit <pending>)
- Category: Benchmark
- Hypothesis: (node 17) the action is under-represented in latent space — the predictor moves only 6.1% of the state signal across the full action range against 21.3% in the true env. A held-out linear probe recovering the action from a (z_pre, z_post) pair should therefore score well below its true-state ceiling.
- Prediction: latent action recovery is positive but far below the ceiling.
- Implementation: `_action_recovery` in `forward_eval.py` — held-out linear-probe R^2 per action dimension from `[z_pre, z_post]`, 80/20 split with a bias column, matching `_factor_recovery`. Added to the panel. Two controls come free: `action[2]` is the mode bit, which `_step` never reads, so it MUST score at or below zero; and the same probe on the TRUE (pre, post) states gives the ceiling.
- Evidence — **the prediction was too generous. Recovery is not low, it is absent.**
- **Ceiling, from the true states:** `ay` **R^2 = 1.0000** (exact — the algebra is linear: `ay = (pos' - pos - vel'*DT) / (0.5*DT)`), `ax` 0.7096 (short of 1.0 only because `decay` depends on `chi`, making `vel*decay` a product a linear probe cannot form).
- **From the latent pair: `ax` -0.1676, `ay` -0.1199.** Negative R^2 is worse than predicting the mean — the representation carries **nothing** a linear probe can use. Not the ~6% node 17 implied; zero.
- **Both controls behave.** `mode_bit` -0.2600 (latent) and -0.0547 (true state), i.e. unrecoverable as it must be. `z_pre` alone, which cannot see the transition, gives -0.1183 / -0.0845.
- **THE MECHANISM IS THE ENCODER, AND `vel` IS THE SHARPEST NUMBER IN THIS LEDGER.** Factor recovery: `peak_amp` **0.9971**, `chi` **0.9498**, `offset` 0.6856, `pos` 0.6537, **`vel` 0.0326**. The three near-perfect factors are per-trajectory CONSTANTS. The two that EVOLVE are the two worst, and `vel` — the channel `ax` acts through — is at zero. `ax` is therefore unrecoverable in principle: recovering it needs `vel` and `vel'`, and neither is encoded.
- **For `pos` the arithmetic is just as decisive.** R^2 0.6537 against sd 0.5812 leaves an encoder residual of **0.342**, while the one-step action effect on `pos` is **0.120** — a signal-to-noise ratio of **0.35**. The action's fingerprint is a third the size of the encoder's own noise on the only variable it can move.
- Decision: **ADOPT `action_recovery` into the panel permanently**, with the `mode_bit` negative control asserted in `smoke_test` — mutation-checked: leaking the target makes `mode_bit` score 1.000 and the assertion fires. **REPLACE node 17's "under-represents by ~3.5x" with "does not represent at all".** Node 17 measured the PREDICTOR's response to an action it receives as an explicit input; this measures whether the ENCODED TRANSITION retains which action was taken. The second is the load-bearing quantity and it is zero, which also means node 17's 6.1% predictor response is fitting noise rather than a weak signal.
- This closes the nodes 6-17 arc. Control was never reachable from any representation-side change, because the encoder discards the action's effect and encodes the frozen per-trajectory factors nearly perfectly instead. Every panel metric that improved was measuring the constants. `vel` at R^2 = 0.033 was visible in every run since node 3 and was never read as a control result.
- Next question: this is now a `_render_state` / encoder-capacity question, not a planner, objective, benchmark or predictor question. Ordered and bounded: (a) check whether `vel` is even present in the rendered observation — if the spectral window at a fixed `t` does not carry velocity, no encoder can recover it and the task is unobservable rather than hard, which would be a generator defect and would retire the entire control line as specified; (b) if it IS present, raise the encoder's capacity for the evolving factors, e.g. by feeding a two-window stack so a difference is representable; (c) only then revisit multi-step training targets. Test (a) FIRST — it is a read of `_render_state` plus one probe, and it decides whether the remaining candidates are worth anything.
53 changes: 53 additions & 0 deletions WorldModel/forward_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
)

FACTOR_NAMES = ["pos", "vel", "chi", "peak_amp", "offset"]
# action[2] is the mode bit. `_step` never reads it, so it is a NEGATIVE CONTROL:
# a probe that "recovers" it is fitting noise and its other numbers mean nothing.
ACTION_NAMES = ["ax", "ay", "mode_bit"]


def _seed_everything(seed: int) -> None:
Expand Down Expand Up @@ -139,6 +142,44 @@ def _factor_recovery(Z: torch.Tensor, factors: np.ndarray, seed: int) -> dict[st
return out


def _action_recovery(
Z_pre: torch.Tensor, Z_post: torch.Tensor, actions: np.ndarray, seed: int
) -> dict[str, float]:
"""Held-out linear-probe R^2 for each action dimension from (z_pre, z_post).

How much of WHICH ACTION WAS TAKEN survives into the representation. The
panel measured how well the latent recovers the state factors, but control
depends on the action channel and nothing scored it — so ten nodes of
representation work optimised metrics dominated by `chi`, `peak_amp` and
`offset`, which are per-trajectory CONSTANTS, while the action channel went
unmeasured (node 17).

Read against two anchors that come free with it:
* `mode_bit` is action[2], which `_step` never reads. It MUST come out at
or below zero. A positive value means the probe is fitting noise.
* the same probe on the TRUE (pre, post) states recovers `ay` at R^2 =
1.000 exactly — the algebra makes it linear — so the ceiling is known
and any shortfall is the representation's, not the probe's.
"""
X = np.concatenate([Z_pre.double().numpy(), Z_post.double().numpy()], axis=1)
rng = np.random.default_rng(seed)
idx = rng.permutation(len(X))
cut = max(1, int(0.8 * len(X)))
tr, te = idx[:cut], idx[cut:]
if len(te) == 0:
te = tr
Xtr = np.concatenate([X[tr], np.ones((len(tr), 1))], axis=1)
Xte = np.concatenate([X[te], np.ones((len(te), 1))], axis=1)
out: dict[str, float] = {}
for k, name in enumerate(ACTION_NAMES):
w, *_ = np.linalg.lstsq(Xtr, actions[tr, k], rcond=None)
pred = Xte @ w
ss_res = float(((actions[te, k] - pred) ** 2).sum())
ss_tot = float(((actions[te, k] - actions[te, k].mean()) ** 2).sum())
out[name] = 1.0 - ss_res / ss_tot if ss_tot > 1e-12 else 0.0
return out


def _rankme(Z: torch.Tensor, eps: float = 1e-7) -> float:
"""Garrido 2023 effective rank: exp(entropy of normalized singular values)."""
s = torch.linalg.svdvals(Z - Z.mean(0, keepdim=True))
Expand Down Expand Up @@ -498,6 +539,7 @@ def evaluate(
)

factors = _read_factors(records)
action_vectors = np.array([r["actionVector"] for r in records], dtype=np.float64)
Z_pre, Z_post = _encode_all(model, dataset, device)
var_term, cov_term = _vicreg_terms(Z_pre)
alignment, uniformity = _alignment_uniformity(Z_pre, Z_post)
Expand All @@ -523,6 +565,7 @@ def evaluate(
"rollout_error": rollout,
"mpc_success": mpc,
"factor_recovery": _factor_recovery(Z_pre, factors, seed),
"action_recovery": _action_recovery(Z_pre, Z_post, action_vectors, seed),
"rankme": _rankme(Z_pre),
"alpha_req": _alpha_req(Z_pre),
"vicreg_var": var_term,
Expand Down Expand Up @@ -588,6 +631,16 @@ def smoke_test() -> None:
# The chi probe exists and is reported (the information-destruction detector).
assert "chi" in a["factor_recovery"]

# The action probe's negative control. action[2] is the mode bit and `_step`
# never reads it, so it carries no information about the transition. If a
# probe recovers it, the probe is fitting noise and its ax/ay numbers are
# meaningless -- which is the only way this metric can silently lie.
ar = a["action_recovery"]
assert set(ar) == set(ACTION_NAMES), ar
assert all(np.isfinite(v) for v in ar.values()), ar
assert ar["mode_bit"] <= 0.05, \
f"action probe recovered the unused mode bit (R^2={ar['mode_bit']:.3f}) — it is fitting noise"

# The log-features (node-33) arm must stay finite end-to-end — a log(0) or a
# train/rollout input-space mismatch would surface here as NaN/inf.
c = evaluate(n=64, mode="signal", seed=0, epochs=3, latent_dim=16, rollout_traj=8,
Expand Down
Loading