yannsay's picture
Deploy clean to build-small-hackathon org (32B Qwen, step-3 table)
aa1d0aa verified
Raw
History Blame Contribute Delete
7.26 kB
#!/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()