1use sha2::{Digest, Sha256};
2use sim_kernel::{
3 CapabilityName, Consistency, ContentId, Cx, EvalFabric, EvalMode, EvalRequest, Expr, ShapeRef,
4 Symbol, Value,
5};
6use sim_lib_numbers_stats::{
7 CoverageEvidence, LatinHypercubePlan, SampleDesign, SobolPlan, SweepPlan,
8};
9pub use sim_lib_numbers_stats::{Scramble, UntestedRegion};
10use sim_lib_physics_influence::SelectionInput;
11use std::{
12 collections::{BTreeMap, BTreeSet},
13 sync::Arc,
14 time::Duration,
15};
16
17pub type Identity = ContentId;
19
20#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub enum Partition {
23 Fit,
24 Selection,
25 Test,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30pub enum SamplerPolicy {
31 Grid {
32 counts: Vec<usize>,
33 },
34 Latin {
35 points: usize,
36 },
37 Sobol {
38 points: usize,
39 skip: u64,
40 scramble: Scramble,
41 },
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum Spacing {
47 Linear,
48 Logarithmic,
49}
50
51#[derive(Clone)]
53pub struct BoundaryInjection {
54 pub label: String,
55 pub values: Vec<f64>,
56}
57
58#[derive(Clone)]
60pub struct ParameterAxis {
61 pub name: String,
62 pub quantity_shape: ShapeRef,
63 pub inclusive_bounds: (f64, f64),
64 pub spacing: Spacing,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct PartitionPlan {
70 pub fit: BTreeSet<usize>,
71 pub selection: BTreeSet<usize>,
72 pub test: BTreeSet<usize>,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct PlacementRequest {
78 pub target: Symbol,
79 pub consistency: Consistency,
80 pub required_capabilities: Vec<CapabilityName>,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct StudyLimits {
85 pub max_points: usize,
86 pub max_failures: usize,
87 pub deadline: Option<Duration>,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct AuditPolicy {
92 pub energy_after_selection_only: bool,
93 pub record_residuals: bool,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct InfluencePolicy {
98 pub require_clean_selection: bool,
99}
100
101#[derive(Clone)]
103pub struct StudyPlan {
104 pub id: Identity,
105 pub model_id: Identity,
106 pub boundary_event_graph_id: Identity,
107 pub initial_state_id: Identity,
108 pub method_plan_ids: Vec<Identity>,
109 pub refinement_plan_ids: Vec<Identity>,
110 pub audit_policy: AuditPolicy,
111 pub influence_policy: InfluencePolicy,
112 pub outputs: Vec<String>,
113 pub limits: StudyLimits,
114 pub seed: u64,
115 pub placement: PlacementRequest,
116 pub axes: Vec<ParameterAxis>,
117 pub sampler: SamplerPolicy,
118 pub boundary_injections: Vec<BoundaryInjection>,
119 pub partitions: PartitionPlan,
120 pub untested_regions: Vec<UntestedRegion>,
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub enum PlanError {
125 Empty(&'static str),
126 InvalidAxis(String),
127 InvalidBoundary(String),
128 PartitionOverlap(usize),
129 MissingPartition(usize),
130 WorkLimit { requested: usize, limit: usize },
131 Sampler(String),
132}
133
134impl StudyPlan {
135 pub fn validate(&self) -> Result<(), PlanError> {
137 if self.axes.is_empty() {
138 return Err(PlanError::Empty("axes"));
139 }
140 if self.outputs.is_empty() {
141 return Err(PlanError::Empty("outputs"));
142 }
143 if self.method_plan_ids.is_empty() {
144 return Err(PlanError::Empty("method plans"));
145 }
146 if self.refinement_plan_ids.is_empty() {
147 return Err(PlanError::Empty("refinement plans"));
148 }
149 if self.untested_regions.is_empty() {
150 return Err(PlanError::Empty("known untested regions"));
151 }
152 if !self.audit_policy.energy_after_selection_only {
153 return Err(PlanError::Empty("energy audit must be post-selection"));
154 }
155 for axis in &self.axes {
156 let (lo, hi) = axis.inclusive_bounds;
157 if axis.name.is_empty()
158 || !lo.is_finite()
159 || !hi.is_finite()
160 || lo > hi
161 || (axis.spacing == Spacing::Logarithmic && lo <= 0.0)
162 {
163 return Err(PlanError::InvalidAxis(axis.name.clone()));
164 }
165 }
166 for boundary in &self.boundary_injections {
167 if boundary.label.is_empty()
168 || boundary.values.len() != self.axes.len()
169 || boundary.values.iter().zip(&self.axes).any(|(v, a)| {
170 !v.is_finite() || *v < a.inclusive_bounds.0 || *v > a.inclusive_bounds.1
171 })
172 {
173 return Err(PlanError::InvalidBoundary(boundary.label.clone()));
174 }
175 }
176 for index in self
177 .partitions
178 .fit
179 .iter()
180 .chain(&self.partitions.selection)
181 .chain(&self.partitions.test)
182 {
183 let lanes = usize::from(self.partitions.fit.contains(index))
184 + usize::from(self.partitions.selection.contains(index))
185 + usize::from(self.partitions.test.contains(index));
186 if lanes > 1 {
187 return Err(PlanError::PartitionOverlap(*index));
188 }
189 }
190 Ok(())
191 }
192
193 pub fn design(&self) -> Result<StudyDesign, PlanError> {
195 self.validate()?;
196 let dimensions = self.axes.len();
197 let base = match &self.sampler {
198 SamplerPolicy::Grid { counts } => {
199 grid_design(dimensions, counts, &self.untested_regions)?
200 }
201 SamplerPolicy::Latin { points } => LatinHypercubePlan {
202 dimensions,
203 points: *points,
204 seed: self.seed,
205 max_work: self.limits.max_points.saturating_mul(dimensions) as u64,
206 untested_regions: self.untested_regions.clone(),
207 }
208 .generate()
209 .map_err(|e| PlanError::Sampler(format!("{e:?}")))?,
210 SamplerPolicy::Sobol {
211 points,
212 skip,
213 scramble,
214 } => SobolPlan {
215 dimensions,
216 points: *points,
217 skip: *skip,
218 scramble: *scramble,
219 seed: self.seed,
220 max_work: self.limits.max_points.saturating_mul(dimensions) as u64,
221 untested_regions: self.untested_regions.clone(),
222 }
223 .generate()
224 .map_err(|e| PlanError::Sampler(format!("{e:?}")))?,
225 };
226 let mut sampled = SweepPlan {
228 inject_lower_boundary: false,
229 inject_upper_boundary: false,
230 untested_regions: vec![],
231 }
232 .apply(base);
233 let mut physical = sampled
234 .points
235 .iter()
236 .map(|p| scale_point(p, &self.axes))
237 .collect::<Vec<_>>();
238 let mut injected = Vec::new();
239 for boundary in &self.boundary_injections {
240 if let Some(existing) = physical
241 .iter()
242 .position(|p| exact_point_eq(p, &boundary.values))
243 {
244 injected.push((boundary.label.clone(), existing, true));
245 } else {
246 let index = physical.len();
247 physical.push(boundary.values.clone());
248 sampled
249 .points
250 .push(unscale_point(&boundary.values, &self.axes));
251 injected.push((boundary.label.clone(), index, false));
252 }
253 }
254 if physical.len() > self.limits.max_points {
255 return Err(PlanError::WorkLimit {
256 requested: physical.len(),
257 limit: self.limits.max_points,
258 });
259 }
260 sampled.coverage.boundary_injections = injected.iter().map(|(_, i, _)| *i).collect();
261 sampled.coverage.duplicates = exact_duplicates(&physical);
262 let mut points = Vec::with_capacity(physical.len());
263 for (ordinal, coordinates) in physical.into_iter().enumerate() {
264 let partition = partition_for(&self.partitions, ordinal)
265 .ok_or(PlanError::MissingPartition(ordinal))?;
266 points.push(StudyPoint {
267 id: point_id(&self.id, ordinal, &coordinates),
268 ordinal,
269 coordinates,
270 partition,
271 });
272 }
273 Ok(StudyDesign {
274 plan_id: self.id.clone(),
275 points,
276 coverage: sampled.coverage,
277 boundary_evidence: injected,
278 })
279 }
280}
281
282fn partition_for(plan: &PartitionPlan, i: usize) -> Option<Partition> {
283 if plan.fit.contains(&i) {
284 Some(Partition::Fit)
285 } else if plan.selection.contains(&i) {
286 Some(Partition::Selection)
287 } else if plan.test.contains(&i) {
288 Some(Partition::Test)
289 } else {
290 None
291 }
292}
293
294fn grid_design(
295 dimensions: usize,
296 counts: &[usize],
297 untested: &[UntestedRegion],
298) -> Result<SampleDesign, PlanError> {
299 if counts.len() != dimensions || counts.contains(&0) {
300 return Err(PlanError::Sampler(
301 "grid counts must match nonzero axes".into(),
302 ));
303 }
304 let count = counts
305 .iter()
306 .try_fold(1usize, |n, v| n.checked_mul(*v))
307 .ok_or(PlanError::Sampler("grid size overflow".into()))?;
308 let mut points = Vec::with_capacity(count);
309 for ordinal in 0..count {
310 let mut remainder = ordinal;
311 let mut point = Vec::with_capacity(dimensions);
312 for cells in counts {
313 let cell = remainder % *cells;
314 remainder /= *cells;
315 point.push(if *cells == 1 {
316 0.5
317 } else {
318 cell as f64 / (*cells - 1) as f64
319 });
320 }
321 points.push(point);
322 }
323 Ok(SampleDesign {
324 points,
325 coverage: CoverageEvidence {
326 sequence_identity: format!("cartesian-grid/inclusive-v1;counts={counts:?}"),
327 boundary_injections: vec![],
328 duplicates: vec![],
329 stratum_occupancy: counts.iter().map(|n| vec![1; *n]).collect(),
330 sampler: None,
331 untested_regions: untested.to_vec(),
332 work: count as u64,
333 },
334 })
335}
336
337fn scale_point(unit: &[f64], axes: &[ParameterAxis]) -> Vec<f64> {
338 unit.iter()
339 .zip(axes)
340 .map(|(u, a)| {
341 let (lo, hi) = a.inclusive_bounds;
342 match a.spacing {
343 Spacing::Linear => lo + u * (hi - lo),
344 Spacing::Logarithmic => (lo.ln() + u * (hi.ln() - lo.ln())).exp(),
345 }
346 })
347 .collect()
348}
349fn unscale_point(values: &[f64], axes: &[ParameterAxis]) -> Vec<f64> {
350 values
351 .iter()
352 .zip(axes)
353 .map(|(v, a)| {
354 let (lo, hi) = a.inclusive_bounds;
355 if lo == hi {
356 0.0
357 } else {
358 match a.spacing {
359 Spacing::Linear => (v - lo) / (hi - lo),
360 Spacing::Logarithmic => (v.ln() - lo.ln()) / (hi.ln() - lo.ln()),
361 }
362 }
363 })
364 .collect()
365}
366fn exact_point_eq(a: &[f64], b: &[f64]) -> bool {
367 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
368}
369fn exact_duplicates(points: &[Vec<f64>]) -> Vec<(usize, usize)> {
370 let mut out = vec![];
371 for later in 0..points.len() {
372 if let Some(earlier) = (0..later).find(|i| exact_point_eq(&points[*i], &points[later])) {
373 out.push((later, earlier));
374 }
375 }
376 out
377}
378
379fn identity(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> Identity {
380 let mut h = Sha256::new();
381 for part in parts {
382 let p = part.as_ref();
383 h.update((p.len() as u64).to_be_bytes());
384 h.update(p);
385 }
386 ContentId::from_bytes(Symbol::qualified("core", "sha256"), h.finalize().into())
387}
388fn point_id(plan: &Identity, ordinal: usize, values: &[f64]) -> Identity {
389 let mut parts = Vec::new();
390 parts.push(plan.bytes.to_vec());
391 parts.push((ordinal as u64).to_be_bytes().to_vec());
392 for value in values {
393 parts.push(value.to_bits().to_be_bytes().to_vec());
394 }
395 identity(parts)
396}
397
398pub struct StudyDesign {
400 pub plan_id: Identity,
401 pub points: Vec<StudyPoint>,
402 pub coverage: CoverageEvidence,
403 pub boundary_evidence: Vec<(String, usize, bool)>,
404}
405#[derive(Clone, Debug, PartialEq)]
406pub struct StudyPoint {
407 pub id: Identity,
408 pub ordinal: usize,
409 pub coordinates: Vec<f64>,
410 pub partition: Partition,
411}
412
413pub trait PointExpression: Send + Sync {
415 fn expression(&self, plan: &StudyPlan, point: &StudyPoint) -> sim_kernel::Result<Expr>;
416}
417
418#[derive(Clone, Debug, Eq, PartialEq)]
419pub enum IncompleteOutcome {
420 Refused(String),
421 Interrupted(String),
422 Unknown(String),
423}
424
425pub enum PointOutcome {
427 Complete(Value),
428 Incomplete(IncompleteOutcome),
429}
430
431pub struct PointResult {
432 pub id: Identity,
433 pub plan_id: Identity,
434 pub point_id: Identity,
435 pub reference_execution_id: Identity,
436 pub provider_id: Identity,
437 pub partition: Partition,
438 pub outcome: PointOutcome,
439}
440
441pub fn realize_sweep(
444 cx: &mut Cx,
445 fabric: &dyn EvalFabric,
446 plan: &StudyPlan,
447 design: &StudyDesign,
448 builder: &dyn PointExpression,
449 provider_id: Identity,
450) -> Vec<PointResult> {
451 let mut results = Vec::with_capacity(design.points.len());
452 for point in &design.points {
453 let execution_id = identity([
454 plan.id.bytes.as_slice(),
455 point.id.bytes.as_slice(),
456 provider_id.bytes.as_slice(),
457 ]);
458 let outcome = match builder.expression(plan, point) {
459 Err(error) => PointOutcome::Incomplete(IncompleteOutcome::Refused(error.to_string())),
460 Ok(expr) => match fabric.realize(
461 cx,
462 EvalRequest {
463 expr,
464 result_shape: None,
465 required_capabilities: plan.placement.required_capabilities.clone(),
466 deadline: plan.limits.deadline,
467 consistency: plan.placement.consistency,
468 mode: EvalMode::Eval,
469 answer_limit: None,
470 stream_buffer: None,
471 stream: false,
472 trace: true,
473 },
474 ) {
475 Ok(reply) => PointOutcome::Complete(reply.value),
476 Err(error) => PointOutcome::Incomplete(classify_failure(error.to_string())),
477 },
478 };
479 let result_id = identity([execution_id.bytes.as_slice(), outcome_tag(&outcome)]);
480 results.push(PointResult {
481 id: result_id,
482 plan_id: plan.id.clone(),
483 point_id: point.id.clone(),
484 reference_execution_id: execution_id,
485 provider_id: provider_id.clone(),
486 partition: point.partition,
487 outcome,
488 });
489 }
490 results
491}
492fn classify_failure(message: String) -> IncompleteOutcome {
493 let lower = message.to_ascii_lowercase();
494 if lower.contains("interrupt") || lower.contains("cancel") {
495 IncompleteOutcome::Interrupted(message)
496 } else if lower.contains("refus") || lower.contains("capab") || lower.contains("limit") {
497 IncompleteOutcome::Refused(message)
498 } else {
499 IncompleteOutcome::Unknown(message)
500 }
501}
502fn outcome_tag(outcome: &PointOutcome) -> &'static [u8] {
503 match outcome {
504 PointOutcome::Complete(_) => b"complete",
505 PointOutcome::Incomplete(IncompleteOutcome::Refused(_)) => b"refused",
506 PointOutcome::Incomplete(IncompleteOutcome::Interrupted(_)) => b"interrupted",
507 PointOutcome::Incomplete(IncompleteOutcome::Unknown(_)) => b"unknown",
508 }
509}
510
511pub fn select<'a>(
513 proof: &SelectionInput,
514 candidates: impl IntoIterator<Item = &'a PointResult>,
515 score: impl Fn(&PointResult) -> Option<f64>,
516) -> Option<&'a PointResult> {
517 let _proof_identity = proof.proof_identity();
518 candidates
519 .into_iter()
520 .filter(|r| r.partition == Partition::Selection)
521 .filter_map(|r| score(r).filter(|v| v.is_finite()).map(|v| (r, v)))
522 .min_by(|a, b| a.1.total_cmp(&b.1))
523 .map(|(r, _)| r)
524}
525
526#[derive(Clone, Debug, Default, PartialEq)]
528pub struct Observations {
529 pub energy_store_change: Option<f64>,
530 pub unexplained_residual: Option<f64>,
531 pub sign_reversal: bool,
532 pub zero_crossing: bool,
533 pub event_transition: bool,
534 pub topology_transition: bool,
535 pub initial_state_sensitivity: Option<f64>,
536 pub boundary_value: bool,
537 pub fold: bool,
538 pub method_sensitivity: Option<f64>,
539 pub tolerance_sensitivity: Option<f64>,
540 pub precision_sensitivity: Option<f64>,
541}
542
543#[derive(Clone, Debug, Default)]
544pub struct ObservationInput {
545 pub energy_before: Option<f64>,
546 pub energy_after: Option<f64>,
547 pub residual: Option<f64>,
548 pub previous: Option<f64>,
549 pub current: Option<f64>,
550 pub event_changed: bool,
551 pub topology_changed: bool,
552 pub initial_delta: Option<f64>,
553 pub boundary_value: bool,
554 pub slope_before: Option<f64>,
555 pub slope_after: Option<f64>,
556 pub method_delta: Option<f64>,
557 pub tolerance_delta: Option<f64>,
558 pub precision_delta: Option<f64>,
559}
560pub fn detect(input: &ObservationInput) -> Observations {
561 let sign_reversal = matches!((input.previous,input.current),(Some(a),Some(b)) if a.signum()!=b.signum() && a!=0.0 && b!=0.0);
562 let zero_crossing = matches!((input.previous,input.current),(Some(a),Some(b)) if a==0.0 || b==0.0 || a.signum()!=b.signum());
563 let fold = matches!((input.slope_before,input.slope_after),(Some(a),Some(b)) if a.signum()!=b.signum());
564 Observations {
565 energy_store_change: input
566 .energy_before
567 .zip(input.energy_after)
568 .map(|(a, b)| b - a),
569 unexplained_residual: input.residual,
570 sign_reversal,
571 zero_crossing,
572 event_transition: input.event_changed,
573 topology_transition: input.topology_changed,
574 initial_state_sensitivity: input.initial_delta.map(f64::abs),
575 boundary_value: input.boundary_value,
576 fold,
577 method_sensitivity: input.method_delta.map(f64::abs),
578 tolerance_sensitivity: input.tolerance_delta.map(f64::abs),
579 precision_sensitivity: input.precision_delta.map(f64::abs),
580 }
581}
582
583pub fn exact_replay(left: &PointResult, right: &PointResult) -> bool {
585 left.reference_execution_id == right.reference_execution_id && left.id == right.id
586}
587
588#[derive(Clone, Debug, PartialEq)]
589pub struct ProviderEnvelope {
590 pub provider_id: Identity,
591 pub absolute_tolerance: f64,
592 pub relative_tolerance: f64,
593}
594#[derive(Clone, Debug, PartialEq)]
595pub struct EnvelopeComparison {
596 pub equivalent: bool,
597 pub absolute_delta: f64,
598 pub allowed_delta: f64,
599}
600pub fn compare_provider_values(
601 left: f64,
602 right: f64,
603 left_provider: &ProviderEnvelope,
604 right_provider: &ProviderEnvelope,
605) -> EnvelopeComparison {
606 let absolute_delta = (right - left).abs();
607 let scale = left.abs().max(right.abs());
608 let allowed_delta = left_provider
609 .absolute_tolerance
610 .max(right_provider.absolute_tolerance)
611 + scale
612 * left_provider
613 .relative_tolerance
614 .max(right_provider.relative_tolerance);
615 EnvelopeComparison {
616 equivalent: absolute_delta <= allowed_delta,
617 absolute_delta,
618 allowed_delta,
619 }
620}
621
622pub struct StudyResults {
624 pub plan_id: Identity,
625 pub results: Vec<PointResult>,
626 pub observations: BTreeMap<Identity, Observations>,
627 pub retry_safe: BTreeSet<Identity>,
628}
629impl StudyResults {
630 pub fn new(plan_id: Identity, results: Vec<PointResult>) -> Self {
631 Self {
632 plan_id,
633 results,
634 observations: BTreeMap::new(),
635 retry_safe: BTreeSet::new(),
636 }
637 }
638 pub fn annotate_selected(&mut self, result: &PointResult, observations: Observations) {
639 self.observations.insert(result.id.clone(), observations);
640 }
641}
642
643pub fn study_lib_symbol() -> Symbol {
645 Symbol::qualified("physics-study", "lib")
646}
647pub fn study_surface_symbols() -> [Symbol; 4] {
648 [
649 Symbol::qualified("physics-study", "design"),
650 Symbol::qualified("physics-study", "realize"),
651 Symbol::qualified("physics-study", "select"),
652 Symbol::qualified("physics-study", "compare-providers"),
653 ]
654}
655
656pub type StudyFabric = Arc<dyn EvalFabric>;