@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +5914 -0
  2. package/LICENSE +21 -0
  3. package/README.md +345 -0
  4. package/dist/cjs-fallback.cjs +15 -0
  5. package/dist/column.d.ts +197 -0
  6. package/dist/column.js +306 -0
  7. package/dist/errors.d.ts +22 -0
  8. package/dist/errors.js +25 -0
  9. package/dist/graph.d.ts +89 -0
  10. package/dist/graph.js +133 -0
  11. package/dist/index.d.ts +59 -0
  12. package/dist/index.js +45 -0
  13. package/dist/node.d.ts +151 -0
  14. package/dist/node.js +268 -0
  15. package/dist/plan/builder.d.ts +138 -0
  16. package/dist/plan/builder.js +166 -0
  17. package/dist/plan/fluent.d.ts +93 -0
  18. package/dist/plan/fluent.js +140 -0
  19. package/dist/plan/folds.d.ts +25 -0
  20. package/dist/plan/folds.js +190 -0
  21. package/dist/plan/graph.d.ts +171 -0
  22. package/dist/plan/graph.js +658 -0
  23. package/dist/plan/history.d.ts +61 -0
  24. package/dist/plan/history.js +82 -0
  25. package/dist/plan/host.d.ts +173 -0
  26. package/dist/plan/host.js +234 -0
  27. package/dist/plan/identity.d.ts +81 -0
  28. package/dist/plan/identity.js +158 -0
  29. package/dist/plan/params.d.ts +15 -0
  30. package/dist/plan/params.js +26 -0
  31. package/dist/plan/registry.d.ts +162 -0
  32. package/dist/plan/registry.js +422 -0
  33. package/dist/plan/run.d.ts +211 -0
  34. package/dist/plan/run.js +360 -0
  35. package/dist/plan/slots.d.ts +65 -0
  36. package/dist/plan/slots.js +114 -0
  37. package/dist/plan/source.d.ts +49 -0
  38. package/dist/plan/source.js +54 -0
  39. package/dist/plan/types.d.ts +376 -0
  40. package/dist/plan/types.js +20 -0
  41. package/dist/pool/index.d.ts +15 -0
  42. package/dist/pool/index.js +12 -0
  43. package/dist/pool/pool.d.ts +92 -0
  44. package/dist/pool/pool.js +237 -0
  45. package/dist/pool/protocol.d.ts +48 -0
  46. package/dist/pool/protocol.js +9 -0
  47. package/dist/pool/wire.d.ts +52 -0
  48. package/dist/pool/wire.js +95 -0
  49. package/dist/pool/worker.d.ts +22 -0
  50. package/dist/pool/worker.js +80 -0
  51. package/dist/port.d.ts +79 -0
  52. package/dist/port.js +222 -0
  53. package/dist/source.d.ts +161 -0
  54. package/dist/source.js +182 -0
  55. package/dist/types.d.ts +77 -0
  56. package/dist/types.js +26 -0
  57. package/package.json +50 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A programmable API that **emits** a plan — [PND-PROCBUILD].
3
+ *
4
+ * Plans-as-data is right for a wire format and required for a cache key,
5
+ * but it is not how an application wants to *author* a graph. A consumer
6
+ * building studies over its own metrics should not be assembling nested
7
+ * JSON by hand.
8
+ *
9
+ * ```ts
10
+ * const g = plan('ACME_5m').as('bands_and_stretch');
11
+ * const bb = g.add('bb', 'bollinger', { period: 20 }, ['px']);
12
+ * const z = g.add('z', 'zscore', { period: 20 }, ['px']);
13
+ *
14
+ * g.expose('upper_band', bb.last({ output: 'Upper' }));
15
+ * g.expose('stretch', z.percentileRank());
16
+ * g.expose('band_series', bb.columns());
17
+ *
18
+ * host.run(g.toJSON());
19
+ * ```
20
+ *
21
+ * **Slots are what make this possible**, which is why this depends on
22
+ * [PND-PROCSLOT] rather than standing alone: a builder needs a stable
23
+ * handle to refer back to, and a content-addressed id cannot be one — it
24
+ * changes the moment a param does. A slot is exactly a handle, and
25
+ * passing `bb` rather than the string `'bb'` is what turns a typo into a
26
+ * compile error.
27
+ *
28
+ * **The builder emits a plan; it does not replace one.** `toJSON()`
29
+ * produces the same envelope a model would compose, so there is one
30
+ * resolution path, one cache, and one thing to test — a graph built in
31
+ * code and one composed by a model land on the same nodes.
32
+ *
33
+ * It deliberately knows nothing about the registry: no op-name checking,
34
+ * no param typing. That keeps it a pure authoring layer with no
35
+ * dependency on the op corpus, and leaves the resolver as the single
36
+ * place a bad plan is diagnosed. Typing params off `ParamDef` is the
37
+ * open question in the ticket, not a gap this file is hiding.
38
+ */
39
+ import { ProcessError } from '../errors.js';
40
+ /** Thrown when a graph is built wrong — before it is ever sent. */
41
+ export class BuilderError extends ProcessError {
42
+ }
43
+ /**
44
+ * The derived slot for a fold over `on` — a function of the node, the
45
+ * fold, **and its params**. Params used to be omitted, so
46
+ * `shape({points: 20})` followed by `shape({points: 100})` landed on one
47
+ * slot and the second call silently kept 20. Sorted by key so two
48
+ * spellings of one param set collide deliberately, the same rule
49
+ * `specId` applies one layer down.
50
+ *
51
+ * One asymmetry is inherent: this builder holds no registry, so it
52
+ * cannot resolve defaults — `shape()` and `shape({points: 40})` derive
53
+ * two slots here even though `specId` resolves them to one computation.
54
+ * That is safe: resolution collapses them to one node, and the response
55
+ * labels it with the first slot that named it. The registry-bound
56
+ * fluent layer canonicalizes params before calling this, so it does not
57
+ * split.
58
+ */
59
+ export function foldSlot(on, op, params) {
60
+ const entries = Object.entries(params ?? {});
61
+ if (entries.length === 0)
62
+ return `${on}:${op}`;
63
+ const p = entries
64
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
65
+ .map(([k, v]) => `${k}=${String(v)}`)
66
+ .join(',');
67
+ return `${on}:${op}(${p})`;
68
+ }
69
+ export class PlanBuilder {
70
+ #from;
71
+ #as;
72
+ #nodes = new Map();
73
+ #outputs = new Map();
74
+ constructor(from) {
75
+ this.#from = from;
76
+ }
77
+ /** Names the result, so a later request can refer back to it. */
78
+ as(name) {
79
+ this.#as = name;
80
+ return this;
81
+ }
82
+ /**
83
+ * Adds a node under a caller-chosen slot name.
84
+ *
85
+ * The name is required rather than derived, because it *is* the stable
86
+ * identity — deriving `sma2` from a counter would renumber the moment
87
+ * a node is inserted above it, which is the property slots exist to
88
+ * provide.
89
+ *
90
+ * @throws {BuilderError} if the slot is already taken.
91
+ */
92
+ add(slot, op, params, inputs) {
93
+ if (this.#nodes.has(slot)) {
94
+ throw new BuilderError(`slot '${slot}' is already used by a '${this.#nodes.get(slot).op}' node`);
95
+ }
96
+ this.#nodes.set(slot, {
97
+ op,
98
+ ...(params !== undefined && { params }),
99
+ in: inputs.map((i) => typeof i === 'string'
100
+ ? i
101
+ : 'output' in i
102
+ ? `${i.slot}#${i.output}`
103
+ : i.slot),
104
+ });
105
+ return this.#handle(slot);
106
+ }
107
+ /**
108
+ * Adds a fold over `on`, or returns the one already added.
109
+ *
110
+ * Idempotent because the derived slot is a function of the node, the
111
+ * fold and its params: writing `z.percentileRank()` in two places is
112
+ * one node, which is the same thing content-addressing does one layer
113
+ * down — while two different param sets are two nodes.
114
+ */
115
+ #fold(on, op, params) {
116
+ const slot = foldSlot(on, op, params);
117
+ if (!this.#nodes.has(slot)) {
118
+ this.#nodes.set(slot, {
119
+ op,
120
+ ...(params !== undefined && { params }),
121
+ in: [on],
122
+ });
123
+ }
124
+ return this.#handle(slot);
125
+ }
126
+ #handle(slot) {
127
+ return {
128
+ slot,
129
+ last: () => this.#fold(slot, 'last'),
130
+ extremes: () => this.#fold(slot, 'extremes'),
131
+ percentileRank: () => this.#fold(slot, 'percentileRank'),
132
+ shape: (options = {}) => this.#fold(slot, 'shape', options.points !== undefined ? { points: options.points } : undefined),
133
+ };
134
+ }
135
+ /**
136
+ * Surfaces a node under the caller's own name.
137
+ *
138
+ * Takes a handle, because a selector's only remaining job is to point
139
+ * at a node — what comes back is whatever that node produces.
140
+ *
141
+ * @throws {BuilderError} if the name is already used.
142
+ */
143
+ expose(name, node, options) {
144
+ if (this.#outputs.has(name)) {
145
+ throw new BuilderError(`output '${name}' is already exposed`);
146
+ }
147
+ this.#outputs.set(name, {
148
+ on: node.slot,
149
+ ...(options?.output !== undefined && { output: options.output }),
150
+ });
151
+ return this;
152
+ }
153
+ /** The envelope. Plain JSON — nothing here survives into the request. */
154
+ toJSON() {
155
+ return {
156
+ from: this.#from,
157
+ ...(this.#as !== undefined && { as: this.#as }),
158
+ nodes: Object.fromEntries(this.#nodes),
159
+ outputs: Object.fromEntries(this.#outputs),
160
+ };
161
+ }
162
+ }
163
+ export function plan(from) {
164
+ return new PlanBuilder(from);
165
+ }
166
+ //# sourceMappingURL=builder.js.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Registry-bound fluent authoring.
3
+ *
4
+ * This is a compiler into the slot envelope, not a second execution path.
5
+ * The registry's accumulated literal type supplies op names, params, input
6
+ * roles and output suffixes; runtime plans still pass through normal registry
7
+ * validation because requests may also arrive as JSON.
8
+ */
9
+ import type { OpDef, ParamDef } from './types.js';
10
+ import { type BuiltRequest, type NodeHandle } from './builder.js';
11
+ import type { DefMap, Registry } from './registry.js';
12
+ import type { SourceRef } from './source.js';
13
+ type ValueFor<D extends ParamDef> = D extends {
14
+ readonly kind: 'integer' | 'number';
15
+ } ? number : D extends {
16
+ readonly kind: 'boolean';
17
+ } ? boolean : D extends {
18
+ readonly kind: 'enum';
19
+ readonly of: readonly (infer V extends string)[];
20
+ } ? V : never;
21
+ type ParamsFor<D extends OpDef> = {
22
+ readonly [K in keyof D['params']]?: ValueFor<D['params'][K]>;
23
+ };
24
+ type TailInputs<D extends OpDef> = D['inputs'] extends readonly [
25
+ unknown,
26
+ ...infer Rest
27
+ ] ? Rest : readonly [];
28
+ type ExtraInputsFor<D extends OpDef> = {
29
+ readonly [I in TailInputs<D>[number] as I extends {
30
+ readonly role: infer Role extends string;
31
+ } ? Role : never]: FluentColumnRef<any>;
32
+ };
33
+ export type BuildOptions<D extends OpDef> = Readonly<{
34
+ as: string;
35
+ } & ParamsFor<D> & ExtraInputsFor<D>>;
36
+ type OutputNames<D extends OpDef> = D['outputs'][number]['id'];
37
+ type OpMethods<Defs extends DefMap> = {
38
+ readonly [Name in keyof Defs as Defs[Name] extends OpDef ? Name : never]: Defs[Name] extends OpDef ? (options: BuildOptions<Defs[Name]>) => NodeFor<Defs, Defs[Name]> : never;
39
+ };
40
+ interface Selection {
41
+ readonly handle: NodeHandle;
42
+ readonly output?: string;
43
+ }
44
+ interface ColumnOps<Defs extends DefMap> {
45
+ /** Latest defined value, with its timestamp. */
46
+ last(): FactRef;
47
+ /** Minimum and maximum, each with its timestamp. */
48
+ extremes(): FactRef;
49
+ /** Where the latest value sits in its own history. */
50
+ percentileRank(): FactRef;
51
+ /** A bounded sample of the complete series. */
52
+ shape(options?: {
53
+ points?: number;
54
+ }): FactRef;
55
+ }
56
+ export type FluentColumnRef<Defs extends DefMap> = ColumnOps<Defs> & OpMethods<Defs>;
57
+ export type SelectedColumnRef<Defs extends DefMap> = FluentColumnRef<Defs> & {
58
+ columns(): ColumnSelection;
59
+ };
60
+ export interface FactRef {
61
+ readonly selection: Selection;
62
+ }
63
+ export type SingleColumnNode<Defs extends DefMap> = SelectedColumnRef<Defs> & {
64
+ readonly slot: string;
65
+ };
66
+ export interface MultiColumnNode<Defs extends DefMap, Outputs extends string> {
67
+ readonly slot: string;
68
+ output<Name extends Outputs>(name: Name): SelectedColumnRef<Defs>;
69
+ columns(): ColumnSelection;
70
+ }
71
+ export interface ColumnSelection {
72
+ readonly selection: Selection;
73
+ }
74
+ type NodeFor<Defs extends DefMap, D extends OpDef> = '' extends OutputNames<D> ? SingleColumnNode<Defs> : MultiColumnNode<Defs, OutputNames<D>>;
75
+ export type FluentRequest<From extends string | SourceRef = string> = BuiltRequest<From>;
76
+ export declare class ProcessBuilder<Defs extends DefMap, From extends string | SourceRef = string> {
77
+ #private;
78
+ constructor(registry: Registry<Defs>, from: From);
79
+ /** Refers to one raw numeric column of the bound source. */
80
+ column(name: string): FluentColumnRef<Defs>;
81
+ /** Names the request so another request may refer to its result. */
82
+ as(name: string): this;
83
+ /**
84
+ * Finishes the request and names its surfaced values in one place.
85
+ *
86
+ * The result is still the exact plain-data envelope accepted by `Host`.
87
+ */
88
+ outputs(outputs: Readonly<Record<string, ColumnSelection | FactRef>>): FluentRequest<From>;
89
+ }
90
+ export declare function process<const Defs extends DefMap>(registry: Registry<Defs>, from: string): ProcessBuilder<Defs, string>;
91
+ export declare function process<const Defs extends DefMap, const From extends SourceRef>(registry: Registry<Defs>, from: From): ProcessBuilder<Defs, From>;
92
+ export {};
93
+ //# sourceMappingURL=fluent.d.ts.map
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Registry-bound fluent authoring.
3
+ *
4
+ * This is a compiler into the slot envelope, not a second execution path.
5
+ * The registry's accumulated literal type supplies op names, params, input
6
+ * roles and output suffixes; runtime plans still pass through normal registry
7
+ * validation because requests may also arrive as JSON.
8
+ */
9
+ import { isFold } from './types.js';
10
+ import { BuilderError, foldSlot, PlanBuilder, } from './builder.js';
11
+ const INPUT = Symbol.for('@pond-ts/process/input');
12
+ export class ProcessBuilder {
13
+ #registry;
14
+ #builder;
15
+ #folds = new Map();
16
+ constructor(registry, from) {
17
+ this.#registry = registry;
18
+ this.#builder = new PlanBuilder(from);
19
+ }
20
+ /** Refers to one raw numeric column of the bound source. */
21
+ column(name) {
22
+ return this.#column(name);
23
+ }
24
+ /** Names the request so another request may refer to its result. */
25
+ as(name) {
26
+ this.#builder.as(name);
27
+ return this;
28
+ }
29
+ /**
30
+ * Finishes the request and names its surfaced values in one place.
31
+ *
32
+ * The result is still the exact plain-data envelope accepted by `Host`.
33
+ */
34
+ outputs(outputs) {
35
+ for (const [name, selected] of Object.entries(outputs)) {
36
+ this.#builder.expose(name, selected.selection.handle, selected.selection.output === undefined
37
+ ? undefined
38
+ : { output: selected.selection.output });
39
+ }
40
+ return this.#builder.toJSON();
41
+ }
42
+ #column(input, node, selectedOutput) {
43
+ const target = {
44
+ ...(node !== undefined && {
45
+ slot: node.slot,
46
+ columns: () => ({
47
+ selection: {
48
+ handle: node,
49
+ ...(selectedOutput !== undefined && { output: selectedOutput }),
50
+ },
51
+ }),
52
+ }),
53
+ last: () => this.#fold(input, 'last'),
54
+ extremes: () => this.#fold(input, 'extremes'),
55
+ percentileRank: () => this.#fold(input, 'percentileRank'),
56
+ shape: (options = {}) => this.#fold(input, 'shape', options),
57
+ };
58
+ Object.defineProperty(target, INPUT, { value: input });
59
+ return new Proxy(target, {
60
+ get: (base, property, receiver) => {
61
+ if (Reflect.has(base, property)) {
62
+ return Reflect.get(base, property, receiver);
63
+ }
64
+ if (typeof property !== 'string')
65
+ return undefined;
66
+ const def = this.#registry.get(property);
67
+ if (isFold(def))
68
+ return undefined;
69
+ return (options) => this.#apply(input, def, options);
70
+ },
71
+ });
72
+ }
73
+ #apply(input, def, options) {
74
+ const slot = options['as'];
75
+ if (typeof slot !== 'string' || slot.length === 0) {
76
+ throw new BuilderError(`${def.name} requires a non-empty 'as' slot`);
77
+ }
78
+ const inputs = [input];
79
+ for (const declared of def.inputs.slice(1)) {
80
+ const ref = options[declared.role];
81
+ if (!isFluentColumn(ref)) {
82
+ throw new BuilderError(`${def.name} requires input '${declared.role}' to be a column reference`);
83
+ }
84
+ inputs.push(ref[INPUT]);
85
+ }
86
+ const params = Object.fromEntries(Object.keys(def.params)
87
+ .filter((key) => options[key] !== undefined)
88
+ .map((key) => [key, options[key]]));
89
+ const handle = this.#builder.add(slot, def.name, Object.keys(params).length === 0 ? undefined : params, inputs);
90
+ if (def.outputs.length === 1 && def.outputs[0].id === '') {
91
+ return this.#column(handle, handle);
92
+ }
93
+ return {
94
+ slot,
95
+ output: (name) => {
96
+ if (!def.outputs.some((output) => output.id === name)) {
97
+ throw new BuilderError(`${def.name} has no output '${name}' — has ${def.outputs.map((o) => `'${o.id}'`).join(', ')}`);
98
+ }
99
+ const picked = { slot, output: name };
100
+ return this.#column(picked, handle, name);
101
+ },
102
+ columns: () => ({ selection: { handle } }),
103
+ };
104
+ }
105
+ #fold(input, op, params = {}) {
106
+ const inputName = typeof input === 'string'
107
+ ? `$${input}`
108
+ : 'output' in input
109
+ ? `${input.slot}#${input.output}`
110
+ : input.slot;
111
+ // The slot carries the fold's params — `foldSlot` — so asking the
112
+ // same node for `shape({points: 20})` and `shape({points: 100})`
113
+ // yields two nodes rather than the second silently reusing the
114
+ // first's 20. Params are canonicalized to their POST-DEFAULT form
115
+ // first, which this registry-bound layer can do and the plain
116
+ // builder cannot: `shape()` and `shape({points: 40})` are one
117
+ // computation to `specId`, so they must be one slot here too.
118
+ const given = params.points === undefined ? undefined : { points: params.points };
119
+ const def = this.#registry.get(op);
120
+ const explicit = given;
121
+ const canonical = Object.fromEntries(Object.entries(def.params).map(([key, d]) => [
122
+ key,
123
+ explicit?.[key] ?? d.default,
124
+ ]));
125
+ const slot = foldSlot(inputName, op, canonical);
126
+ let handle = this.#folds.get(slot);
127
+ if (handle === undefined) {
128
+ handle = this.#builder.add(slot, op, given, [input]);
129
+ this.#folds.set(slot, handle);
130
+ }
131
+ return { selection: { handle } };
132
+ }
133
+ }
134
+ function isFluentColumn(value) {
135
+ return (typeof value === 'object' && value !== null && Reflect.has(value, INPUT));
136
+ }
137
+ export function process(registry, from) {
138
+ return new ProcessBuilder(registry, from);
139
+ }
140
+ //# sourceMappingURL=fluent.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The four folds every consumer wants, as ordinary registry entries.
3
+ *
4
+ * These were a hardcoded `reduce` enum on the selector — a second
5
+ * vocabulary alongside the registry, extensible only by editing this
6
+ * library, absent from every id, and recomputed on every request because
7
+ * the graph's memo stopped one step short of the thing callers read.
8
+ *
9
+ * They are pre-registered by `createRegistry()` because they apply to any
10
+ * numeric series and every consumer wants them, not because they are
11
+ * privileged. Each is a plain {@link FoldDef}: a consumer can `define`
12
+ * over a name to replace one, or add its own beside them.
13
+ *
14
+ * `at` is a function rather than an array on purpose. A fold reports two
15
+ * or three rows out of 150,000, and materializing the key column to
16
+ * answer that was most of what the old reductions cost.
17
+ */
18
+ import type { FoldDef } from './types.js';
19
+ export declare const last: FoldDef;
20
+ export declare const extremes: FoldDef;
21
+ export declare const percentileRank: FoldDef;
22
+ export declare const shape: FoldDef;
23
+ /** Registered by `createRegistry()`; nothing stops a consumer replacing one. */
24
+ export declare const STANDARD_FOLDS: readonly FoldDef[];
25
+ //# sourceMappingURL=folds.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The four folds every consumer wants, as ordinary registry entries.
3
+ *
4
+ * These were a hardcoded `reduce` enum on the selector — a second
5
+ * vocabulary alongside the registry, extensible only by editing this
6
+ * library, absent from every id, and recomputed on every request because
7
+ * the graph's memo stopped one step short of the thing callers read.
8
+ *
9
+ * They are pre-registered by `createRegistry()` because they apply to any
10
+ * numeric series and every consumer wants them, not because they are
11
+ * privileged. Each is a plain {@link FoldDef}: a consumer can `define`
12
+ * over a name to replace one, or add its own beside them.
13
+ *
14
+ * `at` is a function rather than an array on purpose. A fold reports two
15
+ * or three rows out of 150,000, and materializing the key column to
16
+ * answer that was most of what the old reductions cost.
17
+ */
18
+ import { int } from './params.js';
19
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
20
+ /**
21
+ * Six decimal places, applied at the boundary rather than in the graph.
22
+ *
23
+ * A column holds full `Float64Array` precision; a fact is read by a
24
+ * person or quoted by a model, and neither wants seventeen digits.
25
+ */
26
+ const round = (v) => Math.round(v * 1e6) / 1e6;
27
+ const source = [{ role: 'source' }];
28
+ export const last = {
29
+ kind: 'fold',
30
+ name: 'last',
31
+ family: 'read',
32
+ summary: 'The most recent defined value, with its date. The usual way to ask "what is it now?".',
33
+ params: {},
34
+ inputs: source,
35
+ unit: 'inherit',
36
+ label: (_p, inputs) => `latest ${inputs}`,
37
+ fold: (ctx) => {
38
+ // The fold that makes [PND-PROCCOL] worth doing: it reads one cell,
39
+ // and on the boxed path it paid to densify all 500,000 first.
40
+ const v = read(ctx);
41
+ for (let i = v.length - 1; i >= 0; i -= 1) {
42
+ if (v.defined(i))
43
+ return { value: round(v.value(i)), at: day(ctx.at(i)) };
44
+ }
45
+ return { value: null };
46
+ },
47
+ };
48
+ export const extremes = {
49
+ kind: 'fold',
50
+ name: 'extremes',
51
+ family: 'read',
52
+ summary: 'The lowest and highest values over the whole series, each with its date.',
53
+ params: {},
54
+ inputs: source,
55
+ unit: 'inherit',
56
+ label: (_p, inputs) => `range of ${inputs}`,
57
+ fold: (ctx) => {
58
+ const v = read(ctx);
59
+ let lo = Infinity;
60
+ let hi = -Infinity;
61
+ let loAt = -1;
62
+ let hiAt = -1;
63
+ for (let i = 0; i < v.length; i += 1) {
64
+ if (!v.defined(i))
65
+ continue;
66
+ const x = v.value(i);
67
+ if (x < lo) {
68
+ lo = x;
69
+ loAt = i;
70
+ }
71
+ if (x > hi) {
72
+ hi = x;
73
+ hiAt = i;
74
+ }
75
+ }
76
+ if (loAt === -1)
77
+ return { min: null, max: null };
78
+ return {
79
+ min: { value: round(lo), at: day(ctx.at(loAt)) },
80
+ max: { value: round(hi), at: day(ctx.at(hiAt)) },
81
+ };
82
+ },
83
+ };
84
+ export const percentileRank = {
85
+ kind: 'fold',
86
+ name: 'percentileRank',
87
+ family: 'read',
88
+ summary: 'Where the latest value sits within the series’ own history, as a 0–1 fraction. Answers "is this unusual?" without needing a second series to compare against.',
89
+ params: {},
90
+ inputs: source,
91
+ // Not the input's unit: a rank is dimensionless, and inheriting made a
92
+ // percentile of an annualised vol report itself as '%/yr'.
93
+ unit: '%ile',
94
+ label: (_p, inputs) => `rank of ${inputs}`,
95
+ fold: (ctx) => {
96
+ const v = read(ctx);
97
+ // One pass, not the three the old reduction took: it densified, then
98
+ // filtered to find the last defined value, then filtered again to
99
+ // count below it.
100
+ let last;
101
+ let seen = 0;
102
+ for (let i = v.length - 1; i >= 0; i -= 1) {
103
+ if (v.defined(i)) {
104
+ last = v.value(i);
105
+ break;
106
+ }
107
+ }
108
+ if (last === undefined)
109
+ return { value: null };
110
+ let below = 0;
111
+ for (let i = 0; i < v.length; i += 1) {
112
+ if (!v.defined(i))
113
+ continue;
114
+ const x = v.value(i);
115
+ seen += 1;
116
+ if (x < last)
117
+ below += 1;
118
+ }
119
+ const fraction = below / seen;
120
+ return {
121
+ value: round(fraction),
122
+ note: `${Math.round(fraction * 100)}th percentile of ${seen} observations`,
123
+ };
124
+ },
125
+ };
126
+ export const shape = {
127
+ kind: 'fold',
128
+ name: 'shape',
129
+ family: 'read',
130
+ summary: 'A bounded sample of the whole series — the honest answer to "show me the series" for a caller paying by the token.',
131
+ // A param rather than a selector field, which is the difference that
132
+ // matters: it lands in the id, so two callers asking for 40 points
133
+ // share one answer instead of computing it twice.
134
+ params: {
135
+ points: int({ min: 2, max: 400, suggest: [20, 120], default: 40 }),
136
+ },
137
+ inputs: source,
138
+ unit: 'inherit',
139
+ label: (p, inputs) => `shape(${String(p['points'])}) of ${inputs}`,
140
+ fold: (ctx) => {
141
+ const v = read(ctx);
142
+ const want = ctx.params['points'];
143
+ // `ceil`, not `floor`: `floor` rounds the stride DOWN, which rounds
144
+ // the sample count UP — 200 points asked of 399 rows gave a stride
145
+ // of 1 and returned all 399, defeating the token bound this fold
146
+ // exists to keep. With `ceil` the sample never exceeds `points`.
147
+ const step = Math.max(1, Math.ceil(v.length / want));
148
+ const points = [];
149
+ for (let i = 0; i < v.length; i += step) {
150
+ if (v.defined(i))
151
+ points.push([day(ctx.at(i)), round(v.value(i))]);
152
+ }
153
+ return { points: points.length, series: points };
154
+ },
155
+ };
156
+ /**
157
+ * One reader over the `source` role, columnar where it can be.
158
+ *
159
+ * `ctx.numeric` is a zero-copy view and allocates nothing; `ctx.values`
160
+ * is the boxed fallback for a role that is not packed numeric, and is a
161
+ * lazy getter, so this only pays for densifying when it has to
162
+ * ([PND-PROCCOL]). Both are behind one shape so a fold body reads the
163
+ * same either way — `defined(i)` then `value(i)`, never a cell that
164
+ * might be `undefined`.
165
+ */
166
+ function read(ctx) {
167
+ const view = ctx.numeric('source');
168
+ if (view !== undefined) {
169
+ const { values } = view;
170
+ return {
171
+ length: view.length,
172
+ defined: (i) => view.defined(i),
173
+ value: (i) => values[i],
174
+ };
175
+ }
176
+ const boxed = ctx.values['source'];
177
+ return {
178
+ length: boxed.length,
179
+ defined: (i) => boxed[i] !== undefined,
180
+ value: (i) => boxed[i],
181
+ };
182
+ }
183
+ /** Registered by `createRegistry()`; nothing stops a consumer replacing one. */
184
+ export const STANDARD_FOLDS = [
185
+ last,
186
+ extremes,
187
+ percentileRank,
188
+ shape,
189
+ ];
190
+ //# sourceMappingURL=folds.js.map