feat(engine): add ContextualUserModel with interpolated Kneser-Ney scoring - #780
feat(engine): add ContextualUserModel with interpolated Kneser-Ney scoring#780tianjianjiang wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Critical Issues Found
Reviewed the contextual user model implementation. Found several critical issues:
Priority 1 - Correctness Bugs
- Log probability handling inconsistency (contextual_user_model.cpp:97) - Mixing probability and log-probability spaces
- Score replacement logic error (walk_strategy.cpp:63) - User model unconditionally replaces edge weights instead of blending
- Base score probability conversion (contextual_user_model.cpp:153) - Verify whether base LM returns probabilities or log-probabilities
Priority 2 - Robustness
- UTF-8 parsing safety (contextual_user_model.cpp:250) - Potential buffer overflow with malformed input
- Null pointer safety (reading_grid.cpp:194) - Missing error handling for failed overrides
- Division by zero (contextual_user_model.cpp:107) - Constructor allows
discount=0 - Thread safety (contextual_user_model.cpp:35) - No synchronization for mutable state
The most critical is #1 (log probability) and #2 (score blending) as they directly affect the correctness of the Viterbi walk results.
There was a problem hiding this comment.
Code Review
This pull request introduces a ContextualUserModel to provide probabilistic scoring based on user history, which is a significant improvement over the previous heuristic-based approach. The implementation of the Kneser-Ney backoff model and its integration into the Viterbi walk is well-structured. I've found two significant issues that need to be addressed: a critical bug that disables the temporal decay feature, and a high-severity issue in the model's persistence logic that could lead to data corruption. My review includes detailed comments and suggestions to fix these problems.
There was a problem hiding this comment.
Pull request overview
Introduces a new contextual adaptation layer in the C++ engine by adding a ContextualUserModel and threading it into the existing Gramambular2 Viterbi walk pipeline so user evidence can influence path selection and surfaced node values.
Changes:
- Added
ContextualUserModel(KN-style backoff + temporal decay) with persistence and explicit phrase support. - Extended
WalkStrategy::WalkInputand Viterbi relaxation to optionally consult the user model during scoring. - Updated
ReadingGridto accept a user model pointer and apply post-walk “soft overrides”, plus added extensive unit/integration tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Source/Engine/gramambular2/contextual_user_model.h | Declares the new contextual user model API and scoring/persistence helpers. |
| Source/Engine/gramambular2/contextual_user_model.cpp | Implements observation, KN backoff scoring, decay, and save/load. |
| Source/Engine/gramambular2/walk_strategy.h | Extends WalkInput to carry optional user model + timestamp. |
| Source/Engine/gramambular2/walk_strategy.cpp | Integrates user model suggestion scoring into Viterbi relaxation. |
| Source/Engine/gramambular2/reading_grid.h | Adds setUserModel() and tracking for user-model-applied overrides. |
| Source/Engine/gramambular2/reading_grid.cpp | Resets and applies post-walk user model overrides; threads user model into the walk input. |
| Source/Engine/gramambular2/reading_grid_test.cpp | Adds unit tests for ContextualUserModel and integrated walk behavior. |
| Source/Engine/gramambular2/CMakeLists.txt | Adds the new user model sources to the gramambular2 library target. |
cbb3870 to
dee1bd9
Compare
There was a problem hiding this comment.
Critical Issues Review
I've identified several critical issues that should be addressed:
High Priority
-
Numerical stability (contextual_user_model.cpp:99): The log conversion can produce
-inffor very small probabilities, potentially breaking Viterbi score comparisons. Add finite-value guards. -
Buffer overrun risk (contextual_user_model.cpp:268): UTF-8 decoding doesn't validate continuation bytes. Malformed UTF-8 input could cause out-of-bounds reads and crashes.
-
File I/O robustness (contextual_user_model.cpp:283):
loadFromFilesilently skips malformed lines, potentially loading corrupt/incomplete state with no error indication. Consider fail-fast or validation of loaded state consistency.
Medium Priority
-
Thread safety (reading_grid.cpp:191): Post-walk node modification lacks documentation about thread-safety guarantees. If
walk()can be called concurrently or nodes are shared, this could cause data races. -
Variable naming (walk_strategy.cpp:50): Using
distancefor maximization (not minimization) is confusing and error-prone for future maintainers.
Note
The Kneser-Ney continuation count logic (contextual_user_model.cpp:54-59) is actually correct as-is, but the implementation is subtle and could benefit from clarifying comments.
All other aspects of the implementation appear sound. The test coverage is comprehensive and the architecture integrates cleanly with the existing Viterbi walk.
833c6d4 to
96959f6
Compare
dee1bd9 to
61237bd
Compare
There was a problem hiding this comment.
Critical Issues Found
Reviewed PR #780 focusing on correctness, bugs, and security. Found 6 critical/high-priority issues:
Correctness Issues
- Continuation count logic error (contextual_user_model.cpp:53) - Breaks Kneser-Ney backoff by not counting distinct left contexts correctly
- User model score unconditionally replaces node score (walk_strategy.cpp:63) - Contradicts stated priority (fixed spans > user model > base LM) and discards original scores entirely
- Invalid UTF-8 handling (contextual_user_model.cpp:264) - Can cause incorrect character splits and wrong decomposition scores
Data Safety Issues
- Silent data corruption during load (contextual_user_model.cpp:299) - Failed parse lines are silently skipped, no validation of data integrity
- Unsafe raw pointer lifetime (reading_grid.cpp:277) - No lifetime validation for externally-set user model pointer, potential use-after-free
Performance
- Redundant O(n) reset operations (reading_grid.cpp:42) - Nodes reset twice per walk cycle unnecessarily
The continuation count bug (#1) and unconditional score replacement (#2) are the most critical as they affect core algorithm correctness.
61237bd to
c0b2e10
Compare
96959f6 to
9f9477d
Compare
There was a problem hiding this comment.
Critical Issues Summary
Reviewed PR #780 focusing on potential bugs, correctness, and safety issues. Found several critical areas requiring attention:
Memory Safety
- Dangling pointer risk in
ReadingGrid::userModel_- raw pointer with no lifetime guarantees could cause use-after-free - Unsafe pointer dereference in
Relax()- missing null checks before accessingu->nodeandv->node
Arithmetic Safety
- Division by zero possible in
bigramScore()whencTotal == 0anddiscount_ == 0 - Division by zero potential in
continuationScore()iftotalUniqueBigrams_becomes 0 - Integer overflow theoretical risk in
observe()for long-running systems (counts usesize_t)
Data Integrity
- Partial load corruption in
loadFromFile()- returns true even when individual line parsing fails, leaving inconsistent state - Partial write corruption in
saveToFile()- doesn't check write success, could silently produce truncated files - UTF-8 validation missing in
splitValue()- malformed sequences could cause incorrect parsing
Concurrency (Future Risk)
- Race condition on
totalUniqueBigrams_if class is ever used multi-threaded - no synchronization or thread-safety documentation
All issues have inline comments with suggested fixes.
9f9477d to
fe45b23
Compare
c0b2e10 to
3fac8a9
Compare
There was a problem hiding this comment.
Critical Issues Found
Reviewed focusing on bugs, correctness, and performance. Found 7 critical issues:
Bugs (must fix):
- Division by zero in
bigramScore()-contextual_user_model.cpp:128 - Incorrect continuation count reconstruction in
loadFromFile()- breaks save/load round-trip - Node override tracking bug in post-walk processing -
reading_grid.cpp:196
Correctness issues:
- Missing data validation in
loadFromFile()- allows NaN/Inf/negative values - Negative elapsed time not properly handled when timestamps go backward
- UTF-8 validation missing in
splitValue()- can produce wrong character counts
Performance concern:
- Quadratic post-walk overhead for user model suggestions on long sentences
All issues have inline comments with specific line references and suggested fixes.
There was a problem hiding this comment.
Critical Issues Found
Reviewed PR #780 focusing on correctness and potential bugs. Found 6 critical issues that need attention:
High Priority
- Viterbi score replacement bug (walk_strategy.cpp:48) - User model score completely replaces base LM weight instead of blending, breaking the algorithm's probability space
- Log-probability conversion error (contextual_user_model.cpp:99) - Applying log to already-linear probability produces incorrect scores
- Post-walk override bypasses fixed spans (reading_grid.cpp:196) - User model can override structural constraints
Medium Priority
- Division by zero risk (contextual_user_model.cpp:112) - No validation for
discount_before division - UTF-8 validation missing (contextual_user_model.cpp:252) - Malformed input causes incorrect parsing
- Incomplete error handling (contextual_user_model.cpp:290) - loadFromFile returns success on empty/invalid files
All issues have suggested fixes in inline comments.
3fac8a9 to
5938fff
Compare
fe45b23 to
052730b
Compare
There was a problem hiding this comment.
Critical Issues Summary
Found 3 critical correctness/functionality issues and 5 additional concerns:
Critical:
-
Hardcoded timestamp (reading_grid.cpp:173) - Temporal decay is completely disabled in production. All user model observations are treated as fresh, defeating the decay mechanism that tests verify works.
-
Score replacement vs. blending (walk_strategy.cpp:63) - Code replaces base LM scores entirely instead of blending. Description claims "blending" but implementation does full replacement, potentially losing structural information.
-
Duplicate reset logic (reading_grid.cpp:173-177) - Override reset happens both in
clear()andwalk(), but mutations between walks could leave stale overrides if grid isn't cleared.
High Priority:
-
Memory safety concern (reading_grid.cpp:42-45) -
userModelOverriddenNodes_could contain dangling pointers if grid modifications occur between walks. -
Performance O(n²) (contextual_user_model.cpp:67-82) - Double iteration in
suggest()creates redundant scoring calls.
Documentation/Robustness:
- File I/O lacks bounds checking (contextual_user_model.cpp:295)
- Missing documentation on continuation count invariant (contextual_user_model.cpp:112)
- Decomposition assumptions undocumented (contextual_user_model.cpp:165)
The timestamp issue is the most critical - it needs a real timestamp from the caller to make temporal decay functional in production.
052730b to
a915b96
Compare
5938fff to
0b2be43
Compare
There was a problem hiding this comment.
Critical Issues Found
I've identified several critical bugs that need to be addressed before merging:
1. Bigram counting logic error (contextual_user_model.cpp:48-58)
The observe() method incorrectly tracks unique bigrams and continuation counts. When the same (reading, value) appears in a new context, it updates the existing entry but fails to:
- Increment
totalUniqueBigrams_for the NEW bigram key - Correctly track continuation counts across contexts
2. Incorrect Kneser-Ney implementation (contextual_user_model.cpp:142)
The continuation score uses totalUniqueBigrams_ as denominator, but KN requires the sum of continuation counts. This breaks probability normalization.
3. User model score replaces instead of combines (walk_strategy.cpp:63)
The Viterbi relaxation replaces the node's score with the user model score, completely discarding the base LM. This creates discontinuities and doesn't properly interpolate between models.
4. UTF-8 decoding lacks validation (contextual_user_model.cpp:254-265)
The splitValue() function doesn't validate UTF-8 byte sequences, risking buffer overruns and silent corruption on invalid input.
5. Double user model lookup inefficiency (reading_grid.cpp:186-199)
User model is queried during Viterbi walk AND in post-walk override pass, doubling the cost for each node.
Please address these issues, particularly #1-3 which affect correctness.
There was a problem hiding this comment.
Critical Issues Found
This PR introduces a sophisticated contextual user model with Kneser-Ney backoff, but there are 8 critical issues that need addressing:
High Priority
- Score replacement vs blending (walk_strategy.cpp:63) - User model score completely replaces LM score instead of blending, breaking probabilistic composition
- Division by zero (contextual_user_model.cpp:125) - Missing check for
cTotal == 0.0before division - Incorrect totalUniqueBigrams_ after load (contextual_user_model.cpp:290) - Counts all bigram entries instead of unique (reading, value) pairs, breaking continuation scores
Medium Priority
- UTF-8 validation (contextual_user_model.cpp:252) - splitValue doesn't validate multibyte sequences, potential out-of-bounds read
- Dangling pointer risk (reading_grid.cpp:201) - Raw pointer to ContextualUserModel without lifetime guarantees
- Redundant post-walk overrides (reading_grid.cpp:186) - Double application of user preferences may cause inconsistency
Low Priority
- Integer overflow (contextual_user_model.cpp:51) - totalUniqueBigrams_ unbounded
- Linear search performance (contextual_user_model.cpp:151) - O(n) lookup per baseScore call
The most critical issue is #1 (score replacement), as it fundamentally changes the intended probabilistic model behavior.
655cc41 to
2e00a15
Compare
f48ea8e to
47e4a34
Compare
There was a problem hiding this comment.
Critical Issues Found
Found 7 critical issues requiring attention:
Correctness & Logic Errors:
- walk_strategy.cpp:48-64 - User model score unconditionally replaces base LM score, discarding probabilistic information
- contextual_user_model.cpp:99 - Log of zero produces -infinity instead of floor value
- reading_grid.cpp:186-201 - Stale override nodes not properly reset between walks
Data Integrity:
4. contextual_user_model.cpp:283 - loadFromFile() corrupts model state on partial load failure
5. contextual_user_model.cpp:267 - saveToFile() doesn't verify write operations succeed
Robustness:
6. contextual_user_model.cpp:34 - Temporal decay calculation vulnerable to extreme elapsed times and negative values
7. contextual_user_model.cpp:38 - observe() has potential race conditions if called from multiple threads
All issues have suggested fixes in inline comments. The most critical are #1 (affects scoring correctness) and #4 (data corruption risk).
47e4a34 to
5b3b72c
Compare
2e00a15 to
4056205
Compare
|
Claude Code Review Failed The automated Claude review encountered an error and could not complete. You can:
This does not affect the PR approval process. |
5b3b72c to
69ba06a
Compare
4056205 to
452a494
Compare
|
Claude Code Review Failed The automated Claude review encountered an error and could not complete. You can:
This does not affect the PR approval process. |
452a494 to
b1f6f75
Compare
69ba06a to
71a31cb
Compare
|
Claude Code Review Failed The automated Claude review encountered an error and could not complete. You can:
This does not affect the PR approval process. |
71a31cb to
85aee6c
Compare
|
Claude Code Review Failed The automated Claude review encountered an error and could not complete. You can:
This does not affect the PR approval process. |
85aee6c to
927c83c
Compare
|
Claude Code Review Failed The automated Claude review encountered an error and could not complete. You can:
This does not affect the PR approval process. |
927c83c to
0c0dab2
Compare
…oring A persistent, context-sensitive user adaptation model designed as a drop-in successor to UserOverrideModel: observe() and suggest() take the same walk-based arguments, including the three override cases (same- length override, phrase building with force-high-score, phrase breaking based on the post-override walk) and the punctuation/sentence-start context handling. Differences from UserOverrideModel: - Scoring uses absolute-discounting interpolated Kneser-Ney over bigram contexts. A candidate confirmed in enough distinct contexts generalizes to contexts it has never been seen in, which exact-key matching cannot do. - The model persists to a file (atomic save via rename; load validates every field, skips malformed lines with a count, and rejects NaN/Inf/negative values), so adaptation survives restarts. - No reference to the base language model: with insufficient evidence, suggest() returns empty and the walk falls back to base scores naturally, making the lower Kneser-Ney backoff levels implicit. The model is self-contained, with no shared-pointer lifetime concerns. Evidence decays exponentially by wall-clock half-life and the context store is LRU-bounded, both mirroring UserOverrideModel defaults. This is the engine-side half; KeyHandler integration follows in a separate PR. The grid is untouched: suggestions apply through the existing overrideCandidate/selectOverrideUnigram machinery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0c0dab2 to
ec136e0
Compare
There was a problem hiding this comment.
Two correctness bugs in the new ContextualUserModel:
-
continuationFor()ignores temporal decay — the rawstats.count > 0guard means any observation, no matter how old, permanently contributes to cross-context generalization. Fix: passtimestampintocontinuationFor()and usedecayedCount(stats, timestamp) > 0. -
serialize()truncates timestamps to 6 significant digits — the defaultostringstreamprecision silently rounds a 9-digit Unix timestamp down by up to ~1000 seconds, corrupting the decay calculation on every restart. Fix: setstd::setprecision(std::numeric_limits<double>::max_digits10)before writing numeric fields, and add<iomanip>/<limits>includes.
Both issues have inline suggestions attached.
| continue; | ||
| } | ||
| for (const auto& [candidate, stats] : entry) { | ||
| if (stats.count > 0) { |
There was a problem hiding this comment.
Bug: continuationFor() ignores temporal decay — stale observations permanently inflate generalization
stats.count is the stored value, not the decayed value. A candidate observed once long ago (raw count = 1.0) will still pass this check even after its effective probability has decayed to near zero, so it permanently counts as a valid distinct context for generalization. This means candidates that would otherwise be excluded by the kMinSuggestionProbability threshold at the exact-context level continue to trigger cross-context generalization indefinitely.
continuationFor() needs to receive the current timestamp and use decayedCount(stats, timestamp):
| if (stats.count > 0) { | |
| if (decayedCount(stats, timestamp) > 0) { |
The method signature (declaration + definition) also needs the timestamp parameter:
// in header:
[[nodiscard]] Continuation continuationFor(const std::string& reading, double timestamp) const;
// call sites in suggest():
Continuation continuation = continuationFor(reading, timestamp);| const std::string& context = parts[0]; | ||
| const std::string& reading = parts[1]; | ||
| for (const auto& [candidate, stats] : it->second) { | ||
| out << context << "\t" << reading << "\t" << candidate << "\t" |
There was a problem hiding this comment.
Bug: default ostringstream precision silently corrupts timestamps on save/load
std::ostringstream uses 6 significant digits by default (equivalent to %g). A Unix timestamp like 1657772432 (9 digits) is written as 1.65777e+09 = 1657770000, losing ~2432 seconds. After a round-trip through saveToFile/loadFromFile, every stored timestamp is off by up to hundreds of seconds. Since decay is computed as count * exp2(-elapsed / halfLife), a 2000-second error with a 5400-second half-life changes the decayed weight by ~exp2(-2000/5400) ≈ 0.77×, enough to alter suggestions immediately after startup.
Fix by setting maximum precision before the numeric fields:
| out << context << "\t" << reading << "\t" << candidate << "\t" | |
| out << std::setprecision(std::numeric_limits<double>::max_digits10) | |
| << context << "\t" << reading << "\t" << candidate << "\t" | |
| << stats.count << "\t" << stats.timestamp << "\t" |
Also add #include <iomanip> and #include <limits> to the include block at the top of the file.
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>
…ng model rationale Specify the shipped architecture as requirements: dynamic span length derived from LanguageModel::maxKeyLength() (PR #844), the ContextualUserModel with absolute-discounting interpolated Kneser-Ney over bigram contexts (PR #780), and the KeyHandler swap from UserOverrideModel at the existing observe/suggest call sites (PR #781). Key specifications: - Continuation probability normalized per reading: P_cont(w|r) = N1+(.,r,w) / sum over w' of N1+(.,r,w'), with generalization to unseen contexts gated on >= 2 distinct contexts and never force-boosted; minimum suggestion probability 0.25. - Wall-clock exponential decay count * 2^(-dt/halfLife) with the half-life unit in seconds (default 5400 s = 90 min); LRU capacity bound (default 500 contexts). - Two-level scoring with implicit base-LM fallback: insufficient evidence yields an empty suggestion and the walk falls back to base scores naturally; the model holds no base-LM pointer, eliminating the aliasing-shared_ptr lifetime hazard by construction. - TSV v1 persistence (atomic temp+rename save, validated load, serialize() snapshot for off-thread writes) confined to contextual-user-model.txt; user phrase files are never touched and UserOverrideModel had no persisted data, so no migration is needed. - The walk algorithm is untouched: the WalkStrategy/fixedSpans abstraction and the speculative algorithm variants from PR #779 are dropped per maintainer feedback; legacy UserOverrideModel removal is a follow-up after #781 bakes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Reworked from the original proposal, following the review direction on #779 ("work with what's in HEAD"): the model no longer hooks into the walk. It is a self-contained, persistent, context-sensitive successor to
UserOverrideModel, applied through the existingoverrideCandidate/selectOverrideUnigrammachinery.ReadingGridis untouched by this PR.observe()/suggest()are walk-based and signature-compatible withUserOverrideModel, including the three override cases (same-length, phrase-building with force-high-score, phrase-breaking based on the post-override walk) and punctuation/sentence-start context handlingsuggest()returns empty and the walk falls back to base scores naturally (the lower KN backoff levels are implicit) — no aliasing-shared_ptr lifetime concerns by constructionReview items from the previous iteration, and where they went
userModel_raw pointer in gridLoadStatsVerification
ContextualUserModelTest.cpp(KN behavior, generalization threshold, decay, eviction, persistence round-trip, malformed-load, walk-based observe/suggest)McBopomofoLMLibTestsuite passes; pbxproj lints OKmaster(no dependency on other PRs)KeyHandler integration (replacing
UserOverrideModeland adding load/save wiring) follows in #781.🤖 Generated with Claude Code