"""Tests for the Tyrer-Cuzick (IBIS) breast cancer risk model. This test suite validates the Tyrer-Cuzick model implementation against reference values from the IBIS web calculator. Test cases cover various scenarios including: - Different personal risk factor combinations - Various family history patterns - Edge cases and boundary conditions Test cases should be populated with ground truth values from the IBIS web calculator: https://www.ems-trials.org/riskevaluator/ """ import pytest from sentinel.risk_models.tyrer_cuzick import ( BRCA1_CUMULATIVE_RISK_BY_AGE, BRCA2_CUMULATIVE_RISK_BY_AGE, TyrerCuzickRiskModel, build_brca_survivor, build_population_survivor, build_s0_survivor, compute_personal_relative_risk, relative_risk_bmi_post_menopausal, relative_risk_first_birth, relative_risk_height, relative_risk_menarche, ) from sentinel.user_input import ( Anthropometrics, BreastHealthHistory, CancerType, Demographics, FamilyMemberCancer, FamilyRelation, FamilySide, FemaleSpecific, Lifestyle, MenstrualHistory, ParityHistory, PersonalMedicalHistory, RelationshipDegree, Sex, SmokingHistory, SmokingStatus, UserInput, ) def create_test_user( age: int = 40, menarche_age: int | None = 13, has_given_birth: bool = False, age_first_birth: int | None = None, num_live_births: int | None = None, is_postmenopausal: bool = False, menopause_age: int | None = None, height_m: float | None = None, bmi: float | None = None, atypical_hyperplasia: bool = False, lcis: bool = False, polygenic_relative_risk: float | None = None, family_history: list[FamilyMemberCancer] | None = None, ) -> UserInput: """Helper to create UserInput for testing. Args: age: Patient age. menarche_age: Age at menarche. has_given_birth: Whether patient has given birth. age_first_birth: Age at first birth. num_live_births: Number of live births. is_postmenopausal: Whether patient is postmenopausal. menopause_age: Age at menopause. height_m: Height in meters. bmi: Body mass index. atypical_hyperplasia: Whether patient has atypical hyperplasia. lcis: Whether patient has LCIS. polygenic_relative_risk: Polygenic relative risk multiplier. family_history: List of family cancer history. Returns: UserInput: Configured user input for testing. """ # Calculate height and weight from height_m and bmi if provided height_cm = height_m * 100 if height_m is not None else 165.0 weight_kg = ( bmi * (height_m**2) if bmi is not None and height_m is not None else 70.0 ) return UserInput( demographics=Demographics( age_years=age, sex=Sex.FEMALE, anthropometrics=Anthropometrics( height_cm=height_cm, weight_kg=weight_kg, ), ), female_specific=FemaleSpecific( menstrual=MenstrualHistory( age_at_menarche=menarche_age, age_at_menopause=menopause_age if is_postmenopausal else None, ), parity=ParityHistory( num_live_births=num_live_births or (1 if has_given_birth else None), age_at_first_live_birth=age_first_birth, ), breast_health=BreastHealthHistory( atypical_hyperplasia=atypical_hyperplasia, lobular_carcinoma_in_situ=lcis, ), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory( tyrer_cuzick_polygenic_risk_score=polygenic_relative_risk, ), family_history=family_history or [], ) class TestPersonalRiskFactors: """Test personal risk factor calculations.""" def test_relative_risk_menarche_baseline(self): """Test menarche RR at baseline age 13.""" assert relative_risk_menarche(13) == pytest.approx(1.0, rel=1e-6) def test_relative_risk_menarche_early(self): """Test menarche RR for early age (11).""" expected = 0.95 ** (11 - 13) assert relative_risk_menarche(11) == pytest.approx(expected, rel=1e-6) def test_relative_risk_menarche_late(self): """Test menarche RR for late age (15).""" expected = 0.95 ** (15 - 13) assert relative_risk_menarche(15) == pytest.approx(expected, rel=1e-6) def test_relative_risk_first_birth_nulliparous(self): """Test first birth RR for nulliparous women.""" assert relative_risk_first_birth("nulliparous", None) == pytest.approx(1.0) def test_relative_risk_first_birth_early(self): """Test first birth RR for age < 20.""" assert relative_risk_first_birth("parous", 18) == pytest.approx(0.67) def test_relative_risk_first_birth_20_24(self): """Test first birth RR for age 20-24.""" assert relative_risk_first_birth("parous", 22) == pytest.approx(0.74) def test_relative_risk_first_birth_25_29(self): """Test first birth RR for age 25-29.""" assert relative_risk_first_birth("parous", 27) == pytest.approx(0.88) def test_relative_risk_first_birth_30_plus(self): """Test first birth RR for age >= 30.""" assert relative_risk_first_birth("parous", 32) == pytest.approx(1.04) def test_relative_risk_height_low(self): """Test height RR for height < 1.60m.""" assert relative_risk_height(1.55) == pytest.approx(1.0) def test_relative_risk_height_medium(self): """Test height RR for height 1.60-1.70m.""" assert relative_risk_height(1.65) == pytest.approx(1.15) def test_relative_risk_height_high(self): """Test height RR for height >= 1.70m.""" assert relative_risk_height(1.75) == pytest.approx(1.24) def test_relative_risk_bmi_post_menopausal_low(self): """Test BMI RR for BMI < 21 (post-menopausal).""" assert relative_risk_bmi_post_menopausal(20, "post") == pytest.approx(1.0) def test_relative_risk_bmi_post_menopausal_21_23(self): """Test BMI RR for BMI 21-23 (post-menopausal).""" assert relative_risk_bmi_post_menopausal(22, "post") == pytest.approx(1.14) def test_relative_risk_bmi_post_menopausal_high(self): """Test BMI RR for BMI >= 27 (post-menopausal).""" assert relative_risk_bmi_post_menopausal(28, "post") == pytest.approx(1.32) def test_rr_bmi_premenopausal(self): """Test BMI RR for pre-menopausal (should be 1.0).""" assert relative_risk_bmi_post_menopausal(28, "pre") == pytest.approx(1.0) def test_compute_personal_relative_risk_baseline(self): """Test combined personal RR with baseline values.""" rr = compute_personal_relative_risk( menarche_age=13, has_given_birth=False, age_first_birth=None, menopausal_status="pre", menopause_age=None, height_m=None, bmi=None, atypical_hyperplasia=False, lcis=False, polygenic_relative_risk=None, ) assert rr > 0.0 class TestSurvivorFunctions: """Test survivor function computations.""" def test_build_population_survivor(self): """Test population survivor function construction.""" s_pop = build_population_survivor() assert len(s_pop) == 13 assert s_pop[0][2] == pytest.approx(1.0) for i in range(len(s_pop) - 1): assert s_pop[i][2] >= s_pop[i + 1][2] def test_build_brca1_survivor(self): """Test BRCA1 survivor function construction.""" s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) assert len(s_brca1) > 0 assert s_brca1[0][2] <= 1.0 def test_build_brca2_survivor(self): """Test BRCA2 survivor function construction.""" s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) assert len(s_brca2) > 0 assert s_brca2[0][2] <= 1.0 def test_build_s0_survivor(self): """Test S_0 (no BRCA, no LPG) survivor function construction.""" s_pop = build_population_survivor() s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) s0 = build_s0_survivor(s_pop, s_brca1, s_brca2) assert len(s0) == len(s_pop) for _, _, survival in s0: assert 0.0 <= survival <= 1.0 class TestTyrerCuzickModel: """Test Tyrer-Cuzick model calculations.""" def test_model_initialization(self): """Test model initialization.""" model = TyrerCuzickRiskModel() assert model.name == "tyrer_cuzick" assert model.cancer_type() == "breast" def test_calculate_risk_baseline(self): """Test risk calculation with baseline inputs.""" model = TyrerCuzickRiskModel() user = create_test_user(age=45, menarche_age=13) result = model.calculate_risk(user, projection_years=10) assert "cumulative_risk" in result assert "interval_risks" in result assert "personal_relative_risk" in result assert "phenotype_probs" in result assert 0.0 <= result["cumulative_risk"] <= 1.0 assert sum(result["phenotype_probs"]) == pytest.approx(1.0, rel=1e-6) def test_calculate_risk_with_family_history(self): """Test risk calculation with family history.""" model = TyrerCuzickRiskModel() # Mother with breast cancer at age 45 family_history = [ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=45, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ) ] user = create_test_user( age=40, menarche_age=12, has_given_birth=True, age_first_birth=28, family_history=family_history, ) result = model.calculate_risk(user, projection_years=10) assert 0.0 <= result["cumulative_risk"] <= 1.0 def test_calculate_risk_high_risk_factors(self): """Test risk calculation with multiple high-risk factors.""" model = TyrerCuzickRiskModel() user = create_test_user( age=50, menarche_age=11, has_given_birth=False, is_postmenopausal=True, menopause_age=55, height_m=1.75, bmi=28, atypical_hyperplasia=True, ) result = model.calculate_risk(user, projection_years=10) assert result["personal_relative_risk"] > 2.0 def test_polygenic_relative_risk_increases_risk(self): """Test that polygenic RR multiplies risk appropriately.""" model = TyrerCuzickRiskModel() user_baseline = create_test_user(age=50) result_baseline = model.calculate_risk(user_baseline, projection_years=10) user_high_prs = create_test_user(age=50, polygenic_relative_risk=2.0) result_high_prs = model.calculate_risk(user_high_prs, projection_years=10) user_low_prs = create_test_user(age=50, polygenic_relative_risk=0.5) result_low_prs = model.calculate_risk(user_low_prs, projection_years=10) assert result_high_prs["personal_relative_risk"] == pytest.approx( result_baseline["personal_relative_risk"] * 2.0 ) assert result_low_prs["personal_relative_risk"] == pytest.approx( result_baseline["personal_relative_risk"] * 0.5 ) assert result_high_prs["cumulative_risk"] > result_baseline["cumulative_risk"] assert result_low_prs["cumulative_risk"] < result_baseline["cumulative_risk"] def test_polygenic_relative_risk_with_other_factors(self): """Test that polygenic RR combines multiplicatively with other risk factors.""" model = TyrerCuzickRiskModel() user_personal = create_test_user( age=50, menarche_age=11, has_given_birth=True, age_first_birth=35, ) result_personal = model.calculate_risk(user_personal, projection_years=10) user_combined = create_test_user( age=50, menarche_age=11, has_given_birth=True, age_first_birth=35, polygenic_relative_risk=1.5, ) result_combined = model.calculate_risk(user_combined, projection_years=10) assert result_combined["personal_relative_risk"] == pytest.approx( result_personal["personal_relative_risk"] * 1.5 ) assert result_combined["cumulative_risk"] > result_personal["cumulative_risk"] def test_polygenic_relative_risk_boundary_values(self): """Test polygenic RR with boundary values.""" model = TyrerCuzickRiskModel() user_min = create_test_user(age=50, polygenic_relative_risk=0.1) result_min = model.calculate_risk(user_min, projection_years=10) assert result_min["cumulative_risk"] > 0 user_max = create_test_user(age=50, polygenic_relative_risk=10.0) result_max = model.calculate_risk(user_max, projection_years=10) assert result_max["cumulative_risk"] < 1.0 assert result_max["cumulative_risk"] > result_min["cumulative_risk"] * 5 def test_model_description(self): """Test model description methods.""" model = TyrerCuzickRiskModel() assert len(model.description()) > 0 assert len(model.interpretation()) > 0 assert len(model.references()) > 0 class TestReferenceCalculations: """Test cases from IBIS web calculator validated against https://ibis.ikonopedia.com/""" def test_reference_case_1_baseline_low_risk(self): """Baseline low risk with average factors and no family history.""" model = TyrerCuzickRiskModel() user = create_test_user( age=40, height_m=1.65, bmi=25.7, menarche_age=13, has_given_birth=True, age_first_birth=25, ) result = model.calculate_risk(user, projection_years=10) expected_10yr_risk = 0.015 assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.001) def test_reference_case_2_high_personal_risk(self): """High personal risk with early menarche, nulliparous, and late menopause.""" model = TyrerCuzickRiskModel() user = create_test_user( age=60, height_m=1.75, bmi=27.8, menarche_age=11, is_postmenopausal=True, menopause_age=55, has_given_birth=False, ) result = model.calculate_risk(user, projection_years=10) expected_10yr_risk = 0.053 assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) def test_reference_case_3_strong_family_history(self): """Family history with mother diagnosed at age 42.""" model = TyrerCuzickRiskModel() family_history = [ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=42, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), ] user = create_test_user( age=35, height_m=1.60, bmi=25.4, menarche_age=12, has_given_birth=False, family_history=family_history, ) result = model.calculate_risk(user, projection_years=10) expected_10yr_risk = 0.026 assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) def test_reference_case_4_moderate_family_history(self): """Moderate family history with mother and maternal aunt.""" model = TyrerCuzickRiskModel() family_history = [ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=50, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.MATERNAL_AUNT, cancer_type=CancerType.BREAST, age_at_diagnosis=55, degree=RelationshipDegree.SECOND, side=FamilySide.MATERNAL, ), ] user = create_test_user( age=45, height_m=1.68, bmi=25.5, menarche_age=13, has_given_birth=True, age_first_birth=30, family_history=family_history, ) result = model.calculate_risk(user, projection_years=10) expected_10yr_risk = 0.029 assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) def test_reference_case_5_young_with_early_onset_family_history(self): """Early onset family history with mother at 38 and maternal grandmother at 52.""" model = TyrerCuzickRiskModel() family_history = [ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=38, # Early onset degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.MATERNAL_GRANDMOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=52, degree=RelationshipDegree.SECOND, side=FamilySide.MATERNAL, ), ] user = create_test_user( age=30, height_m=1.62, bmi=22.1, menarche_age=12, has_given_birth=True, age_first_birth=28, family_history=family_history, ) result = model.calculate_risk(user, projection_years=10) expected_10yr_risk = 0.024 assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) class TestEdgeCases: """Test edge cases and boundary conditions.""" def test_minimum_age(self): """Test calculation at minimum valid age (20).""" model = TyrerCuzickRiskModel() user = create_test_user(age=20, has_given_birth=False) result = model.calculate_risk(user, projection_years=10) assert 0.0 <= result["cumulative_risk"] <= 1.0 def test_maximum_age(self): """Test calculation at maximum valid age (85).""" model = TyrerCuzickRiskModel() user = create_test_user( age=85, is_postmenopausal=True, menopause_age=52, has_given_birth=True, age_first_birth=25, ) result = model.calculate_risk(user, projection_years=5) assert 0.0 <= result["cumulative_risk"] <= 1.0 def test_empty_pedigree(self): """Test calculation with empty pedigree.""" model = TyrerCuzickRiskModel() user = create_test_user(age=45) result = model.calculate_risk(user, projection_years=10) # Should use population priors assert 0.0 <= result["cumulative_risk"] <= 1.0