Can it follow IDs through larger lists and a second lookup?

Sibling entity transfer
Retrieve a source value by ID with increasing numbers of distractors, then add an intermediate ID link for a two-step lookup.

Example

source:
  - {entity_id: S0, value: 0.7}
  - {entity_id: S1, value: -0.2}
  - {entity_id: S2, value: 0.4}
  - {entity_id: S3, value: -0.9}
target:
  - {entity_id: S1, value: -0.2}
  - {entity_id: S0, value: 0.7}

Target values are hidden. This example shows a direct lookup; the two-step arm adds a separate links collection.

Model tree with lookup, source, entity_id, value, links, entity_id, source_id, target, entity_id, value. Amber cards are hidden prediction targets. The links collection is used only for the two-step lookup.

Model tree with lookup, source, entity_id, value, links, entity_id, source_id, target, entity_id, value. Amber cards are hidden prediction targets. The links collection is used only for the two-step lookup.

Figure 1: Amber cards are hidden prediction targets. The links collection is used only for the two-step lookup.

Comparison

Change lookup keys while keeping answers fixed. Compare with averaging source values, and reorder the records without changing matches.

Result

P075 · Latest full run · Failing

Seed 7503 on cpu: 33 of 42 test checks met. The experiment did not meet every criterion. History: 0 of 3 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
Direct lookup, four sources 20.4% of baseline error
Two-step lookup, four sources 86.4% 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.

Direct lookup works in one of three seeds; the two-step lookup fails in all three. The capability checks remain failed, rather than being redefined as an expected limitation.

Experiment details and code

Latest full run

Seed 7503, cpu, recorded 2026-09-22T17:17:31.534885+00:00. Outcome: not met.

Source fingerprint: 81bf7b59fed933b75aa9d30113ae8a1e2d0a1831f00184095b79b97c22715776. Python 3.12.10; Torch 2.12.0.

Measurement Value
hops_1 steps: 600; training_mean: -0.000447493; ladder: 2: intact: rmse: 0.106417; baseline_rmse: 0.575571; source_mean_rmse: 0.396914; normalized_rmse: 0.18489; broken: rmse: 0.790381; baseline_rmse: 0.575571; source_mean_rmse: 0.396914; normalized_rmse: 1.37321; permuted: rmse: 0.1065; baseline_rmse: 0.575571; source_mean_rmse: 0.396914; normalized_rmse: 0.185034; corruption_gap: 1.18832; permutation_nrmse_delta: 0.000144057; 4: intact: rmse: 0.117778; baseline_rmse: 0.578596; source_mean_rmse: 0.494658; normalized_rmse: 0.203558; broken: rmse: 0.80978; baseline_rmse: 0.578596; source_mean_rmse: 0.494658; normalized_rmse: 1.39956; permuted: rmse: 0.117787; baseline_rmse: 0.578596; source_mean_rmse: 0.494658; normalized_rmse: 0.203573; corruption_gap: 1.196; permutation_nrmse_delta: 1.50245e-05; 8: intact: rmse: 0.136098; baseline_rmse: 0.572296; source_mean_rmse: 0.537296; normalized_rmse: 0.23781; broken: rmse: 0.798642; baseline_rmse: 0.572296; source_mean_rmse: 0.537296; normalized_rmse: 1.39551; permuted: rmse: 0.138483; baseline_rmse: 0.572296; source_mean_rmse: 0.537296; normalized_rmse: 0.241979; corruption_gap: 1.1577; permutation_nrmse_delta: 0.00416861; 16: intact: rmse: 0.152075; baseline_rmse: 0.570317; source_mean_rmse: 0.549301; normalized_rmse: 0.26665; broken: rmse: 0.807131; baseline_rmse: 0.570317; source_mean_rmse: 0.549301; normalized_rmse: 1.41523; permuted: rmse: 0.162288; baseline_rmse: 0.570317; source_mean_rmse: 0.549301; normalized_rmse: 0.284557; corruption_gap: 1.14858; permutation_nrmse_delta: 0.0179076
hops_2 steps: 600; training_mean: -0.00132445; ladder: 2: intact: rmse: 0.407721; baseline_rmse: 0.574542; source_mean_rmse: 0.40427; normalized_rmse: 0.709645; broken: rmse: 0.407721; baseline_rmse: 0.574542; source_mean_rmse: 0.40427; normalized_rmse: 0.709645; permuted: rmse: 0.407862; baseline_rmse: 0.574542; source_mean_rmse: 0.40427; normalized_rmse: 0.709891; corruption_gap: -1.33028e-08; permutation_nrmse_delta: 0.000245691; 4: intact: rmse: 0.501987; baseline_rmse: 0.581267; source_mean_rmse: 0.499134; normalized_rmse: 0.863608; broken: rmse: 0.501987; baseline_rmse: 0.581267; source_mean_rmse: 0.499134; normalized_rmse: 0.863608; permuted: rmse: 0.502277; baseline_rmse: 0.581267; source_mean_rmse: 0.499134; normalized_rmse: 0.864107; corruption_gap: -9.23974e-09; permutation_nrmse_delta: 0.000498769
Behavioral checks
Behavioral check Outcome
1 hop, 2 sources: finite source-unit errors Met
1 hop, 2 sources: intact nRMSE at most 0.50 Met
1 hop, 2 sources: beats source-mean RMSE by 20% Met
1 hop, 2 sources: broken-key nRMSE at least 0.80 Met
1 hop, 2 sources: corruption increases nRMSE by at least 0.30 Met
1 hop, 2 sources: reordered nRMSE at most 0.50 Met
1 hop, 2 sources: permutation nRMSE delta at most 0.10 Met
1 hop, 4 sources: finite source-unit errors Met
1 hop, 4 sources: intact nRMSE at most 0.50 Met
1 hop, 4 sources: beats source-mean RMSE by 20% Met
1 hop, 4 sources: broken-key nRMSE at least 0.80 Met
1 hop, 4 sources: corruption increases nRMSE by at least 0.30 Met
1 hop, 4 sources: reordered nRMSE at most 0.50 Met
1 hop, 4 sources: permutation nRMSE delta at most 0.10 Met
1 hop, 8 sources: finite source-unit errors Met
1 hop, 8 sources: intact nRMSE at most 0.50 Met
1 hop, 8 sources: beats source-mean RMSE by 20% Met
1 hop, 8 sources: broken-key nRMSE at least 0.80 Met
1 hop, 8 sources: corruption increases nRMSE by at least 0.30 Met
1 hop, 8 sources: reordered nRMSE at most 0.50 Met
1 hop, 8 sources: permutation nRMSE delta at most 0.10 Met
1 hop, 16 sources: finite source-unit errors Met
1 hop, 16 sources: intact nRMSE at most 0.50 Met
1 hop, 16 sources: beats source-mean RMSE by 20% Met
1 hop, 16 sources: broken-key nRMSE at least 0.80 Met
1 hop, 16 sources: corruption increases nRMSE by at least 0.30 Met
1 hop, 16 sources: reordered nRMSE at most 0.50 Met
1 hop, 16 sources: permutation nRMSE delta at most 0.10 Met
2 hop, 2 sources: finite source-unit errors Met
2 hop, 2 sources: intact nRMSE at most 0.50 Not met
2 hop, 2 sources: beats source-mean RMSE by 20% Not met
2 hop, 2 sources: broken-key nRMSE at least 0.80 Not met
2 hop, 2 sources: corruption increases nRMSE by at least 0.30 Not met
2 hop, 2 sources: reordered nRMSE at most 0.50 Not met
2 hop, 2 sources: permutation nRMSE delta at most 0.10 Met
2 hop, 4 sources: finite source-unit errors Met
2 hop, 4 sources: intact nRMSE at most 0.50 Not met
2 hop, 4 sources: beats source-mean RMSE by 20% Not met
2 hop, 4 sources: broken-key nRMSE at least 0.80 Met
2 hop, 4 sources: corruption increases nRMSE by at least 0.30 Not met
2 hop, 4 sources: reordered nRMSE at most 0.50 Not met
2 hop, 4 sources: permutation nRMSE delta at most 0.10 Met

Full run history

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

Seed Outcome Recorded Code fingerprint
7501 not met 2026-09-22T17:15:42.504337+00:00 03c38af78d7ebb2b41c7212f875c77f9951d150f14089355583a0a6d68557bd5
7502 not met 2026-09-22T17:16:36.133171+00:00 03c38af78d7ebb2b41c7212f875c77f9951d150f14089355583a0a6d68557bd5
7503 not met 2026-09-22T17:17:31.534885+00:00 03c38af78d7ebb2b41c7212f875c77f9951d150f14089355583a0a6d68557bd5

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 P075

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

PYTHONPATH=proofs python proofs/relational/retrieval_capacity.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.

Process and split unit

Each observation samples independent source values from Uniform(-1, 1). Two randomly chosen sources supply hidden targets; remaining sources are distractors. Every identity is fresh, including across train, validation, and test namespaces. Source and target orders are independent. In the two-hop arm, a separately shuffled link collection maps query keys to source keys. The model must compose those two equality relationships to recover a payload. Every requested key has exactly one source and, where applicable, one link. Missing and duplicate keys are excluded, so no undocumented tie or null convention enters the task. No observation contains a generating index field.

The paired broken-link control retains targets and all key marginals while rotating the links’ source IDs. Its visible relation points to wrong answers.

Model tree and training

Train separate one-hop and two-hop models for 600 AdamW updates each, batch size 64, learning rate 0.003. The xs preset uses two attention layers here; root and every branch retain tokens. One-hop training balances source counts 2/4/8/16, and two-hop training balances 2/4. Thus the ladder tests capacity at observed lengths, not length extrapolation. Fixed source capacity 16 and two target coordinates prevent the parameter budget changing with test length. Each arm has 4,096 train and 512 independent validation rows. Every ladder condition has 2,048 fresh test rows; validation never selects a checkpoint.

Gates are declared before training: each condition must achieve normalized RMSE at most 0.50, beat its per-record source-mean baseline by at least 20%, and have broken-key normalized RMSE at least 0.80 with a gap at least 0.30. Complete-record permutation must preserve the learning gate and change normalized RMSE by at most 0.10. This is a performance robustness gate, not exact permutation invariance; branch attention retains positional capacity. Each failed condition is reported independently, including two-hop failures.

Remaining work

The three-seed panel retained the original gates and 600 updates per arm. Diagnose the one-hop optimization instability and two-hop information route using training and validation data before designing a follow-up experiment. A single successful one-hop seed does not meet the suite’s stability contract. Even stable success would not establish exact joins, unobserved collection lengths, missing-key behavior, duplicate aggregation, or collision immunity. Source-unit errors and every condition failure remain in the evidence.

Complete experiment code

Code
"""P075: test fresh-key retrieval with distractors and an intermediate relation."""

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 = "P075"
BUDGET = 600
BATCH_SIZE = 64
TRAIN_ROWS = 4096
TEST_ROWS = 2048
CAPACITIES = (2, 4, 8, 16)


def records(
    *, rows: int, seed: int, namespace: str, hops: int, capacity: int | None = None, broken: bool = False
) -> Iterator[dict]:
    """Fresh unique keys with a matched key-rotation control and fixed targets."""
    rng = np.random.default_rng(seed)
    choices = CAPACITIES if hops == 1 else CAPACITIES[:2]
    sizes = np.resize(choices, rows) if capacity is None else np.full(rows, capacity)
    rng.shuffle(sizes)
    for row, size in enumerate(sizes):
        keys = [f"{namespace}-{row:06d}-s{index:02d}" for index in range(size)]
        values = rng.uniform(-1.0, 1.0, size)
        selected = rng.choice(size, size=2, replace=False)
        source = [{"entity_id": keys[index], "value": float(values[index])} for index in rng.permutation(size)]
        if hops == 1:
            target = [
                {"entity_id": keys[(index + int(broken)) % size], "value": float(values[index])} for index in selected
            ]
            yield {"source": source, "target": target}
        else:
            query_keys = [f"{namespace}-{row:06d}-q{index:02d}" for index in range(size)]
            links = [
                {"entity_id": query_keys[index], "source_id": keys[(index + int(broken)) % size]}
                for index in rng.permutation(size)
            ]
            target = [{"entity_id": query_keys[index], "value": float(values[index])} for index in selected]
            yield {"source": source, "links": links, "target": target}


def permute(rows: list[dict], seed: int) -> list[dict]:
    """Reorder complete records independently, preserving each visible relation."""
    rng = np.random.default_rng(seed)
    return [
        {name: [items[index] for index in rng.permutation(len(items))] for name, items in row.items()} for row in rows
    ]


def build(hops: int) -> rf.Model:
    fields = {
        "source": rf.Branch(
            length=16,
            reduction=None,
            n_layers=2,
            entity_id=rf.Hash(n_hashes=4, n_bands=8),
            value=rf.Number,
        ),
        "target": rf.Branch(
            length=2,
            reduction=None,
            n_layers=2,
            entity_id=rf.Hash(n_hashes=4, n_bands=8),
            value=rf.Number(mask=True, objective="mse"),
        ),
    }
    if hops == 2:
        fields["links"] = rf.Branch(
            length=16,
            reduction=None,
            n_layers=2,
            entity_id=rf.Hash(n_hashes=4, n_bands=8),
            source_id=rf.Hash(n_hashes=4, n_bands=8),
        )
    model = rf.Model.xs(n_layers=2, n_heads=4, reduction=None, batch_size=BATCH_SIZE, fields=fields)
    model.optimizer = rf.adamw(learning_rate=0.003)
    return model


def predict(model: rf.Model, rows: list[dict]) -> np.ndarray:
    visible = [{**row, "target": [{"entity_id": item["entity_id"]} for item in row["target"]]} for row in rows]
    output = model.predict(pa.Table.from_pylist(visible))["predictions"].to_pylist()
    return np.asarray([[item["value"]["content"] for item in row["target"]] for row in output])


def score(predicted: np.ndarray, rows: list[dict], training_mean: float) -> dict:
    actual = np.asarray([[item["value"] for item in row["target"]] for row in rows])
    source_mean = np.asarray([np.mean([item["value"] for item in row["source"]]) for row in rows])[:, None]
    baseline = float(np.sqrt(np.mean((actual - training_mean) ** 2)))
    rmse = float(np.sqrt(np.mean((predicted - actual) ** 2)))
    return {
        "rmse": rmse,
        "baseline_rmse": baseline,
        "source_mean_rmse": float(np.sqrt(np.mean((actual - source_mean) ** 2))),
        "normalized_rmse": rmse / baseline,
    }


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    metrics, checks = {}, {}
    for hops in (1, 2):
        lit.seed_everything(seed, workers=True)
        model = build(hops)
        data = rf.SyntheticDataModule(
            model=model,
            train=partial(records, rows=TRAIN_ROWS, seed=seed * 100 + 1, namespace="train", hops=hops),
            validate=partial(records, rows=512, seed=seed * 100 + 2, namespace="validate", hops=hops),
            seed=seed,
        )
        trainer = lit.Trainer(
            accelerator=accelerator,
            devices=1,
            max_steps=BUDGET if steps is None else min(steps, BUDGET),
            max_epochs=-1,
            logger=False,
            enable_checkpointing=False,
            enable_progress_bar=False,
            enable_model_summary=False,
            deterministic=True,
            num_sanity_val_steps=0,
        )
        trainer.fit(model, datamodule=data)
        model.eval()
        train = records(rows=TRAIN_ROWS, seed=seed * 100 + 1, namespace="train", hops=hops)
        training_mean = float(np.mean([item["value"] for row in train for item in row["target"]]))
        metrics[f"hops_{hops}"] = {"steps": trainer.global_step, "training_mean": training_mean, "ladder": {}}
        for capacity in CAPACITIES if hops == 1 else CAPACITIES[:2]:
            options = {
                "rows": TEST_ROWS,
                "seed": seed * 100 + 10 + capacity,
                "namespace": f"test-{hops}-{capacity}",
                "hops": hops,
                "capacity": capacity,
            }
            test = list(records(**options))
            broken = list(records(**options, broken=True))
            permuted = permute(test, seed * 100 + 40 + capacity)
            intact = score(predict(model, test), test, training_mean)
            corrupted = score(predict(model, broken), broken, training_mean)
            reordered = score(predict(model, permuted), permuted, training_mean)
            gap = corrupted["normalized_rmse"] - intact["normalized_rmse"]
            delta = abs(reordered["normalized_rmse"] - intact["normalized_rmse"])
            metrics[f"hops_{hops}"]["ladder"][capacity] = {
                "intact": intact,
                "broken": corrupted,
                "permuted": reordered,
                "corruption_gap": gap,
                "permutation_nrmse_delta": delta,
            }
            label = f"{hops} hop, {capacity} sources"
            checks.update(
                {
                    f"{label}: finite source-unit errors": bool(
                        np.isfinite([*intact.values(), *corrupted.values(), *reordered.values()]).all()
                    ),
                    f"{label}: intact nRMSE at most 0.50": intact["normalized_rmse"] <= 0.50,
                    f"{label}: beats source-mean RMSE by 20%": intact["rmse"] <= 0.8 * intact["source_mean_rmse"],
                    f"{label}: broken-key nRMSE at least 0.80": corrupted["normalized_rmse"] >= 0.80,
                    f"{label}: corruption increases nRMSE by at least 0.30": gap >= 0.30,
                    f"{label}: reordered nRMSE at most 0.50": reordered["normalized_rmse"] <= 0.50,
                    f"{label}: permutation nRMSE delta at most 0.10": delta <= 0.10,
                }
            )
    return metrics, checks


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