use std::collections::{HashMap, HashSet};
use panproto_gat::Name;
use panproto_schema::{Edge, Protocol, Schema};
use crate::error::SpanError;
use crate::solve::build::{NoEvidence, build_cfn};
use crate::solve::cost::{CostWeights, DEFAULT_WEIGHTS};
use crate::solve::{
Assignment, Cfn, CfnBuilder, Cost, LimitKind, SearchBudget, SolveOutcome, all_optima_traced,
dispatch_plan, eliminate, solve, solve_epic, solve_iso, solve_monic,
};
use crate::span::{
DEFAULT_OPTIMA_CAP, SchemaSpan, SpanSearch, bijective_edge_map, greedy_edge_map, image_map,
mappable_edges,
};
#[derive(Clone, Debug, Default)]
pub struct SearchOptions {
pub monic: bool,
pub epic: bool,
pub iso: bool,
pub max_results: usize,
pub hard_pins: HashMap<Name, Name>,
}
#[derive(Clone, Debug, Default)]
pub struct DomainConstraints {
pub restricted_domains: HashMap<Name, Vec<Name>>,
pub excluded_targets: HashSet<Name>,
pub excluded_sources: HashSet<Name>,
pub scoring_weights: Option<CostWeights>,
}
#[derive(Clone, Debug)]
pub struct FoundMorphism {
pub vertex_map: HashMap<Name, Name>,
pub edge_map: HashMap<Edge, Edge>,
pub quality: f64,
}
pub fn find_span(
src: &Schema,
tgt: &Schema,
protocol: &Protocol,
opts: &SearchOptions,
) -> Result<SchemaSpan, SpanError> {
SpanSearch::new(protocol)
.with_options(opts.clone())
.run(src, tgt)
}
pub fn find_span_constrained(
src: &Schema,
tgt: &Schema,
protocol: &Protocol,
opts: &SearchOptions,
constraints: &DomainConstraints,
) -> Result<SchemaSpan, SpanError> {
SpanSearch::new(protocol)
.with_options(opts.clone())
.with_constraints(constraints.clone())
.run(src, tgt)
}
pub fn find_morphisms(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
) -> Result<MorphismList, SpanError> {
find_morphisms_constrained(src, tgt, opts, &DomainConstraints::default())
}
pub fn find_best_morphism(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
) -> Result<Option<FoundMorphism>, SpanError> {
let mut opts = opts.clone();
opts.max_results = 1;
Ok(find_morphisms(src, tgt, &opts)?
.morphisms
.into_iter()
.next())
}
pub fn find_morphisms_constrained(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
constraints: &DomainConstraints,
) -> Result<MorphismList, SpanError> {
find_morphisms_budgeted(src, tgt, opts, constraints, &SearchBudget::default())
}
pub fn find_morphisms_budgeted(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
constraints: &DomainConstraints,
budget: &SearchBudget,
) -> Result<MorphismList, SpanError> {
if opts.epic {
let (source, target) = (src.vertices.len(), tgt.vertices.len());
if source < target || (opts.monic && source != target) {
return Ok(MorphismList::exhaustive(Vec::new()));
}
}
let weights = constraints.scoring_weights.unwrap_or(DEFAULT_WEIGHTS);
let cfn = build_cfn(
src,
tgt,
opts,
constraints,
&NoEvidence,
weights,
budget.mem_bytes,
)?;
let limit = if opts.max_results == 0 {
DEFAULT_OPTIMA_CAP
} else {
opts.max_results.min(DEFAULT_OPTIMA_CAP)
};
if opts.iso {
return isomorphisms(&cfn, src, tgt, budget).map(MorphismList::exhaustive);
}
let total = without_bottom(&cfn, budget.mem_bytes);
let (assignments, truncated) = if opts.epic {
answered(solve_epic(&total, budget, tgt.vertices.len(), opts.monic))
} else if opts.monic {
answered(solve_monic(&total, budget))
} else {
optimal_assignments(&total, budget, limit)
}
.map_err(|limit| SpanError::Stopped { limit })?;
Ok(MorphismList {
morphisms: assignments
.iter()
.filter_map(|assignment| morphism_of(&total, src, tgt, assignment, opts))
.collect(),
truncated,
})
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct MorphismList {
pub morphisms: Vec<FoundMorphism>,
pub truncated: bool,
}
impl MorphismList {
#[must_use]
const fn exhaustive(morphisms: Vec<FoundMorphism>) -> Self {
Self {
morphisms,
truncated: false,
}
}
}
pub fn find_best_morphism_constrained(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
constraints: &DomainConstraints,
) -> Result<Option<FoundMorphism>, SpanError> {
find_best_morphism_budgeted(src, tgt, opts, constraints, &SearchBudget::default())
}
pub fn find_best_morphism_budgeted(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
constraints: &DomainConstraints,
budget: &SearchBudget,
) -> Result<Option<FoundMorphism>, SpanError> {
let mut opts = opts.clone();
opts.max_results = 1;
Ok(
find_morphisms_budgeted(src, tgt, &opts, constraints, budget)?
.morphisms
.into_iter()
.next(),
)
}
#[must_use]
pub fn morphism_to_migration(found: &FoundMorphism) -> crate::Migration {
crate::Migration {
vertex_map: found.vertex_map.clone(),
edge_map: found.edge_map.clone(),
hyper_edge_map: HashMap::new(),
label_map: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
expr_resolvers: HashMap::new(),
coercions: HashMap::new(),
domain: None,
codomain: None,
}
}
#[must_use]
pub fn without_bottom(cfn: &Cfn, mem_bytes: usize) -> Cfn {
let spec: Vec<(Name, Vec<Name>)> = cfn
.variables()
.iter()
.map(|variable| (variable.name().clone(), variable.values().to_vec()))
.collect();
let Ok(mut builder) = CfnBuilder::with_mem_bytes(spec, cfn.weights(), mem_bytes) else {
return cfn.clone();
};
builder.add_empty(cfn.c_empty());
for var in cfn.variable_ids() {
let Some(table) = cfn.unary(var) else {
continue;
};
let mut table = table.to_vec();
if let Some(bottom) = table.last_mut() {
*bottom = Cost::TOP_SENTINEL;
}
if builder.add_unary_table(var, &table).is_err() {
return cfn.clone();
}
}
for function in cfn.functions() {
if builder
.add_function(function.scope(), function.table().to_vec())
.is_err()
{
return cfn.clone();
}
}
builder.build()
}
fn optimal_assignments(
cfn: &Cfn,
budget: &SearchBudget,
limit: usize,
) -> Result<(Vec<Assignment>, bool), LimitKind> {
let plan = dispatch_plan(cfn, budget);
if plan.exact {
let buckets = eliminate(cfn, &plan.order);
let (optima, trace) = all_optima_traced(cfn, &buckets, limit);
return Ok((optima, trace.truncated));
}
answered(solve(cfn, budget))
}
fn answered(outcome: SolveOutcome) -> Result<(Vec<Assignment>, bool), LimitKind> {
match (outcome.best, outcome.limit_hit) {
(None, Some(limit)) => Err(limit),
(best, _) => Ok((best.into_iter().collect(), false)),
}
}
fn morphism_of(
cfn: &Cfn,
src: &Schema,
tgt: &Schema,
assignment: &Assignment,
opts: &SearchOptions,
) -> Option<FoundMorphism> {
let vertex_map = image_map(cfn, assignment);
if vertex_map.len() != src.vertices.len() {
return None;
}
debug_assert!(
!opts.epic || is_surjective(&vertex_map, tgt),
"the surjective search returned an assignment that is not onto"
);
let edge_map = greedy_edge_map(src, tgt, &vertex_map);
if edge_map.len() != mappable_edges(src) {
return None;
}
Some(FoundMorphism {
vertex_map,
edge_map,
quality: cfn.quality_of(assignment),
})
}
fn isomorphisms(
cfn: &Cfn,
src: &Schema,
tgt: &Schema,
budget: &SearchBudget,
) -> Result<Vec<FoundMorphism>, SpanError> {
let outcome = solve_iso(cfn, src, tgt, budget)?;
let Some(assignment) = outcome.best else {
return Ok(Vec::new());
};
let vertex_map = image_map(cfn, &assignment);
if vertex_map.len() != src.vertices.len() || src.vertices.len() != tgt.vertices.len() {
return Ok(Vec::new());
}
if !is_surjective(&vertex_map, tgt) {
return Ok(Vec::new());
}
let Some(edge_map) = bijective_edge_map(src, tgt, &vertex_map) else {
return Ok(Vec::new());
};
Ok(vec![FoundMorphism {
vertex_map,
edge_map,
quality: cfn.quality_of(&assignment),
}])
}
fn is_surjective(vertex_map: &HashMap<Name, Name>, tgt: &Schema) -> bool {
let mut images: Vec<&Name> = vertex_map.values().collect();
images.sort_unstable();
images.dedup();
images.len() == tgt.vertices.len()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
mod tests {
use super::*;
use crate::solve::DEFAULT_MEM_BYTES;
use panproto_schema::SchemaBuilder;
fn test_protocol() -> Protocol {
Protocol {
name: "test".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![],
obj_kinds: vec!["object".into(), "string".into(), "integer".into()],
constraint_sorts: vec![],
..Protocol::default()
}
}
fn build_schema(vertices: &[(&str, &str)], edges: &[(&str, &str, &str, &str)]) -> Schema {
let proto = test_protocol();
let mut builder = SchemaBuilder::new(&proto);
for (id, kind) in vertices {
builder = builder.vertex(id, kind, None::<&str>).unwrap();
}
for (src, tgt, kind, name) in edges {
builder = builder.edge(src, tgt, kind, Some(*name)).unwrap();
}
builder.build().unwrap()
}
#[test]
fn identity_morphism_found() {
let schema = build_schema(
&[("root", "object"), ("root.name", "string")],
&[("root", "root.name", "prop", "name")],
);
let results = find_morphisms(&schema, &schema, &SearchOptions::default())
.unwrap()
.morphisms;
assert!(!results.is_empty(), "should find at least the identity");
assert!(
results.iter().any(|m| m
.vertex_map
.iter()
.all(|(src, tgt)| src.as_str() == tgt.as_str())),
"identity morphism should be found"
);
}
#[test]
fn renamed_schema_morphism() {
let old = build_schema(
&[("root", "object"), ("root.text", "string")],
&[("root", "root.text", "prop", "text")],
);
let new = build_schema(
&[("root", "object"), ("root.body", "string")],
&[("root", "root.body", "prop", "body")],
);
let results = find_morphisms(&old, &new, &SearchOptions::default())
.unwrap()
.morphisms;
assert!(!results.is_empty(), "a renamed schema still maps");
assert_eq!(
results[0].vertex_map.get("root").map(Name::as_str),
Some("root")
);
}
#[test]
fn no_morphism_incompatible() {
let a = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let b = build_schema(
&[("root", "object"), ("root.y", "integer")],
&[("root", "root.y", "prop", "y")],
);
assert!(
find_morphisms(&a, &b, &SearchOptions::default())
.unwrap()
.morphisms
.is_empty(),
"no total morphism exists between incompatible schemas"
);
}
#[test]
fn monic_rejects_non_injective() {
let src = build_schema(
&[
("root", "object"),
("root.a", "string"),
("root.b", "string"),
],
&[
("root", "root.a", "prop", "a"),
("root", "root.b", "prop", "b"),
],
);
let tgt = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let opts = SearchOptions {
monic: true,
..SearchOptions::default()
};
assert!(
find_morphisms(&src, &tgt, &opts)
.unwrap()
.morphisms
.is_empty(),
"two source strings cannot share one target injectively"
);
}
#[test]
fn iso_finds_isomorphism() {
let a = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let b = build_schema(
&[("root", "object"), ("root.y", "string")],
&[("root", "root.y", "prop", "y")],
);
let opts = SearchOptions {
iso: true,
..SearchOptions::default()
};
assert!(
!find_morphisms(&a, &b, &opts).unwrap().morphisms.is_empty(),
"structurally identical schemas are isomorphic"
);
}
#[test]
fn iso_refuses_a_vertex_bijection_whose_edge_map_is_not_one() {
let src = build_schema(
&[("a", "object"), ("b", "string")],
&[
("a", "b", "prop", "p"),
("a", "b", "prop", "q"),
("a", "b", "prop", "r"),
],
);
let tgt = build_schema(
&[("x", "object"), ("y", "string")],
&[("x", "y", "prop", "p")],
);
let opts = SearchOptions {
iso: true,
..SearchOptions::default()
};
assert!(
find_morphisms(&src, &tgt, &opts)
.unwrap()
.morphisms
.is_empty(),
"an edge map that is not injective is not an isomorphism"
);
assert!(
find_morphisms(&tgt, &src, &opts)
.unwrap()
.morphisms
.is_empty(),
"an edge map that is not surjective is not an isomorphism"
);
assert!(
!find_morphisms(&src, &tgt, &SearchOptions::default())
.unwrap()
.morphisms
.is_empty()
);
}
#[test]
fn iso_matches_parallel_arcs_by_kind_when_names_disagree() {
let src = build_schema(
&[("a", "object"), ("b", "string")],
&[("a", "b", "prop", "p"), ("a", "b", "item", "q")],
);
let tgt = build_schema(
&[("x", "object"), ("y", "string")],
&[("x", "y", "prop", "s"), ("x", "y", "item", "t")],
);
let opts = SearchOptions {
iso: true,
..SearchOptions::default()
};
let results = find_morphisms(&src, &tgt, &opts).unwrap().morphisms;
assert!(!results.is_empty(), "the kinds match up, so this is an iso");
let found = &results[0];
assert_eq!(found.edge_map.len(), 2);
let mut images: Vec<_> = found.edge_map.values().collect();
images.sort_unstable();
images.dedup();
assert_eq!(images.len(), 2, "the edge map is injective");
for (source, image) in &found.edge_map {
assert_eq!(source.kind, image.kind, "arc kinds are preserved");
}
}
#[test]
fn hard_pins_are_respected() {
let schema = build_schema(
&[
("root", "object"),
("root.a", "string"),
("root.b", "string"),
],
&[
("root", "root.a", "prop", "a"),
("root", "root.b", "prop", "b"),
],
);
let mut hard_pins = HashMap::new();
hard_pins.insert(Name::from("root.a"), Name::from("root.b"));
hard_pins.insert(Name::from("root.b"), Name::from("root.a"));
hard_pins.insert(Name::from("root"), Name::from("root"));
let opts = SearchOptions {
hard_pins,
..SearchOptions::default()
};
let results = find_morphisms(&schema, &schema, &opts).unwrap().morphisms;
assert!(!results.is_empty(), "the pinned assignment is a morphism");
let m = &results[0];
assert_eq!(m.vertex_map.get("root.a").map(Name::as_str), Some("root.b"));
assert_eq!(m.vertex_map.get("root.b").map(Name::as_str), Some("root.a"));
}
#[test]
fn quality_scoring_prefers_name_match() {
let src = build_schema(
&[("root", "object"), ("root.name", "string")],
&[("root", "root.name", "prop", "name")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.name", "string"),
("root.other", "string"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.other", "prop", "other"),
],
);
let results = find_morphisms(&src, &tgt, &SearchOptions::default())
.unwrap()
.morphisms;
assert!(!results.is_empty());
for found in &results {
assert_eq!(
found.vertex_map.get("root.name").map(Name::as_str),
Some("root.name"),
"an optimal morphism maps the name-matching target"
);
}
}
#[test]
fn every_result_attains_the_optimum() {
let src = build_schema(
&[("root", "object"), ("root.a", "string")],
&[("root", "root.a", "prop", "a")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.x", "string"),
("root.y", "string"),
],
&[
("root", "root.x", "prop", "a"),
("root", "root.y", "prop", "a"),
],
);
let results = find_morphisms(&src, &tgt, &SearchOptions::default())
.unwrap()
.morphisms;
assert!(results.len() >= 2, "the tie is enumerated");
let best = results[0].quality;
for found in &results {
assert_eq!(found.quality, best, "every result attains the optimum");
}
}
#[test]
fn max_results_caps_the_list() {
let src = build_schema(
&[("root", "object"), ("root.a", "string")],
&[("root", "root.a", "prop", "a")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.x", "string"),
("root.y", "string"),
],
&[
("root", "root.x", "prop", "a"),
("root", "root.y", "prop", "a"),
],
);
let opts = SearchOptions {
max_results: 1,
..SearchOptions::default()
};
assert_eq!(
find_morphisms(&src, &tgt, &opts).unwrap().morphisms.len(),
1
);
assert!(
find_morphisms(&src, &tgt, &SearchOptions::default())
.unwrap()
.morphisms
.len()
>= 2
);
}
#[test]
fn find_best_agrees_with_the_head_of_find_morphisms() {
let src = build_schema(
&[("root", "object"), ("root.name", "string")],
&[("root", "root.name", "prop", "name")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.name", "string"),
("root.other", "string"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.other", "prop", "other"),
],
);
let best = find_best_morphism(&src, &tgt, &SearchOptions::default())
.expect("the network poses")
.expect("a total morphism exists");
let all = find_morphisms(&src, &tgt, &SearchOptions::default())
.unwrap()
.morphisms;
assert_eq!(best.quality, all[0].quality);
assert_eq!(
best.vertex_map.get("root.name").map(Name::as_str),
Some("root.name")
);
}
#[test]
fn morphism_to_migration_conversion() {
let schema = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let results = find_morphisms(&schema, &schema, &SearchOptions::default())
.unwrap()
.morphisms;
assert!(!results.is_empty());
let mig = morphism_to_migration(&results[0]);
assert_eq!(mig.vertex_map.len(), 2);
assert_eq!(mig.edge_map.len(), 1);
}
#[test]
fn empty_schema_morphism() {
let empty = crate::span::empty_schema("test");
let results = find_morphisms(&empty, &empty, &SearchOptions::default())
.unwrap()
.morphisms;
assert_eq!(
results.len(),
1,
"the empty schema has exactly one self-morphism"
);
assert!(results[0].vertex_map.is_empty());
}
#[test]
fn an_excluded_source_leaves_no_total_morphism() {
let schema = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let constraints = DomainConstraints {
excluded_sources: HashSet::from([Name::from("root.x")]),
..DomainConstraints::default()
};
assert!(
find_morphisms_constrained(&schema, &schema, &SearchOptions::default(), &constraints)
.unwrap()
.morphisms
.is_empty(),
"a total morphism cannot omit part of its domain"
);
}
#[test]
fn a_restricted_domain_is_honoured() {
let src = build_schema(
&[("root", "object"), ("root.a", "string")],
&[("root", "root.a", "prop", "a")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.a", "string"),
("root.z", "string"),
],
&[
("root", "root.a", "prop", "a"),
("root", "root.z", "prop", "z"),
],
);
let constraints = DomainConstraints {
restricted_domains: HashMap::from([(Name::from("root.a"), vec![Name::from("root.z")])]),
..DomainConstraints::default()
};
let results =
find_morphisms_constrained(&src, &tgt, &SearchOptions::default(), &constraints)
.unwrap()
.morphisms;
assert!(!results.is_empty());
for found in &results {
assert_eq!(
found.vertex_map.get("root.a").map(Name::as_str),
Some("root.z"),
"the restriction rules out the name-matching target"
);
}
}
#[test]
fn epic_needs_every_target_covered() {
let src = build_schema(
&[("root", "object"), ("root.a", "string")],
&[("root", "root.a", "prop", "a")],
);
let tgt = build_schema(
&[
("root", "object"),
("root.a", "string"),
("root.b", "string"),
],
&[
("root", "root.a", "prop", "a"),
("root", "root.b", "prop", "b"),
],
);
let opts = SearchOptions {
epic: true,
..SearchOptions::default()
};
assert!(
find_morphisms(&src, &tgt, &opts)
.unwrap()
.morphisms
.is_empty(),
"two source vertices cannot cover three targets"
);
assert!(
!find_morphisms(&src, &src, &opts)
.unwrap()
.morphisms
.is_empty(),
"the identity is onto"
);
}
#[test]
fn forbidding_bottom_leaves_the_network_otherwise_alone() {
let schema = build_schema(
&[("root", "object"), ("root.x", "string")],
&[("root", "root.x", "prop", "x")],
);
let cfn = build_cfn(
&schema,
&schema,
&SearchOptions::default(),
&DomainConstraints::default(),
&NoEvidence,
DEFAULT_WEIGHTS,
DEFAULT_MEM_BYTES,
)
.unwrap();
let total = without_bottom(&cfn, DEFAULT_MEM_BYTES);
assert_eq!(total.n_variables(), cfn.n_variables());
assert_eq!(total.n_functions(), cfn.n_functions());
assert_eq!(total.radix(), cfn.radix());
assert_eq!(total.weights(), cfn.weights());
assert_eq!(total.c_empty(), cfn.c_empty());
for var in cfn.variable_ids() {
let before = cfn.unary(var).unwrap();
let after = total.unary(var).unwrap();
assert_eq!(before.len(), after.len());
let (bottom, real) = after.split_last().unwrap();
assert_eq!(*bottom, Cost::TOP_SENTINEL, "`⊥` is forbidden");
assert_eq!(
real,
&before[..real.len()],
"every other entry is untouched"
);
}
}
#[test]
fn the_optima_of_a_decomposing_network_follow_the_dispatch_order() {
use crate::solve::{VarId, decode, primal_graph};
let names = ["a0", "a1", "a2", "a3", "a9hub", "b0", "b1"];
let spec: Vec<(Name, Vec<Name>)> = names
.iter()
.map(|name| (Name::from(*name), vec![Name::from("t0"), Name::from("t1")]))
.collect();
let mut b = CfnBuilder::new(spec, DEFAULT_WEIGHTS).unwrap();
for leaf in 0..4u32 {
let scope = [VarId::new(leaf), VarId::new(4)];
let len = b.table_length(&scope).unwrap();
b.add_function(&scope, vec![Cost::BOT; len]).unwrap();
}
let pen = Cost::from_raw(100);
b.add_unary_table(VarId::new(5), &[Cost::BOT, Cost::BOT, pen])
.unwrap();
b.add_unary_table(VarId::new(6), &[Cost::BOT, Cost::BOT, pen])
.unwrap();
let mut table = vec![Cost::BOT; 9];
table[0] = Cost::TOP_SENTINEL; table[4] = Cost::TOP_SENTINEL; b.add_function(&[VarId::new(5), VarId::new(6)], table)
.unwrap();
let cfn = b.build();
assert_eq!(
primal_graph(&cfn).components().len(),
2,
"the fixture must decompose, or there is nothing to disagree about"
);
let budget = SearchBudget::default();
let plan = dispatch_plan(&cfn, &budget);
assert!(plan.exact, "both components price inside the budget");
let whole_order = crate::solve::choose_order(&cfn).0;
let whole = decode(&cfn, &eliminate(&cfn, &whole_order), &whole_order);
let dispatched = solve(&cfn, &budget).best.unwrap();
assert_eq!(
cfn.evaluate(&whole),
cfn.evaluate(&dispatched),
"both are optima"
);
assert_ne!(
whole, dispatched,
"the fixture must make the two orders disagree, or the assertion below holds for the wrong reason"
);
let (found, _) = optimal_assignments(&cfn, &budget, 1).unwrap();
assert_eq!(
found.first(),
Some(&dispatched),
"the canonical answer is the one `solve` names, not the one a whole-network order names"
);
}
}