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,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