From e329ac9ca3919d212220277587aa79609fb23bd4 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Tue, 16 Sep 2025 15:40:55 +1200 Subject: [PATCH 1/2] [FileFormats.LP] add a documentation page and refactor some names And fall down the rabbit hole with respect to the many differences between solvers. --- docs/make.jl | 1 + docs/src/submodules/FileFormats/LP.md | 205 ++++++++++++++++ .../config/vocabularies/JuMP/accept.txt | 4 + src/FileFormats/LP/read.jl | 219 ++++++++---------- test/FileFormats/LP/LP.jl | 59 +++-- 5 files changed, 353 insertions(+), 135 deletions(-) create mode 100644 docs/src/submodules/FileFormats/LP.md diff --git a/docs/make.jl b/docs/make.jl index 28f21658e7..050dff7c0e 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -67,6 +67,7 @@ const _PAGES = [ "FileFormats" => [ "Overview" => "submodules/FileFormats/overview.md", "API Reference" => "submodules/FileFormats/reference.md", + "The LP file format" => "submodules/FileFormats/LP.md", ], "Nonlinear" => [ "Overview" => "submodules/Nonlinear/overview.md", diff --git a/docs/src/submodules/FileFormats/LP.md b/docs/src/submodules/FileFormats/LP.md new file mode 100644 index 0000000000..61676133f7 --- /dev/null +++ b/docs/src/submodules/FileFormats/LP.md @@ -0,0 +1,205 @@ +# The LP file format + +The purpose of this page is to document the LP file format, and the various +differences that occur between solvers. + +## Resources + +There are a bunch of different descriptions of the LP file format on the +internet. + + * [CPLEX](https://www.ibm.com/docs/en/icos/22.1.0?topic=cplex-lp-file-format-algebraic-representation) + * [FICO](https://www.fico.com/fico-xpress-optimization/docs/dms2021-01/solver/optimizer/HTML/chapter10_sec_section102.html) + * [Gurobi](https://docs.gurobi.com/projects/optimizer/en/current/reference/fileformats/modelformats.html#lp-format) + * [lpsolve](https://lpsolve.sourceforge.net/5.5/CPLEX-format.htm) + * [Mosek](https://docs.mosek.com/11.0/capi/lp-format.html) + * [QSopt](https://www.math.uwaterloo.ca/~bico/qsopt/hlp/ff_lp_format.htm) + +## Grammar + +This section gives the grammar of an LP file as we implement it. This grammar +may be different to that of particular solvers. + +The syntax rules for the grammar are: + + * ``: the name of a symbol in the grammar + * `A :== B`: A is equivalent to B + * `A | B`: either A or B + * `i""`: case insensitive string + * `[A]`: A is optional + * `(A)*`: there are 0 or more repeats of A + * `(A)+`: there is at least one or more repeats of A + +In addition to the grammar, there are the following rules: + + * Comments begin with `\`, and run until the next `\n` character + * Whitespace is ignored + * Newlines are ignored, except where explicitly described + +``` + :== + \n + [\n] + \n + ()* + [\n ()+] + [\n ()+] + [\n ()+] + [\n ()+] + [] + + :== + i"min" | i"minimum" | i"minimize" | i"minimise" + | i"max" | i"maximum" | i"maximize" | i"maximise" + + :== + (i"subject to" | i"st" | i"st." | i"s.t." | i"such that")[":"] + + :== i"bound" | i"bounds" + + :== i"gen" | i"general" | i"generals" + + :== i"bin" | i"binary" | i"binaries" + + :== i"sos" + + :== i"end" + + :== "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" + + :== a-z | A-Z | !"#$%&()/,;?@_'`|~ + + :== ( | | ".")* + + :== + "+" + | "-" + | +[.()*][("e" | "E")("+" | "-")()+] + | i"inf" + | i"infinity" + + :== + "+" + | "-" + | [ ["*"]] "^" "2" + | [ ["*"]] "*" + + :== + "[" (("+" | "-") )* "]" ["/" "2"] + + :== + "+" + | "-" + | + | + | + | "*" + | + + :== (("+" | "-") )* + + :== [":"] + + :== [] + + :== "<" | "<=" | "=<" | ">" | ">=" | "=>" | "=" | "==" + + := + + := + + :== "=" (0 | 1) "->" + + :== + "S1::" (":")+\n + | "S2::" (":")+\n + + :== + + | + | + + :== + i"free" + | + | + | +``` + +## Differences + +There are many differences in how solvers parse an LP file. + +### The "integer" section + +Consider the section: +``` +integers +x +``` +Gurobi will interpret this as `x in MOI.Integer()`. FICO Xpress will interpret +this as `x in MOI.ZeroOne()`. + +FICO document this behavior, but they're an outlier. + +**We choose to interpret `integers` as `MOI.Integer()`.** + +### Whether variables can have the same name as a keyword + +Consider the file +``` +min +st +st +st >= 0 +end +``` +or even the horrific +``` +min st st st >= 0 end +``` +Gurobi will complain up front that the keyword `st` appears twice, whereas +Xpress will read the file as equivalent to : +``` +minimize +x +subject to +x >= 0 +end +``` + +**We choose to allow variables to be named as keywords, and we use context to +disambiguate.** + +### Whitespace + +Consider the file +``` +minimize + 2x +end +``` + +Gurobi will interpret this as a single variable with the name `2x`, where as +Xpress will interpret this as the expression `2 * x`. + +Gurobi document this behavior, saying that they require whitespace around all +tokens, but they're an outlier. + +**We choose to allow juxtaposted tokens without whitespace.** + +### Identifiers + +In general, an identifier may contain the letters a-z, A-Z, the digits 0-9, and +the characters ```!"#\$%&()/,.;?@_'`|~```. + +Additional solvers put additional restrictions: + + * In (all?) solvers except Gurobi, the identifier must not start with a digit + or a `.` (in Gurobi, identifiers must be separated by whitespace) + * Identifiers in Mosek and lpsolve may not start with the letter e or E + * Keywords must not be used as names in Gurobi or Mosek, but they may in Xpress + * Many solvers _actually_ support reading any UTF-8 string as the identifier, + but they will normalize to the legal letters on write + +**We choose to allow any valid UTF-8 names.** diff --git a/docs/styles/config/vocabularies/JuMP/accept.txt b/docs/styles/config/vocabularies/JuMP/accept.txt index 2da044c592..f526736192 100644 --- a/docs/styles/config/vocabularies/JuMP/accept.txt +++ b/docs/styles/config/vocabularies/JuMP/accept.txt @@ -15,6 +15,7 @@ errored flamegraph getters [Jj]ulia +juxtaposted linkcheck nonlinearly nonzeros @@ -52,7 +53,10 @@ Hijazi Holy JSONSchema MOI +Mosek PATHSolver +QSopt preprint Lubin Nemirovski +Xpress \ No newline at end of file diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl index f3101ca8d8..4f8959b489 100644 --- a/src/FileFormats/LP/read.jl +++ b/src/FileFormats/LP/read.jl @@ -29,13 +29,6 @@ struct _ReadCache{T} end end -function _read_newline_or_eof(state) - if (p = peek(state, _Token)) !== nothing - _ = read(state, _Token, _TOKEN_NEWLINE) - end - return -end - """ Base.read!(io::IO, model::FileFormats.LP.Model) @@ -60,7 +53,6 @@ function Base.read!(io::IO, model::Model{T}) where {T} if token.kind == _TOKEN_KEYWORD _ = read(state, _Token) keyword = Symbol(token.value) - _read_newline_or_eof(state) elseif token.kind == _TOKEN_NEWLINE _ = read(state, _Token, _TOKEN_NEWLINE) elseif keyword == :MINIMIZE @@ -74,13 +66,13 @@ function Base.read!(io::IO, model::Model{T}) where {T} elseif keyword == :CONSTRAINTS _parse_constraint(state, cache) elseif keyword == :BINARY - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) MOI.add_constraint(cache.model, x, MOI.ZeroOne()) elseif keyword == :INTEGER - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) MOI.add_constraint(cache.model, x, MOI.Integer()) elseif keyword == :BOUNDS - _parse_bound(state, cache) + _parse_bound_expression(state, cache) elseif keyword == :SOS _parse_constraint(state, cache) elseif keyword == :END @@ -362,18 +354,6 @@ function Base.read(state::_LexerState, ::Type{_Token}, kind::_TokenKind) return _expect(state, token, kind) end -# We're a bit more relaxed than typical, allowing any letter or digit, not just -# ASCII. -function _is_identifier(c::Char) - return isletter(c) || isdigit(c) || c in "!\"#\$%&()/,.;?@_`'{}|~" -end - -function _is_starting_identifier(c::Char) - return isletter(c) || c in "!\"#\$%&(),;?@_`'{}|~" -end - -_is_number(c::Char) = isdigit(c) || c in ('.', 'e', 'E', '+', '-') - _nothing_or_newline(::Nothing) = true _nothing_or_newline(t::_Token) = t.kind == _TOKEN_NEWLINE @@ -449,6 +429,18 @@ function Base.peek(state::_LexerState, ::Type{_Token}, n::Int = 1) return state.peek_tokens[n] end +# We're a bit more relaxed than typical, allowing any letter or digit, not just +# ASCII. +function _is_identifier(c::Char) + return isletter(c) || isdigit(c) || c in "!\"#\$%&()/,.;?@_`'{}|~" +end + +function _is_starting_identifier(c::Char) + return isletter(c) || c in "!\"#\$%&(),;?@_`'{}|~" +end + +_is_number(c::Char) = isdigit(c) || c in ('.', 'e', 'E', '+', '-') + function _peek_inner(state::_LexerState) while (c = peek(state, Char)) !== nothing pos = position(state.io) @@ -531,11 +523,11 @@ function _next_non_newline(state::_LexerState) end end -# IDENTIFIER := "string" +# :== "string" # # There _are_ rules to what an identifier can be. We handle these when lexing. # Anything that makes it here is deemed acceptable. -function _parse_variable( +function _parse_identifier( state::_LexerState, cache::_ReadCache, )::MOI.VariableIndex @@ -560,12 +552,12 @@ function _parse_variable( return x end -# NUMBER := -# "+" NUMBER -# | "-" NUMBER -# | "inf" -# | "infinity" -# | :(parse(T, x)) +# :== +# "+" +# | "-" +# | *[.()*][("e" | "E")("+" | "-")()+] +# | i"inf" +# | i"infinity" function _parse_number(state::_LexerState, cache::_ReadCache{T})::T where {T} _skip_newlines(state) token = read(state, _Token) @@ -587,12 +579,12 @@ function _parse_number(state::_LexerState, cache::_ReadCache{T})::T where {T} return ret end -# QUAD_TERM := -# "+" QUAD_TERM -# | "-" QUAD_TERM -# | [NUMBER] [*] IDENTIFIER "^" "2" -# | [NUMBER] [*] IDENTIFIER "*" IDENTIFIER -function _parse_quad_term( +# :== +# "+" +# | "-" +# | [ [*]] "^" "2" +# | [ [*]] "*" +function _parse_quadratic_term( state::_LexerState, cache::_ReadCache{T}, prefix::T, @@ -600,10 +592,10 @@ function _parse_quad_term( _skip_newlines(state) if _next_token_is(state, _TOKEN_ADDITION) _ = read(state, _Token) - return _parse_quad_term(state, cache, prefix) + return _parse_quadratic_term(state, cache, prefix) elseif _next_token_is(state, _TOKEN_SUBTRACTION) _ = read(state, _Token) - return _parse_quad_term(state, cache, -prefix) + return _parse_quadratic_term(state, cache, -prefix) end coef = prefix if _next_token_is(state, _TOKEN_NUMBER) @@ -613,7 +605,7 @@ function _parse_quad_term( _skip_newlines(state) _ = read(state, _Token) # Skip optional multiplication end - x1 = _parse_variable(state, cache) + x1 = _parse_identifier(state, cache) _skip_newlines(state) if _next_token_is(state, _TOKEN_EXPONENT) _ = read(state, _Token) # ^ @@ -625,31 +617,30 @@ function _parse_quad_term( return MOI.ScalarQuadraticTerm(T(2) * coef, x1, x1) end token = read(state, _Token, _TOKEN_MULTIPLICATION) - x2 = _parse_variable(state, cache) + x2 = _parse_identifier(state, cache) if x1 == x2 coef *= T(2) end return MOI.ScalarQuadraticTerm(coef, x1, x2) end -# QUADRATIC_EXPRESSION := -# "[" QUAD_TERM (("+" | "-") QUAD_TERM)* "]" -# | "[" QUAD_TERM (("+" | "-") QUAD_TERM)* "]/2" -function _parse_quad_expression( +# :== +# "[" (("+" | "-") )* "]" ["/" "2"] +function _parse_quadratic_expression( state::_LexerState, cache::_ReadCache{T}, prefix::T, ) where {T} token = read(state, _Token, _TOKEN_OPEN_BRACKET) f = zero(MOI.ScalarQuadraticFunction{T}) - push!(f.quadratic_terms, _parse_quad_term(state, cache, prefix)) + push!(f.quadratic_terms, _parse_quadratic_term(state, cache, prefix)) while (p = peek(state, _Token)) !== nothing if p.kind == _TOKEN_ADDITION p = read(state, _Token) - push!(f.quadratic_terms, _parse_quad_term(state, cache, prefix)) + push!(f.quadratic_terms, _parse_quadratic_term(state, cache, prefix)) elseif p.kind == _TOKEN_SUBTRACTION p = read(state, _Token) - push!(f.quadratic_terms, _parse_quad_term(state, cache, -prefix)) + push!(f.quadratic_terms, _parse_quadratic_term(state, cache, -prefix)) elseif p.kind == _TOKEN_NEWLINE _ = read(state, _Token) elseif p.kind == _TOKEN_CLOSE_BRACKET @@ -687,15 +678,14 @@ function _parse_quad_expression( return f end -# TERM := -# [\n*] TERM -# | "+" TERM -# | "-" TERM -# | IDENTIFIER -# | NUMBER -# | NUMBER IDENTIFIER -# | NUMBER "*" IDENTIFIER -# | QUADRATIC_EXPRESSION +# :== +# "+" +# | "-" +# | +# | +# | +# | "*" +# | function _parse_term( state::_LexerState, cache::_ReadCache{T}, @@ -703,53 +693,53 @@ function _parse_term( ) where {T} _skip_newlines(state) if _next_token_is(state, _TOKEN_ADDITION) - # "+" TERM + # "+" _ = read(state, _Token, _TOKEN_ADDITION) return _parse_term(state, cache, prefix) elseif _next_token_is(state, _TOKEN_SUBTRACTION) - # "-" TERM + # "-" _ = read(state, _Token, _TOKEN_SUBTRACTION) return _parse_term(state, cache, -prefix) elseif _next_token_is(state, _TOKEN_IDENTIFIER) - # IDENTIFIER - x = _parse_variable(state, cache) + # + x = _parse_identifier(state, cache) return MOI.ScalarAffineTerm(prefix, x) elseif _next_token_is(state, _TOKEN_NUMBER) coef = prefix * _parse_number(state, cache) if _next_token_is(state, _TOKEN_IDENTIFIER) - # NUMBER IDENTIFIER - x = _parse_variable(state, cache) + # + x = _parse_identifier(state, cache) return MOI.ScalarAffineTerm(coef, x) elseif _next_token_is(state, _TOKEN_MULTIPLICATION) - # NUMBER * IDENTIFIER + # * _ = read(state, _Token, _TOKEN_MULTIPLICATION) - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) return MOI.ScalarAffineTerm(coef, x) elseif _next_token_is(state, _TOKEN_NEWLINE) - # This could either be NUMBER \nEND-OF-TERM, or it could be a term + # This could either be \nEND-OF-, or it could be a term # split by a new line, like `2\nx`. t = _next_non_newline(state) if t === nothing - # NUMBER + # return coef elseif t.kind == _TOKEN_MULTIPLICATION - # NUMBER \n * [\n] IDENTIFIER + # \n * [\n] _skip_newlines(state) _ = read(state, _Token, _TOKEN_MULTIPLICATION) _skip_newlines(state) - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) return MOI.ScalarAffineTerm(coef, x) elseif t.kind == _TOKEN_IDENTIFIER - # NUMBER \n IDENTIFIER - x = _parse_variable(state, cache) + # \n + x = _parse_identifier(state, cache) return MOI.ScalarAffineTerm(coef, x) end end - # NUMBER + # return coef elseif _next_token_is(state, _TOKEN_OPEN_BRACKET) - # QUADRATIC_EXPRESSION - return _parse_quad_expression(state, cache, prefix) + # + return _parse_quadratic_expression(state, cache, prefix) end token = peek(state, _Token) return _throw_parse_error( @@ -780,8 +770,7 @@ function _add_to_expression!( return end -# EXPRESSION := -# TERM (("+" | "-") TERM)* +# :== (("+" | "-") )* function _parse_expression(state::_LexerState, cache::_ReadCache{T}) where {T} f = zero(MOI.ScalarQuadraticFunction{T}) _add_to_expression!(f, _parse_term(state, cache)) @@ -807,11 +796,11 @@ function _parse_expression(state::_LexerState, cache::_ReadCache{T}) where {T} return f end -# SET_SUFFIX := +# :== # "free" -# | ">=" NUMBER -# | "<=" NUMBER -# | "==" NUMBER +# | ">=" +# | "<=" +# | "==" # # There are other inequality operators that are supported, like `>`, `<`, and # `=`. These are normalized when lexing. @@ -840,10 +829,10 @@ function _parse_set_suffix(state, cache) end end -# SET_PREFIX := -# NUMBER ">=" -# | NUMBER "<=" -# | NUMBER "==" +# :== +# ">=" +# | "<=" +# | "==" # # There are other inequality operators that are supported, like `>`, `<`, and # `=`. These are normalized when lexing. @@ -866,8 +855,8 @@ function _parse_set_prefix(state, cache) end end -# NAME := [IDENTIFIER :] -function _parse_optional_name(state::_LexerState, cache::_ReadCache) +# :== [ :] +function _parse_name(state::_LexerState, cache::_ReadCache) _skip_newlines(state) if _next_token_is(state, _TOKEN_IDENTIFIER, 1) && _next_token_is(state, _TOKEN_COLON, 2) @@ -878,16 +867,15 @@ function _parse_optional_name(state::_LexerState, cache::_ReadCache) return nothing end -# OBJECTIVE := [NAME] [EXPRESSION] +# :== [] function _parse_objective(state::_LexerState, cache::_ReadCache) - _ = _parse_optional_name(state, cache) + _ = _parse_name(state, cache) _skip_newlines(state) if _next_token_is(state, _TOKEN_KEYWORD) return # A line like `obj:\nsubject to` end f = _parse_expression(state, cache) MOI.set(cache.model, MOI.ObjectiveFunction{typeof(f)}(), f) - _read_newline_or_eof(state) return end @@ -925,21 +913,20 @@ function _add_bound(cache::_ReadCache, x::MOI.VariableIndex, ::Nothing) return end -# BOUND := -# IDENFITIER SET_SUFFIX \n -# | SET_PREFIX IDENTIFIER \n -# | SET_PREFIX IDENTIFIER SET_SUFFIX \n -function _parse_bound(state, cache) +# :== +# +# | +# | +function _parse_bound_expression(state, cache) if _next_token_is(state, _TOKEN_IDENTIFIER) # `x free` or `x op b` - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) set = _parse_set_suffix(state, cache) _add_bound(cache, x, set) - _read_newline_or_eof(state) return end # `a op x` or `a op x op b` lhs_set = _parse_set_prefix(state, cache) - x = _parse_variable(state, cache) + x = _parse_identifier(state, cache) _add_bound(cache, x, lhs_set) if _next_token_is(state, _TOKEN_GREATER_THAN) || _next_token_is(state, _TOKEN_LESS_THAN) || @@ -949,7 +936,6 @@ function _parse_bound(state, cache) rhs_set = _parse_set_suffix(state, cache) _add_bound(cache, x, rhs_set) end - _read_newline_or_eof(state) return end @@ -959,13 +945,12 @@ function _is_sos_constraint(state) _next_token_is(state, _TOKEN_COLON, 3) end -# SOS_CONSTRAINT := -# [NAME] S1:: (IDENTIFIER:NUMBER)+ -# | [NAME] S2:: (IDENTIFIER:NUMBER)+ +# :== +# S1:: (:)+ +# | S2:: (:)+ # # New lines are not supported within the line. -# Terminating new lines are handled in _parse_constraint -function _parse_sos_constraint( +function _parse_constraint_sos( state::_LexerState, cache::_ReadCache{T}, ) where {T} @@ -989,7 +974,7 @@ function _parse_sos_constraint( "SOS constraints cannot be spread across lines.", ) end - push!(f.variables, _parse_variable(state, cache)) + push!(f.variables, _parse_identifier(state, cache)) _ = read(state, _Token, _TOKEN_COLON) push!(w, _parse_number(state, cache)) if _next_token_is(state, _TOKEN_NEWLINE) @@ -1010,16 +995,13 @@ function _is_indicator_constraint(state) _next_token_is(state, _TOKEN_IMPLIES, 4) end -# INDICATOR_CONSTRAINT := -# IDENTIFIER "=" "0" "->" EXPRESSION SET_SUFFIX -# | IDENTIFIER "=" "1" "->" EXPRESSION SET_SUFFIX -# -# Terminating new lines are handled in _parse_constraint -function _parse_indicator_constraint( +# :== +# "=" ("0" | "1") "->" +function _parse_constraint_indicator( state::_LexerState, cache::_ReadCache{T}, ) where {T} - z = _parse_variable(state, cache) + z = _parse_identifier(state, cache) _ = read(state, _Token, _TOKEN_EQUAL_TO) t = read(state, _Token, _TOKEN_NUMBER) indicator = if t.value == "0" @@ -1039,17 +1021,17 @@ function _parse_indicator_constraint( ) end -# CONSTRAINT := -# [NAME] EXPRESSION SET_SUFFIX \n -# | [NAME] SOS_CONSTRAINT \n -# | [NAME] INDICATOR_CONSTRAINT \n +# :== +# +# | +# | function _parse_constraint(state::_LexerState, cache::_ReadCache) - name = _parse_optional_name(state, cache) + name = _parse_name(state, cache) # Check if this is an SOS constraint c = if _is_sos_constraint(state) - _parse_sos_constraint(state, cache) + _parse_constraint_sos(state, cache) elseif _is_indicator_constraint(state) - _parse_indicator_constraint(state, cache) + _parse_constraint_indicator(state, cache) else f = _parse_expression(state, cache) set = _parse_set_suffix(state, cache) @@ -1058,6 +1040,5 @@ function _parse_constraint(state::_LexerState, cache::_ReadCache) if name !== nothing MOI.set(cache.model, MOI.ConstraintName(), c, name) end - _read_newline_or_eof(state) return end diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index 3d1a788958..56545ccc46 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -976,8 +976,7 @@ function test_read_newline_breaks() + z == 0 Bounds - x >= 0 - -1 <= y + x >= 0 -1 <= y +1 <= z <= +2 End """ @@ -1248,7 +1247,7 @@ function test_subject_to_name() return end -function test_parse_variable() +function test_parse_identifier() cache = LP._ReadCache(LP.Model{Float64}()) for input in [ "x", @@ -1263,14 +1262,14 @@ function test_parse_variable() io = IOBuffer(input) seekstart(io) state = LP._LexerState(io) - x = LP._parse_variable(state, cache) + x = LP._parse_identifier(state, cache) @test cache.variable_name_to_index[input] == x end for input in ["2", "2x", ".x"] io = IOBuffer(input) seekstart(io) state = LP._LexerState(io) - @test_throws LP.ParseError LP._parse_variable(state, cache) + @test_throws LP.ParseError LP._parse_identifier(state, cache) end return end @@ -1333,11 +1332,11 @@ function test_parse_quad_term() io = IOBuffer(input) seekstart(io) state = LP._LexerState(io) - term = LP._parse_quad_term(state, cache, 1.0) + term = LP._parse_quadratic_term(state, cache, 1.0) x = cache.variable_name_to_index["x"] @test term == MOI.ScalarQuadraticTerm(coef, x, x) seekstart(io) - term = LP._parse_quad_term(state, cache, -1.0) + term = LP._parse_quadratic_term(state, cache, -1.0) @test term == MOI.ScalarQuadraticTerm(-coef, x, x) end # Off-diagonal @@ -1356,19 +1355,19 @@ function test_parse_quad_term() io = IOBuffer(input) seekstart(io) state = LP._LexerState(io) - term = LP._parse_quad_term(state, cache, 1.0) + term = LP._parse_quadratic_term(state, cache, 1.0) x = cache.variable_name_to_index["x"] y = cache.variable_name_to_index["y"] @test term == MOI.ScalarQuadraticTerm(coef, x, y) seekstart(io) - term = LP._parse_quad_term(state, cache, -1.0) + term = LP._parse_quadratic_term(state, cache, -1.0) @test term == MOI.ScalarQuadraticTerm(-coef, x, y) end for input in ["x^", "x^x", "x^0", "x^1", "x^3", "x * 2 * x"] io = IOBuffer(input) seekstart(io) state = LP._LexerState(io) - @test_throws LP.ParseError LP._parse_quad_term(state, cache, -1.0) + @test_throws LP.ParseError LP._parse_quadratic_term(state, cache, -1.0) end return end @@ -1414,7 +1413,7 @@ function test_parse_quad_expression() state = LP._LexerState(io) @test_throws( LP.ParseError, - LP._parse_quad_expression(state, cache, 1.0), + LP._parse_quadratic_expression(state, cache, 1.0), ) end return @@ -1540,11 +1539,6 @@ function test_new_line_edge_case_fails() "maximize\nobj: x subject to", # No new line between subject to and constraint "maximize\nobj: x\nsubject to c: x >= 0", - # No new line between multiple constraints - "maximize\nobj: x\nsubject to\nc: x >= 0 x <= 1", - # New lines in bounds section - "maximize\nobj: x\nsubject to\nbounds x >= 0\bx <= 1", - "maximize\nobj: x\nsubject to\nbounds\nx >= 0 x <= 1", ] io = IOBuffer(input) seekstart(io) @@ -1647,6 +1641,39 @@ function test_parse_quadratic_expr_eof() return end +function test_ambiguous_case_1() + # Xpress allows this. We currently don't. + io = IOBuffer("maximize obj: x subject to c: x <= 1 end") + model = LP.Model() + @test_throws LP.ParseError MOI.read!(io, model) + return +end + +function test_ambiguous_case_2() + # Xpress allows this. We currently parse this as two "" + # sections, and think that there is no objective function. + io = IOBuffer("min\nst\nst\nst >= 0\nend") + model = LP.Model() + MOI.read!(io, model) + st = MOI.get(model, MOI.VariableIndex, "st") + @test_broken MOI.get(model, MOI.ObjectiveSense()) == MOI.MIN_SENSE + f = 1.0 * st + @test_broken isapprox(MOI.get(model, MOI.ObjectiveFunction{typeof(f)}()), f) + return +end + +function test_ambiguous_case_3() + # Gurobi doesn't allow this, but Xpress does. We do. + io = IOBuffer("min\nobj: end\nsubject to\nc: end <= 1\nend") + model = LP.Model() + MOI.read!(io, model) + x = MOI.get(model, MOI.VariableIndex, "end") + @test MOI.get(model, MOI.ObjectiveSense()) == MOI.MIN_SENSE + f = 1.0 * x + @test isapprox(MOI.get(model, MOI.ObjectiveFunction{typeof(f)}()), f) + return +end + end # module TestLP.runtests() From 7c06da3325e08fbb3bf74a4300d40fa3ffd185e5 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Wed, 17 Sep 2025 11:59:38 +1200 Subject: [PATCH 2/2] Update --- src/FileFormats/LP/read.jl | 10 ++++++++-- src/Test/test_conic.jl | 2 +- test/FileFormats/LP/models/model1_tricky.lp | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl index 4f8959b489..dc9df9b645 100644 --- a/src/FileFormats/LP/read.jl +++ b/src/FileFormats/LP/read.jl @@ -637,10 +637,16 @@ function _parse_quadratic_expression( while (p = peek(state, _Token)) !== nothing if p.kind == _TOKEN_ADDITION p = read(state, _Token) - push!(f.quadratic_terms, _parse_quadratic_term(state, cache, prefix)) + push!( + f.quadratic_terms, + _parse_quadratic_term(state, cache, prefix), + ) elseif p.kind == _TOKEN_SUBTRACTION p = read(state, _Token) - push!(f.quadratic_terms, _parse_quadratic_term(state, cache, -prefix)) + push!( + f.quadratic_terms, + _parse_quadratic_term(state, cache, -prefix), + ) elseif p.kind == _TOKEN_NEWLINE _ = read(state, _Token) elseif p.kind == _TOKEN_CLOSE_BRACKET diff --git a/src/Test/test_conic.jl b/src/Test/test_conic.jl index 816597de98..c353ff0a29 100644 --- a/src/Test/test_conic.jl +++ b/src/Test/test_conic.jl @@ -5026,7 +5026,7 @@ function version_added( end """ -Problem SDP1 - sdo1 from MOSEK docs +Problem SDP1 - sdo1 from Mosek docs From Mosek.jl/test/mathprogtestextra.jl, under license: Copyright (c) 2013 Ulf Worsoe, Mosek ApS diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index af8a97b31f..5bada4f9d0 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -28,7 +28,7 @@ V6 free 0 <= V7 < 1 \ stupidly allow < as <= 0 <= V8 <= 1 General -Var4 V5 \ integer variables can be listed (MOSEK) +Var4 V5 \ integer variables can be listed (Mosek) V6 \ or each new line Binary V8