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
benchkit/looping.py ADDED
@@ -0,0 +1,873 @@
1
+ """Deterministic detection of repetitive model generations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from dataclasses import asdict, dataclass, replace
8
+ from typing import Literal
9
+
10
+ LoopState = Literal["unavailable", "observing", "clear", "suspected", "looping"]
11
+ LoopSource = Literal["none", "thinking", "answer"]
12
+ LoopEvidence = Literal[
13
+ "none",
14
+ "global_repetition",
15
+ "exact_cycle",
16
+ "numeric_cycle",
17
+ "structural_cycle",
18
+ ]
19
+
20
+ _WORD_RE = re.compile(r"[^\W_]+(?:['’.-][^\W_]+)*", re.UNICODE)
21
+ _TOKEN_RE = re.compile(r"\w+|[^\w\s]", re.UNICODE)
22
+ _NUMBER_RE = re.compile(r"\d+")
23
+ _CODE_LINE_RE = re.compile(
24
+ r"(?m)^\s*(?:def|class|if|elif|else\s*:|for|while|try\s*:|except\b|"
25
+ r"return\b|assert\b|import\b|from\b|#include\b|function\b|"
26
+ r"public\b|private\b|protected\b|const\b|let\b|var\b)"
27
+ )
28
+ _OPEN_THINK = "<think>"
29
+ _CLOSE_THINK = "</think>"
30
+ _ANALYSIS_WORD_LIMIT = 4096
31
+ _ANALYSIS_TOKEN_LIMIT = 4096
32
+ _ANALYSIS_CHAR_LIMIT = 131_072
33
+ _MAX_CYCLE_PERIOD = 1024
34
+ _MAX_ACTIONABLE_PERIOD = 512
35
+ _ACTIVE_TAIL_SLACK = 2
36
+ _ADVISORY_TAIL_SLACK = 8
37
+ _CONFIRM_GROWTH_CHARS = 256
38
+ _CONFIRM_GROWTH_TOKENS = 32
39
+ _CODE_KEYWORDS = {
40
+ "and",
41
+ "as",
42
+ "assert",
43
+ "async",
44
+ "await",
45
+ "break",
46
+ "case",
47
+ "catch",
48
+ "class",
49
+ "const",
50
+ "continue",
51
+ "def",
52
+ "default",
53
+ "del",
54
+ "do",
55
+ "elif",
56
+ "else",
57
+ "except",
58
+ "false",
59
+ "finally",
60
+ "for",
61
+ "from",
62
+ "function",
63
+ "if",
64
+ "import",
65
+ "in",
66
+ "is",
67
+ "lambda",
68
+ "let",
69
+ "match",
70
+ "new",
71
+ "none",
72
+ "not",
73
+ "null",
74
+ "or",
75
+ "pass",
76
+ "private",
77
+ "protected",
78
+ "public",
79
+ "raise",
80
+ "return",
81
+ "static",
82
+ "switch",
83
+ "throw",
84
+ "true",
85
+ "try",
86
+ "var",
87
+ "while",
88
+ "with",
89
+ "yield",
90
+ }
91
+ _SEVERITY: dict[LoopState, int] = {
92
+ "unavailable": 0,
93
+ "observing": 1,
94
+ "clear": 1,
95
+ "suspected": 2,
96
+ "looping": 3,
97
+ }
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class LoopSnapshot:
102
+ """Current repetition signals for one generation."""
103
+
104
+ state: LoopState
105
+ score: float
106
+ source: LoopSource
107
+ analyzed_words: int
108
+ repeated_ngram_coverage: float
109
+ max_window_similarity: float
110
+ low_novelty_windows: int
111
+ max_repeated_block: int
112
+ evidence: LoopEvidence = "none"
113
+ active_cycle: bool = False
114
+ confirmed_cycle: bool = False
115
+ recovered_cycle: bool = False
116
+ cycle_period_tokens: int = 0
117
+ cycle_repetitions: int = 0
118
+ repeated_suffix_tokens: int = 0
119
+ cycle_tail_tokens: int = 0
120
+ evidence_growth_chars: int = 0
121
+ evidence_growth_tokens: int = 0
122
+
123
+ def as_dict(self) -> dict:
124
+ return asdict(self)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class _Cycle:
129
+ """One suffix-anchored periodic sequence."""
130
+
131
+ period: int
132
+ repetitions: int
133
+ span: int
134
+ tail: int
135
+ signature: tuple[str, ...]
136
+
137
+
138
+ @dataclass
139
+ class _CycleTracker:
140
+ """Growth state for a live high-confidence suffix cycle."""
141
+
142
+ fingerprint: tuple[LoopEvidence, int, tuple[str, ...]] | None = None
143
+ first_chars: int = 0
144
+ first_span: int = 0
145
+ confirmations: int = 0
146
+
147
+ def reset(self) -> None:
148
+ self.fingerprint = None
149
+ self.first_chars = 0
150
+ self.first_span = 0
151
+ self.confirmations = 0
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class _TextAnalysis:
156
+ snapshot: LoopSnapshot
157
+ fingerprint: tuple[LoopEvidence, int, tuple[str, ...]] | None = None
158
+
159
+
160
+ class InlineThinkingParser:
161
+ """Split streamed ``<think>`` content even when tags cross chunks."""
162
+
163
+ def __init__(self) -> None:
164
+ self._state: Literal["start", "thinking", "answer"] = "start"
165
+ self._buffer = ""
166
+ self.saw_marker = False
167
+
168
+ def feed(self, text: str) -> tuple[str, str]:
169
+ """Return newly available ``(thinking, answer)`` text."""
170
+ if not text:
171
+ return "", ""
172
+ self._buffer += text
173
+
174
+ if self._state == "start":
175
+ stripped = self._buffer.lstrip()
176
+ if not stripped:
177
+ return "", ""
178
+ if _OPEN_THINK.startswith(stripped):
179
+ return "", ""
180
+ if stripped.startswith(_OPEN_THINK):
181
+ self.saw_marker = True
182
+ self._state = "thinking"
183
+ self._buffer = stripped[len(_OPEN_THINK) :]
184
+ else:
185
+ self._state = "answer"
186
+
187
+ if self._state == "answer":
188
+ answer, self._buffer = self._buffer, ""
189
+ return "", answer
190
+
191
+ closing = self._buffer.find(_CLOSE_THINK)
192
+ if closing >= 0:
193
+ thinking = self._buffer[:closing]
194
+ answer = self._buffer[closing + len(_CLOSE_THINK) :]
195
+ self._buffer = ""
196
+ self._state = "answer"
197
+ return thinking, answer
198
+
199
+ keep = _partial_suffix_length(self._buffer, _CLOSE_THINK)
200
+ if keep:
201
+ thinking = self._buffer[:-keep]
202
+ self._buffer = self._buffer[-keep:]
203
+ else:
204
+ thinking, self._buffer = self._buffer, ""
205
+ return thinking, ""
206
+
207
+ def finish(self) -> tuple[str, str]:
208
+ """Flush buffered text when the stream ends."""
209
+ if self._state == "thinking":
210
+ thinking, answer = self._buffer, ""
211
+ else:
212
+ thinking, answer = "", self._buffer
213
+ self._buffer = ""
214
+ return thinking, answer
215
+
216
+
217
+ class LoopAnalyzer:
218
+ """Incrementally retain text and calculate bounded-cost loop signals."""
219
+
220
+ def __init__(self) -> None:
221
+ self._thinking_tail = ""
222
+ self._answer_tail = ""
223
+ self._thinking_pending: list[str] = []
224
+ self._answer_pending: list[str] = []
225
+ self.thinking_chars = 0
226
+ self.answer_chars = 0
227
+ self._trackers: dict[LoopSource, _CycleTracker] = {
228
+ "thinking": _CycleTracker(),
229
+ "answer": _CycleTracker(),
230
+ }
231
+ self._last_confirmed_cycle: LoopSnapshot | None = None
232
+
233
+ def add(self, *, thinking: str = "", answer: str = "") -> None:
234
+ if thinking:
235
+ self._thinking_pending.append(thinking)
236
+ self.thinking_chars += len(thinking)
237
+ if answer:
238
+ self._answer_pending.append(answer)
239
+ self.answer_chars += len(answer)
240
+
241
+ def reconcile(self, *, thinking: str = "", answer: str = "") -> bool:
242
+ """Append final text omitted from callbacks if it matches the stream.
243
+
244
+ ``False`` means the provider's final payload disagrees with streamed
245
+ content, so callers should rebuild analysis from that authoritative
246
+ payload instead of combining incompatible generations.
247
+ """
248
+ thinking_suffix = _unseen_suffix(
249
+ retained=self.thinking,
250
+ streamed_chars=self.thinking_chars,
251
+ final_text=thinking,
252
+ )
253
+ answer_suffix = _unseen_suffix(
254
+ retained=self.answer,
255
+ streamed_chars=self.answer_chars,
256
+ final_text=answer,
257
+ )
258
+ if thinking_suffix is None or answer_suffix is None:
259
+ return False
260
+ self.add(thinking=thinking_suffix, answer=answer_suffix)
261
+ return True
262
+
263
+ @property
264
+ def thinking(self) -> str:
265
+ self._thinking_tail = _materialize_tail(
266
+ self._thinking_tail,
267
+ self._thinking_pending,
268
+ )
269
+ return self._thinking_tail
270
+
271
+ @property
272
+ def answer(self) -> str:
273
+ self._answer_tail = _materialize_tail(
274
+ self._answer_tail,
275
+ self._answer_pending,
276
+ )
277
+ return self._answer_tail
278
+
279
+ def snapshot(self, *, final: bool = False) -> LoopSnapshot:
280
+ if not self.thinking_chars and not self.answer_chars:
281
+ return LoopSnapshot(
282
+ state="unavailable",
283
+ score=0.0,
284
+ source="none",
285
+ analyzed_words=0,
286
+ repeated_ngram_coverage=0.0,
287
+ max_window_similarity=0.0,
288
+ low_novelty_windows=0,
289
+ max_repeated_block=0,
290
+ )
291
+
292
+ if not final:
293
+ # Only the channel currently receiving visible output is actionable.
294
+ # Historical thinking must not kill a healthy answer after the model
295
+ # has moved on from its reasoning phase.
296
+ source: LoopSource = "answer" if self.answer_chars else "thinking"
297
+ text = self.answer if source == "answer" else self.thinking
298
+ total_chars = (
299
+ self.answer_chars if source == "answer" else self.thinking_chars
300
+ )
301
+ analysis = _analyze_text(text, source, final=False)
302
+ return self._track_live_cycle(analysis, source, total_chars)
303
+
304
+ # A final report retains genuine repetition from either channel, while
305
+ # live action above remains scoped to the active channel.
306
+ thinking_snapshot: LoopSnapshot | None = None
307
+ answer_snapshot: LoopSnapshot | None = None
308
+ if self.thinking_chars:
309
+ thinking_snapshot = _analyze_text(
310
+ self.thinking,
311
+ "thinking",
312
+ final=True,
313
+ ).snapshot
314
+ if self.answer_chars:
315
+ answer_snapshot = _analyze_text(
316
+ self.answer,
317
+ "answer",
318
+ final=True,
319
+ ).snapshot
320
+
321
+ # A visible answer cycle is never hidden by a worse thinking score.
322
+ if answer_snapshot is not None and answer_snapshot.active_cycle:
323
+ return answer_snapshot
324
+
325
+ if thinking_snapshot is not None and thinking_snapshot.active_cycle:
326
+ if answer_snapshot is None:
327
+ return thinking_snapshot
328
+ return replace(
329
+ thinking_snapshot,
330
+ state="suspected",
331
+ active_cycle=False,
332
+ confirmed_cycle=False,
333
+ recovered_cycle=True,
334
+ )
335
+
336
+ # Preserve a cycle that was confirmed during streaming but later broke
337
+ # before the generation ended. It is useful diagnostic evidence, but it
338
+ # is neither actionable nor counted as a final loop.
339
+ if self._last_confirmed_cycle is not None:
340
+ return replace(
341
+ self._last_confirmed_cycle,
342
+ state="suspected",
343
+ active_cycle=False,
344
+ confirmed_cycle=False,
345
+ recovered_cycle=True,
346
+ )
347
+
348
+ candidates = [
349
+ snapshot
350
+ for snapshot in (thinking_snapshot, answer_snapshot)
351
+ if snapshot is not None
352
+ ]
353
+ selected = max(
354
+ candidates,
355
+ key=lambda snap: (_SEVERITY[snap.state], snap.score),
356
+ )
357
+ return selected
358
+
359
+ def _track_live_cycle(
360
+ self,
361
+ analysis: _TextAnalysis,
362
+ source: LoopSource,
363
+ total_chars: int,
364
+ ) -> LoopSnapshot:
365
+ snapshot = analysis.snapshot
366
+ tracker = self._trackers[source]
367
+ fingerprint = analysis.fingerprint
368
+ if not snapshot.active_cycle or fingerprint is None:
369
+ tracker.reset()
370
+ return snapshot
371
+
372
+ if tracker.fingerprint != fingerprint:
373
+ tracker.fingerprint = fingerprint
374
+ tracker.first_chars = total_chars
375
+ tracker.first_span = snapshot.repeated_suffix_tokens
376
+ tracker.confirmations = 1
377
+ else:
378
+ tracker.confirmations += 1
379
+
380
+ growth_chars = max(0, total_chars - tracker.first_chars)
381
+ growth_tokens = max(
382
+ 0,
383
+ snapshot.repeated_suffix_tokens - tracker.first_span,
384
+ )
385
+ confirmed = (
386
+ tracker.confirmations >= 2
387
+ and growth_chars >= _CONFIRM_GROWTH_CHARS
388
+ and (
389
+ growth_tokens >= _CONFIRM_GROWTH_TOKENS
390
+ or snapshot.repeated_suffix_tokens >= _ANALYSIS_TOKEN_LIMIT * 0.9
391
+ )
392
+ )
393
+ tracked = replace(
394
+ snapshot,
395
+ state="looping" if confirmed else "suspected",
396
+ confirmed_cycle=confirmed,
397
+ evidence_growth_chars=growth_chars,
398
+ evidence_growth_tokens=growth_tokens,
399
+ )
400
+ if confirmed:
401
+ self._last_confirmed_cycle = tracked
402
+ return tracked
403
+
404
+
405
+ def _analyze_text(
406
+ text: str,
407
+ source: LoopSource,
408
+ *,
409
+ final: bool,
410
+ ) -> _TextAnalysis:
411
+ """Analyze one visible generation channel."""
412
+
413
+ words = _words(text)[-_ANALYSIS_WORD_LIMIT:]
414
+ tokens = _tokens(text)[-_ANALYSIS_TOKEN_LIMIT:]
415
+ count = len(words)
416
+ token_count = len(tokens)
417
+ coverage = _repeated_ngram_coverage(words, size=8) if count >= 8 else 0.0
418
+ similarity, low_novelty = _window_novelty(words) if count >= 128 else (0.0, 0)
419
+ repeated_block = _max_repeated_block(words, size=20) if count >= 20 else 0
420
+
421
+ repeat_strength = _scaled(coverage, 0.08, 0.50)
422
+ similarity_strength = _scaled(similarity, 0.55, 0.95)
423
+ run_strength = min(1.0, low_novelty / 3)
424
+ block_strength = min(1.0, max(0, repeated_block - 1) / 3)
425
+ blended = (
426
+ 0.35 * repeat_strength
427
+ + 0.30 * similarity_strength
428
+ + 0.20 * run_strength
429
+ + 0.15 * block_strength
430
+ )
431
+ global_score = round(min(0.69, blended), 3)
432
+ global_suspected = count >= 128 and (
433
+ coverage >= 0.18 or repeated_block >= 2 or low_novelty >= 1 or blended >= 0.45
434
+ )
435
+
436
+ raw_cycles = _suffix_cycle_candidates(tokens)
437
+ numeric_tokens = [_normalize_numbers(token) for token in tokens]
438
+ numeric_cycles = _suffix_cycle_candidates(numeric_tokens)
439
+ exact = _best_cycle(
440
+ raw_cycles,
441
+ max_period=_MAX_ACTIONABLE_PERIOD,
442
+ max_tail=_ACTIVE_TAIL_SLACK,
443
+ min_repetitions=4,
444
+ min_span=64,
445
+ )
446
+ numeric = _best_cycle(
447
+ numeric_cycles,
448
+ max_period=_MAX_ACTIONABLE_PERIOD,
449
+ max_tail=_ACTIVE_TAIL_SLACK,
450
+ min_repetitions=6,
451
+ min_span=96,
452
+ )
453
+ # A changing counter is only high-confidence when literal language is also
454
+ # overwhelmingly repetitive. This keeps numbered tables and varied tests
455
+ # from becoming actionable merely because their shapes match.
456
+ if numeric is not None and coverage < 0.65:
457
+ numeric = None
458
+
459
+ active_cycle = exact or numeric
460
+ evidence: LoopEvidence = "none"
461
+ fingerprint: tuple[LoopEvidence, int, tuple[str, ...]] | None = None
462
+ if active_cycle is not None:
463
+ evidence = "exact_cycle" if exact is not None else "numeric_cycle"
464
+ cycle_score = _cycle_score(active_cycle)
465
+ snapshot = _snapshot(
466
+ state="looping" if final else "suspected",
467
+ score=cycle_score,
468
+ source=source,
469
+ count=count,
470
+ coverage=coverage,
471
+ similarity=similarity,
472
+ low_novelty=low_novelty,
473
+ repeated_block=repeated_block,
474
+ evidence=evidence,
475
+ cycle=active_cycle,
476
+ active_cycle=True,
477
+ )
478
+ fingerprint = (evidence, active_cycle.period, active_cycle.signature)
479
+ return _TextAnalysis(snapshot, fingerprint)
480
+
481
+ advisory_cycles: list[tuple[LoopEvidence, _Cycle]] = []
482
+ raw_advisory = _best_cycle(
483
+ raw_cycles,
484
+ max_period=_MAX_CYCLE_PERIOD,
485
+ max_tail=_ADVISORY_TAIL_SLACK,
486
+ min_repetitions=3,
487
+ min_span=48,
488
+ )
489
+ numeric_advisory = _best_cycle(
490
+ numeric_cycles,
491
+ max_period=_MAX_CYCLE_PERIOD,
492
+ max_tail=_ADVISORY_TAIL_SLACK,
493
+ min_repetitions=4,
494
+ min_span=64,
495
+ )
496
+ if raw_advisory is not None:
497
+ advisory_cycles.append(("exact_cycle", raw_advisory))
498
+ if numeric_advisory is not None and coverage >= 0.18:
499
+ advisory_cycles.append(("numeric_cycle", numeric_advisory))
500
+
501
+ if _looks_like_code(text):
502
+ structural_cycles = _suffix_cycle_candidates(_structural_tokens(text))
503
+ structural = _best_cycle(
504
+ structural_cycles,
505
+ max_period=_MAX_CYCLE_PERIOD,
506
+ max_tail=_ADVISORY_TAIL_SLACK,
507
+ min_repetitions=4,
508
+ min_span=64,
509
+ )
510
+ if structural is not None:
511
+ advisory_cycles.append(("structural_cycle", structural))
512
+
513
+ advisory = _strongest_advisory(advisory_cycles)
514
+ if advisory is not None:
515
+ evidence, cycle = advisory
516
+ advisory_score = _advisory_cycle_score(cycle)
517
+ return _TextAnalysis(
518
+ _snapshot(
519
+ state="suspected",
520
+ score=max(global_score, advisory_score),
521
+ source=source,
522
+ count=count,
523
+ coverage=coverage,
524
+ similarity=similarity,
525
+ low_novelty=low_novelty,
526
+ repeated_block=repeated_block,
527
+ evidence=evidence,
528
+ cycle=cycle,
529
+ )
530
+ )
531
+
532
+ state: LoopState
533
+ if global_suspected:
534
+ state = "suspected"
535
+ evidence = "global_repetition"
536
+ elif final:
537
+ state = "clear"
538
+ else:
539
+ state = "observing" if token_count < 64 else "clear"
540
+
541
+ return _TextAnalysis(
542
+ _snapshot(
543
+ state=state,
544
+ score=global_score,
545
+ source=source,
546
+ count=count,
547
+ coverage=coverage,
548
+ similarity=similarity,
549
+ low_novelty=low_novelty,
550
+ repeated_block=repeated_block,
551
+ evidence=evidence,
552
+ )
553
+ )
554
+
555
+
556
+ def _snapshot(
557
+ *,
558
+ state: LoopState,
559
+ score: float,
560
+ source: LoopSource,
561
+ count: int,
562
+ coverage: float,
563
+ similarity: float,
564
+ low_novelty: int,
565
+ repeated_block: int,
566
+ evidence: LoopEvidence = "none",
567
+ cycle: _Cycle | None = None,
568
+ active_cycle: bool = False,
569
+ ) -> LoopSnapshot:
570
+ return LoopSnapshot(
571
+ state=state,
572
+ score=round(score, 3),
573
+ source=source,
574
+ analyzed_words=count,
575
+ repeated_ngram_coverage=round(coverage, 3),
576
+ max_window_similarity=round(similarity, 3),
577
+ low_novelty_windows=low_novelty,
578
+ max_repeated_block=repeated_block,
579
+ evidence=evidence,
580
+ active_cycle=active_cycle,
581
+ cycle_period_tokens=cycle.period if cycle else 0,
582
+ cycle_repetitions=cycle.repetitions if cycle else 0,
583
+ repeated_suffix_tokens=cycle.span if cycle else 0,
584
+ cycle_tail_tokens=cycle.tail if cycle else 0,
585
+ )
586
+
587
+
588
+ def _tokens(text: str) -> list[str]:
589
+ return [match.group(0).casefold() for match in _TOKEN_RE.finditer(text)]
590
+
591
+
592
+ def _normalize_numbers(token: str) -> str:
593
+ return "<num>" if _NUMBER_RE.fullmatch(token) else token
594
+
595
+
596
+ def _suffix_cycle_candidates(tokens: list[str]) -> list[_Cycle]:
597
+ """Return exact periodic sequences ending at, or very near, the suffix.
598
+
599
+ A Z-array over reversed tokens finds every prior copy of the current suffix
600
+ in linear time. This is the same suffix-matching primitive used by DRY
601
+ sampling, with stricter repetition thresholds applied by this detector.
602
+ """
603
+ count = len(tokens)
604
+ if count < 32:
605
+ return []
606
+
607
+ candidates: list[_Cycle] = []
608
+ for tail in range(min(_ADVISORY_TAIL_SLACK, count - 1) + 1):
609
+ end = count - tail
610
+ reversed_tokens = list(reversed(tokens[:end]))
611
+ suffix_matches = _z_values(reversed_tokens)
612
+ primitive_periods: list[int] = []
613
+ max_period = min(_MAX_CYCLE_PERIOD, end - 1)
614
+ for period in range(1, max_period + 1):
615
+ if any(period % primitive == 0 for primitive in primitive_periods):
616
+ continue
617
+
618
+ matched = min(suffix_matches[period], end - period)
619
+ span = matched + period if matched else 0
620
+ repetitions = span // period if period else 0
621
+ if repetitions < 2 or span < 32:
622
+ continue
623
+
624
+ primitive_periods.append(period)
625
+ pattern = tokens[end - period : end]
626
+ candidates.append(
627
+ _Cycle(
628
+ period=period,
629
+ repetitions=repetitions,
630
+ span=span,
631
+ tail=tail,
632
+ signature=_cycle_signature(pattern),
633
+ )
634
+ )
635
+ return candidates
636
+
637
+
638
+ def _z_values(sequence: list[str]) -> list[int]:
639
+ """Return longest prefix matches at every sequence offset in O(n)."""
640
+ count = len(sequence)
641
+ if not count:
642
+ return []
643
+ values = [0] * count
644
+ values[0] = count
645
+ left = 0
646
+ right = 0
647
+ for index in range(1, count):
648
+ if index <= right:
649
+ values[index] = min(right - index + 1, values[index - left])
650
+ while (
651
+ index + values[index] < count
652
+ and sequence[values[index]] == sequence[index + values[index]]
653
+ ):
654
+ values[index] += 1
655
+ match_right = index + values[index] - 1
656
+ if match_right > right:
657
+ left = index
658
+ right = match_right
659
+ return values
660
+
661
+
662
+ def _cycle_signature(pattern: list[str]) -> tuple[str, ...]:
663
+ """Build an exact, rotation-invariant signature for one cycle."""
664
+ if not pattern:
665
+ return ()
666
+
667
+ # Booth's algorithm avoids both hash collisions and ambiguity between
668
+ # distinct cycles that happen to contain the same token bigrams.
669
+ sequence = tuple(pattern)
670
+ doubled = sequence + sequence
671
+ count = len(sequence)
672
+ left = 0
673
+ right = 1
674
+ offset = 0
675
+ while left < count and right < count and offset < count:
676
+ left_token = doubled[left + offset]
677
+ right_token = doubled[right + offset]
678
+ if left_token == right_token:
679
+ offset += 1
680
+ continue
681
+ if left_token > right_token:
682
+ left = left + offset + 1
683
+ if left == right:
684
+ left += 1
685
+ else:
686
+ right = right + offset + 1
687
+ if left == right:
688
+ right += 1
689
+ offset = 0
690
+ start = min(left, right)
691
+ return doubled[start : start + count]
692
+
693
+
694
+ def _best_cycle(
695
+ candidates: list[_Cycle],
696
+ *,
697
+ max_period: int,
698
+ max_tail: int,
699
+ min_repetitions: int,
700
+ min_span: int,
701
+ ) -> _Cycle | None:
702
+ eligible = [
703
+ cycle
704
+ for cycle in candidates
705
+ if cycle.period <= max_period
706
+ and cycle.tail <= max_tail
707
+ and cycle.repetitions >= min_repetitions
708
+ and cycle.span >= min_span
709
+ ]
710
+ if not eligible:
711
+ return None
712
+ maximum_span = max(cycle.span for cycle in eligible)
713
+ near_maximum = [cycle for cycle in eligible if cycle.span >= maximum_span * 0.9]
714
+ return max(
715
+ near_maximum,
716
+ key=lambda cycle: (
717
+ cycle.repetitions,
718
+ cycle.span,
719
+ -cycle.period,
720
+ -cycle.tail,
721
+ ),
722
+ )
723
+
724
+
725
+ def _cycle_score(cycle: _Cycle) -> float:
726
+ repetition_strength = _scaled(cycle.repetitions, 4, 8)
727
+ span_strength = _scaled(cycle.span, 64, 256)
728
+ return 0.80 + 0.10 * repetition_strength + 0.10 * span_strength
729
+
730
+
731
+ def _advisory_cycle_score(cycle: _Cycle) -> float:
732
+ repetition_strength = _scaled(cycle.repetitions, 3, 8)
733
+ span_strength = _scaled(cycle.span, 48, 256)
734
+ return min(0.74, 0.55 + 0.10 * repetition_strength + 0.09 * span_strength)
735
+
736
+
737
+ def _strongest_advisory(
738
+ candidates: list[tuple[LoopEvidence, _Cycle]],
739
+ ) -> tuple[LoopEvidence, _Cycle] | None:
740
+ if not candidates:
741
+ return None
742
+ priority = {"exact_cycle": 3, "numeric_cycle": 2, "structural_cycle": 1}
743
+ return max(
744
+ candidates,
745
+ key=lambda item: (
746
+ _advisory_cycle_score(item[1]),
747
+ priority.get(item[0], 0),
748
+ item[1].span,
749
+ ),
750
+ )
751
+
752
+
753
+ def _looks_like_code(text: str) -> bool:
754
+ tail = text[-16_384:]
755
+ return "```" in tail or len(_CODE_LINE_RE.findall(tail)) >= 3
756
+
757
+
758
+ def _structural_tokens(text: str) -> list[str]:
759
+ structured: list[str] = []
760
+ for line in text[-_ANALYSIS_CHAR_LIMIT:].splitlines()[-512:]:
761
+ indent = len(line) - len(line.lstrip())
762
+ structured.append(f"<indent:{min(8, indent // 4)}>")
763
+ for token in _tokens(line):
764
+ if _NUMBER_RE.search(token):
765
+ structured.append("<num>")
766
+ elif token.replace("_", "").isalnum() and token not in _CODE_KEYWORDS:
767
+ structured.append("<id>")
768
+ else:
769
+ structured.append(token)
770
+ structured.append("<nl>")
771
+ return structured[-_ANALYSIS_TOKEN_LIMIT:]
772
+
773
+
774
+ def _words(text: str) -> list[str]:
775
+ return [match.group(0).casefold() for match in _WORD_RE.finditer(text)]
776
+
777
+
778
+ def _materialize_tail(current: str, pending: list[str]) -> str:
779
+ if not pending:
780
+ return current
781
+ text = (current + "".join(pending))[-_ANALYSIS_CHAR_LIMIT:]
782
+ pending.clear()
783
+ return text
784
+
785
+
786
+ def _unseen_suffix(
787
+ *,
788
+ retained: str,
789
+ streamed_chars: int,
790
+ final_text: str,
791
+ ) -> str | None:
792
+ """Return the part of a matching final payload not seen while streaming."""
793
+ if streamed_chars > len(final_text):
794
+ return None
795
+ retained_start = max(0, streamed_chars - len(retained))
796
+ if final_text[retained_start:streamed_chars] != retained:
797
+ return None
798
+ return final_text[streamed_chars:]
799
+
800
+
801
+ def _partial_suffix_length(text: str, marker: str) -> int:
802
+ limit = min(len(text), len(marker) - 1)
803
+ for size in range(limit, 0, -1):
804
+ if text.endswith(marker[:size]):
805
+ return size
806
+ return 0
807
+
808
+
809
+ def _repeated_ngram_coverage(words: list[str], *, size: int) -> float:
810
+ if len(words) < size:
811
+ return 0.0
812
+ ngrams = [
813
+ tuple(words[index : index + size]) for index in range(len(words) - size + 1)
814
+ ]
815
+ counts = Counter(ngrams)
816
+ covered = bytearray(len(words))
817
+ for index, ngram in enumerate(ngrams):
818
+ if counts[ngram] > 1:
819
+ covered[index : index + size] = b"\x01" * size
820
+ return sum(covered) / len(words)
821
+
822
+
823
+ def _window_novelty(words: list[str]) -> tuple[float, int]:
824
+ size = 64
825
+ if len(words) < size * 2:
826
+ return 0.0, 0
827
+
828
+ windows = [
829
+ _shingles(words[index : index + size], size=3)
830
+ for index in range(0, len(words) - size + 1, size)
831
+ ]
832
+ maximum = 0.0
833
+ longest_run = 0
834
+ current_run = 0
835
+ for index in range(1, len(windows)):
836
+ similarity = max(
837
+ (_jaccard(windows[index], previous) for previous in windows[:index]),
838
+ default=0.0,
839
+ )
840
+ maximum = max(maximum, similarity)
841
+ if similarity >= 0.72:
842
+ current_run += 1
843
+ longest_run = max(longest_run, current_run)
844
+ else:
845
+ current_run = 0
846
+ return maximum, longest_run
847
+
848
+
849
+ def _shingles(words: list[str], *, size: int) -> set[tuple[str, ...]]:
850
+ return {
851
+ tuple(words[index : index + size])
852
+ for index in range(max(0, len(words) - size + 1))
853
+ }
854
+
855
+
856
+ def _jaccard(left: set, right: set) -> float:
857
+ union = left | right
858
+ return len(left & right) / len(union) if union else 0.0
859
+
860
+
861
+ def _max_repeated_block(words: list[str], *, size: int) -> int:
862
+ if len(words) < size:
863
+ return 0
864
+ counts = Counter(
865
+ tuple(words[index : index + size]) for index in range(len(words) - size + 1)
866
+ )
867
+ return max(counts.values(), default=0)
868
+
869
+
870
+ def _scaled(value: float, floor: float, ceiling: float) -> float:
871
+ if value <= floor:
872
+ return 0.0
873
+ return min(1.0, (value - floor) / (ceiling - floor))