@konneal/engine 0.2.144 → 0.2.147

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.
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-Y5T6V5CB.js";
3
+ } from "./chunk-EJVRNPDB.js";
4
4
  import "./chunk-ECOYO7EX.js";
5
- import "./chunk-363Q6FOL.js";
5
+ import "./chunk-VYEMFSU5.js";
6
6
  import "./chunk-XH5QGX2O.js";
7
7
  import "./chunk-NI4CIK6W.js";
8
8
  import "./chunk-W7J5SFXJ.js";
9
- import "./chunk-XBZIJMS7.js";
9
+ import "./chunk-ETLSWSZ7.js";
10
10
  import "./chunk-4NXH5ZI6.js";
11
11
  import "./chunk-VT7DR6NQ.js";
12
12
  import "./chunk-3FYJM7LH.js";
@@ -32,7 +32,7 @@ import {
32
32
  tableRetyped,
33
33
  toolNote,
34
34
  verdictNote
35
- } from "./chunk-363Q6FOL.js";
35
+ } from "./chunk-VYEMFSU5.js";
36
36
  import {
37
37
  NO_CONTEXT,
38
38
  STAGE_NAMES,
@@ -91,8 +91,9 @@ import {
91
91
  cacheableAnswer,
92
92
  canonicalRefusal,
93
93
  exciseMisplacedPin,
94
- refusalAnswer
95
- } from "./chunk-XBZIJMS7.js";
94
+ refusalAnswer,
95
+ refusalShaped
96
+ } from "./chunk-ETLSWSZ7.js";
96
97
  import {
97
98
  entitlementScope,
98
99
  requestSalt,
@@ -2640,7 +2641,7 @@ Answer account questions from these records ONLY: name the record when you use i
2640
2641
  type: "done",
2641
2642
  model,
2642
2643
  query_hash: queryHash,
2643
- follow_ups: understanding?.follow_ups ?? [],
2644
+ follow_ups: refusalShaped(canonical0, refusalScope(answerCites(full))) ? [] : understanding?.follow_ups ?? [],
2644
2645
  blocks: [...c2.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : []],
2645
2646
  context_applied: ctxApplied,
2646
2647
  read: readAs(),
@@ -2723,6 +2724,7 @@ Answer account questions from these records ONLY: name the record when you use i
2723
2724
  if (answer) answer = canonicalRefusal(answer, refusalScope(answerCites(answer)));
2724
2725
  if (answer) answer = scrubToolLines(answer);
2725
2726
  if (answer) answer = exciseMisplacedPin(answer, answerCites(answer));
2727
+ const toolNarration = (t) => /^(?:the\s+)?[a-z][a-z_.]*\.[a-z_]+ tool returned\b/i.test(t.slice(0, 200)) || /tool returned,? for the query\b/i.test(t.slice(0, 300));
2726
2728
  stageTiming.generate = Date.now() - tGen;
2727
2729
  let used = usedHits;
2728
2730
  if (answer && !answer.includes(refusalAnswer())) {
@@ -2739,17 +2741,17 @@ Answer account questions from these records ONLY: name the record when you use i
2739
2741
  );
2740
2742
  return nums.filter((n) => tableNums.has(n)).length >= 2;
2741
2743
  })();
2742
- if (anchors.violations.length > 0 || retyped || unreferenced) {
2743
- console.log("contract check:", anchors.violations.length, "anchor violations; tableRetyped:", retyped, "; tableDataUnreferenced:", unreferenced, "\u2014 regenerating");
2744
+ if (anchors.violations.length > 0 || retyped || unreferenced || toolNarration(answer)) {
2745
+ console.log("contract check:", anchors.violations.length, "anchor violations; tableRetyped:", retyped, "; tableDataUnreferenced:", unreferenced, "; toolNarration:", toolNarration(answer), "\u2014 regenerating");
2744
2746
  const tableUnitId = unreferenced ? used.find((h) => h.metadata.unit_id && h.metadata.block === "table")?.metadata.unit_id : void 0;
2745
- const note = retyped || unreferenced ? `Correction notice: your draft reproduced a table as markdown or presented a served table's data without its reference. Rewrite the answer: describe the table in prose, cite the clause, and write the reference token [[u:${tableUnitId ?? "<unit id>"}]] exactly where the table belongs. Do not render any table as markdown.` : ANCHOR_CORRECTION_NOTE;
2747
+ const note = toolNarration(answer) ? "Correction notice: your draft narrated an internal tool call ('the \u2026 tool returned \u2026') instead of answering. Rewrite: answer the question directly from the retrieved passages, cite the clauses, and never mention tools, queries, wire formats, or JSON." : retyped || unreferenced ? `Correction notice: your draft reproduced a table as markdown or presented a served table's data without its reference. Rewrite the answer: describe the table in prose, cite the clause, and write the reference token [[u:${tableUnitId ?? "<unit id>"}]] exactly where the table belongs. Do not render any table as markdown.` : ANCHOR_CORRECTION_NOTE;
2746
2748
  generateRetries += 1;
2747
2749
  const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], routeEffort(routed.route));
2748
2750
  if (corrected) {
2749
2751
  const correctedAnswer = canonicalRefusal(corrected, refusalScope(answerCites(corrected)));
2750
2752
  const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h) => h.text));
2751
2753
  const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
2752
- if (retryAnchors.violations.length < anchors.violations.length || !retryRetyped && retyped || unreferenced && correctedAnswer.includes("[[u:")) {
2754
+ if (retryAnchors.violations.length < anchors.violations.length || !retryRetyped && retyped || unreferenced && correctedAnswer.includes("[[u:") || !toolNarration(correctedAnswer)) {
2753
2755
  answer = correctedAnswer;
2754
2756
  }
2755
2757
  }
@@ -2822,7 +2824,7 @@ Answer account questions from these records ONLY: name the record when you use i
2822
2824
  if (v.support !== "supported") console.log("entailment:", v.support, raced.score, raced.ungrounded_claims?.slice(0, 2));
2823
2825
  }
2824
2826
  }
2825
- const out = { answer, citations: finalCites, ...jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...entailment ? { entailment } : {}, ...jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {} } : {}, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : [], ...registerBlock ? [registerBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {}, ...ablate ? { ablate } : {}, route: { route: routed.route, features: routed.features } };
2827
+ const out = { answer, citations: finalCites, ...jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...entailment ? { entailment } : {}, ...jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {} } : {}, model: MODELS.member, query_hash: queryHash, follow_ups: refusalShaped(answer, refusalScope(answerCites(answer))) ? [] : understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : [], ...registerBlock ? [registerBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {}, ...ablate ? { ablate } : {}, route: { route: routed.route, features: routed.features } };
2826
2828
  const cacheable = !contextual && !declaredCtx && !ablate && cacheableAnswer(answer, refusalScope(answerCites(answer))) && finalAnchors.violations.length === 0;
2827
2829
  if (cacheable) {
2828
2830
  const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
@@ -101,10 +101,14 @@ function cacheableAnswer(answer, scope) {
101
101
  if (answer.includes(refusalAnswer())) return false;
102
102
  return canonicalRefusal(answer, scope) === answer;
103
103
  }
104
+ function refusalShaped(answer, scope) {
105
+ return !cacheableAnswer(answer, scope);
106
+ }
104
107
 
105
108
  export {
106
109
  refusalAnswer,
107
110
  canonicalRefusal,
108
111
  exciseMisplacedPin,
109
- cacheableAnswer
112
+ cacheableAnswer,
113
+ refusalShaped
110
114
  };
@@ -270,14 +270,60 @@ function parseNumber(raw) {
270
270
  s = s.replace(/\.(\d{3})$/, "$1");
271
271
  return Number(s.replace(/,(?=\d{3}\b)/g, ""));
272
272
  }
273
+ var TOL_SEP_SRC = "\\u00b1|\\bpm\\b|\\+/-";
274
+ var NUM_SRC = "[0-9][0-9 ,.]*[0-9]";
275
+ var UNIT_SRC = "%|\\u00b0?[A-Za-z\\u00b5\\u03a9]{0,6}";
276
+ function statedUncertainty(value, tolRaw, tolUnitRaw, valueUnit) {
277
+ const tolNum = parseNumber(tolRaw);
278
+ const tolUnit = (tolUnitRaw ?? "").trim();
279
+ const relative = tolUnit === "%";
280
+ return {
281
+ tol: relative ? Math.abs(value) * tolNum / 100 : tolNum,
282
+ stated: relative ? `\xB1 ${tolRaw} %` : `\xB1 ${tolRaw}${tolUnit ? ` ${tolUnit}` : ""}`,
283
+ relative,
284
+ unit: relative ? valueUnit : tolUnit || valueUnit
285
+ };
286
+ }
273
287
  function extractParamsWithUnits(query, symbols) {
274
288
  const values = {};
275
289
  const units = {};
290
+ const uncertainties = {};
276
291
  for (const sym of symbols) {
277
292
  const leaf = sym.split(".").pop() ?? sym;
278
293
  const esc = leaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
279
- const re = new RegExp(`\\b${esc}\\b\\D{0,14}?([0-9][0-9 ,.]*[0-9])\\s*(%|\\u00b0?[A-Za-z\\u00b5\\u03a9]{0,6})?`, "iu");
280
- const m = query.match(re);
294
+ const reTolFirst = new RegExp(
295
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(?:${TOL_SEP_SRC})\\s*(${NUM_SRC})\\s*(${UNIT_SRC})?`,
296
+ "iu"
297
+ );
298
+ const reTolAfter = new RegExp(
299
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(${UNIT_SRC})\\s*(?:${TOL_SEP_SRC})\\s*(${NUM_SRC})\\s*(${UNIT_SRC})?`,
300
+ "iu"
301
+ );
302
+ const rePlain = new RegExp(
303
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(${UNIT_SRC})?`,
304
+ "iu"
305
+ );
306
+ const mFirst = query.match(reTolFirst);
307
+ const mAfter = mFirst ? null : query.match(reTolAfter);
308
+ const mTol = mFirst ?? mAfter;
309
+ if (mTol) {
310
+ const value = parseNumber(mTol[1]);
311
+ if (!Number.isFinite(value)) continue;
312
+ values[sym] = value;
313
+ let unit;
314
+ let u;
315
+ if (mFirst) {
316
+ unit = (mFirst[3] ?? "").trim() || void 0;
317
+ u = statedUncertainty(value, mFirst[2], mFirst[3], unit);
318
+ } else {
319
+ unit = (mAfter[2] ?? "").trim() || void 0;
320
+ u = statedUncertainty(value, mAfter[3], mAfter[4], unit || void 0);
321
+ }
322
+ if (unit) units[sym] = unit;
323
+ uncertainties[sym] = u;
324
+ continue;
325
+ }
326
+ const m = query.match(rePlain);
281
327
  if (m) {
282
328
  const v = parseNumber(m[1]);
283
329
  if (Number.isFinite(v)) {
@@ -287,7 +333,7 @@ function extractParamsWithUnits(query, symbols) {
287
333
  }
288
334
  }
289
335
  }
290
- return { values, units };
336
+ return { values, units, uncertainties };
291
337
  }
292
338
  function dimensionMismatch(check, units, dimensions) {
293
339
  const dims = /* @__PURE__ */ new Map();
@@ -313,11 +359,14 @@ function evaluate(content, query, opts) {
313
359
  let voidReason = null;
314
360
  const machine = checks.map((expression) => {
315
361
  const values = {};
362
+ const ids = [];
316
363
  try {
317
- for (const t of tokenize(expression)) if (t.t === "id" && params[t.v] !== void 0) values[t.v] = params[t.v];
364
+ for (const t of tokenize(expression)) if (t.t === "id") ids.push(t.v);
318
365
  } catch {
319
366
  }
367
+ for (const t of new Set(ids)) if (params[t] !== void 0) values[t] = params[t];
320
368
  let result = null;
369
+ let uncertainty;
321
370
  if (missing.length === 0) {
322
371
  const mismatch = opts?.dimensions ? dimensionMismatch(expression, bound.units, opts.dimensions) : null;
323
372
  if (mismatch) {
@@ -328,9 +377,38 @@ function evaluate(content, query, opts) {
328
377
  } catch {
329
378
  result = null;
330
379
  }
380
+ const bandSyms = [...new Set(ids)].filter((s) => bound.uncertainties[s] !== void 0).slice(0, 3);
381
+ if (bandSyms.length && result !== null) {
382
+ const corners = [];
383
+ for (let mask = 0; mask < 1 << bandSyms.length; mask++) {
384
+ const corner = { ...params };
385
+ bandSyms.forEach((s, i) => {
386
+ corner[s] = corner[s] + (mask >> i & 1 ? 1 : -1) * bound.uncertainties[s].tol;
387
+ });
388
+ try {
389
+ corners.push(!!parseAndEval(expression, corner));
390
+ } catch {
391
+ corners.push(null);
392
+ }
393
+ }
394
+ const first = bandSyms[0];
395
+ const un = bound.uncertainties[first];
396
+ uncertainty = {
397
+ symbol: first,
398
+ stated: un.stated,
399
+ tol: un.tol,
400
+ unit: un.unit,
401
+ nominal: result,
402
+ corners
403
+ };
404
+ const decided = corners.filter((c2) => c2 !== null);
405
+ if (decided.length && decided.some((c2) => c2 !== result)) {
406
+ voidReason = voidReason ?? `the stated uncertainty (${un.stated}) on ${first.split(".").pop()} crosses the decision boundary \u2014 the check evaluates ${result ? "true" : "false"} at the stated value but ${decided.find((c2) => c2 !== result) ? "true" : "false"} at the band edge; the question cannot be decided as stated`;
407
+ }
408
+ }
331
409
  }
332
410
  }
333
- return { expression, symbolic: expression, values, result };
411
+ return { expression, symbolic: expression, values, result, uncertainty };
334
412
  });
335
413
  if (missing.length) {
336
414
  return { verdict: "void", missing, checks: machine };
@@ -341,6 +419,7 @@ function evaluate(content, query, opts) {
341
419
  if (voidReason) return { verdict: "void", missing: [], void_reason: voidReason, checks: machine };
342
420
  return null;
343
421
  }
422
+ if (voidReason) return { verdict: "void", missing: [], void_reason: voidReason, checks: machine };
344
423
  return {
345
424
  verdict: failed ? "fail" : "pass",
346
425
  on_violation: failed ? String(c.on_violation ?? "invalid") : void 0,
@@ -356,6 +435,11 @@ function verdictNote(v, node) {
356
435
  for (const c of v.checks) {
357
436
  const vals = Object.entries(c.values).map(([k, n]) => `${k}=${n}`).join(", ");
358
437
  lines.push(`- ${c.expression}${vals ? ` [${vals}]` : ""} \u2192 ${c.result === null ? "not evaluated" : c.result ? "holds" : "VIOLATED"}`);
438
+ if (c.uncertainty) {
439
+ const u = c.uncertainty;
440
+ const edges = u.corners.map((r) => r === null ? "not evaluated" : r ? "holds" : "VIOLATED").join("; ");
441
+ lines.push(` band ${u.stated}${u.unit && !u.stated.includes(u.unit) ? ` ${u.unit}` : ""} on ${u.symbol}: ${edges} across the band`);
442
+ }
359
443
  }
360
444
  if (v.verdict === "void") {
361
445
  lines.push(v.void_reason ? `VERDICT: VOID \u2014 ${v.void_reason}. State the mismatch plainly; the question cannot be decided as stated.` : `VERDICT: VOID \u2014 the question does not state: ${v.missing.join(", ")}. Say exactly what is missing; never assume values.`);
package/dist/refusal.d.ts CHANGED
@@ -40,3 +40,9 @@ export declare function exciseMisplacedPin(answer: string, cites: boolean): stri
40
40
  * every later ask). An answer is cacheable only when it carries no
41
41
  * pin AND the wall would not rewrite it. */
42
42
  export declare function cacheableAnswer(answer: string, scope?: RefusalScope): boolean;
43
+ /** The follow-up-chips law (2026-10-13): the understanding stage's
44
+ * follow_ups are generated BEFORE retrieval and answer — after a
45
+ * refusal they advertise the very topic the corpus does not carry.
46
+ * Chips serve only when the answer is not refusal-shaped (carries no
47
+ * pin and the wall would not rewrite it). */
48
+ export declare function refusalShaped(answer: string, scope?: RefusalScope): boolean;
@@ -4,7 +4,7 @@ import {
4
4
  import "./chunk-XH5QGX2O.js";
5
5
  import "./chunk-NI4CIK6W.js";
6
6
  import "./chunk-W7J5SFXJ.js";
7
- import "./chunk-XBZIJMS7.js";
7
+ import "./chunk-ETLSWSZ7.js";
8
8
  import "./chunk-4NXH5ZI6.js";
9
9
  import "./chunk-VT7DR6NQ.js";
10
10
  import "./chunk-3FYJM7LH.js";
@@ -4,7 +4,7 @@ import {
4
4
  parseToolCall,
5
5
  runTool,
6
6
  toolNote
7
- } from "./chunk-363Q6FOL.js";
7
+ } from "./chunk-VYEMFSU5.js";
8
8
  import "./chunk-NI4CIK6W.js";
9
9
  import "./chunk-4NXH5ZI6.js";
10
10
  import "./chunk-VT7DR6NQ.js";
package/dist/verdict.d.ts CHANGED
@@ -3,6 +3,16 @@ export interface MachineCheck {
3
3
  symbolic: string;
4
4
  values: Record<string, number>;
5
5
  result: boolean | null;
6
+ /** present when the question states a tolerance on a symbol this
7
+ * check reads: the band's corner evaluations beside the nominal */
8
+ uncertainty?: {
9
+ symbol: string;
10
+ stated: string;
11
+ tol: number;
12
+ unit?: string;
13
+ nominal: boolean;
14
+ corners: (boolean | null)[];
15
+ };
6
16
  }
7
17
  export interface Verdict {
8
18
  verdict: "pass" | "fail" | "void";
@@ -17,12 +27,24 @@ export interface Verdict {
17
27
  export declare function extractChecks(content: unknown): string[];
18
28
  export declare function symbolsIn(checks: string[]): string[];
19
29
  export declare function extractParams(query: string, symbols: string[]): Record<string, number>;
30
+ /** A stated uncertainty: "± 60 v" / "pm 60 v" / "± 0.2 %". `tol` is
31
+ * absolute (a relative statement resolves against the stated value);
32
+ * `stated` quotes the source form for narration. */
33
+ export interface StatedUncertainty {
34
+ tol: number;
35
+ stated: string;
36
+ relative: boolean;
37
+ unit?: string;
38
+ }
20
39
  /** The stated values WITH their unit tokens (TODO.sota/04's unit
21
40
  * semantics): "E_max 30000 v" binds e_max=30000 and the unit "v" —
22
- * the dimension check needs both. */
41
+ * the dimension check needs both. A tolerance after the value —
42
+ * "30000 ± 60 v", "30000 v ± 60", "± 0.2 %", or the AsciiMath
43
+ * spelling "30000 pm 60 v" — binds a first-class uncertainty. */
23
44
  export declare function extractParamsWithUnits(query: string, symbols: string[]): {
24
45
  values: Record<string, number>;
25
46
  units: Record<string, string>;
47
+ uncertainties: Record<string, StatedUncertainty>;
26
48
  };
27
49
  /** Dimension coherence (TODO.sota/04 item 2): the units plane gives
28
50
  * each unit token a dimension; a parameter set whose bound symbols
@@ -4,13 +4,13 @@ import {
4
4
  handleMemories,
5
5
  scoreFaithfulness,
6
6
  scoreJudge
7
- } from "../../chunk-Y5T6V5CB.js";
7
+ } from "../../chunk-EJVRNPDB.js";
8
8
  import "../../chunk-ECOYO7EX.js";
9
9
  import {
10
10
  TOOLS_REGISTRY,
11
11
  bindModelNode,
12
12
  modelGroundingBlock
13
- } from "../../chunk-363Q6FOL.js";
13
+ } from "../../chunk-VYEMFSU5.js";
14
14
  import {
15
15
  handleSearch
16
16
  } from "../../chunk-5WITTJQD.js";
@@ -53,7 +53,7 @@ import {
53
53
  } from "../../chunk-W7J5SFXJ.js";
54
54
  import {
55
55
  canonicalRefusal
56
- } from "../../chunk-XBZIJMS7.js";
56
+ } from "../../chunk-ETLSWSZ7.js";
57
57
  import {
58
58
  entitlementScope,
59
59
  standardKeysFrom
@@ -1073,13 +1073,13 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
1073
1073
  // stream:false forces the JSON lane (anon defaults to SSE)
1074
1074
  body: JSON.stringify({ ...args, stream: false })
1075
1075
  });
1076
- const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-5Y763UWT.js");
1076
+ const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-CWPQQTBQ.js");
1077
1077
  let payload;
1078
1078
  if (TOOLS_REGISTRY2.some((t) => t.name === name && t.audiences.includes("mcp"))) {
1079
1079
  const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
1080
1080
  payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
1081
1081
  } else {
1082
- const res = name === "ask" ? await (await import("../../ask-A54GAMZR.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-VEGQBEFC.js")).handleSearch(env, ctx, inner, tier, key);
1082
+ const res = name === "ask" ? await (await import("../../ask-ILW2EV7X.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-2PKZJMUV.js")).handleSearch(env, ctx, inner, tier, key);
1083
1083
  payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1084
1084
  }
1085
1085
  telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
@@ -2,12 +2,14 @@ import {
2
2
  cacheableAnswer,
3
3
  canonicalRefusal,
4
4
  exciseMisplacedPin,
5
- refusalAnswer
6
- } from "../../chunk-XBZIJMS7.js";
5
+ refusalAnswer,
6
+ refusalShaped
7
+ } from "../../chunk-ETLSWSZ7.js";
7
8
  import "../../chunk-3FYJM7LH.js";
8
9
  export {
9
10
  cacheableAnswer,
10
11
  canonicalRefusal,
11
12
  exciseMisplacedPin,
12
- refusalAnswer
13
+ refusalAnswer,
14
+ refusalShaped
13
15
  };
package/docs/API.md CHANGED
@@ -410,7 +410,11 @@ never an invented grounding).
410
410
 
411
411
  `similar: true` means the answer came from the semantic cache (a
412
412
  near-identical recent question). `follow_ups` are suggested next
413
- questions (present when understanding produced them).
413
+ questions (present when understanding produced them) — and are
414
+ withheld entirely on refusal-shaped answers: the chips are generated
415
+ before retrieval, so after an out-of-corpus refusal they would
416
+ advertise the very topic the corpus does not carry. Honest absence
417
+ extends to the conversation's next step.
414
418
 
415
419
  **Citation object** (also the shape in the `done`/JSON responses):
416
420
  ```jsonc
@@ -80,9 +80,9 @@ hand.
80
80
  |---|---|
81
81
  | OII | de-facto: parse.py (HTML-first) + graph.py (relaton/glossarist). Target: consume the four native object serializations; the AI-serialization proposal (metanorma#592) is the upstream contract |
82
82
  | Identity SSOT | ✅ documents registry in D1 (derived status, active flags, supersession chains) |
83
- | Registry serving | ✅ edition notes injected into doc-scoped asks; `GET /api/documents?family=` public API |
83
+ | Registry serving | ✅ edition notes injected into doc-scoped asks; the registry serves through the MCP `oiml_documents` tool and the `/docs/*` renderings |
84
84
  | Graph projection | ✅ 7,128 nodes / 6,486 edges in D1; query lane live (defined_terms → defines → candidates) |
85
- | Chunking | prose-only today; ChunkRecordV2 typed blocks = next build (tables/equations/requirements) |
85
+ | Chunking | typed units live on the MKO plane (tables/formulas/terms/requirements as typed payloads, clause-anchored citations); the parse plane stays clause-prose |
86
86
  | Enrichment | ✅ 98.7% corpus (the residual ~400 are persistent empty enrichments), KV-cached, replayed from the durable record after full restores |
87
87
  | Verification | unit suites + contract tests in CI (`npm run test:units`, pydantic↔TS wire contract, schema-union drift); the data-quality report loop = next build |
88
88
  | Orchestration | manual commands today; one-command pipeline (`ingest run --doc X`) when the above land |
@@ -262,10 +262,14 @@ Beyond the ladder, the machine objects are EXECUTED, not retrieved:
262
262
  machine limit gets the check evaluated against its stated values —
263
263
  pass / the standard's own violation word / void naming what is
264
264
  missing — attached as data the answer must present faithfully.
265
- Counterfactuals are free (values are values).
266
- - **Provable absence** (`/v1/absence`): an enumeration certificate
267
- over the standard's model plane — absent means "N nodes enumerated,
268
- 0 matches", never a bare refusal.
265
+ Stated tolerances bind beside their values and the check re-runs at
266
+ every band corner; a band spanning both outcomes voids with its
267
+ reason (guarded acceptance). Counterfactuals are free (values are
268
+ values).
269
+ - **Honest absence**: the question names a standard, the model plane
270
+ is enumerated, and a fruitless search answers as a plain sentence
271
+ with the scope named — never a bare refusal, never a certificate
272
+ document (the `/v1/absence` endpoint retired at engine 0.2.54).
269
273
  - **Answer verification** (`/v1/verify`): the deterministic contract
270
274
  battery plus a judged faithfulness score, exposed for any answer.
271
275
 
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.144",
30
+ "version": "0.2.147",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -17,7 +17,7 @@ import { reflect } from "./reflect";
17
17
  import { scoreFaithfulness } from "./faithfulness";
18
18
  import { entailmentVerdict } from "./entailment";
19
19
  import { checkQuoteAnchors, ANCHOR_CORRECTION_NOTE } from "./anchors";
20
- import { cacheableAnswer, canonicalRefusal, exciseMisplacedPin } from "./refusal";
20
+ import { cacheableAnswer, canonicalRefusal, exciseMisplacedPin, refusalShaped } from "./refusal";
21
21
  import { contractV2, tableRetyped } from "./refs";
22
22
  import { completeTables, completeFigures } from "./completion";
23
23
  import { NO_CONTEXT, appliedContext, contextNote, namedDocumentIn, parseContext, resolveDocScope, syntheticUnderstanding } from "./context";
@@ -1578,7 +1578,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1578
1578
  const c2 = canonical0.includes(refusalAnswer())
1579
1579
  ? { text: canonical0, blocks: [], dropped: [] as string[] }
1580
1580
  : await contractV2(env.DB, canonical0, usedHits);
1581
- send({ type: "done", model, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : [])], context_applied: ctxApplied, read: readAs(),
1581
+ send({ type: "done", model, query_hash: queryHash, follow_ups: refusalShaped(canonical0, refusalScope(answerCites(full))) ? [] : (understanding?.follow_ups ?? []), blocks: [...c2.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : [])], context_applied: ctxApplied, read: readAs(),
1582
1582
  // the evidence view's ground truth: the exact passages this
1583
1583
  // answer was built from, compact — cache hits carry none,
1584
1584
  // because the cache stores the answer and never the passages
@@ -1690,6 +1690,15 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1690
1690
  // (measured on chip-scoped asks: correct cited answers carrying the
1691
1691
  // pin mid-text, read by users and graders as total refusals)
1692
1692
  if (answer) answer = exciseMisplacedPin(answer, answerCites(answer));
1693
+ // the tool-narration leak (2026-10-14, the R 134-1 probe): the answer
1694
+ // model sometimes NARRATES the agent loop's call in prose — 'The
1695
+ // db.find tool returned, for the query "…": {…json…}' — instead of
1696
+ // answering. The line scrub cannot catch prose; the contract retry
1697
+ // below regenerates with an explicit notice, and the detector also
1698
+ // gates the retry's acceptance so a narrating retry cannot ship.
1699
+ const toolNarration = (t: string) =>
1700
+ /^(?:the\s+)?[a-z][a-z_.]*\.[a-z_]+ tool returned\b/i.test(t.slice(0, 200)) ||
1701
+ /tool returned,? for the query\b/i.test(t.slice(0, 300));
1693
1702
  stageTiming.generate = Date.now() - tGen;
1694
1703
 
1695
1704
  // ── Deterministic quote-anchor + table-retyping check ──
@@ -1714,14 +1723,16 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1714
1723
  );
1715
1724
  return nums.filter((n) => tableNums.has(n)).length >= 2;
1716
1725
  })();
1717
- if (anchors.violations.length > 0 || retyped || unreferenced) {
1718
- console.log("contract check:", anchors.violations.length, "anchor violations; tableRetyped:", retyped, "; tableDataUnreferenced:", unreferenced, "— regenerating");
1726
+ if (anchors.violations.length > 0 || retyped || unreferenced || toolNarration(answer)) {
1727
+ console.log("contract check:", anchors.violations.length, "anchor violations; tableRetyped:", retyped, "; tableDataUnreferenced:", unreferenced, "; toolNarration:", toolNarration(answer), "— regenerating");
1719
1728
  // name the EXACT unit the token must reference — a generic note
1720
1729
  // leaves the model guessing which id to write
1721
1730
  const tableUnitId = unreferenced
1722
1731
  ? used.find((h: Hit) => h.metadata.unit_id && h.metadata.block === "table")?.metadata.unit_id
1723
1732
  : undefined;
1724
- const note = retyped || unreferenced
1733
+ const note = toolNarration(answer)
1734
+ ? "Correction notice: your draft narrated an internal tool call ('the … tool returned …') instead of answering. Rewrite: answer the question directly from the retrieved passages, cite the clauses, and never mention tools, queries, wire formats, or JSON."
1735
+ : retyped || unreferenced
1725
1736
  ? `Correction notice: your draft reproduced a table as markdown or presented a served table's data without its reference. Rewrite the answer: describe the table in prose, cite the clause, and write the reference token [[u:${tableUnitId ?? "<unit id>"}]] exactly where the table belongs. Do not render any table as markdown.`
1726
1737
  : ANCHOR_CORRECTION_NOTE;
1727
1738
  generateRetries += 1;
@@ -1730,7 +1741,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1730
1741
  const correctedAnswer = canonicalRefusal(corrected, refusalScope(answerCites(corrected)));
1731
1742
  const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h: Hit) => h.text));
1732
1743
  const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
1733
- if (retryAnchors.violations.length < anchors.violations.length || (!retryRetyped && retyped) || (unreferenced && correctedAnswer.includes("[[u:"))) {
1744
+ if (retryAnchors.violations.length < anchors.violations.length || (!retryRetyped && retyped) || (unreferenced && correctedAnswer.includes("[[u:")) || !toolNarration(correctedAnswer)) {
1734
1745
  answer = correctedAnswer;
1735
1746
  }
1736
1747
  }
@@ -1849,7 +1860,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1849
1860
  if (v.support !== "supported") console.log("entailment:", v.support, raced.score, raced.ungrounded_claims?.slice(0, 2));
1850
1861
  }
1851
1862
  }
1852
- const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...(entailment ? { entailment } : {}), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...(registerBlock ? [registerBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), ...(ablate ? { ablate } : {}), route: { route: routed.route, features: routed.features } };
1863
+ const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...(entailment ? { entailment } : {}), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: refusalShaped(answer, refusalScope(answerCites(answer))) ? [] : (understanding?.follow_ups ?? []), blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...(registerBlock ? [registerBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), ...(ablate ? { ablate } : {}), route: { route: routed.route, features: routed.features } };
1853
1864
  const cacheable = !contextual && !declaredCtx && !ablate && cacheableAnswer(answer, refusalScope(answerCites(answer))) && finalAnchors.violations.length === 0;
1854
1865
  if (cacheable) {
1855
1866
  const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
@@ -158,3 +158,12 @@ export function cacheableAnswer(answer: string, scope?: RefusalScope): boolean {
158
158
  if (answer.includes(refusalAnswer())) return false;
159
159
  return canonicalRefusal(answer, scope) === answer;
160
160
  }
161
+
162
+ /** The follow-up-chips law (2026-10-13): the understanding stage's
163
+ * follow_ups are generated BEFORE retrieval and answer — after a
164
+ * refusal they advertise the very topic the corpus does not carry.
165
+ * Chips serve only when the answer is not refusal-shaped (carries no
166
+ * pin and the wall would not rewrite it). */
167
+ export function refusalShaped(answer: string, scope?: RefusalScope): boolean {
168
+ return !cacheableAnswer(answer, scope);
169
+ }
@@ -7,12 +7,28 @@
7
7
  // on_violation word ("invalid"/"void") when any fails; "void" plus the
8
8
  // missing parameter names when the question does not state enough —
9
9
  // honest degradation, never a guess.
10
+ //
11
+ // Uncertainty ranges (values stated with a tolerance — "30000 ± 60 v",
12
+ // "30000 pm 0.2 %", the AsciiMath spelling included) are first-class:
13
+ // the check is evaluated across the stated band, and a band that spans
14
+ // both outcomes voids with its reason — guarded acceptance, never a
15
+ // silent worst-case ruling.
10
16
 
11
17
  export interface MachineCheck {
12
18
  expression: string;
13
19
  symbolic: string;
14
20
  values: Record<string, number>;
15
21
  result: boolean | null; // null: could not evaluate (missing params)
22
+ /** present when the question states a tolerance on a symbol this
23
+ * check reads: the band's corner evaluations beside the nominal */
24
+ uncertainty?: {
25
+ symbol: string;
26
+ stated: string;
27
+ tol: number;
28
+ unit?: string;
29
+ nominal: boolean;
30
+ corners: (boolean | null)[];
31
+ };
16
32
  }
17
33
 
18
34
  export interface Verdict {
@@ -196,22 +212,94 @@ export function extractParams(query: string, symbols: string[]): Record<string,
196
212
  return extractParamsWithUnits(query, symbols).values;
197
213
  }
198
214
 
215
+ /** A stated uncertainty: "± 60 v" / "pm 60 v" / "± 0.2 %". `tol` is
216
+ * absolute (a relative statement resolves against the stated value);
217
+ * `stated` quotes the source form for narration. */
218
+ export interface StatedUncertainty {
219
+ tol: number;
220
+ stated: string;
221
+ relative: boolean;
222
+ unit?: string;
223
+ }
224
+
225
+ const TOL_SEP_SRC = "\\u00b1|\\bpm\\b|\\+/-";
226
+ const NUM_SRC = "[0-9][0-9 ,.]*[0-9]";
227
+ const UNIT_SRC = "%|\\u00b0?[A-Za-z\\u00b5\\u03a9]{0,6}";
228
+
229
+ function statedUncertainty(
230
+ value: number,
231
+ tolRaw: string,
232
+ tolUnitRaw: string | undefined,
233
+ valueUnit: string | undefined,
234
+ ): StatedUncertainty {
235
+ const tolNum = parseNumber(tolRaw);
236
+ const tolUnit = (tolUnitRaw ?? "").trim();
237
+ const relative = tolUnit === "%";
238
+ return {
239
+ tol: relative ? Math.abs(value) * tolNum / 100 : tolNum,
240
+ stated: relative ? `± ${tolRaw} %` : `± ${tolRaw}${tolUnit ? ` ${tolUnit}` : ""}`,
241
+ relative,
242
+ unit: relative ? valueUnit : (tolUnit || valueUnit),
243
+ };
244
+ }
245
+
199
246
  /** The stated values WITH their unit tokens (TODO.sota/04's unit
200
247
  * semantics): "E_max 30000 v" binds e_max=30000 and the unit "v" —
201
- * the dimension check needs both. */
248
+ * the dimension check needs both. A tolerance after the value —
249
+ * "30000 ± 60 v", "30000 v ± 60", "± 0.2 %", or the AsciiMath
250
+ * spelling "30000 pm 60 v" — binds a first-class uncertainty. */
202
251
  export function extractParamsWithUnits(
203
252
  query: string,
204
253
  symbols: string[],
205
- ): { values: Record<string, number>; units: Record<string, string> } {
254
+ ): {
255
+ values: Record<string, number>;
256
+ units: Record<string, string>;
257
+ uncertainties: Record<string, StatedUncertainty>;
258
+ } {
206
259
  const values: Record<string, number> = {};
207
260
  const units: Record<string, string> = {};
261
+ const uncertainties: Record<string, StatedUncertainty> = {};
208
262
  for (const sym of symbols) {
209
263
  const leaf = sym.split(".").pop() ?? sym;
210
264
  const esc = leaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
211
- // symbol, a short non-numeric gap (words like "were"/"of" allowed),
212
- // then the number, then an optional unit token directly after
213
- const re = new RegExp(`\\b${esc}\\b\\D{0,14}?([0-9][0-9 ,.]*[0-9])\\s*(%|\\u00b0?[A-Za-z\\u00b5\\u03a9]{0,6})?`, "iu");
214
- const m = query.match(re);
265
+ // tolerance first ("value ± tol unit" — the tol's unit names the
266
+ // shared unit when the value carries none), then value and unit
267
+ // side by side with a trailing tolerance, then the plain form
268
+ const reTolFirst = new RegExp(
269
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(?:${TOL_SEP_SRC})\\s*(${NUM_SRC})\\s*(${UNIT_SRC})?`,
270
+ "iu",
271
+ );
272
+ const reTolAfter = new RegExp(
273
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(${UNIT_SRC})\\s*(?:${TOL_SEP_SRC})\\s*(${NUM_SRC})\\s*(${UNIT_SRC})?`,
274
+ "iu",
275
+ );
276
+ const rePlain = new RegExp(
277
+ `\\b${esc}\\b\\D{0,14}?(${NUM_SRC})\\s*(${UNIT_SRC})?`,
278
+ "iu",
279
+ );
280
+ const mFirst = query.match(reTolFirst);
281
+ const mAfter = mFirst ? null : query.match(reTolAfter);
282
+ const mTol = mFirst ?? mAfter;
283
+ if (mTol) {
284
+ const value = parseNumber(mTol[1]!);
285
+ if (!Number.isFinite(value)) continue;
286
+ values[sym] = value;
287
+ let unit: string | undefined;
288
+ let u: StatedUncertainty;
289
+ if (mFirst) {
290
+ // [2]=tol, [3]=tol unit (shared with the value when present)
291
+ unit = (mFirst[3] ?? "").trim() || undefined;
292
+ u = statedUncertainty(value, mFirst[2]!, mFirst[3], unit);
293
+ } else {
294
+ // [2]=value unit, [3]=tol, [4]=tol unit (defaults to the value's)
295
+ unit = (mAfter![2] ?? "").trim() || undefined;
296
+ u = statedUncertainty(value, mAfter![3]!, mAfter![4], unit || undefined);
297
+ }
298
+ if (unit) units[sym] = unit;
299
+ uncertainties[sym] = u;
300
+ continue;
301
+ }
302
+ const m = query.match(rePlain);
215
303
  if (m) {
216
304
  const v = parseNumber(m[1]!);
217
305
  if (Number.isFinite(v)) {
@@ -221,7 +309,7 @@ export function extractParamsWithUnits(
221
309
  }
222
310
  }
223
311
  }
224
- return { values, units };
312
+ return { values, units, uncertainties };
225
313
  }
226
314
 
227
315
  /** Dimension coherence (TODO.sota/04 item 2): the units plane gives
@@ -261,10 +349,13 @@ export function evaluate(content: unknown, query: string, opts?: { dimensions?:
261
349
  let voidReason: string | null = null;
262
350
  const machine: MachineCheck[] = checks.map((expression) => {
263
351
  const values: Record<string, number> = {};
352
+ const ids: string[] = [];
264
353
  try {
265
- for (const t of tokenize(expression)) if (t.t === "id" && params[t.v] !== undefined) values[t.v] = params[t.v];
354
+ for (const t of tokenize(expression)) if (t.t === "id") ids.push(t.v);
266
355
  } catch { /* values stay partial */ }
356
+ for (const t of new Set(ids)) if (params[t] !== undefined) values[t] = params[t];
267
357
  let result: boolean | null = null;
358
+ let uncertainty: MachineCheck["uncertainty"];
268
359
  if (missing.length === 0) {
269
360
  // unit semantics (TODO.sota/04): a comparison mixing dimensions
270
361
  // cannot evaluate — void with the reason, never a number answer
@@ -273,9 +364,39 @@ export function evaluate(content: unknown, query: string, opts?: { dimensions?:
273
364
  voidReason = voidReason ?? mismatch;
274
365
  } else {
275
366
  try { result = !!parseAndEval(expression, params); } catch { result = null; }
367
+ // uncertainty band: the check re-evaluates at every corner of
368
+ // the stated tolerances (2^k ≤ 8); a band spanning both
369
+ // outcomes is undecidable as stated — void with its reason
370
+ const bandSyms = [...new Set(ids)].filter((s) => bound.uncertainties[s] !== undefined).slice(0, 3);
371
+ if (bandSyms.length && result !== null) {
372
+ const corners: (boolean | null)[] = [];
373
+ for (let mask = 0; mask < 1 << bandSyms.length; mask++) {
374
+ const corner = { ...params };
375
+ bandSyms.forEach((s, i) => { corner[s] = corner[s]! + ((mask >> i) & 1 ? 1 : -1) * bound.uncertainties[s]!.tol; });
376
+ try { corners.push(!!parseAndEval(expression, corner)); } catch { corners.push(null); }
377
+ }
378
+ const first = bandSyms[0]!;
379
+ const un = bound.uncertainties[first]!;
380
+ uncertainty = {
381
+ symbol: first,
382
+ stated: un.stated,
383
+ tol: un.tol,
384
+ unit: un.unit,
385
+ nominal: result,
386
+ corners,
387
+ };
388
+ const decided = corners.filter((c): c is boolean => c !== null);
389
+ if (decided.length && decided.some((c) => c !== result)) {
390
+ voidReason = voidReason ?? (
391
+ `the stated uncertainty (${un.stated}) on ${first.split(".").pop()} crosses the decision boundary — ` +
392
+ `the check evaluates ${result ? "true" : "false"} at the stated value but ` +
393
+ `${decided.find((c) => c !== result) ? "true" : "false"} at the band edge; the question cannot be decided as stated`
394
+ );
395
+ }
396
+ }
276
397
  }
277
398
  }
278
- return { expression, symbolic: expression, values, result };
399
+ return { expression, symbolic: expression, values, result, uncertainty };
279
400
  });
280
401
  if (missing.length) {
281
402
  return { verdict: "void", missing, checks: machine };
@@ -286,6 +407,9 @@ export function evaluate(content: unknown, query: string, opts?: { dimensions?:
286
407
  if (voidReason) return { verdict: "void", missing: [], void_reason: voidReason, checks: machine };
287
408
  return null;
288
409
  }
410
+ // a stated uncertainty spanning both outcomes — guarded acceptance:
411
+ // void with the reason, never a silent worst-case ruling
412
+ if (voidReason) return { verdict: "void", missing: [], void_reason: voidReason, checks: machine };
289
413
  return {
290
414
  verdict: failed ? "fail" : "pass",
291
415
  on_violation: failed ? String(c.on_violation ?? "invalid") : undefined,
@@ -303,6 +427,11 @@ export function verdictNote(v: Verdict, node: { node_id: string; clause?: { urn?
303
427
  for (const c of v.checks) {
304
428
  const vals = Object.entries(c.values).map(([k, n]) => `${k}=${n}`).join(", ");
305
429
  lines.push(`- ${c.expression}${vals ? ` [${vals}]` : ""} → ${c.result === null ? "not evaluated" : c.result ? "holds" : "VIOLATED"}`);
430
+ if (c.uncertainty) {
431
+ const u = c.uncertainty;
432
+ const edges = u.corners.map((r) => (r === null ? "not evaluated" : r ? "holds" : "VIOLATED")).join("; ");
433
+ lines.push(` band ${u.stated}${u.unit && !u.stated.includes(u.unit) ? ` ${u.unit}` : ""} on ${u.symbol}: ${edges} across the band`);
434
+ }
306
435
  }
307
436
  if (v.verdict === "void") {
308
437
  lines.push(v.void_reason