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
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