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,6 @@
1
+ """Repeatable planted physics benchmarks."""
2
+
3
+ from .kinetic_sufficiency import run_kinetic_sufficiency_benchmark
4
+
5
+ __all__ = ["run_kinetic_sufficiency_benchmark"]
6
+
@@ -0,0 +1,547 @@
1
+ """Independent one-dimensional electrostatic particle-in-cell benchmark.
2
+
3
+ The solver uses cloud-in-cell deposition/interpolation, a spectral periodic
4
+ Poisson solve, leapfrog integration, and quiet-start velocity beams. It is an
5
+ independent numerical check of the analytic dispersion-relation benchmark.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+ from functools import lru_cache
12
+ from typing import Literal
13
+
14
+ import numpy as np
15
+ from pydantic import Field, model_validator
16
+ from scipy.optimize import brentq
17
+ from scipy.special import ndtr, ndtri
18
+
19
+ from ..models import (
20
+ DomainSpec,
21
+ EvidenceContract,
22
+ ExperimentSpec,
23
+ HypothesisNode,
24
+ HypothesisOrigin,
25
+ MatchedPairFormalPredicate,
26
+ ObservableSpec,
27
+ PropositionClass,
28
+ StrictModel,
29
+ )
30
+ from .kinetic_sufficiency import (
31
+ DistributionMoments,
32
+ GaussianMixture,
33
+ moments,
34
+ )
35
+
36
+
37
+ class PICDistribution(StrEnum):
38
+ MAXWELLIAN = "maxwellian"
39
+ SYMMETRIC_TWO_STREAM = "symmetric_two_stream"
40
+
41
+
42
+ class PICNumericalConfig(StrictModel):
43
+ """Distribution-independent PIC discretization and validity contract."""
44
+
45
+ schema_version: Literal["0.1.0"] = "0.1.0"
46
+ wavenumber: float = Field(default=0.5, gt=0)
47
+ grid_cells: int = Field(default=64, ge=32)
48
+ velocity_beams: int = Field(default=256, ge=64)
49
+ particles_per_beam: int = Field(default=64, ge=32)
50
+ time_step: float = Field(default=0.05, gt=0)
51
+ final_time: float = Field(default=20.0, gt=0)
52
+ diagnostic_interval: float = Field(default=0.5, gt=0)
53
+ perturbation_amplitude: float = Field(default=0.01, gt=0, lt=0.1)
54
+ seed: int = Field(default=7, ge=0)
55
+ early_window: tuple[float, float] = (0.0, 4.0)
56
+ late_window: tuple[float, float] = (14.0, 18.0)
57
+ damped_ratio_threshold: float = Field(default=0.75, gt=0, lt=1)
58
+ unstable_ratio_threshold: float = Field(default=1.5, gt=1)
59
+ maximum_relative_energy_drift: float = Field(default=1e-3, gt=0)
60
+ maximum_gauss_residual: float = Field(default=1e-10, gt=0)
61
+
62
+ @model_validator(mode="after")
63
+ def validate_discretization(self) -> PICNumericalConfig:
64
+ if abs(self.wavenumber - 0.5) > 1e-12:
65
+ raise ValueError("PIC benchmark version 1 is preregistered at k=0.5")
66
+ if self.grid_cells & (self.grid_cells - 1):
67
+ raise ValueError("grid_cells must be a power of two")
68
+ if self.velocity_beams % 2:
69
+ raise ValueError("velocity_beams must be even")
70
+ steps = self.final_time / self.time_step
71
+ stride = self.diagnostic_interval / self.time_step
72
+ if abs(steps - round(steps)) > 1e-10:
73
+ raise ValueError("final_time must contain an integer number of steps")
74
+ if abs(stride - round(stride)) > 1e-10:
75
+ raise ValueError("diagnostic_interval must contain an integer number of steps")
76
+ early_start, early_end = self.early_window
77
+ late_start, late_end = self.late_window
78
+ if not 0 <= early_start < early_end < late_start < late_end <= self.final_time:
79
+ raise ValueError("diagnostic windows must be ordered inside the simulated time")
80
+ return self
81
+
82
+
83
+ class PICConfig(PICNumericalConfig):
84
+ """Legacy fixed-pair benchmark configuration."""
85
+
86
+ stream_drift: float = Field(default=0.9, gt=0, lt=1)
87
+
88
+
89
+ DEFAULT_PIC_CONFIG = PICConfig()
90
+ DEFAULT_PIC_NUMERICAL_CONFIG = PICNumericalConfig()
91
+
92
+
93
+ class PICTrace(StrictModel):
94
+ times: tuple[float, ...]
95
+ fundamental_mode_amplitudes: tuple[float, ...]
96
+ total_energies: tuple[float, ...]
97
+ gauss_residuals: tuple[float, ...]
98
+
99
+ @model_validator(mode="after")
100
+ def equal_trace_lengths(self) -> PICTrace:
101
+ lengths = {
102
+ len(self.times),
103
+ len(self.fundamental_mode_amplitudes),
104
+ len(self.total_energies),
105
+ len(self.gauss_residuals),
106
+ }
107
+ if len(lengths) != 1 or not self.times:
108
+ raise ValueError("PIC diagnostic traces must be non-empty and aligned")
109
+ return self
110
+
111
+
112
+ class PICCaseResult(StrictModel):
113
+ distribution: PICDistribution
114
+ config: PICConfig
115
+ particle_count: int = Field(ge=1)
116
+ initial_moments: DistributionMoments
117
+ early_rms_amplitude: float = Field(gt=0)
118
+ late_rms_amplitude: float = Field(gt=0)
119
+ amplitude_ratio: float = Field(gt=0)
120
+ effective_growth_rate: float
121
+ classification: Literal["damped", "unstable", "ambiguous"]
122
+ relative_energy_drift: float = Field(ge=0)
123
+ maximum_gauss_residual: float = Field(ge=0)
124
+ validity_passed: bool
125
+ trace: PICTrace
126
+
127
+
128
+ class PICMixtureCaseResult(StrictModel):
129
+ """PIC result for an explicitly supplied Gaussian mixture."""
130
+
131
+ name: str
132
+ distribution: GaussianMixture
133
+ config: PICNumericalConfig
134
+ particle_count: int = Field(ge=1)
135
+ initial_moments: DistributionMoments
136
+ early_rms_amplitude: float = Field(gt=0)
137
+ late_rms_amplitude: float = Field(gt=0)
138
+ amplitude_ratio: float = Field(gt=0)
139
+ effective_growth_rate: float
140
+ classification: Literal["damped", "unstable", "ambiguous"]
141
+ relative_energy_drift: float = Field(ge=0)
142
+ maximum_gauss_residual: float = Field(ge=0)
143
+ validity_passed: bool
144
+ trace: PICTrace
145
+
146
+
147
+ class PICSufficiencyResult(StrictModel):
148
+ schema_version: Literal["0.1.0"] = "0.1.0"
149
+ hypothesis: HypothesisNode
150
+ observable: ObservableSpec
151
+ maxwellian: PICCaseResult
152
+ two_stream: PICCaseResult
153
+ moments_match: bool
154
+ hypothesis_falsified: bool
155
+
156
+
157
+ def build_pic_problem() -> tuple[HypothesisNode, ObservableSpec]:
158
+ domain = DomainSpec(
159
+ id="domain_electrostatic_1d_pic_k05",
160
+ description=(
161
+ "Small-amplitude periodic 1D electrostatic PIC approximations of normalized "
162
+ "electron distributions with immobile ions at k=0.5"
163
+ ),
164
+ model_family="electrostatic_1d_pic_vlasov_poisson",
165
+ assumptions=(
166
+ "electron plasma frequency equals one",
167
+ "immobile uniform neutralizing ion background",
168
+ "periodic single-mode domain",
169
+ "quiet-start Gaussian velocity beams",
170
+ ),
171
+ fixed_parameters={
172
+ "wavenumber": 0.5,
173
+ "perturbation_amplitude": 0.01,
174
+ "stream_drift": 0.9,
175
+ },
176
+ )
177
+ observable = ObservableSpec(
178
+ id="observable_pic_effective_mode_growth",
179
+ name="PIC effective fundamental-mode envelope growth rate",
180
+ semantic_kind="effective_fundamental_growth_rate",
181
+ mathematical_definition="log(late_mode_rms / early_mode_rms) / time_separation",
182
+ estimator="quiet-start electrostatic PIC Fourier-mode diagnostic",
183
+ units="electron_plasma_frequency",
184
+ tolerance=0.02,
185
+ )
186
+ coordinates = ("density", "mean_velocity", "variance")
187
+ hypothesis = HypothesisNode(
188
+ id="hypothesis_pic_low_moments_sufficient_for_stability",
189
+ statement=(
190
+ "Density, mean velocity, and variance are sufficient to determine the "
191
+ "effective fundamental-mode growth rate in the declared PIC family."
192
+ ),
193
+ machine_predicate=(
194
+ "equal(n, mean_v, variance) implies abs(gamma_left-gamma_right) <= 0.02"
195
+ ),
196
+ formal_predicate=MatchedPairFormalPredicate(
197
+ matched_coordinates=coordinates,
198
+ outcome_observable_id=observable.id,
199
+ maximum_outcome_difference=observable.tolerance,
200
+ ),
201
+ proposition_class=PropositionClass.PREDICTIVE_SUFFICIENCY,
202
+ domain=domain,
203
+ coordinates=coordinates,
204
+ evidence_contract=EvidenceContract(
205
+ primary_observable_id=observable.id,
206
+ falsifying_witness=(
207
+ "a matched-moment pair with PIC effective growth rates separated by "
208
+ "more than 0.02 and opposite stability classifications"
209
+ ),
210
+ primary_tolerance=observable.tolerance,
211
+ ),
212
+ origin=HypothesisOrigin.HUMAN,
213
+ )
214
+ return hypothesis, observable
215
+
216
+
217
+ def build_pic_experiment(config: PICConfig = DEFAULT_PIC_CONFIG) -> ExperimentSpec:
218
+ hypothesis, _ = build_pic_problem()
219
+ return ExperimentSpec(
220
+ id="experiment_electrostatic_pic_sufficiency_v1",
221
+ hypothesis_ids=(hypothesis.id,),
222
+ action_type="kinetic_sufficiency",
223
+ physical_parameters={
224
+ "wavenumber": config.wavenumber,
225
+ "perturbation_amplitude": config.perturbation_amplitude,
226
+ "stream_drift": config.stream_drift,
227
+ },
228
+ numerical_parameters={
229
+ "grid_cells": config.grid_cells,
230
+ "velocity_beams": config.velocity_beams,
231
+ "particles_per_beam": config.particles_per_beam,
232
+ "time_step": config.time_step,
233
+ "final_time": config.final_time,
234
+ "diagnostic_interval": config.diagnostic_interval,
235
+ "seed": config.seed,
236
+ },
237
+ required_diagnostics=(
238
+ "dominant_linear_mode",
239
+ "distribution_moments",
240
+ "energy_conservation",
241
+ "gauss_residual",
242
+ ),
243
+ predictions={
244
+ "hypothesis_holds": "matched distributions have equal effective growth rate",
245
+ "falsifying_witness": (
246
+ "matched distributions have separated rates and opposite stability classes"
247
+ ),
248
+ },
249
+ falsification_condition=(
250
+ "both PIC cases pass validity gates, moments match, classifications are opposite, "
251
+ "and effective rates differ by more than 0.02"
252
+ ),
253
+ )
254
+
255
+
256
+ def _velocity_beams(
257
+ distribution: PICDistribution,
258
+ config: PICConfig,
259
+ ) -> np.ndarray:
260
+ half = config.velocity_beams // 2
261
+ probabilities = (np.arange(half, dtype=float) + 0.5) / half
262
+ thermal = ndtri(probabilities)
263
+ thermal = (thermal - np.mean(thermal)) / np.std(thermal)
264
+ if distribution is PICDistribution.MAXWELLIAN:
265
+ return np.tile(thermal, 2)
266
+ component_sigma = np.sqrt(1.0 - config.stream_drift**2)
267
+ return np.concatenate(
268
+ (
269
+ -config.stream_drift + component_sigma * thermal,
270
+ config.stream_drift + component_sigma * thermal,
271
+ )
272
+ )
273
+
274
+
275
+ def _mixture_velocity_beams(
276
+ distribution: GaussianMixture,
277
+ velocity_beams: int,
278
+ ) -> np.ndarray:
279
+ """Deterministic inverse-CDF beams, rescaled to exact discrete moments."""
280
+
281
+ probabilities = (np.arange(velocity_beams, dtype=float) + 0.5) / velocity_beams
282
+ lower = min(component.drift - 10.0 * component.sigma for component in distribution.components)
283
+ upper = max(component.drift + 10.0 * component.sigma for component in distribution.components)
284
+
285
+ def cdf(value: float) -> float:
286
+ return float(
287
+ sum(
288
+ component.weight
289
+ * ndtr((value - component.drift) / component.sigma)
290
+ for component in distribution.components
291
+ )
292
+ )
293
+
294
+ beams = np.asarray(
295
+ [
296
+ brentq(lambda value, target=target: cdf(value) - target, lower, upper)
297
+ for target in probabilities
298
+ ]
299
+ )
300
+ target = moments(distribution)
301
+ beams = (beams - np.mean(beams)) / np.std(beams)
302
+ beams = beams * np.sqrt(target.variance) + target.mean_velocity
303
+ return beams
304
+
305
+
306
+ def _quiet_start_from_beams(
307
+ beams: np.ndarray,
308
+ config: PICNumericalConfig,
309
+ ) -> tuple[np.ndarray, np.ndarray]:
310
+ if beams.shape != (config.velocity_beams,):
311
+ raise ValueError("velocity beam count does not match the PIC configuration")
312
+ length = 2.0 * np.pi / config.wavenumber
313
+ rng = np.random.default_rng(config.seed)
314
+ offsets = rng.random(config.velocity_beams)
315
+ base = np.arange(config.particles_per_beam, dtype=float)
316
+ unperturbed = (
317
+ (base[None, :] + 0.5 + offsets[:, None]) % config.particles_per_beam
318
+ ) * (length / config.particles_per_beam)
319
+ positions = (
320
+ unperturbed
321
+ - config.perturbation_amplitude
322
+ / config.wavenumber
323
+ * np.sin(config.wavenumber * unperturbed)
324
+ ) % length
325
+ velocities = np.repeat(beams, config.particles_per_beam)
326
+ return positions.ravel(), velocities
327
+
328
+
329
+ def _deposit_and_field(
330
+ positions: np.ndarray,
331
+ config: PICNumericalConfig,
332
+ ) -> tuple[np.ndarray, np.ndarray, float]:
333
+ length = 2.0 * np.pi / config.wavenumber
334
+ cell_width = length / config.grid_cells
335
+ coordinate = positions / cell_width
336
+ left = np.floor(coordinate).astype(np.int64) % config.grid_cells
337
+ fraction = coordinate - left
338
+ density = (
339
+ np.bincount(left, weights=1.0 - fraction, minlength=config.grid_cells)
340
+ + np.bincount(
341
+ (left + 1) % config.grid_cells,
342
+ weights=fraction,
343
+ minlength=config.grid_cells,
344
+ )
345
+ ) * (config.grid_cells / positions.size)
346
+ charge_density = 1.0 - density
347
+ charge_modes = np.fft.fft(charge_density) / config.grid_cells
348
+ wave_numbers = 2.0 * np.pi * np.fft.fftfreq(config.grid_cells, d=cell_width)
349
+ field_modes = np.zeros(config.grid_cells, dtype=complex)
350
+ nonzero = wave_numbers != 0
351
+ field_modes[nonzero] = charge_modes[nonzero] / (1j * wave_numbers[nonzero])
352
+ electric_field = np.fft.ifft(field_modes * config.grid_cells).real
353
+ gauss_residual = float(
354
+ np.max(np.abs(1j * wave_numbers[nonzero] * field_modes[nonzero] - charge_modes[nonzero]))
355
+ )
356
+ return electric_field, field_modes, gauss_residual
357
+
358
+
359
+ def _interpolate_field(
360
+ electric_field: np.ndarray,
361
+ positions: np.ndarray,
362
+ config: PICNumericalConfig,
363
+ ) -> np.ndarray:
364
+ length = 2.0 * np.pi / config.wavenumber
365
+ cell_width = length / config.grid_cells
366
+ coordinate = positions / cell_width
367
+ left = np.floor(coordinate).astype(np.int64) % config.grid_cells
368
+ fraction = coordinate - left
369
+ return (
370
+ electric_field[left] * (1.0 - fraction)
371
+ + electric_field[(left + 1) % config.grid_cells] * fraction
372
+ )
373
+
374
+
375
+ def _window_rms(times: np.ndarray, amplitudes: np.ndarray, window: tuple[float, float]) -> float:
376
+ selected = (times >= window[0]) & (times <= window[1])
377
+ if not np.any(selected):
378
+ raise ValueError("diagnostic window contains no samples")
379
+ return float(np.sqrt(np.mean(amplitudes[selected] ** 2)))
380
+
381
+
382
+ @lru_cache(maxsize=32)
383
+ def run_pic_case(
384
+ distribution: PICDistribution,
385
+ config: PICConfig = DEFAULT_PIC_CONFIG,
386
+ ) -> PICCaseResult:
387
+ beams = _velocity_beams(distribution, config)
388
+ positions, initial_velocities = _quiet_start_from_beams(beams, config)
389
+ result = _run_pic_dynamics(positions, initial_velocities, config)
390
+ return PICCaseResult(
391
+ distribution=distribution,
392
+ config=config,
393
+ **result,
394
+ )
395
+
396
+
397
+ def _run_pic_dynamics(
398
+ positions: np.ndarray,
399
+ initial_velocities: np.ndarray,
400
+ config: PICNumericalConfig,
401
+ ) -> dict[str, object]:
402
+ initial_moments = DistributionMoments(
403
+ density=1.0,
404
+ mean_velocity=float(np.mean(initial_velocities)),
405
+ variance=float(np.var(initial_velocities)),
406
+ )
407
+ electric_field, field_modes, gauss_residual = _deposit_and_field(positions, config)
408
+ velocities_half = initial_velocities - 0.5 * config.time_step * _interpolate_field(
409
+ electric_field,
410
+ positions,
411
+ config,
412
+ )
413
+
414
+ step_count = round(config.final_time / config.time_step)
415
+ diagnostic_stride = round(config.diagnostic_interval / config.time_step)
416
+ times: list[float] = []
417
+ amplitudes: list[float] = []
418
+ energies: list[float] = []
419
+ gauss_residuals: list[float] = []
420
+ length = 2.0 * np.pi / config.wavenumber
421
+
422
+ for step in range(step_count + 1):
423
+ if step % diagnostic_stride == 0:
424
+ integer_velocity = velocities_half + 0.5 * config.time_step * _interpolate_field(
425
+ electric_field,
426
+ positions,
427
+ config,
428
+ )
429
+ times.append(step * config.time_step)
430
+ amplitudes.append(float(abs(field_modes[1])))
431
+ energies.append(
432
+ float(
433
+ 0.5 * np.mean(integer_velocity**2)
434
+ + 0.5 * np.mean(electric_field**2)
435
+ )
436
+ )
437
+ gauss_residuals.append(gauss_residual)
438
+ if step == step_count:
439
+ break
440
+ positions = (positions + config.time_step * velocities_half) % length
441
+ electric_field, field_modes, gauss_residual = _deposit_and_field(positions, config)
442
+ velocities_half -= config.time_step * _interpolate_field(
443
+ electric_field,
444
+ positions,
445
+ config,
446
+ )
447
+
448
+ time_array = np.asarray(times)
449
+ amplitude_array = np.asarray(amplitudes)
450
+ energy_array = np.asarray(energies)
451
+ early_rms = _window_rms(time_array, amplitude_array, config.early_window)
452
+ late_rms = _window_rms(time_array, amplitude_array, config.late_window)
453
+ ratio = late_rms / early_rms
454
+ time_separation = (
455
+ sum(config.late_window) / 2.0 - sum(config.early_window) / 2.0
456
+ )
457
+ effective_growth_rate = float(np.log(ratio) / time_separation)
458
+ if ratio < config.damped_ratio_threshold:
459
+ classification: Literal["damped", "unstable", "ambiguous"] = "damped"
460
+ elif ratio > config.unstable_ratio_threshold:
461
+ classification = "unstable"
462
+ else:
463
+ classification = "ambiguous"
464
+ relative_energy_drift = float(
465
+ (np.max(energy_array) - np.min(energy_array)) / energy_array[0]
466
+ )
467
+ finite = all(
468
+ np.all(np.isfinite(values))
469
+ for values in (positions, velocities_half, amplitude_array, energy_array)
470
+ )
471
+ maximum_gauss_residual = max(gauss_residuals)
472
+ validity_passed = (
473
+ finite
474
+ and relative_energy_drift <= config.maximum_relative_energy_drift
475
+ and maximum_gauss_residual <= config.maximum_gauss_residual
476
+ )
477
+ return {
478
+ "particle_count": positions.size,
479
+ "initial_moments": initial_moments,
480
+ "early_rms_amplitude": early_rms,
481
+ "late_rms_amplitude": late_rms,
482
+ "amplitude_ratio": ratio,
483
+ "effective_growth_rate": effective_growth_rate,
484
+ "classification": classification,
485
+ "relative_energy_drift": relative_energy_drift,
486
+ "maximum_gauss_residual": maximum_gauss_residual,
487
+ "validity_passed": validity_passed,
488
+ "trace": PICTrace(
489
+ times=tuple(times),
490
+ fundamental_mode_amplitudes=tuple(amplitudes),
491
+ total_energies=tuple(energies),
492
+ gauss_residuals=tuple(gauss_residuals),
493
+ ),
494
+ }
495
+
496
+
497
+ @lru_cache(maxsize=32)
498
+ def run_pic_mixture_case(
499
+ name: str,
500
+ distribution: GaussianMixture,
501
+ config: PICNumericalConfig = DEFAULT_PIC_NUMERICAL_CONFIG,
502
+ ) -> PICMixtureCaseResult:
503
+ """Run the independent PIC instrument for a frozen mixture candidate."""
504
+
505
+ beams = _mixture_velocity_beams(distribution, config.velocity_beams)
506
+ positions, initial_velocities = _quiet_start_from_beams(beams, config)
507
+ result = _run_pic_dynamics(positions, initial_velocities, config)
508
+ return PICMixtureCaseResult(
509
+ name=name,
510
+ distribution=distribution,
511
+ config=config,
512
+ **result,
513
+ )
514
+
515
+
516
+ def run_pic_sufficiency_benchmark(
517
+ config: PICConfig = DEFAULT_PIC_CONFIG,
518
+ ) -> PICSufficiencyResult:
519
+ maxwellian = run_pic_case(PICDistribution.MAXWELLIAN, config)
520
+ two_stream = run_pic_case(PICDistribution.SYMMETRIC_TWO_STREAM, config)
521
+ hypothesis, observable = build_pic_problem()
522
+ left = maxwellian.initial_moments
523
+ right = two_stream.initial_moments
524
+ moments_match = all(
525
+ abs(getattr(left, name) - getattr(right, name)) <= 1e-12
526
+ for name in ("density", "mean_velocity", "variance")
527
+ )
528
+ separated = (
529
+ abs(maxwellian.effective_growth_rate - two_stream.effective_growth_rate)
530
+ > hypothesis.evidence_contract.primary_tolerance
531
+ )
532
+ hypothesis_falsified = (
533
+ moments_match
534
+ and maxwellian.validity_passed
535
+ and two_stream.validity_passed
536
+ and maxwellian.classification == "damped"
537
+ and two_stream.classification == "unstable"
538
+ and separated
539
+ )
540
+ return PICSufficiencyResult(
541
+ hypothesis=hypothesis,
542
+ observable=observable,
543
+ maxwellian=maxwellian,
544
+ two_stream=two_stream,
545
+ moments_match=moments_match,
546
+ hypothesis_falsified=hypothesis_falsified,
547
+ )