N or P
Contents Glossary
24

Why three variables per rule changes everything

The boundary between cheap and hard constraint problems is at three variables per rule. Knowing this saves the wrong estimate.

When every rule in a logical constraint system involves exactly two variables, the system can be checked for satisfiability in linear time using a graph-based technique (page 15). When some rule involves three or more variables, the problem becomes 3-SAT and is NP-complete — the same complexity class as every other hard problem. The transition from two to three is the sharpest complexity boundary in computer science. In practice, modern SAT solvers handle both flavors with the same machinery and often the same speed on real inputs — but the worst-case picture is dramatically different. For planning purposes, the question to ask is whether the constraints genuinely require three or more variables per rule. If they do, accept that the problem is in the expensive category and budget for a solver. If they do not, model it as 2-SAT and stay in the cheap category.

  1. 01
    Three-piece clauses look one character longer in the encoding and live in a different complexity class. Same crate, same call — the worst-case runtime is the only thing that changed. In practice splr still cracks most real instances in seconds.
    use splr::*;
    
    // 3-SAT clauses — three literals each.  NP-complete in the worst case.
    fn three_sat(clauses: Vec<[i32; 3]>) -> Option<Vec<i32>> {
        let cnf: Vec<Vec<i32>> = clauses.into_iter().map(|c| c.to_vec()).collect();
        let mut s = Solver::try_from((SolverConfig::default(), cnf.as_ref())).ok()?;
        match s.solve().ok()? {
            Certificate::SAT(a) => Some(a),
            Certificate::UNSAT => None,
        }
    }
  2. 02
    Vec<[i32; 3]> is a Vec whose elements are fixed-size arrays of exactly three i32 values. Arrays in Rust are written [T; N] where N is the length, known at compile time. Unlike Vec<T> (heap-allocated, growable, runtime-sized), an array [T; N] is stored inline at its natural size and cannot grow or shrink. Why use it here? 3-SAT requires every clause to have exactly three literals; the type Vec<[i32; 3]> enforces that at compile time — you cannot accidentally hand the function a 4-literal clause. Compare with page 23's input, Vec<Vec<i32>> — clauses of variable length, the general CNF shape. Choosing the type with the right level of strictness is part of how Rust APIs encode their assumptions.
    // Three ways to hold "three integers":
    let v: Vec<i32>    = vec![1, -2, 3];    // heap-allocated, growable, runtime length
    let a: [i32; 3]    = [1, -2, 3];         // inline storage, fixed length at compile time
    let s: &[i32]      = &[1, -2, 3];        // borrowed view, runtime length
    
    // Collections of those:
    let any_length: Vec<Vec<i32>>  = vec![vec![1, -2], vec![3], vec![-4, 5, -6]];
    //              ↑
    //              inner Vec — clauses can be any length
    
    let exactly_3:  Vec<[i32; 3]>  = vec![[1, -2, 3], [-1, 2, -3]];
    //              ↑
    //              inner [_; 3] — every clause is exactly three literals
    
    // The 3-SAT signature refuses a wrong-size literal at compile time:
    let bad: Vec<[i32; 3]> = vec![[1, -2]];  // error: expected 3 elements, found 2
    
    // Why fixed-size arrays exist:
    //   • compile-time size known       →   stack/inline storage, no heap
    //   • exact-shape constraints       →   the type itself documents the rule
    //   • bit/byte buffers              →   [u8; 16], [u8; 32] for fixed-size hashes
    //   • pixel formats                 →   [f32; 4] for RGBA, [f32; 3] for RGB
    //   • SIMD lanes                    →   [f32; 8] for AVX, [f32; 4] for SSE
    
    // Array methods overlap with slice methods because [T; N] derefs to &[T]:
    let arr: [i32; 5] = [1, 2, 3, 4, 5];
    arr.iter().sum::<i32>();      // 15
    arr.len();                     // 5 — known at compile time, but still a method
  3. 03
    Solver::try_from invokes a TryFrom implementation — Rust's standard pattern for fallible conversions between types. Two related traits live next to each other in the standard library. From<T> is for conversions that always succeed: String::from("hello"), i64::from(42_i32). Calling From::from(x) returns the converted value directly. TryFrom<T> is for conversions that might fail: converting an i64 to i32 could overflow, converting raw bytes to UTF-8 might find invalid sequences, building a SAT solver from a tuple of config-and-CNF could find malformed clauses. Calling TryFrom::try_from(x) returns Result<Self, Self::Error>. splr implements TryFrom for Solver so callers can build a solver from a config-and-formula tuple and get a Result back if anything is wrong. Every library that provides controllable conversions is expected to implement these traits.
    // From and TryFrom — the two halves of Rust's standard conversion pattern.
    
    // From<T>: infallible — always succeeds.
    let s: String = String::from("hello");        // &str → String, always works
    let n: i64    = i64::from(42_i32);             // i32 → i64, always works
    
    // TryFrom<T>: fallible — returns Result<Self, Self::Error>.
    let n: Result<i32, _> = i32::try_from(42_i64);                 // Ok, fits
    let n: Result<i32, _> = i32::try_from(10_000_000_000_i64);     // Err, overflow
    
    let s: Result<&str, _> = std::str::from_utf8(&[0xff]);         // Err, invalid UTF-8
    
    // Page 24's case — building a Solver from (config, cnf) might fail
    // if the CNF is malformed:
    let solver: Result<Solver, _> =
        Solver::try_from((SolverConfig::default(), cnf.as_ref()));
    
    // Companion traits — Into / TryInto.  Every From<T> for U gets a free
    // Into<U> for T (and the same for TryFrom/TryInto).  Use whichever reads better:
    let s: String = "hello".into();                 // via Into (the inverse of From)
    let n: i32    = 42_i64.try_into()?;             // via TryInto (the inverse of TryFrom)
    
    // Cheat sheet:
    //   From      / Into       →  always succeed
    //   TryFrom   / TryInto    →  return Result on failure
  4. 04
    .ok() is a method on Result<T, E> that discards the error and returns an Option<T> — Ok(t).ok() == Some(t) and Err(e).ok() == None. The error information is lost (only the absence is preserved), so use .ok() when the caller does not care why something failed. On page 24 .ok() shows up twice followed immediately by ? — .ok()? is the canonical bridge between Result and Option. The reason: the function returns Option<Vec<i32>>, and the ? operator can only short-circuit on the enclosing function's return type. Writing ? directly on a Result inside an Option-returning function would not compile. Converting Result → Option with .ok() first, then applying ?, lets the function return None on any failure. The companion method .err() returns Option<E> — useful when you only want to inspect the failure case.
    // .ok() — discard the error, return Option<T>.
    let success: Result<i32, &str> = Ok(42);
    let failure: Result<i32, &str> = Err("nope");
    
    success.ok();         // Some(42)
    failure.ok();         // None — the "nope" is gone
    
    // The companion .err() flips the perspective:
    success.err();        // None
    failure.err();        // Some("nope")
    
    
    // Why .ok()? appears on page 24:
    //
    // The function returns Option<Vec<i32>>.  ? only short-circuits on the
    // enclosing function's return type:
    //
    //   in a Result-returning fn   →   ? bubbles Err
    //   in an Option-returning fn  →   ? bubbles None
    //
    // .ok() converts Result<T, E> → Option<T> so ? can short-circuit cleanly:
    
    fn three_sat(clauses: Vec<[i32; 3]>) -> Option<Vec<i32>> {
        let cnf: Vec<Vec<i32>> = clauses.into_iter().map(|c| c.to_vec()).collect();
        let mut s = Solver::try_from((SolverConfig::default(), cnf.as_ref())).ok()?;
        //                                                                    ────
        //                                                          Result<Solver, _>
        //                                                        → Option<Solver>
        //                                                        → unwrap, or return None
        match s.solve().ok()? {
            Certificate::SAT(a) => Some(a),
            Certificate::UNSAT => None,
        }
    }
    
    // When to keep the Result vs throw away the error:
    //
    //   .ok()              caller does not care WHY it failed.  Quick to write.
    //   keep the Result    caller needs the error info.  Use .map_err() (page 16)
    //                       or propagate with ? in a Result-returning function.
    //
    // In a library, prefer keeping the error (with thiserror, page 13).
    // .ok() is appropriate when the caller has decided that any failure
    // means the same thing — here, "this 3-SAT problem is unsolvable, somehow."
Karp, R. (1972) proved 3-SAT NP-complete by reduction from SAT. Source →
In plain terms

The two-versus-three boundary in logical constraints is the most studied and most useful complexity boundary in computer science. When every constraint involves exactly two variables, the problem is in the cheap category and solvable in linear time by a graph-based method. When constraints involve three or more variables, the problem is NP-complete.

The transition is genuinely a cliff, not a slope. No clever encoding makes a three-variable constraint problem secretly two-variable. The boundary has been studied for fifty years and never been crossed.

In practice, this matters most at the planning stage. When a product manager describes a constraint system in plain language, the key question is whether each rule could be stated as "if X then Y" — exactly two pieces — or whether each rule fundamentally requires three or more variables to express.

If two variables per rule are enough, the problem is 2-SAT and your team should ship the feature in a week using a graph library (page 15).

If three or more are required, the problem is at least 3-SAT and lives in the expensive category. The right move is to encode it as CNF and dispatch to an industrial SAT solver (page 23). The solver might handle it in seconds on real instances despite the worst-case theoretical hardness, but the engineering effort is the encoding, not the search.

The same SAT solvers handle both 2-SAT and 3-SAT through the same machinery — they do not specialize based on clause width. This means the modeling decision is entirely about how you express the constraints, not which tool you call.

In planning meetings, watch for constraints that try to relate three things at once: "the project lead must be in the same office as the project location and must speak the local language." That is a three-variable constraint and pushes the problem into the expensive category. If the requirement can be split into two two-variable constraints — "lead and location must be in the same office" and "lead must speak the local language" — you stay cheap.

Three variables is the cliff. Watch for it in requirements.

← 23 Boolean satisfiability — the original hard problem
24/39
25 Traveling Salesman — the poster problem →