Repository: forward-market-design/flow-trading-service
Pull request #52: qp export functionality
Author: login: asymptoticgroup
Created: 2025-04-16T21:21:05Z | Merged: 2025-04-18T20:31:56Z

This is still a WIP (hence draft status), but I like using PRs to center discussion. As discussed in #51, this PR:
1. Relocates `--solver [XYZ]` to a subcommand `solve --lib [XYZ]`
2. Creates an `export [--format [mps|lp]]` subcommand
3. Scaffolds and stubs the `export_mps()` and `export_lp()` functions behind a feature flag in `fts-solver`

Todo:
* [x] actually implement the `export_mps` function
* [x] actually implement the `export_lp` function
* [x] determine optimal way to rename the binary in cargo and release-plz
* [x] memorialize a reference auction with known-good MPS and LP outputs in our test suite, then check for exact file content match on those


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 6f1216f..f283262 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -775,6 +775,17 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
 
+[[package]]
+name = "ftauction"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "fts-solver",
+ "serde_json",
+ "thiserror 2.0.12",
+]
+
 [[package]]
 name = "fts-core"
 version = "0.1.4"
@@ -865,16 +876,6 @@ dependencies = [
  "tracing",
 ]
 
-[[package]]
-name = "ftsolve"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "clap",
- "fts-solver",
- "serde_json",
-]
-
 [[package]]
 name = "futures-channel"
 version = "0.3.31"
diff --git a/Cargo.toml b/Cargo.toml
index 19c1285..9cfba92 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = ["fts-core", "fts-demo", "fts-server", "fts-solver", "ftsolve"]
+members = ["fts-core", "fts-demo", "fts-server", "fts-solver", "ftauction"]
 resolver = "2"
 
 [workspace.package]
diff --git a/ftsolve/CHANGELOG.md b/ftauction/CHANGELOG.md
similarity index 100%
rename from ftsolve/CHANGELOG.md
rename to ftauction/CHANGELOG.md
diff --git a/ftsolve/Cargo.toml b/ftauction/Cargo.toml
similarity index 77%
rename from ftsolve/Cargo.toml
rename to ftauction/Cargo.toml
index 347758b..c49f6cf 100644
--- a/ftsolve/Cargo.toml
+++ b/ftauction/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
-name = "ftsolve"
-description = "Solve a flow trading problem" 
+name = "ftauction"
+description = "Tools for analyzing flow trading auctions" 
 version = "0.1.0"
 authors.workspace = true
 edition.workspace = true
@@ -11,5 +11,6 @@ rust-version.workspace = true
 [dependencies]
 fts-solver = { path = "../fts-solver", version = "0.3.0", features = ["clarabel", "osqp", "io"] }
 anyhow = { workspace = true }
+thiserror = { workspace = true }
 clap = { workspace = true, features = ["derive"] }
 serde_json = { workspace = true }
diff --git a/ftsolve/README.md b/ftauction/README.md
similarity index 58%
rename from ftsolve/README.md
rename to ftauction/README.md
index 3270b7c..8162565 100644
--- a/ftsolve/README.md
+++ b/ftauction/README.md
@@ -1,8 +1,10 @@
-# `ftsolve`
+# `ftauction`
 
 This crate is a light wrapper around `fts-solver`, providing a binary that can
 read flow trading-based auctions from a standardized JSON input format and writes
-their solution to stdout or a file.
+their solution to stdout or a file. This binary also makes available other useful operations,
+such as exporting the intermediate quadratic program to a standardized output format
+for analysis in other tools.
 
 The JSON format is very simple. Given the following type definitions:
 ```typescript
@@ -23,7 +25,7 @@ interface Point {
 
 interface DemandCurve {
     // If omitted, `domain` is computed from `points`.
-    // If provided, the points will be interpolated or extrapolated accordingly.
+    // If provided, the curve will be interpolated or extrapolated accordingly.
     // Use `null` as a stand-in for ±∞
     domain?: [number | null, number | null];
 
@@ -42,7 +44,25 @@ The JSON input format is simply anything that deserializes as
 
 ## Installation
 
-To install, simply run `cargo install ftsolve`.
-To build from source, `cargo build --release --bin ftsolve`.
+To install, simply run `cargo install ftauction`.
+To build from source, `cargo build --release --bin ftauction`.
 
-Execute `ftsolve --help` to see options.
\ No newline at end of file
+## Usage
+
+All options are documented in `ftauction --help` and `ftauction [SUBCOMMAND] --help`.
+
+Some examples:
+
+```bash
+
+# Solve an auction given a file
+ftauction solve -o solution.json input.json
+
+# Solve an auction over stdin
+curl http://some.remote/file.json | ftauction solve -o solution.json -
+
+# Read an auction over stdin, export to stdout
+cat input.json | ftauction export - --format mps
+```
+
+The ordering between the input (a path, or "-") and the flags ("--format", for example) is not important.
diff --git a/ftauction/src/commands.rs b/ftauction/src/commands.rs
new file mode 100644
index 0000000..b50d85c
--- /dev/null
+++ b/ftauction/src/commands.rs
@@ -0,0 +1,28 @@
+use super::IOArgs;
+use clap::Subcommand;
+
+mod export;
+mod solve;
+
+#[derive(Subcommand)]
+pub enum Commands {
+    /// Solve the auction and report the solution
+    Solve {
+        #[command(flatten)]
+        io: IOArgs,
+
+        /// Request a specific QP solver
+        #[arg(short, long, default_value = "clarabel")]
+        lib: solve::SolverLib,
+    },
+
+    /// Construct the flow trading quadratic program and export to a standard format
+    Export {
+        #[command(flatten)]
+        io: IOArgs,
+
+        /// The file format to use (if omitted, will infer based on filename)
+        #[arg(short, long)]
+        format: Option<export::ExportFormat>,
+    },
+}
diff --git a/ftauction/src/commands/export.rs b/ftauction/src/commands/export.rs
new file mode 100644
index 0000000..090ad2a
--- /dev/null
+++ b/ftauction/src/commands/export.rs
@@ -0,0 +1,46 @@
+use clap::ValueEnum;
+use fts_solver::{
+    Submission,
+    export::{export_lp, export_mps},
+    io::{BidderId, PortfolioId, ProductId},
+};
+use std::{io::Write, str::FromStr};
+
+// Same story here with the ExportFormat enum, as with the SolverLib enum
+#[derive(Clone, Copy, ValueEnum)]
+pub enum ExportFormat {
+    Mps,
+    Lp,
+}
+
+impl ExportFormat {
+    pub fn export<T, W>(&self, auction: &T, buffer: &mut W) -> anyhow::Result<()>
+    where
+        for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
+        W: Write,
+    {
+        match self {
+            Self::Mps => export_mps(auction, buffer)?,
+            Self::Lp => export_lp(auction, buffer)?,
+        };
+        Ok(())
+    }
+}
+
+impl FromStr for ExportFormat {
+    type Err = ExportFormatError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "mps" | "MPS" => Ok(Self::Mps),
+            "lp" | "LP" => Ok(Self::Lp),
+            _ => Err(Self::Err::ExportExtension(s.to_owned())),
+        }
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ExportFormatError {
+    #[error("Unknown export format: {0}")]
+    ExportExtension(String),
+}
diff --git a/ftauction/src/commands/solve.rs b/ftauction/src/commands/solve.rs
new file mode 100644
index 0000000..57d131e
--- /dev/null
+++ b/ftauction/src/commands/solve.rs
@@ -0,0 +1,34 @@
+use clap::ValueEnum;
+use fts_solver::{
+    AuctionOutcome, Solver as _, Submission,
+    clarabel::ClarabelSolver,
+    io::{BidderId, PortfolioId, ProductId},
+    osqp::OsqpSolver,
+};
+
+// This explicitly articulates the available solvers for the `solve` subcommand
+#[derive(Clone, Copy, ValueEnum)]
+pub enum SolverLib {
+    Clarabel,
+    Osqp,
+}
+
+// Conveniently, we can use the same enum to handle the particulars of calling into
+// the various solver implementations
+impl SolverLib {
+    pub fn solve<T>(&self, auction: &T) -> AuctionOutcome<BidderId, PortfolioId, ProductId>
+    where
+        for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
+    {
+        match self {
+            SolverLib::Clarabel => {
+                let solver = ClarabelSolver::default();
+                solver.solve(auction)
+            }
+            SolverLib::Osqp => {
+                let solver = OsqpSolver::default();
+                solver.solve(auction)
+            }
+        }
+    }
+}
diff --git a/ftauction/src/io.rs b/ftauction/src/io.rs
new file mode 100644
index 0000000..743e489
--- /dev/null
+++ b/ftauction/src/io.rs
@@ -0,0 +1,62 @@
+use clap::Args;
+use std::{
+    fs::File,
+    io::{BufReader, BufWriter, Read, Write, stdin, stdout},
+    path::PathBuf,
+    str::FromStr,
+};
+
+// Most (all, presently) subcommands have a notion of input and output.
+// This struct standardizes their implementation.
+#[derive(Args)]
+pub struct IOArgs {
+    /// The auction JSON file ("-" implies stdin)
+    #[arg(value_parser = clap::value_parser!(PathOrStd))]
+    input: PathOrStd,
+
+    /// The output file ("-" implies stdout)
+    #[arg(short, long, default_value = "-", value_parser = clap::value_parser!(PathOrStd))]
+    output: PathOrStd,
+}
+
+impl IOArgs {
+    pub fn read(&self) -> anyhow::Result<Box<dyn Read>> {
+        match &self.input {
+            PathOrStd::Path(path) => Ok(Box::new(BufReader::new(File::open(path)?))),
+            PathOrStd::Std => Ok(Box::new(stdin().lock())),
+        }
+    }
+
+    pub fn write(&self) -> anyhow::Result<Box<dyn Write>> {
+        match &self.output {
+            PathOrStd::Path(path) => Ok(Box::new(BufWriter::new(File::create(path)?))),
+            PathOrStd::Std => Ok(Box::new(stdout().lock())),
+        }
+    }
+
+    pub fn extension(&self) -> Option<&str> {
+        match &self.output {
+            PathOrStd::Path(path) => path.extension(),
+            PathOrStd::Std => None,
+        }
+        .and_then(|ext| ext.to_str())
+    }
+}
+
+#[derive(Clone)]
+enum PathOrStd {
+    Path(PathBuf),
+    Std,
+}
+
+impl FromStr for PathOrStd {
+    type Err = <PathBuf as FromStr>::Err;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s == "-" {
+            Ok(Self::Std)
+        } else {
+            Ok(Self::Path(s.parse()?))
+        }
+    }
+}
diff --git a/ftauction/src/lib.rs b/ftauction/src/lib.rs
new file mode 100644
index 0000000..038c6a9
--- /dev/null
+++ b/ftauction/src/lib.rs
@@ -0,0 +1,56 @@
+use clap::Parser;
+use fts_solver::{Auction, io::AuctionDto};
+use std::ops::Deref as _;
+
+mod io;
+pub use io::*;
+
+mod commands;
+pub use commands::*;
+
+// The top-level arguments -- presently just which subcommand to execute
+#[derive(Parser)]
+#[command(version, about, long_about = None)]
+pub struct BaseArgs {
+    #[command(subcommand)]
+    pub command: Commands,
+}
+
+impl BaseArgs {
+    pub fn evaluate(self) -> anyhow::Result<()> {
+        match self.command {
+            Commands::Solve { io, lib } => {
+                let input = io.read()?;
+                let auction: Auction<_, _, _> =
+                    serde_json::from_reader::<_, AuctionDto>(input)?.try_into()?;
+                let results = lib.solve(auction.deref());
+                let output = io.write()?;
+                serde_json::to_writer_pretty(output, &results)?;
+            }
+            Commands::Export { io, format } => {
+                let input = io.read()?;
+                let auction: Auction<_, _, _> =
+                    serde_json::from_reader::<_, AuctionDto>(input)?.try_into()?;
+
+                let format = if let Some(format) = format {
+                    format
+                } else if let Some(ext) = io.extension() {
+                    ext.parse()?
+                } else {
+                    return Err(CliError::ExportInference)?;
+                };
+
+                let mut output = io.write()?;
+                format.export(auction.deref(), &mut output)?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum CliError {
+    #[error("Unable to infer export format, please specify a valid format")]
+    ExportInference,
+}
diff --git a/ftauction/src/main.rs b/ftauction/src/main.rs
new file mode 100644
index 0000000..6601e4e
--- /dev/null
+++ b/ftauction/src/main.rs
@@ -0,0 +1,7 @@
+use clap::Parser as _;
+use ftauction::BaseArgs;
+
+pub fn main() -> anyhow::Result<()> {
+    let args = BaseArgs::parse();
+    args.evaluate()
+}
diff --git a/fts-solver/src/export.rs b/fts-solver/src/export.rs
new file mode 100644
index 0000000..9c933fa
--- /dev/null
+++ b/fts-solver/src/export.rs
@@ -0,0 +1,336 @@
+use crate::{HashSet, Submission};
+use std::fmt::Display;
+use std::hash::Hash;
+use std::io::Write;
+
+/// Convert a set of flow trading submissions to a quadratic program and export
+/// this program to `.mps` format.
+pub fn export_mps<
+    T,
+    BidderId: Display + Eq + Hash + Clone + Ord,
+    PortfolioId: Display + Eq + Hash + Clone + Ord,
+    ProductId: Display + Eq + Hash + Clone + Ord,
+>(
+    auction: &T,
+    buffer: &mut impl Write,
+) -> Result<(), std::io::Error>
+where
+    for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
+{
+    // MPS is a somewhat archaic format, but is easy enough to generate.
+    // https://www.ibm.com/docs/en/icos/22.1.2?topic=standard-records-in-mps-format
+    // is a good reference.
+
+    writeln!(buffer, "NAME flow_trade_qp")?;
+    writeln!(buffer, "ROWS")?;
+
+    // Our objective is gains-from-trade ("gft")
+    writeln!(buffer, " N    gft")?;
+
+    // We will have one row per product, set equal to zero, which will give us the shadow prices
+    let mut products = auction
+        .into_iter()
+        .flat_map(|(_, submission)| submission.portfolios.values())
+        .flat_map(|portfolio| portfolio.keys())
+        .collect::<HashSet<_>>();
+    products.sort_unstable();
+    for product_id in products.iter() {
+        // The product dual variables are named `p_{product_id}`
+        writeln!(buffer, " E    p_{product_id}")?;
+    }
+
+    // We will also have one row per demand curve, set equal to zero.
+    for (bidder_id, submission) in auction.into_iter() {
+        // The group dual variables are named `g_{bidder_id}_{offset}`
+        for (offset, _) in submission.demand_curves.iter().enumerate() {
+            writeln!(buffer, " E    g_{bidder_id}_{offset}")?;
+        }
+    }
+
+    // We have two sets of variables: the per-product allocations `x`, and
+    // the demand curve segment fills `y`, related such that Ax - Σy = 0,
+    // where the rows of Σ are disjoint 1 vectors.
+    writeln!(buffer, "COLUMNS")?;
+
+    // We begin with this first set x. Notably, these variables *do not* appear in the objective.
+    for (bidder_id, submission) in auction.into_iter() {
+        for (portfolio_id, portfolio) in submission.portfolios.iter() {
+            for (product_id, weight) in portfolio.iter() {
+                writeln!(
+                    buffer,
+                    "    x_{bidder_id}_{portfolio_id}    p_{product_id}    {weight}",
+                )?;
+            }
+
+            for (offset, (group, _)) in submission.demand_curves.iter().enumerate() {
+                if let Some(weight) = group.get(portfolio_id) {
+                    writeln!(
+                        buffer,
+                        "    x_{bidder_id}_{portfolio_id}    g_{bidder_id}_{offset}    {weight}",
+                    )?;
+                }
+            }
+        }
+    }
+
+    // Now the second set y.
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+            for (idx, segment) in curve.iter().enumerate() {
+                // MPS defaults to minimization. Further, the quadratic terms are specified in a
+                // well-supported extension, so we only do the linear terms here.
+                let (_, b) = segment.slope_intercept();
+                writeln!(
+                    buffer,
+                    "    y_{bidder_id}_{offset}_{idx}    gft    {term}    g_{bidder_id}_{offset}    -1",
+                    term = -b
+                )?;
+            }
+        }
+    }
+
+    // Now we specify the domains for each variable.
+    writeln!(buffer, "BOUNDS")?;
+    for (bidder_id, submission) in auction.into_iter() {
+        for portfolio_id in submission.portfolios.keys() {
+            // The allocation variables are unconstrained.
+            writeln!(buffer, " FR BND x_{bidder_id}_{portfolio_id}")?;
+        }
+    }
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+            for (idx, segment) in curve.iter().enumerate() {
+                // The segment variables are bounded above and below (unless infinite)
+                if segment.q0.is_finite() {
+                    writeln!(
+                        buffer,
+                        " LO BND    y_{bidder_id}_{offset}_{idx}    {min}",
+                        min = segment.q0,
+                    )?;
+                } else {
+                    writeln!(buffer, " MI BND    y_{bidder_id}_{offset}_{idx}",)?;
+                }
+                if segment.q1.is_finite() {
+                    writeln!(
+                        buffer,
+                        " UP BND    y_{bidder_id}_{offset}_{idx}    {max}",
+                        max = segment.q1,
+                    )?;
+                } else {
+                    writeln!(buffer, " PL BND    y_{bidder_id}_{offset}_{idx}",)?;
+                }
+            }
+        }
+    }
+
+    // Finally, we leverage the quadratic extension
+    writeln!(buffer, "QUADOBJ")?;
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+            for (idx, segment) in curve.iter().enumerate() {
+                let (m, _) = segment.slope_intercept();
+                writeln!(
+                    buffer,
+                    "    y_{bidder_id}_{offset}_{idx}    y_{bidder_id}_{offset}_{idx}    {term}",
+                    term = -m
+                )?;
+            }
+        }
+    }
+
+    writeln!(buffer, "ENDATA")?;
+    Ok(())
+}
+
+/// Convert a set of flow trading submissions to a quadratic program and export
+/// this program to `.lp` format.
+pub fn export_lp<
+    T,
+    BidderId: Display + Eq + Hash + Clone + Ord,
+    PortfolioId: Display + Eq + Hash + Clone + Ord,
+    ProductId: Display + Eq + Hash + Clone + Ord,
+>(
+    auction: &T,
+    buffer: &mut impl Write,
+) -> Result<(), std::io::Error>
+where
+    for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
+{
+    // Start with the objective section - maximize gains from trade
+    writeln!(buffer, "Maximize")?;
+
+    // Start the objective function (gft)
+    write!(buffer, "  gft: ")?;
+
+    // Flag to track if we've written any terms yet
+    let mut first_term = true;
+    let mut has_quadratic_terms = false;
+    // Add linear terms from the y variables
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+            for (idx, segment) in curve.iter().enumerate() {
+                let (m, b) = segment.slope_intercept();
+                has_quadratic_terms = has_quadratic_terms || m != 0.0;
+                if first_term {
+                    write!(buffer, "{b} y_{bidder_id}_{offset}_{idx}")?;
+                    first_term = false;
+                } else {
+                    write!(buffer, " + {b} y_{bidder_id}_{offset}_{idx}")?;
+                }
+            }
+        }
+    }
+
+    // If no linear terms were written, add a 0
+    if first_term {
+        write!(buffer, "0")?;
+    }
+
+    if has_quadratic_terms {
+        write!(buffer, " + [ ")?;
+
+        // Reset first_term flag for quadratic terms
+        first_term = true;
+
+        for (bidder_id, submission) in auction.into_iter() {
+            for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+                for (idx, segment) in curve.iter().enumerate() {
+                    let (m, _) = segment.slope_intercept();
+                    if first_term {
+                        write!(buffer, "{m} y_{bidder_id}_{offset}_{idx} ^ 2")?;
+                        first_term = false;
+                    } else {
+                        write!(buffer, " + {m} y_{bidder_id}_{offset}_{idx} ^ 2",)?;
+                    }
+                }
+            }
+        }
+
+        write!(buffer, " ] / 2")?;
+    }
+
+    // End the objective line
+    writeln!(buffer)?;
+
+    // Constraints section
+    writeln!(buffer, "Subject To")?;
+
+    // Product constraints (all products must sum to zero)
+    let mut products = auction
+        .into_iter()
+        .flat_map(|(_, submission)| submission.portfolios.values())
+        .flat_map(|portfolio| portfolio.keys())
+        .collect::<HashSet<_>>();
+    products.sort_unstable();
+
+    for product_id in products {
+        // Start the constraint
+        write!(buffer, "  p_{product_id}: ")?;
+
+        // Flag to track if we've written any terms
+        let mut first_term = true;
+
+        // Collect all terms related to this product
+        for (bidder_id, submission) in auction.into_iter() {
+            for (portfolio_id, portfolio) in submission.portfolios.iter() {
+                if let Some(&weight) = portfolio.get(product_id) {
+                    if first_term {
+                        write!(buffer, "{weight} x_{bidder_id}_{portfolio_id}")?;
+                        first_term = false;
+                    } else {
+                        write!(buffer, " + {weight} x_{bidder_id}_{portfolio_id}",)?;
+                    }
+                }
+            }
+        }
+
+        // If no terms were written, add a 0
+        if first_term {
+            write!(buffer, "0")?;
+        }
+
+        // Finish the constraint: = 0
+        writeln!(buffer, " = 0")?;
+    }
+
+    // Demand curve constraints
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (group, curve)) in submission.demand_curves.iter().enumerate() {
+            // Start the constraint
+            write!(buffer, "  g_{bidder_id}_{offset}: ")?;
+
+            // Flag to track if we've written any terms
+            let mut first_term = true;
+
+            // Add terms for the x variables
+            for (portfolio_id, &weight) in group.iter() {
+                if first_term {
+                    write!(buffer, "{weight} x_{bidder_id}_{portfolio_id}")?;
+                    first_term = false;
+                } else {
+                    write!(buffer, " + {weight} x_{bidder_id}_{portfolio_id}",)?;
+                }
+            }
+
+            // Assertion: first_term = false, since groups are non empty
+
+            // Add terms for the y variables (with negative coefficients)
+            for (idx, _) in curve.iter().enumerate() {
+                write!(buffer, " - y_{bidder_id}_{offset}_{idx}")?;
+            }
+
+            // Finish the constraint: = 0
+            writeln!(buffer, " = 0")?;
+        }
+    }
+
+    // Bounds section
+    writeln!(buffer, "Bounds")?;
+
+    // The x variables are unconstrained (free)
+    for (bidder_id, submission) in auction.into_iter() {
+        for portfolio_id in submission.portfolios.keys() {
+            writeln!(buffer, "  x_{bidder_id}_{portfolio_id} free")?;
+        }
+    }
+
+    // The y variables have specific bounds
+    for (bidder_id, submission) in auction.into_iter() {
+        for (offset, (_, curve)) in submission.demand_curves.iter().enumerate() {
+            for (idx, segment) in curve.iter().enumerate() {
+                match (segment.q0.is_finite(), segment.q1.is_finite()) {
+                    (true, true) => {
+                        writeln!(
+                            buffer,
+                            "  {min} <= y_{bidder_id}_{offset}_{idx} <= {max}",
+                            min = segment.q0,
+                            max = segment.q1
+                        )?;
+                    }
+                    (true, false) => {
+                        writeln!(
+                            buffer,
+                            "  y_{bidder_id}_{offset}_{idx} >= {min}",
+                            min = segment.q0
+                        )?;
+                    }
+                    (false, true) => {
+                        writeln!(
+                            buffer,
+                            "  y_{bidder_id}_{offset}_{idx} <= {max}",
+                            max = segment.q1
+                        )?;
+                    }
+                    (false, false) => {
+                        writeln!(buffer, "  y_{bidder_id}_{offset}_{idx} free")?;
+                    }
+                }
+            }
+        }
+    }
+
+    // End the LP file
+    writeln!(buffer, "End")?;
+
+    Ok(())
+}
diff --git a/fts-solver/src/io.rs b/fts-solver/src/io.rs
index 48289bf..ee3b044 100644
--- a/fts-solver/src/io.rs
+++ b/fts-solver/src/io.rs
@@ -1,6 +1,6 @@
 use crate::{Auction, DemandCurve, HashMap, Point, Submission, SubmissionError};
 use serde::{Deserialize, Deserializer, Serialize};
-use std::{hash::Hash, iter, ops::Deref};
+use std::{fmt, hash::Hash, iter, ops::Deref};
 
 // First order of business: create some newtype wrappers for the various primitives.
 
@@ -10,6 +10,12 @@ macro_rules! string_wrapper {
         #[serde(transparent)]
         #[doc = concat!("A newtype wrapper for ", stringify!($struct))]
         pub struct $struct(String);
+
+        impl fmt::Display for $struct {
+            fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                write!(f, "{}", self.0)
+            }
+        }
     };
 }
 
diff --git a/fts-solver/src/lib.rs b/fts-solver/src/lib.rs
index c2d973f..381b72d 100644
--- a/fts-solver/src/lib.rs
+++ b/fts-solver/src/lib.rs
@@ -22,6 +22,9 @@ pub use types::*;
 #[cfg(feature = "io")]
 pub mod io;
 
+/// Utilities for converting the derived QP to standard file formats
+pub mod export;
+
 // For reproducibility, we need explicitly ordered semantics in our collections.
 // Accordingly, we swap out the stdlib collections for those provided by `indexmap`.
 // Since we're swapping out these types already, we can benefit from a hash function
diff --git a/fts-solver/src/types/demand/disaggregate.rs b/fts-solver/src/types/demand/disaggregate.rs
index c3400d0..97cb8c8 100644
--- a/fts-solver/src/types/demand/disaggregate.rs
+++ b/fts-solver/src/types/demand/disaggregate.rs
@@ -280,4 +280,22 @@ mod tests {
 
         assert_eq!(segments, answer);
     }
+
+    #[test]
+    fn check_slope() {
+        let points = vec![
+            Point {
+                quantity: -2.0,
+                price: 4.0,
+            },
+            Point {
+                quantity: 2.0,
+                price: 5.0,
+            },
+        ];
+        let segments = disaggregate(points.into_iter(), -1.0, 1.0)
+            .unwrap()
+            .collect::<Result<Vec<_>, _>>();
+        assert!(segments.is_err());
+    }
 }
diff --git a/fts-solver/tests/all_solvers.rs b/fts-solver/tests/all_solvers.rs
deleted file mode 100644
index 000f4f8..0000000
--- a/fts-solver/tests/all_solvers.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-#![allow(unused_macros)]
-use rstest_reuse::template;
-
-// This creates a testing "template" to allow for the injection of each solver
-// implementation
-
-#[template]
-#[rstest]
-#[case::clarabel(fts_solver::clarabel::ClarabelSolver::default())]
-#[case::osqp(fts_solver::osqp::OsqpSolver::default())]
-pub fn all_solvers<PortfolioId, ProductId>(#[case] solver: impl solver::Solver) -> () {}
diff --git a/fts-solver/tests/samples.rs b/fts-solver/tests/samples.rs
index 989bd0d..3706047 100644
--- a/fts-solver/tests/samples.rs
+++ b/fts-solver/tests/samples.rs
@@ -1,11 +1,27 @@
 use approx::assert_abs_diff_eq;
-use fts_solver::{Auction, AuctionOutcome, io::AuctionDto};
+use fts_solver::{
+    Auction, AuctionOutcome,
+    export::{export_lp, export_mps},
+    io::AuctionDto,
+};
 use rstest::*;
 use rstest_reuse::{self, *};
-use std::{fmt::Debug, fs::File, io::BufReader, ops::Deref as _, path::PathBuf};
+use std::{
+    fmt::Debug,
+    fs::File,
+    io::{BufReader, Read},
+    ops::Deref,
+    path::PathBuf,
+};
 
-mod all_solvers;
-use all_solvers::all_solvers;
+// This creates a testing "template" to allow for the injection of each solver
+// implementation
+
+#[template]
+#[rstest]
+#[case::clarabel(fts_solver::clarabel::ClarabelSolver::default())]
+#[case::osqp(fts_solver::osqp::OsqpSolver::default())]
+pub fn all_solvers<PortfolioId, ProductId>(#[case] solver: impl solver::Solver) -> () {}
 
 // This test case is actually a dynamically generated, Cartesian product of test cases.
 // For every solver implementation, and for every (input.json, output.json) pair in `./samples/**`,
@@ -21,10 +37,10 @@ use all_solvers::all_solvers;
 #[rstest]
 fn run_auction(
     solver: impl fts_solver::Solver,
-    #[files("tests/samples/**/input.json")] input: PathBuf,
+    #[files("tests/samples/**/output.json")] output: PathBuf,
 ) {
-    let mut output = input.clone();
-    output.set_file_name("output.json");
+    let mut input = output.clone();
+    input.set_file_name("input.json");
 
     let raw_auction: AuctionDto =
         serde_json::from_reader(BufReader::new(File::open(input).unwrap())).unwrap();
@@ -39,6 +55,46 @@ fn run_auction(
     cmp(&solution, &reference, 1e-6, 1e-6);
 }
 
+#[rstest]
+fn check_mps_export(#[files("tests/samples/**/export.mps")] output: PathBuf) {
+    let mut input = output.clone();
+    input.set_file_name("input.json");
+
+    let raw_auction: AuctionDto =
+        serde_json::from_reader(BufReader::new(File::open(input).unwrap())).unwrap();
+
+    let auction: Auction<_, _, _> = raw_auction.try_into().unwrap();
+
+    let mut output_bytes = Vec::new();
+    let output_size = File::open(output)
+        .unwrap()
+        .read_to_end(&mut output_bytes)
+        .unwrap();
+    let mut export_bytes = Vec::with_capacity(output_size);
+    export_mps(auction.deref(), &mut export_bytes).unwrap();
+    assert!(output_bytes == export_bytes, "mps files are not identical");
+}
+
+#[rstest]
+fn check_lp_export(#[files("tests/samples/**/export.lp")] output: PathBuf) {
+    let mut input = output.clone();
+    input.set_file_name("input.json");
+
+    let raw_auction: AuctionDto =
+        serde_json::from_reader(BufReader::new(File::open(input).unwrap())).unwrap();
+
+    let auction: Auction<_, _, _> = raw_auction.try_into().unwrap();
+
+    let mut output_bytes = Vec::new();
+    let output_size = File::open(output)
+        .unwrap()
+        .read_to_end(&mut output_bytes)
+        .unwrap();
+    let mut export_bytes = Vec::with_capacity(output_size);
+    export_lp(auction.deref(), &mut export_bytes).unwrap();
+    assert!(output_bytes == export_bytes, "mps files are not identical");
+}
+
 fn cmp<BidderId: Debug + Eq, PortfolioId: Debug + Eq, ProductId: Debug + Eq>(
     a: &AuctionOutcome<BidderId, PortfolioId, ProductId>,
     b: &AuctionOutcome<BidderId, PortfolioId, ProductId>,
diff --git a/fts-solver/tests/samples/basic_auction/export.lp b/fts-solver/tests/samples/basic_auction/export.lp
new file mode 100644
index 0000000..7b90090
--- /dev/null
+++ b/fts-solver/tests/samples/basic_auction/export.lp
@@ -0,0 +1,21 @@
+Maximize
+  gft: 10 y_buyer_0_0 + 10 y_buyer_1_0 + 0 y_buyer_2_0 + 6.25 y_seller_0_0 + 6.25 y_seller_1_0 + [ -5 y_buyer_0_0 ^ 2 + -5 y_buyer_1_0 ^ 2 + 0 y_buyer_2_0 ^ 2 + 0 y_seller_0_0 ^ 2 + 0 y_seller_1_0 ^ 2 ] / 2
+Subject To
+  p_A: 1 x_buyer_X + 1 x_seller_X = 0
+  p_B: 1 x_buyer_Y + 1 x_seller_Y = 0
+  g_buyer_0: 1 x_buyer_X - y_buyer_0_0 = 0
+  g_buyer_1: 1 x_buyer_Y - y_buyer_1_0 = 0
+  g_buyer_2: 1 x_buyer_X + 1 x_buyer_Y - y_buyer_2_0 = 0
+  g_seller_0: 1 x_seller_X - y_seller_0_0 = 0
+  g_seller_1: 1 x_seller_Y - y_seller_1_0 = 0
+Bounds
+  x_buyer_X free
+  x_buyer_Y free
+  x_seller_X free
+  x_seller_Y free
+  0 <= y_buyer_0_0 <= 1
+  0 <= y_buyer_1_0 <= 1
+  0 <= y_buyer_2_0 <= 1
+  -1 <= y_seller_0_0 <= 0
+  -1 <= y_seller_1_0 <= 0
+End
diff --git a/fts-solver/tests/samples/basic_auction/export.mps b/fts-solver/tests/samples/basic_auction/export.mps
new file mode 100644
index 0000000..5d4dcac
--- /dev/null
+++ b/fts-solver/tests/samples/basic_auction/export.mps
@@ -0,0 +1,48 @@
+NAME flow_trade_qp
+ROWS
+ N    gft
+ E    p_A
+ E    p_B
+ E    g_buyer_0
+ E    g_buyer_1
+ E    g_buyer_2
+ E    g_seller_0
+ E    g_seller_1
+COLUMNS
+    x_buyer_X    p_A    1
+    x_buyer_X    g_buyer_0    1
+    x_buyer_X    g_buyer_2    1
+    x_buyer_Y    p_B    1
+    x_buyer_Y    g_buyer_1    1
+    x_buyer_Y    g_buyer_2    1
+    x_seller_X    p_A    1
+    x_seller_X    g_seller_0    1
+    x_seller_Y    p_B    1
+    x_seller_Y    g_seller_1    1
+    y_buyer_0_0    gft    -10    g_buyer_0    -1
+    y_buyer_1_0    gft    -10    g_buyer_1    -1
+    y_buyer_2_0    gft    -0    g_buyer_2    -1
+    y_seller_0_0    gft    -6.25    g_seller_0    -1
+    y_seller_1_0    gft    -6.25    g_seller_1    -1
+BOUNDS
+ FR BND x_buyer_X
+ FR BND x_buyer_Y
+ FR BND x_seller_X
+ FR BND x_seller_Y
+ LO BND    y_buyer_0_0    0
+ UP BND    y_buyer_0_0    1
+ LO BND    y_buyer_1_0    0
+ UP BND    y_buyer_1_0    1
+ LO BND    y_buyer_2_0    0
+ UP BND    y_buyer_2_0    1
+ LO BND    y_seller_0_0    -1
+ UP BND    y_seller_0_0    0
+ LO BND    y_seller_1_0    -1
+ UP BND    y_seller_1_0    0
+QUADOBJ
+    y_buyer_0_0    y_buyer_0_0    5
+    y_buyer_1_0    y_buyer_1_0    5
+    y_buyer_2_0    y_buyer_2_0    -0
+    y_seller_0_0    y_seller_0_0    -0
+    y_seller_1_0    y_seller_1_0    -0
+ENDATA
diff --git a/fts-solver/tests/samples/trivial_auction/export.lp b/fts-solver/tests/samples/trivial_auction/export.lp
new file mode 100644
index 0000000..6b14034
--- /dev/null
+++ b/fts-solver/tests/samples/trivial_auction/export.lp
@@ -0,0 +1,12 @@
+Maximize
+  gft: 10 y_buyer_0_0 + 7.5 y_seller_0_0 + [ -5 y_buyer_0_0 ^ 2 + 0 y_seller_0_0 ^ 2 ] / 2
+Subject To
+  p_A: 1 x_buyer_X + 1 x_seller_X = 0
+  g_buyer_0: 1 x_buyer_X - y_buyer_0_0 = 0
+  g_seller_0: 1 x_seller_X - y_seller_0_0 = 0
+Bounds
+  x_buyer_X free
+  x_seller_X free
+  0 <= y_buyer_0_0 <= 1
+  -1 <= y_seller_0_0 <= 0
+End
diff --git a/fts-solver/tests/samples/trivial_auction/export.mps b/fts-solver/tests/samples/trivial_auction/export.mps
new file mode 100644
index 0000000..d6886c1
--- /dev/null
+++ b/fts-solver/tests/samples/trivial_auction/export.mps
@@ -0,0 +1,24 @@
+NAME flow_trade_qp
+ROWS
+ N    gft
+ E    p_A
+ E    g_buyer_0
+ E    g_seller_0
+COLUMNS
+    x_buyer_X    p_A    1
+    x_buyer_X    g_buyer_0    1
+    x_seller_X    p_A    1
+    x_seller_X    g_seller_0    1
+    y_buyer_0_0    gft    -10    g_buyer_0    -1
+    y_seller_0_0    gft    -7.5    g_seller_0    -1
+BOUNDS
+ FR BND x_buyer_X
+ FR BND x_seller_X
+ LO BND    y_buyer_0_0    0
+ UP BND    y_buyer_0_0    1
+ LO BND    y_seller_0_0    -1
+ UP BND    y_seller_0_0    0
+QUADOBJ
+    y_buyer_0_0    y_buyer_0_0    5
+    y_seller_0_0    y_seller_0_0    -0
+ENDATA
diff --git a/ftsolve/src/main.rs b/ftsolve/src/main.rs
deleted file mode 100644
index 59324a4..0000000
--- a/ftsolve/src/main.rs
+++ /dev/null
@@ -1,102 +0,0 @@
-use clap::{Parser, ValueEnum};
-use fts_solver::{
-    Auction, AuctionOutcome, Solver as _, Submission,
-    clarabel::ClarabelSolver,
-    io::{AuctionDto, BidderId, PortfolioId, ProductId},
-    osqp::OsqpSolver,
-};
-use std::{
-    fmt::Display,
-    fs::File,
-    io::{BufReader, BufWriter, stdin, stdout},
-    ops::Deref,
-    path::PathBuf,
-};
-
-#[derive(Parser)]
-#[command(version, about, long_about = None)]
-struct Args {
-    /// The JSON file to read (defaults to stdin if omitted)
-    #[arg(short, long)]
-    input: Option<PathBuf>,
-
-    /// The JSON file to write (defaults to stdout if omitted)
-    #[arg(short, long)]
-    output: Option<PathBuf>,
-
-    /// The underlying solver implementation to use
-    #[arg(short, long, default_value_t = SolverFlag::Clarabel)]
-    solver: SolverFlag,
-}
-
-#[derive(Clone, ValueEnum)]
-enum SolverFlag {
-    Clarabel,
-    Osqp,
-}
-
-impl SolverFlag {
-    fn solve<T>(&self, auction: &T) -> AuctionOutcome<BidderId, PortfolioId, ProductId>
-    where
-        for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
-    {
-        match self {
-            SolverFlag::Clarabel => {
-                let solver = ClarabelSolver::default();
-                solver.solve(auction)
-            }
-            SolverFlag::Osqp => {
-                let solver = OsqpSolver::default();
-                solver.solve(auction)
-            }
-        }
-    }
-}
-
-impl Display for SolverFlag {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        (match self {
-            Self::Clarabel => "clarabel",
-            Self::Osqp => "osqp",
-        })
-        .fmt(f)
-    }
-}
-
-impl Args {
-    fn read(&self) -> anyhow::Result<AuctionDto> {
-        if let Some(path) = &self.input {
-            let reader = BufReader::new(File::open(path)?);
-            Ok(serde_json::from_reader(reader)?)
-        } else {
-            let reader = stdin().lock();
-            Ok(serde_json::from_reader(reader)?)
-        }
-    }
-
-    fn write(
-        &self,
-        outcome: &AuctionOutcome<BidderId, PortfolioId, ProductId>,
-    ) -> anyhow::Result<()> {
-        if let Some(path) = &self.output {
-            let writer = BufWriter::new(File::create(path)?);
-            serde_json::to_writer_pretty(writer, outcome)?;
-        } else {
-            let writer = stdout().lock();
-            serde_json::to_writer_pretty(writer, outcome)?;
-        }
-        Ok(())
-    }
-}
-
-pub fn main() -> anyhow::Result<()> {
-    let args = Args::parse();
-
-    let raw = args.read()?;
-    let input: Auction<_, _, _> = raw.try_into()?;
-
-    let output = args.solver.solve(input.deref());
-    args.write(&output)?;
-
-    Ok(())
-}


Reviews:
user: login: islerfab
body: Looks good to me for the export part.

> determine optimal way to rename the binary in cargo and release-plz

I would assume you cannot rename a binary. This will simply publish a fresh crate called `ftauction`, and never touch the `ftsolve` auction again. Which I think is fine. Maybe we could spend a couple of minutes discussing the naming, also together with the upcoming demo binary crate, to avoid such renames.

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T13:43:35Z
user: login: asymptoticgroup
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:14:25Z
user: login: islerfab
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:39:36Z
user: login: asymptoticgroup
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:43:55Z

Review comments:
diff_hunk: @@ -29,11 +29,12 @@ rstest_reuse = { workspace = true }
 serde_json = { workspace = true }
 
 # enable all the features for testing purposes
-fts-solver = { path = ".", features = ["clarabel", "osqp", "io"] }
+fts-solver = { path = ".", features = ["clarabel", "osqp", "io", "export"] }
path: fts-solver/Cargo.toml
user: login: islerfab
body: would it make sense to define the `io` and `export` features in the default?
created_at: 2025-04-17T13:39:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,11 +29,12 @@ rstest_reuse = { workspace = true }
 serde_json = { workspace = true }
 
 # enable all the features for testing purposes
-fts-solver = { path = ".", features = ["clarabel", "osqp", "io"] }
+fts-solver = { path = ".", features = ["clarabel", "osqp", "io", "export"] }
path: fts-solver/Cargo.toml
user: login: asymptoticgroup
body: I am admittedly a little drunk on power with Cargo feature flags. Since Rust compile times have bad tail behavior, it is common to hide code in a library that may not be used by all behind a flag, so that it is never compiled at all. (See, for example, all the "derive" macros a lot of big ecosystem packages provide.)

It makes sense for clarabel, osqp to be behind flags as those are big deps. To some degree, this makes sense as well for `io` as io requires serde, which is optional (and rightfully so, I think). However, `export` requires no features and has no deps (it's just standard file IO and the core fts-solver objects), and is itself fairly small. So it would be perfectly reasonable to eliminate the `export` flag and just include it unconditionally.
created_at: 2025-04-17T15:14:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,11 +29,12 @@ rstest_reuse = { workspace = true }
 serde_json = { workspace = true }
 
 # enable all the features for testing purposes
-fts-solver = { path = ".", features = ["clarabel", "osqp", "io"] }
+fts-solver = { path = ".", features = ["clarabel", "osqp", "io", "export"] }
path: fts-solver/Cargo.toml
user: login: islerfab
body: Agreed! Let's keep features that imply pulling a larger set of code and/or dependencies. I think `export` would only be a feature if e.g. we need some extensive file handling stuff, which we don't.
created_at: 2025-04-17T15:39:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,11 +29,12 @@ rstest_reuse = { workspace = true }
 serde_json = { workspace = true }
 
 # enable all the features for testing purposes
-fts-solver = { path = ".", features = ["clarabel", "osqp", "io"] }
+fts-solver = { path = ".", features = ["clarabel", "osqp", "io", "export"] }
path: fts-solver/Cargo.toml
user: login: asymptoticgroup
body: Done
created_at: 2025-04-17T15:43:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: asymptoticgroup
created_at: 2025-04-17T02:07:22Z
body: I have a working MPS export. Tests forth-coming, but a helpful way to check work:

Use `Julia`, ensure `JuMP` and `OSQP` are installed. Then:
```julia
using JuMP
using OSQP

model = read_from_file("test.mps")
set_optimizer(model, OSQP.Optimizer)
optimize!(model)
[var => value(var) for var in all_variables(model)]
```
This tests the "validity" of the file (can it be imported?) as well as the correctness (did the file lead to the correct output?).
author_association: CONTRIBUTOR
user: login: asymptoticgroup
created_at: 2025-04-18T01:30:13Z
body: On renaming crate:

I think we just need to pull the trigger and see what happens with the release.


On testing:

I slightly changed the `samples` directory approach: if there an output.json file, this means to run a "solution test" assuming the existence of a neighboring input.json. if there is an export.mps, this means to run an "export test" assuming the existence of a neighboring input.json. Etc

So if we don't drop export files in a sample, there are no export tests to run. I'm not convinced this is optimal (and checking for identical file output is stricter than a good test calls for, but the complexity of doing something else is large) -- but if we do hack on the export code and see that the file outputs have changed, this is a strong signal to at least apply a set of eyes on the difference. Which is a good outcome.
author_association: CONTRIBUTOR
user: login: islerfab
created_at: 2025-04-18T16:37:44Z
body: @asymptoticgroup this is awesome. I like the pragmatic and effective testing approach that way. If we need to adapt in the future because the samples are used in other contexts that don't work with the current testing, then we simply change it.

Two minor points to consider:
* I went ahead and just tried out the CLI in a way I would try others ("who needs the --help flag, it will tell me what's wrong if I just type `ftauction solve`", and then I end up in a state where something is expected from me and I don't know what. I don't know if I'm representative of a large group of people, most people would carefully ask for help first. If you wanted to optimize this for me, I would do one of the following:
  * Print a quick message that the user expected to write something now, and if that's unintended, go out and use --input or maybe even --help
  * Make the `--input` argument mandatory for `solve`, unless someone writes `--stdin` (I do expect this to be a tiny fraction of people wanting this, or maybe I'm wrong?
* Your last commits are great and shouldn't be squashed, they give a good changelog. The earlier commits can probably be squashed before merging, and named something like `feat: add export command for mps and lp format`, or anything like that.
author_association: CONTRIBUTOR