Skip to content

Commit d72bb33

Browse files
authored
Merge pull request #43 from wspringer/feature/quadratic-objectives-26
Add support for quadratic objectives in convex QP problems
2 parents 631e133 + 2528446 commit d72bb33

File tree

9 files changed

+615
-24
lines changed

9 files changed

+615
-24
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
default: minor
3+
---
4+
5+
# Add quadratic objective support for convex QP problems
6+
7+
Added support for quadratic programming (QP) problems, allowing optimization of quadratic objectives of the form `minimize c^T x + 0.5 x^T Q x`. The quadratic matrix Q can be specified in either dense or sparse format, and must be positive semidefinite for convexity. This enhancement enables solving portfolio optimization, least squares, and other convex QP problems. Note that mixed-integer quadratic programming (MIQP) is not supported - only continuous variables are allowed with quadratic objectives.

README.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This MCP server exposes the HiGHS optimization solver through a standardized int
1212

1313
- Linear Programming (LP) problems
1414
- Mixed-Integer Programming (MIP) problems
15+
- Quadratic Programming (QP) problems for convex objectives
1516
- Binary and integer variable constraints
1617
- Multi-objective optimization
1718

@@ -91,7 +92,19 @@ The server provides a single tool: `optimize-mip-lp-tool`
9192
problem: {
9293
sense: 'minimize' | 'maximize',
9394
objective: {
94-
linear: number[] // Coefficients for each variable
95+
linear?: number[], // Linear coefficients (optional if quadratic is provided)
96+
quadratic?: { // Quadratic terms for convex QP (optional)
97+
// Dense format:
98+
dense?: number[][] // Symmetric positive semidefinite matrix Q
99+
100+
// OR Sparse format:
101+
sparse?: {
102+
rows: number[], // Row indices (0-indexed)
103+
cols: number[], // Column indices (0-indexed)
104+
values: number[], // Values of Q matrix
105+
shape: [number, number] // [num_variables, num_variables]
106+
}
107+
}
95108
},
96109
variables: Array<{
97110
name?: string, // Variable name (optional, defaults to x1, x2, etc.)
@@ -174,6 +187,13 @@ The server provides a single tool: `optimize-mip-lp-tool`
174187
}
175188
```
176189

190+
### Notes on Quadratic Programming (QP)
191+
192+
- **Convex QP only**: The quadratic matrix Q must be positive semidefinite
193+
- **Continuous variables only**: Integer/binary variables are not supported with quadratic objectives (no MIQP)
194+
- **Format**: Objective function is: minimize c^T x + 0.5 x^T Q x
195+
- **Matrix specification**: When specifying Q, values should be doubled to account for the 0.5 factor
196+
177197
## Use Cases
178198

179199
### 1. Production Planning
@@ -266,7 +286,42 @@ Optimize investment allocation with risk constraints:
266286
}
267287
```
268288

269-
### 4. Resource Allocation
289+
### 4. Portfolio Optimization with Risk (Quadratic Programming)
290+
291+
Minimize portfolio risk (variance) while achieving target return:
292+
293+
```javascript
294+
{
295+
problem: {
296+
sense: 'minimize',
297+
objective: {
298+
// Quadratic: minimize portfolio variance (risk)
299+
quadratic: {
300+
dense: [ // Covariance matrix (×2 for 0.5 factor)
301+
[0.2, 0.04, 0.02],
302+
[0.04, 0.1, 0.04],
303+
[0.02, 0.04, 0.16]
304+
]
305+
}
306+
},
307+
variables: [
308+
{ name: 'Stock_A', lb: 0 },
309+
{ name: 'Stock_B', lb: 0 },
310+
{ name: 'Stock_C', lb: 0 }
311+
],
312+
constraints: {
313+
dense: [
314+
[1, 1, 1], // Sum of weights = 1
315+
[0.1, 0.12, 0.08] // Expected return >= target
316+
],
317+
sense: ['=', '>='],
318+
rhs: [1, 0.1] // 100% allocation, min 10% return
319+
}
320+
}
321+
}
322+
```
323+
324+
### 5. Resource Allocation
270325

271326
Optimize resource allocation across projects with integer constraints:
272327

src/decode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,11 @@ export function decode(
8181
const solutionValues: number[] = [];
8282
const dualValues: number[] = [];
8383

84+
// Determine number of variables
85+
const numVars = problem.variables.length;
86+
8487
// Iterate through variables in the same order as defined in the problem
85-
for (let i = 0; i < problem.objective.linear.length; i++) {
88+
for (let i = 0; i < numVars; i++) {
8689
// Use custom name if provided, otherwise default to x1, x2, etc.
8790
const varName = problem.variables[i].name || `x${i + 1}`;
8891
const column = result.Columns[varName];

src/encode.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,84 @@ function formatObjective(
119119
const terms: string[] = [];
120120
let hasTerms = false;
121121

122-
for (let i = 0; i < objective.linear.length; i++) {
123-
const coeff = objective.linear[i];
124-
const varName = variables[i].name || `x${i + 1}`;
125-
const term = formatCoefficient(coeff, varName, !hasTerms);
126-
if (term) {
127-
terms.push(term);
128-
hasTerms = true;
122+
// Add linear terms
123+
if (objective.linear) {
124+
for (let i = 0; i < objective.linear.length; i++) {
125+
const coeff = objective.linear[i];
126+
const varName = variables[i].name || `x${i + 1}`;
127+
const term = formatCoefficient(coeff, varName, !hasTerms);
128+
if (term) {
129+
terms.push(term);
130+
hasTerms = true;
131+
}
132+
}
133+
}
134+
135+
// Add quadratic terms if present
136+
if (objective.quadratic) {
137+
const quadTerms: string[] = [];
138+
139+
if ("sparse" in objective.quadratic) {
140+
// Process sparse format
141+
const { rows, cols, values } = objective.quadratic.sparse;
142+
for (let idx = 0; idx < values.length; idx++) {
143+
const i = rows[idx];
144+
const j = cols[idx];
145+
const value = values[idx];
146+
147+
if (value !== 0) {
148+
const varI = variables[i].name || `x${i + 1}`;
149+
const varJ = variables[j].name || `x${j + 1}`;
150+
151+
if (i === j) {
152+
// Diagonal term: value * xi^2
153+
quadTerms.push(value === 1 ? `${varI}^2` : `${value} ${varI}^2`);
154+
} else {
155+
// Off-diagonal term: value * xi * xj
156+
// Note: HiGHS expects symmetric matrix, so we only include each term once
157+
quadTerms.push(value === 1 ? `${varI} * ${varJ}` : `${value} ${varI} * ${varJ}`);
158+
}
159+
}
160+
}
161+
} else if ("dense" in objective.quadratic) {
162+
// Process dense format
163+
const matrix = objective.quadratic.dense;
164+
for (let i = 0; i < matrix.length; i++) {
165+
for (let j = i; j < matrix[i].length; j++) {
166+
const value = i === j ? matrix[i][j] : matrix[i][j] + matrix[j][i]; // Sum symmetric entries
167+
168+
if (value !== 0) {
169+
const varI = variables[i].name || `x${i + 1}`;
170+
const varJ = variables[j].name || `x${j + 1}`;
171+
172+
if (i === j) {
173+
// Diagonal term: value * xi^2
174+
quadTerms.push(value === 1 ? `${varI}^2` : `${value} ${varI}^2`);
175+
} else {
176+
// Off-diagonal term: value * xi * xj
177+
quadTerms.push(value === 1 ? `${varI} * ${varJ}` : `${value} ${varI} * ${varJ}`);
178+
}
179+
}
180+
}
181+
}
182+
}
183+
184+
// Add quadratic terms in HiGHS format: [ quadratic_terms ] / 2
185+
if (quadTerms.length > 0) {
186+
if (hasTerms) {
187+
terms.push("+ [ " + quadTerms.join(" + ") + " ] / 2");
188+
} else {
189+
terms.push("[ " + quadTerms.join(" + ") + " ] / 2");
190+
hasTerms = true;
191+
}
129192
}
130193
}
131194

195+
// If no terms at all, add a zero
196+
if (!hasTerms) {
197+
terms.push("0");
198+
}
199+
132200
result += terms.join(" ") + "\n";
133201
return result;
134202
}

src/index.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ describe("HiGHS MCP Server", () => {
152152
expect(response.result.tools).toHaveLength(1);
153153
expect(response.result.tools[0].name).toBe("optimize-mip-lp-tool");
154154
expect(response.result.tools[0].description).toContain("HiGHS solver");
155+
expect(response.result.tools[0].description).toContain("quadratic programming (QP)");
156+
expect(response.result.tools[0].description).toContain("convex quadratic objectives");
157+
expect(response.result.tools[0].description).toContain("continuous variables (no MIQP)");
155158
});
156159

157160
it("should include comprehensive schema documentation", async () => {
@@ -178,10 +181,10 @@ describe("HiGHS MCP Server", () => {
178181
expect(constraintsSchema.anyOf).toBeDefined();
179182
expect(constraintsSchema.anyOf).toHaveLength(2);
180183

181-
// Check that minimum constraints are properly exposed
182-
expect(
183-
tool.inputSchema.properties.problem.properties.objective.properties.linear.minItems,
184-
).toBe(1);
184+
// Check that objective supports both linear and quadratic
185+
const objectiveSchema = tool.inputSchema.properties.problem.properties.objective;
186+
expect(objectiveSchema.properties.linear).toBeDefined();
187+
expect(objectiveSchema.properties.quadratic).toBeDefined();
185188
});
186189
});
187190

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3434
{
3535
name: TOOL_NAME,
3636
description:
37-
"Solve linear programming (LP) or mixed-integer programming (MIP) problems using HiGHS solver",
37+
"Solve linear programming (LP), mixed-integer programming (MIP), or quadratic programming (QP) problems using HiGHS solver. " +
38+
"QP support includes convex quadratic objectives (minimize c^T x + 0.5 x^T Q x) where Q must be positive semidefinite. " +
39+
"Limitations: QP only supports continuous variables (no MIQP), and when specifying Q matrix values should be doubled to account for the 0.5 factor.",
3840
inputSchema: zodToJsonSchema(OptimizationArgsSchema, {
3941
$refStrategy: "none",
4042
}),

src/schemas.ts

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,88 @@ export const CompactVariableSchema = z.object({
2222
),
2323
});
2424

25-
export const ObjectiveSchema = z.object({
26-
linear: z
27-
.array(z.number())
28-
.min(1, "At least one objective coefficient is required")
29-
.describe(
30-
"Linear coefficients for each variable. The length of this array defines the number of variables in the problem. All other arrays (constraint matrix rows, variable bounds, types, and names) must match this length.",
31-
),
25+
// Quadratic matrix schema (reusing SparseMatrixSchema pattern)
26+
const QuadraticMatrixSchema = z.object({
27+
rows: z
28+
.array(z.number().int().nonnegative())
29+
.describe("Row indices of quadratic matrix (0-indexed)"),
30+
cols: z
31+
.array(z.number().int().nonnegative())
32+
.describe("Column indices of quadratic matrix (0-indexed)"),
33+
values: z.array(z.number()).describe("Values of quadratic matrix Q"),
34+
shape: z
35+
.tuple([z.number().int().positive(), z.number().int().positive()])
36+
.describe("[num_variables, num_variables] dimensions of Q matrix"),
3237
});
3338

39+
// Dense quadratic schema
40+
const DenseQuadraticSchema = z.object({
41+
dense: z.array(z.array(z.number())).describe("Dense symmetric matrix Q for quadratic objective"),
42+
});
43+
44+
// Sparse quadratic schema
45+
const SparseQuadraticSchema = z.object({
46+
sparse: QuadraticMatrixSchema.describe(
47+
"Sparse matrix Q in COO (Coordinate) format - only specify non-zero values with their positions",
48+
),
49+
});
50+
51+
// Union of dense and sparse quadratic formats
52+
const QuadraticObjectiveSchema = z
53+
.union([DenseQuadraticSchema, SparseQuadraticSchema])
54+
.refine((data) => {
55+
if ("dense" in data) {
56+
const matrix = data.dense;
57+
return (
58+
matrix.length > 0 && // Non-empty
59+
matrix.length === matrix[0].length && // Square matrix
60+
matrix.every((row) => row.length === matrix.length)
61+
); // All rows same length
62+
} else {
63+
const sparse = data.sparse;
64+
return (
65+
sparse.shape[0] === sparse.shape[1] && // Must be square
66+
sparse.rows.length === sparse.cols.length && // Array lengths match
67+
sparse.rows.length === sparse.values.length && // Array lengths match
68+
sparse.rows.every((r) => r < sparse.shape[0]) && // Row indices in bounds
69+
sparse.cols.every((c) => c < sparse.shape[1])
70+
); // Col indices in bounds
71+
}
72+
}, "Quadratic matrix must be square with valid dimensions");
73+
74+
export const ObjectiveSchema = z
75+
.object({
76+
linear: z
77+
.array(z.number())
78+
.optional()
79+
.describe(
80+
"Linear coefficients for each variable (c in: minimize c^T x + 0.5 x^T Q x). If not provided but quadratic is present, defaults to zeros. The length defines the number of variables when quadratic is not present.",
81+
),
82+
quadratic: QuadraticObjectiveSchema.optional().describe(
83+
"Quadratic terms Q for convex QP (minimize 0.5 x^T Q x + c^T x). Q must be positive semidefinite. Supports both sparse and dense formats.",
84+
),
85+
})
86+
.refine(
87+
(data) => {
88+
// At least one of linear or quadratic must be present
89+
if (!data.linear && !data.quadratic) {
90+
return false;
91+
}
92+
// If both are present, check dimensions match
93+
if (data.linear && data.quadratic) {
94+
const numVars = data.linear.length;
95+
const qSize =
96+
"dense" in data.quadratic ? data.quadratic.dense.length : data.quadratic.sparse.shape[0];
97+
return numVars === qSize;
98+
}
99+
return true;
100+
},
101+
{
102+
message:
103+
"At least one of 'linear' or 'quadratic' must be provided. When both are present, dimensions must match.",
104+
},
105+
);
106+
34107
// Sparse matrix schema
35108
export const SparseMatrixSchema = z.object({
36109
rows: z
@@ -131,8 +204,35 @@ export const ProblemSchema = z
131204
),
132205
})
133206
.superRefine((data, ctx) => {
134-
// Ensure matrix dimensions are consistent
135-
const numVars = data.objective.linear.length;
207+
// Determine number of variables from objective
208+
let numVars: number;
209+
if (data.objective.linear) {
210+
numVars = data.objective.linear.length;
211+
} else if (data.objective.quadratic) {
212+
numVars =
213+
"dense" in data.objective.quadratic
214+
? data.objective.quadratic.dense.length
215+
: data.objective.quadratic.sparse.shape[0];
216+
} else {
217+
// This should be caught by ObjectiveSchema refinement, but just in case
218+
ctx.addIssue({
219+
code: z.ZodIssueCode.custom,
220+
message: "Cannot determine number of variables from objective",
221+
path: ["objective"],
222+
});
223+
return;
224+
}
225+
226+
// Check for QP with integer variables
227+
if (data.objective.quadratic && data.variables.some((v) => v.type && v.type !== "cont")) {
228+
ctx.addIssue({
229+
code: z.ZodIssueCode.custom,
230+
message:
231+
"Quadratic objectives are not supported with integer or binary variables (MIQP not supported by HiGHS)",
232+
path: ["objective", "quadratic"],
233+
});
234+
}
235+
136236
let numConstraints: number;
137237

138238
// Handle both dense and sparse formats

0 commit comments

Comments
 (0)