diff --git a/backend/src/descriptor.rs b/backend/src/descriptor.rs index 50fb14a..43c2d2e 100644 --- a/backend/src/descriptor.rs +++ b/backend/src/descriptor.rs @@ -465,24 +465,7 @@ fn neutral_atom_from_descriptor( .atom_loss_model .map(loss_model_from_descriptor) .transpose()?, - cost_model: NeutralAtomCostModel { - rydberg_stage_weight: non_negative_f64( - "cost_model.rydberg_stage_weight", - d.cost_model.rydberg_stage_weight, - )?, - movement_time_weight: non_negative_f64( - "cost_model.movement_time_weight", - d.cost_model.movement_time_weight, - )?, - trap_transfer_weight: non_negative_f64( - "cost_model.trap_transfer_weight", - d.cost_model.trap_transfer_weight, - )?, - idle_time_weight: non_negative_f64( - "cost_model.idle_time_weight", - d.cost_model.idle_time_weight, - )?, - }, + cost_model: cost_model_from_descriptor(d.cost_model)?, }; let entanglement_capacity = target.zone_capacity(ZoneKind::Entanglement); @@ -495,6 +478,35 @@ fn neutral_atom_from_descriptor( Ok(BackendTarget::neutral_atom_reconfigurable(d.id, target)) } +fn cost_model_from_descriptor( + d: NeutralAtomCostModelDescriptor, +) -> Result { + let rydberg_stage_weight = + non_negative_f64("cost_model.rydberg_stage_weight", d.rydberg_stage_weight)?; + let movement_time_weight = + non_negative_f64("cost_model.movement_time_weight", d.movement_time_weight)?; + let trap_transfer_weight = + non_negative_f64("cost_model.trap_transfer_weight", d.trap_transfer_weight)?; + let idle_time_weight = non_negative_f64("cost_model.idle_time_weight", d.idle_time_weight)?; + // Finite and non-negative is not enough: an all-zero vector scores every + // schedule the same, so placement ignores it. + if rydberg_stage_weight == 0.0 + && movement_time_weight == 0.0 + && trap_transfer_weight == 0.0 + && idle_time_weight == 0.0 + { + return invalid_config( + "cost_model weights are all zero (rydberg_stage_weight, movement_time_weight, trap_transfer_weight, idle_time_weight); at least one must be positive so the objective can select a schedule", + ); + } + Ok(NeutralAtomCostModel { + rydberg_stage_weight, + movement_time_weight, + trap_transfer_weight, + idle_time_weight, + }) +} + fn zone_from_descriptor(d: NeutralAtomZoneDescriptor) -> Result { let kind = d.kind; let pair_gap_um = match (kind, d.pair_gap_um) { diff --git a/backend/tests/target.rs b/backend/tests/target.rs index 389ee6d..c5d8501 100644 --- a/backend/tests/target.rs +++ b/backend/tests/target.rs @@ -659,6 +659,44 @@ fn neutral_atom_error_model_rejects_out_of_range_probability() { assert!(err.to_string().contains("error_model.rydberg")); } +fn with_cost_model(stage: f64, movement: f64, transfer: f64, idle: f64) -> String { + let mut value = neutral_sample_value(); + let model = value + .get_mut("cost_model") + .and_then(|v| v.as_object_mut()) + .expect("cost_model object"); + model.insert("rydberg_stage_weight".into(), serde_json::json!(stage)); + model.insert("movement_time_weight".into(), serde_json::json!(movement)); + model.insert("trap_transfer_weight".into(), serde_json::json!(transfer)); + model.insert("idle_time_weight".into(), serde_json::json!(idle)); + value.to_string() +} + +#[test] +fn neutral_atom_all_zero_cost_weights_are_rejected() { + let err = json::from_str(&with_cost_model(0.0, 0.0, 0.0, 0.0)).unwrap_err(); + assert!( + matches!(err, BackendError::InvalidTargetConfig(_)), + "got {err:?}" + ); + let msg = err.to_string(); + assert!(msg.contains("all zero"), "{msg}"); + assert!(msg.contains("rydberg_stage_weight"), "{msg}"); + assert!(msg.contains("movement_time_weight"), "{msg}"); + assert!(msg.contains("trap_transfer_weight"), "{msg}"); + assert!(msg.contains("idle_time_weight"), "{msg}"); + assert!(msg.contains("at least one must be positive"), "{msg}"); + + let loaded = json::from_str(&with_cost_model(1.0, 0.0, 0.0, 0.0)) + .expect("one positive weight still loads"); + let na = loaded + .neutral_atom_target() + .expect("neutral atom") + .cost_model; + assert_eq!(na.rydberg_stage_weight, 1.0); + assert_eq!(na.movement_time_weight, 0.0); +} + #[test] fn neutral_atom_error_model_rejects_negative_probability() { let src = with_error_model_mutated(|m| { diff --git a/quon_na/src/objective.rs b/quon_na/src/objective.rs index 0ac3b76..1acf797 100644 --- a/quon_na/src/objective.rs +++ b/quon_na/src/objective.rs @@ -59,6 +59,12 @@ pub enum ObjectiveError { "cost_model weights must be finite and non-negative (rydberg_stage_weight, movement_time_weight, trap_transfer_weight, idle_time_weight)" )] InvalidWeights, + /// Every schedule scores 0, so strict cost comparisons never move and the + /// vector does not select a schedule. + #[error( + "cost_model weights are all zero (rydberg_stage_weight, movement_time_weight, trap_transfer_weight, idle_time_weight); at least one must be positive so the objective can select a schedule" + )] + AllZeroWeights, } /// Movement, transfer, and idle cost of one gate orientation. @@ -132,6 +138,9 @@ pub fn weighted_total( if !weights_usable(weights) { return Err(ObjectiveError::InvalidWeights); } + if cost_weights_are_all_zero(weights) { + return Err(ObjectiveError::AllZeroWeights); + } Ok(weights.rydberg_stage_weight * rydberg_stages as f64 + weights.movement_time_weight * movement_time_us as f64 + weights.trap_transfer_weight * trap_transfers as f64 @@ -149,6 +158,18 @@ fn weights_usable(weights: &NeutralAtomCostModel) -> bool { .all(|weight| weight.is_finite() && weight >= 0.0) } +/// True when every §9 weight is exactly zero. +/// +/// Non-finite and negative values are a separate check ([`weights_usable`]). +/// An all-zero vector is finite and non-negative, but every schedule has the +/// same cost, so placement comparisons ignore it. +pub(crate) fn cost_weights_are_all_zero(weights: &NeutralAtomCostModel) -> bool { + weights.rydberg_stage_weight == 0.0 + && weights.movement_time_weight == 0.0 + && weights.trap_transfer_weight == 0.0 + && weights.idle_time_weight == 0.0 +} + /// Objective of a schedule that has already passed verification. /// /// Counts come from `spec` alone: Rydberg stages are entangle layers, movement @@ -342,6 +363,21 @@ mod tests { assert_eq!(err, ObjectiveError::InvalidWeights); } + #[test] + fn all_zero_weights_are_rejected() { + let err = weighted_total(1, 1, 1, 1, &weights(0.0, 0.0, 0.0, 0.0)).expect_err("zero"); + assert_eq!(err, ObjectiveError::AllZeroWeights); + let msg = err.to_string(); + assert!(msg.contains("all zero"), "{msg}"); + assert!(msg.contains("rydberg_stage_weight"), "{msg}"); + assert!(msg.contains("movement_time_weight"), "{msg}"); + assert!(msg.contains("trap_transfer_weight"), "{msg}"); + assert!(msg.contains("idle_time_weight"), "{msg}"); + assert!(msg.contains("at least one must be positive"), "{msg}"); + let total = weighted_total(2, 10, 4, 100, &weights(0.0, 0.0, 0.0, 1.0)).expect("idle"); + assert_eq!(total, 100.0); + } + #[cfg(feature = "mlir")] #[test] fn verified_spec_objective_counts_each_component() { diff --git a/quon_na/src/zoned.rs b/quon_na/src/zoned.rs index 334bfb0..1d05d36 100644 --- a/quon_na/src/zoned.rs +++ b/quon_na/src/zoned.rs @@ -575,6 +575,11 @@ pub enum ZonedScheduleError { EmptySchedule, #[error("schedule layer conflict: {0}")] Conflict(String), + /// Every schedule scores 0, so the placer's strict comparisons never move. + #[error( + "cost_model weights are all zero (rydberg_stage_weight, movement_time_weight, trap_transfer_weight, idle_time_weight); at least one must be positive so the objective can select a schedule" + )] + AllZeroCostWeights, } /// √(d_max / a) duration contribution for one movement group (\[RAP\] Eq. (1)). @@ -978,6 +983,11 @@ pub fn schedule_zoned_with_aware_params( aware_search: AwareSearchParams, cost_model: PlacementCostModel, ) -> Result, ZonedScheduleError> { + if let PlacementCostModel::Weighted { weights, .. } = cost_model { + if crate::objective::cost_weights_are_all_zero(&weights) { + return Err(ZonedScheduleError::AllZeroCostWeights); + } + } arch.validate()?; if req.layers.is_empty() { // Synthesize one layer per commutation/ ASAP is caller's job; allow @@ -3445,6 +3455,63 @@ mod tests { toy_zoned_architecture().validate().expect("ok"); } + fn weighted_cost(stage: f64, movement: f64, transfer: f64, idle: f64) -> PlacementCostModel { + let arch = toy_zoned_architecture(); + PlacementCostModel::Weighted { + weights: backend::NeutralAtomCostModel { + rydberg_stage_weight: stage, + movement_time_weight: movement, + trap_transfer_weight: transfer, + idle_time_weight: idle, + }, + speed_model: arch.speed_model, + trap_transfer_us: arch.trap_transfer_us, + } + } + + fn entangling_request() -> crate::schedule_entry::GraphScheduleRequest { + let graph = matching_graph(2); + let req = schedule_from_graph(graph).expect("stub"); + schedule_entangling_layers(req, 340) + .expect("layers") + .request + } + + #[test] + fn all_zero_cost_weights_fail_and_a_positive_weight_still_schedules() { + let arch = toy_zoned_architecture(); + let err = schedule_zoned_with_aware_params( + entangling_request(), + &arch, + PlacerMode::RoutingAgnostic, + AwareSearchParams::default(), + weighted_cost(0.0, 0.0, 0.0, 0.0), + ) + .expect_err("all-zero weights"); + let msg = err.to_string(); + assert!(msg.contains("all zero"), "{msg}"); + assert!(msg.contains("rydberg_stage_weight"), "{msg}"); + assert!(msg.contains("movement_time_weight"), "{msg}"); + assert!(msg.contains("trap_transfer_weight"), "{msg}"); + assert!(msg.contains("idle_time_weight"), "{msg}"); + assert!(msg.contains("at least one must be positive"), "{msg}"); + + let result = schedule_zoned_with_aware_params( + entangling_request(), + &arch, + PlacerMode::RoutingAgnostic, + AwareSearchParams::default(), + weighted_cost(1.0, 0.0, 0.0, 0.0), + ) + .expect("a positive stage weight still selects a schedule"); + assert!(result.request.layers.iter().any(|layer| { + layer + .actions + .iter() + .any(|action| matches!(action, NeutralAtomAction::Entangle2 { .. })) + })); + } + #[test] fn schedule_zoned_entangles_in_entanglement_zone() { let graph = matching_graph(2);