@net-mesh/sdk 0.19.0

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.
@@ -0,0 +1,1324 @@
1
+ "use strict";
2
+ /**
3
+ * Capability-System Enhancements — TypeScript surface.
4
+ *
5
+ * This module mirrors the substrate's enhancement-track types:
6
+ *
7
+ * - Typed taxonomy ({@link Tag}, {@link TagKey}, {@link TaxonomyAxis})
8
+ * with reserved-prefix enforcement at construction time.
9
+ * - Fluent {@link Predicate} builder (`p.*`) producing the canonical
10
+ * wire IR (`PredicateWire`) consumed by `net-where` headers.
11
+ * - {@link diffCapabilities} returning the same `CapabilitySetDiff`
12
+ * shape the cross-binding fixtures pin.
13
+ * - {@link requireTag} / {@link requireAxisValue} chain helpers
14
+ * producing `{ tags, metadata }` directly.
15
+ * - {@link StandardPlacement} configuration object + the
16
+ * {@link placementFilterFromFn} callback shape.
17
+ *
18
+ * All wire-format types match the JSON shape the substrate emits via
19
+ * `serde_json` — the cross-binding tests in
20
+ * `tests/cross_lang_capability/` pin the canonical bytes.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.StandardPlacementBuilder = exports.RPC_WHERE_HEADER = exports.p = exports.RESERVED_PREFIXES = exports.TAXONOMY_AXES = void 0;
26
+ exports.tagKey = tagKey;
27
+ exports.startsWithReservedPrefix = startsWithReservedPrefix;
28
+ exports.tagToString = tagToString;
29
+ exports.tagFromString = tagFromString;
30
+ exports.tagFromUserString = tagFromUserString;
31
+ exports.predicateToWire = predicateToWire;
32
+ exports.predicateFromWire = predicateFromWire;
33
+ exports.predicateToRpcHeader = predicateToRpcHeader;
34
+ exports.predicateFromRpcHeader = predicateFromRpcHeader;
35
+ exports.whereHeader = whereHeader;
36
+ exports.diffCapabilities = diffCapabilities;
37
+ exports.requireTag = requireTag;
38
+ exports.requireAxisValue = requireAxisValue;
39
+ exports.withMetadata = withMetadata;
40
+ exports.emptyCapabilities = emptyCapabilities;
41
+ exports.standardPlacement = standardPlacement;
42
+ exports.placementFilterFromFn = placementFilterFromFn;
43
+ exports.evaluatePredicate = evaluatePredicate;
44
+ exports.evaluatePredicateWithTrace = evaluatePredicateWithTrace;
45
+ exports.predicateDebugReport = predicateDebugReport;
46
+ exports.redactMetadataKeys = redactMetadataKeys;
47
+ exports.predicateDebugReportFromWire = predicateDebugReportFromWire;
48
+ exports.renderDebugReport = renderDebugReport;
49
+ /** Every axis the substrate knows about. */
50
+ exports.TAXONOMY_AXES = [
51
+ 'hardware',
52
+ 'software',
53
+ 'devices',
54
+ 'dataforts',
55
+ ];
56
+ /**
57
+ * Reserved cross-axis prefixes. Substrate-privileged paths
58
+ * (`announceChain`, fork-coordination, scope helpers) emit these;
59
+ * user code calling {@link tagFromUserString} is rejected.
60
+ */
61
+ exports.RESERVED_PREFIXES = [
62
+ 'causal:',
63
+ 'fork-of:',
64
+ 'heat:',
65
+ 'scope:',
66
+ ];
67
+ /**
68
+ * Build a {@link TagKey}. Throws on empty key — matches the substrate's
69
+ * `TagKey::new` contract (the constructor is fallible-by-debug-assert
70
+ * there; we surface as a thrown error here).
71
+ */
72
+ function tagKey(axis, key) {
73
+ if (!key) {
74
+ throw new Error(`tagKey: key must be non-empty (axis=${axis})`);
75
+ }
76
+ return { axis, key };
77
+ }
78
+ /** True iff `s` starts with one of the {@link RESERVED_PREFIXES}. */
79
+ function startsWithReservedPrefix(s) {
80
+ return exports.RESERVED_PREFIXES.find((p) => s.startsWith(p));
81
+ }
82
+ /**
83
+ * Render a {@link Tag} to its canonical wire string. Matches the
84
+ * substrate's `Display` impl for `Tag`.
85
+ */
86
+ function tagToString(tag) {
87
+ switch (tag.kind) {
88
+ case 'axisPresent':
89
+ return `${tag.axis}.${tag.key}`;
90
+ case 'axisValue':
91
+ return `${tag.axis}.${tag.key}${tag.separator}${tag.value}`;
92
+ case 'reserved':
93
+ return `${tag.prefix}${tag.body}`;
94
+ case 'legacy':
95
+ return tag.raw;
96
+ }
97
+ }
98
+ function axisFromPrefix(prefix) {
99
+ return exports.TAXONOMY_AXES.includes(prefix)
100
+ ? prefix
101
+ : undefined;
102
+ }
103
+ /**
104
+ * Parse a wire string into a {@link Tag}. Privileged path — does
105
+ * NOT reject reserved prefixes (substrate code that legitimately
106
+ * needs to round-trip e.g. `scope:tenant:foo` calls this).
107
+ *
108
+ * User code should prefer {@link tagFromUserString}, which rejects
109
+ * reserved prefixes.
110
+ */
111
+ function tagFromString(s) {
112
+ if (!s) {
113
+ throw new Error('tagFromString: tag must be non-empty');
114
+ }
115
+ const reserved = startsWithReservedPrefix(s);
116
+ if (reserved !== undefined) {
117
+ return { kind: 'reserved', prefix: reserved, body: s.slice(reserved.length) };
118
+ }
119
+ const dot = s.indexOf('.');
120
+ if (dot < 0) {
121
+ return { kind: 'legacy', raw: s };
122
+ }
123
+ const axis = axisFromPrefix(s.slice(0, dot));
124
+ if (!axis) {
125
+ return { kind: 'legacy', raw: s };
126
+ }
127
+ const body = s.slice(dot + 1);
128
+ if (!body) {
129
+ return { kind: 'legacy', raw: s };
130
+ }
131
+ // Pick the earliest of `=` / `:` so `key=value` wins over a
132
+ // later `:`. Mirrors the substrate's `parse_axis_body`.
133
+ const eq = body.indexOf('=');
134
+ const colon = body.indexOf(':');
135
+ let sep;
136
+ let sepIdx = -1;
137
+ if (eq >= 0 && colon >= 0) {
138
+ if (eq < colon) {
139
+ sep = '=';
140
+ sepIdx = eq;
141
+ }
142
+ else {
143
+ sep = ':';
144
+ sepIdx = colon;
145
+ }
146
+ }
147
+ else if (eq >= 0) {
148
+ sep = '=';
149
+ sepIdx = eq;
150
+ }
151
+ else if (colon >= 0) {
152
+ sep = ':';
153
+ sepIdx = colon;
154
+ }
155
+ if (sep === undefined) {
156
+ return { kind: 'axisPresent', axis, key: body };
157
+ }
158
+ const key = body.slice(0, sepIdx);
159
+ const value = body.slice(sepIdx + 1);
160
+ if (!key || !value) {
161
+ // Empty key or value — fall back to legacy so the substrate's
162
+ // round-trip stays lossless.
163
+ return { kind: 'legacy', raw: s };
164
+ }
165
+ return { kind: 'axisValue', axis, key, value, separator: sep };
166
+ }
167
+ /**
168
+ * Parse a wire string from user code. Rejects the reserved
169
+ * cross-axis prefixes ({@link RESERVED_PREFIXES}) — application code
170
+ * cannot emit those by design. Mirrors the substrate's
171
+ * `Tag::parse_user`.
172
+ */
173
+ function tagFromUserString(s) {
174
+ if (!s) {
175
+ throw new Error('tagFromUserString: tag must be non-empty');
176
+ }
177
+ const reserved = startsWithReservedPrefix(s);
178
+ if (reserved !== undefined) {
179
+ throw new Error(`tag ${JSON.stringify(s)} starts with reserved prefix ${JSON.stringify(reserved)}; user code cannot emit reserved-prefix tags`);
180
+ }
181
+ return tagFromString(s);
182
+ }
183
+ /**
184
+ * Fluent predicate constructors. Match the substrate's `Predicate::*`
185
+ * factory methods one-to-one.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * import { p, predicateToWire } from '@net-mesh/sdk';
190
+ *
191
+ * const pred = p.and(
192
+ * p.exists({ axis: 'hardware', key: 'gpu' }),
193
+ * p.numericAtLeast({ axis: 'hardware', key: 'memory_gb' }, 64),
194
+ * p.metadataEquals('intent', 'ml-training'),
195
+ * );
196
+ * const wire = predicateToWire(pred);
197
+ * ```
198
+ */
199
+ exports.p = {
200
+ exists(key) {
201
+ return { type: 'exists', key };
202
+ },
203
+ equals(key, value) {
204
+ return { type: 'equals', key, value };
205
+ },
206
+ numericAtLeast(key, threshold) {
207
+ return { type: 'numericAtLeast', key, threshold };
208
+ },
209
+ numericAtMost(key, threshold) {
210
+ return { type: 'numericAtMost', key, threshold };
211
+ },
212
+ numericInRange(key, min, max) {
213
+ return { type: 'numericInRange', key, min, max };
214
+ },
215
+ semverAtLeast(key, version) {
216
+ return { type: 'semverAtLeast', key, version };
217
+ },
218
+ semverAtMost(key, version) {
219
+ return { type: 'semverAtMost', key, version };
220
+ },
221
+ semverCompatible(key, version) {
222
+ return { type: 'semverCompatible', key, version };
223
+ },
224
+ stringPrefix(key, prefix) {
225
+ return { type: 'stringPrefix', key, prefix };
226
+ },
227
+ stringMatches(key, pattern) {
228
+ return { type: 'stringMatches', key, pattern };
229
+ },
230
+ metadataExists(key) {
231
+ return { type: 'metadataExists', key };
232
+ },
233
+ metadataEquals(key, value) {
234
+ return { type: 'metadataEquals', key, value };
235
+ },
236
+ metadataMatches(key, pattern) {
237
+ return { type: 'metadataMatches', key, pattern };
238
+ },
239
+ metadataNumericAtLeast(key, threshold) {
240
+ return { type: 'metadataNumericAtLeast', key, threshold };
241
+ },
242
+ and(...children) {
243
+ return { type: 'and', children };
244
+ },
245
+ or(...children) {
246
+ return { type: 'or', children };
247
+ },
248
+ not(child) {
249
+ return { type: 'not', child };
250
+ },
251
+ };
252
+ function emit(node, out) {
253
+ switch (node.type) {
254
+ case 'exists':
255
+ out.push({ kind: 'exists', key: node.key });
256
+ return out.length - 1;
257
+ case 'equals':
258
+ out.push({ kind: 'equals', key: node.key, value: node.value });
259
+ return out.length - 1;
260
+ case 'numericAtLeast':
261
+ out.push({
262
+ kind: 'numeric_at_least',
263
+ key: node.key,
264
+ threshold: node.threshold,
265
+ });
266
+ return out.length - 1;
267
+ case 'numericAtMost':
268
+ out.push({
269
+ kind: 'numeric_at_most',
270
+ key: node.key,
271
+ threshold: node.threshold,
272
+ });
273
+ return out.length - 1;
274
+ case 'numericInRange':
275
+ out.push({
276
+ kind: 'numeric_in_range',
277
+ key: node.key,
278
+ min: node.min,
279
+ max: node.max,
280
+ });
281
+ return out.length - 1;
282
+ case 'semverAtLeast':
283
+ out.push({
284
+ kind: 'semver_at_least',
285
+ key: node.key,
286
+ version: node.version,
287
+ });
288
+ return out.length - 1;
289
+ case 'semverAtMost':
290
+ out.push({
291
+ kind: 'semver_at_most',
292
+ key: node.key,
293
+ version: node.version,
294
+ });
295
+ return out.length - 1;
296
+ case 'semverCompatible':
297
+ out.push({
298
+ kind: 'semver_compatible',
299
+ key: node.key,
300
+ version: node.version,
301
+ });
302
+ return out.length - 1;
303
+ case 'stringPrefix':
304
+ out.push({ kind: 'string_prefix', key: node.key, prefix: node.prefix });
305
+ return out.length - 1;
306
+ case 'stringMatches':
307
+ out.push({
308
+ kind: 'string_matches',
309
+ key: node.key,
310
+ pattern: node.pattern,
311
+ });
312
+ return out.length - 1;
313
+ case 'metadataExists':
314
+ out.push({ kind: 'metadata_exists', key: node.key });
315
+ return out.length - 1;
316
+ case 'metadataEquals':
317
+ out.push({
318
+ kind: 'metadata_equals',
319
+ key: node.key,
320
+ value: node.value,
321
+ });
322
+ return out.length - 1;
323
+ case 'metadataMatches':
324
+ out.push({
325
+ kind: 'metadata_matches',
326
+ key: node.key,
327
+ pattern: node.pattern,
328
+ });
329
+ return out.length - 1;
330
+ case 'metadataNumericAtLeast':
331
+ out.push({
332
+ kind: 'metadata_numeric_at_least',
333
+ key: node.key,
334
+ threshold: node.threshold,
335
+ });
336
+ return out.length - 1;
337
+ case 'and': {
338
+ const childIdxs = node.children.map((c) => emit(c, out));
339
+ out.push({ kind: 'and', children: childIdxs });
340
+ return out.length - 1;
341
+ }
342
+ case 'or': {
343
+ const childIdxs = node.children.map((c) => emit(c, out));
344
+ out.push({ kind: 'or', children: childIdxs });
345
+ return out.length - 1;
346
+ }
347
+ case 'not': {
348
+ const childIdx = emit(node.child, out);
349
+ out.push({ kind: 'not', child: childIdx });
350
+ return out.length - 1;
351
+ }
352
+ }
353
+ }
354
+ /** Flatten an AST into the wire IR. Children always sit at lower
355
+ * indices than their parents (post-order). */
356
+ function predicateToWire(pred) {
357
+ const nodes = [];
358
+ const root_idx = emit(pred, nodes);
359
+ return { nodes, root_idx };
360
+ }
361
+ /** Inverse of {@link predicateToWire}. Rebuilds the AST from a wire
362
+ * IR. Throws on out-of-range indices or malformed nodes. */
363
+ function predicateFromWire(wire) {
364
+ const built = new Array(wire.nodes.length);
365
+ // Post-order — every child has a strictly smaller index than its
366
+ // parent, so a left-to-right walk is sufficient.
367
+ for (let i = 0; i < wire.nodes.length; i++) {
368
+ const n = wire.nodes[i];
369
+ built[i] = nodeFromWire(n, built, i);
370
+ }
371
+ // Q6: validate root_idx is an integer. Pre-fix only the
372
+ // numeric range was checked, so a non-integer (e.g. `1.5`,
373
+ // `NaN`, `Infinity`) would compare against `built.length`,
374
+ // pass the bounds check, and `built[wire.root_idx]` would
375
+ // return `undefined` instead of throwing — masking malformed
376
+ // wire input as a silent missing-root.
377
+ if (!Number.isInteger(wire.root_idx) ||
378
+ wire.root_idx < 0 ||
379
+ wire.root_idx >= built.length) {
380
+ throw new Error(`predicateFromWire: root_idx ${wire.root_idx} out of range [0, ${built.length})`);
381
+ }
382
+ return built[wire.root_idx];
383
+ }
384
+ function nodeFromWire(n, prior, selfIdx) {
385
+ const checkChild = (idx) => {
386
+ // Q5: validate `idx` is an integer. Pre-fix only the
387
+ // numeric range was checked, so a non-integer index in the
388
+ // child array (e.g. `1.5`, `NaN`) would index `prior` and
389
+ // return `undefined` — yielding a malformed AST that
390
+ // crashes on later evaluation rather than failing here at
391
+ // decode time.
392
+ if (!Number.isInteger(idx) || idx < 0 || idx >= selfIdx) {
393
+ throw new Error(`predicateFromWire: child index ${idx} not strictly less than self ${selfIdx}`);
394
+ }
395
+ return prior[idx];
396
+ };
397
+ switch (n.kind) {
398
+ case 'exists':
399
+ return { type: 'exists', key: n.key };
400
+ case 'equals':
401
+ return { type: 'equals', key: n.key, value: n.value };
402
+ case 'numeric_at_least':
403
+ return { type: 'numericAtLeast', key: n.key, threshold: n.threshold };
404
+ case 'numeric_at_most':
405
+ return { type: 'numericAtMost', key: n.key, threshold: n.threshold };
406
+ case 'numeric_in_range':
407
+ return {
408
+ type: 'numericInRange',
409
+ key: n.key,
410
+ min: n.min,
411
+ max: n.max,
412
+ };
413
+ case 'semver_at_least':
414
+ return { type: 'semverAtLeast', key: n.key, version: n.version };
415
+ case 'semver_at_most':
416
+ return { type: 'semverAtMost', key: n.key, version: n.version };
417
+ case 'semver_compatible':
418
+ return { type: 'semverCompatible', key: n.key, version: n.version };
419
+ case 'string_prefix':
420
+ return { type: 'stringPrefix', key: n.key, prefix: n.prefix };
421
+ case 'string_matches':
422
+ return { type: 'stringMatches', key: n.key, pattern: n.pattern };
423
+ case 'metadata_exists':
424
+ return { type: 'metadataExists', key: n.key };
425
+ case 'metadata_equals':
426
+ return { type: 'metadataEquals', key: n.key, value: n.value };
427
+ case 'metadata_matches':
428
+ return { type: 'metadataMatches', key: n.key, pattern: n.pattern };
429
+ case 'metadata_numeric_at_least':
430
+ return {
431
+ type: 'metadataNumericAtLeast',
432
+ key: n.key,
433
+ threshold: n.threshold,
434
+ };
435
+ case 'and':
436
+ return {
437
+ type: 'and',
438
+ children: n.children.map(checkChild),
439
+ };
440
+ case 'or':
441
+ return {
442
+ type: 'or',
443
+ children: n.children.map(checkChild),
444
+ };
445
+ case 'not':
446
+ return { type: 'not', child: checkChild(n.child) };
447
+ }
448
+ }
449
+ // ============================================================================
450
+ // nRPC envelope helpers — mirror `predicate_to_rpc_header` /
451
+ // `predicate_from_rpc_headers` in the substrate.
452
+ // ============================================================================
453
+ /** Header the substrate uses to carry a predicate over nRPC. */
454
+ exports.RPC_WHERE_HEADER = 'net-where';
455
+ /** Encode a predicate into the request-header value. The substrate
456
+ * pins this as the canonical JSON-encoded {@link PredicateWire}. */
457
+ function predicateToRpcHeader(pred) {
458
+ return JSON.stringify(predicateToWire(pred));
459
+ }
460
+ /** Decode a `net-where` header value back into an AST. Throws
461
+ * on malformed JSON or out-of-range indices. */
462
+ function predicateFromRpcHeader(value) {
463
+ const wire = JSON.parse(value);
464
+ return predicateFromWire(wire);
465
+ }
466
+ /**
467
+ * Build the `net-where:` request-header entry for a
468
+ * Phase 9b predicate-pushdown call. Drops straight into a
469
+ * `MeshRpc.call` `CallOptions.requestHeaders` array.
470
+ *
471
+ * @example
472
+ * ```ts
473
+ * import { p, tagKey, whereHeader } from '@net-mesh/sdk';
474
+ * const pred = p.exists(tagKey('hardware', 'gpu'));
475
+ * await rpc.call(targetNodeId, 'filter-svc', body, {
476
+ * requestHeaders: [whereHeader(pred)],
477
+ * });
478
+ * ```
479
+ *
480
+ * The header value is the canonical JSON-encoded `PredicateWire`
481
+ * pinned by `predicate_nrpc_envelope.json`.
482
+ */
483
+ function whereHeader(pred) {
484
+ return {
485
+ name: exports.RPC_WHERE_HEADER,
486
+ value: Buffer.from(predicateToRpcHeader(pred), 'utf-8'),
487
+ };
488
+ }
489
+ /**
490
+ * Compute `curr.diff(prev)`. Pinned by the
491
+ * `capability_set_diff.json` cross-binding fixture.
492
+ *
493
+ * - Tag arrays are sorted by wire string.
494
+ * - Metadata changes are sorted by key (BTreeMap semantics in the
495
+ * substrate).
496
+ * - A key rename surfaces as Removed + Added (NOT Updated). Only a
497
+ * value change for the same key is Updated.
498
+ */
499
+ function diffCapabilities(prev, curr) {
500
+ // N-3: compare semantically rather than over raw wire strings.
501
+ // Two `AxisValue` tags differing only in separator form
502
+ // (`hardware.k=v` vs `hardware.k:v`) carry identical semantics;
503
+ // the substrate's `CapabilitySet::diff` uses
504
+ // `Tag::semantic_eq` for exactly this reason (CR-3 fix at
505
+ // `capability.rs:1395-1414`). A raw set-difference over wire
506
+ // strings emits phantom Removed+Added pairs whenever a peer
507
+ // normalizes separator form between announcements.
508
+ //
509
+ // Build a semantic key per parsed tag and compare on that. Keep
510
+ // the original wire string (current side first, falling back to
511
+ // prev) so the emitted op preserves what the caller actually
512
+ // sent.
513
+ const tagSemanticKey = (s) => {
514
+ const t = tagFromString(s);
515
+ switch (t.kind) {
516
+ case 'axisValue':
517
+ // Drop separator from the key — `=` and `:` are
518
+ // semantically identical at the wire layer.
519
+ return `axisValue:${t.axis}.${t.key}=${t.value}`;
520
+ case 'axisPresent':
521
+ return `axisPresent:${t.axis}.${t.key}`;
522
+ case 'reserved':
523
+ return `reserved:${t.prefix}${t.body}`;
524
+ case 'legacy':
525
+ return `legacy:${t.raw}`;
526
+ }
527
+ };
528
+ const prevByKey = new Map();
529
+ for (const t of prev.tags)
530
+ prevByKey.set(tagSemanticKey(t), t);
531
+ const currByKey = new Map();
532
+ for (const t of curr.tags)
533
+ currByKey.set(tagSemanticKey(t), t);
534
+ const added_tags = [];
535
+ const removed_tags = [];
536
+ for (const [k, wire] of currByKey) {
537
+ if (!prevByKey.has(k))
538
+ added_tags.push(wire);
539
+ }
540
+ for (const [k, wire] of prevByKey) {
541
+ if (!currByKey.has(k))
542
+ removed_tags.push(wire);
543
+ }
544
+ added_tags.sort();
545
+ removed_tags.sort();
546
+ const metadata_changes = [];
547
+ const allKeys = new Set([
548
+ ...Object.keys(prev.metadata),
549
+ ...Object.keys(curr.metadata),
550
+ ]);
551
+ const sortedKeys = Array.from(allKeys).sort();
552
+ for (const key of sortedKeys) {
553
+ const inPrev = Object.prototype.hasOwnProperty.call(prev.metadata, key);
554
+ const inCurr = Object.prototype.hasOwnProperty.call(curr.metadata, key);
555
+ if (inPrev && inCurr) {
556
+ const prev_value = prev.metadata[key];
557
+ const new_value = curr.metadata[key];
558
+ if (prev_value !== new_value) {
559
+ metadata_changes.push({
560
+ kind: 'updated',
561
+ key,
562
+ prev_value,
563
+ new_value,
564
+ });
565
+ }
566
+ }
567
+ else if (inCurr) {
568
+ metadata_changes.push({
569
+ kind: 'added',
570
+ key,
571
+ value: curr.metadata[key],
572
+ });
573
+ }
574
+ else if (inPrev) {
575
+ metadata_changes.push({
576
+ kind: 'removed',
577
+ key,
578
+ prev_value: prev.metadata[key],
579
+ });
580
+ }
581
+ }
582
+ return { added_tags, removed_tags, metadata_changes };
583
+ }
584
+ // ============================================================================
585
+ // Chain composition helpers — `requireTag`, `requireAxisValue`, and
586
+ // metadata setters operating on the wire `{ tags, metadata }` shape.
587
+ //
588
+ // These are the user-facing builders for the typed-tag taxonomy. Each
589
+ // one returns a NEW object — the inputs are not mutated, so chains
590
+ // can be composed left-to-right without aliasing surprises.
591
+ // ============================================================================
592
+ function freshTags(caps) {
593
+ // Use a Set to keep tag membership unique, then return as a stable
594
+ // array. Insertion order is preserved unless the tag was already
595
+ // present.
596
+ return Array.from(new Set(caps.tags));
597
+ }
598
+ /**
599
+ * Add an axis-tag (no value) to the wire shape. Idempotent; no-op
600
+ * if the tag is already present.
601
+ */
602
+ function requireTag(caps, axis, key) {
603
+ if (!key) {
604
+ throw new Error('requireTag: key must be non-empty');
605
+ }
606
+ const wire = tagToString({ kind: 'axisPresent', axis, key });
607
+ const tags = freshTags(caps);
608
+ if (!tags.includes(wire))
609
+ tags.push(wire);
610
+ return { tags, metadata: { ...caps.metadata } };
611
+ }
612
+ /**
613
+ * Add an axis-value tag (`<axis>.<key>=<value>` by default) to the
614
+ * wire shape. Idempotent for the exact (axis, key, value, separator)
615
+ * triple.
616
+ */
617
+ function requireAxisValue(caps, axis, key, value, separator = '=') {
618
+ if (!key) {
619
+ throw new Error('requireAxisValue: key must be non-empty');
620
+ }
621
+ if (!value) {
622
+ throw new Error('requireAxisValue: value must be non-empty');
623
+ }
624
+ const wire = tagToString({
625
+ kind: 'axisValue',
626
+ axis,
627
+ key,
628
+ value,
629
+ separator,
630
+ });
631
+ const tags = freshTags(caps);
632
+ if (!tags.includes(wire))
633
+ tags.push(wire);
634
+ return { tags, metadata: { ...caps.metadata } };
635
+ }
636
+ /** Set / overwrite a metadata entry. */
637
+ function withMetadata(caps, key, value) {
638
+ if (!key)
639
+ throw new Error('withMetadata: key must be non-empty');
640
+ return {
641
+ tags: [...caps.tags],
642
+ metadata: { ...caps.metadata, [key]: value },
643
+ };
644
+ }
645
+ /** Empty wire-format capability set. */
646
+ function emptyCapabilities() {
647
+ return { tags: [], metadata: {} };
648
+ }
649
+ /**
650
+ * Builder for {@link StandardPlacement}. Returns a frozen config
651
+ * object suitable for handing to the runtime.
652
+ */
653
+ class StandardPlacementBuilder {
654
+ cfg = {};
655
+ requireTag(axis, key) {
656
+ const wire = tagToString({ kind: 'axisPresent', axis, key });
657
+ this.cfg.requireTags = [...(this.cfg.requireTags ?? []), wire];
658
+ return this;
659
+ }
660
+ requireAxisValue(axis, key, value, separator = '=') {
661
+ const wire = tagToString({
662
+ kind: 'axisValue',
663
+ axis,
664
+ key,
665
+ value,
666
+ separator,
667
+ });
668
+ this.cfg.requireTags = [...(this.cfg.requireTags ?? []), wire];
669
+ return this;
670
+ }
671
+ forbidTag(axis, key) {
672
+ const wire = tagToString({ kind: 'axisPresent', axis, key });
673
+ this.cfg.forbidTags = [...(this.cfg.forbidTags ?? []), wire];
674
+ return this;
675
+ }
676
+ requireMetadata(key, value) {
677
+ this.cfg.requireMetadata = { ...(this.cfg.requireMetadata ?? {}), [key]: value };
678
+ return this;
679
+ }
680
+ withPredicate(pred) {
681
+ this.cfg.predicate = isPredicateWire(pred) ? pred : predicateToWire(pred);
682
+ return this;
683
+ }
684
+ withLimit(n) {
685
+ if (!Number.isFinite(n) || n < 0) {
686
+ throw new Error('StandardPlacementBuilder.withLimit: n must be non-negative finite');
687
+ }
688
+ this.cfg.limit = Math.floor(n);
689
+ return this;
690
+ }
691
+ withCustomFilterId(id) {
692
+ if (!id)
693
+ throw new Error('StandardPlacementBuilder.withCustomFilterId: id must be non-empty');
694
+ this.cfg.customFilterId = id;
695
+ return this;
696
+ }
697
+ build() {
698
+ // Defensive copy + freeze. The builder retains its own state so
699
+ // a chain like `b.build(); b.requireTag(...).build()` works.
700
+ return Object.freeze({
701
+ ...this.cfg,
702
+ requireTags: this.cfg.requireTags
703
+ ? [...this.cfg.requireTags]
704
+ : undefined,
705
+ forbidTags: this.cfg.forbidTags ? [...this.cfg.forbidTags] : undefined,
706
+ requireMetadata: this.cfg.requireMetadata
707
+ ? { ...this.cfg.requireMetadata }
708
+ : undefined,
709
+ predicate: this.cfg.predicate
710
+ ? {
711
+ nodes: [...this.cfg.predicate.nodes],
712
+ root_idx: this.cfg.predicate.root_idx,
713
+ }
714
+ : undefined,
715
+ });
716
+ }
717
+ }
718
+ exports.StandardPlacementBuilder = StandardPlacementBuilder;
719
+ function isPredicateWire(v) {
720
+ return (typeof v === 'object' &&
721
+ v !== null &&
722
+ Array.isArray(v.nodes) &&
723
+ typeof v.root_idx === 'number');
724
+ }
725
+ /** Convenience constructor for a {@link StandardPlacementBuilder}. */
726
+ function standardPlacement() {
727
+ return new StandardPlacementBuilder();
728
+ }
729
+ let placementFilterCounter = 0;
730
+ function placementFilterFromFn(fn, explicitId) {
731
+ const id = explicitId ?? `pf-${++placementFilterCounter}`;
732
+ return { id, fn };
733
+ }
734
+ function parseSemver(s) {
735
+ // Drop pre-release / build suffix.
736
+ const dash = s.indexOf('-');
737
+ const plus = s.indexOf('+');
738
+ let core;
739
+ if (dash >= 0 && plus >= 0) {
740
+ core = s.slice(0, Math.min(dash, plus));
741
+ }
742
+ else if (dash >= 0) {
743
+ core = s.slice(0, dash);
744
+ }
745
+ else if (plus >= 0) {
746
+ core = s.slice(0, plus);
747
+ }
748
+ else {
749
+ core = s;
750
+ }
751
+ const parts = core.split('.').map((p) => p.trim());
752
+ if (parts.length === 0 || parts.length > 3)
753
+ return undefined;
754
+ const major = Number.parseInt(parts[0], 10);
755
+ if (!Number.isFinite(major) || parts[0] === '' || /[^0-9]/.test(parts[0])) {
756
+ return undefined;
757
+ }
758
+ const parsePart = (s) => {
759
+ if (s === undefined)
760
+ return 0;
761
+ if (s === '' || /[^0-9]/.test(s))
762
+ return undefined;
763
+ const n = Number.parseInt(s, 10);
764
+ return Number.isFinite(n) ? n : undefined;
765
+ };
766
+ const minor = parsePart(parts[1]);
767
+ const patch = parsePart(parts[2]);
768
+ if (minor === undefined || patch === undefined)
769
+ return undefined;
770
+ return [major, minor, patch];
771
+ }
772
+ function semverCmp(a, b) {
773
+ if (a[0] !== b[0])
774
+ return a[0] - b[0];
775
+ if (a[1] !== b[1])
776
+ return a[1] - b[1];
777
+ return a[2] - b[2];
778
+ }
779
+ function semverCompatible(lhs, rhs) {
780
+ if (semverCmp(lhs, rhs) < 0)
781
+ return false;
782
+ if (rhs[0] === 0) {
783
+ if (rhs[1] === 0) {
784
+ // P2-F: 0.0.x — patch is the compatibility band; anything
785
+ // other than the exact tuple is a breaking change. Combined
786
+ // with the `lhs >= rhs` guard above this collapses to
787
+ // `lhs === rhs`.
788
+ return lhs[0] === rhs[0] && lhs[1] === rhs[1] && lhs[2] === rhs[2];
789
+ }
790
+ // Q1: 0.x.y — minor is the compatibility band, AND the major
791
+ // must also be 0. Pre-fix `rhs[1] === lhs[1]` alone admitted
792
+ // `lhs = 1.2.5` as compatible with `rhs = 0.2.3` (the
793
+ // `lhs >= rhs` guard passes since 1 > 0, then minors match).
794
+ // 1.x.y is NOT `^0.2.3`-compatible per Cargo: 0.x.y treats
795
+ // minor as the band IFF the band itself is 0.x.y.
796
+ return lhs[0] === 0 && rhs[1] === lhs[1];
797
+ }
798
+ return rhs[0] === lhs[0];
799
+ }
800
+ /**
801
+ * Find the value of an `AxisValue` tag in the wire-format tag list,
802
+ * if any. Mirrors the substrate's `match_axis_tag`
803
+ * (`predicate.rs:1749-1757`), which explicitly skips `AxisPresent`
804
+ * tags for value predicates: feeding `""` through `value_pred` would
805
+ * let an empty-string `Equals` / `StringPrefix` / `StringMatches`
806
+ * predicate spuriously match a presence-only tag. Use
807
+ * {@link axisTagPresent} for the `exists` predicate path.
808
+ *
809
+ * Returns the matched value string for AxisValue tags, or
810
+ * `undefined` if no AxisValue tag matches (including the case where
811
+ * only an AxisPresent tag for the same key exists).
812
+ */
813
+ function axisTagValue(tags, key) {
814
+ const prefix = `${key.axis}.${key.key}`;
815
+ for (const wire of tags) {
816
+ // Match `<axis>.<key>=<value>` or `<axis>.<key>:<value>`. Reject
817
+ // longer key-prefixes (`hardware.gpu` should NOT match
818
+ // `hardware.gpu.vendor=nvidia` — that's a different key) and
819
+ // skip the AxisPresent form (`<axis>.<key>` with no separator).
820
+ if (wire.length <= prefix.length)
821
+ continue;
822
+ if (!wire.startsWith(prefix))
823
+ continue;
824
+ const sep = wire.charAt(prefix.length);
825
+ if (sep === '=' || sep === ':') {
826
+ return wire.slice(prefix.length + 1);
827
+ }
828
+ }
829
+ return undefined;
830
+ }
831
+ /**
832
+ * Parse a string the way Rust's `f64::from_str` does: accepts
833
+ * decimal forms (`1`, `1.5`, `.5`, `1.`), scientific notation
834
+ * (`1e10`, `1.5e-3`), an optional leading `+` or `-`, and the
835
+ * special-case literals `inf` / `infinity` / `nan` (all
836
+ * case-insensitive). Rejects empty / whitespace-padded inputs
837
+ * (Rust's `f64::from_str` is whitespace-strict). Returns `undefined`
838
+ * when Rust would also reject — letting callers funnel the result
839
+ * through IEEE comparison so `Inf >= threshold` and
840
+ * `NaN >= threshold` agree across bindings (matches R1's reasoning
841
+ * for the Go path).
842
+ *
843
+ * N-2: prior shape applied a `/^-?\d+(\.\d+)?$/` regex pre-filter
844
+ * that rejected scientific notation, leading `+`, `inf`, and `NaN`
845
+ * — all of which Rust's `f64::from_str` accepts. A tag value
846
+ * `software.model.context_length=1e6` evaluated `>= 1000000` as
847
+ * `true` in Rust and `false` in TS pre-fix.
848
+ */
849
+ function parseRustF64(s) {
850
+ if (s.length === 0)
851
+ return undefined;
852
+ if (s !== s.trim())
853
+ return undefined;
854
+ const lower = s.toLowerCase();
855
+ const stripped = lower.startsWith('+') || lower.startsWith('-') ? lower.slice(1) : lower;
856
+ if (stripped === 'inf' || stripped === 'infinity') {
857
+ return lower.startsWith('-') ? -Infinity : Infinity;
858
+ }
859
+ if (stripped === 'nan')
860
+ return Number.NaN;
861
+ // `Number()` accepts the rest of f64::from_str's grammar (decimal,
862
+ // scientific, leading `+`/`-`, `.5`, `1.`). It also accepts `""`
863
+ // and whitespace-only as `0` — both already rejected above. Any
864
+ // other input that Rust's parse rejects falls through to NaN here
865
+ // (e.g. `"abc"`, `"1abc"`, `"0x1p3"`).
866
+ const n = Number(s);
867
+ return Number.isNaN(n) ? undefined : n;
868
+ }
869
+ /**
870
+ * Test whether the wire-format tag list carries any tag for `key` —
871
+ * either an `AxisValue` (`<axis>.<key>=<value>`) or an `AxisPresent`
872
+ * (`<axis>.<key>`). Mirrors the substrate's `Predicate::Exists` leaf,
873
+ * which routes through `evaluate_leaf`'s presence-aware path and
874
+ * accepts both forms.
875
+ */
876
+ function axisTagPresent(tags, key) {
877
+ const prefix = `${key.axis}.${key.key}`;
878
+ for (const wire of tags) {
879
+ if (wire === prefix)
880
+ return true;
881
+ if (wire.length <= prefix.length)
882
+ continue;
883
+ if (!wire.startsWith(prefix))
884
+ continue;
885
+ const sep = wire.charAt(prefix.length);
886
+ if (sep === '=' || sep === ':')
887
+ return true;
888
+ }
889
+ return false;
890
+ }
891
+ function evalLeaf(pred, tags, metadata) {
892
+ switch (pred.type) {
893
+ case 'exists': {
894
+ return axisTagPresent(tags, pred.key);
895
+ }
896
+ case 'equals': {
897
+ const v = axisTagValue(tags, pred.key);
898
+ return v !== undefined && v === pred.value;
899
+ }
900
+ case 'numericAtLeast': {
901
+ const v = axisTagValue(tags, pred.key);
902
+ if (v === undefined)
903
+ return false;
904
+ const n = parseRustF64(v);
905
+ return n !== undefined && n >= pred.threshold;
906
+ }
907
+ case 'numericAtMost': {
908
+ const v = axisTagValue(tags, pred.key);
909
+ if (v === undefined)
910
+ return false;
911
+ const n = parseRustF64(v);
912
+ return n !== undefined && n <= pred.threshold;
913
+ }
914
+ case 'numericInRange': {
915
+ const v = axisTagValue(tags, pred.key);
916
+ if (v === undefined)
917
+ return false;
918
+ const n = parseRustF64(v);
919
+ return n !== undefined && n >= pred.min && n <= pred.max;
920
+ }
921
+ case 'semverAtLeast': {
922
+ const rhs = parseSemver(pred.version);
923
+ if (rhs === undefined)
924
+ return false;
925
+ const v = axisTagValue(tags, pred.key);
926
+ if (v === undefined)
927
+ return false;
928
+ const lhs = parseSemver(v);
929
+ return lhs !== undefined && semverCmp(lhs, rhs) >= 0;
930
+ }
931
+ case 'semverAtMost': {
932
+ const rhs = parseSemver(pred.version);
933
+ if (rhs === undefined)
934
+ return false;
935
+ const v = axisTagValue(tags, pred.key);
936
+ if (v === undefined)
937
+ return false;
938
+ const lhs = parseSemver(v);
939
+ return lhs !== undefined && semverCmp(lhs, rhs) <= 0;
940
+ }
941
+ case 'semverCompatible': {
942
+ const rhs = parseSemver(pred.version);
943
+ if (rhs === undefined)
944
+ return false;
945
+ const v = axisTagValue(tags, pred.key);
946
+ if (v === undefined)
947
+ return false;
948
+ const lhs = parseSemver(v);
949
+ return lhs !== undefined && semverCompatible(lhs, rhs);
950
+ }
951
+ case 'stringPrefix': {
952
+ const v = axisTagValue(tags, pred.key);
953
+ return v !== undefined && v.startsWith(pred.prefix);
954
+ }
955
+ case 'stringMatches': {
956
+ const v = axisTagValue(tags, pred.key);
957
+ return v !== undefined && v.includes(pred.pattern);
958
+ }
959
+ case 'metadataExists': {
960
+ return Object.prototype.hasOwnProperty.call(metadata, pred.key);
961
+ }
962
+ case 'metadataEquals': {
963
+ return (Object.prototype.hasOwnProperty.call(metadata, pred.key) &&
964
+ metadata[pred.key] === pred.value);
965
+ }
966
+ case 'metadataMatches': {
967
+ // `hasOwnProperty` parity with `metadataExists` /
968
+ // `metadataEquals`. Direct `metadata[pred.key]` access reads
969
+ // through the prototype chain — a metadata object inheriting
970
+ // an `Object.prototype` member named the same as `pred.key`
971
+ // could spuriously match here while `metadataExists` reports
972
+ // `false`. The two predicates must stay in lockstep.
973
+ if (!Object.prototype.hasOwnProperty.call(metadata, pred.key))
974
+ return false;
975
+ const v = metadata[pred.key];
976
+ return v !== undefined && v.includes(pred.pattern);
977
+ }
978
+ case 'metadataNumericAtLeast': {
979
+ // Same `hasOwnProperty` parity reasoning as `metadataMatches`.
980
+ if (!Object.prototype.hasOwnProperty.call(metadata, pred.key))
981
+ return false;
982
+ const v = metadata[pred.key];
983
+ if (v === undefined)
984
+ return false;
985
+ const n = parseRustF64(v);
986
+ return n !== undefined && n >= pred.threshold;
987
+ }
988
+ case 'and':
989
+ case 'or':
990
+ case 'not':
991
+ throw new Error(`evalLeaf: composite predicate ${pred.type} routed through leaf evaluator (internal bug)`);
992
+ }
993
+ }
994
+ /**
995
+ * Evaluate a {@link Predicate} against a wire-format `(tags, metadata)`
996
+ * context. Mirrors the substrate's `Predicate::evaluate_unplanned`
997
+ * — children of `and` / `or` evaluate in declaration order with
998
+ * standard short-circuit semantics.
999
+ *
1000
+ * Pinned across bindings by `predicate_eval.json`. Use this for local
1001
+ * pre-filtering of result sets before sending an nRPC `where:`
1002
+ * predicate over the wire, or for client-side validation of a
1003
+ * predicate against a known capability set.
1004
+ */
1005
+ function evaluatePredicate(pred, tags, metadata) {
1006
+ switch (pred.type) {
1007
+ case 'and':
1008
+ return pred.children.every((c) => evaluatePredicate(c, tags, metadata));
1009
+ case 'or':
1010
+ return pred.children.some((c) => evaluatePredicate(c, tags, metadata));
1011
+ case 'not':
1012
+ return !evaluatePredicate(pred.child, tags, metadata);
1013
+ default:
1014
+ return evalLeaf(pred, tags, metadata);
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Static per-variant cost — matches the substrate's `static_cost`.
1019
+ * Lower = cheaper; planner sorts children ascending. Composites sum
1020
+ * their children's costs.
1021
+ */
1022
+ function predStaticCost(p) {
1023
+ switch (p.type) {
1024
+ case 'metadataExists':
1025
+ return 10;
1026
+ case 'metadataEquals':
1027
+ return 11;
1028
+ case 'exists':
1029
+ return 20;
1030
+ case 'equals':
1031
+ return 21;
1032
+ case 'metadataNumericAtLeast':
1033
+ return 25;
1034
+ case 'numericAtLeast':
1035
+ case 'numericAtMost':
1036
+ case 'numericInRange':
1037
+ return 30;
1038
+ case 'stringPrefix':
1039
+ return 40;
1040
+ case 'metadataMatches':
1041
+ return 45;
1042
+ case 'stringMatches':
1043
+ return 50;
1044
+ case 'semverAtLeast':
1045
+ case 'semverAtMost':
1046
+ case 'semverCompatible':
1047
+ return 60;
1048
+ case 'and':
1049
+ case 'or':
1050
+ return p.children.reduce((acc, c) => Math.min(acc + predStaticCost(c), 0xffffffff), 0);
1051
+ case 'not':
1052
+ return predStaticCost(p.child);
1053
+ }
1054
+ }
1055
+ function predDebugLabel(p) {
1056
+ // Rust's `{:?}` on a string adds quotes + escapes. We match that
1057
+ // for string-bearing leaves so labels round-trip with the
1058
+ // substrate's `debug_label`.
1059
+ const dbg = (s) => JSON.stringify(s);
1060
+ const tk = (k) => `${k.axis}.${k.key}`;
1061
+ switch (p.type) {
1062
+ case 'exists':
1063
+ return `Exists(${tk(p.key)})`;
1064
+ case 'equals':
1065
+ return `Equals(${tk(p.key)}=${p.value})`;
1066
+ case 'numericAtLeast':
1067
+ return `NumericAtLeast(${tk(p.key)} >= ${p.threshold})`;
1068
+ case 'numericAtMost':
1069
+ return `NumericAtMost(${tk(p.key)} <= ${p.threshold})`;
1070
+ case 'numericInRange':
1071
+ return `NumericInRange(${tk(p.key)} in [${p.min}, ${p.max}])`;
1072
+ case 'semverAtLeast':
1073
+ return `SemverAtLeast(${tk(p.key)} >= ${p.version})`;
1074
+ case 'semverAtMost':
1075
+ return `SemverAtMost(${tk(p.key)} <= ${p.version})`;
1076
+ case 'semverCompatible':
1077
+ return `SemverCompatible(${tk(p.key)} ~= ${p.version})`;
1078
+ case 'stringPrefix':
1079
+ return `StringPrefix(${tk(p.key)} starts with ${dbg(p.prefix)})`;
1080
+ case 'stringMatches':
1081
+ return `StringMatches(${tk(p.key)} contains ${dbg(p.pattern)})`;
1082
+ case 'metadataExists':
1083
+ return `MetadataExists(${p.key})`;
1084
+ case 'metadataEquals':
1085
+ return `MetadataEquals(${p.key}=${p.value})`;
1086
+ case 'metadataMatches':
1087
+ return `MetadataMatches(${p.key} contains ${dbg(p.pattern)})`;
1088
+ case 'metadataNumericAtLeast':
1089
+ return `MetadataNumericAtLeast(${p.key} >= ${p.threshold})`;
1090
+ case 'and':
1091
+ return `And(${p.children.length} clauses)`;
1092
+ case 'or':
1093
+ return `Or(${p.children.length} clauses)`;
1094
+ case 'not':
1095
+ return 'Not';
1096
+ }
1097
+ }
1098
+ /**
1099
+ * Stable sort by `static_cost` ascending. Mirrors Rust's
1100
+ * `sort_by_key` (stable). Children with equal cost preserve their
1101
+ * declaration order.
1102
+ */
1103
+ function planChildren(children) {
1104
+ const indexed = children.map((c, i) => ({
1105
+ child: c,
1106
+ cost: predStaticCost(c),
1107
+ i,
1108
+ }));
1109
+ indexed.sort((a, b) => a.cost - b.cost || a.i - b.i);
1110
+ return indexed.map((x) => x.child);
1111
+ }
1112
+ /**
1113
+ * Evaluate a predicate against `(tags, metadata)` and produce a
1114
+ * trace tree.
1115
+ *
1116
+ * Mirrors the substrate's `Predicate::evaluate_with_trace`:
1117
+ * - `And` / `Or` children evaluated in cost-ascending order.
1118
+ * - Short-circuited siblings DON'T appear in the trace — operators
1119
+ * see "the metadata clause failed; we never got to the GPU
1120
+ * check."
1121
+ * - `Not`'s child carries the pre-negation result; `Not`'s own node
1122
+ * carries the post-negation result.
1123
+ *
1124
+ * Pinned across bindings by `predicate_trace.json`. Useful for
1125
+ * client-side debugging of why a candidate did / didn't match
1126
+ * before hitting the wire.
1127
+ */
1128
+ function evaluatePredicateWithTrace(pred, tags, metadata) {
1129
+ const label = predDebugLabel(pred);
1130
+ if (pred.type === 'and') {
1131
+ const ordered = planChildren(pred.children);
1132
+ const traces = [];
1133
+ let result = true;
1134
+ for (const c of ordered) {
1135
+ const { result: r, trace } = evaluatePredicateWithTrace(c, tags, metadata);
1136
+ traces.push(trace);
1137
+ if (!r) {
1138
+ result = false;
1139
+ break;
1140
+ }
1141
+ }
1142
+ return { result, trace: { label, result, children: traces } };
1143
+ }
1144
+ if (pred.type === 'or') {
1145
+ const ordered = planChildren(pred.children);
1146
+ const traces = [];
1147
+ let result = false;
1148
+ for (const c of ordered) {
1149
+ const { result: r, trace } = evaluatePredicateWithTrace(c, tags, metadata);
1150
+ traces.push(trace);
1151
+ if (r) {
1152
+ result = true;
1153
+ break;
1154
+ }
1155
+ }
1156
+ return { result, trace: { label, result, children: traces } };
1157
+ }
1158
+ if (pred.type === 'not') {
1159
+ const { result: r, trace } = evaluatePredicateWithTrace(pred.child, tags, metadata);
1160
+ return {
1161
+ result: !r,
1162
+ trace: { label, result: !r, children: [trace] },
1163
+ };
1164
+ }
1165
+ const r = evalLeaf(pred, tags, metadata);
1166
+ return { result: r, trace: { label, result: r, children: [] } };
1167
+ }
1168
+ function accumulateTrace(trace, stats) {
1169
+ const entry = stats.get(trace.label) ?? {
1170
+ label: trace.label,
1171
+ evaluated: 0,
1172
+ matched: 0,
1173
+ };
1174
+ entry.evaluated += 1;
1175
+ if (trace.result)
1176
+ entry.matched += 1;
1177
+ stats.set(trace.label, entry);
1178
+ for (const child of trace.children) {
1179
+ accumulateTrace(child, stats);
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Run `pred` against each context in `contexts`, accumulating
1184
+ * per-clause hit / miss stats. Mirrors the substrate's
1185
+ * `PredicateDebugReport::from_evaluations`.
1186
+ *
1187
+ * The returned report's `clause_stats` is sorted by label
1188
+ * (BTreeMap semantics) so bindings produce byte-identical output
1189
+ * for the same input corpus.
1190
+ */
1191
+ function predicateDebugReport(pred, contexts) {
1192
+ const stats = new Map();
1193
+ let matched = 0;
1194
+ for (const ctx of contexts) {
1195
+ const { result, trace } = evaluatePredicateWithTrace(pred, ctx.tags, ctx.metadata);
1196
+ if (result)
1197
+ matched += 1;
1198
+ accumulateTrace(trace, stats);
1199
+ }
1200
+ const sortedLabels = Array.from(stats.keys()).sort();
1201
+ return {
1202
+ total_candidates: contexts.length,
1203
+ matched,
1204
+ clause_stats: sortedLabels.map((l) => stats.get(l)),
1205
+ };
1206
+ }
1207
+ /**
1208
+ * Redact metadata-clause values in a {@link PredicateDebugReport}.
1209
+ *
1210
+ * Walks the report's `clause_stats` and rewrites any label whose
1211
+ * metadata key is in the supplied `keys` list:
1212
+ *
1213
+ * - `MetadataEquals(<key>=<value>)` → `MetadataEquals(<key>=<redacted>)`
1214
+ * - `MetadataMatches(<key> contains "<pattern>")` → `MetadataMatches(<key> contains "<redacted>")`
1215
+ * - `MetadataNumericAtLeast(<key> >= <threshold>)` → `MetadataNumericAtLeast(<key> >= <redacted>)`
1216
+ * - `MetadataExists(<key>)` — unchanged (no value to redact)
1217
+ * - All non-metadata labels (Exists, Equals, Numeric*, Semver*,
1218
+ * String*, And, Or, Not on tags) unchanged.
1219
+ *
1220
+ * After rewriting, stats with the same redacted label are merged
1221
+ * (`evaluated` and `matched` summed). Output is sorted by label.
1222
+ *
1223
+ * Use this before persisting a debug report to disk or sharing with
1224
+ * a teammate when the predicate's authored metadata values are
1225
+ * sensitive (PII, secrets, internal classifications).
1226
+ *
1227
+ * Pinned across bindings by `predicate_debug_report_redacted.json`.
1228
+ */
1229
+ function redactMetadataKeys(report, keys) {
1230
+ const keySet = new Set(keys);
1231
+ const merged = new Map();
1232
+ for (const stat of report.clause_stats) {
1233
+ const newLabel = redactLabel(stat.label, keySet);
1234
+ const existing = merged.get(newLabel) ?? {
1235
+ label: newLabel,
1236
+ evaluated: 0,
1237
+ matched: 0,
1238
+ };
1239
+ existing.evaluated += stat.evaluated;
1240
+ existing.matched += stat.matched;
1241
+ merged.set(newLabel, existing);
1242
+ }
1243
+ const sortedLabels = Array.from(merged.keys()).sort();
1244
+ return {
1245
+ total_candidates: report.total_candidates,
1246
+ matched: report.matched,
1247
+ clause_stats: sortedLabels.map((l) => merged.get(l)),
1248
+ };
1249
+ }
1250
+ /**
1251
+ * Pre-compiled regexes for the three redactable metadata-clause
1252
+ * label shapes. The `^` / `$` anchors prevent accidental matches in
1253
+ * pathological clause values.
1254
+ */
1255
+ const META_EQUALS_RE = /^MetadataEquals\(([^=]+)=(.+)\)$/;
1256
+ const META_MATCHES_RE = /^MetadataMatches\((.+) contains "(.*)"\)$/;
1257
+ const META_NUMERIC_RE = /^MetadataNumericAtLeast\((.+) >= (.+)\)$/;
1258
+ function redactLabel(label, keys) {
1259
+ let m;
1260
+ if ((m = label.match(META_EQUALS_RE))) {
1261
+ if (keys.has(m[1]))
1262
+ return `MetadataEquals(${m[1]}=<redacted>)`;
1263
+ }
1264
+ else if ((m = label.match(META_MATCHES_RE))) {
1265
+ if (keys.has(m[1]))
1266
+ return `MetadataMatches(${m[1]} contains "<redacted>")`;
1267
+ }
1268
+ else if ((m = label.match(META_NUMERIC_RE))) {
1269
+ if (keys.has(m[1]))
1270
+ return `MetadataNumericAtLeast(${m[1]} >= <redacted>)`;
1271
+ }
1272
+ return label;
1273
+ }
1274
+ /**
1275
+ * Reconstruct a {@link PredicateDebugReport} from its wire JSON form
1276
+ * (the shape produced by JSON-stringifying the report). Validates
1277
+ * required fields; on malformed input throws a descriptive error.
1278
+ *
1279
+ * Symmetric inverse of `JSON.stringify(report)` — call
1280
+ * `predicateDebugReportFromWire(JSON.parse(text))` to round-trip a
1281
+ * report through disk.
1282
+ */
1283
+ function predicateDebugReportFromWire(wire) {
1284
+ if (typeof wire !== 'object' ||
1285
+ wire === null ||
1286
+ typeof wire.total_candidates !== 'number' ||
1287
+ typeof wire.matched !== 'number' ||
1288
+ !Array.isArray(wire.clause_stats)) {
1289
+ throw new Error('predicateDebugReportFromWire: expected { total_candidates: number, matched: number, clause_stats: array }');
1290
+ }
1291
+ const w = wire;
1292
+ for (const s of w.clause_stats) {
1293
+ if (typeof s.label !== 'string' ||
1294
+ typeof s.evaluated !== 'number' ||
1295
+ typeof s.matched !== 'number') {
1296
+ throw new Error(`predicateDebugReportFromWire: bad clause_stats entry ${JSON.stringify(s)}`);
1297
+ }
1298
+ }
1299
+ return {
1300
+ total_candidates: w.total_candidates,
1301
+ matched: w.matched,
1302
+ clause_stats: w.clause_stats.map((s) => ({
1303
+ label: s.label,
1304
+ evaluated: s.evaluated,
1305
+ matched: s.matched,
1306
+ })),
1307
+ };
1308
+ }
1309
+ /** Render a one-line-per-clause text summary suitable for CLI output. */
1310
+ function renderDebugReport(report) {
1311
+ const pct = (num, denom) => denom === 0 ? '0.0%' : `${((100 * num) / denom).toFixed(1)}%`;
1312
+ const lines = [];
1313
+ lines.push('Predicate evaluation report');
1314
+ lines.push('─────────────────────────────────────────');
1315
+ lines.push(`Total candidates: ${report.total_candidates}`);
1316
+ lines.push(`Matched: ${report.matched} (${pct(report.matched, report.total_candidates)})`);
1317
+ lines.push('');
1318
+ lines.push('Per-clause stats (alphabetical):');
1319
+ for (const s of report.clause_stats) {
1320
+ lines.push(` ${s.label.padEnd(60)} evaluated ${String(s.evaluated).padStart(5)}, ` +
1321
+ `matched ${String(s.matched).padStart(5)} (${pct(s.matched, s.evaluated)})`);
1322
+ }
1323
+ return lines.join('\n') + '\n';
1324
+ }