forked from 1b-agent/fieldcraft-upstream
docs: oracle in architecture and roadmap; fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
6d9452ad84
commit
cbff230734
4 files changed
+75
-33
No files matched your search
+1
-1
@@ -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 |
|
||||
@@ -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<u32>, Vec<u32>> = 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<u32>, Vec<u32>> = 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<u32, [f64; 3]> =
|
||||
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<u32, [f64; 3]> = 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<u32> = 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());
|
||||
|
||||
@@ -108,7 +108,11 @@ pub struct Expected {
|
||||
}
|
||||
|
||||
fn text(v: &Value, key: &str) -> Result<String, 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"))
|
||||
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<Vec<Measured>, 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<String> =
|
||||
e.quantities.iter().map(|q| format!("{} = {}", cell(&q.description), q.reference.map(number).unwrap_or_else(|| "to be transcribed".into()))).collect();
|
||||
let values: Vec<String> = 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}");
|
||||
|
||||
+17
-1
@@ -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/<case>/` 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.
|
||||
Reference in new issue
Block a user