Validate sourced physical material property cards and elastic stability (#934)

Adds the read-only material.properties.validate command: schema_version 1 engineering material cards in SI units are checked for numerical consistency and elastic stability, with derived isotropic moduli, missing conditions and sources needing review. Nothing is assigned or saved.

Thanks @0xlillith.
This commit is contained in:
selune authored and GitHub committed 2026-10-14 05:28:18 -04:00
1 parent 9ca7d307a4
commit 01f2c78cd1
11 files changed
+707 -5

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ pub mod motion;
mod name_lookup;
pub mod naming;
mod params;
pub mod physical_material;
mod piece_names;
mod profile_refs;
pub mod progress;
+353
View File
@@ -0,0 +1,353 @@
//! Candidate engineering data, separate from assigned density and visual appearance.
//! Validation establishes numerical consistency, not certification or solver support.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use crate::{DocError, Result};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MaterialCard {
pub schema_version: u32,
pub designation: String,
pub family: Family,
pub sources: Vec<Source>,
pub properties: Vec<Property>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Family {
Wood,
Polymer,
Glass,
Metal,
Other,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
pub id: String,
pub title: String,
pub kind: SourceKind,
/// A caller's claim, never an approval to redistribute or proof of authenticity.
pub license: Option<String>,
pub reference: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
UserSupplied,
Measurement,
PublicReference,
Illustrative,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Property {
pub data: PropertyData,
pub source_id: String,
#[serde(default)]
pub conditions: Conditions,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Conditions {
pub temperature_k: Option<f64>,
/// kg water / kg dry material; unlike wet-basis fraction, may exceed one.
pub moisture_dry_basis_kg_kg: Option<f64>,
pub strain_rate_per_s: Option<f64>,
pub state: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterialAxes {
Material123,
WoodLrt,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PropertyData {
Density { kg_m3: f64 },
Elasticity { model: Elasticity },
TensileStrength { pa: f64 },
CompressiveStrength { pa: f64 },
YieldStrength { pa: f64 },
ShearStrength { pa: f64 },
ThermalConductivity { w_m_k: [f64; 3], axes: MaterialAxes },
ThermalExpansion { per_k: [f64; 3], axes: MaterialAxes },
SpecificHeat { j_kg_k: f64 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "symmetry", rename_all = "snake_case", deny_unknown_fields)]
pub enum Elasticity {
Isotropic {
young_modulus_pa: f64,
poisson_ratio: f64,
},
/// E1,E2,E3; G12,G23,G31; nu12,nu23,nu31, with nu_ij = -eps_j/eps_i
/// under uniaxial stress in i. Reciprocal ratios follow nu_ij/Ei = nu_ji/Ej.
Orthotropic {
axes: MaterialAxes,
young_moduli_pa: [f64; 3],
shear_moduli_pa: [f64; 3],
poisson_ratios: [f64; 3],
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct IsotropicModuli {
pub shear_pa: f64,
pub bulk_pa: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Validation {
pub schema_version: u32,
pub property_kinds: Vec<String>,
pub isotropic_moduli: Option<IsotropicModuli>,
pub unspecified_conditions: Vec<String>,
pub source_review_required: Vec<String>,
pub analysis_solver_available: bool,
pub rendering_properties_available: bool,
}
fn invalid(message: impl Into<String>) -> DocError {
DocError::Invalid(message.into())
}
fn text(value: &str, limit: usize, field: &str) -> Result<()> {
if value.trim().is_empty() || value.len() > limit || value.chars().any(char::is_control) {
return Err(invalid(format!("{field}: expected nonempty text of at most {limit} UTF-8 bytes without controls")));
}
Ok(())
}
fn number(value: f64, low: f64, high: f64, field: &str) -> Result<()> {
if !value.is_finite() || value < low || value > high {
return Err(invalid(format!("{field}: expected a finite value in [{low}, {high}]")));
}
Ok(())
}
fn positive(value: f64, high: f64, field: &str) -> Result<()> {
number(value, 0., high, field)?;
if value == 0. {
return Err(invalid(format!("{field}: must be positive")));
}
Ok(())
}
impl Elasticity {
pub fn validate(&self) -> Result<Option<IsotropicModuli>> {
match self {
Self::Isotropic { young_modulus_pa: e, poisson_ratio: nu } => {
positive(*e, 1e15, "young_modulus_pa")?;
if !e.recip().is_finite() {
return Err(invalid("young_modulus_pa produces unrepresentable compliance"));
}
if !nu.is_finite() || *nu <= -1. || *nu >= 0.5 {
return Err(invalid("poisson_ratio: isotropic stability requires -1 < nu < 0.5"));
}
let result = IsotropicModuli { shear_pa: e / (2. * (1. + nu)), bulk_pa: e / (3. * (1. - 2. * nu)) };
if !result.shear_pa.is_finite()
|| !result.bulk_pa.is_finite()
|| result.shear_pa <= 0.
|| result.bulk_pa <= 0.
|| !result.shear_pa.recip().is_finite()
|| !result.bulk_pa.recip().is_finite()
{
return Err(invalid("elastic moduli exceed numeric limits"));
}
Ok(Some(result))
}
Self::Orthotropic { young_moduli_pa: e, shear_moduli_pa: g, poisson_ratios: nu, .. } => {
for value in e.iter().chain(g) {
positive(*value, 1e15, "orthotropic modulus_pa")?;
if !value.recip().is_finite() {
return Err(invalid("orthotropic modulus produces unrepresentable compliance"));
}
}
for value in nu {
number(*value, -1e6, 1e6, "orthotropic poisson_ratio")?;
}
// Diagonal scaling of the reciprocal, symmetric normal compliance
// gives unit diagonal and these off-diagonal entries. Sylvester's
// criterion proves positive definiteness without dimensional cutoffs.
let [e1, e2, e3] = *e;
let [n12, n23, n31] = *nu;
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
if ![a, b, c, determinant].iter().all(|v| v.is_finite()) || 1. - a * a <= 0. || determinant <= 0. {
return Err(invalid("orthotropic compliance must be finite and positive definite"));
}
Ok(None)
}
}
}
}
impl PropertyData {
pub fn kind(&self) -> &'static str {
match self {
Self::Density { .. } => "density",
Self::Elasticity { .. } => "elasticity",
Self::TensileStrength { .. } => "tensile_strength",
Self::CompressiveStrength { .. } => "compressive_strength",
Self::YieldStrength { .. } => "yield_strength",
Self::ShearStrength { .. } => "shear_strength",
Self::ThermalConductivity { .. } => "thermal_conductivity",
Self::ThermalExpansion { .. } => "thermal_expansion",
Self::SpecificHeat { .. } => "specific_heat",
}
}
fn validate(&self) -> Result<Option<IsotropicModuli>> {
match self {
Self::Density { kg_m3 } => positive(*kg_m3, 1e6, "density.kg_m3")?,
Self::Elasticity { model } => return model.validate(),
Self::TensileStrength { pa } | Self::CompressiveStrength { pa } | Self::YieldStrength { pa } | Self::ShearStrength { pa } => {
positive(*pa, 1e15, self.kind())?
}
Self::ThermalConductivity { w_m_k, .. } => {
for k in w_m_k {
positive(*k, 1e9, "thermal_conductivity.w_m_k")?;
}
}
Self::ThermalExpansion { per_k, .. } => {
for a in per_k {
number(*a, -1., 1., "thermal_expansion.per_k")?;
}
}
Self::SpecificHeat { j_kg_k } => positive(*j_kg_k, 1e9, "specific_heat.j_kg_k")?,
}
Ok(None)
}
}
impl MaterialCard {
pub fn validate(&self) -> Result<Validation> {
if self.schema_version != 1 {
return Err(invalid("unsupported material card schema_version (expected 1)"));
}
text(&self.designation, 256, "designation")?;
if self.sources.len() > 32 || self.properties.len() > 16 {
return Err(invalid("material card exceeds 32 sources or 16 properties"));
}
let mut sources = BTreeSet::new();
for source in &self.sources {
text(&source.id, 128, "source.id")?;
text(&source.title, 256, "source.title")?;
if let Some(value) = &source.license {
text(value, 512, "source.license")?;
}
if let Some(value) = &source.reference {
text(value, 2048, "source.reference")?;
}
if !sources.insert(source.id.clone()) {
return Err(invalid("duplicate source id"));
}
}
let mut kinds = BTreeSet::new();
let mut used_sources = BTreeSet::new();
let mut unspecified = Vec::new();
let mut isotropic_moduli = None;
for property in &self.properties {
let kind = property.data.kind();
if !kinds.insert(kind.to_string()) {
return Err(invalid(format!("duplicate property kind: {kind}")));
}
if !sources.contains(&property.source_id) {
return Err(invalid(format!("{kind}: unknown source_id")));
}
used_sources.insert(property.source_id.clone());
let conditions = &property.conditions;
if let Some(t) = conditions.temperature_k {
positive(t, 20000., "temperature_k")?;
}
if let Some(m) = conditions.moisture_dry_basis_kg_kg {
number(m, 0., 1e6, "moisture_dry_basis_kg_kg")?;
}
if let Some(r) = conditions.strain_rate_per_s {
positive(r, 1e12, "strain_rate_per_s")?;
}
if let Some(state) = &conditions.state {
text(state, 1024, "conditions.state")?;
}
if conditions.temperature_k.is_none()
|| conditions.state.is_none()
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
{
unspecified.push(kind.to_string());
}
if let Some(moduli) = property.data.validate()? {
isotropic_moduli = Some(moduli);
}
}
Ok(Validation {
schema_version: 1,
property_kinds: kinds.into_iter().collect(),
isotropic_moduli,
unspecified_conditions: unspecified,
source_review_required: used_sources.into_iter().collect(),
analysis_solver_available: false,
rendering_properties_available: false,
})
}
}
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
/// bounds are admission limits, not claims about a real material's valid range.
pub fn validate_card(value: &Value) -> Result<Validation> {
let mut pending = vec![(value, 0usize)];
let (mut nodes, mut bytes) = (0usize, 0usize);
while let Some((value, depth)) = pending.pop() {
nodes += 1;
bytes += 32;
if nodes > 2048 || depth > 12 {
return Err(invalid("material card exceeds JSON node/depth budget"));
}
match value {
Value::String(s) => {
if s.len() > 256 * 1024 {
return Err(invalid("material card string exceeds admission budget"));
}
bytes += s.len();
}
Value::Array(a) => {
if a.len() > 32 {
return Err(invalid("material card array exceeds 32 entries"));
}
pending.extend(a.iter().map(|v| (v, depth + 1)));
}
Value::Object(o) => {
if o.len() > 32 {
return Err(invalid("material card object exceeds 32 fields"));
}
for (key, v) in o {
if key.len() > 256 * 1024 {
return Err(invalid("material card key exceeds admission budget"));
}
bytes += key.len();
pending.push((v, depth + 1));
}
}
_ => {}
}
if bytes > 256 * 1024 {
return Err(invalid("material card exceeds 256 KiB admission budget"));
}
}
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
card.validate()
}
#[cfg(test)]
#[path = "physical_material_tests.rs"]
mod tests;
+115
View File
@@ -0,0 +1,115 @@
use super::*;
use serde_json::json;
fn card(data: Value) -> Value {
json!({"schema_version":1,"designation":"Original synthetic fixture","family":"wood",
"sources":[{"id":"test","title":"Analytic synthetic data, not a material specification","kind":"illustrative","license":"MIT OR Apache-2.0"}],
"properties":[{"data":data,"source_id":"test"}]})
}
fn isotropic(e: f64, nu: f64) -> Value {
json!({"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":e,"poisson_ratio":nu}})
}
fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt",
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
}
#[test]
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();
assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 4.8e9, bulk_pa: 8e9 }));
let r = validate_card(&card(isotropic(12e9, -0.5))).unwrap();
assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 12e9, bulk_pa: 2e9 }));
for nu in [-2., -1., 0.5, 0.6] {
assert!(validate_card(&card(isotropic(12e9, nu))).is_err());
}
for e in [0., -1., 1e16] {
assert!(validate_card(&card(isotropic(e, 0.25))).is_err());
}
assert!(Elasticity::Isotropic { young_modulus_pa: f64::NAN, poisson_ratio: 0.25 }.validate().is_err());
assert!(Elasticity::Isotropic { young_modulus_pa: 1e-309, poisson_ratio: 0.25 }.validate().is_err());
}
#[test]
fn orthotropy_uses_reciprocity_and_full_positive_definiteness() {
// With equal E and nu=.25 this is the same normal compliance as isotropy.
assert!(validate_card(&card(orthotropic([12e9; 3], [0.25; 3]))).is_ok());
// A general orthotropic ratio may exceed .5: reciprocal nu21 is .08 here.
assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [0.8, 0., 0.]))).is_ok());
// Every 2x2 principal minor is positive; the full determinant is negative.
assert!(validate_card(&card(orthotropic([12e9; 3], [0.6; 3]))).is_err());
assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [4., 0., 0.]))).is_err());
assert!(validate_card(&card(orthotropic([0., 1e9, 1e9], [0.; 3]))).is_err());
let mut bad = card(orthotropic([12e9; 3], [0.25; 3]));
bad["properties"][0]["data"]["model"]["shear_moduli_pa"] = json!([4e9, 0., 4e9]);
assert!(validate_card(&bad).is_err());
// Scaling all moduli changes units of compliance, not its stability verdict.
assert!(validate_card(&card(orthotropic([12.; 3], [0.25; 3]))).is_ok());
assert!(validate_card(&card(orthotropic([1e-300, 1e15, 1.], [0.; 3]))).is_ok());
assert!(validate_card(&card(orthotropic([1e-309, 1., 1.], [0.; 3]))).is_err());
}
#[test]
fn conditions_missing_values_and_source_claims_remain_explicit() {
let mut value = card(json!({"kind":"density","kg_m3":600.}));
let r = validate_card(&value).unwrap();
assert_eq!(r.unspecified_conditions, vec!["density"]);
assert_eq!(r.source_review_required, vec!["test"]); // Even an explicit license is unverified.
assert!(!r.analysis_solver_available && !r.rendering_properties_available);
assert!(r.isotropic_moduli.is_none());
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"moisture_dry_basis_kg_kg":1.5,"state":"synthetic wet specimen"});
assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty());
value["family"] = json!("polymer");
value["properties"][0]["conditions"]["moisture_dry_basis_kg_kg"] = json!(150.);
value["properties"][0]["conditions"]["strain_rate_per_s"] = json!(1.);
assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty());
value["properties"] = json!([]);
let r = validate_card(&value).unwrap();
assert!(r.property_kinds.is_empty() && r.isotropic_moduli.is_none());
let parsed: MaterialCard = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
}
#[test]
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
assert!(validate_card(&card(json!({"kind":"thermal_conductivity","w_m_k":[1.,0.,1.],"axes":"material123"}))).is_err());
assert!(validate_card(&card(json!({"kind":"specific_heat","j_kg_k":500.}))).is_ok());
for kind in ["tensile_strength", "compressive_strength", "yield_strength", "shear_strength"] {
assert!(validate_card(&card(json!({"kind":kind,"pa":1e8}))).is_ok());
assert!(validate_card(&card(json!({"kind":kind,"pa":-1.}))).is_err());
}
}
#[test]
fn hostile_units_sources_fields_and_budgets_are_rejected() {
let valid = card(isotropic(12e9, 0.25));
let mut bad = valid.clone();
bad["properties"][0]["data"]["model"]["young_modulus_mpa"] = json!(12.);
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["properties"][0]["source_id"] = json!("unknown");
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["properties"] = json!([valid["properties"][0], valid["properties"][0]]);
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["sources"] = json!([valid["sources"][0], valid["sources"][0]]);
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["schema_version"] = json!(2);
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["designation"] = json!("x".repeat(257));
assert!(validate_card(&bad).is_err());
let mut bad = valid.clone();
bad["properties"][0]["conditions"] = json!({"moisture_dry_basis_kg_kg":-1.});
assert!(validate_card(&bad).is_err());
assert!(validate_card(&json!(vec![Value::Null; 33])).is_err());
assert!(validate_card(&json!("x".repeat(256 * 1024 + 1))).is_err());
let mut deep = Value::Null;
for _ in 0..14 {
deep = json!([deep]);
}
assert!(validate_card(&deep).is_err());
}