Skip to content

Commit 69ba06a

Browse files
tianjianjiangclaude
andcommitted
feat(engine): add contextual user model with KN backoff scoring
- Four-level KN backoff: bigram → continuation → base LM → decomposed - observe/suggest API with temporal decay (half-life=20) - Walk integration: user model scores blended into Viterbi relaxation - Post-walk soft overrides for non-fixed nodes - Save/load to text file format - Add contextual_user_model to Xcode project; add missing <limits> include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 452a494 commit 69ba06a

9 files changed

Lines changed: 1236 additions & 6 deletions

File tree

McBopomofo.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
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 */; };
2020
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */; };
21+
A71A7066B7F904D314C65992 /* contextual_user_model.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4C960E407C381A6E5F4100F2 /* contextual_user_model.cpp */; };
2122
6A660A702EAF371000D53D7B /* ByteBlockBackedDictionary.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */; };
2223
6A68C1C32EC7F2C0005284A0 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 6A68C1C12EC7F2C0005284A0 /* Localizable.stringsdict */; };
2324
6A6ED16B2797650A0012872E /* template-phrases-replacement.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6A6ED1632797650A0012872E /* template-phrases-replacement.txt */; };
@@ -143,6 +144,8 @@
143144
6A4F5F932879E838008C4307 /* reading_grid.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = reading_grid.cpp; sourceTree = "<group>"; };
144145
1B900C269506F40A48314006 /* walk_strategy.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = walk_strategy.h; sourceTree = "<group>"; };
145146
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = walk_strategy.cpp; sourceTree = "<group>"; };
147+
B590C82EAD5C575FFC76F863 /* contextual_user_model.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = contextual_user_model.h; sourceTree = "<group>"; };
148+
4C960E407C381A6E5F4100F2 /* contextual_user_model.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = contextual_user_model.cpp; sourceTree = "<group>"; };
146149
6A660A6E2EAF371000D53D7B /* ByteBlockBackedDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ByteBlockBackedDictionary.h; sourceTree = "<group>"; };
147150
6A660A6F2EAF371000D53D7B /* ByteBlockBackedDictionary.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ByteBlockBackedDictionary.cpp; sourceTree = "<group>"; };
148151
6A68C1C22EC7F2C0005284A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -435,6 +438,8 @@
435438
6A4F5F932879E838008C4307 /* reading_grid.cpp */,
436439
1B900C269506F40A48314006 /* walk_strategy.h */,
437440
9CBDA16D485EE2E7F11D171B /* walk_strategy.cpp */,
441+
B590C82EAD5C575FFC76F863 /* contextual_user_model.h */,
442+
4C960E407C381A6E5F4100F2 /* contextual_user_model.cpp */,
438443
);
439444
path = gramambular2;
440445
sourceTree = "<group>";
@@ -751,6 +756,7 @@
751756
D4E569DC27A34D0E00AC2CEF /* KeyHandler.mm in Sources */,
752757
6A4F5F982879E838008C4307 /* reading_grid.cpp in Sources */,
753758
9BFBCAEED2ED8369BC7D6ED5 /* walk_strategy.cpp in Sources */,
759+
A71A7066B7F904D314C65992 /* contextual_user_model.cpp in Sources */,
754760
D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */,
755761
D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */,
756762
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 walk_strategy.h walk_strategy.cpp)
7+
add_library(gramambular2_lib language_model.h reading_grid.h reading_grid.cpp walk_strategy.h walk_strategy.cpp contextual_user_model.h contextual_user_model.cpp)
88

99
if (ENABLE_CLANG_TIDY)
1010
set_target_properties(gramambular2_lib PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_COMMAND}")
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// Copyright (c) 2022 and onwards Lukhnos Liu.
2+
//
3+
// Permission is hereby granted, free of charge, to any person
4+
// obtaining a copy of this software and associated documentation
5+
// files (the "Software"), to deal in the Software without
6+
// restriction, including without limitation the rights to use,
7+
// copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
// copies of the Software, and to permit persons to whom the
9+
// Software is furnished to do so, subject to the following
10+
// conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be
13+
// included in all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22+
// OTHER DEALINGS IN THE SOFTWARE.
23+
24+
#include "contextual_user_model.h"
25+
26+
#include <algorithm>
27+
#include <cmath>
28+
#include <fstream>
29+
#include <limits>
30+
#include <sstream>
31+
32+
namespace Formosa::Gramambular2 {
33+
34+
double ContextualUserModel::decayFactor(double elapsed) const {
35+
if (elapsed <= 0.0) return 1.0;
36+
return std::exp(-std::log(2.0) * elapsed / decayHalfLife_);
37+
}
38+
39+
void ContextualUserModel::observe(const std::string& leftReading,
40+
const std::string& leftValue,
41+
const std::string& currentReading,
42+
const std::string& currentValue,
43+
double timestamp) {
44+
std::string leftKey = leftReading + ":" + leftValue;
45+
BigramKey bkey{leftKey, currentReading};
46+
47+
auto& candidates = bigrams_[bkey];
48+
auto it = candidates.find(currentValue);
49+
if (it == candidates.end()) {
50+
candidates[currentValue] = {1.0, timestamp};
51+
totalUniqueBigrams_++;
52+
53+
continuationCounts_[currentReading][currentValue]++;
54+
} else {
55+
double elapsed = timestamp - it->second.lastTimestamp;
56+
it->second.decayedCount =
57+
it->second.decayedCount * decayFactor(elapsed) + 1.0;
58+
it->second.lastTimestamp = timestamp;
59+
}
60+
}
61+
62+
std::optional<ContextualUserModel::Suggestion> ContextualUserModel::suggest(
63+
const std::string& leftReading, const std::string& leftValue,
64+
const std::string& currentReading, double timestamp) const {
65+
std::string leftKey = leftReading + ":" + leftValue;
66+
67+
std::map<std::string, double> scores;
68+
69+
BigramKey bkey{leftKey, currentReading};
70+
auto bIt = bigrams_.find(bkey);
71+
if (bIt != bigrams_.end()) {
72+
for (const auto& [val, obs] : bIt->second) {
73+
scores[val] = bigramScore(leftKey, currentReading, val, timestamp);
74+
}
75+
}
76+
77+
auto cIt = continuationCounts_.find(currentReading);
78+
if (cIt != continuationCounts_.end()) {
79+
for (const auto& [val, cnt] : cIt->second) {
80+
if (scores.find(val) == scores.end()) {
81+
scores[val] = bigramScore(leftKey, currentReading, val, timestamp);
82+
}
83+
}
84+
}
85+
86+
if (scores.empty()) {
87+
return std::nullopt;
88+
}
89+
90+
std::string bestValue;
91+
double bestScore = -std::numeric_limits<double>::infinity();
92+
for (const auto& [val, score] : scores) {
93+
if (score > bestScore) {
94+
bestScore = score;
95+
bestValue = val;
96+
}
97+
}
98+
99+
double logScore = bestScore > 0 ? std::log(bestScore) : std::log(kFloorProbability);
100+
return Suggestion{bestValue, logScore};
101+
}
102+
103+
void ContextualUserModel::addExplicitPhrase(const std::string& reading,
104+
const std::string& value) {
105+
double initialCount = 1.0 / discount_;
106+
std::string leftKey = std::string(kStartSentinel) + ":";
107+
BigramKey bkey{leftKey, reading};
108+
auto& candidates = bigrams_[bkey];
109+
if (candidates.find(value) == candidates.end()) {
110+
candidates[value] = {initialCount, 0.0};
111+
totalUniqueBigrams_++;
112+
continuationCounts_[reading][value]++;
113+
} else {
114+
candidates[value].decayedCount = initialCount;
115+
}
116+
}
117+
118+
double ContextualUserModel::bigramScore(const std::string& leftKey,
119+
const std::string& reading,
120+
const std::string& value,
121+
double timestamp) const {
122+
BigramKey bkey{leftKey, reading};
123+
double c = getDecayedCount(bkey, value, timestamp);
124+
double cTotal = getDecayedContextTotal(bkey, timestamp);
125+
126+
if (cTotal < discount_) {
127+
return continuationScore(reading, value);
128+
}
129+
130+
double discounted = std::max(c - discount_, 0.0) / cTotal;
131+
double lambda =
132+
discount_ * static_cast<double>(getTypeCount(bkey)) / cTotal;
133+
return discounted + lambda * continuationScore(reading, value);
134+
}
135+
136+
double ContextualUserModel::continuationScore(const std::string& reading,
137+
const std::string& value) const {
138+
double nPlus =
139+
static_cast<double>(getContinuationCount(reading, value));
140+
if (totalUniqueBigrams_ == 0) {
141+
return baseScore(reading, value);
142+
}
143+
144+
double total = static_cast<double>(totalUniqueBigrams_);
145+
double discounted = std::max(nPlus - discount_, 0.0) / total;
146+
double lambda =
147+
discount_ * static_cast<double>(uniqueWordsForReading(reading)) / total;
148+
return discounted + lambda * baseScore(reading, value);
149+
}
150+
151+
double ContextualUserModel::baseScore(const std::string& reading,
152+
const std::string& value) const {
153+
auto unigrams = baseLM_->getUnigrams(reading);
154+
for (const auto& u : unigrams) {
155+
if (u.value() == value) {
156+
return std::exp(u.score());
157+
}
158+
}
159+
return decomposedScore(reading, value);
160+
}
161+
162+
double ContextualUserModel::decomposedScore(const std::string& reading,
163+
const std::string& value) const {
164+
auto syllables = splitReading(reading);
165+
auto characters = splitValue(value);
166+
if (syllables.size() != characters.size() || syllables.empty()) {
167+
return kFloorProbability;
168+
}
169+
170+
double product = 1.0;
171+
for (size_t i = 0; i < syllables.size(); i++) {
172+
auto unis = baseLM_->getUnigrams(syllables[i]);
173+
double charProb = kFloorProbability;
174+
for (const auto& u : unis) {
175+
if (u.value() == characters[i]) {
176+
charProb = std::exp(u.score());
177+
break;
178+
}
179+
}
180+
product *= charProb;
181+
}
182+
return product;
183+
}
184+
185+
double ContextualUserModel::getDecayedCount(const BigramKey& key,
186+
const std::string& value,
187+
double timestamp) const {
188+
auto bIt = bigrams_.find(key);
189+
if (bIt == bigrams_.end()) return 0.0;
190+
auto cIt = bIt->second.find(value);
191+
if (cIt == bIt->second.end()) return 0.0;
192+
double elapsed = timestamp - cIt->second.lastTimestamp;
193+
return cIt->second.decayedCount * decayFactor(elapsed);
194+
}
195+
196+
double ContextualUserModel::getDecayedContextTotal(const BigramKey& key,
197+
double timestamp) const {
198+
auto bIt = bigrams_.find(key);
199+
if (bIt == bigrams_.end()) return 0.0;
200+
double total = 0.0;
201+
for (const auto& [val, obs] : bIt->second) {
202+
double elapsed = timestamp - obs.lastTimestamp;
203+
total += obs.decayedCount * decayFactor(elapsed);
204+
}
205+
return total;
206+
}
207+
208+
size_t ContextualUserModel::getTypeCount(const BigramKey& key) const {
209+
auto bIt = bigrams_.find(key);
210+
if (bIt == bigrams_.end()) return 0;
211+
return bIt->second.size();
212+
}
213+
214+
size_t ContextualUserModel::getContinuationCount(
215+
const std::string& reading, const std::string& value) const {
216+
auto rIt = continuationCounts_.find(reading);
217+
if (rIt == continuationCounts_.end()) return 0;
218+
auto vIt = rIt->second.find(value);
219+
if (vIt == rIt->second.end()) return 0;
220+
return vIt->second;
221+
}
222+
223+
size_t ContextualUserModel::uniqueWordsForReading(
224+
const std::string& reading) const {
225+
auto rIt = continuationCounts_.find(reading);
226+
if (rIt == continuationCounts_.end()) return 0;
227+
return rIt->second.size();
228+
}
229+
230+
std::vector<std::string> ContextualUserModel::splitReading(
231+
const std::string& reading) {
232+
std::vector<std::string> result;
233+
std::string current;
234+
for (size_t i = 0; i < reading.size(); ++i) {
235+
if (reading[i] == '-') {
236+
if (!current.empty()) {
237+
result.push_back(current);
238+
current.clear();
239+
}
240+
} else {
241+
current += reading[i];
242+
}
243+
}
244+
if (!current.empty()) {
245+
result.push_back(current);
246+
}
247+
return result;
248+
}
249+
250+
std::vector<std::string> ContextualUserModel::splitValue(
251+
const std::string& value) {
252+
std::vector<std::string> result;
253+
size_t i = 0;
254+
while (i < value.size()) {
255+
unsigned char c = static_cast<unsigned char>(value[i]);
256+
size_t charLen = 1;
257+
if (c >= 0xF0) {
258+
charLen = 4;
259+
} else if (c >= 0xE0) {
260+
charLen = 3;
261+
} else if (c >= 0xC0) {
262+
charLen = 2;
263+
}
264+
if (i + charLen <= value.size()) {
265+
result.push_back(value.substr(i, charLen));
266+
}
267+
i += charLen;
268+
}
269+
return result;
270+
}
271+
272+
bool ContextualUserModel::saveToFile(const std::string& path) const {
273+
std::ofstream out(path);
274+
if (!out.is_open()) return false;
275+
276+
for (const auto& [bkey, candidates] : bigrams_) {
277+
for (const auto& [value, obs] : candidates) {
278+
out << bkey.first << "\t" << bkey.second << "\t" << value << "\t"
279+
<< obs.decayedCount << "\t" << obs.lastTimestamp << "\n";
280+
}
281+
}
282+
return true;
283+
}
284+
285+
bool ContextualUserModel::loadFromFile(const std::string& path) {
286+
std::ifstream in(path);
287+
if (!in.is_open()) return false;
288+
289+
bigrams_.clear();
290+
continuationCounts_.clear();
291+
totalUniqueBigrams_ = 0;
292+
293+
std::string line;
294+
while (std::getline(in, line)) {
295+
if (line.empty() || line[0] == '#') continue;
296+
std::istringstream iss(line);
297+
std::string leftKey, reading, value;
298+
double count, timestamp;
299+
if (!(iss >> leftKey >> reading >> value >> count >> timestamp)) continue;
300+
301+
BigramKey bkey{leftKey, reading};
302+
bigrams_[bkey][value] = {count, timestamp};
303+
totalUniqueBigrams_++;
304+
305+
continuationCounts_[reading][value]++;
306+
}
307+
return true;
308+
}
309+
310+
} // namespace Formosa::Gramambular2

0 commit comments

Comments
 (0)