From 3f9ee065a9ed74dec37f7ea6545d3bed3852216f Mon Sep 17 00:00:00 2001 From: snowykr Date: Sat, 24 Oct 2026 20:27:50 +0900 Subject: [PATCH 1/2] fix: carry digit context into the following word Consume pending numeric adjacency only when starting a word. Preserve owned suffix deletion and invalidate numeric origin across context, commit and split boundaries without changing classifier policy. Fixes #24 Signed-off-by: snowykr --- CHANGELOG.en.md | 4 + CHANGELOG.md | 4 + crates/core/src/session/japanese.rs | 3 + crates/core/src/session/mod.rs | 17 +- crates/core/src/session/preview.rs | 1 + crates/core/tests/session.rs | 271 +++++++++++++++++++++++++++- platforms/ffi/src/lib.rs | 4 + 7 files changed, 299 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 1698cb9..d514d12 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -9,6 +9,10 @@ Korean version: [CHANGELOG.md](CHANGELOG.md). ## [Unreleased] +### Fixed + +- Digits now supply their context to the following word, including after another word, so units such as `10cm` and `10fps` keep their Latin reading. Deleting an owned suffix preserves its numeric origin, while context changes and internal word splits prevent that origin from leaking to unrelated text. + ## [0.10.10] - 2026-10-23 ### Changed diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc1ca3..1b9ecea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대 ## [Unreleased] +### 수정 + +- 다른 단어 뒤에 입력한 숫자를 포함해, 숫자 문맥이 바로 뒤 단어에 전달되도록 고쳐 `10cm`, `10fps` 같은 단위 입력이 라틴 표기를 유지합니다. 조합 중인 접미사를 지웠을 때는 숫자 문맥을 보존하고, 문맥 변경이나 내부 단어 분리 뒤에는 무관한 입력에 이어지지 않도록 했습니다. + ## [0.10.10] - 2026-10-23 ### 변경 diff --git a/crates/core/src/session/japanese.rs b/crates/core/src/session/japanese.rs index 663e00b..2873465 100644 --- a/crates/core/src/session/japanese.rs +++ b/crates/core/src/session/japanese.rs @@ -55,6 +55,7 @@ impl InputSession { self.last_recorded = None; self.ja.live_suppressed = false; self.word.keys = ch.to_string(); + self.word_after_digit = false; self.word.force(Language::Japanese, false); self.update_preview(client); } @@ -552,6 +553,7 @@ impl InputSession { converter.learn(&candidate, &conversion.kana); } client.insert_text(&candidate.text, None); + self.word_after_digit = false; self.note_brackets(&candidate.text); let rest: String = conversion.kana.chars().skip(candidate.kana_count).collect(); if rest.is_empty() { @@ -790,6 +792,7 @@ impl InputSession { let committed = self.committed_japanese(); client.insert_text(&committed, None); self.word.keys = back; + self.word_after_digit = false; self.word.syllable_start = None; self.previous_language = Some(Language::Japanese); self.word.force(Language::Korean, false); diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs index c11a702..a65dc61 100644 --- a/crates/core/src/session/mod.rs +++ b/crates/core/src/session/mod.rs @@ -306,6 +306,8 @@ impl InputSession { /// A new text field became active: pick its app's policy and forget the sentence context. pub fn activate(&mut self, exe: Option<&str>) { + self.width.after_digit = false; + self.word_after_digit = false; #[cfg(feature = "japanese")] if let Some(converter) = &self.ja.converter { converter.reload(); @@ -422,7 +424,9 @@ impl InputSession { if key == Key::ShiftSpace { return self.handle(Key::Printable(' '), client); } - if !(matches!(key, Key::Printable(_)) && self.word.keys.is_empty()) { + if !(matches!(key, Key::Letter(_)) + || (matches!(key, Key::Printable(_)) && self.word.keys.is_empty())) + { self.width.after_digit = false; } let pending = self.last_commit.take(); @@ -460,6 +464,7 @@ impl InputSession { } fn on_letter(&mut self, ch: char, client: &mut dyn TextClient) -> bool { + let after_digit = std::mem::take(&mut self.width.after_digit); // A symbol on its own is confirmed by the next word, which is judged by itself. #[cfg(feature = "japanese")] if !self.word.keys.is_empty() && self.word.keys.chars().all(RomajiComposer::is_symbol_key) { @@ -475,7 +480,7 @@ impl InputSession { if self.korean_only { self.word.force(Language::Korean, false); } - self.word_after_digit = self.width.after_digit; + self.word_after_digit = after_digit; #[cfg(feature = "japanese")] { self.ja.live_suppressed = false; @@ -539,6 +544,8 @@ impl InputSession { self.ja.added_n_marker = false; } if self.word.is_empty() { + // The owned suffix is gone, but its preceding digit is still in the app. + self.width.after_digit = std::mem::take(&mut self.word_after_digit); self.word.reset(); } self.update_preview(client); @@ -629,6 +636,7 @@ impl InputSession { self.word_before_digit = ch.is_ascii_digit(); let word = self.finish_word(); self.word_before_digit = false; + self.width.after_digit = ch.is_numeric(); // Only the word goes in; the app types the space or punctuation itself, as with // the system's Korean input. client.insert_text(&word.text, None); @@ -701,6 +709,7 @@ impl InputSession { }; let language = self.word.shown_language().unwrap_or(Language::English); self.word.reset(); + self.word_after_digit = false; client.insert_text(&word, None); if let Some(h) = &self.history { h.record(&word); @@ -734,6 +743,7 @@ impl InputSession { /// Commits whatever is in progress as-is (focus change, shortcut, deactivation). pub fn commit(&mut self, client: &mut dyn TextClient) { + self.width.after_digit = false; self.close_flip_window(); #[cfg(feature = "japanese")] if let Some(conversion) = self.ja.conversion.take() { @@ -745,10 +755,12 @@ impl InputSession { converter.learn(conversion.current(), &conversion.kana); } self.word.keys.clear(); + self.word_after_digit = false; self.previous_language = Some(Language::Japanese); return; } if self.word.is_empty() { + self.word_after_digit = false; return; } let word = self.finish_word(); @@ -831,6 +843,7 @@ impl InputSession { }; self.previous_language = Some(language.sentence_language()); self.word.reset(); + self.word_after_digit = false; #[cfg(feature = "japanese")] { self.ja.added_n_marker = false; diff --git a/crates/core/src/session/preview.rs b/crates/core/src/session/preview.rs index fcd77fb..0ca2517 100644 --- a/crates/core/src/session/preview.rs +++ b/crates/core/src/session/preview.rs @@ -216,6 +216,7 @@ impl InputSession { } client.insert_text(&korean.text(), None); self.word.keys = back; + self.word_after_digit = false; self.word.syllable_start = None; self.previous_language = Some(Language::Korean); self.word.forget_preview(); diff --git a/crates/core/tests/session.rs b/crates/core/tests/session.rs index 3716f40..1fb87b3 100644 --- a/crates/core/tests/session.rs +++ b/crates/core/tests/session.rs @@ -2,15 +2,15 @@ //! EndToEndTests, SessionPolicyTests and CorrectionTests, driven through a fake text field. use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tacit_core::session::testing::FakeTextField; use tacit_core::session::{InputMode, Range, NOT_FOUND}; use tacit_core::store::policy::Override; use tacit_core::History; use tacit_core::{ - AppPolicies, AppPolicy, EnglishModel, HeuristicEngine, InputSession, Key, LearningEngine, - SplittingEngine, TextClient, + AppPolicies, AppPolicy, Decision, DecisionContext, DecisionEngine, EnglishModel, + HeuristicEngine, InputSession, Key, Language, LearningEngine, SplittingEngine, TextClient, }; fn engine(name: &str) -> Arc { @@ -109,6 +109,271 @@ impl Rig { } } +type DecisionCalls = Arc>>; + +/// Record the context delivered to the engine, independently of linguistic scoring. +struct RecordingDecisionEngine { + calls: DecisionCalls, +} + +impl DecisionEngine for RecordingDecisionEngine { + fn decide(&self, keys: &str, context: &DecisionContext) -> Decision { + self.calls + .lock() + .unwrap() + .push((keys.to_string(), *context)); + Decision { + language: Language::English, + confidence: 1.0, + output: keys.to_string(), + explanation: String::new(), + runner_up: None, + } + } +} + +fn recording_rig() -> (Rig, DecisionCalls) { + let calls = Arc::new(Mutex::new(Vec::new())); + let engine = Arc::new(LearningEngine::new( + Box::new(RecordingDecisionEngine { + calls: calls.clone(), + }), + None, + )); + ( + Rig { + session: InputSession::new(engine, AppPolicies::default(), true), + field: FakeTextField::new(), + }, + calls, + ) +} + +/// Require an actual decision for every growing prefix, not merely an empty iterator. +fn assert_digit_context(calls: &DecisionCalls, token: &str, after_digit: bool) { + let calls = calls.lock().unwrap(); + for end in 1..=token.len() { + let prefix = &token[..end]; + let contexts: Vec<_> = calls + .iter() + .filter(|(keys, _)| keys == prefix) + .map(|(_, context)| context) + .collect(); + assert!(!contexts.is_empty(), "no decision for {prefix}: {calls:?}"); + for context in contexts { + assert_eq!(context.after_digit, after_digit, "{prefix}: {context:?}"); + assert!(!context.before_digit, "{prefix}: {context:?}"); + } + } +} + +#[test] +fn immediate_digit_context_reaches_every_letter_of_the_next_token() { + // A decimal is not special: its final digit supplies the immediate context. + for prefix in ["1", "10", "111", "k8", "k88", "1.5", "1-5", "1/5"] { + let (mut r, calls) = recording_rig(); + r.type_(prefix); + calls.lock().unwrap().clear(); + r.type_("sus"); + assert_digit_context(&calls, "sus", true); + } +} + +#[test] +fn whitespace_and_symbols_end_immediate_digit_context() { + for prefix in ["1 ", "10.", "1,", "1-", "1/", "1.5.", "k8 ", "k8."] { + let (mut r, calls) = recording_rig(); + r.type_(prefix); + calls.lock().unwrap().clear(); + r.type_("sus"); + assert_digit_context(&calls, "sus", false); + } +} + +#[test] +fn session_boundaries_discard_pending_digit_context() { + for boundary in ["delete", "enter", "other", "reset", "activate", "pause"] { + for prefix in ["10", "k8"] { + let (mut r, calls) = recording_rig(); + r.type_(prefix); + match boundary { + "delete" => r.erase(1), + "enter" => { + r.key(Key::Enter); + } + "other" => { + r.key(Key::Other); + } + "reset" => r.session.commit_for_new_context(&mut r.field), + "activate" => r.session.activate(Some("test.digit-context")), + "pause" => { + r.session.toggle_pause(&mut r.field); + r.session.toggle_pause(&mut r.field); + } + _ => unreachable!(), + } + calls.lock().unwrap().clear(); + r.type_("sus"); + assert_digit_context(&calls, "sus", false); + } + } +} + +#[test] +fn digit_context_does_not_leak_past_the_following_token() { + for boundary in [" ", ".", "⏎"] { + let (mut r, calls) = recording_rig(); + r.type_("10sus"); + r.type_(boundary); + calls.lock().unwrap().clear(); + r.type_("hello"); + assert_digit_context(&calls, "hello", false); + } +} + +#[test] +fn deleting_the_owned_token_preserves_digit_adjacency_until_the_digit_is_deleted() { + for delete_digit in [false, true] { + let (mut r, calls) = recording_rig(); + r.type_("1s"); + assert!(r.key(Key::Delete)); + assert!(r.session.keys().is_empty()); + assert_eq!(r.text(), "1"); + if delete_digit { + r.erase(1); + assert_eq!(r.text(), ""); + } + calls.lock().unwrap().clear(); + r.type_("s"); + assert_digit_context(&calls, "s", !delete_digit); + } +} + +#[test] +fn activation_invalidates_the_digit_origin_of_an_owned_token() { + let (mut r, calls) = recording_rig(); + r.type_("1s"); + r.session.activate(Some("test.new-digit-context")); + assert!(r.key(Key::Delete)); + assert!(r.session.keys().is_empty()); + calls.lock().unwrap().clear(); + r.type_("s"); + assert_digit_context(&calls, "s", false); +} + +/// Synthetic routing for a session split-path test, not a model accuracy test. +struct RecordingSplitEngine { + base: SplittingEngine, + calls: DecisionCalls, +} + +impl DecisionEngine for RecordingSplitEngine { + fn decide(&self, keys: &str, context: &DecisionContext) -> Decision { + self.calls + .lock() + .unwrap() + .push((keys.to_string(), *context)); + if keys.starts_with("dhsmfdms") { + return Decision { + language: Language::Korean, + confidence: 1.0, + output: self.base.render(keys, Language::Korean), + explanation: "synthetic Korean prefix routing".to_string(), + runner_up: None, + }; + } + self.base.decide(keys, context) + } + + fn render(&self, keys: &str, language: Language) -> String { + self.base.render(keys, language) + } +} + +#[test] +fn an_internal_korean_split_does_not_transfer_digit_context_to_its_suffix() { + let calls = Arc::new(Mutex::new(Vec::new())); + let engine = Arc::new(LearningEngine::new( + Box::new(RecordingSplitEngine { + base: SplittingEngine::new(Box::new(HeuristicEngine::new()), EnglishModel::shared()), + calls: calls.clone(), + }), + None, + )); + let mut r = Rig { + session: InputSession::new(engine, AppPolicies::default(), true), + field: FakeTextField::new(), + }; + // Synthetic Korean routing makes the whole-token gate deterministic; + // suffix recognition still uses the real engine and English vocabulary. + // Require an actual split before checking context, not an accuracy outcome. + r.type_("1"); + let mut typed = String::new(); + let mut split = false; + for ch in "dhsmfdmsgoogledptj".chars() { + typed.push(ch); + r.type_(&ch.to_string()); + if r.session.keys() != typed { + assert!(!r.session.keys().is_empty()); + assert!(typed.ends_with(r.session.keys())); + split = true; + break; + } + } + assert!(split, "the fixture must exercise an actual internal split"); + let suffix = format!("{}x", r.session.keys()); + calls.lock().unwrap().clear(); + r.type_("x"); + assert_eq!(r.session.keys(), suffix); + let calls = calls.lock().unwrap(); + let contexts: Vec<_> = calls + .iter() + .filter(|(keys, _)| keys == &suffix) + .map(|(_, context)| context) + .collect(); + assert!( + !contexts.is_empty(), + "no decision for split suffix {suffix}" + ); + assert!(contexts.iter().all(|context| !context.after_digit)); +} + +#[test] +fn following_digit_is_recorded_separately_from_preceding_digit() { + for token in ["p", "k", "hello"] { + let (mut r, calls) = recording_rig(); + r.type_(token); + assert_digit_context(&calls, token, false); + calls.lock().unwrap().clear(); + r.type_("8"); + let calls = calls.lock().unwrap(); + let decisions: Vec<_> = calls.iter().filter(|(keys, _)| keys == token).collect(); + assert!( + !decisions.is_empty(), + "no digit-boundary decision for {token}" + ); + assert!(decisions.iter().any(|(_, context)| context.before_digit)); + assert!(decisions.iter().all(|(_, context)| !context.after_digit)); + } +} + +#[test] +fn existing_counter_and_identifier_outputs_are_preserved() { + for (script, expected) in [ + ("10ro ", "10개 "), + ("10sus ", "10년 "), + ("10qjs ", "10번 "), + ("10akfl ", "10마리 "), + ("10rork ", "10개가 "), + ("k8s ", "k8s "), + ("p95 ", "p95 "), + ] { + let mut r = Rig::new("digit-output-control"); + r.type_(script); + assert_eq!(r.text(), expected, "{script}"); + } +} + #[test] fn types_mixed_sentence() { let mut r = Rig::new("mixed"); diff --git a/platforms/ffi/src/lib.rs b/platforms/ffi/src/lib.rs index c62ac49..33ea494 100644 --- a/platforms/ffi/src/lib.rs +++ b/platforms/ffi/src/lib.rs @@ -511,6 +511,10 @@ mod tests { ("d", "d"), ("Tom's", "Tom's"), ("dkssud d", "안녕 ㅇ"), + ("10cm", "10cm"), + ("10fps", "10fps"), + ("10sus", "10년"), + ("k8s", "k8s"), ] { for (kind, ch, boundary) in [(1, ' ', " "), (5, '\0', "")] { for known_context in [true, false] { From 8f3b662ded2b1b77b2bf2a370bb8602cc551abae Mon Sep 17 00:00:00 2001 From: notth3dev <160973721+noth3dev@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:56 +0900 Subject: [PATCH 2/2] fix: clear numeric adjacency across Shift+Space Shift+Space can insert or pass through whitespace before the generic dispatch reset. Clear the pending digit context before either return so the next word is scored without stale numeric adjacency. Cover both whitespace widths after integrating the correction-learning repair. Signed-off-by: notth3dev <160973721+noth3dev@users.noreply.github.com> --- crates/core/src/session/mod.rs | 1 + crates/core/tests/session.rs | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs index b0c78fd..2a68418 100644 --- a/crates/core/src/session/mod.rs +++ b/crates/core/src/session/mod.rs @@ -411,6 +411,7 @@ impl InputSession { if !eligible { return self.handle(Key::Printable(' '), client); } + self.width.after_digit = false; self.close_flip_window(); if self.width.full_width_space && self.previous_language == Some(Language::Japanese) { return false; // the app types the half-width " " diff --git a/crates/core/tests/session.rs b/crates/core/tests/session.rs index c558270..efa213b 100644 --- a/crates/core/tests/session.rs +++ b/crates/core/tests/session.rs @@ -190,6 +190,42 @@ fn whitespace_and_symbols_end_immediate_digit_context() { } } +#[cfg(feature = "embedded-ja")] +#[test] +fn shift_space_discards_digit_context_in_both_widths() { + for after_japanese in [false, true] { + let calls = Arc::new(Mutex::new(Vec::new())); + let engine = Arc::new( + LearningEngine::new( + Box::new(RecordingDecisionEngine { + calls: calls.clone(), + }), + None, + ) + .with_languages(tacit_core::Languages::legacy(true)), + ); + let mut r = Rig { + session: InputSession::new(engine, AppPolicies::default(), true), + field: FakeTextField::new(), + }; + if after_japanese { + r.session.set_full_width_space(true); + r.type_("kana⎋⏎"); + assert_eq!(r.text(), "かな"); + } + r.type_("1"); + let consumed = r.key(Key::ShiftSpace); + assert_eq!(consumed, !after_japanese); + if !consumed { + r.field.insert_text(" ", None); + } + assert_eq!(r.text(), if after_japanese { "かな1 " } else { "1 " }); + calls.lock().unwrap().clear(); + r.type_("sus"); + assert_digit_context(&calls, "sus", false); + } +} + #[test] fn session_boundaries_discard_pending_digit_context() { for boundary in ["delete", "enter", "other", "reset", "activate", "pause"] {