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/eda/models.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Version 1.0 diagnostics; independent of migration and verification formats."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from equiframe._base import FrozenModel, Identifier, NonNegativeInt, Text, _ExplicitOutcome
|
|
9
|
+
from equiframe.types import Sha256
|
|
10
|
+
|
|
11
|
+
Name = Annotated[str, Field(min_length=1)]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Source(FrozenModel):
|
|
15
|
+
path: Text
|
|
16
|
+
format: Literal["parquet", "csv", "arrow"] = "parquet"
|
|
17
|
+
snapshot_id: Text
|
|
18
|
+
expected_sha256: Sha256 | None = None
|
|
19
|
+
expected_bytes: NonNegativeInt | None = None
|
|
20
|
+
manifest_path: Text | None = None
|
|
21
|
+
manifest_sha256: Sha256 | None = None
|
|
22
|
+
delimiter: Annotated[str, Field(min_length=1, max_length=1)] = ","
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def manifest_pair(self) -> Self:
|
|
26
|
+
if (self.manifest_path is None) != (self.manifest_sha256 is None):
|
|
27
|
+
raise ValueError("manifest path and externally trusted SHA-256 required together")
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Resources(FrozenModel):
|
|
32
|
+
memory_mb: Annotated[int, Field(ge=16, le=65536)] = 512
|
|
33
|
+
threads: Annotated[int, Field(ge=1, le=64)] = 1
|
|
34
|
+
timeout_seconds: Annotated[float, Field(gt=0)] = 300.0
|
|
35
|
+
temp_disk_mb: Annotated[int, Field(ge=1)] = 256
|
|
36
|
+
max_input_bytes: Annotated[int, Field(gt=0)] = 512 * 1024 * 1024
|
|
37
|
+
max_rows: Annotated[int, Field(ge=0)] = 10_000_000
|
|
38
|
+
max_columns: Annotated[int, Field(ge=1, le=1000)] = 256
|
|
39
|
+
max_groups: Annotated[int, Field(ge=1, le=1000)] = 100
|
|
40
|
+
examples: Annotated[int, Field(ge=0, le=100)] = 10
|
|
41
|
+
top_categories: Annotated[int, Field(ge=1, le=100)] = 10
|
|
42
|
+
histogram_bins: Annotated[int, Field(ge=1, le=100)] = 20
|
|
43
|
+
chart_rows: Annotated[int, Field(ge=0, le=1000)] = 200
|
|
44
|
+
max_report_bytes: Annotated[int, Field(ge=1)] = 32 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Conversion(FrozenModel):
|
|
48
|
+
column: Name
|
|
49
|
+
target: Identifier
|
|
50
|
+
kind: Literal["decimal", "integer", "date", "timestamp"]
|
|
51
|
+
empty: Literal["invalid", "missing"] = "invalid"
|
|
52
|
+
precision: Annotated[int, Field(ge=1, le=38)] = 38
|
|
53
|
+
scale: Annotated[int, Field(ge=0, le=18)] = 6
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def scale_fits(self) -> Self:
|
|
57
|
+
if self.scale > self.precision:
|
|
58
|
+
raise ValueError("scale exceeds precision")
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class KeyRule(FrozenModel):
|
|
63
|
+
columns: tuple[Name, ...]
|
|
64
|
+
incomplete: Literal["exclude", "include", "reject"] = "exclude"
|
|
65
|
+
empty_is_missing: bool = True
|
|
66
|
+
require_unique: bool = False
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def columns_unique(self) -> Self:
|
|
70
|
+
if not self.columns or len(set(self.columns)) != len(self.columns):
|
|
71
|
+
raise ValueError("key requires distinct nonempty columns")
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NumericRule(FrozenModel):
|
|
76
|
+
column: Name
|
|
77
|
+
groups: tuple[Name, ...] = ()
|
|
78
|
+
monetary: bool = False
|
|
79
|
+
currency_column: Name | None = None
|
|
80
|
+
unit: Text | None = None
|
|
81
|
+
outliers: Literal["iqr", "mad", "none"] = "iqr"
|
|
82
|
+
multiplier: Annotated[float, Field(gt=0)] = 1.5
|
|
83
|
+
min_support: Annotated[int, Field(ge=2)] = 5
|
|
84
|
+
|
|
85
|
+
@model_validator(mode="after")
|
|
86
|
+
def monetary_groups(self) -> Self:
|
|
87
|
+
if len(set(self.groups)) != len(self.groups):
|
|
88
|
+
raise ValueError("group columns must be distinct")
|
|
89
|
+
if self.monetary and (
|
|
90
|
+
self.currency_column is None or self.currency_column not in self.groups
|
|
91
|
+
):
|
|
92
|
+
raise ValueError("money requires explicit currency grouping")
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class EdaConfig(FrozenModel):
|
|
97
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
98
|
+
rules_id: Identifier = "generic"
|
|
99
|
+
rules_revision: Annotated[int, Field(ge=1)] = 1
|
|
100
|
+
identifiers: tuple[Name, ...] = ()
|
|
101
|
+
conversions: tuple[Conversion, ...] = ()
|
|
102
|
+
keys: tuple[KeyRule, ...] = ()
|
|
103
|
+
numeric: tuple[NumericRule, ...] = ()
|
|
104
|
+
percentiles: tuple[Annotated[float, Field(ge=0, le=1)], ...] = (0.25, 0.5, 0.75)
|
|
105
|
+
stddev: Literal["population", "sample"] = "population"
|
|
106
|
+
charts: bool = False
|
|
107
|
+
resources: Resources = Field(default_factory=Resources)
|
|
108
|
+
|
|
109
|
+
@model_validator(mode="after")
|
|
110
|
+
def conversions_unique(self) -> Self:
|
|
111
|
+
if len({c.column for c in self.conversions}) != len(self.conversions):
|
|
112
|
+
raise ValueError("one explicit conversion per source column")
|
|
113
|
+
if len({c.target.casefold() for c in self.conversions}) != len(self.conversions):
|
|
114
|
+
raise ValueError("derived column names must be distinct")
|
|
115
|
+
if {i.casefold() for i in self.identifiers} & {
|
|
116
|
+
c.column.casefold() for c in self.conversions
|
|
117
|
+
}:
|
|
118
|
+
raise ValueError("identifiers retain their original representation")
|
|
119
|
+
if len(set(self.percentiles)) != len(self.percentiles):
|
|
120
|
+
raise ValueError("percentiles must be distinct")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ColumnProfile(FrozenModel):
|
|
125
|
+
name: Name
|
|
126
|
+
physical_type: Text
|
|
127
|
+
rows: NonNegativeInt
|
|
128
|
+
nulls: NonNegativeInt
|
|
129
|
+
nan: NonNegativeInt
|
|
130
|
+
empty: NonNegativeInt
|
|
131
|
+
zero: NonNegativeInt
|
|
132
|
+
nonfinite: NonNegativeInt
|
|
133
|
+
valid: NonNegativeInt
|
|
134
|
+
distinct_present: NonNegativeInt
|
|
135
|
+
null_rate: float | None
|
|
136
|
+
empty_rate: float | None
|
|
137
|
+
nan_rate: float | None
|
|
138
|
+
frequencies: tuple[dict[str, object], ...]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DuplicateProfile(FrozenModel):
|
|
142
|
+
columns: tuple[Name, ...]
|
|
143
|
+
denominator: NonNegativeInt
|
|
144
|
+
incomplete_rows: NonNegativeInt = 0
|
|
145
|
+
policy: Text
|
|
146
|
+
repeated_groups: NonNegativeInt
|
|
147
|
+
involved_rows: NonNegativeInt
|
|
148
|
+
excess_rows: NonNegativeInt
|
|
149
|
+
excess_rate: float | None
|
|
150
|
+
examples: tuple[dict[str, object], ...]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Finding(FrozenModel):
|
|
154
|
+
code: Identifier
|
|
155
|
+
severity: Literal["observation", "rule_violation", "not_evaluable"]
|
|
156
|
+
column: str | None = None
|
|
157
|
+
count: NonNegativeInt | None = None
|
|
158
|
+
denominator: NonNegativeInt | None = None
|
|
159
|
+
reason: Text
|
|
160
|
+
examples: tuple[dict[str, object], ...] = ()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class EdaResult(_ExplicitOutcome, FrozenModel):
|
|
164
|
+
_truth_value_guidance = "inspect .state; EDA never establishes equivalence or publication"
|
|
165
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
166
|
+
run_id: Identifier
|
|
167
|
+
producer_version: Text
|
|
168
|
+
snapshot_id: Text
|
|
169
|
+
state: Literal["completed", "not_evaluable", "failed", "cancelled"]
|
|
170
|
+
reason: Text | None
|
|
171
|
+
elapsed_seconds: Annotated[float, Field(ge=0)]
|
|
172
|
+
calculation_elapsed_seconds: Annotated[float, Field(ge=0)] = 0.0
|
|
173
|
+
chart_state: Literal["completed", "not_requested", "not_evaluable", "failed"] = "not_requested"
|
|
174
|
+
chart_reason: str | None = None
|
|
175
|
+
source: Source
|
|
176
|
+
source_sha256: Sha256 | None
|
|
177
|
+
source_unchanged: bool | None
|
|
178
|
+
snapshot_complete: bool = False
|
|
179
|
+
config_sha256: Sha256
|
|
180
|
+
rows: NonNegativeInt | None
|
|
181
|
+
columns: tuple[ColumnProfile, ...] = ()
|
|
182
|
+
duplicates: tuple[DuplicateProfile, ...] = ()
|
|
183
|
+
conversions: tuple[dict[str, object], ...] = ()
|
|
184
|
+
numeric: tuple[dict[str, object], ...] = ()
|
|
185
|
+
findings: tuple[Finding, ...] = ()
|
|
186
|
+
environment: dict[str, str]
|
|
187
|
+
limits: tuple[Text, ...]
|
|
188
|
+
evidence_links: tuple[dict[str, str], ...] = ()
|
|
189
|
+
|
|
190
|
+
@model_validator(mode="after")
|
|
191
|
+
def completed_capture(self) -> Self:
|
|
192
|
+
if self.state == "completed" and (
|
|
193
|
+
not self.snapshot_complete
|
|
194
|
+
or self.rows is None
|
|
195
|
+
or self.source_sha256 is None
|
|
196
|
+
or self.source_unchanged is not True
|
|
197
|
+
):
|
|
198
|
+
raise ValueError("completed diagnostics require full verified unchanged capture")
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ResultFile(FrozenModel):
|
|
203
|
+
path: Name
|
|
204
|
+
bytes: NonNegativeInt
|
|
205
|
+
sha256: Sha256
|
|
206
|
+
|
|
207
|
+
@model_validator(mode="after")
|
|
208
|
+
def relative_member(self) -> Self:
|
|
209
|
+
if (
|
|
210
|
+
self.path.startswith("/")
|
|
211
|
+
or "\\" in self.path
|
|
212
|
+
or ":" in self.path
|
|
213
|
+
or any(p in ("", ".", "..") for p in self.path.split("/"))
|
|
214
|
+
):
|
|
215
|
+
raise ValueError("result member must be a normalized relative path")
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class EdaManifest(FrozenModel):
|
|
220
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
221
|
+
kind: Literal["equiframe.eda"] = "equiframe.eda"
|
|
222
|
+
run_id: Identifier
|
|
223
|
+
state: Literal["completed", "not_evaluable", "failed", "cancelled"]
|
|
224
|
+
files: tuple[ResultFile, ...]
|
|
225
|
+
|
|
226
|
+
@model_validator(mode="after")
|
|
227
|
+
def closed_names(self) -> Self:
|
|
228
|
+
names = [f.path for f in self.files]
|
|
229
|
+
if len(set(names)) != len(names) or "manifest.json" in names:
|
|
230
|
+
raise ValueError("manifest must list unique members and exclude itself")
|
|
231
|
+
if not {
|
|
232
|
+
"summary.json",
|
|
233
|
+
"source.json",
|
|
234
|
+
"config.json",
|
|
235
|
+
"findings.json",
|
|
236
|
+
"report.html",
|
|
237
|
+
} <= set(names):
|
|
238
|
+
raise ValueError("EDA manifest lacks required diagnostic documents")
|
|
239
|
+
return self
|
equiframe/errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Stable API error codes and printable JSON paths; issues omit rejected inputs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ValidationIssue:
|
|
9
|
+
code: str
|
|
10
|
+
location: tuple[str | int, ...]
|
|
11
|
+
message: str
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def path(self) -> str:
|
|
15
|
+
path = "$"
|
|
16
|
+
for part in self.location:
|
|
17
|
+
if isinstance(part, int):
|
|
18
|
+
path += f"[{part}]"
|
|
19
|
+
elif part.isidentifier():
|
|
20
|
+
path += "." + part
|
|
21
|
+
else:
|
|
22
|
+
path += "[" + json.dumps(part, ensure_ascii=True) + "]"
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EquiFrameError(Exception):
|
|
27
|
+
code = "equiframe_error"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ContractValidationError(EquiFrameError):
|
|
31
|
+
code = "invalid_contract"
|
|
32
|
+
|
|
33
|
+
def __init__(self, issues: tuple[ValidationIssue, ...]) -> None:
|
|
34
|
+
self.issues = issues
|
|
35
|
+
super().__init__("\n".join(f"{item.path}: {item.message} [{item.code}]" for item in issues))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SchemaVersionError(ContractValidationError):
|
|
39
|
+
code = "unsupported_schema_version"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContractParseError(ContractValidationError):
|
|
43
|
+
code = "invalid_json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContractIOError(EquiFrameError):
|
|
47
|
+
code = "contract_io_error"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvalidExecutionInput(EquiFrameError):
|
|
51
|
+
code = "invalid_execution_input"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CapabilityError(InvalidExecutionInput):
|
|
55
|
+
code = "unsupported_capability"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MissingDependencyError(CapabilityError):
|
|
59
|
+
code = "missing_optional_dependency"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ArtifactIntegrityError(InvalidExecutionInput):
|
|
63
|
+
code = "artifact_integrity_error"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ArtifactIOError(EquiFrameError):
|
|
67
|
+
code = "artifact_io_error"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ExecutionCancelled(EquiFrameError):
|
|
71
|
+
code = "execution_cancelled"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ExecutionTimeout(EquiFrameError):
|
|
75
|
+
code = "execution_timeout"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ResourceLimitError(EquiFrameError):
|
|
79
|
+
code = "resource_limit"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NativeEngineError(EquiFrameError):
|
|
83
|
+
code = "native_engine_error"
|
equiframe/evidence.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Exclusive evidence publication, chained events and verification without executing code."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from datetime import timezone as _timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, TypeVar
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from equiframe._base import FrozenModel, Identifier, NonNegativeInt, Text
|
|
14
|
+
from equiframe._document_io import write_document as _write_document
|
|
15
|
+
from equiframe._regular_io import absolute_path, open_regular_file, regular_directory, regular_files
|
|
16
|
+
from equiframe.errors import ArtifactIntegrityError, ArtifactIOError, ResourceLimitError
|
|
17
|
+
from equiframe.local import local_path
|
|
18
|
+
from equiframe.pipeline_types import EvidenceFile, PipelineResult
|
|
19
|
+
from equiframe.types import ArtifactRef, Sha256
|
|
20
|
+
|
|
21
|
+
UTC = _timezone.utc
|
|
22
|
+
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
|
23
|
+
|
|
24
|
+
MAX_DOCUMENT_BYTES = 64 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _EnvironmentManifest(FrozenModel):
|
|
28
|
+
"""The persisted pipeline environment is versioned evidence, not opaque JSON.
|
|
29
|
+
|
|
30
|
+
These fields were emitted by the original 1.0 writer. Reading checks their
|
|
31
|
+
format, without requiring the reader's packages or platform to match them.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
schema_version: Literal["1.0"]
|
|
35
|
+
python: str
|
|
36
|
+
system: str
|
|
37
|
+
machine: str
|
|
38
|
+
packages: dict[str, str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_bytes(value: object) -> bytes:
|
|
42
|
+
return json.dumps(
|
|
43
|
+
value, sort_keys=True, ensure_ascii=True, allow_nan=False, separators=(",", ":")
|
|
44
|
+
).encode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
48
|
+
result: dict[str, object] = {}
|
|
49
|
+
for key, value in pairs:
|
|
50
|
+
if key in result:
|
|
51
|
+
raise ValueError("duplicate JSON member")
|
|
52
|
+
result[key] = value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _constant(value: str) -> None:
|
|
57
|
+
raise ValueError("nonstandard JSON number")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_json(data: bytes) -> object:
|
|
61
|
+
return json.loads(data.decode("utf-8"), object_pairs_hook=_pairs, parse_constant=_constant)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_json(model: type[_ModelT], path: Path) -> _ModelT:
|
|
65
|
+
with open_regular_file(path) as stream:
|
|
66
|
+
data = stream.read(MAX_DOCUMENT_BYTES + 1)
|
|
67
|
+
if len(data) > MAX_DOCUMENT_BYTES:
|
|
68
|
+
raise ResourceLimitError("JSON document exceeds the 64 MiB budget")
|
|
69
|
+
parse_json(data)
|
|
70
|
+
return model.model_validate_json(data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def publish_json(value: BaseModel | dict[str, object], path: Path) -> None:
|
|
74
|
+
if isinstance(value, BaseModel):
|
|
75
|
+
value = type(value).model_validate(value).model_dump(mode="json")
|
|
76
|
+
_write_document(path, canonical_bytes(value) + b"\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EvidenceEvent(FrozenModel):
|
|
80
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
81
|
+
sequence: NonNegativeInt
|
|
82
|
+
timestamp: Text
|
|
83
|
+
operation_id: Identifier
|
|
84
|
+
stage: Identifier
|
|
85
|
+
state: Identifier
|
|
86
|
+
code: Identifier
|
|
87
|
+
previous_sha256: Sha256
|
|
88
|
+
sha256: Sha256
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EventJournal:
|
|
92
|
+
"""One writer per unique operation; each event is flushed before work continues."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, directory: Path, operation_id: str) -> None:
|
|
95
|
+
self.path = directory / "events.jsonl"
|
|
96
|
+
self.operation_id = operation_id
|
|
97
|
+
self.count = 0
|
|
98
|
+
self.head = "0" * 64
|
|
99
|
+
with self.path.open("xb"):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def emit(self, stage: str, state: str, code: str) -> None:
|
|
103
|
+
payload = {
|
|
104
|
+
"schema_version": "1.0",
|
|
105
|
+
"sequence": self.count,
|
|
106
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
107
|
+
"operation_id": self.operation_id,
|
|
108
|
+
"stage": stage,
|
|
109
|
+
"state": state,
|
|
110
|
+
"code": code,
|
|
111
|
+
"previous_sha256": self.head,
|
|
112
|
+
}
|
|
113
|
+
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
|
|
114
|
+
event = EvidenceEvent.model_validate({**payload, "sha256": digest})
|
|
115
|
+
with self.path.open("ab") as stream:
|
|
116
|
+
stream.write(canonical_bytes(event.model_dump(mode="json")) + b"\n")
|
|
117
|
+
stream.flush()
|
|
118
|
+
os.fsync(stream.fileno())
|
|
119
|
+
self.head = digest
|
|
120
|
+
self.count += 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_events(path: Path, *, operation_id: str) -> tuple[EvidenceEvent, ...]:
|
|
124
|
+
head = "0" * 64
|
|
125
|
+
events: list[EvidenceEvent] = []
|
|
126
|
+
with open_regular_file(path) as stream:
|
|
127
|
+
total = 0
|
|
128
|
+
while raw := stream.readline(1024 * 1024 + 1):
|
|
129
|
+
total += len(raw)
|
|
130
|
+
if len(raw) > 1024 * 1024 or total > MAX_DOCUMENT_BYTES:
|
|
131
|
+
raise ResourceLimitError("event journal exceeds verification budget")
|
|
132
|
+
if not raw.endswith(b"\n"):
|
|
133
|
+
raise ArtifactIntegrityError("truncated evidence event")
|
|
134
|
+
parse_json(raw)
|
|
135
|
+
event = EvidenceEvent.model_validate_json(raw)
|
|
136
|
+
payload = event.model_dump(mode="json", exclude={"sha256"})
|
|
137
|
+
if (
|
|
138
|
+
event.sequence != len(events)
|
|
139
|
+
or event.previous_sha256 != head
|
|
140
|
+
or event.operation_id != operation_id
|
|
141
|
+
or hashlib.sha256(canonical_bytes(payload)).hexdigest() != event.sha256
|
|
142
|
+
):
|
|
143
|
+
raise ArtifactIntegrityError("event chain integrity mismatch")
|
|
144
|
+
head = event.sha256
|
|
145
|
+
events.append(event)
|
|
146
|
+
return tuple(events)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def describe_file(path: Path, directory: Path) -> EvidenceFile:
|
|
150
|
+
path = absolute_path(path)
|
|
151
|
+
root = regular_directory(directory).resolve()
|
|
152
|
+
if not path.resolve().is_relative_to(root) or path.is_symlink():
|
|
153
|
+
raise ArtifactIntegrityError("evidence file escapes its operation directory")
|
|
154
|
+
digest = hashlib.sha256()
|
|
155
|
+
size = 0
|
|
156
|
+
with open_regular_file(path) as stream:
|
|
157
|
+
while block := stream.read(1024 * 1024):
|
|
158
|
+
digest.update(block)
|
|
159
|
+
size += len(block)
|
|
160
|
+
return EvidenceFile(
|
|
161
|
+
path=path.relative_to(root).as_posix(), sha256=digest.hexdigest(), size_bytes=size
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def inventory(directory: Path) -> tuple[EvidenceFile, ...]:
|
|
166
|
+
return tuple(describe_file(path, directory) for path in regular_files(directory))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def verify_files(directory: Path, files: tuple[EvidenceFile, ...]) -> None:
|
|
170
|
+
for item in files:
|
|
171
|
+
path = directory / item.path
|
|
172
|
+
try:
|
|
173
|
+
observed = describe_file(path, directory)
|
|
174
|
+
except (OSError, ArtifactIOError) as error:
|
|
175
|
+
raise ArtifactIntegrityError("evidence file is missing or unreadable") from error
|
|
176
|
+
if observed != item:
|
|
177
|
+
raise ArtifactIntegrityError("evidence SHA-256 or size mismatch")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _verify_pipeline_artifacts(directory: Path, result: PipelineResult) -> None:
|
|
181
|
+
"""Bind captured artifact references to the verified operation inventory.
|
|
182
|
+
|
|
183
|
+
Comparison sources keep the original table/execution as provenance, which
|
|
184
|
+
may belong to another operation. Their captured ``data`` snapshots, rather
|
|
185
|
+
than those external original references, are the evidence owned here.
|
|
186
|
+
"""
|
|
187
|
+
root = directory.resolve()
|
|
188
|
+
listed = {item.path: item for item in result.evidence}
|
|
189
|
+
references: list[ArtifactRef] = []
|
|
190
|
+
if result.execution is not None:
|
|
191
|
+
for table in (
|
|
192
|
+
*result.execution.inputs,
|
|
193
|
+
*((result.execution.output,) if result.execution.output else ()),
|
|
194
|
+
):
|
|
195
|
+
references.append(table.data)
|
|
196
|
+
if table.index:
|
|
197
|
+
references.append(table.index)
|
|
198
|
+
if result.rules is not None:
|
|
199
|
+
references.extend(
|
|
200
|
+
check.findings
|
|
201
|
+
for check in (*result.rules.preconditions, *result.rules.rules)
|
|
202
|
+
if check.findings is not None
|
|
203
|
+
)
|
|
204
|
+
if result.profile is not None:
|
|
205
|
+
references.extend(result.profile.metadata.artifacts)
|
|
206
|
+
if result.comparison is not None:
|
|
207
|
+
references.append(result.comparison.differences)
|
|
208
|
+
references.extend(
|
|
209
|
+
source.data
|
|
210
|
+
for source in (result.comparison.left, result.comparison.right)
|
|
211
|
+
if source is not None
|
|
212
|
+
)
|
|
213
|
+
for reference in references:
|
|
214
|
+
try:
|
|
215
|
+
artifact = local_path(reference).resolve()
|
|
216
|
+
except Exception as error:
|
|
217
|
+
raise ArtifactIntegrityError("captured evidence requires a local artifact") from error
|
|
218
|
+
if not artifact.is_relative_to(root):
|
|
219
|
+
raise ArtifactIntegrityError("captured artifact escapes its operation directory")
|
|
220
|
+
item = listed.get(artifact.relative_to(root).as_posix())
|
|
221
|
+
if item is None:
|
|
222
|
+
raise ArtifactIntegrityError("captured artifact is missing from the evidence inventory")
|
|
223
|
+
if (reference.sha256, reference.size_bytes) != (item.sha256, item.size_bytes):
|
|
224
|
+
raise ArtifactIntegrityError("captured artifact reference contradicts its inventory")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def read_pipeline(path: Path, *, verify: bool = True) -> PipelineResult:
|
|
228
|
+
"""Read a manifest; only explicit False skips byte and journal verification."""
|
|
229
|
+
if type(verify) is not bool:
|
|
230
|
+
raise TypeError("verify must be a bool; only explicit False skips integrity checks")
|
|
231
|
+
path = path / "manifest.json" if path.is_dir() else path
|
|
232
|
+
result = load_json(PipelineResult, path)
|
|
233
|
+
if verify:
|
|
234
|
+
directory = path.parent
|
|
235
|
+
verify_files(directory, result.evidence)
|
|
236
|
+
actual = {
|
|
237
|
+
item.relative_to(directory).as_posix()
|
|
238
|
+
for item in directory.rglob("*")
|
|
239
|
+
if item.is_file() and item != path
|
|
240
|
+
}
|
|
241
|
+
if actual != {item.path for item in result.evidence}:
|
|
242
|
+
raise ArtifactIntegrityError("operation files contradict the final evidence inventory")
|
|
243
|
+
_verify_pipeline_artifacts(directory, result)
|
|
244
|
+
required = {
|
|
245
|
+
"events.jsonl",
|
|
246
|
+
"pipeline.json",
|
|
247
|
+
"environment.json",
|
|
248
|
+
"report.json",
|
|
249
|
+
"report.html",
|
|
250
|
+
}
|
|
251
|
+
if not required <= {item.path for item in result.evidence}:
|
|
252
|
+
raise ArtifactIntegrityError("final manifest is missing required evidence")
|
|
253
|
+
load_json(_EnvironmentManifest, directory / "environment.json")
|
|
254
|
+
events = verify_events(directory / "events.jsonl", operation_id=result.pipeline_id)
|
|
255
|
+
if (
|
|
256
|
+
len(events) != result.event_count
|
|
257
|
+
or not events
|
|
258
|
+
or events[-1].sha256 != result.event_head_sha256
|
|
259
|
+
or events[0].stage != "pipeline"
|
|
260
|
+
or events[0].state != "started"
|
|
261
|
+
or events[-1].stage != "pipeline"
|
|
262
|
+
or events[-1].state != "finished"
|
|
263
|
+
):
|
|
264
|
+
raise ArtifactIntegrityError("final journal does not match its manifest")
|
|
265
|
+
if load_json(type(result.spec), directory / "pipeline.json") != result.spec:
|
|
266
|
+
raise ArtifactIntegrityError("persisted pipeline configuration mismatch")
|
|
267
|
+
terminal = tuple(
|
|
268
|
+
(event.stage, event.state, event.code)
|
|
269
|
+
for event in events
|
|
270
|
+
if event.stage != "pipeline" and event.state != "started"
|
|
271
|
+
)
|
|
272
|
+
if terminal != tuple((stage.stage, stage.state, stage.code) for stage in result.stages):
|
|
273
|
+
raise ArtifactIntegrityError("journal stage outcomes contradict the final result")
|
|
274
|
+
listed = {item.path for item in result.evidence}
|
|
275
|
+
for name, value in (
|
|
276
|
+
("execution", result.execution),
|
|
277
|
+
("rules", result.rules),
|
|
278
|
+
("profile", result.profile),
|
|
279
|
+
("detection", result.detection),
|
|
280
|
+
("comparison", result.comparison),
|
|
281
|
+
("hybrid", result.hybrid),
|
|
282
|
+
):
|
|
283
|
+
if value is not None and (
|
|
284
|
+
name + ".json" not in listed
|
|
285
|
+
or load_json(type(value), directory / (name + ".json")) != value
|
|
286
|
+
):
|
|
287
|
+
raise ArtifactIntegrityError("persisted component contradicts the final result")
|
|
288
|
+
if result.detection_requested:
|
|
289
|
+
from equiframe.detector_types import DetectorBundle, detector_fingerprint
|
|
290
|
+
|
|
291
|
+
if "detector-bundle.json" not in listed:
|
|
292
|
+
raise ArtifactIntegrityError("requested detector bundle is missing")
|
|
293
|
+
bundle = load_json(DetectorBundle, directory / "detector-bundle.json")
|
|
294
|
+
if (
|
|
295
|
+
result.detection is not None
|
|
296
|
+
and result.detection.bundle_sha256 != detector_fingerprint(bundle)
|
|
297
|
+
):
|
|
298
|
+
raise ArtifactIntegrityError("detector bundle does not match the scored bundle")
|
|
299
|
+
if result.detection is not None:
|
|
300
|
+
from equiframe.preprocessing import transform_profiles
|
|
301
|
+
from equiframe.profile_types import profile_digest
|
|
302
|
+
|
|
303
|
+
if result.detection.scores.fit_sha256 != detector_fingerprint(bundle.fit):
|
|
304
|
+
raise ArtifactIntegrityError("scored fit fingerprint mismatch")
|
|
305
|
+
if result.detection.scores.matrix_sha256 is not None:
|
|
306
|
+
if not (result.profile is not None):
|
|
307
|
+
raise AssertionError("Internal invariant check failed.")
|
|
308
|
+
matrix = transform_profiles(bundle.fit.preprocessor, (result.profile.vector,))
|
|
309
|
+
if result.detection.scores.matrix_sha256 != profile_digest(
|
|
310
|
+
matrix.model_dump(mode="json")
|
|
311
|
+
):
|
|
312
|
+
raise ArtifactIntegrityError("scored matrix does not belong to the profile")
|
|
313
|
+
from equiframe._report_format_v1 import pipeline_html_v1, pipeline_summary_v1
|
|
314
|
+
|
|
315
|
+
summary = pipeline_summary_v1(result)
|
|
316
|
+
if parse_json((directory / "report.json").read_bytes()) != summary or (
|
|
317
|
+
(directory / "report.html").read_bytes() != pipeline_html_v1(summary).encode("utf8")
|
|
318
|
+
):
|
|
319
|
+
raise ArtifactIntegrityError("report contradicts the final result")
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def inspect_evidence(directory: Path) -> dict[str, object]:
|
|
324
|
+
"""An interrupted operation remains inspectable but can never claim completion."""
|
|
325
|
+
if (directory / "migration.json").exists():
|
|
326
|
+
from equiframe.migration import migration_summary, read_migration
|
|
327
|
+
|
|
328
|
+
migrated = read_migration(directory)
|
|
329
|
+
return {
|
|
330
|
+
**migration_summary(migrated),
|
|
331
|
+
"finalized": True,
|
|
332
|
+
"events": migrated.event_count,
|
|
333
|
+
"files": len(migrated.evidence),
|
|
334
|
+
}
|
|
335
|
+
if (directory / "reduction.json").exists():
|
|
336
|
+
from equiframe.reduction import read_reduction
|
|
337
|
+
|
|
338
|
+
reduced = read_reduction(directory)
|
|
339
|
+
return {
|
|
340
|
+
"reduction_id": reduced.reduction_id,
|
|
341
|
+
"finalized": True,
|
|
342
|
+
"complete": reduced.status in ("reduced", "unchanged"),
|
|
343
|
+
"status": reduced.status,
|
|
344
|
+
"evaluations": reduced.evaluations,
|
|
345
|
+
"minimality": reduced.minimality,
|
|
346
|
+
"files": len(reduced.evidence),
|
|
347
|
+
}
|
|
348
|
+
if (directory / "manifest.json").exists():
|
|
349
|
+
result = read_pipeline(directory)
|
|
350
|
+
return {
|
|
351
|
+
"pipeline_id": result.pipeline_id,
|
|
352
|
+
"finalized": True,
|
|
353
|
+
"complete": result.complete,
|
|
354
|
+
"exit_code": result.exit_code,
|
|
355
|
+
"events": result.event_count,
|
|
356
|
+
"files": len(result.evidence),
|
|
357
|
+
}
|
|
358
|
+
events = verify_events(directory / "events.jsonl", operation_id=directory.name)
|
|
359
|
+
if directory.name.startswith("migration_"):
|
|
360
|
+
return {
|
|
361
|
+
"migration_id": directory.name,
|
|
362
|
+
"finalized": False,
|
|
363
|
+
"complete": False,
|
|
364
|
+
"exit_code": 3,
|
|
365
|
+
"events": len(events),
|
|
366
|
+
"last_stage": events[-1].stage if events else None,
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
"pipeline_id": directory.name,
|
|
370
|
+
"finalized": False,
|
|
371
|
+
"complete": False,
|
|
372
|
+
"exit_code": 3,
|
|
373
|
+
"events": len(events),
|
|
374
|
+
"last_stage": events[-1].stage if events else None,
|
|
375
|
+
}
|