feat(decision): Jev ER auto-match shadow (0PT_DECISION_ER_SHADOW)
CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 40s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m0s
Security Scan (SIAX Cloud) / security (push) Successful in 1m34s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m30s
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / quality (pull_request) Failing after 24s
CI (SIAX Cloud) / quality (push) Failing after 33s

Shadow-eval av D0 auto-match: D0-tröskeln förblir auktoritet,
kvaliteten mäts fire-and-forget mot siax-decision (:4600,
decisionId 0pt.er.auto_match). Gate 0PT_DECISION_ER_SHADOW=1
(OFF by default). Fail-closed: gate-off/fel/ABSTAINED påverkar
aldrig det deterministiska svaret; decision-planen loggar inbyggt
(AUD0).

- packages/decision: erAutoMatchShadow + erAutoMatchShadowEnabled
- src/er/comparePair: void erAutoMatchShadow(...).catch(() => null)
- tests/p0/decision-er-shadow.test.ts: gate-off/on, POST-body,
  DECIDED/ABSTAINED, fetch-fel, fire-and-forget-wire (16 nya tester)
This commit is contained in:
Claude Code
2026-09-23 16:53:55 +02:00
parent 63a837ff1e
commit dd4731aa3c
3 changed files with 198 additions and 1 deletions
+67
View File
@@ -60,3 +60,70 @@ export function erDecisionEnabled(): boolean {
const v = process.env.OPT_DECISION_ER_ENABLED;
return v === "1" || v === "true";
}
// --- Shadow-eval (Jev-everywhere): auto-match-kvalitet mäts, påverkar ALDRIG ---
export interface ErAutoMatchShadowInput {
tenant_id: string;
/** Målentiteten som auto-matchats av D0-tröskeln. */
target: { record_id: string; fields?: Record<string, unknown> };
/** Kandidaterna (tie-set) som D0 matchade mot målet. */
candidates: Array<{ record_id: string; fields?: Record<string, unknown> }>;
/** D0-score som triggade auto-matchen (skickas som kontext till beslutsplanet). */
score?: number;
}
export interface ErShadowResult {
decided: boolean;
choice?: string;
confidence?: number;
model?: string;
latencyMs?: number;
}
/** Shadow-gate: 0PT_DECISION_ER_SHADOW=1 (OFF by default — fire-and-forget). */
export function erAutoMatchShadowEnabled(): boolean {
return process.env["0PT_DECISION_ER_SHADOW"] === "1";
}
/**
* Shadow-eval av D0 auto-match: POST /decision/match (decisionId
* `0pt.er.auto_match`). D0-tröskeln förblir auktoritet — svaret används endast
* för kalibrering (decision-planen loggar inbyggt → AUD0). Fail-closed:
* gate-off, nätverksfel, timeout eller ABSTAINED → null / decided:false,
* kastar ALDRIG.
*/
export async function erAutoMatchShadow(
input: ErAutoMatchShadowInput,
): Promise<ErShadowResult | null> {
if (!erAutoMatchShadowEnabled()) return null;
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.auto_match",
left: input.target,
right: input.candidates,
instructions: "Which candidate is the same entity as the target?",
minConfidence: 0.7,
context: { tenant_id: input.tenant_id, d0_score: input.score },
}),
signal: AbortSignal.timeout(DECISION_TIMEOUT_MS),
});
if (!res.ok) return null;
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 };
return {
decided: true,
choice: String(d.choice ?? ""),
confidence: d.confidence,
model: d.model,
latencyMs: d.latencyMs,
};
} catch {
return null;
}
}
+12 -1
View File
@@ -9,7 +9,7 @@
* an adapter to this canonical decision engine.
*/
import crypto from "crypto";
import { erMatchDecision, erDecisionEnabled } from "@siax/0pt-decision";
import { erMatchDecision, erDecisionEnabled, erAutoMatchShadow } from "@siax/0pt-decision";
import type { N0dClient, N0dJob } from "../clients/n0d-client";
import { SiaxError } from "../platform/errors";
import { isResourceRef } from "../platform/resource-ref";
@@ -247,6 +247,17 @@ export class EntityResolutionService {
});
if (m.decided && m.label) status = m.label as ErCandidateStatus;
}
// Shadow-eval (Jev-everywhere): D0 auto-match förblir auktoritet —
// kvaliteten mäts fire-and-forget mot beslutsplanet (0PT_DECISION_ER_SHADOW,
// OFF by default). Hängande/felande fetch påverkar ALDRIG svaret.
if (rounded >= autoThreshold) {
void erAutoMatchShadow({
tenant_id: req.tenant_id,
target: { record_id: a.record_id, fields: a.fields },
candidates: [{ record_id: b.record_id, fields: b.fields }],
score: rounded,
}).catch(() => null);
}
return {
candidate_id: `erc_${crypto.randomUUID()}`,
record_a: a.record_id,
+119
View File
@@ -0,0 +1,119 @@
/**
* Jev shadow-eval av ER auto-match (0PT_DECISION_ER_SHADOW):
* gate-off → ingen fetch; gate-on → rätt POST-body + DECIDED parsad;
* ABSTAINED → decided:false; fetch-fel → null (fail-closed);
* wirat anrop är fire-and-forget — hängande fetch bryter aldrig
* det deterministiska D0-svaret.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { erAutoMatchShadow, type ErAutoMatchShadowInput } from "@siax/0pt-decision";
import { EntityResolutionService, type ErRecord } from "@/src/er/entity-resolution-service";
const GATE = "0PT_DECISION_ER_SHADOW";
const INPUT: ErAutoMatchShadowInput = {
tenant_id: "t_a",
target: { record_id: "a1", fields: { name: "Volvo AB" } },
candidates: [{ record_id: "a2", fields: { name: "Volvo Aktiebolag" } }],
score: 0.95,
};
function fetchMock(payload: unknown, ok = true) {
return vi.fn().mockResolvedValue({ ok, json: async () => payload });
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("erAutoMatchShadow — gate", () => {
it("gate OFF → ingen fetch, null (default: shadow är av)", async () => {
vi.stubEnv(GATE, "");
const f = fetchMock({});
vi.stubGlobal("fetch", f);
expect(await erAutoMatchShadow(INPUT)).toBeNull();
expect(f).not.toHaveBeenCalled();
});
});
describe("erAutoMatchShadow — gate ON", () => {
it("DECIDED → rätt POST-body + svar parsat", async () => {
vi.stubEnv(GATE, "1");
const f = fetchMock({
data: { status: "DECIDED", choice: "0", confidence: 0.91, model: "jev", latencyMs: 120 },
});
vi.stubGlobal("fetch", f);
const out = await erAutoMatchShadow(INPUT);
expect(out).toEqual({ decided: true, choice: "0", confidence: 0.91, model: "jev", latencyMs: 120 });
expect(f).toHaveBeenCalledTimes(1);
const [url, init] = f.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/\/decision\/match$/);
const body = JSON.parse(String(init.body));
expect(body.decision).toBe("0pt.er.auto_match");
expect(body.left).toEqual(INPUT.target);
expect(body.right).toEqual(INPUT.candidates);
expect(body.instructions).toBe("Which candidate is the same entity as the target?");
expect(init.method).toBe("POST");
});
it("ABSTAINED → decided:false (aldrig fabrikerat beslut)", async () => {
vi.stubEnv(GATE, "1");
vi.stubGlobal("fetch", fetchMock({ data: { status: "ABSTAINED", confidence: 0.3 } }));
const out = await erAutoMatchShadow(INPUT);
expect(out).toEqual({ decided: false, confidence: 0.3 });
});
it("HTTP-fel (res.ok false) → null", async () => {
vi.stubEnv(GATE, "1");
vi.stubGlobal("fetch", fetchMock({}, false));
expect(await erAutoMatchShadow(INPUT)).toBeNull();
});
it("fetch kastar (decision-nere/timeout) → null, kastar aldrig", async () => {
vi.stubEnv(GATE, "1");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
expect(await erAutoMatchShadow(INPUT)).toBeNull();
});
});
describe("EntityResolutionService — shadow-wire (fire-and-forget)", () => {
const RECORDS: ErRecord[] = [
{ record_id: "a1", fields: { name: "Volvo Personvagnar AB", city: "goteborg", vin: "YV1" } },
{ record_id: "a2", fields: { name: "Volvo Personvagnar Aktiebolag", city: "goteborg", vin: "YV1" } },
];
function wireRequest() {
return {
tenant_id: "t_a",
project_id: "p_1",
records: RECORDS,
blocking_fields: ["city"],
compare_fields: [{ field: "name", weight: 2 }, { field: "city" }],
// Tvinga auto-match-bandet (score hamnar annars i review-bandet med defaults).
auto_match_threshold: 0.5,
review_threshold: 0.3,
};
}
it("gate ON + hängande fetch (aldrig resolving) → auto-match-status oförändrad", async () => {
vi.stubEnv(GATE, "1");
vi.stubGlobal("fetch", vi.fn().mockReturnValue(new Promise(() => {})));
const er = new EntityResolutionService();
const res = await er.generateCandidates(wireRequest());
expect(res.pairs).toHaveLength(1);
expect(res.pairs[0].status).toBe("match");
expect(res.pairs[0].decided_by).toBe("score");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it("gate OFF → ingen fetch vid auto-match", async () => {
vi.stubEnv(GATE, "");
const f = fetchMock({});
vi.stubGlobal("fetch", f);
const er = new EntityResolutionService();
const res = await er.generateCandidates(wireRequest());
expect(res.pairs[0].status).toBe("match");
expect(f).not.toHaveBeenCalled();
});
});