capdag 1.240.679 → 1.243.691

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/capdag.js CHANGED
@@ -949,6 +949,50 @@ class CapUrn {
949
949
  return runtimeOut;
950
950
  }
951
951
 
952
+ /**
953
+ * THE effect-audit predicate: does an actually-emitted runtime output
954
+ * satisfy this cap's declared effect contract for the given runtime
955
+ * input? Every check of "did the cap emit what its effect promised"
956
+ * must go through this — never a hand-rolled combination of
957
+ * inferRuntimeOutputMedia with equality or conformance checks, so the
958
+ * contract has exactly one definition.
959
+ *
960
+ * The condition is deliberately asymmetric per effect:
961
+ * - effect=none / effect=patch compute an EXACT runtime output type,
962
+ * so the emission must be tag-equivalent to the inference. A more
963
+ * specific emission would still be a lie: the effect promises the
964
+ * type is fully determined by the input.
965
+ * - effect=declared promises only the declared out=, so an emission
966
+ * that is MORE specific than the declaration is legal and desirable;
967
+ * the emission must conform to the declared output. A more generic
968
+ * emission breaks downstream plan refinement and fails.
969
+ *
970
+ * Throws (rather than returning false) when the inference itself is
971
+ * impossible: the runtime input does not conform to the declared
972
+ * input, the effect is the unconstrained request form (?effect), or
973
+ * the URN state is internally inconsistent — those are upstream
974
+ * contract breaks, not emission mismatches.
975
+ *
976
+ * @param {MediaUrn} runtimeInput
977
+ * @param {MediaUrn} runtimeOutput
978
+ * @returns {boolean}
979
+ */
980
+ isConformantRuntimeOutput(runtimeInput, runtimeOutput) {
981
+ const inferred = this.inferRuntimeOutputMedia(runtimeInput);
982
+ switch (this.effectValue) {
983
+ case CapEffect.NONE:
984
+ case CapEffect.PATCH:
985
+ return runtimeOutput.isEquivalent(inferred);
986
+ case CapEffect.DECLARED:
987
+ return runtimeOutput.conformsTo(inferred);
988
+ default:
989
+ throw new CapUrnError(
990
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
991
+ 'Cannot audit an emission against an unconstrained effect request'
992
+ );
993
+ }
994
+ }
995
+
952
996
  _validateAdmissible() {
953
997
  const inMedia = this.inMediaUrn();
954
998
  const outMedia = this.outMediaUrn();
@@ -1561,6 +1605,17 @@ class MediaUrn {
1561
1605
  */
1562
1606
  isFilePath() { return this._hasMarkerTag('file-path'); }
1563
1607
 
1608
+ /**
1609
+ * True if this URN carries the `live` marker tag — a live-feed
1610
+ * REFERENCE (13.2 §Reference Media): the arg value is a selector record
1611
+ * the runtime resolves into an unbounded content stream via a registered
1612
+ * provider, the same transport-resolution family as `media:file-path`.
1613
+ * This is the canonical membership predicate for that family; callers
1614
+ * must not test the `live` tag themselves.
1615
+ * @returns {boolean}
1616
+ */
1617
+ isLiveFeed() { return this._hasMarkerTag('live'); }
1618
+
1564
1619
  /**
1565
1620
  * Check if this represents a collection type.
1566
1621
  * Returns true if the "collection" marker tag is present.
package/capdag.test.js CHANGED
@@ -2797,6 +2797,16 @@ function test551_isFilePath() {
2797
2797
  assert(!MediaUrn.fromString(MEDIA_IDENTITY).isFilePath(), 'MEDIA_IDENTITY should not be file-path');
2798
2798
  }
2799
2799
 
2800
+ // TEST553: is_live_feed returns true for every URN carrying the `live` marker tag (the reference-media family the runtime resolves via providers), false for everything else — including content URNs a feed delivers.
2801
+ function test553_isLiveFeed() {
2802
+ assert(MediaUrn.fromString('media:live').isLiveFeed(), 'media:live should be live-feed');
2803
+ assert(MediaUrn.fromString('media:live;synthetic').isLiveFeed(), 'media:live;synthetic should be live-feed');
2804
+ assert(MediaUrn.fromString('media:audio;live;microphone').isLiveFeed(), 'microphone URN should be live-feed');
2805
+ assert(!MediaUrn.fromString(MEDIA_STRING).isLiveFeed(), 'MEDIA_STRING should not be live-feed');
2806
+ assert(!MediaUrn.fromString(MEDIA_FILE_PATH).isLiveFeed(), 'MEDIA_FILE_PATH should not be live-feed');
2807
+ assert(!MediaUrn.fromString('media:audio;pcm').isLiveFeed(), 'feed CONTENT urns should not be live-feed');
2808
+ }
2809
+
2800
2810
  // Mirror-specific coverage: isCollection returns true when collection marker tag is present
2801
2811
  // Mirror-specific coverage: N/A for JS (MEDIA_COLLECTION constants removed - no longer exists)
2802
2812
  function test6272_isCollection() {
@@ -3206,6 +3216,80 @@ function test127_invalidEffectNoneFailsHard() {
3206
3216
  );
3207
3217
  }
3208
3218
 
3219
+ // TEST8121: isConformantRuntimeOutput — effect=declared accepts a more
3220
+ // specific emission, rejects a more generic one
3221
+ function test8121_effectConformanceDeclaredAsymmetry() {
3222
+ const cap = CapUrn.fromString('cap:extract;in="media:ext=pdf";out="media:record"');
3223
+ const input = MediaUrn.fromString('media:ext=pdf');
3224
+
3225
+ assert(
3226
+ cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:record')),
3227
+ 'emitting exactly the declared out is conformant'
3228
+ );
3229
+ assert(
3230
+ cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:fmt=json;record')),
3231
+ 'a more specific emission than the declared out is conformant'
3232
+ );
3233
+ assert(
3234
+ !cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:')),
3235
+ 'a more generic emission than the declared out is a violation'
3236
+ );
3237
+ assert(
3238
+ !cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:ext=png;image')),
3239
+ 'an unrelated emission is a violation'
3240
+ );
3241
+ }
3242
+
3243
+ // TEST8122: isConformantRuntimeOutput — effect=none requires the emission
3244
+ // to be tag-equivalent to the runtime input; MORE specific is still a lie
3245
+ function test8122_effectConformanceNoneRequiresEquivalence() {
3246
+ const cap = CapUrn.fromString('cap:decimate-sequence;effect=none');
3247
+ const input = MediaUrn.fromString('media:ext=png;image');
3248
+
3249
+ assert(
3250
+ cap.isConformantRuntimeOutput(input, input),
3251
+ 'emitting exactly the runtime input type is conformant'
3252
+ );
3253
+ assert(
3254
+ !cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:ext=png;image;width=64')),
3255
+ 'effect=none promises the output type IS the input type; more specific is a lie'
3256
+ );
3257
+ assert(
3258
+ !cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:image')),
3259
+ 'a more generic emission is a violation'
3260
+ );
3261
+
3262
+ const strict = CapUrn.fromString('cap:effect=none;in="media:image";out="media:image"');
3263
+ const nonImage = MediaUrn.fromString('media:ext=pdf');
3264
+ assertThrows(
3265
+ () => strict.isConformantRuntimeOutput(nonImage, nonImage),
3266
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
3267
+ 'a nonconforming runtime input is an upstream contract break, surfaced as an error'
3268
+ );
3269
+ }
3270
+
3271
+ // TEST8123: isConformantRuntimeOutput — effect=patch requires exactly the
3272
+ // delta-patched input type
3273
+ function test8123_effectConformancePatchRequiresPatchedInput() {
3274
+ const cap = CapUrn.fromString(
3275
+ 'cap:convert;effect=patch;in="media:ext=jpeg;image";out="media:ext=png;image"'
3276
+ );
3277
+ const input = MediaUrn.fromString('media:ext=jpeg;image;width=64');
3278
+
3279
+ assert(
3280
+ cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:ext=png;image;width=64')),
3281
+ 'the delta-patched input type (preserved tags intact) is conformant'
3282
+ );
3283
+ assert(
3284
+ !cap.isConformantRuntimeOutput(input, MediaUrn.fromString('media:ext=png;image')),
3285
+ 'dropping the preserved width tag violates the patch promise'
3286
+ );
3287
+ assert(
3288
+ !cap.isConformantRuntimeOutput(input, input),
3289
+ 'emitting the unpatched input type is a violation'
3290
+ );
3291
+ }
3292
+
3209
3293
  // TEST128: omitted effect means declared; unconstrained effect must be explicit
3210
3294
  function test128_effectDispatchRequiresExplicitWildcard() {
3211
3295
  const noneCandidate = CapUrn.fromString('cap:effect=none');
@@ -7234,6 +7318,7 @@ async function runTests() {
7234
7318
  runTest('TEST1315: is_numeric', test549_isNumeric);
7235
7319
  runTest('TEST1298: is_bool', test550_isBool);
7236
7320
  runTest('TEST1299: is_file_path', test551_isFilePath);
7321
+ runTest('TEST553: is_live_feed', test553_isLiveFeed);
7237
7322
  runTest('TEST1302: predicate_constant_consistency', test558_predicateConstantConsistency);
7238
7323
 
7239
7324
  // cap_urn.rs: TEST1303-TEST1307 (CapUrn tier tests)
@@ -7270,6 +7355,9 @@ async function runTests() {
7270
7355
  runTest('TEST655: effect_declared_uses_declared_output', test126_effectDeclaredUsesDeclaredOutput);
7271
7356
  runTest('TEST656: invalid_effect_none_fails_hard', test127_invalidEffectNoneFailsHard);
7272
7357
  runTest('TEST657: effect_dispatch_requires_explicit_wildcard', test128_effectDispatchRequiresExplicitWildcard);
7358
+ runTest('TEST8121: effect_conformance_declared_asymmetry', test8121_effectConformanceDeclaredAsymmetry);
7359
+ runTest('TEST8122: effect_conformance_none_requires_equivalence', test8122_effectConformanceNoneRequiresEquivalence);
7360
+ runTest('TEST8123: effect_conformance_patch_requires_patched_input', test8123_effectConformancePatchRequiresPatchedInput);
7273
7361
 
7274
7362
  // machine module: parser tests (mirrors parser.rs)
7275
7363
  console.log('\n--- machine/parser.rs ---');
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.243.691"
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
+ };