capdag 0.162.386 → 0.166.402
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/README.md +1 -1
- package/RULES.md +1 -1
- package/capdag.js +216 -56
- package/capdag.test.js +470 -56
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ Utility for matching sets of caps:
|
|
|
95
95
|
const { CapUrnError, ErrorCodes } = require('capdag');
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
|
-
const cap = CapUrn.fromString('cap:
|
|
98
|
+
const cap = CapUrn.fromString('cap:extract;in=media:;out=media:'); // Missing in/out
|
|
99
99
|
} catch (error) {
|
|
100
100
|
if (error instanceof CapUrnError) {
|
|
101
101
|
console.log(`Error code: ${error.code}`); // MISSING_IN_SPEC
|
package/RULES.md
CHANGED
|
@@ -17,7 +17,7 @@ Cap URNs **must** include `in` and `out` tags that specify input/output media ty
|
|
|
17
17
|
const cap = CapUrn.fromString('cap:in="media:binary";extract;out="media:object"');
|
|
18
18
|
|
|
19
19
|
// Invalid - missing direction specifiers
|
|
20
|
-
CapUrn.fromString('cap:
|
|
20
|
+
CapUrn.fromString('cap:extract;in=media:;out=media:'); // throws ErrorCodes.MISSING_IN_SPEC
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
### 2. Media URN Validation
|
package/capdag.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Follows the exact same rules as Rust, Go, and Objective-C implementations
|
|
3
3
|
|
|
4
4
|
// Import TaggedUrn from the tagged-urn package
|
|
5
|
-
const { TaggedUrn } = require('tagged-urn');
|
|
5
|
+
const { TaggedUrn, valuesMatch: taggedUrnValuesMatch } = require('tagged-urn');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Error types for Cap URN operations
|
|
@@ -113,7 +113,52 @@ function validatePreservedDirectionSpec(spec, tagName) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Per-tag truth-table specificity scoring is owned by the
|
|
117
|
+
// tagged-urn module — same scorer applies uniformly to media-URN
|
|
118
|
+
// tags, cap-tag y-axis, and any other Tagged URN dimension. We
|
|
119
|
+
// re-use the canonical implementation rather than duplicate it.
|
|
120
|
+
const { scoreTagValue } = require('tagged-urn');
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Functional category of a cap, derived from all three axes (`in`,
|
|
124
|
+
* `out`, and the remaining tags). The classification is **logical** —
|
|
125
|
+
* the dispatch protocol does not branch on CapKind. Exposed so tools,
|
|
126
|
+
* UIs, planners, and tests can reason about a cap's role without
|
|
127
|
+
* re-deriving the rules.
|
|
128
|
+
*
|
|
129
|
+
* `media:void` is the **unit type** (no meaningful value). `media:`
|
|
130
|
+
* is the **top type** (universal wildcard). With those anchors the
|
|
131
|
+
* five kinds fall out:
|
|
132
|
+
*
|
|
133
|
+
* IDENTITY in=media:, out=media:, no other tags → A → A
|
|
134
|
+
* SOURCE in=media:void, out!=void → () → B
|
|
135
|
+
* SINK in!=void, out=media:void → A → ()
|
|
136
|
+
* EFFECT in=media:void, out=media:void → () → ()
|
|
137
|
+
* TRANSFORM anything else
|
|
138
|
+
*
|
|
139
|
+
* IDENTITY is the **fully generic** cap on every axis. Adding any
|
|
140
|
+
* tag specifies something on the third axis and demotes the morphism
|
|
141
|
+
* to a TRANSFORM whose in/out happen to be the wildcards.
|
|
142
|
+
*
|
|
143
|
+
* String values are snake_case to match other capdag enum
|
|
144
|
+
* serializations on the wire.
|
|
145
|
+
*/
|
|
146
|
+
const CapKind = Object.freeze({
|
|
147
|
+
IDENTITY: 'identity',
|
|
148
|
+
SOURCE: 'source',
|
|
149
|
+
SINK: 'sink',
|
|
150
|
+
EFFECT: 'effect',
|
|
151
|
+
TRANSFORM: 'transform',
|
|
152
|
+
});
|
|
153
|
+
|
|
116
154
|
class CapUrn {
|
|
155
|
+
// Per-axis weights for cap-URN specificity. Two orders of
|
|
156
|
+
// magnitude separate each axis to keep them in distinct digit
|
|
157
|
+
// slots while folding into a single comparable integer.
|
|
158
|
+
// spec_C(c) = WEIGHT_OUT*spec_U(out) + WEIGHT_IN*spec_U(in) + spec_U(y)
|
|
159
|
+
static WEIGHT_OUT = 10000;
|
|
160
|
+
static WEIGHT_IN = 100;
|
|
161
|
+
|
|
117
162
|
/**
|
|
118
163
|
* Create a new CapUrn with direction specs.
|
|
119
164
|
* @param {string} inSpec - Input media URN (e.g., "media:void")
|
|
@@ -167,6 +212,39 @@ class CapUrn {
|
|
|
167
212
|
return MediaUrn.fromString(this.outSpec);
|
|
168
213
|
}
|
|
169
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Functional category of this cap, derived from all three axes:
|
|
217
|
+
* `in` (parsed MediaUrn), `out` (parsed MediaUrn), and the rest of
|
|
218
|
+
* the tags (the operation/metadata axis — `this.tags` does NOT
|
|
219
|
+
* include in/out, those live in this.inSpec/this.outSpec).
|
|
220
|
+
*
|
|
221
|
+
* Identity requires every axis to be in its most generic form: in
|
|
222
|
+
* is the top media URN (`media:`), out is the top media URN, and
|
|
223
|
+
* there are no other tags. Source/Sink/Effect are decided by void
|
|
224
|
+
* on either directional axis. Anything else is Transform.
|
|
225
|
+
*
|
|
226
|
+
* @returns {string} A {@link CapKind} value (snake_case string).
|
|
227
|
+
* @throws {MediaUrnError} If either side is not a valid media URN
|
|
228
|
+
* (only happens on internally inconsistent state since
|
|
229
|
+
* construction validates both sides).
|
|
230
|
+
*/
|
|
231
|
+
kind() {
|
|
232
|
+
const inMedia = this.inMediaUrn();
|
|
233
|
+
const outMedia = this.outMediaUrn();
|
|
234
|
+
|
|
235
|
+
const inVoid = inMedia.isVoid();
|
|
236
|
+
const outVoid = outMedia.isVoid();
|
|
237
|
+
const inTop = inMedia.isTop();
|
|
238
|
+
const outTop = outMedia.isTop();
|
|
239
|
+
const noExtraTags = Object.keys(this.tags).length === 0;
|
|
240
|
+
|
|
241
|
+
if (inTop && outTop && noExtraTags) return CapKind.IDENTITY;
|
|
242
|
+
if (inVoid && outVoid) return CapKind.EFFECT;
|
|
243
|
+
if (inVoid) return CapKind.SOURCE;
|
|
244
|
+
if (outVoid) return CapKind.SINK;
|
|
245
|
+
return CapKind.TRANSFORM;
|
|
246
|
+
}
|
|
247
|
+
|
|
170
248
|
/**
|
|
171
249
|
* Create a Cap URN from string representation
|
|
172
250
|
* Format: cap:in="<media-urn>";out="<media-urn>";key1=value1;key2=value2;...
|
|
@@ -279,10 +357,20 @@ class CapUrn {
|
|
|
279
357
|
* @returns {string} The canonical string representation
|
|
280
358
|
*/
|
|
281
359
|
toString() {
|
|
282
|
-
//
|
|
283
|
-
|
|
360
|
+
// `in` and `out` segments are emitted only when they refine beyond
|
|
361
|
+
// the trivial wildcard `media:`. A cap whose in/out are both
|
|
362
|
+
// `media:` and which has no other tags has the canonical form
|
|
363
|
+
// `cap:` — the bare identity URN. The canonicalizer collapses both
|
|
364
|
+
// written forms (`cap:` and `cap:in=media:;out=media:`) to one
|
|
365
|
+
// representative so byte-equality matches semantic identity.
|
|
366
|
+
const allTags = { ...this.tags };
|
|
367
|
+
if (this.inSpec !== 'media:') {
|
|
368
|
+
allTags['in'] = this.inSpec;
|
|
369
|
+
}
|
|
370
|
+
if (this.outSpec !== 'media:') {
|
|
371
|
+
allTags['out'] = this.outSpec;
|
|
372
|
+
}
|
|
284
373
|
|
|
285
|
-
// Use TaggedUrn for canonical serialization
|
|
286
374
|
const taggedUrn = new TaggedUrn('cap', allTags, true);
|
|
287
375
|
return taggedUrn.toString();
|
|
288
376
|
}
|
|
@@ -327,6 +415,25 @@ class CapUrn {
|
|
|
327
415
|
return tagValue !== undefined && tagValue === value;
|
|
328
416
|
}
|
|
329
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Check whether a marker tag (a tag whose value is "*") is present at the
|
|
420
|
+
* given key. Equivalent to hasTag(tagName, "*") but expresses authorial
|
|
421
|
+
* intent: this tag is present as a marker (a wildcard-valued tag that
|
|
422
|
+
* serializes as just the key), not as a key=value pair. Direction specs
|
|
423
|
+
* (in/out) are not markers.
|
|
424
|
+
* Example: cap:constrained;... has marker tag "constrained".
|
|
425
|
+
*
|
|
426
|
+
* @param {string} tagName - The marker key
|
|
427
|
+
* @returns {boolean} Whether the tag exists with value "*"
|
|
428
|
+
*/
|
|
429
|
+
hasMarkerTag(tagName) {
|
|
430
|
+
const keyLower = tagName.toLowerCase();
|
|
431
|
+
if (keyLower === 'in' || keyLower === 'out') {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
return this.tags[keyLower] === '*';
|
|
435
|
+
}
|
|
436
|
+
|
|
330
437
|
/**
|
|
331
438
|
* Create a new cap URN with an added or updated tag.
|
|
332
439
|
* Attempts to set `in` / `out` through `withTag` are ignored; use
|
|
@@ -432,23 +539,25 @@ class CapUrn {
|
|
|
432
539
|
}
|
|
433
540
|
}
|
|
434
541
|
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
542
|
+
// Y-axis: every tag's per-key match runs through the six-form
|
|
543
|
+
// truth table (taggedUrnValuesMatch). Walk the union of all keys
|
|
544
|
+
// appearing on either side so missing-on-pattern and
|
|
545
|
+
// missing-on-instance cells both get evaluated.
|
|
546
|
+
const allKeys = new Set([
|
|
547
|
+
...Object.keys(this.tags),
|
|
548
|
+
...Object.keys(request.tags),
|
|
549
|
+
]);
|
|
550
|
+
for (const key of allKeys) {
|
|
551
|
+
const patt = Object.prototype.hasOwnProperty.call(this.tags, key)
|
|
552
|
+
? this.tags[key]
|
|
553
|
+
: undefined;
|
|
554
|
+
const inst = Object.prototype.hasOwnProperty.call(request.tags, key)
|
|
555
|
+
? request.tags[key]
|
|
556
|
+
: undefined;
|
|
557
|
+
if (!taggedUrnValuesMatch(inst, patt)) {
|
|
448
558
|
return false;
|
|
449
559
|
}
|
|
450
560
|
}
|
|
451
|
-
|
|
452
561
|
return true;
|
|
453
562
|
}
|
|
454
563
|
|
|
@@ -464,29 +573,42 @@ class CapUrn {
|
|
|
464
573
|
}
|
|
465
574
|
|
|
466
575
|
/**
|
|
467
|
-
* Calculate specificity score for cap matching
|
|
576
|
+
* Calculate specificity score for cap matching.
|
|
577
|
+
*
|
|
578
|
+
* Weighted sum of the per-tag truth-table score across the three
|
|
579
|
+
* axes (`out`, `in`, `y`):
|
|
580
|
+
*
|
|
581
|
+
* spec_C(c) = WEIGHT_OUT * spec_U(c.out)
|
|
582
|
+
* + WEIGHT_IN * spec_U(c.in)
|
|
583
|
+
* + spec_U(c.y)
|
|
584
|
+
*
|
|
585
|
+
* Per-tag ladder:
|
|
468
586
|
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
587
|
+
* "?" -> 0 (no constraint)
|
|
588
|
+
* starts "?=" -> 1 (absent or not v)
|
|
589
|
+
* "*" -> 2 (must-have-any)
|
|
590
|
+
* starts "!=" -> 3 (present and not v)
|
|
591
|
+
* exact value -> 4 (exact match)
|
|
592
|
+
* "!" -> 5 (must-not-have)
|
|
593
|
+
*
|
|
594
|
+
* The lexicographic priority `(out, in, y)` reflects the routing
|
|
595
|
+
* intent: producing different things is the largest semantic
|
|
596
|
+
* difference between two caps; consuming different things is next;
|
|
597
|
+
* descriptive y-axis metadata is last.
|
|
472
598
|
*
|
|
473
599
|
* @returns {number} The specificity score
|
|
474
600
|
*/
|
|
475
601
|
specificity() {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
if (this.outSpec !== 'media:' && this.outSpec !== '*') {
|
|
484
|
-
const outMedia = TaggedUrn.fromString(this.outSpec);
|
|
485
|
-
count += Object.keys(outMedia.tags).length;
|
|
602
|
+
const inUrn = TaggedUrn.fromString(this.inSpec);
|
|
603
|
+
const outUrn = TaggedUrn.fromString(this.outSpec);
|
|
604
|
+
|
|
605
|
+
let yScore = 0;
|
|
606
|
+
for (const value of Object.values(this.tags)) {
|
|
607
|
+
yScore += scoreTagValue(value);
|
|
486
608
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
609
|
+
return CapUrn.WEIGHT_OUT * outUrn.specificity()
|
|
610
|
+
+ CapUrn.WEIGHT_IN * inUrn.specificity()
|
|
611
|
+
+ yScore;
|
|
490
612
|
}
|
|
491
613
|
|
|
492
614
|
/**
|
|
@@ -687,6 +809,22 @@ class CapUrnBuilder {
|
|
|
687
809
|
return this;
|
|
688
810
|
}
|
|
689
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Add a marker tag (a wildcard-valued tag that serializes as just the key).
|
|
814
|
+
* Equivalent to tag(key, "*") but expresses authorial intent: this tag is
|
|
815
|
+
* present as a marker, not a key=value pair.
|
|
816
|
+
*
|
|
817
|
+
* @param {string} key - The marker key
|
|
818
|
+
* @returns {CapUrnBuilder} This builder instance for chaining
|
|
819
|
+
*/
|
|
820
|
+
marker(key) {
|
|
821
|
+
const keyLower = key.toLowerCase();
|
|
822
|
+
if (keyLower !== 'in' && keyLower !== 'out') {
|
|
823
|
+
this._tags[keyLower] = '*';
|
|
824
|
+
}
|
|
825
|
+
return this;
|
|
826
|
+
}
|
|
827
|
+
|
|
690
828
|
/**
|
|
691
829
|
* Build the final CapUrn
|
|
692
830
|
*
|
|
@@ -1009,6 +1147,11 @@ class MediaUrnError extends Error {
|
|
|
1009
1147
|
|
|
1010
1148
|
const MediaUrnErrorCodes = {
|
|
1011
1149
|
INVALID_PREFIX: 'INVALID_PREFIX',
|
|
1150
|
+
/** `media:void` was combined with one or more other tags. The unit
|
|
1151
|
+
* type is atomic — there is no lattice underneath it. Reasons for
|
|
1152
|
+
* "why void was used" belong on cap-tags or args, not on the
|
|
1153
|
+
* media URN. */
|
|
1154
|
+
VOID_NOT_ATOMIC: 'VOID_NOT_ATOMIC',
|
|
1012
1155
|
};
|
|
1013
1156
|
|
|
1014
1157
|
/**
|
|
@@ -1026,6 +1169,17 @@ class MediaUrn {
|
|
|
1026
1169
|
`Expected prefix 'media', got '${taggedUrn.getPrefix()}'`
|
|
1027
1170
|
);
|
|
1028
1171
|
}
|
|
1172
|
+
// Enforce media:void atomicity. The unit type has no lattice
|
|
1173
|
+
// underneath it; refinements are conceptually wrong.
|
|
1174
|
+
const tagKeys = Object.keys(taggedUrn.tags);
|
|
1175
|
+
if (tagKeys.includes('void') && tagKeys.length > 1) {
|
|
1176
|
+
const extras = tagKeys.filter((k) => k !== 'void').sort();
|
|
1177
|
+
throw new MediaUrnError(
|
|
1178
|
+
MediaUrnErrorCodes.VOID_NOT_ATOMIC,
|
|
1179
|
+
`media:void is atomic and cannot be refined; got extra tag(s): ${extras.join(', ')}. ` +
|
|
1180
|
+
'Move why/how this void is used into cap-tags or args, not the media URN.'
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1029
1183
|
this._urn = taggedUrn;
|
|
1030
1184
|
}
|
|
1031
1185
|
|
|
@@ -1103,9 +1257,18 @@ class MediaUrn {
|
|
|
1103
1257
|
/** @returns {boolean} True if the "textable" marker tag is present */
|
|
1104
1258
|
isText() { return this._urn.getTag('textable') !== undefined; }
|
|
1105
1259
|
|
|
1106
|
-
/** @returns {boolean} True if the "void" marker tag is present
|
|
1260
|
+
/** @returns {boolean} True if the "void" marker tag is present —
|
|
1261
|
+
* the **unit type** in the type-theoretic reading. media:void is
|
|
1262
|
+
* the nullary value; NOT "invalid" or "absent". */
|
|
1107
1263
|
isVoid() { return this._urn.getTag('void') !== undefined; }
|
|
1108
1264
|
|
|
1265
|
+
/** @returns {boolean} True if this is the **top** media URN — the
|
|
1266
|
+
* universal wildcard `media:` with no tags. Order-theoretically,
|
|
1267
|
+
* every other media URN conformsTo this one. Distinct from
|
|
1268
|
+
* isVoid(): top means "any data type accepted here," void means
|
|
1269
|
+
* "no data flows here." */
|
|
1270
|
+
isTop() { return Object.keys(this._urn.tags).length === 0; }
|
|
1271
|
+
|
|
1109
1272
|
/** @returns {boolean} True if the "image" marker tag is present */
|
|
1110
1273
|
isImage() { return this._urn.getTag('image') !== undefined; }
|
|
1111
1274
|
|
|
@@ -1232,7 +1395,7 @@ class MediaUrn {
|
|
|
1232
1395
|
*/
|
|
1233
1396
|
function llmGenerateTextUrn() {
|
|
1234
1397
|
return new CapUrnBuilder()
|
|
1235
|
-
.
|
|
1398
|
+
.marker('generate_text')
|
|
1236
1399
|
.tag('llm', '*')
|
|
1237
1400
|
.tag('ml-model', '*')
|
|
1238
1401
|
.inSpec(MEDIA_STRING)
|
|
@@ -1247,7 +1410,7 @@ function llmGenerateTextUrn() {
|
|
|
1247
1410
|
*/
|
|
1248
1411
|
function renderPageImageUrn(inputMedia) {
|
|
1249
1412
|
return new CapUrnBuilder()
|
|
1250
|
-
.
|
|
1413
|
+
.marker('render_page_image')
|
|
1251
1414
|
.inSpec(inputMedia)
|
|
1252
1415
|
.outSpec(MEDIA_PNG)
|
|
1253
1416
|
.build();
|
|
@@ -1261,7 +1424,7 @@ function renderPageImageUrn(inputMedia) {
|
|
|
1261
1424
|
*/
|
|
1262
1425
|
function formatConversionUrn(inMedia, outMedia) {
|
|
1263
1426
|
return new CapUrnBuilder()
|
|
1264
|
-
.
|
|
1427
|
+
.marker('convert_format')
|
|
1265
1428
|
.inSpec(inMedia)
|
|
1266
1429
|
.outSpec(outMedia)
|
|
1267
1430
|
.build();
|
|
@@ -1294,7 +1457,7 @@ function mediaUrnForType(typeName) {
|
|
|
1294
1457
|
*/
|
|
1295
1458
|
function modelAvailabilityUrn() {
|
|
1296
1459
|
return new CapUrnBuilder()
|
|
1297
|
-
.
|
|
1460
|
+
.marker('model-availability')
|
|
1298
1461
|
.inSpec(MEDIA_MODEL_SPEC)
|
|
1299
1462
|
.outSpec(MEDIA_AVAILABILITY_OUTPUT)
|
|
1300
1463
|
.build();
|
|
@@ -1306,7 +1469,7 @@ function modelAvailabilityUrn() {
|
|
|
1306
1469
|
*/
|
|
1307
1470
|
function modelPathUrn() {
|
|
1308
1471
|
return new CapUrnBuilder()
|
|
1309
|
-
.
|
|
1472
|
+
.marker('model-path')
|
|
1310
1473
|
.inSpec(MEDIA_MODEL_SPEC)
|
|
1311
1474
|
.outSpec(MEDIA_PATH_OUTPUT)
|
|
1312
1475
|
.build();
|
|
@@ -5214,19 +5377,14 @@ class Machine {
|
|
|
5214
5377
|
return ea.target.toString().localeCompare(eb.target.toString());
|
|
5215
5378
|
});
|
|
5216
5379
|
|
|
5217
|
-
// Step 2: Generate aliases
|
|
5380
|
+
// Step 2: Generate edge aliases. The Rust reference implementation
|
|
5381
|
+
// uses `edge_<idx>` unconditionally — there is no privileged tag (such
|
|
5382
|
+
// as the legacy `op=…` tag) we can derive a friendlier name from, so
|
|
5383
|
+
// we mirror the same pure-index scheme here.
|
|
5218
5384
|
const aliases = new Map();
|
|
5219
|
-
const aliasCounts = new Map();
|
|
5220
|
-
|
|
5221
5385
|
for (const idx of edgeOrder) {
|
|
5222
5386
|
const edge = this._edges[idx];
|
|
5223
|
-
const
|
|
5224
|
-
const baseAlias = opTag !== undefined ? opTag : `edge_${idx}`;
|
|
5225
|
-
|
|
5226
|
-
const count = aliasCounts.get(baseAlias) || 0;
|
|
5227
|
-
const alias = count === 0 ? baseAlias : `${baseAlias}_${count}`;
|
|
5228
|
-
aliasCounts.set(baseAlias, count + 1);
|
|
5229
|
-
|
|
5387
|
+
const alias = `edge_${idx}`;
|
|
5230
5388
|
const capStr = edge.capUrn.toString();
|
|
5231
5389
|
aliases.set(alias, { edgeIdx: idx, capStr });
|
|
5232
5390
|
}
|
|
@@ -5297,16 +5455,17 @@ class Machine {
|
|
|
5297
5455
|
// Define edges
|
|
5298
5456
|
for (const edgeIdx of edgeOrder) {
|
|
5299
5457
|
const edge = this._edges[edgeIdx];
|
|
5300
|
-
// Find alias for this edge
|
|
5301
|
-
|
|
5458
|
+
// Find alias for this edge. The label on the rendered diagram is the
|
|
5459
|
+
// edge's alias (`edge_<idx>`), matching the Rust serializer which
|
|
5460
|
+
// also uses pure-index aliases — no special tag (like the legacy
|
|
5461
|
+
// `op=…`) is privileged for label derivation.
|
|
5462
|
+
let label = null;
|
|
5302
5463
|
for (const [a, info] of aliases) {
|
|
5303
5464
|
if (info.edgeIdx === edgeIdx) {
|
|
5304
|
-
|
|
5465
|
+
label = a;
|
|
5305
5466
|
break;
|
|
5306
5467
|
}
|
|
5307
5468
|
}
|
|
5308
|
-
const opTag = edge.capUrn.getTag('op');
|
|
5309
|
-
const label = opTag !== undefined ? opTag : edgeLabel;
|
|
5310
5469
|
|
|
5311
5470
|
const targetKey = edge.target.toString();
|
|
5312
5471
|
const targetName = nodeNames.get(targetKey);
|
|
@@ -5835,6 +5994,7 @@ class CapRegistryClient {
|
|
|
5835
5994
|
// Export for CommonJS
|
|
5836
5995
|
module.exports = {
|
|
5837
5996
|
CapUrn,
|
|
5997
|
+
CapKind,
|
|
5838
5998
|
CapUrnBuilder,
|
|
5839
5999
|
CapMatcher,
|
|
5840
6000
|
CapUrnError,
|
package/capdag.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// All implementations (Rust, Go, JS, ObjC, Python) must pass these identically.
|
|
4
4
|
|
|
5
5
|
const {
|
|
6
|
-
CapUrn, CapUrnBuilder, CapMatcher, CapUrnError, ErrorCodes,
|
|
6
|
+
CapUrn, CapKind, CapUrnBuilder, CapMatcher, CapUrnError, ErrorCodes,
|
|
7
7
|
MediaUrn, MediaUrnError, MediaUrnErrorCodes,
|
|
8
8
|
Cap, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
|
|
9
9
|
resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
|
|
@@ -131,7 +131,7 @@ function makeGraphCap(inUrn, outUrn, title) {
|
|
|
131
131
|
// TEST001: Test that cap URN is created with tags parsed correctly and direction specs accessible
|
|
132
132
|
function test001_capUrnCreation() {
|
|
133
133
|
const cap = CapUrn.fromString(testUrn('generate;ext=pdf;target=thumbnail'));
|
|
134
|
-
|
|
134
|
+
assert(cap.hasMarkerTag('generate'), 'Should get op tag');
|
|
135
135
|
assertEqual(cap.getTag('target'), 'thumbnail', 'Should get target tag');
|
|
136
136
|
assertEqual(cap.getTag('ext'), 'pdf', 'Should get ext tag');
|
|
137
137
|
assertEqual(cap.getInSpec(), MEDIA_VOID, 'Should get inSpec');
|
|
@@ -140,19 +140,19 @@ function test001_capUrnCreation() {
|
|
|
140
140
|
|
|
141
141
|
// TEST002: Test that missing 'in' or 'out' defaults to media: wildcard
|
|
142
142
|
function test002_directionSpecsRequired() {
|
|
143
|
-
const missingIn = CapUrn.fromString('cap:out=
|
|
143
|
+
const missingIn = CapUrn.fromString('cap:in=media:;out=media:void;test');
|
|
144
144
|
assertEqual(missingIn.getInSpec(), MEDIA_IDENTITY, 'Missing in should default to media:');
|
|
145
145
|
assertEqual(missingIn.getOutSpec(), MEDIA_VOID, 'Explicit out should be preserved');
|
|
146
146
|
|
|
147
|
-
const missingOut = CapUrn.fromString('cap:in=
|
|
147
|
+
const missingOut = CapUrn.fromString('cap:in=media:void;out=media:;test');
|
|
148
148
|
assertEqual(missingOut.getInSpec(), MEDIA_VOID, 'Explicit in should be preserved');
|
|
149
149
|
assertEqual(missingOut.getOutSpec(), MEDIA_IDENTITY, 'Missing out should default to media:');
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// TEST003: Test that direction specs must match exactly, different in/out types don't match, wildcard matches any
|
|
153
153
|
function test003_directionMatching() {
|
|
154
|
-
const cap = CapUrn.fromString(testUrn('
|
|
155
|
-
const request = CapUrn.fromString(testUrn('
|
|
154
|
+
const cap = CapUrn.fromString(testUrn('generate'));
|
|
155
|
+
const request = CapUrn.fromString(testUrn('generate'));
|
|
156
156
|
assert(cap.accepts(request), 'Same direction specs should match');
|
|
157
157
|
|
|
158
158
|
// Different direction should not match
|
|
@@ -166,8 +166,8 @@ function test003_directionMatching() {
|
|
|
166
166
|
|
|
167
167
|
// TEST004: Test that unquoted keys and values are normalized to lowercase
|
|
168
168
|
function test004_unquotedValuesLowercased() {
|
|
169
|
-
const cap = CapUrn.fromString('cap:
|
|
170
|
-
|
|
169
|
+
const cap = CapUrn.fromString('cap:ext=pdf;generate;in=media:void;out="media:record;textable"');
|
|
170
|
+
assert(cap.hasMarkerTag('generate'), 'Unquoted value should be lowercased');
|
|
171
171
|
assertEqual(cap.getTag('ext'), 'pdf', 'Unquoted value should be lowercased');
|
|
172
172
|
// Key lookup is case-insensitive
|
|
173
173
|
assertEqual(cap.getTag('OP'), 'generate', 'Key lookup should be case-insensitive');
|
|
@@ -265,13 +265,13 @@ function test014_roundTripEscapes() {
|
|
|
265
265
|
// TEST015: Test that cap: prefix is required and case-insensitive
|
|
266
266
|
function test015_capPrefixRequired() {
|
|
267
267
|
assertThrows(
|
|
268
|
-
() => CapUrn.fromString('in="media:void";out="media:void";
|
|
268
|
+
() => CapUrn.fromString('in="media:void";out="media:void";generate'),
|
|
269
269
|
ErrorCodes.MISSING_CAP_PREFIX,
|
|
270
270
|
'Should require cap: prefix'
|
|
271
271
|
);
|
|
272
272
|
// Valid cap: prefix should work
|
|
273
|
-
const cap = CapUrn.fromString(testUrn('
|
|
274
|
-
|
|
273
|
+
const cap = CapUrn.fromString(testUrn('generate'));
|
|
274
|
+
assert(cap.hasMarkerTag('generate'), 'Should parse with valid cap: prefix');
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
// TEST016: Test that trailing semicolon is equivalent (same hash, same string, matches)
|
|
@@ -291,8 +291,8 @@ function test017_tagMatching() {
|
|
|
291
291
|
assert(cap.accepts(exact), 'Should accept exact match');
|
|
292
292
|
assert(exact.accepts(cap), 'Exact match should accept in reverse too');
|
|
293
293
|
|
|
294
|
-
// Routing direction: request(
|
|
295
|
-
const subset = CapUrn.fromString(testUrn('
|
|
294
|
+
// Routing direction: request(generate) accepts cap(op,ext,target)
|
|
295
|
+
const subset = CapUrn.fromString(testUrn('generate'));
|
|
296
296
|
assert(subset.accepts(cap), 'General request should accept more specific instance');
|
|
297
297
|
assert(!cap.accepts(subset), 'Specific pattern should reject subset instance');
|
|
298
298
|
|
|
@@ -301,7 +301,7 @@ function test017_tagMatching() {
|
|
|
301
301
|
assert(wildcard.accepts(cap), 'Wildcard request should accept specific instance');
|
|
302
302
|
|
|
303
303
|
// Conflicting value — neither direction accepts
|
|
304
|
-
const mismatch = CapUrn.fromString(testUrn('
|
|
304
|
+
const mismatch = CapUrn.fromString(testUrn('extract'));
|
|
305
305
|
assert(!cap.accepts(mismatch), 'Should not accept value mismatch');
|
|
306
306
|
assert(!mismatch.accepts(cap), 'Reverse mismatch should also reject');
|
|
307
307
|
}
|
|
@@ -315,34 +315,52 @@ function test018_matchingCaseSensitiveValues() {
|
|
|
315
315
|
|
|
316
316
|
// TEST019: Missing tag in instance causes rejection — pattern's tags are constraints
|
|
317
317
|
function test019_missingTagHandling() {
|
|
318
|
-
const cap = CapUrn.fromString(testUrn('
|
|
318
|
+
const cap = CapUrn.fromString(testUrn('generate'));
|
|
319
319
|
const request = CapUrn.fromString(testUrn('ext=pdf'));
|
|
320
320
|
assert(!cap.accepts(request), 'Pattern requiring op should reject instance missing op');
|
|
321
321
|
assert(!request.accepts(cap), 'Pattern requiring ext should reject instance missing ext');
|
|
322
322
|
|
|
323
323
|
const cap2 = CapUrn.fromString(testUrn('generate;ext=pdf'));
|
|
324
|
-
const request2 = CapUrn.fromString(testUrn('
|
|
324
|
+
const request2 = CapUrn.fromString(testUrn('generate'));
|
|
325
325
|
assert(!cap2.accepts(request2), 'Specific pattern should reject instance missing ext');
|
|
326
326
|
assert(request2.accepts(cap2), 'General request should accept more specific instance');
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
// TEST020:
|
|
329
|
+
// TEST020: Specificity is the sum of per-tag truth-table scores
|
|
330
|
+
// across in/out/y. Marker tags (bare segments and `key=*`) score 2
|
|
331
|
+
// (must-have-any), exact `key=value` tags score 3, missing/`?` score
|
|
332
|
+
// 0, `!` scores 1.
|
|
333
|
+
//
|
|
334
|
+
// testUrn() builds "cap:in=media:void;out=media:record;<tags>" so
|
|
335
|
+
// the directional baseline is:
|
|
336
|
+
// in: media:void -> {void=*} -> 2
|
|
337
|
+
// out: media:record -> {record=*} -> 2
|
|
338
|
+
// Total directional baseline: 4.
|
|
330
339
|
function test020_specificity() {
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
340
|
+
// testUrn() prepends in="media:void" (1 marker, score 2) and
|
|
341
|
+
// out="media:record" (1 marker, score 2). Cap-URN spec is
|
|
342
|
+
// 10000*spec_U(out) + 100*spec_U(in) + spec_U(y).
|
|
343
|
+
|
|
334
344
|
const cap1 = CapUrn.fromString(testUrn('type=general'));
|
|
335
|
-
|
|
345
|
+
// out=2, in=2, y=4 (type=general exact)
|
|
346
|
+
assertEqual(cap1.specificity(), 10000*2 + 100*2 + 4,
|
|
347
|
+
'out=2, in=2, y=type=general exact=4 -> 20204');
|
|
336
348
|
|
|
337
|
-
const cap2 = CapUrn.fromString(testUrn('
|
|
338
|
-
|
|
349
|
+
const cap2 = CapUrn.fromString(testUrn('generate'));
|
|
350
|
+
// out=2, in=2, y=2 (generate marker = must-have-any)
|
|
351
|
+
assertEqual(cap2.specificity(), 10000*2 + 100*2 + 2,
|
|
352
|
+
'out=2, in=2, y=generate marker=2 -> 20202');
|
|
339
353
|
|
|
340
|
-
const cap3 = CapUrn.fromString(testUrn('op
|
|
341
|
-
|
|
354
|
+
const cap3 = CapUrn.fromString(testUrn('op;ext=pdf'));
|
|
355
|
+
// out=2, in=2, y=2+4 (op marker, ext=pdf exact)
|
|
356
|
+
assertEqual(cap3.specificity(), 10000*2 + 100*2 + 6,
|
|
357
|
+
'out=2, in=2, y=op marker(2)+ext=pdf exact(4) -> 20206');
|
|
342
358
|
|
|
343
|
-
// Wildcard in direction
|
|
344
|
-
const cap4 = CapUrn.fromString(`cap:in=*;out="${MEDIA_OBJECT}";
|
|
345
|
-
|
|
359
|
+
// Wildcard in direction normalizes to media: (no tags, score 0).
|
|
360
|
+
const cap4 = CapUrn.fromString(`cap:in=*;out="${MEDIA_OBJECT}";test`);
|
|
361
|
+
// out=2 (record=*), in=0 (media: empty), y=2 (test marker)
|
|
362
|
+
assertEqual(cap4.specificity(), 10000*2 + 100*0 + 2,
|
|
363
|
+
'out=record=2, in=*->0, y=test marker=2 -> 20002');
|
|
346
364
|
}
|
|
347
365
|
|
|
348
366
|
// TEST021: Test builder creates cap URN with correct tags and direction specs
|
|
@@ -353,7 +371,7 @@ function test021_builder() {
|
|
|
353
371
|
.tag('op', 'generate')
|
|
354
372
|
.tag('ext', 'pdf')
|
|
355
373
|
.build();
|
|
356
|
-
|
|
374
|
+
assert(cap.hasMarkerTag('generate'), 'Builder should set op');
|
|
357
375
|
assertEqual(cap.getTag('ext'), 'pdf', 'Builder should set ext');
|
|
358
376
|
assertEqual(cap.getInSpec(), 'media:void', 'Builder should set inSpec');
|
|
359
377
|
assertEqual(cap.getOutSpec(), 'media:object', 'Builder should set outSpec');
|
|
@@ -388,14 +406,14 @@ function test023_builderPreservesCase() {
|
|
|
388
406
|
function test024_compatibility() {
|
|
389
407
|
const cap1 = CapUrn.fromString(testUrn('generate;ext=pdf'));
|
|
390
408
|
const cap2 = CapUrn.fromString(testUrn('generate;format=*'));
|
|
391
|
-
const cap3 = CapUrn.fromString(testUrn('type=image;
|
|
409
|
+
const cap3 = CapUrn.fromString(testUrn('type=image;extract'));
|
|
392
410
|
|
|
393
411
|
assert(!cap1.accepts(cap2), 'Pattern requiring ext should reject instance missing ext');
|
|
394
412
|
assert(!cap2.accepts(cap1), 'Pattern requiring format should reject instance missing format');
|
|
395
413
|
assert(!cap1.accepts(cap3), 'Different op should not accept');
|
|
396
414
|
assert(!cap3.accepts(cap1), 'Different op should not accept in reverse');
|
|
397
415
|
|
|
398
|
-
const general = CapUrn.fromString(testUrn('
|
|
416
|
+
const general = CapUrn.fromString(testUrn('generate'));
|
|
399
417
|
assert(general.accepts(cap1), 'General request should accept more specific instance');
|
|
400
418
|
assert(!cap1.accepts(general), 'Specific pattern should reject general instance');
|
|
401
419
|
|
|
@@ -407,11 +425,11 @@ function test024_compatibility() {
|
|
|
407
425
|
// TEST025: Test find_best_match returns most specific matching cap
|
|
408
426
|
function test025_bestMatch() {
|
|
409
427
|
const caps = [
|
|
410
|
-
CapUrn.fromString('cap:in=*;out=*;op
|
|
411
|
-
CapUrn.fromString(testUrn('
|
|
428
|
+
CapUrn.fromString('cap:in=*;out=*;op'),
|
|
429
|
+
CapUrn.fromString(testUrn('generate')),
|
|
412
430
|
CapUrn.fromString(testUrn('generate;ext=pdf'))
|
|
413
431
|
];
|
|
414
|
-
const request = CapUrn.fromString(testUrn('
|
|
432
|
+
const request = CapUrn.fromString(testUrn('generate'));
|
|
415
433
|
const best = CapMatcher.findBestMatch(caps, request);
|
|
416
434
|
assert(best !== null, 'Should find a best match');
|
|
417
435
|
assertEqual(best.getTag('ext'), 'pdf', 'Best match should be the most specific (ext=pdf)');
|
|
@@ -419,20 +437,20 @@ function test025_bestMatch() {
|
|
|
419
437
|
|
|
420
438
|
// TEST026: Test merge combines tags from both caps, subset keeps only specified tags
|
|
421
439
|
function test026_mergeAndSubset() {
|
|
422
|
-
const cap1 = CapUrn.fromString(testUrn('
|
|
440
|
+
const cap1 = CapUrn.fromString(testUrn('generate'));
|
|
423
441
|
const cap2 = CapUrn.fromString('cap:in="media:textable";ext=pdf;format=binary;out="media:"');
|
|
424
442
|
|
|
425
443
|
// Merge (other takes precedence)
|
|
426
444
|
const merged = cap1.merge(cap2);
|
|
427
445
|
assertEqual(merged.getInSpec(), 'media:textable', 'Merge should take inSpec from other');
|
|
428
446
|
assertEqual(merged.getOutSpec(), 'media:', 'Merge should take outSpec from other');
|
|
429
|
-
|
|
447
|
+
assert(merged.hasMarkerTag('generate'), 'Merge should keep original tags');
|
|
430
448
|
assertEqual(merged.getTag('ext'), 'pdf', 'Merge should add other tags');
|
|
431
449
|
|
|
432
450
|
// Subset (always preserves in/out)
|
|
433
451
|
const sub = merged.subset(['ext']);
|
|
434
452
|
assertEqual(sub.getTag('ext'), 'pdf', 'Subset should keep ext');
|
|
435
|
-
|
|
453
|
+
assert(!sub.hasMarkerTag('generate'), 'Subset should drop the generate marker');
|
|
436
454
|
assertEqual(sub.getInSpec(), 'media:textable', 'Subset should preserve inSpec');
|
|
437
455
|
}
|
|
438
456
|
|
|
@@ -580,7 +598,7 @@ function test040_matchingSemanticsExactMatch() {
|
|
|
580
598
|
|
|
581
599
|
// TEST041: Matching semantics - cap missing tag matches (implicit wildcard)
|
|
582
600
|
function test041_matchingSemanticsCapMissingTag() {
|
|
583
|
-
const cap = CapUrn.fromString(testUrn('
|
|
601
|
+
const cap = CapUrn.fromString(testUrn('generate'));
|
|
584
602
|
const request = CapUrn.fromString(testUrn('generate;ext=pdf'));
|
|
585
603
|
assert(cap.accepts(request), 'General pattern with only op should accept specific instance');
|
|
586
604
|
assert(!request.accepts(cap), 'Pattern requiring ext should reject instance missing ext');
|
|
@@ -639,7 +657,7 @@ function test048_matchingSemanticsWildcardDirection() {
|
|
|
639
657
|
|
|
640
658
|
// TEST049: Non-overlapping tags — neither direction accepts
|
|
641
659
|
function test049_matchingSemanticsCrossDimension() {
|
|
642
|
-
const cap = CapUrn.fromString(testUrn('
|
|
660
|
+
const cap = CapUrn.fromString(testUrn('generate'));
|
|
643
661
|
const request = CapUrn.fromString(testUrn('ext=pdf'));
|
|
644
662
|
assert(!cap.accepts(request), 'Pattern requiring op should reject instance missing op');
|
|
645
663
|
assert(!request.accepts(cap), 'Pattern requiring ext should reject instance missing ext');
|
|
@@ -706,7 +724,10 @@ function test890_directionSemanticMatching() {
|
|
|
706
724
|
'Generic output cap must NOT satisfy specific output request');
|
|
707
725
|
}
|
|
708
726
|
|
|
709
|
-
// TEST891: Semantic direction specificity
|
|
727
|
+
// TEST891: Semantic direction specificity — more constraints in
|
|
728
|
+
// either axis means a higher score under the truth-table-driven sum.
|
|
729
|
+
// media: (top, no tags) scores 0; each marker tag scores 2; each
|
|
730
|
+
// exact tag scores 3.
|
|
710
731
|
function test891_directionSemanticSpecificity() {
|
|
711
732
|
const genericCap = CapUrn.fromString(
|
|
712
733
|
'cap:in="media:";generate-thumbnail;out="media:image;png;thumbnail"'
|
|
@@ -715,8 +736,20 @@ function test891_directionSemanticSpecificity() {
|
|
|
715
736
|
'cap:in="media:pdf";generate-thumbnail;out="media:image;png;thumbnail"'
|
|
716
737
|
);
|
|
717
738
|
|
|
718
|
-
|
|
719
|
-
|
|
739
|
+
// generic:
|
|
740
|
+
// out=media:image;png;thumbnail -> 2+2+2 = 6
|
|
741
|
+
// in=media: -> 0
|
|
742
|
+
// y: generate-thumbnail marker -> 2
|
|
743
|
+
// spec_C = 10000*6 + 100*0 + 2 = 60002
|
|
744
|
+
assertEqual(genericCap.specificity(), 10000*6 + 100*0 + 2,
|
|
745
|
+
'out=image;png;thumbnail(6) + in=media:(0) + generate-thumbnail marker(2) = 60002');
|
|
746
|
+
// specific:
|
|
747
|
+
// out=media:image;png;thumbnail -> 6
|
|
748
|
+
// in=media:pdf -> 2
|
|
749
|
+
// y: generate-thumbnail marker -> 2
|
|
750
|
+
// spec_C = 10000*6 + 100*2 + 2 = 60202
|
|
751
|
+
assertEqual(specificCap.specificity(), 10000*6 + 100*2 + 2,
|
|
752
|
+
'out=image;png;thumbnail(6) + in=pdf(2) + generate-thumbnail marker(2) = 60202');
|
|
720
753
|
assert(specificCap.specificity() > genericCap.specificity(), 'pdf should be more specific');
|
|
721
754
|
|
|
722
755
|
// CapMatcher should prefer more specific
|
|
@@ -1367,7 +1400,7 @@ function test306_availabilityAndPathOutputDistinct() {
|
|
|
1367
1400
|
// TEST307: Test model_availability_urn builds valid cap URN with correct op and media specs
|
|
1368
1401
|
function test307_modelAvailabilityUrn() {
|
|
1369
1402
|
const urn = modelAvailabilityUrn();
|
|
1370
|
-
assert(urn.
|
|
1403
|
+
assert(urn.hasMarkerTag('model-availability'), 'Must have model-availability marker');
|
|
1371
1404
|
const inSpec = TaggedUrn.fromString(urn.getInSpec());
|
|
1372
1405
|
const expectedIn = TaggedUrn.fromString(MEDIA_MODEL_SPEC);
|
|
1373
1406
|
assert(inSpec.conformsTo(expectedIn), 'input must conform to MEDIA_MODEL_SPEC');
|
|
@@ -1379,7 +1412,7 @@ function test307_modelAvailabilityUrn() {
|
|
|
1379
1412
|
// TEST308: Test model_path_urn builds valid cap URN with correct op and media specs
|
|
1380
1413
|
function test308_modelPathUrn() {
|
|
1381
1414
|
const urn = modelPathUrn();
|
|
1382
|
-
assert(urn.
|
|
1415
|
+
assert(urn.hasMarkerTag('model-path'), 'Must have model-path marker');
|
|
1383
1416
|
const inSpec = TaggedUrn.fromString(urn.getInSpec());
|
|
1384
1417
|
const expectedIn = TaggedUrn.fromString(MEDIA_MODEL_SPEC);
|
|
1385
1418
|
assert(inSpec.conformsTo(expectedIn), 'input must conform to MEDIA_MODEL_SPEC');
|
|
@@ -1398,7 +1431,7 @@ function test309_modelAvailabilityAndPathAreDistinct() {
|
|
|
1398
1431
|
// TEST310: llm_generate_text_urn() produces a valid cap URN with textable in/out specs
|
|
1399
1432
|
function test310_llmGenerateTextUrn() {
|
|
1400
1433
|
const urn = llmGenerateTextUrn();
|
|
1401
|
-
assert(urn.
|
|
1434
|
+
assert(urn.hasMarkerTag('generate_text'), 'Must have generate_text marker');
|
|
1402
1435
|
assert(urn.getTag('llm') !== undefined, 'Must have llm tag');
|
|
1403
1436
|
assert(urn.getTag('ml-model') !== undefined, 'Must have ml-model tag');
|
|
1404
1437
|
assert(TaggedUrn.fromString(urn.getInSpec()).conformsTo(TaggedUrn.fromString(MEDIA_STRING)),
|
|
@@ -1508,7 +1541,7 @@ function testJS_capWithMediaSpecs() {
|
|
|
1508
1541
|
}
|
|
1509
1542
|
|
|
1510
1543
|
function testJS_capJSONSerialization() {
|
|
1511
|
-
const urn = CapUrn.fromString(testUrn('
|
|
1544
|
+
const urn = CapUrn.fromString(testUrn('test'));
|
|
1512
1545
|
const cap = new Cap(urn, 'Test Cap', 'test_command');
|
|
1513
1546
|
cap.mediaSpecs = [
|
|
1514
1547
|
{ urn: 'media:custom', media_type: 'text/plain', title: 'Custom' }
|
|
@@ -1535,7 +1568,7 @@ function testJS_capJSONSerialization() {
|
|
|
1535
1568
|
// JSON.stringify on this side and the Rust serializer on the other side
|
|
1536
1569
|
// surface as failures here.
|
|
1537
1570
|
function testJS_capDocumentationRoundTrip() {
|
|
1538
|
-
const urn = CapUrn.fromString(testUrn('
|
|
1571
|
+
const urn = CapUrn.fromString(testUrn('documented'));
|
|
1539
1572
|
const cap = new Cap(urn, 'Documented Cap', 'documented');
|
|
1540
1573
|
const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
|
|
1541
1574
|
cap.setDocumentation(body);
|
|
@@ -1557,7 +1590,7 @@ function testJS_capDocumentationRoundTrip() {
|
|
|
1557
1590
|
// `documentation: null` would break the symmetric round-trip with Rust
|
|
1558
1591
|
// (which has no null sentinel) and pollute generated JSON.
|
|
1559
1592
|
function testJS_capDocumentationOmittedWhenNull() {
|
|
1560
|
-
const urn = CapUrn.fromString(testUrn('
|
|
1593
|
+
const urn = CapUrn.fromString(testUrn('undocumented'));
|
|
1561
1594
|
const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
|
|
1562
1595
|
assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
|
|
1563
1596
|
|
|
@@ -2341,7 +2374,7 @@ function test1303_withoutTag() {
|
|
|
2341
2374
|
const cap = CapUrn.fromString('cap:in="media:void";test;ext=pdf;out="media:void"');
|
|
2342
2375
|
const removed = cap.withoutTag('ext');
|
|
2343
2376
|
assertEqual(removed.getTag('ext'), undefined, 'withoutTag should remove ext');
|
|
2344
|
-
|
|
2377
|
+
assert(removed.hasMarkerTag('test'), 'withoutTag should preserve op');
|
|
2345
2378
|
|
|
2346
2379
|
// Case-insensitive removal
|
|
2347
2380
|
const removed2 = cap.withoutTag('EXT');
|
|
@@ -2365,7 +2398,7 @@ function test1304_withInOutSpec() {
|
|
|
2365
2398
|
const changedIn = cap.withInSpec('media:');
|
|
2366
2399
|
assertEqual(changedIn.getInSpec(), 'media:', 'withInSpec should change inSpec');
|
|
2367
2400
|
assertEqual(changedIn.getOutSpec(), 'media:void', 'withInSpec should preserve outSpec');
|
|
2368
|
-
|
|
2401
|
+
assert(changedIn.hasMarkerTag('test'), 'withInSpec should preserve tags');
|
|
2369
2402
|
|
|
2370
2403
|
const changedOut = cap.withOutSpec('media:string');
|
|
2371
2404
|
assertEqual(changedOut.getInSpec(), 'media:void', 'withOutSpec should preserve inSpec');
|
|
@@ -2392,7 +2425,7 @@ function test1305_findAllMatches() {
|
|
|
2392
2425
|
const request = CapUrn.fromString('cap:in="media:void";test;out="media:void"');
|
|
2393
2426
|
const matches = CapMatcher.findAllMatches(caps, request);
|
|
2394
2427
|
|
|
2395
|
-
// Should find 2 matches (
|
|
2428
|
+
// Should find 2 matches (test and test;ext=pdf), not different
|
|
2396
2429
|
assertEqual(matches.length, 2, 'Should find 2 matches');
|
|
2397
2430
|
// Sorted by specificity descending: ext=pdf first (more specific)
|
|
2398
2431
|
assert(matches[0].specificity() >= matches[1].specificity(), 'First match should be more specific');
|
|
@@ -2411,10 +2444,10 @@ function test1306_areCompatible() {
|
|
|
2411
2444
|
CapUrn.fromString('cap:in="media:void";different;out="media:void"'),
|
|
2412
2445
|
];
|
|
2413
2446
|
|
|
2414
|
-
// caps1 (
|
|
2447
|
+
// caps1 (test) accepts caps2 (test;ext=pdf) -> compatible
|
|
2415
2448
|
assert(CapMatcher.areCompatible(caps1, caps2), 'caps1 and caps2 should be compatible');
|
|
2416
2449
|
|
|
2417
|
-
// caps1 (
|
|
2450
|
+
// caps1 (test) vs caps3 (different) -> not compatible
|
|
2418
2451
|
assert(!CapMatcher.areCompatible(caps1, caps3), 'caps1 and caps3 should not be compatible');
|
|
2419
2452
|
|
|
2420
2453
|
// Empty sets are not compatible
|
|
@@ -2577,7 +2610,7 @@ function test649_specificityScoring() {
|
|
|
2577
2610
|
assert(specific.specificity() > 0, 'Specific cap should have non-zero specificity');
|
|
2578
2611
|
}
|
|
2579
2612
|
|
|
2580
|
-
// TEST650: N/A for JS (JS requires in/out, cap:in
|
|
2613
|
+
// TEST650: N/A for JS (JS requires in/out, cap:in=media:;out=media:;test would fail parsing)
|
|
2581
2614
|
|
|
2582
2615
|
// TEST651: All identity forms produce the same CapUrn
|
|
2583
2616
|
function test651_identityFormsEquivalent() {
|
|
@@ -2615,7 +2648,7 @@ function test653_identityRoutingIsolation() {
|
|
|
2615
2648
|
const specificCap = CapUrn.fromString('cap:in="media:void";test;out="media:void"');
|
|
2616
2649
|
const best = CapMatcher.findBestMatch([identity, specificCap], specificRequest);
|
|
2617
2650
|
assert(best !== null, 'Should find a match');
|
|
2618
|
-
|
|
2651
|
+
assert(best.hasMarkerTag('test'), 'CapMatcher should prefer specific cap over identity');
|
|
2619
2652
|
}
|
|
2620
2653
|
|
|
2621
2654
|
// ============================================================================
|
|
@@ -4622,7 +4655,7 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
|
4622
4655
|
saved_paths: [],
|
|
4623
4656
|
total_bytes: 0,
|
|
4624
4657
|
duration_ms: 0,
|
|
4625
|
-
failed_cap: 'cap:in=
|
|
4658
|
+
failed_cap: 'cap:in=media:b;out=media:c;y', // different tag order
|
|
4626
4659
|
error: 'fail',
|
|
4627
4660
|
},
|
|
4628
4661
|
],
|
|
@@ -5258,6 +5291,354 @@ function testRenderer_validateResolvedMachinePayload_rejectsMissingFields() {
|
|
|
5258
5291
|
}
|
|
5259
5292
|
}
|
|
5260
5293
|
|
|
5294
|
+
// ============================================================================
|
|
5295
|
+
// CapKind classifier tests (test1800–test1805)
|
|
5296
|
+
//
|
|
5297
|
+
// Mirrored across every language port (Rust, Go, Python, Swift/ObjC,
|
|
5298
|
+
// JS) under the SAME numbers. Any divergence is a wire-level
|
|
5299
|
+
// inconsistency — the kind taxonomy is part of the protocol's public
|
|
5300
|
+
// surface, not a per-port detail.
|
|
5301
|
+
// ============================================================================
|
|
5302
|
+
|
|
5303
|
+
// TEST1800: Identity classifier — only the bare cap: form qualifies.
|
|
5304
|
+
// Adding any tag (even one that doesn't constrain in/out) demotes
|
|
5305
|
+
// the cap to Transform because the operation/metadata axis is no
|
|
5306
|
+
// longer fully generic.
|
|
5307
|
+
function test1800_kindIdentityOnlyForBareCap() {
|
|
5308
|
+
const identity = CapUrn.fromString('cap:');
|
|
5309
|
+
assertEqual(identity.kind(), CapKind.IDENTITY, 'cap: should be Identity');
|
|
5310
|
+
|
|
5311
|
+
for (const spelling of [
|
|
5312
|
+
'cap:in=media:;out=media:',
|
|
5313
|
+
'cap:in=*;out=*',
|
|
5314
|
+
'cap:in=media:',
|
|
5315
|
+
'cap:out=media:',
|
|
5316
|
+
]) {
|
|
5317
|
+
const cap = CapUrn.fromString(spelling);
|
|
5318
|
+
assertEqual(cap.kind(), CapKind.IDENTITY,
|
|
5319
|
+
`${spelling} should classify as Identity (canonical form is cap:)`);
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
const withOp = CapUrn.fromString('cap:passthrough');
|
|
5323
|
+
assertEqual(withOp.kind(), CapKind.TRANSFORM,
|
|
5324
|
+
'cap:passthrough specifies the operation axis — not Identity');
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
// TEST1801: Source classifier — in=media:void, out non-void.
|
|
5328
|
+
function test1801_kindSourceWhenInputIsVoid() {
|
|
5329
|
+
const warm = CapUrn.fromString('cap:in=media:void;out="media:model-artifact";warm');
|
|
5330
|
+
assertEqual(warm.kind(), CapKind.SOURCE, 'warm cap is a Source');
|
|
5331
|
+
|
|
5332
|
+
const gen = CapUrn.fromString('cap:in=media:void;out=media:textable');
|
|
5333
|
+
assertEqual(gen.kind(), CapKind.SOURCE, 'in=void with concrete out is a Source');
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5336
|
+
// TEST1802: Sink classifier — out=media:void, in non-void.
|
|
5337
|
+
function test1802_kindSinkWhenOutputIsVoid() {
|
|
5338
|
+
const discard = CapUrn.fromString('cap:discard;in=media:;out=media:void');
|
|
5339
|
+
assertEqual(discard.kind(), CapKind.SINK, 'discard cap is a Sink');
|
|
5340
|
+
|
|
5341
|
+
const log = CapUrn.fromString('cap:in="media:json;textable";log;out=media:void');
|
|
5342
|
+
assertEqual(log.kind(), CapKind.SINK, 'log cap is a Sink');
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
// TEST1803: Effect classifier — both sides void. Reads as `() → ()`.
|
|
5346
|
+
function test1803_kindEffectWhenBothSidesVoid() {
|
|
5347
|
+
const ping = CapUrn.fromString('cap:in=media:void;out=media:void;ping');
|
|
5348
|
+
assertEqual(ping.kind(), CapKind.EFFECT, 'ping is an Effect');
|
|
5349
|
+
|
|
5350
|
+
const bare = CapUrn.fromString('cap:in=media:void;out=media:void');
|
|
5351
|
+
assertEqual(bare.kind(), CapKind.EFFECT,
|
|
5352
|
+
'in=void;out=void with empty y is still an Effect');
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5355
|
+
// TEST1804: Transform classifier — at least one side non-void, and
|
|
5356
|
+
// the cap is not the bare identity.
|
|
5357
|
+
function test1804_kindTransformForNormalDataProcessors() {
|
|
5358
|
+
const extract = CapUrn.fromString('cap:extract;in=media:pdf;out="media:record;textable"');
|
|
5359
|
+
assertEqual(extract.kind(), CapKind.TRANSFORM, 'extract is a Transform');
|
|
5360
|
+
|
|
5361
|
+
const labeled = CapUrn.fromString('cap:passthrough;in=media:;out=media:');
|
|
5362
|
+
assertEqual(labeled.kind(), CapKind.TRANSFORM,
|
|
5363
|
+
'fully generic in/out with a tag is a Transform, not Identity');
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
// TEST1810: media:void is atomic — refinements are parse errors.
|
|
5367
|
+
//
|
|
5368
|
+
// Mirrored across every language port (Rust, Go, Python, Swift/ObjC,
|
|
5369
|
+
// JS) under the SAME number. Any divergence is a wire-level
|
|
5370
|
+
// inconsistency — the unit type's atomicity is part of the protocol's
|
|
5371
|
+
// deepest layer, not a per-port detail.
|
|
5372
|
+
function test1810_mediaVoidIsAtomic() {
|
|
5373
|
+
// Bare void: must parse successfully.
|
|
5374
|
+
const bare = MediaUrn.fromString('media:void');
|
|
5375
|
+
assert(bare.isVoid(), 'bare media:void must parse — it is the unit type');
|
|
5376
|
+
|
|
5377
|
+
const badInputs = [
|
|
5378
|
+
'media:void;text',
|
|
5379
|
+
'media:void;pdf',
|
|
5380
|
+
'media:void;audio',
|
|
5381
|
+
'media:void;reason=warmup',
|
|
5382
|
+
'media:void;heartbeat',
|
|
5383
|
+
'media:void;manual',
|
|
5384
|
+
// Order must not matter — the parser canonicalizes tags.
|
|
5385
|
+
'media:warmup;void',
|
|
5386
|
+
'media:reason=foo;void',
|
|
5387
|
+
];
|
|
5388
|
+
|
|
5389
|
+
for (const input of badInputs) {
|
|
5390
|
+
let threw = false;
|
|
5391
|
+
let code = null;
|
|
5392
|
+
try {
|
|
5393
|
+
MediaUrn.fromString(input);
|
|
5394
|
+
} catch (e) {
|
|
5395
|
+
threw = true;
|
|
5396
|
+
code = e.code;
|
|
5397
|
+
}
|
|
5398
|
+
assert(threw, `${input}: expected parse error, but parsed successfully`);
|
|
5399
|
+
assertEqual(code, MediaUrnErrorCodes.VOID_NOT_ATOMIC,
|
|
5400
|
+
`${input}: expected VOID_NOT_ATOMIC error code`);
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
// TEST1805: Kind is invariant under canonicalization. The same
|
|
5405
|
+
// morphism written in many surface forms must classify the same way
|
|
5406
|
+
// once parsed.
|
|
5407
|
+
function test1805_kindInvariantUnderCanonicalSpellings() {
|
|
5408
|
+
const cases = [
|
|
5409
|
+
{ a: 'cap:', b: 'cap:in=media:;out=media:', expected: CapKind.IDENTITY },
|
|
5410
|
+
{
|
|
5411
|
+
a: 'cap:extract;in=media:pdf;out=media:textable',
|
|
5412
|
+
b: 'cap:extract;in="media:pdf";out="media:textable"',
|
|
5413
|
+
expected: CapKind.TRANSFORM,
|
|
5414
|
+
},
|
|
5415
|
+
{
|
|
5416
|
+
a: 'cap:in=media:void;out=media:textable;warm',
|
|
5417
|
+
b: 'cap:warm;out=media:textable;in=media:void',
|
|
5418
|
+
expected: CapKind.SOURCE,
|
|
5419
|
+
},
|
|
5420
|
+
];
|
|
5421
|
+
|
|
5422
|
+
for (const { a, b, expected } of cases) {
|
|
5423
|
+
const kindA = CapUrn.fromString(a).kind();
|
|
5424
|
+
const kindB = CapUrn.fromString(b).kind();
|
|
5425
|
+
assertEqual(kindA, expected, `${a} should classify as ${expected}`);
|
|
5426
|
+
assertEqual(kindB, expected, `${b} should classify as ${expected}`);
|
|
5427
|
+
assertEqual(kindA, kindB,
|
|
5428
|
+
`${a} and ${b} parse to the same cap and must classify identically`);
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
|
|
5432
|
+
// ============================================================================
|
|
5433
|
+
// Truth-table specificity tests (test1820–test1824)
|
|
5434
|
+
//
|
|
5435
|
+
// Mirrored across every language port (Rust, Go, Python, Swift/ObjC,
|
|
5436
|
+
// JS) under the SAME numbers. Specificity must be the truth-table
|
|
5437
|
+
// sum across all three axes using the six-form ladder:
|
|
5438
|
+
//
|
|
5439
|
+
// ?x or missing -> 0 (no constraint)
|
|
5440
|
+
// x?=v -> 1 (absent OR not v)
|
|
5441
|
+
// x (=x=*) marker -> 2 (must-have-any)
|
|
5442
|
+
// x!=v -> 3 (present and not v)
|
|
5443
|
+
// x=v exact -> 4 (must-have-this-value)
|
|
5444
|
+
// !x -> 5 (must-not-have)
|
|
5445
|
+
// ============================================================================
|
|
5446
|
+
|
|
5447
|
+
// TEST1820: A `?`-valued cap-tag scores 0. Same as missing.
|
|
5448
|
+
function test1820_specificityQuestionIsZero() {
|
|
5449
|
+
const bare = CapUrn.fromString('cap:');
|
|
5450
|
+
assertEqual(bare.specificity(), 0, 'cap: must score 0 (top of order)');
|
|
5451
|
+
|
|
5452
|
+
const withQ = CapUrn.fromString('cap:?target');
|
|
5453
|
+
assertEqual(withQ.specificity(), 0,
|
|
5454
|
+
'?x must score 0 (explicit no-constraint, same as missing)');
|
|
5455
|
+
}
|
|
5456
|
+
|
|
5457
|
+
// TEST1821: A `!`-valued cap-tag scores 5 (top of negative chain).
|
|
5458
|
+
function test1821_specificityMustNotHaveIsFive() {
|
|
5459
|
+
const cap = CapUrn.fromString('cap:!constrained');
|
|
5460
|
+
assertEqual(cap.specificity(), 5,
|
|
5461
|
+
'!constrained (must-not-have) must score 5');
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
// TEST1822: A `*`-valued cap-tag (including bare markers) scores 2.
|
|
5465
|
+
function test1822_specificityMustHaveAnyIsTwo() {
|
|
5466
|
+
const bareMarker = CapUrn.fromString('cap:extract');
|
|
5467
|
+
assertEqual(bareMarker.specificity(), 2,
|
|
5468
|
+
'bare `extract` parses as extract=* (must-have-any) and scores 2');
|
|
5469
|
+
|
|
5470
|
+
const explicitStar = CapUrn.fromString('cap:extract=*');
|
|
5471
|
+
assertEqual(explicitStar.specificity(), 2,
|
|
5472
|
+
'explicit key=* must score 2 (same as bare marker)');
|
|
5473
|
+
|
|
5474
|
+
assertEqual(bareMarker.specificity(), explicitStar.specificity(),
|
|
5475
|
+
'bare marker and explicit key=* are the same form and must score identically');
|
|
5476
|
+
}
|
|
5477
|
+
|
|
5478
|
+
// TEST1823: An exact-valued cap-tag scores 4.
|
|
5479
|
+
function test1823_specificityExactValueIsFour() {
|
|
5480
|
+
const cap = CapUrn.fromString('cap:target=metadata');
|
|
5481
|
+
assertEqual(cap.specificity(), 4,
|
|
5482
|
+
'target=metadata (exact value) must score 4');
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5485
|
+
// TEST1824: All six forms compose additively on a single cap.
|
|
5486
|
+
// y combining 0+1+2+3+4+5 must sum to 15.
|
|
5487
|
+
function test1824_specificityCombinedYAxis() {
|
|
5488
|
+
const cap = CapUrn.fromString('cap:!constrained;?target;extract;stage!=alpha;target2=metadata;ver?=draft');
|
|
5489
|
+
assertEqual(cap.specificity(), 15,
|
|
5490
|
+
'y combining all six forms (0+1+2+3+4+5) must sum to 15');
|
|
5491
|
+
}
|
|
5492
|
+
|
|
5493
|
+
// ============================================================================
|
|
5494
|
+
// Six-form canonicalization tests (test1830–test1835).
|
|
5495
|
+
// ============================================================================
|
|
5496
|
+
|
|
5497
|
+
// TEST1830: ?x ≡ x? ≡ x=? all canonicalize to ?x.
|
|
5498
|
+
function test1830_canonicalizeNoConstraint() {
|
|
5499
|
+
const canonical = 'cap:?x';
|
|
5500
|
+
for (const input of ['cap:?x', 'cap:x?', 'cap:x=?']) {
|
|
5501
|
+
const cap = CapUrn.fromString(input);
|
|
5502
|
+
assertEqual(cap.toString(), canonical,
|
|
5503
|
+
`input ${input} must canonicalize to ${canonical}`);
|
|
5504
|
+
}
|
|
5505
|
+
}
|
|
5506
|
+
|
|
5507
|
+
// TEST1831: ?x=v and x?=v both canonicalize to x?=v. The third
|
|
5508
|
+
// hypothetical form `x=?v` is NOT recognized as a qualifier — a
|
|
5509
|
+
// value starting with `?` is just an exact value beginning with
|
|
5510
|
+
// a `?` character.
|
|
5511
|
+
function test1831_canonicalizeAbsentOrNotValue() {
|
|
5512
|
+
const canonical = 'cap:x?=foo';
|
|
5513
|
+
for (const input of ['cap:?x=foo', 'cap:x?=foo']) {
|
|
5514
|
+
const cap = CapUrn.fromString(input);
|
|
5515
|
+
assertEqual(cap.toString(), canonical,
|
|
5516
|
+
`input ${input} must canonicalize to ${canonical}`);
|
|
5517
|
+
}
|
|
5518
|
+
|
|
5519
|
+
// `x=?foo` is a plain exact tag whose value is the string `?foo`
|
|
5520
|
+
// — NOT a canonicalization alias.
|
|
5521
|
+
const exact = CapUrn.fromString('cap:x=?foo');
|
|
5522
|
+
assertEqual(exact.toString(), 'cap:x=?foo');
|
|
5523
|
+
assertEqual(exact.getTag('x'), '?foo');
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5526
|
+
// TEST1832: x ≡ x=* both canonicalize to bare x.
|
|
5527
|
+
function test1832_canonicalizeMustHaveAny() {
|
|
5528
|
+
const canonical = 'cap:x';
|
|
5529
|
+
for (const input of ['cap:x', 'cap:x=*']) {
|
|
5530
|
+
const cap = CapUrn.fromString(input);
|
|
5531
|
+
assertEqual(cap.toString(), canonical,
|
|
5532
|
+
`input ${input} must canonicalize to ${canonical}`);
|
|
5533
|
+
}
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5536
|
+
// TEST1833: !x=v and x!=v both canonicalize to x!=v. The third
|
|
5537
|
+
// hypothetical form `x=!v` is NOT recognized as a qualifier — a
|
|
5538
|
+
// value starting with `!` is just an exact value beginning with
|
|
5539
|
+
// a `!` character.
|
|
5540
|
+
function test1833_canonicalizePresentNotValue() {
|
|
5541
|
+
const canonical = 'cap:x!=foo';
|
|
5542
|
+
for (const input of ['cap:!x=foo', 'cap:x!=foo']) {
|
|
5543
|
+
const cap = CapUrn.fromString(input);
|
|
5544
|
+
assertEqual(cap.toString(), canonical,
|
|
5545
|
+
`input ${input} must canonicalize to ${canonical}`);
|
|
5546
|
+
}
|
|
5547
|
+
|
|
5548
|
+
// `x=!foo` is a plain exact tag whose value is the string `!foo`
|
|
5549
|
+
// — NOT a canonicalization alias.
|
|
5550
|
+
const exact = CapUrn.fromString('cap:x=!foo');
|
|
5551
|
+
assertEqual(exact.toString(), 'cap:x=!foo');
|
|
5552
|
+
assertEqual(exact.getTag('x'), '!foo');
|
|
5553
|
+
}
|
|
5554
|
+
|
|
5555
|
+
// TEST1834: x=v stays as x=v.
|
|
5556
|
+
function test1834_canonicalizeExactValue() {
|
|
5557
|
+
const cap = CapUrn.fromString('cap:x=foo');
|
|
5558
|
+
assertEqual(cap.toString(), 'cap:x=foo');
|
|
5559
|
+
}
|
|
5560
|
+
|
|
5561
|
+
// TEST1835: !x ≡ x! ≡ x=! all canonicalize to !x.
|
|
5562
|
+
function test1835_canonicalizeMustNotHave() {
|
|
5563
|
+
const canonical = 'cap:!x';
|
|
5564
|
+
for (const input of ['cap:!x', 'cap:x!', 'cap:x=!']) {
|
|
5565
|
+
const cap = CapUrn.fromString(input);
|
|
5566
|
+
assertEqual(cap.toString(), canonical,
|
|
5567
|
+
`input ${input} must canonicalize to ${canonical}`);
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
5570
|
+
|
|
5571
|
+
// TEST1842: Full 6×6 truth table.
|
|
5572
|
+
function test1842_truthTableFullCrossProduct() {
|
|
5573
|
+
const forms = ['', '?x', 'x?=v', 'x', 'x!=v', 'x=v', '!x'];
|
|
5574
|
+
// miss ?x x?=v x x!=v x=v !x
|
|
5575
|
+
const expected = [
|
|
5576
|
+
[true, true, true, false, false, false, true ], // missing
|
|
5577
|
+
[true, true, true, true, true, true, true ], // ?x
|
|
5578
|
+
[true, true, true, false, false, false, true ], // x?=v
|
|
5579
|
+
[true, true, true, true, true, true, false], // x
|
|
5580
|
+
[true, true, true, true, true, false, false], // x!=v
|
|
5581
|
+
[true, true, false, true, false, true, false], // x=v
|
|
5582
|
+
[true, true, true, false, false, false, true ], // !x
|
|
5583
|
+
];
|
|
5584
|
+
for (let i = 0; i < forms.length; i++) {
|
|
5585
|
+
for (let j = 0; j < forms.length; j++) {
|
|
5586
|
+
const instForm = forms[i];
|
|
5587
|
+
const pattForm = forms[j];
|
|
5588
|
+
const instStr = instForm === '' ? 'cap:' : 'cap:' + instForm;
|
|
5589
|
+
const pattStr = pattForm === '' ? 'cap:' : 'cap:' + pattForm;
|
|
5590
|
+
const inst = CapUrn.fromString(instStr);
|
|
5591
|
+
const patt = CapUrn.fromString(pattStr);
|
|
5592
|
+
const actual = patt.accepts(inst);
|
|
5593
|
+
assertEqual(actual, expected[i][j],
|
|
5594
|
+
`cell (inst=${instForm}, patt=${pattForm}) expected ${expected[i][j]} got ${actual}`);
|
|
5595
|
+
}
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
|
|
5599
|
+
// TEST1843: Invalid qualifier combinations must be rejected.
|
|
5600
|
+
function test1843_rejectInvalidCombinations() {
|
|
5601
|
+
const invalid = [
|
|
5602
|
+
'cap:?x?=v', 'cap:!x!=v', 'cap:?!x', 'cap:!?x',
|
|
5603
|
+
'cap:?x=*', 'cap:!x=*',
|
|
5604
|
+
'cap:?x=?', 'cap:?x=!', 'cap:!x=?', 'cap:!x=!',
|
|
5605
|
+
'cap:?', 'cap:!',
|
|
5606
|
+
];
|
|
5607
|
+
for (const input of invalid) {
|
|
5608
|
+
let threw = false;
|
|
5609
|
+
try { CapUrn.fromString(input); } catch (_e) { threw = true; }
|
|
5610
|
+
assert(threw, `input ${input} must be rejected`);
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
|
|
5614
|
+
// TEST1844: out-axis difference dominates combined in+y differences.
|
|
5615
|
+
function test1844_axisWeightingOutDominates() {
|
|
5616
|
+
const bigOut = CapUrn.fromString('cap:in=media:;out="media:record;textable"');
|
|
5617
|
+
const bigInAndY = CapUrn.fromString(
|
|
5618
|
+
'cap:in=media:pdf;out=media:record;!constrained;?target;extract;stage!=alpha;target2=metadata;ver?=draft'
|
|
5619
|
+
);
|
|
5620
|
+
assert(bigOut.specificity() > bigInAndY.specificity(),
|
|
5621
|
+
'out-axis difference must dominate combined in+y differences');
|
|
5622
|
+
}
|
|
5623
|
+
|
|
5624
|
+
// TEST1845: With equal out, in-axis dominates over y-axis.
|
|
5625
|
+
function test1845_axisWeightingInDominatesY() {
|
|
5626
|
+
const bigIn = CapUrn.fromString('cap:in=media:pdf;out=media:record');
|
|
5627
|
+
const bigY = CapUrn.fromString(
|
|
5628
|
+
'cap:in=media:;out=media:record;!constrained;?target;extract;stage!=alpha;target2=metadata;ver?=draft'
|
|
5629
|
+
);
|
|
5630
|
+
assert(bigIn.specificity() > bigY.specificity(),
|
|
5631
|
+
'in-axis difference must dominate y-axis');
|
|
5632
|
+
}
|
|
5633
|
+
|
|
5634
|
+
// TEST1846: Decoded layout — 10000*out + 100*in + y.
|
|
5635
|
+
function test1846_axisWeightingDecodedLayout() {
|
|
5636
|
+
const cap = CapUrn.fromString('cap:in="media:a;b";out="media:a;b;c;d";extract');
|
|
5637
|
+
// out=4 markers (8), in=2 markers (4), y=1 marker (2)
|
|
5638
|
+
// 10000*8 + 100*4 + 2 = 80402
|
|
5639
|
+
assertEqual(cap.specificity(), 10000*8 + 100*4 + 2);
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5261
5642
|
// ============================================================================
|
|
5262
5643
|
// Test runner
|
|
5263
5644
|
// ============================================================================
|
|
@@ -5651,6 +6032,39 @@ async function runTests() {
|
|
|
5651
6032
|
runTest('RENDERER: buildResolvedMachine_dupNodeIdFails', testRenderer_buildResolvedMachineGraphData_duplicateNodeIdAcrossStrandsFailsHard);
|
|
5652
6033
|
runTest('RENDERER: validateResolvedMachine_rejectsMissingFields', testRenderer_validateResolvedMachinePayload_rejectsMissingFields);
|
|
5653
6034
|
|
|
6035
|
+
console.log('\n--- CapKind classifier (test1800–test1805) ---');
|
|
6036
|
+
runTest('TEST1800: kind_identity_only_for_bare_cap', test1800_kindIdentityOnlyForBareCap);
|
|
6037
|
+
runTest('TEST1801: kind_source_when_input_is_void', test1801_kindSourceWhenInputIsVoid);
|
|
6038
|
+
runTest('TEST1802: kind_sink_when_output_is_void', test1802_kindSinkWhenOutputIsVoid);
|
|
6039
|
+
runTest('TEST1803: kind_effect_when_both_sides_void', test1803_kindEffectWhenBothSidesVoid);
|
|
6040
|
+
runTest('TEST1804: kind_transform_for_normal_processors', test1804_kindTransformForNormalDataProcessors);
|
|
6041
|
+
runTest('TEST1805: kind_invariant_under_canonical', test1805_kindInvariantUnderCanonicalSpellings);
|
|
6042
|
+
|
|
6043
|
+
console.log('\n--- media:void atomicity (test1810) ---');
|
|
6044
|
+
runTest('TEST1810: media_void_is_atomic', test1810_mediaVoidIsAtomic);
|
|
6045
|
+
|
|
6046
|
+
console.log('\n--- Truth-table specificity (test1820–test1824) ---');
|
|
6047
|
+
runTest('TEST1820: specificity_question_is_zero', test1820_specificityQuestionIsZero);
|
|
6048
|
+
runTest('TEST1821: specificity_must_not_have_is_five', test1821_specificityMustNotHaveIsFive);
|
|
6049
|
+
runTest('TEST1822: specificity_must_have_any_is_two', test1822_specificityMustHaveAnyIsTwo);
|
|
6050
|
+
runTest('TEST1823: specificity_exact_value_is_four', test1823_specificityExactValueIsFour);
|
|
6051
|
+
runTest('TEST1824: specificity_combined_y_axis', test1824_specificityCombinedYAxis);
|
|
6052
|
+
|
|
6053
|
+
console.log('\n--- Six-form canonicalization (test1830–test1835) ---');
|
|
6054
|
+
runTest('TEST1830: canonicalize_no_constraint', test1830_canonicalizeNoConstraint);
|
|
6055
|
+
runTest('TEST1831: canonicalize_absent_or_not_value', test1831_canonicalizeAbsentOrNotValue);
|
|
6056
|
+
runTest('TEST1832: canonicalize_must_have_any', test1832_canonicalizeMustHaveAny);
|
|
6057
|
+
runTest('TEST1833: canonicalize_present_not_value', test1833_canonicalizePresentNotValue);
|
|
6058
|
+
runTest('TEST1834: canonicalize_exact_value', test1834_canonicalizeExactValue);
|
|
6059
|
+
runTest('TEST1835: canonicalize_must_not_have', test1835_canonicalizeMustNotHave);
|
|
6060
|
+
|
|
6061
|
+
console.log('\n--- Truth-table cross-product + axis weighting (test1842–test1846) ---');
|
|
6062
|
+
runTest('TEST1842: truth_table_full_cross_product', test1842_truthTableFullCrossProduct);
|
|
6063
|
+
runTest('TEST1843: reject_invalid_combinations', test1843_rejectInvalidCombinations);
|
|
6064
|
+
runTest('TEST1844: axis_weighting_out_dominates', test1844_axisWeightingOutDominates);
|
|
6065
|
+
runTest('TEST1845: axis_weighting_in_dominates_y', test1845_axisWeightingInDominatesY);
|
|
6066
|
+
runTest('TEST1846: axis_weighting_decoded_layout', test1846_axisWeightingDecodedLayout);
|
|
6067
|
+
|
|
5654
6068
|
// Summary
|
|
5655
6069
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|
|
5656
6070
|
if (failCount > 0) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"author": "Bahram Joharshamshiri",
|
|
3
3
|
"dependencies": {
|
|
4
4
|
"peggy": "^5.1.0",
|
|
5
|
-
"tagged-urn": "^0.
|
|
5
|
+
"tagged-urn": "^0.39.102"
|
|
6
6
|
},
|
|
7
7
|
"description": "JavaScript implementation of Cap URN (Capability Uniform Resource Names) with strict validation and matching",
|
|
8
8
|
"engines": {
|
|
@@ -40,5 +40,5 @@
|
|
|
40
40
|
"pretest": "npm run build:parser",
|
|
41
41
|
"test": "node capdag.test.js"
|
|
42
42
|
},
|
|
43
|
-
"version": "0.
|
|
43
|
+
"version": "0.166.402"
|
|
44
44
|
}
|