@konneal/engine 0.2.7 → 0.2.11

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,6 +1,6 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-LWEVSMH6.js";
3
+ } from "./chunk-KMSUJI75.js";
4
4
  import "./chunk-7UT76RQO.js";
5
5
  import "./chunk-EFQALN2Z.js";
6
6
  import "./chunk-DBBGOOMZ.js";
@@ -759,6 +759,84 @@ function verdictNote(v, node) {
759
759
  return lines.join("\n");
760
760
  }
761
761
 
762
+ // workers/worker_public/src/conditions.ts
763
+ var NUM = String.raw`-?\d+(?:[.,]\d+)?`;
764
+ function quantitiesIn(query) {
765
+ const out = {};
766
+ const num2 = (s) => Number(s.replace(",", "."));
767
+ const put = (kind, stated, stated_unit, si) => {
768
+ if (Number.isFinite(si)) out[kind] = { stated, stated_unit, si };
769
+ };
770
+ const tempC = query.match(new RegExp(`(${NUM})\\s*(?:\xB0\\s*)?C\\b`));
771
+ if (tempC) put("temperature", num2(tempC[1]), "degC", num2(tempC[1]) + 273.15);
772
+ const tempK = query.match(new RegExp(`(${NUM})\\s*K\\b`));
773
+ if (tempK && out.temperature === void 0) put("temperature", num2(tempK[1]), "K", num2(tempK[1]));
774
+ const rh = query.match(new RegExp(`(${NUM})\\s*%\\s*(?:RH\\b|relative\\s+humidity)?`, "i"));
775
+ if (rh) put("relative_humidity", num2(rh[1]), "%", num2(rh[1]) / 100);
776
+ const hours = query.match(new RegExp(`(${NUM})\\s*h\\b`, "i"));
777
+ if (hours) put("duration", num2(hours[1]), "h", num2(hours[1]) * 3600);
778
+ const days = query.match(new RegExp(`(${NUM})\\s*days?\\b`, "i"));
779
+ if (days && out.duration === void 0) put("duration", num2(days[1]), "d", num2(days[1]) * 86400);
780
+ return out;
781
+ }
782
+ function scoreSet(entries, q) {
783
+ const checks = [];
784
+ let distance = 0;
785
+ let stated = 0;
786
+ for (const e of entries) {
787
+ const siEntry = e;
788
+ const statedQ = q[e.quantity_kind];
789
+ if (!statedQ || !siEntry?.si) continue;
790
+ const tol = Number(String(siEntry.tolerance ?? "0").replace(",", "."));
791
+ const tolSi = (Number.isFinite(tol) ? tol : 0) * (siEntry.si.unit === "K" ? 1 : siEntry.si.unit === "1" ? 0.01 : 1);
792
+ const in_band = statedQ.si >= siEntry.si.value - tolSi && statedQ.si <= siEntry.si.value + tolSi;
793
+ const gap = Math.max(0, statedQ.si - (siEntry.si.value + tolSi), siEntry.si.value - tolSi - statedQ.si);
794
+ distance += gap / Math.max(tolSi, 1);
795
+ stated += 1;
796
+ checks.push({
797
+ quantity_kind: e.quantity_kind,
798
+ band: `${siEntry.si.value} ${siEntry.si.unit} \xB1${tolSi}`,
799
+ stated: statedQ.stated,
800
+ stated_unit: statedQ.stated_unit,
801
+ in_band
802
+ });
803
+ }
804
+ return stated ? { checks, distance, stated } : null;
805
+ }
806
+ function evaluateConditionSets(nodes, query) {
807
+ const q = quantitiesIn(query);
808
+ const kinds = Object.keys(q);
809
+ if (!kinds.length) return null;
810
+ const scored = [];
811
+ for (const n of nodes) {
812
+ const c = n.content && typeof n.content === "object" ? n.content : {};
813
+ const payload = c.payload ?? {};
814
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
815
+ if (!entries.length) continue;
816
+ const s = scoreSet(entries, q);
817
+ if (s) scored.push({ node_id: n.node_id, checks: s.checks, distance: s.distance });
818
+ }
819
+ if (!scored.length) return null;
820
+ const matched = scored.filter((s) => s.checks.every((c) => c.in_band));
821
+ scored.sort((a, b) => a.distance - b.distance);
822
+ if (matched.length) {
823
+ return {
824
+ verdict: "pass",
825
+ matched: matched.map((m) => m.node_id),
826
+ checks: matched[0].checks,
827
+ note: `VERDICT: PASS \u2014 the stated combination (${kinds.join(", ")}) matches severity set(s) ${matched.map((m) => m.node_id).join(", ")}. Present this verdict and cite the set's clause.`
828
+ };
829
+ }
830
+ const nearest = scored[0];
831
+ return {
832
+ verdict: "fail",
833
+ matched: [],
834
+ nearest: { node_id: nearest.node_id, distance: Number(nearest.distance.toFixed(2)), bands: nearest.checks.map((c) => c.band) },
835
+ checks: nearest.checks,
836
+ note: `VERDICT: FAIL \u2014 no severity set admits the stated combination. The nearest set is ${nearest.node_id} (bands: ${nearest.checks.map((c) => c.band).join("; ")}). Say the combination is outside the menu and name the nearest set; never soften it.`
837
+ };
838
+ }
839
+
762
840
  // workers/worker_public/src/drafts.ts
763
841
  var ACT_VERB = "(?:draft|prepare|pre-?fill|fill\\s+(?:in|out)|start|submit|file|lodge)";
764
842
  var ACT_TARGET = "(?:new\\s+)?(?:certification\\s+|type[ -]evaluation\\s+|OIML[- ]CS\\s+)?application";
@@ -1551,6 +1629,32 @@ ${summary}` }] : [],
1551
1629
  const modelNote = boundModel && !boundModel.gated ? modelGroundingBlock(boundModel) : void 0;
1552
1630
  const machineVerdict = boundModel && !boundModel.gated ? evaluate(boundModel.content, q.query) : null;
1553
1631
  const machineNote = machineVerdict && boundModel ? verdictNote(machineVerdict, boundModel) : void 0;
1632
+ let conditionVerdict = null;
1633
+ let conditionStandard = null;
1634
+ if (!machineVerdict && !boundModel && P().publisher.features?.model_plane) {
1635
+ const ql = q.query.toLowerCase();
1636
+ const severityWord = /\b(severity|test|valid|tolerance|condition|within)\b/.test(ql);
1637
+ const stated = quantitiesIn(q.query);
1638
+ if (severityWord && Object.keys(stated).length >= 1) {
1639
+ const docNum = modelDocHint?.doc_number;
1640
+ const sql = docNum ? "SELECT standard, node_id, content FROM model_nodes WHERE kind = 'condition_set' AND standard = ?1" : "SELECT standard, node_id, content FROM model_nodes WHERE kind = 'condition_set' LIMIT 40";
1641
+ const stmt = docNum ? env.DB.prepare(sql).bind(docNum) : env.DB.prepare(sql);
1642
+ const rows = await stmt.all().catch(() => ({ results: [] }));
1643
+ const candidates = (rows.results ?? []).filter((r) => {
1644
+ const entry = licensedEntryForPackage(String(r.standard));
1645
+ return !entry || (standardKeys?.has(entry.key) ?? false);
1646
+ });
1647
+ const v = evaluateConditionSets(
1648
+ candidates.map((r) => ({ node_id: String(r.node_id), content: JSON.parse(String(r.content ?? "{}")) })),
1649
+ q.query
1650
+ );
1651
+ if (v) {
1652
+ conditionVerdict = v;
1653
+ conditionStandard = String(rows.results?.[0]?.standard ?? "");
1654
+ }
1655
+ }
1656
+ }
1657
+ const conditionNote = conditionVerdict && conditionStandard ? `${conditionVerdict.note} (computed from the ${conditionStandard} condition sets \u2014 machine evaluation, cite the package's clause.)` : void 0;
1554
1658
  const verdictBlock = machineVerdict ? {
1555
1659
  unit_id: boundModel.node_id,
1556
1660
  type: "verdict",
@@ -1564,6 +1668,22 @@ ${summary}` }] : [],
1564
1668
  }
1565
1669
  } : null;
1566
1670
  if (machineVerdict) console.log("verdict engine:", boundModel.node_id, "\u2192", machineVerdict.verdict.toUpperCase(), machineVerdict.missing.length ? `(missing ${machineVerdict.missing.join(",")})` : "");
1671
+ const conditionBlock = conditionVerdict ? {
1672
+ unit_id: conditionVerdict.matched[0] ?? conditionVerdict.nearest.node_id,
1673
+ type: "verdict",
1674
+ docidentifier: `IEC SMART model (${conditionStandard})`,
1675
+ payload: {
1676
+ verdict: conditionVerdict.verdict,
1677
+ missing: [],
1678
+ checks: conditionVerdict.checks.map((c) => ({
1679
+ expression: `${c.quantity_kind} within ${c.band}`,
1680
+ values: { stated: c.stated },
1681
+ result: c.in_band
1682
+ })),
1683
+ ...conditionVerdict.nearest ? { nearest: conditionVerdict.nearest } : { matched: conditionVerdict.matched }
1684
+ }
1685
+ } : null;
1686
+ if (conditionVerdict) console.log("condition engine:", conditionVerdict.matched.join("|") || conditionVerdict.nearest.node_id, "\u2192", conditionVerdict.verdict.toUpperCase());
1567
1687
  try {
1568
1688
  const tR = Date.now();
1569
1689
  if (declaredCtx?.kind === "account") {
@@ -1657,7 +1777,7 @@ Answer account questions from these records ONLY: name the record when you use i
1657
1777
  q.lang,
1658
1778
  keptHistory,
1659
1779
  // stage-extracted graph facts (GraphRAG) ride the same note channel
1660
- [processNote, eNote, contextNote(declaredCtx, docScope), accountNote, modelNote, vocabNote, memNote, machineNote, licenseNote, ...retrieved.notes ?? []].filter(Boolean).join("\n") || void 0,
1780
+ [processNote, eNote, contextNote(declaredCtx, docScope), accountNote, modelNote, vocabNote, memNote, machineNote, conditionNote, licenseNote, ...retrieved.notes ?? []].filter(Boolean).join("\n") || void 0,
1661
1781
  summary,
1662
1782
  budget
1663
1783
  );
@@ -1705,7 +1825,7 @@ Answer account questions from these records ONLY: name the record when you use i
1705
1825
  model,
1706
1826
  query_hash: queryHash,
1707
1827
  follow_ups: understanding?.follow_ups ?? [],
1708
- blocks: verdictBlock ? [...c2.blocks, verdictBlock] : c2.blocks,
1828
+ blocks: [...c2.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : []],
1709
1829
  context_applied: ctxApplied,
1710
1830
  read: readAs(),
1711
1831
  // the evidence view's ground truth: the exact passages this
@@ -1822,7 +1942,7 @@ Answer account questions from these records ONLY: name the record when you use i
1822
1942
  if (completionBlocks.length) console.log("contract completion:", completionBlocks.length, "table block(s) attached server-side");
1823
1943
  }
1824
1944
  completionBlocks.push(...await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks], used));
1825
- const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {} };
1945
+ const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {} };
1826
1946
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
1827
1947
  if (cacheable) {
1828
1948
  const warmVec = await warmEmbed ?? null;
@@ -0,0 +1,34 @@
1
+ export interface ConditionCheck {
2
+ quantity_kind: string;
3
+ band: string;
4
+ stated: number;
5
+ stated_unit: string;
6
+ in_band: boolean;
7
+ }
8
+ export interface ConditionVerdict {
9
+ verdict: "pass" | "fail";
10
+ matched: string[];
11
+ nearest?: {
12
+ node_id: string;
13
+ distance: number;
14
+ bands: string[];
15
+ };
16
+ checks: ConditionCheck[];
17
+ note: string;
18
+ }
19
+ /** The question's stated quantities with their SI normalization
20
+ * (temperature → K, relative_humidity → the ratio unit, duration → s).
21
+ * Units, never words: "12 months" is not a 12 h duration. */
22
+ export declare function quantitiesIn(query: string): Record<string, {
23
+ stated: number;
24
+ stated_unit: string;
25
+ si: number;
26
+ }>;
27
+ /** Evaluate the candidate condition_set nodes against the question's
28
+ * stated quantities. PASS when at least one set admits every stated
29
+ * quantity within its band (the set is named); FAIL names the nearest
30
+ * set and the violated bands. */
31
+ export declare function evaluateConditionSets(nodes: {
32
+ node_id: string;
33
+ content: unknown;
34
+ }[], query: string): ConditionVerdict | null;
@@ -7,7 +7,7 @@ import {
7
7
  handleMemories,
8
8
  scoreJudge,
9
9
  standardForDocNumber
10
- } from "../../chunk-LWEVSMH6.js";
10
+ } from "../../chunk-KMSUJI75.js";
11
11
  import {
12
12
  buildMessages,
13
13
  citations,
@@ -979,7 +979,7 @@ async function handleMcp(env, ctx, req, tier, key) {
979
979
  // stream:false forces the JSON lane (anon defaults to SSE)
980
980
  body: JSON.stringify({ ...args, stream: false })
981
981
  });
982
- const res = name === "ask" ? await (await import("../../ask-6V6AJG2V.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-RFKT7ZZY.js")).handleSearch(env, ctx, inner, tier, key);
982
+ const res = name === "ask" ? await (await import("../../ask-AAEPGPJI.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-RFKT7ZZY.js")).handleSearch(env, ctx, inner, tier, key);
983
983
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
984
984
  });
985
985
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
package/docs/ADOPTION.md CHANGED
@@ -42,6 +42,15 @@ request cannot reach internal indexes at all: the public surface holds
42
42
  no binding to them, and the internal worker re-verifies every session
43
43
  itself.
44
44
 
45
+ ## Grounded in the literature
46
+
47
+ The execution pattern (a deterministic evaluator computes; the model
48
+ narrates) adopts the program-aided line — PAL (arXiv:2211.10435) and
49
+ Program of Thoughts (arXiv:2211.12588) — and the structured-numeric
50
+ retrieval lessons of TableRAG (arXiv:2410.04739, arXiv:2506.10380).
51
+ The deployment's research notes map every technique to its source and
52
+ to the code path that implements it.
53
+
45
54
  ## The proof
46
55
 
47
56
  Your golden suite gates every promotion: content witnesses, refusal
package/package.json CHANGED
@@ -34,7 +34,7 @@
34
34
  "@oimlsmart/oiml-pubid": "^1.2.1"
35
35
  },
36
36
  "dependencies": {},
37
- "version": "0.2.7",
37
+ "version": "0.2.11",
38
38
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
39
39
  "license": "BSD-3-Clause",
40
40
  "type": "module",
@@ -19,8 +19,9 @@ import { contractV2, tableRetyped } from "./refs";
19
19
  import { completeTables, completeFigures } from "./completion";
20
20
  import { NO_CONTEXT, appliedContext, contextNote, namedDocumentIn, parseContext, resolveDocScope, syntheticUnderstanding } from "./context";
21
21
  import { exchangeForLiveToken, liveDataConfig, resolveLiveAccount, type LiveRecord } from "./livedata";
22
- import { bindModelNode, licenseBoundaryNote, licenseBoundaryRefusal, modelCitation, modelEcho, modelGroundingBlock, modelNodeRefIn, standardForDocNumber } from "./modelplane";
22
+ import { bindModelNode, licenseBoundaryNote, licenseBoundaryRefusal, licensedEntryForPackage, modelCitation, modelEcho, modelGroundingBlock, modelNodeRefIn, standardForDocNumber } from "./modelplane";
23
23
  import { evaluate as machineEvaluate, verdictNote } from "./verdict";
24
+ import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./conditions";
24
25
  import { detectDraftIntent, prepareDraft } from "./drafts";
25
26
  import { memoryNote } from "./memories";
26
27
  import { entitlementScope, resolveRequestScope, requestSalt } from "./requestScope";
@@ -717,6 +718,42 @@ async function handleAsk(
717
718
  // and the verdict BLOCK is server-built — data, never generated prose
718
719
  const machineVerdict = boundModel && !boundModel.gated ? machineEvaluate(boundModel.content, q.query) : null;
719
720
  const machineNote = machineVerdict && boundModel ? verdictNote(machineVerdict, boundModel) : undefined;
721
+ // ── condition-set membership (konneal/engine#90): the test-method
722
+ // packages' severity menus as machine-verifiable membership. Fires
723
+ // when no explicit node binding ran and the question states
724
+ // quantities alongside severity vocabulary; the candidate sets come
725
+ // from the model node store (kind condition_set), license-gated like
726
+ // every model lane.
727
+ let conditionVerdict: ConditionVerdict | null = null;
728
+ let conditionStandard: string | null = null;
729
+ if (!machineVerdict && !boundModel && P().publisher.features?.model_plane) {
730
+ const ql = q.query.toLowerCase();
731
+ const severityWord = /\b(severity|test|valid|tolerance|condition|within)\b/.test(ql);
732
+ const stated = quantitiesIn(q.query);
733
+ if (severityWord && Object.keys(stated).length >= 1) {
734
+ const docNum = modelDocHint?.doc_number;
735
+ const sql = docNum
736
+ ? "SELECT standard, node_id, content FROM model_nodes WHERE kind = 'condition_set' AND standard = ?1"
737
+ : "SELECT standard, node_id, content FROM model_nodes WHERE kind = 'condition_set' LIMIT 40";
738
+ const stmt = docNum ? env.DB.prepare(sql).bind(docNum) : env.DB.prepare(sql);
739
+ const rows = await stmt.all().catch(() => ({ results: [] }));
740
+ const candidates = (rows.results ?? []).filter((r: any) => {
741
+ const entry = licensedEntryForPackage(String(r.standard));
742
+ return !entry || (standardKeys?.has(entry.key) ?? false);
743
+ });
744
+ const v = evaluateConditionSets(
745
+ candidates.map((r: any) => ({ node_id: String(r.node_id), content: JSON.parse(String(r.content ?? "{}")) })),
746
+ q.query,
747
+ );
748
+ if (v) {
749
+ conditionVerdict = v;
750
+ conditionStandard = String((rows.results?.[0] as any)?.standard ?? "");
751
+ }
752
+ }
753
+ }
754
+ const conditionNote = conditionVerdict && conditionStandard
755
+ ? `${conditionVerdict.note} (computed from the ${conditionStandard} condition sets — machine evaluation, cite the package's clause.)`
756
+ : undefined;
720
757
  const verdictBlock = machineVerdict
721
758
  ? {
722
759
  unit_id: boundModel!.node_id,
@@ -732,6 +769,26 @@ async function handleAsk(
732
769
  }
733
770
  : null;
734
771
  if (machineVerdict) console.log("verdict engine:", boundModel!.node_id, "→", machineVerdict.verdict.toUpperCase(), machineVerdict.missing.length ? `(missing ${machineVerdict.missing.join(",")})` : "");
772
+ const conditionBlock = conditionVerdict
773
+ ? {
774
+ unit_id: conditionVerdict.matched[0] ?? conditionVerdict.nearest!.node_id,
775
+ type: "verdict",
776
+ docidentifier: `IEC SMART model (${conditionStandard})`,
777
+ payload: {
778
+ verdict: conditionVerdict.verdict,
779
+ missing: [],
780
+ checks: conditionVerdict.checks.map((c) => ({
781
+ expression: `${c.quantity_kind} within ${c.band}`,
782
+ values: { stated: c.stated },
783
+ result: c.in_band,
784
+ })),
785
+ ...(conditionVerdict.nearest
786
+ ? { nearest: conditionVerdict.nearest }
787
+ : { matched: conditionVerdict.matched }),
788
+ },
789
+ }
790
+ : null;
791
+ if (conditionVerdict) console.log("condition engine:", conditionVerdict.matched.join("|") || conditionVerdict.nearest!.node_id, "→", conditionVerdict.verdict.toUpperCase());
735
792
  try {
736
793
  const tR = Date.now();
737
794
  // ── The "my account" live read (TODO.ai-platform/03) — resolved
@@ -881,7 +938,7 @@ async function handleAsk(
881
938
  q.lang,
882
939
  keptHistory,
883
940
  // stage-extracted graph facts (GraphRAG) ride the same note channel
884
- [processNote, eNote, contextNote(declaredCtx, docScope), accountNote, modelNote, vocabNote, memNote, machineNote, licenseNote, ...(retrieved.notes ?? [])].filter(Boolean).join("\n") || undefined,
941
+ [processNote, eNote, contextNote(declaredCtx, docScope), accountNote, modelNote, vocabNote, memNote, machineNote, conditionNote, licenseNote, ...(retrieved.notes ?? [])].filter(Boolean).join("\n") || undefined,
885
942
  summary,
886
943
  budget,
887
944
  );
@@ -934,7 +991,7 @@ async function handleAsk(
934
991
  const c2 = canonical0.includes(refusalAnswer())
935
992
  ? { text: canonical0, blocks: [], dropped: [] as string[] }
936
993
  : await contractV2(env.DB, canonical0, usedHits);
937
- send({ type: "done", model, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: verdictBlock ? [...c2.blocks, verdictBlock] : c2.blocks, context_applied: ctxApplied, read: readAs(),
994
+ send({ type: "done", model, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : [])], context_applied: ctxApplied, read: readAs(),
938
995
  // the evidence view's ground truth: the exact passages this
939
996
  // answer was built from, compact — cache hits carry none,
940
997
  // because the cache stores the answer and never the passages
@@ -1113,7 +1170,7 @@ async function handleAsk(
1113
1170
  // figure completion (#172) — see ./completion for the rationale
1114
1171
  completionBlocks.push(...(await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks], used)));
1115
1172
 
1116
- const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
1173
+ const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
1117
1174
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
1118
1175
  if (cacheable) {
1119
1176
  const warmVec = (await warmEmbed) ?? null;
@@ -0,0 +1,132 @@
1
+ // Condition-set evaluation (konneal/engine#90): the severity menus of
2
+ // the test-method packages as machine-verifiable membership. A
3
+ // condition_set node's payload.entries carry the kernel's quantity
4
+ // doctrine — value + unit inseparable, tolerance the SPECIFIED band —
5
+ // and each entry resolves to SI through the package's own quantity
6
+ // register (the export ships si { value, unit }). The stated quantities
7
+ // normalize through the same SI targets, so 313 K and 40 °C are the
8
+ // same stated temperature.
9
+
10
+ export interface ConditionCheck {
11
+ quantity_kind: string;
12
+ band: string;
13
+ stated: number;
14
+ stated_unit: string;
15
+ in_band: boolean;
16
+ }
17
+
18
+ export interface ConditionVerdict {
19
+ verdict: "pass" | "fail";
20
+ matched: string[];
21
+ nearest?: { node_id: string; distance: number; bands: string[] };
22
+ checks: ConditionCheck[];
23
+ note: string;
24
+ }
25
+
26
+ interface Entry {
27
+ quantity_kind: string;
28
+ value: string | number;
29
+ unit: string;
30
+ tolerance: string | number;
31
+ si?: { value: number; unit: string };
32
+ }
33
+
34
+ const NUM = String.raw`-?\d+(?:[.,]\d+)?`;
35
+
36
+ /** The question's stated quantities with their SI normalization
37
+ * (temperature → K, relative_humidity → the ratio unit, duration → s).
38
+ * Units, never words: "12 months" is not a 12 h duration. */
39
+ export function quantitiesIn(query: string): Record<string, { stated: number; stated_unit: string; si: number }> {
40
+ const out: Record<string, { stated: number; stated_unit: string; si: number }> = {};
41
+ const num = (s: string) => Number(s.replace(",", "."));
42
+ const put = (kind: string, stated: number, stated_unit: string, si: number) => {
43
+ if (Number.isFinite(si)) out[kind] = { stated, stated_unit, si };
44
+ };
45
+ const tempC = query.match(new RegExp(`(${NUM})\\s*(?:°\\s*)?C\\b`));
46
+ if (tempC) put("temperature", num(tempC[1]!), "degC", num(tempC[1]!) + 273.15);
47
+ const tempK = query.match(new RegExp(`(${NUM})\\s*K\\b`));
48
+ if (tempK && out.temperature === undefined) put("temperature", num(tempK[1]!), "K", num(tempK[1]!));
49
+ const rh = query.match(new RegExp(`(${NUM})\\s*%\\s*(?:RH\\b|relative\\s+humidity)?`, "i"));
50
+ if (rh) put("relative_humidity", num(rh[1]!), "%", num(rh[1]!) / 100);
51
+ const hours = query.match(new RegExp(`(${NUM})\\s*h\\b`, "i"));
52
+ if (hours) put("duration", num(hours[1]!), "h", num(hours[1]!) * 3600);
53
+ const days = query.match(new RegExp(`(${NUM})\\s*days?\\b`, "i"));
54
+ if (days && out.duration === undefined) put("duration", num(days[1]!), "d", num(days[1]!) * 86400);
55
+ return out;
56
+ }
57
+
58
+ interface SiEntry {
59
+ quantity_kind: string;
60
+ unit: string;
61
+ tolerance: string | number;
62
+ si: { value: number; unit: string };
63
+ }
64
+
65
+ /** Evaluate ONE set: every stated quantity's SI value inside the
66
+ * entry's SI band (the tolerance converts with the factor, never the
67
+ * offset — a difference has no affine part). */
68
+ function scoreSet(entries: (Entry | SiEntry)[], q: Record<string, { stated: number; stated_unit: string; si: number }>): { checks: ConditionCheck[]; distance: number; stated: number } | null {
69
+ const checks: ConditionCheck[] = [];
70
+ let distance = 0;
71
+ let stated = 0;
72
+ for (const e of entries) {
73
+ const siEntry = e as SiEntry;
74
+ const statedQ = q[e.quantity_kind];
75
+ if (!statedQ || !siEntry?.si) continue;
76
+ const tol = Number(String(siEntry.tolerance ?? "0").replace(",", "."));
77
+ const tolSi = (Number.isFinite(tol) ? tol : 0) * (siEntry.si.unit === "K" ? 1 : siEntry.si.unit === "1" ? 0.01 : 1);
78
+ const in_band = statedQ.si >= siEntry.si.value - tolSi && statedQ.si <= siEntry.si.value + tolSi;
79
+ const gap = Math.max(0, statedQ.si - (siEntry.si.value + tolSi), siEntry.si.value - tolSi - statedQ.si);
80
+ distance += gap / Math.max(tolSi, 1);
81
+ stated += 1;
82
+ checks.push({
83
+ quantity_kind: e.quantity_kind,
84
+ band: `${siEntry.si.value} ${siEntry.si.unit} ±${tolSi}`,
85
+ stated: statedQ.stated,
86
+ stated_unit: statedQ.stated_unit,
87
+ in_band,
88
+ });
89
+ }
90
+ return stated ? { checks, distance, stated } : null;
91
+ }
92
+
93
+ /** Evaluate the candidate condition_set nodes against the question's
94
+ * stated quantities. PASS when at least one set admits every stated
95
+ * quantity within its band (the set is named); FAIL names the nearest
96
+ * set and the violated bands. */
97
+ export function evaluateConditionSets(
98
+ nodes: { node_id: string; content: unknown }[],
99
+ query: string,
100
+ ): ConditionVerdict | null {
101
+ const q = quantitiesIn(query);
102
+ const kinds = Object.keys(q);
103
+ if (!kinds.length) return null;
104
+ const scored: { node_id: string; checks: ConditionCheck[]; distance: number }[] = [];
105
+ for (const n of nodes) {
106
+ const c = (n.content && typeof n.content === "object" ? n.content : {}) as Record<string, any>;
107
+ const payload = (c.payload ?? {}) as { entries?: (Entry | SiEntry)[] };
108
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
109
+ if (!entries.length) continue;
110
+ const s = scoreSet(entries, q);
111
+ if (s) scored.push({ node_id: n.node_id, checks: s.checks, distance: s.distance });
112
+ }
113
+ if (!scored.length) return null;
114
+ const matched = scored.filter((s) => s.checks.every((c) => c.in_band));
115
+ scored.sort((a, b) => a.distance - b.distance);
116
+ if (matched.length) {
117
+ return {
118
+ verdict: "pass",
119
+ matched: matched.map((m) => m.node_id),
120
+ checks: matched[0]!.checks,
121
+ note: `VERDICT: PASS — the stated combination (${kinds.join(", ")}) matches severity set(s) ${matched.map((m) => m.node_id).join(", ")}. Present this verdict and cite the set's clause.`,
122
+ };
123
+ }
124
+ const nearest = scored[0]!;
125
+ return {
126
+ verdict: "fail",
127
+ matched: [],
128
+ nearest: { node_id: nearest.node_id, distance: Number(nearest.distance.toFixed(2)), bands: nearest.checks.map((c) => c.band) },
129
+ checks: nearest.checks,
130
+ note: `VERDICT: FAIL — no severity set admits the stated combination. The nearest set is ${nearest.node_id} (bands: ${nearest.checks.map((c) => c.band).join("; ")}). Say the combination is outside the menu and name the nearest set; never soften it.`,
131
+ };
132
+ }