capdag 1.240.679 → 1.241.684

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 (2) hide show
  1. package/package.json +2 -1
  2. package/planner.js +424 -0
package/package.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "capdag.test.js",
16
16
  "machine.pegjs",
17
17
  "machine-parser.js",
18
+ "planner.js",
18
19
  "RULES.md",
19
20
  "README.md"
20
21
  ],
@@ -40,5 +41,5 @@
40
41
  "pretest": "npm run build:parser",
41
42
  "test": "node capdag.test.js"
42
43
  },
43
- "version": "1.240.679"
44
+ "version": "1.241.684"
44
45
  }
package/planner.js ADDED
@@ -0,0 +1,424 @@
1
+ // Unified configurable planner — the plan-space vocabulary for JS clients.
2
+ //
3
+ // Mirrors core Rust `capdag/src/planner/plan_space.rs` and the proto surface
4
+ // (`machfab/proto/cap.proto` PlanMachines/DiscoverConvergentTargets): the knob
5
+ // enums, the request shape with the same validation the engine applies, and
6
+ // parsers for the proto-JSON response shapes (ts-proto `snakeToCamel=false`,
7
+ // so wire field names are snake_case). This is what lets fabricloom represent
8
+ // and manipulate the FULL planner state — knobs, ranked candidates, apexes —
9
+ // both standalone (fixtures) and fed over gRPC by the desktop clients.
10
+ //
11
+ // Every parser fails hard on malformed input: a missing/mistyped field is a
12
+ // wire bug to surface, never a value to default away.
13
+
14
+ 'use strict';
15
+
16
+ // ============================================================================
17
+ // Knob enums — string values ⇄ proto enum numbers
18
+ // ============================================================================
19
+ //
20
+ // Each knob is a frozen map of canonical string values. The string values are
21
+ // the cross-layer currency (UI state, IPC, fixtures); `toProto`/`fromProto`
22
+ // convert to the proto enum numbers at the wire boundary.
23
+
24
+ function makeKnob(name, entries) {
25
+ const values = Object.freeze(entries.map(([value]) => value));
26
+ const toProtoMap = new Map(entries);
27
+ const fromProtoMap = new Map(entries.map(([value, num]) => [num, value]));
28
+ return Object.freeze({
29
+ name,
30
+ values,
31
+ /** The knob's default (always the first entry — proto 0). */
32
+ default: entries[0][0],
33
+ isValid(value) {
34
+ return toProtoMap.has(value);
35
+ },
36
+ toProto(value) {
37
+ if (!toProtoMap.has(value)) {
38
+ throw new PlanStateError(
39
+ `${name} must be one of ${values.join(' | ')}, got ${JSON.stringify(value)}`
40
+ );
41
+ }
42
+ return toProtoMap.get(value);
43
+ },
44
+ fromProto(num) {
45
+ if (!fromProtoMap.has(num)) {
46
+ throw new PlanStateError(`unknown ${name} proto value ${JSON.stringify(num)}`);
47
+ }
48
+ return fromProtoMap.get(num);
49
+ },
50
+ });
51
+ }
52
+
53
+ class PlanStateError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = 'PlanStateError';
57
+ }
58
+ }
59
+
60
+ /** Axis C — whether the source legs meet. */
61
+ const ConvergencePresence = makeKnob('convergencePresence', [
62
+ ['auto', 0],
63
+ ['converged', 1],
64
+ ['independent', 2],
65
+ ]);
66
+
67
+ /** Axis D — where the apex sits (the meet-in-the-middle cut). */
68
+ const ConvergenceLocation = makeKnob('convergenceLocation', [
69
+ ['auto', 0],
70
+ ['at-source', 1],
71
+ ['earliest', 2],
72
+ ['at-depth', 3],
73
+ ['latest', 4],
74
+ ['at-target', 5],
75
+ ]);
76
+
77
+ /** Axis E — how the apex is realized. */
78
+ const ConvergenceMechanism = makeKnob('convergenceMechanism', [
79
+ ['any', 0],
80
+ ['generalize', 1],
81
+ ['collect', 2],
82
+ ['merge', 3],
83
+ ]);
84
+
85
+ /** Axis G — the colimit shape. */
86
+ const ConvergenceArity = makeKnob('convergenceArity', [
87
+ ['auto', 0],
88
+ ['single', 1],
89
+ ['staged', 2],
90
+ ['partial', 3],
91
+ ]);
92
+
93
+ /** Axis H — whether/how the plan fans out. */
94
+ const DivergencePresence = makeKnob('divergencePresence', [
95
+ ['auto', 0],
96
+ ['none', 1],
97
+ ['broadcast', 2],
98
+ ['split', 3],
99
+ ]);
100
+
101
+ /** Axis I — where a fan-out sits. */
102
+ const DivergenceLocation = makeKnob('divergenceLocation', [
103
+ ['auto', 0],
104
+ ['at-source', 1],
105
+ ['at-depth', 2],
106
+ ['at-target', 3],
107
+ ]);
108
+
109
+ /** Axis K — how candidates are ranked. "intent" is the magic default. */
110
+ const RankPolicy = makeKnob('ranking', [
111
+ ['intent', 0],
112
+ ['shortest', 1],
113
+ ['cost', 2],
114
+ ]);
115
+
116
+ /** Who sets the knobs. Both modes return a ranked list. */
117
+ const PlanMode = makeKnob('mode', [
118
+ ['auto', 0],
119
+ ['configured', 1],
120
+ ]);
121
+
122
+ // ============================================================================
123
+ // PlanRequest — the full knob surface, validated like the engine validates
124
+ // ============================================================================
125
+
126
+ /**
127
+ * One source anchor: `{ mediaUrn, isSequence }`. Heterogeneous inputs are
128
+ * several specs with different media URNs; N same-typed files are one spec
129
+ * with `isSequence: true`.
130
+ */
131
+ function validateSourceSpec(spec, index) {
132
+ if (spec === null || typeof spec !== 'object') {
133
+ throw new PlanStateError(`sources[${index}] must be an object`);
134
+ }
135
+ if (typeof spec.mediaUrn !== 'string' || spec.mediaUrn.length === 0) {
136
+ throw new PlanStateError(`sources[${index}].mediaUrn must be a non-empty string`);
137
+ }
138
+ if (typeof spec.isSequence !== 'boolean') {
139
+ throw new PlanStateError(`sources[${index}].isSequence must be a boolean`);
140
+ }
141
+ return { mediaUrn: spec.mediaUrn, isSequence: spec.isSequence };
142
+ }
143
+
144
+ /**
145
+ * The full plan request. Every field is a knob; `auto` mode fills unset knobs
146
+ * by intent inference engine-side. Constructing one validates the same
147
+ * invariants the engine enforces (at-depth requires a depth; a depth is only
148
+ * valid at at-depth; ≥1 source; ≥1 exact target), so a bad configuration
149
+ * fails at the UI boundary with the same message shape it would fail with on
150
+ * the wire.
151
+ */
152
+ class PlanRequest {
153
+ constructor({
154
+ sources,
155
+ exactTargets,
156
+ convergencePresence = ConvergencePresence.default,
157
+ convergenceLocation = ConvergenceLocation.default,
158
+ convergenceAtDepth = null,
159
+ convergenceMechanism = ConvergenceMechanism.default,
160
+ convergenceAtType = null,
161
+ convergenceArity = ConvergenceArity.default,
162
+ divergencePresence = DivergencePresence.default,
163
+ divergenceLocation = DivergenceLocation.default,
164
+ divergenceAtDepth = null,
165
+ ranking = RankPolicy.default,
166
+ mode = PlanMode.default,
167
+ maxDepth = 0,
168
+ maxPaths = 0,
169
+ maxCandidates = 0,
170
+ }) {
171
+ if (!Array.isArray(sources) || sources.length === 0) {
172
+ throw new PlanStateError('sources must be a non-empty array');
173
+ }
174
+ this.sources = sources.map(validateSourceSpec);
175
+ if (!Array.isArray(exactTargets) || exactTargets.length === 0) {
176
+ throw new PlanStateError('exactTargets must be a non-empty array of media URNs');
177
+ }
178
+ for (const [i, target] of exactTargets.entries()) {
179
+ if (typeof target !== 'string' || target.length === 0) {
180
+ throw new PlanStateError(`exactTargets[${i}] must be a non-empty string`);
181
+ }
182
+ }
183
+ this.exactTargets = [...exactTargets];
184
+
185
+ for (const [knob, value] of [
186
+ [ConvergencePresence, convergencePresence],
187
+ [ConvergenceLocation, convergenceLocation],
188
+ [ConvergenceMechanism, convergenceMechanism],
189
+ [ConvergenceArity, convergenceArity],
190
+ [DivergencePresence, divergencePresence],
191
+ [DivergenceLocation, divergenceLocation],
192
+ [RankPolicy, ranking],
193
+ [PlanMode, mode],
194
+ ]) {
195
+ knob.toProto(value); // throws on an invalid value
196
+ }
197
+ this.convergencePresence = convergencePresence;
198
+ this.convergenceLocation = convergenceLocation;
199
+ this.convergenceMechanism = convergenceMechanism;
200
+ this.convergenceAtType = convergenceAtType;
201
+ this.convergenceArity = convergenceArity;
202
+ this.divergencePresence = divergencePresence;
203
+ this.divergenceLocation = divergenceLocation;
204
+ this.ranking = ranking;
205
+ this.mode = mode;
206
+
207
+ const depthAt = (value, location, field, atValue) => {
208
+ if (location === atValue) {
209
+ if (!Number.isInteger(value) || value < 1) {
210
+ throw new PlanStateError(`${field} must be a positive integer when the location is "${atValue}"`);
211
+ }
212
+ return value;
213
+ }
214
+ if (value !== null && value !== undefined) {
215
+ throw new PlanStateError(`${field} is only valid when the location is "${atValue}"`);
216
+ }
217
+ return 0;
218
+ };
219
+ this.convergenceAtDepth = depthAt(convergenceAtDepth, convergenceLocation, 'convergenceAtDepth', 'at-depth');
220
+ this.divergenceAtDepth = depthAt(divergenceAtDepth, divergenceLocation, 'divergenceAtDepth', 'at-depth');
221
+
222
+ for (const [field, value] of [
223
+ ['maxDepth', maxDepth],
224
+ ['maxPaths', maxPaths],
225
+ ['maxCandidates', maxCandidates],
226
+ ]) {
227
+ if (!Number.isInteger(value) || value < 0) {
228
+ throw new PlanStateError(`${field} must be a non-negative integer (0 = engine default)`);
229
+ }
230
+ }
231
+ this.maxDepth = maxDepth;
232
+ this.maxPaths = maxPaths;
233
+ this.maxCandidates = maxCandidates;
234
+ Object.freeze(this.sources);
235
+ Object.freeze(this.exactTargets);
236
+ Object.freeze(this);
237
+ }
238
+
239
+ /** True iff any space-constraining knob is set — the CONFIGURED trigger.
240
+ * Ranking never counts: it re-orders, it doesn't constrain. */
241
+ get isConfigured() {
242
+ return (
243
+ this.convergencePresence !== 'auto' ||
244
+ this.convergenceLocation !== 'auto' ||
245
+ this.convergenceMechanism !== 'any' ||
246
+ this.convergenceArity !== 'auto' ||
247
+ this.convergenceAtType !== null ||
248
+ this.divergencePresence !== 'auto' ||
249
+ this.divergenceLocation !== 'auto'
250
+ );
251
+ }
252
+
253
+ /** The proto-JSON wire shape (`PlanMachinesRequest`, snake_case fields). */
254
+ toProtoJSON() {
255
+ return {
256
+ sources: this.sources.map((s) => ({ media_urn: s.mediaUrn, is_sequence: s.isSequence })),
257
+ exact_targets: [...this.exactTargets],
258
+ convergence: {
259
+ presence: ConvergencePresence.toProto(this.convergencePresence),
260
+ location: ConvergenceLocation.toProto(this.convergenceLocation),
261
+ at_depth: this.convergenceAtDepth,
262
+ mechanism: ConvergenceMechanism.toProto(this.convergenceMechanism),
263
+ at_type: this.convergenceAtType === null ? undefined : this.convergenceAtType,
264
+ arity: ConvergenceArity.toProto(this.convergenceArity),
265
+ },
266
+ divergence: {
267
+ presence: DivergencePresence.toProto(this.divergencePresence),
268
+ location: DivergenceLocation.toProto(this.divergenceLocation),
269
+ at_depth: this.divergenceAtDepth,
270
+ },
271
+ ranking: RankPolicy.toProto(this.ranking),
272
+ mode: PlanMode.toProto(this.isConfigured ? 'configured' : this.mode),
273
+ max_depth: this.maxDepth,
274
+ max_paths: this.maxPaths,
275
+ max_candidates: this.maxCandidates,
276
+ };
277
+ }
278
+ }
279
+
280
+ // ============================================================================
281
+ // Response parsers — proto-JSON → typed plan state
282
+ // ============================================================================
283
+
284
+ function requireString(record, field, context) {
285
+ const value = record[field];
286
+ if (typeof value !== 'string') {
287
+ throw new PlanStateError(`${context}.${field} must be a string, got ${typeof value}`);
288
+ }
289
+ return value;
290
+ }
291
+
292
+ function requireNumber(record, field, context) {
293
+ const value = record[field];
294
+ if (typeof value !== 'number' || Number.isNaN(value)) {
295
+ throw new PlanStateError(`${context}.${field} must be a number, got ${typeof value}`);
296
+ }
297
+ return value;
298
+ }
299
+
300
+ function requireBoolean(record, field, context) {
301
+ const value = record[field];
302
+ if (typeof value !== 'boolean') {
303
+ throw new PlanStateError(`${context}.${field} must be a boolean, got ${typeof value}`);
304
+ }
305
+ return value;
306
+ }
307
+
308
+ function requireRecord(value, context) {
309
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
310
+ throw new PlanStateError(`${context} must be an object`);
311
+ }
312
+ return value;
313
+ }
314
+
315
+ function requireArray(value, context) {
316
+ if (!Array.isArray(value)) {
317
+ throw new PlanStateError(`${context} must be an array`);
318
+ }
319
+ return value;
320
+ }
321
+
322
+ /** `PlanApexMessage`: where legs meet, by which mechanism, at what depth. */
323
+ function parsePlanApex(raw, context = 'apex') {
324
+ const record = requireRecord(raw, context);
325
+ return Object.freeze({
326
+ mediaUrn: requireString(record, 'media_urn', context),
327
+ mechanism: ConvergenceMechanism.fromProto(requireNumber(record, 'mechanism', context)),
328
+ depth: requireNumber(record, 'depth', context),
329
+ });
330
+ }
331
+
332
+ /** `PlanProfileMessage`: the candidate's (sources, apexes, targets) shape. */
333
+ function parsePlanProfile(raw, context = 'profile') {
334
+ const record = requireRecord(raw, context);
335
+ return Object.freeze({
336
+ sourceMedia: requireArray(record.source_media, `${context}.source_media`).map(String),
337
+ targetMedia: requireArray(record.target_media, `${context}.target_media`).map(String),
338
+ apexes: requireArray(record.apexes, `${context}.apexes`).map((apex, i) =>
339
+ parsePlanApex(apex, `${context}.apexes[${i}]`)
340
+ ),
341
+ converged: requireBoolean(record, 'converged', context),
342
+ diverged: requireBoolean(record, 'diverged', context),
343
+ });
344
+ }
345
+
346
+ /** `PlanCostMessage`. */
347
+ function parsePlanCost(raw, context = 'cost') {
348
+ const record = requireRecord(raw, context);
349
+ return Object.freeze({
350
+ capSteps: requireNumber(record, 'cap_steps', context),
351
+ totalSteps: requireNumber(record, 'total_steps', context),
352
+ maxLegDepth: requireNumber(record, 'max_leg_depth', context),
353
+ intentScore: requireNumber(record, 'intent_score', context),
354
+ });
355
+ }
356
+
357
+ /**
358
+ * `PlanCandidateMessage`: one ranked plan. `notation` IS the plan — canonical
359
+ * machine notation, parseable by `parseMachine` and executable by the engine.
360
+ */
361
+ function parsePlanCandidate(raw, context = 'candidate') {
362
+ const record = requireRecord(raw, context);
363
+ return Object.freeze({
364
+ notation: requireString(record, 'notation', context),
365
+ profile: parsePlanProfile(record.profile, `${context}.profile`),
366
+ cost: parsePlanCost(record.cost, `${context}.cost`),
367
+ label: requireString(record, 'label', context),
368
+ rank: requireNumber(record, 'rank', context),
369
+ });
370
+ }
371
+
372
+ /** `PlanMachinesResponse` → candidates sorted by rank. */
373
+ function parsePlanCandidates(raw, context = 'planMachines') {
374
+ const record = requireRecord(raw, context);
375
+ const candidates = requireArray(record.candidates, `${context}.candidates`).map(
376
+ (candidate, i) => parsePlanCandidate(candidate, `${context}.candidates[${i}]`)
377
+ );
378
+ return candidates.slice().sort((a, b) => a.rank - b.rank);
379
+ }
380
+
381
+ /**
382
+ * `ConvergentTargetMessage`: one discovered target — combined through an apex
383
+ * (`convergent: true`, `apex` set) or reachable by every source on its own.
384
+ */
385
+ function parseConvergentTarget(raw, context = 'target') {
386
+ const record = requireRecord(raw, context);
387
+ const hasApex = record.apex !== undefined && record.apex !== null;
388
+ return Object.freeze({
389
+ mediaDef: requireString(record, 'media_def', context),
390
+ displayName: requireString(record, 'display_name', context),
391
+ minTotalSteps: requireNumber(record, 'min_total_steps', context),
392
+ apex: hasApex ? parsePlanApex(record.apex, `${context}.apex`) : null,
393
+ convergent: requireBoolean(record, 'convergent', context),
394
+ fileExtension: typeof record.file_extension === 'string' ? record.file_extension : '',
395
+ });
396
+ }
397
+
398
+ /** `DiscoverConvergentTargetsResponse` → convergent-first, then by steps. */
399
+ function parseConvergentTargets(raw, context = 'discoverConvergentTargets') {
400
+ const record = requireRecord(raw, context);
401
+ return requireArray(record.targets, `${context}.targets`).map((target, i) =>
402
+ parseConvergentTarget(target, `${context}.targets[${i}]`)
403
+ );
404
+ }
405
+
406
+ module.exports = {
407
+ PlanStateError,
408
+ ConvergencePresence,
409
+ ConvergenceLocation,
410
+ ConvergenceMechanism,
411
+ ConvergenceArity,
412
+ DivergencePresence,
413
+ DivergenceLocation,
414
+ RankPolicy,
415
+ PlanMode,
416
+ PlanRequest,
417
+ parsePlanApex,
418
+ parsePlanProfile,
419
+ parsePlanCost,
420
+ parsePlanCandidate,
421
+ parsePlanCandidates,
422
+ parseConvergentTarget,
423
+ parseConvergentTargets,
424
+ };