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,348 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Multi-signal completion detection (§4.3).
4
+
5
+ Four signals, weighted by content type, with grace periods and self-calibrating baselines.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import Counter, deque
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+
15
+
16
+ class CompletionSignal(str, Enum):
17
+ """The four completion signals."""
18
+
19
+ FACT_FLOW = "fact_flow"
20
+ STRUCTURAL_FLOW = "structural_flow"
21
+ VOCABULARY_NOVELTY = "vocabulary_novelty"
22
+ STRUCTURAL_COMPLETION = "structural_completion"
23
+
24
+
25
+ @dataclass
26
+ class SignalState:
27
+ """State of a single completion signal."""
28
+
29
+ signal: CompletionSignal
30
+ value: float # 0.0–1.0 normalized
31
+ alive: bool # True if signal suggests more content coming
32
+ weight: float # content-type-dependent weight
33
+ raw: float = 0.0 # pre-normalization value
34
+
35
+
36
+ @dataclass
37
+ class CompletionResult:
38
+ """Aggregated completion assessment."""
39
+
40
+ is_complete: bool
41
+ composite_score: float # 0=totally incomplete, 1=fully complete
42
+ signals: list[SignalState]
43
+ grace_tokens_remaining: int
44
+ reason: str
45
+ details: dict[str, object] = field(default_factory=dict)
46
+
47
+
48
+ @dataclass
49
+ class CompletionConfig:
50
+ """Configuration for completion detection."""
51
+
52
+ # Content-type signal weights: {content_type: {signal: weight}}
53
+ # Default weights below; override per content type
54
+ default_weights: dict[str, float] = field(default_factory=lambda: {
55
+ CompletionSignal.FACT_FLOW: 0.35,
56
+ CompletionSignal.STRUCTURAL_FLOW: 0.25,
57
+ CompletionSignal.VOCABULARY_NOVELTY: 0.20,
58
+ CompletionSignal.STRUCTURAL_COMPLETION: 0.20,
59
+ })
60
+
61
+ entity_rich_weights: dict[str, float] = field(default_factory=lambda: {
62
+ CompletionSignal.FACT_FLOW: 0.50,
63
+ CompletionSignal.STRUCTURAL_FLOW: 0.15,
64
+ CompletionSignal.VOCABULARY_NOVELTY: 0.15,
65
+ CompletionSignal.STRUCTURAL_COMPLETION: 0.20,
66
+ })
67
+
68
+ reasoning_dense_weights: dict[str, float] = field(default_factory=lambda: {
69
+ CompletionSignal.FACT_FLOW: 0.25,
70
+ CompletionSignal.STRUCTURAL_FLOW: 0.35,
71
+ CompletionSignal.VOCABULARY_NOVELTY: 0.20,
72
+ CompletionSignal.STRUCTURAL_COMPLETION: 0.20,
73
+ })
74
+
75
+ narrative_weights: dict[str, float] = field(default_factory=lambda: {
76
+ CompletionSignal.FACT_FLOW: 0.20,
77
+ CompletionSignal.STRUCTURAL_FLOW: 0.25,
78
+ CompletionSignal.VOCABULARY_NOVELTY: 0.35,
79
+ CompletionSignal.STRUCTURAL_COMPLETION: 0.20,
80
+ })
81
+
82
+ completion_threshold: float = 0.75
83
+ grace_tokens: int = 500 # extra tokens when secondary signal alive
84
+ calibration_windows: int = 5 # N first windows for baseline
85
+
86
+
87
+ # ── Signal 1: Fact flow (§4.3) ───────────────────────────────────
88
+
89
+ _COMPLETION_HEADINGS = re.compile(
90
+ r"^#+\s*(conclusion|summary|references|bibliography|appendix|closing|final\s+thoughts)",
91
+ re.IGNORECASE | re.MULTILINE,
92
+ )
93
+
94
+ _LIST_CLOSE_PATTERN = re.compile(
95
+ r"(?:^|\n)\s*(?:\d+[.)]\s+|[-*]\s+).*(?:\n\s*$|\Z)",
96
+ re.MULTILINE,
97
+ )
98
+
99
+
100
+ class CompletionDetector:
101
+ """Multi-signal completion with self-calibrating baselines (§4.3)."""
102
+
103
+ def __init__(self, content_type: str = "", config: CompletionConfig | None = None) -> None:
104
+ self._config = config or CompletionConfig()
105
+ self._content_type = content_type
106
+ self._weights = self._select_weights(content_type)
107
+
108
+ # Per-signal history for calibration (bounded to prevent memory leak)
109
+ self._fact_rates: deque[float] = deque(maxlen=100)
110
+ self._structural_scores: deque[float] = deque(maxlen=100)
111
+ self._novelty_scores: deque[float] = deque(maxlen=100)
112
+ self._completion_scores: deque[float] = deque(maxlen=100)
113
+
114
+ # Baselines (calibrated from first N windows)
115
+ self._fact_baseline: float | None = None
116
+ self._structural_baseline: float | None = None
117
+ self._novelty_baseline: float | None = None
118
+
119
+ # N-gram pool for novelty
120
+ self._prior_trigrams: Counter[tuple[str, ...]] = Counter()
121
+
122
+ # Grace period tracking
123
+ self._grace_budget = 0
124
+
125
+ def evaluate(
126
+ self,
127
+ text: str,
128
+ facts_produced: int,
129
+ tokens_consumed: int,
130
+ structural_state: dict[str, object] | None = None,
131
+ ) -> CompletionResult:
132
+ """Evaluate all 4 completion signals for a window output."""
133
+ tokens = text.split()
134
+
135
+ # Signal 1: Fact flow
136
+ fact_rate = (facts_produced / max(1, tokens_consumed)) * 1000.0
137
+ self._fact_rates.append(fact_rate)
138
+ fact_signal = self._score_fact_flow(fact_rate)
139
+
140
+ # Signal 2: Structural flow
141
+ struct_score = self._score_structural_flow(text, structural_state)
142
+ self._structural_scores.append(struct_score)
143
+
144
+ # Signal 3: Vocabulary novelty (3-gram ratio)
145
+ novelty = self._score_novelty(tokens)
146
+ self._novelty_scores.append(novelty)
147
+ self._update_trigrams(tokens)
148
+
149
+ # Signal 4: Structural completion patterns
150
+ completion_score = self._score_structural_completion(text)
151
+ self._completion_scores.append(completion_score)
152
+
153
+ # Calibrate baselines after N windows
154
+ self._maybe_calibrate()
155
+
156
+ # Build signal states
157
+ signals = [
158
+ SignalState(
159
+ signal=CompletionSignal.FACT_FLOW,
160
+ value=1.0 - fact_signal, # invert: high=complete
161
+ alive=fact_signal > 0.2,
162
+ weight=self._weights.get(CompletionSignal.FACT_FLOW, 0.25),
163
+ raw=fact_rate,
164
+ ),
165
+ SignalState(
166
+ signal=CompletionSignal.STRUCTURAL_FLOW,
167
+ value=1.0 - struct_score,
168
+ alive=struct_score > 0.2,
169
+ weight=self._weights.get(CompletionSignal.STRUCTURAL_FLOW, 0.25),
170
+ raw=struct_score,
171
+ ),
172
+ SignalState(
173
+ signal=CompletionSignal.VOCABULARY_NOVELTY,
174
+ value=1.0 - novelty,
175
+ alive=novelty > 0.3,
176
+ weight=self._weights.get(CompletionSignal.VOCABULARY_NOVELTY, 0.25),
177
+ raw=novelty,
178
+ ),
179
+ SignalState(
180
+ signal=CompletionSignal.STRUCTURAL_COMPLETION,
181
+ value=completion_score,
182
+ alive=completion_score < 0.5,
183
+ weight=self._weights.get(CompletionSignal.STRUCTURAL_COMPLETION, 0.25),
184
+ raw=completion_score,
185
+ ),
186
+ ]
187
+
188
+ # Composite score
189
+ composite = sum(s.value * s.weight for s in signals)
190
+ composite = max(0.0, min(1.0, composite))
191
+
192
+ # Grace period: if any secondary signal alive, extend
193
+ any_alive = any(s.alive for s in signals)
194
+ if composite >= self._config.completion_threshold and any_alive:
195
+ if self._grace_budget <= 0:
196
+ self._grace_budget = self._config.grace_tokens
197
+ self._grace_budget -= tokens_consumed
198
+ if self._grace_budget > 0:
199
+ return CompletionResult(
200
+ is_complete=False,
201
+ composite_score=composite,
202
+ signals=signals,
203
+ grace_tokens_remaining=self._grace_budget,
204
+ reason="grace_period",
205
+ )
206
+
207
+ is_complete = composite >= self._config.completion_threshold and not any_alive
208
+ all_dead = not any(s.alive for s in signals)
209
+
210
+ reason = "complete" if is_complete else ("all_signals_dead" if all_dead else "in_progress")
211
+
212
+ return CompletionResult(
213
+ is_complete=is_complete or all_dead,
214
+ composite_score=composite,
215
+ signals=signals,
216
+ grace_tokens_remaining=max(0, self._grace_budget),
217
+ reason=reason,
218
+ )
219
+
220
+ def reset(self) -> None:
221
+ """Clear all state."""
222
+ self._fact_rates.clear()
223
+ self._structural_scores.clear()
224
+ self._novelty_scores.clear()
225
+ self._completion_scores.clear()
226
+ self._prior_trigrams.clear()
227
+ self._fact_baseline = None
228
+ self._structural_baseline = None
229
+ self._novelty_baseline = None
230
+ self._grace_budget = 0
231
+
232
+ # ── Signal scoring ────────────────────────────────────────
233
+
234
+ def _score_fact_flow(self, rate: float) -> float:
235
+ """Score fact flow relative to baseline. High = still flowing."""
236
+ if self._fact_baseline is not None and self._fact_baseline > 0:
237
+ return min(1.0, rate / self._fact_baseline)
238
+ # Pre-calibration: raw rate normalized (10 facts/1000 tokens = 1.0)
239
+ return min(1.0, rate / 10.0)
240
+
241
+ def _score_structural_flow(self, text: str, structural_state: dict[str, object] | None) -> float:
242
+ """Score structural continuation signals. High = structure still developing."""
243
+ score = 0.0
244
+ indicators = 0
245
+
246
+ # Open code blocks
247
+ if text.count("```") % 2 == 1:
248
+ score += 1.0
249
+ indicators += 1
250
+
251
+ # Unfinished lists (last line is list item without blank line after)
252
+ lines = text.rstrip().split("\n")
253
+ if lines:
254
+ last = lines[-1].strip()
255
+ if re.match(r"^(\d+[.)]\s+|[-*]\s+)", last):
256
+ score += 0.8
257
+ indicators += 1
258
+
259
+ # Mid-sentence ending (no terminal punctuation)
260
+ stripped = text.rstrip()
261
+ if stripped and stripped[-1] not in ".!?:;\"')]}":
262
+ score += 0.6
263
+ indicators += 1
264
+
265
+ # Use structural_state if available
266
+ if structural_state:
267
+ if structural_state.get("code_block_open"):
268
+ score += 1.0
269
+ indicators += 1
270
+ if structural_state.get("open_blocks"):
271
+ score += 0.5 * len(structural_state["open_blocks"]) # type: ignore[arg-type]
272
+ indicators += 1
273
+
274
+ return min(1.0, score / max(1, indicators)) if indicators > 0 else 0.5
275
+
276
+ def _score_novelty(self, tokens: list[str]) -> float:
277
+ """3-gram novelty ratio. High = still producing new content."""
278
+ n = 3
279
+ if len(tokens) < n:
280
+ return 1.0
281
+
282
+ current = Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
283
+ if not self._prior_trigrams:
284
+ return 1.0
285
+
286
+ overlap = sum((current & self._prior_trigrams).values())
287
+ total = sum(current.values())
288
+ if total == 0:
289
+ return 1.0
290
+ return 1.0 - (overlap / total)
291
+
292
+ def _score_structural_completion(self, text: str) -> float:
293
+ """Score structural completion markers. High = content is concluding."""
294
+ score = 0.0
295
+
296
+ # Conclusion headings
297
+ if _COMPLETION_HEADINGS.search(text):
298
+ score += 0.6
299
+
300
+ # Closing phrases
301
+ closing = re.search(
302
+ r"\b(in\s+conclusion|to\s+summarize|in\s+summary|overall|finally|"
303
+ r"to\s+conclude|this\s+completes|that\s+covers)\b",
304
+ text, re.IGNORECASE,
305
+ )
306
+ if closing:
307
+ score += 0.3
308
+
309
+ # All lists closed (no dangling list items at end)
310
+ lines = text.rstrip().split("\n")
311
+ if lines:
312
+ last_lines = lines[-3:]
313
+ has_list = any(re.match(r"\s*(\d+[.)]\s+|[-*]\s+)", line) for line in last_lines)
314
+ ends_blank = lines[-1].strip() == ""
315
+ if has_list and ends_blank:
316
+ score += 0.1 # closed list
317
+
318
+ return min(1.0, score)
319
+
320
+ def _update_trigrams(self, tokens: list[str]) -> None:
321
+ n = 3
322
+ if len(tokens) < n:
323
+ return
324
+ for i in range(len(tokens) - n + 1):
325
+ self._prior_trigrams[tuple(tokens[i:i + n])] += 1
326
+
327
+ def _maybe_calibrate(self) -> None:
328
+ """Self-calibrate baselines from first N windows (§4.3)."""
329
+ from itertools import islice
330
+ n = self._config.calibration_windows
331
+ if self._fact_baseline is None and len(self._fact_rates) >= n:
332
+ self._fact_baseline = sum(islice(self._fact_rates, n)) / n or 1.0
333
+ if self._structural_baseline is None and len(self._structural_scores) >= n:
334
+ self._structural_baseline = sum(islice(self._structural_scores, n)) / n or 0.5
335
+ if self._novelty_baseline is None and len(self._novelty_scores) >= n:
336
+ self._novelty_baseline = sum(islice(self._novelty_scores, n)) / n or 0.5
337
+
338
+ def _select_weights(self, content_type: str) -> dict[str, float]:
339
+ """Select signal weights by content type."""
340
+ cfg = self._config
341
+ ct = content_type.upper() if content_type else ""
342
+ if ct == "ENTITY_RICH":
343
+ return cfg.entity_rich_weights
344
+ elif ct in ("REASONING_DENSE", "DOCUMENT"):
345
+ return cfg.reasoning_dense_weights
346
+ elif ct in ("NARRATIVE", "DISCURSIVE"):
347
+ return cfg.narrative_weights
348
+ return cfg.default_weights
@@ -0,0 +1,157 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Chain degradation tracking and regrounding (§04 §3.5.3).
4
+
5
+ d_chain(n) = 1 - ∏(1 - d_i) where d_i is per-window degradation.
6
+ Regrounding: re-extract from raw outputs every N=5 windows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from crp.extraction.types import Fact
16
+
17
+
18
+ @dataclass
19
+ class DegradationMetrics:
20
+ """Per-window degradation measurement."""
21
+
22
+ window_id: str
23
+ d_i: float # per-window degradation (0=perfect, 1=total loss)
24
+ d_chain: float # cumulative chain degradation
25
+ fact_drift: float # how much facts changed on regrounding
26
+ window_index: int
27
+
28
+
29
+ @dataclass
30
+ class RegroundingResult:
31
+ """Result of regrounding: re-extracting from raw outputs."""
32
+
33
+ new_facts: list[Fact]
34
+ reconciled: int # facts confirmed
35
+ drifted: int # facts that changed
36
+ lost: int # facts no longer extractable
37
+ drift_score: float # 0=no drift, 1=total drift
38
+
39
+
40
+ class ChainDegradation:
41
+ """Track cumulative degradation across continuation windows (§04 §3.5.3).
42
+
43
+ Formula: d_chain(n) = 1 - ∏(1 - d_i)
44
+ where d_i is per-window degradation based on extraction quality drop.
45
+
46
+ Triggers regrounding every N=5 windows to reconcile drifted facts.
47
+ """
48
+
49
+ def __init__(self, reground_interval: int = 5) -> None:
50
+ self._reground_interval = max(1, reground_interval)
51
+ self._per_window: list[DegradationMetrics] = []
52
+ self._product: float = 1.0 # running ∏(1 - d_i)
53
+
54
+ def record(
55
+ self,
56
+ window_id: str,
57
+ facts_expected: int,
58
+ facts_produced: int,
59
+ quality_score: float = 1.0,
60
+ ) -> DegradationMetrics:
61
+ """Record per-window degradation.
62
+
63
+ d_i estimated from:
64
+ - fact count drop: (expected - produced) / expected
65
+ - quality score inversion: 1 - quality_score
66
+ Combined with equal weight.
67
+ """
68
+ if facts_expected > 0:
69
+ count_deg = max(0.0, (facts_expected - facts_produced) / facts_expected)
70
+ else:
71
+ count_deg = 0.0
72
+
73
+ quality_deg = max(0.0, 1.0 - quality_score)
74
+
75
+ d_i = 0.5 * count_deg + 0.5 * quality_deg
76
+ d_i = max(0.0, min(1.0, d_i))
77
+
78
+ self._product *= (1.0 - d_i)
79
+ d_chain = 1.0 - self._product
80
+
81
+ metrics = DegradationMetrics(
82
+ window_id=window_id,
83
+ d_i=d_i,
84
+ d_chain=d_chain,
85
+ fact_drift=0.0,
86
+ window_index=len(self._per_window),
87
+ )
88
+ self._per_window.append(metrics)
89
+ return metrics
90
+
91
+ @property
92
+ def chain_degradation(self) -> float:
93
+ """Current cumulative chain degradation d_chain(n)."""
94
+ return 1.0 - self._product
95
+
96
+ @property
97
+ def window_count(self) -> int:
98
+ return len(self._per_window)
99
+
100
+ def should_reground(self) -> bool:
101
+ """Whether regrounding is due (every N windows)."""
102
+ return len(self._per_window) > 0 and len(self._per_window) % self._reground_interval == 0
103
+
104
+ def reground(
105
+ self,
106
+ current_facts: list[Fact],
107
+ regrounded_facts: list[Fact],
108
+ ) -> RegroundingResult:
109
+ """Reconcile current facts against re-extracted facts.
110
+
111
+ Compare by text similarity (word overlap). Facts with overlap < 0.5
112
+ are considered drifted.
113
+ """
114
+ current_texts = {f.id: set(f.text.lower().split()) for f in current_facts}
115
+ reground_texts = {f.id: set(f.text.lower().split()) for f in regrounded_facts}
116
+
117
+ reconciled = 0
118
+ drifted = 0
119
+ lost = 0
120
+
121
+ for _cid, cwords in current_texts.items():
122
+ best_overlap = 0.0
123
+ for _, rwords in reground_texts.items():
124
+ if cwords and rwords:
125
+ overlap = len(cwords & rwords) / max(len(cwords), len(rwords))
126
+ best_overlap = max(best_overlap, overlap)
127
+
128
+ if best_overlap >= 0.7:
129
+ reconciled += 1
130
+ elif best_overlap >= 0.3:
131
+ drifted += 1
132
+ else:
133
+ lost += 1
134
+
135
+ total = max(1, reconciled + drifted + lost)
136
+ drift_score = (drifted + lost) / total
137
+
138
+ # Update last window's fact_drift
139
+ if self._per_window:
140
+ self._per_window[-1].fact_drift = drift_score
141
+
142
+ return RegroundingResult(
143
+ new_facts=regrounded_facts,
144
+ reconciled=reconciled,
145
+ drifted=drifted,
146
+ lost=lost,
147
+ drift_score=drift_score,
148
+ )
149
+
150
+ @property
151
+ def history(self) -> list[DegradationMetrics]:
152
+ return list(self._per_window)
153
+
154
+ def reset(self) -> None:
155
+ """Clear all state."""
156
+ self._per_window.clear()
157
+ self._product = 1.0
@@ -0,0 +1,160 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Document map — incremental TOC tracking across windows (§04 §3.5.2)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class HeadingEntry:
13
+ """A single heading in the document map."""
14
+
15
+ text: str
16
+ level: int # 1–6 for markdown headings
17
+ window_id: str
18
+ position: int # character offset within window output
19
+ completed: bool = False # True if content follows before next heading
20
+
21
+
22
+ @dataclass
23
+ class DocumentMap:
24
+ """Incremental table-of-contents tracker (§04 §3.5.2).
25
+
26
+ Maintains a running TOC as the LLM generates content across windows.
27
+ Tracks heading hierarchy, list positions, and structural completeness.
28
+ """
29
+
30
+ headings: list[HeadingEntry] = field(default_factory=list)
31
+ list_positions: dict[str, int] = field(default_factory=dict) # heading_text → last item #
32
+ current_section: str = ""
33
+ total_sections_expected: int = 0 # 0 = unknown
34
+ windows_processed: int = 0
35
+
36
+ def update(self, text: str, window_id: str) -> list[HeadingEntry]:
37
+ """Process a window output and update the document map.
38
+
39
+ Returns new headings found in this window.
40
+ Deduplicates headings by section number to prevent the same
41
+ section from being tracked multiple times across windows (GAP C fix).
42
+ """
43
+ new_headings: list[HeadingEntry] = []
44
+
45
+ # Build set of existing section numbers for dedup
46
+ existing_section_nums: set[int] = set()
47
+ for h in self.headings:
48
+ m = re.match(r"(\d{1,3})\.", h.text)
49
+ if m:
50
+ existing_section_nums.add(int(m.group(1)))
51
+
52
+ # Extract markdown headings
53
+ for match in re.finditer(r"^(#{1,6})\s+(.+)$", text, re.MULTILINE):
54
+ level = len(match.group(1))
55
+ heading_text = match.group(2).strip()
56
+
57
+ # Dedup: skip if this section number already exists
58
+ sec_match = re.match(r"(\d{1,3})\.", heading_text)
59
+ if sec_match:
60
+ sec_num = int(sec_match.group(1))
61
+ if sec_num in existing_section_nums:
62
+ continue # duplicate section number — skip
63
+ existing_section_nums.add(sec_num)
64
+
65
+ entry = HeadingEntry(
66
+ text=heading_text,
67
+ level=level,
68
+ window_id=window_id,
69
+ position=match.start(),
70
+ )
71
+ new_headings.append(entry)
72
+ self.headings.append(entry)
73
+
74
+ # Mark previous headings as completed if we have new ones
75
+ if new_headings and len(self.headings) > len(new_headings):
76
+ for h in self.headings[:-len(new_headings)]:
77
+ h.completed = True
78
+
79
+ # Track list positions (numbered lists under current section)
80
+ self._update_list_positions(text)
81
+
82
+ # Update current section
83
+ if new_headings:
84
+ self.current_section = new_headings[-1].text
85
+
86
+ self.windows_processed += 1
87
+ return new_headings
88
+
89
+ def get_toc(self) -> str:
90
+ """Render the current TOC as markdown."""
91
+ if not self.headings:
92
+ return ""
93
+
94
+ lines: list[str] = []
95
+ for h in self.headings:
96
+ indent = " " * (h.level - 1)
97
+ marker = "✓" if h.completed else "→" if h.text == self.current_section else "○"
98
+ lines.append(f"{indent}{marker} {h.text}")
99
+
100
+ return "\n".join(lines)
101
+
102
+ def progress(self) -> float:
103
+ """Estimate document completion progress (0.0–1.0)."""
104
+ if not self.headings:
105
+ return 0.0
106
+
107
+ if self.total_sections_expected > 0:
108
+ return min(1.0, len(self.headings) / self.total_sections_expected)
109
+
110
+ completed = sum(1 for h in self.headings if h.completed)
111
+ return completed / len(self.headings) if self.headings else 0.0
112
+
113
+ def missing_sections(self, expected: list[str]) -> list[str]:
114
+ """Compare against expected sections and return missing ones."""
115
+ found = {h.text.lower() for h in self.headings}
116
+ return [s for s in expected if s.lower() not in found]
117
+
118
+ def to_dict(self) -> dict[str, object]:
119
+ return {
120
+ "headings": [
121
+ {
122
+ "text": h.text,
123
+ "level": h.level,
124
+ "window_id": h.window_id,
125
+ "position": h.position,
126
+ "completed": h.completed,
127
+ }
128
+ for h in self.headings
129
+ ],
130
+ "list_positions": self.list_positions,
131
+ "current_section": self.current_section,
132
+ "total_sections_expected": self.total_sections_expected,
133
+ "windows_processed": self.windows_processed,
134
+ }
135
+
136
+ @classmethod
137
+ def from_dict(cls, data: dict[str, object]) -> DocumentMap:
138
+ headings = [
139
+ HeadingEntry(**h) # type: ignore[arg-type]
140
+ for h in data.get("headings", []) # type: ignore[union-attr, attr-defined]
141
+ ]
142
+ return cls(
143
+ headings=headings,
144
+ list_positions=dict(data.get("list_positions", {})), # type: ignore[arg-type, call-overload]
145
+ current_section=str(data.get("current_section", "")),
146
+ total_sections_expected=int(data.get("total_sections_expected", 0)), # type: ignore[call-overload]
147
+ windows_processed=int(data.get("windows_processed", 0)), # type: ignore[call-overload]
148
+ )
149
+
150
+ # ── Internal ──────────────────────────────────────────────
151
+
152
+ def _update_list_positions(self, text: str) -> None:
153
+ """Track numbered list positions."""
154
+ section = self.current_section or "__root__"
155
+ max_num = 0
156
+ for match in re.finditer(r"^(\d+)[.)]\s+", text, re.MULTILINE):
157
+ num = int(match.group(1))
158
+ max_num = max(max_num, num)
159
+ if max_num > 0:
160
+ self.list_positions[section] = max_num