File size: 3,865 Bytes
cbbd21f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
"""Pointer-chasing dataset generation, shortcut checks, and the deterministic
lookup reference.

Task: a fresh uniform-random mapping f: S -> S per example, a start state x,
and a hop count k. The answer is f^k(x). Training uses k ~ Uniform{1..8};
evaluation uses k up to 64 and smaller state spaces.

Sequence serialization (see models.py): the model consumes
[f(0), f(1), ..., f(S-1), x, k]; the position of token i in the mapping part
encodes state i, so learned positional embeddings play the role of "state id".
Sequence length is S+2 regardless of k, so length generalisation here means
generalising over the hop count k, whose token embedding is OOD for k > 8.

Shortcut controls (per Liu et al. 2210.10749, Anil et al. 2207.04901,
Li et al. 2604.07822):
  - f is uniform random per example -> no counter-free / aperiodic structure,
    so no constant-depth lookup shortcut exists in principle.
  - answer marginals f^k(x) are approximately uniform for every k; we check
    this empirically (check_answer_marginals).
  - k is the ONLY length-correlated token; it sits at a fixed position.
  - fresh mappings every batch -> nothing to memorise; a held-out probe with
    never-seen f is generated on the fly at eval time.
"""

import numpy as np


def sample_mappings(rng, batch, S):
    """Uniform random f: S -> S for each example. Shape [batch, S]."""
    return rng.integers(0, S, size=(batch, S))


def iterate_mapping(f, x, k_max):
    """Apply f repeatedly to x. Returns outs of shape [k_max, batch] where
    outs[t-1] = f^t(x), for t = 1..k_max. Vectorised lookup."""
    f = np.asarray(f)
    y = np.asarray(x).copy()
    outs = np.empty((k_max, f.shape[0]), dtype=np.int64)
    for t in range(1, k_max + 1):
        y = np.take_along_axis(f, y[:, None], axis=1).squeeze(1)
        outs[t - 1] = y
    return outs


def sample_train_batch(rng, batch, S, k_min=1, k_max=8):
    """A training batch: fresh f per example, x uniform, k ~ U{k_min..k_max}.
    Returns dict with numpy arrays f [B,S], x [B], k [B], answer [B]."""
    f = sample_mappings(rng, batch, S)
    x = rng.integers(0, S, size=batch)
    k = rng.integers(k_min, k_max + 1, size=batch)
    all_ans = iterate_mapping(f, x, k_max)          # [k_max, B]
    ans = all_ans[k - 1, np.arange(batch)]
    return {"f": f, "x": x, "k": k, "answer": ans}


def sample_eval_batch(rng, batch, S, k):
    """An evaluation batch at a FIXED hop count k (fresh mappings, fresh x).
    answer = f^k(x)."""
    f = sample_mappings(rng, batch, S)
    x = rng.integers(0, S, size=batch)
    all_ans = iterate_mapping(f, x, k)
    ans = all_ans[k - 1, np.arange(batch)]
    return {"f": f, "x": x, "k": np.full(batch, k, dtype=np.int64), "answer": ans}


class LookupSolver:
    """Deterministic exact-answer reference: materialise f as a table and
    iterate k times. Must score 100% by construction; used as a pipeline
    integrity check in evaluation, not as a learned baseline."""

    def solve(self, f, x, k):
        y = np.asarray(x).copy()
        for _ in range(int(k)):
            y = np.take_along_axis(np.asarray(f), y[:, None], axis=1).squeeze(1)
        return y


def check_answer_marginals(rng, S=64, k_max=8, n=200_000, tol=0.02):
    """Empirical shortcut check: the marginal distribution of the answer
    f^k(x) must be ~uniform over S for every training k. Returns dict of
    max deviation from uniform (in total-variation distance) per k."""
    worst = {}
    for k in range(1, k_max + 1):
        b = sample_eval_batch(rng, n // 1000 * 1000, S, k)
        counts = np.bincount(b["answer"], minlength=S).astype(np.float64)
        p = counts / counts.sum()
        tv = 0.5 * np.abs(p - 1.0 / S).sum()
        worst[k] = float(tv)
    ok = all(v < tol for v in worst.values())
    return {"tv_per_k": worst, "tol": tol, "pass": ok}


def sequence_length(S):
    return S + 2