Chapter 09 · Bridge Tables and Many-to-Many Analytical Relationships

Validate Many-to-Many Queries with Control Totals to Prove No Double Counting

Build an executable bridge acceptance suite covering row multiplication, weight normalization, missing membership, temporal correctness, allocation reconciliation, and safe rollups.

Intermediate → Advanced120–140 minutesBridge acceptance suitePython stdlib sqlite3 · local/syntheticLast reviewed: September 2026

Learning outcomes

A bridge is safe only when its invariants are executable. AtlasMart therefore tests the base fact grain, joined cardinality, normalized weights, historical allocation distribution, grand-total reconciliation, temporal coverage, overlap absence, and negative cases. This acceptance suite is designed to fail loudly when a bridge load or BI query silently changes semantics.

01

Assemble bridge correctness into a deterministic local test suite.

02

Assert both atomic control totals and allocated rollup totals.

03

Inject missing membership and bad weights to prove tests detect failures.

04

Validate temporal distribution, not merely the grand total.

05

Publish production acceptance criteria covering semantics, lineage, observability, and rollback.

Chapter 09 continuity contract

Chapter 09 starts from the accepted Chapter 08 state. Canonical paid sales remain seven order-line facts, four paid orders, nine units, 625 paid GMV, 380 cost-at-sale, and 245 gross profit. Customer history keeps Chapter 07's half-open business-effective intervals; Chapter 08's date-role, junk, degenerate, mini-dimension, and inferred-member patterns remain unchanged. Chapter 09 adds a synthetic customer-to-household many-to-many relationship solely to teach bridge semantics. The bridge does not alter the grain of fact_sales_history, does not rewrite customer surrogate keys, and does not redefine the accepted sales measures.

Execution and interpretation note

The mandatory lab uses Python's standard-library sqlite3 module and synthetic local data. Record the actual Python and SQLite versions before execution. Allocation weights, household membership, household types, and effective dates are explicit AtlasMart teaching contracts, not universal business rules. The local fixture proves relational and arithmetic semantics in one process; it does not prove distributed concurrency, cloud optimizer behavior, CDC ordering, production privacy policy, or performance at warehouse scale.

Important boundary

No single invariant is enough. A bridge can reconcile while using the wrong history, have valid history while missing facts, or have complete coverage while using weights that do not follow the approved business rule.

1. Acceptance matrix

Invariant Expected Failure means
base fact controls 7 rows; 4 orders; 9 units; 625 GMV; 380 cost; 245 profit upstream fact drift or wrong grain
temporal join cardinality 11 rows relationship population changed; investigate before comparing money
unweighted joined GMV 900 in deliberate counterexample confirms why raw sum after bridge is unsafe
weight normalization zero violating intervals allocation contract broken
temporal coverage zero missing allocatable fact rows bridge missing relationship
same-member overlap zero rows ambiguous temporal membership version
weighted grand total 625 allocation loses or multiplies money
weighted household distribution 50/225/200/75/75 temporal/business rule drift
SUM(DISTINCT amount) 375 in counterexample proves DISTINCT is not a grain repair

2. Executable acceptance script

verify_ch09.py
import sqlite3# build_conn() seeds the deterministic Chapter 09 fixture.conn = build_conn()assert base_controls(conn) == (7,4,9,625,380,245)joined = conn.execute("""SELECT COUNT(*),SUM(f.extended_amount)FROM fact_sales_history fJOIN bridge_customer_household b  ON b.durable_customer_id=f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_to""").fetchone()assert joined == (11,900), joinedassert conn.execute("SELECT SUM(DISTINCT extended_amount) FROM fact_sales_history").fetchone()[0] == 375assert weight_interval_audit(conn) == []assert overlap_audit(conn) == []assert allocation_rows(conn) == EXPECTED_ALLOCassert round(sum(v for _,v in allocation_rows(conn)),2) == 625.00missing = conn.execute("""SELECT COUNT(*)FROM fact_sales_history fLEFT JOIN bridge_customer_household b  ON b.durable_customer_id=f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_toWHERE b.household_sk IS NULL""").fetchone()[0]assert missing == 0# Negative test 1: remove Ben's membership; coverage must fail and expose 200 GMV.bad = build_conn()bad.execute("DELETE FROM bridge_customer_household WHERE durable_customer_id='D-CUST-002'")miss = bad.execute("""SELECT COUNT(*),SUM(f.extended_amount)FROM fact_sales_history fLEFT JOIN bridge_customer_household b  ON b.durable_customer_id=f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_toWHERE b.household_sk IS NULL""").fetchone()assert miss == (1,200), miss# Negative test 2: corrupt a normalized weight set.bad2 = build_conn()bad2.execute("""UPDATE bridge_customer_householdSET allocation_weight=0.50WHERE durable_customer_id='D-CUST-001'  AND household_sk=1 AND effective_from='2026-01-01'""")assert weight_interval_audit(bad2) != []# Negative test 3: current membership can preserve 625 but distort history.current = conn.execute("""WITH current_bridge AS (  SELECT * FROM bridge_customer_household  WHERE effective_from <= '2026-09-20' AND '2026-09-20' < effective_to)SELECT h.household_code,ROUND(SUM(f.extended_amount*b.allocation_weight),2)FROM fact_sales_history fJOIN current_bridge b ON b.durable_customer_id=f.durable_customer_idJOIN dim_household h ON h.household_sk=b.household_skGROUP BY h.household_code ORDER BY h.household_code""").fetchall()assert round(sum(v for _,v in current),2) == 625.00assert current != EXPECTED_ALLOCprint('base_controls=', base_controls(conn))print('joined_counterexample=', joined)print('historical_allocation=', allocation_rows(conn))print('Chapter 09 acceptance: PASS')

Expected final line: Chapter 09 acceptance: PASS. The negative cases matter as much as the happy path: the suite proves it can detect missing coverage and broken normalization, and it demonstrates a subtle case where a wrong current-membership replay still conserves the grand total.

3. Production observability and lineage

Monitor relationship-row counts, weight-normalization failures, missing-membership amount, bridge overlap violations, allocated-vs-atomic reconciliation delta, and the age/version of membership data. Lineage should connect the source membership decision to bridge rows, allocation definition, transformed metric, and consuming report. A 0.00 reconciliation delta without bridge version metadata is not enough for audit.

4. Performance without sacrificing correctness

Bridge joins can expand rows. Index the relationship lookup columns appropriate to the engine and measure representative workloads, but never “optimize” by removing the temporal predicate or weight multiplication unless the business question changes. Pre-aggregated or semantic-layer views can simplify BI, provided their grain, freshness, and reconciliation contracts remain explicit.

This SQLite lab is intentionally tiny and in-memory; it provides no warehouse-scale latency or optimizer claim. Any production benchmark must disclose engine, data size, relationship cardinality distribution, indexes/partitioning, cache state, query corpus, and concurrency.

5. Change and rollback contract

A bridge policy change can restate historical allocated metrics without changing any atomic fact. Treat that as a semantic migration: version the rule, identify affected intervals/facts, calculate before/after controls, validate downstream reports, communicate ownership, and retain a rollback path. If the new bridge load fails coverage or normalization, keep the prior published version rather than partially exposing mixed semantics.

6. Cleanup/reset

The mandatory fixture is in-memory SQLite; closing the process discards it, and build_conn() restores the deterministic accepted state. No cloud account, production data, credentials, or destructive external cleanup is required.

Knowledge check

Check your understanding

  1. Why does the acceptance suite assert joined row count as well as money totals?
  2. What negative case proves missing bridge coverage is visible?
  3. Why is the current-membership negative test especially important?
  4. Which bridge metrics should production observability expose?
  5. What should happen when an allocation policy changes?
Review the answers

1. Cardinality is direct evidence of relationship expansion and helps diagnose multiplication before aggregate values hide it.

2. Deleting D-CUST-002 membership produces one unresolved fact row carrying 200 GMV.

3. It shows the grand total can still reconcile while historical household attribution is wrong.

4. Coverage/unallocated amount, weight normalization, overlap violations, relationship cardinality, reconciliation delta, freshness/version, and lineage.

5. Treat it as a versioned semantic migration with impact analysis, restatement policy, reconciliation, downstream validation, communication, and rollback.

Summary and next step

Chapter 09 now has a measurable bridge contract: relationship expansion is visible, allocation is governed, temporal history is reconstructed as-of event time, and control totals prove conservation without confusing conservation with semantic correctness. Chapter 10 next separates transaction, periodic snapshot, and accumulating snapshot fact designs.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.