@pond-ts/process 0.61.0 → 0.62.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/API.md CHANGED
@@ -521,31 +521,31 @@ Typed dataflow graphs for pipelines whose **shape is data** (runtime-assembled,
521
521
  user-edited, one computation fanned out to several consumers). Chaining stays
522
522
  the default for pipelines known at authoring time — see the package README.
523
523
 
524
- | Group | Exports | Source |
525
- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
526
- | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
527
- | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
528
- | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
529
- | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
530
- | Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
531
- | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
532
- | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
533
- | Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
534
- | Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
535
- | Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
536
- | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
537
- | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams`, `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
538
- | Plan — identity | `specId` (content-addressed, param-order invariant, defaults materialized), `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
539
- | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
540
- | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError` | `packages/process/src/plan/graph.ts`, `run.ts` |
541
- | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped`, `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
542
- | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
543
- | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
544
- | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
545
- | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
546
- | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
547
- | Errors | `ProcessError` (base), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
548
- | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
524
+ | Group | Exports | Source |
525
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
526
+ | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
527
+ | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
528
+ | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
529
+ | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
530
+ | Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
531
+ | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
532
+ | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
533
+ | Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
534
+ | Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
535
+ | Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
536
+ | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
537
+ | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams` (`{ validate: false }` applies defaults and skips every check), `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
538
+ | Plan — identity | `specId(registry, spec, { validate })` (content-addressed, param-order invariant, defaults materialized; `validate: false` is **total** — names a spec that would not compile in a separate `p1?:` namespace that cannot collide with a valid id; a valid spec's id is identical either way), `SpecIdOptions`, `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
539
+ | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
540
+ | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`, `UnknownColumnError` (a raw string input naming no column of the bound series, checked over the whole closure and re-checked on the warm path) | `packages/process/src/plan/graph.ts`, `run.ts` |
541
+ | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped` (`spec`, `select`, `reason`, `code` — the failure's kind, matching the error class a throw would have carried), `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
542
+ | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
543
+ | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
544
+ | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
545
+ | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
546
+ | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
547
+ | Errors | `ProcessError` (base; `code` — a stable per-class literal, minification-proof, also surfaced on `Skipped`), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
548
+ | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
549
549
 
550
550
  Note: this package's `npm test` includes a `test:dts` step that typechecks the
551
551
  **emitted** `dist/*.d.ts` from a consumer's perspective (`test-dts/`,
package/CHANGELOG.md CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.61.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.62.0...HEAD
12
+ [0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
12
13
  [0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
13
14
  [0.60.0]: https://github.com/pond-ts/pond/compare/v0.59.0...v0.60.0
14
15
  [0.59.0]: https://github.com/pond-ts/pond/compare/v0.58.0...v0.59.0
@@ -64,6 +65,69 @@ include new features and type-level changes; patch bumps are strictly additive.
64
65
 
65
66
  ## [Unreleased]
66
67
 
68
+ ## [0.62.0] — 2026-08-16
69
+
70
+ ### Added
71
+
72
+ - `@pond-ts/process`: **`specId` is total under `{ validate: false }`** — a
73
+ third options argument that names a spec which would not compile
74
+ (`specId(registry, spec, { validate: false })`). Identity used to be coupled
75
+ to validity, so the moments a consumer most needs an id — labelling the chip
76
+ it is skipping, keying "this persisted entry is broken", logging what was
77
+ rejected — were exactly the moments it threw, leaving the consumer to
78
+ re-implement canonicalization or carry a second key. Lenient mode still
79
+ applies defaults and sorts keys, carries an undeclared param through rather
80
+ than dropping it, and recurses into nested inputs; **a valid spec has the
81
+ same id under either mode**, so nothing needs a second cache line. Validity
82
+ stays `compile`'s job. `Registry.resolveParams` takes the same
83
+ `{ validate: false }`.
84
+
85
+ An id that did **not** validate is minted in a separate namespace — marked
86
+ `p1?:` instead of `p1:`, with type-preserving param encoding — so it can
87
+ never collide with a legal id. Both measures are confined to that branch:
88
+ a valid id is byte-identical to what shipped in 0.61.0. The mark rides up a
89
+ chain, so a spec over an unvalidated input is unvalidated too.
90
+
91
+ - `@pond-ts/process`: **`Skipped.code`** — every entry in `RunResult.skipped`
92
+ now carries the failure's kind (`'UnknownColumnError'`, `'ParamError'`,
93
+ `'UnitError'`, `'SlotError'`, …) beside its human `reason`. Under
94
+ `onError: 'skip' | 'collect'` nothing is thrown, so `instanceof` — the right
95
+ discriminator when a consumer catches — never reached a consumer reading
96
+ `skipped`, leaving it to match on prose whose wording is not a contract. The
97
+ value is `ProcessError.code`, a **literal declared per class** rather than
98
+ `constructor.name`, so a consumer's minifier cannot silently rename it. It is
99
+ absent when the throw did not come from this package, which is itself the
100
+ signal: op code failed, not the plan layer.
101
+
102
+ ### Fixed
103
+
104
+ - `@pond-ts/process`: **`run` under `onError: 'throw'` — the default — now
105
+ raises the original error rather than a base `ProcessError` rebuilt from its
106
+ message.** A caught `UnknownColumnError`, `ParamError`, `UnitError` or
107
+ `SlotError` reached the caller as a bare `ProcessError`, so `instanceof`
108
+ could not discriminate on the throw path at all.
109
+
110
+ - `@pond-ts/process`: **a raw string input naming a column the bound series
111
+ does not carry is now rejected**, at `compile`, with a new
112
+ `UnknownColumnError`. Nothing checked it at compile or at pull: the op ran
113
+ against an un-widened series, and one that doesn't defend its own inputs
114
+ appended a plausible-looking column of garbage under the spec's id — with
115
+ `skipped` empty and `onError` never engaged. A persisted plan citing a column
116
+ the feed has since dropped now skips (or throws) instead of returning a
117
+ value. The check is the one `expandSlots` already made against the same
118
+ column list, so the two request forms no longer disagree; it runs before the
119
+ unit check, whose "is 'unitless'" answer for an absent column named the wrong
120
+ problem. The key/time column is not a value column and is rejected too.
121
+
122
+ The check covers the **whole spec closure** (a missing column under a typed
123
+ parent otherwise surfaced as `UnitError`) and runs on the **warm** path as
124
+ well as the cold one — `setSource` replaces the data under compiled nodes by
125
+ design, so a memoized node could outlive the column it reads and go on
126
+ emitting the garbage column this fix exists to prevent. A node that fails the
127
+ re-check is dropped from the graph.
128
+ (Both items reported by Tidal —
129
+ `docs/notes/tidal-process-adoption-friction-2026-08.md`.)
130
+
67
131
  ## [0.61.0] — 2026-08-16
68
132
 
69
133
  ### Added
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,38 @@
1
1
  /** Errors thrown by the graph engine. */
2
- /** Base class for every error this package throws. */
2
+ /**
3
+ * Base class for every error this package throws.
4
+ *
5
+ * ## Why there is a `code` beside `name`
6
+ *
7
+ * `instanceof` is the right discriminator when a consumer **catches**.
8
+ * But `run` under `onError: 'skip' | 'collect'` does not hand the error
9
+ * back — it reports a `Skipped` record — and a consumer branching
10
+ * its UI on the failure kind (a dropped feed column is a dimmed chip; a
11
+ * bad persisted param is a broken one) then has only prose to go on
12
+ * (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
13
+ *
14
+ * Every subclass annotates `code` as `: string` rather than letting the
15
+ * literal type be inferred. Without it the static's type is the literal
16
+ * — `ParamError.code: 'ParamError'` — and a **consumer** subclassing to
17
+ * add its own code fails to compile with TS2417, which would make this
18
+ * additive field a source break for anyone already extending these
19
+ * classes (Codex, PR #667).
20
+ *
21
+ * `code` is that discriminator, and it is a **literal string per class**
22
+ * rather than `constructor.name` because a consumer's minifier may
23
+ * rename the class — leaving `name` as `'t'` in a production build,
24
+ * silently, which is exactly the shape of bug the string exists to let
25
+ * them avoid. Declared once as a `static`, read here through
26
+ * `new.target`, so a subclass states its code on one line.
27
+ */
3
28
  export declare class ProcessError extends Error {
29
+ /** The literal a subclass overrides. Read via `new.target`, not `this`. */
30
+ static readonly code: string;
31
+ /**
32
+ * Stable kind, safe to compare against a literal and to send over a
33
+ * wire. Rides on `Skipped.code` when a run reports rather than throws.
34
+ */
35
+ readonly code: string;
4
36
  constructor(message: string);
5
37
  }
6
38
  /**
@@ -9,14 +41,17 @@ export declare class ProcessError extends Error {
9
41
  * so a cycle surfaces at the line that wires it, not at some later pull.
10
42
  */
11
43
  export declare class CycleError extends ProcessError {
44
+ static readonly code: string;
12
45
  }
13
46
  /** Thrown when pulling through an inlet with no connection and no default. */
14
47
  export declare class UnconnectedInputError extends ProcessError {
48
+ static readonly code: string;
15
49
  }
16
50
  /**
17
51
  * Thrown when a node's `compute` omits a declared output, or when an
18
52
  * outlet is read before anything has produced a value for it.
19
53
  */
20
54
  export declare class MissingOutputError extends ProcessError {
55
+ static readonly code: string;
21
56
  }
22
57
  //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js CHANGED
@@ -1,9 +1,42 @@
1
1
  /** Errors thrown by the graph engine. */
2
- /** Base class for every error this package throws. */
2
+ /**
3
+ * Base class for every error this package throws.
4
+ *
5
+ * ## Why there is a `code` beside `name`
6
+ *
7
+ * `instanceof` is the right discriminator when a consumer **catches**.
8
+ * But `run` under `onError: 'skip' | 'collect'` does not hand the error
9
+ * back — it reports a `Skipped` record — and a consumer branching
10
+ * its UI on the failure kind (a dropped feed column is a dimmed chip; a
11
+ * bad persisted param is a broken one) then has only prose to go on
12
+ * (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
13
+ *
14
+ * Every subclass annotates `code` as `: string` rather than letting the
15
+ * literal type be inferred. Without it the static's type is the literal
16
+ * — `ParamError.code: 'ParamError'` — and a **consumer** subclassing to
17
+ * add its own code fails to compile with TS2417, which would make this
18
+ * additive field a source break for anyone already extending these
19
+ * classes (Codex, PR #667).
20
+ *
21
+ * `code` is that discriminator, and it is a **literal string per class**
22
+ * rather than `constructor.name` because a consumer's minifier may
23
+ * rename the class — leaving `name` as `'t'` in a production build,
24
+ * silently, which is exactly the shape of bug the string exists to let
25
+ * them avoid. Declared once as a `static`, read here through
26
+ * `new.target`, so a subclass states its code on one line.
27
+ */
3
28
  export class ProcessError extends Error {
29
+ /** The literal a subclass overrides. Read via `new.target`, not `this`. */
30
+ static code = 'ProcessError';
31
+ /**
32
+ * Stable kind, safe to compare against a literal and to send over a
33
+ * wire. Rides on `Skipped.code` when a run reports rather than throws.
34
+ */
35
+ code;
4
36
  constructor(message) {
5
37
  super(message);
6
38
  this.name = new.target.name;
39
+ this.code = new.target.code;
7
40
  }
8
41
  }
9
42
  /**
@@ -12,14 +45,17 @@ export class ProcessError extends Error {
12
45
  * so a cycle surfaces at the line that wires it, not at some later pull.
13
46
  */
14
47
  export class CycleError extends ProcessError {
48
+ static code = 'CycleError';
15
49
  }
16
50
  /** Thrown when pulling through an inlet with no connection and no default. */
17
51
  export class UnconnectedInputError extends ProcessError {
52
+ static code = 'UnconnectedInputError';
18
53
  }
19
54
  /**
20
55
  * Thrown when a node's `compute` omits a declared output, or when an
21
56
  * outlet is read before anything has produced a value for it.
22
57
  */
23
58
  export class MissingOutputError extends ProcessError {
59
+ static code = 'MissingOutputError';
24
60
  }
25
61
  //# sourceMappingURL=errors.js.map
package/dist/index.d.ts CHANGED
@@ -41,8 +41,9 @@ export type { DefMap } from './plan/registry.js';
41
41
  export { isFold } from './plan/types.js';
42
42
  export { STANDARD_FOLDS, last, extremes, percentileRank, shape, } from './plan/folds.js';
43
43
  export type { OpDescriptor } from './plan/registry.js';
44
+ export type { SpecIdOptions } from './plan/identity.js';
44
45
  export { specId, refToId, explain, unitOf, columnsOf, dependsOn, outputKey, } from './plan/identity.js';
45
- export { bind, BoundGraph, UnitError } from './plan/graph.js';
46
+ export { bind, BoundGraph, UnitError, UnknownColumnError, } from './plan/graph.js';
46
47
  export { expandSlots, SlotError } from './plan/slots.js';
47
48
  export { plan, PlanBuilder, BuilderError } from './plan/builder.js';
48
49
  export type { NodeHandle, OutputHandle, InputRef, BuiltRequest, } from './plan/builder.js';
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ export { UnknownOpError, ParamError } from './plan/registry.js';
35
35
  export { isFold } from './plan/types.js';
36
36
  export { STANDARD_FOLDS, last, extremes, percentileRank, shape, } from './plan/folds.js';
37
37
  export { specId, refToId, explain, unitOf, columnsOf, dependsOn, outputKey, } from './plan/identity.js';
38
- export { bind, BoundGraph, UnitError } from './plan/graph.js';
38
+ export { bind, BoundGraph, UnitError, UnknownColumnError, } from './plan/graph.js';
39
39
  export { expandSlots, SlotError } from './plan/slots.js';
40
40
  export { plan, PlanBuilder, BuilderError } from './plan/builder.js';
41
41
  export { process, ProcessBuilder } from './plan/fluent.js';
@@ -43,6 +43,7 @@ import type { Slots } from './slots.js';
43
43
  import type { ParamValue } from './types.js';
44
44
  /** Thrown when a graph is built wrong — before it is ever sent. */
45
45
  export declare class BuilderError extends ProcessError {
46
+ static readonly code: string;
46
47
  }
47
48
  /**
48
49
  * The derived slot for a fold over `on` — a function of the node, the
@@ -39,6 +39,7 @@
39
39
  import { ProcessError } from '../errors.js';
40
40
  /** Thrown when a graph is built wrong — before it is ever sent. */
41
41
  export class BuilderError extends ProcessError {
42
+ static code = 'BuilderError';
42
43
  }
43
44
  /**
44
45
  * The derived slot for a fold over `on` — a function of the node, the
@@ -15,6 +15,16 @@ import type { Registry } from './registry.js';
15
15
  import { type FactBody, type Params, type Spec, type Units } from './types.js';
16
16
  /** Thrown when an op demands an input unit its source does not carry. */
17
17
  export declare class UnitError extends ProcessError {
18
+ static readonly code: string;
19
+ }
20
+ /**
21
+ * Thrown when a spec's raw input names a column the bound series does
22
+ * not carry — its own class, because a persisted plan outliving a column
23
+ * is the one compile failure a consumer routinely expects and wants to
24
+ * handle rather than report as a bug.
25
+ */
26
+ export declare class UnknownColumnError extends ProcessError {
27
+ static readonly code: string;
18
28
  }
19
29
  /** One node per spec, plus the spec and params it was compiled from. */
20
30
  interface Compiled {
@@ -18,6 +18,20 @@ import { columnsOf, specId, unitOf } from './identity.js';
18
18
  import { isFold, isPicked, specOf, } from './types.js';
19
19
  /** Thrown when an op demands an input unit its source does not carry. */
20
20
  export class UnitError extends ProcessError {
21
+ static code = 'UnitError';
22
+ }
23
+ /**
24
+ * Thrown when a spec's raw input names a column the bound series does
25
+ * not carry — its own class, because a persisted plan outliving a column
26
+ * is the one compile failure a consumer routinely expects and wants to
27
+ * handle rather than report as a bug.
28
+ */
29
+ export class UnknownColumnError extends ProcessError {
30
+ static code = 'UnknownColumnError';
31
+ }
32
+ /** The bound series' value columns — the key column is not readable as one. */
33
+ function valueColumns(series) {
34
+ return series.schema.slice(1).map((c) => c.name);
21
35
  }
22
36
  /** The per-output key a node's outlets are addressed by. */
23
37
  function outletKey(output) {
@@ -221,6 +235,29 @@ export class BoundGraph {
221
235
  }
222
236
  }
223
237
  }
238
+ /**
239
+ * Drops a node's lookup AND its wiring — the same two halves eviction
240
+ * needs, without the budget's accounting.
241
+ *
242
+ * Deleting from `#nodes` alone frees nothing and leaves the node
243
+ * reachable from the source: `Outlet.#downstream` holds a strong
244
+ * `Set<Inlet>` and `Inlet.node` points back (learned on PR #571).
245
+ */
246
+ #forget(id) {
247
+ const compiled = this.#nodes.get(id);
248
+ if (compiled === undefined)
249
+ return;
250
+ for (const inlet of Object.values(compiled.node.in)) {
251
+ inlet.disconnect();
252
+ }
253
+ this.#nodes.delete(id);
254
+ this.#lru.delete(id);
255
+ this.#pendingFrom.delete(id);
256
+ this.#fullDirty.delete(id);
257
+ for (const upstream of compiled.upstream) {
258
+ this.#nodes.get(upstream)?.dependents.delete(id);
259
+ }
260
+ }
224
261
  #touch(id) {
225
262
  this.#lru.delete(id);
226
263
  this.#lru.add(id);
@@ -289,6 +326,48 @@ export class BoundGraph {
289
326
  this.#touch(id);
290
327
  return hit;
291
328
  }
329
+ /**
330
+ * Every raw input in a spec's whole closure names a column the bound
331
+ * series carries — [PND-PROCTOTAL].
332
+ *
333
+ * Nothing used to check this, at compile OR at pull: the op simply ran
334
+ * with `ctx.series` un-widened, and whatever a non-defensive op
335
+ * returned was appended under the spec's id. `skipped` stayed empty
336
+ * and `onError` never engaged, so a persisted plan citing a column the
337
+ * feed had dropped produced a plausible-looking column of garbage
338
+ * (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
339
+ * The honest outcomes are a skip or a throw, never a value.
340
+ *
341
+ * The check is not new so much as **completed**: `expandSlots` already
342
+ * rejects exactly this against exactly this column list, so one of the
343
+ * two request forms caught it and the other did not.
344
+ *
345
+ * **The whole closure, not just this spec's own inputs**, and that
346
+ * matters for the error a consumer sees rather than for whether one is
347
+ * raised: the typed-unit pass below resolves a nested input's unit
348
+ * before recursion ever reaches the nested spec, so a missing column
349
+ * under a *typed* parent surfaced as `UnitError` — "is 'unitless'",
350
+ * which is true and names the wrong problem, and misses the one class
351
+ * a consumer branches on (Codex, PR #667).
352
+ */
353
+ #checkRawInputs(spec) {
354
+ const columns = valueColumns(this.series);
355
+ const walk = (at) => {
356
+ const def = this.registry.get(at.op);
357
+ at.inputs.forEach((raw, i) => {
358
+ if (typeof raw !== 'string') {
359
+ walk(specOf(raw));
360
+ return;
361
+ }
362
+ if (columns.includes(raw))
363
+ return;
364
+ throw new UnknownColumnError(`'${at.op}' names '${raw}' for input '${def.inputs[i]?.role ?? i}', which is not a column of the bound series — columns are ${columns.length === 0
365
+ ? 'none'
366
+ : columns.map((c) => `'${c}'`).join(', ')}`);
367
+ });
368
+ };
369
+ walk(spec);
370
+ }
292
371
  /**
293
372
  * Compiles a spec (and its inputs) into nodes, memoized by `specId`.
294
373
  *
@@ -309,6 +388,26 @@ export class BoundGraph {
309
388
  const id = specId(this.registry, spec);
310
389
  const existing = this.#nodes.get(id);
311
390
  if (existing) {
391
+ // Re-checked on the WARM path too, not just on the way in.
392
+ // `setSource` replaces the data under compiled nodes by design
393
+ // (`Host.add`, an async source refresh), so a node can outlive the
394
+ // column it reads — and it then recomputes happily against an
395
+ // un-widened series, which is the original garbage-column bug
396
+ // surviving through the memo. Found by the Codex review of
397
+ // PR #667, reproduced: swap `px` for `other`, re-run the same
398
+ // spec, get a column of 42s with `skipped: []`.
399
+ try {
400
+ this.#checkRawInputs(spec);
401
+ }
402
+ catch (e) {
403
+ // And FORGET it, rather than only refusing to hand it back here.
404
+ // A node left in place is still found by `graph.get`, which is
405
+ // how `run`'s selector pass reaches a node — so the plan pass
406
+ // reported the skip and the selector pulled the stale node
407
+ // anyway, producing the very column the check exists to prevent.
408
+ this.#forget(id);
409
+ throw e;
410
+ }
312
411
  this.#touch(id);
313
412
  return existing;
314
413
  }
@@ -317,6 +416,10 @@ export class BoundGraph {
317
416
  if (spec.inputs.length !== op.inputs.length) {
318
417
  throw new ProcessError(`${spec.op} takes ${op.inputs.length} input(s), got ${spec.inputs.length}`);
319
418
  }
419
+ // After arity — an input index past the declared list is an arity
420
+ // problem, not a column one — and before the typed-unit pass, whose
421
+ // answer for an absent column is a misleading 'unitless'.
422
+ this.#checkRawInputs(spec);
320
423
  // Typed inputs: an op may demand a unit its source must already
321
424
  // carry. Checked before compiling so the reason names both sides.
322
425
  op.inputs.forEach((def, i) => {
@@ -21,6 +21,7 @@ import { type SourceRef, type SourceRegistry } from './source.js';
21
21
  import type { Plan, Units } from './types.js';
22
22
  /** Thrown when a request names a dataset the host has not been given. */
23
23
  export declare class UnknownDatasetError extends ProcessError {
24
+ static readonly code: string;
24
25
  }
25
26
  /**
26
27
  * A request as it arrives over a wire.
package/dist/plan/host.js CHANGED
@@ -17,6 +17,7 @@ import { run } from './run.js';
17
17
  import { sourceId, } from './source.js';
18
18
  /** Thrown when a request names a dataset the host has not been given. */
19
19
  export class UnknownDatasetError extends ProcessError {
20
+ static code = 'UnknownDatasetError';
20
21
  }
21
22
  function isLocalEnvelope(envelope) {
22
23
  return typeof envelope.from === 'string';
@@ -36,14 +36,61 @@
36
36
  */
37
37
  import type { Registry } from './registry.js';
38
38
  import type { Params, Spec, SpecRef, Units } from './types.js';
39
+ /** Options for {@link specId}. */
40
+ export interface SpecIdOptions {
41
+ /**
42
+ * Whether the op must exist and its params must be legal — default
43
+ * `true`.
44
+ *
45
+ * Pass `false` to name a spec that would not compile. See
46
+ * {@link specId} for why identity is separable from validity.
47
+ */
48
+ readonly validate?: boolean;
49
+ }
39
50
  /**
40
51
  * Canonical, versioned id for a spec — simultaneously the **column
41
52
  * name**, the **cache key**, and the **provenance citation**.
42
53
  *
43
54
  * Params are sorted by key and materialized post-defaults, so two
44
55
  * spellings of one computation collide deliberately.
56
+ *
57
+ * ## Identity is separable from validity — [PND-PROCTOTAL]
58
+ *
59
+ * By default this validates as it goes, because it resolves params to
60
+ * canonicalize them and an id built from a rejected param would be a
61
+ * cache key for a node that cannot exist.
62
+ *
63
+ * But the moments a consumer most needs an id for an **invalid** spec
64
+ * are exactly the failure paths: labelling the chip it is skipping,
65
+ * keying the "this one is broken" UI state, logging which persisted
66
+ * entry was rejected. Coupling the two left the consumer
67
+ * re-implementing canonicalization — the one thing this function exists
68
+ * to own — or carrying a second key beside a correct one (Tidal,
69
+ * `docs/notes/tidal-process-adoption-friction-2026-08.md`).
70
+ *
71
+ * So `specId(registry, spec, { validate: false })` is **total**: an
72
+ * unknown op keeps its given params verbatim, a known one still gets
73
+ * its defaults applied and its keys sorted, and nothing throws.
74
+ * Validity stays `compile`'s job.
75
+ *
76
+ * **A valid spec has one id under either mode.** Canonicalization is
77
+ * the same code path and `checkParam` never coerces, so the lenient id
78
+ * of a legal spec is the strict one — a consumer may key on it without
79
+ * a second cache line.
80
+ *
81
+ * **An unvalidated id cannot collide with a valid one**, and that takes
82
+ * more than putting the params in: it is marked `p1?:` rather than
83
+ * `p1:`, and its params are encoded type-preservingly. Without both, a
84
+ * spec whose param arrived as `"20"` instead of `20` — a JSON round
85
+ * trip, the very case this mode is for — named the *working* node, since
86
+ * `String(v)` erases the difference `checkParam` would have caught. A
87
+ * valid id is unaffected by either measure, which is why they are
88
+ * confined to this branch.
89
+ *
90
+ * The mark rides **up** a chain: a spec whose nested input did not
91
+ * validate cannot compile either, so it is unvalidated too.
45
92
  */
46
- export declare function specId(registry: Registry, spec: Spec): string;
93
+ export declare function specId(registry: Registry, spec: Spec, options?: SpecIdOptions): string;
47
94
  /** Resolves a reference that may be an inline spec or an id string. */
48
95
  export declare function refToId(registry: Registry, ref: SpecRef): string;
49
96
  /**
@@ -44,32 +44,133 @@ const VERSION = 'p1';
44
44
  function esc(v) {
45
45
  return String(v).replace(/[\\;,()=+]/g, (c) => `\\${c}`);
46
46
  }
47
+ /**
48
+ * Marks an id whose spec did **not** validate — see {@link specId}.
49
+ *
50
+ * A valid id is `p1:…`, so an unvalidated one differs at the character
51
+ * after the version and can never equal one, whatever its params say.
52
+ */
53
+ const UNVALIDATED = '?';
54
+ /**
55
+ * Type-preserving encoding, used **only** inside an unvalidated id.
56
+ *
57
+ * `esc` is `String(v)`, which erases type — so `{period: '20'}` and
58
+ * `{period: 20}` encode identically. Strict mode is safe from that
59
+ * because `checkParam` rejects the string before it is ever encoded;
60
+ * leniency removes that guard, and a JSON or form round-trip turning a
61
+ * number into a string is precisely the broken persisted spec this whole
62
+ * mode exists to name (found by the Layer 2 and Codex reviews of
63
+ * PR #667, which reproduced `'20'` colliding with `20`).
64
+ *
65
+ * Applied to keys too, because leniency carries an **undeclared** param
66
+ * through: a key spelled `a=1,b` would otherwise forge the encoding of
67
+ * two params. Neither change touches a valid id, which is the property
68
+ * that must not move.
69
+ */
70
+ function typedEsc(v) {
71
+ return esc(`${typeof v}:${String(v)}`);
72
+ }
47
73
  /**
48
74
  * Canonical, versioned id for a spec — simultaneously the **column
49
75
  * name**, the **cache key**, and the **provenance citation**.
50
76
  *
51
77
  * Params are sorted by key and materialized post-defaults, so two
52
78
  * spellings of one computation collide deliberately.
79
+ *
80
+ * ## Identity is separable from validity — [PND-PROCTOTAL]
81
+ *
82
+ * By default this validates as it goes, because it resolves params to
83
+ * canonicalize them and an id built from a rejected param would be a
84
+ * cache key for a node that cannot exist.
85
+ *
86
+ * But the moments a consumer most needs an id for an **invalid** spec
87
+ * are exactly the failure paths: labelling the chip it is skipping,
88
+ * keying the "this one is broken" UI state, logging which persisted
89
+ * entry was rejected. Coupling the two left the consumer
90
+ * re-implementing canonicalization — the one thing this function exists
91
+ * to own — or carrying a second key beside a correct one (Tidal,
92
+ * `docs/notes/tidal-process-adoption-friction-2026-08.md`).
93
+ *
94
+ * So `specId(registry, spec, { validate: false })` is **total**: an
95
+ * unknown op keeps its given params verbatim, a known one still gets
96
+ * its defaults applied and its keys sorted, and nothing throws.
97
+ * Validity stays `compile`'s job.
98
+ *
99
+ * **A valid spec has one id under either mode.** Canonicalization is
100
+ * the same code path and `checkParam` never coerces, so the lenient id
101
+ * of a legal spec is the strict one — a consumer may key on it without
102
+ * a second cache line.
103
+ *
104
+ * **An unvalidated id cannot collide with a valid one**, and that takes
105
+ * more than putting the params in: it is marked `p1?:` rather than
106
+ * `p1:`, and its params are encoded type-preservingly. Without both, a
107
+ * spec whose param arrived as `"20"` instead of `20` — a JSON round
108
+ * trip, the very case this mode is for — named the *working* node, since
109
+ * `String(v)` erases the difference `checkParam` would have caught. A
110
+ * valid id is unaffected by either measure, which is why they are
111
+ * confined to this branch.
112
+ *
113
+ * The mark rides **up** a chain: a spec whose nested input did not
114
+ * validate cannot compile either, so it is unvalidated too.
53
115
  */
54
- export function specId(registry, spec) {
55
- const op = registry.get(spec.op);
56
- const params = registry.resolveParams(op, spec.params);
57
- const p = Object.entries(params)
58
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
59
- .map(([k, v]) => `${k}=${esc(v)}`)
60
- .join(',');
61
- const inputs = spec.inputs
116
+ export function specId(registry, spec, options = {}) {
117
+ return build(registry, spec, options.validate === false).id;
118
+ }
119
+ /** An id, and whether anything in its closure failed validation. */
120
+ function build(registry, spec, lenient) {
121
+ let unvalidated = false;
122
+ // Inputs first: a nested spec that did not validate marks this one,
123
+ // and the mark has to be known before the params are encoded.
124
+ //
125
+ // `?? []` because a spec arriving from persistence may be missing the
126
+ // field entirely, and a mode whose promise is totality cannot answer a
127
+ // dropped key with a TypeError. Strict mode reaches `compile`'s arity
128
+ // check instead, which says what is actually wrong.
129
+ const inputs = (spec.inputs ?? [])
62
130
  .map((i) => {
63
131
  if (typeof i === 'string')
64
132
  return esc(i);
65
133
  // `#Lower` rather than a separate field: an input picking a
66
134
  // different output is a different computation, and the id is what
67
135
  // says so.
68
- const base = specId(registry, specOf(i));
69
- return isPicked(i) ? `${base}#${esc(i.output)}` : base;
136
+ const base = build(registry, specOf(i), lenient);
137
+ if (base.unvalidated)
138
+ unvalidated = true;
139
+ return isPicked(i) ? `${base.id}#${esc(i.output)}` : base.id;
70
140
  })
71
141
  .join('+');
72
- return `${VERSION}:${spec.op}(${inputs};${p})`;
142
+ let params;
143
+ if (lenient && !registry.has(spec.op)) {
144
+ unvalidated = true;
145
+ params = spec.params ?? {};
146
+ }
147
+ else {
148
+ const op = registry.get(spec.op);
149
+ try {
150
+ // The strict resolve first even under leniency: when it succeeds
151
+ // the id is byte-identical to the validating one, which is the
152
+ // whole contract. Only its failure moves this spec into the
153
+ // unvalidated namespace.
154
+ params = registry.resolveParams(op, spec.params);
155
+ }
156
+ catch (e) {
157
+ if (!lenient)
158
+ throw e;
159
+ unvalidated = true;
160
+ params = registry.resolveParams(op, spec.params, { validate: false });
161
+ }
162
+ }
163
+ const encodeKey = unvalidated ? esc : (k) => k;
164
+ const encodeValue = unvalidated ? typedEsc : esc;
165
+ const p = Object.entries(params)
166
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
167
+ .map(([k, v]) => `${encodeKey(k)}=${encodeValue(v)}`)
168
+ .join(',');
169
+ const mark = unvalidated ? UNVALIDATED : '';
170
+ return {
171
+ id: `${VERSION}${mark}:${spec.op}(${inputs};${p})`,
172
+ unvalidated,
173
+ };
73
174
  }
74
175
  /** Resolves a reference that may be an inline spec or an id string. */
75
176
  export function refToId(registry, ref) {
@@ -26,9 +26,11 @@ type OutputShape = {
26
26
  };
27
27
  /** Thrown when a plan names an op the registry does not have. */
28
28
  export declare class UnknownOpError extends ProcessError {
29
+ static readonly code: string;
29
30
  }
30
31
  /** Thrown when a param is missing, mistyped, or out of range. */
31
32
  export declare class ParamError extends ProcessError {
33
+ static readonly code: string;
32
34
  }
33
35
  /** Op metadata as a picker or a tool catalog wants it. */
34
36
  export interface OpDescriptor {
@@ -87,8 +89,20 @@ export declare class Registry<Defs extends DefMap = {}> {
87
89
  * an optional-chain at each of the nine call sites.
88
90
  */
89
91
  outputsOf(def: Def): readonly OutputShape[];
90
- /** Applies defaults, then validates every declared param. */
91
- resolveParams(op: Def, given?: Readonly<Record<string, ParamValue>>): Params;
92
+ /**
93
+ * Applies defaults, then validates every declared param.
94
+ *
95
+ * `validate: false` applies the defaults and skips every check,
96
+ * including the unknown-key rejection — an undeclared param is carried
97
+ * through rather than refused. Total by construction, which is what
98
+ * lets `specId` name a spec it would refuse to compile
99
+ * ([PND-PROCTOTAL]). A **valid** spec resolves identically either way:
100
+ * `checkParam` returns its input unchanged and never coerces, so
101
+ * leniency cannot move an id.
102
+ */
103
+ resolveParams(op: Def, given?: Readonly<Record<string, ParamValue>>, options?: {
104
+ validate?: boolean;
105
+ }): Params;
92
106
  /** Grouped for a picker. */
93
107
  byFamily(): Map<string, OpDescriptor[]>;
94
108
  describe(): OpDescriptor[];
@@ -18,9 +18,11 @@ export { int, num, choice, flag } from './params.js';
18
18
  import { isFold } from './types.js';
19
19
  /** Thrown when a plan names an op the registry does not have. */
20
20
  export class UnknownOpError extends ProcessError {
21
+ static code = 'UnknownOpError';
21
22
  }
22
23
  /** Thrown when a param is missing, mistyped, or out of range. */
23
24
  export class ParamError extends ProcessError {
25
+ static code = 'ParamError';
24
26
  }
25
27
  /**
26
28
  * Validates one param and returns it.
@@ -170,15 +172,33 @@ export class Registry {
170
172
  outputsOf(def) {
171
173
  return isFold(def) ? [] : def.outputs;
172
174
  }
173
- /** Applies defaults, then validates every declared param. */
174
- resolveParams(op, given = {}) {
175
+ /**
176
+ * Applies defaults, then validates every declared param.
177
+ *
178
+ * `validate: false` applies the defaults and skips every check,
179
+ * including the unknown-key rejection — an undeclared param is carried
180
+ * through rather than refused. Total by construction, which is what
181
+ * lets `specId` name a spec it would refuse to compile
182
+ * ([PND-PROCTOTAL]). A **valid** spec resolves identically either way:
183
+ * `checkParam` returns its input unchanged and never coerces, so
184
+ * leniency cannot move an id.
185
+ */
186
+ resolveParams(op, given = {}, options = {}) {
187
+ const validate = options.validate !== false;
175
188
  const out = {};
176
189
  for (const [key, def] of Object.entries(op.params)) {
177
190
  const raw = Object.hasOwn(given, key) ? given[key] : def.default;
178
- out[key] = checkParam(op.name, key, def, raw);
191
+ out[key] = validate ? checkParam(op.name, key, def, raw) : raw;
179
192
  }
180
193
  for (const key of Object.keys(given)) {
181
194
  if (!Object.hasOwn(op.params, key)) {
195
+ // Carried, not dropped: two specs differing only in a bogus param
196
+ // are two different broken specs, and an id that collapsed them
197
+ // would key one UI state onto both.
198
+ if (!validate) {
199
+ out[key] = given[key];
200
+ continue;
201
+ }
182
202
  throw new ParamError(`${op.name} has no param '${key}' — takes ${Object.keys(op.params)
183
203
  .map((k) => `'${k}'`)
184
204
  .join(', ') || 'none'}`);
@@ -171,6 +171,20 @@ export interface Skipped {
171
171
  };
172
172
  readonly select?: Select;
173
173
  readonly reason: string;
174
+ /**
175
+ * The failure's kind — {@link ProcessError.code}, e.g.
176
+ * `'UnknownColumnError'`. Absent when the throw did not come from this
177
+ * package, which is itself the useful signal: op code failed, not the
178
+ * plan layer.
179
+ *
180
+ * `reason` is prose for a human and its wording is not a contract.
181
+ * Under `onError: 'skip' | 'collect'` nothing is thrown, so without
182
+ * this a consumer branching on the kind — a dropped feed column is a
183
+ * dimmed, removable chip; a bad persisted param is a broken one — was
184
+ * left matching on that prose (Tidal,
185
+ * `docs/notes/tidal-process-adoption-friction-2026-08.md`).
186
+ */
187
+ readonly code?: string;
174
188
  }
175
189
  export interface RunResult {
176
190
  /** Present only when a `columns` selector asked for it, and `assemble`. */
package/dist/plan/run.js CHANGED
@@ -64,6 +64,19 @@ function normalize(graph, request) {
64
64
  });
65
65
  return { plan: [...expanded.values()], select, slotOf };
66
66
  }
67
+ /**
68
+ * The `reason` and `code` a caught throw contributes to a {@link Skipped}.
69
+ *
70
+ * One place, so every failure path reports its kind the same way — the
71
+ * column loop and the fact loop already diverged once on `onError`
72
+ * itself, which is the argument for not writing this twice.
73
+ */
74
+ function describe(e) {
75
+ return {
76
+ reason: e instanceof Error ? e.message : String(e),
77
+ ...(e instanceof ProcessError && { code: e.code }),
78
+ };
79
+ }
67
80
  export function run(graph, request) {
68
81
  const { onError = 'throw', assemble = true } = request;
69
82
  const registry = graph.registry;
@@ -85,15 +98,26 @@ export function run(graph, request) {
85
98
  outputs: {},
86
99
  facts: [],
87
100
  explain: {},
88
- skipped: [{ reason: e instanceof Error ? e.message : String(e) }],
101
+ skipped: [describe(e)],
89
102
  nodes: [],
90
103
  };
91
104
  }
92
105
  const { plan, select, slotOf } = normalized;
93
- const fail = (entry) => {
106
+ /**
107
+ * Reports a failure, or rethrows it — the ORIGINAL error, not a
108
+ * reconstruction.
109
+ *
110
+ * Rebuilding it as a base `ProcessError` from `entry.reason` erased the
111
+ * class on the default policy: `graph.compile` threw
112
+ * `UnknownColumnError` and `run` turned it into a `ProcessError`, so a
113
+ * consumer catching could not branch on the very class this PR added,
114
+ * and the documented "`code` matches what a throw would have carried"
115
+ * was false in one direction (Codex, PR #667).
116
+ */
117
+ const fail = (error, entry) => {
94
118
  if (onError === 'throw')
95
- throw new ProcessError(entry.reason);
96
- skipped.push(entry);
119
+ throw error;
120
+ skipped.push({ ...entry, ...describe(error) });
97
121
  };
98
122
  // ── resolve the plan ───────────────────────────────────────
99
123
  for (const spec of plan) {
@@ -102,13 +126,12 @@ export function run(graph, request) {
102
126
  resolved.push({ id: compiled.id, spec });
103
127
  }
104
128
  catch (e) {
105
- fail({
129
+ fail(e, {
106
130
  spec: {
107
131
  op: spec.op,
108
132
  params: { ...(spec.params ?? {}) },
109
133
  inputs: spec.inputs,
110
134
  },
111
- reason: e instanceof Error ? e.message : String(e),
112
135
  });
113
136
  }
114
137
  }
@@ -136,7 +159,7 @@ export function run(graph, request) {
136
159
  }
137
160
  }
138
161
  catch (e) {
139
- fail({ select: sel, reason: e instanceof Error ? e.message : String(e) });
162
+ fail(e, { select: sel });
140
163
  continue;
141
164
  }
142
165
  selectors.push({ sel, id });
@@ -265,10 +288,10 @@ export function run(graph, request) {
265
288
  if (sel.output !== undefined &&
266
289
  !declared.some((o) => o.id === sel.output)) {
267
290
  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
- });
291
+ // Built here rather than caught, so the kind is stated rather
292
+ // than derived — a `Skipped` from the plan layer always
293
+ // carries one, and the throw policy raises the same object.
294
+ fail(new ProcessError(`'${compiled.spec.op}' has no output '${sel.output}' (has ${have})`), { select: sel });
272
295
  continue;
273
296
  }
274
297
  // Pulling a column runs op code, which can throw like anything
@@ -299,12 +322,7 @@ export function run(graph, request) {
299
322
  });
300
323
  }
301
324
  catch (e) {
302
- if (onError === 'throw')
303
- throw e;
304
- fail({
305
- select: sel,
306
- reason: e instanceof Error ? e.message : String(e),
307
- });
325
+ fail(e, { select: sel });
308
326
  }
309
327
  }
310
328
  }
@@ -314,7 +332,7 @@ export function run(graph, request) {
314
332
  for (const { sel, id } of selectors) {
315
333
  const compiled = graph.get(id);
316
334
  if (compiled === undefined) {
317
- fail({ select: sel, reason: `'${id}' is not in this plan` });
335
+ fail(new ProcessError(`'${id}' is not in this plan`), { select: sel });
318
336
  continue;
319
337
  }
320
338
  if (!compiled.fold)
@@ -337,7 +355,7 @@ export function run(graph, request) {
337
355
  });
338
356
  }
339
357
  catch (e) {
340
- fail({ select: sel, reason: e instanceof Error ? e.message : String(e) });
358
+ fail(e, { select: sel });
341
359
  }
342
360
  }
343
361
  // Anything the plan resolved but nothing selected. After the pulls, so
@@ -32,6 +32,7 @@ import { ProcessError } from '../errors.js';
32
32
  import type { ParamValue, Spec } from './types.js';
33
33
  /** Thrown when a slot graph cannot be expanded. */
34
34
  export declare class SlotError extends ProcessError {
35
+ static readonly code: string;
35
36
  }
36
37
  /** One node in a slot graph. `in` names source columns or other slots. */
37
38
  export interface SlotDef {
@@ -31,6 +31,7 @@
31
31
  import { ProcessError } from '../errors.js';
32
32
  /** Thrown when a slot graph cannot be expanded. */
33
33
  export class SlotError extends ProcessError {
34
+ static code = 'SlotError';
34
35
  }
35
36
  /**
36
37
  * Expands a slot graph into the nested `Spec` form.
@@ -37,6 +37,7 @@ export declare function defineSource<const Name extends string, const Params ext
37
37
  readonly load: SourceDef<Name, Params>['load'];
38
38
  }): SourceDef<Name, Params>;
39
39
  export declare class UnknownSourceError extends ProcessError {
40
+ static readonly code: string;
40
41
  }
41
42
  export declare class SourceRegistry {
42
43
  #private;
@@ -15,6 +15,7 @@ export function defineSource(definition) {
15
15
  };
16
16
  }
17
17
  export class UnknownSourceError extends ProcessError {
18
+ static code = 'UnknownSourceError';
18
19
  }
19
20
  export class SourceRegistry {
20
21
  #sources = new Map();
package/dist/source.d.ts CHANGED
@@ -17,6 +17,7 @@ import type { PortSpec } from './types.js';
17
17
  export type NoInputs = Readonly<Record<string, never>>;
18
18
  /** Thrown when a source is pulled before a value has been set. */
19
19
  export declare class UnsetSourceError extends ProcessError {
20
+ static readonly code: string;
20
21
  }
21
22
  /**
22
23
  * A graph input whose value is pushed in from outside via {@link set}.
package/dist/source.js CHANGED
@@ -7,6 +7,7 @@ import { ProcessError } from './errors.js';
7
7
  import { Node } from './node.js';
8
8
  /** Thrown when a source is pulled before a value has been set. */
9
9
  export class UnsetSourceError extends ProcessError {
10
+ static code = 'UnsetSourceError';
10
11
  }
11
12
  /**
12
13
  * A graph input whose value is pushed in from outside via {@link set}.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/process",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Computations as data over pond-ts: processing graphs authored fluently or composed as JSON, resolved against a declared op vocabulary with content-addressed caching, provenance, and per-node timings. Experimental, pre-1.0.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "verify": "npm run format:check && npm run build && npm test"
43
43
  },
44
44
  "peerDependencies": {
45
- "pond-ts": "^0.61.0"
45
+ "pond-ts": "^0.62.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "typescript": "^5.6.3",