Skip to content

feat(api): a written LP or MPS file can name each variable and constraint by its declaration and coordinate - #1795

Open
FBumann wants to merge 7 commits into
mainfrom
claude/festive-galileo-9481hq
Open

FBumann wants to merge 7 commits into
mainfrom
claude/festive-galileo-9481hq

Conversation

@FBumann

@FBumann FBumann commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: I think we should add the names lp and mps file io first. It’s super cheap (a flag)

Scope added later: "Move the name clash checks into Model.check!"

Note

The following content was generated by AI.

sps.write(..., names=True) and Model.write(path, names=True) write p(0,wind) and balance(0) in place of x0 and c0, in both LP and MPS files. Model.check('.lp', names=True) refuses what the named write refuses, without writing. The default is unchanged, byte for byte. This PR replaces #1792.

Why this replaces #1792

#1792 was the same branch. While the stack was inverted so that this PR lands before the pyomo export (#1793), this branch's head was merged into feat/pyomo-model while that was still #1792's base. GitHub then marked #1792 as merged into feat/pyomo-model and deleted the branch. Nothing reached main. The branch is restored and opened again here against main.

What a name is, and why
  • Form: declaration(label,label). A declaration with no dims is total(). A bare name can be an LP keyword: HiGHS and Gurobi both fail on a scalar named end, st or bounds. HiGHS also misreads free and inf.
  • Label cleaning: a label keeps \w (Unicode letters, digits, _) and !"#$%&'.;?@`{|}~. Every other character becomes _. The set is what HiGHS's LP reader takes back, probed character by character. Gurobi reads the same set, and non-ASCII letters, back. MPS takes everything except a space, but one rule keeps the two files naming a model the same way. (, ) and , belong to the name, so a label cannot contain them.
  • Collisions are refused. a b and a_b both clean to a_b. A reader would merge the two columns into one, or refuse the duplicate row, so the write raises SpecsolveError and names both coordinates.
  • Precedent: linopy's explicit_coordinate_names writes p[0,wind] with brackets changed to parentheses. It appends #label for uniqueness and cleans only spaces in a label. This PR drops the #label, so JuMP's variable_by_name finds a name without knowing the solver index. The refusal replaces it as the uniqueness guarantee.
  • Structure: readback.file_names spells each declaration's label frame from the build with polars expressions into a Names value (in writers/base.py, numbered by default). Both writers look names up in it by solver index.
The check
  • Model.check(sink, *, names=False). With names=True, the engine's check calls the same readback.file_names that write calls, so the collision is refused in the same words. A test asserts the two messages are equal, for .lp and .mps.
  • names=True with a sink that writes no file is refused: names= is read by a written file (.lp, .mps), not by 'highs'. A caller who passes it to a solver asked a question that sink cannot answer.
  • write still checks the suffix's capabilities first, then spells the names once.
Base of a stack

#1793 (the pyomo export) stacks on this PR, and #1797 (the linopy export) on that. Merge this PR first.

Verification
  • On cfe9f24, rebased onto main after feat(sinks): a built model checks whether a sink takes it without a solve, and check no longer takes a sink #1806: pixi run check (lint, format, pyrefly, and the suite: 4666 passed, 522 skipped, 1 xfailed), clean. pixi run docs-build, test-floors (polars 1.30) and docs-test ran on commits before the rebase and were not run again.
  • Numbered output unchanged: before/after cmp on the LP and MPS files of the dispatch and MILP fixtures: identical.
  • tests/test_named_files.py:
    • Every fixture and every port (the ported models in examples/ports/) reaches the same optimum through a named LP and MPS file.
    • Every row HiGHS reads back by name holds the terms Model.row reads at that coordinate. That includes a masked variable and a constraint whose middle row is dropped, so later rows renumber.
    • On dispatch (unique optimum), each named column reads back the engine's primal value.
    • Label cleaning, datetime labels, keyword-named scalars and the collision refusal on a variable and on a constraint.
    • check with names refuses the collision in the write's words; without names the same model checks clean; names for a solver is refused.
    • Named SOS sets and a quadratic row, read by Gurobi.

Mutation table (full suite each time; the tree came back clean after every row). The first four rows were taken before the rebase, on code the rebase did not change:

mutation result
the shared-name refusal (tools.mutate, in _refuse_a_shared_name) caught
drop the label cleaning replace_all (by hand) caught (14 failed)
a bare name for a dimensionless declaration (by hand) caught (4 failed)
names follow declaration order, not build order (by hand, first commit) caught by the purpose-built probe test_a_quadratic_constraint_declared_first_keeps_its_names
the names refusal in check (tools.mutate, engine.py:143-144) caught
the refusal of names for a sink that writes no file (tools.mutate, engine.py:144-145) caught

Sorting each label frame and sorting the declarations by start both survived the suite, so both sorts were removed. The names rely on the build's order, which the probe above holds.

Not done or not checked
  • JuMP not run: there is no Julia in this environment. The claim that JuMP reads these names rests on the character set HiGHS and Gurobi read.
  • No length limit: CPLEX and Gurobi cap a name at 255 characters, and this PR adds no guard for it.
  • Datetime labels are long: a datetime label is written in polars' string form, for example p(2030_01_01_06_00_00.000000). It is faithful and unique, but long.
  • SOS set names stay s0, s1.
  • Write time not measured: the time cost of named output was not measured.
  • The history is one commit on main plus the check: the old branch merged into the pyomo branch and back, so the rebase took the net diff rather than replaying each commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CM4oeTkzkk5G5zE6zgikSE
https://claude.ai/code/session_01V3XyQpbHm6dhHbSkGcvxTb
https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q

FBumann pushed a commit that referenced this pull request Sep 30, 2026
FBumann pushed a commit that referenced this pull request Sep 30, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CM4oeTkzkk5G5zE6zgikSE

# Conflicts:
#	CHANGELOG.md
@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 14.76%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
❌ 11 regressed benchmarks
✅ 68 untouched benchmarks
🆕 10 new benchmarks
⏩ 307 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_window[sector-s-specsolve-highs-shape] 4.1 MB 6.1 MB -33.55%
❌ test_window[sector-s-specsolve-highs-cold] 4.1 MB 6.2 MB -33.21%
❌ test_window[sector-s-specsolve-highs-values] 4.2 MB 6.2 MB -32.41%
❌ test_window[sector-s-specsolve-highs-one] 4.2 MB 6.1 MB -32.25%
❌ test_emit[sector-s-specsolve-highs] 4.2 MB 6.1 MB -32.21%
❌ test_window[sector-s-specsolve-highs-coefficient] 4.2 MB 6.1 MB -31.77%
❌ test_emit[sector-s-specsolve-lp] 4.6 MB 6.1 MB -24.53%
❌ test_window[transport-s-specsolve-highs-values] 11.3 MB 13.1 MB -13.71%
❌ test_window[transport-s-specsolve-highs-one] 11.3 MB 13.1 MB -13.71%
❌ test_window[storage-s-specsolve-highs-values] 9.3 MB 10.5 MB -11.52%
❌ test_window[storage-s-specsolve-highs-one] 9.3 MB 10.5 MB -11.52%
⚡ test_window[dispatch-s-specsolve-highs-values] 9.1 MB 6.8 MB +34.08%
⚡ test_window[dispatch-s-specsolve-highs-one] 9.1 MB 6.8 MB +34.08%
⚡ test_read[profiled-s-specsolve-parquet] 6.6 MB 5.7 MB +15.65%
⚡ test_window[fleet-s-specsolve-highs-values] 12 MB 11.3 MB +6.03%
🆕 test_emit[milp-s-specsolve-highs] N/A 29.5 MB N/A
🆕 test_emit[milp-s-specsolve-in-memory-highs] N/A 29.5 MB N/A
🆕 test_emit[milp-s-specsolve-in-memory-lp] N/A 47.8 MB N/A
🆕 test_emit[milp-s-specsolve-lp] N/A 47.8 MB N/A
🆕 test_read[milp-s-specsolve-frames] N/A 1.5 MB N/A
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/festive-galileo-9481hq (7d83e70) with main (89306c6)2

Open in CodSpeed

Footnotes

  1. 307 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (f709000) during the generation of this report, so 89306c6 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

…aint by its declaration and coordinate

Rebased onto main after #1806 as one commit: the branch's net diff,
with the changelog, the two write docstrings and the engine's write
resolved against Model.check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
@FBumann
FBumann force-pushed the claude/festive-galileo-9481hq branch from 9ad14fa to 3f473c5 Compare October 2, 2026 09:28
FBumann pushed a commit that referenced this pull request Oct 2, 2026
…dexed by its labels

Rebased onto the rebased #1795 as one commit: the branch's net diff
over its old base, with the changelog and Model's methods kept from
both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
claude added 2 commits October 2, 2026 09:51
Model.check(sink, names=True) refuses what write(names=True) refuses,
so the check answers for a named file too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
@FBumann
FBumann marked this pull request as ready for review October 2, 2026 10:33
claude added 2 commits October 3, 2026 08:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4ckkidC1vKWRnqZKnfVn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4ckkidC1vKWRnqZKnfVn
@FBumann FBumann added now In flight. Capped at five — if it is not one of the five, it is backlog feature New capability labels Nov 11, 2026 — with Claude

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability now In flight. Capped at five — if it is not one of the five, it is backlog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants