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,360 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Extraction pipeline orchestration — blackboard-reactive 6-stage pipeline.
|
|
4
|
+
|
|
5
|
+
Implements the graduated extraction decision tree, self-calibrating baselines,
|
|
6
|
+
and stage escalation logic per §3.2.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import statistics
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from crp.core.task_intent import TaskIntent
|
|
17
|
+
from crp.extraction.complexity import detect_content_complexity
|
|
18
|
+
from crp.extraction.stage1_regex import RegexExtractor
|
|
19
|
+
from crp.extraction.stage2_statistical import StatisticalExtractor
|
|
20
|
+
from crp.extraction.stage3_gliner import GLiNERExtractor, derive_labels_from_noun_phrases
|
|
21
|
+
from crp.extraction.stage4_uie import UIEExtractor
|
|
22
|
+
from crp.extraction.stage5_discourse import DiscourseExtractor, count_discourse_markers
|
|
23
|
+
from crp.extraction.stage6_llm import DispatchFn, LLMExtractor
|
|
24
|
+
from crp.extraction.types import (
|
|
25
|
+
ContentType,
|
|
26
|
+
ExtractionResult,
|
|
27
|
+
Fact,
|
|
28
|
+
FactEdge,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Self-calibrating baseline
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
_CALIBRATION_WINDOW_COUNT = 5 # Initial lock after N windows
|
|
38
|
+
_RECALIBRATION_INTERVAL = 10 # Recalibrate every N windows after lock
|
|
39
|
+
_ROLLING_WINDOW_SIZE = 10 # Rolling window for recalibration
|
|
40
|
+
_DEFAULT_STAGE2_BASELINE = 10 # Default facts before calibration
|
|
41
|
+
_DEFAULT_STAGE3_BASELINE = 5
|
|
42
|
+
_DEFAULT_CONFIDENCE_FLOOR = 0.6
|
|
43
|
+
_DRIFT_THRESHOLD = 0.30 # Recalibrate if yield drifts > 30% from baseline
|
|
44
|
+
# Gap G fix: Raised threshold so ML stages (GLiNER, UIE) fire more often.
|
|
45
|
+
# At 50, stages 1-2 would short-circuit on moderately rich text, preventing
|
|
46
|
+
# NER and relation extraction. At 120, ML stages run for most content,
|
|
47
|
+
# producing richer fact graphs and CKF edges.
|
|
48
|
+
_SHORT_CIRCUIT_THRESHOLD = 120 # Skip later stages only with very rich extraction (§audit M6)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class CalibrationState:
|
|
53
|
+
"""Tracks self-calibrating baselines for stage escalation.
|
|
54
|
+
|
|
55
|
+
Baselines are initially locked after ``_CALIBRATION_WINDOW_COUNT``
|
|
56
|
+
windows. After that, the system *periodically recalibrates* every
|
|
57
|
+
``_RECALIBRATION_INTERVAL`` windows using a rolling window of the
|
|
58
|
+
most recent results. This prevents stale baselines when extraction
|
|
59
|
+
profiles change (e.g. when new content domains appear or stages
|
|
60
|
+
come online).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
baseline_stage_2: float = _DEFAULT_STAGE2_BASELINE
|
|
64
|
+
baseline_stage_3: float = _DEFAULT_STAGE3_BASELINE
|
|
65
|
+
baseline_confidence_floor: float = _DEFAULT_CONFIDENCE_FLOOR
|
|
66
|
+
baseline_locked: bool = False
|
|
67
|
+
calibration_epoch: int = 0 # How many times baselines have been calibrated
|
|
68
|
+
_results: list[ExtractionResult] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
def record(self, result: ExtractionResult) -> None:
|
|
71
|
+
"""Record an extraction result. Calibrates/recalibrates as needed."""
|
|
72
|
+
self._results.append(result)
|
|
73
|
+
n = len(self._results)
|
|
74
|
+
|
|
75
|
+
if not self.baseline_locked and n >= _CALIBRATION_WINDOW_COUNT:
|
|
76
|
+
# Initial calibration
|
|
77
|
+
self._calibrate(self._results[:_CALIBRATION_WINDOW_COUNT])
|
|
78
|
+
elif self.baseline_locked and n % _RECALIBRATION_INTERVAL == 0:
|
|
79
|
+
# Periodic recalibration with rolling window
|
|
80
|
+
recent = self._results[-_ROLLING_WINDOW_SIZE:]
|
|
81
|
+
if self._detect_drift(recent):
|
|
82
|
+
logger.info("Baseline drift detected at window %d — recalibrating", n)
|
|
83
|
+
self._calibrate(recent)
|
|
84
|
+
|
|
85
|
+
# Cap stored results to prevent unbounded growth
|
|
86
|
+
_MAX_RESULTS = _ROLLING_WINDOW_SIZE * 3
|
|
87
|
+
if len(self._results) > _MAX_RESULTS:
|
|
88
|
+
self._results = self._results[-_ROLLING_WINDOW_SIZE:]
|
|
89
|
+
|
|
90
|
+
def _calibrate(self, results: list[ExtractionResult]) -> None:
|
|
91
|
+
"""Compute baselines from the given result set."""
|
|
92
|
+
# Stage 2 yield mean
|
|
93
|
+
s2 = [r.stage_yields.get(2, 0) for r in results]
|
|
94
|
+
if s2:
|
|
95
|
+
self.baseline_stage_2 = statistics.mean(s2)
|
|
96
|
+
|
|
97
|
+
# Stage 3 yield mean (if ran)
|
|
98
|
+
s3 = [r.stage_yields.get(3, 0) for r in results if 3 in r.stages_run]
|
|
99
|
+
if s3:
|
|
100
|
+
self.baseline_stage_3 = statistics.mean(s3)
|
|
101
|
+
|
|
102
|
+
# Confidence 10th percentile
|
|
103
|
+
all_conf = [f.confidence for r in results for f in r.facts]
|
|
104
|
+
if len(all_conf) >= 10:
|
|
105
|
+
self.baseline_confidence_floor = statistics.quantiles(all_conf, n=10)[0]
|
|
106
|
+
|
|
107
|
+
self.baseline_locked = True
|
|
108
|
+
self.calibration_epoch += 1
|
|
109
|
+
logger.info(
|
|
110
|
+
"Baselines calibrated (epoch %d) — stage2=%.1f, stage3=%.1f, conf_floor=%.2f",
|
|
111
|
+
self.calibration_epoch,
|
|
112
|
+
self.baseline_stage_2,
|
|
113
|
+
self.baseline_stage_3,
|
|
114
|
+
self.baseline_confidence_floor,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _detect_drift(self, recent: list[ExtractionResult]) -> bool:
|
|
118
|
+
"""Return True if recent yields have drifted significantly from baselines."""
|
|
119
|
+
if not recent:
|
|
120
|
+
return False
|
|
121
|
+
s2_recent = [r.stage_yields.get(2, 0) for r in recent]
|
|
122
|
+
if s2_recent and self.baseline_stage_2 > 0:
|
|
123
|
+
mean_s2 = statistics.mean(s2_recent)
|
|
124
|
+
drift = abs(mean_s2 - self.baseline_stage_2) / max(self.baseline_stage_2, 1)
|
|
125
|
+
if drift > _DRIFT_THRESHOLD:
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def should_escalate_stage_3(self, stage_1_2_yield: int) -> bool:
|
|
130
|
+
return stage_1_2_yield < self.baseline_stage_2
|
|
131
|
+
|
|
132
|
+
def should_escalate_stage_4(self, stage_3_relation_yield: float) -> bool:
|
|
133
|
+
return stage_3_relation_yield < 0.1
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def results_count(self) -> int:
|
|
137
|
+
return len(self._results)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Pipeline
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class ExtractionPipeline:
|
|
145
|
+
"""Blackboard-reactive 6-stage extraction pipeline.
|
|
146
|
+
|
|
147
|
+
Usage::
|
|
148
|
+
|
|
149
|
+
pipeline = ExtractionPipeline()
|
|
150
|
+
result = pipeline.extract(text, task_intent)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
enable_stage_3: bool = True,
|
|
157
|
+
enable_stage_4: bool = True,
|
|
158
|
+
enable_stage_5: bool = True,
|
|
159
|
+
enable_stage_6: bool = False, # MAY — disabled by default
|
|
160
|
+
dispatch_fn: DispatchFn | None = None,
|
|
161
|
+
short_circuit_threshold: int = _SHORT_CIRCUIT_THRESHOLD,
|
|
162
|
+
) -> None:
|
|
163
|
+
self._stage1 = RegexExtractor()
|
|
164
|
+
self._stage2 = StatisticalExtractor()
|
|
165
|
+
self._stage3 = GLiNERExtractor()
|
|
166
|
+
self._stage4 = UIEExtractor()
|
|
167
|
+
self._stage5 = DiscourseExtractor()
|
|
168
|
+
self._stage6 = LLMExtractor(dispatch_fn)
|
|
169
|
+
|
|
170
|
+
self._enable_3 = enable_stage_3
|
|
171
|
+
self._enable_4 = enable_stage_4
|
|
172
|
+
self._enable_5 = enable_stage_5
|
|
173
|
+
self._enable_6 = enable_stage_6
|
|
174
|
+
self._short_circuit_threshold = short_circuit_threshold
|
|
175
|
+
|
|
176
|
+
self._calibration = CalibrationState()
|
|
177
|
+
|
|
178
|
+
# Eagerly load enabled ML models at init to avoid cold-start latency
|
|
179
|
+
# during first dispatch. GLiNER (~200MB) takes 10-20s to load; doing it
|
|
180
|
+
# here means the cost is paid once at protocol start, not mid-pipeline.
|
|
181
|
+
if self._enable_3:
|
|
182
|
+
self._stage3._ensure_model()
|
|
183
|
+
if self._enable_4:
|
|
184
|
+
self._stage4._ensure_model()
|
|
185
|
+
|
|
186
|
+
# -- Configuration ------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def calibration(self) -> CalibrationState:
|
|
190
|
+
return self._calibration
|
|
191
|
+
|
|
192
|
+
def set_dispatch_fn(self, fn: DispatchFn) -> None:
|
|
193
|
+
self._stage6.set_dispatch(fn)
|
|
194
|
+
|
|
195
|
+
def register_regex_pattern(
|
|
196
|
+
self, name: str, pattern: str, category: str, confidence: float = 0.90,
|
|
197
|
+
) -> None:
|
|
198
|
+
self._stage1.register_pattern(name, pattern, category, confidence)
|
|
199
|
+
|
|
200
|
+
# -- Extraction ---------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def extract(
|
|
203
|
+
self,
|
|
204
|
+
text: str,
|
|
205
|
+
task_intent: TaskIntent | None = None,
|
|
206
|
+
source_window_id: str = "",
|
|
207
|
+
) -> ExtractionResult:
|
|
208
|
+
"""Run the graduated extraction pipeline.
|
|
209
|
+
|
|
210
|
+
Stages 1-2 always run. Stages 3-6 run conditionally based on
|
|
211
|
+
content complexity, yield thresholds, and availability.
|
|
212
|
+
"""
|
|
213
|
+
t0 = time.monotonic()
|
|
214
|
+
result = ExtractionResult(source_window_id=source_window_id)
|
|
215
|
+
all_facts: list[Fact] = []
|
|
216
|
+
all_edges: list[FactEdge] = []
|
|
217
|
+
stage_latency: dict[int, float] = {}
|
|
218
|
+
|
|
219
|
+
# -- Content complexity detection -----------------------------------
|
|
220
|
+
content_type = detect_content_complexity(text)
|
|
221
|
+
result.content_type = content_type
|
|
222
|
+
result.discourse_markers_found = count_discourse_markers(text)
|
|
223
|
+
|
|
224
|
+
word_count = max(len(text.split()), 1)
|
|
225
|
+
|
|
226
|
+
# ── STAGE 1: Regex (ALWAYS) ───────────────────────────────────────
|
|
227
|
+
ts = time.monotonic()
|
|
228
|
+
s1_facts = self._stage1.extract(text, source_window_id)
|
|
229
|
+
stage_latency[1] = (time.monotonic() - ts) * 1000
|
|
230
|
+
all_facts.extend(s1_facts)
|
|
231
|
+
result.stages_run.append(1)
|
|
232
|
+
result.stage_yields[1] = len(s1_facts)
|
|
233
|
+
|
|
234
|
+
# ── STAGE 2: Statistical (ALWAYS) ─────────────────────────────────
|
|
235
|
+
ts = time.monotonic()
|
|
236
|
+
s2_facts = self._stage2.extract(text, source_window_id)
|
|
237
|
+
stage_latency[2] = (time.monotonic() - ts) * 1000
|
|
238
|
+
all_facts.extend(s2_facts)
|
|
239
|
+
result.stages_run.append(2)
|
|
240
|
+
result.stage_yields[2] = len(s2_facts)
|
|
241
|
+
|
|
242
|
+
combined_s1_s2 = len(s1_facts) + len(s2_facts)
|
|
243
|
+
|
|
244
|
+
# Short-circuit: skip expensive ML stages if early stages extracted enough (§audit M6)
|
|
245
|
+
_short_circuited = (
|
|
246
|
+
self._short_circuit_threshold > 0
|
|
247
|
+
and combined_s1_s2 >= self._short_circuit_threshold
|
|
248
|
+
)
|
|
249
|
+
if _short_circuited:
|
|
250
|
+
logger.info(
|
|
251
|
+
"Short-circuit: %d facts from stages 1-2 >= threshold %d, skipping stages 3-6",
|
|
252
|
+
combined_s1_s2, self._short_circuit_threshold,
|
|
253
|
+
)
|
|
254
|
+
result.stages_skipped.extend([s for s in (3, 4, 5, 6) if s not in result.stages_skipped])
|
|
255
|
+
|
|
256
|
+
# ── STAGE 3: GLiNER (conditional) ─────────────────────────────────
|
|
257
|
+
if not _short_circuited and self._enable_3 and self._calibration.should_escalate_stage_3(combined_s1_s2):
|
|
258
|
+
from crp.license_guard import is_feature_allowed
|
|
259
|
+
if not is_feature_allowed("stage_3"):
|
|
260
|
+
result.stages_skipped.append(3)
|
|
261
|
+
elif self._stage3.is_available:
|
|
262
|
+
ts = time.monotonic()
|
|
263
|
+
# Derive labels from Stage 2 noun phrases
|
|
264
|
+
noun_phrases = [f.text for f in s2_facts if f.category == "noun_phrase"]
|
|
265
|
+
labels = derive_labels_from_noun_phrases(noun_phrases) if noun_phrases else None
|
|
266
|
+
s3_facts = self._stage3.extract(
|
|
267
|
+
text, labels=labels, source_window_id=source_window_id,
|
|
268
|
+
)
|
|
269
|
+
stage_latency[3] = (time.monotonic() - ts) * 1000
|
|
270
|
+
all_facts.extend(s3_facts)
|
|
271
|
+
result.stages_run.append(3)
|
|
272
|
+
result.stage_yields[3] = len(s3_facts)
|
|
273
|
+
result.escalation_triggers.append(
|
|
274
|
+
f"stage_1_2_yield={combined_s1_s2} < baseline={self._calibration.baseline_stage_2:.0f}"
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
result.stages_skipped.append(3)
|
|
278
|
+
else:
|
|
279
|
+
result.stages_skipped.append(3)
|
|
280
|
+
|
|
281
|
+
# ── STAGE 4: UIE (conditional) ────────────────────────────────────
|
|
282
|
+
stage_3_ran = 3 in result.stages_run
|
|
283
|
+
if not _short_circuited and self._enable_4 and stage_3_ran:
|
|
284
|
+
# Relation yield from Stage 3 — approximate by counting facts / sentences
|
|
285
|
+
_sent_count = max(len(text.split(".")), 1)
|
|
286
|
+
s3_yield = result.stage_yields.get(3, 0)
|
|
287
|
+
relation_per_sent = s3_yield / _sent_count
|
|
288
|
+
if self._calibration.should_escalate_stage_4(relation_per_sent):
|
|
289
|
+
if self._stage4.is_available:
|
|
290
|
+
ts = time.monotonic()
|
|
291
|
+
s4_facts, s4_edges = self._stage4.extract(text, source_window_id)
|
|
292
|
+
stage_latency[4] = (time.monotonic() - ts) * 1000
|
|
293
|
+
all_facts.extend(s4_facts)
|
|
294
|
+
all_edges.extend(s4_edges)
|
|
295
|
+
result.stages_run.append(4)
|
|
296
|
+
result.stage_yields[4] = len(s4_facts)
|
|
297
|
+
result.escalation_triggers.append(
|
|
298
|
+
f"stage_3_relation_yield={relation_per_sent:.2f} < 0.1"
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
result.stages_skipped.append(4)
|
|
302
|
+
else:
|
|
303
|
+
result.stages_skipped.append(4)
|
|
304
|
+
else:
|
|
305
|
+
result.stages_skipped.append(4)
|
|
306
|
+
|
|
307
|
+
# ── STAGE 5: Discourse (conditional on content type) ──────────────
|
|
308
|
+
if not _short_circuited and self._enable_5 and content_type in (ContentType.REASONING_DENSE, ContentType.NARRATIVE):
|
|
309
|
+
ts = time.monotonic()
|
|
310
|
+
s5_facts, s5_edges = self._stage5.extract(text, source_window_id)
|
|
311
|
+
stage_latency[5] = (time.monotonic() - ts) * 1000
|
|
312
|
+
all_facts.extend(s5_facts)
|
|
313
|
+
all_edges.extend(s5_edges)
|
|
314
|
+
result.stages_run.append(5)
|
|
315
|
+
result.stage_yields[5] = len(s5_facts)
|
|
316
|
+
else:
|
|
317
|
+
result.stages_skipped.append(5)
|
|
318
|
+
|
|
319
|
+
# ── STAGE 6: LLM (conditional — rare) ────────────────────────────
|
|
320
|
+
if (
|
|
321
|
+
not _short_circuited
|
|
322
|
+
and self._enable_6
|
|
323
|
+
and content_type == ContentType.REASONING_DENSE
|
|
324
|
+
and self._stage6.is_available
|
|
325
|
+
):
|
|
326
|
+
# Check Stage 5 edge yield
|
|
327
|
+
_sent_count = max(len(text.split(".")), 1)
|
|
328
|
+
s5_edge_yield = len([e for e in all_edges if e.source_stage == 5]) / _sent_count
|
|
329
|
+
if s5_edge_yield < 0.1:
|
|
330
|
+
ts = time.monotonic()
|
|
331
|
+
s6_facts, s6_edges = self._stage6.extract(text, source_window_id)
|
|
332
|
+
stage_latency[6] = (time.monotonic() - ts) * 1000
|
|
333
|
+
all_facts.extend(s6_facts)
|
|
334
|
+
all_edges.extend(s6_edges)
|
|
335
|
+
result.stages_run.append(6)
|
|
336
|
+
result.stage_yields[6] = len(s6_facts)
|
|
337
|
+
result.escalation_triggers.append(
|
|
338
|
+
f"stage_5_edge_yield={s5_edge_yield:.2f} < 0.1"
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
result.stages_skipped.append(6)
|
|
342
|
+
else:
|
|
343
|
+
result.stages_skipped.append(6)
|
|
344
|
+
|
|
345
|
+
# -- Tick Stage 3 idle counter --------------------------------------
|
|
346
|
+
if 3 not in result.stages_run:
|
|
347
|
+
self._stage3.tick_idle()
|
|
348
|
+
|
|
349
|
+
# -- Assemble result ------------------------------------------------
|
|
350
|
+
result.facts = all_facts
|
|
351
|
+
result.edges = all_edges
|
|
352
|
+
result.per_stage_latency = stage_latency
|
|
353
|
+
result.entity_density = len(s1_facts) / max(word_count, 1)
|
|
354
|
+
result.total_extraction_latency_ms = (time.monotonic() - t0) * 1000
|
|
355
|
+
result.finalize()
|
|
356
|
+
|
|
357
|
+
# -- Record for calibration -----------------------------------------
|
|
358
|
+
self._calibration.record(result)
|
|
359
|
+
|
|
360
|
+
return result
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Post-extraction quality gate — 3-tier validation (§3.2 2H).
|
|
4
|
+
|
|
5
|
+
Tier 1: Structural validation (schema, parse success, empty facts).
|
|
6
|
+
Tier 2: Confidence threshold filter (flag low-confidence facts).
|
|
7
|
+
Tier 3: Anomaly detection (fact explosion, zero facts, duplicates).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import statistics
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from crp.extraction.types import (
|
|
17
|
+
ExtractionResult,
|
|
18
|
+
Fact,
|
|
19
|
+
ValidationIssue,
|
|
20
|
+
ValidationResult,
|
|
21
|
+
ValidationSeverity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Tier 1 — Structural Validation
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def structural_validation(
|
|
32
|
+
result: ExtractionResult,
|
|
33
|
+
output_schema: dict[str, Any] | None = None,
|
|
34
|
+
) -> ValidationResult:
|
|
35
|
+
"""Check schema compliance, parse success rate, and empty facts."""
|
|
36
|
+
issues: list[ValidationIssue] = []
|
|
37
|
+
|
|
38
|
+
# Check 1: JSON Schema compliance (if output_schema provided)
|
|
39
|
+
if output_schema:
|
|
40
|
+
try:
|
|
41
|
+
import jsonschema # type: ignore[import-untyped]
|
|
42
|
+
|
|
43
|
+
payload = [{"text": f.text, "category": f.category} for f in result.facts]
|
|
44
|
+
jsonschema.validate(payload, output_schema)
|
|
45
|
+
except ImportError:
|
|
46
|
+
pass # jsonschema optional
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
issues.append(ValidationIssue(
|
|
49
|
+
type="SCHEMA_MISMATCH",
|
|
50
|
+
severity=ValidationSeverity.HIGH,
|
|
51
|
+
detail=str(exc)[:200],
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
# Check 2: Parsing success rate — count facts with very short text
|
|
55
|
+
parse_failures = sum(1 for f in result.facts if not f.text.strip())
|
|
56
|
+
if result.total_facts > 0 and parse_failures > 0.1 * result.total_facts:
|
|
57
|
+
issues.append(ValidationIssue(
|
|
58
|
+
type="HIGH_PARSE_FAILURE_RATE",
|
|
59
|
+
severity=ValidationSeverity.MEDIUM,
|
|
60
|
+
detail=f"{parse_failures} facts could not be parsed",
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
# Check 3: Suspiciously short facts
|
|
64
|
+
empty = [f for f in result.facts if len(f.text.strip()) < 5]
|
|
65
|
+
if empty:
|
|
66
|
+
issues.append(ValidationIssue(
|
|
67
|
+
type="EMPTY_FACTS",
|
|
68
|
+
severity=ValidationSeverity.LOW,
|
|
69
|
+
detail=f"{len(empty)} facts are suspiciously short (<5 chars)",
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
passed = all(i.severity != ValidationSeverity.HIGH for i in issues)
|
|
73
|
+
return ValidationResult(tier=1, passed=passed, issues=issues)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Tier 2 — Confidence Threshold
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def confidence_threshold_filter(
|
|
81
|
+
result: ExtractionResult,
|
|
82
|
+
floor: float = 0.6,
|
|
83
|
+
) -> ValidationResult:
|
|
84
|
+
"""Mark facts below the confidence floor as flagged.
|
|
85
|
+
|
|
86
|
+
Low-confidence facts are NOT excluded — they remain but get a
|
|
87
|
+
0.5× score multiplier in envelope packing.
|
|
88
|
+
"""
|
|
89
|
+
flagged = 0
|
|
90
|
+
for fact in result.facts:
|
|
91
|
+
if fact.confidence < floor:
|
|
92
|
+
fact.flagged_confidence = True
|
|
93
|
+
fact.confidence_flag_reason = f"Below baseline {floor:.2f}"
|
|
94
|
+
flagged += 1
|
|
95
|
+
|
|
96
|
+
issues: list[ValidationIssue] = []
|
|
97
|
+
if flagged:
|
|
98
|
+
issues.append(ValidationIssue(
|
|
99
|
+
type="LOW_CONFIDENCE_FACTS",
|
|
100
|
+
severity=ValidationSeverity.LOW,
|
|
101
|
+
detail=f"{flagged} facts below confidence floor {floor:.2f}",
|
|
102
|
+
))
|
|
103
|
+
return ValidationResult(tier=2, passed=True, issues=issues)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Tier 3 — Anomaly Detection
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def anomaly_detection(
|
|
111
|
+
result: ExtractionResult,
|
|
112
|
+
history: list[ExtractionResult] | None = None,
|
|
113
|
+
) -> ValidationResult:
|
|
114
|
+
"""Check for fact-count explosion, zero facts, and duplicates."""
|
|
115
|
+
issues: list[ValidationIssue] = []
|
|
116
|
+
|
|
117
|
+
# Check 1: Fact count explosion (>5× typical)
|
|
118
|
+
if history and len(history) >= 2:
|
|
119
|
+
typical = statistics.mean([r.total_facts for r in history[-5:]])
|
|
120
|
+
if typical > 0 and result.total_facts > 5 * typical:
|
|
121
|
+
issues.append(ValidationIssue(
|
|
122
|
+
type="UNUSUALLY_HIGH_FACT_COUNT",
|
|
123
|
+
severity=ValidationSeverity.MEDIUM,
|
|
124
|
+
detail=(
|
|
125
|
+
f"{result.total_facts} facts vs typical ~{typical:.0f}. "
|
|
126
|
+
"Possible extraction running on stack trace?"
|
|
127
|
+
),
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
# Check 2: Zero facts
|
|
131
|
+
if result.total_facts == 0:
|
|
132
|
+
issues.append(ValidationIssue(
|
|
133
|
+
type="NO_FACTS_EXTRACTED",
|
|
134
|
+
severity=ValidationSeverity.MEDIUM,
|
|
135
|
+
detail="Zero facts extracted — content may be too short or unstructured",
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
# Check 3: >20% duplicates
|
|
139
|
+
hashes = [hash(f.text) for f in result.facts]
|
|
140
|
+
duplicates = len(hashes) - len(set(hashes))
|
|
141
|
+
if result.total_facts > 0 and duplicates > 0.2 * result.total_facts:
|
|
142
|
+
issues.append(ValidationIssue(
|
|
143
|
+
type="EXCESSIVE_NEAR_DUPLICATES",
|
|
144
|
+
severity=ValidationSeverity.LOW,
|
|
145
|
+
detail=f"{duplicates} near-duplicate facts detected",
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
passed = all(i.severity != ValidationSeverity.HIGH for i in issues)
|
|
149
|
+
return ValidationResult(tier=3, passed=passed, issues=issues)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Fact normalisation
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def normalize_facts(facts: list[Fact], max_tokens: int = 100, min_tokens: int = 5) -> list[Fact]:
|
|
157
|
+
"""Split overly long facts and merge very short ones.
|
|
158
|
+
|
|
159
|
+
Uses word count as a proxy for token count (accurate tokenisation is Phase 4).
|
|
160
|
+
"""
|
|
161
|
+
normalized: list[Fact] = []
|
|
162
|
+
short_buffer: list[Fact] = []
|
|
163
|
+
|
|
164
|
+
for fact in facts:
|
|
165
|
+
words = fact.text.split()
|
|
166
|
+
wc = len(words)
|
|
167
|
+
|
|
168
|
+
if wc > max_tokens:
|
|
169
|
+
# Split into chunks of ~max_tokens words
|
|
170
|
+
for i in range(0, wc, max_tokens):
|
|
171
|
+
chunk = " ".join(words[i : i + max_tokens])
|
|
172
|
+
normalized.append(Fact(
|
|
173
|
+
text=chunk,
|
|
174
|
+
category=fact.category,
|
|
175
|
+
source_window_id=fact.source_window_id,
|
|
176
|
+
confidence=fact.confidence,
|
|
177
|
+
extraction_stage=fact.extraction_stage,
|
|
178
|
+
metadata={**fact.metadata, "split_from": fact.id},
|
|
179
|
+
))
|
|
180
|
+
elif wc < min_tokens:
|
|
181
|
+
short_buffer.append(fact)
|
|
182
|
+
if len(short_buffer) >= 3:
|
|
183
|
+
merged_text = " | ".join(f.text for f in short_buffer)
|
|
184
|
+
normalized.append(Fact(
|
|
185
|
+
text=merged_text,
|
|
186
|
+
category=short_buffer[0].category,
|
|
187
|
+
source_window_id=short_buffer[0].source_window_id,
|
|
188
|
+
confidence=min(f.confidence for f in short_buffer),
|
|
189
|
+
extraction_stage=short_buffer[0].extraction_stage,
|
|
190
|
+
metadata={"merged_from": [f.id for f in short_buffer]},
|
|
191
|
+
))
|
|
192
|
+
short_buffer.clear()
|
|
193
|
+
else:
|
|
194
|
+
normalized.append(fact)
|
|
195
|
+
|
|
196
|
+
# Flush remaining short facts
|
|
197
|
+
normalized.extend(short_buffer)
|
|
198
|
+
return normalized
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Composite quality gate
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def run_quality_gate(
|
|
206
|
+
result: ExtractionResult,
|
|
207
|
+
*,
|
|
208
|
+
output_schema: dict[str, Any] | None = None,
|
|
209
|
+
confidence_floor: float = 0.6,
|
|
210
|
+
history: list[ExtractionResult] | None = None,
|
|
211
|
+
) -> ExtractionResult:
|
|
212
|
+
"""Run all 3 quality-gate tiers and update the ExtractionResult in-place."""
|
|
213
|
+
all_issues: list[str] = []
|
|
214
|
+
|
|
215
|
+
# Tier 1
|
|
216
|
+
v1 = structural_validation(result, output_schema)
|
|
217
|
+
if not v1.passed:
|
|
218
|
+
result.quality_gate_passed = False
|
|
219
|
+
all_issues.extend(f"[T1] {i.type}: {i.detail}" for i in v1.issues)
|
|
220
|
+
|
|
221
|
+
# Tier 2
|
|
222
|
+
v2 = confidence_threshold_filter(result, confidence_floor)
|
|
223
|
+
all_issues.extend(f"[T2] {i.type}: {i.detail}" for i in v2.issues)
|
|
224
|
+
|
|
225
|
+
# Tier 3
|
|
226
|
+
v3 = anomaly_detection(result, history)
|
|
227
|
+
if not v3.passed:
|
|
228
|
+
result.quality_gate_passed = False
|
|
229
|
+
all_issues.extend(f"[T3] {i.type}: {i.detail}" for i in v3.issues)
|
|
230
|
+
|
|
231
|
+
result.quality_issues = all_issues
|
|
232
|
+
|
|
233
|
+
# Normalise facts
|
|
234
|
+
result.facts = normalize_facts(result.facts)
|
|
235
|
+
result.facts_after_normalization = len(result.facts)
|
|
236
|
+
|
|
237
|
+
return result
|