equiframe 0.12.4__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 (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,431 @@
1
+ """Declarative detector state: no executable estimators, labels or row metadata."""
2
+
3
+ from fractions import Fraction
4
+ from math import ceil
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field, model_validator
8
+ from typing_extensions import Self
9
+
10
+ from equiframe._base import FrozenModel, NonNegativeInt, PositiveInt, Text, _ExplicitOutcome
11
+ from equiframe.profile_types import PreprocessorBundle, ProfileVector, profile_digest
12
+ from equiframe.types import DecisionState, Sha256
13
+
14
+ ScoreValue = Annotated[float, Field(ge=0)] | Literal["+infinity"]
15
+
16
+
17
+ def numeric_score(value: ScoreValue) -> float:
18
+ return float("inf") if value == "+infinity" else value
19
+
20
+
21
+ def quantile_rank(alpha: str, n: int) -> int:
22
+ """Decimal probability and exact rank; binary rounding must not change ceil."""
23
+ import re
24
+
25
+ if not isinstance(alpha, str) or not re.fullmatch(r"0\.[0-9]{1,18}", alpha):
26
+ raise ValueError("alpha must be an explicit decimal string, e.g. '0.05'")
27
+ probability = Fraction(alpha)
28
+ if not 0 < probability < 1 or n < 1:
29
+ raise ValueError("alpha must be between zero and one and n must be positive")
30
+ return ceil((1 - probability) * n)
31
+
32
+
33
+ class NormalCalibrationProfiles(FrozenModel):
34
+ purpose: Literal["normal_calibration"]
35
+ selection_manifest_sha256: Sha256
36
+ profiles: tuple[ProfileVector, ...]
37
+
38
+
39
+ class DetectorSupport(FrozenModel):
40
+ """Technical minima, not a statistical power or false-alarm guarantee."""
41
+
42
+ min_training_rows: Annotated[int, Field(ge=2)]
43
+ min_calibration_rows: PositiveInt
44
+ rationale: Text
45
+
46
+
47
+ class IsolationForestConfig(FrozenModel):
48
+ random_state: Annotated[int, Field(ge=0, le=2**32 - 1)]
49
+ n_estimators: Annotated[int, Field(ge=1, le=2048)] = 200
50
+ max_samples: Annotated[int, Field(ge=2, le=4096)] = 256
51
+
52
+
53
+ class IsolationNode(FrozenModel):
54
+ samples: PositiveInt
55
+ left: NonNegativeInt | None = None
56
+ right: NonNegativeInt | None = None
57
+ feature: NonNegativeInt | None = None
58
+ threshold: float | None = None
59
+
60
+ @model_validator(mode="after")
61
+ def consistent(self) -> Self:
62
+ fields = (self.left, self.right, self.feature, self.threshold)
63
+ if any(f is None for f in fields) and not all(f is None for f in fields):
64
+ raise ValueError("node must be a leaf or a complete binary split")
65
+ return self
66
+
67
+
68
+ class IsolationTree(FrozenModel):
69
+ nodes: Annotated[tuple[IsolationNode, ...], Field(min_length=1, max_length=8191)]
70
+
71
+ @model_validator(mode="after")
72
+ def consistent(self) -> Self:
73
+ parents = [0] * len(self.nodes)
74
+ for index, node in enumerate(self.nodes):
75
+ if node.left is None or node.right is None:
76
+ continue
77
+ if not index < node.left < len(self.nodes) or not index < node.right < len(self.nodes):
78
+ raise ValueError("tree children must be forward references inside the tree")
79
+ parents[node.left] += 1
80
+ parents[node.right] += 1
81
+ if self.nodes[node.left].samples + self.nodes[node.right].samples != node.samples:
82
+ raise ValueError("child sample counts must partition the parent")
83
+ if parents != [0] + [1] * (len(self.nodes) - 1):
84
+ raise ValueError("tree must have one root and no shared or disconnected nodes")
85
+ return self
86
+
87
+
88
+ class DetectorModel(FrozenModel):
89
+ method: Literal["isolation_forest", "mad"]
90
+ state: Literal["fitted", "not_estimable"]
91
+ reason: Text | None = None
92
+ trees: Annotated[tuple[IsolationTree, ...], Field(max_length=2048)] = ()
93
+ effective_samples: Annotated[int, Field(ge=2, le=4096)] | None = None
94
+ medians: tuple[float, ...] = ()
95
+ deviations: tuple[Annotated[float, Field(ge=0)], ...] = ()
96
+
97
+ @model_validator(mode="after")
98
+ def consistent(self) -> Self:
99
+ if self.state == "not_estimable":
100
+ if self.reason is None or self.trees or self.medians or self.deviations:
101
+ raise ValueError("unestimable model requires reason and no fitted parameters")
102
+ if self.effective_samples is not None:
103
+ raise ValueError("unestimable model has no effective subsample")
104
+ elif self.reason is not None:
105
+ raise ValueError("fitted model cannot have a failure reason")
106
+ elif self.method == "isolation_forest":
107
+ if not self.trees or self.effective_samples is None or self.medians or self.deviations:
108
+ raise ValueError("Isolation Forest requires only trees and effective samples")
109
+ if any(t.nodes[0].samples != self.effective_samples for t in self.trees):
110
+ raise ValueError("tree roots must match effective subsample")
111
+ if any(len(t.nodes) > 2 * self.effective_samples - 1 for t in self.trees):
112
+ raise ValueError("tree size exceeds subsample support")
113
+ if not any(len(t.nodes) > 1 for t in self.trees):
114
+ raise ValueError("fitted Isolation Forest must contain at least one split")
115
+ elif (
116
+ not self.medians
117
+ or len(self.medians) != len(self.deviations)
118
+ or self.trees
119
+ or self.effective_samples is not None
120
+ ):
121
+ raise ValueError("MAD requires only equally sized medians and deviations")
122
+ return self
123
+
124
+
125
+ class DetectorVersions(FrozenModel):
126
+ equiframe: Literal[
127
+ "0.7.0",
128
+ "0.8.0",
129
+ "0.8.1",
130
+ "0.9.0",
131
+ "0.10.0",
132
+ "0.11.0",
133
+ "0.11.1",
134
+ "0.12.0a1",
135
+ "0.12.0a2",
136
+ "0.12.0a3",
137
+ "0.12.0a4",
138
+ "0.12.0a5",
139
+ "0.12.0a6",
140
+ "0.12.0a7",
141
+ "0.12.0a8",
142
+ "0.12.0a9",
143
+ "0.12.0a10",
144
+ "0.12.0a11",
145
+ "0.12.0a12",
146
+ "0.12.0a13",
147
+ "0.12.0a14",
148
+ "0.12.0",
149
+ "0.12.1",
150
+ "0.12.2",
151
+ "0.12.3",
152
+ "0.12.4",
153
+ ] = "0.12.4"
154
+ python: Text
155
+ sklearn: Text | None = None
156
+ numpy: Text | None = None
157
+
158
+
159
+ class DetectorFit(FrozenModel):
160
+ schema_version: Literal["1.0"] = "1.0"
161
+ algorithm: Literal["equiframe-if-mad-1.0"] = "equiframe-if-mad-1.0"
162
+ preprocessor: PreprocessorBundle
163
+ config: IsolationForestConfig
164
+ support: DetectorSupport
165
+ versions: DetectorVersions
166
+ training_matrix_sha256: Sha256 | None
167
+ m: DetectorModel
168
+ b: DetectorModel
169
+
170
+ @model_validator(mode="after")
171
+ def consistent(self) -> Self:
172
+ if self.m.method != "isolation_forest" or self.b.method != "mad":
173
+ raise ValueError("M and B must remain separate methods")
174
+ ready = self.preprocessor.state == "ready"
175
+ if ready != (self.training_matrix_sha256 is not None):
176
+ raise ValueError("matrix fingerprint must reflect preprocessor availability")
177
+ for model in (self.m, self.b):
178
+ if model.state == "fitted" and (
179
+ not ready or self.preprocessor.training_rows < self.support.min_training_rows
180
+ ):
181
+ raise ValueError("fitted model lacks preprocessing or training support")
182
+ if self.m.state == "fitted":
183
+ if len(self.m.trees) != self.config.n_estimators:
184
+ raise ValueError("tree count differs from configuration")
185
+ if self.m.effective_samples != min(
186
+ self.config.max_samples, self.preprocessor.training_rows
187
+ ):
188
+ raise ValueError("effective subsample differs from configuration")
189
+ if not self.versions.sklearn or not self.versions.numpy:
190
+ raise ValueError("fitted Isolation Forest requires producer versions")
191
+ if any(
192
+ n.feature is not None and n.feature >= len(self.preprocessor.columns)
193
+ for t in self.m.trees
194
+ for n in t.nodes
195
+ ):
196
+ raise ValueError("tree references a feature outside the frozen matrix")
197
+ if self.b.state == "fitted" and len(self.b.medians) != len(self.preprocessor.columns):
198
+ raise ValueError("MAD parameters differ from frozen matrix width")
199
+ return self
200
+
201
+
202
+ class DetectorScores(_ExplicitOutcome, FrozenModel):
203
+ _truth_value_guidance = "inspect .state explicitly; scores are not an approval decision"
204
+
205
+ state: Literal["scored", "not_estimable"]
206
+ values: tuple[ScoreValue, ...] = ()
207
+ reason: Text | None = None
208
+
209
+ @model_validator(mode="after")
210
+ def consistent(self) -> Self:
211
+ if self.state == "scored" and self.reason is not None:
212
+ raise ValueError("successful scores cannot have a failure reason")
213
+ if self.state == "not_estimable" and (self.reason is None or self.values):
214
+ raise ValueError("unestimable scores require reason and no invented values")
215
+ return self
216
+
217
+
218
+ class DetectorScoreResult(_ExplicitOutcome, FrozenModel):
219
+ _truth_value_guidance = (
220
+ "inspect .m.state, .b.state and .rows explicitly; scores are not approval"
221
+ )
222
+
223
+ schema_version: Literal["1.0"] = "1.0"
224
+ fit_sha256: Sha256
225
+ matrix_sha256: Sha256 | None
226
+ rows: NonNegativeInt
227
+ m: DetectorScores
228
+ b: DetectorScores
229
+
230
+ @model_validator(mode="after")
231
+ def consistent(self) -> Self:
232
+ if any(s.state == "scored" and len(s.values) != self.rows for s in (self.m, self.b)):
233
+ raise ValueError("score count must match input row count")
234
+ if any(s.state == "scored" for s in (self.m, self.b)) and self.matrix_sha256 is None:
235
+ raise ValueError("scores require a matrix fingerprint")
236
+ return self
237
+
238
+
239
+ class DetectorCalibration(FrozenModel):
240
+ alpha: Text
241
+ sample_count: NonNegativeInt
242
+ state: Literal["ready", "not_estimable"]
243
+ scores: tuple[ScoreValue, ...] = ()
244
+ rank: PositiveInt | None = None
245
+ threshold: ScoreValue | None = None
246
+ reason: Text | None = None
247
+
248
+ @model_validator(mode="after")
249
+ def consistent(self) -> Self:
250
+ quantile_rank(self.alpha, max(1, self.sample_count))
251
+ if self.scores:
252
+ if len(self.scores) != self.sample_count:
253
+ raise ValueError("calibration count does not match its scores")
254
+ rank = quantile_rank(self.alpha, self.sample_count)
255
+ expected = sorted(self.scores, key=numeric_score)[rank - 1]
256
+ if len(self.scores) != self.sample_count or self.rank != rank:
257
+ raise ValueError("calibration count/rank does not match its scores")
258
+ if self.threshold != expected:
259
+ raise ValueError("threshold must be the exact empirical order statistic")
260
+ infinite = expected == "+infinity"
261
+ if self.state != ("not_estimable" if infinite else "ready"):
262
+ raise ValueError("calibration state must reflect finite threshold availability")
263
+ if self.reason != ("infinite_calibration_threshold" if infinite else None):
264
+ raise ValueError("calibration reason contradicts its threshold")
265
+ elif (
266
+ self.state != "not_estimable"
267
+ or self.reason is None
268
+ or self.rank is not None
269
+ or self.threshold is not None
270
+ ):
271
+ raise ValueError("missing calibration scores cannot produce a threshold or success")
272
+ return self
273
+
274
+
275
+ class DetectorBundle(FrozenModel):
276
+ schema_version: Literal["1.0"] = "1.0"
277
+ fit: DetectorFit
278
+ calibration_manifest_sha256: Sha256
279
+ calibration_matrix_sha256: Sha256 | None
280
+ m: DetectorCalibration
281
+ b: DetectorCalibration
282
+
283
+ @model_validator(mode="after")
284
+ def consistent(self) -> Self:
285
+ if self.calibration_manifest_sha256 == self.fit.preprocessor.selection_manifest_sha256:
286
+ raise ValueError("calibration must have a separate selection manifest")
287
+ if self.m.alpha != self.b.alpha or self.m.sample_count != self.b.sample_count:
288
+ raise ValueError("M and B must use the same alpha and normal calibration rows")
289
+ for model, calibrated in ((self.fit.m, self.m), (self.fit.b, self.b)):
290
+ if calibrated.scores and (
291
+ model.state != "fitted"
292
+ or self.calibration_matrix_sha256 is None
293
+ or calibrated.sample_count < self.fit.support.min_calibration_rows
294
+ ):
295
+ raise ValueError("calibration lacks model, matrix or minimum support")
296
+ if any(s == "+infinity" or not 0 < s <= 1 for s in self.m.scores):
297
+ raise ValueError("Isolation Forest scores must be finite in (0, 1]")
298
+ return self
299
+
300
+
301
+ class ComponentDecision(_ExplicitOutcome, FrozenModel):
302
+ _truth_value_guidance = (
303
+ "inspect .decision and .complete explicitly; no_alert is not equivalence"
304
+ )
305
+
306
+ decision: DecisionState
307
+ complete: bool
308
+ reason: Text | None = None
309
+
310
+ @model_validator(mode="after")
311
+ def consistent(self) -> Self:
312
+ if self.complete != (self.reason is None):
313
+ raise ValueError("incomplete decision requires an explicit reason")
314
+ if self.decision == "no_alert" and not self.complete:
315
+ raise ValueError("absence of alert requires a complete evaluation")
316
+ if self.decision == "not_evaluable" and self.complete:
317
+ raise ValueError("unavailable decision cannot be complete")
318
+ return self
319
+
320
+
321
+ class DetectorDecision(_ExplicitOutcome, FrozenModel):
322
+ _truth_value_guidance = (
323
+ "inspect .result.decision and .result.complete explicitly; no_alert is not equivalence"
324
+ )
325
+
326
+ result: ComponentDecision
327
+ score: ScoreValue | None
328
+ threshold: Annotated[float, Field(ge=0)] | None
329
+
330
+ @model_validator(mode="after")
331
+ def consistent(self) -> Self:
332
+ if self.result.complete:
333
+ if self.score is None or self.threshold is None:
334
+ raise ValueError("decision requires score and finite calibrated threshold")
335
+ expected = "alert" if numeric_score(self.score) > self.threshold else "no_alert"
336
+ if self.result.decision != expected:
337
+ raise ValueError("decision must use strict score > threshold")
338
+ elif self.result.decision != "not_evaluable" or self.threshold is not None:
339
+ raise ValueError("uncalibrated detector cannot claim a decision or threshold")
340
+ return self
341
+
342
+
343
+ class DetectionResult(_ExplicitOutcome, FrozenModel):
344
+ _truth_value_guidance = (
345
+ "inspect .m and .b decisions and coverage explicitly; no_alert is not equivalence"
346
+ )
347
+
348
+ schema_version: Literal["1.0"] = "1.0"
349
+ bundle_sha256: Sha256
350
+ scores: DetectorScoreResult
351
+ m: tuple[DetectorDecision, ...]
352
+ b: tuple[DetectorDecision, ...]
353
+
354
+ @model_validator(mode="after")
355
+ def consistent(self) -> Self:
356
+ if len(self.m) != self.scores.rows or len(self.b) != self.scores.rows:
357
+ raise ValueError("decisions must cover every input profile")
358
+ for decisions, scores in ((self.m, self.scores.m), (self.b, self.scores.b)):
359
+ expected = scores.values if scores.state == "scored" else (None,) * self.scores.rows
360
+ if tuple(d.score for d in decisions) != expected:
361
+ raise ValueError("decisions must preserve the actual scores")
362
+ return self
363
+
364
+
365
+ class ComponentDetectionResult(_ExplicitOutcome, FrozenModel):
366
+ """Frozen inference for one method; the other detector is not scored."""
367
+
368
+ _truth_value_guidance = (
369
+ "inspect .decisions, .complete and .rows explicitly; no_alert is not equivalence"
370
+ )
371
+
372
+ schema_version: Literal["1.0"] = "1.0"
373
+ method: Literal["isolation_forest", "mad"]
374
+ bundle_sha256: Sha256
375
+ fit_sha256: Sha256
376
+ profiles_sha256: Sha256
377
+ matrix_sha256: Sha256 | None
378
+ rows: NonNegativeInt
379
+ scores: DetectorScores
380
+ decisions: tuple[DetectorDecision, ...]
381
+
382
+ @model_validator(mode="after")
383
+ def consistent(self) -> Self:
384
+ if len(self.decisions) != self.rows:
385
+ raise ValueError("decisions must cover every input profile")
386
+ if self.scores.state == "scored":
387
+ if len(self.scores.values) != self.rows or self.matrix_sha256 is None:
388
+ raise ValueError("scores require every profile and a matrix fingerprint")
389
+ if self.method == "isolation_forest" and any(
390
+ s == "+infinity" or not 0 < s <= 1 for s in self.scores.values
391
+ ):
392
+ raise ValueError("Isolation Forest scores must be finite in (0, 1]")
393
+ expected = self.scores.values if self.scores.state == "scored" else (None,) * self.rows
394
+ if tuple(d.score for d in self.decisions) != expected:
395
+ raise ValueError("decisions must preserve the actual component scores")
396
+ return self
397
+
398
+ @property
399
+ def complete(self) -> bool:
400
+ return all(decision.result.complete for decision in self.decisions)
401
+
402
+
403
+ class HybridDecision(_ExplicitOutcome, FrozenModel):
404
+ _truth_value_guidance = (
405
+ "inspect .result.decision and .result.complete explicitly; no_alert is not equivalence"
406
+ )
407
+
408
+ schema_version: Literal["1.0"] = "1.0"
409
+ r: ComponentDecision
410
+ m: ComponentDecision
411
+ result: ComponentDecision
412
+
413
+ @model_validator(mode="after")
414
+ def consistent(self) -> Self:
415
+ decision = (
416
+ "alert"
417
+ if "alert" in (self.r.decision, self.m.decision)
418
+ else "no_alert"
419
+ if self.r.decision == self.m.decision == "no_alert"
420
+ else "not_evaluable"
421
+ )
422
+ if self.result.decision != decision or self.result.complete != (
423
+ self.r.complete and self.m.complete
424
+ ):
425
+ raise ValueError("H must preserve OR decisions and component completeness")
426
+ return self
427
+
428
+
429
+ def detector_fingerprint(value: DetectorFit | DetectorBundle) -> str:
430
+ validated = type(value).model_validate(value)
431
+ return profile_digest(validated.model_dump(mode="json"))