From dce2ef2ceb091945fd7f342ee805940fad4f7784 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 06:49:35 +0000 Subject: [PATCH 01/12] Add plan for #300: QuadraticAssignment model Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-13-quadratic-assignment.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/plans/2026-03-13-quadratic-assignment.md diff --git a/docs/plans/2026-03-13-quadratic-assignment.md b/docs/plans/2026-03-13-quadratic-assignment.md new file mode 100644 index 000000000..3c00d9ec8 --- /dev/null +++ b/docs/plans/2026-03-13-quadratic-assignment.md @@ -0,0 +1,120 @@ +# Plan: Add QuadraticAssignment Model (#300) + +## Summary + +Add the Quadratic Assignment Problem (QAP) to the codebase as an optimization model in `src/models/algebraic/`. QAP is a classical NP-hard problem (Sahni & Gonzalez, 1976) for facility-location assignment: given cost matrix C (flows between facilities) and distance matrix D (distances between locations), find an injection f minimizing Σ_{i,j} c_{ij} · d_{f(i),f(j)}. + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `QuadraticAssignment` | +| 2 | Mathematical definition | Given n×n cost matrix C and m×m distance matrix D, find injection f:{1..n}→{1..m} minimizing Σ c_{ij}·d_{f(i),f(j)} | +| 3 | Problem type | Optimization (Minimize) | +| 4 | Type parameters | None (no graph type; uses i64 matrices directly) | +| 5 | Struct fields | `cost_matrix: Vec>`, `distance_matrix: Vec>` | +| 6 | Configuration space | `vec![m; n]` — each facility i chooses a location in {0..m-1} | +| 7 | Feasibility check | Assignment must be injective (no two facilities share a location) | +| 8 | Objective function | Σ_{i≠j} cost_matrix[i][j] · distance_matrix[config[i]][config[j]] | +| 9 | Best known exact | Brute force: O(n! · n²). Branch-and-bound with Gilmore-Lawler bounds (Anstreicher 2003). Complexity string: `"num_facilities ^ num_facilities"` (n^n ≥ n!) | +| 10 | Solving strategy | BruteForce enumeration; ILP linearization possible but not required for this PR | +| 11 | Category | `algebraic` (two matrices as input) | + +## Steps + +### Step 1: Create model file `src/models/algebraic/quadratic_assignment.rs` + +**File:** `src/models/algebraic/quadratic_assignment.rs` + +Implement: +1. `inventory::submit!` with `ProblemSchemaEntry` — fields: `cost_matrix` (Vec>), `distance_matrix` (Vec>) +2. Struct `QuadraticAssignment` with fields `cost_matrix: Vec>`, `distance_matrix: Vec>` +3. Constructor `new(cost_matrix, distance_matrix)` — validate: cost_matrix is n×n, distance_matrix is m×m, n ≤ m +4. Getters: `cost_matrix()`, `distance_matrix()`, `num_facilities()` (= n), `num_locations()` (= m) +5. `Problem` trait impl: + - `NAME = "QuadraticAssignment"` + - `Metric = SolutionSize` + - `dims()` → `vec![m; n]` (each of n facilities chooses from m locations) + - `evaluate()`: check injectivity first (return `Invalid` if duplicates), then compute Σ_{i≠j} C[i][j]·D[f(i)][f(j)] + - `variant()` → `crate::variant_params![]` (no type parameters) +6. `OptimizationProblem` impl: `direction()` → `Direction::Minimize`, `Value = i64` +7. `declare_variants!`: `QuadraticAssignment => "num_facilities ^ num_facilities"` +8. Test link: `#[cfg(test)] #[path = "../../unit_tests/models/algebraic/quadratic_assignment.rs"] mod tests;` + +### Step 2: Register the model + +1. **`src/models/algebraic/mod.rs`**: Add `mod quadratic_assignment;` and `pub use quadratic_assignment::QuadraticAssignment;` +2. **`src/models/mod.rs`**: Add `QuadraticAssignment` to the `algebraic` re-export line +3. **`src/lib.rs`**: If QUBO is in prelude, add QuadraticAssignment there too (check first) + +### Step 3: Register in CLI + +1. **`problemreductions-cli/src/dispatch.rs`**: + - Add `use problemreductions::models::algebraic::QuadraticAssignment;` import + - Add match arm in `load_problem()`: `"QuadraticAssignment" => deser_opt::(data)` + - Add match arm in `serialize_any_problem()`: `"QuadraticAssignment" => try_ser::(any)` + +2. **`problemreductions-cli/src/problem_name.rs`**: + - Add `"quadraticassignment" | "qap" => "QuadraticAssignment".to_string()` in `resolve_alias()` + - Add `("QAP", "QuadraticAssignment")` to `ALIASES` array (QAP is a well-established abbreviation) + +3. **`problemreductions-cli/src/commands/create.rs`**: + - Add match arm for `"QuadraticAssignment"` that parses `--matrix` for cost_matrix and a second `--distance-matrix` flag for distance_matrix (both semicolon-separated row format) + - Add CLI flags if needed (`--distance-matrix`) + - Update help text in `after_help` + +### Step 4: Write unit tests + +**File:** `src/unit_tests/models/algebraic/quadratic_assignment.rs` + +Also update `src/unit_tests/models/algebraic/mod.rs` if it exists to include the new test module. + +Tests: +- `test_quadratic_assignment_creation` — construct n=4 instance, verify num_facilities=4, num_locations=4, dims +- `test_quadratic_assignment_evaluate_valid` — test identity assignment f=(0,1,2,3) on the example instance, verify cost=38 +- `test_quadratic_assignment_evaluate_swap` — test f=(0,2,1,3) on example, verify different cost +- `test_quadratic_assignment_evaluate_invalid` — test duplicate assignment like [0,0,1,2], verify SolutionSize::Invalid +- `test_quadratic_assignment_direction` — verify Direction::Minimize +- `test_quadratic_assignment_serialization` — round-trip serde JSON test +- `test_quadratic_assignment_solver` — BruteForce::find_best on the n=4 example, verify optimal cost=38 + +### Step 5: Write paper entry + +Invoke `/write-model-in-paper` to add `QuadraticAssignment` problem-def entry in `docs/paper/reductions.typ`: +- Add to `display-name` dict: `"QuadraticAssignment": [Quadratic Assignment]` +- Formal definition, background (Koopmans-Beckmann 1957, Sahni-Gonzalez 1976) +- Example: the n=4 instance from the issue with CeTZ visualization (facility-location bipartite diagram) + +### Step 6: Regenerate exports + +```bash +make export-schemas # Regenerate problem schemas JSON +make fmt # Format code +``` + +### Step 7: Verify + +```bash +make check # fmt + clippy + test +``` + +## Example Instance (for tests) + +Cost matrix C (4×4): +``` +[[0, 5, 2, 0], + [5, 0, 0, 3], + [2, 0, 0, 4], + [0, 3, 4, 0]] +``` + +Distance matrix D (4×4): +``` +[[0, 1, 2, 3], + [1, 0, 1, 2], + [2, 1, 0, 1], + [3, 2, 1, 0]] +``` + +Identity assignment f=(0,1,2,3): cost = 38 +Optimal: cost = 38 (multiple permutations achieve this due to matrix symmetry) From ae270f81b83808b8423b2258c02b3d854d130f73 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 07:02:10 +0000 Subject: [PATCH 02/12] Implement #300: Add QuadraticAssignment model - Add QuadraticAssignment model in src/models/algebraic/ - Register in CLI (dispatch, aliases, create command) - Add unit tests (creation, evaluation, solver, serialization) - Add paper entry with CeTZ example diagram - Regenerate problem schemas Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 51 ++++++ docs/src/reductions/problem_schemas.json | 16 ++ problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 46 +++++ problemreductions-cli/src/dispatch.rs | 4 +- problemreductions-cli/src/problem_name.rs | 2 + src/lib.rs | 2 +- src/models/algebraic/mod.rs | 3 + src/models/algebraic/quadratic_assignment.rs | 172 ++++++++++++++++++ src/models/mod.rs | 2 +- .../models/algebraic/quadratic_assignment.rs | 116 ++++++++++++ 11 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 src/models/algebraic/quadratic_assignment.rs create mode 100644 src/unit_tests/models/algebraic/quadratic_assignment.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 565abdb14..af0594e2a 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -55,6 +55,7 @@ "ClosestVectorProblem": [Closest Vector Problem], "SubsetSum": [Subset Sum], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "QuadraticAssignment": [Quadratic Assignment], ) // Definition label: "def:" — each definition block must have a matching label @@ -724,6 +725,56 @@ Integer Linear Programming is a universal modeling framework: virtually every NP ) ] +#problem-def("QuadraticAssignment")[ + Given $n$ facilities and $m$ locations ($n <= m$), a flow matrix $C in ZZ^(n times n)$ representing flows between facilities, and a distance matrix $D in ZZ^(m times m)$ representing distances between locations, find an injective assignment $f: {1, dots, n} -> {1, dots, m}$ that minimizes + $ sum_(i != j) C_(i j) dot D_(f(i), f(j)). $ +][ +The Quadratic Assignment Problem was introduced by Koopmans and Beckmann (1957) to model the optimal placement of economic activities (facilities) across geographic locations, minimizing total transportation cost weighted by inter-facility flows. It is NP-hard, as shown by Sahni and Gonzalez (1976) via reduction from the Hamiltonian Circuit problem. QAP is widely regarded as one of the hardest combinatorial optimization problems: even moderate instances ($n > 20$) challenge state-of-the-art exact solvers. Best exact approaches use branch-and-bound with Gilmore--Lawler bounds or Dreyfus--Wagner-like dynamic programming; the best known general algorithm runs in $O^*(n!)$ by exhaustive enumeration of all permutations#footnote[No algorithm significantly improving on brute-force permutation enumeration is known for general QAP.]. + +Applications include facility layout planning, keyboard and control panel design, scheduling, VLSI placement, and hospital floor planning. As a special case, when $D$ is a distance matrix on a line (i.e., $D_(k l) = |k - l|$), QAP reduces to the Optimal Linear Arrangement problem. + +*Example.* Consider $n = m = 4$ with flow matrix $C$ and distance matrix $D$: +$ C = mat(0, 3, 0, 2; 3, 0, 0, 1; 0, 0, 0, 4; 2, 1, 4, 0), quad D = mat(0, 1, 2, 3; 1, 0, 1, 2; 2, 1, 0, 1; 3, 2, 1, 0). $ +The identity assignment $f(i) = i$ gives cost $sum_(i != j) C_(i j) dot D_(i, j) = 3 dot 1 + 2 dot 3 + 3 dot 1 + 1 dot 2 + 4 dot 1 + 2 dot 3 + 1 dot 2 + 4 dot 1 = 3 + 6 + 3 + 2 + 4 + 6 + 2 + 4 = 30$. However, the assignment $f = (1, 2, 4, 3)$ — swapping facilities 3 and 4 — gives cost $3 dot 1 + 2 dot 2 + 3 dot 1 + 1 dot 1 + 4 dot 1 + 2 dot 2 + 1 dot 1 + 4 dot 1 = 3 + 4 + 3 + 1 + 4 + 4 + 1 + 4 = 24$. The optimal assignment is $f^* = (3, 4, 1, 2)$ with cost 22: it places the heavily interacting facilities 3 and 4 (flow 4) at adjacent locations. + +#figure( + canvas(length: 1cm, { + import draw: * + // Facility column (left) + let fac-x = 0 + let loc-x = 5 + let ys = (3, 2, 1, 0) + // Draw facility nodes + for i in range(4) { + circle((fac-x, ys.at(i)), radius: 0.3, fill: graph-colors.at(0), stroke: 0.8pt + graph-colors.at(0), name: "f" + str(i)) + content("f" + str(i), text(fill: white, 8pt)[$F_#(i+1)$]) + } + // Draw location nodes + for j in range(4) { + circle((loc-x, ys.at(j)), radius: 0.3, fill: graph-colors.at(1), stroke: 0.8pt + graph-colors.at(1), name: "l" + str(j)) + content("l" + str(j), text(fill: white, 8pt)[$L_#(j+1)$]) + } + // Column labels + content((fac-x, 3.7), text(9pt, weight: "bold")[Facilities]) + content((loc-x, 3.7), text(9pt, weight: "bold")[Locations]) + // Optimal assignment f* = (3, 4, 1, 2): F1→L3, F2→L4, F3→L1, F4→L2 + let assignments = ((0, 2), (1, 3), (2, 0), (3, 1)) + for (fi, li) in assignments { + line("f" + str(fi) + ".east", "l" + str(li) + ".west", + mark: (end: "straight"), stroke: 1.2pt + luma(80)) + } + // Annotate key flow: F3↔F4 have flow 4 + on-layer(-1, { + rect((-0.55, -0.55), (0.55, 1.55), + fill: graph-colors.at(0).transparentize(92%), + stroke: (dash: "dashed", paint: graph-colors.at(0).transparentize(50%), thickness: 0.6pt)) + }) + content((fac-x, -0.9), text(6pt, fill: luma(100))[flow$(F_3, F_4) = 4$]) + }), + caption: [Optimal assignment $f^* = (3, 4, 1, 2)$ for the $4 times 4$ QAP instance. Facilities (blue, left) are assigned to locations (red, right) by arrows. Facilities $F_3$ and $F_4$ (highest flow $= 4$) are assigned to adjacent locations $L_1$ and $L_2$ (distance $= 1$). Total cost $= 22$.], +) +] + #problem-def("ClosestVectorProblem")[ Given a lattice basis $bold(B) in RR^(m times n)$ (columns $bold(b)_1, dots, bold(b)_n in RR^m$ spanning lattice $cal(L)(bold(B)) = {bold(B) bold(x) : bold(x) in ZZ^n}$) and target $bold(t) in RR^m$, find $bold(x) in ZZ^n$ minimizing $norm(bold(B) bold(x) - bold(t))_2$. ][ diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index ef694f9de..28fb8eecf 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -387,6 +387,22 @@ } ] }, + { + "name": "QuadraticAssignment", + "description": "Minimize total cost of assigning facilities to locations", + "fields": [ + { + "name": "cost_matrix", + "type_name": "Vec>", + "description": "Flow/cost matrix between facilities" + }, + { + "name": "distance_matrix", + "type_name": "Vec>", + "description": "Distance matrix between locations" + } + ] + }, { "name": "Satisfiability", "description": "Find satisfying assignment for CNF formula", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91792e20b..e676db79a 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -218,6 +218,7 @@ Flags by problem type: BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] FVS --arcs [--weights] [--num-vertices] + QAP --matrix (cost), --distance-matrix ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -332,6 +333,9 @@ pub struct CreateArgs { /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, + /// Distance matrix for QuadraticAssignment (semicolon-separated rows, e.g., "0,1,2;1,0,1;2,1,0") + #[arg(long)] + pub distance_matrix: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2df4f0994..63ea904cc 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -48,6 +48,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.target_vec.is_none() && args.bounds.is_none() && args.arcs.is_none() + && args.distance_matrix.is_none() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -84,6 +85,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", + "QuadraticAssignment" => "--matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"", "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "Factoring" => "--target 15 --m 4 --n 4", @@ -264,6 +266,33 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { util::ser_ksat(num_vars, clauses, k)? } + // QuadraticAssignment + "QuadraticAssignment" => { + let cost_str = args.matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "QuadraticAssignment requires --matrix (cost) and --distance-matrix\n\n\ + Usage: pred create QAP --matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"" + ) + })?; + let dist_str = args.distance_matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "QuadraticAssignment requires --distance-matrix\n\n\ + Usage: pred create QAP --matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"" + ) + })?; + let cost_matrix = parse_i64_matrix(cost_str).context("Invalid cost matrix")?; + let distance_matrix = parse_i64_matrix(dist_str).context("Invalid distance matrix")?; + ( + ser( + problemreductions::models::algebraic::QuadraticAssignment::new( + cost_matrix, + distance_matrix, + ), + )?, + resolved_variant.clone(), + ) + } + // QUBO "QUBO" => { let matrix = parse_matrix(args)?; @@ -890,6 +919,23 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { .collect() } +/// Parse a semicolon-separated matrix of i64 values. +/// E.g., "0,5;5,0" +fn parse_i64_matrix(s: &str) -> Result>> { + s.split(';') + .map(|row| { + row.trim() + .split(',') + .map(|v| { + v.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid matrix value: {}", e)) + }) + .collect() + }) + .collect() +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 49dd523cb..20653bd0c 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -1,5 +1,5 @@ use anyhow::{bail, Context, Result}; -use problemreductions::models::algebraic::{ClosestVectorProblem, ILP}; +use problemreductions::models::algebraic::{ClosestVectorProblem, QuadraticAssignment, ILP}; use problemreductions::models::misc::{BinPacking, Knapsack, SubsetSum}; use problemreductions::prelude::*; use problemreductions::rules::{MinimizeSteps, ReductionGraph}; @@ -220,6 +220,7 @@ pub fn load_problem( }, "MaximumSetPacking" => deser_opt::>(data), "MinimumSetCovering" => deser_opt::>(data), + "QuadraticAssignment" => deser_opt::(data), "QUBO" => deser_opt::>(data), "SpinGlass" => match variant.get("weight").map(|s| s.as_str()) { Some("f64") => deser_opt::>(data), @@ -283,6 +284,7 @@ pub fn serialize_any_problem( _ => try_ser::>(any), }, "MinimumSetCovering" => try_ser::>(any), + "QuadraticAssignment" => try_ser::(any), "QUBO" => try_ser::>(any), "SpinGlass" => match variant.get("weight").map(|s| s.as_str()) { Some("f64") => try_ser::>(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 2b6c8c737..a62987459 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -22,6 +22,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("CVP", "ClosestVectorProblem"), ("MaxMatching", "MaximumMatching"), ("FVS", "MinimumFeedbackVertexSet"), + ("QAP", "QuadraticAssignment"), ]; /// Resolve a short alias to the canonical problem name. @@ -56,6 +57,7 @@ pub fn resolve_alias(input: &str) -> String { "knapsack" => "Knapsack".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), "subsetsum" => "SubsetSum".to_string(), + "quadraticassignment" | "qap" => "QuadraticAssignment".to_string(), _ => input.to_string(), // pass-through for exact names } } diff --git a/src/lib.rs b/src/lib.rs index a74c906f3..efe0ebc76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ pub mod variant; /// Prelude module for convenient imports. pub mod prelude { // Problem types - pub use crate::models::algebraic::{BMF, QUBO}; + pub use crate::models::algebraic::{QuadraticAssignment, BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index 6cfc0069d..48c3dede2 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -5,13 +5,16 @@ //! - [`ILP`]: Integer Linear Programming //! - [`ClosestVectorProblem`]: Closest Vector Problem (minimize lattice distance) //! - [`BMF`]: Boolean Matrix Factorization +//! - [`QuadraticAssignment`]: Quadratic Assignment Problem pub(crate) mod bmf; mod closest_vector_problem; mod ilp; +mod quadratic_assignment; mod qubo; pub use bmf::BMF; pub use closest_vector_problem::{ClosestVectorProblem, VarBounds}; pub use ilp::{Comparison, LinearConstraint, ObjectiveSense, VariableDomain, ILP}; +pub use quadratic_assignment::QuadraticAssignment; pub use qubo::QUBO; diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs new file mode 100644 index 000000000..8e70ddd27 --- /dev/null +++ b/src/models/algebraic/quadratic_assignment.rs @@ -0,0 +1,172 @@ +//! Quadratic Assignment Problem (QAP) implementation. +//! +//! The QAP assigns facilities to locations to minimize the total cost, +//! where cost depends on both inter-facility flows and inter-location distances. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "QuadraticAssignment", + module_path: module_path!(), + description: "Minimize total cost of assigning facilities to locations", + fields: &[ + FieldInfo { name: "cost_matrix", type_name: "Vec>", description: "Flow/cost matrix between facilities" }, + FieldInfo { name: "distance_matrix", type_name: "Vec>", description: "Distance matrix between locations" }, + ], + } +} + +/// The Quadratic Assignment Problem (QAP). +/// +/// Given n facilities and m locations, a cost matrix C (n x n) representing +/// flows between facilities, and a distance matrix D (m x m) representing +/// distances between locations, find an injective assignment of facilities +/// to locations that minimizes: +/// +/// f(p) = sum_{i != j} C[i][j] * D[p(i)][p(j)] +/// +/// where p is a permutation mapping facilities to locations. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::algebraic::QuadraticAssignment; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// let cost_matrix = vec![ +/// vec![0, 1, 2], +/// vec![1, 0, 3], +/// vec![2, 3, 0], +/// ]; +/// let distance_matrix = vec![ +/// vec![0, 5, 8], +/// vec![5, 0, 3], +/// vec![8, 3, 0], +/// ]; +/// let problem = QuadraticAssignment::new(cost_matrix, distance_matrix); +/// +/// let solver = BruteForce::new(); +/// let best = solver.find_best(&problem); +/// assert!(best.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuadraticAssignment { + /// Cost/flow matrix between facilities (n x n). + cost_matrix: Vec>, + /// Distance matrix between locations (m x m). + distance_matrix: Vec>, +} + +impl QuadraticAssignment { + /// Create a new Quadratic Assignment Problem. + /// + /// # Arguments + /// * `cost_matrix` - n x n matrix of flows/costs between facilities + /// * `distance_matrix` - m x m matrix of distances between locations + /// + /// # Panics + /// Panics if either matrix is not square, or if num_facilities > num_locations. + pub fn new(cost_matrix: Vec>, distance_matrix: Vec>) -> Self { + let n = cost_matrix.len(); + for row in &cost_matrix { + assert_eq!(row.len(), n, "cost_matrix must be square"); + } + let m = distance_matrix.len(); + for row in &distance_matrix { + assert_eq!(row.len(), m, "distance_matrix must be square"); + } + assert!( + n <= m, + "num_facilities ({n}) must be <= num_locations ({m})" + ); + Self { + cost_matrix, + distance_matrix, + } + } + + /// Get the cost/flow matrix. + pub fn cost_matrix(&self) -> &[Vec] { + &self.cost_matrix + } + + /// Get the distance matrix. + pub fn distance_matrix(&self) -> &[Vec] { + &self.distance_matrix + } + + /// Get the number of facilities. + pub fn num_facilities(&self) -> usize { + self.cost_matrix.len() + } + + /// Get the number of locations. + pub fn num_locations(&self) -> usize { + self.distance_matrix.len() + } +} + +impl Problem for QuadraticAssignment { + const NAME: &'static str = "QuadraticAssignment"; + type Metric = SolutionSize; + + fn dims(&self) -> Vec { + vec![self.num_locations(); self.num_facilities()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + let n = self.num_facilities(); + let m = self.num_locations(); + + // Check that all assignments are valid locations + for &loc in config.iter().take(n) { + if loc >= m { + return SolutionSize::Invalid; + } + } + + // Check injectivity: no two facilities assigned to the same location + let mut used = std::collections::HashSet::new(); + for &loc in config.iter().take(n) { + if !used.insert(loc) { + return SolutionSize::Invalid; + } + } + + // Compute objective: sum_{i != j} cost_matrix[i][j] * distance_matrix[config[i]][config[j]] + let mut total: i64 = 0; + for i in 0..n { + for j in 0..n { + if i != j { + total += self.cost_matrix[i][j] * self.distance_matrix[config[i]][config[j]]; + } + } + } + + SolutionSize::Valid(total) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl OptimizationProblem for QuadraticAssignment { + type Value = i64; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + QuadraticAssignment => "num_facilities ^ num_facilities", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/quadratic_assignment.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index ceb584ce2..706534971 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,7 +9,7 @@ pub mod misc; pub mod set; // Re-export commonly used types -pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; +pub use algebraic::{ClosestVectorProblem, QuadraticAssignment, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, diff --git a/src/unit_tests/models/algebraic/quadratic_assignment.rs b/src/unit_tests/models/algebraic/quadratic_assignment.rs new file mode 100644 index 000000000..8da7066be --- /dev/null +++ b/src/unit_tests/models/algebraic/quadratic_assignment.rs @@ -0,0 +1,116 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; + +/// Create a 4x4 test instance for reuse across tests. +/// +/// Cost matrix C: +/// [[0, 5, 2, 0], +/// [5, 0, 0, 3], +/// [2, 0, 0, 4], +/// [0, 3, 4, 0]] +/// +/// Distance matrix D: +/// [[0, 1, 2, 3], +/// [1, 0, 1, 2], +/// [2, 1, 0, 1], +/// [3, 2, 1, 0]] +fn make_test_instance() -> QuadraticAssignment { + let cost_matrix = vec![ + vec![0, 5, 2, 0], + vec![5, 0, 0, 3], + vec![2, 0, 0, 4], + vec![0, 3, 4, 0], + ]; + let distance_matrix = vec![ + vec![0, 1, 2, 3], + vec![1, 0, 1, 2], + vec![2, 1, 0, 1], + vec![3, 2, 1, 0], + ]; + QuadraticAssignment::new(cost_matrix, distance_matrix) +} + +#[test] +fn test_quadratic_assignment_creation() { + let qap = make_test_instance(); + assert_eq!(qap.num_facilities(), 4); + assert_eq!(qap.num_locations(), 4); + assert_eq!(qap.dims(), vec![4, 4, 4, 4]); + assert_eq!(qap.cost_matrix().len(), 4); + assert_eq!(qap.distance_matrix().len(), 4); +} + +#[test] +fn test_quadratic_assignment_evaluate_identity() { + let qap = make_test_instance(); + // Identity assignment f = (0, 1, 2, 3): + // cost = sum_{i != j} C[i][j] * D[i][j] + // = 5*1 + 2*2 + 0*3 + 5*1 + 0*1 + 3*2 + 2*2 + 0*1 + 4*1 + 0*3 + 3*2 + 4*1 + // = 5 + 4 + 0 + 5 + 0 + 6 + 4 + 0 + 4 + 0 + 6 + 4 = 38 + assert_eq!( + Problem::evaluate(&qap, &[0, 1, 2, 3]), + SolutionSize::Valid(38) + ); +} + +#[test] +fn test_quadratic_assignment_evaluate_swap() { + let qap = make_test_instance(); + // Assignment f = (0, 2, 1, 3): facility 1 -> loc 2, facility 2 -> loc 1 + // cost = sum_{i != j} C[i][j] * D[config[i]][config[j]] + // i=0,j=1: 5*D[0][2]=5*2=10 i=0,j=2: 2*D[0][1]=2*1=2 i=0,j=3: 0*D[0][3]=0 + // i=1,j=0: 5*D[2][0]=5*2=10 i=1,j=2: 0*D[2][1]=0*1=0 i=1,j=3: 3*D[2][3]=3*1=3 + // i=2,j=0: 2*D[1][0]=2*1=2 i=2,j=1: 0*D[1][2]=0*1=0 i=2,j=3: 4*D[1][3]=4*2=8 + // i=3,j=0: 0*D[3][0]=0 i=3,j=1: 3*D[3][2]=3*1=3 i=3,j=2: 4*D[3][1]=4*2=8 + // Total = 10+2+0+10+0+3+2+0+8+0+3+8 = 46 + assert_eq!( + Problem::evaluate(&qap, &[0, 2, 1, 3]), + SolutionSize::Valid(46) + ); +} + +#[test] +fn test_quadratic_assignment_evaluate_invalid() { + let qap = make_test_instance(); + // Duplicate location 0 — not injective, should be Invalid. + assert_eq!( + Problem::evaluate(&qap, &[0, 0, 1, 2]), + SolutionSize::Invalid + ); +} + +#[test] +fn test_quadratic_assignment_direction() { + let qap = make_test_instance(); + assert_eq!(qap.direction(), Direction::Minimize); +} + +#[test] +fn test_quadratic_assignment_serialization() { + let qap = make_test_instance(); + let json = serde_json::to_string(&qap).expect("serialize"); + let qap2: QuadraticAssignment = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(qap2.num_facilities(), 4); + assert_eq!(qap2.num_locations(), 4); + // Verify functional equivalence after round-trip. + assert_eq!( + Problem::evaluate(&qap, &[0, 1, 2, 3]), + Problem::evaluate(&qap2, &[0, 1, 2, 3]) + ); +} + +#[test] +fn test_quadratic_assignment_solver() { + let qap = make_test_instance(); + let solver = BruteForce::new(); + let best = solver.find_best(&qap); + assert!(best.is_some()); + let best_config = best.unwrap(); + // The brute-force solver finds the optimal assignment with cost 36. + assert_eq!( + Problem::evaluate(&qap, &best_config), + SolutionSize::Valid(36) + ); +} From 79db3e3c981d145e42503ac89f24700856858d7e Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 07:03:17 +0000 Subject: [PATCH 03/12] chore: remove plan file after implementation Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-13-quadratic-assignment.md | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 docs/plans/2026-03-13-quadratic-assignment.md diff --git a/docs/plans/2026-03-13-quadratic-assignment.md b/docs/plans/2026-03-13-quadratic-assignment.md deleted file mode 100644 index 3c00d9ec8..000000000 --- a/docs/plans/2026-03-13-quadratic-assignment.md +++ /dev/null @@ -1,120 +0,0 @@ -# Plan: Add QuadraticAssignment Model (#300) - -## Summary - -Add the Quadratic Assignment Problem (QAP) to the codebase as an optimization model in `src/models/algebraic/`. QAP is a classical NP-hard problem (Sahni & Gonzalez, 1976) for facility-location assignment: given cost matrix C (flows between facilities) and distance matrix D (distances between locations), find an injection f minimizing Σ_{i,j} c_{ij} · d_{f(i),f(j)}. - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `QuadraticAssignment` | -| 2 | Mathematical definition | Given n×n cost matrix C and m×m distance matrix D, find injection f:{1..n}→{1..m} minimizing Σ c_{ij}·d_{f(i),f(j)} | -| 3 | Problem type | Optimization (Minimize) | -| 4 | Type parameters | None (no graph type; uses i64 matrices directly) | -| 5 | Struct fields | `cost_matrix: Vec>`, `distance_matrix: Vec>` | -| 6 | Configuration space | `vec![m; n]` — each facility i chooses a location in {0..m-1} | -| 7 | Feasibility check | Assignment must be injective (no two facilities share a location) | -| 8 | Objective function | Σ_{i≠j} cost_matrix[i][j] · distance_matrix[config[i]][config[j]] | -| 9 | Best known exact | Brute force: O(n! · n²). Branch-and-bound with Gilmore-Lawler bounds (Anstreicher 2003). Complexity string: `"num_facilities ^ num_facilities"` (n^n ≥ n!) | -| 10 | Solving strategy | BruteForce enumeration; ILP linearization possible but not required for this PR | -| 11 | Category | `algebraic` (two matrices as input) | - -## Steps - -### Step 1: Create model file `src/models/algebraic/quadratic_assignment.rs` - -**File:** `src/models/algebraic/quadratic_assignment.rs` - -Implement: -1. `inventory::submit!` with `ProblemSchemaEntry` — fields: `cost_matrix` (Vec>), `distance_matrix` (Vec>) -2. Struct `QuadraticAssignment` with fields `cost_matrix: Vec>`, `distance_matrix: Vec>` -3. Constructor `new(cost_matrix, distance_matrix)` — validate: cost_matrix is n×n, distance_matrix is m×m, n ≤ m -4. Getters: `cost_matrix()`, `distance_matrix()`, `num_facilities()` (= n), `num_locations()` (= m) -5. `Problem` trait impl: - - `NAME = "QuadraticAssignment"` - - `Metric = SolutionSize` - - `dims()` → `vec![m; n]` (each of n facilities chooses from m locations) - - `evaluate()`: check injectivity first (return `Invalid` if duplicates), then compute Σ_{i≠j} C[i][j]·D[f(i)][f(j)] - - `variant()` → `crate::variant_params![]` (no type parameters) -6. `OptimizationProblem` impl: `direction()` → `Direction::Minimize`, `Value = i64` -7. `declare_variants!`: `QuadraticAssignment => "num_facilities ^ num_facilities"` -8. Test link: `#[cfg(test)] #[path = "../../unit_tests/models/algebraic/quadratic_assignment.rs"] mod tests;` - -### Step 2: Register the model - -1. **`src/models/algebraic/mod.rs`**: Add `mod quadratic_assignment;` and `pub use quadratic_assignment::QuadraticAssignment;` -2. **`src/models/mod.rs`**: Add `QuadraticAssignment` to the `algebraic` re-export line -3. **`src/lib.rs`**: If QUBO is in prelude, add QuadraticAssignment there too (check first) - -### Step 3: Register in CLI - -1. **`problemreductions-cli/src/dispatch.rs`**: - - Add `use problemreductions::models::algebraic::QuadraticAssignment;` import - - Add match arm in `load_problem()`: `"QuadraticAssignment" => deser_opt::(data)` - - Add match arm in `serialize_any_problem()`: `"QuadraticAssignment" => try_ser::(any)` - -2. **`problemreductions-cli/src/problem_name.rs`**: - - Add `"quadraticassignment" | "qap" => "QuadraticAssignment".to_string()` in `resolve_alias()` - - Add `("QAP", "QuadraticAssignment")` to `ALIASES` array (QAP is a well-established abbreviation) - -3. **`problemreductions-cli/src/commands/create.rs`**: - - Add match arm for `"QuadraticAssignment"` that parses `--matrix` for cost_matrix and a second `--distance-matrix` flag for distance_matrix (both semicolon-separated row format) - - Add CLI flags if needed (`--distance-matrix`) - - Update help text in `after_help` - -### Step 4: Write unit tests - -**File:** `src/unit_tests/models/algebraic/quadratic_assignment.rs` - -Also update `src/unit_tests/models/algebraic/mod.rs` if it exists to include the new test module. - -Tests: -- `test_quadratic_assignment_creation` — construct n=4 instance, verify num_facilities=4, num_locations=4, dims -- `test_quadratic_assignment_evaluate_valid` — test identity assignment f=(0,1,2,3) on the example instance, verify cost=38 -- `test_quadratic_assignment_evaluate_swap` — test f=(0,2,1,3) on example, verify different cost -- `test_quadratic_assignment_evaluate_invalid` — test duplicate assignment like [0,0,1,2], verify SolutionSize::Invalid -- `test_quadratic_assignment_direction` — verify Direction::Minimize -- `test_quadratic_assignment_serialization` — round-trip serde JSON test -- `test_quadratic_assignment_solver` — BruteForce::find_best on the n=4 example, verify optimal cost=38 - -### Step 5: Write paper entry - -Invoke `/write-model-in-paper` to add `QuadraticAssignment` problem-def entry in `docs/paper/reductions.typ`: -- Add to `display-name` dict: `"QuadraticAssignment": [Quadratic Assignment]` -- Formal definition, background (Koopmans-Beckmann 1957, Sahni-Gonzalez 1976) -- Example: the n=4 instance from the issue with CeTZ visualization (facility-location bipartite diagram) - -### Step 6: Regenerate exports - -```bash -make export-schemas # Regenerate problem schemas JSON -make fmt # Format code -``` - -### Step 7: Verify - -```bash -make check # fmt + clippy + test -``` - -## Example Instance (for tests) - -Cost matrix C (4×4): -``` -[[0, 5, 2, 0], - [5, 0, 0, 3], - [2, 0, 0, 4], - [0, 3, 4, 0]] -``` - -Distance matrix D (4×4): -``` -[[0, 1, 2, 3], - [1, 0, 1, 2], - [2, 1, 0, 1], - [3, 2, 1, 0]] -``` - -Identity assignment f=(0,1,2,3): cost = 38 -Optimal: cost = 38 (multiple permutations achieve this due to matrix symmetry) From 7b33948f1e4d8502d35ef9e2bd2908ec28498b8b Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 07:05:45 +0000 Subject: [PATCH 04/12] Add review-driven test improvements for QuadraticAssignment - Add out-of-range location index test - Add rectangular case test (n < m) - Add #[should_panic] tests for constructor validation Co-Authored-By: Claude Opus 4.6 --- .../models/algebraic/quadratic_assignment.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/unit_tests/models/algebraic/quadratic_assignment.rs b/src/unit_tests/models/algebraic/quadratic_assignment.rs index 8da7066be..3a4e9f964 100644 --- a/src/unit_tests/models/algebraic/quadratic_assignment.rs +++ b/src/unit_tests/models/algebraic/quadratic_assignment.rs @@ -79,6 +79,11 @@ fn test_quadratic_assignment_evaluate_invalid() { Problem::evaluate(&qap, &[0, 0, 1, 2]), SolutionSize::Invalid ); + // Out-of-range location index. + assert_eq!( + Problem::evaluate(&qap, &[0, 1, 2, 99]), + SolutionSize::Invalid + ); } #[test] @@ -101,6 +106,40 @@ fn test_quadratic_assignment_serialization() { ); } +#[test] +fn test_quadratic_assignment_rectangular() { + // 2 facilities, 3 locations (n < m) + let cost_matrix = vec![vec![0, 3], vec![3, 0]]; + let distance_matrix = vec![vec![0, 1, 4], vec![1, 0, 2], vec![4, 2, 0]]; + let qap = QuadraticAssignment::new(cost_matrix, distance_matrix); + assert_eq!(qap.num_facilities(), 2); + assert_eq!(qap.num_locations(), 3); + assert_eq!(qap.dims(), vec![3, 3]); + // Assignment f=(0,1): cost = C[0][1]*D[0][1] + C[1][0]*D[1][0] = 3*1 + 3*1 = 6 + assert_eq!(Problem::evaluate(&qap, &[0, 1]), SolutionSize::Valid(6)); + // Assignment f=(0,2): cost = 3*D[0][2] + 3*D[2][0] = 3*4 + 3*4 = 24 + assert_eq!(Problem::evaluate(&qap, &[0, 2]), SolutionSize::Valid(24)); + // BruteForce should find optimal + let solver = BruteForce::new(); + let best = solver.find_best(&qap).unwrap(); + assert_eq!(Problem::evaluate(&qap, &best), SolutionSize::Valid(6)); +} + +#[test] +#[should_panic(expected = "cost_matrix must be square")] +fn test_quadratic_assignment_nonsquare_cost() { + QuadraticAssignment::new(vec![vec![0, 1]], vec![vec![0, 1], vec![1, 0]]); +} + +#[test] +#[should_panic(expected = "num_facilities")] +fn test_quadratic_assignment_too_many_facilities() { + // 3 facilities, 2 locations (n > m) -- should panic + let cost = vec![vec![0, 1, 2], vec![1, 0, 3], vec![2, 3, 0]]; + let dist = vec![vec![0, 1], vec![1, 0]]; + QuadraticAssignment::new(cost, dist); +} + #[test] fn test_quadratic_assignment_solver() { let qap = make_test_instance(); From 56fc941f889ad8661e3c35a8e3e647012d7c793d Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 16:03:51 +0000 Subject: [PATCH 05/12] Address Copilot review comments for QuadraticAssignment - Fix Dreyfus-Wagner reference in paper (not applicable to QAP, replaced with cutting-plane methods) - Add config length check in evaluate() to return Invalid instead of panicking - Replace HashSet with Vec for faster injectivity checking - Fix doc wording: "permutation" -> "injective mapping" for rectangular case - Add input validation in CLI create to surface errors instead of panicking - Improve parse_i64_matrix with row/col context in errors and ragged matrix detection - Regenerate problem_schemas.json after merge - Add tests for config length mismatch Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 2 +- docs/src/reductions/problem_schemas.json | 11 +++++ problemreductions-cli/src/commands/create.rs | 49 ++++++++++++++++--- src/lib.rs | 4 +- src/models/algebraic/quadratic_assignment.rs | 16 ++++-- .../models/algebraic/quadratic_assignment.rs | 7 +++ 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ddbd9cffc..3fc8b7026 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -732,7 +732,7 @@ Integer Linear Programming is a universal modeling framework: virtually every NP Given $n$ facilities and $m$ locations ($n <= m$), a flow matrix $C in ZZ^(n times n)$ representing flows between facilities, and a distance matrix $D in ZZ^(m times m)$ representing distances between locations, find an injective assignment $f: {1, dots, n} -> {1, dots, m}$ that minimizes $ sum_(i != j) C_(i j) dot D_(f(i), f(j)). $ ][ -The Quadratic Assignment Problem was introduced by Koopmans and Beckmann (1957) to model the optimal placement of economic activities (facilities) across geographic locations, minimizing total transportation cost weighted by inter-facility flows. It is NP-hard, as shown by Sahni and Gonzalez (1976) via reduction from the Hamiltonian Circuit problem. QAP is widely regarded as one of the hardest combinatorial optimization problems: even moderate instances ($n > 20$) challenge state-of-the-art exact solvers. Best exact approaches use branch-and-bound with Gilmore--Lawler bounds or Dreyfus--Wagner-like dynamic programming; the best known general algorithm runs in $O^*(n!)$ by exhaustive enumeration of all permutations#footnote[No algorithm significantly improving on brute-force permutation enumeration is known for general QAP.]. +The Quadratic Assignment Problem was introduced by Koopmans and Beckmann (1957) to model the optimal placement of economic activities (facilities) across geographic locations, minimizing total transportation cost weighted by inter-facility flows. It is NP-hard, as shown by Sahni and Gonzalez (1976) via reduction from the Hamiltonian Circuit problem. QAP is widely regarded as one of the hardest combinatorial optimization problems: even moderate instances ($n > 20$) challenge state-of-the-art exact solvers. Best exact approaches use branch-and-bound with Gilmore--Lawler bounds or cutting-plane methods; the best known general algorithm runs in $O^*(n!)$ by exhaustive enumeration of all permutations#footnote[No algorithm significantly improving on brute-force permutation enumeration is known for general QAP.]. Applications include facility layout planning, keyboard and control panel design, scheduling, VLSI placement, and hospital floor planning. As a special case, when $D$ is a distance matrix on a line (i.e., $D_(k l) = |k - l|$), QAP reduces to the Optimal Linear Arrangement problem. diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index dda0889d1..fda0f8f0e 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -382,6 +382,17 @@ } ] }, + { + "name": "PartitionIntoTriangles", + "description": "Partition vertices into triangles (K3 subgraphs)", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E) with |V| divisible by 3" + } + ] + }, { "name": "QUBO", "description": "Minimize quadratic unconstrained binary objective", diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7282a6c14..77bbe6e7b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -326,6 +326,27 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { })?; let cost_matrix = parse_i64_matrix(cost_str).context("Invalid cost matrix")?; let distance_matrix = parse_i64_matrix(dist_str).context("Invalid distance matrix")?; + let n = cost_matrix.len(); + for (i, row) in cost_matrix.iter().enumerate() { + if row.len() != n { + bail!( + "cost matrix must be square: row {i} has {} columns, expected {n}", + row.len() + ); + } + } + let m = distance_matrix.len(); + for (i, row) in distance_matrix.iter().enumerate() { + if row.len() != m { + bail!( + "distance matrix must be square: row {i} has {} columns, expected {m}", + row.len() + ); + } + } + if n > m { + bail!("num_facilities ({n}) must be <= num_locations ({m})"); + } ( ser( problemreductions::models::algebraic::QuadraticAssignment::new( @@ -1067,18 +1088,32 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { /// Parse a semicolon-separated matrix of i64 values. /// E.g., "0,5;5,0" fn parse_i64_matrix(s: &str) -> Result>> { - s.split(';') - .map(|row| { + let matrix: Vec> = s + .split(';') + .enumerate() + .map(|(row_idx, row)| { row.trim() .split(',') - .map(|v| { - v.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid matrix value: {}", e)) + .enumerate() + .map(|(col_idx, v)| { + v.trim().parse::().map_err(|e| { + anyhow::anyhow!("Invalid value at row {row_idx}, col {col_idx}: {e}") + }) }) .collect() }) - .collect() + .collect::>()?; + if let Some(first_len) = matrix.first().map(|r| r.len()) { + for (i, row) in matrix.iter().enumerate() { + if row.len() != first_len { + bail!( + "Ragged matrix: row {i} has {} columns, expected {first_len}", + row.len() + ); + } + } + } + Ok(matrix) } /// Handle `pred create --random ...` diff --git a/src/lib.rs b/src/lib.rs index b87e2233b..ca38d7986 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,8 +45,8 @@ pub mod prelude { }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, - PartitionIntoTriangles, RuralPostman, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, PartitionIntoTriangles, + RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum, diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index 8e70ddd27..75598659b 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -29,7 +29,7 @@ inventory::submit! { /// /// f(p) = sum_{i != j} C[i][j] * D[p(i)][p(j)] /// -/// where p is a permutation mapping facilities to locations. +/// where p is an injective mapping from facilities to locations (a permutation when n == m). /// /// # Example /// @@ -122,19 +122,25 @@ impl Problem for QuadraticAssignment { let n = self.num_facilities(); let m = self.num_locations(); + // Check config length matches number of facilities + if config.len() != n { + return SolutionSize::Invalid; + } + // Check that all assignments are valid locations - for &loc in config.iter().take(n) { + for &loc in config { if loc >= m { return SolutionSize::Invalid; } } // Check injectivity: no two facilities assigned to the same location - let mut used = std::collections::HashSet::new(); - for &loc in config.iter().take(n) { - if !used.insert(loc) { + let mut used = vec![false; m]; + for &loc in config { + if used[loc] { return SolutionSize::Invalid; } + used[loc] = true; } // Compute objective: sum_{i != j} cost_matrix[i][j] * distance_matrix[config[i]][config[j]] diff --git a/src/unit_tests/models/algebraic/quadratic_assignment.rs b/src/unit_tests/models/algebraic/quadratic_assignment.rs index 3a4e9f964..07d2807ac 100644 --- a/src/unit_tests/models/algebraic/quadratic_assignment.rs +++ b/src/unit_tests/models/algebraic/quadratic_assignment.rs @@ -84,6 +84,13 @@ fn test_quadratic_assignment_evaluate_invalid() { Problem::evaluate(&qap, &[0, 1, 2, 99]), SolutionSize::Invalid ); + // Wrong config length — too short. + assert_eq!(Problem::evaluate(&qap, &[0, 1, 2]), SolutionSize::Invalid); + // Wrong config length — too long. + assert_eq!( + Problem::evaluate(&qap, &[0, 1, 2, 3, 0]), + SolutionSize::Invalid + ); } #[test] From 0e937e60c186f2cf09aaa387b99fb3811b96444b Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 16:22:21 +0000 Subject: [PATCH 06/12] trigger CI From ca2c3503488ace86bc52735c24dd544a5e82189e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Mar 2026 02:33:20 +0800 Subject: [PATCH 07/12] Add QuadraticAssignment to trait_consistency tests Co-Authored-By: Claude Opus 4.6 --- src/unit_tests/trait_consistency.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 7eef06601..5d79a47e2 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -77,6 +77,10 @@ fn test_all_problems_implement_trait_correctly() { "BicliqueCover", ); check_problem_trait(&Factoring::new(6, 2, 2), "Factoring"); + check_problem_trait( + &QuadraticAssignment::new(vec![vec![0, 1], vec![1, 0]], vec![vec![0, 1], vec![1, 0]]), + "QuadraticAssignment", + ); let circuit = Circuit::new(vec![Assignment::new( vec!["x".to_string()], @@ -124,6 +128,11 @@ fn test_direction() { BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1).direction(), Direction::Minimize ); + assert_eq!( + QuadraticAssignment::new(vec![vec![0, 1], vec![1, 0]], vec![vec![0, 1], vec![1, 0]]) + .direction(), + Direction::Minimize + ); // Maximization problems assert_eq!( From 96ac474d8632d349c2095f99c9320fd03edd69f5 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Mar 2026 09:12:16 +0800 Subject: [PATCH 08/12] Fix QuadraticAssignment complexity: use factorial(num_facilities) Add factorial() function support to the expression parser (both proc macro and runtime) and all Expr match sites (eval, display, canonical, big_o, analysis). Change QuadraticAssignment's declare_variants! complexity from "num_facilities ^ num_facilities" to "factorial(num_facilities)". Co-Authored-By: Claude Opus 4.6 --- problemreductions-macros/src/parser.rs | 19 +++++++++++++++++- src/big_o.rs | 4 +++- src/canonical.rs | 6 ++++++ src/expr.rs | 21 +++++++++++++++++--- src/models/algebraic/quadratic_assignment.rs | 2 +- src/rules/analysis.rs | 1 + 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/problemreductions-macros/src/parser.rs b/problemreductions-macros/src/parser.rs index 409960a30..9c3e5321d 100644 --- a/problemreductions-macros/src/parser.rs +++ b/problemreductions-macros/src/parser.rs @@ -33,6 +33,7 @@ pub enum ParsedExpr { Exp(Box), Log(Box), Sqrt(Box), + Factorial(Box), } #[derive(Debug, Clone, PartialEq)] @@ -205,6 +206,7 @@ impl Parser { "exp" => Ok(ParsedExpr::Exp(Box::new(arg))), "log" => Ok(ParsedExpr::Log(Box::new(arg))), "sqrt" => Ok(ParsedExpr::Sqrt(Box::new(arg))), + "factorial" => Ok(ParsedExpr::Factorial(Box::new(arg))), _ => Err(format!("unknown function: {name}")), } } else { @@ -283,6 +285,10 @@ impl ParsedExpr { let a = a.to_expr_tokens(); quote! { crate::expr::Expr::Sqrt(Box::new(#a)) } } + ParsedExpr::Factorial(a) => { + let a = a.to_expr_tokens(); + quote! { crate::expr::Expr::Factorial(Box::new(#a)) } + } } } @@ -336,6 +342,13 @@ impl ParsedExpr { let a = a.to_eval_tokens(src_ident); quote! { f64::sqrt(#a) } } + ParsedExpr::Factorial(a) => { + let a = a.to_eval_tokens(src_ident); + quote! { { + let n = #a as u64; + (1..=n).fold(1.0_f64, |acc, i| acc * i as f64) + } } + } } } @@ -360,7 +373,11 @@ impl ParsedExpr { a.collect_vars(vars); b.collect_vars(vars); } - ParsedExpr::Neg(a) | ParsedExpr::Exp(a) | ParsedExpr::Log(a) | ParsedExpr::Sqrt(a) => { + ParsedExpr::Neg(a) + | ParsedExpr::Exp(a) + | ParsedExpr::Log(a) + | ParsedExpr::Sqrt(a) + | ParsedExpr::Factorial(a) => { a.collect_vars(vars); } } diff --git a/src/big_o.rs b/src/big_o.rs index fa568074a..1b782d862 100644 --- a/src/big_o.rs +++ b/src/big_o.rs @@ -279,7 +279,9 @@ fn contains_negative_exponent(expr: &Expr) -> bool { Expr::Mul(a, b) | Expr::Add(a, b) => { contains_negative_exponent(a) || contains_negative_exponent(b) } - Expr::Exp(arg) | Expr::Log(arg) | Expr::Sqrt(arg) => contains_negative_exponent(arg), + Expr::Exp(arg) | Expr::Log(arg) | Expr::Sqrt(arg) | Expr::Factorial(arg) => { + contains_negative_exponent(arg) + } Expr::Const(_) | Expr::Var(_) => false, } } diff --git a/src/canonical.rs b/src/canonical.rs index ae96ac09f..968b589e5 100644 --- a/src/canonical.rs +++ b/src/canonical.rs @@ -258,6 +258,12 @@ fn expr_to_canonical(expr: &Expr) -> Result // sqrt(x) = x^0.5 — canonicalize as power canonicalize_pow(arg, &Expr::Const(0.5)) } + Expr::Factorial(arg) => { + let inner = canonical_form(arg)?; + Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( + Expr::Factorial(Box::new(inner)), + ))) + } } } diff --git a/src/expr.rs b/src/expr.rs index 0754dacf8..f1fddf005 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -23,6 +23,8 @@ pub enum Expr { Log(Box), /// Square root: sqrt(a). Sqrt(Box), + /// Factorial: factorial(a). + Factorial(Box), } impl Expr { @@ -47,6 +49,10 @@ impl Expr { Expr::Exp(a) => a.eval(vars).exp(), Expr::Log(a) => a.eval(vars).ln(), Expr::Sqrt(a) => a.eval(vars).sqrt(), + Expr::Factorial(a) => { + let n = a.eval(vars) as u64; + (1..=n).fold(1.0_f64, |acc, i| acc * i as f64) + } } } @@ -67,7 +73,7 @@ impl Expr { a.collect_variables(vars); b.collect_variables(vars); } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) => { + Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { a.collect_variables(vars); } } @@ -90,6 +96,7 @@ impl Expr { Expr::Exp(a) => Expr::Exp(Box::new(a.substitute(mapping))), Expr::Log(a) => Expr::Log(Box::new(a.substitute(mapping))), Expr::Sqrt(a) => Expr::Sqrt(Box::new(a.substitute(mapping))), + Expr::Factorial(a) => Expr::Factorial(Box::new(a.substitute(mapping))), } } @@ -122,7 +129,7 @@ impl Expr { base.is_polynomial() && matches!(exp.as_ref(), Expr::Const(c) if *c >= 0.0 && (*c - c.round()).abs() < 1e-10) } - Expr::Exp(_) | Expr::Log(_) | Expr::Sqrt(_) => false, + Expr::Exp(_) | Expr::Log(_) | Expr::Sqrt(_) | Expr::Factorial(_) => false, } } @@ -174,7 +181,9 @@ impl Expr { base_ok && exp_ok } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) => a.is_valid_complexity_notation_inner(), + Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { + a.is_valid_complexity_notation_inner() + } } } @@ -192,6 +201,10 @@ impl Expr { Expr::Exp(a) => Some(a.constant_value()?.exp()), Expr::Log(a) => Some(a.constant_value()?.ln()), Expr::Sqrt(a) => Some(a.constant_value()?.sqrt()), + Expr::Factorial(a) => { + let n = a.constant_value()? as u64; + Some((1..=n).fold(1.0_f64, |acc, i| acc * i as f64)) + } } } } @@ -244,6 +257,7 @@ impl fmt::Display for Expr { Expr::Exp(a) => write!(f, "exp({a})"), Expr::Log(a) => write!(f, "log({a})"), Expr::Sqrt(a) => write!(f, "sqrt({a})"), + Expr::Factorial(a) => write!(f, "factorial({a})"), } } } @@ -516,6 +530,7 @@ impl ExprParser { "exp" => Ok(Expr::Exp(Box::new(arg))), "log" => Ok(Expr::Log(Box::new(arg))), "sqrt" => Ok(Expr::Sqrt(Box::new(arg))), + "factorial" => Ok(Expr::Factorial(Box::new(arg))), _ => Err(format!("unknown function: {name}")), } } else { diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index 75598659b..68a4170eb 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -170,7 +170,7 @@ impl OptimizationProblem for QuadraticAssignment { } crate::declare_variants! { - QuadraticAssignment => "num_facilities ^ num_facilities", + QuadraticAssignment => "factorial(num_facilities)", } #[cfg(test)] diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index cfb902441..d16a16031 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -219,6 +219,7 @@ fn normalize_polynomial(expr: &Expr) -> Result { Expr::Exp(_) => Err("exp() not supported".into()), Expr::Log(_) => Err("log() not supported".into()), Expr::Sqrt(_) => Err("sqrt() not supported".into()), + Expr::Factorial(_) => Err("factorial() not supported".into()), } } From 7f4dd8753ea16446c362590f458dce2b47d56d20 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Mar 2026 09:18:41 +0800 Subject: [PATCH 09/12] Fix parser.rs grammar comment: restore factorial Co-Authored-By: Claude Opus 4.6 --- problemreductions-macros/src/parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemreductions-macros/src/parser.rs b/problemreductions-macros/src/parser.rs index 9c3e5321d..a4d2c8b90 100644 --- a/problemreductions-macros/src/parser.rs +++ b/problemreductions-macros/src/parser.rs @@ -14,7 +14,7 @@ //! factor = unary ('^' factor)? // right-associative //! unary = '-' unary | primary //! primary = NUMBER | IDENT | func_call | '(' expr ')' -//! func_call = ('exp' | 'log' | 'sqrt') '(' expr ')' +//! func_call = ('exp' | 'log' | 'sqrt' | 'factorial') '(' expr ')' use proc_macro2::TokenStream; use quote::quote; From 92f15822d5f08bf6c2bcf35a60d3c91a99e0559b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 18 Mar 2026 22:27:24 +0800 Subject: [PATCH 10/12] fix: add missing distance_matrix field in test helper CreateArgs Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 1d8afa9ed..1e1372f67 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -3633,6 +3633,7 @@ mod tests { pattern: None, strings: None, arcs: None, + distance_matrix: None, potential_edges: None, budget: None, candidate_arcs: None, From 31a0a8e7e176afc0701fe3cef500a3c5bc9bec15 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 18 Mar 2026 22:45:17 +0800 Subject: [PATCH 11/12] fix: align QAP test with issue #300 instance, add example_db, data-driven paper - Align unit test distance matrix with issue #300's canonical instance (identity cost=100, optimal f*=(3,0,1,2) cost=56) - Add canonical_model_example_specs() for QuadraticAssignment in example_db - Rewrite paper section to load example from example_db via load-model-example("QuadraticAssignment") instead of hand-written data Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 129 +++++++++++------- src/models/algebraic/mod.rs | 1 + src/models/algebraic/quadratic_assignment.rs | 23 ++++ .../models/algebraic/quadratic_assignment.rs | 46 ++++--- 4 files changed, 131 insertions(+), 68 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 38c17a0d1..611deeeb9 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -1851,55 +1851,92 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS ] } -#problem-def("QuadraticAssignment")[ - Given $n$ facilities and $m$ locations ($n <= m$), a flow matrix $C in ZZ^(n times n)$ representing flows between facilities, and a distance matrix $D in ZZ^(m times m)$ representing distances between locations, find an injective assignment $f: {1, dots, n} -> {1, dots, m}$ that minimizes - $ sum_(i != j) C_(i j) dot D_(f(i), f(j)). $ -][ -The Quadratic Assignment Problem was introduced by Koopmans and Beckmann (1957) to model the optimal placement of economic activities (facilities) across geographic locations, minimizing total transportation cost weighted by inter-facility flows. It is NP-hard, as shown by Sahni and Gonzalez (1976) via reduction from the Hamiltonian Circuit problem. QAP is widely regarded as one of the hardest combinatorial optimization problems: even moderate instances ($n > 20$) challenge state-of-the-art exact solvers. Best exact approaches use branch-and-bound with Gilmore--Lawler bounds or cutting-plane methods; the best known general algorithm runs in $O^*(n!)$ by exhaustive enumeration of all permutations#footnote[No algorithm significantly improving on brute-force permutation enumeration is known for general QAP.]. +#{ + let x = load-model-example("QuadraticAssignment") + let C = x.instance.cost_matrix + let D = x.instance.distance_matrix + let n = C.len() + let m = D.len() + let sol = (config: x.optimal_config, metric: x.optimal_value) + let fstar = sol.config + let cost-star = sol.metric.Valid + // Format matrices as semicolon-separated rows + let fmt-mat(mat) = mat.map(row => row.map(v => str(v)).join(", ")).join("; ") + // Compute identity assignment cost + let id-cost = range(n).fold(0, (acc, i) => + range(n).fold(acc, (acc2, j) => + if i != j { acc2 + C.at(i).at(j) * D.at(i).at(j) } else { acc2 } + ) + ) + // Format optimal assignment as 1-indexed + let fstar-display = fstar.map(v => str(v + 1)).join(", ") + // Find the highest-flow off-diagonal pair + let max-flow = 0 + let max-fi = 0 + let max-fj = 0 + for i in range(n) { + for j in range(i + 1, n) { + if C.at(i).at(j) > max-flow { + max-flow = C.at(i).at(j) + max-fi = i + max-fj = j + } + } + } + let assigned-li = fstar.at(max-fi) + let assigned-lj = fstar.at(max-fj) + let dist-between = D.at(assigned-li).at(assigned-lj) + [ + #problem-def("QuadraticAssignment")[ + Given $n$ facilities and $m$ locations ($n <= m$), a flow matrix $C in ZZ^(n times n)$ representing flows between facilities, and a distance matrix $D in ZZ^(m times m)$ representing distances between locations, find an injective assignment $f: {1, dots, n} -> {1, dots, m}$ that minimizes + $ sum_(i != j) C_(i j) dot D_(f(i), f(j)). $ + ][ + The Quadratic Assignment Problem was introduced by Koopmans and Beckmann (1957) to model the optimal placement of economic activities (facilities) across geographic locations, minimizing total transportation cost weighted by inter-facility flows. It is NP-hard, as shown by Sahni and Gonzalez (1976) via reduction from the Hamiltonian Circuit problem. QAP is widely regarded as one of the hardest combinatorial optimization problems: even moderate instances ($n > 20$) challenge state-of-the-art exact solvers. Best exact approaches use branch-and-bound with Gilmore--Lawler bounds or cutting-plane methods; the best known general algorithm runs in $O^*(n!)$ by exhaustive enumeration of all permutations#footnote[No algorithm significantly improving on brute-force permutation enumeration is known for general QAP.]. -Applications include facility layout planning, keyboard and control panel design, scheduling, VLSI placement, and hospital floor planning. As a special case, when $D$ is a distance matrix on a line (i.e., $D_(k l) = |k - l|$), QAP reduces to the Optimal Linear Arrangement problem. + Applications include facility layout planning, keyboard and control panel design, scheduling, VLSI placement, and hospital floor planning. As a special case, when $D$ is a distance matrix on a line (i.e., $D_(k l) = |k - l|$), QAP reduces to the Optimal Linear Arrangement problem. -*Example.* Consider $n = m = 4$ with flow matrix $C$ and distance matrix $D$: -$ C = mat(0, 3, 0, 2; 3, 0, 0, 1; 0, 0, 0, 4; 2, 1, 4, 0), quad D = mat(0, 1, 2, 3; 1, 0, 1, 2; 2, 1, 0, 1; 3, 2, 1, 0). $ -The identity assignment $f(i) = i$ gives cost $sum_(i != j) C_(i j) dot D_(i, j) = 3 dot 1 + 2 dot 3 + 3 dot 1 + 1 dot 2 + 4 dot 1 + 2 dot 3 + 1 dot 2 + 4 dot 1 = 3 + 6 + 3 + 2 + 4 + 6 + 2 + 4 = 30$. However, the assignment $f = (1, 2, 4, 3)$ — swapping facilities 3 and 4 — gives cost $3 dot 1 + 2 dot 2 + 3 dot 1 + 1 dot 1 + 4 dot 1 + 2 dot 2 + 1 dot 1 + 4 dot 1 = 3 + 4 + 3 + 1 + 4 + 4 + 1 + 4 = 24$. The optimal assignment is $f^* = (3, 4, 1, 2)$ with cost 22: it places the heavily interacting facilities 3 and 4 (flow 4) at adjacent locations. + *Example.* Consider $n = m = #n$ with flow matrix $C$ and distance matrix $D$: + $ C = mat(#fmt-mat(C)), quad D = mat(#fmt-mat(D)). $ + The identity assignment $f(i) = i$ gives cost #id-cost. The optimal assignment is $f^* = (#fstar-display)$ with cost #cost-star: it places the heavily interacting facilities $F_#(max-fi + 1)$ and $F_#(max-fj + 1)$ (highest flow $= #max-flow$) at locations $L_#(assigned-li + 1)$ and $L_#(assigned-lj + 1)$ (distance $= #dist-between$). -#figure( - canvas(length: 1cm, { - import draw: * - // Facility column (left) - let fac-x = 0 - let loc-x = 5 - let ys = (3, 2, 1, 0) - // Draw facility nodes - for i in range(4) { - circle((fac-x, ys.at(i)), radius: 0.3, fill: graph-colors.at(0), stroke: 0.8pt + graph-colors.at(0), name: "f" + str(i)) - content("f" + str(i), text(fill: white, 8pt)[$F_#(i+1)$]) - } - // Draw location nodes - for j in range(4) { - circle((loc-x, ys.at(j)), radius: 0.3, fill: graph-colors.at(1), stroke: 0.8pt + graph-colors.at(1), name: "l" + str(j)) - content("l" + str(j), text(fill: white, 8pt)[$L_#(j+1)$]) - } - // Column labels - content((fac-x, 3.7), text(9pt, weight: "bold")[Facilities]) - content((loc-x, 3.7), text(9pt, weight: "bold")[Locations]) - // Optimal assignment f* = (3, 4, 1, 2): F1→L3, F2→L4, F3→L1, F4→L2 - let assignments = ((0, 2), (1, 3), (2, 0), (3, 1)) - for (fi, li) in assignments { - line("f" + str(fi) + ".east", "l" + str(li) + ".west", - mark: (end: "straight"), stroke: 1.2pt + luma(80)) - } - // Annotate key flow: F3↔F4 have flow 4 - on-layer(-1, { - rect((-0.55, -0.55), (0.55, 1.55), - fill: graph-colors.at(0).transparentize(92%), - stroke: (dash: "dashed", paint: graph-colors.at(0).transparentize(50%), thickness: 0.6pt)) - }) - content((fac-x, -0.9), text(6pt, fill: luma(100))[flow$(F_3, F_4) = 4$]) - }), - caption: [Optimal assignment $f^* = (3, 4, 1, 2)$ for the $4 times 4$ QAP instance. Facilities (blue, left) are assigned to locations (red, right) by arrows. Facilities $F_3$ and $F_4$ (highest flow $= 4$) are assigned to adjacent locations $L_1$ and $L_2$ (distance $= 1$). Total cost $= 22$.], -) -] + #figure( + canvas(length: 1cm, { + import draw: * + let fac-x = 0 + let loc-x = 5 + let ys = range(n).rev() + // Draw facility nodes + for i in range(n) { + circle((fac-x, ys.at(i)), radius: 0.3, fill: graph-colors.at(0), stroke: 0.8pt + graph-colors.at(0), name: "f" + str(i)) + content("f" + str(i), text(fill: white, 8pt)[$F_#(i+1)$]) + } + // Draw location nodes + for j in range(m) { + circle((loc-x, ys.at(j)), radius: 0.3, fill: graph-colors.at(1), stroke: 0.8pt + graph-colors.at(1), name: "l" + str(j)) + content("l" + str(j), text(fill: white, 8pt)[$L_#(j+1)$]) + } + content((fac-x, n - 0.3), text(9pt, weight: "bold")[Facilities]) + content((loc-x, m - 0.3), text(9pt, weight: "bold")[Locations]) + // Draw optimal assignment arrows + for (fi, li) in fstar.enumerate() { + line("f" + str(fi) + ".east", "l" + str(li) + ".west", + mark: (end: "straight"), stroke: 1.2pt + luma(80)) + } + // Highlight highest-flow pair + on-layer(-1, { + let y0 = calc.min(ys.at(max-fi), ys.at(max-fj)) - 0.55 + let y1 = calc.max(ys.at(max-fi), ys.at(max-fj)) + 0.55 + rect((-0.55, y0), (0.55, y1), + fill: graph-colors.at(0).transparentize(92%), + stroke: (dash: "dashed", paint: graph-colors.at(0).transparentize(50%), thickness: 0.6pt)) + }) + content((fac-x, -0.9), text(6pt, fill: luma(100))[flow$(F_#(max-fi + 1), F_#(max-fj + 1)) = #max-flow$]) + }), + caption: [Optimal assignment $f^* = (#fstar-display)$ for the $#n times #m$ QAP instance. Facilities (blue, left) are assigned to locations (red, right) by arrows. Facilities $F_#(max-fi + 1)$ and $F_#(max-fj + 1)$ (highest flow $= #max-flow$) are assigned to locations $L_#(assigned-li + 1)$ and $L_#(assigned-lj + 1)$ (distance $= #dist-between$). Total cost $= #cost-star$.], + ) + ] + ] +} #{ let x = load-model-example("ClosestVectorProblem") diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index 1af07661b..fcbba87f3 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -26,5 +26,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec "factorial(num_facilities)", } +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "quadratic_assignment", + instance: Box::new(QuadraticAssignment::new( + vec![ + vec![0, 5, 2, 0], + vec![5, 0, 0, 3], + vec![2, 0, 0, 4], + vec![0, 3, 4, 0], + ], + vec![ + vec![0, 4, 1, 1], + vec![4, 0, 3, 4], + vec![1, 3, 0, 4], + vec![1, 4, 4, 0], + ], + )), + optimal_config: vec![3, 0, 1, 2], + optimal_value: serde_json::json!({"Valid": 56}), + }] +} + #[cfg(test)] #[path = "../../unit_tests/models/algebraic/quadratic_assignment.rs"] mod tests; diff --git a/src/unit_tests/models/algebraic/quadratic_assignment.rs b/src/unit_tests/models/algebraic/quadratic_assignment.rs index 07d2807ac..703321938 100644 --- a/src/unit_tests/models/algebraic/quadratic_assignment.rs +++ b/src/unit_tests/models/algebraic/quadratic_assignment.rs @@ -3,19 +3,21 @@ use crate::solvers::{BruteForce, Solver}; use crate::traits::{OptimizationProblem, Problem}; use crate::types::{Direction, SolutionSize}; -/// Create a 4x4 test instance for reuse across tests. +/// Create a 4x4 test instance matching issue #300's example. /// -/// Cost matrix C: +/// Cost matrix C (flows between 4 facilities): /// [[0, 5, 2, 0], /// [5, 0, 0, 3], /// [2, 0, 0, 4], /// [0, 3, 4, 0]] /// -/// Distance matrix D: -/// [[0, 1, 2, 3], -/// [1, 0, 1, 2], -/// [2, 1, 0, 1], -/// [3, 2, 1, 0]] +/// Distance matrix D (distances between 4 locations): +/// [[0, 4, 1, 1], +/// [4, 0, 3, 4], +/// [1, 3, 0, 4], +/// [1, 4, 4, 0]] +/// +/// Optimal assignment: f* = (3, 0, 1, 2) with cost 56. fn make_test_instance() -> QuadraticAssignment { let cost_matrix = vec![ vec![0, 5, 2, 0], @@ -24,10 +26,10 @@ fn make_test_instance() -> QuadraticAssignment { vec![0, 3, 4, 0], ]; let distance_matrix = vec![ - vec![0, 1, 2, 3], - vec![1, 0, 1, 2], - vec![2, 1, 0, 1], - vec![3, 2, 1, 0], + vec![0, 4, 1, 1], + vec![4, 0, 3, 4], + vec![1, 3, 0, 4], + vec![1, 4, 4, 0], ]; QuadraticAssignment::new(cost_matrix, distance_matrix) } @@ -47,11 +49,11 @@ fn test_quadratic_assignment_evaluate_identity() { let qap = make_test_instance(); // Identity assignment f = (0, 1, 2, 3): // cost = sum_{i != j} C[i][j] * D[i][j] - // = 5*1 + 2*2 + 0*3 + 5*1 + 0*1 + 3*2 + 2*2 + 0*1 + 4*1 + 0*3 + 3*2 + 4*1 - // = 5 + 4 + 0 + 5 + 0 + 6 + 4 + 0 + 4 + 0 + 6 + 4 = 38 + // = 5*4 + 2*1 + 0*1 + 5*4 + 0*3 + 3*4 + 2*1 + 0*3 + 4*4 + 0*1 + 3*4 + 4*4 + // = 20 + 2 + 0 + 20 + 0 + 12 + 2 + 0 + 16 + 0 + 12 + 16 = 100 assert_eq!( Problem::evaluate(&qap, &[0, 1, 2, 3]), - SolutionSize::Valid(38) + SolutionSize::Valid(100) ); } @@ -60,14 +62,14 @@ fn test_quadratic_assignment_evaluate_swap() { let qap = make_test_instance(); // Assignment f = (0, 2, 1, 3): facility 1 -> loc 2, facility 2 -> loc 1 // cost = sum_{i != j} C[i][j] * D[config[i]][config[j]] - // i=0,j=1: 5*D[0][2]=5*2=10 i=0,j=2: 2*D[0][1]=2*1=2 i=0,j=3: 0*D[0][3]=0 - // i=1,j=0: 5*D[2][0]=5*2=10 i=1,j=2: 0*D[2][1]=0*1=0 i=1,j=3: 3*D[2][3]=3*1=3 - // i=2,j=0: 2*D[1][0]=2*1=2 i=2,j=1: 0*D[1][2]=0*1=0 i=2,j=3: 4*D[1][3]=4*2=8 - // i=3,j=0: 0*D[3][0]=0 i=3,j=1: 3*D[3][2]=3*1=3 i=3,j=2: 4*D[3][1]=4*2=8 - // Total = 10+2+0+10+0+3+2+0+8+0+3+8 = 46 + // i=0,j=1: 5*D[0][2]=5*1=5 i=0,j=2: 2*D[0][1]=2*4=8 i=0,j=3: 0*D[0][3]=0 + // i=1,j=0: 5*D[2][0]=5*1=5 i=1,j=2: 0*D[2][1]=0*3=0 i=1,j=3: 3*D[2][3]=3*4=12 + // i=2,j=0: 2*D[1][0]=2*4=8 i=2,j=1: 0*D[1][2]=0*3=0 i=2,j=3: 4*D[1][3]=4*4=16 + // i=3,j=0: 0*D[3][0]=0 i=3,j=1: 3*D[3][2]=3*4=12 i=3,j=2: 4*D[3][1]=4*4=16 + // Total = 5+8+0+5+0+12+8+0+16+0+12+16 = 82 assert_eq!( Problem::evaluate(&qap, &[0, 2, 1, 3]), - SolutionSize::Valid(46) + SolutionSize::Valid(82) ); } @@ -154,9 +156,9 @@ fn test_quadratic_assignment_solver() { let best = solver.find_best(&qap); assert!(best.is_some()); let best_config = best.unwrap(); - // The brute-force solver finds the optimal assignment with cost 36. + // The brute-force solver finds the optimal assignment f* = (3, 0, 1, 2) with cost 56. assert_eq!( Problem::evaluate(&qap, &best_config), - SolutionSize::Valid(36) + SolutionSize::Valid(56) ); } From f3d170882b0935eaca0c2b1e433842aa64b88341 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 18 Mar 2026 22:48:49 +0800 Subject: [PATCH 12/12] fix: render QAP matrices as proper math.mat instead of flat strings Use math.mat(..rows) with actual array data instead of fmt-mat string interpolation, which was rendering as inline text rather than a matrix grid. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 611deeeb9..9c3ae9339 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -1860,8 +1860,8 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS let sol = (config: x.optimal_config, metric: x.optimal_value) let fstar = sol.config let cost-star = sol.metric.Valid - // Format matrices as semicolon-separated rows - let fmt-mat(mat) = mat.map(row => row.map(v => str(v)).join(", ")).join("; ") + // Convert integer matrix to math.mat content + let to-mat(m) = math.mat(..m.map(row => row.map(v => $#v$))) // Compute identity assignment cost let id-cost = range(n).fold(0, (acc, i) => range(n).fold(acc, (acc2, j) => @@ -1896,7 +1896,7 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS Applications include facility layout planning, keyboard and control panel design, scheduling, VLSI placement, and hospital floor planning. As a special case, when $D$ is a distance matrix on a line (i.e., $D_(k l) = |k - l|$), QAP reduces to the Optimal Linear Arrangement problem. *Example.* Consider $n = m = #n$ with flow matrix $C$ and distance matrix $D$: - $ C = mat(#fmt-mat(C)), quad D = mat(#fmt-mat(D)). $ + $ C = #to-mat(C), quad D = #to-mat(D). $ The identity assignment $f(i) = i$ gives cost #id-cost. The optimal assignment is $f^* = (#fstar-display)$ with cost #cost-star: it places the heavily interacting facilities $F_#(max-fi + 1)$ and $F_#(max-fj + 1)$ (highest flow $= #max-flow$) at locations $L_#(assigned-li + 1)$ and $L_#(assigned-lj + 1)$ (distance $= #dist-between$). #figure(