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
equiframe/detectors.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Fit, score and calibrate family detectors independently from rules and the oracle."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import platform
|
|
5
|
+
import struct
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from equiframe._document_io import read_document as _read_document
|
|
11
|
+
from equiframe._document_io import write_document as _write_document
|
|
12
|
+
from equiframe._regular_io import local_input_path
|
|
13
|
+
from equiframe.detector_types import (
|
|
14
|
+
ComponentDecision,
|
|
15
|
+
ComponentDetectionResult,
|
|
16
|
+
DetectionResult,
|
|
17
|
+
DetectorBundle,
|
|
18
|
+
DetectorCalibration,
|
|
19
|
+
DetectorDecision,
|
|
20
|
+
DetectorFit,
|
|
21
|
+
DetectorModel,
|
|
22
|
+
DetectorScoreResult,
|
|
23
|
+
DetectorScores,
|
|
24
|
+
DetectorSupport,
|
|
25
|
+
DetectorVersions,
|
|
26
|
+
IsolationForestConfig,
|
|
27
|
+
IsolationNode,
|
|
28
|
+
IsolationTree,
|
|
29
|
+
NormalCalibrationProfiles,
|
|
30
|
+
ScoreValue,
|
|
31
|
+
detector_fingerprint,
|
|
32
|
+
numeric_score,
|
|
33
|
+
quantile_rank,
|
|
34
|
+
)
|
|
35
|
+
from equiframe.errors import ArtifactIntegrityError, MissingDependencyError, ResourceLimitError
|
|
36
|
+
from equiframe.preprocessing import _compatible, fit_preprocessor, transform_profiles
|
|
37
|
+
from equiframe.profile_types import (
|
|
38
|
+
FeatureMatrix,
|
|
39
|
+
NormalTrainingProfiles,
|
|
40
|
+
ProfileSpec,
|
|
41
|
+
ProfileVector,
|
|
42
|
+
profile_digest,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unavailable(method: Literal["isolation_forest", "mad"], reason: str) -> DetectorModel:
|
|
47
|
+
return DetectorModel(method=method, state="not_estimable", reason=reason)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _float32(row: tuple[float, ...]) -> tuple[float, ...]:
|
|
51
|
+
try:
|
|
52
|
+
converted = tuple(struct.unpack("f", struct.pack("f", v))[0] for v in row)
|
|
53
|
+
except (OverflowError, struct.error) as exc:
|
|
54
|
+
raise ValueError("Isolation Forest input is outside finite float32 range") from exc
|
|
55
|
+
if not all(math.isfinite(v) for v in converted):
|
|
56
|
+
raise ValueError("Isolation Forest input is outside finite float32 range")
|
|
57
|
+
return converted
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fit_forest(
|
|
61
|
+
matrix: FeatureMatrix, config: IsolationForestConfig
|
|
62
|
+
) -> tuple[DetectorModel, str | None, str | None]:
|
|
63
|
+
converted = tuple(_float32(row) for row in matrix.values)
|
|
64
|
+
if len(set(converted)) < 2:
|
|
65
|
+
return _unavailable("isolation_forest", "no_float32_variation"), None, None
|
|
66
|
+
try:
|
|
67
|
+
import numpy as np
|
|
68
|
+
import sklearn
|
|
69
|
+
from sklearn.ensemble import IsolationForest
|
|
70
|
+
except ImportError as exc:
|
|
71
|
+
raise MissingDependencyError("Isolation Forest fit requires equiframe[ml]") from exc
|
|
72
|
+
samples = min(config.max_samples, len(matrix.values))
|
|
73
|
+
model = IsolationForest(
|
|
74
|
+
n_estimators=config.n_estimators,
|
|
75
|
+
max_samples=samples,
|
|
76
|
+
random_state=config.random_state,
|
|
77
|
+
contamination="auto",
|
|
78
|
+
max_features=1.0,
|
|
79
|
+
bootstrap=False,
|
|
80
|
+
n_jobs=1,
|
|
81
|
+
).fit(np.asarray(converted, dtype=np.float32))
|
|
82
|
+
trees = []
|
|
83
|
+
for estimator in model.estimators_:
|
|
84
|
+
tree = estimator.tree_
|
|
85
|
+
nodes = []
|
|
86
|
+
for i in range(tree.node_count):
|
|
87
|
+
leaf = tree.children_left[i] == -1
|
|
88
|
+
nodes.append(
|
|
89
|
+
IsolationNode(
|
|
90
|
+
samples=int(tree.n_node_samples[i]),
|
|
91
|
+
left=None if leaf else int(tree.children_left[i]),
|
|
92
|
+
right=None if leaf else int(tree.children_right[i]),
|
|
93
|
+
feature=None if leaf else int(tree.feature[i]),
|
|
94
|
+
threshold=None if leaf else float(tree.threshold[i]),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
trees.append(IsolationTree(nodes=tuple(nodes)))
|
|
98
|
+
if not any(len(tree.nodes) > 1 for tree in trees):
|
|
99
|
+
return (
|
|
100
|
+
_unavailable("isolation_forest", "no_isolation_splits"),
|
|
101
|
+
sklearn.__version__,
|
|
102
|
+
np.__version__,
|
|
103
|
+
)
|
|
104
|
+
return (
|
|
105
|
+
DetectorModel(
|
|
106
|
+
method="isolation_forest", state="fitted", trees=tuple(trees), effective_samples=samples
|
|
107
|
+
),
|
|
108
|
+
sklearn.__version__,
|
|
109
|
+
np.__version__,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _median(values: tuple[float, ...]) -> float:
|
|
114
|
+
# statistics.median can overflow while averaging two finite positive numbers.
|
|
115
|
+
ordered = sorted(values)
|
|
116
|
+
middle = len(ordered) // 2
|
|
117
|
+
if len(ordered) % 2:
|
|
118
|
+
return ordered[middle]
|
|
119
|
+
return float((Fraction(ordered[middle - 1]) + Fraction(ordered[middle])) / 2)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fit_detectors(
|
|
123
|
+
training: NormalTrainingProfiles,
|
|
124
|
+
*,
|
|
125
|
+
spec: ProfileSpec,
|
|
126
|
+
config: IsolationForestConfig,
|
|
127
|
+
support: DetectorSupport,
|
|
128
|
+
empty_policy: Literal["drop", "not_estimable"] = "drop",
|
|
129
|
+
) -> DetectorFit:
|
|
130
|
+
"""Fit M and B on the same matrix. Eligibility is verified by the caller/benchmark."""
|
|
131
|
+
config = IsolationForestConfig.model_validate(config)
|
|
132
|
+
support = DetectorSupport.model_validate(support)
|
|
133
|
+
training = NormalTrainingProfiles.model_validate(training)
|
|
134
|
+
preprocessor = fit_preprocessor(training, spec=spec, empty_policy=empty_policy)
|
|
135
|
+
matrix = (
|
|
136
|
+
transform_profiles(preprocessor, training.profiles)
|
|
137
|
+
if preprocessor.state == "ready"
|
|
138
|
+
else None
|
|
139
|
+
)
|
|
140
|
+
reason = (
|
|
141
|
+
"preprocessor_not_estimable"
|
|
142
|
+
if matrix is None
|
|
143
|
+
else "insufficient_training_rows"
|
|
144
|
+
if len(matrix.values) < support.min_training_rows
|
|
145
|
+
else "no_training_variation"
|
|
146
|
+
if len(set(matrix.values)) < 2
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
sklearn_version = numpy_version = None
|
|
150
|
+
if reason is not None:
|
|
151
|
+
m = _unavailable("isolation_forest", reason)
|
|
152
|
+
b = _unavailable("mad", reason)
|
|
153
|
+
else:
|
|
154
|
+
if not (matrix is not None):
|
|
155
|
+
raise AssertionError("Internal invariant check failed.")
|
|
156
|
+
columns = tuple(zip(*matrix.values, strict=True))
|
|
157
|
+
medians = tuple(_median(c) for c in columns)
|
|
158
|
+
deviations = tuple(
|
|
159
|
+
_median(tuple(abs(v - center) for v in values))
|
|
160
|
+
for center, values in zip(medians, columns, strict=True)
|
|
161
|
+
)
|
|
162
|
+
b = DetectorModel(method="mad", state="fitted", medians=medians, deviations=deviations)
|
|
163
|
+
m, sklearn_version, numpy_version = _fit_forest(matrix, config)
|
|
164
|
+
return DetectorFit(
|
|
165
|
+
preprocessor=preprocessor,
|
|
166
|
+
config=config,
|
|
167
|
+
support=support,
|
|
168
|
+
versions=DetectorVersions(
|
|
169
|
+
python=platform.python_version(), sklearn=sklearn_version, numpy=numpy_version
|
|
170
|
+
),
|
|
171
|
+
training_matrix_sha256=profile_digest(matrix.model_dump(mode="json")) if matrix else None,
|
|
172
|
+
m=m,
|
|
173
|
+
b=b,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _average_path(samples: int) -> float:
|
|
178
|
+
if samples <= 1:
|
|
179
|
+
return 0.0
|
|
180
|
+
if samples == 2:
|
|
181
|
+
return 1.0
|
|
182
|
+
# The approximation used by sklearn IsolationForest, not the harmonic series.
|
|
183
|
+
return 2 * (math.log(samples - 1) + 0.5772156649015329) - 2 * (samples - 1) / samples
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _forest_score(model: DetectorModel, row: tuple[float, ...]) -> float:
|
|
187
|
+
row = _float32(row)
|
|
188
|
+
total = 0.0
|
|
189
|
+
for tree in model.trees:
|
|
190
|
+
index = depth = 0
|
|
191
|
+
node = tree.nodes[index]
|
|
192
|
+
while node.left is not None:
|
|
193
|
+
if not (
|
|
194
|
+
node.feature is not None and node.threshold is not None and node.right is not None
|
|
195
|
+
):
|
|
196
|
+
raise AssertionError("Internal invariant check failed.")
|
|
197
|
+
index = node.left if row[node.feature] <= node.threshold else node.right
|
|
198
|
+
depth += 1
|
|
199
|
+
node = tree.nodes[index]
|
|
200
|
+
total += depth + _average_path(node.samples)
|
|
201
|
+
if not (model.effective_samples is not None):
|
|
202
|
+
raise AssertionError("Internal invariant check failed.")
|
|
203
|
+
return float(2 ** (-total / (len(model.trees) * _average_path(model.effective_samples))))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _mad_score(model: DetectorModel, row: tuple[float, ...]) -> ScoreValue:
|
|
207
|
+
maximum = 0.0
|
|
208
|
+
for value, center, scale in zip(row, model.medians, model.deviations, strict=True):
|
|
209
|
+
if scale == 0:
|
|
210
|
+
if value != center:
|
|
211
|
+
return "+infinity"
|
|
212
|
+
continue
|
|
213
|
+
deviation = abs(value - center) / scale
|
|
214
|
+
if not math.isfinite(deviation):
|
|
215
|
+
# Mathematical infinity is reserved for MAD=0, not floating overflow.
|
|
216
|
+
raise ValueError("finite MAD score exceeds numeric range")
|
|
217
|
+
maximum = max(maximum, deviation)
|
|
218
|
+
return maximum
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _inference_matrix(
|
|
222
|
+
fit: DetectorFit, profiles: tuple[ProfileVector, ...]
|
|
223
|
+
) -> FeatureMatrix | None:
|
|
224
|
+
profiles = tuple(_compatible(p, fit.preprocessor.spec) for p in profiles)
|
|
225
|
+
return (
|
|
226
|
+
transform_profiles(fit.preprocessor, profiles)
|
|
227
|
+
if fit.preprocessor.state == "ready"
|
|
228
|
+
else None
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _score_model(model: DetectorModel, matrix: FeatureMatrix | None) -> DetectorScores:
|
|
233
|
+
if model.state == "not_estimable":
|
|
234
|
+
return DetectorScores(state="not_estimable", reason=model.reason)
|
|
235
|
+
if not (matrix is not None):
|
|
236
|
+
raise AssertionError("Internal invariant check failed.")
|
|
237
|
+
scorer = _forest_score if model.method == "isolation_forest" else _mad_score
|
|
238
|
+
return DetectorScores(state="scored", values=tuple(scorer(model, row) for row in matrix.values))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def score_detectors(fit: DetectorFit, profiles: tuple[ProfileVector, ...]) -> DetectorScoreResult:
|
|
242
|
+
"""Frozen inference. Incompatible/failed profiles raise, never become no-alert."""
|
|
243
|
+
fit = DetectorFit.model_validate(fit)
|
|
244
|
+
matrix = _inference_matrix(fit, profiles)
|
|
245
|
+
return DetectorScoreResult(
|
|
246
|
+
fit_sha256=detector_fingerprint(fit),
|
|
247
|
+
matrix_sha256=profile_digest(matrix.model_dump(mode="json")) if matrix else None,
|
|
248
|
+
rows=len(profiles),
|
|
249
|
+
m=_score_model(fit.m, matrix),
|
|
250
|
+
b=_score_model(fit.b, matrix),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _calibrate(scores: DetectorScores, alpha: str, n: int, minimum: int) -> DetectorCalibration:
|
|
255
|
+
if scores.state == "not_estimable" or n < minimum:
|
|
256
|
+
return DetectorCalibration(
|
|
257
|
+
alpha=alpha,
|
|
258
|
+
sample_count=n,
|
|
259
|
+
state="not_estimable",
|
|
260
|
+
reason=scores.reason or "insufficient_calibration_rows",
|
|
261
|
+
)
|
|
262
|
+
rank = quantile_rank(alpha, n)
|
|
263
|
+
threshold = sorted(scores.values, key=numeric_score)[rank - 1]
|
|
264
|
+
infinite = threshold == "+infinity"
|
|
265
|
+
return DetectorCalibration(
|
|
266
|
+
alpha=alpha,
|
|
267
|
+
sample_count=n,
|
|
268
|
+
scores=scores.values,
|
|
269
|
+
rank=rank,
|
|
270
|
+
threshold=threshold,
|
|
271
|
+
state="not_estimable" if infinite else "ready",
|
|
272
|
+
reason="infinite_calibration_threshold" if infinite else None,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def calibrate_detectors(
|
|
277
|
+
fit: DetectorFit, calibration: NormalCalibrationProfiles, *, alpha: str
|
|
278
|
+
) -> DetectorBundle:
|
|
279
|
+
"""Use separate normal calibration; the benchmark enforces disjoint group lineage."""
|
|
280
|
+
fit = DetectorFit.model_validate(fit)
|
|
281
|
+
calibration = NormalCalibrationProfiles.model_validate(calibration)
|
|
282
|
+
quantile_rank(alpha, max(1, len(calibration.profiles)))
|
|
283
|
+
if calibration.selection_manifest_sha256 == fit.preprocessor.selection_manifest_sha256:
|
|
284
|
+
raise ValueError("training and calibration selection manifests must differ")
|
|
285
|
+
scores = score_detectors(fit, calibration.profiles)
|
|
286
|
+
return DetectorBundle(
|
|
287
|
+
fit=fit,
|
|
288
|
+
calibration_manifest_sha256=calibration.selection_manifest_sha256,
|
|
289
|
+
calibration_matrix_sha256=scores.matrix_sha256,
|
|
290
|
+
m=_calibrate(scores.m, alpha, scores.rows, fit.support.min_calibration_rows),
|
|
291
|
+
b=_calibrate(scores.b, alpha, scores.rows, fit.support.min_calibration_rows),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _decisions(
|
|
296
|
+
calibrated: DetectorCalibration, scored: DetectorScores, count: int
|
|
297
|
+
) -> tuple[DetectorDecision, ...]:
|
|
298
|
+
rows = []
|
|
299
|
+
for index in range(count):
|
|
300
|
+
score = scored.values[index] if scored.state == "scored" else None
|
|
301
|
+
if calibrated.state != "ready":
|
|
302
|
+
rows.append(
|
|
303
|
+
DetectorDecision(
|
|
304
|
+
result=ComponentDecision(
|
|
305
|
+
decision="not_evaluable", complete=False, reason=calibrated.reason
|
|
306
|
+
),
|
|
307
|
+
score=score,
|
|
308
|
+
threshold=None,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
continue
|
|
312
|
+
if not (isinstance(calibrated.threshold, float) and score is not None):
|
|
313
|
+
raise AssertionError("Internal invariant check failed.")
|
|
314
|
+
rows.append(
|
|
315
|
+
DetectorDecision(
|
|
316
|
+
result=ComponentDecision(
|
|
317
|
+
decision="alert" if numeric_score(score) > calibrated.threshold else "no_alert",
|
|
318
|
+
complete=True,
|
|
319
|
+
),
|
|
320
|
+
score=score,
|
|
321
|
+
threshold=calibrated.threshold,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
return tuple(rows)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def detect(bundle: DetectorBundle, profiles: tuple[ProfileVector, ...]) -> DetectionResult:
|
|
328
|
+
bundle = DetectorBundle.model_validate(bundle)
|
|
329
|
+
scores = score_detectors(bundle.fit, profiles)
|
|
330
|
+
return DetectionResult(
|
|
331
|
+
bundle_sha256=detector_fingerprint(bundle),
|
|
332
|
+
scores=scores,
|
|
333
|
+
m=_decisions(bundle.m, scores.m, scores.rows),
|
|
334
|
+
b=_decisions(bundle.b, scores.b, scores.rows),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def detect_component(
|
|
339
|
+
bundle: DetectorBundle,
|
|
340
|
+
profiles: tuple[ProfileVector, ...],
|
|
341
|
+
*,
|
|
342
|
+
method: Literal["isolation_forest", "mad"],
|
|
343
|
+
) -> ComponentDetectionResult:
|
|
344
|
+
"""Score and decide one frozen detector, without evaluating the other method.
|
|
345
|
+
|
|
346
|
+
The shared preprocessor and strict decision threshold are identical to
|
|
347
|
+
``detect``. Invalid or technically failed profiles raise; missing model or
|
|
348
|
+
calibration support produces explicit not-evaluable decisions.
|
|
349
|
+
"""
|
|
350
|
+
if method not in ("isolation_forest", "mad"):
|
|
351
|
+
raise ValueError("method must be isolation_forest or mad")
|
|
352
|
+
bundle = DetectorBundle.model_validate(bundle)
|
|
353
|
+
matrix = _inference_matrix(bundle.fit, profiles)
|
|
354
|
+
model, calibrated = (
|
|
355
|
+
(bundle.fit.m, bundle.m) if method == "isolation_forest" else (bundle.fit.b, bundle.b)
|
|
356
|
+
)
|
|
357
|
+
scores = _score_model(model, matrix)
|
|
358
|
+
return ComponentDetectionResult(
|
|
359
|
+
method=method,
|
|
360
|
+
bundle_sha256=detector_fingerprint(bundle),
|
|
361
|
+
fit_sha256=detector_fingerprint(bundle.fit),
|
|
362
|
+
profiles_sha256=profile_digest([p.model_dump(mode="json") for p in profiles]),
|
|
363
|
+
matrix_sha256=profile_digest(matrix.model_dump(mode="json")) if matrix else None,
|
|
364
|
+
rows=len(profiles),
|
|
365
|
+
scores=scores,
|
|
366
|
+
decisions=_decisions(calibrated, scores, len(profiles)),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_detector_bundle(bundle: DetectorBundle, path: Path) -> str:
|
|
371
|
+
"""Append-only JSON. Returns semantic digest for a trusted external manifest."""
|
|
372
|
+
bundle = DetectorBundle.model_validate(bundle)
|
|
373
|
+
payload = bundle.model_dump_json().encode("utf-8")
|
|
374
|
+
if len(payload) > 64 * 1024 * 1024:
|
|
375
|
+
raise ValueError("detector bundle exceeds the 64 MiB document limit")
|
|
376
|
+
_write_document(path, payload)
|
|
377
|
+
return detector_fingerprint(bundle)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def read_detector_bundle(path: Path, *, expected_sha256: str | None = None) -> DetectorBundle:
|
|
381
|
+
"""Read bounded declarative data only; no pickle, estimator restoration or imports."""
|
|
382
|
+
try:
|
|
383
|
+
raw = _read_document(local_input_path(path))
|
|
384
|
+
except ResourceLimitError:
|
|
385
|
+
raise ValueError("detector bundle exceeds the 64 MiB document limit") from None
|
|
386
|
+
bundle = DetectorBundle.model_validate_json(raw)
|
|
387
|
+
if expected_sha256 is not None and detector_fingerprint(bundle) != expected_sha256:
|
|
388
|
+
raise ArtifactIntegrityError("detector bundle fingerprint mismatch")
|
|
389
|
+
return bundle
|
equiframe/diff.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""A direct-input facade over the existing complete comparison kernel.
|
|
2
|
+
|
|
3
|
+
Actual DuckDB relations explicitly supplied by the caller are consumed as native
|
|
4
|
+
inputs. SQL strings, connections and Polars lazy plans are not accepted. Capture
|
|
5
|
+
and inference failures before a valid contract exists raise; once configured,
|
|
6
|
+
the kernel owns semantic failure states.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Literal
|
|
15
|
+
|
|
16
|
+
from typing_extensions import TypeAliasType
|
|
17
|
+
|
|
18
|
+
from equiframe._base import FrozenModel
|
|
19
|
+
from equiframe._diff_capture import (
|
|
20
|
+
DiffCaptureOptions,
|
|
21
|
+
_CapturedInput,
|
|
22
|
+
_NativeInput,
|
|
23
|
+
capture_native,
|
|
24
|
+
)
|
|
25
|
+
from equiframe._document_io import read_document
|
|
26
|
+
from equiframe._regular_io import local_input_path
|
|
27
|
+
from equiframe.comparison import ComparisonInput, _compare, comparison_fingerprint
|
|
28
|
+
from equiframe.comparison_types import ComparisonOptions, ComparisonPolicy, ComparisonResult
|
|
29
|
+
from equiframe.contracts import Contract
|
|
30
|
+
from equiframe.diff_contracts import DiffContract, infer_diff_contract
|
|
31
|
+
from equiframe.errors import ArtifactIntegrityError, CapabilityError, ExecutionCancelled
|
|
32
|
+
from equiframe.execution import CancellationToken
|
|
33
|
+
from equiframe.execution_types import ExecutionResult, TableArtifact
|
|
34
|
+
from equiframe.local import local_path
|
|
35
|
+
from equiframe.tables import ArrowInput
|
|
36
|
+
from equiframe.types import ArtifactRef, Sha256
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import duckdb
|
|
40
|
+
import pandas as pd
|
|
41
|
+
import polars as pl
|
|
42
|
+
import pyarrow as pa
|
|
43
|
+
|
|
44
|
+
DiffInput = TypeAliasType(
|
|
45
|
+
"DiffInput",
|
|
46
|
+
ComparisonInput | pd.DataFrame | pl.DataFrame | pa.Table | duckdb.DuckDBPyRelation,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
# Introspection must not import optional engines. Capture performs actual
|
|
50
|
+
# class admission; this runtime alias is not an admission validator.
|
|
51
|
+
DiffInput = TypeAliasType("DiffInput", ComparisonInput | object)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _DiffReceipt(FrozenModel):
|
|
55
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
56
|
+
comparison_id: str
|
|
57
|
+
spec_sha256: Sha256
|
|
58
|
+
declaration: DiffContract
|
|
59
|
+
capture_options: DiffCaptureOptions
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _input(
|
|
63
|
+
value: DiffInput, options: DiffCaptureOptions, cancellation: CancellationToken | None = None
|
|
64
|
+
) -> ComparisonInput | _NativeInput:
|
|
65
|
+
if isinstance(value, (ArrowInput, ArtifactRef, TableArtifact, ExecutionResult, Path)):
|
|
66
|
+
return value
|
|
67
|
+
return _NativeInput(value, options, cancellation)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def diff(
|
|
71
|
+
candidate: DiffInput,
|
|
72
|
+
expected: DiffInput,
|
|
73
|
+
*,
|
|
74
|
+
contract: Contract | DiffContract | None = None,
|
|
75
|
+
keys: Sequence[str] | None = None,
|
|
76
|
+
policy: ComparisonPolicy | None = None,
|
|
77
|
+
reference: Literal["peer", "expected"] | None = None,
|
|
78
|
+
options: ComparisonOptions | None = None,
|
|
79
|
+
capture_options: DiffCaptureOptions | None = None,
|
|
80
|
+
work_dir: Path | None = None,
|
|
81
|
+
cancellation: CancellationToken | None = None,
|
|
82
|
+
) -> ComparisonResult:
|
|
83
|
+
"""Compare native tables, using the right-hand expected schema by default.
|
|
84
|
+
|
|
85
|
+
Passing a real DuckDBPyRelation authorizes consuming its caller-owned native
|
|
86
|
+
plan once per input. Capture is bounded by rows and Arrow batch bytes, with
|
|
87
|
+
cooperative cancellation between batches. SQL strings/connections and
|
|
88
|
+
Polars LazyFrame objects are not executed through this facade.
|
|
89
|
+
|
|
90
|
+
With no contract, infer types only from the right-hand captured table; keys
|
|
91
|
+
are always explicit. Read all resulting policies and their origins through
|
|
92
|
+
:func:`read_diff_contract`. Explicit contracts also accept the existing
|
|
93
|
+
materialized comparison inputs. DiffContract forbids overrides: construct a
|
|
94
|
+
new declaration to change its policies. Native row/byte capture bounds are
|
|
95
|
+
separate from cooperative comparison bounds; neither is a hard RSS quota.
|
|
96
|
+
|
|
97
|
+
Raises ValueError/Pydantic ValidationError/InvalidExecutionInput for invalid configuration;
|
|
98
|
+
CapabilityError for unsupported or ambiguous reference inference;
|
|
99
|
+
MissingDependencyError, ResourceLimitError or ExecutionCancelled during
|
|
100
|
+
pre-contract capture; and OSError if evidence cannot be published. These
|
|
101
|
+
failures do not guarantee a persisted final report. Configured comparisons
|
|
102
|
+
retain the core's equivalent/different/not_evaluable states and diagnostics.
|
|
103
|
+
"""
|
|
104
|
+
capture = DiffCaptureOptions.model_validate(
|
|
105
|
+
DiffCaptureOptions() if capture_options is None else capture_options
|
|
106
|
+
)
|
|
107
|
+
checked_options = ComparisonOptions.model_validate(
|
|
108
|
+
ComparisonOptions() if options is None else options
|
|
109
|
+
)
|
|
110
|
+
if reference not in (None, "peer", "expected"):
|
|
111
|
+
raise ValueError("reference must be peer or expected")
|
|
112
|
+
if contract is not None and keys is not None:
|
|
113
|
+
raise ValueError("keys cannot override an explicit contract")
|
|
114
|
+
right: ComparisonInput | _NativeInput | _CapturedInput
|
|
115
|
+
if isinstance(contract, DiffContract):
|
|
116
|
+
if policy is not None or reference is not None:
|
|
117
|
+
raise ValueError("policy/reference cannot override a DiffContract")
|
|
118
|
+
declaration = DiffContract.model_validate(contract)
|
|
119
|
+
right = _input(expected, capture, cancellation)
|
|
120
|
+
elif contract is not None:
|
|
121
|
+
if reference is None:
|
|
122
|
+
declaration = DiffContract.from_contract(contract, policy=policy)
|
|
123
|
+
else:
|
|
124
|
+
declaration = DiffContract.from_contract(contract, policy=policy, reference=reference)
|
|
125
|
+
right = _input(expected, capture, cancellation)
|
|
126
|
+
else:
|
|
127
|
+
if isinstance(expected, (ArtifactRef, TableArtifact, ExecutionResult, Path)):
|
|
128
|
+
raise CapabilityError(
|
|
129
|
+
"schema inference requires an eager table or an explicit DuckDB relation; "
|
|
130
|
+
"supply an explicit contract for materialized artifact/execution inputs"
|
|
131
|
+
)
|
|
132
|
+
if cancellation is not None and cancellation.cancelled:
|
|
133
|
+
raise ExecutionCancelled("cancelled before reference capture and inference")
|
|
134
|
+
observed = expected.table if isinstance(expected, ArrowInput) else expected
|
|
135
|
+
loaded = capture_native(observed, capture, cancellation=cancellation)
|
|
136
|
+
if reference is None:
|
|
137
|
+
declaration = infer_diff_contract(loaded.table.schema, keys=keys, policy=policy)
|
|
138
|
+
else:
|
|
139
|
+
declaration = infer_diff_contract(
|
|
140
|
+
loaded.table.schema, keys=keys, policy=policy, reference=reference
|
|
141
|
+
)
|
|
142
|
+
right = _CapturedInput(loaded)
|
|
143
|
+
result = _compare(
|
|
144
|
+
_input(candidate, capture, cancellation),
|
|
145
|
+
right,
|
|
146
|
+
contract=declaration.spec.contract,
|
|
147
|
+
policy=declaration.spec.policy,
|
|
148
|
+
reference=declaration.reference,
|
|
149
|
+
options=checked_options,
|
|
150
|
+
work_dir=work_dir,
|
|
151
|
+
cancellation=cancellation,
|
|
152
|
+
)
|
|
153
|
+
receipt = _DiffReceipt(
|
|
154
|
+
comparison_id=result.comparison_id,
|
|
155
|
+
spec_sha256=result.spec_sha256,
|
|
156
|
+
declaration=declaration,
|
|
157
|
+
capture_options=capture,
|
|
158
|
+
)
|
|
159
|
+
directory = local_path(result.differences).parent
|
|
160
|
+
temporary = directory / "diff-contract.json.part"
|
|
161
|
+
try:
|
|
162
|
+
with temporary.open("xb") as target:
|
|
163
|
+
target.write(receipt.model_dump_json(indent=2).encode("utf-8"))
|
|
164
|
+
target.flush()
|
|
165
|
+
os.fsync(target.fileno())
|
|
166
|
+
os.link(temporary, directory / "diff-contract.json")
|
|
167
|
+
finally:
|
|
168
|
+
temporary.unlink(missing_ok=True)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def read_diff_contract(result: ComparisonResult) -> DiffContract:
|
|
173
|
+
"""Read a diff's companion declaration, bound to its result and policies.
|
|
174
|
+
|
|
175
|
+
This supplementary provenance is not a signature or producer attestation.
|
|
176
|
+
Comparisons made directly with compare have no such companion. Reading
|
|
177
|
+
does not execute the source or query, or verify all comparison evidence.
|
|
178
|
+
"""
|
|
179
|
+
result = ComparisonResult.model_validate(result)
|
|
180
|
+
path = local_path(result.differences).parent / "diff-contract.json"
|
|
181
|
+
raw = read_document(local_input_path(path))
|
|
182
|
+
receipt = _DiffReceipt.model_validate_json(raw)
|
|
183
|
+
declaration = receipt.declaration
|
|
184
|
+
if (
|
|
185
|
+
receipt.comparison_id != result.comparison_id
|
|
186
|
+
or receipt.spec_sha256 != result.spec_sha256
|
|
187
|
+
or comparison_fingerprint(declaration.spec) != result.spec_sha256
|
|
188
|
+
or declaration.spec != result.spec
|
|
189
|
+
or declaration.reference != result.reference
|
|
190
|
+
):
|
|
191
|
+
raise ArtifactIntegrityError("diff declaration does not match the comparison")
|
|
192
|
+
return declaration
|