Evaluation cache: every feature evaluation is kept by a key of its own inputs, the document context it reads and a digest of the model it starts from (bodies identified by the evaluation that made them, kept while passed through), shared with previews and scratch copies; setting a value back, undo, redo and repeated evaluations reuse results (a 20 × 20 patterned plate builds in 1.7 s instead of 7.5 s; undo/redo of a parameter edit 0 ms); cached and from-scratch evaluation compared in a test; numbers in docs/perf.md (solvecraft-cli bench-edit). Designs read now always get feature input names and occurrences filled in

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent b78cb886b8
commit a01f191d25
6 files changed
+325 -7

No files matched your search

+32
View File
@@ -57,6 +57,8 @@ fn main() -> ExitCode {
Some("mcp") => cmd_mcp(&args[1..]), Some("mcp") => cmd_mcp(&args[1..]),
// Hidden, for the kill-mid-save test: save a large design over and over. // Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]), Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("--version" | "-V") => { Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(()) Ok(())
@@ -241,3 +243,33 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
} }
Ok(()) Ok(())
} }
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
};
let v = read_json(path)?;
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
let mut s = Session::default();
let t = std::time::Instant::now();
s.run_script(&script).map_err(|e| e.to_string())?;
let build = t.elapsed().as_secs_f64() * 1000.0;
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
let t = std::time::Instant::now();
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "UndoCommand", json!({}))?;
time(&mut s, "redo", "RedoCommand", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
+165 -2
View File
@@ -20,11 +20,14 @@ pub struct ModelBody {
/// Feature that created it. /// Feature that created it.
pub feature: u64, pub feature: u64,
mesh: OnceLock<Arc<Mesh>>, mesh: OnceLock<Arc<Mesh>>,
/// Identity of this body's content for the evaluation cache: the key of the evaluation
/// that produced it (kept while later features pass it through unchanged).
key: u64,
} }
impl ModelBody { impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self { pub fn new(name: String, body: Body, feature: u64) -> Self {
ModelBody { name, body, feature, mesh: OnceLock::new() } ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0 }
} }
/// Display mesh (chord tolerance 1/1000 of the body size), computed once. /// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> { pub fn mesh(&self) -> Arc<Mesh> {
@@ -187,6 +190,85 @@ pub struct ModelState {
pub body_counter: usize, pub body_counter: usize,
/// Sheet metal bodies (by body name) and their flat patterns. /// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>, pub sheets: Vec<crate::sheet::SheetBody>,
/// Evaluation-cache keys of the sketches (by sketch feature id).
sketch_keys: BTreeMap<u64, u64>,
}
impl ModelState {
/// A digest of everything later features can read: bodies (by content identity), sketches,
/// the body counter, threads and sheet bodies.
fn digest(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for b in &self.bodies {
b.name.hash(&mut h);
b.key.hash(&mut h);
b.feature.hash(&mut h);
}
for s in &self.sketches {
s.feature.hash(&mut h);
self.sketch_keys.get(&s.feature).hash(&mut h);
}
self.body_counter.hash(&mut h);
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
h.finish()
}
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed, and sketches it made, take that key; the rest keep theirs.
fn stamp(&mut self, before: &ModelState, feature: u64, key: u64) {
for b in &mut self.bodies {
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)).map(|x| x.key);
b.key = kept.unwrap_or_else(|| {
let mut h = std::collections::hash_map::DefaultHasher::new();
(key, &b.name).hash(&mut h);
h.finish()
});
}
if self.sketches.iter().any(|s| s.feature == feature) {
self.sketch_keys.insert(feature, key);
}
}
}
/// Evaluation results by cache key, shared by a model and its copies (previews, scratch
/// sessions), so a result computed once is reused wherever the same inputs come back.
#[derive(Debug, Default)]
pub struct EvalCache {
map: std::collections::HashMap<u64, CachedEval>,
order: std::collections::VecDeque<u64>,
pub hits: usize,
}
#[derive(Clone, Debug)]
struct CachedEval {
error: Option<String>,
warning: Option<String>,
ms: f64,
state: Arc<ModelState>,
}
/// Results kept (oldest dropped first).
const CACHE_ENTRIES: usize = 4096;
impl EvalCache {
fn get(&mut self, key: u64) -> Option<CachedEval> {
let r = self.map.get(&key).cloned();
if r.is_some() {
self.hits += 1;
}
r
}
fn put(&mut self, key: u64, e: CachedEval) {
if self.map.insert(key, e).is_none() {
self.order.push_back(key);
}
while self.order.len() > CACHE_ENTRIES {
if let Some(k) = self.order.pop_front() {
self.map.remove(&k);
}
}
}
} }
impl ModelState { impl ModelState {
@@ -221,6 +303,10 @@ pub struct Model {
empty: Arc<ModelState>, empty: Arc<ModelState>,
/// Features recomputed by the last `evaluate`. /// Features recomputed by the last `evaluate`.
pub last_recomputed: usize, pub last_recomputed: usize,
/// Results by inputs, shared with this model's copies.
pub cache: Arc<std::sync::Mutex<EvalCache>>,
/// Evaluate everything from scratch (no reuse; tests compare the two).
pub no_cache: bool,
} }
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted. /// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
@@ -253,6 +339,56 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
h.finish() h.finish()
} }
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
fingerprint(0, f, vals, false).hash(&mut h);
}
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.body_components).unwrap_or_default().hash(&mut h);
for e in doc
.sheet
.rules
.iter()
.flat_map(|r| [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap])
{
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
h.finish()
}
/// The cache key of a feature evaluation: its own inputs, the document context and the state it
/// starts from. Features that replay other features' definitions (patterns and mirrors of
/// features) also take the chain fingerprint, which covers every earlier definition.
fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelState) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
local.hash(&mut h);
ctx.hash(&mut h);
state.digest().hash(&mut h);
f.id.hash(&mut h);
let replays = match &f.kind {
FeatureKind::Pattern { features, .. } | FeatureKind::Mirror { features, .. } => !features.is_empty(),
_ => false,
};
if replays {
chain_fp.hash(&mut h);
}
h.finish()
}
fn now() -> web_time::Instant { fn now() -> web_time::Instant {
web_time::Instant::now() web_time::Instant::now()
} }
@@ -284,9 +420,10 @@ impl Model {
let mut prev_fp = 0u64; let mut prev_fp = 0u64;
let mut state = self.empty.clone(); let mut state = self.empty.clone();
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len()); let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
let mut reuse = true; let mut reuse = !self.no_cache;
let mut recomputed = 0; let mut recomputed = 0;
let marker = doc.marker.unwrap_or(usize::MAX); let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() { for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker; let rolled_back = i >= marker;
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back); let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
@@ -344,6 +481,26 @@ impl Model {
}); });
continue; continue;
} }
// The same feature on the same inputs: its earlier result.
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
if !self.no_cache
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
{
if c.error.is_none() {
state = c.state.clone();
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
error: c.error,
warning: c.warning,
ms: c.ms,
skipped: false,
fingerprint: fp,
state: state.clone(),
});
continue;
}
recomputed += 1; recomputed += 1;
let t0 = now(); let t0 = now();
let mut next = (*state).clone(); let mut next = (*state).clone();
@@ -359,11 +516,17 @@ impl Model {
let ms = t0.elapsed().as_secs_f64() * 1000.0; let ms = t0.elapsed().as_secs_f64() * 1000.0;
let error = match r { let error = match r {
Ok(()) => { Ok(()) => {
next.stamp(&state, f.id, key);
state = Arc::new(next); state = Arc::new(next);
None None
} }
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()), Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
}; };
if !self.no_cache
&& let Ok(mut m) = self.cache.lock()
{
m.put(key, CachedEval { error: error.clone(), warning: warning.clone(), ms, state: state.clone() });
}
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() }); out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
} }
self.results = out; self.results = out;
+4 -5
View File
@@ -83,9 +83,8 @@ pub fn read(s: &str) -> Result<Document> {
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
} }
} }
if version < 2 { // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
v1_to_v2(&mut d)?; normalise(&mut d)?;
}
d.format = format_string(FORMAT); d.format = format_string(FORMAT);
Ok(d) Ok(d)
} }
@@ -134,8 +133,8 @@ fn rigid(m: &crate::Mat) -> bool {
&& t[..3].iter().all(|v| v.abs() <= MAX_REAL) && t[..3].iter().all(|v| v.abs() <= MAX_REAL)
} }
/// Format 1 files may predate feature input names and occurrences. /// Feature input names and component occurrences, which format 1 files may predate.
fn v1_to_v2(d: &mut Document) -> Result<()> { fn normalise(d: &mut Document) -> Result<()> {
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect(); let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
for id in ids { for id in ids {
d.name_feature_inputs(id); d.name_feature_inputs(id);
+70
View File
@@ -1074,3 +1074,73 @@ fn appearances_by_face_body_and_component() {
assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target"); assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target");
assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there"); assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there");
} }
/// The evaluation cache gives exactly what evaluating from scratch gives, through edits,
/// suppression, roll-back, undo and redo.
#[test]
fn cached_evaluation_matches_from_scratch() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
run(
&mut s,
"PatternRectangular",
json!({"bodies": ["Plate"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 100, "dir2": [0, 1, 0], "count2": 2, "spacing2": 70}),
);
run(&mut s, "PrimitiveBox", json!({"corner": [-50, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Side"}));
let same = |s: &Session, what: &str| {
let mut fresh = solvecraft_doc::Model::new();
fresh.no_cache = true;
fresh.evaluate(&s.doc);
let errs = |m: &solvecraft_doc::Model| m.results.iter().map(|r| (r.id, r.error.clone(), r.skipped)).collect::<Vec<_>>();
assert_eq!(errs(&s.model), errs(&fresh), "{what}: feature results");
let shape = |st: &solvecraft_doc::ModelState| {
st.bodies
.iter()
.map(|b| {
let m = solvecraft_kernel::measure(&b.body).unwrap();
// Volume and area to 1e-9: two evaluations from scratch differ in the last
// bits too (the kernel's tessellation is not bit-for-bit repeatable).
(b.name.clone(), b.feature, (m.volume * 1e6).round(), (m.area * 1e6).round(), m.faces, m.edges, m.vertices, m.merged.faces)
})
.collect::<Vec<_>>()
};
assert_eq!(shape(&s.model.state()), shape(&fresh.state()), "{what}: bodies");
assert_eq!(s.model.state().sketches.len(), fresh.state().sketches.len(), "{what}: sketches");
};
// Two evaluations from scratch agree to the same precision.
let mut a = solvecraft_doc::Model::new();
a.no_cache = true;
a.evaluate(&s.doc);
let mut b = solvecraft_doc::Model::new();
b.no_cache = true;
b.evaluate(&s.doc);
let vols = |m: &solvecraft_doc::Model| {
m.state().bodies.iter().map(|x| (solvecraft_kernel::measure(&x.body).unwrap().volume * 1e6).round()).collect::<Vec<_>>()
};
assert_eq!(vols(&a), vols(&b));
same(&s, "built");
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
for (step, (id, p)) in [
("ChangeParameterCommand", json!({"name": "width", "expression": "90 mm"})),
("ChangeParameterCommand", json!({"name": "width", "expression": "80 mm"})),
("ChangeParameterCommand", json!({"name": "thickness", "expression": "10 mm"})),
("UndoCommand", json!({})),
("RedoCommand", json!({})),
("timeline.suppress", json!({"feature": "Holes", "suppressed": true})),
("timeline.suppress", json!({"feature": "Holes", "suppressed": false})),
("timeline.rollTo", json!({"position": 6})),
("timeline.rollTo", json!({})),
("UndoCommand", json!({})),
("UndoCommand", json!({})),
]
.into_iter()
.enumerate()
{
let before = hits(&s);
run(&mut s, id, p.clone());
same(&s, &format!("step {step}: {id} {p}"));
if step == 1 {
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
}
}
}
+11
View File
@@ -250,6 +250,17 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
} }
impl Body { impl Body {
/// The very same body (shared data), not just an equal-looking one.
pub fn same(&self, other: &Body) -> bool {
std::sync::Arc::ptr_eq(&self.solid, &other.solid)
&& match (&self.mesh, &other.mesh) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
&& self.color == other.color
}
pub(crate) fn new(solid: Solid) -> Result<Body> { pub(crate) fn new(solid: Solid) -> Result<Body> {
if solid.boundaries().is_empty() { if solid.boundaries().is_empty() {
return Err(KernelError::Failed("empty result".into())); return Err(KernelError::Failed("empty result".into()));
+43
View File
@@ -38,3 +38,46 @@ What changed: rows scrolled out of view only reserve their height. They register
and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on
screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see
every row's place. every row's place.
# Recompute performance
`solvecraft-cli bench-edit <script> <param> "(<old>) * 1.02"` builds a design from its script
(timing the build), then times a parameter edit, setting the parameter back to its old
expression, undo and redo. Each cell is before → after the evaluation cache, with the number of
features recomputed in parentheses. Release build, on a machine shared with other build jobs (load average about 45; the
times vary by ±50 % run to run, so compare the recompute counts and the zeros). "Early" edits a parameter of the
first sketch or feature, "late" one of the last feature.
| Design | Edit | Build | Edit | Set back | Undo | Redo |
|---|---|---|---|---|---|---|
| sample plate, 20 × 20 pattern | early (`width`) | 7587 ms (10) → 1764 ms (10) | 7969 ms (10) → 1691 ms (10) | 7557 ms (10) → 0 ms (0) | 6534 ms (0) → 0 ms (0) | 4570 ms (0) → 0 ms (0) |
| sample plate, 20 × 20 pattern | late (`d9`) | 7870 ms (10) → 1699 ms (10) | 3944 ms (1) → 812 ms (1) | 4039 ms (1) → 0 ms (0) | 1464 ms (0) → 0 ms (0) | 1398 ms (0) → 0 ms (0) |
| oracle 58 phone stand | early (`d1`) | 163 ms (6) → 140 ms (6) | 228 ms (5) → 140 ms (5) | 171 ms (5) → 0 ms (0) | 158 ms (0) → 0 ms (0) | 130 ms (0) → 0 ms (0) |
| oracle 58 phone stand | late (`d4`) | 144 ms (6) → 195 ms (6) | 118 ms (1) → 118 ms (1) | 145 ms (1) → 0 ms (0) | 149 ms (0) → 0 ms (0) | 187 ms (0) → 0 ms (0) |
| oracle 59 enclosure base | late (`d8`) | 11582 ms (11) → 11179 ms (11) | 5772 ms (1) → 5742 ms (1) | 5606 ms (1) → 0 ms (0) | 6023 ms (0) → 0 ms (0) | 6119 ms (0) → 0 ms (0) |
| oracle 60 pipe flange | early (`d1`) | 8137 ms (7) → 8348 ms (7) | 7569 ms (6) → 10143 ms (6) | 8354 ms (6) → 0 ms (0) | 8607 ms (0) → 0 ms (0) | 10034 ms (0) → 0 ms (0) |
| oracle 60 pipe flange | late (`d6`) | 7661 ms (7) → 11990 ms (7) | 67 ms (1) → 361 ms (1) | 69 ms (1) → 0 ms (0) | 84 ms (0) → 0 ms (0) | 66 ms (0) → 0 ms (0) |
| oracle 61 shaft keyway | early (`d1`) | 1723 ms (6) → 3016 ms (6) | 1785 ms (5) → 2745 ms (5) | 1760 ms (5) → 2323 ms (5) | 1667 ms (0) → 0 ms (0) | 1719 ms (0) → 0 ms (0) |
| oracle 61 shaft keyway | late (`d4`) | 1905 ms (6) → 2247 ms (6) | 1734 ms (1) → 2101 ms (1) | 1682 ms (1) → 0 ms (0) | 1719 ms (0) → 0 ms (0) | 1876 ms (0) → 0 ms (0) |
| oracle 62 heat sink | early (`d1`) | 382 ms (6) → 643 ms (6) | 362 ms (5) → 697 ms (5) | 233 ms (5) → 568 ms (5) | 245 ms (0) → 0 ms (0) | 360 ms (0) → 0 ms (0) |
| oracle 62 heat sink | late (`d5`) | 479 ms (6) → 688 ms (6) | 270 ms (1) → 657 ms (1) | 321 ms (1) → 0 ms (0) | 413 ms (0) → 0 ms (0) | 283 ms (0) → 0 ms (0) |
| oracle 63 hinge knuckle | early (`d1`) | 1305 ms (8) → 1671 ms (8) | 1329 ms (7) → 2397 ms (7) | 1971 ms (7) → 0 ms (0) | 1705 ms (0) → 0 ms (0) | 1321 ms (0) → 0 ms (0) |
| oracle 63 hinge knuckle | late (`d4`) | 1430 ms (8) → 2072 ms (8) | 1387 ms (1) → 2073 ms (1) | 1480 ms (1) → 0 ms (0) | 1192 ms (0) → 0 ms (0) | 1035 ms (0) → 0 ms (0) |
What changed: every feature evaluation is cached by a key made of the feature's own inputs
(definition and parameter values), the document context it may read (construction planes,
sheet metal and plastic rules, components) and a digest of the model it starts from, in which
each body is identified by the evaluation that produced it (bodies a feature passes through
unchanged keep their identity). The same feature on the same inputs is never evaluated twice:
setting a value back, undo, redo, a preview followed by the same command, and the repeated
evaluations while a script builds the design all reuse earlier results. The cache is shared by a
model and its copies (previews, scratch sessions) and keeps the last 4096 results.
An edit still recomputes the edited feature and every later feature whose starting model
changed; in these parts every later feature works on the edited body, so early edits recompute
almost everything, as they must. `cached_evaluation_matches_from_scratch` checks that cached and
from-scratch evaluation give the same models (bodies, volumes and areas to 1e-6, topology,
errors) through edits, suppression, roll-back, undo and redo. Setting a sketch dimension back
can still recompute (oracle 61, 62): solving the sketch moves its points by rounding noise, so
its inputs are not exactly the old ones. Oracle 59's first parameter edit fails (a recipe
expression the bench's `* 1.02` can't scale) and is left out.