From 1225802c5d9d824d50f375dae1438312abe7b76c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 09:17:00 +0200 Subject: [PATCH] fix(campaign): suppress overlapping aggregate cells --- .../backend/reports/aggregate.py | 18 +++++++--- tests/test_aggregate_report.py | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/govoplan_campaign/backend/reports/aggregate.py b/src/govoplan_campaign/backend/reports/aggregate.py index 8aad36b..48a9c22 100644 --- a/src/govoplan_campaign/backend/reports/aggregate.py +++ b/src/govoplan_campaign/backend/reports/aggregate.py @@ -268,15 +268,24 @@ def _build_aggregate_campaign_report( outcome_values, threshold=policy.small_cell_threshold, ) + outcome_suppression_applied = denominator.suppressed or any( + item.suppressed for item in outcomes.values() + ) inactive_entries = _inactive_entry_count(version) standalone_counts = { "inactive_source_entries": _suppress_standalone_count( inactive_entries, threshold=policy.small_cell_threshold, ), - "excluded_or_blocked_jobs": _suppress_standalone_count( - facts.excluded_or_blocked, - threshold=policy.small_cell_threshold, + # This population count overlaps the outcome partition, so exposing it + # can make a suppressed outcome recoverable through subtraction. + "excluded_or_blocked_jobs": ( + AggregateCount(value=None, suppressed=True) + if outcome_suppression_applied + else _suppress_standalone_count( + facts.excluded_or_blocked, + threshold=policy.small_cell_threshold, + ) ), } suppression_applied = denominator.suppressed or any( @@ -322,7 +331,8 @@ def _build_aggregate_campaign_report( suppression_applied=suppression_applied, rule=( "Positive counts below the threshold are hidden. At least one additional " - "count or the denominator is hidden when needed to prevent subtraction." + "count or the denominator is hidden when needed to prevent subtraction. " + "Overlapping population counts are hidden whenever outcome suppression applies." ), ), ) diff --git a/tests/test_aggregate_report.py b/tests/test_aggregate_report.py index ff04692..93e2676 100644 --- a/tests/test_aggregate_report.py +++ b/tests/test_aggregate_report.py @@ -141,6 +141,39 @@ def test_small_cells_use_primary_and_complementary_suppression() -> None: assert report.privacy.suppression_applied is True +def test_outcome_suppression_also_hides_overlapping_population_count() -> None: + jobs = [ + *[ + _job( + index, + "smtp_accepted", + validation_status="blocked" if index < 4 else "ready", + ) + for index in range(8) + ], + _job(8, "skipped", validation_status="excluded"), + ] + + report = build_aggregate_campaign_report( + campaign=_campaign(), # type: ignore[arg-type] + version=_version(inactive_count=5), # type: ignore[arg-type] + jobs=jobs, # type: ignore[arg-type] + policy=_policy(5), + ) + + assert report.outcomes.excluded.suppressed is True + assert report.population.excluded_or_blocked_jobs.model_dump() == { + "value": None, + "suppressed": True, + } + # Source entries without jobs are outside the outcome denominator and do + # not overlap the suppressed partition, so their threshold-safe count stays visible. + assert report.population.inactive_source_entries.model_dump() == { + "value": 5, + "suppressed": False, + } + + def test_all_small_cells_also_suppress_the_denominator_and_state() -> None: jobs = [_job(0, "smtp_accepted"), _job(1, "failed_permanent")]