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
crp/envelope/scoring.py
ADDED
|
@@ -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
|