mrfkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mrfkit/tic.py ADDED
@@ -0,0 +1,660 @@
1
+ """Read insurer Transparency in Coverage (TiC) files.
2
+
3
+ Two kinds of file are read:
4
+
5
+ - An in-network rates file: ``provider_references`` lists who (provider
6
+ groups: a TIN and its NPIs) and ``in_network`` lists what each billing code
7
+ pays them. :func:`iter_tic_in_network` reads it.
8
+ - A table of contents: ``reporting_structure`` lists plans and the URLs of
9
+ their in-network files. :func:`iter_tic_index` reads it.
10
+
11
+ Allowed-amount and prescription-drug files are not supported.
12
+
13
+ Licensed text: an in-network item's ``name``, ``description`` and
14
+ ``covered_services``, and a bundled code's ``description``, are payer or AMA
15
+ CPT descriptor text. No record ever carries them. An unknown key is counted in
16
+ :attr:`ParseStats.unmapped_paths` by its JSON path only, never with its
17
+ value, so a payer's own free-text field cannot leak descriptor text through
18
+ that door either.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import itertools
24
+ import re
25
+ from collections import Counter
26
+ from contextlib import contextmanager
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Iterable, Iterator, Optional, Set, Union
30
+
31
+ import ijson
32
+
33
+ from .codes import normalize_code
34
+ from .files import detect_file_format, open_json
35
+ from .records import TicFileMetadata, TicIndexEntry, TicProviderGroup, TicRate
36
+ from .reference import ParseStats
37
+
38
+ # TiC's own "does not expire" date, also used when a price gives none.
39
+ NEVER_EXPIRES = "9999-12-31"
40
+ NPI_MIN = 1_000_000_000
41
+ NPI_MAX = 9_999_999_999
42
+ # Values at or past these are data errors, not prices.
43
+ MAX_ABS_RATE = 1e10
44
+ MAX_ABS_PERCENTAGE = 1e5
45
+ # Distinct prices held for one billing code before they are emitted. One
46
+ # item can carry tens of thousands of negotiated_rates entries, so memory is
47
+ # bounded by this rather than by the largest item in the file. Emitting
48
+ # early only means a price can appear in two records, each with its own
49
+ # provider groups.
50
+ MAX_HELD_RATES = 50_000
51
+
52
+ KNOWN_KEYS = {
53
+ "$": {"reporting_entity_name", "reporting_entity_type", "last_updated_on",
54
+ "version", "provider_references", "in_network"},
55
+ "$.provider_references[]": {"provider_group_id", "provider_groups",
56
+ "network_name", "location"},
57
+ "$.provider_references[].provider_groups[]": {"npi", "tin"},
58
+ "$.provider_references[].provider_groups[].tin": {"type", "value", "business_name"},
59
+ "$.in_network[]": {"negotiation_arrangement", "name", "billing_code_type",
60
+ "billing_code_type_version", "billing_code", "description",
61
+ "negotiated_rates", "bundled_codes",
62
+ # TiC 2.0, for DRG codes. Read past, not emitted.
63
+ "severity_of_illness"},
64
+ "$.in_network[].negotiated_rates[]": {"provider_references", "negotiated_prices",
65
+ "provider_groups"},
66
+ "$.in_network[].negotiated_rates[].provider_groups[]": {"npi", "tin"},
67
+ "$.in_network[].negotiated_rates[].provider_groups[].tin": {"type", "value",
68
+ "business_name"},
69
+ "$.in_network[].negotiated_rates[].negotiated_prices[]": {
70
+ "negotiated_type", "negotiated_rate", "expiration_date", "service_code",
71
+ "billing_class", "billing_code_modifier", "setting", "additional_information",
72
+ },
73
+ "$.in_network[].bundled_codes[]": {"billing_code_type", "billing_code_type_version",
74
+ "billing_code", "description"},
75
+ }
76
+
77
+ # Descriptor text, dropped on purpose: never emitted and never reported.
78
+ IGNORED_ITEM_KEYS = frozenset({"name", "description", "covered_services"})
79
+ IGNORED_BUNDLED_KEYS = frozenset({"description"})
80
+
81
+ NEGOTIATED_TYPES = frozenset({"negotiated", "fee schedule", "percentage", "per diem", "derived"})
82
+ # Per diem and derived prices may carry no value at all.
83
+ _TYPES_REQUIRING_A_VALUE = frozenset({"negotiated", "fee schedule", "percentage"})
84
+ # "both": the rate applies to the professional and the institutional claim.
85
+ BILLING_CLASSES = frozenset({"professional", "institutional", "both"})
86
+ SETTINGS = frozenset({"both", "outpatient", "inpatient"})
87
+
88
+ _PRICES = "$.in_network[].negotiated_rates[].negotiated_prices[]"
89
+ _POS_CODE_RE = re.compile(r"^\d{2}$")
90
+ _NON_DIGIT_RE = re.compile(r"[^0-9]")
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Value helpers
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def _normalize_tin(raw: Any) -> Optional[str]:
98
+ """Digits only. Some plans write an EIN as ``30-0318970`` and others as
99
+ ``300318970``; one form must win or one provider group splits in two."""
100
+ digits = _NON_DIGIT_RE.sub("", str(raw)) if raw is not None else ""
101
+ return digits or None
102
+
103
+
104
+ def _split_modifiers(raw: Any) -> list:
105
+ """Sorted, distinct modifiers. Some plans pack several into one string ("52,53")."""
106
+ out = set()
107
+ for m in raw or []:
108
+ if m is None:
109
+ continue
110
+ for part in str(m).split(","):
111
+ part = part.strip()
112
+ if part:
113
+ out.add(part)
114
+ return sorted(out)
115
+
116
+
117
+ def _valid_npi(raw: Any) -> Optional[int]:
118
+ """A 10-digit NPI, or None for anything non-numeric or out of NPI range."""
119
+ try:
120
+ n = int(raw)
121
+ except (TypeError, ValueError):
122
+ return None
123
+ return n if NPI_MIN <= n <= NPI_MAX else None
124
+
125
+
126
+ def _is_pos_code(value: Any) -> bool:
127
+ return isinstance(value, str) and bool(_POS_CODE_RE.match(value))
128
+
129
+
130
+ def _parse_expiration(raw: Any, stats: ParseStats) -> str:
131
+ """ISO expiration date. Missing means "does not expire" and is not
132
+ reported; a present but unparseable date is reported, then defaulted,
133
+ and the price is kept."""
134
+ if not raw:
135
+ return NEVER_EXPIRES
136
+ try:
137
+ return datetime.strptime(raw, "%Y-%m-%d").date().isoformat()
138
+ except (ValueError, TypeError):
139
+ stats.record_unmapped_path(f"{_PRICES}.expiration_date=unparseable")
140
+ return NEVER_EXPIRES
141
+
142
+
143
+ def _normalize_tic_code(code: Any, code_type: Any):
144
+ """CPT and HCPCS go through :func:`normalize_code`. Every other type (RC,
145
+ MS-DRG, payer-custom ``CSTM-*`` ...) is kept exactly as the file declares
146
+ it: those are not the standard-shape codes that normalizer gates."""
147
+ if isinstance(code_type, str) and code_type.upper() in ("CPT", "HCPCS"):
148
+ return normalize_code(code, code_type)
149
+ return code, code_type
150
+
151
+
152
+ def _text(value: Any) -> Optional[str]:
153
+ return None if value is None else str(value)
154
+
155
+
156
+ def _check_unknown_keys(stats: ParseStats, path: str, obj: Any,
157
+ ignored: frozenset = frozenset()) -> None:
158
+ """Count every key of *obj* not known at *path*. Path only, never the value."""
159
+ if not isinstance(obj, dict):
160
+ return
161
+ known = KNOWN_KEYS.get(path, ())
162
+ for key in obj:
163
+ if key not in known and key not in ignored:
164
+ stats.record_unmapped_path(f"{path}.{key}")
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Reading the file
169
+ # ---------------------------------------------------------------------------
170
+
171
+ # Some payer files carry bare backslashes inside strings ("\WAKE SPECIALTY
172
+ # PHYSICIANS LLC"), which strict JSON rejects. A valid escape is \" \\ \/ \b
173
+ # \f \n \r \t \uXXXX; anything else gets its backslash doubled so the string
174
+ # keeps the backslash as a literal character.
175
+ _BAD_ESCAPE_RE = re.compile(rb'\\\\|\\(?=[^"\\/bfnrtu])')
176
+
177
+
178
+ def _repair_escapes(data: bytes) -> tuple:
179
+ """Return (repaired bytes, number of bad escapes doubled)."""
180
+ repairs = 0
181
+
182
+ def fix(m):
183
+ nonlocal repairs
184
+ if m.group(0) == b"\\\\":
185
+ return m.group(0)
186
+ repairs += 1
187
+ return b"\\\\"
188
+ return _BAD_ESCAPE_RE.sub(fix, data), repairs
189
+
190
+
191
+ class _EscapeRepairingStream:
192
+ """Wrap a binary stream and repair invalid JSON escapes on the fly.
193
+
194
+ A trailing backslash that could start an escape or a pair is carried into
195
+ the next read, and a read never returns empty bytes before the underlying
196
+ stream is exhausted (empty means EOF to the parser). ``repairs`` counts
197
+ the substitutions.
198
+ """
199
+
200
+ def __init__(self, fh):
201
+ self._fh = fh
202
+ self._carry = b""
203
+ self.repairs = 0
204
+
205
+ def read(self, size=-1):
206
+ while True:
207
+ chunk = self._fh.read(size)
208
+ data = self._carry + chunk
209
+ self._carry = b""
210
+ if not chunk:
211
+ out, n = _repair_escapes(data)
212
+ self.repairs += n
213
+ return out
214
+ trailing = len(data) - len(data.rstrip(b"\\"))
215
+ if trailing % 2 == 1:
216
+ self._carry = b"\\"
217
+ data = data[:-1]
218
+ out, n = _repair_escapes(data)
219
+ self.repairs += n
220
+ if out:
221
+ return out
222
+
223
+
224
+ @contextmanager
225
+ def _open(path: Path, compression: Optional[str]) -> Iterator[_EscapeRepairingStream]:
226
+ with open_json(path, compression) as fh:
227
+ yield _EscapeRepairingStream(fh)
228
+
229
+
230
+ def _bounded_array_events(fh, array_key: str):
231
+ """Parse events up to and including *array_key*'s closing bracket, then
232
+ stop, so streaming that one top-level array never reads the rest of a
233
+ multi-gigabyte file. ``ijson.items`` accepts these events in place of a
234
+ file."""
235
+ for prefix, event, value in ijson.parse(fh):
236
+ yield prefix, event, value
237
+ if prefix == array_key and event == "end_array":
238
+ return
239
+
240
+
241
+ def detect_tic_file(path: Union[str, Path], compression: Optional[str] = None) -> Optional[str]:
242
+ """``'in_network'`` or ``'index'`` for a TiC file, None for anything else.
243
+
244
+ Looks at the root keys in the first events of the file only, so a
245
+ hospital file costs a few kilobytes of parsing.
246
+ """
247
+ path = Path(path)
248
+ if compression is None:
249
+ _, compression = detect_file_format(path)
250
+ try:
251
+ with _open(path, compression) as fh:
252
+ for prefix, event, value in itertools.islice(ijson.parse(fh), 1000):
253
+ if event != "map_key":
254
+ continue
255
+ if prefix == "" and value in ("in_network", "provider_references"):
256
+ return "in_network"
257
+ # A table of contents is sometimes wrapped in a one-element list.
258
+ if prefix in ("", "item") and value == "reporting_structure":
259
+ return "index"
260
+ except ijson.JSONError:
261
+ pass
262
+ return None
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # In-network rates files
267
+ # ---------------------------------------------------------------------------
268
+
269
+ def iter_tic_in_network(
270
+ path: Union[str, Path],
271
+ compression: Optional[str] = None,
272
+ *,
273
+ stats: Optional[ParseStats] = None,
274
+ ) -> Iterator[Any]:
275
+ """Yield the records in a TiC in-network rates file.
276
+
277
+ Order: :class:`TicFileMetadata`, every :class:`TicProviderGroup` from
278
+ ``provider_references``, then :class:`TicRate` records (with a
279
+ :class:`TicProviderGroup` for a group listed inline on a rate just
280
+ before the first rate that uses it).
281
+
282
+ Two passes over separate handles, so *path* must be a file: the first
283
+ reads ``provider_references`` and stops at its closing bracket, the
284
+ second streams ``in_network``. Memory holds the reference ids, not the
285
+ file.
286
+
287
+ *stats*: ``rows_read`` counts negotiated prices read, ``rows_skipped``
288
+ the provider groups, items and prices dropped as invalid or unmappable,
289
+ and ``unmapped_paths`` names each problem by JSON path.
290
+ """
291
+ path = Path(path)
292
+ stats = stats if stats is not None else ParseStats()
293
+ if compression is None:
294
+ _, compression = detect_file_format(path)
295
+
296
+ metadata = _read_metadata(path, compression, stats)
297
+ if any((metadata.reporting_entity_name, metadata.reporting_entity_type,
298
+ metadata.last_updated_on, metadata.version, metadata.network_name)):
299
+ yield metadata
300
+ resolved: Set[Any] = set()
301
+ yield from _provider_reference_groups(path, compression, stats, resolved)
302
+ yield from _rates(path, compression, stats, resolved)
303
+
304
+
305
+ def _read_metadata(path: Path, compression: Optional[str], stats: ParseStats) -> TicFileMetadata:
306
+ """Root fields and the first reference's network name, then stop.
307
+
308
+ Payers write the root scalars before the two big arrays and repeat one
309
+ network label on every reference, so this reads at most one reference.
310
+ """
311
+ fields: Dict[str, str] = {}
312
+ root_keys: Dict[str, None] = {}
313
+ with _open(path, compression) as fh:
314
+ for prefix, event, value in ijson.parse(fh):
315
+ if prefix == "" and event == "map_key":
316
+ root_keys[value] = None
317
+ if value == "in_network":
318
+ break
319
+ elif prefix in ("reporting_entity_name", "reporting_entity_type",
320
+ "last_updated_on", "version") and event in ("string", "number"):
321
+ fields[prefix] = str(value)
322
+ elif prefix == "provider_references.item.network_name.item" and event == "string":
323
+ fields["network_name"] = value
324
+ break
325
+ elif ((prefix == "provider_references.item" and event == "end_map")
326
+ or (prefix == "provider_references" and event == "end_array")):
327
+ break
328
+ _check_unknown_keys(stats, "$", root_keys)
329
+ return TicFileMetadata(**fields)
330
+
331
+
332
+ def _provider_group(pg: Any, path: str, stats: ParseStats) -> Optional[tuple]:
333
+ """(tin_type, tin, npis, business_name) for a valid provider group, else None.
334
+
335
+ A group needs a TIN and at least one valid NPI. Invalid NPIs are dropped
336
+ one by one and reported; the group keeps its valid ones.
337
+ """
338
+ _check_unknown_keys(stats, path, pg)
339
+ tin_obj = pg.get("tin") or {}
340
+ _check_unknown_keys(stats, f"{path}.tin", tin_obj)
341
+ tin_type = tin_obj.get("type")
342
+ if tin_type != "ein":
343
+ stats.record_unmapped_path(f"{path}.tin.type={tin_type}")
344
+
345
+ tin = _normalize_tin(tin_obj.get("value"))
346
+ if not tin:
347
+ stats.record_unmapped_path(f"{path}.tin.value=missing")
348
+ stats.rows_skipped += 1
349
+ return None
350
+
351
+ npis = set()
352
+ any_invalid = False
353
+ for raw in pg.get("npi") or []:
354
+ npi = _valid_npi(raw)
355
+ if npi is None:
356
+ any_invalid = True
357
+ kind = "out_of_range" if str(raw).lstrip("-").isdigit() else "non_numeric"
358
+ stats.record_unmapped_path(f"{path}.npi={kind}")
359
+ stats.rows_skipped += 1
360
+ continue
361
+ npis.add(npi)
362
+ if not npis:
363
+ if not any_invalid:
364
+ stats.record_unmapped_path(f"{path}.npi=empty")
365
+ return None
366
+ return (_text(tin_type), tin, "|".join(str(n) for n in sorted(npis)),
367
+ _text(tin_obj.get("business_name")))
368
+
369
+
370
+ def _provider_reference_groups(path: Path, compression: Optional[str], stats: ParseStats,
371
+ resolved: Set[Any]) -> Iterator[TicProviderGroup]:
372
+ """Pass 1: one record per (reference id, provider group).
373
+
374
+ Adds every reference id with at least one valid group to *resolved*; a
375
+ rate pointing only at other ids has nobody to pay and is skipped.
376
+ """
377
+ # ponytail: one key per distinct group row stays in memory to drop a
378
+ # reference repeated in the file; spill to disk if a file ever has
379
+ # tens of millions of groups.
380
+ seen = set()
381
+ path_groups = "$.provider_references[].provider_groups[]"
382
+ with _open(path, compression) as fh:
383
+ events = _bounded_array_events(fh, "provider_references")
384
+ for ref in ijson.items(events, "provider_references.item"):
385
+ _check_unknown_keys(stats, "$.provider_references[]", ref)
386
+ ref_id = ref.get("provider_group_id")
387
+ if ref.get("location") and not ref.get("provider_groups"):
388
+ # The groups live in a separate file this reader does not fetch.
389
+ stats.record_unmapped_path("$.provider_references[].location=unsupported")
390
+ for pg in ref.get("provider_groups") or []:
391
+ group = _provider_group(pg, path_groups, stats)
392
+ if group is None:
393
+ continue
394
+ resolved.add(ref_id)
395
+ key = (ref_id, group[1], group[2])
396
+ if key not in seen:
397
+ seen.add(key)
398
+ yield TicProviderGroup(str(ref_id), *group)
399
+
400
+
401
+ def _id_order(group_id: Any) -> tuple:
402
+ # Reference ids are numbers; inline ids are strings and sort after them.
403
+ return (isinstance(group_id, str), group_id)
404
+
405
+
406
+ def _rates(path: Path, compression: Optional[str], stats: ParseStats,
407
+ resolved: Set[Any]) -> Iterator[Any]:
408
+ """Pass 2: stream ``in_network`` into rate records."""
409
+ inline_ids: Dict[tuple, str] = {}
410
+ path_inline = "$.in_network[].negotiated_rates[].provider_groups[]"
411
+ with _open(path, compression) as fh:
412
+ for item in ijson.items(fh, "in_network.item"):
413
+ _check_unknown_keys(stats, "$.in_network[]", item, IGNORED_ITEM_KEYS)
414
+ for bc in item.get("bundled_codes") or []:
415
+ _check_unknown_keys(stats, "$.in_network[].bundled_codes[]", bc,
416
+ IGNORED_BUNDLED_KEYS)
417
+
418
+ raw_code = item.get("billing_code")
419
+ raw_code_type = item.get("billing_code_type")
420
+ if not raw_code or not raw_code_type:
421
+ stats.record_unmapped_path(
422
+ "$.in_network[].billing_code=missing" if not raw_code
423
+ else "$.in_network[].billing_code_type=missing")
424
+ stats.rows_skipped += max(1, sum(
425
+ len(nr.get("negotiated_prices") or [])
426
+ for nr in item.get("negotiated_rates") or []))
427
+ continue
428
+ code, code_type = _normalize_tic_code(raw_code, raw_code_type)
429
+
430
+ # Distinct price -> every provider group that has it, for this
431
+ # item. Some payers list one provider per negotiated_rates entry,
432
+ # so identical prices repeat many times per code; one record per
433
+ # distinct price says the same thing in far fewer rows.
434
+ held: Dict[tuple, set] = {}
435
+ for nr in item.get("negotiated_rates") or []:
436
+ _check_unknown_keys(stats, "$.in_network[].negotiated_rates[]", nr)
437
+ group_ids = set()
438
+ unknown_ref = False
439
+ for ref_id in nr.get("provider_references") or []:
440
+ if ref_id in resolved:
441
+ group_ids.add(ref_id)
442
+ else:
443
+ unknown_ref = True
444
+ if unknown_ref:
445
+ stats.record_unmapped_path(
446
+ "$.in_network[].negotiated_rates[].provider_references=unknown")
447
+ for pg in nr.get("provider_groups") or []:
448
+ group = _provider_group(pg, path_inline, stats)
449
+ if group is None:
450
+ continue
451
+ group_id = inline_ids.get(group[1:3])
452
+ if group_id is None:
453
+ group_id = inline_ids[group[1:3]] = f"inline-{len(inline_ids) + 1}"
454
+ yield TicProviderGroup(group_id, *group)
455
+ group_ids.add(group_id)
456
+ if not group_ids:
457
+ continue
458
+
459
+ for price in nr.get("negotiated_prices") or []:
460
+ key = _price_key(price, stats)
461
+ if key is None:
462
+ continue
463
+ merged = held.get(key)
464
+ if merged is None:
465
+ held[key] = set(group_ids)
466
+ else:
467
+ merged |= group_ids
468
+ if len(held) >= MAX_HELD_RATES:
469
+ yield from _rate_records(item, code, code_type, held)
470
+ yield from _rate_records(item, code, code_type, held)
471
+ if fh.repairs:
472
+ stats.warn(f"Repaired {fh.repairs:,} invalid JSON escape(s)")
473
+
474
+
475
+ def _price_key(price: Any, stats: ParseStats) -> Optional[tuple]:
476
+ """The merge key for one negotiated price, or None when it is skipped."""
477
+ _check_unknown_keys(stats, _PRICES, price)
478
+ stats.rows_read += 1
479
+
480
+ negotiated_type = price.get("negotiated_type")
481
+ if negotiated_type not in NEGOTIATED_TYPES:
482
+ stats.record_unmapped_path(f"{_PRICES}.negotiated_type={negotiated_type}")
483
+ stats.rows_skipped += 1
484
+ return None
485
+ billing_class = price.get("billing_class")
486
+ if billing_class not in BILLING_CLASSES:
487
+ stats.record_unmapped_path(f"{_PRICES}.billing_class={billing_class}")
488
+ stats.rows_skipped += 1
489
+ return None
490
+ setting = price.get("setting")
491
+ if setting is not None and setting not in SETTINGS:
492
+ stats.record_unmapped_path(f"{_PRICES}.setting={setting}")
493
+
494
+ value = price.get("negotiated_rate")
495
+ if value is None and negotiated_type in _TYPES_REQUIRING_A_VALUE:
496
+ stats.record_unmapped_path(f"{_PRICES}.negotiated_rate=missing")
497
+ stats.rows_skipped += 1
498
+ return None
499
+ if value is not None:
500
+ limit = MAX_ABS_PERCENTAGE if negotiated_type == "percentage" else MAX_ABS_RATE
501
+ try:
502
+ out_of_range = abs(float(value)) >= limit
503
+ except (TypeError, ValueError):
504
+ out_of_range = True
505
+ if out_of_range:
506
+ stats.record_unmapped_path(f"{_PRICES}.negotiated_rate=out_of_range")
507
+ stats.rows_skipped += 1
508
+ return None
509
+
510
+ service_codes = sorted(price.get("service_code") or [])
511
+ for sc in service_codes:
512
+ if not _is_pos_code(sc):
513
+ stats.record_unmapped_path(f"{_PRICES}.service_code={sc}")
514
+ return (billing_class, setting if setting in SETTINGS else "", negotiated_type, value,
515
+ tuple(service_codes), tuple(_split_modifiers(price.get("billing_code_modifier"))),
516
+ _parse_expiration(price.get("expiration_date"), stats))
517
+
518
+
519
+ def _rate_records(item: dict, code: Any, code_type: Any, held: Dict[tuple, set]) -> Iterator[TicRate]:
520
+ """One record per distinct price held for *item*, then clear *held*."""
521
+ for (billing_class, setting, negotiated_type, value, service_codes, modifiers,
522
+ expiration), group_ids in held.items():
523
+ value = None if value is None else float(value)
524
+ is_percentage = negotiated_type == "percentage"
525
+ yield TicRate(
526
+ billing_code=_text(code),
527
+ billing_code_type=_text(code_type),
528
+ billing_code_type_version=_text(item.get("billing_code_type_version")),
529
+ negotiation_arrangement=_text(item.get("negotiation_arrangement")),
530
+ negotiated_type=negotiated_type,
531
+ negotiated_rate=None if is_percentage else value,
532
+ negotiated_percentage=value if is_percentage else None,
533
+ expiration_date=expiration,
534
+ billing_class=billing_class,
535
+ setting=setting,
536
+ service_codes="|".join(str(s) for s in service_codes) or None,
537
+ modifiers="|".join(modifiers) or None,
538
+ provider_group_ids="|".join(str(g) for g in sorted(group_ids, key=_id_order)),
539
+ )
540
+ held.clear()
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Table of contents (index) files
545
+ # ---------------------------------------------------------------------------
546
+
547
+ def iter_tic_index(
548
+ path: Union[str, Path],
549
+ compression: Optional[str] = None,
550
+ *,
551
+ stats: Optional[ParseStats] = None,
552
+ ) -> Iterator[TicIndexEntry]:
553
+ """Yield one :class:`TicIndexEntry` per (reporting plan, in-network file)
554
+ pair of every ``reporting_structure`` in a table-of-contents file.
555
+
556
+ Streamed: one structure is in memory at a time, so a 10 GB index is
557
+ fine. A root wrapped in a list is accepted. An in-network file with no
558
+ ``location`` is skipped and counted in *stats*.
559
+
560
+ Raises ``ValueError`` when the root is neither an object nor a list.
561
+ """
562
+ path = Path(path)
563
+ stats = stats if stats is not None else ParseStats()
564
+ if compression is None:
565
+ _, compression = detect_file_format(path)
566
+
567
+ root: Dict[str, str] = {}
568
+ with _open(path, compression) as fh:
569
+ events = ijson.parse(fh)
570
+ first = next(events, None)
571
+ if first is None or first[1] not in ("start_map", "start_array"):
572
+ raise ValueError(f"{path.name}: a table of contents must be a JSON object "
573
+ f"(or a list holding one)")
574
+ base = "" if first[1] == "start_map" else "item."
575
+
576
+ def capture_root():
577
+ # Root fields stream past before reporting_structure; keep them.
578
+ yield first
579
+ for prefix, event, value in events:
580
+ if (prefix in (f"{base}reporting_entity_name", f"{base}reporting_entity_type")
581
+ and event == "string"):
582
+ root[prefix[len(base):]] = value
583
+ yield prefix, event, value
584
+
585
+ for structure in ijson.items(capture_root(), f"{base}reporting_structure.item"):
586
+ plans = structure.get("reporting_plans") or [{}]
587
+ allowed = (structure.get("allowed_amount_file") or {}).get("location")
588
+ for entry in structure.get("in_network_files") or []:
589
+ location = entry.get("location")
590
+ if not location:
591
+ stats.record_unmapped_path(
592
+ "$.reporting_structure[].in_network_files[].location=missing")
593
+ stats.rows_skipped += 1
594
+ continue
595
+ stats.rows_read += 1
596
+ for plan in plans:
597
+ yield TicIndexEntry(
598
+ reporting_entity_name=root.get("reporting_entity_name"),
599
+ reporting_entity_type=root.get("reporting_entity_type"),
600
+ plan_name=_text(plan.get("plan_name")),
601
+ plan_id=_text(plan.get("plan_id")),
602
+ plan_id_type=_text(plan.get("plan_id_type")),
603
+ plan_market_type=_text(plan.get("plan_market_type")),
604
+ in_network_location=str(location),
605
+ in_network_description=_text(entry.get("description")),
606
+ allowed_amount_location=_text(allowed),
607
+ )
608
+
609
+
610
+ # "2026-09-01_KPIC-MA_in-network-rates" -> "KPIC-MA_in-network-rates". File
611
+ # names usually lead with their publication date, which would make every
612
+ # monthly index a new set of networks.
613
+ _DATE_PREFIX_RE = re.compile(r"^\d{4}-\d{2}(?:-\d{2})?_")
614
+
615
+
616
+ def _file_name(location: str) -> str:
617
+ return location.split("?", 1)[0].rsplit("/", 1)[-1]
618
+
619
+
620
+ def tic_network_key(location: str) -> str:
621
+ """A stable key for the network an in-network file belongs to.
622
+
623
+ The file's base name without query string, extension or a leading
624
+ publication date, so signed URLs and next month's copy keep the key.
625
+ """
626
+ return _DATE_PREFIX_RE.sub("", _file_name(location).split(".", 1)[0])
627
+
628
+
629
+ def group_tic_networks(entries: Iterable[TicIndexEntry]) -> Dict[str, Dict[str, Any]]:
630
+ """Group index entries into networks, keyed by :func:`tic_network_key`.
631
+
632
+ Capitation (``cap-rates``) and allowed-amount files are left out. Each
633
+ group gives ``file_name`` and ``location`` (the lexicographically first
634
+ file name), ``market_type`` (the most common ``plan_market_type`` over
635
+ the plans that reference it) and ``file_count`` (distinct file names).
636
+ Memory is per distinct file, not per entry: an index can repeat one file
637
+ across millions of plans.
638
+ """
639
+ groups: Dict[str, Dict[str, Any]] = {}
640
+ for entry in entries:
641
+ file_name = _file_name(entry.in_network_location)
642
+ lower = file_name.lower()
643
+ if "cap-rates" in lower or "allowed" in lower:
644
+ continue
645
+ key = tic_network_key(file_name)
646
+ group = groups.get(key)
647
+ if group is None:
648
+ group = groups[key] = {"file_name": file_name, "location": entry.in_network_location,
649
+ "names": set(), "market_types": Counter()}
650
+ elif file_name < group["file_name"]:
651
+ group["file_name"], group["location"] = file_name, entry.in_network_location
652
+ group["names"].add(file_name)
653
+ if entry.plan_market_type:
654
+ group["market_types"][entry.plan_market_type] += 1
655
+
656
+ for group in groups.values():
657
+ modal = group.pop("market_types").most_common(1)
658
+ group["market_type"] = modal[0][0] if modal else None
659
+ group["file_count"] = len(group.pop("names"))
660
+ return groups