feat(solver): wire equiv classes into DFS pipeline

Thread EquivClasses and deduped_allowed through solve_subtree, dfs,
generate_prefixes, handle_solve, and dfs_first (full_name). The DFS
hot loop now iterates deduped representatives instead of all allowed
chars, and expand_match restores full class-member variants at match
time. This reduces branching factor for charsets with metrically
identical glyphs while preserving complete result sets.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-18 10:50:31 -06:00
1 parent 700a76eeee
commit 25a8d47d53
3 files changed
+141 -30

No files matched your search

+86 -10
View File
@@ -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<usize>],
) -> Vec<SolveResult> {
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<usize>],
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<String> = 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);
}
}
}
+43 -15
View File
@@ -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<usize>],
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,
);
+12 -5
View File
@@ -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<usize>],
) -> Vec<(String, f64, i32, usize)> {
let mut prefixes = Vec::new();
fn expand(
wt: &WidthTable, target: f64, tolerance: f64, constraint: &Constraint,
deduped_allowed: &[Vec<usize>],
pfx: &mut Vec<u8>, 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
}