Spaces:
Sleeping
Sleeping
Claude Code
fix(pricing+scoring): rebuild premium engine to 0 logical violations + classification, faithfulness, links, writing
f266901 | # 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. | |