Can it predict the next value without seeing the future?

Dynamic masking
Expose the beginning of a simple numeric sequence, hide its remaining events, and predict the next value.

Example

events:
  - {value: 0.2, future: false, next: false}
  - {value: 0.5, future: false, next: false}
  - {value: 0.8, future: true, next: true}
  - {value: 1.1, future: true, next: false}
  - {value: 1.4, future: true, next: false}

Events marked future are hidden. next selects the single value to predict.

Model tree with record, events, value. Selector notes identify hidden values. future hides events; next chooses the answer.

Model tree with record, events, value. Selector notes identify hidden values. future hides events; next chooses the answer.

Figure 1: Selector notes identify hidden values. future hides events; next chooses the answer.

Comparison

Change hidden future values to check they cannot influence prediction. Then feed predicted values back for a short rollout.

Result

P054 · Latest full run · Passing

Seed 5401 on gpu: 20 of 20 test checks met. History: 7 of 7 full runs met every check, across 3 distinct seeds. History can include earlier source versions. The experiment code has changed since this run; these measurements describe its earlier version.

Comparison Latest measurement
Next value after two events 3.0% of baseline error
Short rollout 8.1% of baseline error

Baseline error compares prediction error with a constant guess. 100% matches that guess; lower is better. Details specify the baseline and root-mean-square error calculation.

Next-value prediction and the short rollout work on these sequences. This does not establish language generation or general forecasting.

Experiment details and code

Latest full run

Seed 5401, gpu, recorded 2026-09-18T16:40:30.727943+00:00. Outcome: met.

Source fingerprint: 94b48d3147ebb0a25e4b265731790e706ee56ce0adcb3f3602c4e1d1e00753ce. Python 3.12.6; Torch 2.12.0.

Measurement Value
steps 800
prefixes 2: nrmse: 0.0298971; baseline_rmse: 0.740252; shuffled_nrmse: 1.41999; future_value_drift: 0; 3: nrmse: 0.0302651; baseline_rmse: 0.908965; shuffled_nrmse: 1.40905; future_value_drift: 0; 4: nrmse: 0.0260132; baseline_rmse: 1.10172; shuffled_nrmse: 1.40223; future_value_drift: 0
rollout rmse: 0.0752909; baseline_rmse: 0.928796; nrmse: 0.0810628; persistence_rmse: 0.505097
Behavioral checks
Behavioral check Outcome
prefix 2: next-value nRMSE below 0.35 Met
prefix 2: shuffled prefix nRMSE above 0.85 Met
prefix 2: future cannot affect prediction Met
prefix 2: exactly the next coordinate is requested Met
prefix 2: exactly the next coordinate is trained Met
prefix 2: only prefix values are encoder inputs Met
prefix 3: next-value nRMSE below 0.35 Met
prefix 3: shuffled prefix nRMSE above 0.85 Met
prefix 3: future cannot affect prediction Met
prefix 3: exactly the next coordinate is requested Met
prefix 3: exactly the next coordinate is trained Met
prefix 3: only prefix values are encoder inputs Met
prefix 4: next-value nRMSE below 0.35 Met
prefix 4: shuffled prefix nRMSE above 0.85 Met
prefix 4: future cannot affect prediction Met
prefix 4: exactly the next coordinate is requested Met
prefix 4: exactly the next coordinate is trained Met
prefix 4: only prefix values are encoder inputs Met
Free-running suffix nRMSE below 0.45 Met
Rollout beats persistence Met

Full run history

Runs may include earlier source versions. Repeating a seed is not an independent seed check.

Seed Outcome Recorded Code fingerprint
5401 met 2026-09-16T21:59:05.039275+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
5401 met 2026-09-16T22:00:08.459379+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
6101 met 2026-09-16T22:00:52.167374+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
6102 met 2026-09-16T22:01:36.388836+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
5401 met 2026-09-17T00:02:43.428802+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
5401 met 2026-09-17T01:01:14.556789+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21
5401 met 2026-09-18T16:40:30.727943+00:00 e89dab8b16a019ec04d0393318a2a67563c0e06315a03ed84d77578ac71fbc21

Recorded results.

Run this experiment

Proof execution is optional and separate from building this site. Use a RelFlow build matching the recorded provenance; replace the wheel path below with that artifact. Run from this documentation repository’s root:

uv run --with /path/to/matching-relflow.whl python proofs/run.py P054

Or, from a prepared proof environment, run the self-contained script directly:

PYTHONPATH=proofs python proofs/masking/prefix_generation.py

Add --accelerator gpu for CUDA or --seed 42 for another seeded experiment. --steps 2 checks execution with a short training budget; it is recorded as a smoke run.

Download the complete proof.

Data and model

A whole sequence draws offset ~ Uniform(-1, 1) and increment ~ Uniform(-0.4, 0.4), then value[t] = offset + t*increment. Neither latent parameter is exposed. Choose a prefix length uniformly from 2, 3, and 4. The branch skips every suffix item; a leaf query requests only the first hidden value. Whole sequences are independently generated for training (4,096), validation (512), and testing (1,024).

Training and controls

Fit for 800 AdamW updates at learning rate 0.002. Score one-step predictions separately for all three prefix lengths, requiring nRMSE below 0.35. A rollout from the first two true values must achieve suffix nRMSE below 0.45 and beat persistence (repeating the second value). Baselines use training means at each position, not held-out statistics. Permuting entire visible prefixes between records retains the original labels and must give nRMSE above 0.85.

Poison all suffix values, including the selected target: drift must remain below 1e-5. Training objectives and inference flags must select exactly one coordinate, while encoder presence selects exactly the prefix. The supplied correct suffix is used only as an evaluation label, never in the rollout.

Complete experiment code

Code
"""P054: leakage-free prefix conditioning and free-running arithmetic-sequence rollout."""

from collections.abc import Iterator
from functools import partial

import lightning.pytorch as lit
import numpy as np
import pyarrow as pa
from reporting import report

import relflow as rf

PROOF_ID = "P054"
LENGTH = 5
BUDGET = 800


def records(*, rows: int, seed: int) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    for _ in range(rows):
        offset = float(rng.uniform(-1.0, 1.0))
        increment = float(rng.uniform(-0.4, 0.4))
        cut = int(rng.integers(2, LENGTH))
        yield {
            "events": [
                {"value": offset + index * increment, "future": index >= cut, "next": index == cut}
                for index in range(LENGTH)
            ]
        }


def build() -> rf.Model:
    return rf.Model(
        d_model=32,
        n_layers=2,
        n_heads=4,
        dropout=0.0,
        batch_size=64,
        events=rf.Branch(
            length=LENGTH,
            reduction=None,
            mask=rf.Mask(query="future", skip=True, dropout=False),
            value=rf.Number(objective="mse", mask=rf.Mask(query="next", reconstruct=True)),
        ),
    )


def prediction(model: rf.Model, rows: list[dict]) -> tuple[np.ndarray, np.ndarray]:
    output = model.predict(pa.Table.from_pylist(rows))["predictions"].to_pylist()
    content = np.asarray(
        [[item["value"]["content"] for item in row["events"]] for row in output], dtype=np.float64
    ).reshape(len(rows), LENGTH)
    inferred = np.asarray(
        [[item["value"]["inferred"] for item in row["events"]] for row in output], dtype=bool
    ).reshape(len(rows), LENGTH)
    return content, inferred


def rollout(model: rf.Model, rows: list[dict]) -> np.ndarray:
    """Expose generated values only; never teacher-force the held-out suffix."""
    values = np.zeros((len(rows), LENGTH), dtype=np.float64)
    values[:, :2] = [[event["value"] for event in row["events"][:2]] for row in rows]
    for cut in range(2, LENGTH):
        inputs = [
            {
                "events": [
                    {"value": float(value), "future": index >= cut, "next": index == cut}
                    for index, value in enumerate(sequence)
                ]
            }
            for sequence in values
        ]
        content, _ = prediction(model, inputs)
        values[:, cut] = content[:, cut]
    return values


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    lit.seed_everything(seed, workers=True)
    model = build()
    model.optimizer = rf.adamw(learning_rate=0.002)
    data = rf.SyntheticDataModule(
        model=model,
        train=partial(records, rows=4096, seed=seed + 1),
        validate=partial(records, rows=512, seed=seed + 2),
        seed=seed,
    )
    trainer = lit.Trainer(
        accelerator=accelerator,
        devices=1,
        max_epochs=-1,
        max_steps=BUDGET if steps is None else min(steps, BUDGET),
        deterministic=True,
        logger=False,
        enable_checkpointing=False,
        enable_progress_bar=False,
        enable_model_summary=False,
        num_sanity_val_steps=0,
    )
    trainer.fit(model, datamodule=data)
    model.eval()
    train = list(records(rows=4096, seed=seed + 1))
    test = list(records(rows=1024, seed=seed + 3))
    actual = np.asarray([[event["value"] for event in row["events"]] for row in test])
    mean = np.asarray([[event["value"] for event in row["events"]] for row in train]).mean(axis=0)
    order = np.random.default_rng(seed + 4).permutation(len(test))
    metrics = {"steps": trainer.global_step, "prefixes": {}}
    checks = {}
    # A separate model audits target selection without fitting held-out moments.
    probe = build()
    for cut in range(2, LENGTH):
        selected = [
            {
                "events": [
                    {**event, "future": index >= cut, "next": index == cut} for index, event in enumerate(row["events"])
                ]
            }
            for row in test
        ]
        content, inferred = prediction(model, selected)
        poisoned = [
            {
                "events": [
                    {**event, "value": 1000.0 + index} if index >= cut else event
                    for index, event in enumerate(row["events"])
                ]
            }
            for row in selected
        ]
        shuffled = [
            {
                "events": [
                    {**event, "value": float(actual[other, index])} if index < cut else event
                    for index, event in enumerate(row["events"])
                ]
            }
            for row, other in zip(selected, order, strict=True)
        ]
        replaced, _ = prediction(model, poisoned)
        broken, _ = prediction(model, shuffled)
        baseline = float(np.sqrt(np.mean((actual[:, cut] - mean[cut]) ** 2)))
        measured = {
            "nrmse": float(np.sqrt(np.mean((content[:, cut] - actual[:, cut]) ** 2))) / baseline,
            "baseline_rmse": baseline,
            "shuffled_nrmse": float(np.sqrt(np.mean((broken[:, cut] - actual[:, cut]) ** 2))) / baseline,
            "future_value_drift": float(np.max(np.abs(content[:, cut] - replaced[:, cut]))),
        }
        metrics["prefixes"][cut] = measured
        checks[f"prefix {cut}: next-value nRMSE below 0.35"] = measured["nrmse"] < 0.35
        checks[f"prefix {cut}: shuffled prefix nRMSE above 0.85"] = measured["shuffled_nrmse"] > 0.85
        checks[f"prefix {cut}: future cannot affect prediction"] = measured["future_value_drift"] < 1e-5
        field = probe.encode(pa.Table.from_pylist(selected), strata="train")["/events/value"]
        expected = np.broadcast_to(np.arange(LENGTH) == cut, inferred.shape)
        visible = np.broadcast_to(np.arange(LENGTH) < cut, inferred.shape)
        checks[f"prefix {cut}: exactly the next coordinate is requested"] = bool(np.array_equal(inferred, expected))
        checks[f"prefix {cut}: exactly the next coordinate is trained"] = bool(
            np.array_equal(field.trainable.cpu().numpy().reshape(expected.shape), expected)
        )
        checks[f"prefix {cut}: only prefix values are encoder inputs"] = bool(
            np.array_equal(field.present.cpu().numpy().reshape(visible.shape), visible)
        )
    generated = rollout(model, test)
    baseline = float(np.sqrt(np.mean((actual[:, 2:] - mean[2:]) ** 2)))
    rmse = float(np.sqrt(np.mean((generated[:, 2:] - actual[:, 2:]) ** 2)))
    persistence = float(np.sqrt(np.mean((actual[:, 1:2] - actual[:, 2:]) ** 2)))
    metrics["rollout"] = {
        "rmse": rmse,
        "baseline_rmse": baseline,
        "nrmse": rmse / baseline,
        "persistence_rmse": persistence,
    }
    checks["Free-running suffix nRMSE below 0.45"] = metrics["rollout"]["nrmse"] < 0.45
    checks["Rollout beats persistence"] = rmse < persistence
    return metrics, checks


if __name__ == "__main__":
    report(PROOF_ID, run, seed=5401)