From 9bda297323b8e3a45c77c4e893c439144db4b352 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 11:17:24 +0800 Subject: [PATCH 1/4] Add plan for #409: [Model] RootedTreeStorageAssignment --- ...26-03-22-rooted-tree-storage-assignment.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/plans/2026-03-22-rooted-tree-storage-assignment.md diff --git a/docs/plans/2026-03-22-rooted-tree-storage-assignment.md b/docs/plans/2026-03-22-rooted-tree-storage-assignment.md new file mode 100644 index 000000000..ffd110380 --- /dev/null +++ b/docs/plans/2026-03-22-rooted-tree-storage-assignment.md @@ -0,0 +1,199 @@ +# Plan: Add RootedTreeStorageAssignment Model + +**Issue:** #409 — [Model] RootedTreeStorageAssignment +**Skill:** add-model +**Execution:** `issue-to-pr` Batch 1 covers add-model Steps 1-5 plus the canonical example wiring; Batch 2 covers add-model Step 6 (paper) after the model, CLI path, and tests are stable. + +## Issue Packet Summary + +- `Good` label is present and `issue-context` returned `action=create-pr`, so this run should create a new PR from branch `issue-409`. +- Use the maintainer fix comments on #409 as the implementation source of truth for the concrete encoding, cleaned-up examples, and complexity string. +- Associated open rule issue exists: #424 `[Rule] Rooted Tree Arrangement to Rooted Tree Storage Assignment`. + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `RootedTreeStorageAssignment` | +| 2 | Mathematical definition | Given a finite set `X`, a collection `C = {X_1, ..., X_n}` of subsets of `X`, and an integer `K`, decide whether there exists a directed rooted tree `T = (X, A)` and supersets `X_i' ⊇ X_i` such that every `X_i'` forms a directed path in `T` and `sum_i |X_i' - X_i| <= K` | +| 3 | Problem type | Satisfaction (`Metric = bool`) | +| 4 | Type parameters | None | +| 5 | Struct fields | `universe_size: usize`, `subsets: Vec>`, `bound: usize` | +| 6 | Configuration space | `vec![universe_size; universe_size]`, where `config[v]` is the parent of vertex `v` and the root satisfies `config[root] = root` | +| 7 | Feasibility check | The config must encode exactly one rooted tree on `0..universe_size`, and every subset must lie on a single ancestor-descendant chain so its minimal path extension is well-defined | +| 8 | Objective function | `bool` — true iff the total minimal extension cost across all subsets is at most `bound` | +| 9 | Best known exact algorithm | Brute-force over parent arrays; use complexity string `"universe_size^universe_size"` with getters `universe_size()` and `num_subsets()` | +| 10 | Solving strategy | Existing `BruteForce` works directly over the parent-array encoding; no ILP path is required in this issue | +| 11 | Category | `set` | +| 12 | Expected outcome | The canonical YES instance uses `X = {0,1,2,3,4}`, subsets `[{0,2},{1,3},{0,4},{2,4}]`, `K = 1`, and satisfying config `[0,0,0,1,2]` (tree edges `0→1`, `0→2`, `1→3`, `2→4`) | + +## Design Decisions + +### Category and API shape + +- Implement this under `src/models/set/` because the input is a universe plus subset family, and the closest existing models are `ConsecutiveSets`, `TwoDimensionalConsecutiveSets`, and `SetBasis`. +- Follow the newer validated-constructor pattern used by `EnsembleComputation` and `TwoDimensionalConsecutiveSets`: expose `try_new(...) -> Result`, keep `new(...)` as the panicking convenience wrapper, and validate again on deserialize. + +### Tree encoding and evaluation semantics + +- Keep the parent-array encoding from the maintainer fix comment: `config[v] = parent(v)` with exactly one root encoded by `config[root] = root`. +- `evaluate()` should: + 1. Reject wrong-length configs and parent values outside `0..universe_size`. + 2. Validate that the config encodes exactly one rooted tree (one self-parent root, every other node reaches that root, no cycles). + 3. Precompute `depth[v]` and an `is_ancestor(u, v)` helper. + 4. For each subset, verify that all vertices are pairwise comparable by ancestry after sorting by depth. + 5. Compute the minimal path-extension cost as the number of vertices on the shallowest-to-deepest path that are not already in the subset. + 6. Return `false` immediately if any subset is infeasible or if the running total exceeds `bound`. + +### CLI and registry notes + +- Registry-backed discovery comes from `ProblemSchemaEntry` plus `declare_variants!`; do not add manual load/serialize dispatch code. +- `problem_name.rs` should not need changes unless a test proves otherwise, because `find_problem_type_by_alias()` already matches canonical names case-insensitively. +- CLI creation can reuse the existing `--universe`, `--sets`, and `--bound` flags, so only `create.rs` and the help table in `cli.rs` should need updates. + +## Batch 1: Model, CLI, Example, Tests + +### Step 1: Write the failing model tests first + +**Files:** +- Create: `src/unit_tests/models/set/rooted_tree_storage_assignment.rs` + +Add tests that fail before the model exists: + +1. `test_rooted_tree_storage_assignment_creation` + - Construct the issue's YES instance. + - Assert `universe_size() == 5`, `num_subsets() == 4`, `bound() == 1`, and `dims() == vec![5; 5]`. +2. `test_rooted_tree_storage_assignment_evaluate_yes_instance` + - Assert `evaluate(&[0, 0, 0, 1, 2])` is `true`. +3. `test_rooted_tree_storage_assignment_rejects_invalid_tree_configs` + - Cover wrong length, out-of-range parent, multiple roots, and a directed cycle. +4. `test_rooted_tree_storage_assignment_no_instance` + - Use the same subsets with `bound = 0`, run `BruteForce::find_all_satisfying`, assert no solutions. +5. `test_rooted_tree_storage_assignment_solver_finds_known_solution` + - Run brute force on the YES instance and assert `[0, 0, 0, 1, 2]` is among the satisfying configs. +6. `test_rooted_tree_storage_assignment_serialization` + - Round-trip serde and confirm normalized subsets/bound survive. + +Run the targeted test and confirm it fails because the model is not registered yet. + +### Step 2: Add the model implementation + +**Files:** +- Create: `src/models/set/rooted_tree_storage_assignment.rs` + +Implement add-model Steps 1, 1.5, and 2 here: + +1. Register `ProblemSchemaEntry` with: + - `name = "RootedTreeStorageAssignment"` + - `display_name = "Rooted Tree Storage Assignment"` + - `aliases = &[]` + - `fields = universe_size / subsets / bound` +2. Define the struct plus validated deserialize helper. +3. Add `try_new`, `new`, and getters: + - `universe_size()` + - `num_subsets()` + - `bound()` + - `subsets()` +4. Normalize subsets into sorted unique vectors and reject out-of-range elements. +5. Implement private helpers for: + - tree validation / root detection + - depth computation + - ancestor checks + - per-subset extension-cost computation +6. Implement `Problem`: + - `NAME = "RootedTreeStorageAssignment"` + - `Metric = bool` + - `dims() = vec![self.universe_size; self.universe_size]` + - `evaluate()` per the design above + - `variant() = crate::variant_params![]` +7. Implement `SatisfactionProblem`. +8. Add `declare_variants! { default sat RootedTreeStorageAssignment => "universe_size^universe_size" }`. +9. Add `canonical_model_example_specs()` using the issue's YES instance and config `[0, 0, 0, 1, 2]`. +10. Link the test file with `#[cfg(test)] #[path = "../../unit_tests/models/set/rooted_tree_storage_assignment.rs"]`. + +After each chunk, rerun the focused unit test until it turns green. + +### Step 3: Register the model and example chain + +**Files:** +- Modify: `src/models/set/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +1. Add the new module and `pub use` in `src/models/set/mod.rs`. +2. Extend the set-category `canonical_model_example_specs()` chain with `rooted_tree_storage_assignment::canonical_model_example_specs()`. +3. Re-export `RootedTreeStorageAssignment` from `src/models/mod.rs`. +4. Add it to the crate prelude in `src/lib.rs`. + +Run a focused compile/test command after registration so missing exports show up early. + +### Step 4: Add CLI creation support and CLI regression coverage + +**Files:** +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/cli.rs` + +1. In `problemreductions-cli/src/commands/create.rs`: + - Import `RootedTreeStorageAssignment`. + - Add an `example_for()` entry like: + - `"RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1"` + - Add the `match` arm that parses `--universe`, `--sets`, and `--bound`, converts `--bound` to `usize`, and constructs the validated model. +2. Add a CLI regression test near the existing JSON creation tests: + - `test_create_rooted_tree_storage_assignment_json` + - Assert the JSON `type`, `universe_size`, `subsets`, and `bound`. +3. In `problemreductions-cli/src/cli.rs`, add `RootedTreeStorageAssignment --universe, --sets, --bound` to the help table. +4. Do not touch `problem_name.rs` unless a failing test proves the canonical-name lookup is insufficient. + +Run the new CLI test first in RED/GREEN style, then rerun the model tests. + +### Step 5: Batch-1 verification + +Run enough fresh verification to justify the implementation commit before moving to paper work: + +```bash +cargo test rooted_tree_storage_assignment +cargo test -p problemreductions-cli test_create_rooted_tree_storage_assignment_json +make test +make clippy +``` + +If `make test` or `make clippy` surfaces unrelated failures, stop and record them in the PR summary instead of papering over them. + +## Batch 2: Paper Entry and Paper-Example Alignment + +### Step 6: Document the model in the Typst paper + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Modify: `src/unit_tests/models/set/rooted_tree_storage_assignment.rs` + +1. Add the display-name dictionary entry for `RootedTreeStorageAssignment`. +2. Add `#problem-def("RootedTreeStorageAssignment")[...][...]` with: + - the corrected formal definition from the issue packet + - short background plus the Garey & Johnson / Gavril citations already accepted in the issue review + - an example based on the canonical YES instance + - a `pred-commands()` block derived from the canonical example data +3. Add or refresh `test_rooted_tree_storage_assignment_paper_example` so the unit test matches the paper's exact instance and satisfying config. +4. Re-run `make paper` and fix any schema/example-export mismatches it reports. + +## Final Verification and Handoff + +Before the implementation summary comment and push, re-run the full verification set: + +```bash +make test +make clippy +make paper +``` + +Expected implementation commits: + +1. `Add plan for #409: [Model] RootedTreeStorageAssignment` +2. `Implement #409: [Model] RootedTreeStorageAssignment` +3. `chore: remove plan file after implementation` + +When posting the PR summary, explicitly call out: + +- the parent-array encoding choice +- the fact that CLI reused existing `--universe/--sets/--bound` flags +- any deviations from this plan From dff2afa38b16cda486cec78c28123fa376547c81 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 11:30:28 +0800 Subject: [PATCH 2/4] Implement #409: [Model] RootedTreeStorageAssignment --- docs/paper/reductions.typ | 65 +++++ problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 60 +++++ src/lib.rs | 3 +- src/models/mod.rs | 4 +- src/models/set/mod.rs | 4 + .../set/rooted_tree_storage_assignment.rs | 242 ++++++++++++++++++ .../set/rooted_tree_storage_assignment.rs | 72 ++++++ 8 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 src/models/set/rooted_tree_storage_assignment.rs create mode 100644 src/unit_tests/models/set/rooted_tree_storage_assignment.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9167a0c15..a47f55024 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -147,6 +147,7 @@ "QuantifiedBooleanFormulas": [Quantified Boolean Formulas (QBF)], "RectilinearPictureCompression": [Rectilinear Picture Compression], "ResourceConstrainedScheduling": [Resource Constrained Scheduling], + "RootedTreeStorageAssignment": [Rooted Tree Storage Assignment], "SchedulingWithIndividualDeadlines": [Scheduling With Individual Deadlines], "SequencingToMinimizeMaximumCumulativeCost": [Sequencing to Minimize Maximum Cumulative Cost], "SequencingToMinimizeWeightedCompletionTime": [Sequencing to Minimize Weighted Completion Time], @@ -2512,6 +2513,70 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("RootedTreeStorageAssignment") + let n = x.instance.universe_size + let subsets = x.instance.subsets + let m = subsets.len() + let K = x.instance.bound + let config = x.optimal_config + let edges = config.enumerate().filter(((v, p)) => v != p).map(((v, p)) => (p, v)) + let fmt-set(s) = "${" + s.map(e => str(e)).join(", ") + "}$" + let highlight-nodes = (0, 2, 4) + let highlight-edges = ((0, 2), (2, 4)) + [ + #problem-def("RootedTreeStorageAssignment")[ + Given a finite set $X = {0, 1, dots, #(n - 1)}$, a collection $cal(C) = {X_1, dots, X_m}$ of subsets of $X$, and a nonnegative integer $K$, find a directed rooted tree $T = (X, A)$ and supersets $X_i' supset.eq X_i$ such that every $X_i'$ forms a directed path in $T$ and $sum_(i = 1)^m |X_i' backslash X_i| <= K$. + ][ + Rooted Tree Storage Assignment is the storage-and-retrieval problem SR5 in Garey and Johnson @garey1979. Their catalog credits a reduction from Rooted Tree Arrangement, framing the problem as hierarchical file organization: pick a rooted tree on the records so every request set can be completed to a single root-to-leaf path using only a limited number of extra records. The implementation here uses one parent variable per element of $X$, so the direct exhaustive bound is $|X|^(|X|)$ candidate parent arrays, filtered down to valid rooted trees#footnote[No exact algorithm improving on the direct parent-array search bound is claimed here for the general formulation.]. + + *Example.* Let $X = {0, 1, dots, #(n - 1)}$, $K = #K$, and $cal(C) = {#range(m).map(i => $X_#(i + 1)$).join(", ")}$ with #subsets.enumerate().map(((i, s)) => $X_#(i + 1) = #fmt-set(s)$).join(", "). The satisfying parent array $p = (#config.map(str).join(", "))$ encodes the rooted tree with arcs #edges.map(((u, v)) => $(#u, #v)$).join(", "). In this tree, $X_1 = {0, 2}$, $X_2 = {1, 3}$, and $X_4 = {2, 4}$ are already directed paths. The only extension is $X_3 = {0, 4}$, which becomes $X_3' = {0, 2, 4}$ along the path $0 -> 2 -> 4$, so the total extension cost is exactly $1 = K$. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o rooted-tree-storage-assignment.json", + "pred solve rooted-tree-storage-assignment.json --solver brute-force", + "pred evaluate rooted-tree-storage-assignment.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + + let positions = ( + (1.5, 1.8), + (0.6, 0.9), + (2.4, 0.9), + (0.6, 0.0), + (2.4, 0.0), + ) + + for (u, v) in edges { + let highlighted = highlight-edges.contains((u, v)) + line( + positions.at(u), + positions.at(v), + stroke: if highlighted { 1.2pt + graph-colors.at(0) } else { 0.8pt + luma(140) }, + mark: (end: "straight", scale: 0.45), + ) + } + + for (vertex, pos) in positions.enumerate() { + let highlighted = highlight-nodes.contains(vertex) + circle( + pos, + radius: 0.2, + fill: if highlighted { graph-colors.at(0) } else { white }, + stroke: 0.6pt + black, + ) + content(pos, if highlighted { text(fill: white)[$#vertex$] } else { [$#vertex$] }) + } + }), + caption: [Rooted Tree Storage Assignment example. The rooted tree encoded by $p = (#config.map(str).join(", "))$ is shown; the blue path $0 -> 2 -> 4$ is the unique extension needed to realize $X_3 = {0, 4}$ within total cost $K = #K$.], + ) + ] + ] +} + #{ let x = load-model-example("TwoDimensionalConsecutiveSets") let n = x.instance.alphabet_size diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 02df552d7..b4cbf8876 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -251,6 +251,7 @@ Flags by problem type: SetBasis --universe, --sets, --k MinimumCardinalityKey --num-attributes, --dependencies, --k PrimeAttributeName --universe, --deps, --query + RootedTreeStorageAssignment --universe, --sets, --bound TwoDimensionalConsecutiveSets --alphabet-size, --sets BicliqueCover --left, --right, --biedges, --k BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index d03de0341..465d60b1d 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -553,6 +553,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\" --budget 4", + "RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1", "MinMaxMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 2" } @@ -2335,6 +2336,33 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // RootedTreeStorageAssignment + "RootedTreeStorageAssignment" => { + let usage = + "Usage: pred create RootedTreeStorageAssignment --universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1"; + let universe_size = args.universe.ok_or_else(|| { + anyhow::anyhow!("RootedTreeStorageAssignment requires --universe\n\n{usage}") + })?; + let subsets = parse_sets(args)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("RootedTreeStorageAssignment requires --bound\n\n{usage}") + })?; + let bound = parse_nonnegative_usize_bound( + bound, + "RootedTreeStorageAssignment", + usage, + )?; + ( + ser(problemreductions::models::set::RootedTreeStorageAssignment::try_new( + universe_size, + subsets, + bound, + ) + .map_err(anyhow::Error::msg)?)?, + resolved_variant.clone(), + ) + } + // BicliqueCover "BicliqueCover" => { let usage = "pred create BicliqueCover --left 2 --right 2 --biedges 0-0,0-1,1-1 --k 2"; @@ -6138,6 +6166,38 @@ mod tests { std::fs::remove_file(output_path).ok(); } + #[test] + fn test_create_rooted_tree_storage_assignment_json() { + let mut args = empty_args(); + args.problem = Some("RootedTreeStorageAssignment".to_string()); + args.universe = Some(5); + args.sets = Some("0,2;1,3;0,4;2,4".to_string()); + args.bound = Some(1); + + let output_path = + std::env::temp_dir().join("pred_test_create_rooted_tree_storage_assignment.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "RootedTreeStorageAssignment"); + assert_eq!(json["data"]["universe_size"], 5); + assert_eq!( + json["data"]["subsets"], + serde_json::json!([[0, 2], [1, 3], [0, 4], [2, 4]]) + ); + assert_eq!(json["data"]["bound"], 1); + + std::fs::remove_file(output_path).ok(); + } + #[test] fn test_create_stacker_crane_json() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index f9e84dca0..504189910 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,8 @@ pub mod prelude { }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, - MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, SetBasis, + MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, + RootedTreeStorageAssignment, SetBasis, }; // Core traits diff --git a/src/models/mod.rs b/src/models/mod.rs index d15d4f5d6..fc60a0877 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,6 +45,6 @@ pub use misc::{ }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, - MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, SetBasis, - TwoDimensionalConsecutiveSets, + MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, + RootedTreeStorageAssignment, SetBasis, TwoDimensionalConsecutiveSets, }; diff --git a/src/models/set/mod.rs b/src/models/set/mod.rs index d96b2d3e2..136a74bb2 100644 --- a/src/models/set/mod.rs +++ b/src/models/set/mod.rs @@ -8,6 +8,7 @@ //! - [`MinimumHittingSet`]: Minimum-size universe subset hitting every set //! - [`MinimumSetCovering`]: Minimum weight set cover //! - [`PrimeAttributeName`]: Determine if an attribute belongs to any candidate key +//! - [`RootedTreeStorageAssignment`]: Extend subsets to directed tree paths within a total-cost bound pub(crate) mod comparative_containment; pub(crate) mod consecutive_sets; @@ -17,6 +18,7 @@ pub(crate) mod minimum_cardinality_key; pub(crate) mod minimum_hitting_set; pub(crate) mod minimum_set_covering; pub(crate) mod prime_attribute_name; +pub(crate) mod rooted_tree_storage_assignment; pub(crate) mod set_basis; pub(crate) mod two_dimensional_consecutive_sets; @@ -28,6 +30,7 @@ pub use minimum_cardinality_key::MinimumCardinalityKey; pub use minimum_hitting_set::MinimumHittingSet; pub use minimum_set_covering::MinimumSetCovering; pub use prime_attribute_name::PrimeAttributeName; +pub use rooted_tree_storage_assignment::RootedTreeStorageAssignment; pub use set_basis::SetBasis; pub use two_dimensional_consecutive_sets::TwoDimensionalConsecutiveSets; @@ -42,6 +45,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Collection of subsets of X" }, + FieldInfo { name: "bound", type_name: "usize", description: "Upper bound K on the total extension cost" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "RootedTreeStorageAssignmentDef")] +pub struct RootedTreeStorageAssignment { + universe_size: usize, + subsets: Vec>, + bound: usize, +} + +#[derive(Debug, Deserialize)] +struct RootedTreeStorageAssignmentDef { + universe_size: usize, + subsets: Vec>, + bound: usize, +} + +impl RootedTreeStorageAssignment { + pub fn new(universe_size: usize, subsets: Vec>, bound: usize) -> Self { + Self::try_new(universe_size, subsets, bound).unwrap_or_else(|err| panic!("{err}")) + } + + pub fn try_new( + universe_size: usize, + subsets: Vec>, + bound: usize, + ) -> Result { + let subsets = subsets + .into_iter() + .enumerate() + .map(|(subset_index, mut subset)| { + let mut seen = HashSet::with_capacity(subset.len()); + for &element in &subset { + if element >= universe_size { + return Err(format!( + "subset {subset_index} contains element {element} outside universe of size {universe_size}" + )); + } + if !seen.insert(element) { + return Err(format!( + "subset {subset_index} contains duplicate element {element}" + )); + } + } + subset.sort_unstable(); + Ok(subset) + }) + .collect::, _>>()?; + + Ok(Self { + universe_size, + subsets, + bound, + }) + } + + pub fn universe_size(&self) -> usize { + self.universe_size + } + + pub fn num_subsets(&self) -> usize { + self.subsets.len() + } + + pub fn subsets(&self) -> &[Vec] { + &self.subsets + } + + pub fn bound(&self) -> usize { + self.bound + } + + fn analyze_tree(config: &[usize]) -> Option> { + let roots = config + .iter() + .enumerate() + .filter(|(vertex, parent)| *vertex == **parent) + .count(); + if roots != 1 { + return None; + } + + let n = config.len(); + let mut state = vec![0u8; n]; + let mut depth = vec![0usize; n]; + + fn visit(vertex: usize, config: &[usize], state: &mut [u8], depth: &mut [usize]) -> bool { + match state[vertex] { + 2 => return true, + 1 => return false, + _ => {} + } + + state[vertex] = 1; + let parent = config[vertex]; + if parent == vertex { + depth[vertex] = 0; + } else { + if !visit(parent, config, state, depth) { + return false; + } + depth[vertex] = depth[parent] + 1; + } + state[vertex] = 2; + true + } + + for vertex in 0..n { + if !visit(vertex, config, &mut state, &mut depth) { + return None; + } + } + + Some(depth) + } + + fn is_ancestor(ancestor: usize, mut vertex: usize, config: &[usize], depth: &[usize]) -> bool { + if depth[ancestor] > depth[vertex] { + return false; + } + + while depth[vertex] > depth[ancestor] { + vertex = config[vertex]; + } + + ancestor == vertex + } + + fn subset_extension_cost(&self, subset: &[usize], config: &[usize], depth: &[usize]) -> Option { + if subset.len() <= 1 { + return Some(0); + } + + let mut ordered = subset.to_vec(); + ordered.sort_by_key(|&vertex| depth[vertex]); + + for pair in ordered.windows(2) { + if !Self::is_ancestor(pair[0], pair[1], config, depth) { + return None; + } + } + + let top = ordered[0]; + let bottom = *ordered.last().unwrap(); + Some(depth[bottom] - depth[top] + 1 - ordered.len()) + } +} + +impl Problem for RootedTreeStorageAssignment { + const NAME: &'static str = "RootedTreeStorageAssignment"; + type Metric = bool; + + fn dims(&self) -> Vec { + vec![self.universe_size; self.universe_size] + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.universe_size { + return false; + } + if config.iter().any(|&parent| parent >= self.universe_size) { + return false; + } + if self.universe_size == 0 { + return self.subsets.is_empty(); + } + + let Some(depth) = Self::analyze_tree(config) else { + return false; + }; + + let mut total_cost = 0usize; + for subset in &self.subsets { + let Some(cost) = self.subset_extension_cost(subset, config, &depth) else { + return false; + }; + total_cost += cost; + if total_cost > self.bound { + return false; + } + } + + true + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl SatisfactionProblem for RootedTreeStorageAssignment {} + +crate::declare_variants! { + default sat RootedTreeStorageAssignment => "universe_size^universe_size", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "rooted_tree_storage_assignment", + instance: Box::new(RootedTreeStorageAssignment::new( + 5, + vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], + 1, + )), + optimal_config: vec![0, 0, 0, 1, 2], + optimal_value: serde_json::json!(true), + }] +} + +impl TryFrom for RootedTreeStorageAssignment { + type Error = String; + + fn try_from(value: RootedTreeStorageAssignmentDef) -> Result { + Self::try_new(value.universe_size, value.subsets, value.bound) + } +} + +#[cfg(test)] +#[path = "../../unit_tests/models/set/rooted_tree_storage_assignment.rs"] +mod tests; diff --git a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs new file mode 100644 index 000000000..93db715d2 --- /dev/null +++ b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs @@ -0,0 +1,72 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +fn yes_instance(bound: usize) -> RootedTreeStorageAssignment { + RootedTreeStorageAssignment::new(5, vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], bound) +} + +#[test] +fn test_rooted_tree_storage_assignment_creation() { + let problem = yes_instance(1); + assert_eq!(problem.universe_size(), 5); + assert_eq!(problem.num_subsets(), 4); + assert_eq!(problem.bound(), 1); + assert_eq!( + problem.subsets(), + &[vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]] + ); + assert_eq!(problem.dims(), vec![5; 5]); +} + +#[test] +fn test_rooted_tree_storage_assignment_evaluate_yes_instance() { + let problem = yes_instance(1); + assert!(problem.evaluate(&[0, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_rejects_invalid_tree_configs() { + let problem = yes_instance(1); + + assert!(!problem.evaluate(&[0, 0, 1, 2])); + assert!(!problem.evaluate(&[0, 0, 0, 1, 5])); + assert!(!problem.evaluate(&[0, 1, 2, 3, 4])); + assert!(!problem.evaluate(&[1, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_solver_finds_known_solution() { + let problem = yes_instance(1); + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(!solutions.is_empty()); + assert!(solutions.contains(&vec![0, 0, 0, 1, 2])); +} + +#[test] +fn test_rooted_tree_storage_assignment_no_instance() { + let problem = yes_instance(0); + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(solutions.is_empty()); +} + +#[test] +fn test_rooted_tree_storage_assignment_serialization() { + let problem = RootedTreeStorageAssignment::new(5, vec![vec![2, 0], vec![3, 1]], 7); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: RootedTreeStorageAssignment = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.universe_size(), 5); + assert_eq!(deserialized.subsets(), &[vec![0, 2], vec![1, 3]]); + assert_eq!(deserialized.bound(), 7); +} + +#[test] +fn test_rooted_tree_storage_assignment_paper_example() { + let problem = yes_instance(1); + let config = vec![0, 0, 0, 1, 2]; + + assert!(problem.evaluate(&config)); + + let solutions = BruteForce::new().find_all_satisfying(&problem); + assert!(solutions.contains(&config)); +} From d8f4ac9e860b7e097d3a2bb9932b78b8ba73619a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 11:30:38 +0800 Subject: [PATCH 3/4] chore: remove plan file after implementation --- ...26-03-22-rooted-tree-storage-assignment.md | 199 ------------------ 1 file changed, 199 deletions(-) delete mode 100644 docs/plans/2026-03-22-rooted-tree-storage-assignment.md diff --git a/docs/plans/2026-03-22-rooted-tree-storage-assignment.md b/docs/plans/2026-03-22-rooted-tree-storage-assignment.md deleted file mode 100644 index ffd110380..000000000 --- a/docs/plans/2026-03-22-rooted-tree-storage-assignment.md +++ /dev/null @@ -1,199 +0,0 @@ -# Plan: Add RootedTreeStorageAssignment Model - -**Issue:** #409 — [Model] RootedTreeStorageAssignment -**Skill:** add-model -**Execution:** `issue-to-pr` Batch 1 covers add-model Steps 1-5 plus the canonical example wiring; Batch 2 covers add-model Step 6 (paper) after the model, CLI path, and tests are stable. - -## Issue Packet Summary - -- `Good` label is present and `issue-context` returned `action=create-pr`, so this run should create a new PR from branch `issue-409`. -- Use the maintainer fix comments on #409 as the implementation source of truth for the concrete encoding, cleaned-up examples, and complexity string. -- Associated open rule issue exists: #424 `[Rule] Rooted Tree Arrangement to Rooted Tree Storage Assignment`. - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `RootedTreeStorageAssignment` | -| 2 | Mathematical definition | Given a finite set `X`, a collection `C = {X_1, ..., X_n}` of subsets of `X`, and an integer `K`, decide whether there exists a directed rooted tree `T = (X, A)` and supersets `X_i' ⊇ X_i` such that every `X_i'` forms a directed path in `T` and `sum_i |X_i' - X_i| <= K` | -| 3 | Problem type | Satisfaction (`Metric = bool`) | -| 4 | Type parameters | None | -| 5 | Struct fields | `universe_size: usize`, `subsets: Vec>`, `bound: usize` | -| 6 | Configuration space | `vec![universe_size; universe_size]`, where `config[v]` is the parent of vertex `v` and the root satisfies `config[root] = root` | -| 7 | Feasibility check | The config must encode exactly one rooted tree on `0..universe_size`, and every subset must lie on a single ancestor-descendant chain so its minimal path extension is well-defined | -| 8 | Objective function | `bool` — true iff the total minimal extension cost across all subsets is at most `bound` | -| 9 | Best known exact algorithm | Brute-force over parent arrays; use complexity string `"universe_size^universe_size"` with getters `universe_size()` and `num_subsets()` | -| 10 | Solving strategy | Existing `BruteForce` works directly over the parent-array encoding; no ILP path is required in this issue | -| 11 | Category | `set` | -| 12 | Expected outcome | The canonical YES instance uses `X = {0,1,2,3,4}`, subsets `[{0,2},{1,3},{0,4},{2,4}]`, `K = 1`, and satisfying config `[0,0,0,1,2]` (tree edges `0→1`, `0→2`, `1→3`, `2→4`) | - -## Design Decisions - -### Category and API shape - -- Implement this under `src/models/set/` because the input is a universe plus subset family, and the closest existing models are `ConsecutiveSets`, `TwoDimensionalConsecutiveSets`, and `SetBasis`. -- Follow the newer validated-constructor pattern used by `EnsembleComputation` and `TwoDimensionalConsecutiveSets`: expose `try_new(...) -> Result`, keep `new(...)` as the panicking convenience wrapper, and validate again on deserialize. - -### Tree encoding and evaluation semantics - -- Keep the parent-array encoding from the maintainer fix comment: `config[v] = parent(v)` with exactly one root encoded by `config[root] = root`. -- `evaluate()` should: - 1. Reject wrong-length configs and parent values outside `0..universe_size`. - 2. Validate that the config encodes exactly one rooted tree (one self-parent root, every other node reaches that root, no cycles). - 3. Precompute `depth[v]` and an `is_ancestor(u, v)` helper. - 4. For each subset, verify that all vertices are pairwise comparable by ancestry after sorting by depth. - 5. Compute the minimal path-extension cost as the number of vertices on the shallowest-to-deepest path that are not already in the subset. - 6. Return `false` immediately if any subset is infeasible or if the running total exceeds `bound`. - -### CLI and registry notes - -- Registry-backed discovery comes from `ProblemSchemaEntry` plus `declare_variants!`; do not add manual load/serialize dispatch code. -- `problem_name.rs` should not need changes unless a test proves otherwise, because `find_problem_type_by_alias()` already matches canonical names case-insensitively. -- CLI creation can reuse the existing `--universe`, `--sets`, and `--bound` flags, so only `create.rs` and the help table in `cli.rs` should need updates. - -## Batch 1: Model, CLI, Example, Tests - -### Step 1: Write the failing model tests first - -**Files:** -- Create: `src/unit_tests/models/set/rooted_tree_storage_assignment.rs` - -Add tests that fail before the model exists: - -1. `test_rooted_tree_storage_assignment_creation` - - Construct the issue's YES instance. - - Assert `universe_size() == 5`, `num_subsets() == 4`, `bound() == 1`, and `dims() == vec![5; 5]`. -2. `test_rooted_tree_storage_assignment_evaluate_yes_instance` - - Assert `evaluate(&[0, 0, 0, 1, 2])` is `true`. -3. `test_rooted_tree_storage_assignment_rejects_invalid_tree_configs` - - Cover wrong length, out-of-range parent, multiple roots, and a directed cycle. -4. `test_rooted_tree_storage_assignment_no_instance` - - Use the same subsets with `bound = 0`, run `BruteForce::find_all_satisfying`, assert no solutions. -5. `test_rooted_tree_storage_assignment_solver_finds_known_solution` - - Run brute force on the YES instance and assert `[0, 0, 0, 1, 2]` is among the satisfying configs. -6. `test_rooted_tree_storage_assignment_serialization` - - Round-trip serde and confirm normalized subsets/bound survive. - -Run the targeted test and confirm it fails because the model is not registered yet. - -### Step 2: Add the model implementation - -**Files:** -- Create: `src/models/set/rooted_tree_storage_assignment.rs` - -Implement add-model Steps 1, 1.5, and 2 here: - -1. Register `ProblemSchemaEntry` with: - - `name = "RootedTreeStorageAssignment"` - - `display_name = "Rooted Tree Storage Assignment"` - - `aliases = &[]` - - `fields = universe_size / subsets / bound` -2. Define the struct plus validated deserialize helper. -3. Add `try_new`, `new`, and getters: - - `universe_size()` - - `num_subsets()` - - `bound()` - - `subsets()` -4. Normalize subsets into sorted unique vectors and reject out-of-range elements. -5. Implement private helpers for: - - tree validation / root detection - - depth computation - - ancestor checks - - per-subset extension-cost computation -6. Implement `Problem`: - - `NAME = "RootedTreeStorageAssignment"` - - `Metric = bool` - - `dims() = vec![self.universe_size; self.universe_size]` - - `evaluate()` per the design above - - `variant() = crate::variant_params![]` -7. Implement `SatisfactionProblem`. -8. Add `declare_variants! { default sat RootedTreeStorageAssignment => "universe_size^universe_size" }`. -9. Add `canonical_model_example_specs()` using the issue's YES instance and config `[0, 0, 0, 1, 2]`. -10. Link the test file with `#[cfg(test)] #[path = "../../unit_tests/models/set/rooted_tree_storage_assignment.rs"]`. - -After each chunk, rerun the focused unit test until it turns green. - -### Step 3: Register the model and example chain - -**Files:** -- Modify: `src/models/set/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -1. Add the new module and `pub use` in `src/models/set/mod.rs`. -2. Extend the set-category `canonical_model_example_specs()` chain with `rooted_tree_storage_assignment::canonical_model_example_specs()`. -3. Re-export `RootedTreeStorageAssignment` from `src/models/mod.rs`. -4. Add it to the crate prelude in `src/lib.rs`. - -Run a focused compile/test command after registration so missing exports show up early. - -### Step 4: Add CLI creation support and CLI regression coverage - -**Files:** -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/cli.rs` - -1. In `problemreductions-cli/src/commands/create.rs`: - - Import `RootedTreeStorageAssignment`. - - Add an `example_for()` entry like: - - `"RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1"` - - Add the `match` arm that parses `--universe`, `--sets`, and `--bound`, converts `--bound` to `usize`, and constructs the validated model. -2. Add a CLI regression test near the existing JSON creation tests: - - `test_create_rooted_tree_storage_assignment_json` - - Assert the JSON `type`, `universe_size`, `subsets`, and `bound`. -3. In `problemreductions-cli/src/cli.rs`, add `RootedTreeStorageAssignment --universe, --sets, --bound` to the help table. -4. Do not touch `problem_name.rs` unless a failing test proves the canonical-name lookup is insufficient. - -Run the new CLI test first in RED/GREEN style, then rerun the model tests. - -### Step 5: Batch-1 verification - -Run enough fresh verification to justify the implementation commit before moving to paper work: - -```bash -cargo test rooted_tree_storage_assignment -cargo test -p problemreductions-cli test_create_rooted_tree_storage_assignment_json -make test -make clippy -``` - -If `make test` or `make clippy` surfaces unrelated failures, stop and record them in the PR summary instead of papering over them. - -## Batch 2: Paper Entry and Paper-Example Alignment - -### Step 6: Document the model in the Typst paper - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Modify: `src/unit_tests/models/set/rooted_tree_storage_assignment.rs` - -1. Add the display-name dictionary entry for `RootedTreeStorageAssignment`. -2. Add `#problem-def("RootedTreeStorageAssignment")[...][...]` with: - - the corrected formal definition from the issue packet - - short background plus the Garey & Johnson / Gavril citations already accepted in the issue review - - an example based on the canonical YES instance - - a `pred-commands()` block derived from the canonical example data -3. Add or refresh `test_rooted_tree_storage_assignment_paper_example` so the unit test matches the paper's exact instance and satisfying config. -4. Re-run `make paper` and fix any schema/example-export mismatches it reports. - -## Final Verification and Handoff - -Before the implementation summary comment and push, re-run the full verification set: - -```bash -make test -make clippy -make paper -``` - -Expected implementation commits: - -1. `Add plan for #409: [Model] RootedTreeStorageAssignment` -2. `Implement #409: [Model] RootedTreeStorageAssignment` -3. `chore: remove plan file after implementation` - -When posting the PR summary, explicitly call out: - -- the parent-array encoding choice -- the fact that CLI reused existing `--universe/--sets/--bound` flags -- any deviations from this plan From d235a2233d7973daf2f893b6cac79a08710965fb Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 23 Mar 2026 00:45:21 +0800 Subject: [PATCH 4/4] chore: fix formatting after merge with main Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 20 +++++++++---------- .../set/rooted_tree_storage_assignment.rs | 7 ++++++- .../set/rooted_tree_storage_assignment.rs | 6 +++++- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index f9cd76180..98067c41a 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -2680,18 +2680,16 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let bound = args.bound.ok_or_else(|| { anyhow::anyhow!("RootedTreeStorageAssignment requires --bound\n\n{usage}") })?; - let bound = parse_nonnegative_usize_bound( - bound, - "RootedTreeStorageAssignment", - usage, - )?; + let bound = parse_nonnegative_usize_bound(bound, "RootedTreeStorageAssignment", usage)?; ( - ser(problemreductions::models::set::RootedTreeStorageAssignment::try_new( - universe_size, - subsets, - bound, - ) - .map_err(anyhow::Error::msg)?)?, + ser( + problemreductions::models::set::RootedTreeStorageAssignment::try_new( + universe_size, + subsets, + bound, + ) + .map_err(anyhow::Error::msg)?, + )?, resolved_variant.clone(), ) } diff --git a/src/models/set/rooted_tree_storage_assignment.rs b/src/models/set/rooted_tree_storage_assignment.rs index dd6d08b12..9692fcc84 100644 --- a/src/models/set/rooted_tree_storage_assignment.rs +++ b/src/models/set/rooted_tree_storage_assignment.rs @@ -147,7 +147,12 @@ impl RootedTreeStorageAssignment { ancestor == vertex } - fn subset_extension_cost(&self, subset: &[usize], config: &[usize], depth: &[usize]) -> Option { + fn subset_extension_cost( + &self, + subset: &[usize], + config: &[usize], + depth: &[usize], + ) -> Option { if subset.len() <= 1 { return Some(0); } diff --git a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs index 93db715d2..478c71e6d 100644 --- a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs +++ b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs @@ -3,7 +3,11 @@ use crate::solvers::BruteForce; use crate::traits::Problem; fn yes_instance(bound: usize) -> RootedTreeStorageAssignment { - RootedTreeStorageAssignment::new(5, vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], bound) + RootedTreeStorageAssignment::new( + 5, + vec![vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]], + bound, + ) } #[test]