sourcelock 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.
- hc_source/__init__.py +5 -0
- hc_source/adapters/__init__.py +500 -0
- hc_source/adapters/_demo.py +258 -0
- hc_source/adapters/_demo_fixture.json +25 -0
- hc_source/adapters/_leie_sample.csv +15 -0
- hc_source/adapters/codes.py +1232 -0
- hc_source/adapters/coverage.py +1569 -0
- hc_source/adapters/hcc.py +1450 -0
- hc_source/adapters/leie.py +1310 -0
- hc_source/adapters/provider.py +1159 -0
- hc_source/cache.py +664 -0
- hc_source/cli.py +959 -0
- hc_source/cli_manifest.py +207 -0
- hc_source/data/codes/hcpcs_2026q3.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2026.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2027.csv.gz +0 -0
- hc_source/data/codes/manifest.json +75 -0
- hc_source/data/codes/regenerate.py +291 -0
- hc_source/data/hcc/hcc_data.json.zlib +0 -0
- hc_source/doctor.py +472 -0
- hc_source/guard.py +877 -0
- hc_source/http.py +541 -0
- hc_source/interfaces.py +395 -0
- hc_source/lockfile.py +236 -0
- hc_source/manifest.py +422 -0
- hc_source/mcp_server.py +203 -0
- hc_source/npi.py +50 -0
- hc_source/receipts.py +74 -0
- hc_source/schemas.py +339 -0
- sourcelock-0.1.0.dist-info/METADATA +272 -0
- sourcelock-0.1.0.dist-info/RECORD +34 -0
- sourcelock-0.1.0.dist-info/WHEEL +4 -0
- sourcelock-0.1.0.dist-info/entry_points.txt +2 -0
- sourcelock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
"""CMS-HCC risk adjustment route: ICD-10-CM -> HCC mapping, hierarchies, and
|
|
2
|
+
community continuing-enrollee RAF scoring for models V24 and V28.
|
|
3
|
+
|
|
4
|
+
Scope (v1): the COMMUNITY CONTINUING-ENROLLEE segments only (CNA/CND/CFA/CFD/
|
|
5
|
+
CPA/CPD). Institutional, new-enrollee, C-SNP new-enrollee, ESRD, and RxHCC are
|
|
6
|
+
out of scope and every receipt says so.
|
|
7
|
+
|
|
8
|
+
Data: this module vendors compact tables derived from the real CMS model
|
|
9
|
+
software (see ``_DATA_B85`` at the bottom and the ``vendor`` block inside it).
|
|
10
|
+
Authority order per artifact:
|
|
11
|
+
|
|
12
|
+
* dx->CC mappings, hierarchies, coefficients, labels, age/sex edits, and model
|
|
13
|
+
logic come from the SAS model software -- ``CMS-HCC software V2826.115.T1``
|
|
14
|
+
(V28, PY2026 midyear-final) and ``CMS-HCC software V2425.86.P1`` (V24,
|
|
15
|
+
PY2025 midyear-final; V24 is absent from PY2026+ bundles and will not
|
|
16
|
+
reappear).
|
|
17
|
+
* the CMS Python package ``CMS_HCC_v28_2026_T_package_v3`` is the cross-check
|
|
18
|
+
lane (0 mapping/hierarchy/coefficient mismatches after accounting for its
|
|
19
|
+
encoding of the mandatory age edits; see tests/fixtures/hcc/derive_report.txt)
|
|
20
|
+
and the source of the documented 3-decimal score rounding.
|
|
21
|
+
* coefficients cross-check against the Rate Announcements: V28 first published
|
|
22
|
+
in the CY2024 Rate Announcement Table VIII-1 (CNA_F65_69 = 0.330), V24 in the
|
|
23
|
+
CY2020 Rate Announcement Table VI-1 (CFA_F65_69 = 0.441).
|
|
24
|
+
* normalization factors and payment-year blends come from the Rate
|
|
25
|
+
Announcements (see ``payment_years`` in the vendored data and
|
|
26
|
+
tests/fixtures/hcc/VERIFIED_ADDENDUM.md).
|
|
27
|
+
|
|
28
|
+
Payment-year coverage: one software release is vendored per model, and which
|
|
29
|
+
payment year it was published for is read from the vendored data's own
|
|
30
|
+
``software_version`` string. ``hcc.score`` answers for that pair and refuses
|
|
31
|
+
every other one -- ``data`` is null, the receipt says REFUSED, names the
|
|
32
|
+
release actually held, lists the pairs this build can score, and points at
|
|
33
|
+
``hcc.map``/``hcc.hierarchy_explain`` for working with the vendored revision
|
|
34
|
+
directly. It does NOT run one year's tables under another year's normalization
|
|
35
|
+
factor: a RAF to three decimals from the wrong year's mapping, edits,
|
|
36
|
+
hierarchies and coefficients is a confident wrong answer, and a warning
|
|
37
|
+
underneath one does not make it a right one. Widening the answer is a
|
|
38
|
+
data-acquisition job (vendor the missing release); the scoreable pairs are
|
|
39
|
+
computed from the vendored version strings, so they move on their own when it
|
|
40
|
+
is done. Whether those upstream zips are still the ones the tables came from is
|
|
41
|
+
the ``hcc.model_software_release`` canary's job -- every other hcc canary reads
|
|
42
|
+
vendored bytes and cannot see CMS republish a zip in place.
|
|
43
|
+
|
|
44
|
+
Hierarchy semantics: hierarchies are applied SEQUENTIALLY in ascending-parent
|
|
45
|
+
order, which is the SAS software's file order and evaluation model (a parent
|
|
46
|
+
zeroed by an earlier rule no longer suppresses its own children). The CMS
|
|
47
|
+
Python package instead suppresses from the pre-hierarchy set; the two differ on
|
|
48
|
+
chains such as V28 HCC62/63/202. The SAS payment software is the authority.
|
|
49
|
+
|
|
50
|
+
Regeneration: ``python tests/fixtures/hcc/regenerate_vendored_data.py
|
|
51
|
+
--source-dir <dir>`` where the dir holds the CMS artifacts listed in the
|
|
52
|
+
vendored ``vendor.artifacts`` block (URLs + sha256 recorded there).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import hashlib
|
|
58
|
+
import json
|
|
59
|
+
import os
|
|
60
|
+
import re
|
|
61
|
+
import zlib
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from datetime import date
|
|
64
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import Any, Literal
|
|
67
|
+
|
|
68
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
69
|
+
|
|
70
|
+
from ..http import FetchResult, SourceUnreachable, fetch
|
|
71
|
+
from ..interfaces import Canary, CanaryObservation, SourceAdapter, ToolResult, ToolSpec
|
|
72
|
+
from ..receipts import build_receipt
|
|
73
|
+
from ..schemas import CanaryStatus, SourceContract
|
|
74
|
+
|
|
75
|
+
SOURCE_ID = "hcc"
|
|
76
|
+
TRANSFORM_VERSION = "1"
|
|
77
|
+
|
|
78
|
+
#: Environment override for the vendored data tables, used by the test suite to
|
|
79
|
+
#: simulate vendored-data drift/tampering. Points at a JSON file with the same
|
|
80
|
+
#: shape as the embedded canonical data.
|
|
81
|
+
DATA_ENV_VAR = "HC_SOURCE_HCC_DATA"
|
|
82
|
+
|
|
83
|
+
#: URL stamped on receipts when the answer came from the packaged tables.
|
|
84
|
+
EMBEDDED_DATA_URL = "package://hc_source/data/hcc/hcc_data.json.zlib"
|
|
85
|
+
|
|
86
|
+
#: The packaged vendored-data artifact (zlib-compressed canonical JSON).
|
|
87
|
+
DATA_FILE = Path(__file__).resolve().parents[1] / "data" / "hcc" / "hcc_data.json.zlib"
|
|
88
|
+
|
|
89
|
+
# Pinned upstream artifacts (identity checked by the release canary; sha256
|
|
90
|
+
# values live in the vendored data's provenance block).
|
|
91
|
+
SOFTWARE_2026_URL = "https://www.cms.gov/files/zip/2026-midyear-final-model-software.zip"
|
|
92
|
+
SOFTWARE_2025_URL = "https://www.cms.gov/files/zip/2025-midyear/final-model-software.zip"
|
|
93
|
+
SOFTWARE_2026_PY_URL = "https://www.cms.gov/files/zip/2026-midyear-final-model-software-python.zip"
|
|
94
|
+
PY2028_PROBE_URL = "https://www.cms.gov/files/zip/2028-initial-model-software.zip"
|
|
95
|
+
|
|
96
|
+
#: Prefix for the per-artifact URL overrides. Same shape as the provider route:
|
|
97
|
+
#: the override exists so a slug rename is a config change rather than a
|
|
98
|
+
#: release, and so drift can be rehearsed against a ``file://`` mirror.
|
|
99
|
+
_ENV_PREFIX = "HC_SOURCE_HCC_"
|
|
100
|
+
|
|
101
|
+
_PINNED_ARTIFACTS = (
|
|
102
|
+
# (short name, env-override name, url, content-length, last-modified date)
|
|
103
|
+
# verified 2026-08-01.
|
|
104
|
+
("sw2026", "SOFTWARE_2026", SOFTWARE_2026_URL, "733317", "23 Dec 2025"),
|
|
105
|
+
("sw2025", "SOFTWARE_2025", SOFTWARE_2025_URL, "802577", "10 Jan 2025"),
|
|
106
|
+
("py2026", "SOFTWARE_2026_PY", SOFTWARE_2026_PY_URL, "332632", "02 Apr 2026"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _url(name: str, default: str) -> str:
|
|
111
|
+
return os.environ.get(f"{_ENV_PREFIX}{name}_URL", default)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _pinned_artifacts() -> tuple[tuple[str, str, str, str], ...]:
|
|
115
|
+
"""(short name, url, pinned content-length, pinned last-modified date).
|
|
116
|
+
|
|
117
|
+
Read through the environment on every call rather than frozen at import, so
|
|
118
|
+
a test or an operator can point one artifact at a mirror without reloading
|
|
119
|
+
the module.
|
|
120
|
+
"""
|
|
121
|
+
return tuple(
|
|
122
|
+
(name, _url(env, url), size, last_modified)
|
|
123
|
+
for name, env, url, size, last_modified in _PINNED_ARTIFACTS
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
CONTRACT = SourceContract(
|
|
127
|
+
source_id=SOURCE_ID,
|
|
128
|
+
authority_url=(
|
|
129
|
+
"https://www.cms.gov/medicare/payment/medicare-advantage-rates-statistics/risk-adjustment"
|
|
130
|
+
),
|
|
131
|
+
fallback_url=None,
|
|
132
|
+
license_notes=(
|
|
133
|
+
"US federal government works published on cms.gov: model software, mapping "
|
|
134
|
+
"workbooks, and Rate Announcements are public domain; no license gate or "
|
|
135
|
+
"click-through. Contains ICD-10-CM codes (CDC/NCHS, not AMA-licensed) and "
|
|
136
|
+
"CMS's own HCC labels; no CPT/HCPCS content in this route."
|
|
137
|
+
),
|
|
138
|
+
cadence=(
|
|
139
|
+
"annual cycle: initial model software ~May of PY-1; midyear-final software "
|
|
140
|
+
"~Dec PY-1; Rate Announcement with normalization factors ~first week of "
|
|
141
|
+
"April; CMS also republishes zips in place (no ETag; pin CL+LM+sha256)"
|
|
142
|
+
),
|
|
143
|
+
effective_date_semantics=(
|
|
144
|
+
"effective_from/effective_to bound the PAYMENT YEAR the answer is for. "
|
|
145
|
+
"hcc.score is asked for a payment year, so its interval is that year -- "
|
|
146
|
+
"its normalization factor and blend are that year's Rate Announcement "
|
|
147
|
+
"values. hcc.map and hcc.hierarchy_explain take no payment year, so "
|
|
148
|
+
"their interval is the payment year of the vendored model software "
|
|
149
|
+
"revision they read (V2826.115.T1 -> PY2026, V2425.86.P1 -> PY2025), "
|
|
150
|
+
"taken from that revision's own version string in the vendored data. The "
|
|
151
|
+
"vendored tables cover exactly one payment year per model, so a score "
|
|
152
|
+
"for any other year carries a warning naming the revision it used. "
|
|
153
|
+
"Diagnoses feeding a payment year are collected in PY-1; the mapping "
|
|
154
|
+
"workbook spans the two ICD-10-CM fiscal years of that window."
|
|
155
|
+
),
|
|
156
|
+
invariants=[
|
|
157
|
+
"V28 has exactly 115 payment HCCs and V24 exactly 86",
|
|
158
|
+
"C2824T2N.csv CNA_F65_69 equals 0.330, the CY2024 Rate Announcement Table VIII-1 value",
|
|
159
|
+
"C2419P1M.csv CFA_F65_69 equals 0.441, the CY2020 Rate Announcement Table VI-1 value",
|
|
160
|
+
"pinned software zips keep their Content-Length and Last-Modified until CMS republishes",
|
|
161
|
+
"PY2028 model software does not exist yet (probe URL returns 404)",
|
|
162
|
+
],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
NON_CLAIMS_COMMON = [
|
|
166
|
+
"SEGMENT_SCOPE: results cover the CMS-HCC community continuing-enrollee segment "
|
|
167
|
+
"only; institutional, new-enrollee, C-SNP new-enrollee, ESRD, and RxHCC models "
|
|
168
|
+
"are out of scope of this route.",
|
|
169
|
+
"NOT_A_PAYMENT_GUARANTEE: a RAF score from published model tables is not a "
|
|
170
|
+
"payment amount or a payment guarantee; CMS payment additionally applies the MA "
|
|
171
|
+
"coding pattern difference adjustment (5.90 percent statutory minimum for "
|
|
172
|
+
"CY2024-CY2027), frailty adjustments, and plan- and enrollee-level rules this "
|
|
173
|
+
"tool does not model.",
|
|
174
|
+
"MODEL_YEAR_MAPPING: scores apply a model calibration year to a payment year "
|
|
175
|
+
"per the CMS mapping (V28 = 2024 CMS-HCC model scored with PY2026 midyear-final "
|
|
176
|
+
"software; V24 = 2020 CMS-HCC model scored with PY2025 midyear-final software, "
|
|
177
|
+
"its last release).",
|
|
178
|
+
"DOES_NOT_VALIDATE_DIAGNOSES: input codes are not checked for ICD-10-CM "
|
|
179
|
+
"validity on a date of service, nor for risk-adjustment eligibility of the "
|
|
180
|
+
"submitting provider type or setting; unmapped codes are reported, not scored.",
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
#: Non-claims for a receipt that refused. The common list above is written for
|
|
184
|
+
#: an answer -- "results cover", "scores apply", "unmapped codes are reported" --
|
|
185
|
+
#: and every one of those sentences is false about a call that returned
|
|
186
|
+
#: ``data: null``. A refusal receipt that carries them describes a scoring run
|
|
187
|
+
#: that never ran, which is precisely the failure this route was hardened to
|
|
188
|
+
#: prevent; the receipt would then be the lie instead of the safeguard.
|
|
189
|
+
NON_CLAIMS_REFUSED = [
|
|
190
|
+
"NO_SCORE_WAS_COMPUTED: this receipt reports a refusal. `data` is null, no "
|
|
191
|
+
"RAF was calculated, no normalization factor or blend weight was applied, "
|
|
192
|
+
"and nothing here may be read as a risk score for the requested payment "
|
|
193
|
+
"year -- not a low one, not a zero one.",
|
|
194
|
+
"DIAGNOSES_WERE_NOT_EXAMINED: beyond the shape check every parameter gets, "
|
|
195
|
+
"the submitted codes were not mapped to HCCs, not run through the mandatory "
|
|
196
|
+
"age/sex edits, and not checked against any ICD-10-CM release. The refusal "
|
|
197
|
+
"is about the (model, payment year) pair requested and says nothing about "
|
|
198
|
+
"the codes -- an absent HCC here is an absent computation, not a negative "
|
|
199
|
+
"finding.",
|
|
200
|
+
"THE_REASON_IS_IN_THE_WARNINGS: this receipt does not claim the pair is "
|
|
201
|
+
"unscoreable in principle, and the two reasons it can be refused are not "
|
|
202
|
+
"the same fact. Either CMS publishes no normalization factor pairing this "
|
|
203
|
+
"model with this payment year -- a fact about the program -- or it does and "
|
|
204
|
+
"the matching CMS model software is not vendored in this build, which is a "
|
|
205
|
+
"fact about this build and widens by vendoring the missing release. The "
|
|
206
|
+
"warnings say which.",
|
|
207
|
+
"SEGMENT_SCOPE: when this route does answer, it covers the CMS-HCC "
|
|
208
|
+
"community continuing-enrollee segment only; institutional, new-enrollee, "
|
|
209
|
+
"C-SNP new-enrollee, ESRD, and RxHCC models are out of scope of it.",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------
|
|
214
|
+
# Vendored data access
|
|
215
|
+
# --------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _embedded_bytes() -> bytes:
|
|
219
|
+
return zlib.decompress(DATA_FILE.read_bytes())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _data_fetch() -> FetchResult:
|
|
223
|
+
"""Read the vendored tables through the receipt-friendly transport shape.
|
|
224
|
+
|
|
225
|
+
The embedded blob is the packaged artifact; ``DATA_ENV_VAR`` lets tests (and
|
|
226
|
+
a re-vendoring workflow) point at a JSON file instead, which travels through
|
|
227
|
+
``hc_source.http.fetch`` so drift simulation uses the same code path.
|
|
228
|
+
"""
|
|
229
|
+
override = os.environ.get(DATA_ENV_VAR)
|
|
230
|
+
if override:
|
|
231
|
+
return fetch(Path(override).absolute().as_uri())
|
|
232
|
+
content = _embedded_bytes()
|
|
233
|
+
# ``retrieved_at`` defaults to now, and that is the truthful stamp here: the
|
|
234
|
+
# packaged file is re-read on every call, so these bytes really were read at
|
|
235
|
+
# this moment. Only the parse is cached, never the FetchResult -- a cached
|
|
236
|
+
# result must keep the timestamp of the read that produced it.
|
|
237
|
+
return FetchResult(
|
|
238
|
+
url=EMBEDDED_DATA_URL,
|
|
239
|
+
status=None,
|
|
240
|
+
content=content,
|
|
241
|
+
sha256=hashlib.sha256(content).hexdigest(),
|
|
242
|
+
headers={},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_PARSE_CACHE: dict[str, dict[str, Any]] = {}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _load() -> tuple[FetchResult, dict[str, Any]]:
|
|
250
|
+
result = _data_fetch()
|
|
251
|
+
cached = _PARSE_CACHE.get(result.sha256)
|
|
252
|
+
if cached is None:
|
|
253
|
+
try:
|
|
254
|
+
cached = json.loads(result.content)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
raise SourceUnreachable(
|
|
257
|
+
result.url, f"vendored data is not valid JSON: {type(exc).__name__}"
|
|
258
|
+
)
|
|
259
|
+
_PARSE_CACHE.clear()
|
|
260
|
+
_PARSE_CACHE[result.sha256] = cached
|
|
261
|
+
return result, cached
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _canonical_hash(payload: dict[str, Any]) -> str:
|
|
265
|
+
"""Canonicalized-content hash: drift decisions key on this, never raw bytes."""
|
|
266
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
267
|
+
return hashlib.sha256(canonical).hexdigest()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _schema_hash(payload: dict[str, Any]) -> str:
|
|
271
|
+
"""Hash the SHAPE of the vendored tables (key structure), not their values."""
|
|
272
|
+
shape = {
|
|
273
|
+
"top": sorted(payload),
|
|
274
|
+
"models": {
|
|
275
|
+
name: sorted(model)
|
|
276
|
+
for name, model in sorted(payload.get("models", {}).items())
|
|
277
|
+
},
|
|
278
|
+
"payment_years": sorted(payload.get("payment_years", {})),
|
|
279
|
+
}
|
|
280
|
+
return hashlib.sha256(
|
|
281
|
+
json.dumps(shape, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
282
|
+
).hexdigest()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_SOFTWARE_PY_RE = re.compile(r"\bPY(?P<year>20\d{2})\b")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _software_payment_year(software_version: str) -> int | None:
|
|
289
|
+
"""The payment year a vendored software revision was published for.
|
|
290
|
+
|
|
291
|
+
Read out of the vendored data's own ``software_version`` string
|
|
292
|
+
("V2826.115.T1 (PY2026 midyear-final)"), never from a table in this module.
|
|
293
|
+
A hard-coded table here is what let ``hcc.score(model='v28',
|
|
294
|
+
payment_year=2024)`` run the PY2026 software and stamp a PY2026 effective
|
|
295
|
+
interval on the receipt: the answer said 2026 while the caller asked about
|
|
296
|
+
2024, and nothing in the receipt admitted it.
|
|
297
|
+
"""
|
|
298
|
+
match = _SOFTWARE_PY_RE.search(software_version)
|
|
299
|
+
return int(match.group("year")) if match else None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass(frozen=True)
|
|
303
|
+
class _Artifact:
|
|
304
|
+
"""The vendored artifact an answer came from, and the window it covers.
|
|
305
|
+
|
|
306
|
+
``effective_from``/``effective_to`` are what the receipt stamps. They come
|
|
307
|
+
from this record -- selected for the (model, payment year) pair actually
|
|
308
|
+
requested -- so the receipt can never describe a different year than the one
|
|
309
|
+
the answer is about.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
model_key: str
|
|
313
|
+
software_version: str
|
|
314
|
+
software_payment_year: int | None
|
|
315
|
+
payment_year: int | None
|
|
316
|
+
effective_from: date | None
|
|
317
|
+
effective_to: date | None
|
|
318
|
+
warnings: tuple[str, ...] = ()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _select_artifact(
|
|
322
|
+
data: dict[str, Any], model_key: str, payment_year: int | None = None
|
|
323
|
+
) -> _Artifact:
|
|
324
|
+
"""Select the vendored artifact for a (model, payment year) pair.
|
|
325
|
+
|
|
326
|
+
The routes that take no payment year answer for the software revision
|
|
327
|
+
itself, so their window is that revision's own payment year. The scoring
|
|
328
|
+
route answers for a payment year, so its window is that year.
|
|
329
|
+
|
|
330
|
+
The vendored data holds one software release per model, so for most payment
|
|
331
|
+
years there are no tables to score with. This function reports that as a
|
|
332
|
+
fact about the vendored data -- which release is held, which year CMS
|
|
333
|
+
published it for -- and stops there. It does not describe what the caller
|
|
334
|
+
will get, because that is the scoring route's decision and the answer is a
|
|
335
|
+
refusal. Coverage is read from the vendored data's own metadata; this
|
|
336
|
+
function never invents it.
|
|
337
|
+
"""
|
|
338
|
+
model = data["models"][model_key]
|
|
339
|
+
software_version = model["software_version"]
|
|
340
|
+
native = _software_payment_year(software_version)
|
|
341
|
+
warnings: list[str] = []
|
|
342
|
+
|
|
343
|
+
if native is None:
|
|
344
|
+
warnings.append(
|
|
345
|
+
f"the vendored software_version for {model_key} does not name a payment "
|
|
346
|
+
"year, so the payment year this revision covers cannot be established "
|
|
347
|
+
"from the vendored data; regenerate the tables from the pinned CMS "
|
|
348
|
+
"artifacts before relying on this receipt's dates."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if payment_year is None:
|
|
352
|
+
window = (date(native, 1, 1), date(native, 12, 31)) if native else (None, None)
|
|
353
|
+
else:
|
|
354
|
+
window = (date(payment_year, 1, 1), date(payment_year, 12, 31))
|
|
355
|
+
if native is not None and native != payment_year:
|
|
356
|
+
warnings.append(
|
|
357
|
+
f"model software mismatch: the vendored data holds no {model_key} "
|
|
358
|
+
f"software release for PY{payment_year}. The one release "
|
|
359
|
+
f"it holds for {model_key} is {software_version}, which CMS published "
|
|
360
|
+
f"for PY{native} -- its mapping, mandatory edits, hierarchies and "
|
|
361
|
+
f"coefficients are PY{native} tables and are not used to answer for "
|
|
362
|
+
f"another year."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return _Artifact(
|
|
366
|
+
model_key=model_key,
|
|
367
|
+
software_version=software_version,
|
|
368
|
+
software_payment_year=native,
|
|
369
|
+
payment_year=payment_year,
|
|
370
|
+
effective_from=window[0],
|
|
371
|
+
effective_to=window[1],
|
|
372
|
+
warnings=tuple(warnings),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _scoreable_pairs(data: dict[str, Any]) -> list[str]:
|
|
377
|
+
"""The (model, payment year) pairs this build can actually score.
|
|
378
|
+
|
|
379
|
+
One line, computed from the vendored data: a model can score the payment
|
|
380
|
+
year its own software release was published for, and only if CMS pairs that
|
|
381
|
+
model with that year in the Rate Announcement. Everything else has no
|
|
382
|
+
tables. Never a literal list -- re-vendoring a newer release must move this
|
|
383
|
+
answer without anybody remembering to edit it.
|
|
384
|
+
"""
|
|
385
|
+
pairs = []
|
|
386
|
+
for model_key, model in sorted(data.get("models", {}).items()):
|
|
387
|
+
native = _software_payment_year(model.get("software_version", ""))
|
|
388
|
+
if native is None:
|
|
389
|
+
continue
|
|
390
|
+
year = data.get("payment_years", {}).get(str(native))
|
|
391
|
+
if year and model_key in year.get("blend", {}):
|
|
392
|
+
pairs.append(f"{model_key}/PY{native}")
|
|
393
|
+
return pairs
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --------------------------------------------------------------------------
|
|
397
|
+
# Typed public parameters
|
|
398
|
+
# --------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
class NoParams(BaseModel):
|
|
401
|
+
model_config = ConfigDict(extra="forbid")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
_CODE_RE = re.compile(r"^[A-Z][0-9][0-9A-Z]{1,5}$")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _normalize_codes(raw: str) -> list[str]:
|
|
408
|
+
"""Split, normalise and validate a comma-separated ICD-10-CM list.
|
|
409
|
+
|
|
410
|
+
The rejection message names the POSITION of the bad token and describes the
|
|
411
|
+
rule; it never quotes the token. Round-1 finding C1 was exactly this line
|
|
412
|
+
interpolating the rejected value, which let an HL7 PID segment round-trip to
|
|
413
|
+
stdout and to an MCP client. A position is enough to fix a typo and carries
|
|
414
|
+
nothing that must not be logged.
|
|
415
|
+
"""
|
|
416
|
+
codes: list[str] = []
|
|
417
|
+
for position, token in enumerate(raw.replace(";", ",").split(",")):
|
|
418
|
+
token = token.strip().upper().replace(".", "")
|
|
419
|
+
if not token:
|
|
420
|
+
continue
|
|
421
|
+
if not _CODE_RE.match(token):
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"diagnoses[{position}] is not a well-formed ICD-10-CM code "
|
|
424
|
+
"(expected a letter, a digit, then 1-5 more digits or letters, "
|
|
425
|
+
"with an optional dot after the third character -- e.g. E11.9 or E119)"
|
|
426
|
+
)
|
|
427
|
+
if token not in codes:
|
|
428
|
+
codes.append(token)
|
|
429
|
+
if not codes:
|
|
430
|
+
raise ValueError("diagnoses must contain at least one ICD-10-CM code")
|
|
431
|
+
if len(codes) > 200:
|
|
432
|
+
raise ValueError("diagnoses accepts at most 200 distinct codes per call")
|
|
433
|
+
return codes
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class MapParams(BaseModel):
|
|
437
|
+
model_config = ConfigDict(extra="forbid")
|
|
438
|
+
|
|
439
|
+
diagnoses: str = Field(
|
|
440
|
+
min_length=1,
|
|
441
|
+
max_length=4000,
|
|
442
|
+
description="Comma-separated ICD-10-CM codes, dots optional, e.g. 'E11.9,I50.9'.",
|
|
443
|
+
)
|
|
444
|
+
model: Literal["v24", "v28"] = Field(
|
|
445
|
+
description="CMS-HCC model: v24 (2020 model, 86 HCCs) or v28 (2024 model, 115 HCCs)."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
@field_validator("diagnoses")
|
|
449
|
+
@classmethod
|
|
450
|
+
def _codes(cls, v: str) -> str:
|
|
451
|
+
_normalize_codes(v)
|
|
452
|
+
return v
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ScoreParams(BaseModel):
|
|
456
|
+
model_config = ConfigDict(extra="forbid")
|
|
457
|
+
|
|
458
|
+
diagnoses: str = Field(
|
|
459
|
+
min_length=1,
|
|
460
|
+
max_length=4000,
|
|
461
|
+
description="Comma-separated ICD-10-CM codes, dots optional, e.g. 'E11.9,I50.9'.",
|
|
462
|
+
)
|
|
463
|
+
model: Literal["v24", "v28"] = Field(
|
|
464
|
+
description="CMS-HCC model: v24 (2020 model) or v28 (2024 model)."
|
|
465
|
+
)
|
|
466
|
+
payment_year: int = Field(
|
|
467
|
+
ge=2024,
|
|
468
|
+
le=2027,
|
|
469
|
+
description=(
|
|
470
|
+
"Medicare Advantage payment year (2024-2027; factors pinned per year). "
|
|
471
|
+
"A score is returned only for a (model, payment year) pair whose CMS "
|
|
472
|
+
"model software is vendored here -- the rest are refused with a receipt "
|
|
473
|
+
"rather than computed from another year's tables."
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
age: int | None = Field(
|
|
477
|
+
default=None,
|
|
478
|
+
ge=0,
|
|
479
|
+
le=110,
|
|
480
|
+
description="Age in whole years during the payment year (categorical input, no dates). "
|
|
481
|
+
"Omitting it drops the demographic term and defaults to the aged segment.",
|
|
482
|
+
)
|
|
483
|
+
sex: Literal["f", "m"] | None = Field(
|
|
484
|
+
default=None,
|
|
485
|
+
description="Sex for the demographic cell and mandatory sex edits: 'f' or 'm', "
|
|
486
|
+
"case-insensitive.",
|
|
487
|
+
)
|
|
488
|
+
dual: Literal["non", "full", "partial"] = Field(
|
|
489
|
+
default="non", description="Medicaid dual status: non, full, or partial benefit."
|
|
490
|
+
)
|
|
491
|
+
orig_disabled: bool = Field(
|
|
492
|
+
default=False,
|
|
493
|
+
description="Originally entitled to Medicare by disability (OREC=1, 'originally "
|
|
494
|
+
"disabled'); applies to the aged community segments only.",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
@field_validator("diagnoses")
|
|
498
|
+
@classmethod
|
|
499
|
+
def _codes(cls, v: str) -> str:
|
|
500
|
+
_normalize_codes(v)
|
|
501
|
+
return v
|
|
502
|
+
|
|
503
|
+
@field_validator("sex", mode="before")
|
|
504
|
+
@classmethod
|
|
505
|
+
def _sex(cls, v: Any) -> Any:
|
|
506
|
+
"""Normalise case before the Literal is matched.
|
|
507
|
+
|
|
508
|
+
The CMS artifacts spell this value 'F' and 'M', and every other code-like
|
|
509
|
+
input on this route is case-folded before matching, so a caller who typed
|
|
510
|
+
the upstream spelling got a rejection that looked like a defect in the
|
|
511
|
+
tool. Normalising keeps the accepted set at two values -- widening the
|
|
512
|
+
Literal to four would put the caller's casing into the segment cell and
|
|
513
|
+
the receipt.
|
|
514
|
+
"""
|
|
515
|
+
return v.lower() if isinstance(v, str) else v
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class HierarchyParams(BaseModel):
|
|
519
|
+
model_config = ConfigDict(extra="forbid")
|
|
520
|
+
|
|
521
|
+
diagnoses: str = Field(
|
|
522
|
+
min_length=1,
|
|
523
|
+
max_length=4000,
|
|
524
|
+
description="Comma-separated ICD-10-CM codes, dots optional.",
|
|
525
|
+
)
|
|
526
|
+
model: Literal["v24", "v28"] = Field(
|
|
527
|
+
description="CMS-HCC model: v24 (2020 model) or v28 (2024 model)."
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
@field_validator("diagnoses")
|
|
531
|
+
@classmethod
|
|
532
|
+
def _codes(cls, v: str) -> str:
|
|
533
|
+
_normalize_codes(v)
|
|
534
|
+
return v
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# --------------------------------------------------------------------------
|
|
538
|
+
# Model engine (pure functions over the vendored tables)
|
|
539
|
+
# --------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
_AGE_BANDS = (
|
|
542
|
+
(0, 34, "0_34"), (35, 44, "35_44"), (45, 54, "45_54"), (55, 59, "55_59"),
|
|
543
|
+
(60, 64, "60_64"), (65, 69, "65_69"), (70, 74, "70_74"), (75, 79, "75_79"),
|
|
544
|
+
(80, 84, "80_84"), (85, 89, "85_89"), (90, 94, "90_94"), (95, 999, "95_GT"),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
_SEGMENT_NAMES = {
|
|
548
|
+
"CNA": "Community, Non-dual, Aged",
|
|
549
|
+
"CND": "Community, Non-dual, Disabled",
|
|
550
|
+
"CFA": "Community, Full-benefit dual, Aged",
|
|
551
|
+
"CFD": "Community, Full-benefit dual, Disabled",
|
|
552
|
+
"CPA": "Community, Partial-benefit dual, Aged",
|
|
553
|
+
"CPD": "Community, Partial-benefit dual, Disabled",
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
_EDIT_WHEN_RE = re.compile(
|
|
557
|
+
r"^(?:sex == (?P<sex>[fm])"
|
|
558
|
+
r"|age < (?P<lt>\d+)"
|
|
559
|
+
r"|age >= (?P<ge>\d+)"
|
|
560
|
+
r"|age < (?P<lo>\d+) or age > (?P<hi>\d+))$"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _edit_applies(when: str, age: int | None, sex: str | None) -> bool | None:
|
|
565
|
+
"""True/False when decidable, None when the needed demographic is missing."""
|
|
566
|
+
m = _EDIT_WHEN_RE.match(when)
|
|
567
|
+
if not m: # unknown rule text in (possibly overridden) data: skip, decidable
|
|
568
|
+
return False
|
|
569
|
+
if m.group("sex") is not None:
|
|
570
|
+
return None if sex is None else sex == m.group("sex")
|
|
571
|
+
if age is None:
|
|
572
|
+
return None
|
|
573
|
+
if m.group("lt") is not None:
|
|
574
|
+
return age < int(m.group("lt"))
|
|
575
|
+
if m.group("ge") is not None:
|
|
576
|
+
return age >= int(m.group("ge"))
|
|
577
|
+
return age < int(m.group("lo")) or age > int(m.group("hi"))
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _map_codes(
|
|
581
|
+
model: dict[str, Any],
|
|
582
|
+
codes: list[str],
|
|
583
|
+
age: int | None,
|
|
584
|
+
sex: str | None,
|
|
585
|
+
warnings: list[str],
|
|
586
|
+
) -> tuple[dict[str, list[int]], list[str], list[dict[str, Any]]]:
|
|
587
|
+
"""Apply the dx->CC map plus the mandatory age/sex edits.
|
|
588
|
+
|
|
589
|
+
Returns (mapped {code: [cc,...]}, unmapped [code,...], edits_applied).
|
|
590
|
+
"""
|
|
591
|
+
mapping: dict[str, list[int]] = model["mapping"]
|
|
592
|
+
mapped: dict[str, list[int]] = {}
|
|
593
|
+
unmapped: list[str] = []
|
|
594
|
+
edits_applied: list[dict[str, Any]] = []
|
|
595
|
+
|
|
596
|
+
for code in codes:
|
|
597
|
+
ccs = mapping.get(code)
|
|
598
|
+
if ccs is None:
|
|
599
|
+
unmapped.append(code)
|
|
600
|
+
continue
|
|
601
|
+
mapped[code] = list(ccs)
|
|
602
|
+
|
|
603
|
+
for rule in model.get("age_sex_edits", []):
|
|
604
|
+
hit = [c for c in mapped if c in rule["codes"]]
|
|
605
|
+
if not hit:
|
|
606
|
+
continue
|
|
607
|
+
applies = _edit_applies(rule["when"], age, sex)
|
|
608
|
+
if applies is None:
|
|
609
|
+
warnings.append(
|
|
610
|
+
f"codes {sorted(hit)} carry a mandatory CMS age/sex edit "
|
|
611
|
+
f"({rule['when']!r} -> CC {rule['set_cc'] if rule['set_cc'] else 'invalid'}); "
|
|
612
|
+
"age/sex not provided, so the default mapping was used."
|
|
613
|
+
)
|
|
614
|
+
continue
|
|
615
|
+
if not applies:
|
|
616
|
+
continue
|
|
617
|
+
for code in hit:
|
|
618
|
+
before = mapped[code]
|
|
619
|
+
if rule["set_cc"] is None:
|
|
620
|
+
mapped[code] = []
|
|
621
|
+
action = "dropped as invalid for this age/sex"
|
|
622
|
+
else:
|
|
623
|
+
mapped[code] = [int(rule["set_cc"])]
|
|
624
|
+
action = f"reassigned to CC {rule['set_cc']}"
|
|
625
|
+
edits_applied.append(
|
|
626
|
+
{"code": code, "was": before, "action": action, "cite": rule["cite"]}
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
if unmapped:
|
|
630
|
+
warnings.append(
|
|
631
|
+
f"{len(unmapped)} code(s) do not map to any payment HCC in this model and "
|
|
632
|
+
f"were excluded from scoring: {unmapped}. Unmapped codes are reported, "
|
|
633
|
+
"never silently dropped."
|
|
634
|
+
)
|
|
635
|
+
return mapped, unmapped, edits_applied
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _apply_pre_hierarchy(
|
|
639
|
+
model: dict[str, Any], present: set[int]
|
|
640
|
+
) -> tuple[set[int], list[dict[str, Any]]]:
|
|
641
|
+
applied = []
|
|
642
|
+
out = set(present)
|
|
643
|
+
for rule in model.get("pre_hierarchy_rules", []):
|
|
644
|
+
cc = int(rule["if_cc"])
|
|
645
|
+
if cc in out and not (out & {int(x) for x in rule["requires_any"]}):
|
|
646
|
+
out.discard(int(rule["else_zero"]))
|
|
647
|
+
applied.append(
|
|
648
|
+
{
|
|
649
|
+
"rule": f"CC{cc} requires one of {rule['requires_any']}",
|
|
650
|
+
"effect": f"CC{rule['else_zero']} zeroed",
|
|
651
|
+
"cite": rule["cite"],
|
|
652
|
+
}
|
|
653
|
+
)
|
|
654
|
+
return out, applied
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _apply_hierarchy(
|
|
658
|
+
model: dict[str, Any], present: set[int]
|
|
659
|
+
) -> tuple[list[int], list[dict[str, int]]]:
|
|
660
|
+
"""Sequential ascending-parent application (SAS file-order semantics)."""
|
|
661
|
+
alive = set(present)
|
|
662
|
+
suppressed: list[dict[str, int]] = []
|
|
663
|
+
hier: dict[str, list[int]] = model["hierarchies"]
|
|
664
|
+
for parent_key in sorted(hier, key=int):
|
|
665
|
+
parent = int(parent_key)
|
|
666
|
+
if parent not in alive:
|
|
667
|
+
continue
|
|
668
|
+
for child in hier[parent_key]:
|
|
669
|
+
if child in alive:
|
|
670
|
+
alive.discard(child)
|
|
671
|
+
suppressed.append({"hcc": child, "suppressed_by": parent})
|
|
672
|
+
return sorted(alive), suppressed
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _category_flags(model: dict[str, Any], final_hccs: set[int]) -> dict[str, int]:
|
|
676
|
+
return {
|
|
677
|
+
name: int(any(h in final_hccs for h in members))
|
|
678
|
+
for name, members in model["categories"].items()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _interaction_flags(
|
|
683
|
+
model: dict[str, Any], final_hccs: set[int], categories: dict[str, int]
|
|
684
|
+
) -> dict[str, int]:
|
|
685
|
+
def resolve(var: str) -> int:
|
|
686
|
+
if var.startswith("HCC") and var[3:].isdigit():
|
|
687
|
+
return int(int(var[3:]) in final_hccs)
|
|
688
|
+
return categories.get(var, 0)
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
name: resolve(v1) * resolve(v2)
|
|
692
|
+
for name, (v1, v2) in model["interactions"].items()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _count_variable(n: int) -> str | None:
|
|
697
|
+
if n <= 0:
|
|
698
|
+
return None
|
|
699
|
+
return f"D{n}" if n < 10 else "D10P"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _dec(value: str) -> Decimal:
|
|
703
|
+
return Decimal(value).quantize(Decimal("0.001"))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# --------------------------------------------------------------------------
|
|
707
|
+
# Handlers
|
|
708
|
+
# --------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _hcc_entry(model: dict[str, Any], hcc: int) -> dict[str, Any]:
|
|
712
|
+
return {"hcc": hcc, "label": model["labels"].get(str(hcc), "")}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _map_handler(params: MapParams) -> ToolResult:
|
|
716
|
+
result, data = _load()
|
|
717
|
+
model = data["models"][params.model]
|
|
718
|
+
codes = _normalize_codes(params.diagnoses)
|
|
719
|
+
warnings: list[str] = []
|
|
720
|
+
mapped, unmapped, _ = _map_codes(model, codes, age=None, sex=None, warnings=warnings)
|
|
721
|
+
|
|
722
|
+
rows = []
|
|
723
|
+
for code in codes:
|
|
724
|
+
if code in mapped:
|
|
725
|
+
rows.append(
|
|
726
|
+
{
|
|
727
|
+
"code": code,
|
|
728
|
+
"hccs": [_hcc_entry(model, cc) for cc in mapped[code]],
|
|
729
|
+
"payment": True,
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
payload = {
|
|
733
|
+
"model": params.model,
|
|
734
|
+
"model_name": model["cms_name"],
|
|
735
|
+
"software_version": model["software_version"],
|
|
736
|
+
"mapped": rows,
|
|
737
|
+
"unmapped": unmapped,
|
|
738
|
+
"mapping_provenance": {
|
|
739
|
+
"derived_from": data["vendor"]["artifacts"],
|
|
740
|
+
"note": "per-mapping authority: SAS dx->CC dump inside the model software "
|
|
741
|
+
"zip named in software_version; cross-checked against the CMS Python "
|
|
742
|
+
"package (V28) at vendor time.",
|
|
743
|
+
},
|
|
744
|
+
}
|
|
745
|
+
artifact = _select_artifact(data, params.model)
|
|
746
|
+
warnings.extend(artifact.warnings)
|
|
747
|
+
return ToolResult(
|
|
748
|
+
data=payload,
|
|
749
|
+
receipt=build_receipt(
|
|
750
|
+
contract=CONTRACT,
|
|
751
|
+
route="hcc.map",
|
|
752
|
+
fetch=result,
|
|
753
|
+
source_version=model["software_version"],
|
|
754
|
+
transform_version=TRANSFORM_VERSION,
|
|
755
|
+
effective_from=artifact.effective_from,
|
|
756
|
+
effective_to=artifact.effective_to,
|
|
757
|
+
warnings=warnings,
|
|
758
|
+
non_claims=NON_CLAIMS_COMMON,
|
|
759
|
+
),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _score_handler(params: ScoreParams) -> ToolResult:
|
|
764
|
+
result, data = _load()
|
|
765
|
+
model = data["models"][params.model]
|
|
766
|
+
codes = _normalize_codes(params.diagnoses)
|
|
767
|
+
warnings: list[str] = []
|
|
768
|
+
|
|
769
|
+
# Select the artifact for THIS (model, payment year) pair before anything
|
|
770
|
+
# else: the receipt's window and the software-revision caveat both come from
|
|
771
|
+
# it, and both are wrong if they are decided by the model alone.
|
|
772
|
+
artifact = _select_artifact(data, params.model, params.payment_year)
|
|
773
|
+
warnings.extend(artifact.warnings)
|
|
774
|
+
|
|
775
|
+
def _miss() -> ToolResult:
|
|
776
|
+
"""A refusal, described as a refusal.
|
|
777
|
+
|
|
778
|
+
`NON_CLAIMS_REFUSED`, not the common list: the common list is written
|
|
779
|
+
for a receipt that has a score under it, and a receipt whose prose
|
|
780
|
+
narrates a computation that did not happen is the exact defect this
|
|
781
|
+
route's hard gate exists to prevent.
|
|
782
|
+
"""
|
|
783
|
+
return ToolResult(
|
|
784
|
+
data=None,
|
|
785
|
+
receipt=build_receipt(
|
|
786
|
+
contract=CONTRACT,
|
|
787
|
+
route="hcc.score",
|
|
788
|
+
fetch=result,
|
|
789
|
+
source_version=model["software_version"],
|
|
790
|
+
transform_version=TRANSFORM_VERSION,
|
|
791
|
+
effective_from=artifact.effective_from,
|
|
792
|
+
effective_to=artifact.effective_to,
|
|
793
|
+
warnings=warnings,
|
|
794
|
+
non_claims=NON_CLAIMS_REFUSED,
|
|
795
|
+
),
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
py = data["payment_years"].get(str(params.payment_year))
|
|
799
|
+
if py is None:
|
|
800
|
+
# Reachable when the vendored tables are overridden or regenerated
|
|
801
|
+
# without a year the parameter model still accepts. Reported as a miss,
|
|
802
|
+
# never as a score computed from another year's factors.
|
|
803
|
+
warnings.append(
|
|
804
|
+
f"payment year {params.payment_year} has no vendored factor set: the "
|
|
805
|
+
f"vendored data carries Rate Announcement factors for payment years "
|
|
806
|
+
f"{', '.join(sorted(data['payment_years']))} only."
|
|
807
|
+
)
|
|
808
|
+
return _miss()
|
|
809
|
+
|
|
810
|
+
# Payment-year / model applicability.
|
|
811
|
+
if params.model not in py["blend"]:
|
|
812
|
+
paid = sorted(
|
|
813
|
+
year for year, entry in data["payment_years"].items()
|
|
814
|
+
if params.model in entry["blend"]
|
|
815
|
+
)
|
|
816
|
+
warnings.append(
|
|
817
|
+
f"{params.model} is not a Part C payment model for payment year "
|
|
818
|
+
f"{params.payment_year}: CMS publishes no normalization factor for it "
|
|
819
|
+
f"({py['source']}). Payment years the vendored data pairs with "
|
|
820
|
+
f"{params.model}: {', '.join(paid) if paid else 'none'}."
|
|
821
|
+
)
|
|
822
|
+
return _miss()
|
|
823
|
+
|
|
824
|
+
if py.get("staged"):
|
|
825
|
+
# The vendored data's own staged flag: factors published, cycle not
|
|
826
|
+
# closed. A fact about the payment year, true whether or not this call
|
|
827
|
+
# goes on to score, so it is stated before the gate below rather than
|
|
828
|
+
# after it -- for a staged year it is the reason the gate fires.
|
|
829
|
+
warnings.append(
|
|
830
|
+
f"payment year {params.payment_year} is staged: its Rate Announcement "
|
|
831
|
+
f"factors are published and pinned ({py['source']}), but CMS's "
|
|
832
|
+
f"midyear-final model software for that year is not vendored here."
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# The hard gate, and it comes before anything that describes a score.
|
|
836
|
+
#
|
|
837
|
+
# Everything above this point is a fact about the vendored data or the
|
|
838
|
+
# payment year -- true of a refusal as much as of an answer. Everything
|
|
839
|
+
# below describes a score that was computed, so on this branch it would be
|
|
840
|
+
# describing a computation that did not happen. A receipt is the whole
|
|
841
|
+
# product here; one that narrates a scoring run and returns `data: null` is
|
|
842
|
+
# the failure this route exists to prevent, wearing our own uniform.
|
|
843
|
+
#
|
|
844
|
+
# `_select_artifact` already names the mismatch on the receipt, and that was
|
|
845
|
+
# not enough. Selecting `data["models"][model]` regardless of the payment
|
|
846
|
+
# year means the mapping, the mandatory edits, the hierarchies and every
|
|
847
|
+
# coefficient come from whichever single release is vendored for that model
|
|
848
|
+
# -- so `score(model="v28", payment_year=2024)` returned a RAF to three
|
|
849
|
+
# decimals computed from PY2026 tables, with a warning underneath it. A
|
|
850
|
+
# caller acts on the number. Warned-but-wrong is not a category this product
|
|
851
|
+
# ships, and the honest answer to "what is the PY2024 V28 score" when the
|
|
852
|
+
# PY2024 V28 release is not vendored is that we do not know.
|
|
853
|
+
#
|
|
854
|
+
# The alternative was vendoring every (model, payment year) release CMS has
|
|
855
|
+
# published. That is the right long-term fix and it is a data-acquisition
|
|
856
|
+
# job, not a code change: the pairs below widen the moment those artifacts
|
|
857
|
+
# are vendored, because this test reads the vendored data's own version
|
|
858
|
+
# strings rather than a table in this module.
|
|
859
|
+
if artifact.software_payment_year != params.payment_year:
|
|
860
|
+
held = (
|
|
861
|
+
f"the {artifact.software_version} release, which CMS published for "
|
|
862
|
+
f"PY{artifact.software_payment_year}"
|
|
863
|
+
if artifact.software_payment_year is not None
|
|
864
|
+
else f"the {artifact.software_version} release, whose payment year the "
|
|
865
|
+
"vendored data does not name"
|
|
866
|
+
)
|
|
867
|
+
pairs = _scoreable_pairs(data)
|
|
868
|
+
warnings.append(
|
|
869
|
+
f"REFUSED: no {params.model} model software for payment year "
|
|
870
|
+
f"{params.payment_year} is vendored, so there is no score to give. "
|
|
871
|
+
f"Computing one would use {held}: its mapping, its mandatory edits, its "
|
|
872
|
+
f"hierarchies and its coefficients. A RAF carried to three decimals from "
|
|
873
|
+
f"another year's tables is a confident wrong answer, and this route "
|
|
874
|
+
f"returns nothing rather than that. Pairs this build can score: "
|
|
875
|
+
f"{', '.join(pairs) if pairs else 'none'}. Use hcc.map or "
|
|
876
|
+
f"hcc.hierarchy_explain to work with the vendored revision directly."
|
|
877
|
+
)
|
|
878
|
+
return _miss()
|
|
879
|
+
|
|
880
|
+
# Past the gate: from here on a score is actually being computed, so prose
|
|
881
|
+
# about one is prose about something that happened.
|
|
882
|
+
blend_weight = py["blend"][params.model]
|
|
883
|
+
if len(py["blend"]) > 1:
|
|
884
|
+
warnings.append(
|
|
885
|
+
f"payment year {params.payment_year} is a blend year "
|
|
886
|
+
f"({py['blend']}); this call scores the {params.model} component only "
|
|
887
|
+
f"(weight {blend_weight}). The full payment risk score blends the "
|
|
888
|
+
f"normalized scores of both models per {py['source']}."
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Demographics -> segment.
|
|
892
|
+
if params.age is None:
|
|
893
|
+
aged = True
|
|
894
|
+
warnings.append(
|
|
895
|
+
"age not provided: demographic (age-sex) term excluded and the aged "
|
|
896
|
+
"community segment assumed; the RAF below is a partial, disease-led score."
|
|
897
|
+
)
|
|
898
|
+
else:
|
|
899
|
+
aged = params.age >= 65
|
|
900
|
+
segment = "C" + {"non": "N", "full": "F", "partial": "P"}[params.dual] + ("A" if aged else "D")
|
|
901
|
+
coefs: dict[str, str] = model["coefficients"][segment]
|
|
902
|
+
|
|
903
|
+
# Mapping + mandatory edits.
|
|
904
|
+
mapped, unmapped, edits_applied = _map_codes(
|
|
905
|
+
model, codes, params.age, params.sex, warnings
|
|
906
|
+
)
|
|
907
|
+
present = {cc for ccs in mapped.values() for cc in ccs}
|
|
908
|
+
|
|
909
|
+
# Pre-hierarchy patch, hierarchy, categories, interactions, counts.
|
|
910
|
+
present, prehier_applied = _apply_pre_hierarchy(model, present)
|
|
911
|
+
final_hccs, suppressed = _apply_hierarchy(model, present)
|
|
912
|
+
final_set = set(final_hccs)
|
|
913
|
+
categories = _category_flags(model, final_set)
|
|
914
|
+
interactions = _interaction_flags(model, final_set, categories)
|
|
915
|
+
|
|
916
|
+
terms: list[dict[str, Any]] = []
|
|
917
|
+
|
|
918
|
+
# Demographic cell.
|
|
919
|
+
if params.age is not None and params.sex is not None:
|
|
920
|
+
band = next(b for lo, hi, b in _AGE_BANDS if lo <= params.age <= hi)
|
|
921
|
+
cell = f"{params.sex.upper()}{band}"
|
|
922
|
+
if cell in coefs:
|
|
923
|
+
terms.append({"variable": cell, "kind": "demographic", "value": coefs[cell]})
|
|
924
|
+
else: # defensive; segment selection keeps bands consistent
|
|
925
|
+
warnings.append(f"no {segment} coefficient for demographic cell {cell}.")
|
|
926
|
+
elif params.age is not None and params.sex is None:
|
|
927
|
+
warnings.append(
|
|
928
|
+
"sex not provided: demographic (age-sex) term excluded; the RAF below "
|
|
929
|
+
"is a partial score."
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
if params.orig_disabled:
|
|
933
|
+
if aged and params.sex is not None:
|
|
934
|
+
var = "OriginallyDisabled_Female" if params.sex == "f" else "OriginallyDisabled_Male"
|
|
935
|
+
if var in coefs:
|
|
936
|
+
terms.append({"variable": var, "kind": "demographic", "value": coefs[var]})
|
|
937
|
+
elif aged:
|
|
938
|
+
warnings.append(
|
|
939
|
+
"orig_disabled needs sex to select its coefficient; term excluded."
|
|
940
|
+
)
|
|
941
|
+
else:
|
|
942
|
+
warnings.append(
|
|
943
|
+
"orig_disabled does not apply to the disabled community segments "
|
|
944
|
+
"(ORIGDS is zero when DISABL=1 per AGESEXV2.TXT); term excluded."
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
# Disease coefficients (post-hierarchy payment HCCs).
|
|
948
|
+
for hcc in final_hccs:
|
|
949
|
+
var = f"HCC{hcc}"
|
|
950
|
+
if var in coefs:
|
|
951
|
+
terms.append(
|
|
952
|
+
{
|
|
953
|
+
"variable": var,
|
|
954
|
+
"kind": "hcc",
|
|
955
|
+
"label": model["labels"].get(str(hcc), ""),
|
|
956
|
+
"value": coefs[var],
|
|
957
|
+
}
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
# Interactions present in this segment's regression.
|
|
961
|
+
for name, flag in sorted(interactions.items()):
|
|
962
|
+
if flag and name in coefs:
|
|
963
|
+
terms.append({"variable": name, "kind": "interaction", "value": coefs[name]})
|
|
964
|
+
|
|
965
|
+
# Payment HCC count variable.
|
|
966
|
+
count_var = _count_variable(len(final_hccs))
|
|
967
|
+
if count_var and count_var in coefs:
|
|
968
|
+
terms.append(
|
|
969
|
+
{
|
|
970
|
+
"variable": count_var,
|
|
971
|
+
"kind": "payment_hcc_count",
|
|
972
|
+
"count": len(final_hccs),
|
|
973
|
+
"value": coefs[count_var],
|
|
974
|
+
}
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
raw = sum((_dec(t["value"]) for t in terms), Decimal("0.000"))
|
|
978
|
+
raw = raw.quantize(Decimal("0.001")) # documented no-op: 3dp inputs sum exactly
|
|
979
|
+
|
|
980
|
+
factor = Decimal(py["normalization"][params.model])
|
|
981
|
+
normalized = (raw / factor).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
|
|
982
|
+
|
|
983
|
+
payload = {
|
|
984
|
+
"scope": "CMS-HCC community continuing-enrollee segment",
|
|
985
|
+
"model": params.model,
|
|
986
|
+
"model_name": model["cms_name"],
|
|
987
|
+
"software_version": model["software_version"],
|
|
988
|
+
"payment_year": params.payment_year,
|
|
989
|
+
"segment": {"code": segment, "description": _SEGMENT_NAMES[segment]},
|
|
990
|
+
"inputs": {
|
|
991
|
+
"diagnoses": codes,
|
|
992
|
+
"age": params.age,
|
|
993
|
+
"sex": params.sex,
|
|
994
|
+
"dual": params.dual,
|
|
995
|
+
"orig_disabled": params.orig_disabled,
|
|
996
|
+
},
|
|
997
|
+
"mapping": {
|
|
998
|
+
"mapped": [{"code": c, "ccs": mapped[c]} for c in codes if c in mapped],
|
|
999
|
+
"unmapped": unmapped,
|
|
1000
|
+
},
|
|
1001
|
+
"edits_applied": edits_applied,
|
|
1002
|
+
"pre_hierarchy_rules_applied": prehier_applied,
|
|
1003
|
+
"hierarchy": {
|
|
1004
|
+
"final_hccs": [_hcc_entry(model, h) for h in final_hccs],
|
|
1005
|
+
"suppressed": suppressed,
|
|
1006
|
+
},
|
|
1007
|
+
"terms": terms,
|
|
1008
|
+
"raw_score": str(raw),
|
|
1009
|
+
"raw_score_state": (
|
|
1010
|
+
"pre-normalization: exact sum of published 3-decimal coefficients; "
|
|
1011
|
+
"CMS's PY2026 Python model software rounds this value to 3 decimals, "
|
|
1012
|
+
"which is a no-op under exact decimal arithmetic"
|
|
1013
|
+
),
|
|
1014
|
+
"normalization": {
|
|
1015
|
+
"factor": str(factor),
|
|
1016
|
+
"factor_source": py["source"],
|
|
1017
|
+
"applied_as": (
|
|
1018
|
+
"normalized_score = raw_score / factor -- 'we apply it by dividing "
|
|
1019
|
+
"each individual risk score in the payment year by the normalization "
|
|
1020
|
+
"factor' (CY2027 Advance Notice p.59)"
|
|
1021
|
+
),
|
|
1022
|
+
"rounding": "3 decimals, ROUND_HALF_UP (see VERIFIED_ADDENDUM)",
|
|
1023
|
+
},
|
|
1024
|
+
"normalized_score": str(normalized),
|
|
1025
|
+
"normalized_score_state": "post-normalization",
|
|
1026
|
+
"blend": {
|
|
1027
|
+
"payment_year_blend": py["blend"],
|
|
1028
|
+
"requested_model_weight": blend_weight,
|
|
1029
|
+
},
|
|
1030
|
+
"not_applied": [
|
|
1031
|
+
f"MA coding pattern difference adjustment "
|
|
1032
|
+
f"({Decimal(py['coding_pattern_adjustment']) * 100:.2f} percent)",
|
|
1033
|
+
"frailty adjustment (PACE/FIDE SNP)",
|
|
1034
|
+
"MCE age/sex range edits (optional SEDITS lane of the CMS software)",
|
|
1035
|
+
"new-enrollee, institutional, ESRD, and RxHCC models",
|
|
1036
|
+
],
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return ToolResult(
|
|
1040
|
+
data=payload,
|
|
1041
|
+
receipt=build_receipt(
|
|
1042
|
+
contract=CONTRACT,
|
|
1043
|
+
route="hcc.score",
|
|
1044
|
+
fetch=result,
|
|
1045
|
+
source_version=f"{model['software_version']} + PY{params.payment_year} factors",
|
|
1046
|
+
transform_version=TRANSFORM_VERSION,
|
|
1047
|
+
effective_from=artifact.effective_from,
|
|
1048
|
+
effective_to=artifact.effective_to,
|
|
1049
|
+
warnings=warnings,
|
|
1050
|
+
non_claims=NON_CLAIMS_COMMON,
|
|
1051
|
+
),
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _hierarchy_handler(params: HierarchyParams) -> ToolResult:
|
|
1056
|
+
result, data = _load()
|
|
1057
|
+
model = data["models"][params.model]
|
|
1058
|
+
codes = _normalize_codes(params.diagnoses)
|
|
1059
|
+
warnings: list[str] = []
|
|
1060
|
+
|
|
1061
|
+
mapped, unmapped, _ = _map_codes(model, codes, age=None, sex=None, warnings=warnings)
|
|
1062
|
+
present = {cc for ccs in mapped.values() for cc in ccs}
|
|
1063
|
+
pre = sorted(present)
|
|
1064
|
+
present, prehier_applied = _apply_pre_hierarchy(model, present)
|
|
1065
|
+
final_hccs, suppressed = _apply_hierarchy(model, present)
|
|
1066
|
+
|
|
1067
|
+
payload = {
|
|
1068
|
+
"model": params.model,
|
|
1069
|
+
"model_name": model["cms_name"],
|
|
1070
|
+
"software_version": model["software_version"],
|
|
1071
|
+
"before_hierarchy": [_hcc_entry(model, h) for h in pre],
|
|
1072
|
+
"pre_hierarchy_rules_applied": prehier_applied,
|
|
1073
|
+
"suppressed": [
|
|
1074
|
+
{
|
|
1075
|
+
"hcc": s["hcc"],
|
|
1076
|
+
"label": model["labels"].get(str(s["hcc"]), ""),
|
|
1077
|
+
"suppressed_by": s["suppressed_by"],
|
|
1078
|
+
"suppressed_by_label": model["labels"].get(str(s["suppressed_by"]), ""),
|
|
1079
|
+
}
|
|
1080
|
+
for s in suppressed
|
|
1081
|
+
],
|
|
1082
|
+
"final_hccs": [_hcc_entry(model, h) for h in final_hccs],
|
|
1083
|
+
"semantics": (
|
|
1084
|
+
"sequential ascending-parent application per the SAS hierarchy macro "
|
|
1085
|
+
"file order; a parent zeroed by an earlier rule no longer suppresses "
|
|
1086
|
+
"its own children"
|
|
1087
|
+
),
|
|
1088
|
+
"unmapped": unmapped,
|
|
1089
|
+
}
|
|
1090
|
+
artifact = _select_artifact(data, params.model)
|
|
1091
|
+
warnings.extend(artifact.warnings)
|
|
1092
|
+
return ToolResult(
|
|
1093
|
+
data=payload,
|
|
1094
|
+
receipt=build_receipt(
|
|
1095
|
+
contract=CONTRACT,
|
|
1096
|
+
route="hcc.hierarchy_explain",
|
|
1097
|
+
fetch=result,
|
|
1098
|
+
source_version=model["software_version"],
|
|
1099
|
+
transform_version=TRANSFORM_VERSION,
|
|
1100
|
+
effective_from=artifact.effective_from,
|
|
1101
|
+
effective_to=artifact.effective_to,
|
|
1102
|
+
warnings=warnings,
|
|
1103
|
+
non_claims=NON_CLAIMS_COMMON,
|
|
1104
|
+
),
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
# --------------------------------------------------------------------------
|
|
1109
|
+
# Canaries
|
|
1110
|
+
# --------------------------------------------------------------------------
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
_RELEASE_DRIFT_REMEDIATION = (
|
|
1114
|
+
"CMS republished or retired a pinned model-software zip (Content-Length/"
|
|
1115
|
+
"Last-Modified moved). Re-download the changed zip, recompute sha256, diff "
|
|
1116
|
+
"the inner-zip listing against the provenance block in the vendored data, "
|
|
1117
|
+
"regenerate with `python tests/fixtures/hcc/regenerate_vendored_data.py "
|
|
1118
|
+
"--source-dir <dir>` if tables changed, then re-pin with `hc-source lock "
|
|
1119
|
+
"init`. If a URL returns HTTP 404, re-run discovery from the risk-adjustment "
|
|
1120
|
+
"hub page -- CMS renames slugs (see the `.zip-0` trap in the route dossier)."
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
_PY2028_REMEDIATION = (
|
|
1124
|
+
"PY2028 model-software artifacts appeared on cms.gov. This is a STAGED future "
|
|
1125
|
+
"cycle, not breakage: fetch the 2028 model-software page from the risk-"
|
|
1126
|
+
"adjustment hub, inventory the inner zips (watch for a model version other "
|
|
1127
|
+
"than V28 -- a '2027 CMS-HCC model' was proposed but not finalized in the "
|
|
1128
|
+
"CY2027 cycle), extract the PY2028 normalization factors from the 2028 Rate "
|
|
1129
|
+
"Announcement, extend payment_years in the vendored data, and re-pin with "
|
|
1130
|
+
"`hc-source lock init`."
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _artifact_identity(result: FetchResult) -> str:
|
|
1135
|
+
"""One model artifact's release identity, as served.
|
|
1136
|
+
|
|
1137
|
+
cms.gov sends no ETag for these zips, so identity is Content-Length plus
|
|
1138
|
+
Last-Modified: the pair that moves when CMS republishes a zip IN PLACE at the
|
|
1139
|
+
same URL. A 4xx/5xx is an identity too -- a retired slug is exactly the event
|
|
1140
|
+
worth reporting.
|
|
1141
|
+
|
|
1142
|
+
A 2xx that carries neither a size nor a Last-Modified is refused rather than
|
|
1143
|
+
reduced to a placeholder. An observation of ``?@?`` would pin cleanly and
|
|
1144
|
+
stay green forever, which is the failure this identity exists to prevent.
|
|
1145
|
+
"""
|
|
1146
|
+
if result.status is not None and result.status >= 400:
|
|
1147
|
+
return f"HTTP{result.status}"
|
|
1148
|
+
content_range = result.headers.get("content-range", "")
|
|
1149
|
+
size = (
|
|
1150
|
+
content_range.rsplit("/", 1)[-1]
|
|
1151
|
+
if "/" in content_range
|
|
1152
|
+
else result.headers.get("content-length")
|
|
1153
|
+
)
|
|
1154
|
+
last_modified = result.headers.get("last-modified")
|
|
1155
|
+
if size and last_modified:
|
|
1156
|
+
# 'Tue, 23 Dec 2025 20:02:15 GMT' -> '23 Dec 2025'.
|
|
1157
|
+
day = (
|
|
1158
|
+
" ".join(last_modified.split()[1:4])
|
|
1159
|
+
if last_modified.count(" ") >= 4
|
|
1160
|
+
else last_modified
|
|
1161
|
+
)
|
|
1162
|
+
return f"{size}@{day}"
|
|
1163
|
+
if result.status is None:
|
|
1164
|
+
# A locally mirrored artifact (file://) has no HTTP identity headers, so
|
|
1165
|
+
# its bytes are the identity. The prefix self-describes: a pin taken
|
|
1166
|
+
# against a mirror must never read as one taken against cms.gov.
|
|
1167
|
+
return f"mirror-sha256:{result.sha256[:12]}"
|
|
1168
|
+
raise SourceUnreachable(
|
|
1169
|
+
result.url,
|
|
1170
|
+
"response carries neither a size (Content-Range/Content-Length) nor a "
|
|
1171
|
+
"Last-Modified, so this artifact's release identity cannot be observed "
|
|
1172
|
+
"and pinning it would attest to nothing",
|
|
1173
|
+
status=result.status,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _probe_handler(_: NoParams) -> ToolResult:
|
|
1178
|
+
"""Live upstream identity probe: a wider, human-readable form of what the
|
|
1179
|
+
``hcc.model_software_release`` canary pins. One 1-byte ranged GET per pinned
|
|
1180
|
+
zip (cms.gov serves no ETag: identity = Content-Length + Last-Modified;
|
|
1181
|
+
confirm with sha256 after any full re-download) plus the deterministic
|
|
1182
|
+
PY2028 404 probe, which the canary deliberately leaves out because a future
|
|
1183
|
+
cycle opening is a staged watch item, not artifact drift."""
|
|
1184
|
+
data_result, _data = _load()
|
|
1185
|
+
warnings: list[str] = []
|
|
1186
|
+
artifacts = []
|
|
1187
|
+
for name, url, pinned_size, pinned_lm in _pinned_artifacts():
|
|
1188
|
+
result = fetch(url, headers={"Range": "bytes=0-0"}, raise_for_status=False)
|
|
1189
|
+
observed = _artifact_identity(result)
|
|
1190
|
+
pinned = f"{pinned_size}@{pinned_lm}"
|
|
1191
|
+
status = "ok" if observed == pinned else "drift"
|
|
1192
|
+
if status == "drift":
|
|
1193
|
+
warnings.append(
|
|
1194
|
+
f"{name}: observed {observed}, pinned {pinned}. {_RELEASE_DRIFT_REMEDIATION}"
|
|
1195
|
+
)
|
|
1196
|
+
artifacts.append(
|
|
1197
|
+
{"artifact": name, "url": url, "pinned": pinned, "observed": observed,
|
|
1198
|
+
"status": status}
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
probe = fetch(PY2028_PROBE_URL, raise_for_status=False)
|
|
1202
|
+
py2028 = "absent" if probe.status == 404 else f"published(HTTP {probe.status})"
|
|
1203
|
+
if py2028 != "absent":
|
|
1204
|
+
warnings.append(f"PY2028 cycle opened. {_PY2028_REMEDIATION}")
|
|
1205
|
+
|
|
1206
|
+
payload = {
|
|
1207
|
+
"artifacts": artifacts,
|
|
1208
|
+
"py2028_cycle": py2028,
|
|
1209
|
+
"remediation_on_drift": _RELEASE_DRIFT_REMEDIATION,
|
|
1210
|
+
"remediation_on_py2028": _PY2028_REMEDIATION,
|
|
1211
|
+
}
|
|
1212
|
+
return ToolResult(
|
|
1213
|
+
data=payload,
|
|
1214
|
+
receipt=build_receipt(
|
|
1215
|
+
contract=CONTRACT,
|
|
1216
|
+
route="hcc.release_probe",
|
|
1217
|
+
fetch=data_result,
|
|
1218
|
+
source_version="pinned artifact identities, verified 2026-08-01",
|
|
1219
|
+
transform_version=TRANSFORM_VERSION,
|
|
1220
|
+
warnings=warnings,
|
|
1221
|
+
non_claims=NON_CLAIMS_COMMON
|
|
1222
|
+
+ [
|
|
1223
|
+
"UPSTREAM_IDENTITY_ONLY: this probe compares Content-Length and "
|
|
1224
|
+
"Last-Modified of the pinned CMS zips; it does not download or "
|
|
1225
|
+
"re-hash their bytes -- confirm any drift with a full download "
|
|
1226
|
+
"and sha256 before re-vendoring."
|
|
1227
|
+
],
|
|
1228
|
+
),
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _observe_vendored_tables() -> CanaryObservation:
|
|
1233
|
+
_, data = _load()
|
|
1234
|
+
return CanaryObservation(
|
|
1235
|
+
value=_canonical_hash(data)[:16],
|
|
1236
|
+
schema_hash=_schema_hash(data),
|
|
1237
|
+
note="canonicalized-content hash of the vendored mapping/coefficient tables",
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _vendored_remediation(status: CanaryStatus, observed: str | None, expected: str | None) -> str:
|
|
1242
|
+
if status is CanaryStatus.SCHEMA_CHANGED:
|
|
1243
|
+
return (
|
|
1244
|
+
"The vendored hcc tables changed SHAPE (model/payment-year key structure). "
|
|
1245
|
+
"This only happens on an intentional re-vendor: review the regeneration "
|
|
1246
|
+
"diff, bump TRANSFORM_VERSION in hc_source/adapters/hcc.py, and re-pin "
|
|
1247
|
+
"with `hc-source lock init`."
|
|
1248
|
+
)
|
|
1249
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1250
|
+
return (
|
|
1251
|
+
"The vendored hcc tables could not be read. Unset HC_SOURCE_HCC_DATA if "
|
|
1252
|
+
"it points at a missing/corrupt file, or reinstall the package; then "
|
|
1253
|
+
"re-run `hc-source doctor`."
|
|
1254
|
+
)
|
|
1255
|
+
return (
|
|
1256
|
+
"The vendored hcc tables' content hash moved without a matching lockfile "
|
|
1257
|
+
"update -- either HC_SOURCE_HCC_DATA points at modified data or the package "
|
|
1258
|
+
"was tampered with. Verify the regeneration provenance (tests/fixtures/hcc/"
|
|
1259
|
+
"derive_report.txt), regenerate from the pinned CMS artifacts, and only "
|
|
1260
|
+
"then re-pin with `hc-source lock init`."
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _observe_coefficient_spots() -> CanaryObservation:
|
|
1265
|
+
# Value kept short for the doctor table. Field order:
|
|
1266
|
+
# v28 CNA_F65_69 | v24 CFA_F65_69 | 2026 v28 norm | 2024 v24 norm |
|
|
1267
|
+
# v28 payment-HCC count | v24 payment-HCC count.
|
|
1268
|
+
_, data = _load()
|
|
1269
|
+
m28, m24 = data["models"]["v28"], data["models"]["v24"]
|
|
1270
|
+
spots = [
|
|
1271
|
+
str(m28["coefficients"]["CNA"].get("F65_69", "?")),
|
|
1272
|
+
str(m24["coefficients"]["CFA"].get("F65_69", "?")),
|
|
1273
|
+
str(data["payment_years"]["2026"]["normalization"].get("v28", "?")),
|
|
1274
|
+
str(data["payment_years"]["2024"]["normalization"].get("v24", "?")),
|
|
1275
|
+
str(len(m28.get("payment_hccs", []))),
|
|
1276
|
+
str(len(m24.get("payment_hccs", []))),
|
|
1277
|
+
]
|
|
1278
|
+
return CanaryObservation(
|
|
1279
|
+
value="|".join(spots),
|
|
1280
|
+
note="v28 CNA_F65_69 | v24 CFA_F65_69 | 2026/2024 normalization | payment-HCC counts",
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _observe_model_software_release() -> CanaryObservation:
|
|
1285
|
+
"""LIVE upstream identity of the model-software artifacts the tables came from.
|
|
1286
|
+
|
|
1287
|
+
Every other hcc canary observes VENDORED bytes, so every one of them stays
|
|
1288
|
+
green forever if CMS republishes the V24 or V28 zip in place at the same URL:
|
|
1289
|
+
the package would simply be quietly out of date, and both source-lock.json
|
|
1290
|
+
and the GitHub Action would keep saying so. This canary is the one hcc
|
|
1291
|
+
observation that leaves the machine, which is why it is declared ``live`` in
|
|
1292
|
+
tests/test_offline_doctor.py and surfaces as UNREACHABLE with no network.
|
|
1293
|
+
"""
|
|
1294
|
+
identities = [
|
|
1295
|
+
_artifact_identity(fetch(url, headers={"Range": "bytes=0-0"}, raise_for_status=False))
|
|
1296
|
+
for _name, url, _size, _last_modified in _pinned_artifacts()
|
|
1297
|
+
]
|
|
1298
|
+
return CanaryObservation(
|
|
1299
|
+
value="|".join(identities),
|
|
1300
|
+
note="upstream Content-Length@Last-Modified of "
|
|
1301
|
+
+ ", ".join(name for name, *_rest in _PINNED_ARTIFACTS)
|
|
1302
|
+
+ ", in that order",
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _model_release_remediation(
|
|
1307
|
+
status: CanaryStatus, observed: str | None, expected: str | None
|
|
1308
|
+
) -> str:
|
|
1309
|
+
# ERROR as well as UNREACHABLE: a canary that failed for any reason did not
|
|
1310
|
+
# observe upstream, and telling that operator CMS republished something
|
|
1311
|
+
# would be a guess dressed as a finding.
|
|
1312
|
+
if status in (CanaryStatus.UNREACHABLE, CanaryStatus.ERROR):
|
|
1313
|
+
return (
|
|
1314
|
+
"The pinned CMS model-software zips could not be read, so this run does "
|
|
1315
|
+
"not know whether the vendored tables still match upstream; the other hcc "
|
|
1316
|
+
"canaries covered the vendored tables only. With no network that is "
|
|
1317
|
+
"expected. With network: check "
|
|
1318
|
+
"https://www.cms.gov/medicare/payment/medicare-advantage-rates-statistics/"
|
|
1319
|
+
"risk-adjustment for a renamed slug (CMS renames them; see the `.zip-0` "
|
|
1320
|
+
f"trap in the route dossier), point {_ENV_PREFIX}SOFTWARE_2026_URL and its "
|
|
1321
|
+
"SOFTWARE_2025/SOFTWARE_2026_PY siblings at the current locations if they "
|
|
1322
|
+
"moved, then re-run `hc-source doctor`."
|
|
1323
|
+
)
|
|
1324
|
+
return _RELEASE_DRIFT_REMEDIATION
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
_SPOT_REMEDIATION = (
|
|
1328
|
+
"A pinned spot value moved. The authorities are: CY2024 Rate Announcement "
|
|
1329
|
+
"Table VIII-1 (v28 CNA_F65_69 = 0.330), CY2020 Rate Announcement Table VI-1 "
|
|
1330
|
+
"(v24 CFA_F65_69 = 0.441), CY2026 Rate Announcement p.5 (2026 v28 "
|
|
1331
|
+
"normalization = 1.067), CY2024 Rate Announcement pp.5-6 (2024 v24 "
|
|
1332
|
+
"normalization = 1.146), and the model software HCC counts (115/86). Compare "
|
|
1333
|
+
"the vendored data against those documents, fix the regeneration, and re-pin "
|
|
1334
|
+
"with `hc-source lock init` only after the values match the PDFs."
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
# --------------------------------------------------------------------------
|
|
1339
|
+
# Adapter
|
|
1340
|
+
# --------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
class HccAdapter(SourceAdapter):
|
|
1344
|
+
source_id = SOURCE_ID
|
|
1345
|
+
contract = CONTRACT
|
|
1346
|
+
|
|
1347
|
+
# Mixed: the vendored-table canaries never touch the network, the release
|
|
1348
|
+
# trains do. Retries only ever fire on a transient SourceUnreachable, so
|
|
1349
|
+
# declaring them at the adapter costs the offline canaries nothing.
|
|
1350
|
+
canary_retries = 2
|
|
1351
|
+
|
|
1352
|
+
def canaries(self) -> list[Canary]:
|
|
1353
|
+
# Two tiers, both under doctor. The offline tier (vendored-content hash
|
|
1354
|
+
# + coefficient invariants) proves the package was not modified. It
|
|
1355
|
+
# cannot prove the package is still CURRENT: CMS republishes these zips
|
|
1356
|
+
# in place at the same URL, and an adapter whose every canary reads its
|
|
1357
|
+
# own vendored bytes reports green forever afterwards. The live tier --
|
|
1358
|
+
# `hcc.model_software_release` -- is the one that can see that happen.
|
|
1359
|
+
# It is UNREACHABLE offline, like every other live canary in the
|
|
1360
|
+
# product, and the PY2028 cycle probe stays in `hcc.release_probe`
|
|
1361
|
+
# because a future cycle opening is a watch item, not artifact drift.
|
|
1362
|
+
return [
|
|
1363
|
+
Canary(
|
|
1364
|
+
canary_id="hcc.vendored_tables",
|
|
1365
|
+
source_id=SOURCE_ID,
|
|
1366
|
+
description=(
|
|
1367
|
+
"Canonicalized-content hash and shape of the vendored mapping/"
|
|
1368
|
+
"hierarchy/coefficient tables (offline)."
|
|
1369
|
+
),
|
|
1370
|
+
observe=_observe_vendored_tables,
|
|
1371
|
+
remediation=_vendored_remediation(CanaryStatus.DRIFT, None, None),
|
|
1372
|
+
remediation_for=_vendored_remediation,
|
|
1373
|
+
),
|
|
1374
|
+
Canary(
|
|
1375
|
+
canary_id="hcc.coefficient_spots",
|
|
1376
|
+
source_id=SOURCE_ID,
|
|
1377
|
+
description=(
|
|
1378
|
+
"Spot coefficient and normalization values against the published "
|
|
1379
|
+
"Rate Announcement tables (offline)."
|
|
1380
|
+
),
|
|
1381
|
+
observe=_observe_coefficient_spots,
|
|
1382
|
+
remediation=_SPOT_REMEDIATION,
|
|
1383
|
+
),
|
|
1384
|
+
Canary(
|
|
1385
|
+
canary_id="hcc.model_software_release",
|
|
1386
|
+
source_id=SOURCE_ID,
|
|
1387
|
+
description=(
|
|
1388
|
+
"Upstream release identity of the CMS model-software zips the "
|
|
1389
|
+
"vendored tables were derived from (Content-Length + Last-Modified "
|
|
1390
|
+
"via 1-byte ranged GETs) -- the only hcc canary that can see CMS "
|
|
1391
|
+
"republish a model zip in place (live)."
|
|
1392
|
+
),
|
|
1393
|
+
observe=_observe_model_software_release,
|
|
1394
|
+
remediation=_RELEASE_DRIFT_REMEDIATION,
|
|
1395
|
+
remediation_for=_model_release_remediation,
|
|
1396
|
+
),
|
|
1397
|
+
]
|
|
1398
|
+
|
|
1399
|
+
def tools(self) -> list[ToolSpec]:
|
|
1400
|
+
return [
|
|
1401
|
+
ToolSpec(
|
|
1402
|
+
name="hcc.map",
|
|
1403
|
+
description=(
|
|
1404
|
+
"Map ICD-10-CM codes to CMS-HCC payment HCCs (V24 or V28) with "
|
|
1405
|
+
"labels and per-mapping provenance to the exact CMS model-software "
|
|
1406
|
+
"file."
|
|
1407
|
+
),
|
|
1408
|
+
params_model=MapParams,
|
|
1409
|
+
handler=_map_handler,
|
|
1410
|
+
tags=("lookup",),
|
|
1411
|
+
),
|
|
1412
|
+
ToolSpec(
|
|
1413
|
+
name="hcc.score",
|
|
1414
|
+
description=(
|
|
1415
|
+
"Community continuing-enrollee RAF score for a diagnosis list "
|
|
1416
|
+
"under CMS-HCC V24 or V28 for a payment year: mapping, mandatory "
|
|
1417
|
+
"edits, hierarchies, interactions, HCC counts, coefficient sum, "
|
|
1418
|
+
"and pinned normalization -- raw and normalized, clearly labeled."
|
|
1419
|
+
),
|
|
1420
|
+
params_model=ScoreParams,
|
|
1421
|
+
handler=_score_handler,
|
|
1422
|
+
tags=("score",),
|
|
1423
|
+
),
|
|
1424
|
+
ToolSpec(
|
|
1425
|
+
name="hcc.release_probe",
|
|
1426
|
+
description=(
|
|
1427
|
+
"Live identity check of the pinned CMS model-software zips "
|
|
1428
|
+
"(Content-Length + Last-Modified via 1-byte ranged GETs) plus "
|
|
1429
|
+
"the PY2028 cycle probe -- the same identities the "
|
|
1430
|
+
"hcc.model_software_release canary pins, reported per artifact "
|
|
1431
|
+
"against the pins recorded in this module, with remediation."
|
|
1432
|
+
),
|
|
1433
|
+
params_model=NoParams,
|
|
1434
|
+
handler=_probe_handler,
|
|
1435
|
+
tags=("watch",),
|
|
1436
|
+
),
|
|
1437
|
+
ToolSpec(
|
|
1438
|
+
name="hcc.hierarchy_explain",
|
|
1439
|
+
description=(
|
|
1440
|
+
"Explain CMS-HCC hierarchy suppression for a diagnosis list: which "
|
|
1441
|
+
"HCCs were zeroed by which, under V24 or V28."
|
|
1442
|
+
),
|
|
1443
|
+
params_model=HierarchyParams,
|
|
1444
|
+
handler=_hierarchy_handler,
|
|
1445
|
+
tags=("explain",),
|
|
1446
|
+
),
|
|
1447
|
+
]
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
ADAPTER = HccAdapter()
|