partforge 0.71.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.
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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:
|
package/package.json
CHANGED
package/src/framework/jobs.js
CHANGED
|
@@ -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.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
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 ?? {},
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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 {
|
|
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,
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
297
|
+
failures,
|
|
258
298
|
warnings: all.filter((c) => c.status === "warn"),
|
|
299
|
+
unevaluated,
|
|
259
300
|
};
|
|
260
301
|
}
|