Skip to content

Detect unbounded LPs in simplex instead of looping forever - #6

Open
shehio wants to merge 1 commit into
developfrom
fix/simplex-unbounded-detection
Open

Detect unbounded LPs in simplex instead of looping forever#6
shehio wants to merge 1 commit into
developfrom
fix/simplex-unbounded-detection

Conversation

@shehio

@shehio shehio commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

simplex() does not terminate on an unbounded linear program, and matrix() writes through its arguments. Both paths were untested.

Unboundedness Is Never Detected

pick_row() performs the minimum-ratio test. When the pivot column holds no positive entry, no basic variable bounds the entering variable and the program is unbounded — but the function returned its initialiser, index = 0:

let index = 0;
for (let i = 0; i < n - 1; i++) {
    if (matrix[i][col] <= 0) { continue; }
    ...
}
return index;

simplex() then pivoted on row 0 on a non-positive element, check() never came back true, and the while loop spun forever.

Reproduced on develop — maximize x subject to −x + y ≤ 1:

$ node unbounded_check.js
calling simplex on unbounded LP...
EXIT=124   # killed by a 12s timeout; no output, no return

pick_row() now returns -1 when the column has no positive entry, and simplex() throws Error('The linear program is unbounded.'). The same LP now returns in milliseconds.

matrix() Mutated Its Caller's Arrays

It negated cT in place and assigned the assembled tableau row back into A[i], so a caller could not reuse or even inspect its own inputs afterwards. Measured on develop:

A before:  [[1,2],[1,1],[3,2]]
A after :  [[1,2,1,0,0,0,16],[1,1,0,1,0,0,9],[3,2,0,0,1,0,24]]
cT before: [40,30]
cT after : [-40,-30]

The rows are now built without writing back into A, and the objective is negated into a local via map. After the fix A, b and cT all compare equal to their original values.

Verification

The bundled Richland example is unchanged: P = 330, both through the test and through node src/linear-programming/simplex.js.

Suite: 34 tests / 10 suites before → 36 tests / 10 suites after, all passing. The two new cases are the unbounded LP and the input-mutation check.

Left Alone

n, m, last_row and last_col are module-level mutable state written by matrix() and read by every other function, so a tableau can only be solved while it is the most recently built one. That is a real latent hazard but it is orthogonal to these two fixes, so I have not touched it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01THEv7BFJd31NjvBRehbZPL

pick_row() returned row 0 when the pivot column held no positive entry,
which is exactly the unboundedness condition. simplex() then pivoted on a
non-positive element and never reached an optimal tableau, so it spun
forever. Maximizing x subject to -x + y <= 1 did not terminate.

pick_row() now returns -1 in that case and simplex() throws.

matrix() also wrote through its arguments: it negated cT in place and
replaced each A[i] with the assembled tableau row, so callers could not
reuse or inspect their own constraint matrix afterwards. It now builds
the rows without touching the inputs.

The bundled Richland example still solves to P = 330.
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.

1 participant