simjecture 0.1.0__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.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Operator control channel and run clock for MVP campaigns.
|
|
2
|
+
|
|
3
|
+
These files live under operator_input/ and are not scientific evidence. The
|
|
4
|
+
runner consults them only at action boundaries. They never use SIGSTOP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from .models import StrictModel
|
|
19
|
+
|
|
20
|
+
CONTROL_RELATIVE = Path("operator_input") / "control.json"
|
|
21
|
+
CLOCK_RELATIVE = Path("operator_input") / "run_clock.json"
|
|
22
|
+
PAUSE_STATE_RELATIVE = Path("operator_input") / "pause_state.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ControlCommand(StrEnum):
|
|
26
|
+
NONE = "none"
|
|
27
|
+
PAUSE = "pause"
|
|
28
|
+
CANCEL = "cancel"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CampaignPaused(Exception):
|
|
32
|
+
"""Raised when the runner honors an action-boundary pause.
|
|
33
|
+
|
|
34
|
+
No terminal report is written. Repeating the original contract resumes.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, run_directory: str | Path, *, iterations: int) -> None:
|
|
38
|
+
self.run_directory = Path(run_directory)
|
|
39
|
+
self.iterations = iterations
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"campaign paused at an action boundary after {iterations} iteration(s)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OperatorControl(StrictModel):
|
|
46
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
47
|
+
command: ControlCommand = ControlCommand.NONE
|
|
48
|
+
requested_at: datetime | None = None
|
|
49
|
+
source: str = "operator"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RunClock(StrictModel):
|
|
53
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
54
|
+
started_at: datetime
|
|
55
|
+
session_started_at: datetime
|
|
56
|
+
last_tick_at: datetime
|
|
57
|
+
accumulated_active_seconds: float = Field(default=0.0, ge=0)
|
|
58
|
+
state: Literal["running", "paused", "finished"] = "running"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PauseState(StrictModel):
|
|
62
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
63
|
+
paused_at: datetime
|
|
64
|
+
iterations: int = Field(ge=0)
|
|
65
|
+
reason: str = "operator requested pause at the next action boundary"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def utc_now() -> datetime:
|
|
69
|
+
return datetime.now(UTC)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_control(run_directory: str | Path) -> OperatorControl:
|
|
73
|
+
path = Path(run_directory) / CONTROL_RELATIVE
|
|
74
|
+
payload = _read_json(path)
|
|
75
|
+
if payload is None:
|
|
76
|
+
return OperatorControl()
|
|
77
|
+
try:
|
|
78
|
+
return OperatorControl.model_validate(payload)
|
|
79
|
+
except Exception:
|
|
80
|
+
return OperatorControl()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_control(
|
|
84
|
+
run_directory: str | Path,
|
|
85
|
+
command: ControlCommand,
|
|
86
|
+
*,
|
|
87
|
+
source: str = "operator",
|
|
88
|
+
now: datetime | None = None,
|
|
89
|
+
) -> OperatorControl:
|
|
90
|
+
control = OperatorControl(
|
|
91
|
+
command=command,
|
|
92
|
+
requested_at=now or utc_now(),
|
|
93
|
+
source=source,
|
|
94
|
+
)
|
|
95
|
+
_write_json(Path(run_directory) / CONTROL_RELATIVE, control.model_dump(mode="json"))
|
|
96
|
+
return control
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clear_control(run_directory: str | Path, *, source: str = "runner") -> None:
|
|
100
|
+
write_control(run_directory, ControlCommand.NONE, source=source)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def poll_control(run_directory: str | Path) -> ControlCommand:
|
|
104
|
+
return read_control(run_directory).command
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_clock(run_directory: str | Path) -> RunClock | None:
|
|
108
|
+
payload = _read_json(Path(run_directory) / CLOCK_RELATIVE)
|
|
109
|
+
if payload is None:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return RunClock.model_validate(payload)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_pause_state(run_directory: str | Path) -> PauseState | None:
|
|
118
|
+
payload = _read_json(Path(run_directory) / PAUSE_STATE_RELATIVE)
|
|
119
|
+
if payload is None:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
return PauseState.model_validate(payload)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def begin_or_resume_clock(
|
|
128
|
+
run_directory: str | Path,
|
|
129
|
+
*,
|
|
130
|
+
now: datetime | None = None,
|
|
131
|
+
) -> RunClock:
|
|
132
|
+
"""Start or continue the operator clock and clear a prior pause request."""
|
|
133
|
+
|
|
134
|
+
moment = now or utc_now()
|
|
135
|
+
existing = read_clock(run_directory)
|
|
136
|
+
if existing is None:
|
|
137
|
+
clock = RunClock(
|
|
138
|
+
started_at=moment,
|
|
139
|
+
session_started_at=moment,
|
|
140
|
+
last_tick_at=moment,
|
|
141
|
+
accumulated_active_seconds=0.0,
|
|
142
|
+
state="running",
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
orphan = 0.0
|
|
146
|
+
if existing.state == "running":
|
|
147
|
+
orphan = max(
|
|
148
|
+
0.0,
|
|
149
|
+
(existing.last_tick_at - existing.session_started_at).total_seconds(),
|
|
150
|
+
)
|
|
151
|
+
clock = RunClock(
|
|
152
|
+
started_at=existing.started_at,
|
|
153
|
+
session_started_at=moment,
|
|
154
|
+
last_tick_at=moment,
|
|
155
|
+
accumulated_active_seconds=existing.accumulated_active_seconds + orphan,
|
|
156
|
+
state="running",
|
|
157
|
+
)
|
|
158
|
+
_write_clock(run_directory, clock)
|
|
159
|
+
clear_control(run_directory, source="runner")
|
|
160
|
+
pause_path = Path(run_directory) / PAUSE_STATE_RELATIVE
|
|
161
|
+
if pause_path.exists():
|
|
162
|
+
pause_path.unlink()
|
|
163
|
+
return clock
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def tick_clock(run_directory: str | Path, *, now: datetime | None = None) -> RunClock:
|
|
167
|
+
moment = now or utc_now()
|
|
168
|
+
clock = read_clock(run_directory)
|
|
169
|
+
if clock is None:
|
|
170
|
+
return begin_or_resume_clock(run_directory, now=moment)
|
|
171
|
+
if clock.state != "running":
|
|
172
|
+
return clock
|
|
173
|
+
updated = clock.model_copy(update={"last_tick_at": moment})
|
|
174
|
+
_write_clock(run_directory, updated)
|
|
175
|
+
return updated
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def pause_at_boundary(
|
|
179
|
+
run_directory: str | Path,
|
|
180
|
+
*,
|
|
181
|
+
iterations: int,
|
|
182
|
+
now: datetime | None = None,
|
|
183
|
+
) -> PauseState:
|
|
184
|
+
moment = now or utc_now()
|
|
185
|
+
clock = read_clock(run_directory)
|
|
186
|
+
if clock is not None and clock.state == "running":
|
|
187
|
+
session = max(0.0, (moment - clock.session_started_at).total_seconds())
|
|
188
|
+
paused_clock = RunClock(
|
|
189
|
+
started_at=clock.started_at,
|
|
190
|
+
session_started_at=clock.session_started_at,
|
|
191
|
+
last_tick_at=moment,
|
|
192
|
+
accumulated_active_seconds=clock.accumulated_active_seconds + session,
|
|
193
|
+
state="paused",
|
|
194
|
+
)
|
|
195
|
+
_write_clock(run_directory, paused_clock)
|
|
196
|
+
state = PauseState(paused_at=moment, iterations=iterations)
|
|
197
|
+
_write_json(Path(run_directory) / PAUSE_STATE_RELATIVE, state.model_dump(mode="json"))
|
|
198
|
+
clear_control(run_directory, source="runner")
|
|
199
|
+
return state
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def finalize_clock(run_directory: str | Path, *, now: datetime | None = None) -> RunClock | None:
|
|
203
|
+
moment = now or utc_now()
|
|
204
|
+
clock = read_clock(run_directory)
|
|
205
|
+
if clock is None:
|
|
206
|
+
return None
|
|
207
|
+
extra = 0.0
|
|
208
|
+
if clock.state == "running":
|
|
209
|
+
extra = max(0.0, (moment - clock.session_started_at).total_seconds())
|
|
210
|
+
finished = RunClock(
|
|
211
|
+
started_at=clock.started_at,
|
|
212
|
+
session_started_at=clock.session_started_at,
|
|
213
|
+
last_tick_at=moment,
|
|
214
|
+
accumulated_active_seconds=clock.accumulated_active_seconds + extra,
|
|
215
|
+
state="finished",
|
|
216
|
+
)
|
|
217
|
+
_write_clock(run_directory, finished)
|
|
218
|
+
return finished
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def elapsed_from_clock(clock: RunClock, *, now: datetime | None = None) -> float:
|
|
222
|
+
moment = now or utc_now()
|
|
223
|
+
if clock.state == "running":
|
|
224
|
+
session = max(0.0, (moment - clock.session_started_at).total_seconds())
|
|
225
|
+
return clock.accumulated_active_seconds + session
|
|
226
|
+
return clock.accumulated_active_seconds
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_usage_payload(usage: Any) -> dict[str, int]:
|
|
230
|
+
"""Normalize provider usage objects without treating them as scientific status."""
|
|
231
|
+
|
|
232
|
+
empty = {
|
|
233
|
+
"prompt_tokens": 0,
|
|
234
|
+
"completion_tokens": 0,
|
|
235
|
+
"total_tokens": 0,
|
|
236
|
+
"cached_tokens": 0,
|
|
237
|
+
"reasoning_tokens": 0,
|
|
238
|
+
}
|
|
239
|
+
if not isinstance(usage, dict):
|
|
240
|
+
return empty
|
|
241
|
+
prompt = _as_int(usage.get("prompt_tokens"))
|
|
242
|
+
if prompt is None:
|
|
243
|
+
prompt = _as_int(usage.get("input_tokens")) or 0
|
|
244
|
+
completion = _as_int(usage.get("completion_tokens"))
|
|
245
|
+
if completion is None:
|
|
246
|
+
completion = _as_int(usage.get("output_tokens")) or 0
|
|
247
|
+
total = _as_int(usage.get("total_tokens"))
|
|
248
|
+
if total is None:
|
|
249
|
+
total = prompt + completion
|
|
250
|
+
cached = 0
|
|
251
|
+
details = usage.get("prompt_tokens_details")
|
|
252
|
+
if isinstance(details, dict):
|
|
253
|
+
cached = _as_int(details.get("cached_tokens")) or 0
|
|
254
|
+
cached = (
|
|
255
|
+
_as_int(usage.get("prompt_cache_hit_tokens"))
|
|
256
|
+
or _as_int(usage.get("cached_tokens"))
|
|
257
|
+
or cached
|
|
258
|
+
)
|
|
259
|
+
reasoning = 0
|
|
260
|
+
out_details = usage.get("completion_tokens_details")
|
|
261
|
+
if isinstance(out_details, dict):
|
|
262
|
+
reasoning = _as_int(out_details.get("reasoning_tokens")) or 0
|
|
263
|
+
return {
|
|
264
|
+
"prompt_tokens": max(0, prompt),
|
|
265
|
+
"completion_tokens": max(0, completion),
|
|
266
|
+
"total_tokens": max(0, total),
|
|
267
|
+
"cached_tokens": max(0, cached),
|
|
268
|
+
"reasoning_tokens": max(0, reasoning),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_token_counts(
|
|
273
|
+
*,
|
|
274
|
+
prompt_tokens: int,
|
|
275
|
+
completion_tokens: int,
|
|
276
|
+
total_tokens: int,
|
|
277
|
+
turns: int,
|
|
278
|
+
missing_turns: int = 0,
|
|
279
|
+
cached_tokens: int = 0,
|
|
280
|
+
) -> str:
|
|
281
|
+
parts = [
|
|
282
|
+
f"tokens: {_comma(prompt_tokens)} in / {_comma(completion_tokens)} out / "
|
|
283
|
+
f"{_comma(total_tokens)} total"
|
|
284
|
+
]
|
|
285
|
+
extras: list[str] = [f"{turns} turn" + ("s" if turns != 1 else "")]
|
|
286
|
+
if missing_turns:
|
|
287
|
+
extras.append(f"{missing_turns} without usage")
|
|
288
|
+
if cached_tokens:
|
|
289
|
+
extras.append(f"{_comma(cached_tokens)} cached")
|
|
290
|
+
parts.append("(" + ", ".join(extras) + ")")
|
|
291
|
+
return " ".join(parts)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _comma(value: int) -> str:
|
|
295
|
+
return f"{value:,}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _as_int(value: Any) -> int | None:
|
|
299
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
300
|
+
return None
|
|
301
|
+
return int(value)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
305
|
+
if not path.is_file():
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
payload = json.loads(path.read_text())
|
|
309
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
310
|
+
return None
|
|
311
|
+
return payload if isinstance(payload, dict) else None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _write_clock(run_directory: str | Path, clock: RunClock) -> None:
|
|
315
|
+
_write_json(Path(run_directory) / CLOCK_RELATIVE, clock.model_dump(mode="json"))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
319
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
temporary = path.with_name(f".{path.name}.tmp")
|
|
321
|
+
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
322
|
+
os.replace(temporary, path)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Validated starting points for guided MVP commissioning campaigns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, model_validator
|
|
12
|
+
|
|
13
|
+
from .models import StrictModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _workspace_relative_path(value: str) -> str:
|
|
17
|
+
path = PurePosixPath(value)
|
|
18
|
+
if (
|
|
19
|
+
not value
|
|
20
|
+
or path.is_absolute()
|
|
21
|
+
or "\\" in value
|
|
22
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
23
|
+
):
|
|
24
|
+
raise ValueError(f"guided commissioning path must be workspace-relative: {value!r}")
|
|
25
|
+
if path.as_posix() != value:
|
|
26
|
+
raise ValueError(f"guided commissioning path must be canonical: {value!r}")
|
|
27
|
+
return path.as_posix()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MVPGuidedCommissioningSpec(StrictModel):
|
|
31
|
+
"""Operator-authored manifest for one known-runnable starting point.
|
|
32
|
+
|
|
33
|
+
The package deliberately says only what was run and where its compact validation
|
|
34
|
+
record lives. It does not qualify the supplied output as campaign evidence.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
38
|
+
name: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9._-]*$")
|
|
39
|
+
description: str = Field(min_length=1)
|
|
40
|
+
capability: str = Field(min_length=1)
|
|
41
|
+
program_path: str = Field(min_length=1)
|
|
42
|
+
validated_argv: tuple[str, ...] = Field(min_length=1)
|
|
43
|
+
validation_summary_path: str = Field(min_length=1)
|
|
44
|
+
operator_validation: str = Field(min_length=1)
|
|
45
|
+
limitations: tuple[str, ...] = ()
|
|
46
|
+
files: tuple[str, ...] = Field(min_length=2)
|
|
47
|
+
|
|
48
|
+
@model_validator(mode="after")
|
|
49
|
+
def paths_and_command_are_self_contained(self) -> MVPGuidedCommissioningSpec:
|
|
50
|
+
normalized_files = tuple(_workspace_relative_path(path) for path in self.files)
|
|
51
|
+
if len(set(normalized_files)) != len(normalized_files):
|
|
52
|
+
raise ValueError("guided commissioning files must be unique")
|
|
53
|
+
program_path = _workspace_relative_path(self.program_path)
|
|
54
|
+
summary_path = _workspace_relative_path(self.validation_summary_path)
|
|
55
|
+
if program_path not in normalized_files:
|
|
56
|
+
raise ValueError("program_path must be listed in files")
|
|
57
|
+
if summary_path not in normalized_files:
|
|
58
|
+
raise ValueError("validation_summary_path must be listed in files")
|
|
59
|
+
if self.validated_argv[0] != program_path:
|
|
60
|
+
raise ValueError("validated_argv[0] must equal program_path")
|
|
61
|
+
if any(not argument for argument in self.validated_argv):
|
|
62
|
+
raise ValueError("validated_argv arguments cannot be empty")
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MVPGuidedCommissioningFile(StrictModel):
|
|
67
|
+
path: str = Field(min_length=1)
|
|
68
|
+
bytes: int = Field(ge=0)
|
|
69
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class MVPGuidedCommissioningPackage:
|
|
74
|
+
"""Loaded, content-addressed guided commissioning package."""
|
|
75
|
+
|
|
76
|
+
manifest_path: Path
|
|
77
|
+
spec: MVPGuidedCommissioningSpec
|
|
78
|
+
file_records: tuple[MVPGuidedCommissioningFile, ...]
|
|
79
|
+
package_sha256: str
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def read(cls, manifest_path: str | Path) -> MVPGuidedCommissioningPackage:
|
|
83
|
+
requested_manifest = Path(manifest_path)
|
|
84
|
+
if requested_manifest.is_symlink():
|
|
85
|
+
raise ValueError("guided commissioning manifest must be a regular file")
|
|
86
|
+
manifest = requested_manifest.resolve()
|
|
87
|
+
if not manifest.is_file():
|
|
88
|
+
raise ValueError("guided commissioning manifest must be a regular file")
|
|
89
|
+
spec = MVPGuidedCommissioningSpec.model_validate_json(manifest.read_text())
|
|
90
|
+
root = manifest.parent
|
|
91
|
+
digest = hashlib.sha256()
|
|
92
|
+
canonical_spec = json.dumps(
|
|
93
|
+
spec.model_dump(mode="json"),
|
|
94
|
+
sort_keys=True,
|
|
95
|
+
separators=(",", ":"),
|
|
96
|
+
).encode()
|
|
97
|
+
digest.update(len(canonical_spec).to_bytes(8, "big"))
|
|
98
|
+
digest.update(canonical_spec)
|
|
99
|
+
records: list[MVPGuidedCommissioningFile] = []
|
|
100
|
+
for relative in sorted(spec.files):
|
|
101
|
+
normalized = _workspace_relative_path(relative)
|
|
102
|
+
requested = root / normalized
|
|
103
|
+
path = requested.resolve()
|
|
104
|
+
if requested.is_symlink() or not path.is_relative_to(root) or not path.is_file():
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"guided commissioning file is missing, irregular, or escapes package: "
|
|
107
|
+
f"{relative!r}"
|
|
108
|
+
)
|
|
109
|
+
encoded = path.read_bytes()
|
|
110
|
+
sha256 = hashlib.sha256(encoded).hexdigest()
|
|
111
|
+
records.append(
|
|
112
|
+
MVPGuidedCommissioningFile(
|
|
113
|
+
path=normalized,
|
|
114
|
+
bytes=len(encoded),
|
|
115
|
+
sha256=sha256,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
name = normalized.encode()
|
|
119
|
+
digest.update(len(name).to_bytes(8, "big"))
|
|
120
|
+
digest.update(name)
|
|
121
|
+
digest.update(len(encoded).to_bytes(8, "big"))
|
|
122
|
+
digest.update(encoded)
|
|
123
|
+
return cls(
|
|
124
|
+
manifest_path=manifest,
|
|
125
|
+
spec=spec,
|
|
126
|
+
file_records=tuple(records),
|
|
127
|
+
package_sha256=digest.hexdigest(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def root(self) -> Path:
|
|
132
|
+
return self.manifest_path.parent
|
|
133
|
+
|
|
134
|
+
def read_file(self, relative: str) -> bytes:
|
|
135
|
+
normalized = _workspace_relative_path(relative)
|
|
136
|
+
record = next(
|
|
137
|
+
(item for item in self.file_records if item.path == normalized),
|
|
138
|
+
None,
|
|
139
|
+
)
|
|
140
|
+
if record is None:
|
|
141
|
+
raise ValueError(f"file is not declared by guided commissioning: {relative!r}")
|
|
142
|
+
requested = self.root / normalized
|
|
143
|
+
path = requested.resolve()
|
|
144
|
+
if requested.is_symlink() or not path.is_relative_to(self.root) or not path.is_file():
|
|
145
|
+
raise ValueError(f"guided commissioning file identity changed: {relative!r}")
|
|
146
|
+
encoded = path.read_bytes()
|
|
147
|
+
if len(encoded) != record.bytes or hashlib.sha256(encoded).hexdigest() != record.sha256:
|
|
148
|
+
raise ValueError(f"guided commissioning file identity changed: {relative!r}")
|
|
149
|
+
return encoded
|
|
150
|
+
|
|
151
|
+
def assert_identity(self) -> None:
|
|
152
|
+
current = self.read(self.manifest_path)
|
|
153
|
+
if current.package_sha256 != self.package_sha256:
|
|
154
|
+
raise ValueError("guided commissioning package identity changed")
|
|
155
|
+
|
|
156
|
+
def descriptor(self) -> dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"available": True,
|
|
159
|
+
"schema_version": self.spec.schema_version,
|
|
160
|
+
"name": self.spec.name,
|
|
161
|
+
"description": self.spec.description,
|
|
162
|
+
"capability": self.spec.capability,
|
|
163
|
+
"program_path": self.spec.program_path,
|
|
164
|
+
"validated_argv": list(self.spec.validated_argv),
|
|
165
|
+
"validation_summary_path": self.spec.validation_summary_path,
|
|
166
|
+
"operator_validation": self.spec.operator_validation,
|
|
167
|
+
"limitations": list(self.spec.limitations),
|
|
168
|
+
"files": [item.model_dump(mode="json") for item in self.file_records],
|
|
169
|
+
"package_sha256": self.package_sha256,
|
|
170
|
+
"scientific_evidence_eligible": False,
|
|
171
|
+
"policy": "operator_validated_starting_point_not_campaign_evidence",
|
|
172
|
+
}
|