Can familiar instructions work in new combinations?

Compositional generalization
Train on group and operation requests, deliberately leaving out three combinations, such as coral’s maximum.

Example

selected_group: amber
operation: mean
items:
  - {group: blue, value: -0.4}
  - {group: amber, value: 0.2}
  - {group: coral, value: 1.1}
  - {group: amber, value: 0.8}
  - {group: blue, value: 0.6}
  - {group: coral, value: 1.5}
answer: 0.51

The answer is shown here for explanation; it is hidden from the model when scored.

Model tree with request, selected_group, operation, items, group, value, answer. Amber cards are hidden prediction targets. Repeated collections keep their fields together.

Model tree with request, selected_group, operation, items, group, value, answer. Amber cards are hidden prediction targets. Repeated collections keep their fields together.

Figure 1: Amber cards are hidden prediction targets. Repeated collections keep their fields together.

Comparison

Test the missing combinations and compare with a model trained on all combinations. Change either request to check that it matters.

Result

P071 · Latest full run · Failing

Seed 7103 on cpu: 29 of 30 test checks met. The experiment did not meet every criterion. History: 2 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
Withheld coral maximum, restricted training 133.0% of baseline error
Same request, complete training 9.8% 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.

Only two of three seeds meet every check. Familiar requests learn, but one withheld combination can fail even when both instructions are used.

Experiment details and code

Latest full run

Seed 7103, cpu, recorded 2026-09-22T17:12:42.064822+00:00. Outcome: not met.

Source fingerprint: ecb127cb5a482cfeaf7186c4516fe473ea4e2aef75418ae6b27389a0d9cffd7b. Python 3.12.10; Torch 2.12.0.

Measurement Value
train_rows 4608
test_rows 2304
noise_rmse 0.02
restricted steps: 600; cells: amber/mean: rows: 256; withheld: True; rmse: 0.404731; baseline_rmse: 0.902383; normalized_rmse: 0.448513; amber/min: rows: 256; withheld: False; rmse: 0.0971708; baseline_rmse: 0.977259; normalized_rmse: 0.099432; amber/max: rows: 256; withheld: False; rmse: 0.075787; baseline_rmse: 0.918343; normalized_rmse: 0.0825259; blue/mean: rows: 256; withheld: False; rmse: 0.0858981; baseline_rmse: 0.89503; normalized_rmse: 0.0959724; blue/min: rows: 256; withheld: True; rmse: 0.131832; baseline_rmse: 0.989074; normalized_rmse: 0.133289; blue/max: rows: 256; withheld: False; rmse: 0.0860322; baseline_rmse: 0.897679; normalized_rmse: 0.0958385; coral/mean: rows: 256; withheld: False; rmse: 0.0810484; baseline_rmse: 0.919977; normalized_rmse: 0.0880983; coral/min: rows: 256; withheld: False; rmse: 0.0975175; baseline_rmse: 0.936324; normalized_rmse: 0.104149; coral/max: rows: 256; withheld: True; rmse: 1.31487; baseline_rmse: 0.988823; normalized_rmse: 1.32973; normalized_rmse: 0.497646; corrupt_selected_group: normalized_rmse: 1.3759; gap: 0.87825; cells: amber/mean: rows: 256; withheld: True; rmse: 1.22034; baseline_rmse: 0.902383; normalized_rmse: 1.35235; amber/min: rows: 256; withheld: False; rmse: 1.36666; baseline_rmse: 0.977259; normalized_rmse: 1.39847; amber/max: rows: 256; withheld: False; rmse: 1.27799; baseline_rmse: 0.918343; normalized_rmse: 1.39162; blue/mean: rows: 256; withheld: False; rmse: 1.24672; baseline_rmse: 0.89503; normalized_rmse: 1.39294; blue/min: rows: 256; withheld: True; rmse: 1.36355; baseline_rmse: 0.989074; normalized_rmse: 1.37861; blue/max: rows: 256; withheld: False; rmse: 1.32548; baseline_rmse: 0.897679; normalized_rmse: 1.47657; coral/mean: rows: 256; withheld: False; rmse: 1.15295; baseline_rmse: 0.919977; normalized_rmse: 1.25324; coral/min: rows: 256; withheld: False; rmse: 1.32106; baseline_rmse: 0.936324; normalized_rmse: 1.4109; coral/max: rows: 256; withheld: True; rmse: 1.3106; baseline_rmse: 0.988823; normalized_rmse: 1.32542; corrupt_operation: normalized_rmse: 0.740721; gap: 0.243075; cells: amber/mean: rows: 256; withheld: True; rmse: 0.283586; baseline_rmse: 0.902383; normalized_rmse: 0.314263; amber/min: rows: 256; withheld: False; rmse: 0.694504; baseline_rmse: 0.977259; normalized_rmse: 0.710666; amber/max: rows: 256; withheld: False; rmse: 0.537571; baseline_rmse: 0.918343; normalized_rmse: 0.58537; blue/mean: rows: 256; withheld: False; rmse: 0.228057; baseline_rmse: 0.89503; normalized_rmse: 0.254804; blue/min: rows: 256; withheld: True; rmse: 0.701691; baseline_rmse: 0.989074; normalized_rmse: 0.709443; blue/max: rows: 256; withheld: False; rmse: 0.265348; baseline_rmse: 0.897679; normalized_rmse: 0.295593; coral/mean: rows: 256; withheld: False; rmse: 0.300481; baseline_rmse: 0.919977; normalized_rmse: 0.326618; coral/min: rows: 256; withheld: False; rmse: 1.64392; baseline_rmse: 0.936324; normalized_rmse: 1.75572; coral/max: rows: 256; withheld: True; rmse: 0.272846; baseline_rmse: 0.988823; normalized_rmse: 0.27593
complete steps: 600; cells: amber/mean: rows: 256; withheld: True; rmse: 0.0471597; baseline_rmse: 0.902301; normalized_rmse: 0.0522661; amber/min: rows: 256; withheld: False; rmse: 0.0577111; baseline_rmse: 0.977968; normalized_rmse: 0.0590112; amber/max: rows: 256; withheld: False; rmse: 0.0551719; baseline_rmse: 0.917923; normalized_rmse: 0.0601052; blue/mean: rows: 256; withheld: False; rmse: 0.0756431; baseline_rmse: 0.894494; normalized_rmse: 0.0845653; blue/min: rows: 256; withheld: True; rmse: 0.0947601; baseline_rmse: 0.989822; normalized_rmse: 0.0957345; blue/max: rows: 256; withheld: False; rmse: 0.0874643; baseline_rmse: 0.899019; normalized_rmse: 0.0972886; coral/mean: rows: 256; withheld: False; rmse: 0.0579; baseline_rmse: 0.919438; normalized_rmse: 0.0629733; coral/min: rows: 256; withheld: False; rmse: 0.0658752; baseline_rmse: 0.936026; normalized_rmse: 0.0703775; coral/max: rows: 256; withheld: True; rmse: 0.0969846; baseline_rmse: 0.988925; normalized_rmse: 0.0980707; normalized_rmse: 0.0779836; corrupt_selected_group: normalized_rmse: 1.42437; gap: 1.34639; cells: amber/mean: rows: 256; withheld: True; rmse: 1.25907; baseline_rmse: 0.902301; normalized_rmse: 1.3954; amber/min: rows: 256; withheld: False; rmse: 1.3908; baseline_rmse: 0.977968; normalized_rmse: 1.42213; amber/max: rows: 256; withheld: False; rmse: 1.36103; baseline_rmse: 0.917923; normalized_rmse: 1.48273; blue/mean: rows: 256; withheld: False; rmse: 1.27775; baseline_rmse: 0.894494; normalized_rmse: 1.42847; blue/min: rows: 256; withheld: True; rmse: 1.43401; baseline_rmse: 0.989822; normalized_rmse: 1.44876; blue/max: rows: 256; withheld: False; rmse: 1.34718; baseline_rmse: 0.899019; normalized_rmse: 1.4985; coral/mean: rows: 256; withheld: False; rmse: 1.24029; baseline_rmse: 0.919438; normalized_rmse: 1.34896; coral/min: rows: 256; withheld: False; rmse: 1.34211; baseline_rmse: 0.936026; normalized_rmse: 1.43384; coral/max: rows: 256; withheld: True; rmse: 1.34678; baseline_rmse: 0.988925; normalized_rmse: 1.36186; corrupt_operation: normalized_rmse: 0.507996; gap: 0.430013; cells: amber/mean: rows: 256; withheld: True; rmse: 0.367084; baseline_rmse: 0.902301; normalized_rmse: 0.406831; amber/min: rows: 256; withheld: False; rmse: 0.656726; baseline_rmse: 0.977968; normalized_rmse: 0.671521; amber/max: rows: 256; withheld: False; rmse: 0.313668; baseline_rmse: 0.917923; normalized_rmse: 0.341715; blue/mean: rows: 256; withheld: False; rmse: 0.385996; baseline_rmse: 0.894494; normalized_rmse: 0.431524; blue/min: rows: 256; withheld: True; rmse: 0.672898; baseline_rmse: 0.989822; normalized_rmse: 0.679817; blue/max: rows: 256; withheld: False; rmse: 0.371901; baseline_rmse: 0.899019; normalized_rmse: 0.413674; coral/mean: rows: 256; withheld: False; rmse: 0.375599; baseline_rmse: 0.919438; normalized_rmse: 0.408509; coral/min: rows: 256; withheld: False; rmse: 0.607221; baseline_rmse: 0.936026; normalized_rmse: 0.648723; coral/max: rows: 256; withheld: True; rmse: 0.352922; baseline_rmse: 0.988925; normalized_rmse: 0.356874
Behavioral checks
Behavioral check Outcome
restricted: amber/mean normalized RMSE below 0.50 Met
restricted: amber/min normalized RMSE below 0.35 Met
restricted: amber/max normalized RMSE below 0.35 Met
restricted: blue/mean normalized RMSE below 0.35 Met
restricted: blue/min normalized RMSE below 0.50 Met
restricted: blue/max normalized RMSE below 0.35 Met
restricted: coral/mean normalized RMSE below 0.35 Met
restricted: coral/min normalized RMSE below 0.35 Met
restricted: coral/max normalized RMSE below 0.50 Not met
restricted: corrupting selected_group increases normalized RMSE Met
restricted: corrupted group normalized RMSE exceeds 0.80 Met
restricted: selected_group control predictions are finite Met
restricted: corrupting operation increases normalized RMSE Met
restricted: operation control predictions are finite Met
restricted: intact predictions are finite Met
complete: amber/mean normalized RMSE below 0.35 Met
complete: amber/min normalized RMSE below 0.35 Met
complete: amber/max normalized RMSE below 0.35 Met
complete: blue/mean normalized RMSE below 0.35 Met
complete: blue/min normalized RMSE below 0.35 Met
complete: blue/max normalized RMSE below 0.35 Met
complete: coral/mean normalized RMSE below 0.35 Met
complete: coral/min normalized RMSE below 0.35 Met
complete: coral/max normalized RMSE below 0.35 Met
complete: corrupting selected_group increases normalized RMSE Met
complete: corrupted group normalized RMSE exceeds 0.80 Met
complete: selected_group control predictions are finite Met
complete: corrupting operation increases normalized RMSE Met
complete: operation control predictions are finite Met
complete: intact predictions are finite Met

Full run history

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

Seed Outcome Recorded Code fingerprint
7101 met 2026-09-22T17:11:17.254836+00:00 bc1cd9769246f0fbd4e0c2d57585d15882c73a2e94f53c416b72727a1ef9b10a
7102 met 2026-09-22T17:11:59.610820+00:00 bc1cd9769246f0fbd4e0c2d57585d15882c73a2e94f53c416b72727a1ef9b10a
7103 not met 2026-09-22T17:12:42.064822+00:00 bc1cd9769246f0fbd4e0c2d57585d15882c73a2e94f53c416b72727a1ef9b10a

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 P071

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

PYTHONPATH=proofs python proofs/composition/held_out_requests.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.

Synthetic process and split

Each fresh bag contains two numbers per group. Each group’s center is drawn independently from Uniform(-1.5, 1.5), with two independent Uniform(-0.8, 0.8) deviations. The visible request selects one group’s mean, minimum, or maximum. Target noise is Normal(0, 0.02). Items are randomly interleaved in every row. The model never receives group centers or the generator’s selected values.

Training excludes amber/mean, blue/min, and coral/max. Every group and every operation still occurs in two observed combinations. Independently generated bags supply 4,608 training rows, 576 validation rows, and 2,304 test rows; test has 256 rows per cell. No bag is reused across splits or requests. Validation for the restricted model also excludes withheld combinations.

Training, controls, and gates

Both arms use the xs preset, retaining all item-branch tokens, batch size 128, and 600 AdamW updates at learning rate 0.002. Initialization is paired. The complete arm has the same row count and update budget, spread over all nine combinations. It is a task-learnability control, not a matched cell-frequency control. No architecture, checkpoint, or threshold is chosen on test results.

The positive control must reach normalized RMSE below 0.35 in every cell. The restricted arm must reach 0.35 on every observed cell and 0.50 on every withheld cell. Each denominator uses a training-only operation mean pooled across exchangeable groups; no withheld target supplies a baseline estimate. Group corruption must give overall normalized RMSE above 0.80 and exceed intact error by 0.25. Operation corruption must add at least 0.10 to overall normalized RMSE. Both control predictions are compared with the original answers, so these controls measure reliance on the requests, not accuracy on the altered instructions. Report their cell scores as well as aggregate gaps.

Complete experiment code

Code
"""P071: test composition on group-operation pairs excluded from training."""

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 = "P071"
BUDGET = 600
GROUPS = ("amber", "blue", "coral")
OPERATIONS = ("mean", "min", "max")
CELLS = tuple((group, operation) for group in GROUPS for operation in OPERATIONS)
WITHHELD = tuple(zip(GROUPS, OPERATIONS, strict=True))
TRAIN_ROWS = 4608
TEST_ROWS = 2304


def records(*, rows: int, seed: int, complete: bool = False) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    cells = CELLS if complete else tuple(cell for cell in CELLS if cell not in WITHHELD)
    requests = np.resize(np.arange(len(cells)), rows)
    rng.shuffle(requests)
    for index in requests:
        group, operation = cells[index]
        values = rng.uniform(-1.5, 1.5, (3, 1)) + rng.uniform(-0.8, 0.8, (3, 2))
        selected = values[GROUPS.index(group)]
        answer = {"mean": np.mean, "min": np.min, "max": np.max}[operation](selected)
        order = rng.permutation(6)
        yield {
            "selected_group": group,
            "operation": operation,
            "items": [{"group": GROUPS[i // 2], "value": float(values.flat[i])} for i in order],
            "answer": float(answer + rng.normal(0, 0.02)),
        }


def build() -> rf.Model:
    model = rf.Model.xs(
        batch_size=128,
        selected_group=rf.Enum(values=GROUPS, p_unavailable=0.0),
        operation=rf.Enum(values=OPERATIONS, p_unavailable=0.0),
        items=rf.Branch(
            length=6,
            reduction=None,
            group=rf.Enum(values=GROUPS, p_unavailable=0.0),
            value=rf.Number,
        ),
        answer=rf.Number(mask=True, objective="mse"),
    )
    model.optimizer = rf.adamw(learning_rate=0.002)
    return model


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


def corrupt(rows: list[dict], field: str) -> list[dict]:
    """Cycle one request to a different declared value without changing its bag or target."""
    values = GROUPS if field == "selected_group" else OPERATIONS
    return [{**row, field: values[(values.index(row[field]) + 1) % len(values)]} for row in rows]


def scores(train: list[dict], test: list[dict], predicted: np.ndarray) -> dict:
    """Use the observed-operation training mean; groups are exchangeable by construction."""
    means = {
        operation: np.mean([row["answer"] for row in train if row["operation"] == operation])
        for operation in OPERATIONS
    }
    result = {}
    for group, operation in CELLS:
        indices = [
            index for index, row in enumerate(test) if (row["selected_group"], row["operation"]) == (group, operation)
        ]
        target = np.asarray([test[index]["answer"] for index in indices])
        rmse = float(np.sqrt(np.mean((predicted[indices] - target) ** 2)))
        baseline = float(np.sqrt(np.mean((means[operation] - target) ** 2)))
        result[f"{group}/{operation}"] = {
            "rows": len(indices),
            "withheld": (group, operation) in WITHHELD,
            "rmse": rmse,
            "baseline_rmse": baseline,
            "normalized_rmse": rmse / baseline,
        }
    return result


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    test = list(records(rows=TEST_ROWS, seed=seed * 100 + 3, complete=True))
    metrics = {"train_rows": TRAIN_ROWS, "test_rows": TEST_ROWS, "noise_rmse": 0.02}
    checks = {}
    for complete in (False, True):
        lit.seed_everything(seed, workers=True)
        model = build()
        data = rf.SyntheticDataModule(
            model=model,
            train=partial(records, rows=TRAIN_ROWS, seed=seed * 100 + 1, complete=complete),
            validate=partial(records, rows=576, seed=seed * 100 + 2, complete=complete),
            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=TRAIN_ROWS, seed=seed * 100 + 1, complete=complete))
        output = predict(model, test)
        cells = scores(train, test, output)
        arm = "complete" if complete else "restricted"
        measured = {"steps": trainer.global_step, "cells": cells}
        for cell, score in cells.items():
            gate = 0.50 if score["withheld"] and not complete else 0.35
            checks[f"{arm}: {cell} normalized RMSE below {gate:.2f}"] = score["normalized_rmse"] < gate
        baseline = float(np.sqrt(np.mean([score["baseline_rmse"] ** 2 for score in cells.values()])))
        target = np.asarray([row["answer"] for row in test])
        intact = float(np.sqrt(np.mean((output - target) ** 2)) / baseline)
        measured["normalized_rmse"] = intact
        for field in ("selected_group", "operation"):
            control = predict(model, corrupt(test, field))
            error = float(np.sqrt(np.mean((control - target) ** 2)) / baseline)
            measured[f"corrupt_{field}"] = {
                "normalized_rmse": error,
                "gap": error - intact,
                "cells": scores(train, test, control),
            }
            checks[f"{arm}: corrupting {field} increases normalized RMSE"] = error - intact > (
                0.25 if field == "selected_group" else 0.10
            )
            if field == "selected_group":
                checks[f"{arm}: corrupted group normalized RMSE exceeds 0.80"] = error > 0.80
            checks[f"{arm}: {field} control predictions are finite"] = bool(np.isfinite(control).all())
        checks[f"{arm}: intact predictions are finite"] = bool(np.isfinite(output).all())
        metrics[arm] = measured
    return metrics, checks


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