diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index dd49a26..0a40563 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -340,7 +340,13 @@ CREATE TABLE IF NOT EXISTS claims ( last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), raw JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id) + -- NULLS NOT DISTINCT (PG15+) is load-bearing: user_id is NULL for every + -- objective claim, and with default NULLS DISTINCT semantics the + -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter never fire, so + -- re-enrichment inserts duplicates instead of reinforcing. + CONSTRAINT claims_edge_source_user_key + UNIQUE NULLS NOT DISTINCT + (subject_type, subject_id, predicate, object_type, object_id, source, user_id) ); CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index f7b2c3f..0da2109 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -504,6 +504,90 @@ const MIGRATIONS: { id: string; sql: string }[] = [ JOIN artists a ON a.id = cf.object_id; `, }, + { + id: '20260730_claims_dedup_nulls_not_distinct', + sql: ` + -- The claims uniqueness constraint was declared as a plain + -- UNIQUE (subject_type, subject_id, predicate, object_type, object_id, + -- source, user_id). Every objective claim (MB, Discogs, tags) has + -- user_id IS NULL, and Postgres treats NULLs as distinct, so none of the + -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter ever fired: + -- re-enrichment inserted a fresh duplicate row every time instead of + -- reinforcing. claim_fusion is SUM(trust * confidence * recency), so a + -- duplicated edge carried N times its intended weight. + -- + -- Two phases, in this order (the constraint cannot be added while + -- duplicates exist): + -- 1. collapse each duplicate group into its most recently reinforced + -- row, carrying forward MAX(last_reinforced_at) / MAX(evidence_at) / + -- MAX(confidence) so reinforcement recency is not lost; + -- 2. replace the constraint with a NULLS NOT DISTINCT version (PG15+). + + -- Phase 1: dedup. + CREATE TEMP TABLE claims_dedup ON COMMIT DROP AS + SELECT + id, + ROW_NUMBER() OVER ordered AS rn, + -- These MUST use the unordered window: an ORDER BY in the window spec + -- makes the default frame "UNBOUNDED PRECEDING TO CURRENT ROW", turning + -- MAX() into a running maximum rather than a per-group one. + MAX(last_reinforced_at) OVER grp AS max_last_reinforced_at, + MAX(evidence_at) OVER grp AS max_evidence_at, + MAX(confidence) OVER grp AS max_confidence + FROM claims + WINDOW + grp AS ( + PARTITION BY subject_type, subject_id, predicate, object_type, object_id, + source, + COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) + ), + ordered AS ( + grp ORDER BY last_reinforced_at DESC, evidence_at DESC, id + ); + + -- Keeper of each group absorbs the group's best values. + UPDATE claims c + SET last_reinforced_at = d.max_last_reinforced_at, + evidence_at = d.max_evidence_at, + confidence = d.max_confidence + FROM claims_dedup d + WHERE c.id = d.id + AND d.rn = 1; + + DELETE FROM claims c + USING claims_dedup d + WHERE c.id = d.id + AND d.rn > 1; + + -- Phase 2: replace the constraint. The live DB has drifted from + -- schema.sql, so find the existing constraint by its definition rather + -- than assuming Postgres' auto-generated name. + DO $mig$ + DECLARE + cname TEXT; + BEGIN + FOR cname IN + SELECT con.conname + FROM pg_constraint con + WHERE con.conrelid = 'claims'::regclass + AND con.contype = 'u' + AND pg_get_constraintdef(con.oid) LIKE '%subject_type%' + AND pg_get_constraintdef(con.oid) NOT LIKE '%NULLS NOT DISTINCT%' + LOOP + EXECUTE format('ALTER TABLE claims DROP CONSTRAINT %I', cname); + END LOOP; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'claims'::regclass AND conname = 'claims_edge_source_user_key' + ) THEN + ALTER TABLE claims ADD CONSTRAINT claims_edge_source_user_key + UNIQUE NULLS NOT DISTINCT + (subject_type, subject_id, predicate, object_type, object_id, source, user_id); + END IF; + END $mig$; + `, + }, ]; export class DbService {