verify+fix: Pi's dedup + Convex-spike designs build-ready [CC]

- dedup: separate client-mapped plan_type (not payer) so CoverageLine never guesses plan type; fix merge_quantities to sum across distinct component configs (blanket-max dropped qty in mixed dup+split groups)
- convex-spike: shipments orderNumber/hcpcs -> arrays (post-dedup multi-value); de-stale dedup note to LOCKED
- ledger: Design Verification section added

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-06-24 15:53:10 -04:00
parent 0e5c2f13b7
commit 6f3ce16960
3 changed files with 36 additions and 18 deletions

View file

@ -6,6 +6,7 @@
**Approves:** Kisa **Approves:** Kisa
**Source:** docs/convex-signal-instance-brief-2026-06-24.md **Source:** docs/convex-signal-instance-brief-2026-06-24.md
**Status:** Design input. This is a SEPARATE, parallel spike in repo `signal-convex`. The current Signal build keeps running. **Status:** Design input. This is a SEPARATE, parallel spike in repo `signal-convex`. The current Signal build keeps running.
**Verification fixes (Claude, 2026-06-24):** `shipments.orderNumber`/`hcpcs` changed to arrays to match the deduped multi-value model; §11 dedup note updated (the key is now LOCKED). Carry the dedup doc's plan_type fix: grade on the client-mapped `planType`, never the payer name.
--- ---
@ -214,10 +215,10 @@ export default defineSchema({
shipments: defineTable({ shipments: defineTable({
lineId: v.id("coverageLines"), lineId: v.id("coverageLines"),
dateOfService: v.string(), // YYYY-MM-DD dateOfService: v.string(), // YYYY-MM-DD
quantity: v.number(), quantity: v.number(), // total after dedup (sum across distinct component configs)
components: v.array(v.string()), components: v.array(v.string()),
orderNumber: v.string(), orderNumbers: v.array(v.string()), // post-dedup: collected, display-only
hcpcs: v.string(), hcpcs: v.array(v.string()), // post-dedup: collected, display-only
isCurrent: v.boolean(), isCurrent: v.boolean(),
}).index("by_line", ["lineId"]) }).index("by_line", ["lineId"])
.index("by_line_dos", ["lineId", "dateOfService"]), .index("by_line_dos", ["lineId", "dateOfService"]),
@ -513,7 +514,7 @@ The priority is proving the core thesis: CSV-in → grade → worklist-display o
### Dedup key (carried from readiness-model decision) ### Dedup key (carried from readiness-model decision)
**Pending Kisa's decision** — same patient + device + DOS under two order numbers → one worklist line or two? The spike design supports both. The `shipments` table has `orderNumber` as a displayed field (not a dedup key), and the dedup logic lives in `readiness/engine.ts`. When Kisa decides, one line changes in the engine. **LOCKED 2026-06-24** — key = `(patient_id, device_type, date_of_service)`; order numbers are display-only (stored as `orderNumbers: v.array`), never in the dedup key. Full logic (tiebreakers, quantity-merge guard, test cases) in `docs/dedup-design-2026-06-24.md`. Dedup runs inside the CSV import mutation, before grading.
### Data-access interface boundary ### Data-access interface boundary

View file

@ -4,6 +4,7 @@
**Design by:** Pi (decision locked by Kisa) **Design by:** Pi (decision locked by Kisa)
**Builds by:** Claude (Phase: patient/line/shipment grouping + required-vs-input verdict) **Builds by:** Claude (Phase: patient/line/shipment grouping + required-vs-input verdict)
**Source:** docs/readiness-model-brief-2026-06-23.md, Kisa dedup decision 2026-06-24 **Source:** docs/readiness-model-brief-2026-06-23.md, Kisa dedup decision 2026-06-24
**Verification fixes (Claude, 2026-06-24):** (1) added a separate client-mapped `plan_type` field, distinct from display `payer`, so CoverageLine keys on the mapped plan type and never the payer name (locked 2026-06-23); (2) fixed `merge_quantities` to sum across distinct component configs instead of taking a blanket max.
--- ---
@ -58,7 +59,8 @@ class MergedShipment:
components: list[str] # combined set, deduped components: list[str] # combined set, deduped
order_numbers: list[str] # collected, displayed only order_numbers: list[str] # collected, displayed only
hcpcs_codes: list[str] # collected, displayed only hcpcs_codes: list[str] # collected, displayed only
payer: str # taken from the LATEST row in the group payer: str # raw payer NAME from the LATEST row — DISPLAY ONLY
plan_type: str # REQUIRED client-mapped plan type (medicare|medicare_advantage|medicaid|commercial|unknown); NEVER derived from payer name; used for CoverageLine keying + grading
# CSV doc fields — merged from all rows, latest non-null wins # CSV doc fields — merged from all rows, latest non-null wins
csv_visit_date: date | None csv_visit_date: date | None
csv_swo_status: str | None csv_swo_status: str | None
@ -91,7 +93,7 @@ When multiple CSV rows have the same `(patient_id, device_type, DOS)`:
| Normal split | Row A qty=3, Row B qty=1 | total_qty = 4 | Sensor + transmitter = 4 components | | Normal split | Row A qty=3, Row B qty=1 | total_qty = 4 | Sensor + transmitter = 4 components |
| Duplicate | Row A qty=3, Row B qty=3 (same components) | total_qty = 6 | ⚠️ This IS the risk of summing: a data-entry duplicate doubles the quantity. Mitigation: flag rows where components are identical across rows in the same group. If all fields except order_number match exactly, it is a likely duplicate → take max, not sum. | | Duplicate | Row A qty=3, Row B qty=3 (same components) | total_qty = 6 | ⚠️ This IS the risk of summing: a data-entry duplicate doubles the quantity. Mitigation: flag rows where components are identical across rows in the same group. If all fields except order_number match exactly, it is a likely duplicate → take max, not sum. |
| Zero row | Row A qty=3, Row B qty=0 | total_qty = 3 | Zero-quantity rows are informational (e.g., a cancelation) and do not add to quantity | | Zero row | Row A qty=3, Row B qty=0 | total_qty = 3 | Zero-quantity rows are informational (e.g., a cancelation) and do not add to quantity |
| Wildly different | Row A qty=3, Row B qty=100 | total_qty = 103 | This is almost certainly an error. Flag for review. For the pilot, take max (100) and log a warning. | | Wildly different | Row A qty=3, Row B qty=100 (same component) | total_qty = 100 | Almost certainly an error. Same component config, so take max (100, not sum); flag for review and log a warning. |
### Recommended default for quantity ambiguity ### Recommended default for quantity ambiguity
@ -99,18 +101,25 @@ When multiple CSV rows have the same `(patient_id, device_type, DOS)`:
```python ```python
def merge_quantities(rows_in_group: list[CSVRow]) -> int: def merge_quantities(rows_in_group: list[CSVRow]) -> int:
"""Sum quantities within a dedup group. """Sum quantities across DISTINCT component configs within a dedup group,
If two rows have identical components, take max instead of sum while collapsing exact-duplicate rows.
(likely a data-entry duplicate, not a real split)."""
component_sets = [set(row.components.split(",")) for row in rows_in_group]
unique_component_configs = set(frozenset(cs) for cs in component_sets)
if len(unique_component_configs) < len(rows_in_group): Group rows by their component set. Within one config, identical rows are
# At least one component config appears more than once likely data-entry duplicates, so take the MAX for that config (not the sum).
# Take the maximum quantity, not the sum (defense against dupes) Then SUM across distinct configs (a real component split, e.g. sensor + transmitter).
return max(row.quantity for row in rows_in_group)
return sum(row.quantity for row in rows_in_group) Fixes the mixed case: two duplicate [sensor] rows + one [transmitter] row
-> max(sensor)=3 + max(transmitter)=1 = 4, not a blanket global max of 3.
"""
from collections import defaultdict
by_config: dict[frozenset, list[int]] = defaultdict(list)
for row in rows_in_group:
config = frozenset(row.components.split(","))
by_config[config].append(row.quantity)
# Per distinct config: max (defend against duplicate rows). Across configs: sum.
return sum(max(qtys) for qtys in by_config.values())
``` ```
--- ---
@ -125,11 +134,11 @@ def assign_to_coverage_lines(merged_shipments: list[MergedShipment]) -> dict[Cov
lines: dict[CoverageLineKey, list[MergedShipment]] = {} lines: dict[CoverageLineKey, list[MergedShipment]] = {}
for shipment in merged_shipments: for shipment in merged_shipments:
# Capline: plan_type comes from client's CSV mapping, not from guessing # plan_type is the client-mapped REQUIRED field, never the payer name (locked 2026-06-23)
key = CoverageLineKey( key = CoverageLineKey(
patient_id=shipment.patient_id, patient_id=shipment.patient_id,
device_type=shipment.device_type, device_type=shipment.device_type,
plan_type_id=shipment.payer, # The client-mapped plan type, not the raw payer name plan_type_id=shipment.plan_type, # mapped plan type; payer name is display-only
) )
lines.setdefault(key, []).append(shipment) lines.setdefault(key, []).append(shipment)

View file

@ -51,3 +51,11 @@ Canonical, git-tracked record of **what shipped, what didn't, and the reason or
## Maintenance ## Maintenance
Update this whenever an item ships or a blocker changes. Move shipped items to a `## Shipped (archive)` section when the table grows. Both agents reason from this; Kisa validates and redirects. Update this whenever an item ships or a blocker changes. Move shipped items to a `## Shipped (archive)` section when the table grows. Both agents reason from this; Kisa validates and redirects.
## Design Verification (2026-06-24, Claude)
Pi's dedup design (item 9) and Convex-spike design (item 6) were verified line-by-line. Both are build-ready; fixes applied directly to the design docs:
- **dedup-design-2026-06-24.md**: (1) added a separate client-mapped `plan_type` field, distinct from display `payer`, so CoverageLine keys on the mapped plan type, never the payer name — the original draft reintroduced the killed plan-type-guessing bug. (2) Fixed `merge_quantities` to sum across distinct component configs; the old blanket-max dropped quantity in mixed duplicate+split groups (e.g. two [sensor] + one [transmitter] returned 3 instead of 4).
- **convex-signal-spike-design-2026-06-24.md**: `shipments.orderNumber`/`hcpcs` changed to arrays (post-dedup multi-value); §11 dedup note de-staled to LOCKED; carry-forward note to grade on the mapped `planType`.
- Noted, not a blocker: "Convex auth -> Clerk = config-only swap" is optimistic; treat as low-but-nonzero effort (Convex Auth still maturing).