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.
Files changed (75) hide show
  1. conjecture_solver/__init__.py +10 -0
  2. conjecture_solver/__main__.py +4 -0
  3. conjecture_solver/action_handlers.py +459 -0
  4. conjecture_solver/adapters/__init__.py +7 -0
  5. conjecture_solver/adapters/base.py +96 -0
  6. conjecture_solver/adapters/fake.py +155 -0
  7. conjecture_solver/adapters/pic.py +196 -0
  8. conjecture_solver/adapters/warpx.py +1375 -0
  9. conjecture_solver/autonomous_research.py +1071 -0
  10. conjecture_solver/benchmarks/__init__.py +6 -0
  11. conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
  12. conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
  13. conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
  14. conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
  15. conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
  16. conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
  17. conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
  18. conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
  19. conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
  20. conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
  21. conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
  22. conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
  23. conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
  24. conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
  25. conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
  26. conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
  27. conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
  28. conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
  29. conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
  30. conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
  31. conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
  32. conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
  33. conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
  34. conjecture_solver/campaign.py +406 -0
  35. conjecture_solver/cli.py +963 -0
  36. conjecture_solver/confirmation.py +287 -0
  37. conjecture_solver/control.py +234 -0
  38. conjecture_solver/deployment.py +874 -0
  39. conjecture_solver/discovery.py +68 -0
  40. conjecture_solver/domains/__init__.py +40 -0
  41. conjecture_solver/domains/base.py +157 -0
  42. conjecture_solver/domains/kinetic_sufficiency.py +521 -0
  43. conjecture_solver/ledger.py +238 -0
  44. conjecture_solver/lifecycle.py +131 -0
  45. conjecture_solver/literature.py +494 -0
  46. conjecture_solver/llm.py +228 -0
  47. conjecture_solver/models.py +279 -0
  48. conjecture_solver/mvp_agent.py +3212 -0
  49. conjecture_solver/mvp_claims.py +1279 -0
  50. conjecture_solver/mvp_control.py +322 -0
  51. conjecture_solver/mvp_guidance.py +172 -0
  52. conjecture_solver/mvp_launch.py +1296 -0
  53. conjecture_solver/mvp_monitor.py +1487 -0
  54. conjecture_solver/mvp_skills.py +538 -0
  55. conjecture_solver/orchestration.py +578 -0
  56. conjecture_solver/outbox.py +526 -0
  57. conjecture_solver/parameters.py +394 -0
  58. conjecture_solver/proposals.py +427 -0
  59. conjecture_solver/research_tools.py +278 -0
  60. conjecture_solver/schema_export.py +246 -0
  61. conjecture_solver/search.py +1047 -0
  62. conjecture_solver/semantics.py +146 -0
  63. conjecture_solver/tui/__init__.py +7 -0
  64. conjecture_solver/tui/app.py +202 -0
  65. conjecture_solver/tui/claim_views.py +213 -0
  66. conjecture_solver/tui/screens.py +1014 -0
  67. conjecture_solver/warpx_analysis.py +184 -0
  68. conjecture_solver/warpx_campaign.py +148 -0
  69. conjecture_solver/warpx_confirmation.py +617 -0
  70. simjecture-0.1.0.dist-info/METADATA +400 -0
  71. simjecture-0.1.0.dist-info/RECORD +75 -0
  72. simjecture-0.1.0.dist-info/WHEEL +4 -0
  73. simjecture-0.1.0.dist-info/entry_points.txt +4 -0
  74. simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
@@ -0,0 +1,874 @@
1
+ """Operator-side deployment and health checks for optional scientific runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata as metadata
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import tomllib
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ from pydantic import Field
18
+
19
+ from .models import StrictModel
20
+ from .mvp_agent import BubblewrapSandbox, MVPAgentConfig
21
+ from .mvp_skills import (
22
+ MVPCapabilityConfig,
23
+ MVPCapabilityInstallation,
24
+ MVPCapabilityRegistry,
25
+ )
26
+
27
+ WARPX_CPU_CAPABILITY = "warpx-cpu-26.07"
28
+ WARPX_CUDA_CAPABILITY = "warpx-cuda-openpmd-26.07"
29
+ PINNED_WARPX_REVISION = "312d507407a1bf6f01ae43fb41b5c3a3700d053c"
30
+
31
+ _CAPABILITY_CONFIGS = {
32
+ WARPX_CPU_CAPABILITY: "warpx-cpu-26.07.json",
33
+ WARPX_CUDA_CAPABILITY: "warpx-cuda-openpmd-26.07.json",
34
+ }
35
+ _CORE_DISTRIBUTIONS = (
36
+ "simjecture",
37
+ "httpx",
38
+ "matplotlib",
39
+ "numpy",
40
+ "pandas",
41
+ "pydantic",
42
+ "scipy",
43
+ )
44
+
45
+
46
+ class DeploymentProfile(StrEnum):
47
+ CORE = "core"
48
+ WARPX_CPU = "warpx-cpu"
49
+ WARPX_CUDA = "warpx-cuda"
50
+
51
+
52
+ _PROFILE_BY_CAPABILITY = {
53
+ WARPX_CPU_CAPABILITY: DeploymentProfile.WARPX_CPU,
54
+ WARPX_CUDA_CAPABILITY: DeploymentProfile.WARPX_CUDA,
55
+ }
56
+
57
+
58
+ class DeploymentCheckStatus(StrEnum):
59
+ PASS = "pass"
60
+ FAIL = "fail"
61
+ WARNING = "warning"
62
+ PLANNED = "planned"
63
+
64
+
65
+ class DeploymentCheck(StrictModel):
66
+ name: str = Field(min_length=1)
67
+ status: DeploymentCheckStatus
68
+ required: bool = True
69
+ detail: str = Field(min_length=1)
70
+ remedy: str | None = None
71
+
72
+
73
+ class DeploymentReport(StrictModel):
74
+ schema_version: Literal["0.1.0"] = "0.1.0"
75
+ profile: str = Field(min_length=1)
76
+ project_root: str = Field(min_length=1)
77
+ ready: bool
78
+ planned: bool = False
79
+ changed: bool = False
80
+ command: tuple[str, ...] = ()
81
+ checks: tuple[DeploymentCheck, ...]
82
+
83
+
84
+ def resolve_project_root(explicit: str | Path | None = None) -> Path:
85
+ """Find the source checkout that owns deployment assets."""
86
+
87
+ if explicit is not None:
88
+ candidates = (Path(explicit).resolve(),)
89
+ else:
90
+ current = Path.cwd().resolve()
91
+ source_fallback = Path(__file__).resolve().parents[2]
92
+ candidates = (current, *current.parents, source_fallback)
93
+
94
+ seen: set[Path] = set()
95
+ for candidate in candidates:
96
+ if candidate in seen:
97
+ continue
98
+ seen.add(candidate)
99
+ if all(
100
+ (candidate / marker).exists()
101
+ for marker in ("pyproject.toml", "skills", "capabilities", "environments")
102
+ ):
103
+ return candidate
104
+ requested = str(explicit) if explicit is not None else str(Path.cwd())
105
+ raise FileNotFoundError(
106
+ "deployment requires a source checkout containing pyproject.toml, skills/, "
107
+ f"capabilities/, and environments/; none was found from {requested}"
108
+ )
109
+
110
+
111
+ def _check(
112
+ name: str,
113
+ status: DeploymentCheckStatus,
114
+ detail: str,
115
+ *,
116
+ required: bool = True,
117
+ remedy: str | None = None,
118
+ ) -> DeploymentCheck:
119
+ return DeploymentCheck(
120
+ name=name,
121
+ status=status,
122
+ required=required,
123
+ detail=" ".join(detail.split()),
124
+ remedy=" ".join(remedy.split()) if remedy else None,
125
+ )
126
+
127
+
128
+ def _nested_value(payload: object, dotted_path: str) -> object:
129
+ current = payload
130
+ for part in dotted_path.split("."):
131
+ if not isinstance(current, dict) or part not in current:
132
+ raise KeyError(dotted_path)
133
+ current = current[part]
134
+ return current
135
+
136
+
137
+ class DeploymentManager:
138
+ """Provision project-local runtimes without granting authority to an agent."""
139
+
140
+ def __init__(self, project_root: str | Path) -> None:
141
+ self.project_root = resolve_project_root(project_root)
142
+ self.capability_root = self.project_root / "capabilities"
143
+ self.runtime_root = self.project_root / ".runtime"
144
+
145
+ def _core_checks(self, *, probe: bool) -> list[DeploymentCheck]:
146
+ checks: list[DeploymentCheck] = []
147
+ supported_python = sys.version_info >= (3, 11)
148
+ checks.append(
149
+ _check(
150
+ "core.python",
151
+ DeploymentCheckStatus.PASS if supported_python else DeploymentCheckStatus.FAIL,
152
+ f"Python {sys.version.split()[0]} at {Path(sys.executable).resolve()}",
153
+ remedy="Install Python 3.11 or newer." if not supported_python else None,
154
+ )
155
+ )
156
+ linux = sys.platform.startswith("linux")
157
+ checks.append(
158
+ _check(
159
+ "core.platform",
160
+ DeploymentCheckStatus.PASS if linux else DeploymentCheckStatus.FAIL,
161
+ f"platform={sys.platform}",
162
+ remedy="The Bubblewrap MVP currently requires Linux." if not linux else None,
163
+ )
164
+ )
165
+ lock_path = self.project_root / "uv.lock"
166
+ locked_versions: dict[str, set[str]] = {}
167
+ try:
168
+ lock = tomllib.loads(lock_path.read_text())
169
+ packages = lock["package"]
170
+ if not isinstance(packages, list):
171
+ raise ValueError("uv.lock package table is not a list")
172
+ for package in packages:
173
+ if not isinstance(package, dict):
174
+ raise ValueError("uv.lock contains a non-table package")
175
+ name = str(package["name"])
176
+ version = str(package["version"])
177
+ locked_versions.setdefault(name, set()).add(version)
178
+ except (OSError, KeyError, TypeError, ValueError, tomllib.TOMLDecodeError) as error:
179
+ checks.append(
180
+ _check(
181
+ "core.lock",
182
+ DeploymentCheckStatus.FAIL,
183
+ f"cannot read the project lock: {error}",
184
+ remedy="Restore uv.lock and run `uv sync --frozen`.",
185
+ )
186
+ )
187
+ else:
188
+ checks.append(
189
+ _check(
190
+ "core.lock",
191
+ DeploymentCheckStatus.PASS,
192
+ f"loaded {len(packages)} locked package record(s) from {lock_path}",
193
+ )
194
+ )
195
+ for distribution in _CORE_DISTRIBUTIONS:
196
+ try:
197
+ version = metadata.version(distribution)
198
+ except metadata.PackageNotFoundError:
199
+ checks.append(
200
+ _check(
201
+ f"core.package.{distribution}",
202
+ DeploymentCheckStatus.FAIL,
203
+ f"{distribution} is not installed",
204
+ remedy="Install the locked project environment with `uv sync --frozen`.",
205
+ )
206
+ )
207
+ else:
208
+ expected = locked_versions.get(distribution, set())
209
+ matches_lock = version in expected
210
+ checks.append(
211
+ _check(
212
+ f"core.package.{distribution}",
213
+ (
214
+ DeploymentCheckStatus.PASS
215
+ if matches_lock
216
+ else DeploymentCheckStatus.FAIL
217
+ ),
218
+ (
219
+ f"{distribution}={version}; "
220
+ f"locked={','.join(sorted(expected)) or 'missing'}"
221
+ ),
222
+ remedy=(
223
+ "Synchronize the exact environment with `uv sync --frozen`."
224
+ if not matches_lock
225
+ else None
226
+ ),
227
+ )
228
+ )
229
+
230
+ bubblewrap = shutil.which("bwrap")
231
+ checks.append(
232
+ _check(
233
+ "core.bubblewrap",
234
+ DeploymentCheckStatus.PASS if bubblewrap else DeploymentCheckStatus.FAIL,
235
+ f"bubblewrap={bubblewrap}" if bubblewrap else "bubblewrap is unavailable",
236
+ remedy=(
237
+ "Install the Bubblewrap operating-system package before autonomous runs."
238
+ if not bubblewrap
239
+ else None
240
+ ),
241
+ )
242
+ )
243
+ if bubblewrap and probe:
244
+ command = [
245
+ bubblewrap,
246
+ "--unshare-all",
247
+ "--die-with-parent",
248
+ "--new-session",
249
+ ]
250
+ for system_root in ("/usr", "/lib", "/lib64"):
251
+ if Path(system_root).exists():
252
+ command.extend(("--ro-bind", system_root, system_root))
253
+ command.extend(("--dev", "/dev", "--proc", "/proc", "/usr/bin/true"))
254
+ try:
255
+ result = subprocess.run(
256
+ command,
257
+ check=False,
258
+ capture_output=True,
259
+ text=True,
260
+ timeout=20,
261
+ )
262
+ except (OSError, subprocess.SubprocessError) as error:
263
+ result_detail = str(error)
264
+ returncode = 1
265
+ else:
266
+ result_detail = result.stderr.strip() or "namespace creation succeeded"
267
+ returncode = result.returncode
268
+ checks.append(
269
+ _check(
270
+ "core.bubblewrap_namespace",
271
+ (
272
+ DeploymentCheckStatus.PASS
273
+ if returncode == 0
274
+ else DeploymentCheckStatus.FAIL
275
+ ),
276
+ result_detail,
277
+ remedy=(
278
+ "Enable unprivileged user namespaces for Bubblewrap on this host."
279
+ if returncode != 0
280
+ else None
281
+ ),
282
+ )
283
+ )
284
+ return checks
285
+
286
+ def _capability_config_path(self, capability: str) -> Path:
287
+ try:
288
+ name = _CAPABILITY_CONFIGS[capability]
289
+ except KeyError as error:
290
+ raise ValueError(f"unknown deployment capability {capability!r}") from error
291
+ return self.capability_root / name
292
+
293
+ def _configured_runtime_root(self, capability: str) -> Path:
294
+ path = self._capability_config_path(capability)
295
+ config = MVPCapabilityConfig.model_validate_json(path.read_bytes())
296
+ return (path.parent / config.runtime_root).resolve()
297
+
298
+ def _skill_resource(self, installation: MVPCapabilityInstallation) -> Path:
299
+ relative = Path(installation.manifest.preflight_resource or "")
300
+ path = self.project_root / "skills" / installation.manifest.skill / relative
301
+ if not path.is_file():
302
+ raise FileNotFoundError(f"capability preflight resource is unavailable: {path}")
303
+ return path
304
+
305
+ def _probe_capability(self, installation: MVPCapabilityInstallation) -> str:
306
+ manifest = installation.manifest
307
+ if not manifest.preflight_resource or not manifest.preflight_result:
308
+ return "capability has no declared preflight"
309
+ source = self._skill_resource(installation)
310
+ with tempfile.TemporaryDirectory(prefix="acs-capability-doctor-") as temporary:
311
+ workspace = Path(temporary)
312
+ program = workspace / manifest.preflight_resource
313
+ program.parent.mkdir(parents=True, exist_ok=True)
314
+ shutil.copy2(source, program)
315
+ registry = MVPCapabilityRegistry((installation,))
316
+ sandbox = BubblewrapSandbox(
317
+ workspace,
318
+ MVPAgentConfig(
319
+ max_iterations=1,
320
+ max_wall_seconds=180,
321
+ max_command_seconds=120,
322
+ max_workspace_bytes=128 * 1024 * 1024,
323
+ max_file_bytes=64 * 1024 * 1024,
324
+ max_memory_bytes=4 * 1024 * 1024 * 1024,
325
+ ),
326
+ registry,
327
+ )
328
+ result = sandbox.run_capability(
329
+ manifest.name,
330
+ (manifest.preflight_resource,),
331
+ timeout_seconds=120,
332
+ )
333
+ if result.returncode != 0:
334
+ detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic output"
335
+ raise RuntimeError(
336
+ f"preflight command exited with {result.returncode}: {detail}"
337
+ )
338
+ result_path = workspace / manifest.preflight_result
339
+ if not result_path.is_file():
340
+ raise RuntimeError(
341
+ f"preflight did not create {manifest.preflight_result!r}"
342
+ )
343
+ payload = json.loads(result_path.read_text())
344
+ failed = [
345
+ path
346
+ for path in manifest.preflight_checks
347
+ if _nested_value(payload, path) is not True
348
+ ]
349
+ if failed:
350
+ raise RuntimeError(f"preflight checks were false: {', '.join(failed)}")
351
+ return (
352
+ f"{manifest.name} passed {len(manifest.preflight_checks)} declared "
353
+ "preflight check(s)"
354
+ )
355
+
356
+ def _capability_checks(
357
+ self,
358
+ capability: str,
359
+ *,
360
+ required: bool,
361
+ probe: bool,
362
+ ) -> tuple[list[DeploymentCheck], MVPCapabilityInstallation | None]:
363
+ status_on_failure = (
364
+ DeploymentCheckStatus.FAIL if required else DeploymentCheckStatus.WARNING
365
+ )
366
+ config_path = self._capability_config_path(capability)
367
+ if not config_path.is_file():
368
+ return (
369
+ [
370
+ _check(
371
+ f"capability.{capability}.configuration",
372
+ status_on_failure,
373
+ f"missing capability manifest {config_path}",
374
+ required=required,
375
+ remedy="Restore the versioned capability manifest from the repository.",
376
+ )
377
+ ],
378
+ None,
379
+ )
380
+ try:
381
+ installation = MVPCapabilityInstallation.read(config_path)
382
+ except (OSError, ValueError) as error:
383
+ return (
384
+ [
385
+ _check(
386
+ f"capability.{capability}.runtime",
387
+ status_on_failure,
388
+ str(error),
389
+ required=required,
390
+ remedy=(
391
+ "Run `simjecture install "
392
+ f"{_PROFILE_BY_CAPABILITY[capability].value}`."
393
+ ),
394
+ )
395
+ ],
396
+ None,
397
+ )
398
+ checks = [
399
+ _check(
400
+ f"capability.{capability}.runtime",
401
+ DeploymentCheckStatus.PASS,
402
+ (
403
+ f"runtime={installation.runtime_root}; "
404
+ f"contract_sha256={installation.contract_hash}"
405
+ ),
406
+ required=required,
407
+ )
408
+ ]
409
+ if probe:
410
+ try:
411
+ detail = self._probe_capability(installation)
412
+ except (OSError, ValueError, KeyError, RuntimeError, json.JSONDecodeError) as error:
413
+ if capability == WARPX_CPU_CAPABILITY:
414
+ remedy = (
415
+ "Inspect the runtime and rerun the CPU profile installer with --repair."
416
+ )
417
+ else:
418
+ remedy = (
419
+ "Preserve or move the CUDA runtime and dependency roots, then perform "
420
+ "a fresh audited installation."
421
+ )
422
+ checks.append(
423
+ _check(
424
+ f"capability.{capability}.preflight",
425
+ status_on_failure,
426
+ str(error),
427
+ required=required,
428
+ remedy=remedy,
429
+ )
430
+ )
431
+ else:
432
+ checks.append(
433
+ _check(
434
+ f"capability.{capability}.preflight",
435
+ DeploymentCheckStatus.PASS,
436
+ detail,
437
+ required=required,
438
+ )
439
+ )
440
+ return checks, installation
441
+
442
+ @staticmethod
443
+ def _ready(checks: list[DeploymentCheck]) -> bool:
444
+ return not any(
445
+ item.required and item.status is DeploymentCheckStatus.FAIL for item in checks
446
+ )
447
+
448
+ def doctor(self, profile: str = "all", *, probe: bool = True) -> DeploymentReport:
449
+ if profile not in {"all", *(item.value for item in DeploymentProfile)}:
450
+ raise ValueError(f"unknown deployment profile {profile!r}")
451
+ checks = self._core_checks(probe=probe)
452
+ capabilities: tuple[tuple[str, bool], ...]
453
+ if profile == DeploymentProfile.WARPX_CPU:
454
+ capabilities = ((WARPX_CPU_CAPABILITY, True),)
455
+ elif profile == DeploymentProfile.WARPX_CUDA:
456
+ capabilities = ((WARPX_CUDA_CAPABILITY, True),)
457
+ elif profile == "all":
458
+ capabilities = (
459
+ (WARPX_CPU_CAPABILITY, False),
460
+ (WARPX_CUDA_CAPABILITY, False),
461
+ )
462
+ else:
463
+ capabilities = ()
464
+ for capability, required in capabilities:
465
+ capability_checks, _ = self._capability_checks(
466
+ capability,
467
+ required=required,
468
+ probe=probe,
469
+ )
470
+ checks.extend(capability_checks)
471
+ return DeploymentReport(
472
+ profile=profile,
473
+ project_root=str(self.project_root),
474
+ ready=self._ready(checks),
475
+ checks=tuple(checks),
476
+ )
477
+
478
+ def _write_report(self, report: DeploymentReport) -> None:
479
+ directory = self.runtime_root / "deployment"
480
+ directory.mkdir(parents=True, exist_ok=True)
481
+ destination = directory / f"{report.profile}.json"
482
+ temporary = destination.with_suffix(".json.tmp")
483
+ temporary.write_text(report.model_dump_json(indent=2) + "\n")
484
+ os.replace(temporary, destination)
485
+
486
+ def _already_ready(self, profile: DeploymentProfile) -> DeploymentReport | None:
487
+ report = self.doctor(profile.value, probe=True)
488
+ return report if report.ready else None
489
+
490
+ def _install_cpu(
491
+ self,
492
+ *,
493
+ dry_run: bool,
494
+ repair: bool,
495
+ environment_manager: str | None,
496
+ capture_output: bool,
497
+ ) -> DeploymentReport:
498
+ ready = self._already_ready(DeploymentProfile.WARPX_CPU)
499
+ if ready is not None:
500
+ self._write_report(ready)
501
+ return ready
502
+ checks = self._core_checks(probe=True)
503
+ if not self._ready(checks):
504
+ return DeploymentReport(
505
+ profile=DeploymentProfile.WARPX_CPU,
506
+ project_root=str(self.project_root),
507
+ ready=False,
508
+ checks=tuple(checks),
509
+ )
510
+ prefix = self._configured_runtime_root(WARPX_CPU_CAPABILITY)
511
+ managed_prefix = (prefix / "conda-meta").is_dir()
512
+ if prefix.exists() and not repair:
513
+ checks.append(
514
+ _check(
515
+ "install.warpx-cpu.existing_runtime",
516
+ DeploymentCheckStatus.FAIL,
517
+ f"an existing but unhealthy runtime was left unchanged at {prefix}",
518
+ remedy="Inspect it, then rerun with --repair to update a Conda-managed prefix.",
519
+ )
520
+ )
521
+ return DeploymentReport(
522
+ profile=DeploymentProfile.WARPX_CPU,
523
+ project_root=str(self.project_root),
524
+ ready=False,
525
+ checks=tuple(checks),
526
+ )
527
+ if prefix.exists() and repair and not managed_prefix:
528
+ checks.append(
529
+ _check(
530
+ "install.warpx-cpu.existing_runtime",
531
+ DeploymentCheckStatus.FAIL,
532
+ f"refusing to modify a non-Conda directory at {prefix}",
533
+ remedy="Move the directory aside and run the installer again.",
534
+ )
535
+ )
536
+ return DeploymentReport(
537
+ profile=DeploymentProfile.WARPX_CPU,
538
+ project_root=str(self.project_root),
539
+ ready=False,
540
+ checks=tuple(checks),
541
+ )
542
+ manager = (
543
+ shutil.which(environment_manager)
544
+ if environment_manager
545
+ else shutil.which("micromamba") or shutil.which("mamba")
546
+ )
547
+ if not manager:
548
+ checks.append(
549
+ _check(
550
+ "install.warpx-cpu.environment_manager",
551
+ DeploymentCheckStatus.FAIL,
552
+ "neither micromamba nor mamba is available",
553
+ remedy="Install Miniforge, Mambaforge, or Micromamba.",
554
+ )
555
+ )
556
+ return DeploymentReport(
557
+ profile=DeploymentProfile.WARPX_CPU,
558
+ project_root=str(self.project_root),
559
+ ready=False,
560
+ checks=tuple(checks),
561
+ )
562
+ environment_file = self.project_root / "environments" / "warpx-cpu.yml"
563
+ action = "update" if managed_prefix and repair else "create"
564
+ if action == "update":
565
+ command = (
566
+ manager,
567
+ "env",
568
+ "update",
569
+ "--yes",
570
+ "--prefix",
571
+ str(prefix),
572
+ "--file",
573
+ str(environment_file),
574
+ "--prune",
575
+ )
576
+ else:
577
+ command = (
578
+ manager,
579
+ "create",
580
+ "--yes",
581
+ "--prefix",
582
+ str(prefix),
583
+ "--file",
584
+ str(environment_file),
585
+ )
586
+ if dry_run:
587
+ checks.append(
588
+ _check(
589
+ "install.warpx-cpu.command",
590
+ DeploymentCheckStatus.PLANNED,
591
+ " ".join(command),
592
+ )
593
+ )
594
+ return DeploymentReport(
595
+ profile=DeploymentProfile.WARPX_CPU,
596
+ project_root=str(self.project_root),
597
+ ready=False,
598
+ planned=True,
599
+ command=command,
600
+ checks=tuple(checks),
601
+ )
602
+ result = subprocess.run(
603
+ command,
604
+ check=False,
605
+ text=True,
606
+ capture_output=capture_output,
607
+ )
608
+ if result.returncode != 0:
609
+ detail = result.stderr.strip() if capture_output else "environment manager failed"
610
+ checks.append(
611
+ _check(
612
+ "install.warpx-cpu.command",
613
+ DeploymentCheckStatus.FAIL,
614
+ detail or "environment manager failed without diagnostic output",
615
+ remedy="Review the Conda solver output; the existing prefix was not deleted.",
616
+ )
617
+ )
618
+ return DeploymentReport(
619
+ profile=DeploymentProfile.WARPX_CPU,
620
+ project_root=str(self.project_root),
621
+ ready=False,
622
+ command=command,
623
+ checks=tuple(checks),
624
+ )
625
+ report = self.doctor(DeploymentProfile.WARPX_CPU, probe=True)
626
+ report = report.model_copy(update={"changed": True, "command": command})
627
+ self._write_report(report)
628
+ return report
629
+
630
+ def _source_revision_check(self, source: Path) -> DeploymentCheck:
631
+ git = shutil.which("git")
632
+ if not source.is_dir():
633
+ return _check(
634
+ "install.warpx-cuda.source",
635
+ DeploymentCheckStatus.FAIL,
636
+ f"WarpX source directory is unavailable: {source}",
637
+ remedy=f"Check out WarpX revision {PINNED_WARPX_REVISION}.",
638
+ )
639
+ if not git:
640
+ return _check(
641
+ "install.warpx-cuda.source",
642
+ DeploymentCheckStatus.FAIL,
643
+ "git is unavailable, so the WarpX source revision cannot be verified",
644
+ remedy="Install Git and use the audited WarpX source checkout.",
645
+ )
646
+ result = subprocess.run(
647
+ (git, "-C", str(source), "rev-parse", "HEAD"),
648
+ check=False,
649
+ capture_output=True,
650
+ text=True,
651
+ )
652
+ observed = result.stdout.strip()
653
+ if result.returncode != 0 or observed != PINNED_WARPX_REVISION:
654
+ return _check(
655
+ "install.warpx-cuda.source",
656
+ DeploymentCheckStatus.FAIL,
657
+ f"observed source revision={observed or 'unavailable'}",
658
+ remedy=f"Use the audited WarpX revision {PINNED_WARPX_REVISION}.",
659
+ )
660
+ return _check(
661
+ "install.warpx-cuda.source",
662
+ DeploymentCheckStatus.PASS,
663
+ f"WarpX source revision={observed}",
664
+ )
665
+
666
+ def _install_cuda(
667
+ self,
668
+ *,
669
+ source: str | Path | None,
670
+ jobs: int,
671
+ arch: str | None,
672
+ dry_run: bool,
673
+ capture_output: bool,
674
+ ) -> DeploymentReport:
675
+ ready = self._already_ready(DeploymentProfile.WARPX_CUDA)
676
+ if ready is not None:
677
+ self._write_report(ready)
678
+ return ready
679
+ checks = self._core_checks(probe=True)
680
+ prefix = self._configured_runtime_root(WARPX_CUDA_CAPABILITY)
681
+ cuda_roots = (
682
+ prefix,
683
+ self.runtime_root / "cuda-toolkit-12.4",
684
+ self.runtime_root / "warpx-cuda-openpmd-deps",
685
+ )
686
+ existing_roots = tuple(path for path in cuda_roots if path.exists())
687
+ if existing_roots:
688
+ checks.append(
689
+ _check(
690
+ "install.warpx-cuda.existing_runtime",
691
+ DeploymentCheckStatus.FAIL,
692
+ (
693
+ "existing but unhealthy CUDA deployment roots were left unchanged: "
694
+ + ", ".join(str(path) for path in existing_roots)
695
+ ),
696
+ remedy=(
697
+ "Move the runtime and its project-local dependency prefixes aside, "
698
+ "then run a fresh audited installation."
699
+ ),
700
+ )
701
+ )
702
+ if source is None:
703
+ checks.append(
704
+ _check(
705
+ "install.warpx-cuda.source",
706
+ DeploymentCheckStatus.FAIL,
707
+ "--source is required for a new CUDA build",
708
+ remedy=f"Supply a WarpX checkout at revision {PINNED_WARPX_REVISION}.",
709
+ )
710
+ )
711
+ else:
712
+ checks.append(self._source_revision_check(Path(source).resolve()))
713
+ if jobs < 1:
714
+ checks.append(
715
+ _check(
716
+ "install.warpx-cuda.jobs",
717
+ DeploymentCheckStatus.FAIL,
718
+ f"jobs={jobs}",
719
+ remedy="Choose at least one build job.",
720
+ )
721
+ )
722
+ if arch is not None and not arch.isdigit():
723
+ checks.append(
724
+ _check(
725
+ "install.warpx-cuda.arch",
726
+ DeploymentCheckStatus.FAIL,
727
+ f"invalid CUDA architecture={arch!r}",
728
+ remedy="Use digits such as 89, or omit --arch for detection.",
729
+ )
730
+ )
731
+ if not self._ready(checks):
732
+ return DeploymentReport(
733
+ profile=DeploymentProfile.WARPX_CUDA,
734
+ project_root=str(self.project_root),
735
+ ready=False,
736
+ checks=tuple(checks),
737
+ )
738
+ assert source is not None
739
+ bootstrap = self.project_root / "skills" / "warpx" / "scripts" / "bootstrap_local_cuda.sh"
740
+ if not bootstrap.is_file() or not os.access(bootstrap, os.X_OK):
741
+ checks.append(
742
+ _check(
743
+ "install.warpx-cuda.bootstrap",
744
+ DeploymentCheckStatus.FAIL,
745
+ f"CUDA bootstrap is unavailable or not executable: {bootstrap}",
746
+ remedy="Restore the versioned WarpX skill scripts.",
747
+ )
748
+ )
749
+ return DeploymentReport(
750
+ profile=DeploymentProfile.WARPX_CUDA,
751
+ project_root=str(self.project_root),
752
+ ready=False,
753
+ checks=tuple(checks),
754
+ )
755
+ command = (
756
+ str(bootstrap),
757
+ "--source",
758
+ str(Path(source).resolve()),
759
+ "--jobs",
760
+ str(jobs),
761
+ *(("--arch", arch) if arch is not None else ()),
762
+ )
763
+ if dry_run:
764
+ checks.append(
765
+ _check(
766
+ "install.warpx-cuda.command",
767
+ DeploymentCheckStatus.PLANNED,
768
+ " ".join(command),
769
+ )
770
+ )
771
+ return DeploymentReport(
772
+ profile=DeploymentProfile.WARPX_CUDA,
773
+ project_root=str(self.project_root),
774
+ ready=False,
775
+ planned=True,
776
+ command=command,
777
+ checks=tuple(checks),
778
+ )
779
+ result = subprocess.run(
780
+ command,
781
+ check=False,
782
+ text=True,
783
+ capture_output=capture_output,
784
+ )
785
+ if result.returncode != 0:
786
+ detail = result.stderr.strip() if capture_output else "CUDA bootstrap failed"
787
+ checks.append(
788
+ _check(
789
+ "install.warpx-cuda.command",
790
+ DeploymentCheckStatus.FAIL,
791
+ detail or "CUDA bootstrap failed without diagnostic output",
792
+ remedy="Preserve the build log and inspect the documented failure signatures.",
793
+ )
794
+ )
795
+ return DeploymentReport(
796
+ profile=DeploymentProfile.WARPX_CUDA,
797
+ project_root=str(self.project_root),
798
+ ready=False,
799
+ command=command,
800
+ checks=tuple(checks),
801
+ )
802
+ report = self.doctor(DeploymentProfile.WARPX_CUDA, probe=True)
803
+ report = report.model_copy(update={"changed": True, "command": command})
804
+ self._write_report(report)
805
+ return report
806
+
807
+ def install(
808
+ self,
809
+ profile: DeploymentProfile,
810
+ *,
811
+ dry_run: bool = False,
812
+ repair: bool = False,
813
+ environment_manager: str | None = None,
814
+ source: str | Path | None = None,
815
+ jobs: int = 8,
816
+ arch: str | None = None,
817
+ capture_output: bool = False,
818
+ ) -> DeploymentReport:
819
+ if profile is DeploymentProfile.CORE:
820
+ report = self.doctor(DeploymentProfile.CORE, probe=True)
821
+ if not dry_run:
822
+ self._write_report(report)
823
+ return report
824
+ if profile is DeploymentProfile.WARPX_CPU:
825
+ return self._install_cpu(
826
+ dry_run=dry_run,
827
+ repair=repair,
828
+ environment_manager=environment_manager,
829
+ capture_output=capture_output,
830
+ )
831
+ if repair:
832
+ checks = self._core_checks(probe=True)
833
+ checks.append(
834
+ _check(
835
+ "install.warpx-cuda.repair",
836
+ DeploymentCheckStatus.FAIL,
837
+ "automatic in-place CUDA repair is intentionally unavailable",
838
+ remedy=(
839
+ "Preserve or move the existing instrument and dependency roots, "
840
+ "then perform a fresh audited build."
841
+ ),
842
+ )
843
+ )
844
+ return DeploymentReport(
845
+ profile=DeploymentProfile.WARPX_CUDA,
846
+ project_root=str(self.project_root),
847
+ ready=False,
848
+ checks=tuple(checks),
849
+ )
850
+ return self._install_cuda(
851
+ source=source,
852
+ jobs=jobs,
853
+ arch=arch,
854
+ dry_run=dry_run,
855
+ capture_output=capture_output,
856
+ )
857
+
858
+
859
+ def print_deployment_report(report: DeploymentReport, *, as_json: bool) -> None:
860
+ if as_json:
861
+ print(report.model_dump_json(indent=2))
862
+ return
863
+ print(f"profile={report.profile}")
864
+ print(f"ready={str(report.ready).lower()}")
865
+ print(f"planned={str(report.planned).lower()}")
866
+ print(f"changed={str(report.changed).lower()}")
867
+ print(f"project_root={report.project_root}")
868
+ for item in report.checks:
869
+ print(
870
+ f"check={item.name} status={item.status.value} "
871
+ f"required={str(item.required).lower()} detail={item.detail}"
872
+ )
873
+ if item.remedy:
874
+ print(f"remedy={item.name}: {item.remedy}")