Code: app/src/solver/ (lp.ts — the LP core, diagnose.ts — root-cause
cards, migrate.ts — the legacy-doc mapping), with effect aggregation in
app/src/server/effects.ts and the factory-level solver in
app/src/server/factory-solve.server.ts.
A block is goals + chosen recipes + a made set (items the block claims
in-block production for) + pins. The solve is a small LP (HiGHS, the same
engine as cost analysis): recipe run-rates are nonnegative variables, and the
objective minimizes machine-seconds (with a tiny epsilon per recipe so zero-cost
synthetic recipes can't create ties) — so identical inputs always solve
identically, and a ≥ goal binds at exactly its rate unless chemistry forces
surplus. Each goal has a target rate — stored per-second always; a goal's
optional unit (s/min/h) is purely the display/input window the editor converts
at (#10), so the solver never sees units. A stock goal (#38, stock + window on
the goal) means "keep N on hand": its rate is derived (stock / window, default
10 min), so the solver still sees an ordinary per-second target — the machines are
sized to rebuild the buffer within the window — while its cached boundary flow gets
role "stock" so the factory ledger can badge refill demands apart from continuous
throughput. Each goal becomes a net ≥ rate
constraint (a negative rate is a SINK block: consume ≥ |rate|) — a floor the
minimizing objective presses the plan down onto, so the good comes out at
exactly that rate unless a co-product ratio forces surplus (which simply
exports); goals[0] names the
block, anchors the rate-scaling tools, and is the default icon (the block editor can
override the icon with any item/fluid, stored as icon in the block doc). A good you
don't target isn't a goal — it falls out as a byproduct (export) or import. See
app/src/lib/goals.ts for the model and the migration from the legacy
single-target shape. The item rules:
- A
madeitem getsnet ≥ 0: production covers consumption, surplus exports, imports are forbidden — the rule that makes a block a plan instead of a shopping list. The set is built by gestures: setting a goal implies it, and adding a producer through an item's chip marks it; right-click toggles it; a removed recipe takes nothing with it implicitly. - Every other item is free: consumption imports, surplus exports, and an incidental byproduct just offsets the import — a 0.02/s side-product of something else is never scaled up to cover a 10/s demand.
- Draining a byproduct: adding a consumer through a byproduct's chip marks
the good made AND — when the chosen recipe is a terminal sink — records a
drain (
net = 0): the surplus must be consumed in-block, which is what forces a void to run at all (it produces nothing the objective wants). Terminal means the recipe net-consumes the good and none of its other products feeds anything else in the block — everything it makes leaves (lib/sink-classify.ts,drainsOnConsume, tested). That single test is the line between "consume my surplus" and "restructure production": a void like coal-gas → ash (nothing here uses ash) drains and runs; a reprocessor whose output re-enters the chain (block 27's grade-2 → grade-3, which the chain consumes) is only marked made, never drained, so forcing it can't cascade. A reprocessing consumer needs no drain — once the good is made (import forbidden), recycling the surplus is cheaper than making more, so the optimizer uses it; without the made mark it would instead IMPORT the byproduct and idle the real producers. The solve also reportsimportedProducible— imports of goods an enabled in-block recipe produces, the tell-tale of that trap — and the import chip offers one click to claim the good in-block. - Pins (
pinsin the doc, in building counts) constrain single rows:count= always run exactly N buildings (supply-push — this is how byproducts route into in-block consumers),cap= at most N (a built-capacity ceiling; the diagnosis reports the shortfall in buildings when it binds), andshare= this consumer takes a fraction of the item's production (baseremainingapplies it after count-pinned consumers' fixed intake). Counts convert to rates at solve time via the row's real per-building craft rate, so pins follow module/machine changes.
Fluid temperatures are real identities (#110, temps.ts): when any enabled
consumer declares an accepted temperature range, that fluid expands — each
producer's output becomes a (fluid, temperature) variant (explicit product
temperature, else the prototype default), each consumer range becomes a pool
good, and zero-cost selector pseudo-recipes convert in-range variants into the
pool, so a range consumer draws from any mix of acceptable temperatures (range
POOLING, not YAFC's hard per-temperature split). A made mark expands to every
variant and pool, so a pool with no in-range producer reads as unmade —
"nothing makes water ≤101°" — and the interim per-producer warnings stay as the
complementary explanation of which producer misses which consumer. Fluids
no ranged consumer touches stay single bare goods (zero cost for most blocks);
boundary flows fold back to the bare fluid name. The whole thing is a pure
input transformation — the LP core never sees temperatures.
There are no per-item dispositions and no relaxed/underdetermined states: the LP
either solves or is infeasible, and infeasibility is diagnosed, never
silently patched. diagnose.ts extracts root-cause cards: an elastic pass finds
what's short (with magnitudes), violated constraints split into independent
problems by shared recipe variables, and each problem's variable neighborhood is
deletion-tested for IIS membership — a card lists exactly the gestures (goals,
made marks, pins) whose single removal repairs the block, each with a one-click
fix in the balance card. A diagnosis can only name things the user can click.
Legacy docs (pre-#91 dispositions) migrate on read: the server derives a
made set (migrate.ts — auto-balanced intermediates and balance overrides
become marks; import/export overrides unlink), echoes it on the result, and
the editor adopts it so the next save persists the new shape.
A recipe can be disabled (disabledRecipes in the block doc): it stays in the
block, keeping its machine/fuel/module picks, but is filtered out before the system is
built, so it adds no equations, boundary flows, or building counts — as if it weren't
there. Use it to A/B two recipes for the same output, or to stage rows you'll enable
later. A whole block can likewise be disabled (blocks.enabled = false): it still
opens and solves for editing, but every factory-wide rollup (totals, coherence,
suppliers, machine counts, what-if) skips it.
A block doc can carry planned spoil losses (#20, spoilRates: item → rot rate
/s). Each entry is merged into the solver targets as extra pinned net production —
surplus that rots in storage — so the chain is sized to cover the loss. The rotted
surplus never reaches the boundary flows (pinned items are excluded from exports),
which is correct: spoiled goods aren't available to other blocks.
computeBlock also rolls up a pollution budget (#23): per row, machine base
emissions_per_minute × count × energy-consumption multiplier × pollution-module
multiplier (per-fuel emissions multipliers are approximated as 1). Cached on the
block like electricity_w and summed in the Factory header.
Fuel folds into the balance by energy source. Electric draw nets as
pyops-electricity consumption post-solve; solid burners burn their per-row fuel
pick (folded post-solve, or modeled in the system when the block produces the fuel
itself — self-fueling — so ash and the extra production come out right). Fluid
burners (#25) have no pick: an unfiltered burns_fluid machine (Py glassworks,
smelter, antimony drill, oil boiler) burns any fuel-valued fluid, so its draw is
modeled like heat — a pyops-fluid-fuel ingredient (1 unit = 1 MJ) the system must
balance. Adding a burn-fluid-<fluid> conversion recipe (1 fluid → its
fuel_value in MJ) sizes that conversion to the draw; the choice of conversion
decides which fluid burns, several split like any other multi-producer good, and
with none present the MJ surfaces as a "Fluid fuel" import. A filtered fluid
burner (Py oil/gas powerplants) is pinned to its filter fluid.
burns_fluid: false sources (uf6 reactors, compost plants, the solar tower) are
temperature-fed (#114): they drain their filter fluid for its heat content,
not a fuel value. The import derives the drain from the prototype
(db/fluid-energy.ts): a fixed units/s per machine — an explicit
fluid_usage_per_tick (neutron absorbers, the solar tower's 60/s) or the
engine's derivation from maximum_temperature (nuclear-reactor-mk01: 300 kW ÷
((250° − 0.01°) × 20 J/°) ≈ 60.0024 uf6/s) — or, for scale_fluid_usage
sources (compost plants), an energy-following one (the effectivity-folded draw
÷ usable J per unit, so consumption modules reduce it). The drain is injected
as a real system ingredient of the actual feed fluid — an in-block producer
covers it, otherwise it surfaces as an import — and the row's fuel chip mirrors
it (no per-row pick, never folded post-hoc).
A block can also be a designated fuel supplier (#115), exporting
pyops-fluid-fuel MJ for other blocks' generic draws. The designation is an
explicit routing gesture — a conversion recipe nothing demands honestly solves
to 0 — so either pin pyops-fluid-fuel as a goal (the conversion is sized to the
pinned MW and the MJ exports as a primary — a dedicated fuel farm)
or route the feed fluid with a 100% share pin on the conversion (all
production routes into it and the MJ exports as a byproduct — burning off
co-products). A block that merely
exports a fuel-valued fluid without a conversion is never conscripted as fuel
supply: kerosene sold as feedstock stays feedstock.
Reactor rows honour the neighbour bonus (#94): each adjacent working reactor
adds neighbour_bonus × base heat (Py's breeder reactor dumps neighbour_bonus: 1,
+100% per neighbour). The block doc can carry an assumed x×y farm per reactor row
(reactorLayouts), and the row's pyops-heat output is scaled by the grid's
average multiplier 1 + b·(4 − 2/x − 2/y) (app/src/lib/reactor.ts) before the
solve — so a 2×2 farm needs a third of the flat-rated reactors. Only heat scales;
fuel burn stays per-reactor. No layout stored = 1×1 = no bonus. The row shows a
layout chip with the multiplier and a preset picker.
Rows can be grouped into sub-blocks (rowGroups + recipeGroups in the block
doc) — named, collapsible groups the editor renders as one folded line with the
chain's net flows (member products minus member ingredients; intermediates cancel).
By default (#7) they're display-only: the groups never reach the solver, which
sees the same flat recipe set either way (app/src/lib/row-groups.ts holds the pure
grouping/net-flow logic).
A group can be promoted to a real, separately-solved module (#76, composed
on the group + its own internal goals; app/src/solver/subblock.ts). A composed
sub-block is solved with solveBlockLp exactly like a top-level block — its
internal goals size it and its made set (auto = every good a member produces, so
it makes its own intermediates) keeps the intermediates hidden. Its net imports/
exports at the solved rates (temperatures folded to bare fluids) become a synthetic
"recipe" whose ingredients = the module's imports, products = its net exports
including its goal output, and energyRequired = the module's machine-seconds,
so the parent's objective weighs the module's real cost. The parent then solves
normally over its own recipes + these synthetic sub-block recipes, scaling each
module as one black box; a member's row still renders at its effective rate
(nested run-rate × the parent's chosen run-rate of the synthetic). The member
recipes, their pins, and whole-machine rates route into the module's solve; the
module's goal goods are claimed made at the parent so the minimizing objective
can't idle the module and import the good instead. The internal goals are not
parent goals — the module never looks like a factory-level producer of its goal —
but forced co-products stay on the contract, so they export as byproducts and the
factory coherence/byproduct model still sees them. A sub-block is a subset of one
parent block's recipes, solved first in isolation, so it can never depend on its
parent: the whole thing is deterministic and cycle-safe. The nested-solve contract
is unit-tested in subblock.test.ts, including a 2-level compose reproducing the
equivalent flat block's boundary flows.
Deferred for now (#76): the module carries only goals (its made is auto-derived,
not user-editable per group); a sub-block's own infeasibility surfaces as a status
badge on its header and, when its output can't be produced, as a parent-level
shortfall — it does not yet get its own IIS diagnosis cards. Sub-blocks don't nest
(a group's members are plain recipes, never other groups), and spoilRates stay a
parent-level concern.
A goal that no recipe in the block makes (an unfinished block, or one whose
producer vanished after a data migration) is not enforced — that would zero
the rest of the block. Such goals (and made marks with no producer) are
returned in unmade and the rest of the block solves normally; the editor flags
just those ("no recipe — add one") and the sidebar/tabs tint the block amber. Note
the factory/coherence index still treats every goal as produced at its target rate
(goals are a declared intent), so an unmade goal won't show as a deficit there —
the per-block health flag is what surfaces it.
Recipes, marks, and pins are user-chosen — the LP's objective is only a tie-breaker, never a recipe selector. It handles Py's cyclic recipe chains and reports fractional building counts, and because every constraint traces to a user gesture, a failure names the gesture rather than swapping recipes behind your back.
Synthetic spoiling recipes (kind = "spoiling", energy = the spoil time in
seconds) run in no machine — the items just sit in storage until they rot. For those
rows computeBlock reports a spoil buffer (#19): rate × spoil time items are
resident mid-spoil at steady state, shown on the row with the equivalent stack count
— the chest space a deliberate-decay step (uranium, nagesium) actually needs.
Separate from the per-second flows, buildCost (db/queries.server.ts, surfaced by
computeBlock) reports the one-time materials to construct the block's
buildings: it ceils the solved machine counts per building type, expands each
building's own build recipe, and sums the direct ingredients. This is why a science
block needs steel — the buildings are made of it — even though no recipe in the
chain consumes steel (#38). It's direct ingredients only; producing those materials'
sub-chain is the factory ledger's job.
Module/beacon effects (effects.ts) apply before the solve:
- Productivity scales a recipe's products (a real balance change). Per
Factorio 2.0, each product's
ignored_by_productivityis an amount: that many units are catalytic and stay unscaled, only the remainder is multiplied (Kovarex: 41 U-235 out, 40 ignored; coal liquefaction: 90 heavy oil, 25 ignored). The shared math lives inlib/productivity.ts(#93). - Speed scales the machine count.
- Consumption scales power/fuel.
- Pollution scales the block's pollution budget (#23).
Factorio's clamps are respected: speed, consumption, and pollution multipliers
bottom out at 0.2, productivity caps at the recipe's maximum_productivity
(+300% by default — but Py raises it to 1e6 on nearly every recipe).
Module auto-fill (module-fill.server.ts) is suggested, never applied:
the solve only ever uses the doc's stored module picks, so a plan never
rearranges its modules behind the player's back (research unlocking a better
tier, or a count drifting across a whole-building boundary, changes the
suggestion, not the block). Each solve computes a per-row suggested fill by a
direct algorithm — no payback economics: if the recipe allows productivity,
every slot gets the best unlocked prod module; otherwise the row gets the
fewest speed modules that reach the smallest whole building count, with the
remaining slots on efficiency — past that floor, extra speed only shaves
fractions of a building you can't build, so those slots cut power instead.
Zero speed modules is a real answer (a row already under the floor, or modules
too weak to save a whole machine, suggests all-efficiency). The split is sized
against the row's module-less baseline with beacon and TURD speed included, so
planting speed beacons updates the suggestion to shed now-redundant speed
modules. Rows whose stored fill differs from the suggestion carry
suggestedModules; the UI shows a ✨ hint (gated by the Settings toggle) with
one-click apply, the modules dialog previews the suggestion, and the block
toolbar applies all suggestions at once (confirming when it would overwrite
rows that already have modules). The assistant's draft-a-block adopts the
suggestions as the draft's explicit picks and re-solves with them.
Research-driven productivity (#92) is folded into the same effects stage,
gated by the research horizon exactly like machine availability (everything in
FUTURE mode, reached techs in NOW/target): mining-productivity levels add an
uncapped bonus to every mining recipe (resources aren't recipes, so no cap
applies — matching in-game mining productivity exceeding +300%), and Factorio
2.0 change-recipe-productivity techs (Py's microfilters tiers) add base
productivity to their target recipes — applied even when the recipe doesn't
allow productivity modules (e.g. bhoddos-spore gets +100% from
microfilters-mk02 despite having no allow_productivity). Repeatable techs
(Py's infinite mining-productivity-12) count at most one level, since the
mod's research sync reports researched tech names, not levels.
The factory-level what-if (factory-solve.server.ts) is an LP. It treats each block
as a fixed-ratio "super-recipe" (its cached boundary flows at the current rate) and
solves for the per-block scale factors that satisfy every demand.
Why an LP rather than the exact block solver: real Py factories can't balance every good exactly — multi-product blocks force off-ratio surplus — so exact equality is infeasible. The LP uses production ≥ demand (surplus allowed) and minimizes total scaling, which is always feasible and matches "scale each block up/down to meet demand". It's report-only: it never writes; you adjust each block by hand (or ignore the suggestion).
Two energy pseudo-goods stay free boundaries (never balanced across blocks):
pyops-electricity (grid-distributed — matching it would create a power feedback
loop) and pyops-heat (block-local by game rule). pyops-fluid-fuel is not
free (#115): a designated supplier's MJ export matches generic MJ imports
block-to-block like any other good — a primary MJ export classifies as an
intermediate the LP scales with demand, and an MJ import with no supplier
classifies as a raw, the signal to designate one.