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.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|