|
| 1 | +# CountingProblem Trait — Supporting #P and PP-Complete Problems |
| 2 | + |
| 3 | +**Date:** 2026-03-22 |
| 4 | +**Status:** Approved design, pending implementation |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +The current trait hierarchy supports two problem families: |
| 9 | + |
| 10 | +- `OptimizationProblem` (`Metric = SolutionSize<V>`) — find a config that maximizes/minimizes an objective |
| 11 | +- `SatisfactionProblem` (`Metric = bool`) — find a config satisfying all constraints |
| 12 | + |
| 13 | +8 issues are blocked because they model problems where the answer depends on **aggregating over the entire configuration space** — counting feasible configs or summing weighted probabilities. These are #P-complete or PP-complete problems (not known to be in NP) that don't fit either existing trait. |
| 14 | + |
| 15 | +### Blocked issues |
| 16 | + |
| 17 | +**Models:** #235 NetworkReliability, #237 NetworkSurvivability, #404 KthLargestSubset, #405 KthLargestMTuple |
| 18 | + |
| 19 | +**Rules (blocked on models above):** #256 SteinerTree → NetworkReliability, #257 VertexCover → NetworkSurvivability, #394 SubsetSum → KthLargestSubset, #395 SubsetSum → KthLargestMTuple |
| 20 | + |
| 21 | +## Design |
| 22 | + |
| 23 | +### New type: `Weight<W>` |
| 24 | + |
| 25 | +A newtype wrapper for per-configuration weights, parallel to `SolutionSize<V>` for optimization problems. Infeasible configs have weight zero — no separate `Infeasible` variant needed (unlike `SolutionSize::Invalid`) because a zero-weight config contributes nothing to the sum. |
| 26 | + |
| 27 | +```rust |
| 28 | +// src/types.rs |
| 29 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 30 | +pub struct Weight<W>(pub W); |
| 31 | +``` |
| 32 | + |
| 33 | +### New trait: `CountingProblem` |
| 34 | + |
| 35 | +A marker trait parallel to `SatisfactionProblem`, binding `Metric = Weight<Self::Value>`: |
| 36 | + |
| 37 | +```rust |
| 38 | +// src/traits.rs |
| 39 | +pub trait CountingProblem: Problem<Metric = Weight<Self::Value>> { |
| 40 | + /// The inner weight type (e.g., `u64` for unweighted counting, `f64` for probabilities). |
| 41 | + type Value: Clone + AddAssign + Zero + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +The `evaluate(config) -> Weight<V>` method (inherited from `Problem`) returns the weight of a single configuration. The "answer" to the problem is the sum of weights over all configurations. This is computed by the solver, not by `evaluate`. |
| 46 | + |
| 47 | +### Trait hierarchy (updated) |
| 48 | + |
| 49 | +``` |
| 50 | +Problem (Metric: Clone) |
| 51 | +├── OptimizationProblem (Metric = SolutionSize<V>) — existing, unchanged |
| 52 | +├── SatisfactionProblem (Metric = bool) — existing, unchanged |
| 53 | +└── CountingProblem (Metric = Weight<V>) — NEW |
| 54 | +``` |
| 55 | + |
| 56 | +### Solver extension |
| 57 | + |
| 58 | +Add a separate `CountingSolver` trait (parallel to how problem families have distinct traits) rather than extending the existing `Solver` trait. This avoids forcing `ILPSolver` to implement a meaningless `count` method: |
| 59 | + |
| 60 | +```rust |
| 61 | +// src/solvers/mod.rs (existing Solver trait unchanged) |
| 62 | + |
| 63 | +/// Solver trait for counting problems. |
| 64 | +pub trait CountingSolver { |
| 65 | + /// Compute the total weight (sum of evaluate over all configs). |
| 66 | + fn count<P: CountingProblem>(&self, problem: &P) -> P::Value; |
| 67 | +} |
| 68 | + |
| 69 | +// src/solvers/brute_force.rs |
| 70 | +impl CountingSolver for BruteForce { |
| 71 | + fn count<P: CountingProblem>(&self, problem: &P) -> P::Value { |
| 72 | + let mut total = P::Value::zero(); |
| 73 | + for config in DimsIterator::new(problem.dims()) { |
| 74 | + total += problem.evaluate(&config).0; |
| 75 | + } |
| 76 | + total |
| 77 | + } |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +`BruteForce` also gets a convenience method for testing: |
| 82 | +```rust |
| 83 | +/// Return all feasible configs and their weights alongside the total count. |
| 84 | +pub fn count_with_configs<P: CountingProblem>(&self, problem: &P) |
| 85 | + -> (P::Value, Vec<(Vec<usize>, P::Value)>); |
| 86 | +``` |
| 87 | + |
| 88 | +### Reduction support |
| 89 | + |
| 90 | +Counting reductions preserve aggregate counts, not individual solutions. New traits parallel to `ReductionResult` / `ReduceTo<T>`: |
| 91 | + |
| 92 | +```rust |
| 93 | +// src/rules/traits.rs |
| 94 | + |
| 95 | +pub trait CountingReductionResult { |
| 96 | + type Source: CountingProblem; |
| 97 | + type Target: CountingProblem; |
| 98 | + |
| 99 | + /// Get a reference to the target problem. |
| 100 | + fn target_problem(&self) -> &Self::Target; |
| 101 | + |
| 102 | + /// Transform the target's aggregate count back to the source's count. |
| 103 | + /// |
| 104 | + /// For parsimonious reductions (1-to-1 config mapping), this is identity. |
| 105 | + /// For non-parsimonious reductions, this applies a correction factor |
| 106 | + /// (e.g., divide by 2 if the reduction doubles feasible configs). |
| 107 | + fn extract_count( |
| 108 | + &self, |
| 109 | + target_count: <Self::Target as CountingProblem>::Value, |
| 110 | + ) -> <Self::Source as CountingProblem>::Value; |
| 111 | +} |
| 112 | + |
| 113 | +pub trait ReduceToCount<T: CountingProblem>: CountingProblem { |
| 114 | + type Result: CountingReductionResult<Source = Self, Target = T>; |
| 115 | + fn reduce_to_count(&self) -> Self::Result; |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +### Registry and CLI integration |
| 120 | + |
| 121 | +#### `declare_variants!` macro |
| 122 | + |
| 123 | +Gets a new `count` keyword. The macro generates a `CountSolveFn` (instead of `SolveFn`) that calls `BruteForce::count()` and formats the result: |
| 124 | + |
| 125 | +```rust |
| 126 | +crate::declare_variants! { |
| 127 | + default count NetworkReliability => "2^num_edges * num_vertices", |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +The `count` keyword generates: |
| 132 | +- A new `SolverKind::Count` variant in the proc macro's internal `SolverKind` enum (alongside existing `Opt` and `Sat`) |
| 133 | +- A `solver_kind` field on `VariantEntry` to distinguish problem families at runtime (enum with `Optimization`, `Satisfaction`, `Counting` variants) |
| 134 | +- A `count_fn: Option<CountSolveFn>` field on `VariantEntry` where `CountSolveFn = fn(&dyn Any) -> String` |
| 135 | +- The generated function downcasts `&dyn Any` to the concrete type, calls `BruteForce.count(&problem)`, and formats the result |
| 136 | +- The existing `ProblemType` struct (which holds problem metadata, not a classification enum) is unchanged |
| 137 | + |
| 138 | +#### `LoadedDynProblem` |
| 139 | + |
| 140 | +Gets a new method: |
| 141 | +```rust |
| 142 | +pub fn solve_counting(&self) -> Option<String> { |
| 143 | + (self.count_fn?)(self.inner.as_any()) |
| 144 | +} |
| 145 | +``` |
| 146 | + |
| 147 | +The existing `solve_brute_force` remains unchanged for opt/sat problems. |
| 148 | + |
| 149 | +#### `pred solve` CLI |
| 150 | + |
| 151 | +The solve command checks `VariantEntry::solver_kind` to determine the dispatch path: |
| 152 | +- `SolverKind::Optimization` / `SolverKind::Satisfaction` → existing `solve_brute_force()` |
| 153 | +- `SolverKind::Counting` → new `solve_counting()`, displays `Total weight: <value>` |
| 154 | + |
| 155 | +#### `#[reduction]` proc macro |
| 156 | + |
| 157 | +The existing macro hardcodes `ReduceTo` trait detection. It must be extended to also recognize `ReduceToCount`: |
| 158 | + |
| 159 | +- When the macro sees `impl ReduceToCount<Target> for Source`, it generates a `reduce_count_fn` field on `ReductionEntry` |
| 160 | +- The generated function returns a `Box<dyn DynCountingReductionResult>` (new type-erased trait for counting reductions) |
| 161 | +- `overhead` attribute works identically — overhead expressions are about problem size, not about solution type |
| 162 | + |
| 163 | +#### `ReductionEntry` changes |
| 164 | + |
| 165 | +`ReductionEntry` (in `src/rules/registry.rs`) gains new optional fields for counting reductions. A given entry has either `reduce_fn` (opt/sat) or `reduce_count_fn` (counting), never both: |
| 166 | + |
| 167 | +```rust |
| 168 | +pub struct ReductionEntry { |
| 169 | + // ... existing fields unchanged ... |
| 170 | + pub reduce_fn: Option<ReduceFn>, // existing: opt/sat reductions |
| 171 | + pub reduce_count_fn: Option<CountReduceFn>, // NEW: counting reductions |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +Where `CountReduceFn = fn(&dyn Any) -> Box<dyn DynCountingReductionResult>`. |
| 176 | + |
| 177 | +#### Reduction graph integration |
| 178 | + |
| 179 | +Counting edges and opt/sat edges coexist in the same `ReductionGraph`. The graph is about problem reachability — edge type doesn't affect pathfinding. The distinction matters only at solve time: |
| 180 | + |
| 181 | +- `ReductionEdgeData` gains an `edge_kind: EdgeKind` field (`enum EdgeKind { Standard, Counting }`) |
| 182 | +- `reduce_along_path` checks edge kinds: a path must be homogeneous (all-standard or all-counting); mixed paths are invalid |
| 183 | +- For all-counting paths, the runtime builds a `CountingReductionChain` instead of a `ReductionChain` |
| 184 | + |
| 185 | +#### Counting reduction chains |
| 186 | + |
| 187 | +For multi-hop counting paths (A →count→ B →count→ C): |
| 188 | + |
| 189 | +```rust |
| 190 | +pub trait DynCountingReductionResult { |
| 191 | + fn target_problem_any(&self) -> &dyn Any; |
| 192 | + /// Transform target count to source count using serde_json::Value for type erasure. |
| 193 | + fn extract_count_dyn(&self, target_count: serde_json::Value) -> serde_json::Value; |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +`CountingReductionChain` composes these: reduce A→B→C, solve C to get count as `serde_json::Value`, then call `extract_count_dyn` backwards through the chain. This parallels `ReductionChain` for opt/sat reductions. |
| 198 | + |
| 199 | +**Note on cross-type reductions:** When source and target have different `Value` types (e.g., `u64` → `f64`), the `extract_count` implementation is responsible for the type conversion. The `serde_json::Value` type erasure in `DynCountingReductionResult` handles this naturally at the runtime dispatch level. |
| 200 | + |
| 201 | +#### Exports |
| 202 | + |
| 203 | +Add to prelude and `lib.rs`: |
| 204 | +- `CountingProblem`, `Weight`, `CountingSolver` traits/types |
| 205 | +- `ReduceToCount`, `CountingReductionResult` traits |
| 206 | + |
| 207 | +### Concrete models |
| 208 | + |
| 209 | +All models store **only the counting problem data** — no decision thresholds (`k`, `q`). The threshold is part of the GJ decision formulation but not part of the counting problem we model. |
| 210 | + |
| 211 | +| Model | Value type | Fields | evaluate returns | |
| 212 | +|---|---|---|---| |
| 213 | +| `NetworkReliability` | `f64` | `graph`, `terminals`, `failure_probs` | `Weight(Π p_e^{x_e} · (1-p_e)^{1-x_e})` if terminals connected, else `Weight(0.0)` | |
| 214 | +| `NetworkSurvivability` | `f64` | `graph`, `terminals`, `failure_probs` | Same pattern for survivability | |
| 215 | +| `KthLargestSubset` | `u64` | `sizes`, `bound` | `Weight(1)` if subset sum ≤ bound, else `Weight(0)` | |
| 216 | +| `KthLargestMTuple` | `u64` | `sizes`, `bound` | `Weight(1)` if m-tuple condition met, else `Weight(0)` | |
| 217 | + |
| 218 | +### `Weight<W>` utility impls |
| 219 | + |
| 220 | +For ergonomics, `Weight<W>` implements: |
| 221 | +- `PartialOrd` where `W: PartialOrd` — delegates to inner value |
| 222 | +- `Eq` where `W: Eq`, `Hash` where `W: Hash` — conditional impls (works for `u64`, not `f64`) |
| 223 | +- `Add<Output = Weight<W>>` and `std::iter::Sum` where `W: Add` — enables `configs.map(evaluate).sum()` |
| 224 | +- `Display` where `W: Display` — prints the inner value directly (e.g., `0.9832` not `Weight(0.9832)`) |
| 225 | + |
| 226 | +### What is NOT changed |
| 227 | + |
| 228 | +- `OptimizationProblem`, `SatisfactionProblem` — untouched |
| 229 | +- `ReduceTo<T>`, `ReductionResult` — untouched |
| 230 | +- All existing models and rules — untouched |
| 231 | +- Existing `Solver` trait — untouched (new `CountingSolver` is separate) |
| 232 | + |
| 233 | +## Alternatives considered |
| 234 | + |
| 235 | +1. **Generalized metric aggregation** (#737) — replace all three leaf traits with a single `Aggregation` enum. Elegant but large breaking refactor with no immediate payoff. Filed for future consideration. |
| 236 | + |
| 237 | +2. **Two-level trait** (`is_feasible` + `weight` methods) — more explicit but adds unnecessary surface area and boilerplate for unweighted counting. |
| 238 | + |
| 239 | +3. **`Metric = f64` without wrapper** — works but loses type safety. `Weight<W>` follows the `SolutionSize<V>` pattern and makes intent explicit. |
| 240 | + |
| 241 | +## Related issues |
| 242 | + |
| 243 | +- #737 — Generalized metric aggregation (future architecture) |
| 244 | +- #748 — DefaultSolver per problem (future architecture) |
0 commit comments