mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
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:
1 parent
700a76eeee
commit
25a8d47d53
3 files changed
+141
-30
No files matched your search
+86
-10
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user