File size: 15,498 Bytes
2ff33ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
#!/usr/bin/env python3
"""
in2_gen.py -- synthesise IN2-style TRAINING shards for bluscriptCP.

IN2 (arXiv:2404.16811) teaches long-range retrieval by making the answer
impossible to produce without a fact planted at a random distant position. We
cannot reproduce its "loss on the answer only" masking (see the plan: TI has no
maskable CE, `narrow` has no autograd node, and sentinel target ids are unsafe
because the fwd/bwd kernels do not bounds-check). So we remove the NEED to mask:

  * our filler is CONTIGUOUS REAL TEXT, not IN2's shuffled-segment bag, so
    unmasked LM loss on it is ordinary harmless LM training; and
  * dilution is fixed by construction -- K triples per example raise the answer
    tokens from ~0.1% of positions to several percent.

Output is what DataLoaderLite already eats: a HEADERLESS little-endian uint16
token stream in a file whose name contains "train"/"val", under a directory
passed as CP_DATA_ROOT. No C++ training/loss/loader change is required.

LAYOUT (one example = exactly T tokens)

    [ filler, with needles and decoys planted throughout ]
       ^needle_i (key_i + val_i)        ^decoy (key + val, NO probe)
    ... and at each site p_i:  [ key_i ][ val_i ]
                                probe    answer

  * SITES p_i are drawn from an ARM-INDEPENDENT rng stream, so arms B and C have
    bit-identical answer-position histograms and differ ONLY in needle distance.
    Without this we would difference NLLs measured at different points on a
    position-varying curve -- the error needle_gen.py's fixed-slot design exists
    to prevent.
  * Sites are INTERLEAVED over the sequence, never concatenated at the tail:
    tail-packing would confine every supervised position to the last K*SPAN
    offsets and train a position-specific circuit indistinguishable from
    retrieval.
  * PAIRING is shuffled and DECOY needles (no probe) are planted, so "the i-th
    probe pairs with the i-th needle" is not a winning heuristic. That shortcut
    is order/counting, not content retrieval, and would earn full reward at any
    distance.
  * KEY ALPHABET is capped (default 512, not the full 50304). Uniform draws from
    the vocab cost ln(50304)=10.83 nats IRREDUCIBLY -- pure noise that inflates
    the pre-clip gradient norm and, against the GLOBAL clip_grad_norm_(1.0),
    could silently leave the arms not LR-matched. ln(512)=6.24 nats gives the
    same discriminability (512^4 = 6.9e10 keys) with 42% less noise.

ARMS
  a  plain text, no injection            -- drift control
  b  needle immediately before its probe -- the objective change at distance ~0
  c  needle uniform in [0, p_i - SPAN)   -- DISTANCE, the actual IN2 claim

Stdlib only, matching needle_gen.py (the local pyenv python3 lacks numpy).

Usage
  python3 in2_gen.py --arm c --T 16384 --examples 64 --split train --out-dir /tmp/in2c
  python3 in2_gen.py --arm b --T 16384 --examples 64 --split train --out-dir /tmp/in2b
"""
import argparse, array, json, os, random, sys

VOCAB = 50304          # must match cfg.vocab_size; the C++ side bound-checks too
PAD_TAIL = 4           # >=2: the loader's y-shift makes the last position of each
                       # example target the NEXT example's first token. Keep the
                       # answer clear of it.
MIX = 1_000_003        # odd multiplier for deterministic seed derivation
MAX_SHARD_TOKENS = 100_000_000   # DataLoaderLite is constructed with this cap;
                                 # anything past it in a shard is SILENTLY invisible


def check_platform():
    if sys.byteorder != "little":
        raise SystemExit("training shards must be uint16 LITTLE-endian; this host is big-endian")
    if array.array("H").itemsize != 2:
        raise SystemExit("unexpected array itemsize ('H' must be 2B)")


def load_filler(path, need, rng):
    """One CONTIGUOUS run of real tokens per example, so filler keeps local
    textual statistics. Source shards are already uint16, so unlike
    needle_gen.py (which must widen to int32 for CP_EVAL_TOKENS_BIN) we keep the
    native width -- training shards are uint16."""
    ntok = os.path.getsize(path) // 2
    if ntok < need + 1:
        raise SystemExit(f"{path} has {ntok} tokens, need >= {need + 1}")
    start = rng.randrange(0, ntok - need)
    buf = array.array("H")
    with open(path, "rb") as f:
        f.seek(start * 2)
        buf.fromfile(f, need)
    lo, hi = min(buf), max(buf)
    if lo < 0 or hi >= VOCAB:
        raise SystemExit(f"{path} has ids outside [0,{VOCAB}): min={lo} max={hi}")
    return buf


def draw_sites(T, n, span, rng, lo_margin):
    """n site starts in [lo_margin, T-PAD_TAIL-span], separated by >= 2*span.

    Drawn from an ARM-INDEPENDENT rng so every arm sees the same sites. Returned
    sorted; the pairing to needles is shuffled separately.

    Separation is 2*span, not span, and the reason is load-bearing: arm B plants
    its needle immediately before the probe, at p-span. At only span separation,
    two adjacent sites can sit exactly span apart and B's needle then lands on
    top of the earlier site, so B silently drops that triple -- which would make
    B's answer_start histogram DIFFER from C's and destroy the matched-position
    discipline the B-vs-C contrast depends on. The constraint is applied for all
    arms so sites stay arm-independent.
    """
    hi = T - PAD_TAIL - span
    if hi <= lo_margin:
        raise SystemExit(f"T={T} too small for span={span}")
    sep = 2 * span
    chosen = []
    guard = 0
    while len(chosen) < n:
        guard += 1
        if guard > 400 * n:
            raise SystemExit(f"could not place {n} sites in T={T} at separation {sep}; "
                             f"lower --K (need roughly K*{sep} << {hi - lo_margin})")
        p = rng.randrange(lo_margin, hi + 1)
        if all(abs(p - q) >= sep for q in chosen):
            chosen.append(p)
    return sorted(chosen)


def place_free(occupied, T, span, rng, hi_bound):
    """A start in [0, hi_bound-span] disjoint from `occupied`; None if it fails."""
    for _ in range(400):
        if hi_bound - span < 0:
            return None
        s = rng.randrange(0, hi_bound - span + 1)
        if all(s + span <= a or s >= a + span for a, _ in occupied):
            return s
    return None


def make_example(arm, T, K, key_len, val_len, alphabet, decoys, d_min,
                 filler, site_rng, arm_rng):
    """Returns (tokens uint16 array of length T, list of records).

    site_rng  -- ARM-INDEPENDENT: sites, keys, values, pairing.
    arm_rng   -- arm-specific: needle placement only.
    """
    span = key_len + val_len
    toks = filler                                    # fresh copy per example
    recs = []

    if arm == "a":                                   # plain text, no injection
        return toks, recs, 0

    sites = draw_sites(T, K, span, site_rng, lo_margin=span + d_min)

    # Distinct key/value pairs, then a shuffled site<->needle pairing so the
    # i-th probe does not correspond to the i-th needle by position.
    keys = [[site_rng.randrange(alphabet) for _ in range(key_len)] for _ in range(K)]
    vals = [[site_rng.randrange(alphabet) for _ in range(val_len)] for _ in range(K)]
    order = list(range(K))
    site_rng.shuffle(order)

    occupied = [(p, "site") for p in sites]

    for si, p in enumerate(sites):
        j = order[si]                                # which key/value this site queries
        key, val = keys[j], vals[j]
        toks[p:p + key_len] = array.array("H", key)              # probe
        toks[p + key_len:p + span] = array.array("H", val)       # answer

        if arm == "b":
            ns = p - span                            # immediately before the probe
            # Guaranteed free by the 2*span site separation; assert rather than
            # skip, because a silent skip would desynchronise B's histogram from C's.
            if ns < 0 or any(ns + span > a and ns < a + span
                             for a, _ in occupied if a != p):
                raise SystemExit(f"arm b: needle slot {ns} for site {p} is occupied -- "
                                 "site separation invariant violated")
        else:                                        # arm c: uniform, far
            hi = p - d_min
            ns = place_free(occupied, T, span, arm_rng, hi)
            if ns is None:
                raise SystemExit(f"arm c: could not place a needle before site {p} "
                                 f"(d_min={d_min}); lower --K or --d-min")
        toks[ns:ns + span] = array.array("H", key + val)
        occupied.append((ns, "needle"))

        answer_start = p + key_len
        recs.append(dict(needle_start=ns, probe_start=p, answer_start=answer_start,
                         distance=p - ns,
                         csv_first=answer_start - 1))   # see off-by-one note below

    n_decoy = 0
    # Decoy needles: no probe queries them, so needle count != probe count and
    # the i-th-of-K index carries no information.
    for _ in range(decoys):
        key = [site_rng.randrange(alphabet) for _ in range(key_len)]
        val = [site_rng.randrange(alphabet) for _ in range(val_len)]
        ds = place_free(occupied, T, span, arm_rng, T - PAD_TAIL)
        if ds is None:
            continue
        toks[ds:ds + span] = array.array("H", key + val)
        occupied.append((ds, "decoy"))
        n_decoy += 1

    return toks, recs, n_decoy


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--arm", required=True, choices=["a", "b", "c"])
    ap.add_argument("--T", type=int, required=True)
    ap.add_argument("--examples", type=int, required=True)
    ap.add_argument("--split", default="train", choices=["train", "val"])
    ap.add_argument("--K", type=int, default=64, help="triples per example")
    ap.add_argument("--key-len", type=int, default=4, help="probe/key tokens")
    ap.add_argument("--val-len", type=int, default=8, help="answer/value tokens")
    ap.add_argument("--alphabet", type=int, default=512, help="key/value alphabet size")
    ap.add_argument("--decoys", type=int, default=None, help="default K//4")
    ap.add_argument("--d-min", type=int, default=64, help="arm c: min needle->probe distance")
    ap.add_argument("--filler-shards", nargs="+", required=True)
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--out-dir", required=True)
    ap.add_argument("--shards", type=int, default=1,
                    help="split the examples across this many files. REQUIRED above "
                         "100M tokens: DataLoaderLite is constructed with a "
                         "max_tokens_per_shard of 1e8 and anything past it in a single "
                         "shard is SILENTLY invisible. The example index stays GLOBAL "
                         "across shards, so the arm-independent RNG stream (sites, keys, "
                         "pairing, filler) is unchanged by how the split falls -- arms "
                         "remain comparable.")
    args = ap.parse_args()

    check_platform()
    if args.alphabet > VOCAB:
        raise SystemExit(f"--alphabet {args.alphabet} exceeds VOCAB {VOCAB}")
    decoys = args.decoys if args.decoys is not None else args.K // 4
    span = args.key_len + args.val_len
    for f in args.filler_shards:
        if not os.path.exists(f):
            raise SystemExit(f"filler shard not found: {f}")
        if args.split == "train" and "val" in os.path.basename(f):
            raise SystemExit(f"refusing to build TRAIN data from a val shard: {f}\n"
                             "  eval filler must stay disjoint from training data")
    os.makedirs(args.out_dir, exist_ok=True)

    total_tok = args.examples * args.T
    per_shard = (args.examples + args.shards - 1) // args.shards
    if per_shard * args.T > MAX_SHARD_TOKENS:
        raise SystemExit(f"{per_shard * args.T} tokens/shard exceeds the loader's "
                         f"{MAX_SHARD_TOKENS} cap; raise --shards to at least "
                         f"{(total_tok + MAX_SHARD_TOKENS - 1) // MAX_SHARD_TOKENS}")

    all_recs, hist, n_decoy_total = [], {}, 0
    shard_paths = []
    stream = array.array("H")
    shard_i = 0
    for i in range(args.examples):
        # Site/key/value/pairing rng is ARM-INDEPENDENT -- identical across arms.
        site_rng = random.Random(((args.seed * MIX + args.T) * MIX + i) * MIX + 7)
        # Needle placement is arm-specific.
        arm_rng = random.Random(((args.seed * MIX + args.T) * MIX + i) * MIX
                                + 1000 + ord(args.arm))
        # Filler draw is arm-independent too, so arms see the same documents.
        fill_rng = random.Random(((args.seed * MIX + args.T) * MIX + i) * MIX + 99)
        shard = args.filler_shards[i % len(args.filler_shards)]
        filler = load_filler(shard, args.T, fill_rng)

        toks, recs, n_dec = make_example(args.arm, args.T, args.K, args.key_len, args.val_len,
                                  args.alphabet, decoys, args.d_min,
                                  filler, site_rng, arm_rng)
        if len(toks) != args.T:
            raise SystemExit(f"example {i} is {len(toks)} tokens, expected {args.T}")
        stream.extend(toks)
        for r in recs:
            hist[r["answer_start"]] = hist.get(r["answer_start"], 0) + 1
        all_recs.append(recs)
        n_decoy_total += n_dec

        # Flush a shard whenever it is full, or on the last example. The global `i`
        # above drives every RNG, so the split point cannot change the data.
        if len(stream) // args.T >= per_shard or i == args.examples - 1:
            sp = os.path.join(args.out_dir, f"in2{args.arm}_{args.split}_{shard_i:05d}.bin")
            with open(sp, "wb") as f:
                stream.tofile(f)
            shard_paths.append(sp)
            stream = array.array("H")
            shard_i += 1

    path = shard_paths[0]

    n_ans = sum(len(r) for r in all_recs) * args.val_len
    # Counted from ACTUAL placements, never derived from parameters: arm A plants
    # nothing, so a parameter-derived figure reported phantom decoy tokens for it.
    n_syn = sum(len(r) for r in all_recs) * (2 * span) + n_decoy_total * span
    man = dict(arm=args.arm, T=args.T, examples=args.examples, split=args.split,
               K=args.K, key_len=args.key_len, val_len=args.val_len, span=span,
               alphabet=args.alphabet, decoys=decoys, d_min=args.d_min,
               seed=args.seed, vocab=VOCAB, pad_tail=PAD_TAIL,
               filler_shards=args.filler_shards, bin=path, shards=shard_paths,
               total_tokens=total_tok, dtype="uint16",
               answer_tokens=n_ans, answer_frac=n_ans / total_tok,
               synthetic_tokens=n_syn, synthetic_frac=n_syn / total_tok,
               decoys_placed=n_decoy_total,
               answer_start_hist=dict(sorted(hist.items())),
               records=all_recs)
    with open(path.replace(".bin", ".json"), "w") as f:
        json.dump(man, f, indent=1)

    print(f"wrote {len(shard_paths)} shard(s) in {args.out_dir}  {total_tok} tokens total")
    print(f"  answer tokens {n_ans} ({n_ans/total_tok:.2%})  "
          f"synthetic {n_syn} ({n_syn/total_tok:.2%})")
    if all_recs and all_recs[0]:
        ds = [r["distance"] for rs in all_recs for r in rs]
        ds.sort()
        print(f"  distance: min {ds[0]}  median {ds[len(ds)//2]}  max {ds[-1]}  n={len(ds)}")


if __name__ == "__main__":
    main()