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,135 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Quality tier classification and reporting (§05, §10).
|
|
4
|
+
|
|
5
|
+
Each CRP session runs at a quality tier that tells the user how well
|
|
6
|
+
the context pipeline is performing:
|
|
7
|
+
|
|
8
|
+
S — all signals green, overhead < 5 %
|
|
9
|
+
A — minor gaps, overhead < 10 %
|
|
10
|
+
B — acceptable, overhead < 15 %
|
|
11
|
+
C — degraded, hierarchical processing needed
|
|
12
|
+
D — minimal / fallback mode
|
|
13
|
+
|
|
14
|
+
QualityReporter takes a handful of easily-computed metrics and maps
|
|
15
|
+
them to one of these tiers. It can also detect *degradation* (tier
|
|
16
|
+
dropping below a previous high-water mark).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QualityTier(Enum):
|
|
27
|
+
"""CRP quality tiers (best → worst)."""
|
|
28
|
+
|
|
29
|
+
S = "S"
|
|
30
|
+
A = "A"
|
|
31
|
+
B = "B"
|
|
32
|
+
C = "C"
|
|
33
|
+
D = "D"
|
|
34
|
+
|
|
35
|
+
def __lt__(self, other: QualityTier) -> bool:
|
|
36
|
+
order = ["S", "A", "B", "C", "D"]
|
|
37
|
+
return order.index(self.value) < order.index(other.value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Threshold rules: (max_overhead_pct, max_fact_miss_pct, tier)
|
|
41
|
+
# Checked in order — first match wins.
|
|
42
|
+
_TIER_RULES: list[tuple[float, float, QualityTier]] = [
|
|
43
|
+
(5.0, 2.0, QualityTier.S),
|
|
44
|
+
(10.0, 10.0, QualityTier.A),
|
|
45
|
+
(15.0, 25.0, QualityTier.B),
|
|
46
|
+
(30.0, 50.0, QualityTier.C),
|
|
47
|
+
# Everything else → D
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class QualityReport:
|
|
53
|
+
"""Result of a quality assessment."""
|
|
54
|
+
|
|
55
|
+
tier: QualityTier
|
|
56
|
+
overhead_pct: float
|
|
57
|
+
fact_miss_pct: float
|
|
58
|
+
degraded: bool = False # True if tier dropped below previous high
|
|
59
|
+
details: dict[str, Any] | None = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"tier": self.tier.value,
|
|
64
|
+
"overhead_pct": round(self.overhead_pct, 2),
|
|
65
|
+
"fact_miss_pct": round(self.fact_miss_pct, 2),
|
|
66
|
+
"degraded": self.degraded,
|
|
67
|
+
"details": self.details or {},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class QualityReporter:
|
|
72
|
+
"""Classify and track quality tiers across a session.
|
|
73
|
+
|
|
74
|
+
Usage::
|
|
75
|
+
|
|
76
|
+
qr = QualityReporter()
|
|
77
|
+
report = qr.assess(overhead_pct=4.2, fact_miss_pct=1.0)
|
|
78
|
+
assert report.tier == QualityTier.S
|
|
79
|
+
|
|
80
|
+
# Later, if overhead rises:
|
|
81
|
+
report2 = qr.assess(overhead_pct=18.0, fact_miss_pct=30.0)
|
|
82
|
+
assert report2.tier == QualityTier.C
|
|
83
|
+
assert report2.degraded # dropped from S → C
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._high_water: QualityTier | None = None
|
|
88
|
+
self._history: list[QualityReport] = []
|
|
89
|
+
|
|
90
|
+
def assess(
|
|
91
|
+
self,
|
|
92
|
+
overhead_pct: float,
|
|
93
|
+
fact_miss_pct: float,
|
|
94
|
+
details: dict[str, Any] | None = None,
|
|
95
|
+
) -> QualityReport:
|
|
96
|
+
"""Compute the current quality tier and check for degradation.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
overhead_pct: Current overhead as a percentage (0-100).
|
|
100
|
+
fact_miss_pct: Percentage of available facts *not* included
|
|
101
|
+
in the envelope (0-100).
|
|
102
|
+
details: Optional opaque dict attached to the report.
|
|
103
|
+
"""
|
|
104
|
+
tier = self._classify(overhead_pct, fact_miss_pct)
|
|
105
|
+
degraded = self._high_water is not None and self._high_water < tier
|
|
106
|
+
|
|
107
|
+
# Update high-water mark.
|
|
108
|
+
if self._high_water is None or tier < self._high_water:
|
|
109
|
+
self._high_water = tier
|
|
110
|
+
|
|
111
|
+
report = QualityReport(
|
|
112
|
+
tier=tier,
|
|
113
|
+
overhead_pct=overhead_pct,
|
|
114
|
+
fact_miss_pct=fact_miss_pct,
|
|
115
|
+
degraded=degraded,
|
|
116
|
+
details=details,
|
|
117
|
+
)
|
|
118
|
+
self._history.append(report)
|
|
119
|
+
return report
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def current_tier(self) -> QualityTier | None:
|
|
123
|
+
"""Most recently assessed tier, or ``None`` if never assessed."""
|
|
124
|
+
return self._history[-1].tier if self._history else None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def history(self) -> list[QualityReport]:
|
|
128
|
+
return list(self._history)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _classify(overhead_pct: float, fact_miss_pct: float) -> QualityTier:
|
|
132
|
+
for max_oh, max_fm, tier in _TIER_RULES:
|
|
133
|
+
if overhead_pct <= max_oh and fact_miss_pct <= max_fm:
|
|
134
|
+
return tier
|
|
135
|
+
return QualityTier.D
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Structured logging with correlation IDs for CRP pipeline (§audit H9).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- Per-request correlation IDs propagated across dispatch pipeline stages
|
|
7
|
+
- JSON-structured log formatter for machine-parseable logs
|
|
8
|
+
- Context-local storage for request metadata
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextvars
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Context variable for correlation ID — propagated across async boundaries
|
|
21
|
+
_correlation_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
22
|
+
"crp_correlation_id", default=""
|
|
23
|
+
)
|
|
24
|
+
_session_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
25
|
+
"crp_session_id", default=""
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def new_correlation_id() -> str:
|
|
30
|
+
"""Generate and set a new correlation ID for the current context."""
|
|
31
|
+
cid = uuid.uuid4().hex[:12]
|
|
32
|
+
_correlation_id.set(cid)
|
|
33
|
+
return cid
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_correlation_id() -> str:
|
|
37
|
+
"""Get the current correlation ID (empty string if none set)."""
|
|
38
|
+
return _correlation_id.get()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_session_context(session_id: str) -> None:
|
|
42
|
+
"""Set the session ID for the current logging context."""
|
|
43
|
+
_session_id.set(session_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StructuredFormatter(logging.Formatter):
|
|
47
|
+
"""JSON log formatter with automatic correlation ID injection."""
|
|
48
|
+
|
|
49
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
50
|
+
entry: dict[str, Any] = {
|
|
51
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)),
|
|
52
|
+
"level": record.levelname,
|
|
53
|
+
"logger": record.name,
|
|
54
|
+
"msg": record.getMessage(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cid = _correlation_id.get()
|
|
58
|
+
if cid:
|
|
59
|
+
entry["correlation_id"] = cid
|
|
60
|
+
|
|
61
|
+
sid = _session_id.get()
|
|
62
|
+
if sid:
|
|
63
|
+
entry["session_id"] = sid
|
|
64
|
+
|
|
65
|
+
if record.exc_info and record.exc_info[1]:
|
|
66
|
+
entry["exception"] = str(record.exc_info[1])
|
|
67
|
+
|
|
68
|
+
return json.dumps(entry, separators=(",", ":"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def configure_structured_logging(level: int = logging.INFO) -> None:
|
|
72
|
+
"""Configure the root 'crp' logger with structured JSON output.
|
|
73
|
+
|
|
74
|
+
Call once at application startup for machine-parseable logs.
|
|
75
|
+
"""
|
|
76
|
+
root_logger = logging.getLogger("crp")
|
|
77
|
+
if not root_logger.handlers:
|
|
78
|
+
handler = logging.StreamHandler()
|
|
79
|
+
handler.setFormatter(StructuredFormatter())
|
|
80
|
+
root_logger.addHandler(handler)
|
|
81
|
+
root_logger.setLevel(level)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Per-window telemetry writer — ``telemetry.jsonl`` output (§8.9).
|
|
4
|
+
|
|
5
|
+
TelemetryWriter appends one JSON object per line for every completed
|
|
6
|
+
window. The JSONL file can be consumed by downstream analytics,
|
|
7
|
+
dashboards, or the AuditLog.
|
|
8
|
+
|
|
9
|
+
Design goals:
|
|
10
|
+
* One line per window → easy to ``grep``, ``jq``, or stream-parse.
|
|
11
|
+
* Flush after every write so no data is lost on crash.
|
|
12
|
+
* Works with file paths or any writable file-like object.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from typing import IO, Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WindowTelemetry:
|
|
27
|
+
"""Telemetry record for a single completed window.
|
|
28
|
+
|
|
29
|
+
Fields mirror the most useful per-window stats. Extra fields can
|
|
30
|
+
be added via ``extra``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
session_id: str
|
|
34
|
+
window_id: str
|
|
35
|
+
timestamp: float = field(default_factory=time.time)
|
|
36
|
+
input_tokens: int = 0
|
|
37
|
+
output_tokens: int = 0
|
|
38
|
+
overhead_tokens: int = 0
|
|
39
|
+
facts_included: int = 0
|
|
40
|
+
facts_available: int = 0
|
|
41
|
+
latency_ms: float = 0.0
|
|
42
|
+
quality_tier: str = ""
|
|
43
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
d = asdict(self)
|
|
47
|
+
# Flatten extra into the top level for ease of querying.
|
|
48
|
+
extra = d.pop("extra", {})
|
|
49
|
+
d.update(extra)
|
|
50
|
+
return d
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TelemetryWriter:
|
|
54
|
+
"""Append-only JSONL writer for per-window telemetry.
|
|
55
|
+
|
|
56
|
+
Usage::
|
|
57
|
+
|
|
58
|
+
tw = TelemetryWriter("telemetry.jsonl")
|
|
59
|
+
tw.write(WindowTelemetry(session_id="abc", window_id="w0", ...))
|
|
60
|
+
tw.close()
|
|
61
|
+
|
|
62
|
+
Also works as a context manager::
|
|
63
|
+
|
|
64
|
+
with TelemetryWriter("telemetry.jsonl") as tw:
|
|
65
|
+
tw.write(record)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, path_or_file: str | IO[str]) -> None:
|
|
69
|
+
if isinstance(path_or_file, str):
|
|
70
|
+
# Ensure parent directory exists.
|
|
71
|
+
parent = os.path.dirname(path_or_file)
|
|
72
|
+
if parent:
|
|
73
|
+
os.makedirs(parent, exist_ok=True)
|
|
74
|
+
self._file: IO[str] = open(path_or_file, "a", encoding="utf-8") # noqa: SIM115
|
|
75
|
+
self._owns_file = True
|
|
76
|
+
else:
|
|
77
|
+
self._file = path_or_file
|
|
78
|
+
self._owns_file = False
|
|
79
|
+
self._count = 0
|
|
80
|
+
self._closed = False
|
|
81
|
+
# Ensure close on interpreter shutdown (§audit H3)
|
|
82
|
+
atexit.register(self.close)
|
|
83
|
+
|
|
84
|
+
# -- writing ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def write(self, record: WindowTelemetry) -> None:
|
|
87
|
+
"""Serialize *record* and append one JSON line. Flushes immediately."""
|
|
88
|
+
line = json.dumps(record.to_dict(), separators=(",", ":"))
|
|
89
|
+
self._file.write(line + "\n")
|
|
90
|
+
self._file.flush()
|
|
91
|
+
self._count += 1
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def records_written(self) -> int:
|
|
95
|
+
return self._count
|
|
96
|
+
|
|
97
|
+
# -- lifecycle --------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
if self._closed:
|
|
101
|
+
return
|
|
102
|
+
self._closed = True
|
|
103
|
+
if self._owns_file:
|
|
104
|
+
self._file.close()
|
|
105
|
+
|
|
106
|
+
def __del__(self) -> None:
|
|
107
|
+
"""Safety net: close file if not already closed (§audit H3)."""
|
|
108
|
+
try:
|
|
109
|
+
self.close()
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> TelemetryWriter:
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *exc: Any) -> None:
|
|
117
|
+
self.close()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Decision Provenance Engine (DPE) — §7.14.3.
|
|
4
|
+
|
|
5
|
+
Orchestrates claim detection, attribution scoring, provenance chain
|
|
6
|
+
construction, and report generation for every LLM dispatch window.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from crp.provenance import DecisionProvenanceEngine, ProvenanceConfig
|
|
11
|
+
|
|
12
|
+
dpe = DecisionProvenanceEngine(config=ProvenanceConfig())
|
|
13
|
+
report = dpe.analyse(
|
|
14
|
+
output_text="The server uses AES-256 encryption...",
|
|
15
|
+
packed_facts=envelope_result.packed_facts,
|
|
16
|
+
session_id=session.id,
|
|
17
|
+
window_id=window.id,
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
from crp.envelope.packer import PackedFact
|
|
27
|
+
|
|
28
|
+
from ._types import (
|
|
29
|
+
AttributionType,
|
|
30
|
+
ClaimAttribution,
|
|
31
|
+
ClaimType,
|
|
32
|
+
EntailmentResult,
|
|
33
|
+
FactScore,
|
|
34
|
+
FidelityReport,
|
|
35
|
+
HallucinationRiskReport,
|
|
36
|
+
OmissionSeverity,
|
|
37
|
+
ProvenanceChain,
|
|
38
|
+
ProvenanceConfig,
|
|
39
|
+
ProvenanceLink,
|
|
40
|
+
ProvenanceReport,
|
|
41
|
+
)
|
|
42
|
+
from .attribution_scorer import score_all_claims
|
|
43
|
+
from .claim_detector import DetectedClaim, detect_claims
|
|
44
|
+
from .contradiction_detector import detect_contradictions
|
|
45
|
+
from .distortion_detector import detect_distortions
|
|
46
|
+
from .entailment_verifier import verify_entailment
|
|
47
|
+
from .fabrication_detector import detect_fabrications
|
|
48
|
+
from .hallucination_scorer import score_hallucination_risk
|
|
49
|
+
from .omission_analyzer import analyze_omissions
|
|
50
|
+
from .provenance_chain import build_all_chains, enrich_fact_metadata
|
|
51
|
+
from .report_generator import generate_json_report, generate_markdown_report
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"DecisionProvenanceEngine",
|
|
56
|
+
"ProvenanceConfig",
|
|
57
|
+
"ProvenanceReport",
|
|
58
|
+
"FidelityReport",
|
|
59
|
+
"ClaimAttribution",
|
|
60
|
+
"ClaimType",
|
|
61
|
+
"AttributionType",
|
|
62
|
+
"FactScore",
|
|
63
|
+
"ProvenanceChain",
|
|
64
|
+
"ProvenanceLink",
|
|
65
|
+
"DetectedClaim",
|
|
66
|
+
"detect_claims",
|
|
67
|
+
"score_all_claims",
|
|
68
|
+
"build_all_chains",
|
|
69
|
+
"enrich_fact_metadata",
|
|
70
|
+
"generate_markdown_report",
|
|
71
|
+
"generate_json_report",
|
|
72
|
+
"detect_distortions",
|
|
73
|
+
"detect_fabrications",
|
|
74
|
+
"analyze_omissions",
|
|
75
|
+
"detect_contradictions",
|
|
76
|
+
"verify_entailment",
|
|
77
|
+
"score_hallucination_risk",
|
|
78
|
+
"EntailmentResult",
|
|
79
|
+
"HallucinationRiskReport",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DecisionProvenanceEngine:
|
|
84
|
+
"""Main entry point for the Decision Provenance Engine.
|
|
85
|
+
|
|
86
|
+
Ties together claim detection → attribution scoring → provenance chain
|
|
87
|
+
construction → report generation into a single ``analyse()`` call.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, *, config: ProvenanceConfig | None = None) -> None:
|
|
91
|
+
self._config = config or ProvenanceConfig()
|
|
92
|
+
|
|
93
|
+
# -- Properties ---------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def config(self) -> ProvenanceConfig:
|
|
97
|
+
return self._config
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def enabled(self) -> bool:
|
|
101
|
+
return self._config.enabled
|
|
102
|
+
|
|
103
|
+
# -- Main pipeline ------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def analyse(
|
|
106
|
+
self,
|
|
107
|
+
output_text: str,
|
|
108
|
+
packed_facts: Sequence[PackedFact],
|
|
109
|
+
*,
|
|
110
|
+
session_id: str = "",
|
|
111
|
+
window_id: str = "",
|
|
112
|
+
envelope_saturation: float = 0.0,
|
|
113
|
+
task_input_preview: str = "",
|
|
114
|
+
fact_metadata: dict[str, dict[str, object]] | None = None,
|
|
115
|
+
) -> ProvenanceReport:
|
|
116
|
+
"""Run the full DPE pipeline and return a provenance report.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
output_text: Raw LLM output text for this window.
|
|
120
|
+
packed_facts: Facts that were packed into the envelope.
|
|
121
|
+
session_id: Current CRP session ID.
|
|
122
|
+
window_id: Current dispatch window ID.
|
|
123
|
+
envelope_saturation: Envelope token saturation ratio (0.0-1.0).
|
|
124
|
+
task_input_preview: First 120 chars of the task input.
|
|
125
|
+
fact_metadata: Optional mapping of fact_id → metadata dict
|
|
126
|
+
containing ``source_window_id`` and
|
|
127
|
+
``extraction_stage`` for chain enrichment.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
ProvenanceReport with attribution data, chains, and counts.
|
|
131
|
+
"""
|
|
132
|
+
if not self._config.enabled:
|
|
133
|
+
return ProvenanceReport(
|
|
134
|
+
session_id=session_id,
|
|
135
|
+
window_id=window_id,
|
|
136
|
+
timestamp=time.time(),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
facts_list = list(packed_facts)
|
|
140
|
+
|
|
141
|
+
# Step 1: Detect claims
|
|
142
|
+
claims = detect_claims(
|
|
143
|
+
output_text,
|
|
144
|
+
min_length=self._config.min_claim_length,
|
|
145
|
+
max_claims=self._config.max_claims_per_output,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Step 2: Score claims against facts
|
|
149
|
+
attributions = score_all_claims(claims, facts_list, config=self._config)
|
|
150
|
+
|
|
151
|
+
# Step 3: Enrich with fact metadata (if available)
|
|
152
|
+
if fact_metadata:
|
|
153
|
+
enrich_fact_metadata(attributions, fact_metadata)
|
|
154
|
+
|
|
155
|
+
# Step 4: Build provenance chains
|
|
156
|
+
chains = build_all_chains(
|
|
157
|
+
attributions,
|
|
158
|
+
session_id=session_id,
|
|
159
|
+
window_id=window_id,
|
|
160
|
+
envelope_saturation=envelope_saturation,
|
|
161
|
+
envelope_facts_included=len(facts_list),
|
|
162
|
+
task_input_preview=task_input_preview,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Step 5: Fidelity verification
|
|
166
|
+
distortions = detect_distortions(attributions, facts_list)
|
|
167
|
+
omissions = analyze_omissions(attributions, facts_list)
|
|
168
|
+
fabrications = detect_fabrications(attributions, facts_list)
|
|
169
|
+
contradictions = detect_contradictions(attributions)
|
|
170
|
+
|
|
171
|
+
critical_omissions = sum(
|
|
172
|
+
1 for o in omissions if o.severity == OmissionSeverity.CRITICAL
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Composite fidelity score: start at 1.0, deduct for issues
|
|
176
|
+
# Normalize penalties by total claims to avoid score collapse on
|
|
177
|
+
# long outputs. With 30 claims, 3 distortions should not
|
|
178
|
+
# obliterate the score the same way it would for 3 claims.
|
|
179
|
+
_total = max(len(attributions), 1)
|
|
180
|
+
fidelity_score = 1.0
|
|
181
|
+
fidelity_score -= (len(distortions) / _total) * 0.40
|
|
182
|
+
fidelity_score -= (len(fabrications) / _total) * 0.35
|
|
183
|
+
fidelity_score -= (critical_omissions / _total) * 0.25
|
|
184
|
+
fidelity_score -= (len(contradictions) / _total) * 0.50
|
|
185
|
+
fidelity_score = max(0.0, round(fidelity_score, 4))
|
|
186
|
+
|
|
187
|
+
fidelity = FidelityReport(
|
|
188
|
+
distortions=distortions,
|
|
189
|
+
fabrications=fabrications,
|
|
190
|
+
omissions=omissions,
|
|
191
|
+
contradictions=contradictions,
|
|
192
|
+
distortion_count=len(distortions),
|
|
193
|
+
fabrication_count=len(fabrications),
|
|
194
|
+
critical_omission_count=critical_omissions,
|
|
195
|
+
contradiction_count=len(contradictions),
|
|
196
|
+
fidelity_score=fidelity_score,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Step 6: Semantic entailment verification
|
|
200
|
+
entailment_results: list[EntailmentResult] = []
|
|
201
|
+
if self._config.entailment_enabled:
|
|
202
|
+
entailment_results = verify_entailment(
|
|
203
|
+
attributions, facts_list, config=self._config,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Step 7: Hallucination risk scoring
|
|
207
|
+
risk_report: HallucinationRiskReport | None = None
|
|
208
|
+
if self._config.risk_scoring_enabled:
|
|
209
|
+
risk_report = score_hallucination_risk(
|
|
210
|
+
attributions,
|
|
211
|
+
fidelity=fidelity,
|
|
212
|
+
entailment_results=entailment_results,
|
|
213
|
+
config=self._config,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Step 8: Compute counts and build report
|
|
217
|
+
report = self._build_report(
|
|
218
|
+
claims=claims,
|
|
219
|
+
attributions=attributions,
|
|
220
|
+
chains=chains,
|
|
221
|
+
session_id=session_id,
|
|
222
|
+
window_id=window_id,
|
|
223
|
+
output_text=output_text,
|
|
224
|
+
facts_count=len(facts_list),
|
|
225
|
+
fidelity=fidelity,
|
|
226
|
+
entailment_results=entailment_results,
|
|
227
|
+
risk_report=risk_report,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return report
|
|
231
|
+
|
|
232
|
+
# -- Report assembly ----------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _build_report(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
claims: list[DetectedClaim],
|
|
238
|
+
attributions: list[ClaimAttribution],
|
|
239
|
+
chains: list[ProvenanceChain],
|
|
240
|
+
session_id: str,
|
|
241
|
+
window_id: str,
|
|
242
|
+
output_text: str,
|
|
243
|
+
facts_count: int,
|
|
244
|
+
fidelity: FidelityReport | None = None,
|
|
245
|
+
entailment_results: list[EntailmentResult] | None = None,
|
|
246
|
+
risk_report: HallucinationRiskReport | None = None,
|
|
247
|
+
) -> ProvenanceReport:
|
|
248
|
+
"""Assemble counts and build the ProvenanceReport."""
|
|
249
|
+
# Claim type counts
|
|
250
|
+
factual = sum(1 for c in claims if c.claim_type == ClaimType.FACTUAL_CLAIM)
|
|
251
|
+
opinion = sum(1 for c in claims if c.claim_type == ClaimType.OPINION)
|
|
252
|
+
procedural = sum(1 for c in claims if c.claim_type == ClaimType.PROCEDURAL)
|
|
253
|
+
hedge = sum(1 for c in claims if c.claim_type == ClaimType.HEDGE)
|
|
254
|
+
connective = sum(1 for c in claims if c.claim_type == ClaimType.CONNECTIVE)
|
|
255
|
+
|
|
256
|
+
# Attribution type counts
|
|
257
|
+
grounded = sum(
|
|
258
|
+
1 for a in attributions
|
|
259
|
+
if a.attribution_type == AttributionType.CONTEXT_GROUNDED
|
|
260
|
+
)
|
|
261
|
+
parametric = sum(
|
|
262
|
+
1 for a in attributions
|
|
263
|
+
if a.attribution_type == AttributionType.PARAMETRIC
|
|
264
|
+
)
|
|
265
|
+
mixed = sum(
|
|
266
|
+
1 for a in attributions
|
|
267
|
+
if a.attribution_type == AttributionType.MIXED
|
|
268
|
+
)
|
|
269
|
+
uncertain = sum(
|
|
270
|
+
1 for a in attributions
|
|
271
|
+
if a.attribution_type == AttributionType.UNCERTAIN
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Grounding ratio: context-grounded factual claims / total factual claims
|
|
275
|
+
scorable = sum(
|
|
276
|
+
1 for a in attributions
|
|
277
|
+
if a.claim_type in (ClaimType.FACTUAL_CLAIM, ClaimType.HEDGE)
|
|
278
|
+
)
|
|
279
|
+
grounding_ratio = grounded / scorable if scorable > 0 else 0.0
|
|
280
|
+
|
|
281
|
+
return ProvenanceReport(
|
|
282
|
+
session_id=session_id,
|
|
283
|
+
window_id=window_id,
|
|
284
|
+
timestamp=time.time(),
|
|
285
|
+
total_claims=len(claims),
|
|
286
|
+
factual_claims=factual,
|
|
287
|
+
opinion_claims=opinion,
|
|
288
|
+
procedural_claims=procedural,
|
|
289
|
+
hedge_claims=hedge,
|
|
290
|
+
connective_claims=connective,
|
|
291
|
+
context_grounded_count=grounded,
|
|
292
|
+
parametric_count=parametric,
|
|
293
|
+
mixed_count=mixed,
|
|
294
|
+
uncertain_count=uncertain,
|
|
295
|
+
grounding_ratio=round(grounding_ratio, 4),
|
|
296
|
+
attributions=attributions,
|
|
297
|
+
chains=chains,
|
|
298
|
+
chain_verified=True,
|
|
299
|
+
output_token_count=len(output_text.split()),
|
|
300
|
+
envelope_facts_count=facts_count,
|
|
301
|
+
fidelity=fidelity,
|
|
302
|
+
entailment_results=entailment_results or [],
|
|
303
|
+
risk_report=risk_report,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# -- Report formatting helpers ------------------------------------------
|
|
307
|
+
|
|
308
|
+
def generate_markdown(self, report: ProvenanceReport) -> str:
|
|
309
|
+
"""Generate a human-readable markdown provenance report."""
|
|
310
|
+
return generate_markdown_report(report)
|
|
311
|
+
|
|
312
|
+
def generate_json(self, report: ProvenanceReport) -> dict:
|
|
313
|
+
"""Generate a machine-readable JSON provenance report."""
|
|
314
|
+
return generate_json_report(report)
|