From b4f07536bab6935ee022047f15ac5e24ffd66d21 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 16:29:04 +0800 Subject: [PATCH] Replace scalar Dijkstra with measured Pareto label-setting search (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements M3 (F3b) of the Symbolic Growth Domain & Pareto Search milestone. The scalar Dijkstra in `ReductionGraph` had a path-dependent-cost hole: when two paths reached the same node, only the cheaper-so-far label's size was kept, so a cheaper-but-larger intermediate state could poison downstream choices (issue #788). This replaces it with a generic multi-label Pareto search plus a measured concrete-instance label domain. - New `PathLabel` trait (`extend` + `dominates` + `cost`) and a generic `pareto_search` kernel over per-node antichain bags with predecessor pointers, branch-and-bound, deterministic safety caps (hop cap 16, bag cap 32 with a deterministic tie-break), and a deterministically ordered Pareto front. - `CostLabel`: scalar formula label reproducing Dijkstra behavior for the existing `PathCostFn` cost functions; `find_cheapest_path*` keep their signatures and now run the kernel with it. - `MeasuredLabel` (`src/rules/pareto.rs`): the concrete-instance label. Its `extend` runs a four-part pruning stack in order — (1) symbolic pre-flight guard (evaluated in f64 so a `2^num_vertices` prediction is refused without executing, making OOM structurally impossible), (2) execute + measure the real target size, (3) branch-and-bound, (4) componentwise measured-size dominance (disabled by the `exhaustive` flag; guards 1-3 stay sound). A caught panic from a reduction whose preconditions the instance violates prunes that edge. - `MeasuredPath` carries the constructed reduction chain (via `Rc`) so downstream solve/witness extraction reuses it without re-executing. - `ILPSolver::best_path_to_ilp` now uses `find_measured_best_path_to_name`, ranking ILP variants by real measured size instead of step count / formula. Verification (all green): #788 known-answer test (HC on the prism graph selects the measured optimum), OOM pre-flight guard test (64-vertex HighlyConnectedDeletion refused in <1ms, exponential construction never started), and a hand-built diamond negative control where scalar cost selection commits to P1 while the Pareto search returns the strictly-better-final-size P2. Closes #788. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/graph.rs | 437 +++++++++++++++++++++++++++++---- src/rules/mod.rs | 8 +- src/rules/pareto.rs | 317 ++++++++++++++++++++++++ src/rules/registry.rs | 13 + src/solvers/ilp/solver.rs | 109 ++++---- src/unit_tests/rules/pareto.rs | 287 ++++++++++++++++++++++ 6 files changed, 1071 insertions(+), 100 deletions(-) create mode 100644 src/rules/pareto.rs create mode 100644 src/unit_tests/rules/pareto.rs diff --git a/src/rules/graph.rs b/src/rules/graph.rs index ef8a27ff2..56ac164fe 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -13,6 +13,7 @@ //! - JSON export for documentation and visualization use crate::rules::cost::PathCostFn; +use crate::rules::pareto::{CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, HOP_CAP}; use crate::rules::registry::{ AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, }; @@ -26,6 +27,7 @@ use serde::Serialize; use std::any::Any; use std::cmp::Reverse; use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet}; +use std::rc::Rc; /// A source/target pair from the reduction graph, returned by /// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`]. @@ -464,6 +466,11 @@ impl ReductionGraph { /// Find the cheapest path between two specific problem variants while /// requiring a specific edge capability. + /// + /// Runs the generic [Pareto label-setting search](Self::pareto_search) with a + /// scalar [`CostLabel`], reproducing Dijkstra's single-objective behavior for the + /// given [`PathCostFn`]. Returns the front's best element under the deterministic + /// tie-break (smallest cost, then fewest hops, then lexicographic node names). #[allow(clippy::too_many_arguments)] pub fn find_cheapest_path_mode( &self, @@ -477,71 +484,233 @@ impl ReductionGraph { ) -> Option { let src = self.lookup_node(source, source_variant)?; let dst = self.lookup_node(target, target_variant)?; - let node_path = self.dijkstra(src, dst, mode, input_size, cost_fn)?; - Some(self.node_path_to_reduction_path(&node_path)) + let initial = CostLabel::new(input_size.clone(), cost_fn); + let mut front = self.pareto_search(src, dst, mode, initial, false); + self.pick_best_front(&mut front).map(|(path, _)| path) } - /// Core Dijkstra search on node indices. - fn dijkstra( + /// Generic Pareto label-setting search from `src` to `dst`. + /// + /// Maintains a per-node **bag** (an antichain of non-dominated labels); a label is + /// discarded only when another label at the same node [dominates](PathLabel::dominates) + /// it. Each surviving label carries a predecessor pointer for path reconstruction. + /// The frontier is explored in ascending [`cost`](PathLabel::cost) order, which gives + /// an early branch-and-bound bound. Deterministic safety caps apply: [`HOP_CAP`] + /// bounds path length, and [`BAG_CAP`] bounds each bag with a deterministic tie-break + /// (never iteration-order truncation). Edges are visited in a deterministic + /// (target-name, target-variant) order. + /// + /// When `exhaustive` is `true`, the componentwise dominance guard is disabled (bags + /// retain all labels up to the cap); the sound guards inside [`PathLabel::extend`] and + /// the branch-and-bound bound still apply. + /// + /// Returns the Pareto front at `dst`: `(path, label)` pairs, deterministically + /// ordered by (cost, hops, node-name path). + pub(crate) fn pareto_search( &self, src: NodeIndex, dst: NodeIndex, mode: ReductionMode, - input_size: &ProblemSize, - cost_fn: &C, - ) -> Option> { - let mut costs: HashMap = HashMap::new(); - let mut sizes: HashMap = HashMap::new(); - let mut prev: HashMap = HashMap::new(); - let mut heap = BinaryHeap::new(); + initial: L, + exhaustive: bool, + ) -> Vec<(ReductionPath, L)> { + struct Entry { + node: NodeIndex, + label: L, + pred: Option, + hops: usize, + } - costs.insert(src, 0.0); - sizes.insert(src, input_size.clone()); - heap.push(Reverse((OrderedFloat(0.0), src))); + let mut arena: Vec> = Vec::new(); + let mut bags: HashMap> = HashMap::new(); + let mut frontier: BinaryHeap, usize)>> = BinaryHeap::new(); + let mut best_final: Option = None; - while let Some(Reverse((cost, node))) = heap.pop() { - if node == dst { - let mut path = vec![dst]; - let mut current = dst; - while current != src { - let &prev_node = prev.get(¤t)?; - path.push(prev_node); - current = prev_node; - } - path.reverse(); - return Some(path); + arena.push(Entry { + node: src, + label: initial.clone(), + pred: None, + hops: 0, + }); + bags.entry(src).or_default().push(0); + frontier.push(Reverse((OrderedFloat(initial.cost()), 0))); + + // Reconstruct the node-name path for an arena entry (used for deterministic + // tie-breaks). Returns the sequence of node names from source to `idx`. + let name_path = |arena: &Vec>, idx: usize| -> Vec<&'static str> { + let mut names = Vec::new(); + let mut cur = Some(idx); + while let Some(i) = cur { + names.push(self.nodes[self.graph[arena[i].node]].name); + cur = arena[i].pred; } + names.reverse(); + names + }; - if cost.0 > *costs.get(&node).unwrap_or(&f64::INFINITY) { + while let Some(Reverse((cost, idx))) = frontier.pop() { + let node = arena[idx].node; + // Skip stale entries (removed from their bag because dominated / capped out). + if !bags.get(&node).is_some_and(|b| b.contains(&idx)) { + continue; + } + // The destination is terminal: keep it in the front, never expand it. + if node == dst { + continue; + } + if arena[idx].hops >= HOP_CAP { + continue; + } + // Branch-and-bound: a label already at least as costly as the best completed + // path cannot yield a cheaper destination (cost is non-decreasing). + if best_final.is_some_and(|bf| cost.0 >= bf) { continue; } - let current_size = match sizes.get(&node) { - Some(s) => s.clone(), - None => continue, - }; + // Deterministic edge order. + let mut edges: Vec<(NodeIndex, EdgeIndex)> = self + .graph + .edges(node) + .filter(|e| Self::edge_supports_mode(e.weight(), mode)) + .map(|e| (e.target(), e.id())) + .collect(); + edges.sort_by(|a, b| { + let na = &self.nodes[self.graph[a.0]]; + let nb = &self.nodes[self.graph[b.0]]; + (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) + }); - for edge_ref in self.graph.edges(node) { - if !Self::edge_supports_mode(edge_ref.weight(), mode) { + let hops = arena[idx].hops; + for (target, edge_idx) in edges { + let weight = &self.graph[edge_idx]; + let target_node = &self.nodes[self.graph[target]]; + let redge = ReductionEdge { + overhead: &weight.overhead, + reduce_fn: weight.reduce_fn, + capabilities: weight.capabilities, + target_name: target_node.name, + target_variant: &target_node.variant, + }; + let Some(new_label) = arena[idx].label.extend(&redge) else { + continue; + }; + let new_cost = new_label.cost(); + // Branch-and-bound against the best completed path. + if best_final.is_some_and(|bf| new_cost >= bf) { continue; } - let overhead = &edge_ref.weight().overhead; - let next = edge_ref.target(); - - let edge_cost = cost_fn.edge_cost(overhead, ¤t_size); - let new_cost = cost.0 + edge_cost; - let new_size = overhead.evaluate_output_size(¤t_size); - - if new_cost < *costs.get(&next).unwrap_or(&f64::INFINITY) { - costs.insert(next, new_cost); - sizes.insert(next, new_size); - prev.insert(next, node); - heap.push(Reverse((OrderedFloat(new_cost), next))); + // Componentwise dominance against the target's bag. + if !exhaustive { + let bag = bags.entry(target).or_default(); + if bag.iter().any(|&j| arena[j].label.dominates(&new_label)) { + continue; + } + bag.retain(|&j| !new_label.dominates(&arena[j].label)); + } + let nidx = arena.len(); + arena.push(Entry { + node: target, + label: new_label, + pred: Some(idx), + hops: hops + 1, + }); + bags.entry(target).or_default().push(nidx); + frontier.push(Reverse((OrderedFloat(new_cost), nidx))); + if target == dst { + best_final = Some(match best_final { + Some(bf) => bf.min(new_cost), + None => new_cost, + }); + } + + // Enforce the per-node bag cap with a deterministic tie-break. + if bags[&target].len() > BAG_CAP { + let mut entries = bags[&target].clone(); + entries.sort_by(|&a, &b| { + arena[a] + .label + .cost() + .partial_cmp(&arena[b].label.cost()) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| arena[a].hops.cmp(&arena[b].hops)) + .then_with(|| name_path(&arena, a).cmp(&name_path(&arena, b))) + }); + entries.truncate(BAG_CAP); + bags.insert(target, entries); } } } - None + // The front is the (live) bag at the destination. + let mut front: Vec<(ReductionPath, L)> = bags + .get(&dst) + .map(|b| b.as_slice()) + .unwrap_or(&[]) + .iter() + .map(|&idx| { + let mut node_path = Vec::new(); + let mut cur = Some(idx); + while let Some(i) = cur { + node_path.push(arena[i].node); + cur = arena[i].pred; + } + node_path.reverse(); + ( + self.node_path_to_reduction_path(&node_path), + arena[idx].label.clone(), + ) + }) + .collect(); + + // Deterministic ordering of the front. + front.sort_by(|a, b| { + a.1.cost() + .partial_cmp(&b.1.cost()) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.len().cmp(&b.0.len())) + .then_with(|| a.0.type_names().cmp(&b.0.type_names())) + }); + front + } + + /// Name-keyed entry to [`pareto_search`](Self::pareto_search): resolves the source + /// and target variant nodes, then runs the generic search. Returns an empty vector + /// if either endpoint is not registered. Test-only: drives the generic kernel with a + /// custom label on a hand-built graph. + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn pareto_search_by_name( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + target_variant: &BTreeMap, + mode: ReductionMode, + initial: L, + exhaustive: bool, + ) -> Vec<(ReductionPath, L)> { + let (Some(src), Some(dst)) = ( + self.lookup_node(source, source_variant), + self.lookup_node(target, target_variant), + ) else { + return vec![]; + }; + self.pareto_search(src, dst, mode, initial, exhaustive) + } + + /// Pick the best element of a Pareto front under the deterministic tie-break + /// (smallest cost, then fewest hops, then lexicographic node names). The front is + /// already sorted by [`pareto_search`](Self::pareto_search), so this returns the + /// first element. + fn pick_best_front( + &self, + front: &mut Vec<(ReductionPath, L)>, + ) -> Option<(ReductionPath, L)> { + if front.is_empty() { + None + } else { + Some(front.remove(0)) + } } /// Convert a node index path to a `ReductionPath`. @@ -1561,10 +1730,188 @@ impl ReductionGraph { } } +/// A concrete reduction path selected by the measured Pareto search. +/// +/// Holds the winning [`ReductionPath`], its **measured** final target +/// [`ProblemSize`], and the already-constructed reduction chain so downstream +/// solve/witness extraction reuses it without re-executing the reductions. +pub struct MeasuredPath { + /// The variant-level path. + pub path: ReductionPath, + /// Measured size of the final target problem. + pub size: ProblemSize, + /// The executed reduction steps (one per hop), shared via `Rc`. + steps: Vec>, +} + +impl MeasuredPath { + /// Get the final target problem as a type-erased reference. + pub fn target_problem_any(&self) -> &dyn Any { + self.steps + .last() + .expect("MeasuredPath has no steps") + .target_problem_any() + } + + /// Extract a solution from target space back to source space. + pub fn extract_solution(&self, target_solution: &[usize]) -> Vec { + self.steps + .iter() + .rev() + .fold(target_solution.to_vec(), |sol, step| { + step.extract_solution_dyn(&sol) + }) + } +} + +impl ReductionGraph { + /// Find the reduction path with the smallest **measured** final target size. + /// + /// Unlike [`find_cheapest_path_mode`](Self::find_cheapest_path_mode), which ranks + /// paths by overhead *formulas* (scaling upper bounds that can be arbitrarily loose + /// on structure-dependent constructions), this runs the [`MeasuredLabel`] domain: + /// it *actually executes* each reduction on `source_instance` and measures the real + /// constructed target size. Formulas are used only as a pre-flight guard against + /// catastrophic constructions (making OOM structurally impossible) — never to + /// arbitrate between concrete candidates. See design doc M3/F3b. + /// + /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use + /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. + /// `exhaustive` disables only the heuristic componentwise-dominance guard (the sound + /// pre-flight, budget, and branch-and-bound guards still apply). + /// + /// Returns `None` if no in-budget witness-capable path exists (or `source == target`). + #[allow(clippy::too_many_arguments)] + pub fn find_measured_best_path( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + target_variant: &BTreeMap, + mode: ReductionMode, + source_instance: &dyn Any, + budget: usize, + exhaustive: bool, + ) -> Option { + let src = self.lookup_node(source, source_variant)?; + let dst = self.lookup_node(target, target_variant)?; + if src == dst { + return None; + } + let source_size = Self::compute_source_size(source, source_instance); + let initial = MeasuredLabel::new(source_instance, source_size, budget); + let mut front = self.pareto_search(src, dst, mode, initial, exhaustive); + let (path, label) = self.pick_best_front(&mut front)?; + let steps: Vec> = label.chain().to_vec(); + if steps.is_empty() { + return None; + } + Some(MeasuredPath { + path, + size: label.measured_size().clone(), + steps, + }) + } + + /// Find the measured-smallest path from `source` to **any** variant of the target + /// problem name `target`. + /// + /// Runs [`find_measured_best_path`](Self::find_measured_best_path) once per target + /// variant and returns the overall measured-smallest result, with a deterministic + /// tie-break by (measured total size, hops, node-name path). + #[allow(clippy::too_many_arguments)] + pub fn find_measured_best_path_to_name( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + mode: ReductionMode, + source_instance: &dyn Any, + budget: usize, + exhaustive: bool, + ) -> Option { + let mut best: Option = None; + for tv in self.variants_for(target) { + let Some(candidate) = self.find_measured_best_path( + source, + source_variant, + target, + &tv, + mode, + source_instance, + budget, + exhaustive, + ) else { + continue; + }; + let better = match &best { + None => true, + Some(cur) => { + let c = (candidate.size.total(), candidate.path.len()); + let b = (cur.size.total(), cur.path.len()); + c < b || (c == b && candidate.path.type_names() < cur.path.type_names()) + } + }; + if better { + best = Some(candidate); + } + } + best + } +} + +#[cfg(test)] +impl ReductionGraph { + /// Build a bare reduction graph from an explicit node/edge list (test-only). + /// + /// Nodes carry the empty variant and empty complexity; each edge carries a + /// [`ReductionEdgeData`]. This lets tests exercise the generic Pareto search on a + /// hand-built topology (e.g. the negative-control diamond) without depending on the + /// registered inventory. + pub(crate) fn from_test_edges( + node_names: &[&'static str], + edges: &[(&'static str, &'static str, ReductionEdgeData)], + ) -> Self { + let mut graph: DiGraph = DiGraph::new(); + let mut nodes: Vec = Vec::new(); + let mut name_to_nodes: HashMap<&'static str, Vec> = HashMap::new(); + let mut index_of: HashMap<&'static str, NodeIndex> = HashMap::new(); + + for &name in node_names { + let node_id = nodes.len(); + nodes.push(VariantNode { + name, + variant: BTreeMap::new(), + complexity: "", + }); + let idx = graph.add_node(node_id); + index_of.insert(name, idx); + name_to_nodes.entry(name).or_default().push(idx); + } + + for (src, dst, data) in edges { + let s = index_of[src]; + let d = index_of[dst]; + graph.add_edge(s, d, data.clone()); + } + + Self { + graph, + nodes, + name_to_nodes, + default_variants: HashMap::new(), + } + } +} + #[cfg(test)] #[path = "../unit_tests/rules/graph.rs"] mod tests; +#[cfg(test)] +#[path = "../unit_tests/rules/pareto.rs"] +mod pareto_tests; + #[cfg(test)] #[path = "../unit_tests/rules/reduction_path_parity.rs"] mod reduction_path_parity_tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e648997a4..90f577207 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -2,6 +2,7 @@ pub mod analysis; pub mod cost; +pub mod pareto; pub mod registry; pub use cost::{ CustomCost, Minimize, MinimizeOutputSize, MinimizeSteps, MinimizeStepsThenOverhead, PathCostFn, @@ -403,8 +404,11 @@ pub(crate) mod undirectedflowlowerbounds_ilp; pub(crate) mod undirectedtwocommodityintegralflow_ilp; pub use graph::{ - AggregateReductionChain, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, - ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, + AggregateReductionChain, MeasuredPath, NeighborInfo, NeighborTree, ReductionChain, + ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, +}; +pub use pareto::{ + CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, DEFAULT_SIZE_BUDGET, HOP_CAP, }; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs new file mode 100644 index 000000000..78106526c --- /dev/null +++ b/src/rules/pareto.rs @@ -0,0 +1,317 @@ +//! Pareto label-setting search over the reduction graph. +//! +//! This module replaces the old scalar Dijkstra (`ReductionGraph::dijkstra`) with a +//! generic multi-label search. The core motivation (issue #788, design doc +//! `docs/design/symbolic-growth-domain.md`, section M3/F3b) is that edge costs are +//! **path-dependent**: the cost of a reduction depends on the size of the problem +//! accumulated along the path so far. Scalar Dijkstra keeps only the cheapest-so-far +//! label per node, so a cheaper-but-larger intermediate state can poison downstream +//! choices — it can miss the path whose *final* target is smallest. +//! +//! The fix is the standard algorithm for partial-order path costs — **multi-label +//! Pareto search** (Martins 1984; McRAPTOR-style per-node label bags). Each node keeps +//! an antichain of non-dominated labels (a "bag"); a label is only pruned when another +//! label at the same node dominates it. See [`ReductionGraph::pareto_search`]. +//! +//! Two label domains are provided: +//! - [`CostLabel`]: a scalar formula label that reproduces Dijkstra's behavior for the +//! existing `PathCostFn` cost functions (used by `find_cheapest_path*`). It carries the +//! accumulated `ProblemSize` (from overhead formulas) and an additive scalar cost. +//! - [`MeasuredLabel`]: the concrete-instance label. For a concrete source instance, it +//! *actually executes* each reduction and measures the real constructed target size. +//! Formulas are only used as a pre-flight guard, never to arbitrate between candidates. + +use crate::rules::cost::PathCostFn; +use crate::rules::registry::{EdgeCapabilities, ReduceFn, ReductionOverhead}; +use crate::rules::traits::DynReductionResult; +use crate::types::ProblemSize; +use std::any::Any; +use std::cell::Cell; +use std::collections::BTreeMap; +use std::panic; +use std::rc::Rc; +use std::sync::Once; + +thread_local! { + /// When set, the installed panic hook suppresses output on the current thread. + static SILENCE_PANIC: Cell = const { Cell::new(false) }; +} + +static HOOK_INIT: Once = Once::new(); + +/// Run `f`, catching any panic and returning `None`, without printing the panic to +/// stderr on this thread. +/// +/// During the measured search we deliberately execute candidate reductions to measure +/// their real output size. A reduction whose preconditions the current instance violates +/// panics (its macro-generated dispatch downcasts and unwraps); such an edge is simply +/// not a viable path, so we treat the panic as "edge infeasible" and prune it — the +/// design's guarantee that path selection never crashes. The thread-local silencer keeps +/// this expected, recovered panic from spamming stderr while leaving genuine panics on +/// other threads untouched. +fn catch_reduction(f: impl FnOnce() -> R) -> Option { + HOOK_INIT.call_once(|| { + let prev = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if SILENCE_PANIC.with(|s| s.get()) { + return; + } + prev(info); + })); + }); + SILENCE_PANIC.with(|s| s.set(true)); + let result = panic::catch_unwind(panic::AssertUnwindSafe(f)); + SILENCE_PANIC.with(|s| s.set(false)); + result.ok() +} + +/// Default hard total-size budget for the measured search (in "size units", i.e. the +/// sum of all `ProblemSize` components). Generous by design: the point is to refuse +/// astronomic constructions (e.g. a `2^num_vertices` blow-up), not to micro-manage. +pub const DEFAULT_SIZE_BUDGET: usize = 10_000_000; + +/// Maximum number of reduction steps (hops) explored along any path. +pub const HOP_CAP: usize = 16; + +/// Maximum number of non-dominated labels retained per node. On overflow, the bag is +/// truncated by a deterministic tie-break (never by iteration order). +pub const BAG_CAP: usize = 32; + +/// A borrowed view of one reduction edge, handed to [`PathLabel::extend`]. +/// +/// It exposes exactly what a label needs to advance: the overhead formula (for the +/// symbolic pre-flight guard and formula-based sizing), the executable reduction +/// function (for measured execution), the edge capabilities, and the target node's +/// identity (for measuring the constructed target's size by name). +pub struct ReductionEdge<'g> { + /// Overhead expressions mapping source size fields to target size fields. + pub overhead: &'g ReductionOverhead, + /// Type-erased witness reduction executor, if this edge supports witness/config mode. + pub reduce_fn: Option, + /// Capability metadata for the edge. + pub capabilities: EdgeCapabilities, + /// Target problem name (e.g. "ILP"). + pub target_name: &'static str, + /// Target problem variant. + pub target_variant: &'g BTreeMap, +} + +/// A path cost that composes along reduction edges under a partial order. +/// +/// **Isotonicity invariant (correctness condition for dominance pruning):** if label +/// `A` dominates label `B`, then for any edge `e`, `A.extend(e)` dominates `B.extend(e)` +/// (when both are `Some`). This follows from the monotonicity of overhead / reduction +/// size in the source size. The Pareto search relies on it to safely discard dominated +/// labels. +/// +/// **B&B soundness:** [`cost`](PathLabel::cost) must be non-decreasing along `extend` +/// (a reduction never shrinks the tracked cost below the current value). Every concrete +/// cost function and the measured-size total satisfy this. +pub trait PathLabel: Clone { + /// Advance this label across `edge`. Returns `None` when a guard prunes the edge + /// (e.g. the measured label's pre-flight size guard). A `None` must be *isotone*: + /// if `A` dominates `B` and `A.extend(e)` is `None`, that is fine, but a guard must + /// never prune a dominating label while keeping a dominated one. + fn extend(&self, edge: &ReductionEdge) -> Option; + + /// Partial order: `true` iff `self` is at least as good as `other` in every + /// component (and strictly better in at least one, or equal). Used to keep each + /// node's bag an antichain. + fn dominates(&self, other: &Self) -> bool; + + /// Scalar summary used for branch-and-bound pruning, frontier ordering, and the + /// deterministic final tie-break. Smaller is better. Must be non-decreasing along + /// `extend` (see trait docs). + fn cost(&self) -> f64; +} + +/// Formula-based scalar label reproducing Dijkstra behavior for a [`PathCostFn`]. +/// +/// Carries the accumulated `ProblemSize` (advanced through overhead formulas) and the +/// additive scalar cost. Dominance is scalar (`self.cost <= other.cost`), so each node +/// keeps only its minimum-cost label — exactly the classic single-objective shortest +/// path, but expressed in the generic kernel. +pub struct CostLabel<'c, C: PathCostFn> { + size: ProblemSize, + cost: f64, + cost_fn: &'c C, +} + +// Manual `Clone` (the derive would wrongly require `C: Clone`; `cost_fn` is a reference). +impl Clone for CostLabel<'_, C> { + fn clone(&self) -> Self { + Self { + size: self.size.clone(), + cost: self.cost, + cost_fn: self.cost_fn, + } + } +} + +impl<'c, C: PathCostFn> CostLabel<'c, C> { + /// Create the initial label at the source node. + pub fn new(input_size: ProblemSize, cost_fn: &'c C) -> Self { + Self { + size: input_size, + cost: 0.0, + cost_fn, + } + } +} + +impl PathLabel for CostLabel<'_, C> { + fn extend(&self, edge: &ReductionEdge) -> Option { + let increment = self.cost_fn.edge_cost(edge.overhead, &self.size); + let new_size = edge.overhead.evaluate_output_size(&self.size); + Some(Self { + size: new_size, + cost: self.cost + increment, + cost_fn: self.cost_fn, + }) + } + + fn dominates(&self, other: &Self) -> bool { + self.cost <= other.cost + } + + fn cost(&self) -> f64 { + self.cost + } +} + +/// The current constructed position of a [`MeasuredLabel`]. +#[derive(Clone)] +enum MeasuredPos<'a> { + /// At the source node: the original, un-reduced source instance. + Source(&'a dyn Any), + /// At a reduced node: the last reduction step's result. The current problem instance + /// is `result.target_problem_any()`. + Reduced(Rc), +} + +/// The concrete-instance measured label (design doc M3/F3b). +/// +/// For a concrete source instance, formulas are advisory — the **measured** target size +/// is authoritative. `extend` runs this four-part pruning stack, in order: +/// +/// 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the current +/// *measured* size. If the (upper-bound) prediction already exceeds the budget, return +/// `None` **without executing** — so a catastrophic construction (e.g. a +/// `2^num_vertices` blow-up) is never even started. This is what makes OOM +/// structurally impossible during path selection. +/// 2. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget +/// → `None`. +/// 3. **Branch-and-bound:** handled by the kernel using [`cost`](PathLabel::cost) against +/// the best completed path's final size. +/// 4. **Componentwise measured-size dominance:** [`dominates`](PathLabel::dominates), a +/// heuristic under a documented size-monotone-future assumption. The kernel's +/// `exhaustive` flag disables *only* this guard, keeping 1–3 (which are sound). +#[derive(Clone)] +pub struct MeasuredLabel<'a> { + /// Measured size of the problem instance at the current node. + size: ProblemSize, + /// The reduction steps executed so far (empty at the source). Shared via `Rc` so + /// cloning a label is cheap and never re-executes a reduction. + chain: Vec>, + /// Current constructed position. + pos: MeasuredPos<'a>, + /// Hard total-size budget. + budget: usize, +} + +impl<'a> MeasuredLabel<'a> { + /// Create the initial measured label at the source node. + /// + /// `source_size` is the measured size of `source` (typically + /// `ReductionGraph::compute_source_size`). + pub fn new(source: &'a dyn Any, source_size: ProblemSize, budget: usize) -> Self { + Self { + size: source_size, + chain: Vec::new(), + pos: MeasuredPos::Source(source), + budget, + } + } + + /// The reduction chain executed to reach this label (one entry per hop). + pub(crate) fn chain(&self) -> &[Rc] { + &self.chain + } + + /// The measured problem size at this label's node. + pub(crate) fn measured_size(&self) -> &ProblemSize { + &self.size + } +} + +/// Componentwise "less-or-equal in every field" test between two measured sizes. +/// +/// `a` covers `b` iff every field of `b` is present in `a` with a value `>=` b's — i.e. +/// `a` is componentwise `<=` `b`. Missing fields are treated as `0`. +fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { + // a <= b componentwise: for each field in either, a[f] <= b[f]. + a.components.iter().all(|(name, av)| { + let bv = b.get(name).unwrap_or(0); + *av <= bv + }) && b.components.iter().all(|(name, bv)| { + let av = a.get(name).unwrap_or(0); + av <= *bv + }) +} + +impl PathLabel for MeasuredLabel<'_> { + fn extend(&self, edge: &ReductionEdge) -> Option { + // Guard 1: symbolic pre-flight. Predict the target size from the overhead + // formula evaluated at the *measured* current size. Because formulas are upper + // bounds, a prediction over budget means we must not even start the construction. + // Computed in `f64` so an astronomic prediction (e.g. `2^num_vertices`) is flagged + // rather than overflowing `usize`. + let predicted_total = edge.overhead.evaluate_output_total_f64(&self.size); + if predicted_total > self.budget as f64 { + return None; + } + + // Guard 2: execute the reduction and measure the real target size. Executing a + // reduction whose preconditions the current instance violates panics; such an + // edge is not a viable path, so a caught panic prunes it (returns `None`). The + // measurement (`compute_source_size`) probes every same-name size function, so + // mismatched-variant probes panic internally too — both are wrapped in one + // silenced `catch_reduction`. + let reduce_fn = edge.reduce_fn?; + let current: &dyn Any = match &self.pos { + MeasuredPos::Source(s) => *s, + MeasuredPos::Reduced(r) => r.target_problem_any(), + }; + let target_name = edge.target_name; + let (result, measured) = catch_reduction(|| { + let result: Rc = Rc::from(reduce_fn(current)); + let measured = crate::rules::ReductionGraph::compute_source_size( + target_name, + result.target_problem_any(), + ); + (result, measured) + })?; + if measured.total() > self.budget { + return None; + } + + let mut chain = self.chain.clone(); + chain.push(result.clone()); + Some(Self { + size: measured, + chain, + pos: MeasuredPos::Reduced(result), + budget: self.budget, + }) + } + + fn dominates(&self, other: &Self) -> bool { + // Componentwise measured-size dominance. Labels compared here are always at the + // same node (same problem variant), so their size fields coincide. + size_le(&self.size, &other.size) + } + + fn cost(&self) -> f64 { + self.size.total() as f64 + } +} diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 8048022da..0fea24d44 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -41,6 +41,19 @@ impl ReductionOverhead { ProblemSize::new(fields) } + /// Predicted total output size as an `f64`, summing every output field's formula. + /// + /// Unlike [`evaluate_output_size`](Self::evaluate_output_size), this never rounds to + /// `usize`, so an astronomic prediction (e.g. `2^num_vertices` on a large instance) + /// stays a large finite `f64` instead of overflowing. Used by the measured Pareto + /// search's pre-flight guard to refuse catastrophic constructions before executing. + pub fn evaluate_output_total_f64(&self, input: &ProblemSize) -> f64 { + self.output_size + .iter() + .map(|(_, expr)| expr.eval(input).max(0.0)) + .sum() + } + /// Collect all input variable names referenced by the overhead expressions. pub fn input_variable_names(&self) -> HashSet<&'static str> { self.output_size diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 51b2a0df2..c77b3e017 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -240,48 +240,36 @@ impl ILPSolver { any.is::>() || any.is::>() || any.is::() } - /// Two-level path selection: - /// 1. Dijkstra finds the cheapest path to each ILP variant using - /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). - /// 2. Across ILP variants, we pick the path whose composed final output size - /// is smallest — this is the actual ILP problem size the solver will face. + /// Select the witness reduction path to ILP whose **measured** final ILP size is + /// smallest. + /// + /// Delegates to the measured Pareto search + /// ([`ReductionGraph::find_measured_best_path_to_name`]): it actually executes each + /// reduction on `instance` and measures the real constructed ILP size, choosing the + /// smallest across all ILP variants. Overhead formulas are used only as a pre-flight + /// guard against catastrophic constructions — never to arbitrate between concrete + /// candidates. This fixes issue #788 (formula/step ranking could miss the path with + /// the smallest real ILP) and makes OOM structurally impossible during selection. + /// + /// The returned [`MeasuredPath`](crate::rules::MeasuredPath) carries the already + /// constructed reduction chain, so the caller solves and extracts without + /// re-executing the reductions. fn best_path_to_ilp( &self, graph: &crate::rules::ReductionGraph, name: &str, variant: &std::collections::BTreeMap, - mode: ReductionMode, instance: &dyn std::any::Any, - ) -> Option { - let ilp_variants = graph.variants_for("ILP"); - let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); - let mut best_path: Option = None; - let mut best_cost = f64::INFINITY; - - for dv in &ilp_variants { - if let Some(path) = graph.find_cheapest_path_mode( - name, - variant, - "ILP", - dv, - mode, - &input_size, - &crate::rules::MinimizeStepsThenOverhead, - ) { - // Use composed final output size for cross-variant comparison, - // since this determines the actual ILP problem size. - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .unwrap_or_default(); - let cost = final_size.total() as f64; - if cost < best_cost { - best_cost = cost; - best_path = Some(path); - } - } - } - - best_path + ) -> Option { + graph.find_measured_best_path_to_name( + name, + variant, + "ILP", + ReductionMode::Witness, + instance, + crate::rules::DEFAULT_SIZE_BUDGET, + false, + ) } pub fn try_solve_via_reduction( @@ -300,13 +288,8 @@ impl ILPSolver { let graph = crate::rules::ReductionGraph::new(); - let Some(path) = - self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) - else { - if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) - .is_some() - { + let Some(measured) = self.best_path_to_ilp(&graph, name, variant, instance) else { + if self.has_aggregate_path_to_ilp(&graph, name, variant) { return Err(SolveViaReductionError::WitnessPathRequired { name: name.to_string(), }); @@ -317,17 +300,37 @@ impl ILPSolver { }); }; - let chain = graph.reduce_along_path(&path, instance).ok_or_else(|| { - SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - } - })?; - let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { - SolveViaReductionError::NoSolution { + let ilp_solution = self + .solve_dyn(measured.target_problem_any()) + .ok_or_else(|| SolveViaReductionError::NoSolution { name: name.to_string(), - } - })?; - Ok(chain.extract_solution(&ilp_solution)) + })?; + Ok(measured.extract_solution(&ilp_solution)) + } + + /// Whether an aggregate-capable (but possibly not witness-capable) reduction path to + /// some ILP variant exists. Used only to distinguish "no path at all" from "a path + /// exists but cannot recover a witness" for error reporting. + fn has_aggregate_path_to_ilp( + &self, + graph: &crate::rules::ReductionGraph, + name: &str, + variant: &std::collections::BTreeMap, + ) -> bool { + let input_size = crate::types::ProblemSize::new(vec![]); + graph.variants_for("ILP").iter().any(|dv| { + graph + .find_cheapest_path_mode( + name, + variant, + "ILP", + dv, + ReductionMode::Aggregate, + &input_size, + &crate::rules::MinimizeSteps, + ) + .is_some() + }) } /// Solve a type-erased problem by finding a reduction path to ILP. diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs new file mode 100644 index 000000000..bd21f6294 --- /dev/null +++ b/src/unit_tests/rules/pareto.rs @@ -0,0 +1,287 @@ +//! Tests for the Pareto label-setting search (`src/rules/pareto.rs`) and its two label +//! domains. Covers: +//! - The measured concrete-instance label (issue #788 known-answer, OOM pre-flight guard). +//! - The generic kernel's correctness on a hand-built diamond (negative control): a +//! scalar-cost path selection commits to the wrong prefix, while the Pareto search +//! returns the path with the strictly-better final measured size. + +use super::*; +use crate::expr::Expr; +use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; +use crate::rules::cost::CustomCost; +use crate::rules::pareto::{PathLabel, ReductionEdge}; +use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; +use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; +use crate::topology::SimpleGraph; +use crate::types::ProblemSize; +use std::any::Any; +use std::time::Instant; + +// --------------------------------------------------------------------------- +// Verification 1: issue #788 known-answer check. +// --------------------------------------------------------------------------- + +/// The prism (triangular-prism) graph from issue #788: 6 vertices, 9 edges. +fn prism_hamiltonian_circuit() -> HamiltonianCircuit { + let prism = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (3, 4), + (4, 5), + (5, 3), + (0, 3), + (1, 4), + (2, 5), + ], + ); + HamiltonianCircuit::new(prism) +} + +/// #788: the measured Pareto search selects the path whose *measured* final ILP size is +/// smallest. +/// +/// The literal reduction chain quoted in issue #788 (HC → HP → ConsecutiveOnesSubmatrix → +/// ILP, total 60) no longer exists on the current reduction graph. The *current* measured +/// optimum is HC → LongestCircuit → ILP with a measured total of 232 +/// (num_constraints=127, num_vars=105); the next candidates are RuralPostman → ILP +/// (366) and TravelingSalesman → ILP (768). This test pins the measured optimum so +/// the selector is proven to rank by *measured* final size, not by step count or formula. +#[test] +fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { + let hc = prism_hamiltonian_circuit(); + let graph = ReductionGraph::new(); + let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); + + let measured = graph + .find_measured_best_path_to_name( + "HamiltonianCircuit", + &variant, + "ILP", + ReductionMode::Witness, + &hc as &dyn Any, + DEFAULT_SIZE_BUDGET, + false, + ) + .expect("a measured witness path from HamiltonianCircuit to ILP"); + + // Measured final ILP size is the current-graph optimum. + assert_eq!( + measured.size.total(), + 232, + "measured optimum should be 232, got {:?}", + measured.size + ); + // Via LongestCircuit, to the bool ILP variant. + assert_eq!( + measured.path.type_names(), + vec!["HamiltonianCircuit", "LongestCircuit", "ILP"], + ); + + // The constructed chain is reusable: the final target is a genuine ILP. + use crate::models::algebraic::ILP; + let ilp = measured + .target_problem_any() + .downcast_ref::>() + .expect("final target is ILP"); + assert_eq!(ilp.num_vars, 105); +} + +// --------------------------------------------------------------------------- +// Verification 2: OOM pre-flight guard is real. +// --------------------------------------------------------------------------- + +/// Routing a 64-vertex instance through the `2^num_vertices` overhead edge +/// (`highlyconnecteddeletion_ilp`) must be refused by the symbolic pre-flight guard +/// *before* the exponential construction is ever started: the search completes near +/// instantly and returns no in-budget path (the sole HCD → ILP edge is pruned). +/// +/// The instance is a dense 64-vertex graph on purpose — if the guard were removed, the +/// reduction would enumerate ~2^64 feasible clusters and exhaust memory. Because guard 1 +/// evaluates the formula (`2^64 ≫ budget`) and skips without executing, the test is safe. +#[test] +fn test_oom_preflight_guard_highlyconnecteddeletion() { + // Dense 64-vertex graph (complete graph K_64): cheap to build, catastrophic to reduce. + let n = 64; + let mut edges = Vec::new(); + for u in 0..n { + for v in (u + 1)..n { + edges.push((u, v)); + } + } + let hcd = HighlyConnectedDeletion::new(SimpleGraph::new(n, edges)); + let graph = ReductionGraph::new(); + let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); + + let start = Instant::now(); + let result = graph.find_measured_best_path_to_name( + "HighlyConnectedDeletion", + &variant, + "ILP", + ReductionMode::Witness, + &hcd as &dyn Any, + DEFAULT_SIZE_BUDGET, + false, + ); + let elapsed = start.elapsed(); + + // The only HCD -> ILP path is the 2^num_vertices edge; it is pre-flight-pruned. + assert!( + result.is_none(), + "the 2^num_vertices construction must be refused, not selected" + ); + // Structural proof the exponential enumeration was never started: it finishes fast. + assert!( + elapsed.as_secs_f64() < 1.0, + "search must complete in < 1s (never executes the exponential edge); took {:?}", + elapsed + ); +} + +// --------------------------------------------------------------------------- +// Verification 4: negative control on a hand-built diamond. +// --------------------------------------------------------------------------- + +/// A test label whose objective is the *final* measured size `s`, while carrying a +/// separate accumulated step cost `c`. Dominance is componentwise Pareto over `(c, s)`, +/// so two labels that trade off `c` against `s` are incomparable and both survive — the +/// exact structure a scalar Dijkstra collapses (keeping only the min-`c` label, and thus +/// its `s`). +#[derive(Clone)] +struct DiamondLabel { + /// Accumulated step cost. + c: f64, + /// Current (path-dependent) measured size. + s: f64, +} + +impl DiamondLabel { + fn ctx(&self) -> ProblemSize { + ProblemSize::new(vec![("s", self.s.round().max(0.0) as usize)]) + } +} + +impl PathLabel for DiamondLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + let ctx = self.ctx(); + let add_c = edge.overhead.get("c").map(|e| e.eval(&ctx)).unwrap_or(0.0); + let new_s = edge + .overhead + .get("s") + .map(|e| e.eval(&ctx)) + .unwrap_or(self.s); + Some(DiamondLabel { + c: self.c + add_c, + s: new_s, + }) + } + + fn dominates(&self, other: &Self) -> bool { + self.c <= other.c && self.s <= other.s + } + + fn cost(&self) -> f64 { + self.s + } +} + +fn diamond_edge(c: f64, s: Expr) -> ReductionEdgeData { + ReductionEdgeData { + overhead: ReductionOverhead::new(vec![("c", Expr::Const(c)), ("s", s)]), + reduce_fn: None, + reduce_aggregate_fn: None, + capabilities: EdgeCapabilities::witness_only(), + } +} + +/// Negative control: P1 (S→M→T) has the lower first-edge cost but a larger measured +/// intermediate size at M; P2 (S→P→M→T) has a higher first-edge cost but a strictly +/// smaller final measured size. A scalar-cost path selection (`find_cheapest_path` over +/// the additive step cost) commits to P1's prefix at M and returns P1; the measured +/// Pareto search keeps both routes into M (they are incomparable) and returns P2. +#[test] +fn test_negative_control_diamond_pareto_beats_scalar() { + let empty = std::collections::BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "M", "P", "T"], + &[ + // S -> M: cheap first edge (c=1), large intermediate size (s=100). + ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), + // S -> P: pricier first edge (c=2), small size (s=5). + ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), + // P -> M: small size (s=6). + ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), + // M -> T: identity on size (final size = size at M). + ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ], + ); + + // (a) Scalar-cost selection (minimize additive step cost `c`) commits to P1. + let scalar = graph + .find_cheapest_path( + "S", + &empty, + "T", + &empty, + &ProblemSize::new(vec![]), + &CustomCost(|oh: &ReductionOverhead, sz: &ProblemSize| { + oh.get("c").map(|e| e.eval(sz)).unwrap_or(0.0) + }), + ) + .expect("scalar path S -> T"); + assert_eq!( + scalar.type_names(), + vec!["S", "M", "T"], + "scalar cost selection should commit to the cheap-prefix P1" + ); + + // (b) The measured Pareto search returns P2 (strictly smaller final size). + let initial = DiamondLabel { c: 0.0, s: 0.0 }; + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + false, + ); + assert!(!front.is_empty(), "front should reach T"); + let (best_path, best_label) = &front[0]; + assert_eq!( + best_path.type_names(), + vec!["S", "P", "M", "T"], + "Pareto search should return the better-final-size P2" + ); + assert_eq!(best_label.cost(), 6.0, "P2's final measured size is 6"); +} + +/// The `exhaustive` flag disables only the heuristic componentwise-dominance guard; the +/// front still contains the true optimum. On the diamond, both routes into M survive +/// regardless, so the answer is unchanged. +#[test] +fn test_diamond_exhaustive_matches_pruned() { + let empty = std::collections::BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "M", "P", "T"], + &[ + ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), + ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), + ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), + ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ], + ); + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + DiamondLabel { c: 0.0, s: 0.0 }, + true, + ); + assert_eq!(front[0].0.type_names(), vec!["S", "P", "M", "T"]); + assert_eq!(front[0].1.cost(), 6.0); +}