gen: most-constrained-first fill order, joint completion via SAT, pins and seam clues per overlap region, regiven repair, coupled uniqueness, per-tile assertions re-verified from JSON in tests; manifest v3 (shapes, overlaps, holes, no zones). judge: shapes input, forced/guess by propagation. main: gen | judge | pack | version. Fixtures: the 2x2 of 14x14 standard and the X=15 envelope.
- commit
- 4d39f71fc595a33887036143c48a6823ccfc6c3b
- parent
- 9b64e24301e84116b771f74ef5844a1a7e1fdf3d
- author
- dami <gazey55@gmail.com>
| A | engine/ | 1358 | ++++++++++++++++++++++++++++++ |
| A | engine/ | 405 | +++++++++ |
| A | engine/ | 108 | ++ |
| A | engine/ | 185 | ++++ |
| A | engine/ | 118 | +++ |
| A | engine/ | 1 | + |
| A | engine/ | 1 | + |
@@ -0,0 +1,1358 @@
1+// Package gen builds a mosaic over a layout: Takuzu tiles of any valid2+// shape (unions of rectangles whose lines are even and at least 4 long)3+// scattered on a field, overlapping by arbitrary regions. Every tile alone4+// has 2..m solutions and every alternative differs from the truth inside5+// some overlap region; the mosaic as a whole has exactly one solution.6+// Deterministic per seed. The phase-1 X-by-X grid is the packer kind7+// "grid".8+package gen9+10+import (11+ "context"12+ "crypto/sha256"13+ "encoding/binary"14+ "encoding/hex"15+ "errors"16+ "fmt"17+ "math/rand/v2"18+ "os"19+ "runtime"20+ "sort"21+ "sync"22+ "time"23+24+ "mosaic-engine/pack"25+ "mosaic-engine/rules"26+ "mosaic-engine/sat"27+)28+29+// Policy is what the generator asserts for a tile of a given cell count.30+// Small tiles are fillers around the hard ones: they still have 2..m31+// solutions that differ inside an overlap, but the technique checks and32+// the blank floor scale up with size, and only tiles of 144 cells and33+// more (a 12x12) are held to the canonical hard standard.34+type Policy struct {35+ M int36+ BlankMin float6437+ BlankMax float6438+ NeedsUnique bool39+ NeedsBifurcation bool40+}41+42+func PolicyFor(cells int) Policy {43+ switch {44+ case cells <= 64:45+ return Policy{M: pack.MFor(cells), BlankMin: 0.55, BlankMax: 0.70}46+ case cells <= 100:47+ return Policy{M: pack.MFor(cells), BlankMin: 0.65, BlankMax: 0.75}48+ case cells < 144:49+ return Policy{M: pack.MFor(cells), BlankMin: 0.70, BlankMax: 0.75}50+ case cells <= 196:51+ return Policy{M: pack.MFor(cells), BlankMin: 0.70, BlankMax: 0.75, NeedsUnique: true, NeedsBifurcation: true}52+ default:53+ // counting past ~73% blank at 256 cells is where single counts take seconds54+ return Policy{M: pack.MFor(cells), BlankMin: 0.70, BlankMax: 0.73, NeedsUnique: true, NeedsBifurcation: true}55+ }56+}57+58+// Verbose logs failed attempts to stderr.59+var Verbose = os.Getenv("MOSAIC_DEBUG") != ""60+61+// Version changes whenever the generator's output for a seed could change.62+const Version = "gen-v5" // v5: manifest v3, shapes and overlap regions63+64+type Params struct {65+ Seed string `json:"seed"`66+ Nonce string `json:"nonce,omitempty"`67+ G int `json:"G"`68+ Kind string `json:"kind"` // scatter | grid | big-first | bands69+ N int `json:"n,omitempty"`70+ // legacy grid shorthand: X tiles per side of size N71+ X int `json:"X,omitempty"`72+ BigArea float64 `json:"big_area,omitempty"`73+ Coverage float64 `json:"coverage,omitempty"`74+ Compound float64 `json:"compound,omitempty"`75+ BandUnique bool `json:"band_unique"`76+ MaxAttempts int `json:"max_attempts,omitempty"`77+}78+79+type Rules struct {80+ Overlaps bool `json:"overlaps"`81+ CrossTile bool `json:"cross_tile"`82+ SeamClues bool `json:"seam_clues"`83+ BandUnique bool `json:"band_unique"`84+}85+86+type PackerInfo struct {87+ Kind string `json:"kind"`88+ Params map[string]any `json:"params"`89+}90+91+// OverlapPublic is what the manifest says about a shared region: the92+// partner and how many cells; the cell set is the intersection of the two93+// shapes.94+type OverlapPublic struct {95+ With int `json:"with"`96+ Cells int `json:"cells"`97+}98+99+// TilePublic is what the manifest carries about a tile.100+type TilePublic struct {101+ ID int `json:"id"`102+ X0 int `json:"x0"`103+ Y0 int `json:"y0"`104+ W int `json:"w"`105+ H int `json:"h"`106+ Rects [][4]int `json:"rects"`107+ Cells int `json:"cells"`108+ Hash string `json:"hash"`109+ M int `json:"m"`110+ Overlaps []OverlapPublic `json:"overlaps"`111+ Pins []int `json:"pins"` // indexes into Overlaps some alternative differs on112+}113+114+type Manifest struct {115+ Version int `json:"version"`116+ ID string `json:"id"`117+ SeedCommit string `json:"seed_commit"`118+ G int `json:"G"`119+ Packer PackerInfo `json:"packer"`120+ MByN map[string]int `json:"m_by_n"`121+ Rules Rules `json:"rules"`122+ SeedTile int `json:"seed_tile"`123+ Tiles []TilePublic `json:"tiles"`124+ K string `json:"k"`125+ Generator string `json:"generator"`126+ Library *string `json:"library"`127+}128+129+// TilePrivate is the generator's per-tile record: never in the public log.130+type TilePrivate struct {131+ ID int `json:"id"`132+ Givens [][2]int `json:"givens"` // [global cell id, value]133+ Blank float64 `json:"blank"`134+ NeedsUnique bool `json:"needs_unique"`135+ NeedsBifurcation bool `json:"needs_bifurcation"`136+ Flip []int `json:"flip"` // global ids of the planted ambiguity137+ Ladder Ladder `json:"ladder"`138+}139+140+// Ladder is what the technique-ladder solver used on the pinned tile141+// (analyzer data; not asserted).142+type Ladder struct {143+ UsedUnique bool `json:"used_unique"`144+ UsedBifurcation bool `json:"used_bifurcation"`145+ Bifurcations int `json:"bifurcations"`146+ Complete bool `json:"complete"`147+}148+149+type Stats struct {150+ Ms int64 `json:"ms"`151+ Attempts int `json:"attempts"`152+ Refills int `json:"refills"`153+ Backjumps int `json:"backjumps"`154+ Regivens int `json:"regivens"`155+ Waves int `json:"waves"`156+}157+158+type Output struct {159+ Manifest Manifest `json:"manifest"`160+ Solution []string `json:"solution"` // G rows; '.' where no tile covers the cell161+ Tiles []TilePrivate `json:"tiles"`162+ Stats Stats `json:"stats"`163+ Layout pack.Report `json:"layout"`164+}165+166+func defaults(p Params) (Params, error) {167+ if p.X > 0 {168+ if p.N == 0 {169+ p.N = 14170+ }171+ p.Kind = "grid"172+ p.G = p.X*(p.N-1) + 1173+ }174+ if p.Kind == "" {175+ p.Kind = "scatter"176+ }177+ if p.G < pack.MinLine {178+ return p, fmt.Errorf("G must be at least %d", pack.MinLine)179+ }180+ if p.MaxAttempts == 0 {181+ p.MaxAttempts = 20182+ }183+ if p.Nonce == "" {184+ h := sha256.Sum256([]byte("mosaic-nonce|" + p.Seed))185+ p.Nonce = hex.EncodeToString(h[:8])186+ }187+ return p, nil188+}189+190+func rngFor(parts ...string) *rand.Rand {191+ h := sha256.New()192+ for _, s := range parts {193+ h.Write([]byte(s))194+ h.Write([]byte{0})195+ }196+ sum := h.Sum(nil)197+ return rand.New(rand.NewPCG(binary.LittleEndian.Uint64(sum[:8]), binary.LittleEndian.Uint64(sum[8:16])))198+}199+200+// Generate runs the full pipeline. The layout is drawn once from the seed;201+// attempts redraw the tile contents with a derived rng when an attempt202+// cannot satisfy every assertion.203+func Generate(ctx context.Context, p Params) (*Output, error) {204+ p, err := defaults(p)205+ if err != nil {206+ return nil, err207+ }208+ L, err := pack.Pack(pack.Params{Seed: p.Seed + "|" + p.Nonce, G: p.G, Kind: p.Kind, N: p.N, BigArea: p.BigArea, Coverage: p.Coverage, Compound: p.Compound})209+ if err != nil {210+ return nil, err211+ }212+ // every tile must be able to carry the rules on its own before any213+ // search starts (an infeasible shape costs SAT seconds to refute)214+ for _, t := range L.Tiles {215+ sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}216+ if err := sh.Valid(); err != nil {217+ return nil, fmt.Errorf("layout tile %d (%dx%d at %d,%d): %w", t.ID, t.W, t.H, t.X0, t.Y0, err)218+ }219+ }220+ start := time.Now()221+ var last error222+ for attempt := 0; attempt < p.MaxAttempts; attempt++ {223+ out, err := generateAttempt(ctx, p, L, attempt)224+ if err == nil {225+ out.Stats.Attempts = attempt + 1226+ out.Stats.Ms = time.Since(start).Milliseconds()227+ out.Layout = L.Report228+ return out, nil229+ }230+ if ctx.Err() != nil {231+ return nil, ctx.Err()232+ }233+ last = err234+ if Verbose {235+ fmt.Fprintf(os.Stderr, "gen: attempt %d failed after %s: %v\n", attempt, time.Since(start).Round(time.Millisecond), err)236+ }237+ }238+ return nil, fmt.Errorf("no mosaic after %d attempts: %w", p.MaxAttempts, last)239+}240+241+// tileGeom is everything fixed about a tile before any content exists.242+// Local indexes are ly*W+lx over the bounding box; cells outside the243+// shape (mask false) are never read or written.244+type tileGeom struct {245+ id int246+ sh rules.Shape247+ W, H int248+ cells int249+ m int250+ mask []bool251+ overlaps []pack.Overlap252+ shared []bool // local index -> owned by another tile too253+ regsAt [][]int // local index -> indexes of pin-eligible overlaps (>= MinLine cells)254+ junction []bool // local index -> shared cell that is not on any pin-eligible overlap, or has 3+ owners255+ dist int // BFS distance to the seed tile over overlaps256+ rank int // position in the fill order (see fillOrder)257+ deps []int // tiles earlier in the fill order sharing any cell, by id258+}259+260+func (g *tileGeom) gid(m *mosaic, local int) int {261+ return m.global.Idx(g.sh.X0+local%g.W, g.sh.Y0+local/g.W)262+}263+264+type mosaic struct {265+ p Params266+ L *pack.Layout267+ G int268+ global *rules.Board269+ geom []*tileGeom270+ order []int // fill order: position -> tile id271+ owners [][]int // per cell, tile ids owning it272+ stats Stats273+274+ bandUniqueHeld bool275+276+ seamGivens map[int]rules.Cell // shared cells given to hold m; shared by all owners277+ used map[int]bool // cells of planted rectangles: never givens278+ mu sync.Mutex279+}280+281+func newMosaic(p Params, L *pack.Layout) *mosaic {282+ m := &mosaic{p: p, L: L, G: L.G, bandUniqueHeld: p.BandUnique,283+ seamGivens: map[int]rules.Cell{}, used: map[int]bool{}}284+ m.global = rules.NewBoard(m.G, m.G)285+ m.owners = L.Owners()286+ for _, t := range L.Tiles {287+ m.global.Shapes = append(m.global.Shapes, rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects})288+ }289+ // BFS distance from the seed tile over overlaps290+ dist := make([]int, len(L.Tiles))291+ for i := range dist {292+ dist[i] = -1293+ }294+ dist[L.SeedTile] = 0295+ queue := []int{L.SeedTile}296+ for len(queue) > 0 {297+ t := queue[0]298+ queue = queue[1:]299+ for _, u := range L.Neighbours(t) {300+ if dist[u] < 0 {301+ dist[u] = dist[t] + 1302+ queue = append(queue, u)303+ }304+ }305+ }306+ for _, t := range L.Tiles {307+ g := &tileGeom{id: t.ID, sh: m.global.Shapes[t.ID], W: t.W, H: t.H, cells: t.Cells, m: t.M, overlaps: L.Overlaps[t.ID], dist: dist[t.ID]}308+ g.mask = g.sh.Mask()309+ g.shared = make([]bool, t.W*t.H)310+ g.regsAt = make([][]int, t.W*t.H)311+ g.junction = make([]bool, t.W*t.H)312+ for oi, o := range g.overlaps {313+ if o.Cells < pack.MinLine {314+ continue315+ }316+ for _, id := range o.Ids {317+ gx, gy := id%m.G, id/m.G318+ li := (gy-t.Y0)*t.W + gx - t.X0319+ g.regsAt[li] = append(g.regsAt[li], oi)320+ }321+ }322+ for li, ok := range g.mask {323+ if !ok {324+ continue325+ }326+ own := m.owners[g.gid(m, li)]327+ if len(own) >= 2 {328+ g.shared[li] = true329+ }330+ // shared with three or more tiles, or shared only through331+ // regions too small to pin: a junction, never planted, never332+ // given333+ if len(own) >= 3 || (len(own) >= 2 && len(g.regsAt[li]) == 0) {334+ g.junction[li] = true335+ }336+ }337+ m.geom = append(m.geom, g)338+ }339+ m.order = m.fillOrder()340+ for pos, id := range m.order {341+ m.geom[id].rank = pos342+ }343+ for _, g := range m.geom {344+ depSet := map[int]bool{}345+ for li, ok := range g.mask {346+ if !ok {347+ continue348+ }349+ for _, o := range m.owners[g.gid(m, li)] {350+ if m.geom[o].rank < g.rank {351+ depSet[o] = true352+ }353+ }354+ }355+ for d := range depSet {356+ g.deps = append(g.deps, d)357+ }358+ sort.Ints(g.deps)359+ }360+ return m361+}362+363+// fillOrder decides the order tiles get their solution in: most constrained364+// first. Starting from the seed tile, the next tile is always the unplaced365+// one with the largest share of its cells already fixed by placed366+// neighbours (ties: fewer cells, then lower id). A small tile is therefore367+// filled as soon as one neighbour exists, while it still has few fixed368+// cells and many completions, and its remaining cells become constraints369+// on the large tiles around it, which have room to spare.370+func (m *mosaic) fillOrder() []int {371+ total := len(m.geom)372+ placed := make([]bool, total)373+ fixed := make([]int, total)374+ order := make([]int, 0, total)375+ place := func(id int) {376+ placed[id] = true377+ order = append(order, id)378+ g := m.geom[id]379+ for li, ok := range g.mask {380+ if !ok {381+ continue382+ }383+ for _, o := range m.owners[g.gid(m, li)] {384+ if !placed[o] {385+ fixed[o]++386+ }387+ }388+ }389+ }390+ place(m.L.SeedTile)391+ for len(order) < total {392+ best := -1393+ var bestShare float64394+ for _, g := range m.geom {395+ if placed[g.id] {396+ continue397+ }398+ share := float64(fixed[g.id]) / float64(g.cells)399+ if best < 0 || share > bestShare ||400+ (share == bestShare && (g.cells < m.geom[best].cells || (g.cells == m.geom[best].cells && g.id < best))) {401+ best, bestShare = g.id, share402+ }403+ }404+ place(best)405+ }406+ return order407+}408+409+func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int) (*Output, error) {410+ m := newMosaic(p, L)411+ tag := fmt.Sprintf("%s|%s|%d", p.Seed, p.Nonce, attempt)412+413+ // Phase A: the solution, tile by tile in most-constrained-first order414+ // (fillOrder) with backjumping (fillSolution).415+ total := len(L.Tiles)416+ if err := m.fillSolution(ctx, tag); err != nil {417+ return nil, err418+ }419+ // Phase B: givens, in waves over the fixed solution. A tile is ready when420+ // every earlier tile (fill order) it shares a cell with is done (seam421+ // givens flow from earlier tiles); tiles in one wave share no cell, so422+ // they run in parallel and the output equals sequential fill order.423+ tiles := make([]*tileWork, total)424+ done := make([]bool, total)425+ remaining := total426+ for remaining > 0 {427+ var idx []int428+ for _, g := range m.geom {429+ if done[g.id] {430+ continue431+ }432+ ready := true433+ for _, d := range g.deps {434+ if !done[d] {435+ ready = false436+ break437+ }438+ }439+ if ready {440+ idx = append(idx, g.id)441+ }442+ }443+ if len(idx) == 0 {444+ return nil, errors.New("dependency cycle in tile order (bug)")445+ }446+ m.stats.Waves++447+ results := make([]*tileWork, len(idx))448+ errs := make([]error, len(idx))449+ var wg sync.WaitGroup450+ sem := make(chan struct{}, runtime.GOMAXPROCS(0))451+ for k, i := range idx {452+ wg.Add(1)453+ sem <- struct{}{}454+ go func(k, i int) {455+ defer wg.Done()456+ defer func() { <-sem }()457+ results[k], errs[k] = m.buildGivens(ctx, tag, i)458+ }(k, i)459+ }460+ wg.Wait()461+ for k, i := range idx {462+ if errs[k] != nil {463+ g := m.geom[i]464+ return nil, fmt.Errorf("tile %d (%dx%d, %d cells at %d,%d): %w", i, g.W, g.H, g.cells, g.sh.X0, g.sh.Y0, errs[k])465+ }466+ tiles[i] = results[k]467+ m.commitGivens(results[k])468+ done[i] = true469+ remaining--470+ }471+ }472+ if err := m.global.Validate(rules.All); err != nil {473+ return nil, fmt.Errorf("solution invalid: %w", err)474+ }475+476+ // Later tiles may have added seam givens on earlier tiles' shared cells:477+ // settle every tile's final givens and solution set, then check the478+ // coupled system has exactly one solution. Repair by regenerating the479+ // givens of a tile that lacks a witness neighbour, or that its480+ // neighbours' clues pinned past its policy.481+ shapes := make([]rules.Shape, total)482+ for i, g := range m.geom {483+ shapes[i] = g.sh484+ }485+ regiven := func(bad, round int) error {486+ for _, id := range tiles[bad].protect {487+ delete(m.used, id)488+ }489+ tw, err := m.makeGivens(ctx, tag, bad, 100+round)490+ if err != nil {491+ return fmt.Errorf("repair tile %d: %w", bad, err)492+ }493+ tiles[bad] = tw494+ m.commitGivens(tw)495+ m.stats.Regivens++496+ return nil497+ }498+ for round := 0; ; round++ {499+ sols := make([]rules.TileSolutions, total)500+ settled := true501+ for i, t := range tiles {502+ if err := m.settle(t); err != nil {503+ if round >= 3*total {504+ return nil, err505+ }506+ if Verbose {507+ fmt.Fprintf(os.Stderr, "gen: settle: %v; regiven\n", err)508+ }509+ if err := regiven(i, round); err != nil {510+ return nil, err511+ }512+ settled = false513+ break514+ }515+ sols[i] = t.sols516+ }517+ if !settled {518+ continue519+ }520+ count, _ := rules.CoupledSolutions(shapes, sols, 2)521+ if count == 1 {522+ break523+ }524+ if round >= 3*total {525+ return nil, fmt.Errorf("coupled system has %d solutions after %d repairs", count, round)526+ }527+ bad := m.tileWithoutWitness(tiles)528+ if bad < 0 {529+ return nil, fmt.Errorf("coupled system has %d solutions but every tile has a witness (bug)", count)530+ }531+ if err := regiven(bad, round); err != nil {532+ return nil, err533+ }534+ }535+536+ commit := sha256.Sum256([]byte(p.Seed + "|" + p.Nonce))537+ mByN := map[string]int{}538+ for n := pack.MinN; n <= pack.MaxN; n += 2 {539+ mByN[fmt.Sprint(n)] = pack.MByN(n)540+ }541+ out := &Output{542+ Manifest: Manifest{543+ Version: 3,544+ ID: hex.EncodeToString(commit[:]),545+ SeedCommit: hex.EncodeToString(commit[:]),546+ G: m.G,547+ Packer: PackerInfo{Kind: L.Kind, Params: map[string]any{"n": p.N, "big_area": p.BigArea, "coverage": p.Coverage, "compound": p.Compound}},548+ MByN: mByN,549+ Rules: Rules{Overlaps: true, CrossTile: false, SeamClues: len(m.seamGivens) > 0, BandUnique: m.bandUniqueHeld},550+ SeedTile: L.SeedTile,551+ K: "unmeasured",552+ Generator: generatorHash(),553+ },554+ Stats: m.stats,555+ }556+ for y := 0; y < m.G; y++ {557+ row := make([]byte, m.G)558+ for x := 0; x < m.G; x++ {559+ if len(m.owners[m.global.Idx(x, y)]) == 0 {560+ row[x] = '.'561+ } else {562+ row[x] = byte('0' + m.global.At(x, y))563+ }564+ }565+ out.Solution = append(out.Solution, string(row))566+ }567+ for _, t := range tiles {568+ out.Manifest.Tiles = append(out.Manifest.Tiles, t.public)569+ out.Tiles = append(out.Tiles, t.private)570+ }571+ return out, nil572+}573+574+func generatorHash() string {575+ h := sha256.Sum256([]byte("mosaic-engine/" + Version))576+ return hex.EncodeToString(h[:8])577+}578+579+// fillSolution walks the fill order with backjumping. A tile whose prefilled580+// cells admit no completion sends the search back to the latest earlier581+// tile that shares a cell with it; everything from there on is cleared and582+// redrawn with the next try. Repeated failures of the same tile escalate:583+// every second failure targets the next earlier dependency, so a conflict584+// caused by a neighbour other than the latest one is reached rather than585+// looping on a refill that cannot help.586+func (m *mosaic) fillSolution(ctx context.Context, tag string) error {587+ total := len(m.geom)588+ tries := make([]int, total) // by tile id589+ fails := make([]int, total) // consecutive NOFITs by tile id590+ backjumps := 0591+ for pos := 0; pos < total; {592+ if ctx.Err() != nil {593+ return ctx.Err()594+ }595+ i := m.order[pos]596+ ok, err := m.fillTile(ctx, tag, i, tries[i])597+ if err != nil {598+ return err599+ }600+ tries[i]++601+ if ok {602+ fails[i] = 0603+ pos++604+ continue605+ }606+ backjumps++607+ m.bump(&m.stats.Backjumps)608+ if backjumps > 6*total {609+ return errors.New("too many backjumps")610+ }611+ g := m.geom[i]612+ if len(g.deps) == 0 {613+ return fmt.Errorf("tile %d admits no completion with nothing prefilled (bug)", i)614+ }615+ // dependencies from latest to earliest in the fill order616+ byRank := append([]int(nil), g.deps...)617+ sort.Slice(byRank, func(a, b int) bool { return m.geom[byRank[a]].rank > m.geom[byRank[b]].rank })618+ k := fails[i] / 2619+ fails[i]++620+ if k >= len(byRank) {621+ k = len(byRank) - 1622+ }623+ j := byRank[k]624+ if Verbose {625+ fmt.Fprintf(os.Stderr, "gen: tile %d (%d cells) has no completion; backjump to tile %d (dep %d of %d, try %d)\n", i, g.cells, j, k+1, len(byRank), tries[j])626+ }627+ for p := pos; p >= m.geom[j].rank; p-- {628+ m.clearTile(m.order[p])629+ }630+ pos = m.geom[j].rank631+ }632+ return nil633+}634+635+// buildGivens makes a tile's givens over its solution. A few rounds try636+// different removal orders; if none work, the tile's own interior is637+// redrawn (shared cells stay, since the neighbours are filled already) and638+// the rounds repeat. Only after several refills does the attempt fail.639+// `fillTry` continues the per-tile fill counter across refills so each640+// draw is distinct and deterministic.641+func (m *mosaic) buildGivens(ctx context.Context, tag string, i int) (*tileWork, error) {642+ var gerr error643+ fillTry := 1 // 0 was used by phase A644+ for refill := 0; refill < 6; refill++ {645+ for round := 0; round < 4; round++ {646+ tw, err := m.makeGivens(ctx, tag, i, refill*4+round)647+ if err == nil {648+ return tw, nil649+ }650+ if ctx.Err() != nil {651+ return nil, ctx.Err()652+ }653+ gerr = err654+ if Verbose {655+ fmt.Fprintf(os.Stderr, "gen: tile %d refill %d round %d: %v\n", i, refill, round, err)656+ }657+ }658+ m.clearInterior(i)659+ ok, err := m.fillTile(ctx, tag, i, fillTry)660+ fillTry++661+ if err != nil {662+ return nil, err663+ }664+ if !ok {665+ return nil, fmt.Errorf("refill %d admits no completion: %w", refill, gerr)666+ }667+ }668+ return nil, gerr669+}670+671+// commitGivens registers a tile's planted cells and seam givens.672+func (m *mosaic) commitGivens(t *tileWork) {673+ for _, id := range t.protect {674+ m.used[id] = true675+ }676+ for _, id := range t.ownSeam {677+ m.seamGivens[id] = t.ownSeamValue[id]678+ }679+}680+681+func (m *mosaic) bump(counter *int) {682+ m.mu.Lock()683+ *counter++684+ m.mu.Unlock()685+}686+687+// clearTile blanks every cell of the tile that no tile earlier in the fill688+// order owns.689+func (m *mosaic) clearTile(i int) {690+ g := m.geom[i]691+ for li, ok := range g.mask {692+ if !ok {693+ continue694+ }695+ id := g.gid(m, li)696+ earlier := false697+ for _, o := range m.owners[id] {698+ if m.geom[o].rank < g.rank {699+ earlier = true700+ }701+ }702+ if !earlier {703+ m.global.Cells[id] = rules.Empty704+ }705+ }706+}707+708+// clearInterior blanks only the cells no other tile owns. Used once every709+// tile has a solution (phase B): a redraw must not touch a shared cell,710+// because the tiles across it are already filled and may be building their711+// givens in the same wave.712+func (m *mosaic) clearInterior(i int) {713+ g := m.geom[i]714+ for li, ok := range g.mask {715+ if !ok {716+ continue717+ }718+ id := g.gid(m, li)719+ if len(m.owners[id]) == 1 {720+ m.global.Cells[id] = rules.Empty721+ }722+ }723+}724+725+// fillTile completes tile i over its prefilled cells, jointly with every726+// later neighbour (tiles after it in the fill order that share a cell):727+// one SAT instance over the union of those shapes, of which only tile i's728+// cells are written. That is forward checking with the interactions729+// between the neighbours included, which per-neighbour checks miss (the730+// last small neighbour of a boxed-in tile cannot balance its own edge and731+// finish the big tile's forced edge at once). Returns ok=false when no732+// joint completion exists: a definitive NOFIT, and the caller backjumps.733+func (m *mosaic) fillTile(ctx context.Context, tag string, i, refill int) (bool, error) {734+ g := m.geom[i]735+ var later []rules.Shape736+ for _, o := range m.geom {737+ if o.rank > g.rank {738+ for _, d := range o.deps {739+ if d == i {740+ later = append(later, o.sh)741+ break742+ }743+ }744+ }745+ }746+ const tries = 16 // only band collisions need a second draw747+ var collided []rules.Cell748+ for t := 0; t < tries; t++ {749+ if ctx.Err() != nil {750+ return false, ctx.Err()751+ }752+ rng := rngFor(tag, "fill", fmt.Sprint(i), fmt.Sprint(refill), fmt.Sprint(t))753+ full, ok := sat.CompleteRegion(m.global.Cells, m.G, later, g.sh, rng)754+ if !ok {755+ return false, nil756+ }757+ if m.p.BandUnique && m.bandCollision(i, full) {758+ m.bump(&m.stats.Refills)759+ collided = full760+ continue761+ }762+ m.writeTile(g, full)763+ return true, nil764+ }765+ m.mu.Lock()766+ m.bandUniqueHeld = false767+ m.mu.Unlock()768+ m.writeTile(g, collided)769+ return true, nil770+}771+772+// writeTile fills only the cells that are still empty: prefilled cells came773+// from earlier tiles and carry the same value.774+func (m *mosaic) writeTile(g *tileGeom, full []rules.Cell) {775+ for li, ok := range g.mask {776+ if !ok {777+ continue778+ }779+ id := g.gid(m, li)780+ if m.global.Cells[id] != rules.Empty {781+ continue782+ }783+ m.global.Cells[id] = full[li]784+ }785+}786+787+// bandCollision reports whether any row of the candidate tile equals a row788+// of an already generated tile of the same size in the same band (same789+// y0). Only square tiles take part (the grid packers).790+func (m *mosaic) bandCollision(i int, full []rules.Cell) bool {791+ g := m.geom[i]792+ if g.W != g.H || len(g.sh.Rects) != 1 {793+ return false794+ }795+ seen := map[string]bool{}796+ for _, o := range m.geom {797+ if o.rank >= g.rank || o.sh.Y0 != g.sh.Y0 || o.W != g.W || o.H != g.H || len(o.sh.Rects) != 1 {798+ continue799+ }800+ for ly := 0; ly < o.H; ly++ {801+ row := make([]byte, o.W)802+ for lx := 0; lx < o.W; lx++ {803+ row[lx] = byte('0' + m.global.At(o.sh.X0+lx, o.sh.Y0+ly))804+ }805+ seen[string(row)] = true806+ }807+ }808+ for ly := 0; ly < g.H; ly++ {809+ row := make([]byte, g.W)810+ for lx := 0; lx < g.W; lx++ {811+ row[lx] = byte('0' + full[ly*g.W+lx])812+ }813+ if seen[string(row)] {814+ return true815+ }816+ }817+ return false818+}819+820+// tileWork is a tile's givens under construction: its solution set, the821+// public and private records, the interior givens (local, Empty where822+// blank or outside the shape), the planted cells to protect from removal,823+// and the seam givens it added on its own shared cells (global ids and824+// values).825+type tileWork struct {826+ id int827+ sols rules.TileSolutions828+ public TilePublic829+ private TilePrivate830+ interior []rules.Cell831+ protect []int832+ ownSeam []int833+ ownSeamValue map[int]rules.Cell834+}835+836+// puzzleOf assembles a tile's current puzzle: interior givens plus every837+// seam given on its cells, whichever tile added it.838+func (m *mosaic) puzzleOf(t *tileWork) []rules.Cell {839+ g := m.geom[t.id]840+ p := append([]rules.Cell(nil), t.interior...)841+ for li, ok := range g.mask {842+ if !ok {843+ continue844+ }845+ if v, ok := m.seamGivens[g.gid(m, li)]; ok {846+ p[li] = v847+ }848+ }849+ return p850+}851+852+// settle recomputes a tile's givens, blank ratio, hash and solution set853+// from the final puzzle, and re-checks the assertions seam givens can move.854+func (m *mosaic) settle(t *tileWork) error {855+ g := m.geom[t.id]856+ p := m.puzzleOf(t)857+ var exhausted bool858+ t.sols, exhausted = solutionsPT(p, g.sh, g.m+1)859+ if !exhausted {860+ return fmt.Errorf("tile %d: solution count over budget after settling", t.id)861+ }862+ if len(t.sols) < 2 || len(t.sols) > g.m {863+ return fmt.Errorf("tile %d: %d solutions after settling", t.id, len(t.sols))864+ }865+ t.public.M = len(t.sols)866+ // pins follow the settled solution set: neighbours' seam clues may have867+ // removed alternatives, so the overlaps some alternative still differs868+ // on are recomputed here, not carried from makeGivens869+ truth := g.sh.Local(m.global.Cells, m.G)870+ pinned := map[int]bool{}871+ for _, s := range t.sols {872+ for _, li := range rules.Diff(truth, s) {873+ for _, oi := range g.regsAt[li] {874+ pinned[oi] = true875+ }876+ }877+ }878+ t.public.Pins = []int{}879+ for oi := range g.overlaps {880+ if pinned[oi] {881+ t.public.Pins = append(t.public.Pins, oi)882+ }883+ }884+ if len(t.public.Pins) == 0 {885+ return fmt.Errorf("tile %d: no alternative differs inside an overlap after settling", t.id)886+ }887+ t.private.Givens = nil888+ blanks := 0889+ for li, ok := range g.mask {890+ if !ok {891+ continue892+ }893+ if p[li] == rules.Empty {894+ blanks++895+ } else {896+ t.private.Givens = append(t.private.Givens, [2]int{g.gid(m, li), int(p[li])})897+ }898+ }899+ t.private.Blank = float64(blanks) / float64(g.cells)900+ if t.private.Blank < PolicyFor(g.cells).BlankMin {901+ return fmt.Errorf("tile %d: %.0f%% blank after neighbours' seam clues", t.id, t.private.Blank*100)902+ }903+ // content hash: shape, solution bits, givens mask, shared mask904+ h := sha256.New()905+ h.Write([]byte(fmt.Sprintf("%d,%d,%v|", g.W, g.H, g.sh.Rects)))906+ sol := make([]byte, len(p))907+ giv := make([]byte, len(p))908+ shared := make([]byte, len(p))909+ for li := range p {910+ sol[li], giv[li], shared[li] = '.', '.', '.'911+ if !g.mask[li] {912+ continue913+ }914+ sol[li] = byte('0' + m.global.Cells[g.gid(m, li)])915+ if p[li] != rules.Empty {916+ giv[li] = '1'917+ } else {918+ giv[li] = '0'919+ }920+ if g.shared[li] {921+ shared[li] = '1'922+ } else {923+ shared[li] = '0'924+ }925+ }926+ h.Write(sol)927+ h.Write(giv)928+ h.Write(shared)929+ t.public.Hash = hex.EncodeToString(h.Sum(nil))930+ return nil931+}932+933+// tileGrid reads a tile's solution as H rows of W local cells (Empty934+// outside the shape).935+func (m *mosaic) tileGrid(i int) [][]rules.Cell {936+ g := m.geom[i]937+ s := make([][]rules.Cell, g.H)938+ for ly := range s {939+ s[ly] = make([]rules.Cell, g.W)940+ for lx := range s[ly] {941+ s[ly][lx] = rules.Empty942+ if g.mask[ly*g.W+lx] {943+ s[ly][lx] = m.global.At(g.sh.X0+lx, g.sh.Y0+ly)944+ }945+ }946+ }947+ return s948+}949+950+// makeGivens plants a flip rectangle touching a pin-eligible overlap, then951+// removes clues while the tile keeps at most m solutions, and checks every952+// per-tile assertion.953+func (m *mosaic) makeGivens(ctx context.Context, tag string, i int, round int) (*tileWork, error) {954+ g := m.geom[i]955+ S := m.tileGrid(i)956+ gid := func(lx, ly int) int { return g.gid(m, ly*g.W+lx) }957+958+ // preferred overlaps: with a neighbour closer to the seed tile959+ preferred := make([]bool, len(g.overlaps))960+ for oi, o := range g.overlaps {961+ preferred[oi] = m.geom[o.With].dist >= 0 && g.dist >= 0 && m.geom[o.With].dist < g.dist962+ }963+964+ type cand struct {965+ r rules.Rect966+ pref bool967+ }968+ var cands []cand969+ for _, r := range rules.FindFlips(g.sh, S, rules.All) {970+ onRegion, pref, clash := false, false, false971+ for _, c := range r.Cells() {972+ li := c[1]*g.W + c[0]973+ if _, given := m.seamGivens[gid(c[0], c[1])]; given || m.used[gid(c[0], c[1])] || g.junction[li] {974+ clash = true975+ }976+ if len(g.regsAt[li]) > 0 {977+ onRegion = true978+ }979+ for _, oi := range g.regsAt[li] {980+ if preferred[oi] {981+ pref = true982+ }983+ }984+ }985+ if onRegion && !clash {986+ cands = append(cands, cand{r, pref})987+ }988+ }989+ if len(cands) == 0 {990+ // small tiles hemmed in by junctions may have no plantable991+ // rectangle: rely on the ambiguity blank shared cells leave naturally992+ cands = append(cands, cand{r: rules.Rect{R1: -1}})993+ }994+ rng := rngFor(tag, "givens", fmt.Sprint(i), fmt.Sprint(round))995+ rng.Shuffle(len(cands), func(a, b int) { cands[a], cands[b] = cands[b], cands[a] })996+ sort.SliceStable(cands, func(a, b int) bool { return cands[a].pref && !cands[b].pref })997+998+ limit := len(cands)999+ if limit > 10 {1000+ limit = 101001+ }1002+ // Candidates are independent and read-only over the mosaic, so they run1003+ // in parallel; the winner is the first success by index, which keeps the1004+ // output deterministic whatever the scheduling.1005+ type result struct {1006+ tw *tileWork1007+ err error1008+ ms int641009+ }1010+ results := make([]result, limit)1011+ var wg sync.WaitGroup1012+ for ci := 0; ci < limit; ci++ {1013+ wg.Add(1)1014+ go func(ci int) {1015+ defer wg.Done()1016+ t0 := time.Now()1017+ crng := rngFor(tag, "givens", fmt.Sprint(i), fmt.Sprint(round), fmt.Sprint(ci))1018+ tw, err := m.removeClues(ctx, crng, i, S, cands[ci].r)1019+ results[ci] = result{tw, err, time.Since(t0).Milliseconds()}1020+ }(ci)1021+ }1022+ wg.Wait()1023+ var lastErr error1024+ for ci, res := range results {1025+ if Verbose {1026+ fmt.Fprintf(os.Stderr, "gen: tile %d (%d cells) flip %d/%d %v in %dms: %v\n", i, g.cells, ci+1, limit, cands[ci].r, res.ms, res.err)1027+ }1028+ if res.err == nil {1029+ return res.tw, nil1030+ }1031+ lastErr = res.err1032+ }1033+ if ctx.Err() != nil {1034+ return nil, ctx.Err()1035+ }1036+ return nil, fmt.Errorf("no acceptable givens: %w", lastErr)1037+}1038+1039+// solutionsPT enumerates up to limit solutions (all rules) with the SAT1040+// backend. ok is always true (kept for the callers' shape).1041+func solutionsPT(puzzle []rules.Cell, sh rules.Shape, limit int) (rules.TileSolutions, bool) {1042+ sols, _ := sat.Enumerate(puzzle, sh, rules.All, limit)1043+ return rules.TileSolutions(sols), true1044+}1045+1046+// countPT counts solutions up to limit (all rules) with the SAT backend.1047+func countPT(puzzle []rules.Cell, sh rules.Shape, limit int) int {1048+ c, _ := sat.Count(puzzle, sh, rules.All, limit)1049+ return c1050+}1051+1052+// uniquenessRects lists rectangles whose flip keeps run and balance but1053+// duplicates a line, shuffled.1054+func uniquenessRects(sh rules.Shape, S [][]rules.Cell, rng *rand.Rand) []rules.Rect {1055+ valid := map[rules.Rect]bool{}1056+ for _, r := range rules.FindFlips(sh, S, rules.All) {1057+ valid[r] = true1058+ }1059+ var out []rules.Rect1060+ for _, r := range rules.FindFlips(sh, S, rules.Run|rules.Balance) {1061+ if !valid[r] {1062+ out = append(out, r)1063+ }1064+ }1065+ rng.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })1066+ return out1067+}1068+1069+func (m *mosaic) removeClues(ctx context.Context, rng *rand.Rand, ti int, S [][]rules.Cell, r rules.Rect) (*tileWork, error) {1070+ g := m.geom[ti]1071+ W := g.W1072+ M := g.m1073+ gid := func(lx, ly int) int { return g.gid(m, ly*W+lx) }1074+ protect := map[int]bool{} // local indices never given: planted rectangles1075+ planted := r.R1 >= 01076+ if planted {1077+ for _, c := range r.Cells() {1078+ protect[c[1]*W+c[0]] = true1079+ }1080+ }1081+ ownSeam := map[int]rules.Cell{} // seam givens this tile adds (local index)1082+ build := func() []rules.Cell {1083+ p := make([]rules.Cell, len(g.mask))1084+ for i := range p {1085+ switch {1086+ case !g.mask[i]:1087+ p[i] = rules.Empty1088+ case protect[i]:1089+ p[i] = rules.Empty1090+ case g.shared[i]:1091+ if v, ok := m.seamGivens[gid(i%W, i/W)]; ok {1092+ p[i] = v1093+ } else if v, ok := ownSeam[i]; ok {1094+ p[i] = v1095+ } else {1096+ p[i] = rules.Empty1097+ }1098+ default:1099+ p[i] = S[i/W][i%W]1100+ }1101+ }1102+ return p1103+ }1104+ // Seam clues: blank shared cells alone can leave more than m1105+ // solutions. Give the fewest shared cells that bring the count within1106+ // m, never planted cells, never junctions.1107+ addSeamClues := func() bool {1108+ if countPT(build(), g.sh, M+1) <= M {1109+ return true1110+ }1111+ var cands []int1112+ for i := range g.mask {1113+ if !g.mask[i] {1114+ continue1115+ }1116+ _, inherited := m.seamGivens[gid(i%W, i/W)]1117+ _, own := ownSeam[i]1118+ if g.shared[i] && !g.junction[i] && !protect[i] && !inherited && !own && !m.used[gid(i%W, i/W)] {1119+ cands = append(cands, i)1120+ }1121+ }1122+ rng.Shuffle(len(cands), func(a, b int) { cands[a], cands[b] = cands[b], cands[a] })1123+ ok := false1124+ added := []int{}1125+ for _, i := range cands {1126+ ownSeam[i] = S[i/W][i%W]1127+ added = append(added, i)1128+ if countPT(build(), g.sh, M+1) <= M {1129+ ok = true1130+ break1131+ }1132+ }1133+ if !ok {1134+ for _, i := range added {1135+ delete(ownSeam, i)1136+ }1137+ return false1138+ }1139+ for _, i := range added {1140+ v := ownSeam[i]1141+ delete(ownSeam, i)1142+ if countPT(build(), g.sh, M+1) > M {1143+ ownSeam[i] = v1144+ }1145+ }1146+ return true1147+ }1148+ if !addSeamClues() {1149+ return nil, fmt.Errorf("shared cells stay ambiguous with every one given")1150+ }1151+ // Plant a uniqueness situation: a rectangle whose flip is legal under1152+ // run+balance but duplicates a line. Kept blank, only the uniqueness1153+ // constraint separates the solution from its flip there.1154+ uniq := uniquenessRects(g.sh, S, rng)1155+ if len(uniq) > 4 {1156+ uniq = uniq[:4]1157+ }1158+ for _, u := range uniq {1159+ clash := false1160+ for _, c := range u.Cells() {1161+ li := c[1]*W + c[0]1162+ id := gid(c[0], c[1])1163+ _, given := m.seamGivens[id]1164+ _, own := ownSeam[li]1165+ if protect[li] || m.used[id] || given || own || g.junction[li] {1166+ clash = true1167+ }1168+ }1169+ if clash {1170+ continue1171+ }1172+ for _, c := range u.Cells() {1173+ protect[c[1]*W+c[0]] = true1174+ }1175+ if addSeamClues() {1176+ break1177+ }1178+ for _, c := range u.Cells() {1179+ delete(protect, c[1]*W+c[0])1180+ }1181+ }1182+ puzzle := build()1183+ blanks := 01184+ var removable []int1185+ for i, v := range puzzle {1186+ if !g.mask[i] {1187+ continue1188+ }1189+ if v == rules.Empty {1190+ blanks++1191+ } else if !g.shared[i] {1192+ removable = append(removable, i)1193+ }1194+ }1195+ count := func(lim int) int { return countPT(puzzle, g.sh, lim) }1196+ rng.Shuffle(len(removable), func(a, b int) { removable[a], removable[b] = removable[b], removable[a] })1197+ pol := PolicyFor(g.cells)1198+ target := int(pol.BlankMax * float64(g.cells))1199+ for _, i := range removable {1200+ if blanks >= target {1201+ break1202+ }1203+ if ctx.Err() != nil {1204+ return nil, ctx.Err()1205+ }1206+ saved := puzzle[i]1207+ puzzle[i] = rules.Empty1208+ t := rules.Single(g.sh, append([]rules.Cell(nil), puzzle...))1209+ t.Propagate(rules.All, 0)1210+ if t.Cells[i] == saved {1211+ blanks++1212+ continue1213+ }1214+ if count(M+1) > M {1215+ puzzle[i] = saved1216+ continue1217+ }1218+ blanks++1219+ }1220+ blank := float64(blanks) / float64(g.cells)1221+ if blank < pol.BlankMin {1222+ return nil, fmt.Errorf("only %.0f%% blank", blank*100)1223+ }1224+ sols, exhausted := solutionsPT(puzzle, g.sh, M+1)1225+ if !exhausted {1226+ return nil, errors.New("solution count over budget")1227+ }1228+ if len(sols) < 2 || len(sols) > M {1229+ return nil, fmt.Errorf("%d solutions alone", len(sols))1230+ }1231+ // every alternative must differ inside some pin-eligible overlap1232+ flat := make([]rules.Cell, len(g.mask))1233+ for ly := 0; ly < g.H; ly++ {1234+ copy(flat[ly*W:(ly+1)*W], S[ly])1235+ }1236+ pins := map[int]bool{}1237+ for _, s := range sols {1238+ d := rules.Diff(flat, s)1239+ if len(d) == 0 {1240+ continue1241+ }1242+ touched := false1243+ for _, i := range d {1244+ for _, oi := range g.regsAt[i] {1245+ pins[oi] = true1246+ touched = true1247+ }1248+ }1249+ if !touched {1250+ return nil, errors.New("an alternative solution differs inside no overlap region")1251+ }1252+ }1253+ // technique checks on the pinned puzzle (every shared cell fixed)1254+ pinned := append([]rules.Cell(nil), puzzle...)1255+ for i := range pinned {1256+ if g.mask[i] && g.shared[i] {1257+ pinned[i] = S[i/W][i%W]1258+ }1259+ }1260+ rating := rules.Single(g.sh, append([]rules.Cell(nil), pinned...)).Ladder(12)1261+ needsBif := rating.UsedBifurcation1262+ noUnique, _ := sat.Count(pinned, g.sh, rules.Run|rules.Balance, 2)1263+ needsUnique := noUnique >= 21264+ if pol.NeedsBifurcation && !needsBif {1265+ return nil, errors.New("pinned tile falls to propagation alone")1266+ }1267+ if pol.NeedsUnique && !needsUnique {1268+ return nil, errors.New("pinned tile is unique without the uniqueness constraint")1269+ }1270+ if one, ok := solutionsPT(pinned, g.sh, 2); !ok || len(one) != 1 {1271+ return nil, errors.New("pinned tile is not unique (bug)")1272+ }1273+1274+ tw := &tileWork{id: ti, sols: sols}1275+ tw.public = TilePublic{ID: ti, X0: g.sh.X0, Y0: g.sh.Y0, W: g.W, H: g.H, Rects: g.sh.Rects, Cells: g.cells, M: len(sols), Overlaps: []OverlapPublic{}, Pins: []int{}}1276+ for oi, o := range g.overlaps {1277+ tw.public.Overlaps = append(tw.public.Overlaps, OverlapPublic{With: o.With, Cells: o.Cells})1278+ if pins[oi] {1279+ tw.public.Pins = append(tw.public.Pins, oi)1280+ }1281+ }1282+ tw.private = TilePrivate{ID: ti, Blank: blank, NeedsUnique: needsUnique, NeedsBifurcation: needsBif, Flip: []int{},1283+ Ladder: Ladder{UsedUnique: rating.UsedUnique, UsedBifurcation: rating.UsedBifurcation, Bifurcations: rating.Bifurcations, Complete: rating.Complete}}1284+ if planted {1285+ for _, c := range r.Cells() {1286+ tw.private.Flip = append(tw.private.Flip, gid(c[0], c[1]))1287+ }1288+ }1289+ tw.interior = make([]rules.Cell, len(g.mask))1290+ for i, v := range puzzle {1291+ if !g.mask[i] || g.shared[i] {1292+ tw.interior[i] = rules.Empty1293+ } else {1294+ tw.interior[i] = v1295+ }1296+ }1297+ for i := range protect {1298+ tw.protect = append(tw.protect, gid(i%W, i/W))1299+ }1300+ tw.ownSeamValue = map[int]rules.Cell{}1301+ for i, v := range ownSeam {1302+ id := gid(i%W, i/W)1303+ tw.ownSeam = append(tw.ownSeam, id)1304+ tw.ownSeamValue[id] = v1305+ }1306+ sort.Ints(tw.protect)1307+ sort.Ints(tw.ownSeam)1308+ for i, v := range puzzle {1309+ if g.mask[i] && v != rules.Empty {1310+ tw.private.Givens = append(tw.private.Givens, [2]int{gid(i%W, i/W), int(v)})1311+ }1312+ }1313+ return tw, nil1314+}1315+1316+// tileWithoutWitness finds a tile some alternative of which is not refuted1317+// by any neighbour that is unanimous on a shared cell where the alternative1318+// differs from the solution. Returns -1 if every tile has a witness.1319+func (m *mosaic) tileWithoutWitness(tiles []*tileWork) int {1320+ unanimous := func(t *tileWork, gx, gy int) bool {1321+ g := m.geom[t.id]1322+ i := (gy-g.sh.Y0)*g.W + (gx - g.sh.X0)1323+ v := t.sols[0][i]1324+ for _, s := range t.sols[1:] {1325+ if s[i] != v {1326+ return false1327+ }1328+ }1329+ return v == m.global.At(gx, gy)1330+ }1331+ for ti, t := range tiles {1332+ g := m.geom[ti]1333+ f := g.sh.Local(m.global.Cells, m.G)1334+ for _, s := range t.sols {1335+ d := rules.Diff(f, s)1336+ if len(d) == 0 {1337+ continue1338+ }1339+ witness := false1340+ for _, i := range d {1341+ gx, gy := g.sh.X0+i%g.W, g.sh.Y0+i/g.W1342+ for _, ui := range m.owners[m.global.Idx(gx, gy)] {1343+ if ui != ti && unanimous(tiles[ui], gx, gy) {1344+ witness = true1345+ break1346+ }1347+ }1348+ if witness {1349+ break1350+ }1351+ }1352+ if !witness {1353+ return ti1354+ }1355+ }1356+ }1357+ return -11358+}@@ -0,0 +1,405 @@
1+package gen2+3+import (4+ "context"5+ "encoding/json"6+ "os"7+ "testing"8+9+ "mosaic-engine/pack"10+ "mosaic-engine/rules"11+ "mosaic-engine/sat"12+)13+14+func TestGenDeterministicPerSeed(t *testing.T) {15+ p := Params{Seed: "det", X: 2, N: 8}16+ a, err := Generate(context.Background(), p)17+ if err != nil {18+ t.Fatal(err)19+ }20+ b, err := Generate(context.Background(), p)21+ if err != nil {22+ t.Fatal(err)23+ }24+ a.Stats, b.Stats = Stats{}, Stats{}25+ ja, _ := json.Marshal(a)26+ jb, _ := json.Marshal(b)27+ if string(ja) != string(jb) {28+ t.Fatal("same seed produced different mosaics")29+ }30+ c, err := Generate(context.Background(), Params{Seed: "det2", X: 2, N: 8})31+ if err != nil {32+ t.Fatal(err)33+ }34+ if c.Solution[0] == a.Solution[0] && c.Solution[1] == a.Solution[1] {35+ t.Fatal("different seeds produced the same solution")36+ }37+}38+39+// checkAssertions re-verifies every per-tile and whole-mosaic assertion from40+// the emitted JSON alone, independently of the generator's own bookkeeping.41+func checkAssertions(t *testing.T, o *Output) {42+ t.Helper()43+ man := o.Manifest44+ G := man.G45+ if man.Version != 3 || len(o.Solution) != G {46+ t.Fatalf("bad manifest version %d or rows %d", man.Version, len(o.Solution))47+ }48+ if len(o.Tiles) != len(man.Tiles) {49+ t.Fatalf("%d private tiles for %d public", len(o.Tiles), len(man.Tiles))50+ }51+ board := rules.NewBoard(G, G)52+ shapes := make([]rules.Shape, len(man.Tiles))53+ masks := make([][]bool, len(man.Tiles))54+ owners := make([][]int, G*G)55+ for i, tp := range man.Tiles {56+ if tp.ID != i {57+ t.Fatalf("tile %d out of order", i)58+ }59+ shapes[i] = rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}60+ if err := shapes[i].Valid(); err != nil {61+ t.Fatalf("tile %d: %v", i, err)62+ }63+ if tp.X0 < 0 || tp.Y0 < 0 || tp.X0+tp.W > G || tp.Y0+tp.H > G {64+ t.Fatalf("tile %d outside the field", i)65+ }66+ masks[i] = shapes[i].Mask()67+ if c := shapes[i].Cells(); c != tp.Cells {68+ t.Fatalf("tile %d: %d cells, manifest says %d", i, c, tp.Cells)69+ }70+ board.Shapes = append(board.Shapes, shapes[i])71+ for li, ok := range masks[i] {72+ if ok {73+ id := (tp.Y0+li/tp.W)*G + tp.X0 + li%tp.W74+ owners[id] = append(owners[id], i)75+ }76+ }77+ }78+ for y, row := range o.Solution {79+ if len(row) != G {80+ t.Fatalf("row %d has %d cells", y, len(row))81+ }82+ for x, ch := range row {83+ id := y*G + x84+ switch ch {85+ case '.':86+ if len(owners[id]) != 0 {87+ t.Fatalf("owned cell %d is a hole in the solution", id)88+ }89+ case '0', '1':90+ if len(owners[id]) == 0 {91+ t.Fatalf("unowned cell %d has a value", id)92+ }93+ board.Cells[id] = rules.Cell(ch - '0')94+ default:95+ t.Fatalf("bad solution char %q", ch)96+ }97+ }98+ }99+ if err := board.Validate(rules.All); err != nil {100+ t.Fatal(err)101+ }102+ if !board.Complete() {103+ t.Fatal("solution incomplete on some tile")104+ }105+ // overlap cell sets from the masks; pin-eligible when >= MinLine cells106+ overlapCells := func(a, b int) []int {107+ var out []int108+ sa := shapes[a]109+ for li, ok := range masks[a] {110+ if !ok {111+ continue112+ }113+ gx, gy := sa.X0+li%sa.W, sa.Y0+li/sa.W114+ if shapes[b].Contains(gx, gy) {115+ out = append(out, li)116+ }117+ }118+ return out119+ }120+ sols := make([]rules.TileSolutions, len(man.Tiles))121+ seamGiven := map[int][]int{}122+ for i, tp := range man.Tiles {123+ priv := o.Tiles[i]124+ sh := shapes[i]125+ if tp.M < 2 || tp.M > pack.MFor(tp.Cells) {126+ t.Fatalf("tile %d m=%d for %d cells", i, tp.M, tp.Cells)127+ }128+ cells := make([]rules.Cell, tp.W*tp.H)129+ for j := range cells {130+ cells[j] = rules.Empty131+ }132+ regions := map[int][]int{} // overlap index -> local cells133+ onRegion := make([]bool, tp.W*tp.H)134+ for oi, ov := range tp.Overlaps {135+ oc := overlapCells(i, ov.With)136+ if len(oc) != ov.Cells {137+ t.Fatalf("tile %d overlap %d with %d: %d shared cells, manifest says %d", i, oi, ov.With, len(oc), ov.Cells)138+ }139+ if ov.Cells >= pack.MinLine {140+ regions[oi] = oc141+ for _, li := range oc {142+ onRegion[li] = true143+ }144+ }145+ }146+ for _, g := range priv.Givens {147+ gx, gy := g[0]%G, g[0]/G148+ if !sh.Contains(gx, gy) {149+ t.Fatalf("tile %d: given %d outside the shape", i, g[0])150+ }151+ if len(owners[g[0]]) >= 3 {152+ t.Fatalf("tile %d: given on cell %d with %d owners", i, g[0], len(owners[g[0]]))153+ }154+ if len(owners[g[0]]) >= 2 {155+ if !man.Rules.SeamClues {156+ t.Fatalf("tile %d: shared cell %d is a given but the manifest says no seam clues", i, g[0])157+ }158+ seamGiven[g[0]] = append(seamGiven[g[0]], i)159+ }160+ if int(o.Solution[gy][gx]-'0') != g[1] {161+ t.Fatalf("tile %d: given %d disagrees with the solution", i, g[0])162+ }163+ cells[(gy-tp.Y0)*tp.W+gx-tp.X0] = rules.Cell(g[1])164+ }165+ pol := PolicyFor(tp.Cells)166+ blank := 1 - float64(len(priv.Givens))/float64(tp.Cells)167+ if blank < pol.BlankMin {168+ t.Fatalf("tile %d (%d cells): %.0f%% blank", i, tp.Cells, blank*100)169+ }170+ // re-derived from the JSON, not the generator's bookkeeping: the171+ // SAT enumerator counts; the propagation solver must agree wherever172+ // it can finish within its budget173+ sols2, _ := sat.Enumerate(cells, sh, rules.All, pack.MFor(tp.Cells)+1)174+ s := rules.TileSolutions(sols2)175+ if len(s) < 2 || len(s) > pack.MFor(tp.Cells) || len(s) != tp.M {176+ t.Fatalf("tile %d: %d solutions alone (reported %d, m=%d)", i, len(s), tp.M, pack.MFor(tp.Cells))177+ }178+ 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) {179+ t.Fatalf("tile %d: propagation solver finds %d solutions, SAT %d", i, len(byRules), len(s))180+ }181+ truth := sh.Local(board.Cells, G)182+ hasTruth := false183+ pinned := map[int]bool{}184+ for _, sol := range s {185+ d := rules.Diff(truth, sol)186+ if len(d) == 0 {187+ hasTruth = true188+ continue189+ }190+ touched := false191+ for _, j := range d {192+ if onRegion[j] {193+ touched = true194+ for oi, oc := range regions {195+ for _, li := range oc {196+ if li == j {197+ pinned[oi] = true198+ }199+ }200+ }201+ }202+ }203+ if !touched {204+ t.Fatalf("tile %d: an alternative differs inside no overlap region", i)205+ }206+ }207+ if !hasTruth {208+ t.Fatalf("tile %d: the solution is not among the tile's solutions", i)209+ }210+ if len(tp.Pins) == 0 {211+ t.Fatalf("tile %d: no pins", i)212+ }213+ for _, pi := range tp.Pins {214+ if pi < 0 || pi >= len(tp.Overlaps) || tp.Overlaps[pi].Cells < pack.MinLine {215+ t.Fatalf("tile %d: pin %d is not a pin-eligible overlap", i, pi)216+ }217+ if !pinned[pi] {218+ t.Fatalf("tile %d: pin %d but no alternative differs there", i, pi)219+ }220+ }221+ if (pol.NeedsUnique && !priv.NeedsUnique) || (pol.NeedsBifurcation && !priv.NeedsBifurcation) {222+ t.Fatalf("tile %d (%d cells): needs_unique=%v needs_bifurcation=%v", i, tp.Cells, priv.NeedsUnique, priv.NeedsBifurcation)223+ }224+ if tp.Hash == "" {225+ t.Fatalf("tile %d: no hash", i)226+ }227+ sols[i] = s228+ }229+ for id, listed := range seamGiven {230+ if len(listed) != len(owners[id]) {231+ t.Fatalf("seam given %d listed by %d tiles, owned by %d", id, len(listed), len(owners[id]))232+ }233+ }234+ count, _ := rules.CoupledSolutions(shapes, sols, 2)235+ if count != 1 {236+ t.Fatalf("mosaic has %d coupled solutions", count)237+ }238+ if man.SeedTile < 0 || man.SeedTile >= len(man.Tiles) {239+ t.Fatalf("seed tile %d outside the mosaic", man.SeedTile)240+ }241+}242+243+func TestGenAssertionsSmall(t *testing.T) {244+ for _, seed := range []string{"a", "b"} {245+ o, err := Generate(context.Background(), Params{Seed: seed, X: 2, N: 8})246+ if err != nil {247+ t.Fatal(err)248+ }249+ checkAssertions(t, o)250+ }251+ o, err := Generate(context.Background(), Params{Seed: "three", X: 3, N: 8})252+ if err != nil {253+ t.Fatal(err)254+ }255+ checkAssertions(t, o)256+}257+258+func TestGenPacked(t *testing.T) {259+ for _, kind := range []string{"bands", "big-first"} {260+ o, err := Generate(context.Background(), Params{Seed: "packed", G: 30, Kind: kind})261+ if err != nil {262+ t.Fatalf("%s: %v", kind, err)263+ }264+ checkAssertions(t, o)265+ t.Logf("%s G=30: %d tiles, %d waves, %dms", kind, len(o.Manifest.Tiles), o.Stats.Waves, o.Stats.Ms)266+ }267+}268+269+func TestGenScatter(t *testing.T) {270+ o, err := Generate(context.Background(), Params{Seed: "scatter", G: 40, Kind: "scatter"})271+ if err != nil {272+ // a "layout tile ... parallel lines" error means the packer let a273+ // shape through that rules.Shape.Valid refuses (pigeonhole bound)274+ t.Fatal(err)275+ }276+ checkAssertions(t, o)277+ compound := 0278+ for _, tp := range o.Manifest.Tiles {279+ if len(tp.Rects) > 1 {280+ compound++281+ }282+ }283+ holes := 0284+ for _, row := range o.Solution {285+ for _, ch := range row {286+ if ch == '.' {287+ holes++288+ }289+ }290+ }291+ t.Logf("scatter G=40: %d tiles (%d compound), coverage %.2f, %d hole cells, %d waves, %d attempts, %dms",292+ len(o.Manifest.Tiles), compound, o.Layout.Coverage, holes, o.Stats.Waves, o.Stats.Attempts, o.Stats.Ms)293+ if compound == 0 {294+ t.Error("no compound (non-rectangular) tile in a scatter field")295+ }296+}297+298+func loadFixture(t *testing.T, path string) *Output {299+ t.Helper()300+ data, err := os.ReadFile(path)301+ if err != nil {302+ t.Skip("no fixture: " + err.Error())303+ }304+ var o Output305+ if err := json.Unmarshal(data, &o); err != nil {306+ t.Fatal(err)307+ }308+ if o.Manifest.Version != 3 {309+ t.Skip("fixture predates manifest v3")310+ }311+ return &o312+}313+314+// TestGenAssertionsStandard checks the shipped 2x2 n=14 fixture (PLAN item 1).315+func TestGenAssertionsStandard(t *testing.T) {316+ o := loadFixture(t, "../testdata/mosaic-2-14.json")317+ checkAssertions(t, o)318+ if len(o.Manifest.Tiles) != 4 || o.Manifest.G != 27 {319+ t.Fatalf("fixture is not a 2x2 of 14: %d tiles, G=%d", len(o.Manifest.Tiles), o.Manifest.G)320+ }321+}322+323+// TestGenAssertionsEnvelope checks the X=15 fixture when present (PLAN item 6).324+func TestGenAssertionsEnvelope(t *testing.T) {325+ o := loadFixture(t, "../testdata/mosaic-15-14.json")326+ checkAssertions(t, o)327+ if len(o.Manifest.Tiles) != 225 || o.Manifest.G != 196 {328+ t.Fatalf("fixture is not a 15x15 of 14: %d tiles, G=%d", len(o.Manifest.Tiles), o.Manifest.G)329+ }330+}331+332+// TestGenFixtureReproduces regenerates the 2x2 fixture from its seed and333+// compares byte for byte (MOSAIC_SLOW=1; ~15 s).334+func TestGenFixtureReproduces(t *testing.T) {335+ if os.Getenv("MOSAIC_SLOW") == "" {336+ t.Skip("MOSAIC_SLOW=1 to regenerate the fixture")337+ }338+ o := loadFixture(t, "../testdata/mosaic-2-14.json")339+ re, err := Generate(context.Background(), Params{Seed: "a", X: 2, N: 14})340+ if err != nil {341+ t.Fatal(err)342+ }343+ o.Stats, re.Stats = Stats{}, Stats{}344+ ja, _ := json.Marshal(o)345+ jb, _ := json.Marshal(re)346+ if string(ja) != string(jb) {347+ t.Fatal("regenerated fixture differs from the shipped one")348+ }349+}350+351+// TestFillOrder checks the most-constrained-first order on a real scatter352+// layout: a permutation, seed tile first, every later tile touches an353+// earlier one, and few fillers (<= 64 cells) are filled after all of their354+// neighbours (the regime where a small shape with most sides fixed has no355+// completion and the old id order looped on backjumps for minutes).356+func TestFillOrder(t *testing.T) {357+ L, err := pack.Pack(pack.Params{Seed: "a|order", G: 60, Kind: "scatter"})358+ if err != nil {359+ t.Fatal(err)360+ }361+ m := newMosaic(Params{Seed: "a", Nonce: "order", G: 60, Kind: "scatter"}, L)362+ if len(m.order) != len(L.Tiles) {363+ t.Fatalf("order has %d entries for %d tiles", len(m.order), len(L.Tiles))364+ }365+ if m.order[0] != L.SeedTile {366+ t.Fatalf("order starts at %d, seed tile is %d", m.order[0], L.SeedTile)367+ }368+ seen := map[int]bool{}369+ late, fillers := 0, 0370+ for pos, id := range m.order {371+ if seen[id] {372+ t.Fatalf("tile %d twice in the order", id)373+ }374+ seen[id] = true375+ g := m.geom[id]376+ if g.rank != pos {377+ t.Fatalf("tile %d rank %d at position %d", id, g.rank, pos)378+ }379+ if pos > 0 && len(g.deps) == 0 {380+ t.Fatalf("tile %d at position %d shares no cell with an earlier tile", id, pos)381+ }382+ for _, d := range g.deps {383+ if m.geom[d].rank >= g.rank {384+ t.Fatalf("tile %d depends on %d, which is later", id, d)385+ }386+ }387+ nbs := L.Neighbours(id)388+ last := len(nbs) >= 3389+ for _, nb := range nbs {390+ if m.geom[nb].rank > g.rank {391+ last = false392+ }393+ }394+ if g.cells <= 64 {395+ fillers++396+ if last {397+ late++398+ }399+ }400+ }401+ t.Logf("%d of %d fillers are filled after three or more neighbours", late, fillers)402+ if late*5 > fillers {403+ t.Errorf("%d of %d fillers are filled after all of their >= 3 neighbours; want under 20%%", late, fillers)404+ }405+}@@ -0,0 +1,108 @@
1+// Package judge classifies candidate commits as forced or guess by running2+// the rules over revealed information only: the known cells of the revealed3+// tiles, propagated to a fixpoint with optional bifurcation.4+package judge5+6+import (7+ "fmt"8+ "time"9+10+ "mosaic-engine/rules"11+)12+13+// ShapeSpec is a revealed tile: a union of rectangles (local coordinates)14+// placed at (x0, y0) on the board.15+type ShapeSpec struct {16+ X0 int `json:"x0"`17+ Y0 int `json:"y0"`18+ W int `json:"w"`19+ H int `json:"h"`20+ Rects [][4]int `json:"rects"`21+}22+23+type Input struct {24+ G int `json:"G"`25+ Shapes []ShapeSpec `json:"shapes"` // revealed tiles26+ Known [][2]int `json:"known"` // [global id, value]27+ Candidates [][2]int `json:"candidates"` // [global id, value]28+ Depth int `json:"depth"`29+}30+31+type Result struct {32+ ID int `json:"id"`33+ V int `json:"v"`34+ Class string `json:"class"` // forced | guess | contradiction | known35+ Rules []string `json:"rules"`36+}37+38+type Output struct {39+ Ms float64 `json:"ms"`40+ Results []Result `json:"results"`41+ Steps int `json:"steps"`42+}43+44+func Judge(in Input) (*Output, error) {45+ start := time.Now()46+ if in.G < 4 {47+ return nil, fmt.Errorf("bad size G=%d", in.G)48+ }49+ b := rules.NewBoard(in.G, in.G)50+ for _, w := range in.Shapes {51+ sh := rules.Shape{X0: w.X0, Y0: w.Y0, W: w.W, H: w.H, Rects: w.Rects}52+ if w.X0 < 0 || w.Y0 < 0 || w.X0+w.W > in.G || w.Y0+w.H > in.G {53+ return nil, fmt.Errorf("shape at (%d,%d) %dx%d outside the %dx%d board", w.X0, w.Y0, w.W, w.H, in.G, in.G)54+ }55+ if err := sh.Valid(); err != nil {56+ return nil, fmt.Errorf("shape at (%d,%d): %w", w.X0, w.Y0, err)57+ }58+ b.Shapes = append(b.Shapes, sh)59+ }60+ for _, k := range in.Known {61+ if k[0] < 0 || k[0] >= in.G*in.G || k[1] < 0 || k[1] > 1 {62+ return nil, fmt.Errorf("bad known cell %v", k)63+ }64+ b.Cells[k[0]] = rules.Cell(k[1])65+ }66+ if err := b.Validate(rules.All); err != nil {67+ return nil, fmt.Errorf("known cells violate rules: %w", err)68+ }69+ known := map[int]rules.Cell{}70+ for _, k := range in.Known {71+ known[k[0]] = rules.Cell(k[1])72+ }73+ steps, ok := b.Propagate(rules.All, in.Depth)74+ if !ok {75+ return nil, fmt.Errorf("known cells lead to a contradiction")76+ }77+ forced := map[int]rules.Step{}78+ for _, s := range steps {79+ forced[b.Idx(s.X, s.Y)] = s80+ }81+ out := &Output{Steps: len(steps)}82+ for _, c := range in.Candidates {83+ if c[0] < 0 || c[0] >= in.G*in.G || c[1] < 0 || c[1] > 1 {84+ return nil, fmt.Errorf("bad candidate %v", c)85+ }86+ r := Result{ID: c[0], V: c[1], Rules: []string{}}87+ if v, isKnown := known[c[0]]; isKnown {88+ r.Rules = []string{"seam"}89+ if v == rules.Cell(c[1]) {90+ r.Class = "known"91+ } else {92+ r.Class = "contradiction"93+ }94+ } else if s, isForced := forced[c[0]]; isForced {95+ r.Rules = s.Rules96+ if s.V == rules.Cell(c[1]) {97+ r.Class = "forced"98+ } else {99+ r.Class = "contradiction"100+ }101+ } else {102+ r.Class = "guess"103+ }104+ out.Results = append(out.Results, r)105+ }106+ out.Ms = float64(time.Since(start).Microseconds()) / 1000107+ return out, nil108+}@@ -0,0 +1,185 @@
1+package judge2+3+import (4+ "context"5+ "encoding/json"6+ "os"7+ "testing"8+ "time"9+10+ "mosaic-engine/gen"11+ "mosaic-engine/rules"12+)13+14+func generate(t *testing.T, X, n int) *gen.Output {15+ t.Helper()16+ out, err := gen.Generate(context.Background(), gen.Params{Seed: "judge-test", X: X, N: n})17+ if err != nil {18+ t.Fatal(err)19+ }20+ return out21+}22+23+func fixture(t *testing.T) *gen.Output {24+ t.Helper()25+ data, err := os.ReadFile("../testdata/mosaic-2-14.json")26+ if err != nil {27+ t.Skip("no 14x14 fixture: " + err.Error())28+ }29+ var o gen.Output30+ if err := json.Unmarshal(data, &o); err != nil {31+ t.Fatal(err)32+ }33+ if o.Manifest.Version != 3 {34+ t.Skip("fixture predates manifest v3")35+ }36+ return &o37+}38+39+// pinnedInput builds the referee's view of one revealed tile: its givens plus40+// every cell it shares with another tile fixed (as if all neighbours were41+// solved).42+func pinnedInput(o *gen.Output, i int, depth int) Input {43+ G := o.Manifest.G44+ tp := o.Manifest.Tiles[i]45+ in := Input{G: G, Shapes: []ShapeSpec{{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}}, Depth: depth}46+ seen := map[int]bool{}47+ for _, g := range o.Tiles[i].Givens {48+ in.Known = append(in.Known, g)49+ seen[g[0]] = true50+ }51+ sh := rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}52+ for ly := 0; ly < tp.H; ly++ {53+ for lx := 0; lx < tp.W; lx++ {54+ gx, gy := tp.X0+lx, tp.Y0+ly55+ if !sh.Contains(gx, gy) {56+ continue57+ }58+ id := gy*G + gx59+ shared := false60+ for _, other := range o.Manifest.Tiles {61+ osh := rules.Shape{X0: other.X0, Y0: other.Y0, W: other.W, H: other.H, Rects: other.Rects}62+ if other.ID != i && osh.Contains(gx, gy) {63+ shared = true64+ }65+ }66+ if shared && !seen[id] {67+ in.Known = append(in.Known, [2]int{id, int(o.Solution[gy][gx] - '0')})68+ seen[id] = true69+ }70+ }71+ }72+ return in73+}74+75+func TestJudgeClassifiesForcedGuessContradiction(t *testing.T) {76+ // n = 12 is the smallest size the policy holds to the hard standard77+ // (needs a bifurcation), so a pinned tile still has guess cells at78+ // depth 0; fillers (n <= 8) may be fully forced once pinned79+ o := generate(t, 2, 12)80+ in := pinnedInput(o, 0, 0)81+ G := o.Manifest.G82+ tp := o.Manifest.Tiles[0]83+ known := map[int]bool{}84+ for _, k := range in.Known {85+ known[k[0]] = true86+ }87+ sh := rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}88+ for ly := 0; ly < tp.H; ly++ {89+ for lx := 0; lx < tp.W; lx++ {90+ id := (tp.Y0+ly)*G + tp.X0 + lx91+ if known[id] || !sh.Contains(tp.X0+lx, tp.Y0+ly) {92+ continue93+ }94+ v := int(o.Solution[tp.Y0+ly][tp.X0+lx] - '0')95+ in.Candidates = append(in.Candidates, [2]int{id, v}, [2]int{id, 1 - v})96+ }97+ }98+ out, err := Judge(in)99+ if err != nil {100+ t.Fatal(err)101+ }102+ forced, guess := 0, 0103+ for i := 0; i < len(out.Results); i += 2 {104+ right, wrong := out.Results[i], out.Results[i+1]105+ switch right.Class {106+ case "forced":107+ forced++108+ if len(right.Rules) == 0 || wrong.Class != "contradiction" {109+ t.Fatalf("cell %d: %+v / %+v", right.ID, right, wrong)110+ }111+ case "guess":112+ guess++113+ if wrong.Class != "guess" {114+ t.Fatalf("cell %d: right is guess but wrong value is %s", right.ID, wrong.Class)115+ }116+ default:117+ t.Fatalf("cell %d: unexpected class %s", right.ID, right.Class)118+ }119+ }120+ if forced == 0 || guess == 0 {121+ t.Fatalf("want both forced and guess cells on a pinned hard tile, got forced=%d guess=%d", forced, guess)122+ }123+ t.Logf("forced=%d guess=%d steps=%d in %.2fms", forced, guess, out.Steps, out.Ms)124+}125+126+func TestJudgeKnownCells(t *testing.T) {127+ o := generate(t, 2, 8)128+ in := pinnedInput(o, 0, 0)129+ k := in.Known[0]130+ in.Candidates = [][2]int{k, {k[0], 1 - k[1]}}131+ out, err := Judge(in)132+ if err != nil {133+ t.Fatal(err)134+ }135+ if out.Results[0].Class != "known" || out.Results[0].Rules[0] != "seam" {136+ t.Fatalf("known cell: %+v", out.Results[0])137+ }138+ if out.Results[1].Class != "contradiction" {139+ t.Fatalf("wrong value on a known cell: %+v", out.Results[1])140+ }141+}142+143+// TestJudgeUnder50msAt14 is PLAN item 2: a batch at n=14 judged in under144+// 50 ms with depth 0, over all four revealed tiles of a 2x2 mosaic.145+func TestJudgeUnder50msAt14(t *testing.T) {146+ o := fixture(t)147+ G := o.Manifest.G148+ in := Input{G: G, Depth: 0}149+ seen := map[int]bool{}150+ for i, tp := range o.Manifest.Tiles {151+ in.Shapes = append(in.Shapes, ShapeSpec{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects})152+ for _, g := range o.Tiles[i].Givens {153+ if !seen[g[0]] {154+ seen[g[0]] = true155+ in.Known = append(in.Known, g)156+ }157+ }158+ }159+ for id := 0; id < G*G; id++ {160+ if !seen[id] && o.Solution[id/G][id%G] != '.' {161+ in.Candidates = append(in.Candidates, [2]int{id, int(o.Solution[id/G][id%G] - '0')})162+ }163+ }164+ var worst time.Duration165+ for i := 0; i < 5; i++ {166+ t0 := time.Now()167+ out, err := Judge(in)168+ if err != nil {169+ t.Fatal(err)170+ }171+ if d := time.Since(t0); d > worst {172+ worst = d173+ }174+ if i == 0 {175+ t.Logf("%d candidates, %d steps, %.2fms", len(in.Candidates), out.Steps, out.Ms)176+ }177+ }178+ if worst > 50*time.Millisecond {179+ t.Fatalf("judge took %s, want < 50ms", worst)180+ }181+ in.Depth = 1182+ t0 := time.Now()183+ out, _ := Judge(in)184+ t.Logf("depth 1: %d steps in %s", out.Steps, time.Since(t0))185+}@@ -0,0 +1,118 @@
1+// mosaic-engine: pure functions over JSON. No state, no ports.2+//3+// mosaic-engine gen [--seed s --G 100 --kind scatter --coverage 0.72 --compound 0.4 | --X 2 --n 14] (or JSON params on stdin)4+// mosaic-engine judge (JSON on stdin)5+// mosaic-engine pack [--seed s --G 100 --kind scatter|grid|big-first|bands --n 14]6+// mosaic-engine version (generator version; cache key)7+package main8+9+import (10+ "context"11+ "encoding/json"12+ "flag"13+ "fmt"14+ "io"15+ "os"16+ "time"17+18+ "mosaic-engine/gen"19+ "mosaic-engine/judge"20+ "mosaic-engine/pack"21+)22+23+func main() {24+ if len(os.Args) < 2 {25+ fmt.Fprintln(os.Stderr, "usage: mosaic-engine gen|judge")26+ os.Exit(2)27+ }28+ switch os.Args[1] {29+ case "gen":30+ runGen(os.Args[2:])31+ case "judge":32+ runJudge()33+ case "pack":34+ runPack(os.Args[2:])35+ case "version":36+ fmt.Println(gen.Version)37+ default:38+ fmt.Fprintln(os.Stderr, "unknown command", os.Args[1])39+ os.Exit(2)40+ }41+}42+43+func runGen(args []string) {44+ fs := flag.NewFlagSet("gen", flag.ExitOnError)45+ seed := fs.String("seed", "", "seed string; empty reads JSON params from stdin")46+ nonce := fs.String("nonce", "", "commitment nonce (derived from seed when empty)")47+ G := fs.Int("G", 0, "cells per side (packed layouts)")48+ kind := fs.String("kind", "scatter", "scatter | grid | big-first | bands")49+ n := fs.Int("n", 14, "tile size for kind=grid (even, 6..16)")50+ X := fs.Int("X", 0, "legacy grid shorthand: X tiles per side of size n")51+ bigArea := fs.Float64("big-area", 0, "big-first: share of area placed as large tiles first")52+ coverage := fs.Float64("coverage", 0, "scatter: target share of the field covered (default 0.72)")53+ compound := fs.Float64("compound", 0, "scatter: share of drops that are unions of two rectangles (default 0.4)")54+ band := fs.Bool("band-unique", false, "reject tiles whose rows repeat within the band (grid only)")55+ timeout := fs.Duration("timeout", 30*time.Minute, "overall budget")56+ _ = fs.Parse(args)57+ p := gen.Params{Seed: *seed, Nonce: *nonce, G: *G, Kind: *kind, N: *n, X: *X, BigArea: *bigArea, Coverage: *coverage, Compound: *compound, BandUnique: *band}58+ if *seed == "" {59+ if err := json.NewDecoder(os.Stdin).Decode(&p); err != nil {60+ fail(err)61+ }62+ }63+ if p.G == 0 && p.X == 0 {64+ fail(fmt.Errorf("gen needs --G (packed) or --X with --n (grid)"))65+ }66+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)67+ defer cancel()68+ out, err := gen.Generate(ctx, p)69+ if err != nil {70+ fail(err)71+ }72+ emit(out)73+}74+75+func runPack(args []string) {76+ fs := flag.NewFlagSet("pack", flag.ExitOnError)77+ seed := fs.String("seed", "a", "seed string")78+ G := fs.Int("G", 100, "cells per side")79+ kind := fs.String("kind", "scatter", "scatter | grid | big-first | bands")80+ n := fs.Int("n", 14, "tile size for kind=grid")81+ bigArea := fs.Float64("big-area", 0.45, "big-first: share of area placed as large tiles first")82+ coverage := fs.Float64("coverage", 0, "scatter: target share of the field covered (default 0.72)")83+ compound := fs.Float64("compound", 0, "scatter: share of drops that are unions of two rectangles (default 0.4)")84+ _ = fs.Parse(args)85+ L, err := pack.Pack(pack.Params{Seed: *seed, G: *G, Kind: *kind, N: *n, BigArea: *bigArea, Coverage: *coverage, Compound: *compound})86+ if err != nil {87+ fail(err)88+ }89+ emit(L)90+}91+92+func runJudge() {93+ data, err := io.ReadAll(os.Stdin)94+ if err != nil {95+ fail(err)96+ }97+ var in judge.Input98+ if err := json.Unmarshal(data, &in); err != nil {99+ fail(err)100+ }101+ out, err := judge.Judge(in)102+ if err != nil {103+ fail(err)104+ }105+ emit(out)106+}107+108+func emit(v any) {109+ enc := json.NewEncoder(os.Stdout)110+ if err := enc.Encode(v); err != nil {111+ fail(err)112+ }113+}114+115+func fail(err error) {116+ _ = json.NewEncoder(os.Stderr).Encode(map[string]string{"error": err.Error()})117+ os.Exit(1)118+}