From 46f986225eea611a4a897783a91a203ea68de958 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 12 May 2026 23:36:11 -0600 Subject: [PATCH 1/7] Polish pecos-neo tool builder execution --- exp/pecos-neo/Cargo.toml | 13 +- exp/pecos-neo/docs/README.md | 2 +- .../docs/design/tags-and-dispatch.md | 22 +- exp/pecos-neo/docs/dev/design-patterns.md | 6 +- exp/pecos-neo/docs/dev/importance-sampling.md | 4 +- .../docs/user-guides/importance-sampling.md | 4 +- exp/pecos-neo/src/adapter.rs | 135 +- exp/pecos-neo/src/lib.rs | 6 +- exp/pecos-neo/src/sampling/design.md | 2 +- exp/pecos-neo/src/tool.rs | 7 +- exp/pecos-neo/src/tool/design.md | 61 +- exp/pecos-neo/src/tool/simulation.rs | 1506 +++++++++++++---- 12 files changed, 1418 insertions(+), 350 deletions(-) diff --git a/exp/pecos-neo/Cargo.toml b/exp/pecos-neo/Cargo.toml index 69786fbac..0a4f57b8d 100644 --- a/exp/pecos-neo/Cargo.toml +++ b/exp/pecos-neo/Cargo.toml @@ -26,11 +26,8 @@ rand_core.workspace = true rayon.workspace = true num_cpus = "1.16" -# Optional: for adapter to wrap classical engines -pecos-engines = { workspace = true, optional = true } - -# Optional: for program types (Qasm, Program enum, etc.) -pecos-programs = { workspace = true, optional = true } +pecos-engines.workspace = true +pecos-programs.workspace = true # Optional: for QASM support pecos-qasm = { workspace = true, optional = true } # Optional: for direct HUGR interpreter support @@ -39,7 +36,6 @@ pecos-hugr = { workspace = true, optional = true } [dev-dependencies] rand.workspace = true num-complex.workspace = true -pecos-engines.workspace = true pecos-qasm.workspace = true pecos-hugr.workspace = true criterion.workspace = true @@ -53,9 +49,8 @@ harness = false [features] default = [] -engines-adapter = ["dep:pecos-engines", "dep:pecos-programs"] -qasm = ["engines-adapter", "dep:pecos-qasm"] -hugr = ["engines-adapter", "dep:pecos-hugr"] +qasm = ["dep:pecos-qasm"] +hugr = ["dep:pecos-hugr"] [lints] workspace = true diff --git a/exp/pecos-neo/docs/README.md b/exp/pecos-neo/docs/README.md index cbdabe7b1..29680c275 100644 --- a/exp/pecos-neo/docs/README.md +++ b/exp/pecos-neo/docs/README.md @@ -93,7 +93,7 @@ Full guide: [Events, Signals, and Handlers](user-guides/events.md) ### Run simple circuits or control the simulation step-by-step `CircuitRunner` is a good fit when you just want to run a circuit without the -full `sim_neo` orchestration, or when you want direct control over the +full `sim_neo` execution stack, or when you want direct control over the simulation process -- stepping through gates, inspecting state between operations, or integrating into your own execution loop. diff --git a/exp/pecos-neo/docs/design/tags-and-dispatch.md b/exp/pecos-neo/docs/design/tags-and-dispatch.md index 71a11f8c9..71b4654a9 100644 --- a/exp/pecos-neo/docs/design/tags-and-dispatch.md +++ b/exp/pecos-neo/docs/design/tags-and-dispatch.md @@ -20,7 +20,7 @@ This works well for standard quantum simulation, but real-world use cases need richer communication between pipeline participants: - A classical engine annotating qubits with zone temperatures or timing metadata -- An orchestrator marking QEC round boundaries for importance sampling sync points +- A sampling controller marking QEC round boundaries for importance sampling sync points - A noise model receiving backend-specific hints (e.g., "use approximate mode") - A simulator receiving custom instructions that aren't standard gates - Per-qubit calibration data flowing from a device model @@ -591,7 +591,7 @@ for round in 0..num_rounds { // ... syndrome extraction circuit ... } -// Orchestrator reads round boundaries via signal handler +// Sampling controller reads round boundaries via signal handler runner.on_signal::(|round| { // round.0 is the round number }); @@ -657,7 +657,7 @@ impl NoiseChannel for AdaptiveNoise { fn name(&self) -> &'static str { "adaptive" } } -// Orchestrator also sees the same signal (observe-only) +// Sampling controller also sees the same signal (observe-only) runner.on_signal::(|round| { // sync point for importance sampling }); @@ -784,28 +784,28 @@ simple data is fast, complex data costs more. No artificial distinction needed. `QubitId`s as fields. Most signals (temperatures, round boundaries, flags) don't need them. -2. **Signals are per-entity; orchestrator gets its own store.** Each entity +2. **Signals are per-entity; sampling controller gets its own store.** Each entity processes its own `CommandQueue` with its own signals -- this is the common - case. The orchestrator can also hold signals in the `World` as a shared + case. The sampling controller can also hold signals in the `World` as a shared resource (e.g., a `SignalStore` component on a well-known entity or a - dedicated `World` resource). This keeps orchestrator-level signals inside + dedicated `World` resource). This keeps sampling-level signals inside the `World` rather than as external state. 3. **Signals are one-directional.** The forward path is engine -> `CommandQueue` -> runner -> consumers. The existing backward paths already cover responses: - `NoiseResponse` (noise channels respond to events including signals) - - `MeasurementOutcomes` (results flow back to the orchestrator) - - Orchestrator inspection of per-shot results (weights, outcomes, etc.) + - `MeasurementOutcomes` (results flow back to the sampling controller) + - Sampling controller inspection of per-shot results (weights, outcomes, etc.) If request/response semantics are needed, define two signal types and use - the orchestrator as intermediary: `CalibrationRequest` flows forward in - shot N, the orchestrator observes results, then injects `CalibrationUpdate` + the sampling controller as intermediary: `CalibrationRequest` flows forward in + shot N, the sampling controller observes results, then injects `CalibrationUpdate` into shot N+1's `CommandQueue`. No special bidirectional infrastructure. 4. **Signals are transient per-shot.** Signals live in the `CommandQueue` and are consumed during shot execution. They do not persist across shots. The - orchestrator can observe results and decide to inject new signals into + sampling controller can observe results and decide to inject new signals into subsequent shots, but the signals themselves are re-emitted each time. 5. **`Signal` trait and `impl_signal!` macro live in pecos-core.** The trait diff --git a/exp/pecos-neo/docs/dev/design-patterns.md b/exp/pecos-neo/docs/dev/design-patterns.md index 1336d2c0d..0654d4067 100644 --- a/exp/pecos-neo/docs/dev/design-patterns.md +++ b/exp/pecos-neo/docs/dev/design-patterns.md @@ -30,7 +30,7 @@ Need to run a quantum circuit simulation? ├─► Estimating rare event probabilities? │ │ │ ├─► P ~ 10^-3 to 10^-6? -│ │ └─► Use sim_neo() with importance_sampling() orchestrator +│ │ └─► Use sim_neo() with importance_sampling() │ │ │ └─► P ~ 10^-6 or smaller? │ └─► Use SubsetSimulation or ProperSubsetSimulation @@ -68,7 +68,7 @@ let results = sim_neo(circuit) // With importance sampling let results = sim_neo(circuit) - .orchestrator(importance_sampling() + .sampling(importance_sampling() .with_p1(0.001) .with_boost(10.0)) .shots(10000) @@ -224,7 +224,7 @@ Complex configuration uses nested builders that compose naturally: ```rust // Top-level builder accepts nested builders sim_neo(circuit) - .orchestrator(importance_sampling() // Nested builder + .sampling(importance_sampling() // Nested builder .with_p1(0.001) .with_boost(10.0)) .quantum(sparse_stab()) // Another nested builder diff --git a/exp/pecos-neo/docs/dev/importance-sampling.md b/exp/pecos-neo/docs/dev/importance-sampling.md index 1c07ba86c..b044bf133 100644 --- a/exp/pecos-neo/docs/dev/importance-sampling.md +++ b/exp/pecos-neo/docs/dev/importance-sampling.md @@ -28,7 +28,7 @@ let circuit = CommandBuilder::new() // Run with importance sampling let results = sim_neo(circuit) - .orchestrator(importance_sampling() + .sampling(importance_sampling() .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_p_meas(0.001) // Measurement error rate @@ -50,7 +50,7 @@ For uniform error rates, use the `with_uniform_error()` method: ```rust let results = sim_neo(circuit) - .orchestrator(importance_sampling() + .sampling(importance_sampling() .with_uniform_error(0.001) // Same rate for all gate types .with_boost(10.0)) .shots(10000) diff --git a/exp/pecos-neo/docs/user-guides/importance-sampling.md b/exp/pecos-neo/docs/user-guides/importance-sampling.md index 79fa26c6a..6d5d73f6a 100644 --- a/exp/pecos-neo/docs/user-guides/importance-sampling.md +++ b/exp/pecos-neo/docs/user-guides/importance-sampling.md @@ -9,7 +9,7 @@ reweighting the results. Much more efficient than brute-force Monte Carlo. use pecos_neo::tool::{sim_neo, importance_sampling}; let results = sim_neo(circuit) - .orchestrator(importance_sampling() + .sampling(importance_sampling() .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_boost(10.0)) // Sample 10x more errors @@ -22,7 +22,7 @@ For uniform rates across all gate types: ```rust sim_neo(circuit) - .orchestrator(importance_sampling() + .sampling(importance_sampling() .with_uniform_error(0.001) .with_boost(10.0)) .shots(10000) diff --git a/exp/pecos-neo/src/adapter.rs b/exp/pecos-neo/src/adapter.rs index 51d7a3cee..29e0ae177 100644 --- a/exp/pecos-neo/src/adapter.rs +++ b/exp/pecos-neo/src/adapter.rs @@ -30,8 +30,6 @@ //! ## Example //! //! ```rust,no_run -//! #[cfg(feature = "engines-adapter")] -//! fn example() { //! use std::str::FromStr; //! use pecos_neo::adapter::ClassicalEngineAdapter; //! use pecos_neo::prelude::*; @@ -62,14 +60,11 @@ //! .with_noise(noise); //! //! let result = runner.run_shot(&mut program); -//! } //! ``` use crate::command::{CommandQueue, GateCommand, GateType as NeoGateType}; -#[cfg(feature = "engines-adapter")] -use crate::outcome::MeasurementOutcomes; -#[cfg(feature = "engines-adapter")] -use crate::program::CommandSource; +use crate::outcome::{MeasurementOutcome, MeasurementOutcomes}; +use crate::program::{CommandSource, DynProgramRunner, ProgramResult}; use pecos_core::gate_type::GateType as CoreGateType; use pecos_core::gates::Gate; use pecos_core::{Angle64, QubitId}; @@ -164,7 +159,6 @@ fn convert_gate(gate: &Gate) -> Result Result { @@ -182,7 +176,6 @@ pub fn byte_message_to_command_queue( /// Convert `MeasurementOutcomes` to a `ByteMessage` containing outcomes. /// /// The outcomes are ordered by qubit ID for consistency with the engine's expectations. -#[cfg(feature = "engines-adapter")] #[must_use] pub fn outcomes_to_byte_message(outcomes: &MeasurementOutcomes) -> pecos_engines::ByteMessage { let mut builder = pecos_engines::ByteMessage::outcomes_builder(); @@ -198,7 +191,6 @@ pub fn outcomes_to_byte_message(outcomes: &MeasurementOutcomes) -> pecos_engines /// /// This allows existing engines (`QASMEngine`, `HugrEngine`, etc.) to be used /// with pecos-neo's `ProgramRunner` and sampling infrastructure. -#[cfg(feature = "engines-adapter")] pub struct ClassicalEngineAdapter { /// The wrapped classical control engine. engine: E, @@ -211,8 +203,6 @@ pub struct ClassicalEngineAdapter { /// Track measurement count for outcome ordering. measurement_count: usize, } - -#[cfg(feature = "engines-adapter")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AdapterState { /// Not yet started. @@ -222,8 +212,6 @@ enum AdapterState { /// Complete. Complete, } - -#[cfg(feature = "engines-adapter")] impl ClassicalEngineAdapter where E: pecos_engines::ClassicalControlEngine, @@ -296,8 +284,6 @@ where } } } - -#[cfg(feature = "engines-adapter")] impl CommandSource for ClassicalEngineAdapter where E: pecos_engines::ClassicalControlEngine, @@ -356,6 +342,123 @@ where } } +/// Runner adapter for executing pecos-neo command sources through a +/// `pecos_engines::QuantumEngine`. +/// +/// This lets `sim_neo()` accept the same Rust quantum engine builders as the +/// stable `sim()` API without identifying backends by name. The adapter only +/// translates between the two execution protocols; unsupported behavior is +/// rejected by the surrounding builder before this runner is constructed. +pub struct QuantumEngineProgramRunner { + engine: Box, +} +impl QuantumEngineProgramRunner { + /// Create a new runner around a quantum engine. + #[must_use] + pub fn new(engine: Box) -> Self { + Self { engine } + } + + fn commands_to_message(commands: &CommandQueue) -> pecos_engines::ByteMessage { + let gates = command_queue_to_gates(commands); + let mut builder = pecos_engines::ByteMessage::quantum_operations_builder(); + builder.add_gate_commands(&gates); + builder.build() + } + + fn measured_qubits(commands: &CommandQueue) -> Vec { + commands + .iter() + .filter(|cmd| { + matches!( + cmd.gate_type, + NeoGateType::MZ | NeoGateType::MeasureLeaked | NeoGateType::MeasureFree + ) + }) + .flat_map(|cmd| cmd.qubits.iter().copied()) + .collect() + } + + fn outcomes_from_message( + message: &pecos_engines::ByteMessage, + measured_qubits: &[QubitId], + ) -> Result { + let values = message.outcomes()?; + if values.len() != measured_qubits.len() { + return Err(pecos_core::errors::PecosError::Processing(format!( + "quantum engine returned {} measurement outcomes for {} measured qubits", + values.len(), + measured_qubits.len() + ))); + } + + let mut outcomes = MeasurementOutcomes::with_capacity(values.len()); + for (&qubit, value) in measured_qubits.iter().zip(values.iter().copied()) { + match value { + 0 => outcomes.record(MeasurementOutcome::new(qubit, false, false)), + 1 => outcomes.record(MeasurementOutcome::new(qubit, true, false)), + 2 => outcomes.record(MeasurementOutcome::leaked(qubit)), + other => { + return Err(pecos_core::errors::PecosError::Processing(format!( + "quantum engine returned invalid measurement outcome {other}" + ))); + } + } + } + + Ok(outcomes) + } +} +impl DynProgramRunner for QuantumEngineProgramRunner { + fn run_shot(&mut self, source: &mut dyn CommandSource) -> ProgramResult { + source.reset(); + self.engine + .reset() + .expect("quantum engine reset should not fail"); + + let mut all_outcomes = MeasurementOutcomes::new(); + let mut num_batches = 0; + let mut last_outcomes: Option = None; + + loop { + let commands = source.next_commands(last_outcomes.as_ref()); + + match commands { + Some(cmds) if !cmds.is_empty() => { + let measured_qubits = Self::measured_qubits(&cmds); + let message = Self::commands_to_message(&cmds); + let response = self + .engine + .process(message) + .expect("quantum engine command batch should execute"); + let outcomes = Self::outcomes_from_message(&response, &measured_qubits) + .expect("quantum engine outcomes should match measured qubits"); + + num_batches += 1; + for outcome in outcomes.iter() { + all_outcomes.record(*outcome); + } + last_outcomes = Some(outcomes); + } + _ => break, + } + + if source.is_complete() { + break; + } + } + + ProgramResult { + outcomes: all_outcomes, + num_batches, + } + } + + fn set_full_seed(&mut self, seed: u64) { + self.engine.set_seed(seed); + } +} + // ============================================================================ // Gate conversion utilities (always available) // ============================================================================ diff --git a/exp/pecos-neo/src/lib.rs b/exp/pecos-neo/src/lib.rs index e9296bdb9..085555c22 100644 --- a/exp/pecos-neo/src/lib.rs +++ b/exp/pecos-neo/src/lib.rs @@ -248,10 +248,10 @@ pub use runner::{CircuitRunner, EventHandlers, ExecutionError, GateExecutorFn, G // Re-export adapter utilities (always available) pub use adapter::{command_queue_to_gates, gate_to_command, gates_to_command_queue}; -// Re-export ClassicalEngineAdapter when engines-adapter feature is enabled -#[cfg(feature = "engines-adapter")] +// Re-export classical/quantum engine adapter utilities. pub use adapter::{ - ClassicalEngineAdapter, byte_message_to_command_queue, outcomes_to_byte_message, + ClassicalEngineAdapter, QuantumEngineProgramRunner, byte_message_to_command_queue, + outcomes_to_byte_message, }; /// Prelude module for convenient imports. diff --git a/exp/pecos-neo/src/sampling/design.md b/exp/pecos-neo/src/sampling/design.md index c6bd505b3..68af6b3a5 100644 --- a/exp/pecos-neo/src/sampling/design.md +++ b/exp/pecos-neo/src/sampling/design.md @@ -538,7 +538,7 @@ fn enumerate_paths( - Implements `CommandSource` to bridge to pecos-neo infrastructure - Conversion utilities: `gate_to_command`, `gates_to_command_queue`, `command_queue_to_gates` - `ByteMessage` <-> `CommandQueue` / `MeasurementOutcomes` conversion - - Feature-gated via `engines-adapter` feature + - Always available as part of the core `pecos-neo` builder integration ### Next Steps diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 6803f8937..7393aa270 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -98,11 +98,10 @@ pub use resource::{Resource, Resources}; pub use simulation::{ Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, NoiseResource, QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, - SimulatorFactory, SparseStabBuilder, StateVecBuilder, StoredOverrides, custom_backend, - custom_backend_from_factory, custom_backend_with_rotations, importance_sampling, sim_neo, - sim_neo_builder, sparse_stab, state_vector, + SimulatorFactory, SparseStabBuilder, StabilizerBuilder, StateVecBuilder, StoredOverrides, + custom_backend, custom_backend_from_factory, custom_backend_with_rotations, + importance_sampling, sim_neo, sim_neo_builder, sparse_stab, stabilizer, state_vector, }; -#[cfg(feature = "engines-adapter")] pub use simulation::{PendingEngineBuilder, TypedProgram}; pub use system::{IntoSystem, Schedule, System}; diff --git a/exp/pecos-neo/src/tool/design.md b/exp/pecos-neo/src/tool/design.md index fb67a160d..7ccdcd3f4 100644 --- a/exp/pecos-neo/src/tool/design.md +++ b/exp/pecos-neo/src/tool/design.md @@ -2,8 +2,24 @@ ## Overview -This document describes the Bevy-inspired `Tool` architecture for pecos-neo, providing a -flexible, plugin-based system for building various quantum simulation and validation tools. +PECOS is a QEC/QCVV library for solving concrete research and engineering +tasks by forming tools. A tool is the runnable workflow the user cares about: +quantum simulation, DEM sampling, fault catalog generation, decoding, +validation, tomography, or a related analysis workflow. + +Common tools are usually exposed through convenience functions that return +specialized builders. Those builders often use a builder-of-builders pattern: +users pass smaller builders for quantum engines, classical engines, noise +models, sampling strategies, decoders, and other role-specific components. +Those component builders can come from PECOS or from external crates/packages, +as long as they implement the relevant traits and capability contracts. This +keeps PECOS flexible without requiring every extension to be hard-coded into a +central backend enum. + +Underneath those public builders, pecos-neo uses a `Tool` architecture inspired +by Bevy's `App` approach: resources, systems, plugins, schedules, and +data-oriented execution plans. This lets different tools share implementation +components without forcing the public API into one generic abstraction. ## Design Goals @@ -13,6 +29,37 @@ flexible, plugin-based system for building various quantum simulation and valida 4. **Convenient**: Specialized builders like `sim_neo()` mirror `sim()` ergonomics 5. **Extensible**: Easy to add new tool types (simulation, FT validation, etc.) +## Public API Framing + +PECOS should expose domain-specific tool builders, not one generic backend +switch. Entry points such as `sim_neo(...)`, future DEM sampling helpers, fault +catalog builders, and validation tools are convenience functions that assemble a +`Tool` from shared components. Most of these entry points follow a +builder-of-builders pattern: + +```rust +sim_neo(circuit) + .classical(qasm_engine()) + .quantum(stabilizer()) + .sampling(importance_sampling()) + .build() + .run(); +``` + +The public method names should describe the domain role of each component: + +- `.quantum(...)` selects a quantum simulator/engine for circuit-like sources. +- `.sampling(...)` selects the statistical strategy, such as Monte Carlo or + rare-event sampling. +- `.noise(...)` configures noise modeling when the tool supports it. + +Avoid exposing a broad `.backend(...)` concept at the main user layer. A DEM +sampler, a quantum state simulator, and a fault-catalog enumerator can share +execution infrastructure internally, but they are different tools from the +user's perspective. Internally, runner/driver/factory traits can stay generic +and capability-based; externally, the API should remain scientific-domain +specific and immediately readable. + ## Architecture ``` @@ -183,7 +230,7 @@ impl SimNeoBuilder { .insert_resource(self.config) .insert_resource(QuantumBackendResource(self.quantum_backend)); - Simulation { tool, orchestrator: self.orchestrator, parallel_data } + Simulation { tool, sampling: self.sampling, parallel_data } } } ``` @@ -195,7 +242,7 @@ Reusable handle that wraps a configured Tool: ```rust pub struct Simulation { tool: Tool, - orchestrator: Orchestrator, + sampling: Sampling, parallel_data: Option, } @@ -293,7 +340,7 @@ impl Plugin for NoisePlugin { ### `ImportanceSamplingSimPlugin` -When the importance sampling orchestrator is selected, this plugin replaces +When the importance sampling strategy is selected, this plugin replaces `UnifiedSimulationPlugin`. It uses `ImportanceSamplingRunner` internally for biased noise with weight tracking, running through the same Stage/Schedule system as Monte Carlo execution. @@ -319,7 +366,7 @@ just as they do for Monte Carlo. A standalone plugin for manual weight tracking (pre-shot reset, post-shot recording, finish statistics). Available for users building custom Tool -configurations. Not used by the IS orchestrator path, which handles its own +configurations. Not used by the IS sampling path, which handles its own weight storage via `SimulationResults.weights`. ## Results @@ -415,7 +462,7 @@ let results = tool.resource::(); ### Phase 3: Advanced Features - [x] `ImportanceSamplingPlugin` -- [x] Parallel execution (MonteCarlo orchestrator with workers > 1) +- [x] Parallel execution (MonteCarlo sampling with workers > 1) - [ ] `FTValidatorBuilder` and FT validation ### Phase 4: Integration diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index d218dc1cd..022e66aae 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -122,7 +122,9 @@ use crate::sampling::importance_runner::ImportanceSamplingRunner; use pecos_core::rng::RngManageable; use pecos_core::rng::rng_manageable::derive_seed; use pecos_random::PecosRng; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, SparseStab, StateVec}; +use pecos_simulators::{ + ArbitraryRotationGateable, CliffordGateable, SparseStab, Stabilizer, StateVec, +}; use rayon::prelude::*; use std::collections::BTreeMap; @@ -144,12 +146,24 @@ pub enum QuantumBackend { #[default] SparseStab, + /// Public stabilizer simulator. + /// + /// Uses PECOS's stable stabilizer simulator interface while preserving + /// Clifford-only semantics. + Stabilizer, + /// State vector simulator. /// /// Supports arbitrary gates including non-Clifford (T, rotations). /// Memory scales as 2^n for n qubits. StateVec, + /// Adapted `pecos-engines` quantum-engine builder. + /// + /// This path uses `QuantumEngineProgramRunner` to execute `sim_neo` command + /// batches through the gate-by-gate `QuantumEngine` protocol. + AdaptedQuantumEngine(Box), + /// Custom simulator backend via factory function. /// /// Allows any simulator implementing `CliffordGateable + RngManageable` @@ -164,7 +178,9 @@ impl std::fmt::Debug for QuantumBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SparseStab => write!(f, "SparseStab"), + Self::Stabilizer => write!(f, "Stabilizer"), Self::StateVec => write!(f, "StateVec"), + Self::AdaptedQuantumEngine(_) => write!(f, "AdaptedQuantumEngine(...)"), Self::Custom(_) => write!(f, "Custom(...)"), } } @@ -190,6 +206,27 @@ impl From for QuantumBackend { } } +/// Builder for the public stabilizer backend configuration. +/// +/// Currently a simple marker type; future versions may add configuration +/// options while preserving the stable simulator interface. +#[derive(Debug, Clone, Default)] +pub struct StabilizerBuilder; + +impl StabilizerBuilder { + /// Create a new stabilizer builder. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl From for QuantumBackend { + fn from(_: StabilizerBuilder) -> Self { + QuantumBackend::Stabilizer + } +} + /// Builder for state vector backend configuration. /// /// Currently a simple marker type; future versions may add configuration options @@ -234,6 +271,30 @@ pub fn sparse_stab() -> SparseStabBuilder { SparseStabBuilder::new() } +/// Create a stabilizer backend builder. +/// +/// This is the stable public stabilizer backend for Clifford circuits. Use +/// [`sparse_stab()`] when you specifically want the current sparse-tableau +/// implementation. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{sim_neo, stabilizer}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); +/// let results = sim_neo(circuit) +/// .quantum(stabilizer()) +/// .shots(1000) +/// .build() +/// .run(); +/// ``` +#[must_use] +pub fn stabilizer() -> StabilizerBuilder { + StabilizerBuilder::new() +} + /// Create a state vector backend builder. /// /// The state vector simulator supports arbitrary gates including non-Clifford @@ -269,6 +330,15 @@ pub fn state_vector() -> StateVecBuilder { /// Implement this directly only for advanced use cases (e.g., simulators that /// need custom noise injection or seed handling). pub trait SimulatorFactory: Send + Sync { + /// Short diagnostic label for error messages. + /// + /// This is not used for dispatch; execution is selected by the trait + /// object itself. The label only keeps unsupported-configuration errors + /// readable after type erasure. + fn diagnostic_label(&self) -> &'static str { + "custom backend" + } + /// Create a program runner for the given number of qubits. /// /// Called once during simulation startup. The returned runner handles @@ -285,6 +355,58 @@ pub trait SimulatorFactory: Send + Sync { seed: Option, ) -> Box; } +#[doc(hidden)] +pub trait AdaptedQuantumEngineFactory: Send + Sync { + fn create_runner(&self, num_qubits: usize, seed: Option) -> Box; + + fn create_parallel_runner_factory( + &self, + num_qubits: usize, + ) -> Box; +} +struct QuantumEngineSimulatorFactory +where + B: pecos_engines::QuantumEngineBuilder + Clone + 'static, +{ + builder: B, +} +impl AdaptedQuantumEngineFactory for QuantumEngineSimulatorFactory +where + B: pecos_engines::QuantumEngineBuilder + Clone + 'static, +{ + fn create_runner(&self, num_qubits: usize, seed: Option) -> Box { + let mut builder = self.builder.clone(); + builder.set_qubits_if_needed(num_qubits); + let mut engine = builder + .build() + .expect("Failed to build quantum engine backend"); + if let Some(seed) = seed { + engine.set_seed(seed); + } + Box::new(crate::adapter::QuantumEngineProgramRunner::new(engine)) + } + + fn create_parallel_runner_factory( + &self, + num_qubits: usize, + ) -> Box { + Box::new(AdaptedQuantumEngineRunnerFactory { + builder: self.builder.clone(), + num_qubits, + }) + } +} +impl From for QuantumBackend +where + B: pecos_engines::IntoQuantumEngineBuilder + 'static, + B::Builder: Clone + 'static, +{ + fn from(builder: B) -> Self { + QuantumBackend::AdaptedQuantumEngine(Box::new(QuantumEngineSimulatorFactory { + builder: builder.into_quantum_engine_builder(), + })) + } +} /// Blanket implementation for closures that create simulators. /// @@ -482,6 +604,13 @@ impl SimNeoInput for CommandQueue { } } +/// Implementation for boxed dynamic command sources. +impl SimNeoInput for Box { + fn into_sim_neo_builder(self) -> SimNeoBuilder { + SimNeoBuilder::with_command_source(self) + } +} + /// Implementation for `TickCircuit`. impl SimNeoInput for pecos_quantum::TickCircuit { fn into_sim_neo_builder(self) -> SimNeoBuilder { @@ -525,7 +654,6 @@ impl SimNeoInput for &pecos_quantum::DagCircuit { /// .build() /// .run(); /// ``` -#[cfg(feature = "engines-adapter")] impl SimNeoInput for &str { fn into_sim_neo_builder(self) -> SimNeoBuilder { SimNeoBuilder::with_program_source(self.to_string()) @@ -533,7 +661,6 @@ impl SimNeoInput for &str { } /// Implementation for `String` (program source code). -#[cfg(feature = "engines-adapter")] impl SimNeoInput for String { fn into_sim_neo_builder(self) -> SimNeoBuilder { SimNeoBuilder::with_program_source(self) @@ -566,7 +693,6 @@ impl SimNeoInput for String { /// .build() /// .run(); /// ``` -#[cfg(feature = "engines-adapter")] impl SimNeoInput for pecos_programs::Qasm { fn into_sim_neo_builder(self) -> SimNeoBuilder { SimNeoBuilder::with_typed_program(TypedProgram::Qasm(self)) @@ -588,7 +714,6 @@ impl SimNeoInput for pecos_programs::Qasm { /// .build() /// .run(); /// ``` -#[cfg(feature = "engines-adapter")] impl SimNeoInput for pecos_programs::Hugr { fn into_sim_neo_builder(self) -> SimNeoBuilder { SimNeoBuilder::with_typed_program(TypedProgram::Hugr(self)) @@ -611,7 +736,6 @@ impl SimNeoInput for pecos_programs::Hugr { /// .build() /// .run(); /// ``` -#[cfg(feature = "engines-adapter")] impl SimNeoInput for pecos_programs::Program { fn into_sim_neo_builder(self) -> SimNeoBuilder { let typed = match self { @@ -647,7 +771,7 @@ impl Default for SimConfig { } } -/// Builder for importance sampling orchestration. +/// Builder for importance sampling configuration. /// /// Specifies the true error rates and boost factor for biased sampling. /// Use the [`importance_sampling()`] function to create an instance. @@ -735,7 +859,7 @@ impl ImportanceSamplingBuilder { self } - /// Build the sampling. + /// Build the sampling strategy. #[must_use] pub fn build(self) -> Sampling { Sampling::ImportanceSampling { config: self } @@ -778,7 +902,7 @@ impl From for Sampling { } } -/// Create an importance sampling sampling builder. +/// Create an importance sampling strategy builder. /// /// Importance sampling biases noise toward higher error rates to observe /// rare events more frequently, then reweights results for unbiased estimates. @@ -812,7 +936,7 @@ pub fn importance_sampling() -> ImportanceSamplingBuilder { ImportanceSamplingBuilder::new() } -/// Orchestration strategy for simulation execution. +/// Sampling strategy for simulation execution. /// /// This enum defines how shots are executed. Different strategies offer /// trade-offs between simplicity, parallelism, and specialized sampling. @@ -853,13 +977,13 @@ impl Default for Sampling { } impl Sampling { - /// Create a Monte Carlo sampling with specified workers. + /// Create a Monte Carlo sampling strategy with specified workers. #[must_use] pub fn monte_carlo(workers: usize) -> Self { Self::MonteCarlo { workers } } - /// Create a Monte Carlo sampling with auto-detected worker count. + /// Create a Monte Carlo sampling strategy with auto-detected worker count. #[must_use] pub fn monte_carlo_auto() -> Self { let workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); @@ -1060,6 +1184,8 @@ struct MaxDecompDepthResource(usize); pub enum StoredOverrides { /// Overrides for the sparse stabilizer backend. SparseStab(GateOverrides), + /// Overrides for the public stabilizer backend. + Stabilizer(GateOverrides), /// Overrides for the state vector backend. StateVec(GateOverrides), } @@ -1070,6 +1196,12 @@ impl From> for StoredOverrides { } } +impl From> for StoredOverrides { + fn from(overrides: GateOverrides) -> Self { + Self::Stabilizer(overrides) + } +} + impl From> for StoredOverrides { fn from(overrides: GateOverrides) -> Self { Self::StateVec(overrides) @@ -1087,8 +1219,10 @@ struct EventHandlersResource(EventHandlers); /// Trait for type-erased engine building. /// /// This allows storing different engine builder types uniformly. -#[cfg(feature = "engines-adapter")] pub trait BoxedEngineBuilder: Send + Sync { + /// Clone this builder into a boxed trait object for independent workers. + fn clone_box(&self) -> Box; + /// Build the classical engine and wrap it in an adapter. /// /// # Errors @@ -1105,23 +1239,31 @@ pub trait BoxedEngineBuilder: Send + Sync { #[allow(dead_code)] fn num_qubits_hint(&self) -> Option; } +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} /// Wrapper for concrete classical engine builders. -#[cfg(feature = "engines-adapter")] struct EngineBuilderWrapper where - B: pecos_engines::ClassicalControlEngineBuilder + Send + Sync, + B: pecos_engines::ClassicalControlEngineBuilder + Clone + Send + Sync, B::Engine: 'static, { builder: B, } - -#[cfg(feature = "engines-adapter")] impl BoxedEngineBuilder for EngineBuilderWrapper where - B: pecos_engines::ClassicalControlEngineBuilder + Send + Sync, + B: pecos_engines::ClassicalControlEngineBuilder + Clone + Send + Sync + 'static, B::Engine: 'static, { + fn clone_box(&self) -> Box { + Box::new(EngineBuilderWrapper { + builder: self.builder.clone(), + }) + } + fn build_adapter( self: Box, ) -> Result, pecos_core::errors::PecosError> { @@ -1137,44 +1279,39 @@ where } } -/// Engine builder stored as data, waiting for source to be configured at build time. +/// Engine builder stored as data, waiting for source text to be configured at build time. /// -/// This enum holds engine builders in their unconfigured state. At `.build()` time, -/// the source code is injected and the builder is configured. This follows the -/// "everything is data" principle - we store configuration as data and defer -/// actual construction to build time. -#[cfg(feature = "engines-adapter")] -pub enum PendingEngineBuilder { - /// QASM engine builder (requires `qasm` feature) - #[cfg(feature = "qasm")] - Qasm(pecos_qasm::QasmEngineBuilder), - /// HUGR engine builder (requires `hugr` feature) - #[cfg(feature = "hugr")] - Hugr(pecos_hugr::HugrEngineBuilder), +/// This keeps `.classical(builder)` shape-based instead of tying it to a closed +/// list of built-in language frontends. Built-in QASM/HUGR builders provide +/// `From` impls when those optional frontend features are enabled, and external +/// crates can construct this wrapper with [`PendingEngineBuilder::from_source_builder`]. +pub struct PendingEngineBuilder { + configure: Box Box + Send + Sync>, } -#[cfg(feature = "engines-adapter")] impl PendingEngineBuilder { + /// Create a pending source builder from a function that accepts raw source + /// and returns a configured classical-engine builder. + pub fn from_source_builder(configure: F) -> Self + where + B: pecos_engines::ClassicalControlEngineBuilder + Clone + Send + Sync + 'static, + B::Engine: 'static, + F: FnOnce(String) -> B + Send + Sync + 'static, + { + Self { + configure: Box::new(move |source| { + Box::new(EngineBuilderWrapper { + builder: configure(source), + }) + }), + } + } + /// Configure this builder with source and return a boxed engine builder. /// /// Called at `.build()` time to inject the source into the stored builder. fn configure_with_source(self, source: String) -> Box { - match self { - #[cfg(feature = "qasm")] - Self::Qasm(builder) => { - let configured = builder.qasm(source); - Box::new(EngineBuilderWrapper { - builder: configured, - }) - } - #[cfg(feature = "hugr")] - Self::Hugr(builder) => { - let configured = builder.hugr_bytes(source.into_bytes()); - Box::new(EngineBuilderWrapper { - builder: configured, - }) - } - } + (self.configure)(source) } } @@ -1182,7 +1319,7 @@ impl PendingEngineBuilder { #[cfg(feature = "qasm")] impl From for PendingEngineBuilder { fn from(builder: pecos_qasm::QasmEngineBuilder) -> Self { - Self::Qasm(builder) + Self::from_source_builder(move |source| builder.qasm(source)) } } @@ -1190,7 +1327,7 @@ impl From for PendingEngineBuilder { #[cfg(feature = "hugr")] impl From for PendingEngineBuilder { fn from(builder: pecos_hugr::HugrEngineBuilder) -> Self { - Self::Hugr(builder) + Self::from_source_builder(move |source| builder.hugr_bytes(source.into_bytes())) } } @@ -1198,21 +1335,19 @@ impl From for PendingEngineBuilder { pub enum ProgramSource { /// A static circuit (no mid-circuit feedback). Static(CommandQueue), + /// A dynamic command source. + Dynamic(Box), /// Raw program source code (needs engine factory to interpret). - #[cfg(feature = "engines-adapter")] RawSource(String), /// A typed program (knows its type, can use `.auto()` for engine selection). - #[cfg(feature = "engines-adapter")] Typed(TypedProgram), /// A classical engine builder (supports mid-circuit feedback). - #[cfg(feature = "engines-adapter")] Classical(Box), } /// Typed program variants for automatic engine selection. /// /// When using `.auto()`, the appropriate engine is selected based on the variant. -#[cfg(feature = "engines-adapter")] #[derive(Debug, Clone)] pub enum TypedProgram { /// QASM program - uses `qasm_engine()` @@ -1229,6 +1364,15 @@ pub struct ProgramSourceResource(pub ProgramSource); /// Temporary storage for current shot outcomes. struct CurrentOutcomes(MeasurementOutcomes); +fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { + circuit + .iter() + .flat_map(|cmd| cmd.qubits.iter()) + .map(|q| q.0) + .max() + .map_or(1, |max| max + 1) +} + // --- SimNeoBuilder --- /// Builder for configuring simulation tools (builder-of-builders pattern). @@ -1290,7 +1434,6 @@ pub struct SimNeoBuilder { /// The program source (circuit, raw source, or engine builder). source: Option, /// Engine builder stored as data, waiting for source at build time. - #[cfg(feature = "engines-adapter")] pending_builder: Option, /// Noise model (collected as data, used at build time). noise: Option, @@ -1298,7 +1441,7 @@ pub struct SimNeoBuilder { definitions: Option, /// Simulation configuration (data). config: SimConfig, - /// Orchestration strategy (data). + /// Sampling strategy (data). sampling: Sampling, /// Quantum backend configuration (data). quantum_backend: QuantumBackend, @@ -1318,7 +1461,24 @@ impl SimNeoBuilder { pub fn with_circuit(circuit: CommandQueue) -> Self { Self { source: Some(ProgramSource::Static(circuit)), - #[cfg(feature = "engines-adapter")] + pending_builder: None, + noise: None, + definitions: None, + config: SimConfig::default(), + sampling: Sampling::default(), + quantum_backend: QuantumBackend::default(), + explicit_num_qubits: None, + max_decomp_depth: None, + overrides: None, + event_handlers: None, + } + } + + /// Create a simulation builder for a dynamic command source. + #[must_use] + pub fn with_command_source(source: Box) -> Self { + Self { + source: Some(ProgramSource::Dynamic(source)), pending_builder: None, noise: None, definitions: None, @@ -1336,7 +1496,6 @@ impl SimNeoBuilder { /// /// Use `.classical(builder)` to specify how to interpret the source. #[must_use] - #[cfg(feature = "engines-adapter")] pub fn with_program_source(source: String) -> Self { Self { source: Some(ProgramSource::RawSource(source)), @@ -1358,7 +1517,6 @@ impl SimNeoBuilder { /// Use `.auto()` to automatically select the engine, or /// `.classical(builder)` for explicit control. #[must_use] - #[cfg(feature = "engines-adapter")] pub fn with_typed_program(program: TypedProgram) -> Self { Self { source: Some(ProgramSource::Typed(program)), @@ -1388,7 +1546,6 @@ impl SimNeoBuilder { pub fn empty() -> Self { Self { source: None, - #[cfg(feature = "engines-adapter")] pending_builder: None, noise: None, definitions: None, @@ -1417,7 +1574,7 @@ impl SimNeoBuilder { /// let results = sim_neo(qasm_code) /// .classical(qasm_engine()) // stores builder as data /// .shots(1000) - /// .build() // orchestrates: configures builder, builds engine, creates Tool + /// .build() // configures builder, builds engine, creates Tool /// .run(); /// ``` /// @@ -1438,7 +1595,6 @@ impl SimNeoBuilder { /// # Panics /// /// Panics if no raw source was provided via `sim_neo(source_code)`. - #[cfg(feature = "engines-adapter")] #[must_use] pub fn classical(mut self, builder: B) -> Self where @@ -1474,6 +1630,12 @@ impl SimNeoBuilder { Use sim_neo(source_code).classical(builder) for classical engines." ); } + Some(ProgramSource::Dynamic(_)) => { + panic!( + "Cannot use .classical() with an existing dynamic command source. \ + Use sim_neo(source_code).classical(builder) for classical engines." + ); + } Some(ProgramSource::Classical(_)) => { panic!( "Classical engine already set. \ @@ -1508,11 +1670,10 @@ impl SimNeoBuilder { /// .build() /// .run(); /// ``` - #[cfg(feature = "engines-adapter")] #[must_use] pub fn with_engine(mut self, engine_builder: B) -> Self where - B: pecos_engines::ClassicalControlEngineBuilder + Send + Sync + 'static, + B: pecos_engines::ClassicalControlEngineBuilder + Clone + Send + Sync + 'static, B::Engine: 'static, { self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { @@ -1548,58 +1709,54 @@ impl SimNeoBuilder { /// - No typed program was provided (use `sim_neo(Qasm::from_string(...))`) /// - The program type is not yet supported for auto-selection /// - /// Note: `.auto()` also sets the orchestration strategy automatically: - /// - **Static circuits**: Monte Carlo with auto-detected parallel workers - /// - **Classical engines** (QASM, HUGR, etc.): single-worker Monte Carlo - /// (classical engines maintain state across operations and cannot be parallelized) - #[cfg(feature = "engines-adapter")] + /// Note: `.auto()` also selects the default Monte Carlo sampling strategy. The + /// parallel execution plan decides whether the selected command source and + /// quantum backend can safely build independent worker state. #[must_use] pub fn auto(mut self) -> Self { - let is_classical = match self.source.take() { - Some(ProgramSource::Typed(typed)) => { - match typed { - #[cfg(feature = "qasm")] - TypedProgram::Qasm(qasm) => { - // Auto-select qasm_engine() and configure with the program - let builder = pecos_qasm::qasm_engine().qasm(qasm.source); - self.source = - Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { - builder, - }))); - true - } - #[cfg(not(feature = "qasm"))] - TypedProgram::Qasm(_) => { - panic!( - "QASM auto-selection requires the 'qasm' feature. \ - Enable it with: features = [\"qasm\"]" - ); - } - #[cfg(feature = "hugr")] - TypedProgram::Hugr(hugr) => { - // Auto-select hugr_engine() and configure with the program - let builder = pecos_hugr::hugr_engine().hugr_bytes(hugr.hugr); - self.source = - Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { - builder, - }))); - true - } - #[cfg(not(feature = "hugr"))] - TypedProgram::Hugr(_) => { - panic!( - "HUGR auto-selection requires the 'hugr' feature. \ - Enable it with: features = [\"hugr\"]" - ); - } - TypedProgram::Unsupported(type_name) => { - panic!( - "Program type '{type_name}' is not yet supported for auto-selection. \ - Use .classical(engine) to specify the engine explicitly." - ); - } + match self.source.take() { + Some(ProgramSource::Typed(typed)) => match typed { + #[cfg(feature = "qasm")] + TypedProgram::Qasm(qasm) => { + // Auto-select qasm_engine() and configure with the program. + let builder = pecos_qasm::qasm_engine().qasm(qasm.source); + self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + builder, + }))); + self.sampling = Sampling::monte_carlo_auto(); + self } - } + #[cfg(not(feature = "qasm"))] + TypedProgram::Qasm(_) => { + panic!( + "QASM auto-selection requires the 'qasm' feature. \ + Enable it with: features = [\"qasm\"]" + ); + } + #[cfg(feature = "hugr")] + TypedProgram::Hugr(hugr) => { + // Auto-select hugr_engine() and configure with the program. + let builder = pecos_hugr::hugr_engine().hugr_bytes(hugr.hugr); + self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + builder, + }))); + self.sampling = Sampling::monte_carlo_auto(); + self + } + #[cfg(not(feature = "hugr"))] + TypedProgram::Hugr(_) => { + panic!( + "HUGR auto-selection requires the 'hugr' feature. \ + Enable it with: features = [\"hugr\"]" + ); + } + TypedProgram::Unsupported(type_name) => { + panic!( + "Program type '{type_name}' is not yet supported for auto-selection. \ + Use .classical(engine) to specify the engine explicitly." + ); + } + }, Some(ProgramSource::RawSource(_)) => { panic!( "Cannot use .auto() with raw string source. \ @@ -1613,6 +1770,12 @@ impl SimNeoBuilder { Static circuits don't need an engine - just call .build() directly." ); } + Some(ProgramSource::Dynamic(_)) => { + panic!( + "Cannot use .auto() with an existing dynamic command source. \ + Command sources are already executable." + ); + } Some(ProgramSource::Classical(_)) => { panic!( "Engine already configured. \ @@ -1625,16 +1788,7 @@ impl SimNeoBuilder { Use sim_neo(Qasm::from_string(...)).auto() or similar." ); } - }; - - // Classical engines are stateful and cannot be parallelized across workers. - // Static circuits can run in parallel since each worker gets its own simulator. - if is_classical { - self.sampling = Sampling::MonteCarlo { workers: 1 }; - } else { - self.sampling = Sampling::monte_carlo_auto(); } - self } /// Set the number of qubits explicitly. @@ -1661,7 +1815,7 @@ impl SimNeoBuilder { self } - /// Set the orchestration strategy for simulation execution. + /// Set the sampling strategy for simulation execution. /// /// # Example /// @@ -1754,7 +1908,12 @@ impl SimNeoBuilder { self } - /// Set the noise model. + /// Set the `sim_neo` noise model. + /// + /// This configures `sim_neo`'s noise-modeling layer. It is intentionally + /// separate from the quantum-engine builder protocol; backends that only + /// provide quantum execution reject this configuration instead of silently + /// ignoring it. /// /// Accepts any type that implements `Into`: /// - `ComposableNoiseModel` directly @@ -1952,7 +2111,6 @@ impl SimNeoBuilder { #[must_use] pub fn build(self) -> Simulation { // Resolve the program source - configure pending builder with source if needed - #[cfg(feature = "engines-adapter")] let source = { match (self.source, self.pending_builder) { // Raw source + pending builder = configure and use @@ -1993,54 +2151,17 @@ impl SimNeoBuilder { } }; - #[cfg(not(feature = "engines-adapter"))] - let source = self - .source - .expect("No program source set. Use sim_neo(circuit) to provide a circuit."); - - // Extract parallel execution data for static circuits using built-in backends. - // Custom backends can't be parallelized (factory is consumed at startup). - let parallel_data = match (&source, &self.quantum_backend) { - (ProgramSource::Static(circuit), QuantumBackend::SparseStab) => { - let inferred_qubits = circuit - .iter() - .flat_map(|cmd| cmd.qubits.iter()) - .map(|q| q.0) - .max() - .map_or(1, |max| max + 1); - let num_qubits = self.explicit_num_qubits.unwrap_or(inferred_qubits); - - Some(ParallelExecutionData { - circuit: circuit.clone(), - num_qubits, - backend: BuiltinBackend::SparseStab, - noise: self.noise.clone(), - definitions: self.definitions.clone(), - max_decomp_depth: self.max_decomp_depth, - overrides: self.overrides.clone(), - event_handlers: self.event_handlers.clone(), - }) - } - (ProgramSource::Static(circuit), QuantumBackend::StateVec) => { - let inferred_qubits = circuit - .iter() - .flat_map(|cmd| cmd.qubits.iter()) - .map(|q| q.0) - .max() - .map_or(1, |max| max + 1); - let num_qubits = self.explicit_num_qubits.unwrap_or(inferred_qubits); - - Some(ParallelExecutionData { - circuit: circuit.clone(), - num_qubits, - backend: BuiltinBackend::StateVec, - noise: self.noise.clone(), - definitions: self.definitions.clone(), - max_decomp_depth: self.max_decomp_depth, - overrides: self.overrides.clone(), - event_handlers: self.event_handlers.clone(), - }) - } + let parallel_plan = match self.sampling { + Sampling::MonteCarlo { workers } if workers > 1 => build_parallel_execution_plan( + &source, + &self.quantum_backend, + self.explicit_num_qubits, + self.noise.clone(), + self.definitions.clone(), + self.max_decomp_depth, + self.overrides.clone(), + self.event_handlers.clone(), + ), _ => None, }; @@ -2091,7 +2212,7 @@ impl SimNeoBuilder { Simulation { tool, sampling: self.sampling, - parallel_data, + parallel_plan, } } @@ -2162,6 +2283,8 @@ impl Plugin for UnifiedSimulationPlugin { pub enum QuantumRunner { /// Sparse stabilizer simulator (Clifford-only). SparseStab(ProgramRunner), + /// Public stabilizer simulator (Clifford-only). + Stabilizer(ProgramRunner), /// State vector simulator (supports arbitrary gates). StateVec(ProgramRunner), /// Custom simulator backend via dynamic dispatch. @@ -2173,6 +2296,7 @@ impl QuantumRunner { pub fn run_shot(&mut self, source: &mut dyn CommandSource) -> crate::program::ProgramResult { match self { Self::SparseStab(runner) => runner.run_shot(source), + Self::Stabilizer(runner) => runner.run_shot(source), Self::StateVec(runner) => runner.run_shot(source), Self::Custom(runner) => runner.run_shot(source), } @@ -2182,6 +2306,7 @@ impl QuantumRunner { pub fn set_full_seed(&mut self, seed: u64) { match self { Self::SparseStab(pr) => pr.set_full_seed(seed), + Self::Stabilizer(pr) => pr.set_full_seed(seed), Self::StateVec(pr) => pr.set_full_seed(seed), Self::Custom(runner) => runner.set_full_seed(seed), } @@ -2198,6 +2323,138 @@ pub struct UnifiedShotState { pub shot_index: usize, } +fn reject_dynamic_runner_config( + backend_name: &str, + definitions: Option<&GateDefinitionsResource>, + max_depth: Option<&MaxDecompDepthResource>, + overrides: Option<&GateOverridesResource>, + event_handlers: Option<&EventHandlersResource>, +) { + assert!( + definitions.is_none(), + "{backend_name} does not support sim_neo gate definitions. \ + Put custom gate handling inside the backend runner/factory instead." + ); + assert!( + max_depth.is_none(), + "{backend_name} does not support sim_neo gate decomposition depth. \ + Put decomposition handling inside the backend runner/factory instead." + ); + assert!( + overrides.is_none(), + "{backend_name} does not support sim_neo gate overrides. \ + Put override handling inside the backend runner/factory instead." + ); + assert!( + event_handlers.is_none(), + "{backend_name} does not support sim_neo event handlers. \ + Use a ProgramRunner-based backend when event hooks are required." + ); +} +fn reject_parallel_adapted_engine_config( + noise: Option<&ComposableNoiseModel>, + definitions: Option<&GateDefinitions>, + max_depth: Option<&usize>, + overrides: Option<&StoredOverrides>, + event_handlers: Option<&EventHandlers>, +) { + assert!( + noise.is_none(), + "QuantumEngineBuilder backends do not support sim_neo noise modeling. \ + Use a noise-modeling runner/backend instead." + ); + assert!( + definitions.is_none(), + "QuantumEngineBuilder backend does not support sim_neo gate definitions. \ + Put custom gate handling inside the backend runner/factory instead." + ); + assert!( + max_depth.is_none(), + "QuantumEngineBuilder backend does not support sim_neo gate decomposition depth. \ + Put decomposition handling inside the backend runner/factory instead." + ); + assert!( + overrides.is_none(), + "QuantumEngineBuilder backend does not support sim_neo gate overrides. \ + Put override handling inside the backend runner/factory instead." + ); + assert!( + event_handlers.is_none(), + "QuantumEngineBuilder backend does not support sim_neo event handlers. \ + Use a ProgramRunner-based backend when event hooks are required." + ); +} + +fn apply_standard_runner_config( + mut runner: ProgramRunner, + noise: Option, + seed: Option, + max_depth: Option, +) -> ProgramRunner +where + S: CliffordGateable, +{ + if let Some(n) = noise { + runner = runner.with_noise(n.0); + } + if let Some(seed) = seed { + runner = runner.with_seed(seed); + } + if let Some(d) = max_depth { + runner = runner.with_max_decomp_depth(d.0); + } + runner +} + +fn apply_event_handlers( + mut runner: ProgramRunner, + event_handlers: Option, +) -> ProgramRunner +where + S: CliffordGateable, +{ + if let Some(eh) = event_handlers { + runner = runner.with_event_handlers(eh.0); + } + runner +} + +fn clifford_runner( + simulator: S, + definitions: Option, + noise: Option, + seed: Option, + max_depth: Option, +) -> ProgramRunner +where + S: CliffordGateable, +{ + let runner = if let Some(defs) = definitions { + ProgramRunner::with_definitions(simulator, defs.0) + } else { + ProgramRunner::new(simulator) + }; + apply_standard_runner_config(runner, noise, seed, max_depth) +} + +fn rotation_runner( + simulator: S, + definitions: Option, + noise: Option, + seed: Option, + max_depth: Option, +) -> ProgramRunner +where + S: CliffordGateable + ArbitraryRotationGateable, +{ + let runner = if let Some(defs) = definitions { + ProgramRunner::rotations_with_definitions(simulator, defs.0) + } else { + ProgramRunner::rotations(simulator) + }; + apply_standard_runner_config(runner, noise, seed, max_depth) +} + /// Startup system for unified simulation. fn unified_simulation_startup(resources: &mut Resources) { let config = resources.get::().clone(); @@ -2234,7 +2491,10 @@ fn unified_simulation_startup(resources: &mut Resources) { let program = StaticProgram::new(circuit, num_qubits); (Box::new(program), num_qubits) } - #[cfg(feature = "engines-adapter")] + ProgramSource::Dynamic(source) => { + let num_qubits = explicit_qubits.unwrap_or_else(|| source.num_qubits()); + (source, num_qubits) + } ProgramSource::RawSource(_) => { // This should never happen - build() resolves RawSource with engine factory unreachable!( @@ -2242,7 +2502,6 @@ fn unified_simulation_startup(resources: &mut Resources) { This is a bug in the simulation builder." ); } - #[cfg(feature = "engines-adapter")] ProgramSource::Typed(_) => { // This should never happen - build() catches Typed without .auto() unreachable!( @@ -2250,7 +2509,6 @@ fn unified_simulation_startup(resources: &mut Resources) { This is a bug in the simulation builder." ); } - #[cfg(feature = "engines-adapter")] ProgramSource::Classical(engine_builder) => { // Build the engine adapter let adapter = engine_builder @@ -2273,25 +2531,23 @@ fn unified_simulation_startup(resources: &mut Resources) { let event_handlers = resources.try_remove::(); let quantum_runner = match backend { QuantumBackend::SparseStab => { - let sim = SparseStab::new(num_qubits); - let mut runner = if let Some(defs) = definitions { - ProgramRunner::with_definitions(sim, defs.0) - } else { - ProgramRunner::new(sim) - }; - if let Some(n) = noise { - runner = runner.with_noise(n.0); - } - if let Some(seed) = config.seed { - runner = runner.with_seed(seed); - } - if let Some(ref d) = max_depth { - runner = runner.with_max_decomp_depth(d.0); - } - if let Some(ref o) = overrides { + let mut runner = clifford_runner( + SparseStab::new(num_qubits), + definitions, + noise, + config.seed, + max_depth, + ); + if let Some(o) = overrides { match o.0 { - StoredOverrides::SparseStab(ref ov) => { - runner = runner.with_overrides(ov.clone()); + StoredOverrides::SparseStab(ov) => { + runner = runner.with_overrides(ov); + } + StoredOverrides::Stabilizer(_) => { + panic!( + "Stabilizer gate overrides used with SparseStab backend. \ + Use GateOverrides:: instead." + ); } StoredOverrides::StateVec(_) => { panic!( @@ -2301,31 +2557,57 @@ fn unified_simulation_startup(resources: &mut Resources) { } } } - if let Some(ref eh) = event_handlers { - runner = runner.with_event_handlers(eh.0.clone()); - } + runner = apply_event_handlers(runner, event_handlers); QuantumRunner::SparseStab(runner) } - QuantumBackend::StateVec => { - let sim = StateVec::new(num_qubits); - let mut runner = if let Some(defs) = definitions { - ProgramRunner::rotations_with_definitions(sim, defs.0) - } else { - ProgramRunner::rotations(sim) - }; - if let Some(n) = noise { - runner = runner.with_noise(n.0); - } - if let Some(seed) = config.seed { - runner = runner.with_seed(seed); - } - if let Some(ref d) = max_depth { - runner = runner.with_max_decomp_depth(d.0); + QuantumBackend::Stabilizer => { + let mut runner = clifford_runner( + Stabilizer::new(num_qubits), + definitions, + noise, + config.seed, + max_depth, + ); + if let Some(o) = overrides { + match o.0 { + StoredOverrides::Stabilizer(ov) => { + runner = runner.with_overrides(ov); + } + StoredOverrides::SparseStab(_) => { + panic!( + "SparseStab gate overrides used with Stabilizer backend. \ + Use GateOverrides:: instead." + ); + } + StoredOverrides::StateVec(_) => { + panic!( + "StateVec gate overrides used with Stabilizer backend. \ + Use GateOverrides:: instead." + ); + } + } } - if let Some(ref o) = overrides { + runner = apply_event_handlers(runner, event_handlers); + QuantumRunner::Stabilizer(runner) + } + QuantumBackend::StateVec => { + let mut runner = rotation_runner( + StateVec::new(num_qubits), + definitions, + noise, + config.seed, + max_depth, + ); + if let Some(o) = overrides { match o.0 { - StoredOverrides::StateVec(ref ov) => { - runner = runner.with_overrides(ov.clone()); + StoredOverrides::StateVec(ov) => { + runner = runner.with_overrides(ov); + } + StoredOverrides::Stabilizer(_) => { + panic!( + "Stabilizer gate overrides used with StateVec backend. \ + Use GateOverrides:: instead." + ); } StoredOverrides::SparseStab(_) => { panic!( @@ -2335,12 +2617,33 @@ fn unified_simulation_startup(resources: &mut Resources) { } } } - if let Some(ref eh) = event_handlers { - runner = runner.with_event_handlers(eh.0.clone()); - } + runner = apply_event_handlers(runner, event_handlers); QuantumRunner::StateVec(runner) } + QuantumBackend::AdaptedQuantumEngine(factory) => { + reject_dynamic_runner_config( + "QuantumEngineBuilder backend", + definitions.as_ref(), + max_depth.as_ref(), + overrides.as_ref(), + event_handlers.as_ref(), + ); + assert!( + noise.is_none(), + "QuantumEngineBuilder backends do not support sim_neo noise modeling. \ + Use a noise-modeling runner/backend instead." + ); + let runner = factory.create_runner(num_qubits, config.seed); + QuantumRunner::Custom(runner) + } QuantumBackend::Custom(factory) => { + reject_dynamic_runner_config( + factory.diagnostic_label(), + definitions.as_ref(), + max_depth.as_ref(), + overrides.as_ref(), + event_handlers.as_ref(), + ); // Custom backends create their own runner; gate definitions // should be captured in the factory closure if needed. let runner = factory.create_runner(num_qubits, noise.map(|n| n.0), config.seed); @@ -2399,7 +2702,7 @@ fn unified_simulation_post_shot(resources: &mut Resources) { /// Plugin for importance-sampling simulation. /// -/// Replaces [`UnifiedSimulationPlugin`] when the IS sampling is selected. +/// Replaces [`UnifiedSimulationPlugin`] when importance sampling is selected. /// Uses [`ImportanceSamplingRunner`] for biased noise with weight tracking. struct ImportanceSamplingSimPlugin { is_config: ImportanceSamplingBuilder, @@ -2461,12 +2764,12 @@ fn is_sim_startup(resources: &mut Resources) { let source_resource = resources.remove::(); let is_config = resources.remove::().0; - #[cfg(not(feature = "engines-adapter"))] - let ProgramSource::Static(circuit) = source_resource.0; - #[cfg(feature = "engines-adapter")] let circuit = match source_resource.0 { ProgramSource::Static(circuit) => circuit, - ProgramSource::RawSource(_) | ProgramSource::Typed(_) | ProgramSource::Classical(_) => { + ProgramSource::Dynamic(_) + | ProgramSource::RawSource(_) + | ProgramSource::Typed(_) + | ProgramSource::Classical(_) => { panic!( "Importance sampling requires a static circuit. \ Classical engines are not supported." @@ -2569,52 +2872,286 @@ fn is_sim_post_shot(resources: &mut Resources) { /// ``` pub struct Simulation { tool: Tool, - /// Orchestration strategy (stored as data). + /// Sampling strategy (stored as data). sampling: Sampling, - /// Data for parallel execution (if applicable). - /// Stored separately from Tool to allow cloning for workers. - parallel_data: Option, + /// Data-oriented plan for parallel execution (if applicable). + parallel_plan: Option, } -/// Which built-in backend to use for parallel/importance-sampling execution. +/// Native backend used by the internal parallel runner factory. #[derive(Debug, Clone, Copy)] -enum BuiltinBackend { +enum NativeParallelBackend { SparseStab, + Stabilizer, StateVec, } -/// Data stored for parallel execution support. -/// -/// This is populated for static circuits using built-in backends. -/// For classical engines or custom backends, this is None and execution falls back to sequential. -struct ParallelExecutionData { - /// The circuit to execute (cloned for each worker). +trait ParallelCommandSourceFactory: Send + Sync { + fn create_source(&self) -> Box; +} + +#[doc(hidden)] +pub trait ParallelQuantumRunnerFactory: Send + Sync { + fn create_runner(&self, seed: Option) -> QuantumRunner; +} + +struct ParallelExecutionPlan { + command_source_factory: Box, + quantum_runner_factory: Box, +} + +struct StaticCommandSourceFactory { circuit: CommandQueue, - /// Number of qubits for simulators. num_qubits: usize, - /// Which built-in backend to use. - backend: BuiltinBackend, - /// Noise model (cloned per worker for parallel execution). +} + +impl ParallelCommandSourceFactory for StaticCommandSourceFactory { + fn create_source(&self) -> Box { + Box::new(StaticProgram::new(self.circuit.clone(), self.num_qubits)) + } +} +struct ClassicalCommandSourceFactory { + builder: Box, +} +impl ParallelCommandSourceFactory for ClassicalCommandSourceFactory { + fn create_source(&self) -> Box { + self.builder + .clone() + .build_adapter() + .expect("Failed to build classical engine for worker") + } +} + +struct NativeQuantumRunnerFactory { + backend: NativeParallelBackend, + num_qubits: usize, noise: Option, - /// Gate definitions (cloned per worker for parallel execution). definitions: Option, - /// Max decomposition depth (cloned per worker for parallel execution). max_decomp_depth: Option, - /// Gate overrides (cloned per worker for parallel execution). overrides: Option, - /// Event handlers (cloned per worker for parallel execution). event_handlers: Option, } -impl Simulation { - /// Set the number of shots for the next run. - pub fn shots(&mut self, shots: usize) -> &mut Self { - self.tool.resource_mut::().shots = shots; - self - } - - /// Set the seed for the next run. - pub fn seed(&mut self, seed: u64) -> &mut Self { +impl ParallelQuantumRunnerFactory for NativeQuantumRunnerFactory { + fn create_runner(&self, seed: Option) -> QuantumRunner { + let noise = self.noise.clone().map(NoiseResource); + let definitions = self.definitions.clone().map(GateDefinitionsResource); + let max_depth = self.max_decomp_depth.map(MaxDecompDepthResource); + let event_handlers = self.event_handlers.clone().map(EventHandlersResource); + + match self.backend { + NativeParallelBackend::SparseStab => { + let mut runner = clifford_runner( + SparseStab::new(self.num_qubits), + definitions, + noise, + seed, + max_depth, + ); + if let Some(overrides) = self.overrides.clone() { + match overrides { + StoredOverrides::SparseStab(ov) => runner = runner.with_overrides(ov), + StoredOverrides::Stabilizer(_) => { + panic!( + "Stabilizer gate overrides used with SparseStab backend. \ + Use GateOverrides:: instead." + ); + } + StoredOverrides::StateVec(_) => { + panic!( + "StateVec gate overrides used with SparseStab backend. \ + Use GateOverrides:: instead." + ); + } + } + } + runner = apply_event_handlers(runner, event_handlers); + QuantumRunner::SparseStab(runner) + } + NativeParallelBackend::Stabilizer => { + let mut runner = clifford_runner( + Stabilizer::new(self.num_qubits), + definitions, + noise, + seed, + max_depth, + ); + if let Some(overrides) = self.overrides.clone() { + match overrides { + StoredOverrides::Stabilizer(ov) => runner = runner.with_overrides(ov), + StoredOverrides::SparseStab(_) => { + panic!( + "SparseStab gate overrides used with Stabilizer backend. \ + Use GateOverrides:: instead." + ); + } + StoredOverrides::StateVec(_) => { + panic!( + "StateVec gate overrides used with Stabilizer backend. \ + Use GateOverrides:: instead." + ); + } + } + } + runner = apply_event_handlers(runner, event_handlers); + QuantumRunner::Stabilizer(runner) + } + NativeParallelBackend::StateVec => { + let mut runner = rotation_runner( + StateVec::new(self.num_qubits), + definitions, + noise, + seed, + max_depth, + ); + if let Some(overrides) = self.overrides.clone() { + match overrides { + StoredOverrides::StateVec(ov) => runner = runner.with_overrides(ov), + StoredOverrides::SparseStab(_) => { + panic!( + "SparseStab gate overrides used with StateVec backend. \ + Use GateOverrides:: instead." + ); + } + StoredOverrides::Stabilizer(_) => { + panic!( + "Stabilizer gate overrides used with StateVec backend. \ + Use GateOverrides:: instead." + ); + } + } + } + runner = apply_event_handlers(runner, event_handlers); + QuantumRunner::StateVec(runner) + } + } + } +} +struct AdaptedQuantumEngineRunnerFactory +where + B: pecos_engines::QuantumEngineBuilder + Clone + 'static, +{ + builder: B, + num_qubits: usize, +} +impl ParallelQuantumRunnerFactory for AdaptedQuantumEngineRunnerFactory +where + B: pecos_engines::QuantumEngineBuilder + Clone + 'static, +{ + fn create_runner(&self, seed: Option) -> QuantumRunner { + let mut builder = self.builder.clone(); + builder.set_qubits_if_needed(self.num_qubits); + let mut engine = builder + .build() + .expect("Failed to build quantum engine backend for worker"); + if let Some(seed) = seed { + engine.set_seed(seed); + } + QuantumRunner::Custom(Box::new(crate::adapter::QuantumEngineProgramRunner::new( + engine, + ))) + } +} + +#[allow(clippy::too_many_arguments)] +fn build_parallel_execution_plan( + source: &ProgramSource, + backend: &QuantumBackend, + explicit_num_qubits: Option, + noise: Option, + definitions: Option, + max_decomp_depth: Option, + overrides: Option, + event_handlers: Option, +) -> Option { + let (source_factory, num_qubits): (Box, usize) = match source + { + ProgramSource::Static(circuit) => { + let num_qubits = + explicit_num_qubits.unwrap_or_else(|| infer_num_qubits_from_circuit(circuit)); + ( + Box::new(StaticCommandSourceFactory { + circuit: circuit.clone(), + num_qubits, + }), + num_qubits, + ) + } + ProgramSource::Dynamic(_) => return None, + ProgramSource::Classical(engine_builder) => { + let probe = engine_builder + .clone() + .build_adapter() + .expect("Failed to build classical engine while preparing parallel plan"); + let num_qubits = explicit_num_qubits.unwrap_or_else(|| probe.num_qubits()); + ( + Box::new(ClassicalCommandSourceFactory { + builder: engine_builder.clone(), + }), + num_qubits, + ) + } + ProgramSource::RawSource(_) | ProgramSource::Typed(_) => { + unreachable!("raw and typed sources should be resolved before plan construction") + } + }; + + let runner_factory: Box = match backend { + QuantumBackend::SparseStab => Box::new(NativeQuantumRunnerFactory { + backend: NativeParallelBackend::SparseStab, + num_qubits, + noise, + definitions, + max_decomp_depth, + overrides, + event_handlers, + }), + QuantumBackend::Stabilizer => Box::new(NativeQuantumRunnerFactory { + backend: NativeParallelBackend::Stabilizer, + num_qubits, + noise, + definitions, + max_decomp_depth, + overrides, + event_handlers, + }), + QuantumBackend::StateVec => Box::new(NativeQuantumRunnerFactory { + backend: NativeParallelBackend::StateVec, + num_qubits, + noise, + definitions, + max_decomp_depth, + overrides, + event_handlers, + }), + QuantumBackend::AdaptedQuantumEngine(factory) => { + reject_parallel_adapted_engine_config( + noise.as_ref(), + definitions.as_ref(), + max_decomp_depth.as_ref(), + overrides.as_ref(), + event_handlers.as_ref(), + ); + factory.create_parallel_runner_factory(num_qubits) + } + QuantumBackend::Custom(_) => return None, + }; + + Some(ParallelExecutionPlan { + command_source_factory: source_factory, + quantum_runner_factory: runner_factory, + }) +} + +impl Simulation { + /// Set the number of shots for the next run. + pub fn shots(&mut self, shots: usize) -> &mut Self { + self.tool.resource_mut::().shots = shots; + self + } + + /// Set the seed for the next run. + pub fn seed(&mut self, seed: u64) -> &mut Self { self.tool.resource_mut::().seed = Some(seed); self } @@ -2624,28 +3161,29 @@ impl Simulation { /// Returns the simulation results. The simulation can be run again /// after reconfiguring with [`shots()`](Self::shots) or [`seed()`](Self::seed). /// - /// Execution strategy depends on the sampling: + /// Execution strategy depends on the sampling strategy: /// - `MonteCarlo { workers: 1 }`: Runs shots via the Tool (default) /// - `MonteCarlo { workers: n }`: Parallelizes shots across n workers /// - `ImportanceSampling`: Runs via the Tool with `ImportanceSamplingSimPlugin` /// /// # Panics - /// Panics if parallel Monte Carlo is used without a static circuit and built-in backend. + /// Panics if parallel Monte Carlo is used without per-worker runner construction support. pub fn run(&mut self) -> SimulationResults { let config = self.tool.resource::().clone(); - // Dispatch based on orchestration strategy + // Dispatch based on sampling strategy match &self.sampling { Sampling::MonteCarlo { workers } if *workers > 1 => { - let data = self.parallel_data.as_ref().unwrap_or_else(|| { + let plan = self.parallel_plan.as_ref().unwrap_or_else(|| { panic!( - "Parallel Monte Carlo requires a static circuit \ - using a built-in backend (SparseStab or StateVec). \ - Remove .workers() / .auto_workers() for single-worker execution, \ - or switch to a built-in backend." + "Parallel Monte Carlo requires per-worker runner construction support. \ + Dynamic programs need a command-source factory, and custom backends \ + need a quantum-runner factory. Remove .workers() / .auto_workers() \ + for single-worker execution, or use a backend/source path with \ + explicit per-worker construction." ) }); - self.run_parallel(&config, data, *workers) + self.run_parallel(&config, plan, *workers) } _ => { // Both MonteCarlo{workers:1} and ImportanceSampling run via the Tool. @@ -2669,7 +3207,7 @@ impl Simulation { fn run_parallel( &self, config: &SimConfig, - data: &ParallelExecutionData, + plan: &ParallelExecutionPlan, num_workers: usize, ) -> SimulationResults { let shots = config.shots; @@ -2698,32 +3236,16 @@ impl Simulation { shots: worker_shots, seed: config.seed, }); - resources.insert(ProgramSourceResource(ProgramSource::Static( - data.circuit.clone(), - ))); - resources.insert(QuantumBackendResource(match data.backend { - BuiltinBackend::SparseStab => QuantumBackend::SparseStab, - BuiltinBackend::StateVec => QuantumBackend::StateVec, - })); - resources.insert(ExplicitNumQubits(Some(data.num_qubits))); + resources.insert(ExplicitNumQubits(None)); resources.insert(SimulationResults::new()); - if let Some(ref noise) = data.noise { - resources.insert(NoiseResource(noise.clone())); - } - if let Some(ref defs) = data.definitions { - resources.insert(GateDefinitionsResource(defs.clone())); - } - if let Some(depth) = data.max_decomp_depth { - resources.insert(MaxDecompDepthResource(depth)); - } - if let Some(ref overrides) = data.overrides { - resources.insert(GateOverridesResource(overrides.clone())); - } - if let Some(ref handlers) = data.event_handlers { - resources.insert(EventHandlersResource(handlers.clone())); - } + resources.insert(UnifiedShotState { + quantum_runner: plan.quantum_runner_factory.create_runner(config.seed), + command_source: plan.command_source_factory.create_source(), + shot_index: 0, + }); - // Run Startup (creates runner/simulator via UnifiedSimulationPlugin systems) + // Run Startup. Since the worker state is already assembled, the + // unified startup system only resets the command source and clears results. schedule.run_stage(Stage::Startup, &mut resources); // Set global starting shot index so per-shot seeding matches sequential @@ -2904,6 +3426,7 @@ mod tests { use super::*; use crate::command::CommandBuilder; use crate::noise::{ComposableNoiseModel, SingleQubitChannel}; + use crate::program::ConditionalProgram; use pecos_core::QubitId; #[test] @@ -3283,8 +3806,8 @@ mod tests { } #[test] - fn test_sim_neo_monte_carlo_orchestrator() { - // Test Monte Carlo orchestration with multiple workers + fn test_sim_neo_monte_carlo_sampling() { + // Test Monte Carlo sampling with multiple workers let circuit = CommandBuilder::new() .pz(&[0]) .x(&[0]) // Flip to |1> @@ -3329,7 +3852,7 @@ mod tests { } #[test] - fn test_sim_neo_orchestrator_explicit() { + fn test_sim_neo_sampling_explicit() { // Test explicit sampling configuration use super::Sampling; @@ -3502,6 +4025,396 @@ mod tests { } } + #[test] + fn test_sim_neo_quantum_stabilizer() { + // Test explicitly selecting the stable public stabilizer backend. + use super::stabilizer; + + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit) + .quantum(stabilizer()) + .shots(10) + .seed(42) + .run(); + + assert_eq!(results.len(), 10); + + for outcome in &results.outcomes { + assert!( + outcome.get_bit(QubitId(0)).unwrap(), + "X gate should produce |1>" + ); + } + } + #[test] + fn test_sim_neo_quantum_engine_builder_adapter() { + let circuit = CommandBuilder::new() + .pz(&[0]) + .x(&[0]) + .mz(&[0]) + .pz(&[1]) + .h(&[1]) + .mz(&[1]) + .build(); + + let results = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .shots(12) + .seed(42) + .run(); + + assert_eq!(results.len(), 12); + for outcome in &results.outcomes { + assert!( + outcome.get_bit(QubitId(0)).unwrap(), + "X gate should produce |1>" + ); + assert!( + outcome.get_bit(QubitId(1)).is_some(), + "QuantumEngine adapter should return measurement outcomes by qubit" + ); + } + } + #[test] + fn test_sim_neo_quantum_engine_builder_adapter_preserves_engine_gate_capabilities() { + let circuit = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .t(&[0]) + .mz(&[0]) + .build(); + + let results = sim_neo(circuit) + .quantum(pecos_engines::state_vector()) + .shots(8) + .seed(123) + .run(); + + assert_eq!(results.len(), 8); + for outcome in &results.outcomes { + assert!( + outcome.get_bit(QubitId(0)).is_some(), + "QuantumEngine adapter should preserve state-vector support for T gates" + ); + } + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backends do not support sim_neo noise modeling" + )] + fn test_sim_neo_quantum_engine_builder_rejects_composable_noise() { + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.1)); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .noise(noise) + .shots(1) + .run(); + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backends do not support sim_neo noise modeling" + )] + fn test_sim_neo_quantum_engine_builder_parallel_rejects_composable_noise() { + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.1)); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .noise(noise) + .workers(2) + .shots(2) + .run(); + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backend does not support sim_neo gate definitions" + )] + fn test_sim_neo_quantum_engine_builder_rejects_gate_definitions() { + use crate::extensible::GateDefinitions; + + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .gate_definitions(GateDefinitions::new()) + .shots(1) + .run(); + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backend does not support sim_neo gate decomposition depth" + )] + fn test_sim_neo_quantum_engine_builder_rejects_max_decomp_depth() { + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .max_decomp_depth(20) + .shots(1) + .run(); + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backend does not support sim_neo gate overrides" + )] + fn test_sim_neo_quantum_engine_builder_rejects_gate_overrides() { + use crate::extensible::gates; + use crate::runner::GateOverrides; + + let overrides = + GateOverrides::::new().register(gates::X, |_sim, _angles, _qubits| true); + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .gate_overrides(overrides) + .shots(1) + .run(); + } + #[test] + #[should_panic( + expected = "QuantumEngineBuilder backend does not support sim_neo event handlers" + )] + fn test_sim_neo_quantum_engine_builder_rejects_event_handlers() { + let handlers = + EventHandlers::new().on_before_gate(|_ctx| crate::noise::NoiseResponse::None); + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let _ = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .event_handlers(handlers) + .shots(1) + .run(); + } + #[test] + fn test_sim_neo_quantum_engine_builder_parallel_static_circuit() { + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit) + .quantum(pecos_engines::stabilizer()) + .workers(2) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.len(), 1); + } + } + #[test] + fn test_sim_neo_quantum_engine_builder_parallel_preserves_gate_capabilities() { + let circuit = CommandBuilder::new() + .pz(&[0]) + .x(&[0]) + .t(&[0]) + .mz(&[0]) + .build(); + + let results = sim_neo(circuit) + .quantum(pecos_engines::state_vector()) + .workers(2) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.len(), 1); + } + } + + fn deterministic_conditional_program() -> Box { + let initial = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let branch = |outcomes: &MeasurementOutcomes| { + if outcomes.get_bit(QubitId(0)) == Some(true) { + Some(CommandBuilder::new().x(&[1]).mz(&[1]).build()) + } else { + Some(CommandBuilder::new().mz(&[1]).build()) + } + }; + Box::new(ConditionalProgram::new(initial, branch, 2)) + } + + #[cfg(feature = "qasm")] + fn deterministic_conditional_qasm() -> &'static str { + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + x q[0]; + measure q[0] -> c[0]; + if (c[0] == 1) x q[1]; + measure q[1] -> c[1]; + "# + } + + #[test] + fn test_sim_neo_dynamic_command_source_native_stabilizer() { + let results = sim_neo(deterministic_conditional_program()) + .quantum(stabilizer()) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + + #[test] + fn test_sim_neo_dynamic_command_source_rerun() { + let mut sim = sim_neo(deterministic_conditional_program()) + .quantum(stabilizer()) + .shots(2) + .seed(42) + .build(); + + let first = sim.run(); + assert_eq!(first.len(), 2); + for outcome in &first.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + } + + sim.shots(4); + let second = sim.run(); + assert_eq!(second.len(), 4); + for outcome in &second.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_conditional_native_stabilizer() { + let results = sim_neo(deterministic_conditional_qasm()) + .classical(pecos_qasm::qasm_engine()) + .quantum(stabilizer()) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + #[test] + fn test_sim_neo_dynamic_command_source_quantum_engine_adapter() { + let results = sim_neo(deterministic_conditional_program()) + .quantum(pecos_engines::stabilizer()) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_conditional_quantum_engine_adapter() { + let results = sim_neo(deterministic_conditional_qasm()) + .classical(pecos_qasm::qasm_engine()) + .quantum(pecos_engines::stabilizer()) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_conditional_native_stabilizer_parallel() { + let results = sim_neo(deterministic_conditional_qasm()) + .classical(pecos_qasm::qasm_engine()) + .quantum(stabilizer()) + .workers(2) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_conditional_quantum_engine_adapter_parallel() { + let results = sim_neo(deterministic_conditional_qasm()) + .classical(pecos_qasm::qasm_engine()) + .quantum(pecos_engines::stabilizer()) + .workers(2) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_auto_conditional_parallel_after_worker_selection() { + let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); + let results = sim_neo(program) + .auto() + .workers(2) + .quantum(pecos_engines::stabilizer()) + .shots(6) + .seed(42) + .run(); + + assert_eq!(results.len(), 6); + for outcome in &results.outcomes { + assert_eq!(outcome.get_bit(QubitId(0)), Some(true)); + assert_eq!(outcome.get_bit(QubitId(1)), Some(true)); + assert_eq!(outcome.len(), 2); + } + } + #[test] + #[should_panic( + expected = "Parallel Monte Carlo requires per-worker runner construction support" + )] + fn test_sim_neo_dynamic_command_source_quantum_engine_adapter_rejects_parallel_workers() { + let _ = sim_neo(deterministic_conditional_program()) + .quantum(pecos_engines::stabilizer()) + .workers(2) + .shots(2) + .run(); + } + #[test] fn test_sim_neo_quantum_state_vector() { // Test state vector backend @@ -3599,7 +4512,18 @@ mod tests { } } - // --- Importance Sampling Sampling Tests --- + // --- Importance Sampling Strategy Tests --- + + #[test] + #[should_panic(expected = "Importance sampling requires a static circuit")] + fn test_sim_neo_importance_sampling_rejects_dynamic_command_source() { + use super::importance_sampling; + + let _ = sim_neo(deterministic_conditional_program()) + .sampling(importance_sampling()) + .shots(1) + .run(); + } #[test] fn test_sim_neo_importance_sampling_basic() { From bdbe77cf9c5afeabc8687428cee0c082a0f7df4c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 09:49:01 -0600 Subject: [PATCH 2/7] Make CI LLVM setup use PECOS tooling --- .github/workflows/python-test.yml | 40 +++------- .github/workflows/rust-test.yml | 50 ++++-------- .github/workflows/test-docs-examples.yml | 23 +++--- Justfile | 6 ++ crates/pecos-build/src/llvm/installer.rs | 20 +++-- crates/pecos-cli/src/cli.rs | 10 +++ crates/pecos-cli/src/cli/env_cmd.rs | 98 +++++++++++++++++++++++- crates/pecos-cli/src/cli/llvm_cmd.rs | 42 ++++++++++ crates/pecos-cli/src/main.rs | 10 ++- 9 files changed, 216 insertions(+), 83 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index e347ddcda..e81baa76b 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -84,7 +84,11 @@ jobs: enable-cache: true - name: Set up Rust - run: rustup show + run: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.cargo/bin:$PATH" + rustup show - name: Install just uses: extractions/setup-just@v3 @@ -102,13 +106,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }} - - # Install LLVM first (needed before CLI install since inkwell requires LLVM_SYS_140_PREFIX) - # Use cargo run for LLVM install — only builds pecos+pecos-build, not the full workspace - - name: Install LLVM ${{ env.LLVM_VERSION }} (Unix) - if: steps.cache-llvm.outputs.cache-hit != 'true' && runner.os != 'Windows' - run: cargo run -p pecos-cli --release -- install llvm + key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 # Configure MSVC linker BEFORE any cargo build (Git's link.exe conflicts with MSVC's) - name: Configure MSVC linker (Windows) @@ -129,32 +127,16 @@ jobs: exit 1 } - - name: Install LLVM ${{ env.LLVM_VERSION }} (Windows) - if: steps.cache-llvm.outputs.cache-hit != 'true' && runner.os == 'Windows' - run: cargo run -p pecos-cli --release -- install llvm - - # Configure LLVM environment - - name: Configure LLVM environment (Unix) - if: runner.os != 'Windows' - run: | - PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$PECOS_LLVM" >> $GITHUB_ENV + - name: Ensure LLVM ${{ env.LLVM_VERSION }} + run: just ci-env - name: Configure LLVM environment (Windows) if: runner.os == 'Windows' shell: pwsh run: | - $env:PECOS_LLVM = (cargo run -p pecos-cli --release -- llvm find 2>$null) - $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM - "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - # Add LLVM bin to PATH - $llvmBinDir = Join-Path -Path $env:PECOS_LLVM -ChildPath "bin" - if (Test-Path $llvmBinDir) { - "$llvmBinDir" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - } + # Rewrite .cargo/config.toml with both linker and LLVM config + # (`pecos env --github-actions` sets LLVM_SYS_140_PREFIX for following steps.) + $env:PECOS_LLVM = $env:LLVM_SYS_140_PREFIX # Rewrite .cargo/config.toml with both linker and LLVM config # (pecos install llvm may have already written this, so overwrite cleanly) diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index fc3d0aa05..13ffd0c3d 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -17,6 +17,7 @@ on: - 'python/pecos-rslib/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' + - 'Justfile' - '.github/workflows/rust-test.yml' - '.pre-commit-config.yaml' pull_request: @@ -29,6 +30,7 @@ on: - 'python/pecos-rslib/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' + - 'Justfile' - '.github/workflows/rust-test.yml' - '.pre-commit-config.yaml' @@ -56,6 +58,9 @@ jobs: - name: Set up Rust run: rustup override set stable && rustup update + - name: Install just + uses: extractions/setup-just@v3 + - name: Cache Rust uses: Swatinem/rust-cache@v2 with: @@ -75,17 +80,10 @@ jobs: uses: actions/cache@v4 with: path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }} - - - name: Install LLVM ${{ env.LLVM_VERSION }} - if: steps.cache-llvm.outputs.cache-hit != 'true' - run: cargo run -p pecos-cli --release -- install llvm + key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - - name: Configure LLVM - run: | - PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$PECOS_LLVM" >> $GITHUB_ENV + - name: Ensure LLVM ${{ env.LLVM_VERSION }} + run: just ci-env - name: Check formatting run: cargo fmt --all -- --check @@ -172,6 +170,9 @@ jobs: - name: Set up Rust run: rustup show + - name: Install just + uses: extractions/setup-just@v3 + - name: Cache Rust uses: Swatinem/rust-cache@v2 with: @@ -182,31 +183,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }} - - - name: Install LLVM ${{ env.LLVM_VERSION }} (Unix) - if: steps.cache-llvm.outputs.cache-hit != 'true' && matrix.os != 'windows-latest' - run: cargo run -p pecos-cli --release -- install llvm - - - name: Install LLVM ${{ env.LLVM_VERSION }} (Windows) - if: steps.cache-llvm.outputs.cache-hit != 'true' && matrix.os == 'windows-latest' - run: cargo run -p pecos-cli --release -- install llvm - - - name: Configure LLVM (Unix) - if: matrix.os != 'windows-latest' - run: | - PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$PECOS_LLVM" >> $GITHUB_ENV - - - name: Configure LLVM (Windows) - if: matrix.os == 'windows-latest' - shell: pwsh - run: | - $env:PECOS_LLVM = (cargo run -p pecos-cli --release -- llvm find 2>$null) - $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM - "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - name: Set up Visual Studio environment on Windows if: matrix.os == 'windows-latest' @@ -214,6 +191,9 @@ jobs: with: arch: x64 + - name: Ensure LLVM ${{ env.LLVM_VERSION }} + run: just ci-env + - name: Install CUDA Toolkit (Linux) if: matrix.os == 'ubuntu-latest' uses: Jimver/cuda-toolkit@v0.2.30 diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index 6949c3417..d3b370c1f 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -5,10 +5,14 @@ on: branches: [ main, master, development, dev ] paths: - 'docs/**' + - 'Justfile' + - '.github/workflows/test-docs-examples.yml' pull_request: branches: [ main, master, development, dev ] paths: - 'docs/**' + - 'Justfile' + - '.github/workflows/test-docs-examples.yml' workflow_dispatch: env: @@ -35,7 +39,11 @@ jobs: enable-cache: true - name: Set up Rust - run: rustup show + run: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.cargo/bin:$PATH" + rustup show - name: Install just uses: extractions/setup-just@v3 @@ -52,17 +60,10 @@ jobs: uses: actions/cache@v4 with: path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }} - - - name: Install LLVM ${{ env.LLVM_VERSION }} - if: steps.cache-llvm.outputs.cache-hit != 'true' - run: cargo run -p pecos-cli --release -- install llvm + key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - - name: Configure LLVM - run: | - PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$PECOS_LLVM" >> $GITHUB_ENV + - name: Ensure LLVM ${{ env.LLVM_VERSION }} + run: just ci-env - name: Install dependencies and build run: | diff --git a/Justfile b/Justfile index c05a2600f..8f1977165 100644 --- a/Justfile +++ b/Justfile @@ -52,6 +52,12 @@ setup: setup-ci: {{pecos}} setup --yes +# Ensure CI has a runtime-valid LLVM and export PECOS build env files +[group('setup')] +ci-env: + {{pecos}} llvm ensure --managed --no-configure + {{pecos}} env --github-actions + # Check development environment for common problems [group('setup')] doctor: diff --git a/crates/pecos-build/src/llvm/installer.rs b/crates/pecos-build/src/llvm/installer.rs index 2351d6097..ac02ef691 100644 --- a/crates/pecos-build/src/llvm/installer.rs +++ b/crates/pecos-build/src/llvm/installer.rs @@ -46,10 +46,20 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { let llvm_dir = crate::home::get_versioned_dep_path("llvm", crate::home::LLVM_VERSION)?; // Check if already installed - if !force && llvm_dir.exists() && is_valid_installation(&llvm_dir) { - return Err(Error::Llvm( - "LLVM is already installed. Use --force to reinstall.".into(), - )); + if llvm_dir.exists() { + if is_valid_installation(&llvm_dir) { + if !force { + return Err(Error::Llvm( + "LLVM is already installed. Use --force to reinstall.".into(), + )); + } + } else if !force { + return Err(Error::Llvm(format!( + "Existing LLVM directory is not a valid LLVM 14 installation: {}. \ + Use --force to reinstall.", + llvm_dir.display() + ))); + } } // Remove existing if force @@ -419,7 +429,7 @@ pub fn is_valid_installation(path: &Path) -> bool { } } - true + super::get_llvm_version(path).is_ok_and(|version| version.starts_with("14.")) } fn verify_llvm_runtime(llvm_dir: &Path) -> Result<()> { diff --git a/crates/pecos-cli/src/cli.rs b/crates/pecos-cli/src/cli.rs index 7d2d5d2bf..b7154f28a 100644 --- a/crates/pecos-cli/src/cli.rs +++ b/crates/pecos-cli/src/cli.rs @@ -162,6 +162,16 @@ pub enum LlvmCommands { #[arg(short, long)] quiet: bool, }, + /// Ensure LLVM 14 is installed and runtime-valid + Ensure { + /// Require the PECOS-managed installation under ~/.pecos/deps + #[arg(long)] + managed: bool, + + /// Skip automatic .cargo/config.toml configuration + #[arg(long)] + no_configure: bool, + }, /// Configure .cargo/config.toml with LLVM path Configure, /// Find LLVM installation path diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index 4f8007142..cb2cec25b 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -20,10 +20,17 @@ //! Usage: //! eval $(pecos env) # bash/zsh — set variables in current shell //! pecos env --format json # machine-readable output -//! pecos env --show # human-readable display +//! pecos env --format show # human-readable display +//! pecos env --github-actions # write GitHub Actions env/path files use std::collections::BTreeMap; use std::fmt::Write; +use std::fs::OpenOptions; +use std::io::Write as IoWrite; +use std::path::Path; + +use pecos_build::Result; +use pecos_build::errors::Error; /// Collect the build environment for the current platform. /// @@ -36,6 +43,7 @@ pub fn collect_env() -> BTreeMap { // LLVM if let Some(llvm_path) = pecos_build::llvm::find_llvm_14(None) { let llvm_str = llvm_path.display().to_string(); + env.insert("PECOS_LLVM".into(), llvm_str.clone()); env.insert("LLVM_SYS_140_PREFIX".into(), llvm_str); // Add LLVM bin to PATH @@ -119,12 +127,98 @@ pub fn print_show(env: &BTreeMap) { } } +/// Write environment variables to GitHub Actions environment files. +pub fn write_github_actions(env: &BTreeMap) -> Result<()> { + let github_env = std::env::var("GITHUB_ENV").map_err(|_| { + Error::Config( + "GITHUB_ENV is not set; --github-actions must run inside GitHub Actions".into(), + ) + })?; + let github_path = std::env::var("GITHUB_PATH").map_err(|_| { + Error::Config( + "GITHUB_PATH is not set; --github-actions must run inside GitHub Actions".into(), + ) + })?; + + write_github_actions_files(env, Path::new(&github_env), Path::new(&github_path)) +} + +fn write_github_actions_files( + env: &BTreeMap, + github_env: &Path, + github_path: &Path, +) -> Result<()> { + let mut env_file = OpenOptions::new() + .append(true) + .create(true) + .open(github_env)?; + for (key, value) in env { + if key != "PATH" { + writeln!(env_file, "{key}={value}")?; + } + } + + let mut path_file = OpenOptions::new() + .append(true) + .create(true) + .open(github_path)?; + if let Some(llvm_path) = env.get("LLVM_SYS_140_PREFIX") { + writeln!(path_file, "{}", Path::new(llvm_path).join("bin").display())?; + } + + Ok(()) +} + /// Run the env subcommand. -pub fn run(format: &str) { +pub fn run(format: &str, github_actions: bool) -> Result<()> { let env = collect_env(); + if github_actions { + return write_github_actions(&env); + } + match format { "json" => print_json(&env), "show" => print_show(&env), _ => print_shell(&env), } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_actions_writer_uses_env_file_and_path_file() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let env_path = std::env::temp_dir().join(format!("pecos-gh-env-{unique}")); + let path_path = std::env::temp_dir().join(format!("pecos-gh-path-{unique}")); + + let mut env = BTreeMap::new(); + env.insert( + "LLVM_SYS_140_PREFIX".to_string(), + "/opt/pecos/llvm-14".to_string(), + ); + env.insert( + "PATH".to_string(), + "/opt/pecos/llvm-14/bin:/usr/bin".to_string(), + ); + env.insert("PECOS_LLVM".to_string(), "/opt/pecos/llvm-14".to_string()); + + write_github_actions_files(&env, &env_path, &path_path).unwrap(); + + let env_file = std::fs::read_to_string(&env_path).unwrap(); + let path_file = std::fs::read_to_string(&path_path).unwrap(); + + assert!(env_file.contains("LLVM_SYS_140_PREFIX=/opt/pecos/llvm-14")); + assert!(env_file.contains("PECOS_LLVM=/opt/pecos/llvm-14")); + assert!(!env_file.contains("PATH=")); + assert_eq!(path_file.trim(), "/opt/pecos/llvm-14/bin"); + + let _ = std::fs::remove_file(env_path); + let _ = std::fs::remove_file(path_path); + } } diff --git a/crates/pecos-cli/src/cli/llvm_cmd.rs b/crates/pecos-cli/src/cli/llvm_cmd.rs index 8e9c858c1..c415a5c3a 100644 --- a/crates/pecos-cli/src/cli/llvm_cmd.rs +++ b/crates/pecos-cli/src/cli/llvm_cmd.rs @@ -14,6 +14,10 @@ pub fn run(command: LlvmCommands) -> Result<()> { run_check(quiet); Ok(()) } + LlvmCommands::Ensure { + managed, + no_configure, + } => run_ensure(managed, no_configure), LlvmCommands::Configure => run_configure(), LlvmCommands::Find { export } => { run_find(export); @@ -57,6 +61,44 @@ fn run_check(quiet: bool) { } } +fn run_ensure(managed: bool, no_configure: bool) -> Result<()> { + let llvm_path = if managed { + ensure_managed_llvm(no_configure)? + } else if let Some(path) = find_llvm_14(get_repo_root_from_manifest()) { + if !no_configure { + auto_configure_llvm(None)?; + } + path + } else { + pecos_build::llvm::installer::install_llvm(false, no_configure)? + }; + + let version = get_llvm_version(&llvm_path)?; + println!("LLVM {version} ready at {}", llvm_path.display()); + Ok(()) +} + +fn ensure_managed_llvm(no_configure: bool) -> Result { + let llvm_path = + pecos_build::home::get_versioned_dep_path("llvm", pecos_build::home::LLVM_VERSION)?; + + if !pecos_build::llvm::installer::is_valid_installation(&llvm_path) { + if llvm_path.exists() { + println!( + "Existing LLVM at {} failed runtime validation; reinstalling.", + llvm_path.display() + ); + pecos_build::llvm::installer::install_llvm(true, no_configure)?; + } else { + pecos_build::llvm::installer::install_llvm(false, no_configure)?; + } + } else if !no_configure { + auto_configure_llvm(None)?; + } + + Ok(llvm_path) +} + fn run_configure() -> Result<()> { let llvm_path = auto_configure_llvm(None)?; println!("Configured LLVM path: {}", llvm_path.display()); diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index 27f203b7e..fed80bffd 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -121,10 +121,15 @@ enum Commands { /// Example: eval $(pecos env) /// Example: pecos env --format show /// Example: pecos env --format json + /// Example: pecos env --github-actions Env { /// Output format: shell (default), json, show #[arg(long, default_value = "shell")] format: String, + + /// Write variables to GitHub Actions environment files + #[arg(long)] + github_actions: bool, }, /// Set up build environment (detect and install missing dependencies) @@ -692,7 +697,10 @@ fn main() -> Result<(), Box> { Commands::Llvm { command } => cli::llvm_cmd::run(command.clone())?, Commands::Selene { command } => cli::selene_cmd::run(command.clone())?, Commands::Deps { command } => cli::manifest_cmd::run(command.clone())?, - Commands::Env { format } => cli::env_cmd::run(format), + Commands::Env { + format, + github_actions, + } => cli::env_cmd::run(format, *github_actions)?, Commands::Setup { yes, no, From 482044b638615cda618124ca52999af444152e89 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 10:18:04 -0600 Subject: [PATCH 3/7] Update CI Windows and CUDA setup actions --- .github/workflows/cuda-build-check.yml | 2 +- .github/workflows/go-test.yml | 7 +- .github/workflows/julia-release.yml | 11 ++- .github/workflows/julia-test.yml | 7 +- .github/workflows/python-release.yml | 9 ++- .github/workflows/python-test.yml | 9 ++- .github/workflows/rust-test.yml | 21 +++--- .github/workflows/selene-plugins.yml | 4 +- scripts/ci/setup-msvc.ps1 | 99 ++++++++++++++++++++++++++ 9 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 scripts/ci/setup-msvc.ps1 diff --git a/.github/workflows/cuda-build-check.yml b/.github/workflows/cuda-build-check.yml index 0dfffae69..53d717551 100644 --- a/.github/workflows/cuda-build-check.yml +++ b/.github/workflows/cuda-build-check.yml @@ -55,7 +55,7 @@ jobs: save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Install CUDA Toolkit - uses: Jimver/cuda-toolkit@v0.2.30 + uses: Jimver/cuda-toolkit@v0.2.35 id: cuda-toolkit with: cuda: '12.6.3' diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index f272c5905..b0c8c489c 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] + os: [ubuntu-latest, windows-2022, macOS-latest] go-version: ["stable"] # Latest stable (experimental bindings) steps: @@ -58,9 +58,8 @@ jobs: - name: Set up Visual Studio environment on Windows if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 - name: Build Rust FFI library (Windows) if: runner.os == 'Windows' diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 5324b53bb..48824c8cf 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -92,7 +92,7 @@ jobs: - os: macos-latest architecture: aarch64 runner: macos-latest # ARM64 Mac - - os: windows-latest + - os: windows-2022 architecture: x86_64 steps: @@ -144,9 +144,8 @@ jobs: - name: Set up Visual Studio environment on Windows if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 - name: Build library (Windows) if: runner.os == 'Windows' @@ -249,8 +248,8 @@ jobs: - runner: ubuntu-latest os: ubuntu-latest architecture: x86_64 - - runner: windows-latest - os: windows-latest + - runner: windows-2022 + os: windows-2022 architecture: x86_64 - runner: macos-15-intel os: macos-15-intel diff --git a/.github/workflows/julia-test.yml b/.github/workflows/julia-test.yml index 2b62e94e6..2fbeeeb3f 100644 --- a/.github/workflows/julia-test.yml +++ b/.github/workflows/julia-test.yml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] + os: [ubuntu-latest, windows-2022, macOS-latest] julia-version: ["1"] # Latest stable (experimental bindings) steps: @@ -94,9 +94,8 @@ jobs: - name: Set up Visual Studio environment on Windows if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 - name: Build Rust FFI library (Windows) if: runner.os == 'Windows' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index a672ae10d..467a49d97 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -118,16 +118,15 @@ jobs: # Set up Visual Studio environment on Windows (required for nvcc to find cl.exe) - name: Set up Visual Studio environment (Windows) if: runner.os == 'Windows' && matrix.install_cuda - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 # Install CUDA on Windows before cibuildwheel (cibuildwheel runs on host, not in containers) # Uses specific sub-packages to avoid VS 17.3.x bug that hangs on NSight/VS Integration # See: https://github.com/Jimver/cuda-toolkit/issues/382 - name: Install CUDA Toolkit (Windows) if: runner.os == 'Windows' && matrix.install_cuda - uses: Jimver/cuda-toolkit@v0.2.30 + uses: Jimver/cuda-toolkit@v0.2.35 with: cuda: '12.5.1' method: 'local' @@ -336,7 +335,7 @@ jobs: - runner: ubuntu-latest os: ubuntu-latest architecture: x86_64 - - runner: windows-latest + - runner: windows-2022 os: windows-2022 architecture: x86_64 - runner: macos-15-intel diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index e81baa76b..fec479a5b 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -34,9 +34,9 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] include: # Windows/macOS: only test oldest and newest stable Python (slow platforms) - - os: windows-latest + - os: windows-2022 python-version: "3.10" - - os: windows-latest + - os: windows-2022 python-version: "3.14" - os: macOS-latest python-version: "3.10" @@ -74,9 +74,8 @@ jobs: - name: Set up Visual Studio environment on Windows if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 13ffd0c3d..b63369fc9 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -67,7 +67,7 @@ jobs: save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Install CUDA Toolkit - uses: Jimver/cuda-toolkit@v0.2.30 + uses: Jimver/cuda-toolkit@v0.2.35 id: cuda-toolkit with: cuda: '12.6.3' @@ -141,7 +141,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-2022] steps: - uses: actions/checkout@v6 @@ -153,7 +153,7 @@ jobs: sudo apt-get clean - name: Install Rust (for local testing) - if: matrix.os == 'windows-latest' + if: runner.os == 'Windows' run: | curl -sSf -o rustup-init.exe https://win.rustup.rs ./rustup-init.exe -y --default-toolchain stable --profile minimal @@ -161,7 +161,7 @@ jobs: $env:Path += ";$HOME\.cargo\bin" - name: Install Rust (for local testing) - if: matrix.os != 'windows-latest' + if: runner.os != 'Windows' run: | curl https://sh.rustup.rs -sSf | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH @@ -186,17 +186,16 @@ jobs: key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - name: Set up Visual Studio environment on Windows - if: matrix.os == 'windows-latest' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/ci/setup-msvc.ps1 -Arch x64 -HostArch x64 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env - name: Install CUDA Toolkit (Linux) if: matrix.os == 'ubuntu-latest' - uses: Jimver/cuda-toolkit@v0.2.30 + uses: Jimver/cuda-toolkit@v0.2.35 id: cuda-toolkit-test with: cuda: '12.6.3' @@ -216,7 +215,7 @@ jobs: cargo build -p pecos-cli --release - name: Compile tests (Windows) - if: matrix.os == 'windows-latest' + if: runner.os == 'Windows' run: cargo build -p pecos-cli --release - name: Run tests (macOS) @@ -234,5 +233,5 @@ jobs: cargo run -p pecos-cli --release -- rust test - name: Run tests (Windows) - if: matrix.os == 'windows-latest' + if: runner.os == 'Windows' run: cargo run -p pecos-cli --release -- rust test diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index 8efabd9e1..fe185674a 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -39,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, macos-15-intel, windows-latest] + os: [ubuntu-latest, macos-latest, macos-15-intel, windows-2022] steps: - uses: actions/checkout@v6 @@ -116,7 +116,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, macos-15-intel, windows-latest] + os: [ubuntu-latest, macos-latest, macos-15-intel, windows-2022] plugin: - name: pecos-selene-stabilizer package: pecos_selene_stabilizer diff --git a/scripts/ci/setup-msvc.ps1 b/scripts/ci/setup-msvc.ps1 new file mode 100644 index 000000000..0d8d437b8 --- /dev/null +++ b/scripts/ci/setup-msvc.ps1 @@ -0,0 +1,99 @@ +# Copyright 2025 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +param( + [string]$Arch = "x64", + [string]$HostArch = "x64" +) + +$ErrorActionPreference = "Stop" + +if (-not $env:GITHUB_ENV -or -not $env:GITHUB_PATH) { + throw "setup-msvc.ps1 must run inside GitHub Actions so GITHUB_ENV and GITHUB_PATH are available" +} + +function Add-GitHubEnv { + param( + [Parameter(Mandatory = $true)][string]$Name, + [AllowEmptyString()][string]$Value + ) + + $delimiter = [Guid]::NewGuid().ToString("N") + "$Name<<$delimiter" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $Value | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "$delimiter" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +} + +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { + throw "Could not find vswhere.exe at $vswhere" +} + +$vsPath = & $vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $vsPath) { + $vsPath = & $vswhere -latest -property installationPath +} +if (-not $vsPath) { + throw "Could not find a Visual Studio installation" +} + +$devcmd = Join-Path $vsPath "Common7\Tools\VsDevCmd.bat" +if (-not (Test-Path $devcmd)) { + throw "Could not find VsDevCmd.bat at $devcmd" +} + +$before = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +Get-ChildItem Env: | ForEach-Object { + $before[$_.Name] = $_.Value +} + +$command = "`"$devcmd`" -no_logo -arch=$Arch -host_arch=$HostArch && set" +$lines = & cmd.exe /s /c $command +if ($LASTEXITCODE -ne 0) { + throw "VsDevCmd.bat failed with exit code $LASTEXITCODE" +} + +$after = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($line in $lines) { + if ($line -match '^([^=][^=]*)=(.*)$') { + $after[$Matches[1]] = $Matches[2] + } +} + +foreach ($name in ($after.Keys | Sort-Object)) { + if ($name -ieq "Path") { + continue + } + + $value = $after[$name] + if (-not $before.ContainsKey($name) -or $before[$name] -cne $value) { + Add-GitHubEnv -Name $name -Value $value + } +} + +$oldPathParts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if ($before.ContainsKey("Path")) { + foreach ($pathPart in ($before["Path"] -split ';')) { + if ($pathPart) { + [void]$oldPathParts.Add($pathPart) + } + } +} + +if ($after.ContainsKey("Path")) { + foreach ($pathPart in ($after["Path"] -split ';')) { + if ($pathPart -and -not $oldPathParts.Contains($pathPart)) { + $pathPart | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + } + } +} + +Write-Host "Configured Visual Studio environment from $vsPath for $Arch" From ff0b6c059a762fa4d6f1c2e102db26cf419b5819 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 10:24:31 -0600 Subject: [PATCH 4/7] Harden CI Rust caches --- .github/workflows/cuda-build-check.yml | 2 ++ .github/workflows/go-test.yml | 2 ++ .github/workflows/julia-release.yml | 4 ++-- .github/workflows/julia-test.yml | 4 +++- .github/workflows/python-test.yml | 2 ++ .github/workflows/rust-test.yml | 6 ++++++ .github/workflows/selene-plugins.yml | 6 ++++++ .github/workflows/test-docs-examples.yml | 2 ++ 8 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cuda-build-check.yml b/.github/workflows/cuda-build-check.yml index 53d717551..66069b2f5 100644 --- a/.github/workflows/cuda-build-check.yml +++ b/.github/workflows/cuda-build-check.yml @@ -52,6 +52,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Install CUDA Toolkit diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index b0c8c489c..76269d64d 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -54,6 +54,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin workspaces: go/pecos-go-ffi - name: Set up Visual Studio environment on Windows diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 48824c8cf..02d064ca9 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -263,7 +263,7 @@ jobs: ref: ${{ inputs.sha || github.sha }} - name: Set up Julia ${{ matrix.julia-version }} - uses: julia-actions/setup-julia@v2 + uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.julia-version }} @@ -341,7 +341,7 @@ jobs: ref: ${{ inputs.sha || github.sha }} - name: Set up Julia - uses: julia-actions/setup-julia@v2 + uses: julia-actions/setup-julia@v3 with: version: '1.10' diff --git a/.github/workflows/julia-test.yml b/.github/workflows/julia-test.yml index 2fbeeeb3f..fb3b03ba3 100644 --- a/.github/workflows/julia-test.yml +++ b/.github/workflows/julia-test.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v6 - name: Set up Julia ${{ matrix.julia-version }} - uses: julia-actions/setup-julia@v2 + uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.julia-version }} @@ -90,6 +90,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin workspaces: julia/pecos-julia-ffi - name: Set up Visual Studio environment on Windows diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index fec479a5b..a8f8c727d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -95,6 +95,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin workspaces: | python/pecos-rslib python/pecos-rslib-llvm diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index b63369fc9..5bb61a184 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -64,6 +64,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Install CUDA Toolkit @@ -108,6 +110,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Check formatting @@ -176,6 +180,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Cache LLVM ${{ env.LLVM_VERSION }} diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index fe185674a..507c3828a 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -59,6 +59,9 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + prefix-key: v1-rust-no-bin - name: Build and install Selene plugins (Unix) if: runner.os != 'Windows' @@ -147,6 +150,9 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + prefix-key: v1-rust-no-bin - name: Build Rust library (Unix) if: runner.os != 'Windows' diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index d3b370c1f..8acb05b13 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -51,6 +51,8 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 with: + cache-bin: false + prefix-key: v1-rust-no-bin workspaces: | python/pecos-rslib python/pecos-rslib-llvm From 8c19e9fa65ec9d19a3dfc4f949be608434e5bc16 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 10:53:13 -0600 Subject: [PATCH 5/7] Fix Windows CI toolchain setup --- .github/workflows/python-test.yml | 17 ++++++++++++++--- .github/workflows/rust-test.yml | 10 +++++----- .github/workflows/selene-plugins.yml | 4 ++++ .github/workflows/test-docs-examples.yml | 4 ++-- scripts/ci/setup-msvc.ps1 | 11 +++++++++++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index a8f8c727d..2943d689c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -82,7 +82,18 @@ jobs: with: enable-cache: true - - name: Set up Rust + - name: Set up Rust (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + curl -sSf -o rustup-init.exe https://win.rustup.rs + ./rustup-init.exe -y --default-toolchain stable --default-host x86_64-pc-windows-msvc --profile minimal + "$HOME\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + $env:Path += ";$HOME\.cargo\bin" + rustup show + + - name: Set up Rust (Unix) + if: runner.os != 'Windows' run: | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --profile minimal echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -90,7 +101,7 @@ jobs: rustup show - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Cache Rust uses: Swatinem/rust-cache@v2 @@ -104,7 +115,7 @@ jobs: # Cache LLVM installation (fixed version, only varies by OS) - name: Cache LLVM ${{ env.LLVM_VERSION }} id: cache-llvm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.pecos/deps/llvm-14 key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 5bb61a184..d27b814f1 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -59,7 +59,7 @@ jobs: run: rustup override set stable && rustup update - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Cache Rust uses: Swatinem/rust-cache@v2 @@ -79,7 +79,7 @@ jobs: - name: Cache LLVM ${{ env.LLVM_VERSION }} id: cache-llvm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.pecos/deps/llvm-14 key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 @@ -160,7 +160,7 @@ jobs: if: runner.os == 'Windows' run: | curl -sSf -o rustup-init.exe https://win.rustup.rs - ./rustup-init.exe -y --default-toolchain stable --profile minimal + ./rustup-init.exe -y --default-toolchain stable --default-host x86_64-pc-windows-msvc --profile minimal echo "$HOME\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append $env:Path += ";$HOME\.cargo\bin" @@ -175,7 +175,7 @@ jobs: run: rustup show - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Cache Rust uses: Swatinem/rust-cache@v2 @@ -186,7 +186,7 @@ jobs: - name: Cache LLVM ${{ env.LLVM_VERSION }} id: cache-llvm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.pecos/deps/llvm-14 key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index 507c3828a..710e0f4e4 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -61,7 +61,9 @@ jobs: uses: Swatinem/rust-cache@v2 with: cache-bin: false + key: ${{ matrix.os }} prefix-key: v1-rust-no-bin + save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Build and install Selene plugins (Unix) if: runner.os != 'Windows' @@ -152,7 +154,9 @@ jobs: uses: Swatinem/rust-cache@v2 with: cache-bin: false + key: ${{ matrix.os }}-${{ matrix.plugin.name }} prefix-key: v1-rust-no-bin + save-if: ${{ github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev' }} - name: Build Rust library (Unix) if: runner.os != 'Windows' diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index 8acb05b13..b89842305 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -46,7 +46,7 @@ jobs: rustup show - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Cache Rust uses: Swatinem/rust-cache@v2 @@ -59,7 +59,7 @@ jobs: - name: Cache LLVM ${{ env.LLVM_VERSION }} id: cache-llvm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.pecos/deps/llvm-14 key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 diff --git a/scripts/ci/setup-msvc.ps1 b/scripts/ci/setup-msvc.ps1 index 0d8d437b8..3a841a8cc 100644 --- a/scripts/ci/setup-msvc.ps1 +++ b/scripts/ci/setup-msvc.ps1 @@ -96,4 +96,15 @@ if ($after.ContainsKey("Path")) { } } +$linkPath = Get-ChildItem -Path (Join-Path $vsPath "VC\Tools\MSVC") -Recurse -Filter "link.exe" | + Where-Object { $_.FullName -like "*\bin\Hostx64\x64\*" } | + Select-Object -First 1 -ExpandProperty FullName + +if (-not $linkPath) { + throw "Could not find MSVC link.exe for x64" +} + +Add-GitHubEnv -Name "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER" -Value $linkPath + Write-Host "Configured Visual Studio environment from $vsPath for $Arch" +Write-Host "Configured Cargo MSVC linker: $linkPath" From 322beef135fbfd00b1469f9cc44c8c66c9d5be44 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 10:56:05 -0600 Subject: [PATCH 6/7] Pin CI uv setup version --- .github/workflows/python-test.yml | 1 + .github/workflows/selene-plugins.yml | 2 ++ .github/workflows/test-docs-examples.yml | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 2943d689c..c10fc0f6d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -80,6 +80,7 @@ jobs: - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 with: + version: "0.11.14" enable-cache: true - name: Set up Rust (Windows) diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index 710e0f4e4..dab9a5d6c 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -52,6 +52,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: + version: "0.11.14" enable-cache: true - name: Set up Rust @@ -145,6 +146,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: + version: "0.11.14" enable-cache: true - name: Set up Rust diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index b89842305..0c3d64bf0 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -36,6 +36,7 @@ jobs: - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 with: + version: "0.11.14" enable-cache: true - name: Set up Rust From 73309285f4b7caaeda3169428ee902d6ed3985bd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 13 May 2026 11:55:53 -0600 Subject: [PATCH 7/7] Fix CLI env path handling on Windows --- crates/pecos-cli/src/cli/env_cmd.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index cb2cec25b..1d942fc8d 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -49,11 +49,12 @@ pub fn collect_env() -> BTreeMap { // Add LLVM bin to PATH let bin_path = llvm_path.join("bin"); if bin_path.exists() { - let current_path = std::env::var("PATH").unwrap_or_default(); - env.insert( - "PATH".into(), - format!("{}:{current_path}", bin_path.display()), - ); + let current_path = std::env::var_os("PATH").unwrap_or_default(); + let path_entries = + std::iter::once(bin_path).chain(std::env::split_paths(¤t_path)); + if let Ok(path) = std::env::join_paths(path_entries) { + env.insert("PATH".into(), path.to_string_lossy().into_owned()); + } } } @@ -197,26 +198,27 @@ mod tests { let env_path = std::env::temp_dir().join(format!("pecos-gh-env-{unique}")); let path_path = std::env::temp_dir().join(format!("pecos-gh-path-{unique}")); + let llvm_prefix = Path::new("/opt/pecos/llvm-14"); + let llvm_prefix_str = llvm_prefix.display().to_string(); + let llvm_bin_str = llvm_prefix.join("bin").display().to_string(); + let mut env = BTreeMap::new(); - env.insert( - "LLVM_SYS_140_PREFIX".to_string(), - "/opt/pecos/llvm-14".to_string(), - ); + env.insert("LLVM_SYS_140_PREFIX".to_string(), llvm_prefix_str.clone()); env.insert( "PATH".to_string(), "/opt/pecos/llvm-14/bin:/usr/bin".to_string(), ); - env.insert("PECOS_LLVM".to_string(), "/opt/pecos/llvm-14".to_string()); + env.insert("PECOS_LLVM".to_string(), llvm_prefix_str.clone()); write_github_actions_files(&env, &env_path, &path_path).unwrap(); let env_file = std::fs::read_to_string(&env_path).unwrap(); let path_file = std::fs::read_to_string(&path_path).unwrap(); - assert!(env_file.contains("LLVM_SYS_140_PREFIX=/opt/pecos/llvm-14")); - assert!(env_file.contains("PECOS_LLVM=/opt/pecos/llvm-14")); + assert!(env_file.contains(&format!("LLVM_SYS_140_PREFIX={llvm_prefix_str}"))); + assert!(env_file.contains(&format!("PECOS_LLVM={llvm_prefix_str}"))); assert!(!env_file.contains("PATH=")); - assert_eq!(path_file.trim(), "/opt/pecos/llvm-14/bin"); + assert_eq!(path_file.trim(), llvm_bin_str); let _ = std::fs::remove_file(env_path); let _ = std::fs::remove_file(path_path);