File size: 7,255 Bytes
aa1d0aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
#!/usr/bin/env python3
"""
select_indicators.py β€” deterministic indicator selection for Step 2.

Given a Step-1 route (sectors + optional subpillars), returns the fixed set of
catalog indicator ids. No LLM, no judgment β€” same args β†’ byte-identical output.

The LLM MUST use this script instead of choosing indicators from memory. This is
the single change that removes run-to-run variance in indicator sets.

Usage:
    python3 scripts/select_indicators.py \\
        --sectors WASH "Food Security" Shelter \\
        [--subpillars humanitarian_conditions_living_standards humanitarian_conditions_coping_mechanisms] \\
        [-o selected_indicators_<slug>.json]

Sector matching rules:
    - Case-insensitive; normalised to lowercase with underscores internally.
    - Aliases accepted: "food security" β†’ food_security, "wash" β†’ wash,
      "shelter" β†’ shelter (cccm cluster uses sectors: [shelter]).
    - Inclusion criterion: indicator's step1_framework_anchor.sectors intersects --sectors.
    - Subpillars (optional): used ONLY for ordering/grouping, never to drop indicators.

Output JSON:
    {
      "route": {"sectors": [...], "subpillars": [...]},
      "selected": [
        {"id": "...", "label": "...", "cluster": "...", "priority": true/false},
        ...
      ],
      "count_by_sector": {"WASH": 17, "Food Security": 9, "Shelter": 15}
    }

Determinism contract: output ordering is stable β€” sort by (sector order as given in
--sectors, then priority desc, then id asc). Two invocations with identical args
produce byte-identical JSON.

Self-test (run with --test):
    Asserts two invocations with the same args produce identical bytes.
"""
import argparse
import json
import os
import sys

try:
    import yaml
except ImportError:
    sys.exit("select_indicators.py requires pyyaml: pip3 install pyyaml --break-system-packages")

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
INDEX_PATH = os.path.join(SKILL_DIR, "catalog", "index.yaml")

# Canonical sector name β†’ normalised key for matching
SECTOR_ALIASES = {
    "food_security":     "food_security",
    "food security":     "food_security",
    "foodsecurity":      "food_security",
    "wash":              "wash",
    "water, sanitation and hygiene": "wash",
    "shelter":           "shelter",
    "shelter/cccm":      "shelter",
    "cccm":              "shelter",
}

# Canonical display name for each normalised sector key
SECTOR_DISPLAY = {
    "food_security": "Food Security",
    "wash":          "WASH",
    "shelter":       "Shelter",
}


def normalise_sector(s: str) -> str:
    """Return the canonical normalised key or the lower-stripped input."""
    key = s.strip().lower()
    return SECTOR_ALIASES.get(key, key)


def load_index() -> list:
    with open(INDEX_PATH, encoding="utf-8") as f:
        entries = yaml.safe_load(f)
    # Filter out comment-only entries (pyyaml returns None for pure-comment docs)
    if entries is None:
        sys.exit(f"select_indicators.py: {INDEX_PATH} parsed as empty")
    return [e for e in entries if isinstance(e, dict) and "id" in e]


def select(sectors_norm: list, subpillars: list, index: list):
    """
    Return list of matching indicator dicts, ordered by:
      (sector_order_index asc, priority desc, id asc)

    sectors_norm: list of normalised sector keys in the order the analyst gave them.
    subpillars: list of subpillar id strings β€” used for ordering only, never filtering.
    """
    sector_order = {s: i for i, s in enumerate(sectors_norm)}

    matched = []
    for entry in index:
        anchor_sectors = entry.get("step1_framework_anchor", {}).get("sectors", [])
        # normalise anchor sectors for matching
        anchor_norm = [normalise_sector(s) for s in anchor_sectors]
        # check intersection
        if not any(s in sector_order for s in anchor_norm):
            continue
        # pick the first matching sector for ordering
        order_idx = min(
            sector_order[s] for s in anchor_norm if s in sector_order
        )
        matched.append({
            "id": entry["id"],
            "label": entry.get("label", entry["id"]),
            "cluster": entry.get("cluster", ""),
            "priority": bool(entry.get("priority", False)),
            "_order_idx": order_idx,
        })

    # Sort: sector order asc, priority desc (True=1 β†’ negate), id asc
    matched.sort(key=lambda e: (e["_order_idx"], not e["priority"], e["id"]))

    # Strip internal sort key
    selected = [
        {"id": e["id"], "label": e["label"], "cluster": e["cluster"], "priority": e["priority"]}
        for e in matched
    ]
    return selected


def count_by_sector(selected: list, sectors_norm: list) -> dict:
    """Return display-name keyed counts in sector order."""
    counts = {}
    for s in sectors_norm:
        display = SECTOR_DISPLAY.get(s, s)
        counts[display] = sum(
            1 for e in selected
            if normalise_sector(e.get("cluster", "")) == s
            or (s == "shelter" and e.get("cluster") == "cccm")
            or (s == "food_security" and e.get("cluster") == "food_security")
            or (s == "wash" and e.get("cluster") == "wash")
        )
    return counts


def build_result(sectors_raw: list, subpillars: list) -> dict:
    sectors_norm = [normalise_sector(s) for s in sectors_raw]
    index = load_index()
    selected = select(sectors_norm, subpillars, index)
    counts = count_by_sector(selected, sectors_norm)
    return {
        "route": {
            "sectors": sectors_raw,
            "subpillars": subpillars or [],
        },
        "selected": selected,
        "count_by_sector": counts,
    }


def to_json(result: dict) -> str:
    return json.dumps(result, ensure_ascii=False, indent=2) + "\n"


def main():
    ap = argparse.ArgumentParser(
        description="Deterministic indicator selection from Step-1 route."
    )
    ap.add_argument("--sectors", nargs="+", required=True,
                    help="Sectors from Step 1, e.g. WASH 'Food Security' Shelter")
    ap.add_argument("--subpillars", nargs="*", default=[],
                    help="Subpillar ids from Step 1 (ordering only, never filtering)")
    ap.add_argument("-o", "--out", default=None,
                    help="Write JSON to this path (also prints to stdout)")
    ap.add_argument("--test", action="store_true",
                    help="Self-test: assert two runs produce identical bytes, then exit 0")
    a = ap.parse_args()

    if a.test:
        result1 = to_json(build_result(a.sectors, a.subpillars or []))
        result2 = to_json(build_result(a.sectors, a.subpillars or []))
        if result1 != result2:
            sys.exit("FAIL: two invocations produced different output")
        print("PASS: determinism check (two invocations β†’ identical bytes)")
        print(f"  {len(json.loads(result1)['selected'])} indicators selected")
        return

    result = build_result(a.sectors, a.subpillars or [])
    output = to_json(result)

    if a.out:
        with open(a.out, "w", encoding="utf-8") as f:
            f.write(output)
        print(f"wrote {a.out}", file=sys.stderr)

    sys.stdout.write(output)


if __name__ == "__main__":
    main()