benchkit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,535 @@
1
+ """Deterministic synthetic task generation for the RULER benchmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import random
8
+ import string
9
+ import urllib.request
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from benchkit.benchmarks.base import Task
15
+ from benchkit.leakage import assert_candidate_parity
16
+
17
+ # Providers use both binary and decimal interpretations of "128K". The final
18
+ # target is 128,000 so a server advertising a 128,000-token served window can
19
+ # run the bucket instead of being rejected for falling 3,072 tokens short.
20
+ CONTEXT_BUCKETS = (4096, 8192, 16384, 32768, 65536, 128000)
21
+ DEFAULT_SAMPLES_PER_KIND = 500
22
+ PRACTICAL_SAMPLES_PER_KIND = 3
23
+ TASK_KINDS = (
24
+ "niah_single_1",
25
+ "niah_single_2",
26
+ "niah_single_3",
27
+ "niah_multikey_1",
28
+ "niah_multikey_2",
29
+ "niah_multikey_3",
30
+ "niah_multivalue",
31
+ "niah_multiquery",
32
+ "variable_tracking",
33
+ "cwe",
34
+ "fwe",
35
+ "qa_1",
36
+ "qa_2",
37
+ )
38
+
39
+
40
+ TASKS_PER_BUCKET = DEFAULT_SAMPLES_PER_KIND * len(TASK_KINDS)
41
+
42
+ # RULER lengths include the expected completion. Keep enough room for a short
43
+ # answer and the server's chat template, neither of which is visible to the
44
+ # benchmark-side tokenizer call.
45
+ OUTPUT_RESERVE_TOKENS = 256
46
+ FALLBACK_CHARS_PER_TOKEN = 4
47
+ FIT_TOLERANCE = 0.01
48
+
49
+ _VARIABLE_NOISE = (
50
+ "The grass is green. The sky is blue. The sun is yellow. "
51
+ "Here we go. There and back again."
52
+ )
53
+
54
+
55
+ def context_label(tokens: int) -> str:
56
+ """Return the compact label used for a context bucket."""
57
+ if tokens == 128000:
58
+ return "128k"
59
+ if tokens % 1024 == 0:
60
+ return f"{tokens // 1024}k"
61
+ if tokens % 1000 == 0:
62
+ return f"{tokens // 1000}k"
63
+ return f"{tokens:,}"
64
+
65
+
66
+ def _seed(bucket: int, sample: int, kind: str) -> int:
67
+ kind_offset = (
68
+ 53
69
+ if kind == "variable_tracking"
70
+ else sum((index + 1) * ord(char) for index, char in enumerate(kind))
71
+ )
72
+ return bucket * 1009 + sample * 97 + kind_offset
73
+
74
+
75
+ def _unique_variables(rng: random.Random, count: int) -> list[str]:
76
+ values: list[str] = []
77
+ while len(values) < count:
78
+ value = "".join(rng.choices(string.ascii_uppercase, k=6))
79
+ if value not in values:
80
+ values.append(value)
81
+ return values
82
+
83
+
84
+ def _random_uuid(rng: random.Random) -> str:
85
+ return str(uuid.UUID(int=rng.getrandbits(128), version=4))
86
+
87
+
88
+ def _random_word(rng: random.Random) -> str:
89
+ return f"{''.join(rng.choices(string.ascii_lowercase, k=7))}-{''.join(rng.choices(string.ascii_lowercase, k=8))}"
90
+
91
+
92
+ def _niah_task(bucket: int, sample: int, kind: str) -> Task:
93
+ seed = _seed(bucket, sample, kind)
94
+ rng = random.Random(seed)
95
+ config = {
96
+ "niah_single_1": ("noise", "word", "number", 1, 1, 1),
97
+ "niah_single_2": ("essay", "word", "number", 1, 1, 1),
98
+ "niah_single_3": ("essay", "word", "uuid", 1, 1, 1),
99
+ "niah_multikey_1": ("essay", "word", "number", 4, 1, 1),
100
+ "niah_multikey_2": ("needle", "word", "number", 1, 1, 1),
101
+ "niah_multikey_3": ("needle", "uuid", "uuid", 1, 1, 1),
102
+ "niah_multivalue": ("essay", "word", "number", 1, 4, 1),
103
+ "niah_multiquery": ("essay", "word", "number", 4, 1, 4),
104
+ }[kind]
105
+ haystack, key_type, value_type, key_count, value_count, query_count = config
106
+
107
+ def generate(requested_type: str) -> str:
108
+ if requested_type == "number":
109
+ return str(rng.randint(1_000_000, 9_999_999))
110
+ if requested_type == "uuid":
111
+ return _random_uuid(rng)
112
+ return _random_word(rng)
113
+
114
+ keys = [generate(key_type) for _ in range(key_count)]
115
+ values = [[generate(value_type) for _ in range(value_count)] for _ in keys]
116
+ query_indices = rng.sample(range(key_count), query_count)
117
+ probe_rng = random.Random(seed ^ 0xD157AC7)
118
+ probe_pairs = [_distractor_pair(probe_rng, key_type, value_type) for _ in range(16)]
119
+ assert_candidate_parity(
120
+ target_keys=keys,
121
+ distractor_keys=[key for key, _ in probe_pairs],
122
+ target_values=[value for group in values for value in group],
123
+ distractor_values=[value for _, value in probe_pairs],
124
+ )
125
+ label = context_label(bucket)
126
+ return Task(
127
+ id=f"RULER/{label}/{kind}/{sample:03d}",
128
+ prompt="",
129
+ metadata={
130
+ "variant": label,
131
+ "context_tokens": bucket,
132
+ "task_type": kind,
133
+ "seed": seed,
134
+ "haystack": haystack,
135
+ "key_type": key_type,
136
+ "value_type": value_type,
137
+ "keys": keys,
138
+ "values": values,
139
+ "query": [keys[index] for index in query_indices],
140
+ "answers": [value for index in query_indices for value in values[index]],
141
+ },
142
+ )
143
+
144
+
145
+ def _variable_task(bucket: int, sample: int) -> Task:
146
+ seed = _seed(bucket, sample, "variable_tracking")
147
+ rng = random.Random(seed)
148
+ variables = _unique_variables(rng, 5)
149
+ value = str(rng.randint(10_000, 99_999))
150
+ label = context_label(bucket)
151
+ return Task(
152
+ id=f"RULER/{label}/variable_tracking/{sample:02d}",
153
+ prompt="",
154
+ metadata={
155
+ "variant": label,
156
+ "context_tokens": bucket,
157
+ "task_type": "variable_tracking",
158
+ "seed": seed,
159
+ "variables": variables,
160
+ "query": value,
161
+ "answers": variables,
162
+ },
163
+ )
164
+
165
+
166
+ def generate_tasks(*, samples_per_kind: int = DEFAULT_SAMPLES_PER_KIND) -> list[Task]:
167
+ """Create lightweight task specifications for every context bucket."""
168
+ if samples_per_kind < 1:
169
+ raise ValueError("RULER samples per task must be positive")
170
+ tasks: list[Task] = []
171
+ for bucket in CONTEXT_BUCKETS:
172
+ # Keep each sample's 13 task families together so small CLI slices are
173
+ # representative instead of containing only the first NIAH family.
174
+ for sample in range(samples_per_kind):
175
+ for kind in TASK_KINDS:
176
+ tasks.append(
177
+ _variable_task(bucket, sample)
178
+ if kind == "variable_tracking"
179
+ else _niah_task(bucket, sample, kind)
180
+ if kind.startswith("niah_")
181
+ else _aggregation_task(bucket, sample, kind)
182
+ )
183
+ return tasks
184
+
185
+
186
+ def _aggregation_task(bucket: int, sample: int, kind: str) -> Task:
187
+ seed = _seed(bucket, sample, kind)
188
+ answers: list[str] = []
189
+ metadata = {
190
+ "variant": context_label(bucket),
191
+ "context_tokens": bucket,
192
+ "task_type": kind,
193
+ "seed": seed,
194
+ "answers": answers,
195
+ }
196
+ if kind in {"cwe", "fwe"}:
197
+ rng = random.Random(seed)
198
+ vocabulary = [
199
+ "".join(rng.choices(string.ascii_lowercase, k=6)) for _ in range(2000)
200
+ ]
201
+ metadata["vocabulary"] = list(dict.fromkeys(vocabulary))
202
+ metadata["answers"] = metadata["vocabulary"][: 10 if kind == "cwe" else 3]
203
+ elif kind in {"qa_1", "qa_2"}:
204
+ metadata["qa_index"] = sample
205
+ return Task(
206
+ id=f"RULER/{context_label(bucket)}/{kind}/{sample:03d}",
207
+ prompt="",
208
+ metadata=metadata,
209
+ )
210
+
211
+
212
+ _QA_URLS = {
213
+ "qa_1": "https://rajpurkar.github.io/SQuAD-explorer/dataset/dev-v2.0.json",
214
+ "qa_2": "https://huggingface.co/datasets/namlh2004/hotpotqa/resolve/7e54db4656209750ff487f6fdf8e39a66dba136b/hotpot_dev_distractor_v1.json",
215
+ }
216
+
217
+
218
+ def cache_dir() -> Path:
219
+ return Path.home() / ".cache" / "benchkit" / "ruler"
220
+
221
+
222
+ ESSAY_FILE = "PaulGrahamEssays.json"
223
+ QA_FILES = {"qa_1": "squad.json", "qa_2": "hotpotqa.json"}
224
+
225
+
226
+ def corpus_installed() -> bool:
227
+ root = cache_dir()
228
+ return all((root / name).exists() for name in (ESSAY_FILE, *QA_FILES.values()))
229
+
230
+
231
+ def install_corpus() -> None:
232
+ """Fetch the QA corpora; the essay corpus has no pinned download."""
233
+ for kind in QA_FILES:
234
+ _qa_path(kind)
235
+ if not (cache_dir() / ESSAY_FILE).exists():
236
+ raise RuntimeError(
237
+ f"SQuAD and HotpotQA are installed, but {ESSAY_FILE} must be "
238
+ f"generated into {cache_dir()} with NVIDIA/RULER's "
239
+ "download_paulgraham_essay.py"
240
+ )
241
+
242
+
243
+ def _qa_path(kind: str) -> Path:
244
+ root = cache_dir()
245
+ root.mkdir(parents=True, exist_ok=True)
246
+ path = root / QA_FILES[kind]
247
+ if not path.exists():
248
+ try:
249
+ urllib.request.urlretrieve(_QA_URLS[kind], path)
250
+ except OSError as exc:
251
+ raise RuntimeError(
252
+ f"RULER {kind} requires {path}; download failed: {exc}"
253
+ ) from exc
254
+ return path
255
+
256
+
257
+ def _essay_words() -> list[str]:
258
+ root = cache_dir()
259
+ path = root / ESSAY_FILE
260
+ if not path.exists():
261
+ raise RuntimeError(
262
+ f"essay-backed RULER tasks require {ESSAY_FILE} in "
263
+ f"{root}; generate it with NVIDIA/RULER's download_paulgraham_essay.py"
264
+ )
265
+ text = str(json.loads(path.read_text(encoding="utf-8"))["text"])
266
+ return text.split()
267
+
268
+
269
+ def _qa_records(kind: str) -> tuple[list[dict], list[str]]:
270
+ raw = json.loads(_qa_path(kind).read_text(encoding="utf-8"))
271
+ if kind == "qa_1":
272
+ docs = sorted({p["context"] for d in raw["data"] for p in d["paragraphs"]})
273
+ index = {doc: i for i, doc in enumerate(docs)}
274
+ records = [
275
+ {
276
+ "query": qa["question"],
277
+ "answers": [a["text"] for a in qa["answers"]],
278
+ "docs": [index[p["context"]]],
279
+ }
280
+ for d in raw["data"]
281
+ for p in d["paragraphs"]
282
+ for qa in p["qas"]
283
+ if not qa.get("is_impossible")
284
+ ]
285
+ else:
286
+ docs = sorted(
287
+ {
288
+ f"{title}\n{''.join(text)}"
289
+ for row in raw
290
+ for title, text in row["context"]
291
+ }
292
+ )
293
+ index = {doc: i for i, doc in enumerate(docs)}
294
+ records = [
295
+ {
296
+ "query": row["question"],
297
+ "answers": [row["answer"]],
298
+ "docs": [
299
+ index[f"{title}\n{''.join(text)}"] for title, text in row["context"]
300
+ ],
301
+ }
302
+ for row in raw
303
+ ]
304
+ return records, docs
305
+
306
+
307
+ def _render_aggregation(task: Task, filler_units: int) -> str:
308
+ metadata = task.metadata
309
+ kind = metadata["task_type"]
310
+ vocab = list(metadata["vocabulary"])
311
+ rng = random.Random(int(metadata["seed"]))
312
+ if kind == "cwe":
313
+ common = vocab[:10]
314
+ uncommon = vocab[10 : 10 + max(1, filler_units)]
315
+ words = common * 30 + uncommon * 3
316
+ rng.shuffle(words)
317
+ context = " ".join(f"{i + 1}. {word}" for i, word in enumerate(words))
318
+ return f"Below is a numbered list of words. In these words, some appear more often than others. Memorize the ones that appear most often.\n{context}\nQuestion: What are the 10 most common words in the above list?"
319
+ counts = [
320
+ max(1, int(max(10, filler_units) * ((i + 1) ** -2.0)))
321
+ for i in range(len(vocab))
322
+ ]
323
+ words = [
324
+ word for word, count in zip(vocab, counts, strict=True) for _ in range(count)
325
+ ]
326
+ rng.shuffle(words)
327
+ return (
328
+ "Read the following coded text and track the frequency of each coded word. Find the three most frequently appeared coded words. "
329
+ + " ".join(words)
330
+ + "\nQuestion: Do not provide any explanation. Please ignore the dots '....'. What are the three most frequently appeared words in the above coded text?"
331
+ )
332
+
333
+
334
+ def _render_qa(task: Task, filler_units: int) -> str:
335
+ kind = str(task.metadata["task_type"])
336
+ records, docs = _qa_records(kind)
337
+ record = records[int(task.metadata["qa_index"]) % len(records)]
338
+ task.metadata["answers"] = list(record["answers"])
339
+ selected = list(record["docs"])
340
+ rng = random.Random(int(task.metadata["seed"]))
341
+ available = [i for i in range(len(docs)) if i not in selected]
342
+ selected.extend(
343
+ rng.sample(available, min(len(available), max(0, filler_units - len(selected))))
344
+ )
345
+ rendered = [docs[i] for i in selected]
346
+ rng.shuffle(rendered)
347
+ context = "\n\n".join(f"Document {i + 1}:\n{doc}" for i, doc in enumerate(rendered))
348
+ return f"Answer the question based on the given documents. Only give me the answer and do not output any other words.\n\nThe following are given documents.\n\n{context}\n\nAnswer the question based on the given documents. Only give me the answer and do not output any other words.\n\nQuestion: {record['query']}"
349
+
350
+
351
+ def _needle_line(key: str, value: str) -> str:
352
+ return f"One of the special magic numbers for {key} is: {value}."
353
+
354
+
355
+ def _distractor_pair(
356
+ rng: random.Random, key_type: str, value_type: str
357
+ ) -> tuple[str, str]:
358
+ key = _random_uuid(rng) if key_type == "uuid" else _random_word(rng)
359
+ value = (
360
+ _random_uuid(rng)
361
+ if value_type == "uuid"
362
+ else str(rng.randint(1_000_000, 9_999_999))
363
+ )
364
+ return key, value
365
+
366
+
367
+ def _insert_at_positions(filler: list[str], inserts: list[str], seed: int) -> list[str]:
368
+ """Distribute inserts through filler while preserving insert order."""
369
+ rng = random.Random(seed)
370
+ positions = sorted(rng.randrange(len(filler) + 1) for _ in inserts)
371
+ by_position: dict[int, list[str]] = {}
372
+ for position, value in zip(positions, inserts, strict=True):
373
+ by_position.setdefault(position, []).append(value)
374
+
375
+ output: list[str] = []
376
+ for index in range(len(filler) + 1):
377
+ output.extend(by_position.get(index, ()))
378
+ if index < len(filler):
379
+ output.append(filler[index])
380
+ return output
381
+
382
+
383
+ def _render_niah(task: Task, filler_units: int) -> str:
384
+ metadata = task.metadata
385
+ seed = int(metadata["seed"])
386
+ rng = random.Random(seed ^ 0xD157AC7)
387
+ pairs = (
388
+ [
389
+ _distractor_pair(rng, metadata["key_type"], metadata["value_type"])
390
+ for _ in range(filler_units)
391
+ ]
392
+ if metadata["haystack"] == "needle"
393
+ else []
394
+ )
395
+ if pairs:
396
+ filler = [_needle_line(key, value) for key, value in pairs]
397
+ elif metadata["haystack"] == "essay":
398
+ words = _essay_words()
399
+ filler = [words[index % len(words)] for index in range(filler_units)]
400
+ else:
401
+ filler = [_VARIABLE_NOISE] * filler_units
402
+ needles = [
403
+ _needle_line(key, value)
404
+ for key, values in zip(metadata["keys"], metadata["values"], strict=True)
405
+ for value in values
406
+ ]
407
+ assert_candidate_parity(
408
+ target_keys=list(metadata["keys"]),
409
+ distractor_keys=[p[0] for p in pairs],
410
+ target_values=list(metadata["answers"]),
411
+ distractor_values=[p[1] for p in pairs],
412
+ )
413
+ context = "\n".join(_insert_at_positions(filler, needles, seed ^ 0xA51CE))
414
+ return (
415
+ "Some special magic numbers are hidden within the following text. "
416
+ "Memorize the records because you will be asked for one key afterward.\n\n"
417
+ f"{context}\n\n"
418
+ "Question: What is the special magic number for "
419
+ f"{', and '.join(metadata['query'])}?\n"
420
+ "Answer with only the matching number."
421
+ )
422
+
423
+
424
+ def _render_variable_tracking(task: Task, filler_units: int) -> str:
425
+ metadata = task.metadata
426
+ variables = list(metadata["variables"])
427
+ query = str(metadata["query"])
428
+ chain = [f"VAR {variables[0]} = {query}"] + [
429
+ f"VAR {variables[index]} = VAR {variables[index - 1]}"
430
+ for index in range(1, len(variables))
431
+ ]
432
+ filler = [_VARIABLE_NOISE] * filler_units
433
+ context = "\n".join(
434
+ _insert_at_positions(filler, chain, int(metadata["seed"]) ^ 0xC4A1)
435
+ )
436
+ return (
437
+ "Memorize and track the variable assignments hidden in the text. "
438
+ "Assignments are transitive: if VAR BBBBBB = VAR AAAAAA, both variables "
439
+ "have the same value.\n\n"
440
+ "Example:\n"
441
+ "VAR QAZWSX = 12345\n"
442
+ "VAR EDCVRF = VAR QAZWSX\n"
443
+ "For value 12345 the variables are QAZWSX, EDCVRF.\n\n"
444
+ f"{context}\n\n"
445
+ "Question: Find all variables assigned the value "
446
+ f"{query} in the text above.\n"
447
+ "Answer with only the variable names, separated by commas."
448
+ )
449
+
450
+
451
+ def render_prompt(task: Task, filler_units: int) -> str:
452
+ """Render a task with the requested number of synthetic filler units."""
453
+ kind = task.metadata["task_type"]
454
+ if str(kind).startswith("niah_"):
455
+ return _render_niah(task, max(0, filler_units))
456
+ if kind == "variable_tracking":
457
+ return _render_variable_tracking(task, max(0, filler_units))
458
+ if kind in {"cwe", "fwe"}:
459
+ return _render_aggregation(task, max(0, filler_units))
460
+ if kind in {"qa_1", "qa_2"}:
461
+ return _render_qa(task, max(1, filler_units))
462
+ raise ValueError(f"Unknown RULER task type: {kind}")
463
+
464
+
465
+ def _fallback_units(task: Task, target_tokens: int) -> int:
466
+ base = render_prompt(task, 0)
467
+ one = render_prompt(task, 1)
468
+ chars_per_unit = max(1, len(one) - len(base))
469
+ target_chars = target_tokens * FALLBACK_CHARS_PER_TOKEN
470
+ return max(0, (target_chars - len(base)) // chars_per_unit)
471
+
472
+
473
+ def fit_prompt(
474
+ task: Task,
475
+ token_count: Callable[[str], int | None] | None = None,
476
+ *,
477
+ initial_units: int | None = None,
478
+ ) -> tuple[str, int, int | None]:
479
+ """Fit a generated prompt just below its context bucket.
480
+
481
+ When the server exposes a tokenizer, a short secant-style calibration keeps
482
+ the prompt within one percent of the target. Generic servers fall back to a
483
+ conservative character-to-token estimate.
484
+ """
485
+ bucket = int(task.metadata["context_tokens"])
486
+ target = max(1, bucket - OUTPUT_RESERVE_TOKENS)
487
+ units = (
488
+ max(0, initial_units)
489
+ if initial_units is not None
490
+ else _fallback_units(task, target)
491
+ )
492
+ prompt = render_prompt(task, units)
493
+ if token_count is None:
494
+ return prompt, units, None
495
+
496
+ measured = token_count(prompt)
497
+ if not isinstance(measured, int) or measured <= 0:
498
+ return prompt, units, None
499
+
500
+ base_tokens: int | None = None
501
+ for _ in range(4):
502
+ if measured <= target and measured >= target * (1 - FIT_TOLERANCE):
503
+ break
504
+ if base_tokens is None:
505
+ base_tokens = token_count(render_prompt(task, 0))
506
+ if not isinstance(base_tokens, int) or base_tokens < 0:
507
+ base_tokens = 0
508
+
509
+ variable_tokens = max(1, measured - base_tokens)
510
+ tokens_per_unit = variable_tokens / max(units, 1)
511
+ next_units = max(0, math.floor((target - base_tokens) / tokens_per_unit))
512
+ if next_units == units:
513
+ next_units = max(0, units - 1) if measured > target else units + 1
514
+ units = next_units
515
+ prompt = render_prompt(task, units)
516
+ next_measured = token_count(prompt)
517
+ if not isinstance(next_measured, int) or next_measured <= 0:
518
+ return prompt, units, None
519
+ measured = next_measured
520
+
521
+ # Never knowingly send a prompt above the bucket. The linear adjustment is
522
+ # normally exact; this final correction handles unusual tokenizer merges.
523
+ for _ in range(2):
524
+ if measured <= target or units == 0:
525
+ break
526
+ excess = measured - target
527
+ tokens_per_unit = max(1.0, measured / max(units, 1))
528
+ units = max(0, units - math.ceil(excess / tokens_per_unit) - 1)
529
+ prompt = render_prompt(task, units)
530
+ next_measured = token_count(prompt)
531
+ if not isinstance(next_measured, int) or next_measured <= 0:
532
+ return prompt, units, None
533
+ measured = next_measured
534
+
535
+ return prompt, units, measured