Can it learn classes declared in advance?

Enum
Declare the allowed input and output classes with Enum, then learn which output belongs to each visible combination.

Example

route: bay
channel: store
label: green

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

Model tree with record, route, channel, label. Amber cards are hidden prediction targets. Other fields provide the input.

Model tree with record, route, channel, label. Amber cards are hidden prediction targets. Other fields provide the input.

Figure 1: Amber cards are hidden prediction targets. Other fields provide the input.

Comparison

Compare informative training with shuffled evidence, and check that fitting and save/load preserve class order and size.

Result

P069 · Latest full run · Passing

Seed 6910 on cpu: 10 of 10 test checks met. History: 11 of 11 full runs met every check, across 10 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
Informative training 100.0%
Shuffled training and test 24.6%

The declared classes support reliable learning in this small task. This covers new records of familiar combinations, rather than new identities or meanings inferred from label names.

Experiment details and code

Latest full run

Seed 6910, cpu, recorded 2026-09-21T21:09:39.244302+00:00. Outcome: met.

Source fingerprint: 5847e6cae4b8269e6fcaed294400d0322ca605e77b7789177521bd6165636c50. Python 3.12.10; Torch 2.12.0.

Measurement Value
chance_accuracy 0.25
train_rows 4096
validation_rows 512
test_rows 2048
informative steps: 300; accuracy: 1; shuffled_test_accuracy: 0.257324; storage: route: labels: arch, bay, cove, dune, elm, ford, glen, hill; embedding_rows: 8; count_rows: 8; decoder_rows: None; channel: labels: web, store; embedding_rows: 2; count_rows: 2; decoder_rows: None; label: labels: amber, blue, coral, green; embedding_rows: 4; count_rows: 4; decoder_rows: 4; training_counts: route: arch: 2407; bay: 2407; cove: 2407; dune: 2410; elm: 2370; ford: 2396; glen: 2398; hill: 2405; channel: web: 9604; store: 9596; label: amber: 4792; blue: 4814; coral: 4790; green: 4804
shuffled_control steps: 300; accuracy: 0.0625; shuffled_test_accuracy: 0.245605; storage: route: labels: arch, bay, cove, dune, elm, ford, glen, hill; embedding_rows: 8; count_rows: 8; decoder_rows: None; channel: labels: web, store; embedding_rows: 2; count_rows: 2; decoder_rows: None; label: labels: amber, blue, coral, green; embedding_rows: 4; count_rows: 4; decoder_rows: 4; training_counts: route: arch: 2403; bay: 2415; cove: 2390; dune: 2397; elm: 2395; ford: 2405; glen: 2398; hill: 2397; channel: web: 9599; store: 9601; label: amber: 4792; blue: 4814; coral: 4790; green: 4804
Behavioral checks
Behavioral check Outcome
informative: declaration order and storage remain fixed Met
informative: prediction leaves all exposure counts unchanged Met
Checkpoint preserves declared storage and counts Met
Checkpoint preserves held-out predictions Met
shuffled_control: declaration order and storage remain fixed Met
shuffled_control: prediction leaves all exposure counts unchanged Met
Informative accuracy reaches 0.95 Met
Shuffled-context control stays below 0.35 on independent shuffled test rows Met
Shuffling test context removes learned accuracy Met
Informative accuracy exceeds the control by at least 0.55 Met

Full run history

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

Seed Outcome Recorded Code fingerprint
6901 met 2026-09-21T21:04:51.243411+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6902 met 2026-09-21T21:05:02.239145+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6903 met 2026-09-21T21:05:15.500132+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6901 met 2026-09-21T21:05:38.945656+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6904 met 2026-09-21T21:08:33.185355+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6905 met 2026-09-21T21:08:44.124590+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6906 met 2026-09-21T21:08:55.142016+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6907 met 2026-09-21T21:09:06.142352+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6908 met 2026-09-21T21:09:17.087447+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6909 met 2026-09-21T21:09:28.077534+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538
6910 met 2026-09-21T21:09:39.244302+00:00 17d69910d686b02bfc0cb197f73dacdc0e9dbaa909bc51922a3366080f684538

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 P069

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

PYTHONPATH=proofs python proofs/enum/fixed_class_learning.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 observability

Draw balanced route/channel combinations, then set the label index to (route_index + 2 * channel_index) % 4. All 16 visible combinations appear equally often. Their opaque names carry no built-in meaning. Independent row streams supply 4,096 training, 512 validation, and 2,048 test records. Repeated combinations are intentional: this tests learning a closed finite relationship, not generalization to combinations absent during fitting.

The route and channel jointly identify the hidden answer. The negative control permutes whole visible contexts between rows while leaving targets in place, preserving all input and target marginals but removing their link. A constant-label baseline scores exactly 0.25 on the balanced test set.

Training and gates

Both models use the xs preset and 300 AdamW updates at learning rate 0.002, batch size 64, one device, and no loader workers. Their parameter initialization and source rows are paired; only the context permutation differs. Input unavailability augmentation is disabled to isolate the information control. No checkpoint or hyperparameter is selected from the test split.

Predeclared gates require at least 0.95 held-out accuracy with real context, at most 0.35 on independently shuffled test context for both trained models, and a 0.55 gap between informative accuracy and the matched shuffled control. The shuffled-trained model’s informative-test accuracy is diagnostic: with only 16 distinct contexts, accidental agreement with the finite mapping need not concentrate around chance merely because test rows repeat those contexts. Declaration order, allocated rows, and evaluation-frozen exposure counts are separate mechanical gates. A checkpoint must preserve those invariants and held-out probabilities.

Complete experiment code

Code
"""P069: learn a finite Enum relationship without changing declared class storage."""

from collections.abc import Iterator
from functools import partial
from pathlib import Path
from tempfile import TemporaryDirectory

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

import relflow as rf

PROOF_ID = "P069"
BUDGET = 300
ROUTES = ("arch", "bay", "cove", "dune", "elm", "ford", "glen", "hill")
CHANNELS = ("web", "store")
LABELS = ("amber", "blue", "coral", "green")


def records(*, rows: int, seed: int, shuffled: bool = False) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    combinations = np.resize(np.arange(len(ROUTES) * len(CHANNELS)), rows)
    rng.shuffle(combinations)
    routes = combinations % len(ROUTES)
    channels = combinations // len(ROUTES)
    labels = (routes + 2 * channels) % len(LABELS)
    context = rng.permutation(rows) if shuffled else np.arange(rows)
    for row, source in enumerate(context):
        yield {"route": ROUTES[routes[source]], "channel": CHANNELS[channels[source]], "label": LABELS[labels[row]]}


def build() -> rf.Model:
    return rf.Model.xs(
        batch_size=64,
        route=rf.Enum(values=ROUTES, p_unavailable=0.0),
        channel=rf.Enum(values=CHANNELS, p_unavailable=0.0),
        label=rf.Enum(values=LABELS, p_unavailable=0.0, topk=[4], mask=True),
    )


def storage(model: rf.Model) -> dict:
    """Inspect declared IDs and parameter/count row counts, excluding learned values."""
    result = {}
    for name in ("route", "channel", "label"):
        address = rf.Address(name)
        node = model.nodes[address]
        result[name] = {
            "labels": rf.Enum.vocabulary(model, address),
            "embedding_rows": node.embedder.embeddings["content"].num_embeddings,
            "count_rows": node.embedder.counters["content"].counts.numel(),
            "decoder_rows": node.decoder.linears["content"].out_features if name == "label" else None,
        }
    return result


def prediction(model: rf.Model, rows: list[dict]) -> list[dict]:
    output = model.predict(pa.Table.from_pylist(rows))["predictions"].to_pylist()
    return [row["label"]["content"] for row in output]


def accuracy(output: list[dict], rows: list[dict]) -> float:
    return float(np.mean([value["value"] == row["label"] for value, row in zip(output, rows, strict=True)]))


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    test = list(records(rows=2048, seed=seed + 3))
    shuffled_test = list(records(rows=2048, seed=seed + 3, shuffled=True))
    metrics = {"chance_accuracy": 0.25, "train_rows": 4096, "validation_rows": 512, "test_rows": 2048}
    checks = {}
    for shuffled in (False, True):
        lit.seed_everything(seed, workers=True)
        model = build()
        initial = storage(model)
        model.optimizer = rf.adamw(learning_rate=0.002)
        data = rf.SyntheticDataModule(
            model=model,
            train=partial(records, rows=4096, seed=seed + 1, shuffled=shuffled),
            validate=partial(records, rows=512, seed=seed + 2, shuffled=shuffled),
            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()
        key = "shuffled_control" if shuffled else "informative"
        counts = {name: rf.Enum.counts(model, rf.Address(name)) for name in ("route", "channel", "label")}
        output = prediction(model, test)
        measured = accuracy(output, test)
        permuted = accuracy(prediction(model, shuffled_test), shuffled_test)
        metrics[key] = {
            "steps": trainer.global_step,
            "accuracy": measured,
            "shuffled_test_accuracy": permuted,
            "storage": storage(model),
            "training_counts": counts,
        }
        checks[f"{key}: declaration order and storage remain fixed"] = storage(model) == initial
        checks[f"{key}: prediction leaves all exposure counts unchanged"] = counts == {
            name: rf.Enum.counts(model, rf.Address(name)) for name in counts
        }
        if not shuffled:
            with TemporaryDirectory(prefix="relflow-enum-") as directory:
                path = Path(directory) / "model.pt"
                model.save(path)
                loaded = rf.Model.load(path).to(model.device)
                loaded.eval()
                restored = prediction(loaded, test)
                checks["Checkpoint preserves declared storage and counts"] = storage(loaded) == initial and counts == {
                    name: rf.Enum.counts(loaded, rf.Address(name)) for name in counts
                }
                checks["Checkpoint preserves held-out predictions"] = all(
                    before["value"] == after["value"] and abs(before["probability"] - after["probability"]) < 1e-5
                    for before, after in zip(output, restored, strict=True)
                )
    informative = metrics["informative"]["accuracy"]
    control = metrics["shuffled_control"]["shuffled_test_accuracy"]
    checks["Informative accuracy reaches 0.95"] = informative >= 0.95
    checks["Shuffled-context control stays below 0.35 on independent shuffled test rows"] = control <= 0.35
    checks["Shuffling test context removes learned accuracy"] = metrics["informative"]["shuffled_test_accuracy"] <= 0.35
    checks["Informative accuracy exceeds the control by at least 0.55"] = informative - control >= 0.55
    return metrics, checks


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