capdag 0.161.384 → 0.165.398

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 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:op=extract'); // Missing in/out
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:op=extract'); // throws ErrorCodes.MISSING_IN_SPEC
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
- // Build complete tags map including in and out
283
- const allTags = { ...this.tags, 'in': this.inSpec, 'out': this.outSpec };
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
- // Check all tags required by the pattern. Missing tags in the instance reject.
436
- for (const [patternKey, patternValue] of Object.entries(this.tags)) {
437
- const requestValue = request.tags[patternKey];
438
-
439
- if (requestValue === undefined) {
440
- return false;
441
- }
442
-
443
- if (patternValue === '*' || requestValue === '*') {
444
- continue;
445
- }
446
-
447
- if (patternValue !== requestValue) {
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:
586
+ *
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)
468
593
  *
469
- * More specific caps have higher scores and are preferred.
470
- * Direction specs contribute their MediaUrn tag count (more tags = more specific).
471
- * Other tags contribute 1 per non-wildcard value.
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
- let count = 0;
477
- // Direction specs contribute their MediaUrn tag count. `media:` is the
478
- // wildcard top and contributes zero.
479
- if (this.inSpec !== 'media:' && this.inSpec !== '*') {
480
- const inMedia = TaggedUrn.fromString(this.inSpec);
481
- count += Object.keys(inMedia.tags).length;
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
- // Count non-wildcard tags
488
- count += Object.values(this.tags).filter(value => value !== '*').length;
489
- return count;
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
- .tag('op', 'generate_text')
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
- .tag('op', 'render_page_image')
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
- .tag('op', 'convert_format')
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
- .tag('op', 'model-availability')
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
- .tag('op', 'model-path')
1472
+ .marker('model-path')
1310
1473
  .inSpec(MEDIA_MODEL_SPEC)
1311
1474
  .outSpec(MEDIA_PATH_OUTPUT)
1312
1475
  .build();
@@ -4528,6 +4691,24 @@ class CartridgeRepoServer {
4528
4691
  * Reasons why a cartridge attachment attempt failed.
4529
4692
  * Mirrors Rust CartridgeAttachmentErrorKind.
4530
4693
  */
4694
+ const CartridgeLifecycle = Object.freeze({
4695
+ // Discovery scan has found the version directory and is about
4696
+ // to inspect it. Transient.
4697
+ DISCOVERED: 'discovered',
4698
+ // Reading cartridge.json, computing directory hash, validating
4699
+ // on-disk install context. Hashing can take seconds for large
4700
+ // model cartridges; runs on a background queue so other
4701
+ // cartridges' inspections proceed in parallel.
4702
+ INSPECTING: 'inspecting',
4703
+ // Inspection succeeded; awaiting a verdict from the registry
4704
+ // verifier service. Skipped for dev cartridges
4705
+ // (registry_url == null) and bundle cartridges.
4706
+ VERIFYING: 'verifying',
4707
+ // Cleared every gate. Caps are registered with the engine and
4708
+ // dispatch can route requests to this cartridge.
4709
+ OPERATIONAL: 'operational',
4710
+ });
4711
+
4531
4712
  const CartridgeAttachmentErrorKind = Object.freeze({
4532
4713
  INCOMPATIBLE: 'incompatible',
4533
4714
  MANIFEST_INVALID: 'manifest_invalid',
@@ -4636,9 +4817,9 @@ class CartridgeRuntimeStats {
4636
4817
  /**
4637
4818
  * Full identity of an installed cartridge, including optional attachment error
4638
4819
  * and runtime statistics.
4639
- * Mirrors Rust InstalledCartridgeIdentity.
4820
+ * Mirrors Rust InstalledCartridgeRecord.
4640
4821
  */
4641
- class InstalledCartridgeIdentity {
4822
+ class InstalledCartridgeRecord {
4642
4823
  /**
4643
4824
  * @param {Object} opts
4644
4825
  * @param {string|null} [opts.registryUrl=null]
@@ -4649,8 +4830,9 @@ class InstalledCartridgeIdentity {
4649
4830
  * @param {Array<Object>} [opts.capGroups=[]] - Cartridge's manifest cap_groups; each element is `{name, caps, adapter_urns}`.
4650
4831
  * @param {CartridgeAttachmentError|null} [opts.attachmentError=null]
4651
4832
  * @param {CartridgeRuntimeStats|null} [opts.runtimeStats=null]
4833
+ * @param {string} [opts.lifecycle='discovered'] - One of `discovered` | `inspecting` | `verifying` | `operational`. Mutually exclusive with attachmentError; see `machfab-mac/docs/cartridge state machine.md`.
4652
4834
  */
4653
- constructor({ registryUrl = null, channel, id, version, sha256, capGroups = [], attachmentError = null, runtimeStats = null }) {
4835
+ constructor({ registryUrl = null, channel, id, version, sha256, capGroups = [], attachmentError = null, runtimeStats = null, lifecycle = 'discovered' }) {
4654
4836
  this.registry_url = registryUrl;
4655
4837
  this.channel = channel;
4656
4838
  this.id = id;
@@ -4659,6 +4841,7 @@ class InstalledCartridgeIdentity {
4659
4841
  this.cap_groups = capGroups;
4660
4842
  this.attachment_error = attachmentError;
4661
4843
  this.runtime_stats = runtimeStats;
4844
+ this.lifecycle = lifecycle;
4662
4845
  }
4663
4846
 
4664
4847
  toJSON() {
@@ -4667,6 +4850,7 @@ class InstalledCartridgeIdentity {
4667
4850
  id: this.id,
4668
4851
  version: this.version,
4669
4852
  sha256: this.sha256,
4853
+ lifecycle: this.lifecycle,
4670
4854
  };
4671
4855
  if (this.registry_url !== null) obj.registry_url = this.registry_url;
4672
4856
  if (this.cap_groups && this.cap_groups.length > 0) obj.cap_groups = this.cap_groups;
@@ -4676,7 +4860,7 @@ class InstalledCartridgeIdentity {
4676
4860
  }
4677
4861
 
4678
4862
  static fromJSON(d) {
4679
- return new InstalledCartridgeIdentity({
4863
+ return new InstalledCartridgeRecord({
4680
4864
  registryUrl: d.registry_url !== undefined ? d.registry_url : null,
4681
4865
  channel: d.channel,
4682
4866
  id: d.id,
@@ -4685,6 +4869,9 @@ class InstalledCartridgeIdentity {
4685
4869
  capGroups: Array.isArray(d.cap_groups) ? d.cap_groups : [],
4686
4870
  attachmentError: d.attachment_error ? CartridgeAttachmentError.fromJSON(d.attachment_error) : null,
4687
4871
  runtimeStats: d.runtime_stats ? CartridgeRuntimeStats.fromJSON(d.runtime_stats) : null,
4872
+ // Default to 'discovered' (the safe sentinel) — never
4873
+ // 'operational' — when the field is missing from the wire.
4874
+ lifecycle: typeof d.lifecycle === 'string' ? d.lifecycle : 'discovered',
4688
4875
  });
4689
4876
  }
4690
4877
 
@@ -5190,19 +5377,14 @@ class Machine {
5190
5377
  return ea.target.toString().localeCompare(eb.target.toString());
5191
5378
  });
5192
5379
 
5193
- // Step 2: Generate aliases from op= tag
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.
5194
5384
  const aliases = new Map();
5195
- const aliasCounts = new Map();
5196
-
5197
5385
  for (const idx of edgeOrder) {
5198
5386
  const edge = this._edges[idx];
5199
- const opTag = edge.capUrn.getTag('op');
5200
- const baseAlias = opTag !== undefined ? opTag : `edge_${idx}`;
5201
-
5202
- const count = aliasCounts.get(baseAlias) || 0;
5203
- const alias = count === 0 ? baseAlias : `${baseAlias}_${count}`;
5204
- aliasCounts.set(baseAlias, count + 1);
5205
-
5387
+ const alias = `edge_${idx}`;
5206
5388
  const capStr = edge.capUrn.toString();
5207
5389
  aliases.set(alias, { edgeIdx: idx, capStr });
5208
5390
  }
@@ -5273,16 +5455,17 @@ class Machine {
5273
5455
  // Define edges
5274
5456
  for (const edgeIdx of edgeOrder) {
5275
5457
  const edge = this._edges[edgeIdx];
5276
- // Find alias for this edge
5277
- let edgeLabel = null;
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;
5278
5463
  for (const [a, info] of aliases) {
5279
5464
  if (info.edgeIdx === edgeIdx) {
5280
- edgeLabel = a;
5465
+ label = a;
5281
5466
  break;
5282
5467
  }
5283
5468
  }
5284
- const opTag = edge.capUrn.getTag('op');
5285
- const label = opTag !== undefined ? opTag : edgeLabel;
5286
5469
 
5287
5470
  const targetKey = edge.target.toString();
5288
5471
  const targetName = nodeNames.get(targetKey);
@@ -5811,6 +5994,7 @@ class CapRegistryClient {
5811
5994
  // Export for CommonJS
5812
5995
  module.exports = {
5813
5996
  CapUrn,
5997
+ CapKind,
5814
5998
  CapUrnBuilder,
5815
5999
  CapMatcher,
5816
6000
  CapUrnError,
@@ -5966,10 +6150,11 @@ module.exports = {
5966
6150
  CartridgeRepoServer,
5967
6151
  CartridgeChannel,
5968
6152
  // Bifaci — cartridge attachment & runtime identity
6153
+ CartridgeLifecycle,
5969
6154
  CartridgeAttachmentErrorKind,
5970
6155
  CartridgeAttachmentError,
5971
6156
  CartridgeRuntimeStats,
5972
- InstalledCartridgeIdentity,
6157
+ InstalledCartridgeRecord,
5973
6158
  // Registry slug
5974
6159
  DEV_SLUG,
5975
6160
  SLUG_HEX_LEN,
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
- assertEqual(cap.getTag('op'), 'generate', 'Should get op tag');
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="media:void";op=test');
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="media:void";op=test');
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('op=generate'));
155
- const request = CapUrn.fromString(testUrn('op=generate'));
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:IN="media:void";OP=Generate;EXT=PDF;OUT="media:record;textable"');
170
- assertEqual(cap.getTag('op'), 'generate', 'Unquoted value should be lowercased');
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";op=generate'),
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('op=generate'));
274
- assertEqual(cap.getTag('op'), 'generate', 'Should parse with valid cap: prefix');
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(op=generate) accepts cap(op,ext,target)
295
- const subset = CapUrn.fromString(testUrn('op=generate'));
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('op=extract'));
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('op=generate'));
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('op=generate'));
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: Test specificity calculation (direction specs use MediaUrn tag count, wildcards don't count)
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
- // Direction specs contribute their MediaUrn tag count:
332
- // MEDIA_VOID = "media:void" -> 1 tag (void)
333
- // MEDIA_OBJECT = "media:record" -> 1 tag (record)
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
- assertEqual(cap1.specificity(), 3, 'void(1) + record(1) + type(1)');
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('op=generate'));
338
- assertEqual(cap2.specificity(), 3, 'void(1) + record(1) + op(1)');
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=*;ext=pdf'));
341
- assertEqual(cap3.specificity(), 3, 'void(1) + record(1) + ext(1) (wildcard op doesn\'t count)');
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 doesn't count
344
- const cap4 = CapUrn.fromString(`cap:in=*;out="${MEDIA_OBJECT}";op=test`);
345
- assertEqual(cap4.specificity(), 2, 'record(1) + op(1) (in wildcard doesn\'t count)');
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
- assertEqual(cap.getTag('op'), 'generate', 'Builder should set op');
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;op=extract'));
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('op=generate'));
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('op=generate')),
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('op=generate'));
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('op=generate'));
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
- assertEqual(merged.getTag('op'), 'generate', 'Merge should keep original tags');
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
- assertEqual(sub.getTag('op'), undefined, 'Subset should drop op');
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('op=generate'));
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('op=generate'));
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 - more media URN tags = higher 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
- assertEqual(genericCap.specificity(), 4, 'media:(0) + image;png;thumbnail(3) + op(1) = 4');
719
- assertEqual(specificCap.specificity(), 5, 'pdf(1) + image;png;thumbnail(3) + op(1) = 5');
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.hasTag('op', 'model-availability'), 'Must have op=model-availability');
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.hasTag('op', 'model-path'), 'Must have op=model-path');
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.hasTag('op', 'generate_text'), 'Must have op=generate_text');
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('op=test'));
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('op=documented'));
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('op=undocumented'));
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
- assertEqual(removed.getTag('op'), 'test', 'withoutTag should preserve op');
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
- assertEqual(changedIn.getTag('op'), 'test', 'withInSpec should preserve tags');
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 (op=test and test;ext=pdf), not op=different
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 (op=test) accepts caps2 (test;ext=pdf) -> compatible
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 (op=test) vs caps3 (op=different) -> not compatible
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;out;op=test would fail parsing)
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
- assertEqual(best.getTag('op'), 'test', 'CapMatcher should prefer specific cap over identity');
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="media:b";out="media:c";op=y', // different tag order
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.35.86"
5
+ "tagged-urn": "^0.39.0"
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.161.384"
43
+ "version": "0.165.398"
44
44
  }