diff --git a/solver_rs/src/dfs.rs b/solver_rs/src/dfs.rs index e6cf37b..344b8fb 100644 --- a/solver_rs/src/dfs.rs +++ b/solver_rs/src/dfs.rs @@ -88,13 +88,16 @@ pub fn solve_subtree( last_char_idx: i32, // -1 if no prefix constraint: &Constraint, start_state: usize, + equiv: &EquivClasses, + deduped_allowed: &[Vec], ) -> Vec { let smax = compute_state_max(wt, constraint); + let byte_to_idx = build_byte_to_idx(&wt.charset); let mut results = Vec::new(); if prefix.is_empty() { - // Start from scratch — iterate first chars - for &first_idx in &constraint.state_allowed[start_state] { + // Start from scratch — iterate first chars (deduped: one representative per class) + for &first_idx in &deduped_allowed[start_state] { let start_width = wt.left_edge[first_idx]; if start_width > target + tolerance { continue; @@ -118,6 +121,9 @@ pub fn solve_subtree( max_length, constraint, &smax, + equiv, + deduped_allowed, + &byte_to_idx, 1, // depth start_width, first_idx, @@ -137,6 +143,9 @@ pub fn solve_subtree( max_length, constraint, &smax, + equiv, + deduped_allowed, + &byte_to_idx, 0, // depth prefix_width, last_char_idx as usize, @@ -159,6 +168,9 @@ fn dfs( max_length: usize, constraint: &Constraint, smax: &[f64], + equiv: &EquivClasses, + deduped_allowed: &[Vec], + byte_to_idx: &[usize; 128], depth: usize, acc_width: f64, last_idx: usize, @@ -173,13 +185,16 @@ fn dfs( let final_width = acc_width + wt.right_edge[last_idx]; let err = (final_width - target).abs(); if err <= tolerance { - // SAFETY: path contains valid UTF-8 (ASCII charset chars) - let text = unsafe { String::from_utf8_unchecked(path.clone()) }; - results.push(SolveResult { - text, - width: final_width, - error: err, - }); + let expanded = expand_match(path, equiv, &wt.charset, byte_to_idx); + for text_bytes in expanded { + // SAFETY: path contains valid UTF-8 (ASCII charset chars) + let text = unsafe { String::from_utf8_unchecked(text_bytes) }; + results.push(SolveResult { + text, + width: final_width, + error: err, + }); + } } } @@ -189,7 +204,7 @@ fn dfs( let chars_left = max_length - current_length; - for &next_idx in &constraint.state_allowed[cstate] { + for &next_idx in &deduped_allowed[cstate] { let advance = wt.advance(last_idx, next_idx); let new_width = acc_width + advance; @@ -218,6 +233,9 @@ fn dfs( max_length, constraint, smax, + equiv, + deduped_allowed, + byte_to_idx, depth + 1, new_width, next_idx, @@ -564,4 +582,62 @@ mod tests { assert_eq!(expanded[2], b"acb"); assert_eq!(expanded[3], b"acc"); } + + #[test] + fn test_solve_subtree_expands_equivalences() { + let wt = make_wt( + "abc", + &[5.0,6.0,6.0, 5.0,6.0,6.0, 5.0,6.0,6.0], + &[4.0, 5.0, 5.0], + &[0.0, 0.0, 0.0], + ); + let equiv = compute_equiv_classes(&wt, &[]); + let constraint = Constraint { + state_allowed: vec![vec![0, 1, 2]], + state_next: vec![vec![0, 0, 0]], + accept_states: vec![true], + }; + let deduped = dedup_state_allowed(&equiv, &constraint); + + // target = 10.0, tol = 0.5, length 2 + // "ab": left[a]=4 + wt[a][b]=6 + right[b]=0 = 10 ✓ + // "ac": left[a]=4 + wt[a][c]=6 + right[c]=0 = 10 ✓ (expanded from "ab") + let results = solve_subtree( + &wt, 10.0, 0.5, 2, 2, + "", 0.0, -1, + &constraint, 0, + &equiv, &deduped, + ); + let mut texts: Vec = results.iter().map(|r| r.text.clone()).collect(); + texts.sort(); + assert!(texts.contains(&"ab".to_string()), "should contain ab, got {:?}", texts); + assert!(texts.contains(&"ac".to_string()), "should contain ac, got {:?}", texts); + } + + #[test] + fn test_solve_subtree_no_equiv_same_results() { + let wt = make_wt( + "abc", + &[5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0], + &[4.0, 5.0, 6.0], + &[0.0, 1.0, 2.0], + ); + let equiv = compute_equiv_classes(&wt, &[]); + assert_eq!(equiv.num_classes, 3); + let constraint = Constraint { + state_allowed: vec![vec![0, 1, 2]], + state_next: vec![vec![0, 0, 0]], + accept_states: vec![true], + }; + let deduped = dedup_state_allowed(&equiv, &constraint); + let results = solve_subtree( + &wt, 15.0, 1.0, 2, 2, + "", 0.0, -1, + &constraint, 0, + &equiv, &deduped, + ); + for r in &results { + assert!((r.width - 15.0).abs() <= 1.0, "result {} has error {} > tolerance", r.text, r.error); + } + } } diff --git a/solver_rs/src/full_name.rs b/solver_rs/src/full_name.rs index 396828a..2cbf98c 100644 --- a/solver_rs/src/full_name.rs +++ b/solver_rs/src/full_name.rs @@ -4,7 +4,9 @@ /// outer DFS enumerates first words, inner DFS solves second word with /// exact remaining width. -use crate::dfs::{Constraint, SolveResult, WidthTable, compute_length_bounds, solve_subtree}; +use crate::dfs::{Constraint, SolveResult, WidthTable, compute_length_bounds, solve_subtree, + EquivClasses, compute_equiv_classes, dedup_state_allowed, build_byte_to_idx, + expand_match}; use std::collections::HashSet; /// Build a "capitalized" constraint: state 0 = uppercase, state 1 = lowercase. @@ -83,8 +85,15 @@ pub fn solve_full_name( capitalized_constraint(word_charset) }; + let equiv1 = compute_equiv_classes(&wt1, &[space_advance, left_after_space]); + let equiv2 = compute_equiv_classes(&wt2, &[]); + let deduped1 = dedup_state_allowed(&equiv1, &constraint); + let deduped2 = dedup_state_allowed(&equiv2, &constraint); + let byte_to_idx = build_byte_to_idx(word_charset); + let min_word_len: usize = if uppercase_only { 1 } else { 2 }; + // Keep original allowed lists for bounds computation let first_allowed = &constraint.state_allowed[0]; // uppercase (or all) let body_allowed = if constraint.state_allowed.len() > 1 { &constraint.state_allowed[1] // lowercase (or all) @@ -92,6 +101,10 @@ pub fn solve_full_name( &constraint.state_allowed[0] }; + // Deduped versions for DFS iteration + let first_deduped = &deduped1[0]; + let body_deduped = if deduped1.len() > 1 { &deduped1[1] } else { &deduped1[0] }; + // Min second word width (for overshoot pruning of first word) let min_second = { let min_start: f64 = first_allowed.iter() @@ -154,6 +167,8 @@ pub fn solve_full_name( target, tolerance, min_word_len, first_max_len, first_max_width, min_space, min_second, max_word_advance, first_allowed, body_allowed, + &equiv1, &byte_to_idx, first_deduped, body_deduped, + &equiv2, &deduped2, 0, 0.0, 0, &mut Vec::new(), true, &mut results, &mut seen, ); @@ -183,6 +198,12 @@ fn dfs_first( max_word_advance: f64, first_allowed: &[usize], body_allowed: &[usize], + equiv1: &EquivClasses, + byte_to_idx: &[usize; 128], + first_deduped: &[usize], + body_deduped: &[usize], + equiv2: &EquivClasses, + deduped2: &[Vec], depth: usize, acc_width: f64, last_idx: usize, @@ -207,21 +228,26 @@ fn dfs_first( auto_min2, auto_max2, "", 0.0, -1, constraint, 0, + equiv2, deduped2, ); - let first_text = unsafe { String::from_utf8_unchecked(path.clone()) }; - for r2 in &second_results { - let full_text = format!("{} {}", first_text, r2.text); - if !seen.contains(&full_text) { - let full_width = first_width + sp + r2.width; - let err = (full_width - target).abs(); - if err <= tolerance { - seen.insert(full_text.clone()); - results.push(SolveResult { - text: full_text, - width: full_width, - error: err, - }); + // Expand first word (all class-member variants) + let first_expanded = expand_match(path, equiv1, charset, byte_to_idx); + for first_bytes in &first_expanded { + let first_text = unsafe { String::from_utf8_unchecked(first_bytes.clone()) }; + for r2 in &second_results { + let full_text = format!("{} {}", first_text, r2.text); + if !seen.contains(&full_text) { + let full_width = first_width + sp + r2.width; + let err = (full_width - target).abs(); + if err <= tolerance { + seen.insert(full_text.clone()); + results.push(SolveResult { + text: full_text, + width: full_width, + error: err, + }); + } } } } @@ -233,7 +259,7 @@ fn dfs_first( return; } - let allowed = if depth == 0 { first_allowed } else { body_allowed }; + let allowed = if depth == 0 { first_deduped } else { body_deduped }; let chars_left = first_max_len - depth; for &next_idx in allowed { @@ -264,6 +290,8 @@ fn dfs_first( target, tolerance, min_word_len, first_max_len, first_max_width, min_space, min_second, max_word_advance, first_allowed, body_allowed, + equiv1, byte_to_idx, first_deduped, body_deduped, + equiv2, deduped2, depth + 1, new_width, next_idx, path, false, results, seen, ); diff --git a/solver_rs/src/main.rs b/solver_rs/src/main.rs index e015eab..bbe37ab 100644 --- a/solver_rs/src/main.rs +++ b/solver_rs/src/main.rs @@ -10,7 +10,8 @@ use std::sync::Arc; use axum::{Json, Router, routing::post}; use axum::extract::State; use axum::response::sse::{Event, Sse}; -use dfs::{Constraint, SolveResult, WidthTable, compute_length_bounds, solve_subtree}; +use dfs::{Constraint, SolveResult, WidthTable, compute_length_bounds, solve_subtree, + compute_equiv_classes, dedup_state_allowed}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::ReceiverStream; @@ -129,6 +130,9 @@ async fn handle_solve( } }; + let equiv = compute_equiv_classes(&wt, &[]); + let deduped_allowed = dedup_state_allowed(&equiv, &constraint); + let (auto_min, auto_max) = compute_length_bounds(&wt, req.target, req.tolerance); let min_length = req.min_length.unwrap_or(auto_min); let max_length = req.max_length.unwrap_or(auto_max); @@ -139,7 +143,7 @@ async fn handle_solve( let prefix_depth = if n <= 52 { 2 } else { 1 }; let prefix_depth = prefix_depth.min(max_length); - let prefixes = generate_prefixes(&wt, req.target, req.tolerance, prefix_depth, &constraint); + let prefixes = generate_prefixes(&wt, req.target, req.tolerance, prefix_depth, &constraint, &deduped_allowed); let total = Arc::new(AtomicUsize::new(0)); @@ -153,6 +157,7 @@ async fn handle_solve( min_length, max_length, prefix, *prefix_width, *last_idx, &constraint, *pfx_state, + &equiv, &deduped_allowed, ); results.sort_by(|a, b| a.error.partial_cmp(&b.error).unwrap()); @@ -228,11 +233,13 @@ async fn handle_full_name( fn generate_prefixes( wt: &WidthTable, target: f64, tolerance: f64, depth: usize, constraint: &Constraint, + deduped_allowed: &[Vec], ) -> Vec<(String, f64, i32, usize)> { let mut prefixes = Vec::new(); fn expand( wt: &WidthTable, target: f64, tolerance: f64, constraint: &Constraint, + deduped_allowed: &[Vec], pfx: &mut Vec, acc_width: f64, last_idx: i32, remaining: usize, cstate: usize, prefixes: &mut Vec<(String, f64, i32, usize)>, ) { @@ -241,7 +248,7 @@ fn generate_prefixes( prefixes.push((text, acc_width, last_idx, cstate)); return; } - for &next_idx in &constraint.state_allowed[cstate] { + for &next_idx in &deduped_allowed[cstate] { let advance = if last_idx == -1 { wt.left_edge[next_idx] } else { @@ -252,12 +259,12 @@ fn generate_prefixes( let ns = constraint.state_next[cstate][next_idx]; if ns < 0 { continue; } pfx.push(wt.charset[next_idx] as u8); - expand(wt, target, tolerance, constraint, pfx, new_width, next_idx as i32, remaining - 1, ns as usize, prefixes); + expand(wt, target, tolerance, constraint, deduped_allowed, pfx, new_width, next_idx as i32, remaining - 1, ns as usize, prefixes); pfx.pop(); } } - expand(wt, target, tolerance, constraint, &mut Vec::new(), 0.0, -1, depth, 0, &mut prefixes); + expand(wt, target, tolerance, constraint, deduped_allowed, &mut Vec::new(), 0.0, -1, depth, 0, &mut prefixes); prefixes }