@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.
- package/README.md +1684 -0
- package/dist/_internal.d.ts +25 -0
- package/dist/_internal.js +60 -0
- package/dist/capabilities.d.ts +271 -0
- package/dist/capabilities.js +186 -0
- package/dist/capability-enhancements.d.ts +574 -0
- package/dist/capability-enhancements.js +1324 -0
- package/dist/capability-schema.d.ts +112 -0
- package/dist/capability-schema.js +317 -0
- package/dist/channel.d.ts +56 -0
- package/dist/channel.js +95 -0
- package/dist/compute.d.ts +546 -0
- package/dist/compute.js +741 -0
- package/dist/cortex.d.ts +236 -0
- package/dist/cortex.js +584 -0
- package/dist/deck.d.ts +342 -0
- package/dist/deck.js +717 -0
- package/dist/groups.d.ts +208 -0
- package/dist/groups.js +431 -0
- package/dist/identity.d.ts +149 -0
- package/dist/identity.js +264 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +147 -0
- package/dist/mesh.d.ts +369 -0
- package/dist/mesh.js +433 -0
- package/dist/meshdb.d.ts +87 -0
- package/dist/meshdb.js +111 -0
- package/dist/meshos.d.ts +277 -0
- package/dist/meshos.js +359 -0
- package/dist/node.d.ts +120 -0
- package/dist/node.js +246 -0
- package/dist/redis-dedup.d.ts +48 -0
- package/dist/redis-dedup.js +52 -0
- package/dist/stream.d.ts +47 -0
- package/dist/stream.js +118 -0
- package/dist/subnets.d.ts +75 -0
- package/dist/subnets.js +54 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +5 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|