Skip to content

Refactor: Replace heuristic SCC solver with LP-based solver#74

Merged
JamboChen merged 5 commits into
JamboChen:masterfrom
lsequeiraa:refactor/lp-solver
May 11, 2026
Merged

Refactor: Replace heuristic SCC solver with LP-based solver#74
JamboChen merged 5 commits into
JamboChen:masterfrom
lsequeiraa:refactor/lp-solver

Conversation

@lsequeiraa

@lsequeiraa lsequeiraa commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the SCC flow solver's heuristic stack — Gaussian elimination + ad-hoc row dropping + max(b/a) tie-breaking — with a linear-program formulation backed by javascript-lp-solver (~30 KB Unlicense). Two follow-up fixes are bundled: phantom-disposal-sink suppression for floating-point residuals, and Phase 4.5 raw-material deficit propagation for raw items inside an SCC.

Evidence — three-way comparison

30 scenarios run against v0.6.2 (pre-PR #73, commit 339fb5b), master at PR #73's merge commit (49be16e), and this PR's HEAD.

Bucket Count
PR #73 regression — broken on master, OK on v0.6.2, LP recovers 4
Pre-existing bug — broken on v0.6.2 and master, LP fixes 1
PR #73 fix preserved (Xiranite Jade Gourd 19→14 Xiranite) 7
All three branches identical 18
LP divergent 0

PR #73 fixed the Xiranite Jade Gourd over-consumption (#72) but introduced 4 new mass-balance failures on the SC Wuling Battery / Heavy Xiranite chain. v0.6.2 ran them correctly; master under-allocates the Refining Unit, leaving the Xircon refinement chain with a Sewage deficit. LP recovers v0.6.2's metrics exactly, additionally fixes 1 pre-existing bug (Heavy Xiranite + Cuprium Part), and preserves PR #73's intended Gourd fix.

# Scenario v0.6.2 master LP
A1 SC Wuling Battery 1/min ✓ raw 65 ✗ Sewage 5/8 ✓ raw 65
B2 SC Wuling Battery (10) + LC (5) + HC (5) ✓ raw 1075 ✗ Sewage 50/80 ✓ raw 1075
C4 SC Wuling Battery at 4/min ✓ raw 260 ✗ Sewage 20/32 ✓ raw 260
D1 SC Wuling Battery at 100/min ✓ raw 6500 ✗ Sewage 500/800 ✓ raw 6500
B1 Heavy Xiranite + Cuprium Part 1/min each ✗ Inert Xircon Effluent 1/16 ✗ Sewage 1/4 ✓ valid

Master's raw count on the regression rows is computed against an invalid plan that under-runs the Refining Unit and under-reports the Cuprium Ore and Clean Water it would actually need. LP matches v0.6.2 — both are the true cost of a valid factory; master's "lower" number is fiction.

Motivation

PR #73's filterImpossibleDisposalRows row-priority heuristic was correct for the Xiranite Jade Gourd case and incorrect for the SC Wuling Battery case. The heuristic stack — Gaussian elimination with priority hints — can't satisfy both simultaneously without a richer formulation. LP solves all five failures structurally via explicit constraints + slack variables.

LP formulation

Variables x[r] ≥ 0 per SCC recipe. Per-item constraints select operator from {equal, min, disposal-slack} based on item type; raw materials are excluded from constraints (infinite-supply, counted only in cost); recipe overrides become equality pins during feeder extension. Lex two-pass objective: minimize raw consumption, then minimize power subject to raw-cost at pass-1 optimum + 1e-6. Full constraint catalogue in lp-solver.ts JSDoc.

Bundled fixes

Phase 4.5 raw deficit propagation (57bbfd2): raw items in scc.items (placed by Tarjan via byproduct cycles) were dropped from itemDemands because Phase 5 only iterates scc.externalInputs. New propagateRawMaterialDeficit mirrors master's heuristic Phase 4. Affects Clean Water (via LIQUID_PURIFIER_XIRANITE_POLY_1) and Precipitation Acid (via LIQUID_PURIFIER_COPPER_ENR_1).

Phantom disposal sink suppression (19c301d): non-integer LP facility counts produced ~1e-13 residuals that triggered phantom disposal-recipe injection — visible as disconnected "0/min" sinks at certain target rates. New SURPLUS_EPSILON = 1e-6 tolerance + defensive mapper filters.

Scope

11 files, ~+1365 / −696 LOC. Adds src/lib/lp-solver.ts (+451), rewrites src/lib/flow-solver.ts, deletes src/lib/linear-solver.ts and its tests, adds 12 unit tests for the LP module, adds 8 regression tests for the bundled fixes. Adds javascript-lp-solver ^1.0.3 (~30 KB MIT).

Tests

99/99 pass.

@lsequeiraa lsequeiraa marked this pull request as draft May 7, 2026 16:29
lsequeiraa added a commit to lsequeiraa/endfield-calc that referenced this pull request May 7, 2026
LP pass-1 minimizes raw + SLACK_PENALTY × slack. The reported `totalRaw`
sums only `rawCostPerFacility × x[r]` over recipes (slack penalty is
excluded from the reported total). But pass-2's `lex_raw_cap` constraint
set coefficients from each variable's `rawCost` field, including slack
variables (which carry SLACK_PENALTY). The constraint LHS evaluated to
`recipe_raw + SLACK_PENALTY × slack` while the RHS was `recipe_raw +
tolerance` — mathematically infeasible whenever pass-1 had slack > 0.

Pass-2 then fell back to pass-1's result, skipping power minimization
entirely. On plans where raw-minimizing recipes also minimize power
(currently most real data), this was invisible. On plans where they
diverge, the solver returned an arbitrary (potentially high-power)
choice among raw-degenerate recipes — silently violating the documented
"lex raw → power" invariant. The bug was firing in 7+ separated-mapper
test runs as "[LP_SOLVER] pass-2 infeasible after lex-cap; falling back
to pass-1" warnings, but the affected scenarios' assertions did not
depend on the recipe choices that emerged from the fallback, so tests
passed despite the bug.

Fix: skip slack variables when assigning lex_raw_cap coefficients. Slack
is independently minimized via SLACK_PENALTY in pass-2's power objective,
so it doesn't need an additional cap.

Also adds a defensive `bounded === false` check on pass-2's result. The
LP is currently bounded by structure (POWER_COST_FLOOR floor + lex_raw_cap
ceiling), but this protects against future code changes that might break
the bounding invariant.

Regression test (`lex-cap excludes slack — power minimization works
under forced slack`): two recipes with identical raw cost and asymmetric
power, plus a disposal-slack constraint forcing slack=1. Asserts the
low-power recipe is chosen and totalPower reflects it. The test fails
without this fix (the fallback picks the first declared recipe).

Verification: 100/100 tests pass. The seven "pass-2 infeasible" warnings
in separated-mapper tests are eliminated. 30-scenario v0.6.2 / master /
LP comparison shows no metric changes vs the pre-fix LP run — the fix is
invisible on current data because no real plan triggers the
recipe-power-degeneracy condition, but the documented invariant is now
upheld.

Reported by reviewer review of PR JamboChen#74.
lsequeiraa added 4 commits May 7, 2026 18:35
- Replace Gaussian elimination + filterImpossibleDisposalRows +
  max(b/a) tie-breaking with javascript-lp-solver (~30 KB MIT).
- Each SCC sub-problem becomes an LP: facility-count variables ≥ 0,
  per-item constraints (equal/min/disposal-slack), raw materials
  excluded from balance (counted only in cost objective).
- Lex two-pass: minimize raw, then minimize power subject to raw
  cap from pass 1.
- Recipe overrides become equality pins via LPInput.pinnedRecipes
  during feeder extension.
- Delete src/lib/linear-solver.ts (109 LOC heuristic) and its tests.
- Add 12 unit tests for the LP module.
- LP facility counts at non-integer rates (e.g. 1/6) leave 1e-13
  residuals in injectDisposalRecipes' surplus calc, triggering
  disposal-recipe injection at facilityCount ≈ 0.
- Mapper edge filters drop the near-zero edges, but the disposal sink
  node has no rate filter — rendered as a disconnected "0/min" node
  (e.g. Xircon Effluent on Xiranite Jade Gourd at 1, 2, 4, 5/min).
- Add SURPLUS_EPSILON = 1e-6 tolerance in injectDisposalRecipes.
- Add defensive node-rate filter (≤ 0.001) in both mappers.
- Parameterized regression test over rates 1..6/min.
- When Tarjan places a forcedRawMaterial in scc.items via a byproduct
  cycle (Clean Water from LIQUID_PURIFIER_XIRANITE_POLY_1, Precipitation
  Acid from LIQUID_PURIFIER_COPPER_ENR_1), the LP excludes it from
  balance constraints and Phase 5 misses it (only iterates
  scc.externalInputs).
- Raw item's consumption never reaches itemDemands → vanishes from plan
  output (e.g. SC Wuling Battery missing Clean Water).
- New Phase 4.5 helper propagateRawMaterialDeficit, called from both
  solveSCCFlow and tryExtendSCCWithFeeders. Mirrors master's heuristic
  Phase 4 deficit logic, restricted to raw items.
- Math.max(existing, deficit) semantics: surplus byproduct doesn't
  credit external demand (conservative; matches master).
- Regression tests for SC Wuling Battery + Hetonite Solution.
- Pass-1 reports totalRaw summing only rawCostPerFacility × x[r] over
  recipes (slack penalty excluded). Pass-2's lex_raw_cap LHS used to
  include slack vars (carrying SLACK_PENALTY = 1e6), making the
  constraint infeasible whenever pass-1 had slack > 0.
- Pass-2 then fell back to pass-1's result, skipping power minimization
  and silently violating the documented "lex raw → power" invariant.
- Bug fired in 7+ separated-mapper test runs as "[LP_SOLVER] pass-2
  infeasible after lex-cap" warnings, invisible on real data because
  affected scenarios' assertions didn't depend on the recipe choices
  that emerged from the fallback.
- Skip slack vars from lex_raw_cap coefficient assignment.
- Add defensive bounded === false check on pass-2 result.
- Regression test: 2 power-asymmetric recipes with identical raw,
  forced slack=1, asserts low-power recipe is chosen.

Reported by reviewer review of PR JamboChen#74.
@lsequeiraa lsequeiraa force-pushed the refactor/lp-solver branch from fb44867 to 6874835 Compare May 7, 2026 17:36
@lsequeiraa

Copy link
Copy Markdown
Contributor Author

Force-pushed to rewrite commit bodies for concision (more scannable in git log and on the Commits tab).

No code changes from the previous push — the file diff vs master is byte-identical (12 files, +1482 / −694), and the four logical commits are preserved with the same subjects, just shorter dashed-list bodies. The PR description's commit-hash references have been updated to the new SHAs (57bbfd2, 19c301d).

- Remove redundant block separators and trivial 'what' comments.
- Trim verbose bug-context narrative blocks; full context lives in
  commit messages (fdda578, 19c301d, 57bbfd2, 6874835).
- Keep API-contract JSDocs, magic-number rationales, phase markers,
  and defensive-guard explanations.
- Net ~87 lines of comments removed across lp-solver.ts (-29),
  flow-solver.ts (-38), and lp-solver.test.ts (-19). No code changes.
@lsequeiraa lsequeiraa marked this pull request as ready for review May 7, 2026 18:23
@JamboChen

JamboChen commented May 9, 2026

Copy link
Copy Markdown
Owner

I’m very interested in this PR overall. I had already seen some production-planning tools move toward LP-based solvers, for example FactorioLab, so the direction itself makes sense to me.

My main hesitation about introducing LP was never about correctness, but more about the default user experience for early-game players. I worried that an optimizer could naturally converge toward solutions that rely on more advanced materials or facilities, which might not always match what a beginner expects or wants. That may just be my own assumption, but I still tend to prefer defaults that produce “good beginner-friendly” plans. My assumption is that users of this tool gradually learn and refine their production chains over time; otherwise they would probably just import blueprints directly.

Also, from what I understand, the Simplex algorithm itself is one of the earliest and most mature LP algorithms, so I was actually surprised that the chosen library is relatively new by comparison, with its first release only around 2019. Could you explain why you chose javascript-lp-solver specifically instead of other LP/optimization libraries?

BTW, I have also implemented a Simplex method myself before and even made it print intermediate steps for learning purposes.

@lsequeiraa

lsequeiraa commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for digging in @JamboChen. And yes, LP-based solvers are an increasingly common pattern for these tools.

Beginner-friendliness: the LP runs after recipe selection. selectRecipe is unchanged from master, so the LP can't swap an advanced recipe for a basic one (or vice versa); that's decided upstream in DFS. The LP only adjusts facility counts within an SCC. Recipe overrides become equality constraints (pinnedRecipes) and are honored.

If anything, the lex objective (raw, then power) is more beginner-friendly than the heuristic: the four PR #73 regressions in the comparison sweep were cases where master under-allocated the Refining Unit and silently produced an invalid plan; LP scales each producer to exactly what's needed. Across the 30 scenarios LP picks the same recipe families as master on every plan, so no tier creep on current data.

A natural follow-up here: let users specify which materials and facilities they've unlocked, so the solver only considers what they have available.

Library choice: honest answer, I picked javascript-lp-solver on familiarity, not from a structured survey.

Looking again, the closest JS alternative is yalps, a deliberate rewrite of javascript-lp-solver. Per its fresh benchmarks (PR #12, March 2026), yalps is roughly 1.2× to 3× faster than javascript-lp-solver 1.0.3 across the 11-benchmark suite; bundle is ~3.7 KB gzipped vs ~15 KB. So smaller and faster on micro-benchmarks. For our scale (small SCCs, sub-millisecond solves), the delta isn't user-visible.

javascript-lp-solver 1.0.3 is a good fit. I wanted to avoid adding licensing or dependency overhead to the project, and this one fits: zero runtime dependencies (pure JS, no WASM blob) and Unlicense (public domain). On top of that, it just had a TS rewrite (3 releases in 4 months, 0.4.24 to 1.0.3 is faster on 8 of 11 benchmarks per that same PR), ~73K weekly downloads, already validated against 100 tests plus the 30-scenario harness. Staying with it for this PR, but the yalps swap is mechanical (similar JSON-ish API) if you'd prefer it as a follow-up.

@JamboChen

JamboChen commented May 11, 2026

Copy link
Copy Markdown
Owner

Thanks for the clarification and for investigating the alternatives in detail. Your explanation is very convincing to me.

LP can't swap an advanced recipe for a basic one (or vice versa); that's decided upstream in DFS. The LP only adjusts facility counts within an SCC.

This addresses most of my beginner-friendliness concern.

zero runtime dependencies (pure JS, no WASM blob)

I’m not opposed to WASM in general, but given the scale of computation here, introducing WASM prematurely would probably create more downsides than benefits. A pure-JS dependency with zero runtime overhead and a permissive license seems like a very reasonable fit for this project.

but the yalps swap is mechanical (similar JSON-ish API) if you'd prefer it as a follow-up.

For the current scale, the existing library already seems more than sufficient to me. Thanks again for the detailed investigation and explanation. @lsequeiraa

@JamboChen JamboChen merged commit d5f3f00 into JamboChen:master May 11, 2026
2 checks passed
JamboChen pushed a commit that referenced this pull request May 18, 2026
… 3) (#80)

* Refactor: Replace heuristic SCC solver with LP-based solver

- Replace Gaussian elimination + filterImpossibleDisposalRows +
  max(b/a) tie-breaking with javascript-lp-solver (~30 KB MIT).
- Each SCC sub-problem becomes an LP: facility-count variables ≥ 0,
  per-item constraints (equal/min/disposal-slack), raw materials
  excluded from balance (counted only in cost objective).
- Lex two-pass: minimize raw, then minimize power subject to raw
  cap from pass 1.
- Recipe overrides become equality pins via LPInput.pinnedRecipes
  during feeder extension.
- Delete src/lib/linear-solver.ts (109 LOC heuristic) and its tests.
- Add 12 unit tests for the LP module.

* Fix: Suppress phantom disposal sink for floating-point surplus residuals

- LP facility counts at non-integer rates (e.g. 1/6) leave 1e-13
  residuals in injectDisposalRecipes' surplus calc, triggering
  disposal-recipe injection at facilityCount ≈ 0.
- Mapper edge filters drop the near-zero edges, but the disposal sink
  node has no rate filter — rendered as a disconnected "0/min" node
  (e.g. Xircon Effluent on Xiranite Jade Gourd at 1, 2, 4, 5/min).
- Add SURPLUS_EPSILON = 1e-6 tolerance in injectDisposalRecipes.
- Add defensive node-rate filter (≤ 0.001) in both mappers.
- Parameterized regression test over rates 1..6/min.

* Fix: Propagate raw-material consumption when raw items are inside an SCC

- When Tarjan places a forcedRawMaterial in scc.items via a byproduct
  cycle (Clean Water from LIQUID_PURIFIER_XIRANITE_POLY_1, Precipitation
  Acid from LIQUID_PURIFIER_COPPER_ENR_1), the LP excludes it from
  balance constraints and Phase 5 misses it (only iterates
  scc.externalInputs).
- Raw item's consumption never reaches itemDemands → vanishes from plan
  output (e.g. SC Wuling Battery missing Clean Water).
- New Phase 4.5 helper propagateRawMaterialDeficit, called from both
  solveSCCFlow and tryExtendSCCWithFeeders. Mirrors master's heuristic
  Phase 4 deficit logic, restricted to raw items.
- Math.max(existing, deficit) semantics: surplus byproduct doesn't
  credit external demand (conservative; matches master).
- Regression tests for SC Wuling Battery + Hetonite Solution.

* Fix: Exclude slack variables from lex-pass-2 raw-cost cap

- Pass-1 reports totalRaw summing only rawCostPerFacility × x[r] over
  recipes (slack penalty excluded). Pass-2's lex_raw_cap LHS used to
  include slack vars (carrying SLACK_PENALTY = 1e6), making the
  constraint infeasible whenever pass-1 had slack > 0.
- Pass-2 then fell back to pass-1's result, skipping power minimization
  and silently violating the documented "lex raw → power" invariant.
- Bug fired in 7+ separated-mapper test runs as "[LP_SOLVER] pass-2
  infeasible after lex-cap" warnings, invisible on real data because
  affected scenarios' assertions didn't depend on the recipe choices
  that emerged from the fallback.
- Skip slack vars from lex_raw_cap coefficient assignment.
- Add defensive bounded === false check on pass-2 result.
- Regression test: 2 power-asymmetric recipes with identical raw,
  forced slack=1, asserts low-power recipe is chosen.

Reported by reviewer review of PR #74.

* Refactor: Trim verbose comments to high-signal core

- Remove redundant block separators and trivial 'what' comments.
- Trim verbose bug-context narrative blocks; full context lives in
  commit messages (fdda578, 19c301d, 57bbfd2, 6874835).
- Keep API-contract JSDocs, magic-number rationales, phase markers,
  and defensive-guard explanations.
- Net ~87 lines of comments removed across lp-solver.ts (-29),
  flow-solver.ts (-38), and lp-solver.test.ts (-19). No code changes.

* Add: Phase 3 multi-formula bin packing solver for Reactor / Expanded Crucible

Adds a third optimization phase that automatically packs pool recipes into
shared multi-formula buildings. Reactor Crucible (5 inner slots, 50W) and
Expanded Crucible (8 inner slots, 100W) can each host multiple formulas
simultaneously sharing inner-slot inventory and external port budget; the
ILP picks the optimal packing under a lex (raw → buildings → power)
objective.

Algorithm
- Phase 3 (`packCrucibleBins`) runs after `calculateFlows` produces per-recipe
  slot demands.
- Bin enumeration: DFS over recipe subsets per multi-formula facility,
  pruning combinations that exceed inner-slot or port caps.
- Equivalence classes: byte-identical recipe twins (e.g. _1/_2 pool variants)
  are pooled so the ILP can swap between Reactor and Expanded freely.
- Lex two-pass MIP via javascript-lp-solver: minimise total buildings, then
  total power. Brute-force-style fallback if MIP misbehaves.
- Recipe overrides respected: pinning forces a specific variant.
- Allocations keyed by demand recipe id (Phase 2's pick) so downstream
  consumers reach the right plan node even when the ILP swaps variants.

Type / data
- New `FacilityCapabilities` (innerSlots, liquidIn/OutPorts, beltIn/OutPorts,
  optional maxFormulas) on `Facility`. Reactor and Expanded Crucible now
  carry capabilities; other facilities default to single-formula.
- New `CrucibleBin` and `RecipeBinAllocation` shapes on `ProductionPlan`.
- `ProductionGraphNode` (recipe variant) carries `binId` and
  `binSisterRecipeIds`; the recipe's `facility` is rewritten to the bin's
  facility when Phase 3 swaps variants.

Mappers / UI
- Merged and separated mappers thread `binId` and sister recipes onto flow
  nodes. Edges between co-located producer and consumer recipes carry
  `direction: 'internal'` and skip the transport label.
- `flow-assertions` gains internal-edge invariants and bin-annotation
  completeness checks (dev-only).
- `CustomProductionNode` shows a Layers badge on grouped nodes plus a
  tooltip section listing sister formulas.
- `ProductionTable` per-row count column shows building count for grouped
  bins (with slot count below) and "(grouped)" for non-primary rows in the
  Power column. New plan-level totals footer reports total buildings,
  total power, and savings via grouping.
- All seven locales updated.

Tests (118 → 117 passing; spike removed after validation)
- `multi-formula-packing.test.ts` — 11 unit tests covering Xircon triple,
  Reactor pair, port-cap rejection, override pinning, signature equivalence,
  deterministic output, singleton fallback.
- `calculator.test.ts` — 4 new Phase 3 regression tests asserting
  `crucibleBins` / `recipeBinAllocations` correctness on real game data
  (Xircon at 30 and 60/min: 4 buildings, 400W).
- `flow-integrity.test.ts` — every active recipe has a binId, internal
  edges only connect same-bin recipes.
- `separated-mapper.test.ts` — Phase 3 bin annotations on facility nodes.

* Fix: Phase 3 demand-vs-physical id mismatch and bin-aware totals

The original Phase 3 implementation stored physical recipe ids on
`bin.recipeIds` (e.g. `lx_2` when ILP swapped to Expanded), but every
downstream consumer compared against demand recipe ids (Phase 2's pick,
e.g. `lx_1`) — silently breaking sister filtering, primary-row power
attribution, and internal-edge tagging on every Xircon-style plan.

Root-cause fix: rewrite `bin.recipeIds` to mean DEMAND recipe ids. The
bin's `facilityId` separately records the physical facility, which is
all that's needed for power and building-count cost. With this, every
consumer's plain-id comparison just works.

Bug-for-bug fixes
- `bin.recipeIds` now holds demand recipe ids; physical-recipe info
  lives on `bin.facilityId` (the only place it's needed).
- Sister-recipe filter (calculator.ts, useProductionTable.ts) drops
  the row's own id correctly; previously included the recipe's own
  twin → "4 formulas" badge for a 3-formula bin.
- Primary-row determination via `bin.recipeIds[0] === node.recipeId`
  works after Phase 3 swaps; previously every row in a swapped bin
  had `isBinPrimary = false` and the Power column showed "(grouped)"
  on every row.
- `coLocatedPairs` in the separated mapper now matches Phase 2's
  recipe ids; internal edges are tagged correctly.
- Internal direction now dominates backward when a recipe is BOTH
  in an SCC and co-located in a bin. Internal flow is always
  transport-free regardless of cycle membership; backward edges
  visualise the cycle but still imply transport.
- Cycle-node mapping in calculator.ts uses physicalFacility (from
  bin lookup) consistent with the production-graph node above.
- `applyEdgeStyling` preserves `direction === "internal"` and
  applies a distinct dashed-muted visual; previously internal edges
  lost their visual distinction at layout time.
- Totals footer in ProductionTable now reads from a hook-computed
  `PlanTotals` derived from `plan.crucibleBins` directly. Previously
  derived from rows, which undercounted when a recipe was split
  across multiple bin shapes (asymmetric demand → MIP picks split).

Quality / robustness
- Defensive `r1.result` finite-number guard in solvePacking.
- Dev-only warning when multiple `recipeOverrides` collide on the
  same equivalence class.
- Removed dead `shapeBinId` map and `void recipeMap; void itemMap;`
  parameter swallows.
- Localised the "internal" edge label via new `transport.internal`
  i18n key in all 7 locales.
- Per-row spanning-info tooltip lists every bin a recipe occupies
  when split allocations occur.

Tests
- 4 new tests in multi-formula-packing.test.ts covering demand-id
  semantics, sister filter correctness, and totals aggregation.
- 1 new test in calculator.test.ts asserting plan totals match
  `plan.crucibleBins` aggregate (split-allocation safe).
- Strengthened flow-integrity.test.ts to positively assert at least
  one internal edge exists in the Xircon plan; previously the test
  passed vacuously when zero internal edges were emitted.
- Strengthened separated-mapper.test.ts to verify the badge formula
  count matches the bin's recipe count (catches off-by-one).
- Dropped the unnecessary disposal-skip in the
  recipe-bin-allocations test; disposals DO have allocations.
123 tests pass (was 118).

* Refactor: clean up Phase 3 medium-severity review items

#1 Remove empty no-op for-loop in useProductionTable totals
   computation; replace with a clarifying comment above the
   `plan.crucibleBins` iteration.

#2 Use Facility[] from @/types in useProductionTable signature
   instead of a structural-subtype shorthand. Internal type
   inference now correctly preserves FacilityCapabilities
   through the hook.

#3 Extract resolveBinInfo closure in buildProductionGraph;
   recipe-node and cycle-node bin lookups now share one
   implementation. Removes ~25 lines of duplicated logic.

#4 Drop the dead `false` key in totalSlotsByCapability — only
   the multi-formula baseline is read. Replaced with a single
   `multiFormulaBaseline` accumulator. Also folded the second
   pass over `plan.crucibleBins` (for `multiFormulaActual`)
   into the main pass.

#5 `bin.isGrouped` now reflects `demandIds.length >= 2` instead
   of `shape.recipeIds.length >= 2`. In the theoretical edge
   case where a multi-formula shape has only one demand class
   allocated, `isGrouped` correctly returns false to match the
   bin's user-visible recipe count.

123 tests still pass; no behavioural changes to optimal plans.

* Add: bin-fused Recipe / Facility views for multi-formula buildings

Multi-formula bins (Reactor / Expanded Crucible groups) now render as
a single building card per bin in Recipe View and per physical building
in Facility View. The card focuses on outputs (headline + byproducts);
inputs come from incoming edges. Internal flows between co-located
recipes are hidden from the graph and surfaced in the tooltip instead.

For singleton bins (the common case), the cards render visually
identical to the previous per-recipe layout — no regression.

Architecture
- New `bin-fused-mapper.ts` with two mappers:
  - `mapPlanToFlowBinFused` (Recipe View, default ON): emits one node
    per bin. Headline output picked by heuristic; bin's other external
    outputs become byproducts; internal items get no edges.
  - `mapPlanToFlowBinFusedSeparated` (Facility View, always ON):
    emits `ceil(bin.buildingCount)` nodes per bin — one per physical
    building, each running all bin formulas. Per-building rates =
    bin total / buildingCount. Per-building edges (one per upstream
    item per building) match Facility View per-instance philosophy.
- `pickBinHeadlineOutput` heuristic in plan-helpers.ts:
  target -> tier -> solid -> alphabetical. Picks Xircon for the
  Xircon scenario (target + tier 3 + solid).

UI
- Recipe View toggle Buildings / Recipes near existing controls.
  Default = Buildings (bin-fused). Tooltip explains the trade-off.
  Persisted in URL hash as `bf=0` for off (default omitted).
- Facility View has no toggle — always bin-fused (matches its job
  of showing physical layout).
- CustomProductionNode enriched for grouped bins: lists all bin
  formulas, internal items, port utilization, and inner-slot count
  in the tooltip. Layers badge on the card itself.
- Per-recipe (legacy) merged-mapper stays untouched as the fallback
  when the toggle is off — useful for chain debugging.

Type extensions
- ProductionNode.binExtraOutputs — bin's external outputs other
  than the headline; rendered as byproducts on the card.
- ProductionNode.bin — full CrucibleBin reference for the tooltip's
  per-formula / internal-items / port-utilization sections.

Tests
- 12 new tests in bin-fusion-mapper.test.ts:
  - pickBinHeadlineOutput heuristic ordering.
  - Bin-fused merged mapper: one node per bin, headline correctness,
    bin metadata threading, internal flows produce no edges, no
    dangling edges in the Xircon plan.
  - Bin-fused separated mapper: N building-nodes per bin, per-building
    rate accuracy, no dangling edges.
- All 135 tests pass (123 existing + 12 new).

i18n
- New app.binFusionLabelOn/Off/Tooltip keys.
- New production.tree.formulasInBin/internalItems/innerSlotsUsed/
  portUtilization keys.
- All 7 languages updated.

* Fix: Hide internally-balanced byproducts on bin-fused production cards

CustomProductionNode rendered the headline recipe's secondary outputs
(e.g. Sewage from POOL_XIRANITE_POLY_1) on top of binExtraOutputs, so a
grouped {LX, XE, X} Xircon bin showed Sewage as a card byproduct even
though X-produced sewage and XE-consumed sewage net to zero (correctly
classified as internal in bin.internalItems).

Extract the byproduct list computation into a pure function
computeNodeByproducts(node, items) in plan-helpers.ts: for grouped bins
(node.bin?.isGrouped) it relies solely on bin.externalOutputs (via
binExtraOutputs); for singletons / per-recipe view it falls back to the
recipe's outputs as before. Singletons and per-recipe view are
unchanged because node.bin is undefined in those cases.

Add 11 pure-function tests covering grouped/singleton/per-recipe
branches and dedupe semantics.

* Fix: Bin-fused view reports active production rates and avoids idle slots

Phase 3's bin externalOutputs/internalItems used shape.netOutputs scaled
by bin.buildingCount — full physical capacity. When a recipe's Phase 2
slot demand was less than the bin's buildingCount (e.g. Xircon at
target=57: x_X=1.9 but the {LX,XE,X} bin had 3 X-slots), the bin card
displayed 90 Xircon/min instead of the actual 57 demanded by the plan,
and Sewage appeared as internal even though X under-produces it
relative to XE consumption at the active rate.

Two coordinated changes in multi-formula-packing.ts:

1. allocateSlotsToBins computes per-bin per-physical-recipe ACTIVE slot
   counts from allocEntries, then derives bin externalOutputs /
   externalInputs / internalItems from the active flows. Items netting
   to zero across active flows go to internalItems; positive nets to
   externalOutputs; negative to externalInputs. The result matches
   Phase 2 LP's mass-balance plan exactly.

2. solvePacking adds a lex pass 3 minimising Σ x_t × |recipes_in_shape_t|
   subject to building + power caps from passes 1-2. This is equivalent
   (up to a constant) to minimising total over-provisioning. For Xircon
   target=57, MIP now picks 2×{LX,XE,X} + 2×{LX,XE} (4 buildings, no
   idle slots) instead of 3×{LX,XE,X} + 1×{LX,XE} (4 buildings, one
   idle X-slot). Both packings tie on total buildings and total power;
   pass 3 breaks the tie toward the smaller-shape mix.

Test updates:
  - multi-formula-packing.test.ts: revise the optimal-triple test to
    assert slot-coverage instead of a specific packing (pass 3 now
    picks the smaller-shape variant). Add fractional-demand active-rate
    test reproducing the user's target=57 scenario, plus a focused
    pass-3 tie-break test.
  - calculator.test.ts: rewrite the target=57 Xircon integrity test to
    assert the new active-rate behaviour (Xircon=57/min, Sewage shifted
    to externalInputs at 3/min). Strengthen Inv #2 to verify bin I/O
    classification matches active per-recipe slot net flows derived from
    plan.recipeBinAllocations.

* Fix: Restore raw-material pickup node in bin-fused view for items with byproduct producers

Raw materials produced as byproducts by some bin (e.g. Liquid Water
produced by LIQUID_PURIFIER_XIRANITE_POLY_1 alongside Liquid Xiranite
Poly) had their pickup node skipped in bin-fused (bf=1) view because
the rawMaterialDemand loop did 'if (producersByItem.has(itemId))
continue' — treating any bin producer as supplying full demand.
Result at Xircon target=56: 90/min water consumer demand had no
visible source, while bf=0 correctly emitted the pickup node.

bf=0 (merged-mapper) handles this via getItemProducers returning [] for
all raw items regardless of byproduct producers, ensuring a raw pickup
is always emitted. Mirror that in bin-fused-mapper by skipping raw items
when registering producers in both merged and separated paths. The
existing rawMaterialDemand loop then fires correctly for raw items,
emitting a pickup node sized to total consumer demand. Producer bins
still show the byproduct on their card via bin.externalOutputs (no data
layer change).

Add a focused regression test asserting (a) the Liquid Water pickup
node exists in the Xircon plan, (b) no water-bearing edge originates
from the Purifier bin, (c) edges flow from the pickup to consumer
bins, (d) the Purifier bin's externalOutputs still includes water so
the bin card displays it as a byproduct.

* Fix: Production statistics aggregate multi-formula bins via shared helper

useProductionStats previously counted facility requirements per recipe
and ceiled each via getEffectiveFacilityCount, triple-counting shared
multi-formula bins. At Xircon target=6 the {LX, XE, X} bin (1 Expanded
hosting 3 recipes at fractional slot demand) reported as 'Expanded
Crucible: 3' instead of 1, ignoring Phase 3 packing.

Extract a shared aggregateBinTotals helper in plan-helpers.ts. Both
useProductionStats and useProductionTable derive bin-level totals
(totalBuildings, totalPower, perFacility, multiFormulaActual /
BaselineBuildings) from this single source so they cannot drift.
groupedSavings still computes from the always-ceiled baseline / actual
fields — physical counterfactuals stay integer regardless of ceilMode.

ceilMode now meaningfully differentiates the bin-level view:

  - ceilMode=true (physical): each bin contributes
    Math.max(1, Math.ceil(bin.buildingCount)). Power × ceiled buildings
    (a built building pays full power even at partial load).

  - ceilMode=false (theoretical): each bin contributes the MEAN of its
    recipes' active slot allocations (sum_alloc / recipe_count).
    Singletons collapse to bin.buildingCount (no change). Grouped bins
    surface the partial-load info that the integer building count would
    otherwise hide. By construction mean ≤ bin.buildingCount, so
    ceilMode=OFF never exceeds ceilMode=ON.

The bin-fused-mapper's merged path mirrors the helper in ceilMode=OFF:
a grouped {LX, XE, X} bin at Xircon target=57 with activities
(LX=2, XE=2, X=1.9) shows '×1.97' instead of '×2' on the card,
revealing the X recipe at 95% load. Singleton bins (incl. the Liquid
Purifier) are unaffected. The Facility View (separated bin-fused path)
is inherently a physical per-building view and is unchanged.

Adds buildBinActivitySums as an exported helper so the mapper and the
aggregator share the once-per-call walk of plan.recipeBinAllocations.

* Fix: Facility View ignores bf=0 toggle from Recipe View

The Recipe-View bin-fusion toggle is only rendered when
visualizationMode === 'merged' (ProductionViewTabs.tsx), but the
underlying binFusion boolean was still threaded into the separated
branch of the mapper-selection ternary. When the user persisted bf=0
in the URL hash and switched from Recipe View to Facility View, the
legacy per-recipe-instance separated mapper was used instead of the
bin-fused per-building mapper, violating the documented invariant on
ProductionDependencyTree's binFusion prop ('Has no effect on Facility
View, which is always bin-fused.').

Collapse the separated branch so Facility View always picks
mapPlanToFlowBinFusedSeparated regardless of the binFusion flag. The
URL hash state itself is preserved across view switches: returning to
Recipe View still restores the user's chosen toggle position.

mapPlanToFlowSeparated remains exported for the test suites
(separated-mapper.test.ts, flow-integrity.test.ts); only the
production runtime path is collapsed.

* Update: Bin-fusion toggle uses a stable label and hides when inert

The toggle's label previously swapped between two value-nouns
('Buildings' / 'Recipes') depending on its state, causing a visible
width shift on every flick in EN / ES / RU / KO locales (the CJK
locales happened to be equal-width by coincidence of the chosen
wording).

Adopt the convention used by the sibling switches (ceilMode,
twoEndAlignment): a single stable label naming the concept being
toggled. New EN label 'Group recipes'; the switch position alone
conveys state. Tooltip also simplified to a one-sentence outcome
description: 'Combine recipes that share a building into one card.'
The 'for chain debugging' justification for the OFF state is dropped
— if a user is poking the toggle they will see what happens.

Also hide the toggle entirely when the plan contains no grouped bins.
The signal is plan.crucibleBins.some((bin) => bin.isGrouped) — true
exactly when at least one bin packs ≥2 demand recipes, which is the
only case where ON / OFF produce different visualisations. The
binFusion state is preserved in URL hash and React state across
visibility transitions; if the user later targets a multi-formula
chain, the toggle reappears in their previously-set position.

All 7 locales updated with new label + tooltip strings.

* Refactor: Adopt upstream factory-buildings schema for Facility

Aligns the calc-side Facility type with the game-data dump emitted by the
upstream FactoryBuildingTable extractor. Drops the calc-only
FacilityCapabilities aggregate in favor of the dump's native shape
(channelsIn / channelsOut + cacheSlots), letting the solver read the same
data the game does without an intermediate translation layer.

Phase A — schema refactor:

  - Facility now carries numId, tier, category, powerConsumption,
    channelsIn, channelsOut, optional cacheSlots, domains, cap.
  - Multi-formula capability is signalled by cacheSlots != null
    (replacing capabilities != null).
  - Distinct-item port caps are derived from channel counts at the call
    site:
        liquidInPorts  = channelsIn.pipe.length
        liquidOutPorts = channelsOut.pipe.length
        beltOutPorts   = channelsOut.belt.length
    Belt-input variety remains uncapped wrt bin packing; the unused
    beltInPorts check is removed from multi-formula-packing.ts.
  - Channel topology (per-pipe / per-belt port counts), category, numId,
    domains, and placement caps are preserved as advisory data for
    future consumers (per-channel routing visualisation, placement-aware
    planning, categorical filters). Today's solver does not read them.

Phase B — FacilityId rename (item_port_* prefix dropped):

  - FacilityId enum re-keyed to bare game IDs (component_mc_1,
    dismantler_1, …). Four meaningful renames (cmpt → component,
    filling_pd → filling_powder, seedcol → seedcollector,
    tools_asm → tools_assebling) plus 12 simple prefix strips, sourced
    from FactoryBuildingItemTable.json's buildingId fields.
  - Three transport buildings (loader_1, pump_1, pump_2) added to the
    enum + facilities catalog as inert data, ready for future
    transport-aware planning; no recipe references them today.
  - All recipe.facilityId references rewritten (~260 occurrences).
  - Image files renamed via git mv (history preserved).
  - Facility i18n keys renamed in all 7 locales; translations unchanged.

Test fixtures across multi-formula-packing.test.ts, plan-helpers.test.ts,
calculator.test.ts, capacity-pool.test.ts, lp-solver.test.ts, and
fixtures/test-data.ts are restructured inline to the new schema — no
semantic-translation helper layer. The facility test-helper in
multi-formula-packing.test.ts becomes a thin Partial<Facility> defaults
wrapper so authors write the dump-shape fields they care about
explicitly.

CustomProductionNode's port-utilisation tooltip now reads channels
directly. plan-helpers' multi-formula bin filter switches from
facility.capabilities to facility.cacheSlots != null.

* Refactor: Clean up bin-fused-mapper and harden resolveBinInfo fallback

- Replace hardcoded transport-capacity constants (120/30) in
  bin-fused-mapper with the shared getTransportCapacity() helper so
  pipe/belt capacity stays consistent with the rest of the calculator.
- Remove unused ProductionGraphNode type import and the obsolete `void`
  workaround block at the end of bin-fused-mapper; current lint config
  no longer requires it.
- Gate resolveBinInfo's documented "rare" fallback paths behind an
  import.meta.env.DEV warning so future Phase 3 coverage regressions
  surface during development.

* Refactor: Rename Facility channels to buffers to match engine terminology

The upstream factory-buildings dump renamed Channel/Channels and the
channelsIn/channelsOut fields to Buffer/Buffers and buffersIn/buffersOut,
aligning the schema with the game engine's own naming. The engine uses
'buffer' to describe the binding/grouping layer that ties one or more
physical ports to one cache — see *BufferBinding[] arrays in
FactoryMachineCraftGroupTable.json and buildingBufferStackLimit in
FactoryItemTable.json.

Mechanical rename across consumers:
  - src/types/core.ts: Channel → Buffer, Channels → Buffers, fields and
    exports updated; doc comments rewritten to use 'buffer' prose.
  - src/data/facilities.ts: all 19 entries.
  - src/lib/multi-formula-packing.ts: solver reads
    facility.buffersIn.pipe.length etc.; header comments updated.
  - src/components/nodes/CustomProductionNode.tsx: port-utilisation
    tooltip reads buffer counts.
  - All test fixtures (multi-formula-packing, plan-helpers, lp-solver,
    capacity-pool, fixtures/test-data) updated inline.

No behavior change. Cap derivation rules unchanged:
  liquidInPorts  = buffersIn.pipe.length
  liquidOutPorts = buffersOut.pipe.length
  beltOutPorts   = buffersOut.belt.length

* Fix: Bin-fused mappers skip target-sink emission for zero-rate targets

A target with rate=0 (reachable via a URL hash like #t=item_steel:0,
which the parseHash parser accepts via rate>=0) caused both bin-fused
mappers to emit an isolated target-sink node. The consumer-registration
loop correctly skips zero-rate targets via the existing
`if (userTargetRate <= 0.001) return;` guard, but the sink-emission
loops were missing the matching guard — so the emitted sink had no
incoming edges and tripped assertFlowIntegrity's "isolated node"
warning in dev mode for legitimate user input.

Mirror the same guard in both sink-emission loops so registration and
emission stay in lock-step. Add regression tests in
bin-fusion-mapper.test.ts for both mapper variants.

* Refactor: Drop Crucible prefix from algorithm-agnostic bin types

Multi-formula bin packing is a building-family-agnostic concept; future
multi-formula facilities should slot in without semantic friction.

Identifier renames: CrucibleBin → Bin, crucibleBins → bins,
packCrucibleBins → packBins, [CRUCIBLE_PACKING] → [BIN_PACKING],
tree.crucibleGroup → tree.multiFormulaGroup.

User-facing label "Crucible Group" → "Multi-Formula Building" (and
locale equivalents); savingsExplain swaps building-specific nouns for
agnostic "buildings". Abstract algorithm prose generalized; test
scenarios + historical bug refs kept specific. In-game building names
in facility.json untouched.

* Fix: Multi-formula packer honors per-pin overrides via split constraints

Restructure solvePacking and allocateSlotsToBins so each pinned demand
recipe gets its own restricted constraint, combined with the existing
class-wide total. When two items in the same equivalence class are
pinned to different recipe variants (e.g. tier-1 vs tier-2 facility),
both pins are honored simultaneously instead of the old "last wins"
heuristic that silently dropped one. Pinned demands allocate first so
they claim restricted shapes before unpinned demand can substitute.

Surface infeasible pins (override restricting to a recipe with no
valid bin shape) via a new ProductionDependencyGraph.warnings field
plumbed through PackingResult -> calculator -> useProductionPlan's
warnings banner. Users get a diagnostic instead of silent fallback.

Tests cover the conflict, single-pin + unpinned coexistence, and
infeasible-pin fallback cases.

* Update: SavedPlan persists binFusion toggle

Add optional binFusion field to the SavedPlan JSON schema so file
save/load round-trips preserve the user's view toggle. Legacy saves
that predate bin-fusion omit the field; the loader defaults to true
to match parseHash and the in-app default.

Resolves the inconsistency where the URL hash kept bf=0 but the JSON
save/load path silently snapped the toggle back to default.

* Fix: Bin-fused mappers fold singleton-terminal targets into target sink

Restore bf=0 parity for simple A -> B chains by detecting singleton-
terminal bins (single recipe, single external output, sole producer
of a target with no other consumers) and folding their card into the
target sink's embedded productionInfo chip — matching the existing
isRecipeTerminal collapse in merged-mapper.ts.

Bake the bin -> sink redirect into producersByItem / consumersByItem
construction rather than remapping post-hoc: skipped bins are excluded
from producer entries, and their input items have consumer entries
keyed to the target sink id so upstream edges land on the sink
directly. This is the merged-mapper terminal-recipe edge rerouting
(merged-mapper.ts:204-211) but at map-build time. Two regressions
hit by the post-hoc approach (isolated raw_item_liquid_water +
target-sink-item_xiranite_powder on Xiranite Powder, missing star
ribbon on Xircon Poly @ 60/min Facility View) trace to the
"phantom entries that get retroactively dropped" pattern.

For the separated mapper, the skip gate additionally requires
ceil(buildingCount) === 1 so multi-building targets keep their
per-building cards. The per-building emission options now also set
isDirectTarget / directTargetRate based on the headline-target match,
restoring the amber Star ribbon for terminal multi-facility targets
(previously hardcoded false).

Elevate assertFlowIntegrity to throw on any violation in test mode
(import.meta.env?.MODE === "test") so dangling edges, isolated nodes,
and cross-bin internal flows become hard test failures instead of
silently-warning console noise. Production browser behavior unchanged
(still warns).

Tests cover: skip+embed parity for iron-nugget (Recipe + Facility),
Xiranite Powder regression (multi-input terminal), multi-building
counter-case with no embed, star ribbon for Xircon Poly grouped and
iron-nugget singleton multi-building, plus no-isolated-node guards
in every new test.

* Fix: Bin-fused mapper cycle layout, target allocation priority, and ELK priority clamping

Three related correctness fixes for bin-fused rendering and layout:

1. Backward edge tagging for cycle layout (mapPlanToFlowBinFusedSeparated):
   build cyclePairs from plan.detectedCycles and tag bin-to-bin edges
   between cycle-partner recipes with direction="backward". layout.ts
   reads this to set elk.layered.priority.direction, deprioritizing
   cycle edges so ELK reverses them during layered cycle breaking
   instead of arbitrary forward edges. Restores parity with
   mapPlanToFlowSeparated:64-76,287-296 — without the tag, multi-bin
   cycles like the moss seed loop (planter <-> seedcollector) laid out
   inconsistently between bf=0 and bf=1 in Facility View.

2. Target priority over disposal in greedy allocation (both bin-fused
   mappers): swap consumer registration order so target sinks are
   added BEFORE disposal-bin consumers in consumersByItem. The greedy
   allocator iterates in insertion order; previously disposal got
   first pick at producer output, leaving target sinks epsilon
   under-allocated in floating-point edge cases. Matches the
   target-pass-then-disposal-pass structure of mapPlanToFlowSeparated.

3. ELK priority.direction clamping (layout.ts): the option has lower
   bound 0 per ELK docs, so the previous "-10" value for backward
   edges was silently clamped to 0. Now uses "0" explicitly with a
   comment documenting the option semantics. Same effective behaviour
   on master but expresses the intent correctly.

Tests:
- New: moss seed cycle edges carry direction=backward (#3).
- New: target sink incoming edges sum to userTargetRate in both views
  (#6).

* Refactor: Remove dead-code legacy separated mapper

mapPlanToFlowSeparated has been the legacy per-recipe Facility View
mapper since the bin-fused mapper was introduced. Commit 7a9eb7b
("Facility View ignores bf=0 toggle from Recipe View") removed its
last production call site — `ProductionDependencyTree.tsx` now always
uses mapPlanToFlowBinFusedSeparated for separated mode. The legacy
mapper has been kept alive only by its own test file and three test
cases in flow-integrity.

Migrate the surviving regression coverage and delete:

- src/components/mappers/separated-mapper.ts (~900 LOC).
- src/tests/lib/separated-mapper.test.ts (~350 LOC).
- src/lib/node-keys.ts: createPickupPointId (only used by the
  deleted mapper).
- src/lib/utils.ts: getOutputAmount and calcByproductRate (likewise).
- src/lib/utils.ts: remove unused Recipe type import.

Migrated coverage:

- flow-integrity.test.ts: replace the three separated integrity
  cases (copper_nugget, iron_nugget, xiranite_poly) with bin-fused
  Recipe + Facility variants. Drop the "internal-flow edges only
  between same-bin recipes" test entirely — bin-fused has no
  cross-bin internal edges (intra-bin flows are hidden as
  internalItems), so the invariant doesn't apply.

- bin-fusion-mapper.test.ts: add the Battery SCC scenario (from
  byproductSCCRecipes synthetic data) to exercise a different graph
  topology than Xircon, and the Phase 3 sister-count off-by-one
  regression test (real data, grouped Xircon bins).

Doc comments in bin-fused-mapper.ts continue to reference the now-
deleted file as historical context — those references document the
design rationale and are intentionally retained.

Net change: -1280 LOC.

* Refactor: Audit and trim PR comments

Sweep of comments added or rewritten throughout the bin-fused mapper
work. Net -21 LOC across 5 files; zero behaviour change.

Fixes (Tier 1) — broken cross-file references after the legacy
separated-mapper deletion:
- bin-fused-mapper.ts: 8 comment blocks referencing the now-deleted
  mapPlanToFlowSeparated / separated-mapper.ts. Replaced with
  behavioural descriptions or trimmed where the comment was self-
  contained.
- bin-fusion-mapper.test.ts: 3 broken refs in test rationale comments
  rewritten; 2 "Migrated from now-deleted separated-mapper.test.ts"
  historical markers removed (the rationale is what matters).
- capacity-pool.ts: docstring referenced allocateFromPool, a function
  from the deleted mapper. Rephrased to "activated by the caller via
  markProcessed".

Consolidations (Tier 2):
- bin-fused-mapper.ts: the singleton-terminal detection block in the
  Facility View function shrank from a duplicate of the merged-view
  explanation to a short cross-reference plus the extra N === 1 gate.
- bin-fused-mapper.ts: replaced opaque "Option A vs Option B from the
  design discussion" prose with the concrete technical reasoning
  (alternative left phantom state in maps; isolated-node bugs traced
  back to that pattern).
- flow-assertions.ts: "Xiranite Powder isolated-nodes" name-checked
  twice; kept as motivating example in one location, generalised the
  other.

Fluff removal (Tier 3):
- bin-fused-mapper.ts: dropped pure-label "// Bin classification."
  comment; collapsed 4 redundant lines in the edge-emit defensive
  filter into one.

Line-number stripping (Tier 4):
- All file:line citations replaced with file-only references. Line
  numbers drift; the symbol/concept references are stable. Touched
  bin-fused-mapper.ts (4), bin-fusion-mapper.test.ts (2), and
  plan-helpers.test.ts (1, pre-existing stale ref).

* Refactor: Remove dead-code CapacityPoolManager and tighten leaky exports

Tier A — dead-from-app code removed after the legacy separated mapper
deletion (commit 02051da):
- src/components/flow/capacity-pool.ts (~220 LOC). CapacityPoolManager
  was only used by mapPlanToFlowSeparated; the bin-fused mapper has
  its own per-building rate computation and doesn't need it.
- src/tests/lib/capacity-pool.test.ts (~220 LOC). 10 tests for the
  deleted implementation.
- src/types/flow.ts: drop FacilityInstance, CapacityPoolEntry, and
  AllocationResult types — orphaned with the implementation.

Tier B — tighten 5 leaky `export` keywords + delete 1 unused type
surfaced by knip:
- src/lib/graph-builder.ts: drop `isDismantleRecipe` from the re-export
  block (only used inside the file); delete `export { getOrThrow }`
  entirely (likewise internal).
- src/lib/plan-helpers.ts: drop `export` from `getRecipeOutputItemIds`
  (only used by `getRecipeOutputItemId` within the same file).
- src/lib/utils.ts: drop `export` from TRANSPORT_BELT_CAPACITY and
  TRANSPORT_PIPE_CAPACITY (consumed only by `getTransportCapacity`
  inside the same file).
- src/components/flow/flow-utils.ts: delete `AggregatedProductionNodeData`
  type entirely — defined but never imported.

Net: -501 LOC. Tests drop from 211 to 201; both implementations are
covered exclusively elsewhere now. Knip drops 5 unused exports and the
1 unused type from its surface area; the remaining unused exports are
shadcn UI subcomponents intentionally kept for future use.

* Update: CLAUDE.md reflects bin-fused architecture and current invariants

Refresh project memory for the Phase 3 multi-formula packing work that
landed in this branch. Trim documentary content (which Claude can
infer from `ls` / `grep`) so each retained line carries a rule or
non-obvious decision.

Restructures:
- "Repository Map" becomes "Where critical logic lives" — 7 non-obvious
  files explicitly called out; the exhaustive enumeration is gone.
- New "Bin-fused architecture" section: mapper-selection table plus
  the singleton-bin-for-uniformity rule.
- "Algorithm invariants" drops two stale entries (merged+separated
  mapper agreement, backward-byproduct allocateByproduct) and adds
  six new ones from the bin-fused / packer work (demand-id rewrite,
  active-rate I/O, aggregateBinTotals as single source of truth,
  per-pin + class-total constraints, singleton-terminal detection
  ordering, target-before-disposal allocator order,
  assertFlowIntegrity test-mode throw).
- "Anti-patterns" updates the separated-mapper reference to
  bin-fused-mapper and adds five new ones (aggregateBinTotals bypass,
  late singleton-terminal detection, disposal-before-target consumer
  registration, direct solver.Solve calls, negative ELK priority).
- "Terminology" drops inferable terms (SCC, productionRate,
  actualOutputRate) and adds Bin / singleton vs grouped /
  internal items.
- "Internationalization" trimmed to the recipe-namespace generation
  rule; file-path documentation removed.

Stale references purged: no remaining mentions of `separated-mapper`,
`CapacityPoolManager`, `capacity-pool`, `actualOutputRate`, or
`calcByproductRate` — all of which were deleted on this branch.

Final size: 133 lines (down from 138). Within the under-200 target
recommended by Anthropic's CLAUDE.md guidance; rule density up.

* Update: Bin-fused card with internal-items row and grouping chip

Redesign the production-node card to make multi-formula bins
self-explanatory at a glance and drop the floating purple Layers
badge that overlapped the item icon.

Zone 1 unchanged: primary output + byproducts in the bin's
externalOutputs.

NEW Zone 1.5 (grouped bins only): a muted, dashed-bordered row
showing internal items as small icons. Driven by
`node.bin?.internalItems` — only rendered when the variant has
items balanced internally (e.g., Liquid Xiranite in a {LX, XE, X}
bin). Items are de-emphasised (60% opacity, hover for name) to
clearly distinguish from Zone 1's exported items.

Zone 2 chip becomes two lines for grouped bins:
- Line 1: facility icon + name + `×N` count (identical to
  singleton chip — full chip width, no truncation).
- Line 2: `Boxes` icon + `N formulas` text. Muted styling.

The floating `-top-1 -left-1` purple `Layers` badge is gone —
all grouping signal now lives inline in the chip and Zone 1.5.
Singleton chips are byte-identical to their pre-change rendering.

New i18n key `tree.internal` (English fallback "Internal").

* Fix: Bin packer enforces strict-equality demand with sub-visible variant filter

Reworks Phase 3 (multi-formula packing) from a flexible-LP that
permitted over-production into a shape-variant enumeration that
honours Phase 2 demand exactly, and filters out LP-floating-point
residue before it surfaces as orphan nodes downstream.

# Path H: shape-variant enumeration (multi-formula-packing.ts)

For each multi-formula class, enumerate every combination of
recipes a building could host (the 'shape variants'), and for
each variant pre-compute its canonical rate-direction vector by
Gaussian-eliminating the null space of its rate ratios. The LP
then chooses how many buildings of each variant and a single
scale `u_v` per variant, instead of one continuous slot count
per (recipe, building) pair.

This makes port-cap safety a property of the variant set itself
(every variant in the set is feasible by construction) rather
than an after-the-fact constraint the LP has to satisfy via
indicator variables or big-M.

Hybrid solver path: integer MIP when `variants.length < 30 &&
maxClassDemand < 10` (typical 1-3 target plans, optimal building
count); continuous-LP-and-round-up otherwise (high-variant or
high-demand plans like 12-target or 156-slot, avoids 90s B&B).

# Strict equality (fixes Xircon 6/min -> 9.60 over-production bug)

Demand constraints are now `{ equal: D }` instead of `{ min: D }`.
The previous min-style let the LP pick a V1 variant with `u=0.32`
that forced X to over-produce. Strict equality + variant
enumeration spans the conic hull of singletons so feasibility is
preserved.

Test-mode `throw` on equality-LP infeasibility (catches future
data drift); production falls back to `emitSingletonBins`.

# Sub-visible variant filter (fixes orphan-node bug)

Variants emitting rates below `MIN_VISIBLE_RATE_PER_MIN` (0.001/min)
are skipped at packer-emission time. Without this, LP FP residue
(u ~1e-6) in vestigial 2-recipe variants emitted real `Bin`s with
~3e-5/min rates that the mapper then dropped, leaving the bin
with no incident edges and triggering `assertFlowIntegrity`.

Filter uses `max(input_amount, output_amount)` per item — covers
recipes like X consuming 2 LXP per 1 Xircon where input bound
is tighter than output bound.

Repro: 3-target plan {Hetonite Part, SC Wuling Battery, Yazhen
Syringe} @ 6/min each.

# Shared visibility threshold (flow-thresholds.ts)

NEW module exporting `MIN_VISIBLE_RATE_PER_MIN = 0.001`. Replaces
21 hardcoded `0.001` literals in `bin-fused-mapper.ts`. Used
both at packer emission and at mapper edge construction so the
two views agree by construction.

# FP-tolerance pickup count (bin-fused-mapper.ts:908, :1062)

`Math.ceil(totalDemand / transportCap)` rounded 480.0000001 / 120
to 5 instead of 4, creating phantom 5th pickup with zero rate.
Now: `Math.ceil((totalDemand - MIN_VISIBLE_RATE_PER_MIN) / transportCap)`.
Same threshold applied at both sites.

# Type changes (production.ts)

`Bin.variantId: string` — required field identifying the shape
variant the bin instantiates. Threads through plan-helpers and
mapper assertions.

# Invariant strengthening (multi-formula-packing.ts)

`assertBinPortCaps` runs in test mode (throws) and dev (warns) —
catches packer regressions that produce bins exceeding facility
`maxInputs`/`maxOutputs` budget.

# Test infra

- vite.config.ts: import from `vitest/config` (was `vite`),
  `testTimeout: 30000` for slower MIP plans in CI.
- multi-formula-packing.test.ts: +12 tests covering port-cap
  invariants, equality infeasibility throw, variant enumeration,
  sub-visible filter.
- bin-fusion-mapper.test.ts: 3-target orphan-bin regression
  (both Recipe and Facility views); `variantId` added to 4
  `Bin` fixtures.
- calculator.test.ts: Xircon target=N tests aggregate rate
  across bins (was per-recipe).
- plan-helpers.test.ts: `variantId` added to 5 `Bin` fixtures;
  `mean strictly below buildingCount` relaxed to accommodate
  per-variant scaling.

# Verification

- 205/205 tests pass in 5.8s.
- 5/5 determinism runs identical.
- 25/25 sweep combinations (5 target items x 5 rates).
- 38ms for 12-target plan.
- 3/3 disposal-injection scenarios.

* Refactor: Drop dead code and redundant brand casts in bin-fused pipeline

Cleanup commit; no runtime behaviour change. Each item identified by
the post-PR review of feat/expanded-crucible-packing.

- Remove 6 redundant `as ItemId` casts in bin-fused-mapper.ts. The
  `ProductionGraphNode` type already brands `itemId: ItemId` inside
  the `type === "item"` variant, so casts after the early-return
  guard are no-ops. CLAUDE.md anti-pattern.

- Hard-delete `effectiveTotals` fallback in ProductionTable.tsx. The
  fallback recomputed plan totals from row data using `Math.ceil`
  unconditionally — bypassed `aggregateBinTotals` and ignored
  `ceilMode`, contradicting the single-source-of-truth rule. Dead in
  practice (the sole caller, ProductionViewTabs, always passes
  `tableData.totals`). Footer now reads `totals.*` directly; the
  prop becomes required.

- Drop unused `facilities` prop from ProductionTable. Declared in the
  prop type but never referenced inside the component body. Caller
  (ProductionViewTabs) drops the corresponding argument.

- Migrate 4 bare `0.001` literals in merged-mapper.ts (edge skip,
  target-allocation gate, disposal-rate gate, disposal-edge skip) and
  2 in plan-helpers.ts (`computeGreedyAllocation` demand and
  available thresholds) to `MIN_VISIBLE_RATE_PER_MIN` from
  flow-thresholds.ts. Completes the threshold-extraction work started
  for the bin-fused pipeline; merged-mapper and the greedy allocator
  now share the same visible-rate cutoff as the packer.

- Fix off-by-one `colSpan={9}` -> `colSpan={8}` on the empty-state
  row in ProductionTable. The table renders 8 `TableHead`s.

- Move `CustomBezierEdge` import from line 198 to the top of
  ProductionDependencyTree.tsx alongside the other 13 imports.

- Fix in-place mutation in `handleTargetChange`. Previously the
  setter shallow-copied the array but kept `prev[index]` reference
  identity intact while mutating its `rate` field. Now uses
  `prev.map` to produce a fresh object at the target index too —
  safe for any consumer that memoises off object identity.

- Add comment on `tools_assebling_mc_1` in constants.ts noting the
  typo is preserved verbatim from the upstream
  `FactoryBuildingTable` dump (the `bun run extract:recipes`
  pipeline matches upstream IDs, image asset, recipe references —
  renaming locally would break the chain).

- Add comment on `displayPlan` clarifying that `plan.bins` and
  `plan.recipeBinAllocations` are intentionally not filtered. Phase
  3 only emits bins for recipes with positive slot demand, so the
  surviving-bin set is always consistent with the surviving-node set
  after zero-rate filtering.

Pre-ship: 205/205 tests pass; lint clean of new warnings (only
pre-existing shadcn react-refresh warnings); build succeeds; knip
surfaces no new unused exports.

* Add: Solver timeout guard and pin packer invariants with tighter tests

Defensive infrastructure plus contract pinning. No runtime behaviour
change on current workloads.

# Solver timeout

`solver.Solve` on `javascript-lp-solver` v1.0.3 has a documented
class of bugs around degenerate LP cycling (#112) and tight-equality
MIP B&B that can hang indefinitely on pathological problems. The
existing `USE_INTEGER_LP` variant-count threshold protects against
the typical cliff, but it's a soft guard — a 25-variant plan with
maxClassDemand=9 stays on the integer path even if the specific
problem turns out to be pathological.

Add `options: { timeout: 30000 }` to all three lex-pass models in
`solvePacking`. The solver returns the best integer solution found
so far on timeout (or `feasible: false` if none); our existing
try/catch + lex fall-back chain catches the latter and degrades to
`emitSingletonBins`. 30 s matches vitest's `testTimeout` in
`vite.config.ts`.

# Strict-equality demand: per-recipe upper-bound assertions

Existing tests asserted `allocated ≥ phase2 − 1e-6` only. Under
strict equality (`{ equal: demand }`) the allocation should EQUAL
demand, not just exceed it. A future regression that re-introduced
over-allocation (the original Xircon target=6/min bug — Phase 3
emitted 9.6/min from an under-constrained LP) wouldn't be caught.

- `multi-formula-packing.test.ts`:
  - "optimal triple: 4 buildings on Expanded" now asserts
    `toBeCloseTo(demand, 2)` per recipe (±0.005 slot tolerance).
  - "fractional demand at target=57" same treatment.
- `calculator.test.ts`:
  - Renamed parameterised test from "Phase 3 allocation ≥ Phase 2"
    to "Phase 3 allocation matches Phase 2 (strict equality)".
  - Now reads `alloc.perBin.slots` (the true per-bin allocation)
    instead of `bin.buildingCount` (which only bounds it from
    above). Asserts `phase2 − 0.005 ≤ allocated ≤ phase2 + 0.005`.
  - Keeps the building-count lower-bound check as a separate
    physical invariant.

The 0.005-slot tolerance is generous against the documented
sub-visible-variant rate drift (< 0.005 items/min cumulative per
plan, see `docs/path-h-design.md` Trade-off 1) while still
catching over-provisioning down to ~0.5%.

# `bin.recipeIds` ascending-sort contract pinned

`useProductionTable.ts:263` relies on `bin.recipeIds[0]` being the
alphabetically-first recipe id (the "primary row owns the power"
heuristic — non-primary rows in grouped bins show "(grouped)" and
zero power). The packer guarantees this at
`multi-formula-packing.ts:1306` via `.sort()`, but no test
previously pinned the contract.

New test in `deterministic-output` describe creates a grouped bin
where the demand-recipe IDs are intentionally not in alphabetical
order in the input (`z_recipe`, `a_recipe`). Asserts every emitted
bin's `recipeIds` is strictly ascending, and the grouped bin
specifically places `a_recipe` first — proving the contract is
active, not vacuously satisfied.

Pre-ship: 206/206 tests pass; lint clean of new warnings; build
succeeds.

* Add: BinId brand for type-safe bin identifier propagation

Type-system-only change; no runtime behaviour change. Closes the
brand-discipline gap noted by the post-PR review.

# Motivation

`ItemId`, `RecipeId`, and `FacilityId` are nominally-typed via
literal-string unions of the closed game-data enum
(`src/types/constants.ts`). Bin IDs are the only first-class plan
identifier without nominal typing — they're dynamically constructed
by `makeBinId` and previously typed as plain `string`. Without a
brand:

  - `bin.id` could be silently swapped with a `RecipeId` or any
    other string at the call site.
  - Mapper synthetic sink IDs (`disposal-<recipeId>`,
    `<binId>-bldg<idx>`, target-sink IDs) were
    structurally indistinguishable from real `Bin.id` values in the
    producer/consumer maps.
  - `useProductionTable.ts`' `primaryRecipeId = bin.recipeIds[0]`
    convention relied on the packer's sort contract via plain
    string-equality.

# Approach

Add `BinId = string & { readonly __brand: "BinId" }` to
`src/types/constants.ts` (and re-export through `@/types`). Brand
intersection (not literal-string union) is the right pattern here
because the value set is not enumerable.

Thread `BinId` through:
- `Bin.id` (the canonical home).
- `RecipeBinAllocation.perBin[].binId`.
- `ProductionNode.binId`, `ProductionGraphNode.binId` (the
  recipe-variant in production graph).
- `ProductionLineData.binId`,
  `ProductionLineData.binSpanningInfo[].binId` (table rows).
- `buildBinActivitySums`' return type: `Map<BinId, number>`.
- `resolveBinInfo`' return type in calculator.ts:
  `binId: BinId | undefined`.
- `bin-fused-mapper.ts`'s `singletonTerminalBinIds: Set<BinId>`,
  `binsById: Map<BinId, Bin>`, `buildingInstanceId(binId: BinId,
  idx)`, `binIdFromInstanceId`' return type.
- `makeBinId` in `multi-formula-packing.ts`: returns `BinId` via
  a single `as BinId` cast at the construction site (the only
  permissible cast — every other site receives `BinId` by
  inheritance through the type system).

# Boundaries kept as plain `string`

- Mapper `ProducerEntry` / `ConsumerEntry` `binId: string` is a
  union — either a real `BinId` or a synthetic sink ID. Comment
  clarifies the mixed semantics.
- Per-building instance IDs (`<binId>-bldg<idx>`) are derived
  syntactic IDs; stay `string`.
- Target sink IDs (`createTargetSinkId(...)`) and disposal sink
  IDs (`disposal-<recipeId>`) stay `string`.

# Test fixtures

`bin-fusion-mapper.test.ts` (5 sites) and `plan-helpers.test.ts`
(5 sites) now use `"bin-..." as BinId` for synthetic fixture
construction — analogous to existing `as ItemId` / `as RecipeId`
casts elsewhere in the same test files. One additional
`n.id as BinId` cast at the test boundary where React Flow node
IDs (plain string) are compared against a `Set<BinId>` built from
real bin IDs.

# Pre-ship

- `bunx tsc -b --noEmit` clean (type-check-only).
- 206/206 tests pass.
- 4 sensitive suites individually pass: calculator (92), flow-
  integrity (10), bin-fusion-mapper (30), multi-formula-packing
  (24).
- Lint clean of new warnings (3 pre-existing shadcn warnings remain).
- Build succeeds.

* Refactor: Tighten as-never test fixture casts to as-unknown-as branded types

Test-only churn; no runtime effect.

`bin-fusion-mapper.test.ts` previously used `as never` /
`as never[]` to cast synthetic fixture IDs into the closed-enum
literal-union types (`FacilityId`, `RecipeId`). `never` is the
bottom type and assignable to anything, so it worked, but it
obscures both the intent ("this is a synthetic test ID") and the
target type.

Replace with the conventional double-cast pattern `as unknown as
TypeName`, matching the existing precedent at line 82
(`new Set(["a"] as unknown as ItemIdType[])`).

Changes:
- Add `FacilityId as FacilityIdType` import alongside existing
  `ItemIdType` / `RecipeIdType` aliases.
- 6 `facilityId: "fac" as never` -> `as unknown as FacilityIdType`
  (mkRecipe helper + 5 Bin fixtures).
- 5 `recipeIds: [...] as never[]` -> `as unknown as RecipeIdType[]`
  (5 Bin fixtures with synthetic recipe IDs `ra`/`rb`/`rs`/`rl`
  or empty).
- 2 `b.facilityId === ("liquid_purifier_1" as never)` ->
  `as FacilityIdType` (single-cast — `liquid_purifier_1` is a real
  FacilityId enum value, no double-cast needed).

Pre-ship: TS clean, 206/206 tests pass, lint clean of new warnings,
build succeeds.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants