Skip to content

Commit f1d9cc9

Browse files
authored
Merge pull request #99 from CodingThrust/feat/expr-overhead-system
Replace Polynomial overhead system with Expr AST
2 parents 9671215 + cc8bebb commit f1d9cc9

85 files changed

Lines changed: 1487 additions & 1408 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,7 @@ Problem (core trait — all problems must implement)
6767
├── fn dims(&self) -> Vec<usize> // config space: [2, 2, 2] for 3 binary variables
6868
├── fn evaluate(&self, config) -> Metric
6969
├── fn variant() -> Vec<(&str, &str)> // e.g., [("graph","SimpleGraph"), ("weight","i32")]
70-
├── fn num_variables(&self) -> usize // default: dims().len()
71-
├── fn problem_size_names() -> &[&str] // static field names for size metrics
72-
└── fn problem_size_values(&self) -> Vec<usize> // instance-level size values
70+
└── fn num_variables(&self) -> usize // default: dims().len()
7371
7472
OptimizationProblem : Problem<Metric = SolutionSize<Self::Value>> (extension for optimization)
7573
@@ -98,6 +96,20 @@ enum Direction { Maximize, Minimize }
9896
- Weight management via inherent methods (`weights()`, `set_weights()`, `is_weighted()`), not traits
9997
- `NumericSize` supertrait bundles common numeric bounds (`Clone + Default + PartialOrd + Num + Zero + Bounded + AddAssign + 'static`)
10098

99+
### Overhead System
100+
Reduction overhead is expressed using `Expr` AST (in `src/expr.rs`) with the `#[reduction]` macro:
101+
```rust
102+
#[reduction(overhead = {
103+
num_vertices = "num_vertices + num_clauses",
104+
num_edges = "3 * num_clauses",
105+
})]
106+
impl ReduceTo<Target> for Source { ... }
107+
```
108+
- Expression strings are parsed at compile time by a Pratt parser in the proc macro crate
109+
- Each problem type provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) that the overhead expressions reference
110+
- `ReductionOverhead` stores `Vec<(&'static str, Expr)>` — field name to symbolic expression mappings
111+
- Expressions support: constants, variables, `+`, `*`, `^`, `exp()`, `log()`, `sqrt()`
112+
101113
### Problem Names
102114
Problem types use explicit optimization prefixes:
103115
- `MaximumIndependentSet`, `MaximumClique`, `MaximumMatching`, `MaximumSetPacking`
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Overhead System Redesign
2+
3+
**Issue:** #61 — Introduce overhead system
4+
**Date:** 2026-02-25
5+
**Approach:** Macro-first dual emission
6+
7+
## Summary
8+
9+
Replace the current `Polynomial`-based overhead system with a general `Expr` AST, compile-time macro-parsed expression strings, and per-problem inherent getters. The proc macro emits both compiled Rust code (for evaluation + compiler validation) and symbolic `Expr` AST literals (for composition + export).
10+
11+
## Motivation
12+
13+
Three pain points with the current system:
14+
1. **Ergonomics**`problem_size_names()`/`problem_size_values()` parallel arrays are awkward; `poly!` macro is verbose
15+
2. **Correctness** — variable name mismatches between overhead expressions and problem size fields are caught only at runtime
16+
3. **Simplification**`Polynomial` only supports sums of monomials; general math (exp, log) requires a new representation anyway
17+
18+
## Design
19+
20+
### 1. Expression AST (`Expr`)
21+
22+
Replaces `Polynomial` and `Monomial` with a general math expression tree.
23+
24+
```rust
25+
// src/expr.rs (replaces src/polynomial.rs)
26+
27+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28+
pub enum Expr {
29+
Const(f64),
30+
Var(&'static str),
31+
Add(Box<Expr>, Box<Expr>),
32+
Mul(Box<Expr>, Box<Expr>),
33+
Pow(Box<Expr>, Box<Expr>),
34+
Exp(Box<Expr>),
35+
Log(Box<Expr>),
36+
Sqrt(Box<Expr>),
37+
}
38+
```
39+
40+
Key operations:
41+
- `eval(&self, vars: &ProblemSize) -> f64`
42+
- `substitute(&self, mapping: &HashMap<&str, &Expr>) -> Expr`
43+
- `variables(&self) -> HashSet<&'static str>`
44+
- `is_polynomial(&self) -> bool`
45+
- `degree(&self) -> Option<u32>`
46+
- `Display` for human-readable formulas
47+
- `simplify(&self) -> Expr` — minimal constant folding
48+
49+
### 2. Problem Getters
50+
51+
Remove `problem_size_names()` and `problem_size_values()` from the `Problem` trait. Each problem type implements inherent getter methods instead.
52+
53+
```rust
54+
// Before: trait methods returning parallel arrays
55+
impl Problem for MaximumIndependentSet<SimpleGraph, i32> {
56+
fn problem_size_names() -> &'static [&'static str] { &["num_vertices", "num_edges"] }
57+
fn problem_size_values(&self) -> Vec<usize> {
58+
vec![self.graph().num_vertices(), self.graph().num_edges()]
59+
}
60+
}
61+
62+
// After: inherent methods — natural, compiler-checked, IDE-friendly
63+
impl<G: Graph, W: WeightElement> MaximumIndependentSet<G, W> {
64+
pub fn num_vertices(&self) -> usize { self.graph().num_vertices() }
65+
pub fn num_edges(&self) -> usize { self.graph().num_edges() }
66+
}
67+
```
68+
69+
### 3. Proc Macro — Dual Emission
70+
71+
The `#[reduction]` macro parses expression strings at compile time and emits two outputs.
72+
73+
User-facing syntax:
74+
```rust
75+
#[reduction(overhead = {
76+
num_vars = "num_vertices",
77+
num_constraints = "num_edges + num_vertices^2",
78+
})]
79+
impl ReduceTo<QUBO<f64>> for MaximumIndependentSet<SimpleGraph, i32> { ... }
80+
```
81+
82+
Macro emits:
83+
1. **Compiled evaluation function**`src.num_vertices()`, `src.num_edges()` calls. Compiler catches missing getters.
84+
2. **Symbolic Expr AST**`Expr::Add(...)` construction for composition/export.
85+
86+
Expression grammar (Pratt parser, ~200 LOC in proc macro crate):
87+
```
88+
expr = term (('+' | '-') term)*
89+
term = factor (('*' | '/') factor)*
90+
factor = base ('^' factor)?
91+
base = NUMBER | IDENT | func_call | '(' expr ')'
92+
func_call = ('exp' | 'log' | 'sqrt') '(' expr ')'
93+
```
94+
95+
### 4. Updated `ReductionOverhead` and `ReductionEntry`
96+
97+
```rust
98+
pub struct ReductionOverhead {
99+
pub output_size: Vec<(&'static str, Expr)>, // Expr replaces Polynomial
100+
}
101+
102+
pub struct ReductionEntry {
103+
// ...existing fields...
104+
pub overhead_fn: fn() -> ReductionOverhead, // symbolic (composition/export)
105+
pub overhead_eval_fn: fn(&dyn Any) -> ProblemSize, // compiled (evaluation)
106+
// REMOVED: source_size_names_fn, target_size_names_fn
107+
}
108+
```
109+
110+
`PathCostFn` uses the symbolic `ReductionOverhead` (via `Expr::eval`) since it operates on type-erased `ProblemSize` during graph traversal.
111+
112+
### 5. Export Pipeline
113+
114+
JSON format gains both structured AST and display string:
115+
```json
116+
{
117+
"overhead": [{
118+
"field": "num_vars",
119+
"expr": {"Pow": [{"Var": "num_vertices"}, {"Const": 2.0}]},
120+
"formula": "num_vertices^2"
121+
}]
122+
}
123+
```
124+
125+
The paper reads `formula` strings — no Typst code changes needed.
126+
127+
## Migration Strategy
128+
129+
| Phase | Description | Files | Risk |
130+
|-------|-------------|-------|------|
131+
| 1 | Add `Expr` type alongside `Polynomial` | 2-3 new | Low (additive) |
132+
| 2 | Update proc macro with Pratt parser, support new syntax | 1 file | Medium |
133+
| 3 | Add inherent getters to all problem types | ~15 model files | Low (additive) |
134+
| 4 | Migrate all reductions to new syntax | ~20 rule files | Low (mechanical) |
135+
| 5 | Remove deprecated APIs (`problem_size_*`, `Polynomial`, `poly!`) | ~10 files | Medium (breaking) |
136+
| 6 | Update documentation and regenerate exports | 3-4 files | Low |
137+
138+
Phases 1-3 are purely additive. Phase 4 is bulk migration. Phase 5 is cleanup.

docs/src/design.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ trait Problem: Clone {
3737
fn evaluate(&self, config: &[usize]) -> Self::Metric;
3838
fn variant() -> Vec<(&'static str, &'static str)>; // e.g., [("graph", "SimpleGraph"), ("weight", "i32")]
3939
fn num_variables(&self) -> usize; // default: dims().len()
40-
fn problem_size_names() -> &'static [&'static str]; // e.g., ["num_vertices", "num_edges"]
41-
fn problem_size_values(&self) -> Vec<usize>; // e.g., [10, 15] for a specific instance
4240
}
4341
4442
trait OptimizationProblem: Problem<Metric = SolutionSize<Self::Value>> {
@@ -49,7 +47,7 @@ trait OptimizationProblem: Problem<Metric = SolutionSize<Self::Value>> {
4947
trait SatisfactionProblem: Problem<Metric = bool> {} // marker trait
5048
```
5149

52-
- **`Problem`** — the base trait. Every problem declares a `NAME` (e.g., `"MaximumIndependentSet"`). The solver explores the configuration space defined by `dims()` and scores each configuration with `evaluate()`. For example, a 4-vertex MIS has `dims() = [2, 2, 2, 2]` (each vertex is selected or not); `evaluate(&[1, 0, 1, 0])` returns `Valid(2)` if vertices 0 and 2 form an independent set, or `Invalid` if they share an edge. `problem_size_names()` and `problem_size_values()` expose the instance's structural dimensions (e.g., `num_vertices`, `num_edges`) as a `ProblemSize`used by the reduction graph to evaluate overhead polynomials along a path.
50+
- **`Problem`** — the base trait. Every problem declares a `NAME` (e.g., `"MaximumIndependentSet"`). The solver explores the configuration space defined by `dims()` and scores each configuration with `evaluate()`. For example, a 4-vertex MIS has `dims() = [2, 2, 2, 2]` (each vertex is selected or not); `evaluate(&[1, 0, 1, 0])` returns `Valid(2)` if vertices 0 and 2 form an independent set, or `Invalid` if they share an edge. Each problem also provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) used by reduction overhead expressions.
5351
- **`OptimizationProblem`** — extends `Problem` with a comparable `Value` type and a `direction()` (`Maximize` or `Minimize`).
5452
- **`SatisfactionProblem`** — constrains `Metric = bool`: `true` if all constraints are satisfied, `false` otherwise.
5553

problemreductions-cli/src/commands/graph.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
143143
"\n{}\n",
144144
crate::output::fmt_section(&format!("Size fields ({}):", size_fields.len()))
145145
));
146-
for f in size_fields {
146+
for f in &size_fields {
147147
text.push_str(&format!(" {f}\n"));
148148
}
149149
}

problemreductions-cli/src/commands/inspect.rs

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,10 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> {
3333

3434
let mut text = format!("Type: {}{}\n", name, variant_str);
3535

36-
// Size info
37-
let size_names = problem.problem_size_names_dyn();
38-
let size_values = problem.problem_size_values_dyn();
39-
if !size_names.is_empty() {
40-
let sizes: Vec<String> = size_names
41-
.iter()
42-
.zip(size_values.iter())
43-
.map(|(n, v)| format!("{} {}", v, n))
44-
.collect();
45-
text.push_str(&format!("Size: {}\n", sizes.join(", ")));
36+
// Size fields from the reduction graph
37+
let size_fields = graph.size_field_names(name);
38+
if !size_fields.is_empty() {
39+
text.push_str(&format!("Size fields: {}\n", size_fields.join(", ")));
4640
}
4741
text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn()));
4842

@@ -60,9 +54,7 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> {
6054
"kind": "problem",
6155
"type": name,
6256
"variant": variant,
63-
"size": size_names.iter().zip(size_values.iter())
64-
.map(|(n, v)| serde_json::json!({"field": n, "value": v}))
65-
.collect::<Vec<_>>(),
57+
"size_fields": size_fields,
6658
"num_variables": problem.num_variables_dyn(),
6759
"solvers": ["ilp", "brute-force"],
6860
"reduces_to": targets,

problemreductions-cli/src/dispatch.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@ pub trait DynProblem: Any {
3939
fn dims_dyn(&self) -> Vec<usize>;
4040
fn problem_name(&self) -> &'static str;
4141
fn variant_map(&self) -> BTreeMap<String, String>;
42-
fn problem_size_names_dyn(&self) -> &'static [&'static str];
43-
fn problem_size_values_dyn(&self) -> Vec<usize>;
4442
fn num_variables_dyn(&self) -> usize;
4543
}
4644

@@ -70,12 +68,6 @@ where
7068
.map(|(k, v)| (k.to_string(), v.to_string()))
7169
.collect()
7270
}
73-
fn problem_size_names_dyn(&self) -> &'static [&'static str] {
74-
T::problem_size_names()
75-
}
76-
fn problem_size_values_dyn(&self) -> Vec<usize> {
77-
self.problem_size_values()
78-
}
7971
fn num_variables_dyn(&self) -> usize {
8072
self.num_variables()
8173
}

problemreductions-cli/src/mcp/prompts.rs

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ pub fn list_prompts() -> Vec<Prompt> {
2525
Some(vec![PromptArgument {
2626
name: "description".into(),
2727
title: None,
28-
description: Some(
29-
"Free-text description of your real-world problem".into(),
30-
),
28+
description: Some("Free-text description of your real-world problem".into()),
3129
required: Some(true),
3230
}]),
3331
),
@@ -114,9 +112,7 @@ pub fn list_prompts() -> Vec<Prompt> {
114112
),
115113
Prompt::new(
116114
"overview",
117-
Some(
118-
"Explore the full landscape of NP-hard problems and reductions in the graph",
119-
),
115+
Some("Explore the full landscape of NP-hard problems and reductions in the graph"),
120116
None,
121117
),
122118
]
@@ -266,10 +262,7 @@ pub fn get_prompt(
266262
.unwrap_or("QUBO");
267263

268264
Some(GetPromptResult {
269-
description: Some(format!(
270-
"Find reduction path from {} to {}",
271-
source, target
272-
)),
265+
description: Some(format!("Find reduction path from {} to {}", source, target)),
273266
messages: vec![PromptMessage::new_text(
274267
PromptMessageRole::User,
275268
format!(

problemreductions-cli/src/mcp/tools.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ impl McpServer {
177177
let mut json = serde_json::json!({
178178
"name": spec.name,
179179
"variants": variants,
180-
"size_fields": size_fields,
180+
"size_fields": &size_fields,
181181
"reduces_to": outgoing.iter().map(|e| {
182182
serde_json::json!({
183183
"source": {"name": e.source_name, "variant": e.source_variant},
@@ -605,8 +605,7 @@ impl McpServer {
605605
let variant = problem.variant_map();
606606
let graph = ReductionGraph::new();
607607

608-
let size_names = problem.problem_size_names_dyn();
609-
let size_values = problem.problem_size_values_dyn();
608+
let size_fields = graph.size_field_names(name);
610609

611610
let outgoing = graph.outgoing_reductions(name);
612611
let mut targets: Vec<String> = outgoing.iter().map(|e| e.target_name.to_string()).collect();
@@ -617,9 +616,7 @@ impl McpServer {
617616
"kind": "problem",
618617
"type": name,
619618
"variant": variant,
620-
"size": size_names.iter().zip(size_values.iter())
621-
.map(|(n, v)| serde_json::json!({"field": n, "value": v}))
622-
.collect::<Vec<_>>(),
619+
"size_fields": size_fields,
623620
"num_variables": problem.num_variables_dyn(),
624621
"solvers": ["ilp", "brute-force"],
625622
"reduces_to": targets,

problemreductions-cli/tests/cli_tests.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,7 +1937,10 @@ fn test_inspect_problem() {
19371937
stdout.contains("Type: MaximumIndependentSet"),
19381938
"expected 'Type: MaximumIndependentSet', got: {stdout}"
19391939
);
1940-
assert!(stdout.contains("Size:"), "expected 'Size:', got: {stdout}");
1940+
assert!(
1941+
stdout.contains("Size fields:"),
1942+
"expected 'Size fields:', got: {stdout}"
1943+
);
19411944
assert!(
19421945
stdout.contains("Variables:"),
19431946
"expected 'Variables:', got: {stdout}"
@@ -2093,7 +2096,22 @@ fn test_inspect_json_output() {
20932096
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
20942097
assert_eq!(json["kind"], "problem");
20952098
assert_eq!(json["type"], "MaximumIndependentSet");
2096-
assert!(json["size"].is_array());
2099+
let size_fields: Vec<&str> = json["size_fields"]
2100+
.as_array()
2101+
.expect("size_fields should be an array")
2102+
.iter()
2103+
.map(|v| v.as_str().unwrap())
2104+
.collect();
2105+
assert!(
2106+
size_fields.contains(&"num_vertices"),
2107+
"MIS size_fields should contain num_vertices, got: {:?}",
2108+
size_fields
2109+
);
2110+
assert!(
2111+
size_fields.contains(&"num_edges"),
2112+
"MIS size_fields should contain num_edges, got: {:?}",
2113+
size_fields
2114+
);
20972115
assert!(json["solvers"].is_array());
20982116
assert!(json["reduces_to"].is_array());
20992117

0 commit comments

Comments
 (0)