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.
Files changed (62) hide show
  1. equiframe_benchmark/PROTOCOL_V2.md +172 -0
  2. equiframe_benchmark/__init__.py +284 -0
  3. equiframe_benchmark/__main__.py +3 -0
  4. equiframe_benchmark/_scale_workload.py +375 -0
  5. equiframe_benchmark/calibration_types.py +112 -0
  6. equiframe_benchmark/cli.py +196 -0
  7. equiframe_benchmark/commands.py +149 -0
  8. equiframe_benchmark/commands_scale.py +38 -0
  9. equiframe_benchmark/commands_step10.py +275 -0
  10. equiframe_benchmark/commands_v2.py +206 -0
  11. equiframe_benchmark/cost_table.py +62 -0
  12. equiframe_benchmark/costs.py +788 -0
  13. equiframe_benchmark/distribution.py +932 -0
  14. equiframe_benchmark/evaluation.py +614 -0
  15. equiframe_benchmark/evaluation_v2.py +428 -0
  16. equiframe_benchmark/fixtures.py +299 -0
  17. equiframe_benchmark/freeze.py +869 -0
  18. equiframe_benchmark/freeze_v2.py +412 -0
  19. equiframe_benchmark/integration.py +655 -0
  20. equiframe_benchmark/io.py +29 -0
  21. equiframe_benchmark/models.py +313 -0
  22. equiframe_benchmark/models_v2.py +13 -0
  23. equiframe_benchmark/mutations.py +34 -0
  24. equiframe_benchmark/oracle.py +264 -0
  25. equiframe_benchmark/pairwise.py +248 -0
  26. equiframe_benchmark/pilot.py +254 -0
  27. equiframe_benchmark/py.typed +0 -0
  28. equiframe_benchmark/reconciliation.py +294 -0
  29. equiframe_benchmark/reproduction.py +542 -0
  30. equiframe_benchmark/scale_io.py +19 -0
  31. equiframe_benchmark/scale_process.py +1077 -0
  32. equiframe_benchmark/scale_types.py +120 -0
  33. equiframe_benchmark/scaling.py +529 -0
  34. equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
  35. equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
  36. equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
  37. equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
  38. equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
  39. equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
  40. equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
  41. equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
  42. equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
  43. equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
  44. equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
  45. equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
  46. equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
  47. equiframe_benchmark/selection.py +178 -0
  48. equiframe_benchmark/snapshot.py +24 -0
  49. equiframe_benchmark/splits.py +92 -0
  50. equiframe_benchmark/study.py +526 -0
  51. equiframe_benchmark/study_bundle.py +379 -0
  52. equiframe_benchmark/study_bundle_v2.py +388 -0
  53. equiframe_benchmark/study_v2.py +544 -0
  54. equiframe_benchmark/tables.py +347 -0
  55. equiframe_benchmark/tables_v2.py +340 -0
  56. equiframe_benchmark/training_types.py +52 -0
  57. equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
  58. equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
  59. equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
  60. equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
  61. equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
  62. 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