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 +55 -0
- package/capdag.test.js +88 -0
- package/package.json +2 -1
- package/planner.js +424 -0
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.
|
|
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
|
+
};
|