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
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
227 lines
8.1 KiB
Python
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
|