From cbff2307343c9dc191d1572dec6cbbabca0b82f7 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:19 -0400 Subject: [PATCH] docs: oracle in architecture and roadmap; fmt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --- ROADMAP.md | 2 +- crates/engine/src/cmd/block.rs | 30 ++++++++++++++---- crates/testkit/src/oracle.rs | 58 +++++++++++++++++++--------------- docs/architecture.md | 18 ++++++++++- 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index da4cbb7..df37e03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,5 +33,5 @@ hex meshes today and it gives the best elements where stresses are read. | M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `modelcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started | | M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned | | M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned | -| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md) | started | +| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 20 theory cases (patch tests of every solid element, cantilevers, Lamé, Kirsch, beam frequencies, couplings, explicit waves and impact, CDT invariants), 8 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1, LE10, FV32) and 10 pending cases with citations (shells, 2D, thermal, plasticity, Taylor bar, CFD); `mesh.block` mapped blocks build their meshes | started | | Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done), MCP server, browser build, command schema as the public API | started | diff --git a/crates/engine/src/cmd/block.rs b/crates/engine/src/cmd/block.rs index 33a865a..00aeacc 100644 --- a/crates/engine/src/cmd/block.rs +++ b/crates/engine/src/cmd/block.rs @@ -249,7 +249,9 @@ fn check_conforming(model: &Model, kind: ElementKind, mesh: &BlockMesh, elements // The block's boundary faces, by sorted corners, with their full node lists. let mut new_faces: BTreeMap, Vec> = BTreeMap::new(); for &(e, f, _) in &mesh.faces { - let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { continue }; + let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { + continue; + }; new_faces.insert(corners_of(kind, nodes, local), key(local.iter().filter_map(|&i| nodes.get(i).copied()))); } let mut old_faces: BTreeMap, Vec> = BTreeMap::new(); @@ -271,11 +273,17 @@ fn check_conforming(model: &Model, kind: ElementKind, mesh: &BlockMesh, elements } // Hanging nodes: boundary nodes of either side on the other side's element edges. let xyz = |label: u32| model.node(label).map(|n| n.xyz); - let block_points: BTreeMap = - mesh.elements.iter().zip(elements).flat_map(|(pts, labels)| pts.iter().zip(labels).filter_map(|(&p, &l)| Some((l, *mesh.points.get(p as usize)?)))).collect(); + let block_points: BTreeMap = mesh + .elements + .iter() + .zip(elements) + .flat_map(|(pts, labels)| pts.iter().zip(labels).filter_map(|(&p, &l)| Some((l, *mesh.points.get(p as usize)?)))) + .collect(); let mut new_edges: BTreeSet<[u32; 2]> = BTreeSet::new(); for &(e, f, _) in &mesh.faces { - let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { continue }; + let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { + continue; + }; let cs: Vec = local.iter().filter(|&&i| i < kind.corner_count()).filter_map(|&i| nodes.get(i).copied()).collect(); for (i, &a) in cs.iter().enumerate() { let b = cs.get((i + 1) % cs.len()).copied().unwrap_or(a); @@ -389,9 +397,19 @@ mod tests { let c = |v: f64| if v.abs() < 1e-12 { 0.0 } else { v }; json!([c(r * a.cos()), c(r * a.sin()), z]) }; - let corners = json!([at(1.0, k, 0.0), at(2.0, k, 0.0), at(2.0, k + 1, 0.0), at(1.0, k + 1, 0.0), at(1.0, k, 1.0), at(2.0, k, 1.0), at(2.0, k + 1, 1.0), at(1.0, k + 1, 1.0)]); + let corners = json!([ + at(1.0, k, 0.0), + at(2.0, k, 0.0), + at(2.0, k + 1, 0.0), + at(1.0, k + 1, 0.0), + at(1.0, k, 1.0), + at(2.0, k, 1.0), + at(2.0, k + 1, 1.0), + at(1.0, k + 1, 1.0) + ]); let edges = json!([{"corners": [1, 4], "arc": {"center": [0, 0, 0]}}, {"corners": [2, 3], "arc": {"center": [0, 0, 0]}}, {"corners": [5, 8], "arc": {"center": [0, 0, 1]}}, {"corners": [6, 7], "arc": {"center": [0, 0, 1]}}]); - ring.execute("mesh.block", &json!({"body": "Ring", "kind": "hex20", "corners": corners, "divisions": [2, 3, 2], "edges": edges})).unwrap(); + ring.execute("mesh.block", &json!({"body": "Ring", "kind": "hex20", "corners": corners, "divisions": [2, 3, 2], "edges": edges})) + .unwrap(); } assert_eq!(ring.model.elements().len(), 48); assert!(t.execute("mesh.block", &json!({"body": "B", "kind": "hex8", "corners": cube(1.0), "divisions": [1, 1, 1], "merge": 0})).is_ok()); diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index 1939799..34d723b 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -108,7 +108,11 @@ pub struct Expected { } fn text(v: &Value, key: &str) -> Result { - v.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string).ok_or_else(|| format!("`{key}` must be a non-empty string")) + v.get(key) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("`{key}` must be a non-empty string")) } impl Expected { @@ -137,7 +141,11 @@ impl Expected { Some(t) => Tolerance::from_json(t).map_err(context)?, None => return Err(context("`tolerance` is required".into())), }; - let reason = if pending.is_some() { q.get("reason").and_then(Value::as_str).unwrap_or_default().to_string() } else { text(q, "reason").map_err(context)? }; + let reason = if pending.is_some() { + q.get("reason").and_then(Value::as_str).unwrap_or_default().to_string() + } else { + text(q, "reason").map_err(context)? + }; let measure = q.get("measure").cloned().unwrap_or(Value::Null); if pending.is_none() && measure.get("pointer").and_then(Value::as_str).is_none() { return Err(context("`measure` needs a JSON `pointer` into a step's output or a command's".into())); @@ -238,11 +246,7 @@ pub fn run_case(dir: &Path, expected: &Expected) -> Result, String outputs.insert(id.to_string(), out); } } - Ok(expected - .quantities - .iter() - .map(|q| Measured { id: q.id.clone(), value: measure(&mut session, &outputs, &q.measure) }) - .collect()) + Ok(expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: measure(&mut session, &outputs, &q.measure) }).collect()) } /// Read one number: from a recipe step's output (`"from": id`) or a command run now @@ -283,7 +287,9 @@ pub enum Verdict { #[derive(Debug, Clone, PartialEq)] pub enum FixtureCheck { Matches, - Moved { frozen: f64 }, + Moved { + frozen: f64, + }, Missing, /// No fixture is kept (pending, failing or known-failing quantity). NotKept, @@ -366,7 +372,8 @@ pub fn run(root: &Path, options: &Options) -> Result<(), String> { expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: Err("pending".into()) }).collect() } else { let start = Instant::now(); - let m = run_case(dir, &expected).unwrap_or_else(|e| expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: Err(e.clone()) }).collect()); + let m = run_case(dir, &expected) + .unwrap_or_else(|e| expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: Err(e.clone()) }).collect()); eprintln!("oracle: {case} ({:.2} s)", start.elapsed().as_secs_f64()); m }; @@ -405,7 +412,9 @@ pub fn run(root: &Path, options: &Options) -> Result<(), String> { for r in &case_rows { let id = format!("{case}/{}", r.quantity.id); match &r.verdict { - Verdict::Fail => problems.push(format!("{id}: {} vs reference {} ({})", show(r.ours), show(r.quantity.reference), r.quantity.tolerance.describe())), + Verdict::Fail => { + problems.push(format!("{id}: {} vs reference {} ({})", show(r.ours), show(r.quantity.reference), r.quantity.tolerance.describe())) + } Verdict::Fixed => problems.push(format!("{id}: passes now; remove its `known_failure` mark")), Verdict::Error(e) => problems.push(format!("{id}: {e}")), _ => {} @@ -428,11 +437,7 @@ pub fn run(root: &Path, options: &Options) -> Result<(), String> { if !skipped.is_empty() { eprintln!("oracle: skipped slow cases: {}", skipped.join(", ")); } - if problems.is_empty() { - Ok(()) - } else { - Err(format!("{} problem(s):\n {}", problems.len(), problems.join("\n "))) - } + if problems.is_empty() { Ok(()) } else { Err(format!("{} problem(s):\n {}", problems.len(), problems.join("\n "))) } } fn print_summary(rows: &[Row]) { @@ -562,7 +567,10 @@ benchmark, {} experiment; {} pending cases wait for capabilities still being bui "benchmark" => "Published benchmarks", _ => "Experiments", }; - let _ = write!(md, "## {heading}\n\n| Case | Quantity | Reference | ModelCraft | Error | Tolerance | Result | Fixture |\n|---|---|---|---|---|---|---|---|\n"); + let _ = write!( + md, + "## {heading}\n\n| Case | Quantity | Reference | ModelCraft | Error | Tolerance | Result | Fixture |\n|---|---|---|---|---|---|---|---|\n" + ); for (case, _) in &in_kind { for r in rows.iter().filter(|r| &r.case == case) { let mut result = verdict_text(&r.verdict); @@ -597,8 +605,11 @@ benchmark, {} experiment; {} pending cases wait for capabilities still being bui if !pending.is_empty() { md += "\n## Pending cases\n\nReference values already cited; each case gets its recipe when the capability it needs lands.\n\n| Case | Kind | Problem | Needs | Reference values | Source |\n|---|---|---|---|---|---|\n"; for (case, e) in pending { - let values: Vec = - e.quantities.iter().map(|q| format!("{} = {}", cell(&q.description), q.reference.map(number).unwrap_or_else(|| "to be transcribed".into()))).collect(); + let values: Vec = e + .quantities + .iter() + .map(|q| format!("{} = {}", cell(&q.description), q.reference.map(number).unwrap_or_else(|| "to be transcribed".into()))) + .collect(); let _ = writeln!( md, "| [{case}](../tests/oracle/{case}/) | {} | {} | {} | {} | {} |", @@ -655,17 +666,14 @@ mod tests { let pending = json!({"title": "t", "kind": "benchmark", "source": "s", "status": "pending", "requires": "shells", "quantities": [{"id": "a", "quantity": "a", "value": null}]}); assert!(Expected::parse(&pending).unwrap().pending.is_some()); - for (key, bad) in [ - ("kind", json!("guess")), - ("quantities", json!([])), - ("status", json!("maybe")), - ("title", json!("")), - ] { + for (key, bad) in [("kind", json!("guess")), ("quantities", json!([])), ("status", json!("maybe")), ("title", json!(""))] { let mut v = good.clone(); v[key] = bad; assert!(Expected::parse(&v).is_err(), "{key}"); } - for (key, bad) in [("tolerance", json!({})), ("tolerance", json!({"relative": -1})), ("value", json!("x")), ("reason", json!("")), ("measure", json!({}))] { + for (key, bad) in + [("tolerance", json!({})), ("tolerance", json!({"relative": -1})), ("value", json!("x")), ("reason", json!("")), ("measure", json!({}))] + { let mut v = good.clone(); v["quantities"][0][key] = bad; assert!(Expected::parse(&v).is_err(), "{key}"); diff --git a/docs/architecture.md b/docs/architecture.md index ece476f..a348e0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,7 +79,13 @@ Rules beyond the layer order: recombination (Lee and Lo 1994) and one uniform split that lands on the boundary nodes, then smoothing and untangling; every quad is checked with exact predicates. One `SizeField` trait (`modelcraft-mesh::size`) sets interior sizes and edge divisions, with even counts per loop. - Mapped and swept hex build on these face meshes. + Mapped and swept hex build on these face meshes. Without geometry, `mesh.block` + (`modelcraft-mesh::block`) meshes one six-sided block from its eight corners and optional + circular or elliptical edges by the same edge-based transfinite interpolation, as hex8, hex20, + tet4/tet10 (Kuhn's six tetrahedra per cell) or wedge6, with grading; blocks join where their + nodes coincide, face to face (mismatched divisions or kinds are refused), and their named + faces, edges and vertices become geometry tags that sets select. It meshes the analytic + shapes of the verification cases. 6. **Quality.** Scaled Jacobian, aspect ratio, dihedral angles; every generated mesh is checked for positive Jacobians and conserved volume before it reaches the model. @@ -303,3 +309,13 @@ geometry is stored. `cargo xtask corpus` (a release build of the testkit's `corp builds every part, tessellates it and runs each stage of `modelcraft_testkit::runner`, writing docs/meshing/corpus.md. A mesher joins by implementing `runner::Stage` (its columns, and a run over the part's tessellated surfaces) and adding itself to `runner::stages()`. + +## Verification oracle + +`tests/oracle//` holds the verification and validation cases (docs/verification.md): our +own model of each problem as a command recipe (`recipe.json`, optionally a SolidCraft +`design.json`), its cited reference values with tolerances and reasons (`expected.json`), and our +frozen results (`fixture.json`). `cargo xtask oracle` runs the testkit's `oracle` binary +(`modelcraft_testkit::oracle`): one `Session` per case, quantities read by JSON pointer from step +outputs or commands such as `solve.probe {"at": [[x, y, z]]}`, compared with the reference and the +fixture; it writes docs/oracle.md. CI runs it as its own job.