kernel-cases: shells as a graded count, no SMLib verdict where it can't take the record, $OUT

- `"shells": N` in expected.json grades the total number of shells: a body with a sealed void
  holds two, and some joins keep several lumps in one body. Those cases (#3175, #3455, #3526,
  #3730) are graded on validity again instead of being marked not valid.
- When SMLib's record conversion says "not supported yet" (revolved surfaces, traced intersection
  curves), its check gives no verdict. That's shown but not graded, since it isn't a fault of the
  body. solidcraft_kernel::SmlibCheck carries the three outcomes.
- `$OUT` in a recipe is a scratch directory outside the repository, for files a case writes. The
  two STEP-export crash cases write there instead of /tmp.
- cargo xtask kernel-cases --results FILE keeps every result as JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 06:22:02 -04:00
committed by jyviko
parent 75aa8acb6c
commit 678a70da2c
12 changed files with 102 additions and 52 deletions
+35 -11
View File
@@ -14,14 +14,24 @@ fn kernel() -> String {
std::env::var("SOLIDCRAFT_BOOLEANS").ok().filter(|k| !k.is_empty()).unwrap_or_else(|| "default".into())
}
/// The case's commands: a script as is, or an oracle recipe translated; `$CASE` in a string is
/// the case directory (for input files such as `input.step`).
fn script(dir: &str, recipe: &Value) -> Result<Value, String> {
/// The case's commands: a script as is, or an oracle recipe translated. In a string, `$CASE` is
/// the case directory (input files such as `input.step`) and `$OUT` a scratch directory for files
/// the case writes (an export), outside the repository.
fn script(dir: &str, out: &str, recipe: &Value) -> Result<Value, String> {
let s = if recipe.get("commands").is_some() || recipe.is_array() { recipe.clone() } else { recipe::to_script(recipe)? };
let text = serde_json::to_string(&s).map_err(|e| e.to_string())?;
let dir_json = serde_json::to_string(dir).map_err(|e| e.to_string())?;
let dir_inner = dir_json.get(1..dir_json.len().saturating_sub(1)).unwrap_or("");
serde_json::from_str(&text.replace("$CASE", dir_inner)).map_err(|e| e.to_string())
let inner = |p: &str| -> Result<String, String> {
let j = serde_json::to_string(p).map_err(|e| e.to_string())?;
Ok(j.get(1..j.len().saturating_sub(1)).unwrap_or("").to_string())
};
serde_json::from_str(&text.replace("$CASE", &inner(dir)?).replace("$OUT", &inner(out)?)).map_err(|e| e.to_string())
}
/// A scratch directory for one case's output files.
fn scratch(area: &str, name: &str) -> Result<String, String> {
let d = std::env::temp_dir().join(format!("solidcraft-kernel-cases-{}", std::process::id())).join(format!("{area}-{name}"));
std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?;
Ok(d.to_string_lossy().to_string())
}
/// An error that means "this kernel can't do it yet", not a wrong answer.
@@ -52,7 +62,7 @@ fn check_case(dir: &str) -> Value {
}
};
out["sources"] = want["sources"].clone();
let commands = match read_json(&format!("{dir}/recipe.json")).and_then(|r| script(dir, &r)) {
let commands = match scratch(&area, &name).and_then(|o| read_json(&format!("{dir}/recipe.json")).and_then(|r| script(dir, &o, &r))) {
Ok(s) => s,
Err(e) => {
out["status"] = json!("fail");
@@ -105,6 +115,10 @@ fn check_case(dir: &str) -> Value {
let mut bodies = Vec::new();
let mut c = Checks { list: Vec::new(), pass: true };
let want_valid = want["valid"].as_bool().unwrap_or(true);
// Shells: the total where the case gives it (a body with a void, or several lumps in one
// body), else one per body.
let want_shells = want["shells"].as_u64();
let mut shells = 0u64;
for b in &world.bodies {
let m = match solidcraft_engine::kernel::measure(&b.body) {
Ok(m) => m,
@@ -118,13 +132,22 @@ fn check_case(dir: &str) -> Value {
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
"faces": m.merged.faces, "edges": m.merged.edges, "vertices": m.merged.vertices,
}));
shells += m.shells as u64;
if want_valid {
// One closed shell, and every validity check this build has.
c.push("shells", json!(format!("{}: {}", b.name, m.shells)), json!(1), m.shells == 1);
// Closed shells, and every validity check this build has.
if want_shells.is_none() {
c.push("shells", json!(format!("{}: {}", b.name, m.shells)), json!(1), m.shells == 1);
}
let v = solidcraft_engine::kernel::validity(&b.body);
c.push("record_valid", json!(v.record.clone().err().unwrap_or_else(|| "ok".into())), json!("ok"), v.record.is_ok());
if let Some(sm) = &v.smlib {
c.push("smlib_check", json!(sm.clone().err().unwrap_or_else(|| "ok".into())), json!("ok"), sm.is_ok());
match &v.smlib {
Some(solidcraft_engine::kernel::SmlibCheck::Valid) => c.push("smlib_check", json!("ok"), json!("ok"), true),
Some(solidcraft_engine::kernel::SmlibCheck::Invalid(e)) => c.push("smlib_check", json!(e), json!("ok"), false),
// No verdict: the record can't be built in SMLib yet. Shown, not graded.
Some(solidcraft_engine::kernel::SmlibCheck::NotSupported(e)) => {
c.push("smlib_check", json!(format!("no verdict: {e}")), json!("ok"), true)
}
None => {}
}
}
}
@@ -142,6 +165,7 @@ fn check_case(dir: &str) -> Value {
}
};
count(&mut c, "body_count", got.body_count as u64);
count(&mut c, "shells", shells);
count(&mut c, "faces", got.faces);
count(&mut c, "edges", edges);
count(&mut c, "vertices", vertices);
+1 -1
View File
@@ -224,7 +224,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use validity::{Validity, boolean_kernel, smlib_built, validity};
pub use validity::{SmlibCheck, Validity, boolean_kernel, smlib_built, validity};
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+26 -9
View File
@@ -10,12 +10,22 @@ pub struct Validity {
/// The record: structure, then geometry against topology within tolerances.
pub record: Result<(), String>,
/// SMLib's `check(All)` of the record; `None` in a build without SMLib.
pub smlib: Option<Result<(), String>>,
pub smlib: Option<SmlibCheck>,
}
/// SMLib's verdict on a body.
#[derive(Clone, Debug)]
pub enum SmlibCheck {
Valid,
Invalid(String),
/// The record can't be built in SMLib yet (a geometry kind the conversion doesn't take): no
/// verdict, and no fault of the body's.
NotSupported(String),
}
impl Validity {
pub fn ok(&self) -> bool {
self.record.is_ok() && self.smlib.as_ref().is_none_or(Result::is_ok)
self.record.is_ok() && !matches!(self.smlib, Some(SmlibCheck::Invalid(_)))
}
}
@@ -30,16 +40,23 @@ pub fn validity(body: &Body) -> Validity {
}
#[cfg(feature = "smlib")]
fn smlib_validity(body: &Body) -> Option<Result<(), String>> {
let checked = crate::smlib_body(body).map_err(|e| e.to_string()).and_then(|b| {
let r = b.check(solidcraft_smlib::CheckLevel::All).map_err(|e| format!("SMLib check: {e}"))?;
if r.is_valid() { Ok(()) } else { Err(format!("SMLib check: {r}")) }
});
Some(checked)
fn smlib_validity(body: &Body) -> Option<SmlibCheck> {
let b = match crate::smlib_body(body) {
Ok(b) => b,
Err(e) => {
let e = e.to_string();
return Some(if e.contains("not supported yet") { SmlibCheck::NotSupported(e) } else { SmlibCheck::Invalid(e) });
}
};
Some(match b.check(solidcraft_smlib::CheckLevel::All) {
Ok(r) if r.is_valid() => SmlibCheck::Valid,
Ok(r) => SmlibCheck::Invalid(format!("SMLib check: {r}")),
Err(e) => SmlibCheck::Invalid(format!("SMLib check: {e}")),
})
}
#[cfg(not(feature = "smlib"))]
fn smlib_validity(_: &Body) -> Option<Result<(), String>> {
fn smlib_validity(_: &Body) -> Option<SmlibCheck> {
None
}
+4 -2
View File
@@ -43,7 +43,8 @@ Each case has:
- `recipe.json`: an engine command script (`{"commands": [...]}`, as `solidcraft-cli run`
takes) or an oracle recipe (`tests/oracle/*/recipe.json`). `$CASE` in a string is the case
directory, for an input file such as `$CASE/input.step` (written by hand from the STEP or IGES
specification, never a user's file).
specification, never a user's file), and `$OUT` a scratch directory outside the repository for
files the case writes (an export).
- `expected.json`: the right answer, graded by `solidcraft-cli kernel-cases`.
Each area also has `skipped.json` (or several, `skipped-<part>.json`, where an area is triaged in
@@ -75,7 +76,8 @@ parts): every issue and PR looked at that did not become a case, with one line e
| `source` | `analytic` (numbers worked out by hand; `method` gives the formulas) or `invariants` (no number is known: only the outcome, validity and counts are graded). |
| `method` | How the numbers were derived, or which invariants hold and why. |
| `outcome` | `solid` (default: the script succeeds) or `refused` (the right answer is a clean error, never a panic or a hang). |
| `valid` | Default `true`: every body is one closed shell that passes the record's structure and geometry checks and, in builds with SMLib, SMLib's `check(All)`. `false` only where the result is legitimately not a solid. |
| `valid` | Default `true`: every body is one closed shell (or `shells` in all) that passes the record's structure and geometry checks and, in builds with SMLib, SMLib's `check(All)`. `false` only where the result is legitimately not a solid (a sheet). Where SMLib can't take the record yet (a surface kind its conversion lacks), its check gives no verdict and is not graded. |
| `shells` | The total number of shells, where it isn't one per body: a body with a sealed void (2), several lumps kept in one body. |
| `body_count`, `faces`, `edges`, `vertices` | Exact counts, where the geometry fixes them. Faces and edges count whole analytic surfaces and curves once (a full cylinder side is one face). |
| `volume_mm3`, `area_mm2`, `center_of_mass`, `bbox` | As in `tests/oracle`: totals over every body, relative tolerance 1e-3 for volume and area, 1e-3 of the box diagonal for positions. |
| `tolerance` | Optional overrides: `rel`, `abs_mm`. |
@@ -4,8 +4,8 @@
],
"what": "a cut over the bottom edge of a revolved cylinder that holds a sealed spherical void on its axis: the body must not come out empty or refused",
"source": "analytic",
"method": "revolved cylinder r24 h60 minus the sealed sphere r2 at z=30 minus the box x 20..30, y -10..10, z -5..10 where it meets the cylinder: {x>=20, |y|<=10, x^2+y^2<=24^2} is Int from -10 to 10 of (sqrt(576-y^2) - 20) dy = 10*sqrt(476) + 576*asin(10/24) - 400 = 65.725 over z in 0..10. V = pi*24^2*60 - 4/3*pi*2^3 - 10*area = 107882.68. One body that holds two shells (outer and the void), so valid is false: the rig reads more than one shell as not a solid",
"valid": false,
"method": "revolved cylinder r24 h60 minus the sealed sphere r2 at z=30 minus the box x 20..30, y -10..10, z -5..10 where it meets the cylinder: {x>=20, |y|<=10, x^2+y^2<=24^2} is Int from -10 to 10 of (sqrt(576-y^2) - 20) dy = 10*sqrt(476) + 576*asin(10/24) - 400 = 65.725 over z in 0..10. V = pi*24^2*60 - 4/3*pi*2^3 - 10*area = 107882.68. One body with two shells (outer and the void)",
"body_count": 1,
"shells": 2,
"volume_mm3": 107882.68287918373
}
@@ -4,8 +4,8 @@
],
"what": "one combine of 64 separate pegs that do not touch: 36 s on truck, growing 4-9x per doubling; must finish in seconds",
"source": "analytic",
"method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. valid is false only because the one body holds 64 separate shells, which the rig's one-shell check does not allow",
"method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. One body with 64 shells",
"body_count": 1,
"volume_mm3": 801.9529,
"valid": false
}
"shells": 64,
"volume_mm3": 801.9529
}
@@ -5,8 +5,8 @@
],
"what": "intersect of a two-lump target (two spheres joined while apart) with a box holding the bottom half of each",
"source": "analytic",
"method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. valid is false only because the one body holds two separate shells, which the rig's one-shell check does not allow",
"method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. One body with two shells",
"body_count": 1,
"volume_mm3": 268.0826,
"valid": false
}
"shells": 2,
"volume_mm3": 268.0826
}
@@ -834,7 +834,7 @@
{
"command": "file.export",
"params": {
"path": "/tmp/kernel-case-1985.step",
"path": "$OUT/kernel-case-1985.step",
"format": "step"
}
},
@@ -1103,7 +1103,7 @@
{
"command": "file.export",
"params": {
"path": "/tmp/kernel-case-2243.step",
"path": "$OUT/kernel-case-2243.step",
"format": "step"
}
},
@@ -1,16 +1,16 @@
{
"sources": [
"tangency-labs/solidcraft#3730",
"tangency-labs/solidcraft#3872"
],
"what": "a block with a closed cavity written as BREP_WITH_VOIDS whose void shell (outward-facing faces) is turned by ORIENTED_CLOSED_SHELL .F.; the cavity must be taken out, not added",
"source": "analytic",
"method": "V = 40*30*12.5 - 20*6*5 = 15000 - 600; A = 2(40*30+40*12.5+30*12.5) + 2(20*6+20*5+6*5) = 4150 + 500; faces 6+6, edges 12+12, vertices 8+8; valid is false only because a body with a void holds two shells (outer and cavity), which the one-shell validity check does not allow",
"valid": false,
"body_count": 1,
"volume_mm3": 14400.0,
"area_mm2": 4650.0,
"faces": 12,
"edges": 24,
"vertices": 16
"sources": [
"tangency-labs/solidcraft#3730",
"tangency-labs/solidcraft#3872"
],
"what": "a block with a closed cavity written as BREP_WITH_VOIDS whose void shell (outward-facing faces) is turned by ORIENTED_CLOSED_SHELL .F.; the cavity must be taken out, not added",
"source": "analytic",
"method": "V = 40*30*12.5 - 20*6*5 = 15000 - 600; A = 2(40*30+40*12.5+30*12.5) + 2(20*6+20*5+6*5) = 4150 + 500; faces 6+6, edges 12+12, vertices 8+8; one body with two shells (outer and cavity)",
"body_count": 1,
"shells": 2,
"volume_mm3": 14400.0,
"area_mm2": 4650.0,
"faces": 12,
"edges": 24,
"vertices": 16
}
+7 -1
View File
@@ -26,6 +26,8 @@ struct Options {
area: Option<String>,
timeout: Duration,
jobs: usize,
/// Where to keep every case's full result as JSON.
results: Option<String>,
}
fn options(rest: &[&str]) -> Result<Options, String> {
@@ -38,7 +40,7 @@ fn options(rest: &[&str]) -> Result<Options, String> {
};
let secs = arg("--timeout").map(|t| t.parse::<u64>().map_err(|e| format!("--timeout: {e}"))).transpose()?.unwrap_or(DEFAULT_TIMEOUT_S);
let jobs = arg("--jobs").map(|j| j.parse::<usize>().map_err(|e| format!("--jobs: {e}"))).transpose()?.unwrap_or(4).clamp(1, 32);
Ok(Options { kernels, cli: arg("--cli"), area: arg("--area"), timeout: Duration::from_secs(secs), jobs })
Ok(Options { kernels, cli: arg("--cli"), area: arg("--area"), timeout: Duration::from_secs(secs), jobs, results: arg("--results") })
}
fn read(path: &Path) -> Value {
@@ -323,6 +325,10 @@ pub fn run(rest: &[&str]) -> Result<(), String> {
return Err(format!("no cases (recipe.json + expected.json) under {}", dir.display()));
}
let results = run_all(&o, &list);
if let Some(path) = &o.results {
let text = serde_json::to_string_pretty(&results).map_err(|e| e.to_string())?;
std::fs::write(path, text).map_err(|e| format!("{path}: {e}"))?;
}
println!("{}", write_report(&o, &dir, &list, &results)?);
Ok(())
}
+3 -2
View File
@@ -46,10 +46,11 @@ commands:
replay the reference tests (tests/oracle/*/recipe.json), compare with each
case's expected.json and write docs/oracle.md (--cli: a solidcraft-cli
already built, instead of cargo run --release)
kernel-cases [--kernel default|smlib|both] [--area AREA] [--cli BIN] [--timeout S] [--jobs N]
kernel-cases [--kernel default|smlib|both] [--area AREA] [--cli BIN] [--timeout S] [--jobs N] [--results FILE]
the kernel regression rig: run every case in tests/kernel-cases/<area>/<id>/ (one
process each, killed after --timeout, default 180 s) with each kernel asked for,
grade it against the case's own answer and write docs/kernel-cases.md
grade it against the case's own answer and write docs/kernel-cases.md (--results:
every result as JSON too)
action-matrix [--bless] [--results FILE] [--from DIR]
try every ordinary action on every face, edge, vertex and body of the design
corpus and compare with tests/action_matrix/baseline.json (--bless rewrites it;