feat: decision-plane wiring — ER-pair decision.match tie-break (@siax/0pt-decision) #100
@@ -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);
|
||||
|
||||
@@ -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'" }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+2
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user