@c9up/atom 0.1.12 → 0.1.14

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.
package/src/bulk.ts ADDED
@@ -0,0 +1,1054 @@
1
+ /**
2
+ * Bulk — plan a computation in JavaScript, execute it in one crossing.
3
+ *
4
+ * The cost of arithmetic through N-API is not the arithmetic. A crossing costs
5
+ * about 200 ns whatever it carries, and a `Vec<String>` argument costs the same
6
+ * again for every element in it, so a caller that walks a schedule one
7
+ * operation at a time spends nearly all of its time on the boundary. Bulk
8
+ * crosses once: the values go over as a typed array, which is a pointer rather
9
+ * than a walk, and the operations go over beside them as bytecode.
10
+ *
11
+ * What that buys, measured on a 360-row schedule of six operations each: 0.012 ms
12
+ * against 0.806 ms for one native call per operation, and 2.363 ms for the same
13
+ * work through {@link Decimal}.
14
+ *
15
+ * What it does not buy is worth saying as plainly. Below roughly three
16
+ * operations per element, a plain loop over a `BigInt64Array` beats anything
17
+ * that crosses at all. Bulk is for chains of dependent operations — a schedule,
18
+ * a rate solver, a statistic over a column — and not for a lone sum.
19
+ *
20
+ * @example
21
+ * const { net, biggest } = Atom.bulk((b) => {
22
+ * const gross = b.column(quantities).times(b.column(prices))
23
+ * return { net: gross.sum().minus(gross.sum().times('0.0025')), biggest: gross.max() }
24
+ * })
25
+ */
26
+
27
+ import { defaultPrecision } from "./context.js";
28
+ import { Decimal, type DecimalInput } from "./Decimal.js";
29
+ import type { ParsedDecimal } from "./math.js";
30
+ import {
31
+ addTs,
32
+ cmpTs,
33
+ divTs,
34
+ formatDecimal,
35
+ mulTs,
36
+ parseDecimal,
37
+ powTs,
38
+ sqrtTs,
39
+ subTs,
40
+ } from "./math.js";
41
+ import { tryNativeAtom } from "./native.js";
42
+
43
+ const INSTRUCTION_WIDTH = 6;
44
+ const COLUMN_REGISTERS = 16;
45
+ const SCALAR_REGISTERS = 32;
46
+
47
+ /** Kept in step with `crates/atom-engine/src/bulk.rs`. */
48
+ const Op = {
49
+ LoadColumn: 0,
50
+ LoadScalar: 1,
51
+ AddColumns: 10,
52
+ SubColumns: 11,
53
+ MulColumns: 12,
54
+ AddColumnScalar: 13,
55
+ SubColumnScalar: 14,
56
+ MulColumnScalar: 15,
57
+ NegColumn: 16,
58
+ AbsColumn: 17,
59
+ SortColumn: 18,
60
+ SumColumn: 20,
61
+ MinColumn: 21,
62
+ MaxColumn: 22,
63
+ Dot: 23,
64
+ SumSquaredDeviations: 24,
65
+ AtColumn: 25,
66
+ AddScalars: 30,
67
+ SubScalars: 31,
68
+ MulScalars: 32,
69
+ DivScalars: 33,
70
+ NegScalar: 34,
71
+ AbsScalar: 35,
72
+ MinScalars: 36,
73
+ MaxScalars: 37,
74
+ PowScalar: 38,
75
+ SqrtScalar: 39,
76
+ Emit: 40,
77
+ } as const;
78
+
79
+ const I64_MIN = -(2n ** 63n);
80
+ const I64_MAX = 2n ** 63n - 1n;
81
+
82
+ /**
83
+ * One planned operation.
84
+ *
85
+ * The graph this forms is the program. It is compiled to bytecode for the Rust
86
+ * engine, and it is also what the TypeScript executor walks directly — so the
87
+ * two executors share a description rather than each holding an opinion.
88
+ */
89
+ interface PlanNode {
90
+ readonly shape: "column" | "scalar";
91
+ readonly code: number;
92
+ readonly inputs: readonly PlanNode[];
93
+ readonly imm0: number;
94
+ /**
95
+ * Present only on loads: the values this node puts into the buffer, in the
96
+ * one form both executors can start from.
97
+ *
98
+ * Parsed once, when the load is made. Keeping the original strings beside
99
+ * this was how it worked first, and it cost twice: every value was
100
+ * normalised on the way in and parsed again while laying out the buffer.
101
+ * It also left two descriptions of one column free to disagree.
102
+ */
103
+ readonly parsed?: readonly ParsedDecimal[];
104
+ /**
105
+ * Compiler scratch: which compilation last touched this node, and the
106
+ * position it was given in that one. Kept on the node because the
107
+ * alternative — a `Map` keyed by node — was measured as the whole remaining
108
+ * cost of compiling a long chain.
109
+ */
110
+ stamp: number;
111
+ slot: number;
112
+ }
113
+
114
+ /** Distinguishes one compilation's marks from the last one's. */
115
+ let compilation = 0;
116
+
117
+ /** Reads the node behind a public handle. Only this module may. */
118
+ let nodeOf: (value: BulkValue) => PlanNode;
119
+
120
+ /**
121
+ * A value that has been planned but not computed.
122
+ *
123
+ * It carries {@link Decimal}'s method names, deliberately: moving a hot path
124
+ * into bulk should be a change of where the value comes from and nothing else.
125
+ */
126
+ abstract class BulkValue {
127
+ readonly #node: PlanNode;
128
+
129
+ protected constructor(node: PlanNode) {
130
+ this.#node = node;
131
+ }
132
+
133
+ static {
134
+ nodeOf = (value: BulkValue) => value.#node;
135
+ }
136
+
137
+ /**
138
+ * Refused on purpose.
139
+ *
140
+ * Without this, `` `${total}` `` yields "[object Object]" and `total + 1`
141
+ * yields a string — silently, inside a financial calculation. A planned
142
+ * value has no string form until it has been run.
143
+ */
144
+ toString(): never {
145
+ throw new Error(
146
+ "[ATOM_BULK_NOT_RUN] This value has been planned, not computed. Pass it to run() to get a Decimal.",
147
+ );
148
+ }
149
+
150
+ /** Refused for the same reason as {@link toString}. */
151
+ valueOf(): never {
152
+ throw new Error(
153
+ "[ATOM_BULK_NOT_RUN] This value has been planned, not computed. Pass it to run() to get a Decimal.",
154
+ );
155
+ }
156
+
157
+ toJSON(): never {
158
+ return this.toString();
159
+ }
160
+ }
161
+
162
+ function scalar(
163
+ code: number,
164
+ inputs: readonly PlanNode[],
165
+ imm0 = 0,
166
+ ): BulkScalar {
167
+ return new BulkScalar({
168
+ shape: "scalar",
169
+ code,
170
+ inputs,
171
+ imm0,
172
+ stamp: 0,
173
+ slot: -1,
174
+ });
175
+ }
176
+
177
+ function column(
178
+ code: number,
179
+ inputs: readonly PlanNode[],
180
+ imm0 = 0,
181
+ ): BulkColumn {
182
+ return new BulkColumn({
183
+ shape: "column",
184
+ code,
185
+ inputs,
186
+ imm0,
187
+ stamp: 0,
188
+ slot: -1,
189
+ });
190
+ }
191
+
192
+ /** Options accepted wherever a planned division happens. */
193
+ export interface BulkDivOptions {
194
+ precision?: number;
195
+ }
196
+
197
+ /** Options for {@link BulkColumn.stddev}. */
198
+ export interface BulkStddevOptions extends BulkDivOptions {
199
+ sample?: boolean;
200
+ }
201
+
202
+ /** A single planned value. */
203
+ export class BulkScalar extends BulkValue {
204
+ constructor(node: PlanNode) {
205
+ super(node);
206
+ }
207
+
208
+ plus(other: BulkScalar): BulkScalar {
209
+ return scalar(Op.AddScalars, [nodeOf(this), nodeOf(other)]);
210
+ }
211
+
212
+ minus(other: BulkScalar): BulkScalar {
213
+ return scalar(Op.SubScalars, [nodeOf(this), nodeOf(other)]);
214
+ }
215
+
216
+ times(other: BulkScalar): BulkScalar {
217
+ return scalar(Op.MulScalars, [nodeOf(this), nodeOf(other)]);
218
+ }
219
+
220
+ div(other: BulkScalar, options: BulkDivOptions = {}): BulkScalar {
221
+ return scalar(
222
+ Op.DivScalars,
223
+ [nodeOf(this), nodeOf(other)],
224
+ options.precision ?? defaultPrecision(),
225
+ );
226
+ }
227
+
228
+ neg(): BulkScalar {
229
+ return scalar(Op.NegScalar, [nodeOf(this)]);
230
+ }
231
+
232
+ abs(): BulkScalar {
233
+ return scalar(Op.AbsScalar, [nodeOf(this)]);
234
+ }
235
+
236
+ min(other: BulkScalar): BulkScalar {
237
+ return scalar(Op.MinScalars, [nodeOf(this), nodeOf(other)]);
238
+ }
239
+
240
+ max(other: BulkScalar): BulkScalar {
241
+ return scalar(Op.MaxScalars, [nodeOf(this), nodeOf(other)]);
242
+ }
243
+
244
+ /**
245
+ * `this ** exp` for a whole `exp`.
246
+ *
247
+ * A non-negative exponent is exact — scale accumulates and nothing is
248
+ * dropped. A negative exponent has to divide, so it follows the same
249
+ * truncate-toward-zero contract as {@link div} at `precision` digits.
250
+ */
251
+ pow(exp: number, options: BulkDivOptions = {}): BulkScalar {
252
+ if (!Number.isSafeInteger(exp)) {
253
+ throw new Error(`[ATOM_BULK_BAD_EXPONENT] Not a whole exponent: ${exp}`);
254
+ }
255
+ if (exp >= 0) {
256
+ return scalar(Op.PowScalar, [nodeOf(this)], exp);
257
+ }
258
+ // A negative exponent has to divide, so it is compiled as one: the
259
+ // engine's power opcode stays exact by refusing to round.
260
+ const precision = options.precision ?? defaultPrecision();
261
+ return literalScalar("1").div(scalar(Op.PowScalar, [nodeOf(this)], -exp), {
262
+ precision,
263
+ });
264
+ }
265
+
266
+ sqrt(options: BulkDivOptions = {}): BulkScalar {
267
+ return scalar(
268
+ Op.SqrtScalar,
269
+ [nodeOf(this)],
270
+ options.precision ?? defaultPrecision(),
271
+ );
272
+ }
273
+ }
274
+
275
+ /** A planned column of values, all of one length. */
276
+ export class BulkColumn extends BulkValue {
277
+ constructor(node: PlanNode) {
278
+ super(node);
279
+ }
280
+
281
+ plus(other: BulkColumn | BulkScalar): BulkColumn {
282
+ return other instanceof BulkColumn
283
+ ? column(Op.AddColumns, [nodeOf(this), nodeOf(other)])
284
+ : column(Op.AddColumnScalar, [nodeOf(this), nodeOf(other)]);
285
+ }
286
+
287
+ minus(other: BulkColumn | BulkScalar): BulkColumn {
288
+ return other instanceof BulkColumn
289
+ ? column(Op.SubColumns, [nodeOf(this), nodeOf(other)])
290
+ : column(Op.SubColumnScalar, [nodeOf(this), nodeOf(other)]);
291
+ }
292
+
293
+ times(other: BulkColumn | BulkScalar): BulkColumn {
294
+ return other instanceof BulkColumn
295
+ ? column(Op.MulColumns, [nodeOf(this), nodeOf(other)])
296
+ : column(Op.MulColumnScalar, [nodeOf(this), nodeOf(other)]);
297
+ }
298
+
299
+ neg(): BulkColumn {
300
+ return column(Op.NegColumn, [nodeOf(this)]);
301
+ }
302
+
303
+ abs(): BulkColumn {
304
+ return column(Op.AbsColumn, [nodeOf(this)]);
305
+ }
306
+
307
+ sorted(): BulkColumn {
308
+ return column(Op.SortColumn, [nodeOf(this)]);
309
+ }
310
+
311
+ sum(): BulkScalar {
312
+ return scalar(Op.SumColumn, [nodeOf(this)]);
313
+ }
314
+
315
+ min(): BulkScalar {
316
+ return scalar(Op.MinColumn, [nodeOf(this)]);
317
+ }
318
+
319
+ max(): BulkScalar {
320
+ return scalar(Op.MaxColumn, [nodeOf(this)]);
321
+ }
322
+
323
+ /**
324
+ * `Σ aᵢ·bᵢ` in one pass.
325
+ *
326
+ * Fused rather than composed from {@link times} and {@link sum}: the
327
+ * products are never materialised, which measured about four times faster
328
+ * than allocating the intermediate column.
329
+ */
330
+ dot(other: BulkColumn): BulkScalar {
331
+ return scalar(Op.Dot, [nodeOf(this), nodeOf(other)]);
332
+ }
333
+
334
+ at(index: number): BulkScalar {
335
+ // Checked here rather than left to the engine: the length is known while
336
+ // the plan is being written, and the two executors would otherwise
337
+ // refuse the same mistake with two different messages.
338
+ const length = this.#length();
339
+ if (!Number.isSafeInteger(index) || index < 0 || index >= length) {
340
+ throw new Error(
341
+ `[ATOM_BULK_BAD_INDEX] Index ${index} is outside a column of ${length}`,
342
+ );
343
+ }
344
+ return scalar(Op.AtColumn, [nodeOf(this)], index);
345
+ }
346
+
347
+ avg(options: BulkDivOptions = {}): BulkScalar {
348
+ return this.sum().div(literalScalar(String(this.#length())), options);
349
+ }
350
+
351
+ median(options: BulkDivOptions = {}): BulkScalar {
352
+ const length = this.#length();
353
+ const sorted = this.sorted();
354
+ const middle = Math.floor(length / 2);
355
+ if (length % 2 === 1) return sorted.at(middle);
356
+ return sorted
357
+ .at(middle - 1)
358
+ .plus(sorted.at(middle))
359
+ .div(literalScalar("2"), options);
360
+ }
361
+
362
+ /**
363
+ * The value below which `fraction` of the column falls, by nearest rank.
364
+ */
365
+ percentile(fraction: number): BulkScalar {
366
+ if (!(fraction >= 0 && fraction <= 1)) {
367
+ throw new Error(
368
+ `[ATOM_BULK_BAD_PERCENTILE] Expected a fraction between 0 and 1, got ${fraction}`,
369
+ );
370
+ }
371
+ const length = this.#length();
372
+ const rank = Math.min(length - 1, Math.ceil(fraction * length) - 1);
373
+ return this.sorted().at(Math.max(0, rank));
374
+ }
375
+
376
+ stddev(options: BulkStddevOptions = {}): BulkScalar {
377
+ const length = this.#length();
378
+ const divisor = options.sample === true ? length - 1 : length;
379
+ if (divisor <= 0) {
380
+ throw new Error(
381
+ "[ATOM_BULK_TOO_SHORT] A sample standard deviation needs at least two values",
382
+ );
383
+ }
384
+ const precision = options.precision ?? defaultPrecision();
385
+ const mean = this.avg({ precision: precision + 8 });
386
+ const squares = scalar(Op.SumSquaredDeviations, [
387
+ nodeOf(this),
388
+ nodeOf(mean),
389
+ ]);
390
+ const variance = squares.div(literalScalar(String(divisor)), {
391
+ precision: precision + 8,
392
+ });
393
+ return variance.sqrt({ precision });
394
+ }
395
+
396
+ /** Every column in one program shares a length; loads carry it. */
397
+ #length(): number {
398
+ const seen = (node: PlanNode): number => {
399
+ if (node.parsed !== undefined) return node.parsed.length;
400
+ for (const input of node.inputs) {
401
+ if (input.shape === "column") return seen(input);
402
+ }
403
+ throw new Error("[ATOM_BULK_NO_LENGTH] A column with no source");
404
+ };
405
+ return seen(nodeOf(this));
406
+ }
407
+ }
408
+
409
+ /**
410
+ * A value on its way into a column, in the one form the plan keeps.
411
+ *
412
+ * A whole number skips the decimal machinery entirely: routing it through a
413
+ * `Decimal` and back out as a string, only to parse that string again, made an
414
+ * integer column twice as expensive as the same column written as text — the
415
+ * opposite of what a caller holding integers has any reason to expect.
416
+ */
417
+ function parseInput(value: DecimalInput): ParsedDecimal {
418
+ if (typeof value === "bigint") return { int: value, scale: 0 };
419
+ if (typeof value === "number" && Number.isSafeInteger(value)) {
420
+ return { int: BigInt(value), scale: 0 };
421
+ }
422
+ if (typeof value === "string") return parseDecimal(value);
423
+ return parseDecimal(Decimal.from(value).toString());
424
+ }
425
+
426
+ /** A whole number, however it was written. */
427
+ function toInteger(value: string | number | bigint): bigint {
428
+ if (typeof value === "bigint") return value;
429
+ if (typeof value === "number") {
430
+ if (!Number.isSafeInteger(value)) {
431
+ throw new Error(
432
+ `[ATOM_BULK_NOT_MINOR_UNITS] Minor units must be whole and exact, got ${value}`,
433
+ );
434
+ }
435
+ return BigInt(value);
436
+ }
437
+ const parsed = parseDecimal(value);
438
+ if (parsed.scale !== 0) {
439
+ throw new Error(
440
+ `[ATOM_BULK_NOT_MINOR_UNITS] Minor units must be whole, got ${value}`,
441
+ );
442
+ }
443
+ return parsed.int;
444
+ }
445
+
446
+ function literalScalar(value: string): BulkScalar {
447
+ return new BulkScalar({
448
+ shape: "scalar",
449
+ code: Op.LoadScalar,
450
+ inputs: [],
451
+ imm0: 0,
452
+ parsed: [parseDecimal(value)],
453
+ stamp: 0,
454
+ slot: -1,
455
+ });
456
+ }
457
+
458
+ /**
459
+ * A planned computation.
460
+ *
461
+ * Holds the values it was given as a typed array and the operations as a graph.
462
+ * Nothing is computed until {@link run}, and a `Bulk` can be run more than once:
463
+ * the buffer is built once and re-passed, which costs nothing, because handing a
464
+ * typed array across the boundary is a pointer rather than a walk.
465
+ */
466
+ export class Bulk {
467
+ readonly #columns: ParsedDecimal[][] = [];
468
+ #rows: number | undefined;
469
+
470
+ /**
471
+ * Take a column of values into the plan.
472
+ *
473
+ * Every column in one plan has to be the same length: a program over columns
474
+ * of different lengths is a caller's bug, and computing over the shorter one
475
+ * would return a plausible figure for data nobody has.
476
+ */
477
+ column(values: Iterable<DecimalInput>): BulkColumn {
478
+ return this.#load([...values].map(parseInput));
479
+ }
480
+
481
+ /**
482
+ * Take a column that is already held as minor units.
483
+ *
484
+ * The counterpart of {@link Decimal.fromMinorUnits}, and the fast lane into
485
+ * a plan: `minorUnits([1234n, 99n], 2)` is 12.34 and 0.99. An integer needs
486
+ * no parsing and no normalising, so a column of them reaches the engine at
487
+ * about a sixteenth of what the same column costs as decimal strings —
488
+ * which is the shape money is usually stored in anyway.
489
+ */
490
+ minorUnits(
491
+ values: Iterable<string | number | bigint>,
492
+ scale: number,
493
+ ): BulkColumn {
494
+ // Mirrors Decimal's own bound, which is not exported.
495
+ if (!Number.isInteger(scale) || scale < 0 || scale > 10_000) {
496
+ throw new Error(`[ATOM_BULK_BAD_SCALE] Invalid scale: ${scale}`);
497
+ }
498
+ return this.#load(
499
+ [...values].map((value) => ({ int: toInteger(value), scale })),
500
+ );
501
+ }
502
+
503
+ #load(parsed: ParsedDecimal[]): BulkColumn {
504
+ if (this.#rows === undefined) {
505
+ this.#rows = parsed.length;
506
+ } else if (this.#rows !== parsed.length) {
507
+ throw new Error(
508
+ `[ATOM_BULK_LENGTH_MISMATCH] This plan holds columns of ${this.#rows} values; got ${parsed.length}`,
509
+ );
510
+ }
511
+ if (parsed.length === 0) {
512
+ throw new Error("[ATOM_BULK_EMPTY_COLUMN] A column needs a value");
513
+ }
514
+ this.#columns.push(parsed);
515
+ return new BulkColumn({
516
+ shape: "column",
517
+ code: Op.LoadColumn,
518
+ inputs: [],
519
+ imm0: 0,
520
+ parsed,
521
+ stamp: 0,
522
+ slot: -1,
523
+ });
524
+ }
525
+
526
+ /** Take a single value into the plan. */
527
+ of(value: DecimalInput): BulkScalar {
528
+ return literalScalar(Decimal.from(value).toString());
529
+ }
530
+
531
+ /**
532
+ * Compute the requested values.
533
+ *
534
+ * Runs on the Rust engine when one is loaded and every value fits its 128-bit
535
+ * working width; otherwise, and on an overflow reported mid-run, the same
536
+ * plan is evaluated on the BigInt executor, which has no ceiling. The fast
537
+ * path is allowed to be too narrow. It is never allowed to be wrong.
538
+ */
539
+ run<T extends Readonly<Record<string, BulkScalar>>>(
540
+ outputs: T,
541
+ ): { [K in keyof T]: Decimal } {
542
+ const keys = Object.keys(outputs);
543
+ if (keys.length === 0) {
544
+ throw new Error("[ATOM_BULK_NO_OUTPUT] run() needs a value to compute");
545
+ }
546
+ const roots = keys.map((key) => {
547
+ const value = outputs[key];
548
+ if (!(value instanceof BulkScalar)) {
549
+ throw new Error(
550
+ `[ATOM_BULK_BAD_OUTPUT] "${key}" is not a planned value`,
551
+ );
552
+ }
553
+ return nodeOf(value);
554
+ });
555
+
556
+ const results = this.#execute(roots);
557
+ const out: Record<string, Decimal> = {};
558
+ keys.forEach((key, index) => {
559
+ const value = results[index];
560
+ if (value === undefined) {
561
+ throw new Error("[ATOM_BULK_LOST_OUTPUT] The run lost a value");
562
+ }
563
+ out[key] = new Decimal(value);
564
+ });
565
+ return out as { [K in keyof T]: Decimal };
566
+ }
567
+
568
+ #execute(roots: readonly PlanNode[]): string[] {
569
+ const native = tryNativeAtom();
570
+ if (native !== undefined) {
571
+ // A plan with no column has no rows, and is still a plan: a chain of
572
+ // scalar steps is what bulk is best at, so it must not be the one
573
+ // shape that never crosses.
574
+ const rows = this.#rows ?? 0;
575
+ const compiled = compile(roots, rows);
576
+ if (compiled !== undefined) {
577
+ try {
578
+ return native.runBulk(compiled.values, rows, compiled.program);
579
+ } catch (error) {
580
+ if (!isOverflow(error)) throw error;
581
+ }
582
+ }
583
+ }
584
+ return roots.map((root) => evaluateScalar(root, new Map()));
585
+ }
586
+ }
587
+
588
+ function isOverflow(error: unknown): boolean {
589
+ return (
590
+ error instanceof Error && error.message.includes("[ATOM_BULK_OVERFLOW]")
591
+ );
592
+ }
593
+
594
+ interface Compiled {
595
+ readonly values: BigInt64Array;
596
+ readonly program: Int32Array;
597
+ }
598
+
599
+ /**
600
+ * Lay the plan out as a buffer and a program.
601
+ *
602
+ * Returns `undefined` when a value will not fit the engine's 64-bit transport
603
+ * width, which is the signal to take the BigInt executor instead. Deciding it
604
+ * here, before the crossing, keeps the decision cheap and keeps the engine from
605
+ * having to guess what the caller wanted.
606
+ *
607
+ * This runs once per call to `run`, over every operation in the plan, so its
608
+ * own cost is part of what bulk costs. Measured on a 2 160 step schedule, a
609
+ * first version built on four `Map`s and a recursive walk took 1.34 ms — a
610
+ * hundred times the engine it was feeding. Hence the shape here: one `Map` to
611
+ * number the nodes, typed arrays for everything indexed by that number, and an
612
+ * explicit stack, which also removes a recursion that would have overflowed on
613
+ * a long enough chain.
614
+ */
615
+ function compile(
616
+ roots: readonly PlanNode[],
617
+ rows: number,
618
+ ): Compiled | undefined {
619
+ // Topological order, iteratively: a node is emitted once every input of it
620
+ // has been. `stamp` says whether a node was placed by THIS compilation, so
621
+ // a plan can be compiled again without clearing anything first.
622
+ compilation += 1;
623
+ const mark = compilation;
624
+ const placed = (node: PlanNode): boolean => node.stamp === mark;
625
+ const order: PlanNode[] = [];
626
+ const stack: PlanNode[] = [];
627
+ for (let i = roots.length - 1; i >= 0; i--) {
628
+ const root = roots[i];
629
+ if (root !== undefined) stack.push(root);
630
+ }
631
+ while (stack.length > 0) {
632
+ const node = stack.pop();
633
+ if (node === undefined || placed(node)) continue;
634
+ let ready = true;
635
+ for (const input of node.inputs) {
636
+ if (!placed(input)) {
637
+ ready = false;
638
+ break;
639
+ }
640
+ }
641
+ if (ready) {
642
+ node.stamp = mark;
643
+ node.slot = order.length;
644
+ order.push(node);
645
+ continue;
646
+ }
647
+ stack.push(node);
648
+ for (const input of node.inputs) {
649
+ if (!placed(input)) stack.push(input);
650
+ }
651
+ }
652
+
653
+ // How many times each result is still needed. A register goes back on the
654
+ // free list the moment its last reader has been emitted.
655
+ const remaining = new Int32Array(order.length);
656
+ for (const node of order) {
657
+ for (const input of node.inputs) {
658
+ remaining[input.slot] = (remaining[input.slot] ?? 0) + 1;
659
+ }
660
+ }
661
+ for (const root of roots) {
662
+ remaining[root.slot] = (remaining[root.slot] ?? 0) + 1;
663
+ }
664
+
665
+ // Columns first and contiguous, literals after: the engine addresses a
666
+ // column by its ordinal and a literal by its absolute index.
667
+ const columnLoads: PlanNode[] = [];
668
+ const scalarLoads: PlanNode[] = [];
669
+ for (const node of order) {
670
+ if (node.parsed === undefined) continue;
671
+ if (node.code === Op.LoadColumn) columnLoads.push(node);
672
+ else if (node.code === Op.LoadScalar) scalarLoads.push(node);
673
+ }
674
+
675
+ const addresses = new Int32Array(order.length);
676
+ const loadScales = new Int32Array(order.length);
677
+ const buffer = new BigInt64Array(
678
+ columnLoads.length * rows + scalarLoads.length,
679
+ );
680
+ let cursor = 0;
681
+ for (const load of columnLoads) {
682
+ const next = fill(load, buffer, cursor, addresses, loadScales);
683
+ if (next === undefined) return undefined;
684
+ // A column is addressed by its ordinal, not by its offset.
685
+ addresses[load.slot] = cursor / rows;
686
+ cursor = next;
687
+ }
688
+ for (const load of scalarLoads) {
689
+ const next = fill(load, buffer, cursor, addresses, loadScales);
690
+ if (next === undefined) return undefined;
691
+ cursor = next;
692
+ }
693
+
694
+ const program = new Int32Array(
695
+ (order.length + roots.length) * INSTRUCTION_WIDTH,
696
+ );
697
+ const columnRegisters = new FreeList(COLUMN_REGISTERS);
698
+ const scalarRegisters = new FreeList(SCALAR_REGISTERS);
699
+ const assigned = new Int32Array(order.length);
700
+ let write = 0;
701
+
702
+ for (let position = 0; position < order.length; position += 1) {
703
+ const node = order[position];
704
+ if (node === undefined) return undefined;
705
+ const first = node.inputs[0];
706
+ const second = node.inputs[1];
707
+ const firstSlot = first === undefined ? -1 : first.slot;
708
+ const secondSlot = second === undefined ? -1 : second.slot;
709
+ const a = firstSlot < 0 ? 0 : assigned[firstSlot];
710
+ const b = secondSlot < 0 ? 0 : assigned[secondSlot];
711
+
712
+ // Inputs are read before the destination is written, so a register this
713
+ // instruction frees can be the one it writes to.
714
+ if (firstSlot >= 0)
715
+ release(
716
+ firstSlot,
717
+ order,
718
+ remaining,
719
+ assigned,
720
+ columnRegisters,
721
+ scalarRegisters,
722
+ );
723
+ if (secondSlot >= 0)
724
+ release(
725
+ secondSlot,
726
+ order,
727
+ remaining,
728
+ assigned,
729
+ columnRegisters,
730
+ scalarRegisters,
731
+ );
732
+
733
+ const list = node.shape === "column" ? columnRegisters : scalarRegisters;
734
+ const destination = list.take();
735
+ if (destination === undefined) return undefined;
736
+ assigned[position] = destination;
737
+
738
+ const isLoad = node.code === Op.LoadColumn || node.code === Op.LoadScalar;
739
+ program[write] = node.code;
740
+ program[write + 1] = destination;
741
+ program[write + 2] = isLoad ? (addresses[position] ?? 0) : (a ?? 0);
742
+ program[write + 3] = isLoad ? 0 : (b ?? 0);
743
+ program[write + 4] = isLoad ? (loadScales[position] ?? 0) : node.imm0;
744
+ program[write + 5] = 0;
745
+ write += INSTRUCTION_WIDTH;
746
+ }
747
+
748
+ for (const root of roots) {
749
+ program[write] = Op.Emit;
750
+ program[write + 2] = assigned[root.slot] ?? 0;
751
+ write += INSTRUCTION_WIDTH;
752
+ }
753
+
754
+ return { values: buffer, program };
755
+ }
756
+
757
+ /**
758
+ * Put one load's values into the buffer at their common scale.
759
+ *
760
+ * The scale factor is computed per distinct scale rather than per value: a
761
+ * `10n ** BigInt(n)` for every element of a large column is most of what
762
+ * laying out the buffer costs.
763
+ */
764
+ function fill(
765
+ load: PlanNode,
766
+ buffer: BigInt64Array,
767
+ start: number,
768
+ addresses: Int32Array,
769
+ scales: Int32Array,
770
+ ): number | undefined {
771
+ const parsed = load.parsed ?? [];
772
+ let scale = 0;
773
+ for (const value of parsed) scale = Math.max(scale, value.scale);
774
+ scales[load.slot] = scale;
775
+ addresses[load.slot] = start;
776
+
777
+ let cursor = start;
778
+
779
+ // A column whose values all share one scale — every column out of
780
+ // `minorUnits`, and most out of a database — needs no lifting at all. Worth
781
+ // the branch: the general path costs a map lookup and a bigint multiply on
782
+ // every value, to multiply by one.
783
+ let uniform = true;
784
+ for (const value of parsed) {
785
+ if (value.scale !== scale) {
786
+ uniform = false;
787
+ break;
788
+ }
789
+ }
790
+ if (uniform) {
791
+ for (const value of parsed) {
792
+ if (value.int < I64_MIN || value.int > I64_MAX) return undefined;
793
+ buffer[cursor] = value.int;
794
+ cursor += 1;
795
+ }
796
+ return cursor;
797
+ }
798
+
799
+ const factors = new Map<number, bigint>();
800
+ for (const value of parsed) {
801
+ const shift = scale - value.scale;
802
+ let factor = factors.get(shift);
803
+ if (factor === undefined) {
804
+ factor = 10n ** BigInt(shift);
805
+ factors.set(shift, factor);
806
+ }
807
+ const lifted = value.int * factor;
808
+ if (lifted < I64_MIN || lifted > I64_MAX) return undefined;
809
+ buffer[cursor] = lifted;
810
+ cursor += 1;
811
+ }
812
+ return cursor;
813
+ }
814
+
815
+ function release(
816
+ slot: number,
817
+ order: readonly PlanNode[],
818
+ remaining: Int32Array,
819
+ assigned: Int32Array,
820
+ columns: FreeList,
821
+ scalars: FreeList,
822
+ ): void {
823
+ const left = (remaining[slot] ?? 0) - 1;
824
+ remaining[slot] = left;
825
+ if (left > 0) return;
826
+ const node = order[slot];
827
+ const register = assigned[slot];
828
+ if (node === undefined || register === undefined) return;
829
+ (node.shape === "column" ? columns : scalars).give(register);
830
+ }
831
+
832
+ /** Hands out registers and takes them back. Order does not matter, so it is a stack. */
833
+ class FreeList {
834
+ readonly #free: number[];
835
+
836
+ constructor(size: number) {
837
+ this.#free = Array.from({ length: size }, (_, position) => position);
838
+ }
839
+
840
+ take(): number | undefined {
841
+ return this.#free.pop();
842
+ }
843
+
844
+ give(register: number): void {
845
+ this.#free.push(register);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * The BigInt executor.
851
+ *
852
+ * Walks the same graph the compiler lays out, using the same helpers
853
+ * {@link Decimal} uses, so the two executors agree by sharing their arithmetic
854
+ * rather than by each restating it. It has no width limit, which is what makes
855
+ * it the answer to an overflow rather than a second opinion.
856
+ */
857
+ function evaluateScalar(node: PlanNode, memo: Map<PlanNode, unknown>): string {
858
+ const cached = memo.get(node);
859
+ if (typeof cached === "string") return cached;
860
+ const value = computeScalar(node, memo);
861
+ memo.set(node, value);
862
+ return value;
863
+ }
864
+
865
+ function evaluateColumn(
866
+ node: PlanNode,
867
+ memo: Map<PlanNode, unknown>,
868
+ ): string[] {
869
+ const cached = memo.get(node);
870
+ if (Array.isArray(cached)) return cached;
871
+ const value = computeColumn(node, memo);
872
+ memo.set(node, value);
873
+ return value;
874
+ }
875
+
876
+ function computeScalar(node: PlanNode, memo: Map<PlanNode, unknown>): string {
877
+ const scalarAt = (index: number): string => {
878
+ const input = node.inputs[index];
879
+ if (input === undefined) {
880
+ throw new Error("[ATOM_BULK_BAD_PROGRAM] A missing operand");
881
+ }
882
+ return evaluateScalar(input, memo);
883
+ };
884
+ const columnAt = (index: number): string[] => {
885
+ const input = node.inputs[index];
886
+ if (input === undefined) {
887
+ throw new Error("[ATOM_BULK_BAD_PROGRAM] A missing operand");
888
+ }
889
+ return evaluateColumn(input, memo);
890
+ };
891
+
892
+ switch (node.code) {
893
+ case Op.LoadScalar: {
894
+ const value = node.parsed?.[0];
895
+ if (value === undefined) {
896
+ throw new Error("[ATOM_BULK_BAD_PROGRAM] A load with no value");
897
+ }
898
+ return formatDecimal(value.int, value.scale);
899
+ }
900
+ case Op.AddScalars:
901
+ return addTs(scalarAt(0), scalarAt(1));
902
+ case Op.SubScalars:
903
+ return subTs(scalarAt(0), scalarAt(1));
904
+ case Op.MulScalars:
905
+ return mulTs(scalarAt(0), scalarAt(1));
906
+ case Op.DivScalars:
907
+ return divTs(scalarAt(0), scalarAt(1), node.imm0);
908
+ case Op.NegScalar:
909
+ return subTs("0", scalarAt(0));
910
+ case Op.AbsScalar: {
911
+ const value = scalarAt(0);
912
+ return cmpTs(value, "0") < 0 ? subTs("0", value) : value;
913
+ }
914
+ case Op.MinScalars: {
915
+ const [left, right] = [scalarAt(0), scalarAt(1)];
916
+ return cmpTs(left, right) <= 0 ? left : right;
917
+ }
918
+ case Op.MaxScalars: {
919
+ const [left, right] = [scalarAt(0), scalarAt(1)];
920
+ return cmpTs(left, right) >= 0 ? left : right;
921
+ }
922
+ case Op.PowScalar:
923
+ return powTs(scalarAt(0), node.imm0, 0);
924
+ case Op.SqrtScalar:
925
+ return sqrtTs(scalarAt(0), node.imm0);
926
+ case Op.SumColumn:
927
+ return columnAt(0).reduce((total, value) => addTs(total, value), "0");
928
+ case Op.MinColumn:
929
+ return columnAt(0).reduce((best, value) =>
930
+ cmpTs(value, best) < 0 ? value : best,
931
+ );
932
+ case Op.MaxColumn:
933
+ return columnAt(0).reduce((best, value) =>
934
+ cmpTs(value, best) > 0 ? value : best,
935
+ );
936
+ case Op.Dot: {
937
+ const [left, right] = [columnAt(0), columnAt(1)];
938
+ return left.reduce(
939
+ (total, value, index) =>
940
+ addTs(total, mulTs(value, right[index] ?? "0")),
941
+ "0",
942
+ );
943
+ }
944
+ case Op.SumSquaredDeviations: {
945
+ const values = columnAt(0);
946
+ const mean = scalarAt(1);
947
+ return values.reduce((total, value) => {
948
+ const deviation = subTs(value, mean);
949
+ return addTs(total, mulTs(deviation, deviation));
950
+ }, "0");
951
+ }
952
+ case Op.AtColumn: {
953
+ const value = columnAt(0)[node.imm0];
954
+ if (value === undefined) {
955
+ throw new Error(
956
+ `[ATOM_BULK_BAD_INDEX] Index ${node.imm0} is outside the column`,
957
+ );
958
+ }
959
+ return value;
960
+ }
961
+ default:
962
+ throw new Error(
963
+ `[ATOM_BULK_BAD_PROGRAM] Opcode ${node.code} does not produce a value`,
964
+ );
965
+ }
966
+ }
967
+
968
+ function computeColumn(node: PlanNode, memo: Map<PlanNode, unknown>): string[] {
969
+ const columnAt = (index: number): string[] => {
970
+ const input = node.inputs[index];
971
+ if (input === undefined) {
972
+ throw new Error("[ATOM_BULK_BAD_PROGRAM] A missing operand");
973
+ }
974
+ return evaluateColumn(input, memo);
975
+ };
976
+ const scalarAt = (index: number): string => {
977
+ const input = node.inputs[index];
978
+ if (input === undefined) {
979
+ throw new Error("[ATOM_BULK_BAD_PROGRAM] A missing operand");
980
+ }
981
+ return evaluateScalar(input, memo);
982
+ };
983
+
984
+ switch (node.code) {
985
+ case Op.LoadColumn:
986
+ // Rendered here rather than kept alongside: this executor is the
987
+ // path not taken, and a column of strings nobody reads is a column
988
+ // of strings nobody should have built.
989
+ return (node.parsed ?? []).map((value) =>
990
+ formatDecimal(value.int, value.scale),
991
+ );
992
+ case Op.AddColumns: {
993
+ const right = columnAt(1);
994
+ return columnAt(0).map((value, index) =>
995
+ addTs(value, right[index] ?? "0"),
996
+ );
997
+ }
998
+ case Op.SubColumns: {
999
+ const right = columnAt(1);
1000
+ return columnAt(0).map((value, index) =>
1001
+ subTs(value, right[index] ?? "0"),
1002
+ );
1003
+ }
1004
+ case Op.MulColumns: {
1005
+ const right = columnAt(1);
1006
+ return columnAt(0).map((value, index) =>
1007
+ mulTs(value, right[index] ?? "0"),
1008
+ );
1009
+ }
1010
+ case Op.AddColumnScalar: {
1011
+ const other = scalarAt(1);
1012
+ return columnAt(0).map((value) => addTs(value, other));
1013
+ }
1014
+ case Op.SubColumnScalar: {
1015
+ const other = scalarAt(1);
1016
+ return columnAt(0).map((value) => subTs(value, other));
1017
+ }
1018
+ case Op.MulColumnScalar: {
1019
+ const other = scalarAt(1);
1020
+ return columnAt(0).map((value) => mulTs(value, other));
1021
+ }
1022
+ case Op.NegColumn:
1023
+ return columnAt(0).map((value) => subTs("0", value));
1024
+ case Op.AbsColumn:
1025
+ return columnAt(0).map((value) =>
1026
+ cmpTs(value, "0") < 0 ? subTs("0", value) : value,
1027
+ );
1028
+ case Op.SortColumn:
1029
+ return [...columnAt(0)].sort((a, b) => cmpTs(a, b));
1030
+ default:
1031
+ throw new Error(
1032
+ `[ATOM_BULK_BAD_PROGRAM] Opcode ${node.code} does not produce a column`,
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Plan a computation and run it.
1039
+ *
1040
+ * The callback form runs once and hands back the results. Call it without a
1041
+ * callback to keep the plan and run it more than once — the values are taken in
1042
+ * once and re-passed at no cost.
1043
+ */
1044
+ export function bulk<T extends Readonly<Record<string, BulkScalar>>>(
1045
+ build: (b: Bulk) => T,
1046
+ ): { [K in keyof T]: Decimal };
1047
+ export function bulk(): Bulk;
1048
+ export function bulk<T extends Readonly<Record<string, BulkScalar>>>(
1049
+ build?: (b: Bulk) => T,
1050
+ ): Bulk | { [K in keyof T]: Decimal } {
1051
+ const plan = new Bulk();
1052
+ if (build === undefined) return plan;
1053
+ return plan.run(build(plan));
1054
+ }