material: report exact missing condition fields (#1334)

Material-card validation now names the exact condition fields missing for each property (missing_condition_fields), alongside the existing unspecified_conditions.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
This commit is contained in:
seluneand0xlillith authored and GitHub committed 2026-10-15 13:10:49 -04:00
1 parent fd422a3fc1
commit 8b7fa2602e
4 files changed
+85 -6

No files matched your search

+20 -6
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use crate::{DocError, Result};
@@ -116,6 +116,10 @@ pub struct Validation {
pub property_kinds: Vec<String>,
pub isotropic_moduli: Option<IsotropicModuli>,
pub unspecified_conditions: Vec<String>,
/// Property kind -> absent condition fields under the existing family policy.
/// Empty when reading an older report that did not include this detail.
#[serde(default)]
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
pub source_review_required: Vec<String>,
pub analysis_solver_available: bool,
pub rendering_properties_available: bool,
@@ -256,6 +260,7 @@ impl MaterialCard {
let mut kinds = BTreeSet::new();
let mut used_sources = BTreeSet::new();
let mut unspecified = Vec::new();
let mut missing_condition_fields = BTreeMap::new();
let mut isotropic_moduli = None;
for property in &self.properties {
let kind = property.data.kind();
@@ -279,12 +284,20 @@ impl MaterialCard {
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())
{
let mut missing = Vec::new();
for (absent, field) in [
(conditions.temperature_k.is_none(), "temperature_k"),
(conditions.state.is_none(), "state"),
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
] {
if absent {
missing.push(field.to_string());
}
}
if !missing.is_empty() {
unspecified.push(kind.to_string());
missing_condition_fields.insert(kind.to_string(), missing);
}
if let Some(moduli) = property.data.validate()? {
isotropic_moduli = Some(moduli);
@@ -295,6 +308,7 @@ impl MaterialCard {
property_kinds: kinds.into_iter().collect(),
isotropic_moduli,
unspecified_conditions: unspecified,
missing_condition_fields,
source_review_required: used_sources.into_iter().collect(),
analysis_solver_available: false,
rendering_properties_available: false,
+52
View File
@@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
}
#[test]
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
for (family, extra) in
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
{
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["family"] = json!(family);
let mut expected = vec!["temperature_k", "state"];
expected.extend(extra);
let result = validate_card(&value).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
value["properties"][0]["conditions"] =
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
for field in expected {
let mut partial = value.clone();
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
let result = validate_card(&partial).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
}
value["properties"] = json!([]);
let result = validate_card(&value).unwrap();
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
}
}
#[test]
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
let before = value.clone();
let result = validate_card(&value).unwrap();
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
let serialized = serde_json::to_value(&result).unwrap();
assert_eq!(serialized["missing_condition_fields"], expected);
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
assert_eq!(value, before);
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
let mut old = serialized;
old.as_object_mut().unwrap().remove("missing_condition_fields");
let old: Validation = serde_json::from_value(old).unwrap();
assert!(old.missing_condition_fields.is_empty());
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
assert_eq!(old.source_review_required, result.source_review_required);
}
#[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());
@@ -57,6 +57,7 @@ mod tests {
let response = s.execute("material.properties.directional", &json!({"card":card,"direction":[1,2,3]})).unwrap();
assert_eq!(response["young_modulus_pa"], 12e9);
assert_eq!(response["validation"]["source_review_required"], json!(["test"]));
assert_eq!(response["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]}));
for direction in [json!([0, 0, 0]), json!([1, 2]), json!([1, 2, 3, 4]), json!([true, 0, 0]), json!(["1", 0, 0]), Value::Null] {
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":direction})).is_err());
}
@@ -76,6 +77,7 @@ mod tests {
assert_eq!(result["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9}));
assert_eq!(result["validation"]["source_review_required"], json!(["fixture"]));
assert_eq!(result["validation"]["rendering_properties_available"], false);
assert_eq!(result["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]}));
card["properties"][0]["data"]["model"]["poisson_ratio"] = json!(0.6);
assert!(s.execute("material.properties.validate", &json!({"card":card})).is_err());
}
@@ -91,6 +93,7 @@ mod tests {
let card = json!({"schema_version":1,"designation":"Synthetic empty card","family":"other","sources":[],"properties":[]});
let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap();
assert_eq!(result["validation"]["analysis_solver_available"], false);
assert_eq!(result["validation"]["missing_condition_fields"], json!({}));
for p in [json!({}), json!({"card":card,"assign":true}), json!({"card":{"schema_version":9}})] {
assert!(s.execute("material.properties.validate", &p).is_err());
}
+10
View File
@@ -58,6 +58,16 @@ isotropic derived response and its reciprocals must also remain finite/positive.
Moisture is kg water / kg dry material and can exceed one. It is not a wet-mass
fraction. Unspecified temperature/state, wood moisture or polymer strain rate are
reported in `unspecified_conditions`; they are not guessed or replaced by zero.
The validation report also returns `missing_condition_fields`, a map from each
incomplete property kind to its exact absent field names. Temperature and state
apply to every family; wood additionally checks dry-basis moisture and polymers
strain rate, following the same reporting policy as `unspecified_conditions`.
For example, a wood density with temperature and state but no moisture reports
`{"density":["moisture_dry_basis_kg_kg"]}`. Complete properties are omitted;
an empty card returns an empty map, without inventing properties or conditions.
This detail is read-only and does not change admission or the saved card schema.
Older serialized validation reports without the map deserialize with an empty
map; regenerate validation from the card to obtain the missing-field detail.
The family label does not certify that a chosen constitutive model fits a material.
Scalar strengths are individual measurements, not a three-dimensional failure
envelope. Typed strength directions and test methods remain a separate extension.