use runmat_analysis_core::{
AnalysisFieldValues, BoundaryCondition, BoundaryConditionKind, ElectromagneticDomain, LoadCase,
LoadKind, MaterialElectricalModel, ReferenceFrame,
};
use runmat_analysis_fea::fixtures::{fixture_model, FixtureId};
use runmat_analysis_fea::{
fea_cht_fluid_temperature_field_id, fea_cht_interface_heat_flux_field_id,
fea_cht_interface_temperature_jump_field_id, fea_cht_solid_temperature_field_id,
fea_fsi_interface_displacement_field_id, fea_fsi_interface_pressure_field_id,
fea_fsi_interface_traction_field_id, fea_modal_mode_shape_field_id,
fea_nonlinear_load_factor_field_id, fea_nonlinear_residual_norm_field_id,
fea_transient_residual_norm_field_id, ComputeBackend, FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY,
FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE, FEA_FIELD_CFD_PRESSURE, FEA_FIELD_CFD_VELOCITY,
FEA_FIELD_CFD_VORTICITY, FEA_FIELD_CFD_WALL_SHEAR_STRESS, FEA_FIELD_CHT_FLUID_PRESSURE,
FEA_FIELD_CHT_FLUID_VELOCITY, FEA_FIELD_EM_VECTOR_POTENTIAL_IMAG,
FEA_FIELD_EM_VECTOR_POTENTIAL_REAL, FEA_FIELD_MODAL_EIGENVALUE, FEA_FIELD_MODAL_FREQUENCY_HZ,
FEA_FIELD_MODAL_MODAL_MASS, FEA_FIELD_MODAL_MODAL_STIFFNESS, FEA_FIELD_MODAL_M_ORTHOGONALITY,
FEA_FIELD_MODAL_PARTICIPATION_FACTOR, FEA_FIELD_MODAL_RELATIVE_FREQUENCY_SEPARATION,
FEA_FIELD_MODAL_RESIDUAL_NORM, FEA_FIELD_STRUCTURAL_DISPLACEMENT,
FEA_FIELD_STRUCTURAL_EQUATION_SCALE, FEA_FIELD_STRUCTURAL_REACTION_FORCE,
FEA_FIELD_STRUCTURAL_REACTION_MOMENT, FEA_FIELD_STRUCTURAL_RESIDUAL_NORM,
FEA_FIELD_STRUCTURAL_ROTATION, FEA_FIELD_STRUCTURAL_STRAIN, FEA_FIELD_STRUCTURAL_STRESS,
FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY, FEA_FIELD_STRUCTURAL_VON_MISES,
};
use runmat_geometry_core::{EntityKind, GeometryAsset, UnitSystem};
use runmat_runtime::analysis::{
analysis_create_model_op, analysis_plan_study_op, analysis_plan_study_sweep_op,
analysis_results_by_run_id_op, analysis_results_compare_op, analysis_results_op,
analysis_run_acoustic_op, analysis_run_cfd_op, analysis_run_cht_op,
analysis_run_electromagnetic_op, analysis_run_fsi_op, analysis_run_linear_static_op,
analysis_run_linear_static_with_options, analysis_run_modal_op,
analysis_run_modal_with_options_op, analysis_run_nonlinear_op,
analysis_run_nonlinear_with_options_op, analysis_run_study_op, analysis_run_study_sweep_op,
analysis_run_transient_op, analysis_run_transient_with_options_op, analysis_trends_op,
analysis_validate, analysis_validate_study_op, analysis_validate_study_sweep_op,
AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile, AnalysisModalRunOptions,
AnalysisNonlinearRunOptions, AnalysisResultsCompareQuery, AnalysisResultsQuery,
AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec, AnalysisStudySweepSpec,
AnalysisTransientRunOptions, AnalysisTrendsQuery, ModalFrequencyBasis, ModalFrequencyUnits,
PrecisionMode, PreconditionerMode, QualityGate, QualityPolicy, QualityReasonCode, RunStatus,
};
use runmat_runtime::geometry::{
geometry_capture_view_op, geometry_inspect_op, geometry_list_regions_op, geometry_load_op,
geometry_prep_artifact_health_op, geometry_prep_for_analysis_op, geometry_query_entities_op,
GeometryCaptureViewSpec, GeometryEntityQuery, GeometryPrepArtifactHealthQuery,
GeometryPrepForAnalysisSpec, GeometryPrepProfile,
};
use runmat_runtime::operations::OperationContext;
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
const TRIANGLE_STL: &str = "solid tri\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid tri\n";
const SIMPLE_STEP: &str = "ISO-10303-21;\nHEADER;\nFILE_NAME('Assembly_A');\nENDSEC;\nDATA;\n#10=PRODUCT('Base_Mount','',(#1));\n#11=PRODUCT('Tip_Load','',(#1));\n#20=MATERIAL_DESIGNATION('Aluminum 6061');\nENDSEC;\nEND-ISO-10303-21;\n";
struct EnvVarRestoreGuard {
key: &'static str,
previous: Option<String>,
}
impl Drop for EnvVarRestoreGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.as_ref() {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}
fn study_artifact_env_guard() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn sorted_object_keys(value: &Value) -> Vec<String> {
let mut keys = value
.as_object()
.expect("snapshot target must be object")
.keys()
.cloned()
.collect::<Vec<_>>();
keys.sort();
keys
}
fn assert_fallback_event_schema(event: &str) {
let parts: Vec<&str> = event.splitn(3, ':').collect();
assert_eq!(parts.len(), 3, "fallback event must have 3 parts");
assert!(
matches!(
parts[0],
"BACKEND_NO_PROVIDER" | "BACKEND_UPLOAD_FAILED" | "SOLVER_BACKEND_FALLBACK"
),
"unexpected fallback category: {}",
parts[0]
);
if parts[0] == "SOLVER_BACKEND_FALLBACK" {
assert!(
parts[1].starts_with("requested="),
"unexpected solver backend fallback stage: {}",
parts[1]
);
} else {
assert!(
parts[1] == FEA_FIELD_STRUCTURAL_DISPLACEMENT
|| parts[1] == FEA_FIELD_STRUCTURAL_VON_MISES
|| parts[1] == FEA_FIELD_STRUCTURAL_STRAIN
|| parts[1] == FEA_FIELD_STRUCTURAL_STRESS
|| parts[1] == FEA_FIELD_STRUCTURAL_REACTION_FORCE
|| parts[1] == FEA_FIELD_STRUCTURAL_ROTATION
|| parts[1] == FEA_FIELD_STRUCTURAL_REACTION_MOMENT
|| parts[1] == FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY
|| parts[1] == FEA_FIELD_STRUCTURAL_RESIDUAL_NORM
|| parts[1] == FEA_FIELD_STRUCTURAL_EQUATION_SCALE,
"unexpected fallback stage: {}",
parts[1]
);
}
assert!(!parts[2].is_empty(), "fallback reason must be non-empty");
}
fn contract_study_spec(
study_id: &str,
model_id: &str,
geometry: GeometryAsset,
profile: AnalysisCreateModelProfile,
run_kind: AnalysisRunKind,
) -> AnalysisStudySpec {
AnalysisStudySpec {
study_id: study_id.to_string(),
geometry,
create_model_intent: AnalysisCreateModelIntentSpec {
model_id: model_id.to_string(),
profile,
prep_context: None,
},
model: None,
run_kind,
backend: ComputeBackend::Cpu,
linear_static_run_options: None,
modal_run_options: None,
acoustic_run_options: None,
thermal_run_options: None,
transient_run_options: None,
cfd_run_options: None,
cht_run_options: None,
fsi_run_options: None,
nonlinear_run_options: None,
electromagnetic_run_options: None,
}
}
#[test]
fn geometry_operation_contracts_are_v1_and_versioned() {
let context = OperationContext::new(Some("trace-contract-1".to_string()), None);
let inspect = geometry_inspect_op("/part.stl", TRIANGLE_STL.as_bytes(), context.clone())
.expect("inspect should succeed");
assert_eq!(inspect.operation, "geometry.inspect");
assert_eq!(inspect.op_version, "geometry.inspect/v1");
let load = geometry_load_op("/part.stl", TRIANGLE_STL.as_bytes(), context)
.expect("load should succeed");
assert_eq!(load.operation, "geometry.load");
assert_eq!(load.op_version, "geometry.load/v1");
let list_regions = geometry_list_regions_op(
&load.data,
OperationContext::new(Some("trace-contract-1c".to_string()), None),
)
.expect("list regions should succeed");
assert_eq!(list_regions.operation, "geometry.list_regions");
assert_eq!(list_regions.op_version, "geometry.list_regions/v1");
let query_entities = geometry_query_entities_op(
&load.data,
GeometryEntityQuery {
region_id: None,
mesh_id: None,
entity_kind: EntityKind::Node,
limit: Some(2),
},
OperationContext::new(Some("trace-contract-1d".to_string()), None),
)
.expect("query entities should succeed");
assert_eq!(query_entities.operation, "geometry.query_entities");
assert_eq!(query_entities.op_version, "geometry.query_entities/v1");
let capture_view = geometry_capture_view_op(
&load.data,
GeometryCaptureViewSpec {
format: "png".to_string(),
width: 800,
height: 600,
},
OperationContext::new(Some("trace-contract-1e".to_string()), None),
)
.expect_err("capture view is not wired yet");
assert_eq!(capture_view.operation, "geometry.capture_view");
assert_eq!(capture_view.op_version, "geometry.capture_view/v1");
assert_eq!(
capture_view.error_code,
"RM.GEOMETRY.CAPTURE_VIEW.UNSUPPORTED"
);
let svg_capture = geometry_capture_view_op(
&load.data,
GeometryCaptureViewSpec {
format: "svg".to_string(),
width: 800,
height: 600,
},
OperationContext::new(Some("trace-contract-1e-svg".to_string()), None),
)
.expect("svg capture should succeed via default adapter");
assert_eq!(svg_capture.operation, "geometry.capture_view");
assert_eq!(svg_capture.op_version, "geometry.capture_view/v1");
let prep = geometry_prep_for_analysis_op(
&load.data,
GeometryPrepForAnalysisSpec {
profile: GeometryPrepProfile::AnalysisReady,
target_element_budget: 100_000,
},
OperationContext::new(Some("trace-contract-1f".to_string()), None),
)
.expect("prep for analysis should succeed");
assert_eq!(prep.operation, "geometry.prep_for_analysis");
assert_eq!(prep.op_version, "geometry.prep_for_analysis/v1");
assert!(!prep.data.prep_artifact_id.is_empty());
assert!(!prep.data.prep.prepared_meshes.is_empty());
let adaptive_prep = geometry_prep_for_analysis_op(
&load.data,
GeometryPrepForAnalysisSpec {
profile: GeometryPrepProfile::AdaptiveRefine,
target_element_budget: 100_000,
},
OperationContext::new(Some("trace-contract-1f-adapt".to_string()), None),
)
.expect("adaptive prep for analysis should succeed");
assert_eq!(adaptive_prep.operation, "geometry.prep_for_analysis");
assert_eq!(adaptive_prep.op_version, "geometry.prep_for_analysis/v1");
let prep_health = geometry_prep_artifact_health_op(
GeometryPrepArtifactHealthQuery::default(),
OperationContext::new(Some("trace-contract-1g".to_string()), None),
)
.expect("prep artifact health should succeed");
assert_eq!(prep_health.operation, "geometry.prep_artifact_health");
assert_eq!(prep_health.op_version, "geometry.prep_artifact_health/v1");
assert_eq!(
prep_health.data.schema_version,
"geometry-prep-artifact-health/v1"
);
assert_eq!(svg_capture.data.format, "svg");
let invalid_capture_view = geometry_capture_view_op(
&load.data,
GeometryCaptureViewSpec {
format: "png".to_string(),
width: 0,
height: 600,
},
OperationContext::new(Some("trace-contract-1f".to_string()), None),
)
.expect_err("invalid capture dimensions should fail");
assert_eq!(invalid_capture_view.operation, "geometry.capture_view");
assert_eq!(invalid_capture_view.op_version, "geometry.capture_view/v1");
assert_eq!(
invalid_capture_view.error_code,
"RM.GEOMETRY.CAPTURE_VIEW.INVALID_SPEC"
);
}
#[test]
fn analysis_validate_contract_is_v1_and_maps_codes() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let context = OperationContext::new(Some("trace-contract-2".to_string()), None);
let envelope = analysis_validate(&model, UnitSystem::Meter, &ReferenceFrame::Global, context)
.expect("validate should succeed");
assert_eq!(envelope.operation, "fea.validate");
assert_eq!(envelope.op_version, "fea.validate/v1");
let mut invalid = model;
invalid.materials.clear();
let err = analysis_validate(
&invalid,
UnitSystem::Meter,
&ReferenceFrame::Global,
OperationContext::new(None, None),
)
.expect_err("validate should fail");
assert_eq!(err.operation, "fea.validate");
assert_eq!(err.op_version, "fea.validate/v1");
assert_eq!(err.error_code, "RM.FEA.VALIDATE.MISSING_MATERIALS");
}
#[test]
fn analysis_create_model_contract_is_v1_and_maps_codes() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-create-1".to_string()), None),
)
.expect("geometry load should succeed");
let envelope = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_generated_model".to_string(),
profile: AnalysisCreateModelProfile::LinearStaticStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-2".to_string()), None),
)
.expect("create model should succeed");
assert_eq!(envelope.operation, "fea.create_model");
assert_eq!(envelope.op_version, "fea.create_model/v1");
assert_eq!(envelope.data.model_id.0, "contract_generated_model");
let err = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "".to_string(),
profile: AnalysisCreateModelProfile::LinearStaticStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-3".to_string()), None),
)
.expect_err("create model should fail for empty model id");
assert_eq!(err.operation, "fea.create_model");
assert_eq!(err.op_version, "fea.create_model/v1");
assert_eq!(err.error_code, "RM.FEA.CREATE_MODEL.INVALID_INTENT");
let modal = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_modal_model".to_string(),
profile: AnalysisCreateModelProfile::ModalStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-4-modal".to_string()), None),
)
.expect("modal profile should be supported");
assert_eq!(modal.operation, "fea.create_model");
assert_eq!(modal.op_version, "fea.create_model/v1");
assert_eq!(
modal.data.steps[0].kind,
runmat_analysis_core::AnalysisStepKind::Modal
);
let acoustic = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_acoustic_harmonic_model".to_string(),
profile: AnalysisCreateModelProfile::AcousticHarmonic,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-4-acoustic".to_string()), None),
)
.expect("acoustic harmonic profile should be supported");
assert_eq!(acoustic.operation, "fea.create_model");
assert_eq!(acoustic.op_version, "fea.create_model/v1");
assert_eq!(
acoustic.data.steps[0].kind,
runmat_analysis_core::AnalysisStepKind::Modal
);
let transient = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_transient_model".to_string(),
profile: AnalysisCreateModelProfile::TransientStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-4-transient".to_string()), None),
)
.expect("transient profile should be supported");
assert_eq!(transient.operation, "fea.create_model");
assert_eq!(transient.op_version, "fea.create_model/v1");
assert_eq!(
transient.data.steps[0].kind,
runmat_analysis_core::AnalysisStepKind::Transient
);
let nonlinear = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_nonlinear_model".to_string(),
profile: AnalysisCreateModelProfile::NonlinearStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-4-nonlinear".to_string()), None),
)
.expect("nonlinear profile should be supported");
assert_eq!(nonlinear.operation, "fea.create_model");
assert_eq!(nonlinear.op_version, "fea.create_model/v1");
assert_eq!(
nonlinear.data.steps[0].kind,
runmat_analysis_core::AnalysisStepKind::Nonlinear
);
let electromagnetic = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_electromagnetic_model".to_string(),
profile: AnalysisCreateModelProfile::ElectromagneticStatic,
prep_context: None,
},
OperationContext::new(
Some("trace-contract-create-4-electromagnetic".to_string()),
None,
),
)
.expect("electromagnetic profile should be supported");
assert_eq!(electromagnetic.operation, "fea.create_model");
assert_eq!(electromagnetic.op_version, "fea.create_model/v1");
assert_eq!(
electromagnetic.data.steps[0].kind,
runmat_analysis_core::AnalysisStepKind::Electromagnetic
);
let electromagnetic_domain = electromagnetic
.data
.electromagnetic
.as_ref()
.expect("electromagnetic profile should seed electromagnetic domain");
assert!(electromagnetic_domain.enabled);
assert_eq!(electromagnetic_domain.reference_frequency_hz, 60.0);
assert_eq!(electromagnetic_domain.applied_current_a, 100.0);
}
#[test]
fn analysis_study_workflow_contract_persists_evidence_artifacts() {
let _study_artifact_env_guard = study_artifact_env_guard();
static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(1);
let evidence_root = std::env::temp_dir().join(format!(
"runmat-study-contract-artifacts-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&evidence_root);
let env_guard = EnvVarRestoreGuard {
key: "RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
previous: std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT").ok(),
};
std::env::set_var(
"RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
evidence_root.display().to_string(),
);
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-study-1".to_string()), None),
)
.expect("geometry load should succeed");
let spec = contract_study_spec(
"contract_study_linear_static",
"contract_study_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
);
let validate = analysis_validate_study_op(
&spec,
OperationContext::new(Some("trace-contract-study-2".to_string()), None),
)
.expect("validate study should succeed");
assert_eq!(validate.operation, "fea.validate_study");
assert_eq!(validate.op_version, "fea.validate_study/v1");
assert!(validate.data.valid);
assert_eq!(validate.data.issues.len(), 0);
assert!(PathBuf::from(&validate.data.evidence_artifact_path).exists());
let plan = analysis_plan_study_op(
&spec,
OperationContext::new(Some("trace-contract-study-3".to_string()), None),
)
.expect("plan study should succeed");
assert_eq!(plan.operation, "fea.plan_study");
assert_eq!(plan.op_version, "fea.plan_study/v1");
assert!(plan.data.study_fingerprint.starts_with("sha256:"));
assert!(plan.data.electromagnetic_run_options.is_none());
assert_eq!(plan.data.run_operation, "fea.run_linear_static");
assert_eq!(plan.data.run_op_version, "fea.run_linear_static/v1");
assert!(PathBuf::from(&plan.data.evidence_artifact_path).exists());
let run = analysis_run_study_op(
&spec,
OperationContext::new(Some("trace-contract-study-4".to_string()), None),
)
.expect("run study should succeed");
assert_eq!(run.operation, "fea.run_study");
assert_eq!(run.op_version, "fea.run_study/v1");
assert_eq!(run.data.study_fingerprint, plan.data.study_fingerprint);
assert!(run.data.electromagnetic_run_options.is_none());
assert_eq!(run.data.run_operation, "fea.run_linear_static");
assert_eq!(run.data.run_op_version, "fea.run_linear_static/v1");
assert_eq!(run.data.solver_convergence, QualityGate::Pass);
assert_eq!(run.data.result_quality, QualityGate::Pass);
assert_eq!(run.data.quality_reasons.len(), 0);
assert_eq!(run.data.provenance.backend, ComputeBackend::Cpu);
assert!(PathBuf::from(&run.data.evidence_artifact_path).exists());
drop(env_guard);
let _ = fs::remove_dir_all(&evidence_root);
}
#[test]
fn analysis_study_sweep_contract_executes_multiple_studies() {
let _study_artifact_env_guard = study_artifact_env_guard();
static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(1);
let evidence_root = std::env::temp_dir().join(format!(
"runmat-study-sweep-contract-artifacts-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&evidence_root);
let env_guard = EnvVarRestoreGuard {
key: "RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
previous: std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT").ok(),
};
std::env::set_var(
"RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
evidence_root.display().to_string(),
);
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-study-sweep-1".to_string()), None),
)
.expect("geometry load should succeed");
let studies = vec![
contract_study_spec(
"contract_study_sweep_linear",
"contract_study_sweep_linear_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
),
contract_study_spec(
"contract_study_sweep_em",
"contract_study_sweep_em_model",
geometry.data.clone(),
AnalysisCreateModelProfile::ElectromagneticStatic,
AnalysisRunKind::Electromagnetic,
),
];
let sweep = AnalysisStudySweepSpec {
sweep_id: "contract_study_sweep_001".to_string(),
studies,
fail_fast: true,
};
let envelope = analysis_run_study_sweep_op(
&sweep,
OperationContext::new(Some("trace-contract-study-sweep-2".to_string()), None),
)
.expect("study sweep should succeed");
assert_eq!(envelope.operation, "fea.run_study_sweep");
assert_eq!(envelope.op_version, "fea.run_study_sweep/v1");
assert_eq!(envelope.data.sweep_id, "contract_study_sweep_001");
assert_eq!(envelope.data.study_count, 2);
assert_eq!(envelope.data.success_count, 2);
assert_eq!(envelope.data.failed_count, 0);
assert!(envelope.data.failure_entries.is_empty());
assert_eq!(envelope.data.run_entries.len(), 2);
assert!(envelope.data.run_entries.iter().all(|entry| {
entry.run_id.starts_with("run_")
&& (entry.run_kind == AnalysisRunKind::LinearStatic
|| entry.run_kind == AnalysisRunKind::Electromagnetic)
}));
assert!(PathBuf::from(&envelope.data.evidence_artifact_path).exists());
drop(env_guard);
let _ = fs::remove_dir_all(&evidence_root);
}
#[test]
fn analysis_study_sweep_contract_can_continue_on_failure() {
let _study_artifact_env_guard = study_artifact_env_guard();
static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(1);
let evidence_root = std::env::temp_dir().join(format!(
"runmat-study-sweep-continue-contract-artifacts-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&evidence_root);
let env_guard = EnvVarRestoreGuard {
key: "RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
previous: std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT").ok(),
};
std::env::set_var(
"RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
evidence_root.display().to_string(),
);
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(
Some("trace-contract-study-sweep-continue-1".to_string()),
None,
),
)
.expect("geometry load should succeed");
let mut invalid_study = contract_study_spec(
"contract_study_sweep_invalid",
"contract_study_sweep_invalid_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
);
invalid_study.study_id = " ".to_string();
let sweep = AnalysisStudySweepSpec {
sweep_id: "contract_study_sweep_continue_001".to_string(),
studies: vec![
contract_study_spec(
"contract_study_sweep_ok",
"contract_study_sweep_ok_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
),
invalid_study,
],
fail_fast: false,
};
let envelope = analysis_run_study_sweep_op(
&sweep,
OperationContext::new(
Some("trace-contract-study-sweep-continue-2".to_string()),
None,
),
)
.expect("continue-on-failure sweep should succeed");
assert_eq!(envelope.operation, "fea.run_study_sweep");
assert_eq!(envelope.op_version, "fea.run_study_sweep/v1");
assert_eq!(envelope.data.study_count, 2);
assert_eq!(envelope.data.success_count, 1);
assert_eq!(envelope.data.failed_count, 1);
assert_eq!(envelope.data.run_entries.len(), 1);
assert_eq!(envelope.data.failure_entries.len(), 1);
assert_eq!(envelope.data.failure_entries[0].study_index, 1);
assert_eq!(
envelope.data.failure_entries[0].error_code,
"RM.FEA.RUN_STUDY.INVALID_SPEC"
);
assert!(PathBuf::from(&envelope.data.evidence_artifact_path).exists());
drop(env_guard);
let _ = fs::remove_dir_all(&evidence_root);
}
#[test]
fn analysis_study_sweep_validate_contract_is_v1_and_typed() {
let _study_artifact_env_guard = study_artifact_env_guard();
static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(1);
let evidence_root = std::env::temp_dir().join(format!(
"runmat-study-sweep-validate-contract-artifacts-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&evidence_root);
let env_guard = EnvVarRestoreGuard {
key: "RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
previous: std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT").ok(),
};
std::env::set_var(
"RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
evidence_root.display().to_string(),
);
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(
Some("trace-contract-study-sweep-validate-1".to_string()),
None,
),
)
.expect("geometry load should succeed");
let mut invalid_study = contract_study_spec(
"contract_study_sweep_invalid",
"contract_study_sweep_invalid_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
);
invalid_study.study_id = " ".to_string();
let sweep = AnalysisStudySweepSpec {
sweep_id: " ".to_string(),
studies: vec![
contract_study_spec(
"contract_study_sweep_validate_ok",
"contract_study_sweep_validate_ok_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
),
invalid_study,
],
fail_fast: true,
};
let envelope = analysis_validate_study_sweep_op(
&sweep,
OperationContext::new(
Some("trace-contract-study-sweep-validate-2".to_string()),
None,
),
)
.expect("sweep validation should return a typed payload");
assert_eq!(envelope.operation, "fea.validate_study_sweep");
assert_eq!(envelope.op_version, "fea.validate_study_sweep/v1");
assert!(!envelope.data.valid);
assert_eq!(
envelope.data.issue_codes,
vec!["RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string()]
);
assert_eq!(envelope.data.study_entries.len(), 2);
assert!(envelope.data.study_entries.iter().any(|entry| {
!entry.valid
&& entry
.issue_codes
.iter()
.any(|code| code == "RM.FEA.STUDY.ID_EMPTY")
}));
assert!(PathBuf::from(&envelope.data.evidence_artifact_path).exists());
drop(env_guard);
let _ = fs::remove_dir_all(&evidence_root);
}
#[test]
fn analysis_study_sweep_plan_contract_is_v1_and_typed() {
let _study_artifact_env_guard = study_artifact_env_guard();
static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(1);
let evidence_root = std::env::temp_dir().join(format!(
"runmat-study-sweep-plan-contract-artifacts-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&evidence_root);
let env_guard = EnvVarRestoreGuard {
key: "RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
previous: std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT").ok(),
};
std::env::set_var(
"RUNMAT_FEA_STUDY_ARTIFACT_ROOT",
evidence_root.display().to_string(),
);
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-study-sweep-plan-1".to_string()), None),
)
.expect("geometry load should succeed");
let sweep = AnalysisStudySweepSpec {
sweep_id: "contract_study_sweep_plan_001".to_string(),
studies: vec![
contract_study_spec(
"contract_study_sweep_plan_ok",
"contract_study_sweep_plan_ok_model",
geometry.data.clone(),
AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::LinearStatic,
),
contract_study_spec(
"contract_study_sweep_plan_em",
"contract_study_sweep_plan_em_model",
geometry.data.clone(),
AnalysisCreateModelProfile::ElectromagneticStatic,
AnalysisRunKind::Electromagnetic,
),
],
fail_fast: true,
};
let envelope = analysis_plan_study_sweep_op(
&sweep,
OperationContext::new(Some("trace-contract-study-sweep-plan-2".to_string()), None),
)
.expect("sweep planning should return typed payload");
assert_eq!(envelope.operation, "fea.plan_study_sweep");
assert_eq!(envelope.op_version, "fea.plan_study_sweep/v1");
assert_eq!(envelope.data.study_count, 2);
assert_eq!(envelope.data.planned_count, 2);
assert_eq!(envelope.data.failed_count, 0);
assert_eq!(envelope.data.plan_entries.len(), 2);
assert!(envelope.data.failure_entries.is_empty());
assert!(envelope
.data
.plan_entries
.iter()
.any(|entry| entry.run_kind == AnalysisRunKind::LinearStatic));
assert!(envelope
.data
.plan_entries
.iter()
.any(|entry| entry.run_kind == AnalysisRunKind::Electromagnetic));
assert!(PathBuf::from(&envelope.data.evidence_artifact_path).exists());
drop(env_guard);
let _ = fs::remove_dir_all(&evidence_root);
}
#[test]
fn prep_to_create_model_to_validate_flow_is_contract_stable() {
let geometry = geometry_load_op(
"/assembly.step",
SIMPLE_STEP.as_bytes(),
OperationContext::new(Some("trace-contract-prep-flow-1".to_string()), None),
)
.expect("geometry load should succeed");
let prep = geometry_prep_for_analysis_op(
&geometry.data,
GeometryPrepForAnalysisSpec {
profile: GeometryPrepProfile::AnalysisReady,
target_element_budget: 120_000,
},
OperationContext::new(Some("trace-contract-prep-flow-2".to_string()), None),
)
.expect("prep should succeed");
assert_eq!(prep.operation, "geometry.prep_for_analysis");
assert_eq!(prep.op_version, "geometry.prep_for_analysis/v1");
let created = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_prep_model".to_string(),
profile: AnalysisCreateModelProfile::LinearStaticStructural,
prep_context: Some(runmat_runtime::analysis::AnalysisCreateModelPrepContext {
source_geometry_id: prep.data.prep.provenance.source_geometry_id.clone(),
source_geometry_revision: prep.data.prep.provenance.source_geometry_revision,
region_mappings: prep.data.prep.region_mappings.clone(),
}),
},
OperationContext::new(Some("trace-contract-prep-flow-3".to_string()), None),
)
.expect("create model should succeed");
let validated = analysis_validate(
&created.data,
created.data.units,
&runmat_analysis_core::ReferenceFrame::Global,
OperationContext::new(Some("trace-contract-prep-flow-4".to_string()), None),
)
.expect("validate should succeed");
assert_eq!(validated.operation, "fea.validate");
assert_eq!(validated.op_version, "fea.validate/v1");
assert!(validated.data.valid);
}
#[test]
fn analysis_create_model_infers_materials_from_step_metadata_contract() {
let geometry = geometry_load_op(
"/assembly.step",
SIMPLE_STEP.as_bytes(),
OperationContext::new(Some("trace-contract-create-step-1".to_string()), None),
)
.expect("step geometry load should succeed");
let envelope = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_step_model".to_string(),
profile: AnalysisCreateModelProfile::LinearStaticStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-create-step-2".to_string()), None),
)
.expect("create model should succeed");
assert!(envelope
.data
.materials
.iter()
.any(|material| material.material_id == "mat_aluminum"));
assert_eq!(envelope.data.boundary_conditions[0].region_id, "region_1");
assert_eq!(envelope.data.loads[0].region_id, "region_2");
assert_eq!(envelope.data.material_assignments.len(), 2);
}
#[test]
fn analysis_validate_maps_unit_and_frame_mismatch_codes() {
let mut unit_mismatch = fixture_model(FixtureId::CantileverLinearStatic);
unit_mismatch.units = UnitSystem::Inch;
let unit_err = analysis_validate(
&unit_mismatch,
UnitSystem::Meter,
&ReferenceFrame::Global,
OperationContext::new(Some("trace-contract-2b".to_string()), None),
)
.expect_err("validate should fail for units");
assert_eq!(unit_err.error_code, "RM.FEA.VALIDATE.UNIT_MISMATCH");
assert_eq!(
unit_err.context.get("model_units").map(|s| s.as_str()),
Some("Inch")
);
assert_eq!(
unit_err.context.get("geometry_units").map(|s| s.as_str()),
Some("Meter")
);
let mut frame_mismatch = fixture_model(FixtureId::CantileverLinearStatic);
frame_mismatch.frame = ReferenceFrame::Local("fixture_frame".to_string());
let frame_err = analysis_validate(
&frame_mismatch,
UnitSystem::Meter,
&ReferenceFrame::Global,
OperationContext::new(Some("trace-contract-2c".to_string()), None),
)
.expect_err("validate should fail for frame");
assert_eq!(frame_err.error_code, "RM.FEA.VALIDATE.FRAME_MISMATCH");
assert_eq!(
frame_err.context.get("model_frame").map(|s| s.as_str()),
Some("Local(\"fixture_frame\")")
);
assert_eq!(
frame_err.context.get("geometry_frame").map(|s| s.as_str()),
Some("Global")
);
}
#[test]
fn analysis_run_contract_is_v1_and_publishable_for_fixture() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let envelope = analysis_run_linear_static_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-3".to_string()), None),
)
.expect("run should succeed");
assert_eq!(envelope.operation, "fea.run_linear_static");
assert_eq!(envelope.op_version, "fea.run_linear_static/v1");
assert_eq!(envelope.data.run_status, RunStatus::Publishable);
assert!(envelope.data.publishable);
assert!(envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_DISPLACEMENT)
.is_some());
assert!(envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_VON_MISES)
.is_some());
assert!(envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_RESIDUAL_NORM)
.is_some());
assert!(envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_EQUATION_SCALE)
.is_some());
assert!(matches!(
envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_DISPLACEMENT)
.expect("structural displacement field should be present")
.values,
AnalysisFieldValues::HostF64(_)
));
}
#[test]
fn analysis_run_modal_contract_is_v1_and_typed() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-modal-1".to_string()), None),
)
.expect("geometry load should succeed");
let modal_model = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_modal_model".to_string(),
profile: AnalysisCreateModelProfile::ModalStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-modal-2".to_string()), None),
)
.expect("modal model should be created");
let modal_envelope = analysis_run_modal_op(
&modal_model.data,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-modal-3".to_string()), None),
)
.expect("modal run should produce envelope");
assert_eq!(modal_envelope.operation, "fea.run_modal");
assert_eq!(modal_envelope.op_version, "fea.run_modal/v1");
assert_eq!(
modal_envelope.data.run.solver_method,
"matrix_free_subspace_iteration"
);
let modal_results = modal_envelope
.data
.modal_results
.as_ref()
.expect("modal results payload should exist");
assert!(!modal_results.eigenvalues_hz.is_empty());
assert_eq!(
modal_results.eigenvalues_hz.len(),
modal_results.mode_shapes.len()
);
assert_eq!(
modal_results.mode_shapes[0].field_id,
fea_modal_mode_shape_field_id(1)
);
assert_eq!(modal_results.mode_shapes[0].shape.len(), 2);
assert_eq!(modal_results.mode_shapes[0].shape[1], 3);
assert_eq!(modal_results.modal_payload_version, "modal_results/v1");
assert_eq!(
modal_results.eigenvalues_hz.len(),
modal_results.residual_norms.len()
);
assert_eq!(modal_results.mode_units, ModalFrequencyUnits::Hz);
assert_eq!(
modal_results.frequency_basis,
ModalFrequencyBasis::NativeEigenSolve
);
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_FREQUENCY_HZ)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_EIGENVALUE)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_MODAL_MASS)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_MODAL_STIFFNESS)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_PARTICIPATION_FACTOR)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_RESIDUAL_NORM)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_RELATIVE_FREQUENCY_SEPARATION)
.is_some());
assert!(modal_envelope
.data
.run
.field(FEA_FIELD_MODAL_M_ORTHOGONALITY)
.is_some());
assert_eq!(modal_envelope.data.run_status, RunStatus::Degraded);
assert!(!modal_envelope.data.publishable);
assert!(modal_envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::ModalResidualExceeded));
let invalid = analysis_run_modal_op(
&fixture_model(FixtureId::CantileverLinearStatic),
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-modal-4".to_string()), None),
)
.expect_err("modal run should reject models without modal step");
assert_eq!(invalid.operation, "fea.run_modal");
assert_eq!(invalid.op_version, "fea.run_modal/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_MODAL.INVALID_MODEL");
}
#[test]
fn analysis_run_modal_with_options_contract_controls_mode_budget() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-modal-opts-1".to_string()), None),
)
.expect("geometry load should succeed");
let modal_model = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_modal_model_opts".to_string(),
profile: AnalysisCreateModelProfile::ModalStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-modal-opts-2".to_string()), None),
)
.expect("modal model should be created");
let envelope = analysis_run_modal_with_options_op(
&modal_model.data,
ComputeBackend::Cpu,
AnalysisModalRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
quality_policy: QualityPolicy::Balanced,
mode_count: 2,
residual_warn_threshold: 1.0e-2,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-modal-opts-3".to_string()), None),
)
.expect("modal run with options should succeed");
assert_eq!(envelope.operation, "fea.run_modal");
assert_eq!(envelope.op_version, "fea.run_modal/v1");
let modal = envelope
.data
.modal_results
.as_ref()
.expect("modal payload should exist");
assert!(!modal.eigenvalues_hz.is_empty());
assert!(modal.eigenvalues_hz.len() <= 2);
assert!(envelope.data.provenance.deterministic_mode);
let invalid = analysis_run_modal_with_options_op(
&modal_model.data,
ComputeBackend::Cpu,
AnalysisModalRunOptions {
deterministic_mode: false,
precision_mode: PrecisionMode::Fp64,
quality_policy: QualityPolicy::Balanced,
mode_count: 0,
residual_warn_threshold: 1.0e-3,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-modal-opts-4".to_string()), None),
)
.expect_err("mode_count=0 should fail");
assert_eq!(invalid.error_code, "RM.FEA.RUN_MODAL.INVALID_OPTIONS");
assert_eq!(invalid.operation, "fea.run_modal");
assert_eq!(invalid.op_version, "fea.run_modal/v1");
}
#[test]
fn analysis_run_acoustic_contract_is_v1_and_typed() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-acoustic-1".to_string()), None),
)
.expect("geometry load should succeed");
let acoustic_model = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_acoustic_model".to_string(),
profile: AnalysisCreateModelProfile::AcousticHarmonic,
prep_context: None,
},
OperationContext::new(Some("trace-contract-acoustic-2".to_string()), None),
)
.expect("acoustic model should be created");
let envelope = analysis_run_acoustic_op(
&acoustic_model.data,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-acoustic-3".to_string()), None),
)
.expect("acoustic run should produce envelope");
assert_eq!(envelope.operation, "fea.run_acoustic");
assert_eq!(envelope.op_version, "fea.run_acoustic/v1");
assert!(envelope.data.modal_results.is_none());
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_ACOUSTIC_HARMONIC_RESPONSE"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_ACOUSTIC_DOMAIN_ASSEMBLY"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL"));
assert!(envelope
.data
.run
.field(FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE)
.is_some());
assert!(envelope
.data
.run
.field(FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY)
.is_some());
let invalid = analysis_run_acoustic_op(
&fixture_model(FixtureId::CantileverLinearStatic),
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-acoustic-4".to_string()), None),
)
.expect_err("acoustic run should reject models without acoustic harmonic step marker");
assert_eq!(invalid.operation, "fea.run_acoustic");
assert_eq!(invalid.op_version, "fea.run_acoustic/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_ACOUSTIC.INVALID_MODEL");
}
#[test]
fn analysis_run_cfd_contract_shapes_cell_and_boundary_fields() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "cfd_steady".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Cfd,
}];
model.cfd = Some(runmat_analysis_core::CfdDomain {
enabled: true,
solve_family: runmat_analysis_core::CfdSolveFamily::SteadyState,
reference_density_kg_per_m3: 1.225,
dynamic_viscosity_pa_s: 1.81e-5,
inlet_velocity_m_per_s: 4.25,
turbulence_intensity: 0.06,
time_profile: Vec::new(),
});
model.boundary_conditions = vec![
BoundaryCondition {
bc_id: "bc_cfd_inlet".to_string(),
region_id: "fluid_inlet".to_string(),
kind: BoundaryConditionKind::CfdInletVelocity {
velocity_m_per_s: 4.25,
},
},
BoundaryCondition {
bc_id: "bc_cfd_outlet".to_string(),
region_id: "fluid_outlet".to_string(),
kind: BoundaryConditionKind::CfdOutletPressure { pressure_pa: 0.0 },
},
BoundaryCondition {
bc_id: "bc_cfd_wall".to_string(),
region_id: "fluid_wall".to_string(),
kind: BoundaryConditionKind::CfdSlipWall,
},
];
let envelope = analysis_run_cfd_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-cfd-1".to_string()), None),
)
.expect("cfd run should return envelope");
assert_eq!(envelope.operation, "fea.run_cfd");
assert_eq!(envelope.op_version, "fea.run_cfd/v1");
assert_eq!(
envelope.data.run.solver_method,
"cfd_velocity_pressure_finite_volume"
);
let velocity = envelope
.data
.run
.field(FEA_FIELD_CFD_VELOCITY)
.expect("cfd velocity field should be present");
let pressure = envelope
.data
.run
.field(FEA_FIELD_CFD_PRESSURE)
.expect("cfd pressure field should be present");
let vorticity = envelope
.data
.run
.field(FEA_FIELD_CFD_VORTICITY)
.expect("cfd vorticity field should be present");
let wall_shear = envelope
.data
.run
.field(FEA_FIELD_CFD_WALL_SHEAR_STRESS)
.expect("cfd wall shear field should be present");
assert_eq!(velocity.shape.len(), 2);
assert_eq!(velocity.shape[1], 3);
assert_eq!(pressure.shape, vec![velocity.shape[0]]);
assert_eq!(vorticity.shape, velocity.shape);
assert_eq!(wall_shear.shape, vec![1, 3]);
assert_ne!(wall_shear.shape[0], velocity.shape[0]);
}
#[test]
fn analysis_run_cht_contract_shapes_coupled_interface_fields() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-cht-1".to_string()), None),
)
.expect("geometry load should succeed");
let model = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_cht_model".to_string(),
profile: AnalysisCreateModelProfile::ChtCoupled,
prep_context: None,
},
OperationContext::new(Some("trace-contract-cht-2".to_string()), None),
)
.expect("cht model should be created");
let envelope = analysis_run_cht_op(
&model.data,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-cht-3".to_string()), None),
)
.expect("cht run should return envelope");
assert_eq!(envelope.operation, "fea.run_cht");
assert_eq!(envelope.op_version, "fea.run_cht/v1");
assert_eq!(envelope.data.run.solver_method, "cht_conjugate_projection");
assert!(envelope.data.transient_results.is_none());
let fluid_velocity = envelope
.data
.run
.field(FEA_FIELD_CHT_FLUID_VELOCITY)
.expect("cht fluid velocity should be present");
let fluid_pressure = envelope
.data
.run
.field(FEA_FIELD_CHT_FLUID_PRESSURE)
.expect("cht fluid pressure should be present");
let fluid_temperature = envelope
.data
.run
.field(&fea_cht_fluid_temperature_field_id(0))
.expect("cht fluid temperature should be present");
let solid_temperature = envelope
.data
.run
.field(&fea_cht_solid_temperature_field_id(0))
.expect("cht solid temperature should be present");
let interface_heat_flux = envelope
.data
.run
.field(&fea_cht_interface_heat_flux_field_id(0))
.expect("cht interface heat flux should be present");
let interface_temperature_jump = envelope
.data
.run
.field(&fea_cht_interface_temperature_jump_field_id(0))
.expect("cht interface temperature jump should be present");
let thermal = envelope
.data
.thermal_results
.as_ref()
.expect("cht should retain the thermal payload");
let thermal_flux_face_count = thermal
.heat_flux_snapshots
.first()
.and_then(|field| field.shape.first().copied())
.expect("thermal heat-flux snapshot should carry a recovery domain");
assert_eq!(fluid_velocity.shape.len(), 2);
assert_eq!(fluid_velocity.shape[1], 3);
assert_eq!(fluid_pressure.shape, vec![fluid_velocity.shape[0]]);
assert_eq!(solid_temperature.shape, fluid_temperature.shape);
assert_eq!(interface_heat_flux.shape.len(), 1);
assert!(interface_heat_flux.shape[0] >= thermal_flux_face_count);
assert_eq!(interface_temperature_jump.shape, interface_heat_flux.shape);
}
#[test]
fn analysis_run_transient_contract_is_v1_and_typed() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "transient_1".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Transient,
}];
let envelope = analysis_run_transient_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-transient-1".to_string()), None),
)
.expect("transient run should return envelope");
assert_eq!(envelope.operation, "fea.run_transient");
assert_eq!(envelope.op_version, "fea.run_transient/v1");
assert_eq!(envelope.data.run.solver_method, "implicit_euler_pcg");
assert_eq!(envelope.data.run_status, RunStatus::Publishable);
assert!(envelope.data.publishable);
let transient = envelope
.data
.transient_results
.as_ref()
.expect("transient payload should exist");
assert_eq!(
transient.integration_method,
runmat_runtime::analysis::TransientIntegrationMethod::ImplicitEuler
);
assert_eq!(
transient.time_points_s.len(),
transient.displacement_snapshots.len()
);
assert_eq!(
transient.time_points_s.len(),
transient.residual_norm_snapshots.len()
);
assert_eq!(
transient.residual_norm_snapshots[1].field_id,
fea_transient_residual_norm_field_id(1)
);
let invalid = analysis_run_transient_op(
&fixture_model(FixtureId::CantileverLinearStatic),
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-transient-2".to_string()), None),
)
.expect_err("transient run should reject models without transient step");
assert_eq!(invalid.operation, "fea.run_transient");
assert_eq!(invalid.op_version, "fea.run_transient/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_TRANSIENT.INVALID_MODEL");
}
#[test]
fn analysis_run_fsi_contract_is_v1_and_typed() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![
runmat_analysis_core::AnalysisStep {
step_id: "fsi_structure".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Transient,
},
runmat_analysis_core::AnalysisStep {
step_id: "fsi_flow".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Cfd,
},
];
model.cfd = Some(runmat_analysis_core::CfdDomain {
enabled: true,
solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
reference_density_kg_per_m3: 1.225,
dynamic_viscosity_pa_s: 1.81e-5,
inlet_velocity_m_per_s: 4.0,
turbulence_intensity: 0.06,
time_profile: vec![
runmat_analysis_core::CfdTimeProfilePoint {
normalized_time: 0.0,
inlet_scale: 0.6,
},
runmat_analysis_core::CfdTimeProfilePoint {
normalized_time: 1.0,
inlet_scale: 1.0,
},
],
});
let envelope = analysis_run_fsi_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-fsi-1".to_string()), None),
)
.expect("fsi run should return envelope");
assert_eq!(envelope.operation, "fea.run_fsi");
assert_eq!(envelope.op_version, "fea.run_fsi/v1");
assert_eq!(
envelope.data.run.solver_method,
"fsi_partitioned_projection"
);
assert!(envelope.data.transient_results.is_none());
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_CFD_FLOW"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_CFD_RESIDUAL"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_FSI_INTERFACE_RESIDUAL"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_FSI_COUPLING"));
let interface_pressure = envelope
.data
.run
.field(&fea_fsi_interface_pressure_field_id(0))
.expect("fsi interface pressure field should be present");
let interface_traction = envelope
.data
.run
.field(&fea_fsi_interface_traction_field_id(0))
.expect("fsi interface traction field should be present");
let interface_displacement = envelope
.data
.run
.field(&fea_fsi_interface_displacement_field_id(0))
.expect("fsi interface displacement field should be present");
assert_eq!(interface_pressure.shape.len(), 1);
assert_eq!(
interface_traction.shape,
vec![interface_pressure.shape[0], 3]
);
assert_eq!(interface_displacement.shape.len(), 2);
assert_eq!(interface_displacement.shape[1], 3);
assert_eq!(
interface_displacement.shape[0],
interface_pressure.shape[0] + 1
);
let mut invalid_model = model.clone();
invalid_model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "fsi_flow_only".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Cfd,
}];
let invalid = analysis_run_fsi_op(
&invalid_model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-fsi-2".to_string()), None),
)
.expect_err("fsi run should reject missing transient step");
assert_eq!(invalid.operation, "fea.run_fsi");
assert_eq!(invalid.op_version, "fea.run_fsi/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_FSI.INVALID_MODEL");
}
#[test]
fn analysis_run_transient_thermo_field_reference_errors_are_typed() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "transient_1".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Transient,
}];
model.thermo_mechanical = Some(runmat_analysis_core::ThermoMechanicalDomain {
enabled: true,
reference_temperature_k: 293.15,
applied_temperature_delta_k: 70.0,
field_artifact_id: Some("missing_thermo_field".to_string()),
field_source: None,
region_temperature_deltas: Vec::new(),
time_profile: Vec::new(),
});
let invalid = analysis_run_transient_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisTransientRunOptions::default(),
OperationContext::new(
Some("trace-contract-transient-thermo-field-1".to_string()),
None,
),
)
.expect_err("missing thermo field artifact should fail");
assert_eq!(invalid.operation, "fea.run_transient");
assert_eq!(invalid.op_version, "fea.run_transient/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_THERMO_FIELD.NOT_FOUND");
}
#[test]
fn analysis_run_nonlinear_contract_is_v1_and_typed() {
let model = fixture_model(FixtureId::NonlinearAssembly);
let envelope = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-nonlinear-1".to_string()), None),
)
.expect("nonlinear run should succeed for nonlinear fixture");
assert_eq!(envelope.operation, "fea.run_nonlinear");
assert_eq!(envelope.op_version, "fea.run_nonlinear/v1");
assert!(envelope.data.nonlinear_results.is_some());
let nonlinear = envelope
.data
.nonlinear_results
.as_ref()
.expect("nonlinear payload should be present");
assert_eq!(nonlinear.load_factors.len(), nonlinear.residual_norms.len());
assert_eq!(
nonlinear.residual_norms.len(),
nonlinear.increment_norms.len()
);
assert_eq!(
nonlinear.increment_norms.len(),
nonlinear.iteration_counts.len()
);
assert_eq!(
nonlinear.load_factors.len(),
nonlinear.load_factor_snapshots.len()
);
assert_eq!(
nonlinear.residual_norms.len(),
nonlinear.residual_norm_snapshots.len()
);
assert_eq!(
nonlinear.load_factor_snapshots[0].field_id,
fea_nonlinear_load_factor_field_id(0)
);
assert_eq!(
nonlinear.residual_norm_snapshots[0].field_id,
fea_nonlinear_residual_norm_field_id(0)
);
assert!(nonlinear.max_line_search_backtracks_per_increment > 0);
assert!(nonlinear.iteration_spike_count <= nonlinear.load_factors.len());
assert!(nonlinear.backtrack_burst_count > 0);
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_NONLINEAR_CONVERGENCE"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_NONLINEAR_COST"));
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-nonlinear-results-1".to_string()), None),
)
.expect("fea.results should succeed for nonlinear run");
assert!(results.data.summary.increment_count > 0);
assert!(results.data.summary.failed_increment_count.is_some());
assert!(results.data.summary.max_nonlinear_increment_norm.is_some());
assert!(results.data.summary.max_nonlinear_iteration_count.is_some());
assert!(results
.data
.summary
.nonlinear_line_search_backtracks
.is_some());
assert!(results
.data
.summary
.nonlinear_max_backtracks_per_increment
.is_some());
assert!(results
.data
.summary
.nonlinear_tangent_rebuild_count
.is_some());
assert!(results
.data
.summary
.nonlinear_iteration_spike_count
.is_some());
assert!(results
.data
.summary
.nonlinear_convergence_stall_count
.is_some());
assert!(results
.data
.summary
.nonlinear_backtrack_burst_count
.is_some());
let invalid = analysis_run_nonlinear_op(
&fixture_model(FixtureId::CantileverLinearStatic),
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-nonlinear-2".to_string()), None),
)
.expect_err("nonlinear run should reject models without nonlinear step");
assert_eq!(invalid.operation, "fea.run_nonlinear");
assert_eq!(invalid.op_version, "fea.run_nonlinear/v1");
assert_eq!(invalid.error_code, "RM.FEA.RUN_NONLINEAR.INVALID_MODEL");
}
#[test]
fn analysis_run_electromagnetic_contract_is_v1_typed_payload() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps[0].kind = runmat_analysis_core::AnalysisStepKind::Electromagnetic;
model.materials[0].electrical = Some(MaterialElectricalModel::default());
model.boundary_conditions = vec![BoundaryCondition {
bc_id: "bc_em_snapshot_ground".to_string(),
region_id: model.boundary_conditions[0].region_id.clone(),
kind: BoundaryConditionKind::VectorPotentialGround,
}];
model.loads = vec![LoadCase {
load_id: "load_em_snapshot_coil".to_string(),
region_id: model.loads[0].region_id.clone(),
kind: LoadKind::CoilCurrent {
current_a: 120.0,
phase_rad: 0.0,
amplitude_scale: 1.0,
},
}];
model.electromagnetic = Some(ElectromagneticDomain {
enabled: true,
reference_frequency_hz: 60.0,
applied_current_a: 120.0,
});
model.materials[0].electrical = Some(MaterialElectricalModel::default());
model.boundary_conditions = vec![
BoundaryCondition {
bc_id: "em_contract_ground".to_string(),
region_id: "em_contract_region".to_string(),
kind: BoundaryConditionKind::VectorPotentialGround,
},
BoundaryCondition {
bc_id: "em_contract_insulation".to_string(),
region_id: "em_contract_region".to_string(),
kind: BoundaryConditionKind::MagneticInsulation,
},
];
model.loads = vec![LoadCase {
load_id: "em_contract_source".to_string(),
region_id: "em_contract_region".to_string(),
kind: LoadKind::CoilCurrent {
current_a: 120.0,
phase_rad: 0.0,
amplitude_scale: 1.0,
},
}];
let envelope = analysis_run_electromagnetic_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-run-em-1".to_string()), None),
)
.expect("electromagnetic run should return typed payload");
assert_eq!(envelope.operation, "fea.run_electromagnetic");
assert_eq!(envelope.op_version, "fea.run_electromagnetic/v1");
assert_ne!(envelope.data.run_status, RunStatus::Rejected);
assert!(envelope.data.electromagnetic_results.is_some());
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_EM_STATIC"));
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "FEA_EM_SWEEP"));
let em_payload = envelope
.data
.electromagnetic_results
.as_ref()
.expect("electromagnetic payload expected");
assert_eq!(em_payload.sweep_frequency_hz.len(), 1);
assert_eq!(em_payload.sweep_peak_flux_density.len(), 1);
assert_eq!(em_payload.sweep_solve_quality.len(), 1);
assert!(em_payload.resonance_peak_frequency_hz.is_some());
assert!(em_payload.resonance_quality_factor.is_none());
assert_eq!(
em_payload.vector_potential_real.field_id,
FEA_FIELD_EM_VECTOR_POTENTIAL_REAL
);
assert_eq!(
em_payload.vector_potential_imag.field_id,
FEA_FIELD_EM_VECTOR_POTENTIAL_IMAG
);
assert_eq!(em_payload.vector_potential_real.shape.len(), 2);
assert_eq!(em_payload.vector_potential_real.shape[1], 3);
assert_eq!(
em_payload.vector_potential_imag.shape,
em_payload.vector_potential_real.shape
);
match &em_payload.vector_potential_real.values {
AnalysisFieldValues::HostF64(values) => {
assert_eq!(
values.len(),
em_payload
.vector_potential_real
.shape
.iter()
.product::<usize>()
);
}
AnalysisFieldValues::DeviceRef(_) => {
panic!("CPU EM vector potential should be returned as host values")
}
}
}
#[test]
fn analysis_results_can_filter_nonlinear_diagnostics_by_code() {
let model = fixture_model(FixtureId::NonlinearLoadPathMix);
let envelope = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(
Some("trace-contract-nonlinear-diagnostics-1".to_string()),
None,
),
)
.expect("nonlinear run should succeed");
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery {
include_fields: Vec::new(),
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: vec![
"FEA_NONLINEAR_CONVERGENCE".to_string(),
"FEA_NONLINEAR_COST".to_string(),
],
include_modal_results: true,
mode_indices: Vec::new(),
include_transient_results: true,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(
Some("trace-contract-nonlinear-diagnostics-2".to_string()),
None,
),
)
.expect("results query should succeed");
let diagnostics = results
.data
.diagnostics
.as_ref()
.expect("diagnostics should be included");
assert!(!diagnostics.is_empty());
assert!(diagnostics.iter().all(|diag| {
diag.code == "FEA_NONLINEAR_CONVERGENCE" || diag.code == "FEA_NONLINEAR_COST"
}));
}
#[test]
fn electromagnetic_contract_snapshot_matches_expected_shape() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps[0].kind = runmat_analysis_core::AnalysisStepKind::Electromagnetic;
model.materials[0].electrical = Some(MaterialElectricalModel::default());
model.boundary_conditions = vec![BoundaryCondition {
bc_id: "bc_em_snapshot_ground".to_string(),
region_id: model.boundary_conditions[0].region_id.clone(),
kind: BoundaryConditionKind::VectorPotentialGround,
}];
model.loads = vec![LoadCase {
load_id: "load_em_snapshot_coil".to_string(),
region_id: model.loads[0].region_id.clone(),
kind: LoadKind::CoilCurrent {
current_a: 120.0,
phase_rad: 0.0,
amplitude_scale: 1.0,
},
}];
model.electromagnetic = Some(ElectromagneticDomain {
enabled: true,
reference_frequency_hz: 60.0,
applied_current_a: 120.0,
});
let envelope = analysis_run_electromagnetic_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-em-snapshot-1".to_string()), None),
)
.expect("electromagnetic run should succeed");
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-em-snapshot-2".to_string()), None),
)
.expect("electromagnetic results should succeed");
let snapshot_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/electromagnetic_contract_snapshot.json");
let expected: Value = serde_json::from_str(
&fs::read_to_string(&snapshot_path).expect("read electromagnetic contract snapshot"),
)
.expect("parse electromagnetic contract snapshot");
let electromagnetic = serde_json::to_value(
results
.data
.electromagnetic_results
.as_ref()
.expect("electromagnetic payload expected"),
)
.expect("serialize electromagnetic payload");
let summary = serde_json::to_value(&results.data.summary).expect("serialize summary");
let expected_em_keys = sorted_object_keys(
expected
.get("electromagnetic_results")
.expect("snapshot electromagnetic_results"),
);
let expected_summary_keys =
sorted_object_keys(expected.get("summary").expect("snapshot summary"));
assert_eq!(sorted_object_keys(&electromagnetic), expected_em_keys);
assert_eq!(sorted_object_keys(&summary), expected_summary_keys);
}
#[test]
fn nonlinear_contract_snapshot_matches_expected_shape() {
let model = fixture_model(FixtureId::NonlinearLoadPathMix);
let envelope = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(
Some("trace-contract-nonlinear-snapshot-1".to_string()),
None,
),
)
.expect("nonlinear run should succeed");
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery::default(),
OperationContext::new(
Some("trace-contract-nonlinear-snapshot-2".to_string()),
None,
),
)
.expect("results should succeed");
let snapshot_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/nonlinear_contract_snapshot.json");
let expected: Value = serde_json::from_str(
&fs::read_to_string(&snapshot_path).expect("read nonlinear contract snapshot"),
)
.expect("parse nonlinear contract snapshot");
let nonlinear = serde_json::to_value(
results
.data
.nonlinear_results
.as_ref()
.expect("nonlinear payload expected"),
)
.expect("serialize nonlinear payload");
let summary = serde_json::to_value(&results.data.summary).expect("serialize summary");
let expected_nonlinear_keys = sorted_object_keys(
expected
.get("nonlinear_results")
.expect("snapshot nonlinear_results"),
);
let expected_summary_keys =
sorted_object_keys(expected.get("summary").expect("snapshot summary"));
assert_eq!(sorted_object_keys(&nonlinear), expected_nonlinear_keys);
assert_eq!(sorted_object_keys(&summary), expected_summary_keys);
}
#[test]
fn analysis_run_nonlinear_strict_iteration_cap_sets_degraded_status() {
let model = fixture_model(FixtureId::NonlinearAssembly);
let envelope = analysis_run_nonlinear_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisNonlinearRunOptions {
quality_policy: QualityPolicy::Strict,
max_newton_iters: 1,
line_search: false,
..AnalysisNonlinearRunOptions::balanced()
},
OperationContext::new(
Some("trace-contract-nonlinear-strict-cap".to_string()),
None,
),
)
.expect("nonlinear strict run should return envelope");
assert_eq!(envelope.operation, "fea.run_nonlinear");
assert_eq!(envelope.op_version, "fea.run_nonlinear/v1");
assert_eq!(envelope.data.run_status, RunStatus::Degraded);
assert!(!envelope.data.publishable);
assert!(envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::NonlinearIncrementFailure));
}
#[test]
fn analysis_run_nonlinear_policy_contract_divergence_is_explicit() {
let model = fixture_model(FixtureId::NonlinearAssembly);
let run_with_policy = |policy| {
analysis_run_nonlinear_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisNonlinearRunOptions {
quality_policy: policy,
max_newton_iters: 1,
line_search: false,
max_line_search_backtracks: 0,
..AnalysisNonlinearRunOptions::balanced()
},
OperationContext::new(
Some(format!("trace-contract-nonlinear-policy-{policy:?}")),
None,
),
)
.expect("nonlinear run should produce typed envelope")
};
let exploratory = run_with_policy(QualityPolicy::Exploratory);
let balanced = run_with_policy(QualityPolicy::Balanced);
let strict = run_with_policy(QualityPolicy::Strict);
assert!(exploratory.data.publishable);
assert_eq!(exploratory.data.run_status, RunStatus::Publishable);
for degraded in [balanced, strict] {
assert!(!degraded.data.publishable);
assert_eq!(degraded.data.run_status, RunStatus::Degraded);
assert!(degraded
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::NonlinearIncrementFailure));
}
}
#[test]
fn analysis_run_nonlinear_policy_diverges_on_harder_fixture_profile() {
let model = fixture_model(FixtureId::NonlinearSofteningBenchmark);
let run_with_policy = |policy| {
analysis_run_nonlinear_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisNonlinearRunOptions {
quality_policy: policy,
max_newton_iters: 3,
..AnalysisNonlinearRunOptions::production_recommended()
},
OperationContext::new(
Some(format!("trace-contract-nonlinear-hard-policy-{policy:?}")),
None,
),
)
.expect("hard nonlinear fixture should produce typed envelope")
};
let exploratory = run_with_policy(QualityPolicy::Exploratory);
let balanced = run_with_policy(QualityPolicy::Balanced);
let strict = run_with_policy(QualityPolicy::Strict);
assert!(exploratory.data.publishable);
assert_eq!(exploratory.data.run_status, RunStatus::Publishable);
assert!(!balanced.data.publishable);
assert_eq!(balanced.data.run_status, RunStatus::Degraded);
assert!(!strict.data.publishable);
assert_eq!(strict.data.run_status, RunStatus::Degraded);
}
#[test]
fn analysis_run_nonlinear_prep_reference_errors_are_typed() {
let model = fixture_model(FixtureId::NonlinearAssembly);
let missing = analysis_run_nonlinear_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisNonlinearRunOptions {
prep_artifact_id: Some("prep:missing".to_string()),
..AnalysisNonlinearRunOptions::production_recommended()
},
OperationContext::new(Some("trace-contract-prep-ref-1".to_string()), None),
)
.expect_err("missing prep artifact should fail");
assert_eq!(missing.operation, "fea.run_nonlinear");
assert_eq!(missing.op_version, "fea.run_nonlinear/v1");
assert_eq!(missing.error_code, "RM.FEA.RUN_PREP.NOT_FOUND");
}
#[test]
fn analysis_run_nonlinear_stale_prep_reference_is_typed() {
runmat_runtime::geometry::reset_prep_artifact_store_for_tests();
std::env::set_var("RUNMAT_GEOMETRY_PREP_REQUIRE_LATEST_REVISION", "true");
let mut geometry_v1 = geometry_load_op(
"/assembly.step",
SIMPLE_STEP.as_bytes(),
OperationContext::new(Some("trace-contract-prep-stale-1".to_string()), None),
)
.expect("geometry load should succeed")
.data;
geometry_v1.revision = 1;
let mut geometry_v2 = geometry_v1.clone();
geometry_v2.revision = 2;
let prep_v1 = geometry_prep_for_analysis_op(
&geometry_v1,
GeometryPrepForAnalysisSpec::default(),
OperationContext::new(Some("trace-contract-prep-stale-2".to_string()), None),
)
.expect("prep v1 should succeed");
let _prep_v2 = geometry_prep_for_analysis_op(
&geometry_v2,
GeometryPrepForAnalysisSpec::default(),
OperationContext::new(Some("trace-contract-prep-stale-3".to_string()), None),
)
.expect("prep v2 should succeed");
let model = analysis_create_model_op(
&geometry_v1,
AnalysisCreateModelIntentSpec {
model_id: "contract_stale_prep_model".to_string(),
profile: AnalysisCreateModelProfile::NonlinearStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-prep-stale-4".to_string()), None),
)
.expect("create model should succeed")
.data;
let stale = analysis_run_nonlinear_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisNonlinearRunOptions {
prep_artifact_id: Some(prep_v1.data.prep_artifact_id),
..AnalysisNonlinearRunOptions::production_recommended()
},
OperationContext::new(Some("trace-contract-prep-stale-5".to_string()), None),
)
.expect_err("stale prep artifact should fail");
assert_eq!(stale.operation, "fea.run_nonlinear");
assert_eq!(stale.op_version, "fea.run_nonlinear/v1");
assert_eq!(stale.error_code, "RM.FEA.RUN_PREP.STALE");
std::env::remove_var("RUNMAT_GEOMETRY_PREP_REQUIRE_LATEST_REVISION");
runmat_runtime::geometry::reset_prep_artifact_store_for_tests();
}
#[test]
fn analysis_results_compare_contract_is_v1_and_handles_missing_run_ids() {
let model = fixture_model(FixtureId::NonlinearAssembly);
let baseline = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-compare-1".to_string()), None),
)
.expect("baseline nonlinear run should succeed");
let candidate = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-compare-2".to_string()), None),
)
.expect("candidate nonlinear run should succeed");
let compare = analysis_results_compare_op(
AnalysisResultsCompareQuery {
baseline_run_id: baseline.data.run_id.clone(),
candidate_run_id: candidate.data.run_id.clone(),
},
OperationContext::new(Some("trace-contract-compare-3".to_string()), None),
)
.expect("compare should succeed");
assert_eq!(compare.operation, "fea.results_compare");
assert_eq!(compare.op_version, "fea.results_compare/v1");
assert!(compare.data.solve_ms_delta.is_some());
let missing = analysis_results_compare_op(
AnalysisResultsCompareQuery {
baseline_run_id: "missing-run-id".to_string(),
candidate_run_id: candidate.data.run_id,
},
OperationContext::new(Some("trace-contract-compare-4".to_string()), None),
)
.expect_err("missing baseline run should fail");
assert_eq!(missing.operation, "fea.results_compare");
assert_eq!(missing.op_version, "fea.results_compare/v1");
assert_eq!(missing.error_code, "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND");
}
#[test]
fn analysis_trends_contract_is_v1_and_typed() {
let model = fixture_model(FixtureId::NonlinearAssembly);
for idx in 0..3 {
let _ = analysis_run_nonlinear_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some(format!("trace-contract-trends-{idx}")), None),
)
.expect("nonlinear run should succeed for trends");
}
let trends = analysis_trends_op(
AnalysisTrendsQuery { window_size: 2 },
OperationContext::new(Some("trace-contract-trends-op".to_string()), None),
)
.expect("trends operation should succeed");
assert_eq!(trends.operation, "fea.trends");
assert_eq!(trends.op_version, "fea.trends/v1");
assert_eq!(trends.data.window_size, 2);
let nonlinear = trends
.data
.summaries
.iter()
.find(|summary| summary.run_kind == AnalysisRunKind::Nonlinear)
.expect("nonlinear trend summary should be present");
assert_eq!(nonlinear.sample_count, 2);
assert!(nonlinear.median_solve_ms.is_some());
assert!(nonlinear.p95_solve_ms.is_some());
assert!(nonlinear.thermo_coupling_enabled_rate.is_none());
assert!(nonlinear.thermo_transient_warn_rate.is_none());
assert!(nonlinear.thermo_nonlinear_warn_rate.is_none());
assert!(nonlinear.thermo_spread_breach_rate.is_none());
assert!(nonlinear.thermo_heterogeneity_breach_rate.is_none());
}
#[test]
fn analysis_run_transient_with_options_contract_controls_execution_window() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "transient_1".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Transient,
}];
let envelope = analysis_run_transient_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisTransientRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
quality_policy: QualityPolicy::Balanced,
time_step_s: 1.5e-3,
min_time_step_s: 1.5e-3,
max_time_step_s: 1.5e-3,
step_count: 4,
max_linear_iters: 64,
tolerance: 1.0e-8,
residual_target: 1.0e-6,
adaptive_time_step: false,
max_step_retries: 0,
adapt_min_scale: 0.8,
adapt_max_scale: 1.25,
adapt_growth_exponent: 0.35,
adapt_retry_growth_cap: 1.05,
adapt_nonconverged_shrink: 0.75,
dt_bucket_rel_tolerance: 0.0,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-transient-opts-1".to_string()), None),
)
.expect("transient run with options should succeed");
assert_eq!(envelope.operation, "fea.run_transient");
assert_eq!(envelope.op_version, "fea.run_transient/v1");
let transient = envelope
.data
.transient_results
.as_ref()
.expect("transient payload should exist");
assert_eq!(transient.time_points_s.len(), 5);
assert!((transient.time_points_s[4] - 6.0e-3).abs() < 1.0e-12);
assert!(envelope.data.provenance.deterministic_mode);
}
#[test]
fn analysis_modal_large_fixture_quality_signals_propagate_to_results() {
let model = fixture_model(FixtureId::ModalLarge);
let envelope = analysis_run_modal_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisModalRunOptions {
mode_count: 8,
..AnalysisModalRunOptions::balanced()
},
OperationContext::new(Some("trace-contract-modal-large-1".to_string()), None),
)
.expect("modal large fixture run should succeed");
let has_orthogonality_warning = envelope.data.run.diagnostics.iter().any(|diag| {
diag.code == "FEA_MODAL_ORTHOGONALITY"
&& diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
});
let has_separation_warning = envelope.data.run.diagnostics.iter().any(|diag| {
diag.code == "FEA_MODAL_SEPARATION"
&& diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
});
assert_eq!(
envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::ModalOrthogonalityExceeded),
has_orthogonality_warning
);
assert_eq!(
envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::ModalSeparationLow),
has_separation_warning
);
if has_orthogonality_warning || has_separation_warning {
assert!(!envelope.data.publishable);
assert_eq!(envelope.data.run_status, RunStatus::Degraded);
}
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-modal-large-2".to_string()), None),
)
.expect("modal large fixture results should succeed");
assert!(results.data.summary.mode_count >= 2);
assert_eq!(
results.data.summary.mode_count,
results.data.summary.available_mode_indices.len()
);
assert!(results.data.summary.max_modal_residual_norm.is_some());
assert!(results.data.summary.first_mode_converged.is_some());
assert!(results
.data
.diagnostics
.as_ref()
.expect("diagnostics should be included")
.iter()
.any(|diag| diag.code == "FEA_MODAL_ORTHOGONALITY"));
assert!(results
.data
.diagnostics
.as_ref()
.expect("diagnostics should be included")
.iter()
.any(|diag| diag.code == "FEA_MODAL_SEPARATION"));
}
#[test]
fn analysis_transient_long_fixture_quality_signals_propagate_to_results() {
let model = fixture_model(FixtureId::TransientLong);
let envelope = analysis_run_transient_with_options_op(
&model,
ComputeBackend::Cpu,
AnalysisTransientRunOptions {
step_count: 24,
..AnalysisTransientRunOptions::balanced()
},
OperationContext::new(Some("trace-contract-transient-long-1".to_string()), None),
)
.expect("transient long fixture run should succeed");
let has_stability_warning = envelope.data.run.diagnostics.iter().any(|diag| {
(diag.code == "FEA_TRANSIENT_STABILITY" || diag.code == "FEA_TRANSIENT_ENERGY")
&& diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
});
let has_step_failure_warning = envelope.data.run.diagnostics.iter().any(|diag| {
diag.code == "FEA_TRANSIENT_STEP_FAILURE"
&& diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
});
assert_eq!(
envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::TransientStabilityExceeded),
has_stability_warning
);
assert_eq!(
envelope
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::TransientStepFailure),
has_step_failure_warning
);
if has_stability_warning || has_step_failure_warning {
assert!(!envelope.data.publishable);
assert_eq!(envelope.data.run_status, RunStatus::Degraded);
}
let results = analysis_results_op(
&envelope.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-transient-long-2".to_string()), None),
)
.expect("transient long fixture results should succeed");
assert!(results.data.summary.snapshot_count > 8);
assert_eq!(results.data.summary.time_start_s, Some(0.0));
assert!(results.data.summary.time_end_s.unwrap_or(0.0) > 0.0);
assert!(results.data.summary.max_transient_residual_norm.is_some());
assert!(results.data.summary.final_step_converged.is_some());
assert!(results
.data
.diagnostics
.as_ref()
.expect("diagnostics should be included")
.iter()
.any(|diag| diag.code == "FEA_TRANSIENT_STABILITY"));
assert!(results
.data
.diagnostics
.as_ref()
.expect("diagnostics should be included")
.iter()
.any(|diag| diag.code == "FEA_TRANSIENT_ENERGY"));
}
#[test]
fn analysis_results_contract_is_v1_and_filterable() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let run = analysis_run_linear_static_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-3b-run".to_string()), None),
)
.expect("run should succeed");
let results = analysis_results_op(
&run.data,
AnalysisResultsQuery {
include_fields: vec![FEA_FIELD_STRUCTURAL_VON_MISES.to_string()],
include_field_values: true,
include_diagnostics: false,
diagnostic_codes: Vec::new(),
include_modal_results: true,
mode_indices: Vec::new(),
include_transient_results: true,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-3b-results".to_string()), None),
)
.expect("results should succeed");
assert_eq!(results.operation, "fea.results");
assert_eq!(results.op_version, "fea.results/v1");
assert_eq!(results.data.fields.len(), 1);
assert_eq!(
results.data.fields[0].field_id,
FEA_FIELD_STRUCTURAL_VON_MISES
);
assert!(results.data.diagnostics.is_none());
}
#[test]
fn analysis_results_unknown_field_maps_typed_error_contract() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let run = analysis_run_linear_static_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-3c-run".to_string()), None),
)
.expect("run should succeed");
let err = analysis_results_op(
&run.data,
AnalysisResultsQuery {
include_fields: vec!["nonexistent".to_string()],
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: Vec::new(),
include_modal_results: true,
mode_indices: Vec::new(),
include_transient_results: true,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-3c-results".to_string()), None),
)
.expect_err("results should fail");
assert_eq!(err.operation, "fea.results");
assert_eq!(err.op_version, "fea.results/v1");
assert_eq!(err.error_code, "RM.FEA.RESULTS.FIELD_NOT_FOUND");
}
#[test]
fn analysis_results_modal_query_controls_are_typed() {
let geometry = geometry_load_op(
"/part.stl",
TRIANGLE_STL.as_bytes(),
OperationContext::new(Some("trace-contract-modal-results-1".to_string()), None),
)
.expect("geometry load should succeed");
let modal_model = analysis_create_model_op(
&geometry.data,
AnalysisCreateModelIntentSpec {
model_id: "contract_modal_results_model".to_string(),
profile: AnalysisCreateModelProfile::ModalStructural,
prep_context: None,
},
OperationContext::new(Some("trace-contract-modal-results-2".to_string()), None),
)
.expect("modal model should be created");
let modal_run = analysis_run_modal_op(
&modal_model.data,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-modal-results-3".to_string()), None),
)
.expect("modal run should succeed");
let excluded = analysis_results_op(
&modal_run.data,
AnalysisResultsQuery {
include_fields: Vec::new(),
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: Vec::new(),
include_modal_results: false,
mode_indices: Vec::new(),
include_transient_results: true,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-modal-results-4".to_string()), None),
)
.expect("results should succeed");
assert!(excluded.data.modal_results.is_none());
assert!(excluded.data.summary.mode_count > 0);
assert_eq!(
excluded.data.summary.mode_count,
excluded.data.summary.available_mode_indices.len()
);
assert!(excluded.data.summary.min_frequency_hz.is_some());
assert!(excluded.data.summary.max_frequency_hz.is_some());
assert!(excluded.data.summary.max_modal_residual_norm.is_some());
assert!(excluded.data.summary.first_mode_converged.is_some());
assert_eq!(excluded.data.summary.snapshot_count, 0);
assert_eq!(excluded.data.summary.time_start_s, None);
assert_eq!(excluded.data.summary.time_end_s, None);
assert_eq!(excluded.data.summary.max_transient_residual_norm, None);
assert_eq!(excluded.data.summary.final_step_converged, None);
let invalid_mode = analysis_results_op(
&modal_run.data,
AnalysisResultsQuery {
include_fields: Vec::new(),
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: Vec::new(),
include_modal_results: true,
mode_indices: vec![99],
include_transient_results: true,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-modal-results-5".to_string()), None),
)
.expect_err("unknown mode index should fail");
assert_eq!(invalid_mode.error_code, "RM.FEA.RESULTS.MODE_NOT_FOUND");
assert_eq!(invalid_mode.operation, "fea.results");
assert_eq!(invalid_mode.op_version, "fea.results/v1");
}
#[test]
fn analysis_results_transient_query_controls_are_typed() {
let mut model = fixture_model(FixtureId::CantileverLinearStatic);
model.steps = vec![runmat_analysis_core::AnalysisStep {
step_id: "transient_1".to_string(),
kind: runmat_analysis_core::AnalysisStepKind::Transient,
}];
let transient_run = analysis_run_transient_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-transient-results-1".to_string()), None),
)
.expect("transient run should succeed");
let excluded = analysis_results_op(
&transient_run.data,
AnalysisResultsQuery {
include_fields: Vec::new(),
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: Vec::new(),
include_modal_results: true,
mode_indices: Vec::new(),
include_transient_results: false,
transient_snapshot_indices: Vec::new(),
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-transient-results-2".to_string()), None),
)
.expect("results should succeed");
assert!(excluded.data.transient_results.is_none());
assert!(excluded.data.summary.snapshot_count > 0);
assert_eq!(excluded.data.summary.time_start_s, Some(0.0));
assert!(excluded.data.summary.time_end_s.unwrap_or(0.0) > 0.0);
assert!(excluded.data.summary.max_transient_residual_norm.is_some());
assert!(excluded.data.summary.final_step_converged.is_some());
let invalid_snapshot = analysis_results_op(
&transient_run.data,
AnalysisResultsQuery {
include_fields: Vec::new(),
include_field_values: true,
include_diagnostics: true,
diagnostic_codes: Vec::new(),
include_modal_results: true,
mode_indices: Vec::new(),
include_transient_results: true,
transient_snapshot_indices: vec![999],
include_nonlinear_results: true,
include_electromagnetic_results: true,
},
OperationContext::new(Some("trace-contract-transient-results-3".to_string()), None),
)
.expect_err("unknown transient snapshot index should fail");
assert_eq!(
invalid_snapshot.error_code,
"RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND"
);
assert_eq!(invalid_snapshot.operation, "fea.results");
assert_eq!(invalid_snapshot.op_version, "fea.results/v1");
}
#[test]
fn analysis_results_by_run_id_contract_roundtrip() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let run = analysis_run_linear_static_op(
&model,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-results-id-run".to_string()), None),
)
.expect("run should succeed");
let results = analysis_results_by_run_id_op(
&run.data.run_id,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-results-id-get".to_string()), None),
)
.expect("results by run id should succeed");
assert_eq!(results.operation, "fea.results");
assert_eq!(results.op_version, "fea.results/v1");
assert_eq!(results.data.summary.field_count, results.data.fields.len());
assert_eq!(results.data.summary.field_count, 10);
assert_eq!(results.data.summary.mode_count, 0);
assert!(results.data.summary.available_mode_indices.is_empty());
assert_eq!(results.data.summary.min_frequency_hz, None);
assert_eq!(results.data.summary.max_frequency_hz, None);
assert_eq!(results.data.summary.max_modal_residual_norm, None);
assert_eq!(results.data.summary.first_mode_converged, None);
assert_eq!(results.data.summary.snapshot_count, 0);
assert_eq!(results.data.summary.time_start_s, None);
assert_eq!(results.data.summary.time_end_s, None);
assert_eq!(results.data.summary.max_transient_residual_norm, None);
assert_eq!(results.data.summary.final_step_converged, None);
assert_eq!(results.data.summary.thermo_coupling_enabled, None);
assert_eq!(results.data.summary.thermo_coupling_fingerprint, None);
assert_eq!(
results.data.summary.thermo_constitutive_temperature_factor,
None
);
assert_eq!(results.data.summary.thermo_effective_modulus_scale, None);
assert_eq!(
results
.data
.summary
.thermo_constitutive_material_spread_ratio,
None
);
assert_eq!(
results.data.summary.thermo_assignment_heterogeneity_index,
None
);
assert_eq!(results.data.summary.thermo_transient_severity, None);
assert_eq!(results.data.summary.thermo_nonlinear_severity, None);
}
#[test]
fn analysis_results_by_run_id_missing_maps_typed_error_contract() {
let err = analysis_results_by_run_id_op(
"run_does_not_exist",
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-results-id-missing".to_string()), None),
)
.expect_err("results by run id should fail");
assert_eq!(err.operation, "fea.results");
assert_eq!(err.op_version, "fea.results/v1");
assert_eq!(err.error_code, "RM.FEA.RESULTS.RUN_NOT_FOUND");
}
#[test]
fn analysis_run_contract_maps_fixture_validation_failures() {
let missing_materials = fixture_model(FixtureId::MissingMaterials);
let err = analysis_run_linear_static_op(
&missing_materials,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-4".to_string()), None),
)
.expect_err("run should fail");
assert_eq!(err.operation, "fea.run_linear_static");
assert_eq!(err.op_version, "fea.run_linear_static/v1");
assert_eq!(
err.error_code,
"RM.FEA.RUN_LINEAR_STATIC.SOLVER_MODEL_INVALID"
);
assert_eq!(
err.context.get("analysis_model_id").map(|s| s.as_str()),
Some("missing_materials")
);
let missing_loads = fixture_model(FixtureId::MissingLoads);
let err = analysis_run_linear_static_op(
&missing_loads,
ComputeBackend::Cpu,
OperationContext::new(Some("trace-contract-5".to_string()), None),
)
.expect_err("run should fail");
assert_eq!(
err.error_code,
"RM.FEA.RUN_LINEAR_STATIC.SOLVER_MODEL_INVALID"
);
assert_eq!(
err.context.get("analysis_model_id").map(|s| s.as_str()),
Some("missing_loads")
);
}
#[test]
fn multi_material_confidence_mismatch_degrades_publishability() {
let model = fixture_model(FixtureId::MultiMaterialAssembly);
let envelope = analysis_run_linear_static_op(
&model,
ComputeBackend::Gpu,
OperationContext::new(Some("trace-contract-multi-material".to_string()), None),
)
.expect("run should succeed");
assert_eq!(envelope.data.run_status, RunStatus::Degraded);
assert!(!envelope.data.publishable);
assert!(envelope
.data
.run
.diagnostics
.iter()
.any(|diag| diag.code == "ANALYSIS_MATERIAL_ASSIGNMENT_CONFLICT_INFERRED"));
}
#[test]
fn analysis_run_deterministic_contract_is_stable_across_replays() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let options = AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Balanced,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
};
let first = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Cpu,
options.clone(),
OperationContext::new(Some("trace-contract-6a".to_string()), None),
)
.expect("first deterministic run should succeed");
let second = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Cpu,
options.clone(),
OperationContext::new(Some("trace-contract-6b".to_string()), None),
)
.expect("second deterministic run should succeed");
assert_eq!(first.data.run_status, RunStatus::Publishable);
assert_eq!(second.data.run_status, RunStatus::Publishable);
assert!(first.data.publishable);
assert!(second.data.publishable);
assert_eq!(first.data.provenance.precision_mode, "fp64");
assert_eq!(second.data.provenance.precision_mode, "fp64");
assert!(first.data.provenance.deterministic_mode);
assert!(second.data.provenance.deterministic_mode);
assert_eq!(first.data.run.fields, second.data.run.fields);
assert_eq!(first.data.run.diagnostics, second.data.run.diagnostics);
}
#[test]
fn analysis_run_backend_selection_is_recorded_in_provenance() {
let model = fixture_model(FixtureId::CantileverLinearStatic);
let options = AnalysisRunOptions::default();
let cpu = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Cpu,
options.clone(),
OperationContext::new(Some("trace-contract-7a".to_string()), None),
)
.expect("cpu run should succeed");
let gpu = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
options.clone(),
OperationContext::new(Some("trace-contract-7b".to_string()), None),
)
.expect("gpu run should succeed");
assert_eq!(cpu.data.provenance.backend, ComputeBackend::Cpu);
assert_eq!(gpu.data.provenance.backend, ComputeBackend::Gpu);
assert_eq!(cpu.data.provenance.solver_device_apply_k_ratio, 0.0);
assert_eq!(cpu.data.provenance.solver_host_sync_count, 0);
assert!(
(0.0..=1.0).contains(&gpu.data.provenance.solver_device_apply_k_ratio),
"gpu ratio must be in [0,1]"
);
assert!(matches!(
gpu.data.provenance.solver_backend.as_str(),
"runtime_tensor" | "cpu_reference"
));
assert_eq!(cpu.data.run_status, RunStatus::Publishable);
assert_eq!(gpu.data.run_status, RunStatus::Publishable);
}
#[test]
fn analysis_run_gpu_without_provider_records_fallback_contract() {
let _guard = runmat_accelerate_api::ThreadProviderGuard::set(None);
let model = fixture_model(FixtureId::CantileverLinearStatic);
let envelope = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions::default(),
OperationContext::new(Some("trace-contract-8".to_string()), None),
)
.expect("gpu run should succeed with host fallback");
if envelope.data.provenance.solver_backend == "cpu_reference" {
assert!(envelope
.data
.provenance
.fallback_events
.iter()
.any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK")));
assert_eq!(envelope.data.provenance.solver_device_apply_k_ratio, 0.0);
} else {
assert_eq!(envelope.data.provenance.solver_backend, "runtime_tensor");
}
for event in &envelope.data.provenance.fallback_events {
assert_fallback_event_schema(event);
}
assert!(matches!(
envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_DISPLACEMENT)
.expect("structural displacement field should be present")
.values,
AnalysisFieldValues::HostF64(_)
));
}
#[test]
fn analysis_run_gpu_with_provider_emits_device_ref_contract() {
struct ContractTestProvider;
impl runmat_accelerate_api::AccelProvider for ContractTestProvider {
fn upload(
&self,
host: &runmat_accelerate_api::HostTensorView,
) -> anyhow::Result<runmat_accelerate_api::GpuTensorHandle> {
static NEXT_BUFFER_ID: AtomicU64 = AtomicU64::new(2000);
Ok(runmat_accelerate_api::GpuTensorHandle {
shape: host.shape.to_vec(),
device_id: 11,
buffer_id: NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed),
})
}
fn download<'a>(
&'a self,
h: &'a runmat_accelerate_api::GpuTensorHandle,
) -> runmat_accelerate_api::AccelDownloadFuture<'a> {
Box::pin(async move {
Ok(runmat_accelerate_api::HostTensorOwned {
data: vec![0.0; h.shape.iter().product()],
shape: h.shape.clone(),
storage: runmat_accelerate_api::handle_storage(h),
})
})
}
fn free(&self, _h: &runmat_accelerate_api::GpuTensorHandle) -> anyhow::Result<()> {
Ok(())
}
fn device_info(&self) -> String {
"contract-test-provider".to_string()
}
fn device_id(&self) -> u32 {
11
}
fn device_info_struct(&self) -> runmat_accelerate_api::ApiDeviceInfo {
runmat_accelerate_api::ApiDeviceInfo {
device_id: 11,
name: "contract-test-provider".to_string(),
vendor: "runmat-tests".to_string(),
memory_bytes: None,
backend: Some("contract_gpu".to_string()),
}
}
}
static PROVIDER: ContractTestProvider = ContractTestProvider;
let _guard = runmat_accelerate_api::ThreadProviderGuard::set(Some(&PROVIDER));
let model = fixture_model(FixtureId::CantileverLinearStatic);
let envelope = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions::default(),
OperationContext::new(Some("trace-contract-9".to_string()), None),
)
.expect("gpu run should succeed");
assert!(!envelope
.data
.provenance
.fallback_events
.iter()
.any(|event| event.starts_with("BACKEND_NO_PROVIDER")
|| event.starts_with("BACKEND_UPLOAD_FAILED")));
if envelope.data.provenance.solver_backend == "cpu_reference" {
assert!(envelope
.data
.provenance
.fallback_events
.iter()
.any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK")));
}
assert!(matches!(
envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_DISPLACEMENT)
.expect("structural displacement field should be present")
.values,
AnalysisFieldValues::DeviceRef(_)
));
assert!(matches!(
envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_VON_MISES)
.expect("structural von Mises field should be present")
.values,
AnalysisFieldValues::DeviceRef(_)
));
}
#[test]
fn analysis_run_gpu_upload_failure_records_fallback_contract() {
struct UploadFailProvider;
impl runmat_accelerate_api::AccelProvider for UploadFailProvider {
fn upload(
&self,
_host: &runmat_accelerate_api::HostTensorView,
) -> anyhow::Result<runmat_accelerate_api::GpuTensorHandle> {
Err(anyhow::anyhow!("forced-upload-failure"))
}
fn download<'a>(
&'a self,
h: &'a runmat_accelerate_api::GpuTensorHandle,
) -> runmat_accelerate_api::AccelDownloadFuture<'a> {
Box::pin(async move {
Ok(runmat_accelerate_api::HostTensorOwned {
data: vec![0.0; h.shape.iter().product()],
shape: h.shape.clone(),
storage: runmat_accelerate_api::handle_storage(h),
})
})
}
fn free(&self, _h: &runmat_accelerate_api::GpuTensorHandle) -> anyhow::Result<()> {
Ok(())
}
fn device_info(&self) -> String {
"upload-fail-provider".to_string()
}
}
static PROVIDER: UploadFailProvider = UploadFailProvider;
let _guard = runmat_accelerate_api::ThreadProviderGuard::set(Some(&PROVIDER));
let model = fixture_model(FixtureId::CantileverLinearStatic);
let envelope = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions::default(),
OperationContext::new(Some("trace-contract-10".to_string()), None),
)
.expect("gpu run should still succeed with fallback");
assert!(envelope
.data
.provenance
.fallback_events
.iter()
.any(|event| event.starts_with("BACKEND_UPLOAD_FAILED:structural.displacement")));
for event in &envelope.data.provenance.fallback_events {
assert_fallback_event_schema(event);
}
assert!(matches!(
envelope
.data
.run
.field(FEA_FIELD_STRUCTURAL_DISPLACEMENT)
.expect("structural displacement field should be present")
.values,
AnalysisFieldValues::HostF64(_)
));
}
#[test]
fn strict_policy_quality_reasons_propagate_to_results_contracts() {
let model = fixture_model(FixtureId::MultiMaterialAssembly);
let run = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Cpu,
AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Strict,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-11-run".to_string()), None),
)
.expect("run should succeed");
assert_eq!(run.data.run_status, RunStatus::Degraded);
assert!(!run.data.publishable);
assert_eq!(run.data.provenance.quality_policy, "strict");
assert!(run
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::MaterialAssignmentConflict));
let direct_results = analysis_results_op(
&run.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-11-results".to_string()), None),
)
.expect("results should succeed");
assert_eq!(direct_results.data.run_status, RunStatus::Degraded);
assert!(!direct_results.data.publishable);
assert_eq!(direct_results.data.provenance.quality_policy, "strict");
assert!(direct_results
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::MaterialAssignmentConflict));
let by_id_results = analysis_results_by_run_id_op(
&run.data.run_id,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-11-results-id".to_string()), None),
)
.expect("results by run_id should succeed");
assert_eq!(by_id_results.data.run_status, RunStatus::Degraded);
assert!(!by_id_results.data.publishable);
assert_eq!(by_id_results.data.provenance.quality_policy, "strict");
assert!(by_id_results
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::MaterialAssignmentConflict));
}
#[test]
fn balanced_and_strict_diverge_for_same_field_promotion_fallback() {
struct UploadFailProvider;
impl runmat_accelerate_api::AccelProvider for UploadFailProvider {
fn upload(
&self,
_host: &runmat_accelerate_api::HostTensorView,
) -> anyhow::Result<runmat_accelerate_api::GpuTensorHandle> {
Err(anyhow::anyhow!("forced-upload-failure"))
}
fn download<'a>(
&'a self,
h: &'a runmat_accelerate_api::GpuTensorHandle,
) -> runmat_accelerate_api::AccelDownloadFuture<'a> {
Box::pin(async move {
Ok(runmat_accelerate_api::HostTensorOwned {
data: vec![0.0; h.shape.iter().product()],
shape: h.shape.clone(),
storage: runmat_accelerate_api::handle_storage(h),
})
})
}
fn free(&self, _h: &runmat_accelerate_api::GpuTensorHandle) -> anyhow::Result<()> {
Ok(())
}
fn device_info(&self) -> String {
"upload-fail-provider".to_string()
}
}
static PROVIDER: UploadFailProvider = UploadFailProvider;
let _guard = runmat_accelerate_api::ThreadProviderGuard::set(Some(&PROVIDER));
let model = fixture_model(FixtureId::CantileverLinearStatic);
let balanced = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Balanced,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-12-balanced".to_string()), None),
)
.expect("balanced run should succeed");
let strict = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Strict,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-12-strict".to_string()), None),
)
.expect("strict run should succeed");
assert_eq!(
balanced.data.solver_convergence,
strict.data.solver_convergence
);
assert_eq!(balanced.data.result_quality, strict.data.result_quality);
assert_eq!(balanced.data.provenance.quality_policy, "balanced");
assert_eq!(strict.data.provenance.quality_policy, "strict");
assert!(balanced
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
assert!(strict
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
assert!(balanced.data.publishable);
assert_eq!(balanced.data.run_status, RunStatus::Publishable);
assert!(!strict.data.publishable);
assert_eq!(strict.data.run_status, RunStatus::Degraded);
}
#[test]
fn balanced_and_strict_divergence_propagates_through_results_endpoints() {
struct UploadFailProvider;
impl runmat_accelerate_api::AccelProvider for UploadFailProvider {
fn upload(
&self,
_host: &runmat_accelerate_api::HostTensorView,
) -> anyhow::Result<runmat_accelerate_api::GpuTensorHandle> {
Err(anyhow::anyhow!("forced-upload-failure"))
}
fn download<'a>(
&'a self,
h: &'a runmat_accelerate_api::GpuTensorHandle,
) -> runmat_accelerate_api::AccelDownloadFuture<'a> {
Box::pin(async move {
Ok(runmat_accelerate_api::HostTensorOwned {
data: vec![0.0; h.shape.iter().product()],
shape: h.shape.clone(),
storage: runmat_accelerate_api::handle_storage(h),
})
})
}
fn free(&self, _h: &runmat_accelerate_api::GpuTensorHandle) -> anyhow::Result<()> {
Ok(())
}
fn device_info(&self) -> String {
"upload-fail-provider".to_string()
}
}
static PROVIDER: UploadFailProvider = UploadFailProvider;
let _guard = runmat_accelerate_api::ThreadProviderGuard::set(Some(&PROVIDER));
let model = fixture_model(FixtureId::CantileverLinearStatic);
let balanced_run = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Balanced,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-13-balanced-run".to_string()), None),
)
.expect("balanced run should succeed");
let strict_run = analysis_run_linear_static_with_options(
&model,
ComputeBackend::Gpu,
AnalysisRunOptions {
deterministic_mode: true,
precision_mode: PrecisionMode::Fp64,
preconditioner_mode: PreconditionerMode::Auto,
quality_policy: QualityPolicy::Strict,
prep_context: None,
prep_artifact_id: None,
prep_calibration_profile: None,
},
OperationContext::new(Some("trace-contract-13-strict-run".to_string()), None),
)
.expect("strict run should succeed");
let balanced_results = analysis_results_op(
&balanced_run.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-13-balanced-results".to_string()), None),
)
.expect("balanced results should succeed");
let strict_results = analysis_results_op(
&strict_run.data,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-13-strict-results".to_string()), None),
)
.expect("strict results should succeed");
assert_eq!(balanced_results.data.run_status, RunStatus::Publishable);
assert!(balanced_results.data.publishable);
assert_eq!(balanced_results.data.provenance.quality_policy, "balanced");
assert!(balanced_results
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
assert_eq!(strict_results.data.run_status, RunStatus::Degraded);
assert!(!strict_results.data.publishable);
assert_eq!(strict_results.data.provenance.quality_policy, "strict");
assert!(strict_results
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
let balanced_by_id = analysis_results_by_run_id_op(
&balanced_run.data.run_id,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-13-balanced-by-id".to_string()), None),
)
.expect("balanced by-id results should succeed");
let strict_by_id = analysis_results_by_run_id_op(
&strict_run.data.run_id,
AnalysisResultsQuery::default(),
OperationContext::new(Some("trace-contract-13-strict-by-id".to_string()), None),
)
.expect("strict by-id results should succeed");
assert_eq!(balanced_by_id.data.run_status, RunStatus::Publishable);
assert!(balanced_by_id.data.publishable);
assert_eq!(balanced_by_id.data.provenance.quality_policy, "balanced");
assert!(balanced_by_id
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
assert_eq!(strict_by_id.data.run_status, RunStatus::Degraded);
assert!(!strict_by_id.data.publishable);
assert_eq!(strict_by_id.data.provenance.quality_policy, "strict");
assert!(strict_by_id
.data
.quality_reasons
.iter()
.any(|reason| reason.code == QualityReasonCode::FieldPromotionFallback));
}