Skip to content

feat: replace UserOverrideModel with ContextualUserModel in KeyHandler - #781

Draft
tianjianjiang wants to merge 1 commit into
feat/contextual_user_modelfrom
feat/keyhandler_user_model
Draft

feat: replace UserOverrideModel with ContextualUserModel in KeyHandler#781
tianjianjiang wants to merge 1 commit into
feat/contextual_user_modelfrom
feat/keyhandler_user_model

Conversation

@tianjianjiang

@tianjianjiang tianjianjiang commented Feb 8, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #780. This PR makes the replacement of UserOverrideModel explicit: ContextualUserModel becomes the user adaptation mechanism. Because the two models expose the same walk-based observe/suggest interface, the diff is small and touches only the existing call sites — the suggestion at insert time still applies through overrideCandidate() + re-walk, and the observation in fixNodeWithReading still uses the walk captured before the override (so the recorded context is what the user actually saw — no left-context timing issue).

Behavior changes:

  • Learning persists across restarts — loaded once at startup (AppDelegate), saved after each observed selection
  • Suggestions generalize to unseen contexts once a candidate is confirmed in ≥2 distinct contexts
  • Plain Bopomofo no longer feeds the model — persistence makes single-character selections there pollution

Review items from the previous iteration

Item Resolution
Aliasing shared_ptr wrapping a stack global (UB / use-after-free) Gone — the model holds no base-LM reference; plain static object like gUserOverrideModel was
Blocking file save on the main thread per selection Saves snapshot via serialize() on the input thread (µs), then write atomically on a serial background queue
Left context captured after fixSpan()/walk Not applicable — observation uses prevWalk captured before the re-walk, same as UOM semantics
Missing file I/O error handling Load logs unreadable files and reports skipped malformed-line counts; save logs write failures
Iterator underflow / unchecked optional Not applicable — that code path (fixSpan flow) no longer exists; existing findNodeAt guards retained
Unrelated upstream changes in the diff Rebuilt from a clean base; diff is 5 files, +69/−13
Dual-model non-determinism Only ContextualUserModel is wired; UserOverrideModel sources stay in tree but nothing calls them (removal deferred until baked)

Data safety

The model reads/writes only contextual-user-model.txt in the user data folder. User phrase files are untouched. UserOverrideModel was in-memory only, so there is no legacy learned data to migrate.

Verification

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings February 8, 2026 16:09

@claude claude 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.

Critical Issues Found

I've identified several critical issues that should be addressed before merging:

Memory Safety

  1. Dangling pointer risk (LanguageModelManager.mm:40) - Aliasing shared_ptr wrapping stack object creates unsafe lifetime semantics
  2. Iterator underflow (KeyHandler.mm:211) - Undefined behavior when accessing nodeIter - 1 at container begin
  3. Unchecked optional (KeyHandler.mm:192) - nodeOpt may be empty but is used without validation

Performance & Reliability

  1. Blocking I/O on main thread (KeyHandler.mm:219) - Synchronous file save on every selection will cause UI lag
  2. Missing error handling (LanguageModelManager.mm:83) - File I/O failures are silently ignored

These issues affect correctness, crash safety, and user experience. The memory safety issues in particular need immediate attention.

Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated

@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 pull request effectively integrates the new ContextualUserModel into the KeyHandler, simplifying the adaptation logic by replacing the two-walk override mechanism with a more efficient single-walk approach. The changes are well-structured and align with the goals outlined in the description.

My review focuses on two main areas of concern, which are also noted as future work in the PR description but are important enough to highlight as high-severity issues:

  1. Synchronous I/O: The user model is saved synchronously on every selection, which can block the main thread and impact UI responsiveness.
  2. Thread Safety: The global language models are accessed without synchronization, creating a risk of race conditions and data corruption in a multi-threaded environment.

Addressing these will be critical for ensuring the stability and performance of the input method.

Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated

Copilot AI 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.

Pull request overview

Integrates the C++ ContextualUserModel into the macOS input method plumbing (via LanguageModelManager and KeyHandler) so user selections can be observed, persisted, and influence subsequent Viterbi walks without the prior post-walk override flow.

Changes:

  • Add a global ContextualUserModel instance in LanguageModelManager, load it on startup, and provide save/load accessors.
  • Wire the user model into KeyHandler’s ReadingGrid via setUserModel(), and record selections via observe() + immediate persistence.
  • Update engine tests around fixed-span clearing behavior; project file updates to include new engine sources and adjust build settings.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
Source/LanguageModelManager.mm Creates/loads/saves a global ContextualUserModel and exposes it to ObjC++ callers.
Source/LanguageModelManager+Privates.h Declares private accessors for the contextual user model and its persistence path.
Source/KeyHandler.mm Sets the contextual model on the grid and records user selections; also includes unrelated state-machine refactors.
Source/Engine/gramambular2/reading_grid_test.cpp Adds a regression test ensuring ReadingGrid::clear() resets fixed spans.
McBopomofo.xcodeproj/project.pbxproj Adds new engine sources to the Xcode build and modifies resources/deployment targets.
Comments suppressed due to low confidence (1)

Source/KeyHandler.mm:499

  • The “tone-marker-only changes prior reading” logic gated by Preferences.allowChangingPriorTone (Issue 753) was removed from this method. The preference still exists, but there is no remaining code path that applies it, so enabling the setting will no longer have any effect and the feature regresses.
    BOOL composeReading = isValidKey && _bpmfReadingBuffer->hasToneMarker() && !_bpmfReadingBuffer->hasToneMarkerOnly();

    // see if we have composition if Enter/Space is hit and buffer is not empty
    // this is bit-OR'ed so that the tone marker key is also taken into account
    composeReading |= (!_bpmfReadingBuffer->isEmpty() && (charCode == 32 || charCode == 13));

Comment thread McBopomofo.xcodeproj/project.pbxproj
Comment thread Source/Engine/gramambular2/reading_grid_test.cpp Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread McBopomofo.xcodeproj/project.pbxproj
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from cbb3870 to dee1bd9 Compare February 12, 2026 07:34
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 8d4236f to 7a40097 Compare February 12, 2026 07:39

@claude claude 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.

Critical Issues

Found 5 critical/high-priority issues that should be addressed:

  1. Thread safety - Global gContextualUserModel accessed without synchronization (data race risk)
  2. Performance - Synchronous file I/O on every candidate selection (UI jank on slow storage)
  3. Memory safety - Aliasing shared_ptr to stack-allocated global creates fragile lifetime dependency
  4. Correctness - Optional dereferencing in fixNodeWithReading needs null check
  5. Robustness - Silent failure on file I/O errors loses user data

See inline comments for details and suggested fixes.

Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm

@claude claude 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.

Critical Issues

Three critical issues that should be addressed before merge:

  1. Stack object lifetime with shared_ptr alias (LanguageModelManager.mm:37-40) - Potential use-after-free if destruction order is wrong
  2. Synchronous I/O on main thread (KeyHandler.mm:219) - Blocks UI on every candidate selection, causing user-visible lag
  3. Thread safety violation (KeyHandler.mm:219) - If save becomes async, concurrent access to gContextualUserModel from multiple threads/contexts will corrupt state

The synchronous I/O issue is marked as "future work" in the PR description but represents a UX regression that users will experience immediately. At minimum, the save should be dispatched to a background queue with proper synchronization.

Additional concerns about null pointer handling and failed override observation logic warrant verification but may be correct as-is depending on API contracts.

Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm
Comment thread Source/KeyHandler.mm Outdated

@claude claude 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.

Critical Issues

1. Left context extraction timing (Source/KeyHandler.mm:210)
The left context is extracted from _latestWalk after fixSpan() and _walk(), but it should capture the state before the user's selection. This may pass incorrect context to observe() if the structural fix changes the previous node.

2. Synchronous I/O on main thread (Source/LanguageModelManager.mm:455)
saveContextualUserModel performs synchronous disk I/O on the main thread during every candidate selection, causing input lag on slow disks. Additionally, there's no thread safety for gContextualUserModel, blocking future async save work.

3. Missing file I/O error handling (Source/KeyHandler.mm:219)
saveContextualUserModel can silently fail (disk full, permissions), causing unnoticed user data loss. Needs at minimum error logging, ideally retry or user notification.

Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from c0b2e10 to 3fac8a9 Compare February 14, 2026 18:35
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 5f9397e to 28b5858 Compare February 14, 2026 18:35

@claude claude 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.

Critical Issues

Identified 5 critical issues requiring attention before merge:

  1. Global initialization order UB (LanguageModelManager.mm:37-40): gContextualUserModel initialization with aliasing shared_ptr has undefined behavior due to C++ global initialization order rules

  2. Synchronous I/O on main thread (LanguageModelManager.mm:455): File save after every selection will cause UI stuttering

  3. Potential stale pointer issue (KeyHandler.mm:195): fixSpan may receive invalidated node pointer after modifications

  4. Silent learning failure (KeyHandler.mm:186): Early return on override failure bypasses context recording

  5. Thread safety (KeyHandler.mm:217): Unprotected concurrent access to global model from multiple call sites

The global initialization order issue is a correctness/safety bug that could cause crashes. The synchronous I/O issue will impact user experience noticeably during typing.

Comment thread Source/LanguageModelManager.mm
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm
Comment thread Source/KeyHandler.mm Outdated
@github-actions

Copy link
Copy Markdown

Claude Code Review Failed

The automated Claude review encountered an error and could not complete. You can:

  • Check the workflow logs for details
  • Trigger a manual review by commenting @claude on this PR
  • The review will be retried automatically on the next push

This does not affect the PR approval process.

@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 3fac8a9 to 5938fff Compare February 14, 2026 19:08
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 28b5858 to 33a9ba5 Compare February 14, 2026 19:08
@github-actions

Copy link
Copy Markdown

Claude Code Review Failed

The automated Claude review encountered an error and could not complete. You can:

  • Check the workflow logs for details
  • Trigger a manual review by commenting @claude on this PR
  • The review will be retried automatically on the next push

This does not affect the PR approval process.

@claude claude 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.

Critical Issues Summary

High Priority:

  1. Synchronous I/O on main thread (LanguageModelManager.mm:453) - Will cause UI freezes on every candidate selection
  2. Aliasing shared_ptr lifetime issue (LanguageModelManager.mm:38) - Potential use-after-free during static destruction
  3. OverrideType mismatch (KeyHandler.mm:184) - May prevent fixSpan from finding the overridden node

Medium Priority:
4. Silent failure in fixSpan (KeyHandler.mm:199) - No error handling if node lookup fails
5. Confusing control flow (KeyHandler.mm:209) - Redundant nullptr check with unclear intent

The synchronous I/O issue is the most user-visible problem and should be addressed before merge.

Comment thread Source/KeyHandler.mm
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 33a9ba5 to eb31ef9 Compare February 14, 2026 21:05
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 5938fff to 0b2be43 Compare February 14, 2026 21:05

@claude claude 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.

Critical Issues

  1. Synchronous I/O blocking main thread (LanguageModelManager.mm:455) - saveContextualUserModel performs file I/O on the main thread during every candidate selection, risking UI freezes

  2. Thread safety (LanguageModelManager.mm:40) - Global gContextualUserModel lacks synchronization for concurrent observe()/saveToFile() calls

  3. Value matching bug (KeyHandler.mm:189) - findInSpan predicate should check both reading AND value to avoid fixing the wrong node when multiple overridden nodes share the same reading

  4. Aliasing shared_ptr fragility (KeyHandler.mm:65) - The gEmptySharedPtr pattern creates a dangling pointer risk if refactored

Recommendations

  • Add value check to the findInSpan predicate (see inline suggestion)
  • Consider deferring or batching file saves to avoid blocking input
  • Add synchronization for the global contextual user model
  • Document the lifetime dependency between gLmPtr and gLanguageModelMcBopomofo

Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
@github-actions

Copy link
Copy Markdown

Claude Code Review Failed

The automated Claude review encountered an error and could not complete. You can:

  • Check the workflow logs for details
  • Trigger a manual review by commenting @claude on this PR
  • The review will be retried automatically on the next push

This does not affect the PR approval process.

@tianjianjiang
tianjianjiang marked this pull request as draft February 24, 2026 04:55
@tianjianjiang tianjianjiang self-assigned this Feb 24, 2026
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 47e4a34 to 5b3b72c Compare February 26, 2026 17:18
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 6953ce3 to 51bedfd Compare February 26, 2026 17:18

@claude claude 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.

Three critical issues found in this PR:

Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 5b3b72c to 69ba06a Compare February 26, 2026 18:02
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 51bedfd to d244ab7 Compare February 26, 2026 18:02

@claude claude 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.

Three issues found, ordered by severity:

  1. observe() called when fixSpan was skipped (KeyHandler.mm ~line 217) — correctness bug introduced by this PR. If findInSpan doesn't find the overridden node, fixSpan is silently omitted but observe() still fires, recording a preference the walk isn't constrained to honor.

  2. Aliasing shared_ptr with null owner (LanguageModelManager.mm ~line 39) — gLmPtr has use_count() == 0 and any weak_ptr derived from it would be immediately expired. Safe only if ContextualUserModel never constructs weak_ptrs or inspects use_count().

  3. Missing setCursor on cend early return (KeyHandler.mm ~line 202) — pre-existing gap made more visible now that the nullptr branch below it explicitly resets the cursor. Minor but worth fixing for consistency.

Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/LanguageModelManager.mm Outdated
Comment thread Source/KeyHandler.mm
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 69ba06a to 71a31cb Compare February 26, 2026 18:42
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from d244ab7 to 3e7bf8e Compare February 26, 2026 18:44

@claude claude 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.

Four issues found, one of which is a correctness bug:

  1. Logic error (KeyHandler.mm): _contextualUserModel->observe() is called for all input modes including plain Bopomofo, but the user model is only installed on the grid for InputModeBopomofo. This causes plain Bopomofo selections to train predictions that influence only Bopomofo mode — a regression from the old code which explicitly guarded if (_inputMode != InputModePlainBopomofo).

  2. Sentinel inconsistency (KeyHandler.mm): For sentence-initial position, leftReading is set to kStartSentinel but leftValue is left as "". Both should use the same sentinel to ensure consistent bigram key construction in observe() and suggest().

  3. Null dereference in test (reading_grid_test.cpp): grid.spans()[6].nodeOf(2) return value is used immediately without a null check.

  4. Non-standard shared_ptr construction (LanguageModelManager.mm): The aliasing-from-null pattern (shared_ptr(emptySharedPtr, rawPtr)) produces a shared_ptr where get() != nullptr but use_count() == 0, which is a subtle invariant violation. A no-op deleter is clearer and safer.

Comment thread Source/KeyHandler.mm
Comment thread Source/KeyHandler.mm Outdated
Comment thread Source/Engine/gramambular2/reading_grid_test.cpp Outdated
Comment thread Source/LanguageModelManager.mm Outdated
tianjianjiang added a commit that referenced this pull request Mar 1, 2026
The stack consists of 3 stacked PRs (#779, #780, #781)
plus 4 independent PRs (#784, #785, #786, #787) targeting
master directly, not "a stack of 6 PRs each building on
the previous".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 71a31cb to 85aee6c Compare March 1, 2026 06:51
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 3e7bf8e to 76a57fc Compare March 1, 2026 06:54
@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch from 85aee6c to 927c83c Compare March 1, 2026 06:58
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from 76a57fc to b0b2ee1 Compare March 1, 2026 06:59

@claude claude 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.

Four issues found, two of which are critical for correctness/UX:

  1. Aliasing shared_ptr with null control block (LanguageModelManager.mm:37) — produces a shared_ptr with use_count()==0 and a valid stored pointer, which violates normal shared_ptr invariants. A no-op-deleter shared_ptr is semantically cleaner.

  2. Synchronous file I/O on the main thread (KeyHandler.mm:220) — blocks the entire IME input pipeline on every candidate selection. Should be dispatched to a background queue.

  3. Missing null-guard in new test (reading_grid_test.cpp:990) — nodeOf() can return null; dereferencing without checking is UB.

  4. User model applied to plain Bopomofo walks without a mode guard (KeyHandler.mm:143) — the standard-mode model's suggestions flow into plain Bopomofo grid walks even though observations are correctly mode-gated.

Comment thread Source/KeyHandler.mm
_contextualUserModel->observe(leftReading, leftValue,
currentNode->reading(), currentNode->value(),
[NSDate date].timeIntervalSince1970);
[LanguageModelManager saveContextualUserModel];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Synchronous I/O on the main thread on every candidate selection.

saveContextualUserModel calls gContextualUserModel.saveToFile(...) synchronously. For an input method that must remain responsive at all times, a blocking filesystem write on every selection will cause perceptible UI stalls whenever the system is under I/O pressure or when the model file grows large.

At minimum, dispatch the save to a background queue:

Suggested change
[LanguageModelManager saveContextualUserModel];
_contextualUserModel->observe(leftReading, leftValue,
currentNode->reading(), currentNode->value(),
[NSDate date].timeIntervalSince1970);
dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{
[LanguageModelManager saveContextualUserModel];
});

(A coalescing/debounce approach would be even better, but async dispatch already prevents main-thread blocking.)

Comment thread Source/KeyHandler.mm Outdated

Formosa::Gramambular2::ReadingGrid::WalkResult prevWalk = _latestWalk;
// Structurally fix the overridden node so the walk is constrained to it.
auto nodeOpt = _grid->findInSpan(actualCursor, [&readingStr](const auto& node) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Silent fixSpan no-op when overrideCandidate succeeds but findInSpan returns nothing.

overrideCandidate uses cursor-position semantics to locate and override a node, but findInSpan may use span-start-position semantics. If actualCursor lands in the middle of a multi-syllable candidate span, findInSpan could return nullopt even though the override succeeded, silently skipping fixSpan. The walk then uses only the soft override (not the structural pin), so subsequent keystrokes can un-fix the selection.

If overrideCandidate succeeded there must always be a matching overridden node at that cursor. An assertion here would catch the mismatch early:

Suggested change
auto nodeOpt = _grid->findInSpan(actualCursor, [&readingStr](const auto& node) {
auto nodeOpt = _grid->findInSpan(actualCursor, [&readingStr](const auto& node) {
return node->reading() == readingStr && node->isOverridden();
});
// findInSpan must find the node we just overrode; a miss indicates a
// cursor-position mismatch between overrideCandidate and findInSpan.
NSCAssert(nodeOpt.has_value(), @"fixNodeWithReading: overrideCandidate succeeded but findInSpan found no overridden node at cursor %zu", actualCursor);
if (nodeOpt) {
_grid->fixSpan(actualCursor, *nodeOpt);
}

Comment thread Source/LanguageModelManager.mm Outdated

// Aliasing shared_ptr: wraps the stack-allocated LM for ContextualUserModel.
static std::shared_ptr<Formosa::Gramambular2::LanguageModel> gEmptySharedPtr;
static std::shared_ptr<Formosa::Gramambular2::LanguageModel> gLmPtr(gEmptySharedPtr, &gLanguageModelMcBopomofo);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aliasing shared_ptr from an empty shared_ptr produces use_count() == 0.

The aliasing constructor shared_ptr<T>(r, ptr) shares the control block of r. Since gEmptySharedPtr has no control block, gLmPtr.use_count() is 0 even though gLmPtr.get() is non-null. Any future code in ContextualUserModel or its callers that checks use_count() >= 1 or calls unique() will see incorrect values and could draw wrong conclusions (e.g., thinking the LM is unowned and safe to delete).

A no-op-deleter shared_ptr gives the conventional use_count() == 1 and is clearer about intent:

Suggested change
static std::shared_ptr<Formosa::Gramambular2::LanguageModel> gLmPtr(gEmptySharedPtr, &gLanguageModelMcBopomofo);
static std::shared_ptr<Formosa::Gramambular2::LanguageModel> gLmPtr(
&gLanguageModelMcBopomofo, [](Formosa::Gramambular2::LanguageModel*) {});
static Formosa::Gramambular2::ContextualUserModel gContextualUserModel(gLmPtr);

(gEmptySharedPtr can then be removed.)

Comment thread Source/LanguageModelManager.mm Outdated
static McBopomofo::McBopomofoLM gLanguageModelPlainBopomofo;
static McBopomofo::UserOverrideModel gUserOverrideModel(kUserOverrideModelCapacity, kObservedOverrideHalflife);

// Aliasing shared_ptr: wraps the stack-allocated LM for ContextualUserModel.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aliasing shared_ptr with null control blockgLmPtr is constructed via the aliasing constructor from a default-constructed (null) gEmptySharedPtr. This produces a shared_ptr where use_count() == 0 and get() != nullptr — semantics that differ from every normal shared_ptr and are easy to misuse. If ContextualUserModel ever stores this shared_ptr and any caller later checks use_count() or assumes reference counting is active, it will silently misbehave.

A no-op-deleter avoids the footgun while still preventing deletion of the stack-allocated object:

Suggested change
// Aliasing shared_ptr: wraps the stack-allocated LM for ContextualUserModel.
static std::shared_ptr<Formosa::Gramambular2::LanguageModel> gLmPtr(&gLanguageModelMcBopomofo, [](Formosa::Gramambular2::LanguageModel*){});

Comment thread Source/KeyHandler.mm Outdated
}
_contextualUserModel->observe(leftReading, leftValue,
currentNode->reading(), currentNode->value(),
[NSDate date].timeIntervalSince1970);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Synchronous file I/O on the main thread on every candidate selectionsaveContextualUserModel writes to disk synchronously during key event processing. For an IME this blocks the text input pipeline on every candidate pick, causing perceptible lag especially on slower or busier systems.

The PR description acknowledges this as deferred future work, but it should be tracked explicitly. At minimum, consider dispatching the save to a background queue:

Suggested change
[NSDate date].timeIntervalSince1970);
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
[LanguageModelManager saveContextualUserModel];
});

(If saveToFile is not thread-safe, a dedicated serial queue is needed instead.)

nzNode->selectOverrideUnigram(
"年終", ReadingGrid::Node::OverrideType::kOverrideValueWithHighScore);
grid.fixSpan(6, nzNode);
auto result = grid.walk();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing null-guard on nodeOf() returnnodeOf(2) can return nullptr if the span at index 6 has no 2-syllable node. Dereferencing it immediately after is undefined behaviour and will crash the test process. Other tests in this file guard with EXPECT_TRUE(nzNode != nullptr) before use; this one should too.

Suggested change
auto result = grid.walk();
auto nzNode = grid.spans()[6].nodeOf(2);
ASSERT_TRUE(nzNode != nullptr);
nzNode->selectOverrideUnigram(

Comment thread Source/KeyHandler.mm
_grid = new Formosa::Gramambular2::ReadingGrid(lm);
_grid->setReadingSeparator("-");
_grid->setUserModel(_contextualUserModel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

User model applied to plain Bopomofo walkssetUserModel(_contextualUserModel) is called unconditionally in -init, so the global ContextualUserModel (trained exclusively on standard Bopomofo selections) also influences plain Bopomofo grid walks. Observations are correctly gated (_inputMode != InputModePlainBopomofo), but suggestions from the standard-mode model can still surface in plain-mode walks. Consider guarding the setUserModel call as well, or using a separate model instance per mode.

@tianjianjiang
tianjianjiang force-pushed the feat/contextual_user_model branch 2 times, most recently from 0c0dab2 to ec136e0 Compare June 10, 2026 19:22
ContextualUserModel supersedes UserOverrideModel as the user adaptation
mechanism. The interfaces are deliberately compatible, so the swap
touches only the existing call sites: the suggestion at insert time
still applies through overrideCandidate() plus a re-walk, and the
observation in fixNodeWithReading still uses the walk captured before
the override, which records the context the user actually saw.

What changes in behavior:

- Learning now persists across restarts. The model loads once at
  startup and saves after each observed selection. Saves snapshot the
  model on the input thread via serialize() and write atomically on a
  serial background queue, so input handling never blocks on disk I/O.
- Suggestions generalize: a candidate confirmed in enough distinct
  contexts is suggested in contexts it has never been seen in.
- Observation is skipped in Plain Bopomofo mode; since the model now
  persists, single-character selections there must not pollute it.

The model state lives in its own file (contextual-user-model.txt) and
never touches user phrase files. UserOverrideModel sources remain in
tree; removing them is deferred until this has baked.

The model holds no base-language-model reference, so no aliasing
shared_ptr is needed anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tianjianjiang
tianjianjiang force-pushed the feat/keyhandler_user_model branch from b0b2ee1 to 5b41cd2 Compare June 10, 2026 19:30
@tianjianjiang tianjianjiang changed the title Integrate contextual user model into KeyHandler feat: replace UserOverrideModel with ContextualUserModel in KeyHandler Jun 10, 2026

@claude claude 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.

The substitution of ContextualUserModel for UserOverrideModel is clean and the thread-safety reasoning in the comments is correct. One critical performance issue found: saveContextualUserModel enqueues a new serialize+write on every candidate selection with no coalescing, which could queue up redundant writes under rapid input. See inline comment for a simple fix with an atomic flag.

if (![data writeToFile:path options:NSDataWritingAtomic error:&error]) {
NSLog(@"Error: Could not save contextual user model to %@: %@", path, error);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant queued writes under rapid input. saveContextualUserModel is called on every confirmed candidate selection. Each call calls serialize() on the input thread and unconditionally queues a disk write, even when a previous write hasn't completed yet. Under fast candidate navigation, multiple identical (or near-identical) snapshots pile up in the serial queue — all but the last are wasted work.

A simple fix is an atomic "dirty" flag so only one write is ever pending at a time:

Suggested change
});
dispatch_async(LTContextualUserModelQueue(), ^{
NSData *data = [NSData dataWithBytes:snapshot.data() length:snapshot.size()];
NSError *error = nil;
if (![data writeToFile:path options:NSDataWritingAtomic error:&error]) {
NSLog(@"Error: Could not save contextual user model to %@: %@", path, error);
}
});
}

Suggested replacement for the whole method:

static std::atomic<bool> gContextualUserModelSavePending{false};

+ (void)saveContextualUserModel
{
    // Only snapshot and enqueue if no write is already pending.
    bool expected = false;
    if (!gContextualUserModelSavePending.compare_exchange_strong(expected, true)) {
        return;
    }
    std::string snapshot = gContextualUserModel.serialize();
    NSString *path = [self contextualUserModelDataPath];
    dispatch_async(LTContextualUserModelQueue(), ^{
        gContextualUserModelSavePending.store(false);
        NSData *data = [NSData dataWithBytes:snapshot.data() length:snapshot.size()];
        NSError *error = nil;
        if (![data writeToFile:path options:NSDataWritingAtomic error:&error]) {
            NSLog(@"Error: Could not save contextual user model to %@: %@", path, error);
        }
    });
}

This ensures at most one snapshot + write is ever in flight. The tradeoff is that observations made between enqueue and write completion aren't persisted immediately — but those will be captured by the next save triggered by the next selection.

tianjianjiang added a commit that referenced this pull request Jun 10, 2026
…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>
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