unparallelled commited on
Commit
82daec4
·
verified ·
1 Parent(s): cf50a09

Upload scripts/longeval_gen.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/longeval_gen.py +246 -3
scripts/longeval_gen.py CHANGED
@@ -184,6 +184,219 @@ def t_val_backward(rng, depth):
184
  return [(s, depth)], q, a, [k]
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  TASKS = {
188
  "niah_single": t_niah_single,
189
  "niah_multikey": t_niah_multikey,
@@ -191,6 +404,16 @@ TASKS = {
191
  "vt": t_vt,
192
  "val_forward": t_val_forward,
193
  "val_backward": t_val_backward,
 
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
 
@@ -202,13 +425,30 @@ def build_one(task, rng, prompt_len, hay, depth, hay_off=0):
202
  replicates rather than independent draws -- which inflates apparent
203
  precision because the effective sample size is far below N.
204
  """
205
- sents, q, a, expected = TASKS[task](rng, depth)
 
 
 
 
 
 
 
206
  q_ids, a_ids = ENC.encode(q), ENC.encode(a)
207
  tail = len(q_ids) + len(a_ids)
208
  body_budget = prompt_len - tail
209
  if body_budget <= 0:
210
  raise SystemExit(f"--prompt-len {prompt_len} too small for task {task}")
211
 
 
 
 
 
 
 
 
 
 
 
212
  # Plant each needle at its depth fraction of the BODY, then fill with haystack.
213
  planted = sorted(((max(0.0, min(1.0, d)), ENC.encode(s)) for s, d in sents),
214
  key=lambda x: x[0])
@@ -255,8 +495,11 @@ def main():
255
 
256
  for task in tasks:
257
  rng = random.Random(args.seed) # per-task, so tasks are independent
258
- # (n + 2) windows so every prompt gets its own filler offset
259
- hay = load_haystack(args.haystack, args.prompt_len * (args.n + 2))
 
 
 
260
  bin_p = os.path.join(args.out_dir, f"{task}_prompts.bin")
261
  meta = []
262
  with open(bin_p, "wb") as f:
 
184
  return [(s, depth)], q, a, [k]
185
 
186
 
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # RULER PARITY ADDITIONS (2026-09-17)
190
+ #
191
+ # RULER is 13 tasks in 4 categories: 8 NIAH retrieval variants, 1 multi-hop
192
+ # tracing, 2 aggregation, 2 QA. The six tasks above cover 4 of the 8 NIAH slots
193
+ # plus VT. Everything below fills the rest.
194
+ #
195
+ # The NIAH variants differ only in NEEDLE TYPE (7-digit number vs UUID) and
196
+ # HAYSTACK TYPE (real text vs a repeated noise sentence), so they are
197
+ # parameterisations rather than new mechanisms -- which is exactly how RULER
198
+ # defines them.
199
+ #
200
+ # CWE and FWE are structurally different: the body IS the data, not a haystack
201
+ # with needles planted in it. They are listed in BODY_TASKS and build_one takes
202
+ # a separate path for them.
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def rnd_uuid(rng):
206
+ h = "0123456789abcdef"
207
+ f = lambda n: "".join(rng.choice(h) for _ in range(n))
208
+ return f"{f(8)}-{f(4)}-{f(4)}-{f(4)}-{f(12)}"
209
+
210
+
211
+ # Tasks whose haystack must be the repeated-noise filler rather than real text.
212
+ NOISE_TASKS = {"niah_single_1"}
213
+ # Tasks that generate their own body instead of planting needles in a haystack.
214
+ BODY_TASKS = {"cwe", "fwe", "qa_1", "qa_2"}
215
+
216
+
217
+ def t_niah_single_1(rng, depth):
218
+ """S-NIAH-1: word-number needle in REPEATED NOISE (see NOISE_TASKS)."""
219
+ return t_niah_single(rng, depth)
220
+
221
+
222
+ def t_niah_single_3(rng, depth):
223
+ """S-NIAH-3: word-UUID needle. UUIDs are far longer and less token-friendly
224
+ than 7-digit numbers, so this is the hardest single-needle variant."""
225
+ k, v = rng.choice(WORDS), rnd_uuid(rng)
226
+ s = f" The special magic uuid for {k} is {v}. "
227
+ q = f"\nQuestion: What is the special magic uuid for {k}?\n"
228
+ a = f"Answer: The special magic uuid for {k} is"
229
+ return [(s, depth)], q, a, [v]
230
+
231
+
232
+ def t_niah_multikey_2(rng, depth):
233
+ """MK-NIAH-2: 8 distractor keys instead of 4."""
234
+ return t_niah_multikey(rng, depth, n_keys=8)
235
+
236
+
237
+ def t_niah_multikey_3(rng, depth, n_keys=8):
238
+ """MK-NIAH-3: 8 keys AND UUID values."""
239
+ keys = rng.sample(WORDS, n_keys)
240
+ vals = [rnd_uuid(rng) for _ in keys]
241
+ tgt = rng.randrange(n_keys)
242
+ sents = [(f" The special magic uuid for {k} is {v}. ",
243
+ depth if i == tgt else rng.random())
244
+ for i, (k, v) in enumerate(zip(keys, vals))]
245
+ k, v = keys[tgt], vals[tgt]
246
+ q = f"\nQuestion: What is the special magic uuid for {k}?\n"
247
+ a = f"Answer: The special magic uuid for {k} is"
248
+ return sents, q, a, [v]
249
+
250
+
251
+ def t_niah_multiquery(rng, depth, n_keys=4):
252
+ """MQ-NIAH: several keys planted, ALL of them queried at once. Distinct from
253
+ multikey (one target among distractors) and multivalue (one key, many
254
+ values): here every planted pair must be retrieved."""
255
+ keys = rng.sample(WORDS, n_keys)
256
+ vals = [rnd_number(rng) for _ in keys]
257
+ sents = [(f" The special magic number for {k} is {v}. ",
258
+ depth if i == 0 else rng.random())
259
+ for i, (k, v) in enumerate(zip(keys, vals))]
260
+ q = ("\nQuestion: What are the special magic numbers for "
261
+ + ", ".join(keys) + "?\n")
262
+ a = "Answer: The special magic numbers are"
263
+ return sents, q, a, vals # every value must appear
264
+
265
+
266
+ # ---- aggregation ----------------------------------------------------------
267
+ # These return (body_text, question, answer_prefix, expected). build_one
268
+ # tokenises body_text directly and does NOT use the haystack.
269
+
270
+ CWE_COMMON, CWE_TOPK = 10, 10
271
+ FWE_TOPK = 3
272
+
273
+
274
+ def t_cwe(rng, depth, body_budget_tok=4096):
275
+ """CWE: a word list in which CWE_COMMON words recur many times and the rest
276
+ appear once. The model must name the top-K most common.
277
+
278
+ Word pool is built from WORDS x a digit suffix so every item is a single
279
+ common English token plus a digit -- keeps tokenisation tight and avoids
280
+ the model recognising real-word frequency from pretraining."""
281
+ pool = [f"{w}{i}" for w in WORDS for i in range(10)]
282
+ rng.shuffle(pool)
283
+ common = pool[:CWE_COMMON]
284
+ rest = pool[CWE_COMMON:]
285
+ # ~4 tokens per "word," item; reserve a little headroom.
286
+ n_items = max(CWE_COMMON * 6, int(body_budget_tok / 4.2))
287
+ reps = max(3, (n_items // 2) // max(1, CWE_COMMON))
288
+ items = [c for c in common for _ in range(reps)]
289
+ singles = rest * (max(0, n_items - len(items)) // max(1, len(rest)) + 1)
290
+ items += singles[: max(0, n_items - len(items))]
291
+ rng.shuffle(items)
292
+ body = " " + ", ".join(items) + ". "
293
+ q = f"\nQuestion: What are the {CWE_TOPK} most common words in the list above?\n"
294
+ a = "Answer: The most common words are"
295
+ return body, q, a, common # all K must appear
296
+
297
+
298
+ def t_fwe(rng, depth, body_budget_tok=4096, alpha=2.0):
299
+ """FWE: words drawn from a Zeta(alpha) distribution over a shuffled pool;
300
+ the model must name the top-3 most frequent. Unlike CWE the frequencies are
301
+ graded rather than two-tier, so ranking matters."""
302
+ pool = [f"{w}{i}" for w in WORDS for i in range(10)]
303
+ rng.shuffle(pool)
304
+ n_items = max(60, int(body_budget_tok / 4.2))
305
+ # Zeta: P(rank r) ~ r^-alpha over the shuffled pool.
306
+ wts = [(r + 1) ** (-alpha) for r in range(len(pool))]
307
+ tot = sum(wts)
308
+ cum, acc = [], 0.0
309
+ for w in wts:
310
+ acc += w / tot
311
+ cum.append(acc)
312
+ def draw():
313
+ u = rng.random()
314
+ lo, hi = 0, len(cum) - 1
315
+ while lo < hi:
316
+ mid = (lo + hi) // 2
317
+ if cum[mid] < u: lo = mid + 1
318
+ else: hi = mid
319
+ return pool[lo]
320
+ items = [draw() for _ in range(n_items)]
321
+ counts = {}
322
+ for it in items: counts[it] = counts.get(it, 0) + 1
323
+ top = [w for w, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))][:FWE_TOPK]
324
+ rng.shuffle(items)
325
+ body = " " + ", ".join(items) + ". "
326
+ q = f"\nQuestion: What are the {FWE_TOPK} most frequently appearing words in the list above?\n"
327
+ a = "Answer: The most frequent words are"
328
+ return body, q, a, top
329
+
330
+
331
+
332
+ # ---- QA with distractors (RULER qa_1 / qa_2) ------------------------------
333
+ # RULER "extends traditional QA datasets by adding distracting paragraphs".
334
+ # The GOLD paragraph sits at `depth`; the rest of the body is distractor
335
+ # paragraphs drawn from other questions in the same dataset.
336
+ #
337
+ # HotpotQA yes/no answers are DROPPED: scoring is substring match on normalised
338
+ # text, and "yes" matches almost any generation, which would score noise as a hit.
339
+
340
+ QA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qa_data")
341
+ _QA_CACHE = {}
342
+
343
+
344
+ def _load_qa(kind):
345
+ if kind in _QA_CACHE:
346
+ return _QA_CACHE[kind]
347
+ import pyarrow.parquet as pq
348
+ if kind == "squad":
349
+ t = pq.read_table(os.path.join(QA_DIR, "squad_val.parquet")).to_pydict()
350
+ rows = []
351
+ for ctx, qn, ans in zip(t["context"], t["question"], t["answers"]):
352
+ a = ans["text"][0] if ans["text"] else ""
353
+ if len(a) >= 3:
354
+ rows.append((ctx, qn, a))
355
+ else:
356
+ t = pq.read_table(os.path.join(QA_DIR, "hotpot_val.parquet")).to_pydict()
357
+ rows = []
358
+ for ctx, qn, a in zip(t["context"], t["question"], t["answer"]):
359
+ if a.lower() in ("yes", "no") or len(a) < 3:
360
+ continue # see note above
361
+ paras = [" ".join(sents) for sents in ctx["sentences"]]
362
+ rows.append(("\n\n".join(paras), qn, a))
363
+ if not rows:
364
+ raise SystemExit(f"no usable {kind} rows in {QA_DIR}")
365
+ _QA_CACHE[kind] = rows
366
+ return rows
367
+
368
+
369
+ def _t_qa(kind, rng, depth, body_budget_tok):
370
+ rows = _load_qa(kind)
371
+ gold_ctx, question, answer = rows[rng.randrange(len(rows))]
372
+ gold = " " + gold_ctx.strip() + " "
373
+ # distractors from OTHER questions, appended until the budget is full
374
+ need = max(0, body_budget_tok - len(ENC.encode(gold)))
375
+ pre, post, got = [], [], 0
376
+ want_pre = int(max(0.0, min(1.0, depth)) * need)
377
+ while got < need:
378
+ d = rows[rng.randrange(len(rows))][0]
379
+ if d == gold_ctx:
380
+ continue
381
+ piece = " " + d.strip() + " "
382
+ n = len(ENC.encode(piece))
383
+ (pre if sum(len(ENC.encode(x)) for x in pre) < want_pre else post).append(piece)
384
+ got += n
385
+ body = "".join(pre) + gold + "".join(post)
386
+ q = f"\nQuestion: {question}\n"
387
+ a = "Answer:"
388
+ return body, q, a, [answer]
389
+
390
+
391
+ def t_qa_1(rng, depth, body_budget_tok=4096):
392
+ """SQuAD paragraph + SQuAD distractors."""
393
+ return _t_qa("squad", rng, depth, body_budget_tok)
394
+
395
+
396
+ def t_qa_2(rng, depth, body_budget_tok=4096):
397
+ """HotpotQA (multi-hop, 2 gold paragraphs) + distractors."""
398
+ return _t_qa("hotpot", rng, depth, body_budget_tok)
399
+
400
  TASKS = {
401
  "niah_single": t_niah_single,
402
  "niah_multikey": t_niah_multikey,
 
404
  "vt": t_vt,
405
  "val_forward": t_val_forward,
406
  "val_backward": t_val_backward,
407
+ # --- RULER parity (2026-09-17) ---
408
+ "niah_single_1": t_niah_single_1,
409
+ "niah_single_3": t_niah_single_3,
410
+ "niah_multikey_2": t_niah_multikey_2,
411
+ "niah_multikey_3": t_niah_multikey_3,
412
+ "niah_multiquery": t_niah_multiquery,
413
+ "cwe": t_cwe,
414
+ "fwe": t_fwe,
415
+ "qa_1": t_qa_1,
416
+ "qa_2": t_qa_2,
417
  }
418
 
419
 
 
425
  replicates rather than independent draws -- which inflates apparent
426
  precision because the effective sample size is far below N.
427
  """
428
+ # BODY_TASKS (cwe/fwe) generate their own body and need to know the budget,
429
+ # so they are called with it; everything else keeps the original signature.
430
+ if task in BODY_TASKS:
431
+ probe_q, probe_a = TASKS[task](random.Random(0), depth, 64)[1:3]
432
+ tail0 = len(ENC.encode(probe_q)) + len(ENC.encode(probe_a))
433
+ body_text, q, a, expected = TASKS[task](rng, depth, max(64, prompt_len - tail0))
434
+ else:
435
+ sents, q, a, expected = TASKS[task](rng, depth)
436
  q_ids, a_ids = ENC.encode(q), ENC.encode(a)
437
  tail = len(q_ids) + len(a_ids)
438
  body_budget = prompt_len - tail
439
  if body_budget <= 0:
440
  raise SystemExit(f"--prompt-len {prompt_len} too small for task {task}")
441
 
442
+ if task in BODY_TASKS:
443
+ # The body IS the data. Repeat-or-truncate to hit the exact length; the
444
+ # C++ reader slices at a fixed stride so drift is fatal.
445
+ b = ENC.encode(body_text)
446
+ if len(b) < body_budget:
447
+ b = (b * (body_budget // max(1, len(b)) + 2))[:body_budget]
448
+ out = b[:body_budget] + q_ids + a_ids
449
+ assert len(out) == prompt_len, (len(out), prompt_len)
450
+ return out, expected
451
+
452
  # Plant each needle at its depth fraction of the BODY, then fill with haystack.
453
  planted = sorted(((max(0.0, min(1.0, d)), ENC.encode(s)) for s, d in sents),
454
  key=lambda x: x[0])
 
495
 
496
  for task in tasks:
497
  rng = random.Random(args.seed) # per-task, so tasks are independent
498
+ # (n + 2) windows so every prompt gets its own filler offset.
499
+ # NOISE_TASKS (S-NIAH-1) deliberately use the repeated-noise filler
500
+ # instead of real text -- that IS the variant's definition in RULER.
501
+ hay_path = "" if task in NOISE_TASKS else args.haystack
502
+ hay = load_haystack(hay_path, args.prompt_len * (args.n + 2))
503
  bin_p = os.path.join(args.out_dir, f"{task}_prompts.bin")
504
  meta = []
505
  with open(bin_p, "wb") as f: