pub mod database;
pub mod family;
pub mod fingerprint;
pub use database::{Database, Strategy, display_name};
pub use family::FamilyMap;
pub use fingerprint::{MusFingerprint, fingerprint};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const DEFAULT_DB_SEARCH_PATHS: &[&str] = &[
"demystify/named-strategies",
"named-strategies",
"../demystify/named-strategies",
];
pub fn load_or_discover(explicit: Option<&Path>) -> anyhow::Result<Arc<Database>> {
let dir: Option<PathBuf> = explicit.map(PathBuf::from).or_else(|| {
DEFAULT_DB_SEARCH_PATHS
.iter()
.map(PathBuf::from)
.find(|p| p.exists())
});
match dir {
Some(d) => Ok(Arc::new(Database::load_from_dir(&d)?)),
None => Ok(Arc::new(Database::empty())),
}
}
#[cfg(test)]
mod integration_tests {
use std::path::PathBuf;
use std::sync::Arc;
use crate::named_strategy::Database;
use crate::problem::planner::PuzzlePlanner;
use crate::problem::solver::PuzzleSolver;
use crate::problem::util::test_utils::build_puzzleparse;
fn db_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("named-strategies")
}
fn run_named_solve(param_rel: &str) -> Vec<String> {
let parse = build_puzzleparse("../eprime/sudoku.eprime", param_rel);
let solver = PuzzleSolver::new(Arc::new(parse)).expect("solver init");
let db = Arc::new(Database::load_from_dir(&db_path()).expect("db load"));
let mut planner = PuzzlePlanner::new(solver).with_database(db);
planner
.quick_solve()
.into_iter()
.flatten()
.filter_map(|um| um.name)
.collect()
}
#[test]
fn shipped_db_loads_without_errors() {
let db = Database::load_from_dir(&db_path()).expect("DB should load");
assert!(
!db.is_empty(),
"shipped Sudoku.toml should contain at least one strategy"
);
}
#[test]
fn shipped_db_orientation_groups_exist_in_sudoku_model() {
let parse = build_puzzleparse(
"../eprime/sudoku.eprime",
"../eprime/sudoku/sudokuwiki/hiddensingles/hiddensingles.param",
);
let declared_groups: std::collections::BTreeSet<&str> =
parse.eprime.families.keys().map(String::as_str).collect();
let db_file: toml::Value = toml::from_str(
&std::fs::read_to_string(db_path().join("Sudoku.toml"))
.expect("Sudoku.toml should exist"),
)
.expect("Sudoku.toml should be valid TOML");
let strategies = db_file
.get("strategy")
.and_then(toml::Value::as_array)
.expect("Sudoku.toml should contain a [[strategy]] array");
let mut bad: Vec<(String, String)> = Vec::new();
for entry in strategies {
let name = entry
.get("name")
.and_then(toml::Value::as_str)
.expect("strategy missing 'name'");
if let Some(group) = entry.get("orientation_group").and_then(toml::Value::as_str)
&& !declared_groups.contains(group)
{
bad.push((name.to_string(), group.to_string()));
}
}
assert!(
bad.is_empty(),
"Sudoku.toml references orientation_group(s) not declared in eprime/sudoku.eprime: {bad:?}"
);
}
#[test]
fn fingerprint_pipeline_produces_named_steps_on_hidden_singles() {
let names =
run_named_solve("../eprime/sudoku/sudokuwiki/hiddensingles/hiddensingles.param");
assert!(
!names.is_empty(),
"expected at least one named step; got none"
);
assert!(
names.iter().any(|n| n.ends_with("hidden single")),
"expected at least one 'hidden single' step; names were: {names:?}"
);
}
#[test]
#[ignore = "flaky: rayon-driven MUS search non-determinism sometimes routes the puzzle through hidden singles only"]
fn fingerprint_pipeline_finds_hidden_pair_and_triple() {
let names =
run_named_solve("../eprime/sudoku/sudokuwiki/hiddenpairstriples/hiddentriples.param");
assert!(
names.iter().any(|n| n.ends_with("hidden pair")),
"expected at least one 'hidden pair' step; names were: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("hidden triple")),
"expected at least one 'hidden triple' step; names were: {names:?}"
);
}
#[test]
fn named_steps_carry_orientation_prefix() {
let names =
run_named_solve("../eprime/sudoku/sudokuwiki/hiddensingles/hiddensingles.param");
let prefixed: Vec<&String> = names
.iter()
.filter(|n| n.starts_with("Row ") || n.starts_with("Column ") || n.starts_with("Box "))
.collect();
assert!(
!prefixed.is_empty(),
"expected at least one orientation-prefixed name; names were: {names:?}"
);
}
#[test]
fn all_alternatives_for_literal_returns_named_muses() {
let parse = build_puzzleparse(
"../eprime/sudoku.eprime",
"../eprime/sudoku/sudokuwiki/hiddensingles/hiddensingles.param",
);
let solver = PuzzleSolver::new(Arc::new(parse)).expect("solver init");
let db = Arc::new(Database::load_from_dir(&db_path()).expect("db load"));
let mut planner = PuzzlePlanner::new(solver).with_database(db);
let first_step = planner
.quick_solve()
.into_iter()
.next()
.expect("first step");
let first_um = first_step.into_iter().next().expect("first mus");
let first_lit = first_um.lits.iter().next().expect("first lit").clone();
let mut lit_def: Vec<i64> = first_lit.var().indices().clone();
lit_def.push(first_lit.val());
let parse2 = build_puzzleparse(
"../eprime/sudoku.eprime",
"../eprime/sudoku/sudokuwiki/hiddensingles/hiddensingles.param",
);
let solver2 = PuzzleSolver::new(Arc::new(parse2)).expect("solver init 2");
let db2 = Arc::new(Database::load_from_dir(&db_path()).expect("db load 2"));
let mut planner2 = PuzzlePlanner::new(solver2).with_database(db2);
let alts = planner2.all_alternatives_for_literal(lit_def);
assert!(
!alts.is_empty(),
"expected at least one alternative MUS for lit {first_lit:?}"
);
for um in &alts {
assert!(
!um.fingerprint.is_empty(),
"every UserMus must carry a fingerprint"
);
}
let sizes: Vec<usize> = alts.iter().map(|u| u.constraints.len()).collect();
let mut sorted = sizes.clone();
sorted.sort();
assert_eq!(sizes, sorted, "alternatives must be sorted smallest-first");
}
}