Files
stack/tests/aco/test_load_bcda.py
kert a05cc51916
Some checks failed
Infra CI / notebooks (push) Successful in 1m6s
CI / lint (push) Successful in 46s
CI / notebooks-smoke (push) Successful in 1m44s
CI / test (push) Failing after 2m36s
Infra CI / zotero (push) Successful in 21s
Infra CI / llm (push) Successful in 55s
Infra CI / bcda (push) Successful in 1m38s
Infra CI / docs (push) Successful in 1m46s
Infra CI / api (push) Successful in 1m8s
Infra CI / mc (push) Failing after 36s
CI / lint (pull_request) Successful in 43s
CI / test (pull_request) Failing after 2m30s
Infra CI / zotero (pull_request) Successful in 15s
Infra CI / llm (pull_request) Successful in 16s
CI / notebooks-smoke (pull_request) Successful in 1m52s
Infra CI / docs (pull_request) Successful in 15s
Infra CI / api (pull_request) Successful in 15s
Infra CI / notebooks (pull_request) Successful in 49s
Infra CI / bcda (pull_request) Successful in 18s
Infra CI / mc (pull_request) Failing after 35s
feat(bcda): incremental _since pulls — watermarks, temporality checks, reconciliation with tiebreakers, ledger and invariants
Design: docs/superpowers/specs/2026-10-25-bcda-since-reconciliation-design.md.

Until now a _since export was loaded like a full one: the loader took the
newest export directory per type, so a delta silently replaced the baseline
it was meant to extend. bcda.sync makes incremental pulls safe end to end.

First order — which row is current. A baseline (no _since) is the
authoritative snapshot for its types; deltas of the same population at or
after its cut-off extend it. Keys: Patient BENE_SK, Coverage id, EOB claim
control number (each claim *version* has a new FHIR id — migration guide).
Winner per key: newest meta.lastUpdated, then NCH/DDPS over SharedSystem
(the connector's rule), then later transactionTime, later applied sequence,
larger id. NotFinalAction winners stay as tombstones; only a baseline
retires rows (the API cannot express deletion). eob_item follows its header.

Second order — what a job did, per table, in the ledger: new / replaced /
unchanged / stale_ignored, backfill (lastUpdated < _since for an enrollee we
did not have — the API's promise for newly attributed enrollees on
Group/all) vs late (same shape for one we had — should not happen),
tombstoned, adjusted (clm_orig_cntl_num → bcda.eob_supersession), retired,
orphans (claims/coverage without a current Patient), scope drift.

Third order — invariants on every apply before anything is published:
unique keys, count conservation (|cur'| = |cur| + new − retired), watermark
monotone, every item has a header; replay determinism and idempotency are
tested (apply in either order → byte-identical tables; re-apply → no-op).
Current tables are re-derived from the applied partitions each time, so the
state is a function of the set of applied jobs, not their arrival order.

Temporality checks before a _since export starts (block/warn/ok): format,
future (server 400s), floor (no v3 lastUpdated before 2026-03-26),
no_baseline, gap (since > watermark: a window nobody would ever fetch),
overlap (free), endpoint_mix (runout vs current population),
backfill_scope (/Patient delta over a Group/all baseline), scope_filter
(_typeFilter drift), inflight, unapplied, attribution_newer,
stale_baseline. next_since() is the previous job's transactionTime, as
BCDA recommends; a combined job takes the earliest watermark of its types.

Store: job rows carry transaction_time and request, exports/<key>/
manifest.json keeps the completed-job body, resume() fills both in for
jobs completed before this; adopted jobs read _since from their request
line; a delta with nothing to deliver (200, empty output) still counts and
advances the watermark. flatten_export(out_dir=) writes per-job partitions
under flat/history/<key>/; current tables keep _job_key/_transaction_time/
_since/_kind/_applied_seq and the connector's _ensure() drops them before
the stage contracts. load_bcda(reconcile=True) applies pending jobs and
also loads bcda.sync_ledger, bcda.sync_watermark, bcda.eob_supersession;
an explicit ndjson_dir or a store without job rows keeps the legacy path.

Surfaces: `stack bcda sync status|check|apply|pull`, `stack bcda export
--since auto [--force]`, `stack bcda resume`; the manager's Exports page
gets a _since mode (off / watermark / date) with the checks shown and
blocks respected, and a Sync page (watermarks, pending, reconcile, ledger);
notebook section 8.

Verified on the real Large-entity store: applying the three baselines over
the legacy flat/ tables reported every row unchanged (0 new, 0 retired)
and loaded counts identical to the full load (221,021 medical lines,
11,968 pharmacy, 9,582 eligibility); a real /Patient-endpoint EOB delta
with a _typeFilter reconciled as 248 unchanged with the drift on record;
the sandbox's transactionTime is midnight UTC of the run day, so a
same-day zero-row delta is correctly ignored as predating its baseline.

refs #1139
2026-10-25 16:04:16 -04:00

227 lines
8.1 KiB
Python

"""Tests for BCDA data loading."""
from __future__ import annotations
from pathlib import Path
import pytest
from aco.load.bcda import _find_latest_export
class TestFindLatestExport:
def test_no_exports_dir_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="No exports directory"):
_find_latest_export(tmp_path)
def test_empty_exports_dir_raises(self, tmp_path: Path) -> None:
(tmp_path / "exports").mkdir()
with pytest.raises(FileNotFoundError, match="No export directories"):
_find_latest_export(tmp_path)
def test_finds_latest(self, tmp_path: Path) -> None:
import os
exports = tmp_path / "exports"
exports.mkdir()
(exports / "old_job").mkdir()
(exports / "new_job").mkdir()
# Set old_job mtime to past so new_job is clearly newer
os.utime(exports / "old_job", (1000000, 1000000))
result = _find_latest_export(tmp_path)
assert [d.name for d in result] == ["new_job"]
def test_newest_per_resource_type(self, tmp_path: Path) -> None:
"""Three single-type v3 jobs: each type comes from its own dir."""
import os
exports = tmp_path / "exports"
exports.mkdir()
for i, (name, rtype) in enumerate(
[
("j_eob", "ExplanationOfBenefit"),
("j_pat", "Patient"),
("j_cov", "Coverage"),
]
):
d = exports / name
d.mkdir()
(d / f"{rtype}.ndjson").write_text("{}\n")
os.utime(d, (1000000 + i, 1000000 + i))
# a newer Patient-only job supersedes j_pat
d = exports / "j_pat2"
d.mkdir()
(d / "Patient.ndjson").write_text("{}\n")
os.utime(d, (2000000, 2000000))
result = _find_latest_export(tmp_path)
assert [d.name for d in result] == ["j_eob", "j_cov", "j_pat2"]
class TestLoadV3EndToEnd:
"""Official v3 samples, split like three single-type jobs → DuckDB."""
def test_load_from_three_export_dirs(self, tmp_path: Path) -> None:
import shutil
import duckdb
from aco.load.bcda import load_bcda
from conf import path as conf_path
samples = Path(__file__).resolve().parents[2] / "dev" / "vendor" / "bcda-site"
store = tmp_path / "store"
for job, rtype in (
("j1", "Patient"),
("j2", "Coverage"),
("j3", "ExplanationOfBenefit"),
):
d = store / "exports" / job
d.mkdir(parents=True)
shutil.copy(samples / f"v3_{rtype}.ndjson", d / f"{rtype}.ndjson")
# a second EOB part in the same job dir
shutil.copy(
samples / "v3_ExplanationOfBenefit.ndjson",
store / "exports" / "j3" / "ExplanationOfBenefit.1.ndjson",
)
db = tmp_path / "aco.duckdb"
from unittest.mock import patch
real_path = conf_path
def fake_path(key: str):
if key == "storage.bcda":
return store
return real_path(key)
with patch("conf.path", side_effect=fake_path):
stats = load_bcda(database=str(db), run_pipeline=True)
assert stats == {
"bcda.patient": 94,
"bcda.explanation_of_benefit": 538,
"bcda.eob_item": 2464,
"bcda.coverage": 175,
# the connector pipeline ran and landed input_layer.*; the
# duplicated EOB part collapses on claim control number
"input_layer.medical_claim": 1021,
"input_layer.pharmacy_claim": 102,
"input_layer.eligibility": 94,
}
con = duckdb.connect(str(db), read_only=True)
plans = dict(
con.execute(
"select class_plan, count(*) from bcda.coverage group by 1"
).fetchall()
)
assert set(plans) == {"Part A", "Part B", "Part C", "Dual Medicare/Medicaid"}
sys_types = dict(
con.execute(
"select system_type, count(*) from bcda.explanation_of_benefit group by 1"
).fetchall()
)
assert sys_types == {"NationalClaimsHistory": 282, "DDPS": 256}
assert (
con.execute(
"select count(*) from bcda.patient where bene_mbi_id is null"
).fetchone()[0]
== 0
)
assert (
con.execute(
"select count(distinct claim_id) from input_layer.medical_claim"
).fetchone()[0]
== 131
)
assert (
con.execute(
"select count(*) from input_layer.medical_claim "
"where claim_type = 'institutional' and length(bill_type_code) <> 4"
).fetchone()[0]
== 0
)
con.close()
class TestLoadReconciled:
"""A store with job records goes through bcda.sync, not the newest-dir rule."""
def test_reconcile_path(self, tmp_path: Path) -> None:
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import duckdb
from aco.load.bcda import load_bcda
from bcda import sync
from bcda.store import Store
from conf import path as conf_path
from tests.bcda.test_sync import _rows, add_job
store = Store(path=str(tmp_path / "store"))
t0 = datetime(2026, 10, 1, tzinfo=timezone.utc)
add_job(store, "B_PAT", {"Patient": _rows("Patient")}, transaction_time=t0)
add_job(store, "B_COV", {"Coverage": _rows("Coverage")}, transaction_time=t0)
add_job(
store,
"B_EOB",
{"ExplanationOfBenefit": _rows("ExplanationOfBenefit")},
transaction_time=t0,
)
# a Patient delta that retires nothing and adds one new enrollee
import copy
new_pat = copy.deepcopy(_rows("Patient")[0])
new_pat["id"] = "-424242"
new_pat["meta"]["lastUpdated"] = "2026-10-03T00:00:00.000+00:00"
for ident in new_pat.get("identifier", []): # a new enrollee: new MBI too
ident["value"] = ident["value"] + "X"
add_job(
store,
"D_PAT",
{"Patient": [new_pat]},
since=sync.format_instant(t0),
transaction_time=t0 + timedelta(days=7),
)
real_path = conf_path
def fake_path(key: str):
return tmp_path / "store" if key == "storage.bcda" else real_path(key)
db = tmp_path / "aco.duckdb"
with patch("conf.path", side_effect=fake_path):
stats = load_bcda(database=str(db), run_pipeline=True)
assert stats["bcda.patient"] == 95
assert stats["bcda.explanation_of_benefit"] == 269
assert stats["bcda.eob_item"] == 1232
assert stats["bcda.coverage"] == 175
assert stats["bcda.sync_ledger"] == 4
assert stats["input_layer.eligibility"] == 95
con = duckdb.connect(str(db), read_only=True)
wm = dict(
con.execute(
"select resource_type, job_key from bcda.sync_watermark"
).fetchall()
)
assert wm == {
"Patient": "D_PAT",
"Coverage": "B_COV",
"ExplanationOfBenefit": "B_EOB",
}
assert (
con.execute(
"select count(*) from bcda.patient where _job_key = 'D_PAT'"
).fetchone()[0]
== 1
)
con.close()
# second load: nothing new to apply, same counts, ledger unchanged
with patch("conf.path", side_effect=fake_path):
stats2 = load_bcda(database=str(db), run_pipeline=False)
assert stats2["bcda.patient"] == 95 and stats2["bcda.sync_ledger"] == 4
# skip_flatten reuses the reconciled parquet; reconcile=False with no
# explicit dir falls back to the newest-directory rule (Patient from D_PAT only)
with patch("conf.path", side_effect=fake_path):
assert load_bcda(database=str(db), skip_flatten=True)["bcda.patient"] == 95
legacy = load_bcda(database=str(db), reconcile=False)
assert legacy["bcda.patient"] == 1