Skip to content

Commit 301f3cc

Browse files
authored
chore: added mermaid and updated weights (#2552)
* chore: added mermaid and updated weights * chore: updated docs --------- Co-authored-by: Mirco Rudolph <mirco@openearth.org>
1 parent 0488773 commit 301f3cc

6 files changed

Lines changed: 286 additions & 4 deletions

File tree

hiap-meed/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Impact block behavior (implemented):
188188
- The service sums activity emissions per GPC key before scoring.
189189
- Impact computes canonical score as:
190190
- `0.80 * reduction_share_of_city_emissions + 0.20 * timeline_score`
191-
- Timeline mapping: `<5 years -> 1.0`, `5-10 years -> 0.5`, `>10 years -> 0.0`
191+
- Timeline mapping: `<5 years -> 1.0`, `5-10 years -> 0.5`, `>10 years -> 0.0`, missing or unknown timeline `-> 0.5`
192192
- Unknown `impact_text` values are rejected with `422` (raised during Impact scoring and surfaced by the API error handler).
193193

194194
Alignment block behavior (implemented):

hiap-meed/app/modules/prioritizer/blocks/alignment.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ def _co_benefit_effect_label(impact_numeric: float | None, *, present: bool) ->
167167
return "neutral"
168168

169169

170+
def _co_benefit_score_source(impact_numeric: float | None, *, present: bool) -> str:
171+
"""Explain whether the normalized score came from a real value or a missing fallback."""
172+
if not present:
173+
return "missing_co_benefit"
174+
if impact_numeric is None:
175+
return "missing_impact_numeric"
176+
return "derived_from_value"
177+
178+
170179
def _build_selected_co_benefit_match_details(
171180
*,
172181
action_co_benefits: dict[str, dict[str, object]],
@@ -196,7 +205,11 @@ def _build_selected_co_benefit_match_details(
196205
"normalized_preference_score": (
197206
_normalized_co_benefit_score(impact_numeric)
198207
if impact_numeric is not None
199-
else 0.0
208+
else 0.5
209+
),
210+
"score_source": _co_benefit_score_source(
211+
impact_numeric,
212+
present=benefit is not None,
200213
),
201214
"effect_label": _co_benefit_effect_label(
202215
impact_numeric,

hiap-meed/app/modules/prioritizer/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"very high": 1.0,
2828
}
2929

30-
IMPACT_DEFAULT_TIMELINE_SCORE = 0.0
30+
IMPACT_DEFAULT_TIMELINE_SCORE = 0.5
3131

3232
IMPACT_TIMELINE_TO_SCORE: dict[str, float] = {
3333
"<5 years": 1.0,

hiap-meed/docs/pipeline-description.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ Timeline mapping:
577577
- `<5 years` -> `1.0`
578578
- `5-10 years` -> `0.5`
579579
- `>10 years` -> `0.0`
580-
- missing or unknown timeline -> `0.0`
580+
- missing or unknown timeline -> `0.5`
581581

582582
Meaning:
583583

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Scoring Weights Overview
2+
3+
This diagram summarizes the scoring weights currently implemented in the `hiap-meed` prioritization pipeline.
4+
5+
```mermaid
6+
%%{init: {"flowchart": {"nodeSpacing": 38, "rankSpacing": 48, "curve": "basis"}, "themeVariables": {"lineColor": "#7a7a7a"}} }%%
7+
flowchart TD
8+
FS["Final Score"]
9+
FS -->|0.55| IMP["Impact Pillar"]
10+
FS -->|0.22| ALN["Alignment Pillar"]
11+
FS -->|0.23| FEA["Feasibility Pillar"]
12+
13+
IMP -->|0.80| IMP_RED["Emissions Reduction Share of City Total"]
14+
IMP -->|0.20| IMP_TIME["Timeline Component"]
15+
16+
IMP_TIME -->|1.0| IMP_T_SHORT["<5 years"]
17+
IMP_TIME -->|0.5| IMP_T_MED["5-10 years"]
18+
IMP_TIME -->|0.0| IMP_T_LONG[">10 years"]
19+
IMP_TIME -->|0.5| IMP_T_UNKNOWN["Missing / Unknown timeline"]
20+
21+
IMP_RED --> IMP_BAND["Impact Text Multiplier"]
22+
IMP_BAND -->|0.2| BAND_VLOW["very low"]
23+
IMP_BAND -->|0.4| BAND_LOW["low"]
24+
IMP_BAND -->|0.6| BAND_MED["medium"]
25+
IMP_BAND -->|0.8| BAND_HIGH["high"]
26+
IMP_BAND -->|1.0| BAND_VHIGH["very high"]
27+
28+
ALN -->|0.75| ALN_POL["Policy Component"]
29+
ALN -->|0.15| ALN_SEC["Sector Match Component"]
30+
ALN -->|0.05| ALN_COB["Co-benefit Preference Component"]
31+
ALN -->|0.05| ALN_TIME["Timeframe Preference Component"]
32+
33+
ALN_POL -->|direct 0..1 input| POL_SCORE["policy_support_score"]
34+
POL_SCORE -->|fallback 0.0| POL_MISS["Missing policy score"]
35+
36+
ALN_SEC -->|1.0| SEC_MATCH["Sector overlap with city preference"]
37+
ALN_SEC -->|0.0| SEC_NO["No sector overlap"]
38+
39+
ALN_COB -->|normalize from -2..2 to 0..1| COB_NORM["Selected co-benefit impacts"]
40+
COB_NORM -->|0.0| COB_HARM["Most harmful aggregate"]
41+
COB_NORM -->|0.5| COB_NEUT["Neutral aggregate"]
42+
COB_NORM -->|1.0| COB_POS["Most beneficial aggregate"]
43+
ALN_COB -->|0.5| COB_NONE["No co-benefits selected"]
44+
45+
ALN_TIME -->|1.0| ALN_TIME_EXACT["Exact timeframe match"]
46+
ALN_TIME -->|0.5| ALN_TIME_ADJ["Adjacent match"]
47+
ALN_TIME -->|0.0| ALN_TIME_FAR["Far mismatch"]
48+
ALN_TIME -->|0.5| ALN_TIME_NOPREF["no_preference / no supported preference"]
49+
ALN_TIME -->|0.5| ALN_TIME_UNKNOWN["Missing / Unknown action timeline"]
50+
51+
FEA -->|0.50| FEA_LEG["Soft Legal Component"]
52+
FEA -->|0.50| FEA_SOC["Socioeconomic Component"]
53+
54+
FEA_LEG -->|aligned_soft / total_soft| LEG_RATIO["Recommended + Optional requirements only"]
55+
LEG_RATIO -->|0.0 fallback| LEG_NONE["No soft legal requirements"]
56+
57+
FEA_SOC -->|normalize from -2..2 to 0..1| SOC_NORM["Weighted socioeconomic average"]
58+
SOC_NORM -->|bucket -2| SOC_VLOW["very_low"]
59+
SOC_NORM -->|bucket -1| SOC_LOW["low"]
60+
SOC_NORM -->|bucket 0| SOC_MED["medium"]
61+
SOC_NORM -->|bucket 1| SOC_HIGH["high"]
62+
SOC_NORM -->|bucket 2| SOC_VHIGH["very_high"]
63+
FEA_SOC -->|missing city indicator -> raw 0| SOC_MISS["Missing city indicator contributes 0 before normalization"]
64+
FEA_SOC -->|0.5 fallback| SOC_NONE["No socioeconomic rules on action"]
65+
66+
linkStyle 0,1,2 stroke-width:4px
67+
linkStyle 3,4,16,17 stroke-width:3px
68+
linkStyle 18,19,20,21,27,28 stroke-width:2.5px
69+
```
70+
71+
## Notes
72+
73+
- `0.5` is not universally neutral across all components.
74+
- `0.5` is a true neutral midpoint only for components normalized from a signed scale such as `-2..2`.
75+
- Timeline is scored differently in Impact and Alignment:
76+
- Impact uses intrinsic speed scoring.
77+
- Alignment uses match-to-city-preference scoring.
78+
79+
## Weight Intuition
80+
81+
These examples use the current default final weights:
82+
83+
- Impact = `0.55`
84+
- Alignment = `0.22`
85+
- Feasibility = `0.23`
86+
87+
### Top-level pillars
88+
89+
- A `+0.10` change in Impact changes the final score by `+0.055`
90+
- A `+0.10` change in Alignment changes the final score by `+0.022`
91+
- A `+0.10` change in Feasibility changes the final score by `+0.023`
92+
93+
This means Impact is a bit more than twice as influential as Alignment or Feasibility in the final ranking.
94+
95+
### Impact examples
96+
97+
- Impact timeline has internal weight `0.20`, so a full `1.0` swing inside that component changes:
98+
- Impact score by `0.20`
99+
- Final score by `0.55 * 0.20 = 0.11`
100+
101+
- Example: changing Impact timeline from missing/unknown `0.5` to short `<5 years = 1.0`
102+
- Impact score change = `+0.10`
103+
- Final score change = `+0.055`
104+
105+
- Example: changing Impact timeline from long `0.0` to short `1.0`
106+
- Impact score change = `+0.20`
107+
- Final score change = `+0.11`
108+
109+
- Impact emissions reduction share has internal weight `0.80`, so a `+0.10` change there changes:
110+
- Impact score by `+0.08`
111+
- Final score by `0.55 * 0.08 = 0.044`
112+
113+
### Alignment examples
114+
115+
- Policy support has internal weight `0.75`, so a `+0.10` change in `policy_support_score` changes:
116+
- Alignment score by `+0.075`
117+
- Final score by `0.22 * 0.075 = 0.0165`
118+
119+
- Sector match is binary with internal weight `0.15`
120+
- no match `0.0` -> match `1.0`
121+
- Alignment score change = `+0.15`
122+
- Final score change = `0.22 * 0.15 = 0.033`
123+
124+
- Timeframe preference has internal weight `0.05`
125+
- far mismatch `0.0` -> exact match `1.0`
126+
- Alignment score change = `+0.05`
127+
- Final score change = `0.22 * 0.05 = 0.011`
128+
129+
- Co-benefit preference also has internal weight `0.05`
130+
- harmful aggregate `0.0` -> beneficial aggregate `1.0`
131+
- Alignment score change = `+0.05`
132+
- Final score change = `0.011`
133+
134+
### Feasibility examples
135+
136+
- Soft legal has internal weight `0.50`
137+
- `0.0` -> `1.0`
138+
- Feasibility score change = `+0.50`
139+
- Final score change = `0.23 * 0.50 = 0.115`
140+
141+
- Socioeconomic fit also has internal weight `0.50`
142+
- `0.5` neutral -> `1.0` very supportive
143+
- Feasibility score change = `+0.25`
144+
- Final score change = `0.23 * 0.25 = 0.0575`
145+
146+
### Quick reading guide
147+
148+
- The biggest single driver in the current setup is usually Impact.
149+
- Inside Alignment, Policy support dominates the other Alignment subcomponents.
150+
- Alignment timeframe matters, but its effect on the final score is small relative to Impact timeline.
151+
- Missing Impact timeline is now neutral at `0.5`, so it no longer penalizes actions just because the timeline is unknown.

hiap-meed/tests/unit/test_prioritizer_blocks.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,30 @@ def test_impact_block_timeline_mapping_prefers_faster_implementation() -> None:
304304
assert result.score_by_action_id["A_fast"] > result.score_by_action_id["A_slow"]
305305

306306

307+
@pytest.mark.unit
308+
def test_impact_block_missing_timeline_is_neutral_not_punitive() -> None:
309+
"""Missing impact timeline uses the neutral 0.5 fallback score."""
310+
result = impact.run(
311+
actions=[
312+
Action(
313+
action_id="A_missing_timeline",
314+
action_name="Missing timeline",
315+
implementation_timeline=None,
316+
emissions={
317+
"gpc_reference_number": ["I.1.1"],
318+
"impact_text": "high",
319+
},
320+
)
321+
],
322+
city_emissions_by_gpc_ref={"I.1.1": 100.0},
323+
)
324+
325+
evidence = result.evidence_by_action_id["A_missing_timeline"]
326+
assert evidence["timeline_bucket_known"] is False
327+
assert evidence["timeline_component_score"] == pytest.approx(0.5)
328+
assert evidence["timeline_score"] == pytest.approx(0.5)
329+
330+
307331
@pytest.mark.unit
308332
def test_alignment_block_with_mock_api_data() -> None:
309333
"""Alignment block computes canonical weighted scores with policy and sectors."""
@@ -381,6 +405,100 @@ def test_alignment_other_preference_component_is_neutral_without_selected_keys()
381405
assert first_action_evidence["co_benefit_component_score"] == pytest.approx(0.5)
382406

383407

408+
@pytest.mark.unit
409+
def test_alignment_selected_co_benefit_evidence_marks_missing_entry_as_neutral() -> None:
410+
"""Missing selected co-benefit entries stay neutral in evidence and say why."""
411+
result = alignment.run(
412+
actions=[
413+
Action(
414+
action_id="A_missing_key",
415+
action_name="Missing key action",
416+
co_benefits={},
417+
)
418+
],
419+
policy_signals_by_action_id={},
420+
city_preference_sectors=[],
421+
city_preference_timeframes=[],
422+
city_preference_co_benefit_keys=["air_quality"],
423+
)
424+
425+
evidence = result.evidence_by_action_id["A_missing_key"]
426+
assert evidence["co_benefit_component_score"] == pytest.approx(0.5)
427+
assert evidence["matched_preferred_co_benefits"] == []
428+
detail = evidence["selected_co_benefit_match_details"][0]
429+
assert detail["co_benefit_key"] == "air_quality"
430+
assert detail["action_has_co_benefit"] is False
431+
assert detail["impact_numeric"] is None
432+
assert detail["normalized_preference_score"] == pytest.approx(0.5)
433+
assert detail["score_source"] == "missing_co_benefit"
434+
assert detail["effect_label"] == "not_provided"
435+
436+
437+
@pytest.mark.unit
438+
def test_alignment_selected_co_benefit_evidence_marks_missing_numeric_as_neutral() -> None:
439+
"""Missing impact_numeric stays neutral in evidence and is distinct from missing key."""
440+
result = alignment.run(
441+
actions=[
442+
Action(
443+
action_id="A_missing_numeric",
444+
action_name="Missing numeric action",
445+
co_benefits={
446+
"air_quality": {
447+
"impact_text": "unknown",
448+
"impact_relationship": "unknown",
449+
}
450+
},
451+
)
452+
],
453+
policy_signals_by_action_id={},
454+
city_preference_sectors=[],
455+
city_preference_timeframes=[],
456+
city_preference_co_benefit_keys=["air_quality"],
457+
)
458+
459+
evidence = result.evidence_by_action_id["A_missing_numeric"]
460+
assert evidence["co_benefit_component_score"] == pytest.approx(0.5)
461+
assert evidence["matched_preferred_co_benefits"] == ["air_quality"]
462+
detail = evidence["selected_co_benefit_match_details"][0]
463+
assert detail["co_benefit_key"] == "air_quality"
464+
assert detail["action_has_co_benefit"] is True
465+
assert detail["impact_numeric"] is None
466+
assert detail["normalized_preference_score"] == pytest.approx(0.5)
467+
assert detail["score_source"] == "missing_impact_numeric"
468+
assert detail["effect_label"] == "unknown"
469+
470+
471+
@pytest.mark.unit
472+
def test_alignment_selected_co_benefit_evidence_marks_real_value_source() -> None:
473+
"""Known co-benefit values keep their normalized score and explicit value source."""
474+
result = alignment.run(
475+
actions=[
476+
Action(
477+
action_id="A_known_numeric",
478+
action_name="Known numeric action",
479+
co_benefits={
480+
"air_quality": {
481+
"impact_numeric": 1,
482+
"impact_text": "beneficial",
483+
"impact_relationship": "positive",
484+
}
485+
},
486+
)
487+
],
488+
policy_signals_by_action_id={},
489+
city_preference_sectors=[],
490+
city_preference_timeframes=[],
491+
city_preference_co_benefit_keys=["air_quality"],
492+
)
493+
494+
detail = result.evidence_by_action_id["A_known_numeric"][
495+
"selected_co_benefit_match_details"
496+
][0]
497+
assert detail["normalized_preference_score"] == pytest.approx(0.75)
498+
assert detail["score_source"] == "derived_from_value"
499+
assert detail["effect_label"] == "beneficial"
500+
501+
384502
@pytest.mark.unit
385503
@pytest.mark.parametrize(
386504
("city_preference_timeframes", "action_timeline", "expected_component_value"),

0 commit comments

Comments
 (0)