clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

engine: chunkwise field generation and in-place regiven repair

dami committed 7a66be0
gen: chunk mode (Region, Fixed context tiles with their solutions, IDBase,
Chunk): fixed tiles are prefilled and never cleared, pin like solved
neighbours, take seam clues on the new tile's side only, and enter the
coupled-uniqueness CSP as single-solution tiles. GenerateField plans a
grid of chunk regions overlapping by a band, generates them colour by
colour ((cx mod 2, cy mod 2), so same-colour chunks never share a cell)
with a Runner (in process or one subprocess per chunk), and merges them:
dense ids, symmetric overlaps, overlaid solution, manifest.field. The
settle/coupling repair now releases neighbours' seam clues on a tile
whose only pin-eligible overlap they fixed and rebuilds it and them,
instead of restarting the attempt. main: field subcommand; pack flags
--max-hole --min-cells --max-cells.
commit
7a66be04b749d12e6697eaa692b8a575acba0b53
parent
63f237fe7731f12460b3c4da446a2330b252ce19
author
dami <gazey55@gmail.com>
4 files changed, +678 -44
Aengine/gen/field.go372++++++++++++++++++++++++++++++
Mengine/gen/gen.go245++++++++++++++++---
Mengine/gen/gen_test.go64+++++-
Mengine/main.go41+++-
A engine/gen/field.go +372 -0
@@ -0,0 +1,372 @@
1+package gen2+3+import (4+ "bytes"5+ "context"6+ "crypto/sha256"7+ "encoding/hex"8+ "encoding/json"9+ "errors"10+ "fmt"11+ "os"12+ "os/exec"13+ "sort"14+ "strings"15+ "sync"16+ "time"17+18+ "mosaic-engine/pack"19+ "mosaic-engine/rules"20+)21+22+// A field is a large board generated as a grid of chunks. Every chunk is23+// an ordinary scatter generation over its own region of the field; the24+// regions of neighbouring chunks overlap by a band so tiles can straddle25+// the border, and a chunk generated later sees the earlier neighbours'26+// tiles in that band as fixed context (prefilled, immutable, pin-eligible27+// like any solved neighbour). Chunks are coloured by (cx mod 2, cy mod 2)28+// and generated colour by colour, so that chunks of one colour never share29+// a cell and run in parallel, and every chunk of a later colour is unique30+// given its earlier neighbours. The whole field is therefore unique, and31+// the output does not depend on how many chunks ran at once.32+33+// FieldParams plans a field of Chunks x Chunks regions of ChunkG cells,34+// overlapping by Band cells; G = Chunks*ChunkG - (Chunks-1)*Band.35+type FieldParams struct {36+ Seed string `json:"seed"`37+ Chunks int `json:"chunks"`38+ ChunkG int `json:"chunk_g"`39+ Band int `json:"band"`40+ Kind string `json:"kind"`41+ Coverage float64 `json:"coverage,omitempty"`42+ Compound float64 `json:"compound,omitempty"`43+ Jobs int `json:"jobs,omitempty"` // chunks generated at once (default 4)44+ Retries int `json:"retries,omitempty"` // re-packs of a chunk whose generation fails (default 3)45+ Timeout time.Duration46+}47+48+// FieldPlan is the geometry of a field's chunks.49+type FieldPlan struct {50+ G int51+ Chunks int52+ ChunkG int53+ Band int54+ Regions [][4]int // per chunk index cy*Chunks+cx55+ Colour []int // (cx mod 2) + 2*(cy mod 2)56+}57+58+// PlanField lays out the chunk regions.59+func PlanField(p FieldParams) (FieldPlan, error) {60+ if p.Chunks < 1 || p.ChunkG < pack.MaxN {61+ return FieldPlan{}, fmt.Errorf("field needs chunks >= 1 and chunk_g >= %d", pack.MaxN)62+ }63+ if p.Band <= 0 {64+ p.Band = 1665+ }66+ if 2*p.Band > p.ChunkG {67+ return FieldPlan{}, fmt.Errorf("band %d too wide for chunk_g %d", p.Band, p.ChunkG)68+ }69+ pitch := p.ChunkG - p.Band70+ plan := FieldPlan{G: p.Chunks*pitch + p.Band, Chunks: p.Chunks, ChunkG: p.ChunkG, Band: p.Band}71+ for cy := 0; cy < p.Chunks; cy++ {72+ for cx := 0; cx < p.Chunks; cx++ {73+ plan.Regions = append(plan.Regions, [4]int{cx * pitch, cy * pitch, p.ChunkG, p.ChunkG})74+ plan.Colour = append(plan.Colour, cx%2+2*(cy%2))75+ }76+ }77+ return plan, nil78+}79+80+// Runner generates one chunk (or any board) from its params.81+type Runner func(ctx context.Context, p Params) (*Output, error)82+83+// InProcess runs a chunk in this process. Chunks then share the SAT84+// solver's mutex, so parallel chunks gain little; use SubprocessRunner85+// for real parallelism.86+func InProcess(ctx context.Context, p Params) (*Output, error) {87+ return Generate(ctx, p)88+}89+90+// SubprocessRunner runs `exe gen` with the params as JSON on stdin.91+func SubprocessRunner(exe string, timeout time.Duration) Runner {92+ return func(ctx context.Context, p Params) (*Output, error) {93+ in, err := json.Marshal(p)94+ if err != nil {95+ return nil, err96+ }97+ args := []string{"gen"}98+ if timeout > 0 {99+ args = append(args, "--timeout", timeout.String())100+ }101+ cmd := exec.CommandContext(ctx, exe, args...)102+ cmd.Stdin = bytes.NewReader(in)103+ var out, errb bytes.Buffer104+ cmd.Stdout = &out105+ cmd.Stderr = &errb106+ cmd.Env = os.Environ()107+ if err := cmd.Run(); err != nil {108+ msg := strings.TrimSpace(errb.String())109+ if i := strings.LastIndex(msg, "\n"); i >= 0 {110+ msg = msg[i+1:]111+ }112+ return nil, fmt.Errorf("chunk process: %v: %s", err, msg)113+ }114+ var o Output115+ if err := json.Unmarshal(out.Bytes(), &o); err != nil {116+ return nil, fmt.Errorf("chunk output: %w", err)117+ }118+ return &o, nil119+ }120+}121+122+func chunkNonce(seed string, chunk, retry int) string {123+ h := sha256.Sum256([]byte(fmt.Sprintf("mosaic-chunk|%s|%d|%d", seed, chunk, retry)))124+ return hex.EncodeToString(h[:8])125+}126+127+// provisional ids keep chunks' tiles apart before the merge renumbers128+const idStride = 100000129+130+// GenerateField generates every chunk (colour by colour, Jobs at a time)131+// with the runner and merges them into one Output.132+func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, error) {133+ if p.Kind == "" {134+ p.Kind = "scatter"135+ }136+ if p.Kind != "scatter" {137+ return nil, fmt.Errorf("field chunks must be scatter, not %q", p.Kind)138+ }139+ if p.Jobs <= 0 {140+ p.Jobs = 4141+ }142+ if p.Retries <= 0 {143+ p.Retries = 3144+ }145+ plan, err := PlanField(p)146+ if err != nil {147+ return nil, err148+ }149+ start := time.Now()150+ nChunks := len(plan.Regions)151+ outs := make([]*Output, nChunks)152+ var mu sync.Mutex153+ for colour := 0; colour < 4; colour++ {154+ var idx []int155+ for i, c := range plan.Colour {156+ if c == colour {157+ idx = append(idx, i)158+ }159+ }160+ if len(idx) == 0 {161+ continue162+ }163+ // context for each chunk of this colour: every finished tile that164+ // intersects its region165+ fixedFor := make([][]FixedTile, nChunks)166+ for _, i := range idx {167+ fixedFor[i] = contextFor(plan, outs, i)168+ }169+ errs := make([]error, nChunks)170+ sem := make(chan struct{}, p.Jobs)171+ var wg sync.WaitGroup172+ for _, i := range idx {173+ wg.Add(1)174+ sem <- struct{}{}175+ go func(i int) {176+ defer wg.Done()177+ defer func() { <-sem }()178+ var last error179+ for retry := 0; retry < p.Retries; retry++ {180+ if ctx.Err() != nil {181+ last = ctx.Err()182+ break183+ }184+ gp := Params{185+ Seed: p.Seed, Nonce: chunkNonce(p.Seed, i, retry), G: plan.G, Kind: p.Kind,186+ Coverage: p.Coverage, Compound: p.Compound,187+ Region: plan.Regions[i], Fixed: fixedFor[i], IDBase: (i + 1) * idStride, Chunk: i,188+ }189+ o, err := run(ctx, gp)190+ if err == nil {191+ mu.Lock()192+ outs[i] = o193+ mu.Unlock()194+ last = nil195+ break196+ }197+ last = err198+ if Verbose {199+ fmt.Fprintf(os.Stderr, "field: chunk %d retry %d: %v\n", i, retry, err)200+ }201+ }202+ errs[i] = last203+ }(i)204+ }205+ wg.Wait()206+ for _, i := range idx {207+ if errs[i] != nil {208+ return nil, fmt.Errorf("chunk %d (colour %d, region %v) failed after %d packings: %w", i, colour, plan.Regions[i], p.Retries, errs[i])209+ }210+ }211+ }212+ out, err := mergeField(p, plan, outs)213+ if err != nil {214+ return nil, err215+ }216+ out.Stats.Ms = time.Since(start).Milliseconds()217+ return out, nil218+}219+220+// contextFor collects the finished tiles intersecting chunk i's region as221+// fixed context, with their solutions read from their chunk's output.222+func contextFor(plan FieldPlan, outs []*Output, i int) []FixedTile {223+ r := plan.Regions[i]224+ var fixed []FixedTile225+ for _, o := range outs {226+ if o == nil {227+ continue228+ }229+ for _, t := range o.Manifest.Tiles {230+ if t.X0 >= r[0]+r[2] || t.X0+t.W <= r[0] || t.Y0 >= r[1]+r[3] || t.Y0+t.H <= r[1] {231+ continue232+ }233+ pt := pack.Tile{ID: t.ID, X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects, Cells: t.Cells, M: t.M}234+ sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}235+ cells := make([]rules.Cell, t.W*t.H)236+ for li, ok := range sh.Mask() {237+ cells[li] = rules.Empty238+ if ok {239+ cells[li] = rules.Cell(o.Solution[t.Y0+li/t.W][t.X0+li%t.W] - '0')240+ }241+ }242+ fixed = append(fixed, FixedTile{Tile: pt, Cells: cells})243+ }244+ }245+ sort.Slice(fixed, func(a, b int) bool { return fixed[a].Tile.ID < fixed[b].Tile.ID })246+ return fixed247+}248+249+// mergeField renumbers the chunks' tiles densely in chunk order, remaps250+// overlaps (adding the reverse entry a chunk could not know about),251+// overlays the solutions and assembles one manifest.252+func mergeField(p FieldParams, plan FieldPlan, outs []*Output) (*Output, error) {253+ G := plan.G254+ final := map[int]int{}255+ var tiles []TilePublic256+ var priv []TilePrivate257+ seedTiles := make([]int, len(outs))258+ var chunkStats []Stats259+ for i, o := range outs {260+ if o == nil {261+ return nil, fmt.Errorf("chunk %d missing", i)262+ }263+ for k, t := range o.Manifest.Tiles {264+ final[t.ID] = len(tiles)265+ tiles = append(tiles, t)266+ priv = append(priv, o.Tiles[k])267+ }268+ seedTiles[i] = final[o.Manifest.SeedTile]269+ chunkStats = append(chunkStats, o.Stats)270+ }271+ // remap ids and overlaps; collect reverse overlaps272+ type key struct{ a, b int }273+ seen := map[key]bool{}274+ reverse := map[int][]OverlapPublic{}275+ for k := range tiles {276+ t := &tiles[k]277+ t.ID = k278+ priv[k].ID = k279+ ovs := make([]OverlapPublic, len(t.Overlaps))280+ for j, ov := range t.Overlaps {281+ w, ok := final[ov.With]282+ if !ok {283+ return nil, fmt.Errorf("tile %d overlaps unknown tile %d", k, ov.With)284+ }285+ ovs[j] = OverlapPublic{With: w, Cells: ov.Cells}286+ seen[key{k, w}] = true287+ }288+ t.Overlaps = ovs289+ }290+ for k, t := range tiles {291+ for _, ov := range t.Overlaps {292+ if !seen[key{ov.With, k}] {293+ reverse[ov.With] = append(reverse[ov.With], OverlapPublic{With: k, Cells: ov.Cells})294+ }295+ }296+ }297+ for w, extra := range reverse {298+ sort.Slice(extra, func(a, b int) bool { return extra[a].With < extra[b].With })299+ tiles[w].Overlaps = append(tiles[w].Overlaps, extra...)300+ }301+ // solution overlay302+ sol := make([][]byte, G)303+ for y := range sol {304+ sol[y] = bytes.Repeat([]byte{'.'}, G)305+ }306+ for i, o := range outs {307+ if len(o.Solution) != G {308+ return nil, fmt.Errorf("chunk %d: %d solution rows for G=%d", i, len(o.Solution), G)309+ }310+ for y, row := range o.Solution {311+ for x := 0; x < G; x++ {312+ if row[x] == '.' {313+ continue314+ }315+ if sol[y][x] != '.' && sol[y][x] != row[x] {316+ return nil, fmt.Errorf("chunks disagree at (%d,%d)", x, y)317+ }318+ sol[y][x] = row[x]319+ }320+ }321+ }322+ nonce := chunkNonce(p.Seed, -1, 0)323+ commit := sha256.Sum256([]byte(p.Seed + "|" + nonce))324+ mByN := map[string]int{}325+ for n := pack.MinN; n <= pack.MaxN; n += 2 {326+ mByN[fmt.Sprint(n)] = pack.MByN(n)327+ }328+ seamClues := false329+ attempts, regivens, backjumps, waves := 0, 0, 0, 0330+ for _, o := range outs {331+ seamClues = seamClues || o.Manifest.Rules.SeamClues332+ attempts += o.Stats.Attempts333+ regivens += o.Stats.Regivens334+ backjumps += o.Stats.Backjumps335+ waves += o.Stats.Waves336+ }337+ covered := 0338+ for y := range sol {339+ for x := range sol[y] {340+ if sol[y][x] != '.' {341+ covered++342+ }343+ }344+ }345+ out := &Output{346+ Manifest: Manifest{347+ Version: 3,348+ ID: hex.EncodeToString(commit[:]),349+ SeedCommit: hex.EncodeToString(commit[:]),350+ G: G,351+ Packer: PackerInfo{Kind: "field", Params: map[string]any{"kind": p.Kind, "chunks": p.Chunks, "chunk_g": plan.ChunkG, "band": plan.Band, "coverage": p.Coverage, "compound": p.Compound}},352+ MByN: mByN,353+ Rules: Rules{Overlaps: true, CrossTile: false, SeamClues: seamClues},354+ SeedTile: seedTiles[0],355+ Tiles: tiles,356+ K: "unmeasured",357+ Generator: generatorHash(),358+ Field: &FieldPublic{Chunks: plan.Chunks, ChunkG: plan.ChunkG, Band: plan.Band, SeedTiles: seedTiles},359+ },360+ Tiles: priv,361+ Stats: Stats{Attempts: attempts, Regivens: regivens, Backjumps: backjumps, Waves: waves},362+ Layout: pack.Report{Tiles: len(tiles), Coverage: float64(covered) / float64(G*G)},363+ ChunkStats: chunkStats,364+ }365+ for y := range sol {366+ out.Solution = append(out.Solution, string(sol[y]))367+ }368+ if len(tiles) == 0 {369+ return nil, errors.New("empty field")370+ }371+ return out, nil372+}
M engine/gen/gen.go +203 -42
@@ -74,6 +74,30 @@ type Params struct {
7474 Compound float64 `json:"compound,omitempty"`7575 BandUnique bool `json:"band_unique"`7676 MaxAttempts int `json:"max_attempts,omitempty"`77+ // chunk mode (scatter): place tiles inside Region against Fixed context78+ // tiles from neighbouring chunks, whose cells are prefilled and never79+ // changed; emitted tile ids start at IDBase; Chunk is the chunk's index80+ // in its field (informational; the orchestrator sets Nonce per chunk).81+ Region [4]int `json:"region,omitempty"`82+ Fixed []FixedTile `json:"fixed,omitempty"`83+ IDBase int `json:"id_base,omitempty"`84+ Chunk int `json:"chunk,omitempty"`85+}86+87+// FixedTile is a context tile from another chunk: its shape (field88+// coordinates, real id) and its solution as local W*H cells (Empty outside89+// the shape).90+type FixedTile struct {91+ Tile pack.Tile `json:"tile"`92+ Cells []rules.Cell `json:"cells"`93+}94+95+// FieldPublic describes a field assembled from chunks (manifest.field).96+type FieldPublic struct {97+ Chunks int `json:"chunks"` // chunks per side98+ ChunkG int `json:"chunk_g"` // cells per side of a chunk region99+ Band int `json:"band"` // overlap band between neighbouring regions100+ SeedTiles []int `json:"seed_tiles"`77101 }78102 79103 type Rules struct {
@@ -124,6 +148,7 @@ type Manifest struct {
124148 K string `json:"k"`125149 Generator string `json:"generator"`126150 Library *string `json:"library"`151+ Field *FieldPublic `json:"field,omitempty"`127152 }128153 129154 // TilePrivate is the generator's per-tile record: never in the public log.
@@ -156,11 +181,12 @@ type Stats struct {
156181 }157182 158183 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"`184+ Manifest Manifest `json:"manifest"`185+ Solution []string `json:"solution"` // G rows; '.' where no emitted tile covers the cell186+ Tiles []TilePrivate `json:"tiles"`187+ Stats Stats `json:"stats"`188+ Layout pack.Report `json:"layout"`189+ ChunkStats []Stats `json:"chunk_stats,omitempty"` // fields: per chunk, in chunk order164190 }165191 166192 func defaults(p Params) (Params, error) {
@@ -205,13 +231,21 @@ func Generate(ctx context.Context, p Params) (*Output, error) {
205231 if err != nil {206232 return nil, err207233 }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})234+ var fixed []pack.Tile235+ for _, f := range p.Fixed {236+ if len(f.Cells) != f.Tile.W*f.Tile.H {237+ return nil, fmt.Errorf("fixed tile %d: %d cells for a %dx%d box", f.Tile.ID, len(f.Cells), f.Tile.W, f.Tile.H)238+ }239+ fixed = append(fixed, f.Tile)240+ }241+ 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,242+ Region: p.Region, Fixed: fixed, IDBase: p.IDBase})209243 if err != nil {210244 return nil, err211245 }212246 // every tile must be able to carry the rules on its own before any213247 // search starts (an infeasible shape costs SAT seconds to refute)214- for _, t := range L.Tiles {248+ for _, t := range L.All() {215249 sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}216250 if err := sh.Valid(); err != nil {217251 return nil, fmt.Errorf("layout tile %d (%dx%d at %d,%d): %w", t.ID, t.W, t.H, t.X0, t.Y0, err)
@@ -254,7 +288,9 @@ type tileGeom struct {
254288 junction []bool // local index -> shared cell that is not on any pin-eligible overlap, or has 3+ owners255289 dist int // BFS distance to the seed tile over overlaps256290 rank int // position in the fill order (see fillOrder)257- deps []int // tiles earlier in the fill order sharing any cell, by id291+ deps []int // tiles earlier in the fill order sharing any cell, by index292+ fixed bool // context tile from another chunk: prefilled, never cleared, never given293+ pubID int // the id the manifest uses (IDBase + index for emitted tiles)258294 }259295 260296 func (g *tileGeom) gid(m *mosaic, local int) int {
@@ -262,32 +298,45 @@ func (g *tileGeom) gid(m *mosaic, local int) int {
262298 }263299 264300 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 Stats301+ p Params302+ L *pack.Layout303+ G int304+ global *rules.Board305+ geom []*tileGeom // emitted tiles first (index = position in L.Tiles), then fixed context tiles306+ n int // number of emitted tiles307+ order []int // fill order: position -> tile index; fixed tiles come first308+ firstNew int // position in order of the first emitted tile309+ owners [][]int // per cell, tile indexes owning it310+ stats Stats273311 274312 bandUniqueHeld bool275313 276314 seamGivens map[int]rules.Cell // shared cells given to hold m; shared by all owners315+ seamOwner map[int]int // which tile index added each seam given277316 used map[int]bool // cells of planted rectangles: never givens278317 mu sync.Mutex279318 }280319 281320 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{}}321+ m := &mosaic{p: p, L: L, G: L.G, n: len(L.Tiles), bandUniqueHeld: p.BandUnique,322+ seamGivens: map[int]rules.Cell{}, seamOwner: map[int]int{}, used: map[int]bool{}}284323 m.global = rules.NewBoard(m.G, m.G)285324 m.owners = L.Owners()286- for _, t := range L.Tiles {325+ all := L.All()326+ for _, t := range all {287327 m.global.Shapes = append(m.global.Shapes, rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects})288328 }329+ // context tiles' cells are the truth from the neighbouring chunk330+ for k, f := range p.Fixed {331+ sh := m.global.Shapes[m.n+k]332+ for li, ok := range sh.Mask() {333+ if ok {334+ m.global.Cells[m.global.Idx(sh.X0+li%sh.W, sh.Y0+li/sh.W)] = f.Cells[li]335+ }336+ }337+ }289338 // BFS distance from the seed tile over overlaps290- dist := make([]int, len(L.Tiles))339+ dist := make([]int, len(all))291340 for i := range dist {292341 dist[i] = -1293342 }
@@ -303,8 +352,8 @@ func newMosaic(p Params, L *pack.Layout) *mosaic {
303352 }304353 }305354 }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]}355+ for i, t := range all {356+ g := &tileGeom{id: i, sh: m.global.Shapes[i], W: t.W, H: t.H, cells: t.Cells, m: t.M, overlaps: L.Overlaps[i], dist: dist[i], fixed: i >= m.n, pubID: t.ID}308357 g.mask = g.sh.Mask()309358 g.shared = make([]bool, t.W*t.H)310359 g.regsAt = make([][]int, t.W*t.H)
@@ -387,6 +436,12 @@ func (m *mosaic) fillOrder() []int {
387436 }388437 }389438 }439+ for _, g := range m.geom {440+ if g.fixed {441+ place(g.id)442+ }443+ }444+ m.firstNew = len(order)390445 place(m.L.SeedTile)391446 for len(order) < total {392447 best := -1
@@ -412,7 +467,7 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
412467 413468 // Phase A: the solution, tile by tile in most-constrained-first order414469 // (fillOrder) with backjumping (fillSolution).415- total := len(L.Tiles)470+ total := len(m.geom) // emitted tiles plus fixed context416471 if err := m.fillSolution(ctx, tag); err != nil {417472 return nil, err418473 }
@@ -422,7 +477,13 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
422477 // they run in parallel and the output equals sequential fill order.423478 tiles := make([]*tileWork, total)424479 done := make([]bool, total)425- remaining := total480+ remaining := m.n481+ for i := m.n; i < total; i++ {482+ // a context tile has one solution: its cells483+ g := m.geom[i]484+ tiles[i] = &tileWork{id: i, sols: rules.TileSolutions{g.sh.Local(m.global.Cells, m.G)}}485+ done[i] = true486+ }426487 for remaining > 0 {427488 var idx []int428489 for _, g := range m.geom {
@@ -461,7 +522,7 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
461522 for k, i := range idx {462523 if errs[k] != nil {463524 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])525+ return nil, fmt.Errorf("tile %d (%dx%d, %d cells at %d,%d): %w", g.pubID, g.W, g.H, g.cells, g.sh.X0, g.sh.Y0, errs[k])465526 }466527 tiles[i] = results[k]467528 m.commitGivens(results[k])
@@ -488,17 +549,92 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
488549 }489550 tw, err := m.makeGivens(ctx, tag, bad, 100+round)490551 if err != nil {491- return fmt.Errorf("repair tile %d: %w", bad, err)552+ return fmt.Errorf("repair tile %d: %w", m.geom[bad].pubID, err)492553 }493554 tiles[bad] = tw494555 m.commitGivens(tw)495556 m.stats.Regivens++496557 return nil497558 }559+ // release takes back the seam clues other tiles put on bad's shared560+ // cells (they can fix its only pin-eligible overlap outright, leaving561+ // nothing for an alternative to differ on) and returns the tiles that562+ // lost a clue, so their givens can be rebuilt afterwards563+ release := func(bad int) []int {564+ g := m.geom[bad]565+ var dirty []int566+ seen := map[int]bool{}567+ for li, ok := range g.mask {568+ if !ok || !g.shared[li] {569+ continue570+ }571+ id := g.gid(m, li)572+ nb, has := m.seamOwner[id]573+ if !has || nb == bad {574+ continue575+ }576+ delete(m.seamGivens, id)577+ delete(m.seamOwner, id)578+ t := tiles[nb]579+ delete(t.ownSeamValue, id)580+ for k, v := range t.ownSeam {581+ if v == id {582+ t.ownSeam = append(t.ownSeam[:k], t.ownSeam[k+1:]...)583+ break584+ }585+ }586+ if !seen[nb] {587+ seen[nb] = true588+ dirty = append(dirty, nb)589+ }590+ }591+ sort.Ints(dirty)592+ return dirty593+ }594+ released := make([]int, total)595+ // repair rebuilds bad's givens; when that fails because the neighbours'596+ // clues left nothing to pin on, it releases those clues, rebuilds bad597+ // and then the neighbours that lost a clue, at most twice per tile598+ repair := func(bad, round int) error {599+ err := regiven(bad, round)600+ if err == nil {601+ return nil602+ }603+ if released[bad] >= 2 {604+ return err605+ }606+ dirty := release(bad)607+ if len(dirty) == 0 {608+ return err609+ }610+ released[bad]++611+ if Verbose {612+ fmt.Fprintf(os.Stderr, "gen: repair tile %d: released %d neighbours' clues (%v)\n", m.geom[bad].pubID, len(dirty), err)613+ }614+ if err := regiven(bad, round); err != nil {615+ return err616+ }617+ for _, nb := range dirty {618+ if m.geom[nb].fixed {619+ continue620+ }621+ if serr := m.settle(tiles[nb]); serr != nil {622+ if err := regiven(nb, round); err != nil {623+ return err624+ }625+ }626+ }627+ return nil628+ }498629 for round := 0; ; round++ {499630 sols := make([]rules.TileSolutions, total)500631 settled := true501- for i, t := range tiles {632+ for i := 0; i < total; i++ {633+ t := tiles[i]634+ if m.geom[i].fixed {635+ sols[i] = t.sols636+ continue637+ }502638 if err := m.settle(t); err != nil {503639 if round >= 3*total {504640 return nil, err
@@ -506,7 +642,7 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
506642 if Verbose {507643 fmt.Fprintf(os.Stderr, "gen: settle: %v; regiven\n", err)508644 }509- if err := regiven(i, round); err != nil {645+ if err := repair(i, round); err != nil {510646 return nil, err511647 }512648 settled = false
@@ -528,7 +664,7 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
528664 if bad < 0 {529665 return nil, fmt.Errorf("coupled system has %d solutions but every tile has a witness (bug)", count)530666 }531- if err := regiven(bad, round); err != nil {667+ if err := repair(bad, round); err != nil {532668 return nil, err533669 }534670 }
@@ -547,7 +683,7 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
547683 Packer: PackerInfo{Kind: L.Kind, Params: map[string]any{"n": p.N, "big_area": p.BigArea, "coverage": p.Coverage, "compound": p.Compound}},548684 MByN: mByN,549685 Rules: Rules{Overlaps: true, CrossTile: false, SeamClues: len(m.seamGivens) > 0, BandUnique: m.bandUniqueHeld},550- SeedTile: L.SeedTile,686+ SeedTile: m.geom[L.SeedTile].pubID,551687 K: "unmeasured",552688 Generator: generatorHash(),553689 },
@@ -556,17 +692,32 @@ func generateAttempt(ctx context.Context, p Params, L *pack.Layout, attempt int)
556692 for y := 0; y < m.G; y++ {557693 row := make([]byte, m.G)558694 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))695+ row[x] = '.'696+ for _, o := range m.owners[m.global.Idx(x, y)] {697+ if o < m.n {698+ row[x] = byte('0' + m.global.At(x, y))699+ break700+ }563701 }564702 }565703 out.Solution = append(out.Solution, string(row))566704 }567- for _, t := range tiles {568- out.Manifest.Tiles = append(out.Manifest.Tiles, t.public)569- out.Tiles = append(out.Tiles, t.private)705+ for _, t := range tiles[:m.n] {706+ pub := t.public707+ pub.ID = m.geom[t.id].pubID708+ pub.Overlaps = append([]OverlapPublic(nil), pub.Overlaps...)709+ for k := range pub.Overlaps {710+ pub.Overlaps[k].With = m.geom[pub.Overlaps[k].With].pubID711+ }712+ priv := t.private713+ priv.ID = pub.ID714+ out.Manifest.Tiles = append(out.Manifest.Tiles, pub)715+ out.Tiles = append(out.Tiles, priv)716+ }717+ if len(p.Fixed) > 0 || p.Region != [4]int{} {718+ out.Manifest.Packer.Params["region"] = p.Region719+ out.Manifest.Packer.Params["chunk"] = p.Chunk720+ out.Manifest.Packer.Params["id_base"] = p.IDBase570721 }571722 return out, nil572723 }
@@ -588,7 +739,7 @@ func (m *mosaic) fillSolution(ctx context.Context, tag string) error {
588739 tries := make([]int, total) // by tile id589740 fails := make([]int, total) // consecutive NOFITs by tile id590741 backjumps := 0591- for pos := 0; pos < total; {742+ for pos := m.firstNew; pos < total; {592743 if ctx.Err() != nil {593744 return ctx.Err()594745 }
@@ -609,11 +760,20 @@ func (m *mosaic) fillSolution(ctx context.Context, tag string) error {
609760 return errors.New("too many backjumps")610761 }611762 g := m.geom[i]612- if len(g.deps) == 0 {763+ // dependencies from latest to earliest in the fill order; fixed764+ // context tiles cannot be redrawn765+ var byRank []int766+ for _, d := range g.deps {767+ if !m.geom[d].fixed {768+ byRank = append(byRank, d)769+ }770+ }771+ if len(byRank) == 0 {772+ if len(g.deps) > 0 {773+ return fmt.Errorf("tile %d admits no completion against the fixed context", g.pubID)774+ }613775 return fmt.Errorf("tile %d admits no completion with nothing prefilled (bug)", i)614776 }615- // dependencies from latest to earliest in the fill order616- byRank := append([]int(nil), g.deps...)617777 sort.Slice(byRank, func(a, b int) bool { return m.geom[byRank[a]].rank > m.geom[byRank[b]].rank })618778 k := fails[i] / 2619779 fails[i]++
@@ -675,6 +835,7 @@ func (m *mosaic) commitGivens(t *tileWork) {
675835 }676836 for _, id := range t.ownSeam {677837 m.seamGivens[id] = t.ownSeamValue[id]838+ m.seamOwner[id] = t.id678839 }679840 }680841
@@ -1328,7 +1489,7 @@ func (m *mosaic) tileWithoutWitness(tiles []*tileWork) int {
13281489 }13291490 return v == m.global.At(gx, gy)13301491 }1331- for ti, t := range tiles {1492+ for ti, t := range tiles[:m.n] {13321493 g := m.geom[ti]13331494 f := g.sh.Local(m.global.Cells, m.G)13341495 for _, s := range t.sols {
M engine/gen/gen_test.go +63 -1
@@ -227,7 +227,9 @@ func checkAssertions(t *testing.T, o *Output) {
227227 sols[i] = s228228 }229229 for id, listed := range seamGiven {230- if len(listed) != len(owners[id]) {230+ // every owner lists a seam clue; in a field a clue on a cell shared231+ // with a tile of an earlier chunk is listed by the later tile only232+ if len(listed) != len(owners[id]) && (man.Field == nil || len(listed) > len(owners[id])) {231233 t.Fatalf("seam given %d listed by %d tiles, owned by %d", id, len(listed), len(owners[id]))232234 }233235 }
@@ -403,3 +405,63 @@ func TestFillOrder(t *testing.T) {
403405 t.Errorf("%d of %d fillers are filled after all of their >= 3 neighbours; want under 20%%", late, fillers)404406 }405407 }408+409+// TestField2x2 generates a 2x2 field of G=40 scatter chunks in process,410+// re-verifies every assertion from the merged JSON (including one coupled411+// solution over the whole field, and per colour: the first colour's chunks412+// unique alone, later ones unique given the earlier), and checks the413+// result does not depend on how many chunks ran at once.414+func TestField2x2(t *testing.T) {415+ p := FieldParams{Seed: "field", Chunks: 2, ChunkG: 40, Band: 16, Kind: "scatter", Jobs: 1}416+ a, err := GenerateField(context.Background(), p, InProcess)417+ if err != nil {418+ t.Fatal(err)419+ }420+ checkAssertions(t, a)421+ if a.Manifest.Field == nil || a.Manifest.Field.Chunks != 2 || len(a.Manifest.Field.SeedTiles) != 4 {422+ t.Fatalf("field info %+v", a.Manifest.Field)423+ }424+ if a.Manifest.G != 2*40-16 {425+ t.Fatalf("G=%d", a.Manifest.G)426+ }427+ // tiles straddle chunk borders: some overlap crosses a region border428+ plan, _ := PlanField(p)429+ chunkOf := func(tp TilePublic) int {430+ for i, r := range plan.Regions {431+ 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] {432+ return i433+ }434+ }435+ return -1436+ }437+ cross := 0438+ for _, tp := range a.Manifest.Tiles {439+ for _, ov := range tp.Overlaps {440+ if chunkOf(tp) != chunkOf(a.Manifest.Tiles[ov.With]) {441+ cross++442+ }443+ }444+ }445+ if cross == 0 {446+ t.Fatal("no overlap crosses a chunk border")447+ }448+ 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 {449+ var v []int450+ for _, s := range a.ChunkStats {451+ v = append(v, s.Attempts)452+ }453+ return v454+ }())455+ p.Jobs = 4456+ b, err := GenerateField(context.Background(), p, InProcess)457+ if err != nil {458+ t.Fatal(err)459+ }460+ a.Stats, b.Stats = Stats{}, Stats{}461+ a.ChunkStats, b.ChunkStats = nil, nil462+ ja, _ := json.Marshal(a)463+ jb, _ := json.Marshal(b)464+ if string(ja) != string(jb) {465+ t.Fatal("field differs with jobs=4")466+ }467+}
M engine/main.go +40 -1
@@ -3,7 +3,12 @@
33 // mosaic-engine gen [--seed s --G 100 --kind scatter --coverage 0.72 --compound 0.4 | --X 2 --n 14] (or JSON params on stdin)44 // mosaic-engine judge (JSON on stdin)55 // mosaic-engine pack [--seed s --G 100 --kind scatter|grid|big-first|bands --n 14]6+// mosaic-engine field --seed s --chunks 9 --chunk-G 60 [--band 16 --jobs 8 --coverage --compound]67 // mosaic-engine version (generator version; cache key)8+//9+// gen with no --seed reads its Params as JSON on stdin (the chunk mode10+// fields region, fixed, id_base, chunk included); field runs one such11+// process per chunk.712 package main813 914 import (
@@ -32,6 +37,8 @@ func main() {
3237 runJudge()3338 case "pack":3439 runPack(os.Args[2:])40+ case "field":41+ runField(os.Args[2:])3542 case "version":3643 fmt.Println(gen.Version)3744 default:
@@ -72,6 +79,35 @@ func runGen(args []string) {
7279 emit(out)7380 }7481 82+func runField(args []string) {83+ fs := flag.NewFlagSet("field", flag.ExitOnError)84+ seed := fs.String("seed", "a", "field seed")85+ chunks := fs.Int("chunks", 3, "chunks per side")86+ chunkG := fs.Int("chunk-G", 60, "cells per side of a chunk region")87+ band := fs.Int("band", 16, "overlap band between neighbouring chunk regions")88+ jobs := fs.Int("jobs", 4, "chunks generated at once (each is a process)")89+ retries := fs.Int("retries", 3, "re-packs of a chunk whose generation fails")90+ coverage := fs.Float64("coverage", 0, "scatter: target share of a region covered (default 0.72)")91+ compound := fs.Float64("compound", 0, "scatter: share of drops that are unions of two rectangles (default 0.4)")92+ timeout := fs.Duration("timeout", 2*time.Hour, "overall budget")93+ chunkTimeout := fs.Duration("chunk-timeout", 30*time.Minute, "budget per chunk process")94+ _ = fs.Parse(args)95+ exe, err := os.Executable()96+ if err != nil {97+ fail(err)98+ }99+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)100+ defer cancel()101+ out, err := gen.GenerateField(ctx, gen.FieldParams{102+ Seed: *seed, Chunks: *chunks, ChunkG: *chunkG, Band: *band, Kind: "scatter",103+ Coverage: *coverage, Compound: *compound, Jobs: *jobs, Retries: *retries,104+ }, gen.SubprocessRunner(exe, *chunkTimeout))105+ if err != nil {106+ fail(err)107+ }108+ emit(out)109+}110+75111 func runPack(args []string) {76112 fs := flag.NewFlagSet("pack", flag.ExitOnError)77113 seed := fs.String("seed", "a", "seed string")
@@ -81,8 +117,11 @@ func runPack(args []string) {
81117 bigArea := fs.Float64("big-area", 0.45, "big-first: share of area placed as large tiles first")82118 coverage := fs.Float64("coverage", 0, "scatter: target share of the field covered (default 0.72)")83119 compound := fs.Float64("compound", 0, "scatter: share of drops that are unions of two rectangles (default 0.4)")120+ maxHole := fs.Int("max-hole", 0, "scatter: fill holes larger than this many cells (default 24)")121+ minCells := fs.Int("min-cells", 0, "scatter: smallest tile (cells)")122+ maxCells := fs.Int("max-cells", 0, "scatter: largest tile (cells)")84123 _ = fs.Parse(args)85- L, err := pack.Pack(pack.Params{Seed: *seed, G: *G, Kind: *kind, N: *n, BigArea: *bigArea, Coverage: *coverage, Compound: *compound})124+ L, err := pack.Pack(pack.Params{Seed: *seed, G: *G, Kind: *kind, N: *n, BigArea: *bigArea, Coverage: *coverage, Compound: *compound, MaxHole: *maxHole, MinCells: *minCells, MaxCells: *maxCells})86125 if err != nil {87126 fail(err)88127 }