jeuko commited on
Commit
f6b7a59
·
verified ·
1 Parent(s): afe6ab0

Sync from GitHub (main)

Browse files
AGENTS.md CHANGED
@@ -45,11 +45,13 @@ When making changes to the project, ensure that the following files are updated
45
  Implemented risk calculators include:
46
  - **Gail** - Breast cancer risk
47
  - **Claus** - Breast cancer risk based on family history
 
48
  - **PLCOm2012** - Lung cancer risk
 
49
  - **CRC-PRO** - Colorectal cancer risk
50
  - **PCPT** - Prostate cancer risk
51
  - **Extended PBCG** - Prostate cancer risk (extended model)
52
- - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
53
  - **QCancer** - Multi-site cancer differential
54
 
55
  Additional models should follow the interfaces under `src/sentinel/risk_models`.
 
45
  Implemented risk calculators include:
46
  - **Gail** - Breast cancer risk
47
  - **Claus** - Breast cancer risk based on family history
48
+ - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
49
  - **PLCOm2012** - Lung cancer risk
50
+ - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
51
  - **CRC-PRO** - Colorectal cancer risk
52
  - **PCPT** - Prostate cancer risk
53
  - **Extended PBCG** - Prostate cancer risk (extended model)
54
+ - **MRAT** - Melanoma risk (5-year prediction)
55
  - **QCancer** - Multi-site cancer differential
56
 
57
  Additional models should follow the interfaces under `src/sentinel/risk_models`.
GEMINI.md CHANGED
@@ -45,11 +45,13 @@ When making changes to the project, ensure that the following files are updated
45
  Risk calculators exposed to Gemini-based agents include:
46
  - **Gail** - Breast cancer risk
47
  - **Claus** - Breast cancer risk based on family history
 
48
  - **PLCOm2012** - Lung cancer risk
 
49
  - **CRC-PRO** - Colorectal cancer risk
50
  - **PCPT** - Prostate cancer risk
51
  - **Extended PBCG** - Prostate cancer risk (extended model)
52
- - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
53
  - **QCancer** - Multi-site cancer differential
54
 
55
  Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide.
 
45
  Risk calculators exposed to Gemini-based agents include:
46
  - **Gail** - Breast cancer risk
47
  - **Claus** - Breast cancer risk based on family history
48
+ - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
49
  - **PLCOm2012** - Lung cancer risk
50
+ - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
51
  - **CRC-PRO** - Colorectal cancer risk
52
  - **PCPT** - Prostate cancer risk
53
  - **Extended PBCG** - Prostate cancer risk (extended model)
54
+ - **MRAT** - Melanoma risk (5-year prediction)
55
  - **QCancer** - Multi-site cancer differential
56
 
57
  Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide.
README.md CHANGED
@@ -129,11 +129,16 @@ When making changes to the project, check if the following files should also upd
129
 
130
  The assistant currently includes the following built-in risk calculators:
131
 
132
- - Gail Model (Breast Cancer)
133
- - PLCOm2012 (Lung Cancer)
134
- - CRC-PRO (Colorectal Cancer)
135
- - PCPT (Prostate Cancer)
136
- - QCancer (Multi-site cancer differential)
 
 
 
 
 
137
 
138
  ## Generating Documentation
139
 
 
129
 
130
  The assistant currently includes the following built-in risk calculators:
131
 
132
+ - **Gail** - Breast cancer risk
133
+ - **Claus** - Breast cancer risk based on family history
134
+ - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
135
+ - **PLCOm2012** - Lung cancer risk
136
+ - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
137
+ - **CRC-PRO** - Colorectal cancer risk
138
+ - **PCPT** - Prostate cancer risk
139
+ - **Extended PBCG** - Prostate cancer risk (extended model)
140
+ - **MRAT** - Melanoma risk (5-year prediction)
141
+ - **QCancer** - Multi-site cancer differential
142
 
143
  ## Generating Documentation
144
 
src/sentinel/risk_models/__init__.py CHANGED
@@ -5,6 +5,7 @@ from sentinel.risk_models.claus import ClausRiskModel
5
  from sentinel.risk_models.crc_pro import CRCProRiskModel
6
  from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel
7
  from sentinel.risk_models.gail import GailRiskModel
 
8
  from sentinel.risk_models.mrat import MRATRiskModel
9
  from sentinel.risk_models.pcpt import PCPTRiskModel
10
  from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
@@ -13,6 +14,7 @@ from sentinel.risk_models.qcancer import QCancerRiskModel
13
  RISK_MODELS = [
14
  GailRiskModel,
15
  PLCOm2012RiskModel,
 
16
  BOADICEARiskModel,
17
  CRCProRiskModel,
18
  ExtendedPBCGRiskModel,
@@ -26,6 +28,7 @@ __all__ = [
26
  "RISK_MODELS",
27
  "ClausRiskModel",
28
  "GailRiskModel",
 
29
  "MRATRiskModel",
30
  "PLCOm2012RiskModel",
31
  ]
 
5
  from sentinel.risk_models.crc_pro import CRCProRiskModel
6
  from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel
7
  from sentinel.risk_models.gail import GailRiskModel
8
+ from sentinel.risk_models.llpi import LLPiRiskModel
9
  from sentinel.risk_models.mrat import MRATRiskModel
10
  from sentinel.risk_models.pcpt import PCPTRiskModel
11
  from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
 
14
  RISK_MODELS = [
15
  GailRiskModel,
16
  PLCOm2012RiskModel,
17
+ LLPiRiskModel,
18
  BOADICEARiskModel,
19
  CRCProRiskModel,
20
  ExtendedPBCGRiskModel,
 
28
  "RISK_MODELS",
29
  "ClausRiskModel",
30
  "GailRiskModel",
31
+ "LLPiRiskModel",
32
  "MRATRiskModel",
33
  "PLCOm2012RiskModel",
34
  ]
src/sentinel/risk_models/llpi.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lung cancer risk estimation using the Liverpool Lung Project improved (LLPi) model.
2
+
3
+ This module provides a Python implementation of the LLPi model, which predicts the
4
+ 8.7-year probability of developing lung cancer. The model is based on a Cox proportional
5
+ hazards model calibrated to Liverpool data.
6
+
7
+ The model was published by Marcus et al. (2015) and represents an improvement over
8
+ the original Liverpool Lung Project (LLP) model.
9
+
10
+ Reference:
11
+ Marcus MW, Chen Y, Raji OY, Duffy SW, Field JK.
12
+ LLPi: Liverpool lung project risk prediction model for lung cancer incidence.
13
+ Cancer Prev Res (Phila) 2015;8:570-5.
14
+ """
15
+
16
+ import math
17
+ from typing import Annotated
18
+
19
+ from pydantic import Field
20
+
21
+ from sentinel.risk_models.base import RiskModel
22
+ from sentinel.user_input import (
23
+ CancerType,
24
+ ChronicCondition,
25
+ Sex,
26
+ SmokingStatus,
27
+ UserInput,
28
+ )
29
+
30
+
31
+ class LLPiRiskModel(RiskModel):
32
+ """Compute 8.7-year absolute lung cancer risk using the LLPi model.
33
+
34
+ The LLPi model uses a Cox proportional hazards approach with baseline
35
+ survival calibrated to Liverpool population data. It requires:
36
+ - Age
37
+ - Sex
38
+ - Years smoked
39
+ - COPD diagnosis
40
+ - Prior cancer history
41
+ - Family history of lung cancer (early vs late onset)
42
+ """
43
+
44
+ # Model coefficients from the R implementation
45
+ _COEFFICIENTS = {
46
+ "age": 0.036,
47
+ "male": 0.391,
48
+ "smkyears": 0.043,
49
+ "copd": 0.890,
50
+ "prior_cancer": 1.044,
51
+ "fam_cancer_early": 0.521, # Early onset family history
52
+ "fam_cancer_late": 0.071, # Late onset family history
53
+ }
54
+
55
+ # Baseline survival and centering constant from Liverpool data
56
+ _BASELINE_SURVIVAL = 0.9728386 # Baseline survival at 8.7 years
57
+ _MEAN_LINEAR_PREDICTOR = 3.556 # Mean linear predictor in Liverpool data
58
+
59
+ def __init__(self):
60
+ super().__init__("llpi")
61
+
62
+ REQUIRED_INPUTS: dict[str, tuple[type, bool]] = {
63
+ "demographics.age_years": (Annotated[int, Field(ge=40, le=85)], True),
64
+ "demographics.sex": (Sex, True),
65
+ "lifestyle.smoking.years_smoked": (Annotated[int, Field(ge=0)], True),
66
+ "personal_medical_history.chronic_conditions": (
67
+ list,
68
+ False,
69
+ ), # list[ChronicCondition]
70
+ "personal_medical_history.previous_cancers": (list, False), # list[CancerType]
71
+ "family_history": (list, False), # list[FamilyMemberCancer]
72
+ }
73
+
74
+ def calculate_risk(
75
+ self,
76
+ *,
77
+ age: int,
78
+ male: int,
79
+ smkyears: int,
80
+ copd: int,
81
+ prior_cancer: int,
82
+ fam_cancer_onset: int,
83
+ ) -> float:
84
+ """Calculate the 8.7-year lung cancer risk using the LLPi formula.
85
+
86
+ Args:
87
+ age: Patient age in years.
88
+ male: 1 if male, 0 if female.
89
+ smkyears: Total years smoked.
90
+ copd: 1 if COPD/emphysema present, 0 otherwise.
91
+ prior_cancer: 1 if prior cancer history, 0 otherwise.
92
+ fam_cancer_onset: 0=none, 1=early onset, 2=late onset.
93
+
94
+ Returns:
95
+ Risk as a decimal (0-1).
96
+ """
97
+ # Calculate linear predictor
98
+ linear_predictors = (
99
+ self._COEFFICIENTS["age"] * age
100
+ + self._COEFFICIENTS["male"] * male
101
+ + self._COEFFICIENTS["smkyears"] * smkyears
102
+ + self._COEFFICIENTS["copd"] * copd
103
+ + self._COEFFICIENTS["prior_cancer"] * prior_cancer
104
+ )
105
+
106
+ # Add family history component
107
+ if fam_cancer_onset == 1: # Early onset
108
+ linear_predictors += self._COEFFICIENTS["fam_cancer_early"]
109
+ elif fam_cancer_onset == 2: # Late onset
110
+ linear_predictors += self._COEFFICIENTS["fam_cancer_late"]
111
+
112
+ # Calculate risk using survival formula
113
+ # risk = 1 - S0^exp(linear_predictors - mean_linear_predictors)
114
+ exponent = math.exp(linear_predictors - self._MEAN_LINEAR_PREDICTOR)
115
+ risk = 1 - (self._BASELINE_SURVIVAL**exponent)
116
+
117
+ return risk
118
+
119
+ def _determine_family_cancer_onset(self, user: UserInput) -> int:
120
+ """Determine family cancer onset category from family history.
121
+
122
+ Args:
123
+ user: The user profile.
124
+
125
+ Returns:
126
+ 0 = no family history of lung cancer
127
+ 1 = early onset (diagnosed before age 60)
128
+ 2 = late onset (diagnosed at age 60 or later)
129
+ """
130
+ if not user.family_history:
131
+ return 0
132
+
133
+ # Check for lung cancer in first-degree relatives
134
+ lung_cancer_ages = [
135
+ member.age_at_diagnosis
136
+ for member in user.family_history
137
+ if member.cancer_type == CancerType.LUNG
138
+ and member.age_at_diagnosis is not None
139
+ ]
140
+
141
+ if not lung_cancer_ages:
142
+ return 0
143
+
144
+ # If any relative had early onset (< 60), classify as early onset
145
+ if any(age < 60 for age in lung_cancer_ages):
146
+ return 1
147
+ else:
148
+ return 2
149
+
150
+ def compute_score(self, user: UserInput) -> str:
151
+ """Compute the score for the LLPi model.
152
+
153
+ Args:
154
+ user: The user profile.
155
+
156
+ Returns:
157
+ str: Risk percentage as a string or an N/A message if not applicable.
158
+
159
+ Raises:
160
+ ValueError: If required inputs are missing or invalid.
161
+ """
162
+ # Validate inputs first
163
+ is_valid, errors = self.validate_inputs(user)
164
+ if not is_valid:
165
+ raise ValueError(f"Invalid inputs for LLPi: {'; '.join(errors)}")
166
+
167
+ # Check if the user is a current or former smoker
168
+ if user.lifestyle.smoking.status == SmokingStatus.NEVER:
169
+ return "N/A: Model is for current or former smokers only."
170
+
171
+ # Check for minimum smoking history
172
+ if user.lifestyle.smoking.years_smoked == 0:
173
+ return "N/A: Model requires smoking history."
174
+
175
+ # Determine sex (male = 1, female = 0)
176
+ male = 1 if user.demographics.sex == Sex.MALE else 0
177
+
178
+ # Check for COPD
179
+ copd = (
180
+ 1
181
+ if ChronicCondition.COPD in user.personal_medical_history.chronic_conditions
182
+ else 0
183
+ )
184
+
185
+ # Check for prior cancer history
186
+ prior_cancer = 1 if user.personal_medical_history.previous_cancers else 0
187
+
188
+ # Determine family cancer onset category
189
+ fam_cancer_onset = self._determine_family_cancer_onset(user)
190
+
191
+ # Calculate the risk
192
+ risk = self.calculate_risk(
193
+ age=user.demographics.age_years,
194
+ male=male,
195
+ smkyears=user.lifestyle.smoking.years_smoked,
196
+ copd=copd,
197
+ prior_cancer=prior_cancer,
198
+ fam_cancer_onset=fam_cancer_onset,
199
+ )
200
+
201
+ # Convert to percentage
202
+ risk_percentage = risk * 100
203
+
204
+ return f"{risk_percentage:.2f}%"
205
+
206
+ def cancer_type(self) -> str:
207
+ return "lung"
208
+
209
+ def description(self) -> str:
210
+ return (
211
+ "The Liverpool Lung Project improved (LLPi) model predicts the 8.7-year "
212
+ "probability of developing lung cancer for current or former smokers. "
213
+ "The model uses age, sex, smoking history, COPD status, prior cancer history, "
214
+ "and family history of lung cancer."
215
+ )
216
+
217
+ def interpretation(self) -> str:
218
+ return (
219
+ "The score is the percentage chance of developing lung cancer in the next "
220
+ "8.7 years. A higher score indicates greater risk. This score is based on "
221
+ "data from the Liverpool Lung Project and can help identify individuals "
222
+ "who may benefit from lung cancer screening."
223
+ )
224
+
225
+ def references(self) -> list[str]:
226
+ return [
227
+ "Marcus MW, Chen Y, Raji OY, Duffy SW, Field JK. "
228
+ "LLPi: Liverpool lung project risk prediction model for lung cancer incidence. "
229
+ "Cancer Prev Res (Phila) 2015;8:570-5."
230
+ ]
tests/test_risk_models/test_llpi_model.py ADDED
@@ -0,0 +1,786 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the Liverpool Lung Project improved (LLPi) Risk Model.
2
+
3
+ Ground truth values are calculated from the reference R implementation
4
+ in lcmodels/R/lcmodels.R (risk.llpi function).
5
+ """
6
+
7
+ import pytest
8
+
9
+ from sentinel.risk_models.llpi import LLPiRiskModel
10
+ from sentinel.user_input import (
11
+ Anthropometrics,
12
+ CancerType,
13
+ ChronicCondition,
14
+ Demographics,
15
+ FamilyMemberCancer,
16
+ FamilyRelation,
17
+ FamilySide,
18
+ Lifestyle,
19
+ PersonalMedicalHistory,
20
+ RelationshipDegree,
21
+ Sex,
22
+ SmokingHistory,
23
+ SmokingStatus,
24
+ UserInput,
25
+ )
26
+
27
+ # Ground truth test cases generated from R implementation
28
+ GROUND_TRUTH_CASES = [
29
+ {
30
+ "name": "low_risk_young_female",
31
+ "input": UserInput(
32
+ demographics=Demographics(
33
+ age_years=50,
34
+ sex=Sex.FEMALE,
35
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=70.0),
36
+ ),
37
+ lifestyle=Lifestyle(
38
+ smoking=SmokingHistory(
39
+ status=SmokingStatus.FORMER,
40
+ years_smoked=10,
41
+ cigarettes_per_day=10,
42
+ years_since_quit=5,
43
+ )
44
+ ),
45
+ personal_medical_history=PersonalMedicalHistory(
46
+ chronic_conditions=[],
47
+ previous_cancers=[],
48
+ ),
49
+ family_history=[],
50
+ ),
51
+ "expected": 0.73,
52
+ },
53
+ {
54
+ "name": "moderate_risk_male",
55
+ "input": UserInput(
56
+ demographics=Demographics(
57
+ age_years=60,
58
+ sex=Sex.MALE,
59
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
60
+ ),
61
+ lifestyle=Lifestyle(
62
+ smoking=SmokingHistory(
63
+ status=SmokingStatus.CURRENT,
64
+ years_smoked=25,
65
+ cigarettes_per_day=20,
66
+ years_since_quit=None,
67
+ )
68
+ ),
69
+ personal_medical_history=PersonalMedicalHistory(
70
+ chronic_conditions=[],
71
+ previous_cancers=[],
72
+ ),
73
+ family_history=[],
74
+ ),
75
+ "expected": 2.91,
76
+ },
77
+ {
78
+ "name": "high_risk_copd",
79
+ "input": UserInput(
80
+ demographics=Demographics(
81
+ age_years=70,
82
+ sex=Sex.MALE,
83
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0),
84
+ ),
85
+ lifestyle=Lifestyle(
86
+ smoking=SmokingHistory(
87
+ status=SmokingStatus.FORMER,
88
+ years_smoked=45,
89
+ cigarettes_per_day=30,
90
+ years_since_quit=2,
91
+ )
92
+ ),
93
+ personal_medical_history=PersonalMedicalHistory(
94
+ chronic_conditions=[ChronicCondition.COPD],
95
+ previous_cancers=[],
96
+ ),
97
+ family_history=[],
98
+ ),
99
+ "expected": 21.62,
100
+ },
101
+ {
102
+ "name": "high_risk_prior_cancer",
103
+ "input": UserInput(
104
+ demographics=Demographics(
105
+ age_years=65,
106
+ sex=Sex.MALE,
107
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=78.0),
108
+ ),
109
+ lifestyle=Lifestyle(
110
+ smoking=SmokingHistory(
111
+ status=SmokingStatus.FORMER,
112
+ years_smoked=35,
113
+ cigarettes_per_day=25,
114
+ years_since_quit=5,
115
+ )
116
+ ),
117
+ personal_medical_history=PersonalMedicalHistory(
118
+ chronic_conditions=[],
119
+ previous_cancers=[CancerType.PROSTATE],
120
+ ),
121
+ family_history=[],
122
+ ),
123
+ "expected": 14.31,
124
+ },
125
+ {
126
+ "name": "early_onset_family_history",
127
+ "input": UserInput(
128
+ demographics=Demographics(
129
+ age_years=55,
130
+ sex=Sex.FEMALE,
131
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=68.0),
132
+ ),
133
+ lifestyle=Lifestyle(
134
+ smoking=SmokingHistory(
135
+ status=SmokingStatus.CURRENT,
136
+ years_smoked=20,
137
+ cigarettes_per_day=15,
138
+ years_since_quit=None,
139
+ )
140
+ ),
141
+ personal_medical_history=PersonalMedicalHistory(
142
+ chronic_conditions=[],
143
+ previous_cancers=[],
144
+ ),
145
+ family_history=[
146
+ FamilyMemberCancer(
147
+ relation=FamilyRelation.MOTHER,
148
+ side=FamilySide.MATERNAL,
149
+ degree=RelationshipDegree.FIRST,
150
+ cancer_type=CancerType.LUNG,
151
+ age_at_diagnosis=55, # Early onset (< 60)
152
+ )
153
+ ],
154
+ ),
155
+ "expected": 2.24,
156
+ },
157
+ {
158
+ "name": "late_onset_family_history",
159
+ "input": UserInput(
160
+ demographics=Demographics(
161
+ age_years=58,
162
+ sex=Sex.MALE,
163
+ anthropometrics=Anthropometrics(height_cm=178.0, weight_kg=82.0),
164
+ ),
165
+ lifestyle=Lifestyle(
166
+ smoking=SmokingHistory(
167
+ status=SmokingStatus.FORMER,
168
+ years_smoked=30,
169
+ cigarettes_per_day=20,
170
+ years_since_quit=3,
171
+ )
172
+ ),
173
+ personal_medical_history=PersonalMedicalHistory(
174
+ chronic_conditions=[],
175
+ previous_cancers=[],
176
+ ),
177
+ family_history=[
178
+ FamilyMemberCancer(
179
+ relation=FamilyRelation.FATHER,
180
+ side=FamilySide.PATERNAL,
181
+ degree=RelationshipDegree.FIRST,
182
+ cancer_type=CancerType.LUNG,
183
+ age_at_diagnosis=65, # Late onset (>= 60)
184
+ )
185
+ ],
186
+ ),
187
+ "expected": 3.59,
188
+ },
189
+ {
190
+ "name": "multiple_risk_factors",
191
+ "input": UserInput(
192
+ demographics=Demographics(
193
+ age_years=68,
194
+ sex=Sex.MALE,
195
+ anthropometrics=Anthropometrics(height_cm=172.0, weight_kg=76.0),
196
+ ),
197
+ lifestyle=Lifestyle(
198
+ smoking=SmokingHistory(
199
+ status=SmokingStatus.CURRENT,
200
+ years_smoked=40,
201
+ cigarettes_per_day=30,
202
+ years_since_quit=None,
203
+ )
204
+ ),
205
+ personal_medical_history=PersonalMedicalHistory(
206
+ chronic_conditions=[ChronicCondition.COPD],
207
+ previous_cancers=[CancerType.PROSTATE],
208
+ ),
209
+ family_history=[
210
+ FamilyMemberCancer(
211
+ relation=FamilyRelation.MOTHER,
212
+ side=FamilySide.MATERNAL,
213
+ degree=RelationshipDegree.FIRST,
214
+ cancer_type=CancerType.LUNG,
215
+ age_at_diagnosis=58, # Early onset
216
+ )
217
+ ],
218
+ ),
219
+ "expected": 58.29,
220
+ },
221
+ {
222
+ "name": "female_copd_family_history",
223
+ "input": UserInput(
224
+ demographics=Demographics(
225
+ age_years=62,
226
+ sex=Sex.FEMALE,
227
+ anthropometrics=Anthropometrics(height_cm=162.0, weight_kg=65.0),
228
+ ),
229
+ lifestyle=Lifestyle(
230
+ smoking=SmokingHistory(
231
+ status=SmokingStatus.FORMER,
232
+ years_smoked=28,
233
+ cigarettes_per_day=18,
234
+ years_since_quit=4,
235
+ )
236
+ ),
237
+ personal_medical_history=PersonalMedicalHistory(
238
+ chronic_conditions=[ChronicCondition.COPD],
239
+ previous_cancers=[],
240
+ ),
241
+ family_history=[
242
+ FamilyMemberCancer(
243
+ relation=FamilyRelation.FATHER,
244
+ side=FamilySide.PATERNAL,
245
+ degree=RelationshipDegree.FIRST,
246
+ cancer_type=CancerType.LUNG,
247
+ age_at_diagnosis=70, # Late onset
248
+ )
249
+ ],
250
+ ),
251
+ "expected": 6.19,
252
+ },
253
+ {
254
+ "name": "minimal_smoking_no_risk",
255
+ "input": UserInput(
256
+ demographics=Demographics(
257
+ age_years=52,
258
+ sex=Sex.FEMALE,
259
+ anthropometrics=Anthropometrics(height_cm=160.0, weight_kg=62.0),
260
+ ),
261
+ lifestyle=Lifestyle(
262
+ smoking=SmokingHistory(
263
+ status=SmokingStatus.FORMER,
264
+ years_smoked=5,
265
+ cigarettes_per_day=8,
266
+ years_since_quit=10,
267
+ )
268
+ ),
269
+ personal_medical_history=PersonalMedicalHistory(
270
+ chronic_conditions=[],
271
+ previous_cancers=[],
272
+ ),
273
+ family_history=[],
274
+ ),
275
+ "expected": 0.63,
276
+ },
277
+ {
278
+ "name": "heavy_smoker_female",
279
+ "input": UserInput(
280
+ demographics=Demographics(
281
+ age_years=58,
282
+ sex=Sex.FEMALE,
283
+ anthropometrics=Anthropometrics(height_cm=168.0, weight_kg=72.0),
284
+ ),
285
+ lifestyle=Lifestyle(
286
+ smoking=SmokingHistory(
287
+ status=SmokingStatus.CURRENT,
288
+ years_smoked=38,
289
+ cigarettes_per_day=25,
290
+ years_since_quit=None,
291
+ )
292
+ ),
293
+ personal_medical_history=PersonalMedicalHistory(
294
+ chronic_conditions=[],
295
+ previous_cancers=[],
296
+ ),
297
+ family_history=[],
298
+ ),
299
+ "expected": 3.20,
300
+ },
301
+ ]
302
+
303
+
304
+ class TestLLPiModel:
305
+ """Test suite for LLPiRiskModel."""
306
+
307
+ def setup_method(self):
308
+ """Initialize LLPiRiskModel instance for testing."""
309
+ self.model = LLPiRiskModel()
310
+
311
+ @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"])
312
+ def test_ground_truth_validation(self, case):
313
+ """Test against calculated ground truth results from R implementation.
314
+
315
+ Args:
316
+ case: Parameterized ground truth case dict.
317
+ """
318
+ user = case["input"]
319
+ score_str = self.model.compute_score(user)
320
+ calculated = float(score_str.rstrip("%"))
321
+ expected = case["expected"]
322
+
323
+ # Using tight tolerance since these are calculated values
324
+ assert calculated == pytest.approx(expected, abs=0.01)
325
+
326
+ def test_never_smoker_handling(self):
327
+ """Test that never smokers receive N/A response."""
328
+ never_smoker = UserInput(
329
+ demographics=Demographics(
330
+ age_years=55,
331
+ sex=Sex.MALE,
332
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
333
+ ),
334
+ lifestyle=Lifestyle(
335
+ smoking=SmokingHistory(
336
+ status=SmokingStatus.NEVER,
337
+ cigarettes_per_day=0,
338
+ years_smoked=0,
339
+ years_since_quit=None,
340
+ )
341
+ ),
342
+ personal_medical_history=PersonalMedicalHistory(),
343
+ family_history=[],
344
+ )
345
+
346
+ score = self.model.compute_score(never_smoker)
347
+ assert score == "N/A: Model is for current or former smokers only."
348
+
349
+ def test_no_smoking_history(self):
350
+ """Test handling when years_smoked is 0."""
351
+ user = UserInput(
352
+ demographics=Demographics(
353
+ age_years=55,
354
+ sex=Sex.MALE,
355
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
356
+ ),
357
+ lifestyle=Lifestyle(
358
+ smoking=SmokingHistory(
359
+ status=SmokingStatus.CURRENT,
360
+ cigarettes_per_day=10,
361
+ years_smoked=0,
362
+ years_since_quit=None,
363
+ )
364
+ ),
365
+ personal_medical_history=PersonalMedicalHistory(),
366
+ family_history=[],
367
+ )
368
+
369
+ score = self.model.compute_score(user)
370
+ assert "Model requires smoking history" in score
371
+
372
+ def test_age_validation(self):
373
+ """Test age validation (40-85 range)."""
374
+ young_user = UserInput(
375
+ demographics=Demographics(
376
+ age_years=35,
377
+ sex=Sex.MALE,
378
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
379
+ ),
380
+ lifestyle=Lifestyle(
381
+ smoking=SmokingHistory(
382
+ status=SmokingStatus.CURRENT,
383
+ cigarettes_per_day=20,
384
+ years_smoked=15,
385
+ years_since_quit=None,
386
+ )
387
+ ),
388
+ personal_medical_history=PersonalMedicalHistory(),
389
+ family_history=[],
390
+ )
391
+
392
+ with pytest.raises(ValueError, match=r"Invalid inputs for LLPi:"):
393
+ self.model.compute_score(young_user)
394
+
395
+ old_user = UserInput(
396
+ demographics=Demographics(
397
+ age_years=90,
398
+ sex=Sex.MALE,
399
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
400
+ ),
401
+ lifestyle=Lifestyle(
402
+ smoking=SmokingHistory(
403
+ status=SmokingStatus.FORMER,
404
+ cigarettes_per_day=20,
405
+ years_smoked=30,
406
+ years_since_quit=10,
407
+ )
408
+ ),
409
+ personal_medical_history=PersonalMedicalHistory(),
410
+ family_history=[],
411
+ )
412
+
413
+ with pytest.raises(ValueError, match=r"Invalid inputs for LLPi:"):
414
+ self.model.compute_score(old_user)
415
+
416
+ def test_copd_detection(self):
417
+ """Test COPD detection from chronic conditions."""
418
+ user = UserInput(
419
+ demographics=Demographics(
420
+ age_years=60,
421
+ sex=Sex.MALE,
422
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
423
+ ),
424
+ lifestyle=Lifestyle(
425
+ smoking=SmokingHistory(
426
+ status=SmokingStatus.CURRENT,
427
+ cigarettes_per_day=20,
428
+ years_smoked=25,
429
+ years_since_quit=None,
430
+ )
431
+ ),
432
+ personal_medical_history=PersonalMedicalHistory(
433
+ chronic_conditions=[ChronicCondition.COPD, ChronicCondition.DIABETES],
434
+ ),
435
+ family_history=[],
436
+ )
437
+
438
+ score = self.model.compute_score(user)
439
+ assert "%" in score
440
+
441
+ # Risk should be higher with COPD
442
+ risk_with_copd = float(score.rstrip("%"))
443
+
444
+ # Compare to same user without COPD
445
+ user_no_copd = UserInput(
446
+ demographics=Demographics(
447
+ age_years=60,
448
+ sex=Sex.MALE,
449
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
450
+ ),
451
+ lifestyle=Lifestyle(
452
+ smoking=SmokingHistory(
453
+ status=SmokingStatus.CURRENT,
454
+ cigarettes_per_day=20,
455
+ years_smoked=25,
456
+ years_since_quit=None,
457
+ )
458
+ ),
459
+ personal_medical_history=PersonalMedicalHistory(
460
+ chronic_conditions=[ChronicCondition.DIABETES],
461
+ ),
462
+ family_history=[],
463
+ )
464
+
465
+ score_no_copd = self.model.compute_score(user_no_copd)
466
+ risk_no_copd = float(score_no_copd.rstrip("%"))
467
+
468
+ assert risk_with_copd > risk_no_copd
469
+
470
+ def test_prior_cancer_detection(self):
471
+ """Test prior cancer history detection."""
472
+ user = UserInput(
473
+ demographics=Demographics(
474
+ age_years=60,
475
+ sex=Sex.MALE,
476
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
477
+ ),
478
+ lifestyle=Lifestyle(
479
+ smoking=SmokingHistory(
480
+ status=SmokingStatus.CURRENT,
481
+ cigarettes_per_day=20,
482
+ years_smoked=25,
483
+ years_since_quit=None,
484
+ )
485
+ ),
486
+ personal_medical_history=PersonalMedicalHistory(
487
+ chronic_conditions=[],
488
+ previous_cancers=[CancerType.COLORECTAL, CancerType.PROSTATE],
489
+ ),
490
+ family_history=[],
491
+ )
492
+
493
+ score = self.model.compute_score(user)
494
+ assert "%" in score
495
+
496
+ # Risk should be higher with prior cancer
497
+ risk_with_cancer = float(score.rstrip("%"))
498
+
499
+ # Compare to same user without prior cancer
500
+ user_no_cancer = UserInput(
501
+ demographics=Demographics(
502
+ age_years=60,
503
+ sex=Sex.MALE,
504
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
505
+ ),
506
+ lifestyle=Lifestyle(
507
+ smoking=SmokingHistory(
508
+ status=SmokingStatus.CURRENT,
509
+ cigarettes_per_day=20,
510
+ years_smoked=25,
511
+ years_since_quit=None,
512
+ )
513
+ ),
514
+ personal_medical_history=PersonalMedicalHistory(
515
+ chronic_conditions=[],
516
+ previous_cancers=[],
517
+ ),
518
+ family_history=[],
519
+ )
520
+
521
+ score_no_cancer = self.model.compute_score(user_no_cancer)
522
+ risk_no_cancer = float(score_no_cancer.rstrip("%"))
523
+
524
+ assert risk_with_cancer > risk_no_cancer
525
+
526
+ def test_family_history_early_vs_late(self):
527
+ """Test that early onset family history has higher coefficient than late onset."""
528
+ base_user_data = {
529
+ "demographics": Demographics(
530
+ age_years=60,
531
+ sex=Sex.MALE,
532
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
533
+ ),
534
+ "lifestyle": Lifestyle(
535
+ smoking=SmokingHistory(
536
+ status=SmokingStatus.CURRENT,
537
+ cigarettes_per_day=20,
538
+ years_smoked=25,
539
+ years_since_quit=None,
540
+ )
541
+ ),
542
+ "personal_medical_history": PersonalMedicalHistory(),
543
+ }
544
+
545
+ # Early onset family history
546
+ user_early = UserInput(
547
+ **base_user_data,
548
+ family_history=[
549
+ FamilyMemberCancer(
550
+ relation=FamilyRelation.MOTHER,
551
+ side=FamilySide.MATERNAL,
552
+ degree=RelationshipDegree.FIRST,
553
+ cancer_type=CancerType.LUNG,
554
+ age_at_diagnosis=55, # Early onset
555
+ )
556
+ ],
557
+ )
558
+
559
+ # Late onset family history
560
+ user_late = UserInput(
561
+ **base_user_data,
562
+ family_history=[
563
+ FamilyMemberCancer(
564
+ relation=FamilyRelation.FATHER,
565
+ side=FamilySide.PATERNAL,
566
+ degree=RelationshipDegree.FIRST,
567
+ cancer_type=CancerType.LUNG,
568
+ age_at_diagnosis=70, # Late onset
569
+ )
570
+ ],
571
+ )
572
+
573
+ # No family history
574
+ user_none = UserInput(**base_user_data, family_history=[])
575
+
576
+ risk_early = float(self.model.compute_score(user_early).rstrip("%"))
577
+ risk_late = float(self.model.compute_score(user_late).rstrip("%"))
578
+ risk_none = float(self.model.compute_score(user_none).rstrip("%"))
579
+
580
+ # Early onset should confer higher risk than late onset
581
+ assert risk_early > risk_late > risk_none
582
+
583
+ def test_family_history_non_lung_cancer_ignored(self):
584
+ """Test that non-lung cancer family history is ignored."""
585
+ user_lung = UserInput(
586
+ demographics=Demographics(
587
+ age_years=60,
588
+ sex=Sex.MALE,
589
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
590
+ ),
591
+ lifestyle=Lifestyle(
592
+ smoking=SmokingHistory(
593
+ status=SmokingStatus.CURRENT,
594
+ cigarettes_per_day=20,
595
+ years_smoked=25,
596
+ years_since_quit=None,
597
+ )
598
+ ),
599
+ personal_medical_history=PersonalMedicalHistory(),
600
+ family_history=[
601
+ FamilyMemberCancer(
602
+ relation=FamilyRelation.MOTHER,
603
+ side=FamilySide.MATERNAL,
604
+ degree=RelationshipDegree.FIRST,
605
+ cancer_type=CancerType.LUNG,
606
+ age_at_diagnosis=65,
607
+ )
608
+ ],
609
+ )
610
+
611
+ user_breast = UserInput(
612
+ demographics=Demographics(
613
+ age_years=60,
614
+ sex=Sex.MALE,
615
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
616
+ ),
617
+ lifestyle=Lifestyle(
618
+ smoking=SmokingHistory(
619
+ status=SmokingStatus.CURRENT,
620
+ cigarettes_per_day=20,
621
+ years_smoked=25,
622
+ years_since_quit=None,
623
+ )
624
+ ),
625
+ personal_medical_history=PersonalMedicalHistory(),
626
+ family_history=[
627
+ FamilyMemberCancer(
628
+ relation=FamilyRelation.MOTHER,
629
+ side=FamilySide.MATERNAL,
630
+ degree=RelationshipDegree.FIRST,
631
+ cancer_type=CancerType.BREAST,
632
+ age_at_diagnosis=65,
633
+ )
634
+ ],
635
+ )
636
+
637
+ user_none = UserInput(
638
+ demographics=Demographics(
639
+ age_years=60,
640
+ sex=Sex.MALE,
641
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
642
+ ),
643
+ lifestyle=Lifestyle(
644
+ smoking=SmokingHistory(
645
+ status=SmokingStatus.CURRENT,
646
+ cigarettes_per_day=20,
647
+ years_smoked=25,
648
+ years_since_quit=None,
649
+ )
650
+ ),
651
+ personal_medical_history=PersonalMedicalHistory(),
652
+ family_history=[],
653
+ )
654
+
655
+ risk_lung = float(self.model.compute_score(user_lung).rstrip("%"))
656
+ risk_breast = float(self.model.compute_score(user_breast).rstrip("%"))
657
+ risk_none = float(self.model.compute_score(user_none).rstrip("%"))
658
+
659
+ # Lung cancer family history should increase risk
660
+ assert risk_lung > risk_none
661
+ # Breast cancer family history should not affect risk
662
+ assert risk_breast == pytest.approx(risk_none, abs=0.01)
663
+
664
+ def test_sex_difference(self):
665
+ """Test that males have higher risk than females (all else equal)."""
666
+ user_male = UserInput(
667
+ demographics=Demographics(
668
+ age_years=60,
669
+ sex=Sex.MALE,
670
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
671
+ ),
672
+ lifestyle=Lifestyle(
673
+ smoking=SmokingHistory(
674
+ status=SmokingStatus.CURRENT,
675
+ cigarettes_per_day=20,
676
+ years_smoked=25,
677
+ years_since_quit=None,
678
+ )
679
+ ),
680
+ personal_medical_history=PersonalMedicalHistory(),
681
+ family_history=[],
682
+ )
683
+
684
+ user_female = UserInput(
685
+ demographics=Demographics(
686
+ age_years=60,
687
+ sex=Sex.FEMALE,
688
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
689
+ ),
690
+ lifestyle=Lifestyle(
691
+ smoking=SmokingHistory(
692
+ status=SmokingStatus.CURRENT,
693
+ cigarettes_per_day=20,
694
+ years_smoked=25,
695
+ years_since_quit=None,
696
+ )
697
+ ),
698
+ personal_medical_history=PersonalMedicalHistory(),
699
+ family_history=[],
700
+ )
701
+
702
+ risk_male = float(self.model.compute_score(user_male).rstrip("%"))
703
+ risk_female = float(self.model.compute_score(user_female).rstrip("%"))
704
+
705
+ # Males should have higher risk (positive coefficient for male)
706
+ assert risk_male > risk_female
707
+
708
+ def test_model_metadata(self):
709
+ """Test model metadata methods."""
710
+ assert self.model.name == "llpi"
711
+ assert self.model.cancer_type() == "lung"
712
+ assert "LLPi" in self.model.description()
713
+ assert "8.7-year" in self.model.description()
714
+ assert "percentage chance" in self.model.interpretation()
715
+ assert isinstance(self.model.references(), list)
716
+ assert len(self.model.references()) > 0
717
+ assert "Marcus" in self.model.references()[0]
718
+
719
+ def test_calculate_risk_directly(self):
720
+ """Test the calculate_risk method directly with known inputs."""
721
+ # Test case: moderate_risk_male from ground truth
722
+ risk = self.model.calculate_risk(
723
+ age=60,
724
+ male=1,
725
+ smkyears=25,
726
+ copd=0,
727
+ prior_cancer=0,
728
+ fam_cancer_onset=0,
729
+ )
730
+
731
+ # Should match ground truth: 2.91%
732
+ assert risk * 100 == pytest.approx(2.91, abs=0.01)
733
+
734
+ def test_increasing_age_increases_risk(self):
735
+ """Test that increasing age increases risk (positive age coefficient)."""
736
+ risks = []
737
+ for age in [50, 60, 70]:
738
+ user = UserInput(
739
+ demographics=Demographics(
740
+ age_years=age,
741
+ sex=Sex.MALE,
742
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
743
+ ),
744
+ lifestyle=Lifestyle(
745
+ smoking=SmokingHistory(
746
+ status=SmokingStatus.CURRENT,
747
+ cigarettes_per_day=20,
748
+ years_smoked=25,
749
+ years_since_quit=None,
750
+ )
751
+ ),
752
+ personal_medical_history=PersonalMedicalHistory(),
753
+ family_history=[],
754
+ )
755
+ risk = float(self.model.compute_score(user).rstrip("%"))
756
+ risks.append(risk)
757
+
758
+ # Risk should increase with age
759
+ assert risks[0] < risks[1] < risks[2]
760
+
761
+ def test_increasing_smoking_years_increases_risk(self):
762
+ """Test that longer smoking history increases risk."""
763
+ risks = []
764
+ for years in [10, 25, 40]:
765
+ user = UserInput(
766
+ demographics=Demographics(
767
+ age_years=60,
768
+ sex=Sex.MALE,
769
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
770
+ ),
771
+ lifestyle=Lifestyle(
772
+ smoking=SmokingHistory(
773
+ status=SmokingStatus.CURRENT,
774
+ cigarettes_per_day=20,
775
+ years_smoked=years,
776
+ years_since_quit=None,
777
+ )
778
+ ),
779
+ personal_medical_history=PersonalMedicalHistory(),
780
+ family_history=[],
781
+ )
782
+ risk = float(self.model.compute_score(user).rstrip("%"))
783
+ risks.append(risk)
784
+
785
+ # Risk should increase with more smoking years
786
+ assert risks[0] < risks[1] < risks[2]