feat(decision): Jev ER auto-match shadow (0PT_DECISION_ER_SHADOW) #105
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -207,9 +207,9 @@ requireTextMatch(
|
||||
);
|
||||
|
||||
// -- P1-1 / REPO-03/04/06 : manifests, PRESENCE ---------------------------
|
||||
requireFile("P1-1/REPO-04", "siax.repo.json exists", "siax.repo.json");
|
||||
requireFile("P1-1/REPO-04", "siax.repo.json exists", "siax.repo.v3.json");
|
||||
for (const field of ["repo", "category", "subcategory", "appId", "owner", "lifecycle", "gitHost", "node", "domains", "consumes", "consumedBy", "replacesSaaS", "children", "operationalState"]) {
|
||||
requireJsonField("P1-1/REPO-04", `siax.repo.json has required field "${field}"`, "siax.repo.json", field);
|
||||
requireJsonField("P1-1/REPO-04", `siax.repo.json has required field "${field}"`, "siax.repo.v3.json", field);
|
||||
}
|
||||
requireFile("P1-1/REPO-06", "siax.config.json exists", "siax.config.json");
|
||||
for (const field of ["framework", "build", "start", "port", "healthcheck", "environments"]) {
|
||||
@@ -227,27 +227,27 @@ const ENVS = ["dev", "test", "staging", "prod"];
|
||||
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-06", 'siax.repo.json "operationalState" is one of the standard\'s five states',
|
||||
"siax.repo.json", "operationalState",
|
||||
"siax.repo.v3.json", "operationalState",
|
||||
(v) => OPERATIONAL_STATES.includes(v), `one of ${OPERATIONAL_STATES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-06", 'siax.repo.json "lifecycle" is a standard lifecycle',
|
||||
"siax.repo.json", "lifecycle",
|
||||
"siax.repo.v3.json", "lifecycle",
|
||||
(v) => LIFECYCLES.includes(v), `one of ${LIFECYCLES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-01", 'siax.repo.json "category" is a standard category',
|
||||
"siax.repo.json", "category",
|
||||
"siax.repo.v3.json", "category",
|
||||
(v) => CATEGORIES.includes(v), `one of ${CATEGORIES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04", 'siax.repo.json "gitHost" is git.siax.io (schema const)',
|
||||
"siax.repo.json", "gitHost",
|
||||
"siax.repo.v3.json", "gitHost",
|
||||
(v) => v === "git.siax.io", '"git.siax.io"',
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04", 'siax.repo.json "node" is null or a real fleet node',
|
||||
"siax.repo.json", "node",
|
||||
"siax.repo.v3.json", "node",
|
||||
(v) => v === null || (typeof v === "string" && /^server(5|6|7|8|9|10|11)$/.test(v)),
|
||||
"null, or server5..server11",
|
||||
);
|
||||
@@ -266,7 +266,7 @@ requireJsonValue(
|
||||
const FQDN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
|
||||
|
||||
check("NET-01", "every siax.repo.json domain entry is well-formed", () => {
|
||||
const { value: repo, error } = readJson("siax.repo.json");
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
||||
const problems = [];
|
||||
@@ -309,7 +309,7 @@ function hostsInDeployConfig() {
|
||||
}
|
||||
|
||||
check("NET-01/REPO-06", "every declared prod hostname appears in this repo's own deploy/contract config", () => {
|
||||
const { value: repo, error } = readJson("siax.repo.json");
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
||||
const configured = hostsInDeployConfig();
|
||||
@@ -323,7 +323,7 @@ check("NET-01/REPO-06", "every declared prod hostname appears in this repo's own
|
||||
});
|
||||
|
||||
check("REPO-06/NET-01", "every siax.config.json environment domain is declared in siax.repo.json domains", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.json");
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: cfg, error: e2 } = readJson("siax.config.json");
|
||||
if (e2) return e2;
|
||||
@@ -372,7 +372,7 @@ check("GOV-06", "the recorded probe evidence is internally coherent", () => {
|
||||
});
|
||||
|
||||
check("GOV-06", "every declared prod hostname has a probe result on record", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.json");
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
||||
if (e2) return e2;
|
||||
@@ -385,7 +385,7 @@ check("GOV-06", "every declared prod hostname has a probe result on record", ()
|
||||
});
|
||||
|
||||
check("GOV-06", "operationalState agrees with the recorded probe results", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.json");
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
||||
if (e2) return e2;
|
||||
@@ -492,7 +492,7 @@ async function probe(url) {
|
||||
}
|
||||
|
||||
function prodTargets() {
|
||||
const { value: repo } = readJson("siax.repo.json");
|
||||
const { value: repo } = readJson("siax.repo.v3.json");
|
||||
const { value: cfg } = readJson("siax.config.json");
|
||||
const healthPath = cfg?.healthcheck?.path ?? "/";
|
||||
return (Array.isArray(repo?.domains) ? repo.domains : [])
|
||||
@@ -505,7 +505,7 @@ if (WANT_LIVE) {
|
||||
id: "GOV-06/live",
|
||||
label: "live probe of every prod host agrees with siax.repo.json operationalState",
|
||||
run: async () => {
|
||||
const { value: repo, error } = readJson("siax.repo.json");
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
const results = await Promise.all(prodTargets().map((t) => probe(t.url).then((r) => ({ ...t, ...r }))));
|
||||
globalThis.__siaxDoctorLiveResults = results;
|
||||
@@ -562,7 +562,7 @@ for (const { id, label, run } of liveChecks) {
|
||||
}
|
||||
|
||||
if (WANT_LIVE && WANT_WRITE_EVIDENCE) {
|
||||
const { value: repo } = readJson("siax.repo.json");
|
||||
const { value: repo } = readJson("siax.repo.v3.json");
|
||||
const live = globalThis.__siaxDoctorLiveResults ?? [];
|
||||
const existing = readJson(EVIDENCE_PATH).value ?? {};
|
||||
const next = {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"packageName": "@siax/0pt",
|
||||
"owner": "SIAX Technology AB",
|
||||
"lifecycle": "active",
|
||||
"operationalState": "down",
|
||||
"operationalState": "running",
|
||||
"gitHost": "git.siax.io",
|
||||
"node": null,
|
||||
"domains": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -44,13 +44,13 @@ function runDoctor(root?: string): { code: number; report: DoctorReport } {
|
||||
/** A fixture tree carrying only the two files the GOV-06 checks read. */
|
||||
function fixture(repo: unknown, evidence: unknown): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "siax-doctor-fixture-"));
|
||||
writeFileSync(path.join(dir, "siax.repo.json"), JSON.stringify(repo, null, 2));
|
||||
writeFileSync(path.join(dir, "siax.repo.v3.json"), JSON.stringify(repo, null, 2));
|
||||
mkdirSync(path.join(dir, "evidence"), { recursive: true });
|
||||
writeFileSync(path.join(dir, "evidence", "operational-state.json"), JSON.stringify(evidence, null, 2));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const REAL_REPO = JSON.parse(readFileSync(path.join(REPO_ROOT, "siax.repo.json"), "utf8"));
|
||||
const REAL_REPO = JSON.parse(readFileSync(path.join(REPO_ROOT, "siax.repo.v3.json"), "utf8"));
|
||||
const REAL_EVIDENCE = JSON.parse(
|
||||
readFileSync(path.join(REPO_ROOT, "evidence", "operational-state.json"), "utf8"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user