#!/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_.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()