@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,2264 @@
1
+ /**
2
+ * GraphBuilder: the only mutable object in the package and the producer of snapshots (design
3
+ * section 6). A long-lived, structure-of-arrays accumulator (compact.ts) that can be frozen
4
+ * repeatedly through the pipeline of freeze.ts. Node indices are assigned in first-seen order and
5
+ * edge indices in addEdge order (invariant I14); removal tombstones in O(degree) through the two
6
+ * incidence lists; a compacting freeze renumbers the builder's own indices to equal the new
7
+ * snapshot's (decision C7) and reports the remaps; freeze() never shares core arrays or columns with
8
+ * the snapshot (I18) while the id `Map` and `ids` array are shared by design (section 4.2).
9
+ *
10
+ * Every public method and getter throws E_BUILDER_DISPOSED after dispose(); every throw leaves the
11
+ * builder in a consistent state (design section 11.1): the failing operation is not applied (the
12
+ * record, composition and expansion methods validate everything they will write before the first
13
+ * mutation), and a freeze that fails commits nothing.
14
+ */
15
+
16
+ import { assertJsonValue, dtypeOfArray, metaToDecl, resolveColumnMeta } from "../columns/column.js";
17
+ import { assertWellFormedString } from "../columns/dictionary.js";
18
+ import { type InferredDtype, wideningRank } from "../columns/infer.js";
19
+ import { INVALID_INDEX, MAX_COUNT } from "../constants.js";
20
+ import { GraphFormatError } from "../errors.js";
21
+ import { describeId, validateNodeId } from "../ids/node-id-map.js";
22
+ import { detachString } from "../ids/string-store.js";
23
+ import { EMPTY_GRAPH_META, resolveGraphMeta } from "../snapshot/graph-meta.js";
24
+ import {
25
+ type BuilderOptionsPatch,
26
+ type Column,
27
+ type ColumnDecl,
28
+ type ColumnDeclPatch,
29
+ type ColumnDomain,
30
+ type ColumnHandle,
31
+ type ColumnMeta,
32
+ type Dtype,
33
+ type DuplicatePolicy,
34
+ type ExtensionHandle,
35
+ type F32,
36
+ type F64,
37
+ type FreezeOptions,
38
+ type FreezeReport,
39
+ type GraphBuilderContract,
40
+ type GraphBuilderOptions,
41
+ type GraphMeta,
42
+ type GraphMetaPatch,
43
+ type GraphSnapshot,
44
+ type NodeId,
45
+ type ResolvedBuilderOptions,
46
+ type SetDirectedOptions,
47
+ type TypedArrayData,
48
+ type U32,
49
+ } from "../types/index.js";
50
+ import {
51
+ createStagingColumn,
52
+ type ExtensionStaging,
53
+ type IndexMaps,
54
+ inferInitialDtype,
55
+ inferredEquivalent,
56
+ Staging,
57
+ StagingColumn,
58
+ } from "./compact.js";
59
+ import { type FreezeContext, type GraphValue, runFreeze, type Widening } from "./freeze.js";
60
+ import { assertDuplicatePolicy } from "./options.js";
61
+
62
+ // ============================================================ option handling
63
+
64
+ const SELF_LOOP_POLICIES: ReadonlySet<string> = new Set(["keep", "drop", "error"]);
65
+ const WEIGHT_DTYPES: ReadonlySet<string> = new Set(["f32", "f64"]);
66
+ /** The name of the bool edge column written by an in-place expansion (design section 3.6). */
67
+ const DIRECTED_COLUMN = "graphty.directed";
68
+
69
+ /** The name of the u32 edge column pairing the halves of an expanded edge (design section 3.6). */
70
+ const PAIR_COLUMN = "graphty.pair";
71
+
72
+ /** The builder's option fields other than the current direction, resolved once at construction. */
73
+ interface FixedOptions {
74
+ readonly weighted: boolean | "auto";
75
+ readonly weightDtype: "f32" | "f64";
76
+ readonly duplicateEdges: DuplicatePolicy;
77
+ readonly selfLoops: "keep" | "drop" | "error";
78
+ readonly addMissingNodes: boolean;
79
+ readonly expectedNodes: number | null;
80
+ readonly expectedEdges: number | null;
81
+ }
82
+
83
+ /**
84
+ * The E_UNSUPPORTED error for an option value outside its documented set.
85
+ * @param field - the option name
86
+ * @param found - the value
87
+ * @returns the error
88
+ */
89
+ function optionError(field: string, found: unknown): GraphFormatError {
90
+ return new GraphFormatError("E_UNSUPPORTED", `builder option ${field} has an unsupported value ${String(found)}`, {
91
+ field,
92
+ found,
93
+ reason: "unsupported option",
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Resolve the constructor options (design section 12.2) with their defaults, checking every enum.
99
+ * @param options - the constructor options
100
+ * @returns the fixed options and the initial direction
101
+ */
102
+ function resolveOptions(options: GraphBuilderOptions): { readonly fixed: FixedOptions; readonly directed: boolean } {
103
+ if (typeof options !== "object" || options === null) {
104
+ throw optionError("options", options);
105
+ }
106
+ if (typeof options.directed !== "boolean") {
107
+ throw optionError("directed", options.directed);
108
+ }
109
+ const weighted = options.weighted ?? "auto";
110
+ if (weighted !== "auto" && typeof weighted !== "boolean") {
111
+ throw optionError("weighted", weighted);
112
+ }
113
+ const weightDtype = options.weightDtype ?? "f32";
114
+ if (!WEIGHT_DTYPES.has(weightDtype)) {
115
+ throw optionError("weightDtype", weightDtype);
116
+ }
117
+ const duplicateEdges = assertDuplicatePolicy("duplicateEdges", options.duplicateEdges ?? "keep", optionError);
118
+ const selfLoops = options.selfLoops ?? "keep";
119
+ if (!SELF_LOOP_POLICIES.has(selfLoops)) {
120
+ throw optionError("selfLoops", selfLoops);
121
+ }
122
+ const addMissingNodes = options.addMissingNodes ?? true;
123
+ if (typeof addMissingNodes !== "boolean") {
124
+ throw optionError("addMissingNodes", addMissingNodes);
125
+ }
126
+ const hint = (field: "expectedNodes" | "expectedEdges"): number | null => {
127
+ const value = options[field];
128
+ if (value === undefined) {
129
+ return null;
130
+ }
131
+ if (!Number.isInteger(value) || value < 0) {
132
+ throw optionError(field, value);
133
+ }
134
+ return value;
135
+ };
136
+ return {
137
+ fixed: {
138
+ weighted,
139
+ weightDtype,
140
+ duplicateEdges,
141
+ selfLoops,
142
+ addMissingNodes,
143
+ expectedNodes: hint("expectedNodes"),
144
+ expectedEdges: hint("expectedEdges"),
145
+ },
146
+ directed: options.directed,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Drop the explicitly undefined fields of a patch so it can be spread over defaults.
152
+ * @param patch - the patch
153
+ * @returns the defined fields
154
+ */
155
+ function definedFields(patch: BuilderOptionsPatch): Partial<GraphBuilderOptions> {
156
+ const out: Partial<GraphBuilderOptions> = {};
157
+ for (const key of Object.keys(patch) as (keyof GraphBuilderOptions)[]) {
158
+ const value = patch[key];
159
+ if (value !== undefined) {
160
+ (out as Record<string, unknown>)[key] = value;
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Whether two column declarations describe the same storage (the "same declaration" rule of design
168
+ * section 11.3 for a repeated declare*Column).
169
+ * @param a - one resolved metadata
170
+ * @param b - the other
171
+ * @returns true when dtype, components and the list child agree
172
+ */
173
+ function sameShape(a: ColumnMeta, b: ColumnMeta): boolean {
174
+ return (
175
+ a.dtype === b.dtype &&
176
+ a.components === b.components &&
177
+ a.itemDtype === b.itemDtype &&
178
+ a.itemComponents === b.itemComponents
179
+ );
180
+ }
181
+
182
+ /** The name index of one staging column array (see GraphBuilder.columnIndex). */
183
+ interface ColumnNameIndex {
184
+ /** The array the map describes. */
185
+ readonly columns: readonly StagingColumn[];
186
+ /** Column name -> index in `columns`. */
187
+ readonly map: Map<string, number>;
188
+ }
189
+
190
+ // ============================================================ the class
191
+
192
+ /**
193
+ * The mutable accumulator that produces frozen snapshots (design sections 6 and 12.2). Construct
194
+ * with `{ directed }` (required) and the policies of GraphBuilderOptions; push nodes and edges by id
195
+ * or by index; declare and write attribute columns; call `freeze()` as often as needed. Implements
196
+ * GraphSink, the subset importers program against (design section 8.3).
197
+ */
198
+ export class GraphBuilder implements GraphBuilderContract {
199
+ private readonly fixed: FixedOptions;
200
+
201
+ private directedValue: boolean;
202
+
203
+ private locked = false;
204
+
205
+ private optionsCache: ResolvedBuilderOptions | null = null;
206
+
207
+ private staging: Staging;
208
+
209
+ /** Column name -> index of the node staging columns; rebuilt when the column array is replaced. */
210
+ private nodeNames: ColumnNameIndex | null = null;
211
+
212
+ /** Column name -> index of the edge staging columns; rebuilt when the column array is replaced. */
213
+ private edgeNames: ColumnNameIndex | null = null;
214
+
215
+ private graphValues = new Map<string, GraphValue>();
216
+
217
+ private metaValue: GraphMeta = EMPTY_GRAPH_META;
218
+
219
+ private widenings: Widening[] = [];
220
+
221
+ private mutations = 0;
222
+
223
+ private dirtyFlag = true;
224
+
225
+ private disposed = false;
226
+
227
+ /**
228
+ * Create an empty builder.
229
+ * @param options - the options; `directed` is required
230
+ */
231
+ constructor(options: GraphBuilderOptions) {
232
+ const resolved = resolveOptions(options);
233
+ this.fixed = resolved.fixed;
234
+ this.directedValue = resolved.directed;
235
+ this.staging = this.freshStaging();
236
+ }
237
+
238
+ /**
239
+ * Seed a builder from a snapshot in O(n + m) (design section 6.6): ids, edges in logical order
240
+ * with their declared orientation, weights (through the role-"weight" column when present, so an
241
+ * f64 shadow and the explicit / omitted record survive), columns, extension tables and meta;
242
+ * indices are preserved. The staging precision follows an f64 shadow unless the patch says
243
+ * otherwise.
244
+ * @param snapshot - the snapshot to continue
245
+ * @param options - option overrides; `directed` defaults to the snapshot's
246
+ * @returns the builder
247
+ */
248
+ static from(snapshot: GraphSnapshot, options?: BuilderOptionsPatch): GraphBuilder {
249
+ const patch = definedFields(options ?? {});
250
+ const shadow = snapshot.edges.byRole("weight");
251
+ const weightDtype = patch.weightDtype ?? (shadow !== null && shadow.dtype === "f64" ? "f64" : undefined);
252
+ // a declared weight array is never dropped (design section 3.7): a weighted snapshot whose
253
+ // edges all omitted the weight seeds a `weighted: true` builder unless the patch says otherwise
254
+ const weighted = patch.weighted ?? (snapshot.flags.weighted ? true : undefined);
255
+ const builder = new GraphBuilder({
256
+ directed: snapshot.directed,
257
+ expectedNodes: snapshot.nodeCount,
258
+ expectedEdges: snapshot.edgeCount,
259
+ ...patch,
260
+ ...(weightDtype === undefined ? {} : { weightDtype }),
261
+ ...(weighted === undefined ? {} : { weighted }),
262
+ });
263
+ builder.addGraph(snapshot, { onDuplicateNode: "error" });
264
+ builder.setMeta(snapshot.meta);
265
+ return builder;
266
+ }
267
+
268
+ // ---------------------------------------------------------------- options and state
269
+
270
+ /**
271
+ * The resolved options; `directed` is the current value.
272
+ * @returns the options
273
+ */
274
+ get options(): ResolvedBuilderOptions {
275
+ this.check();
276
+ this.optionsCache ??= Object.freeze({ directed: this.directedValue, ...this.fixed });
277
+ return this.optionsCache;
278
+ }
279
+
280
+ /**
281
+ * The current direction.
282
+ * @returns true when directed
283
+ */
284
+ get directed(): boolean {
285
+ this.check();
286
+ return this.directedValue;
287
+ }
288
+
289
+ /**
290
+ * Whether lockDirected() was called.
291
+ * @returns true when locked
292
+ */
293
+ get directedLocked(): boolean {
294
+ this.check();
295
+ return this.locked;
296
+ }
297
+
298
+ /**
299
+ * Live node count.
300
+ * @returns the count
301
+ */
302
+ get nodeCount(): number {
303
+ this.check();
304
+ return this.staging.liveNodeCount;
305
+ }
306
+
307
+ /**
308
+ * Live edge count (exact, from the incidence lists).
309
+ * @returns the count
310
+ */
311
+ get edgeCount(): number {
312
+ this.check();
313
+ return this.staging.liveEdgeCount;
314
+ }
315
+
316
+ /**
317
+ * Next node index to be assigned.
318
+ * @returns the bound
319
+ */
320
+ get nodeBound(): number {
321
+ this.check();
322
+ return this.staging.nodeBound;
323
+ }
324
+
325
+ /**
326
+ * Next logical edge index to be assigned.
327
+ * @returns the bound
328
+ */
329
+ get edgeBound(): number {
330
+ this.check();
331
+ return this.staging.edgeBound;
332
+ }
333
+
334
+ /**
335
+ * Increments on every topology or weight mutation; column writes and freeze() do not count.
336
+ * @returns the count
337
+ */
338
+ get mutationCount(): number {
339
+ this.check();
340
+ return this.mutations;
341
+ }
342
+
343
+ /**
344
+ * Whether the builder was mutated since the last freeze() (true for a builder never frozen).
345
+ * @returns true when a fresh freeze would differ from the last one
346
+ */
347
+ get dirty(): boolean {
348
+ this.check();
349
+ return this.dirtyFlag;
350
+ }
351
+
352
+ /**
353
+ * Change the direction (design section 6.6): a no-op when unchanged; free while no live edge
354
+ * exists; with live edges only undirected -> directed with `expand`, which appends a mirror edge
355
+ * for every live edge and writes the graphty.directed / graphty.pair columns. E_DIRECTED when
356
+ * locked or refused.
357
+ * @param directed - the new direction
358
+ * @param options - `expand` for the in-place expansion
359
+ */
360
+ setDirected(directed: boolean, options?: SetDirectedOptions): void {
361
+ this.check();
362
+ if (directed === this.directedValue) {
363
+ return;
364
+ }
365
+ if (this.locked) {
366
+ throw new GraphFormatError("E_DIRECTED", "the builder's direction is locked", {
367
+ reason: "locked",
368
+ directed: this.directedValue,
369
+ });
370
+ }
371
+ if (this.staging.liveEdgeCount === 0) {
372
+ this.directedValue = directed;
373
+ this.optionsCache = null;
374
+ this.mutated();
375
+ return;
376
+ }
377
+ if (!directed) {
378
+ throw new GraphFormatError("E_DIRECTED", "a builder with edges cannot become undirected", {
379
+ reason: "edges present",
380
+ edgeCount: this.staging.liveEdgeCount,
381
+ });
382
+ }
383
+ if (options?.expand !== true) {
384
+ throw new GraphFormatError(
385
+ "E_DIRECTED",
386
+ "an undirected builder with edges becomes directed only with { expand: true }",
387
+ { reason: "expand required", edgeCount: this.staging.liveEdgeCount },
388
+ );
389
+ }
390
+ this.expandToDirected();
391
+ }
392
+
393
+ /** Fix the direction: every later changing setDirected() throws E_DIRECTED. */
394
+ lockDirected(): void {
395
+ this.check();
396
+ this.locked = true;
397
+ }
398
+
399
+ // ---------------------------------------------------------------- nodes
400
+
401
+ /**
402
+ * Add a node, or return the index of the live node with this id; a tombstoned id is revived at
403
+ * its old index (design section 6.6).
404
+ * @param id - the node id; E_INVALID_ID when illegal
405
+ * @returns the node index
406
+ */
407
+ addNode(id: NodeId): number {
408
+ this.check();
409
+ return this.addValidatedNode(validateNodeId(id));
410
+ }
411
+
412
+ /**
413
+ * Add many nodes, writing every id's index (new or existing) into `out`.
414
+ * @param ids - the ids
415
+ * @param out - receives the indices; allocated when omitted; E_COLUMN_LENGTH when too short
416
+ * @returns `out`
417
+ */
418
+ addNodes(ids: Iterable<NodeId>, out?: U32): U32 {
419
+ this.check();
420
+ const list = Array.isArray(ids) ? (ids as readonly NodeId[]) : [...ids];
421
+ const validated = list.map((id) => validateNodeId(id));
422
+ if (out !== undefined && out.length < validated.length) {
423
+ throw new GraphFormatError("E_COLUMN_LENGTH", `out has ${out.length} entries for ${validated.length} ids`, {
424
+ expected: validated.length,
425
+ found: out.length,
426
+ });
427
+ }
428
+ const target = out ?? new Uint32Array(validated.length);
429
+ for (let i = 0; i < validated.length; i++) {
430
+ target[i] = this.addValidatedNode(validated[i]);
431
+ }
432
+ return target;
433
+ }
434
+
435
+ /**
436
+ * Append `count` nodes whose ids are their own indices, never touching the id Map while every
437
+ * node is anonymous (design section 6.6).
438
+ * @param count - how many; E_TOO_LARGE beyond MAX_COUNT; E_DUPLICATE_ID when an index is already an id
439
+ * @returns the first new index
440
+ */
441
+ addAnonymousNodes(count: number): number {
442
+ this.check();
443
+ if (!Number.isInteger(count) || count < 0) {
444
+ throw new GraphFormatError("E_INDEX_RANGE", `node count ${count} is not a non-negative integer`, {
445
+ count,
446
+ });
447
+ }
448
+ const { staging } = this;
449
+ const first = staging.nodeBound;
450
+ this.checkNodeLimit(count);
451
+ if (staging.idToIndex !== null) {
452
+ for (let i = first; i < first + count; i++) {
453
+ if (staging.idToIndex.has(i)) {
454
+ throw new GraphFormatError(
455
+ "E_DUPLICATE_ID",
456
+ `node id ${i} exists; anonymous node ${i} cannot use it`,
457
+ {
458
+ id: i,
459
+ index: staging.idToIndex.get(i),
460
+ },
461
+ );
462
+ }
463
+ }
464
+ }
465
+ for (let i = 0; i < count; i++) {
466
+ staging.pushNode(null);
467
+ }
468
+ if (count > 0) {
469
+ this.mutated();
470
+ }
471
+ return first;
472
+ }
473
+
474
+ /**
475
+ * Whether a live node has this id (total: an illegal id is simply absent).
476
+ * @param id - the id
477
+ * @returns true when present and alive
478
+ */
479
+ hasNode(id: NodeId): boolean {
480
+ this.check();
481
+ return this.lookup(id) !== INVALID_INDEX;
482
+ }
483
+
484
+ /**
485
+ * Total lookup of a live node.
486
+ * @param id - the id
487
+ * @returns the index, or INVALID_INDEX
488
+ */
489
+ indexOf(id: NodeId): number {
490
+ this.check();
491
+ return this.lookup(id);
492
+ }
493
+
494
+ /**
495
+ * The id of a live node index.
496
+ * @param index - the index; E_INDEX_RANGE when out of range or tombstoned
497
+ * @returns the id
498
+ */
499
+ idOf(index: number): NodeId {
500
+ this.check();
501
+ const { staging } = this;
502
+ if (!this.isLiveNode(index)) {
503
+ throw new GraphFormatError("E_INDEX_RANGE", `node index ${index} is out of range or removed`, {
504
+ index,
505
+ bound: staging.nodeBound,
506
+ });
507
+ }
508
+ return staging.ids === null ? index : staging.ids[index];
509
+ }
510
+
511
+ /**
512
+ * Tombstone a node and every live incident edge (O(degree)); bumps mutationCount.
513
+ * @param id - the id; E_UNKNOWN_NODE when absent or already removed
514
+ * @returns the removed live incident edge indices, ascending
515
+ */
516
+ removeNode(id: NodeId): U32 {
517
+ this.check();
518
+ const index = this.lookup(id);
519
+ if (index === INVALID_INDEX) {
520
+ throw new GraphFormatError("E_UNKNOWN_NODE", `unknown node id ${describeId(id)}`, { id });
521
+ }
522
+ return this.removeLiveNode(index);
523
+ }
524
+
525
+ /**
526
+ * Tombstone a node by index and every live incident edge.
527
+ * @param index - the index; E_UNKNOWN_NODE (details.index) when out of range or removed
528
+ * @returns the removed live incident edge indices, ascending
529
+ */
530
+ removeNodeByIndex(index: number): U32 {
531
+ this.check();
532
+ if (!this.isLiveNode(index)) {
533
+ throw new GraphFormatError("E_UNKNOWN_NODE", `node index ${index} is out of range or removed`, { index });
534
+ }
535
+ return this.removeLiveNode(index);
536
+ }
537
+
538
+ /**
539
+ * Grow staging capacity ahead of a bulk push.
540
+ * @param nodes - the node count to fit
541
+ * @param edges - the edge count to fit
542
+ */
543
+ reserve(nodes?: number, edges?: number): void {
544
+ this.check();
545
+ const check = (field: string, value: number | undefined): number => {
546
+ if (value === undefined) {
547
+ return 0;
548
+ }
549
+ if (!Number.isInteger(value) || value < 0 || value > MAX_COUNT) {
550
+ throw new GraphFormatError(
551
+ "E_TOO_LARGE",
552
+ `reserve(${field}) ${value} is not an integer in [0, MAX_COUNT]`,
553
+ {
554
+ field,
555
+ count: value,
556
+ max: MAX_COUNT,
557
+ },
558
+ );
559
+ }
560
+ return value;
561
+ };
562
+ this.staging.reserve(check("nodes", nodes), check("edges", edges));
563
+ }
564
+
565
+ // ---------------------------------------------------------------- edges
566
+
567
+ /**
568
+ * Add a logical edge by id; unknown endpoints are created when addMissingNodes is set (a
569
+ * tombstoned endpoint is revived), else E_UNKNOWN_NODE. A NaN weight is E_INVALID_WEIGHT.
570
+ * @param source - source id
571
+ * @param target - target id
572
+ * @param weight - the weight; 1 when omitted
573
+ * @returns the logical edge index
574
+ */
575
+ addEdge(source: NodeId, target: NodeId, weight?: number): number {
576
+ this.check();
577
+ const w = this.checkWeight(weight, undefined);
578
+ const u = this.resolveEndpoint(validateNodeId(source));
579
+ const v = this.resolveEndpoint(validateNodeId(target));
580
+ return this.pushEdge(u, v, w);
581
+ }
582
+
583
+ /**
584
+ * Add a logical edge by node index.
585
+ * @param u - source index; E_UNKNOWN_NODE (details.index) when out of range or removed
586
+ * @param v - target index
587
+ * @param weight - the weight; 1 when omitted
588
+ * @returns the logical edge index
589
+ */
590
+ addEdgeByIndex(u: number, v: number, weight?: number): number {
591
+ this.check();
592
+ const w = this.checkWeight(weight, undefined);
593
+ this.checkLiveNode(u);
594
+ this.checkLiveNode(v);
595
+ return this.pushEdge(u, v, w);
596
+ }
597
+
598
+ /**
599
+ * Bulk add edges by node index (the index-space path used with addAnonymousNodes). Everything is
600
+ * validated before the first edge is pushed.
601
+ * @param src - source indices
602
+ * @param dst - target indices; E_COLUMN_LENGTH when the lengths differ
603
+ * @param weights - per-edge weights; 1 when omitted; E_INVALID_WEIGHT for NaN
604
+ * @returns the first new logical edge index
605
+ */
606
+ addEdges(src: U32, dst: U32, weights?: F32 | F64): number {
607
+ this.check();
608
+ const count = src.length;
609
+ if (dst.length !== count || (weights !== undefined && weights.length !== count)) {
610
+ throw new GraphFormatError(
611
+ "E_COLUMN_LENGTH",
612
+ `addEdges: src has ${count} entries, dst ${dst.length}, weights ${weights === undefined ? "none" : weights.length}`,
613
+ { src: count, dst: dst.length, weights: weights?.length ?? null },
614
+ );
615
+ }
616
+ for (let i = 0; i < count; i++) {
617
+ this.checkLiveNode(src[i]);
618
+ this.checkLiveNode(dst[i]);
619
+ }
620
+ let explicit: F32 | F64 | null = null;
621
+ if (weights !== undefined) {
622
+ for (let i = 0; i < count; i++) {
623
+ this.checkWeight(weights[i], i);
624
+ }
625
+ explicit = this.fixed.weighted === false ? null : weights;
626
+ }
627
+ this.checkEdgeLimit(count, this.arcsOf(src, dst, count));
628
+ const { staging } = this;
629
+ staging.reserve(0, staging.edgeBound + count);
630
+ const first = staging.pushEdges(src, dst, explicit);
631
+ if (count > 0) {
632
+ this.mutated();
633
+ }
634
+ return first;
635
+ }
636
+
637
+ /**
638
+ * Bulk add edges by id. Every id and weight is validated before anything is added.
639
+ * @param src - source ids
640
+ * @param dst - target ids; E_COLUMN_LENGTH when the lengths differ
641
+ * @param weights - per-edge weights; 1 when omitted
642
+ * @returns the first new logical edge index
643
+ */
644
+ addEdgesByIds(src: ArrayLike<NodeId>, dst: ArrayLike<NodeId>, weights?: ArrayLike<number>): number {
645
+ this.check();
646
+ const count = src.length;
647
+ if (dst.length !== count || (weights !== undefined && weights.length !== count)) {
648
+ throw new GraphFormatError(
649
+ "E_COLUMN_LENGTH",
650
+ `addEdgesByIds: src has ${count} entries, dst ${dst.length}, weights ${weights === undefined ? "none" : weights.length}`,
651
+ { src: count, dst: dst.length, weights: weights?.length ?? null },
652
+ );
653
+ }
654
+ const sources = new Array<NodeId>(count);
655
+ const targets = new Array<NodeId>(count);
656
+ const checked = new Array<number | undefined>(count);
657
+ for (let i = 0; i < count; i++) {
658
+ sources[i] = validateNodeId(src[i]);
659
+ targets[i] = validateNodeId(dst[i]);
660
+ checked[i] = this.checkWeight(weights === undefined ? undefined : weights[i], i);
661
+ }
662
+ if (!this.fixed.addMissingNodes) {
663
+ for (let i = 0; i < count; i++) {
664
+ this.requireLive(sources[i]);
665
+ this.requireLive(targets[i]);
666
+ }
667
+ }
668
+ this.checkEdgeLimit(count, this.directedValue ? count : 2 * count);
669
+ const first = this.staging.edgeBound;
670
+ for (let i = 0; i < count; i++) {
671
+ const u = this.resolveEndpoint(sources[i]);
672
+ const v = this.resolveEndpoint(targets[i]);
673
+ this.staging.pushEdge(u, v, checked[i]);
674
+ }
675
+ if (count > 0) {
676
+ this.mutated();
677
+ }
678
+ return first;
679
+ }
680
+
681
+ /**
682
+ * Tombstone an edge (O(1)); bumps mutationCount when something was removed.
683
+ * @param edge - the logical edge index
684
+ * @returns true when a live edge was removed
685
+ */
686
+ removeEdge(edge: number): boolean {
687
+ this.check();
688
+ if (!this.isLiveEdge(edge)) {
689
+ return false;
690
+ }
691
+ this.staging.killEdge(edge);
692
+ this.mutated();
693
+ return true;
694
+ }
695
+
696
+ /**
697
+ * Whether an edge index is live.
698
+ * @param edge - the logical edge index
699
+ * @returns true when live
700
+ */
701
+ hasEdge(edge: number): boolean {
702
+ this.check();
703
+ return this.isLiveEdge(edge);
704
+ }
705
+
706
+ /**
707
+ * Read a live edge's endpoints back.
708
+ * @param edge - the logical edge index; E_INDEX_RANGE when out of range or removed
709
+ * @returns [source index, target index] in declared orientation
710
+ */
711
+ edgeEndpoints(edge: number): readonly [source: number, target: number] {
712
+ this.check();
713
+ this.checkEdge(edge);
714
+ return [this.staging.src.get(edge), this.staging.dst.get(edge)];
715
+ }
716
+
717
+ /**
718
+ * Read a live edge's weight back.
719
+ * @param edge - the logical edge index; E_INDEX_RANGE when out of range or removed
720
+ * @returns the weight (1 when unweighted)
721
+ */
722
+ edgeWeight(edge: number): number {
723
+ this.check();
724
+ this.checkEdge(edge);
725
+ const { weight } = this.staging;
726
+ return weight === null ? 1 : weight.get(edge);
727
+ }
728
+
729
+ /**
730
+ * Set the weight of a live edge, allocating the weight array on first use and marking the weight
731
+ * explicit; bumps mutationCount.
732
+ * @param edge - the logical edge index; E_INDEX_RANGE when out of range or removed
733
+ * @param weight - the weight; E_INVALID_WEIGHT for NaN
734
+ */
735
+ setEdgeWeight(edge: number, weight: number): void {
736
+ this.check();
737
+ this.checkEdge(edge);
738
+ const w = this.checkWeight(weight, undefined);
739
+ const { staging } = this;
740
+ if (w !== undefined) {
741
+ staging.ensureWeights().set(edge, w);
742
+ staging.trackWeight(edge, true);
743
+ }
744
+ this.mutated();
745
+ }
746
+
747
+ /**
748
+ * Live edges leaving a node, from the out-list (O(degree)). On an undirected builder every
749
+ * incident edge leaves the node (invariant I7, the snapshot's row holds both orientations), so
750
+ * both lists are walked and a self-loop is listed once.
751
+ * @param index - the node index; E_INDEX_RANGE when out of range
752
+ * @returns a fresh ascending array of live edge indices
753
+ */
754
+ outEdgesOf(index: number): U32 {
755
+ this.check();
756
+ this.checkNodeIndex(index);
757
+ return this.directedValue ? this.staging.outEdges(index) : this.staging.incidentEdges(index);
758
+ }
759
+
760
+ /**
761
+ * Live edges entering a node, from the in-list (O(degree)); on an undirected builder the same
762
+ * set as outEdgesOf (the alias rule of inDegree === outDegree).
763
+ * @param index - the node index; E_INDEX_RANGE when out of range
764
+ * @returns a fresh ascending array of live edge indices
765
+ */
766
+ inEdgesOf(index: number): U32 {
767
+ this.check();
768
+ this.checkNodeIndex(index);
769
+ return this.directedValue ? this.staging.inEdges(index) : this.staging.incidentEdges(index);
770
+ }
771
+
772
+ /**
773
+ * Live edges u -> v (undirected: either orientation), from the incidence lists.
774
+ * @param u - source index; E_INDEX_RANGE when out of range
775
+ * @param v - target index; E_INDEX_RANGE when out of range
776
+ * @returns a fresh ascending array of live edge indices
777
+ */
778
+ findEdges(u: number, v: number): U32 {
779
+ this.check();
780
+ this.checkNodeIndex(u);
781
+ this.checkNodeIndex(v);
782
+ const { staging } = this;
783
+ const found: number[] = [];
784
+ for (let e = staging.firstOut.get(u); e !== INVALID_INDEX; e = staging.nextOut.get(e)) {
785
+ if (staging.edgeAlive.get(e) && staging.dst.get(e) === v) {
786
+ found.push(e);
787
+ }
788
+ }
789
+ if (!this.directedValue && u !== v) {
790
+ for (let e = staging.firstIn.get(u); e !== INVALID_INDEX; e = staging.nextIn.get(e)) {
791
+ if (staging.edgeAlive.get(e) && staging.src.get(e) === v) {
792
+ found.push(e);
793
+ }
794
+ }
795
+ }
796
+ return Uint32Array.from(found).sort();
797
+ }
798
+
799
+ // ---------------------------------------------------------------- attributes
800
+
801
+ /**
802
+ * Declare a node column; the same shape again returns the existing handle, a different shape is
803
+ * E_COLUMN_EXISTS, a taken role E_DUPLICATE_ROLE.
804
+ * @param decl - the declaration
805
+ * @returns the column handle
806
+ */
807
+ declareNodeColumn(decl: ColumnDecl): ColumnHandle {
808
+ this.check();
809
+ return this.declare("node", decl);
810
+ }
811
+
812
+ /**
813
+ * Declare an edge column; the same shape again returns the existing handle, a different shape is
814
+ * E_COLUMN_EXISTS, a taken role E_DUPLICATE_ROLE.
815
+ * @param decl - the declaration
816
+ * @returns the column handle
817
+ */
818
+ declareEdgeColumn(decl: ColumnDecl): ColumnHandle {
819
+ this.check();
820
+ return this.declare("edge", decl);
821
+ }
822
+
823
+ /**
824
+ * Look up a node column handle.
825
+ * @param name - the column name
826
+ * @returns the handle, or INVALID_INDEX when absent
827
+ */
828
+ nodeColumn(name: string): ColumnHandle {
829
+ this.check();
830
+ return this.handleOf(this.staging.nodeColumns, name);
831
+ }
832
+
833
+ /**
834
+ * Look up an edge column handle.
835
+ * @param name - the column name
836
+ * @returns the handle, or INVALID_INDEX when absent
837
+ */
838
+ edgeColumn(name: string): ColumnHandle {
839
+ this.check();
840
+ return this.handleOf(this.staging.edgeColumns, name);
841
+ }
842
+
843
+ /**
844
+ * Set one node cell; a string column name auto-declares an inferred column (design section 5.1)
845
+ * on the first set value, and an unset value for an undeclared name is a no-op.
846
+ * @param column - the handle (E_UNKNOWN_COLUMN when stale) or name
847
+ * @param index - the node index; E_INDEX_RANGE when out of range
848
+ * @param value - the value; undefined / null unset the row
849
+ */
850
+ setNodeValue(column: ColumnHandle | string, index: number, value: unknown): void {
851
+ this.check();
852
+ this.checkNodeIndex(index);
853
+ this.writeCell("node", column, index, value);
854
+ }
855
+
856
+ /**
857
+ * Set one edge cell; a string column name auto-declares an inferred column.
858
+ * @param column - the handle (E_UNKNOWN_COLUMN when stale) or name
859
+ * @param edge - the logical edge index; E_INDEX_RANGE when out of range
860
+ * @param value - the value; undefined / null unset the row
861
+ */
862
+ setEdgeValue(column: ColumnHandle | string, edge: number, value: unknown): void {
863
+ this.check();
864
+ this.checkEdgeIndex(edge);
865
+ this.writeCell("edge", column, edge, value);
866
+ }
867
+
868
+ /**
869
+ * Widen an inferred node column to a wider dtype of the design section 5.1 order without
870
+ * changing any value (an importer that knows from the lexical grammar that a column of `2.0`
871
+ * cells is f64 although every value so far was integral). A no-op when the column is already
872
+ * as wide; E_COLUMN_TYPE for a declared (non-inferred) column, a dtype outside the inference
873
+ * order or a narrower one; the widening is reported in FreezeReport.widened.
874
+ * @param column - the handle (E_UNKNOWN_COLUMN when stale) or name (E_UNKNOWN_COLUMN when absent)
875
+ * @param dtype - the dtype to widen to
876
+ */
877
+ widenNodeColumn(column: ColumnHandle | string, dtype: Dtype): void {
878
+ this.check();
879
+ this.widenColumn("node", column, dtype);
880
+ }
881
+
882
+ /**
883
+ * Widen an inferred edge column to a wider dtype of the design section 5.1 order without
884
+ * changing any value; see widenNodeColumn().
885
+ * @param column - the handle (E_UNKNOWN_COLUMN when stale) or name (E_UNKNOWN_COLUMN when absent)
886
+ * @param dtype - the dtype to widen to
887
+ */
888
+ widenEdgeColumn(column: ColumnHandle | string, dtype: Dtype): void {
889
+ this.check();
890
+ this.widenColumn("edge", column, dtype);
891
+ }
892
+
893
+ /**
894
+ * Bulk-set a node column from a typed array (copied into staging); replaces a column of the same
895
+ * name. The length must be nodeBound * components (bool: ceil(nodeBound / 32) words).
896
+ * @param name - the column name
897
+ * @param data - the values
898
+ * @param decl - declaration fields (dtype defaults to the array's)
899
+ */
900
+ setNodeColumn(name: string, data: TypedArrayData, decl?: ColumnDeclPatch): void {
901
+ this.check();
902
+ this.setBulkColumn("node", this.staging.nodeBound, name, data, decl ?? {});
903
+ }
904
+
905
+ /**
906
+ * Bulk-set an edge column from a typed array (copied into staging); replaces a column of the same
907
+ * name. The length must be edgeBound * components.
908
+ * @param name - the column name
909
+ * @param data - the values
910
+ * @param decl - declaration fields (dtype defaults to the array's)
911
+ */
912
+ setEdgeColumn(name: string, data: TypedArrayData, decl?: ColumnDeclPatch): void {
913
+ this.check();
914
+ this.setBulkColumn("edge", this.staging.edgeBound, name, data, decl ?? {});
915
+ }
916
+
917
+ /**
918
+ * Set a graph-level attribute (the graph table's single row); the value and declaration are
919
+ * checked now, the column is built at freeze.
920
+ * @param name - the column name
921
+ * @param value - the value; undefined unsets it
922
+ * @param decl - declaration fields for the column
923
+ */
924
+ setGraphValue(name: string, value: unknown, decl?: ColumnDeclPatch): void {
925
+ this.check();
926
+ const patch = decl ?? {};
927
+ // build once now so a bad value or declaration fails at the call, not at freeze
928
+ resolveColumnMeta(name, "graph", { dtype: "json", ...patch });
929
+ if (value !== undefined) {
930
+ buildGraphColumn(name, value, patch);
931
+ }
932
+ this.graphValues.set(name, { decl: patch, value });
933
+ }
934
+
935
+ /**
936
+ * Merge fields into the graph metadata (design section 5.9); undefined fields are left alone,
937
+ * `extra` must be a JSON value (E_COLUMN_TYPE with details.field otherwise).
938
+ * @param meta - the fields to set
939
+ */
940
+ setMeta(meta: GraphMetaPatch): void {
941
+ this.check();
942
+ this.metaValue = resolveGraphMeta(this.metaValue, meta);
943
+ }
944
+
945
+ /**
946
+ * Create an extension table (design section 5.10).
947
+ * @param name - the table name; E_COLUMN_EXISTS when taken
948
+ * @param decls - its columns; E_COLUMN_EXISTS for a repeated name, E_DUPLICATE_ROLE for a repeated role
949
+ * @returns the table handle
950
+ */
951
+ addExtensionTable(name: string, decls: readonly ColumnDecl[]): ExtensionHandle {
952
+ this.check();
953
+ const { staging } = this;
954
+ if (staging.extensions.some((table) => table.name === name)) {
955
+ throw new GraphFormatError("E_COLUMN_EXISTS", `extension table "${name}" already exists`, { table: name });
956
+ }
957
+ const columns: StagingColumn[] = [];
958
+ for (const decl of decls) {
959
+ const column = createStagingColumn(decl.name, "extension", decl, false);
960
+ if (columns.some((other) => other.meta.name === column.meta.name)) {
961
+ throw new GraphFormatError("E_COLUMN_EXISTS", `column "${decl.name}" is declared twice in "${name}"`, {
962
+ table: name,
963
+ column: decl.name,
964
+ });
965
+ }
966
+ checkRole(columns, column.meta, name);
967
+ columns.push(column);
968
+ }
969
+ staging.extensions.push({ name, columns, rowCount: 0 });
970
+ return (staging.extensions.length - 1) as ExtensionHandle;
971
+ }
972
+
973
+ /**
974
+ * Append a row to an extension table.
975
+ * @param table - the table handle; E_INDEX_RANGE when unknown
976
+ * @param values - one value per declared column; E_COLUMN_LENGTH otherwise
977
+ * @returns the new row index
978
+ */
979
+ addExtensionRow(table: ExtensionHandle, values: readonly unknown[]): number {
980
+ this.check();
981
+ const target = this.extensionOf(table);
982
+ if (values.length !== target.columns.length) {
983
+ throw new GraphFormatError(
984
+ "E_COLUMN_LENGTH",
985
+ `extension table "${target.name}" has ${target.columns.length} columns; ${values.length} values given`,
986
+ { table: target.name, expected: target.columns.length, found: values.length },
987
+ );
988
+ }
989
+ const row = target.rowCount;
990
+ const checked = target.columns.map((column, i) => {
991
+ const value = this.checkReference(column.meta, values[i]);
992
+ column.checkValue(row, value);
993
+ return value;
994
+ });
995
+ for (let i = 0; i < checked.length; i++) {
996
+ target.columns[i].write(row, checked[i]);
997
+ }
998
+ target.rowCount = row + 1;
999
+ return row;
1000
+ }
1001
+
1002
+ /**
1003
+ * Add a node from a record: every key present overwrites that row (last-write-wins per attribute,
1004
+ * design section 6.6), auto-declaring inferred columns.
1005
+ * @param id - the node id
1006
+ * @param attrs - attribute values keyed by column name
1007
+ * @returns the node index
1008
+ */
1009
+ addNodeRecord(id: NodeId, attrs: Readonly<Record<string, unknown>>): number {
1010
+ this.check();
1011
+ const validated = validateNodeId(id);
1012
+ const keys = Object.keys(attrs);
1013
+ // every attribute is validated before the node is added (design section 11.1)
1014
+ this.checkCells("node", this.prospectiveNodeIndex(validated), keys, attrs);
1015
+ const index = this.addValidatedNode(validated);
1016
+ for (const key of keys) {
1017
+ this.writeCell("node", key, index, attrs[key]);
1018
+ }
1019
+ return index;
1020
+ }
1021
+
1022
+ /**
1023
+ * Add an edge from a record; always a new edge (parallels are kept). The weight comes from
1024
+ * `weightKey` ("weight" by default; null = none) and must be a number (E_INVALID_WEIGHT).
1025
+ * @param source - source id
1026
+ * @param target - target id
1027
+ * @param attrs - attribute values keyed by column name
1028
+ * @param weightKey - the key holding the weight; default "weight"; null = no weight
1029
+ * @returns the logical edge index
1030
+ */
1031
+ addEdgeRecord(
1032
+ source: NodeId,
1033
+ target: NodeId,
1034
+ attrs: Readonly<Record<string, unknown>>,
1035
+ weightKey?: string | null,
1036
+ ): number {
1037
+ this.check();
1038
+ const key = weightKey === undefined ? "weight" : weightKey;
1039
+ let weight: number | undefined;
1040
+ if (key !== null && Object.prototype.hasOwnProperty.call(attrs, key)) {
1041
+ const raw = attrs[key];
1042
+ if (raw !== undefined && raw !== null) {
1043
+ if (typeof raw !== "number") {
1044
+ throw new GraphFormatError("E_INVALID_WEIGHT", `edge weight "${key}" is not a number`, {
1045
+ key,
1046
+ found: typeof raw,
1047
+ });
1048
+ }
1049
+ weight = raw;
1050
+ }
1051
+ }
1052
+ const keys = Object.keys(attrs).filter((name) => name !== key);
1053
+ // every attribute is validated before the edge (and any missing endpoint) is added (design 11.1)
1054
+ this.checkWeight(weight, undefined);
1055
+ this.checkCells("edge", this.staging.edgeBound, keys, attrs);
1056
+ const edge = this.addEdge(source, target, weight);
1057
+ for (const name of keys) {
1058
+ this.writeCell("edge", name, edge, attrs[name]);
1059
+ }
1060
+ return edge;
1061
+ }
1062
+
1063
+ // ---------------------------------------------------------------- composition
1064
+
1065
+ /**
1066
+ * Append another snapshot (design section 6.6): nodes are merged by id ("merge": the incoming set
1067
+ * rows overwrite; "error": E_DUPLICATE_ID), edges are appended in logical order with their
1068
+ * declared orientation and weights, columns declared in both graphs widen to the union dtype of
1069
+ * design section 5.1, dictionaries are re-interned, extension tables are appended with their
1070
+ * references rewritten, and graph attributes overwrite by name.
1071
+ * @param snapshot - the snapshot to append
1072
+ * @param options - duplicate-id handling
1073
+ * @param options.onDuplicateNode - "merge" (default) or "error"
1074
+ */
1075
+ addGraph(snapshot: GraphSnapshot, options?: { readonly onDuplicateNode?: "merge" | "error" | undefined }): void {
1076
+ this.check();
1077
+ const onDuplicate = options?.onDuplicateNode ?? "merge";
1078
+ if (onDuplicate !== "merge" && onDuplicate !== "error") {
1079
+ throw new GraphFormatError(
1080
+ "E_UNSUPPORTED",
1081
+ `addGraph option onDuplicateNode has an unsupported value ${String(onDuplicate)}`,
1082
+ { field: "onDuplicateNode", found: onDuplicate, reason: "unsupported option" },
1083
+ );
1084
+ }
1085
+ if (snapshot.directed !== this.directedValue) {
1086
+ throw new GraphFormatError(
1087
+ "E_DIRECTED",
1088
+ `a ${snapshot.directed ? "directed" : "undirected"} snapshot cannot be appended to a ${this.directedValue ? "directed" : "undirected"} builder`,
1089
+ { reason: "direction mismatch", directed: this.directedValue, found: snapshot.directed },
1090
+ );
1091
+ }
1092
+ const { staging } = this;
1093
+ const { nodeCount, edgeCount } = snapshot;
1094
+ // everything that can be refused is checked before the first mutation (design section 11.1):
1095
+ // duplicate ids, the count limits, weights, roles and extension column names
1096
+ const anonymous =
1097
+ staging.ids === null &&
1098
+ staging.nodeBound === 0 &&
1099
+ snapshot.ids.kind === "identity" &&
1100
+ snapshot.ids.offset === 0;
1101
+ const nodeMap = new Uint32Array(nodeCount);
1102
+ let newNodes = nodeCount;
1103
+ if (!anonymous) {
1104
+ newNodes = 0;
1105
+ for (let i = 0; i < nodeCount; i++) {
1106
+ const id = snapshot.ids.idOf(i);
1107
+ const existing = this.lookup(id);
1108
+ if (existing === INVALID_INDEX) {
1109
+ newNodes++;
1110
+ } else if (onDuplicate === "error") {
1111
+ throw new GraphFormatError("E_DUPLICATE_ID", `node id ${describeId(id)} already exists`, {
1112
+ id,
1113
+ index: existing,
1114
+ });
1115
+ }
1116
+ }
1117
+ }
1118
+ this.checkNodeLimit(newNodes);
1119
+ const list = snapshot.edgeList();
1120
+ this.checkEdgeLimit(edgeCount, this.arcsOf(list.src, list.dst, edgeCount));
1121
+ const shadow = snapshot.edges.byRole("weight");
1122
+ const weights = new Array<number | undefined>(edgeCount);
1123
+ for (let e = 0; e < edgeCount; e++) {
1124
+ let weight: number | undefined;
1125
+ if (shadow !== null) {
1126
+ weight = shadow.isSet(e) ? (shadow.value(e) as number) : undefined;
1127
+ } else if (list.weights !== null) {
1128
+ weight = list.weights[e];
1129
+ }
1130
+ weights[e] = this.checkWeight(weight, e);
1131
+ }
1132
+ for (const column of snapshot.nodes) {
1133
+ this.checkIncomingColumn("node", column);
1134
+ }
1135
+ for (const column of snapshot.edges) {
1136
+ if (column !== shadow) {
1137
+ this.checkIncomingColumn("edge", column);
1138
+ }
1139
+ }
1140
+ for (const [name, table] of snapshot.extensions) {
1141
+ this.checkIncomingExtension(name, table);
1142
+ }
1143
+ // nodes
1144
+ if (anonymous) {
1145
+ const first = this.addAnonymousNodes(nodeCount);
1146
+ for (let i = 0; i < nodeCount; i++) {
1147
+ nodeMap[i] = first + i;
1148
+ }
1149
+ } else {
1150
+ for (let i = 0; i < nodeCount; i++) {
1151
+ const id = snapshot.ids.idOf(i);
1152
+ const existing = this.lookup(id);
1153
+ nodeMap[i] = existing === INVALID_INDEX ? this.addValidatedNode(id) : existing;
1154
+ }
1155
+ }
1156
+ // edges: declared orientation, weights through the role column when present; a weighted
1157
+ // snapshot keeps its declared weight array (design section 3.7) unless the builder is unweighted
1158
+ if (snapshot.flags.weighted && this.fixed.weighted !== false) {
1159
+ staging.ensureWeights();
1160
+ }
1161
+ const edgeMap = new Uint32Array(edgeCount);
1162
+ for (let e = 0; e < edgeCount; e++) {
1163
+ edgeMap[e] = this.pushEdge(nodeMap[list.src[e]], nodeMap[list.dst[e]], weights[e]);
1164
+ }
1165
+ // columns
1166
+ const refs: IndexMaps = { node: nodeMap, edge: edgeMap };
1167
+ for (const column of snapshot.nodes) {
1168
+ this.copyColumn("node", column, nodeMap, refs);
1169
+ }
1170
+ for (const column of snapshot.edges) {
1171
+ if (column !== shadow) {
1172
+ this.copyColumn("edge", column, edgeMap, refs);
1173
+ }
1174
+ }
1175
+ for (const column of snapshot.graph) {
1176
+ if (column.isSet(0)) {
1177
+ this.setGraphValue(column.meta.name, column.value(0), metaToDecl(column.meta));
1178
+ }
1179
+ }
1180
+ for (const [name, table] of snapshot.extensions) {
1181
+ this.appendExtension(name, table, refs);
1182
+ }
1183
+ }
1184
+
1185
+ // ---------------------------------------------------------------- output and lifecycle
1186
+
1187
+ /**
1188
+ * Run the freeze pipeline of design section 6.3 and return a snapshot; the builder keeps its
1189
+ * staging (compacted and renumbered when tombstones existed, rewritten by a merge policy) unless
1190
+ * `release` is set.
1191
+ * @param options - the freeze options
1192
+ * @returns the snapshot
1193
+ */
1194
+ freeze(options?: FreezeOptions): GraphSnapshot {
1195
+ return this.freezeWithReport(options).snapshot;
1196
+ }
1197
+
1198
+ /**
1199
+ * freeze() plus the report of what was renumbered, merged, dropped and widened (design section
1200
+ * 6.6): the remaps are relative to the previous freeze (the builder's own index space on the
1201
+ * first) and null exactly when nothing was renumbered.
1202
+ * @param options - the freeze options
1203
+ * @returns the snapshot and its report
1204
+ */
1205
+ freezeWithReport(options?: FreezeOptions): { snapshot: GraphSnapshot; report: FreezeReport } {
1206
+ this.check();
1207
+ const opts = options ?? {};
1208
+ const context: FreezeContext = {
1209
+ staging: this.staging,
1210
+ directed: this.directedValue,
1211
+ options: this.options,
1212
+ graphValues: this.graphValues,
1213
+ meta: this.metaValue,
1214
+ widened: this.widenings,
1215
+ };
1216
+ const outcome = runFreeze(context, opts);
1217
+ this.staging = outcome.staging;
1218
+ this.widenings = [];
1219
+ this.dirtyFlag = false;
1220
+ if (opts.release === true) {
1221
+ this.staging = this.freshStaging();
1222
+ }
1223
+ return { snapshot: outcome.snapshot, report: outcome.report };
1224
+ }
1225
+
1226
+ /** Empty the builder (nodes, edges, columns, extension tables, graph attributes, meta), keeping its options and lock. */
1227
+ clear(): void {
1228
+ this.check();
1229
+ this.staging = this.freshStaging();
1230
+ this.graphValues = new Map();
1231
+ this.metaValue = EMPTY_GRAPH_META;
1232
+ this.widenings = [];
1233
+ this.mutated();
1234
+ }
1235
+
1236
+ /** Release everything; every further call throws E_BUILDER_DISPOSED. */
1237
+ dispose(): void {
1238
+ if (this.disposed) {
1239
+ return;
1240
+ }
1241
+ this.staging = new Staging({
1242
+ weightDtype: this.fixed.weightDtype,
1243
+ weighted: false,
1244
+ expectedNodes: null,
1245
+ expectedEdges: null,
1246
+ resizable: undefined,
1247
+ });
1248
+ this.graphValues = new Map();
1249
+ this.metaValue = EMPTY_GRAPH_META;
1250
+ this.widenings = [];
1251
+ this.disposed = true;
1252
+ }
1253
+
1254
+ /**
1255
+ * Bytes of typed staging currently held (capacity, not length; JS arrays and the id Map excluded).
1256
+ * @returns the byte count
1257
+ */
1258
+ byteLength(): number {
1259
+ this.check();
1260
+ return this.staging.byteLength();
1261
+ }
1262
+
1263
+ // ---------------------------------------------------------------- private: state
1264
+
1265
+ private check(): void {
1266
+ if (this.disposed) {
1267
+ throw new GraphFormatError("E_BUILDER_DISPOSED", "the builder was disposed", {});
1268
+ }
1269
+ }
1270
+
1271
+ private mutated(): void {
1272
+ this.mutations++;
1273
+ this.dirtyFlag = true;
1274
+ }
1275
+
1276
+ private freshStaging(): Staging {
1277
+ return new Staging({
1278
+ weightDtype: this.fixed.weightDtype,
1279
+ weighted: this.fixed.weighted === true,
1280
+ expectedNodes: this.fixed.expectedNodes,
1281
+ expectedEdges: this.fixed.expectedEdges,
1282
+ resizable: undefined,
1283
+ });
1284
+ }
1285
+
1286
+ // ---------------------------------------------------------------- private: nodes
1287
+
1288
+ /**
1289
+ * The index a validated id would get from addValidatedNode: its existing (live or tombstoned)
1290
+ * index, or the next one.
1291
+ * @param id - a validated id
1292
+ * @returns the prospective index
1293
+ */
1294
+ private prospectiveNodeIndex(id: NodeId): number {
1295
+ const { staging } = this;
1296
+ if (staging.idToIndex === null) {
1297
+ return typeof id === "number" && Number.isInteger(id) && id >= 0 && id < staging.nodeBound
1298
+ ? id
1299
+ : staging.nodeBound;
1300
+ }
1301
+ return staging.idToIndex.get(typeof id === "number" && id === 0 ? 0 : id) ?? staging.nodeBound;
1302
+ }
1303
+
1304
+ /**
1305
+ * The live index of an id, total.
1306
+ * @param id - any value
1307
+ * @returns the index, or INVALID_INDEX when absent, illegal or tombstoned
1308
+ */
1309
+ private lookup(id: NodeId): number {
1310
+ const { staging } = this;
1311
+ if (staging.idToIndex === null) {
1312
+ if (typeof id !== "number" || !Number.isInteger(id) || id < 0 || id >= staging.nodeBound) {
1313
+ return INVALID_INDEX;
1314
+ }
1315
+ return staging.nodeAlive.get(id) ? id : INVALID_INDEX;
1316
+ }
1317
+ const index = staging.idToIndex.get(typeof id === "number" && id === 0 ? 0 : id);
1318
+ if (index === undefined || !staging.nodeAlive.get(index)) {
1319
+ return INVALID_INDEX;
1320
+ }
1321
+ return index;
1322
+ }
1323
+
1324
+ private isLiveNode(index: number): boolean {
1325
+ const { staging } = this;
1326
+ return Number.isInteger(index) && index >= 0 && index < staging.nodeBound && staging.nodeAlive.get(index);
1327
+ }
1328
+
1329
+ private isLiveEdge(edge: number): boolean {
1330
+ const { staging } = this;
1331
+ return Number.isInteger(edge) && edge >= 0 && edge < staging.edgeBound && staging.edgeAlive.get(edge);
1332
+ }
1333
+
1334
+ private checkNodeIndex(index: number): void {
1335
+ if (!Number.isInteger(index) || index < 0 || index >= this.staging.nodeBound) {
1336
+ throw new GraphFormatError("E_INDEX_RANGE", `node index ${index} is out of range`, {
1337
+ index,
1338
+ bound: this.staging.nodeBound,
1339
+ });
1340
+ }
1341
+ }
1342
+
1343
+ private checkEdgeIndex(edge: number): void {
1344
+ if (!Number.isInteger(edge) || edge < 0 || edge >= this.staging.edgeBound) {
1345
+ throw new GraphFormatError("E_INDEX_RANGE", `edge index ${edge} is out of range`, {
1346
+ edge,
1347
+ bound: this.staging.edgeBound,
1348
+ });
1349
+ }
1350
+ }
1351
+
1352
+ private checkLiveNode(index: number): void {
1353
+ if (!this.isLiveNode(index)) {
1354
+ throw new GraphFormatError("E_UNKNOWN_NODE", `node index ${index} is out of range or removed`, { index });
1355
+ }
1356
+ }
1357
+
1358
+ private checkEdge(edge: number): void {
1359
+ if (!this.isLiveEdge(edge)) {
1360
+ throw new GraphFormatError("E_INDEX_RANGE", `edge index ${edge} is out of range or removed`, {
1361
+ edge,
1362
+ bound: this.staging.edgeBound,
1363
+ });
1364
+ }
1365
+ }
1366
+
1367
+ private checkNodeLimit(adding: number): void {
1368
+ if (this.staging.nodeBound + adding > MAX_COUNT) {
1369
+ throw new GraphFormatError(
1370
+ "E_TOO_LARGE",
1371
+ `node count ${this.staging.nodeBound + adding} exceeds MAX_COUNT`,
1372
+ {
1373
+ count: this.staging.nodeBound + adding,
1374
+ max: MAX_COUNT,
1375
+ },
1376
+ );
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * The E_TOO_LARGE check of invariant I3 at the add* call that would cross the limit: the edge
1382
+ * index space and the live arc count (doubled for an undirected graph, loops once) must both
1383
+ * stay at or below MAX_COUNT.
1384
+ * @param addingEdges - edges about to be added
1385
+ * @param addingArcs - arcs they contribute (1 per directed edge or loop, 2 per undirected non-loop)
1386
+ */
1387
+ private checkEdgeLimit(addingEdges: number, addingArcs: number): void {
1388
+ const { staging } = this;
1389
+ const edges = staging.edgeBound + addingEdges;
1390
+ const liveArcs = this.directedValue ? staging.liveEdgeCount : 2 * staging.liveEdgeCount - staging.selfLoopCount;
1391
+ const arcs = liveArcs + addingArcs;
1392
+ if (edges > MAX_COUNT || arcs > MAX_COUNT) {
1393
+ throw new GraphFormatError("E_TOO_LARGE", `edge count ${edges} (${arcs} arcs) exceeds MAX_COUNT`, {
1394
+ count: edges,
1395
+ arcs,
1396
+ max: MAX_COUNT,
1397
+ });
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * The arcs a batch of edges contributes.
1403
+ * @param src - sources
1404
+ * @param dst - targets
1405
+ * @param count - how many
1406
+ * @returns the arc count
1407
+ */
1408
+ private arcsOf(src: ArrayLike<number>, dst: ArrayLike<number>, count: number): number {
1409
+ if (this.directedValue) {
1410
+ return count;
1411
+ }
1412
+ let arcs = 0;
1413
+ for (let i = 0; i < count; i++) {
1414
+ arcs += src[i] === dst[i] ? 1 : 2;
1415
+ }
1416
+ return arcs;
1417
+ }
1418
+
1419
+ /**
1420
+ * Add or revive a validated id.
1421
+ * @param id - a validated id
1422
+ * @returns the index
1423
+ */
1424
+ private addValidatedNode(id: NodeId): number {
1425
+ const { staging } = this;
1426
+ staging.materialiseIds();
1427
+ const map = staging.idToIndex as Map<NodeId, number>;
1428
+ const existing = map.get(id);
1429
+ if (existing !== undefined) {
1430
+ if (!staging.nodeAlive.get(existing)) {
1431
+ staging.nodeAlive.set(existing, true);
1432
+ staging.liveNodeCount++;
1433
+ this.mutated();
1434
+ }
1435
+ return existing;
1436
+ }
1437
+ this.checkNodeLimit(1);
1438
+ const index = staging.pushNode(typeof id === "string" ? detachString(id) : id);
1439
+ this.mutated();
1440
+ return index;
1441
+ }
1442
+
1443
+ /**
1444
+ * Resolve an edge endpoint id: the live index, a revived or new node under addMissingNodes, or
1445
+ * E_UNKNOWN_NODE.
1446
+ * @param id - a validated id
1447
+ * @returns the index
1448
+ */
1449
+ private resolveEndpoint(id: NodeId): number {
1450
+ const index = this.lookup(id);
1451
+ if (index !== INVALID_INDEX) {
1452
+ return index;
1453
+ }
1454
+ if (this.fixed.addMissingNodes) {
1455
+ return this.addValidatedNode(id);
1456
+ }
1457
+ throw new GraphFormatError("E_UNKNOWN_NODE", `unknown node id ${describeId(id)}`, { id });
1458
+ }
1459
+
1460
+ private requireLive(id: NodeId): void {
1461
+ if (this.lookup(id) === INVALID_INDEX) {
1462
+ throw new GraphFormatError("E_UNKNOWN_NODE", `unknown node id ${describeId(id)}`, { id });
1463
+ }
1464
+ }
1465
+
1466
+ private removeLiveNode(index: number): U32 {
1467
+ const { staging } = this;
1468
+ const removed: number[] = [];
1469
+ for (let e = staging.firstOut.get(index); e !== INVALID_INDEX; e = staging.nextOut.get(e)) {
1470
+ if (staging.edgeAlive.get(e)) {
1471
+ staging.killEdge(e);
1472
+ removed.push(e);
1473
+ }
1474
+ }
1475
+ for (let e = staging.firstIn.get(index); e !== INVALID_INDEX; e = staging.nextIn.get(e)) {
1476
+ if (staging.edgeAlive.get(e)) {
1477
+ staging.killEdge(e);
1478
+ removed.push(e);
1479
+ }
1480
+ }
1481
+ staging.nodeAlive.set(index, false);
1482
+ staging.liveNodeCount--;
1483
+ this.mutated();
1484
+ return Uint32Array.from(removed).sort();
1485
+ }
1486
+
1487
+ // ---------------------------------------------------------------- private: edges
1488
+
1489
+ /**
1490
+ * Validate a weight argument: NaN is E_INVALID_WEIGHT; on an unweighted (`weighted: false`) builder
1491
+ * only 1 is accepted and it is treated as omitted.
1492
+ * @param weight - the argument
1493
+ * @param edge - the bulk position, for the error details
1494
+ * @returns the weight to store, or undefined when omitted
1495
+ */
1496
+ private checkWeight(weight: number | undefined, edge: number | undefined): number | undefined {
1497
+ if (weight === undefined) {
1498
+ return undefined;
1499
+ }
1500
+ if (typeof weight !== "number" || Number.isNaN(weight)) {
1501
+ throw new GraphFormatError("E_INVALID_WEIGHT", `weight ${String(weight)} is not a number`, {
1502
+ weight,
1503
+ ...(edge === undefined ? {} : { edge }),
1504
+ });
1505
+ }
1506
+ if (this.fixed.weighted === false) {
1507
+ if (weight !== 1) {
1508
+ throw new GraphFormatError("E_INVALID_WEIGHT", `weight ${weight} on a builder declared unweighted`, {
1509
+ weight,
1510
+ reason: "unweighted builder",
1511
+ ...(edge === undefined ? {} : { edge }),
1512
+ });
1513
+ }
1514
+ return undefined;
1515
+ }
1516
+ return weight;
1517
+ }
1518
+
1519
+ private pushEdge(u: number, v: number, weight: number | undefined): number {
1520
+ this.checkEdgeLimit(1, this.directedValue || u === v ? 1 : 2);
1521
+ const e = this.staging.pushEdge(u, v, weight);
1522
+ this.mutated();
1523
+ return e;
1524
+ }
1525
+
1526
+ /**
1527
+ * The in-place expansion of design section 6.6: every live edge u -> v gets a mirror v -> u at a
1528
+ * new index (loops are not mirrored), graphty.directed is 0 for both halves and graphty.pair links
1529
+ * them; every other column's mirror rows stay unset.
1530
+ */
1531
+ private expandToDirected(): void {
1532
+ const { staging } = this;
1533
+ const live = staging.liveEdgeCount;
1534
+ this.checkEdgeLimit(live, live);
1535
+ const directedDecl: ColumnDecl = { name: DIRECTED_COLUMN, dtype: "bool", role: "directed" };
1536
+ const pairDecl: ColumnDecl = { name: PAIR_COLUMN, dtype: "u32", role: "pair", refersTo: "edge" };
1537
+ // both declarations are checked before either is applied (design section 11.1)
1538
+ this.checkDeclaration("edge", directedDecl);
1539
+ this.checkDeclaration("edge", pairDecl);
1540
+ const directedHandle = this.declare("edge", directedDecl);
1541
+ const pairHandle = this.declare("edge", pairDecl);
1542
+ const directedColumn = staging.edgeColumns[directedHandle];
1543
+ const pairColumn = staging.edgeColumns[pairHandle];
1544
+ const bound = staging.edgeBound;
1545
+ for (let e = 0; e < bound; e++) {
1546
+ if (!staging.edgeAlive.get(e)) {
1547
+ continue;
1548
+ }
1549
+ directedColumn.write(e, false);
1550
+ const u = staging.src.get(e);
1551
+ const v = staging.dst.get(e);
1552
+ if (u === v) {
1553
+ continue;
1554
+ }
1555
+ const explicit = staging.weightExplicit(e);
1556
+ const weight = explicit && staging.weight !== null ? staging.weight.get(e) : undefined;
1557
+ const mirror = staging.pushEdge(v, u, weight);
1558
+ directedColumn.write(mirror, false);
1559
+ pairColumn.write(e, mirror);
1560
+ pairColumn.write(mirror, e);
1561
+ }
1562
+ this.directedValue = true;
1563
+ this.optionsCache = null;
1564
+ this.mutated();
1565
+ }
1566
+
1567
+ // ---------------------------------------------------------------- private: columns
1568
+
1569
+ private columnsOf(domain: "node" | "edge"): StagingColumn[] {
1570
+ return domain === "node" ? this.staging.nodeColumns : this.staging.edgeColumns;
1571
+ }
1572
+
1573
+ private handleOf(columns: readonly StagingColumn[], name: string): ColumnHandle {
1574
+ const index = this.columnIndex(columns, name);
1575
+ return (index < 0 ? INVALID_INDEX : index) as ColumnHandle;
1576
+ }
1577
+
1578
+ /**
1579
+ * The index of a staging column by name in O(1): a Map beside each column array, rebuilt when
1580
+ * the array is replaced (a fresh staging, a compaction) and kept in step by pushColumn().
1581
+ * Column names never change, so the map is invalidated only by identity or length.
1582
+ * @param columns - the node or edge staging columns
1583
+ * @param name - the column name
1584
+ * @returns the index, or -1 when absent
1585
+ */
1586
+ private columnIndex(columns: readonly StagingColumn[], name: string): number {
1587
+ const cached = columns === this.staging.nodeColumns ? this.nodeNames : this.edgeNames;
1588
+ let index = cached;
1589
+ if (index === null || index.columns !== columns || index.map.size !== columns.length) {
1590
+ const map = new Map<string, number>();
1591
+ for (let i = 0; i < columns.length; i++) {
1592
+ map.set(columns[i].meta.name, i);
1593
+ }
1594
+ index = { columns, map };
1595
+ if (columns === this.staging.nodeColumns) {
1596
+ this.nodeNames = index;
1597
+ } else if (columns === this.staging.edgeColumns) {
1598
+ this.edgeNames = index;
1599
+ }
1600
+ }
1601
+ return index.map.get(name) ?? -1;
1602
+ }
1603
+
1604
+ /**
1605
+ * Append a staging column and keep the name index in step.
1606
+ * @param columns - the node or edge staging columns
1607
+ * @param column - the column to append
1608
+ * @returns the new column's index
1609
+ */
1610
+ private pushColumn(columns: StagingColumn[], column: StagingColumn): number {
1611
+ columns.push(column);
1612
+ const index = columns === this.staging.nodeColumns ? this.nodeNames : this.edgeNames;
1613
+ if (index !== null && index.columns === columns && index.map.size === columns.length - 1) {
1614
+ index.map.set(column.meta.name, columns.length - 1);
1615
+ }
1616
+ return columns.length - 1;
1617
+ }
1618
+
1619
+ /**
1620
+ * Declare a column (design section 11.3): the same shape returns the existing handle, another
1621
+ * shape is E_COLUMN_EXISTS, a role held by another column E_DUPLICATE_ROLE.
1622
+ * @param domain - node or edge
1623
+ * @param decl - the declaration
1624
+ * @returns the handle
1625
+ */
1626
+ private declare(domain: "node" | "edge", decl: ColumnDecl): ColumnHandle {
1627
+ const { meta, existing } = this.checkDeclaration(domain, decl);
1628
+ if (existing !== INVALID_INDEX) {
1629
+ return existing as ColumnHandle;
1630
+ }
1631
+ const columns = this.columnsOf(domain);
1632
+ return this.pushColumn(columns, new StagingColumn(meta, false)) as ColumnHandle;
1633
+ }
1634
+
1635
+ /**
1636
+ * The checks of declare() without applying anything: the resolved metadata and, when a column
1637
+ * of the same shape exists, its handle.
1638
+ * @param domain - node or edge
1639
+ * @param decl - the declaration
1640
+ * @returns the metadata and the existing handle (INVALID_INDEX when the column is new)
1641
+ */
1642
+ private checkDeclaration(domain: "node" | "edge", decl: ColumnDecl): { meta: ColumnMeta; existing: number } {
1643
+ if (typeof decl.name !== "string" || decl.name.length === 0) {
1644
+ throw new GraphFormatError("E_COLUMN_TYPE", "a column declaration needs a non-empty name", {
1645
+ field: "name",
1646
+ found: decl.name,
1647
+ });
1648
+ }
1649
+ const meta = resolveColumnMeta(decl.name, domain, decl);
1650
+ const columns = this.columnsOf(domain);
1651
+ const existing = this.columnIndex(columns, meta.name);
1652
+ if (existing >= 0) {
1653
+ if (sameShape(columns[existing].meta, meta)) {
1654
+ return { meta, existing };
1655
+ }
1656
+ throw new GraphFormatError(
1657
+ "E_COLUMN_EXISTS",
1658
+ `${domain} column "${meta.name}" is already declared as ${columns[existing].meta.dtype}`,
1659
+ { column: meta.name, domain, found: columns[existing].meta.dtype, expected: meta.dtype },
1660
+ );
1661
+ }
1662
+ checkRole(columns, meta, domain);
1663
+ return { meta, existing: INVALID_INDEX };
1664
+ }
1665
+
1666
+ /**
1667
+ * The staging column a handle or name refers to, auto-declaring an inferred column for an unknown
1668
+ * name when the value is set.
1669
+ * @param domain - node or edge
1670
+ * @param column - the handle or name
1671
+ * @param value - the value about to be written (decides the inferred dtype)
1672
+ * @returns the column, or null when an unknown name receives an unset value
1673
+ */
1674
+ private resolveColumn(
1675
+ domain: "node" | "edge",
1676
+ column: ColumnHandle | string,
1677
+ value: unknown,
1678
+ ): StagingColumn | null {
1679
+ const columns = this.columnsOf(domain);
1680
+ if (typeof column === "number") {
1681
+ if (!Number.isInteger(column) || column < 0 || column >= columns.length) {
1682
+ throw new GraphFormatError("E_UNKNOWN_COLUMN", `no ${domain} column with handle ${column}`, {
1683
+ domain,
1684
+ handle: column,
1685
+ });
1686
+ }
1687
+ return columns[column];
1688
+ }
1689
+ const index = this.columnIndex(columns, column);
1690
+ if (index >= 0) {
1691
+ return columns[index];
1692
+ }
1693
+ if (value === undefined || value === null) {
1694
+ return null;
1695
+ }
1696
+ const dtype = inferInitialDtype(value);
1697
+ const created = createStagingColumn(column, domain, { dtype, nullable: true }, true);
1698
+ this.pushColumn(columns, created);
1699
+ return created;
1700
+ }
1701
+
1702
+ /**
1703
+ * Validate every value of a record against the columns it would be written to, without writing
1704
+ * (design section 11.1: a record whose value is refused is not applied at all). An existing
1705
+ * column checks the value as its `write` would; a new name must be inferrable (a JSON value or a
1706
+ * well-formed string) unless the value is unset.
1707
+ * @param domain - node or edge
1708
+ * @param row - the row the record is meant for (error details only)
1709
+ * @param keys - the record keys
1710
+ * @param attrs - the record
1711
+ */
1712
+ private checkCells(
1713
+ domain: "node" | "edge",
1714
+ row: number,
1715
+ keys: readonly string[],
1716
+ attrs: Readonly<Record<string, unknown>>,
1717
+ ): void {
1718
+ const columns = this.columnsOf(domain);
1719
+ for (const key of keys) {
1720
+ const value = attrs[key];
1721
+ const at = this.columnIndex(columns, key);
1722
+ const column = at < 0 ? undefined : columns[at];
1723
+ if (column !== undefined) {
1724
+ column.checkValue(row, this.checkReference(column.meta, value));
1725
+ } else if (value !== undefined && value !== null) {
1726
+ inferInitialDtype(value);
1727
+ if (typeof value === "string") {
1728
+ assertWellFormedString(value, { column: key, row });
1729
+ } else if (typeof value === "object") {
1730
+ assertJsonValue(value, `${key}[${row}]`);
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+
1736
+ /**
1737
+ * The refersTo range rule of invariant I12 at the write: an index-valued cell of a refersTo
1738
+ * column must be below the referenced space's current bound (a tombstoned target is a dangling
1739
+ * reference the next compaction resolves) or INVALID_INDEX; E_INDEX_RANGE otherwise. A scalar
1740
+ * INVALID_INDEX means "no reference" and is written as an unset row (E_COLUMN_TYPE on a
1741
+ * non-nullable column, which cannot hold it). Values of other shapes are left to the column's
1742
+ * own type check.
1743
+ * @param meta - the column
1744
+ * @param value - the value about to be written
1745
+ * @returns the value to write (undefined for a scalar INVALID_INDEX)
1746
+ */
1747
+ private checkReference(meta: ColumnMeta, value: unknown): unknown {
1748
+ if (meta.refersTo === null || value === undefined || value === null) {
1749
+ return value;
1750
+ }
1751
+ const bound = meta.refersTo === "node" ? this.staging.nodeBound : this.staging.edgeBound;
1752
+ const check = (index: unknown): void => {
1753
+ if (typeof index === "number" && index !== INVALID_INDEX && !(index >= 0 && index < bound)) {
1754
+ throw new GraphFormatError(
1755
+ "E_INDEX_RANGE",
1756
+ `column "${meta.name}" refers to ${String(meta.refersTo)} ${index}, which is not below ${bound}`,
1757
+ { column: meta.name, refersTo: meta.refersTo, found: index, bound },
1758
+ );
1759
+ }
1760
+ };
1761
+ if (Array.isArray(value)) {
1762
+ for (const item of value as unknown[]) {
1763
+ check(item);
1764
+ }
1765
+ return value;
1766
+ }
1767
+ check(value);
1768
+ return value === INVALID_INDEX ? undefined : value;
1769
+ }
1770
+
1771
+ /**
1772
+ * The checks copyColumn would fail on before it writes anything: a new column's role must be
1773
+ * free (E_DUPLICATE_ROLE).
1774
+ * @param domain - node or edge
1775
+ * @param column - the incoming column
1776
+ */
1777
+ private checkIncomingColumn(domain: "node" | "edge", column: Column): void {
1778
+ const columns = this.columnsOf(domain);
1779
+ if (this.columnIndex(columns, column.meta.name) < 0) {
1780
+ checkRole(columns, column.meta, domain);
1781
+ }
1782
+ }
1783
+
1784
+ /**
1785
+ * The checks appendExtension would fail on before it writes anything: an existing table of the
1786
+ * same name must hold every incoming column (E_UNKNOWN_COLUMN); a new table's declarations must
1787
+ * resolve (E_COLUMN_EXISTS / E_DUPLICATE_ROLE).
1788
+ * @param name - the table name
1789
+ * @param table - the incoming table
1790
+ */
1791
+ private checkIncomingExtension(name: string, table: Iterable<Column>): void {
1792
+ const target = this.staging.extensions.find((t) => t.name === name);
1793
+ const columns = [...table];
1794
+ if (target === undefined) {
1795
+ const seen: StagingColumn[] = [];
1796
+ for (const column of columns) {
1797
+ const decl = { ...metaToDecl(column.meta), name: column.meta.name } as ColumnDecl;
1798
+ const created = createStagingColumn(decl.name, "extension", decl, false);
1799
+ if (seen.some((other) => other.meta.name === created.meta.name)) {
1800
+ throw new GraphFormatError(
1801
+ "E_COLUMN_EXISTS",
1802
+ `column "${decl.name}" is declared twice in "${name}"`,
1803
+ {
1804
+ table: name,
1805
+ column: decl.name,
1806
+ },
1807
+ );
1808
+ }
1809
+ checkRole(seen, created.meta, name);
1810
+ seen.push(created);
1811
+ }
1812
+ return;
1813
+ }
1814
+ for (const column of columns) {
1815
+ if (!target.columns.some((c) => c.meta.name === column.meta.name)) {
1816
+ throw new GraphFormatError(
1817
+ "E_UNKNOWN_COLUMN",
1818
+ `extension table "${name}" has no column "${column.meta.name}"`,
1819
+ { table: name, column: column.meta.name },
1820
+ );
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ /**
1826
+ * The widening of widenNodeColumn / widenEdgeColumn.
1827
+ * @param domain - node or edge
1828
+ * @param column - the handle or name
1829
+ * @param dtype - the dtype to widen to
1830
+ */
1831
+ private widenColumn(domain: "node" | "edge", column: ColumnHandle | string, dtype: Dtype): void {
1832
+ const columns = this.columnsOf(domain);
1833
+ let target: StagingColumn | undefined;
1834
+ if (typeof column === "number") {
1835
+ target = Number.isInteger(column) && column >= 0 && column < columns.length ? columns[column] : undefined;
1836
+ } else {
1837
+ const index = this.columnIndex(columns, column);
1838
+ target = index < 0 ? undefined : columns[index];
1839
+ }
1840
+ if (target === undefined) {
1841
+ throw new GraphFormatError("E_UNKNOWN_COLUMN", `no ${domain} column ${String(column)}`, { domain, column });
1842
+ }
1843
+ const current = target.inferredDtype;
1844
+ if (current === null) {
1845
+ throw new GraphFormatError(
1846
+ "E_COLUMN_TYPE",
1847
+ `${domain} column "${target.meta.name}" is declared, not inferred`,
1848
+ {
1849
+ column: target.meta.name,
1850
+ domain,
1851
+ },
1852
+ );
1853
+ }
1854
+ if (wideningRank(dtype as InferredDtype) < 0) {
1855
+ throw new GraphFormatError("E_COLUMN_TYPE", `${dtype} is not a dtype inference widens to`, {
1856
+ column: target.meta.name,
1857
+ dtype,
1858
+ });
1859
+ }
1860
+ const next = dtype as InferredDtype;
1861
+ if (wideningRank(next) < wideningRank(current)) {
1862
+ throw new GraphFormatError(
1863
+ "E_COLUMN_TYPE",
1864
+ `${domain} column "${target.meta.name}" is ${current}; it cannot narrow to ${next}`,
1865
+ { column: target.meta.name, found: current, expected: next },
1866
+ );
1867
+ }
1868
+ if (next === current) {
1869
+ return;
1870
+ }
1871
+ target.widenTo(next);
1872
+ this.widenings.push({ column: target.meta.name, domain, from: current, to: next });
1873
+ this.mutated();
1874
+ }
1875
+
1876
+ private writeCell(domain: "node" | "edge", column: ColumnHandle | string, row: number, value: unknown): void {
1877
+ const target = this.resolveColumn(domain, column, value);
1878
+ if (target === null) {
1879
+ return;
1880
+ }
1881
+ const widened = target.write(row, this.checkReference(target.meta, value));
1882
+ if (widened !== null) {
1883
+ this.widenings.push({ column: target.meta.name, domain, from: widened.from, to: widened.to });
1884
+ }
1885
+ }
1886
+
1887
+ /**
1888
+ * Replace or create a column from a typed array (setNodeColumn / setEdgeColumn).
1889
+ * @param domain - node or edge
1890
+ * @param rows - the table's current bound
1891
+ * @param name - the column name
1892
+ * @param data - the values
1893
+ * @param decl - the declaration patch
1894
+ */
1895
+ private setBulkColumn(
1896
+ domain: "node" | "edge",
1897
+ rows: number,
1898
+ name: string,
1899
+ data: TypedArrayData,
1900
+ decl: ColumnDeclPatch,
1901
+ ): void {
1902
+ const column = stagingColumnFromTypedArray(domain, rows, name, data, decl, {
1903
+ node: this.staging.nodeBound,
1904
+ edge: this.staging.edgeBound,
1905
+ });
1906
+ const columns = this.columnsOf(domain);
1907
+ const existing = this.columnIndex(columns, name);
1908
+ checkRole(
1909
+ columns.filter((c) => c.meta.name !== name),
1910
+ column.meta,
1911
+ domain,
1912
+ );
1913
+ if (existing >= 0) {
1914
+ columns[existing] = column;
1915
+ } else {
1916
+ this.pushColumn(columns, column);
1917
+ }
1918
+ }
1919
+
1920
+ private extensionOf(handle: ExtensionHandle): ExtensionStaging {
1921
+ const { extensions } = this.staging;
1922
+ if (!Number.isInteger(handle) || handle < 0 || handle >= extensions.length) {
1923
+ throw new GraphFormatError("E_INDEX_RANGE", `no extension table with handle ${handle}`, { handle });
1924
+ }
1925
+ return extensions[handle];
1926
+ }
1927
+
1928
+ /**
1929
+ * Copy a snapshot column into the builder (addGraph): declared under the same metadata when the
1930
+ * name is new, else widened to the union dtype (design section 6.6); every set row is written
1931
+ * through `rowMap`, with refersTo values rewritten through `refs`.
1932
+ * @param domain - node or edge
1933
+ * @param column - the source column
1934
+ * @param rowMap - source row -> builder row
1935
+ * @param refs - source node / edge index -> builder index
1936
+ */
1937
+ private copyColumn(domain: "node" | "edge", column: Column, rowMap: U32, refs: IndexMaps): void {
1938
+ const columns = this.columnsOf(domain);
1939
+ const { name } = column.meta;
1940
+ let index = this.columnIndex(columns, name);
1941
+ if (index < 0) {
1942
+ const decl = { ...metaToDecl(column.meta), name };
1943
+ checkRole(columns, column.meta, domain);
1944
+ index = this.pushColumn(columns, createStagingColumn(name, domain, decl, false));
1945
+ } else {
1946
+ const target = columns[index];
1947
+ if (!sameShape(target.meta, column.meta)) {
1948
+ const union = wider(inferredEquivalent(target.meta.dtype), inferredEquivalent(column.meta.dtype));
1949
+ if (target.meta.dtype !== union) {
1950
+ const from = target.meta.dtype;
1951
+ target.widenTo(union);
1952
+ this.widenings.push({ column: name, domain, from, to: union });
1953
+ }
1954
+ }
1955
+ }
1956
+ const target = columns[index];
1957
+ const valueMap = referenceMap(column.meta, refs);
1958
+ for (let row = 0; row < column.length; row++) {
1959
+ if (!column.isSet(row)) {
1960
+ continue;
1961
+ }
1962
+ let value = column.value(row);
1963
+ if (valueMap !== null) {
1964
+ value = remapReference(value, valueMap);
1965
+ if (value === undefined) {
1966
+ continue;
1967
+ }
1968
+ }
1969
+ target.write(rowMap[row], value);
1970
+ }
1971
+ }
1972
+
1973
+ /**
1974
+ * Append the rows of a snapshot extension table (addGraph): into the table of the same name when
1975
+ * the columns match, else a new table is declared from the source columns.
1976
+ * @param name - the table name
1977
+ * @param table - the source table
1978
+ * @param refs - source node / edge index -> builder index
1979
+ */
1980
+ private appendExtension(
1981
+ name: string,
1982
+ table: Iterable<Column> & { readonly rowCount: number },
1983
+ refs: IndexMaps,
1984
+ ): void {
1985
+ const { staging } = this;
1986
+ const sourceColumns = [...table];
1987
+ let target = staging.extensions.find((t) => t.name === name);
1988
+ if (target === undefined) {
1989
+ const decls = sourceColumns.map(
1990
+ (column) => ({ ...metaToDecl(column.meta), name: column.meta.name }) as ColumnDecl,
1991
+ );
1992
+ const handle = this.addExtensionTable(name, decls);
1993
+ target = staging.extensions[handle];
1994
+ }
1995
+ const targetColumns = target.columns;
1996
+ const first = target.rowCount;
1997
+ for (const column of sourceColumns) {
1998
+ const into = targetColumns.find((c) => c.meta.name === column.meta.name);
1999
+ if (into === undefined) {
2000
+ throw new GraphFormatError(
2001
+ "E_UNKNOWN_COLUMN",
2002
+ `extension table "${name}" has no column "${column.meta.name}"`,
2003
+ { table: name, column: column.meta.name },
2004
+ );
2005
+ }
2006
+ const valueMap = referenceMap(column.meta, refs);
2007
+ for (let row = 0; row < column.length; row++) {
2008
+ if (!column.isSet(row)) {
2009
+ into.ensureLength(first + row + 1);
2010
+ continue;
2011
+ }
2012
+ let value = column.value(row);
2013
+ if (valueMap !== null) {
2014
+ value = remapReference(value, valueMap);
2015
+ if (value === undefined) {
2016
+ into.ensureLength(first + row + 1);
2017
+ continue;
2018
+ }
2019
+ }
2020
+ into.write(first + row, value);
2021
+ }
2022
+ }
2023
+ for (const column of targetColumns) {
2024
+ column.ensureLength(first + table.rowCount);
2025
+ }
2026
+ target.rowCount = first + table.rowCount;
2027
+ }
2028
+ }
2029
+
2030
+ // ============================================================ module helpers
2031
+
2032
+ /**
2033
+ * Enforce "at most one column per role" among staging columns (design section 5.5).
2034
+ * @param columns - the existing columns
2035
+ * @param meta - the column being added
2036
+ * @param where - the table, for the message
2037
+ */
2038
+ function checkRole(columns: readonly StagingColumn[], meta: ColumnMeta, where: string): void {
2039
+ if (meta.role === null) {
2040
+ return;
2041
+ }
2042
+ const holder = columns.find((column) => column.meta.name !== meta.name && column.meta.role === meta.role);
2043
+ if (holder !== undefined) {
2044
+ throw new GraphFormatError(
2045
+ "E_DUPLICATE_ROLE",
2046
+ `role "${meta.role}" is already held by column "${holder.meta.name}" in ${where}`,
2047
+ { role: meta.role, column: meta.name, holder: holder.meta.name },
2048
+ );
2049
+ }
2050
+ }
2051
+
2052
+ /**
2053
+ * The wider of two inferred dtypes in the order of design section 5.1.
2054
+ * @param a - one dtype
2055
+ * @param b - the other
2056
+ * @returns the wider
2057
+ */
2058
+ function wider(a: InferredDtype, b: InferredDtype): InferredDtype {
2059
+ return wideningRank(a) >= wideningRank(b) ? a : b;
2060
+ }
2061
+
2062
+ /**
2063
+ * The index map a refersTo column's values are rewritten through, if any.
2064
+ * @param meta - the column
2065
+ * @param refs - the node and edge maps
2066
+ * @returns the map of the referenced space, or null
2067
+ */
2068
+ function referenceMap(meta: ColumnMeta, refs: IndexMaps): U32 | null {
2069
+ switch (meta.refersTo) {
2070
+ case "node":
2071
+ return refs.node;
2072
+ case "edge":
2073
+ return refs.edge;
2074
+ default:
2075
+ return null;
2076
+ }
2077
+ }
2078
+
2079
+ /**
2080
+ * Rewrite an index-valued cell (a number, or an array of numbers for a list) through an index map;
2081
+ * dangling references are dropped, and a cell with nothing left is unset.
2082
+ * @param value - the cell
2083
+ * @param map - source index -> builder index
2084
+ * @returns the rewritten cell, or undefined to unset
2085
+ */
2086
+ function remapReference(value: unknown, map: U32): unknown {
2087
+ if (typeof value === "number") {
2088
+ return value < map.length ? map[value] : undefined;
2089
+ }
2090
+ if (typeof value === "object" && value !== null) {
2091
+ const items = Array.from(value as ArrayLike<number>);
2092
+ const kept = items.filter((item) => item < map.length).map((item) => map[item]);
2093
+ return kept.length === 0 && items.length > 0 ? undefined : kept;
2094
+ }
2095
+ throw new GraphFormatError("E_COLUMN_TYPE", `a refersTo cell holds a ${typeof value}, not an index or a list`, {
2096
+ found: typeof value,
2097
+ reason: "refersTo",
2098
+ });
2099
+ }
2100
+
2101
+ /**
2102
+ * Build a one-row graph column now to validate a setGraphValue call.
2103
+ * @param name - the column name
2104
+ * @param value - the value
2105
+ * @param decl - the declaration patch
2106
+ */
2107
+ function buildGraphColumn(name: string, value: unknown, decl: ColumnDeclPatch): void {
2108
+ const column = createStagingColumn(
2109
+ name,
2110
+ "graph",
2111
+ decl.dtype === undefined ? { ...decl, dtype: inferInitialDtype(value) } : decl,
2112
+ decl.dtype === undefined,
2113
+ );
2114
+ column.write(0, value);
2115
+ }
2116
+
2117
+ /**
2118
+ * A staging column from a typed array (setNodeColumn / setEdgeColumn, design section 12.2): the
2119
+ * dtype comes from the array class unless the patch names one (bool and dict over a Uint32Array), the
2120
+ * length must be rows * components (bool: ceil(rows / 32) words), and every row is set unless the
2121
+ * patch declares the column nullable.
2122
+ * @param domain - node or edge
2123
+ * @param rows - the row count
2124
+ * @param name - the column name
2125
+ * @param data - the values
2126
+ * @param decl - the declaration patch
2127
+ * @param bounds - the builder's node and edge bounds, for refersTo range checks (E_INDEX_RANGE)
2128
+ * @returns the staging column
2129
+ */
2130
+ function stagingColumnFromTypedArray(
2131
+ domain: ColumnDomain,
2132
+ rows: number,
2133
+ name: string,
2134
+ data: TypedArrayData,
2135
+ decl: ColumnDeclPatch,
2136
+ bounds: IndexBounds,
2137
+ ): StagingColumn {
2138
+ const arrayDtype = dtypeOfArray(data);
2139
+ const dtype = decl.dtype ?? arrayDtype;
2140
+ const compatible = dtype === arrayDtype || (arrayDtype === "u32" && (dtype === "bool" || dtype === "dict"));
2141
+ if (!compatible) {
2142
+ throw new GraphFormatError(
2143
+ "E_COLUMN_TYPE",
2144
+ `column "${name}": a ${arrayDtype} array cannot back a ${dtype} column`,
2145
+ {
2146
+ column: name,
2147
+ field: "dtype",
2148
+ found: arrayDtype,
2149
+ expected: dtype,
2150
+ },
2151
+ );
2152
+ }
2153
+ const column = createStagingColumn(name, domain, { nullable: false, ...decl, dtype }, false);
2154
+ const { components } = column.meta;
2155
+ let expected: number;
2156
+ switch (dtype) {
2157
+ case "bool":
2158
+ expected = Math.ceil(rows / 32);
2159
+ break;
2160
+ case "dict":
2161
+ expected = rows;
2162
+ break;
2163
+ case "f32":
2164
+ case "f64":
2165
+ case "i32":
2166
+ case "u32":
2167
+ case "u8":
2168
+ expected = rows * components;
2169
+ break;
2170
+ default:
2171
+ throw new GraphFormatError(
2172
+ "E_COLUMN_TYPE",
2173
+ `column "${name}": a ${String(dtype)} column cannot adopt a typed array`,
2174
+ {
2175
+ column: name,
2176
+ field: "dtype",
2177
+ },
2178
+ );
2179
+ }
2180
+ if (data.length !== expected) {
2181
+ throw new GraphFormatError(
2182
+ "E_COLUMN_LENGTH",
2183
+ `column "${name}": array length ${data.length}, expected ${expected}`,
2184
+ {
2185
+ column: name,
2186
+ expected,
2187
+ found: data.length,
2188
+ },
2189
+ );
2190
+ }
2191
+ column.ensureLength(rows);
2192
+ switch (dtype) {
2193
+ case "bool": {
2194
+ const words = data as U32;
2195
+ for (let row = 0; row < rows; row++) {
2196
+ column.write(row, ((words[row >>> 5] >>> (row & 31)) & 1) === 1);
2197
+ }
2198
+ break;
2199
+ }
2200
+ case "dict": {
2201
+ const { dictionary } = column;
2202
+ if (dictionary === null) {
2203
+ throw new GraphFormatError("E_COLUMN_TYPE", `column "${name}" has no dictionary`, { column: name });
2204
+ }
2205
+ for (let row = 0; row < rows; row++) {
2206
+ const code = data[row];
2207
+ if (code >= dictionary.size) {
2208
+ throw new GraphFormatError(
2209
+ "E_COLUMN_TYPE",
2210
+ `column "${name}": code ${code} at row ${row} is outside the ${dictionary.size} declared options`,
2211
+ {
2212
+ column: name,
2213
+ row,
2214
+ code,
2215
+ },
2216
+ );
2217
+ }
2218
+ column.write(row, dictionary.values[code]);
2219
+ }
2220
+ break;
2221
+ }
2222
+ default: {
2223
+ const { typed } = column;
2224
+ if (typed === null) {
2225
+ throw new GraphFormatError("E_COLUMN_TYPE", `column "${name}" has no typed storage`, { column: name });
2226
+ }
2227
+ const { refersTo } = column.meta;
2228
+ const bound = refersTo === "node" ? bounds.node : bounds.edge;
2229
+ for (let i = 0; i < data.length; i++) {
2230
+ const value = data[i];
2231
+ if (refersTo !== null && value !== INVALID_INDEX && value >= bound) {
2232
+ throw new GraphFormatError(
2233
+ "E_INDEX_RANGE",
2234
+ `column "${name}" refers to ${refersTo} ${value} at row ${i}, which is not below ${bound}`,
2235
+ { column: name, row: i, refersTo, found: value, bound },
2236
+ );
2237
+ }
2238
+ if (refersTo !== null && value === INVALID_INDEX && column.validity === null) {
2239
+ throw new GraphFormatError(
2240
+ "E_COLUMN_TYPE",
2241
+ `column "${name}": row ${i} of a non-nullable refersTo column holds INVALID_INDEX`,
2242
+ { column: name, row: i, field: "nullable" },
2243
+ );
2244
+ }
2245
+ typed.set(i, value);
2246
+ }
2247
+ if (column.validity !== null) {
2248
+ for (let row = 0; row < rows; row++) {
2249
+ column.validity.set(row, refersTo === null || data[row] !== INVALID_INDEX);
2250
+ }
2251
+ }
2252
+ break;
2253
+ }
2254
+ }
2255
+ return column;
2256
+ }
2257
+
2258
+ /** The current node and edge bounds of a builder, for refersTo range checks. */
2259
+ interface IndexBounds {
2260
+ /** The node index bound. */
2261
+ readonly node: number;
2262
+ /** The edge index bound. */
2263
+ readonly edge: number;
2264
+ }