Skip to content

Refactor DAG shortest path algorithm using Viterbi - #777

Merged
lukhnos merged 1 commit into
openvanilla:masterfrom
ChiahongHong:viterbi
Feb 3, 2026
Merged

Refactor DAG shortest path algorithm using Viterbi#777
lukhnos merged 1 commit into
openvanilla:masterfrom
ChiahongHong:viterbi

Conversation

@ChiahongHong

Copy link
Copy Markdown
Contributor

Recently, I’ve been reading the "NLP bible," Speech and Language Processing, which explains that for HMMs, the most standard and efficient algorithm is Viterbi.

Hidden Markov Models - https://web.stanford.edu/~jurafsky/slp3/A.pdf

Viterbi

The Viterbi algorithm is a DP approach used to find the most likely sequence of hidden states

↑↑↑ This looks a bit complex, so let’s consider a simpler version instead

  • Forward Pass: maximum score for each position i by relaxing all possible spans ending at that position
  • Backward Pass: reconstruct the optimal path by backtracking from the end of the grid to the start using the stored back-pointers
  • Time Complexity: Maintained $O(V+E)$

Topological Sort Removed

Also, I realized that ReadingGrid itself is a linear lattice, so there is no need to perform a topological sort. It is already inherently topologically ordered:

  • Every node starts at index i and ends at i + spanLen
  • Since spanLen >= 1, an edge always points to a higher index (strictly forward)
  • Therefore, the array indices already constitute a valid topological order. By iterating linearly through the indices, we satisfy the topological requirement implicitly

Performance

  • MacBook Air M2
  • Because the nature of the algorithm is different, the meanings of vertices and edges change accordingly
    • vertices are the reachable states
    • edges are the candidate word transitions

Before

  • Stress test elapsed: 8069 microseconds
[==========] Running 21 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 21 tests from ReadingGridTest
[ RUN      ] ReadingGridTest.Span
[       OK ] ReadingGridTest.Span (32 ms)
[ RUN      ] ReadingGridTest.ScoreRankedLanguageModel
[       OK ] ReadingGridTest.ScoreRankedLanguageModel (0 ms)
[ RUN      ] ReadingGridTest.BasicOperations
[       OK ] ReadingGridTest.BasicOperations (0 ms)
[ RUN      ] ReadingGridTest.InvalidOperations
[       OK ] ReadingGridTest.InvalidOperations (0 ms)
[ RUN      ] ReadingGridTest.DeleteAfterCursor
[       OK ] ReadingGridTest.DeleteAfterCursor (0 ms)
[ RUN      ] ReadingGridTest.MultipleSpans
[       OK ] ReadingGridTest.MultipleSpans (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionSimple
[       OK ] ReadingGridTest.SpanDeletionSimple (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionFromMiddle
[       OK ] ReadingGridTest.SpanDeletionFromMiddle (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionFromMiddleUsingDeleteAfterCursor
[       OK ] ReadingGridTest.SpanDeletionFromMiddleUsingDeleteAfterCursor (0 ms)
[ RUN      ] ReadingGridTest.SpanInsertion
[       OK ] ReadingGridTest.SpanInsertion (0 ms)
[ RUN      ] ReadingGridTest.LongGridDeletion
[       OK ] ReadingGridTest.LongGridDeletion (0 ms)
[ RUN      ] ReadingGridTest.FindNodeInSpans
[       OK ] ReadingGridTest.FindNodeInSpans (0 ms)
[ RUN      ] ReadingGridTest.StressTest
stress test elapsed: 8069 microseconds, vertices: 16001, edges: 31996
[       OK ] ReadingGridTest.StressTest (162 ms)
[ RUN      ] ReadingGridTest.LongGridInsertion
[       OK ] ReadingGridTest.LongGridInsertion (0 ms)
[ RUN      ] ReadingGridTest.WordSegmentationTest
[       OK ] ReadingGridTest.WordSegmentationTest (0 ms)
[ RUN      ] ReadingGridTest.InputTest
[       OK ] ReadingGridTest.InputTest (0 ms)
[ RUN      ] ReadingGridTest.OverrideResetOverlappingNodes
[       OK ] ReadingGridTest.OverrideResetOverlappingNodes (0 ms)
[ RUN      ] ReadingGridTest.OverrideResetTest
[       OK ] ReadingGridTest.OverrideResetTest (0 ms)
[ RUN      ] ReadingGridTest.DisambiguateCandidates
[       OK ] ReadingGridTest.DisambiguateCandidates (0 ms)
[ RUN      ] ReadingGridTest.FindInSpan1
[       OK ] ReadingGridTest.FindInSpan1 (0 ms)
[ RUN      ] ReadingGridTest.FindInSpan2
[       OK ] ReadingGridTest.FindInSpan2 (0 ms)
[----------] 21 tests from ReadingGridTest (198 ms total)

[----------] Global test environment tear-down
[==========] 21 tests from 1 test suite ran. (198 ms total)
[  PASSED  ] 21 tests.
[100%] Built target runGramambular2Test

After

  • Stress test elapsed: 1019 microseconds
[==========] Running 21 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 21 tests from ReadingGridTest
[ RUN      ] ReadingGridTest.Span
[       OK ] ReadingGridTest.Span (29 ms)
[ RUN      ] ReadingGridTest.ScoreRankedLanguageModel
[       OK ] ReadingGridTest.ScoreRankedLanguageModel (0 ms)
[ RUN      ] ReadingGridTest.BasicOperations
[       OK ] ReadingGridTest.BasicOperations (0 ms)
[ RUN      ] ReadingGridTest.InvalidOperations
[       OK ] ReadingGridTest.InvalidOperations (0 ms)
[ RUN      ] ReadingGridTest.DeleteAfterCursor
[       OK ] ReadingGridTest.DeleteAfterCursor (0 ms)
[ RUN      ] ReadingGridTest.MultipleSpans
[       OK ] ReadingGridTest.MultipleSpans (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionSimple
[       OK ] ReadingGridTest.SpanDeletionSimple (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionFromMiddle
[       OK ] ReadingGridTest.SpanDeletionFromMiddle (0 ms)
[ RUN      ] ReadingGridTest.SpanDeletionFromMiddleUsingDeleteAfterCursor
[       OK ] ReadingGridTest.SpanDeletionFromMiddleUsingDeleteAfterCursor (0 ms)
[ RUN      ] ReadingGridTest.SpanInsertion
[       OK ] ReadingGridTest.SpanInsertion (0 ms)
[ RUN      ] ReadingGridTest.LongGridDeletion
[       OK ] ReadingGridTest.LongGridDeletion (0 ms)
[ RUN      ] ReadingGridTest.FindNodeInSpans
[       OK ] ReadingGridTest.FindNodeInSpans (0 ms)
[ RUN      ] ReadingGridTest.StressTest
stress test elapsed: 1019 microseconds, vertices: 8001, edges: 16001
[       OK ] ReadingGridTest.StressTest (151 ms)
[ RUN      ] ReadingGridTest.LongGridInsertion
[       OK ] ReadingGridTest.LongGridInsertion (0 ms)
[ RUN      ] ReadingGridTest.WordSegmentationTest
[       OK ] ReadingGridTest.WordSegmentationTest (0 ms)
[ RUN      ] ReadingGridTest.InputTest
[       OK ] ReadingGridTest.InputTest (0 ms)
[ RUN      ] ReadingGridTest.OverrideResetOverlappingNodes
[       OK ] ReadingGridTest.OverrideResetOverlappingNodes (0 ms)
[ RUN      ] ReadingGridTest.OverrideResetTest
[       OK ] ReadingGridTest.OverrideResetTest (0 ms)
[ RUN      ] ReadingGridTest.DisambiguateCandidates
[       OK ] ReadingGridTest.DisambiguateCandidates (0 ms)
[ RUN      ] ReadingGridTest.FindInSpan1
[       OK ] ReadingGridTest.FindInSpan1 (0 ms)
[ RUN      ] ReadingGridTest.FindInSpan2
[       OK ] ReadingGridTest.FindInSpan2 (0 ms)
[----------] 21 tests from ReadingGridTest (184 ms total)

[----------] Global test environment tear-down
[==========] 21 tests from 1 test suite ran. (184 ms total)
[  PASSED  ] 21 tests.
[100%] Built target runGramambular2Test

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an excellent pull request that refactors the shortest path algorithm to use the Viterbi algorithm, resulting in a significant performance improvement and much cleaner, more maintainable code. The new logic is well-encapsulated within the Viterbi class. I have two suggestions: one critical fix to prevent a crash when no valid path is found, and one high-priority comment to remove some dead code left over from the refactoring.

Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
@lukhnos

lukhnos commented Jan 28, 2026

Copy link
Copy Markdown
Contributor

This in an impressive PR!

I once had the 2nd ed. of Jurafsky and Martin on my bookshelf for a class, but I regret I had never grokked most of the book :|.

I think I'll want to spend some time on the code. Before that, one quick observation: you pointed out that the grid is already in topological order, and since the relaxing step in Cormen has the same runtime (O(|V| + |E|)), I wonder if we had spent too much time on the preparation + topological sort?

On my mid-range Intel Linux laptop, the stress test in debug mode had roughly the same run time (8000 us) as yours, of which ~1000 us (12%) was spent on the actual relaxing, and the rest was taken up by the graph-building and the topological search, which now seems totatlly unnecessary. I can't but wonder if just by simplifying that step we'd get the same result.

I also wonder what your numbers will be like if you run your PR in release mode. On my laptop the original code ran at 4x faster (~2100 us), and even less time was spent on the relaxing step (150 us, or 6%), which means the prep+sorting step was even more relatively expensive.

@ChiahongHong

ChiahongHong commented Jan 29, 2026

Copy link
Copy Markdown
Contributor Author

I think I'll want to spend some time on the code.

I completely agree that we should take our time with this. Since this is the core algorithm, it’s crucial to ensure the implementation is robus.

Your assessment was spot on! I ran the experiments in Release mode as you suggested, and the results confirm your observations. The overhead of building the graph structure is indeed the primary bottleneck.

I can't but wonder if just by simplifying that step we'd get the same result.

I was not sure how the original graph-building step could be simplified within the existing architecture for relaxing, so I only removed the topological sort and performed the relax step directly. Since topological sort is not the primary cause of the latency, even after removing it from the original code, it still takes ~700 us.

This proves that bypassing the explicit graph construction provides the most significant performance gain.

Viterbi

ReadingGrid::walk (All Steps)

int64_t start = GetEpochNowInMicroseconds();

Viterbi viterbi(spans_, readings_.size());
viterbi.ForwardPass(result);
viterbi.BackwardPass(result);

result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
202 us 213 us 219 us

1. Forward Pass (Relax)

int64_t start = GetEpochNowInMicroseconds();
viterbi.ForwardPass(result);
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
159 us 154 us 151 us

2. Backward Pass

int64_t start = GetEpochNowInMicroseconds();
viterbi.BackwardPass(result);
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
46 us 44 us 45 us

Original Code

ReadingGrid::walk (All Steps)

Round 1 Round 2 Round 3
951 us 956 us 1014 us

1. Preparation

int64_t start = GetEpochNowInMicroseconds();

std::vector<VertexSpan> vspans(spans_.size(), VertexSpan());
size_t vertices = 0;
size_t edges = 0;
for (size_t i = 0, len = spans_.size(); i < len; ++i) {
  const ReadingGrid::Span& span = spans_[i];
  for (size_t j = 1, maxSpanLen = span.maxLength(); j <= maxSpanLen; ++j) {
    NodePtr p = span.nodeOf(j);
    if (p != nullptr) {
      vspans[i].emplace_back(std::move(p));
      ++vertices;
    }
  }
}
result.vertices = vertices;

Vertex terminal(std::make_shared<ReadingGrid::Node>(
    "_TERMINAL_", 0, std::vector<LanguageModel::Unigram>()));
for (size_t i = 0, vspansLen = vspans.size(); i < vspansLen; ++i) {
  for (Vertex& v : vspans[i]) {
    size_t nextVertexPos = i + v.node->spanningLength();
    if (nextVertexPos == vspansLen) {
      v.edges.push_back(&terminal);
      continue;
    }

    for (Vertex& nv : vspans[nextVertexPos]) {
      v.edges.push_back(&nv);
      ++edges;
    }
  }
}
result.edges = edges;

Vertex root(std::make_shared<ReadingGrid::Node>(
    "_ROOT_", 0, std::vector<LanguageModel::Unigram>()));
root.distance = 0;
for (Vertex& v : vspans[0]) {
  root.edges.push_back(&v);
}

result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
671 us 714 us 664 us

2. TopologicalSort

int64_t start = GetEpochNowInMicroseconds();
std::vector<Vertex*> ordered = TopologicalSort(&root);
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
161 us 152 us 158 us

3. Relax

int64_t start = GetEpochNowInMicroseconds();
for (auto it = ordered.rbegin(), rend = ordered.rend(); it != rend; ++it) {
  Vertex* u = *it;
  for (Vertex* v : u->edges) {
    Relax(u, v);
  }
}
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
105 us 102 us 107 us

Original Code & Remove Topological Sort

int64_t start = GetEpochNowInMicroseconds();

std::vector<VertexSpan> vspans(spans_.size(), VertexSpan());
size_t vertices = 0;
size_t edges = 0;
for (size_t i = 0, len = spans_.size(); i < len; ++i) {
  const ReadingGrid::Span& span = spans_[i];
  for (size_t j = 1, maxSpanLen = span.maxLength(); j <= maxSpanLen; ++j) {
    NodePtr p = span.nodeOf(j);
    if (p != nullptr) {
      vspans[i].emplace_back(std::move(p));
      ++vertices;
    }
  }
}
result.vertices = vertices;

Vertex terminal(std::make_shared<ReadingGrid::Node>(
    "_TERMINAL_", 0, std::vector<LanguageModel::Unigram>()));
for (size_t i = 0, vspansLen = vspans.size(); i < vspansLen; ++i) {
  for (Vertex& v : vspans[i]) {
    size_t nextVertexPos = i + v.node->spanningLength();
    if (nextVertexPos == vspansLen) {
      v.edges.push_back(&terminal);
      continue;
    }

    for (Vertex& nv : vspans[nextVertexPos]) {
      v.edges.push_back(&nv);
      ++edges;
    }
  }
}
result.edges = edges;

Vertex root(std::make_shared<ReadingGrid::Node>(
    "_ROOT_", 0, std::vector<LanguageModel::Unigram>()));
root.distance = 0;
for (Vertex& v : vspans[0]) {
  root.edges.push_back(&v);
}

// 1. Relax edges from root
for (Vertex& v : vspans[0]) {
  Relax(&root, &v);
}

// 2. Relax edges span by span
for (size_t i = 0; i < vspans.size(); ++i) {
  for (Vertex& u : vspans[i]) {
    for (Vertex* v : u.edges) {
      Relax(&u, v);
    }
  }
}

std::vector<NodePtr> walked;
size_t totalReadingLen = 0;
Vertex* it = &terminal;
while (it->prev != nullptr) {
  walked.push_back(it->prev->node);
  it = it->prev;
  totalReadingLen += it->node->spanningLength();
}

assert(totalReadingLen == readings_.size());
assert(walked.size() >= 2);
result.totalReadings = totalReadingLen;
result.nodes = std::vector<NodePtr>(walked.rbegin() + 1, walked.rend());
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
Round 1 Round 2 Round 3
728 us 757 us 745 us

@lukhnos lukhnos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChiahongHong thank you very much! I've reviewed your code as well as the references you provided. The insight that the grid is always in topological order is very helpful, and this is an elegant solution. I've left some comments.

As an aside, I have tried to improve the original code by doing one step further than you did in the follow-up—instead of building the vertex graph, which is indeed the most expensive step, I have tried to bake the score and the prev fields into Node itself (a very unsightly thing in itself), and in the end the code would be "just" 10% slower than your PR (so 550 μs vs 500 μs in release builds).

The problem with the original approach is that during the relaxing step, each node still has to consider every incoming node, when in fact we are able to compute the top incoming node at each span location already (which is what your Viterbi implementation does!) because the grid is already in topological order. So while both algorithms run at O(|V| + |E|) times, the numbers of effective |V| and |E| each has to consider are different (as you also pointed out in the PR description), and that really explains the 10% difference.

While the original code already achieves sub-millisecond walks, this PR does it even faster with less code and less data: a really elegant solution!

Comment thread Source/Engine/gramambular2/reading_grid.cpp
Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
Comment thread Source/Engine/gramambular2/reading_grid.cpp Outdated
@ChiahongHong

Copy link
Copy Markdown
Contributor Author

@lukhnos Thank you for the detailed feedback!

I have removed the Vertex struct and integrated the Viterbi logic and State struct directly into the walk() function as you suggested. I’ve also removed the terms "Forward Pass" and "Backward Pass" to avoid any confusion, since those were just my own way of conceptualizing the steps rather than official textbook terms.

As I’m following your recommended approach and don’t have strong preferences about the structure, please let me know if there are any other areas that need refinement or if I’ve missed anything.

@lukhnos lukhnos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for your contribution!

@lukhnos
lukhnos merged commit 4199b23 into openvanilla:master Feb 3, 2026
8 checks passed
lukhnos added a commit to lukhnos/fcitx5-mcbopomofo that referenced this pull request Feb 3, 2026
lukhnos added a commit to lukhnos/fcitx5-mcbopomofo that referenced this pull request Feb 3, 2026
tianjianjiang added a commit that referenced this pull request Feb 14, 2026
Replace the DAG shortest-path algorithm documentation with the Viterbi
implementation merged in #777. The new section documents the linear
lattice forward pass, relaxation, and backward path reconstruction.

- Replace 4-step DAG section with 2-step Viterbi (forward + backward)
- Update code reference table: remove TopologicalSort/Relax rows
- Update references: Jurafsky & Martin + vene.ro lattice Viterbi
- Fix TOC link to match new heading
- Bump version to 1.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tianjianjiang added a commit that referenced this pull request Feb 26, 2026
Extract the Viterbi walk algorithm from ReadingGrid::walk() into a
dedicated WalkStrategy class, enabling strategy-pattern extensibility.

Changes:
- Add WalkStrategy base class with ViterbiStrategy implementation
- Use forward-pass DP (matching post-#777 algorithm) instead of
  explicit DAG construction + topological sort
- Add fixedSpans support: blocked[] array constrains the walk to
  respect user-selected spans
- Convert Span storage from fixed array to vector for dynamic
  span lengths based on language model maxKeyLength()
- Add fixSpan()/clearFixedSpans() to ReadingGrid for structural
  override support
- ReadingGrid::walk() delegates to the configured WalkStrategy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tianjianjiang added a commit that referenced this pull request Jun 10, 2026
Describe the forward-pass Viterbi DP walk (PR #777) with verified
file/line references against current master, an O(|V| + |E|) complexity
analysis matching the implementation comment, and measured stress-test
numbers (vertices/edges from WalkResult).

Replace the dropped WalkStrategy/fixedSpans walk-integration design with
the actual candidate-override mechanism (overrideCandidate plus
re-walk), and align the contextual user model section with the shipped
ContextualUserModel design (PR #780): two-level interpolated Kneser-Ney
with per-reading continuation normalization, wall-clock decay with a
5400-second half-life, LRU capacity bound, TSV persistence, and implicit
base-LM fallback via empty suggestions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants