crprotocol 2.0.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 (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,419 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Stitch algorithm — echo detection, content-aware stitching, validation (§4.8, §04 §3.4)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+
11
+
12
+ class ContentBoundary(str, Enum):
13
+ """Content type for boundary-aware stitching."""
14
+
15
+ PROSE = "prose"
16
+ CODE = "code"
17
+ MARKDOWN = "markdown"
18
+ STRUCTURED = "structured"
19
+
20
+
21
+ @dataclass
22
+ class StitchResult:
23
+ """Result of stitching two outputs together."""
24
+
25
+ text: str
26
+ echo_removed: int # chars of echo removed
27
+ boundary_type: ContentBoundary
28
+ bridge_inserted: bool
29
+ trimmed_fragments: list[str] # never silently discard
30
+ validation_warnings: list[str]
31
+
32
+
33
+ @dataclass
34
+ class StitchConfig:
35
+ """Configuration for the stitch algorithm."""
36
+
37
+ echo_window: int = 2000 # chars to compare for echo
38
+ min_echo_length: int = 20 # minimum echo to detect
39
+ semantic_echo_threshold: float = 0.85
40
+ max_bridge_tokens: int = 50
41
+
42
+
43
+ # ── LCS-based echo detection (§04 §3.4) ──────────────────────────
44
+
45
+ def _longest_common_suffix_prefix(prior_tail: str, continuation_head: str) -> str:
46
+ """Find the longest common substring where prior ends and continuation starts.
47
+
48
+ Uses a suffix-of-prior / prefix-of-continuation match approach.
49
+ """
50
+ if not prior_tail or not continuation_head:
51
+ return ""
52
+
53
+ best = ""
54
+ # Check progressively longer overlaps
55
+ max_len = min(len(prior_tail), len(continuation_head))
56
+ for length in range(1, max_len + 1):
57
+ if prior_tail[-length:] == continuation_head[:length]:
58
+ best = prior_tail[-length:]
59
+
60
+ return best
61
+
62
+
63
+ def _lcs_dynamic(a: str, b: str) -> str:
64
+ """Longest Common Substring via dynamic programming.
65
+
66
+ Used for echo detection between last 2000 chars of prior
67
+ and first 2000 chars of continuation.
68
+ """
69
+ if not a or not b:
70
+ return ""
71
+
72
+ m, n = len(a), len(b)
73
+ # Optimize: only need current and previous row
74
+ prev = [0] * (n + 1)
75
+ curr = [0] * (n + 1)
76
+ longest = 0
77
+ end_pos = 0
78
+
79
+ for i in range(1, m + 1):
80
+ for j in range(1, n + 1):
81
+ if a[i - 1] == b[j - 1]:
82
+ curr[j] = prev[j - 1] + 1
83
+ if curr[j] > longest:
84
+ longest = curr[j]
85
+ end_pos = i
86
+ else:
87
+ curr[j] = 0
88
+ prev, curr = curr, [0] * (n + 1)
89
+
90
+ if longest == 0:
91
+ return ""
92
+ return a[end_pos - longest:end_pos]
93
+
94
+
95
+ def detect_echo(
96
+ prior: str,
97
+ continuation: str,
98
+ config: StitchConfig | None = None,
99
+ ) -> str:
100
+ """Detect echoed content at the start of continuation.
101
+
102
+ Strategy:
103
+ 1. Suffix-prefix overlap (exact boundary echo)
104
+ 2. LCS on last/first 2000 chars (partial echo)
105
+ """
106
+ cfg = config or StitchConfig()
107
+ window = cfg.echo_window
108
+
109
+ prior_tail = prior[-window:] if len(prior) > window else prior
110
+ cont_head = continuation[:window] if len(continuation) > window else continuation
111
+
112
+ # Strategy 1: exact suffix-prefix overlap
113
+ overlap = _longest_common_suffix_prefix(prior_tail, cont_head)
114
+ if len(overlap) >= cfg.min_echo_length:
115
+ return overlap
116
+
117
+ # Strategy 2: LCS
118
+ lcs = _lcs_dynamic(prior_tail, cont_head)
119
+ if len(lcs) >= cfg.min_echo_length:
120
+ # Only count as echo if it appears at/near the start of continuation
121
+ pos = continuation.find(lcs)
122
+ if pos >= 0 and pos < cfg.echo_window // 2:
123
+ return lcs
124
+
125
+ # Strategy 3: Semantic echo — word-overlap similarity (§5E.5)
126
+ prior_words = set(prior_tail.lower().split())
127
+ cont_words = set(cont_head.lower().split())
128
+ if prior_words and cont_words:
129
+ overlap = len(prior_words & cont_words) / min(len(prior_words), len(cont_words)) # type: ignore[assignment]
130
+ if overlap >= cfg.semantic_echo_threshold: # type: ignore[operator]
131
+ # Find the overlapping segment in continuation
132
+ # Use first sentence that significantly overlaps
133
+ cont_sentences = [s.strip() for s in cont_head.split(".") if len(s.strip()) >= cfg.min_echo_length]
134
+ for sent in cont_sentences[:5]:
135
+ sent_words = set(sent.lower().split())
136
+ if prior_words and sent_words:
137
+ s_overlap = len(prior_words & sent_words) / max(1, len(sent_words))
138
+ if s_overlap >= 0.7:
139
+ return sent
140
+
141
+ return ""
142
+
143
+
144
+ # ── Content-type boundary detection ──────────────────────────────
145
+
146
+ def _detect_boundary_type(text: str) -> ContentBoundary:
147
+ """Detect content type from the tail of text."""
148
+ tail = text[-500:] if len(text) > 500 else text
149
+
150
+ # Code: triple backticks, indentation patterns
151
+ if "```" in tail or re.search(r"^\s{4,}\S", tail, re.MULTILINE):
152
+ return ContentBoundary.CODE
153
+
154
+ # Markdown: headings, lists, links
155
+ if re.search(r"^#{1,6}\s", tail, re.MULTILINE) or re.search(r"^\s*[-*]\s", tail, re.MULTILINE):
156
+ return ContentBoundary.MARKDOWN
157
+
158
+ # Structured: JSON/YAML-like
159
+ if re.search(r"[{}\[\]]", tail) or re.search(r"^\s+\w+:", tail, re.MULTILINE):
160
+ return ContentBoundary.STRUCTURED
161
+
162
+ return ContentBoundary.PROSE
163
+
164
+
165
+ def _find_clean_boundary(text: str, boundary_type: ContentBoundary) -> int:
166
+ """Find the best position to end the prior output for clean stitching."""
167
+ if boundary_type == ContentBoundary.PROSE:
168
+ # End at last sentence boundary
169
+ for pattern in [r"[.!?]\s*$", r"[.!?]\s+\S", r"\n\n", r"\n"]:
170
+ match = list(re.finditer(pattern, text[-500:]))
171
+ if match:
172
+ return len(text) - 500 + match[-1].end()
173
+ return len(text)
174
+
175
+ elif boundary_type == ContentBoundary.CODE:
176
+ # End at last complete line or code block boundary
177
+ idx = text.rfind("\n```\n")
178
+ if idx > len(text) - 500:
179
+ return idx + 4
180
+ idx = text.rfind("\n\n")
181
+ if idx > len(text) - 200:
182
+ return idx + 2
183
+ return len(text)
184
+
185
+ elif boundary_type == ContentBoundary.MARKDOWN:
186
+ # End at last double-newline or heading
187
+ idx = text.rfind("\n\n")
188
+ if idx > len(text) - 300:
189
+ return idx + 2
190
+ return len(text)
191
+
192
+ else: # STRUCTURED
193
+ # End at last complete line
194
+ idx = text.rfind("\n")
195
+ if idx > len(text) - 200:
196
+ return idx + 1
197
+ return len(text)
198
+
199
+
200
+ # ── Bridge insertion (no-echo fallback) ──────────────────────────
201
+
202
+ def _build_bridge(boundary_type: ContentBoundary, structural_hint: str = "") -> str:
203
+ """Build a small bridge text for no-echo stitching."""
204
+ if boundary_type == ContentBoundary.CODE:
205
+ return "\n\n"
206
+ elif boundary_type == ContentBoundary.MARKDOWN:
207
+ return "\n\n"
208
+ elif boundary_type == ContentBoundary.STRUCTURED:
209
+ return "\n"
210
+ else:
211
+ return "\n\n"
212
+
213
+
214
+ # ── Post-stitch validation (§04 §3.4) ────────────────────────────
215
+
216
+ def _validate_stitch(text: str) -> list[str]:
217
+ """Post-stitch validation: duplicates, brackets, heading hierarchy."""
218
+ warnings: list[str] = []
219
+
220
+ # Duplicate paragraph detection (exact match of 50+ char paragraphs)
221
+ paragraphs = [p.strip() for p in text.split("\n\n") if len(p.strip()) > 50]
222
+ seen: set[str] = set()
223
+ for p in paragraphs:
224
+ if p in seen:
225
+ warnings.append(f"duplicate_paragraph: {p[:60]}...")
226
+ seen.add(p)
227
+
228
+ # Bracket balance
229
+ for open_c, close_c, name in [("(", ")", "paren"), ("[", "]", "bracket"), ("{", "}", "brace")]:
230
+ balance = text.count(open_c) - text.count(close_c)
231
+ if balance != 0:
232
+ warnings.append(f"unbalanced_{name}: {'+' if balance > 0 else ''}{balance}")
233
+
234
+ # Heading hierarchy (no skipped levels: # → ### without ##)
235
+ headings = re.findall(r"^(#{1,6})\s", text, re.MULTILINE)
236
+ if headings:
237
+ prev_level = 0
238
+ for h in headings:
239
+ level = len(h)
240
+ if prev_level > 0 and level > prev_level + 1:
241
+ warnings.append(f"heading_skip: h{prev_level}→h{level}")
242
+ prev_level = level
243
+
244
+ return warnings
245
+
246
+
247
+ def _dedup_sections(text: str) -> tuple[str, int]:
248
+ """Remove duplicate sections by heading number (GAP C fix).
249
+
250
+ When the same numbered section (e.g. '## 7. Data Protection') appears
251
+ multiple times, keep the LAST occurrence (most recent/complete) and
252
+ remove earlier duplicates.
253
+
254
+ Returns (deduped_text, sections_removed).
255
+ """
256
+ # Split text into section blocks keyed by heading number
257
+ section_pattern = re.compile(r"^(#{1,6})\s+(\d{1,3})\.\s+", re.MULTILINE)
258
+ matches = list(section_pattern.finditer(text))
259
+
260
+ if not matches:
261
+ return text, 0
262
+
263
+ # Track section number → list of (start, end) positions
264
+ section_spans: dict[int, list[tuple[int, int]]] = {}
265
+ for i, m in enumerate(matches):
266
+ sec_num = int(m.group(2))
267
+ start = m.start()
268
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
269
+ section_spans.setdefault(sec_num, []).append((start, end))
270
+
271
+ # Find sections with duplicates — remove all but last occurrence
272
+ ranges_to_remove: list[tuple[int, int]] = []
273
+ for sec_num, spans in section_spans.items():
274
+ if len(spans) > 1:
275
+ # Keep last, remove earlier ones
276
+ for start, end in spans[:-1]:
277
+ ranges_to_remove.append((start, end))
278
+
279
+ if not ranges_to_remove:
280
+ return text, 0
281
+
282
+ # Remove in reverse order to preserve positions
283
+ ranges_to_remove.sort(reverse=True)
284
+ result = text
285
+ for start, end in ranges_to_remove:
286
+ result = result[:start] + result[end:]
287
+
288
+ # Clean up multiple blank lines
289
+ result = re.sub(r"\n{3,}", "\n\n", result)
290
+
291
+ return result, len(ranges_to_remove)
292
+
293
+
294
+ # ── Main stitch function ─────────────────────────────────────────
295
+
296
+ def stitch_outputs(
297
+ prior: str,
298
+ continuation: str,
299
+ config: StitchConfig | None = None,
300
+ ) -> StitchResult:
301
+ """Stitch prior output with continuation output (§4.8, §04 §3.4).
302
+
303
+ Algorithm:
304
+ 1. Detect echo (LCS on last/first 2000 chars, min 20 chars)
305
+ 2. Remove echo from continuation start
306
+ 3. Content-type-aware boundary detection
307
+ 4. No-echo fallback: structural continuation, bridge insertion
308
+ 5. Post-stitch validation
309
+ 6. Store any trimmed fragments (never silently discard)
310
+ """
311
+ cfg = config or StitchConfig()
312
+ trimmed: list[str] = []
313
+
314
+ if not prior:
315
+ return StitchResult(
316
+ text=continuation,
317
+ echo_removed=0,
318
+ boundary_type=ContentBoundary.PROSE,
319
+ bridge_inserted=False,
320
+ trimmed_fragments=trimmed,
321
+ validation_warnings=[],
322
+ )
323
+
324
+ if not continuation:
325
+ return StitchResult(
326
+ text=prior,
327
+ echo_removed=0,
328
+ boundary_type=ContentBoundary.PROSE,
329
+ bridge_inserted=False,
330
+ trimmed_fragments=trimmed,
331
+ validation_warnings=[],
332
+ )
333
+
334
+ boundary_type = _detect_boundary_type(prior)
335
+
336
+ # Step 1-2: Echo detection and removal
337
+ echo = detect_echo(prior, continuation, cfg)
338
+ echo_len = len(echo)
339
+ bridge_inserted = False
340
+
341
+ if echo_len >= cfg.min_echo_length:
342
+ # Remove echo from continuation start
343
+ echo_pos = continuation.find(echo)
344
+ if echo_pos >= 0:
345
+ removed = continuation[:echo_pos + echo_len]
346
+ if echo_pos > 0:
347
+ trimmed.append(removed[:echo_pos])
348
+ continuation = continuation[echo_pos + echo_len:]
349
+ else:
350
+ # No-echo fallback: insert bridge
351
+ bridge = _build_bridge(boundary_type)
352
+ prior_trimmed = prior.rstrip()
353
+ continuation = bridge + continuation.lstrip()
354
+ prior = prior_trimmed
355
+ bridge_inserted = True
356
+
357
+ # Step 3: Content-type-aware boundary
358
+ combined = prior + continuation
359
+
360
+ # Step 4b: Section-level deduplication (GAP C fix)
361
+ combined, sections_removed = _dedup_sections(combined)
362
+ if sections_removed:
363
+ trimmed.append(f"[dedup: {sections_removed} duplicate section(s) removed]")
364
+
365
+ # Step 5: Validation
366
+ warnings = _validate_stitch(combined)
367
+
368
+ return StitchResult(
369
+ text=combined,
370
+ echo_removed=echo_len,
371
+ boundary_type=boundary_type,
372
+ bridge_inserted=bridge_inserted,
373
+ trimmed_fragments=trimmed,
374
+ validation_warnings=warnings,
375
+ )
376
+
377
+
378
+ # ── N-way iterative stitch (§04 §3.4) ────────────────────────────
379
+
380
+ def stitch_many(
381
+ outputs: list[str],
382
+ config: StitchConfig | None = None,
383
+ ) -> StitchResult:
384
+ """N-way iterative stitch for 50+ windows.
385
+
386
+ Applies pairwise stitch left-to-right, accumulating the result.
387
+ """
388
+ if not outputs:
389
+ return StitchResult(
390
+ text="", echo_removed=0, boundary_type=ContentBoundary.PROSE,
391
+ bridge_inserted=False, trimmed_fragments=[], validation_warnings=[],
392
+ )
393
+
394
+ cfg = config or StitchConfig()
395
+ accumulated = outputs[0]
396
+ total_echo = 0
397
+ all_trimmed: list[str] = []
398
+ any_bridge = False
399
+ last_boundary = ContentBoundary.PROSE
400
+
401
+ for i in range(1, len(outputs)):
402
+ result = stitch_outputs(accumulated, outputs[i], cfg)
403
+ accumulated = result.text
404
+ total_echo += result.echo_removed
405
+ all_trimmed.extend(result.trimmed_fragments)
406
+ if result.bridge_inserted:
407
+ any_bridge = True
408
+ last_boundary = result.boundary_type
409
+
410
+ final_warnings = _validate_stitch(accumulated)
411
+
412
+ return StitchResult(
413
+ text=accumulated,
414
+ echo_removed=total_echo,
415
+ boundary_type=last_boundary,
416
+ bridge_inserted=any_bridge,
417
+ trimmed_fragments=all_trimmed,
418
+ validation_warnings=final_warnings,
419
+ )
@@ -0,0 +1,142 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Continuation trigger — wall detection and continuation conditions (§4.2)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class TriggerConfig:
12
+ """Configuration for continuation triggering."""
13
+
14
+ max_continuations: int = 50
15
+ token_ratio_threshold: float = 0.95
16
+ min_gap_score: float = 0.0
17
+ min_info_flow: float = 0.0
18
+ # When the model stops naturally (no wall hit) but significant
19
+ # unfulfilled requirements remain, override the stop and continue.
20
+ # This handles small models that stop prematurely before completing
21
+ # all requested sections. Set to 0.0 to disable gap override.
22
+ gap_override_threshold: float = 0.3
23
+
24
+
25
+ @dataclass
26
+ class TriggerResult:
27
+ """Result of continuation trigger evaluation."""
28
+
29
+ should_continue: bool
30
+ wall_hit: bool
31
+ gap_remaining: float
32
+ info_flow: float
33
+ continuation_count: int
34
+ reason: str
35
+ details: dict[str, object] = field(default_factory=dict)
36
+
37
+
38
+ def detect_wall_hit(
39
+ finish_reason: str | None,
40
+ output_tokens: int | None = None,
41
+ max_output_tokens: int | None = None,
42
+ ) -> bool:
43
+ """Detect physical context-window wall hit.
44
+
45
+ Primary: finish_reason == "length" (universal across providers).
46
+ Fallback: output_tokens / max_output_tokens >= 0.95 when finish_reason unavailable.
47
+ """
48
+ if finish_reason is not None:
49
+ return finish_reason.lower() in ("length", "max_tokens")
50
+
51
+ if output_tokens is not None and max_output_tokens is not None and max_output_tokens > 0:
52
+ return output_tokens / max_output_tokens >= 0.95
53
+
54
+ return False
55
+
56
+
57
+ def evaluate_continuation(
58
+ *,
59
+ finish_reason: str | None,
60
+ output_tokens: int | None = None,
61
+ max_output_tokens: int | None = None,
62
+ gap_score: float = 1.0,
63
+ info_flow: float = 1.0,
64
+ continuation_count: int = 0,
65
+ config: TriggerConfig | None = None,
66
+ ) -> TriggerResult:
67
+ """Evaluate whether continuation should proceed.
68
+
69
+ Three conditions must ALL be met (§4.2):
70
+ 1. Wall hit detected (physical truncation)
71
+ 2. Gap score > min_gap_score (unfulfilled requirements remain)
72
+ 3. Info flow > min_info_flow (model still producing useful content)
73
+ 4. continuation_count < max_continuations (safety bound)
74
+ """
75
+ cfg = config or TriggerConfig()
76
+
77
+ wall_hit = detect_wall_hit(finish_reason, output_tokens, max_output_tokens)
78
+
79
+ if not wall_hit:
80
+ # Gap override: if the model stopped naturally but significant
81
+ # requirements remain unfulfilled, continue anyway. This handles
82
+ # small models that stop prematurely before completing all sections.
83
+ # Still respect max_continuations safety bound.
84
+ if (cfg.gap_override_threshold > 0.0
85
+ and gap_score > cfg.gap_override_threshold
86
+ and continuation_count < cfg.max_continuations):
87
+ return TriggerResult(
88
+ should_continue=True,
89
+ wall_hit=False,
90
+ gap_remaining=gap_score,
91
+ info_flow=info_flow,
92
+ continuation_count=continuation_count,
93
+ reason="gap_override",
94
+ details={"gap_threshold": cfg.gap_override_threshold},
95
+ )
96
+ return TriggerResult(
97
+ should_continue=False,
98
+ wall_hit=False,
99
+ gap_remaining=gap_score,
100
+ info_flow=info_flow,
101
+ continuation_count=continuation_count,
102
+ reason="no_wall_hit",
103
+ )
104
+
105
+ if gap_score <= cfg.min_gap_score:
106
+ return TriggerResult(
107
+ should_continue=False,
108
+ wall_hit=True,
109
+ gap_remaining=gap_score,
110
+ info_flow=info_flow,
111
+ continuation_count=continuation_count,
112
+ reason="gap_fulfilled",
113
+ )
114
+
115
+ if info_flow <= cfg.min_info_flow:
116
+ return TriggerResult(
117
+ should_continue=False,
118
+ wall_hit=True,
119
+ gap_remaining=gap_score,
120
+ info_flow=info_flow,
121
+ continuation_count=continuation_count,
122
+ reason="info_flow_dead",
123
+ )
124
+
125
+ if continuation_count >= cfg.max_continuations:
126
+ return TriggerResult(
127
+ should_continue=False,
128
+ wall_hit=True,
129
+ gap_remaining=gap_score,
130
+ info_flow=info_flow,
131
+ continuation_count=continuation_count,
132
+ reason="max_continuations_reached",
133
+ )
134
+
135
+ return TriggerResult(
136
+ should_continue=True,
137
+ wall_hit=True,
138
+ gap_remaining=gap_score,
139
+ info_flow=info_flow,
140
+ continuation_count=continuation_count,
141
+ reason="continue",
142
+ )