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.
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.
- 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, } } -
Vec<[i32; 3]>is aVecwhose elements are fixed-size arrays of exactly threei32values. Arrays in Rust are written[T; N]whereNis the length, known at compile time. UnlikeVec<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 typeVec<[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 -
Solver::try_frominvokes aTryFromimplementation — 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). CallingFrom::from(x)returns the converted value directly.TryFrom<T>is for conversions that might fail: converting ani64toi32could 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. CallingTryFrom::try_from(x)returnsResult<Self, Self::Error>.splrimplementsTryFromforSolverso callers can build a solver from a config-and-formula tuple and get aResultback 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 -
.ok()is a method onResult<T, E>that discards the error and returns anOption<T>—Ok(t).ok() == Some(t)andErr(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 returnsOption<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 returnNoneon any failure. The companion method.err()returnsOption<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."