@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * The automatic mode's acceptance loop with a replay (issue #126, rigc#1266
3
+ * mechanism 2, rig-c 2.24.0): what the rig stage does with an `auto` part
4
+ * whose full reduction is refused by the motion gate (`src/automotion.ts`).
5
+ *
6
+ * rig-c's `reduceMesh` reports `changes.acceptedAt`, one entry per accepted
7
+ * operation — `{ step, kind, count, sourceVertices }`, `step` the attempt
8
+ * number it was taken at and `kind` a refinement `insertion`, a single
9
+ * `removal` or a `boundary-run` (from rig-c 2.25.0, rigc#1279; before it the
10
+ * entries were the bare attempt numbers) — and takes `stopAfterAccepted: n`,
11
+ * which returns byte for byte the mesh the same call held after its n-th
12
+ * accepted operation, terminated `replayed-to-accepted-step` (the contract:
13
+ * rig-c's docs/MESH_REDUCTION.md §2 and §7, *Mechanism 2 — implemented*).
14
+ * Every step below is an operation, not a vertex: a boundary run removes two
15
+ * or more vertices in one step. The choosing is this package's (mechanism 3,
16
+ * agreed on issue #126 as Q8), and it is done this way:
17
+ *
18
+ * 1. **The domain.** N = `acceptedAt.length` operations; the first I of them
19
+ * are the refinement's insertions ({@link refinementSteps}: the entries up
20
+ * to the last of kind `insertion` — rig-c refines inside the declared
21
+ * regions, then removes). A step at or below I is the
22
+ * source, refined or not, and is never written as an automatic result —
23
+ * writing the source would silently defeat the mode, so a search that finds
24
+ * no removal step passing is refused, "no reduction passes the motion
25
+ * bound" ({@link noReductionProblem}). Step N is the full result, which the
26
+ * gate already refused. So only the removal steps I+1 .. N−1 are probed, and
27
+ * a replay inside the refinement — which rig-c returns unaccepted, a
28
+ * refinement step not being required to meet the targets — is never asked
29
+ * for.
30
+ * 2. **The search** ({@link bisectAccepted}): a bisection on n over (I, N),
31
+ * lo = I taken as passing and hi = N as failing; each probe replays step
32
+ * n, holds it to the same acceptance as the full result ({@link
33
+ * replayVerdict}: the termination rig-c promised, every declared geometry
34
+ * row passing), compiles it and compares it with the source on the idle's
35
+ * **grid** frames only ({@link selectionSchedule}: the schedule walks no
36
+ * irr frame at all, so none can inform the choice). It keeps the largest n
37
+ * it saw pass. Validity along the order is not monotone (the contract's M3
38
+ * measured it), so the search finds *a* passing prefix by bisection, not
39
+ * necessarily the last, and the row says so ({@link REPLAY_RULE}). Its
40
+ * length is at most ⌈log₂(N − I)⌉ replays ({@link maxReplays}) — derived
41
+ * from the domain, not an author's number; no config knob exists.
42
+ * 3. **The acceptance.** The chosen replay is written into the rig, which goes
43
+ * through rigc's gate again as written, and is compared with the source on
44
+ * the whole idle — grid and irr — with the grid frames declared
45
+ * `selection` and the irr frames held out ({@link splitSchedule};
46
+ * `heldOutClaim` true, by rig-c's walk). It is accepted only when rig-c's
47
+ * verdict is `accepted` on every frame, the held-out ones included;
48
+ * otherwise {@link heldOutProblem} names the selection value and the held-out
49
+ * value with the worst frame of each. Nothing is written otherwise.
50
+ *
51
+ * The probe — a replay, a build, a comparison — runs in the rig stage
52
+ * (`src/build.ts`), the one place this package runs rigc. Pure: no clock, no
53
+ * randomness, nothing read or written.
54
+ */
55
+ import type { AcceptedOperation, MeasureRow, MeshQualityReport, MotionSchedule, ReducedMesh } from 'rig-c/mesh';
56
+ import { autoVerdict, terminationText } from './automesh.ts';
57
+ import { idleSchedule, localRow, motionRowText } from './automotion.ts';
58
+ import type { Problem } from './errors.ts';
59
+
60
+ /** What the row and the build line say the search is, in so many words. */
61
+ export const REPLAY_RULE =
62
+ 'bisection over acceptedAt by stopAfterAccepted (rigc#1266 mechanism 2): removal steps only, selected on the idle grid frames with the irr frames held out, accepted on the whole idle; it finds a passing prefix, not necessarily the last (validity along the order is not monotone)';
63
+
64
+ /** The idle's `grid` frame ids, in walk order, from the frames rig-c walked in `report` — never typed, never the irr ones. */
65
+ export function gridFrameIds(report: MeshQualityReport): string[] {
66
+ return (report.candidates[0]?.motion?.schedule.walked ?? []).filter((f) => f.phase === 'grid').map((f) => f.id);
67
+ }
68
+
69
+ /** The search's schedule: {@link idleSchedule} walking the `grid` phase alone, every frame of it `selection`. */
70
+ export function selectionSchedule(grid: readonly string[]): MotionSchedule {
71
+ const s = idleSchedule();
72
+ return { ...s, phases: ['grid'], selection: [...grid] };
73
+ }
74
+
75
+ /** The final acceptance's schedule: {@link idleSchedule} whole — grid and irr — with the grid frames `selection`, so every irr frame is held out. */
76
+ export function splitSchedule(grid: readonly string[]): MotionSchedule {
77
+ return { ...idleSchedule(), selection: [...grid] };
78
+ }
79
+
80
+ /**
81
+ * I: the operations of `acceptedAt` that are the refinement's insertions — the
82
+ * entries up to and including the last of kind `insertion`, found by `kind`
83
+ * and not by a count of vertices (rigc#1279: an operation may remove several).
84
+ * rig-c refines first and then removes, so they are a prefix; 0 when there is
85
+ * none.
86
+ */
87
+ export function refinementSteps(acceptedAt: readonly AcceptedOperation[]): number {
88
+ for (let i = acceptedAt.length - 1; i >= 0; i--) if (acceptedAt[i].kind === 'insertion') return i + 1;
89
+ return 0;
90
+ }
91
+
92
+ /** Two accepted operations are the same operation: the same attempt number, kind, count and source vertices in order. */
93
+ export function sameOperation(a: AcceptedOperation, b: AcceptedOperation): boolean {
94
+ return a.step === b.step && a.kind === b.kind && a.count === b.count && a.sourceVertices.length === b.sourceVertices.length && a.sourceVertices.every((v, i) => v === b.sourceVertices[i]);
95
+ }
96
+
97
+ /** An operation in a phrase: `12 removal [7]`, `40 boundary-run x3 [3, 4, 5]`. */
98
+ export function operationText(o: AcceptedOperation | undefined): string {
99
+ if (o === undefined) return 'none';
100
+ return `${o.step} ${o.kind}${o.count === 1 ? '' : ` x${o.count}`}${o.sourceVertices.length === 0 ? '' : ` [${o.sourceVertices.join(', ')}]`}`;
101
+ }
102
+
103
+ /** The most replays a bisection over the removal steps (I, N) takes: ⌈log₂(N − I)⌉, 0 when there is no step strictly between. */
104
+ export function maxReplays(accepted: number, inserted: number): number {
105
+ const span = accepted - inserted;
106
+ return span <= 1 ? 0 : Math.ceil(Math.log2(span));
107
+ }
108
+
109
+ /** One probe of the search: the step replayed and what it read on the selection frames. */
110
+ export interface ReplayProbe {
111
+ step: number;
112
+ /** `pass`: accepted on selection; `fail`: compared and not accepted; `refused`: the replay or its build was refused before a comparison. */
113
+ verdict: 'pass' | 'fail' | 'refused';
114
+ /** The attachment-level `MQ_LOCAL_DEFORMATION` on the selection frames, or null when nothing was compared. */
115
+ value: number | null;
116
+ frame: string | null;
117
+ /** Why a `refused` probe was refused; null otherwise. */
118
+ reason: string | null;
119
+ }
120
+
121
+ /**
122
+ * The bisection (module header, step 2): lo = `lo` taken as passing, hi = `hi`
123
+ * as failing; while a step lies strictly between, probe the middle one
124
+ * (rounded down) and move lo up to it when it passes, hi down to it when it does
125
+ * not. Returns the largest step seen to pass — `lo` itself when none did — and
126
+ * every probe in the order taken. Deterministic: the order depends only on
127
+ * the answers.
128
+ */
129
+ export function bisectAccepted<P extends ReplayProbe>(lo: number, hi: number, probe: (step: number) => P): { chosen: number; probes: P[] } {
130
+ const probes: P[] = [];
131
+ let a = lo;
132
+ let b = hi;
133
+ while (b - a > 1) {
134
+ const mid = Math.floor((a + b) / 2);
135
+ const p = probe(mid);
136
+ probes.push(p);
137
+ if (p.verdict === 'pass') a = mid;
138
+ else b = mid;
139
+ }
140
+ return { chosen: a, probes };
141
+ }
142
+
143
+ /**
144
+ * The acceptance of one replay (module header, steps 1–2): the termination the
145
+ * contract promises for `stopAfterAccepted: step` — `replayed-to-accepted-step`
146
+ * with `acceptedSteps` the step and `candidatesTried` the full run's
147
+ * `acceptedAt[step − 1].step`, its own `acceptedAt` the full run's first `step`
148
+ * operations, each the same operation ({@link sameOperation}) —
149
+ * and then {@link autoVerdict}'s rule, the full result's: the candidate
150
+ * `accepted` with every declared geometry row passing. A replay that breaks the
151
+ * promise is `AUTO_MESH_TERMINATION` naming the step and the difference; one
152
+ * that keeps it but does not meet the targets is `AUTO_MESH_ACCEPTED`, as the
153
+ * full result would be.
154
+ */
155
+ export function replayVerdict(
156
+ object: string,
157
+ step: number,
158
+ acceptedAt: readonly AcceptedOperation[],
159
+ result: { mesh: ReducedMesh | null; report: MeshQualityReport },
160
+ ): { accepted: true; mesh: ReducedMesh; candidatesTried: number } | { accepted: false; problem: Problem } {
161
+ const t = result.report.termination;
162
+ const own = result.report.candidates[0]?.changes?.acceptedAt ?? null;
163
+ const want = acceptedAt[step - 1]?.step;
164
+ const broken =
165
+ want === undefined
166
+ ? `was asked for a step the full run never took (it accepted ${acceptedAt.length} operation(s))`
167
+ : t === null || t.reason !== 'replayed-to-accepted-step'
168
+ ? `ended ${terminationText(t)}`
169
+ : t.acceptedSteps !== step
170
+ ? `reports acceptedSteps ${t.acceptedSteps}`
171
+ : t.candidatesTried !== want
172
+ ? `reports candidatesTried ${t.candidatesTried} where the full run's acceptedAt[${step - 1}].step is ${want}`
173
+ : own === null || own.length !== step || own.some((o, i) => !sameOperation(o, acceptedAt[i]))
174
+ ? `reports acceptedAt of ${(own ?? []).length} operation(s) (first differing: ${operationText((own ?? []).find((o, i) => acceptedAt[i] === undefined || !sameOperation(o, acceptedAt[i])))}), not the full run's first ${step}`
175
+ : null;
176
+ if (broken !== null || want === undefined) {
177
+ return {
178
+ accepted: false,
179
+ problem: {
180
+ code: 'AUTO_MESH_TERMINATION',
181
+ object,
182
+ detail: `rig-c's reduceMesh with stopAfterAccepted ${step} ${broken}; the contract (rigc#1268) promises replayed-to-accepted-step with acceptedSteps ${step} and candidatesTried ${want ?? 'the full run\'s acceptedAt[n - 1].step'}, and a replay that breaks it is not a candidate`,
183
+ },
184
+ };
185
+ }
186
+ const v = autoVerdict(object, result);
187
+ if (!v.accepted) return v;
188
+ return { accepted: true, mesh: v.mesh, candidatesTried: want };
189
+ }
190
+
191
+ /** One role's reading of the attachment-level local-deformation row: value, state and worst frame, or null when that role walked no frame. */
192
+ export interface RoleReading {
193
+ value: number | null;
194
+ state: MeasureRow['state'];
195
+ frame: string | null;
196
+ }
197
+
198
+ /** The local-deformation row's `selection` and `held-out` readings (rig-c's `byRole`). */
199
+ export function roleReadings(report: MeshQualityReport): { selection: RoleReading | null; held_out: RoleReading | null; bound: { op: '<=' | '>='; value: number } | null } {
200
+ const r = localRow(report);
201
+ const by = r?.motion?.byRole;
202
+ const of = (x: { value: number | null; state: MeasureRow['state']; frame: string | null } | null | undefined): RoleReading | null => (x === null || x === undefined ? null : { value: x.value, state: x.state, frame: x.frame });
203
+ return { selection: of(by?.selection), held_out: of(by?.heldOut), bound: r?.bound ?? null };
204
+ }
205
+
206
+ /** A role's reading in a phrase: `0.93 <= 1 at idle@grid@1`, the operator turned when it fails. */
207
+ export function readingText(r: RoleReading | null, bound: { op: '<=' | '>='; value: number } | null): string {
208
+ if (r === null) return 'not walked';
209
+ if (r.value === null || bound === null) return `${r.state}`;
210
+ const op = r.state === 'pass' ? bound.op : bound.op === '<=' ? '>' : '<';
211
+ return `${r.value} ${op} ${bound.value} at ${r.frame ?? 'every frame'}`;
212
+ }
213
+
214
+ /** The probes in one phrase: `step 12 pass 0.4 at …, step 18 fail 1.2 at …`. */
215
+ export function probesText(probes: readonly ReplayProbe[]): string {
216
+ if (probes.length === 0) return 'no step lies strictly between the source and the full result';
217
+ return probes.map((p) => `step ${p.step} ${p.verdict}${p.value === null ? '' : ` ${p.value} at ${p.frame ?? 'every frame'}`}${p.reason === null ? '' : ` (${p.reason})`}`).join(', ');
218
+ }
219
+
220
+ /**
221
+ * The refusal when the search keeps no removal step (module header, step 1):
222
+ * the source is not written as an automatic result. Names the full result's
223
+ * reading, the domain and every probe.
224
+ */
225
+ export function noReductionProblem(object: string, full: string, accepted: number, inserted: number, probes: readonly ReplayProbe[]): Problem {
226
+ return {
227
+ code: 'AUTO_MESH_MOTION',
228
+ object,
229
+ detail:
230
+ `no reduction passes the motion bound: the full result (accepted step ${accepted} of ${accepted}) ${full}; a bisection over the removal steps ${inserted + 1}..${accepted - 1} by stopAfterAccepted, ` +
231
+ `selected on the idle grid frames with the irr frames held out, kept none (${probesText(probes)}); the source${inserted > 0 ? `, refined or not (steps 0..${inserted}),` : ' (step 0)'} is not an automatic result, and nothing is built in its place — raise the motion bound, protect the vertices that carry the motion (protect.weightJump), or put the part in contour mode`,
232
+ };
233
+ }
234
+
235
+ /** The refusal of a chosen replay that the whole idle does not accept: the selection and held-out values, the worst frame of each, and every failing row. */
236
+ export function heldOutProblem(object: string, step: number, accepted: number, report: MeshQualityReport, failing: string): Problem {
237
+ const r = roleReadings(report);
238
+ return {
239
+ code: 'AUTO_MESH_MOTION',
240
+ object,
241
+ detail:
242
+ `the replay to accepted step ${step} of ${accepted}, chosen on the idle grid frames, is not accepted on the whole idle: selection ${readingText(r.selection, r.bound)}; held out ${readingText(r.held_out, r.bound)} (${failing}); ` +
243
+ 'a candidate chosen on some frames must pass on the frames it was not chosen by, and nothing is built in its place',
244
+ };
245
+ }
246
+
247
+ /**
248
+ * The final acceptance of a chosen replay (module header, step 3): null when
249
+ * rig-c accepts it on the whole schedule — every frame, the held-out ones
250
+ * included — else {@link heldOutProblem} with every gated row that did not pass.
251
+ */
252
+ export function finalVerdict(object: string, step: number, accepted: number, report: MeshQualityReport): Problem | null {
253
+ const c = report.candidates[0];
254
+ if (c !== undefined && c.accepted) return null;
255
+ const failing = [...(c?.geometry?.rows ?? []), ...(c?.motion?.rows ?? [])].filter((r) => r.bound !== null && r.state !== 'pass');
256
+ return heldOutProblem(object, step, accepted, report, failing.length > 0 ? failing.map(motionRowText).join('; ') : 'a required motion row was not measured');
257
+ }
258
+
259
+ /** What `mesh_report.json`'s row carries about a search that chose a replay (`replay`, before `deformation`). */
260
+ export interface ReplayRow {
261
+ rule: string;
262
+ /** The full run's `acceptedAt` length: N operations. */
263
+ accepted_steps: number;
264
+ /** The refinement's insertions among them: I (operations 1..I, {@link refinementSteps}). */
265
+ refinement_steps: number;
266
+ /** The operation written: I < n < N. */
267
+ chosen_step: number;
268
+ /** `reduceMesh` calls with `stopAfterAccepted`: one per probe. */
269
+ replays: number;
270
+ /** ⌈log₂(N − I)⌉ ({@link maxReplays}). */
271
+ max_replays: number;
272
+ /** The candidates tried across the replays (each replay's `candidatesTried` — `acceptedAt[n − 1].step` — summed); the full run's own is the `quality_report`'s. */
273
+ candidates_tried: number;
274
+ /** The full result's reading on the whole idle, as the gate refused it. */
275
+ full: RoleReading | null;
276
+ probes: ReplayProbe[];
277
+ /** The chosen replay on the whole idle, per role, from the final comparison. */
278
+ selection: RoleReading | null;
279
+ held_out: RoleReading | null;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // The multi-interval selection (issue #148), opt-in: `meshes.<part>.auto.motion.selection`.
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** What the row and the build line say the multi-interval search is, in so many words. */
287
+ export const MULTI_INTERVAL_RULE =
288
+ 'multi-interval selection over acceptedAt by stopAfterAccepted (issue #148): removal steps only; the bisection first, whole, then the midpoint (rounded down) of the longest untested run of removal steps, the lower run on a tie, each step replayed at most once, up to maxProbes replays in all; ' +
289
+ 'every probe selected on the idle grid frames; the fewest vertices among the tested steps that passed, the lower step on a tie; accepted on the whole idle with the irr frames held out, and no second candidate taken on a held-out failure; the best among tested candidates, not the last, the minimal or a complete walk';
290
+
291
+ /** One probe of the multi-interval search: {@link ReplayProbe} with the replay's own vertex count, null when the replay was refused before a mesh. */
292
+ export interface CountedProbe extends ReplayProbe {
293
+ vertices: number | null;
294
+ }
295
+
296
+ /** How the multi-interval search ended. */
297
+ export type MultiIntervalTermination =
298
+ /** Every removal step strictly between the source and the full result was replayed: the tested set is the whole domain. */
299
+ | 'every-removal-step-tested'
300
+ /** `maxProbes` replays were made and some removal step was never replayed: the tested set is partial. */
301
+ | 'budget-exhausted'
302
+ /** The probe order proposed a step already tested or outside the domain; it was not replayed, and the search stopped there. */
303
+ | 'order-proposed-a-tested-step'
304
+ /** The search was refused (a replay that broke rig-c's promise, a red build, a refused comparison input): it stopped there and the part is refused. */
305
+ | 'refused';
306
+
307
+ /**
308
+ * The untested runs of removal steps: the maximal runs of consecutive steps
309
+ * strictly between `lo` and `hi` that are not in `tested`, as `[first, last]`
310
+ * in ascending order.
311
+ */
312
+ export function untestedIntervals(lo: number, hi: number, tested: ReadonlySet<number>): Array<[number, number]> {
313
+ const out: Array<[number, number]> = [];
314
+ let start: number | null = null;
315
+ for (let s = lo + 1; s < hi; s++) {
316
+ if (!tested.has(s)) {
317
+ if (start === null) start = s;
318
+ continue;
319
+ }
320
+ if (start !== null) out.push([start, s - 1]);
321
+ start = null;
322
+ }
323
+ if (start !== null) out.push([start, hi - 1]);
324
+ return out;
325
+ }
326
+
327
+ /**
328
+ * The next step the multi-interval order proposes after the bisection: the
329
+ * midpoint, rounded down, of the longest untested run ({@link
330
+ * untestedIntervals}), the lower run when two are equally long; null when
331
+ * every removal step has been tested. Depends only on the tested set.
332
+ */
333
+ export function nextIntervalProbe(lo: number, hi: number, tested: ReadonlySet<number>): number | null {
334
+ let best: [number, number] | null = null;
335
+ for (const run of untestedIntervals(lo, hi, tested)) if (best === null || run[1] - run[0] > best[1] - best[0]) best = run;
336
+ return best === null ? null : Math.floor((best[0] + best[1]) / 2);
337
+ }
338
+
339
+ /**
340
+ * The passing intervals observed: walking the tested steps in ascending
341
+ * order, each maximal run of tested steps that passed with no tested step
342
+ * that did not pass between them, as `[first, last]`. An untested step inside
343
+ * a run is not claimed to pass — it was not replayed.
344
+ */
345
+ export function passingIntervals(probes: readonly ReplayProbe[]): Array<[number, number]> {
346
+ const sorted = [...probes].sort((a, b) => a.step - b.step);
347
+ const out: Array<[number, number]> = [];
348
+ let run: [number, number] | null = null;
349
+ for (const p of sorted) {
350
+ if (p.verdict === 'pass') {
351
+ if (run === null) run = [p.step, p.step];
352
+ else run[1] = p.step;
353
+ continue;
354
+ }
355
+ if (run !== null) out.push(run);
356
+ run = null;
357
+ }
358
+ if (run !== null) out.push(run);
359
+ return out;
360
+ }
361
+
362
+ /**
363
+ * The selection rule: among the tested probes that passed with a vertex
364
+ * count, the fewest vertices, then the lower step. Null when none passed.
365
+ * The count is the replay's own, never one derived from the step number: an
366
+ * operation may remove several vertices (a boundary run), and one may insert.
367
+ */
368
+ export function fewestVertices(probes: readonly CountedProbe[]): CountedProbe | null {
369
+ let best: CountedProbe | null = null;
370
+ for (const p of probes) {
371
+ if (p.verdict !== 'pass' || p.vertices === null) continue;
372
+ if (best === null || best.vertices === null || p.vertices < best.vertices || (p.vertices === best.vertices && p.step < best.step)) best = p;
373
+ }
374
+ return best;
375
+ }
376
+
377
+ /**
378
+ * The multi-interval search (issue #148) over the removal steps strictly
379
+ * between `lo` (the source, refined or not — taken as passing and never
380
+ * chosen) and `hi` (the full result, which the gate refused): the bisection
381
+ * ({@link bisectAccepted}) first, whole, so its result is always among the
382
+ * tested; then, while fewer than `maxProbes` distinct steps have been
383
+ * replayed, the step `order` proposes ({@link nextIntervalProbe}). `probe` is
384
+ * called once per distinct step: a proposal of a tested step (or one outside
385
+ * the domain) is not replayed, not counted, and ends the search
386
+ * (`order-proposed-a-tested-step` — the shipped order never proposes one).
387
+ * `halted()` true — the caller's search was refused — ends it too
388
+ * (`refused`); a replay refused on geometry alone (`AUTO_MESH_ACCEPTED`) is a
389
+ * probe that did not pass, as in the bisection. Returns the probes in the order taken,
390
+ * the bisection's own result, the chosen probe ({@link fewestVertices}) and
391
+ * how it ended. Deterministic: the order depends only on the answers.
392
+ *
393
+ * The caller holds `maxProbes` at or above the bisection's worst length
394
+ * ({@link maxReplays}, refused otherwise by {@link budgetProblem}), so the
395
+ * bisection always completes inside the budget.
396
+ */
397
+ export function multiIntervalSearch(
398
+ lo: number,
399
+ hi: number,
400
+ maxProbes: number,
401
+ probe: (step: number) => CountedProbe,
402
+ halted: () => boolean = () => false,
403
+ order: (lo: number, hi: number, tested: ReadonlySet<number>) => number | null = nextIntervalProbe,
404
+ ): { probes: CountedProbe[]; bisection: number; chosen: CountedProbe | null; termination: MultiIntervalTermination } {
405
+ const probes: CountedProbe[] = [];
406
+ const tested = new Set<number>();
407
+ const ask = (step: number): CountedProbe => {
408
+ const p = probe(step);
409
+ probes.push(p);
410
+ tested.add(step);
411
+ return p;
412
+ };
413
+ const bis = bisectAccepted(lo, hi, ask);
414
+ let termination: MultiIntervalTermination | null = null;
415
+ // Each turn either replays a new step of the domain or ends the search, so hi − lo turns always suffice; the cap
416
+ // makes that a fact of the loop rather than of the order handed in.
417
+ for (let turn = 0; termination === null; turn++) {
418
+ const s = halted() ? null : order(lo, hi, tested);
419
+ if (halted()) termination = 'refused';
420
+ else if (s === null) termination = 'every-removal-step-tested';
421
+ else if (tested.has(s) || s <= lo || s >= hi || turn > hi - lo) termination = 'order-proposed-a-tested-step';
422
+ else if (tested.size >= maxProbes) termination = 'budget-exhausted';
423
+ else ask(s);
424
+ }
425
+ return { probes, bisection: bis.chosen, chosen: fewestVertices(probes), termination };
426
+ }
427
+
428
+ /** The refusal of a budget below the bisection it must contain: the bisection over (I, N) may take ⌈log₂(N − I)⌉ replays. */
429
+ export function budgetProblem(object: string, maxProbes: number, accepted: number, inserted: number): Problem {
430
+ const need = maxReplays(accepted, inserted);
431
+ return {
432
+ code: 'AUTO_MESH_SELECTION_BUDGET',
433
+ object: `${object}.motion.selection.maxProbes`,
434
+ detail:
435
+ `is ${maxProbes}; the multi-interval selection runs the bisection over the removal steps ${inserted + 1}..${accepted - 1} of ${accepted} accepted step(s) whole first, which may take ${need} replay(s) (⌈log₂(N − I)⌉ with N ${accepted}, I ${inserted}), so ${need} or more is required — ` +
436
+ "the budget is the author's and is never raised here; raise it, or leave selection out for the bisection alone",
437
+ };
438
+ }
439
+
440
+ /** The refusal when the multi-interval search keeps no removal step: the source is not written, as under the bisection. */
441
+ export function noMultiReductionProblem(
442
+ object: string,
443
+ full: string,
444
+ accepted: number,
445
+ inserted: number,
446
+ maxProbes: number,
447
+ termination: MultiIntervalTermination,
448
+ probes: readonly ReplayProbe[],
449
+ ): Problem {
450
+ return {
451
+ code: 'AUTO_MESH_MOTION',
452
+ object,
453
+ detail:
454
+ `no reduction passes the motion bound among the tested candidates: the full result (accepted step ${accepted} of ${accepted}) ${full}; a multi-interval selection over the removal steps ${inserted + 1}..${accepted - 1} by stopAfterAccepted (maxProbes ${maxProbes}, ended ${termination}), ` +
455
+ `selected on the idle grid frames with the irr frames held out, kept none (${probesText(probes)}); a step never replayed is not claimed to fail; the source${inserted > 0 ? `, refined or not (steps 0..${inserted}),` : ' (step 0)'} is not an automatic result, and nothing is built in its place — raise maxProbes, raise the motion bound, protect the vertices that carry the motion (protect.weightJump), or put the part in contour mode`,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Schedule-role contamination (issue #148, item 4): every frame a probe's
461
+ * comparison walked must be one the final comparison declares `selection`,
462
+ * and no frame the final holds out may have been read by a probe. Null when
463
+ * that holds; otherwise the problem names the frames and withdraws the
464
+ * held-out claim — the candidate is refused, never re-chosen.
465
+ */
466
+ export function rolesProblem(object: string, step: number, probeFrames: readonly string[], finalReport: MeshQualityReport): Problem | null {
467
+ const walked = finalReport.candidates[0]?.motion?.schedule.walked ?? [];
468
+ const heldOut = new Set(walked.filter((f) => f.role === 'held-out').map((f) => f.id));
469
+ const selection = new Set(walked.filter((f) => f.role === 'selection').map((f) => f.id));
470
+ const read = [...new Set(probeFrames)].sort();
471
+ const leaked = read.filter((id) => heldOut.has(id));
472
+ const stray = read.filter((id) => !selection.has(id) && !heldOut.has(id));
473
+ if (leaked.length === 0 && stray.length === 0) return null;
474
+ const named = [leaked.length > 0 ? `the held-out frame(s) [${leaked.join(', ')}]` : '', stray.length > 0 ? `frame(s) the final comparison does not declare selection [${stray.join(', ')}]` : ''].filter((x) => x !== '').join(' and ');
475
+ return {
476
+ code: 'AUTO_MESH_SELECTION_ROLES',
477
+ object,
478
+ detail: `the replay to accepted step ${step} was chosen by probes that read ${named}; a frame read to choose is selection, so the held-out claim is withdrawn, the candidate is not accepted on it, and nothing is built in its place`,
479
+ };
480
+ }
481
+
482
+ /** What `mesh_report.json`'s row carries about a multi-interval search that chose a replay (`replay`, before `deformation`). */
483
+ export interface MultiIntervalRow {
484
+ rule: string;
485
+ policy: 'multi-interval';
486
+ max_probes: number;
487
+ /** The full run's `acceptedAt` length: N operations. */
488
+ accepted_steps: number;
489
+ /** The refinement's insertions among them: I. */
490
+ refinement_steps: number;
491
+ /** The operation written: I < n < N. */
492
+ chosen_step: number;
493
+ /** The bisection's own result inside the search (I when it kept none). */
494
+ bisection_step: number;
495
+ /** `reduceMesh` calls with `stopAfterAccepted`: one per distinct probe, at most `max_probes`. */
496
+ replays: number;
497
+ /** The candidates tried across the replays (each replay's `candidatesTried` summed). */
498
+ candidates_tried: number;
499
+ termination: MultiIntervalTermination;
500
+ /** The tested removal steps, ascending. */
501
+ tested: number[];
502
+ /** Maximal runs of tested passing steps with no tested failure between ({@link passingIntervals}). */
503
+ passing_intervals: Array<[number, number]>;
504
+ /** Maximal runs of removal steps never replayed ({@link untestedIntervals}); empty only when every step was tested. */
505
+ untested_intervals: Array<[number, number]>;
506
+ /** The chosen replay's counts, read off its mesh. */
507
+ chosen: { vertices: number; boundary: number; interior: number; triangles: number; bindings: number };
508
+ /** The full result's reading on the whole idle, as the gate refused it. */
509
+ full: RoleReading | null;
510
+ probes: CountedProbe[];
511
+ /** Which frames played which role: every probe read the grid frames alone (selection); the irr frames were read once, by the final comparison (held out). */
512
+ roles: { selection: 'grid'; held_out: 'irr'; probe_frames: number };
513
+ /** The chosen replay on the whole idle, per role, from the final comparison. */
514
+ selection: RoleReading | null;
515
+ held_out: RoleReading | null;
516
+ }