@pond-ts/process 0.54.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.
- package/CHANGELOG.md +5914 -0
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/cjs-fallback.cjs +15 -0
- package/dist/column.d.ts +197 -0
- package/dist/column.js +306 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +25 -0
- package/dist/graph.d.ts +89 -0
- package/dist/graph.js +133 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +45 -0
- package/dist/node.d.ts +151 -0
- package/dist/node.js +268 -0
- package/dist/plan/builder.d.ts +138 -0
- package/dist/plan/builder.js +166 -0
- package/dist/plan/fluent.d.ts +93 -0
- package/dist/plan/fluent.js +140 -0
- package/dist/plan/folds.d.ts +25 -0
- package/dist/plan/folds.js +190 -0
- package/dist/plan/graph.d.ts +171 -0
- package/dist/plan/graph.js +658 -0
- package/dist/plan/history.d.ts +61 -0
- package/dist/plan/history.js +82 -0
- package/dist/plan/host.d.ts +173 -0
- package/dist/plan/host.js +234 -0
- package/dist/plan/identity.d.ts +81 -0
- package/dist/plan/identity.js +158 -0
- package/dist/plan/params.d.ts +15 -0
- package/dist/plan/params.js +26 -0
- package/dist/plan/registry.d.ts +162 -0
- package/dist/plan/registry.js +422 -0
- package/dist/plan/run.d.ts +211 -0
- package/dist/plan/run.js +360 -0
- package/dist/plan/slots.d.ts +65 -0
- package/dist/plan/slots.js +114 -0
- package/dist/plan/source.d.ts +49 -0
- package/dist/plan/source.js +54 -0
- package/dist/plan/types.d.ts +376 -0
- package/dist/plan/types.js +20 -0
- package/dist/pool/index.d.ts +15 -0
- package/dist/pool/index.js +12 -0
- package/dist/pool/pool.d.ts +92 -0
- package/dist/pool/pool.js +237 -0
- package/dist/pool/protocol.d.ts +48 -0
- package/dist/pool/protocol.js +9 -0
- package/dist/pool/wire.d.ts +52 -0
- package/dist/pool/wire.js +95 -0
- package/dist/pool/worker.d.ts +22 -0
- package/dist/pool/worker.js +80 -0
- package/dist/port.d.ts +79 -0
- package/dist/port.js +222 -0
- package/dist/source.d.ts +161 -0
- package/dist/source.js +182 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +26 -0
- package/package.json +50 -0
package/dist/plan/run.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run` — one entry point, one response. [PND-DEMOM0], [PND-PROCTERM].
|
|
3
|
+
*
|
|
4
|
+
* The request carries a plan **and** what it wants back. A renderer asks
|
|
5
|
+
* for columns; an agent asks for facts; a legend chip is a fact riding
|
|
6
|
+
* alongside columns in the same pass. Collapsing the request rather than
|
|
7
|
+
* forking the terminal is what makes that one call.
|
|
8
|
+
*
|
|
9
|
+
* Two things measured earlier are load-bearing here:
|
|
10
|
+
*
|
|
11
|
+
* - **Facts read node values directly.** Assembling a `TimeSeries` so a
|
|
12
|
+
* reduction has a column to read cost 52× more, and 441× once facts
|
|
13
|
+
* memoize. Assembly happens only when `columns` is asked for.
|
|
14
|
+
* - **Assembly resolves a closure.** "Needed" is not "selected with
|
|
15
|
+
* `columns`": a reduction reads a column too, and `crossings`'s
|
|
16
|
+
* `against` names a second one. Assembling only the column-selectors
|
|
17
|
+
* produced a fact with *no value* rather than an error — silent, which
|
|
18
|
+
* is worse than a throw.
|
|
19
|
+
*/
|
|
20
|
+
import { appendColumn } from '../column.js';
|
|
21
|
+
import { ProcessError } from '../errors.js';
|
|
22
|
+
import { columnsOf, explain, refToId, unitOf } from './identity.js';
|
|
23
|
+
import { expandSlots } from './slots.js';
|
|
24
|
+
import { specId } from './identity.js';
|
|
25
|
+
import { specOf } from './types.js';
|
|
26
|
+
// ── run ──────────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Reduces either request form to the one the resolver already handles.
|
|
29
|
+
*
|
|
30
|
+
* A slot request expands to exactly the nested plan its equivalent would
|
|
31
|
+
* have been written as, so both land on identical ids — that equality is
|
|
32
|
+
* the contract, and it is why nothing downstream of here knows slots
|
|
33
|
+
* exist ([PND-PROCSLOT]).
|
|
34
|
+
*/
|
|
35
|
+
function normalize(graph, request) {
|
|
36
|
+
if (!('nodes' in request)) {
|
|
37
|
+
return {
|
|
38
|
+
plan: request.plan,
|
|
39
|
+
select: request.select ?? [],
|
|
40
|
+
slotOf: new Map(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const columns = graph.series.schema.slice(1).map((c) => c.name);
|
|
44
|
+
const expanded = expandSlots(request.nodes, columns);
|
|
45
|
+
// First slot wins. Two slots may legally resolve to ONE id — the
|
|
46
|
+
// registry-free builder cannot canonicalize defaults, so `shape()`
|
|
47
|
+
// and `shape({points: 40})` arrive as two slots naming the same
|
|
48
|
+
// computation. Attribution must not depend on which happened to be
|
|
49
|
+
// declared last; "the first slot to name it labels it" is the rule.
|
|
50
|
+
const slotOf = new Map();
|
|
51
|
+
for (const [slot, spec] of expanded) {
|
|
52
|
+
const id = specId(graph.registry, spec);
|
|
53
|
+
if (!slotOf.has(id))
|
|
54
|
+
slotOf.set(id, slot);
|
|
55
|
+
}
|
|
56
|
+
const select = Object.entries(request.outputs ?? {}).map(([name, sel]) => {
|
|
57
|
+
// `on` names a slot here. Anything else is left alone, so an id
|
|
58
|
+
// string still works — a follow-up can cite what the last response
|
|
59
|
+
// returned without re-deriving it.
|
|
60
|
+
const on = typeof sel.on === 'string' && expanded.has(sel.on)
|
|
61
|
+
? expanded.get(sel.on)
|
|
62
|
+
: sel.on;
|
|
63
|
+
return { ...sel, on, name };
|
|
64
|
+
});
|
|
65
|
+
return { plan: [...expanded.values()], select, slotOf };
|
|
66
|
+
}
|
|
67
|
+
export function run(graph, request) {
|
|
68
|
+
const { onError = 'throw', assemble = true } = request;
|
|
69
|
+
const registry = graph.registry;
|
|
70
|
+
const skipped = [];
|
|
71
|
+
const resolved = [];
|
|
72
|
+
// Slot expansion happens before anything resolves, so its failures
|
|
73
|
+
// used to escape the error policy entirely — a mistyped input came
|
|
74
|
+
// back as a thrown 500 rather than a `skipped` reason an agent could
|
|
75
|
+
// read and retry against. Every other class of bad plan is
|
|
76
|
+
// collectable; there was no argument for this one being different.
|
|
77
|
+
let normalized;
|
|
78
|
+
try {
|
|
79
|
+
normalized = normalize(graph, request);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (onError === 'throw')
|
|
83
|
+
throw e;
|
|
84
|
+
return {
|
|
85
|
+
outputs: {},
|
|
86
|
+
facts: [],
|
|
87
|
+
explain: {},
|
|
88
|
+
skipped: [{ reason: e instanceof Error ? e.message : String(e) }],
|
|
89
|
+
nodes: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const { plan, select, slotOf } = normalized;
|
|
93
|
+
const fail = (entry) => {
|
|
94
|
+
if (onError === 'throw')
|
|
95
|
+
throw new ProcessError(entry.reason);
|
|
96
|
+
skipped.push(entry);
|
|
97
|
+
};
|
|
98
|
+
// ── resolve the plan ───────────────────────────────────────
|
|
99
|
+
for (const spec of plan) {
|
|
100
|
+
try {
|
|
101
|
+
const compiled = graph.compile(spec);
|
|
102
|
+
resolved.push({ id: compiled.id, spec });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
fail({
|
|
106
|
+
spec: {
|
|
107
|
+
op: spec.op,
|
|
108
|
+
params: { ...(spec.params ?? {}) },
|
|
109
|
+
inputs: spec.inputs,
|
|
110
|
+
},
|
|
111
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const explainMap = {};
|
|
116
|
+
for (const { id, spec } of resolved)
|
|
117
|
+
explainMap[id] = explain(registry, spec);
|
|
118
|
+
// ── work out what the selection actually needs ──────────────
|
|
119
|
+
// Every id any selector mentions, including a `crossings` `against`.
|
|
120
|
+
const needed = new Map(); // id -> report in `outputs`
|
|
121
|
+
const selectors = [];
|
|
122
|
+
for (const sel of select) {
|
|
123
|
+
let id;
|
|
124
|
+
try {
|
|
125
|
+
id = refToId(registry, sel.on);
|
|
126
|
+
// An inline spec is a complete description of a computation, so
|
|
127
|
+
// resolve it whether or not the plan also lists it at top level.
|
|
128
|
+
// Requiring both was redundant bookkeeping that no schema could
|
|
129
|
+
// express — so it lived in prose, and a caller composing against
|
|
130
|
+
// the schema alone duly selected a spec it had not listed, and got
|
|
131
|
+
// a skip instead of an answer ([PND-PROCSCHEMA], M5).
|
|
132
|
+
if (typeof sel.on !== 'string' && graph.get(id) === undefined) {
|
|
133
|
+
const compiled = graph.compile(sel.on);
|
|
134
|
+
resolved.push({ id: compiled.id, spec: sel.on });
|
|
135
|
+
explainMap[id] = explain(registry, sel.on);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
fail({ select: sel, reason: e instanceof Error ? e.message : String(e) });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
selectors.push({ sel, id });
|
|
143
|
+
// Surfacing a column-producing node means surfacing its columns.
|
|
144
|
+
// There is no longer a `columns: true` to opt into, because what a
|
|
145
|
+
// selector yields is decided by the node it points at.
|
|
146
|
+
const compiled = graph.get(id);
|
|
147
|
+
const isFoldNode = compiled?.fold === true;
|
|
148
|
+
if (!isFoldNode)
|
|
149
|
+
needed.set(id, true);
|
|
150
|
+
}
|
|
151
|
+
const outputs = {};
|
|
152
|
+
const timings = [];
|
|
153
|
+
const timed = new Set();
|
|
154
|
+
let assembled;
|
|
155
|
+
let drawn;
|
|
156
|
+
const columnCache = new Map();
|
|
157
|
+
/**
|
|
158
|
+
* Pulls a node's inputs before the node itself, so the time recorded
|
|
159
|
+
* against it is its own compute rather than an ancestor's. Without
|
|
160
|
+
* this a leaf absorbs the whole subtree's cost and the badge lies.
|
|
161
|
+
*/
|
|
162
|
+
const warm = (id) => {
|
|
163
|
+
if (timed.has(id))
|
|
164
|
+
return;
|
|
165
|
+
timed.add(id);
|
|
166
|
+
const compiled = graph.get(id);
|
|
167
|
+
if (compiled === undefined)
|
|
168
|
+
return;
|
|
169
|
+
// Lineage for the whole closure, not just the plan's top level. A
|
|
170
|
+
// nested spec is a node in `nodes` and will be a node in the M4
|
|
171
|
+
// pipeline view, and both label from here — leaving it out meant a
|
|
172
|
+
// badge with a raw id under it.
|
|
173
|
+
explainMap[id] ??= explain(registry, compiled.spec);
|
|
174
|
+
// Resolved on the way down, so an edge names the id the consumer
|
|
175
|
+
// will see in `nodes` rather than a spec it would have to hash.
|
|
176
|
+
const upstream = compiled.spec.inputs.map((input) => {
|
|
177
|
+
if (typeof input === 'string')
|
|
178
|
+
return input;
|
|
179
|
+
const upId = refToId(registry, specOf(input));
|
|
180
|
+
warm(upId);
|
|
181
|
+
return upId;
|
|
182
|
+
});
|
|
183
|
+
const declared = registry.outputsOf(registry.get(compiled.spec.op));
|
|
184
|
+
const wasDirty = compiled.node.dirty;
|
|
185
|
+
const t0 = performance.now();
|
|
186
|
+
// A fold is pulled exactly like a column node — same memo, same
|
|
187
|
+
// version check. That equivalence is the point of [PND-PROCFOLD]:
|
|
188
|
+
// the badge row now covers the part callers actually read.
|
|
189
|
+
if (compiled.fold)
|
|
190
|
+
graph.factOf(compiled);
|
|
191
|
+
else
|
|
192
|
+
graph.columnOf(compiled, declared[0].id);
|
|
193
|
+
const ms = performance.now() - t0;
|
|
194
|
+
timings.push({
|
|
195
|
+
id,
|
|
196
|
+
...(slotOf.has(id) && { slot: slotOf.get(id) }),
|
|
197
|
+
pulled: true,
|
|
198
|
+
cached: !wasDirty,
|
|
199
|
+
ms: Math.round(ms * 1000) / 1000,
|
|
200
|
+
inputs: upstream,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Records a resolved node the request never pulled, inputs first.
|
|
205
|
+
*
|
|
206
|
+
* The pipeline is the plan, not the subset one selector happened to
|
|
207
|
+
* reach. Reading `node.dirty` is free — no value is produced — so this
|
|
208
|
+
* costs nothing beyond the walk.
|
|
209
|
+
*/
|
|
210
|
+
const record = (id) => {
|
|
211
|
+
if (timed.has(id))
|
|
212
|
+
return;
|
|
213
|
+
timed.add(id);
|
|
214
|
+
const compiled = graph.get(id);
|
|
215
|
+
if (compiled === undefined)
|
|
216
|
+
return;
|
|
217
|
+
explainMap[id] ??= explain(registry, compiled.spec);
|
|
218
|
+
const upstream = compiled.spec.inputs.map((input) => {
|
|
219
|
+
if (typeof input === 'string')
|
|
220
|
+
return input;
|
|
221
|
+
const upId = refToId(registry, specOf(input));
|
|
222
|
+
record(upId);
|
|
223
|
+
return upId;
|
|
224
|
+
});
|
|
225
|
+
timings.push({
|
|
226
|
+
id,
|
|
227
|
+
...(slotOf.has(id) && { slot: slotOf.get(id) }),
|
|
228
|
+
pulled: false,
|
|
229
|
+
cached: !compiled.node.dirty,
|
|
230
|
+
ms: 0,
|
|
231
|
+
inputs: upstream,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const columnFor = (id, suffix) => {
|
|
235
|
+
const key = id + suffix;
|
|
236
|
+
const hit = columnCache.get(key);
|
|
237
|
+
if (hit)
|
|
238
|
+
return hit;
|
|
239
|
+
const compiled = graph.get(id);
|
|
240
|
+
if (compiled === undefined)
|
|
241
|
+
throw new ProcessError(`'${id}' is not in this plan`);
|
|
242
|
+
warm(id);
|
|
243
|
+
const col = graph.columnOf(compiled, suffix);
|
|
244
|
+
columnCache.set(key, col);
|
|
245
|
+
return col;
|
|
246
|
+
};
|
|
247
|
+
// ── resolve the surfaced columns, and assemble only if asked ───
|
|
248
|
+
if (needed.size > 0) {
|
|
249
|
+
drawn = {};
|
|
250
|
+
if (assemble)
|
|
251
|
+
assembled = graph.series;
|
|
252
|
+
for (const [id, report] of needed) {
|
|
253
|
+
const compiled = graph.get(id);
|
|
254
|
+
if (compiled === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
const cols = columnsOf(registry, compiled.spec, id);
|
|
257
|
+
if (report)
|
|
258
|
+
outputs[id] = [];
|
|
259
|
+
const selections = selectors.filter((selection) => selection.id === id);
|
|
260
|
+
for (const { sel } of selections) {
|
|
261
|
+
const declared = registry.outputsOf(registry.get(compiled.spec.op));
|
|
262
|
+
// A selector naming an output the node does not declare used to
|
|
263
|
+
// filter every iteration below and surface NOTHING — no columns,
|
|
264
|
+
// no error, no `skipped` entry. Silent is the worst of the three.
|
|
265
|
+
if (sel.output !== undefined &&
|
|
266
|
+
!declared.some((o) => o.id === sel.output)) {
|
|
267
|
+
const have = declared.map((o) => `'${o.id}'`).join(', ');
|
|
268
|
+
fail({
|
|
269
|
+
select: sel,
|
|
270
|
+
reason: `'${compiled.spec.op}' has no output '${sel.output}' (has ${have})`,
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// Pulling a column runs op code, which can throw like anything
|
|
275
|
+
// else a request does — so it answers to the same error policy
|
|
276
|
+
// the fact loop already honours. Under 'throw' the original
|
|
277
|
+
// error propagates untouched.
|
|
278
|
+
try {
|
|
279
|
+
declared.forEach((o, n) => {
|
|
280
|
+
if (sel.output !== undefined && o.id !== sel.output)
|
|
281
|
+
return;
|
|
282
|
+
const columnName = cols[n];
|
|
283
|
+
const col = columnFor(id, o.id);
|
|
284
|
+
// Several selectors may surface the same node or column under
|
|
285
|
+
// different caller names. Report every selection, but materialize
|
|
286
|
+
// each physical column once.
|
|
287
|
+
if (drawn[columnName] === undefined) {
|
|
288
|
+
drawn[columnName] = col;
|
|
289
|
+
if (assemble)
|
|
290
|
+
assembled = appendColumn(assembled, columnName, col);
|
|
291
|
+
}
|
|
292
|
+
if (report) {
|
|
293
|
+
outputs[id].push({
|
|
294
|
+
column: columnName,
|
|
295
|
+
unit: unitOf(registry, compiled.spec, graph.units, n),
|
|
296
|
+
...(sel.name !== undefined && { name: sel.name }),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
if (onError === 'throw')
|
|
303
|
+
throw e;
|
|
304
|
+
fail({
|
|
305
|
+
select: sel,
|
|
306
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// ── facts are pulled from fold nodes, like any other value ──
|
|
313
|
+
const facts = [];
|
|
314
|
+
for (const { sel, id } of selectors) {
|
|
315
|
+
const compiled = graph.get(id);
|
|
316
|
+
if (compiled === undefined) {
|
|
317
|
+
fail({ select: sel, reason: `'${id}' is not in this plan` });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (!compiled.fold)
|
|
321
|
+
continue;
|
|
322
|
+
try {
|
|
323
|
+
warm(id);
|
|
324
|
+
// Provenance wins. The body is spread FIRST and its reserved keys
|
|
325
|
+
// are dropped, so a custom fold cannot masquerade as another node,
|
|
326
|
+
// rename an output the caller did not, or forge a unit — `id`,
|
|
327
|
+
// `name`, `op` and `unit` always mean what the graph says.
|
|
328
|
+
const body = { ...graph.factOf(compiled) };
|
|
329
|
+
for (const key of ['id', 'name', 'op', 'unit'])
|
|
330
|
+
delete body[key];
|
|
331
|
+
facts.push({
|
|
332
|
+
...body,
|
|
333
|
+
id,
|
|
334
|
+
...(sel.name !== undefined && { name: sel.name }),
|
|
335
|
+
op: compiled.spec.op,
|
|
336
|
+
unit: unitOf(registry, compiled.spec, graph.units),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
fail({ select: sel, reason: e instanceof Error ? e.message : String(e) });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Anything the plan resolved but nothing selected. After the pulls, so
|
|
344
|
+
// a node that *was* read keeps its timing rather than being shadowed.
|
|
345
|
+
for (const { id } of resolved)
|
|
346
|
+
record(id);
|
|
347
|
+
// The budget is enforced once the run has resolved, not during it:
|
|
348
|
+
// evicting a node this run is about to read would only recompile it.
|
|
349
|
+
graph.enforceBudget();
|
|
350
|
+
return {
|
|
351
|
+
...(assembled !== undefined && { series: assembled }),
|
|
352
|
+
...(drawn !== undefined && { columns: drawn }),
|
|
353
|
+
outputs,
|
|
354
|
+
facts,
|
|
355
|
+
explain: explainMap,
|
|
356
|
+
skipped,
|
|
357
|
+
nodes: timings,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slots — [PND-PROCSLOT].
|
|
3
|
+
*
|
|
4
|
+
* A plan written as nested specs uses **one identity for two jobs**. A
|
|
5
|
+
* node's id is derived from its op, params and inputs, so it keys the
|
|
6
|
+
* cache correctly — and changes the moment a param does, even though the
|
|
7
|
+
* topology has not. Moving a `period` from 20 to 50 leaves a
|
|
8
|
+
* structurally identical plan in which every downstream id is different.
|
|
9
|
+
*
|
|
10
|
+
* A **slot** is the missing identity: a caller-assigned name for a
|
|
11
|
+
* position in the graph, stable across a param edit.
|
|
12
|
+
*
|
|
13
|
+
* ```jsonc
|
|
14
|
+
* {
|
|
15
|
+
* "bb": { "op": "bollinger", "params": { "period": 20 }, "in": ["px"] },
|
|
16
|
+
* "z": { "op": "zscore", "params": { "period": 20 }, "in": ["px"] }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Slots are an **alias layer, not a replacement**. `specId` remains the
|
|
21
|
+
* cache key, because it is what makes a node found again across requests,
|
|
22
|
+
* sessions and callers — a saved view composed months ago and a fresh
|
|
23
|
+
* compose land on the same node precisely because the id is derived
|
|
24
|
+
* rather than assigned. One caller's `bb` means nothing to another's.
|
|
25
|
+
*
|
|
26
|
+
* That is why this file only **expands**: a slot graph becomes the nested
|
|
27
|
+
* `Spec` form the rest of the layer already resolves, so a slot plan and
|
|
28
|
+
* the equivalent nested plan produce identical ids by construction, and
|
|
29
|
+
* neither `compile` nor `specId` needs to know slots exist.
|
|
30
|
+
*/
|
|
31
|
+
import { ProcessError } from '../errors.js';
|
|
32
|
+
import type { ParamValue, Spec } from './types.js';
|
|
33
|
+
/** Thrown when a slot graph cannot be expanded. */
|
|
34
|
+
export declare class SlotError extends ProcessError {
|
|
35
|
+
}
|
|
36
|
+
/** One node in a slot graph. `in` names source columns or other slots. */
|
|
37
|
+
export interface SlotDef {
|
|
38
|
+
readonly op: string;
|
|
39
|
+
readonly params?: Readonly<Record<string, ParamValue>>;
|
|
40
|
+
/**
|
|
41
|
+
* Inputs, in the op's declared order. Each entry is a source column
|
|
42
|
+
* name or another slot's name — **not** an inline spec. Being unable
|
|
43
|
+
* to nest here is the point: nesting is what slots replace.
|
|
44
|
+
*/
|
|
45
|
+
readonly in: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** A graph keyed by caller-assigned names. */
|
|
48
|
+
export type Slots = Readonly<Record<string, SlotDef>>;
|
|
49
|
+
/**
|
|
50
|
+
* Expands a slot graph into the nested `Spec` form.
|
|
51
|
+
*
|
|
52
|
+
* `columns` is the bound source's column names, needed for two checks
|
|
53
|
+
* that are far cheaper here than as a failure later:
|
|
54
|
+
*
|
|
55
|
+
* - a slot may not take the name of a column, because an input string
|
|
56
|
+
* would then be ambiguous and the column would be shadowed silently
|
|
57
|
+
* (the alternative, a sigil like `"@bb"`, adds syntax to a format that
|
|
58
|
+
* has none — [PND-PROCSLOT] prefers the validation);
|
|
59
|
+
* - an input naming neither a slot nor a column is a typo, and saying so
|
|
60
|
+
* with both lists beats a downstream "column not found".
|
|
61
|
+
*
|
|
62
|
+
* @throws {SlotError} on a name collision, an unknown reference, or a cycle.
|
|
63
|
+
*/
|
|
64
|
+
export declare function expandSlots(slots: Slots, columns: readonly string[]): Map<string, Spec>;
|
|
65
|
+
//# sourceMappingURL=slots.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slots — [PND-PROCSLOT].
|
|
3
|
+
*
|
|
4
|
+
* A plan written as nested specs uses **one identity for two jobs**. A
|
|
5
|
+
* node's id is derived from its op, params and inputs, so it keys the
|
|
6
|
+
* cache correctly — and changes the moment a param does, even though the
|
|
7
|
+
* topology has not. Moving a `period` from 20 to 50 leaves a
|
|
8
|
+
* structurally identical plan in which every downstream id is different.
|
|
9
|
+
*
|
|
10
|
+
* A **slot** is the missing identity: a caller-assigned name for a
|
|
11
|
+
* position in the graph, stable across a param edit.
|
|
12
|
+
*
|
|
13
|
+
* ```jsonc
|
|
14
|
+
* {
|
|
15
|
+
* "bb": { "op": "bollinger", "params": { "period": 20 }, "in": ["px"] },
|
|
16
|
+
* "z": { "op": "zscore", "params": { "period": 20 }, "in": ["px"] }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Slots are an **alias layer, not a replacement**. `specId` remains the
|
|
21
|
+
* cache key, because it is what makes a node found again across requests,
|
|
22
|
+
* sessions and callers — a saved view composed months ago and a fresh
|
|
23
|
+
* compose land on the same node precisely because the id is derived
|
|
24
|
+
* rather than assigned. One caller's `bb` means nothing to another's.
|
|
25
|
+
*
|
|
26
|
+
* That is why this file only **expands**: a slot graph becomes the nested
|
|
27
|
+
* `Spec` form the rest of the layer already resolves, so a slot plan and
|
|
28
|
+
* the equivalent nested plan produce identical ids by construction, and
|
|
29
|
+
* neither `compile` nor `specId` needs to know slots exist.
|
|
30
|
+
*/
|
|
31
|
+
import { ProcessError } from '../errors.js';
|
|
32
|
+
/** Thrown when a slot graph cannot be expanded. */
|
|
33
|
+
export class SlotError extends ProcessError {
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Expands a slot graph into the nested `Spec` form.
|
|
37
|
+
*
|
|
38
|
+
* `columns` is the bound source's column names, needed for two checks
|
|
39
|
+
* that are far cheaper here than as a failure later:
|
|
40
|
+
*
|
|
41
|
+
* - a slot may not take the name of a column, because an input string
|
|
42
|
+
* would then be ambiguous and the column would be shadowed silently
|
|
43
|
+
* (the alternative, a sigil like `"@bb"`, adds syntax to a format that
|
|
44
|
+
* has none — [PND-PROCSLOT] prefers the validation);
|
|
45
|
+
* - an input naming neither a slot nor a column is a typo, and saying so
|
|
46
|
+
* with both lists beats a downstream "column not found".
|
|
47
|
+
*
|
|
48
|
+
* @throws {SlotError} on a name collision, an unknown reference, or a cycle.
|
|
49
|
+
*/
|
|
50
|
+
export function expandSlots(slots, columns) {
|
|
51
|
+
const names = Object.keys(slots);
|
|
52
|
+
const columnSet = new Set(columns);
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (columnSet.has(name)) {
|
|
55
|
+
throw new SlotError(`slot '${name}' collides with a source column of the same name — rename the slot, or an input naming '${name}' would be ambiguous`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const done = new Map();
|
|
59
|
+
// Insertion-ordered, so the cycle message reads as a path rather than
|
|
60
|
+
// an unordered set of names.
|
|
61
|
+
const visiting = [];
|
|
62
|
+
const expand = (name) => {
|
|
63
|
+
const cached = done.get(name);
|
|
64
|
+
if (cached !== undefined)
|
|
65
|
+
return cached;
|
|
66
|
+
const at = visiting.indexOf(name);
|
|
67
|
+
if (at !== -1) {
|
|
68
|
+
throw new SlotError(`slot cycle: ${[...visiting.slice(at), name].join(' → ')}`);
|
|
69
|
+
}
|
|
70
|
+
const def = slots[name];
|
|
71
|
+
visiting.push(name);
|
|
72
|
+
const inputs = def.in.map((ref) => {
|
|
73
|
+
if (Object.hasOwn(slots, ref))
|
|
74
|
+
return expand(ref);
|
|
75
|
+
if (columnSet.has(ref))
|
|
76
|
+
return ref;
|
|
77
|
+
// `bb#Lower` — one named output of a multi-output slot. A suffix
|
|
78
|
+
// rather than a nested object because `in` is a list of strings
|
|
79
|
+
// and keeping it that way is what makes the slot schema flat, with
|
|
80
|
+
// no recursive `$ref` to make portable ([PND-PROCSLOT]).
|
|
81
|
+
const hash = ref.lastIndexOf('#');
|
|
82
|
+
if (hash > 0) {
|
|
83
|
+
const upstream = ref.slice(0, hash);
|
|
84
|
+
if (Object.hasOwn(slots, upstream)) {
|
|
85
|
+
return { from: expand(upstream), output: ref.slice(hash + 1) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Name the `#` spelling when the reference looks like an attempt
|
|
89
|
+
// at one. A model reaching for a band's upper line writes
|
|
90
|
+
// `bb.Upper` on the first try — reasonably — and a list of valid
|
|
91
|
+
// slots does not tell it what it got wrong.
|
|
92
|
+
const guess = /^(.+)[.:/](.+)$/.exec(ref);
|
|
93
|
+
const hint = guess !== null && Object.hasOwn(slots, guess[1])
|
|
94
|
+
? ` — to read one output of slot '${guess[1]}', write '${guess[1]}#${guess[2]}'`
|
|
95
|
+
: '';
|
|
96
|
+
throw new SlotError(`slot '${name}' names '${ref}', which is neither a slot nor a column${hint} — slots are ${quoted(names)}; columns are ${quoted(columns)}`);
|
|
97
|
+
});
|
|
98
|
+
visiting.pop();
|
|
99
|
+
const spec = {
|
|
100
|
+
op: def.op,
|
|
101
|
+
...(def.params !== undefined && { params: def.params }),
|
|
102
|
+
inputs,
|
|
103
|
+
};
|
|
104
|
+
done.set(name, spec);
|
|
105
|
+
return spec;
|
|
106
|
+
};
|
|
107
|
+
for (const name of names)
|
|
108
|
+
expand(name);
|
|
109
|
+
return done;
|
|
110
|
+
}
|
|
111
|
+
function quoted(values) {
|
|
112
|
+
return values.length === 0 ? 'none' : values.map((v) => `'${v}'`).join(', ');
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=slots.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque, asynchronous data sources.
|
|
3
|
+
*
|
|
4
|
+
* A request carries only `{ source, params }`. The loader stays on the host,
|
|
5
|
+
* where credentials, URLs, retries and cache policy belong. The canonical
|
|
6
|
+
* source id chooses a long-lived bound graph; `revision` decides whether that
|
|
7
|
+
* graph's source value must be invalidated.
|
|
8
|
+
*/
|
|
9
|
+
import { ProcessError } from '../errors.js';
|
|
10
|
+
import type { SeriesSchema, TimeSeries } from 'pond-ts';
|
|
11
|
+
import type { ParamValue } from './types.js';
|
|
12
|
+
export type SourceParams = Readonly<Record<string, ParamValue>>;
|
|
13
|
+
/** JSON-safe source identity carried by a request. */
|
|
14
|
+
export interface SourceRef<Name extends string = string, Params extends SourceParams = SourceParams> {
|
|
15
|
+
readonly source: Name;
|
|
16
|
+
readonly params: Params;
|
|
17
|
+
}
|
|
18
|
+
export interface LoadedSource {
|
|
19
|
+
readonly value: TimeSeries<SeriesSchema>;
|
|
20
|
+
/**
|
|
21
|
+
* Version of the remote value: an ETag, cursor, object version, or another
|
|
22
|
+
* stable token. Equal revisions deliberately preserve every graph cache.
|
|
23
|
+
*/
|
|
24
|
+
readonly revision: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SourceLoadContext {
|
|
27
|
+
readonly previous?: LoadedSource;
|
|
28
|
+
}
|
|
29
|
+
export interface SourceDef<Name extends string = string, Params extends SourceParams = SourceParams> {
|
|
30
|
+
readonly name: Name;
|
|
31
|
+
readonly load: (params: Params, context: SourceLoadContext) => Promise<LoadedSource>;
|
|
32
|
+
ref(params: Params): SourceRef<Name, Params>;
|
|
33
|
+
}
|
|
34
|
+
/** Defines a typed source token and its host-side loader. */
|
|
35
|
+
export declare function defineSource<const Name extends string, const Params extends SourceParams>(definition: {
|
|
36
|
+
readonly name: Name;
|
|
37
|
+
readonly load: SourceDef<Name, Params>['load'];
|
|
38
|
+
}): SourceDef<Name, Params>;
|
|
39
|
+
export declare class UnknownSourceError extends ProcessError {
|
|
40
|
+
}
|
|
41
|
+
export declare class SourceRegistry {
|
|
42
|
+
#private;
|
|
43
|
+
define<const Name extends string, const Params extends SourceParams>(source: SourceDef<Name, Params>): this;
|
|
44
|
+
load(ref: SourceRef, previous?: LoadedSource): Promise<LoadedSource>;
|
|
45
|
+
}
|
|
46
|
+
export declare function createSourceRegistry(): SourceRegistry;
|
|
47
|
+
/** Canonical, order-independent identity for one source invocation. */
|
|
48
|
+
export declare function sourceId(ref: SourceRef): string;
|
|
49
|
+
//# sourceMappingURL=source.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque, asynchronous data sources.
|
|
3
|
+
*
|
|
4
|
+
* A request carries only `{ source, params }`. The loader stays on the host,
|
|
5
|
+
* where credentials, URLs, retries and cache policy belong. The canonical
|
|
6
|
+
* source id chooses a long-lived bound graph; `revision` decides whether that
|
|
7
|
+
* graph's source value must be invalidated.
|
|
8
|
+
*/
|
|
9
|
+
import { ProcessError } from '../errors.js';
|
|
10
|
+
/** Defines a typed source token and its host-side loader. */
|
|
11
|
+
export function defineSource(definition) {
|
|
12
|
+
return {
|
|
13
|
+
...definition,
|
|
14
|
+
ref: (params) => ({ source: definition.name, params }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export class UnknownSourceError extends ProcessError {
|
|
18
|
+
}
|
|
19
|
+
export class SourceRegistry {
|
|
20
|
+
#sources = new Map();
|
|
21
|
+
define(source) {
|
|
22
|
+
this.#sources.set(source.name, {
|
|
23
|
+
name: source.name,
|
|
24
|
+
ref: (params) => ({ source: source.name, params }),
|
|
25
|
+
load: (params, context) => source.load(params, context),
|
|
26
|
+
});
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
async load(ref, previous) {
|
|
30
|
+
const source = this.#sources.get(ref.source);
|
|
31
|
+
if (source === undefined) {
|
|
32
|
+
const have = [...this.#sources.keys()].map((k) => `'${k}'`).join(', ');
|
|
33
|
+
throw new UnknownSourceError(`unknown source '${ref.source}'${have ? ` — have ${have}` : ''}`);
|
|
34
|
+
}
|
|
35
|
+
return source.load(ref.params, {
|
|
36
|
+
...(previous !== undefined && { previous }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function createSourceRegistry() {
|
|
41
|
+
return new SourceRegistry();
|
|
42
|
+
}
|
|
43
|
+
/** Canonical, order-independent identity for one source invocation. */
|
|
44
|
+
export function sourceId(ref) {
|
|
45
|
+
const params = Object.keys(ref.params)
|
|
46
|
+
.sort()
|
|
47
|
+
.map((key) => {
|
|
48
|
+
const value = ref.params[key];
|
|
49
|
+
return `${encodeURIComponent(key)}=${encodeURIComponent(`${typeof value}:${JSON.stringify(value)}`)}`;
|
|
50
|
+
})
|
|
51
|
+
.join('&');
|
|
52
|
+
return `source:${encodeURIComponent(ref.source)}${params ? `?${params}` : ''}`;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=source.js.map
|