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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- 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"))
|