From fc1a62bb81543d1089ec4590fbec875c9acdb4d7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 23 Sep 2026 00:15:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20decision-plane=20wiring=20=E2=80=94=20E?= =?UTF-8?q?R-pair=20decision.match=20tie-break=20(@siax/0pt-decision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/decision: erMatchDecision (0pt.er.pair_match mot siax-decision :4600) - comparePair async + borderline-band (±0.05 av review_threshold) → decision.match (D1 advisory) — deterministisk status GÄLLER om decision abstainar - OPT_DECISION_ER_ENABLED=1-gate (off tills kalibrering) - tests async-anpassade (await generateCandidates/rejects.toThrowError) - typecheck: 13 fel = pre-existing extraction-route (main-paritet), 0 nya fel - tester: 1108 passed | 184 skipped --- app/api/0pt/internal/er/candidates/route.ts | 2 +- packages/decision/package.json | 10 ++++ packages/decision/src/index.ts | 62 +++++++++++++++++++++ packages/decision/tsconfig.json | 12 ++++ pnpm-lock.yaml | 2 + src/er/entity-resolution-service.ts | 26 +++++++-- tests/p0/entity-resolution.test.ts | 34 +++++------ 7 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 packages/decision/package.json create mode 100644 packages/decision/src/index.ts create mode 100644 packages/decision/tsconfig.json diff --git a/app/api/0pt/internal/er/candidates/route.ts b/app/api/0pt/internal/er/candidates/route.ts index e22689c..29e9e75 100644 --- a/app/api/0pt/internal/er/candidates/route.ts +++ b/app/api/0pt/internal/er/candidates/route.ts @@ -93,7 +93,7 @@ export async function POST(request: NextRequest) { if (denied) return denied; try { - const result = er.generateCandidates(parseErCandidateRequest(await request.json())); + const result = await er.generateCandidates(parseErCandidateRequest(await request.json())); return NextResponse.json(enforceCompanyReviewBoundary(result)); } catch (error: unknown) { return siaxErrorResponse(error); diff --git a/packages/decision/package.json b/packages/decision/package.json new file mode 100644 index 0000000..7440a68 --- /dev/null +++ b/packages/decision/package.json @@ -0,0 +1,10 @@ +{ + "name": "@siax/0pt-decision", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { ".": "./src/index.ts" }, + "scripts": { "test": "echo 'no tests yet'" } +} diff --git a/packages/decision/src/index.ts b/packages/decision/src/index.ts new file mode 100644 index 0000000..b78048a --- /dev/null +++ b/packages/decision/src/index.ts @@ -0,0 +1,62 @@ +/** + * `@siax/0pt-decision` — 0pt ER via siax-decision (:4600). + * + * Kaskad: hard rules + weighted-scoring (D0) → decision.match (D1-advisory) + * för pending_review-ambiguitet → ABSTAINED/decision-nere → pending_review + * gäller (fail-closed, aldrig fabricerat). 0pt behåller provenance-semantik. + */ + +const DECISION_URL = process.env.DECISION_API_URL ?? "http://127.0.0.1:4600"; +const DECISION_TOKEN = process.env.DECISION_TOKEN ?? ""; +const DECISION_TIMEOUT_MS = Number(process.env.DECISION_TIMEOUT_MS ?? 2000); + +export interface DecisionMatch { + decided: boolean; + label?: "match" | "pending_review" | "non_match"; + confidence?: number; + model?: string; + latencyMs?: number; +} + +/** decision.match för ER-pair (semantic tie-break vid gränsfall). */ +export async function erMatchDecision(pair: { + record_a: string; + record_b: string; + similarities: Array<{ field: string; similarity: number; weight: number }>; +}): Promise<{ decided: boolean; label?: string; confidence?: number; model?: string; latencyMs?: number }> { + try { + const res = await fetch(`${DECISION_URL}/decision/match`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${DECISION_TOKEN}` }, + body: JSON.stringify({ + decision: "0pt.er.pair_match", + left: { record_id: pair.record_a }, + right: [{ record_id: pair.record_b }], + instructions: "Do these two entity records refer to the same real-world entity?", + minConfidence: 0.7, + context: { field_similarities: pair.similarities }, + }), + signal: AbortSignal.timeout(DECISION_TIMEOUT_MS), + }); + if (!res.ok) return { decided: false }; + const out = (await res.json()) as { data?: { status?: string; choice?: string; confidence?: number; model?: string; latencyMs?: number } }; + const d = out.data; + if (!d || d.status !== "DECIDED") return { decided: false, confidence: d?.confidence }; + const label = String(d.choice ?? "pending_review"); + return { + decided: true, + label: label === "0" ? "non_match" : label === "1" ? "match" : (label as "match" | "pending_review" | "non_match"), + confidence: d.confidence, + model: d.model, + latencyMs: d.latencyMs, + }; + } catch { + return { decided: false }; + } +} + +/** Optional gate: enabled via OPT_DECISION_ER_ENABLED=1 (off tills kalibrering). */ +export function erDecisionEnabled(): boolean { + const v = process.env.OPT_DECISION_ER_ENABLED; + return v === "1" || v === "true"; +} diff --git a/packages/decision/tsconfig.json b/packages/decision/tsconfig.json new file mode 100644 index 0000000..637a89c --- /dev/null +++ b/packages/decision/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe40adc..dde8cf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,8 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) + packages/decision: {} + packages/schemas: {} packages: diff --git a/src/er/entity-resolution-service.ts b/src/er/entity-resolution-service.ts index 21f614f..cc3b2cd 100644 --- a/src/er/entity-resolution-service.ts +++ b/src/er/entity-resolution-service.ts @@ -9,6 +9,7 @@ * an adapter to this canonical decision engine. */ import crypto from "crypto"; +import { erMatchDecision, erDecisionEnabled } from "../../packages/decision/src/index.js"; import type { N0dClient, N0dJob } from "../clients/n0d-client"; import { SiaxError } from "../platform/errors"; import { isResourceRef } from "../platform/resource-ref"; @@ -88,7 +89,7 @@ export class EntityResolutionService { this.hardRules.push(rule); } - generateCandidates(req: ErCandidateRequest): ErCandidateResult { + async generateCandidates(req: ErCandidateRequest): Promise { if (!req.tenant_id?.trim()) throw new SiaxError("INVALID_REQUEST", "0pt.er", "tenant_id is required"); if (!req.project_id?.trim()) throw new SiaxError("INVALID_REQUEST", "0pt.er", "project_id is required"); if (req.records.length < 2 || req.records.length > 5_000) { @@ -144,7 +145,7 @@ export class EntityResolutionService { for (let i = 0; i < bucket.length; i++) { for (let j = i + 1; j < bucket.length; j++) { blockedComparisons += 1; - pairs.push(this.comparePair(bucket[i], bucket[j], req, autoThreshold, reviewThreshold, key)); + pairs.push(await this.comparePair(bucket[i], bucket[j], req, autoThreshold, reviewThreshold, key)); } } } @@ -200,14 +201,14 @@ export class EntityResolutionService { }); } - private comparePair( + private async comparePair( a: ErRecord, b: ErRecord, req: ErCandidateRequest, autoThreshold: number, reviewThreshold: number, blockingKey: string, - ): ErCandidatePair { + ): Promise { for (const rule of this.hardRules) { const decision = rule.decide(a, b); if (decision !== "abstain") { @@ -230,11 +231,22 @@ export class EntityResolutionService { ? 0 : explanation.reduce((sum, item) => sum + item.similarity * item.weight, 0) / weightSum; const rounded = Math.round(score * 1000) / 1000; - const status: ErCandidateStatus = rounded >= autoThreshold + let status: ErCandidateStatus = rounded >= autoThreshold ? "match" : rounded >= reviewThreshold ? "pending_review" : "non_match"; + // DECISION-plane (D1 advisory): borderline-band → decision.match tie-break. + // Deterministisk status GÄLLER om decision abstainar (doctor DEC-*: + // decision informerar, ersätter aldrig D0-authority). + if (erDecisionEnabled() && Math.abs(rounded - reviewThreshold) <= DECISION_BORDERLINE_BAND) { + const m = await erMatchDecision({ + record_a: a.record_id, + record_b: b.record_id, + similarities: explanation.map((item) => ({ field: item.field, similarity: item.similarity, weight: item.weight })), + }); + if (m.decided && m.label) status = m.label as ErCandidateStatus; + } return { candidate_id: `erc_${crypto.randomUUID()}`, record_a: a.record_id, @@ -314,3 +326,7 @@ function levenshtein(a: string, b: string): number { } return prev[n]; } + + +// --- DECISION-plane wiring (D1 advisory, aldrig gate) ------------------- +const DECISION_BORDERLINE_BAND = Number(process.env.DECISION_BORDERLINE_BAND ?? 0.05); diff --git a/tests/p0/entity-resolution.test.ts b/tests/p0/entity-resolution.test.ts index 07938dd..d69b4a8 100644 --- a/tests/p0/entity-resolution.test.ts +++ b/tests/p0/entity-resolution.test.ts @@ -30,7 +30,7 @@ function request(overrides: Record = {}) { } describe("similarity primitives", () => { - it("normalize folds diacritics + punctuation (Göteborg ≡ goteborg — same block); similarity is 1 for equal, 0 for null", () => { + it("normalize folds diacritics + punctuation (Göteborg ≡ goteborg — same block); similarity is 1 for equal, 0 for null", async () => { expect(normalize("Göteborg!")).toBe("goteborg"); expect(normalize("Göteborg")).toBe(normalize("goteborg")); expect(normalize("Ørsted")).toBe("orsted"); @@ -41,18 +41,18 @@ describe("similarity primitives", () => { }); describe("EntityResolutionService — candidate API", () => { - it("blocking limits comparisons to same-block pairs and reports the reduction", () => { + it("blocking limits comparisons to same-block pairs and reports the reduction", async () => { const er = new EntityResolutionService(); - const res = er.generateCandidates(request()); + const res = await er.generateCandidates(request()); // 4 records = 6 possible pairs; blocking on city → (a1,a2) + (b1,b2) = 2. expect(res.total_possible_comparisons).toBe(6); expect(res.blocked_comparisons).toBe(2); expect(res.pairs).toHaveLength(2); }); - it("scores near-duplicates high (match) and different companies lower, with per-field explanations", () => { + it("scores near-duplicates high (match) and different companies lower, with per-field explanations", async () => { const er = new EntityResolutionService(); - const res = er.generateCandidates(request()); + const res = await er.generateCandidates(request()); const volvo = res.pairs.find((p) => p.record_a === "a1")!; const eric = res.pairs.find((p) => p.record_a === "b1")!; expect(volvo.score).toBeGreaterThan(eric.score); @@ -63,15 +63,15 @@ describe("EntityResolutionService — candidate API", () => { expect(volvo.explanation[1].similarity).toBe(1); }); - it("hands mid-band pairs to human review, never auto-merging them", () => { + it("hands mid-band pairs to human review, never auto-merging them", async () => { const er = new EntityResolutionService(); - const res = er.generateCandidates(request({ auto_match_threshold: 0.95, review_threshold: 0.3 })); + const res = await er.generateCandidates(request({ auto_match_threshold: 0.95, review_threshold: 0.3 })); const eric = res.pairs.find((p) => p.record_a === "b1")!; expect(eric.status).toBe("pending_review"); expect(res.review_queue.map((p) => p.candidate_id)).toContain(eric.candidate_id); }); - it("product hard rules override the generic score in BOTH directions (§10 hook)", () => { + it("product hard rules override the generic score in BOTH directions (§10 hook)", async () => { const er = new EntityResolutionService(); er.registerHardRule({ rule_id: "vehiq.vin_equality", @@ -80,7 +80,7 @@ describe("EntityResolutionService — candidate API", () => { return a.fields.vin === b.fields.vin ? "force_match" : "force_non_match"; }, }); - const res = er.generateCandidates(request()); + const res = await er.generateCandidates(request()); const volvo = res.pairs.find((p) => p.record_a === "a1")!; const eric = res.pairs.find((p) => p.record_a === "b1")!; expect(volvo.status).toBe("match"); @@ -91,19 +91,19 @@ describe("EntityResolutionService — candidate API", () => { expect(eric.decided_by).toBe("vehiq.vin_equality"); }); - it("refuses unblocked all-pairs runs and invalid thresholds", () => { + it("refuses unblocked all-pairs runs and invalid thresholds", async () => { const er = new EntityResolutionService(); - expect(() => er.generateCandidates(request({ blocking_fields: [] }))).toThrowError(/blocking_fields/); - expect(() => + await expect(er.generateCandidates(request({ blocking_fields: [] }))).rejects.toThrowError(/blocking_fields/); + await expect( er.generateCandidates(request({ auto_match_threshold: 0.5, review_threshold: 0.6 })) - ).toThrowError(/review_threshold/); - expect(() => er.generateCandidates(request({ records: [RECORDS[0]] }))).toThrowError(/between 2 and 5000/); - expect(() => er.generateCandidates(request({ tenant_id: "" }))).toThrowError(/tenant_id/); + ).rejects.toThrowError(/review_threshold/); + await expect(er.generateCandidates(request({ records: [RECORDS[0]] }))).rejects.toThrowError(/between 2 and 5000/); + await expect(er.generateCandidates(request({ tenant_id: "" }))).rejects.toThrowError(/tenant_id/); }); - it("records with empty blocking keys are never blocked together", () => { + it("records with empty blocking keys are never blocked together", async () => { const er = new EntityResolutionService(); - const res = er.generateCandidates( + const res = await er.generateCandidates( request({ records: [ { record_id: "x1", fields: { name: "A", city: null } }, -- 2.54.0