mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
kernel-cases: base recipes, missing capabilities, and the commands' own time
What the complexity ladder needs from the rig's case runner, kept in the one runner: - A script may name a `base` case directory: that case's commands (and its own base's, up to 32 deep) run first, so a ladder case starts from the previous tier's finished part without copying its commands. `$CASE` is each recipe's own directory; `$OUT` is the running case's scratch directory, in its bases too. - `missing` in expected.json reports the case as not supported yet without running it: a capability with no command or parameter (multi-start threads, fillet setbacks) stays visible instead of being faked with a stand-in. - Each result carries `seconds` (the commands' own time, without process start or grading) and `measured` (body count, face/edge/vertex counts, volume, area), for the ladder's golden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
@@ -14,17 +14,39 @@ fn kernel() -> String {
|
||||
std::env::var("SOLIDCRAFT_BOOLEANS").ok().filter(|k| !k.is_empty()).unwrap_or_else(|| "default".into())
|
||||
}
|
||||
|
||||
/// How many cases a chain of `base` recipes may go through (a ladder case builds on the one
|
||||
/// before it, which builds on the one before that).
|
||||
const MAX_BASE_DEPTH: usize = 32;
|
||||
|
||||
/// 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.
|
||||
/// the case writes (an export), outside the repository. A script with `"base": "<case
|
||||
/// directory>"` (relative to this case's directory) runs that case's commands first: a ladder
|
||||
/// case starts from the previous tier's finished part without copying its commands.
|
||||
fn script(dir: &str, out: &str, recipe: &Value) -> Result<Value, String> {
|
||||
script_at(dir, out, recipe, 0)
|
||||
}
|
||||
|
||||
fn script_at(dir: &str, out: &str, recipe: &Value, depth: usize) -> 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 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())
|
||||
let own: Value = serde_json::from_str(&text.replace("$CASE", &inner(dir)?).replace("$OUT", &inner(out)?)).map_err(|e| e.to_string())?;
|
||||
let Some(base) = recipe.get("base").and_then(Value::as_str) else { return Ok(own) };
|
||||
if depth >= MAX_BASE_DEPTH {
|
||||
return Err(format!("`base` chain deeper than {MAX_BASE_DEPTH} cases"));
|
||||
}
|
||||
let base_dir = std::path::Path::new(dir).join(base).to_string_lossy().to_string();
|
||||
let base_script = read_json(&format!("{base_dir}/recipe.json"))
|
||||
.and_then(|r| script_at(&base_dir, out, &r, depth + 1))
|
||||
.map_err(|e| format!("base {base}: {e}"))?;
|
||||
let list = |v: &Value| v.get("commands").or(Some(v)).and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let mut commands = list(&base_script);
|
||||
commands.extend(list(&own));
|
||||
Ok(json!({"commands": commands}))
|
||||
}
|
||||
|
||||
/// A scratch directory for one case's output files.
|
||||
@@ -62,6 +84,13 @@ fn check_case(dir: &str) -> Value {
|
||||
}
|
||||
};
|
||||
out["sources"] = want["sources"].clone();
|
||||
// A capability SolidCraft doesn't have yet (no command or parameter for it): recorded as a
|
||||
// case so the gap is visible, never run against a stand-in.
|
||||
if let Some(missing) = want["missing"].as_str() {
|
||||
out["status"] = json!("unsupported");
|
||||
out["error"] = json!(format!("not supported yet: {missing}"));
|
||||
return out;
|
||||
}
|
||||
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) => {
|
||||
@@ -88,7 +117,10 @@ fn check_case(dir: &str) -> Value {
|
||||
let refused = outcome == "refused";
|
||||
let may_refuse = refused || outcome == "solid_or_refused";
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
let clock = std::time::Instant::now();
|
||||
let ran = s.run_script(&commands);
|
||||
// The commands' own time, without process start or grading: the ladder's timing golden.
|
||||
out["seconds"] = json!(clock.elapsed().as_secs_f64());
|
||||
match (ran, may_refuse) {
|
||||
(Err(e), _) if crashed(&e.to_string()) => {
|
||||
out["status"] = json!("crash");
|
||||
@@ -159,6 +191,11 @@ fn check_case(dir: &str) -> Value {
|
||||
let got = totals(&json!({"bodies": bodies}));
|
||||
let (edges, vertices): (u64, u64) =
|
||||
bodies.iter().fold((0, 0), |(e, v), b| (e + b["edges"].as_u64().unwrap_or(0), v + b["vertices"].as_u64().unwrap_or(0)));
|
||||
// What the kernel made, graded or not: the ladder's golden records it per kernel.
|
||||
out["measured"] = json!({
|
||||
"body_count": got.body_count, "faces": got.faces, "edges": edges, "vertices": vertices,
|
||||
"volume_mm3": got.volume, "area_mm2": got.area,
|
||||
});
|
||||
let has = |k: &str| want.get(k).is_some_and(|v| !v.is_null());
|
||||
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
|
||||
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
|
||||
@@ -227,3 +264,42 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let bad = results.iter().filter(|r| matches!(r["status"].as_str(), Some("fail" | "crash"))).count();
|
||||
if bad > 0 { Err(format!("{bad} kernel case(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn case_dir(root: &std::path::Path, name: &str, recipe: &Value) -> String {
|
||||
let d = root.join(name);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("recipe.json"), recipe.to_string()).unwrap();
|
||||
d.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_commands_run_first_through_the_chain() {
|
||||
let root = std::env::temp_dir().join(format!("kernel-cases-base-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
case_dir(&root, "a", &json!({"commands": [{"command": "a", "params": {"file": "$OUT/a.step"}}]}));
|
||||
case_dir(&root, "b", &json!({"base": "../a", "commands": [{"command": "b"}]}));
|
||||
let c = case_dir(&root, "c", &json!({"base": "../b", "commands": [{"command": "c", "params": {"file": "$CASE/x"}}]}));
|
||||
let r = read_json(&format!("{c}/recipe.json")).unwrap();
|
||||
let s = script(&c, "/scratch", &r).unwrap();
|
||||
let ids: Vec<&str> = s["commands"].as_array().unwrap().iter().map(|c| c["command"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["a", "b", "c"]);
|
||||
assert_eq!(s["commands"][2]["params"]["file"], json!(format!("{c}/x")));
|
||||
// `$OUT` is the running case's scratch directory, in its bases too.
|
||||
assert_eq!(s["commands"][0]["params"]["file"], json!("/scratch/a.step"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_base_cycle_is_an_error_not_a_hang() {
|
||||
let root = std::env::temp_dir().join(format!("kernel-cases-cycle-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let a = case_dir(&root, "a", &json!({"base": "../a", "commands": []}));
|
||||
let r = read_json(&format!("{a}/recipe.json")).unwrap();
|
||||
assert!(script(&a, "/scratch", &r).unwrap_err().contains("deeper than"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user