@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,153 @@
1
+ import type { ClusterNode, ClusterTable, MergeEvent } from './types'
2
+
3
+ /** @public */
4
+ export interface ClusterRuntime {
5
+ /** Events[0..k) are active. Exposed for tests and debugging. */
6
+ readonly k: number
7
+ /** Reset state from scratch for the given zoom (cold start / after rebuild). */
8
+ seed(zoom: number): void
9
+ /**
10
+ * Reset state for the given zoom, carrying hysteresis state over from a previous partition
11
+ * (the visible map of the model being replaced). Threshold-forced events ignore history:
12
+ * zoom \<= zMerge is always active, zoom \>= zSplit always inactive. An event inside its band
13
+ * is active iff its members were merged together in `previous`; a band event with no history
14
+ * (e.g. introduced by the rebuild) stays inactive until the next zoom-out crosses its zMerge.
15
+ * Active events must form a prefix of the table, so classification stops at the first
16
+ * inactive event; later band-active events conservatively resolve to split.
17
+ */
18
+ seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
19
+ /** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
20
+ onCamera(zoom: number): void
21
+ /** The current partition: cluster id → node. Do not mutate. */
22
+ getVisible(): ReadonlyMap<string, ClusterNode>
23
+ }
24
+
25
+ /** @public */
26
+ export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
27
+ return new ClusterRuntimeImpl(table)
28
+ }
29
+
30
+ class ClusterRuntimeImpl implements ClusterRuntime {
31
+ // mutable internally; readonly through the ClusterRuntime interface
32
+ k = 0
33
+ private visible = new Map<string, ClusterNode>()
34
+ private seeded = false
35
+
36
+ constructor(private readonly table: ClusterTable) {
37
+ this.resetVisible()
38
+ }
39
+
40
+ seed(zoom: number): void {
41
+ validateZoom(zoom)
42
+ this.k = seedCount(this.table.events, zoom)
43
+ this.resetVisible()
44
+ for (let i = 0; i < this.k; i++) {
45
+ applyEvent(this.visible, this.table.events[i])
46
+ }
47
+ this.seeded = true
48
+ }
49
+
50
+ seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
51
+ validateZoom(zoom)
52
+ const ownerByMember = new Map<string, string>()
53
+ for (const node of previous.values()) {
54
+ for (const member of node.members) {
55
+ ownerByMember.set(member, node.id)
56
+ }
57
+ }
58
+
59
+ const events = this.table.events
60
+ let k = 0
61
+ while (k < events.length) {
62
+ const event = events[k]
63
+ if (zoom >= event.zSplit) break
64
+ if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) break
65
+ k++
66
+ }
67
+
68
+ this.k = k
69
+ this.resetVisible()
70
+ for (let i = 0; i < k; i++) {
71
+ applyEvent(this.visible, events[i])
72
+ }
73
+ this.seeded = true
74
+ }
75
+
76
+ onCamera(zoom: number): void {
77
+ validateZoom(zoom)
78
+ if (!this.seeded) {
79
+ throw new Error('Cluster runtime must be seeded before onCamera')
80
+ }
81
+
82
+ while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
83
+ applyEvent(this.visible, this.table.events[this.k])
84
+ this.k++
85
+ }
86
+ while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
87
+ this.k--
88
+ unapplyEvent(this.visible, this.table.events[this.k])
89
+ }
90
+ }
91
+
92
+ getVisible(): ReadonlyMap<string, ClusterNode> {
93
+ return this.visible
94
+ }
95
+
96
+ private resetVisible() {
97
+ this.visible = new Map()
98
+ for (const leaf of this.table.leaves) {
99
+ this.visible.set(leaf.id, leaf)
100
+ }
101
+ }
102
+ }
103
+
104
+ /** True iff every member id belonged to the same node of the previous partition. */
105
+ function wasMergedTogether(
106
+ members: readonly string[],
107
+ ownerByMember: ReadonlyMap<string, string>
108
+ ): boolean {
109
+ const owner = ownerByMember.get(members[0])
110
+ if (owner === undefined) return false
111
+ for (let i = 1; i < members.length; i++) {
112
+ if (ownerByMember.get(members[i]) !== owner) return false
113
+ }
114
+ return true
115
+ }
116
+
117
+ function seedCount(events: readonly MergeEvent[], zoom: number): number {
118
+ let lo = 0
119
+ let hi = events.length
120
+ while (lo < hi) {
121
+ const mid = (lo + hi) >> 1
122
+ if (zoom <= seedThreshold(events[mid])) {
123
+ lo = mid + 1
124
+ } else {
125
+ hi = mid
126
+ }
127
+ }
128
+ return lo
129
+ }
130
+
131
+ function seedThreshold(event: MergeEvent): number {
132
+ return Math.sqrt(event.zMerge * event.zSplit)
133
+ }
134
+
135
+ function applyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
136
+ for (const child of event.children) {
137
+ visible.delete(child.id)
138
+ }
139
+ visible.set(event.result.id, event.result)
140
+ }
141
+
142
+ function unapplyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
143
+ visible.delete(event.result.id)
144
+ for (const child of event.children) {
145
+ visible.set(child.id, child)
146
+ }
147
+ }
148
+
149
+ function validateZoom(zoom: number) {
150
+ if (!Number.isFinite(zoom) || zoom <= 0) {
151
+ throw new Error('zoom must be finite and greater than 0')
152
+ }
153
+ }
@@ -0,0 +1,514 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mstEdges } from './mst'
3
+ import { cappedReplay } from './replay'
4
+ // These imports are red until step 3 is implemented — that is intentional.
5
+ // Implement `schedule.ts` (and the step 3 additions to `types.ts`) per
6
+ // CLUSTERING-STEPS.md until this suite passes, without modifying this file.
7
+ import { contract, finalize } from './schedule'
8
+ import { ClusterNode, ContractedEvent, LeafInput, MergeEvent, RawMergeEvent } from './types'
9
+
10
+ // --- Synthetic builders -------------------------------------------------------
11
+ // `contract` is specified over plain RawMergeEvent[] input precisely so it can
12
+ // be tested with hand-built events, without running the replay.
13
+
14
+ /** Build a ClusterNode from member ids (leaf node for one id, cluster node for more). */
15
+ function node(ids: string[], x = 0, y = 0): ClusterNode {
16
+ const members = ids.slice().sort()
17
+ return {
18
+ id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
19
+ centroid: { x, y },
20
+ count: members.length,
21
+ members,
22
+ }
23
+ }
24
+
25
+ /** Build a RawMergeEvent merging two nodes, with contract-conformant ordering. */
26
+ function raw(z: number, a: ClusterNode, b: ClusterNode): RawMergeEvent {
27
+ const [first, second] = a.members[0] < b.members[0] ? [a, b] : [b, a]
28
+ return { z, children: [first, second], result: node([...a.members, ...b.members]) }
29
+ }
30
+
31
+ /** Every cluster id referenced anywhere in the output (children and results). */
32
+ function collectIds(events: readonly ContractedEvent[]): Set<string> {
33
+ const ids = new Set<string>()
34
+ for (const ev of events) {
35
+ for (const c of ev.children) ids.add(c.id)
36
+ ids.add(ev.result.id)
37
+ }
38
+ return ids
39
+ }
40
+
41
+ function contractedShape(ev: ContractedEvent) {
42
+ return {
43
+ zMerge: ev.zMerge,
44
+ children: ev.children.map((c) => c.id),
45
+ result: ev.result.id,
46
+ }
47
+ }
48
+
49
+ const A = node(['a'])
50
+ const B = node(['b'])
51
+ const C = node(['c'])
52
+ const D = node(['d'])
53
+ const E = node(['e'])
54
+ const F = node(['f'])
55
+ const X = node(['x'])
56
+ const Y = node(['y'])
57
+
58
+ // --- contract: fixtures -------------------------------------------------------
59
+
60
+ describe('contract fixtures', () => {
61
+ it('returns [] for empty input', () => {
62
+ expect(contract([], 0.12)).toEqual([])
63
+ })
64
+
65
+ it('passes a single event through unchanged (apart from the type)', () => {
66
+ const e1 = raw(4, A, B)
67
+ expect(contract([e1], 0.12)).toEqual([{ zMerge: 4, children: [A, B], result: e1.result }])
68
+ })
69
+
70
+ it('fuses a two-event chain within eps into one 3-way event at the anchor z', () => {
71
+ const e1 = raw(4, A, B) // result P = cluster:2:a
72
+ const e2 = raw(3.7, e1.result, C) // 3.7 >= 4/1.12 ≈ 3.571 → same window, chained via P
73
+ const out = contract([e1, e2], 0.12)
74
+ expect(out).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
75
+ // the intermediate P is erased — representable nowhere
76
+ expect(collectIds(out).has(e1.result.id)).toBe(false)
77
+ })
78
+
79
+ it('keeps the same chain separate when the second event falls outside eps', () => {
80
+ const e1 = raw(4, A, B)
81
+ const e2 = raw(3.5, e1.result, C) // 3.5 < 4/1.12 ≈ 3.571 → next window
82
+ const out = contract([e1, e2], 0.12)
83
+ expect(out.map(contractedShape)).toEqual([
84
+ { zMerge: 4, children: ['a', 'b'], result: e1.result.id },
85
+ { zMerge: 3.5, children: [e1.result.id, 'c'], result: e2.result.id },
86
+ ])
87
+ // the intermediate survives: it is genuinely visible between 3.5 and 4
88
+ expect(collectIds(out).has(e1.result.id)).toBe(true)
89
+ })
90
+
91
+ it('synchronizes unrelated same-window merges without fusing them', () => {
92
+ const e1 = raw(4, A, B)
93
+ const e2 = raw(3.9, X, Y) // within eps of the anchor, but disjoint
94
+ const out = contract([e1, e2], 0.12)
95
+ // two events, BOTH at the anchor z, ordered by ascending min-member id of result
96
+ expect(out.map(contractedShape)).toEqual([
97
+ { zMerge: 4, children: ['a', 'b'], result: e1.result.id },
98
+ { zMerge: 4, children: ['x', 'y'], result: e2.result.id },
99
+ ])
100
+ })
101
+
102
+ it('separates interleaved chains within one window by connectivity', () => {
103
+ const e1 = raw(4, A, B) // chain 1
104
+ const e2 = raw(3.95, X, Y) // chain 2, interleaved between chain 1's events
105
+ const e3 = raw(3.9, e1.result, C) // chain 1 again, via e1.result
106
+ const out = contract([e1, e2, e3], 0.12)
107
+ expect(out.map(contractedShape)).toEqual([
108
+ { zMerge: 4, children: ['a', 'b', 'c'], result: e3.result.id },
109
+ { zMerge: 4, children: ['x', 'y'], result: e2.result.id },
110
+ ])
111
+ expect(collectIds(out).has(e1.result.id)).toBe(false)
112
+ })
113
+
114
+ it('fuses branched chains (two results consumed by one later event)', () => {
115
+ const e1 = raw(4, A, B) // → cluster:2:a
116
+ const e2 = raw(3.9, C, D) // → cluster:2:c
117
+ const e3 = raw(3.85, e1.result, e2.result) // consumes both → one connected chain
118
+ const out = contract([e1, e2, e3], 0.12)
119
+ expect(out).toEqual([{ zMerge: 4, children: [A, B, C, D], result: e3.result }])
120
+ const ids = collectIds(out)
121
+ expect(ids.has(e1.result.id)).toBe(false)
122
+ expect(ids.has(e2.result.id)).toBe(false)
123
+ })
124
+
125
+ it('anchors the window to its first event, never the previous one', () => {
126
+ // 3.8 is within eps of the 4.0 anchor; 3.55 is within eps of 3.8 but NOT of
127
+ // the anchor (3.55 < 4/1.12 ≈ 3.571) — a gradient must break into windows.
128
+ const e1 = raw(4, A, B)
129
+ const e2 = raw(3.8, X, Y)
130
+ const e3 = raw(3.55, C, D)
131
+ const out = contract([e1, e2, e3], 0.12)
132
+ expect(out.map((ev) => ev.zMerge)).toEqual([4, 4, 3.55])
133
+ })
134
+
135
+ it('includes an event exactly on the window boundary (>=, not >)', () => {
136
+ // eps = 0.25 → boundary at exactly 4/1.25 = 3.2
137
+ const e1 = raw(4, A, B)
138
+ const e2 = raw(3.2, X, Y)
139
+ const out = contract([e1, e2], 0.25)
140
+ expect(out.map((ev) => ev.zMerge)).toEqual([4, 4])
141
+ })
142
+
143
+ it('fuses a chain that spans windows only up to the boundary, keeping the seam intact', () => {
144
+ // One chain across five events; eps = 0.12 splits it into windows
145
+ // {4.0, 3.8, 3.6} and {3.4, 3.2}. The second window's fused event must
146
+ // reference the first window's fused RESULT (cluster:4:a), not an erased
147
+ // intermediate.
148
+ const e1 = raw(4, A, B) // → cluster:2:a
149
+ const e2 = raw(3.8, e1.result, C) // → cluster:3:a
150
+ const e3 = raw(3.6, e2.result, D) // → cluster:4:a
151
+ const e4 = raw(3.4, e3.result, E) // → cluster:5:a
152
+ const e5 = raw(3.2, e4.result, F) // → cluster:6:a
153
+ const out = contract([e1, e2, e3, e4, e5], 0.12)
154
+ expect(out.map(contractedShape)).toEqual([
155
+ { zMerge: 4, children: ['a', 'b', 'c', 'd'], result: 'cluster:4:a' },
156
+ { zMerge: 3.4, children: ['cluster:4:a', 'e', 'f'], result: 'cluster:6:a' },
157
+ ])
158
+ const ids = collectIds(out)
159
+ for (const erased of [e1.result.id, e2.result.id, e4.result.id]) {
160
+ expect(ids.has(erased)).toBe(false)
161
+ }
162
+ })
163
+
164
+ it('groups exact ties with eps = 0, and nothing else', () => {
165
+ const e1 = raw(4, A, B)
166
+ const e2 = raw(4, e1.result, C) // exact tie, chained → fuses even at eps = 0
167
+ expect(contract([e1, e2], 0)).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
168
+
169
+ const e3 = raw(4, A, B)
170
+ const e4 = raw(3.9999999, e3.result, C) // any difference → separate windows
171
+ expect(contract([e3, e4], 0).map((ev) => ev.zMerge)).toEqual([4, 3.9999999])
172
+ })
173
+
174
+ it('lets an Infinity anchor admit only other Infinity events', () => {
175
+ const e1 = raw(Infinity, A, B)
176
+ const e2 = raw(4, e1.result, C) // finite: must NOT join the Infinity window
177
+ expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
178
+ { zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
179
+ { zMerge: 4, children: [e1.result.id, 'c'], result: e2.result.id },
180
+ ])
181
+ })
182
+
183
+ it('keeps two coincident groups separate inside one Infinity window', () => {
184
+ const e1 = raw(Infinity, A, B)
185
+ const e2 = raw(Infinity, X, Y)
186
+ expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
187
+ { zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
188
+ { zMerge: Infinity, children: ['x', 'y'], result: e2.result.id },
189
+ ])
190
+ })
191
+ })
192
+
193
+ describe('contract validation and purity', () => {
194
+ it('throws on negative or non-finite eps', () => {
195
+ const events = [raw(4, A, B)]
196
+ expect(() => contract(events, -0.1)).toThrow()
197
+ expect(() => contract(events, NaN)).toThrow()
198
+ expect(() => contract(events, Infinity)).toThrow()
199
+ })
200
+
201
+ it('is pure: identical repeat output, no input mutation', () => {
202
+ const e1 = raw(4, A, B)
203
+ const e2 = raw(3.7, e1.result, C)
204
+ const input = [e1, e2]
205
+ const snapshot = JSON.parse(JSON.stringify(input))
206
+ const first = contract(input, 0.12)
207
+ const second = contract(input, 0.12)
208
+ expect(first).toEqual(second)
209
+ expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
210
+ })
211
+ })
212
+
213
+ // --- finalize: fixtures --------------------------------------------------------
214
+
215
+ const FIN_OPTS = { Tc: 40, Tu: 60, minZoom: 0.1, maxZoom: 8, maxSplitZoom: 1e9 } // r = 1.5
216
+
217
+ function contracted(zMerge: number, children: ClusterNode[], result: ClusterNode): ContractedEvent {
218
+ return { zMerge, children, result }
219
+ }
220
+
221
+ describe('finalize fixtures', () => {
222
+ it('returns [] for empty input', () => {
223
+ expect(finalize([], FIN_OPTS)).toEqual([])
224
+ })
225
+
226
+ it('applies zSplit = r · zMerge and preserves everything else', () => {
227
+ const ev1 = contracted(4, [A, B], node(['a', 'b']))
228
+ const ev2 = contracted(1, [node(['a', 'b']), C], node(['a', 'b', 'c']))
229
+ expect(finalize([ev1, ev2], FIN_OPTS)).toEqual([
230
+ { zMerge: 4, zSplit: 6, children: ev1.children, result: ev1.result },
231
+ { zMerge: 1, zSplit: 1.5, children: ev2.children, result: ev2.result },
232
+ ])
233
+ })
234
+
235
+ it('clamps a band that straddles maxZoom (the stuck-merged trap)', () => {
236
+ // zMerge 6, r 1.5 → raw zSplit 9 > maxZoom 8: without the clamp this pair
237
+ // would merge on zoom-out and never split again within reachable zoom.
238
+ const ev = contracted(6, [A, B], node(['a', 'b']))
239
+ const [out] = finalize([ev], FIN_OPTS)
240
+ expect(out.zSplit).toBe(8)
241
+ expect(out.zMerge).toBe(6)
242
+ })
243
+
244
+ it('does not clamp bands that fit under maxZoom', () => {
245
+ const ev = contracted(2, [A, B], node(['a', 'b']))
246
+ expect(finalize([ev], FIN_OPTS)[0].zSplit).toBe(3)
247
+ })
248
+
249
+ it('leaves zMerge >= maxZoom events unclamped by maxZoom (band above camera range)', () => {
250
+ // zMerge exactly at maxZoom: the clamp condition is zMerge < maxZoom, strict
251
+ const atMax = contracted(8, [A, B], node(['a', 'b']))
252
+ expect(finalize([atMax], FIN_OPTS)[0].zSplit).toBe(12)
253
+ // coincident anchors are no longer permanently merged — maxSplitZoom caps them
254
+ const inf = contracted(Infinity, [X, Y], node(['x', 'y']))
255
+ expect(finalize([inf], FIN_OPTS)[0]).toMatchObject({ zMerge: 1e9 / 1.5, zSplit: 1e9 })
256
+ })
257
+
258
+ it('caps every split at maxSplitZoom, folding the band below the cap', () => {
259
+ // r = 1.5, maxSplitZoom 6 -> zMerge cap 4: past zoom 6 nothing stays clustered,
260
+ // however close (or coincident) the members are.
261
+ const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
262
+ const coincident = contracted(Infinity, [A, B], node(['a', 'b']))
263
+ const tight = contracted(10, [A, B], node(['a', 'b']))
264
+ const normal = contracted(2, [A, B], node(['a', 'b']))
265
+ expect(finalize([coincident], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
266
+ expect(finalize([tight], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
267
+ // events already below the cap are untouched
268
+ expect(finalize([normal], opts)[0]).toMatchObject({ zMerge: 2, zSplit: 3 })
269
+ })
270
+
271
+ it('capping preserves sort order and the zSplit > zMerge invariant', () => {
272
+ const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
273
+ const events = [
274
+ contracted(Infinity, [A, B], node(['a', 'b'])),
275
+ contracted(10, [C, X], node(['c', 'x'])),
276
+ contracted(2, [Y, node(['a', 'b'])], node(['a', 'b', 'y'])),
277
+ ]
278
+ const out = finalize(events, opts)
279
+ expect(out.map((e) => e.zMerge)).toEqual([4, 4, 2])
280
+ for (let i = 0; i < out.length; i++) {
281
+ expect(out[i].zSplit).toBeGreaterThan(out[i].zMerge)
282
+ if (i > 0) expect(out[i].zSplit).toBeLessThanOrEqual(out[i - 1].zSplit)
283
+ }
284
+ })
285
+
286
+ it('throws on non-positive or non-finite maxSplitZoom', () => {
287
+ const events = [contracted(2, [A, B], node(['a', 'b']))]
288
+ expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: 0 })).toThrow()
289
+ expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: -1 })).toThrow()
290
+ expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: NaN })).toThrow()
291
+ expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: Infinity })).toThrow()
292
+ })
293
+
294
+ it('prunes the trailing suffix below minZoom, keeping the boundary value', () => {
295
+ const keep = contracted(4, [A, B], node(['a', 'b']))
296
+ const boundary = contracted(0.1, [node(['a', 'b']), C], node(['a', 'b', 'c'])) // == minZoom: kept
297
+ const prune = contracted(0.0999, [node(['a', 'b', 'c']), D], node(['a', 'b', 'c', 'd']))
298
+ const out = finalize([keep, boundary, prune], FIN_OPTS)
299
+ expect(out.map((ev) => ev.zMerge)).toEqual([4, 0.1])
300
+ })
301
+
302
+ it('throws on every invalid option combination', () => {
303
+ const events = [contracted(4, [A, B], node(['a', 'b']))]
304
+ expect(() => finalize(events, { ...FIN_OPTS, Tc: 0 })).toThrow()
305
+ expect(() => finalize(events, { ...FIN_OPTS, Tc: -1 })).toThrow()
306
+ expect(() => finalize(events, { ...FIN_OPTS, Tu: 40 })).toThrow() // Tu must be > Tc
307
+ expect(() => finalize(events, { ...FIN_OPTS, Tu: 30 })).toThrow()
308
+ expect(() => finalize(events, { ...FIN_OPTS, minZoom: 0 })).toThrow()
309
+ expect(() => finalize(events, { ...FIN_OPTS, minZoom: -1 })).toThrow()
310
+ expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.1 })).toThrow() // must be > minZoom
311
+ expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.05 })).toThrow()
312
+ expect(() => finalize(events, { ...FIN_OPTS, Tc: NaN })).toThrow()
313
+ expect(() => finalize(events, { ...FIN_OPTS, Tu: Infinity })).toThrow()
314
+ expect(() => finalize(events, { ...FIN_OPTS, maxZoom: Infinity })).toThrow()
315
+ })
316
+
317
+ it('is pure: identical repeat output, no input mutation', () => {
318
+ const input = [contracted(4, [A, B], node(['a', 'b']))]
319
+ const snapshot = JSON.parse(JSON.stringify(input))
320
+ expect(finalize(input, FIN_OPTS)).toEqual(finalize(input, FIN_OPTS))
321
+ expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
322
+ })
323
+ })
324
+
325
+ // --- Composed pipeline properties ---------------------------------------------
326
+ // finalize(contract(cappedReplay(mstEdges(leaves)))) must satisfy every table
327
+ // invariant of CLUSTERING.md §7.6.
328
+
329
+ function leaf(id: string, x: number, y: number): LeafInput {
330
+ return { id, point: { x, y } }
331
+ }
332
+
333
+ function mulberry32(seed: number): () => number {
334
+ let a = seed >>> 0
335
+ return () => {
336
+ a = (a + 0x6d2b79f5) >>> 0
337
+ let t = a
338
+ t = Math.imul(t ^ (t >>> 15), t | 1)
339
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
340
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
341
+ }
342
+ }
343
+
344
+ function shuffled<T>(items: readonly T[], rand: () => number): T[] {
345
+ const out = items.slice()
346
+ for (let i = out.length - 1; i > 0; i--) {
347
+ const j = Math.floor(rand() * (i + 1))
348
+ ;[out[i], out[j]] = [out[j], out[i]]
349
+ }
350
+ return out
351
+ }
352
+
353
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
354
+ const rand = mulberry32(seed)
355
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
356
+ }
357
+
358
+ const PIPE_OPTS = {
359
+ Tc: 40,
360
+ Tu: 60,
361
+ eps: 0.12,
362
+ Dmax: 120,
363
+ minZoom: 1e-6,
364
+ maxZoom: 8,
365
+ maxSplitZoom: 1e9,
366
+ }
367
+
368
+ function buildTableEvents(
369
+ leaves: readonly LeafInput[],
370
+ opts: typeof PIPE_OPTS = PIPE_OPTS
371
+ ): MergeEvent[] {
372
+ const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
373
+ return finalize(contract(rawEvents, opts.eps), {
374
+ Tc: opts.Tc,
375
+ Tu: opts.Tu,
376
+ minZoom: opts.minZoom,
377
+ maxZoom: opts.maxZoom,
378
+ maxSplitZoom: opts.maxSplitZoom,
379
+ })
380
+ }
381
+
382
+ /** All §7.6 invariants over a finalized event list. */
383
+ function expectTableInvariants(
384
+ events: readonly MergeEvent[],
385
+ leaves: readonly LeafInput[],
386
+ opts: typeof PIPE_OPTS
387
+ ) {
388
+ // 1–3: threshold ordering and band validity
389
+ for (let i = 1; i < events.length; i++) {
390
+ expect(events[i].zMerge).toBeLessThanOrEqual(events[i - 1].zMerge)
391
+ expect(events[i].zSplit).toBeLessThanOrEqual(events[i - 1].zSplit)
392
+ }
393
+ for (const ev of events) {
394
+ // coincident clusters are the one exception: zMerge = zSplit = Infinity (never split)
395
+ if (Number.isFinite(ev.zMerge)) {
396
+ expect(ev.zSplit).toBeGreaterThan(ev.zMerge)
397
+ } else {
398
+ expect(ev.zSplit).toBe(Infinity)
399
+ }
400
+ if (ev.zMerge < opts.maxZoom) expect(ev.zSplit).toBeLessThanOrEqual(opts.maxZoom)
401
+ expect(ev.zMerge).toBeGreaterThanOrEqual(opts.minZoom)
402
+ }
403
+ // 4: dendrogram integrity — replaying all events over the leaves is well
404
+ // formed (children exist when consumed ⇒ no erased intermediate is referenced)
405
+ const live = new Map<string, ClusterNode>()
406
+ for (const l of leaves) {
407
+ live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
408
+ }
409
+ for (const ev of events) {
410
+ expect(ev.children.length).toBeGreaterThanOrEqual(2)
411
+ for (const child of ev.children) {
412
+ expect(live.get(child.id)).toBeDefined()
413
+ live.delete(child.id)
414
+ }
415
+ for (let i = 1; i < ev.children.length; i++) {
416
+ expect(ev.children[i - 1].members[0] < ev.children[i].members[0]).toBe(true)
417
+ }
418
+ const union = ev.children.flatMap((c) => c.members).sort()
419
+ expect(ev.result.members).toEqual(union)
420
+ expect(ev.result.count).toBe(union.length)
421
+ expect(ev.result.id).toBe(`cluster:${union.length}:${union[0]}`)
422
+ live.set(ev.result.id, ev.result)
423
+ }
424
+ // with minZoom = 1e-6 nothing prunes, so the dendrogram completes to one root
425
+ if (leaves.length >= 2) {
426
+ expect(live.size).toBe(1)
427
+ expect([...live.values()][0].members).toEqual(leaves.map((l) => l.id).sort())
428
+ }
429
+ }
430
+
431
+ describe('composed pipeline (replay → contract → finalize)', () => {
432
+ it('satisfies all table invariants on random inputs', () => {
433
+ for (const seed of [3, 42]) {
434
+ for (const n of [2, 7, 30, 80]) {
435
+ const leaves = randomLeaves(n, seed * 17 + n)
436
+ expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
437
+ }
438
+ }
439
+ })
440
+
441
+ it('satisfies all table invariants with ties and coincident anchors (lattice)', () => {
442
+ const rand = mulberry32(11)
443
+ const leaves = Array.from({ length: 40 }, (_, i) =>
444
+ leaf(`lat-${i}`, Math.floor(rand() * 8) * 10, Math.floor(rand() * 8) * 10)
445
+ )
446
+ expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
447
+ })
448
+
449
+ it('reduces to the identity at eps = 0 on tie-free input', () => {
450
+ // Centroid pricing plus the monotonicity clamp can produce exact ties even
451
+ // on random input (a clamped threshold lands exactly on its predecessor),
452
+ // and eps = 0 legitimately fuses chained ties. Identity is only promised
453
+ // for tie-free tables — so probe seeds until one produces distinct
454
+ // thresholds, and assert the identity there.
455
+ let verified = false
456
+ for (const seed of [5, 6, 7, 8, 9, 10]) {
457
+ const leaves = randomLeaves(40, seed)
458
+ const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
459
+ if (new Set(rawEvents.map((ev) => ev.z)).size !== rawEvents.length) continue
460
+ const contractedEvents = contract(rawEvents, 0)
461
+ expect(
462
+ contractedEvents.map((ev) => ({ z: ev.zMerge, c: ev.children, r: ev.result }))
463
+ ).toEqual(rawEvents.map((ev) => ({ z: ev.z, c: ev.children, r: ev.result })))
464
+ verified = true
465
+ }
466
+ expect(verified).toBe(true)
467
+ })
468
+
469
+ it('schedules the uniform chain as synchronized pairs, quads, then the octet', () => {
470
+ // CLUSTERING.md appendix A.2 with centroid pricing: leaf pairs all fire at
471
+ // z = 4 (one contraction window, four disconnected chains → four events at
472
+ // the shared anchor), quads at centroid distance 20 → z = 2, and the
473
+ // quad-quad bridge at centroid distance 40 → z = 1.
474
+ const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
475
+ const events = buildTableEvents(leaves)
476
+ expect(
477
+ events.map((ev) => ({
478
+ zMerge: ev.zMerge,
479
+ children: ev.children.map((c) => c.id),
480
+ result: ev.result.id,
481
+ }))
482
+ ).toEqual([
483
+ { zMerge: 4, children: ['p1', 'p2'], result: 'cluster:2:p1' },
484
+ { zMerge: 4, children: ['p3', 'p4'], result: 'cluster:2:p3' },
485
+ { zMerge: 4, children: ['p5', 'p6'], result: 'cluster:2:p5' },
486
+ { zMerge: 4, children: ['p7', 'p8'], result: 'cluster:2:p7' },
487
+ { zMerge: 2, children: ['cluster:2:p1', 'cluster:2:p3'], result: 'cluster:4:p1' },
488
+ { zMerge: 2, children: ['cluster:2:p5', 'cluster:2:p7'], result: 'cluster:4:p5' },
489
+ { zMerge: 1, children: ['cluster:4:p1', 'cluster:4:p5'], result: 'cluster:8:p1' },
490
+ ])
491
+ expect(events.map((ev) => ev.zSplit)).toEqual([6, 6, 6, 6, 3, 3, 1.5])
492
+ })
493
+
494
+ it('is deterministic end to end under input permutation', () => {
495
+ const base = randomLeaves(50, 23)
496
+ const expected = buildTableEvents(base).map((ev) => ({
497
+ zMerge: ev.zMerge,
498
+ zSplit: ev.zSplit,
499
+ children: ev.children.map((c) => c.id),
500
+ result: ev.result.id,
501
+ }))
502
+ for (const shuffleSeed of [7, 29]) {
503
+ const perm = shuffled(base, mulberry32(shuffleSeed))
504
+ expect(
505
+ buildTableEvents(perm).map((ev) => ({
506
+ zMerge: ev.zMerge,
507
+ zSplit: ev.zSplit,
508
+ children: ev.children.map((c) => c.id),
509
+ result: ev.result.id,
510
+ }))
511
+ ).toEqual(expected)
512
+ }
513
+ })
514
+ })