Skip to content

Hide native bridge constraints from is_valid - #3130

Open
blegat wants to merge 6 commits into
bl/index-space-redesignfrom
bl/bridge-validity-pr3013
Open

blegat wants to merge 6 commits into
bl/index-space-redesignfrom
bl/bridge-validity-pr3013

Conversation

@blegat

@blegat blegat commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

@blegat
blegat added this pull request to stack #3131 November 5, 2026 12:42
@blegat
blegat force-pushed the bl/bridge-validity-pr3013 branch from ddcb1bc to 0352ee4 Compare November 5, 2026 15:00
@blegat
blegat force-pushed the bl/bridge-validity-pr3013 branch from 7034812 to e75c966 Compare November 6, 2026 12:38
@blegat

blegat commented Nov 6, 2026 •

Copy link
Copy Markdown
Member Author

Benchmark by GPT 6.1 Sol

Benchmark script:
import BenchmarkTools
import MathOptInterface as MOI

include("bridge_validity.jl")

const SCENARIOS = (:native, :one_first, :one_last, :all, :stacked)
const WORKLOADS = (:build, :rows, :starts, :copy)

function new_model(scenario)
    inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}())
    if scenario == :stacked
        inner = MOI.Bridges.Variable.Free{Float64}(inner)
    end
    return MOI.Bridges.Variable.Vectorize{Float64}(inner)
end

function add_bounded_variable(model)
    x, _ = MOI.add_constrained_variable(model, MOI.GreaterThan(0.0))
    return x
end

function build_variables(n::Int, scenario::Symbol)
    model = new_model(scenario)
    variables = MOI.VariableIndex[]
    sizehint!(variables, n)
    if scenario == :native
        append!(variables, MOI.add_variables(model, n))
    elseif scenario == :one_first || scenario == :stacked
        push!(variables, add_bounded_variable(model))
        append!(variables, MOI.add_variables(model, n - 1))
    elseif scenario == :one_last
        append!(variables, MOI.add_variables(model, n - 1))
        push!(variables, add_bounded_variable(model))
    elseif scenario == :all
        for _ in 1:n
            push!(variables, add_bounded_variable(model))
        end
    else
        error("Unknown scenario: $scenario")
    end
    return model, variables
end

function row_functions(variables)
    n = length(variables)
    return [
        MOI.ScalarAffineFunction(
            [
                MOI.ScalarAffineTerm(
                    Float64((-1)^j * j),
                    variables[mod1(i + j - 1, n)],
                ) for j in 1:4
            ],
            0.0,
        ) for i in 1:n
    ]
end

function prepare_rows(n, scenario)
    model, variables = build_variables(n, scenario)
    functions = row_functions(variables)
    sets = fill(MOI.LessThan(1.0), n)
    return (; model, variables, functions, sets)
end

function add_rows(data)
    return MOI.add_constraints(data.model, data.functions, data.sets)
end

function prepare_starts(n, scenario)
    model, variables = build_variables(n, scenario)
    MOI.set(model, MOI.VariablePrimalStart(), variables, Float64.(1:n))
    return model, variables
end

function read_starts(model, variables)
    total = 0.0
    for x in variables
        total += MOI.get(model, MOI.VariablePrimalStart(), x)::Float64
    end
    return total
end

function validate(scenario, n::Int = 8)
    data = prepare_rows(n, scenario)
    if length(unique(data.variables)) != n
        error("Visible variable indices alias")
    end
    if MOI.get(data.model, MOI.NumberOfVariables()) != n
        error("Incorrect visible variable count")
    end
    constraints = add_rows(data)
    for (c, f) in zip(constraints, data.functions)
        if !(MOI.get(data.model, MOI.ConstraintFunction(), c) ≈ f)
            error("Incorrect affine function after bridge substitution")
        end
    end
    model, variables = prepare_starts(n, scenario)
    if read_starts(model, variables) != sum(1:n)
        error("Incorrect primal starts after bridge substitution")
    end
    dest = new_model(scenario)
    indices = MOI.copy_to(dest, data.model)
    for (c, f) in zip(constraints, data.functions)
        mapped = MOI.Utilities.map_indices(indices, f)
        if !(MOI.get(dest, MOI.ConstraintFunction(), indices[c]) ≈ mapped)
            error("Incorrect affine function after copy_to")
        end
    end
    return true
end

function benchmark(workload, n, scenario)
    if workload == :build
        return BenchmarkTools.@benchmarkable build_variables($n, $scenario)
    elseif workload == :rows
        return BenchmarkTools.@benchmarkable add_rows(data) setup =
            (data = prepare_rows($n, $scenario)) evals = 1
    elseif workload == :starts
        model, variables = prepare_starts(n, scenario)
        return BenchmarkTools.@benchmarkable read_starts($model, $variables)
    elseif workload == :copy
        data = prepare_rows(n, scenario)
        add_rows(data)
        source = data.model
        return BenchmarkTools.@benchmarkable MOI.copy_to(dest, $source) setup =
            (dest = new_model($scenario)) evals = 1
    end
    return error("Unknown workload: $workload")
end

function run_suite(output::String, label::String; n::Int = 10_000)
    println("$label: Julia $(VERSION), MOI $(pathof(MOI))")
    open(output, "w") do io
        println(
            io,
            "version\tscenario\tworkload\tn\ttime_ns\tbytes\tallocs\tsamples\tstatus",
        )
        for scenario in SCENARIOS
            try
                validate(scenario)
            catch err
                message =
                    replace(sprint(showerror, err), '\n' => ' ', '\t' => ' ')
                for workload in WORKLOADS
                    println(
                        io,
                        join(
                            (
                                label,
                                scenario,
                                workload,
                                n,
                                "",
                                "",
                                "",
                                "",
                                message,
                            ),
                            '\t',
                        ),
                    )
                end
                println("$label $scenario: $message")
                flush(io)
                continue
            end
            for workload in WORKLOADS
                trial = BenchmarkTools.run(
                    benchmark(workload, n, scenario);
                    samples = 30,
                    evals = 1,
                    seconds = 0.5,
                )
                result = minimum(trial)
                println(
                    io,
                    join(
                        (
                            label,
                            scenario,
                            workload,
                            n,
                            result.time,
                            result.memory,
                            result.allocs,
                            length(trial.times),
                            "ok",
                        ),
                        '\t',
                    ),
                )
                flush(io)
                println(
                    "$label $scenario $workload: $(result.time / 1e6) ms, $(result.memory) bytes",
                )
            end
        end
    end
    return
end

These measurements compare #3013 with master and the ownership fix in
#3130. They were recorded before the latest rebase; the measurements below
are unchanged.

Label Reference Meaning
Master 77474f53 PR base
#3013 #3013 Index-space redesign
#3130 #3130 Ownership tracking on top of #3013
Scan 7d513384 Previous scanning fix, query-only comparison

Julia 1.13.3, BenchmarkTools 1.8.0, Intel Core i7-12700K, Linux. Runs were
serial and pinned to performance CPU 8. Values are minima; small percentage
differences should not be treated as significant. Allocation values are
cumulative bytes allocated per operation.

Native constraint validity (nanoseconds per call)

Only the is_valid method is exchanged in one ownership-build process. All
models, constructors, and other methods stay identical. The queried constraint
is a separate native user Integer constraint, so every implementation must
return true. Each result uses 100 samples with one evaluation per sample.

Bridged binaries #3013 time Scan time #3130 time Scan allocated bytes #3130 allocated bytes
0 14.0 74.0 15.0 192 0
1 15.0 133.0 33.0 272 0
1,000 14.0 41436.0 30.0 80,192 0
10,000 14.0 421491.0 39.0 800,192 0

PR #3013 workloads: whole-checkout comparisons

Each model has 10,000 visible variables. The wrapper is
Variable.Vectorize(UniversalFallback(Model{Float64}())). Bridged variables
are created in GreaterThan(0.0); unbridged variables use add_variables.
The stacked case adds an inner Variable.Free wrapper.

Each affine row has four terms on cyclically adjacent variables. Rows are
constructed outside the timed insertion. Primal starts are populated before
timing reads. Copying prepares the source in advance and creates a fresh
destination wrapper outside each timed sample. Construction includes creating
wrappers. No solver is run.

Each scenario first passes variable-index uniqueness/count, affine-function
round-trip, primal-start, and copy-to correctness checks on an 8-variable
model. The timed models then use 10,000 variables. Master fails
the stacked case because visible variable indices alias; that case is marked
unsupported rather than timing incorrect work.

Times are milliseconds. Each case requests up to 30 samples, one evaluation
per sample, and a 0.5-second budget; longer cases produced 4 to 30 samples. Exact
sample counts, bytes, and allocation counts are in
the raw TSV.

Construct 10,000 variables: minimum ms

Variable bridges Master #3013 #3130
None 0.084 0.086 0.087
One before native variables 0.116 0.401 0.383
One after native variables 0.085 0.090 0.089
All variables 9.460 8.400 12.437
Stacked Vectorize(Free(model)) unsupported 7.238 6.881

Add 10,000 affine rows: minimum ms

Variable bridges Master #3013 #3130
None 0.304 0.308 0.310
One before native variables 8.432 8.548 8.734
One after native variables 8.428 8.513 8.650
All variables 20.218 9.435 9.593
Stacked Vectorize(Free(model)) unsupported 19.148 19.192

Read 10,000 primal starts: minimum ms

Variable bridges Master #3013 #3130
None 0.174 0.180 0.173
One before native variables 0.172 0.196 0.189
One after native variables 0.176 0.186 0.179
All variables 0.755 0.804 1.016
Stacked Vectorize(Free(model)) unsupported 1.139 1.339

Copy the model with 10,000 rows: minimum ms

Variable bridges Master #3013 #3130
None 1.180 1.226 1.250
One before native variables 36.791 36.890 37.897
One after native variables 36.642 34.878 35.544
All variables 145.014 73.446 78.342
Stacked Vectorize(Free(model)) unsupported 123.086 119.405

Cumulative allocation in MiB per operation

Workload Variable bridges Master #3013 #3130
Construct 10,000 variables None 0.589 0.589 0.589
Construct 10,000 variables One before native variables 0.591 1.260 1.261
Construct 10,000 variables One after native variables 0.591 0.591 0.593
Construct 10,000 variables All variables 7.284 7.876 15.403
Construct 10,000 variables Stacked Vectorize(Free(model)) unsupported 12.416 12.419
Add 10,000 affine rows None 1.987 1.987 1.987
Add 10,000 affine rows One before native variables 15.035 14.958 14.958
Add 10,000 affine rows One after native variables 15.035 14.958 14.958
Add 10,000 affine rows All variables 31.513 15.568 15.568
Add 10,000 affine rows Stacked Vectorize(Free(model)) unsupported 31.589 31.589
Read 10,000 primal starts None 0.000 0.000 0.000
Read 10,000 primal starts One before native variables 0.000 0.000 0.000
Read 10,000 primal starts One after native variables 0.000 0.000 0.000
Read 10,000 primal starts All variables 1.068 1.068 1.068
Read 10,000 primal starts Stacked Vectorize(Free(model)) unsupported 1.373 1.373
Copy the model with 10,000 rows None 5.961 5.961 5.961
Copy the model with 10,000 rows One before native variables 63.232 61.265 61.267
Copy the model with 10,000 rows One after native variables 63.287 60.597 60.598
Copy the model with 10,000 rows All variables 138.901 73.387 80.914
Copy the model with 10,000 rows Stacked Vectorize(Free(model)) unsupported 146.949 146.952

MiB rounding hides the 112 bytes allocated by the single-bridge primal-start
reads; the native case allocates zero bytes. See the TSV for exact values.

Observed tradeoffs

  • With every variable bridged, PR Allow stacking of bridge optimier #3013 improves row insertion by 2.14×
    and copying by 1.97× relative to master.
  • Creating one bridge before the native variables activates mapping for later
    variables: PR construction is 3.45× the master time in that case.
    Creating that bridge last avoids most of this cost. The mapping table
    contains 10,000 entries with the first bridge and one entry with the last
    bridge; with no variable bridge it contains zero entries.
  • Ownership tracking keeps validity queries independent of bridge count and
    performs no query allocations, but costs about 48% extra construction
    time and 7.53 MiB extra cumulative allocation for 10,000 variable bridges.
    Fully bridged primal-start reads take about 26% longer than PR alone.
  • Rows and copying remain faster than master in the fully bridged case even
    with ownership tracking. No production code was changed during benchmarking.

Reproduction

Model workloads and
query-only comparison are standalone scripts. Use separate
Julia environments pinned to each indicated checkout, with BenchmarkTools 1.8.0
and MathOptInterface as dependencies. Run the same model script against every checkout:

taskset -c 8 julia --project=<master-env> perf/bridge_optimizer.jl /tmp/master.tsv master
taskset -c 8 julia --project=<pr-env> perf/bridge_optimizer.jl /tmp/pr.tsv pr3013
taskset -c 8 julia --project=test perf/bridge_optimizer.jl /tmp/ownership.tsv ownership
taskset -c 8 julia --project=test perf/bridge_validity.jl /tmp/validity.tsv bl/index-space-redesign 7d513384 bl/bridge-validity-pr3013

The query-only script must run with the ownership implementation loaded. It
reads method bodies from the specified local Git commits, temporarily replaces
that one method, and restores it in finally. It does not modify source files.

ZeroOneBridge's internal Integer constraint is absent from constraint lists
but still reported as valid. This makes JuMP treat a bridged binary as
integer and breaks fix_discrete_variables and its undo operation.

Filter native validity using the constraints reported by variable,
constraint, and objective bridges. Compare in the recursive model's
namespace: lazy bridges store outer indices, while single bridges store
inner indices. Preserve recursive bridge attribute and deletion dispatch.

This version is based on PR #3013, head
6145229. That PR fixes the original
vector-index reproducer of #2696, but not the hidden Integer constraint.
The corresponding fix on current master is 72eb949b.

Validation on this base:
- Modified ZeroOne bridge file: 227 assertions passed, including active
  variable mapping with distinct inner and outer variable indices.
- New variable/objective visibility tests: 10 assertions passed.
- Original vector and binary issue #2696 reproductions: both passed.
- JuMP/Mosek bridged-direct reproduction: 22 assertions passed, including
  fixing, LP dual = 1.0, undo, relax_integrality, and re-solving the MILP.
- JuliaFormatter 2.14.7 leaves no unstaged tracked diff.

The complete modified general bridge-optimizer file additionally passes
823 assertions on current master.

Addresses #2696 and jump-dev/MosekTools.jl#222.
Replace per-query scans with a sparse registry of bridge-created constraint
indices. Allocate it only when a bridge creates a child constraint; never
enumerate or copy existing user constraints. Validity checks use expected
O(1) dictionary lookups and allocate no memory in the benchmark below.

Record each new bridge's children and refresh only the affected bridge's
entries on setters, modifications, final_touch, and deletion. Reference
counts preserve overlapping reports. Refresh after failed mutations too,
and release empty registries.

Extend bridge call contexts so recursive operations can still access their
children while public queries and direct deletion hide them. Preserve the
distinct public and inner namespaces of single-bridge wrappers, including
identity bridges with coincident index values. Cover variable, constraint,
and objective bridges and clear ownership state on empty!.

Validation (only focused test files, not the full suite):
- General bridge optimizer: 894 passed, 1 pre-existing expected broken.
- ZeroOne bridge: 233 passed, including nested bridged children and active
  variable index mapping.
- Ownership/context regressions: 35 passed, including a counter proving
  1,000 validity queries do not inspect bridge child lists, sparse storage
  with 1,000 pre-existing native constraints, recursive access, final_touch,
  exception recovery, bulk deletion, and registry cleanup.
- JuMP/Mosek bridged direct model: 22 passed, including MILP solves,
  fixing, LP dual = 1.0, undo, relaxation, and re-solving.
- Temporary @show @__LINE__ tracing exercised 30 of 31 instrumentation
  points. The optional no-context fallback for third-party optimizer
  subtypes was not exercised. Tracing was removed afterward.
- JuliaFormatter 2.14.7 over the repository leaves no unstaged diff.

Query benchmark: is_valid for one native user Integer constraint in a
ZeroOne SingleBridgeOptimizer with the indicated number of bridged binary
variables. Julia 1.13.3, BenchmarkTools minima of 100 samples, evals=1.
Only the is_valid method was swapped between the original PR #3013 code,
the previous scanning fix, and sparse ownership; the models and remaining
implementation were identical. Times are nanoseconds per query.

| Bridged binaries | Original ns | Scanning ns | Sparse ns | Sparse allocations |
| ---: | ---: | ---: | ---: | ---: |
| 0 | 12.0 | 51.0 | 13.0 | 0 |
| 1 | 12.0 | 75.0 | 25.0 | 0 |
| 1000 | 12.0 | 23797.0 | 26.0 | 0 |
| 10000 | 12.0 | 237965.0 | 28.0 | 0 |

Addresses #2696 and jump-dev/MosekTools.jl#222 on top of PR #3013.
Skip bridge-owned children in public variable cleanup, including children implemented by a constraint bridge. Keep recursive bridge deletion responsible for their lifetime. Extend the context-substitution regression to check hidden vector constraints and deletion of a user scalar bound for Float32 and Float64.
Filter constraint indices using the existing per-type ownership registry instead of querying every bridge and repeatedly searching the returned list. Preserve filtering before index translation for single-bridge wrappers and after translation for lazy wrappers. Reuse the registry to subtract hidden constraint counts without bridge metadata queries.

Keep the original scan for custom bridge optimizers without a BridgeContext. Test native, single-bridge, lazy, mutation, deletion, recursive-context, and fallback behavior with counters that detect bridge queries. General bridge optimizer, lazy optimizer, bridge-context, and ZeroOne bridge regression files pass. Temporary line tracing verifies the new paths, and JuliaFormatter leaves code unchanged. Benchmark files remain unstaged; no benchmarks were rerun.
The two-dimensional reformulation creates its Nonnegatives constraint with the input function type H, while the declaration incorrectly used G, the rotated-cone function type. Ownership tracking therefore missed the VectorOfVariables child, exposing it to the user and allowing it to be deleted before its parent.

Declare H-in-Nonnegatives and test variable, affine, and quadratic inputs for child registration, visibility, counting, and deletion. The complete GeoMean bridge test file and lazy bridge optimizer regressions pass.
Exercise callback results and exceptions without BridgeContext, and verify ownership and context depth after an unsupported objective modification with variable and objective bridges. Temporary line tracing confirms the paths previously missed by Codecov run.

The equivalent NL unary-expression printing fix is already in the updated base, so retain its implementation. Benchmark files remain unstaged and benchmarks were not rerun.
@blegat
blegat force-pushed the bl/bridge-validity-pr3013 branch from 88538fe to db64189 Compare November 6, 2026 16:45

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

None yet

Development

Successfully merging this pull request may close these issues.

MOI.is_valid can be falsely positive for bridged constraints

1 participant