Zig implementation of Weighted Finite State Transducers, semantically aligned with OpenFst/Pynini. Stable C ABI for Swift/Rust/C++ integration.
const libfst = @import("libfst");
const W = libfst.TropicalWeight;
const MutableFst = libfst.MutableFst(W);
const Fst = libfst.Fst(W);
const A = libfst.Arc(W);
var fst = MutableFst.init(allocator);
defer fst.deinit();
const s0 = try fst.addState();
const s1 = try fst.addState();
fst.setStart(s0);
fst.setFinal(s1, W.one);
try fst.addArc(s0, A.init('a' + 1, 'b' + 1, W.one, s1));
// Freeze for runtime (contiguous, thread-safe, mmap-friendly)
var frozen = try Fst.fromMutable(allocator, &fst);
defer frozen.deinit();#include <fst.h>
FstMutableHandle h = fst_mutable_new();
uint32_t s0 = fst_mutable_add_state(h);
uint32_t s1 = fst_mutable_add_state(h);
fst_mutable_set_start(h, s0);
fst_mutable_set_final(h, s1, 0.0);
fst_mutable_add_arc(h, s0, 'a'+1, 'b'+1, 0.0, s1);
FstHandle frozen = fst_freeze(h);
// ... query frozen FST ...
fst_free(frozen);
fst_mutable_free(h);| Operation | Description |
|---|---|
compose |
Combine two FSTs (key operation for text normalization) |
compose_shortest_path |
Lazy shortest path on implicit compose graph (n=1) |
determinize |
Convert acceptor NFA to DFA (acceptor-only) |
minimize |
Merge equivalent states (Hopcroft partition refinement) |
rm_epsilon |
Remove epsilon transitions |
shortest_path |
Find single best path (n=1) |
union |
L(a) ∪ L(b) |
concat |
L(a) · L(b) |
closure |
L* (star), L+ (plus), L? (optional) |
invert |
Swap input/output tapes |
project |
Project to input or output tape |
difference |
L(a) - L(b), with deterministic epsilon-free unweighted RHS |
replace |
Recursive subroutine substitution with cycle detection |
cdrewrite |
Context-dependent obligatory rewrite (unit-weight grammars) |
reverse |
Reverse all paths |
optimize |
Pipeline: rmEpsilon → determinize → minimize → connect |
Handle table bookkeeping is mutex-protected, but heavy algorithms run on snapshots outside the lock. This removes full-call serialization for compute-heavy C API operations.
fst_compose_frozen pins immutable handles for lock-free composition
instead of cloning frozen bytes on every call.
fst_compose_frozen_shortest_path additionally avoids materializing the
full compose lattice for best-path extraction.
In-place mutating C APIs (union/concat/closure/minimize) use optimistic
commit and return invalid_arg if the same handle changed concurrently.
MutableFst: single-writer. Fst (frozen): fully reentrant and thread-safe.
zig build # static library + C header
zig build test # unit tests
zig build prop # property-based tests (semiring laws, idempotency)
zig build fuzz # fuzz test harness
zig build diff # diff tests vs Pynini golden outputs (needs corpus)
zig build att2lfst # build converter at zig-out/bin/att2lfst
zig build bench # run profile-friendly benchmark (scenarios + JSON output)Example benchmark run:
zig build bench -Doptimize=ReleaseFast -- \
--scenario compose_frozen_epsilon_dense \
--len 96 --transducer-len 4096 --branches 12 \
--iters 200 --warmup 30 \
--format jsonIssue #1 matrix benchmark:
python3 bench/run_issue1_bench.py \
--lengths 5,10,20,30,40,50 \
--transducer-len 2048 \
--branches 6 \
--iters 100 --warmup 20Issue #1 profile-friendly stress benchmark:
python3 bench/run_issue1_profile_bench.py \
--lengths 11,19,33,64,96,128,160,192,224,251 \
--transducer-len 4096 \
--branches 12 \
--iters 120 --warmup 20Default stress matrix includes:
compose_frozen_epsilon_dense(epsilon-heavy topology)compose_frozen_ambiguous_chain(nondeterministic repeated-label topology)compose_frozen_shortest_path_ambiguous(eager compose + shortest_path)compose_frozen_lazy_shortest_path_ambiguous(lazy compose_shortest_path)compose_frozen_shortest_path_epsilon_dense(eager compose + shortest_path)compose_frozen_lazy_shortest_path_epsilon_dense(lazy compose_shortest_path)
Legacy example:
zig build bench -Doptimize=ReleaseFast -- \
--scenario optimize_transducer \
--len 16384 --branches 4 \
--iters 300 --warmup 20 \
--format jsonTo generate golden corpus for diff tests:
uv pip install pynini
python tests/gen_golden.py
zig build diffCurrent limitation: the checked-in shortest-path differential fixture expects
n=2, while the implementation intentionally supports only n=1.
Two-phase model:
MutableFst(W)— build-time mutable FST, all operations work on thisFst(W)— frozen immutable FST, contiguous memory layout, thread-safe queries
Weight types: TropicalWeight (min/+), LogWeight (log-add/+) — both f64 semirings.
Labels: u32 (0 = epsilon). StateId: u32 (maxInt = no_state sentinel).
Zig 0.16.0 — uses Build.addLibrary / Build.createModule API and the
std.Io I/O model.
cdrewriteimplements obligatory behavior by composing with the produced rule, then takingshortest_path(n=1)on projected output.- To avoid ambiguous weighted preference inversions,
cdrewritecurrently rejects weighted rule/context FSTs (tau,lambda,rhomust be unit-weight).
For OnType integration, a pragmatic path is:
- Keep WeText/OpenFst assets as source of truth (
*_tagger.fst,*_verbalizer.fst) - Either load OpenFst
.fstdirectly viafst_load_openfst(requiresfstprint), or convert them offline into libfst binary format for deployment simplicity
tools/convert_wetext_fst.sh zh_itn_tagger.fst zh_itn_tagger.libfst.fst
tools/convert_wetext_fst.sh zh_itn_verbalizer.fst zh_itn_verbalizer.libfst.fstRequires fstprint (OpenFst CLI) and zig.
python3 tools/convert_wetext_dir.py \
--input-dir /path/to/wetext/itn \
--output-dir /path/to/libfst-assetsBy default it converts *_tagger.fst and *_verbalizer.fst.
fstprint zh_itn_tagger.fst > /tmp/zh_itn_tagger.att
zig build att2lfst
./zig-out/bin/att2lfst --input /tmp/zh_itn_tagger.att --output zh_itn_tagger.libfst.fstfst_load(path): libfst native binary only.fst_load_openfst(path): OpenFst.fstviafstprint+ AT&T import.
MIT