solverpilot 0.1__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 (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+
10
+ _ALLOWED_SPLITS = {"train", "validation", "test"}
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SplitRecord:
15
+ instance: str
16
+ split: str
17
+ group: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class SplitValidation:
22
+ ok: bool
23
+ instances: int
24
+ groups: int
25
+ split_counts: dict[str, int]
26
+ errors: tuple[str, ...]
27
+ sha256: str
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "ok": self.ok,
32
+ "instances": self.instances,
33
+ "groups": self.groups,
34
+ "split_counts": self.split_counts,
35
+ "errors": list(self.errors),
36
+ "sha256": self.sha256,
37
+ }
38
+
39
+
40
+ def load_split_records(path: str | Path) -> tuple[SplitRecord, ...]:
41
+ raw = Path(path).read_bytes()
42
+ payload = json.loads(raw)
43
+ if not isinstance(payload, dict):
44
+ raise ValueError("split artifact must be a JSON object keyed by instance")
45
+ out: list[SplitRecord] = []
46
+ for instance, entry in payload.items():
47
+ if isinstance(entry, str):
48
+ entry = {"split": entry}
49
+ if not isinstance(entry, dict):
50
+ raise ValueError(f"split entry for {instance!r} must be a string or object")
51
+ split = str(entry.get("split", "")).lower()
52
+ if split not in _ALLOWED_SPLITS:
53
+ raise ValueError(f"invalid split for {instance!r}: {split!r}")
54
+ group = entry.get("group")
55
+ out.append(SplitRecord(str(instance), split, None if group is None else str(group)))
56
+ return tuple(out)
57
+
58
+
59
+ def validate_split_records(
60
+ records: Iterable[SplitRecord],
61
+ *,
62
+ expected_instances: Iterable[str] | None = None,
63
+ artifact_bytes: bytes | None = None,
64
+ ) -> SplitValidation:
65
+ records = tuple(records)
66
+ errors: list[str] = []
67
+ names = [r.instance for r in records]
68
+ if len(names) != len(set(names)):
69
+ errors.append("duplicate instance in split artifact")
70
+ if expected_instances is not None:
71
+ expected = set(map(str, expected_instances))
72
+ observed = set(names)
73
+ missing = sorted(expected - observed)
74
+ extra = sorted(observed - expected)
75
+ if missing:
76
+ errors.append(f"missing split assignments: {missing[:10]}")
77
+ if extra:
78
+ errors.append(f"unexpected split assignments: {extra[:10]}")
79
+
80
+ group_splits: dict[str, set[str]] = {}
81
+ for r in records:
82
+ group = r.group if r.group is not None else r.instance
83
+ group_splits.setdefault(group, set()).add(r.split)
84
+ leaked = sorted(group for group, splits in group_splits.items() if len(splits) > 1)
85
+ if leaked:
86
+ errors.append(f"group leakage across splits: {leaked[:10]}")
87
+
88
+ counts = {name: sum(r.split == name for r in records) for name in sorted(_ALLOWED_SPLITS)}
89
+ if artifact_bytes is None:
90
+ canonical = json.dumps(
91
+ {r.instance: {"split": r.split, "group": r.group} for r in sorted(records, key=lambda x: x.instance)},
92
+ sort_keys=True,
93
+ separators=(",", ":"),
94
+ ).encode()
95
+ artifact_bytes = canonical
96
+ return SplitValidation(
97
+ ok=not errors,
98
+ instances=len(records),
99
+ groups=len(group_splits),
100
+ split_counts=counts,
101
+ errors=tuple(errors),
102
+ sha256=hashlib.sha256(artifact_bytes).hexdigest(),
103
+ )
104
+
105
+
106
+ def validate_split_file(path: str | Path, *, expected_instances: Iterable[str] | None = None) -> SplitValidation:
107
+ p = Path(path)
108
+ return validate_split_records(load_split_records(p), expected_instances=expected_instances, artifact_bytes=p.read_bytes())
109
+
110
+
111
+ def generate_group_split(
112
+ instance_to_group: dict[str, str | None],
113
+ *,
114
+ train_fraction: float = 0.60,
115
+ validation_fraction: float = 0.20,
116
+ test_fraction: float = 0.20,
117
+ seed: int = 0,
118
+ ) -> tuple[SplitRecord, ...]:
119
+ """Create a deterministic, group-safe train/validation/test split.
120
+
121
+ Groups are indivisible. Assignment is deterministic for a mapping/seed and does
122
+ not consume benchmark outcomes, winners, runtimes, or objectives.
123
+ """
124
+ fractions = {"train": float(train_fraction), "validation": float(validation_fraction), "test": float(test_fraction)}
125
+ if any(v < 0 or v > 1 for v in fractions.values()):
126
+ raise ValueError("split fractions must be in [0, 1]")
127
+ if abs(sum(fractions.values()) - 1.0) > 1e-12:
128
+ raise ValueError("split fractions must sum to 1")
129
+ if not instance_to_group:
130
+ raise ValueError("instance_to_group must not be empty")
131
+ groups: dict[str, list[str]] = {}
132
+ for instance, raw_group in instance_to_group.items():
133
+ instance = str(instance); group = instance if raw_group is None else str(raw_group)
134
+ groups.setdefault(group, []).append(instance)
135
+ n = len(instance_to_group); targets = {name: fractions[name] * n for name in fractions}; current = {name: 0 for name in fractions}
136
+ def group_rank(item):
137
+ group, members = item
138
+ return (-len(members), hashlib.sha256(f"{seed}\0{group}".encode()).hexdigest())
139
+ assignment: dict[str, str] = {}; split_order = ("train", "validation", "test")
140
+ for group, members in sorted(groups.items(), key=group_rank):
141
+ size=len(members); best_split=None; best_score=None
142
+ for split in split_order:
143
+ candidate=dict(current); candidate[split]+=size
144
+ score=sum(((candidate[name]-targets[name])/max(targets[name],1.0))**2 for name in split_order)
145
+ key=(score,split_order.index(split))
146
+ if best_score is None or key < best_score: best_score=key; best_split=split
147
+ if best_split is None: raise RuntimeError("failed to assign split")
148
+ assignment[group]=best_split; current[best_split]+=size
149
+ records=tuple(SplitRecord(str(instance), assignment[str(instance) if group is None else str(group)], None if group is None else str(group)) for instance,group in sorted(instance_to_group.items()))
150
+ validation=validate_split_records(records, expected_instances=instance_to_group.keys())
151
+ if not validation.ok: raise RuntimeError("generated split failed its own leakage/coverage validation: "+"; ".join(validation.errors))
152
+ return records
153
+
154
+
155
+ def save_split_records(records: Iterable[SplitRecord], path: str | Path, *, seed: int | None = None) -> SplitValidation:
156
+ records=tuple(records)
157
+ payload={r.instance: ({"split":r.split} if r.group is None else {"split":r.split,"group":r.group}) for r in sorted(records,key=lambda x:x.instance)}
158
+ p=Path(path); p.write_text(json.dumps(payload,indent=2,sort_keys=True),encoding="utf-8")
159
+ validation=validate_split_file(p)
160
+ meta={"schema_version":"solverpilot.split-meta.v1","split_sha256":validation.sha256,"seed":seed,"instances":validation.instances,"groups":validation.groups,"split_counts":validation.split_counts,"group_leakage_checked":validation.ok}
161
+ Path(str(p)+".meta.json").write_text(json.dumps(meta,indent=2,sort_keys=True),encoding="utf-8")
162
+ return validation
163
+
164
+
165
+ def instances_by_split(records: Iterable[SplitRecord]) -> dict[str, tuple[str, ...]]:
166
+ grouped={name:[] for name in sorted(_ALLOWED_SPLITS)}
167
+ for record in records: grouped[record.split].append(record.instance)
168
+ return {name:tuple(sorted(values)) for name,values in grouped.items()}
169
+
170
+
171
+ def filter_rows_by_split(rows: Iterable[dict], records: Iterable[SplitRecord], *, allowed_splits: tuple[str, ...] = ("train",)) -> list[dict]:
172
+ allowed=tuple(str(x).lower() for x in allowed_splits)
173
+ if not allowed or any(name not in _ALLOWED_SPLITS for name in allowed):
174
+ raise ValueError("allowed_splits must contain only train/validation/test")
175
+ mapping={r.instance:r.split for r in records}; out=[]
176
+ for row in rows:
177
+ instance=str(row.get("instance") or "")
178
+ if not instance: raise ValueError("benchmark row is missing instance")
179
+ if instance not in mapping: raise ValueError(f"benchmark row instance {instance!r} is absent from frozen split")
180
+ if mapping[instance] in allowed: out.append(dict(row))
181
+ return out
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import asdict
5
+ import json
6
+ import math
7
+ import random
8
+ from pathlib import Path
9
+ from statistics import median
10
+ from typing import Iterable
11
+
12
+ from solverpilot.evaluation import performance_profile, portfolio_metrics
13
+
14
+ from .integrity import (
15
+ CURRENT_BENCHMARK_ROW_SCHEMA,
16
+ canonical_sha256,
17
+ experiment_identity as compute_experiment_identity,
18
+ verify_row_integrity,
19
+ )
20
+
21
+
22
+ _SUCCESS_REFERENCE = {"objective_matches_optimum", "meets_or_beats_best_known", "infeasible_matches"}
23
+
24
+
25
+ def _independently_verified_optimal(row: dict) -> bool:
26
+ evidence = row.get("optimality_evidence")
27
+ return isinstance(evidence, dict) and evidence.get("independently_verified_optimal") is True
28
+
29
+
30
+ def _row_is_benchmark_success(row: dict) -> bool:
31
+ if row.get("state") != "solved" or row.get("validated") is not True:
32
+ return False
33
+ check = row.get("reference_check")
34
+ if check in _SUCCESS_REFERENCE:
35
+ return True
36
+ if check == "not_checkable":
37
+ # Backend-reported optimality is not an independent proof. Without a frozen
38
+ # reference objective, exact-optimization metrics require the explicit trust
39
+ # evidence produced by SolverPilot's result layer.
40
+ return row.get("public_status") == "valid_optimal" and _independently_verified_optimal(row)
41
+ return False
42
+
43
+
44
+ def _get_cost(row: dict, field: str) -> float | None:
45
+ current = row
46
+ for part in field.split("."):
47
+ if not isinstance(current, dict) or part not in current:
48
+ return None
49
+ current = current[part]
50
+ if current is None:
51
+ return None
52
+ try:
53
+ value = float(current)
54
+ except (TypeError, ValueError):
55
+ return None
56
+ return value if math.isfinite(value) and value >= 0 else None
57
+
58
+
59
+ def load_rows(paths: Iterable[str | Path]) -> list[dict]:
60
+ rows: list[dict] = []
61
+ seen_ids: set[str] = set()
62
+ for p in paths:
63
+ path = Path(p)
64
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
65
+ if not line.strip():
66
+ continue
67
+ row = json.loads(line)
68
+ verify_row_integrity(row, context=f"{path}:{line_no}")
69
+ run_id = row.get("run_id")
70
+ if not isinstance(run_id, str):
71
+ raise ValueError(f"{path}:{line_no}: missing run_id")
72
+ if run_id in seen_ids:
73
+ # Identical run IDs across merged shard/result files are ambiguous and must be rejected.
74
+ raise ValueError(f"duplicate run_id while merging: {run_id}")
75
+ seen_ids.add(run_id)
76
+ rows.append(row)
77
+ return rows
78
+
79
+
80
+ def _environment_ids(rows: list[dict]) -> set[str]:
81
+ if any(not str(r.get("environment_id") or "").strip() for r in rows):
82
+ raise ValueError("every timing row must provide a non-empty environment_id")
83
+ return {str(r["environment_id"]).strip() for r in rows}
84
+
85
+
86
+ def _require_protocol_identity(rows: list[dict]) -> str:
87
+ if any(not str(r.get("protocol_id") or "").strip() for r in rows):
88
+ raise ValueError("every timing row must provide a non-empty protocol_id")
89
+ ids = {str(r["protocol_id"]).strip() for r in rows}
90
+ if len(ids) != 1:
91
+ raise ValueError(f"cannot summarize multiple benchmark protocols together: {sorted(ids)}")
92
+ return next(iter(ids))
93
+
94
+
95
+ def _valid_sha256(value: object) -> bool:
96
+ text = str(value or "")
97
+ return len(text) == 64 and all(ch in "0123456789abcdefABCDEF" for ch in text)
98
+
99
+
100
+ def _require_instance_identity(rows: list[dict]) -> dict[str, str]:
101
+ grouped: dict[str, set[str]] = defaultdict(set)
102
+ for row in rows:
103
+ instance = str(row.get("instance") or "")
104
+ sha = row.get("instance_sha256")
105
+ if not instance:
106
+ raise ValueError("every benchmark row must provide a non-empty instance")
107
+ if not _valid_sha256(sha):
108
+ raise ValueError(f"benchmark row for {instance!r} is missing a valid instance_sha256")
109
+ grouped[instance].add(str(sha).lower())
110
+ mismatched = {instance: hashes for instance, hashes in grouped.items() if len(hashes) != 1}
111
+ if mismatched:
112
+ first = sorted(mismatched)[0]
113
+ raise ValueError(f"instance identity mismatch for {first!r}: {sorted(mismatched[first])}")
114
+ return {instance: next(iter(hashes)) for instance, hashes in grouped.items()}
115
+
116
+
117
+ def _require_comparable_environment(rows: list[dict], *, allow_mixed_environments: bool) -> set[str]:
118
+ ids = _environment_ids(rows)
119
+ if len(ids) > 1 and not allow_mixed_environments:
120
+ raise ValueError(
121
+ "cannot aggregate timing results from multiple benchmark environments; "
122
+ "rerun on homogeneous hardware/software or pass allow_mixed_environments=True explicitly"
123
+ )
124
+ return ids
125
+
126
+
127
+ def _require_experiment_identity(rows: list[dict]) -> set[str]:
128
+ by_environment: dict[str, set[str]] = defaultdict(set)
129
+ ids: set[str] = set()
130
+ for row in rows:
131
+ raw = row.get("experiment_identity")
132
+ schema = str(row.get("schema_version") or "")
133
+ if raw is None:
134
+ if schema == CURRENT_BENCHMARK_ROW_SCHEMA:
135
+ raise ValueError("every current benchmark row must provide experiment_identity")
136
+ continue
137
+ text = str(raw).strip().lower()
138
+ if not _valid_sha256(text):
139
+ raise ValueError("benchmark row has invalid experiment_identity")
140
+ if schema == CURRENT_BENCHMARK_ROW_SCHEMA:
141
+ backend_set_sha256 = str(row.get("backend_set_sha256") or "").strip().lower()
142
+ if not _valid_sha256(backend_set_sha256):
143
+ raise ValueError("current benchmark row has invalid backend_set_sha256")
144
+ backend_identity = row.get("backend_identity")
145
+ backend_identity_sha256 = str(row.get("backend_identity_sha256") or "").strip().lower()
146
+ if not isinstance(backend_identity, dict) or not _valid_sha256(backend_identity_sha256):
147
+ raise ValueError("current benchmark row has invalid backend identity metadata")
148
+ if canonical_sha256(backend_identity).lower() != backend_identity_sha256:
149
+ raise ValueError("backend identity checksum mismatch")
150
+ thread_policy = row.get("thread_policy")
151
+ if not isinstance(thread_policy, dict):
152
+ raise ValueError("current benchmark row is missing thread_policy")
153
+ expected = compute_experiment_identity(
154
+ protocol_id=str(row.get("protocol_id") or ""),
155
+ environment_id=str(row.get("environment_id") or ""),
156
+ backend_set_sha256=backend_set_sha256,
157
+ thread_policy=thread_policy,
158
+ )
159
+ if expected.lower() != text:
160
+ raise ValueError("experiment_identity does not match row provenance fields")
161
+ env = str(row.get("environment_id") or "").strip()
162
+ by_environment[env].add(text)
163
+ ids.add(text)
164
+ inconsistent = {env: values for env, values in by_environment.items() if len(values) > 1}
165
+ if inconsistent:
166
+ env = sorted(inconsistent)[0]
167
+ raise ValueError(f"experiment identity mismatch within environment {env!r}: {sorted(inconsistent[env])}")
168
+ return ids
169
+
170
+
171
+ def _paired_bootstrap_policy_ratio(
172
+ policy_costs: list[float], sbs_costs: list[float], *, draws: int, seed: int
173
+ ) -> dict:
174
+ if draws < 1:
175
+ raise ValueError("bootstrap_draws must be >= 1")
176
+ n = len(policy_costs)
177
+ if n != len(sbs_costs) or n == 0:
178
+ raise ValueError("bootstrap inputs must have equal nonzero length")
179
+ rng = random.Random(seed)
180
+ ratios = []
181
+ for _ in range(draws):
182
+ idx = [rng.randrange(n) for _ in range(n)]
183
+ p = sum(policy_costs[i] for i in idx) / n
184
+ s = sum(sbs_costs[i] for i in idx) / n
185
+ ratios.append(float("inf") if s == 0 else p / s)
186
+ ratios.sort()
187
+ def q(frac: float) -> float:
188
+ pos = frac * (draws - 1)
189
+ lo = int(pos); hi = min(draws - 1, lo + 1); w = pos - lo
190
+ return ratios[lo] * (1 - w) + ratios[hi] * w
191
+ point_s = sum(sbs_costs) / n
192
+ point_p = sum(policy_costs) / n
193
+ return {
194
+ "point": float("inf") if point_s == 0 else point_p / point_s,
195
+ "low": q(0.025),
196
+ "median": q(0.5),
197
+ "high": q(0.975),
198
+ "draws": draws,
199
+ "seed": seed,
200
+ }
201
+
202
+
203
+ def summarize_rows(
204
+ rows: list[dict],
205
+ *,
206
+ cutoff_s: float,
207
+ par_penalty: float = 10.0,
208
+ cost_field: str = "wall_s",
209
+ bootstrap_draws: int = 10000,
210
+ bootstrap_seed: int = 0,
211
+ allow_mixed_environments: bool = False,
212
+ ) -> dict:
213
+ if cutoff_s <= 0 or par_penalty < 1:
214
+ raise ValueError("cutoff_s must be positive and par_penalty must be >= 1")
215
+ if not rows:
216
+ raise ValueError("no rows to summarize")
217
+
218
+ protocol_id = _require_protocol_identity(rows)
219
+ instance_hashes = _require_instance_identity(rows)
220
+ environment_ids = _require_comparable_environment(rows, allow_mixed_environments=allow_mixed_environments)
221
+ experiment_ids = _require_experiment_identity(rows)
222
+
223
+ backends = sorted({str(r["backend"]) for r in rows})
224
+ instance_reps: dict[tuple[str, str], list[dict]] = defaultdict(list)
225
+ for row in rows:
226
+ instance_reps[(str(row["instance"]), str(row["backend"]))].append(row)
227
+ instances = sorted({key[0] for key in instance_reps})
228
+
229
+ costs: dict[str, list[float]] = {b: [] for b in backends}
230
+ solver_stats: dict[str, dict] = {}
231
+ rectangular = True
232
+ for backend in backends:
233
+ solved = 0
234
+ valid = 0
235
+ reference_ok = 0
236
+ median_wall_inputs: list[float] = []
237
+ for instance in instances:
238
+ reps = instance_reps.get((instance, backend), [])
239
+ if not reps:
240
+ rectangular = False
241
+ cost = cutoff_s * par_penalty
242
+ costs[backend].append(cost)
243
+ continue
244
+ good_costs: list[float] = []
245
+ for r in reps:
246
+ if r.get("state") == "solved":
247
+ solved += 1
248
+ if r.get("validated") is True:
249
+ valid += 1
250
+ if _row_is_benchmark_success(r):
251
+ reference_ok += 1
252
+ if _row_is_benchmark_success(r):
253
+ cost_value = _get_cost(r, cost_field)
254
+ if cost_value is not None:
255
+ good_costs.append(min(cost_value, cutoff_s))
256
+ median_wall_inputs.append(cost_value)
257
+ cost = median(good_costs) if good_costs else cutoff_s * par_penalty
258
+ costs[backend].append(float(cost))
259
+ solver_stats[backend] = {
260
+ "rows": sum(len(instance_reps.get((i, backend), [])) for i in instances),
261
+ "solved_rows": solved,
262
+ "validated_rows": valid,
263
+ "reference_ok_rows": reference_ok,
264
+ "median_success_wall_s": None if not median_wall_inputs else float(median(median_wall_inputs)),
265
+ "par_cost_mean": sum(costs[backend]) / len(costs[backend]),
266
+ }
267
+
268
+ direct_backends_for_metrics = [b for b in backends if b != "@auto"] or list(backends)
269
+ direct_costs_for_metrics = {b: costs[b] for b in direct_backends_for_metrics}
270
+ means = {b: sum(v) / len(v) for b, v in direct_costs_for_metrics.items()}
271
+ sbs = min(means, key=lambda b: (means[b], b))
272
+ vbs_choices = [min(direct_backends_for_metrics, key=lambda b: (costs[b][i], b)) for i in range(len(instances))]
273
+ metrics = portfolio_metrics(direct_costs_for_metrics, vbs_choices, metric=f"PAR{par_penalty:g}@{cutoff_s:g}s")
274
+ taus = (1.0, 1.1, 1.25, 1.5, 2.0, 5.0, 10.0)
275
+ profile = performance_profile(direct_costs_for_metrics, taus)
276
+
277
+ auto_policy = None
278
+ if "@auto" in backends:
279
+ direct_backends = [b for b in backends if b != "@auto"]
280
+ if not direct_backends:
281
+ auto_policy = {"eligible": False, "reason": "no_direct_backends"}
282
+ else:
283
+ direct_costs = {b: costs[b] for b in direct_backends}
284
+ policy_choices: list[str] = []
285
+ policy_costs: list[float] = []
286
+ unstable: list[str] = []
287
+ for idx, instance in enumerate(instances):
288
+ reps = instance_reps.get((instance, "@auto"), [])
289
+ good = [
290
+ r for r in reps
291
+ if _row_is_benchmark_success(r)
292
+ ]
293
+ choices = {
294
+ r.get("trace", {}).get("planner_selected_backend") for r in good
295
+ if r.get("trace", {}).get("planner_selected_backend")
296
+ }
297
+ if len(choices) != 1 or not choices.issubset(set(direct_backends)):
298
+ unstable.append(instance)
299
+ continue
300
+ choice = next(iter(choices))
301
+ policy_choices.append(choice)
302
+ good_costs = [min(v, cutoff_s) for r in good if (v := _get_cost(r, cost_field)) is not None]
303
+ policy_costs.append(float(median(good_costs)) if good_costs else cutoff_s * par_penalty)
304
+ if unstable or len(policy_choices) != len(instances):
305
+ auto_policy = {
306
+ "eligible": False,
307
+ "reason": "planner_choice_missing_unstable_or_outside_direct_portfolio",
308
+ "instances": len(instances),
309
+ "problem_instances": unstable,
310
+ }
311
+ elif cost_field.startswith("trace."):
312
+ auto_policy = {
313
+ "eligible": False,
314
+ "reason": "selected cost_field excludes planner/inspection overhead; use wall_s or worker_solve_wall_s for deployable auto-policy evaluation",
315
+ }
316
+ else:
317
+ pm = portfolio_metrics(
318
+ direct_costs, policy_choices, metric=f"PAR{par_penalty:g}@{cutoff_s:g}s", policy_costs=policy_costs
319
+ )
320
+ ratio_ci = _paired_bootstrap_policy_ratio(
321
+ policy_costs, direct_costs[pm.sbs_solver], draws=bootstrap_draws, seed=bootstrap_seed
322
+ )
323
+ auto_policy = {
324
+ "eligible": True, **asdict(pm), "choices": policy_choices,
325
+ "policy_to_sbs_ratio_bootstrap_95pct": ratio_ci,
326
+ }
327
+
328
+ return {
329
+ "schema_version": "1.2",
330
+ "protocol_id": protocol_id,
331
+ "instance_identity_verified": True,
332
+ "instance_sha256": instance_hashes,
333
+ "environment_ids": sorted(environment_ids),
334
+ "experiment_identities": sorted(experiment_ids),
335
+ "mixed_environments_allowed": bool(allow_mixed_environments),
336
+ "instances": len(instances),
337
+ "backends": backends,
338
+ "rectangular_input": rectangular,
339
+ "cutoff_s": cutoff_s,
340
+ "cost_field": cost_field,
341
+ "cost_accounting": {
342
+ "field": cost_field,
343
+ "auto_decision_overhead_included": cost_field in {"wall_s", "worker_solve_wall_s"},
344
+ "scope": (
345
+ "controller end-to-end wall time including worker startup/parse/solve"
346
+ if cost_field == "wall_s"
347
+ else "SolverPilot solve call wall time including inspect/plan/build/validate/diagnose"
348
+ if cost_field == "worker_solve_wall_s"
349
+ else "internal trace component; not deployable policy total cost"
350
+ ),
351
+ },
352
+ "par_penalty": par_penalty,
353
+ "bootstrap": {"draws": bootstrap_draws, "seed": bootstrap_seed},
354
+ "solver_stats": solver_stats,
355
+ "sbs_solver": sbs,
356
+ "sbs_cost": means[sbs],
357
+ "vbs_cost": metrics.vbs_cost,
358
+ "vbs": {"cost": metrics.vbs_cost, "is_oracle": True, "deployable": False},
359
+ "sbs_vbs_gap": means[sbs] - metrics.vbs_cost,
360
+ "relative_sbs_vbs_gap": None if means[sbs] <= 0 else (means[sbs] - metrics.vbs_cost) / means[sbs],
361
+ "performance_profile": {"taus": list(taus), "fractions": profile},
362
+ "auto_policy_metrics": auto_policy,
363
+ }
364
+
365
+
366
+ def summarize_jsonl(
367
+ paths: Iterable[str | Path],
368
+ *,
369
+ cutoff_s: float,
370
+ par_penalty: float = 10.0,
371
+ cost_field: str = "wall_s",
372
+ bootstrap_draws: int = 10000,
373
+ bootstrap_seed: int = 0,
374
+ allow_mixed_environments: bool = False,
375
+ ) -> dict:
376
+ return summarize_rows(
377
+ load_rows(paths),
378
+ cutoff_s=cutoff_s,
379
+ par_penalty=par_penalty,
380
+ cost_field=cost_field,
381
+ bootstrap_draws=bootstrap_draws,
382
+ bootstrap_seed=bootstrap_seed,
383
+ allow_mixed_environments=allow_mixed_environments,
384
+ )