Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
[workspace]
members = [".", "problemreductions-macros", "problemreductions-cli"]
members = [
".",
"problemreductions-expr",
"problemreductions-macros",
"problemreductions-cli",
]

[package]
name = "problemreductions"
Expand Down Expand Up @@ -27,12 +32,14 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
num-bigint = "0.4"
num-rational = "0.4"
num-traits = "0.2"
good_lp = { version = "=1.14.2", default-features = false, optional = true }
inventory = "0.3"
ordered-float = "5.0"
rand = "0.10"
problemreductions-macros = { version = "0.6.0", path = "problemreductions-macros" }
problemreductions-expr = { version = "0.6.0", path = "problemreductions-expr" }

[dev-dependencies]
proptest = "1.0"
Expand Down
2 changes: 1 addition & 1 deletion examples/chained_reduction_factoring_to_spinglass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub fn run() {
}

// Compose overheads symbolically along the full path
let composed = graph.compose_path_overhead(rpath);
let composed = graph.compose_path_overhead(rpath).unwrap();
println!("Composed (source → target):");
for (field, poly) in &composed.output_size {
println!(" {} = {}", field, poly);
Expand Down
19 changes: 11 additions & 8 deletions problemreductions-cli/src/bin/pred_sym.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use clap::{Parser, Subcommand};
use problemreductions::{big_o_normal_form, Expr, ProblemSize};
use problemreductions::{big_o_normal_form, evaluate_approximate, Expr, ProblemSize};

#[derive(Parser)]
#[command(
Expand Down Expand Up @@ -112,22 +112,20 @@ fn main() {
}
Commands::Eval { expr, vars } => {
let parsed = parse_expr_or_exit(&expr);
let bindings: Vec<(&str, usize)> = vars
let bindings: Vec<(String, usize)> = vars
.split(',')
.filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
let name = parts.next()?.trim();
let value: usize = parts.next()?.trim().parse().ok()?;
// Leak the name for &'static str compatibility
let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
Some((leaked, value))
Some((name.to_string(), value))
})
.collect();

// Check for unbound variables
let expr_vars = parsed.variables();
let bound_vars: std::collections::HashSet<&str> =
bindings.iter().map(|(k, _)| *k).collect();
bindings.iter().map(|(name, _)| name.as_str()).collect();
let mut unbound: Vec<&str> = expr_vars
.iter()
.filter(|v| !bound_vars.contains(*v))
Expand All @@ -143,8 +141,13 @@ fn main() {
std::process::exit(1);
}

let size = ProblemSize::new(bindings);
let result = parsed.eval(&size);
let size = ProblemSize {
components: bindings,
};
let result = evaluate_approximate(&parsed, &size).unwrap_or_else(|error| {
eprintln!("Error: {error}");
std::process::exit(1);
});

// Format as integer if it's a whole number
if (result - result.round()).abs() < 1e-10 {
Expand Down
95 changes: 50 additions & 45 deletions problemreductions-cli/src/commands/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,16 @@ fn format_path_text(

// Show composed overall overhead for multi-step paths
if reduction_path.len() > 1 {
let composed = overheads.iter().cloned().reduce(|acc, oh| acc.compose(&oh));
text.push_str(&format!("\n {}:\n", crate::output::fmt_section("Overall")));
for (field, poly) in &composed.expect("multi-step path has overheads").output_size {
text.push_str(&format!(" {field} = {}\n", big_o_of(poly)));
match graph.compose_path_overhead(reduction_path) {
Ok(composed) => {
for (field, poly) in &composed.output_size {
text.push_str(&format!(" {field} = {}\n", big_o_of(poly)));
}
}
Err(error) => {
text.push_str(&format!(" unavailable: {error}\n"));
}
}
}

Expand All @@ -480,15 +486,19 @@ pub(crate) fn format_path_json(
})
.collect();

let composed = overheads.into_iter().reduce(|acc, oh| acc.compose(&oh));
let overall = composed
.as_ref()
.map_or_else(Vec::new, |overhead| overhead_to_json(&overhead.output_size));
let (overall, overall_error) = match graph.compose_path_overhead(reduction_path) {
Ok(composed) => (
Some(overhead_to_json(&composed.output_size)),
None::<String>,
),
Err(error) => (None, Some(error.to_string())),
};

serde_json::json!({
"steps": reduction_path.len(),
"path": steps_json,
"overall_overhead": overall,
"overall_overhead_error": overall_error,
})
}

Expand Down Expand Up @@ -541,10 +551,9 @@ fn format_front_text(
));
for excluded in &result.excluded {
text.push_str(&format!(
" Excluded {}: {} ({})\n",
" Excluded {}: {}\n",
path_arrow_summary(graph, &excluded.path),
excluded.failure.reason,
excluded.failure.fields.join(", ")
excluded.failure,
));
}
text
Expand All @@ -568,13 +577,14 @@ pub(crate) fn format_front_json(
let big_o: BTreeMap<&str, String> = label
.fields()
.iter()
.map(|(f, g)| (*f, g.to_big_o()))
.map(|(field, growth)| (field.as_str(), growth.to_big_o()))
.collect();
let route = format_path_json(graph, reduction_path);
serde_json::json!({
"steps": route["steps"],
"path": route["path"],
"overall_overhead": route["overall_overhead"],
"overall_overhead_error": route["overall_overhead_error"],
"growth": label.fields(),
"big_o": big_o,
})
Expand Down Expand Up @@ -643,10 +653,9 @@ fn path_front(
.iter()
.map(|item| {
format!(
"{}: {} ({})",
"{}: {}",
path_arrow_summary(graph, &item.path),
item.failure.reason,
item.failure.fields.join(", ")
item.failure,
)
})
.collect::<Vec<_>>()
Expand Down Expand Up @@ -1012,27 +1021,21 @@ mod tests {
}
}

/// Regression and budget tests for bounded `pred path --all` overhead rendering.
/// Regression tests for `pred path --all` overhead rendering.
/// All tests run **in-process** against the CLI's own private rendering helpers —
/// no `pred` binary is spawned.
///
/// Note on line lengths: composed overheads of long paths render to *genuine*
/// multivariate polynomial normal forms (an antichain of pairwise-incomparable
/// monomials). These are the correct, tight Big-O answers, not raw fallbacks — a
/// degree-8 trivariate form like `O(a^8 + a^6 b^2 + … + c^8)` legitimately runs
/// several hundred chars. The guarantee is *structural boundedness*: the
/// antichain is capped at `growth::ANTICHAIN_CAP = 32` terms and computed
/// bottom-up in linear time.
/// several hundred chars. Antichains are retained exactly; there is no hidden
/// term cap or componentwise widening.
#[cfg(test)]
mod path_overhead_rendering_tests {
use super::big_o_of;
use problemreductions::big_o_normal_form;
use problemreductions::rules::{ReductionGraph, ReductionPath};

/// Structural upper bound on a single rendered `O(...)` field: an antichain of
/// at most 32 terms (`ANTICHAIN_CAP`) over a handful of variables, each term a
/// short monomial and independent of path length.
const RENDER_LEN_BOUND: usize = 2000;
use problemreductions::rules::{PathOverheadCompositionError, ReductionGraph, ReductionPath};

/// A deeply composed path as a node-name chain (KSat → QUBO through
/// QuadraticAssignment/ILP). Used to reconstruct the path from the live graph
Expand Down Expand Up @@ -1073,7 +1076,7 @@ mod path_overhead_rendering_tests {
let graph = ReductionGraph::new();
let path = named_exploding_path(&graph);

let composed = graph.compose_path_overhead(&path);
let composed = graph.compose_path_overhead(&path).unwrap();
assert!(
!composed.output_size.is_empty(),
"composed overhead has no size fields"
Expand All @@ -1091,13 +1094,6 @@ mod path_overhead_rendering_tests {
!rendered.contains("O(?)"),
"field {field} rendered as unbounded O(?): expr = {expr}"
);
// Structurally bounded — no raw-expression explosion.
assert!(
rendered.len() < RENDER_LEN_BOUND,
"field {field} rendered {} chars (>= {RENDER_LEN_BOUND}); \
raw fallback may have returned: {rendered}",
rendered.len()
);
// The rendered normal form is never *longer* than the raw composed
// expression: proof that normalization (not passthrough) happened.
let raw_len = expr.to_string().len();
Expand All @@ -1121,12 +1117,11 @@ mod path_overhead_rendering_tests {
}

/// Whole-graph budget: rendering Big-O for **every** path of representative
/// hot pairs must finish well within the CI budget and never produce an
/// unbounded-length string. This is the "can't OOM/hang again" guard: it walks
/// the *complete* path set (`find_all_paths`), so no enumeration cap can hide a
/// runaway rendering.
/// hot pairs must finish within the CI budget and every result must either
/// normalize or expose a concrete analysis error. It walks the *complete*
/// path set (`find_all_paths`), so no enumeration cap can hide work.
#[test]
fn all_path_overhead_rendering_stays_bounded() {
fn all_path_overhead_rendering_finishes() {
let graph = ReductionGraph::new();
let start = std::time::Instant::now();
for (src, dst) in [("KSat", "QUBO"), ("MIS", "QUBO")] {
Expand All @@ -1142,16 +1137,26 @@ mod path_overhead_rendering_tests {
for path in &paths {
// Per-step overheads plus the composed overall overhead.
let per_step = graph.path_overheads(path);
let overall = graph.compose_path_overhead(path);
for oh in per_step.iter().chain(std::iter::once(&overall)) {
for oh in &per_step {
for (field, expr) in &oh.output_size {
let rendered = big_o_of(expr);
assert!(
rendered.len() < RENDER_LEN_BOUND,
"{src}->{dst} field {field} rendered {} chars (>= {RENDER_LEN_BOUND})",
rendered.len()
);
big_o_normal_form(expr).unwrap_or_else(|error| {
panic!("{src}->{dst} field {field} failed analysis: {error}")
});
}
}
match graph.compose_path_overhead(path) {
Ok(overall) => {
for (field, expr) in &overall.output_size {
big_o_normal_form(expr).unwrap_or_else(|error| {
panic!("{src}->{dst} field {field} failed analysis: {error}")
});
}
}
Err(PathOverheadCompositionError::Step { error, .. }) => assert!(
!error.field_errors().is_empty(),
"composition error must identify a failing output field"
),
Err(error) => panic!("unexpected path composition error: {error}"),
}
}
}
Expand Down
9 changes: 1 addition & 8 deletions problemreductions-cli/src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,7 @@ impl McpServer {
let details = error
.excluded
.iter()
.map(|item| {
format!(
"{}: {} ({})",
item.path,
item.failure.reason,
item.failure.fields.join(", ")
)
})
.map(|item| format!("{}: {}", item.path, item.failure,))
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
Expand Down
27 changes: 17 additions & 10 deletions problemreductions-cli/tests/pred_sym_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fn test_pred_sym_parse() {
let output = pred_sym().args(["parse", "n + m"]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout.trim(), "n + m");
assert_eq!(stdout.trim(), "m + n");
}

#[test]
Expand Down Expand Up @@ -51,18 +51,11 @@ fn test_pred_sym_big_o_signed_polynomial() {
}

#[test]
fn test_pred_sym_big_o_sqrt_display() {
// A fractional polynomial degree renders with sqrt notation.
// (`2^sqrt(n)` — a nonlinear exponent — is now unsupported, so use an
// in-domain sqrt input instead.)
fn test_pred_sym_big_o_preserves_fractional_degrees() {
let output = pred_sym().args(["big-o", "sqrt(n * m)"]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("sqrt"),
"expected sqrt notation, got: {}",
stdout.trim()
);
assert_eq!(stdout.trim(), "O(m^0.5 * n^0.5)");
}

#[test]
Expand Down Expand Up @@ -175,6 +168,20 @@ fn test_pred_sym_eval_unbound_variable_error() {
);
}

#[test]
fn test_pred_sym_eval_non_finite_result_is_an_error() {
let output = pred_sym()
.args(["eval", "log(n)", "--vars", "n=0"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("no finite real approximation"),
"got: {stderr}"
);
}

#[test]
fn test_pred_sym_compare_unequal_exits_nonzero() {
let output = pred_sym().args(["compare", "n^2", "n^3"]).output().unwrap();
Expand Down
17 changes: 17 additions & 0 deletions problemreductions-expr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "problemreductions-expr"
version = "0.6.0"
edition = "2021"
description = "Lossless symbolic expressions for problemreductions"
license = "MIT"
repository = "https://github.com/CodingThrust/problem-reductions"

[dependencies]
num-bigint = { version = "0.4", features = ["serde"] }
num-rational = { version = "0.4", features = ["serde"] }
num-traits = "0.2"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"

[dev-dependencies]
serde_json = "1.0"
Loading