clone

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

engine: field --grow (rings of chunks on a fixed-G canvas) and --rings for the nightly job

dami committed 414d340
Chunks live on an integer lattice with an origin; the initial grid is
centred on a field of --G cells (default: exactly what the grid needs),
and cell ids never change. GrowField generates the ring around the
occupied set as a checkerboard against the existing tiles as context
and emits a delta: the new tiles only (ids continue), the merged
solution, the new ring's seed tiles, the field record with the ring
occupied; ApplyGrowth merges a delta into a state (old tiles gain the
reverse overlaps, appended, since pins index the lists). A ring whose
chunks all fall under existing tiles is exhausted. "field --grow --state
<private json> [--ring N]" emits one delta; "field ... --rings N|all"
emits {base, rings: [delta...]} for the nightly job.

Found on the way: a chunk region crowded by context placed its seed tile
on infeasible context after 200 misses (now: 2000 draws, then the fill
pass's shapes under the filler rule, then an empty layout, never a
silent overlap); a clipped region must keep MaxN cells beyond the band;
a joint completion refused by the neighbours falls back to one hop and
then to the tile alone (the neighbours backjump to it, which fixed
context cannot offer). Tests: 3x3 of G=40 on G=300 grown twice with
every assertion on the merged field, determinism with jobs 1 vs 4, and
the exhausted case.
commit
414d340c00f72159330754ce6ef05f459c08fd18
parent
dabf07be33ea276c7d0210ed94181386ea01b3ec
author
dami <gazey55@gmail.com>
5 files changed, +630 -97
Mengine/gen/field.go417++++++++++++++++++++++++------
Aengine/gen/field_grow_test.go131+++++++++
Mengine/gen/gen.go46+++-
Mengine/main.go66++++-
Mengine/pack/pack.go67++++-
M engine/gen/field.go +338 -79
@@ -24,16 +24,27 @@ import (
2424 // regions of neighbouring chunks overlap by a band so tiles can straddle2525 // the border, and a chunk generated later sees the earlier neighbours'2626 // 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 unique27+// like any solved neighbour). Chunks live on an integer lattice: chunk28+// (cx, cy) covers the region starting at origin + (cx, cy) * pitch, with29+// pitch = chunk_g - band. They are coloured by (cx mod 2, cy mod 2) and30+// generated colour by colour, so that chunks of one colour never share a31+// cell and run in parallel, and every chunk of a later colour is unique3032 // given its earlier neighbours. The whole field is therefore unique, and3133 // the output does not depend on how many chunks ran at once.34+//35+// A field grows: GrowField generates the ring of lattice cells around the36+// occupied set, against the existing tiles as context, and emits a delta37+// (the new tiles only, the merged solution). Cell ids are y*G+x on the38+// full field, which is allocated once (G) and never changes, so growth39+// never renumbers anything: new tile ids continue from the old ones.3240 3341 // FieldParams plans a field of Chunks x Chunks regions of ChunkG cells,34-// overlapping by Band cells; G = Chunks*ChunkG - (Chunks-1)*Band.42+// overlapping by Band cells, on a field of G cells per side (default43+// Chunks*ChunkG - (Chunks-1)*Band; a larger G centres the initial grid and44+// leaves room to grow).3545 type FieldParams struct {3646 Seed string `json:"seed"`47+ G int `json:"G,omitempty"`3748 Chunks int `json:"chunks"`3849 ChunkG int `json:"chunk_g"`3950 Band int `json:"band"`
@@ -45,17 +56,41 @@ type FieldParams struct {
4556 Timeout time.Duration4657 }4758 48-// FieldPlan is the geometry of a field's chunks.59+// FieldPlan is the geometry of a field's chunk lattice.4960 type FieldPlan struct {50- G int51- Chunks int52- ChunkG int53- Band int61+ G int62+ Chunks int63+ ChunkG int64+ Band int65+ Origin [2]int // cells: chunk (0,0) starts here66+ // the initial grid, in lattice coordinates 0..Chunks-15467 Regions [][4]int // per chunk index cy*Chunks+cx5568 Colour []int // (cx mod 2) + 2*(cy mod 2)5669 }5770 58-// PlanField lays out the chunk regions.71+func (p FieldPlan) pitch() int { return p.ChunkG - p.Band }72+73+// regionOf is the region of lattice chunk (cx, cy), clipped to the field;74+// ok is false when less than pack.MaxN cells remain on either side.75+func (p FieldPlan) regionOf(cx, cy int) (r [4]int, ok bool) {76+ x0 := p.Origin[0] + cx*p.pitch()77+ y0 := p.Origin[1] + cy*p.pitch()78+ x1, y1 := x0+p.ChunkG, y0+p.ChunkG79+ x0, y0 = max(x0, 0), max(y0, 0)80+ x1, y1 = min(x1, p.G), min(y1, p.G)81+ // a clipped region must keep at least MaxN cells beyond the band it82+ // shares with a neighbour, or there is nothing to place in it83+ if x1-x0 < p.Band+pack.MaxN || y1-y0 < p.Band+pack.MaxN {84+ return r, false85+ }86+ return [4]int{x0, y0, x1 - x0, y1 - y0}, true87+}88+89+func colourOf(cx, cy int) int {90+ return ((cx%2)+2)%2 + 2*(((cy%2)+2)%2)91+}92+93+// PlanField lays out the initial chunk grid.5994 func PlanField(p FieldParams) (FieldPlan, error) {6095 if p.Chunks < 1 || p.ChunkG < pack.MaxN {6196 return FieldPlan{}, fmt.Errorf("field needs chunks >= 1 and chunk_g >= %d", pack.MaxN)
@@ -67,22 +102,42 @@ func PlanField(p FieldParams) (FieldPlan, error) {
67102 return FieldPlan{}, fmt.Errorf("band %d too wide for chunk_g %d", p.Band, p.ChunkG)68103 }69104 pitch := p.ChunkG - p.Band70- plan := FieldPlan{G: p.Chunks*pitch + p.Band, Chunks: p.Chunks, ChunkG: p.ChunkG, Band: p.Band}105+ def := p.Chunks*pitch + p.Band106+ G := p.G107+ if G == 0 {108+ G = def109+ }110+ if G < def {111+ return FieldPlan{}, fmt.Errorf("G=%d smaller than the %d the chunk grid needs", G, def)112+ }113+ plan := FieldPlan{G: G, Chunks: p.Chunks, ChunkG: p.ChunkG, Band: p.Band}114+ plan.Origin = [2]int{(G - def) / 2, (G - def) / 2}71115 for cy := 0; cy < p.Chunks; cy++ {72116 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))117+ r, ok := plan.regionOf(cx, cy)118+ if !ok {119+ return FieldPlan{}, fmt.Errorf("chunk (%d,%d) does not fit", cx, cy)120+ }121+ plan.Regions = append(plan.Regions, r)122+ plan.Colour = append(plan.Colour, colourOf(cx, cy))75123 }76124 }77125 return plan, nil78126 }79127 128+// planOf rebuilds the lattice plan from a field's public record.129+func planOf(m *Manifest) (FieldPlan, error) {130+ f := m.Field131+ if f == nil {132+ return FieldPlan{}, errors.New("not a field: manifest has no field record")133+ }134+ return FieldPlan{G: m.G, Chunks: f.Chunks, ChunkG: f.ChunkG, Band: f.Band, Origin: f.Origin}, nil135+}136+80137 // Runner generates one chunk (or any board) from its params.81138 type Runner func(ctx context.Context, p Params) (*Output, error)82139 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.140+// InProcess runs a chunk in this process.86141 func InProcess(ctx context.Context, p Params) (*Output, error) {87142 return Generate(ctx, p)88143 }
@@ -136,46 +191,232 @@ func chunkNonce(seed string, chunk, retry int) string {
136191 // provisional ids keep chunks' tiles apart before the merge renumbers137192 const idStride = 100000138193 139-// GenerateField generates every chunk (colour by colour, Jobs at a time)140-// with the runner and merges them into one Output.194+// errEmptyRing: every chunk of the batch generated nothing (its region was195+// already covered by context); for a ring that means exhausted.196+var errEmptyRing = errors.New("empty ring")197+198+// latticeChunk is one chunk to generate: its lattice coordinates, region199+// and colour; idx orders it deterministically within its batch.200+type latticeChunk struct {201+ cx, cy int202+ idx int203+ region [4]int204+ colour int205+}206+207+// GenerateField generates the initial chunk grid (colour by colour, Jobs208+// at a time) with the runner and merges it into one Output.141209 func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, error) {142- if p.Kind == "" {143- p.Kind = "scatter"144- }210+ p = fieldDefaults(p)145211 if p.Kind != "scatter" {146212 return nil, fmt.Errorf("field chunks must be scatter, not %q", p.Kind)147213 }214+ plan, err := PlanField(p)215+ if err != nil {216+ return nil, err217+ }218+ start := time.Now()219+ var chunks []latticeChunk220+ for i, r := range plan.Regions {221+ chunks = append(chunks, latticeChunk{cx: i % p.Chunks, cy: i / p.Chunks, idx: i, region: r, colour: plan.Colour[i]})222+ }223+ outs, err := generateChunks(ctx, p, plan, chunks, nil, nil, p.Seed, 0, run)224+ if err != nil {225+ return nil, err226+ }227+ out, err := mergeChunks(p, plan, chunks, outs, nil, nil, 0)228+ if err != nil {229+ return nil, err230+ }231+ out.Manifest.Field.Occupied = make([][2]int, 0, len(chunks))232+ for _, c := range chunks {233+ out.Manifest.Field.Occupied = append(out.Manifest.Field.Occupied, [2]int{c.cx, c.cy})234+ }235+ out.Stats.Ms = time.Since(start).Milliseconds()236+ return out, nil237+}238+239+func fieldDefaults(p FieldParams) FieldParams {240+ if p.Kind == "" {241+ p.Kind = "scatter"242+ }148243 if p.Jobs <= 0 {149244 p.Jobs = 4150245 }151246 if p.Retries <= 0 {152247 p.Retries = 3153248 }154- plan, err := PlanField(p)249+ if p.Band <= 0 {250+ p.Band = 16251+ }252+ return p253+}254+255+// GrowField generates the ring of lattice chunks around the field's256+// occupied set (ring 1 is the 8-neighbourhood; ring 2 the one beyond) and257+// returns a delta Output: only the new tiles (ids continuing from the258+// existing ones; overlaps may reference old ids), the merged solution,259+// the new chunks' seed tiles, and the field record with the ring added.260+// No existing tile's cell changes; that is asserted. When no chunk of the261+// ring fits on the field, the delta is empty and Stats.Exhausted is set.262+func GrowField(ctx context.Context, state *Output, p FieldParams, ring int, run Runner) (*Output, error) {263+ p = fieldDefaults(p)264+ plan, err := planOf(&state.Manifest)155265 if err != nil {156266 return nil, err157267 }268+ if len(state.Solution) != plan.G {269+ return nil, fmt.Errorf("state has %d solution rows for G=%d", len(state.Solution), plan.G)270+ }271+ if ring < 1 {272+ ring = 1273+ }274+ f := state.Manifest.Field275+ occupied := map[[2]int]bool{}276+ for _, c := range f.Occupied {277+ occupied[c] = true278+ }158279 start := time.Now()159- nChunks := len(plan.Regions)160- outs := make([]*Output, nChunks)280+ k := f.Grown + 1281+ // the ring: lattice cells within `ring` of the occupied set, not282+ // occupied, whose region fits; the innermost band first so every new283+ // chunk touches context284+ var chunks []latticeChunk285+ for d := 1; d <= ring; d++ {286+ var layer [][2]int287+ seenL := map[[2]int]bool{}288+ for c := range occupied {289+ for dy := -d; dy <= d; dy++ {290+ for dx := -d; dx <= d; dx++ {291+ n := [2]int{c[0] + dx, c[1] + dy}292+ if occupied[n] || seenL[n] {293+ continue294+ }295+ seenL[n] = true296+ layer = append(layer, n)297+ }298+ }299+ }300+ sort.Slice(layer, func(a, b int) bool {301+ if layer[a][1] != layer[b][1] {302+ return layer[a][1] < layer[b][1]303+ }304+ return layer[a][0] < layer[b][0]305+ })306+ for _, n := range layer {307+ r, ok := plan.regionOf(n[0], n[1])308+ if !ok {309+ continue310+ }311+ chunks = append(chunks, latticeChunk{cx: n[0], cy: n[1], idx: len(chunks), region: r, colour: colourOf(n[0], n[1])})312+ occupied[n] = true313+ }314+ }315+ base := len(state.Manifest.Tiles)316+ if len(chunks) == 0 {317+ out := &Output{Manifest: state.Manifest, Solution: state.Solution, Stats: Stats{Exhausted: true}}318+ out.Manifest.Tiles = nil319+ out.Manifest.Field = cloneField(f)320+ out.Manifest.Field.SeedTiles = nil321+ out.Manifest.Field.Grown = k322+ out.Stats.Ms = time.Since(start).Milliseconds()323+ return out, nil324+ }325+ salt := fmt.Sprintf("%s|grow|%d", p.Seed, k)326+ outs, err := generateChunks(ctx, p, plan, chunks, state.Manifest.Tiles, state.Solution, salt, base, run)327+ if err != nil {328+ return nil, err329+ }330+ out, err := mergeChunks(p, plan, chunks, outs, state.Manifest.Tiles, state.Solution, base)331+ if errors.Is(err, errEmptyRing) {332+ out := &Output{Manifest: state.Manifest, Solution: state.Solution, Stats: Stats{Exhausted: true}}333+ out.Manifest.Tiles = nil334+ out.Manifest.Field = cloneField(f)335+ out.Manifest.Field.SeedTiles = nil336+ out.Manifest.Field.Grown = k337+ out.Stats.Ms = time.Since(start).Milliseconds()338+ return out, nil339+ }340+ if err != nil {341+ return nil, err342+ }343+ out.Manifest.ID, out.Manifest.SeedCommit = state.Manifest.ID, state.Manifest.SeedCommit344+ out.Manifest.Packer = state.Manifest.Packer345+ out.Manifest.Field.Origin = f.Origin346+ out.Manifest.Field.Occupied = append(append([][2]int(nil), f.Occupied...), func() [][2]int {347+ var v [][2]int348+ for _, c := range chunks {349+ v = append(v, [2]int{c.cx, c.cy})350+ }351+ return v352+ }()...)353+ out.Manifest.Field.Grown = k354+ out.Stats.Ms = time.Since(start).Milliseconds()355+ return out, nil356+}357+358+func cloneField(f *FieldPublic) *FieldPublic {359+ c := *f360+ c.SeedTiles = append([]int(nil), f.SeedTiles...)361+ c.Occupied = append([][2]int(nil), f.Occupied...)362+ return &c363+}364+365+// ApplyGrowth merges a growth delta into a field state: tiles appended,366+// solution replaced, field record replaced. Old tiles gain the reverse367+// overlap entries the delta's new tiles declare against them.368+func ApplyGrowth(state, delta *Output) (*Output, error) {369+ if len(delta.Manifest.Tiles) > 0 && delta.Manifest.Tiles[0].ID != len(state.Manifest.Tiles) {370+ return nil, fmt.Errorf("delta's first tile id %d, state has %d tiles", delta.Manifest.Tiles[0].ID, len(state.Manifest.Tiles))371+ }372+ out := *state373+ out.Manifest.Tiles = append(append([]TilePublic(nil), state.Manifest.Tiles...), delta.Manifest.Tiles...)374+ out.Tiles = append(append([]TilePrivate(nil), state.Tiles...), delta.Tiles...)375+ // deep-copy old overlap lists before extending them376+ for k := range state.Manifest.Tiles {377+ out.Manifest.Tiles[k].Overlaps = append([]OverlapPublic(nil), state.Manifest.Tiles[k].Overlaps...)378+ }379+ for _, t := range delta.Manifest.Tiles {380+ for _, ov := range t.Overlaps {381+ if ov.With < len(state.Manifest.Tiles) {382+ o := &out.Manifest.Tiles[ov.With]383+ o.Overlaps = append(o.Overlaps, OverlapPublic{With: t.ID, Cells: ov.Cells})384+ }385+ }386+ }387+ // (old lists are appended to, never reordered: pins index them)388+ out.Solution = delta.Solution389+ if delta.Manifest.Field != nil {390+ out.Manifest.Field = cloneField(delta.Manifest.Field)391+ out.Manifest.Field.SeedTiles = append(append([]int(nil), state.Manifest.Field.SeedTiles...), delta.Manifest.Field.SeedTiles...)392+ }393+ out.Manifest.Rules.SeamClues = state.Manifest.Rules.SeamClues || delta.Manifest.Rules.SeamClues394+ out.Layout = delta.Layout395+ out.Layout.Tiles = len(out.Manifest.Tiles)396+ return &out, nil397+}398+399+// generateChunks runs the chunks colour by colour, Jobs at a time; every400+// chunk sees the base tiles and the finished chunks intersecting its401+// region as fixed context. Provisional ids: idBase + (idx+1)*idStride.402+func generateChunks(ctx context.Context, p FieldParams, plan FieldPlan, chunks []latticeChunk, baseTiles []TilePublic, baseSol []string, salt string, idBase int, run Runner) ([]*Output, error) {403+ outs := make([]*Output, len(chunks))161404 var mu sync.Mutex162405 for colour := 0; colour < 4; colour++ {163406 var idx []int164- for i, c := range plan.Colour {165- if c == colour {407+ for i, c := range chunks {408+ if c.colour == colour {166409 idx = append(idx, i)167410 }168411 }169412 if len(idx) == 0 {170413 continue171414 }172- // context for each chunk of this colour: every finished tile that173- // intersects its region174- fixedFor := make([][]FixedTile, nChunks)415+ fixedFor := make([][]FixedTile, len(chunks))175416 for _, i := range idx {176- fixedFor[i] = contextFor(plan, outs, i)417+ fixedFor[i] = contextFor(chunks[i].region, outs, baseTiles, baseSol)177418 }178- errs := make([]error, nChunks)419+ errs := make([]error, len(chunks))179420 sem := make(chan struct{}, p.Jobs)180421 var wg sync.WaitGroup181422 for _, i := range idx {
@@ -191,9 +432,9 @@ func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, err
191432 break192433 }193434 gp := Params{194- Seed: p.Seed, Nonce: chunkNonce(p.Seed, i, retry), G: plan.G, Kind: p.Kind,435+ Seed: p.Seed, Nonce: chunkNonce(salt, chunks[i].idx, retry), G: plan.G, Kind: p.Kind,195436 Coverage: p.Coverage, Compound: p.Compound,196- Region: plan.Regions[i], Fixed: fixedFor[i], IDBase: (i + 1) * idStride, Chunk: i,437+ Region: chunks[i].region, Fixed: fixedFor[i], IDBase: idBase + (chunks[i].idx+1)*idStride, Chunk: chunks[i].idx,197438 }198439 o, err := run(ctx, gp)199440 if err == nil {
@@ -205,7 +446,7 @@ func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, err
205446 }206447 last = err207448 if Verbose {208- fmt.Fprintf(os.Stderr, "field: chunk %d retry %d: %v\n", i, retry, err)449+ fmt.Fprintf(os.Stderr, "field: chunk (%d,%d) retry %d: %v\n", chunks[i].cx, chunks[i].cy, retry, err)209450 }210451 }211452 errs[i] = last
@@ -214,103 +455,121 @@ func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, err
214455 wg.Wait()215456 for _, i := range idx {216457 if errs[i] != nil {217- return nil, fmt.Errorf("chunk %d (colour %d, region %v) failed after %d packings: %w", i, colour, plan.Regions[i], p.Retries, errs[i])458+ return nil, fmt.Errorf("chunk (%d,%d) (colour %d, region %v) failed after %d packings: %w", chunks[i].cx, chunks[i].cy, colour, chunks[i].region, p.Retries, errs[i])218459 }219460 }220461 }221- out, err := mergeField(p, plan, outs)222- if err != nil {223- return nil, err462+ return outs, nil463+}464+465+func fixedTile(t TilePublic, sol []string) FixedTile {466+ 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}467+ sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}468+ cells := make([]rules.Cell, t.W*t.H)469+ for li, ok := range sh.Mask() {470+ cells[li] = rules.Empty471+ if ok {472+ cells[li] = rules.Cell(sol[t.Y0+li/t.W][t.X0+li%t.W] - '0')473+ }224474 }225- out.Stats.Ms = time.Since(start).Milliseconds()226- return out, nil475+ return FixedTile{Tile: pt, Cells: cells}476+}477+478+func intersects(t TilePublic, r [4]int) bool {479+ return !(t.X0 >= r[0]+r[2] || t.X0+t.W <= r[0] || t.Y0 >= r[1]+r[3] || t.Y0+t.H <= r[1])227480 }228481 229-// contextFor collects the finished tiles intersecting chunk i's region as230-// fixed context, with their solutions read from their chunk's output.231-func contextFor(plan FieldPlan, outs []*Output, i int) []FixedTile {232- r := plan.Regions[i]482+// contextFor collects the tiles intersecting a region as fixed context:483+// the base field's tiles and the finished chunks' tiles.484+func contextFor(r [4]int, outs []*Output, baseTiles []TilePublic, baseSol []string) []FixedTile {233485 var fixed []FixedTile486+ for _, t := range baseTiles {487+ if intersects(t, r) {488+ fixed = append(fixed, fixedTile(t, baseSol))489+ }490+ }234491 for _, o := range outs {235492 if o == nil {236493 continue237494 }238495 for _, t := range o.Manifest.Tiles {239- 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] {240- continue496+ if intersects(t, r) {497+ fixed = append(fixed, fixedTile(t, o.Solution))241498 }242- 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}243- sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}244- cells := make([]rules.Cell, t.W*t.H)245- for li, ok := range sh.Mask() {246- cells[li] = rules.Empty247- if ok {248- cells[li] = rules.Cell(o.Solution[t.Y0+li/t.W][t.X0+li%t.W] - '0')249- }250- }251- fixed = append(fixed, FixedTile{Tile: pt, Cells: cells})252499 }253500 }254501 sort.Slice(fixed, func(a, b int) bool { return fixed[a].Tile.ID < fixed[b].Tile.ID })255502 return fixed256503 }257504 258-// mergeField renumbers the chunks' tiles densely in chunk order, remaps259-// overlaps (adding the reverse entry a chunk could not know about),260-// overlays the solutions and assembles one manifest.261-func mergeField(p FieldParams, plan FieldPlan, outs []*Output) (*Output, error) {505+// mergeChunks renumbers the chunks' tiles densely from idBase in chunk506+// order, remaps overlaps (adding the reverse entry a chunk could not know507+// about, among the new tiles), overlays the solutions on the base and508+// assembles one manifest holding only the new tiles.509+func mergeChunks(p FieldParams, plan FieldPlan, chunks []latticeChunk, outs []*Output, baseTiles []TilePublic, baseSol []string, idBase int) (*Output, error) {262510 G := plan.G263511 final := map[int]int{}512+ for _, t := range baseTiles {513+ final[t.ID] = t.ID514+ }264515 var tiles []TilePublic265516 var priv []TilePrivate266- seedTiles := make([]int, len(outs))517+ var seedTiles []int267518 var chunkStats []Stats268519 for i, o := range outs {269520 if o == nil {270521 return nil, fmt.Errorf("chunk %d missing", i)271522 }272523 for k, t := range o.Manifest.Tiles {273- final[t.ID] = len(tiles)524+ final[t.ID] = idBase + len(tiles)274525 tiles = append(tiles, t)275526 priv = append(priv, o.Tiles[k])276527 }277- seedTiles[i] = final[o.Manifest.SeedTile]528+ if len(o.Manifest.Tiles) > 0 {529+ seedTiles = append(seedTiles, final[o.Manifest.SeedTile])530+ }278531 chunkStats = append(chunkStats, o.Stats)279532 }280- // remap ids and overlaps; collect reverse overlaps281533 type key struct{ a, b int }282534 seen := map[key]bool{}283535 reverse := map[int][]OverlapPublic{}284536 for k := range tiles {285537 t := &tiles[k]286- t.ID = k287- priv[k].ID = k538+ id := idBase + k539+ t.ID = id540+ priv[k].ID = id288541 ovs := make([]OverlapPublic, len(t.Overlaps))289542 for j, ov := range t.Overlaps {290543 w, ok := final[ov.With]291544 if !ok {292- return nil, fmt.Errorf("tile %d overlaps unknown tile %d", k, ov.With)545+ return nil, fmt.Errorf("tile %d overlaps unknown tile %d", id, ov.With)293546 }294547 ovs[j] = OverlapPublic{With: w, Cells: ov.Cells}295- seen[key{k, w}] = true548+ seen[key{id, w}] = true296549 }297550 t.Overlaps = ovs298551 }299- for k, t := range tiles {552+ for _, t := range tiles {300553 for _, ov := range t.Overlaps {301- if !seen[key{ov.With, k}] {302- reverse[ov.With] = append(reverse[ov.With], OverlapPublic{With: k, Cells: ov.Cells})554+ if ov.With >= idBase && !seen[key{ov.With, t.ID}] {555+ reverse[ov.With] = append(reverse[ov.With], OverlapPublic{With: t.ID, Cells: ov.Cells})303556 }304557 }305558 }559+ // pins index into a tile's overlap list, so an existing list is never560+ // reordered: reverse entries are appended (sorted among themselves)306561 for w, extra := range reverse {307562 sort.Slice(extra, func(a, b int) bool { return extra[a].With < extra[b].With })308- tiles[w].Overlaps = append(tiles[w].Overlaps, extra...)563+ tiles[w-idBase].Overlaps = append(tiles[w-idBase].Overlaps, extra...)309564 }310- // solution overlay565+ // solution overlay on the base; a base cell never changes311566 sol := make([][]byte, G)312567 for y := range sol {313- sol[y] = bytes.Repeat([]byte{'.'}, G)568+ if baseSol != nil {569+ sol[y] = []byte(baseSol[y])570+ } else {571+ sol[y] = bytes.Repeat([]byte{'.'}, G)572+ }314573 }315574 for i, o := range outs {316575 if len(o.Solution) != G {
@@ -351,6 +610,9 @@ func mergeField(p FieldParams, plan FieldPlan, outs []*Output) (*Output, error)
351610 }352611 }353612 }613+ if len(tiles) == 0 {614+ return nil, errEmptyRing615+ }354616 out := &Output{355617 Manifest: Manifest{356618 Version: 3,
@@ -364,18 +626,15 @@ func mergeField(p FieldParams, plan FieldPlan, outs []*Output) (*Output, error)
364626 Tiles: tiles,365627 K: "unmeasured",366628 Generator: generatorHash(),367- Field: &FieldPublic{Chunks: plan.Chunks, ChunkG: plan.ChunkG, Band: plan.Band, SeedTiles: seedTiles},629+ Field: &FieldPublic{Chunks: plan.Chunks, ChunkG: plan.ChunkG, Band: plan.Band, Origin: plan.Origin, SeedTiles: seedTiles},368630 },369631 Tiles: priv,370632 Stats: Stats{Attempts: attempts, Regivens: regivens, Backjumps: backjumps, Waves: waves},371- Layout: pack.Report{Tiles: len(tiles), Coverage: float64(covered) / float64(G*G)},633+ Layout: pack.Report{Tiles: idBase + len(tiles), Coverage: float64(covered) / float64(G*G)},372634 ChunkStats: chunkStats,373635 }374636 for y := range sol {375637 out.Solution = append(out.Solution, string(sol[y]))376638 }377- if len(tiles) == 0 {378- return nil, errors.New("empty field")379- }380639 return out, nil381640 }
A engine/gen/field_grow_test.go +131 -0
@@ -0,0 +1,131 @@
1+package gen2+3+import (4+ "context"5+ "encoding/json"6+ "testing"7+)8+9+// TestFieldGrow: a 3x3 of G=40 chunks centred on a G=300 field grown twice.10+// The merged field passes every assertion; no old cell changes; ids11+// continue; the result does not depend on jobs; a full field reports12+// exhausted.13+func TestFieldGrow(t *testing.T) {14+ p := FieldParams{Seed: "grow", G: 300, Chunks: 3, ChunkG: 40, Band: 16, Kind: "scatter", Jobs: 1}15+ base, err := GenerateField(context.Background(), p, InProcess)16+ if err != nil {17+ t.Fatal(err)18+ }19+ f := base.Manifest.Field20+ if f.Origin != [2]int{(300 - (3*24 + 16)) / 2, (300 - (3*24 + 16)) / 2} || len(f.Occupied) != 9 || f.Grown != 0 {21+ t.Fatalf("field record %+v", f)22+ }23+ if base.Manifest.G != 300 {24+ t.Fatalf("G=%d", base.Manifest.G)25+ }26+ checkAssertions(t, base)27+ state := base28+ for k := 1; k <= 2; k++ {29+ delta, err := GrowField(context.Background(), state, p, 1, InProcess)30+ if err != nil {31+ t.Fatalf("grow %d: %v", k, err)32+ }33+ if delta.Stats.Exhausted {34+ t.Fatalf("grow %d: exhausted on a field with room", k)35+ }36+ if len(delta.Manifest.Tiles) == 0 || delta.Manifest.Tiles[0].ID != len(state.Manifest.Tiles) {37+ t.Fatalf("grow %d: delta ids start at %d, state has %d tiles", k, delta.Manifest.Tiles[0].ID, len(state.Manifest.Tiles))38+ }39+ if delta.Manifest.Field.Grown != k || len(delta.Manifest.Field.SeedTiles) == 0 {40+ t.Fatalf("grow %d: field record %+v", k, delta.Manifest.Field)41+ }42+ // no old cell changes43+ for y, row := range state.Solution {44+ for x := range row {45+ if row[x] != '.' && delta.Solution[y][x] != row[x] {46+ t.Fatalf("grow %d changed old cell (%d,%d)", k, x, y)47+ }48+ }49+ }50+ // every new tile overlaps something (old or new) by a whole line's worth51+ for _, tp := range delta.Manifest.Tiles {52+ ok := false53+ for _, ov := range tp.Overlaps {54+ if ov.Cells >= 4 {55+ ok = true56+ }57+ }58+ if !ok {59+ t.Fatalf("grow %d: new tile %d has no pin-eligible overlap", k, tp.ID)60+ }61+ }62+ merged, err := ApplyGrowth(state, delta)63+ if err != nil {64+ t.Fatal(err)65+ }66+ expect := 9 + 1667+ if k == 2 {68+ expect += 2469+ }70+ if len(merged.Manifest.Field.Occupied) != expect {71+ t.Fatalf("grow %d: %d chunks occupied, want %d", k, len(merged.Manifest.Field.Occupied), expect)72+ }73+ checkAssertions(t, merged)74+ t.Logf("grow %d: +%d tiles (%d total), %d chunks, coverage %.2f, %d ms", k, len(delta.Manifest.Tiles), len(merged.Manifest.Tiles), len(delta.ChunkStats), merged.Layout.Coverage, delta.Stats.Ms)75+ state = merged76+ }77+}78+79+// TestFieldGrowDeterministic: a ring generated with jobs=1 and jobs=4 is80+// byte-identical (on a small 2x2 base so the suite stays fast).81+func TestFieldGrowDeterministic(t *testing.T) {82+ p := FieldParams{Seed: "det", G: 200, Chunks: 2, ChunkG: 40, Band: 16, Kind: "scatter", Jobs: 1}83+ base, err := GenerateField(context.Background(), p, InProcess)84+ if err != nil {85+ t.Fatal(err)86+ }87+ p4 := p88+ p4.Jobs = 489+ d1, err := GrowField(context.Background(), base, p, 1, InProcess)90+ if err != nil {91+ t.Fatal(err)92+ }93+ d4, err := GrowField(context.Background(), base, p4, 1, InProcess)94+ if err != nil {95+ t.Fatal(err)96+ }97+ if d1.Stats.Exhausted || len(d1.Manifest.Tiles) == 0 {98+ t.Fatal("ring 1 generated nothing")99+ }100+ d1.Stats, d4.Stats = Stats{}, Stats{}101+ d1.ChunkStats, d4.ChunkStats = nil, nil102+ j1, _ := json.Marshal(d1)103+ j4, _ := json.Marshal(d4)104+ if string(j1) != string(j4) {105+ t.Fatal("growth differs with jobs=4")106+ }107+}108+109+// TestFieldGrowExhausted: a field with no room around its grid reports110+// exhausted and changes nothing.111+func TestFieldGrowExhausted(t *testing.T) {112+ p := FieldParams{Seed: "full", Chunks: 2, ChunkG: 40, Band: 16, Kind: "scatter", Jobs: 1}113+ base, err := GenerateField(context.Background(), p, InProcess)114+ if err != nil {115+ t.Fatal(err)116+ }117+ delta, err := GrowField(context.Background(), base, p, 1, InProcess)118+ if err != nil {119+ t.Fatal(err)120+ }121+ if !delta.Stats.Exhausted || len(delta.Manifest.Tiles) != 0 || delta.Manifest.Field.Grown != 1 {122+ t.Fatalf("want an empty exhausted delta, got %d tiles exhausted=%v grown=%d", len(delta.Manifest.Tiles), delta.Stats.Exhausted, delta.Manifest.Field.Grown)123+ }124+ merged, err := ApplyGrowth(base, delta)125+ if err != nil {126+ t.Fatal(err)127+ }128+ if len(merged.Manifest.Tiles) != len(base.Manifest.Tiles) {129+ t.Fatal("exhausted growth changed the tile count")130+ }131+}
M engine/gen/gen.go +39 -7
@@ -95,10 +95,13 @@ type FixedTile struct {
9595 9696 // FieldPublic describes a field assembled from chunks (manifest.field).9797 type FieldPublic struct {98- Chunks int `json:"chunks"` // chunks per side99- ChunkG int `json:"chunk_g"` // cells per side of a chunk region100- Band int `json:"band"` // overlap band between neighbouring regions101- SeedTiles []int `json:"seed_tiles"`98+ Chunks int `json:"chunks"` // chunks per side of the initial grid99+ ChunkG int `json:"chunk_g"` // cells per side of a chunk region100+ Band int `json:"band"` // overlap band between neighbouring regions101+ Origin [2]int `json:"origin"` // cells: lattice chunk (0,0) starts here; chunk (cx,cy) at origin + (cx,cy)*(chunk_g-band)102+ Occupied [][2]int `json:"occupied"` // lattice chunks generated so far103+ Grown int `json:"grown"` // rings added by GrowField104+ SeedTiles []int `json:"seed_tiles"`102105 }103106 104107 type Rules struct {
@@ -203,9 +206,10 @@ type Stats struct {
203206 Regivens int `json:"regivens"`204207 Releases int `json:"releases"` // repairs that took back neighbours' seam clues205208 Waves int `json:"waves"`206- WaveRetries int `json:"wave_retries"` // tiles whose givens needed a fresh draw within the attempt207- Redraws int `json:"redraws"` // stuck tiles whose interior solution was redrawn in repair208- FillRetries int `json:"fill_retries"` // fill phases redrawn after thrashing on backjumps209+ WaveRetries int `json:"wave_retries"` // tiles whose givens needed a fresh draw within the attempt210+ Redraws int `json:"redraws"` // stuck tiles whose interior solution was redrawn in repair211+ FillRetries int `json:"fill_retries"` // fill phases redrawn after thrashing on backjumps212+ Exhausted bool `json:"exhausted,omitempty"` // GrowField: no chunk of the ring fits on the field209213 // phase timings of the successful attempt (wall clock) and of the210214 // attempts wasted before it211215 FillMs int64 `json:"fill_ms"`
@@ -357,6 +361,17 @@ func Generate(ctx context.Context, p Params) (*Output, error) {
357361 if err != nil {358362 return nil, err359363 }364+ if len(L.Tiles) == 0 {365+ // a chunk region the context already covers: nothing to generate366+ commit := sha256.Sum256([]byte(p.Seed + "|" + p.Nonce))367+ out := &Output{Manifest: Manifest{Version: 3, ID: hex.EncodeToString(commit[:]), SeedCommit: hex.EncodeToString(commit[:]), G: p.G,368+ Packer: PackerInfo{Kind: L.Kind, Params: map[string]any{}}, MByN: map[string]int{}, Rules: Rules{Overlaps: true}, SeedTile: -1, Tiles: []TilePublic{}, K: "unmeasured", Generator: generatorHash()},369+ Tiles: []TilePrivate{}, Layout: L.Report}370+ for y := 0; y < p.G; y++ {371+ out.Solution = append(out.Solution, strings.Repeat(".", p.G))372+ }373+ return out, nil374+ }360375 start := time.Now()361376 satCalls0, satNs0 := sat.Counters()362377 var last error
@@ -1241,6 +1256,11 @@ func (m *mosaic) fillTile(ctx context.Context, tag string, i, refill int) (bool,
12411256 }12421257 }12431258 }1259+ // the one-hop region, for the fallback below1260+ hop1Shapes := make([]rules.Shape, 0, len(hop1))1261+ for _, j := range hop1 {1262+ hop1Shapes = append(hop1Shapes, m.geom[j].sh)1263+ }12441264 const tries = 16 // only band collisions need a second draw12451265 var collided []rules.Cell12461266 for t := 0; t < tries; t++ {
@@ -1249,6 +1269,18 @@ func (m *mosaic) fillTile(ctx context.Context, tag string, i, refill int) (bool,
12491269 }12501270 rng := rngFor(tag, "fill", fmt.Sprint(i), fmt.Sprint(refill), fmt.Sprint(t))12511271 full, ok := sat.CompleteRegion(m.global.Cells, m.G, later, g.sh, rng)1272+ if !ok && len(later) > 0 {1273+ // the joint region refused; the tile alone may still complete1274+ // (the neighbours' joint infeasibility is theirs to backjump1275+ // on, and this tile, unlike fixed context, can be redrawn).1276+ // Try one hop, then the tile by itself.1277+ if len(hop1Shapes) < len(later) {1278+ full, ok = sat.CompleteRegion(m.global.Cells, m.G, hop1Shapes, g.sh, rng)1279+ }1280+ if !ok {1281+ full, ok = sat.CompleteRegion(m.global.Cells, m.G, nil, g.sh, rng)1282+ }1283+ }12521284 if !ok {12531285 return false, nil12541286 }
M engine/main.go +61 -5
@@ -82,13 +82,18 @@ func runGen(args []string) {
8282 func runField(args []string) {8383 fs := flag.NewFlagSet("field", flag.ExitOnError)8484 seed := fs.String("seed", "a", "field seed")85- chunks := fs.Int("chunks", 3, "chunks per side")85+ G := fs.Int("G", 0, "cells per side of the whole field (default: what the chunk grid needs; larger centres the grid and leaves room to grow)")86+ chunks := fs.Int("chunks", 3, "chunks per side of the initial grid")8687 chunkG := fs.Int("chunk-G", 60, "cells per side of a chunk region")8788 band := fs.Int("band", 16, "overlap band between neighbouring chunk regions")8889 jobs := fs.Int("jobs", 4, "chunks generated at once (each is a process)")8990 retries := fs.Int("retries", 3, "re-packs of a chunk whose generation fails")9091 coverage := fs.Float64("coverage", 0, "scatter: target share of a region covered (default 0.72)")9192 compound := fs.Float64("compound", 0, "scatter: share of drops that are unions of two rectangles (default 0.4)")93+ grow := fs.Bool("grow", false, "grow an existing field: read its private output from --state and emit the delta for the next ring")94+ rings := fs.String("rings", "", "with the initial generation: also generate this many rings (or 'all' until the field is exhausted) and emit {base, rings: [delta...]}")95+ state := fs.String("state", "", "with --grow: file holding the field's private output (gen JSON)")96+ ring := fs.Int("ring", 1, "with --grow: how many rings of chunks to add")9297 timeout := fs.Duration("timeout", 2*time.Hour, "overall budget")9398 chunkTimeout := fs.Duration("chunk-timeout", 30*time.Minute, "budget per chunk process")9499 _ = fs.Parse(args)
@@ -98,14 +103,65 @@ func runField(args []string) {
98103 }99104 ctx, cancel := context.WithTimeout(context.Background(), *timeout)100105 defer cancel()101- out, err := gen.GenerateField(ctx, gen.FieldParams{102- Seed: *seed, Chunks: *chunks, ChunkG: *chunkG, Band: *band, Kind: "scatter",106+ fp := gen.FieldParams{107+ Seed: *seed, G: *G, Chunks: *chunks, ChunkG: *chunkG, Band: *band, Kind: "scatter",103108 Coverage: *coverage, Compound: *compound, Jobs: *jobs, Retries: *retries,104- }, gen.SubprocessRunner(exe, *chunkTimeout))109+ }110+ run := gen.SubprocessRunner(exe, *chunkTimeout)111+ if *grow {112+ if *state == "" {113+ fail(fmt.Errorf("--grow needs --state <private output json>"))114+ }115+ data, err := os.ReadFile(*state)116+ if err != nil {117+ fail(err)118+ }119+ var st gen.Output120+ if err := json.Unmarshal(data, &st); err != nil {121+ fail(fmt.Errorf("state: %w", err))122+ }123+ delta, err := gen.GrowField(ctx, &st, fp, *ring, run)124+ if err != nil {125+ fail(err)126+ }127+ emit(delta)128+ return129+ }130+ out, err := gen.GenerateField(ctx, fp, run)105131 if err != nil {106132 fail(err)107133 }108- emit(out)134+ if *rings == "" {135+ emit(out)136+ return137+ }138+ // the nightly job: the initial cluster and every ring, in one document,139+ // each ring exactly the delta --grow would emit140+ want := -1141+ if *rings != "all" {142+ if _, err := fmt.Sscan(*rings, &want); err != nil || want < 0 {143+ fail(fmt.Errorf("--rings wants a count or 'all', got %q", *rings))144+ }145+ }146+ cur := out147+ var deltas []*gen.Output148+ for k := 1; want < 0 || k <= want; k++ {149+ delta, err := gen.GrowField(ctx, cur, fp, 1, run)150+ if err != nil {151+ fail(err)152+ }153+ if delta.Stats.Exhausted {154+ break155+ }156+ deltas = append(deltas, delta)157+ if cur, err = gen.ApplyGrowth(cur, delta); err != nil {158+ fail(err)159+ }160+ if gen.Verbose {161+ fmt.Fprintf(os.Stderr, "field: ring %d: +%d tiles (%d total) in %d ms\n", k, len(delta.Manifest.Tiles), len(cur.Manifest.Tiles), delta.Stats.Ms)162+ }163+ }164+ emit(map[string]any{"base": out, "rings": deltas})109165 }110166 111167 func runPack(args []string) {
M engine/pack/pack.go +61 -6
@@ -941,9 +941,20 @@ func scatter(L *Layout, p Params, rng *rand.Rand) error {
941941 seed.X0 = st.rx0 + rng.IntN(st.rx1-st.rx0-seed.W+1)942942 seed.Y0 = st.ry0 + rng.IntN(st.ry1-st.ry0-seed.H+1)943943 if len(p.Fixed) > 0 {944- // the seed must not sit on the context; draw until it is clear of945- // every fixed tile (or accepted as an overlap of one)946- for try := 0; try < 200; try++ {944+ // the seed must not sit on the context: draw positions (and, past945+ // the first few hundred, smaller shapes) until it is clear of every946+ // fixed tile or accepted as an overlap of one; a region so crowded947+ // that nothing lands is a packing failure, never a silent overlap948+ placed := false949+ for try := 0; try < 2000 && !placed; try++ {950+ if try > 0 && try%200 == 0 {951+ // smaller shapes land more easily; never below 8x8, which952+ // drawShape could not satisfy under the shape rules953+ seed = drawShape(rng, p.MinCells, max(64, p.MaxCells/(1+try/200)), p.Compound)954+ seed.ID = p.IDBase955+ }956+ seed.X0 = st.rx0 + rng.IntN(st.rx1-st.rx0-seed.W+1)957+ seed.Y0 = st.ry0 + rng.IntN(st.ry1-st.ry0-seed.H+1)947958 clear := true948959 for _, id := range seed.globalCells(G) {949960 if len(st.own[id]) > 0 {
@@ -952,13 +963,57 @@ func scatter(L *Layout, p Params, rng *rand.Rand) error {
952963 }953964 }954965 if clear {966+ placed = true955967 break956968 }957969 if ok, _ := st.accepts(seed); ok {958- break970+ placed = true959971 }960- seed.X0 = st.rx0 + rng.IntN(st.rx1-st.rx0-seed.W+1)961- seed.Y0 = st.ry0 + rng.IntN(st.ry1-st.ry0-seed.H+1)972+ }973+ if !placed {974+ // nothing lands as a drop: try the fill pass's small shapes over975+ // the region's free cells under the filler rule, largest first976+ var free []int977+ for y := st.ry0; y < st.ry1; y++ {978+ for x := st.rx0; x < st.rx1; x++ {979+ if len(st.own[y*G+x]) == 0 {980+ free = append(free, y*G+x)981+ }982+ }983+ }984+ rng.Shuffle(len(free), func(a, b int) { free[a], free[b] = free[b], free[a] })985+ fillers:986+ for si, shape := range fillShapes {987+ sg := fillGeoms[si]988+ evals := 0989+ for _, c := range free {990+ for _, li := range sg.cells {991+ evals++992+ if evals > 400 {993+ continue fillers994+ }995+ t := shape996+ t.Rects = append([][4]int(nil), shape.Rects...)997+ t.ID = p.IDBase998+ t.X0, t.Y0 = c%G-li%shape.W, c/G-li/shape.W999+ if !st.inRegion(t) {1000+ continue1001+ }1002+ if ok, _ := st.acceptsAs(t, sg, true); ok {1003+ seed = t1004+ placed = true1005+ break fillers1006+ }1007+ }1008+ }1009+ }1010+ }1011+ if !placed {1012+ // the context covers the region: nothing to generate here (a1013+ // grown ring's chunk under its neighbours' tiles); an empty1014+ // layout, not a failure1015+ L.Report.Stopped = "covered"1016+ return nil9621017 }9631018 }9641019 L.Tiles = append(L.Tiles, seed)