@graphty/graph-format 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -43
  3. package/dist/graph-format.d.ts +1 -0
  4. package/dist/graph-format.d.ts.map +1 -0
  5. package/dist/graph-format.js +15798 -0
  6. package/dist/graph-format.js.map +1 -0
  7. package/dist/src/builder/arena.d.ts +85 -0
  8. package/dist/src/builder/arena.d.ts.map +1 -0
  9. package/dist/src/builder/arena.js +154 -0
  10. package/dist/src/builder/arena.js.map +1 -0
  11. package/dist/src/builder/compact.d.ts +515 -0
  12. package/dist/src/builder/compact.d.ts.map +1 -0
  13. package/dist/src/builder/compact.js +1553 -0
  14. package/dist/src/builder/compact.js.map +1 -0
  15. package/dist/src/builder/counting-sort.d.ts +96 -0
  16. package/dist/src/builder/counting-sort.d.ts.map +1 -0
  17. package/dist/src/builder/counting-sort.js +430 -0
  18. package/dist/src/builder/counting-sort.js.map +1 -0
  19. package/dist/src/builder/freeze.d.ts +72 -0
  20. package/dist/src/builder/freeze.d.ts.map +1 -0
  21. package/dist/src/builder/freeze.js +548 -0
  22. package/dist/src/builder/freeze.js.map +1 -0
  23. package/dist/src/builder/graph-builder.d.ts +602 -0
  24. package/dist/src/builder/graph-builder.d.ts.map +1 -0
  25. package/dist/src/builder/graph-builder.js +1940 -0
  26. package/dist/src/builder/graph-builder.js.map +1 -0
  27. package/dist/src/builder/options.d.ts +16 -0
  28. package/dist/src/builder/options.d.ts.map +1 -0
  29. package/dist/src/builder/options.js +21 -0
  30. package/dist/src/builder/options.js.map +1 -0
  31. package/dist/src/columns/bitmap.d.ts +96 -0
  32. package/dist/src/columns/bitmap.d.ts.map +1 -0
  33. package/dist/src/columns/bitmap.js +184 -0
  34. package/dist/src/columns/bitmap.js.map +1 -0
  35. package/dist/src/columns/column.d.ts +220 -0
  36. package/dist/src/columns/column.d.ts.map +1 -0
  37. package/dist/src/columns/column.js +2109 -0
  38. package/dist/src/columns/column.js.map +1 -0
  39. package/dist/src/columns/dictionary.d.ts +60 -0
  40. package/dist/src/columns/dictionary.d.ts.map +1 -0
  41. package/dist/src/columns/dictionary.js +102 -0
  42. package/dist/src/columns/dictionary.js.map +1 -0
  43. package/dist/src/columns/growable.d.ts +198 -0
  44. package/dist/src/columns/growable.d.ts.map +1 -0
  45. package/dist/src/columns/growable.js +296 -0
  46. package/dist/src/columns/growable.js.map +1 -0
  47. package/dist/src/columns/infer.d.ts +121 -0
  48. package/dist/src/columns/infer.d.ts.map +1 -0
  49. package/dist/src/columns/infer.js +268 -0
  50. package/dist/src/columns/infer.js.map +1 -0
  51. package/dist/src/columns/remap.d.ts +143 -0
  52. package/dist/src/columns/remap.d.ts.map +1 -0
  53. package/dist/src/columns/remap.js +588 -0
  54. package/dist/src/columns/remap.js.map +1 -0
  55. package/dist/src/columns/table.d.ts +184 -0
  56. package/dist/src/columns/table.d.ts.map +1 -0
  57. package/dist/src/columns/table.js +485 -0
  58. package/dist/src/columns/table.js.map +1 -0
  59. package/dist/src/constants.d.ts +34 -0
  60. package/dist/src/constants.d.ts.map +1 -0
  61. package/dist/src/constants.js +79 -0
  62. package/dist/src/constants.js.map +1 -0
  63. package/dist/src/errors.d.ts +76 -0
  64. package/dist/src/errors.d.ts.map +1 -0
  65. package/dist/src/errors.js +32 -0
  66. package/dist/src/errors.js.map +1 -0
  67. package/dist/src/ids/edge-id-index.d.ts +77 -0
  68. package/dist/src/ids/edge-id-index.d.ts.map +1 -0
  69. package/dist/src/ids/edge-id-index.js +155 -0
  70. package/dist/src/ids/edge-id-index.js.map +1 -0
  71. package/dist/src/ids/node-id-map.d.ts +280 -0
  72. package/dist/src/ids/node-id-map.d.ts.map +1 -0
  73. package/dist/src/ids/node-id-map.js +1139 -0
  74. package/dist/src/ids/node-id-map.js.map +1 -0
  75. package/dist/src/ids/string-store.d.ts +219 -0
  76. package/dist/src/ids/string-store.d.ts.map +1 -0
  77. package/dist/src/ids/string-store.js +526 -0
  78. package/dist/src/ids/string-store.js.map +1 -0
  79. package/dist/src/index.d.ts +30 -0
  80. package/dist/src/index.d.ts.map +1 -0
  81. package/dist/src/index.js +29 -0
  82. package/dist/src/index.js.map +1 -0
  83. package/dist/src/populate/from-csr.d.ts +61 -0
  84. package/dist/src/populate/from-csr.d.ts.map +1 -0
  85. package/dist/src/populate/from-csr.js +706 -0
  86. package/dist/src/populate/from-csr.js.map +1 -0
  87. package/dist/src/populate/from-edge-arrays.d.ts +51 -0
  88. package/dist/src/populate/from-edge-arrays.d.ts.map +1 -0
  89. package/dist/src/populate/from-edge-arrays.js +217 -0
  90. package/dist/src/populate/from-edge-arrays.js.map +1 -0
  91. package/dist/src/populate/from-records.d.ts +51 -0
  92. package/dist/src/populate/from-records.d.ts.map +1 -0
  93. package/dist/src/populate/from-records.js +447 -0
  94. package/dist/src/populate/from-records.js.map +1 -0
  95. package/dist/src/snapshot/derived.d.ts +211 -0
  96. package/dist/src/snapshot/derived.d.ts.map +1 -0
  97. package/dist/src/snapshot/derived.js +1254 -0
  98. package/dist/src/snapshot/derived.js.map +1 -0
  99. package/dist/src/snapshot/graph-meta.d.ts +23 -0
  100. package/dist/src/snapshot/graph-meta.d.ts.map +1 -0
  101. package/dist/src/snapshot/graph-meta.js +159 -0
  102. package/dist/src/snapshot/graph-meta.js.map +1 -0
  103. package/dist/src/snapshot/graph-snapshot.d.ts +509 -0
  104. package/dist/src/snapshot/graph-snapshot.d.ts.map +1 -0
  105. package/dist/src/snapshot/graph-snapshot.js +1247 -0
  106. package/dist/src/snapshot/graph-snapshot.js.map +1 -0
  107. package/dist/src/snapshot/hash.d.ts +90 -0
  108. package/dist/src/snapshot/hash.d.ts.map +1 -0
  109. package/dist/src/snapshot/hash.js +228 -0
  110. package/dist/src/snapshot/hash.js.map +1 -0
  111. package/dist/src/snapshot/queries.d.ts +74 -0
  112. package/dist/src/snapshot/queries.d.ts.map +1 -0
  113. package/dist/src/snapshot/queries.js +131 -0
  114. package/dist/src/snapshot/queries.js.map +1 -0
  115. package/dist/src/snapshot/validate.d.ts +192 -0
  116. package/dist/src/snapshot/validate.d.ts.map +1 -0
  117. package/dist/src/snapshot/validate.js +990 -0
  118. package/dist/src/snapshot/validate.js.map +1 -0
  119. package/dist/src/snapshot/views.d.ts +274 -0
  120. package/dist/src/snapshot/views.d.ts.map +1 -0
  121. package/dist/src/snapshot/views.js +856 -0
  122. package/dist/src/snapshot/views.js.map +1 -0
  123. package/dist/src/types/builder.d.ts +456 -0
  124. package/dist/src/types/builder.d.ts.map +1 -0
  125. package/dist/src/types/builder.js +11 -0
  126. package/dist/src/types/builder.js.map +1 -0
  127. package/dist/src/types/columns.d.ts +615 -0
  128. package/dist/src/types/columns.d.ts.map +1 -0
  129. package/dist/src/types/columns.js +14 -0
  130. package/dist/src/types/columns.js.map +1 -0
  131. package/dist/src/types/index.d.ts +14 -0
  132. package/dist/src/types/index.d.ts.map +1 -0
  133. package/dist/src/types/index.js +11 -0
  134. package/dist/src/types/index.js.map +1 -0
  135. package/dist/src/types/internal.d.ts +172 -0
  136. package/dist/src/types/internal.d.ts.map +1 -0
  137. package/dist/src/types/internal.js +13 -0
  138. package/dist/src/types/internal.js.map +1 -0
  139. package/dist/src/types/snapshot.d.ts +801 -0
  140. package/dist/src/types/snapshot.d.ts.map +1 -0
  141. package/dist/src/types/snapshot.js +14 -0
  142. package/dist/src/types/snapshot.js.map +1 -0
  143. package/dist/src/types/wire.d.ts +196 -0
  144. package/dist/src/types/wire.d.ts.map +1 -0
  145. package/dist/src/types/wire.js +8 -0
  146. package/dist/src/types/wire.js.map +1 -0
  147. package/dist/src/util/mask.d.ts +55 -0
  148. package/dist/src/util/mask.d.ts.map +1 -0
  149. package/dist/src/util/mask.js +71 -0
  150. package/dist/src/util/mask.js.map +1 -0
  151. package/dist/src/util/options.d.ts +22 -0
  152. package/dist/src/util/options.d.ts.map +1 -0
  153. package/dist/src/util/options.js +49 -0
  154. package/dist/src/util/options.js.map +1 -0
  155. package/dist/src/util/shared-buffers.d.ts +42 -0
  156. package/dist/src/util/shared-buffers.d.ts.map +1 -0
  157. package/dist/src/util/shared-buffers.js +52 -0
  158. package/dist/src/util/shared-buffers.js.map +1 -0
  159. package/dist/src/util/typed-array.d.ts +205 -0
  160. package/dist/src/util/typed-array.d.ts.map +1 -0
  161. package/dist/src/util/typed-array.js +289 -0
  162. package/dist/src/util/typed-array.js.map +1 -0
  163. package/dist/src/wire/bytes.d.ts +69 -0
  164. package/dist/src/wire/bytes.d.ts.map +1 -0
  165. package/dist/src/wire/bytes.js +361 -0
  166. package/dist/src/wire/bytes.js.map +1 -0
  167. package/dist/src/wire/carried-views.d.ts +111 -0
  168. package/dist/src/wire/carried-views.d.ts.map +1 -0
  169. package/dist/src/wire/carried-views.js +286 -0
  170. package/dist/src/wire/carried-views.js.map +1 -0
  171. package/dist/src/wire/from-wire.d.ts +144 -0
  172. package/dist/src/wire/from-wire.d.ts.map +1 -0
  173. package/dist/src/wire/from-wire.js +1425 -0
  174. package/dist/src/wire/from-wire.js.map +1 -0
  175. package/dist/src/wire/to-wire.d.ts +171 -0
  176. package/dist/src/wire/to-wire.d.ts.map +1 -0
  177. package/dist/src/wire/to-wire.js +652 -0
  178. package/dist/src/wire/to-wire.js.map +1 -0
  179. package/dist/tsconfig.build.tsbuildinfo +1 -0
  180. package/package.json +72 -7
  181. package/src/builder/arena.ts +192 -0
  182. package/src/builder/compact.ts +1811 -0
  183. package/src/builder/counting-sort.ts +506 -0
  184. package/src/builder/freeze.ts +689 -0
  185. package/src/builder/graph-builder.ts +2264 -0
  186. package/src/builder/options.ts +29 -0
  187. package/src/columns/bitmap.ts +196 -0
  188. package/src/columns/column.ts +2574 -0
  189. package/src/columns/dictionary.ts +114 -0
  190. package/src/columns/growable.ts +350 -0
  191. package/src/columns/infer.ts +285 -0
  192. package/src/columns/remap.ts +663 -0
  193. package/src/columns/table.ts +568 -0
  194. package/src/constants.ts +89 -0
  195. package/src/errors.ts +115 -0
  196. package/src/ids/edge-id-index.ts +179 -0
  197. package/src/ids/node-id-map.ts +1340 -0
  198. package/src/ids/string-store.ts +589 -0
  199. package/src/index.ts +139 -0
  200. package/src/lib-resizable-array-buffer.d.ts +34 -0
  201. package/src/populate/from-csr.ts +802 -0
  202. package/src/populate/from-edge-arrays.ts +257 -0
  203. package/src/populate/from-records.ts +536 -0
  204. package/src/snapshot/derived.ts +1486 -0
  205. package/src/snapshot/graph-meta.ts +167 -0
  206. package/src/snapshot/graph-snapshot.ts +1503 -0
  207. package/src/snapshot/hash.ts +241 -0
  208. package/src/snapshot/queries.ts +136 -0
  209. package/src/snapshot/validate.ts +1108 -0
  210. package/src/snapshot/views.ts +943 -0
  211. package/src/types/builder.ts +480 -0
  212. package/src/types/columns.ts +727 -0
  213. package/src/types/index.ts +14 -0
  214. package/src/types/internal.ts +195 -0
  215. package/src/types/snapshot.ts +886 -0
  216. package/src/types/wire.ts +207 -0
  217. package/src/util/mask.ts +89 -0
  218. package/src/util/options.ts +57 -0
  219. package/src/util/shared-buffers.ts +56 -0
  220. package/src/util/typed-array.ts +371 -0
  221. package/src/wire/bytes.ts +440 -0
  222. package/src/wire/carried-views.ts +327 -0
  223. package/src/wire/from-wire.ts +1690 -0
  224. package/src/wire/to-wire.ts +755 -0
@@ -0,0 +1,1340 @@
1
+ /**
2
+ * The immutable id-to-index bijection of a snapshot (design section 4): five storage kinds chosen by
3
+ * inspecting the ids once in O(n), SameValueZero equality, `INVALID_INDEX` on a miss, a dense-integer
4
+ * fast path with no Map at all, lazily materialised typed / decoded representations in either
5
+ * direction, and a reverse `Map` that is shared with the builder (guarded by `index < size`) or built
6
+ * lazily on the first lookup.
7
+ *
8
+ * The class exposes exactly the public surface of design section 12.2. Everything the sibling
9
+ * modules need beyond it (construction from ids, typed arrays or the wire; the typed form for the
10
+ * wire; gather and remap for derived graphs and compaction) is a module-level function that reaches
11
+ * the private storage through a module-private WeakMap, so nothing internal appears on the instance
12
+ * type.
13
+ */
14
+
15
+ import { INVALID_INDEX, MAX_COUNT } from "../constants.js";
16
+ import { GraphFormatError } from "../errors.js";
17
+ import {
18
+ type F64,
19
+ type NodeId,
20
+ type NodeIdMapContract,
21
+ type NodeIdMapKind,
22
+ type U8,
23
+ type U32,
24
+ type ValidationLevel,
25
+ } from "../types/index.js";
26
+ import { assertOneOf } from "../util/options.js";
27
+ import { claimHolder } from "../util/shared-buffers.js";
28
+ import { checkUtf8Layout, encodeUtf8Rows, hasLoneSurrogate, resolveRange, Utf8Store } from "./string-store.js";
29
+
30
+ // ============================================================ id validation
31
+
32
+ /**
33
+ * A short, plain-ASCII rendering of an id for error messages.
34
+ * @param id - the value
35
+ * @returns the number, a quoted (possibly truncated) string, or the type name
36
+ */
37
+ export function describeId(id: unknown): string {
38
+ if (typeof id === "number") {
39
+ return String(id);
40
+ }
41
+ if (typeof id === "string") {
42
+ const text = id.length > 40 ? `${id.slice(0, 37)}...` : id;
43
+ return JSON.stringify(text);
44
+ }
45
+ if (id === null) {
46
+ return "null";
47
+ }
48
+ if (typeof id === "bigint") {
49
+ return `${String(id)}n`;
50
+ }
51
+ return `a value of type ${typeof id}`;
52
+ }
53
+
54
+ /**
55
+ * Check a value as a node id and normalise it (design section 4.1): a finite number (`-0` becomes
56
+ * `0`) or a string without a lone surrogate. NaN, non-finite numbers, bigints, objects, null and
57
+ * undefined are E_INVALID_ID; a lone surrogate is E_INVALID_ID with details.reason "lone surrogate".
58
+ * @param id - the value to check
59
+ * @returns the id as stored
60
+ */
61
+ export function validateNodeId(id: unknown): NodeId {
62
+ if (typeof id === "number") {
63
+ if (!Number.isFinite(id)) {
64
+ throw new GraphFormatError("E_INVALID_ID", `invalid node id ${String(id)}: not a finite number`, {
65
+ id,
66
+ reason: "non-finite",
67
+ });
68
+ }
69
+ return id === 0 ? 0 : id;
70
+ }
71
+ if (typeof id === "string") {
72
+ if (hasLoneSurrogate(id)) {
73
+ throw new GraphFormatError("E_INVALID_ID", `invalid node id ${describeId(id)}: lone surrogate`, {
74
+ id,
75
+ reason: "lone surrogate",
76
+ });
77
+ }
78
+ return id;
79
+ }
80
+ throw new GraphFormatError("E_INVALID_ID", `invalid node id: ${describeId(id)} is not a string or a number`, {
81
+ reason: "unsupported type",
82
+ type: typeof id,
83
+ });
84
+ }
85
+
86
+ // ============================================================ storage
87
+
88
+ /**
89
+ * The private state of a NodeIdMap (design section 4.2). Which slots are non-null depends on the
90
+ * kind and on which representations have been materialised so far:
91
+ *
92
+ * - identity: nothing;
93
+ * - dense: denseValues (index -> id) and denseInverse (id -> index, INVALID_INDEX where absent);
94
+ * - numeric: decoded (the builder's array, shared) and / or numbers (F64, the wire form); map;
95
+ * - string: strings (a Utf8Store over the builder's array or over the wire buffers); map;
96
+ * - mixed: decoded (shared) and / or tags + numbers + strings (the wire form); map.
97
+ *
98
+ * `map` is null until first needed for numeric / string / mixed and always null for identity / dense.
99
+ * The object is mutable so that lazily materialised representations can be cached; the map instance
100
+ * itself never changes. Exported only because the public constructor names it; never exported from
101
+ * the package barrel.
102
+ */
103
+ interface NodeIdMapStorage {
104
+ /** The storage kind. */
105
+ readonly kind: NodeIdMapKind;
106
+ /** Number of ids. */
107
+ readonly size: number;
108
+ /** identity only: id === index + offset; 0 otherwise. */
109
+ readonly offset: number;
110
+ /** dense: the id of every index. */
111
+ readonly denseValues: U32 | null;
112
+ /** dense: index of every id in [0, maxId], INVALID_INDEX where absent. */
113
+ readonly denseInverse: U32 | null;
114
+ /** numeric / mixed: the decoded ids, shared by reference; entries at or beyond `size` are invisible. */
115
+ readonly decoded: readonly NodeId[] | null;
116
+ /** numeric: the ids as f64 (the wire form); mixed: the number rows, 0 where the row is a string. */
117
+ numbers: F64 | null;
118
+ /** mixed: 0 for a number row, 1 for a string row (the wire form). */
119
+ tags: U8 | null;
120
+ /** string / mixed: the Utf8 store (string: every row; mixed: string rows, number rows empty). */
121
+ strings: Utf8Store | null;
122
+ /** numeric / string / mixed: the reverse map; shared with the builder or built on first lookup. */
123
+ map: Map<NodeId, number> | null;
124
+ /** String(idOf(i)) -> i, built lazily by stringIndex(). */
125
+ stringIndex: Map<string, number> | null;
126
+ }
127
+
128
+ /** The typed representation of an id map, one slot per wire member (design section 4.5). */
129
+ export interface NodeIdMapTypedParts {
130
+ /** The storage kind. */
131
+ readonly kind: NodeIdMapKind;
132
+ /** Number of ids. */
133
+ readonly size: number;
134
+ /** identity only: id === index + offset; 0 otherwise. */
135
+ readonly offset: number;
136
+ /** dense: u32 ids per index; numeric: f64 ids per index; null otherwise. */
137
+ readonly values: U32 | F64 | null;
138
+ /** mixed: u8 tag per index (0 number, 1 string); null otherwise. */
139
+ readonly tags: U8 | null;
140
+ /** mixed: f64 per index (0 where string); null otherwise. */
141
+ readonly numbers: F64 | null;
142
+ /** string / mixed: rows + 1 offsets of the Utf8 store; null otherwise. */
143
+ readonly offsets: U32 | null;
144
+ /** string / mixed: the UTF-8 bytes; null otherwise. */
145
+ readonly utf8: U8 | null;
146
+ }
147
+
148
+ /** Storage behind every instance, reachable by the module-level functions but not by consumers. */
149
+ const INTERNALS = new WeakMap<NodeIdMap, NodeIdMapStorage>();
150
+
151
+ /**
152
+ * The storage of an instance.
153
+ * @param map - the id map
154
+ * @returns its private storage
155
+ */
156
+ function storageOf(map: NodeIdMap): NodeIdMapStorage {
157
+ const storage = INTERNALS.get(map);
158
+ if (storage === undefined) {
159
+ throw new GraphFormatError("E_UNSUPPORTED", "not a NodeIdMap of this package", { reason: "foreign id map" });
160
+ }
161
+ return storage;
162
+ }
163
+
164
+ /**
165
+ * A storage record with every slot null except the given ones.
166
+ * @param kind - the storage kind
167
+ * @param size - the id count
168
+ * @param slots - the non-null slots
169
+ * @returns the storage
170
+ */
171
+ function makeStorage(kind: NodeIdMapKind, size: number, slots: Partial<NodeIdMapStorage> = {}): NodeIdMapStorage {
172
+ return {
173
+ kind,
174
+ size,
175
+ offset: 0,
176
+ denseValues: null,
177
+ denseInverse: null,
178
+ decoded: null,
179
+ numbers: null,
180
+ tags: null,
181
+ strings: null,
182
+ map: null,
183
+ stringIndex: null,
184
+ ...slots,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Whether a typed array the storage still reads from was transferred away.
190
+ * @param storage - the storage
191
+ * @returns true when detached
192
+ */
193
+ function storageDetached(storage: NodeIdMapStorage): boolean {
194
+ if (storage.size === 0) {
195
+ return false;
196
+ }
197
+ return (
198
+ (storage.denseValues !== null && storage.denseValues.length === 0) ||
199
+ (storage.decoded === null && storage.numbers !== null && storage.numbers.length === 0) ||
200
+ (storage.decoded === null && storage.tags !== null && storage.tags.length === 0) ||
201
+ (storage.decoded === null && storage.strings !== null && storage.strings.detached)
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Whether an id map's typed storage was transferred away (design section 9.1), for validate().
207
+ * @param map - the id map
208
+ * @returns true when detached
209
+ */
210
+ export function idMapDetached(map: NodeIdMap): boolean {
211
+ const storage = INTERNALS.get(map);
212
+ return storage !== undefined && storageDetached(storage);
213
+ }
214
+
215
+ /**
216
+ * -0 is stored and reported as 0 (SameValueZero, design section 4.1).
217
+ * @param value - a numeric id
218
+ * @returns the id with -0 folded into 0
219
+ */
220
+ function normaliseZero(value: number): number {
221
+ return value === 0 ? 0 : value;
222
+ }
223
+
224
+ /**
225
+ * The E_DUPLICATE_ID error for two indices holding the same id.
226
+ * @param id - the id
227
+ * @param first - the lower index
228
+ * @param second - the higher index
229
+ * @returns the error
230
+ */
231
+ function duplicateIdError(id: NodeId, first: number, second: number): GraphFormatError {
232
+ return new GraphFormatError(
233
+ "E_DUPLICATE_ID",
234
+ `duplicate node id ${describeId(id)} at indices ${first} and ${second}`,
235
+ {
236
+ id,
237
+ indices: [first, second],
238
+ },
239
+ );
240
+ }
241
+
242
+ /**
243
+ * The E_INVALID_SNAPSHOT error for an id bijection failure of untrusted typed input (invariant I11).
244
+ * @param reason - what failed
245
+ * @param details - extra context
246
+ * @returns the error
247
+ */
248
+ function bijectionError(reason: string, details: Readonly<Record<string, unknown>>): GraphFormatError {
249
+ return new GraphFormatError("E_INVALID_SNAPSHOT", `id map violates invariant I11: ${reason}`, {
250
+ invariant: "I11",
251
+ reason,
252
+ ...details,
253
+ });
254
+ }
255
+
256
+ /**
257
+ * The E_BAD_SERIALIZATION error for a malformed typed id map.
258
+ * @param ref - the manifest member
259
+ * @param reason - what is wrong
260
+ * @param details - extra context
261
+ * @returns the error
262
+ */
263
+ function layoutError(ref: string, reason: string, details: Readonly<Record<string, unknown>> = {}): GraphFormatError {
264
+ return new GraphFormatError("E_BAD_SERIALIZATION", `${ref}: ${reason}`, { ref, reason, ...details });
265
+ }
266
+
267
+ /**
268
+ * Build the dense inverse array (id -> index) from the dense values, detecting duplicates.
269
+ * @param values - the id of every index, integers in [0, MAX_COUNT)
270
+ * @param maxId - the largest id
271
+ * @param onDuplicate - builds the error thrown for a repeated id
272
+ * @returns a fresh inverse of maxId + 1 entries filled with INVALID_INDEX where absent
273
+ */
274
+ function buildDenseInverse(
275
+ values: U32,
276
+ maxId: number,
277
+ onDuplicate: (id: number, first: number, second: number) => GraphFormatError,
278
+ ): U32 {
279
+ const inverse = new Uint32Array(maxId + 1).fill(INVALID_INDEX);
280
+ for (let i = 0; i < values.length; i++) {
281
+ const id = values[i];
282
+ if (inverse[id] !== INVALID_INDEX) {
283
+ throw onDuplicate(id, inverse[id], i);
284
+ }
285
+ inverse[id] = i;
286
+ }
287
+ return inverse;
288
+ }
289
+
290
+ /**
291
+ * Build the reverse map of `size` decoded ids, detecting duplicates.
292
+ * @param ids - the ids in index order
293
+ * @param size - how many entries to index
294
+ * @param onDuplicate - builds the error thrown for a repeated id
295
+ * @returns a fresh Map from id to index
296
+ */
297
+ function buildReverseMap(
298
+ ids: ArrayLike<NodeId>,
299
+ size: number,
300
+ onDuplicate: (id: NodeId, first: number, second: number) => GraphFormatError,
301
+ ): Map<NodeId, number> {
302
+ const map = new Map<NodeId, number>();
303
+ for (let i = 0; i < size; i++) {
304
+ const id = ids[i];
305
+ const seen = map.get(id);
306
+ if (seen !== undefined) {
307
+ throw onDuplicate(id, seen, i);
308
+ }
309
+ map.set(id, i);
310
+ }
311
+ return map;
312
+ }
313
+
314
+ // ============================================================ detection
315
+
316
+ /** What one O(n) pass over the ids learns (design section 4.2). */
317
+ interface IdScan {
318
+ /** Every id is a number. */
319
+ readonly allNumbers: boolean;
320
+ /** Every id is a string. */
321
+ readonly allStrings: boolean;
322
+ /** ids[i] === i + offset for every i, with an integer offset. */
323
+ readonly identity: boolean;
324
+ /** The identity offset (meaningful when identity). */
325
+ readonly offset: number;
326
+ /** Every id is an integer in [0, MAX_COUNT). */
327
+ readonly allDenseIntegers: boolean;
328
+ /** The largest id when allDenseIntegers, else -1. */
329
+ readonly maxId: number;
330
+ }
331
+
332
+ /**
333
+ * One pass over the first `size` ids that decides the storage kind. The ids are assumed valid
334
+ * (finite numbers or strings).
335
+ * @param ids - the ids in index order
336
+ * @param size - how many to inspect
337
+ * @returns the scan result
338
+ */
339
+ function scanIds(ids: ArrayLike<NodeId>, size: number): IdScan {
340
+ let allNumbers = true;
341
+ let allStrings = true;
342
+ let allDenseIntegers = true;
343
+ let maxId = -1;
344
+ let offset = 0;
345
+ let identity = true;
346
+ if (size > 0) {
347
+ const first = ids[0];
348
+ if (typeof first === "number" && Number.isSafeInteger(first)) {
349
+ offset = first;
350
+ } else {
351
+ identity = false;
352
+ }
353
+ }
354
+ for (let i = 0; i < size; i++) {
355
+ const id = ids[i];
356
+ if (typeof id === "number") {
357
+ allStrings = false;
358
+ if (identity && id !== i + offset) {
359
+ identity = false;
360
+ }
361
+ if (allDenseIntegers) {
362
+ if (Number.isInteger(id) && id >= 0 && id < MAX_COUNT) {
363
+ if (id > maxId) {
364
+ maxId = id;
365
+ }
366
+ } else {
367
+ allDenseIntegers = false;
368
+ }
369
+ }
370
+ } else {
371
+ allNumbers = false;
372
+ identity = false;
373
+ allDenseIntegers = false;
374
+ }
375
+ }
376
+ if (!allNumbers) {
377
+ maxId = -1;
378
+ }
379
+ return { allNumbers, allStrings, identity, offset, allDenseIntegers, maxId };
380
+ }
381
+
382
+ /**
383
+ * The kind a scan selects (design section 4.2): identity, else dense when the inverse array would be
384
+ * at most twice the node count, else numeric, string or mixed.
385
+ * @param scan - the scan result
386
+ * @param size - the id count
387
+ * @returns the kind
388
+ */
389
+ function kindOf(scan: IdScan, size: number): NodeIdMapKind {
390
+ if (size === 0 || scan.identity) {
391
+ return "identity";
392
+ }
393
+ if (scan.allDenseIntegers && scan.maxId + 1 <= 2 * size) {
394
+ return "dense";
395
+ }
396
+ if (scan.allNumbers) {
397
+ return "numeric";
398
+ }
399
+ if (scan.allStrings) {
400
+ return "string";
401
+ }
402
+ return "mixed";
403
+ }
404
+
405
+ /**
406
+ * Copy the first `size` ids into a fresh U32 (dense values).
407
+ * @param ids - the ids, all integers in [0, MAX_COUNT)
408
+ * @param size - how many
409
+ * @returns the values
410
+ */
411
+ function denseValuesOf(ids: ArrayLike<NodeId>, size: number): U32 {
412
+ const values = new Uint32Array(size);
413
+ for (let i = 0; i < size; i++) {
414
+ const id = ids[i];
415
+ values[i] = typeof id === "number" ? id : INVALID_INDEX;
416
+ }
417
+ return values;
418
+ }
419
+
420
+ // ============================================================ the class
421
+
422
+ /**
423
+ * Bijection between node ids and node indices for one snapshot (design section 4, invariant I11):
424
+ * `indexOf(idOf(i)) === i` for every i, `size === nodeCount`, SameValueZero equality (so `1` and
425
+ * `"1"` are distinct, `-0` is `0`, `1.5` is legal and `NaN` never occurs). Immutable. Lookups by id
426
+ * are total and return `INVALID_INDEX` on a miss (decision C9); `requireIndex` is the checked form.
427
+ *
428
+ * Instances are created by the module-level factories (`nodeIdMapFromIds`, `nodeIdMapFromF64`,
429
+ * `identityNodeIdMap`, `nodeIdMapFromTyped`) and by `gatherNodeIdMap` / `remapNodeIdMap`; the
430
+ * constructor takes a prepared storage record and is not meant to be called by consumers.
431
+ */
432
+ export class NodeIdMap implements NodeIdMapContract {
433
+ /** The storage kind (design section 4.2). */
434
+ readonly kind: NodeIdMapKind;
435
+
436
+ /** Number of ids; equals nodeCount. */
437
+ readonly size: number;
438
+
439
+ /** identity only: id === index + offset (0 or 1 in practice); 0 for every other kind. */
440
+ readonly offset: number;
441
+
442
+ /** The private storage; also registered in INTERNALS for the module-level functions. */
443
+ private readonly storage: NodeIdMapStorage;
444
+
445
+ /**
446
+ * Wrap a prepared storage record. Sibling modules use the factories of this module instead.
447
+ * @param storage - the storage; the kind's slots must be filled as documented on NodeIdMapStorage
448
+ * @internal
449
+ */
450
+ constructor(storage: NodeIdMapStorage) {
451
+ this.kind = storage.kind;
452
+ this.size = storage.size;
453
+ this.offset = storage.kind === "identity" ? storage.offset : 0;
454
+ this.storage = storage;
455
+ INTERNALS.set(this, storage);
456
+ Object.freeze(this);
457
+ }
458
+
459
+ /**
460
+ * The id of a node index.
461
+ * @param index - the node index
462
+ * @returns the id; E_INDEX_RANGE when index is not an integer in [0, size)
463
+ */
464
+ idOf(index: number): NodeId {
465
+ if (!(index >= 0 && index < this.size) || !Number.isInteger(index)) {
466
+ throw new GraphFormatError("E_INDEX_RANGE", `node index ${index} out of range (size ${this.size})`, {
467
+ index,
468
+ size: this.size,
469
+ });
470
+ }
471
+ return this.idAt(index);
472
+ }
473
+
474
+ /**
475
+ * Total lookup by id: SameValueZero, never coerces, so `indexOf("1")` misses a map of numbers.
476
+ * @param id - the node id
477
+ * @returns the node index, or INVALID_INDEX when absent
478
+ */
479
+ indexOf(id: NodeId): number {
480
+ const { storage } = this;
481
+ switch (storage.kind) {
482
+ case "identity": {
483
+ if (typeof id !== "number") {
484
+ return INVALID_INDEX;
485
+ }
486
+ const index = id - storage.offset;
487
+ if (!Number.isInteger(index) || index < 0 || index >= storage.size) {
488
+ return INVALID_INDEX;
489
+ }
490
+ return index === 0 ? 0 : index;
491
+ }
492
+ case "dense": {
493
+ const inverse = storage.denseInverse as U32;
494
+ if (typeof id !== "number" || !Number.isInteger(id) || id < 0 || id >= inverse.length) {
495
+ return INVALID_INDEX;
496
+ }
497
+ return inverse[id];
498
+ }
499
+ case "numeric":
500
+ case "string":
501
+ case "mixed": {
502
+ const index = this.reverseMap().get(id);
503
+ return index === undefined || index >= storage.size ? INVALID_INDEX : index;
504
+ }
505
+ default:
506
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(storage.kind)}`, {
507
+ kind: storage.kind,
508
+ });
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Whether an id is present.
514
+ * @param id - the node id
515
+ * @returns true when present
516
+ */
517
+ has(id: NodeId): boolean {
518
+ return this.indexOf(id) !== INVALID_INDEX;
519
+ }
520
+
521
+ /**
522
+ * Checked lookup by id, for algorithms that today throw "node not found".
523
+ * @param id - the node id
524
+ * @returns the node index; E_UNKNOWN_NODE when absent
525
+ */
526
+ requireIndex(id: NodeId): number {
527
+ const index = this.indexOf(id);
528
+ if (index === INVALID_INDEX) {
529
+ throw new GraphFormatError("E_UNKNOWN_NODE", `unknown node id ${describeId(id)}`, { id });
530
+ }
531
+ return index;
532
+ }
533
+
534
+ /**
535
+ * Bulk lookup.
536
+ * @param ids - the ids to resolve
537
+ * @param onMissing - "invalid" (default) writes INVALID_INDEX for a miss; "throw" raises E_UNKNOWN_NODE
538
+ * @returns a fresh U32 of indices in input order
539
+ */
540
+ indicesOf(ids: Iterable<NodeId>, onMissing: "invalid" | "throw" = "invalid"): U32 {
541
+ assertOneOf("onMissing", onMissing, ["invalid", "throw"] as const);
542
+ const lookup =
543
+ onMissing === "throw"
544
+ ? (id: NodeId): number => this.requireIndex(id)
545
+ : (id: NodeId): number => this.indexOf(id);
546
+ if (Array.isArray(ids)) {
547
+ const list = ids as readonly NodeId[];
548
+ const out = new Uint32Array(list.length);
549
+ for (let i = 0; i < list.length; i++) {
550
+ out[i] = lookup(list[i]);
551
+ }
552
+ return out;
553
+ }
554
+ let out = new Uint32Array(16);
555
+ let count = 0;
556
+ for (const id of ids) {
557
+ if (count === out.length) {
558
+ const grown = new Uint32Array(out.length * 2);
559
+ grown.set(out);
560
+ out = grown;
561
+ }
562
+ out[count++] = lookup(id);
563
+ }
564
+ return out.slice(0, count);
565
+ }
566
+
567
+ /**
568
+ * Bulk decode of a range of ids in one pass. Bounds follow Array.prototype.slice (negative values
569
+ * count from the end, out-of-range values are clamped).
570
+ * @param start - first index (default 0)
571
+ * @param end - one past the last index (default size)
572
+ * @returns a fresh array of the ids in index order
573
+ */
574
+ idsSlice(start = 0, end = this.size): NodeId[] {
575
+ const [from, to] = resolveRange(start, end, this.size);
576
+ const { storage } = this;
577
+ this.assertAttached();
578
+ switch (storage.kind) {
579
+ case "identity": {
580
+ const out = new Array<NodeId>(to - from);
581
+ for (let i = from; i < to; i++) {
582
+ out[i - from] = i + storage.offset;
583
+ }
584
+ return out;
585
+ }
586
+ case "dense":
587
+ return Array.from((storage.denseValues as U32).subarray(from, to));
588
+ case "numeric":
589
+ if (storage.decoded !== null) {
590
+ return storage.decoded.slice(from, to);
591
+ }
592
+ return Array.from((storage.numbers as F64).subarray(from, to), normaliseZero);
593
+ case "string":
594
+ return (storage.strings as Utf8Store).slice(from, to);
595
+ case "mixed": {
596
+ if (storage.decoded !== null) {
597
+ return storage.decoded.slice(from, to);
598
+ }
599
+ const tags = storage.tags as U8;
600
+ const numbers = storage.numbers as F64;
601
+ const strings = storage.strings as Utf8Store;
602
+ const out = new Array<NodeId>(to - from);
603
+ for (let i = from; i < to; i++) {
604
+ out[i - from] = tags[i] === 0 ? normaliseZero(numbers[i]) : strings.at(i);
605
+ }
606
+ return out;
607
+ }
608
+ default:
609
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(storage.kind)}`, {
610
+ kind: storage.kind,
611
+ });
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Every id in index order.
617
+ * @returns a fresh array
618
+ */
619
+ toArray(): NodeId[] {
620
+ return this.idsSlice(0, this.size);
621
+ }
622
+
623
+ /**
624
+ * Iterate the ids in index order.
625
+ * @yields each id, decoding lazily
626
+ */
627
+ *[Symbol.iterator](): IterableIterator<NodeId> {
628
+ for (let i = 0; i < this.size; i++) {
629
+ yield this.idAt(i);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Key an index-aligned result vector by id (boundary helper, decision C11).
635
+ * @param values - one value per node index
636
+ * @returns a Map from id to value, in index order
637
+ */
638
+ toMap<T>(values: ArrayLike<T>): Map<NodeId, T> {
639
+ const out = new Map<NodeId, T>();
640
+ for (let i = 0; i < this.size; i++) {
641
+ out.set(this.idAt(i), values[i]);
642
+ }
643
+ return out;
644
+ }
645
+
646
+ /**
647
+ * Key an index-aligned result vector by String(id), for legacy Map<string, T> result shapes. When
648
+ * two ids share a string form (1 and "1") the HIGHER index wins, exactly as the legacy
649
+ * algorithms' assignment in node order does (design section 14.2).
650
+ * @param values - one value per node index
651
+ * @returns a Map from String(id) to value
652
+ */
653
+ toStringMap<T>(values: ArrayLike<T>): Map<string, T> {
654
+ const out = new Map<string, T>();
655
+ for (let i = 0; i < this.size; i++) {
656
+ out.set(String(this.idAt(i)), values[i]);
657
+ }
658
+ return out;
659
+ }
660
+
661
+ /**
662
+ * Key an index-aligned result vector by String(id), for legacy record result shapes only. The
663
+ * record has a null prototype so ids such as "__proto__" are ordinary keys; when two ids share a
664
+ * string form the HIGHER index wins (the legacy assignment order, design section 14.2).
665
+ * @param values - one value per node index
666
+ * @returns a record from String(id) to value
667
+ */
668
+ toRecord<T>(values: ArrayLike<T>): Record<string, T> {
669
+ const out = Object.create(null) as Record<string, T>;
670
+ for (let i = 0; i < this.size; i++) {
671
+ out[String(this.idAt(i))] = values[i];
672
+ }
673
+ return out;
674
+ }
675
+
676
+ /**
677
+ * Iterate [id, value] pairs of an index-aligned result vector.
678
+ * @param values - one value per node index
679
+ * @yields the pairs in index order
680
+ */
681
+ *entries<T>(values: ArrayLike<T>): IterableIterator<[NodeId, T]> {
682
+ for (let i = 0; i < this.size; i++) {
683
+ yield [this.idAt(i), values[i]];
684
+ }
685
+ }
686
+
687
+ /**
688
+ * String(idOf(i)) -> i, built lazily once, for legacy string-typed id parameters. When two ids
689
+ * share a string form (always one number and one string, since ids are distinct) the STRING id
690
+ * wins: a legacy string parameter "1" addressed the node whose id is the string "1".
691
+ * @returns the read-only string index
692
+ */
693
+ stringIndex(): ReadonlyMap<string, number> {
694
+ const { storage } = this;
695
+ if (storage.stringIndex === null) {
696
+ const index = new Map<string, number>();
697
+ for (let i = 0; i < storage.size; i++) {
698
+ const id = this.idAt(i);
699
+ const key = String(id);
700
+ if (typeof id === "string" || !index.has(key)) {
701
+ index.set(key, i);
702
+ }
703
+ }
704
+ storage.stringIndex = index;
705
+ }
706
+ return storage.stringIndex;
707
+ }
708
+
709
+ /**
710
+ * Bytes of typed storage currently materialised; excludes the reverse Map and JS strings.
711
+ * @returns the byte count
712
+ */
713
+ byteLength(): number {
714
+ const { storage } = this;
715
+ let bytes = 0;
716
+ if (storage.denseValues !== null) {
717
+ bytes += storage.denseValues.byteLength;
718
+ }
719
+ if (storage.denseInverse !== null) {
720
+ bytes += storage.denseInverse.byteLength;
721
+ }
722
+ if (storage.numbers !== null) {
723
+ bytes += storage.numbers.byteLength;
724
+ }
725
+ if (storage.tags !== null) {
726
+ bytes += storage.tags.byteLength;
727
+ }
728
+ if (storage.strings !== null) {
729
+ bytes += storage.strings.byteLength();
730
+ }
731
+ return bytes;
732
+ }
733
+
734
+ /**
735
+ * Unchecked id of an in-range index.
736
+ * @param index - a node index in [0, size)
737
+ * @returns the id
738
+ */
739
+ private idAt(index: number): NodeId {
740
+ const { storage } = this;
741
+ this.assertAttached();
742
+ switch (storage.kind) {
743
+ case "identity":
744
+ return index + storage.offset;
745
+ case "dense":
746
+ return (storage.denseValues as U32)[index];
747
+ case "numeric":
748
+ return storage.decoded !== null
749
+ ? storage.decoded[index]
750
+ : normaliseZero((storage.numbers as F64)[index]);
751
+ case "string":
752
+ return (storage.strings as Utf8Store).at(index);
753
+ case "mixed":
754
+ if (storage.decoded !== null) {
755
+ return storage.decoded[index];
756
+ }
757
+ return (storage.tags as U8)[index] === 0
758
+ ? normaliseZero((storage.numbers as F64)[index])
759
+ : (storage.strings as Utf8Store).at(index);
760
+ default:
761
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(storage.kind)}`, {
762
+ kind: storage.kind,
763
+ });
764
+ }
765
+ }
766
+
767
+ /**
768
+ * E_DETACHED when a typed array the map reads from was transferred away (design sections 9.1
769
+ * and 11.3): derived from the array state (a transferred buffer leaves zero-length views).
770
+ */
771
+ private assertAttached(): void {
772
+ if (storageDetached(this.storage)) {
773
+ throw new GraphFormatError("E_DETACHED", "the id map's storage was transferred away", {
774
+ kind: this.storage.kind,
775
+ });
776
+ }
777
+ }
778
+
779
+ /**
780
+ * The reverse map of a numeric / string / mixed map, built on first use (one pass over the ids,
781
+ * after a bulk decode of a wire-decoded Utf8 store) when it was not shared by the builder.
782
+ * @returns the map from id to index
783
+ */
784
+ private reverseMap(): Map<NodeId, number> {
785
+ const { storage } = this;
786
+ if (storage.map === null) {
787
+ // the bulk decode below reads the Utf8 store: E_DETACHED, never a TypeError, after a transfer
788
+ this.assertAttached();
789
+ if (storage.decoded === null && storage.strings !== null) {
790
+ storage.strings.materialiseDecoded();
791
+ }
792
+ const map = new Map<NodeId, number>();
793
+ for (let i = 0; i < storage.size; i++) {
794
+ map.set(this.idAt(i), i);
795
+ }
796
+ storage.map = map;
797
+ }
798
+ return storage.map;
799
+ }
800
+ }
801
+
802
+ // ============================================================ factories
803
+
804
+ /**
805
+ * An identity map: id === index + offset, no storage at all (design section 4.2). What
806
+ * `fromEdgeArrays` without ids and `contract()` produce; offset 1 covers 1-based files.
807
+ * @param size - the node count
808
+ * @param offset - the integer added to every index; default 0
809
+ * @returns the map
810
+ */
811
+ export function identityNodeIdMap(size: number, offset = 0): NodeIdMap {
812
+ if (!Number.isInteger(size) || size < 0 || size > MAX_COUNT) {
813
+ throw new GraphFormatError("E_TOO_LARGE", `node count ${size} is not an integer in [0, MAX_COUNT]`, {
814
+ count: size,
815
+ max: MAX_COUNT,
816
+ });
817
+ }
818
+ if (!Number.isSafeInteger(offset)) {
819
+ throw new GraphFormatError("E_INVALID_ID", `identity offset ${offset} is not a safe integer`, {
820
+ offset,
821
+ reason: "non-integer offset",
822
+ });
823
+ }
824
+ return new NodeIdMap(makeStorage("identity", size, { offset }));
825
+ }
826
+
827
+ /** Options of nodeIdMapFromIds. */
828
+ interface FromIdsOptions {
829
+ /**
830
+ * The builder's reverse map, shared by reference and guarded by `index < size` (decision C17). When
831
+ * given, the ids are trusted (already validated and distinct) and `validate` is ignored.
832
+ */
833
+ readonly map?: Map<NodeId, number> | undefined;
834
+ /**
835
+ * Check every id (E_INVALID_ID) and their distinctness (E_DUPLICATE_ID), copying the array and
836
+ * normalising -0; default true. false trusts the caller (derived graphs) and shares the array.
837
+ */
838
+ readonly validate?: boolean | undefined;
839
+ }
840
+
841
+ /**
842
+ * Build a map from decoded ids in index order, choosing the kind in one O(n) pass (design section
843
+ * 4.2, freeze step 9). With `options.map` (the builder's Map) the array and the Map are shared by
844
+ * reference and nothing is validated; identity and dense maps drop the Map. Without it the ids are
845
+ * validated and copied unless `validate` is false.
846
+ * @param ids - the ids; only entries below `size` are read
847
+ * @param size - the node count; default ids.length
848
+ * @param options - the shared map and validation switch
849
+ * @returns the map
850
+ */
851
+ export function nodeIdMapFromIds(
852
+ ids: readonly NodeId[],
853
+ size: number = ids.length,
854
+ options: FromIdsOptions = {},
855
+ ): NodeIdMap {
856
+ if (!Number.isInteger(size) || size < 0 || size > MAX_COUNT) {
857
+ throw new GraphFormatError("E_TOO_LARGE", `node count ${size} is not an integer in [0, MAX_COUNT]`, {
858
+ count: size,
859
+ max: MAX_COUNT,
860
+ });
861
+ }
862
+ const trusted = options.map !== undefined || options.validate === false;
863
+ let source: readonly NodeId[] = ids;
864
+ if (!trusted) {
865
+ const copy = new Array<NodeId>(size);
866
+ for (let i = 0; i < size; i++) {
867
+ copy[i] = validateNodeId(ids[i]);
868
+ }
869
+ source = copy;
870
+ }
871
+ const scan = scanIds(source, size);
872
+ const kind = kindOf(scan, size);
873
+ switch (kind) {
874
+ case "identity":
875
+ return new NodeIdMap(makeStorage("identity", size, { offset: size === 0 ? 0 : scan.offset }));
876
+ case "dense": {
877
+ const denseValues = denseValuesOf(source, size);
878
+ const denseInverse = buildDenseInverse(denseValues, scan.maxId, duplicateIdError);
879
+ return new NodeIdMap(makeStorage("dense", size, { denseValues, denseInverse }));
880
+ }
881
+ case "numeric":
882
+ case "mixed": {
883
+ const map = options.map ?? (trusted ? null : buildReverseMap(source, size, duplicateIdError));
884
+ return new NodeIdMap(makeStorage(kind, size, { decoded: source, map }));
885
+ }
886
+ case "string": {
887
+ const map = options.map ?? (trusted ? null : buildReverseMap(source, size, duplicateIdError));
888
+ const strings = Utf8Store.fromStrings(source as readonly string[], size);
889
+ return new NodeIdMap(makeStorage("string", size, { strings, map }));
890
+ }
891
+ default:
892
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(kind)}`, { kind });
893
+ }
894
+ }
895
+
896
+ /** Options of nodeIdMapFromF64. */
897
+ interface FromF64Options {
898
+ /**
899
+ * Check that every value is finite (E_INVALID_ID) and that the values are distinct (E_DUPLICATE_ID);
900
+ * default true. The array is adopted by reference either way; -0 entries are rewritten to 0 in a
901
+ * copy when validating.
902
+ */
903
+ readonly validate?: boolean | undefined;
904
+ }
905
+
906
+ /**
907
+ * Build a map from numeric ids in a Float64Array (the `ids: F64` form of `fromEdgeArrays` /
908
+ * `fromCsr`), choosing identity, dense or numeric in one pass. A numeric map adopts the array as its
909
+ * typed representation.
910
+ * @param values - one id per index
911
+ * @param options - the validation switch
912
+ * @returns the map
913
+ */
914
+ export function nodeIdMapFromF64(values: F64, options: FromF64Options = {}): NodeIdMap {
915
+ const size = values.length;
916
+ if (size > MAX_COUNT) {
917
+ throw new GraphFormatError("E_TOO_LARGE", `node count ${size} exceeds MAX_COUNT`, {
918
+ count: size,
919
+ max: MAX_COUNT,
920
+ });
921
+ }
922
+ const validate = options.validate !== false;
923
+ let numbers = values;
924
+ if (validate) {
925
+ let negativeZero = false;
926
+ for (let i = 0; i < size; i++) {
927
+ const v = values[i];
928
+ if (!Number.isFinite(v)) {
929
+ throw new GraphFormatError(
930
+ "E_INVALID_ID",
931
+ `invalid node id ${String(v)} at index ${i}: not a finite number`,
932
+ {
933
+ id: v,
934
+ index: i,
935
+ reason: "non-finite",
936
+ },
937
+ );
938
+ }
939
+ if (Object.is(v, -0)) {
940
+ negativeZero = true;
941
+ }
942
+ }
943
+ if (negativeZero) {
944
+ numbers = values.slice();
945
+ for (let i = 0; i < size; i++) {
946
+ if (numbers[i] === 0) {
947
+ numbers[i] = 0;
948
+ }
949
+ }
950
+ }
951
+ }
952
+ const scan = scanIds(numbers, size);
953
+ const kind = kindOf(scan, size);
954
+ switch (kind) {
955
+ case "identity":
956
+ return new NodeIdMap(makeStorage("identity", size, { offset: size === 0 ? 0 : scan.offset }));
957
+ case "dense": {
958
+ const denseValues = denseValuesOf(numbers, size);
959
+ const denseInverse = buildDenseInverse(denseValues, scan.maxId, duplicateIdError);
960
+ return new NodeIdMap(makeStorage("dense", size, { denseValues, denseInverse }));
961
+ }
962
+ case "numeric": {
963
+ const map = validate ? buildReverseMap(numbers, size, duplicateIdError) : null;
964
+ return new NodeIdMap(makeStorage("numeric", size, { numbers, map }));
965
+ }
966
+ default:
967
+ throw new GraphFormatError("E_UNSUPPORTED", `unexpected id map kind ${kind} for numeric ids`, { kind });
968
+ }
969
+ }
970
+
971
+ /**
972
+ * The distinct backing buffers of an id map's materialised typed storage (dense values and inverse,
973
+ * numbers, tags, an encoded Utf8 store), for the owner count of design section 9.1.
974
+ * @param map - the id map
975
+ * @returns the buffers, each once
976
+ */
977
+ export function idMapBuffers(map: NodeIdMap): ArrayBuffer[] {
978
+ const storage = INTERNALS.get(map);
979
+ const out = new Set<ArrayBuffer>();
980
+ if (storage === undefined) {
981
+ // a foreign object standing in for a map (tests wrap one): nothing typed to claim
982
+ return [];
983
+ }
984
+ for (const view of [storage.denseValues, storage.denseInverse, storage.numbers, storage.tags]) {
985
+ if (view !== null) {
986
+ out.add(view.buffer);
987
+ }
988
+ }
989
+ if (storage.strings !== null && storage.strings.encoded) {
990
+ out.add(storage.strings.offsets.buffer);
991
+ out.add(storage.strings.utf8.buffer);
992
+ }
993
+ return [...out];
994
+ }
995
+
996
+ /**
997
+ * Build a map from its typed (wire) representation (design sections 4.5 and 9.5), adopting the arrays
998
+ * by reference. "structure" checks lengths, tag values, the dense bound and the Utf8 layout
999
+ * (E_BAD_SERIALIZATION with details.ref); "full" additionally checks invariant I11: finite numbers,
1000
+ * well-formed UTF-8 (a fatal decode) and distinct ids (E_INVALID_SNAPSHOT with details.invariant
1001
+ * "I11"), building the reverse map eagerly. A dense duplicate is always detected because the inverse
1002
+ * array is rebuilt on load. An unknown kind is E_UNSUPPORTED with details.kind.
1003
+ * @param parts - the typed representation
1004
+ * @param validate - the validation level; default "structure"
1005
+ * @returns the map
1006
+ */
1007
+ export function nodeIdMapFromTyped(parts: NodeIdMapTypedParts, validate: ValidationLevel = "structure"): NodeIdMap {
1008
+ const { kind, size } = parts;
1009
+ const structure = validate !== "none";
1010
+ const full = validate === "full";
1011
+ if (structure && (!Number.isInteger(size) || size < 0 || size > MAX_COUNT)) {
1012
+ throw layoutError("ids.size", `size ${size} is not an integer in [0, MAX_COUNT]`, { found: size });
1013
+ }
1014
+ switch (kind) {
1015
+ case "identity": {
1016
+ // O(1) at every level: every id index + offset (the largest is offset + size - 1) must be a
1017
+ // safe integer (I11)
1018
+ if (!Number.isSafeInteger(parts.offset) || (size > 0 && !Number.isSafeInteger(parts.offset + size - 1))) {
1019
+ const reason = `offset ${parts.offset} with ${size} ids leaves the safe-integer range`;
1020
+ throw full
1021
+ ? bijectionError(reason, { found: parts.offset, size })
1022
+ : layoutError("ids.offset", reason, { found: parts.offset, size });
1023
+ }
1024
+ return new NodeIdMap(makeStorage("identity", size, { offset: normaliseZero(parts.offset) }));
1025
+ }
1026
+ case "dense": {
1027
+ const { values } = parts;
1028
+ if (!(values instanceof Uint32Array)) {
1029
+ throw layoutError("ids.values", "a dense id map needs u32 values");
1030
+ }
1031
+ if (structure && values.length !== size) {
1032
+ throw layoutError("ids.values", `expected ${size} values, found ${values.length}`, {
1033
+ expected: size,
1034
+ found: values.length,
1035
+ });
1036
+ }
1037
+ let maxId = -1;
1038
+ for (let i = 0; i < values.length; i++) {
1039
+ if (values[i] > maxId) {
1040
+ maxId = values[i];
1041
+ }
1042
+ }
1043
+ // the dense bounds are checked at every level: the scan is already paid, and the inverse
1044
+ // array below is allocated over [0, maxId], which an unchecked id could make enormous
1045
+ if (maxId >= MAX_COUNT) {
1046
+ throw layoutError("ids.values", `dense id ${maxId} is not below MAX_COUNT`, { found: maxId });
1047
+ }
1048
+ if (maxId + 1 > 2 * Math.max(size, values.length)) {
1049
+ throw layoutError("ids.values", `dense bound violated: max id ${maxId} with ${size} nodes`, {
1050
+ maxId,
1051
+ size,
1052
+ });
1053
+ }
1054
+ const denseInverse = buildDenseInverse(values, maxId, (id, first, second) =>
1055
+ bijectionError(`duplicate id ${id}`, { id, indices: [first, second] }),
1056
+ );
1057
+ return new NodeIdMap(makeStorage("dense", size, { denseValues: values, denseInverse }));
1058
+ }
1059
+ case "numeric": {
1060
+ const numbers = parts.values;
1061
+ if (!(numbers instanceof Float64Array)) {
1062
+ throw layoutError("ids.values", "a numeric id map needs f64 values");
1063
+ }
1064
+ if (structure && numbers.length !== size) {
1065
+ throw layoutError("ids.values", `expected ${size} values, found ${numbers.length}`, {
1066
+ expected: size,
1067
+ found: numbers.length,
1068
+ });
1069
+ }
1070
+ let map: Map<NodeId, number> | null = null;
1071
+ if (full) {
1072
+ for (let i = 0; i < size; i++) {
1073
+ if (!Number.isFinite(numbers[i])) {
1074
+ throw bijectionError(`non-finite id at index ${i}`, { index: i, id: numbers[i] });
1075
+ }
1076
+ }
1077
+ map = buildReverseMap(numbers, size, (id, first, second) =>
1078
+ bijectionError(`duplicate id ${describeId(id)}`, { id, indices: [first, second] }),
1079
+ );
1080
+ }
1081
+ return new NodeIdMap(makeStorage("numeric", size, { numbers, map }));
1082
+ }
1083
+ case "string": {
1084
+ const { offsets, utf8 } = parts;
1085
+ if (offsets === null || utf8 === null) {
1086
+ throw layoutError("ids.offsets", "a string id map needs offsets and utf8");
1087
+ }
1088
+ if (structure) {
1089
+ checkUtf8Layout(offsets, utf8, size, "ids.offsets");
1090
+ }
1091
+ const strings = Utf8Store.fromEncoded(offsets, utf8);
1092
+ let map: Map<NodeId, number> | null = null;
1093
+ if (full) {
1094
+ const bad = strings.firstMalformedRow();
1095
+ if (bad !== -1) {
1096
+ throw bijectionError(`malformed UTF-8 in id at index ${bad}`, { index: bad });
1097
+ }
1098
+ map = buildReverseMap(strings.decodeAll(), size, (id, first, second) =>
1099
+ bijectionError(`duplicate id ${describeId(id)}`, { id, indices: [first, second] }),
1100
+ );
1101
+ }
1102
+ return new NodeIdMap(makeStorage("string", size, { strings, map }));
1103
+ }
1104
+ case "mixed": {
1105
+ const { tags, numbers, offsets, utf8 } = parts;
1106
+ if (tags === null || numbers === null || offsets === null || utf8 === null) {
1107
+ throw layoutError("ids.tags", "a mixed id map needs tags, numbers, offsets and utf8");
1108
+ }
1109
+ if (structure) {
1110
+ if (tags.length !== size) {
1111
+ throw layoutError("ids.tags", `expected ${size} tags, found ${tags.length}`, {
1112
+ expected: size,
1113
+ found: tags.length,
1114
+ });
1115
+ }
1116
+ if (numbers.length !== size) {
1117
+ throw layoutError("ids.numbers", `expected ${size} numbers, found ${numbers.length}`, {
1118
+ expected: size,
1119
+ found: numbers.length,
1120
+ });
1121
+ }
1122
+ for (let i = 0; i < size; i++) {
1123
+ if (tags[i] > 1) {
1124
+ throw layoutError("ids.tags", `tag ${tags[i]} at index ${i} is not 0 or 1`, {
1125
+ index: i,
1126
+ found: tags[i],
1127
+ });
1128
+ }
1129
+ }
1130
+ checkUtf8Layout(offsets, utf8, size, "ids.offsets");
1131
+ }
1132
+ const strings = Utf8Store.fromEncoded(offsets, utf8);
1133
+ let map: Map<NodeId, number> | null = null;
1134
+ if (full) {
1135
+ const bad = strings.firstMalformedRow();
1136
+ if (bad !== -1) {
1137
+ throw bijectionError(`malformed UTF-8 in id at index ${bad}`, { index: bad });
1138
+ }
1139
+ const decoded = new Array<NodeId>(size);
1140
+ for (let i = 0; i < size; i++) {
1141
+ if (tags[i] === 0) {
1142
+ if (!Number.isFinite(numbers[i])) {
1143
+ throw bijectionError(`non-finite id at index ${i}`, { index: i, id: numbers[i] });
1144
+ }
1145
+ decoded[i] = numbers[i];
1146
+ } else {
1147
+ decoded[i] = strings.at(i);
1148
+ }
1149
+ }
1150
+ map = buildReverseMap(decoded, size, (id, first, second) =>
1151
+ bijectionError(`duplicate id ${describeId(id)}`, { id, indices: [first, second] }),
1152
+ );
1153
+ }
1154
+ return new NodeIdMap(makeStorage("mixed", size, { tags, numbers, strings, map }));
1155
+ }
1156
+ default:
1157
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(kind)}`, { kind });
1158
+ }
1159
+ }
1160
+
1161
+ // ============================================================ typed form, gather, remap
1162
+
1163
+ /**
1164
+ * The typed (wire) representation of a map (design section 4.5), materialising and caching whatever
1165
+ * the map only held in decoded form: the F64 of a numeric map, the Utf8 store of a string map, the
1166
+ * tags + numbers + Utf8 store of a mixed map. The dense inverse and the reverse Map are never part
1167
+ * of it. Repeated calls return the same arrays.
1168
+ * @param map - the id map
1169
+ * @returns the typed parts; every slot the kind does not use is null
1170
+ */
1171
+ export function nodeIdMapToTyped(map: NodeIdMap): NodeIdMapTypedParts {
1172
+ const storage = storageOf(map);
1173
+ const { kind, size } = storage;
1174
+ switch (kind) {
1175
+ case "identity":
1176
+ return {
1177
+ kind,
1178
+ size,
1179
+ offset: storage.offset,
1180
+ values: null,
1181
+ tags: null,
1182
+ numbers: null,
1183
+ offsets: null,
1184
+ utf8: null,
1185
+ };
1186
+ case "dense":
1187
+ return {
1188
+ kind,
1189
+ size,
1190
+ offset: 0,
1191
+ values: storage.denseValues,
1192
+ tags: null,
1193
+ numbers: null,
1194
+ offsets: null,
1195
+ utf8: null,
1196
+ };
1197
+ case "numeric": {
1198
+ if (storage.numbers === null) {
1199
+ const decoded = storage.decoded as readonly NodeId[];
1200
+ const numbers = new Float64Array(size);
1201
+ for (let i = 0; i < size; i++) {
1202
+ const id = decoded[i];
1203
+ if (typeof id !== "number") {
1204
+ throw new GraphFormatError(
1205
+ "E_INVALID_ID",
1206
+ `numeric id map holds a ${typeof id} at index ${i}`,
1207
+ {
1208
+ index: i,
1209
+ found: typeof id,
1210
+ },
1211
+ );
1212
+ }
1213
+ numbers[i] = id;
1214
+ }
1215
+ storage.numbers = numbers;
1216
+ claimHolder(numbers.buffer);
1217
+ }
1218
+ return {
1219
+ kind,
1220
+ size,
1221
+ offset: 0,
1222
+ values: storage.numbers,
1223
+ tags: null,
1224
+ numbers: null,
1225
+ offsets: null,
1226
+ utf8: null,
1227
+ };
1228
+ }
1229
+ case "string": {
1230
+ const strings = storage.strings as Utf8Store;
1231
+ return {
1232
+ kind,
1233
+ size,
1234
+ offset: 0,
1235
+ values: null,
1236
+ tags: null,
1237
+ numbers: null,
1238
+ offsets: strings.offsets,
1239
+ utf8: strings.utf8,
1240
+ };
1241
+ }
1242
+ case "mixed": {
1243
+ if (storage.tags === null || storage.numbers === null || storage.strings === null) {
1244
+ const decoded = storage.decoded as readonly NodeId[];
1245
+ const tags = new Uint8Array(size);
1246
+ const numbers = new Float64Array(size);
1247
+ for (let i = 0; i < size; i++) {
1248
+ const id = decoded[i];
1249
+ if (typeof id === "number") {
1250
+ numbers[i] = id;
1251
+ } else {
1252
+ tags[i] = 1;
1253
+ }
1254
+ }
1255
+ const { offsets, utf8 } = encodeUtf8Rows(decoded, size);
1256
+ storage.tags = tags;
1257
+ storage.numbers = numbers;
1258
+ storage.strings = Utf8Store.fromEncoded(offsets, utf8);
1259
+ // the map is the first holder of the buffers it materialises (design section 9.1)
1260
+ for (const buffer of [tags.buffer, numbers.buffer, offsets.buffer, utf8.buffer]) {
1261
+ claimHolder(buffer);
1262
+ }
1263
+ }
1264
+ return {
1265
+ kind,
1266
+ size,
1267
+ offset: 0,
1268
+ values: null,
1269
+ tags: storage.tags,
1270
+ numbers: storage.numbers,
1271
+ offsets: storage.strings.offsets,
1272
+ utf8: storage.strings.utf8,
1273
+ };
1274
+ }
1275
+ default:
1276
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id map kind ${String(kind)}`, { kind });
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * The map of a gathered node space: `out.idOf(i) === map.idOf(indexMap[i])` (design section 7.3:
1282
+ * inducedSubgraph, relabel). The kind is re-detected from the gathered ids, so an identity map
1283
+ * gathered through a prefix stays identity and otherwise becomes dense or numeric per the rules of
1284
+ * design section 4.2. The caller guarantees indexMap has no repeats (a permutation or a selection),
1285
+ * so the result is a bijection without re-validation.
1286
+ * @param map - the source map
1287
+ * @param indexMap - new index -> source index; E_INDEX_RANGE when an entry is out of range
1288
+ * @returns the gathered map
1289
+ */
1290
+ export function gatherNodeIdMap(map: NodeIdMap, indexMap: U32): NodeIdMap {
1291
+ const n = indexMap.length;
1292
+ const ids = new Array<NodeId>(n);
1293
+ for (let i = 0; i < n; i++) {
1294
+ ids[i] = map.idOf(indexMap[i]);
1295
+ }
1296
+ return nodeIdMapFromIds(ids, n, { validate: false });
1297
+ }
1298
+
1299
+ /**
1300
+ * The map of a compacted node space: `out.idOf(remap[old]) === map.idOf(old)` for every old index
1301
+ * whose entry is not INVALID_INDEX (design sections 4.4 and 6.3 step 1). Every new index must be hit
1302
+ * exactly once.
1303
+ * @param map - the source map
1304
+ * @param remap - old index -> new index or INVALID_INDEX; length === map.size
1305
+ * @param newSize - the compacted node count
1306
+ * @returns the remapped map; E_INDEX_RANGE when remap is not a surjection onto [0, newSize)
1307
+ */
1308
+ export function remapNodeIdMap(map: NodeIdMap, remap: U32, newSize: number): NodeIdMap {
1309
+ if (remap.length !== map.size) {
1310
+ throw new GraphFormatError("E_INDEX_RANGE", `remap has ${remap.length} entries for ${map.size} ids`, {
1311
+ expected: map.size,
1312
+ found: remap.length,
1313
+ });
1314
+ }
1315
+ const ids = new Array<NodeId>(newSize);
1316
+ const seen = new Uint8Array(newSize);
1317
+ let filled = 0;
1318
+ for (let old = 0; old < remap.length; old++) {
1319
+ const target = remap[old];
1320
+ if (target === INVALID_INDEX) {
1321
+ continue;
1322
+ }
1323
+ if (target >= newSize || seen[target] === 1) {
1324
+ throw new GraphFormatError("E_INDEX_RANGE", `remap entry ${target} at ${old} is out of range or repeated`, {
1325
+ index: target,
1326
+ size: newSize,
1327
+ });
1328
+ }
1329
+ seen[target] = 1;
1330
+ ids[target] = map.idOf(old);
1331
+ filled++;
1332
+ }
1333
+ if (filled !== newSize) {
1334
+ throw new GraphFormatError("E_INDEX_RANGE", `remap fills ${filled} of ${newSize} new indices`, {
1335
+ filled,
1336
+ size: newSize,
1337
+ });
1338
+ }
1339
+ return nodeIdMapFromIds(ids, newSize, { validate: false });
1340
+ }