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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- 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
|