Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,20 @@ the run length is a choice you make:
| Mode | Shape | For |
| ----- | -------- | -------------------------------------------------------- |
| Quick | 1 × 30s | A board you are still changing |
| Deep | 10 × 10s | The usual answer — ten short walks beat one long one |
| Max | 50 × 10s | Settling a board; mostly buys a fuller shortlist of ties |
| Deep | 5 × 20s | The usual answer — the best of five walks, kept |
| Max | 25 × 20s | Settling a board; mostly buys a fuller shortlist of ties |

Ten short attempts usually beat one long one because past the first few seconds
Several attempts usually beat one long one because past the first few seconds
the annealing temperature has fallen far enough that a long walk mostly polishes
the basin it is already in. Attempts run as independent tasks across a worker
pool, so the wall-clock cost of "10 × 10s" depends on how many cores the browser
gives you — the control prints the estimate.
the basin it is already in. Not too short, though: on a large island a 10s walk
is still climbing, and five 20s walks beat ten 10s ones at the same cost.
Attempts run as independent tasks across a worker pool, so the wall-clock cost
of "5 × 20s" depends on how many cores the browser gives you — the control
prints the estimate.

Under Singularity Isolation every attempt runs twice as long (Quick 1 × 60s,
Deep 10 × 20s, Max 50 × 20s): a generator's output depends on its neighbours
there, and a 10s walk does not have time to find layouts that keep them apart.
Deep 5 × 40s, Max 25 × 40s): a generator's output depends on its neighbours
there, and a short walk does not have time to find layouts that keep them apart.

## Quick start

Expand Down
60 changes: 48 additions & 12 deletions docs/SOLVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ the terminal.

```
packages/solver/
src/solver/ the engine: types, island split, simulation, distribution, search
src/solver/ the engine: types, island split, simulation, distribution, and
placementSearch.ts, the search's entry point (solveIsland)
src/solver/search/ one file per search stage: seed, anneal, composition, repair,
rightSize, pooledCoolers, and the placement and stability primitives
they share
src/data/ the authored building tables, the anomaly table, the shipped island codes,
unlock resolution, mapping a game export onto all of that
src/encoding/ the blueprint wire format, and a reader for the game's export code
src/utils/ the game's number ladder
src/grid.ts tile predicates and the ASCII alphabet the tests are written in
bin/ the CLI (`npm run solve`)
scripts/ the fixture exporter (`npm run fixtures`)
scripts/ the fixture exporter (`npm run fixtures`), the gap survey (`npm run survey`)
and the stage profiler (`npm run profile`)
tests/ vitest, run by the repo's `npm test`
fixtures/ golden fixtures, committed
```
Expand Down Expand Up @@ -64,9 +69,11 @@ gone; the reasoning is not.
`DistributionScratch` **must never change a number.** It is an allocation optimization and
nothing else.

Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay private
to `placementSearch.ts` rather than moving to `constants.ts`: they are meaningless apart from the
stage they tune.
Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay beside
the stage they tune rather than moving to `constants.ts`: they are meaningless apart from it. Each
stage of the search is a file of its own under `src/solver/search/`, and its constants are private
to that file; the budget shares that divide the time between stages live in `placementSearch.ts`
with `solveIsland`, and the one constant two stages share, `SWAP_RADIUS`, in `search/placement.ts`.

### `anomalies.ts` — the prestige rule changes

Expand Down Expand Up @@ -499,6 +506,27 @@ three. Nothing came back lower anywhere. `anomalies.test.ts` pins the seed on a
scraps: every cooler on a scrap and none beside a generator under the pool, the reverse under the
base rules, and a seed that simulates alive.

**Under Singularity the seed is sized for the rule, and the plain seed is kept beside it.** Sized
from the plain roster, a full-unlock hub is one ecto reactor filling one generator, and the "shared"
fit stands three generators round one reactor — every one of them crowded, on a board where an
isolated generator takes two reactors and makes four times the power. That seed scored 32% of the
Singularity bound on Event Horizon where the base seed scores 60% of its own, and the first repair
had to double it. So under a generator isolation `constructSeed` sizes each hub fit with the
generator at its `isolated` rating (the plain entry is still what is written, and `simulateIsland`
rates it from its neighbours as always), and claims no hub whose generator would stand beside one.
After the first repair that seed reads 78–88% of the bound on the eight largest islands against the
plain one's 50–64%. `solveIsland` builds and repairs the plain-sized seed too and starts from
whichever repairs higher, the plain one on a tie — judged after the repair rather than before,
because on Entropy Isles' 20-tile island both repair to the same five full hubs and only the plain
one's looser arrangement leads the later stages to the six-generator optimum (2.54AD against 2.36AD
at its 0.3s budget). The plain repair is given the time the sized one took, so this costs at most
one more repair. Measured with the survey: at 15s × 5 runs a wash, −1.5% to +2.2% per map; at 5s × 3
runs Event Horizon +14% and Shadowspire +18%, four maps +2–4%, Ash Bay and Magma Rift 4% and 1%
lower inside their bands; with generator7 at tier 3 every map inside 0.8%. An un-crowding move in
the walk — a crowded generator swapped onto a tile with no generator beside it — was measured at the
same time and came back inside the noise band on every map (−2.0% to +1.7% at 15s × 3), so it is not
in.

**Two calibrations were measured under the rules and left as they are.** `hillClimb`'s
`moveScale` — the figure the annealing temperature is derived from — is read off the **plain**
roster, so under a rule that rates a tile above it the walk runs colder than intended, up to `multiplier`
Expand Down Expand Up @@ -550,10 +578,13 @@ property of the rules in force rather than of the grid, so it cannot be applied

Two shapes in it are load-bearing and look like details:

- A supplier expands **every** unvisited adjacent consumer instead of shortcutting to the sink
when it finds one with room. The shortcut marks later consumers visited and can pick a
different equal-length augmenting path — same maximum flow, different split, and the split
decides which buildings clear their cooling.
- The sink is reached by the **first** consumer with room to be enqueued, through the supplier
that enqueued it — the queue is FIFO, so that is the one Unity's consumer node connects from.
The search ends at that enqueue, which saves the expansions in between and nothing else. A
supplier must never pick the sink on its own terms (scan its edge list for a consumer with
room, or mark later consumers visited without enqueuing them): that can pick a different
equal-length augmenting path — same maximum flow, different split, and the split decides
which buildings clear their cooling.
- Supply and demand are counted **down** rather than accumulated up, because `cap - sent` is not
bit-identical to a decremented remainder and the fixtures assert exactly.

Expand Down Expand Up @@ -840,9 +871,14 @@ could not see would restart numbering at 1 and overwrite an untagged run.
`--attempts` runs a batch session: N whole solves through a worker pool, with a leaderboard of
distinct layouts at the end. Ctrl-C stops it and still prints the report.

Cores are spent on two axes and the flags pick which. A **run** farms its islands out to the pool
— the same search either way, since each island still gets its own slice (`islandBudgetsS`) of
the budget, so only wall clock changes. A **session** runs whole attempts in the pool with islands
Cores are spent on two axes and the flags pick which. A **run** farms its islands out to the pool,
each with its own slice (`islandBudgetsS`) of the budget. When every island has a worker of its
own the slices are stretched until the largest is the whole budget (`fitBudgetsToPool`), since a
run that fits the pool lasts as long as its largest slice anyway — the app's Quick does the same.
That makes it a different search from the serial fallback (one island, or a pool of one), which
keeps the summed split, so on a multi-island board the core count decides how long each island is
searched. `npm run survey` keeps the summed split too: it measures one attempt's slices, which is
what a queued Deep or Max run spends. A **session** runs whole attempts in the pool with islands
serial inside each; the pool already owns the cores, and a nested pool per island would
oversubscribe the machine.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"build:solver": "npm run build --workspace @reactor2/solver",
"solve": "npm run solve --workspace @reactor2/solver --",
"fixtures": "npm run fixtures --workspace @reactor2/solver --",
"survey": "npm run survey --workspace @reactor2/solver --"
"survey": "npm run survey --workspace @reactor2/solver --",
"profile": "npm run profile --workspace @reactor2/solver --"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.2.0",
Expand Down
45 changes: 36 additions & 9 deletions packages/solver/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
at 98% of its bound — is unchanged. The `coolerMultiplier` has to be earned
back before any of that shows.

**The seed is the one stage that branches on the rule.** Under a pool
**The seed branches on the rule.** Under a pool
`constructSeed` builds every hub engine-first — a phantom cooler sizes the
fit, so the neighbour slots all go to reactors — charges the cooling as a
board-wide debt in each candidate's rank, reserves the tiles for it, and pays
Expand Down Expand Up @@ -245,6 +245,16 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
The neighbour scan reads the _original_ placement, since the test is on what a
neighbour is and no rating changes that — so there is no order to get right.

**The seed branches on this rule too.** Sized from the plain roster, a
full-unlock hub is one reactor filling one generator and the shared fit
stands three generators round one reactor, all crowded; that seed scored 32%
of the bound on Event Horizon. Under a generator isolation `constructSeed`
sizes each hub with the generator at `isolated` and claims no hub whose
generator would stand beside one, and `solveIsland` repairs the plain-sized
seed too and starts from whichever repairs higher, the plain one on a tie.
`docs/SOLVER.md` has the measurements: a wash at 15s, +14-18% on the two
largest maps at 5s.

**The bonus is on a capacity, which is why the rule is close to power-neutral
at full unlocks.** `isolated` scales a generator's _intake_, and a generator
fed by the reactors it already had simply fills to `1/isolated` and produces
Expand Down Expand Up @@ -290,7 +300,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
figures are read: a step of the walk writes one or two tiles and then
simulates the island, which reads every occupied tile, so the write is some
25x less work and `simulateIsland` stays untouched. `put()` in
`placementSearch.ts` is the only way a building enters a placement, so the
`search/placement.ts` is the only way a building enters a placement, so the
bonus cannot be missed at one of three dozen sites — and a miss would be
silent, since the layout would simply be worth less than it is.

Expand Down Expand Up @@ -575,8 +585,13 @@ is the entry point. `planSolve()` does the shared setup:
**seed → repair → composition retarget → (annealing → repair)\* → pruning →
right-sizing**. The starred pair repeats until the deadline: repair's slice is
reserved before the layout exists and converges long before its cap, so what
it hands back becomes another short walk rather than idle time. The reasoning
for each stage is in the file header and `docs/SOLVER.md`; the short
it hands back becomes another short walk rather than idle time. Each stage
is a file under `search/` (`seed`, `anneal`, `composition`, `repair`,
`rightSize`, `pooledCoolers`, over the shared `placement` and `stability`
primitives); `placementSearch.ts` keeps `solveIsland`, the budget split,
the deterministic replay and `internals`, and stays the one module anything
outside imports the search from. The reasoning for each stage is in
`placementSearch.ts`'s header and `docs/SOLVER.md`; the short
version is that seeding builds self-sufficient hubs and systematically misses
layouts where hubs _share_ a cooler or reactor, and the other stages cover
that from different directions.
Expand Down Expand Up @@ -619,11 +634,13 @@ building sits on a tile and nothing else. Tied layouts go through it too.
**`distribution.ts` is a port of Unity's `FlowNetwork.cs`.** Two shapes in it are
load-bearing and look like details:

- A supplier expands **every** unvisited adjacent consumer instead of
shortcutting to the sink when it finds one with room. The shortcut marks later
consumers visited and can pick a different equal-length augmenting path — same
maximum flow, different split, and the split decides which buildings clear
their cooling.
- The sink is reached by the **first** consumer with room to be enqueued,
through the supplier that enqueued it — the queue is FIFO, so that is the one
Unity's consumer node connects from, and the search ends at that enqueue. A
supplier must never pick the sink on its own terms (scan its edge list for a
consumer with room, or mark later consumers visited without enqueuing them):
that can pick a different equal-length augmenting path — same maximum flow,
different split, and the split decides which buildings clear their cooling.
- Supply and demand are counted **down** rather than accumulated up, because
`cap - sent` is not bit-identical to a decremented remainder and the golden
fixtures assert exactly.
Expand Down Expand Up @@ -738,6 +755,16 @@ it warns if the first three differ. Expect about ±0.8% per map at 15s × 5 runs
so a gain smaller than that needs more runs to show; "no island got worse" is
the claim it can make at any size.

**For a change to what a stage costs, use the profiler before and after.**
`npm run profile` times each stage of the search on the largest shipped island
under each anomaly — the bound, the composition targets, seed construction,
one simulation, the collector's tie check, one full repair — on the same
deterministic seed layout both times, so two runs taken back to back on an
idle machine measure the same work. It is how the seed was found to cost some 40x
what its comment claimed, and how a repair was found to cost more than the
polish share of a Quick attempt on a phone: the fixtures cannot see either,
since neither changes a number.

The survey solves at full unlocks unless told otherwise. `--unlock ID=LEVEL`
(0-based, `-1` to lock) surveys another roster, and a rule that rates one role
needs one: at full unlocks Singularity's generator bonus buys a capacity no
Expand Down
16 changes: 12 additions & 4 deletions packages/solver/bin/solve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { verify } from "../src/solver/report";
import {
buildOptimizationResult,
fitBudgetsToPool,
planSolve,
solve,
} from "../src/solver/solver";
Expand Down Expand Up @@ -142,9 +143,12 @@ function offsetSeed(
/**
* One solve, with the islands farmed out to `pool` when there is more than one.
*
* Islands never interact, so this is the same search either way — each island
* still gets its own slice of the budget (`islandBudgetsS`). Only the wall clock
* changes: concurrently it is ~max(slice) instead of the whole budget.
* Islands never interact, so each one gets its own slice of the budget
* (`islandBudgetsS`) either way. Not the same search, though: when every island
* has a worker the pooled run stretches the slices until the largest is the
* whole budget (`fitBudgetsToPool`), while the serial fallback — one island, or
* a pool of one — keeps them summing to it. So on a board of several islands
* the core count decides how long each one is searched.
*/
async function solveWithPool(
grid: Tile[][],
Expand Down Expand Up @@ -176,6 +180,10 @@ async function solveWithPool(
placements: [],
powerOutput: 0,
}));
// When every island has a worker of its own the run lasts as long as its
// largest slice, so the app's Quick rule stretches them to the budget;
// `fitBudgetsToPool` checks that and leaves a run that queues alone.
const budgetsS = fitBudgetsToPool(plan.budgetsS, timeBudgetS, 1, pool.size);

await pool.run(
plan.islands.map((island, i) => ({
Expand All @@ -184,7 +192,7 @@ async function solveWithPool(
kind: "island" as const,
island,
effectiveBuildings: plan.effectiveBuildings,
budgetS: plan.budgetsS[i],
budgetS: budgetsS[i],
seed: plan.seeds[i],
// The resolved definition rather than the id: the plan has already
// resolved it, and a worker re-resolving would be a second reading of
Expand Down
1 change: 1 addition & 0 deletions packages/solver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"solve": "tsx bin/solve.ts",
"fixtures": "tsx scripts/exportFixtures.ts",
"survey": "tsx scripts/gapSurvey.ts",
"profile": "tsx scripts/profile.ts",
"prepublishOnly": "npm run build"
}
}
6 changes: 4 additions & 2 deletions packages/solver/scripts/gapSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
* of a bound that is not tight.
*
* Islands are solved one task each through the CLI's own worker pool, with
* the slice of `--time` `planSolve` gives them — the same budget a real run
* spends on that island — so a figure here is a figure the app would get.
* the slice of `--time` `planSolve` gives them — the same budget a queued app
* run spends on that island. A run that fits the app's pool stretches those
* slices (`fitBudgetsToPool`), so on a board of several large islands this
* undercounts what a Quick run gets.
*
* **Every map is surveyed with its obstacles cleared.** Rocks, trees and ponds
* are removable in the game and become grass here; water and the transformer
Expand Down
Loading
Loading