Skip to content

Commit 4cdb043

Browse files
committed
fix(scenario): prevent sink feedback from ratcheting goals
1 parent ae593be commit 4cdb043

7 files changed

Lines changed: 76 additions & 25 deletions

File tree

app/src/server/factorio.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ export const applyFactoryRebalanceFn = createServerFn({ method: "POST" })
404404
let passes = 0;
405405
let residual = 0;
406406
const failedBlocks = new Map<number, string>();
407+
const sinkBaselines = new Map(
408+
base.flatMap((block) =>
409+
[...block.startGoals]
410+
.filter(([, rate]) => rate < 0)
411+
.map(([good, rate]) => [`${block.id}\u0000${good}`, rate] as const),
412+
),
413+
);
407414
for (; passes < REBALANCE_MAX_PASSES; passes++) {
408415
// Pass one uses SQLite's persisted boundary flows. On later passes, only
409416
// re-solve docs whose rate changed; unchanged blocks keep their latest
@@ -452,7 +459,7 @@ export const applyFactoryRebalanceFn = createServerFn({ method: "POST" })
452459
status = "Infeasible";
453460
break;
454461
}
455-
const result = factoryBalanceStep(withFlows, overrides);
462+
const result = factoryBalanceStep(withFlows, overrides, sinkBaselines);
456463
status = result.status;
457464
// Goal changes are the only actionable controls. A scale on a utility or
458465
// goal-less block cannot be applied and must not keep the iteration alive.

app/src/server/factory-balance-step.server.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type GoalChange = {
2525
export function factoryBalanceStep(
2626
blocks: BlockWithFlows[],
2727
demandOverrides: Record<string, number> = {},
28+
sinkBaselines: Map<string, number> = new Map(),
2829
) {
2930
const kindOf = new Map<string, string>();
3031
const produced = new Map<string, number>();
@@ -157,11 +158,18 @@ export function factoryBalanceStep(
157158
for (const [good, entries] of sinks) {
158159
if (producers.has(good)) continue;
159160
const available = incidental.get(good) ?? 0;
160-
if (available <= EPS) continue;
161-
const current = entries.reduce((sum, entry) => sum + entry.rate, 0);
161+
const baseline = entries.map((entry) =>
162+
Math.abs(sinkBaselines.get(`${entry.block.id}\u0000${good}`) ?? -entry.rate),
163+
);
164+
const baselineTotal = baseline.reduce((sum, rate) => sum + rate, 0);
165+
const required = Math.max(available, baselineTotal);
162166
entries.forEach((entry, index) => {
163167
const allocation =
164-
current > EPS ? available * (entry.rate / current) : index === 0 ? available : 0;
168+
baselineTotal > EPS
169+
? required * (baseline[index]! / baselineTotal)
170+
: index === 0
171+
? required
172+
: 0;
165173
addChange(entry.block, good, -allocation);
166174
});
167175
}

app/src/server/factory-balance-step.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ describe("factoryBalanceStep", () => {
8989
);
9090
});
9191

92+
it("returns an enlarged sink to its starting baseline when surplus disappears", () => {
93+
const sink = block(2, "Sink", { name: "waste", rate: -10 }, [
94+
{ item: "waste", kind: "fluid", role: "import", rate: 10 },
95+
]);
96+
const baselines = new Map([[`${sink.id}\u0000waste`, -2]]);
97+
98+
const result = factoryBalanceStep([sink], {}, baselines);
99+
100+
expect(result.goalChanges).toContainEqual(
101+
expect.objectContaining({ good: "waste", currentRate: -10, requiredRate: -2 }),
102+
);
103+
});
104+
92105
it("leaves a good with no configured producer as a raw import", () => {
93106
const gears = block(1, "Gears", { name: "gear", rate: 1 }, [
94107
{ item: "gear", kind: "item", role: "primary", rate: 1 },

app/src/server/factory-balance.server.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ export async function previewFactoryBalance(demandOverrides: Record<string, numb
8585
);
8686

8787
const initialModels = await models();
88-
const initial = factoryBalanceStep(initialModels, demandOverrides);
88+
const sinkBaselines = new Map(
89+
base.flatMap((entry) =>
90+
[...entry.startGoals]
91+
.filter(([, rate]) => rate < 0)
92+
.map(([good, rate]) => [`${entry.block.id}\u0000${good}`, rate] as const),
93+
),
94+
);
95+
const initial = factoryBalanceStep(initialModels, demandOverrides, sinkBaselines);
8996
let currentModels = initialModels;
9097
let settled = initial;
9198
let failed = false;
@@ -133,7 +140,7 @@ export async function previewFactoryBalance(demandOverrides: Record<string, numb
133140
break;
134141
}
135142
currentModels = await models();
136-
settled = factoryBalanceStep(currentModels, demandOverrides);
143+
settled = factoryBalanceStep(currentModels, demandOverrides, sinkBaselines);
137144
}
138145
const hitPassLimit = passes >= 15 && settled.goalChanges.length > 0;
139146

app/src/server/factory-rebalance.test.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -367,23 +367,34 @@ describe("applyFactoryRebalanceFn solve reuse", () => {
367367
};
368368
vi.mocked(blockCompute.computeBlock).mockResolvedValue(solved as never);
369369
vi.mocked(factoryBalance.factoryBalanceStep)
370-
.mockImplementationOnce((blocks) => ({
371-
...factoryResult(blocks, { 1: 1 }),
372-
goalChanges: [
373-
{
374-
id: 1,
375-
name: "Tar",
376-
good: "scrude",
377-
kind: "fluid",
378-
currentRate: -20,
379-
requiredRate: -61.88,
380-
scale: 3.094,
381-
delta: -41.88,
382-
goal: true,
383-
},
384-
],
385-
}))
386-
.mockImplementationOnce((blocks) => factoryResult(blocks, { 1: 1 }));
370+
.mockImplementationOnce((blocks, _overrides, sinkBaselines) => {
371+
expect(sinkBaselines).toEqual(
372+
new Map([
373+
[`1\u0000tar`, -9.575],
374+
[`1\u0000scrude`, -20],
375+
]),
376+
);
377+
return {
378+
...factoryResult(blocks, { 1: 1 }),
379+
goalChanges: [
380+
{
381+
id: 1,
382+
name: "Tar",
383+
good: "scrude",
384+
kind: "fluid",
385+
currentRate: -20,
386+
requiredRate: -61.88,
387+
scale: 3.094,
388+
delta: -41.88,
389+
goal: true,
390+
},
391+
],
392+
};
393+
})
394+
.mockImplementationOnce((blocks, _overrides, sinkBaselines) => {
395+
expect(sinkBaselines?.get(`1\u0000scrude`)).toBe(-20);
396+
return factoryResult(blocks, { 1: 1 });
397+
});
387398

388399
const result = await applyFactoryRebalanceFn({ data: {} });
389400

docs/development/solver.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,10 @@ Factory Scenario balances enabled blocks through repeated goal-and-solve passes,
328328
YAFC's factory balancing. Each pass reads the persisted boundary flows, keeps terminal positive
329329
goals fixed, and computes the current mismatch for every good. Intermediate positive goals move
330330
to downstream demand after incidental byproducts are credited; negative goals move to absorb
331-
byproduct-only surplus.
331+
byproduct-only surplus. The negative rates present when balancing starts are retained as sink
332+
baselines: a pass may enlarge them to absorb real surplus, but a later pass returns them toward
333+
those baselines when the surplus disappears. This prevents transient byproducts from ratcheting
334+
consumer blocks upward across the iteration loop.
332335

333336
The actionable output is one change per throughput goal—it never collapses a multi-goal block
334337
back to its first goal. **Balance factory** updates those goals, re-solves each affected block,

docs/guide/balance.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ if a Tar block consumes Shale oil as its second goal, surplus Shale oil produces
7070
Terminal goals—goods nothing else in the factory consumes—anchor the balance. Intermediate
7171
production goals move to cover downstream demand, while negative goals can move to absorb
7272
surplus. After each change PyOps re-solves the affected block and repeats until its boundary
73-
flows settle. A valid producer currently set to `0/s` is probed at a normalized rate, so
73+
flows settle. A consume goal can temporarily grow to handle that surplus, but it will not be
74+
left above the rate it had when balancing started after the surplus disappears. A valid producer
75+
currently set to `0/s` is probed at a normalized rate, so
7476
**Balance factory** can start it instead of reporting an infeasible zero-times-scale row.
7577

7678
If no enabled block can supply a required ingredient, Scenario keeps the solve usable and

0 commit comments

Comments
 (0)