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,1375 @@
1
+ """Restricted WarpX/PICMI compiler and execution boundary.
2
+
3
+ The model-facing surface is a small set of validated scalar parameters. This
4
+ module, not a language model, owns the executable PICMI template and the
5
+ result-validity rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import platform
15
+ import subprocess
16
+ from collections.abc import Sequence
17
+ from datetime import datetime
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from statistics import median
21
+ from typing import Any, Literal
22
+
23
+ from pydantic import Field, model_validator
24
+
25
+ from ..models import ExperimentSpec, StrictModel, utc_now
26
+ from ..search import SymmetricMixtureCandidate
27
+ from .base import (
28
+ CapabilityManifest,
29
+ CostEstimate,
30
+ JobReference,
31
+ JobState,
32
+ JobStatus,
33
+ NormalizedResult,
34
+ RawResult,
35
+ RunPackage,
36
+ ValidationReport,
37
+ )
38
+
39
+ ELECTRON_CHARGE_C = 1.602176634e-19
40
+ ELECTRON_MASS_KG = 9.1093837139e-31
41
+ VACUUM_PERMITTIVITY_F_M = 8.8541878128e-12
42
+ SPEED_OF_LIGHT_M_S = 299792458.0
43
+
44
+ WARPX_ADAPTER_VERSION = "0.3.0"
45
+ WARPX_CONTRACT_VERSION = "warpx_picmi_pair_v1"
46
+ WARPX_ACTION = "warpx_kinetic_sufficiency_confirmation"
47
+ WARPX_MODEL = "warpx_electrostatic_1d3v_pic"
48
+ WARPX_REQUIRED_DIAGNOSTICS = (
49
+ "dominant_linear_mode",
50
+ "distribution_moments",
51
+ "field_energy",
52
+ "gauss_residual",
53
+ "openpmd_electric_field",
54
+ "particle_energy",
55
+ )
56
+
57
+
58
+ def _canonical_json(value: Any) -> str:
59
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
60
+
61
+
62
+ def _sha256_text(value: str) -> str:
63
+ return hashlib.sha256(value.encode()).hexdigest()
64
+
65
+
66
+ def _warpx_versions_equivalent(left: str, right: str) -> bool:
67
+ """Treat release spellings such as 26.07 and Python metadata 26.7 as equal."""
68
+
69
+ try:
70
+ return tuple(int(part) for part in left.split(".")) == tuple(
71
+ int(part) for part in right.split(".")
72
+ )
73
+ except ValueError:
74
+ return left == right
75
+
76
+
77
+ class WarpXRunnerKind(StrEnum):
78
+ CONTRACT_FIXTURE = "contract_fixture"
79
+ LOCAL_CPU = "local_cpu"
80
+ SITE_SCHEDULER = "site_scheduler"
81
+
82
+
83
+ class WarpXExecutionProfile(StrictModel):
84
+ """Identity and trust status of the process that executes a run package."""
85
+
86
+ profile_id: str = Field(min_length=1)
87
+ runner_kind: WarpXRunnerKind
88
+ warpx_version: str = Field(min_length=1)
89
+ openpmd_backend: Literal["h5"] = "h5"
90
+ qualification_hash: str | None = None
91
+ qualified_for_scientific_evidence: bool = False
92
+
93
+ @model_validator(mode="after")
94
+ def qualification_is_coherent(self) -> WarpXExecutionProfile:
95
+ if self.runner_kind is WarpXRunnerKind.CONTRACT_FIXTURE:
96
+ if self.qualified_for_scientific_evidence or self.qualification_hash is not None:
97
+ raise ValueError("a contract fixture can never be qualified as scientific evidence")
98
+ elif self.qualified_for_scientific_evidence and self.qualification_hash is None:
99
+ raise ValueError("an evidence-qualified profile requires a qualification hash")
100
+ return self
101
+
102
+
103
+ class WarpXPhysicalConfig(StrictModel):
104
+ reference_density_m3: float = Field(default=1.0e18, ge=1.0e12, le=1.0e25)
105
+ velocity_unit_m_s: float = Field(default=1.0e6, ge=1.0e3, le=2.0e7)
106
+ wavenumber_dimensionless: float = Field(default=0.5, ge=0.1, le=2.0)
107
+ perturbation_amplitude: float = Field(default=0.02, ge=1.0e-7, le=0.05)
108
+ inner_pair_weight: float = Field(default=0.5, gt=0, lt=1)
109
+ inner_drift: float = Field(default=0.95, ge=0, lt=1)
110
+ outer_drift: float = Field(default=0.95, ge=0, lt=1)
111
+
112
+ @model_validator(mode="after")
113
+ def candidate_is_safe(self) -> WarpXPhysicalConfig:
114
+ candidate = self.candidate
115
+ if (candidate.outer_drift + 6.0 * candidate.thermal_sigma) * self.velocity_unit_m_s > (
116
+ 0.25 * SPEED_OF_LIGHT_M_S
117
+ ):
118
+ raise ValueError("the declared nonrelativistic distribution extends above 0.25 c")
119
+ return self
120
+
121
+ @property
122
+ def candidate(self) -> SymmetricMixtureCandidate:
123
+ return SymmetricMixtureCandidate(
124
+ inner_pair_weight=self.inner_pair_weight,
125
+ inner_drift=self.inner_drift,
126
+ outer_drift=self.outer_drift,
127
+ )
128
+
129
+ @property
130
+ def plasma_frequency_rad_s(self) -> float:
131
+ return math.sqrt(
132
+ self.reference_density_m3
133
+ * ELECTRON_CHARGE_C**2
134
+ / (ELECTRON_MASS_KG * VACUUM_PERMITTIVITY_F_M)
135
+ )
136
+
137
+ @property
138
+ def wavenumber_m_inverse(self) -> float:
139
+ return self.wavenumber_dimensionless * self.plasma_frequency_rad_s / self.velocity_unit_m_s
140
+
141
+ @property
142
+ def domain_length_m(self) -> float:
143
+ return 2.0 * math.pi / self.wavenumber_m_inverse
144
+
145
+
146
+ class WarpXNumericalConfig(StrictModel):
147
+ grid_cells: int = Field(default=64, ge=32, le=4096)
148
+ electron_macroparticles_per_cell: int = Field(default=512, ge=16, le=8192)
149
+ ion_macroparticles_per_cell: int = Field(default=16, ge=1, le=1024)
150
+ time_step_omega_pe: float = Field(default=0.05, ge=0.001, le=0.2)
151
+ final_time_omega_pe: float = Field(default=20.0, ge=1.0, le=500.0)
152
+ diagnostic_interval_steps: int = Field(default=2, ge=1, le=10000)
153
+ random_seed: int = Field(default=1, ge=1, le=2_147_483_647)
154
+ poisson_relative_tolerance: float = Field(default=1.0e-10, ge=1.0e-14, le=1.0e-4)
155
+ poisson_max_iterations: int = Field(default=200, ge=10, le=10000)
156
+ minimum_diagnostic_samples: int = Field(default=50, ge=3, le=10000)
157
+ early_window_start_omega_pe: float = Field(default=0.0, ge=0)
158
+ early_window_end_omega_pe: float = Field(default=4.0, gt=0)
159
+ late_window_start_omega_pe: float = Field(default=14.0, gt=0)
160
+ late_window_end_omega_pe: float = Field(default=18.0, gt=0)
161
+ minimum_window_samples: int = Field(default=20, ge=3, le=10000)
162
+ damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
163
+ unstable_ratio_threshold: float = Field(default=1.5, gt=1)
164
+ maximum_relative_energy_drift: float = Field(default=0.01, gt=0, le=1.0)
165
+ maximum_relative_gauss_residual: float = Field(default=0.01, gt=0, le=1.0)
166
+ maximum_relative_charge_imbalance: float = Field(default=0.01, gt=0, le=1.0)
167
+ moment_tolerance: float = Field(default=0.05, gt=0, le=0.5)
168
+ outcome_tolerance_omega_pe: float = Field(default=0.02, gt=0, le=1.0)
169
+
170
+ @model_validator(mode="after")
171
+ def discrete_layout_is_valid(self) -> WarpXNumericalConfig:
172
+ if self.grid_cells & (self.grid_cells - 1):
173
+ raise ValueError("grid_cells must be a power of two")
174
+ if self.electron_macroparticles_per_cell % 4:
175
+ raise ValueError("electron_macroparticles_per_cell must be divisible by four")
176
+ steps = self.final_time_omega_pe / self.time_step_omega_pe
177
+ if abs(steps - round(steps)) > 1.0e-10:
178
+ raise ValueError("final_time_omega_pe must contain an integer number of steps")
179
+ if self.max_steps % self.diagnostic_interval_steps:
180
+ raise ValueError("diagnostic_interval_steps must divide the total step count")
181
+ available_samples = self.max_steps // self.diagnostic_interval_steps + 1
182
+ if available_samples < self.minimum_diagnostic_samples:
183
+ raise ValueError("the diagnostic cadence cannot provide the required samples")
184
+ windows = (
185
+ self.early_window_start_omega_pe,
186
+ self.early_window_end_omega_pe,
187
+ self.late_window_start_omega_pe,
188
+ self.late_window_end_omega_pe,
189
+ )
190
+ if not 0 <= windows[0] < windows[1] < windows[2] < windows[3] <= self.final_time_omega_pe:
191
+ raise ValueError("the growth windows must be ordered and inside the run")
192
+ cadence = self.diagnostic_interval_steps * self.time_step_omega_pe
193
+ for start, end in ((windows[0], windows[1]), (windows[2], windows[3])):
194
+ samples = math.floor((end - start) / cadence + 1.0e-10) + 1
195
+ if samples < self.minimum_window_samples:
196
+ raise ValueError("a growth window cannot provide the required samples")
197
+ return self
198
+
199
+ @property
200
+ def max_steps(self) -> int:
201
+ return round(self.final_time_omega_pe / self.time_step_omega_pe)
202
+
203
+
204
+ class WarpXCompiledCase(StrictModel):
205
+ case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"]
206
+ script: str = Field(min_length=1)
207
+ script_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
208
+
209
+ @model_validator(mode="after")
210
+ def hash_matches_script(self) -> WarpXCompiledCase:
211
+ if _sha256_text(self.script) != self.script_hash:
212
+ raise ValueError("compiled PICMI script hash does not match its content")
213
+ return self
214
+
215
+
216
+ class WarpXCaseSummary(StrictModel):
217
+ case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"]
218
+ solver_converged: bool
219
+ finite_values: bool
220
+ diagnostic_samples: int = Field(ge=0)
221
+ effective_growth_rate_omega_pe: float
222
+ early_rms_amplitude_v_m: float = Field(gt=0)
223
+ late_rms_amplitude_v_m: float = Field(gt=0)
224
+ amplitude_ratio: float = Field(gt=0)
225
+ early_window_sample_count: int = Field(ge=0)
226
+ late_window_sample_count: int = Field(ge=0)
227
+ classification: Literal["damped", "ambiguous", "unstable"]
228
+ fundamental_amplitude_initial_v_m: float = Field(ge=0)
229
+ fundamental_amplitude_final_v_m: float = Field(ge=0)
230
+ initial_density_normalized: float = Field(gt=0)
231
+ initial_mean_velocity_normalized: float
232
+ initial_variance_normalized: float = Field(ge=0)
233
+ relative_energy_drift: float = Field(ge=0)
234
+ relative_gauss_residual: float = Field(ge=0)
235
+ relative_charge_imbalance: float = Field(ge=0)
236
+ diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
237
+
238
+
239
+ class WarpXPairSummary(StrictModel):
240
+ contract_version: Literal["warpx_picmi_pair_v1"] = WARPX_CONTRACT_VERSION
241
+ postprocessor_version: Literal["warpx_openpmd_v2"] = "warpx_openpmd_v2"
242
+ runtime_warpx_version: str = Field(min_length=1)
243
+ reference: WarpXCaseSummary
244
+ candidate: WarpXCaseSummary
245
+
246
+ @model_validator(mode="after")
247
+ def cases_have_expected_roles(self) -> WarpXPairSummary:
248
+ if self.reference.case_name != "unit_maxwellian_reference":
249
+ raise ValueError("reference summary has the wrong case name")
250
+ if self.candidate.case_name != "symmetric_mixture_candidate":
251
+ raise ValueError("candidate summary has the wrong case name")
252
+ return self
253
+
254
+
255
+ class WarpXQualificationRecord(StrictModel):
256
+ schema_version: Literal["0.1.0"] = "0.1.0"
257
+ profile_id: str = Field(min_length=1)
258
+ package_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
259
+ expected_warpx_version: str = Field(min_length=1)
260
+ observed_warpx_version: str | None = None
261
+ qualification_kind: Literal["picmi_compile_only"] = "picmi_compile_only"
262
+ compiled_case_hashes: dict[str, str] = Field(default_factory=dict)
263
+ checks: dict[str, bool]
264
+ passed: bool
265
+ scientific_evidence_eligible: Literal[False] = False
266
+ host_architecture: str = Field(default_factory=platform.machine)
267
+ created_at: datetime = Field(default_factory=utc_now)
268
+
269
+ @model_validator(mode="after")
270
+ def pass_matches_checks(self) -> WarpXQualificationRecord:
271
+ if not self.checks:
272
+ raise ValueError("a qualification record requires at least one check")
273
+ if self.passed != all(self.checks.values()):
274
+ raise ValueError("qualification pass flag must equal the conjunction of checks")
275
+ if self.passed and set(self.compiled_case_hashes) != {
276
+ "unit_maxwellian_reference",
277
+ "symmetric_mixture_candidate",
278
+ }:
279
+ raise ValueError("a passing qualification must compile both cases")
280
+ return self
281
+
282
+
283
+ class WarpXQualifiedScope(StrictModel):
284
+ physical: WarpXPhysicalConfig
285
+ minimum_grid_cells: int = Field(default=64, ge=32)
286
+ minimum_electron_macroparticles_per_cell: int = Field(default=512, ge=16)
287
+ minimum_ion_macroparticles_per_cell: int = Field(default=16, ge=1)
288
+ maximum_time_step_omega_pe: float = Field(default=0.05, gt=0)
289
+ minimum_final_time_omega_pe: float = Field(default=20.0, gt=0)
290
+ maximum_diagnostic_cadence_omega_pe: float = Field(default=0.1, gt=0)
291
+ early_window_start_omega_pe: float = Field(default=0.0, ge=0)
292
+ early_window_end_omega_pe: float = Field(default=4.0, gt=0)
293
+ late_window_start_omega_pe: float = Field(default=14.0, gt=0)
294
+ late_window_end_omega_pe: float = Field(default=18.0, gt=0)
295
+ maximum_damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
296
+ minimum_unstable_ratio_threshold: float = Field(default=1.5, gt=1)
297
+ maximum_poisson_relative_tolerance: float = Field(default=1.0e-10, gt=0)
298
+ minimum_poisson_max_iterations: int = Field(default=200, ge=10)
299
+ minimum_diagnostic_samples: int = Field(default=50, ge=3)
300
+ minimum_window_samples: int = Field(default=20, ge=3)
301
+ maximum_relative_energy_drift: float = Field(default=0.01, gt=0)
302
+ maximum_relative_gauss_residual: float = Field(default=0.01, gt=0)
303
+ maximum_relative_charge_imbalance: float = Field(default=0.01, gt=0)
304
+ maximum_moment_tolerance: float = Field(default=0.05, gt=0)
305
+ minimum_outcome_tolerance_omega_pe: float = Field(default=0.02, gt=0)
306
+
307
+ def validation_errors(
308
+ self,
309
+ physical: WarpXPhysicalConfig,
310
+ numerical: WarpXNumericalConfig,
311
+ ) -> tuple[str, ...]:
312
+ errors: list[str] = []
313
+ if physical != self.physical:
314
+ errors.append("physical configuration is outside the qualified scope")
315
+ if numerical.grid_cells < self.minimum_grid_cells:
316
+ errors.append("grid resolution is below the qualified scope")
317
+ if (
318
+ numerical.electron_macroparticles_per_cell
319
+ < self.minimum_electron_macroparticles_per_cell
320
+ ):
321
+ errors.append("electron particle resolution is below the qualified scope")
322
+ if numerical.ion_macroparticles_per_cell < self.minimum_ion_macroparticles_per_cell:
323
+ errors.append("ion particle resolution is below the qualified scope")
324
+ if numerical.time_step_omega_pe > self.maximum_time_step_omega_pe:
325
+ errors.append("time step is above the qualified scope")
326
+ if numerical.final_time_omega_pe < self.minimum_final_time_omega_pe:
327
+ errors.append("run duration is below the qualified scope")
328
+ cadence = numerical.diagnostic_interval_steps * numerical.time_step_omega_pe
329
+ if cadence > self.maximum_diagnostic_cadence_omega_pe:
330
+ errors.append("diagnostic cadence is coarser than the qualified scope")
331
+ configured_windows = (
332
+ numerical.early_window_start_omega_pe,
333
+ numerical.early_window_end_omega_pe,
334
+ numerical.late_window_start_omega_pe,
335
+ numerical.late_window_end_omega_pe,
336
+ )
337
+ qualified_windows = (
338
+ self.early_window_start_omega_pe,
339
+ self.early_window_end_omega_pe,
340
+ self.late_window_start_omega_pe,
341
+ self.late_window_end_omega_pe,
342
+ )
343
+ if any(
344
+ abs(configured - qualified) > 1.0e-12
345
+ for configured, qualified in zip(configured_windows, qualified_windows, strict=True)
346
+ ):
347
+ errors.append("growth windows differ from the qualified scope")
348
+ if numerical.damped_ratio_threshold > self.maximum_damped_ratio_threshold:
349
+ errors.append("damped classification gate is weaker than the qualified scope")
350
+ if numerical.unstable_ratio_threshold < self.minimum_unstable_ratio_threshold:
351
+ errors.append("unstable classification gate is weaker than the qualified scope")
352
+ if numerical.poisson_relative_tolerance > self.maximum_poisson_relative_tolerance:
353
+ errors.append("Poisson tolerance is weaker than the qualified scope")
354
+ if numerical.poisson_max_iterations < self.minimum_poisson_max_iterations:
355
+ errors.append("Poisson iteration limit is below the qualified scope")
356
+ if numerical.minimum_diagnostic_samples < self.minimum_diagnostic_samples:
357
+ errors.append("diagnostic sample gate is weaker than the qualified scope")
358
+ if numerical.minimum_window_samples < self.minimum_window_samples:
359
+ errors.append("window sample gate is weaker than the qualified scope")
360
+ if numerical.maximum_relative_energy_drift > self.maximum_relative_energy_drift:
361
+ errors.append("energy-drift gate is weaker than the qualified scope")
362
+ if numerical.maximum_relative_gauss_residual > self.maximum_relative_gauss_residual:
363
+ errors.append("Gauss-residual gate is weaker than the qualified scope")
364
+ if numerical.maximum_relative_charge_imbalance > self.maximum_relative_charge_imbalance:
365
+ errors.append("charge-imbalance gate is weaker than the qualified scope")
366
+ if numerical.moment_tolerance > self.maximum_moment_tolerance:
367
+ errors.append("moment gate is weaker than the qualified scope")
368
+ if numerical.outcome_tolerance_omega_pe < self.minimum_outcome_tolerance_omega_pe:
369
+ errors.append("outcome-separation gate is weaker than the qualified scope")
370
+ return tuple(errors)
371
+
372
+
373
+ class WarpXCalibrationPoint(StrictModel):
374
+ seed: int = Field(ge=1)
375
+ resolution_id: str = Field(min_length=1)
376
+ grid_cells: int = Field(ge=32)
377
+ electron_macroparticles_per_cell: int = Field(ge=16)
378
+ time_step_omega_pe: float = Field(gt=0)
379
+ run_package_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
380
+ normalized_result_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
381
+ execution_profile: WarpXExecutionProfile
382
+ numerical_validity_passed: bool
383
+ reference_growth_rate: float
384
+ candidate_growth_rate: float
385
+ reference_amplitude_ratio: float = Field(gt=0)
386
+ candidate_amplitude_ratio: float = Field(gt=0)
387
+ reference_classification: Literal["damped", "ambiguous", "unstable"]
388
+ candidate_classification: Literal["damped", "ambiguous", "unstable"]
389
+ reference_diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
390
+ candidate_diagnostic_manifest_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
391
+
392
+
393
+ class WarpXPhysicsQualificationRecord(StrictModel):
394
+ schema_version: Literal["0.2.0"] = "0.2.0"
395
+ qualification_kind: Literal["known_mode_matrix_v2"] = "known_mode_matrix_v2"
396
+ compile_qualification: WarpXQualificationRecord
397
+ calibration_points: tuple[WarpXCalibrationPoint, ...] = Field(min_length=1)
398
+ postprocessor_version: Literal["warpx_openpmd_v2"] = "warpx_openpmd_v2"
399
+ scope: WarpXQualifiedScope
400
+ analytic_reference_growth_rate: float
401
+ analytic_candidate_growth_rate: float
402
+ expected_reference_classification: Literal["damped"] = "damped"
403
+ expected_candidate_classification: Literal["unstable"] = "unstable"
404
+ minimum_seed_count: int = Field(default=4, ge=2)
405
+ minimum_resolution_count: int = Field(default=2, ge=2)
406
+ minimum_effective_rate_separation: float = Field(default=0.1, gt=0)
407
+ maximum_median_rate_shift: float = Field(default=0.03, gt=0)
408
+ median_reference_rates_by_resolution: dict[str, float]
409
+ median_candidate_rates_by_resolution: dict[str, float]
410
+ checks: dict[str, bool]
411
+ passed: bool
412
+ authorizes_scientific_evidence: bool
413
+ created_at: datetime = Field(default_factory=utc_now)
414
+
415
+ @model_validator(mode="after")
416
+ def status_is_coherent(self) -> WarpXPhysicsQualificationRecord:
417
+ if not self.checks:
418
+ raise ValueError("physics qualification requires checks")
419
+ if self.passed != all(self.checks.values()):
420
+ raise ValueError("physics qualification pass flag must equal all checks")
421
+ if self.authorizes_scientific_evidence != self.passed:
422
+ raise ValueError("only a passing physics qualification can authorize evidence")
423
+ return self
424
+
425
+
426
+ def warpx_physics_qualification_hash(record: WarpXPhysicsQualificationRecord) -> str:
427
+ def without_timestamps(value: Any) -> Any:
428
+ if isinstance(value, dict):
429
+ return {
430
+ key: without_timestamps(item) for key, item in value.items() if key != "created_at"
431
+ }
432
+ if isinstance(value, list):
433
+ return [without_timestamps(item) for item in value]
434
+ return value
435
+
436
+ return _sha256_text(_canonical_json(without_timestamps(record.model_dump(mode="json"))))
437
+
438
+
439
+ def build_warpx_physics_qualification(
440
+ *,
441
+ compile_qualification: WarpXQualificationRecord,
442
+ calibration_results: Sequence[NormalizedResult],
443
+ scope: WarpXQualifiedScope,
444
+ analytic_reference_growth_rate: float,
445
+ analytic_candidate_growth_rate: float,
446
+ minimum_seed_count: int = 4,
447
+ minimum_resolution_count: int = 2,
448
+ minimum_effective_rate_separation: float = 0.1,
449
+ maximum_median_rate_shift: float = 0.03,
450
+ ) -> WarpXPhysicsQualificationRecord:
451
+ points: list[WarpXCalibrationPoint] = []
452
+ physical_configs: list[WarpXPhysicalConfig] = []
453
+ numerical_configs: list[WarpXNumericalConfig] = []
454
+ for result in calibration_results:
455
+ diagnostics = result.diagnostics
456
+ pair = WarpXPairSummary.model_validate(diagnostics["pair_summary"])
457
+ profile = WarpXExecutionProfile.model_validate(diagnostics["execution_profile"])
458
+ physical = WarpXPhysicalConfig.model_validate(diagnostics["physical"])
459
+ numerical = WarpXNumericalConfig.model_validate(diagnostics["numerical"])
460
+ resolution_id = (
461
+ f"g{numerical.grid_cells}_ppc{numerical.electron_macroparticles_per_cell}_"
462
+ f"dt{numerical.time_step_omega_pe:g}"
463
+ )
464
+ physical_configs.append(physical)
465
+ numerical_configs.append(numerical)
466
+ points.append(
467
+ WarpXCalibrationPoint(
468
+ seed=numerical.random_seed,
469
+ resolution_id=resolution_id,
470
+ grid_cells=numerical.grid_cells,
471
+ electron_macroparticles_per_cell=(numerical.electron_macroparticles_per_cell),
472
+ time_step_omega_pe=numerical.time_step_omega_pe,
473
+ run_package_hash=str(diagnostics["run_package_hash"]),
474
+ normalized_result_hash=_sha256_text(
475
+ _canonical_json(result.model_dump(mode="json"))
476
+ ),
477
+ execution_profile=profile,
478
+ numerical_validity_passed=bool(
479
+ diagnostics["validity_gates"]["numerical_validity_passed"]
480
+ ),
481
+ reference_growth_rate=pair.reference.effective_growth_rate_omega_pe,
482
+ candidate_growth_rate=pair.candidate.effective_growth_rate_omega_pe,
483
+ reference_amplitude_ratio=pair.reference.amplitude_ratio,
484
+ candidate_amplitude_ratio=pair.candidate.amplitude_ratio,
485
+ reference_classification=pair.reference.classification,
486
+ candidate_classification=pair.candidate.classification,
487
+ reference_diagnostic_manifest_hash=(pair.reference.diagnostic_manifest_hash),
488
+ candidate_diagnostic_manifest_hash=(pair.candidate.diagnostic_manifest_hash),
489
+ )
490
+ )
491
+
492
+ seed_set = {point.seed for point in points}
493
+ resolution_set = {point.resolution_id for point in points}
494
+ matrix_keys = {(point.seed, point.resolution_id) for point in points}
495
+ matrix_complete = (
496
+ len(seed_set) >= minimum_seed_count
497
+ and len(resolution_set) >= minimum_resolution_count
498
+ and len(matrix_keys) == len(points) == len(seed_set) * len(resolution_set)
499
+ and all(
500
+ {point.seed for point in points if point.resolution_id == resolution_id} == seed_set
501
+ for resolution_id in resolution_set
502
+ )
503
+ )
504
+
505
+ def medians(attribute: str) -> dict[str, float]:
506
+ return {
507
+ resolution_id: float(
508
+ median(
509
+ getattr(point, attribute)
510
+ for point in points
511
+ if point.resolution_id == resolution_id
512
+ )
513
+ )
514
+ for resolution_id in sorted(resolution_set)
515
+ }
516
+
517
+ reference_medians = medians("reference_growth_rate")
518
+ candidate_medians = medians("candidate_growth_rate")
519
+
520
+ def median_range(values: dict[str, float]) -> float:
521
+ return max(values.values()) - min(values.values()) if values else math.inf
522
+
523
+ manifest_hashes = {
524
+ digest
525
+ for point in points
526
+ for digest in (
527
+ point.reference_diagnostic_manifest_hash,
528
+ point.candidate_diagnostic_manifest_hash,
529
+ )
530
+ }
531
+ checks = {
532
+ "compile_qualification_passed": compile_qualification.passed,
533
+ "compile_package_is_in_calibration_matrix": (
534
+ compile_qualification.package_hash in {point.run_package_hash for point in points}
535
+ ),
536
+ "calibration_matrix_complete": matrix_complete,
537
+ "calibration_packages_are_unique": (
538
+ len({point.run_package_hash for point in points}) == len(points)
539
+ ),
540
+ "calibration_diagnostics_are_unique": len(manifest_hashes) == 2 * len(points),
541
+ "calibration_profiles_were_unqualified": all(
542
+ not point.execution_profile.qualified_for_scientific_evidence for point in points
543
+ ),
544
+ "calibration_runners_are_not_fixtures": all(
545
+ point.execution_profile.runner_kind is not WarpXRunnerKind.CONTRACT_FIXTURE
546
+ for point in points
547
+ ),
548
+ "calibration_configurations_are_in_scope": all(
549
+ physical == scope.physical and not scope.validation_errors(physical, numerical)
550
+ for physical, numerical in zip(physical_configs, numerical_configs, strict=True)
551
+ ),
552
+ "all_calibration_runs_are_numerically_valid": all(
553
+ point.numerical_validity_passed for point in points
554
+ ),
555
+ "all_reference_cases_match_damped_oracle": all(
556
+ point.reference_classification == "damped" for point in points
557
+ ),
558
+ "all_candidate_cases_match_unstable_oracle": all(
559
+ point.candidate_classification == "unstable" for point in points
560
+ ),
561
+ "all_pairs_exceed_effective_rate_separation": all(
562
+ point.candidate_growth_rate - point.reference_growth_rate
563
+ >= minimum_effective_rate_separation
564
+ for point in points
565
+ ),
566
+ "reference_median_rate_is_resolution_converged": (
567
+ median_range(reference_medians) <= maximum_median_rate_shift
568
+ ),
569
+ "candidate_median_rate_is_resolution_converged": (
570
+ median_range(candidate_medians) <= maximum_median_rate_shift
571
+ ),
572
+ }
573
+ passed = all(checks.values())
574
+ return WarpXPhysicsQualificationRecord(
575
+ compile_qualification=compile_qualification,
576
+ calibration_points=tuple(
577
+ sorted(points, key=lambda point: (point.resolution_id, point.seed))
578
+ ),
579
+ scope=scope,
580
+ analytic_reference_growth_rate=analytic_reference_growth_rate,
581
+ analytic_candidate_growth_rate=analytic_candidate_growth_rate,
582
+ minimum_seed_count=minimum_seed_count,
583
+ minimum_resolution_count=minimum_resolution_count,
584
+ minimum_effective_rate_separation=minimum_effective_rate_separation,
585
+ maximum_median_rate_shift=maximum_median_rate_shift,
586
+ median_reference_rates_by_resolution=reference_medians,
587
+ median_candidate_rates_by_resolution=candidate_medians,
588
+ checks=checks,
589
+ passed=passed,
590
+ authorizes_scientific_evidence=passed,
591
+ )
592
+
593
+
594
+ def qualified_warpx_profile(
595
+ record: WarpXPhysicsQualificationRecord,
596
+ *,
597
+ runner_kind: WarpXRunnerKind = WarpXRunnerKind.LOCAL_CPU,
598
+ ) -> WarpXExecutionProfile:
599
+ if not record.passed:
600
+ raise ValueError("a failed physics qualification cannot create a profile")
601
+ qualification_hash = warpx_physics_qualification_hash(record)
602
+ return WarpXExecutionProfile(
603
+ profile_id=f"warpx_qualified_{qualification_hash[:16]}",
604
+ runner_kind=runner_kind,
605
+ warpx_version=record.compile_qualification.expected_warpx_version,
606
+ qualification_hash=qualification_hash,
607
+ qualified_for_scientific_evidence=True,
608
+ )
609
+
610
+
611
+ def _distribution_components(
612
+ case_name: str, physical: WarpXPhysicalConfig
613
+ ) -> tuple[tuple[float, float, float], ...]:
614
+ if case_name == "unit_maxwellian_reference":
615
+ return ((1.0, 0.0, 1.0),)
616
+ candidate = physical.candidate
617
+ return tuple(
618
+ (component.weight, component.drift, component.sigma)
619
+ for component in candidate.distribution().components
620
+ )
621
+
622
+
623
+ def _picmi_script(
624
+ case_name: Literal["unit_maxwellian_reference", "symmetric_mixture_candidate"],
625
+ physical: WarpXPhysicalConfig,
626
+ numerical: WarpXNumericalConfig,
627
+ ) -> str:
628
+ """Render a fixed PICMI program containing validated numeric literals only."""
629
+
630
+ omega_pe = physical.plasma_frequency_rad_s
631
+ dt_s = numerical.time_step_omega_pe / omega_pe
632
+ components = _distribution_components(case_name, physical)
633
+ ppc = numerical.electron_macroparticles_per_cell // len(components)
634
+ component_rows = _canonical_json(
635
+ [
636
+ {
637
+ "density": weight * physical.reference_density_m3,
638
+ "drift": drift * physical.velocity_unit_m_s,
639
+ "sigma": sigma * physical.velocity_unit_m_s,
640
+ }
641
+ for weight, drift, sigma in components
642
+ ]
643
+ )
644
+ return f'''#!/usr/bin/env python3
645
+ """Generated from a typed source specification; edit that specification, not this file."""
646
+
647
+ import argparse
648
+ from pywarpx import picmi
649
+
650
+ CASE_NAME = {case_name!r}
651
+ COMPONENTS = {component_rows}
652
+
653
+ grid = picmi.Cartesian1DGrid(
654
+ number_of_cells=[{numerical.grid_cells}],
655
+ lower_bound=[0.0],
656
+ upper_bound=[{physical.domain_length_m!r}],
657
+ lower_boundary_conditions=["periodic"],
658
+ upper_boundary_conditions=["periodic"],
659
+ lower_boundary_conditions_particles=["periodic"],
660
+ upper_boundary_conditions_particles=["periodic"],
661
+ warpx_max_grid_size={min(64, numerical.grid_cells)},
662
+ )
663
+ solver = picmi.ElectrostaticSolver(
664
+ grid=grid,
665
+ required_precision={numerical.poisson_relative_tolerance!r},
666
+ maximum_iterations={numerical.poisson_max_iterations},
667
+ )
668
+ simulation = picmi.Simulation(
669
+ solver=solver,
670
+ time_step_size={dt_s!r},
671
+ max_steps={numerical.max_steps},
672
+ particle_shape=2,
673
+ verbose=1,
674
+ warpx_random_seed={numerical.random_seed},
675
+ warpx_serialize_initial_conditions=True,
676
+ warpx_used_inputs_file="warpx_used_inputs",
677
+ )
678
+
679
+ electrons = []
680
+ for index, component in enumerate(COMPONENTS):
681
+ distribution = picmi.AnalyticDistribution(
682
+ density_expression=(
683
+ f"{{component['density']}}*(1+{physical.perturbation_amplitude!r}*"
684
+ f"cos({physical.wavenumber_m_inverse!r}*z))"
685
+ ),
686
+ directed_velocity=[0.0, 0.0, component["drift"]],
687
+ rms_velocity=[0.0, 0.0, component["sigma"]],
688
+ )
689
+ species = picmi.Species(
690
+ particle_type="electron",
691
+ name=f"electrons_{{index}}",
692
+ initial_distribution=distribution,
693
+ )
694
+ electrons.append(species)
695
+ simulation.add_species(
696
+ species,
697
+ layout=picmi.GriddedLayout(
698
+ grid=grid,
699
+ n_macroparticle_per_cell=[{ppc}],
700
+ ),
701
+ )
702
+
703
+ ions = picmi.Species(
704
+ particle_type="proton",
705
+ name="immobile_ions",
706
+ initial_distribution=picmi.UniformDistribution(
707
+ density={physical.reference_density_m3!r},
708
+ directed_velocity=[0.0, 0.0, 0.0],
709
+ ),
710
+ warpx_do_not_push=True,
711
+ )
712
+ simulation.add_species(
713
+ ions,
714
+ layout=picmi.GriddedLayout(
715
+ grid=grid,
716
+ n_macroparticle_per_cell=[{numerical.ion_macroparticles_per_cell}],
717
+ ),
718
+ )
719
+
720
+ simulation.add_diagnostic(
721
+ picmi.FieldDiagnostic(
722
+ name="fields",
723
+ grid=grid,
724
+ period={numerical.diagnostic_interval_steps},
725
+ data_list=["E", "rho"],
726
+ warpx_format="openpmd",
727
+ warpx_openpmd_backend="h5",
728
+ )
729
+ )
730
+ simulation.add_diagnostic(
731
+ picmi.ParticleDiagnostic(
732
+ name="particles",
733
+ period={numerical.max_steps},
734
+ species=electrons,
735
+ data_list=["weighting", "momentum", "position"],
736
+ warpx_format="openpmd",
737
+ warpx_openpmd_backend="h5",
738
+ )
739
+ )
740
+ simulation.add_diagnostic(
741
+ picmi.ReducedDiagnostic(diag_type="FieldEnergy", name="field_energy", period=1)
742
+ )
743
+ simulation.add_diagnostic(
744
+ picmi.ReducedDiagnostic(diag_type="ParticleEnergy", name="particle_energy", period=1)
745
+ )
746
+
747
+ parser = argparse.ArgumentParser()
748
+ parser.add_argument("--compile-only", action="store_true")
749
+ arguments = parser.parse_args()
750
+ if arguments.compile_only:
751
+ simulation.write_input_file(file_name="inputs")
752
+ else:
753
+ simulation.step()
754
+ '''
755
+
756
+
757
+ def build_warpx_experiment(
758
+ candidate: SymmetricMixtureCandidate | None = None,
759
+ *,
760
+ physical: WarpXPhysicalConfig | None = None,
761
+ numerical: WarpXNumericalConfig | None = None,
762
+ experiment_id: str = "experiment_warpx_moment_sufficiency_v1",
763
+ ) -> ExperimentSpec:
764
+ chosen = candidate or (
765
+ physical.candidate
766
+ if physical
767
+ else SymmetricMixtureCandidate(inner_pair_weight=0.5, inner_drift=0.95, outer_drift=0.95)
768
+ )
769
+ base_physical = physical or WarpXPhysicalConfig(
770
+ inner_pair_weight=chosen.inner_pair_weight,
771
+ inner_drift=chosen.inner_drift,
772
+ outer_drift=chosen.outer_drift,
773
+ )
774
+ if base_physical.candidate != chosen:
775
+ raise ValueError("candidate and physical configuration disagree")
776
+ numerical = numerical or WarpXNumericalConfig()
777
+ return ExperimentSpec(
778
+ id=experiment_id,
779
+ hypothesis_ids=("hypothesis_low_moments_sufficient_for_stability",),
780
+ action_type=WARPX_ACTION,
781
+ physical_parameters=base_physical.model_dump(mode="json"),
782
+ numerical_parameters=numerical.model_dump(mode="json"),
783
+ required_diagnostics=WARPX_REQUIRED_DIAGNOSTICS,
784
+ predictions={
785
+ "sufficiency": "matched distributions have equal normalized growth rate",
786
+ "counterexample": "matched distributions differ in normalized growth rate",
787
+ },
788
+ falsification_condition=(
789
+ "both cases pass numerical gates and normalized growth rates differ by more than "
790
+ f"{numerical.outcome_tolerance_omega_pe}"
791
+ ),
792
+ )
793
+
794
+
795
+ class SubprocessWarpXScheduler:
796
+ """Durable local runner contract used by qualification and site wrappers.
797
+
798
+ The configured command receives ``--package``, ``--result`` and
799
+ ``--work-directory``. It must write one ``WarpXPairSummary`` JSON object.
800
+ The scheduler is synchronous for now, but job identity and state are stored
801
+ on disk so controller restarts reattach instead of creating a second run.
802
+ """
803
+
804
+ def __init__(
805
+ self,
806
+ *,
807
+ work_root: Path,
808
+ command: tuple[str, ...],
809
+ profile: WarpXExecutionProfile,
810
+ timeout_seconds: float = 3600.0,
811
+ ) -> None:
812
+ if not command:
813
+ raise ValueError("runner command cannot be empty")
814
+ self.work_root = Path(work_root).resolve()
815
+ self.command = command
816
+ self.profile = profile
817
+ self.timeout_seconds = timeout_seconds
818
+
819
+ def _job_id(self, run: RunPackage, idempotency_key: str) -> str:
820
+ identity = _sha256_text(f"{run.package_hash}:{idempotency_key}")
821
+ return f"warpx-{identity[:20]}"
822
+
823
+ def _job_dir(self, job_id: str) -> Path:
824
+ return self.work_root / job_id
825
+
826
+ @staticmethod
827
+ def _archive_interrupted_outputs(job_dir: Path) -> None:
828
+ """Preserve partial outputs before replaying a controller-orphaned job."""
829
+
830
+ for name in ("execution", "pair_summary.json", "pair_summary.json.tmp"):
831
+ source = job_dir / name
832
+ if not source.exists():
833
+ continue
834
+ ordinal = 1
835
+ while (job_dir / f"interrupted_{ordinal}_{name}").exists():
836
+ ordinal += 1
837
+ source.rename(job_dir / f"interrupted_{ordinal}_{name}")
838
+
839
+ def _commit_summary(
840
+ self,
841
+ *,
842
+ job_id: str,
843
+ run: RunPackage,
844
+ summary: WarpXPairSummary,
845
+ raw_path: Path,
846
+ status_path: Path,
847
+ ) -> None:
848
+ result_payload = {
849
+ "experiment_id": run.experiment_id,
850
+ "run_package_hash": run.package_hash,
851
+ "physical": run.payload["physical"],
852
+ "numerical": run.payload["numerical"],
853
+ "execution_profile": self.profile.model_dump(mode="json"),
854
+ "pair_summary": summary.model_dump(mode="json"),
855
+ }
856
+ result_hash = _sha256_text(_canonical_json(result_payload))
857
+ raw = RawResult(
858
+ job_id=job_id,
859
+ payload=result_payload,
860
+ artifact_hashes=(result_hash,),
861
+ )
862
+ raw_path.write_text(raw.model_dump_json(indent=2) + "\n")
863
+ status_path.write_text(
864
+ json.dumps(
865
+ {
866
+ "state": JobState.COMPLETED.value,
867
+ "package_hash": run.package_hash,
868
+ "detail": "runner produced a schema-valid pair summary",
869
+ },
870
+ indent=2,
871
+ sort_keys=True,
872
+ )
873
+ + "\n"
874
+ )
875
+
876
+ def submit(self, run: RunPackage, *, idempotency_key: str) -> JobReference:
877
+ job_id = self._job_id(run, idempotency_key)
878
+ job_dir = self._job_dir(job_id)
879
+ job_dir.mkdir(parents=True, exist_ok=True)
880
+ status_path = job_dir / "status.json"
881
+ raw_path = job_dir / "raw_result.json"
882
+ result_path = job_dir / "pair_summary.json"
883
+ lock_path = job_dir / "dispatch.lock"
884
+ with lock_path.open("a+") as dispatch_lock:
885
+ # The runner inherits this descriptor. After an accidental
886
+ # controller stop, another controller waits for the still-running
887
+ # child, then commits its completed result or safely replays it.
888
+ fcntl.flock(dispatch_lock.fileno(), fcntl.LOCK_EX)
889
+ if status_path.exists():
890
+ status = json.loads(status_path.read_text())
891
+ if status["package_hash"] != run.package_hash:
892
+ raise ValueError("stored job has a different run-package hash")
893
+ if status["state"] in {JobState.COMPLETED.value, JobState.FAILED.value}:
894
+ return JobReference(
895
+ job_id=job_id,
896
+ experiment_id=run.experiment_id,
897
+ idempotency_key=idempotency_key,
898
+ )
899
+ if result_path.exists():
900
+ try:
901
+ orphaned_summary = WarpXPairSummary.model_validate_json(
902
+ result_path.read_text()
903
+ )
904
+ except ValueError:
905
+ pass
906
+ else:
907
+ self._commit_summary(
908
+ job_id=job_id,
909
+ run=run,
910
+ summary=orphaned_summary,
911
+ raw_path=raw_path,
912
+ status_path=status_path,
913
+ )
914
+ return JobReference(
915
+ job_id=job_id,
916
+ experiment_id=run.experiment_id,
917
+ idempotency_key=idempotency_key,
918
+ )
919
+ self._archive_interrupted_outputs(job_dir)
920
+
921
+ package_path = job_dir / "run_package.json"
922
+ package_path.write_text(run.model_dump_json(indent=2) + "\n")
923
+ status_path.write_text(
924
+ json.dumps(
925
+ {
926
+ "state": JobState.RUNNING.value,
927
+ "package_hash": run.package_hash,
928
+ "detail": "local subprocess owns the durable dispatch lock",
929
+ },
930
+ indent=2,
931
+ sort_keys=True,
932
+ )
933
+ + "\n"
934
+ )
935
+ command = (
936
+ *self.command,
937
+ "--package",
938
+ str(package_path),
939
+ "--result",
940
+ str(result_path),
941
+ "--work-directory",
942
+ str(job_dir),
943
+ )
944
+ try:
945
+ completed = subprocess.run(
946
+ command,
947
+ cwd=job_dir,
948
+ capture_output=True,
949
+ text=True,
950
+ timeout=self.timeout_seconds,
951
+ check=False,
952
+ pass_fds=(dispatch_lock.fileno(),),
953
+ )
954
+ except (OSError, subprocess.TimeoutExpired) as error:
955
+ detail = f"{type(error).__name__}: {error}"
956
+ status_path.write_text(
957
+ json.dumps(
958
+ {
959
+ "state": JobState.FAILED.value,
960
+ "package_hash": run.package_hash,
961
+ "detail": detail,
962
+ },
963
+ indent=2,
964
+ sort_keys=True,
965
+ )
966
+ + "\n"
967
+ )
968
+ else:
969
+ if completed.returncode != 0 or not result_path.exists():
970
+ detail = (
971
+ f"runner exit={completed.returncode}; stderr={completed.stderr[-4000:]}"
972
+ )
973
+ status_path.write_text(
974
+ json.dumps(
975
+ {
976
+ "state": JobState.FAILED.value,
977
+ "package_hash": run.package_hash,
978
+ "detail": detail,
979
+ },
980
+ indent=2,
981
+ sort_keys=True,
982
+ )
983
+ + "\n"
984
+ )
985
+ else:
986
+ try:
987
+ summary = WarpXPairSummary.model_validate_json(result_path.read_text())
988
+ except ValueError as error:
989
+ status_path.write_text(
990
+ json.dumps(
991
+ {
992
+ "state": JobState.FAILED.value,
993
+ "package_hash": run.package_hash,
994
+ "detail": f"runner result failed schema validation: {error}",
995
+ },
996
+ indent=2,
997
+ sort_keys=True,
998
+ )
999
+ + "\n"
1000
+ )
1001
+ else:
1002
+ self._commit_summary(
1003
+ job_id=job_id,
1004
+ run=run,
1005
+ summary=summary,
1006
+ raw_path=raw_path,
1007
+ status_path=status_path,
1008
+ )
1009
+ return JobReference(
1010
+ job_id=job_id,
1011
+ experiment_id=run.experiment_id,
1012
+ idempotency_key=idempotency_key,
1013
+ )
1014
+
1015
+ def monitor(self, job: JobReference) -> JobStatus:
1016
+ status_path = self._job_dir(job.job_id) / "status.json"
1017
+ if not status_path.exists():
1018
+ return JobStatus(job_id=job.job_id, state=JobState.UNKNOWN)
1019
+ payload = json.loads(status_path.read_text())
1020
+ return JobStatus(
1021
+ job_id=job.job_id,
1022
+ state=JobState(payload["state"]),
1023
+ detail=str(payload.get("detail", "")),
1024
+ )
1025
+
1026
+ def retrieve(self, job: JobReference) -> RawResult:
1027
+ path = self._job_dir(job.job_id) / "raw_result.json"
1028
+ if not path.exists():
1029
+ raise LookupError(f"WarpX job {job.job_id} has no completed result")
1030
+ result = RawResult.model_validate_json(path.read_text())
1031
+ if result.job_id != job.job_id or result.payload.get("experiment_id") != job.experiment_id:
1032
+ raise ValueError("stored WarpX result identity does not match the job reference")
1033
+ return result
1034
+
1035
+ def cancel(self, job: JobReference) -> JobStatus:
1036
+ status = self.monitor(job)
1037
+ if status.state in {JobState.COMPLETED, JobState.FAILED, JobState.UNKNOWN}:
1038
+ return status
1039
+ return JobStatus(
1040
+ job_id=job.job_id,
1041
+ state=JobState.UNKNOWN,
1042
+ detail="the synchronous local runner cannot be safely cancelled cross-process",
1043
+ )
1044
+
1045
+
1046
+ class WarpXAdapter:
1047
+ _PHYSICAL_KEYS = set(WarpXPhysicalConfig.model_fields)
1048
+ _NUMERICAL_KEYS = set(WarpXNumericalConfig.model_fields)
1049
+
1050
+ def __init__(
1051
+ self,
1052
+ scheduler: SubprocessWarpXScheduler,
1053
+ *,
1054
+ physics_qualification: WarpXPhysicsQualificationRecord | None = None,
1055
+ ) -> None:
1056
+ self.scheduler = scheduler
1057
+ self.physics_qualification = physics_qualification
1058
+ if scheduler.profile.qualified_for_scientific_evidence:
1059
+ if physics_qualification is None or not physics_qualification.passed:
1060
+ raise ValueError("a qualified execution profile requires its passing record")
1061
+ if scheduler.profile.qualification_hash != warpx_physics_qualification_hash(
1062
+ physics_qualification
1063
+ ):
1064
+ raise ValueError("execution profile qualification hash does not match its record")
1065
+ elif physics_qualification is not None:
1066
+ raise ValueError("an unqualified execution profile cannot carry a physics record")
1067
+
1068
+ def capabilities(self) -> CapabilityManifest:
1069
+ return CapabilityManifest(
1070
+ adapter_name="warpx_picmi",
1071
+ adapter_version=WARPX_ADAPTER_VERSION,
1072
+ supported_actions=(WARPX_ACTION,),
1073
+ supported_models=(WARPX_MODEL,),
1074
+ supported_diagnostics=WARPX_REQUIRED_DIAGNOSTICS,
1075
+ supported_coordinates=("density", "mean_velocity", "variance"),
1076
+ supported_observable_kinds=("effective_fundamental_growth_rate",),
1077
+ supports_checkpoint=True,
1078
+ deterministic=False,
1079
+ )
1080
+
1081
+ def _configs(
1082
+ self, experiment: ExperimentSpec
1083
+ ) -> tuple[WarpXPhysicalConfig, WarpXNumericalConfig]:
1084
+ return (
1085
+ WarpXPhysicalConfig.model_validate(experiment.physical_parameters),
1086
+ WarpXNumericalConfig.model_validate(experiment.numerical_parameters),
1087
+ )
1088
+
1089
+ def validate(self, experiment: ExperimentSpec) -> ValidationReport:
1090
+ errors: list[str] = []
1091
+ warnings: list[str] = []
1092
+ if experiment.action_type != WARPX_ACTION:
1093
+ errors.append("unsupported action_type")
1094
+ unknown_physical = set(experiment.physical_parameters) - self._PHYSICAL_KEYS
1095
+ unknown_numerical = set(experiment.numerical_parameters) - self._NUMERICAL_KEYS
1096
+ if unknown_physical:
1097
+ errors.append(f"unknown physical parameters: {sorted(unknown_physical)}")
1098
+ if unknown_numerical:
1099
+ errors.append(f"unknown numerical parameters: {sorted(unknown_numerical)}")
1100
+ missing = set(WARPX_REQUIRED_DIAGNOSTICS) - set(experiment.required_diagnostics)
1101
+ extra = set(experiment.required_diagnostics) - set(WARPX_REQUIRED_DIAGNOSTICS)
1102
+ if missing:
1103
+ errors.append(f"missing required diagnostics: {sorted(missing)}")
1104
+ if extra:
1105
+ errors.append(f"unsupported diagnostics: {sorted(extra)}")
1106
+ if not errors:
1107
+ try:
1108
+ physical, numerical = self._configs(experiment)
1109
+ except ValueError as error:
1110
+ errors.append(f"invalid WarpX configuration: {error}")
1111
+ else:
1112
+ if self.physics_qualification is not None:
1113
+ errors.extend(
1114
+ self.physics_qualification.scope.validation_errors(
1115
+ physical,
1116
+ numerical,
1117
+ )
1118
+ )
1119
+ if numerical.grid_cells * numerical.electron_macroparticles_per_cell > 1e7:
1120
+ warnings.append("the paired run initializes more than ten million electrons")
1121
+ cell_size = physical.domain_length_m / numerical.grid_cells
1122
+ thermal_crossing = physical.velocity_unit_m_s * (
1123
+ numerical.time_step_omega_pe / physical.plasma_frequency_rad_s
1124
+ )
1125
+ if thermal_crossing / cell_size > 0.5:
1126
+ warnings.append("thermal particles cross more than half a cell per step")
1127
+ return ValidationReport(valid=not errors, errors=tuple(errors), warnings=tuple(warnings))
1128
+
1129
+ def estimate_cost(self, experiment: ExperimentSpec) -> CostEstimate:
1130
+ report = self.validate(experiment)
1131
+ if not report.valid:
1132
+ raise ValueError("; ".join(report.errors))
1133
+ _, numerical = self._configs(experiment)
1134
+ particles_per_case = numerical.grid_cells * (
1135
+ numerical.electron_macroparticles_per_cell + numerical.ion_macroparticles_per_cell
1136
+ )
1137
+ work = 2 * particles_per_case * numerical.max_steps
1138
+ snapshots = 2 * (numerical.max_steps // numerical.diagnostic_interval_steps + 1)
1139
+ return CostEstimate(
1140
+ compute_units=work / 100_000_000,
1141
+ wall_seconds=work / 5_000_000,
1142
+ storage_bytes=snapshots * numerical.grid_cells * 512,
1143
+ )
1144
+
1145
+ def compile_input(self, experiment: ExperimentSpec) -> RunPackage:
1146
+ report = self.validate(experiment)
1147
+ if not report.valid:
1148
+ raise ValueError("; ".join(report.errors))
1149
+ physical, numerical = self._configs(experiment)
1150
+ cases = tuple(
1151
+ WarpXCompiledCase(
1152
+ case_name=case_name,
1153
+ script=script.strip(),
1154
+ script_hash=_sha256_text(script.strip()),
1155
+ )
1156
+ for case_name in (
1157
+ "unit_maxwellian_reference",
1158
+ "symmetric_mixture_candidate",
1159
+ )
1160
+ for script in (_picmi_script(case_name, physical, numerical),)
1161
+ )
1162
+ payload = {
1163
+ "contract_version": WARPX_CONTRACT_VERSION,
1164
+ "compiler": "restricted_picmi_template_v2",
1165
+ "expected_warpx_version": self.scheduler.profile.warpx_version,
1166
+ "physical": physical.model_dump(mode="json"),
1167
+ "numerical": numerical.model_dump(mode="json"),
1168
+ "cases": [case.model_dump(mode="json") for case in cases],
1169
+ }
1170
+ return RunPackage(
1171
+ experiment_id=experiment.id,
1172
+ adapter_name="warpx_picmi",
1173
+ payload=payload,
1174
+ package_hash=_sha256_text(_canonical_json(payload)),
1175
+ )
1176
+
1177
+ def submit(self, run: RunPackage, *, idempotency_key: str) -> JobReference:
1178
+ if run.adapter_name != "warpx_picmi":
1179
+ raise ValueError("run package belongs to a different adapter")
1180
+ return self.scheduler.submit(run, idempotency_key=idempotency_key)
1181
+
1182
+ def monitor(self, job: JobReference) -> JobStatus:
1183
+ return self.scheduler.monitor(job)
1184
+
1185
+ def retrieve(self, job: JobReference) -> RawResult:
1186
+ return self.scheduler.retrieve(job)
1187
+
1188
+ def normalize(self, result: RawResult) -> NormalizedResult:
1189
+ payload = result.payload
1190
+ expected_artifact_hash = _sha256_text(_canonical_json(payload))
1191
+ if result.artifact_hashes != (expected_artifact_hash,):
1192
+ raise ValueError("WarpX raw-result artifact hash does not match its payload")
1193
+ physical = WarpXPhysicalConfig.model_validate(payload["physical"])
1194
+ numerical = WarpXNumericalConfig.model_validate(payload["numerical"])
1195
+ profile = WarpXExecutionProfile.model_validate(payload["execution_profile"])
1196
+ summary = WarpXPairSummary.model_validate(payload["pair_summary"])
1197
+ reference = summary.reference
1198
+ candidate = summary.candidate
1199
+
1200
+ def expected_classification(case: WarpXCaseSummary) -> str:
1201
+ if case.amplitude_ratio < numerical.damped_ratio_threshold:
1202
+ return "damped"
1203
+ if case.amplitude_ratio > numerical.unstable_ratio_threshold:
1204
+ return "unstable"
1205
+ return "ambiguous"
1206
+
1207
+ case_gates = {
1208
+ case.case_name: {
1209
+ "solver_converged": case.solver_converged,
1210
+ "finite_values": case.finite_values,
1211
+ "diagnostic_samples": (
1212
+ case.diagnostic_samples >= numerical.minimum_diagnostic_samples
1213
+ ),
1214
+ "energy_drift": (
1215
+ case.relative_energy_drift <= numerical.maximum_relative_energy_drift
1216
+ ),
1217
+ "gauss_residual": (
1218
+ case.relative_gauss_residual <= numerical.maximum_relative_gauss_residual
1219
+ ),
1220
+ "charge_imbalance": (
1221
+ case.relative_charge_imbalance <= numerical.maximum_relative_charge_imbalance
1222
+ ),
1223
+ "early_window_samples": (
1224
+ case.early_window_sample_count >= numerical.minimum_window_samples
1225
+ ),
1226
+ "late_window_samples": (
1227
+ case.late_window_sample_count >= numerical.minimum_window_samples
1228
+ ),
1229
+ "classification_consistent": (case.classification == expected_classification(case)),
1230
+ }
1231
+ for case in (reference, candidate)
1232
+ }
1233
+ moments_match = all(
1234
+ difference <= numerical.moment_tolerance
1235
+ for difference in (
1236
+ abs(reference.initial_density_normalized - candidate.initial_density_normalized),
1237
+ abs(
1238
+ reference.initial_mean_velocity_normalized
1239
+ - candidate.initial_mean_velocity_normalized
1240
+ ),
1241
+ abs(reference.initial_variance_normalized - candidate.initial_variance_normalized),
1242
+ abs(reference.initial_density_normalized - 1.0),
1243
+ abs(reference.initial_mean_velocity_normalized),
1244
+ abs(reference.initial_variance_normalized - 1.0),
1245
+ abs(candidate.initial_density_normalized - 1.0),
1246
+ abs(candidate.initial_mean_velocity_normalized),
1247
+ abs(candidate.initial_variance_normalized - 1.0),
1248
+ )
1249
+ )
1250
+ runtime_matches = _warpx_versions_equivalent(
1251
+ summary.runtime_warpx_version,
1252
+ profile.warpx_version,
1253
+ )
1254
+ numerical_validity = (
1255
+ all(all(gates.values()) for gates in case_gates.values())
1256
+ and moments_match
1257
+ and runtime_matches
1258
+ )
1259
+ separation = abs(
1260
+ candidate.effective_growth_rate_omega_pe - reference.effective_growth_rate_omega_pe
1261
+ )
1262
+ opposite = {reference.classification, candidate.classification} == {
1263
+ "damped",
1264
+ "unstable",
1265
+ }
1266
+ raw_witness = (
1267
+ numerical_validity and opposite and separation > numerical.outcome_tolerance_omega_pe
1268
+ )
1269
+ eligible = numerical_validity and profile.qualified_for_scientific_evidence
1270
+ diagnostics = {
1271
+ "contract_version": WARPX_CONTRACT_VERSION,
1272
+ "run_package_hash": str(payload["run_package_hash"]),
1273
+ "physical": physical.model_dump(mode="json"),
1274
+ "numerical": numerical.model_dump(mode="json"),
1275
+ "execution_profile": profile.model_dump(mode="json"),
1276
+ "pair_summary": summary.model_dump(mode="json"),
1277
+ "validity_gates": {
1278
+ "cases": case_gates,
1279
+ "moments_match": moments_match,
1280
+ "runtime_version_matches_profile": runtime_matches,
1281
+ "numerical_validity_passed": numerical_validity,
1282
+ },
1283
+ "raw_witness_satisfies_predicate": raw_witness,
1284
+ "scientific_evidence_eligible": eligible,
1285
+ }
1286
+ return NormalizedResult(
1287
+ experiment_id=str(payload["experiment_id"]),
1288
+ observables={
1289
+ "maxwellian_growth_rate": reference.effective_growth_rate_omega_pe,
1290
+ "two_stream_growth_rate": candidate.effective_growth_rate_omega_pe,
1291
+ "moments_match": moments_match,
1292
+ "outcome_separation": separation,
1293
+ "numerical_validity_passed": numerical_validity,
1294
+ "hypothesis_falsified": eligible and raw_witness,
1295
+ },
1296
+ diagnostics=diagnostics,
1297
+ artifact_hashes=result.artifact_hashes,
1298
+ )
1299
+
1300
+ def cancel(self, job: JobReference) -> JobStatus:
1301
+ return self.scheduler.cancel(job)
1302
+
1303
+
1304
+ def qualify_warpx_picmi_compiler(
1305
+ run: RunPackage,
1306
+ *,
1307
+ python_executable: Path,
1308
+ work_directory: Path,
1309
+ profile_id: str = "warpx_cpu_26_07_compile",
1310
+ ) -> WarpXQualificationRecord:
1311
+ """Import WarpX and compile both generated PICMI cases without evolving them."""
1312
+
1313
+ expected_version = str(run.payload["expected_warpx_version"])
1314
+ python_executable = python_executable.resolve()
1315
+ work_directory = work_directory.resolve()
1316
+ checks: dict[str, bool] = {}
1317
+ compiled: dict[str, str] = {}
1318
+ observed_version: str | None = None
1319
+ work_directory.mkdir(parents=True, exist_ok=True)
1320
+ try:
1321
+ version = subprocess.run(
1322
+ [
1323
+ str(python_executable),
1324
+ "-c",
1325
+ "import importlib.metadata; print(importlib.metadata.version('pywarpx'))",
1326
+ ],
1327
+ capture_output=True,
1328
+ text=True,
1329
+ timeout=60,
1330
+ check=False,
1331
+ )
1332
+ except (OSError, subprocess.TimeoutExpired):
1333
+ checks["runtime_import"] = False
1334
+ checks["runtime_version"] = False
1335
+ else:
1336
+ observed_version = version.stdout.strip() if version.returncode == 0 else None
1337
+ checks["runtime_import"] = version.returncode == 0
1338
+ checks["runtime_version"] = bool(observed_version) and _warpx_versions_equivalent(
1339
+ observed_version,
1340
+ expected_version,
1341
+ )
1342
+
1343
+ for case_payload in run.payload["cases"]:
1344
+ case = WarpXCompiledCase.model_validate(case_payload)
1345
+ case_dir = work_directory / case.case_name
1346
+ case_dir.mkdir(parents=True, exist_ok=True)
1347
+ script_path = case_dir / "run_picmi.py"
1348
+ script_path.write_text(case.script)
1349
+ try:
1350
+ execution = subprocess.run(
1351
+ [str(python_executable), str(script_path), "--compile-only"],
1352
+ cwd=case_dir,
1353
+ capture_output=True,
1354
+ text=True,
1355
+ timeout=180,
1356
+ check=False,
1357
+ )
1358
+ except (OSError, subprocess.TimeoutExpired):
1359
+ checks[f"compile_{case.case_name}"] = False
1360
+ continue
1361
+ input_path = case_dir / "inputs"
1362
+ success = execution.returncode == 0 and input_path.exists()
1363
+ checks[f"compile_{case.case_name}"] = success
1364
+ if success:
1365
+ compiled[case.case_name] = hashlib.sha256(input_path.read_bytes()).hexdigest()
1366
+
1367
+ return WarpXQualificationRecord(
1368
+ profile_id=profile_id,
1369
+ package_hash=run.package_hash,
1370
+ expected_warpx_version=expected_version,
1371
+ observed_warpx_version=observed_version,
1372
+ compiled_case_hashes=compiled,
1373
+ checks=checks,
1374
+ passed=bool(checks) and all(checks.values()),
1375
+ )