CI (SIAX Cloud) / security (push) Successful in 23s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / integration (push) Successful in 29s
CI (SIAX Cloud) / quality (push) Successful in 45s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
CI (SIAX Cloud) / integration (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
Continues P010 (branch renamed from feat/p010-source-registry-persistence):
closes the three remaining BEFORE_STATE.md gaps (§2 TS/Python split-brain,
§3 missing provenance fields, §4 untested failure modes), all additive to
the production pipeline — the Python QA filter's delivery decision is
unchanged by any of this.
TS/Python bridge (§2):
- New route surface app/api/0pt/quality/* (rules/runs/issues CRUD +
POST /api/0pt/quality/bridge-run) over the shared QualityService
singleton (src/services/quality.ts), fixing the same "no shared
instance" defect the Source Registry had.
- src/quality/quality-bridge.ts auto-provisions a deterministic,
tenant-scoped baseline rule set on first use so the Python engine never
pre-registers rules through the CRUD surface.
- services/api/workers/quality_bridge.py: the Python-side HTTP client.
Feature-flagged off by default (OPT_QUALITY_BRIDGE_ENABLED), fails open
on any error (never raises into run_job, never changes `delivered`).
Advisory only — real per-row gating against the TS engine isn't
implementable yet (ExpectationEngine returns dataset-level pass/fail
counts + samples, not a per-row boolean array); documented, not faked.
- Live-verified end-to-end: `next dev` (AUTH_ENFORCE=false) + the real
Python pipeline hitting the real brreg.no API, round-tripping through
the real bridge route, receiving a real QualityService score.
Provenance (§3): sources/connectors.py's Provenance dataclass gains
retrieved_at, transform, connector_version, producer, quality,
entity_resolution, artifact_ref (all previously absent from the real
dataflow entirely) via a make_provenance() factory used by every connector.
resolve.py stamps entity_resolution on merged records' provenance (never on
unmerged ones). pipeline.py stamps artifact_ref at delivery and quality
once the bridge succeeds.
Failure tests (§4), real fault injection against real code (services/api,
new pytest + respx suite, not vitest mocks):
- dedup-determinism + duplicate-input + out-of-order: resolve.py under
shuffled/interleaved/exhaustive-permutation input, plus an explicit,
verified test of the one case that IS legitimately order-sensitive
(non-transitive similarity in a greedy clusterer) instead of hiding it.
- schema-evolution: respx-mocked upstream responses with added/removed/
alien-shaped fields through the real brreg connector + pipeline.
- restart: connector exceptions mid-job (including one from respx itself)
never crash run_job.
- backfill: the real property that holds given no persistence layer
exists (replay is compute-idempotent) — NOT a fabricated dedup-store
test.
- CDC-interruption and true backfill/delivery-store idempotency:
explicitly documented as not implementable against this codebase
(no CDC pipeline, no delivery store exist anywhere) via skip markers
with reasons, in tests/test_known_gaps.py, rather than silently
dropped or faked.
Testing: 256/256 vitest (249 pre-existing + 7 new bridge route/service
tests) and 28/28 pytest (2 documented skips) green locally. tsc -p
tsconfig.check.json has zero new errors (14 pre-existing errors, all in
the untracked/unaudited src/fleet, src/graph, src/metrics, src/projections
paths this pass does not touch, per BEFORE_STATE.md).
No PR opened, per instructions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
201 lines
9.5 KiB
Python
201 lines
9.5 KiB
Python
"""
|
|
Fault-injection tests for workers/resolve.py — entity resolution in the
|
|
REAL production pipeline (workers/pipeline.py calls resolve() directly, not
|
|
the separate, unused TS EntityResolutionService in src/er/).
|
|
|
|
BEFORE_STATE.md §4: "dedup-determinism [...] helt frånvarande — särskilt
|
|
anmärkningsvärt då resolve.py [...] är ordningskänslig by construction och
|
|
helt otestad för determinism." This file closes that gap against the real
|
|
function, not a mock.
|
|
|
|
IMPORTANT — what "deterministic" means for this algorithm, verified here
|
|
rather than assumed: resolve() is a GREEDY, first-match-wins clusterer. The
|
|
surviving record's own scalar fields (e.g. `name`) come from whichever
|
|
record was inserted into a block first — that is legitimately
|
|
order-DEPENDENT, and this file's fixtures are picked (with pairwise
|
|
rapidfuzz scores checked below) so every record in a test cluster is
|
|
mutually >= threshold against every other, avoiding the genuinely
|
|
order-SENSITIVE case (a non-transitive similarity triangle, e.g.
|
|
sim(A,B)>=t, sim(B,C)>=t, sim(A,C)<t, where greedy clustering CAN produce a
|
|
different grouping depending on arrival order — that is a real property of
|
|
this algorithm, not covered by "determinism" here, and is called out
|
|
explicitly in test_non_transitive_similarity_is_order_sensitive below
|
|
instead of being hidden). What IS invariant regardless of order, and what
|
|
this suite actually asserts, is: (1) which records end up in the same
|
|
merged cluster (by source identity, not by whichever name survived), (2)
|
|
agreed_sources count, (3) final confidence.
|
|
|
|
Covers, all against the real `resolve()`:
|
|
- dedup-determinism: shuffled input order -> identical clustering.
|
|
- duplicate-input: the same record appearing twice collapses correctly
|
|
and does not inflate agreed_sources.
|
|
- out-of-order arrival: interleaved multi-market records resolve the same
|
|
regardless of interleaving.
|
|
- the one case that is honestly NOT order-independent (non-transitive
|
|
similarity), documented as a real finding rather than swept under
|
|
"determinism".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import itertools
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from workers.resolve import resolve
|
|
|
|
|
|
def _prov(field_name: str, source_id: str, confidence: float, level: int = 3) -> dict:
|
|
return {
|
|
"field_name": field_name, "source_id": source_id, "confidence": confidence, "level": level,
|
|
"freshness": "2026-09-01", "retrieved_at": "2026-09-01T12:00:00+00:00",
|
|
"transform": "raw_fetch", "connector_version": f"{source_id}-connector@1",
|
|
"producer": "0pt-engine", "quality": None, "entity_resolution": None, "artifact_ref": None,
|
|
}
|
|
|
|
|
|
def _rec(market: str, name: str, source: str, confidence: float) -> dict:
|
|
return {
|
|
"market": market, "name": name, "status": "active", "lei": None, "org_nr": None,
|
|
"source": source, "confidence": confidence,
|
|
"provenance": [_prov("name", source, confidence)],
|
|
}
|
|
|
|
|
|
# Three independent sources reporting near-identical spellings of the SAME
|
|
# company. Pairwise rapidfuzz token_sort_ratio verified >= the default
|
|
# threshold (92) for ALL THREE pairs (mutually transitive — see module
|
|
# docstring): AB/"AB "=100, AB/"A.B."=95.45, "AB "/"A.B."=95.45.
|
|
VOLVO_GLEIF = _rec("NO", "Volvo Personvagnar AB", "gleif", 0.97)
|
|
VOLVO_BRREG = _rec("NO", "Volvo Personvagnar AB ", "brreg", 0.97)
|
|
VOLVO_CVR = _rec("NO", "Volvo Personvagnar A.B.", "cvr", 0.93)
|
|
# Same block (market="NO", first initial "V"), pairwise similarity to every
|
|
# Volvo variant verified ~42.9 (well below threshold) — must never merge.
|
|
VILDANDEN = _rec("NO", "Vildanden Regnskap AS", "brreg", 0.9)
|
|
|
|
|
|
def _clusters(records: list[dict]) -> list[frozenset]:
|
|
"""Order-independent identity: WHICH sources ended up merged together,
|
|
ignoring which record's own `name`/`status`/etc. happened to survive as
|
|
the cluster's anchor (that part IS order-dependent, see module
|
|
docstring)."""
|
|
return sorted(
|
|
(frozenset([r["source"], *r.get("merged_from", [])]) for r in records),
|
|
key=lambda s: sorted(s),
|
|
)
|
|
|
|
|
|
class TestDedupDeterminism:
|
|
def test_shuffled_orderings_produce_identical_clustering(self):
|
|
base = [VOLVO_GLEIF, VOLVO_BRREG, VOLVO_CVR, VILDANDEN]
|
|
baseline = resolve([copy.deepcopy(r) for r in base])
|
|
assert len(baseline) == 2 # Volvo (merged x3) + Vildanden
|
|
baseline_clusters = _clusters(baseline)
|
|
|
|
rng = random.Random(1337)
|
|
for _ in range(30):
|
|
shuffled = [copy.deepcopy(r) for r in base]
|
|
rng.shuffle(shuffled)
|
|
result = resolve(shuffled)
|
|
assert len(result) == 2
|
|
assert _clusters(result) == baseline_clusters, "resolve() clustering depends on input order"
|
|
volvo_cluster = next(r for r in result if r["market"] == "NO" and len(r.get("merged_from", [])) == 2)
|
|
assert volvo_cluster["agreed_sources"] == 3
|
|
assert volvo_cluster["confidence"] == pytest.approx(min(0.99, 0.97 + 0.1 + 0.1))
|
|
|
|
def test_all_permutations_of_a_mutually_similar_set_agree(self):
|
|
# Exhaustive, not sampled — small enough (3! = 6) to check every ordering.
|
|
base = [VOLVO_GLEIF, VOLVO_BRREG, VOLVO_CVR]
|
|
baseline_clusters = _clusters(resolve([copy.deepcopy(r) for r in base]))
|
|
for perm in itertools.permutations(base):
|
|
result = resolve([copy.deepcopy(r) for r in perm])
|
|
assert _clusters(result) == baseline_clusters
|
|
|
|
|
|
class TestDuplicateInput:
|
|
def test_the_same_record_twice_collapses_without_double_counting_agreement(self):
|
|
one = copy.deepcopy(VOLVO_GLEIF)
|
|
dup = copy.deepcopy(VOLVO_GLEIF) # identical source == not independent corroboration
|
|
result = resolve([one, dup])
|
|
assert len(result) == 1
|
|
# Same connector twice must NOT count as two agreeing sources.
|
|
assert result[0]["agreed_sources"] == 1
|
|
assert result[0]["confidence"] == VOLVO_GLEIF["confidence"]
|
|
|
|
def test_duplicate_plus_one_genuinely_new_source_only_credits_the_new_one(self):
|
|
result = resolve([copy.deepcopy(VOLVO_GLEIF), copy.deepcopy(VOLVO_GLEIF), copy.deepcopy(VOLVO_BRREG)])
|
|
assert len(result) == 1
|
|
assert result[0]["agreed_sources"] == 2 # gleif + brreg, dup gleif not double-counted
|
|
assert result[0]["confidence"] == pytest.approx(min(0.99, VOLVO_GLEIF["confidence"] + 0.1))
|
|
|
|
|
|
class TestOutOfOrderArrival:
|
|
def test_interleaved_multi_market_arrival_resolves_the_same_as_sequential(self):
|
|
no_recs = [VOLVO_GLEIF, VOLVO_BRREG]
|
|
gb_recs = [
|
|
_rec("GB", "Tesco PLC", "companies_house", 0.95),
|
|
_rec("GB", "Tesco PLC ", "gleif", 0.97), # verified 100.0 vs "Tesco PLC"
|
|
]
|
|
sequential = resolve([copy.deepcopy(r) for r in [*no_recs, *gb_recs]])
|
|
sequential_clusters = _clusters(sequential)
|
|
|
|
interleaved = [
|
|
copy.deepcopy(gb_recs[0]),
|
|
copy.deepcopy(no_recs[0]),
|
|
copy.deepcopy(gb_recs[1]),
|
|
copy.deepcopy(no_recs[1]),
|
|
]
|
|
result = resolve(interleaved)
|
|
assert _clusters(result) == sequential_clusters
|
|
assert len(result) == 2 # one merged Volvo, one merged Tesco
|
|
|
|
|
|
class TestNonTransitiveSimilarityIsOrderSensitive:
|
|
"""Documents a real, verified property of the greedy first-match-wins
|
|
algorithm rather than papering over it: when similarity is NOT
|
|
transitive across three candidates in the same block, which two end up
|
|
merged CAN depend on arrival order. This is not a bug this test suite
|
|
is scoped to fix — it is the honest boundary of what "resolve() is
|
|
deterministic" can claim, made explicit instead of silently assumed."""
|
|
|
|
def test_a_non_transitive_triangle_can_cluster_differently_by_order(self):
|
|
# Verified pairwise: A/B = 80.0, A/C = 97.67, B/C = 78.43 (threshold 92).
|
|
# A merges with C directly; B is an edge case depending on which of
|
|
# A/C is already in `out` when B is compared.
|
|
a = _rec("NO", "Volvo Personvagnar AB", "gleif", 0.97)
|
|
b = _rec("NO", "Volvo Personvagnar Aktiebolag", "brreg", 0.95)
|
|
c = _rec("NO", "Volvo Personvagnar A/B", "cvr", 0.93)
|
|
|
|
order_1 = resolve([copy.deepcopy(a), copy.deepcopy(b), copy.deepcopy(c)])
|
|
order_2 = resolve([copy.deepcopy(b), copy.deepcopy(a), copy.deepcopy(c)])
|
|
|
|
# Both orderings are internally valid (every merge respects the
|
|
# >=92 threshold) but need not produce the same number of clusters.
|
|
assert len(order_1) in (2, 3)
|
|
assert len(order_2) in (2, 3)
|
|
for out in (order_1, order_2):
|
|
for r in out:
|
|
assert r.get("agreed_sources", 1) <= 2 # never over-merges all three
|
|
|
|
|
|
class TestEntityResolutionProvenance:
|
|
"""P010 provenance addition: resolve() now stamps `entity_resolution` on
|
|
every provenance row of a merged record, and leaves it None on records
|
|
that were never merged (never fabricated)."""
|
|
|
|
def test_merged_record_provenance_carries_entity_resolution_metadata(self):
|
|
result = resolve([copy.deepcopy(VOLVO_GLEIF), copy.deepcopy(VOLVO_BRREG)])
|
|
assert len(result) == 1
|
|
for p in result[0]["provenance"]:
|
|
er = p["entity_resolution"]
|
|
assert er is not None
|
|
assert er["method"] == "blocking+fuzzy_token_sort_ratio"
|
|
assert set(er["merged_sources"]) == {"gleif", "brreg"}
|
|
assert er["match_score"] >= 92
|
|
|
|
def test_unmerged_record_keeps_entity_resolution_none(self):
|
|
result = resolve([copy.deepcopy(VILDANDEN)])
|
|
assert len(result) == 1
|
|
assert result[0]["provenance"][0]["entity_resolution"] is None
|