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,30 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Observability — events, metrics, audit log, quality reporting, telemetry."""
4
+
5
+ from crp.observability.audit import AuditLog
6
+ from crp.observability.events import ALL_EVENT_TYPES, CRPEvent, EventEmitter
7
+ from crp.observability.metrics import (
8
+ ExportFormat,
9
+ HealthMonitor,
10
+ HealthStatus,
11
+ MetricsExporter,
12
+ )
13
+ from crp.observability.quality import QualityReport, QualityReporter, QualityTier
14
+ from crp.observability.telemetry import TelemetryWriter, WindowTelemetry
15
+
16
+ __all__ = [
17
+ "ALL_EVENT_TYPES",
18
+ "AuditLog",
19
+ "CRPEvent",
20
+ "EventEmitter",
21
+ "ExportFormat",
22
+ "HealthMonitor",
23
+ "HealthStatus",
24
+ "MetricsExporter",
25
+ "QualityReport",
26
+ "QualityReporter",
27
+ "QualityTier",
28
+ "TelemetryWriter",
29
+ "WindowTelemetry",
30
+ ]
@@ -0,0 +1,118 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Audit log — reconstruct sessions from recorded events (§8.9).
4
+
5
+ AuditLog stores every emitted CRPEvent and provides query helpers to:
6
+ * Replay the full timeline for a session or time range.
7
+ * Filter by event type.
8
+ * Reconstruct how many facts were created / superseded / archived.
9
+
10
+ Design goals:
11
+ * In-memory by default (no external DB).
12
+ * Thread-safe append + query.
13
+ * Easy to hook into EventEmitter via ``emitter.on_all(audit.record)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from typing import Any
20
+
21
+ from crp.observability.events import CRPEvent
22
+
23
+
24
+ class AuditLog:
25
+ """Immutable append-only event log with query support.
26
+
27
+ Usage::
28
+
29
+ audit = AuditLog()
30
+ emitter.on_all(audit.record) # auto-capture every event
31
+ ...
32
+ timeline = audit.query(session_id="abc")
33
+ fact_events = audit.query(event_type="fact.created")
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self._lock = threading.Lock()
38
+ self._events: list[CRPEvent] = []
39
+
40
+ # -- recording --------------------------------------------------------
41
+
42
+ def record(self, event: CRPEvent) -> None:
43
+ """Append an event (thread-safe, never raises)."""
44
+ with self._lock:
45
+ self._events.append(event)
46
+
47
+ # -- querying ---------------------------------------------------------
48
+
49
+ def query(
50
+ self,
51
+ *,
52
+ event_type: str | None = None,
53
+ session_id: str | None = None,
54
+ since: float | None = None,
55
+ until: float | None = None,
56
+ ) -> list[CRPEvent]:
57
+ """Return events matching all supplied filters.
58
+
59
+ Args:
60
+ event_type: Exact event type string (e.g. "dispatch.completed").
61
+ session_id: Match events whose ``data["session_id"]`` equals this.
62
+ since: Only events with ``timestamp >= since``.
63
+ until: Only events with ``timestamp <= until``.
64
+ """
65
+ with self._lock:
66
+ results: list[CRPEvent] = []
67
+ for ev in self._events:
68
+ if event_type is not None and ev.event_type != event_type:
69
+ continue
70
+ if session_id is not None and ev.data.get("session_id") != session_id:
71
+ continue
72
+ if since is not None and ev.timestamp < since:
73
+ continue
74
+ if until is not None and ev.timestamp > until:
75
+ continue
76
+ results.append(ev)
77
+ return results
78
+
79
+ def reconstruct_session(self, session_id: str) -> dict[str, Any]:
80
+ """Build a summary dict for *session_id* from the event stream.
81
+
82
+ Returns a dict with keys:
83
+ * ``session_id``
84
+ * ``events`` — full ordered list of events (as dicts).
85
+ * ``dispatch_count`` — number of dispatch.completed events.
86
+ * ``facts_created`` — count of fact.created events.
87
+ * ``facts_superseded`` — count of fact.superseded events.
88
+ * ``duration_s`` — seconds between first and last event.
89
+ """
90
+ events = self.query(session_id=session_id)
91
+ dispatch_count = sum(1 for e in events if e.event_type == "dispatch.completed")
92
+ facts_created = sum(1 for e in events if e.event_type == "fact.created")
93
+ facts_superseded = sum(1 for e in events if e.event_type == "fact.superseded")
94
+
95
+ if events:
96
+ duration = events[-1].timestamp - events[0].timestamp
97
+ else:
98
+ duration = 0.0
99
+
100
+ return {
101
+ "session_id": session_id,
102
+ "events": [e.to_dict() for e in events],
103
+ "dispatch_count": dispatch_count,
104
+ "facts_created": facts_created,
105
+ "facts_superseded": facts_superseded,
106
+ "duration_s": round(duration, 3),
107
+ }
108
+
109
+ @property
110
+ def count(self) -> int:
111
+ """Total number of recorded events."""
112
+ with self._lock:
113
+ return len(self._events)
114
+
115
+ def clear(self) -> None:
116
+ """Remove all stored events (useful in testing)."""
117
+ with self._lock:
118
+ self._events.clear()
@@ -0,0 +1,233 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Event emitter for CRP observability — structured event bus (§09 §9.5).
4
+
5
+ EventEmitter is a lightweight publish/subscribe bus that lets CLI,
6
+ diagnostics, and future metrics sinks observe protocol activity
7
+ without coupling to internals.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger("crp.events")
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Canonical event type constants (§8.9) — 30+ named event types.
23
+ #
24
+ # Naming convention: ``<domain>.<action>`` (e.g. "session.created").
25
+ # All lowercase, dots as separators, past-tense verbs.
26
+ # ---------------------------------------------------------------------------
27
+
28
+ # Session lifecycle
29
+ SESSION_CREATED = "session.created"
30
+ SESSION_CLOSED = "session.closed"
31
+ SESSION_EXPIRED = "session.expired"
32
+ SESSION_RESTORED = "session.restored"
33
+
34
+ # Dispatch / window lifecycle
35
+ DISPATCH_STARTED = "dispatch.started"
36
+ DISPATCH_COMPLETED = "dispatch.completed"
37
+ DISPATCH_FAILED = "dispatch.failed"
38
+ WINDOW_OPENED = "window.opened"
39
+ WINDOW_COMPLETED = "window.completed"
40
+ WINDOW_CONTINUED = "window.continued"
41
+ WINDOW_CANCELLED = "window.cancelled"
42
+
43
+ # Extraction
44
+ FACT_CREATED = "fact.created"
45
+ FACT_SUPERSEDED = "fact.superseded"
46
+ FACT_COMPACTED = "fact.compacted"
47
+ FACT_ARCHIVED = "fact.archived"
48
+ FACT_RESTORED = "fact.restored"
49
+ EXTRACTION_COMPLETED = "extraction.completed"
50
+
51
+ # Ingestion
52
+ INGEST_STARTED = "ingest.started"
53
+ INGEST_COMPLETED = "ingest.completed"
54
+
55
+ # Envelope / context
56
+ ENVELOPE_BUILT = "envelope.built"
57
+ ENVELOPE_SATURATED = "envelope.saturated" # saturation > 0.95
58
+ CONTEXT_OVERFLOW = "context.overflow"
59
+ CONTEXT_TRUNCATED = "context.truncated"
60
+
61
+ # Budget / cost
62
+ BUDGET_WARNING = "budget.warning"
63
+ BUDGET_EXHAUSTED = "budget.exhausted"
64
+ OVERHEAD_SHED = "overhead.shed"
65
+ COST_RECORDED = "cost.recorded"
66
+
67
+ # Provider / LLM
68
+ PROVIDER_CONNECTED = "provider.connected"
69
+ PROVIDER_DISCONNECTED = "provider.disconnected"
70
+ PROVIDER_ERROR = "provider.error"
71
+ PROVIDER_RETRY = "provider.retry"
72
+
73
+ # Quality
74
+ QUALITY_ASSESSED = "quality.assessed"
75
+ QUALITY_DEGRADED = "quality.degraded"
76
+
77
+ # Security
78
+ AUTH_SUCCESS = "auth.success"
79
+ AUTH_FAILURE = "auth.failure"
80
+
81
+ # Health
82
+ HEALTH_CHECK_PASSED = "health.check.passed"
83
+ HEALTH_CHECK_FAILED = "health.check.failed"
84
+
85
+ # Startup
86
+ STARTUP_COMPLETED = "startup.completed"
87
+ STARTUP_FAILED = "startup.failed"
88
+
89
+ # Collect all event types for programmatic access (useful for audit/metrics).
90
+ ALL_EVENT_TYPES: frozenset[str] = frozenset([
91
+ SESSION_CREATED, SESSION_CLOSED, SESSION_EXPIRED, SESSION_RESTORED,
92
+ DISPATCH_STARTED, DISPATCH_COMPLETED, DISPATCH_FAILED,
93
+ WINDOW_OPENED, WINDOW_COMPLETED, WINDOW_CONTINUED, WINDOW_CANCELLED,
94
+ FACT_CREATED, FACT_SUPERSEDED, FACT_COMPACTED, FACT_ARCHIVED,
95
+ FACT_RESTORED, EXTRACTION_COMPLETED,
96
+ INGEST_STARTED, INGEST_COMPLETED,
97
+ ENVELOPE_BUILT, ENVELOPE_SATURATED, CONTEXT_OVERFLOW, CONTEXT_TRUNCATED,
98
+ BUDGET_WARNING, BUDGET_EXHAUSTED, OVERHEAD_SHED, COST_RECORDED,
99
+ PROVIDER_CONNECTED, PROVIDER_DISCONNECTED, PROVIDER_ERROR, PROVIDER_RETRY,
100
+ QUALITY_ASSESSED, QUALITY_DEGRADED,
101
+ AUTH_SUCCESS, AUTH_FAILURE,
102
+ HEALTH_CHECK_PASSED, HEALTH_CHECK_FAILED,
103
+ STARTUP_COMPLETED, STARTUP_FAILED,
104
+ ])
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Event dataclass
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ @dataclass
112
+ class CRPEvent:
113
+ """Single protocol event."""
114
+
115
+ event_type: str # e.g. "session.created", "dispatch.started", "window.completed"
116
+ timestamp: float = field(default_factory=time.time)
117
+ data: dict[str, Any] = field(default_factory=dict)
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return {
121
+ "event_type": self.event_type,
122
+ "timestamp": self.timestamp,
123
+ "data": self.data,
124
+ }
125
+
126
+
127
+ # Listener type: callable that receives one CRPEvent.
128
+ Listener = Callable[[CRPEvent], None]
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # EventEmitter
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class EventEmitter:
137
+ """Simple synchronous event bus.
138
+
139
+ Usage::
140
+
141
+ emitter = EventEmitter()
142
+ emitter.on("dispatch.started", my_callback)
143
+ emitter.emit("dispatch.started", {"task": "..."})
144
+ """
145
+
146
+ def __init__(self, *, max_listeners_per_event: int = 100) -> None:
147
+ self._listeners: dict[str, list[Listener]] = {}
148
+ self._global_listeners: list[Listener] = []
149
+ self._started = False
150
+ self._max_listeners = max_listeners_per_event
151
+
152
+ # -- lifecycle --------------------------------------------------------
153
+
154
+ def start(self) -> None:
155
+ """Mark the emitter as active (startup step 6)."""
156
+ self._started = True
157
+ logger.debug("EventEmitter started")
158
+
159
+ def stop(self) -> None:
160
+ """Stop accepting and delivering events."""
161
+ self._started = False
162
+
163
+ @property
164
+ def is_running(self) -> bool:
165
+ return self._started
166
+
167
+ # -- subscription -----------------------------------------------------
168
+
169
+ def on(self, event_type: str, listener: Listener) -> None:
170
+ """Subscribe *listener* to *event_type*.
171
+
172
+ Deduplicates listeners and enforces a per-event cap (§audit4 REL-M2).
173
+ """
174
+ listeners = self._listeners.setdefault(event_type, [])
175
+ if listener in listeners:
176
+ return
177
+ if len(listeners) >= self._max_listeners:
178
+ logger.warning(
179
+ "Max listeners (%d) reached for event '%s' — ignoring",
180
+ self._max_listeners, event_type,
181
+ )
182
+ return
183
+ listeners.append(listener)
184
+
185
+ def on_all(self, listener: Listener) -> None:
186
+ """Subscribe *listener* to every event type."""
187
+ if listener in self._global_listeners:
188
+ return
189
+ if len(self._global_listeners) >= self._max_listeners:
190
+ logger.warning(
191
+ "Max global listeners (%d) reached — ignoring",
192
+ self._max_listeners,
193
+ )
194
+ return
195
+ self._global_listeners.append(listener)
196
+
197
+ def off(self, event_type: str, listener: Listener) -> bool:
198
+ """Remove a specific listener. Returns True if removed."""
199
+ listeners = self._listeners.get(event_type, [])
200
+ try:
201
+ listeners.remove(listener)
202
+ return True
203
+ except ValueError:
204
+ return False
205
+
206
+ # -- emission ---------------------------------------------------------
207
+
208
+ def emit(self, event_type: str, data: dict[str, Any] | None = None) -> None:
209
+ """Emit an event to all matching listeners.
210
+
211
+ If the emitter has not been started, events are silently dropped.
212
+ Listener exceptions are logged but never propagate.
213
+ """
214
+ if not self._started:
215
+ return
216
+ event = CRPEvent(event_type=event_type, data=data or {})
217
+
218
+ for listener in self._listeners.get(event_type, []):
219
+ try:
220
+ listener(event)
221
+ except Exception:
222
+ logger.exception("Listener error for %s", event_type)
223
+
224
+ for listener in self._global_listeners:
225
+ try:
226
+ listener(event)
227
+ except Exception:
228
+ logger.exception("Global listener error for %s", event_type)
229
+
230
+ @property
231
+ def listener_count(self) -> int:
232
+ total = sum(len(v) for v in self._listeners.values())
233
+ return total + len(self._global_listeners)
@@ -0,0 +1,264 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Metrics export and health monitoring (§8.9, §05).
4
+
5
+ MetricsExporter — collects counters/gauges and exports as Prometheus,
6
+ OTLP-compatible JSON, or plain JSON.
7
+ HealthMonitor — liveness + readiness probes for the CRP runtime.
8
+
9
+ Design goals:
10
+ * Zero external dependencies (no prometheus_client, no opentelemetry).
11
+ * Thread-safe counters via stdlib threading.Lock.
12
+ * Easy to read: one class per concern, plain dicts for state.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from typing import Any
23
+
24
+ # ═══════════════════════════════════════════════════════════════════════════
25
+ # MetricsExporter (§9.2)
26
+ # ═══════════════════════════════════════════════════════════════════════════
27
+
28
+
29
+ class ExportFormat(Enum):
30
+ """Supported output formats."""
31
+
32
+ JSON = "json"
33
+ PROMETHEUS = "prometheus"
34
+ OTLP_JSON = "otlp_json"
35
+
36
+
37
+ class MetricsExporter:
38
+ """Collects CRP metrics (counters, gauges, histograms) and exports them.
39
+
40
+ Usage::
41
+
42
+ mx = MetricsExporter()
43
+ mx.incr("dispatch.count")
44
+ mx.gauge("overhead.ratio", 0.12)
45
+ mx.observe("dispatch.latency_ms", 42.3)
46
+ print(mx.export(ExportFormat.JSON))
47
+ """
48
+
49
+ _MAX_HISTOGRAM_SIZE = 10_000 # Cap per-metric observations to prevent unbounded growth
50
+
51
+ def __init__(self) -> None:
52
+ self._lock = threading.Lock()
53
+ # Counters: monotonically increasing integers.
54
+ self._counters: dict[str, int] = {}
55
+ # Gauges: point-in-time values that can go up or down.
56
+ self._gauges: dict[str, float] = {}
57
+ # Histograms: list of observed values (for percentiles).
58
+ self._histograms: dict[str, list[float]] = {}
59
+
60
+ # -- recording --------------------------------------------------------
61
+
62
+ def incr(self, name: str, delta: int = 1) -> None:
63
+ """Increment a counter by *delta* (default 1)."""
64
+ with self._lock:
65
+ self._counters[name] = self._counters.get(name, 0) + delta
66
+
67
+ def gauge(self, name: str, value: float) -> None:
68
+ """Set a gauge to the current *value*."""
69
+ with self._lock:
70
+ self._gauges[name] = value
71
+
72
+ def observe(self, name: str, value: float) -> None:
73
+ """Record an observation in a histogram bucket."""
74
+ with self._lock:
75
+ bucket = self._histograms.setdefault(name, [])
76
+ bucket.append(value)
77
+ if len(bucket) > self._MAX_HISTOGRAM_SIZE:
78
+ # Keep the most recent half to avoid repeated trimming
79
+ del bucket[: len(bucket) // 2]
80
+
81
+ # -- querying ---------------------------------------------------------
82
+
83
+ def get_counter(self, name: str) -> int:
84
+ with self._lock:
85
+ return self._counters.get(name, 0)
86
+
87
+ def get_gauge(self, name: str) -> float | None:
88
+ with self._lock:
89
+ return self._gauges.get(name)
90
+
91
+ def get_histogram(self, name: str) -> list[float]:
92
+ with self._lock:
93
+ return list(self._histograms.get(name, []))
94
+
95
+ # -- export -----------------------------------------------------------
96
+
97
+ def export(self, fmt: ExportFormat = ExportFormat.JSON) -> str:
98
+ """Export all metrics in the given format.
99
+
100
+ Supported:
101
+ * ``JSON`` — human-friendly nested dict.
102
+ * ``PROMETHEUS`` — text/plain Prometheus exposition format.
103
+ * ``OTLP_JSON`` — OpenTelemetry-compatible JSON envelope.
104
+ """
105
+ with self._lock:
106
+ counters = dict(self._counters)
107
+ gauges = dict(self._gauges)
108
+ histograms = {k: list(v) for k, v in self._histograms.items()}
109
+
110
+ if fmt == ExportFormat.JSON:
111
+ return self._export_json(counters, gauges, histograms)
112
+ if fmt == ExportFormat.PROMETHEUS:
113
+ return self._export_prometheus(counters, gauges, histograms)
114
+ if fmt == ExportFormat.OTLP_JSON:
115
+ return self._export_otlp(counters, gauges, histograms)
116
+ raise ValueError(f"Unknown export format: {fmt}")
117
+
118
+ def reset(self) -> None:
119
+ """Clear all collected metrics."""
120
+ with self._lock:
121
+ self._counters.clear()
122
+ self._gauges.clear()
123
+ self._histograms.clear()
124
+
125
+ # -- private helpers --------------------------------------------------
126
+
127
+ @staticmethod
128
+ def _export_json(
129
+ counters: dict[str, int],
130
+ gauges: dict[str, float],
131
+ histograms: dict[str, list[float]],
132
+ ) -> str:
133
+ return json.dumps(
134
+ {"counters": counters, "gauges": gauges, "histograms": histograms},
135
+ indent=2,
136
+ )
137
+
138
+ @staticmethod
139
+ def _export_prometheus(
140
+ counters: dict[str, int],
141
+ gauges: dict[str, float],
142
+ histograms: dict[str, list[float]],
143
+ ) -> str:
144
+ """Prometheus text exposition: each metric on its own line."""
145
+ lines: list[str] = []
146
+ for name, value in sorted(counters.items()):
147
+ safe = name.replace(".", "_")
148
+ lines.append(f"# TYPE crp_{safe} counter")
149
+ lines.append(f"crp_{safe} {value}")
150
+ for name, value in sorted(gauges.items()): # type: ignore[assignment]
151
+ safe = name.replace(".", "_")
152
+ lines.append(f"# TYPE crp_{safe} gauge")
153
+ lines.append(f"crp_{safe} {value}")
154
+ for name, values in sorted(histograms.items()):
155
+ safe = name.replace(".", "_")
156
+ if values:
157
+ lines.append(f"# TYPE crp_{safe} summary")
158
+ lines.append(f"crp_{safe}_count {len(values)}")
159
+ lines.append(f"crp_{safe}_sum {sum(values)}")
160
+ return "\n".join(lines) + "\n" if lines else ""
161
+
162
+ @staticmethod
163
+ def _export_otlp(
164
+ counters: dict[str, int],
165
+ gauges: dict[str, float],
166
+ histograms: dict[str, list[float]],
167
+ ) -> str:
168
+ """OTLP-compatible JSON envelope (simplified)."""
169
+ metrics: list[dict[str, Any]] = []
170
+ for name, value in counters.items():
171
+ metrics.append({
172
+ "name": f"crp.{name}",
173
+ "type": "sum",
174
+ "data_points": [{"value": value, "time_unix_nano": int(time.time() * 1e9)}],
175
+ })
176
+ for name, value in gauges.items(): # type: ignore[assignment]
177
+ metrics.append({
178
+ "name": f"crp.{name}",
179
+ "type": "gauge",
180
+ "data_points": [{"value": value, "time_unix_nano": int(time.time() * 1e9)}],
181
+ })
182
+ for name, values in histograms.items():
183
+ metrics.append({
184
+ "name": f"crp.{name}",
185
+ "type": "histogram",
186
+ "data_points": [{
187
+ "count": len(values),
188
+ "sum": sum(values),
189
+ "time_unix_nano": int(time.time() * 1e9),
190
+ }],
191
+ })
192
+ envelope = {
193
+ "resource_metrics": [{
194
+ "scope_metrics": [{"metrics": metrics}],
195
+ }],
196
+ }
197
+ return json.dumps(envelope, indent=2)
198
+
199
+
200
+ # ═══════════════════════════════════════════════════════════════════════════
201
+ # HealthMonitor (§9.7)
202
+ # ═══════════════════════════════════════════════════════════════════════════
203
+
204
+
205
+ @dataclass
206
+ class HealthStatus:
207
+ """Result of a health probe."""
208
+
209
+ alive: bool = True
210
+ ready: bool = True
211
+ details: dict[str, Any] = field(default_factory=dict)
212
+
213
+ def to_dict(self) -> dict[str, Any]:
214
+ return {"alive": self.alive, "ready": self.ready, "details": self.details}
215
+
216
+
217
+ class HealthMonitor:
218
+ """Liveness and readiness probes for the CRP runtime.
219
+
220
+ Register named checks with ``add_check(name, callable)``. Each check
221
+ returns ``True`` (healthy) or ``False`` (unhealthy).
222
+
223
+ Usage::
224
+
225
+ hm = HealthMonitor()
226
+ hm.add_check("emitter", lambda: emitter.is_running)
227
+ hm.add_check("warm_store", lambda: warm_store is not None)
228
+ status = hm.probe()
229
+ assert status.alive
230
+ """
231
+
232
+ def __init__(self) -> None:
233
+ self._checks: dict[str, Any] = {} # name → callable() → bool
234
+ self._alive = True
235
+
236
+ def add_check(self, name: str, check_fn: Any) -> None:
237
+ """Register a readiness check."""
238
+ self._checks[name] = check_fn
239
+
240
+ def remove_check(self, name: str) -> bool:
241
+ """Remove a check. Returns True if it existed."""
242
+ return self._checks.pop(name, None) is not None
243
+
244
+ def set_alive(self, alive: bool) -> None:
245
+ """Manually mark the runtime as alive or dead (e.g. on shutdown)."""
246
+ self._alive = alive
247
+
248
+ def probe(self) -> HealthStatus:
249
+ """Run all registered checks and return combined status.
250
+
251
+ Liveness: ``True`` unless ``set_alive(False)`` was called.
252
+ Readiness: ``True`` only if *all* registered checks pass.
253
+ """
254
+ details: dict[str, Any] = {}
255
+ all_ready = True
256
+ for name, fn in self._checks.items():
257
+ try:
258
+ ok = bool(fn())
259
+ except Exception:
260
+ ok = False
261
+ details[name] = ok
262
+ if not ok:
263
+ all_ready = False
264
+ return HealthStatus(alive=self._alive, ready=all_ready, details=details)