Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 33 additions & 23 deletions spp_metric_service/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,27 @@ Key Capabilities
Key Models
~~~~~~~~~~

+---------------------------------+------------------------------------+
| Model | Description |
+=================================+====================================+
| ``spp.demographic.dimension`` | Configurable dimension for |
| | breakdowns (field or CEL) |
+---------------------------------+------------------------------------+
| ``spp.metrics.fairness`` | Abstract service: equity/parity |
| | analysis |
+---------------------------------+------------------------------------+
| ``spp.metrics.distribution`` | Abstract service: distribution |
| | statistics |
+---------------------------------+------------------------------------+
| ``spp.metrics.breakdown`` | Abstract service: |
| | multi-dimensional grouping |
+---------------------------------+------------------------------------+
| ``spp.metrics.privacy`` | Abstract service: k-anonymity |
| | enforcement |
+---------------------------------+------------------------------------+
| ``spp.metrics.dimension.cache`` | Abstract service: dimension |
| | evaluation cache |
+---------------------------------+------------------------------------+
+--------------------------------+-------------------------------------+
| Model | Description |
+================================+=====================================+
| ``spp.demographic.dimension`` | Configurable dimension for |
| | breakdowns (field or CEL) |
+--------------------------------+-------------------------------------+
| ``spp.metric.fairness`` | Abstract service: equity/parity |
| | analysis |
+--------------------------------+-------------------------------------+
| ``spp.metric.distribution`` | Abstract service: distribution |
| | statistics |
+--------------------------------+-------------------------------------+
| ``spp.metric.breakdown`` | Abstract service: multi-dimensional |
| | grouping |
+--------------------------------+-------------------------------------+
| ``spp.metric.privacy`` | Abstract service: k-anonymity |
| | enforcement |
+--------------------------------+-------------------------------------+
| ``spp.metric.dimension.cache`` | Abstract service: dimension |
| | evaluation cache |
+--------------------------------+-------------------------------------+

Configuration
~~~~~~~~~~~~~
Expand Down Expand Up @@ -96,10 +96,10 @@ Group Access
Extension Points
~~~~~~~~~~~~~~~~

- Override ``_analyze_dimension()`` in ``spp.metrics.fairness`` for
- Override ``_analyze_dimension()`` in ``spp.metric.fairness`` for
custom analysis logic
- Add new dimension types by extending ``spp.demographic.dimension``
- Override ``enforce()`` in ``spp.metrics.privacy`` for custom
- Override ``enforce()`` in ``spp.metric.privacy`` for custom
suppression strategies

Dependencies
Expand All @@ -115,6 +115,16 @@ Dependencies
Changelog
=========

19.0.2.1.0
~~~~~~~~~~

- feat: demographic breakdown expansion and SQL column support for
metric disaggregation (re-land from #76; uses the spp_cel_domain SQL
CASE compiler). Expansion is all-or-nothing: any individuals-scoped
dimension expands the whole registrant set to active members, so
breakdown totals count members and need not reconcile with a
group-level scope count.

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_metric_service/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "OpenSPP Metric Service",
"summary": "Computation services for fairness, distribution, breakdown, and privacy",
"category": "OpenSPP",
"version": "19.0.2.0.0",
"version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
16 changes: 10 additions & 6 deletions spp_metric_service/data/demographic_dimensions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<field name="description">Gender identity from ISO 5218 vocabulary</field>
<field name="sequence">10</field>
<field name="dimension_type">field</field>
<field name="field_path">gender_id</field>
<field name="applies_to">all</field>
<field name="field_path">gender_id.code</field>
<field name="applies_to">individuals</field>
<field
name="value_labels_json"
>{"0": "Not Known", "1": "Male", "2": "Female", "9": "Not Applicable"}</field>
Expand Down Expand Up @@ -56,23 +56,27 @@
>{"true": "Group/Household", "false": "Individual"}</field>
</record>

<!-- Age Group Dimension (example using CEL expression) -->
<!-- Age Group Dimension (UNICEF/WHO-aligned buckets) -->
<record id="dimension_age_group" model="spp.demographic.dimension">
<field name="name">age_group</field>
<field name="label">Age Group</field>
<field name="description">Age group based on birth date</field>
<field
name="description"
>Age group based on birth date (UNICEF/WHO-aligned)</field>
<field name="sequence">50</field>
<field name="dimension_type">expression</field>
<field
name="cel_expression"
><![CDATA[
r.birthdate == null ? "unknown" :
age_years(r.birthdate) < 18 ? "child" :
age_years(r.birthdate) < 5 ? "under_5" :
age_years(r.birthdate) < 15 ? "child" :
age_years(r.birthdate) < 18 ? "adolescent" :
age_years(r.birthdate) < 60 ? "adult" : "elderly"
]]></field>
<field name="applies_to">individuals</field>
<field
name="value_labels_json"
>{"child": "Child (0-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"}</field>
>{"under_5": "Under 5", "child": "Child (5-14)", "adolescent": "Adolescent (15-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"}</field>
</record>
</odoo>
55 changes: 55 additions & 0 deletions spp_metric_service/models/breakdown_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N

Uses dimension cache for 5-10x performance improvement.

Expansion semantics (deliberate): if ANY requested dimension has
``applies_to == "individuals"``, the ENTIRE registrant set is expanded
from groups to their active individual members before evaluation —
including for any non-individual dimensions mixed into the same
request (mixing scopes in one breakdown is inherently ambiguous; the
expansion is all-or-nothing by design). Consequently breakdown totals
count members and intentionally need not reconcile with a group-level
scope count.

:param registrant_ids: List of partner IDs
:param group_by: List of dimension names
:param statistics: List of statistic names (optional)
Expand Down Expand Up @@ -56,6 +65,14 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N
if not dimensions:
return {}

# Auto-expand groups to members when any dimension applies to individuals only
needs_expansion = any(d.applies_to == "individuals" for d in dimensions)
if needs_expansion:
registrant_ids = self._expand_groups_to_members(registrant_ids)

if not registrant_ids:
return {}

# Get cache service
cache_service = self.env["spp.metric.dimension.cache"]

Expand Down Expand Up @@ -95,3 +112,41 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N
# TODO: Add per-cell statistics if needed

return breakdown

@api.model
def _expand_groups_to_members(self, registrant_ids):
"""
Expand group IDs to their individual member IDs.

Groups are replaced by their active members. Individual IDs pass through.
The result is deduplicated.

:param registrant_ids: List of partner IDs (groups and/or individuals)
:returns: Deduplicated list of individual partner IDs
:rtype: list
"""
# sudo: aggregate breakdown metrics must expand groups to their members
# across all registrants regardless of the caller's record rules.
# Read-only (no writes); callers are authorized at the service entry point.
Partner = self.env["res.partner"].sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models
records = Partner.browse(registrant_ids).exists()

groups = records.filtered("is_group")
group_ids = groups.ids
individual_ids = set((records - groups).ids)

if not group_ids:
return list(individual_ids)

# Expand groups via active memberships
Membership = self.env["spp.group.membership"].sudo() # nosemgrep: odoo-sudo-without-context
memberships = Membership.search(
[
("group", "in", group_ids),
("is_ended", "=", False),
]
)

individual_ids.update(memberships.individual.ids)

return list(individual_ids)
Loading
Loading