Skip to main content

msime_engine/touch/
mod.rs

1//! 触屏按键的落点(#10018):宿主注册的字母键几何、每次按键相对被按键中心的偏移,以及按落点给双拼纠错计价。
2//!
3//! 只有触屏宿主经 `msime_client_set_key_geometry`、`msime_client_touch_character` 送进来;桌面宿主和实体键盘永远不调用,所有用到它的地方在没有触点时都走原来的路径,结果逐字节不变。坐标系由宿主自定,引擎按字母键键宽、键高的中位数归一,一个键是单位大小,不论宿主的像素、密度和键距如何。
4//!
5//! 罚分看落点离候选键和离被按键各有多远:`pen(j) = clamp(B + s·(d²(P, c_j) − d²(P, c_k)), P_MIN, P_MAX)`,`d²` 按 `σx`、`σy` 各向异性,并按纵向偏置 `b_y` 把每个键的瞄准点往下挪。单位与词网格的边分相同(自然对数),纠错边从对数概率里扣它,解码不用改。设计和取值见 `.agents/notes/implemented/feature/2026-10-27-shuangpin-touch-correction.md`。
6
7/// 偏移的量化单位:一个键分成这么多份。打分和缓存签名用同一个量化值,命中缓存的答案和重新计算的一致。
8pub const TOUCH_STEPS_PER_KEY: f32 = 20.0;
9/// 偏移钳到 ±1.5 键:更远的落点宿主不会归给这个键。
10pub const TOUCH_OFFSET_LIMIT: i8 = 30;
11/// 平面表里的 QWERTY 邻键离被按的键超过这么远(键为单位)时不算邻键。分离键盘上 T 和 Y、G 和 H 分在两半,平面表却把它们当邻键,宿主导出的 iPad 分离布局里分在两半的平面表邻键相距 4.6 个键以上;普通布局里平面表邻键的键心最远相距 2.37 个键(iPhone 微软双拼那份的 J 和 M:第二行多了 `;`,键距变宽)。
12const FLAT_NEIGHBOR_REACH: f32 = 3.0;
13/// 归一后的坐标(键心和落点)离原点最多这么多个键。宿主的坐标原点和单位任意,真实键盘用屏幕坐标也只有几百个键宽;超过它的几何(键宽极小、坐标极大)在算距离平方时会溢出成无穷,两个无穷相减得 NaN,罚分和邻键判断随之失效,所以整份拒绝。
14const COORDINATE_LIMIT: f32 = 10_000.0;
15
16/// 一次按键的落点:相对被按键中心的偏移,单位 1/20 键([`TOUCH_STEPS_PER_KEY`]),钳到 ±[`TOUCH_OFFSET_LIMIT`]。`copy` 是这个字母的第几个实例:0 是 `keys` 里的那个,1 起依次是 `duplicates` 里同一字母的各个(分离键盘两边各有一个 G、V)。
17#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
18pub struct KeyTouch {
19    pub dx: i8,
20    pub dy: i8,
21    pub copy: u8,
22}
23
24/// 宿主注册的一份字母键几何,按键宽、键高的中位数归一。`id` 是宿主分配的非零代次,布局一变(旋转、分离、单手、键高、微软 `;` 显隐)就换一个。
25#[derive(Clone, Debug, PartialEq)]
26pub struct KeyGeometry {
27    id: u32,
28    /// 归一用的键宽、键高(宿主单位)。
29    unit: (f32, f32),
30    /// 归一后的键心:先是 a 到 z,有 `;` 时接着是它,最后是重复键。
31    keys: Vec<(u8, f32, f32)>,
32    /// `unit` 和 `keys` 的内容指纹([`Self::fingerprint`]),构造时算好。
33    fingerprint: u64,
34}
35
36/// 宿主给的一个键:`[cx, cy, w, h]`,宿主坐标。
37pub type KeyRect = [f32; 4];
38
39/// 几何不合格的原因,整份拒绝。
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum KeyGeometryError {
42    /// `id` 为 0:那是「清除几何」,不是一份几何。
43    ZeroId,
44    /// 某个数不是有限数,宽高不为正,或归一后的键心离原点超过 `COORDINATE_LIMIT` 个键。
45    InvalidRect,
46    /// 重复键不是一个小写字母。
47    InvalidDuplicate,
48}
49
50impl std::fmt::Display for KeyGeometryError {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        formatter.write_str(match self {
53            Self::ZeroId => "key geometry id must be non-zero",
54            Self::InvalidRect => {
55                "key geometry needs finite centers, positive sizes and a bounded extent"
56            }
57            Self::InvalidDuplicate => "key geometry duplicates must be one lowercase letter",
58        })
59    }
60}
61
62impl std::error::Error for KeyGeometryError {}
63
64impl KeyGeometry {
65    /// `keys` 是 a 到 z,`semicolon` 是微软双拼显示的 `;` 键,`duplicates` 是分离键盘里重复的字母键。数值都要有限、宽高为正,归一后的键心都在 `COORDINATE_LIMIT` 之内,否则整份拒绝。
66    pub fn new(
67        id: u32,
68        keys: &[KeyRect; 26],
69        semicolon: Option<KeyRect>,
70        duplicates: &[(u8, KeyRect)],
71    ) -> Result<Self, KeyGeometryError> {
72        if id == 0 {
73            return Err(KeyGeometryError::ZeroId);
74        }
75        let valid = |[cx, cy, w, h]: KeyRect| {
76            cx.is_finite() && cy.is_finite() && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0
77        };
78        if !keys
79            .iter()
80            .chain(semicolon.iter())
81            .chain(duplicates.iter().map(|(_, rect)| rect))
82            .all(|rect| valid(*rect))
83        {
84            return Err(KeyGeometryError::InvalidRect);
85        }
86        if duplicates
87            .iter()
88            .any(|(letter, _)| !letter.is_ascii_lowercase())
89        {
90            return Err(KeyGeometryError::InvalidDuplicate);
91        }
92        let median = |mut values: [f32; 26]| {
93            values.sort_by(f32::total_cmp);
94            values[values.len() / 2]
95        };
96        let unit = (
97            median(keys.map(|[_, _, w, _]| w)),
98            median(keys.map(|[_, _, _, h]| h)),
99        );
100        let normalized = |key: u8, [cx, cy, _, _]: KeyRect| (key, cx / unit.0, cy / unit.1);
101        let mut normalized_keys = Vec::with_capacity(27 + duplicates.len());
102        normalized_keys.extend(
103            (b'a'..=b'z')
104                .zip(keys)
105                .map(|(key, rect)| normalized(key, *rect)),
106        );
107        normalized_keys.extend(semicolon.map(|rect| normalized(b';', rect)));
108        normalized_keys.extend(
109            duplicates
110                .iter()
111                .map(|(letter, rect)| normalized(*letter, *rect)),
112        );
113        // 原始数值都有限,归一后仍可能溢出(键宽 1e-3、坐标 1e36),或大到距离平方溢出。
114        if !normalized_keys
115            .iter()
116            .all(|&(_, x, y)| within_limit(x) && within_limit(y))
117        {
118            return Err(KeyGeometryError::InvalidRect);
119        }
120        let fingerprint = content_fingerprint(unit, &normalized_keys);
121        Ok(Self {
122            id,
123            unit,
124            keys: normalized_keys,
125            fingerprint,
126        })
127    }
128
129    /// 宿主下发的 JSON:`{"id":u32,"keys":[[cx,cy,w,h] × 26,按 a..z],"semicolon":[cx,cy,w,h]|null,"duplicates":[["g",cx,cy,w,h],…]}`,形状沿用滑行请求。空串或 `id` 为 0 表示清除,返回 `Ok(None)`;多出的字段、不是 26 个字母键或数值不合格时整份拒绝。
130    pub fn from_json(bytes: &[u8]) -> Result<Option<Self>, String> {
131        #[derive(serde::Deserialize)]
132        #[serde(deny_unknown_fields)]
133        struct Wire {
134            id: u32,
135            #[serde(default)]
136            keys: Vec<KeyRect>,
137            #[serde(default)]
138            semicolon: Option<KeyRect>,
139            #[serde(default)]
140            duplicates: Vec<(String, f32, f32, f32, f32)>,
141        }
142        if bytes.is_empty() {
143            return Ok(None);
144        }
145        let wire: Wire =
146            serde_json::from_slice(bytes).map_err(|_| "invalid key geometry".to_owned())?;
147        if wire.id == 0 {
148            return Ok(None);
149        }
150        let keys: [KeyRect; 26] = wire
151            .keys
152            .try_into()
153            .map_err(|_| "key geometry needs 26 letter keys".to_owned())?;
154        let duplicates = wire
155            .duplicates
156            .into_iter()
157            .map(|(letter, cx, cy, w, h)| match letter.as_bytes() {
158                [key] => Ok((*key, [cx, cy, w, h])),
159                _ => Err(KeyGeometryError::InvalidDuplicate.to_string()),
160            })
161            .collect::<Result<Vec<_>, _>>()?;
162        Self::new(wire.id, &keys, wire.semicolon, &duplicates)
163            .map(Some)
164            .map_err(|error| error.to_string())
165    }
166
167    pub fn id(&self) -> u32 {
168        self.id
169    }
170
171    /// 几何内容(归一单位和每个键心)的指纹。宿主可能用同一个代次下发内容不同的几何,方案层把它当新几何处理;按触点计价的缓存键要带上它,才不会把旧布局算出的答案交给新布局。内容相同(`==`)的几何指纹相同。
172    pub fn fingerprint(&self) -> u64 {
173        self.fingerprint
174    }
175
176    /// 有没有 `;` 键(宿主只在微软双拼下显示它)。
177    pub fn has_semicolon(&self) -> bool {
178        self.keys.iter().any(|(key, _, _)| *key == b';')
179    }
180
181    /// `key` 的各个实例的键心,按 `copy` 的次序。
182    fn copies(&self, key: u8) -> impl Iterator<Item = (f32, f32)> + '_ {
183        self.keys
184            .iter()
185            .filter(move |(candidate, _, _)| *candidate == key)
186            .map(|&(_, x, y)| (x, y))
187    }
188
189    /// `key` 离 `point` 最近的实例的键心。
190    fn nearest_copy(&self, key: u8, point: (f32, f32)) -> Option<(f32, f32)> {
191        self.copies(key).min_by(|left, right| {
192            distance_squared(*left, point).total_cmp(&distance_squared(*right, point))
193        })
194    }
195
196    /// 宿主坐标里的按下点 (x, y) 落在 `key` 上的触点:取 `key` 离它最近的那个实例,偏移量化到 1/20 键并钳到 ±1.5 键。`key` 不在几何里(没有 `;` 键时的 `;`、不是字母)、坐标不是有限数或归一后超出 `COORDINATE_LIMIT` 时为 `None`,这一键按没有触点处理。大写字母按小写算。
197    pub fn touch(&self, key: u8, x: f32, y: f32) -> Option<KeyTouch> {
198        let point = (x / self.unit.0, y / self.unit.1);
199        if !within_limit(point.0) || !within_limit(point.1) {
200            return None;
201        }
202        let key = key.to_ascii_lowercase();
203        let (copy, center) = self
204            .copies(key)
205            .enumerate()
206            .min_by(|(_, left), (_, right)| {
207                distance_squared(*left, point).total_cmp(&distance_squared(*right, point))
208            })?;
209        let quantize = |offset: f32| {
210            (offset * TOUCH_STEPS_PER_KEY).round().clamp(
211                f32::from(-TOUCH_OFFSET_LIMIT),
212                f32::from(TOUCH_OFFSET_LIMIT),
213            ) as i8
214        };
215        Some(KeyTouch {
216            dx: quantize(point.0 - center.0),
217            dy: quantize(point.1 - center.1),
218            copy: u8::try_from(copy).ok()?,
219        })
220    }
221
222    /// 触点所在的那个实例的键心,以及还原出的(量化后的)落点,都是归一坐标。
223    fn pressed(&self, key: u8, touch: KeyTouch) -> Option<((f32, f32), (f32, f32))> {
224        let center = self.copies(key).nth(usize::from(touch.copy))?;
225        let point = (
226            center.0 + f32::from(touch.dx) / TOUCH_STEPS_PER_KEY,
227            center.1 + f32::from(touch.dy) / TOUCH_STEPS_PER_KEY,
228        );
229        Some((center, point))
230    }
231
232    /// 按在 `pressed` 上、落点是 `touch` 时,把这一键换成 `candidate` 的罚分。候选键取离落点最近的实例,所以分离键盘上的重复键各自就近。`pressed` 或 `candidate` 不在几何里时为 `None`。
233    pub fn penalty(
234        &self,
235        pressed: u8,
236        touch: KeyTouch,
237        candidate: u8,
238        tuning: &TouchTuning,
239    ) -> Option<f64> {
240        let (center, point) = self.pressed(pressed, touch)?;
241        let other = self.nearest_copy(candidate, point)?;
242        let spread = |(cx, cy): (f32, f32)| {
243            let dx = (f64::from(point.0) - f64::from(cx)) / tuning.sigma_x;
244            let dy = (f64::from(point.1) - f64::from(cy) - tuning.bias_y) / tuning.sigma_y;
245            dx * dx + dy * dy
246        };
247        let raw = tuning.base + tuning.scale * (spread(other) - spread(center));
248        Some(raw.clamp(tuning.min_penalty, tuning.max_penalty))
249    }
250
251    /// 平面表的邻键 `candidate` 在这份几何上是否真的挨着被按的那个实例。分离键盘上 T 和 Y 分在两半,平面表的这条邻接就不成立。
252    pub fn adjacent(&self, pressed: u8, touch: KeyTouch, candidate: u8) -> bool {
253        let Some((center, _)) = self.pressed(pressed, touch) else {
254            return false;
255        };
256        self.nearest_copy(candidate, center)
257            .is_some_and(|other| distance_squared(center, other).sqrt() <= FLAT_NEIGHBOR_REACH)
258    }
259}
260
261/// 计价参数,单位是词网格的自然对数分。`sigma_*`、`bias_y` 以键为单位。默认值在三端宿主导出的几何上扫参得到(见模块注释里的笔记)。
262#[derive(Clone, Copy, Debug, PartialEq)]
263pub struct TouchTuning {
264    /// B:落点在两键正中间时的罚分。
265    pub base: f64,
266    /// s:距离平方之差每一单位加多少罚分。
267    pub scale: f64,
268    /// σx、σy:横向、纵向的距离各以多少个键为一个单位。
269    pub sigma_x: f64,
270    pub sigma_y: f64,
271    /// b_y:每个键的瞄准点往下挪多少个键高(按键普遍偏下)。
272    pub bias_y: f64,
273    /// P_MIN、P_MAX:罚分的下限和上限。平面表原有的邻键永不丢弃,最多付到 P_MAX。
274    pub min_penalty: f64,
275    pub max_penalty: f64,
276    /// P_CUT:平面表里没有的键(真实布局下的斜邻键、`;`)罚分超过它就不加。
277    pub cut: f64,
278    /// 纠错整句的分数(已经扣了罚分)不比字面整句低出这么多时,落点算是支持它:它留在引擎给的座位上(整句块之后),不和整句的次选读法一起挪到表尾。打对了的输入按在键心,罚分高,纠错整句多半低于这条线,照旧沉底,不挤占它后面的候选。
279    pub show_margin: f64,
280    /// 同时要求纠错整句改动的每一键在落点下的价都不超过它,落点才算支持这次改正。打对的输入里,按在键心附近的那一键改过去的价高,挡在这条线外;只看分数差时,字面整句本来就排得很靠后的那些用例常有一条分数接近的纠错整句,它坐到整句块之后,把金标准往后挤一位。
281    pub show_penalty: f64,
282    /// m_sp(方案里的 `shuangpin_touch_lead_margin`):纠错整句坐首选要求它的分数(已扣罚分)比字面整句高出这么多乘以纠错边数(`TypoSentence::leads`)。双拼触点专用,不复用全拼值为无穷大的 `typo_lead_margin`;只在 Rust 侧可调,宿主经 FFI 设不了。无穷大时首选规则整个不起作用。
283    pub lead_margin: f64,
284    /// P_LEAD:坐首选还要求改动的每一键在落点下改过去的价(不打学习折扣)都不超过它。按在键心的字母改到邻键的价接近 B + s,挡在这条线外,所以打在键心的字母永远不会被首选改掉。
285    pub lead_penalty: f64,
286}
287
288impl Default for TouchTuning {
289    /// 在 Android 模拟器导出的几何(`resources/eval/key-geometry/android-portrait*.json`)上扫参得到;扫参表见模块注释里的笔记。
290    fn default() -> Self {
291        Self {
292            base: 8.0,
293            scale: 12.0,
294            sigma_x: 1.0,
295            sigma_y: 1.0,
296            bias_y: 0.0,
297            min_penalty: 4.0,
298            max_penalty: 28.0,
299            cut: 16.0,
300            show_margin: 6.0,
301            show_penalty: 14.0,
302            lead_margin: f64::INFINITY,
303            lead_penalty: 10.0,
304        }
305    }
306}
307
308/// 有限且不超过 [`COORDINATE_LIMIT`];NaN 不满足。
309fn within_limit(value: f32) -> bool {
310    value.abs() <= COORDINATE_LIMIT
311}
312
313/// `unit` 和 `keys` 的位模式的哈希。先加 0.0 把 -0.0 归成 0.0,内容按 `==` 相同的几何指纹也相同(NaN 在构造时已被拒绝)。
314fn content_fingerprint(unit: (f32, f32), keys: &[(u8, f32, f32)]) -> u64 {
315    use std::hash::{Hash, Hasher};
316    let bits = |value: f32| (value + 0.0).to_bits();
317    let mut hasher = std::collections::hash_map::DefaultHasher::new();
318    bits(unit.0).hash(&mut hasher);
319    bits(unit.1).hash(&mut hasher);
320    for &(key, x, y) in keys {
321        key.hash(&mut hasher);
322        bits(x).hash(&mut hasher);
323        bits(y).hash(&mut hasher);
324    }
325    hasher.finish()
326}
327
328fn distance_squared((ax, ay): (f32, f32), (bx, by): (f32, f32)) -> f32 {
329    (ax - bx) * (ax - bx) + (ay - by) * (ay - by)
330}
331
332#[cfg(test)]
333mod tests;