partforge 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1964,9 +1964,14 @@ carries:
1964
1964
  Subpart facts include `minWall` (number or `null` — null exactly when no reading
1965
1965
  exists, e.g. the OCCT backend or min-wall measurement turned off, matching
1966
1966
  `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`). Min wall casts one ray
1967
- per triangle, which is unbounded work on a dense mesh, so past 50,000 triangles
1967
+ per triangle, which is unbounded work on a dense mesh, so past a sample budget
1968
1968
  it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
1969
1969
  and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
1970
+ **The budget depends on whether the reading is checked against anything**: a part
1971
+ that declares a min-wall gate — a `verify.process` profile, or an `expect`
1972
+ mentioning `minWall` — gets 50,000, because a gate's verdict rides on it; a part
1973
+ that declares neither gets 5,000, because there the number is a diagnostic for a
1974
+ reader rather than an assertion. Declaring the gate is what buys the resolution.
1970
1975
  `sampled` is how many triangles the walk *selected*, not how many rays were
1971
1976
  cast: a degenerate (zero-area) triangle has no normal to cast along and is
1972
1977
  skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
@@ -1980,6 +1985,24 @@ Overlap entries are
1980
1985
  `{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
1981
1986
  pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
1982
1987
  `nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
1988
+ `measuredGaps` is the companion to `measuredMinWall` for that pass, and `gaps` is
1989
+ **absent** rather than empty when it did not run — an empty table means "measured,
1990
+ and these pairs have no distance", which a declared `clearance` gate fails on.
1991
+
1992
+ ### Quick checks
1993
+
1994
+ An editor may ask for a **quick** check, which skips both ray-casting passes — min
1995
+ wall and pair distances — and keeps everything derived from the build itself:
1996
+ triangles, bbox, volume, genus, watertight, the assembly overlap check, and lint.
1997
+ On a 460k-triangle assembly that is roughly 6.8 s down to 0.9 s.
1998
+
1999
+ Gates still run on a quick check wherever the facts allow it, so a violated `bbox`
2000
+ or `holes` expectation still fails. What a quick check will **never** do is return a
2001
+ pass: any gate it could not evaluate is listed in `verify.unevaluated`, and one such
2002
+ gate makes `verify.ok` **`null`** rather than `true`. So `ok` is tri-state — `false`
2003
+ (something failed), `null` (nothing failed, but something went unchecked), `true`
2004
+ (everything declared was checked and passed) — and code that treats a truthy `ok` as
2005
+ "passed" stays correct without changing. Run a full check before trusting a part.
1983
2006
 
1984
2007
  A **thrown** error (bad part module, kernel failure) with `--json` prints pure
1985
2008
  JSON to stdout and exits 1:
@@ -98,11 +98,25 @@ them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothin
98
98
  a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
99
99
  have gone unbuilt for three consecutive rebinds.
100
100
 
101
+ `beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
102
+ commits a round, and an inner pair is a balanced no-op. Nesting is real rather than
103
+ theoretical — `buildView` opens a round of its own, so any caller that brackets around
104
+ a view build contains one. A backend that keeps a single open round (rather than a
105
+ stack) must collapse inner pairs this way; committing on the inner `end()` would close
106
+ the outer round early and leave the rest of that build uncached.
107
+
101
108
  Sub-part brackets bound cache RETENTION, not reuse: a solid one sub-part builds is reused
102
109
  by any other that asks for the same content hash, so a sheet of identical cells split
103
110
  across row sub-parts evaluates each distinct cell once rather than once per row. An adopted
104
111
  entry is retained by both partitions and disposed only when the last one drops it.
105
112
 
113
+ The oracle (`buildView`, `assemblyOverlaps`) brackets under partition names of its
114
+ own rather than the display sub-part names, and a host adding another oracle-side build
115
+ should do the same. Both reuse the display build's solids through the cross-partition
116
+ index, so measuring a view costs almost nothing right after drawing it; keeping them in
117
+ separate partitions is what stops a measurement's own geometry — verify walks cases with
118
+ params of their own — from displacing the geometry the viewer is showing.
119
+
106
120
  **Transform hoisting.** Booleans commute with rigid transforms, so a conforming backend MAY
107
121
  lift a transform every operand shares out of the boolean and apply it to the result
108
122
  instead — which is what lets N identically-built copies share one evaluation. Two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.70.0",
3
+ "version": "0.72.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,10 +9,17 @@ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
9
9
  // → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
10
10
  export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
11
11
  const { p, d } = resolveParams(part, params);
12
- const posed = viewSubParts(part, view, p).map((name) => ({
13
- name,
14
- solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
15
- }));
12
+ // Same posed solids buildView builds, so this round is almost entirely hits off
13
+ // that one; only the pairwise intersects below are new. Its own oracle partition,
14
+ // for the reason buildView's comment gives.
15
+ kernel.beginSubPart?.(`oracle:overlaps:${view}`);
16
+ let posed;
17
+ try {
18
+ posed = viewSubParts(part, view, p).map((name) => ({
19
+ name,
20
+ solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
21
+ }));
22
+ } catch (e) { kernel.endSubPart?.(); throw e; } // never strand the round on a failed build
16
23
 
17
24
  const overlaps = [];
18
25
  for (let i = 0; i < posed.length; i++) {
@@ -27,6 +34,7 @@ export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance =
27
34
  }
28
35
  }
29
36
  }
30
- kernel.cleanup?.(); // free the per-check WASM objects
37
+ kernel.endSubPart?.();
38
+ kernel.cleanup?.(); // free the per-check WASM objects (cached solids are pinned)
31
39
  return overlaps;
32
40
  }
@@ -15,6 +15,7 @@ export function createSolidCache() {
15
15
  const lastBuilt = new Map(); // name -> rebind generation of the partition's last begin()
16
16
  let generation = 0; // bumped only by sweep() (i.e. per part rebind)
17
17
  let name = null, active = null, prev = null;
18
+ let depth = 0; // bracket nesting; only the OUTERMOST one is real (see begin)
18
19
  let hits = 0, misses = 0;
19
20
 
20
21
  // One partition stops retaining `entry`. Disposal waits for the LAST holder:
@@ -30,9 +31,19 @@ export function createSolidCache() {
30
31
  };
31
32
 
32
33
  return {
33
- begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
34
+ // Nested brackets collapse into the outermost one. There is a single open
35
+ // round (name/active/prev), not a stack, so an inner begin() would otherwise
36
+ // rebind it and the inner end() would commit-and-close the OUTER round early —
37
+ // evicting its entries and leaving the rest of that build uncached. Callers
38
+ // nest legitimately now that buildView brackets: an outer bracket is free to
39
+ // contain one, and the inner pair becomes a no-op.
40
+ begin(n) {
41
+ if (depth++ > 0) return;
42
+ name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map();
43
+ },
34
44
 
35
45
  end() {
46
+ if (depth > 0 && --depth > 0) return; // inner bracket — the outer one still owns the round
36
47
  if (name == null) return;
37
48
  for (const [hash, entry] of prev) {
38
49
  if (!active.has(hash)) release(hash, entry); // this partition drops it
@@ -241,10 +241,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
241
241
  // an unparameterized inspect that case IS this measurement. Seeding it in
242
242
  // (see verify.js's seeding block for the min-wall superset rule that makes
243
243
  // the reuse sound) stops the oracle from rebuilding the same geometry and
244
- // re-casting the same min-wall rays a second time. Measuring `{ minWall:
245
- // true }` here is what makes the seed usable by any verify run, min-wall
246
- // gated or not — the result says so itself (`measuredMinWall`), so this
247
- // call and the seed cannot drift apart.
244
+ // re-casting the same min-wall rays a second time. On a full lap the seed is
245
+ // usable by any verify run, min-wall gated or not, because the pass ran — and
246
+ // the result says so ITSELF (`measuredMinWall`/`measuredGaps`), never a claim
247
+ // by this caller, so the two cannot drift apart. On a quick lap the passes did
248
+ // not run, the stamps say false, and verify reports what it could not check.
248
249
  //
249
250
  // The view is built HERE rather than inside measure, and handed down through
250
251
  // `opts.built`, because optional match scoring needs the same meshes: one build
@@ -259,15 +260,31 @@ export async function handle(kernel, part, msg, post, opts = {}) {
259
260
  // measure built internally and its own signature default hid this. Found by a
260
261
  // live browser check, not by tests: this suite passes explicit views, and the
261
262
  // cloud's unit tests fake the worker.
263
+ //
264
+ // `checks: "quick"` is the agent's fast lap. Min wall and pair distances are
265
+ // the oracle's two ray-casting passes and, profiled on a 460k-triangle
266
+ // assembly, 79% of its cost — and they SHARE the BVH those rays need, so
267
+ // skipping one leaves the index build standing and saves about half of what
268
+ // skipping both does. Everything else is derived from the build this job
269
+ // already paid for and stays: triangles, bbox, volume, genus, watertight,
270
+ // and the assembly overlap check. verify still runs — the gates that read
271
+ // those facts are free — and reports what it could not evaluate rather than
272
+ // passing it, which is why `quick` can be honoured on a gated part instead
273
+ // of refused. Anything other than the literal "quick" is the full lap: an
274
+ // unrecognized value must never quietly buy less checking than the caller
275
+ // asked for.
276
+ const quick = msg.checks === "quick";
262
277
  const view = msg.view ?? Object.keys(part.views)[0];
263
278
  const built = buildView(kernel, part, view, msg.params ?? {});
264
- const measured = measure(kernel, part, view, msg.params ?? {}, { minWall: true, built });
279
+ const measured = measure(kernel, part, view, msg.params ?? {},
280
+ { minWall: !quick, gaps: !quick, built });
265
281
  const report = {
266
282
  measure: measured,
267
283
  verify: verify(kernel, part, {
268
284
  // The defaulted view, not msg.view: the seed below was measured on it, and
269
285
  // verify's seed reuse is only sound when both name the same view.
270
286
  view,
287
+ quick,
271
288
  seed: { params: msg.params ?? {}, result: measured },
272
289
  }),
273
290
  };
@@ -7,8 +7,19 @@ import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
7
7
  // before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
8
8
  export function buildView(kernel, part, view, params = {}) {
9
9
  const { p, d } = resolveParams(part, params);
10
- return viewSubParts(part, view, p).map((name) => {
11
- const solid = buildPosed(kernel, part, name, { purpose: "display", view, p, d });
12
- return { name, solid, mesh: solid.toMesh() };
13
- });
10
+ // Cache round for the whole view. The name is the ORACLE's, deliberately not the
11
+ // display sub-part names the generate path brackets under: a distinct partition
12
+ // still reuses those solids (the cache indexes entries by content hash across
13
+ // partitions), while keeping this round's own eviction away from the geometry the
14
+ // viewer is showing — bracketing under the display names would make running the
15
+ // oracle throw away the display cache. One name per view also bounds retention:
16
+ // verify walks its cases through here, so each case evicts the previous rather
17
+ // than accumulating every case's geometry at once.
18
+ kernel.beginSubPart?.(`oracle:view:${view}`);
19
+ try {
20
+ return viewSubParts(part, view, p).map((name) => {
21
+ const solid = buildPosed(kernel, part, name, { purpose: "display", view, p, d });
22
+ return { name, solid, mesh: solid.toMesh() };
23
+ });
24
+ } finally { kernel.endSubPart?.(); }
14
25
  }
@@ -0,0 +1,50 @@
1
+ // Which declared gates a part actually has — the questions the oracle asks before
2
+ // deciding how hard to work. Kept out of verify.js so measure() can ask the same
3
+ // question without importing verify (which imports measure), and so there is ONE
4
+ // definition of "does this part gate on min wall": measure sizes its sample budget
5
+ // by it and verify decides whether to measure it at all. Two derivations that drift
6
+ // would put a coarse reading behind a real gate, which is exactly the trap verify's
7
+ // seeding block guards against.
8
+ //
9
+ // Every function here is TOTAL. A part whose profile name is unknown, or whose
10
+ // `expect` function throws, is reported as GATED — the conservative direction, since
11
+ // that is the full-resolution behaviour every part had before budgets existed. The
12
+ // real error surfaces from verify, which is where a reader can act on it.
13
+ import { resolveProfile } from "./dfm-profiles.js";
14
+ import { expandCases } from "./cases.js";
15
+ import { resolveParams } from "../part-model.js";
16
+
17
+ // `expect` may be a function of a case's resolved params, so the answer is a property
18
+ // of the EXPANDED cases, not of the raw spec — which means answering it costs a call
19
+ // to the PART'S OWN code, once per case. Both callers here are on hot paths (verify
20
+ // expands for its case loop; measure asks the gate question on every call, and verify
21
+ // calls measure once per case), so an unmemoized expansion would invoke a part's
22
+ // `expect` O(cases²) times per report. Memoized by part identity, and re-derived if
23
+ // the spec object behind that identity was swapped — the realistic mutation, and the
24
+ // one a bare WeakMap would serve staleness for.
25
+ const expansions = new WeakMap();
26
+
27
+ export function expandExpectations(part) {
28
+ const spec = part?.verify?.expect ?? {};
29
+ const hit = expansions.get(part);
30
+ if (hit && hit.spec === spec) return hit.expanded;
31
+ const expanded = typeof spec !== "function"
32
+ ? expandCases(part).map((c) => ({ ...c, expect: spec }))
33
+ : expandCases(part).map((c) => {
34
+ const { p, d } = resolveParams(part, c.params);
35
+ return { ...c, expect: spec(p, d) ?? {} };
36
+ });
37
+ if (part && typeof part === "object") expansions.set(part, { spec, expanded });
38
+ return expanded;
39
+ }
40
+
41
+ export function partGatesMinWall(part, { process, expanded } = {}) {
42
+ try {
43
+ const spec = process ?? part?.verify?.process;
44
+ if (spec && resolveProfile(spec)?.minWall != null) return true;
45
+ return (expanded ?? expandExpectations(part)).some(({ expect }) =>
46
+ Object.values(expect ?? {}).some((o) => o && typeof o === "object" && "minWall" in o));
47
+ } catch {
48
+ return true; // unresolvable → measure it properly and let verify report the error
49
+ }
50
+ }
@@ -3,7 +3,8 @@ import { cachedBVH } from "./bvh.js";
3
3
  import { assemblyOverlaps } from "../assembly.js";
4
4
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
5
5
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
6
- import { minWall } from "./min-wall.js";
6
+ import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
7
+ import { partGatesMinWall } from "./gates.js";
7
8
 
8
9
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
9
10
  const unionBounds = (list) => list.reduce(
@@ -40,13 +41,20 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
40
41
  // the cache just fills it earlier. min-wall indexes exactly one mesh, so it is
41
42
  // handed the resolved BVH rather than the Map.
42
43
  const bvhCache = new Map();
44
+ // Sample budget for the min-wall pass. A part that declares a min-wall gate (a
45
+ // process profile or an `expect` mentioning it) gets the full resolution, because
46
+ // a gate's verdict rides on the reading. Everything else gets the diagnostic
47
+ // budget: min wall is the single most expensive thing the oracle does — one
48
+ // inward ray per sampled triangle plus the BVH those rays need — and on an
49
+ // ungated part it buys a fact nobody checks, at full price, on every agent edit.
50
+ const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
43
51
  const subBounds = [];
44
52
  const subparts = built.map(({ name, solid, mesh }) => {
45
53
  const b = bounds(mesh.positions);
46
54
  subBounds.push(b);
47
55
  // Resolved lazily and only when asked for: without min-wall, a single-sub-part
48
56
  // view (no meshGaps) must still build no index at all.
49
- const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache) }) : null;
57
+ const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples }) : null;
50
58
  const vol = solid.volume();
51
59
  // Deviation-from-reference: only for a sub-part that declares `reference:
52
60
  // "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
@@ -96,7 +104,13 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
96
104
  // so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
97
105
  // *almost* touch; overlapping pairs are excluded by name (a fully-contained
98
106
  // sub-part has surface distance > 0 but is the overlap gate's business).
99
- const gaps = built.length > 1 ? meshGaps(built, { bvhCache }) : [];
107
+ // `opts.gaps: false` is the quick lap's second half (see jobs.js): pair distances
108
+ // are the other ray-casting pass, and they and min-wall share the BVH, so skipping
109
+ // only one leaves the index build standing. The result is `undefined`, NEVER `[]`:
110
+ // pairGapChecks reads an empty table as "measured, and this pair has no distance"
111
+ // and fails a declared gate on it, while an absent table reads as no reading.
112
+ const measuredGaps = opts.gaps !== false;
113
+ const gaps = measuredGaps ? (built.length > 1 ? meshGaps(built, { bvhCache }) : []) : undefined;
100
114
 
101
115
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
102
116
  // above is already read, so this is safe.
@@ -106,7 +120,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
106
120
 
107
121
  const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
108
122
  const gapThreshold = opts.gapThreshold ?? GAP_THRESHOLD;
109
- const nearMisses = gaps.filter(
123
+ const nearMisses = (gaps ?? []).filter(
110
124
  (g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
111
125
  );
112
126
 
@@ -133,6 +147,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
133
147
  // nothing measured it, which reads identically to "no reading available";
134
148
  // verify's seeding rule turns on exactly this distinction (see verify.js).
135
149
  measuredMinWall: !!opts.minWall,
150
+ // Companion stamp to measuredMinWall, and read the same way: whether the pass
151
+ // ran, said by the pass itself rather than claimed by whoever holds the result.
152
+ measuredGaps,
136
153
  subparts,
137
154
  aggregate,
138
155
  overlaps,
@@ -33,6 +33,23 @@ import { buildBVH, readTriangleInto } from "./bvh.js";
33
33
  // are the only ones that engage it. Override per call with `{ maxSamples }`.
34
34
  const MAX_SAMPLES = 50_000;
35
35
 
36
+ // The budget for a min-wall reading NOTHING GATES ON — a fact the report carries so
37
+ // the model can notice a thin feature, rather than a number a declared minimum is
38
+ // checked against. A tenth the rays, and the reading is stamped `sampled` either way,
39
+ // so no consumer can mistake it for exact. measure() picks between the two by asking
40
+ // gates.js whether the part declares a min-wall gate; a part that does gets
41
+ // MAX_SAMPLES, unchanged.
42
+ //
43
+ // The win is real but bounded, and the bound is worth knowing before tuning this
44
+ // number: the RAYS get ten times cheaper, the BVH they cast into does not, and it is
45
+ // roughly half the pass. Measured on src/parts/screw.js (210k triangles, same code
46
+ // path, gated vs not): measure() 837 ms → 458 ms, reporting the identical 0.008 mm.
47
+ // Dropping the index too means not measuring min wall at all, which is the quick
48
+ // lap's business (see jobs.js), not this constant's.
49
+ const DIAGNOSTIC_SAMPLES = 5_000;
50
+
51
+ export { MAX_SAMPLES, DIAGNOSTIC_SAMPLES };
52
+
36
53
  const gcd = (a, b) => { while (b) { const t = a % b; a = b; b = t; } return a; };
37
54
 
38
55
  // Stride for the sampling walk: near n/φ and coprime to n, so stepping by it visits
@@ -2,9 +2,8 @@ import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
2
2
  import { measure as defaultMeasure } from "./measure.js";
3
3
  import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { resolveProfile } from "./dfm-profiles.js";
5
- import { expandCases } from "./cases.js";
5
+ import { expandExpectations, partGatesMinWall } from "./gates.js";
6
6
  import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
7
- import { resolveParams } from "../part-model.js";
8
7
  import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
9
8
 
10
9
  // Re-exported for backwards compatibility: the registries moved to framework/ so
@@ -52,11 +51,19 @@ function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
52
51
  // No gap table at all = legacy facts → skip. A table that MERELY LACKS the pair
53
52
  // = the sub-part built empty (meshGaps skips empty meshes) → a declared gate
54
53
  // must fail loudly, not skip, or verify.ok would vouch for an unverified pair.
54
+ // `measuredGaps === false` says the pass was deliberately skipped (a quick lap);
55
+ // an absent stamp is a legacy result, whose skip stays exactly as untagged as it
56
+ // has always been rather than retroactively withholding verdicts.
57
+ const gapsSkipped = facts.measuredGaps === false;
55
58
  const noReading = (base) => (facts.gaps
56
59
  ? { ...base, actual: null, status: "fail", pass: false,
57
60
  message: "no measured distance for the pair",
58
61
  hint: "one sub-part produced no mesh (an empty solid?) — fix the build before trusting this gate" }
59
- : { ...base, actual: null, status: "skip", pass: null, message: "unavailable" });
62
+ : { ...base, actual: null, status: "skip", pass: null,
63
+ ...(gapsSkipped
64
+ ? { unevaluated: true, message: "not measured (quick check)",
65
+ hint: "re-run this check without `quick` to measure pair distances" }
66
+ : { message: "unavailable" }) });
60
67
 
61
68
  if (contacts != null && !Array.isArray(contacts)) {
62
69
  throw new Error(`contacts: must be an array of ["a", "b"] pairs, got ${JSON.stringify(contacts)}`);
@@ -157,12 +164,25 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
157
164
  for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
158
165
  checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
159
166
 
167
+ // Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
168
+ // is a warn ("min wall unavailable"), which does not move `ok` — right when the
169
+ // rays were cast and all missed, wrong when they were never cast. The stamp is
170
+ // what tells those apart, and it is measure()'s own, never a caller's claim.
171
+ const minWallSkipped = facts.measuredMinWall === false;
160
172
  for (const s of facts.subparts) {
161
173
  const merged = {
162
174
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
163
175
  ...(expect?.[s.name] ?? {}),
164
176
  };
165
- for (const [metric, expr] of Object.entries(merged)) checks.push(check("subpart", s.name, metric, expr, SUBPART_METRICS, s));
177
+ for (const [metric, expr] of Object.entries(merged)) {
178
+ const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
179
+ if (minWallSkipped && metric === "minWall" && c.actual == null) {
180
+ checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
181
+ hint: "re-run this check without `quick` to measure min wall" });
182
+ continue;
183
+ }
184
+ checks.push(c);
185
+ }
166
186
  }
167
187
  return checks;
168
188
  }
@@ -170,26 +190,23 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
170
190
  // `seed` lets a caller that has ALREADY measured this part hand the result in so
171
191
  // verify does not recompute it — see the seeding block below for the shape and
172
192
  // the one correctness rule that governs it.
173
- export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed } = {}) {
193
+ // `quick` is the fast lap (see jobs.js): evaluate everything the seed already
194
+ // supports and MEASURE NOTHING. What that leaves unchecked is reported as
195
+ // `unevaluated` rather than quietly downgraded, and one unevaluated gate withholds
196
+ // `ok` — a fast lap yields facts, never a verdict.
197
+ export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed, quick = false } = {}) {
174
198
  view = view ?? Object.keys(part.views)[0];
175
199
  const profileSpec = process ?? part.verify?.process;
176
200
  const profile = profileSpec ? resolveProfile(profileSpec) : null;
177
- const expectSpec = part.verify?.expect ?? {};
178
201
 
179
- const cases = expandCases(part);
180
202
  // `expect` can be a pure function of the case's resolved params — (p, d) →
181
203
  // expect object — so topology that legitimately changes with a preset (an
182
204
  // optional drain or bore flipping the genus) can be pinned per case instead
183
- // of one static number that some presets must violate.
184
- const resolveExpect = (params) => {
185
- if (typeof expectSpec !== "function") return expectSpec;
186
- const { p, d } = resolveParams(part, params);
187
- return expectSpec(p, d) ?? {};
188
- };
189
- const expanded = cases.map((c) => ({ ...c, expect: resolveExpect(c.params) }));
190
- const expectMentionsMinWall = expanded.some(({ expect }) =>
191
- Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o));
192
- const needMinWall = profile?.minWall != null || expectMentionsMinWall;
205
+ // of one static number that some presets must violate. The expansion lives in
206
+ // gates.js because measure() needs the same answer (it sizes the min-wall sample
207
+ // budget by it) and must not derive it separately — see there.
208
+ const expanded = expandExpectations(part);
209
+ const needMinWall = partGatesMinWall(part, { process, expanded });
193
210
  const readKeys = subPartReadKeys(part, view, part.defaults);
194
211
  const signature = (params) =>
195
212
  readKeys === RELEVANT_ALL
@@ -237,24 +254,48 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
237
254
  //
238
255
  // Non-default measure options (a custom `gapThreshold`) are the caller's
239
256
  // responsibility: seed only a measurement taken the way verify would take it.
240
- if (seed?.result && (seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
257
+ // Under `quick` the superset rule is not waived so much as satisfied differently:
258
+ // a min-wall-less seed is reused, and the min-wall gate it cannot answer becomes
259
+ // `unevaluated` instead of being re-measured. The rule exists to stop a coarse
260
+ // reading standing in for a gate's verdict, and a withheld verdict does that too.
261
+ if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
241
262
  memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
242
263
  }
243
264
 
244
265
  const measureCase = (params) => {
245
266
  const key = signature(params);
246
- if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
267
+ if (memo.has(key)) return memo.get(key);
268
+ if (quick) return null; // a case the seed does not cover — reported, never built
269
+ memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
247
270
  return memo.get(key);
248
271
  };
249
272
 
250
273
  const subPartNames = Object.keys(part.parts);
251
- const caseResults = expanded.map(({ name, params, expect }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect, subPartNames }) }));
274
+ // An unmeasured case gets ONE check standing for the whole case rather than a
275
+ // silent absence: "this preset was not checked" has to be visible in the same
276
+ // list every other verdict lives in, or a reader counting passes sees a shorter
277
+ // list and no reason.
278
+ const notMeasured = (name) => [{
279
+ scope: "case", subpart: null, metric: "measured", kind: "gate", expr: "measured",
280
+ actual: null, status: "skip", pass: null, unevaluated: true,
281
+ message: "not measured (quick check)",
282
+ hint: "re-run this check without `quick` to evaluate this case",
283
+ }];
284
+ const caseResults = expanded.map(({ name, params, expect }) => {
285
+ const facts = measureCase(params);
286
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames }) : notMeasured(name) };
287
+ });
252
288
  const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
289
+ const failures = all.filter((c) => c.status === "fail");
290
+ const unevaluated = all.filter((c) => c.unevaluated);
253
291
  return {
254
- ok: !all.some((c) => c.status === "fail"),
292
+ // Tri-state, and the order matters: a real failure is still a failure even on a
293
+ // lap that skipped other gates, so `false` outranks the withheld `null`.
294
+ ok: failures.length ? false : unevaluated.length ? null : true,
255
295
  view,
256
296
  cases: caseResults,
257
- failures: all.filter((c) => c.status === "fail"),
297
+ failures,
258
298
  warnings: all.filter((c) => c.status === "warn"),
299
+ unevaluated,
259
300
  };
260
301
  }