File size: 17,250 Bytes
f266901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# Premium Dependency Map

| Field | Value |
| --- | --- |
| Document type | Dependency / change-cascade map (data + code) |
| Subject data file | `40-data/premiums/illustrative_premiums.json` |
| Generated (this doc) | 2026-05-18 |
| Companion | [`premium-source-map.md`](premium-source-map.md) (per-sample provenance) |
| Source of truth for chain | `backend/premium_calculator.py`, `backend/brain_tools.py`, `backend/main.py`, `backend/scorecard.py` |

## 0. Purpose

This document is the **change-cascade contract for the premium pipeline**. It exists so a future edit to the curated JSON, a calculator helper, an API contract, or the reviews/scorecard parity surface does not silently break a downstream consumer.

Every node below names: **what it consumes**, **what depends on it**, and an explicit **"if you change X you must re-verify Y"** rule. Function and constant names are taken verbatim from the source files (read 2026-05-18); they are not paraphrased.

## 1. The pricing chain (end to end)

```
40-data/premiums/illustrative_premiums.json
   β”‚  (base_premiums{}, scaling_factors{})
   β–Ό
premium_calculator._load_data()                     [reads + json.loads the file; {} on any error]
   β–Ό
premium_calculator._canonical_sample_key()          [resolve recommended/marketplace id β†’ base_premiums key]
   β”‚   β”œβ”€ _SAMPLE_DOCTYPE_SUFFIXES   (__brochure/__cis/__wordings/__prospectus/__policy)
   β”‚   └─ _KNOWN_BAD_SAMPLE_KEYS     (currently EMPTY frozenset β€” quarantine mechanism, retained)
   β–Ό
premium_calculator._plausible_samples()             [type-aware β‚Ή/lakh sanity guard, via _per_lakh_band()]
   β–Ό
premium_calculator._interpolate_from_samples()      [nearest sample in (age, log SI) space]
   β–Ό
premium_calculator.estimate()                       [#38 FULL ratio-normalization + OUTPUT plausibility ceiling]
   β”‚      └─ NO-sample path β†’ _attribute_base_factor()  (product-TYPE model; no JSON I/O)
   β–Ό
premium_calculator.bulk_estimate()                  [calls estimate() per policy on the curated path;
   β”‚                                                  flat β‚Ή500/lakh Γ— type-factor on the no-sample path]
   β–Ό
premium_calculator.estimate_premium_band()          [prices the 26-policy _DEFAULT_BAND_POLICY_IDS basket;
   β”‚                                                  p25–p75 interquartile via resolve_profile_sum_insured()]
   β–Ό
backend/main.py  POST /api/premium/estimate         (PremiumEstimateRequest β†’ PremiumEstimateResponse)
                 POST /api/premium/bulk             (PremiumBulkRequest β†’ PremiumBulkResponse)
                 GET  /api/profile/predicted-premium-band  (β†’ PredictedPremiumBandResponse)
   β–Ό
frontend/src/lib/api.ts  postPremiumEstimate() / PremiumEstimateResponse
   β–Ό
frontend/src/components/PolicyPremiumWidget.tsx     (point + Β±15% band + methodology line + SI disclosure)
        └─ embedded in PolicyCompareModal.tsx; header chip in app/page.tsx (premiumBand state)
```

Parallel, independent chain (claim-experience parity, **not** premium-priced):

```
40-data/reviews/<slug>.json
   β–Ό
brain_tools._insurer_reviews(slug)        [cached read; None if missing]
   β–Ό
scorecard.score_claim_experience(p, insurer_reviews=…)   [IRDAI CSR + complaints/10k β†’ sub-score]
   β–Ό
scorecard.build_scorecard(data, insurer_reviews=…, profile=…)
   β”œβ”€ recommendation path:  brain_tools._scorecard_signal()  β†’ cited-card grade
   └─ marketplace path:     main.py /api/policies/all         β†’ marketplace grade
   β–Ό  PARITY INVARIANT (tests/test_scorecard_parity.py): cited-card GRADE LETTER == marketplace GRADE LETTER
```

## 2. Node-by-node dependency contract

### 2.1 `40-data/premiums/illustrative_premiums.json`

- **Consumes:** nothing β€” it is the single source of truth. Top-level keys: `last_updated`, `methodology`, `sources_consulted`, `notes`, `base_premiums` (100 entries), `scaling_factors`, `link_rot_repairs`.
- **Depended on by:** `_load_data()` (the ONLY reader), and transitively everything below it. Also documented by [`premium-source-map.md`](premium-source-map.md).
- **Sample shape (post-harvest):** evidenced samples carry `age`, `sum_insured_inr`, `city_tier`, `smoker`, `family_size`, `annual_premium_inr`, `source_url`, `source_quote`, `source_quality`, `fetched_on` (+ optional `source_note`/`derivation_note`/`variant`). 194 evidenced samples across 73 entries; 27 entries are model-only.
- **If you change X you must re-verify Y:**
  - Change/add a `samples[]` entry β†’ re-run `_plausible_samples()` mentally against `_per_lakh_band()` for that policy type (comprehensive 250–6500/L; top-up 10–800/L; benefit plans unbounded). A sample outside the band is silently dropped, so the policy may regress to the model path.
  - Change a `base_premiums` **key** β†’ re-verify `_canonical_sample_key()` still resolves the marketplace/recommended id (suffix + single-hyphen forms) AND that the key appears in `_DEFAULT_BAND_POLICY_IDS` if it should be in the header basket.
  - Change `scaling_factors` (age/SI/city/floater/smoker/PED multipliers) β†’ re-verify the **#38 ratio-normalization** in `estimate()`: it divides the sample's own multipliers out and re-applies the user's, so a changed factor moves every curated-path estimate. Re-run `tests/test_premium_attribute_and_normalization.py` + `tests/test_premium_reconciliation.py`.
  - Edit/add/remove any evidenced sample β†’ **regenerate [`premium-source-map.md`](premium-source-map.md) Β§1 counts and Β§2/Β§3 tables in the same commit** (the JSON is the single source of truth for both docs).

### 2.2 `premium_calculator._load_data()`

- **Consumes:** `PREMIUM_DATA` path (`settings.DATA_DIR / "premiums" / "illustrative_premiums.json"`).
- **Depended on by:** `estimate()`, `bulk_estimate()` (each call re-reads β€” no module cache).
- **If you change X:** moving/renaming the JSON, or breaking its JSON validity, makes `_load_data()` return `{}` **silently** β†’ every policy falls to `FALLBACK_*` constants (no exception, no log). Re-verify by calling `estimate(policy_id=...)` for a known-curated policy and asserting `base_sample_used is not None`.

### 2.3 `premium_calculator._canonical_sample_key()`

- **Consumes:** the incoming `policy_id`, `base_premiums` keys, `_SAMPLE_DOCTYPE_SUFFIXES`, `_KNOWN_BAD_SAMPLE_KEYS`.
- **Depended on by:** `estimate()` (sample lookup) **and** `bulk_estimate()` (the `if _canonical_sample_key(pid, …) is not None` branch decides curated-anchor vs flat-base). Both must agree or the widget and the per-policy panel diverge.
- **If you change X:** add a doctype suffix or change the hyphen-normalization β†’ re-verify BOTH call sites resolve the same key (a mismatch reintroduces the β‚Ή33,700 collision / the SBI Arogya Supreme double-floater bug). `_KNOWN_BAD_SAMPLE_KEYS` is currently the empty frozenset (SBI Arogya Supreme was unquarantined 2026-05-18 after its bad brochure-extract was physically replaced with real SBI rate-chart figures); to re-quarantine a proven-bad entry, add its key here AND note it in the source map.

### 2.4 `premium_calculator._per_lakh_band()` / `_plausible_samples()`

- **Consumes:** `policy_id` substring (type detection), each sample's `sum_insured_inr` + `annual_premium_inr`.
- **Depended on by:** `estimate()` (input guard before interpolation) **and** the **OUTPUT plausibility ceiling** at the end of `estimate()` (ceiling = `_hi_b * 1.5`).
- **If you change X:** widening/narrowing a band β†’ re-verify (a) no real evidenced sample is now dropped (would silently demote a policy to the model path) and (b) the output ceiling still trips only on genuinely broken data. The product-type substrings (`top-up`, `hospital-cash`, `cancer`, `critical-illness`, …) are matched against the lowercased `policy_id`; renaming a key can flip a plan between bands.

### 2.5 `premium_calculator._attribute_base_factor()` β€” the no-sample path

- **Consumes:** `policy_id` substring only. No JSON I/O β€” deterministic on the id.
- **Depended on by:** `estimate()` (no-sample branch), `bulk_estimate()` (flat-base `flat_base = BULK_BASE_INR_PER_LAKH * si_lakhs * _attribute_base_factor(pid)`), and therefore every one of the **27 model-only entries** in the source map Β§3.
- **Returns:** super-top-up/top-up 0.35Γ—, hospital-cash/fixed-benefit 0.30Γ—, cancer/critical-illness 0.55Γ—, `sanjeevani` 0.70Γ—, comprehensive 1.0Γ— (no regression for the dominant type).
- **If you change X:** changing a factor moves every model-only policy of that type AND the flat-base widget number. Re-verify against the type-band so a model-only estimate stays inside `_per_lakh_band()`; re-run `tests/test_premium_attribute_and_normalization.py`. **Provenance label:** model-path estimates carry the methodology string *"Indicative estimate modelled from this plan's product type … NOT a quote"* β€” if you ever anchor a model-only policy to a real sample, move it out of source-map Β§3 into Β§2 and the label flips to the *"Anchored to a verified public-quote sample"* variant automatically (driven by `sample_used is not None`).

### 2.6 `premium_calculator.estimate()`

- **Consumes:** `_load_data()`, `_canonical_sample_key()`, `_plausible_samples()`, `_interpolate_from_samples()`, `scaling_factors`, the B6/D2/KI-275 loadings (`_health_loading`, `_existing_cover_loading`, `_parents_loading`, `_copay_discount`, `_family_history_loading`, `_copay_multiplier`).
- **Depended on by:** `bulk_estimate()` (curated path calls `estimate()` directly), `POST /api/premium/estimate`, and indirectly the header band (`estimate_premium_band β†’ bulk_estimate β†’ estimate`).
- **Two critical internal invariants:**
  1. **#38 full ratio-normalization** β€” the sample's own age/SI/city/family multipliers are divided out, then the user's profile is applied exactly once by the unconditional city/floater/smoker/PED block. Sample `family_size` is HEADCOUNT (1=individual); `estimate()`'s floater key is dependents-beyond-self (`max(0, headcount-1)`). Breaking this re-introduces double-counted floater (the SBI β‚Ή149,800 bug).
  2. **OUTPUT plausibility ceiling** β€” if a sample-anchored point still exceeds `_per_lakh_band(...)[1] * 1.5` per lakh after normalization+loadings, it drops `sample_used`/`sources` and falls back to the policy-blind model base.
- **If you change X:** changing the loading order, the normalization, or the ceiling β†’ re-run BOTH `tests/test_premium_reconciliation.py` and `tests/test_premium_attribute_and_normalization.py`; the header-band p25–p75 contract depends on `estimate()` being stable.

### 2.7 `premium_calculator.bulk_estimate()` / `estimate_premium_band()`

- **Consumes:** `bulk_estimate()` calls `estimate()` on the curated path; `estimate_premium_band()` calls `bulk_estimate()` over `_DEFAULT_BAND_POLICY_IDS` (26 policies) and `resolve_profile_sum_insured()` for the shared SI.
- **Depended on by:** `POST /api/premium/bulk`, `GET /api/profile/predicted-premium-band`, the PolicyCompareModal widget, and the header "Premium range" chip.
- **SI contract (KI-278):** `resolve_profile_sum_insured()` precedence (`desired_sum_insured_inr ?? existing_cover_inr ?? β‚Ή10L`, snapped to β‚Ή50k) MUST stay byte-identical to `PremiumCalculatorPanel`'s slider seed (`frontend/src/app/page.tsx` ~L2417) and `PolicyPremiumWidget`'s `initialSumInsured`. The chip band is the **p25–p75 interquartile** of the basket, NOT raw min–max.
- **If you change X:**
  - Add/remove a policy in `_DEFAULT_BAND_POLICY_IDS` β†’ re-verify each id resolves via `_canonical_sample_key()` (else it silently uses the flat path) and re-check the chip band is still a sane range.
  - Change the SI precedence on either side β†’ change BOTH `resolve_profile_sum_insured()` and the page.tsx slider seed in the same commit, else header β‰  panel returns. Re-run `tests/test_premium_reconciliation.py`.

### 2.8 API layer (`backend/main.py`)

- **Consumes:** `estimate`, `bulk_estimate`, `estimate_premium_band`, `unpublished_si_disclosure`, `_policy_corroborated_si`.
- **Depended on by:** `frontend/src/lib/api.ts` (`postPremiumEstimate`, `PremiumEstimateResponse` type), `PolicyPremiumWidget.tsx`, `PolicyCompareModal.tsx`, `app/page.tsx` (`premiumBand`).
- **Contract surfaces that must not drift:** `PremiumEstimateResponse.base_sample_used` (widget shows/hides its "Estimate" badge off this β€” it is `e.base_sample_used is not None`), `methodology` (rendered verbatim under the estimate), `sources` (the source URLs), `sum_insured_disclosure` (rendered verbatim only when `_policy_corroborated_si(...).kind == "none"`). `predicted-premium-band` feeds the profile dict via `brain_tools.SLOT_UNION` with the answered-only gate (`profile.asked`).
- **If you change X:** renaming/removing a response field β†’ update `frontend/src/lib/api.ts` types + every `.tsx` consumer in the same commit. Changing `tenure_years`/`deductible_inr` snapping uses `BULK_TENURE_MULT`/`BULK_DEDUCTIBLE_DISCOUNT` from the calculator β€” keep them in sync.

### 2.9 Reviews β†’ scorecard claim-experience PARITY chain

- **Consumes:** `40-data/reviews/<slug>.json` β†’ `brain_tools._insurer_reviews(slug)` (cached; `None` if missing) β†’ `scorecard.score_claim_experience(p, insurer_reviews=…)` β†’ `scorecard.build_scorecard(data, insurer_reviews=…, profile=…)`.
- **Two paths that MUST stay in parity:**
  - **Recommendation / cited-card grade:** `brain_tools._scorecard_signal()` builds `data` via `_merge_curated(extracted, curated)` (KI-PARITY 2026-05-18 β€” curated-only made the cited grade systematically lower), resolves `slug`, passes `_insurer_reviews(slug)` into `build_scorecard`.
  - **Marketplace grade:** `/api/policies/all` builds `build_scorecard` on the full curated+reviews layer.
- **PARITY INVARIANT:** the cited-card **grade letter** must equal the marketplace **grade letter** for the same policy. Locked by `tests/test_scorecard_parity.py` (overall scores may differ by a few points because the marketplace overlays `EXTRACTED_DIR`; the LETTER, which `_recommendation_fit` gates on, must match).
- **If you change X:**
  - Edit a `40-data/reviews/<slug>.json` `claim_metrics` value β†’ re-verify `score_claim_experience()` band thresholds (CSR β‰₯95 +20, β‰₯90 +12, β‰₯85 +5, β‰₯75 βˆ’6, else βˆ’20; complaints/10k ≀10 +8 … >45 βˆ’16) and re-run `tests/test_scorecard_parity.py` β€” a CSR/complaints change can flip the sub-score enough to move the grade letter, which must move identically on BOTH paths.
  - Change `_scorecard_signal()`'s data assembly (`_merge_curated`, `_candidate_stems`, slug derivation) β†’ re-run `tests/test_scorecard_parity.py`; divergence makes the recommendation path drop policies (empty `citations` β†’ CitedPolicyCards never render).
  - This chain is **independent of premium pricing** β€” it does NOT read `illustrative_premiums.json`. Document it here only because it shares the source-methodology + dependency-map treatment and the same `_insurer_reviews`/scorecard machinery.

## 3. Quick "change X β†’ re-verify Y" lookup

| If you change… | You must re-verify… | Tests / docs to re-run |
| --- | --- | --- |
| A `samples[]` entry / a premium figure | `_plausible_samples` band, `_interpolate_from_samples` nearest pick, `estimate()` #38 normalization | `test_premium_reconciliation.py`, `test_premium_attribute_and_normalization.py`, regenerate `premium-source-map.md` |
| A `base_premiums` key name | `_canonical_sample_key` both call sites, `_DEFAULT_BAND_POLICY_IDS` membership, `_per_lakh_band`/`_attribute_base_factor` substring match | `test_premium_reconciliation.py`, source map Β§2/Β§3 |
| `scaling_factors` multipliers | `estimate()` ratio-normalization output, header band | both premium tests |
| `_attribute_base_factor` | every model-only entry + flat-base widget, type-band sanity | `test_premium_attribute_and_normalization.py`, source map Β§3 |
| `resolve_profile_sum_insured` precedence | page.tsx slider seed byte-identity, header == panel | `test_premium_reconciliation.py` |
| A `PremiumEstimateResponse` field | `frontend/src/lib/api.ts` types, `PolicyPremiumWidget.tsx`, `PolicyCompareModal.tsx`, `page.tsx` | frontend typecheck/build |
| `_DEFAULT_BAND_POLICY_IDS` | each id resolves via `_canonical_sample_key`, chip band sanity | `test_premium_reconciliation.py` |
| `40-data/reviews/<slug>.json` claim_metrics | `score_claim_experience` thresholds, cited-grade == marketplace-grade | `test_scorecard_parity.py` |
| `_scorecard_signal` data assembly | parity invariant (grade letter both paths) | `test_scorecard_parity.py` |
| Move/rename/corrupt the JSON | `_load_data()` returns `{}` SILENTLY β†’ all-fallback | manual `estimate()` smoke on a known-curated id |

## 4. Regeneration

Both this map and [`premium-source-map.md`](premium-source-map.md) are derived from the same single source of truth (`40-data/premiums/illustrative_premiums.json`) plus the code chain above. After any premium-harvest or calculator-contract change, update the affected Β§2 node contract and the Β§3 lookup row in the **same commit** as the code/data change β€” the "if you change X you must re-verify Y" rules are only useful if they stay in lockstep with the chain.