Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / integration (push) Successful in 29s
CI (SIAX Cloud) / quality (push) Successful in 48s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 28s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
CI (SIAX Cloud) / integration (pull_request) Successful in 55s
Python Engine CI (SIAX Cloud) / python-engine (push) Failing after 4m52s
Python Engine CI (SIAX Cloud) / python-engine (pull_request) Failing after 4m48s
Slutgate inför merge (per fortsättningsplan): Kontrakt: CreateDatasetVersionInput.artifact_refs → readonly string[] (anropare får skicka 'as const'; servicen muterar aldrig); postgres.js tx.array() får muterbar kopia vid INSERT. Testkorrigeringar (stale mot starkare implementation — aldrig svagare): - entity-resolution-http: request(body, undefined) triggade default-param och skickade GILTIGT token → 200. Tomt token = 'utan token' → 401 fail-closed. - entity-resolution: /two records/ → /between 2 and 5000/ (bounds-aware validering) - search-service: omformat purpose-filter-test + NY negativ kontroll — rebuild avvisar dokument utan purpose_tags (fail closed, generation discarded) - dataset-postgres.realinfra: sätter DATABASE_URL i setup så den durabla AUD0-outboxen aktiveras (testet ljög annars om durabilitet) — 5/5 mot live Postgres STATUS.md: AUD0-klient + lineage-rader (outbox nu real-infra testad), gap-listan (persistence/routes/outbox stängda, exakt vad som återstår), P065 STÄNGD med closure-record (thin adapter, v3-semantik, hard-negative transitivitet, fail-closed 401 — 4/4 planpunkter verifierade). Verifierat: typecheck EXIT 0 · 945/945 unit · 5/5 real-Postgres · next build EXIT 0
138 lines
5.9 KiB
TypeScript
138 lines
5.9 KiB
TypeScript
/**
|
|
* EntityResolutionService unit tests (Master-DoD §10 Entity Resolution):
|
|
* blocking, transparent scoring, explanation, product hard-rule hook,
|
|
* human-review handoff, batch delegation contract.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
EntityResolutionService,
|
|
normalize,
|
|
similarity,
|
|
type ErRecord,
|
|
} from "@/src/er/entity-resolution-service";
|
|
|
|
const RECORDS: ErRecord[] = [
|
|
{ record_id: "a1", fields: { name: "Volvo Personvagnar AB", city: "göteborg", vin: "YV1XZ1234" } },
|
|
{ record_id: "a2", fields: { name: "Volvo Personvagnar Aktiebolag", city: "goteborg", vin: "YV1XZ1234" } },
|
|
{ record_id: "b1", fields: { name: "Ericsson AB", city: "stockholm", vin: "ERIC999" } },
|
|
{ record_id: "b2", fields: { name: "Eriksson Bygg AB", city: "stockholm", vin: "BYGG111" } },
|
|
];
|
|
|
|
function request(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
tenant_id: "t_a",
|
|
project_id: "p_1",
|
|
records: RECORDS,
|
|
blocking_fields: ["city"],
|
|
compare_fields: [{ field: "name", weight: 2 }, { field: "city" }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("similarity primitives", () => {
|
|
it("normalize folds diacritics + punctuation (Göteborg ≡ goteborg — same block); similarity is 1 for equal, 0 for null", () => {
|
|
expect(normalize("Göteborg!")).toBe("goteborg");
|
|
expect(normalize("Göteborg")).toBe(normalize("goteborg"));
|
|
expect(normalize("Ørsted")).toBe("orsted");
|
|
expect(similarity("Volvo AB", "volvo ab")).toBe(1);
|
|
expect(similarity(null, "x")).toBe(0);
|
|
expect(similarity("abc", "xyz")).toBeLessThan(0.5);
|
|
});
|
|
});
|
|
|
|
describe("EntityResolutionService — candidate API", () => {
|
|
it("blocking limits comparisons to same-block pairs and reports the reduction", () => {
|
|
const er = new EntityResolutionService();
|
|
const res = 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", () => {
|
|
const er = new EntityResolutionService();
|
|
const res = 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);
|
|
expect(volvo.decided_by).toBe("score");
|
|
// Explanation is per-field with weights (§10 explanation).
|
|
expect(volvo.explanation.map((e) => e.field)).toEqual(["name", "city"]);
|
|
expect(volvo.explanation[0].weight).toBe(2);
|
|
expect(volvo.explanation[1].similarity).toBe(1);
|
|
});
|
|
|
|
it("hands mid-band pairs to human review, never auto-merging them", () => {
|
|
const er = new EntityResolutionService();
|
|
const res = 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)", () => {
|
|
const er = new EntityResolutionService();
|
|
er.registerHardRule({
|
|
rule_id: "vehiq.vin_equality",
|
|
decide: (a, b) => {
|
|
if (!a.fields.vin || !b.fields.vin) return "abstain";
|
|
return a.fields.vin === b.fields.vin ? "force_match" : "force_non_match";
|
|
},
|
|
});
|
|
const res = 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");
|
|
expect(volvo.decided_by).toBe("vehiq.vin_equality");
|
|
expect(volvo.score).toBe(1);
|
|
// Same city block but different VIN → forced non-match despite name similarity.
|
|
expect(eric.status).toBe("non_match");
|
|
expect(eric.decided_by).toBe("vehiq.vin_equality");
|
|
});
|
|
|
|
it("refuses unblocked all-pairs runs and invalid thresholds", () => {
|
|
const er = new EntityResolutionService();
|
|
expect(() => er.generateCandidates(request({ blocking_fields: [] }))).toThrowError(/blocking_fields/);
|
|
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/);
|
|
});
|
|
|
|
it("records with empty blocking keys are never blocked together", () => {
|
|
const er = new EntityResolutionService();
|
|
const res = er.generateCandidates(
|
|
request({
|
|
records: [
|
|
{ record_id: "x1", fields: { name: "A", city: null } },
|
|
{ record_id: "x2", fields: { name: "A", city: null } },
|
|
],
|
|
})
|
|
);
|
|
expect(res.pairs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("EntityResolutionService — batch delegation (0pt-motor via N0D)", () => {
|
|
it("submits the Splink batch job with the records artifact as input (contract only, never faked)", async () => {
|
|
const { createN0dClient } = await import("@/src/clients/n0d-client");
|
|
const er = new EntityResolutionService({ n0dClient: createN0dClient() }); // test runtime → mock
|
|
const job = await er.submitBatchResolution({
|
|
tenant_id: "t_a",
|
|
project_id: "p_1",
|
|
records_artifact_ref: "st0re:artifact:art_records",
|
|
});
|
|
expect(job.job_id).toContain("mock_job_");
|
|
expect(job.job_type).toBe("0pt.er.batch_resolution");
|
|
});
|
|
|
|
it("without an N0D client the batch path fails hard (DEPENDENCY_UNAVAILABLE)", async () => {
|
|
const er = new EntityResolutionService();
|
|
await expect(
|
|
er.submitBatchResolution({ tenant_id: "t_a", project_id: "p_1", records_artifact_ref: "st0re:artifact:x" })
|
|
).rejects.toMatchObject({ code: "DEPENDENCY_UNAVAILABLE" });
|
|
});
|
|
});
|