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,388 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Provider pricing, real-time cost tracking, and budget enforcement (§6.8).
4
+
5
+ Budget invariants:
6
+ - Warn at 80% of any cap.
7
+ - Hard stop at 100% → BudgetExhaustedError.
8
+ - OverheadBudget caps total overhead at 15%.
9
+ - Feature shedding cascade: review_tier3 → orc_steps → curation →
10
+ re_grounding → review_tier2 (lowest priority shed first).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any
19
+
20
+ from crp.core.errors import BudgetExhaustedError
21
+
22
+ logger = logging.getLogger("crp.cost_model")
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Constants
27
+ # ---------------------------------------------------------------------------
28
+
29
+ WARN_THRESHOLD_PCT = 80
30
+ DEFAULT_OVERHEAD_CAP = 15.0 # %
31
+ GRACE_PCT = 5.0 # high-priority features may exceed by 5%
32
+ HIGH_PRIORITY_WEIGHT = 2 # features with weight >= 2 get grace
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Provider pricing
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ProviderPricing:
42
+ """Token pricing for an LLM provider (USD per 1M tokens)."""
43
+
44
+ input_price_per_million: float = 0.0
45
+ output_price_per_million: float = 0.0
46
+ provider_name: str = "unknown"
47
+
48
+ def input_cost(self, tokens: int) -> float:
49
+ return tokens * self.input_price_per_million / 1_000_000
50
+
51
+ def output_cost(self, tokens: int) -> float:
52
+ return tokens * self.output_price_per_million / 1_000_000
53
+
54
+ def total_cost(self, input_tokens: int, output_tokens: int) -> float:
55
+ return self.input_cost(input_tokens) + self.output_cost(output_tokens)
56
+
57
+
58
+ # Built-in pricing tables (approximate, users can override)
59
+ KNOWN_PRICING: dict[str, ProviderPricing] = {
60
+ "gpt-4o": ProviderPricing(2.50, 10.00, "openai"),
61
+ "gpt-4o-mini": ProviderPricing(0.15, 0.60, "openai"),
62
+ "gpt-4-turbo": ProviderPricing(10.00, 30.00, "openai"),
63
+ "claude-3-opus": ProviderPricing(15.00, 75.00, "anthropic"),
64
+ "claude-3-sonnet": ProviderPricing(3.00, 15.00, "anthropic"),
65
+ "claude-3-haiku": ProviderPricing(0.25, 1.25, "anthropic"),
66
+ "gemini-1.5-pro": ProviderPricing(3.50, 10.50, "google"),
67
+ "gemini-1.5-flash": ProviderPricing(0.075, 0.30, "google"),
68
+ }
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Window cost record
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ @dataclass
77
+ class WindowCost:
78
+ """Cost record for a single window."""
79
+
80
+ window_id: str = ""
81
+ input_tokens: int = 0
82
+ output_tokens: int = 0
83
+ cost_usd: float = 0.0
84
+ is_overhead: bool = False
85
+ feature_name: str = ""
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Budget warning
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class BudgetWarningLevel(str, Enum):
94
+ """Budget warning levels."""
95
+
96
+ NONE = "none"
97
+ WARN = "warn" # 80%+
98
+ CRITICAL = "critical" # 95%+
99
+ EXCEEDED = "exceeded" # 100%+
100
+
101
+
102
+ @dataclass
103
+ class BudgetWarning:
104
+ """A budget warning emitted when approaching limits."""
105
+
106
+ cap_type: str = "" # "windows" | "input_tokens" | "output_tokens"
107
+ level: BudgetWarningLevel = BudgetWarningLevel.NONE
108
+ used: int = 0
109
+ limit: int = 0
110
+ pct_used: float = 0.0
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # CostModel
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ class CostModel:
119
+ """Real-time cost tracking with budget enforcement (§6.8).
120
+
121
+ Tracks per-window, per-session, and cumulative costs.
122
+ Warns at 80%, hard-stops at 100% of user-set budgets.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ pricing: ProviderPricing | None = None,
128
+ max_windows: int | None = None,
129
+ max_input_tokens: int | None = None,
130
+ max_output_tokens: int | None = None,
131
+ ) -> None:
132
+ self._pricing = pricing or ProviderPricing()
133
+ self._max_windows = max_windows
134
+ self._max_input_tokens = max_input_tokens
135
+ self._max_output_tokens = max_output_tokens
136
+
137
+ # Running totals
138
+ self._windows: list[WindowCost] = []
139
+ self._total_input_tokens = 0
140
+ self._total_output_tokens = 0
141
+ self._total_cost_usd = 0.0
142
+ self._warnings: list[BudgetWarning] = []
143
+
144
+ @property
145
+ def pricing(self) -> ProviderPricing:
146
+ return self._pricing
147
+
148
+ @property
149
+ def total_windows(self) -> int:
150
+ return len(self._windows)
151
+
152
+ @property
153
+ def total_input_tokens(self) -> int:
154
+ return self._total_input_tokens
155
+
156
+ @property
157
+ def total_output_tokens(self) -> int:
158
+ return self._total_output_tokens
159
+
160
+ @property
161
+ def total_cost_usd(self) -> float:
162
+ return self._total_cost_usd
163
+
164
+ @property
165
+ def warnings(self) -> list[BudgetWarning]:
166
+ return list(self._warnings)
167
+
168
+ def record_window(
169
+ self,
170
+ window_id: str,
171
+ input_tokens: int,
172
+ output_tokens: int,
173
+ is_overhead: bool = False,
174
+ feature_name: str = "",
175
+ ) -> WindowCost:
176
+ """Record a completed window's cost. Returns the WindowCost record."""
177
+ cost_usd = self._pricing.total_cost(input_tokens, output_tokens)
178
+ record = WindowCost(
179
+ window_id=window_id,
180
+ input_tokens=input_tokens,
181
+ output_tokens=output_tokens,
182
+ cost_usd=cost_usd,
183
+ is_overhead=is_overhead,
184
+ feature_name=feature_name,
185
+ )
186
+ self._windows.append(record)
187
+ self._total_input_tokens += input_tokens
188
+ self._total_output_tokens += output_tokens
189
+ self._total_cost_usd += cost_usd
190
+ return record
191
+
192
+ def check_budget(self, input_tokens: int = 0) -> list[BudgetWarning]:
193
+ """Check all budget caps. Raises BudgetExhaustedError if exceeded.
194
+
195
+ Returns any warnings generated (80%+ of cap).
196
+ """
197
+ warnings: list[BudgetWarning] = []
198
+
199
+ # Windows cap
200
+ if self._max_windows is not None:
201
+ w = self._check_cap(
202
+ "windows",
203
+ self.total_windows + 1,
204
+ self._max_windows,
205
+ )
206
+ if w:
207
+ warnings.append(w)
208
+
209
+ # Input tokens cap
210
+ if self._max_input_tokens is not None:
211
+ w = self._check_cap(
212
+ "input_tokens",
213
+ self._total_input_tokens + input_tokens,
214
+ self._max_input_tokens,
215
+ )
216
+ if w:
217
+ warnings.append(w)
218
+
219
+ # Output tokens cap (check cumulative)
220
+ if self._max_output_tokens is not None:
221
+ w = self._check_cap(
222
+ "output_tokens",
223
+ self._total_output_tokens,
224
+ self._max_output_tokens,
225
+ )
226
+ if w:
227
+ warnings.append(w)
228
+
229
+ self._warnings.extend(warnings)
230
+ return warnings
231
+
232
+ def _check_cap(
233
+ self, cap_type: str, used: int, limit: int
234
+ ) -> BudgetWarning | None:
235
+ """Check a single budget cap. Raises on exceeded."""
236
+ if limit <= 0:
237
+ return None
238
+
239
+ pct = (used / limit) * 100
240
+
241
+ if pct >= 100:
242
+ raise BudgetExhaustedError(
243
+ f"{cap_type} budget exceeded",
244
+ cap_type=cap_type,
245
+ used=used,
246
+ limit=limit,
247
+ windows_completed=self.total_windows,
248
+ )
249
+
250
+ if pct >= 95:
251
+ level = BudgetWarningLevel.CRITICAL
252
+ elif pct >= WARN_THRESHOLD_PCT:
253
+ level = BudgetWarningLevel.WARN
254
+ else:
255
+ return None
256
+
257
+ warning = BudgetWarning(
258
+ cap_type=cap_type,
259
+ level=level,
260
+ used=used,
261
+ limit=limit,
262
+ pct_used=round(pct, 1),
263
+ )
264
+ logger.warning(
265
+ "Budget %s: %s at %.1f%% (%d / %d)",
266
+ level.value, cap_type, pct, used, limit,
267
+ )
268
+ return warning
269
+
270
+ def estimate(
271
+ self,
272
+ planned_dispatches: int = 1,
273
+ avg_input_tokens: int = 0,
274
+ avg_output_tokens: int = 0,
275
+ ) -> dict[str, Any]:
276
+ """Pre-flight cost estimation."""
277
+ total_in = planned_dispatches * avg_input_tokens
278
+ total_out = planned_dispatches * avg_output_tokens
279
+ cost = self._pricing.total_cost(total_in, total_out)
280
+ return {
281
+ "estimated_windows": planned_dispatches,
282
+ "estimated_input_tokens": total_in,
283
+ "estimated_output_tokens": total_out,
284
+ "estimated_cost_usd": cost if cost > 0 else None,
285
+ }
286
+
287
+ def reset(self) -> None:
288
+ """Reset all counters (on session start)."""
289
+ self._windows.clear()
290
+ self._total_input_tokens = 0
291
+ self._total_output_tokens = 0
292
+ self._total_cost_usd = 0.0
293
+ self._warnings.clear()
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # OverheadBudget (§6.9)
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ @dataclass
302
+ class FeaturePriority:
303
+ """Priority entry for overhead feature shedding."""
304
+
305
+ name: str
306
+ weight: int
307
+
308
+
309
+ FEATURE_PRIORITY: list[FeaturePriority] = [
310
+ FeaturePriority("review_tier3", 3), # Full review windows (most expensive)
311
+ FeaturePriority("orc_steps", 2), # Extra ORC reasoning steps
312
+ FeaturePriority("curation", 1), # Curation windows
313
+ FeaturePriority("re_grounding", 1), # Re-grounding windows
314
+ FeaturePriority("review_tier2", 0), # Binary probes (cheapest, shed last)
315
+ ]
316
+
317
+
318
+ class OverheadDecision(str, Enum):
319
+ """Result of overhead budget check."""
320
+
321
+ ALLOW = "allow"
322
+ DENY = "deny"
323
+
324
+
325
+ @dataclass
326
+ class OverheadBudget:
327
+ """Caps total protocol overhead across all features (§6.9).
328
+
329
+ Default: 15% of productive windows.
330
+ Feature shedding: lowest priority shed first.
331
+ Never shed: extraction (always runs) and Tier 1 validation (zero LLM cost).
332
+ """
333
+
334
+ max_overhead_pct: float = DEFAULT_OVERHEAD_CAP
335
+ current_overhead_windows: int = 0
336
+ current_productive_windows: int = 0
337
+ shed_log: list[str] = field(default_factory=list)
338
+
339
+ @property
340
+ def current_ratio(self) -> float:
341
+ if self.current_productive_windows == 0:
342
+ return 0.0
343
+ return self.current_overhead_windows / self.current_productive_windows
344
+
345
+ def record_productive(self) -> None:
346
+ """Record a productive (non-overhead) window."""
347
+ self.current_productive_windows += 1
348
+
349
+ def check(self, feature_name: str) -> OverheadDecision:
350
+ """Check if an overhead window is allowed for the given feature.
351
+
352
+ Returns ALLOW or DENY. If denied, logs the shedding event.
353
+ """
354
+ ratio = self.current_ratio
355
+
356
+ if ratio < self.max_overhead_pct / 100:
357
+ self.current_overhead_windows += 1
358
+ return OverheadDecision.ALLOW
359
+
360
+ # Over budget — check if feature is high-priority (gets grace)
361
+ weight = self._get_weight(feature_name)
362
+ if weight >= HIGH_PRIORITY_WEIGHT:
363
+ grace_limit = (self.max_overhead_pct + GRACE_PCT) / 100
364
+ if ratio < grace_limit:
365
+ self.current_overhead_windows += 1
366
+ return OverheadDecision.ALLOW
367
+
368
+ # Deny and log
369
+ msg = (
370
+ f"Overhead budget exceeded ({ratio:.1%} > {self.max_overhead_pct}%). "
371
+ f"Shedding {feature_name}."
372
+ )
373
+ logger.info(msg)
374
+ self.shed_log.append(msg)
375
+ return OverheadDecision.DENY
376
+
377
+ def reset(self) -> None:
378
+ """Reset on session start."""
379
+ self.current_overhead_windows = 0
380
+ self.current_productive_windows = 0
381
+ self.shed_log.clear()
382
+
383
+ @staticmethod
384
+ def _get_weight(feature_name: str) -> int:
385
+ for fp in FEATURE_PRIORITY:
386
+ if fp.name == feature_name:
387
+ return fp.weight
388
+ return 0
@@ -0,0 +1,217 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Overhead budget manager — shedding cascade + lazy embedding (§6.12, §3.2).
4
+
5
+ OverheadBudgetManager sits on top of the existing ``OverheadBudget`` in
6
+ ``cost_model.py`` and adds two features from the spec:
7
+
8
+ 1. **Feature shedding cascade (§6.12)**
9
+ When overhead exceeds the cap (default 15%), features are shed in a
10
+ fixed cost order:
11
+
12
+ community_detection → cross_encoder → gliner → uie → discourse
13
+
14
+ Each feature can be individually disabled and later re-enabled when
15
+ overhead drops.
16
+
17
+ 2. **Lazy embedding batch (§3.2)**
18
+ Instead of computing embeddings on every ``fact.created`` event,
19
+ the manager *defers* them and batch-processes once N facts have
20
+ accumulated or a flush is requested.
21
+
22
+ Design goals:
23
+ * Pure logic — no external deps, no I/O.
24
+ * Works with or without an EventEmitter.
25
+ * Thread-safe (uses a threading.Lock around mutable state).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import threading
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger("crp.overhead_manager")
36
+
37
+
38
+ # ═══════════════════════════════════════════════════════════════════════════
39
+ # Feature shedding cascade (§9.6a)
40
+ # ═══════════════════════════════════════════════════════════════════════════
41
+
42
+ # Ordered cheapest-to-most-expensive.
43
+ # First in the list = shed first when over budget.
44
+ SHEDDING_CASCADE: list[str] = [
45
+ "community_detection",
46
+ "cross_encoder",
47
+ "gliner",
48
+ "uie",
49
+ "discourse",
50
+ ]
51
+
52
+ # ML intelligence features that must NEVER be shed. These are the core
53
+ # extraction pipeline stages that provide CRP's analytical intelligence.
54
+ # Under pressure, the system throttles throughput (fewer facts per stage,
55
+ # larger batches) rather than disabling these capabilities.
56
+ PROTECTED_INTELLIGENCE: frozenset[str] = frozenset({"gliner", "uie", "discourse"})
57
+
58
+
59
+ @dataclass
60
+ class SheddingState:
61
+ """Tracks which features are currently enabled or shed."""
62
+
63
+ enabled: dict[str, bool] = field(default_factory=dict)
64
+
65
+ def __post_init__(self) -> None:
66
+ if not self.enabled:
67
+ self.enabled = {f: True for f in SHEDDING_CASCADE}
68
+
69
+
70
+ # ═══════════════════════════════════════════════════════════════════════════
71
+ # Lazy embedding batch (§9.6b)
72
+ # ═══════════════════════════════════════════════════════════════════════════
73
+
74
+ @dataclass
75
+ class _PendingEmbedding:
76
+ """One deferred embedding request."""
77
+
78
+ fact_id: str
79
+ text: str
80
+
81
+
82
+ # ═══════════════════════════════════════════════════════════════════════════
83
+ # OverheadBudgetManager
84
+ # ═══════════════════════════════════════════════════════════════════════════
85
+
86
+
87
+ class OverheadBudgetManager:
88
+ """Orchestrates feature shedding and lazy embedding batching.
89
+
90
+ Usage::
91
+
92
+ mgr = OverheadBudgetManager(max_overhead_pct=15, batch_size=32)
93
+
94
+ # After each window, update overhead ratio:
95
+ mgr.update_overhead(current_pct=12.5)
96
+ assert mgr.is_feature_enabled("gliner")
97
+
98
+ # When overhead spikes:
99
+ mgr.update_overhead(current_pct=18.0)
100
+ assert not mgr.is_feature_enabled("community_detection") # shed first
101
+
102
+ # Lazy embedding:
103
+ mgr.defer_embedding("f1", "some fact text")
104
+ mgr.defer_embedding("f2", "another fact text")
105
+ batch = mgr.flush_embeddings() # returns pending, clears queue
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ max_overhead_pct: float = 15.0,
111
+ batch_size: int = 32,
112
+ ) -> None:
113
+ self._lock = threading.Lock()
114
+ self._max_pct = max_overhead_pct
115
+ self._batch_size = batch_size
116
+ self._shedding = SheddingState()
117
+ self._pending: list[_PendingEmbedding] = []
118
+ self._current_pct: float = 0.0
119
+ self._shed_log: list[str] = []
120
+
121
+ # -- shedding cascade -------------------------------------------------
122
+
123
+ def update_overhead(self, current_pct: float) -> list[str]:
124
+ """Update the current overhead percentage and shed/restore features.
125
+
126
+ Returns a list of features that changed state (shed or restored).
127
+ """
128
+ with self._lock:
129
+ self._current_pct = current_pct
130
+ changed: list[str] = []
131
+
132
+ if current_pct > self._max_pct:
133
+ # Shed features cheapest-first until (conceptually) under budget.
134
+ # PROTECTED_INTELLIGENCE features (gliner, uie, discourse) are
135
+ # never shed — they are core ML extraction intelligence.
136
+ for feat in SHEDDING_CASCADE:
137
+ if feat in PROTECTED_INTELLIGENCE:
138
+ continue # never shed ML intelligence
139
+ if self._shedding.enabled[feat]:
140
+ self._shedding.enabled[feat] = False
141
+ msg = f"Shed {feat} (overhead {current_pct:.1f}% > {self._max_pct}%)"
142
+ logger.info(msg)
143
+ self._shed_log.append(msg)
144
+ changed.append(feat)
145
+ break # shed one at a time per update call
146
+ else:
147
+ # Restore features most-expensive-first (reverse order).
148
+ for feat in reversed(SHEDDING_CASCADE):
149
+ if feat in PROTECTED_INTELLIGENCE:
150
+ continue # protected features are always on
151
+ if not self._shedding.enabled[feat]:
152
+ self._shedding.enabled[feat] = True
153
+ msg = f"Restored {feat} (overhead {current_pct:.1f}%)"
154
+ logger.info(msg)
155
+ changed.append(feat)
156
+ break
157
+
158
+ return changed
159
+
160
+ def is_feature_enabled(self, name: str) -> bool:
161
+ """Check if a feature is currently enabled."""
162
+ with self._lock:
163
+ return self._shedding.enabled.get(name, True)
164
+
165
+ @property
166
+ def shed_log(self) -> list[str]:
167
+ with self._lock:
168
+ return list(self._shed_log)
169
+
170
+ @property
171
+ def enabled_features(self) -> dict[str, bool]:
172
+ with self._lock:
173
+ return dict(self._shedding.enabled)
174
+
175
+ # -- lazy embedding batch ---------------------------------------------
176
+
177
+ def defer_embedding(self, fact_id: str, text: str) -> bool:
178
+ """Queue a fact for deferred embedding.
179
+
180
+ Returns ``True`` if the batch is now full and should be flushed.
181
+ """
182
+ with self._lock:
183
+ self._pending.append(_PendingEmbedding(fact_id=fact_id, text=text))
184
+ return len(self._pending) >= self._batch_size
185
+
186
+ def flush_embeddings(self) -> list[tuple[str, str]]:
187
+ """Return all pending embeddings as ``(fact_id, text)`` and clear queue."""
188
+ with self._lock:
189
+ batch = [(p.fact_id, p.text) for p in self._pending]
190
+ self._pending.clear()
191
+ return batch
192
+
193
+ @property
194
+ def pending_embedding_count(self) -> int:
195
+ with self._lock:
196
+ return len(self._pending)
197
+
198
+ # -- introspection ----------------------------------------------------
199
+
200
+ @property
201
+ def current_overhead_pct(self) -> float:
202
+ return self._current_pct
203
+
204
+ @property
205
+ def max_overhead_pct(self) -> float:
206
+ return self._max_pct
207
+
208
+ def summary(self) -> dict[str, Any]:
209
+ """Return a JSON-serializable summary of the manager state."""
210
+ with self._lock:
211
+ return {
212
+ "current_overhead_pct": round(self._current_pct, 2),
213
+ "max_overhead_pct": self._max_pct,
214
+ "features": dict(self._shedding.enabled),
215
+ "pending_embeddings": len(self._pending),
216
+ "shed_log": list(self._shed_log),
217
+ }