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.
@@ -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()