Determinization transforms a non-deterministic WFST into an equivalent deterministic one, where each state has at most one outgoing transition per input label. This enables efficient single-pass recognition and is often a prerequisite for minimization.
Defined centrally in ../NOTATION.md; repeated locally for the terms this doc uses.
| Symbol | Meaning |
|---|---|
|
|
semiring plus (combine alternatives; tropical |
semiring divide (divide on a DivisibleSemiring); residual |
|
|
|
|
| final-weight function |
|
| set of final states. | |
|
|
number of states / transitions. |
A WFST (Weighted Finite-State Transducer) is deterministic if:
- It has exactly one start state
- For each state, all outgoing transitions have distinct input labels
- There are no epsilon (
$\varepsilon$ ) transitions on the input
Non-deterministic: Deterministic:
a/1.0 a/1.0
┌──────────► 1 ┌──────────► 1
│ │
0 0
│ │
└──────────► 2 └──────────► 2
a/2.0 b/2.0
(Two 'a' arcs) (Distinct labels)
- Efficient recognition: Single path per input string—no backtracking needed
- Prerequisite for minimization: Weighted minimization requires deterministic input
- Unique path property: Simplifies lattice generation and scoring
- Composition optimization: Deterministic components compose more efficiently
Unlike classical automata theory where determinization uses simple state sets, weighted determinization uses weighted subsets—sets of (state, residual_weight) pairs (Mohri 2009):
Non-deterministic state set: {1, 2}
Weighted subset: {(1, 0.5), (2, 1.5)}
│ │ │ │
│ │ │ └─ residual weight for state 2
│ │ └─ state 2
│ └─ residual weight for state 1
└─ state 1
The residual weight tracks "how much extra weight" each original state carries compared to the minimum. The figure below shows the construction collapsing two
Red panel = non-deterministic input (two
Text view
before (non-deterministic): after (deterministic):
a/1.0 a/1.0
┌────────► 1 ──b/1.0──► (3) 0 ─────────► {(1,0̄),(2,1.0)} ─b/1.0─► {(3,0̄)}
0 ▲
└────────► 2 ──b/1.0─────┘ arc carries min = 1.0;
a/2.0 residual 1.0 of state 2 rides in the subset
/// Configuration for determinization
pub struct DeterminizeConfig {
/// Maximum number of states in output (prevents runaway)
pub max_states: Option<usize>,
/// Whether to epsilon-remove first (recommended)
pub remove_epsilon_first: bool,
/// Whether to connect (trim) after determinization
pub connect_after: bool,
}
/// Errors during determinization
pub enum DeterminizeError {
NoStartState,
StateLimitExceeded { limit: usize },
NotDeterminizable { reason: String },
}/// Determinize a WFST using weighted powerset construction
pub fn determinize<L, W, F>(
fst: &F,
config: DeterminizeConfig,
) -> Result<F, DeterminizeError>;
/// Check if a WFST is deterministic
pub fn is_deterministic<L, W, F>(fst: &F) -> bool;
/// Count degree of non-determinism (max same-label arcs from one state)
pub fn non_determinism_degree<L, W, F>(fst: &F) -> usize;use lling_llang::prelude::*;
use lling_llang::algorithms::{determinize, is_deterministic, DeterminizeConfig};
// Build a non-deterministic WFST
// Two 'a' transitions from state 0:
// 0 --a/1.0--> 1 --b--> 3 (final)
// 0 --a/2.0--> 2 --c--> 3 (final)
let mut fst = VectorWfst::<char, TropicalWeight>::new();
fst.add_states(4);
fst.set_start(0);
fst.add_arc(0, Some('a'), Some('a'), 1, TropicalWeight::new(1.0));
fst.add_arc(0, Some('a'), Some('a'), 2, TropicalWeight::new(2.0));
fst.add_arc(1, Some('b'), Some('b'), 3, TropicalWeight::new(1.0));
fst.add_arc(2, Some('c'), Some('c'), 3, TropicalWeight::new(1.0));
fst.set_final(3, TropicalWeight::one());
// Check: not deterministic
assert!(!is_deterministic(&fst));
assert_eq!(non_determinism_degree(&fst), 2);
// Determinize
let det_fst = determinize(&fst, DeterminizeConfig::standard())?;
// Verify: now deterministic
assert!(is_deterministic(&det_fst));// Diamond: two paths with same label sequence
// 0 --a/1--> 1 --b--> 3 (final)
// 0 --a/2--> 2 --b--> 3 (final)
let mut fst = VectorWfst::<char, TropicalWeight>::new();
fst.add_states(4);
fst.set_start(0);
fst.add_arc(0, Some('a'), Some('a'), 1, TropicalWeight::new(1.0));
fst.add_arc(0, Some('a'), Some('a'), 2, TropicalWeight::new(2.0));
fst.add_arc(1, Some('b'), Some('b'), 3, TropicalWeight::new(1.0));
fst.add_arc(2, Some('b'), Some('b'), 3, TropicalWeight::new(1.0));
fst.set_final(3, TropicalWeight::one());
let det_fst = determinize(&fst, DeterminizeConfig::standard())?;
// Diamond collapses to linear chain
// Result: 0 --a/1--> {1,2} --b/1--> {3}
// Fewer states than original
assert!(det_fst.num_states() <= fst.num_states());// Non-deterministic with two paths to final states
// 0 --a/1--> 1 (final, w=0)
// 0 --a/3--> 2 (final, w=0)
let mut fst = VectorWfst::<char, TropicalWeight>::new();
fst.add_states(3);
fst.set_start(0);
fst.add_arc(0, Some('a'), Some('a'), 1, TropicalWeight::new(1.0));
fst.add_arc(0, Some('a'), Some('a'), 2, TropicalWeight::new(3.0));
fst.set_final(1, TropicalWeight::one());
fst.set_final(2, TropicalWeight::one());
let det_fst = determinize(&fst, DeterminizeConfig::standard())?;
// After determinization:
// - 'a' transition has weight 1.0 (minimum of 1.0 and 3.0)
// - Final state merges both original finals
// - Residual weights incorporated into final weightThe algorithm maintains a mapping from weighted subsets to deterministic output
states, expanding one subset at a time (Mohri 2009).
The invariant is that each output state names a unique normalized weighted subset:
for every input label leaving that subset, all destination states are gathered, their
common weight factor
Text view
procedure DETERMINIZE(fst):
result ← new WFST
initial_subset ← {(fst.start, 1̄)} // residual 1̄ at the start
result.start ← new_state(initial_subset)
queue.push(result.start, initial_subset)
while queue not empty:
(output_state, subset) ← queue.pop()
for (state, residual) in subset: // final weights
if fst.is_final(state):
result.final[output_state] ⊕= residual ⊗ fst.final_weight(state)
for label in input_labels(subset): // one arc per label
target_subset ← compute_target_subset(subset, label)
min_w ← ⊕-min{ w : (s, w) ∈ target_subset }
normalized ← {(s, w ⊘ min_w) : (s, w) ∈ target_subset}
target_state ← get_or_create(normalized)
result.add_arc(output_state, label, min_w, target_state)
return result
⟨ seed the start subset ⟩ ≡
initial_subset ← {(fst.start, 1̄)}
result.start ← new_state(initial_subset)
queue.push(result.start, initial_subset)
⟨ accumulate the final weight of a subset ⟩ ≡
for (state, residual) in subset:
if fst.is_final(state):
result.final[output_state] ⊕= residual ⊗ fst.final_weight(state)
⟨ build one deterministic arc per label ⟩ ≡
for label in input_labels(subset):
target_subset ← compute_target_subset(subset, label) // ⟨ move + multiply residuals ⟩
min_w ← ⊕-min{ w : (s, w) ∈ target_subset } // common factor
normalized ← {(s, w ⊘ min_w) : (s, w) ∈ target_subset} // residuals
target_state ← get_or_create(normalized) // dedupe via subset cache
result.add_arc(output_state, label, min_w, target_state)
⟨ weighted subset determinization ⟩ ≡
⟨ seed the start subset ⟩
while queue not empty:
(output_state, subset) ← queue.pop()
⟨ accumulate the final weight of a subset ⟩
⟨ build one deterministic arc per label ⟩
return result
The get_or_create cache is what bounds the construction: two states reached by
label sequences with identical normalized residual profiles map to the same output
state, so the determinized automaton stays finite whenever the input is determinizable.
The key insight is weight normalization using the semiring's divide operation divide on a DivisibleSemiring):
Before normalization:
target_subset = {(1, 2.0), (2, 5.0), (3, 3.0)}
Compute minimum:
min_w = ⊕-min(2.0, 5.0, 3.0) = 2.0
Normalized (each weight divided by min, w ⊘ min_w):
normalized = {(1, 0.0), (2, 3.0), (3, 1.0)}
Transition weight = min_w = 2.0
This ensures:
- The arc carries the "common" weight factor
- Residuals track the "extra" weight per original state
- Total path weight is preserved
When a weighted subset contains final states, the deterministic state's final weight combines all contributions:
where
| Case | Complexity |
|---|---|
| Worst case |
|
| Unambiguous input |
|
| Practical | Often near-linear for speech/NLP |
| Structure | Size |
|---|---|
| Subset cache | |
| Queue | |
| Output WFST |
The powerset construction can create
Exponential blowup example:
0 → 1 → 2 → ... → n (with alternating a/b choices)
↓ ↓ ↓
1' 2' n'
Each state can be in or out of the subset → 2ⁿ possibilities
The max_states configuration prevents runaway:
let config = DeterminizeConfig {
max_states: Some(1_000_000), // Limit output size
..Default::default()
};Epsilon transitions on the input make determinization more complex:
// Recommended: remove epsilon first
let config = DeterminizeConfig {
remove_epsilon_first: true, // Default
..Default::default()
};If remove_epsilon_first is true, the algorithm handles
If the input is already deterministic, the algorithm essentially copies it:
let fst = build_deterministic_fst();
assert!(is_deterministic(&fst));
let det = determinize(&fst, DeterminizeConfig::standard())?;
// det has same structure as fstlet fst: VectorWfst<char, TropicalWeight> = VectorWfst::new();
let det = determinize(&fst, DeterminizeConfig::standard())?;
assert_eq!(det.num_states(), 0);Determinization requires a divisible semiring:
pub trait DivisibleSemiring: Semiring {
fn divide(&self, other: &Self) -> Option<Self>;
}| Semiring | Divisible | Division Operation |
|---|---|---|
| Tropical | Yes | |
| Log | Yes | |
| Probability | Yes | |
| Boolean | No | N/A |
| String | No | N/A |
Why division? Weight normalization requires dividing each weight by the minimum (
use lling_llang::algorithms::{
remove_epsilon, determinize, minimize,
EpsilonRemovalConfig, DeterminizeConfig, MinimizeConfig,
};
// Standard optimization pipeline:
// 1. Remove epsilon transitions
remove_epsilon(&mut fst, EpsilonRemovalConfig::default())?;
// 2. Determinize
let det = determinize(&fst, DeterminizeConfig::standard())?;
// 3. Minimize (requires deterministic input)
let min = minimize(&det, MinimizeConfig::default())?;if !is_deterministic(&fst) {
let det = determinize(&fst, DeterminizeConfig::standard())?;
// Use det...
} else {
// Already deterministic, skip
}let degree = non_determinism_degree(&fst);
match degree {
0 => println!("Empty WFST"),
1 => println!("Already deterministic"),
d => println!("Non-determinism degree: {} (max same-label arcs)", d),
}The before/after diagram above renders this construction; the ASCII views are kept here for reference.
a/1.0 b/1.0
[0] ─────────► 1 ─────────────────────► (3)
│ ▲
│ │
└─────────► 2 ─────────────────────────┘
a/2.0 c/1.0
Non-deterministic: two 'a' arcs from state 0
a/1.0 b/1.0
[0] ─────────► {1,2} ─────────────────► ({3}, final)
│ ▲
│ │
└───────────────────────┘
c/1.0
Deterministic: single 'a' arc to merged state {1,2}
Arc weight = ⊕-min(1.0, 2.0) = 1.0
Original paths for input "ab":
Path 1: 0 --a/1--> 1 --b/2--> 3 (final) Total: 3
Path 2: 0 --a/3--> 2 --b/2--> 3 (final) Total: 5
Determinized (tropical ⊕ = min):
0 --a/1--> {1:0, 2:2} --b/2--> (final)
After 'a': weight=1, residuals={1:0, 2:2}
After 'b': weight=1 ⊗ 2 = 3, final with residual combination
Best path weight preserved: 3
use lling_llang::algorithms::DeterminizeError;
match determinize(&fst, config) {
Ok(det) => {
// Success - use determinized WFST
}
Err(DeterminizeError::NoStartState) => {
// Input WFST has no start state
}
Err(DeterminizeError::StateLimitExceeded { limit }) => {
// Output grew too large, increase max_states or simplify input
}
Err(DeterminizeError::NotDeterminizable { reason }) => {
// WFST cannot be determinized (cycle issues)
}
}- Remove epsilon first: Epsilon-free WFSTs determinize faster
- Set reasonable limits: Use
max_statesto catch blowup early - Check first: Use
is_deterministic()to skip unnecessary work - Measure degree: High
non_determinism_degreesuggests potential blowup
- Mohri 2009 — Weighted Automata Algorithms: weighted determinization via the weighted-subset construction, residual normalization, and the determinizability condition (twins property).
- Mohri 2002 — Weighted Finite-State Transducers in Speech Recognition: determinization as a stage of the standard recognition-cascade optimization pipeline.
- Allauzen 2007 — OpenFst: the reference library whose
Determinizeoperation andDivisibleSemiring-style weight API this implementation mirrors.
- Epsilon Removal: Required before determinization
- Minimization: Uses determinization as prerequisite
- Weight Pushing: Often combined with determinization
- Semirings: Understanding divisible semirings