lenatriestounderstand

Lab · runnable experiments

Learning Rules from Examples: How Can a Model Discover a Hidden Rule?

Created Aug 9, 2026 Updated Aug 9, 2026

Read the parent note

The note reports what nine experiments found. This lab is the other half: you build the world they run in, one piece at a time, and each piece answers a question the next one depends on.

This page was run on three models — Qwen3-8B and Qwen3-30B-A3B, 4-bit, locally through MLX, plus GPT-5.6 luna through the API. Two more that the note uses, Qwen3-32B and GPT-5.6 terra, are sitting commented out in Setup: uncomment them, or replace the list with models of your own. Sample sizes here are a fraction of the note's, so read the shape across models rather than any single number, and see Learning Rules from Examples for the full sweeps. Everything that does not call a model is seeded and reproduces digit for digit.

Setup

Everything the lab uses is defined in the lab, and the standard library is enough for every section that does not call a model. For the ones that do, install a backend:

pip install openai                # any platform; reads OPENAI_API_KEY
                                  # or: pip install anthropic, reads ANTHROPIC_API_KEY

pip install mlx-lm                # local models, no key — Apple silicon only

MLX runs only on Apple silicon. On Windows or Linux the API path works unchanged; running models locally there needs a different runtime and a matching branch in Learner.

import os
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")   # keep the output readable

import json, math, random, statistics, itertools, zlib, sys, subprocess, hashlib, datetime
from collections import Counter, defaultdict
from pathlib import Path

# ── The models to run the experiments on ────────────────────────────────────
# Write the ones you want. A local name needs its weights already in your
# Hugging Face cache, an API name needs its key in the environment, and anything
# unreachable is reported and skipped rather than silently dropped.
MODELS = [
    "qwen3-8b",             # mlx-community/Qwen3-8B-4bit           ~4.5 GB
    "qwen3-30b-a3b",        # mlx-community/Qwen3-30B-A3B-...-4bit   ~17 GB
    "gpt-5.6-luna",         # OPENAI_API_KEY
    # "qwen3-32b",          # mlx-community/Qwen3-32B-4bit           ~18 GB
    # "gpt-5.6-terra",      # OPENAI_API_KEY
]

# Model replies are cached on disk so that re-running the notebook after an edit
# costs nothing. Delete the directory to force fresh calls; set CACHE_ENABLED to
# False to never write one.
CACHE, CACHE_ENABLED = Path(".lab_cache"), True

print("executed:", datetime.date.today().isoformat())
executed: 2026-08-09

The language

Every primitive maps list[int] -> list[int]. One type in, one type out, which is not cosmetic: it makes every composition well-formed, so a program is just a chain and search never has to reason about types. Selectors return a one-element list for the same reason.

def identity(x):       return list(x)
def reverse(x):        return list(x[::-1])
def rotate_left(x):    return list(x[1:]) + list(x[:1])
def rotate_right(x):   return list(x[-1:]) + list(x[:-1])
def duplicate(x):      return list(x) + list(x)
def every_second(x):   return list(x[::2])              # 0-based: positions 0, 2, 4
def increment(x):      return [v + 1 for v in x]
def decrement(x):      return [v - 1 for v in x]
def double(x):         return [2 * v for v in x]
def swap_ends(x):      return [x[-1]] + list(x[1:-1]) + [x[0]] if len(x) > 1 else list(x)
def sort_asc(x):       return sorted(x)
def sort_desc(x):      return sorted(x, reverse=True)
def keep_last(x):      return list(x[-1:])
def keep_max(x):       return [max(x)] if x else []
def pairwise_swap(x):
    out = [v for a, b in zip(x[::2], x[1::2]) for v in (b, a)]
    return out + (list(x[-1:]) if len(x) % 2 else [])

OPS = {f.__name__: f for f in (identity, reverse, rotate_left, rotate_right, duplicate,
                               every_second, increment, decrement, double, swap_ends,
                               sort_asc, sort_desc, keep_last, keep_max, pairwise_swap)}

x = [3, 1, 4, 1, 5]
for name in ("reverse", "every_second", "pairwise_swap", "keep_max"):
    print(f"  {name:<15}{x} -> {OPS[name](x)}")
  reverse        [3, 1, 4, 1, 5] -> [5, 1, 4, 1, 3]
  every_second   [3, 1, 4, 1, 5] -> [3, 4, 5]
  pairwise_swap  [3, 1, 4, 1, 5] -> [1, 3, 1, 4, 5]
  keep_max       [3, 1, 4, 1, 5] -> [5]

Note every_second: it keeps positions 0, 2, 4. Call that "drop the even positions" and you have written a 1-based description of 0-based behaviour, which is the kind of thing that mis-grades a model that was right.

A program is a chain of these, applied left to right, and a task is a hidden program plus demonstrations. Both are generated from a seed, so a task is a function of an integer rather than a file.

def run(program, x, ops=OPS):
    out = list(x)
    for name in program:
        out = ops[name](out)
    return out

def as_callable(program, ops=OPS):
    return lambda x: run(program, x, ops)

def make_task(seed, program, n_demos=3, n_tests=1, lengths=(3, 4, 5, 6), ops=OPS):
    """A hidden program, some demonstrations of it, and a held-out query."""
    rng = random.Random(seed)
    fn = as_callable(program, ops)
    xs = [[rng.randint(1, 9) for _ in range(lengths[i % len(lengths)])]
          for i in range(n_demos + n_tests)]
    return {"program": tuple(program), "fn": fn,
            "demos": [(v, fn(v)) for v in xs[:n_demos]],
            "tests": [(v, fn(v)) for v in xs[n_demos:]]}

def random_program(rng, depth, ops=OPS):
    names = [n for n in ops if n != "identity"]
    return tuple(rng.choice(names) for _ in range(depth))

t = make_task(2649, ("reverse",))
print("hidden:", " -> ".join(t["program"]))
for a, b in t["demos"]:
    print(f"    {a} -> {b}")
hidden: reverse
    [3, 5, 5] -> [5, 5, 3]
    [9, 5, 4, 8] -> [8, 4, 5, 9]
    [7, 7, 6, 4, 7] -> [7, 4, 6, 7, 7]

Same seed, same task

for seed, prim in ((2649, "reverse"), (5501, "rotate_left")):
    first, again = make_task(seed, (prim,)), make_task(seed, (prim,))
    print(f"seed {seed}, hidden rule {prim}   regenerates identically: "
          f"{first['demos'] == again['demos'] and first['tests'] == again['tests']}")
seed 2649, hidden rule reverse   regenerates identically: True
seed 5501, hidden rule rotate_left   regenerates identically: True

Change the seed and you get a different task; keep it and you get this one. There is nothing to download and nothing to lose.

Question 1: how big is the space, really?

A chain of d primitives from k options gives k^d programs. That number is where most accounts of program synthesis stop. Count them.

k = len(OPS)
for d in range(1, 7):
    print(f"  depth {d}:  {k**d:>12,} programs")
  depth 1:            15 programs
  depth 2:           225 programs
  depth 3:         3,375 programs
  depth 4:        50,625 programs
  depth 5:       759,375 programs
  depth 6:    11,390,625 programs

Eleven million at depth six. Now count something else: how many of those programs are distinguishable by running them. Give each program an output signature on a fixed set of probe inputs and keep only one program per signature.

# Seven probe inputs, deliberately varied in length, order, ties and sign.
PROBES = ([1, 2, 3], [4, 7, 9, 2], [5, 8], [3, 3, 1, 9, 6], [7],
          [2, 6, 4, 8, 1, 5], [-2, 5, 0])
MAX_LEN = 64                      # `duplicate` doubles length; cap the frontier

def signature(fn, probes=PROBES):
    """A program's behavioural fingerprint, or None if it runs out of bounds."""
    out = []
    for p in probes:
        try:
            y = fn(list(p))
        except Exception:
            return None
        if len(y) > MAX_LEN:
            return None
        out.append(tuple(y))
    return tuple(out)

def enumerate_signatures(max_depth, ops):
    """Bottom-up, level by level, keeping the shortest program per signature."""
    names = list(ops)
    seen, frontier, rows = {}, [()], []
    for depth in range(1, max_depth + 1):
        nxt = []
        for prefix in frontier:
            for name in names:
                cand = prefix + (name,)
                sig = signature(as_callable(cand, ops))
                if sig is None or sig in seen:
                    continue
                seen[sig] = cand
                nxt.append(cand)
        frontier = nxt
        rows.append((depth, len(names) ** depth, len(seen)))
    return rows, seen

rows, seen = enumerate_signatures(6, OPS)
print(f"{'depth':>6}{'programs':>14}{'signatures':>13}{'overcount':>11}")
for d, syn, sig in rows:
    print(f"{d:>6}{syn:>14,}{sig:>13,}{syn/sig:>10.0f}x")
 depth      programs   signatures  overcount
     1            15           15         1x
     2           225          115         2x
     3         3,375          680         5x
     4        50,625        3,433        15x
     5       759,375       15,616        49x
     6    11,390,625       66,274       172x

Eleven million programs are sixty-six thousand distinguishable behaviours, and the whole thing enumerated while you read this. The combinatorial argument counts programs; only observable behaviour can ever be tested against an example.

Question 2: is that trick actually valid?

It is tempting to call those sixty-six thousand "distinct behaviours" and move on. But equivalence was decided on seven probe inputs. Two programs that agree on seven inputs can disagree on an eighth — so what was counted is distinct signatures, not distinct functions.

Do not take my word for it. Look for a counterexample.

groups = defaultdict(list)
frontier = [()]
for _ in range(3):
    nxt = []
    for prefix in frontier:
        for name in OPS:
            cand = prefix + (name,)
            sig = signature(as_callable(cand))
            if sig is None:
                continue
            groups[sig].append(cand)
            nxt.append(cand)
    frontier = nxt

multi = {s: g for s, g in groups.items() if len(g) > 1}
print(f"signature groups holding more than one program: {len(multi)}")

rng = random.Random(4242)
IN_DIST = [[rng.randint(1, 9) for _ in range(rng.randint(3, 6))] for _ in range(400)]
LONGER  = [[rng.randint(1, 9) for _ in range(rng.randint(7, 12))] for _ in range(300)]

def sig_on(prog, pool):
    try:
        return tuple(tuple(run(prog, list(x))) for x in pool)
    except Exception:
        return None

for label, pool in (("same lengths as the demos (3-6)", IN_DIST), ("longer inputs (7-12)", LONGER)):
    split = sum(1 for g in multi.values()
                if any(sig_on(o, pool) != sig_on(g[0], pool) for o in g[1:]))
    print(f"  separable on {label:<32} {split:>3}/{len(multi)} = {split/len(multi):.2%}")
signature groups holding more than one program: 446
  separable on same lengths as the demos (3-6)    2/446 = 0.45%
  separable on longer inputs (7-12)              25/446 = 5.61%

So the pruning is a very good approximation and not a sound one — even at the input lengths the experiments use. Everything below inherits that caveat, and the honest word is "signatures".

Look at one of the pairs it merged:

for sig, g in multi.items():
    a, b = g[0], g[1]
    for x in LONGER:
        if run(a, list(x)) != run(b, list(x)):
            print("merged as identical:", " -> ".join(a), " vs ", " -> ".join(b))
            print("  on probes: indistinguishable")
            print(f"  on {x}:")
            print(f"    {run(a, list(x))}")
            print(f"    {run(b, list(x))}")
            break
    else:
        continue
    break
merged as identical: every_second -> reverse  vs  every_second -> swap_ends
  on probes: indistinguishable
  on [6, 8, 3, 6, 2, 3, 2, 9, 2, 2, 9]:
    [9, 2, 2, 2, 3, 6]
    [9, 3, 2, 2, 2, 6]

The version space, in bits

A dataset does not specify a rule; it eliminates hypotheses. With a finite language you can be completely literal about that, and then the vague statement "the space narrows" becomes a number: log2 of the survivors.

Take a hidden permutation of five positions — 120 hypotheses, 6.91 bits — and watch.

def permutation_op(perm):
    """Output position i takes input element perm[i]. Defined for one arity only."""
    perm = tuple(perm)
    def op(x):
        if len(x) != len(perm):
            raise ValueError("wrong arity")
        return [x[i] for i in perm]
    return op

def permutation_hypotheses(n):
    return [(f"perm{list(p)}", permutation_op(p)) for p in itertools.permutations(range(n))]

def permutation_task(seed, n=5, n_demos=6, alphabet=3):
    rng = random.Random(seed)
    perm = list(range(n)); rng.shuffle(perm)
    op = permutation_op(perm)
    xs = [[rng.randint(1, alphabet) for _ in range(n)] for _ in range(n_demos + 1)]
    return {"perm": perm, "fn": op,
            "demos": [(v, op(v)) for v in xs[:-1]], "query": xs[-1]}

def consistent(hyps, examples):
    """Mitchell's version space: everything still compatible with the data."""
    out = []
    for label, fn in hyps:
        ok = True
        for x, y in examples:
            try:
                if fn(list(x)) != list(y):
                    ok = False; break
            except Exception:
                ok = False; break
        if ok:
            out.append((label, fn))
    return out

def bits(hyps):
    return math.log2(len(hyps)) if hyps else float("nan")

hyps = permutation_hypotheses(5)
task = permutation_task(11, n=5, n_demos=6, alphabet=3)

live = hyps
print(f"start: {len(live):>3} hypotheses  {bits(live):.2f} bits")
for x, y in task["demos"]:
    live = consistent(live, [(x, y)])
    print(f"  {x} -> {y}   {len(live):>3} left  {bits(live):.2f} bits")
print("\nhidden operator was perm" + str(task["perm"]))
start: 120 hypotheses  6.91 bits
  [1, 3, 2, 3, 3] -> [2, 1, 3, 3, 3]     6 left  2.58 bits
  [1, 1, 2, 2, 1] -> [2, 1, 1, 1, 2]     2 left  1.00 bits
  [1, 3, 3, 3, 1] -> [3, 1, 3, 1, 3]     1 left  0.00 bits
  [3, 2, 2, 3, 3] -> [2, 3, 2, 3, 3]     1 left  0.00 bits
  [3, 3, 1, 3, 1] -> [1, 3, 3, 1, 3]     1 left  0.00 bits
  [3, 1, 1, 1, 1] -> [1, 3, 1, 1, 1]     1 left  0.00 bits

hidden operator was perm[2, 0, 1, 4, 3]

The knob that decides everything

Rerun that with values drawn from a wider alphabet and the picture changes completely — not because the rule got easier, but because each demonstration now carries more.

def floor_curve(alphabet, arity=5, seeds=200, max_k=6):
    hyps = permutation_hypotheses(arity)
    curves = []
    for s in range(seeds):
        t = permutation_task(1000 * alphabet + s, n=arity, n_demos=max_k, alphabet=alphabet)
        live, row = hyps, []
        for (x, y) in t["demos"]:
            live = consistent(live, [(x, y)])
            row.append(bits(live))
        curves.append(row)
    return [statistics.fmean(c[i] for c in curves) for i in range(max_k)]

print(f"{'k':>3}" + "".join(f"{'a=' + str(a):>9}" for a in (2, 3, 4, 9)))
cur = {a: floor_curve(a) for a in (2, 3, 4, 9)}
for i in range(6):
    print(f"{i+1:>3}" + "".join(f"{cur[a][i]:>9.2f}" for a in (2, 3, 4, 9)))
  k      a=2      a=3      a=4      a=9
  1     4.04     2.86     2.29     1.08
  2     2.13     1.11     0.65     0.10
  3     1.11     0.42     0.19     0.01
  4     0.64     0.17     0.04     0.01
  5     0.30     0.06     0.01     0.01
  6     0.21     0.01     0.00     0.00

Question 3: how few examples could ever be enough?

The curves above are what random examples buy. A different question: what if someone who already knew the answer got to choose the examples? That is the teaching dimension, and here it has a closed form. What follows searches for a teaching set of each size and reports the smallest one it finds, which is an upper bound checked against the formula rather than a proof of it: for sets of two or more the search samples candidates instead of enumerating them, so it can overshoot and never undershoot. Agreement across sixteen cells is evidence, not a derivation.

def teaches(combo, hyps):
    sigs = set()
    for _, fn in hyps:
        s = tuple(tuple(fn(list(x))) for x in combo)
        if s in sigs:
            return False
        sigs.add(s)
    return True

def teaching_dimension(n, a, trials=3000):
    hyps = permutation_hypotheses(n)
    universe = list(itertools.product(range(1, a + 1), repeat=n))
    r = random.Random(0)
    for k in range(1, 5):
        cands = [(u,) for u in universe] if k == 1 else \
                [tuple(r.choice(universe) for _ in range(k)) for _ in range(trials)]
        if any(teaches(c, hyps) for c in cands):
            return k
    return None

print(f"{'arity':>6}{'alphabet':>10}{'measured':>10}{'ceil(log_a n)':>15}")
for n in (3, 4, 5, 6):
    for a in (2, 3, 9):
        m = teaching_dimension(n, a)
        pred = max(1, math.ceil(math.log(n, a)))
        print(f"{n:>6}{a:>10}{m:>10}{pred:>15}{'   ok' if m == pred else '   MISMATCH'}")
 arity  alphabet  measured  ceil(log_a n)
     3         2         2              2   ok
     3         3         1              1   ok
     3         9         1              1   ok
     4         2         2              2   ok
     4         3         2              2   ok
     4         9         1              1   ok
     5         2         3              3   ok
     5         3         2              2   ok
     5         9         1              1   ok
     6         2         3              3   ok
     6         3         2              2   ok
     6         9         1              1   ok

TD(n, a) = ceil(log_a n) — the number of digits needed to give every input position a distinct label in base a. A teaching set is an identifying code. Print one and read it down the columns:

hyps5 = permutation_hypotheses(5)
universe = list(itertools.product(range(1, 3), repeat=5))
r = random.Random(0)
found = None
for _ in range(20000):
    combo = tuple(r.choice(universe) for _ in range(3))
    if teaches(combo, hyps5):
        found = combo
        break
for row in found:
    print("   ", list(row))
print("\n  position codes read down the columns:")
for pos in range(5):
    print(f"    position {pos}: {''.join(str(row[pos]) for row in found)}")
    [1, 2, 1, 1, 2]
    [2, 1, 1, 2, 2]
    [1, 1, 2, 2, 1]

  position codes read down the columns:
    position 0: 121
    position 1: 211
    position 2: 112
    position 3: 122
    position 4: 221

Three binary probes, five positions, every position a distinct 3-bit code — which is exactly ceil(log2 5) = 3. Against that, the random-sampling curves above needed nine examples at the same alphabet. A factor of three, on the same hypothesis space, purely from choosing the evidence rather than accepting it.

Question 4: which question is worth asking?

If a query partitions the surviving hypotheses, its value is the entropy of that partition. Build the oracle and let it pick.

def eig(hyps, x):
    """Expected information gain: with deterministic hypotheses this is exactly
    the entropy of the partition the query induces on the survivors."""
    groups = defaultdict(int)
    for _, fn in hyps:
        try:
            groups[tuple(fn(list(x)))] += 1
        except Exception:
            groups[None] += 1
    n = len(hyps)
    return -sum((c / n) * math.log2(c / n) for c in groups.values()) if n > 1 else 0.0

_qr = random.Random(0)
cands = [[_qr.randint(1, 9) for _ in range(5)] for _ in range(300)]
live8 = consistent(hyps5, task["demos"][:1])
best = max(cands, key=lambda x: eig(live8, x))
worst = min(cands, key=lambda x: eig(live8, x))
print(f"survivors after one demonstration: {len(live8)}  ({bits(live8):.2f} bits)")
print(f"  best query  {best}  -> {eig(live8, best):.2f} bits")
print(f"  worst query {worst}  -> {eig(live8, worst):.2f} bits")
print(f"  a query everyone agrees on is worth exactly {eig(live8, [1,1,1,1,1]):.2f} bits")
survivors after one demonstration: 6  (2.58 bits)
  best query  [7, 7, 1, 5, 9]  -> 2.58 bits
  worst query [2, 2, 7, 2, 2]  -> -0.00 bits
  a query everyone agrees on is worth exactly -0.00 bits

Picking the models

Everything up to here is arithmetic. From here on there is a learner, so this is where one gets built: the client, and the check for whether each name in MODELS is actually reachable from this machine. The list itself is in Setup — write the models you want there — and every table below carries one row per model that survived the check.

Two things about running a model locally that the usual phrasing hides. pip install mlx-lm installs the inference library and no weights at all — the weights are a separate multi-gigabyte download that Hugging Face performs the first time you load a repository, about 4.5 GB for Qwen3-8B at 4-bit and about 18 GB for Qwen3-32B. And the check below deliberately looks only for checkpoints already in your cache, so nothing here ever starts a download behind your back: fetch a model once yourself, add its repository id to LOCAL, and it appears. An API model needs only a key in the environment.

the model client - MLX, OpenAI or Anthropic
class Learner:
    """Minimal client: a local MLX model if one is cached, else an API model.

    Two behaviours matter for measurement and are worth having even here. An
    empty reply that used its whole budget is a *truncation*, not a wrong
    answer, so it is retried with more room. And replies are memoised in-process
    so re-running a cell costs nothing.
    """

    def __init__(self, kind, model):
        self.kind, self.model, self._seen = kind, model, {}
        if kind == "mlx":
            from mlx_lm import load
            self._m, self._t = load(model)
        elif kind == "openai":
            from openai import OpenAI
            self._c = OpenAI()                      # reads OPENAI_API_KEY
        else:
            import anthropic
            self._c = anthropic.Anthropic()         # reads ANTHROPIC_API_KEY

    def _once(self, system, user, max_tokens):
        if self.kind == "mlx":
            from mlx_lm import generate
            from mlx_lm.sample_utils import make_sampler
            msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
            try:
                prompt = self._t.apply_chat_template(msgs, add_generation_prompt=True,
                                                     enable_thinking=False)
            except TypeError:
                prompt = self._t.apply_chat_template(msgs, add_generation_prompt=True)
            text = generate(self._m, self._t, prompt=prompt, max_tokens=max_tokens,
                            sampler=make_sampler(temp=0.0), verbose=False)
            return text, len(self._t.encode(text))
        if self.kind == "openai":
            r = self._c.chat.completions.create(
                model=self.model, max_completion_tokens=max_tokens,
                messages=[{"role": "system", "content": system},
                          {"role": "user", "content": user}])
            return (r.choices[0].message.content or ""), r.usage.completion_tokens
        r = self._c.messages.create(
            model=self.model, max_tokens=max_tokens, system=system,
            messages=[{"role": "user", "content": user}])
        text = "".join(b.text for b in r.content if getattr(b, "type", "") == "text")
        return text, r.usage.output_tokens

    def _path(self, system, user, max_tokens):
        blob = json.dumps([self.kind, self.model, system, user, max_tokens]).encode()
        return CACHE / f"{hashlib.sha256(blob).hexdigest()[:32]}.json"

    def ask(self, system, user, max_tokens=900):
        """Answer from memory, then from disk, then from the model.

        The disk cache is what makes re-running this notebook after an edit
        free. It is written by your run, on your machine — a fresh copy of this
        lab has none of it and asks the model for everything.
        """
        key = (system, user, max_tokens)
        if key in self._seen:
            return self._seen[key]
        path = self._path(system, user, max_tokens)
        if CACHE_ENABLED and path.exists():
            d = json.loads(path.read_text())
            if d["text"].strip():        # an empty cached reply is a failure, not an answer
                out = type("Reply", (), {"text": d["text"],
                                         "completion_tokens": d["tokens"]})()
                self._seen[key] = out
                return out
        text, used = self._once(system, user, max_tokens)
        for factor in (4, 12):                       # empty + budget spent = truncated
            if text.strip() or used < 0.98 * max_tokens:
                break
            text, used = self._once(system, user, int(max_tokens * factor))
        if CACHE_ENABLED:
            CACHE.mkdir(exist_ok=True)
            path.write_text(json.dumps({"text": text, "tokens": used}))
        out = type("Reply", (), {"text": text, "completion_tokens": used})()
        self._seen[key] = out
        return out


LOCAL = {"qwen3-8b": "mlx-community/Qwen3-8B-4bit",
         "qwen3-30b-a3b": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit",
         "qwen3-32b": "mlx-community/Qwen3-32B-4bit"}

def available_learners():
    """Every model this machine can actually reach, cheapest first.

    Local weights are only listed if they are already in the Hugging Face cache
    — this never triggers a multi-gigabyte download behind your back. API models
    are listed if the matching key is in the environment.
    """
    found = []
    try:
        from huggingface_hub import try_to_load_from_cache
        import mlx_lm  # noqa: F401
        for alias, repo in LOCAL.items():
            if try_to_load_from_cache(repo, "config.json"):
                found.append((alias, "mlx", repo))
    except Exception:
        pass
    if os.environ.get("OPENAI_API_KEY"):
        found.append(("gpt-5.6-luna", "openai", "gpt-5.6-luna"))
        found.append(("gpt-5.6-terra", "openai", "gpt-5.6-terra"))
    if os.environ.get("ANTHROPIC_API_KEY"):
        found.append(("claude-opus-5", "anthropic", "claude-opus-5"))
    return found

AVAILABLE = available_learners()
_BUILT = {}

RESIDENT_LOCAL = 2      # how many sets of local weights may be held at once

def learner(name):
    """Build on first use, holding at most RESIDENT_LOCAL local checkpoints.

    Three 4-bit Qwen3 models at once are about 40 GB. Evicting the
    least-recently-used one costs a reload when a cell loops over several local
    models, and costs nothing at all in the single-model default. Lower it to 1
    on a smaller machine, raise it if you have the memory and would rather not
    reload. API models hold no weights and are never evicted.
    """
    if name not in _BUILT:
        kind, model = next((k, m) for n, k, m in AVAILABLE if n == name)
        if kind == "mlx":
            local = [k for k, v in _BUILT.items() if v.kind == "mlx"]
            for other in local[:max(0, len(local) - RESIDENT_LOCAL + 1)]:
                del _BUILT[other]
        _BUILT[name] = Learner(kind, model)
    _BUILT[name] = _BUILT.pop(name)          # move to the end: most recently used
    return _BUILT[name]

reachable = [n for n, _, _ in AVAILABLE]
for m in MODELS:
    if m not in reachable:
        why = ("weights not in the Hugging Face cache" if m in LOCAL
               else "no API key for it in the environment")
        print(f"skipping {m}: {why}")
MODELS = [m for m in MODELS if m in reachable]
print("running:", ", ".join(MODELS) or "nothing — the model cells will say so and skip")
running: qwen3-8b, qwen3-30b-a3b, gpt-5.6-luna

The verifier comes first

Before any model speaks, decide how you will grade it. Not by reading its English — by compiling what it says and running it.

def extract_json(text):
    """First well-formed JSON object anywhere in the reply — survives markdown
    fences and chatter. No regex; the decoder decides where the object ends."""
    dec = json.JSONDecoder()
    for i, ch in enumerate(text):
        if ch != "{":
            continue
        try:
            obj, _ = dec.raw_decode(text[i:])
        except ValueError:
            continue
        if isinstance(obj, dict):
            return obj
    return None

def compile_answer(ans, ops=OPS):
    """Turn what the model said into something executable.

    A named chain of primitives is the only representation accepted, and that
    is deliberate twice over. It keeps the answer inside the language the model
    was actually offered — validating against a wider dictionary would accept a
    primitive the prompt had removed and quietly destroy the ablation later on.
    And it means nothing the model writes is ever executed as code: the reply is
    a list of names, checked against `ops`, and the interpreter is the one
    written at the top of this lab. Running model-authored Python behind a
    trimmed `__builtins__` is not a sandbox, and there is nothing here it would
    buy.
    """
    if not isinstance(ans, dict):
        return None, "no-answer"
    names = ans.get("program")
    if isinstance(names, list) and names and all(str(n) in ops for n in names):
        prog = tuple(str(n) for n in names)
        return (lambda x: run(prog, list(x), ops)), "program"
    return None, "uncompilable"

def extensionally_equal(fn, truth, probes=PROBES):
    for pr in probes:
        try:
            if list(fn(list(pr))) != list(truth(list(pr))):
                return False
        except Exception:
            return False
    return True

replies = [
    '{"rule": "move the first element to the end", "program": ["rotate_left"]}',
    'Sure! ```json\n{"rule":"reverse then duplicate","program":["reverse","duplicate"]}\n```',
    '{"rule": "shift left", "python": "def f(x):\\n    return x[1:] + x[:1]"}',
    '{"rule": "move the last element to the front, no wait, the first to the end",'
    ' "program": ["rotate_left"]}',
]
truth = OPS["rotate_left"]
for r in replies:
    ans = extract_json(r) or {}
    fn, how = compile_answer(ans)
    ok = fn is not None and extensionally_equal(fn, truth)
    print(f"  compiled from {how:<9} equals rotate_left: {ok}   <- {ans.get('rule','')[:44]}")
  compiled from program   equals rotate_left: True   <- move the first element to the end
  compiled from program   equals rotate_left: False   <- reverse then duplicate
  compiled from uncompilable equals rotate_left: False   <- shift left
  compiled from program   equals rotate_left: True   <- move the last element to the front, no wait,

The last one is the point: a confused sentence that compiles to the right behaviour is correct, and string-matching would have marked it wrong.

The third one is the other point. It gives only Python, and it comes back uncompilable even though the code is right. That is deliberate. A named chain of primitives is the only representation this lab will execute, which keeps every answer inside the language the model was offered — the thing the ablation later depends on — and means nothing a model writes is ever run as code on your machine.

The axis nobody usually measures

There are four separate questions hiding in "did it learn the rule?", and one of them — does the learner's own program reproduce the demonstrations it was shown — is checkable without knowing the answer.

Run it yourself. One task per primitive, three demonstrations each, and every reply graded on all four axes — including the one that needs no answer key.

SYSTEM = ("You infer a hidden transformation on lists of integers from examples. "
          "You are precise, you commit to an answer, and you reply with JSON only.")

def menu(ops=OPS):
    return "\n".join(f"  {n}" for n in ops)

def identification_prompt(demos, query, ops=OPS):
    shown = "\n".join(f"  {list(a)} -> {list(b)}" for a, b in demos)
    return (f"A hidden function phi maps a list of integers to a list of integers.\n"
            f"Observed:\n{shown}\n\nWhat is phi({list(query)})?\n\n"
            f"phi is a chain of one or more of these primitives, applied left to right:\n"
            f"{menu(ops)}\n\n"
            'Answer with one JSON object and nothing else:\n'
            '{"rule": "<one sentence>", "program": [<primitive names>], '
            '"output": [<the transformed list>]}')

def ask(model, prompt, max_tokens=900):
    return learner(model).ask(SYSTEM, prompt, max_tokens=max_tokens)
def identify(model, primitive, seed):
    """One task, one reply, graded on all four axes separately."""
    task = make_task(seed, (primitive,), n_demos=3, n_tests=1)
    query, truth = task["tests"][0]
    reply = ask(model, identification_prompt(task["demos"], query))
    ans = extract_json(reply.text)
    fn, how = compile_answer(ans or {})
    stated_out = (ans or {}).get("output")
    return {
        "primitive": primitive,
        "stated_program": (ans or {}).get("program"),
        # 1. did the reply come back as usable JSON at all
        "parsed": ans is not None,
        # 2. is the list it wrote out for the query right
        "output_correct": (list(stated_out) == list(truth)
                           if isinstance(stated_out, list) else False),
        # 3. is the rule it named the hidden function, on the probe set
        "rule_correct": fn is not None and extensionally_equal(fn, task["fn"]),
        # 4. checkable without an answer key: does its own rule reproduce the demos?
        "self_consistent": fn is not None and all(
            list(fn(list(a))) == list(b) for a, b in task["demos"]),
    }, task

E1 = {}
targets = [n for n in OPS if n != "identity"]
for m in MODELS:
    E1[m] = [identify(m, n, zlib.crc32(f"lab:{n}".encode()) % 10_000)[0] for n in targets]

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{len(targets)} primitives, one task each, one reply graded four ways\n")
    print(f"{'model':<16}{'parsed':>9}{'output':>9}{'rule':>8}{'fits demos':>13}"
          f"{'fits but wrong':>16}")
    for m, got in E1.items():
        n = len(got)
        col = lambda k: sum(bool(r[k]) for r in got) / n
        both = sum(1 for r in got if r["self_consistent"] and not r["rule_correct"])
        print(f"{m:<16}{col('parsed'):>9.2f}{col('output_correct'):>9.2f}"
              f"{col('rule_correct'):>8.2f}{col('self_consistent'):>13.2f}{both:>16}")
    print("\n  " + "  ".join(f"{r['primitive']}{'+' if r['rule_correct'] else '-'}"
                              for r in E1[MODELS[0]]))
14 primitives, one task each, one reply graded four ways

model              parsed   output    rule   fits demos  fits but wrong
qwen3-8b             1.00     0.36    0.36         0.36               0
qwen3-30b-a3b        1.00     0.64    0.36         0.43               1
gpt-5.6-luna         1.00     0.93    0.93         1.00               1

  reverse-  rotate_left-  rotate_right-  duplicate+  every_second-  increment+  decrement-  double+  swap_ends-  sort_asc-  sort_desc-  keep_last+  keep_max+  pairwise_swap-

The four scoring columns are the axes the note keeps apart, and they do not move together: writing the right output for one query is a different skill from naming the rule that produced it. The two that matter most land on top of each other — rule correct and fits demos — and the last column is zero or one. That is the finding, and the last column is what the pairing is for: a weak model does not produce plausible wrong answers. Its failures contradict the very examples it was shown, so a check that never sees the answer key disposes of them. Watch that column as you read down the models — it is the quantity this whole lab later depends on, and the interesting news is that it does not stay at zero as the models get better.

Fourteen tasks per model is a small sample and the absolute levels bounce around; the ordering across models is the stable part. The note's sweep, 210 tasks per model, put Qwen3-8B at 0.367 correct and 0.371 self-consistent with 1 plausible-but-wrong.

Take one of its misses apart:

if not MODELS:
    print("no learner available — skipping")
else:
    got = E1[MODELS[0]]
    miss = next((r for r in got if not r["rule_correct"] and r["stated_program"]), None)
    if miss is None:
        print("no compilable miss this run — try another model or more primitives")
    else:
        task = make_task(zlib.crc32(f"lab:{miss['primitive']}".encode()) % 10_000,
                         (miss["primitive"],))
        fn = (lambda prog: (lambda x: run(tuple(prog), list(x))))(miss['stated_program'])
        print(f"hidden rule: {miss['primitive']} | model said: {miss['stated_program']}")
        for x, y in task['demos']:
            out = fn(list(x))
            print(f"  {x} -> shown {y}, its rule gives {out}"
                  f"   {'ok' if out == y else '<-- breaks here'}")
hidden rule: reverse | model said: ['sort_desc', 'reverse']
  [9, 7, 6] -> shown [6, 7, 9], its rule gives [6, 7, 9]   ok
  [5, 2, 5, 6] -> shown [6, 5, 2, 5], its rule gives [2, 5, 5, 6]   <-- breaks here
  [4, 6, 1, 5, 7] -> shown [7, 5, 1, 6, 4], its rule gives [1, 4, 5, 6, 7]   <-- breaks here

A three-line check, no ground truth, and the answer is gone. That check is the whole basis of the guided search later on.

Asking the learner where the floor is

The curves above say what the evidence permits. They say nothing about any model. Now put a learner on the same tasks and split the results by the only variable that matters: was the rule pinned down by the evidence, or not?

def permutation_prompt(demos, query, arity, explicit=True):
    shown = "\n".join(f"  {list(a)} -> {list(b)}" for a, b in demos)
    convention = (f" Positions are 0-based, so p is a permutation of 0..{arity-1}."
                  if explicit else "")
    return (f"A hidden operator Psi rearranges lists of exactly {arity} integers: output "
            f"position i takes input position p[i], for some unknown permutation p."
            f"{convention}\nObserved:\n{shown}\n\nWhat is Psi({list(query)})?\n\n"
            'Answer with one JSON object and nothing else:\n'
            '{"permutation": [...], "output": [...]}')

def displacement(perm):
    """Distance from the identity — a simplicity proxy with no theory behind it
    beyond 'moving fewer things is a smaller change'."""
    return sum(1 for i, v in enumerate(perm) if i != v)

def evidence_trial(model, k, alphabet, arity=5, seeds=6):
    """One cell of the sweep: k demonstrations over an alphabet of that size."""
    hyps = permutation_hypotheses(arity)
    rows = []
    for s in range(seeds):
        t = permutation_task(7000 + 100 * k + s, n=arity, n_demos=k, alphabet=alphabet)
        live = consistent(hyps, t["demos"])
        live_perms = [json.loads(l[len("perm"):]) for l, _ in live]
        obj = extract_json(ask(
            model, permutation_prompt(t["demos"], t["query"], arity), 900).text) or {}
        perm = obj.get("permutation")
        valid = (isinstance(perm, list) and len(perm) == arity
                 and sorted(perm) == list(range(arity)))
        rows.append({
            "identifiable": len(live) == 1,
            "bits": bits(live),
            "exact": bool(valid and list(perm) == t["perm"]),
            # a weaker, fairer question when the evidence cannot pin it down:
            # did it at least name something the data still allows?
            "consistent_pick": bool(valid and list(perm) in live_perms),
            "named_disp": displacement(perm) if valid else None,
            "min_disp": min(displacement(p) for p in live_perms),
            # what picking uniformly among the survivors would score: the share
            # of survivors that are already least-moved. Without this the rate
            # below is a preference with nothing to be a preference over.
            "chance": (sum(1 for p in live_perms
                           if displacement(p) == min(displacement(q) for q in live_perms))
                       / len(live_perms)),
        })
    return rows

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'k':>3}{'alphabet':>10}{'mean bits left':>16}"
          f"{'exact':>8}{'consistent':>12}")
    E2 = {}
    for m in MODELS:
        E2[m] = []
        for alphabet, k in ((9, 3), (9, 6), (3, 3), (3, 6)):
            rows = evidence_trial(m, k, alphabet)
            E2[m] += rows
            n = len(rows)
            print(f"{m:<15}{k:>3}{alphabet:>10}"
                  f"{statistics.fmean(r['bits'] for r in rows):>16.2f}"
                  f"{sum(r['exact'] for r in rows) / n:>8.2f}"
                  f"{sum(r['consistent_pick'] for r in rows) / n:>12.2f}")
model            k  alphabet  mean bits left   exact  consistent
qwen3-8b         3         9            0.17    0.17        0.33
qwen3-8b         6         9            0.00    0.17        0.17
qwen3-8b         3         3            0.50    0.00        0.00
qwen3-8b         6         3            0.17    0.00        0.00
qwen3-30b-a3b    3         9            0.17    0.33        0.33
qwen3-30b-a3b    6         9            0.00    0.83        0.83
qwen3-30b-a3b    3         3            0.50    0.17        0.17
qwen3-30b-a3b    6         3            0.17    0.00        0.00
gpt-5.6-luna     3         9            0.17    0.83        1.00
gpt-5.6-luna     6         9            0.00    1.00        1.00
gpt-5.6-luna     3         3            0.50    0.67        1.00
gpt-5.6-luna     6         3            0.17    0.83        1.00

A large alphabet makes ties rare, so each demonstration eliminates almost everything and the version space collapses to one candidate. A small alphabet produces repeated values, which are exactly the cases a permutation cannot be read off from — two demonstrations can be consistent with dozens of permutations. In the note's sweep the difference is stark: 0.976 exact where the evidence identifies the rule, and a collapse where it does not. The model is not worse at the small-alphabet tasks. The evidence is.

That leaves the interesting half. When the evidence does not pin the rule down, the learner still has to name one — and which one it names is a direct readout of its prior, measurable without any theory of what the prior is.

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'ambiguous & consistent':>24}{'least-moved':>13}"
          f"{'rate':>7}{'chance':>8}")
    for m in MODELS:
        amb = [r for r in E2[m] if not r["identifiable"] and r["consistent_pick"]]
        hit = sum(1 for r in amb if r["named_disp"] == r["min_disp"])
        rate = hit / len(amb) if amb else float("nan")
        chance = statistics.fmean(r["chance"] for r in amb) if amb else float("nan")
        cells = (f"{rate:>7.2f}{chance:>8.2f}" if amb else f"{'-':>7}{'-':>8}")
        print(f"{m:<15}{len(amb):>24}{hit:>13}{cells}")
model            ambiguous & consistent  least-moved   rate  chance
qwen3-8b                              1            0   0.00    0.50
qwen3-30b-a3b                         0            0      -       -
gpt-5.6-luna                          5            1   0.20    0.50

Displacement — how many positions the permutation moves — is a crude simplicity measure chosen in advance, not fitted afterwards. The last column is what makes the one before it mean anything: it is the rate a learner sampling uniformly among the survivors would score, computed per task as the share of survivors that are already least-moved. A rate above chance is a prior for small changes; a rate at chance is no prior at all, only arithmetic. In the note's sweep the gap is 0.512 expected against roughly three quarters observed.

Do not read the rate off this run. A task only enters the count if the evidence left it ambiguous and the learner named a survivor, and at twenty-four tasks per model that leaves a handful of rows or none at all — a model with zero qualifying tasks prints a dash rather than a number. The note's sweep is where this is decided; the cell here exists so the measurement is yours to enlarge.

It is measurable at all precisely because the likelihood is flat across every survivor: they all reproduce the evidence exactly, so nothing but the prior can be choosing between them.

That is the whole argument of the note in one measurement. With a deterministic hypothesis class, the posterior is the prior restricted to what survives — so whenever the evidence leaves more than one survivor, what you are observing is not inference. It is taste.

When the ambiguity is built on purpose

So far the ambiguity has been incidental — the evidence happened to be thin. Now build it deliberately. The hidden rule is keep_last, and every demonstration is sorted ascending, so the last element is also the maximum. keep_max fits the evidence exactly, and so do a few dozen other behaviours. One test input breaks the correlation.

PROBE = [9, 2, 4]                    # last element is deliberately not the maximum

# Every depth-<=2 program, as (label, callable). Small enough to write out.
DEPTH2 = [(" -> ".join(p), as_callable(p)) for p in
          [(a,) for a in OPS] + [(a, b) for a in OPS for b in OPS]]

def shortcut_demos(seed, aligned=True, n_demos=3):
    """True rule: keep_last. aligned=True sorts each input ascending, so the last
    element is also the maximum and the two rules cannot be told apart. aligned=False
    is the control — descending inputs, where they already disagree in the demos."""
    rng = random.Random(seed)
    demos = []
    for _ in range(n_demos):
        body = sorted(rng.sample(range(1, 10), rng.choice([3, 4])))
        x = body if aligned else body[::-1]
        demos.append((x, [x[-1]]))
    return demos

def shortcut_prompt(demos, condition, probe=None):
    probe = PROBE if probe is None else probe
    shown = "\n".join(f"  {list(a)} -> {list(b)}" for a, b in demos)
    head = (f"A hidden function phi maps a list of integers to a list of integers.\n"
            f"Observed:\n{shown}\n\nWhat is phi({probe})?\n\n"
            f"phi is a chain of one or more of these primitives, applied left to right:\n"
            f"{menu()}\n\n")
    if condition == "direct":
        return head + ('Answer with one JSON object and nothing else:\n'
                       '{"output": [<the transformed list>]}')
    if condition == "rule_first":
        return head + ("State the rule before answering.\n\n"
                       'Answer with one JSON object and nothing else:\n'
                       '{"rule": "<one sentence>", "program": [<primitive names>], '
                       '"output": [<the transformed list>]}')
    return head + ("First list every rule consistent with ALL the observations, not just "
                   "the first one you think of. Then answer.\n\n"
                   'Answer with one JSON object and nothing else:\n'
                   '{"candidates": [<one entry per consistent rule>], '
                   '"rule": "<the one you commit to>", "output": [<the transformed list>]}')

REPAIR_PROBE = [8, 3, 5]              # a second input where last is not the maximum

def verdict(out, probe=None):
    probe = PROBE if probe is None else probe
    if out == [probe[-1]]:  return "true rule"
    if out == [max(probe)]: return "shortcut"
    return "other"

Before asking anyone, count what the evidence actually leaves open.

demos = shortcut_demos(1, aligned=True)
live = consistent(DEPTH2, demos)
print("demonstrations:", [[x, y] for x, y in demos])
print(f"depth-<=2 behaviours consistent with them: {len(live)}   ({bits(live):.2f} bits)")
print("  including:", ",  ".join(l for l, _ in live[:5]))
print(f"\non the test {PROBE}:  keep_last -> {[PROBE[-1]]},  keep_max -> {[max(PROBE)]}")
demonstrations: [[[1, 2, 5], [5]], [[4, 6, 8, 9], [9]], [[1, 2, 8], [8]]]
depth-<=2 behaviours consistent with them: 36   (5.17 bits)
  including: keep_last,  keep_max,  identity -> keep_last,  identity -> keep_max,  reverse -> keep_max

on the test [9, 2, 4]:  keep_last -> [4],  keep_max -> [9]

The shortcut is not a mistake in the data — it is a hypothesis the data genuinely supports. Which one comes back is a readout of the learner's prior, so it is worth asking three different ways, and worth running the control where the two rules already disagree.

CONDITIONS = ("direct", "rule_first", "hypotheses_first")

def shortcut_trial(model, condition, aligned, seeds=4):
    """The fourth condition, `repair`, is different in kind: the learner is shown
    the counterexample it just got wrong, added to the evidence, and asked again
    on a fresh input. It measures whether being corrected once is enough."""
    tally = Counter()
    for s in range(seeds):
        d = shortcut_demos(s, aligned=aligned)
        if condition == "repair":
            d = list(d) + [(PROBE, [PROBE[-1]])]      # hand it the counterexample
            probe, asked = REPAIR_PROBE, "rule_first"
        else:
            probe, asked = PROBE, condition
        obj = extract_json(ask(model, shortcut_prompt(d, asked, probe), 1500).text) or {}
        out = obj.get("output")
        tally[verdict(out, probe) if isinstance(out, list) else "other"] += 1
    return tally

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'asked':<19}{'demos':<10}{'true rule':>10}{'shortcut':>10}{'other':>7}")
    for m in MODELS:
        rows = [(c, True) for c in CONDITIONS] + [("repair", True), ("rule_first", False)]
        for cond, aligned in rows:
            t = shortcut_trial(m, cond, aligned=aligned)
            n = sum(t.values())
            print(f"{m:<15}{cond:<19}{'baited' if aligned else 'control':<10}"
                  f"{t['true rule']/n:>10.2f}{t['shortcut']/n:>10.2f}{t['other']/n:>7.2f}")
model          asked              demos      true rule  shortcut  other
qwen3-8b       direct             baited          0.75      0.25   0.00
qwen3-8b       rule_first         baited          1.00      0.00   0.00
qwen3-8b       hypotheses_first   baited          1.00      0.00   0.00
qwen3-8b       repair             baited          1.00      0.00   0.00
qwen3-8b       rule_first         control         1.00      0.00   0.00
qwen3-30b-a3b  direct             baited          0.25      0.75   0.00
qwen3-30b-a3b  rule_first         baited          0.00      1.00   0.00
qwen3-30b-a3b  hypotheses_first   baited          0.00      1.00   0.00
qwen3-30b-a3b  repair             baited          0.00      1.00   0.00
qwen3-30b-a3b  rule_first         control         0.25      0.25   0.50
gpt-5.6-luna   direct             baited          0.25      0.75   0.00
gpt-5.6-luna   rule_first         baited          0.00      1.00   0.00
gpt-5.6-luna   hypotheses_first   baited          0.25      0.75   0.00
gpt-5.6-luna   repair             baited          0.50      0.25   0.25
gpt-5.6-luna   rule_first         control         1.00      0.00   0.00

The control row is what makes the rest mean anything: when the demonstrations themselves separate keep_last from keep_max, the learner has no trouble, so any gap on the baited rows is the bait and not the difficulty.

Read the small local models and the frontier ones against each other here, because the ordering is the opposite of the one you would guess. Qwen3-8B answers with the true rule in about 80% of baited tasks. That is not a point in its favour: it is not reading the maximum at all, it is reproducing the most literal description of the mapping it can, and the last element is the most literal one. The bait needs a learner good enough to notice the pattern before it can catch anything.

Which is what the note's sweep found — the effect got sharper the more capable the model was. GPT-5.6 luna answered with the shortcut in 85% of baited tasks when asked to state its rule first, and in 20% when asked to enumerate every consistent hypothesis before committing. Same model, same evidence, same test; the only change was being made to look at the version space instead of at the first hypothesis that fit. The repair row asks the cheaper question people usually reach for instead: show it the counterexample and ask again.

Choosing your own experiment

The oracle above knew every hypothesis and could score every candidate query. A learner cannot do that, but it can be handed the candidate set and asked to design one input. Whatever it proposes gets scored by the same entropy the oracle maximises, so the comparison is in bits rather than in vibes.

# One finite, shared query domain. The oracle maximises over exactly the inputs
# the learner is allowed to pick from, so "regret" is a real regret — with an
# open-ended input space the best-in-a-sample is not an oracle at all, and a
# learner can beat it, which is precisely what happened once in the note's sweep.
QUERY_DOMAIN = [[a, b, c] for a in (1, 2, 5, 9) for b in (1, 3, 7) for c in (2, 4, 8)] + \
               [[a, b, c, d] for a in (1, 6) for b in (2, 9) for c in (3, 5) for d in (4, 7)]

def query_prompt(labels, domain=QUERY_DOMAIN):
    """Both the oracle and the learner choose by index out of the same list.

    Describing the domain without printing it, and then accepting any list the
    learner writes, would leave the oracle restricted and the learner free —
    which is not a comparison at all. Numbering the queries removes the gap by
    construction: an answer is a `Q` number or it is nothing.
    """
    listing = "\n".join(f"  H{i+1}: {l}" for i, l in enumerate(labels))
    queries = "\n".join(f"  Q{i}: {x}" for i, x in enumerate(domain))
    return ("A hidden function is exactly one of these candidates:\n" + listing +
            "\n\nYou may observe the hidden function's output on ONE input, and it "
            "must be one of these:\n" + queries +
            "\n\nChoose the one whose output would rule out as many candidates as "
            "possible.\n\n"
            'Answer with one JSON object and nothing else:\n'
            '{"query_id": <the Q number>, "reason": "<one sentence>"}')

def parse_query_id(text, domain=QUERY_DOMAIN):
    """Accept 7, "7" and "Q7" alike.

    A reply that names the right query in a slightly different shape is a
    correct answer. Insisting on a bare integer manufactures exactly the kind of
    capability gap the traps below are about — and it did: on the first run of
    this section one model answered `"Q36"` every single time and scored zero
    bits in all twelve cells.
    """
    raw = (extract_json(text) or {}).get("query_id")
    if isinstance(raw, bool):
        return None
    if isinstance(raw, str):
        raw = raw.strip().lstrip("Qq")
    try:
        i = int(raw)
    except (TypeError, ValueError):
        return None
    return domain[i] if 0 <= i < len(domain) else None

QPROBE = QUERY_DOMAIN[::7]

def _qsig(fn):
    try:
        return tuple(tuple(fn(list(x))) for x in QPROBE)
    except Exception:
        return None

def candidate_pool():
    """One program per distinct QPROBE signature, as (label, callable).

    Distinct on the probe set, which is not the same as behaviourally distinct —
    the section on probe collisions measured exactly how much that costs.
    """
    sigs = {}
    for prog in [(a,) for a in OPS] + [(a, b) for a in OPS for b in OPS]:
        fn = as_callable(prog)
        sg = _qsig(fn)
        if sg is not None and sg not in sigs:
            sigs[sg] = (" -> ".join(prog), fn)
    return list(sigs.values())

POOL = candidate_pool()

def hypothesis_set(rng, size, condition):
    """Two ways to build a candidate set, and the difference is the experiment.

    `diverse` samples at random: most pairs already disagree on most inputs, so
    almost any query separates almost everything and a learner picking blindly
    scores near the ceiling. Such a task cannot tell you whether the learner is
    choosing. `confusable` ranks the pool by how often each program agrees with
    a random anchor and keeps the closest — there the choice of query decides
    how much you learn, which is the version the note reports.
    """
    if condition == "diverse":
        return rng.sample(POOL, size)
    anchor = POOL[rng.randrange(len(POOL))]
    a_sig = _qsig(anchor[1])
    agreement = lambda e: sum(u == v for u, v in zip(a_sig, _qsig(e[1])))
    return sorted(POOL, key=lambda e: (-agreement(e), len(e[0])))[:size]

def query_design(model, size, seed, condition="confusable"):
    """Returns (ceiling, oracle bits, mean bits of a blind pick, model bits, query)."""
    rng = random.Random(seed)
    hyps = hypothesis_set(rng, size, condition)
    oracle = max(eig(hyps, x) for x in QUERY_DOMAIN)
    chance = statistics.fmean(eig(hyps, x) for x in QUERY_DOMAIN)
    q = parse_query_id(ask(model, query_prompt([l for l, _ in hyps]), 900).text)
    return bits(hyps), oracle, chance, (eig(hyps, q) if q else 0.0), q
if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'set':<12}{'n':>4}{'ceiling':>9}{'oracle':>8}{'model':>7}"
          f"{'blind':>7}{'regret':>8}   its query")
    for m in MODELS:
        for cond in ("diverse", "confusable"):
            for size in (5, 10, 20):
                ceil_, o, c, g, q = query_design(m, size, seed=size, condition=cond)
                print(f"{m:<15}{cond:<12}{size:>4}{ceil_:>9.2f}{o:>8.2f}{g:>7.2f}"
                      f"{c:>7.2f}{o - g:>8.2f}   {q}")
model          set            n  ceiling  oracle  model  blind  regret   its query
qwen3-8b       diverse        5     2.32    2.32   1.92   2.22    0.40   [1, 2, 3, 4]
qwen3-8b       diverse       10     3.32    3.32   3.32   3.30    0.00   [1, 3, 2]
qwen3-8b       diverse       20     4.32    4.32   4.12   3.92    0.20   [1, 2, 3, 4]
qwen3-8b       confusable     5     2.32    2.32   1.92   1.80    0.40   [1, 2, 3, 4]
qwen3-8b       confusable    10     3.32    3.32   3.32   3.16    0.00   [1, 3, 2]
qwen3-8b       confusable    20     4.32    4.22   3.92   2.95    0.30   [1, 2, 3, 4]
qwen3-30b-a3b  diverse        5     2.32    2.32   1.92   2.22    0.40   [9, 7, 8]
qwen3-30b-a3b  diverse       10     3.32    3.32   3.32   3.30    0.00   [1, 2, 3, 4]
qwen3-30b-a3b  diverse       20     4.32    4.32   3.78   3.92    0.54   [9, 1, 2]
qwen3-30b-a3b  confusable     5     2.32    2.32   1.92   1.80    0.40   [1, 2, 3, 4]
qwen3-30b-a3b  confusable    10     3.32    3.32   3.32   3.16    0.00   [9, 1, 2]
qwen3-30b-a3b  confusable    20     4.32    4.22   3.92   2.95    0.30   [1, 2, 3, 4]
gpt-5.6-luna   diverse        5     2.32    2.32   2.32   2.22    0.00   [1, 3, 2]
gpt-5.6-luna   diverse       10     3.32    3.32   3.32   3.30    0.00   [1, 3, 2]
gpt-5.6-luna   diverse       20     4.32    4.32   3.48   3.92    0.84   [1, 1, 2]
gpt-5.6-luna   confusable     5     2.32    2.32   1.37   1.80    0.95   [1, 1, 2]
gpt-5.6-luna   confusable    10     3.32    3.32   3.32   3.16    0.00   [1, 3, 2]
gpt-5.6-luna   confusable    20     4.32    4.22   3.92   2.95    0.30   [1, 2, 3, 4]

Ceiling for a set of n equally likely candidates is log2 n bits — 2.32 at five, 4.32 at twenty — and a query reaches it only if it splits every candidate apart. Watch the two conditions separately. On the diverse sets a blind pick is already close to the oracle, which is why the note rebuilt the experiment: a task where guessing scores well cannot tell you whether the learner is choosing. On the confusable sets the gap between blind and oracle opens up, and that gap is the room the learner has to be measured in.

In the note's sweep luna matched the best available query in five of six cells and exceeded it in the sixth — which was possible there only because that sweep let the learner write any list while scoring the ceiling over a sample of 300, the very asymmetry the numbered domain here removes. The local models gave up between 0.05 and 0.6 bits, always in the same direction, and always more on the confusable sets: the harder two hypotheses are to tell apart, the less the query is designed to tell them apart.

One query is a single decision. The real protocol is a loop — propose, observe, discard the candidates that disagree, propose again — and it is scored in questions asked rather than in bits.

def sequential(model, size=20, seed=41, max_steps=6):
    """Query, observe, shrink, repeat. Compare against greedy and blind schedules."""
    rng = random.Random(seed)
    hyps = hypothesis_set(rng, size, "confusable")
    truth_label, truth_fn = hyps[rng.randrange(len(hyps))]

    def schedule(pick):
        live, steps = list(hyps), 0
        while len(live) > 1 and steps < max_steps:
            q = pick(live)
            if q is None:
                break
            live = consistent(live, [(q, list(truth_fn(list(q))))])
            steps += 1
        return steps, len(live)

    greedy = schedule(lambda live: max(QUERY_DOMAIN, key=lambda x: eig(live, x)))
    blind = schedule(lambda live: QUERY_DOMAIN[rng.randrange(len(QUERY_DOMAIN))])

    live, steps, history = list(hyps), 0, []
    while len(live) > 1 and steps < max_steps:
        prompt = query_prompt([l for l, _ in live])
        if history:
            prompt += "\n\nYou already observed:\n" + "\n".join(
                f"  {q} -> {y}" for q, y in history)
        q = parse_query_id(ask(model, prompt, 900).text)
        if q is None:
            break
        gain = eig(live, q)
        y = list(truth_fn(list(q)))
        live = consistent(live, [(q, y)])
        history.append((list(q), y))
        steps += 1
        if gain == 0.0 and steps >= 3:
            break            # it is asking uninformative questions; stop paying
    return {"truth": truth_label, "model": (steps, len(live)),
            "greedy": greedy, "blind": blind, "history": history}

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'questions asked':>16}{'greedy':>9}{'blind':>7}"
          f"{'left over':>11}")
    for m in MODELS:
        r = sequential(m)
        print(f"{m:<15}{r['model'][0]:>16}{r['greedy'][0]:>9}{r['blind'][0]:>7}"
              f"{r['model'][1]:>11}")
        for q, y in r["history"]:
            print(f"                 asked {str(q):<16} -> {y}")
model           questions asked   greedy  blind  left over
qwen3-8b                      1        1      4          1
                 asked [1, 2, 3, 4]     -> [4, 2, 3, 1]
qwen3-30b-a3b                 3        1      4          2
                 asked [9, 7, 8]        -> [8, 7, 9]
                 asked [9, 7, 8]        -> [8, 7, 9]
                 asked [9, 7, 8]        -> [8, 7, 9]
gpt-5.6-luna                  2        1      4          1
                 asked [1, 3, 2]        -> [2, 3, 1]
                 asked [1, 2, 3, 4]     -> [4, 2, 3, 1]

A learner that never returns a usable query answers zero questions and leaves the whole set standing — that is the row to check first, because it is a parsing failure far more often than a reasoning one.

Twenty candidates hold 4.32 bits of uncertainty. Restricted to perfectly balanced yes/no questions, five would be enough in principle — but these queries are not binary: one output can fall into twenty different groups, so a single well-chosen query can in principle finish the job. That is what makes the count interesting rather than arithmetic. The note's sweep had luna matching the greedy schedule exactly, one question every time, while Qwen3-8B needed 2.25 on average and failed to identify the rule at all in four cases out of twelve.

This is the one place in the whole note where the frontier model does something a small model cannot approximate. Naming a rule is a guess a verifier can check; choosing the experiment that will make the next guess cheap is not.

Where composition breaks

Every task so far has hidden a single primitive or a short chain. Lengthen the chain and the space grows as 15^d, but so does something else: the number of intermediate results you have to hold in your head to explain the last one.

def depth_trial(model, depth, n=5):
    """Hide a random chain of exactly `depth` primitives; count exact recoveries."""
    ok = 0
    for i in range(n):
        seed = 4000 + 100 * depth + i
        prog = random_program(random.Random(seed), depth)
        t = make_task(seed, prog, n_demos=4, n_tests=1)
        obj = extract_json(ask(
            model, identification_prompt(t["demos"], t["tests"][0][0]), 1500).text) or {}
        fn, _ = compile_answer(obj)
        ok += bool(fn is not None and extensionally_equal(fn, t["fn"]))
    return ok / n

if not MODELS:
    print("no learner available — skipping")
else:
    depths = (1, 2, 3, 4)
    print(f"{'model':<15}" + "".join(f"{'depth ' + str(d):>10}" for d in depths))
    for m in MODELS:
        print(f"{m:<15}" + "".join(f"{depth_trial(m, d):>10.2f}" for d in depths))
model             depth 1   depth 2   depth 3   depth 4
qwen3-8b             0.80      0.00      0.20      0.00
qwen3-30b-a3b        0.80      0.00      0.40      0.00
gpt-5.6-luna         1.00      1.00      1.00      0.80

Five tasks per cell is enough to see a cliff and not enough to see a slope, so read the column order loosely. The note's sweep, fifteen tasks per cell, has the local models falling off a cliff between depth 1 and depth 2 — Qwen3-30B goes 0.87, 0.47, 0.33, 0.27, 0.07 — while luna decays gently from 1.00 to 0.80 at depth 5. A gentle decay is what compositional generalization should look like. A cliff at the very first composition is consistent with a learner that handles familiar primitives and does not reliably combine them — consistent with, not proof of; this measurement cannot separate that from several other explanations.

There is a second question hiding here, and it is the one that produced the worst measurement bug in the project: does a then b behave differently from b then a? The same two operations, the same difficulty, only the order reversed.

PAIRS = [("reverse", "increment"), ("sort_asc", "rotate_left"), ("double", "keep_last")]

def order_trial(model, a, b, n=2):
    """Accuracy on (a then b) and on (b then a), same pair, same seeds."""
    out = {}
    for tag, prog in (("ab", (a, b)), ("ba", (b, a))):
        ok = 0
        for i in range(n):
            seed = zlib.crc32(f"{a}|{b}|{tag}|{i}".encode()) % 10_000
            t = make_task(seed, prog, n_demos=4, n_tests=1)
            obj = extract_json(ask(
                model, identification_prompt(t["demos"], t["tests"][0][0]), 1500).text) or {}
            fn, _ = compile_answer(obj)
            ok += bool(fn is not None and extensionally_equal(fn, t["fn"]))
        out[tag] = ok / n
    return out

if not MODELS:
    print("no learner available — skipping")
else:
    ORDER = {}
    for m in MODELS:
        ORDER[m] = [order_trial(m, a, b) for a, b in PAIRS]
        for (a, b), r in zip(PAIRS, ORDER[m]):
            print(f"  {m:<15}{a} -> {b:<14} {r['ab']:.2f}    reversed {r['ba']:.2f}")
        gaps = [abs(r["ab"] - r["ba"]) for r in ORDER[m]]
        print(f"  {m:<15}mean |gap| over {len(PAIRS)} pairs: {statistics.fmean(gaps):.3f}\n")
  qwen3-8b       reverse -> increment      0.00    reversed 0.50
  qwen3-8b       sort_asc -> rotate_left    0.00    reversed 0.00
  qwen3-8b       double -> keep_last      0.00    reversed 0.00
  qwen3-8b       mean |gap| over 3 pairs: 0.167

  qwen3-30b-a3b  reverse -> increment      0.00    reversed 0.00
  qwen3-30b-a3b  sort_asc -> rotate_left    0.00    reversed 1.00
  qwen3-30b-a3b  double -> keep_last      0.00    reversed 0.00
  qwen3-30b-a3b  mean |gap| over 3 pairs: 0.333

  gpt-5.6-luna   reverse -> increment      1.00    reversed 1.00
  gpt-5.6-luna   sort_asc -> rotate_left    1.00    reversed 1.00
  gpt-5.6-luna   double -> keep_last      1.00    reversed 1.00
  gpt-5.6-luna   mean |gap| over 3 pairs: 0.000

Two seeds per direction is far too few to conclude anything. A gap that large will appear whether or not there is an order effect, and deciding which one you are looking at requires a null model — which is where I got it wrong, twice, in a way the next section reproduces from scratch. Come back here after Trap 4 and the last cell of this lab closes the loop on these numbers.

Knowing a rule and being able to run it

Every task so far tested the rule at roughly the length it was demonstrated on. Pull those apart: demonstrate at length 3 to 6, then ask for the answer at 8, 16, 32, 64 and 128. Only length-polymorphic primitives are used — a permutation operator is defined for one arity and would make the question meaningless.

The reply is asked for three things at once and they are scored separately, because they are three different abilities.

LENGTH_RULES = ("reverse", "every_second", "rotate_left", "duplicate", "pairwise_swap")
TEST_LENGTHS = (8, 16, 32, 64, 128)

def length_prompt(demos, query):
    shown = "\n".join(f"  {list(a)} -> {list(b)}" for a, b in demos)
    return (f"A hidden function phi maps a list of integers to a list of integers. "
            f"The same rule applies at every length.\nObserved:\n{shown}\n\n"
            f"Apply phi to this list of {len(query)} integers:\n  {list(query)}\n\n"
            f"phi is a chain of one or more of these primitives, applied left to right:\n"
            f"{menu()}\n\n"
            'Answer with one JSON object and nothing else:\n'
            '{"rule": "<one sentence>", "program": [<primitive names>], '
            '"output": [<the full transformed list>]}')

def length_case(rule, seed):
    """One task, reused at every length. The demonstrations are fixed and the
    query at length L is the first L elements of one long sequence, so moving
    down the column changes the length and nothing else. Drawing a fresh task
    per length would confound the thing being measured with task difficulty."""
    rng = random.Random(seed)
    fn = OPS[rule]
    demos = [(x, fn(x)) for x in
             [[rng.randint(1, 9) for _ in range(L)] for L in (3, 4, 5, 6)]]
    long_query = [rng.randint(1, 9) for _ in range(max(TEST_LENGTHS))]
    return fn, demos, long_query

def length_trial(model, rule, length, seed):
    """Three scores from one reply: the rule, the written-out list, and the
    model's own program executed by Python at that length."""
    fn, demos, long_query = length_case(rule, seed)
    query = long_query[:length]
    truth = fn(query)
    obj = extract_json(ask(model, length_prompt(demos, query), 3000).text) or {}
    got, _ = compile_answer(obj)
    written = obj.get("output")
    executed = None
    if got is not None:
        try:
            executed = list(got(list(query))) == list(truth)
        except Exception:
            executed = False
    return {
        "rule_correct": got is not None and extensionally_equal(got, fn),
        "written_correct": isinstance(written, list) and list(written) == list(truth),
        "program_at_length": executed,
    }

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'length':>8}{'rule':>8}{'wrote it out':>14}{'its program run':>17}")
    for m in MODELS:
        for L in TEST_LENGTHS:
            rows = [length_trial(m, r, L, zlib.crc32(f"e6:{r}".encode()) % 10_000)
                    for r in LENGTH_RULES]
            n = len(rows)
            ran = [r for r in rows if r["program_at_length"] is not None]
            print(f"{m:<15}{L:>8}"
                  f"{sum(r['rule_correct'] for r in rows) / n:>8.2f}"
                  f"{sum(r['written_correct'] for r in rows) / n:>14.2f}"
                  + (f"{sum(bool(r['program_at_length']) for r in ran) / len(ran):>17.2f}"
                     if ran else f"{'-':>17}"))
model            length    rule  wrote it out  its program run
qwen3-8b              8    0.20          0.40             0.20
qwen3-8b             16    0.20          0.20             0.20
qwen3-8b             32    0.40          0.40             0.40
qwen3-8b             64    0.40          0.40             0.40
qwen3-8b            128    0.40          0.20             0.50
qwen3-30b-a3b         8    0.40          0.40             0.40
qwen3-30b-a3b        16    0.40          0.40             0.40
qwen3-30b-a3b        32    0.40          0.40             0.40
qwen3-30b-a3b        64    0.60          0.40             0.75
qwen3-30b-a3b       128    0.40          0.20             1.00
gpt-5.6-luna          8    1.00          1.00             1.00
gpt-5.6-luna         16    1.00          1.00             1.00
gpt-5.6-luna         32    1.00          1.00             1.00
gpt-5.6-luna         64    1.00          0.80             1.00
gpt-5.6-luna        128    1.00          0.80             1.00

Three columns that should agree and do not. The first stays flat: the rule is a short sentence and length has nothing to do with it. The second falls off — writing 128 integers by hand is a transcription task, and it fails the way transcription fails, one slipped element at a time. The third recovers everything the second lost, because it is the model's own answer, run by Python instead of by the model.

The note's sweep makes the split sharper than five tasks per cell can: identification holds near its short-length level all the way to 128 while the written-out list collapses, and executing the stated program restores it. Whatever is failing at length 128 is not the inference. The fix for that failure is an interpreter, not more examples — which is the whole argument for giving a model tools rather than more demonstrations, arrived at from a measurement instead of from a slogan.

Everything so far was constructive. What follows is the opposite: you will reproduce, on purpose, four measurement bugs that each produced a convincing and completely false result in this project. They are ordered by how long they took me to notice.

Trap 1 — the token budget that looks like a capability gap

Ask the same question twice, changing nothing but the ceiling on the reply.

if not MODELS:
    print("no learner available — skipping")
else:
    t = make_task(7, ("rotate_left",))
    prompt = identification_prompt(t["demos"], t["tests"][0][0])
    for budget in (24, 900):
        r = ask(MODELS[0], prompt, max_tokens=budget)
        ok = compile_answer(extract_json(r.text) or {})[0] is not None
        shown = r.text.strip().replace("\n", " ")[:60] or "(empty)"
        print(f"  max_tokens={budget:<4} parsed={str(ok):<5} out={r.completion_tokens:<4} {shown}")
  max_tokens=24   parsed=False out=24   {"rule": "The function phi rotates the list left by one posi
  max_tokens=900  parsed=True  out=55   {"rule": "The function phi rotates the list left by one posi

A cramped budget produces something unusable — and with a reasoning model it produces an empty string, because the whole allowance went on hidden thinking. Score that as a wrong answer and you have manufactured a capability gap out of a configuration constant. It cost me two false findings before the rule became: a truncated reply is missing data, never a wrong answer. The harness now retries at 4x and 12x and marks anything still empty as excluded rather than failed.

Trap 2 — the seed that gives every model a different exam

prog = "import sys; print([hash(('reverse', s)) % 1000 for s in range(3)])"
runs = [subprocess.run([sys.executable, "-c", prog], capture_output=True, text=True).stdout.strip()
        for _ in range(3)]
for r in runs:
    print("  ", r)
print("\nsame expression, three processes:", "IDENTICAL" if len(set(runs)) == 1 else "ALL DIFFERENT")
print("crc32 instead:", [zlib.crc32(f"reverse:{s}".encode()) % 1000 for s in range(3)])
   [498, 179, 429]
   [263, 688, 194]
   [62, 487, 912]

same expression, three processes: ALL DIFFERENT
crc32 instead: [649, 143, 669]

Python salts string hashing per process. Seeding tasks with hash() hands every model a different task set — same distribution, so the comparison is not wrong, but it is not reproducible and the differences are partly noise.

Trap 3 — the convention nobody stated

My permutation prompt said "output position i takes input position p[i]" and never said whether positions start at 0. Ask both ways and count how many replies come back 1-indexed.

# `permutation_prompt` is the one defined above; the `explicit` flag is the
# whole experiment — it is the only thing that changes between the two rows.
def permutation_trial(model, explicit, seeds=6, arity=5):
    one_indexed = valid = 0
    for i in range(seeds):
        t = permutation_task(100 + i, n=arity, n_demos=3, alphabet=3)
        prompt = permutation_prompt(t["demos"], t["query"], arity, explicit)
        obj = extract_json(ask(model, prompt).text) or {}
        perm = obj.get("permutation")
        if isinstance(perm, list) and len(perm) == arity:
            if sorted(perm) == list(range(arity)):
                valid += 1
            elif sorted(perm) == list(range(1, arity + 1)):
                one_indexed += 1
    return valid, one_indexed

if not MODELS:
    print("no learner available — skipping")
else:
    for m in MODELS:
        for explicit in (False, True):
            v, o = permutation_trial(m, explicit)
            label = "0-based stated explicitly" if explicit else "convention left implicit"
            print(f"  {m:<14} {label:<28} valid: {v}   1-indexed: {o}")
  qwen3-8b       convention left implicit     valid: 3   1-indexed: 3
  qwen3-8b       0-based stated explicitly    valid: 6   1-indexed: 0
  qwen3-30b-a3b  convention left implicit     valid: 5   1-indexed: 1
  qwen3-30b-a3b  0-based stated explicitly    valid: 6   1-indexed: 0
  gpt-5.6-luna   convention left implicit     valid: 0   1-indexed: 6
  gpt-5.6-luna   0-based stated explicitly    valid: 6   1-indexed: 0

Every 1-indexed reply was scored as an invalid permutation, which read as the model being unable to do the task. It was the prompt. One sentence and one worked example moved my measured accuracy from 0.70 to 0.78 without touching the model.

Trap 4 — the null that agrees with you

The most dangerous one, because it is invisible in the data. The question was whether a then b and b then a differ by more than chance. Build a world where you know the answer: fourteen pairs of genuinely different difficulty, and no order effect at all baked in. Then see which null tells you the truth.

rng = random.Random(3)
true_rates = [rng.uniform(0.05, 0.75) for _ in range(14)]   # pairs differ in difficulty
CELL = 6                                                     # seeds per direction

def sample_gap(rates):
    gaps = []
    for p in rates:
        ab = sum(rng.random() < p for _ in range(CELL)) / CELL
        ba = sum(rng.random() < p for _ in range(CELL)) / CELL
        gaps.append(abs(ab - ba))
    return statistics.fmean(gaps)

observed = sample_gap(true_rates)          # a world with NO order effect
overall = statistics.fmean(true_rates)

def null_band(rates, trials=4000):
    r = random.Random(0)
    out = []
    for _ in range(trials):
        gaps = []
        for p in rates:
            ab = sum(r.random() < p for _ in range(CELL)) / CELL
            ba = sum(r.random() < p for _ in range(CELL)) / CELL
            gaps.append(abs(ab - ba))
        out.append(statistics.fmean(gaps))
    out.sort()
    return statistics.fmean(out), out[int(0.05 * len(out))], out[int(0.95 * len(out))]

print(f"observed mean gap in a world with no order effect: {observed:.3f}\n")
for label, rates in (("one global rate", [overall] * len(true_rates)),
                     ("conditioned on the pair", true_rates)):
    mean, lo, hi = null_band(rates)
    verdict = "outside the null" if not (lo <= observed <= hi) else "inside the null"
    print(f"  {label:<26} null {mean:.3f}  [{lo:.3f}, {hi:.3f}]  -> {verdict}")
observed mean gap in a world with no order effect: 0.179

  one global rate            null 0.219  [0.143, 0.298]  -> inside the null
  conditioned on the pair    null 0.194  [0.119, 0.262]  -> inside the null

The global-rate null is far too wide, because it pretends every pair is equally hard. Data generated with no order effect lands suspiciously low inside it — which is exactly the shape I read, in my own results, as evidence that the models compose. Conditioning the null on the pair removes that illusion. On the real data the corrected null flipped the answer: the 30B's observed gap went from "below chance" to above the 95th percentile.

A null model is an assumption, and it is the assumption least likely to be examined — because when it agrees with you, you stop looking.

Now close the loop: take the real a -> b versus b -> a measurements from earlier and run them through the corrected null instead of the synthetic one.

def conditioned_null(pairs, cell, trials=4000, seed=0):
    """Null gap distribution with each pair's difficulty held fixed.

    The pooled rate for a pair is estimated from both directions together, so
    the simulated world has that pair's difficulty and no order effect at all.
    Everything the resampling produces is therefore noise by construction.
    """
    r = random.Random(seed)
    rates = [(p["ab"] + p["ba"]) / 2 for p in pairs]
    out = []
    for _ in range(trials):
        gaps = []
        for q in rates:
            ab = sum(r.random() < q for _ in range(cell)) / cell
            ba = sum(r.random() < q for _ in range(cell)) / cell
            gaps.append(abs(ab - ba))
        out.append(statistics.fmean(gaps))
    out.sort()
    return statistics.fmean(out), out[int(0.95 * len(out))]

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'observed gap':>14}{'null mean':>11}{'null 95th':>11}   verdict")
    for m in MODELS:
        obs = statistics.fmean(abs(p["ab"] - p["ba"]) for p in ORDER[m])
        mean, p95 = conditioned_null(ORDER[m], cell=2)
        verdict = "exceeds the null" if obs > p95 else "inside the null"
        print(f"{m:<15}{obs:>14.3f}{mean:>11.3f}{p95:>11.3f}   {verdict}")
model            observed gap  null mean  null 95th   verdict
qwen3-8b                0.167      0.103      0.333   inside the null
qwen3-30b-a3b           0.333      0.124      0.333   inside the null
gpt-5.6-luna            0.000      0.000      0.000   inside the null

Read that verdict with the sample size in front of you: three pairs at two seeds a direction is a coin with almost no information in it, and the null is correspondingly wide. It is here so the machinery is complete and runnable, not so the answer is trustworthy at this scale. The note's version is fourteen pairs at six seeds a direction, and there the same procedure separates the models: Qwen3-30B's observed gap of 0.179 clears the 95th percentile of 0.143, while the 8B stays inside. Under the global-rate null — the one this section just showed to be wrong — both looked below chance.

Ordering a search you could have done exhaustively

Enumerating the space took a second, so exact search is not the bottleneck. The question is not whether a model can replace search — it is whether it can order it. Measure the ordering in the same shape as everything else: how much of the search it removes.

def enumerate_programs(max_depth, ops=OPS):
    """Shortest program per behaviour signature, bottom-up, level by level."""
    names, seen, frontier, out = list(ops), {}, [()], []
    for _ in range(max_depth):
        nxt = []
        for prefix in frontier:
            for name in names:
                cand = prefix + (name,)
                sig = signature(as_callable(cand, ops))
                if sig is None or sig in seen:
                    continue
                seen[sig] = cand
                out.append(cand); nxt.append(cand)
        frontier = nxt
    return out

SPACE = [(pr, as_callable(pr)) for pr in enumerate_programs(3)]

def first_hit(order, demos):
    for i, idx in enumerate(order, 1):
        prog, fn = SPACE[idx]
        try:
            if all(list(fn(list(x))) == list(y) for x, y in demos):
                return i
        except Exception:
            pass
    return None

r = random.Random(5)
prog = random_program(r, 3)
t = make_task(5, prog, n_demos=4, n_tests=1)
fixed = list(range(len(SPACE)))
shuffled = fixed[:]; random.Random(1).shuffle(shuffled)

nb, nr = first_hit(fixed, t['demos']), first_hit(shuffled, t['demos'])
print(f"hidden program: {' -> '.join(prog)}")
print(f"  shortest-first order : {nb:>4} candidates evaluated")
print(f"  shuffled order       : {nr:>4}")
print(f"  the fixed order was worth log2({nr}/{nb}) = {math.log2(nr/nb):+.2f} bits, "
      f"before any model")
hidden program: sort_asc -> every_second -> keep_last
  shortest-first order :  305 candidates evaluated
  shuffled order       :  169
  the fixed order was worth log2(169/305) = -0.85 bits, before any model

That is the baseline to beat, and it is not zero: enumerating shortest-first is already a description-length prior. Now the recorded model runs.

Ask the learner to rank the primitives, order the space by its ranking, and search.

def parse_ranking(text):
    """Pull the model's ordering over primitives out of its reply."""
    obj = extract_json(text)
    if not isinstance(obj, dict):
        return None
    names = [str(v) for v in (obj.get("ranking") or []) if str(v) in OPS]
    return names or None

def order_by_ranking(names):
    """Best-first: cost is the mean rank of a program's primitives, so a chain of
    well-rated operations is tried early. Unlisted primitives sink to the bottom."""
    score = {n: -1.0 for n in OPS}
    for i, n in enumerate(names):
        score[n] = float(len(names) - i)
    def cost(i):
        prog = SPACE[i][0]
        return (-sum(score.get(n, 0.0) for n in prog) / len(prog), len(prog))
    return sorted(range(len(SPACE)), key=cost)

def demo_features(demos):
    """Cheap, model-free descriptors of what the transformation did."""
    ratios = [len(y) / max(len(x), 1) for x, y in demos]
    return {"ratio": statistics.fmean(ratios),
            "multiset_preserved": all(sorted(x) == sorted(y) for x, y in demos),
            "values_changed": all(any(v not in x for v in y) for x, y in demos),
            "sorted_asc": all(y == sorted(y) for _, y in demos),
            "sorted_desc": all(y == sorted(y, reverse=True) for _, y in demos),
            "singleton": all(len(y) == 1 for _, y in demos)}

def heuristic_scores(demos):
    """A hand-written primitive plausibility rule, thirty lines and no model.

    This is the baseline that decides what the model's ranking is worth. Beating
    brute force is easy; beating a morning's worth of hand-written rules is the
    claim actually being tested.
    """
    f = demo_features(demos)
    s = {n: 0.0 for n in OPS}
    s["duplicate"] += 3 if f["ratio"] > 1.5 else -3
    for n in ("every_second", "keep_last", "keep_max"):
        s[n] += 2 if f["ratio"] < 0.75 else -2
    if f["singleton"]:
        for n in ("keep_last", "keep_max"):
            s[n] += 3
    if f["multiset_preserved"]:
        for n in ("increment", "decrement", "double"):
            s[n] -= 4
        for n in ("reverse", "rotate_left", "rotate_right", "swap_ends",
                  "pairwise_swap", "sort_asc", "sort_desc"):
            s[n] += 1
    if f["values_changed"]:
        for n in ("increment", "decrement", "double"):
            s[n] += 3
    if f["sorted_asc"]:
        s["sort_asc"] += 2
    if f["sorted_desc"]:
        s["sort_desc"] += 2
    return s

def order_by_scores(scores):
    def cost(i):
        prog = SPACE[i][0]
        return (-sum(scores.get(n, 0.0) for n in prog) / len(prog), len(prog))
    return sorted(range(len(SPACE)), key=cost)

def guided_search(model, n_tasks=8):
    """Four solvers on the same tasks: brute, blind shuffle, hand heuristic, model."""
    fixed = list(range(len(SPACE)))
    got = {k: [] for k in ("brute", "random", "heuristic", "model")}
    bits = {k: [] for k in ("random", "heuristic", "model")}
    for i in range(n_tasks):
        r = random.Random(900 + i)
        prog = random_program(r, 3)
        t = make_task(900 + i, prog, n_demos=4, n_tests=1)
        shown = "\n".join(f"  {list(a)} -> {list(b)}" for a, b in t["demos"])
        reply = ask(model,
                    f"A hidden program is a chain of primitives applied left to right.\n"
                    f"Observed:\n{shown}\n\nAvailable primitives:\n{menu()}\n\n"
                    "Rank the primitives by how likely each is to appear. This ranking "
                    "will order an exhaustive search.\n\n"
                    'Answer with one JSON object: {"ranking": [<names, most likely first>]}',
                    max_tokens=1500)
        ranking = parse_ranking(reply.text)

        shuffled = list(range(len(SPACE)))
        random.Random(1000 + i).shuffle(shuffled)
        orders = {"brute": fixed, "random": shuffled,
                  "heuristic": order_by_scores(heuristic_scores(t["demos"])),
                  "model": order_by_ranking(ranking) if ranking else None}
        hits = {k: (first_hit(o, t["demos"]) if o is not None else None)
                for k, o in orders.items()}
        if not hits["brute"] or any(v is None for v in hits.values()):
            continue
        for k, v in hits.items():
            got[k].append(v)
        for k in bits:
            bits[k].append(math.log2(hits["brute"] / hits[k]))
    return got, bits

if not MODELS:
    print("no learner available — skipping")
else:
    print(f"{'model':<15}{'brute':>8}{'shuffled':>10}{'heuristic':>11}{'model':>8}"
          f"     guidance vs brute (bits)")
    for m in MODELS:
        got, bits = guided_search(m)
        if got["brute"]:
            med = lambda k: statistics.median(got[k])
            b = lambda k: statistics.fmean(bits[k])
            print(f"{m:<15}{med('brute'):>8.0f}{med('random'):>10.0f}"
                  f"{med('heuristic'):>11.0f}{med('model'):>8.0f}"
                  f"     shuffle {b('random'):+.2f}   hand {b('heuristic'):+.2f}"
                  f"   model {b('model'):+.2f}")
model             brute  shuffled  heuristic   model     guidance vs brute (bits)
qwen3-8b            113       243         16      30     shuffle -1.08   hand +3.13   model +2.02
qwen3-30b-a3b       113       243         16     185     shuffle -1.08   hand +3.13   model -0.25
gpt-5.6-luna        113       243         16       4     shuffle -1.08   hand +3.13   model +4.74

The column that matters is not model against brute — it is model against heuristic. Ordering by a shuffle costs bits, as it should. Ordering by thirty lines of hand-written rules about length ratios and multiset preservation is worth several, for free, forever.

That baseline is unkind to the small models at eight tasks: the hand rules beat them here, and one of them lands below brute force. The note's larger sweep is where the weak models draw level with the heuristic; what survives at any scale is the direction of the argument — a model whose stated rules contradict their own demonstrations two times in three is worthless as a hypothesis and still worth bits as an ordering, because a verifier makes being wrong cheap. It is useless as a hypothesis and worth about as much as a hand-written heuristic as an ordering, because a verifier makes being wrong cheap.

And where the verifier stops helping

That argument has a limit, and it is the last thing this world has to say. A consistency check catches a weak model because its errors contradict the evidence. What happens when the errors fit?

def _safe(fn, x):
    try:
        return list(fn(list(x)))
    except Exception:
        return None

def reachability(ablated, n_tasks=16):
    """Classify each task by exhaustive search *before* any model is asked.

    Using the removed primitive in the generating program does not make a rule
    inexpressible — another composition may compute exactly the same function.
    Grading a decline as correct without checking that would score the model on
    a label I guessed. So enumerate the reduced language and look.
    """
    ops = {n: f for n, f in OPS.items() if n != ablated}
    # Every raw chain, with no probe-signature pruning. The pruning is not
    # sound — measured near the top of this lab — and a classification that
    # calls a task impossible must not rest on it. Fourteen primitives to
    # depth 3 is under three thousand programs, so soundness is free here.
    space = [(c, as_callable(c, ops)) for d in (1, 2, 3)
             for c in itertools.product(list(ops), repeat=d)]
    out = []
    for i in range(n_tasks):
        r = random.Random(500 + i)
        # Random hidden programs over the FULL language, not programs forced to
        # contain the ablated primitive. Forcing it guarantees the answer and
        # turns the classification into a restatement of how the task was built.
        prog = random_program(r, r.choice([1, 2, 3]))
        t = make_task(500 + i, prog, n_demos=4, n_tests=1)
        fitting = [(pr, fn) for pr, fn in space
                   if all(_safe(fn, a) == list(b) for a, b in t["demos"])]
        exact = [pr for pr, fn in fitting if extensionally_equal(fn, t["fn"])]
        # A: the truth is expressible. B: something fits the demonstrations but
        # nothing computes the truth. C: nothing in the reduced language even
        # fits the demonstrations — the one class established exactly, since it
        # is a statement about a finite space and finitely many equations.
        cls = "A reachable" if exact else ("B fits but wrong" if fitting
                                           else "C no fitting program")
        out.append({"task": t, "ops": ops, "hidden": prog, "class": cls,
                    "uses_ablated": ablated in prog,
                    "reachable": len(exact) > 0, "n_fitting": len(fitting)})
    return out

def ablated_trial(model, ablated, n_tasks=16):
    """Offer the reduced language, allow a decline, and grade against the
    exhaustive verdict rather than against the shape of the hidden program."""
    cases = reachability(ablated, n_tasks)
    tally = Counter()
    for c in cases:
        t, ops = c["task"], c["ops"]
        prompt = identification_prompt(t["demos"], t["tests"][0][0], ops)
        prompt += ('\n\nIf no chain of the listed primitives can express the rule, answer '
                   '{"inexpressible": true} instead of guessing.')
        obj = extract_json(ask(model, prompt, max_tokens=1500).text) or {}
        tag = "reachable" if c["reachable"] else "unreachable"
        if obj.get("inexpressible"):
            tally[f"{tag}/declined"] += 1
            continue
        # graded against the language it was shown, not the full one
        fn, _ = compile_answer(obj, ops)
        fits = fn is not None and all(_safe(fn, a) == list(b) for a, b in t["demos"])
        if fits and fn is not None and extensionally_equal(fn, t["fn"]):
            tally[f"{tag}/correct"] += 1
        elif fits:
            tally[f"{tag}/fits but wrong"] += 1
        else:
            tally[f"{tag}/fails its own demos"] += 1
    return cases, tally

if not MODELS:
    print("no learner available — skipping")
else:
    for m in MODELS:
        for ablated in ("duplicate", "increment"):
            cases, tally = ablated_trial(m, ablated)
            by_class = Counter(c["class"] for c in cases)
            used = sum(1 for c in cases if c["uses_ablated"])
            still = sum(1 for c in cases if c["uses_ablated"] and c["reachable"])
            print(f"  {m} without `{ablated}`: " +
                  ", ".join(f"{by_class[k]} {k}" for k in sorted(by_class)) +
                  f"   ({used} tasks were generated using it, {still} survive anyway)")
            for k in sorted(tally):
                print(f"      {k:<34}{tally[k]}")
        print()
  qwen3-8b without `duplicate`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 3
      reachable/fails its own demos     11
      unreachable/fails its own demos   2
  qwen3-8b without `increment`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 5
      reachable/fails its own demos     9
      unreachable/fails its own demos   2

  qwen3-30b-a3b without `duplicate`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 8
      reachable/fails its own demos     6
      unreachable/fails its own demos   2
  qwen3-30b-a3b without `increment`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 9
      reachable/fails its own demos     5
      unreachable/fails its own demos   2

  gpt-5.6-luna without `duplicate`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 13
      reachable/fails its own demos     1
      unreachable/declined              1
      unreachable/fails its own demos   1
  gpt-5.6-luna without `increment`: 14 A reachable, 2 C no fitting program   (2 tasks were generated using it, 0 survive anyway)
      reachable/correct                 13
      reachable/fails its own demos     1
      unreachable/declined              2

Read the class labels first, because they are computed before any model is asked. Class C is the honest one: no chain in the reduced language fits the demonstrations at all, and that is settled exactly — a finite space, finitely many equations, no probe set involved. Only in class C is declining the right answer, and only there does a confident reply have to be a confabulation.

The local models never claim inexpressibility and never confabulate convincingly — their wrong answers fail the demonstrations, so the check disposes of them. The frontier model declines correctly a third of the time, never wrongly, and otherwise returns a program that reproduces every demonstration and is wrong everywhere else.

So the share of failures a consistency check can catch falls as the proposer improves. The verifier keeps its guarantee; what shrinks is how much of the failure surface that guarantee covers.

One last measurement: how much evidence the check needs

def decoys_per_task(n_demos, ablated=None, n_tasks=24, depth=3):
    """A decoy fits every demonstration and is not the hidden function.
    Purely a property of the language and the evidence — no model involved."""
    ops = OPS if ablated is None else {n: f for n, f in OPS.items() if n != ablated}
    space = [(pr, as_callable(pr, ops)) for pr in enumerate_programs(depth, ops)]
    counts = []
    for i in range(n_tasks):
        r = random.Random(7000 + i)
        prog = random_program(r, r.choice([1, 2, 3]))
        t = make_task(7000 + i, prog, n_demos=n_demos, n_tests=1)
        fits = [fn for _, fn in space
                if all(_safe(fn, a) == list(b) for a, b in t["demos"])]
        exact = [fn for fn in fits if extensionally_equal(fn, t["fn"])]
        counts.append(len(fits) - len(exact))
    return statistics.fmean(counts)

print(f"{'demos':>6}{'full language':>16}{'keep_last removed':>20}")
for nd in (1, 2, 3, 4, 6):
    print(f"{nd:>6}{decoys_per_task(nd):>16.1f}{decoys_per_task(nd, 'keep_last'):>20.1f}")
 demos   full language   keep_last removed
     1            20.1                19.3
     2             3.8                 3.6
     3             0.5                 0.4
     4             0.1                 0.1
     6             0.0                 0.0

About a fourfold collapse per demonstration — roughly two bits each, which is the version-space arithmetic from earlier arriving by a different route. "Fits the data and is wrong everywhere else" is not, in this setup, a symptom of an impoverished language. It is what too few bits looks like, and it is the condition under which the verifier means anything at all.

Letting the solver grow its own language

Everything above searched a language someone else fixed. The last question is whether a solver can extend it — and, more importantly, whether extending it helps or hurts, because every new primitive it promotes also widens the branching factor of every later search.

No model is involved here at all. This is the symbolic half, and it is fully deterministic: the same seeds give the same numbers on any machine.

BASE = {n: f for n, f in OPS.items() if n != "identity"}
MOTIFS = [("reverse", "duplicate"), ("sort_asc", "every_second"),
          ("rotate_left", "increment"), ("swap_ends", "double")]

def expand(chain, lib):
    """A chain of library names, flattened back to base primitives."""
    out = []
    for name in chain:
        out.extend(lib.get(name, (name,)))
    return tuple(out)

def build_stream(n_tasks, seed=7):
    """Tasks that share recurring substructure without ever repeating.

    Each task is a motif wrapped in one arbitrary primitive, so the motifs
    genuinely recur — otherwise there is nothing for a library learner to find,
    and the experiment would be rigged in the opposite direction.
    """
    rng = random.Random(seed)
    tasks = []
    for i in range(n_tasks):
        motif = MOTIFS[i % len(MOTIFS)]
        wrap = rng.choice(list(BASE))
        chain = (wrap,) + motif if rng.random() < 0.5 else motif + (wrap,)
        fn = lambda x, c=chain: run(c, list(x), BASE)
        xs = [[rng.randint(1, 9) for _ in range(rng.choice([3, 4, 5]))] for _ in range(4)]
        tasks.append({"target": chain, "demos": [(x, fn(x)) for x in xs]})
    return tasks

def solve(demos, lib, max_depth=3):
    """Bottom-up search over base primitives plus the current library.

    A library entry costs depth 1 no matter how long it expands to — that
    discount is the entire benefit an abstraction is supposed to buy.
    """
    names = list(BASE) + list(lib)
    seen, frontier, nodes = set(), [()], 0
    for _ in range(max_depth):
        nxt = []
        for prefix in frontier:
            for name in names:
                cand = prefix + (name,)
                flat = expand(cand, lib)
                if len(flat) > 9:
                    continue
                fn = lambda x, f=flat: run(f, list(x), BASE)
                sig = signature(fn)
                if sig is None or sig in seen:
                    continue
                seen.add(sig)
                nodes += 1
                if all(_safe(fn, x) == list(y) for x, y in demos):
                    return nodes, cand
                nxt.append(cand)
        frontier = nxt
    return nodes, None

def mine(solutions, lib):
    """Best fragment by description-length saving: (uses - 1) * (len - 1).

    Not by frequency. A two-step fragment used twice saves one step and costs a
    dictionary entry; the objective has to say so. This is the DreamCoder
    library-learning criterion in miniature.
    """
    counts = Counter()
    for chain in solutions:
        flat = expand(chain, lib)
        for size in (2, 3):
            for i in range(len(flat) - size + 1):
                counts[flat[i:i + size]] += 1
    known = {tuple(v) for v in lib.values()}
    best, best_score = None, 0
    for frag, uses in counts.items():
        if frag in known:                 # already promoted; do not rediscover it
            continue
        score = (uses - 1) * (len(frag) - 1)
        if score > best_score:
            best, best_score = frag, score
    return best, best_score

Two solvers, the same tasks in the same order. One keeps the fixed language; the other mines its own solutions after every round and promotes the best fragment it finds.

def campaign(tasks, grow, rounds=5):
    lib, solved_chains, log = {}, [], []
    batch = max(1, len(tasks) // rounds)
    for r in range(rounds):
        chunk = tasks[r * batch:(r + 1) * batch]
        nodes, lengths, solved, exact = [], [], 0, 0
        for t in chunk:
            n, chain = solve(t["demos"], lib)
            nodes.append(n)
            if chain is not None:
                solved += 1
                lengths.append(len(chain))
                solved_chains.append(chain)
                # Search returns the *shortest* program that fits, which need not
                # be the one the task was built from. Mining only ever sees what
                # search returns, so this fraction bounds what it can discover.
                exact += (expand(chain, lib) == t["target"])
        log.append({"round": r, "solved": solved,
                    "median_nodes": statistics.median(nodes) if nodes else None,
                    "program_length": round(statistics.fmean(lengths), 2) if lengths else None,
                    "found_target": round(exact / max(solved, 1), 2),
                    "branching": len(BASE) + len(lib), "library": list(lib)})
        if grow and solved_chains:
            frag, score = mine(solved_chains, lib)
            if frag and score >= 2:
                lib[f"P{len(lib) + 1}"] = frag
    return log, lib

tasks = build_stream(60)
fixed_log, _ = campaign(tasks, grow=False)
grow_log, lib = campaign(tasks, grow=True)

print(f"{'round':>6}{'fixed nodes':>13}{'growing nodes':>15}{'speedup':>9}"
      f"{'prog len':>10}{'found target':>14}{'branching':>11}")
for a, b in zip(fixed_log, grow_log):
    sp = a["median_nodes"] / b["median_nodes"] if b["median_nodes"] else float("nan")
    print(f"{a['round']:>6}{a['median_nodes']:>13.0f}{b['median_nodes']:>15.0f}"
          f"{sp:>8.2f}x{b['program_length']:>10.2f}{b['found_target']:>14.2f}"
          f"{b['branching']:>11}")
def recovered(motif, lib):
    """Compare behaviour, not spelling. `double -> swap_ends` computes exactly the
    same function as `swap_ends -> double`, because one acts on values and the
    other on positions, so an equality test on tuples would report a motif as
    missed when the solver had in fact found it."""
    want = signature(lambda x: run(motif, list(x), BASE))
    return any(signature(lambda x, f=tuple(v): run(f, list(x), BASE)) == want
               for v in lib.values())

hits = sum(recovered(m, lib) for m in MOTIFS)
print(f"\nplanted motifs recovered: {hits} / {len(MOTIFS)}")
print("learned:", {k: " -> ".join(v) for k, v in lib.items()} or "nothing promoted")
 round  fixed nodes  growing nodes  speedup  prog len  found target  branching
     0          126            126    1.00x      2.50          0.25         14
     1           98             98    1.00x      2.08          0.08         14
     2          214            214    1.00x      2.75          0.42         14
     3           98             70    1.40x      1.83          0.00         15
     4          135             86    1.58x      2.08          0.33         16

planted motifs recovered: 3 / 4
learned: {'P1': 'reverse -> duplicate', 'P2': 'sort_asc -> every_second', 'P3': 'double -> swap_ends'}

The library arrives late, and the found target column is why. Search returns the shortest program consistent with the demonstrations, which is often not the chain the task was generated from — an equally short alternative computes the same function. Mining only ever sees what search returned, so a motif is discoverable only in the tasks where search happened to recover it. That is not a flaw in the setup; it is the honest version of "the solver learns from its own solutions", and it is why the promotion threshold has to be a description-length saving rather than a count.

Three things move at once and only one of them is the headline. Node counts fall, because a motif that used to cost two levels of search now costs one. Program length falls with them, which is the same fact stated in the language of description length. And the branching factor climbs — every promotion is a new primitive that every future search must also try.

The recovery count compares behaviour rather than spelling, and it has to: the solver routinely finds double -> swap_ends where the motif was planted as swap_ends -> double. One acts on values and the other on positions, so they commute and compute the same function — a solver that recovered the structure would look like it had missed it under an equality test on names. The note's larger run, eighty tasks over eight rounds, recovers all four this way.

That third column is why the mining objective is (uses - 1) * (len - 1) and not a frequency count. A library that promotes eagerly makes the space wider faster than it makes the programs shorter, and the solver ends up slower than the one that never learned anything. Growth is not free; it is a bet that the structure you found recurs.

Where to take it

The pieces are small enough to bend. A few directions that would answer something the note could not:

  • Make the probe set adaptive. The collision check measured a 0.45% in-distribution rate. Add the demonstrations themselves to the probe set before pruning and the rate should go to zero for that task — cheap to try, and it makes the pruning sound where it matters.
  • Put noise in the demonstrations. Everything here assumes noiseless data, which is what makes the likelihood binary and hands the whole decision to the prior. Score hypotheses by error count instead and watch where the argument stops holding.
  • Give the model the interpreter. The length section showed identification surviving at 128 while transcription does not, and the third column already proves the fix works when you run the program. Let the model call run(program, x) itself and the gap should close inside the reply rather than in the grader.
  • Put a model in the library loop. The library learner above mines fragments by description length and names them P1, P2. Ask a model to name them instead, and see whether an abstraction called mirror_and_repeat transfers to a new task stream better than an anonymous one does.
Read the parent note