Skip to content

Commit 4056205

Browse files
tianjianjiangclaude
andcommitted
refactor(engine): extract walk strategy with dynamic span support
- Extract Viterbi walk into WalkStrategy interface with ViterbiStrategy default - Placeholder subclasses: PrunedViterbi, MMSEG, SegmentViterbi - Replace fixed-size span array with dynamic vector (LM maxKeyLength) - Add fixSpan/clearFixedSpans for structural override constraints - Remove unused edges field from WalkResult - Fix clear() to also clear fixedSpans_ - Add walk_strategy to Xcode project compile sources Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb597f0 commit 4056205

8 files changed

Lines changed: 655 additions & 94 deletions

File tree

McBopomofo.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
6A2E40F9253A6AA000D1AE1D /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6A2E40F5253A69DA00D1AE1D /* Images.xcassets */; };
1818
6A38BC1515FC117A00A8A51F /* data.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A38BBF615FC117A00A8A51F /* data.txt */; };
1919
6A4F5F982879E838008C4307 /* reading_grid.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 6A4F5F932879E838008C4307 /* reading_grid.cpp */; };
20+
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */; };
2021
6A660A702EAF371000D53D7B /* ByteBlockBackedDictionary.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */; };
2122
6A68C1C32EC7F2C0005284A0 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 6A68C1C12EC7F2C0005284A0 /* Localizable.stringsdict */; };
2223
6A6ED16B2797650A0012872E /* template-phrases-replacement.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A6ED1632797650A0012872E /* template-phrases-replacement.txt */; };
@@ -140,6 +141,8 @@
140141
6A4F5F912879E838008C4307 /* reading_grid.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = reading_grid.h; sourceTree = "<group>"; };
141142
6A4F5F922879E838008C4307 /* language_model.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = language_model.h; sourceTree = "<group>"; };
142143
6A4F5F932879E838008C4307 /* reading_grid.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = reading_grid.cpp; sourceTree = "<group>"; };
144+
1B900C269506F40A48314006 /* walk_strategy.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = walk_strategy.h; sourceTree = "<group>"; };
145+
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = walk_strategy.cpp; sourceTree = "<group>"; };
143146
6A660A6E2EAF371000D53D7B /* ByteBlockBackedDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ByteBlockBackedDictionary.h; sourceTree = "<group>"; };
144147
6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ByteBlockBackedDictionary.cpp; sourceTree = "<group>"; };
145148
6A68C1C22EC7F2C0005284A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -430,6 +433,8 @@
430433
6A4F5F912879E838008C4307 /* reading_grid.h */,
431434
6A4F5F922879E838008C4307 /* language_model.h */,
432435
6A4F5F932879E838008C4307 /* reading_grid.cpp */,
436+
1B900C269506F40A48314006 /* walk_strategy.h */,
437+
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */,
433438
);
434439
path = gramambular2;
435440
sourceTree = "<group>";
@@ -745,6 +750,7 @@
745750
D4314F0D2ED3690F0071DD71 /* NumberInputHelper.swift in Sources */,
746751
D4E569DC27A34D0E00AC2CEF /* KeyHandler.mm in Sources */,
747752
6A4F5F982879E838008C4307 /* reading_grid.cpp in Sources */,
753+
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */,
748754
D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */,
749755
D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */,
750756
D47F7DCE278BFB57002F9DD7 /* PreferencesWindowController.swift in Sources */,

Source/Engine/gramambular2/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ project(gramambular2)
44
set(CMAKE_CXX_STANDARD 17)
55
set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC")
66

7-
add_library(gramambular2_lib language_model.h reading_grid.h reading_grid.cpp)
7+
add_library(gramambular2_lib language_model.h reading_grid.h reading_grid.cpp walk_strategy.h walk_strategy.cpp)
88

99
if (ENABLE_CLANG_TIDY)
1010
set_target_properties(gramambular2_lib PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_COMMAND}")

Source/Engine/gramambular2/language_model.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class LanguageModel {
4141
virtual std::vector<Unigram> getUnigrams(const std::string& reading) = 0;
4242
virtual bool hasUnigrams(const std::string& reading) = 0;
4343

44+
// Maximum key length in syllables. Returns 0 if unknown, in which case the
45+
// grid uses a default value.
46+
virtual size_t maxKeyLength() const { return 0; }
47+
4448
// An immutable unigram with an actual value, along with a score, which is
4549
// usually a log probability from a language model.
4650
class Unigram {

Source/Engine/gramambular2/reading_grid.cpp

Lines changed: 71 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,19 @@
2525

2626
#include <algorithm>
2727
#include <chrono>
28-
#include <limits>
29-
#include <stack>
3028
#include <string>
3129
#include <utility>
3230
#include <vector>
3331

32+
#include "walk_strategy.h"
33+
3434
namespace Formosa::Gramambular2 {
3535

3636
void ReadingGrid::clear() {
3737
cursor_ = 0;
3838
readings_.clear();
3939
spans_.clear();
40+
fixedSpans_.clear();
4041
}
4142

4243
void ReadingGrid::setCursor(size_t cursor) {
@@ -121,83 +122,68 @@ int64_t GetEpochNowInMicroseconds() {
121122

122123
} // namespace
123124

124-
// Find the weightiest path in the grid graph. The path represents the most
125-
// likely hidden chain of events from the observations.
126-
// We use the Viterbi algorithm to compute such path.
127-
// Instead of computing the path with the shortest distance, though, we compute
128-
// the path with the longest distance (so the weightiest), since with log
129-
// probability a larger value means a larger probability. The algorithm runs in
130-
// O(|V| + |E|) time for G = (V, E) where G is a DAG. This means the walk is
131-
// fairly economical even when the grid is large.
125+
void ReadingGrid::setWalkStrategy(std::shared_ptr<WalkStrategy> strategy) {
126+
walkStrategy_ = std::move(strategy);
127+
}
128+
129+
void ReadingGrid::fixSpan(size_t position, NodePtr node) {
130+
assert(node != nullptr);
131+
assert(position < readings_.size());
132+
assert(position + node->spanningLength() <= readings_.size());
133+
size_t newEnd = position + node->spanningLength();
134+
auto it = fixedSpans_.begin();
135+
while (it != fixedSpans_.end()) {
136+
size_t existStart = it->first;
137+
size_t existEnd = existStart + it->second->spanningLength();
138+
// Two spans overlap if their ranges intersect.
139+
if (existStart < newEnd && position < existEnd) {
140+
it = fixedSpans_.erase(it);
141+
} else {
142+
++it;
143+
}
144+
}
145+
fixedSpans_[position] = std::move(node);
146+
}
147+
148+
void ReadingGrid::clearFixedSpans() {
149+
for (auto& [pos, node] : fixedSpans_) {
150+
node->reset();
151+
}
152+
fixedSpans_.clear();
153+
}
154+
132155
ReadingGrid::WalkResult ReadingGrid::walk() {
133156
WalkResult result;
134157
if (spans_.empty()) {
135158
return result;
136159
}
137160
int64_t start = GetEpochNowInMicroseconds();
138161

139-
// Defines a state in the DP table. This structure tracks the maximum
140-
// accumulated score and the back-pointer required for path reconstruction in
141-
// the Viterbi algorithm.
142-
struct State {
143-
size_t fromIndex = 0;
144-
ReadingGrid::NodePtr fromNode = nullptr;
145-
double maxScore = -std::numeric_limits<double>::infinity();
146-
};
147-
148-
const size_t readingLen = readings_.size();
149-
std::vector<State> viterbi(readingLen + 1);
150-
viterbi[0].maxScore = 0.0;
151-
152-
// Iterate through the grid and compute the maximum accumulated score for each
153-
// reachable position. Since the grid is a lattice where edges only point
154-
// forward, processing nodes in index order is equivalent to processing them
155-
// in topological order.
156-
size_t reachableStates = 0;
157-
size_t evaluatedEdges = 0;
158-
for (size_t i = 0; i < readingLen; ++i) {
159-
++reachableStates;
160-
161-
const ReadingGrid::Span& span = spans_[i];
162-
const size_t maxSpanLen = span.maxLength();
163-
164-
for (size_t spanLen = 1; spanLen <= maxSpanLen; ++spanLen) {
165-
const ReadingGrid::NodePtr& node = span.nodeOf(spanLen);
166-
if (node == nullptr) {
167-
continue;
168-
}
169-
++evaluatedEdges;
170-
171-
// Performs a relaxation on a transition. This updates the destination
172-
// state if the path through the current node yields a higher score than
173-
// the previously known best path. This is the core operation of the
174-
// Viterbi algorithm, adapted for finding the maximum likelihood path.
175-
double score = viterbi[i].maxScore + node->score();
176-
State& target = viterbi[i + spanLen];
177-
if (score > target.maxScore) {
178-
target.maxScore = score;
179-
target.fromNode = node;
180-
target.fromIndex = i;
181-
}
182-
}
162+
if (!walkStrategy_) {
163+
walkStrategy_ = std::make_shared<ViterbiStrategy>();
183164
}
184-
// Vertices are the reachable states
185-
// Edges are the candidate word transitions
186-
result.vertices = reachableStates;
187-
result.edges = evaluatedEdges;
188165

189-
// Reconstruct the most likely path by tracing back from the end of the grid
190-
// to the root using the back-pointers
166+
const std::map<size_t, NodePtr>* fixedPtr =
167+
fixedSpans_.empty() ? nullptr : &fixedSpans_;
168+
WalkStrategy::WalkInput input{spans_, readings_.size(), fixedPtr};
169+
result.nodes = walkStrategy_->walk(input);
170+
191171
size_t totalReadingLen = 0;
192-
for (size_t curr = readingLen; curr > 0; curr = viterbi[curr].fromIndex) {
193-
assert(viterbi[curr].fromNode != nullptr);
194-
totalReadingLen += viterbi[curr].fromNode->spanningLength();
195-
result.nodes.emplace_back(std::move(viterbi[curr].fromNode));
172+
size_t vertices = 0;
173+
for (const auto& node : result.nodes) {
174+
totalReadingLen += node->spanningLength();
175+
}
176+
for (size_t i = 0, len = spans_.size(); i < len; ++i) {
177+
const Span& span = spans_[i];
178+
for (size_t j = 1, maxSpanLen = span.maxLength(); j <= maxSpanLen; ++j) {
179+
if (span.nodeOf(j) != nullptr) {
180+
++vertices;
181+
}
182+
}
196183
}
197-
std::reverse(result.nodes.begin(), result.nodes.end());
198-
assert(totalReadingLen == readingLen);
199-
result.totalReadings = totalReadingLen;
200184

185+
result.totalReadings = totalReadingLen;
186+
result.vertices = vertices;
201187
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
202188
return result;
203189
}
@@ -292,7 +278,7 @@ void ReadingGrid::removeAffectedNodes(size_t loc) {
292278
if (spans_.empty()) {
293279
return;
294280
}
295-
size_t affectedLength = kMaximumSpanLength - 1;
281+
size_t affectedLength = maxSpanLength_ - 1;
296282
size_t begin = loc <= affectedLength ? 0 : loc - affectedLength;
297283
size_t end = loc >= 1 ? loc - 1 : 0;
298284
for (size_t i = begin; i <= end; ++i) {
@@ -333,14 +319,14 @@ bool ReadingGrid::hasNodeAt(size_t loc, size_t readingLen,
333319

334320
void ReadingGrid::update() {
335321
size_t begin =
336-
(cursor_ <= kMaximumSpanLength) ? 0 : cursor_ - kMaximumSpanLength;
337-
size_t end = cursor_ + kMaximumSpanLength;
322+
(cursor_ <= maxSpanLength_) ? 0 : cursor_ - maxSpanLength_;
323+
size_t end = cursor_ + maxSpanLength_;
338324
if (end > readings_.size()) {
339325
end = readings_.size();
340326
}
341327

342328
for (size_t pos = begin; pos < end; pos++) {
343-
for (size_t len = 1; len <= kMaximumSpanLength && pos + len <= end; len++) {
329+
for (size_t len = 1; len <= maxSpanLength_ && pos + len <= end; len++) {
344330
std::string combinedReading =
345331
combineReading(readings_.begin() + static_cast<ptrdiff_t>(pos),
346332
readings_.begin() + static_cast<ptrdiff_t>(pos + len));
@@ -419,7 +405,7 @@ std::vector<ReadingGrid::NodeInSpan> ReadingGrid::overlappingNodesAt(
419405
}
420406
}
421407

422-
size_t begin = loc - std::min(loc, kMaximumSpanLength - 1);
408+
size_t begin = loc - std::min(loc, maxSpanLength_ - 1);
423409
for (size_t i = begin; i < loc; ++i) {
424410
size_t beginLen = loc - i + 1;
425411
size_t endLen = spans_[i].maxLength();
@@ -541,22 +527,25 @@ std::vector<std::string> ReadingGrid::WalkResult::readingsAsStrings() const {
541527
}
542528

543529
void ReadingGrid::Span::clear() {
544-
nodes_.fill(nullptr);
530+
nodes_.clear();
545531
maxLength_ = 0;
546532
}
547533

548534
void ReadingGrid::Span::add(const ReadingGrid::NodePtr& node) {
549-
assert(node->spanningLength() > 0 &&
550-
node->spanningLength() <= kMaximumSpanLength);
551-
nodes_[node->spanningLength() - 1] = node;
535+
assert(node->spanningLength() > 0);
536+
size_t idx = node->spanningLength() - 1;
537+
if (idx >= nodes_.size()) {
538+
nodes_.resize(idx + 1);
539+
}
540+
nodes_[idx] = node;
552541
if (node->spanningLength() >= maxLength_) {
553542
maxLength_ = node->spanningLength();
554543
}
555544
}
556545

557546
void ReadingGrid::Span::removeNodesOfOrLongerThan(size_t length) {
558-
assert(length > 0 && length <= kMaximumSpanLength);
559-
for (size_t i = length - 1; i < kMaximumSpanLength; ++i) {
547+
assert(length > 0);
548+
for (size_t i = length - 1; i < nodes_.size(); ++i) {
560549
nodes_[i] = nullptr;
561550
}
562551
maxLength_ = 0;
@@ -580,7 +569,10 @@ void ReadingGrid::Span::removeNodesOfOrLongerThan(size_t length) {
580569
}
581570

582571
ReadingGrid::NodePtr ReadingGrid::Span::nodeOf(size_t length) const {
583-
assert(length > 0 && length <= kMaximumSpanLength);
572+
assert(length > 0);
573+
if (length - 1 >= nodes_.size()) {
574+
return nullptr;
575+
}
584576
return nodes_[length - 1];
585577
}
586578

Source/Engine/gramambular2/reading_grid.h

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
#ifndef SRC_ENGINE_GRAMAMBULAR2_READING_GRID_H_
2525
#define SRC_ENGINE_GRAMAMBULAR2_READING_GRID_H_
2626

27-
#include <array>
2827
#include <cassert>
2928
#include <cstdint>
3029
#include <functional>
30+
#include <map>
3131
#include <memory>
3232
#include <optional>
3333
#include <string>
@@ -47,12 +47,17 @@ namespace Formosa::Gramambular2 {
4747
// While we use the terminology from hidden Markov model (HMM), the actual
4848
// implementation is a much simpler Bayesian inference, since the underlying
4949
// language model consists of only unigrams. Once we have put all plausible
50-
// unigrams as nodes on the grid, a simple DAG shortest-path walk will give us
50+
// unigrams as nodes on the grid, a simple DAG longest-path walk will give us
5151
// the maximum likelihood estimation (MLE) for the hidden values.
5252
class ReadingGrid {
5353
public:
54+
static constexpr size_t kDefaultMaxSpanLength = 8;
55+
5456
explicit ReadingGrid(std::shared_ptr<LanguageModel> lm)
55-
: lm_(std::move(lm)) {}
57+
: lm_(std::move(lm)) {
58+
size_t lmMax = lm_.maxKeyLength();
59+
maxSpanLength_ = lmMax > 0 ? lmMax : kDefaultMaxSpanLength;
60+
}
5661

5762
void clear();
5863

@@ -75,9 +80,10 @@ class ReadingGrid {
7580
// Delete the reading after the cursor, like Del. Cursor is unmoved.
7681
bool deleteReadingAfterCursor();
7782

78-
static constexpr size_t kMaximumSpanLength = 8;
7983
static constexpr char kDefaultSeparator[] = "-";
8084

85+
[[nodiscard]] size_t maxSpanLength() const { return maxSpanLength_; }
86+
8187
// A Node consists of a set of unigrams, a reading, and a spanning length.
8288
// The spanning length denotes the length of the node in the grid. The grid
8389
// is responsible for constructing its nodes. For Mandarin multi-character
@@ -165,7 +171,6 @@ class ReadingGrid {
165171
std::vector<NodePtr> nodes;
166172
size_t totalReadings = 0;
167173
size_t vertices = 0;
168-
size_t edges = 0;
169174
uint64_t elapsedMicroseconds = 0;
170175

171176
// Convenient method for finding the node at the cursor. Returns
@@ -180,6 +185,16 @@ class ReadingGrid {
180185
std::vector<std::string> readingsAsStrings() const;
181186
};
182187

188+
// Set the walk strategy. Default is ViterbiStrategy.
189+
void setWalkStrategy(std::shared_ptr<class WalkStrategy> strategy);
190+
191+
// Fix a span at the given position to the given node. The walk will be
192+
// constrained to go through this node. Last-write-wins: fixing at a position
193+
// that overlaps an existing fix clears the overlapping fix.
194+
void fixSpan(size_t position, NodePtr node);
195+
196+
void clearFixedSpans();
197+
183198
WalkResult walk();
184199

185200
struct Candidate {
@@ -220,7 +235,7 @@ class ReadingGrid {
220235
[[nodiscard]] size_t maxLength() const { return maxLength_; }
221236

222237
protected:
223-
std::array<NodePtr, kMaximumSpanLength> nodes_;
238+
std::vector<NodePtr> nodes_;
224239
size_t maxLength_ = 0;
225240
};
226241

@@ -233,6 +248,7 @@ class ReadingGrid {
233248
}
234249
std::vector<Unigram> getUnigrams(const std::string& reading) override;
235250
bool hasUnigrams(const std::string& reading) override;
251+
size_t maxKeyLength() const override { return lm_->maxKeyLength(); }
236252

237253
protected:
238254
std::shared_ptr<LanguageModel> lm_;
@@ -246,10 +262,13 @@ class ReadingGrid {
246262

247263
protected:
248264
size_t cursor_ = 0;
265+
size_t maxSpanLength_;
249266
std::string separator_ = kDefaultSeparator;
250267
std::vector<std::string> readings_;
251268
std::vector<Span> spans_;
252269
ScoreRankedLanguageModel lm_;
270+
std::shared_ptr<class WalkStrategy> walkStrategy_;
271+
std::map<size_t, NodePtr> fixedSpans_;
253272

254273
// Internal methods for maintaining the grid.
255274

0 commit comments

Comments
 (0)