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,310 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Phase 2 — Bi-encoder scoring (§3.2).
4
+
5
+ Computes composite relevance scores for facts against task aspects.
6
+ Formula (02_CORE §3.2, authoritative):
7
+
8
+ sim = 0.7 × max(cos(fact_emb, aspect_emb)) + 0.3 × cos(fact_emb, full_emb)
9
+ score = sim × recency × novelty + dep_bonus
10
+
11
+ Falls back to word-overlap cosine when sentence-transformers is unavailable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+
20
+ from crp.extraction.types import Fact, FactGraph
21
+
22
+ from .decomposer import _EMBED_DIM, DecompositionResult, _bag_vector, _build_vocab, _tokenize
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # HNSW ANN index support (lazy, optional)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _HNSWLIB_AVAILABLE: bool | None = None
29
+ ANN_THRESHOLD = 1000 # Use ANN when facts > this; brute-force below
30
+
31
+
32
+ def _check_hnswlib() -> bool:
33
+ global _HNSWLIB_AVAILABLE # noqa: PLW0603
34
+ if _HNSWLIB_AVAILABLE is not None:
35
+ return _HNSWLIB_AVAILABLE
36
+ try:
37
+ import hnswlib # type: ignore[import-untyped] # noqa: F401
38
+
39
+ _HNSWLIB_AVAILABLE = True
40
+ except ImportError:
41
+ _HNSWLIB_AVAILABLE = False
42
+ return _HNSWLIB_AVAILABLE
43
+
44
+
45
+ def _ann_query(
46
+ fact_embeddings_list: list[list[float]],
47
+ query_vectors: list[list[float]],
48
+ top_k: int,
49
+ ) -> list[list[int]]:
50
+ """Use HNSW ANN index to find top-K nearest facts per query vector.
51
+
52
+ Returns list of index lists (one per query vector).
53
+ Falls back to brute-force if hnswlib unavailable.
54
+ """
55
+ import numpy as np # type: ignore[import-untyped]
56
+
57
+ if _check_hnswlib() and len(fact_embeddings_list) > ANN_THRESHOLD:
58
+ import hnswlib # type: ignore[import-untyped]
59
+
60
+ dim = len(fact_embeddings_list[0])
61
+ index = hnswlib.Index(space="cosine", dim=dim)
62
+ index.init_index(max_elements=len(fact_embeddings_list), ef_construction=200, M=16)
63
+ data = np.array(fact_embeddings_list, dtype=np.float32)
64
+ index.add_items(data)
65
+ index.set_ef(max(top_k * 2, 50))
66
+
67
+ results: list[list[int]] = []
68
+ for qv in query_vectors:
69
+ labels, _ = index.knn_query(np.array([qv], dtype=np.float32), k=min(top_k, len(fact_embeddings_list)))
70
+ results.append(labels[0].tolist())
71
+ return results
72
+
73
+ # Brute-force fallback (always works)
74
+ results = []
75
+ for qv in query_vectors:
76
+ sims = []
77
+ for i, fv in enumerate(fact_embeddings_list):
78
+ sims.append((i, cosine_similarity(qv, fv)))
79
+ sims.sort(key=lambda x: x[1], reverse=True)
80
+ results.append([idx for idx, _ in sims[:top_k]])
81
+ return results
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Cosine similarity
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
89
+ """Cosine similarity between two equal-length vectors."""
90
+ dot = sum(x * y for x, y in zip(a, b))
91
+ norm_a = math.sqrt(sum(x * x for x in a)) or 1e-12
92
+ norm_b = math.sqrt(sum(x * x for x in b)) or 1e-12
93
+ return dot / (norm_a * norm_b)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Scored fact result
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ @dataclass
102
+ class ScoredFact:
103
+ """A fact with its composite relevance score."""
104
+
105
+ fact: Fact
106
+ similarity: float = 0.0
107
+ recency_weight: float = 1.0
108
+ novelty_weight: float = 1.0
109
+ dependency_bonus: float = 0.0
110
+ composite_score: float = 0.0
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Fact embedding helpers
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def embed_fact_ml(fact: Fact, model: object) -> list[float]:
119
+ """Embed a single fact using a sentence-transformers model."""
120
+ return model.encode([fact.text], show_progress_bar=False).tolist()[0] # type: ignore[attr-defined]
121
+
122
+
123
+ def embed_fact_fallback(fact: Fact, vocab: dict[str, int]) -> list[float]:
124
+ """Embed a fact using bag-of-words fallback."""
125
+ return _bag_vector(_tokenize(fact.text), vocab, _EMBED_DIM)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Recency weight: w = e^(-λ × age_in_windows)
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def recency_weight(age_in_windows: int, decay_lambda: float = 0.1) -> float:
134
+ """Exponential recency decay. λ=0.1 → ~20 window half-life."""
135
+ return math.exp(-decay_lambda * age_in_windows)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Novelty weight
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def novelty_weight(seen_count: int) -> float:
144
+ """Novelty multiplier per spec: 0→1.5×, 1-2→1.0×, ≥3→0.5×."""
145
+ if seen_count == 0:
146
+ return 1.5
147
+ if seen_count <= 2:
148
+ return 1.0
149
+ return 0.5
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Dependency bonus
154
+ # ---------------------------------------------------------------------------
155
+
156
+ _DEP_BONUS_CAP = 0.5
157
+
158
+
159
+ def dependency_bonus(
160
+ fact: Fact,
161
+ graph: FactGraph,
162
+ recent_scored: dict[str, float],
163
+ ) -> float:
164
+ """Sum of score × edge.confidence × 0.3 for graph-connected scored facts, capped 0.5."""
165
+ bonus = 0.0
166
+ for edge in graph.edges_from(fact.id):
167
+ neighbour_score = recent_scored.get(edge.target_id, 0.0)
168
+ conf = edge.confidence if isinstance(edge.confidence, (int, float)) else 0.0
169
+ bonus += neighbour_score * conf * 0.3
170
+ for edge in graph.edges_to(fact.id):
171
+ neighbour_score = recent_scored.get(edge.source_id, 0.0)
172
+ conf = edge.confidence if isinstance(edge.confidence, (int, float)) else 0.0
173
+ bonus += neighbour_score * conf * 0.3
174
+ return min(bonus, _DEP_BONUS_CAP)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Main scoring function
179
+ # ---------------------------------------------------------------------------
180
+
181
+ _ASPECT_WEIGHT = 0.7
182
+ _FULL_WEIGHT = 0.3
183
+
184
+
185
+ @dataclass
186
+ class ScoringConfig:
187
+ """Tuneable parameters for bi-encoder scoring."""
188
+
189
+ decay_lambda: float = 0.1
190
+ aspect_weight: float = _ASPECT_WEIGHT
191
+ full_weight: float = _FULL_WEIGHT
192
+ dep_bonus_cap: float = _DEP_BONUS_CAP
193
+
194
+
195
+ def score_facts(
196
+ facts: list[Fact],
197
+ decomposition: DecompositionResult,
198
+ graph: FactGraph,
199
+ *,
200
+ current_window_index: int = 0,
201
+ seen_counts: dict[str, int] | None = None,
202
+ fact_window_indices: dict[str, int] | None = None,
203
+ config: ScoringConfig | None = None,
204
+ ) -> list[ScoredFact]:
205
+ """Score *facts* against the decomposed task aspects.
206
+
207
+ Returns a list of ScoredFact sorted by composite_score descending.
208
+
209
+ Parameters
210
+ ----------
211
+ facts : list[Fact]
212
+ Facts to score (from warm state).
213
+ decomposition : DecompositionResult
214
+ Task decomposition output (aspects + embeddings).
215
+ graph : FactGraph
216
+ Fact graph for dependency bonus computation.
217
+ current_window_index : int
218
+ Current window number in the session (for recency).
219
+ seen_counts : dict[str, int] | None
220
+ fact_id → number of times included in previous envelopes.
221
+ fact_window_indices : dict[str, int] | None
222
+ fact_id → window index when the fact was created.
223
+ config : ScoringConfig | None
224
+ Override default scoring parameters.
225
+ """
226
+ if not facts or not decomposition.aspects:
227
+ return []
228
+
229
+ cfg = config or ScoringConfig()
230
+ _seen = seen_counts or {}
231
+ _window_idx = fact_window_indices or {}
232
+
233
+ # -- Compute fact embeddings ------------------------------------------------
234
+ fact_embeddings: dict[str, list[float]] = {}
235
+
236
+ if decomposition.used_ml_model:
237
+ # Import here so we don't fail if sentence-transformers absent
238
+ try:
239
+ from .decomposer import _EMBED_MODEL
240
+
241
+ if _EMBED_MODEL is not None:
242
+ texts = [f.text for f in facts]
243
+ embs = _EMBED_MODEL.encode(texts, show_progress_bar=False).tolist() # type: ignore[attr-defined]
244
+ for f_obj, emb in zip(facts, embs):
245
+ fact_embeddings[f_obj.id] = emb
246
+ except Exception: # noqa: BLE001
247
+ pass
248
+
249
+ if not fact_embeddings:
250
+ # Fallback: bag-of-words
251
+ all_tokens: list[str] = []
252
+ for a in decomposition.aspects:
253
+ all_tokens.extend(_tokenize(a))
254
+ for f_obj in facts:
255
+ all_tokens.extend(_tokenize(f_obj.text))
256
+ vocab = _build_vocab(all_tokens)
257
+ for f_obj in facts:
258
+ fact_embeddings[f_obj.id] = embed_fact_fallback(f_obj, vocab)
259
+
260
+ # -- Score each fact --------------------------------------------------------
261
+ aspect_embs = decomposition.aspect_embeddings
262
+ full_emb = decomposition.full_embedding
263
+ scored: list[ScoredFact] = []
264
+ recent_scored: dict[str, float] = {} # for dependency bonus (last 50)
265
+
266
+ for f_obj in facts:
267
+ f_emb = fact_embeddings.get(f_obj.id)
268
+ if f_emb is None:
269
+ continue
270
+
271
+ # Similarity: 0.7 × max(cos(fact, aspect)) + 0.3 × cos(fact, full)
272
+ if aspect_embs:
273
+ max_aspect_sim = max(cosine_similarity(f_emb, a_emb) for a_emb in aspect_embs)
274
+ else:
275
+ max_aspect_sim = 0.0
276
+ full_sim = cosine_similarity(f_emb, full_emb) if full_emb else 0.0
277
+ sim = cfg.aspect_weight * max_aspect_sim + cfg.full_weight * full_sim
278
+
279
+ # Recency
280
+ age = max(0, current_window_index - _window_idx.get(f_obj.id, 0))
281
+ rec = recency_weight(age, cfg.decay_lambda)
282
+
283
+ # Novelty
284
+ nov = novelty_weight(_seen.get(f_obj.id, 0))
285
+
286
+ # Dependency bonus (uses last 50 scored facts)
287
+ dep = dependency_bonus(f_obj, graph, recent_scored)
288
+
289
+ composite = sim * rec * nov + dep
290
+
291
+ sf = ScoredFact(
292
+ fact=f_obj,
293
+ similarity=sim,
294
+ recency_weight=rec,
295
+ novelty_weight=nov,
296
+ dependency_bonus=dep,
297
+ composite_score=composite,
298
+ )
299
+ scored.append(sf)
300
+
301
+ # Track for dependency bonus of subsequent facts (rolling window of 50)
302
+ recent_scored[f_obj.id] = composite
303
+ if len(recent_scored) > 50:
304
+ # Remove oldest (first inserted) — dict preserves insertion order
305
+ oldest_key = next(iter(recent_scored))
306
+ del recent_scored[oldest_key]
307
+
308
+ # Sort descending by composite score
309
+ scored.sort(key=lambda s: s.composite_score, reverse=True)
310
+ return scored
@@ -0,0 +1,45 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """6-stage graduated extraction pipeline — regex → statistical → GLiNER → UIE → discourse → LLM."""
4
+
5
+ from crp.extraction.complexity import detect_content_complexity
6
+ from crp.extraction.contradiction import apply_supersessions, detect_contradictions
7
+ from crp.extraction.pipeline import ExtractionPipeline
8
+ from crp.extraction.quality_gate import run_quality_gate
9
+ from crp.extraction.structured_output import StructuredOutputHandler
10
+ from crp.extraction.types import (
11
+ ContentType,
12
+ Contradiction,
13
+ ExtractionResult,
14
+ Fact,
15
+ FactEdge,
16
+ FactEvent,
17
+ FactGraph,
18
+ RelationType,
19
+ ValidationIssue,
20
+ ValidationResult,
21
+ ValidationSeverity,
22
+ )
23
+
24
+ __all__ = [
25
+ # Data types
26
+ "ContentType",
27
+ "Contradiction",
28
+ "ExtractionResult",
29
+ "Fact",
30
+ "FactEdge",
31
+ "FactEvent",
32
+ "FactGraph",
33
+ "RelationType",
34
+ "ValidationIssue",
35
+ "ValidationResult",
36
+ "ValidationSeverity",
37
+ # Pipeline
38
+ "ExtractionPipeline",
39
+ # Helpers
40
+ "detect_content_complexity",
41
+ "detect_contradictions",
42
+ "apply_supersessions",
43
+ "run_quality_gate",
44
+ "StructuredOutputHandler",
45
+ ]
@@ -0,0 +1,96 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Content complexity detection — classifies text as ENTITY_RICH, REASONING_DENSE, or NARRATIVE.
4
+
5
+ Applied to every window output to determine pipeline routing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from crp.extraction.stage5_discourse import count_discourse_markers
13
+ from crp.extraction.types import ContentType
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _SENT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
20
+ _WORD_RE = re.compile(r"\w+", re.UNICODE)
21
+ _SUBORD_RE = re.compile(
22
+ r"\b(?:that|which|who|whom|whose|where|when|while|"
23
+ r"whereas|although|because|since|if|unless|until|after|before)\b",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+
28
+ def _count_sentences(text: str) -> int:
29
+ """Rough sentence count."""
30
+ return max(len(_SENT_RE.split(text.strip())), 1)
31
+
32
+
33
+ def _count_words(text: str) -> int:
34
+ return len(_WORD_RE.findall(text))
35
+
36
+
37
+ def _count_subordinate_clauses(text: str) -> int:
38
+ return len(_SUBORD_RE.findall(text))
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Stage 1 entity density (inline — avoids circular import)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _ENTITY_PATTERNS = [
46
+ re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"),
47
+ re.compile(r"\bCVE-\d{4}-\d{4,7}\b"),
48
+ re.compile(r"https?://[^\s\"'<>\])}]+", re.ASCII),
49
+ re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"),
50
+ re.compile(r"\b(?:ERR|ERROR|WARN|FATAL|CRITICAL|E|W)[-_]?\d{3,6}\b", re.IGNORECASE),
51
+ re.compile(r"\bv?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.]+)?\b"),
52
+ ]
53
+
54
+
55
+ def _quick_entity_count(text: str) -> int:
56
+ """Fast regex pass for structured entities (subset of Stage 1)."""
57
+ total = 0
58
+ for pat in _ENTITY_PATTERNS:
59
+ total += len(pat.findall(text))
60
+ return total
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Public API
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def detect_content_complexity(text: str) -> ContentType:
68
+ """Classify text complexity for pipeline routing.
69
+
70
+ Thresholds per spec:
71
+ ENTITY_RICH: entity_density > 0.05
72
+ REASONING_DENSE: discourse_ratio > 0.30 OR subordinate_clause_ratio > 0.40
73
+ NARRATIVE: everything else
74
+ """
75
+ word_count = _count_words(text)
76
+ sent_count = _count_sentences(text)
77
+
78
+ # Entity density
79
+ entity_count = _quick_entity_count(text)
80
+ entity_density = entity_count / max(word_count, 1)
81
+
82
+ if entity_density > 0.05:
83
+ return ContentType.ENTITY_RICH
84
+
85
+ # Discourse marker ratio
86
+ discourse_count = count_discourse_markers(text)
87
+ discourse_ratio = discourse_count / max(sent_count, 1)
88
+
89
+ # Subordinate clause ratio
90
+ subord_count = _count_subordinate_clauses(text)
91
+ subord_ratio = subord_count / max(sent_count, 1)
92
+
93
+ if discourse_ratio > 0.30 or subord_ratio > 0.40:
94
+ return ContentType.REASONING_DENSE
95
+
96
+ return ContentType.NARRATIVE
@@ -0,0 +1,132 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Contradiction detection — identifies and handles superseded facts (§3.4).
4
+
5
+ Detection rule: cosine similarity > 0.85 AND normalised edit distance > 0.3.
6
+ Action: mark earlier fact as superseded, 0.5× score multiplier in envelope.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Sequence
13
+
14
+ from crp.extraction.types import Contradiction, Fact, FactEvent
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Similarity helpers (pure-Python, no ML)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def _normalise_text(text: str) -> str:
24
+ """Lowercase and strip extra whitespace."""
25
+ return " ".join(text.lower().split())
26
+
27
+
28
+ def _edit_distance(a: str, b: str) -> int:
29
+ """Levenshtein distance (word-level for efficiency)."""
30
+ wa, wb = a.split(), b.split()
31
+ m, n = len(wa), len(wb)
32
+ if m == 0:
33
+ return n
34
+ if n == 0:
35
+ return m
36
+ # Single-row optimisation
37
+ prev = list(range(n + 1))
38
+ for i in range(1, m + 1):
39
+ curr = [i] + [0] * n
40
+ for j in range(1, n + 1):
41
+ cost = 0 if wa[i - 1] == wb[j - 1] else 1
42
+ curr[j] = min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
43
+ prev = curr
44
+ return prev[n]
45
+
46
+
47
+ def _normalised_edit_distance(a: str, b: str) -> float:
48
+ """Word-level edit distance normalised by max length."""
49
+ na, nb = _normalise_text(a), _normalise_text(b)
50
+ max_len = max(len(na.split()), len(nb.split()), 1)
51
+ return _edit_distance(na, nb) / max_len
52
+
53
+
54
+ def _word_overlap_similarity(a: str, b: str) -> float:
55
+ """Jaccard-based word overlap (fast proxy for cosine similarity).
56
+
57
+ Real cosine similarity requires embeddings (Phase 4). This serves as
58
+ a reasonable stand-in for Phase 2.
59
+ """
60
+ wa = set(_normalise_text(a).split())
61
+ wb = set(_normalise_text(b).split())
62
+ if not wa or not wb:
63
+ return 0.0
64
+ intersection = len(wa & wb)
65
+ union = len(wa | wb)
66
+ return intersection / union
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Public API
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def detect_contradictions(
74
+ new_facts: Sequence[Fact],
75
+ existing_facts: Sequence[Fact],
76
+ similarity_threshold: float = 0.85,
77
+ content_diff_threshold: float = 0.30,
78
+ ) -> list[Contradiction]:
79
+ """Detect contradictions between new and existing facts.
80
+
81
+ A contradiction occurs when two facts are semantically very similar
82
+ (sim > *similarity_threshold*) but textually different enough
83
+ (edit distance > *content_diff_threshold*) to suggest conflicting info.
84
+ """
85
+ contradictions: list[Contradiction] = []
86
+
87
+ for new_fact in new_facts:
88
+ for existing in existing_facts:
89
+ sim = _word_overlap_similarity(new_fact.text, existing.text)
90
+ if sim < similarity_threshold:
91
+ continue
92
+
93
+ diff = _normalised_edit_distance(new_fact.text, existing.text)
94
+ if diff > content_diff_threshold:
95
+ contradictions.append(Contradiction(
96
+ fact_a=existing,
97
+ fact_b=new_fact,
98
+ similarity=round(sim, 4),
99
+ content_diff=round(diff, 4),
100
+ confidence=round(sim * diff, 4),
101
+ ))
102
+
103
+ return contradictions
104
+
105
+
106
+ def apply_supersessions(
107
+ contradictions: list[Contradiction],
108
+ ) -> list[FactEvent]:
109
+ """Mark fact_a as superseded by fact_b for each contradiction.
110
+
111
+ Returns a list of FactEvent records for the audit log.
112
+ """
113
+ events: list[FactEvent] = []
114
+ for c in contradictions:
115
+ if c.fact_a is None or c.fact_b is None:
116
+ continue
117
+ c.fact_a.superseded_by = c.fact_b.id
118
+ c.fact_a.supersession_confidence = c.confidence
119
+ events.append(FactEvent(
120
+ event_type="superseded",
121
+ fact_id=c.fact_a.id,
122
+ payload={
123
+ "superseded_by": c.fact_b.id,
124
+ "similarity": c.similarity,
125
+ "content_diff": c.content_diff,
126
+ },
127
+ ))
128
+ logger.debug(
129
+ "Fact %s superseded by %s (sim=%.2f, diff=%.2f)",
130
+ c.fact_a.id[:8], c.fact_b.id[:8], c.similarity, c.content_diff,
131
+ )
132
+ return events