From 58319691187e4b60167cab8abb09127e4b58a931 Mon Sep 17 00:00:00 2001 From: aurascoper Date: Sun, 2 Aug 2026 00:34:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(worldmodel):=20action-conditioned=20probe?= =?UTF-8?q?=20=E2=80=94=20the=20action=20is=20not=20represented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger node 18. Node 17's test (c): measure how much of WHICH ACTION WAS TAKEN survives into the representation. The prediction was too generous. CEILING, from the true (pre, post) states: ay R^2 = 1.0000 exact -- ay = (pos' - pos - vel'*DT)/(0.5*DT) is linear ax R^2 = 0.7096 short of 1.0 only because decay depends on chi FROM THE LATENT PAIR: ax R^2 = -0.1676 ay R^2 = -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 (action[2], which _step never reads) scores -0.2600 from the latent and -0.0547 from the true states; 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: 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. So ax is unrecoverable in principle: recovering it needs vel and vel', neither encoded. For pos: R^2 0.6537 against sd 0.5812 leaves residual noise 0.342, while the one-step action effect is 0.120. SNR 0.35. The action's fingerprint is a third the size of the encoder's own noise on the only variable it can move. ADOPTED into the panel with the mode_bit negative control asserted in smoke_test. Mutation-checked: leaking the probe's target makes mode_bit score 1.000 and the assertion fires. That control is the only way this metric can silently lie, so it is asserted rather than eyeballed. CORRECTS NODE 17. "Under-represents by ~3.5x" becomes "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 load-bearing and it is zero -- which also means node 17's 6.1% predictor response is fitting noise, not 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 factors nearly perfectly instead. vel at R^2 = 0.033 was visible in every run since node 3 and was never read as a control result. Next test named: check whether vel is present in _render_state at all. If the observation does not carry velocity, the task is unobservable rather than hard, which is a generator defect and would retire the control line as specified. Verified: smoke 3 consecutive runs; ledger, ADR and test suites green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DJJ8y1zo7WSqEmGNW8DQap --- WorldModel/EXPERIMENT_LEDGER.md | 15 ++++++++++ WorldModel/forward_eval.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/WorldModel/EXPERIMENT_LEDGER.md b/WorldModel/EXPERIMENT_LEDGER.md index 6d6e5d0..8cad6e9 100644 --- a/WorldModel/EXPERIMENT_LEDGER.md +++ b/WorldModel/EXPERIMENT_LEDGER.md @@ -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 ) +- 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. diff --git a/WorldModel/forward_eval.py b/WorldModel/forward_eval.py index f52be39..73aa371 100644 --- a/WorldModel/forward_eval.py +++ b/WorldModel/forward_eval.py @@ -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: @@ -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)) @@ -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) @@ -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, @@ -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,