Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions McBopomofo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
6A2E40F9253A6AA000D1AE1D /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6A2E40F5253A69DA00D1AE1D /* Images.xcassets */; };
6A38BC1515FC117A00A8A51F /* data.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A38BBF615FC117A00A8A51F /* data.txt */; };
6A4F5F982879E838008C4307 /* reading_grid.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 6A4F5F932879E838008C4307 /* reading_grid.cpp */; };
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */; };
6A660A702EAF371000D53D7B /* ByteBlockBackedDictionary.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */; };
6A68C1C32EC7F2C0005284A0 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 6A68C1C12EC7F2C0005284A0 /* Localizable.stringsdict */; };
6A6ED16B2797650A0012872E /* template-phrases-replacement.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A6ED1632797650A0012872E /* template-phrases-replacement.txt */; };
Expand Down Expand Up @@ -140,6 +141,8 @@
6A4F5F912879E838008C4307 /* reading_grid.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = reading_grid.h; sourceTree = "<group>"; };
6A4F5F922879E838008C4307 /* language_model.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = language_model.h; sourceTree = "<group>"; };
6A4F5F932879E838008C4307 /* reading_grid.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = reading_grid.cpp; sourceTree = "<group>"; };
1B900C269506F40A48314006 /* walk_strategy.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = walk_strategy.h; sourceTree = "<group>"; };
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = walk_strategy.cpp; sourceTree = "<group>"; };
6A660A6E2EAF371000D53D7B /* ByteBlockBackedDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ByteBlockBackedDictionary.h; sourceTree = "<group>"; };
6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ByteBlockBackedDictionary.cpp; sourceTree = "<group>"; };
6A68C1C22EC7F2C0005284A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
Expand Down Expand Up @@ -430,6 +433,8 @@
6A4F5F912879E838008C4307 /* reading_grid.h */,
6A4F5F922879E838008C4307 /* language_model.h */,
6A4F5F932879E838008C4307 /* reading_grid.cpp */,
1B900C269506F40A48314006 /* walk_strategy.h */,
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */,
);
path = gramambular2;
sourceTree = "<group>";
Expand Down Expand Up @@ -745,6 +750,7 @@
D4314F0D2ED3690F0071DD71 /* NumberInputHelper.swift in Sources */,
D4E569DC27A34D0E00AC2CEF /* KeyHandler.mm in Sources */,
6A4F5F982879E838008C4307 /* reading_grid.cpp in Sources */,
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */,
D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */,
D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */,
D47F7DCE278BFB57002F9DD7 /* PreferencesWindowController.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion Source/Engine/gramambular2/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ project(gramambular2)
set(CMAKE_CXX_STANDARD 17)
set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC")

add_library(gramambular2_lib language_model.h reading_grid.h reading_grid.cpp)
add_library(gramambular2_lib language_model.h reading_grid.h reading_grid.cpp walk_strategy.h walk_strategy.cpp)

if (ENABLE_CLANG_TIDY)
set_target_properties(gramambular2_lib PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_COMMAND}")
Expand Down
4 changes: 4 additions & 0 deletions Source/Engine/gramambular2/language_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ class LanguageModel {
virtual std::vector<Unigram> getUnigrams(const std::string& reading) = 0;
virtual bool hasUnigrams(const std::string& reading) = 0;

// Maximum key length in syllables. Returns 0 if unknown, in which case the
// grid uses a default value.
virtual size_t maxKeyLength() const { return 0; }

// An immutable unigram with an actual value, along with a score, which is
// usually a log probability from a language model.
class Unigram {
Expand Down
143 changes: 61 additions & 82 deletions Source/Engine/gramambular2/reading_grid.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@

#include <algorithm>
#include <chrono>
#include <limits>
#include <stack>
#include <string>
#include <utility>
#include <vector>

#include "walk_strategy.h"

namespace Formosa::Gramambular2 {

void ReadingGrid::clear() {
cursor_ = 0;
readings_.clear();
spans_.clear();
fixedSpans_.clear();
}

void ReadingGrid::setCursor(size_t cursor) {
Comment thread
tianjianjiang marked this conversation as resolved.
Expand Down Expand Up @@ -121,83 +122,55 @@ int64_t GetEpochNowInMicroseconds() {

Comment thread
tianjianjiang marked this conversation as resolved.
} // namespace

// Find the weightiest path in the grid graph. The path represents the most
// likely hidden chain of events from the observations.
// We use the Viterbi algorithm to compute such path.
// Instead of computing the path with the shortest distance, though, we compute
// the path with the longest distance (so the weightiest), since with log
// probability a larger value means a larger probability. The algorithm runs in
// O(|V| + |E|) time for G = (V, E) where G is a DAG. This means the walk is
// fairly economical even when the grid is large.
void ReadingGrid::setWalkStrategy(std::shared_ptr<WalkStrategy> strategy) {
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
walkStrategy_ = std::move(strategy);
}

Comment thread
tianjianjiang marked this conversation as resolved.
void ReadingGrid::fixSpan(size_t position, NodePtr node) {
assert(node != nullptr);
Comment thread
tianjianjiang marked this conversation as resolved.
assert(position < readings_.size());
assert(position + node->spanningLength() <= readings_.size());
size_t newEnd = position + node->spanningLength();
auto it = fixedSpans_.begin();
Comment thread
tianjianjiang marked this conversation as resolved.
while (it != fixedSpans_.end()) {
size_t existStart = it->first;
size_t existEnd = existStart + it->second->spanningLength();
// Two spans overlap if their ranges intersect.
if (existStart < newEnd && position < existEnd) {
Comment thread
tianjianjiang marked this conversation as resolved.
it = fixedSpans_.erase(it);
} else {
++it;
Comment thread
tianjianjiang marked this conversation as resolved.
}
}
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
fixedSpans_[position] = std::move(node);
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
}
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.

void ReadingGrid::clearFixedSpans() {
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
for (auto& [pos, node] : fixedSpans_) {
Comment thread
tianjianjiang marked this conversation as resolved.
node->reset();
}
Comment thread
tianjianjiang marked this conversation as resolved.
fixedSpans_.clear();
Comment on lines +147 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

node->reset() has a silent side-effect on the shared Node objects still referenced by spans_.

The NodePtrs stored in fixedSpans_ are the same shared_ptr<Node> objects that live inside the grid's spans_ vector. Calling node->reset() mutates that shared state — if reset() removes the score override, any future walk (even one unrelated to fixed spans) will see the mutation. From the API caller's perspective, clearFixedSpans() appears to be a structural operation but it secretly alters node scoring state.

If the intention is that a fixed span "owns" its override and clearing it should also undo the override, that coupling should be documented explicitly and/or enforced structurally (e.g., clear the override before calling fixSpan and restore it on clear). If the intention is that overrides and span-fixing are independent, the reset() call should be removed and callers who want both undone should call selectOverrideUnigram separately.

Suggested change
void ReadingGrid::clearFixedSpans() {
for (auto& [pos, node] : fixedSpans_) {
node->reset();
}
fixedSpans_.clear();
void ReadingGrid::clearFixedSpans() {
fixedSpans_.clear();
}

}

ReadingGrid::WalkResult ReadingGrid::walk() {
WalkResult result;
if (spans_.empty()) {
return result;
}
int64_t start = GetEpochNowInMicroseconds();

// Defines a state in the DP table. This structure tracks the maximum
// accumulated score and the back-pointer required for path reconstruction in
// the Viterbi algorithm.
struct State {
size_t fromIndex = 0;
ReadingGrid::NodePtr fromNode = nullptr;
double maxScore = -std::numeric_limits<double>::infinity();
};

const size_t readingLen = readings_.size();
std::vector<State> viterbi(readingLen + 1);
viterbi[0].maxScore = 0.0;

// Iterate through the grid and compute the maximum accumulated score for each
// reachable position. Since the grid is a lattice where edges only point
// forward, processing nodes in index order is equivalent to processing them
// in topological order.
size_t reachableStates = 0;
size_t evaluatedEdges = 0;
for (size_t i = 0; i < readingLen; ++i) {
++reachableStates;

const ReadingGrid::Span& span = spans_[i];
const size_t maxSpanLen = span.maxLength();

for (size_t spanLen = 1; spanLen <= maxSpanLen; ++spanLen) {
const ReadingGrid::NodePtr& node = span.nodeOf(spanLen);
if (node == nullptr) {
continue;
}
++evaluatedEdges;

// Performs a relaxation on a transition. This updates the destination
// state if the path through the current node yields a higher score than
// the previously known best path. This is the core operation of the
// Viterbi algorithm, adapted for finding the maximum likelihood path.
double score = viterbi[i].maxScore + node->score();
State& target = viterbi[i + spanLen];
if (score > target.maxScore) {
target.maxScore = score;
target.fromNode = node;
target.fromIndex = i;
}
}
if (!walkStrategy_) {
walkStrategy_ = std::make_shared<ViterbiStrategy>();
}
// Vertices are the reachable states
// Edges are the candidate word transitions
result.vertices = reachableStates;
result.edges = evaluatedEdges;

// Reconstruct the most likely path by tracing back from the end of the grid
// to the root using the back-pointers
size_t totalReadingLen = 0;
for (size_t curr = readingLen; curr > 0; curr = viterbi[curr].fromIndex) {
assert(viterbi[curr].fromNode != nullptr);
totalReadingLen += viterbi[curr].fromNode->spanningLength();
result.nodes.emplace_back(std::move(viterbi[curr].fromNode));
}
std::reverse(result.nodes.begin(), result.nodes.end());
assert(totalReadingLen == readingLen);
result.totalReadings = totalReadingLen;

const std::map<size_t, NodePtr>* fixedPtr =
fixedSpans_.empty() ? nullptr : &fixedSpans_;
WalkStrategy::WalkInput input{spans_, readings_.size(), fixedPtr};
auto walkOutput = walkStrategy_->walk(input);
result.nodes = std::move(walkOutput.nodes);
result.totalReadings = walkOutput.totalReadings;
result.vertices = walkOutput.vertices;
result.edges = walkOutput.edges;
result.elapsedMicroseconds = GetEpochNowInMicroseconds() - start;
return result;
}
Comment thread
tianjianjiang marked this conversation as resolved.
Expand Down Expand Up @@ -292,7 +265,7 @@ void ReadingGrid::removeAffectedNodes(size_t loc) {
if (spans_.empty()) {
return;
}
size_t affectedLength = kMaximumSpanLength - 1;
size_t affectedLength = maxSpanLength_ - 1;
size_t begin = loc <= affectedLength ? 0 : loc - affectedLength;
size_t end = loc >= 1 ? loc - 1 : 0;
for (size_t i = begin; i <= end; ++i) {
Expand Down Expand Up @@ -333,14 +306,14 @@ bool ReadingGrid::hasNodeAt(size_t loc, size_t readingLen,

void ReadingGrid::update() {
size_t begin =
(cursor_ <= kMaximumSpanLength) ? 0 : cursor_ - kMaximumSpanLength;
size_t end = cursor_ + kMaximumSpanLength;
(cursor_ <= maxSpanLength_) ? 0 : cursor_ - maxSpanLength_;
size_t end = cursor_ + maxSpanLength_;
if (end > readings_.size()) {
end = readings_.size();
}

for (size_t pos = begin; pos < end; pos++) {
for (size_t len = 1; len <= kMaximumSpanLength && pos + len <= end; len++) {
for (size_t len = 1; len <= maxSpanLength_ && pos + len <= end; len++) {
std::string combinedReading =
combineReading(readings_.begin() + static_cast<ptrdiff_t>(pos),
readings_.begin() + static_cast<ptrdiff_t>(pos + len));
Expand Down Expand Up @@ -419,7 +392,7 @@ std::vector<ReadingGrid::NodeInSpan> ReadingGrid::overlappingNodesAt(
}
}
Comment thread
tianjianjiang marked this conversation as resolved.

size_t begin = loc - std::min(loc, kMaximumSpanLength - 1);
size_t begin = loc - std::min(loc, maxSpanLength_ - 1);
for (size_t i = begin; i < loc; ++i) {
size_t beginLen = loc - i + 1;
size_t endLen = spans_[i].maxLength();
Expand Down Expand Up @@ -541,22 +514,25 @@ std::vector<std::string> ReadingGrid::WalkResult::readingsAsStrings() const {
}

void ReadingGrid::Span::clear() {
nodes_.fill(nullptr);
nodes_.clear();
maxLength_ = 0;
}

void ReadingGrid::Span::add(const ReadingGrid::NodePtr& node) {
assert(node->spanningLength() > 0 &&
node->spanningLength() <= kMaximumSpanLength);
nodes_[node->spanningLength() - 1] = node;
assert(node->spanningLength() > 0);
size_t idx = node->spanningLength() - 1;
if (idx >= nodes_.size()) {
nodes_.resize(idx + 1);
}
nodes_[idx] = node;
if (node->spanningLength() >= maxLength_) {
maxLength_ = node->spanningLength();
}
}

void ReadingGrid::Span::removeNodesOfOrLongerThan(size_t length) {
assert(length > 0 && length <= kMaximumSpanLength);
for (size_t i = length - 1; i < kMaximumSpanLength; ++i) {
assert(length > 0);
for (size_t i = length - 1; i < nodes_.size(); ++i) {
nodes_[i] = nullptr;
}
maxLength_ = 0;
Expand All @@ -580,7 +556,10 @@ void ReadingGrid::Span::removeNodesOfOrLongerThan(size_t length) {
}

ReadingGrid::NodePtr ReadingGrid::Span::nodeOf(size_t length) const {
assert(length > 0 && length <= kMaximumSpanLength);
assert(length > 0);
if (length - 1 >= nodes_.size()) {
return nullptr;
}
return nodes_[length - 1];
}

Expand Down
30 changes: 25 additions & 5 deletions Source/Engine/gramambular2/reading_grid.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
#ifndef SRC_ENGINE_GRAMAMBULAR2_READING_GRID_H_
#define SRC_ENGINE_GRAMAMBULAR2_READING_GRID_H_

#include <array>
#include <cassert>
#include <cstdint>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
Expand All @@ -47,12 +47,17 @@ namespace Formosa::Gramambular2 {
// While we use the terminology from hidden Markov model (HMM), the actual
// implementation is a much simpler Bayesian inference, since the underlying
Comment thread
tianjianjiang marked this conversation as resolved.
// language model consists of only unigrams. Once we have put all plausible
// unigrams as nodes on the grid, a simple DAG shortest-path walk will give us
// unigrams as nodes on the grid, a simple DAG longest-path walk will give us
// the maximum likelihood estimation (MLE) for the hidden values.
class ReadingGrid {
public:
static constexpr size_t kDefaultMaxSpanLength = 8;
Comment thread
tianjianjiang marked this conversation as resolved.

Comment thread
tianjianjiang marked this conversation as resolved.
explicit ReadingGrid(std::shared_ptr<LanguageModel> lm)
Comment thread
tianjianjiang marked this conversation as resolved.
: lm_(std::move(lm)) {}
: lm_(std::move(lm)) {
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
size_t lmMax = lm_.maxKeyLength();
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
Comment on lines +55 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maxKeyLength() is queried from the language model in the constructor body after lm_ is moved — verify the delegation chain is safe.

The constructor body calls lm_.maxKeyLength(), where lm_ is a ScoreRankedLanguageModel that wraps the shared_ptr<LanguageModel> moved in the initializer list. ScoreRankedLanguageModel::maxKeyLength() delegates to its internal lm_->maxKeyLength(). This is safe as long as ScoreRankedLanguageModel is fully initialized before the body runs (which it is, since it appears first in the member order). No bug, but worth a quick comment to avoid confusion:

Suggested change
explicit ReadingGrid(std::shared_ptr<LanguageModel> lm)
: lm_(std::move(lm)) {}
: lm_(std::move(lm)) {
size_t lmMax = lm_.maxKeyLength();
explicit ReadingGrid(std::shared_ptr<LanguageModel> lm)
: lm_(std::move(lm)) {
// lm_ is fully initialized here; maxKeyLength() delegates to the wrapped LM.
size_t lmMax = lm_.maxKeyLength();
maxSpanLength_ = lmMax > 0 ? lmMax : kDefaultMaxSpanLength;
}

maxSpanLength_ = lmMax > 0 ? lmMax : kDefaultMaxSpanLength;
Comment thread
tianjianjiang marked this conversation as resolved.
Comment thread
tianjianjiang marked this conversation as resolved.
}

void clear();

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

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

[[nodiscard]] size_t maxSpanLength() const { return maxSpanLength_; }

// A Node consists of a set of unigrams, a reading, and a spanning length.
// The spanning length denotes the length of the node in the grid. The grid
// is responsible for constructing its nodes. For Mandarin multi-character
Expand Down Expand Up @@ -180,6 +186,16 @@ class ReadingGrid {
std::vector<std::string> readingsAsStrings() const;
};

// Set the walk strategy. Default is ViterbiStrategy.
void setWalkStrategy(std::shared_ptr<class WalkStrategy> strategy);

// Fix a span at the given position to the given node. The walk will be
// constrained to go through this node. Last-write-wins: fixing at a position
// that overlaps an existing fix clears the overlapping fix.
void fixSpan(size_t position, NodePtr node);

void clearFixedSpans();

WalkResult walk();

struct Candidate {
Expand Down Expand Up @@ -220,7 +236,7 @@ class ReadingGrid {
[[nodiscard]] size_t maxLength() const { return maxLength_; }

protected:
std::array<NodePtr, kMaximumSpanLength> nodes_;
std::vector<NodePtr> nodes_;
size_t maxLength_ = 0;
};

Expand All @@ -233,6 +249,7 @@ class ReadingGrid {
}
std::vector<Unigram> getUnigrams(const std::string& reading) override;
bool hasUnigrams(const std::string& reading) override;
size_t maxKeyLength() const override { return lm_->maxKeyLength(); }
Comment thread
tianjianjiang marked this conversation as resolved.

protected:
std::shared_ptr<LanguageModel> lm_;
Expand All @@ -246,10 +263,13 @@ class ReadingGrid {

protected:
size_t cursor_ = 0;
size_t maxSpanLength_;
Comment thread
tianjianjiang marked this conversation as resolved.
std::string separator_ = kDefaultSeparator;
std::vector<std::string> readings_;
std::vector<Span> spans_;
ScoreRankedLanguageModel lm_;
std::shared_ptr<class WalkStrategy> walkStrategy_;
std::map<size_t, NodePtr> fixedSpans_;

// Internal methods for maintaining the grid.

Expand Down
Loading