@tldraw/commenting 0.0.0-bootstrap

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 (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. package/src/ui/send-button.tsx +15 -0
@@ -0,0 +1,547 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mstEdges } from './mst'
3
+ // These imports are red until step 2 is implemented — that is intentional.
4
+ // Implement `replay.ts` (and the step 2 additions to `types.ts`) per
5
+ // CLUSTERING-STEPS.md until this suite passes, without modifying this file.
6
+ import { cappedReplay, D_FLOOR } from './replay'
7
+ import { ClusterNode, LeafInput, MstEdge, RawMergeEvent } from './types'
8
+
9
+ function leaf(id: string, x: number, y: number): LeafInput {
10
+ return { id, point: { x, y } }
11
+ }
12
+
13
+ /** Deterministic PRNG so random tests are reproducible. */
14
+ function mulberry32(seed: number): () => number {
15
+ let a = seed >>> 0
16
+ return () => {
17
+ a = (a + 0x6d2b79f5) >>> 0
18
+ let t = a
19
+ t = Math.imul(t ^ (t >>> 15), t | 1)
20
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
21
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
22
+ }
23
+ }
24
+
25
+ function shuffled<T>(items: readonly T[], rand: () => number): T[] {
26
+ const out = items.slice()
27
+ for (let i = out.length - 1; i > 0; i--) {
28
+ const j = Math.floor(rand() * (i + 1))
29
+ ;[out[i], out[j]] = [out[j], out[i]]
30
+ }
31
+ return out
32
+ }
33
+
34
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
35
+ const rand = mulberry32(seed)
36
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
37
+ }
38
+
39
+ /** Order-independent, index-free projection of an event for cross-run comparison. */
40
+ function eventShape(ev: RawMergeEvent) {
41
+ return {
42
+ z: ev.z,
43
+ children: ev.children.map((c) => c.id),
44
+ result: ev.result.id,
45
+ members: ev.result.members,
46
+ count: ev.result.count,
47
+ }
48
+ }
49
+
50
+ function eventShapes(events: RawMergeEvent[]) {
51
+ return events.map(eventShape)
52
+ }
53
+
54
+ // --- Reference oracle -------------------------------------------------------
55
+ // Direct transcription of contract clause 4 (CLUSTERING-STEPS.md step 2): at
56
+ // each of the n−1 steps, evaluate every unfired MST edge's zEff against the
57
+ // CURRENT clusters and fire the highest, tie-broken by the edge's normalized
58
+ // id pair. O(n · E) per build — slow and obviously correct.
59
+
60
+ interface RefCluster {
61
+ members: string[] // sorted lexicographic; members[0] is the min-member id
62
+ centroid: { x: number; y: number }
63
+ count: number
64
+ minX: number
65
+ minY: number
66
+ maxX: number
67
+ maxY: number
68
+ }
69
+
70
+ function refNode(c: RefCluster): ClusterNode {
71
+ return {
72
+ id: c.count === 1 ? c.members[0] : `cluster:${c.count}:${c.members[0]}`,
73
+ centroid: { x: c.centroid.x, y: c.centroid.y },
74
+ count: c.count,
75
+ members: c.members.slice(),
76
+ }
77
+ }
78
+
79
+ function refDiag(a: RefCluster, b: RefCluster): number {
80
+ const w = Math.max(a.maxX, b.maxX) - Math.min(a.minX, b.minX)
81
+ const h = Math.max(a.maxY, b.maxY) - Math.min(a.minY, b.minY)
82
+ return Math.hypot(w, h)
83
+ }
84
+
85
+ function referenceReplay(
86
+ leaves: readonly LeafInput[],
87
+ edges: readonly MstEdge[],
88
+ opts: { Tc: number; Dmax: number }
89
+ ): RawMergeEvent[] {
90
+ const n = leaves.length
91
+ const parent = Array.from({ length: n }, (_, i) => i)
92
+ const find = (x: number): number => {
93
+ while (parent[x] !== x) {
94
+ parent[x] = parent[parent[x]]
95
+ x = parent[x]
96
+ }
97
+ return x
98
+ }
99
+ const clusters = new Map<number, RefCluster>()
100
+ for (let i = 0; i < n; i++) {
101
+ const p = leaves[i].point
102
+ clusters.set(i, {
103
+ members: [leaves[i].id],
104
+ centroid: { x: p.x, y: p.y },
105
+ count: 1,
106
+ minX: p.x,
107
+ minY: p.y,
108
+ maxX: p.x,
109
+ maxY: p.y,
110
+ })
111
+ }
112
+
113
+ const zOf = (e: MstEdge): number => {
114
+ // coincident anchors: permanently merged; the fit term is not consulted
115
+ if (e.d < D_FLOOR) return Infinity
116
+ const a = clusters.get(find(e.a))!
117
+ const b = clusters.get(find(e.b))!
118
+ // badge-anchored gap: priced by the clusters' centroid distance
119
+ const gap = opts.Tc / Math.hypot(a.centroid.x - b.centroid.x, a.centroid.y - b.centroid.y)
120
+ const fit = opts.Dmax / refDiag(a, b)
121
+ return Math.min(gap, fit)
122
+ }
123
+
124
+ const unfired = edges.slice()
125
+ const events: RawMergeEvent[] = []
126
+ let lastZ = Infinity // emitted thresholds are clamped non-increasing
127
+ while (unfired.length > 0) {
128
+ let best = 0
129
+ let bestZ = zOf(unfired[0])
130
+ for (let i = 1; i < unfired.length; i++) {
131
+ const z = zOf(unfired[i])
132
+ if (z > bestZ) {
133
+ best = i
134
+ bestZ = z
135
+ continue
136
+ }
137
+ if (z === bestZ) {
138
+ // tie-break by the edge's normalized id pair, ascending idLo then idHi
139
+ const a = unfired[i]
140
+ const b = unfired[best]
141
+ const aLo = leaves[a.a].id
142
+ const aHi = leaves[a.b].id
143
+ const bLo = leaves[b.a].id
144
+ const bHi = leaves[b.b].id
145
+ if (aLo < bLo || (aLo === bLo && aHi < bHi)) {
146
+ best = i
147
+ bestZ = z
148
+ }
149
+ }
150
+ }
151
+ const e = unfired.splice(best, 1)[0]
152
+ const ra = find(e.a)
153
+ const rb = find(e.b)
154
+ const ca = clusters.get(ra)!
155
+ const cb = clusters.get(rb)!
156
+ const [first, second] = ca.members[0] < cb.members[0] ? [ca, cb] : [cb, ca]
157
+ const count = ca.count + cb.count
158
+ const merged: RefCluster = {
159
+ members: [...ca.members, ...cb.members].sort(),
160
+ centroid: {
161
+ x: (ca.count * ca.centroid.x + cb.count * cb.centroid.x) / count,
162
+ y: (ca.count * ca.centroid.y + cb.count * cb.centroid.y) / count,
163
+ },
164
+ count,
165
+ minX: Math.min(ca.minX, cb.minX),
166
+ minY: Math.min(ca.minY, cb.minY),
167
+ maxX: Math.max(ca.maxX, cb.maxX),
168
+ maxY: Math.max(ca.maxY, cb.maxY),
169
+ }
170
+ parent[ra] = rb
171
+ clusters.delete(ra)
172
+ clusters.set(find(rb), merged)
173
+ const z = Math.min(bestZ, lastZ)
174
+ lastZ = z
175
+ events.push({
176
+ z,
177
+ children: [refNode(first), refNode(second)],
178
+ result: refNode(merged),
179
+ })
180
+ }
181
+ return events
182
+ }
183
+
184
+ // --- Shared invariant assertions --------------------------------------------
185
+
186
+ function expectNonIncreasingZ(events: RawMergeEvent[]) {
187
+ for (let i = 1; i < events.length; i++) {
188
+ expect(events[i].z).toBeLessThanOrEqual(events[i - 1].z)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Dendrogram integrity: children are consumed exactly once, results are well
194
+ * formed unions, and replaying all events over the leaves ends in one root.
195
+ */
196
+ function expectDendrogramIntegrity(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
197
+ const live = new Map<string, ClusterNode>()
198
+ for (const l of leaves) {
199
+ live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
200
+ }
201
+ for (const ev of events) {
202
+ expect(ev.children).toHaveLength(2)
203
+ // children must currently exist (consumed exactly once, produced before use)
204
+ for (const child of ev.children) {
205
+ expect(live.get(child.id)).toBeDefined()
206
+ live.delete(child.id)
207
+ }
208
+ // children tuple ordered by ascending min-member id
209
+ expect(ev.children[0].members[0] < ev.children[1].members[0]).toBe(true)
210
+ // result members = sorted union of children members
211
+ const union = [...ev.children[0].members, ...ev.children[1].members].sort()
212
+ expect(ev.result.members).toEqual(union)
213
+ expect(ev.result.count).toBe(ev.children[0].count + ev.children[1].count)
214
+ expect(ev.result.id).toBe(`cluster:${ev.result.count}:${union[0]}`)
215
+ expect(live.has(ev.result.id)).toBe(false)
216
+ live.set(ev.result.id, ev.result)
217
+ }
218
+ // n−1 merges over a spanning tree always end in a single root
219
+ if (leaves.length >= 2) {
220
+ expect(live.size).toBe(1)
221
+ const root = [...live.values()][0]
222
+ expect(root.members).toEqual(leaves.map((l) => l.id).sort())
223
+ }
224
+ }
225
+
226
+ /** Locality invariant: at its birth zoom, a cluster spans at most Dmax screen px. */
227
+ function expectLocality(events: RawMergeEvent[], leaves: readonly LeafInput[], Dmax: number) {
228
+ const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
229
+ for (const ev of events) {
230
+ if (!Number.isFinite(ev.z)) continue
231
+ let minX = Infinity
232
+ let minY = Infinity
233
+ let maxX = -Infinity
234
+ let maxY = -Infinity
235
+ for (const id of ev.result.members) {
236
+ const p = pointOf.get(id)!
237
+ minX = Math.min(minX, p.x)
238
+ minY = Math.min(minY, p.y)
239
+ maxX = Math.max(maxX, p.x)
240
+ maxY = Math.max(maxY, p.y)
241
+ }
242
+ const diag = Math.hypot(maxX - minX, maxY - minY)
243
+ expect(ev.z * diag).toBeLessThanOrEqual(Dmax * (1 + 1e-9))
244
+ }
245
+ }
246
+
247
+ /** Centroids equal the count-weighted mean = plain mean of all member anchors. */
248
+ function expectCentroids(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
249
+ const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
250
+ for (const ev of events) {
251
+ let sx = 0
252
+ let sy = 0
253
+ for (const id of ev.result.members) {
254
+ const p = pointOf.get(id)!
255
+ sx += p.x
256
+ sy += p.y
257
+ }
258
+ expect(ev.result.centroid.x).toBeCloseTo(sx / ev.result.count, 8)
259
+ expect(ev.result.centroid.y).toBeCloseTo(sy / ev.result.count, 8)
260
+ }
261
+ }
262
+
263
+ // --- Fixtures ----------------------------------------------------------------
264
+
265
+ describe('cappedReplay fixtures', () => {
266
+ it('exports the pinned D_FLOOR constant', () => {
267
+ expect(D_FLOOR).toBe(1e-9)
268
+ })
269
+
270
+ it('returns [] for fewer than two leaves', () => {
271
+ expect(cappedReplay([], [], { Tc: 40, Dmax: 120 })).toEqual([])
272
+ const one = [leaf('only', 3, 3)]
273
+ expect(cappedReplay(one, mstEdges(one), { Tc: 40, Dmax: 120 })).toEqual([])
274
+ })
275
+
276
+ it('merges a gap-limited pair at z = Tc/d with exact node bookkeeping', () => {
277
+ const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
278
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
279
+ expect(events).toEqual([
280
+ {
281
+ z: 4,
282
+ children: [
283
+ { id: 'a', centroid: { x: 0, y: 0 }, count: 1, members: ['a'] },
284
+ { id: 'b', centroid: { x: 10, y: 0 }, count: 1, members: ['b'] },
285
+ ],
286
+ result: { id: 'cluster:2:a', centroid: { x: 5, y: 0 }, count: 2, members: ['a', 'b'] },
287
+ },
288
+ ])
289
+ })
290
+
291
+ it('gives coincident anchors z = Infinity', () => {
292
+ const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
293
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
294
+ expect(events).toHaveLength(1)
295
+ expect(events[0].z).toBe(Infinity)
296
+ expect(events[0].result.id).toBe('cluster:2:a')
297
+ })
298
+
299
+ it('treats sub-D_FLOOR distances as coincident, but not distances above it', () => {
300
+ const below = [leaf('a', 0, 0), leaf('b', 1e-12, 0)]
301
+ expect(cappedReplay(below, mstEdges(below), { Tc: 40, Dmax: 120 })[0].z).toBe(Infinity)
302
+ const above = [leaf('a', 0, 0), leaf('b', 1e-6, 0)]
303
+ const [ev] = cappedReplay(above, mstEdges(above), { Tc: 40, Dmax: 120 })
304
+ expect(ev.z).toBe(40 / 1e-6)
305
+ expect(Number.isFinite(ev.z)).toBe(true)
306
+ })
307
+
308
+ it('fires the coincident merge first, then the bridge (Infinity sorts to the head)', () => {
309
+ const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 105, 5)]
310
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
311
+ expect(eventShapes(events)).toEqual([
312
+ { z: Infinity, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
313
+ {
314
+ z: 40 / 100,
315
+ children: ['cluster:2:a', 'c'],
316
+ result: 'cluster:3:a',
317
+ members: ['a', 'b', 'c'],
318
+ count: 3,
319
+ },
320
+ ])
321
+ })
322
+
323
+ it('replays the ABCDE path of CLUSTERING.md appendix A.1 (cap not binding)', () => {
324
+ // a —10— b —25— c —12— d —40— e, Tc = 300, cap effectively off.
325
+ // The MST edges still decide WHICH clusters merge; the gap term prices
326
+ // each merge by the clusters' CENTROID distance:
327
+ // a+b at 300/10 (leaves: centroid dist = edge length)
328
+ // c+d at 300/12... no — c–d edge, leaves, d = 12 → 300/12 = 25
329
+ // {a,b}+{c,d} via b–c: centroids (5,0) vs (41,0) → 300/36
330
+ // +e via d–e: centroids (23,0) vs (87,0) → 300/64
331
+ const leaves = [
332
+ leaf('a', 0, 0),
333
+ leaf('b', 10, 0),
334
+ leaf('c', 35, 0),
335
+ leaf('d', 47, 0),
336
+ leaf('e', 87, 0),
337
+ ]
338
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 300, Dmax: 1e9 })
339
+ expect(eventShapes(events)).toEqual([
340
+ { z: 30, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
341
+ { z: 25, children: ['c', 'd'], result: 'cluster:2:c', members: ['c', 'd'], count: 2 },
342
+ {
343
+ z: 300 / 36,
344
+ children: ['cluster:2:a', 'cluster:2:c'],
345
+ result: 'cluster:4:a',
346
+ members: ['a', 'b', 'c', 'd'],
347
+ count: 4,
348
+ },
349
+ {
350
+ z: 300 / 64,
351
+ children: ['cluster:4:a', 'e'],
352
+ result: 'cluster:5:a',
353
+ members: ['a', 'b', 'c', 'd', 'e'],
354
+ count: 5,
355
+ },
356
+ ])
357
+ // exact incremental centroids: (5,0), (41,0), (23,0), (35.8,0)
358
+ expect(events.map((e) => e.result.centroid.x)).toEqual([5, 41, 23, 35.8])
359
+ })
360
+
361
+ it('prices cluster merges by badge (centroid) distance, not nearest members', () => {
362
+ // Two tight pairs whose facing members are only 14 apart but whose
363
+ // centroids are 20 apart. Nearest-member pricing would join the badges at
364
+ // z = 40/14 ≈ 2.86 — while the rendered badges still look far apart.
365
+ // Centroid pricing fires at 40/20 = 2.
366
+ const leaves = [leaf('a', 0, 0), leaf('b', 6, 0), leaf('c', 20, 0), leaf('d', 26, 0)]
367
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 1000 })
368
+ expect(events.map((e) => e.z)).toEqual([40 / 6, 40 / 6, 2])
369
+ expect(events[2].children.map((c) => c.id)).toEqual(['cluster:2:a', 'cluster:2:c'])
370
+ })
371
+
372
+ it('builds the 8-pin chain hierarchically: pairs, then quads, then the octet', () => {
373
+ // p1..p8 spaced 10 apart, Tc = 40, Dmax = 120 (CLUSTERING.md appendix A.2).
374
+ // Leaf pairs all price at 40/10 = 4. Once a pair forms, extending it is
375
+ // priced from the pair's CENTROID (dist 15 → z ≈ 2.67), which loses ties
376
+ // against the remaining leaf pairs — so the chain forms 4 pairs first.
377
+ // Pair-pair merges price at centroid dist 20 → z = 2 (quads), and the
378
+ // final quad-quad bridge at centroid dist 40 (centroids 15 and 55) → z = 1.
379
+ // Centroid pricing alone now produces the progressive doubling the cap
380
+ // used to force; the cap remains as the hard extent guarantee.
381
+ const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
382
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
383
+ expect(events.map((e) => e.z)).toEqual([4, 4, 4, 4, 2, 2, 1])
384
+ expect(events.map((e) => e.result.id)).toEqual([
385
+ 'cluster:2:p1',
386
+ 'cluster:2:p3',
387
+ 'cluster:2:p5',
388
+ 'cluster:2:p7',
389
+ 'cluster:4:p1',
390
+ 'cluster:4:p5',
391
+ 'cluster:8:p1',
392
+ ])
393
+ expect(events[4].children.map((c) => c.id)).toEqual(['cluster:2:p1', 'cluster:2:p3'])
394
+ expect(events[6].children.map((c) => c.id)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
395
+ expect(events[6].result.members).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
396
+ })
397
+
398
+ it('computes the fit term from the union bbox DIAGONAL, not an axis span', () => {
399
+ // {a,b} spans (0,0)–(0,30); adding c(40,30) makes the union bbox 40×30,
400
+ // diag hypot(40,30) = 50. With Tc = 100, Dmax = 110:
401
+ // a–b: leaves at dist 30 → min(100/30, 110/30) = 100/30 (gap-limited)
402
+ // b–c after a+b: centroid (0,15) to c(40,30) → gap = 100/√1825 ≈ 2.34;
403
+ // fit via the diagonal = 110/50 = 2.2 wins (an axis-span implementation
404
+ // would give 110/40 = 2.75 and the gap term would win instead)
405
+ const leaves = [leaf('a', 0, 0), leaf('b', 0, 30), leaf('c', 40, 30)]
406
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 100, Dmax: 110 })
407
+ expect(events.map((e) => e.z)).toEqual([100 / 30, 2.2])
408
+ expect(events[1].result.id).toBe('cluster:3:a')
409
+ })
410
+ })
411
+
412
+ // --- Properties against the reference oracle ---------------------------------
413
+
414
+ describe('cappedReplay vs reference simulator', () => {
415
+ const OPTION_SETS = [
416
+ { Tc: 40, Dmax: 120 }, // default shape
417
+ { Tc: 40, Dmax: 40 }, // tightest legal cap — maximally fit-limited
418
+ { Tc: 5, Dmax: 1000 }, // cap effectively off
419
+ ]
420
+
421
+ it.each([2, 3, 5, 10, 40, 80])('matches the oracle on %i random points', (n) => {
422
+ for (const seed of [1, 42]) {
423
+ const leaves = randomLeaves(n, seed * 31 + n)
424
+ const edges = mstEdges(leaves)
425
+ for (const opts of OPTION_SETS) {
426
+ const actual = cappedReplay(leaves, edges, opts)
427
+ const expected = referenceReplay(leaves, edges, opts)
428
+ expect(eventShapes(actual)).toEqual(eventShapes(expected))
429
+ // pinned formulas (hypot diag, incremental centroid) ⇒ exact floats
430
+ expect(actual.map((e) => e.z)).toEqual(expected.map((e) => e.z))
431
+ for (let i = 0; i < actual.length; i++) {
432
+ expect(actual[i].result.centroid).toEqual(expected[i].result.centroid)
433
+ }
434
+ }
435
+ }
436
+ })
437
+
438
+ it('matches the oracle on mixed-sign and far-offset coordinates', () => {
439
+ const rand = mulberry32(101)
440
+ const mixed = Array.from({ length: 40 }, (_, i) =>
441
+ leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000)
442
+ )
443
+ const offset = Array.from({ length: 40 }, (_, i) =>
444
+ leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000)
445
+ )
446
+ for (const leaves of [mixed, offset]) {
447
+ const edges = mstEdges(leaves)
448
+ for (const opts of OPTION_SETS) {
449
+ expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
450
+ eventShapes(referenceReplay(leaves, edges, opts))
451
+ )
452
+ }
453
+ }
454
+ })
455
+
456
+ it('matches the oracle on lattice points (exact ties + coincident anchors)', () => {
457
+ const rand = mulberry32(7)
458
+ const leaves = Array.from({ length: 50 }, (_, i) =>
459
+ leaf(`lat-${i}`, Math.floor(rand() * 10) * 10, Math.floor(rand() * 10) * 10)
460
+ )
461
+ const edges = mstEdges(leaves)
462
+ for (const opts of OPTION_SETS) {
463
+ expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
464
+ eventShapes(referenceReplay(leaves, edges, opts))
465
+ )
466
+ }
467
+ })
468
+ })
469
+
470
+ describe('cappedReplay invariants', () => {
471
+ it('satisfies all structural invariants on random inputs', () => {
472
+ for (const seed of [3, 99]) {
473
+ for (const n of [2, 7, 30, 60]) {
474
+ const leaves = randomLeaves(n, seed * 17 + n)
475
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
476
+ expect(events).toHaveLength(n - 1)
477
+ expectNonIncreasingZ(events)
478
+ expectDendrogramIntegrity(events, leaves)
479
+ expectLocality(events, leaves, 120)
480
+ expectCentroids(events, leaves)
481
+ }
482
+ }
483
+ })
484
+
485
+ it('holds the locality invariant even at the tightest legal cap', () => {
486
+ const leaves = randomLeaves(50, 13)
487
+ const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 40 })
488
+ expectNonIncreasingZ(events)
489
+ expectDendrogramIntegrity(events, leaves)
490
+ expectLocality(events, leaves, 40)
491
+ })
492
+
493
+ it('is invariant under input permutation (as id-keyed event structures)', () => {
494
+ const base = randomLeaves(40, 21)
495
+ const expected = eventShapes(cappedReplay(base, mstEdges(base), { Tc: 40, Dmax: 120 }))
496
+ for (const shuffleSeed of [5, 23]) {
497
+ const perm = shuffled(base, mulberry32(shuffleSeed))
498
+ expect(eventShapes(cappedReplay(perm, mstEdges(perm), { Tc: 40, Dmax: 120 }))).toEqual(
499
+ expected
500
+ )
501
+ }
502
+ })
503
+
504
+ it('is pure: identical repeat output, no input mutation', () => {
505
+ const leaves = randomLeaves(20, 5)
506
+ const edges = mstEdges(leaves)
507
+ const leavesSnapshot = JSON.parse(JSON.stringify(leaves))
508
+ const edgesSnapshot = JSON.parse(JSON.stringify(edges))
509
+ const first = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
510
+ const second = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
511
+ expect(first).toEqual(second)
512
+ expect(leaves).toEqual(leavesSnapshot)
513
+ expect(edges).toEqual(edgesSnapshot)
514
+ })
515
+ })
516
+
517
+ describe('cappedReplay validation', () => {
518
+ const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
519
+ const edges = mstEdges(leaves)
520
+
521
+ it('throws on non-positive Tc, naming the option', () => {
522
+ expect(() => cappedReplay(leaves, edges, { Tc: 0, Dmax: 120 })).toThrow(/Tc/)
523
+ expect(() => cappedReplay(leaves, edges, { Tc: -5, Dmax: 120 })).toThrow(/Tc/)
524
+ })
525
+
526
+ it('throws on non-positive Dmax, naming the option', () => {
527
+ expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 0 })).toThrow(/Dmax/)
528
+ expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: -1 })).toThrow(/Dmax/)
529
+ })
530
+
531
+ it('throws when Dmax < Tc, naming the option', () => {
532
+ expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 30 })).toThrow(/Dmax/)
533
+ })
534
+ })
535
+
536
+ describe('cappedReplay performance', () => {
537
+ it('replays 2,000 points well within budget', () => {
538
+ const leaves = randomLeaves(2000, 2026)
539
+ const edges = mstEdges(leaves)
540
+ const start = performance.now()
541
+ const events = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
542
+ const elapsed = performance.now() - start
543
+ expect(events).toHaveLength(1999)
544
+ // generous bound to stay non-flaky on slow CI
545
+ expect(elapsed).toBeLessThan(2000)
546
+ })
547
+ })