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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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))
|