Skip to content

Commit a99c00d

Browse files
Add rule stop functionality (#106)
* Added rule_stop logic * lint fixes * added types to test func * lint fixed * fixed is_rule_stop * added enum for rule stop * RuleStop is False ("N") by default, if not set to "Y" * RuleStop is set to bool * added unit test for rules stop * lint fixes * intermittent unpack issue fixed
1 parent 377c8a4 commit a99c00d

File tree

4 files changed

+82
-4
lines changed

4 files changed

+82
-4
lines changed

src/eligibility_signposting_api/model/rules.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
StartDate = NewType("StartDate", date)
3030
EndDate = NewType("EndDate", date)
3131
CohortLabel = NewType("CohortLabel", str)
32+
RuleStop = NewType("RuleStop", bool)
3233

3334

3435
class RuleType(StrEnum):
@@ -99,6 +100,13 @@ class IterationRule(BaseModel):
99100
operator: RuleOperator = Field(..., alias="Operator")
100101
comparator: RuleComparator = Field(..., alias="Comparator")
101102
attribute_target: RuleAttributeTarget | None = Field(None, alias="AttributeTarget")
103+
rule_stop: RuleStop | None = Field(None, alias="RuleStop")
104+
105+
@field_validator("rule_stop", mode="before")
106+
def parse_yn_to_bool(cls, v: str) -> bool: # noqa: N805
107+
if isinstance(v, str):
108+
return v.upper() == "Y"
109+
return False
102110

103111
model_config = {"populate_by_name": True, "extra": "ignore"}
104112

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,15 @@ def evaluate_eligibility_by_iteration_rules(
106106
exclusion_reasons, actionable_reasons = [], []
107107
by_priority = sorted(iteration.iteration_rules, key=priority_getter)
108108
for _, rule_group in groupby(by_priority, key=priority_getter):
109-
status, group_actionable, group_exclusions = self.evaluate_priority_group(rule_group, worst_status)
109+
status, group_actionable, group_exclusions, is_rule_stop = self.evaluate_priority_group(
110+
rule_group, worst_status
111+
)
110112
# Merge results
111113
worst_status = status
112114
actionable_reasons.extend(group_actionable)
113115
exclusion_reasons.extend(group_exclusions)
116+
if is_rule_stop:
117+
break
114118
condition_status_entry = status_with_reasons.setdefault(worst_status, [])
115119
condition_status_entry.extend(
116120
actionable_reasons if worst_status is eligibility.Status.actionable else exclusion_reasons
@@ -124,9 +128,9 @@ def evaluate_priority_group(
124128
self,
125129
iteration_rule_group: Iterator[rules.IterationRule],
126130
worst_status_so_far_for_condition: eligibility.Status,
127-
) -> tuple[eligibility.Status, list[eligibility.Reason], list[eligibility.Reason]]:
131+
) -> tuple[eligibility.Status, list[eligibility.Reason], list[eligibility.Reason], bool]:
132+
is_rule_stop = False
128133
exclusion_reasons, actionable_reasons = [], []
129-
130134
exclude_capable_rules = [
131135
ir
132136
for ir in iteration_rule_group
@@ -147,4 +151,6 @@ def evaluate_priority_group(
147151
actionable_reasons.append(reason)
148152

149153
worst_group_status = eligibility.Status.worst(best_status, worst_status_so_far_for_condition)
150-
return worst_group_status, actionable_reasons, exclusion_reasons
154+
if worst_group_status.is_exclusion:
155+
is_rule_stop = any(rule.rule_stop for rule in exclude_capable_rules)
156+
return worst_group_status, actionable_reasons, exclusion_reasons, is_rule_stop

tests/fixtures/builders/model/rule.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class IterationCohortFactory(ModelFactory[rules.IterationCohort]): ...
2222
class IterationRuleFactory(ModelFactory[rules.IterationRule]):
2323
attribute_target = None
2424
cohort_label = None
25+
rule_stop = False
2526

2627

2728
class IterationFactory(ModelFactory[rules.Iteration]):

tests/unit/services/calculators/test_eligibility_calculator.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from eligibility_signposting_api.model import rules
99
from eligibility_signposting_api.model import rules as rules_model
1010
from eligibility_signposting_api.model.eligibility import ConditionName, DateOfBirth, NHSNumber, Postcode, Status
11+
from eligibility_signposting_api.model.rules import IterationRule
1112
from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator
1213
from tests.fixtures.builders.model import rule as rule_builder
1314
from tests.fixtures.builders.repos.person import person_rows_builder
@@ -811,3 +812,65 @@ def test_status_if_iteration_rules_contains_cohort_label_field(
811812
),
812813
test_comment,
813814
)
815+
816+
817+
@pytest.mark.parametrize(
818+
("rule_stop", "expected_status", "test_comment"),
819+
[
820+
("Y", Status.not_actionable, "Stops at the first rule"),
821+
("N", Status.not_eligible, "Both the rules are executed"),
822+
("", Status.not_eligible, "Both the rules are executed"),
823+
(None, Status.not_eligible, "Both the rules are executed"),
824+
],
825+
)
826+
def test_rules_stop_behavior(rule_stop: str | None, expected_status: Status, test_comment: str, faker: Faker) -> None:
827+
# Given
828+
nhs_number = NHSNumber(faker.nhs_number())
829+
date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74))
830+
person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"])
831+
832+
# Base rule template
833+
# Not using model factory to create Iteration rules since it sets boolean values for "Y"/"N"
834+
simple_age_data = {
835+
"Name": "Exclude too young less than 75",
836+
"Description": "Exclude too young less than 75",
837+
"AttributeLevel": "PERSON",
838+
"AttributeName": "DATE_OF_BIRTH",
839+
"Operator": "Y>",
840+
"Comparator": "-75",
841+
}
842+
843+
# Build rule variations
844+
rule_variants = [
845+
{"Type": "S", "Priority": 10, "RuleStop": rule_stop},
846+
{"Type": "S", "Priority": 10},
847+
{"Type": "F", "Priority": 15},
848+
]
849+
850+
iteration_rules = [IterationRule.model_validate({**simple_age_data, **variant}) for variant in rule_variants]
851+
852+
# Build campaign configuration
853+
campaign_config = rule_builder.CampaignConfigFactory.build(
854+
target="RSV",
855+
iterations=[
856+
rule_builder.IterationFactory.build(
857+
iteration_rules=[],
858+
iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")],
859+
)
860+
],
861+
)
862+
campaign_config.iterations[0].iteration_rules.extend(iteration_rules)
863+
864+
calculator = EligibilityCalculator(person_rows, [campaign_config])
865+
866+
# When
867+
actual = calculator.evaluate_eligibility()
868+
869+
# Then
870+
assert_that(
871+
actual,
872+
is_eligibility_status().with_conditions(
873+
has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status))
874+
),
875+
test_comment,
876+
)

0 commit comments

Comments
 (0)