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,613 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Novel context relay strategies — beyond push and pull (§21).
4
+
5
+ Three fundamentally different approaches to providing context to LLMs:
6
+
7
+ §21.1 REFLEXIVE DISPATCH — Verify-then-Refine
8
+ Generate first with zero context (pure parametric knowledge).
9
+ CRP analyzes the output against its knowledge base, identifying
10
+ factual errors, unsupported claims, and missing information.
11
+ A targeted correction payload is assembled and sent back with
12
+ the model's own output — the model refines with surgical
13
+ precision instead of drowning in pre-loaded context.
14
+
15
+ Analogy: A fact-checker reviews your first draft and hands
16
+ you a marked-up copy with corrections and sources.
17
+
18
+ §21.2 PROGRESSIVE DISCLOSURE — Index → Detail on Demand
19
+ Instead of full facts, send a compact CONTEXT INDEX — one-line
20
+ summaries of every available fact, grouped by topic. The model
21
+ sees WHAT knowledge exists without the full payload. It generates
22
+ with awareness of available resources, and CRP detects which
23
+ indexed items were referenced. A second pass provides full
24
+ detail for only the referenced items.
25
+
26
+ Analogy: A library card catalog — you browse titles first,
27
+ then check out only the books you need.
28
+
29
+ §21.3 STREAM-AUGMENTED GENERATION — Real-time Context Injection
30
+ Stream tokens from the LLM. Buffer into sentences. After each
31
+ sentence, CRP runs real-time fact-matching against the knowledge
32
+ base. When relevant facts are found, generation is paused, the
33
+ partial output + injected context are sent as a new prompt, and
34
+ the model continues from where it left off — now informed.
35
+
36
+ Analogy: A research assistant who watches you write and slides
37
+ relevant papers across the desk exactly when you need them.
38
+
39
+ Each strategy has fundamentally different characteristics:
40
+ - Reflexive: Best for ACCURACY — catches and corrects hallucinations
41
+ - Progressive: Best for EFFICIENCY — minimal token usage, max coverage
42
+ - Stream-aug: Best for COHERENCE — context arrives at point-of-need
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import logging
49
+ import re
50
+ from dataclasses import dataclass, field
51
+ from typing import Any, Callable, TYPE_CHECKING
52
+
53
+ if TYPE_CHECKING:
54
+ from crp.extraction.types import Fact
55
+ from crp.state.warm_store import WarmStateStore
56
+ from crp.ckf.fabric import ContextualKnowledgeFabric
57
+
58
+ logger = logging.getLogger("crp.relay_strategies")
59
+
60
+
61
+ # ═══════════════════════════════════════════════════════════════════════
62
+ # §21.1 REFLEXIVE DISPATCH — Verify-then-Refine
63
+ # ═══════════════════════════════════════════════════════════════════════
64
+
65
+ @dataclass
66
+ class FactCorrection:
67
+ """A correction identified by comparing output against knowledge base."""
68
+ claim_text: str # What the model said
69
+ matching_fact: str # What the knowledge base says
70
+ fact_id: str # Traceable fact ID
71
+ confidence: float # How confident CRP is in the match
72
+ correction_type: str # "contradiction", "unsupported", "partial", "enrichment"
73
+
74
+
75
+ @dataclass
76
+ class ReflexiveAnalysis:
77
+ """Result of analyzing model output against CRP knowledge base."""
78
+ corrections: list[FactCorrection] = field(default_factory=list)
79
+ unsupported_claims: list[str] = field(default_factory=list)
80
+ enrichment_facts: list[str] = field(default_factory=list)
81
+ coverage_score: float = 0.0 # 0-1: how much of the output is KB-supported
82
+ claims_checked: int = 0
83
+ claims_supported: int = 0
84
+ claims_contradicted: int = 0
85
+
86
+ @property
87
+ def needs_refinement(self) -> bool:
88
+ """Whether the output needs a refinement pass."""
89
+ return (
90
+ len(self.corrections) > 0
91
+ or len(self.unsupported_claims) > 2
92
+ or self.coverage_score < 0.3
93
+ )
94
+
95
+
96
+ def analyze_output_against_kb(
97
+ output: str,
98
+ warm_store: WarmStateStore,
99
+ count_tokens: Callable[[str], int],
100
+ embed_fn: Callable[[str], list[float]] | None = None,
101
+ ) -> ReflexiveAnalysis:
102
+ """Compare LLM output against CRP knowledge base.
103
+
104
+ Extracts claims/sentences from the output and cross-references
105
+ each against the WarmStore facts. Identifies:
106
+ - Supported claims (fact backing exists)
107
+ - Contradicted claims (conflicting evidence)
108
+ - Unsupported claims (no evidence either way)
109
+ - Enrichment opportunities (related facts not mentioned)
110
+ """
111
+ analysis = ReflexiveAnalysis()
112
+
113
+ # Split output into sentences (claim units)
114
+ sentences = _split_into_sentences(output)
115
+ if not sentences:
116
+ return analysis
117
+
118
+ # Get all ranked facts
119
+ all_facts = warm_store.get_ranked_facts(limit=100)
120
+ if not all_facts:
121
+ # No knowledge base — everything is unsupported
122
+ analysis.unsupported_claims = sentences[:10]
123
+ analysis.claims_checked = len(sentences)
124
+ return analysis
125
+
126
+ # Build a simple term index over facts for fast matching
127
+ fact_index: dict[str, list[Any]] = {}
128
+ for f in all_facts:
129
+ for term in _extract_key_terms(f.text):
130
+ fact_index.setdefault(term, []).append(f)
131
+
132
+ supported_count = 0
133
+ for sentence in sentences:
134
+ sentence_stripped = sentence.strip()
135
+ if len(sentence_stripped) < 15: # Skip trivial fragments
136
+ continue
137
+
138
+ analysis.claims_checked += 1
139
+ sentence_terms = _extract_key_terms(sentence_stripped)
140
+
141
+ # Find facts with overlapping terms
142
+ candidate_facts: dict[str, int] = {} # fact_id → overlap count
143
+ for term in sentence_terms:
144
+ for f in fact_index.get(term, []):
145
+ candidate_facts[f.id] = candidate_facts.get(f.id, 0) + 1
146
+
147
+ if not candidate_facts:
148
+ analysis.unsupported_claims.append(sentence_stripped[:200])
149
+ continue
150
+
151
+ # Score candidates
152
+ best_id = max(candidate_facts, key=candidate_facts.get)
153
+ best_overlap = candidate_facts[best_id]
154
+ best_fact = next(f for f in all_facts if f.id == best_id)
155
+
156
+ overlap_ratio = best_overlap / max(len(sentence_terms), 1)
157
+
158
+ if overlap_ratio >= 0.4:
159
+ # Well-supported claim
160
+ supported_count += 1
161
+
162
+ # Check for contradiction: if the fact says something
163
+ # quantitatively different, flag it
164
+ if _detect_contradiction(sentence_stripped, best_fact.text):
165
+ analysis.corrections.append(FactCorrection(
166
+ claim_text=sentence_stripped[:200],
167
+ matching_fact=best_fact.text,
168
+ fact_id=best_fact.id,
169
+ confidence=best_fact.confidence,
170
+ correction_type="contradiction",
171
+ ))
172
+ analysis.claims_contradicted += 1
173
+ else:
174
+ analysis.claims_supported += 1
175
+ elif overlap_ratio >= 0.15:
176
+ # Partial match — could be enriched
177
+ analysis.corrections.append(FactCorrection(
178
+ claim_text=sentence_stripped[:200],
179
+ matching_fact=best_fact.text,
180
+ fact_id=best_fact.id,
181
+ confidence=best_fact.confidence,
182
+ correction_type="enrichment",
183
+ ))
184
+ else:
185
+ analysis.unsupported_claims.append(sentence_stripped[:200])
186
+
187
+ # Find enrichment facts: high-confidence facts NOT referenced in output
188
+ output_lower = output.lower()
189
+ for f in all_facts[:20]: # Top-ranked, high-confidence
190
+ if f.confidence >= 0.8:
191
+ fact_terms = _extract_key_terms(f.text)
192
+ # If most fact terms don't appear in output, it's enrichment
193
+ hits = sum(1 for t in fact_terms if t in output_lower)
194
+ if hits < len(fact_terms) * 0.3:
195
+ analysis.enrichment_facts.append(f.text)
196
+ if len(analysis.enrichment_facts) >= 5:
197
+ break
198
+
199
+ analysis.coverage_score = (
200
+ analysis.claims_supported / max(analysis.claims_checked, 1)
201
+ )
202
+
203
+ logger.info(
204
+ "Reflexive analysis: %d claims checked, %d supported, %d contradicted, "
205
+ "%d unsupported, %d corrections, coverage=%.2f",
206
+ analysis.claims_checked, analysis.claims_supported,
207
+ analysis.claims_contradicted, len(analysis.unsupported_claims),
208
+ len(analysis.corrections), analysis.coverage_score,
209
+ )
210
+
211
+ return analysis
212
+
213
+
214
+ def build_refinement_prompt(
215
+ original_output: str,
216
+ analysis: ReflexiveAnalysis,
217
+ count_tokens: Callable[[str], int],
218
+ max_correction_tokens: int = 3000,
219
+ ) -> str:
220
+ """Build the refinement prompt from reflexive analysis.
221
+
222
+ Returns a structured correction payload that tells the model
223
+ exactly what to fix, with evidence.
224
+ """
225
+ parts: list[str] = []
226
+ token_budget = max_correction_tokens
227
+
228
+ parts.append("=== FACT-CHECK RESULTS ===")
229
+ parts.append(f"Coverage: {analysis.coverage_score:.0%} of your claims are supported by verified evidence.")
230
+ parts.append(f"Corrections needed: {len(analysis.corrections)}")
231
+ parts.append("")
232
+
233
+ # Corrections (most important)
234
+ if analysis.corrections:
235
+ parts.append("--- CORRECTIONS (verified evidence differs from your output) ---")
236
+ for c in analysis.corrections:
237
+ entry = (
238
+ f"• YOUR CLAIM: \"{c.claim_text}\"\n"
239
+ f" VERIFIED FACT: \"{c.matching_fact}\" (confidence: {c.confidence:.0%})\n"
240
+ f" TYPE: {c.correction_type}"
241
+ )
242
+ entry_tokens = count_tokens(entry)
243
+ if entry_tokens > token_budget:
244
+ break
245
+ token_budget -= entry_tokens
246
+ parts.append(entry)
247
+ parts.append("")
248
+
249
+ # Enrichments (additional facts the model missed)
250
+ if analysis.enrichment_facts and token_budget > 200:
251
+ parts.append("--- ADDITIONAL VERIFIED FACTS (consider incorporating) ---")
252
+ for ef in analysis.enrichment_facts:
253
+ entry = f"• {ef}"
254
+ entry_tokens = count_tokens(entry)
255
+ if entry_tokens > token_budget:
256
+ break
257
+ token_budget -= entry_tokens
258
+ parts.append(entry)
259
+ parts.append("")
260
+
261
+ parts.append("=== END FACT-CHECK RESULTS ===")
262
+ parts.append("")
263
+ parts.append(
264
+ "Please revise your response incorporating the corrections above. "
265
+ "Keep the same structure and style, but fix factual errors and "
266
+ "incorporate the additional verified facts where relevant."
267
+ )
268
+
269
+ return "\n".join(parts)
270
+
271
+
272
+ # ═══════════════════════════════════════════════════════════════════════
273
+ # §21.2 PROGRESSIVE DISCLOSURE — Index → Detail on Demand
274
+ # ═══════════════════════════════════════════════════════════════════════
275
+
276
+ @dataclass
277
+ class ContextIndex:
278
+ """A compact index of available knowledge — titles/summaries only."""
279
+ entries: list[ContextIndexEntry] = field(default_factory=list)
280
+ total_facts: int = 0
281
+ index_tokens: int = 0
282
+
283
+ def to_text(self) -> str:
284
+ """Render the index as a compact text block for the LLM."""
285
+ if not self.entries:
286
+ return ""
287
+ lines = ["=== AVAILABLE CONTEXT INDEX ==="]
288
+ lines.append(f"({self.total_facts} verified facts available. "
289
+ "Reference items by [ID] to receive full details.)")
290
+ lines.append("")
291
+ for e in self.entries:
292
+ lines.append(f"[{e.ref_id}] {e.summary} (confidence: {e.confidence:.0%})")
293
+ lines.append("")
294
+ lines.append("=== END INDEX ===")
295
+ return "\n".join(lines)
296
+
297
+
298
+ @dataclass
299
+ class ContextIndexEntry:
300
+ """One entry in the context index."""
301
+ ref_id: str # Short reference ID like "F1", "F2"
302
+ fact_id: str # Real CRP fact ID (for lookup)
303
+ summary: str # One-line summary (compressed)
304
+ confidence: float # Fact confidence
305
+ full_text: str # Full text (not sent until referenced)
306
+ tokens: int # Token cost of full text
307
+
308
+
309
+ def build_context_index(
310
+ warm_store: WarmStateStore,
311
+ count_tokens: Callable[[str], int],
312
+ max_entries: int = 50,
313
+ max_index_tokens: int = 1500,
314
+ ) -> ContextIndex:
315
+ """Build a compact context index from WarmStore facts.
316
+
317
+ Each entry is compressed to a one-line summary (~15 tokens)
318
+ instead of the full fact text (~50-200 tokens). This gives
319
+ the LLM awareness of ALL available knowledge at ~10% of the
320
+ token cost of sending full facts.
321
+ """
322
+ ranked_facts = warm_store.get_ranked_facts(limit=max_entries)
323
+ index = ContextIndex(total_facts=warm_store.fact_count)
324
+
325
+ token_budget = max_index_tokens
326
+ for i, f in enumerate(ranked_facts):
327
+ # Compress fact to one-line summary
328
+ summary = _compress_fact_to_summary(f.text)
329
+ ref_id = f"F{i+1}"
330
+
331
+ entry_line = f"[{ref_id}] {summary} (confidence: {f.confidence:.0%})"
332
+ entry_tokens = count_tokens(entry_line)
333
+
334
+ if entry_tokens > token_budget:
335
+ break
336
+ token_budget -= entry_tokens
337
+
338
+ index.entries.append(ContextIndexEntry(
339
+ ref_id=ref_id,
340
+ fact_id=f.id,
341
+ summary=summary,
342
+ confidence=f.confidence,
343
+ full_text=f.text,
344
+ tokens=count_tokens(f.text),
345
+ ))
346
+
347
+ index.index_tokens = max_index_tokens - token_budget
348
+
349
+ logger.info(
350
+ "Progressive index: %d entries, %d tokens (from %d total facts)",
351
+ len(index.entries), index.index_tokens, index.total_facts,
352
+ )
353
+ return index
354
+
355
+
356
+ def detect_index_references(
357
+ output: str,
358
+ index: ContextIndex,
359
+ ) -> list[ContextIndexEntry]:
360
+ """Detect which index entries were referenced in the output.
361
+
362
+ The LLM may reference entries by [F1], [F2] etc., or by
363
+ mentioning key terms from the summary. Both are detected.
364
+ """
365
+ referenced: list[ContextIndexEntry] = []
366
+ output_lower = output.lower()
367
+
368
+ for entry in index.entries:
369
+ # Check for explicit reference [F1], [F2], etc.
370
+ if f"[{entry.ref_id}]" in output or f"[{entry.ref_id.lower()}]" in output_lower:
371
+ referenced.append(entry)
372
+ continue
373
+
374
+ # Check for key term overlap with summary
375
+ summary_terms = _extract_key_terms(entry.summary)
376
+ if summary_terms:
377
+ hits = sum(1 for t in summary_terms if t in output_lower)
378
+ if hits >= max(2, len(summary_terms) * 0.5):
379
+ referenced.append(entry)
380
+
381
+ logger.info(
382
+ "Progressive disclosure: %d of %d index entries referenced",
383
+ len(referenced), len(index.entries),
384
+ )
385
+ return referenced
386
+
387
+
388
+ def build_detail_injection(
389
+ referenced_entries: list[ContextIndexEntry],
390
+ count_tokens: Callable[[str], int],
391
+ max_tokens: int = 3000,
392
+ ) -> str:
393
+ """Build the detail payload for referenced index entries.
394
+
395
+ Only the entries the model actually referenced get expanded
396
+ to full text. This is maximally efficient — no wasted context.
397
+ """
398
+ if not referenced_entries:
399
+ return ""
400
+
401
+ parts: list[str] = ["=== EXPANDED CONTEXT (you referenced these) ==="]
402
+ token_budget = max_tokens
403
+
404
+ for entry in referenced_entries:
405
+ detail = f"[{entry.ref_id}] {entry.full_text}"
406
+ detail_tokens = count_tokens(detail)
407
+ if detail_tokens > token_budget:
408
+ break
409
+ token_budget -= detail_tokens
410
+ parts.append(detail)
411
+
412
+ parts.append("=== END EXPANDED CONTEXT ===")
413
+ return "\n".join(parts)
414
+
415
+
416
+ # ═══════════════════════════════════════════════════════════════════════
417
+ # §21.3 STREAM-AUGMENTED — Real-time Context Injection
418
+ # ═══════════════════════════════════════════════════════════════════════
419
+
420
+ @dataclass
421
+ class AugmentationEvent:
422
+ """Record of a real-time context injection during streaming."""
423
+ sentence_index: int # Which sentence triggered it
424
+ trigger_text: str # The sentence that triggered injection
425
+ facts_injected: int # How many facts were injected
426
+ injection_tokens: int # Token cost of injection
427
+ resumption_point: str # The text sent as resumption anchor
428
+
429
+
430
+ @dataclass
431
+ class StreamAugmentationState:
432
+ """Tracks state during stream-augmented generation."""
433
+ sentence_buffer: str = ""
434
+ sentences_completed: int = 0
435
+ augmentation_events: list[AugmentationEvent] = field(default_factory=list)
436
+ total_injections: int = 0
437
+ total_injection_tokens: int = 0
438
+ accumulated_output: str = ""
439
+
440
+ @property
441
+ def should_check(self) -> bool:
442
+ """Check after every N sentences to avoid excessive overhead."""
443
+ return self.sentences_completed % 2 == 0 # Every 2 sentences
444
+
445
+
446
+ def find_relevant_facts_for_sentence(
447
+ sentence: str,
448
+ warm_store: WarmStateStore,
449
+ already_injected: set[str],
450
+ count_tokens: Callable[[str], int],
451
+ max_facts: int = 3,
452
+ max_tokens: int = 500,
453
+ ) -> list[tuple[str, str]]:
454
+ """Find WarmStore facts relevant to a sentence that haven't been injected yet.
455
+
456
+ Returns [(fact_id, fact_text), ...] — only NEW facts not already served.
457
+ """
458
+ sentence_terms = _extract_key_terms(sentence)
459
+ if not sentence_terms:
460
+ return []
461
+
462
+ ranked = warm_store.get_ranked_facts(limit=50)
463
+ scored: list[tuple[float, Any]] = []
464
+
465
+ for f in ranked:
466
+ if f.id in already_injected:
467
+ continue
468
+ fact_terms = _extract_key_terms(f.text)
469
+ overlap = sum(1 for t in sentence_terms if t in set(fact_terms))
470
+ if overlap >= max(1, len(sentence_terms) // 4):
471
+ score = overlap / max(len(sentence_terms), 1) * f.confidence
472
+ scored.append((score, f))
473
+
474
+ scored.sort(key=lambda x: -x[0])
475
+
476
+ results: list[tuple[str, str]] = []
477
+ token_budget = max_tokens
478
+ for _, f in scored[:max_facts]:
479
+ ft = count_tokens(f.text)
480
+ if ft > token_budget:
481
+ break
482
+ token_budget -= ft
483
+ results.append((f.id, f.text))
484
+
485
+ return results
486
+
487
+
488
+ def build_augmented_continuation(
489
+ system_prompt: str,
490
+ partial_output: str,
491
+ injected_facts: list[tuple[str, str]],
492
+ task_input: str,
493
+ ) -> list[dict[str, str]]:
494
+ """Build the message sequence for resuming after a stream augmentation.
495
+
496
+ The model receives:
497
+ 1. System prompt (unchanged)
498
+ 2. Original task
499
+ 3. Its own partial output so far (as assistant message)
500
+ 4. Injected context (as system/user interjection)
501
+ 5. Instruction to continue from where it left off
502
+ """
503
+ facts_text = "\n".join(f"• {text}" for _, text in injected_facts)
504
+
505
+ messages: list[dict[str, str]] = [
506
+ {"role": "system", "content": system_prompt},
507
+ {"role": "user", "content": task_input},
508
+ {"role": "assistant", "content": partial_output},
509
+ {
510
+ "role": "user",
511
+ "content": (
512
+ f"[CONTEXT INJECTION — verified facts relevant to what you just wrote]\n"
513
+ f"{facts_text}\n"
514
+ f"[END INJECTION]\n\n"
515
+ f"Continue writing from exactly where you left off. Do not repeat "
516
+ f"what you already wrote. Incorporate the above facts naturally."
517
+ ),
518
+ },
519
+ ]
520
+ return messages
521
+
522
+
523
+ # ═══════════════════════════════════════════════════════════════════════
524
+ # Shared utilities
525
+ # ═══════════════════════════════════════════════════════════════════════
526
+
527
+ # Stop words for term extraction (minimal set for speed)
528
+ _STOP_WORDS: frozenset[str] = frozenset({
529
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
530
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
531
+ "should", "may", "might", "shall", "can", "to", "of", "in", "for",
532
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
533
+ "before", "after", "above", "below", "between", "and", "but", "or",
534
+ "not", "no", "so", "if", "than", "then", "that", "this", "these",
535
+ "those", "it", "its", "he", "she", "they", "we", "you", "i", "my",
536
+ "his", "her", "their", "our", "your", "which", "what", "who", "whom",
537
+ "how", "when", "where", "why", "all", "each", "every", "both",
538
+ "few", "more", "most", "other", "some", "such", "only", "very",
539
+ "also", "just", "about", "up", "out", "over", "own", "same",
540
+ })
541
+
542
+
543
+ def _extract_key_terms(text: str) -> list[str]:
544
+ """Extract significant terms from text (lowercased, stop-words removed)."""
545
+ words = re.findall(r'[a-zA-Z0-9]+', text.lower())
546
+ return [w for w in words if w not in _STOP_WORDS and len(w) > 2]
547
+
548
+
549
+ def _split_into_sentences(text: str) -> list[str]:
550
+ """Split text into sentences using regex (handles common cases)."""
551
+ # Split on sentence-ending punctuation followed by space or newline
552
+ sentences = re.split(r'(?<=[.!?])\s+', text)
553
+ # Also split on double newlines (paragraph boundaries)
554
+ result: list[str] = []
555
+ for s in sentences:
556
+ parts = s.split("\n\n")
557
+ result.extend(p.strip() for p in parts if p.strip())
558
+ return result
559
+
560
+
561
+ def _compress_fact_to_summary(text: str) -> str:
562
+ """Compress a fact to a one-line summary (~10-20 words max).
563
+
564
+ Strategy: take the first clause/sentence, truncate at ~80 chars.
565
+ """
566
+ # Take first sentence
567
+ match = re.match(r'^([^.!?]+[.!?])', text)
568
+ first_sentence = match.group(1) if match else text
569
+
570
+ if len(first_sentence) <= 80:
571
+ return first_sentence
572
+
573
+ # Truncate at word boundary near 80 chars
574
+ truncated = first_sentence[:80]
575
+ last_space = truncated.rfind(" ")
576
+ if last_space > 40:
577
+ truncated = truncated[:last_space]
578
+ return truncated + "..."
579
+
580
+
581
+ def _detect_contradiction(claim: str, fact: str) -> bool:
582
+ """Simple heuristic contradiction detection.
583
+
584
+ Checks for numeric disagreements and negation patterns.
585
+ Not perfect — serves as a first-pass filter.
586
+ """
587
+ # Extract numbers from both
588
+ claim_numbers = set(re.findall(r'\b\d+(?:\.\d+)?\b', claim))
589
+ fact_numbers = set(re.findall(r'\b\d+(?:\.\d+)?\b', fact))
590
+
591
+ # If both mention numbers but they differ significantly, likely contradiction
592
+ if claim_numbers and fact_numbers:
593
+ shared_terms = _extract_key_terms(claim)
594
+ fact_terms = set(_extract_key_terms(fact))
595
+ topic_overlap = sum(1 for t in shared_terms if t in fact_terms)
596
+ if topic_overlap >= 2 and claim_numbers != fact_numbers:
597
+ # Same topic, different numbers — likely contradiction
598
+ return True
599
+
600
+ # Check negation patterns
601
+ claim_lower = claim.lower()
602
+ fact_lower = fact.lower()
603
+ negation_pairs = [
604
+ ("not ", ""), ("never ", "always "), ("false", "true"),
605
+ ("incorrect", "correct"), ("wrong", "right"),
606
+ ]
607
+ for neg, pos in negation_pairs:
608
+ if neg in claim_lower and pos in fact_lower:
609
+ return True
610
+ if pos in claim_lower and neg in fact_lower:
611
+ return True
612
+
613
+ return False