Add enumeration-safe preprocessing mode (#43) - #44
berendmarkhorst merged 5 commits into
Conversation
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Hi! Thanks for implementing this! I like the overall approach, especially keeping the strict deletion tests enabled while disabling or adjusting reductions that only preserve one optimum. The documentation and per-reduction tests are also helpful. I tested the current implementation and found two correctness issues that should be fixed before merging:
For example: g = nx.Graph()
g.add_weighted_edges_from([
("A", "B", 1),
("B", "C", 1),
])
raw = SteinerProblem(
g,
[["A", "C"]],
preprocess=False,
max_degree=1,
).get_optimal_solutions(limit=10)
safe = SteinerProblem(
g,
[["A", "C"]],
preprocess=True,
enumeration_safe=True,
max_degree=1,
).get_optimal_solutions(limit=10)
print(len(raw), raw.exhausted) # 0, True
print(len(safe), safe.exhausted) # 1, True
print(list(safe)[0].edges) # A-B, B-CThe original problem is infeasible because The simplest solution may be to reject
After This can again erase tied optima: g = nx.Graph()
g.add_weighted_edges_from([
("A", "D", 2),
("A", "B", 1),
("B", "D", 1),
("A", "X", 1),
("B", "X", 100),
])
kwargs = dict(
preprocess=True,
enumeration_safe=True,
heavy=False,
contract_terminals=False,
)
safe = SteinerProblem(
g, [["A", "D", "X"]], **kwargs
).get_optimal_solutions(limit=10)
safe_da = SteinerProblem(
g, [["A", "D", "X"]], da_reduce=True, **kwargs
).get_optimal_solutions(limit=10)
print(len(safe), safe.exhausted) # 2, True
print(len(safe_da), safe_da.exhausted) # 1, TrueHere, dual-ascent reduction deletes the expensive While testing, I also found a broader, pre-existing ambiguity in how Two smaller points:
The full test suite and CI pass, and the main approach appears sound for unconstrained instances with strictly positive edge costs. Could you address the cases above and add regression tests comparing the enumeration-safe pool with |
…sing berendmarkhorst found two correctness gaps and requested a validation guard, a fixed example, and regression tests before merging berendmarkhorst#44: 1. max_degree/hop_limit-constrained instances could become incorrectly feasible: the degree-1/degree-2 structural fixpoint (unlike the heavy reductions) always runs regardless of these modifiers and is not degree- or hop-aware, so a contraction could map a reduced-graph solution back to one that violates the constraint on the original graph. get_optimal_solutions() now raises NotImplementedError for preprocess=True combined with either modifier (a pre-existing gap in preprocess=True generally, previously unreachable here since preprocess=True was always rejected before this PR). 2. da_reduce=True's structural cascade (reduce_graph_with_dual_ascent) did not forward enumeration_safe into its own _structural_fixpoint call, so a degree-2 contraction exposed by a (sound) dual-ascent deletion could still silently erase a tied optimum. Both now accept and thread enumeration_safe through. Also, per the reviewer's request, enumeration_safe=True now requires strictly positive edge weights (ValueError otherwise): the tied- optimum-preservation argument assumes positive costs, and a zero-cost edge separately triggers a pre-existing, out-of-scope ambiguity in what get_optimal_solutions() counts as a distinct solution (tracked in a separate issue the reviewer opened). Adds regression tests reproducing both reported cases, the new validation, and -- as requested -- randomized comparisons of the enumeration-safe pool against both a brute-force oracle and the preprocess=False path across seeded random graphs.
The performance.md example for enumeration_safe called get_solution() instead of get_optimal_solutions(), so it never actually demonstrated the feature it introduces (flagged in the PR berendmarkhorst#44 review). Both guide pages now also document the two restrictions added in the prior commit: strictly positive edge weights, and no preprocess=True combined with max_degree/hop_limit in get_optimal_solutions().
|
Thanks for the thorough review — both correctness issues reproduced and are fixed:
Also added, per your suggestion:
Pushed as two new commits ( |
|
Hi! Thanks for updating this. The implementation now addresses my earlier review points, and the overall approach looks good. Since PR #46 has been merged, this PR currently has conflicts in
There is also one small documentation cleanup: some comments and the test for non-positive weights still describe zero-cost enumeration as an unresolved, out-of-scope ambiguity. PR #46 has now resolved that ambiguity by defining solutions as inclusion-minimal. The strictly-positive-weight restriction for I tested a local merge with these conflicts resolved, and the complete test suite passed. Once the branch is rebased and the outdated wording is updated, this looks ready to merge. |
get_optimal_solutions() (berendmarkhorst#41/berendmarkhorst#42) requires preprocess=False because the default reduction pipeline can silently collapse tied-optimal alternatives before the ILP ever runs: degree-2 contraction against a same-cost parallel edge, node replacement, and the adjacent-terminal / Nearest-Vertex / Short-Links terminal-contraction tests each pick one witness among possibly several tied ones and structurally erase the rest. Per the design agreed in berendmarkhorst#43, add an opt-in `enumeration_safe=True` constructor kwarg that restricts preprocess_graph() to reductions proven to preserve the *complete set* of optima instead of merely the optimal value: * special distance, long-edge and bound-based deletions, and non-terminal degree-1 removal, are already exact (strict inequalities) and unaffected; * degree-2 contraction skips a node instead of contracting it when the contracted path ties an existing parallel edge; * node replacement (pseudo-elimination) is disabled outright; * of the terminal-contraction tests, only the forced degree-1 case still fires -- adjacent-terminal, Nearest-Vertex and Short-Links are skipped. get_optimal_solutions() now accepts preprocess=True when enumeration_safe=True, and back-maps the reduced-graph solution (adding the fixed-edge cost back to the objective) for that path -- previously dead code, since preprocess=True was always rejected before reaching it.
Explain enumeration_safe=True as the alternative to preprocess=False for get_optimal_solutions() on the solver-backends page, and add a section to the performance guide listing exactly which reduction tests it keeps, skips, or adjusts and why.
…sing berendmarkhorst found two correctness gaps and requested a validation guard, a fixed example, and regression tests before merging berendmarkhorst#44: 1. max_degree/hop_limit-constrained instances could become incorrectly feasible: the degree-1/degree-2 structural fixpoint (unlike the heavy reductions) always runs regardless of these modifiers and is not degree- or hop-aware, so a contraction could map a reduced-graph solution back to one that violates the constraint on the original graph. get_optimal_solutions() now raises NotImplementedError for preprocess=True combined with either modifier (a pre-existing gap in preprocess=True generally, previously unreachable here since preprocess=True was always rejected before this PR). 2. da_reduce=True's structural cascade (reduce_graph_with_dual_ascent) did not forward enumeration_safe into its own _structural_fixpoint call, so a degree-2 contraction exposed by a (sound) dual-ascent deletion could still silently erase a tied optimum. Both now accept and thread enumeration_safe through. Also, per the reviewer's request, enumeration_safe=True now requires strictly positive edge weights (ValueError otherwise): the tied- optimum-preservation argument assumes positive costs, and a zero-cost edge separately triggers a pre-existing, out-of-scope ambiguity in what get_optimal_solutions() counts as a distinct solution (tracked in a separate issue the reviewer opened). Adds regression tests reproducing both reported cases, the new validation, and -- as requested -- randomized comparisons of the enumeration-safe pool against both a brute-force oracle and the preprocess=False path across seeded random graphs.
The performance.md example for enumeration_safe called get_solution() instead of get_optimal_solutions(), so it never actually demonstrated the feature it introduces (flagged in the PR berendmarkhorst#44 review). Both guide pages now also document the two restrictions added in the prior commit: strictly positive edge weights, and no preprocess=True combined with max_degree/hop_limit in get_optimal_solutions().
…rebase PR berendmarkhorst#46 resolved the zero-cost-edge enumeration ambiguity by defining solutions as inclusion-minimal, so the strictly-positive-weight restriction on enumeration_safe=True no longer needs to cite it as a separate, out-of-scope issue -- its rationale is just that the tied-optimum-preservation argument assumes positive costs.
a0eddcd to
0cf949d
Compare
|
I just resolved the conflicts and it should work now. |
Summary
Closes #43. Implements the "enumeration-safe preprocessing mode" (option (a)) agreed on in the issue discussion:
get_optimal_solutions()(#41/#42) requirespreprocess=Falsebecause the default reduction pipeline can silently collapse tied-optimal alternatives before the ILP ever runs — degree-2 contraction against a same-cost parallel edge, node replacement, and the adjacent-terminal/Nearest-Vertex/Short-Links terminal-contraction tests each pick one witness among possibly several tied ones and structurally erase the rest.An opt-in
enumeration_safe=Trueconstructor kwarg now restrictspreprocess_graph()to reductions proven to preserve the complete set of optima, not merely the optimal value:A-B-Cexample from the issue).get_optimal_solutions()now acceptspreprocess=Truewhenenumeration_safe=True, and back-maps the reduced-graph solution (adding the fixed-edge cost back to the objective) for that path — previously dead code, sincepreprocess=Truewas always rejected before reaching it.Test plan
pytest— full suite passes (736 tests), including newtests/test_enumeration_safe.pycovering the diamond-graph tie from the issue end-to-end throughget_optimal_solutions(), plus per-reduction checks that node replacement / adjacent-terminal / NV / SL are disabled while degree-1 terminal contraction and the strict deletion tests are unaffected.black/flake8/mypy— no new issues introduced versus the pre-existing baseline.sphinx-build);solvers.mdandperformance.mdupdated to document the new flag.