@elaraai/e3 1.0.78 → 1.0.80
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/README.md +11 -2
- package/dist/src/browser.d.ts +5 -4
- package/dist/src/browser.d.ts.map +1 -1
- package/dist/src/browser.js +3 -2
- package/dist/src/browser.js.map +1 -1
- package/dist/src/dataset-file.d.ts +17 -19
- package/dist/src/dataset-file.d.ts.map +1 -1
- package/dist/src/dataset-file.js +26 -45
- package/dist/src/dataset-file.js.map +1 -1
- package/dist/src/export.d.ts +2 -3
- package/dist/src/export.d.ts.map +1 -1
- package/dist/src/export.js +150 -80
- package/dist/src/export.js.map +1 -1
- package/dist/src/export.spec.js +177 -57
- package/dist/src/export.spec.js.map +1 -1
- package/dist/src/function.spec.js +29 -58
- package/dist/src/function.spec.js.map +1 -1
- package/dist/src/functions-link.spec.js +19 -17
- package/dist/src/functions-link.spec.js.map +1 -1
- package/dist/src/index.d.ts +56 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +11 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/input.d.ts +8 -5
- package/dist/src/input.d.ts.map +1 -1
- package/dist/src/input.js +8 -5
- package/dist/src/input.js.map +1 -1
- package/dist/src/migration.d.ts +172 -0
- package/dist/src/migration.d.ts.map +1 -0
- package/dist/src/migration.js +108 -0
- package/dist/src/migration.js.map +1 -0
- package/dist/src/migration.spec.d.ts +6 -0
- package/dist/src/migration.spec.d.ts.map +1 -0
- package/dist/src/migration.spec.js +197 -0
- package/dist/src/migration.spec.js.map +1 -0
- package/dist/src/mutation-programs.spec.d.ts +6 -0
- package/dist/src/mutation-programs.spec.d.ts.map +1 -0
- package/dist/src/mutation-programs.spec.js +310 -0
- package/dist/src/mutation-programs.spec.js.map +1 -0
- package/dist/src/mutation.d.ts +117 -9
- package/dist/src/mutation.d.ts.map +1 -1
- package/dist/src/mutation.js +104 -46
- package/dist/src/mutation.js.map +1 -1
- package/dist/src/open-beast-link.spec.js +10 -8
- package/dist/src/open-beast-link.spec.js.map +1 -1
- package/dist/src/output.d.ts +117 -0
- package/dist/src/output.d.ts.map +1 -0
- package/dist/src/output.js +119 -0
- package/dist/src/output.js.map +1 -0
- package/dist/src/package.d.ts +7 -5
- package/dist/src/package.d.ts.map +1 -1
- package/dist/src/package.js +98 -8
- package/dist/src/package.js.map +1 -1
- package/dist/src/package.spec.d.ts +6 -0
- package/dist/src/package.spec.d.ts.map +1 -0
- package/dist/src/package.spec.js +16 -0
- package/dist/src/package.spec.js.map +1 -0
- package/dist/src/record-guards.d.ts +41 -0
- package/dist/src/record-guards.d.ts.map +1 -0
- package/dist/src/record-guards.js +44 -0
- package/dist/src/record-guards.js.map +1 -0
- package/dist/src/record-index.d.ts +129 -0
- package/dist/src/record-index.d.ts.map +1 -0
- package/dist/src/record-index.js +144 -0
- package/dist/src/record-index.js.map +1 -0
- package/dist/src/record-index.spec.d.ts +6 -0
- package/dist/src/record-index.spec.d.ts.map +1 -0
- package/dist/src/record-index.spec.js +160 -0
- package/dist/src/record-index.spec.js.map +1 -0
- package/dist/src/record-programs.d.ts +128 -0
- package/dist/src/record-programs.d.ts.map +1 -0
- package/dist/src/record-programs.js +480 -0
- package/dist/src/record-programs.js.map +1 -0
- package/dist/src/record.d.ts.map +1 -1
- package/dist/src/record.js +2 -0
- package/dist/src/record.js.map +1 -1
- package/dist/src/record.spec.js +71 -13
- package/dist/src/record.spec.js.map +1 -1
- package/dist/src/runner.d.ts +6 -9
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +9 -25
- package/dist/src/runner.js.map +1 -1
- package/dist/src/runner.spec.js +16 -34
- package/dist/src/runner.spec.js.map +1 -1
- package/dist/src/sha256.d.ts +4 -3
- package/dist/src/sha256.d.ts.map +1 -1
- package/dist/src/sha256.js +20 -17
- package/dist/src/sha256.js.map +1 -1
- package/dist/src/sha256.spec.js +10 -0
- package/dist/src/sha256.spec.js.map +1 -1
- package/dist/src/task.d.ts +116 -251
- package/dist/src/task.d.ts.map +1 -1
- package/dist/src/task.js +169 -435
- package/dist/src/task.js.map +1 -1
- package/dist/src/task.spec.js +174 -489
- package/dist/src/task.spec.js.map +1 -1
- package/dist/src/types.d.ts +215 -23
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/src/task.js
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Copyright (c) 2025 Elara AI Pty Ltd
|
|
3
3
|
* Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
|
|
4
4
|
*/
|
|
5
|
-
import { Expr, variant,
|
|
6
|
-
import {
|
|
7
|
-
import { DEFAULT_RUNNER, runnerToCommand, runnerToVariant } from './runner.js';
|
|
5
|
+
import { Expr, variant, ArrayType, StringType, NullType, East, isTypeValueEqual, printType, toEastTypeValue } from '@elaraai/east';
|
|
6
|
+
import { DEFAULT_RUNNER } from './runner.js';
|
|
8
7
|
import { validateEnvironmentDecl } from './environment.js';
|
|
9
8
|
/**
|
|
10
9
|
* Singleton tree definition for `.tasks`.
|
|
@@ -31,29 +30,6 @@ function createTaskTree(name) {
|
|
|
31
30
|
deps: new Set([tasksTree]),
|
|
32
31
|
};
|
|
33
32
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Creates a private IR dataset for a task at `.tasks.${name}.${field}`: the
|
|
36
|
-
* task's `function_ir`, or a stream task's `merge_ir`.
|
|
37
|
-
*
|
|
38
|
-
* @param name - Task name
|
|
39
|
-
* @param taskTree - The task's subtree
|
|
40
|
-
* @param field - The dataset's name within the task's subtree
|
|
41
|
-
* @param eastIR - The compiled function IR
|
|
42
|
-
* @returns A DatasetDef for the IR (private, not typed)
|
|
43
|
-
*/
|
|
44
|
-
function createIRDataset(name, taskTree, field, eastIR) {
|
|
45
|
-
return {
|
|
46
|
-
kind: 'dataset',
|
|
47
|
-
name: field,
|
|
48
|
-
path: [variant('field', 'tasks'), variant('field', name), variant('field', field)],
|
|
49
|
-
type: IRType,
|
|
50
|
-
// Store the full EastIR/AsyncEastIR bundle so export.ts can use encodeEastIR
|
|
51
|
-
// and preserve source_map into the beast2 blob.
|
|
52
|
-
default: eastIR,
|
|
53
|
-
writable: false,
|
|
54
|
-
deps: new Set([...taskTree.deps, taskTree]),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
33
|
/**
|
|
58
34
|
* Creates an output dataset for a task at `.tasks.${name}.output`.
|
|
59
35
|
*
|
|
@@ -76,7 +52,7 @@ function createOutputDataset(name, taskTree, outputType) {
|
|
|
76
52
|
* Collects all dependencies for a task.
|
|
77
53
|
*
|
|
78
54
|
* Walks the dependency graph to include:
|
|
79
|
-
* - The task's subtree and its
|
|
55
|
+
* - The task's subtree and its output
|
|
80
56
|
* - All input datasets and their dependencies
|
|
81
57
|
*/
|
|
82
58
|
function collectDeps(taskTree, outputDataset, inputs) {
|
|
@@ -99,54 +75,24 @@ function collectDeps(taskTree, outputDataset, inputs) {
|
|
|
99
75
|
export function task(name, inputs, fn, config) {
|
|
100
76
|
if (config?.environment)
|
|
101
77
|
validateEnvironmentDecl(config.environment, name);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
for (const input of inputs) {
|
|
79
|
+
if (input.kind === 'partition') {
|
|
80
|
+
throw new Error(`task '${name}': input '${input.dataset.name}' is marked with e3.partition, which only e3.streamTask takes — ` +
|
|
81
|
+
`pass the dataset itself`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
107
84
|
const taskTree = createTaskTree(name);
|
|
108
|
-
|
|
109
|
-
const functionIRDataset = createIRDataset(name, taskTree, 'function_ir', eastIR);
|
|
110
|
-
// The first input is the FunctionIR to execute
|
|
111
|
-
const input_datasets = [
|
|
112
|
-
functionIRDataset,
|
|
113
|
-
...inputs
|
|
114
|
-
];
|
|
115
|
-
// Create the output dataset
|
|
116
|
-
const output = createOutputDataset(name, taskTree, outputType);
|
|
117
|
-
// Resolve the typed Runner to argv at task-definition time. The variant is
|
|
118
|
-
// SDK-only metadata: it never reaches the IR or the wire — only the
|
|
119
|
-
// resolved string array does, baked in as an East constant below. Default
|
|
120
|
-
// is east-node + east-node-std (every e3 project has Node already).
|
|
121
|
-
const argvPrefix = runnerToCommand(config?.runner ?? DEFAULT_RUNNER);
|
|
122
|
-
// Build the command IR. At task-execution time this East function is
|
|
123
|
-
// evaluated with the staged input/output paths and returns the final argv.
|
|
124
|
-
const commandFn = East.function([ArrayType(StringType), StringType], ArrayType(StringType), ($, input_paths, output_path) => {
|
|
125
|
-
const command = $.let(argvPrefix, ArrayType(StringType));
|
|
126
|
-
// Function argument paths
|
|
127
|
-
const i = $.let(1n);
|
|
128
|
-
$.while(East.less(i, input_paths.size()), $ => {
|
|
129
|
-
$(command.pushLast("-i"));
|
|
130
|
-
$(command.pushLast(input_paths.get(i)));
|
|
131
|
-
$.assign(i, i.add(1n));
|
|
132
|
-
});
|
|
133
|
-
// Output path
|
|
134
|
-
$(command.pushLast('-o'));
|
|
135
|
-
$(command.pushLast(output_path));
|
|
136
|
-
// Function IR is the first input
|
|
137
|
-
$(command.pushLast(input_paths.get(0n)));
|
|
138
|
-
$.return(command);
|
|
139
|
-
});
|
|
85
|
+
const output = createOutputDataset(name, taskTree, Expr.type(fn).output);
|
|
140
86
|
const taskDef = {
|
|
141
87
|
kind: 'task',
|
|
142
88
|
name,
|
|
143
|
-
// Keep the full EastIR bundle so export.ts can encode
|
|
144
|
-
|
|
145
|
-
|
|
89
|
+
// Keep the full EastIR bundle (IR + source map) so export.ts can encode
|
|
90
|
+
// it with its source map.
|
|
91
|
+
body: { kind: 'east', program: fn.toIR() },
|
|
92
|
+
inputs,
|
|
146
93
|
output,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
metadata: config?.metadata,
|
|
94
|
+
role: config?.role ?? variant('data', null),
|
|
95
|
+
deps: collectDeps(taskTree, output, inputs),
|
|
150
96
|
runner: config?.runner ?? DEFAULT_RUNNER,
|
|
151
97
|
environment: config?.environment,
|
|
152
98
|
};
|
|
@@ -154,6 +100,20 @@ export function task(name, inputs, fn, config) {
|
|
|
154
100
|
output.deps.add(taskDef);
|
|
155
101
|
return taskDef;
|
|
156
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Defines a task that runs a command rather than East: a bash script built
|
|
105
|
+
* from the staged input paths and the path the output must be written to.
|
|
106
|
+
*
|
|
107
|
+
* @typeParam Name - Task name (literal type)
|
|
108
|
+
* @typeParam Inputs - Input dataset types
|
|
109
|
+
* @typeParam Output - Output type
|
|
110
|
+
* @param name - Task name
|
|
111
|
+
* @param inputs - Input datasets, staged as beast2 files
|
|
112
|
+
* @param outputType - The East type of the beast2 file the command writes
|
|
113
|
+
* @param command - Builds the bash script from the input paths and the output path
|
|
114
|
+
* @param config - The environment the command runs in
|
|
115
|
+
* @returns A TaskDef with `.output` for chaining
|
|
116
|
+
*/
|
|
157
117
|
export function customTask(name, inputs, outputType, command, config) {
|
|
158
118
|
if (config?.environment)
|
|
159
119
|
validateEnvironmentDecl(config.environment, name);
|
|
@@ -169,9 +129,10 @@ export function customTask(name, inputs, outputType, command, config) {
|
|
|
169
129
|
kind: 'task',
|
|
170
130
|
name,
|
|
171
131
|
// Keep the full EastIR bundle so export.ts can encode with source map.
|
|
172
|
-
command: commandFn.toIR(),
|
|
132
|
+
body: { kind: 'command', command: commandFn.toIR() },
|
|
173
133
|
inputs,
|
|
174
134
|
output,
|
|
135
|
+
role: variant('data', null),
|
|
175
136
|
deps: collectDeps(taskTree, output, inputs),
|
|
176
137
|
environment: config?.environment,
|
|
177
138
|
};
|
|
@@ -179,326 +140,156 @@ export function customTask(name, inputs, outputType, command, config) {
|
|
|
179
140
|
output.deps.add(taskDef);
|
|
180
141
|
return taskDef;
|
|
181
142
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
function collectionKeyType(name, dataset) {
|
|
186
|
-
const type = dataset.type;
|
|
187
|
-
switch (type.type) {
|
|
188
|
-
case 'Dict': return type.key;
|
|
189
|
-
case 'Set': return type.key;
|
|
190
|
-
case 'Array': return type.value;
|
|
191
|
-
default:
|
|
192
|
-
throw new Error(`partitionTask '${name}': partitioned dataset '${dataset.name}' must be a collection (Array, Set or Dict), got ${type.type}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Streaming tasks
|
|
145
|
+
// =============================================================================
|
|
195
146
|
/**
|
|
196
|
-
*
|
|
197
|
-
* key-range slices, runs `fn` once per partition as an ordinary
|
|
198
|
-
* content-addressed execution (parallel, memoized per partition), and
|
|
199
|
-
* assembles the output — by splicing shards in partition order, by merging
|
|
200
|
-
* keyed partials on the task's runner when `merge` is given, or by folding
|
|
201
|
-
* partials pairwise when `combine` is given.
|
|
147
|
+
* Marks an input of a {@link streamTask} as one its work may be split over.
|
|
202
148
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* contract is deliberately enforced at SPLICE TIME, not build time: whether
|
|
208
|
-
* an arbitrary body preserves key order is not decidable from types, and a
|
|
209
|
-
* static rule would false-reject permitted monotone re-keys — a violation
|
|
210
|
-
* fails the task deterministically at splice, naming the offending
|
|
211
|
-
* partitions and the remedies (`combine` / `customTask`). With `combine`
|
|
212
|
-
* each execution returns a *partial* and the partials fold.
|
|
149
|
+
* The body receives one piece of the dataset, typed as the whole: a key range
|
|
150
|
+
* of a Set or Dict, or a position range of an Array. Pieces are cut where the
|
|
151
|
+
* content says, so an insertion re-cuts only the pieces around it. Rows whose
|
|
152
|
+
* `by` fields are equal are never split across pieces.
|
|
213
153
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
154
|
+
* `by` names leading key fields, in order — `['account']`, or
|
|
155
|
+
* `['account', 'at.day']`, whose last entry reads the first field of `at`. Two
|
|
156
|
+
* or more partitioned inputs are cut at the same keys, so they must be Sets or
|
|
157
|
+
* Dicts whose keys, or whose `by` fields, have the same types. {@link
|
|
158
|
+
* streamTask} checks all of this against the dataset, naming the task.
|
|
217
159
|
*
|
|
218
|
-
* @typeParam
|
|
219
|
-
* @
|
|
220
|
-
* @
|
|
221
|
-
* @
|
|
222
|
-
* @param name - Task name
|
|
223
|
-
* @param spec - The declaration: partitioned inputs, alignment, output type,
|
|
224
|
-
* combine mode, sizing, runner and environment
|
|
225
|
-
* @param fn - Implementation body, run once per partition
|
|
226
|
-
* @returns A TaskDef with `.output` for chaining
|
|
227
|
-
* @throws {Error} When a partitioned dataset is not a collection, partitions
|
|
228
|
-
* mix collection kinds (or include Arrays) under co-partitioning, key
|
|
229
|
-
* types cannot align, `by` (explicit or the implicit alignment under
|
|
230
|
-
* co-partitioning) does not read a leading prefix of every partitioned
|
|
231
|
-
* dataset's key, the output is not a collection in splice mode (no
|
|
232
|
-
* `combine`), or `merge` is given alongside `combine`, on an Array output,
|
|
233
|
-
* in the wrong form for the output's kind, or with the `custom` runtime.
|
|
160
|
+
* @typeParam T - The dataset's East type
|
|
161
|
+
* @param dataset - The dataset
|
|
162
|
+
* @param config - `by`, the fields rows are grouped by
|
|
163
|
+
* @returns The marked input, to pass in a stream task's `inputs`
|
|
234
164
|
*
|
|
235
165
|
* @example
|
|
236
166
|
* ```ts
|
|
237
|
-
* const
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
167
|
+
* const totals = e3.streamTask('totals', {
|
|
168
|
+
* inputs: [e3.partition(sales, { by: ['account'] }), rates],
|
|
169
|
+
* output: e3.output.dict(StringType, FloatType, { merge: ($, account, a, b) => a.add(b) }),
|
|
170
|
+
* }, ($, sales, rates, emit) => {
|
|
171
|
+
* $.for(sales, ($, sale, key) => {
|
|
172
|
+
* $(emit(key.account, sale.amount.multiply(rates.get(sale.currency))));
|
|
173
|
+
* });
|
|
174
|
+
* });
|
|
243
175
|
* ```
|
|
244
176
|
*/
|
|
245
|
-
export function
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
byParamType = keyTypes[0];
|
|
266
|
-
}
|
|
267
|
-
else if (keyTypes.every((k) => k.type === 'Struct')) {
|
|
268
|
-
const first = keyTypes[0];
|
|
269
|
-
const shared = {};
|
|
270
|
-
for (const [field, fieldType] of Object.entries(first.fields)) {
|
|
271
|
-
const everywhere = keyTypes.every((k) => {
|
|
272
|
-
const other = k.fields[field];
|
|
273
|
-
return other !== undefined && isTypeValueEqual(toEastTypeValue(other), toEastTypeValue(fieldType));
|
|
274
|
-
});
|
|
275
|
-
if (everywhere)
|
|
276
|
-
shared[field] = fieldType;
|
|
177
|
+
export function partition(dataset, config) {
|
|
178
|
+
return { kind: 'partition', dataset, by: [...(config?.by ?? [])] };
|
|
179
|
+
}
|
|
180
|
+
/** The key a Set or Dict is cut by; an Array, cut by position, has none. */
|
|
181
|
+
function collectionKey(type) {
|
|
182
|
+
const collection = type;
|
|
183
|
+
return collection.type === 'Set' || collection.type === 'Dict' ? collection.key : undefined;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Checks a stream task's partitioned inputs against their datasets: each is a
|
|
187
|
+
* collection, each `by` names leading key fields, and co-partitioned inputs
|
|
188
|
+
* are Sets or Dicts cut by fields of the same types.
|
|
189
|
+
*/
|
|
190
|
+
function checkPartitions(name, partitions) {
|
|
191
|
+
const cuts = [];
|
|
192
|
+
for (const { dataset, by } of partitions) {
|
|
193
|
+
const where = `streamTask '${name}': partitioned input '${dataset.name}'`;
|
|
194
|
+
const type = dataset.type;
|
|
195
|
+
if (type.type !== 'Array' && type.type !== 'Set' && type.type !== 'Dict') {
|
|
196
|
+
throw new Error(`${where} must be a collection (Array, Set or Dict), got ${type.type}`);
|
|
277
197
|
}
|
|
278
|
-
|
|
279
|
-
|
|
198
|
+
const key = collectionKey(type);
|
|
199
|
+
if (key === undefined) {
|
|
200
|
+
if (by.length > 0)
|
|
201
|
+
throw new Error(`${where} is an Array, cut by position, so it has no key for \`by\` to name`);
|
|
202
|
+
continue;
|
|
280
203
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const first = toEastTypeValue(keyTypes[0]);
|
|
285
|
-
if (!keyTypes.every((k) => isTypeValueEqual(toEastTypeValue(k), first))) {
|
|
286
|
-
throw new Error(`partitionTask '${name}': co-partitioned key types must be identical (or share struct fields) for boundary alignment`);
|
|
204
|
+
if (by.length === 0) {
|
|
205
|
+
cuts.push({ input: dataset.name, types: [key] });
|
|
206
|
+
continue;
|
|
287
207
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// Reify `by` and validate the leading-prefix soundness condition per
|
|
291
|
-
// dataset: struct keys sort lexicographically by declared field order, so
|
|
292
|
-
// the projection must read a leading prefix of each dataset's OWN order.
|
|
293
|
-
let byIr;
|
|
294
|
-
let byShape = null;
|
|
295
|
-
if (spec.by !== undefined) {
|
|
296
|
-
const byFn = East.function([byParamType], undefined, spec.by);
|
|
297
|
-
const bundle = byFn.toIR();
|
|
298
|
-
const shape = partitionProjectionShape(bundle.ir);
|
|
299
|
-
if (shape === null) {
|
|
300
|
-
throw new Error(`partitionTask '${name}': \`by\` must project a leading prefix of the partition key — ` +
|
|
301
|
-
`accepted shapes: the key itself, a leading field (\`key.f\`), a nested leading-field ` +
|
|
302
|
-
`path (\`key.a.b\`, each step the first field of its level), or a struct literal of ` +
|
|
303
|
-
`leading fields in declared order (\`{ f1: key.f1, f2: key.f2 }\`)`);
|
|
208
|
+
if (key.type !== 'Struct') {
|
|
209
|
+
throw new Error(`${where} has a ${key.type} key, which has no fields for \`by\` to name`);
|
|
304
210
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const order = Object.keys(keyType.fields);
|
|
313
|
-
const misaligned = shape.names.length > order.length || shape.names.some((f, j) => order[j] !== f);
|
|
314
|
-
if (misaligned) {
|
|
315
|
-
throw new Error(`partitionTask '${name}': \`by\` projects (${shape.names.join(', ')}), which is not a leading prefix of ` +
|
|
316
|
-
`partitioned dataset '${partitions[i].name}' key field order (${order.join(', ')})`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
// Nested path: every step must read the FIRST field of its
|
|
321
|
-
// level's struct — struct keys sort lexicographically by declared
|
|
322
|
-
// field order, so only first-field descent preserves it.
|
|
323
|
-
let level = keyType;
|
|
324
|
-
for (let j = 0; j < shape.names.length; j++) {
|
|
325
|
-
const step = shape.names[j];
|
|
326
|
-
if (level.type !== 'Struct') {
|
|
327
|
-
throw new Error(`partitionTask '${name}': \`by\` path (key.${shape.names.join('.')}) descends into a ${level.type} at '${step}' — ` +
|
|
328
|
-
`every step must read the first field of a struct level of partitioned dataset '${partitions[i].name}'`);
|
|
329
|
-
}
|
|
330
|
-
const levelFields = level.fields;
|
|
331
|
-
const order = Object.keys(levelFields);
|
|
332
|
-
if (order[0] !== step) {
|
|
333
|
-
throw new Error(`partitionTask '${name}': \`by\` path (key.${shape.names.slice(0, j + 1).join('.')}) reads '${step}', which is not ` +
|
|
334
|
-
`the first field of partitioned dataset '${partitions[i].name}' key level (${order.join(', ')})`);
|
|
335
|
-
}
|
|
336
|
-
level = levelFields[step];
|
|
337
|
-
}
|
|
338
|
-
}
|
|
211
|
+
const fields = key.fields;
|
|
212
|
+
const order = Object.keys(fields);
|
|
213
|
+
const types = by.map((entry, i) => {
|
|
214
|
+
const [head, ...path] = entry.split('.');
|
|
215
|
+
if (order[i] !== head || (path.length > 0 && i !== by.length - 1)) {
|
|
216
|
+
throw new Error(`${where}: \`by\` (${by.join(', ')}) must name leading key fields in order — the key's fields are ` +
|
|
217
|
+
`(${order.join(', ')}), and only the last entry may read into one, as 'at.day' does`);
|
|
339
218
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// key order when it is implicit:
|
|
347
|
-
// - with no `by`, the runtime aligns secondaries under the PRIMARY's key
|
|
348
|
-
// comparator — the projection is the primary's full field sequence;
|
|
349
|
-
// - with an identity `by`, it is the shared-intersection struct in its
|
|
350
|
-
// declared field order.
|
|
351
|
-
// A key set like {sku,period} × {period,sku} passes the field-wise
|
|
352
|
-
// intersection yet sorts differently per dataset — accepted, it would
|
|
353
|
-
// silently mis-assign rows at run time with a success status.
|
|
354
|
-
if (partitions.length > 1) {
|
|
355
|
-
const firstKey = toEastTypeValue(keyTypes[0]);
|
|
356
|
-
const keysIdentical = keyTypes.every((k) => isTypeValueEqual(toEastTypeValue(k), firstKey));
|
|
357
|
-
const validatedByFields = byShape !== null && byShape.names.length > 0;
|
|
358
|
-
if (!keysIdentical && !validatedByFields) {
|
|
359
|
-
// Non-identical keys reach here only via the struct-intersection path.
|
|
360
|
-
const sourceFields = (t) => t.fields;
|
|
361
|
-
const effective = spec.by !== undefined
|
|
362
|
-
? { label: 'the identity `by` projection reads the shared key fields', fields: sourceFields(byParamType) }
|
|
363
|
-
: { label: `with no \`by\`, co-partition boundaries align on partitioned dataset '${partitions[0].name}' key order`, fields: sourceFields(keyTypes[0]) };
|
|
364
|
-
const eff = Object.keys(effective.fields);
|
|
365
|
-
for (let i = 0; i < partitions.length; i++) {
|
|
366
|
-
const fields = sourceFields(keyTypes[i]);
|
|
367
|
-
const order = Object.keys(fields);
|
|
368
|
-
const misaligned = eff.length > order.length || eff.some((f, j) => order[j] !== f || !isTypeValueEqual(toEastTypeValue(effective.fields[f]), toEastTypeValue(fields[f])));
|
|
369
|
-
if (misaligned) {
|
|
370
|
-
throw new Error(`partitionTask '${name}': ${effective.label} (${eff.join(', ')}), which is not a leading prefix of ` +
|
|
371
|
-
`partitioned dataset '${partitions[i].name}' key field order (${order.join(', ')}) — ` +
|
|
372
|
-
`declare \`by\` as a shared leading-prefix projection, or re-key upstream`);
|
|
219
|
+
let level = fields[head];
|
|
220
|
+
for (const step of path) {
|
|
221
|
+
const levelFields = level.type === 'Struct' ? Object.keys(level.fields) : [];
|
|
222
|
+
if (levelFields[0] !== step) {
|
|
223
|
+
throw new Error(`${where}: \`by\` path '${entry}' reads '${step}', which is not the first field of ` +
|
|
224
|
+
`${level.type === 'Struct' ? `(${levelFields.join(', ')})` : `a ${level.type}`} — rows sort by a struct's first field, so only it groups them`);
|
|
373
225
|
}
|
|
226
|
+
level = level.fields[step];
|
|
374
227
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
// SHARDS in partition order — only a collection can splice. A
|
|
379
|
-
// non-collection output would pass definition time and fail only when the
|
|
380
|
-
// input first carves into two or more partitions.
|
|
381
|
-
if (spec.combine === undefined && output.type !== 'Array' && output.type !== 'Set' && output.type !== 'Dict') {
|
|
382
|
-
throw new Error(`partitionTask '${name}': without \`combine\`, each partition returns a shard of the output and the shards ` +
|
|
383
|
-
`splice (or, with \`merge\`, merge) in key order — the output must be a collection (Array, Set or Dict), ` +
|
|
384
|
-
`got ${output.type}. Provide \`combine\` to fold non-collection partials.`);
|
|
228
|
+
return level;
|
|
229
|
+
});
|
|
230
|
+
cuts.push({ input: dataset.name, types });
|
|
385
231
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
232
|
+
if (partitions.length < 2)
|
|
233
|
+
return;
|
|
234
|
+
if (cuts.length < partitions.length) {
|
|
235
|
+
throw new Error(`streamTask '${name}': co-partitioned inputs are cut at the same keys, so each must be a Set or a Dict — ` +
|
|
236
|
+
`partition one input and pass the others whole`);
|
|
391
237
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (spec.merge !== undefined) {
|
|
401
|
-
if (spec.combine !== undefined) {
|
|
402
|
-
throw new Error(`partitionTask '${name}': \`merge\` and \`combine\` are two assembly modes — give one. ` +
|
|
403
|
-
`\`merge\` merges keyed partials by key; \`combine\` folds whole partials.`);
|
|
238
|
+
const [first, ...rest] = cuts;
|
|
239
|
+
for (const other of rest) {
|
|
240
|
+
const same = other.types.length === first.types.length &&
|
|
241
|
+
other.types.every((t, i) => isTypeValueEqual(toEastTypeValue(t), toEastTypeValue(first.types[i])));
|
|
242
|
+
if (!same) {
|
|
243
|
+
throw new Error(`streamTask '${name}': co-partitioned inputs '${first.input}' and '${other.input}' have no common key — ` +
|
|
244
|
+
`they are cut by (${first.types.map((t) => printType(t)).join(', ')}) and (${other.types.map((t) => printType(t)).join(', ')}); ` +
|
|
245
|
+
`give each a \`by\` naming fields of the same types`);
|
|
404
246
|
}
|
|
405
|
-
if (spec.runner?.runtime === 'custom') {
|
|
406
|
-
throw new Error(`partitionTask '${name}': merge runs the fan-in on the task's runner, which must be a stock runtime ` +
|
|
407
|
-
`(east-node, east-py, east-c) — the custom runtime has no merge command`);
|
|
408
|
-
}
|
|
409
|
-
if (spec.merge === 'union') {
|
|
410
|
-
if (output.type !== 'Set') {
|
|
411
|
-
throw new Error(`partitionTask '${name}': \`merge: 'union'\` assembles a Set output, got ${output.type}` +
|
|
412
|
-
(output.type === 'Dict' ? ' — a Dict output takes a per-key merge function' : ''));
|
|
413
|
-
}
|
|
414
|
-
mergeSets = true;
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
if (output.type !== 'Dict') {
|
|
418
|
-
throw new Error(`partitionTask '${name}': a \`merge\` FUNCTION resolves a key present in two partials, so the output must be a Dict, ` +
|
|
419
|
-
`got ${output.type}${output.type === 'Set' ? " — a Set output takes `merge: 'union'`" : ''}`);
|
|
420
|
-
}
|
|
421
|
-
const keyType = output.key;
|
|
422
|
-
const valueType = output.value;
|
|
423
|
-
const mergeFn = East.function([keyType, valueType, valueType], valueType, spec.merge);
|
|
424
|
-
mergeIr = encodeEastIR(mergeFn.toIR());
|
|
425
|
-
}
|
|
426
|
-
mergeCommand = encodeEastIR(mergeCommandIr(runnerToVariant(spec.runner ?? DEFAULT_RUNNER), mergeSets ? 'union' : 'function'));
|
|
427
|
-
}
|
|
428
|
-
const targetPartitionBytes = spec.targetPartitionBytes ?? DEFAULT_TARGET_PARTITION_BYTES;
|
|
429
|
-
if (!Number.isInteger(targetPartitionBytes) || targetPartitionBytes <= 0) {
|
|
430
|
-
throw new Error(`partitionTask '${name}': targetPartitionBytes must be a positive integer, got ${spec.targetPartitionBytes}`);
|
|
431
247
|
}
|
|
432
|
-
const bodyFn = East.function([...partitions.map((p) => p.type), ...inputs.map((i) => i.type)], output, fn);
|
|
433
|
-
const metadata = encodePartitionTaskMetadata({
|
|
434
|
-
partitions: BigInt(partitions.length),
|
|
435
|
-
by: byIr !== undefined ? some(byIr) : none,
|
|
436
|
-
combine: combineIr !== undefined ? some(combineIr) : none,
|
|
437
|
-
targetPartitionBytes: BigInt(targetPartitionBytes),
|
|
438
|
-
merge: mergeIr !== undefined ? some(mergeIr) : none,
|
|
439
|
-
mergeSets,
|
|
440
|
-
mergeCommand: mergeCommand !== undefined ? some(mergeCommand) : none,
|
|
441
|
-
});
|
|
442
|
-
return task(name, [...partitions, ...inputs], bodyFn, {
|
|
443
|
-
kind: TASK_KIND_PARTITION,
|
|
444
|
-
metadata,
|
|
445
|
-
...(spec.runner !== undefined && { runner: spec.runner }),
|
|
446
|
-
...(spec.environment !== undefined && { environment: spec.environment }),
|
|
447
|
-
});
|
|
448
248
|
}
|
|
449
249
|
/**
|
|
450
|
-
* Defines a
|
|
451
|
-
*
|
|
452
|
-
* locals) and writes the output incrementally through the `emit` capability
|
|
453
|
-
* — bounded memory at both ends, exact left-fold semantics, no parallelism
|
|
454
|
-
* and no partial recompute.
|
|
250
|
+
* Defines a stream task: a body that emits its output into an output kind
|
|
251
|
+
* rather than returning it, over inputs its work may be split across.
|
|
455
252
|
*
|
|
456
|
-
* `emit` is
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* v5: Dict key emitted out of order: 1 after 2 — Set/Dict emissions must
|
|
463
|
-
* ascend in East order`, the same words on every runtime). A re-key whose
|
|
464
|
-
* output keys do not arrive in order is a `partitionTask` with `merge`.
|
|
465
|
-
* Duplicate Set/Dict keys are a runtime error unless `merge` folds them: a
|
|
466
|
-
* function for a Dict output (adjacent equal keys fold left in emission
|
|
467
|
-
* order), `'union'` for a Set output. The body's return value is unused —
|
|
468
|
-
* the output dataset is what `emit` wrote.
|
|
253
|
+
* `emit` is the body's trailing parameter, and the output kind fixes its
|
|
254
|
+
* signature: `emit(t)` for `array`, `set` and `fold`, `emit(k, v)` for
|
|
255
|
+
* `dict`. Emission order is free — the platform sorts sets and dicts — and
|
|
256
|
+
* the parts of the output combine as the kind says: concatenated for an
|
|
257
|
+
* array, united for a set, by key for a dict (equal keys folding with its
|
|
258
|
+
* `merge`), and folded with `combine` from `zero` for a fold.
|
|
469
259
|
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
260
|
+
* An input wrapped in {@link partition} is one the work may be split over:
|
|
261
|
+
* the body runs once per piece, each piece typed as the whole dataset, and
|
|
262
|
+
* the pieces' outputs combine by the output kind. The other inputs reach
|
|
263
|
+
* every piece whole. With no partitioned input the task is one unit, with
|
|
264
|
+
* exact left-to-right semantics; a producer has no inputs at all, and emits
|
|
265
|
+
* what it reads from platform functions.
|
|
266
|
+
*
|
|
267
|
+
* The author's contract, the only one: `merge` and `combine` are associative,
|
|
268
|
+
* `zero` is an identity of `combine`, and a partitioned body's combined
|
|
269
|
+
* result does not depend on where its input was cut.
|
|
475
270
|
*
|
|
476
271
|
* @typeParam Name - Task name (literal type)
|
|
477
|
-
* @typeParam
|
|
478
|
-
* @typeParam
|
|
479
|
-
* @typeParam Inputs - Ordinary input dataset defs
|
|
272
|
+
* @typeParam Inputs - The inputs, some marked with {@link partition}
|
|
273
|
+
* @typeParam Output - The output kind
|
|
480
274
|
* @param name - Task name
|
|
481
|
-
* @param spec - The
|
|
482
|
-
*
|
|
483
|
-
* @param fn - Implementation body; receives the stream value (when
|
|
484
|
-
* declared), the inputs, then `emit`, and returns nothing
|
|
275
|
+
* @param spec - The inputs, the output kind, the runner and the environment
|
|
276
|
+
* @param fn - The body; receives the inputs, then `emit`, and returns nothing
|
|
485
277
|
* @returns A TaskDef with `.output` for chaining
|
|
486
|
-
* @throws {Error} When the output is not
|
|
487
|
-
*
|
|
488
|
-
*
|
|
278
|
+
* @throws {Error} When the output is not an output kind, the runner is the
|
|
279
|
+
* `custom` runtime, a partitioned input is not a collection, a `by` names
|
|
280
|
+
* something other than leading key fields, or co-partitioned inputs have no
|
|
281
|
+
* common key.
|
|
489
282
|
*
|
|
490
283
|
* @example
|
|
491
284
|
* ```ts
|
|
492
|
-
* const
|
|
285
|
+
* const sales = e3.input('sales', DictType(SaleKeyType, SaleType));
|
|
493
286
|
*
|
|
494
|
-
* const
|
|
495
|
-
*
|
|
496
|
-
* output:
|
|
497
|
-
* }, ($,
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* $.assign(balance, balance.add(event.amount));
|
|
501
|
-
* $(emit({ at: event.at, balance }));
|
|
287
|
+
* const byAccount = e3.streamTask('by_account', {
|
|
288
|
+
* inputs: [e3.partition(sales)],
|
|
289
|
+
* output: e3.output.dict(StringType, FloatType, { merge: ($, account, a, b) => a.add(b) }),
|
|
290
|
+
* }, ($, sales, emit) => {
|
|
291
|
+
* $.for(sales, ($, sale) => {
|
|
292
|
+
* $(emit(sale.account, sale.amount));
|
|
502
293
|
* });
|
|
503
294
|
* });
|
|
504
295
|
* ```
|
|
@@ -506,92 +297,35 @@ export function partitionTask(name, spec, fn) {
|
|
|
506
297
|
export function streamTask(name, spec, fn) {
|
|
507
298
|
if (spec.environment)
|
|
508
299
|
validateEnvironmentDecl(spec.environment, name);
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
let emitType;
|
|
513
|
-
switch (output.type) {
|
|
514
|
-
case 'Dict':
|
|
515
|
-
emitKind = 'dict';
|
|
516
|
-
emitType = FunctionType([output.key, output.value], NullType);
|
|
517
|
-
break;
|
|
518
|
-
case 'Set':
|
|
519
|
-
emitKind = 'set';
|
|
520
|
-
emitType = FunctionType([output.key], NullType);
|
|
521
|
-
break;
|
|
522
|
-
case 'Array':
|
|
523
|
-
emitKind = 'array';
|
|
524
|
-
emitType = FunctionType([output.value], NullType);
|
|
525
|
-
break;
|
|
526
|
-
default:
|
|
527
|
-
throw new Error(`streamTask '${name}': output must be a collection type (Array, Set or Dict), got ${output.type} — for small results use e3.task`);
|
|
300
|
+
const outputKind = spec.output;
|
|
301
|
+
if (!['array', 'set', 'dict', 'fold'].includes(outputKind?.kind)) {
|
|
302
|
+
throw new Error(`streamTask '${name}': output is an output kind — e3.output.array, set, dict or fold`);
|
|
528
303
|
}
|
|
529
304
|
const runner = spec.runner ?? DEFAULT_RUNNER;
|
|
530
305
|
if (runner.runtime === 'custom') {
|
|
531
|
-
throw new Error(`streamTask '${name}': the custom runtime
|
|
532
|
-
|
|
533
|
-
// `merge` folds equal keys in the runner's sink: a function for a Dict
|
|
534
|
-
// output, reified as a free East function (Key, Value, Value) -> Value;
|
|
535
|
-
// `'union'` for a Set output.
|
|
536
|
-
let mergeMode = 'none';
|
|
537
|
-
let mergeIR;
|
|
538
|
-
if (spec.merge !== undefined) {
|
|
539
|
-
if (output.type === 'Dict' && typeof spec.merge === 'function') {
|
|
540
|
-
mergeMode = 'function';
|
|
541
|
-
mergeIR = East.function([output.key, output.value, output.value], output.value, spec.merge).toIR();
|
|
542
|
-
}
|
|
543
|
-
else if (output.type === 'Set' && spec.merge === 'union') {
|
|
544
|
-
mergeMode = 'union';
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
const given = output.type === 'Array' ? '' : typeof spec.merge === 'function' ? ' with a function' : ` with '${String(spec.merge)}'`;
|
|
548
|
-
throw new Error(`streamTask '${name}': merge applies to Dict (a function) or Set ('union') outputs, got ${output.type}${given}`);
|
|
549
|
-
}
|
|
306
|
+
throw new Error(`streamTask '${name}': the custom runtime runs only a program that returns its output — ` +
|
|
307
|
+
`use a stock runtime (east-node, east-py, east-c)`);
|
|
550
308
|
}
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const bodyFn = East.function(paramTypes, NullType, fn);
|
|
557
|
-
const eastIR = bodyFn.toIR();
|
|
558
|
-
// Wire inputs: the body IR, the merge IR (function mode), the stream, then
|
|
559
|
-
// the ordinary inputs — the layout streamCommandIr's argv reads.
|
|
309
|
+
const inputs = spec.inputs;
|
|
310
|
+
checkPartitions(name, inputs.filter((i) => i.kind === 'partition'));
|
|
311
|
+
const datasets = inputs.map((i) => (i.kind === 'partition' ? i.dataset : i));
|
|
312
|
+
const parameters = [...datasets.map((d) => d.type), outputKind.emit];
|
|
313
|
+
const bodyFn = East.function(parameters, NullType, fn);
|
|
560
314
|
const taskTree = createTaskTree(name);
|
|
561
|
-
const
|
|
562
|
-
const input_datasets = [
|
|
563
|
-
functionIRDataset,
|
|
564
|
-
...(mergeIR !== undefined ? [createIRDataset(name, taskTree, 'merge_ir', mergeIR)] : []),
|
|
565
|
-
...(spec.stream !== undefined ? [spec.stream] : []),
|
|
566
|
-
...inputs,
|
|
567
|
-
];
|
|
568
|
-
const outputDataset = createOutputDataset(name, taskTree, output);
|
|
569
|
-
// The runner constructs the emit sink for the output kind, folding equal
|
|
570
|
-
// keys as `merge` says, and feeds the stream (always the first `-i` input)
|
|
571
|
-
// lazily instead of whole-decoding it.
|
|
572
|
-
const command = streamCommandIr(runnerToVariant(runner), {
|
|
573
|
-
emit: emitKind,
|
|
574
|
-
merge: mergeMode,
|
|
575
|
-
stream: spec.stream !== undefined ? 'first' : 'none',
|
|
576
|
-
});
|
|
577
|
-
const metadata = encodeStreamTaskMetadata({
|
|
578
|
-
stream: spec.stream !== undefined,
|
|
579
|
-
emit: emitKind,
|
|
580
|
-
merge: mergeMode,
|
|
581
|
-
});
|
|
315
|
+
const output = createOutputDataset(name, taskTree, outputKind.type);
|
|
582
316
|
const taskDef = {
|
|
583
317
|
kind: 'task',
|
|
584
318
|
name,
|
|
585
|
-
|
|
586
|
-
inputs
|
|
587
|
-
output
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
319
|
+
body: { kind: 'east', program: bodyFn.toIR() },
|
|
320
|
+
inputs,
|
|
321
|
+
output,
|
|
322
|
+
outputKind,
|
|
323
|
+
role: variant('data', null),
|
|
324
|
+
deps: collectDeps(taskTree, output, datasets),
|
|
591
325
|
runner,
|
|
592
|
-
|
|
326
|
+
environment: spec.environment,
|
|
593
327
|
};
|
|
594
|
-
|
|
328
|
+
output.deps.add(taskDef);
|
|
595
329
|
return taskDef;
|
|
596
330
|
}
|
|
597
331
|
//# sourceMappingURL=task.js.map
|