Spaces:
Sleeping
Sleeping
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.
|