Nothing
# The R side of the flow model: the door a problem comes through, the solve, and
# the certificate.
#
# The C++ tests in cpp_tests/ own the solver and the certifier. What is checked
# here is what only exists in R: that a malformed problem is named in R terms
# before it reaches a solver that can only speak in node indices, that the three
# steps stay separable, and that verify_flow() is a check rather than a report.
# A certificate that passed on potentials it never looked at would prove nothing,
# so every failing condition below is asserted to fail.
# Two supply nodes shipping to two demand nodes. Node 1 has two units and node 2
# has one; node 3 wants two and node 4 wants one. Its optimum is unique: both of
# node 1's units go to node 3 and node 2's unit goes to node 4, for a cost of 3.
flow_fixture <- function() {
list(
n_nodes = 4,
supply = c(2, 1, -2, -1),
arcs = data.frame(
tail = c(1, 1, 2, 2),
head = c(3, 4, 3, 4),
lower = c(0, 0, 0, 0),
upper = c(2, 2, 2, 2),
cost = c(1, 3, 2, 1)
)
)
}
FLOW_OPTIMUM <- c(2, 0, 0, 1)
test_that("a problem is checked at the door, in the caller's terms", {
ok <- flow_fixture()
expect_error(couplr:::.flow_problem(0, numeric(0), ok$arcs), "at least one node")
expect_error(couplr:::.flow_problem(2.5, c(1, -1), ok$arcs), "whole number")
expect_error(couplr:::.flow_problem(4, c(1, -1), ok$arcs), "but the problem has 4 nodes")
expect_error(couplr:::.flow_problem(4, c(2, 1, -2, NA), ok$arcs), "finite whole numbers")
expect_error(couplr:::.flow_problem(4, c(2, 1, -2, 0), ok$arcs), "sum to zero")
})
test_that("an arc list is checked column by column", {
ok <- flow_fixture()
with_arcs <- function(f) {
arcs <- ok$arcs
couplr:::.flow_problem(ok$n_nodes, ok$supply, f(arcs))
}
expect_error(couplr:::.flow_problem(4, ok$supply, "not a frame"), "must be a data frame")
expect_error(with_arcs(function(a) a[, c("tail", "head")]), "missing the column")
expect_error(with_arcs(function(a) { a$tail[1] <- NA; a }), "carry NA")
expect_error(with_arcs(function(a) { a$head[1] <- 9L; a }), "node ids in 1:4")
expect_error(with_arcs(function(a) { a$lower[1] <- -1; a }), "none negative")
expect_error(with_arcs(function(a) { a$lower[1] <- 0.5; a }), "finite whole numbers")
expect_error(with_arcs(function(a) { a$upper[1] <- 1.5; a }), "whole numbers or Inf")
expect_error(with_arcs(function(a) { a$lower[1] <- 2; a$upper[1] <- 1; a }),
"none below")
# Forbidden is expressed by leaving the arc out. An Inf cost that reaches the
# residual search poisons every potential derived from it.
expect_error(with_arcs(function(a) { a$cost[1] <- Inf; a }), "omit an arc rather")
expect_error(with_arcs(function(a) { a$cost[1] <- NA_real_; a }), "must be finite")
})
test_that("the arcs arrive typed, and Inf is the only unbounded capacity", {
ok <- flow_fixture()
ok$arcs$upper[1] <- Inf
prob <- couplr:::.flow_problem(ok$n_nodes, ok$supply, ok$arcs)
expect_s3_class(prob, "couplr_flow_problem")
expect_identical(prob$n_nodes, 4L)
expect_type(prob$arcs$tail, "integer")
expect_type(prob$arcs$head, "integer")
expect_type(prob$arcs$upper, "double")
expect_identical(names(prob$arcs),
c("tail", "head", "lower", "upper", "cost"))
expect_true(is.infinite(prob$arcs$upper[1]))
})
test_that("a problem can be given as a built object or as its three fields", {
ok <- flow_fixture()
built <- couplr:::.flow_problem(ok$n_nodes, ok$supply, ok$arcs)
expect_identical(couplr:::.as_flow_problem(built), built)
expect_equal(couplr:::.as_flow_problem(ok), built)
expect_error(couplr:::.as_flow_problem(list(n_nodes = 4)), "must be a flow problem")
})
test_that("the print method reports the shape of the network", {
prob <- couplr:::.as_flow_problem(flow_fixture())
expect_output(print(prob), "Flow problem")
expect_output(print(prob), "Nodes: 4")
expect_output(print(prob), "Arcs: 4")
expect_output(print(prob), "Flow to place: 3 units")
expect_output(expect_invisible(print(prob)), "Flow problem")
})
test_that("solving returns the flow, the potentials and a status from the vocabulary", {
solved <- couplr:::.flow_solve(flow_fixture())
expect_s3_class(solved, "flow_solve_result")
expect_identical(solved$status, "optimal")
expect_true(solved$status %in% solver_status_values())
expect_equal(solved$flow, FLOW_OPTIMUM)
expect_equal(solved$total_cost, 3)
expect_equal(solved$flow_sent, 3)
expect_equal(solved$flow_required, 3)
# Potentials are defined up to a constant per component, and the lowering
# needs one particular representative: the first node is the origin.
expect_length(solved$potential, 4)
expect_equal(solved$potential[1], 0)
# The problem travels with the answer, which is what lets the flow, the
# potentials and the network they belong to reach the certificate together.
expect_s3_class(solved$problem, "couplr_flow_problem")
})
test_that("potentials are omitted when they are not asked for", {
solved <- couplr:::.flow_solve(flow_fixture(), return_potentials = FALSE)
expect_length(solved$potential, 0)
expect_equal(solved$flow, FLOW_OPTIMUM)
})
test_that("a solve that cannot place every unit says so rather than reporting optimal", {
# Node 4 wants a unit and nothing reaches it.
stranded <- list(
n_nodes = 4,
supply = c(2, 1, -2, -1),
arcs = data.frame(tail = c(1, 2), head = c(3, 3),
lower = c(0, 0), upper = c(2, 2), cost = c(1, 1))
)
partial <- couplr:::.flow_solve(stranded)
expect_identical(partial$status, "partial")
expect_equal(partial$flow_sent, 2)
expect_equal(partial$flow_required, 3)
# An arc that carries nothing is not a route.
blocked <- list(
n_nodes = 2,
supply = c(1, -1),
arcs = data.frame(tail = 1, head = 2, lower = 0, upper = 0, cost = 1)
)
expect_identical(couplr:::.flow_solve(blocked)$status, "infeasible")
})
test_that("a solve that falls short places the cheapest flow of its value", {
# Two nodes with a unit each and one deficit node reachable from both, over
# arcs costing 10 and 1. Only one unit can be placed, and it is the one over
# the arc costing 1 whichever order the arcs and nodes are given in.
for (dear_first in c(TRUE, FALSE)) {
arcs <- data.frame(tail = c(1, 2), head = c(3, 3), lower = 0, upper = 1,
cost = c(10, 1))
if (!dear_first) arcs <- arcs[2:1, ]
short <- couplr:::.flow_solve(list(n_nodes = 4, supply = c(1, 1, -1, -1),
arcs = arcs))
expect_identical(short$status, "partial")
expect_equal(short$flow_sent, 1)
expect_equal(short$total_cost, 1)
}
})
test_that("stopping on the augmentation cap is not optimality", {
capped <- couplr:::.flow_solve(flow_fixture(), max_augmentations = 1)
expect_identical(capped$status, "iteration_limit")
expect_lt(capped$flow_sent, capped$flow_required)
expect_error(couplr:::.flow_solve(flow_fixture(), max_augmentations = -1),
"must not be negative")
})
test_that("a lower bound is met by the solver, not by the caller", {
# The cheap arc is 1 -> 3, but 1 -> 4 must carry a unit.
forced <- flow_fixture()
forced$arcs$lower[2] <- 1
solved <- couplr:::.flow_solve(forced)
expect_identical(solved$status, "optimal")
expect_equal(solved$flow[2], 1)
expect_true(verify_flow(solved)$certified_optimal)
# A unit is pushed off the cheapest route and node 3's second unit comes from
# node 2 instead: 3 on the forced arc, 1 from node 1 and 2 from node 2.
expect_equal(solved$total_cost, 6)
})
test_that("verify_flow certifies a solve result end to end", {
solved <- couplr:::.flow_solve(flow_fixture())
cert <- verify_flow(solved)
expect_s3_class(cert, "flow_certificate")
expect_true(cert$certified_optimal)
expect_true(cert$primal_feasible)
expect_true(cert$dual_feasible)
expect_true(cert$complementary_slackness)
expect_equal(cert$n_capacity_violations, 0)
expect_equal(cert$n_conservation_violations, 0)
expect_equal(cert$n_cs_violations, 0)
expect_equal(cert$primal_objective, 3)
expect_equal(cert$dual_objective, 3)
expect_equal(cert$duality_gap, 0)
expect_equal(cert$tolerance, 1e-9)
# worst_arc names the arc attaining the smallest reduced cost over the
# residual graph, which exists whenever any arc can still take flow. On an
# optimal flow that minimum is at the tolerance rather than below it, which
# is what dual_feasible above reads.
expect_equal(cert$min_residual_reduced_cost, 0)
expect_equal(cert$worst_arc, 1)
})
test_that("a flow and a problem certify without a solve result to carry them", {
cert <- verify_flow(FLOW_OPTIMUM, flow_fixture(), potential = c(0, 0, 1, 1))
expect_true(cert$certified_optimal)
expect_equal(cert$primal_objective, 3)
})
test_that("potentials that certify nothing fail the check rather than passing it", {
# The optimal flow, priced by potentials that are not optimal ones. Every arc
# prices at its own cost, so nothing is underpriced and dual feasibility
# holds, while the two arcs carrying flow price above zero and slackness does
# not.
cert <- verify_flow(FLOW_OPTIMUM, flow_fixture(), potential = c(0, 0, 0, 0))
expect_false(cert$certified_optimal)
expect_true(cert$primal_feasible)
expect_true(cert$dual_feasible)
expect_false(cert$complementary_slackness)
expect_equal(cert$n_cs_violations, 2)
expect_equal(cert$primal_objective, 3)
expect_equal(cert$dual_objective, 0)
expect_equal(cert$duality_gap, 3)
})
test_that("a dual violation names the arc that attains it, indexed from one", {
# Both demand nodes priced far above every arc's cost, so each arc that can
# still take flow prices below zero. Arc 4 is the worst at 1 - 10.
cert <- verify_flow(FLOW_OPTIMUM, flow_fixture(), potential = c(0, 0, 10, 10))
expect_false(cert$certified_optimal)
expect_false(cert$dual_feasible)
expect_equal(cert$min_residual_reduced_cost, -9)
expect_equal(cert$worst_arc, 4)
})
test_that("a flow outside its bounds fails primal feasibility and reports no objective", {
cert <- verify_flow(c(3, 0, 0, 1), flow_fixture(), potential = c(0, 0, 1, 1))
expect_false(cert$certified_optimal)
expect_false(cert$primal_feasible)
expect_equal(cert$n_capacity_violations, 1)
# A cost summed over a vector that is not a flow invites comparison with the
# dual bound while corresponding to no feasible solution.
expect_true(is.na(cert$primal_objective))
expect_true(is.na(cert$duality_gap))
})
test_that("a flow that does not conserve is not a flow", {
cert <- verify_flow(c(1, 0, 0, 1), flow_fixture(), potential = c(0, 0, 1, 1))
expect_false(cert$primal_feasible)
expect_equal(cert$n_capacity_violations, 0)
# Node 1 sends one unit short and node 3 receives one short.
expect_equal(cert$n_conservation_violations, 2)
expect_equal(cert$max_conservation_error, 1)
})
test_that("a fractional flow is not rounded into one that certifies", {
cert <- verify_flow(c(1.5, 0.5, 0, 1), flow_fixture(), potential = c(0, 0, 1, 1))
expect_false(cert$certified_optimal)
expect_false(cert$primal_feasible)
expect_true(is.na(cert$primal_objective))
})
test_that("optimal potentials certify an optimal flow the solver did not return", {
# Every arc costs the same, so both perfect matchings cost 2 and the solver
# returns one of them. Optimal potentials are shared by all optimal solutions
# of a linear program, so they certify the other one too.
tied <- list(
n_nodes = 4,
supply = c(1, 1, -1, -1),
arcs = data.frame(tail = c(1, 1, 2, 2), head = c(3, 4, 3, 4),
lower = 0, upper = 1, cost = 1)
)
solved <- couplr:::.flow_solve(tied)
expect_equal(solved$total_cost, 2)
other <- 1 - solved$flow
expect_equal(sum(other), 2)
expect_true(verify_flow(other, tied, potential = solved$potential)$certified_optimal)
})
test_that("verify_flow solves for potentials when it is given none", {
cert <- verify_flow(FLOW_OPTIMUM, flow_fixture())
expect_true(cert$certified_optimal)
expect_equal(cert$dual_objective, 3)
})
test_that("the check refuses what it cannot certify", {
prob <- flow_fixture()
expect_error(verify_flow(FLOW_OPTIMUM), "`problem` is required")
expect_error(verify_flow(c(2, 0, 1), prob),
"3 flow values but the problem has 4 arcs")
expect_error(verify_flow(FLOW_OPTIMUM, prob, potential = c(0, 0, 1)),
"has length 3 but the problem has 4 nodes")
expect_error(verify_flow(FLOW_OPTIMUM, prob, tol = -1), "non-negative")
expect_error(verify_flow(FLOW_OPTIMUM, prob, tol = c(1e-9, 1e-9)), "single non-negative")
expect_error(verify_flow("two units", prob), "numeric vector of arc flows")
expect_error(verify_flow(list(potential = c(0, 0, 1, 1)), prob),
"numeric vector of arc flows")
})
test_that("the certificate prints its conditions and its conclusion", {
cert <- verify_flow(couplr:::.flow_solve(flow_fixture()))
expect_output(print(cert), "Flow certificate")
expect_output(print(cert), "primal_feasible TRUE")
expect_output(print(cert), "certified_optimal TRUE")
expect_output(print(cert), "duality_gap")
expect_output(expect_invisible(print(cert)), "Flow certificate")
# The worst arc is reported only when there is a dual violation to attribute.
bad <- verify_flow(FLOW_OPTIMUM, flow_fixture(), potential = c(0, 0, 10, 10))
expect_output(print(bad), "worst reduced cost")
expect_output(print(bad), "certified_optimal FALSE")
})
test_that("full_match carries the certificate of the flow it actually solved", {
set.seed(4)
left <- data.frame(id = 1:4, x = rnorm(4))
right <- data.frame(id = 5:14, x = rnorm(10))
result <- full_match(left, right, vars = "x")
expect_s3_class(result$certificate, "flow_certificate")
expect_true(result$certificate$certified_optimal)
expect_true(result$certificate$primal_feasible)
expect_equal(result$certificate$duality_gap, 0, tolerance = 1e-9)
})
# --- warm starts --------------------------------------------------------------
#
# A warm start is a starting point, never an answer: what it may change is how
# many augmentations a solve takes, and what it may not change is what the solve
# returns. Every test here compares a warm solve against the cold solve of the
# same problem and asserts the two agree.
# A network wide enough that a warm start has something to save: 40 rows and 40
# columns over a random cost matrix, compiled the way the balance designs
# compile.
flow_wide_fixture <- function(seed = 7L, n = 40L) {
set.seed(seed)
n_nodes <- 2 * n + 2
rows <- seq_len(n)
cols <- seq_len(n)
arcs <- data.frame(
tail = c(rep(1, n), rep(1 + rows, each = n), 1 + n + cols),
head = c(1 + rows, rep(1 + n + cols, times = n), rep(2 + 2 * n, n)),
lower = 0,
upper = 1,
cost = c(rep(0, n), round(stats::runif(n * n, 0, 10), 3), rep(0, n))
)
list(n_nodes = n_nodes,
supply = c(n, rep(0, 2 * n), -n),
arcs = arcs)
}
test_that("a warm start returns the cold answer", {
prob <- flow_wide_fixture()
cold <- couplr:::.flow_solve(prob)
warm <- couplr:::.flow_solve(prob, warm_flow = cold$flow,
warm_potential = cold$potential)
expect_identical(warm$status, "optimal")
expect_equal(warm$total_cost, cold$total_cost)
expect_equal(warm$flow, cold$flow)
expect_true(verify_flow(warm)$certified_optimal)
# Started from its own optimum, the solve has nothing left to augment.
expect_identical(warm$n_augmentations, 0)
})
test_that("a warm start from another cost vector still lands on the optimum", {
prob <- flow_wide_fixture(seed = 9L)
other <- prob
other$arcs$cost <- rev(prob$arcs$cost)
stale <- couplr:::.flow_solve(other)
cold <- couplr:::.flow_solve(prob)
warm <- couplr:::.flow_solve(prob, warm_flow = stale$flow,
warm_potential = stale$potential)
expect_identical(warm$status, "optimal")
expect_equal(warm$total_cost, cold$total_cost)
expect_true(verify_flow(warm)$certified_optimal)
})
test_that("either half of a warm start is enough on its own", {
prob <- flow_wide_fixture(seed = 11L)
cold <- couplr:::.flow_solve(prob)
flow_only <- couplr:::.flow_solve(prob, warm_flow = cold$flow)
pot_only <- couplr:::.flow_solve(prob, warm_potential = cold$potential)
for (res in list(flow_only, pot_only)) {
expect_identical(res$status, "optimal")
expect_equal(res$total_cost, cold$total_cost)
expect_true(verify_flow(res)$certified_optimal)
}
})
test_that("a warm start of the wrong shape is named in the caller's terms", {
prob <- flow_fixture()
expect_error(couplr:::.flow_solve(prob, warm_flow = c(1, 2)),
"`warm_flow` has length 2")
expect_error(couplr:::.flow_solve(prob, warm_potential = c(0, 0)),
"`warm_potential` has length 2")
expect_error(couplr:::.flow_solve(prob, warm_flow = rep(NA_real_, 4)),
"must not contain NA")
expect_error(couplr:::.flow_solve(prob, warm_flow = c(0, 0, 0, 0.5)),
"not a whole number")
# A flow above an arc's capacity is a different problem's flow, not a start.
expect_error(couplr:::.flow_solve(prob, warm_flow = c(9, 0, 0, 0)),
"outside its bounds")
})
test_that("a warm start agrees with the cold answer across instances", {
for (seed in 21:32) {
prob <- flow_wide_fixture(seed = seed, n = 25L)
cold <- couplr:::.flow_solve(prob)
shifted <- prob
shifted$arcs$cost <- prob$arcs$cost + round(stats::runif(nrow(prob$arcs)), 3)
moved_cold <- couplr:::.flow_solve(shifted)
moved_warm <- couplr:::.flow_solve(shifted, warm_flow = cold$flow,
warm_potential = cold$potential)
expect_equal(moved_warm$total_cost, moved_cold$total_cost)
expect_true(verify_flow(moved_warm)$certified_optimal)
}
})
# --- time budgets -------------------------------------------------------------
test_that("a spent budget stops the solve and says so", {
prob <- flow_wide_fixture(seed = 13L)
stopped <- couplr:::.flow_solve(prob, time_limit = 0)
expect_identical(stopped$status, "interrupted")
expect_true("interrupted" %in% solver_status_values())
expect_lt(stopped$flow_sent, stopped$flow_required)
# It is not an answer and it is not evidence that no answer exists.
expect_false(verify_flow(stopped)$certified_optimal)
expect_identical(couplr:::.flow_solve(prob)$status, "optimal")
})
test_that("an interrupted flow still respects every arc bound", {
prob <- flow_wide_fixture(seed = 15L)
stopped <- couplr:::.flow_solve(prob, time_limit = 0)
expect_length(stopped$flow, nrow(prob$arcs))
expect_true(all(stopped$flow >= prob$arcs$lower))
expect_true(all(stopped$flow <= prob$arcs$upper))
expect_length(stopped$potential, prob$n_nodes)
})
test_that("a budget the solve fits inside changes nothing", {
prob <- flow_wide_fixture(seed = 17L)
cold <- couplr:::.flow_solve(prob)
budgeted <- couplr:::.flow_solve(prob, time_limit = 60)
expect_identical(budgeted$status, "optimal")
expect_equal(budgeted$total_cost, cold$total_cost)
})
test_that("a time limit is checked in the caller's terms", {
expect_error(couplr:::.flow_solve(flow_fixture(), time_limit = -1),
"non-negative")
expect_error(couplr:::.flow_solve(flow_fixture(), time_limit = c(1, 2)),
"single non-negative")
})
# --- the tolerance the reduced costs are read against -------------------------
#
# cbar(a) is computed as cost(a) + pi(tail(a)) - pi(head(a)), so its last bits
# are worth the largest of those three times the machine epsilon. The tolerance
# it is compared against scales with them for that reason, and the tests below
# are the two halves of that: rounding at the problem's own scale is not a
# violation, and a violation at that scale still is one.
test_that("the per-arc tolerance scales with the numbers behind the reduced cost", {
small <- couplr:::.flow_solve(flow_fixture())
expect_true(verify_flow(small)$certified_optimal)
# Costs and potentials of order 1 leave the scale at its floor.
expect_equal(verify_flow(small, tol = 1e-9)$dual_tolerance, 1e-9)
big <- flow_fixture()
big$arcs$cost <- big$arcs$cost * 1e7
solved <- couplr:::.flow_solve(big)
cert <- verify_flow(solved, tol = 1e-9)
expect_true(cert$certified_optimal)
expect_gt(cert$dual_tolerance, 1e-9)
expect_equal(cert$dual_tolerance,
1e-9 * max(1, abs(big$arcs$cost), abs(solved$potential)))
})
test_that("a violation at the problem's scale is named rather than absorbed", {
big <- flow_fixture()
big$arcs$cost <- big$arcs$cost * 1e7
solved <- couplr:::.flow_solve(big)
arc_tol <- 1e-9 * max(1, abs(big$arcs$cost), abs(solved$potential))
# An arc that can still take flow is what dual feasibility prices. Its head's
# potential is moved until the arc prices a thousand tolerances below zero,
# which is still eight orders of magnitude below the costs themselves.
a <- which(solved$flow < big$arcs$upper)[[1L]]
head_node <- big$arcs$head[[a]]
cbar <- big$arcs$cost[[a]] + solved$potential[[big$arcs$tail[[a]]]] -
solved$potential[[head_node]]
broken <- solved$potential
broken[[head_node]] <- broken[[head_node]] + cbar + 1000 * arc_tol
cert <- verify_flow(solved, potential = broken, tol = 1e-9)
expect_false(cert$certified_optimal)
expect_false(cert$dual_feasible)
expect_lt(cert$min_residual_reduced_cost, -cert$dual_tolerance)
})
test_that("a balance design large enough to round past 1e-9 still certifies", {
skip_on_cran()
# The lexicographic tier weights rank cardinality above balance above
# distance, so the potentials grow with the problem. At this size they reach
# the millions, where one unit in the last place is around 1e-9 and the
# reduced costs cannot be read against an absolute 1e-9.
set.seed(41L)
n <- 500L
left <- data.frame(g = sample.int(4L, n, TRUE),
x = stats::rnorm(n, 0.6), y = stats::rnorm(n, 0.4))
right <- data.frame(g = sample.int(4L, n, TRUE),
x = stats::rnorm(n, -0.6), y = stats::rnorm(n, -0.4))
cost <- abs(outer(left$x, right$x, "-")) + abs(outer(left$y, right$y, "-"))
cost[matrix(stats::runif(n * n) > 0.05, n, n)] <- Inf
built <- couplr:::.balance_flow_problem(
cost, couplr:::.refined_hierarchy(left, right, "g", exact = 1L))
solved <- couplr:::.flow_solve(built$problem)
cert <- verify_flow(solved, tol = 1e-9)
expect_identical(solved$status, "optimal")
expect_gt(max(abs(solved$potential)), 1e6)
# One unit in the last place of a potential this size is around 1e-9, and the
# worst residual arc sits within a few of them: that is the resolution an
# absolute 1e-9 is read at, rather than above. The tolerance the certificate
# used scales with the same numbers, which puts it orders of magnitude clear.
ulp <- .Machine$double.eps * max(abs(solved$potential))
expect_gt(ulp, 5e-10)
expect_lt(abs(cert$min_residual_reduced_cost), 4 * ulp)
expect_gt(cert$dual_tolerance, 1e-3)
expect_true(cert$certified_optimal)
report <- couplr:::.cardinality_solve(left, right, cost, refined = "g",
exact = 1L)
expect_identical(report$stopped_on, "optimality")
expect_identical(report$gap, 0L)
expect_true(report$certified)
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.