feat: decision-plane wiring — ER-pair decision.match tie-break (@siax/0pt-decision) #100

Merged
admin merged 1 commits from feat/decision-plane into main 2026-09-22 22:29:43 +00:00
7 changed files with 125 additions and 23 deletions
+1 -1
View File
@@ -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);
+10
View File
@@ -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'" }
}
+62
View File
@@ -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";
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
+2
View File
@@ -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:
+21 -5
View File
@@ -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<ErCandidateResult> {
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<ErCandidatePair> {
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);
+17 -17
View File
@@ -30,7 +30,7 @@ function request(overrides: Record<string, unknown> = {}) {
}
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 } },