Claude Code Claude Opus 4.7 (1M context) commited on
Commit
61980e6
·
1 Parent(s): 3a0fde4

fix(reco): cap 3, exclude non-extracted policies, unify premium, ask PED

Browse files

Five live recommendation-card defects, fixed at source:

- #28 hard ≤3 cap in _build_recommendation_citations + prompt 2-3 + compare modal ≤3 (>3 collapsed the layout to 1 char/line)
- #29 _has_extraction renderability gate: non-extracted policies excluded from _quality_seed_candidates AND the citation gate (was rendering raw slug / N/A / 'No extraction available' / 'Data not indexed')
- #30 'Your share & the limits' always renders both rows with 'Not specified' fallback (was omitting nulls -> different field per card)
- #31 removed Path C NonCuratedPricingNotice (dead component+styles deleted); estimate_premium_band now p25-p75 interquartile not raw min-max (was identical 30x-wide band on every non-curated card)
- #32 SYSTEM_PROMPT now mandates the explicit pre-existing-conditions question before retrieve_policies

Tests: rewrote no-cap + band-superset guards to the new contracts, added tests/test_extraction_gate.py + tests/conftest.py renderable-stub; full pytest gate green; frontend tsc + next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/brain_tools.py CHANGED
@@ -150,6 +150,35 @@ def _candidate_stems(policy_id: str) -> list[str]:
150
  return stems
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def _load_policy_facts(policy_id: str) -> dict:
154
  """Return {fact_key: value} for a policy_id, or {} when no facts file
155
  exists / is unreadable. Cached per policy_id (incl. negative cache)."""
@@ -646,6 +675,11 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
646
  if not pid or pid in seen:
647
  continue
648
  seen.add(pid)
 
 
 
 
 
649
  sig = _scorecard_signal(pid, profile=profile)
650
  ov = sig.get("_overall_score")
651
  if ov is None:
 
150
  return stems
151
 
152
 
153
+ _extraction_cache: dict = {}
154
+
155
+
156
+ def _has_extraction(policy_id: str) -> bool:
157
+ """True iff this policy has an LLM-extracted corpus file — the EXACT
158
+ renderability rule the marketplace uses (main builds its card set from
159
+ settings.EXTRACTED_DIR/*.json). A policy with curated facts but no
160
+ extracted file renders as a BROKEN card: raw policy_id as the title,
161
+ grade "N/A", "No extraction available for this policy.", "Data not
162
+ indexed" (/api/bulk-scorecard). Such a policy must NEVER be quality-
163
+ seeded or cited. Canonical-stem aware (doctype siblings count); cached
164
+ incl. negatives."""
165
+ pid = (policy_id or "").strip()
166
+ if not pid:
167
+ return False
168
+ if pid in _extraction_cache:
169
+ return _extraction_cache[pid]
170
+ ok = False
171
+ try:
172
+ for stem in _candidate_stems(pid):
173
+ if (settings.EXTRACTED_DIR / f"{stem}.json").exists():
174
+ ok = True
175
+ break
176
+ except Exception: # noqa: BLE001 — predicate must never break retrieval
177
+ ok = False
178
+ _extraction_cache[pid] = ok
179
+ return ok
180
+
181
+
182
  def _load_policy_facts(policy_id: str) -> dict:
183
  """Return {fact_key: value} for a policy_id, or {} when no facts file
184
  exists / is unreadable. Cached per policy_id (incl. negative cache)."""
 
675
  if not pid or pid in seen:
676
  continue
677
  seen.add(pid)
678
+ if not _has_extraction(pid):
679
+ # Curated-graded but no extracted corpus → its card would
680
+ # render as N/A / "No extraction available for this policy"
681
+ # / "Data not indexed". Never seed a non-renderable policy.
682
+ continue
683
  sig = _scorecard_signal(pid, profile=profile)
684
  ov = sig.get("_overall_score")
685
  if ov is None:
backend/premium_calculator.py CHANGED
@@ -953,6 +953,25 @@ def _median(xs: list[int]) -> int:
953
  return int((s[mid - 1] + s[mid]) / 2)
954
 
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  def estimate_premium_band(
957
  profile: Optional[dict] = None,
958
  candidate_policy_ids: Optional[list[str]] = None,
@@ -970,13 +989,17 @@ def estimate_premium_band(
970
  `getPredictedPremiumBand()` / the `premiumBand` state in page.tsx.
971
 
972
  Contract guarantees (KI-278, 2026-05-16):
973
- • The chip band = the SAME 26-policy basket priced at the SAME
974
- profile-resolved SI the per-settings panel uses. The panel's point
975
- estimate for the user's stated SI therefore lands INSIDE
976
- [min_inr, max_inr] by construction (it's literally one member of
977
- the basket the band aggregates), so the two surfaces can never
978
- contradict the way they did pre-fix (header ₹6.5k–₹26.5k vs panel
979
- ₹19.1k for one profile).
 
 
 
 
980
  • SI precedence is resolved by `resolve_profile_sum_insured(profile)`
981
  — byte-identical to PremiumCalculatorPanel's slider seed
982
  (`desired_sum_insured_inr ?? existing_cover_inr ?? default`). The
@@ -1035,14 +1058,15 @@ def estimate_premium_band(
1035
  }
1036
 
1037
  return {
1038
- # Directional rounding (KI-278) floor the min / ceil the max so the
1039
- # displayed band is a strict superset of every basket member. This is
1040
- # what guarantees the per-settings panel's point estimate (one basket
1041
- # member at the same profile-resolved SI) always reads as INSIDE the
1042
- # header band the user sees.
1043
- "min_inr": _floor_to_500(min(premiums)),
 
1044
  "median_inr": _round_to_500(_median(premiums)),
1045
- "max_inr": _ceil_to_500(max(premiums)),
1046
  "sample_size": len(premiums),
1047
  "assumed": bool(any_assumed),
1048
  "sum_insured_used": resolved_si,
 
953
  return int((s[mid - 1] + s[mid]) / 2)
954
 
955
 
956
+ def _percentile(xs: list[int], q: float) -> int:
957
+ """Linear-interpolated q-th percentile (q in 0..100). Used for the
958
+ predicted-premium BAND edges. The basket mixes cheap fixed-benefit
959
+ plans with premium indemnity plans, so absolute min/max sit ~4-5x
960
+ apart — a band that wide renders as a useless, broken-looking range
961
+ ("₹44,000-₹1,96,000"). The interquartile p25-p75 is the honest
962
+ "what similar profiles typically pay" range."""
963
+ if not xs:
964
+ return 0
965
+ s = sorted(xs)
966
+ if len(s) == 1:
967
+ return int(s[0])
968
+ pos = (q / 100.0) * (len(s) - 1)
969
+ lo = int(pos)
970
+ hi = min(lo + 1, len(s) - 1)
971
+ frac = pos - lo
972
+ return int(round(s[lo] + (s[hi] - s[lo]) * frac))
973
+
974
+
975
  def estimate_premium_band(
976
  profile: Optional[dict] = None,
977
  candidate_policy_ids: Optional[list[str]] = None,
 
989
  `getPredictedPremiumBand()` / the `premiumBand` state in page.tsx.
990
 
991
  Contract guarantees (KI-278, 2026-05-16):
992
+ • The chip band = the p25-p75 INTERQUARTILE of the 26-policy basket
993
+ priced at the profile-resolved SI the "what similar profiles
994
+ typically pay" range, NOT the raw min-max envelope (the basket
995
+ mixes fixed-benefit and premium indemnity plans whose absolute
996
+ spread is ~4-5x and renders as a useless, broken-looking band).
997
+ The per-settings panel shows ONE specific plan's point estimate,
998
+ which may sit inside or just outside this typical band — that is
999
+ expected and correct (a specific plan can be cheaper or pricier
1000
+ than the typical cohort); the surfaces no longer contradict
1001
+ because the band is explicitly a "typical range", not an
1002
+ absolute envelope.
1003
  • SI precedence is resolved by `resolve_profile_sum_insured(profile)`
1004
  — byte-identical to PremiumCalculatorPanel's slider seed
1005
  (`desired_sum_insured_inr ?? existing_cover_inr ?? default`). The
 
1058
  }
1059
 
1060
  return {
1061
+ # INTERQUARTILE band (p25-p75), NOT raw min-max. The basket mixes
1062
+ # cheap fixed-benefit and premium indemnity plans whose absolute
1063
+ # min/max sit ~4-5x apart a band that wide ("₹44,000-₹1,96,000")
1064
+ # is useless and reads as broken. p25-p75 is the honest "what
1065
+ # similar profiles typically pay" range; median is the typical
1066
+ # anchor. Edges still directionally rounded for clean display.
1067
+ "min_inr": _floor_to_500(_percentile(premiums, 25)),
1068
  "median_inr": _round_to_500(_median(premiums)),
1069
+ "max_inr": _ceil_to_500(_percentile(premiums, 75)),
1070
  "sample_size": len(premiums),
1071
  "assumed": bool(any_assumed),
1072
  "sum_insured_used": resolved_si,
backend/single_brain.py CHANGED
@@ -77,11 +77,21 @@ SYSTEM_PROMPT = """You are an Indian health-insurance advisor speaking with a cu
77
 
78
  YOUR JOB:
79
  1. Have a natural conversation to learn the customer's profile.
80
- 2. Once you have ALL required slots, summarise + confirm, then call retrieve_policies, then recommend 2-4 options with policy citations.
81
  3. Help the customer choose one. Cite the UIN / policy_id for every claim about features, sums insured, or premiums.
82
 
83
  REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
84
 
 
 
 
 
 
 
 
 
 
 
85
  ═══════════════════════════════════
86
  ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
87
  ═══════════════════════════════════
@@ -991,6 +1001,11 @@ def _norm_policy_name(s: str) -> str:
991
  # shortlist with a weak plan. We do NOT loosen the scorecard or fabricate;
992
  # we only stop presenting weak-fit plans AS recommendations.
993
  _MIN_RECOMMENDATION_OVERALL: float = 70.0
 
 
 
 
 
994
  _STRONG_RECOMMENDATION_GRADES: frozenset[str] = frozenset({"A", "B"})
995
 
996
 
@@ -1166,6 +1181,14 @@ def _build_recommendation_citations(
1166
  c = best_by_canon.get(k)
1167
  if c is None:
1168
  continue
 
 
 
 
 
 
 
 
1169
  strong, overall, _grade = _recommendation_fit(c)
1170
  if not strong:
1171
  dropped.append(
@@ -1196,6 +1219,8 @@ def _build_recommendation_citations(
1196
  cite = _cite_canon(k)
1197
  if cite is not None:
1198
  out.append(cite)
 
 
1199
  return out
1200
 
1201
  # ---- Path 1: explicit mark_recommendation selection -------------------
 
77
 
78
  YOUR JOB:
79
  1. Have a natural conversation to learn the customer's profile.
80
+ 2. Once you have ALL required slots, summarise + confirm, then call retrieve_policies, then recommend EXACTLY 2-3 options (NEVER more than 3 — the recommendation cards do not render past 3) with policy citations.
81
  3. Help the customer choose one. Cite the UIN / policy_id for every claim about features, sums insured, or premiums.
82
 
83
  REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
84
 
85
+ PRE-EXISTING CONDITIONS ARE MANDATORY — you MUST explicitly ASK the
86
+ customer this question (do not skip it, do not infer it): "Do you have
87
+ any pre-existing conditions — diabetes, BP / hypertension, thyroid,
88
+ heart, asthma, or a cancer history — or none?" Then call
89
+ save_profile_field(field="health_conditions", value=...) with their
90
+ answer (use value="none" when they have none). NEVER call
91
+ retrieve_policies or recommend any policy until health_conditions has
92
+ been captured this way — it materially changes eligibility, pricing
93
+ and the recommendation.
94
+
95
  ═══════════════════════════════════
96
  ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
97
  ═══════════════════════════════════
 
1001
  # shortlist with a weak plan. We do NOT loosen the scorecard or fabricate;
1002
  # we only stop presenting weak-fit plans AS recommendations.
1003
  _MIN_RECOMMENDATION_OVERALL: float = 70.0
1004
+ # Hard ceiling on cited recommendations. The CitedPolicyCards layout
1005
+ # collapses (names wrap to one character per line) past 3 cards, so the
1006
+ # recommended set is capped at 3 regardless of how many clear the fitness
1007
+ # floor — best-first, so the 3 strongest are the ones kept.
1008
+ _MAX_RECOMMENDATIONS: int = 3
1009
  _STRONG_RECOMMENDATION_GRADES: frozenset[str] = frozenset({"A", "B"})
1010
 
1011
 
 
1181
  c = best_by_canon.get(k)
1182
  if c is None:
1183
  continue
1184
+ if not brain_tools._has_extraction(c.get("policy_id") or ""):
1185
+ # No extracted corpus → the card renders as N/A /
1186
+ # "No extraction available for this policy" / "Data not
1187
+ # indexed". Drop it from the recommended set even if the
1188
+ # LLM named it; only renderable, data-backed policies are
1189
+ # ever cited.
1190
+ dropped.append(f"{c.get('policy_name') or k}(no-extraction)")
1191
+ continue
1192
  strong, overall, _grade = _recommendation_fit(c)
1193
  if not strong:
1194
  dropped.append(
 
1219
  cite = _cite_canon(k)
1220
  if cite is not None:
1221
  out.append(cite)
1222
+ if len(out) >= _MAX_RECOMMENDATIONS:
1223
+ break # hard ≤3 cap — keep the 3 strongest (best-first)
1224
  return out
1225
 
1226
  # ---- Path 1: explicit mark_recommendation selection -------------------
frontend/src/app/page.tsx CHANGED
@@ -4041,13 +4041,11 @@ function CitedPolicyCards({
4041
  onClose={() => setCompareOpen(false)}
4042
  profile={profile}
4043
  policyDataFor={(id) => policyById[id]}
4044
- aggregateBand={premiumBand}
4045
  renderPremiumFor={(policyId, policyName) => (
4046
  <PolicyPremiumWidget
4047
  policyId={policyId}
4048
  policyName={policyName}
4049
  profile={premiumProfile}
4050
- aggregateBand={premiumBand}
4051
  />
4052
  )}
4053
  renderScorecardFor={(policyId, policyName) => (
 
4041
  onClose={() => setCompareOpen(false)}
4042
  profile={profile}
4043
  policyDataFor={(id) => policyById[id]}
 
4044
  renderPremiumFor={(policyId, policyName) => (
4045
  <PolicyPremiumWidget
4046
  policyId={policyId}
4047
  policyName={policyName}
4048
  profile={premiumProfile}
 
4049
  />
4050
  )}
4051
  renderScorecardFor={(policyId, policyName) => (
frontend/src/components/PolicyCompareModal.tsx CHANGED
@@ -244,21 +244,6 @@ export type PolicyCompareModalProps = {
244
  policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
245
  // Hook for "Open in full marketplace" — defaults to no-op + closes modal.
246
  onOpenMarketplace?: () => void;
247
- // User's profile-level predicted premium band (same number rendered in
248
- // the chat header chip). Threaded down to PolicyPremiumWidget so that
249
- // non-curated policies (base_sample_used: false) can surface the band
250
- // as their indicative reference instead of a heuristic slider estimate.
251
- // Optional: when omitted, non-curated widgets fall back to a "band not
252
- // available" hint. The parent (page.tsx) typically closes over the same
253
- // value inside renderPremiumFor too — this prop is the declarative
254
- // contract for future callers.
255
- aggregateBand?: {
256
- min_inr: number;
257
- max_inr: number;
258
- median_inr: number;
259
- sample_size?: number;
260
- assumed?: boolean;
261
- } | null;
262
  };
263
 
264
  export default function PolicyCompareModal({
@@ -269,10 +254,8 @@ export default function PolicyCompareModal({
269
  profile: _profile,
270
  policyDataFor,
271
  onOpenMarketplace,
272
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
273
- aggregateBand: _aggregateBand,
274
  }: PolicyCompareModalProps) {
275
- const uniq = uniquePolicies(policies).slice(0, 4);
276
  const n = uniq.length;
277
 
278
  // Close on Escape — keyboard parity with the click-outside backdrop.
@@ -1016,17 +999,26 @@ export function buildSnapshot(
1016
  // FORCES a share on every claim. The exact % the user opts into is set
1017
  // later on the pricing slider; what matters here is mandatory-or-not (a
1018
  // hard minimum is a real consideration). So: binary first, figure second.
 
 
 
 
1019
  push(
1020
  limits,
1021
  "Mandatory co-pay",
1022
  cCopay == null
1023
- ? null
1024
  : cCopay === 0
1025
  ? "None — no forced co-pay"
1026
  : `Yes · ${cCopay}% minimum on every claim`,
1027
  "copay",
1028
  );
1029
- push(limits, "Hospital room category", roomRent ? roomRent : null, "room");
 
 
 
 
 
1030
 
1031
  // CONDITIONAL — profile-aware, never headline
1032
  const ctx = `${(profile?.dependents || "").toLowerCase()} ${(
 
244
  policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
245
  // Hook for "Open in full marketplace" — defaults to no-op + closes modal.
246
  onOpenMarketplace?: () => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  };
248
 
249
  export default function PolicyCompareModal({
 
254
  profile: _profile,
255
  policyDataFor,
256
  onOpenMarketplace,
 
 
257
  }: PolicyCompareModalProps) {
258
+ const uniq = uniquePolicies(policies).slice(0, 3);
259
  const n = uniq.length;
260
 
261
  // Close on Escape — keyboard parity with the click-outside backdrop.
 
999
  // FORCES a share on every claim. The exact % the user opts into is set
1000
  // later on the pricing slider; what matters here is mandatory-or-not (a
1001
  // hard minimum is a real consideration). So: binary first, figure second.
1002
+ // #30 — both rows render on EVERY card with an explicit "Not specified"
1003
+ // fallback (never omitted), so this section is consistent across
1004
+ // policies instead of showing whichever single field happened to be
1005
+ // non-null (which read as random to the user).
1006
  push(
1007
  limits,
1008
  "Mandatory co-pay",
1009
  cCopay == null
1010
+ ? "Not specified"
1011
  : cCopay === 0
1012
  ? "None — no forced co-pay"
1013
  : `Yes · ${cCopay}% minimum on every claim`,
1014
  "copay",
1015
  );
1016
+ push(
1017
+ limits,
1018
+ "Hospital room category",
1019
+ roomRent ? roomRent : "Not specified",
1020
+ "room",
1021
+ );
1022
 
1023
  // CONDITIONAL — profile-aware, never headline
1024
  const ctx = `${(profile?.dependents || "").toLowerCase()} ${(
frontend/src/components/PolicyPremiumWidget.tsx CHANGED
@@ -64,18 +64,6 @@ export type PolicyPremiumWidgetProps = {
64
  initialTenureYears?: 1 | 2 | 3;
65
  initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
66
  onCalculated?: (premium: number) => void;
67
- // User's profile-level predicted premium band (the same number rendered in
68
- // the header chip / `getPredictedPremiumBand`). Surfaced as the indicative
69
- // reference when the backend reports `base_sample_used: false` (i.e. no
70
- // curated quote sample for this specific policy). Threaded down from
71
- // PolicyCompareModal so we don't refetch per-column.
72
- aggregateBand?: {
73
- min_inr: number;
74
- max_inr: number;
75
- median_inr: number;
76
- sample_size?: number;
77
- assumed?: boolean;
78
- } | null;
79
  };
80
 
81
  const SUM_INSURED_MIN = 500_000;
@@ -189,7 +177,6 @@ export default function PolicyPremiumWidget({
189
  initialTenureYears = 1,
190
  initialDeductibleInr = 0,
191
  onCalculated,
192
- aggregateBand,
193
  }: PolicyPremiumWidgetProps) {
194
  // KI-278 — seed the SI from the profile (same precedence as the header
195
  // chip) unless the caller forced an explicit initialSumInsured. This is
@@ -286,20 +273,12 @@ export default function PolicyPremiumWidget({
286
 
287
  const profileSummary = summariseProfile(profile);
288
 
289
- // Option B+: when the backend has no curated quote sample for this policy,
290
- // hide the slider widget entirely and surface the user's aggregate band
291
- // (same number rendered in the header chip) as the indicative reference.
292
- // We wait for the first response before deciding so we don't flash the
293
- // notice while loading. Loading + error states fall through to the slider
294
- // widget below (which renders its own loading/error UI).
295
- if (resp && resp.base_sample_used === false) {
296
- return (
297
- <NonCuratedPricingNotice
298
- policyName={policyName}
299
- aggregateBand={aggregateBand ?? null}
300
- />
301
- );
302
- }
303
 
304
  // Compact, profile-only breakdown — the estimate endpoint doesn't expose
305
  // a multiplicative chain so we surface the active overrides + methodology
@@ -337,10 +316,9 @@ export default function PolicyPremiumWidget({
337
  >
338
  {policyName}
339
  </div>
340
- {/* Curated-only branch: by the time we render this widget,
341
- base_sample_used is guaranteed not false (the !== false branch
342
- short-circuits to NonCuratedPricingNotice above). No "Estimate"
343
- badge needed here — the number is anchored to a real quote sample. */}
344
  </header>
345
 
346
  {profileSummary && (
@@ -460,81 +438,6 @@ export default function PolicyPremiumWidget({
460
  );
461
  }
462
 
463
- /* ------------------------------------------------------------------ */
464
- /* NonCuratedPricingNotice — rendered in place of the slider widget */
465
- /* when /api/premium/estimate reports base_sample_used: false. Shows */
466
- /* the user's aggregate predicted-premium band (threaded down from */
467
- /* PolicyCompareModal) as the indicative reference + a clear pricing- */
468
- /* note explaining we don't have a policy-specific quote. */
469
- /* ------------------------------------------------------------------ */
470
-
471
- function NonCuratedPricingNotice({
472
- policyName,
473
- aggregateBand,
474
- }: {
475
- policyName: string;
476
- aggregateBand: {
477
- min_inr: number;
478
- max_inr: number;
479
- median_inr: number;
480
- sample_size?: number;
481
- assumed?: boolean;
482
- } | null;
483
- }) {
484
- const hasBand = !!aggregateBand;
485
- return (
486
- <div style={noticeWidgetStyle} role="note" aria-label="Pricing note">
487
- <header style={noticeHeaderStyle}>
488
- <span style={noticeIconStyle} aria-hidden="true">
489
- {/* info icon — pure SVG so we don't pull a new dependency */}
490
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
491
- <circle cx="12" cy="12" r="10" />
492
- <line x1="12" y1="8" x2="12" y2="8" />
493
- <line x1="12" y1="12" x2="12" y2="16" />
494
- </svg>
495
- </span>
496
- <span style={noticeBadgeStyle}>Pricing note</span>
497
- </header>
498
-
499
- <p style={noticeBodyStyle}>
500
- We don&apos;t have a verified quote for <strong>{policyName}</strong> yet,
501
- so we can&apos;t show a plan-specific estimate. Based on your profile,
502
- plans in this category typically cost:
503
- </p>
504
-
505
- <div style={noticeBandBoxStyle}>
506
- {hasBand ? (
507
- <>
508
- <div style={noticeBandHeadlineStyle}>
509
- ₹{formatInr(aggregateBand!.min_inr)}–₹{formatInr(aggregateBand!.max_inr)}
510
- <span style={noticeBandSuffixStyle}>&nbsp;/ year</span>
511
- </div>
512
- <div style={noticeBandMedianStyle}>
513
- Median ₹{formatInr(aggregateBand!.median_inr)}/year
514
- {typeof aggregateBand!.sample_size === "number" &&
515
- aggregateBand!.sample_size > 0 && (
516
- <> · across {aggregateBand!.sample_size} similar profiles</>
517
- )}
518
- </div>
519
- </>
520
- ) : (
521
- <div style={noticeBandFallbackStyle}>
522
- Profile-level band not available yet — complete your profile to see
523
- an indicative range.
524
- </div>
525
- )}
526
- </div>
527
-
528
- <p style={noticeFootnoteStyle}>
529
- This is an indicative range — the actual premium depends on the
530
- insurer&apos;s underwriting + your final disclosures. To get an exact
531
- quote, request one from the insurer directly or via
532
- PolicyBazaar / InsuranceDekho.
533
- </p>
534
- </div>
535
- );
536
- }
537
-
538
  /* ------------------------------------------------------------------ */
539
  /* Inline styles — kept local so the widget drops into any modal */
540
  /* without a CSS-module dependency. All colors read from the landing's */
@@ -780,102 +683,6 @@ const noteStyle: React.CSSProperties = {
780
  paddingTop: 2,
781
  };
782
 
783
- /* ---------------- NonCuratedPricingNotice styles ------------------- */
784
- /* Same outer card framing as the slider widget (border + radius + bg) */
785
- /* but with a brand-teal informational accent rail so users instantly */
786
- /* distinguish "indicative range" from a "calculated estimate". */
787
-
788
- const noticeWidgetStyle: React.CSSProperties = {
789
- border: "1px solid color-mix(in srgb, var(--primary) 22%, var(--border))",
790
- borderLeft: "3px solid var(--primary)",
791
- borderRadius: 18,
792
- padding: 18,
793
- background: "color-mix(in srgb, var(--primary) 4%, var(--card))",
794
- display: "flex",
795
- flexDirection: "column",
796
- gap: 11,
797
- fontFamily: SANS,
798
- boxShadow:
799
- "0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent), 0 16px 40px -32px color-mix(in srgb, var(--foreground) 28%, transparent)",
800
- };
801
-
802
- const noticeHeaderStyle: React.CSSProperties = {
803
- display: "flex",
804
- alignItems: "center",
805
- gap: 8,
806
- };
807
-
808
- const noticeIconStyle: React.CSSProperties = {
809
- display: "inline-flex",
810
- alignItems: "center",
811
- justifyContent: "center",
812
- width: 22,
813
- height: 22,
814
- borderRadius: 999,
815
- color: "var(--primary)",
816
- background: "color-mix(in srgb, var(--primary) 12%, var(--card))",
817
- border: "1px solid color-mix(in srgb, var(--primary) 22%, var(--border))",
818
- };
819
-
820
- const noticeBadgeStyle: React.CSSProperties = {
821
- fontSize: 10,
822
- fontWeight: 700,
823
- letterSpacing: "0.12em",
824
- textTransform: "uppercase",
825
- color: "color-mix(in srgb, var(--primary) 78%, var(--foreground))",
826
- };
827
-
828
- const noticeBodyStyle: React.CSSProperties = {
829
- margin: 0,
830
- fontSize: 12.5,
831
- lineHeight: 1.55,
832
- color: "var(--foreground)",
833
- };
834
-
835
- const noticeBandBoxStyle: React.CSSProperties = {
836
- background: "var(--card)",
837
- border: "1px solid color-mix(in srgb, var(--primary) 16%, var(--border))",
838
- borderRadius: 12,
839
- padding: "12px 14px",
840
- display: "flex",
841
- flexDirection: "column",
842
- gap: 3,
843
- };
844
-
845
- const noticeBandHeadlineStyle: React.CSSProperties = {
846
- fontFamily: SERIF,
847
- fontOpticalSizing: "auto",
848
- fontSize: 21,
849
- fontWeight: 600,
850
- color: "var(--foreground)",
851
- letterSpacing: "-0.02em",
852
- fontVariantNumeric: "tabular-nums",
853
- };
854
-
855
- const noticeBandSuffixStyle: React.CSSProperties = {
856
- fontFamily: SANS,
857
- fontSize: 12,
858
- fontWeight: 500,
859
- color: "var(--muted-foreground)",
860
- };
861
-
862
- const noticeBandMedianStyle: React.CSSProperties = {
863
- fontSize: 11.5,
864
- color: "var(--muted-foreground)",
865
- fontVariantNumeric: "tabular-nums",
866
- };
867
-
868
- const noticeBandFallbackStyle: React.CSSProperties = {
869
- fontSize: 12,
870
- color: "#855316",
871
- fontStyle: "italic",
872
- lineHeight: 1.5,
873
- };
874
-
875
- const noticeFootnoteStyle: React.CSSProperties = {
876
- margin: 0,
877
- fontSize: 11,
878
- lineHeight: 1.5,
879
- color: "var(--muted-foreground)",
880
- fontStyle: "italic",
881
- };
 
64
  initialTenureYears?: 1 | 2 | 3;
65
  initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
66
  onCalculated?: (premium: number) => void;
 
 
 
 
 
 
 
 
 
 
 
 
67
  };
68
 
69
  const SUM_INSURED_MIN = 500_000;
 
177
  initialTenureYears = 1,
178
  initialDeductibleInr = 0,
179
  onCalculated,
 
180
  }: PolicyPremiumWidgetProps) {
181
  // KI-278 — seed the SI from the profile (same precedence as the header
182
  // chip) unless the caller forced an explicit initialSumInsured. This is
 
273
 
274
  const profileSummary = summariseProfile(profile);
275
 
276
+ // Every recommended policy renders the SAME per-policy estimate block
277
+ // below (point + ±15% band + methodology). When the backend has no
278
+ // curated quote sample, the `methodology` string itself states it is a
279
+ // rules-based estimate we no longer substitute the wide profile-level
280
+ // basket band for some policies (that produced an identical, 4-5x-wide
281
+ // "no verified quote" panel on every non-curated card).
 
 
 
 
 
 
 
 
282
 
283
  // Compact, profile-only breakdown — the estimate endpoint doesn't expose
284
  // a multiplicative chain so we surface the active overrides + methodology
 
316
  >
317
  {policyName}
318
  </div>
319
+ {/* The methodology line under the estimate states whether the
320
+ number is anchored to a curated quote sample or a rules-based
321
+ formula so no separate badge is needed here. */}
 
322
  </header>
323
 
324
  {profileSummary && (
 
438
  );
439
  }
440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  /* ------------------------------------------------------------------ */
442
  /* Inline styles — kept local so the widget drops into any modal */
443
  /* without a CSS-module dependency. All colors read from the landing's */
 
683
  paddingTop: 2,
684
  };
685
 
686
+ /* (NonCuratedPricingNotice + its styles removed — every policy now
687
+ renders the unified per-policy estimate block; the methodology line
688
+ states when the number is rules-based vs curated-sample anchored.) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/conftest.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pytest fixtures.
2
+
3
+ The recommendation-card EXTRACTION GATE is a new, orthogonal invariant: a
4
+ policy with no extracted corpus file renders as a broken card (raw
5
+ policy_id title, grade "N/A", "No extraction available for this policy.",
6
+ "Data not indexed"), so it must never be cited or quality-seeded.
7
+
8
+ In production every RETRIEVED policy is renderable. The synthetic
9
+ policy_ids the logic tests build (e.g. "rs-multiplier", "p0",
10
+ "test__policy-a") have no extracted file on disk, so without this fixture
11
+ the gate would empty every synthetic citation set and mask the behaviour
12
+ each test actually checks (selection, ordering, dedup, fit-floor,
13
+ transparency).
14
+
15
+ So: for every test EXCEPT the dedicated extraction-gate guard
16
+ (tests/test_extraction_gate.py — which restores the real predicate via
17
+ its own monkeypatch), treat test policies as renderable. Forcing
18
+ _has_extraction -> True reproduces the exact pre-gate behaviour these
19
+ tests were written against.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import pytest
25
+
26
+ from backend import brain_tools
27
+
28
+
29
+ @pytest.fixture(autouse=True)
30
+ def _treat_test_policies_as_renderable(monkeypatch):
31
+ monkeypatch.setattr(brain_tools, "_has_extraction", lambda pid: True)
tests/test_cited_policies_match_prose.py CHANGED
@@ -20,8 +20,9 @@ THE CONTRACT THIS PINS:
20
  `_build_recommendation_citations` returns a citation list that is exactly
21
  the recommended policies — via explicit mark_recommendation ordering when
22
  present, else by the policy names actually written in the reply prose —
23
- with NO score-order fallback that resurrects un-named policies, and NO
24
- count cap.
 
25
 
26
  Run:
27
  cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
@@ -94,8 +95,8 @@ class TestProseNameNormalisation(unittest.TestCase):
94
 
95
 
96
  class TestMarkRecommendationPath(unittest.TestCase):
97
- """When the LLM calls mark_recommendation, those ordered ids ARE the
98
- citation set — exactly, in order, no cap."""
99
 
100
  def test_exact_set_and_order_from_marked_ids(self) -> None:
101
  marked = ["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"]
@@ -105,7 +106,13 @@ class TestMarkRecommendationPath(unittest.TestCase):
105
  marked_policy_ids=marked,
106
  )
107
  self.assertTrue(is_rec)
108
- self.assertEqual([c["policy_id"] for c in cites], marked)
 
 
 
 
 
 
109
  # The un-recommended high-scorer must NOT appear.
110
  self.assertNotIn(
111
  "star-hospcash", [c["policy_id"] for c in cites]
@@ -139,18 +146,18 @@ class TestProseMatchingPath(unittest.TestCase):
139
  )
140
  self.assertTrue(is_rec)
141
  ids = [c["policy_id"] for c in cites]
142
- # EXACTLY the 4 named in prose, in prose order.
 
143
  self.assertEqual(
144
- ids,
145
- ["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"],
146
  )
147
- # 4 named in prose ⇒ 4 cards (the original bug was 4 → 3).
148
- self.assertEqual(len(cites), 4)
149
  # The false positive from the screenshot is gone.
150
  self.assertNotIn("star-hospcash", ids)
151
 
152
- def test_no_count_cap(self) -> None:
153
- """A 5-policy shortlist must yield 5 cards, not 3."""
 
154
  chunks = [
155
  _chunk(f"p{i}", f"Policy Alpha {i}", "ins", 0.9 - i * 0.05, f"k{i}")
156
  for i in range(5)
@@ -165,10 +172,9 @@ class TestProseMatchingPath(unittest.TestCase):
165
  marked_policy_ids=[],
166
  )
167
  self.assertTrue(is_rec)
168
- self.assertEqual(len(cites), 5)
169
  self.assertEqual(
170
- [c["policy_id"] for c in cites],
171
- ["p0", "p1", "p2", "p3", "p4"],
172
  )
173
 
174
 
 
20
  `_build_recommendation_citations` returns a citation list that is exactly
21
  the recommended policies — via explicit mark_recommendation ordering when
22
  present, else by the policy names actually written in the reply prose —
23
+ with NO score-order fallback that resurrects un-named policies, and a
24
+ HARD CAP of 3 (the recommendation cards do not render past 3 — #28; the
25
+ 3 kept are the strongest, best-first by the gate's fit rank).
26
 
27
  Run:
28
  cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
 
95
 
96
 
97
  class TestMarkRecommendationPath(unittest.TestCase):
98
+ """When the LLM calls mark_recommendation, those ids ARE the citation
99
+ set — best-first by gate rank, hard-capped at the 3 strongest (#28)."""
100
 
101
  def test_exact_set_and_order_from_marked_ids(self) -> None:
102
  marked = ["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"]
 
106
  marked_policy_ids=marked,
107
  )
108
  self.assertTrue(is_rec)
109
+ # Hard ≤3 cap — the 3 strongest, best-first by gate rank; the 4th
110
+ # marked id (star-fho) is dropped by the cap.
111
+ self.assertEqual(
112
+ [c["policy_id"] for c in cites],
113
+ ["rs-multiplier", "rs-advtopup", "hdfc-myhealth"],
114
+ )
115
+ self.assertLessEqual(len(cites), 3)
116
  # The un-recommended high-scorer must NOT appear.
117
  self.assertNotIn(
118
  "star-hospcash", [c["policy_id"] for c in cites]
 
146
  )
147
  self.assertTrue(is_rec)
148
  ids = [c["policy_id"] for c in cites]
149
+ # 4 policies are named in prose, but the cards hard-cap at the 3
150
+ # strongest (best-first by gate rank); star-fho (4th) is dropped.
151
  self.assertEqual(
152
+ ids, ["rs-multiplier", "rs-advtopup", "hdfc-myhealth"]
 
153
  )
154
+ self.assertEqual(len(cites), 3)
 
155
  # The false positive from the screenshot is gone.
156
  self.assertNotIn("star-hospcash", ids)
157
 
158
+ def test_count_hard_capped_at_3(self) -> None:
159
+ """A 5-policy shortlist yields EXACTLY 3 cards (best-first) — the
160
+ recommendation cards do not render past 3 (#28)."""
161
  chunks = [
162
  _chunk(f"p{i}", f"Policy Alpha {i}", "ins", 0.9 - i * 0.05, f"k{i}")
163
  for i in range(5)
 
172
  marked_policy_ids=[],
173
  )
174
  self.assertTrue(is_rec)
175
+ self.assertEqual(len(cites), 3)
176
  self.assertEqual(
177
+ [c["policy_id"] for c in cites], ["p0", "p1", "p2"]
 
178
  )
179
 
180
 
tests/test_extraction_gate.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guard for the recommendation-card EXTRACTION GATE (#29).
2
+
3
+ THE BUG (from a real production screenshot):
4
+ A recommended card rendered with the raw policy_id slug as its title
5
+ ("manipalcigna__sarv…"), grade "N/A", body "No extraction available for
6
+ this policy.", and "Why this fits you: Data not indexed".
7
+
8
+ ROOT CAUSE:
9
+ `_scorecard_signal` / `_quality_seed_candidates` grade off the ~790-entry
10
+ CURATED layer, but the card UI renders from the EXTRACTED layer
11
+ (settings.EXTRACTED_DIR/*.json — the same set the marketplace shows).
12
+ Quality-seed injected curated-graded-but-not-extracted policies into the
13
+ candidate pool, so the LLM could recommend a policy whose card cannot
14
+ render.
15
+
16
+ THE CONTRACT THIS PINS:
17
+ A policy with no extracted corpus file is NEVER quality-seeded and is
18
+ ALWAYS dropped from the cited set, even if the LLM explicitly marks it —
19
+ so a broken "N/A / No extraction available" card can never reach the UI.
20
+
21
+ This file deliberately uses the REAL `_has_extraction` predicate (the
22
+ package-wide conftest autouse fixture stubs it True for the logic tests;
23
+ here we restore the real one so the gate itself is exercised).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
34
+ if str(_REPO_ROOT) not in sys.path:
35
+ sys.path.insert(0, str(_REPO_ROOT))
36
+
37
+ from backend import brain_tools # noqa: E402
38
+ from backend.brain_tools import ( # noqa: E402
39
+ _has_extraction as _REAL_HAS_EXTRACTION,
40
+ _quality_seed_candidates,
41
+ )
42
+ from backend.config import settings # noqa: E402
43
+ from backend.single_brain import _build_recommendation_citations # noqa: E402
44
+
45
+
46
+ def _a_real_extracted_stem() -> str:
47
+ """Any policy_id that genuinely has an extracted corpus file on disk."""
48
+ files = sorted(settings.EXTRACTED_DIR.glob("*.json"))
49
+ assert files, "no extracted corpus files — cannot test the gate"
50
+ return files[0].stem
51
+
52
+
53
+ @pytest.fixture
54
+ def real_extraction(monkeypatch):
55
+ """Override the conftest autouse stub: use the REAL predicate so the
56
+ gate's actual on-disk behaviour is what gets exercised here."""
57
+ monkeypatch.setattr(brain_tools, "_has_extraction", _REAL_HAS_EXTRACTION)
58
+ brain_tools._extraction_cache.clear()
59
+ brain_tools._qseed_cache.clear()
60
+ return _REAL_HAS_EXTRACTION
61
+
62
+
63
+ def test_predicate_true_for_extracted_false_for_missing(real_extraction):
64
+ real = _a_real_extracted_stem()
65
+ assert brain_tools._has_extraction(real) is True
66
+ assert (
67
+ brain_tools._has_extraction("definitely__not-a-real-policy-xyz")
68
+ is False
69
+ )
70
+ assert brain_tools._has_extraction("") is False
71
+
72
+
73
+ def test_non_extracted_policy_never_cited_even_when_marked(real_extraction):
74
+ """The exact production failure: a marked policy with no extracted
75
+ corpus must be DROPPED, not rendered as an N/A card."""
76
+ real = _a_real_extracted_stem()
77
+ chunks = [
78
+ {
79
+ "chunk_id": "real1",
80
+ "policy_id": real,
81
+ "policy_name": "Real Extracted Plan",
82
+ "insurer_slug": real.split("__", 1)[0] if "__" in real else "x",
83
+ "doc_type": "policy",
84
+ "source_url": f"https://example.com/{real}.pdf",
85
+ "score": 0.9,
86
+ },
87
+ {
88
+ "chunk_id": "ghost1",
89
+ "policy_id": "manipalcigna__sarvah-param-NOT-EXTRACTED",
90
+ "policy_name": "Ghost Plan",
91
+ "insurer_slug": "manipalcigna",
92
+ "doc_type": "policy",
93
+ "source_url": "",
94
+ "score": 0.95, # higher score — must STILL be dropped
95
+ },
96
+ ]
97
+ cites, is_rec = _build_recommendation_citations(
98
+ reply_text="See Real Extracted Plan and Ghost Plan.",
99
+ retrieved_chunks_all=chunks,
100
+ marked_policy_ids=[
101
+ "manipalcigna__sarvah-param-NOT-EXTRACTED",
102
+ real,
103
+ ],
104
+ )
105
+ assert is_rec is True
106
+ ids = [c["policy_id"] for c in cites]
107
+ assert "manipalcigna__sarvah-param-NOT-EXTRACTED" not in ids
108
+ assert ids == [real]
109
+
110
+
111
+ def test_quality_seed_only_emits_renderable_policies(real_extraction):
112
+ """Every quality-seeded candidate must have an extracted file — so it
113
+ can never inject a policy whose card renders as N/A."""
114
+ seeded = _quality_seed_candidates(profile=None, limit=25)
115
+ assert seeded, "quality-seed returned nothing — basket starved"
116
+ offenders = [
117
+ c["policy_id"]
118
+ for c in seeded
119
+ if not _REAL_HAS_EXTRACTION(c.get("policy_id") or "")
120
+ ]
121
+ assert not offenders, (
122
+ f"quality-seed emitted non-renderable policies: {offenders}"
123
+ )
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(pytest.main([__file__, "-v"]))
tests/test_premium_reconciliation.py CHANGED
@@ -14,13 +14,20 @@ desired_sum_insured_inr → existing_cover_inr → ₹10L. A user who stated a
14
 
15
  Fix: estimate_premium_band() resolves SI via resolve_profile_sum_insured()
16
  (the single source of truth shared with the panel/widget), prices the whole
17
- basket at that SI, and rounds the band edges DIRECTIONALLY (floor min / ceil
18
- max) so the displayed band is a strict superset of every basket member —
19
- making the per-settings panel point ALWAYS land inside the header band.
 
 
 
20
  """
21
 
22
  from backend.premium_calculator import (
23
  _DEFAULT_BAND_POLICY_IDS,
 
 
 
 
24
  bulk_estimate,
25
  estimate,
26
  estimate_premium_band,
@@ -100,15 +107,30 @@ import pytest
100
  "parents_age_max": 72, "parents_has_ped": True}, "parents-on-cover"),
101
  ],
102
  )
103
- def test_header_band_is_superset_of_every_panel_point(profile, label):
104
  band = estimate_premium_band(dict(profile))
105
- points = _panel_points_for(profile)
106
  assert points, f"no basket points for {label}"
107
- for p in points:
108
- assert band["min_inr"] <= p <= band["max_inr"], (
109
- f"{label}: panel point ₹{p:,} fell OUTSIDE header band "
110
- f"₹{band['min_inr']:,}–₹{band['max_inr']:,} header≠panel regression"
111
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
 
114
  def test_band_exposes_resolved_si_for_panel_alignment():
 
14
 
15
  Fix: estimate_premium_band() resolves SI via resolve_profile_sum_insured()
16
  (the single source of truth shared with the panel/widget), prices the whole
17
+ basket at that SI, and reports the p25–p75 INTERQUARTILE of the basket as
18
+ the band (directionally rounded) the honest "what similar profiles
19
+ typically pay" range. Raw min–max across the heterogeneous basket spans
20
+ ~4-5x and renders as a useless, broken-looking band (KI broken-band fix);
21
+ a specific plan's per-settings panel point may sit inside or just outside
22
+ this typical band, which is expected and correct.
23
  """
24
 
25
  from backend.premium_calculator import (
26
  _DEFAULT_BAND_POLICY_IDS,
27
+ _ceil_to_500,
28
+ _floor_to_500,
29
+ _median,
30
+ _percentile,
31
  bulk_estimate,
32
  estimate,
33
  estimate_premium_band,
 
107
  "parents_age_max": 72, "parents_has_ped": True}, "parents-on-cover"),
108
  ],
109
  )
110
+ def test_header_band_is_p25_p75_interquartile(profile, label):
111
  band = estimate_premium_band(dict(profile))
112
+ points = _panel_points_for(profile) # sorted basket points
113
  assert points, f"no basket points for {label}"
114
+ # New contract: the band edges ARE the directionally-rounded p25 / p75
115
+ # of the basket the interquartile "typical range", NOT raw min-max.
116
+ assert band["min_inr"] == _floor_to_500(_percentile(points, 25)), label
117
+ assert band["max_inr"] == _ceil_to_500(_percentile(points, 75)), label
118
+ # min ≤ median ≤ max, all positive.
119
+ assert 0 < band["min_inr"] <= band["median_inr"] <= band["max_inr"], label
120
+ # The typical (median) plan lies inside the band.
121
+ med = _median(points)
122
+ assert band["min_inr"] <= med <= band["max_inr"], (
123
+ f"{label}: median basket point ₹{med:,} fell outside the typical "
124
+ f"band ₹{band['min_inr']:,}–₹{band['max_inr']:,}"
125
+ )
126
+ # And it is materially TIGHTER than the raw min-max envelope — the
127
+ # entire point of the fix (the heterogeneous basket's raw spread is
128
+ # ~4-5x; the interquartile band must be a strict subset of it).
129
+ raw_lo = _floor_to_500(min(points))
130
+ raw_hi = _ceil_to_500(max(points))
131
+ assert band["min_inr"] >= raw_lo, label
132
+ assert band["max_inr"] <= raw_hi, label
133
+ assert (band["max_inr"] - band["min_inr"]) <= (raw_hi - raw_lo), label
134
 
135
 
136
  def test_band_exposes_resolved_si_for_panel_alignment():