diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index c05d668..915c9fe 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -11,6 +11,7 @@ Korean version: [CHANGELOG.md](CHANGELOG.md). ### 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. - Explicit language corrections on unchanged input and context now learn against the reading actually shown. Hidden final scoring no longer prevents those corrections from being learned or treats a return to the original reading as a new correction. Existing scoring after editing a forced word and ordinary confirmation behavior are preserved. ## [0.10.10] - 2026-10-23 diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b97c5..31710ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대 ### 수정 +- 다른 단어 뒤에 입력한 숫자를 포함해, 숫자 문맥이 바로 뒤 단어에 전달되도록 고쳐 `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 ea25bfd..c58b811 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_mut() = 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_mut() = 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 faa76da..2a68418 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; self.word.invalidate_correction_origin(); #[cfg(feature = "japanese")] if let Some(converter) = &self.ja.converter { @@ -409,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 " " @@ -424,7 +427,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(); @@ -462,6 +467,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() @@ -479,7 +485,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; @@ -543,6 +549,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); @@ -632,6 +640,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); @@ -704,6 +713,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); @@ -737,6 +747,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() { @@ -748,10 +759,12 @@ impl InputSession { converter.learn(conversion.current(), &conversion.kana); } self.word.keys_mut().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(); @@ -835,6 +848,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 808e3d9..421d46d 100644 --- a/crates/core/src/session/preview.rs +++ b/crates/core/src/session/preview.rs @@ -229,6 +229,7 @@ impl InputSession { } client.insert_text(&korean.text(), None); *self.word.keys_mut() = 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 adc6278..efa213b 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,307 @@ 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); + } +} + +#[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"] { + 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] {