InsuranceBot / 70-docs /80-audit /premium-dependency-map.md
Claude Code
fix(pricing+scoring): rebuild premium engine to 0 logical violations + classification, faithfulness, links, writing
f266901
|
Raw
History Blame Contribute Delete
17.3 kB
# 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.