diff --git a/Cargo.toml b/Cargo.toml index ea667542..e51f1fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "cspuz_rs", "cspuz_rs_puzzles", "cspuz_solver_backend", + "cspuz_bench", ] default-members = [ diff --git a/cspuz_bench/Cargo.toml b/cspuz_bench/Cargo.toml new file mode 100644 index 00000000..1b89809e --- /dev/null +++ b/cspuz_bench/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cspuz_bench" +version = "0.1.0" +edition = "2021" + +[lib] +name = "cspuz_bench" +crate-type = ["lib"] + +[features] +default = [] +experimental-backend-glucose-rs = ["cspuz_rs/experimental-backend-glucose-rs", "cspuz_rs_puzzles/experimental-backend-glucose-rs"] + +[dependencies] +cspuz_core = { path = "../cspuz_core", default-features = false } +cspuz_rs = { path = "../cspuz_rs", default-features = false, features = ["generator"]} +cspuz_rs_puzzles = { path = "../cspuz_rs_puzzles", default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +zstd = "0.13" +rand = "0.8.5" diff --git a/cspuz_bench/src/benchmarks/dbchoco.rs b/cspuz_bench/src/benchmarks/dbchoco.rs new file mode 100644 index 00000000..03ff9434 --- /dev/null +++ b/cspuz_bench/src/benchmarks/dbchoco.rs @@ -0,0 +1,38 @@ +use cspuz_rs_puzzles::puzzles::dbchoco::{deserialize_problem, solve_doublechoco}; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct DoubleChocoTask { + pub color: Vec>, + pub num: Vec>>, + pub expectation: Option, +} + +pub(super) fn run_benchmark( + task: &DoubleChocoTask, +) -> Result { + let color = task.color.clone(); + let num = task.num.clone(); + let expectation = task.expectation.as_ref().map(|e| e.to_cspuz_rs()); + + let (answer, bench_result) = super::run_with_bench_result(|| solve_doublechoco(&color, &num)); + if answer != expectation { + Err(super::BenchmarkError::AnswerMismatch) + } else { + Ok(bench_result) + } +} + +pub(super) fn materialize_solve_task(url: &str) -> super::Task { + let (color, num) = deserialize_problem(url).expect("Failed to deserialize problem"); + let expectation = solve_doublechoco(&color, &num) + .as_ref() + .map(super::BoolInnerGridEdgesIrrefutableFacts::from_cspuz_rs); + + super::Task::DoubleChoco(DoubleChocoTask { + color, + num, + expectation, + }) +} diff --git a/cspuz_bench/src/benchmarks/mod.rs b/cspuz_bench/src/benchmarks/mod.rs new file mode 100644 index 00000000..9cb63991 --- /dev/null +++ b/cspuz_bench/src/benchmarks/mod.rs @@ -0,0 +1,288 @@ +use cspuz_core::integration::{reset_thread_local_perf_stats, thread_local_perf_stats}; +use cspuz_rs::graph::BoolGridEdgesIrrefutableFacts as CspuzRsBoolGridEdgesIrrefutableFacts; +use cspuz_rs::graph::BoolInnerGridEdgesIrrefutableFacts as CspuzRsBoolInnerGridEdgesIrrefutableFacts; +use serde::{Deserialize, Serialize}; + +mod dbchoco; +mod nurikabe; +mod slitherlink; +mod yajilin; + +#[derive(Serialize, Deserialize)] +pub struct BoolGridEdgesIrrefutableFacts { + pub horizontal: Vec>>, + pub vertical: Vec>>, +} + +impl BoolGridEdgesIrrefutableFacts { + pub fn to_cspuz_rs(&self) -> CspuzRsBoolGridEdgesIrrefutableFacts { + CspuzRsBoolGridEdgesIrrefutableFacts { + horizontal: self.horizontal.clone(), + vertical: self.vertical.clone(), + } + } + + pub fn from_cspuz_rs(facts: &CspuzRsBoolGridEdgesIrrefutableFacts) -> Self { + Self { + horizontal: facts.horizontal.clone(), + vertical: facts.vertical.clone(), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct BoolInnerGridEdgesIrrefutableFacts { + pub horizontal: Vec>>, + pub vertical: Vec>>, +} + +impl BoolInnerGridEdgesIrrefutableFacts { + pub fn to_cspuz_rs(&self) -> CspuzRsBoolInnerGridEdgesIrrefutableFacts { + CspuzRsBoolInnerGridEdgesIrrefutableFacts { + horizontal: self.horizontal.clone(), + vertical: self.vertical.clone(), + } + } + + pub fn from_cspuz_rs(facts: &CspuzRsBoolInnerGridEdgesIrrefutableFacts) -> Self { + Self { + horizontal: facts.horizontal.clone(), + vertical: facts.vertical.clone(), + } + } +} + +pub struct BenchResult { + pub elapsed_time_seconds: f64, + pub sat_num_propagations: f64, +} + +fn accumulate(results: &[BenchResult]) -> BenchResult { + let elapsed_time_seconds = results.iter().map(|r| r.elapsed_time_seconds).sum(); + let sat_num_propagations = results.iter().map(|r| r.sat_num_propagations).sum(); + BenchResult { + elapsed_time_seconds, + sat_num_propagations, + } +} + +#[derive(Debug)] +pub enum BenchmarkError { + AnswerMismatch, +} + +fn run_with_bench_result(f: F) -> (T, BenchResult) +where + F: FnOnce() -> T, +{ + reset_thread_local_perf_stats(); + + let orig_default_config = cspuz_core::config::Config::default(); + let updated_default_config = cspuz_core::config::Config { + record_perf_stats_thread_local: true, + ..orig_default_config + }; + cspuz_core::config::Config::set_default(updated_default_config); + + let start = std::time::Instant::now(); + let answer = f(); + let elapsed_time_seconds = start.elapsed().as_secs_f64(); + + let perf_stats = thread_local_perf_stats(); + let sat_num_propagations = perf_stats.propagations(); + + cspuz_core::config::Config::set_default(orig_default_config); + + ( + answer, + BenchResult { + elapsed_time_seconds, + sat_num_propagations: sat_num_propagations as f64, + }, + ) +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Task { + DoubleChoco(dbchoco::DoubleChocoTask), + Nurikabe(nurikabe::NurikabeTask), + Slitherlink(slitherlink::SlitherlinkTask), + Yajilin(yajilin::YajilinTask), +} + +pub fn run_benchmark(task: &Task) -> Result { + match task { + Task::DoubleChoco(task) => dbchoco::run_benchmark(task), + Task::Nurikabe(task) => nurikabe::run_benchmark(task), + Task::Slitherlink(task) => slitherlink::run_benchmark(task), + Task::Yajilin(task) => yajilin::run_benchmark(task), + } +} + +#[derive(Serialize, Deserialize)] +struct BaseBenchmarkSet { + solve_tasks: Vec, + generate_tasks: Vec, +} + +#[derive(Serialize, Deserialize)] +struct BaseSolveTask { + name: String, + puzzle_type: String, + url: String, + comment: Option, +} + +#[derive(Serialize, Deserialize)] +struct BaseGenerateTaskSet { + name: String, + puzzle_type: String, + height: usize, + width: usize, + seed: u64, +} + +#[derive(Serialize, Deserialize)] +struct BenchmarkSet { + solve_tasks: Vec, + generate_tasks: Vec, +} + +#[derive(Serialize, Deserialize)] +struct SolveTask { + name: String, + puzzle_type: String, + task: Task, +} + +#[derive(Serialize, Deserialize)] +struct GenerateTaskSet { + name: String, + puzzle_type: String, + tasks: Vec, +} + +fn materialize_benchmark_set(base: BaseBenchmarkSet) -> BenchmarkSet { + let solve_tasks = base + .solve_tasks + .into_iter() + .map(|base_task| { + let task = match base_task.puzzle_type.as_str() { + "dbchoco" => dbchoco::materialize_solve_task(&base_task.url), + "nurikabe" => nurikabe::materialize_solve_task(&base_task.url), + "slitherlink" => slitherlink::materialize_solve_task(&base_task.url), + "yajilin" => yajilin::materialize_solve_task(&base_task.url), + _ => panic!("Unknown puzzle type: {}", base_task.puzzle_type), + }; + SolveTask { + name: base_task.name, + puzzle_type: base_task.puzzle_type, + task, + } + }) + .collect(); + + let generate_tasks = base + .generate_tasks + .into_iter() + .map(|base_task| match base_task.puzzle_type.as_str() { + "slitherlink" => slitherlink::materialize_generate_task(base_task), + "yajilin" => yajilin::materialize_generate_task(base_task), + _ => panic!("Unknown puzzle type: {}", base_task.puzzle_type), + }) + .collect(); + + BenchmarkSet { + solve_tasks, + generate_tasks, + } +} + +pub fn run_materialize(src_path: &str, dest_path: &str) -> Result<(), Box> { + let base_benchmark_set: BaseBenchmarkSet = + serde_json::from_reader(std::fs::File::open(src_path)?)?; + let benchmark_set = materialize_benchmark_set(base_benchmark_set); + let dest_file = std::fs::File::create(dest_path)?; + let mut encoder = zstd::stream::write::Encoder::new(dest_file, 0)?; + serde_json::to_writer(&mut encoder, &benchmark_set)?; + encoder.finish()?; + Ok(()) +} + +pub fn run_benchmarks(src_path: &str) { + let src_file = std::fs::File::open(src_path).unwrap(); + let decoder = zstd::stream::read::Decoder::new(src_file).unwrap(); + let benchmark_set: BenchmarkSet = serde_json::from_reader(decoder).unwrap(); + let mut results = vec![]; + + for solve_task in &benchmark_set.solve_tasks { + println!("Running solve benchmark: {}", solve_task.name); + let result = run_benchmark(&solve_task.task).unwrap(); + results.push(("solve", solve_task.name.as_str(), result)); + } + + for generate_task_set in &benchmark_set.generate_tasks { + println!("Running generate benchmark set: {}", generate_task_set.name); + + let mut task_results = vec![]; + for task in &generate_task_set.tasks { + let result = run_benchmark(task).unwrap(); + task_results.push(result); + } + let accumulated_result = accumulate(&task_results); + results.push(( + "generate", + generate_task_set.name.as_str(), + accumulated_result, + )); + } + + print_benchmark_results(&results); +} + +fn print_benchmark_results(results: &[(&str, &str, BenchResult)]) { + let kind_width = results + .iter() + .map(|(kind, _, _)| kind.len()) + .max() + .unwrap_or(0) + .max("Kind".len()); + let name_width = results + .iter() + .map(|(_, name, _)| name.len()) + .max() + .unwrap_or(0) + .max("Benchmark".len()); + let elapsed_header = "Elapsed (s)"; + let propagations_header = "SAT propagations"; + let separator = format!( + "+-{}-+-{}-+-{}-+-{}-+", + "-".repeat(kind_width), + "-".repeat(name_width), + "-".repeat(elapsed_header.len()), + "-".repeat(propagations_header.len()), + ); + + println!("{separator}"); + println!( + "| {:elapsed_width$} | {:>propagations_width$} |", + "Kind", + "Benchmark", + elapsed_header, + propagations_header, + elapsed_width = elapsed_header.len(), + propagations_width = propagations_header.len(), + ); + println!("{separator}"); + for (kind, name, result) in results { + println!( + "| {kind:elapsed_width$.3} | {:>propagations_width$.0} |", + result.elapsed_time_seconds, + result.sat_num_propagations, + elapsed_width = elapsed_header.len(), + propagations_width = propagations_header.len(), + ); + } + println!("{separator}"); +} diff --git a/cspuz_bench/src/benchmarks/nurikabe.rs b/cspuz_bench/src/benchmarks/nurikabe.rs new file mode 100644 index 00000000..e9c5de02 --- /dev/null +++ b/cspuz_bench/src/benchmarks/nurikabe.rs @@ -0,0 +1,30 @@ +use cspuz_rs_puzzles::puzzles::nurikabe::{deserialize_problem, solve_nurikabe}; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct NurikabeTask { + pub clues: Vec>>, + pub expectation: Option>>>, +} + +pub(super) fn run_benchmark( + task: &NurikabeTask, +) -> Result { + let clues = task.clues.clone(); + let expectation = task.expectation.clone(); + + let (answer, bench_result) = super::run_with_bench_result(|| solve_nurikabe(&clues)); + if answer != expectation { + Err(super::BenchmarkError::AnswerMismatch) + } else { + Ok(bench_result) + } +} + +pub(super) fn materialize_solve_task(url: &str) -> super::Task { + let clues = deserialize_problem(url).expect("Failed to deserialize problem"); + let expectation = solve_nurikabe(&clues); + + super::Task::Nurikabe(NurikabeTask { clues, expectation }) +} diff --git a/cspuz_bench/src/benchmarks/slitherlink.rs b/cspuz_bench/src/benchmarks/slitherlink.rs new file mode 100644 index 00000000..4df83071 --- /dev/null +++ b/cspuz_bench/src/benchmarks/slitherlink.rs @@ -0,0 +1,94 @@ +use cspuz_rs::generator; +use cspuz_rs_puzzles::puzzles::slitherlink::{deserialize_problem, solve_slitherlink}; + +use rand::SeedableRng; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; + +#[derive(Serialize, Deserialize)] +pub struct SlitherlinkTask { + pub problem: Vec>>, + pub expectation: Option, +} + +pub(super) fn run_benchmark( + task: &SlitherlinkTask, +) -> Result { + let problem = task.problem.clone(); + let expectation = task.expectation.as_ref().map(|e| e.to_cspuz_rs()); + + let (answer, bench_result) = + super::run_with_bench_result(|| solve_slitherlink(false, &problem)); + if answer != expectation { + Err(super::BenchmarkError::AnswerMismatch) + } else { + Ok(bench_result) + } +} + +pub(super) fn materialize_solve_task(url: &str) -> super::Task { + let (full, problem) = deserialize_problem(url).expect("Failed to deserialize problem"); + assert_eq!(full, false); + + let expectation = solve_slitherlink(false, &problem) + .map(|ans| super::BoolGridEdgesIrrefutableFacts::from_cspuz_rs(&ans)); + super::Task::Slitherlink(SlitherlinkTask { + problem, + expectation, + }) +} + +pub(super) fn materialize_generate_task( + base: super::BaseGenerateTaskSet, +) -> super::GenerateTaskSet { + assert_eq!(base.puzzle_type, "slitherlink"); + + let height = base.height; + let width = base.width; + let pattern = + vec![ + vec![ + generator::Choice::new(vec![None, Some(0), Some(1), Some(2), Some(3)], None); + width + ]; + height + ]; + + let trajectory = RefCell::new(vec![]); + let solve_with_record = |problem: &Vec>>| { + let answer = solve_slitherlink(false, problem); + + trajectory + .borrow_mut() + .push((problem.clone(), answer.clone())); + + answer + }; + + let mut rng = rand::rngs::StdRng::seed_from_u64(base.seed); + let _ = generator::Generator::new( + solve_with_record, + pattern, + generator::default_uniqueness_checker(), + generator::default_scorer(None, 5.0), + ) + .generate(&mut rng); + + let tasks = trajectory + .into_inner() + .into_iter() + .map(|(problem, answer)| { + let expectation = + answer.map(|ans| super::BoolGridEdgesIrrefutableFacts::from_cspuz_rs(&ans)); + super::Task::Slitherlink(SlitherlinkTask { + problem, + expectation, + }) + }) + .collect::>(); + super::GenerateTaskSet { + name: base.name, + puzzle_type: base.puzzle_type, + tasks, + } +} diff --git a/cspuz_bench/src/benchmarks/yajilin.rs b/cspuz_bench/src/benchmarks/yajilin.rs new file mode 100644 index 00000000..61863761 --- /dev/null +++ b/cspuz_bench/src/benchmarks/yajilin.rs @@ -0,0 +1,210 @@ +use cspuz_rs::generator::{self, DefaultScorableAnswer}; +use cspuz_rs::graph::BoolGridEdgesIrrefutableFacts as CspuzRsBoolGridEdgesIrrefutableFacts; +use cspuz_rs::items::{Arrow, NumberedArrow}; +use cspuz_rs_puzzles::puzzles::yajilin::{deserialize_problem, solve_yajilin}; +use rand::SeedableRng; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; + +#[derive(Serialize, Deserialize)] +pub enum YajilinDirection { + Unspecified, + Up, + Down, + Left, + Right, +} + +impl YajilinDirection { + fn to_cspuz_rs(&self) -> Arrow { + match self { + YajilinDirection::Unspecified => Arrow::Unspecified, + YajilinDirection::Up => Arrow::Up, + YajilinDirection::Down => Arrow::Down, + YajilinDirection::Left => Arrow::Left, + YajilinDirection::Right => Arrow::Right, + } + } + + fn from_cspuz_rs(direction: Arrow) -> Self { + match direction { + Arrow::Unspecified => YajilinDirection::Unspecified, + Arrow::Up => YajilinDirection::Up, + Arrow::Down => YajilinDirection::Down, + Arrow::Left => YajilinDirection::Left, + Arrow::Right => YajilinDirection::Right, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct YajilinIrrefutableFacts { + pub is_line: super::BoolGridEdgesIrrefutableFacts, + pub is_black: Vec>>, +} + +#[derive(Serialize, Deserialize)] +pub struct YajilinTask { + pub outside: bool, + pub clues: Vec>>, + pub expectation: Option, +} + +#[derive(Clone)] +struct YajilinAnswer { + is_line: CspuzRsBoolGridEdgesIrrefutableFacts, + is_black: Vec>>, +} + +impl DefaultScorableAnswer for YajilinAnswer { + fn score(&self) -> f64 { + self.is_line.score() + self.is_black.score() + } + + fn fully_solved(&self) -> bool { + self.is_line.fully_solved() && self.is_black.fully_solved() + } +} + +fn clues_to_cspuz_rs( + clues: &[Vec>], +) -> Vec>> { + clues + .iter() + .map(|row| { + row.iter() + .map(|clue| { + clue.as_ref() + .map(|(direction, number)| (direction.to_cspuz_rs(), *number)) + }) + .collect() + }) + .collect() +} + +fn clues_from_cspuz_rs( + clues: &[Vec>], +) -> Vec>> { + clues + .iter() + .map(|row| { + row.iter() + .map(|clue| { + clue.map(|(direction, number)| { + (YajilinDirection::from_cspuz_rs(direction), number) + }) + }) + .collect() + }) + .collect() +} + +pub(super) fn run_benchmark( + task: &YajilinTask, +) -> Result { + let clues = clues_to_cspuz_rs(&task.clues); + let expectation = task.expectation.as_ref().map(|expectation| { + ( + expectation.is_line.to_cspuz_rs(), + expectation.is_black.clone(), + ) + }); + + let (answer, bench_result) = + super::run_with_bench_result(|| solve_yajilin(task.outside, &clues)); + if answer != expectation { + Err(super::BenchmarkError::AnswerMismatch) + } else { + Ok(bench_result) + } +} + +pub(super) fn materialize_solve_task(url: &str) -> super::Task { + let (outside, clues) = deserialize_problem(url).expect("Failed to deserialize problem"); + let expectation = + solve_yajilin(outside, &clues).map(|(is_line, is_black)| YajilinIrrefutableFacts { + is_line: super::BoolGridEdgesIrrefutableFacts::from_cspuz_rs(&is_line), + is_black, + }); + + super::Task::Yajilin(YajilinTask { + outside, + clues: clues_from_cspuz_rs(&clues), + expectation, + }) +} + +fn clue_candidates(y: usize, x: usize, height: usize, width: usize) -> Vec> { + let directions_and_lengths = [ + (Arrow::Up, y), + (Arrow::Down, height - y - 1), + (Arrow::Left, x), + (Arrow::Right, width - x - 1), + ]; + + let mut candidates = vec![None]; + for (direction, length) in directions_and_lengths { + for number in 0..=((length + 1) / 2) { + candidates.push(Some((direction, number as i32))); + } + } + candidates +} + +pub(super) fn materialize_generate_task( + base: super::BaseGenerateTaskSet, +) -> super::GenerateTaskSet { + assert_eq!(base.puzzle_type, "yajilin"); + + let pattern = (0..base.height) + .map(|y| { + (0..base.width) + .map(|x| { + generator::Choice::new(clue_candidates(y, x, base.height, base.width), None) + }) + .collect() + }) + .collect::>>(); + + let trajectory = RefCell::new(vec![]); + let solve_with_record = |problem: &Vec>>| { + let answer = solve_yajilin(false, problem) + .map(|(is_line, is_black)| YajilinAnswer { is_line, is_black }); + trajectory + .borrow_mut() + .push((problem.clone(), answer.clone())); + answer + }; + + let mut rng = rand::rngs::StdRng::seed_from_u64(base.seed); + let score_is_line = generator::default_scorer(None, 20.0); + let _ = generator::Generator::new( + solve_with_record, + pattern, + generator::default_uniqueness_checker(), + |problem, answer: &YajilinAnswer| score_is_line(problem, &answer.is_line), + ) + .generate(&mut rng); + + let tasks = trajectory + .into_inner() + .into_iter() + .map(|(clues, answer)| { + let expectation = answer.map(|answer| YajilinIrrefutableFacts { + is_line: super::BoolGridEdgesIrrefutableFacts::from_cspuz_rs(&answer.is_line), + is_black: answer.is_black, + }); + super::Task::Yajilin(YajilinTask { + outside: false, + clues: clues_from_cspuz_rs(&clues), + expectation, + }) + }) + .collect(); + + super::GenerateTaskSet { + name: base.name, + puzzle_type: base.puzzle_type, + tasks, + } +} diff --git a/cspuz_bench/src/bin/bench.rs b/cspuz_bench/src/bin/bench.rs new file mode 100644 index 00000000..5db5d970 --- /dev/null +++ b/cspuz_bench/src/bin/bench.rs @@ -0,0 +1,9 @@ +fn main() { + // cspuz_bench/tasks/base.json -> cspuz_bench/tasks/bench.json.zst + // NOTE: relative to this file, not the current working directory + + cspuz_bench::benchmarks::run_benchmarks(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tasks/bench.json.zst" + )); +} diff --git a/cspuz_bench/src/bin/materialize_tasks.rs b/cspuz_bench/src/bin/materialize_tasks.rs new file mode 100644 index 00000000..1f4f9e08 --- /dev/null +++ b/cspuz_bench/src/bin/materialize_tasks.rs @@ -0,0 +1,7 @@ +fn main() { + cspuz_bench::benchmarks::run_materialize( + concat!(env!("CARGO_MANIFEST_DIR"), "/tasks/base.json"), + concat!(env!("CARGO_MANIFEST_DIR"), "/tasks/bench.json.zst"), + ) + .unwrap(); +} diff --git a/cspuz_bench/src/lib.rs b/cspuz_bench/src/lib.rs new file mode 100644 index 00000000..53b3f4db --- /dev/null +++ b/cspuz_bench/src/lib.rs @@ -0,0 +1 @@ +pub mod benchmarks; diff --git a/cspuz_bench/tasks/base.json b/cspuz_bench/tasks/base.json new file mode 100644 index 00000000..514c30e4 --- /dev/null +++ b/cspuz_bench/tasks/base.json @@ -0,0 +1,63 @@ +{ + "solve_tasks": [ + { + "name": "dbchoco_0", + "puzzle_type": "dbchoco", + "url": "https://puzz.link/p?dbchoco/10/10/v0v30r0rororooo0rs3szx8g7k6zx9t", + "comment": "https://puzsq.logicpuzzle.app/puzzle/22059" + }, + { + "name": "dbchoco_1_multiple_answers", + "puzzle_type": "dbchoco", + "url": "https://puzz.link/p?dbchoco/10/10/v0v30r0rororooo0rs3szx8g7k5zx9t", + "comment": "Some clues are changed from https://puzsq.logicpuzzle.app/puzzle/22059" + }, + { + "name": "nurikabe_0_multiple_answers", + "puzzle_type": "nurikabe", + "url": "https://puzz.link/p?nurikabe/10/10/zsai4xelazv" + }, + { + "name": "yajilin_0", + "puzzle_type": "yajilin", + "url": "https://puzz.link/p?yajilin/17/17/t41f30b40a40f23b26y21a46j42n24d24a20r43i40l42i12r45a44g41r32e20e22b31j22h13b20j33h13c41w", + "comment": "https://puzsq.logicpuzzle.app/puzzle/7657" + }, + { + "name": "yajilin_1_multiple_answers", + "puzzle_type": "yajilin", + "url": "https://puzz.link/p?yajilin/17/17/t41f30b40a40f23b25y21a45j42n24d24a20r43i40l42i12r45a44g41r32e20e22b31j22h13b20j32h13c41w", + "comment": "Some clues are changed from https://puzsq.logicpuzzle.app/puzzle/7657" + } + ], + "generate_tasks": [ + { + "name": "slitherlink_10x10_0", + "puzzle_type": "slitherlink", + "height": 10, + "width": 10, + "seed": 4200 + }, + { + "name": "slitherlink_10x10_1", + "puzzle_type": "slitherlink", + "height": 10, + "width": 10, + "seed": 4201 + }, + { + "name": "slitherlink_10x18_0", + "puzzle_type": "slitherlink", + "height": 10, + "width": 18, + "seed": 4300 + }, + { + "name": "yajilin_10x10_0", + "puzzle_type": "yajilin", + "height": 10, + "width": 10, + "seed": 4400 + } + ] +}