1package gen23import (4 "context"5 "encoding/hex"6 "encoding/json"7 "os"8 "testing"910 "mosaic-engine/pack"11 "mosaic-engine/rules"12 "mosaic-engine/sat"13)1415func TestGenDeterministicPerSeed(t *testing.T) {16 p := Params{Seed: "det", X: 2, N: 8}17 a, err := Generate(context.Background(), p)18 if err != nil {19 t.Fatal(err)20 }21 b, err := Generate(context.Background(), p)22 if err != nil {23 t.Fatal(err)24 }25 a.Stats, b.Stats = Stats{}, Stats{}26 ja, _ := json.Marshal(a)27 jb, _ := json.Marshal(b)28 if string(ja) != string(jb) {29 t.Fatal("same seed produced different mosaics")30 }31 c, err := Generate(context.Background(), Params{Seed: "det2", X: 2, N: 8})32 if err != nil {33 t.Fatal(err)34 }35 if c.Solution[0] == a.Solution[0] && c.Solution[1] == a.Solution[1] {36 t.Fatal("different seeds produced the same solution")37 }38}3940// checkAssertions re-verifies every per-tile and whole-mosaic assertion from41// the emitted JSON alone, independently of the generator's own bookkeeping.42func priv0(tiles []TilePrivate, id int) TilePrivate {43 for _, p := range tiles {44 if p.ID == id {45 return p46 }47 }48 return TilePrivate{}49}5051func checkAssertions(t *testing.T, o *Output) {52 t.Helper()53 man := o.Manifest54 G := man.G55 if man.Version != 3 || len(o.Solution) != G {56 t.Fatalf("bad manifest version %d or rows %d", man.Version, len(o.Solution))57 }58 if len(o.Tiles) != len(man.Tiles) {59 t.Fatalf("%d private tiles for %d public", len(o.Tiles), len(man.Tiles))60 }61 board := rules.NewBoard(G, G)62 shapes := make([]rules.Shape, len(man.Tiles))63 masks := make([][]bool, len(man.Tiles))64 owners := make([][]int, G*G)65 for i, tp := range man.Tiles {66 if tp.ID != i {67 t.Fatalf("tile %d out of order", i)68 }69 shapes[i] = rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}70 if err := shapes[i].Valid(); err != nil {71 t.Fatalf("tile %d: %v", i, err)72 }73 if tp.X0 < 0 || tp.Y0 < 0 || tp.X0+tp.W > G || tp.Y0+tp.H > G {74 t.Fatalf("tile %d outside the field", i)75 }76 masks[i] = shapes[i].Mask()77 if c := shapes[i].Cells(); c != tp.Cells {78 t.Fatalf("tile %d: %d cells, manifest says %d", i, c, tp.Cells)79 }80 board.Shapes = append(board.Shapes, shapes[i])81 for li, ok := range masks[i] {82 if ok {83 id := (tp.Y0+li/tp.W)*G + tp.X0 + li%tp.W84 owners[id] = append(owners[id], i)85 }86 }87 }88 for y, row := range o.Solution {89 if len(row) != G {90 t.Fatalf("row %d has %d cells", y, len(row))91 }92 for x, ch := range row {93 id := y*G + x94 switch ch {95 case '.':96 if len(owners[id]) != 0 {97 t.Fatalf("owned cell %d is a hole in the solution", id)98 }99 case '0', '1':100 if len(owners[id]) == 0 {101 t.Fatalf("unowned cell %d has a value", id)102 }103 board.Cells[id] = rules.Cell(ch - '0')104 default:105 t.Fatalf("bad solution char %q", ch)106 }107 }108 }109 if err := board.Validate(rules.All); err != nil {110 t.Fatal(err)111 }112 if !board.Complete() {113 t.Fatal("solution incomplete on some tile")114 }115 // overlap cell sets from the masks; pin-eligible when >= MinLine cells116 overlapCells := func(a, b int) []int {117 var out []int118 sa := shapes[a]119 for li, ok := range masks[a] {120 if !ok {121 continue122 }123 gx, gy := sa.X0+li%sa.W, sa.Y0+li/sa.W124 if shapes[b].Contains(gx, gy) {125 out = append(out, li)126 }127 }128 return out129 }130 sols := make([]rules.TileSolutions, len(man.Tiles))131 seamGiven := map[int][]int{}132 for i, tp := range man.Tiles {133 priv := o.Tiles[i]134 sh := shapes[i]135 if tp.M < 2 || tp.M > pack.MFor(tp.Cells) {136 t.Fatalf("tile %d m=%d for %d cells", i, tp.M, tp.Cells)137 }138 cells := make([]rules.Cell, tp.W*tp.H)139 for j := range cells {140 cells[j] = rules.Empty141 }142 regions := map[int][]int{} // overlap index -> local cells143 onRegion := make([]bool, tp.W*tp.H)144 for oi, ov := range tp.Overlaps {145 oc := overlapCells(i, ov.With)146 if len(oc) != ov.Cells {147 t.Fatalf("tile %d overlap %d with %d: %d shared cells, manifest says %d", i, oi, ov.With, len(oc), ov.Cells)148 }149 if ov.Cells >= pack.MinLine {150 regions[oi] = oc151 for _, li := range oc {152 onRegion[li] = true153 }154 }155 }156 for _, g := range priv.Givens {157 gx, gy := g[0]%G, g[0]/G158 if !sh.Contains(gx, gy) {159 t.Fatalf("tile %d: given %d outside the shape", i, g[0])160 }161 if len(owners[g[0]]) >= 3 {162 // a junction is never planted or clued by its own tile alone;163 // what is allowed is a seam clue two owners share, which a164 // third tile then covers as fixed context (chunked fields165 // and growth do this: the later chunk's assertions were166 // computed with the cell fixed)167 givers := 0168 for _, own := range owners[g[0]] {169 for _, og := range priv0(o.Tiles, own).Givens {170 if og[0] == g[0] {171 givers++172 break173 }174 }175 }176 if givers < 2 {177 t.Fatalf("tile %d: given on cell %d with %d owners but only %d giver", i, g[0], len(owners[g[0]]), givers)178 }179 }180 if len(owners[g[0]]) >= 2 {181 if !man.Rules.SeamClues {182 t.Fatalf("tile %d: shared cell %d is a given but the manifest says no seam clues", i, g[0])183 }184 seamGiven[g[0]] = append(seamGiven[g[0]], i)185 }186 if int(o.Solution[gy][gx]-'0') != g[1] {187 t.Fatalf("tile %d: given %d disagrees with the solution", i, g[0])188 }189 cells[(gy-tp.Y0)*tp.W+gx-tp.X0] = rules.Cell(g[1])190 }191 pol := PolicyFor(tp.Cells)192 // the technique checks key on the tile's free cells (no other193 // owner), recomputed here from the manifest's geometry194 free := 0195 for ly := 0; ly < tp.H; ly++ {196 for lx := 0; lx < tp.W; lx++ {197 if !sh.Contains(tp.X0+lx, tp.Y0+ly) {198 continue199 }200 if len(owners[(tp.Y0+ly)*G+tp.X0+lx]) == 1 {201 free++202 }203 }204 }205 tech := PolicyFor(free)206 pol.NeedsUnique, pol.NeedsBifurcation = tech.NeedsUnique, tech.NeedsBifurcation207 blank := 1 - float64(len(priv.Givens))/float64(tp.Cells)208 if blank < pol.BlankMin {209 t.Fatalf("tile %d (%d cells): %.0f%% blank", i, tp.Cells, blank*100)210 }211 // re-derived from the JSON, not the generator's bookkeeping: the212 // SAT enumerator counts; the propagation solver must agree wherever213 // it can finish within its budget214 sols2, _ := sat.Enumerate(cells, sh, rules.All, pack.MFor(tp.Cells)+1)215 s := rules.TileSolutions(sols2)216 if len(s) < 2 || len(s) > pack.MFor(tp.Cells) || len(s) != tp.M {217 t.Fatalf("tile %d: %d solutions alone (reported %d, m=%d)", i, len(s), tp.M, pack.MFor(tp.Cells))218 }219 if byRules, exhausted := rules.Single(sh, append([]rules.Cell(nil), cells...)).SolutionsBudget(rules.All, pack.MFor(tp.Cells)+1, rules.DefaultBudget); exhausted && len(byRules) != len(s) {220 t.Fatalf("tile %d: propagation solver finds %d solutions, SAT %d", i, len(byRules), len(s))221 }222 truth := sh.Local(board.Cells, G)223 hasTruth := false224 pinned := map[int]bool{}225 for _, sol := range s {226 d := rules.Diff(truth, sol)227 if len(d) == 0 {228 hasTruth = true229 continue230 }231 touched := false232 for _, j := range d {233 if onRegion[j] {234 touched = true235 for oi, oc := range regions {236 for _, li := range oc {237 if li == j {238 pinned[oi] = true239 }240 }241 }242 }243 }244 if !touched {245 t.Fatalf("tile %d: an alternative differs inside no overlap region", i)246 }247 }248 if !hasTruth {249 t.Fatalf("tile %d: the solution is not among the tile's solutions", i)250 }251 if len(tp.Pins) == 0 {252 t.Fatalf("tile %d: no pins", i)253 }254 for _, pi := range tp.Pins {255 if pi < 0 || pi >= len(tp.Overlaps) || tp.Overlaps[pi].Cells < pack.MinLine {256 t.Fatalf("tile %d: pin %d is not a pin-eligible overlap", i, pi)257 }258 if !pinned[pi] {259 t.Fatalf("tile %d: pin %d but no alternative differs there", i, pi)260 }261 }262 if (pol.NeedsUnique && !priv.NeedsUnique) || (pol.NeedsBifurcation && !priv.NeedsBifurcation) {263 t.Fatalf("tile %d (%d cells): needs_unique=%v needs_bifurcation=%v", i, tp.Cells, priv.NeedsUnique, priv.NeedsBifurcation)264 }265 if tp.Hash == "" {266 t.Fatalf("tile %d: no hash", i)267 }268 // the hash is a keyed commitment: recompute it from the JSON with269 // the private key and it must match; without the key it must not270 if man.HashScheme != "mosaic-tile-v1" || o.HashKey == "" || o.DaySeed == "" {271 t.Fatalf("tile %d: hash scheme %q, key present %v, seed present %v", i, man.HashScheme, o.HashKey != "", o.DaySeed != "")272 }273 if i == 0 {274 // the day's commitments verify from the released seed and key275 if DayCommit(o.DaySeed) != man.SeedCommit || man.ID != man.SeedCommit {276 t.Fatalf("seed_commit does not verify from the released day seed")277 }278 if kb, _ := hex.DecodeString(o.HashKey); HashKeyCommit(kb) != man.HashKeyCommit {279 t.Fatalf("hash_key_commit does not verify from the released key")280 }281 if hex.EncodeToString(DayKey(o.DaySeed)) != o.HashKey {282 t.Fatalf("the day key is not the labelled derivation of the day seed")283 }284 }285 {286 key, err := hex.DecodeString(o.HashKey)287 if err != nil || len(key) != 32 {288 t.Fatalf("bad hash key")289 }290 sol := make([]byte, tp.W*tp.H)291 giv := make([]byte, tp.W*tp.H)292 given := map[int]bool{}293 for _, g := range priv.Givens {294 given[g[0]] = true295 }296 for ly := 0; ly < tp.H; ly++ {297 for lx := 0; lx < tp.W; lx++ {298 li := ly*tp.W + lx299 sol[li], giv[li] = '.', '.'300 if !sh.Contains(tp.X0+lx, tp.Y0+ly) {301 continue302 }303 gid := (tp.Y0+ly)*G + tp.X0 + lx304 sol[li] = o.Solution[tp.Y0+ly][tp.X0+lx]305 giv[li] = '0'306 if given[gid] {307 giv[li] = '1'308 }309 }310 }311 if got := TileHash(key, man.ID, tp.ID, tp.X0, tp.Y0, tp.W, tp.H, tp.Rects, sol, giv); got != tp.Hash {312 t.Fatalf("tile %d: hash does not verify under the key", i)313 }314 if TileHash(nil, man.ID, tp.ID, tp.X0, tp.Y0, tp.W, tp.H, tp.Rects, sol, giv) == tp.Hash {315 t.Fatalf("tile %d: hash verifies without the key", i)316 }317 }318 sols[i] = s319 }320 for id, listed := range seamGiven {321 // every owner lists a seam clue; in a field a clue on a cell shared322 // with a tile of an earlier chunk is listed by the later tile only323 if len(listed) != len(owners[id]) && (man.Field == nil || len(listed) > len(owners[id])) {324 t.Fatalf("seam given %d listed by %d tiles, owned by %d", id, len(listed), len(owners[id]))325 }326 }327 count, _ := sat.Coupled(shapes, sols, 2)328 if count != 1 {329 t.Fatalf("mosaic has %d coupled solutions", count)330 }331 if man.SeedTile < 0 || man.SeedTile >= len(man.Tiles) {332 t.Fatalf("seed tile %d outside the mosaic", man.SeedTile)333 }334}335336func TestGenAssertionsSmall(t *testing.T) {337 for _, seed := range []string{"a", "b"} {338 o, err := Generate(context.Background(), Params{Seed: seed, X: 2, N: 8})339 if err != nil {340 t.Fatal(err)341 }342 checkAssertions(t, o)343 }344 o, err := Generate(context.Background(), Params{Seed: "three", X: 3, N: 8})345 if err != nil {346 t.Fatal(err)347 }348 checkAssertions(t, o)349}350351func TestGenScatter(t *testing.T) {352 o, err := Generate(context.Background(), Params{Seed: "scatter", G: 40, Kind: "scatter"})353 if err != nil {354 // a "layout tile ... parallel lines" error means the packer let a355 // shape through that rules.Shape.Valid refuses (pigeonhole bound)356 t.Fatal(err)357 }358 checkAssertions(t, o)359 compound := 0360 for _, tp := range o.Manifest.Tiles {361 if len(tp.Rects) > 1 {362 compound++363 }364 }365 holes := 0366 for _, row := range o.Solution {367 for _, ch := range row {368 if ch == '.' {369 holes++370 }371 }372 }373 t.Logf("scatter G=40: %d tiles (%d compound), coverage %.2f, %d hole cells, %d waves, %d attempts, %dms",374 len(o.Manifest.Tiles), compound, o.Layout.Coverage, holes, o.Stats.Waves, o.Stats.Attempts, o.Stats.Ms)375 if compound == 0 {376 t.Error("no compound (non-rectangular) tile in a scatter field")377 }378}379380func loadFixture(t *testing.T, path string) *Output {381 t.Helper()382 data, err := os.ReadFile(path)383 if err != nil {384 t.Skip("no fixture: " + err.Error())385 }386 var o Output387 if err := json.Unmarshal(data, &o); err != nil {388 t.Fatal(err)389 }390 if o.Manifest.Version != 3 {391 t.Skip("fixture predates manifest v3")392 }393 return &o394}395396// TestGenAssertionsStandard checks the shipped 2x2 n=14 fixture (PLAN item 1).397func TestGenAssertionsStandard(t *testing.T) {398 o := loadFixture(t, "../testdata/mosaic-2-14.json")399 checkAssertions(t, o)400 if len(o.Manifest.Tiles) != 4 || o.Manifest.G != 27 {401 t.Fatalf("fixture is not a 2x2 of 14: %d tiles, G=%d", len(o.Manifest.Tiles), o.Manifest.G)402 }403}404405// TestGenAssertionsEnvelope checks the X=15 fixture when present (PLAN item 6).406func TestGenAssertionsEnvelope(t *testing.T) {407 o := loadFixture(t, "../testdata/mosaic-15-14.json")408 checkAssertions(t, o)409 if len(o.Manifest.Tiles) != 225 || o.Manifest.G != 196 {410 t.Fatalf("fixture is not a 15x15 of 14: %d tiles, G=%d", len(o.Manifest.Tiles), o.Manifest.G)411 }412}413414// TestGenFixtureReproduces regenerates the 2x2 fixture from its seed and415// compares byte for byte (MOSAIC_SLOW=1; ~15 s).416func TestGenFixtureReproduces(t *testing.T) {417 if os.Getenv("MOSAIC_SLOW") == "" {418 t.Skip("MOSAIC_SLOW=1 to regenerate the fixture")419 }420 o := loadFixture(t, "../testdata/mosaic-2-14.json")421 re, err := Generate(context.Background(), Params{Seed: "a", X: 2, N: 14})422 if err != nil {423 t.Fatal(err)424 }425 o.Stats, re.Stats = Stats{}, Stats{}426 ja, _ := json.Marshal(o)427 jb, _ := json.Marshal(re)428 if string(ja) != string(jb) {429 t.Fatal("regenerated fixture differs from the shipped one")430 }431}432433// TestFillOrder checks the most-constrained-first order on a real scatter434// layout: a permutation, seed tile first, every later tile touches an435// earlier one, and few fillers (<= 64 cells) are filled after all of their436// neighbours (the regime where a small shape with most sides fixed has no437// completion and the old id order looped on backjumps for minutes).438func TestFillOrder(t *testing.T) {439 L, err := pack.Pack(pack.Params{Seed: "a|order", G: 60, Kind: "scatter"})440 if err != nil {441 t.Fatal(err)442 }443 m := newMosaic(Params{Seed: "a", Nonce: "order", G: 60, Kind: "scatter"}, L)444 if len(m.order) != len(L.Tiles) {445 t.Fatalf("order has %d entries for %d tiles", len(m.order), len(L.Tiles))446 }447 if m.order[0] != L.SeedTile {448 t.Fatalf("order starts at %d, seed tile is %d", m.order[0], L.SeedTile)449 }450 seen := map[int]bool{}451 late, fillers := 0, 0452 for pos, id := range m.order {453 if seen[id] {454 t.Fatalf("tile %d twice in the order", id)455 }456 seen[id] = true457 g := m.geom[id]458 if g.rank != pos {459 t.Fatalf("tile %d rank %d at position %d", id, g.rank, pos)460 }461 if pos > 0 && len(g.deps) == 0 {462 t.Fatalf("tile %d at position %d shares no cell with an earlier tile", id, pos)463 }464 for _, d := range g.deps {465 if m.geom[d].rank >= g.rank {466 t.Fatalf("tile %d depends on %d, which is later", id, d)467 }468 }469 nbs := L.Neighbours(id)470 last := len(nbs) >= 3471 for _, nb := range nbs {472 if m.geom[nb].rank > g.rank {473 last = false474 }475 }476 if g.cells <= 64 {477 fillers++478 if last {479 late++480 }481 }482 }483 t.Logf("%d of %d fillers are filled after three or more neighbours", late, fillers)484 if late*5 > fillers {485 t.Errorf("%d of %d fillers are filled after all of their >= 3 neighbours; want under 20%%", late, fillers)486 }487}488489// TestField2x2 generates a 2x2 field of G=40 scatter chunks in process,490// re-verifies every assertion from the merged JSON (including one coupled491// solution over the whole field, and per colour: the first colour's chunks492// unique alone, later ones unique given the earlier), and checks the493// result does not depend on how many chunks ran at once.494func TestField2x2(t *testing.T) {495 p := FieldParams{Seed: "field", Chunks: 2, ChunkG: 40, Band: 16, Kind: "scatter", Jobs: 1}496 a, err := GenerateField(context.Background(), p, InProcess)497 if err != nil {498 t.Fatal(err)499 }500 checkAssertions(t, a)501 if a.Manifest.Field == nil || a.Manifest.Field.Chunks != 2 || len(a.Manifest.Field.SeedTiles) != 4 {502 t.Fatalf("field info %+v", a.Manifest.Field)503 }504 if a.Manifest.G != 2*40-16 {505 t.Fatalf("G=%d", a.Manifest.G)506 }507 // tiles straddle chunk borders: some overlap crosses a region border508 plan, _ := PlanField(p)509 chunkOf := func(tp TilePublic) int {510 for i, r := range plan.Regions {511 if tp.X0 >= r[0] && tp.Y0 >= r[1] && tp.X0+tp.W <= r[0]+r[2] && tp.Y0+tp.H <= r[1]+r[3] {512 return i513 }514 }515 return -1516 }517 cross := 0518 for _, tp := range a.Manifest.Tiles {519 for _, ov := range tp.Overlaps {520 if chunkOf(tp) != chunkOf(a.Manifest.Tiles[ov.With]) {521 cross++522 }523 }524 }525 if cross == 0 {526 t.Fatal("no overlap crosses a chunk border")527 }528 t.Logf("field 2x2 G=40: %d tiles, %d cross-chunk overlap entries, coverage %.2f, %d ms, chunk attempts %v", len(a.Manifest.Tiles), cross, a.Layout.Coverage, a.Stats.Ms, func() []int {529 var v []int530 for _, s := range a.ChunkStats {531 v = append(v, s.Attempts)532 }533 return v534 }())535 p.Jobs = 4536 b, err := GenerateField(context.Background(), p, InProcess)537 if err != nil {538 t.Fatal(err)539 }540 a.Stats, b.Stats = Stats{}, Stats{}541 a.ChunkStats, b.ChunkStats = nil, nil542 ja, _ := json.Marshal(a)543 jb, _ := json.Marshal(b)544 if string(ja) != string(jb) {545 t.Fatal("field differs with jobs=4")546 }547}548549// TestReleaseClues checks the repair's first step on a real generation: a550// seam clue one tile put on a cell it shares with another is taken back551// from the shared record and from its owner when the other tile is552// repaired, and the owner is reported dirty.553func TestReleaseClues(t *testing.T) {554 checked := false555 attemptHook = func(m *mosaic, tiles []*tileWork) {556 for id, owner := range m.seamOwner {557 var bad = -1558 for _, o := range m.owners[id] {559 if o != owner && !m.geom[o].fixed {560 bad = o561 break562 }563 }564 if bad < 0 {565 continue566 }567 before := len(tiles[owner].ownSeam)568 dirty := m.releaseClues(tiles, bad)569 if _, still := m.seamGivens[id]; still {570 t.Errorf("clue %d still recorded after release", id)571 }572 if _, still := m.seamOwner[id]; still {573 t.Errorf("clue %d still owned after release", id)574 }575 if len(tiles[owner].ownSeam) >= before {576 t.Errorf("owner %d still lists the clue", owner)577 }578 for _, v := range tiles[owner].ownSeam {579 if v == id {580 t.Errorf("owner %d still lists clue %d", owner, id)581 }582 }583 found := false584 for _, d := range dirty {585 if d == owner {586 found = true587 }588 }589 if !found {590 t.Errorf("owner %d not reported dirty: %v", owner, dirty)591 }592 checked = true593 break594 }595 }596 defer func() { attemptHook = nil }()597 if _, err := Generate(context.Background(), Params{Seed: "release", G: 48, Kind: "scatter"}); err != nil {598 t.Fatal(err)599 }600 if !checked {601 t.Skip("no seam clue on a cell shared with another tile in this seed")602 }603}604605// TestRepairAtG200 is the recorded failure of the restart-based repair:606// seed a at G=200 scatter needed two attempts before the in-place repair607// (the first died on "an alternative solution differs inside no overlap608// region" after a regiven). With release it must finish in one.609func TestRepairAtG200(t *testing.T) {610 if os.Getenv("MOSAIC_SLOW") == "" {611 t.Skip("MOSAIC_SLOW=1 runs the G=200 repair check (~10 min)")612 }613 o, err := Generate(context.Background(), Params{Seed: "a", G: 200, Kind: "scatter"})614 if err != nil {615 t.Fatal(err)616 }617 t.Logf("G=200: %d tiles, attempts %d, regivens %d, releases %d, %d ms", len(o.Manifest.Tiles), o.Stats.Attempts, o.Stats.Regivens, o.Stats.Releases, o.Stats.Ms)618 if o.Stats.Attempts != 1 {619 t.Fatalf("took %d attempts", o.Stats.Attempts)620 }621}622623// TestHashIsACommitment: an agent that enumerates a revealed tile's m624// solutions from its givens and hashes each cannot tell the truth by625// matching the manifest's hash (the attack an agent reported); with the626// archived key exactly one candidate matches.627func TestHashIsACommitment(t *testing.T) {628 o := loadFixture(t, "testdata/mosaic-2-14.json")629 key, _ := hex.DecodeString(o.HashKey)630 G := o.Manifest.G631 for i, tp := range o.Manifest.Tiles {632 sh := rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}633 puzzle := make([]rules.Cell, tp.W*tp.H)634 giv := make([]byte, tp.W*tp.H)635 for li := range puzzle {636 puzzle[li] = rules.Empty637 giv[li] = '.'638 lx, ly := li%tp.W, li/tp.W639 if sh.Contains(tp.X0+lx, tp.Y0+ly) {640 giv[li] = '0'641 }642 }643 for _, g := range o.Tiles[i].Givens {644 gx, gy := g[0]%G, g[0]/G645 li := (gy-tp.Y0)*tp.W + gx - tp.X0646 puzzle[li] = rules.Cell(g[1])647 giv[li] = '1'648 }649 cands, _ := sat.Enumerate(puzzle, sh, rules.All, tp.M+1)650 if len(cands) < 2 {651 t.Fatalf("tile %d: %d candidates", i, len(cands))652 }653 plain, keyed := 0, 0654 for _, c := range cands {655 sol := make([]byte, tp.W*tp.H)656 for li := range sol {657 sol[li] = '.'658 if giv[li] != '.' {659 sol[li] = byte('0' + c[li])660 }661 }662 if TileHash(nil, o.Manifest.ID, tp.ID, tp.X0, tp.Y0, tp.W, tp.H, tp.Rects, sol, giv) == tp.Hash {663 plain++664 }665 if TileHash(key, o.Manifest.ID, tp.ID, tp.X0, tp.Y0, tp.W, tp.H, tp.Rects, sol, giv) == tp.Hash {666 keyed++667 }668 }669 if plain != 0 || keyed != 1 {670 t.Fatalf("tile %d: %d candidates match unkeyed, %d keyed (want 0 and 1)", i, plain, keyed)671 }672 }673}