spine-rigc 2.47.0 → 2.48.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/src/guide.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * `rigc guide` — read exactly the row of the guide a failure line names, or one
3
+ * numbered section of it, from the shipped `docs/AUTHORING.md` at the moment of
4
+ * the call (issue #1397, the form shared word for word with rig-c/parts#343).
5
+ *
6
+ * ⭐ **The guide stays one ledger.** Nothing here keeps a copy, an index or a
7
+ * generated split of it: every read is a slice of the file's text as it is on
8
+ * disk when the command runs, so there is nothing to drift. The one other file
9
+ * read is the one a code's row lives in when that is not AUTHORING — `ingest`'s
10
+ * finding codes, whose table is `docs/INGEST.md`'s *Every finding code* — and
11
+ * the output names the file it read.
12
+ *
13
+ * Pure: the texts come in as arguments and the lines go out as values, so the
14
+ * CLI owns the reading of the files and the selftest can plant on a text.
15
+ *
16
+ * 📐 What counts as a **rule row** is grammar, not a hand-kept list of tables: a
17
+ * table row (outside a code fence) whose first cell is one code span naming a
18
+ * rule — `` `A20_MESH_WEIGHTS_COHERENT` ``, optionally after its web anchor. In
19
+ * AUTHORING a rule name carries an underscore, which is what tells a rule from
20
+ * the other upper-case words tables there key on (the `BLOCK`/`JUDGE`/`LOSS`
21
+ * gutters, a picture's `REFERENCE` pane); in INGEST's finding-code table every
22
+ * row is a rule, `DURATION` included. A row whose code holds a `<WORD>` is a
23
+ * **family** — `ATTACHMENT_<TYPE>` — and answers for every code it spells
24
+ * (`ATTACHMENT_POINT`) that has no row of its own.
25
+ *
26
+ * 📐 A section is addressed the way the shared form fixes it (HQ's ruling on
27
+ * rig-c/parts#343): `§<n>` is the `## <n>.` heading, and `§<n>.<m>` is the m-th
28
+ * `###` under it, **counted** when the guide is read — never a number read off
29
+ * the heading. AUTHORING also writes the number into each `###`, so the two
30
+ * must agree, and `numberingFaults` is what says where they do not.
31
+ */
32
+
33
+ /** The command a pointer names — `rigc` here, `rig-parts` in the sibling package. */
34
+ export const GUIDE_BIN = 'rigc';
35
+
36
+ /** The guide, relative to the package root — what every read is a slice of. */
37
+ export const GUIDE_FILE = 'docs/AUTHORING.md';
38
+
39
+ /**
40
+ * The shipped documents a rule's row may live in other than the guide, and the
41
+ * heading that opens the one table of rules each holds. Only a code with no row
42
+ * in the guide is looked for here (issue #1397, change 7).
43
+ */
44
+ export const GUIDE_RULE_TABLES_ELSEWHERE: ReadonlyArray<{ file: string; heading: string }> = [
45
+ { file: 'docs/INGEST.md', heading: '#### Every finding code' },
46
+ ];
47
+
48
+ /**
49
+ * The end of every failure line that names a rule: ` (see rigc guide <RULE>)` —
50
+ * that text, that spacing. The one writer of it; `failLine` and the finding
51
+ * line are the two callers that put it on a line.
52
+ */
53
+ export function guidePointer(rule: string): string {
54
+ return ` (see ${GUIDE_BIN} guide ${rule})`;
55
+ }
56
+
57
+ /**
58
+ * The section a compile error points at (HQ's ruling on rig-c/parts#343): no compile error names a rule — 0 of
59
+ * the 463 throw sites carry a code, and §5.1's tables are keyed on the message — and §5.1 has no #### a thrower
60
+ * could name, so every one points at the section its row is in. When §5.1 grows #### families a thrower can
61
+ * name, this is where `§5.1.<k>` goes.
62
+ */
63
+ export const COMPILE_ERROR_SECTION = '§5.1';
64
+
65
+ /** A compile error as the CLI prints it: the prefix, the message, and the pointer to §5.1. */
66
+ export function compileErrorLine(message: string): string {
67
+ return `rigc compile error: ${message}${guidePointer(COMPILE_ERROR_SECTION)}`;
68
+ }
69
+
70
+ /** A refusal naming its rule, unindented, as `guide` prints its own: `FAIL <RULE>: <detail> (see rigc guide <RULE>)`. */
71
+ export function refusalLine(rule: string, detail: string): string {
72
+ return `FAIL ${rule}: ${detail}${guidePointer(rule)}`;
73
+ }
74
+
75
+ /** A failure line as every gate prints it: the report's two-space gutter, then the refusal line. */
76
+ export function failLine(rule: string, detail: string): string {
77
+ return ` ${refusalLine(rule, detail)}`;
78
+ }
79
+
80
+ /** One heading outside a code fence: its level, its text, the line it is on (1-based) and its byte offset. */
81
+ export interface GuideHeading {
82
+ level: number;
83
+ title: string;
84
+ line: number;
85
+ offset: number;
86
+ }
87
+
88
+ const ENCODER = new TextEncoder();
89
+
90
+ /** A string's length in UTF-8 bytes — the size a reader's context pays for it. No `Buffer`: nothing here is Node's. */
91
+ function utf8Length(text: string): number {
92
+ return ENCODER.encode(text).length;
93
+ }
94
+
95
+ /** A fenced block's opening, and the run of the same character that closes it. */
96
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
97
+
98
+ /** Whether `line` closes the block `fence` opened: a run of the same character, at least as long, and nothing after it. */
99
+ function closesFence(line: string, fence: string): boolean {
100
+ const run = /^ {0,3}(`+|~+)\s*$/.exec(line);
101
+ return run !== null && run[1][0] === fence[0] && run[1].length >= fence.length;
102
+ }
103
+
104
+ /**
105
+ * Every heading of a markdown text that is a heading — a `#` line inside a
106
+ * fenced block is a shell comment or an example, and AUTHORING carries
107
+ * hundreds of them.
108
+ */
109
+ export function guideHeadings(text: string): GuideHeading[] {
110
+ const out: GuideHeading[] = [];
111
+ let fence: string | null = null;
112
+ let offset = 0;
113
+ const lines = text.split('\n');
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ const opens = FENCE.exec(line);
117
+ if (opens !== null) {
118
+ if (fence === null) fence = opens[1];
119
+ else if (closesFence(line, fence)) fence = null;
120
+ } else if (fence === null) {
121
+ const heading = /^(#{1,6}) (.*)$/.exec(line);
122
+ if (heading !== null) out.push({ level: heading[1].length, title: heading[2].trim(), line: i + 1, offset });
123
+ }
124
+ offset += utf8Length(line) + 1;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** One addressable section: its address, its heading, and the byte and line span it covers. */
130
+ export interface GuideSection {
131
+ /** `5` or `5.2` — what follows `§`. */
132
+ address: string;
133
+ heading: GuideHeading;
134
+ /** Byte offsets into the text: the heading's line up to the next heading of its level or above. */
135
+ start: number;
136
+ end: number;
137
+ /** Line numbers, 1-based and inclusive. */
138
+ firstLine: number;
139
+ lastLine: number;
140
+ }
141
+
142
+ /** The number a `## ` heading carries before its first `.` — `5` for `5. Reading a failure`. */
143
+ function chapterNumber(title: string): string | null {
144
+ const m = /^(\d+)\. /.exec(title);
145
+ return m === null ? null : m[1];
146
+ }
147
+
148
+ /**
149
+ * Every section `§<n>` and `§<n>.<m>` addresses, in the order the text gives
150
+ * them. `§<n>.<m>` is the m-th `###` under `## <n>.`, counted here.
151
+ */
152
+ export function guideSections(text: string): GuideSection[] {
153
+ const heads = guideHeadings(text);
154
+ const total = utf8Length(text);
155
+ const lineCount = text.split('\n').length;
156
+ const span = (k: number): { start: number; end: number; firstLine: number; lastLine: number } => {
157
+ const h = heads[k];
158
+ const next = heads.slice(k + 1).find((x) => x.level <= h.level);
159
+ return {
160
+ start: h.offset,
161
+ end: next === undefined ? total : next.offset,
162
+ firstLine: h.line,
163
+ lastLine: next === undefined ? lineCount : next.line - 1,
164
+ };
165
+ };
166
+ const out: GuideSection[] = [];
167
+ let chapter: string | null = null;
168
+ let m = 0;
169
+ let sub = 0;
170
+ for (let k = 0; k < heads.length; k++) {
171
+ const h = heads[k];
172
+ if (h.level <= 2) {
173
+ chapter = h.level === 2 ? chapterNumber(h.title) : null;
174
+ m = 0;
175
+ sub = 0;
176
+ if (chapter !== null) out.push({ address: chapter, heading: h, ...span(k) });
177
+ } else if (h.level === 3 && chapter !== null) {
178
+ m++;
179
+ sub = 0;
180
+ out.push({ address: `${chapter}.${m}`, heading: h, ...span(k) });
181
+ } else if (h.level === 4 && chapter !== null && m > 0) {
182
+ // §<n>.<m>.<k>: the k-th #### under the m-th ### (HQ's ruling on rig-c/parts#343, S-A). A #### above the
183
+ // chapter's first ### belongs to no ###, so nothing addresses it.
184
+ sub++;
185
+ out.push({ address: `${chapter}.${m}.${sub}`, heading: h, ...span(k) });
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Every `###` and `####` whose written number is not the position it is counted
193
+ * at — the guide writes `### 5.2 …` and `#### 4.5.1 …`, and `§5.2` is the second
194
+ * `###` under `## 5.`, `§4.5.1` the first `####` under `§4.5`, so a heading
195
+ * inserted, moved or misnumbered would make the printed number lie about what
196
+ * `rigc guide` prints for it. Named by heading and line. A heading that writes
197
+ * no number is counted and not checked; one that writes a number its counted
198
+ * address is not — a lettered `10.6b`, a four-part `3.5.2.1` at `####` — is a
199
+ * fault, because nothing could address it by it.
200
+ */
201
+ export function numberingFaults(text: string, file: string = GUIDE_FILE): string[] {
202
+ const faults: string[] = [];
203
+ for (const section of guideSections(text)) {
204
+ if (section.heading.level < 3) continue;
205
+ const written = /^(\d+(?:\.\d+)*[a-z]?(?:\.\d+)*)\s/.exec(section.heading.title);
206
+ if (written === null) continue;
207
+ if (written[1] !== section.address) {
208
+ const parts = section.address.split('.');
209
+ const level = '#'.repeat(section.heading.level);
210
+ const parent = section.heading.level === 3 ? `"## ${parts[0]}."` : `§${parts.slice(0, -1).join('.')}`;
211
+ faults.push(
212
+ `${file}:${section.heading.line} "${level} ${section.heading.title}" writes ${written[1]} and is the ` +
213
+ `${ordinal(Number(parts[parts.length - 1]))} ${level} under ${parent}, so ${GUIDE_BIN} guide §${section.address} prints it — ` +
214
+ `renumber it ${section.address}, or make it a ${level}# of the ${level} above it`,
215
+ );
216
+ }
217
+ }
218
+ return faults;
219
+ }
220
+
221
+ function ordinal(n: number): string {
222
+ const tens = n % 100;
223
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : n % 10 === 1 ? 'st' : n % 10 === 2 ? 'nd' : n % 10 === 3 ? 'rd' : 'th';
224
+ return `${n}${suffix}`;
225
+ }
226
+
227
+ /** A rule row: the rule its first cell names, the row's text, where it is, and the table it sits in. */
228
+ export interface GuideRow {
229
+ rule: string;
230
+ /** The row as written. */
231
+ text: string;
232
+ file: string;
233
+ line: number;
234
+ /** The table's header row and its separator, so the cells can be read by their column. */
235
+ header: string[];
236
+ /** The nearest heading above the row. */
237
+ heading: GuideHeading | null;
238
+ /** The anchor id the first cell opens with, or `null` when it carries none. */
239
+ anchor: string | null;
240
+ }
241
+
242
+ /** A rule name as a row spells it: upper-case words joined by `_`, a `<WORD>` standing for a family's member. */
243
+ const RULE_CELL = /^\|\s*(?:<a id="([^"]*)"><\/a>)?`([A-Z][A-Z0-9]*(?:<[A-Z]+>)?(?:_(?:[A-Z0-9]+|<[A-Z]+>))*)`\s*\|/;
244
+
245
+ /**
246
+ * Every rule row of a text. With `section` the rows are those under that
247
+ * heading, up to the next heading of its level or above — the one table of
248
+ * rules a document other than the guide holds; without it, every row of the
249
+ * text whose code carries an underscore (the module comment says why).
250
+ */
251
+ export function guideRows(text: string, file: string, section?: string): GuideRow[] {
252
+ const heads = guideHeadings(text);
253
+ const lines = text.split('\n');
254
+ let from = 1;
255
+ let to = lines.length;
256
+ if (section !== undefined) {
257
+ const at = heads.findIndex((h) => `${'#'.repeat(h.level)} ${h.title}`.startsWith(section));
258
+ if (at < 0) return [];
259
+ const next = heads.slice(at + 1).find((h) => h.level <= heads[at].level);
260
+ from = heads[at].line + 1;
261
+ to = next === undefined ? lines.length : next.line - 1;
262
+ }
263
+ const out: GuideRow[] = [];
264
+ let fence: string | null = null;
265
+ let header: string[] = [];
266
+ let h = 0;
267
+ let heading: GuideHeading | null = null;
268
+ for (let i = 0; i < lines.length; i++) {
269
+ const line = lines[i];
270
+ while (h < heads.length && heads[h].line <= i + 1) heading = heads[h++];
271
+ const opens = FENCE.exec(line);
272
+ if (opens !== null) {
273
+ if (fence === null) fence = opens[1];
274
+ else if (closesFence(line, fence)) fence = null;
275
+ continue;
276
+ }
277
+ if (fence !== null) continue;
278
+ if (line.startsWith('|') && i + 1 < lines.length && /^\|\s*:?-{3,}/.test(lines[i + 1])) header = [line, lines[i + 1]];
279
+ if (i + 1 < from || i + 1 > to) continue;
280
+ const row = RULE_CELL.exec(line);
281
+ if (row === null) continue;
282
+ if (section === undefined && !row[2].includes('_')) continue;
283
+ out.push({ rule: row[2], text: line, file, line: i + 1, header, heading, anchor: row[1] ?? null });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Whether a family row's code (`ATTACHMENT_<TYPE>`) spells `rule`. */
289
+ export function familySpells(family: string, rule: string): boolean {
290
+ if (!family.includes('<')) return false;
291
+ const pattern = family.split(/<[A-Z]+>/).map((part) => part.replace(/[^A-Z0-9_]/g, '')).join('[A-Z0-9]+');
292
+ return new RegExp(`^${pattern}$`).test(rule);
293
+ }
294
+
295
+ /** The documents `guide` reads, as their texts: the guide, and each other document a rule table lives in. */
296
+ export interface GuideTexts {
297
+ guide: string;
298
+ /** By file, as `GUIDE_RULE_TABLES_ELSEWHERE` names them; a file that could not be read is absent. */
299
+ elsewhere: Readonly<Record<string, string>>;
300
+ }
301
+
302
+ /** Every rule row `guide` can print: the guide's, then each other document's table. */
303
+ export function allRuleRows(texts: GuideTexts): GuideRow[] {
304
+ const rows = guideRows(texts.guide, GUIDE_FILE);
305
+ for (const table of GUIDE_RULE_TABLES_ELSEWHERE) {
306
+ const text = texts.elsewhere[table.file];
307
+ if (text !== undefined) rows.push(...guideRows(text, table.file, table.heading));
308
+ }
309
+ return rows;
310
+ }
311
+
312
+ /**
313
+ * The rows that answer for `rule`: its own rows, in the guide first; or, where
314
+ * it has none anywhere, the family rows that spell it. Empty when nothing does.
315
+ */
316
+ export function rowsFor(rule: string, rows: readonly GuideRow[]): GuideRow[] {
317
+ const own = rows.filter((row) => row.rule === rule);
318
+ if (own.length > 0) return own;
319
+ return rows.filter((row) => familySpells(row.rule, rule));
320
+ }
321
+
322
+ /**
323
+ * The `n` known names nearest to `asked`, case aside: a name it is the start of
324
+ * (or, from three characters, a part of) first; then by edit distance less the
325
+ * prefix the two share, so `A00_MODEL_READ` is nearer `A00_MODEL_REGIONS_ON_PAGES`
326
+ * than a short name a few edits away; then by name.
327
+ */
328
+ export function nearestNames(asked: string, known: readonly string[], n = 3): string[] {
329
+ const key = asked.toUpperCase();
330
+ const shared = (name: string): number => {
331
+ let k = 0;
332
+ while (k < key.length && k < name.length && key[k] === name[k]) k++;
333
+ return k;
334
+ };
335
+ const score = (name: string): number =>
336
+ name.toUpperCase().startsWith(key) || (key.length >= 3 && name.toUpperCase().includes(key)) ? -Infinity : editDistance(key, name.toUpperCase()) - shared(name.toUpperCase());
337
+ return [...new Set(known)]
338
+ .map((name) => ({ name, d: score(name) }))
339
+ .sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
340
+ .slice(0, n)
341
+ .map((x) => x.name);
342
+ }
343
+
344
+ function editDistance(a: string, b: string): number {
345
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
346
+ for (let i = 1; i <= a.length; i++) {
347
+ const cur = [i];
348
+ for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
349
+ prev = cur;
350
+ }
351
+ return prev[b.length];
352
+ }
353
+
354
+ /**
355
+ * The two rules `guide` itself refuses by (HQ's ruling on rig-c/parts#343, the printed form): a name no row
356
+ * carries, and an address no section has. Each has its row in the guide, like every other rule a line can name.
357
+ */
358
+ export const GUIDE_RULE_KNOWN = 'GUIDE_RULE_KNOWN';
359
+ export const GUIDE_SECTION_KNOWN = 'GUIDE_SECTION_KNOWN';
360
+ export const GUIDE_RULES: readonly string[] = [GUIDE_RULE_KNOWN, GUIDE_SECTION_KNOWN];
361
+
362
+ /** What `guide` answers: the lines to print, or the refusal's rule and detail (stderr, exit 1). */
363
+ export type GuideAnswer = { ok: true; lines: string[] } | { ok: false; rule: string; detail: string };
364
+
365
+ function kb(bytes: number): string {
366
+ return `${(bytes / 1024).toFixed(1)} KB`;
367
+ }
368
+
369
+ /** The `§` address of the section a line of the guide sits in — its deepest addressed heading; `null` outside every numbered section. */
370
+ function addressOfLine(sections: readonly GuideSection[], line: number): string | null {
371
+ let best: GuideSection | null = null;
372
+ for (const s of sections) if (s.firstLine <= line && line <= s.lastLine && (best === null || s.heading.level > best.heading.level)) best = s;
373
+ return best === null ? null : best.address;
374
+ }
375
+
376
+ /** A section's children — the addresses one level below it, in the order the guide gives them; `null` is the guide itself. */
377
+ function childrenOf(sections: readonly GuideSection[], address: string | null): GuideSection[] {
378
+ const depth = address === null ? 0 : address.split('.').length;
379
+ return sections.filter((s) => s.address.split('.').length === depth + 1 && (address === null || s.address.startsWith(`${address}.`)));
380
+ }
381
+
382
+ /**
383
+ * `rigc guide <RULE>` and `rigc guide §<n>[.<m>[.<k>]]`, over the texts as read, in the printed form HQ fixed for
384
+ * both packages on rig-c/parts#343:
385
+ *
386
+ * - a rule: per row, `docs/AUTHORING.md §<addr>, line <N> — under "<heading>"`, then the row verbatim;
387
+ * - a section: `docs/AUTHORING.md §<addr> "<title>", lines <a>–<b>, <X.X> KB`, then — when it has children — one
388
+ * `§<addr> <X.X KB> <title>` line per child, then the section verbatim;
389
+ * - an unknown rule or section: a refusal by `GUIDE_RULE_KNOWN` / `GUIDE_SECTION_KNOWN`.
390
+ */
391
+ export function guideAnswer(asked: string, texts: GuideTexts): GuideAnswer {
392
+ const sections = guideSections(texts.guide);
393
+ if (asked.startsWith('§')) {
394
+ const address = asked.slice(1);
395
+ const section = sections.find((s) => s.address === address);
396
+ if (section === undefined) {
397
+ // The deepest section the address names a prefix of, and its sections: §5.9 -> §5 and §5.1…§5.4.
398
+ const parts = address.split('.');
399
+ let deepest: GuideSection | null = null;
400
+ for (let n = parts.length - 1; n >= 1 && deepest === null; n--) deepest = sections.find((s) => s.address === parts.slice(0, n).join('.')) ?? null;
401
+ const kids = childrenOf(sections, deepest === null ? null : deepest.address).map((s) => `§${s.address}`);
402
+ const where = deepest === null ? `the guide itself, ${GUIDE_FILE}` : `§${deepest.address} "${deepest.heading.title}"`;
403
+ return {
404
+ ok: false,
405
+ rule: GUIDE_SECTION_KNOWN,
406
+ detail: `"${asked}" — no such section; the deepest that exists is ${where}, whose sections are: ${kids.length === 0 ? 'none' : kids.join(', ')}`,
407
+ };
408
+ }
409
+ const body = texts.guide.split('\n').slice(section.firstLine - 1, section.lastLine).join('\n').replace(/\n+$/, '');
410
+ const lines = [`${GUIDE_FILE} §${section.address} "${section.heading.title}", lines ${section.firstLine}–${section.lastLine}, ${kb(section.end - section.start)}`];
411
+ // A chapter lists its ###, a ### its #### — each with the address that prints it, its size and its title.
412
+ const subs = childrenOf(sections, section.address);
413
+ const wide = Math.max(0, ...subs.map((sub) => sub.address.length + 1));
414
+ const wideKb = Math.max(0, ...subs.map((sub) => kb(sub.end - sub.start).length));
415
+ for (const sub of subs) lines.push(`${`§${sub.address}`.padEnd(wide)} ${kb(sub.end - sub.start).padStart(wideKb)} ${sub.heading.title}`);
416
+ lines.push(body);
417
+ return { ok: true, lines };
418
+ }
419
+ const rows = allRuleRows(texts);
420
+ const found = rowsFor(asked, rows);
421
+ if (found.length === 0) {
422
+ return {
423
+ ok: false,
424
+ rule: GUIDE_RULE_KNOWN,
425
+ // The files actually searched, named (HQ on rig-c/parts#343): the guide, then each other document a rule table lives in.
426
+ detail: `"${asked}" — no row in ${[GUIDE_FILE, ...GUIDE_RULE_TABLES_ELSEWHERE.map((t) => t.file)].join(' or ')} names it; the nearest: ${nearestNames(asked, rows.map((r) => r.rule)).join(', ')}`,
427
+ };
428
+ }
429
+ const lines: string[] = [];
430
+ for (const row of found) {
431
+ const address = row.file === GUIDE_FILE ? addressOfLine(sections, row.line) : null;
432
+ const where = `${row.file}${address === null ? '' : ` §${address}`}, line ${row.line}`;
433
+ lines.push(`${where} — under "${row.heading === null ? '(no heading)' : row.heading.title}"`);
434
+ if (row.rule !== asked) lines.push(`${asked} has no row of its own; the row for the family ${row.rule}, which spells it, answers for it`);
435
+ lines.push(row.text);
436
+ }
437
+ return { ok: true, lines };
438
+ }
package/src/ingest.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  SPINE_VERSION,
53
53
  } from './compile.ts';
54
54
  import { CompileError } from './errors.ts';
55
+ import { guidePointer } from './guide.ts';
55
56
  import { physicsDrives } from './core/constraints_physics.ts';
56
57
  import { CHANNELS_BY_KIND } from './timelines.ts';
57
58
  import {
@@ -149,6 +150,17 @@ export const INGEST_GUTTERS: Record<IngestFindingKind, string> = {
149
150
  lossy: 'LOSS',
150
151
  };
151
152
 
153
+ /**
154
+ * One finding as `ingest` and `repack` print it: the gutter padded to one width
155
+ * so the codes line up, the code, where, the detail, and the pointer to the
156
+ * code's row (issue #1397) — the one writer of the line, so both commands print
157
+ * the same one.
158
+ */
159
+ export function findingLine(finding: IngestFinding): string {
160
+ const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
161
+ return ` ${INGEST_GUTTERS[finding.kind].padEnd(width)} ${finding.code}: ${finding.where} — ${finding.detail}${guidePointer(finding.code)}`;
162
+ }
163
+
152
164
  export interface IngestFinding {
153
165
  /** Stable code, so a table can count them and a doc can name one. */
154
166
  code: string;
@@ -66,7 +66,11 @@
66
66
  * on every walk and stepped at the declared `dt` to each frame (the oracle's
67
67
  * `stepSchedule` rule: from the last frame, steps of `dt` while before the
68
68
  * next, then to it), the same steps for the reference and every candidate. A
69
- * nonzero `warmupSteps` is refused, never run as zero.
69
+ * nonzero `warmupSteps` is refused, never run as zero. The physics clock
70
+ * moves by `physicsClockStep` of each step, the render's clock
71
+ * (`PHYSICS_CLOCK_LEAD` in `src/core/raw.ts`, issue #1380), so a step
72
+ * lasting a whole number of a constraint's steps takes them as exact
73
+ * arithmetic does (`comparisonWalk`, issue #1389).
70
74
  *
71
75
  * ## What it never does
72
76
  *
@@ -106,8 +110,8 @@ import {
106
110
  import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
107
111
  import { uvCarriers, uvCarriersWith, type Carrier, type CarrierPlant } from './meshcarriers.ts';
108
112
  import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
109
- import { poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
110
- import { sampleTime } from './core/animation.ts';
113
+ import { physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
114
+ import { sampleTime, type TimelinePlant } from './core/animation.ts';
111
115
  import { CORE_DEFAULT_SKIN } from './core/skins.ts';
112
116
  import { readVersion } from './package_meta.ts';
113
117
  import { hypot2 } from './numeric.ts';
@@ -187,7 +191,9 @@ export interface MotionComparisonInput {
187
191
  * Issue #1291's faults in carrying `motionAmplitude` into the setup measurement, for the `mesh-compare` suite's
188
192
  * negative controls: the amplitude not handed over; `null` handed over as the field left out; the field not validated
189
193
  * before the builds are read; `null` echoed for a field left out; and the load counted as a required row, so the
190
- * amplitude moves a verdict. `compareMeshesInMotion` plants none. Issue #1323 adds `CarrierPlant`
194
+ * amplitude moves a verdict. `compareMeshesInMotion` plants none. Issue #1389 adds `'frame-physics-clock'`: every
195
+ * build posed with the physics clock moved by the schedule's step itself, the walk before that issue
196
+ * (`comparisonWalk`). Issue #1323 adds `CarrierPlant`
191
197
  * (`src/meshcarriers.ts`): a fault in resolving a sample's several carriers, planted in every `uvCarriers` the
192
198
  * comparison reads.
193
199
  */
@@ -197,6 +203,7 @@ export type ComparePlant =
197
203
  | 'amplitude-unvalidated'
198
204
  | 'echo-when-unset'
199
205
  | 'amplitude-gates-acceptance'
206
+ | 'frame-physics-clock'
200
207
  | OverBoundPlant
201
208
  | CarrierPlant;
202
209
 
@@ -594,7 +601,37 @@ function drawnOf(pose: RawPose, ref: AttachmentRef): number[] | null {
594
601
  return row === undefined ? null : row.vertices;
595
602
  }
596
603
 
597
- function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames: readonly Planned[], walks: readonly Walk[]): Posed | { refused: string } {
604
+ /**
605
+ * The walk every build of a comparison is posed on (issue #1389): `poseRawAnimationEach`, reset at the animation, the
606
+ * physics clock moved by `physicsClockStep` of each step — the render's clock (`src/core/raw.ts`). A walk time that
607
+ * ends on a whole number of a physics constraint's steps leaves zero time over in exact arithmetic and a few ulps
608
+ * either side of zero in doubles, so with the step itself as the clock the constraint took one step too few or one
609
+ * too many by rounding. Measured over the clock arithmetic on 300 durations as the core reads them (float32, as Spine
610
+ * does: n/12 s to 20 s and tenths to 8 s): at 12 fps and `dt` 1/12, the rate `@rig-c/parts` walks, 11,248 of 44,486
611
+ * grid updates sit on a tie against a 60 Hz constraint and the frame clock happens to resolve every one as exact
612
+ * arithmetic does, while against 30, 24 and 50 Hz it mis-steps 3,274, 3,350 and 3,160 of them; at 4 fps and `dt` 1/60
613
+ * it mis-steps 19,876 grid and 594 irr updates against 60 Hz. The render's clock mis-steps none of any, so no time
614
+ * that is not a tie came within its lead of a whole step. `clock: 'frame'` is that walk, the step itself — `ComparePlant`
615
+ * `'frame-physics-clock'` and `MQ178`'s planted red. With no physics constraint the clock is read by nothing, so every
616
+ * pose is the same either way (`MQ179`); with one, the lead moves the shown lag weight by about `2⁻³⁰` of the time
617
+ * walked even where no step moves: on `MQ179`'s spring at parts' rate and 60 Hz, one unit of a row's sixth decimal,
618
+ * which can move the worst sample to a neighbour that nearly ties it.
619
+ */
620
+ export function comparisonWalk(doc: CompiledDocument, animation: string, deltas: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, clock: 'render' | 'frame' = 'render'): void {
621
+ poseRawAnimationEach(doc, animation, deltas, plant, 'animation', visit, clock === 'render' ? physicsClockStep : undefined);
622
+ }
623
+
624
+ /**
625
+ * The walks `schedule` plans over the model document `model`, in walk order, each with the steps every build is posed
626
+ * on — `planSchedule`'s, read for the `mesh-compare` suite's `MQ178`. Internal: merely exported, so not promised; it
627
+ * reads the schedule without the input checks `compareMeshesInMotion` runs first.
628
+ */
629
+ export function comparisonWalks(schedule: MotionSchedule, model: string): Array<{ animation: string; phase: 'grid' | 'irr' | null; deltas: number[] }> {
630
+ const doc = readModel(model, 'the model document');
631
+ return planSchedule(schedule, doc, doc.constraints.filter((c) => c.kind === 'physics').length).walks.map((w) => ({ animation: w.animation, phase: w.phase, deltas: [...w.deltas] }));
632
+ }
633
+
634
+ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames: readonly Planned[], walks: readonly Walk[], clock: 'render' | 'frame'): Posed | { refused: string } {
598
635
  try {
599
636
  const view = viewOf(doc, refs[0].skin);
600
637
  const setupPose = poseRawSetup(view);
@@ -612,12 +649,19 @@ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames
612
649
  list.push(i);
613
650
  wanted.set(f.pose, list);
614
651
  });
615
- poseRawAnimationEach(view, w.animation, w.deltas, {}, 'animation', (pose, index) => {
616
- const at = wanted.get(index);
617
- if (at === undefined) return;
618
- const drawn = refs.map((r) => drawnOf(pose, r));
619
- for (const i of at) out[i] = drawn;
620
- });
652
+ comparisonWalk(
653
+ view,
654
+ w.animation,
655
+ w.deltas,
656
+ {},
657
+ (pose, index) => {
658
+ const at = wanted.get(index);
659
+ if (at === undefined) return;
660
+ const drawn = refs.map((r) => drawnOf(pose, r));
661
+ for (const i of at) out[i] = drawn;
662
+ },
663
+ clock,
664
+ );
621
665
  });
622
666
  return { setup, frames: out, setupBones };
623
667
  } catch (err) {
@@ -1003,11 +1047,12 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1003
1047
  const carriersOf = (uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> =>
1004
1048
  uvCarriersWith(uvs, triangles, samples, who, carrierPlant);
1005
1049
  const refCarriers = order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1006
- const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks);
1050
+ const clock = plant === 'frame-physics-clock' ? 'frame' : 'render';
1051
+ const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1007
1052
 
1008
1053
  motionOf = (b: Build, isReference: boolean) => {
1009
1054
  const carriers = isReference ? refCarriers : order.map((i, k) => carriersOf(b.meshes[i].uvs, b.meshes[i].triangles, samplesOf[k].samples, `candidate "${b.id}", attachment ${nameOf(refs[i])}`));
1010
- const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks);
1055
+ const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks, clock);
1011
1056
  const built: Built[] = [];
1012
1057
  const perFrame: NonNullable<CandidateReport['perFrame']> = [];
1013
1058
  // Issue #1315: kept for a candidate only, and only when asked — a call without the field does none of this.
@@ -30,7 +30,7 @@
30
30
  * the same walk handing each pose over as it is posed (`CORE_RAW_WALK`), so
31
31
  * one pose is held at a time. Since issue #1380 the physics clock — and
32
32
  * nothing else — moves by `physicsClockStep(1/fps)` rather than by the step
33
- * (`PHYSICS_CLOCK_LEAD` below), in both posers: `CR03` still holds the walk
33
+ * (`PHYSICS_CLOCK_LEAD`, `./core/raw.ts`), in both posers: `CR03` still holds the walk
34
34
  * under the step itself, and the loop-seam suite's `LS06` holds the two
35
35
  * posers equal under the render's clock.
36
36
  * - **Vertices, triangles, bones, slot colours, draw order** are the raw
@@ -100,7 +100,7 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
100
100
  import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
101
101
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
102
102
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
103
- import { poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
103
+ import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
104
104
  import type { TimelinePlant } from './core/animation.ts';
105
105
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
106
106
  import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
@@ -369,61 +369,10 @@ export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: read
369
369
  export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep);
370
370
 
371
371
  /**
372
- * How much faster than the frames a render's physics clock runs: each frame
373
- * moves the skeleton's clock — the time a physics constraint integrates — by
374
- * `physicsClockStep(1 / fps)`, the frame's step and `2⁻³⁰` of it more, while
375
- * the animation is applied at the running sum of the frame steps as before
376
- * (issue #1380).
377
- *
378
- * ⭐ **Why a render's physics clock is not the frame clock.** A physics
379
- * constraint keeps the time it has not yet integrated (`remaining`), takes
380
- * whole steps of `1 / fps` (its own `fps`, 60 by default) while at least one
381
- * step remains, and shows `offset − lag·(1 − remaining / step)`, `lag` being
382
- * everything this update's steps moved (`src/core/constraints_physics.ts`,
383
- * *The step*). Whenever a frame lasts a whole number of physics steps — 12,
384
- * 20, 30 and 60 fps against 60 Hz, and every second frame at 24 — the time
385
- * left at the frame's end is zero in exact arithmetic, and in doubles it is
386
- * either a few ulps above zero or a few ulps short of one whole step. Which of
387
- * the two is decided by rounding, not by the motion: the shown weight is 1 or
388
- * 0, so the spring is drawn one frame late or on time, and it changes regime
389
- * on a single frame. Measured on the public trailer (`show`, 20 fps, 321
390
- * frames): every spring jumps at frames 5, 12, 45 and 188 — the four frames
391
- * that arithmetic alone predicts — and the loop's start (frame 80, on time)
392
- * and end (frame 320, a frame late, which is what exact arithmetic draws)
393
- * differ by 31.70 units of vertex and 10.37 of bone
394
- * while every keyed bone agrees to 1.3e-12. spine-core 4.3.13 stepped the
395
- * same way draws the same frames to the bit: this is the runtime under a
396
- * fixed offline step, not a defect of the core's port, which is why the fix
397
- * is the render's schedule and the physics is untouched.
398
- *
399
- * Running the clock `2⁻³⁰` fast puts the time left a little ABOVE every
400
- * whole number of steps instead of on it, by `2⁻³⁰ · t` after `t` seconds:
401
- * 1.5e-8 s at 16 s, against a rounding error of at most a few ulps of the
402
- * clock (3.6e-15 s at 16 s) — so every tie resolves the way exact arithmetic
403
- * resolves it, a step taken, and the shown weight sits at 1 − 9e-7 rather
404
- * than flipping. It stays below the smallest gap a step boundary can fall
405
- * into for 7.8e5 s even at 23 fps against 60 Hz (a gap of 1/1380 s), and a
406
- * physics `reset` key, which restarts the time left from zero, is resolved
407
- * the same way on every frame after it, because the lead is taken on every
408
- * frame rather than once (read off the clock arithmetic with a reset at frame
409
- * 100, 16 s at the five rates: no tie; no tree probe keys a reset under the
410
- * render's clock). A pose stays a function of its frame
411
- * index alone: the lead does not depend on how many frames are asked for.
412
- *
413
- * Rejected, measured over the same arithmetic (16 s at 12, 20, 24, 25, 30 and
414
- * 60 fps): the clock as `i / fps` rather than a running sum (90 frames still
415
- * flipped at 20 fps); a one-time lead of half a step (114 flipped at 24 fps,
416
- * where half a step lands on the other tie); a one-time lead of any size,
417
- * which a `reset` key cancels; and snapping `remaining` inside the physics,
418
- * which changes the runtime's physics rather than the render's schedule and
419
- * which spine-core's poser could not follow.
372
+ * The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
373
+ * comparison (`./meshcompare.ts`, issue #1389) reads the same clock without this module's closure.
420
374
  */
421
- export const PHYSICS_CLOCK_LEAD = 2 ** -30;
422
-
423
- /** What one frame of `step` seconds moves a render's physics clock by — `PHYSICS_CLOCK_LEAD`; both posers step with it. */
424
- export function physicsClockStep(step: number): number {
425
- return step + step * PHYSICS_CLOCK_LEAD;
426
- }
375
+ export { PHYSICS_CLOCK_LEAD, physicsClockStep };
427
376
 
428
377
  /** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
429
378
  export interface CorePoserPlants {