Files
0pt/services/api/workers/resolve.py
T
Simon AxelssonandClaude Opus 5 9267576b7c
CI (SIAX Cloud) / integration (push) Successful in 42s
CI (SIAX Cloud) / quality (push) Successful in 1m17s
CI (SIAX Cloud) / security (push) Successful in 46s
Security Scan (SIAX Cloud) / security (push) Successful in 31s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
feat(data-os): P060-P068 durable ingestion plane — source classification gate, connector contract, common envelope, schema evolution, quality enforcement, queryable lineage, idempotent backfill
BEFORE_STATE (verified, with evidence):

* P060 PARTIAL/BROKEN. The registry persisted rights/provenance/health/
  freshness to Postgres, but carried no authorization, no schedule, no
  checkpoint, no schema and no owner distinct from created_by — and the
  activation DoD was inverted: postgres-source-repository.ts wrote
  `${input.status ?? "active"}`, so a source with no rights, no provenance
  and no authorization was ACTIVE the instant it was registered. No
  activation operation existed at all.
* P061 MISSING. services/api/sources/connectors.py had per-source fetch_*()
  functions only — no lifecycle, no cursor, no resume, no health, no
  conformance suite.
* P062 MISSING, already documented as such in
  services/api/tests/test_known_gaps.py:10-19 (no CDC pipeline anywhere;
  workers/pipeline.py has no persistence at all).
* P063 MISSING. schema_version existed as a free string; no comparison,
  no policy, no versioned source schema.
* P064 PARTIAL. Rules/runs/issues/scores existed, but a failing rule only
  opened an issue and the data flowed on. No quarantine anywhere.
* P065 DUPLICATE (confirmed). Two ER engines: src/er/entity-resolution-
  service.ts (canonical) and services/api/workers/resolve.py (simplified,
  still called by pipeline.py:110).
* P067 PARTIAL. Lineage was emitted to AUD0 but nothing here could ANSWER a
  lineage question — no store, no query, no route.
* P068 MISSING, documented in test_known_gaps.py:44-52 ("no delivery-store
  persistence layer exists to dedupe a backfill against").

BUILT (only what was PARTIAL/BROKEN/MISSING):

* P060 sql/007_ingestion_plane.sql adds authorization/schedule/schemas/
  checkpoints/ledger/quarantine/lineage tables, owner_id, and flips the
  opt_sources status DEFAULT to 'pending' so even a raw psql INSERT cannot
  produce an unclassified-but-active source. SourceRegistryService gains
  classifySource/activateSource/deactivateSource + authorization/schedule
  accessors; registration NEVER activates (asking for status:"active" goes
  through the gate, not around it); a service identity cannot authorize a
  source. Routes: activate (GET classification / POST activate),
  authorization, schedule.
* P061 src/connectors/connector.ts — all nine operations on one interface.
  src/connectors/conformance.ts — a runnable suite (15 checks, no "skip"
  verdict). HttpJsonConnector (real HTTP, opaque cursor) and
  JsonlFileConnector (real file, line offsets, CDC deletes) both pass it.
  The suite caught a real defect while being written: an API that drops
  next_cursor at the end left the connector unable to resume, which is why
  HttpJsonConnectorConfig has cursor_field.
* P062 src/types/ingestion.ts + src/ingestion/{envelope,ingestion-runner}.ts.
  One envelope for batch/stream/cdc; durable per-(source,partition)
  checkpoints; resume proven by killing a run inside the sink.
* P063 src/schema/{record-schema,compatibility,schema-registry}.ts. Two
  enforcement points, both upstream of any consumer write: a discovery gate
  before the first read, and per-record conformance before the page reaches
  the sink. A producer cannot widen its own policy retroactively.
* P064 src/quality/quality-policy.ts composes the EXISTING ExpectationEngine
  (not a second engine) and adds the missing consequence: block /
  quarantine / warn, with quarantine durable and recoverable. No policy is
  reported as "none_declared", never as a pass; an unexecutable expectation
  counts as a violation, never a silent pass.
* P067 src/lineage/{lineage-edge,lineage-store}.ts + GET /api/0pt/lineage.
  AUD0 stays the system of record; this is 0pt's own queryable projection.
* P068 opt_ingestion_ledger + IngestionRunner.backfill: bounded, idempotent
  on repeat, leaves the live checkpoint alone, still re-emits real changes.

A BUG THIS PASS WROTE AND CAUGHT: the first IngestionRepository draft
combined the idempotency check and its recording into one atomic call
BEFORE the sink. The crash/resume test then delivered 7 of 10 records — the
page in flight when the sink died was already marked delivered, so the
resumed run skipped it as a duplicate. Permanent, silent data loss. Fixed by
splitting peekDeliveries/commitDeliveries and committing both the ledger and
the checkpoint only after the sink resolves.

NOT DONE, stated rather than deferred quietly:

* P065 is NOT met. resolve.py is still a second simplified production
  engine. Closing it means rewriting it as an adapter over the canonical
  service, which cannot be verified from this repo today: services/api is
  not built by this repo's Dockerfile, api.0pt.app answers 404, and NO CI
  workflow runs its pytest suite. The duplication is now stated at both
  sites in code and the removal plan is in STATUS.md. The new ingestion
  plane performs no ER, so it adds no third engine.
* P066 (Iceberg) untouched — ST0RE is owned by another workflow. Contract
  read only.
* Delivery is AT-LEAST-ONCE, not exactly-once (no per-partition run lock).
* No CDC *source* exists; the contract is met, a CDC connector is not built.
* 0pt does not schedule itself — the schedule row is a declaration ACT0
  would read.

VERIFIED: pnpm typecheck clean; pnpm test 365 passed / 25 skipped (was
311/18); pnpm build clean with all four new routes in the route table; lint
unchanged at 12 pre-existing errors. Real Postgres 16
(tests/p0/ingestion-plane-postgres.realinfra.test.ts, 7 passed) plus a real
`docker restart` of that container with an identical read-back through the
production code path — that is P060's "a restart preserves all sources",
measured.

DEPLOYMENT REALITY (curl, 2026-09-04): 0pt.app/api/health returns 200 from
Vercel with db:not_configured; 0pt.app/api/0pt/sources returns 404;
api.0pt.app/health returns 404; cd.yml still guards on a placeholder Coolify
app id. None of the capability layer runs in production. Nothing here is
LIVE_VERIFIED and STATUS.md says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:03:14 +02:00

118 lines
5.9 KiB
Python

"""
Entity resolution (docs/06): block, score, and link records across sources so the
same company from GLEIF + Brreg + CVR collapses to one record with corroborated
confidence. rapidfuzz only; deterministic; no external calls.
=======================================================================
P065 — THIS IS THE SECOND ER ENGINE. IT IS NOT THE CANONICAL ONE.
=======================================================================
0pt has TWO entity-resolution implementations, and this is the simplified
one:
* CANONICAL: src/er/entity-resolution-service.ts — the declared generic ER
capability. Deterministic blocking + an explainable per-pair score,
product-supplied HARD RULES that override the score in both directions,
a configurable review band that hands ambiguous pairs to humans instead
of auto-merging, and delegation of heavy probabilistic linkage (Splink)
to 0pt-motor via the N0D job "0pt.er.batch_resolution".
* THIS FILE: blocking on (market, first initial) + a single
rapidfuzz token_sort_ratio threshold, first-match-wins, auto-merge with
no review band, no hard-rule hook, no explanation beyond the score.
P065's definition of done is that no second simplified production engine
remains. It has NOT been met: this file is still what
`workers/pipeline.py` calls (line ~110, `deduped = resolve(records)`).
WHY IT WAS NOT REPLACED IN THE P060-P068 PASS, stated plainly rather than
quietly deferred: replacing it means turning this module into an ADAPTER
that calls the canonical capability, and that change cannot be verified
from this repo today —
* `services/api` is not deployed by this repo's Dockerfile (which builds
only the Next.js app), and api.0pt.app answers 404, so there is no
running engine to test against;
* no CI workflow runs services/api's pytest suite at all (verified:
`grep -rn "pytest" .gitea/workflows .github/workflows` finds nothing),
so a rewrite here would land with zero automated coverage.
Rewriting it blind and calling P065 done would be exactly the
"claimed complete, broken in operation" failure this programme exists to
stop. The removal plan is recorded in STATUS.md.
Until then: do NOT add capability to this file. Any new ER behaviour
belongs in the canonical TypeScript service.
Deterministic by construction: for a fixed `records` input, blocking is a pure
function of (market, name-first-letter) and matching is first-match-wins
within a block, so results do not depend on which source answered first — see
tests/p0/test_resolve_determinism.py, which asserts this holds across
shuffled input orderings (BEFORE_STATE.md §4 flagged this as untested; it is
order-SENSITIVE only in the sense that ties within a block resolve to
whichever candidate appears earliest in `out`, which is itself deterministic
given a fixed `records` order — the test pins exactly that contract).
"""
from __future__ import annotations
from typing import Any
from rapidfuzz import fuzz
def block_key(rec: dict[str, Any]) -> tuple[str, str]:
"""Cheap blocking key — same market + first initial — to avoid O(n^2)."""
return (rec.get("market") or "", (rec.get("name") or "")[:1].upper())
def resolve(records: list[dict[str, Any]], threshold: int = 92) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for r in records:
match = None
match_score: float | None = None
for o in out:
if block_key(o) != block_key(r):
continue
score = fuzz.token_sort_ratio(o.get("name", ""), r.get("name", ""))
if score >= threshold:
match = o
match_score = score
break
if match:
match.setdefault("merged_from", []).append(r.get("source"))
# Merge the losing record's per-field provenance regardless of source
# novelty below, so the surviving record's provenance always reflects
# every contributing record's fields.
existing_provenance = match.setdefault("provenance", [])
seen_provenance = {(p.get("field_name"), p.get("source_id")) for p in existing_provenance}
for p in r.get("provenance", []):
key = (p.get("field_name"), p.get("source_id"))
if key not in seen_provenance:
existing_provenance.append(p)
seen_provenance.add(key)
# Corroboration (confidence/agreed_sources bump) requires an
# *independent* source — two hits from the same connector must not
# count as two agreeing sources.
seen_sources = match.setdefault("_sources_seen", {match.get("source")} - {None})
src = r.get("source")
if src is not None and src not in seen_sources:
match["confidence"] = min(0.99, match.get("confidence", 0.0) + 0.1)
match["agreed_sources"] = match.get("agreed_sources", 1) + 1
seen_sources.add(src)
# Record the entity-resolution decision on every provenance row the
# surviving record now carries (BEFORE_STATE.md §3: no per-field
# entity_resolution tracking existed anywhere in the repo). Rows on
# a record that was never merged into anything keep
# entity_resolution == None — honest, not backfilled with a guess.
er_info = {
"method": "blocking+fuzzy_token_sort_ratio",
"block_key": list(block_key(match)),
"match_score": match_score,
"threshold": threshold,
"merged_sources": sorted(s for s in match["_sources_seen"] if s is not None),
}
for p in existing_provenance:
p["entity_resolution"] = er_info
else:
out.append({**r, "agreed_sources": 1, "_sources_seen": {r.get("source")} - {None}})
for o in out:
o.pop("_sources_seen", None)
return out