rig-c 0.0.0-stage → 2.20.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 (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/diff.ts ADDED
@@ -0,0 +1,2252 @@
1
+ /**
2
+ * rigc diff — structural comparison of two skeletons, section by section.
3
+ *
4
+ * The benchmark ladder needs a yardstick, and a yardstick that reports one
5
+ * number is not one. A single "87% match" cannot distinguish a rig with the
6
+ * right skeleton and the wrong timing from a rig with the right timing and the
7
+ * wrong skeleton, and those are opposite diagnoses. So this reports a ratio per
8
+ * MEASURE, grouped into sections, and refuses to combine them: the section
9
+ * ratios are means of their own measures and are labelled as such, and there is
10
+ * no report-wide score at all.
11
+ *
12
+ * Three properties the measures are built to have:
13
+ *
14
+ * 1. **`diff X X` is 1.000 everywhere.** Every measure is a comparison of two
15
+ * derived quantities, never a judgement, so a file against itself has
16
+ * nothing to differ on. The CLI has a `--self-check` for exactly this, and
17
+ * the selftest runs it: a comparison tool that cannot recognise identity is
18
+ * reporting noise, and noise looks like a small honest gap.
19
+ * 2. **A difference moves as few measures as possible.** Reordering slots must
20
+ * not disturb the slot-to-bone binding figure, so bindings are compared by
21
+ * name and order is a separate measure. This is what makes the report
22
+ * diagnostic rather than merely quantitative.
23
+ * 3. **Name-agnostic figures sit beside name-matched ones.** A candidate that
24
+ * builds the right tree with its own names scores zero on `parent_by_name`
25
+ * and full marks on `depth_histogram` / `degree_sequence`. Reporting only
26
+ * the first would call a correct rig a total failure; reporting only the
27
+ * second would call any 14-bone tree a match. Both, separately, or neither
28
+ * is honest.
29
+ *
30
+ * ⭐ That held at the MEASURE level and not at the SECTION level, which is
31
+ * where a reader actually looks (issue #21). `bones` rolled eight measures
32
+ * into one mean, five of them gated on the same one-name-in-common
33
+ * condition — the naming figure counted five times, not five findings — so
34
+ * a rig with a structurally identical tree and its own vocabulary read
35
+ * `bones=0.567` and a reader who did not open the table under it read "the
36
+ * skeleton is wrong" when the skeleton was right. So `bones` and `slots`
37
+ * now carry a SECOND, independent comparison in `nameAgnostic`: the same
38
+ * two skeletons compared with names thrown away entirely.
39
+ *
40
+ * The two are not a partition of one mean. They are two comparisons of one
41
+ * section, with their own measure sets, and neither is a subset of the
42
+ * other — `section.ratio` is unchanged, to the digit, from what it has
43
+ * always been, so every `bench.json` already on disk stays comparable.
44
+ * Read them as a pair: name-agnostic 1.000 with name-matched low says the
45
+ * shape is right and the vocabulary differs; both low says the rig is
46
+ * wrong; name-agnostic low alone is impossible, since a wrong shape cannot
47
+ * have right names.
48
+ *
49
+ * ⭐ `animations` carries the same second comparison, and it arrived last
50
+ * because it needs something the other two do not: a PAIRING. A bone is
51
+ * paired with a bone by its depth and its child count, which the file
52
+ * states; two animations have no such shape to be matched on, so the
53
+ * candidate's `take01` and the reference's `arcs` are the same shot only
54
+ * because somebody says they are. Until that was said, every animation
55
+ * measure was keyed on the name — and a candidate that followed a brief
56
+ * withholding it read `count` 1/1 and **0.000 on all eight measures below**,
57
+ * on a shot with the same duration, the same timeline families and the same
58
+ * key counts (issue #720). That is the *gate that cannot be passed* shape on
59
+ * the measuring instrument rather than on the gate.
60
+ *
61
+ * Two things say it, and nothing else does. `--as <candidate>=<reference>`
62
+ * pairs them outright; failing that, **one animation each side** pairs by
63
+ * position, because there is exactly one reading of which shot is which and
64
+ * no name is consulted to reach it. Anything else — two against two, three
65
+ * against one — has several readings, so the block is ABSENT rather than
66
+ * guessed at, which is what `DiffSection.nameAgnostic` means by *"should say
67
+ * so by having none"*. ⚠️ Guessing there is the failure this file is built
68
+ * against: pairing two-against-two by position would score a candidate whose
69
+ * two shots are declared in the other order 0.000 across the block and call
70
+ * it a measurement.
71
+ *
72
+ * 🔒 `names` stays in the name-matched block alone, and that is the whole
73
+ * point of the split rather than an oversight: the pair is read as *agnostic
74
+ * 1.000 with `names` 0.000*, which says the shot is right and its name is the
75
+ * author's own.
76
+ *
77
+ * 4. **A measure that cannot gate is not in the mean.** `section.reported`
78
+ * carries the measures `docs/GATE.md`'s *What never gates* calls
79
+ * unobservable by construction — *"could any reading of the frames have
80
+ * decided it?"*, and for these the answer is no whatever the frames are.
81
+ * They are printed beside the section, with their conventions, and they have
82
+ * **no mean at all**: an average of "does this mesh declare edges" and "how
83
+ * many keys per second" is a number with no referent, and a section mean is
84
+ * the one figure a stored ladder row quotes. Same discipline as point 3 and
85
+ * the same reason — `section.ratio` does not move when a reported measure is
86
+ * added, so every `bench.json` already on disk stays comparable — with one
87
+ * addition of its own: *reports, never gates* becomes a property of where the
88
+ * measure sits rather than of a sentence somebody has to remember.
89
+ *
90
+ * Pure JSON reading — no spine-core, no filesystem. Validity is `validate.ts`'s
91
+ * job and this file assumes nothing about it. In particular the name-agnostic
92
+ * measures resolve nothing through the atlas: see `attachments.region_size`.
93
+ * The per-frame pose comparison that DOES need spine-core is a separate
94
+ * instrument — [`bonedist.ts`](bonedist.ts), the ladder's stage 3.
95
+ */
96
+ import { constraintAt } from './rig.ts';
97
+ import { walkTimelines } from './timelines.ts';
98
+ // Type only, and erased: the values themselves are read by `skeletonValues` in
99
+ // `src/validate.ts`, which is one of the three modules CLAUDE.md allows to link
100
+ // spine-core. This file stays what its header says it is — see
101
+ // `diffSkeletonValues` for why the reading and the comparing are split there.
102
+ import type { SkeletonValue } from './validate.ts';
103
+
104
+ type Json = Record<string, unknown>;
105
+
106
+ function isObj(v: unknown): v is Json {
107
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
108
+ }
109
+
110
+ function arr(v: unknown): unknown[] {
111
+ return Array.isArray(v) ? v : [];
112
+ }
113
+
114
+ function objs(v: unknown): Json[] {
115
+ return arr(v).filter(isObj);
116
+ }
117
+
118
+ function str(v: unknown): string | null {
119
+ return typeof v === 'string' ? v : null;
120
+ }
121
+
122
+ function num(v: unknown): number | null {
123
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
124
+ }
125
+
126
+ /** One comparable quantity. `total` 0 means neither side had anything to compare. */
127
+ export interface DiffMeasure {
128
+ /** Stable dotted id, e.g. `bones.parent_by_name`. Tests name these. */
129
+ id: string;
130
+ matched: number;
131
+ total: number;
132
+ ratio: number;
133
+ /** What the measure is, in one line, for the human table. */
134
+ what: string;
135
+ /** Set when the measure is vacuous or otherwise needs a caveat. */
136
+ note?: string;
137
+ }
138
+
139
+ /**
140
+ * A second comparison of one section, made without consulting a name anywhere.
141
+ *
142
+ * Its `ratio` is an unweighted mean of its OWN measures and is not comparable,
143
+ * term for term, with the section's — the two sets overlap but neither contains
144
+ * the other. `count` / `depth_histogram` / `degree_sequence` appear in both,
145
+ * once under their section id and once under `<section>.agnostic.*`, so that
146
+ * each report reads on its own without the other open beside it.
147
+ */
148
+ export interface DiffAgnostic {
149
+ /** Unweighted mean of the measures below. NOT a quality score either. */
150
+ ratio: number;
151
+ measures: DiffMeasure[];
152
+ /**
153
+ * How the two sides were put against each other, for a block that had to
154
+ * choose — `animations` alone today. Absent where the correspondence is the
155
+ * elements themselves and there was nothing to decide.
156
+ *
157
+ * ⚠️ It is data rather than a caption. A block whose figures depend on a
158
+ * pairing, printed without the pairing beside it, is a measurement of
159
+ * something the reader cannot name — and the two pairings say different
160
+ * things: `--as` is the caller's claim, position is this file's reading of a
161
+ * one-against-one roster.
162
+ */
163
+ pairedBy?: string;
164
+ }
165
+
166
+ /**
167
+ * Measures that are **reported and never gate**, with no mean over them.
168
+ *
169
+ * `docs/GATE.md`'s *What never gates* seals off "anything unobservable by
170
+ * construction", and its test is *could any reading of the frames have decided
171
+ * it?* — mesh `edges` draw no pixel at all, and two keyings of one curve render
172
+ * the same frames at every rate. Those measures still belong in the report:
173
+ * `bench` is a structural comparison against the reference export and a feature
174
+ * the reference declares that a candidate drops is a finding, whether or not a
175
+ * clause may read it.
176
+ *
177
+ * ⚠️ No `ratio` field, deliberately, and not for lack of arithmetic. The
178
+ * measures here have unlike units — a presence share, a keys-per-second
179
+ * agreement — so their mean would be a number with no referent, and the one
180
+ * number a stored ladder row quotes per section is `DiffSection.ratio`. Keeping
181
+ * these out of it is what lets a measure be added without moving a figure
182
+ * somebody has already recorded.
183
+ */
184
+ export interface DiffReported {
185
+ measures: DiffMeasure[];
186
+ }
187
+
188
+ export interface DiffSection {
189
+ name: string;
190
+ /** Unweighted mean of this section's measures. NOT a quality score. */
191
+ ratio: number;
192
+ measures: DiffMeasure[];
193
+ /**
194
+ * The same section compared with names thrown away — `bones` and `slots`
195
+ * only, because they are the two sections whose measures are dominated by
196
+ * name-keyed ones (#21). Absent elsewhere rather than empty: a section with
197
+ * no name-agnostic comparison defined should say so by having none.
198
+ */
199
+ nameAgnostic?: DiffAgnostic;
200
+ /**
201
+ * Measures reported beside this section and folded into nothing. Absent
202
+ * rather than empty, for the same reason `nameAgnostic` is.
203
+ */
204
+ reported?: DiffReported;
205
+ }
206
+
207
+ export interface DiffReport {
208
+ sections: DiffSection[];
209
+ /**
210
+ * The `skeleton` block itself — measured, and reported beside the sections
211
+ * rather than inside one (issue #578).
212
+ *
213
+ * ⭐ It is a `DiffReported` and not a seventh `DiffSection`, and the reason is
214
+ * the one thing a section must have: a `ratio`. A section's mean is the figure
215
+ * a stored `bench.json` row quotes, and these measures cannot be in one —
216
+ * `docs/GATE.md`'s *What never gates* covers them twice over, so a `skeleton`
217
+ * section would carry either a vacuous `mean 1.000 over 0 measures` (the false
218
+ * green this file exists to refuse) or a nullable `ratio` every reader of every
219
+ * other section would then have to handle. `DiffReported` already models
220
+ * exactly "measures with no mean over them", which is what the header is.
221
+ *
222
+ * ⚠️ Not optional, deliberately: a block that may be absent is a block that can
223
+ * silently stop being emitted, and `movedReportedMeasures` over an absent one
224
+ * is the empty list — indistinguishable from one that is all 1.000. The
225
+ * selftest asserts its COUNT for the same reason it does for the others.
226
+ */
227
+ header: DiffReported;
228
+ /** Raw counts either side, for orientation. Never combined into anything. */
229
+ candidate: Record<string, number>;
230
+ reference: Record<string, number>;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // measure primitives
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const FRAME = 1 / 60;
238
+
239
+ function ratioOf(matched: number, total: number): number {
240
+ return total === 0 ? 1 : matched / total;
241
+ }
242
+
243
+ function measure(id: string, what: string, matched: number, total: number, note?: string): DiffMeasure {
244
+ return {
245
+ id,
246
+ what,
247
+ matched,
248
+ total,
249
+ ratio: ratioOf(matched, total),
250
+ ...(total === 0 ? { note: note ?? 'neither side has any' } : note ? { note } : {}),
251
+ };
252
+ }
253
+
254
+ /** |A ∩ B| over |A ∪ B| — the set measure. */
255
+ function jaccard(id: string, what: string, a: Set<string>, b: Set<string>): DiffMeasure {
256
+ let shared = 0;
257
+ for (const x of a) if (b.has(x)) shared++;
258
+ return measure(id, what, shared, a.size + b.size - shared);
259
+ }
260
+
261
+ /**
262
+ * Histogram intersection: sum of the per-bucket minima over the larger total.
263
+ * Used for every name-agnostic comparison, because it degrades smoothly — one
264
+ * bone in the wrong bucket costs one bone, not the whole measure.
265
+ */
266
+ function histogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
267
+ let shared = 0;
268
+ for (const [k, v] of a) shared += Math.min(v, b.get(k) ?? 0);
269
+ const sum = (m: Map<string, number>) => [...m.values()].reduce((x, y) => x + y, 0);
270
+ return measure(id, what, shared, Math.max(sum(a), sum(b)));
271
+ }
272
+
273
+ /** Longest common subsequence length — order agreement that survives an insert. */
274
+ function lcs(a: string[], b: string[]): number {
275
+ const rows: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
276
+ for (let i = 1; i <= a.length; i++) {
277
+ for (let j = 1; j <= b.length; j++) {
278
+ rows[i][j] = a[i - 1] === b[j - 1] ? rows[i - 1][j - 1] + 1 : Math.max(rows[i - 1][j], rows[i][j - 1]);
279
+ }
280
+ }
281
+ return rows[a.length][b.length];
282
+ }
283
+
284
+ function counted(values: Iterable<string>): Map<string, number> {
285
+ const out = new Map<string, number>();
286
+ for (const v of values) out.set(v, (out.get(v) ?? 0) + 1);
287
+ return out;
288
+ }
289
+
290
+ /**
291
+ * Agreement over the names both sides have, scored against the larger roster.
292
+ *
293
+ * ⚠️ The denominator is deliberately `max(candidate, reference)` rather than the
294
+ * shared count. Scoring only the shared names would let a candidate with two of
295
+ * twenty bones report 1.000 for "parents agree", which is the shape of a
296
+ * measurement that flatters whatever is missing.
297
+ */
298
+ function agreement<T>(
299
+ id: string,
300
+ what: string,
301
+ a: Map<string, T>,
302
+ b: Map<string, T>,
303
+ same: (x: T, y: T) => boolean,
304
+ ): DiffMeasure {
305
+ let agree = 0;
306
+ for (const [k, v] of a) {
307
+ const other = b.get(k);
308
+ if (other !== undefined && same(v, other)) agree++;
309
+ }
310
+ return measure(id, what, agree, Math.max(a.size, b.size));
311
+ }
312
+
313
+ function meanRatio(measures: DiffMeasure[]): number {
314
+ return measures.length === 0 ? 1 : measures.reduce((s, m) => s + m.ratio, 0) / measures.length;
315
+ }
316
+
317
+ function sectionOf(
318
+ name: string,
319
+ measures: DiffMeasure[],
320
+ nameAgnostic?: DiffMeasure[],
321
+ reported?: DiffMeasure[],
322
+ pairedBy?: string,
323
+ ): DiffSection {
324
+ return {
325
+ name,
326
+ ratio: meanRatio(measures),
327
+ measures,
328
+ ...(nameAgnostic === undefined
329
+ ? {}
330
+ : {
331
+ nameAgnostic: {
332
+ ratio: meanRatio(nameAgnostic),
333
+ measures: nameAgnostic,
334
+ ...(pairedBy === undefined ? {} : { pairedBy }),
335
+ },
336
+ }),
337
+ ...(reported === undefined ? {} : { reported: { measures: reported } }),
338
+ };
339
+ }
340
+
341
+ /**
342
+ * How many decimal places a reported RATE is compared at.
343
+ *
344
+ * The counts column prints `matched`/`total` verbatim, so a rate measured to
345
+ * full float precision would print seventeen digits of it. Rounding here rather
346
+ * than at print time keeps `ratio === matched / total` true in the data — a
347
+ * printed figure that is not the compared quantity is the same defect as a
348
+ * measure nobody asserts on, one layer down.
349
+ */
350
+ const RATE_PLACES = 3;
351
+
352
+ function atPlaces(n: number): number {
353
+ const scale = 10 ** RATE_PLACES;
354
+ return Math.round(n * scale) / scale;
355
+ }
356
+
357
+ /**
358
+ * Two rates compared as `min / max`, so that identity reads 1.000 and either
359
+ * direction of disagreement reads below it.
360
+ *
361
+ * ⚠️ The direction is the whole point of the measure and a ratio cannot carry
362
+ * it, so `note` states both rates and which side is the bigger one. A histogram
363
+ * intersection over the same quantity reads 421/1339 whether the candidate has
364
+ * a third of the reference's keys or three times them — that ambiguity is what
365
+ * [issue #20](https://github.com/firejune/rigc/issues/20) turned on, and a
366
+ * figure that repeated it would be the second copy of the same defect.
367
+ */
368
+ function rateAgreement(id: string, what: string, unit: string, a: number, b: number, convention: string): DiffMeasure {
369
+ const candidate = atPlaces(a);
370
+ const reference = atPlaces(b);
371
+ const lo = Math.min(candidate, reference);
372
+ const hi = Math.max(candidate, reference);
373
+ const direction =
374
+ hi === 0
375
+ ? 'neither side has any'
376
+ : candidate === reference
377
+ ? 'the two agree'
378
+ : reference === 0
379
+ ? 'the reference has none'
380
+ : candidate === 0
381
+ ? 'the candidate has none'
382
+ : `candidate carries ${(candidate / reference).toFixed(2)}x — ${candidate > reference ? 'OVER' : 'UNDER'}-keyed`;
383
+ return {
384
+ id,
385
+ what,
386
+ matched: lo,
387
+ total: hi,
388
+ ratio: ratioOf(lo, hi),
389
+ note: `candidate ${candidate} vs reference ${reference} ${unit}; ${direction}. ${convention}`,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Order agreement over a sequence of SHAPES rather than names — the
395
+ * name-agnostic half of an `order` measure.
396
+ *
397
+ * LCS over the signatures, against the larger roster, exactly as the
398
+ * name-matched `order` measures do it, so the two read on the same scale. Two
399
+ * elements with the same signature are interchangeable here and swapping them
400
+ * is correctly invisible: name-agnostically they ARE the same element. The
401
+ * name-matched `order` measure is what catches that swap.
402
+ */
403
+ function orderShape(id: string, what: string, a: string[], b: string[]): DiffMeasure {
404
+ return measure(id, what, lcs(a, b), Math.max(a.length, b.length));
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // per-section extraction
409
+ // ---------------------------------------------------------------------------
410
+
411
+ interface BoneFacts {
412
+ order: string[];
413
+ parent: Map<string, string | null>;
414
+ hasLength: Map<string, boolean>;
415
+ hasInherit: Map<string, boolean>;
416
+ depths: Map<string, number>;
417
+ children: Map<string, number>;
418
+ }
419
+
420
+ function boneFacts(root: Json): BoneFacts {
421
+ const bones = objs(root.bones);
422
+ const order: string[] = [];
423
+ const parent = new Map<string, string | null>();
424
+ const hasLength = new Map<string, boolean>();
425
+ const hasInherit = new Map<string, boolean>();
426
+ const children = new Map<string, number>();
427
+ for (const b of bones) {
428
+ const name = str(b.name);
429
+ if (name === null) continue;
430
+ order.push(name);
431
+ parent.set(name, str(b.parent));
432
+ hasLength.set(name, 'length' in b);
433
+ hasInherit.set(name, 'inherit' in b);
434
+ children.set(name, children.get(name) ?? 0);
435
+ }
436
+ for (const [, p] of parent) {
437
+ if (p !== null) children.set(p, (children.get(p) ?? 0) + 1);
438
+ }
439
+ // Depth by walking to the root. A parent declared after its child is invalid
440
+ // Spine, so the walk terminates on any file the parser would accept; the
441
+ // `seen` guard is there so a malformed candidate cannot hang the comparison.
442
+ const depths = new Map<string, number>();
443
+ for (const name of order) {
444
+ let depth = 0;
445
+ const seen = new Set<string>([name]);
446
+ for (let at = parent.get(name) ?? null; at !== null; at = parent.get(at) ?? null) {
447
+ depth++;
448
+ if (seen.has(at)) break;
449
+ seen.add(at);
450
+ }
451
+ depths.set(name, depth);
452
+ }
453
+ return { order, parent, hasLength, hasInherit, depths, children };
454
+ }
455
+
456
+ /**
457
+ * The shape of a bone nothing declares — a slot bound to a name no bone
458
+ * carries. A real answer rather than a gap: it says "this hangs off nothing".
459
+ */
460
+ const UNDECLARED = '?';
461
+
462
+ /**
463
+ * A bone's place in the tree, written without its name: how far it sits from a
464
+ * root, and how many children hang off it. `d1c3` is "one hop down, three
465
+ * children".
466
+ *
467
+ * Nothing else in a bone's declaration is name-free AND structural. `x`, `y`,
468
+ * `rotation`, `scale` are the setup pose, which this section does not compare
469
+ * on either side of the split; `length` and `inherit` are compared by name
470
+ * above and have no name-free counterpart, because there is no way to say
471
+ * WHICH bone is missing its length without naming one.
472
+ */
473
+ function boneShape(facts: BoneFacts, name: string): string {
474
+ if (!facts.parent.has(name)) return UNDECLARED;
475
+ return `d${facts.depths.get(name) ?? 0}c${facts.children.get(name) ?? 0}`;
476
+ }
477
+
478
+ function boneShapes(facts: BoneFacts): string[] {
479
+ return facts.order.map((n) => boneShape(facts, n));
480
+ }
481
+
482
+ function diffBones(c: Json, r: Json): DiffSection {
483
+ const a = boneFacts(c);
484
+ const b = boneFacts(r);
485
+ const shared = a.order.filter((n) => b.parent.has(n));
486
+ const max = Math.max(a.order.length, b.order.length);
487
+ const aShapes = boneShapes(a);
488
+ const bShapes = boneShapes(b);
489
+ return sectionOf(
490
+ 'bones',
491
+ [
492
+ measure('bones.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
493
+ jaccard('bones.names', 'the bone names themselves', new Set(a.order), new Set(b.order)),
494
+ agreement('bones.parent_by_name', 'each bone hangs off the same parent', a.parent, b.parent, (x, y) => x === y),
495
+ measure(
496
+ 'bones.order',
497
+ 'the bones are declared in the same order',
498
+ lcs(shared, b.order.filter((n) => a.parent.has(n))),
499
+ max,
500
+ ),
501
+ agreement('bones.length_present', 'a setup `length` is present or absent alike', a.hasLength, b.hasLength, (x, y) => x === y),
502
+ agreement('bones.inherit_present', 'a setup `inherit` is present or absent alike', a.hasInherit, b.hasInherit, (x, y) => x === y),
503
+ histogram(
504
+ 'bones.depth_histogram',
505
+ 'NAME-AGNOSTIC: as many bones at each depth',
506
+ counted([...a.depths.values()].map(String)),
507
+ counted([...b.depths.values()].map(String)),
508
+ ),
509
+ histogram(
510
+ 'bones.degree_sequence',
511
+ 'NAME-AGNOSTIC: as many bones with each child count',
512
+ counted([...a.children.values()].map(String)),
513
+ counted([...b.children.values()].map(String)),
514
+ ),
515
+ ],
516
+ // The tree compared as a tree — no name is consulted anywhere below.
517
+ [
518
+ measure('bones.agnostic.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
519
+ histogram(
520
+ 'bones.agnostic.depth_histogram',
521
+ 'as many bones at each depth',
522
+ counted([...a.depths.values()].map(String)),
523
+ counted([...b.depths.values()].map(String)),
524
+ ),
525
+ histogram(
526
+ 'bones.agnostic.degree_sequence',
527
+ 'as many bones with each child count',
528
+ counted([...a.children.values()].map(String)),
529
+ counted([...b.children.values()].map(String)),
530
+ ),
531
+ // Strictly stronger than the two above it, and it earns its place for
532
+ // that reason: two trees can hold the same depths and the same child
533
+ // counts while pairing them up differently — a deep leaf and a shallow
534
+ // fork against a shallow leaf and a deep fork. This asks for the pair.
535
+ histogram(
536
+ 'bones.agnostic.shape_histogram',
537
+ 'as many bones of each depth-and-child-count shape (`d1c3` = one hop down, three children)',
538
+ counted(aShapes),
539
+ counted(bShapes),
540
+ ),
541
+ orderShape(
542
+ 'bones.agnostic.order_shape',
543
+ 'the bones are declared in the same order of shapes',
544
+ aShapes,
545
+ bShapes,
546
+ ),
547
+ ],
548
+ );
549
+ }
550
+
551
+ interface SlotFacts {
552
+ order: string[];
553
+ bone: Map<string, string | null>;
554
+ attachment: Map<string, string | null>;
555
+ blend: Map<string, string>;
556
+ hasColor: Map<string, boolean>;
557
+ /**
558
+ * `<slot>` -> the TYPE of what it shows in setup: `region`, `mesh`, and so
559
+ * on; `none` when the slot shows nothing; `absent` when it names an
560
+ * attachment no skin carries. The type is name-free — it is *what kind of
561
+ * thing is drawn here*, which survives every rename — while the attachment's
562
+ * own name is compared by `slots.attachment` above.
563
+ */
564
+ setupType: Map<string, string>;
565
+ }
566
+
567
+ /** `<slot>/<attachment>` -> type, over every skin. */
568
+ function attachmentTypes(root: Json): Map<string, string> {
569
+ const out = new Map<string, string>();
570
+ for (const skin of objs(root.skins)) {
571
+ if (!isObj(skin.attachments)) continue;
572
+ for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
573
+ if (!isObj(slotMap)) continue;
574
+ for (const [attName, att] of Object.entries(slotMap)) {
575
+ if (!isObj(att)) continue;
576
+ // First skin wins. Every skeleton on the ladder has exactly one skin
577
+ // (`default`), and a per-skin type comparison would need a skin
578
+ // correspondence, which is a naming question by another route.
579
+ if (!out.has(`${slotName}/${attName}`)) out.set(`${slotName}/${attName}`, str(att.type) ?? 'region');
580
+ }
581
+ }
582
+ }
583
+ return out;
584
+ }
585
+
586
+ function slotFacts(root: Json): SlotFacts {
587
+ const order: string[] = [];
588
+ const bone = new Map<string, string | null>();
589
+ const attachment = new Map<string, string | null>();
590
+ const blend = new Map<string, string>();
591
+ const hasColor = new Map<string, boolean>();
592
+ const setupType = new Map<string, string>();
593
+ const types = attachmentTypes(root);
594
+ for (const s of objs(root.slots)) {
595
+ const name = str(s.name);
596
+ if (name === null) continue;
597
+ const setup = str(s.attachment);
598
+ order.push(name);
599
+ bone.set(name, str(s.bone));
600
+ attachment.set(name, setup);
601
+ blend.set(name, str(s.blend) ?? 'normal');
602
+ hasColor.set(name, 'color' in s);
603
+ setupType.set(name, setup === null ? 'none' : (types.get(`${name}/${setup}`) ?? 'absent'));
604
+ }
605
+ return { order, bone, attachment, blend, hasColor, setupType };
606
+ }
607
+
608
+ /** What a slot is, written without a name: what it draws, on what shape of bone. */
609
+ function slotShapes(facts: SlotFacts, bones: BoneFacts): string[] {
610
+ return facts.order.map((n) => `${facts.setupType.get(n) ?? 'none'}@${boneShape(bones, facts.bone.get(n) ?? '')}`);
611
+ }
612
+
613
+ function diffSlots(c: Json, r: Json): DiffSection {
614
+ const a = slotFacts(c);
615
+ const b = slotFacts(r);
616
+ const ab = boneFacts(c);
617
+ const bb = boneFacts(r);
618
+ const max = Math.max(a.order.length, b.order.length);
619
+ const aShapes = slotShapes(a, ab);
620
+ const bShapes = slotShapes(b, bb);
621
+ const byPosition = (f: SlotFacts): Map<string, number> =>
622
+ counted(f.order.map((n, i) => `${i}:${f.setupType.get(n) ?? 'none'}`));
623
+ const boundTo = (f: SlotFacts, bones: BoneFacts): Map<string, number> =>
624
+ counted(f.order.map((n) => boneShape(bones, f.bone.get(n) ?? '')));
625
+ return sectionOf(
626
+ 'slots',
627
+ [
628
+ measure('slots.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
629
+ jaccard('slots.names', 'the slot names themselves', new Set(a.order), new Set(b.order)),
630
+ measure(
631
+ 'slots.order',
632
+ 'the slots array IS the draw order, so its order is data',
633
+ lcs(
634
+ a.order.filter((n) => b.bone.has(n)),
635
+ b.order.filter((n) => a.bone.has(n)),
636
+ ),
637
+ max,
638
+ ),
639
+ agreement('slots.bone', 'each slot is bound to the same bone', a.bone, b.bone, (x, y) => x === y),
640
+ agreement('slots.attachment', 'each slot shows the same setup attachment', a.attachment, b.attachment, (x, y) => x === y),
641
+ agreement('slots.blend', 'each slot uses the same blend mode', a.blend, b.blend, (x, y) => x === y),
642
+ agreement('slots.color_present', 'a tint is present or absent alike', a.hasColor, b.hasColor, (x, y) => x === y),
643
+ ],
644
+ [
645
+ measure('slots.agnostic.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
646
+ // Positional on purpose, and paired with the LCS measure below for the
647
+ // same reason `slots.order` is paired with `slots.names`: this one says
648
+ // "position 3 draws a mesh on both sides", the LCS one degrades smoothly
649
+ // when a slot is inserted rather than counting every later slot wrong.
650
+ histogram(
651
+ 'slots.agnostic.attachment_types_by_position',
652
+ 'the same kind of attachment sits at each position in the draw order',
653
+ byPosition(a),
654
+ byPosition(b),
655
+ ),
656
+ histogram(
657
+ 'slots.agnostic.bone_binding_shape',
658
+ 'as many slots hang off a bone of each shape (`?` = no such bone is declared)',
659
+ boundTo(a, ab),
660
+ boundTo(b, bb),
661
+ ),
662
+ orderShape(
663
+ 'slots.agnostic.order_shape',
664
+ 'the draw order is the same order of `<attachment type>@<bone shape>`',
665
+ aShapes,
666
+ bShapes,
667
+ ),
668
+ ],
669
+ );
670
+ }
671
+
672
+ interface AttachmentFact {
673
+ type: string;
674
+ /** uv pairs, i.e. the real vertex count of a mesh. */
675
+ vertices: number | null;
676
+ triangles: number | null;
677
+ weighted: boolean | null;
678
+ hull: number | null;
679
+ /**
680
+ * Whether the mesh DECLARES an `edges` key at all — see
681
+ * `attachments.mesh_edges` for why that is the granularity and not the list.
682
+ */
683
+ edgesPresent: boolean | null;
684
+ /** `<width>x<height>` as stated, or `unstated` — see `attachments.region_size`. */
685
+ size: string;
686
+ /**
687
+ * The name the runtime gives the attachment: its stated `name`, else its
688
+ * placeholder key (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`)
689
+ * — see `attachments.runtime_name`.
690
+ */
691
+ runtimeName: string;
692
+ /**
693
+ * Every name the attachment resolves, as `<field>:<name>` and sorted — see
694
+ * `attachmentRefTokens` for which fields and why.
695
+ */
696
+ refs: string;
697
+ /** The same tokens as a list, for the note that spells a disagreement out. */
698
+ refList: string[];
699
+ }
700
+
701
+ /**
702
+ * The names an attachment resolves (issue #1085): the atlas region a region or
703
+ * a mesh draws, a clipping polygon's end slot, and the mesh a linked mesh takes
704
+ * its geometry from — with the skin and slot it is found under, where stated.
705
+ *
706
+ * ⭐ The region is read as the runtime resolves it, `path`, else the stated
707
+ * `name`, else the placeholder (SPEC_COVERAGE §1.5's three-level indirection),
708
+ * for the reason `runtime_name` reads its name that way: a file that spells the
709
+ * path out and one that leaves it to the parser name the same region, and a
710
+ * measure that called those different would score every export against every
711
+ * writer that states its paths. Every other field is a token only when the file
712
+ * states it — absent is no token, never a default, as in `constraints.refs`.
713
+ *
714
+ * Measured on public builds through spine-core: a region's `path` moved to the
715
+ * other eye of `spineboy-pro` changes 94 of its 99 sampled draws, its clipping
716
+ * polygon's `end` moved six slots on changes the clipping of 9, and a linked
717
+ * mesh on `gallery/nod` taking `ear_r` instead of `ear_l` as its source changes
718
+ * all 18 of its sampled poses. `diff` compared none of the three, and two of them occur in
719
+ * none of the twelve editor exports (a stated `path` and a linked mesh).
720
+ */
721
+ function attachmentRefTokens(type: string, key: string, att: Json): string[] {
722
+ const out: string[] = [];
723
+ if (type === 'region' || type === 'mesh' || type === 'linkedmesh') out.push(`path:${str(att.path) ?? str(att.name) ?? key}`);
724
+ for (const field of ['end', 'source', 'skin', 'slot'] as const) {
725
+ const s = str(att[field]);
726
+ if (s !== null) out.push(`${field}:${s}`);
727
+ }
728
+ return out.sort();
729
+ }
730
+
731
+ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
732
+ const skins = new Set<string>();
733
+ const byKey = new Map<string, AttachmentFact>();
734
+ for (const skin of objs(root.skins)) {
735
+ const skinName = str(skin.name) ?? 'default';
736
+ skins.add(skinName);
737
+ if (!isObj(skin.attachments)) continue;
738
+ for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
739
+ if (!isObj(slotMap)) continue;
740
+ for (const [attName, att] of Object.entries(slotMap)) {
741
+ if (!isObj(att)) continue;
742
+ const type = str(att.type) ?? 'region';
743
+ const uvs = arr(att.uvs);
744
+ const verticesRun = arr(att.vertices);
745
+ const vertexCount = uvs.length > 0 ? uvs.length / 2 : num(att.vertexCount);
746
+ // Weighted versus unweighted carries no marker: the run is longer than
747
+ // 2 numbers per vertex exactly when it holds bone/weight triples.
748
+ const weighted =
749
+ vertexCount === null || verticesRun.length === 0 ? null : verticesRun.length !== vertexCount * 2;
750
+ byKey.set(`${skinName}/${slotName}/${attName}`, {
751
+ type,
752
+ vertices: type === 'mesh' ? vertexCount : null,
753
+ triangles: type === 'mesh' ? arr(att.triangles).length / 3 : null,
754
+ weighted: type === 'mesh' ? weighted : null,
755
+ hull: type === 'mesh' ? num(att.hull) : null,
756
+ // `'edges' in att` rather than a length test, so a mesh that declares
757
+ // `"edges": []` reads as DECLARED. That is a different statement from
758
+ // omitting the key — the parser stores it as a different thing — and
759
+ // `bench/count_features.ts`'s `mesh_hasEdges` survey counts presence
760
+ // the same way, so the corpus census and this measure agree on what
761
+ // "has edges" means.
762
+ edgesPresent: type === 'mesh' ? 'edges' in att : null,
763
+ size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
764
+ runtimeName: str(att.name) ?? attName,
765
+ refs: attachmentRefTokens(type, attName, att).join(' '),
766
+ refList: attachmentRefTokens(type, attName, att),
767
+ });
768
+ }
769
+ }
770
+ }
771
+ return { skins, byKey };
772
+ }
773
+
774
+ function diffAttachments(c: Json, r: Json): DiffSection {
775
+ const a = attachmentFacts(c);
776
+ const b = attachmentFacts(r);
777
+ const meshes = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'mesh'));
778
+ const regions = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'region'));
779
+ const am = meshes(a.byKey);
780
+ const bm = meshes(b.byKey);
781
+ const ar = regions(a.byKey);
782
+ const br = regions(b.byKey);
783
+ return sectionOf('attachments', [
784
+ jaccard('attachments.skins', 'the skin names', a.skins, b.skins),
785
+ measure(
786
+ 'attachments.count',
787
+ 'how many attachments',
788
+ Math.min(a.byKey.size, b.byKey.size),
789
+ Math.max(a.byKey.size, b.byKey.size),
790
+ ),
791
+ jaccard('attachments.names', 'skin/slot/attachment keys', new Set(a.byKey.keys()), new Set(b.byKey.keys())),
792
+ histogram(
793
+ 'attachments.type_counts',
794
+ 'as many of each attachment type',
795
+ counted([...a.byKey.values()].map((f) => f.type)),
796
+ counted([...b.byKey.values()].map((f) => f.type)),
797
+ ),
798
+ agreement('attachments.mesh_vertices', 'each mesh has the same vertex count', am, bm, (x, y) => x.vertices === y.vertices),
799
+ agreement('attachments.mesh_triangles', 'each mesh has the same triangle count', am, bm, (x, y) => x.triangles === y.triangles),
800
+ agreement('attachments.mesh_weighted', 'each mesh is weighted, or is not, alike', am, bm, (x, y) => x.weighted === y.weighted),
801
+ agreement('attachments.mesh_hull', 'each mesh declares the same hull length', am, bm, (x, y) => x.hull === y.hull),
802
+ // Replaces `attachments.region_size_present` (issue #28), which asked
803
+ // whether each region STATED a size and read near zero on every honest run.
804
+ //
805
+ // The issue's diagnosis was that Spine's exporter omits `width`/`height`
806
+ // when they match the atlas region, so a rigc rig — which always states
807
+ // them (AUTHORING R1/R5) — could never agree. That is not what the corpus
808
+ // says: all twelve reference exports state a size on every one of their
809
+ // regions, and no region omits either field — so there is nothing for an
810
+ // atlas lookup to resolve and the `--atlas` plumbing the issue proposed
811
+ // would be dead code against every rung on the ladder.
812
+ //
813
+ // ⛔ No count on that claim, deliberately, and this was the fourth copy of
814
+ // the one issue #490 struck off `docs/LADDER.md`: nothing derives the
815
+ // figure — not the plainest reading, every `region` attachment under
816
+ // `skins` in the twelve `export/*.json` skeletons, and not counting by
817
+ // skin, by slot, by attachment name, by atlas path or by atlas region. The
818
+ // claim carries the argument without one, because it is universal and a
819
+ // single counterexample refutes it: after `bun run fetch-examples`, read
820
+ // the region attachments of those twelve files and check that each states
821
+ // both `width` and `height`.
822
+ //
823
+ // What the measure actually reported was the naming gap, a third time. It
824
+ // was keyed by `skin/slot/attachment`, so it could never exceed the name
825
+ // overlap: rung 1 read `0/8` where `attachments.names` read `0/16`, and
826
+ // `3/5` where three names matched. That is #21's defect in this section.
827
+ //
828
+ // So it is name-agnostic and numeric instead: do the two rigs agree about
829
+ // how big their regions are? A rig that states a size a differently-named
830
+ // rig also states now agrees, which is what a structural diff is for.
831
+ histogram(
832
+ 'attachments.region_size',
833
+ 'NAME-AGNOSTIC: as many regions of each stated size (`unstated` is its own size)',
834
+ counted([...ar.values()].map((f) => f.size)),
835
+ counted([...br.values()].map((f) => f.size)),
836
+ ),
837
+ wiringAgreement(
838
+ 'attachments.refs',
839
+ 'each attachment resolves the same region, clipping end and linked-mesh source',
840
+ a.byKey,
841
+ b.byKey,
842
+ (key) => `attachment ${JSON.stringify(key)}`,
843
+ '`attachments.names`',
844
+ ),
845
+ wiringAgreement(
846
+ 'attachments.skin_members',
847
+ 'each skin activates the same skin-required bones and constraints',
848
+ skinMembers(c),
849
+ skinMembers(r),
850
+ (skin) => `skin ${JSON.stringify(skin)}`,
851
+ '`attachments.skins`',
852
+ ),
853
+ ],
854
+ undefined,
855
+ // ── reported (issue #46) ────────────────────────────────────────────────
856
+ //
857
+ // `docs/LADDER.md` gates rung 6 on three features — transform constraints,
858
+ // weighted meshes from authored geometry, and mesh `edges` — and this section
859
+ // measured nine things, none of which read the third. A candidate that
860
+ // dropped one of the rung's own gating features scored a clean 1.000 for the
861
+ // meshes it kept, which is the shape of a measurement that flatters whatever
862
+ // is missing.
863
+ //
864
+ // ⭐ Present-vs-absent, and not the list. `edges` constrains triangulation in
865
+ // the editor and has no runtime effect whatever — it draws no pixel — so two
866
+ // candidates can carry different-but-equivalent lists and mean the same rig,
867
+ // and index equality would score a correct rig below 1.000. `edges.length`
868
+ // has the same defect in weaker form: `6-arcs`' `tail` declares 116 entries
869
+ // and a mesh triangulated differently declares a different number while
870
+ // being just as right. Present-vs-absent is the only distinction that is
871
+ // unambiguous, and it is exactly the one that was invisible.
872
+ //
873
+ // 🚫 Reported rather than in the mean, and on principle rather than for
874
+ // compatibility: `edges` is unobservable by construction under GATE.md's own
875
+ // test — no reading of the frames could decide it, because no reading of the
876
+ // frames can see it.
877
+ [
878
+ agreement(
879
+ 'attachments.mesh_edges',
880
+ 'each mesh declares an edge list, or declares none, alike',
881
+ am,
882
+ bm,
883
+ (x, y) => x.edgesPresent === y.edgesPresent,
884
+ ),
885
+ // ── issue #796 ───────────────────────────────────────────────────────────
886
+ //
887
+ // The name the RUNTIME gives each attachment — `name` if stated, else the
888
+ // placeholder key — agreed per skin/slot/placeholder. Every measure above is
889
+ // keyed by the placeholder, so a rebuild that renamed every attachment while
890
+ // keeping every key read 1.000 across this whole section: that is what a
891
+ // rebuild did on two production rigs (a stated name respelled as `path`, and
892
+ // `<skin>/<placeholder>` composed over a name the file never stated), and it
893
+ // took a pose oracle comparing `slot.attachment.name` to see it.
894
+ //
895
+ // 🚫 Reported rather than in the mean, by the same test as `mesh_edges`: a
896
+ // name draws no pixel, so no reading of the frames could decide it. It is
897
+ // still a value a consumer reads — `slot.attachment.name` — which is why it
898
+ // is measured at all.
899
+ //
900
+ // ⚠️ Over the keys BOTH sides hold, not over the larger roster, and that is
901
+ // the one departure from `agreement` here. A key one side lacks is already
902
+ // `attachments.names` and `attachments.count`; scored again here it would
903
+ // move this measure on every rename of a key and every dropped attachment,
904
+ // and "the same key answers to another name" — the only thing nothing else
905
+ // reads — would be one term among many. The denominator this reports is the
906
+ // number of keys compared, so a report over few shared keys says so.
907
+ runtimeNames(a.byKey, b.byKey),
908
+ ]);
909
+ }
910
+
911
+ /**
912
+ * What each skin activates (issue #1085): the `bones` and the per-kind
913
+ * constraint lists a skin declares, as `<list>:<name>` tokens, sorted — a skin's
914
+ * MEMBERSHIP, which is what decides whether a bone or constraint marked
915
+ * `skin: true` is active under it.
916
+ *
917
+ * Measured on public builds through spine-core: a `gallery/look` bone made
918
+ * skin-required and left out of the skin's `bones` moves every one of its 27
919
+ * sampled poses, and `gallery/walk`'s `leg_b_ik` left out of the skin's `ik`
920
+ * moves all 9. None of the twelve editor exports declares a membership list, so
921
+ * every one of them reads `1/1` per skin here — two empty lists agree — which is
922
+ * the comparison being made rather than an absence of one.
923
+ */
924
+ function skinMembers(root: Json): Map<string, { refs: string; refList: string[] }> {
925
+ const out = new Map<string, { refs: string; refList: string[] }>();
926
+ for (const skin of objs(root.skins)) {
927
+ const name = str(skin.name) ?? 'default';
928
+ const refList: string[] = [];
929
+ for (const list of ['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const) {
930
+ for (const member of arr(skin[list])) {
931
+ const s = str(member);
932
+ if (s !== null) refList.push(`${list}:${s}`);
933
+ }
934
+ }
935
+ refList.sort();
936
+ out.set(name, { refs: refList.join(' '), refList });
937
+ }
938
+ return out;
939
+ }
940
+
941
+ /**
942
+ * `agreement` over the larger roster on a `refs` string, with the note
943
+ * `constraints.refs` writes: each entry both sides hold whose names differ,
944
+ * field by field and candidate first, then a count of the entries one side holds
945
+ * alone, which `elsewhere` is the measure that names.
946
+ */
947
+ function wiringAgreement<T extends { refs: string; refList: string[] }>(
948
+ id: string,
949
+ what: string,
950
+ a: Map<string, T>,
951
+ b: Map<string, T>,
952
+ label: (key: string) => string,
953
+ elsewhere: string,
954
+ ): DiffMeasure {
955
+ const base = agreement(id, what, a, b, (x, y) => x.refs === y.refs);
956
+ const missed = base.total - base.matched;
957
+ if (missed === 0) return base;
958
+ const differ: string[] = [];
959
+ for (const [key, fact] of a) {
960
+ const other = b.get(key);
961
+ if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(label(key), fact, other));
962
+ }
963
+ const oneSided = missed - differ.length;
964
+ const spelled = differ.slice(0, REFS_SPELLED_OUT);
965
+ const note =
966
+ (differ.length === 0 ? '' : `${differ.length} differ: ${spelled.join('; ')}`) +
967
+ (differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
968
+ (oneSided === 0 ? '' : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which ${elsewhere} names`);
969
+ return { ...base, note };
970
+ }
971
+
972
+ /**
973
+ * `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
974
+ * hold, how many answer to the same runtime name — see the note at its call.
975
+ */
976
+ function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentFact>): DiffMeasure {
977
+ let shared = 0;
978
+ let agree = 0;
979
+ const differ: string[] = [];
980
+ for (const [key, fact] of a) {
981
+ const other = b.get(key);
982
+ if (other === undefined) continue;
983
+ shared++;
984
+ if (fact.runtimeName === other.runtimeName) agree++;
985
+ else if (differ.length < 3) differ.push(`${key} "${fact.runtimeName}" vs "${other.runtimeName}"`);
986
+ }
987
+ const missed = shared - agree;
988
+ return measure(
989
+ 'attachments.runtime_name',
990
+ 'each attachment both sides hold answers to the same name at runtime (`name` if stated, else its placeholder)',
991
+ agree,
992
+ shared,
993
+ shared === 0
994
+ ? 'no skin/slot/placeholder key is on both sides, so no name was compared'
995
+ : missed === 0
996
+ ? undefined
997
+ : `${missed} renamed: ${differ.join('; ')}${missed > differ.length ? `; …and ${missed - differ.length} more` : ''}`,
998
+ );
999
+ }
1000
+
1001
+ interface ConstraintFact {
1002
+ type: string;
1003
+ /**
1004
+ * Every name the constraint resolves, as `<field>:<name>` and sorted — its
1005
+ * wiring: the bones and slots it reads and writes, a slider's animation, and
1006
+ * the properties a slider or a transform maps (`property:rotate`,
1007
+ * `property:x>y`). See `constraintFacts` for which fields and why.
1008
+ */
1009
+ refs: string;
1010
+ /** The same tokens as a list, for the note that spells a disagreement out. */
1011
+ refList: string[];
1012
+ }
1013
+
1014
+ /**
1015
+ * Every constraint, keyed the way the format resolves one: by KIND and name.
1016
+ *
1017
+ * ⚠️ Keyed by the name alone, a skeleton that carries `leg` as an ik constraint
1018
+ * and as a transform one — valid Spine, and what `findConstraint(name, type)`
1019
+ * exists to tell apart — lost one of the pair out of all five measures below
1020
+ * (issue #692). It was invisible rather than wrong: both sides collapse the same
1021
+ * way, so the report read 1.000 while its own header line counted one constraint
1022
+ * more than `constraints.count` did, measured at 14/14 against 13/13 on the
1023
+ * export that made the card.
1024
+ *
1025
+ * ⭐ **What the wiring is** (issue #1078): every field of a 4.3 constraint whose
1026
+ * value is a NAME the parser resolves — `SPEC_COVERAGE.md` §1.4, field by field.
1027
+ * `bones`, `bone`, `target`, `source` and `slot` name a bone or a slot; a
1028
+ * slider's `animation` names an animation; a slider's `property` and the
1029
+ * `from`/`to` keys of a transform's `properties` name the property read and the
1030
+ * property written, from a closed set the parser throws outside of. Every other
1031
+ * field is a number, a flag or a mode — a value, which this file does not compare
1032
+ * on either side of any split; the value walk (`diffSkeletonValues`) does.
1033
+ *
1034
+ * 🔍 Until #1078 the list stopped at the five bone and slot fields, so an editor
1035
+ * import that repointed both sliders of a probe (`yaw -> "5"` came back as
1036
+ * `yaw -> "-a"`, issue #1040) read 1.000 on every constraint measure. The two
1037
+ * property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
1038
+ * a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
1039
+ * `x>y` moved no measure here and no value in the value walk either (its skip
1040
+ * list named `properties`), so nothing in the tree compared them at all. Since
1041
+ * issue #1084 the walk reads both, by class (`property/kind`, `properties/FromX/to/ToY`).
1042
+ *
1043
+ * ⚠️ Absent is no token, never a default: a transform with no `properties` maps
1044
+ * nothing, and two sides that both omit a field agree about it. And the tokens
1045
+ * are a set, so the order a file writes `properties` in — an object, re-keyed by
1046
+ * any writer — is not a difference.
1047
+ */
1048
+ function constraintFacts(root: Json): Map<string, ConstraintFact> {
1049
+ const out = new Map<string, ConstraintFact>();
1050
+ for (const con of objs(root.constraints)) {
1051
+ const name = str(con.name);
1052
+ if (name === null) continue;
1053
+ const at = constraintAt(str(con.type) ?? '(none)', name);
1054
+ const refs = new Set<string>();
1055
+ for (const b of arr(con.bones)) {
1056
+ const s = str(b);
1057
+ if (s !== null) refs.add(`bone:${s}`);
1058
+ }
1059
+ for (const key of ['bone', 'target', 'source', 'slot', 'animation', 'property'] as const) {
1060
+ const s = str(con[key]);
1061
+ if (s !== null) refs.add(`${key}:${s}`);
1062
+ }
1063
+ if (isObj(con.properties)) {
1064
+ for (const [from, entry] of Object.entries(con.properties)) {
1065
+ const to = isObj(entry) && isObj(entry.to) ? Object.keys(entry.to) : [];
1066
+ // A `from` with no `to` maps nothing at runtime, but it is still a
1067
+ // statement the file makes, so it is a token of its own rather than
1068
+ // silently equal to the key being absent.
1069
+ if (to.length === 0) refs.add(`property:${from}>`);
1070
+ for (const t of to) refs.add(`property:${from}>${t}`);
1071
+ }
1072
+ }
1073
+ const refList = [...refs].sort();
1074
+ out.set(at, { type: str(con.type) ?? '(none)', refs: refList.join(' '), refList });
1075
+ }
1076
+ return out;
1077
+ }
1078
+
1079
+ /** How many disagreeing constraints `constraints.refs`' note spells out before it counts the rest. */
1080
+ const REFS_SPELLED_OUT = 3;
1081
+
1082
+ /**
1083
+ * One constraint's disagreement, by field: `slider constraint "yaw": animation
1084
+ * "5" vs "-a"` — the candidate's names for each field that differs, then the
1085
+ * reference's, `none` where a side has none.
1086
+ */
1087
+ function refsDisagreement(at: string, a: { refList: string[] }, b: { refList: string[] }): string {
1088
+ const byField = (f: { refList: string[] }): Map<string, string[]> => {
1089
+ const m = new Map<string, string[]>();
1090
+ for (const token of f.refList) {
1091
+ const colon = token.indexOf(':');
1092
+ const field = token.slice(0, colon);
1093
+ m.set(field, [...(m.get(field) ?? []), token.slice(colon + 1)]);
1094
+ }
1095
+ return m;
1096
+ };
1097
+ const af = byField(a);
1098
+ const bf = byField(b);
1099
+ const fields = [...new Set([...af.keys(), ...bf.keys()])].sort();
1100
+ const spell = (names: string[] | undefined): string =>
1101
+ names === undefined ? 'none' : names.map((n) => JSON.stringify(n)).join(', ');
1102
+ const parts = fields
1103
+ .filter((f) => (af.get(f) ?? []).join(' ') !== (bf.get(f) ?? []).join(' '))
1104
+ .map((f) => `${f} ${spell(af.get(f))} vs ${spell(bf.get(f))}`);
1105
+ return `${at}: ${parts.join(', ')}`;
1106
+ }
1107
+
1108
+ /**
1109
+ * `constraints.refs`: `agreement` over the constraints, unchanged in what it
1110
+ * counts, with a note naming each disagreement and both sides' names — the
1111
+ * figure alone says that some constraint is wired differently, and a reader
1112
+ * acting on it needs to know which one and to what (issue #1078).
1113
+ *
1114
+ * The note's two counts partition the misses: a constraint both sides hold whose
1115
+ * wiring differs is spelled out, and one only one side holds is counted, since
1116
+ * `constraints.names` is the measure that names it.
1117
+ */
1118
+ function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, ConstraintFact>): DiffMeasure {
1119
+ const base = agreement(
1120
+ 'constraints.refs',
1121
+ 'each constraint names the same bones, slots, animation and properties',
1122
+ a,
1123
+ b,
1124
+ (x, y) => x.refs === y.refs,
1125
+ );
1126
+ const differ: string[] = [];
1127
+ for (const [at, fact] of a) {
1128
+ const other = b.get(at);
1129
+ if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(at, fact, other));
1130
+ }
1131
+ const missed = base.total - base.matched;
1132
+ if (missed === 0) return base;
1133
+ const oneSided = missed - differ.length;
1134
+ const spelled = differ.slice(0, REFS_SPELLED_OUT);
1135
+ const note =
1136
+ (differ.length === 0 ? '' : `${differ.length} wired differently: ${spelled.join('; ')}`) +
1137
+ (differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
1138
+ (oneSided === 0
1139
+ ? ''
1140
+ : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which \`constraints.names\` names`);
1141
+ return { ...base, note };
1142
+ }
1143
+
1144
+ /**
1145
+ * The constraints in the order the file declares them, by kind and name — the
1146
+ * key `constraintFacts` uses, so that `leg` as an ik and as a transform are two
1147
+ * entries here as well.
1148
+ *
1149
+ * 🔍 **Why the order is data** (issue #1085). 4.3 folds every constraint into one
1150
+ * array and applies them in its order, so two files with the same constraints
1151
+ * wired the same way can pose differently. Measured by reversing the array and
1152
+ * posing both through spine-core: `spineboy-pro` moves 98 of its 99 sampled
1153
+ * poses, `6-arcs-pro` 8 of 9 and `sack-pro` 8 of 36 (33 of 36 with physics
1154
+ * stepped); swapping `aim-torso-ik` and `aim-torso-transform` alone moves the 9
1155
+ * samples of `aim`. Before this nothing in the tree compared it — the value walk
1156
+ * keys constraints by name, so it is blind to the order by construction.
1157
+ *
1158
+ * ⚠️ Not every swap poses differently, and the measure does not pretend to know
1159
+ * which do: `gallery/look`'s two sliders and `gallery/walk`'s two legs drive
1160
+ * disjoint bones and read the same pose either way. A reordering is reported as
1161
+ * what the file states, the way `bones.order` reports one.
1162
+ */
1163
+ function constraintOrder(root: Json): string[] {
1164
+ const out: string[] = [];
1165
+ for (const con of objs(root.constraints)) {
1166
+ const name = str(con.name);
1167
+ if (name !== null) out.push(constraintAt(str(con.type) ?? '(none)', name));
1168
+ }
1169
+ return out;
1170
+ }
1171
+
1172
+ function diffConstraints(c: Json, r: Json): DiffSection {
1173
+ const a = constraintFacts(c);
1174
+ const b = constraintFacts(r);
1175
+ const ao = constraintOrder(c);
1176
+ const bo = constraintOrder(r);
1177
+ const shared = ao.filter((n) => b.has(n));
1178
+ const sharedOther = bo.filter((n) => a.has(n));
1179
+ const inOrder = lcs(shared, sharedOther);
1180
+ const max = Math.max(a.size, b.size);
1181
+ return sectionOf('constraints', [
1182
+ measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
1183
+ jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
1184
+ histogram(
1185
+ 'constraints.type_counts',
1186
+ 'as many of each constraint type',
1187
+ counted([...a.values()].map((f) => f.type)),
1188
+ counted([...b.values()].map((f) => f.type)),
1189
+ ),
1190
+ agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
1191
+ constraintRefs(a, b),
1192
+ measure(
1193
+ 'constraints.order',
1194
+ 'the constraints are declared, and so applied, in the same order',
1195
+ inOrder,
1196
+ max,
1197
+ [
1198
+ ...(inOrder === shared.length ? [] : [outOfOrder(shared, sharedOther)]),
1199
+ ...(shared.length === max ? [] : [`${max - shared.length} of the larger roster not on both sides, which \`constraints.names\` names`]),
1200
+ ].join('; ') || undefined,
1201
+ ),
1202
+ ]);
1203
+ }
1204
+
1205
+ /**
1206
+ * `constraints.order`'s note when the shared constraints are not in one order:
1207
+ * both sides' order of them, candidate first, spelled out to a limit.
1208
+ */
1209
+ function outOfOrder(a: string[], b: string[]): string {
1210
+ const spell = (list: string[]): string =>
1211
+ list.slice(0, ORDER_SPELLED_OUT).join(', ') + (list.length > ORDER_SPELLED_OUT ? `, …and ${list.length - ORDER_SPELLED_OUT} more` : '');
1212
+ return `${a.length - lcs(a, b)} out of order: candidate ${spell(a)}; reference ${spell(b)}`;
1213
+ }
1214
+
1215
+ /** How many constraints `constraints.order`'s note lists per side before it counts the rest. */
1216
+ const ORDER_SPELLED_OUT = 6;
1217
+
1218
+ interface AnimationFacts {
1219
+ names: string[];
1220
+ /** `<anim>` -> last key time. Skeleton JSON has no duration field. */
1221
+ duration: Map<string, number>;
1222
+ /** `<anim>|<path>` -> 1, one entry per timeline that exists. */
1223
+ kinds: Map<string, number>;
1224
+ /** `<anim>|<kind>` -> number of keys. */
1225
+ keys: Map<string, number>;
1226
+ /** `<anim>|linear|stepped|bezier` -> number of keys with that curve. */
1227
+ curves: Map<string, number>;
1228
+ events: Map<string, number>;
1229
+ hasDrawOrder: Map<string, boolean>;
1230
+ hasDeform: Map<string, boolean>;
1231
+ }
1232
+
1233
+ function animationFacts(root: Json): AnimationFacts {
1234
+ const facts: AnimationFacts = {
1235
+ names: [],
1236
+ duration: new Map(),
1237
+ kinds: new Map(),
1238
+ keys: new Map(),
1239
+ curves: new Map(),
1240
+ events: new Map(),
1241
+ hasDrawOrder: new Map(),
1242
+ hasDeform: new Map(),
1243
+ };
1244
+ if (!isObj(root.animations)) return facts;
1245
+ for (const name of Object.keys(root.animations)) {
1246
+ facts.names.push(name);
1247
+ facts.duration.set(name, 0);
1248
+ facts.events.set(name, 0);
1249
+ facts.hasDrawOrder.set(name, false);
1250
+ facts.hasDeform.set(name, false);
1251
+ }
1252
+ const bump = (m: Map<string, number>, k: string, by = 1) => m.set(k, (m.get(k) ?? 0) + by);
1253
+ walkTimelines(root, (path, kind, name, keys) => {
1254
+ const anim = path.slice(0, path.indexOf('.'));
1255
+ // The path carries the target's name (bone `face`, constraint `probe_ik`),
1256
+ // which is compared under bones/constraints already. Here only the SHAPE
1257
+ // matters, so the target is dropped and the kind kept.
1258
+ bump(facts.kinds, `${anim}|${kind}.${name}`);
1259
+ bump(facts.keys, `${anim}|${kind}.${name}`, keys.length);
1260
+ if (kind === 'drawOrder' || kind === 'drawOrderFolder') facts.hasDrawOrder.set(anim, true);
1261
+ if (kind === 'attachment' && name === 'deform') facts.hasDeform.set(anim, true);
1262
+ if (kind === 'event') facts.events.set(anim, (facts.events.get(anim) ?? 0) + keys.length);
1263
+ for (const key of keys) {
1264
+ if (!isObj(key)) continue;
1265
+ const time = num(key.time) ?? 0;
1266
+ if (time > (facts.duration.get(anim) ?? 0)) facts.duration.set(anim, time);
1267
+ const curve = key.curve;
1268
+ const shape = curve === undefined ? 'linear' : curve === 'stepped' ? 'stepped' : Array.isArray(curve) ? 'bezier' : 'other';
1269
+ bump(facts.curves, `${anim}|${shape}`);
1270
+ }
1271
+ });
1272
+ return facts;
1273
+ }
1274
+
1275
+ /**
1276
+ * The names the animations resolve, as two multisets of tokens (issue #1085).
1277
+ *
1278
+ * `animationFacts` keeps each timeline's kind and drops its target on purpose —
1279
+ * a candidate's own vocabulary would otherwise be counted against it a second
1280
+ * time inside `timeline_kinds` (#21) — and reads no key's contents beyond its
1281
+ * time and curve. So until this, a bone timeline moved onto another bone, an
1282
+ * attachment key naming another attachment, a draw-order key naming another slot
1283
+ * and an event key firing another event each moved no measure in this file, and
1284
+ * every one of them poses or draws differently: measured on public builds
1285
+ * through spine-core, a retargeted bone timeline moves 7 of `gallery/look`'s 27
1286
+ * sampled poses, two slots' attachment keys swapped move 9 of `spineboy-pro`'s
1287
+ * 99, a draw-order offset moved to the next slot moves 9 of `spineboy-ess`'s 72
1288
+ * and an event key renamed to a second event moves the event block of one sample.
1289
+ *
1290
+ * - `targets`: one token per timeline, `<animation>|<group>|<target>` — the
1291
+ * bone, slot or constraint it keys, a deform's or sequence's
1292
+ * `skin/slot/attachment`, and each slot a draw-order folder holds. The
1293
+ * timeline's own kind is left out on purpose: it is `timeline_kinds`'
1294
+ * subject, and a rotate keyed as a scale on the same bone moves that measure
1295
+ * and must not move this one too.
1296
+ * - `keyed`: one token per name a KEY carries, with its position in the
1297
+ * timeline — an attachment key's attachment (`(none)` for a key that clears
1298
+ * the slot), each draw-order offset's slot, an event key's event.
1299
+ *
1300
+ * Both are names, so both are name-matched, and a candidate with its own names
1301
+ * reads low here exactly as it does on `names` — the name-agnostic block is
1302
+ * where that rig is read. ⚠️ No key VALUE is read: a key's time, an offset's
1303
+ * distance and an event's payload are the value walk's.
1304
+ */
1305
+ interface AnimationRefs {
1306
+ targets: Map<string, number>;
1307
+ keyed: Map<string, number>;
1308
+ }
1309
+
1310
+ /** The physics group's empty name, which applies a timeline to every physics constraint. */
1311
+ const EVERY_PHYSICS_CONSTRAINT = '(every physics constraint)';
1312
+
1313
+ function animationRefs(root: Json): AnimationRefs {
1314
+ const targets = new Map<string, number>();
1315
+ const keyed = new Map<string, number>();
1316
+ const bump = (m: Map<string, number>, k: string): void => {
1317
+ m.set(k, (m.get(k) ?? 0) + 1);
1318
+ };
1319
+ if (!isObj(root.animations)) return { targets, keyed };
1320
+ for (const [anim, body] of Object.entries(root.animations)) {
1321
+ if (!isObj(body)) continue;
1322
+ for (const group of ['bones', 'slots', 'path', 'physics', 'slider'] as const) {
1323
+ const held = body[group];
1324
+ if (!isObj(held)) continue;
1325
+ for (const [target, timelines] of Object.entries(held)) {
1326
+ if (!isObj(timelines)) continue;
1327
+ const named = group === 'physics' && target === '' ? EVERY_PHYSICS_CONSTRAINT : target;
1328
+ for (const [timeline, keys] of Object.entries(timelines)) {
1329
+ if (!Array.isArray(keys)) continue;
1330
+ bump(targets, `${anim}|${group}|${named}`);
1331
+ if (group === 'slots' && timeline === 'attachment') {
1332
+ keys.forEach((key, i) => {
1333
+ if (isObj(key)) bump(keyed, `${anim}|attachment key ${i}|${target}: ${str(key.name) ?? '(none)'}`);
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ for (const group of ['ik', 'transform'] as const) {
1340
+ const held = body[group];
1341
+ if (!isObj(held)) continue;
1342
+ for (const [target, keys] of Object.entries(held)) if (Array.isArray(keys)) bump(targets, `${anim}|${group}|${target}`);
1343
+ }
1344
+ if (isObj(body.attachments)) {
1345
+ for (const [skin, bySlot] of Object.entries(body.attachments)) {
1346
+ if (!isObj(bySlot)) continue;
1347
+ for (const [slot, byAttachment] of Object.entries(bySlot)) {
1348
+ if (!isObj(byAttachment)) continue;
1349
+ for (const [attachment, timelines] of Object.entries(byAttachment)) {
1350
+ if (!isObj(timelines)) continue;
1351
+ for (const keys of Object.values(timelines)) {
1352
+ if (Array.isArray(keys)) bump(targets, `${anim}|attachments|${skin}/${slot}/${attachment}`);
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ }
1358
+ const offsets = (label: string, keys: unknown): void => {
1359
+ arr(keys).forEach((key, i) => {
1360
+ if (!isObj(key)) return;
1361
+ for (const offset of objs(key.offsets)) bump(keyed, `${anim}|${label} key ${i}|${str(offset.slot) ?? '(none)'}`);
1362
+ });
1363
+ };
1364
+ offsets('drawOrder', body.drawOrder);
1365
+ objs(body.drawOrderFolder).forEach((folder, f) => {
1366
+ for (const slot of arr(folder.slots)) bump(targets, `${anim}|drawOrderFolder ${f}|${str(slot) ?? '(none)'}`);
1367
+ offsets(`drawOrderFolder ${f}`, folder.keys);
1368
+ });
1369
+ arr(body.events).forEach((key, i) => {
1370
+ if (isObj(key)) bump(keyed, `${anim}|event key ${i}|${str(key.name) ?? '(none)'}`);
1371
+ });
1372
+ }
1373
+ return { targets, keyed };
1374
+ }
1375
+
1376
+ /** How many tokens a names histogram's note spells out per side before it counts the rest. */
1377
+ const TOKENS_SPELLED_OUT = 3;
1378
+
1379
+ /**
1380
+ * `histogram`, with a note naming what each side holds that the other does not —
1381
+ * the figure alone says some name moved, and a reader acting on it needs which.
1382
+ */
1383
+ function namesHistogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
1384
+ const base = histogram(id, what, a, b);
1385
+ if (base.matched === base.total) return base;
1386
+ // A token one side holds more often than the other, with how many more — a
1387
+ // bone keyed by three timelines on one side and one on the other is `×2`.
1388
+ const only = (x: Map<string, number>, y: Map<string, number>): string[] => {
1389
+ const out: string[] = [];
1390
+ for (const [k, v] of x) {
1391
+ const extra = v - (y.get(k) ?? 0);
1392
+ if (extra > 0) out.push(`${JSON.stringify(k)}${extra > 1 ? ` ×${extra}` : ''}`);
1393
+ }
1394
+ return out;
1395
+ };
1396
+ const spell = (side: string, list: string[]): string =>
1397
+ list.length === 0
1398
+ ? ''
1399
+ : `${side} only: ${list.slice(0, TOKENS_SPELLED_OUT).join(', ')}` +
1400
+ (list.length > TOKENS_SPELLED_OUT ? `, …and ${list.length - TOKENS_SPELLED_OUT} more` : '');
1401
+ const parts = [spell('candidate', only(a, b)), spell('reference', only(b, a))].filter((p) => p !== '');
1402
+ return { ...base, note: `${base.total - base.matched} differ — ${parts.join('; ')}` };
1403
+ }
1404
+
1405
+ /**
1406
+ * What a side's keying costs, in the two shapes that separate the two ways a
1407
+ * key total can differ: more timelines, or more keys inside a timeline.
1408
+ *
1409
+ * Every number here is already collected by `animationFacts` — this only sums
1410
+ * it. `seconds` is the sum of each animation's last key time, which is what
1411
+ * `animations.duration` compares, because skeleton JSON carries no duration
1412
+ * field of its own.
1413
+ */
1414
+ interface KeyingTotals {
1415
+ keys: number;
1416
+ timelines: number;
1417
+ seconds: number;
1418
+ }
1419
+
1420
+ function keyingTotals(f: AnimationFacts): KeyingTotals {
1421
+ const sum = (m: Map<string, number>): number => [...m.values()].reduce((x, y) => x + y, 0);
1422
+ return { keys: sum(f.keys), timelines: sum(f.kinds), seconds: sum(f.duration) };
1423
+ }
1424
+
1425
+ /** One animation on each side, said to be the same shot. */
1426
+ export interface DiffAnimationPair {
1427
+ candidate: string;
1428
+ reference: string;
1429
+ }
1430
+
1431
+ /**
1432
+ * What a caller may tell `diffSkeletons` that neither file can say itself.
1433
+ *
1434
+ * Only the animation pairing today, and it is an INPUT in the sense
1435
+ * `bonedist`'s correspondence file is one: two skeletons cannot derive which of
1436
+ * their shots are the same shot, so a value worked out here would be a guess
1437
+ * reported as a measurement.
1438
+ */
1439
+ export interface DiffOptions {
1440
+ /** `--as <candidate>=<reference>`, in the order the caller stated them. */
1441
+ animationPairs?: readonly DiffAnimationPair[];
1442
+ }
1443
+
1444
+ /**
1445
+ * The pairing used for `animations.agnostic.*`, or `null` when there is none.
1446
+ *
1447
+ * ⚠️ A stated pair naming an animation a side does not have is DROPPED and said
1448
+ * so in `pairedBy`, rather than silently making the block narrower. `cmdDiff`
1449
+ * refuses one by name before this is reached, so through the CLI the branch is
1450
+ * unreachable; it exists because this module is exported and a caller of the
1451
+ * API can state one.
1452
+ */
1453
+ function pairAnimations(
1454
+ a: AnimationFacts,
1455
+ b: AnimationFacts,
1456
+ stated: readonly DiffAnimationPair[],
1457
+ ): { pairs: DiffAnimationPair[]; pairedBy: string } | null {
1458
+ const spell = (p: DiffAnimationPair): string => `${p.candidate}=${p.reference}`;
1459
+ if (stated.length > 0) {
1460
+ const usable = stated.filter((p) => a.duration.has(p.candidate) && b.duration.has(p.reference));
1461
+ if (usable.length === 0) return null;
1462
+ const dropped = stated.filter((p) => !usable.includes(p));
1463
+ return {
1464
+ pairs: [...usable],
1465
+ pairedBy:
1466
+ `paired by --as: ${usable.map(spell).join(', ')}` +
1467
+ (dropped.length === 0 ? '' : `; ${dropped.map(spell).join(', ')} named an animation a side does not have and was dropped`),
1468
+ };
1469
+ }
1470
+ if (a.names.length === 1 && b.names.length === 1) {
1471
+ const pair = { candidate: a.names[0], reference: b.names[0] };
1472
+ return { pairs: [pair], pairedBy: `paired by position: ${spell(pair)}, the one animation each side carries` };
1473
+ }
1474
+ return null;
1475
+ }
1476
+
1477
+ /**
1478
+ * `f` restricted to the animations `label` names, with each one's name replaced
1479
+ * by the label — `#0` for the first pair, `#1` for the second.
1480
+ *
1481
+ * That substitution is the whole of what makes the block name-agnostic: the
1482
+ * measures below are the name-matched ones run again over facts whose keys are
1483
+ * positions in the pairing. Everything else about them — the tolerance, the
1484
+ * denominators, the histogram — is unchanged, which is what lets the two blocks
1485
+ * be read against each other.
1486
+ */
1487
+ function underLabels(f: AnimationFacts, label: ReadonlyMap<string, string>): AnimationFacts {
1488
+ const keyed = <T>(m: Map<string, T>): Map<string, T> => {
1489
+ const out = new Map<string, T>();
1490
+ for (const [anim, v] of m) {
1491
+ const to = label.get(anim);
1492
+ if (to !== undefined) out.set(to, v);
1493
+ }
1494
+ return out;
1495
+ };
1496
+ // `kinds`, `keys` and `curves` are keyed `<anim>|<rest>`, so only the head is
1497
+ // relabelled and the tail — the timeline's kind, the curve's shape — is what
1498
+ // the histogram then intersects on.
1499
+ const prefixed = (m: Map<string, number>): Map<string, number> => {
1500
+ const out = new Map<string, number>();
1501
+ for (const [k, v] of m) {
1502
+ const bar = k.indexOf('|');
1503
+ const to = label.get(k.slice(0, bar));
1504
+ if (to === undefined) continue;
1505
+ const id = `${to}${k.slice(bar)}`;
1506
+ out.set(id, (out.get(id) ?? 0) + v);
1507
+ }
1508
+ return out;
1509
+ };
1510
+ return {
1511
+ names: [...label.values()],
1512
+ duration: keyed(f.duration),
1513
+ kinds: prefixed(f.kinds),
1514
+ keys: prefixed(f.keys),
1515
+ curves: prefixed(f.curves),
1516
+ events: keyed(f.events),
1517
+ hasDrawOrder: keyed(f.hasDrawOrder),
1518
+ hasDeform: keyed(f.hasDeform),
1519
+ };
1520
+ }
1521
+
1522
+ /**
1523
+ * The six measures of `animations.agnostic.*`.
1524
+ *
1525
+ * ⛔ `names` is not among them, by construction — a block that threw the names
1526
+ * away cannot then compare them. ⛔ Neither is `count`, which `bones` and
1527
+ * `slots` do carry, and the difference is what the block is OVER: those two
1528
+ * compare whole rosters, so their agnostic half has the same subject as their
1529
+ * name-matched half and restates the count for a reader with one block open.
1530
+ * This one is over the PAIRS. A `count` in it would either restate the roster
1531
+ * figure — a different subject under the same heading — or count the pairs,
1532
+ * which measures the flag rather than the two rigs. The roster figure is
1533
+ * `animations.count`, and it is already name-free.
1534
+ */
1535
+ function agnosticAnimationMeasures(a: AnimationFacts, b: AnimationFacts, pairs: readonly DiffAnimationPair[]): DiffMeasure[] {
1536
+ const slot = (i: number): string => `#${i}`;
1537
+ const ca = underLabels(a, new Map(pairs.map((p, i) => [p.candidate, slot(i)])));
1538
+ const rb = underLabels(b, new Map(pairs.map((p, i) => [p.reference, slot(i)])));
1539
+ return [
1540
+ agreement(
1541
+ 'animations.agnostic.duration',
1542
+ 'each paired animation runs as long (last key time, within one frame)',
1543
+ ca.duration,
1544
+ rb.duration,
1545
+ (x, y) => Math.abs(x - y) <= FRAME,
1546
+ ),
1547
+ histogram('animations.agnostic.timeline_kinds', 'the same timelines exist in the paired animations', ca.kinds, rb.kinds),
1548
+ histogram('animations.agnostic.key_counts', 'those timelines carry as many keys', ca.keys, rb.keys),
1549
+ histogram('animations.agnostic.curve_kinds', 'as many linear / stepped / bezier keys', ca.curves, rb.curves),
1550
+ agreement(
1551
+ 'animations.agnostic.draw_order',
1552
+ 'a draw-order timeline is present or absent alike',
1553
+ ca.hasDrawOrder,
1554
+ rb.hasDrawOrder,
1555
+ (x, y) => x === y,
1556
+ ),
1557
+ agreement(
1558
+ 'animations.agnostic.deform',
1559
+ 'a deform timeline is present or absent alike',
1560
+ ca.hasDeform,
1561
+ rb.hasDeform,
1562
+ (x, y) => x === y,
1563
+ ),
1564
+ ];
1565
+ }
1566
+
1567
+ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPair[]): DiffSection {
1568
+ const a = animationFacts(c);
1569
+ const b = animationFacts(r);
1570
+ const at = keyingTotals(a);
1571
+ const bt = keyingTotals(b);
1572
+ const paired = pairAnimations(a, b, pairsStated);
1573
+ const refsA = animationRefs(c);
1574
+ const refsB = animationRefs(r);
1575
+ const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
1576
+ const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
1577
+ return sectionOf('animations', [
1578
+ measure('animations.count', 'how many animations', Math.min(a.names.length, b.names.length), Math.max(a.names.length, b.names.length)),
1579
+ jaccard('animations.names', 'the animation names', new Set(a.names), new Set(b.names)),
1580
+ agreement(
1581
+ 'animations.duration',
1582
+ 'each animation runs as long (last key time, within one frame)',
1583
+ a.duration,
1584
+ b.duration,
1585
+ (x, y) => Math.abs(x - y) <= FRAME,
1586
+ ),
1587
+ histogram('animations.timeline_kinds', 'the same timelines exist', a.kinds, b.kinds),
1588
+ histogram('animations.key_counts', 'those timelines carry as many keys', a.keys, b.keys),
1589
+ histogram('animations.curve_kinds', 'as many linear / stepped / bezier keys', a.curves, b.curves),
1590
+ histogram('animations.event_keys', 'as many event firings', a.events, b.events),
1591
+ agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
1592
+ agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
1593
+ namesHistogram(
1594
+ 'animations.targets',
1595
+ 'each timeline keys the same bone, slot, constraint or attachment',
1596
+ refsA.targets,
1597
+ refsB.targets,
1598
+ ),
1599
+ namesHistogram(
1600
+ 'animations.keyed_names',
1601
+ 'each key names the same attachment, draw-order slot or event',
1602
+ refsA.keyed,
1603
+ refsB.keyed,
1604
+ ),
1605
+ ],
1606
+ // ── the same two skeletons' shots, paired rather than named (issue #720) ──
1607
+ //
1608
+ // Absent unless something pairs them — see `pairAnimations` and the header's
1609
+ // point 3. `undefined` and not `[]`: a block with no measures in it prints a
1610
+ // vacuous `mean 1.000 over 0 measures`, which is the false green this whole
1611
+ // file is built to refuse, and `movedAgnosticMeasures` cannot tell it from a
1612
+ // block that agreed about everything.
1613
+ paired === null ? undefined : agnosticAnimationMeasures(a, b, paired.pairs),
1614
+ // ── reported (issue #20) ────────────────────────────────────────────────
1615
+ //
1616
+ // 🔍 What #20 asked and what was actually wrong. The issue proposed making key
1617
+ // density OBSERVABLE — 24/30 fps reference renders, or a hint in the brief —
1618
+ // and both routes were measured before either was built (the figures are in
1619
+ // [BENCHMARK.md](../docs/BENCHMARK.md), *Key density*). Neither works, and the
1620
+ // reason is the same one twice: **the two keyings render the same pictures.**
1621
+ // A candidate that lays extra keys along the curve the reference already
1622
+ // describes poses identically at every instant, so a higher sampling rate
1623
+ // samples the same curve more often and sees the same thing — the frames'
1624
+ // rate is not the ceiling, the pixels are. And a hint in the brief does not
1625
+ // make density observable; it makes it TOLD, which turns a scored measure into
1626
+ // an input and would be a reference-side value of a scored row landing in an
1627
+ // allowed-reading surface — the exact text LADDER.md's honesty rule seals.
1628
+ //
1629
+ // ⭐ So what the issue found is a REPORTING defect, and it is here.
1630
+ // `animations.key_counts` is a histogram intersection over
1631
+ // `max(candidate, reference)`, so **it cannot say which side is the bigger
1632
+ // one**: rung 4 read 421/1339 for a candidate carrying three times the
1633
+ // reference's keys, and that figure is indistinguishable from one carrying a
1634
+ // third of them. The author read it as a gap and left it, correctly, because
1635
+ // nothing in the report said "over-keyed". These two measures say so, with
1636
+ // their conventions, in the direction the ratio drops.
1637
+ //
1638
+ // Two figures and not one, because a key total can differ two ways and the
1639
+ // repairs are opposite: `key_density` moves when the shot is keyed harder,
1640
+ // `keys_per_timeline` when each timeline is, and a candidate with the
1641
+ // reference's density spread over twice the timelines shows up on the second
1642
+ // alone. 🚫 Neither gates — GATE.md's *What never gates* names key density
1643
+ // outright, and gate v2.3 (#153) settled that a clause does not read a figure
1644
+ // the authoring loop cannot see.
1645
+ [
1646
+ rateAgreement(
1647
+ 'animations.key_density',
1648
+ 'how hard the shot is keyed, as keys per second',
1649
+ 'keys/s',
1650
+ perSecond(at),
1651
+ perSecond(bt),
1652
+ `Convention: every key of every timeline (${at.keys} vs ${bt.keys}) over the summed last-key time of ` +
1653
+ `every animation (${atPlaces(at.seconds)}s vs ${atPlaces(bt.seconds)}s), compared as min/max at ${RATE_PLACES} decimal places.`,
1654
+ ),
1655
+ rateAgreement(
1656
+ 'animations.keys_per_timeline',
1657
+ 'how hard each timeline is keyed, as keys per timeline',
1658
+ 'keys/timeline',
1659
+ perTimeline(at),
1660
+ perTimeline(bt),
1661
+ `Convention: every key of every timeline (${at.keys} vs ${bt.keys}) over the number of timelines that exist ` +
1662
+ `(${at.timelines} vs ${bt.timelines}), compared as min/max at ${RATE_PLACES} decimal places. Read beside ` +
1663
+ '`key_density`: this one alone moving means the same keying spread over a different number of timelines.',
1664
+ ),
1665
+ ],
1666
+ paired?.pairedBy);
1667
+ }
1668
+
1669
+ function eventFacts(root: Json): Map<string, string> {
1670
+ const out = new Map<string, string>();
1671
+ if (!isObj(root.events)) return out;
1672
+ for (const [name, def] of Object.entries(root.events)) {
1673
+ const d = isObj(def) ? def : {};
1674
+ // The typed payload, in the parser's own defaults (`:469-484`), so that a
1675
+ // field written explicitly at its default reads the same as one omitted.
1676
+ out.set(
1677
+ name,
1678
+ JSON.stringify({
1679
+ int: num(d.int) ?? 0,
1680
+ float: num(d.float) ?? 0,
1681
+ string: str(d.string) ?? '',
1682
+ audio: str(d.audio) ?? '',
1683
+ volume: num(d.volume) ?? 1,
1684
+ balance: num(d.balance) ?? 0,
1685
+ }),
1686
+ );
1687
+ }
1688
+ return out;
1689
+ }
1690
+
1691
+ function diffEvents(c: Json, r: Json): DiffSection {
1692
+ const a = eventFacts(c);
1693
+ const b = eventFacts(r);
1694
+ return sectionOf('events', [
1695
+ jaccard('events.names', 'the event names', new Set(a.keys()), new Set(b.keys())),
1696
+ agreement('events.payloads', 'each event carries the same typed payload', a, b, (x, y) => x === y),
1697
+ ]);
1698
+ }
1699
+
1700
+ // ---------------------------------------------------------------------------
1701
+ // the skeleton header
1702
+ // ---------------------------------------------------------------------------
1703
+
1704
+ /**
1705
+ * The header's box: `x`, `y`, `width`, `height` of the setup-pose bounding box
1706
+ * — the box around the attachments at the setup pose, which is what the Spine
1707
+ * format says the four are — or the absence of all four.
1708
+ *
1709
+ * 🔁 **Renamed from `stage_present`/`stage_box` by issue #907, because the old
1710
+ * name had become a lie.** Until then `build` copied rigc's stage — the crop
1711
+ * the art was painted in — into these four fields, so on a rigc candidate the
1712
+ * measure read a stage and on an editor export it read a bounding box, under
1713
+ * one word. Since #907 `build` writes the setup-pose bounding box there too
1714
+ * (`headerBoundsOf` in `src/compile.ts`, held to spine-core's `getBounds`), so
1715
+ * both sides of every comparison carry the same kind of value, and the stage —
1716
+ * which a rig spec states and the model document carries — is not in the file
1717
+ * at all. A measure called `stage` would send a reader to change the stage,
1718
+ * which no longer moves this number.
1719
+ *
1720
+ * 🔍 **Why this measure exists at all** (issue #578). The header box was the
1721
+ * one value `build` required and no instrument in this tree could see:
1722
+ * `validate`, `check` and `render` all ignore it — `render` frames from the
1723
+ * posed bounds — and `diff` had no header measure, so a sweep that handed a
1724
+ * deliberately absurd unit stage `0,0,1,1` to 37 real exports read **1.000 on
1725
+ * every measure** for 32 of them.
1726
+ *
1727
+ * ⭐ **`bounds_present` is 1/1 or 0/1 and never `0/0`.** Both sides always have
1728
+ * a presence to compare, including when both say "none" — that is agreement,
1729
+ * not an absence of data, and `total: 0` here would be the vacuous 1.000 this
1730
+ * file refuses. `bounds_box` is the one that goes vacuous, and only when there
1731
+ * are not two boxes to compare.
1732
+ */
1733
+ interface HeaderBox {
1734
+ present: boolean;
1735
+ /**
1736
+ * The four fields as the header MEANS them: the extent exactly as stated, and
1737
+ * the origin of a stated box as stated or `0` where it is omitted. See
1738
+ * `headerBox` for why the second half is a reading and not a fallback.
1739
+ */
1740
+ box: Array<number | null>;
1741
+ }
1742
+
1743
+ const BOX_FIELDS = ['x', 'y', 'width', 'height'] as const;
1744
+
1745
+ /**
1746
+ * A header states a box by its EXTENT: a numeric `width` and `height`.
1747
+ *
1748
+ * `x`/`y` alone are an origin for a box that is not there — no export carries
1749
+ * that shape, and rigc never emits it — so they do not make a box on their own.
1750
+ *
1751
+ * ⭐ **Inside a stated box, an omitted `x`/`y` IS `0`** (issue #620). That is a
1752
+ * reading of the format, not a value invented for a gap — the distinction this
1753
+ * file lives or dies by — and three measurements carry it, none of them
1754
+ * anybody's word for the convention:
1755
+ *
1756
+ * - **The header's writer omits a field at its default.** All twelve exports
1757
+ * under `examples/` omit `referenceScale`, whose default the parser itself
1758
+ * spells two lines below the four raw assignments (`SkeletonJson.js:74`,
1759
+ * `getValue(skeletonMap, "referenceScale", 100)`), and none of the 389 bone
1760
+ * `x`/`y`/`rotation`/`shearX`/`shearY` values those same files DO write is an
1761
+ * explicit `0`. A box at the origin therefore has no spelling but the
1762
+ * omission, so reading the omission as absence reads a value the format cannot
1763
+ * express. (An editor round trip of a header stating `"x": 0, "y": 0` wrote
1764
+ * neither back — measured through a licensed 4.3.26 editor for issue #907.)
1765
+ * - **The binary reader supplies it unconditionally.** `SkeletonBinary.js:69-72`
1766
+ * reads the four as four floats with no key to be missing, so one skeleton's
1767
+ * origin is `0` in a `.skel` and absent in a `.json`. A measure that called
1768
+ * those two different boxes would be reporting the container.
1769
+ * - **The JSON reader's silence is an oversight rather than a meaning.**
1770
+ * `SkeletonJson.js:70-73` is `skeletonData.x = skeletonMap.x`, a raw
1771
+ * assignment that overwrites `SkeletonData`'s own `0` with `undefined`. The
1772
+ * line beside it does the same to `fps`, whose default is `30` and which every
1773
+ * one of the twelve omits — so taking `undefined` for a meaning would say the
1774
+ * editor has never exported a frame rate.
1775
+ *
1776
+ * ⚠️ **The default is the ORIGIN's alone**, and the guard is the paragraph above
1777
+ * it: the extent is what states a box, so defaulting it would turn the box-less
1778
+ * header of issue #578 into a `0x0` box at `0,0` and answer the question
1779
+ * instead of reading it.
1780
+ */
1781
+ function headerBox(root: Json): HeaderBox {
1782
+ const header = isObj(root.skeleton) ? root.skeleton : {};
1783
+ const box = BOX_FIELDS.map((k) => num(header[k]));
1784
+ const present = box[2] !== null && box[3] !== null;
1785
+ return { present, box: present ? [box[0] ?? 0, box[1] ?? 0, box[2], box[3]] : box };
1786
+ }
1787
+
1788
+ /**
1789
+ * The two header measures.
1790
+ *
1791
+ * ⚠️ **The box is compared EXACTLY, and that is a measurement rather than a
1792
+ * choice.** The structural measures in this file have exactly one tolerance —
1793
+ * `FRAME`, one sixtieth of a second, used once, for `animations.duration` — and
1794
+ * no spatial one anywhere, because they compare no position at all (that is
1795
+ * `bonedist.ts`). Each side's box is a number its writer computed: rigc's is
1796
+ * spine-core's `getBounds` on the header's 1e-6 grid at float32 (`headerBoxNumber`; `tools/core_gate.ts` holds it), and
1797
+ * the editor's is its own arithmetic, which on the twelve examples sits up to
1798
+ * 0.0071 units from `getBounds` on the same skeleton and agrees with its
1799
+ * float32 on 13 of the 48 numbers (issue #907's measurement). A rebuild and its
1800
+ * export therefore read below 4/4 here by the editor's arithmetic alone, and an
1801
+ * epsilon chosen to hide that would be a number nobody measured, in the file
1802
+ * whose whole job is to report measured ones. The measure is reported and gates
1803
+ * nothing on the ladder.
1804
+ *
1805
+ * ⭐ `diffSkeletonValues` (issue #615) is the second tolerance in the file and
1806
+ * it does not weaken this one. It compares positions, so it needs one; both its
1807
+ * terms are read off other code — the 1e-6 grid rigc's closed-form models are
1808
+ * evaluated on and the parser's float32 storage — rather than chosen here; and
1809
+ * the four numbers above are the one place the two overlap, where `bounds_box`
1810
+ * stays the stricter reading and says so by staying exact.
1811
+ */
1812
+ function diffHeader(c: Json, r: Json): DiffReported {
1813
+ const a = headerBox(c);
1814
+ const b = headerBox(r);
1815
+ const both = a.present && b.present;
1816
+ const agreed = both ? a.box.filter((v, i) => v === b.box[i]).length : 0;
1817
+ const side = (f: HeaderBox): string => (f.present ? `${f.box[2]}x${f.box[3]} at ${f.box[0]},${f.box[1]}` : 'none');
1818
+ return {
1819
+ measures: [
1820
+ measure(
1821
+ 'skeleton.bounds_present',
1822
+ 'both headers carry a setup-pose bounding box, or neither does',
1823
+ a.present === b.present ? 1 : 0,
1824
+ 1,
1825
+ `candidate ${side(a)}; reference ${side(b)}. A header carries a box by stating a width and a height`,
1826
+ ),
1827
+ measure(
1828
+ 'skeleton.bounds_box',
1829
+ 'the header carries the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
1830
+ agreed,
1831
+ both ? BOX_FIELDS.length : 0,
1832
+ both
1833
+ ? `candidate ${side(a)}; reference ${side(b)}`
1834
+ : a.present === b.present
1835
+ ? 'neither header carries a box, so there is no box to compare — `bounds_present` carries that'
1836
+ : 'only one header carries a box, so there is no second box to compare — `bounds_present` carries that',
1837
+ ),
1838
+ ],
1839
+ };
1840
+ }
1841
+
1842
+ // ---------------------------------------------------------------------------
1843
+ // the report
1844
+ // ---------------------------------------------------------------------------
1845
+
1846
+ function orientation(root: Json): Record<string, number> {
1847
+ const attachments = attachmentFacts(root);
1848
+ return {
1849
+ bones: objs(root.bones).length,
1850
+ slots: objs(root.slots).length,
1851
+ skins: objs(root.skins).length,
1852
+ attachments: attachments.byKey.size,
1853
+ constraints: objs(root.constraints).length,
1854
+ animations: isObj(root.animations) ? Object.keys(root.animations).length : 0,
1855
+ events: isObj(root.events) ? Object.keys(root.events).length : 0,
1856
+ };
1857
+ }
1858
+
1859
+ export function diffSkeletons(candidate: unknown, reference: unknown, options?: DiffOptions): DiffReport {
1860
+ const c = isObj(candidate) ? candidate : {};
1861
+ const r = isObj(reference) ? reference : {};
1862
+ const animationPairs = options?.animationPairs ?? [];
1863
+ return {
1864
+ sections: [
1865
+ diffBones(c, r),
1866
+ diffSlots(c, r),
1867
+ diffAttachments(c, r),
1868
+ diffConstraints(c, r),
1869
+ diffAnimations(c, r, animationPairs),
1870
+ diffEvents(c, r),
1871
+ ],
1872
+ header: diffHeader(c, r),
1873
+ candidate: orientation(c),
1874
+ reference: orientation(r),
1875
+ };
1876
+ }
1877
+
1878
+ /**
1879
+ * Every NAME-MATCHED measure that is not a perfect match, by id. What a test
1880
+ * asserts on.
1881
+ *
1882
+ * The name-agnostic reports are deliberately not folded in here. They are a
1883
+ * separate comparison with its own measure set, and a single flattened list
1884
+ * would make one edit's footprint depend on how many measures the other report
1885
+ * happens to define — which is the opposite of what these assertions are for.
1886
+ * `movedAgnosticMeasures` is the other half, and a case pins both.
1887
+ */
1888
+ export function movedMeasures(report: DiffReport): string[] {
1889
+ return report.sections.flatMap((s) => s.measures.filter((m) => m.ratio < 1).map((m) => m.id));
1890
+ }
1891
+
1892
+ /** The same, over the name-agnostic reports. Empty when every shape agrees. */
1893
+ export function movedAgnosticMeasures(report: DiffReport): string[] {
1894
+ return report.sections.flatMap((s) => (s.nameAgnostic?.measures ?? []).filter((m) => m.ratio < 1).map((m) => m.id));
1895
+ }
1896
+
1897
+ /**
1898
+ * The same, over the reported measures.
1899
+ *
1900
+ * A third list rather than a third of one flattened list, for the reason
1901
+ * `movedAgnosticMeasures` gives: a case's expectation should not depend on how
1902
+ * many measures a different report happens to define. Pinned on every case and
1903
+ * not only the ones about a mesh or a curve — a figure nothing asserts on is a
1904
+ * figure that can quietly stop moving, and these gate nothing, so nothing else
1905
+ * would notice.
1906
+ */
1907
+ export function movedReportedMeasures(report: DiffReport): string[] {
1908
+ return [...report.sections.flatMap((s) => s.reported?.measures ?? []), ...report.header.measures]
1909
+ .filter((m) => m.ratio < 1)
1910
+ .map((m) => m.id);
1911
+ }
1912
+
1913
+ // ---------------------------------------------------------------------------
1914
+ // The value level — what the structural measures above are blind to
1915
+ // ---------------------------------------------------------------------------
1916
+
1917
+ /**
1918
+ * The one absolute grid rigc still emits on: `onModelGrid` in
1919
+ * [`compile.ts`](compile.ts), 1e-6 in a closed-form model's own unit, which a
1920
+ * deform `transform` and a track `derive` are evaluated onto before they are
1921
+ * written. So a value a model produced may sit up to one step of it from a
1922
+ * reading that evaluated the same model in float64 — through no fault of
1923
+ * anything this measure is looking for.
1924
+ *
1925
+ * ⚠️ **Restated, not retired, by issue #716.** This said `r6` until then — the
1926
+ * six fixed decimals every emitted number took, and `keyTime` rounding a key
1927
+ * time down over the same step. Both are gone: every number is now its
1928
+ * float32's shortest name, whose distance from its double the second term
1929
+ * already covers, and a key time steps at most one float down. What survives
1930
+ * is the models' grid, which is absolute because a model's identities are
1931
+ * exact zeros and a float's grid is relative. On the corpus, where nothing is
1932
+ * a model, the term is idle: the twelve rebuilds read identical to their
1933
+ * sources, value for value (`valueTolerance`'s figure).
1934
+ */
1935
+ export const VALUE_EMITTED_GRID = 1e-6;
1936
+
1937
+ /**
1938
+ * One float32 ULP, relative. `spine-core` holds every frame, curve sample and
1939
+ * vertex in a `Float32Array` (`Utils.newFloatArray`), so two decimals that
1940
+ * differ by the grid above can land on adjacent float32s, and the difference
1941
+ * the walk sees is the decimal gap plus that step.
1942
+ *
1943
+ * ⚠️ It is also the floor of what this measure can see AT ALL: a difference
1944
+ * smaller than one float32 step is invisible to the parser and therefore to
1945
+ * this. The tolerance states that rather than hiding it.
1946
+ */
1947
+ export const VALUE_PARSED_ULP = 2 ** -23;
1948
+
1949
+ /**
1950
+ * What two readings of one number are allowed to differ by, and nothing more.
1951
+ *
1952
+ * Both terms are derived rather than fitted: the first is rigc's models' grid,
1953
+ * the second is the parser's storage. Measured over the twelve editor exports
1954
+ * in `examples/`, the widest gap between a rebuild and the file it was read
1955
+ * from is **0** — none of 189,699 numeric values differs at all, since issue
1956
+ * #716 made every emitted number its float's own name (it was 0.81 of this
1957
+ * bound, over 56,951 values that differed, while rigc emitted six decimals) —
1958
+ * so the corpus sits inside a bound that was not drawn around it, and no
1959
+ * longer tests its width: `IG21` does.
1960
+ */
1961
+ export function valueTolerance(magnitude: number): number {
1962
+ return VALUE_EMITTED_GRID + VALUE_PARSED_ULP * magnitude;
1963
+ }
1964
+
1965
+ /** Whether two readings of one path agree. `NaN` on both sides is agreement: neither file states it. */
1966
+ function valuesAgree(a: number | string, b: number | string): boolean {
1967
+ if (typeof a !== 'number' || typeof b !== 'number') return a === b;
1968
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
1969
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
1970
+ return Math.abs(a - b) <= valueTolerance(Math.max(Math.abs(a), Math.abs(b)));
1971
+ }
1972
+
1973
+ /**
1974
+ * The measures `diffSkeletonValues` defines, in the order it prints them, and
1975
+ * what each one is. The set is fixed rather than derived from the paths in
1976
+ * front of it, so that a run can say a measure compared NOTHING — the same
1977
+ * distinction `DiffMeasure.total` draws everywhere else in this file.
1978
+ */
1979
+ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }> = [
1980
+ { id: 'values.skeleton', what: 'the header and its setup-pose bounding box', prefix: 'skeleton/' },
1981
+ { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1982
+ { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1983
+ { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
1984
+ {
1985
+ id: 'values.constraints',
1986
+ what: 'every constraint field the parser reads: pose, flags, offsets, and the property map and its kinds',
1987
+ prefix: 'constraints/',
1988
+ },
1989
+ { id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
1990
+ { id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
1991
+ { id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
1992
+ { id: 'values.curves', what: 'every curve type and the Bezier samples the parser built from its handles', prefix: 'animations/' },
1993
+ ];
1994
+
1995
+ /**
1996
+ * Which measure a path belongs to. The first path segment decides it, except
1997
+ * under `animations/`, where the three arms are the three questions that fail
1998
+ * differently: a timing that moved, a value that moved, and an easing that
1999
+ * moved. A segment nothing here names becomes a measure of its own rather than
2000
+ * disappearing into one of these — a walk that grows a top-level kind should
2001
+ * arrive as a new line, not as a silently wider denominator.
2002
+ */
2003
+ function valueMeasureFor(path: string): string {
2004
+ const head = path.slice(0, path.indexOf('/') + 1);
2005
+ if (head === 'animations/') {
2006
+ if (path.endsWith('/duration') || /\/key\/\d+\/time$/.test(path)) return 'values.key_times';
2007
+ if (/\/curves\/\d+$/.test(path)) return 'values.curves';
2008
+ return 'values.key_values';
2009
+ }
2010
+ const known = VALUE_MEASURES.find((m) => m.prefix === head);
2011
+ return known === undefined ? `values.${head.slice(0, -1) || path}` : known.id;
2012
+ }
2013
+
2014
+ /** How many offending paths a note spells out before it counts the rest. */
2015
+ const VALUE_OFFENDERS_SPELLED_OUT = 3;
2016
+
2017
+ /**
2018
+ * The value-level comparison: are the numbers inside the structure the same
2019
+ * numbers?
2020
+ *
2021
+ * ## Why it is a separate call rather than a section of `diffSkeletons`
2022
+ *
2023
+ * Because its inputs are not JSON. Every default in the format — `time` absent
2024
+ * is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 while its
2025
+ * `mix` is 1 — has to be applied before two files can be compared value by
2026
+ * value, and writing that table here would be a second spelling of the format
2027
+ * inside the gate. So the defaults come from the parser, through
2028
+ * `skeletonValues` in [`validate.ts`](validate.ts), and this function compares
2029
+ * what it returns: an ordered list of `path → value` with every default already
2030
+ * applied by the one reader that owns them.
2031
+ *
2032
+ * ⚠️ **It reports; it does not gate the ladder.** A rung's brief withholds
2033
+ * coordinates on purpose, so no reading of the reference frames could decide
2034
+ * these — `docs/GATE.md`'s *What never gates* applies word for word. The corpus
2035
+ * gate is the one place they DO decide a verdict, and for the reason `IG16`
2036
+ * already gives about `mesh_edges`: there the reference is the very file the
2037
+ * specs were read from, so a value that moved is a decompiler loss rather than
2038
+ * a candidate's entitlement.
2039
+ *
2040
+ * ## What a difference means, and what it cannot mean
2041
+ *
2042
+ * A path missing on one side is counted as unmatched and named as such: it is a
2043
+ * structural finding too, and the measures above it are where that is
2044
+ * diagnosed. A number present on both sides is compared under
2045
+ * `valueTolerance`, which is derived from rigc's quantiser and the parser's
2046
+ * float32 storage — never from the corpus.
2047
+ */
2048
+ export function diffSkeletonValues(candidate: SkeletonValue[], reference: SkeletonValue[]): DiffMeasure[] {
2049
+ const left = new Map<string, number | string>();
2050
+ for (const v of candidate) left.set(v.path, v.value);
2051
+ const right = new Map<string, number | string>();
2052
+ for (const v of reference) right.set(v.path, v.value);
2053
+
2054
+ interface Tally {
2055
+ matched: number;
2056
+ total: number;
2057
+ offenders: string[];
2058
+ unpaired: number;
2059
+ }
2060
+ const tallies = new Map<string, Tally>();
2061
+ const order: string[] = VALUE_MEASURES.map((m) => m.id);
2062
+ const tallyFor = (id: string): Tally => {
2063
+ const held = tallies.get(id);
2064
+ if (held !== undefined) return held;
2065
+ const fresh: Tally = { matched: 0, total: 0, offenders: [], unpaired: 0 };
2066
+ tallies.set(id, fresh);
2067
+ if (!order.includes(id)) order.push(id);
2068
+ return fresh;
2069
+ };
2070
+ for (const id of order) tallyFor(id);
2071
+
2072
+ // The candidate's paths in the candidate's own order, then whatever the
2073
+ // reference has that the candidate does not — an iteration over the two
2074
+ // lists as they were built, never over a set.
2075
+ for (const { path, value } of candidate) {
2076
+ const tally = tallyFor(valueMeasureFor(path));
2077
+ tally.total++;
2078
+ const other = right.get(path);
2079
+ if (other === undefined) {
2080
+ tally.unpaired++;
2081
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (candidate only)`);
2082
+ continue;
2083
+ }
2084
+ if (valuesAgree(value, other)) {
2085
+ tally.matched++;
2086
+ continue;
2087
+ }
2088
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) {
2089
+ const gap =
2090
+ typeof value === 'number' && typeof other === 'number'
2091
+ ? `, off by ${Math.abs(value - other).toExponential(3)} against ${valueTolerance(
2092
+ Math.max(Math.abs(value), Math.abs(other)),
2093
+ ).toExponential(3)} allowed`
2094
+ : '';
2095
+ tally.offenders.push(`${path} ${JSON.stringify(value)} vs ${JSON.stringify(other)}${gap}`);
2096
+ }
2097
+ }
2098
+ for (const { path } of reference) {
2099
+ if (left.has(path)) continue;
2100
+ const tally = tallyFor(valueMeasureFor(path));
2101
+ tally.total++;
2102
+ tally.unpaired++;
2103
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (reference only)`);
2104
+ }
2105
+
2106
+ return order.map((id) => {
2107
+ const tally = tallyFor(id);
2108
+ const defined = VALUE_MEASURES.find((m) => m.id === id);
2109
+ const missed = tally.total - tally.matched;
2110
+ const note =
2111
+ tally.total === 0
2112
+ ? 'neither side carries one'
2113
+ : missed === 0
2114
+ ? undefined
2115
+ : `${missed} moved` +
2116
+ (tally.unpaired === 0 ? '' : `, ${tally.unpaired} of them on one side only`) +
2117
+ `: ${tally.offenders.join('; ')}` +
2118
+ (missed > tally.offenders.length ? `; …and ${missed - tally.offenders.length} more` : '');
2119
+ return measure(id, defined?.what ?? 'values under a path kind this report does not define', tally.matched, tally.total, note);
2120
+ });
2121
+ }
2122
+
2123
+ /** Every value measure that is not a perfect match, by id. What a test asserts on. */
2124
+ export function movedValueMeasures(measures: DiffMeasure[]): string[] {
2125
+ return measures.filter((m) => m.ratio < 1).map((m) => m.id);
2126
+ }
2127
+
2128
+ /**
2129
+ * `skeleton 1.000 · bones 1.000 · …`, the same one-line shape `reportedFigures`
2130
+ * has, and with no roll-up for the same reason: a mean over "did the times move"
2131
+ * and "did the vertices move" is a number with no referent.
2132
+ */
2133
+ export function valueFigures(measures: DiffMeasure[]): string {
2134
+ return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
2135
+ }
2136
+
2137
+ const fmt = (n: number): string => n.toFixed(3);
2138
+
2139
+ /**
2140
+ * `mesh_edges 1.000 · key_density 0.333`, or `null` when nothing is reported.
2141
+ *
2142
+ * Every reported measure by name, and no roll-up of any kind: these have unlike
2143
+ * units, so a digest over them would be the mean `DiffReported` exists to
2144
+ * refuse. The section is dropped from the id because the ids are unique across
2145
+ * the report and the line is a summary.
2146
+ */
2147
+ export function reportedFigures(report: DiffReport): string | null {
2148
+ const measures = [...report.sections.flatMap((s) => s.reported?.measures ?? []), ...report.header.measures];
2149
+ if (measures.length === 0) return null;
2150
+ return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
2151
+ }
2152
+
2153
+ /** `bones 0.567 (name-matched) · 1.000 (name-agnostic)`, or just the one figure. */
2154
+ export function sectionFigures(section: DiffSection): string {
2155
+ const matched = `${fmt(section.ratio)} (name-matched)`;
2156
+ return section.nameAgnostic === undefined
2157
+ ? `${section.name} ${matched}`
2158
+ : `${section.name} ${matched} · ${fmt(section.nameAgnostic.ratio)} (name-agnostic)`;
2159
+ }
2160
+
2161
+ function measureLines(measures: DiffMeasure[], strip: number): string[] {
2162
+ return measures.map((m) => {
2163
+ const counts = `${m.matched}/${m.total}`;
2164
+ return ` ${fmt(m.ratio)} ${m.id.slice(strip).padEnd(28)} ${counts.padEnd(11)} ${m.what}${m.note ? ` — ${m.note}` : ''}`;
2165
+ });
2166
+ }
2167
+
2168
+ export function diffLines(report: DiffReport, labels: { candidate: string; reference: string }): string[] {
2169
+ const lines: string[] = [];
2170
+ lines.push(` candidate ${labels.candidate}`);
2171
+ lines.push(` reference ${labels.reference}`);
2172
+ const keys = Object.keys(report.reference);
2173
+ lines.push(` .. ${keys.map((k) => `${k}=${report.candidate[k]}/${report.reference[k]}`).join(' ')} (candidate/reference)`);
2174
+ lines.push('');
2175
+ // The `skeleton` block, first because that is where it sits in the file, and
2176
+ // with `(no mean)` for the same reason a section's `(reported)` block has one.
2177
+ lines.push(
2178
+ ` ${'skeleton (reported)'.padEnd(21)} (no mean) over ${report.header.measures.length} measures` +
2179
+ ' — the header\'s setup-pose bounding box, which no reading of the frames could decide',
2180
+ );
2181
+ lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
2182
+ lines.push('');
2183
+ // Wide enough for `bones (name-agnostic)` and `animations (reported)`, both
2184
+ // exactly 21, so that most of a section's headings line their figures up
2185
+ // under each other and read as a pair.
2186
+ //
2187
+ // ⚠️ Two headings are longer and push their own figure right instead:
2188
+ // `attachments (reported)`, which has done so since that block existed, and
2189
+ // `animations (name-agnostic)` (issue #720). Widening the column is the
2190
+ // obvious repair and it is the wrong one — it moves every heading line of
2191
+ // every report, and those lines are quoted verbatim in `docs/LADDER.md` and
2192
+ // in the landed run records under `bench/runs/`, which are sealed. A
2193
+ // cosmetic alignment is not worth a byte change in every transcript already
2194
+ // written, and the overflow is visible rather than silent.
2195
+ const head = (label: string, ratio: number, n: number): string =>
2196
+ ` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
2197
+ for (const section of report.sections) {
2198
+ lines.push(head(section.name, section.ratio, section.measures.length));
2199
+ lines.push(...measureLines(section.measures, section.name.length + 1));
2200
+ const agnostic = section.nameAgnostic;
2201
+ if (agnostic) {
2202
+ lines.push('');
2203
+ lines.push(
2204
+ `${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
2205
+ ' — the same two skeletons compared with names thrown away' +
2206
+ // The pairing is part of the figure, not decoration: see `DiffAgnostic.pairedBy`.
2207
+ (agnostic.pairedBy === undefined ? '' : `, ${agnostic.pairedBy}`),
2208
+ );
2209
+ lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
2210
+ }
2211
+ // No mean on this heading, and the absence is the statement: see
2212
+ // `DiffReported`. `(no mean)` sits where `mean 0.000` would, so the column
2213
+ // the eye follows down the report is unbroken.
2214
+ const reported = section.reported;
2215
+ if (reported) {
2216
+ lines.push('');
2217
+ lines.push(
2218
+ ` ${`${section.name} (reported)`.padEnd(21)} (no mean) over ${reported.measures.length} ` +
2219
+ `measure${reported.measures.length === 1 ? '' : 's'}` +
2220
+ ' — unobservable from the frames, so reported and folded into nothing',
2221
+ );
2222
+ lines.push(...measureLines(reported.measures, section.name.length + 1));
2223
+ }
2224
+ lines.push('');
2225
+ }
2226
+ lines.push(' There is no overall score, on purpose: a section mean is an average of the');
2227
+ lines.push(' measures printed under it, and averaging those together would hide which');
2228
+ lines.push(' half of a rig is wrong. Read the measures.');
2229
+ lines.push('');
2230
+ lines.push(' `bones` and `slots` carry two figures because most of their measures are');
2231
+ lines.push(' keyed on names, and a candidate is entitled to its own. They are two');
2232
+ lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
2233
+ lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
2234
+ lines.push('');
2235
+ lines.push(' `animations` carries the same pair, and only once something has PAIRED the two');
2236
+ lines.push(' sides\' shots: `--as <candidate>=<reference>`, or one animation each side, which');
2237
+ lines.push(' pairs by position. With neither there is no reading of which shot is which, so');
2238
+ lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
2239
+ lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
2240
+ lines.push('');
2241
+ lines.push(' `skeleton` is the file\'s own header block and reports two measures for its box.');
2242
+ lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
2243
+ lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
2244
+ lines.push(' each side\'s box is its writer\'s own arithmetic.');
2245
+ lines.push('');
2246
+ lines.push(' A `(reported)` block has no mean because its measures have unlike units, and');
2247
+ lines.push(' it stays out of the section mean above it for the same reason no clause may');
2248
+ lines.push(' read it: no reading of the reference frames could have decided these. They are');
2249
+ lines.push(' findings against the reference export all the same — each states its own');
2250
+ lines.push(' convention, and a rate states which side is the bigger one.');
2251
+ return lines;
2252
+ }