equiframe-benchmark 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_benchmark/PROTOCOL_V2.md +172 -0
- equiframe_benchmark/__init__.py +284 -0
- equiframe_benchmark/__main__.py +3 -0
- equiframe_benchmark/_scale_workload.py +375 -0
- equiframe_benchmark/calibration_types.py +112 -0
- equiframe_benchmark/cli.py +196 -0
- equiframe_benchmark/commands.py +149 -0
- equiframe_benchmark/commands_scale.py +38 -0
- equiframe_benchmark/commands_step10.py +275 -0
- equiframe_benchmark/commands_v2.py +206 -0
- equiframe_benchmark/cost_table.py +62 -0
- equiframe_benchmark/costs.py +788 -0
- equiframe_benchmark/distribution.py +932 -0
- equiframe_benchmark/evaluation.py +614 -0
- equiframe_benchmark/evaluation_v2.py +428 -0
- equiframe_benchmark/fixtures.py +299 -0
- equiframe_benchmark/freeze.py +869 -0
- equiframe_benchmark/freeze_v2.py +412 -0
- equiframe_benchmark/integration.py +655 -0
- equiframe_benchmark/io.py +29 -0
- equiframe_benchmark/models.py +313 -0
- equiframe_benchmark/models_v2.py +13 -0
- equiframe_benchmark/mutations.py +34 -0
- equiframe_benchmark/oracle.py +264 -0
- equiframe_benchmark/pairwise.py +248 -0
- equiframe_benchmark/pilot.py +254 -0
- equiframe_benchmark/py.typed +0 -0
- equiframe_benchmark/reconciliation.py +294 -0
- equiframe_benchmark/reproduction.py +542 -0
- equiframe_benchmark/scale_io.py +19 -0
- equiframe_benchmark/scale_process.py +1077 -0
- equiframe_benchmark/scale_types.py +120 -0
- equiframe_benchmark/scaling.py +529 -0
- equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
- equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
- equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
- equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
- equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
- equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
- equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
- equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
- equiframe_benchmark/selection.py +178 -0
- equiframe_benchmark/snapshot.py +24 -0
- equiframe_benchmark/splits.py +92 -0
- equiframe_benchmark/study.py +526 -0
- equiframe_benchmark/study_bundle.py +379 -0
- equiframe_benchmark/study_bundle_v2.py +388 -0
- equiframe_benchmark/study_v2.py +544 -0
- equiframe_benchmark/tables.py +347 -0
- equiframe_benchmark/tables_v2.py +340 -0
- equiframe_benchmark/training_types.py +52 -0
- equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
- equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
- equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
- equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +8 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""Paired operational costs on fixed captures, without ETL, labels or fitting.
|
|
2
|
+
|
|
3
|
+
Trusted actions include their internal artifact verification, rereads and evidence
|
|
4
|
+
publication. Initial DW reading, transformation and capture are outside this API.
|
|
5
|
+
RSS is an absolute sampled process maximum (a lower bound), never an allocation
|
|
6
|
+
delta or a hard quota. CPU includes the sampler thread and excludes subprocesses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import platform
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from datetime import timezone as _timezone
|
|
18
|
+
from importlib import import_module
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
from itertools import pairwise
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from threading import Event, Thread
|
|
23
|
+
from typing import Annotated, Literal, Protocol, cast
|
|
24
|
+
from uuid import uuid4
|
|
25
|
+
|
|
26
|
+
import equiframe as ef
|
|
27
|
+
from equiframe._base import strict_int_literal
|
|
28
|
+
from equiframe._regular_io import open_regular_file
|
|
29
|
+
from equiframe.evidence import inventory, verify_files
|
|
30
|
+
from equiframe.local import local_path
|
|
31
|
+
from equiframe.profile_types import profile_digest
|
|
32
|
+
from pydantic import BeforeValidator, Field, model_validator
|
|
33
|
+
from typing_extensions import Self
|
|
34
|
+
|
|
35
|
+
from equiframe_benchmark.io import load_document, save_document
|
|
36
|
+
from equiframe_benchmark.models import Digest, Document, Text, digest
|
|
37
|
+
|
|
38
|
+
UTC = _timezone.utc
|
|
39
|
+
|
|
40
|
+
Seconds = Annotated[float, Field(ge=0)]
|
|
41
|
+
Bytes = Annotated[int, Field(ge=0)]
|
|
42
|
+
Method = Literal["profile_m", "comparison"]
|
|
43
|
+
OperationState = Literal[
|
|
44
|
+
"completed", "not_evaluable", "failed", "cancelled", "timeout", "resource_exhausted"
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CostProtocol(Document):
|
|
49
|
+
warmups: Annotated[Literal[1], BeforeValidator(strict_int_literal)] = 1
|
|
50
|
+
repetitions: Annotated[Literal[5], BeforeValidator(strict_int_literal)] = 5
|
|
51
|
+
seed: Annotated[int, Field(ge=0, le=2**32 - 1)]
|
|
52
|
+
sample_interval_seconds: Annotated[float, Field(ge=0.001, le=1)] = 0.01
|
|
53
|
+
cache_policy: Text
|
|
54
|
+
threads: Annotated[int, Field(gt=0)]
|
|
55
|
+
memory_budget_bytes: Annotated[int, Field(gt=0)] | None = None
|
|
56
|
+
concurrent_loads: Text
|
|
57
|
+
controls_rationale: Text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CostSpec(Document):
|
|
61
|
+
case_id: Text
|
|
62
|
+
execution: ef.ExecutionResult
|
|
63
|
+
reference: ef.ArtifactRef | ef.TableArtifact | ef.ExecutionResult
|
|
64
|
+
profile: ef.ProfileSpec
|
|
65
|
+
detector_bundle_sha256: Digest
|
|
66
|
+
comparison_policy: ef.ComparisonPolicy
|
|
67
|
+
comparison_reference: Literal["peer", "expected"] = "expected"
|
|
68
|
+
profile_options: ef.ProfileOptions = ef.ProfileOptions()
|
|
69
|
+
comparison_options: ef.ComparisonOptions = ef.ComparisonOptions()
|
|
70
|
+
protocol: CostProtocol
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def materialized(self) -> Self:
|
|
74
|
+
if self.execution.state != "completed":
|
|
75
|
+
raise ValueError("costs require an already completed captured execution")
|
|
76
|
+
if self.profile.family != self.execution.plan.contract.family:
|
|
77
|
+
raise ValueError("cost profile family must match the captured contract")
|
|
78
|
+
if isinstance(self.reference, ef.ExecutionResult) and self.reference.state != "completed":
|
|
79
|
+
raise ValueError("reference execution must already be completed")
|
|
80
|
+
by_uri: dict[str, ef.ArtifactRef] = {}
|
|
81
|
+
for ref in self.artifacts:
|
|
82
|
+
if ref.format not in ("arrow", "parquet"):
|
|
83
|
+
raise ValueError("costs require captured Arrow or Parquet tables")
|
|
84
|
+
if ref.uri in by_uri and by_uri[ref.uri] != ref:
|
|
85
|
+
raise ValueError("one URI cannot declare different captured contents")
|
|
86
|
+
by_uri[ref.uri] = ref
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def artifacts(self) -> tuple[ef.ArtifactRef, ...]:
|
|
91
|
+
refs = list(_execution_refs(self.execution))
|
|
92
|
+
if isinstance(self.reference, ef.ExecutionResult):
|
|
93
|
+
refs.extend(_execution_refs(self.reference))
|
|
94
|
+
elif isinstance(self.reference, ef.TableArtifact):
|
|
95
|
+
refs.extend(_table_refs(self.reference))
|
|
96
|
+
else:
|
|
97
|
+
refs.append(self.reference)
|
|
98
|
+
return tuple(dict.fromkeys(refs))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ProfileMCostResult(Document):
|
|
102
|
+
profile: ef.ProfileResult
|
|
103
|
+
detection: ef.ComponentDetectionResult | None
|
|
104
|
+
|
|
105
|
+
@model_validator(mode="after")
|
|
106
|
+
def isolated(self) -> Self:
|
|
107
|
+
if self.profile.complete and self.detection is None:
|
|
108
|
+
raise ValueError("a complete profile requires its actual isolated M outcome")
|
|
109
|
+
if self.detection is not None and (
|
|
110
|
+
not self.profile.complete
|
|
111
|
+
or self.detection.method != "isolation_forest"
|
|
112
|
+
or self.detection.rows != 1
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("M cost requires a complete profile and one isolated M result")
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def complete(self) -> bool:
|
|
119
|
+
return self.profile.complete and self.detection is not None and self.detection.complete
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ResourceSample(Document):
|
|
123
|
+
"""Cumulative process CPU and instantaneous RSS returned by an explicit probe."""
|
|
124
|
+
|
|
125
|
+
cpu_seconds: Seconds
|
|
126
|
+
rss_bytes: Bytes
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ResourceProbe(Protocol):
|
|
130
|
+
def sample(self) -> ResourceSample: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ResourcePoint(Document):
|
|
134
|
+
monotonic_seconds: Seconds
|
|
135
|
+
sample: ResourceSample
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CostIssue(Document):
|
|
139
|
+
stage: Literal["operation", "measurement", "integrity"]
|
|
140
|
+
code: Text
|
|
141
|
+
exception_type: Text | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResourceMeasurement(Document):
|
|
145
|
+
scope: Literal["process_including_sampler_thread_excluding_subprocesses"] = (
|
|
146
|
+
"process_including_sampler_thread_excluding_subprocesses"
|
|
147
|
+
)
|
|
148
|
+
rss_method: Literal["sampled_absolute_lower_bound"] = "sampled_absolute_lower_bound"
|
|
149
|
+
cpu_method: Literal["process_counter_delta_over_sample_bracket"] = (
|
|
150
|
+
"process_counter_delta_over_sample_bracket"
|
|
151
|
+
)
|
|
152
|
+
state: Literal["complete", "partial", "not_available"]
|
|
153
|
+
wall_seconds: Seconds | None
|
|
154
|
+
cpu_seconds: Seconds | None
|
|
155
|
+
rss_baseline_bytes: Bytes | None
|
|
156
|
+
sampled_rss_peak_bytes: Bytes | None
|
|
157
|
+
sample_interval_seconds: Annotated[float, Field(gt=0)]
|
|
158
|
+
max_sample_gap_seconds: Seconds | None
|
|
159
|
+
samples: tuple[ResourcePoint, ...]
|
|
160
|
+
issues: tuple[CostIssue, ...]
|
|
161
|
+
|
|
162
|
+
@model_validator(mode="after")
|
|
163
|
+
def consistent(self) -> Self:
|
|
164
|
+
if self.state == "complete" and (
|
|
165
|
+
self.issues
|
|
166
|
+
or self.wall_seconds is None
|
|
167
|
+
or self.cpu_seconds is None
|
|
168
|
+
or self.rss_baseline_bytes is None
|
|
169
|
+
or self.sampled_rss_peak_bytes is None
|
|
170
|
+
or len(self.samples) < 2
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("complete resource measurement requires actual observations")
|
|
173
|
+
if self.state != "complete" and not self.issues:
|
|
174
|
+
raise ValueError("incomplete resource measurement requires a reason")
|
|
175
|
+
if self.sampled_rss_peak_bytes != (
|
|
176
|
+
max(p.sample.rss_bytes for p in self.samples) if self.samples else None
|
|
177
|
+
):
|
|
178
|
+
raise ValueError("sampled peak must preserve the actual absolute RSS maximum")
|
|
179
|
+
if self.rss_baseline_bytes is not None and (
|
|
180
|
+
not self.samples or self.rss_baseline_bytes != self.samples[0].sample.rss_bytes
|
|
181
|
+
):
|
|
182
|
+
raise ValueError("baseline must preserve the initial RSS sample")
|
|
183
|
+
times = tuple(p.monotonic_seconds for p in self.samples)
|
|
184
|
+
if tuple(sorted(times)) != times:
|
|
185
|
+
raise ValueError("resource samples must be chronological")
|
|
186
|
+
if self.cpu_seconds is not None and (
|
|
187
|
+
len(self.samples) < 2
|
|
188
|
+
or self.cpu_seconds
|
|
189
|
+
!= self.samples[-1].sample.cpu_seconds - self.samples[0].sample.cpu_seconds
|
|
190
|
+
):
|
|
191
|
+
raise ValueError("CPU duration must preserve the observed process counter delta")
|
|
192
|
+
gap = max((b - a for a, b in pairwise(times)), default=None)
|
|
193
|
+
if self.max_sample_gap_seconds != gap:
|
|
194
|
+
raise ValueError("sampling gap must preserve the actual timestamps")
|
|
195
|
+
if self.state == "complete" and gap is not None and gap > 2 * self.sample_interval_seconds:
|
|
196
|
+
raise ValueError("sampling lag must be declared as a partial measurement")
|
|
197
|
+
if self.state == "complete" and any(
|
|
198
|
+
b.sample.cpu_seconds < a.sample.cpu_seconds for a, b in pairwise(self.samples)
|
|
199
|
+
):
|
|
200
|
+
raise ValueError("decreasing CPU counters cannot represent a complete measurement")
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ArtifactVerification(Document):
|
|
205
|
+
artifact: ef.ArtifactRef
|
|
206
|
+
state: Literal["verified", "mismatch", "unavailable"]
|
|
207
|
+
observed_sha256: Digest | None
|
|
208
|
+
observed_size_bytes: Bytes | None
|
|
209
|
+
|
|
210
|
+
@model_validator(mode="after")
|
|
211
|
+
def consistent(self) -> Self:
|
|
212
|
+
observed = (self.observed_sha256, self.observed_size_bytes)
|
|
213
|
+
expected = (self.artifact.sha256, self.artifact.size_bytes)
|
|
214
|
+
if self.state == "unavailable":
|
|
215
|
+
if observed != (None, None):
|
|
216
|
+
raise ValueError("unavailable artifact cannot claim observed bytes")
|
|
217
|
+
elif None in observed or (self.state == "verified") != (observed == expected):
|
|
218
|
+
raise ValueError("artifact verification must reflect its actual digest and size")
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CostObservation(Document):
|
|
223
|
+
phase: Literal["warmup", "measurement"]
|
|
224
|
+
repetition: Annotated[int, Field(ge=0, le=5)]
|
|
225
|
+
position: Annotated[Literal[0, 1], BeforeValidator(strict_int_literal)]
|
|
226
|
+
method: Method
|
|
227
|
+
state: OperationState
|
|
228
|
+
profile_m: ProfileMCostResult | None
|
|
229
|
+
comparison: ef.ComparisonResult | None
|
|
230
|
+
resources: ResourceMeasurement
|
|
231
|
+
artifacts_before: tuple[ArtifactVerification, ...]
|
|
232
|
+
artifacts_after: tuple[ArtifactVerification, ...]
|
|
233
|
+
issues: tuple[CostIssue, ...]
|
|
234
|
+
|
|
235
|
+
@model_validator(mode="after")
|
|
236
|
+
def consistent(self) -> Self:
|
|
237
|
+
if (self.phase == "warmup") != (self.repetition == 0):
|
|
238
|
+
raise ValueError("warmup and measured repetitions must remain separate")
|
|
239
|
+
if self.method == "profile_m" and self.comparison is not None:
|
|
240
|
+
raise ValueError("M observation cannot contain a comparison")
|
|
241
|
+
if self.method == "comparison" and self.profile_m is not None:
|
|
242
|
+
raise ValueError("comparison observation cannot contain detector output")
|
|
243
|
+
returned = self.profile_m if self.method == "profile_m" else self.comparison
|
|
244
|
+
if self.state in ("completed", "not_evaluable"):
|
|
245
|
+
available = (
|
|
246
|
+
returned is not None
|
|
247
|
+
and returned.complete
|
|
248
|
+
and (
|
|
249
|
+
not isinstance(returned, ef.ComparisonResult)
|
|
250
|
+
or returned.state != "not_evaluable"
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
if returned is None or (self.state == "completed") != available:
|
|
254
|
+
raise ValueError("operation state must preserve actual component availability")
|
|
255
|
+
elif not self.issues:
|
|
256
|
+
raise ValueError("unsuccessful operation requires a diagnostic")
|
|
257
|
+
if self.state == "completed" and any(
|
|
258
|
+
item.state != "verified" for item in (*self.artifacts_before, *self.artifacts_after)
|
|
259
|
+
):
|
|
260
|
+
raise ValueError("completed cost requires unchanged captured artifacts")
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class CostComparisonResult(Document):
|
|
265
|
+
cost_id: Text
|
|
266
|
+
spec: CostSpec
|
|
267
|
+
spec_sha256: Digest
|
|
268
|
+
started_at: Text
|
|
269
|
+
finished_at: Text
|
|
270
|
+
environment: tuple[ef.VersionInfo, ...]
|
|
271
|
+
artifacts_before: tuple[ArtifactVerification, ...]
|
|
272
|
+
artifacts_after: tuple[ArtifactVerification, ...]
|
|
273
|
+
warmups: tuple[CostObservation, ...]
|
|
274
|
+
observations: tuple[CostObservation, ...]
|
|
275
|
+
evidence: tuple[ef.EvidenceFile, ...]
|
|
276
|
+
|
|
277
|
+
@model_validator(mode="after")
|
|
278
|
+
def consistent(self) -> Self:
|
|
279
|
+
if self.spec_sha256 != digest(self.spec):
|
|
280
|
+
raise ValueError("cost specification fingerprint mismatch")
|
|
281
|
+
schedule = _schedule(self.spec.protocol.seed)
|
|
282
|
+
for phase, records, pairs in (
|
|
283
|
+
("warmup", self.warmups, schedule[:1]),
|
|
284
|
+
("measurement", self.observations, schedule[1:]),
|
|
285
|
+
):
|
|
286
|
+
expected = tuple(
|
|
287
|
+
(phase, repetition, position, method)
|
|
288
|
+
for repetition, methods in pairs
|
|
289
|
+
for position, method in enumerate(methods)
|
|
290
|
+
)
|
|
291
|
+
actual = tuple((r.phase, r.repetition, r.position, r.method) for r in records)
|
|
292
|
+
if actual != expected:
|
|
293
|
+
raise ValueError("cost observations must preserve the full alternating schedule")
|
|
294
|
+
for record in records:
|
|
295
|
+
for checks in (record.artifacts_before, record.artifacts_after):
|
|
296
|
+
if tuple(c.artifact for c in checks) != self.spec.artifacts:
|
|
297
|
+
raise ValueError("observed captures must match the cost specification")
|
|
298
|
+
if record.state in ("completed", "not_evaluable"):
|
|
299
|
+
_validate_outcome(self.spec, record.profile_m, record.comparison)
|
|
300
|
+
for checks in (self.artifacts_before, self.artifacts_after):
|
|
301
|
+
if tuple(c.artifact for c in checks) != self.spec.artifacts:
|
|
302
|
+
raise ValueError("top-level captures must bind the cost specification")
|
|
303
|
+
if len({item.path for item in self.evidence}) != len(self.evidence):
|
|
304
|
+
raise ValueError("cost evidence paths must be unique")
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def complete(self) -> bool:
|
|
309
|
+
return all(c.state == "verified" for c in self.artifacts_after) and all(
|
|
310
|
+
r.state == "completed" and r.resources.state == "complete"
|
|
311
|
+
for r in (*self.warmups, *self.observations)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _table_refs(table: ef.TableArtifact) -> tuple[ef.ArtifactRef, ...]:
|
|
316
|
+
return (table.data,) + ((table.index,) if table.index else ())
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _execution_refs(execution: ef.ExecutionResult) -> tuple[ef.ArtifactRef, ...]:
|
|
320
|
+
return tuple(
|
|
321
|
+
ref
|
|
322
|
+
for table in (*execution.inputs, *((execution.output,) if execution.output else ()))
|
|
323
|
+
for ref in _table_refs(table)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _schedule(seed: int) -> tuple[tuple[int, tuple[Method, Method]], ...]:
|
|
328
|
+
first: tuple[Method, Method] = (
|
|
329
|
+
("profile_m", "comparison")
|
|
330
|
+
if random.Random(seed).randrange(2) == 0
|
|
331
|
+
else ("comparison", "profile_m")
|
|
332
|
+
)
|
|
333
|
+
return ((0, first), *((n, first if n % 2 else (first[1], first[0])) for n in range(1, 6)))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _verify(artifacts: tuple[ef.ArtifactRef, ...]) -> tuple[ArtifactVerification, ...]:
|
|
337
|
+
checks = []
|
|
338
|
+
for ref in artifacts:
|
|
339
|
+
try:
|
|
340
|
+
size = 0
|
|
341
|
+
hashed = hashlib.sha256()
|
|
342
|
+
with open_regular_file(local_path(ref)) as stream:
|
|
343
|
+
while block := stream.read(1024 * 1024):
|
|
344
|
+
size += len(block)
|
|
345
|
+
hashed.update(block)
|
|
346
|
+
observed = hashed.hexdigest()
|
|
347
|
+
checks.append(
|
|
348
|
+
ArtifactVerification(
|
|
349
|
+
artifact=ref,
|
|
350
|
+
state="verified"
|
|
351
|
+
if (observed, size) == (ref.sha256, ref.size_bytes)
|
|
352
|
+
else "mismatch",
|
|
353
|
+
observed_sha256=observed,
|
|
354
|
+
observed_size_bytes=size,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
except (OSError, ef.EquiFrameError, ValueError):
|
|
358
|
+
checks.append(
|
|
359
|
+
ArtifactVerification(
|
|
360
|
+
artifact=ref,
|
|
361
|
+
state="unavailable",
|
|
362
|
+
observed_sha256=None,
|
|
363
|
+
observed_size_bytes=None,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
return tuple(checks)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _reference_data(
|
|
370
|
+
reference: ef.ArtifactRef | ef.TableArtifact | ef.ExecutionResult,
|
|
371
|
+
) -> ef.ArtifactRef:
|
|
372
|
+
if isinstance(reference, ef.ExecutionResult):
|
|
373
|
+
if not (reference.output is not None):
|
|
374
|
+
raise AssertionError("Internal invariant check failed.")
|
|
375
|
+
return reference.output.data
|
|
376
|
+
return reference.data if isinstance(reference, ef.TableArtifact) else reference
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _validate_outcome(
|
|
380
|
+
spec: CostSpec, profile_m: ProfileMCostResult | None, comparison: ef.ComparisonResult | None
|
|
381
|
+
) -> None:
|
|
382
|
+
if profile_m is not None:
|
|
383
|
+
profile = profile_m.profile
|
|
384
|
+
if (
|
|
385
|
+
profile.vector.spec != spec.profile
|
|
386
|
+
or profile.options != spec.profile_options
|
|
387
|
+
or profile.metadata.execution_sha256
|
|
388
|
+
!= profile_digest(spec.execution.model_dump(mode="json"))
|
|
389
|
+
or profile.metadata.run_id != spec.execution.run_id
|
|
390
|
+
or profile.metadata.execution_id != spec.execution.execution_id
|
|
391
|
+
or profile.metadata.contract_sha256 != spec.execution.contract_sha256
|
|
392
|
+
or profile.metadata.artifacts != _execution_refs(spec.execution)
|
|
393
|
+
):
|
|
394
|
+
raise ValueError("profile does not belong to the declared captured execution")
|
|
395
|
+
if profile_m.detection is not None and (
|
|
396
|
+
profile_m.detection.bundle_sha256 != spec.detector_bundle_sha256
|
|
397
|
+
or profile_m.detection.profiles_sha256
|
|
398
|
+
!= profile_digest([profile.vector.model_dump(mode="json")])
|
|
399
|
+
):
|
|
400
|
+
raise ValueError("M result does not belong to the declared frozen bundle and profile")
|
|
401
|
+
if comparison is not None:
|
|
402
|
+
if (
|
|
403
|
+
comparison.spec
|
|
404
|
+
!= ef.ComparisonSpec(
|
|
405
|
+
contract=spec.execution.plan.contract, policy=spec.comparison_policy
|
|
406
|
+
)
|
|
407
|
+
or comparison.options != spec.comparison_options
|
|
408
|
+
or comparison.reference != spec.comparison_reference
|
|
409
|
+
):
|
|
410
|
+
raise ValueError("comparison does not use the declared contract, policy and options")
|
|
411
|
+
if comparison.left and (
|
|
412
|
+
comparison.left.execution != spec.execution
|
|
413
|
+
or comparison.left.data.sha256 != _reference_data(spec.execution).sha256
|
|
414
|
+
):
|
|
415
|
+
raise ValueError("comparison left side does not use the captured execution")
|
|
416
|
+
if comparison.right:
|
|
417
|
+
ref = _reference_data(spec.reference)
|
|
418
|
+
if (
|
|
419
|
+
comparison.right.data.sha256,
|
|
420
|
+
comparison.right.data.size_bytes,
|
|
421
|
+
comparison.right.data.format,
|
|
422
|
+
) != (ref.sha256, ref.size_bytes, ref.format):
|
|
423
|
+
raise ValueError("comparison reference changed its captured content")
|
|
424
|
+
if (
|
|
425
|
+
isinstance(spec.reference, ef.ExecutionResult)
|
|
426
|
+
and comparison.right.execution != spec.reference
|
|
427
|
+
):
|
|
428
|
+
raise ValueError("reference execution preconditions must remain available")
|
|
429
|
+
if (
|
|
430
|
+
isinstance(spec.reference, ef.TableArtifact)
|
|
431
|
+
and comparison.right.table != spec.reference
|
|
432
|
+
):
|
|
433
|
+
raise ValueError("reference table metadata must remain available")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class _CpuTimes(Protocol):
|
|
437
|
+
user: float
|
|
438
|
+
system: float
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class _MemoryInfo(Protocol):
|
|
442
|
+
rss: int
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class _Process(Protocol):
|
|
446
|
+
def cpu_times(self) -> _CpuTimes: ...
|
|
447
|
+
|
|
448
|
+
def memory_info(self) -> _MemoryInfo: ...
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class _PsutilProbe:
|
|
452
|
+
def __init__(self) -> None:
|
|
453
|
+
try:
|
|
454
|
+
psutil = import_module("psutil")
|
|
455
|
+
except ImportError as error:
|
|
456
|
+
raise ef.MissingDependencyError(
|
|
457
|
+
"cost resources require equiframe-benchmark[resources]"
|
|
458
|
+
) from error
|
|
459
|
+
self.process = cast(_Process, psutil.Process())
|
|
460
|
+
|
|
461
|
+
def sample(self) -> ResourceSample:
|
|
462
|
+
times = self.process.cpu_times()
|
|
463
|
+
return ResourceSample(
|
|
464
|
+
cpu_seconds=times.user + times.system, rss_bytes=self.process.memory_info().rss
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class _Monitor:
|
|
469
|
+
def __init__(self, factory: Callable[[], ResourceProbe], interval: float) -> None:
|
|
470
|
+
self.interval = interval
|
|
471
|
+
self.samples: list[ResourcePoint] = []
|
|
472
|
+
self.issues: list[CostIssue] = []
|
|
473
|
+
self.initial: ResourcePoint | None = None
|
|
474
|
+
self.final: ResourcePoint | None = None
|
|
475
|
+
self.stop_event = Event()
|
|
476
|
+
self.thread: Thread | None = None
|
|
477
|
+
try:
|
|
478
|
+
self.probe: ResourceProbe | None = factory()
|
|
479
|
+
except Exception as error:
|
|
480
|
+
self.probe = None
|
|
481
|
+
self.issues.append(
|
|
482
|
+
CostIssue(
|
|
483
|
+
stage="measurement",
|
|
484
|
+
code="resource_probe_unavailable",
|
|
485
|
+
exception_type=type(error).__name__,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def sample(self) -> ResourcePoint | None:
|
|
490
|
+
if self.probe is None:
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
observed = ResourceSample.model_validate(self.probe.sample())
|
|
494
|
+
point = ResourcePoint(monotonic_seconds=time.perf_counter(), sample=observed)
|
|
495
|
+
self.samples.append(point)
|
|
496
|
+
return point
|
|
497
|
+
except Exception as error:
|
|
498
|
+
self.issues.append(
|
|
499
|
+
CostIssue(
|
|
500
|
+
stage="measurement",
|
|
501
|
+
code="resource_sample_failed",
|
|
502
|
+
exception_type=type(error).__name__,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
def start(self) -> None:
|
|
508
|
+
self.initial = self.sample()
|
|
509
|
+
if self.probe is not None:
|
|
510
|
+
self.thread = Thread(target=self.watch, name="equiframe-cost-rss")
|
|
511
|
+
try:
|
|
512
|
+
self.thread.start()
|
|
513
|
+
except Exception as error:
|
|
514
|
+
self.thread = None
|
|
515
|
+
self.issues.append(
|
|
516
|
+
CostIssue(
|
|
517
|
+
stage="measurement",
|
|
518
|
+
code="sampling_thread_unavailable",
|
|
519
|
+
exception_type=type(error).__name__,
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def watch(self) -> None:
|
|
524
|
+
while not self.stop_event.wait(self.interval):
|
|
525
|
+
self.sample()
|
|
526
|
+
|
|
527
|
+
def finish(self, wall_seconds: float | None) -> ResourceMeasurement:
|
|
528
|
+
self.stop_event.set()
|
|
529
|
+
if self.thread is not None:
|
|
530
|
+
self.thread.join()
|
|
531
|
+
self.final = self.sample()
|
|
532
|
+
times = tuple(p.monotonic_seconds for p in self.samples)
|
|
533
|
+
gap = max((b - a for a, b in pairwise(times)), default=None)
|
|
534
|
+
cpu = None
|
|
535
|
+
if self.initial and self.final:
|
|
536
|
+
cpu = self.final.sample.cpu_seconds - self.initial.sample.cpu_seconds
|
|
537
|
+
if cpu < 0:
|
|
538
|
+
cpu = None
|
|
539
|
+
if any(b.sample.cpu_seconds < a.sample.cpu_seconds for a, b in pairwise(self.samples)):
|
|
540
|
+
self.issues.append(CostIssue(stage="measurement", code="cpu_counter_decreased"))
|
|
541
|
+
if gap is not None and gap > 2 * self.interval:
|
|
542
|
+
self.issues.append(CostIssue(stage="measurement", code="sampling_lagged"))
|
|
543
|
+
return ResourceMeasurement(
|
|
544
|
+
state="partial" if self.issues else "complete",
|
|
545
|
+
wall_seconds=wall_seconds,
|
|
546
|
+
cpu_seconds=cpu,
|
|
547
|
+
rss_baseline_bytes=self.initial.sample.rss_bytes if self.initial else None,
|
|
548
|
+
sampled_rss_peak_bytes=max(p.sample.rss_bytes for p in self.samples)
|
|
549
|
+
if self.samples
|
|
550
|
+
else None,
|
|
551
|
+
sample_interval_seconds=self.interval,
|
|
552
|
+
max_sample_gap_seconds=gap,
|
|
553
|
+
samples=tuple(self.samples),
|
|
554
|
+
issues=tuple(self.issues),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _unavailable(interval: float, code: str) -> ResourceMeasurement:
|
|
559
|
+
return ResourceMeasurement(
|
|
560
|
+
state="not_available",
|
|
561
|
+
wall_seconds=None,
|
|
562
|
+
cpu_seconds=None,
|
|
563
|
+
rss_baseline_bytes=None,
|
|
564
|
+
sampled_rss_peak_bytes=None,
|
|
565
|
+
sample_interval_seconds=interval,
|
|
566
|
+
max_sample_gap_seconds=None,
|
|
567
|
+
samples=(),
|
|
568
|
+
issues=(CostIssue(stage="measurement", code=code),),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _attempt(
|
|
573
|
+
spec: CostSpec,
|
|
574
|
+
method: Method,
|
|
575
|
+
phase: Literal["warmup", "measurement"],
|
|
576
|
+
repetition: int,
|
|
577
|
+
position: Literal[0, 1],
|
|
578
|
+
directory: Path,
|
|
579
|
+
token: ef.CancellationToken,
|
|
580
|
+
action: Callable[[Path, ef.CancellationToken], ProfileMCostResult | ef.ComparisonResult],
|
|
581
|
+
factory: Callable[[], ResourceProbe],
|
|
582
|
+
) -> CostObservation:
|
|
583
|
+
before = _verify(spec.artifacts)
|
|
584
|
+
profile_m = None
|
|
585
|
+
compared = None
|
|
586
|
+
issues: list[CostIssue] = []
|
|
587
|
+
state: OperationState
|
|
588
|
+
interval = spec.protocol.sample_interval_seconds
|
|
589
|
+
if token.cancelled or any(c.state != "verified" for c in before):
|
|
590
|
+
state = "cancelled" if token.cancelled else "failed"
|
|
591
|
+
issues.append(
|
|
592
|
+
CostIssue(
|
|
593
|
+
stage="operation" if token.cancelled else "integrity",
|
|
594
|
+
code="cancelled_before_operation"
|
|
595
|
+
if token.cancelled
|
|
596
|
+
else "captured_artifact_unavailable",
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
resources = _unavailable(interval, "operation_not_started")
|
|
600
|
+
else:
|
|
601
|
+
monitor = _Monitor(factory, interval)
|
|
602
|
+
monitor.start()
|
|
603
|
+
start = time.perf_counter()
|
|
604
|
+
try:
|
|
605
|
+
returned = action(directory, token)
|
|
606
|
+
if method == "profile_m":
|
|
607
|
+
if not isinstance(returned, ProfileMCostResult):
|
|
608
|
+
raise TypeError("profile_m callback must return ProfileMCostResult")
|
|
609
|
+
profile_m = ProfileMCostResult.model_validate(returned)
|
|
610
|
+
state = "completed" if profile_m.complete else "not_evaluable"
|
|
611
|
+
if not profile_m.complete and token.cancelled:
|
|
612
|
+
state = "cancelled"
|
|
613
|
+
issues.append(CostIssue(stage="operation", code="cancelled_during_operation"))
|
|
614
|
+
else:
|
|
615
|
+
if not isinstance(returned, ef.ComparisonResult):
|
|
616
|
+
raise TypeError("comparison callback must return ComparisonResult")
|
|
617
|
+
compared = ef.ComparisonResult.model_validate(returned)
|
|
618
|
+
state = "completed" if compared.state != "not_evaluable" else "not_evaluable"
|
|
619
|
+
if compared.state == "not_evaluable":
|
|
620
|
+
failures = {d.code for d in compared.diagnostics}
|
|
621
|
+
for code in ("cancelled", "timeout", "resource_exhausted"):
|
|
622
|
+
if code in failures:
|
|
623
|
+
state = cast(OperationState, code)
|
|
624
|
+
issues.append(CostIssue(stage="operation", code=code))
|
|
625
|
+
break
|
|
626
|
+
_validate_outcome(spec, profile_m, compared)
|
|
627
|
+
except Exception as error:
|
|
628
|
+
state = (
|
|
629
|
+
"cancelled"
|
|
630
|
+
if isinstance(error, ef.ExecutionCancelled)
|
|
631
|
+
else "timeout"
|
|
632
|
+
if isinstance(error, ef.ExecutionTimeout)
|
|
633
|
+
else "resource_exhausted"
|
|
634
|
+
if isinstance(error, ef.ResourceLimitError)
|
|
635
|
+
else "failed"
|
|
636
|
+
)
|
|
637
|
+
issues.append(
|
|
638
|
+
CostIssue(
|
|
639
|
+
stage="operation", code="operation_failed", exception_type=type(error).__name__
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
finally:
|
|
643
|
+
resources = monitor.finish(time.perf_counter() - start)
|
|
644
|
+
after = _verify(spec.artifacts)
|
|
645
|
+
if any(c.state != "verified" for c in after):
|
|
646
|
+
state = "failed"
|
|
647
|
+
issues.append(CostIssue(stage="integrity", code="captured_artifact_changed"))
|
|
648
|
+
return CostObservation(
|
|
649
|
+
phase=phase,
|
|
650
|
+
repetition=repetition,
|
|
651
|
+
position=position,
|
|
652
|
+
method=method,
|
|
653
|
+
state=state,
|
|
654
|
+
profile_m=profile_m,
|
|
655
|
+
comparison=compared,
|
|
656
|
+
resources=resources,
|
|
657
|
+
artifacts_before=before,
|
|
658
|
+
artifacts_after=after,
|
|
659
|
+
issues=tuple(issues),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def measure_costs(
|
|
664
|
+
spec: CostSpec,
|
|
665
|
+
*,
|
|
666
|
+
profile_m: Callable[[Path, ef.CancellationToken], ProfileMCostResult],
|
|
667
|
+
comparison: Callable[[Path, ef.CancellationToken], ef.ComparisonResult],
|
|
668
|
+
work_dir: Path | None = None,
|
|
669
|
+
cancellation: ef.CancellationToken | None = None,
|
|
670
|
+
probe_factory: Callable[[], ResourceProbe] | None = None,
|
|
671
|
+
) -> CostComparisonResult:
|
|
672
|
+
"""Measure one warmup pair and five alternating pairs on the same fixed bytes.
|
|
673
|
+
|
|
674
|
+
Actions are trusted synchronous code, never imported from JSON. Resource
|
|
675
|
+
collection failure does not skip an action. Cancellation is cooperative;
|
|
676
|
+
failures and partial measurements are persisted, with no fabricated zeros.
|
|
677
|
+
Thread/cache/memory controls are caller declarations, not enforced quotas.
|
|
678
|
+
"""
|
|
679
|
+
spec = CostSpec.model_validate(spec)
|
|
680
|
+
identifier = "cost_" + uuid4().hex
|
|
681
|
+
directory = (work_dir or Path.cwd() / "equiframe-costs").resolve() / identifier
|
|
682
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
683
|
+
save_document(spec, directory / "spec.json")
|
|
684
|
+
token = cancellation if cancellation is not None else ef.CancellationToken()
|
|
685
|
+
started = datetime.now(UTC).isoformat()
|
|
686
|
+
before = _verify(spec.artifacts)
|
|
687
|
+
records = []
|
|
688
|
+
actions: dict[
|
|
689
|
+
Method, Callable[[Path, ef.CancellationToken], ProfileMCostResult | ef.ComparisonResult]
|
|
690
|
+
] = {"profile_m": profile_m, "comparison": comparison}
|
|
691
|
+
for repetition, methods in _schedule(spec.protocol.seed):
|
|
692
|
+
phase: Literal["warmup", "measurement"] = "warmup" if repetition == 0 else "measurement"
|
|
693
|
+
for position, method in enumerate(methods):
|
|
694
|
+
folder = directory / f"{phase}-{repetition}-{method}"
|
|
695
|
+
folder.mkdir()
|
|
696
|
+
record = _attempt(
|
|
697
|
+
spec,
|
|
698
|
+
method,
|
|
699
|
+
phase,
|
|
700
|
+
repetition,
|
|
701
|
+
0 if position == 0 else 1,
|
|
702
|
+
folder,
|
|
703
|
+
token,
|
|
704
|
+
actions[method],
|
|
705
|
+
probe_factory or _PsutilProbe,
|
|
706
|
+
)
|
|
707
|
+
save_document(record, folder / "observation.json")
|
|
708
|
+
records.append(record)
|
|
709
|
+
environment = [
|
|
710
|
+
ef.VersionInfo(name="python", version=platform.python_version()),
|
|
711
|
+
ef.VersionInfo(name="system", version=platform.system()),
|
|
712
|
+
ef.VersionInfo(name="machine", version=platform.machine()),
|
|
713
|
+
]
|
|
714
|
+
for name in ("equiframe", "equiframe-benchmark", "pyarrow", "psutil"):
|
|
715
|
+
try:
|
|
716
|
+
installed = version(name)
|
|
717
|
+
except PackageNotFoundError:
|
|
718
|
+
installed = "not-installed"
|
|
719
|
+
environment.append(ef.VersionInfo(name=name, version=installed))
|
|
720
|
+
result = CostComparisonResult(
|
|
721
|
+
cost_id=identifier,
|
|
722
|
+
spec=spec,
|
|
723
|
+
spec_sha256=digest(spec),
|
|
724
|
+
started_at=started,
|
|
725
|
+
finished_at=datetime.now(UTC).isoformat(),
|
|
726
|
+
environment=tuple(environment),
|
|
727
|
+
artifacts_before=before,
|
|
728
|
+
artifacts_after=_verify(spec.artifacts),
|
|
729
|
+
warmups=tuple(records[:2]),
|
|
730
|
+
observations=tuple(records[2:]),
|
|
731
|
+
evidence=inventory(directory),
|
|
732
|
+
)
|
|
733
|
+
save_document(result, directory / "costs.json")
|
|
734
|
+
return result
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def read_costs(path: Path) -> CostComparisonResult:
|
|
738
|
+
"""Revalidate schedules, original outcomes, captures and every local evidence byte."""
|
|
739
|
+
path = path / "costs.json" if path.is_dir() else path
|
|
740
|
+
result = load_document(CostComparisonResult, path)
|
|
741
|
+
directory = path.parent.resolve()
|
|
742
|
+
verify_files(directory, result.evidence)
|
|
743
|
+
listed = {item.path for item in result.evidence}
|
|
744
|
+
actual = {
|
|
745
|
+
p.relative_to(directory).as_posix()
|
|
746
|
+
for p in directory.rglob("*")
|
|
747
|
+
if p.is_file() and p != path.resolve()
|
|
748
|
+
}
|
|
749
|
+
if (
|
|
750
|
+
actual != listed
|
|
751
|
+
or "spec.json" not in listed
|
|
752
|
+
or load_document(CostSpec, directory / "spec.json") != result.spec
|
|
753
|
+
):
|
|
754
|
+
raise ef.ArtifactIntegrityError("cost files contradict the final manifest")
|
|
755
|
+
for record in (*result.warmups, *result.observations):
|
|
756
|
+
relative = f"{record.phase}-{record.repetition}-{record.method}/observation.json"
|
|
757
|
+
if relative not in listed or load_document(CostObservation, directory / relative) != record:
|
|
758
|
+
raise ef.ArtifactIntegrityError("cost observation contradicts its final manifest")
|
|
759
|
+
if record.comparison is not None:
|
|
760
|
+
refs = [record.comparison.differences] + [
|
|
761
|
+
source.data
|
|
762
|
+
for source in (record.comparison.left, record.comparison.right)
|
|
763
|
+
if source
|
|
764
|
+
]
|
|
765
|
+
for ref in refs:
|
|
766
|
+
owned = local_path(ref).resolve()
|
|
767
|
+
if not owned.is_relative_to(directory):
|
|
768
|
+
raise ef.ArtifactIntegrityError("comparison evidence escapes cost operation")
|
|
769
|
+
match = next(
|
|
770
|
+
(
|
|
771
|
+
item
|
|
772
|
+
for item in result.evidence
|
|
773
|
+
if item.path == owned.relative_to(directory).as_posix()
|
|
774
|
+
),
|
|
775
|
+
None,
|
|
776
|
+
)
|
|
777
|
+
if match is None or (match.sha256, match.size_bytes) != (
|
|
778
|
+
ref.sha256,
|
|
779
|
+
ref.size_bytes,
|
|
780
|
+
):
|
|
781
|
+
raise ef.ArtifactIntegrityError(
|
|
782
|
+
"comparison artifact contradicts cost inventory"
|
|
783
|
+
)
|
|
784
|
+
if all(c.state == "verified" for c in result.artifacts_after) and any(
|
|
785
|
+
c.state != "verified" for c in _verify(result.spec.artifacts)
|
|
786
|
+
):
|
|
787
|
+
raise ef.ArtifactIntegrityError("original captured artifacts no longer match cost evidence")
|
|
788
|
+
return result
|