# The Invariant: how it is built

Peg solitaire as a lock: a colouring the jumps cannot change tells you which end holes a lone peg can ever reach.

## What it imports

```html
<script src="../../../sitoolz/js/blake2b.js"></script><script src="../../../sitoolz/js/rangeblends.js"></script><script src="../../../sitoolz/js/rulerland.js"></script>
<script src="../../../sitoolz/js/qrand.js"></script><script src="../../../sitoolz/js/indexrng.js"></script>   <!-- the levels are dealt by the indexed generator -->
<script src="../../../sitoolz/js/zones.js"></script>     <!-- SIZones.gfkInvariant: a weighted sum mod k over a board -->
```

## The twenty lines that make it

```js
const HOLE = (r, c) => (r >= 2 && r <= 4) || (c >= 2 && c <= 4);          // the English board, 33 holes
const CA = (r, c) => (r + 2*c) % 3, CB = (r, c) => (2*r + c) % 3;         // two three-colourings: the colours cycle along every line
const W = [(r, c) => CA(r, c) < 2 ? 1 : 0, (r, c) => CA(r, c) > 0 ? 1 : 0, (r, c) => CB(r, c) < 2 ? 1 : 0, (r, c) => CB(r, c) > 0 ? 1 : 0];
const INV = board => W.map(w => SIZones.gfkInvariant(board, 2, w));       // four parity bits: a jump changes all three colour counts at once
function jump(b, r, c, r2, c2){ const n = b.map(x => x.slice()); n[r][c] = 0; n[(r + r2)/2][(c + c2)/2] = 0; n[r2][c2] = 1; return n; }
function lone(r, c){ const b = empty(); b[r][c] = 1; return INV(b); }
const allowed = (board) => holes.filter(([r, c]) => INV(board).join() === lone(r, c).join());   // where a lone peg can ever end
function make(k){ /* a level: reverse jumps from the lone target peg, dealt by SIIndexrng, so a path exists */
  const g = new SIIndexrng.Index("invariant-" + k); let b = empty(); b[END[k][0]][END[k][1]] = 1, i = 0;
  while (pegs(b) < PEGS[k]){ const opts = reverseJumps(b); const [r, c, r1, c1, r2, c2] = opts[g.roll("reverse", i++, opts.length)]; b[r][c] = 0; b[r1][c1] = 1; b[r2][c2] = 1; }
  return b; }
```

The hint is a depth-first search to the target that prunes any board whose invariant disagrees with the target's, which is why it is fast on these boards.

## How to change the law

- Any weight is an invariant candidate: `gfkInvariant(board, k, weight)` for k in {2, 3, 5, 7}. A weight is preserved by the jumps exactly when it sums to 0 mod k over every three holes in a line; the parity weights above do, the raw (r + 2c) mod 3 sum does not (the card's example is the colouring, not the sum).
- Other boards: change `HOLE`; the French board (37 holes) or a diamond change which holes the invariant allows.
- Harder levels: more reverse jumps; the search stays quick below about fifteen pegs.

## How to verify it

`fixtures/zones.json` (from `tests/write_zones_fixture.py`) holds GF(k) systems and invariants checked by `tests/twins-zones.test.cjs`; the smoke here solves level 1 by hints with the four bits unmoved after every jump, and checks the verdict refuses 22 of the 33 holes on level 2.

## What was missing

- `sitoolz.zones.gfk_invariant`'s docstring (and `cards/zones.md`) say the weighted sum with weight (r + 2c) mod 3 "is what the jumps preserve". It is not: for three holes in a line with weights x, x+1, x+2 (or x, x+2, x+1), a jump changes the sum by 1 − x or 2x + 2 mod 3, which depends on x. What the jumps preserve is the parity of the colour counts, pairwise, which this page computes with the same function at k = 2. The library function is right; its example should be corrected.

![](shot.png)
