equiframe 0.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,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
+ }