Skip to content

Commit 32ea0a5

Browse files
committed
fix(solver): scale goals beyond coproduct plateaus
1 parent 8a4306e commit 32ea0a5

6 files changed

Lines changed: 205 additions & 93 deletions

File tree

app/e2e/mut/rebalance.e2e.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ test("Scenario zeros an unpinned consume goal without saving the preview", async
199199
}
200200
});
201201

202-
test("Scenario explains an infeasible material balance", async ({ page }) => {
202+
test("Scenario scales a goal beyond recovered coproduct supply", async ({ page }) => {
203203
test.setTimeout(120_000);
204204
const db = new DatabaseSync(activeProjectDbFile());
205205
const pins = db.prepare("SELECT value FROM meta WHERE key = 'factory_pins_v1'").get() as
@@ -224,18 +224,11 @@ test("Scenario explains an infeasible material balance", async ({ page }) => {
224224
await goto(page, "/factory/scenario");
225225
await dismissDataDriftPrompt(page);
226226

227-
const diagnostic = page.getByTestId("scenario-validation");
228-
await expect(diagnostic.getByText("Scenario solve failed: Infeasible")).toBeVisible({
227+
await expect(page.getByRole("link", { name: /^Creosote / })).toBeVisible({
229228
timeout: 60_000,
230229
});
231-
const conflicts = diagnostic.getByRole("region", { name: "Factory material conflicts" });
232-
await expect(conflicts.getByText(/Creosote shortage:/)).toBeVisible();
233-
await expect(conflicts.getByText(/required · .* available/).first()).toBeVisible();
234-
await expect(conflicts.getByRole("link", { name: "Simple circuit board" }).first()).toBeVisible();
235-
await expect(conflicts.getByRole("link", { name: "Coal gas" })).toBeVisible();
236-
await expect(
237-
conflicts.getByText("The configured Creosote goal has no additional scalable output."),
238-
).toBeVisible();
230+
await expect(page.getByTestId("scenario-validation")).toBeHidden();
231+
await expect(page.getByRole("button", { name: "Balance factory" })).toBeEnabled();
239232
} finally {
240233
const cleanup = new DatabaseSync(activeProjectDbFile());
241234
if (pins)

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

Lines changed: 90 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ type Column = {
3838
kind: string;
3939
sign: 1 | -1;
4040
priority: number;
41+
/** Whether this marginal basis is active at the reference goal vector. */
42+
activeAtReference: boolean;
4143
flows: Flow[];
4244
};
4345
type FixedFlow = { item: string; kind: string; net: number };
@@ -256,9 +258,9 @@ function signedBoundary(
256258
* A multi-goal block is one coupled LP: probing each goal with every sibling at
257259
* zero can combine mutually incompatible recipe bases. Finite differences at
258260
* the solved full vector recover the active LP basis, including negative
259-
* marginal flows when increasing one goal reduces another recipe. A separate
260-
* zero-activity solve captures operational estimates such as incidental
261-
* spoilage without mistaking a non-smooth recipe-basis change for fixed flow.
261+
* marginal flows when increasing one goal reduces another recipe. The affine
262+
* intercept makes the response pass through that full solved reference point,
263+
* retaining operational and activation flows that have zero local derivative.
262264
* Final apply validation catches a target that crosses into another basis. */
263265
async function responseColumns(
264266
row: NonNullable<ReturnType<typeof q.getBlock>>,
@@ -272,40 +274,81 @@ async function responseColumns(
272274
const baseResult = await computeBlock(reference);
273275
if (baseResult.broken || baseResult.status !== "solved") return { columns: [], fixed: [] };
274276
const base = signedBoundary(reference, baseResult);
275-
const idle: SolveInput = {
276-
...reference,
277-
goals: reference.goals.map((goal) => ({
278-
...goal,
279-
rate: 0,
280-
direction: goalConsumes(goal) ? "consume" : "produce",
281-
})),
282-
};
283-
const idleResult = await computeBlock(idle);
277+
const anchorResult = reference.goals.some(
278+
(goal, index) => goal.rate !== doc.goals[index]?.rate,
279+
)
280+
? await computeBlock(doc)
281+
: baseResult;
282+
const anchor =
283+
anchorResult.broken || anchorResult.status !== "solved"
284+
? new Map<string, { kind: string; net: number }>()
285+
: signedBoundary(doc, anchorResult);
284286
const columns = await Promise.all(
285287
reference.goals.map(async (goal, goalIndex): Promise<Column | null> => {
286288
const sign: 1 | -1 = goalConsumes(goal) ? -1 : 1;
287-
const delta = Math.max(1e-3, Math.abs(goal.rate) * 1e-3);
288-
const perturbed: SolveInput = {
289-
...reference,
290-
goals: reference.goals.map((candidate, index) =>
291-
index === goalIndex
292-
? { ...candidate, rate: candidate.rate + sign * delta }
293-
: candidate,
294-
),
289+
const solveBoundaryAt = async (rate: number) => {
290+
const perturbed: SolveInput = {
291+
...reference,
292+
goals: reference.goals.map((candidate, index) =>
293+
index === goalIndex ? { ...candidate, rate } : candidate,
294+
),
295+
};
296+
const result = await computeBlock(perturbed);
297+
if (result.broken || result.status !== "solved" || result.unmade?.includes(goal.name))
298+
return null;
299+
return signedBoundary(perturbed, result);
295300
};
296-
const result = await computeBlock(perturbed);
297-
if (result.broken || result.status !== "solved" || result.unmade?.includes(goal.name))
298-
return null;
299-
const next = signedBoundary(perturbed, result);
301+
let delta = Math.max(1e-3, Math.abs(goal.rate) * 1e-3);
302+
let previous = base;
303+
let next = await solveBoundaryAt(goal.rate + sign * delta);
304+
if (!next) return null;
305+
306+
// A sibling goal can already make more of this good than its own
307+
// minimum asks for. A tiny perturbation then consumes only that
308+
// existing surplus: total block output stays flat and the local
309+
// derivative is zero even though the goal becomes scalable after the
310+
// surplus is exhausted. Coupled goals can create the same plateau by
311+
// substituting one recipe basis for another. Search outward for the
312+
// next segment where the goal changes its own boundary flow, then
313+
// measure that segment locally so the plateau does not dilute its
314+
// true marginal response.
315+
const localOwnNet =
316+
((next.get(goal.name)?.net ?? 0) - (base.get(goal.name)?.net ?? 0)) / delta;
317+
const activeAtReference = sign * localOwnNet > EPS;
318+
const coveredRate = sign * (base.get(goal.name)?.net ?? 0);
319+
if (sign * localOwnNet <= EPS) {
320+
let segmentMagnitude =
321+
Math.max(Math.abs(goal.rate), coveredRate, RATE_CHANGE_ABS_TOL) * 1.001;
322+
for (let attempt = 0; attempt < 6; attempt += 1) {
323+
const segmentDelta = Math.max(1e-3, segmentMagnitude * 1e-3);
324+
const segmentStart = sign * segmentMagnitude;
325+
const segmentBase = await solveBoundaryAt(segmentStart);
326+
const segmentNext = await solveBoundaryAt(segmentStart + sign * segmentDelta);
327+
const segmentOwnNet =
328+
segmentBase && segmentNext
329+
? ((segmentNext.get(goal.name)?.net ?? 0) -
330+
(segmentBase.get(goal.name)?.net ?? 0)) /
331+
segmentDelta
332+
: 0;
333+
if (segmentBase && segmentNext && sign * segmentOwnNet > EPS) {
334+
previous = segmentBase;
335+
next = segmentNext;
336+
delta = segmentDelta;
337+
break;
338+
}
339+
segmentMagnitude *= 2;
340+
}
341+
}
342+
300343
const priority = doc.supplyPriorities?.[goal.name] ?? doc.supplyPriority ?? 0;
301-
const goods = new Set([...base.keys(), ...next.keys()]);
344+
const goods = new Set([...previous.keys(), ...next.keys()]);
302345
const flows: Flow[] = [...goods].flatMap((good) => {
303-
const marginal = ((next.get(good)?.net ?? 0) - (base.get(good)?.net ?? 0)) / delta;
346+
const marginal = ((next.get(good)?.net ?? 0) - (previous.get(good)?.net ?? 0)) / delta;
304347
if (Math.abs(marginal) <= EPS) return [];
305348
return [
306349
{
307350
item: good,
308-
kind: next.get(good)?.kind ?? base.get(good)?.kind ?? "item",
351+
kind: next.get(good)?.kind ?? previous.get(good)?.kind ?? "item",
309352
role:
310353
marginal < 0
311354
? "import"
@@ -325,46 +368,33 @@ async function responseColumns(
325368
kind,
326369
sign,
327370
priority,
371+
activeAtReference,
328372
flows,
329373
};
330374
}),
331375
);
332376
const solvedColumns = columns.filter(
333377
(column): column is Column => column != null && !FREE_GOODS.has(column.good),
334378
);
335-
const fixedBoundary =
336-
idleResult.broken || idleResult.status !== "solved"
337-
? new Map<string, { kind: string; net: number }>()
338-
: signedBoundary(idle, idleResult);
339-
if (reference.goals.some((goal) => FREE_GOODS.has(goal.name))) {
340-
const withoutFree: SolveInput = {
341-
...reference,
342-
goals: reference.goals.map((goal) =>
343-
FREE_GOODS.has(goal.name)
344-
? {
345-
...goal,
346-
rate: 0,
347-
direction: goalConsumes(goal) ? "consume" : "produce",
348-
}
349-
: goal,
350-
),
351-
};
352-
const withoutFreeResult = await computeBlock(withoutFree);
353-
if (!withoutFreeResult.broken && withoutFreeResult.status === "solved") {
354-
const withoutFreeBoundary = signedBoundary(withoutFree, withoutFreeResult);
355-
for (const item of new Set([...base.keys(), ...withoutFreeBoundary.keys()])) {
356-
const frozen = (base.get(item)?.net ?? 0) - (withoutFreeBoundary.get(item)?.net ?? 0);
357-
if (Math.abs(frozen) <= EPS) continue;
358-
const current = fixedBoundary.get(item);
359-
fixedBoundary.set(item, {
360-
kind:
361-
base.get(item)?.kind ??
362-
withoutFreeBoundary.get(item)?.kind ??
363-
current?.kind ??
364-
"item",
365-
net: (current?.net ?? 0) + frozen,
366-
});
367-
}
379+
// fixed = anchor - J * anchor goals. This local affine intercept retains
380+
// flows that switch on with an active recipe but stay flat under tiny
381+
// perturbations; an idle-only intercept silently dropped them on every
382+
// re-linearization pass. A marginal basis found beyond a coproduct
383+
// plateau is excluded because that segment is not active at the current
384+
// reference point.
385+
const fixedBoundary = new Map(
386+
[...anchor].map(([item, flow]) => [item, { ...flow }] as const),
387+
);
388+
for (const column of solvedColumns) {
389+
if (!column.activeAtReference) continue;
390+
const anchorGoal = doc.goals.find((goal) => goal.name === column.good);
391+
const magnitude = Math.abs(anchorGoal?.rate ?? 0);
392+
for (const flow of column.flows) {
393+
const current = fixedBoundary.get(flow.item);
394+
fixedBoundary.set(flow.item, {
395+
kind: current?.kind ?? flow.kind,
396+
net: (current?.net ?? 0) - magnitude * flow.rate * (flow.role === "import" ? -1 : 1),
397+
});
368398
}
369399
}
370400
const fixed = [...fixedBoundary].flatMap(([item, flow]) =>

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

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ let ashSinkAdditive = 0;
1111
let infeasibleIronAbove = Number.POSITIVE_INFINITY;
1212
let nonlinearElectricity = false;
1313
let recoveredIronPerCoke = 10;
14+
let ironGoalScalable = true;
1415
let solveVersion = 0;
1516
const blocks = [
1617
{
@@ -111,11 +112,11 @@ vi.mock("./block-compute.server.ts", () => ({
111112
const cokeRate = Math.abs(cokeGoal.rate);
112113
const ironRate = Math.abs(recoveredIronGoal.rate);
113114
const recoveredIron = cokeRate * recoveredIronPerCoke;
114-
const dedicatedIron = Math.max(0, ironRate - recoveredIron);
115+
const dedicatedIron = ironGoalScalable ? Math.max(0, ironRate - recoveredIron) : 0;
115116
return {
116117
broken: false,
117118
status: "solved",
118-
unmade: [],
119+
unmade: !ironGoalScalable && ironRate > recoveredIron ? ["iron"] : [],
119120
imports: [{ name: "ore", kind: "item", rate: cokeRate * 3 + dedicatedIron * 2 }],
120121
exports:
121122
recoveredIron > ironRate
@@ -230,6 +231,7 @@ beforeEach(() => {
230231
infeasibleIronAbove = Number.POSITIVE_INFINITY;
231232
nonlinearElectricity = false;
232233
recoveredIronPerCoke = 10;
234+
ironGoalScalable = true;
233235
for (const doc of docs.values()) doc.updatedAt = new Date(++solveVersion * 1000);
234236
blocks.splice(4);
235237
docs.delete(5);
@@ -352,7 +354,7 @@ describe("pinned factory solve", () => {
352354
expect(saved?.goals.find((goal) => goal.name === "iron")?.rate).toBeCloseTo(4);
353355
});
354356

355-
it("reports the material and blocks that make the factory LP infeasible", async () => {
357+
it("scales a goal past supply recovered from a sibling goal", async () => {
356358
recoveredIronPerCoke = 2;
357359
blocks.push({
358360
id: 5,
@@ -384,6 +386,51 @@ describe("pinned factory solve", () => {
384386

385387
const result = await plan.solvePinnedFactory();
386388

389+
expect(result.status).toBe("Optimal");
390+
expect(result.validation).toBeNull();
391+
expect(result.goalChanges).toContainEqual(
392+
expect.objectContaining({
393+
id: 5,
394+
good: "iron",
395+
requiredRate: 4,
396+
projectedOutput: 4,
397+
}),
398+
);
399+
});
400+
401+
it("reports a configured goal whose output is genuinely not scalable", async () => {
402+
recoveredIronPerCoke = 2;
403+
ironGoalScalable = false;
404+
blocks.push({
405+
id: 5,
406+
name: "Coke and iron",
407+
rate: 0,
408+
goals: [
409+
{ name: "coke", rate: 0 },
410+
{ name: "iron", rate: 0 },
411+
],
412+
flows: [],
413+
});
414+
docs.set(5, {
415+
id: 5,
416+
name: "Coke and iron",
417+
updatedAt: new Date(++solveVersion * 1000),
418+
data: {
419+
goals: [
420+
{ name: "coke", rate: 0 },
421+
{ name: "iron", rate: 0 },
422+
],
423+
recipes: ["recipe-5"],
424+
supplyPriority: 1,
425+
},
426+
});
427+
plan.saveFactoryPins([
428+
{ good: "science", kind: "item", rate: 1 },
429+
{ good: "coke", kind: "item", rate: 1 },
430+
]);
431+
432+
const result = await plan.solvePinnedFactory();
433+
387434
expect(result.status).toBe("Infeasible");
388435
expect(result.validation?.materialConflicts).toContainEqual(
389436
expect.objectContaining({
@@ -478,15 +525,14 @@ describe("pinned factory solve", () => {
478525
expect(result.raws).toContainEqual(expect.objectContaining({ good: "ore", projected: 12 }));
479526
});
480527

481-
it("measures validation residue against gross throughput", async () => {
528+
it("captures a fixed response offset after re-linearizing", async () => {
482529
ironImportOffset = 0.02;
483530

484531
const result = await plan.solvePinnedFactory();
485532

486533
expect(result.status).toBe("Optimal");
487534
expect(result.passes).toBe(2);
488-
expect(result.residual).toBeGreaterThan(0);
489-
expect(result.residual).toBeLessThan(0.005);
535+
expect(result.residual).toBe(0);
490536
});
491537

492538
it("balances fixed incidental outputs as factory surplus", async () => {

app/src/solver/lp-tiebreak.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test, vi } from "vite-plus/test";
2+
3+
const solve = vi
4+
.fn()
5+
// Machine minimum: feasible, but it makes twice the requested product.
6+
.mockReturnValueOnce({ Status: "Optimal", Columns: { x0: { Primal: 1 } } })
7+
// Goal-surplus minimum: exact goal at half the recipe rate.
8+
.mockReturnValueOnce({
9+
Status: "Optimal",
10+
Columns: { x0: { Primal: 0.5 }, goalSlack0: { Primal: 0 } },
11+
})
12+
// Optional machine tie-break: simulate HiGHS rejecting the retained optimum.
13+
.mockReturnValueOnce({ Status: "Infeasible", Columns: {} });
14+
15+
vi.mock("highs", () => ({ default: vi.fn(async () => ({ solve })) }));
16+
17+
test("keeps the feasible goal optimum when the machine tie-break fails", async () => {
18+
const { solveBlockLp } = await import("./lp.ts");
19+
const result = await solveBlockLp({
20+
goals: [{ name: "product", rate: 1 }],
21+
recipes: [
22+
{
23+
name: "double-product",
24+
energyRequired: 1,
25+
ingredients: [],
26+
products: [{ kind: "item", name: "product", amount: 2 }],
27+
},
28+
],
29+
});
30+
31+
expect(result.status).toBe("solved");
32+
expect(result.recipes).toContainEqual(
33+
expect.objectContaining({ recipe: "double-product", rate: 0.5 }),
34+
);
35+
expect(solve).toHaveBeenCalledTimes(3);
36+
});

0 commit comments

Comments
 (0)