From 86afb006f6b273b8d17fca6d0ccfdf0c90f9d17d Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 5 Nov 2026 19:07:58 +0800 Subject: [PATCH 1/2] test: add a candidate-ranking eval harness and the 1.0.2 baseline Subjective complaints about candidate order cannot be verified, so this adds a ruler before touching ranking: a reproducible sample set derived from already-registered data (intent from jieba frequencies, codes from the WindInput-derived table, so the two are not the same source), an evaluator that drives the real Session, a make target, and the frozen baseline bound to commit plus table hashes. The baseline shows ranking within a code is healthy and the real gap is the first-level codes. Signed-off-by: wangzhizhou <824219521@qq.com> --- .gitignore | 1 + Makefile | 8 + crates/ime-cli/src/bin/ime-eval.rs | 426 ++++++++++++++++++++ docs/reviews/227-candidate-eval-baseline.md | 55 +++ tools/eval/README.md | 80 ++++ tools/eval/baselines/1.0.2-build37.json | 82 ++++ tools/eval/gen-samples.py | 172 ++++++++ tools/eval/samples.tsv | 205 ++++++++++ 8 files changed, 1029 insertions(+) create mode 100644 crates/ime-cli/src/bin/ime-eval.rs create mode 100644 docs/reviews/227-candidate-eval-baseline.md create mode 100644 tools/eval/README.md create mode 100644 tools/eval/baselines/1.0.2-build37.json create mode 100644 tools/eval/gen-samples.py create mode 100644 tools/eval/samples.tsv diff --git a/.gitignore b/.gitignore index 44c4667e..15403fc0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ docs/experiments/runs/sessions/ # Python 回归运行缓存 __pycache__/ *.py[cod] +tools/eval/last-run.json diff --git a/Makefile b/Makefile index e880b7aa..2250f80a 100644 --- a/Makefile +++ b/Makefile @@ -94,9 +94,17 @@ bootstrap: ## 安装开发依赖(版本固定,见 MVP 审计 SEC-07) cargo install cargo-deny --version 0.20.2 --locked cargo install cargo-fuzz --version 0.13.2 --locked +EVAL_SAMPLES ?= tools/eval/samples.tsv +EVAL_TABLE ?= data/generated/wubi86-core.tsv +EVAL_JSON ?= tools/eval/last-run.json + verify-no-network: ## 静态扫描已知网络依赖/符号(不代替最终包动态外联核验) ./scripts/verify-no-network.sh +eval-candidates: ## 候选排序评测(样本集对比;写 tools/eval/last-run.json) + $(CLEAN) $(CARGO) run -q -p ime-cli --bin ime-eval -- \ + --samples $(EVAL_SAMPLES) --table $(EVAL_TABLE) --json $(EVAL_JSON) + usage-report: ## 汇总用量/成本(纯本地,不花 token) ./scripts/usage-report.sh diff --git a/crates/ime-cli/src/bin/ime-eval.rs b/crates/ime-cli/src/bin/ime-eval.rs new file mode 100644 index 00000000..8ccd848b --- /dev/null +++ b/crates/ime-cli/src/bin/ime-eval.rs @@ -0,0 +1,426 @@ +//! 候选排序评测:把样本集喂给真实 [`Session`],统计位次、按键数、翻页次数。 +//! +//! 用法: +//! +//! ```sh +//! cargo run -q -p ime-cli --bin ime-eval -- \ +//! --samples tools/eval/samples.tsv \ +//! --table data/generated/wubi86-core.tsv \ +//! --json out.json +//! ``` +//! +//! 样本格式(TSV,`#` 开头为注释):`categorykeystarget`。 +//! `keys` 用 `|` 分词,模拟"按词组码":每段输入结束后按空格或数字键选定一个候选再继续。 +//! +//! 指标口径(`docs/MVP后执行计划.md` B 批要求,四个都能从样本集直接量化): +//! +//! - **位次**:目标在候选列表里的下标(0 = 首选);找不到记 `missing`,不计入位次均值。 +//! - **按键数**:`keys` 长度 + 每段 1 键确认(空格或数字)+ 非首选时再 1 键选序号; +//! 四码唯一自动上屏的段不需要确认键。 +//! - **翻页次数**:`位次 / 每页候选数`,取整。 +//! - **命中率 / 首选率**:整条样本是否被完整命中、命中的样本里首选比例。 + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::io::Write as _; + +use ime_core::{InputMode, Session}; +use ime_table::{CodeTableEngine, Table}; + +/// 与宿主一致的每页候选数(`candidate.count` 默认值)。 +const PAGE_SIZE: usize = 9; +/// 判定候选位次时枚举的最大候选数,避免极端码位拖慢评测。 +const MAX_CANDIDATES: usize = 500; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Sample { + category: String, + /// 以 `|` 分段的按键串。 + keys: String, + target: String, +} + +#[derive(Clone, Debug, Default)] +struct ItemResult { + /// 每个命中段的候选位次;未命中段的 `None` 让整条样本记为 missing。 + positions: Vec, + keystrokes: usize, + missing: bool, + hit: bool, +} + +impl ItemResult { + fn first_choice_all(&self) -> bool { + self.hit && self.positions.iter().all(|p| *p == 0) + } + + fn pages(&self) -> usize { + self.positions.iter().map(|p| p / PAGE_SIZE).sum() + } +} + +#[derive(Clone, Debug, Default)] +struct CategoryStats { + samples: usize, + hits: usize, + first_choice: usize, + positions: Vec, + keystrokes: usize, + pages: usize, +} + +impl CategoryStats { + fn add(&mut self, result: &ItemResult) { + self.samples += 1; + self.hits += usize::from(result.hit); + self.first_choice += usize::from(result.first_choice_all()); + self.positions.extend(result.positions.iter().copied()); + self.keystrokes += result.keystrokes; + self.pages += result.pages(); + } + + fn mean_position(&self) -> f64 { + if self.positions.is_empty() { + return 0.0; + } + let sum: usize = self.positions.iter().sum(); + sum as f64 / self.positions.len() as f64 + } + + fn mean_keystrokes(&self) -> f64 { + if self.samples == 0 { + return 0.0; + } + self.keystrokes as f64 / self.samples as f64 + } +} + +/// 解析样本文件;非法行直接报错(样本集是评测输入,静默跳过等于自欺)。 +fn parse_samples(input: &str) -> Result, String> { + let mut out = Vec::new(); + for (lineno, line) in input.lines().enumerate() { + let line = line.trim_end_matches(['\r']); + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split('\t'); + let (Some(category), Some(keys), Some(target), None) = + (fields.next(), fields.next(), fields.next(), fields.next()) + else { + return Err(format!("第 {} 行不是三列 TSV:{line}", lineno + 1)); + }; + if category.is_empty() || keys.is_empty() || target.is_empty() { + return Err(format!("第 {} 行有空字段:{line}", lineno + 1)); + } + out.push(Sample { + category: category.to_string(), + keys: keys.to_string(), + target: target.to_string(), + }); + } + Ok(out) +} + +/// 逐段输入并挑出能让 `remaining` 前进最远的候选。 +fn evaluate_sample(session: &mut Session, sample: &Sample) -> ItemResult { + let mut result = ItemResult::default(); + let mut remaining = sample.target.as_str(); + for segment in sample.keys.split('|') { + session.cancel(); + for ch in segment.chars() { + session.handle_key(ch); + } + let auto = !session.committed().is_empty(); + // 自动上屏时,已提交内容本身即该段的候选;否则在候选列表里按目标剩余部分挑选。 + let (position, consumed) = if auto { + // 四码唯一自动上屏:上屏内容必须正好是目标的开头,否则这一段就是打错了。 + let text = session.committed().to_string(); + if !remaining.starts_with(&text) { + result.missing = true; + result.keystrokes += segment.chars().count(); + return result; + } + let pos = session + .candidates() + .iter() + .position(|c| c.text == text && !c.is_prefix); + (pos.unwrap_or(0), text.len()) + } else { + match pick(session, remaining) { + Some((pos, len)) => (pos, len), + None => { + result.missing = true; + result.keystrokes += segment.chars().count() + 1; + return result; + } + } + }; + result.positions.push(position); + // 键数:本段按键 + 确认键(自动上屏豁免)+ 非首选时的序号键。 + result.keystrokes += + segment.chars().count() + usize::from(!auto) + usize::from(position > 0); + remaining = &remaining[consumed..]; + if remaining.is_empty() { + break; + } + } + result.hit = remaining.is_empty(); + if !result.hit { + result.missing = true; + } + result +} + +/// 在候选列表里找 `target` 的前缀候选,返回(位次,消耗的字符数)。 +fn pick(session: &Session, target: &str) -> Option<(usize, usize)> { + let mut best: Option<(usize, usize)> = None; + for (index, candidate) in session.candidates().iter().enumerate() { + if index >= MAX_CANDIDATES { + break; + } + if let Some(rest) = target.strip_prefix(candidate.text.as_str()) { + let consumed = candidate.text.len(); + let _ = rest; + if consumed > 0 && best.map(|(_, c)| consumed > c).unwrap_or(true) { + best = Some((index, consumed)); + } + } + } + best +} + +fn summarize( + samples: &[Sample], + results: &[ItemResult], +) -> (CategoryStats, BTreeMap) { + let mut overall = CategoryStats::default(); + let mut per_category: BTreeMap = BTreeMap::new(); + for (sample, result) in samples.iter().zip(results) { + overall.add(result); + per_category + .entry(sample.category.clone()) + .or_default() + .add(result); + } + (overall, per_category) +} + +fn render_json(overall: &CategoryStats, per_category: &BTreeMap) -> String { + let mut out = String::from("{\n"); + let _ = writeln!(out, " \"pageSize\": {PAGE_SIZE},"); + let _ = writeln!(out, " \"maxCandidates\": {MAX_CANDIDATES},"); + let _ = writeln!(out, " \"overall\": {},", stats_json(overall)); + let _ = writeln!(out, " \"categories\": {{"); + for (index, (name, stats)) in per_category.iter().enumerate() { + let comma = if index + 1 == per_category.len() { + "" + } else { + "," + }; + let _ = writeln!(out, " {name:?}: {}{comma}", stats_json(stats)); + } + out.push_str(" }\n}\n"); + out +} + +fn stats_json(stats: &CategoryStats) -> String { + format!( + "{{\"samples\": {}, \"hits\": {}, \"firstChoice\": {}, \"meanPosition\": {:.2}, \"meanKeystrokes\": {:.2}, \"pages\": {}}}", + stats.samples, + stats.hits, + stats.first_choice, + stats.mean_position(), + stats.mean_keystrokes(), + stats.pages + ) +} + +fn render_table(overall: &CategoryStats, per_category: &BTreeMap) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{:<12} {:>7} {:>6} {:>7} {:>9} {:>10} {:>6}", + "category", "samples", "hit%", "first%", "avgPos", "keys/sample", "pages" + ); + let mut rows: Vec<(&str, &CategoryStats)> = + per_category.iter().map(|(k, v)| (k.as_str(), v)).collect(); + rows.push(("ALL", overall)); + for (name, stats) in rows { + let hit = if stats.samples == 0 { + 0.0 + } else { + 100.0 * stats.hits as f64 / stats.samples as f64 + }; + let first = if stats.samples == 0 { + 0.0 + } else { + 100.0 * stats.first_choice as f64 / stats.samples as f64 + }; + let _ = writeln!( + out, + "{name:<12} {:>7} {:>6.1} {:>7.1} {:>9.2} {:>10.2} {:>6}", + stats.samples, + hit, + first, + stats.mean_position(), + stats.mean_keystrokes(), + stats.pages + ); + } + out +} + +fn arg_value(args: &[String], flag: &str) -> Option { + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if arg == flag { + return args.get(i + 1).cloned(); + } + if let Some(value) = arg.strip_prefix(&format!("{flag}=")) { + return Some(value.to_string()); + } + i += 1; + } + None +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let samples_path = arg_value(&args, "--samples").unwrap_or_else(|| { + eprintln!("用法:ime-eval --samples [--table ] [--json <路径>]"); + std::process::exit(2); + }); + let table_path = + arg_value(&args, "--table").unwrap_or_else(|| "data/generated/wubi86-core.tsv".to_string()); + + let samples_text = match std::fs::read_to_string(&samples_path) { + Ok(text) => text, + Err(e) => { + eprintln!("错误:样本集 {samples_path} 读取失败:{e}"); + std::process::exit(1); + } + }; + let samples = match parse_samples(&samples_text) { + Ok(samples) if !samples.is_empty() => samples, + Ok(_) => { + eprintln!("错误:样本集 {samples_path} 为空"); + std::process::exit(1); + } + Err(e) => { + eprintln!("错误:{e}"); + std::process::exit(1); + } + }; + let table_text = match std::fs::read_to_string(&table_path) { + Ok(text) => text, + Err(e) => { + eprintln!("错误:码表 {table_path} 读取失败:{e}"); + std::process::exit(1); + } + }; + let table = match Table::from_tsv(&table_text) { + Ok(mut table) => { + table.finalize(); + table + } + Err(e) => { + eprintln!("错误:码表 {table_path} 解析失败:{e:?}"); + std::process::exit(1); + } + }; + + let table = std::sync::Arc::new(table); + let mut results = Vec::with_capacity(samples.len()); + for sample in &samples { + let mut session = Session::new(InputMode::WubiOnly); + session.set_wubi_engine(Box::new(CodeTableEngine::new_shared( + std::sync::Arc::clone(&table), + 4, + ))); + session.set_page_size(PAGE_SIZE); + results.push(evaluate_sample(&mut session, sample)); + } + + let (overall, per_category) = summarize(&samples, &results); + print!("{}", render_table(&overall, &per_category)); + if let Some(path) = arg_value(&args, "--json") { + let json = render_json(&overall, &per_category); + match std::fs::write(&path, json) { + Ok(()) => println!("JSON 已写入 {path}"), + Err(e) => { + eprintln!("错误:JSON 写入 {path} 失败:{e}"); + std::process::exit(1); + } + } + } + let _ = std::io::stdout().flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_three_column_samples_and_rejects_malformed_lines() { + let text = "# 注释\n\nsingle\tabcd\t工\nphrase\tabcd|efgh\t工工\n"; + let samples = parse_samples(text).unwrap(); + assert_eq!(samples.len(), 2); + assert_eq!(samples[1].keys, "abcd|efgh"); + assert!(parse_samples("single\tabcd\n").is_err()); + assert!(parse_samples("a\tb\tc\td\n").is_err()); + } + + #[test] + fn keystrokes_charge_confirmation_and_selection_but_not_auto_commit() { + let first = ItemResult { + positions: vec![0], + keystrokes: 5, + missing: false, + hit: true, + }; + assert!(first.first_choice_all()); + let deep = ItemResult { + positions: vec![12], + keystrokes: 6, + missing: false, + hit: true, + }; + assert!(!deep.first_choice_all()); + assert_eq!(deep.pages(), 1); + } + + #[test] + fn empty_category_stats_do_not_divide_by_zero() { + let stats = CategoryStats::default(); + assert_eq!(stats.mean_position(), 0.0); + assert_eq!(stats.mean_keystrokes(), 0.0); + } + + #[test] + fn picks_the_longest_matching_candidate_with_its_index() { + let table = + Table::from_tsv("# code\ttext\tweight\naaaa\t工\t10\naaaa\t恭\t5\naaa\t工\t1\n") + .unwrap(); + let mut session = Session::new(InputMode::WubiOnly); + session.set_wubi_engine(Box::new(CodeTableEngine::new(table, 4))); + session.set_page_size(PAGE_SIZE); + for ch in "aaaa".chars() { + session.handle_key(ch); + } + session.cancel(); + for ch in "aaa".chars() { + session.handle_key(ch); + } + let sample = Sample { + category: "probe".into(), + keys: "aaa".into(), + target: "工".into(), + }; + let result = evaluate_sample(&mut session, &sample); + assert!(result.hit, "工 应在 aaa 的候选里"); + assert_eq!(result.positions, vec![0]); + } +} diff --git a/docs/reviews/227-candidate-eval-baseline.md b/docs/reviews/227-candidate-eval-baseline.md new file mode 100644 index 00000000..1398641d --- /dev/null +++ b/docs/reviews/227-candidate-eval-baseline.md @@ -0,0 +1,55 @@ +# 227 — 候选排序评测基线(B 批) + +- 日期:2026-11-05 | 基线提交:`328d83e8`(1.0.2 / build 37) +- 产物:`tools/eval/`(生成器、样本集、评测器、基线 JSON、README) +- 关联:`docs/MVP后执行计划.md` B 批、ADR-304、报告 224 + +## 1. 为什么先做这件事 + +「打单字不跟手」是主观描述。ADR-304(简码入表 + 位次上移)要发布,就必须能回答两个问题: +**改之前是多少、改之后是多少**,且这个数字在别人机器上能一秒复现。所以先造尺子,再动刀。 + +## 2. 尺子怎么做 + +| 组成 | 位置 | 说明 | +|---|---|---| +| 样本集 | `tools/eval/samples.tsv`(200 条) | 由 `gen-samples.py` 从仓库内已登记数据派生,可复现 | +| 评测器 | `crates/ime-cli/src/bin/ime-eval.rs` | 直接驱动真实 `Session` + 真实码表,不模拟、不桩 | +| 一键入口 | `make eval-candidates` | 纯本地;输出表格 + JSON | +| 基线 | `tools/eval/baselines/1.0.2-build37.json` | 绑定提交号、样本 SHA、码表 SHA | + +**关键设计:意图来源与排序权重不同源。** 单字意图取 `pinyin-chars.tsv` 的 jieba 词频(D10), +码表排序用的是 WindInput 权重(D3)。若两者同源,"首选率 100%"只是自证——第一版正是这样写错的, +看到 50 条单字全中首选才发现,已改为跨源取。 + +## 3. 基线数字(1.0.2 / build 37,未改排序) + +``` +category samples hit% first% avgPos keys/sample +jianma1 25 56.0 32.0 1.14 2.24 +jianma2 30 100.0 93.3 0.07 3.07 +single 50 100.0 100.0 0.00 4.46 +word2 25 100.0 100.0 0.00 4.16 +word2v 30 100.0 93.3 0.07 4.40 +word34 23 100.0 87.0 0.17 4.35 +sentence 17 70.6 70.6 0.00 11.00 +ALL 200 92.0 85.5 0.11 4.47 +``` + +## 4. 结论与对 1.1.0 的约束 + +1. **同码内排序已经不是瓶颈**:`single` / `word2` 命中且首选;`word34` 87% 首选。 + 这与报告 224「单字 bonus=0 是问题」的判断**部分冲突**——bonus 只在"用户已经学过该词"时才起作用, + 基础排序本身是健康的。 +2. **缺口集中在一级简码**:`jianma1` 只有 32% 首选、44% 连候选列表里都找不到(用户得改打全码)。 + 这正是 ADR-304 的 P0。 +3. `sentence` 的 70.6% 是**数据覆盖**问题(码表没有这些词),不是排序问题;1.1.0 只要求不劣化。 +4. 于是 1.1.0 的判定线写死为:`jianma1.first%` 与 `jianma1.keys/sample` 必须明显改善, + 其余类别 `hit% / first% / keys` 不得劣化;`sentence` 单独看,不达标不阻塞。 + +## 5. 未做 / 已知限制 + +- 未模拟**学习曲线**(`personalize` 需要真实选择序列,基线与对照都用冷启动引擎)。 + ADR-304 P1 的位次上移收益要靠单测 + 装机手感确认,本尺子只保证"静态排序不劣化"。 +- 未建模分词歧义(长句按码表贪心切词;用户的实际切法与它不同)。 +- 样本集一旦入库即为考卷,改动须在报告里说明理由。 diff --git a/tools/eval/README.md b/tools/eval/README.md new file mode 100644 index 00000000..7e58caa0 --- /dev/null +++ b/tools/eval/README.md @@ -0,0 +1,80 @@ +# 候选排序评测(B 批基线) + +> 目的:把"打单字不跟手"这类主观描述变成**可复跑的数字**,让 ADR-304(简码入表 + 位次上移) +> 的收益和副作用都能对账。基线必须在改排序**之前**采集——`baselines/1.0.2-build37.json` 就是那条线。 + +## 1. 一键跑 + +```sh +make eval-candidates # 结果打到终端,并写 tools/eval/last-run.json +make eval-candidates EVAL_JSON=/tmp/x.json # 指定输出 +``` + +默认样本 `tools/eval/samples.tsv`、码表 `data/generated/wubi86-core.tsv`。 +纯本地、不联网、不读用户配置、不写 `~/Library`。 + +## 2. 样本集 + +`tools/eval/gen-samples.py` 生成,**勿手改** `samples.tsv`(要改进就改生成器,样本集一改等于换考卷)。 + +| 类别 | 条数 | 键盘输入 | 意图来源 | +|---|---|---|---| +| `jianma1` | 25 | 1 字母(五笔86 一级简码) | 公开标准键位表 | +| `jianma2` | 30 | 2 字母(该字全码前两位,ADR-304 二级简码规则) | 高频单字 | +| `single` | 50 | 4 字母全码 | `pinyin-chars.tsv` 单字词频(jieba,D10) | +| `word2` | 25 | 4 字母词码 | `input-quality.tsv` **holdout**(ADR-227) | +| `word2v` | 30 | 同上 | 同上 **validation**(数量补足,单独统计) | +| `word34` | 23 | 4 字母词码 | 同上,holdout → validation → train 取够 | +| `sentence` | 17 | 分段 `usrh\|ceid\|irlg` | 同上,按码表贪心切词 | + +**关键设计:意图来源与排序权重不同源。** 单字用 jieba 词频(D10),码表排序用 WindInput 权重(D3); +若两者同源,"首选率 100%"就只是自证。 + +## 3. 指标口径 + +| 指标 | 定义 | +|---|---| +| `hit%` | 整条样本被完整命中的比例(分段样本要求每段都命中) | +| `first%` | 命中的样本里全部段落都是首选的比例 | +| `avgPos` | 命中候选的平均位次(0 = 首选);未命中不计入 | +| `keys/sample` | 按键数 = 本段字母数 + 确认键(四码唯一自动上屏豁免)+ 非首选时 1 个序号键 | +| `pages` | 累计翻页数 = 位次 / 每页候选数(9) | + +## 4. 对比流程 + +```sh +make eval-candidates EVAL_JSON=/tmp/after.json +python3 - <<'PY' +import json +a = json.load(open('tools/eval/baselines/1.0.2-build37.json'))['metrics'] +b = json.load(open('/tmp/after.json')) +for cat in sorted(set(a['categories']) | set(b['categories'])): + x = a['categories'].get(cat); y = b['categories'].get(cat) + if not (x and y): print(f"{cat}: 仅一侧存在"); continue + print(f"{cat:<10} first {x['firstChoice']:>3}→{y['firstChoice']:<3} " + f"pos {x['meanPosition']:.2f}→{y['meanPosition']:.2f} " + f"keys {x['meanKeystrokes']:.2f}→{y['meanKeystrokes']:.2f}") +PY +``` + +判定口径(`docs/MVP后执行计划.md` §4):`hit%` / `first%` / `keys` 不得劣化,`jianma1` 的 `first%` +与 `keys` 必须明显改善;否则不发布。 + +## 5. 基线(1.0.2 / build 37) + +``` +category samples hit% first% avgPos keys/sample +jianma1 25 56.0 32.0 1.14 2.24 +jianma2 30 100.0 93.3 0.07 3.07 +single 50 100.0 100.0 0.00 4.46 +word2 25 100.0 100.0 0.00 4.16 +word2v 30 100.0 93.3 0.07 4.40 +word34 23 100.0 87.0 0.17 4.35 +sentence 17 70.6 70.6 0.00 11.00 +ALL 200 92.0 85.5 0.11 4.47 +``` + +读法:单字/词组在同码内排序已经不错(`first%` 87–100%),**真正的缺口是一级简码** +(只有 32% 首选、44% 连命都命不中)——这正是 ADR-304 要补的那一段,也和报告 224 的诊断一致。 +`sentence` 的 `hit%` 70.6% 多半来自码表没收录的切词段,属于数据覆盖问题,不是排序问题; +1.1.0 判定时按"不得劣化"处理,不作为改善目标。 diff --git a/tools/eval/baselines/1.0.2-build37.json b/tools/eval/baselines/1.0.2-build37.json new file mode 100644 index 00000000..91f83c58 --- /dev/null +++ b/tools/eval/baselines/1.0.2-build37.json @@ -0,0 +1,82 @@ +{ + "label": "1.0.2 / build 37(简码入表、位次上移之前的基线)", + "recordedAt": "2026-11-05T11:06:46Z", + "commit": "328d83e8715c9af604db210355b94d48ebb16821", + "version": "1.0.2", + "build": 37, + "samplesSHA256": "33197cfde564a6c696aeb6fe16e0a101e9b0dc09c7cc7a4e58749d68c9d7f94d", + "tableSHA256": "1b403b85a10f53c7061870c2b4a96de68023cdfdcc7fdd6c9605f5225c38deaa", + "engine": { + "binary": "ime-eval (ime-cli)", + "pageSize": 9, + "maxCandidates": 500 + }, + "metrics": { + "overall": { + "samples": 200, + "hits": 184, + "firstChoice": 171, + "meanPosition": 0.11, + "meanKeystrokes": 4.47, + "pages": 0 + }, + "categories": { + "jianma1": { + "samples": 25, + "hits": 14, + "firstChoice": 8, + "meanPosition": 1.14, + "meanKeystrokes": 2.24, + "pages": 0 + }, + "jianma2": { + "samples": 30, + "hits": 30, + "firstChoice": 28, + "meanPosition": 0.07, + "meanKeystrokes": 3.07, + "pages": 0 + }, + "sentence": { + "samples": 17, + "hits": 12, + "firstChoice": 12, + "meanPosition": 0.0, + "meanKeystrokes": 11.0, + "pages": 0 + }, + "single": { + "samples": 50, + "hits": 50, + "firstChoice": 50, + "meanPosition": 0.0, + "meanKeystrokes": 4.46, + "pages": 0 + }, + "word2": { + "samples": 25, + "hits": 25, + "firstChoice": 25, + "meanPosition": 0.0, + "meanKeystrokes": 4.16, + "pages": 0 + }, + "word2v": { + "samples": 30, + "hits": 30, + "firstChoice": 28, + "meanPosition": 0.07, + "meanKeystrokes": 4.4, + "pages": 0 + }, + "word34": { + "samples": 23, + "hits": 23, + "firstChoice": 20, + "meanPosition": 0.17, + "meanKeystrokes": 4.35, + "pages": 0 + } + } + } +} diff --git a/tools/eval/gen-samples.py b/tools/eval/gen-samples.py new file mode 100644 index 00000000..acb74e65 --- /dev/null +++ b/tools/eval/gen-samples.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""生成候选排序评测样本集 `tools/eval/samples.tsv`。 + +样本只从仓库内**已登记**的数据派生,可复现: + +| 类别 | 来源 | 取法 | +|---|---|---| +| `jianma1` | 五笔86 一级简码(公开标准,见 `docs/技术方案.md`) | 25 个键位全取,按键 = 1 字母 | +| `jianma2` | 高频单字取码表前两码(ADR-304 的二级简码规则) | 30 条,按键 = 2 字母 | +| `single` | 意图频次取 `data/generated/pinyin-chars.tsv`(jieba 单字词频,D10),码取码表 | 前 50 个高频单字;**意图来源与码表排序权重不同源**,否则"首选率 100%"是自证 | +| `word2` | `data/input-quality.tsv` **holdout** 两字词(ADR-227) | 全部,且码表里有 4 字母码 | +| `word2v` | 同上 **validation** 两字词 | 补足数量用(与 holdout 分开统计,避免混考卷) | +| `word34` | 同上,按 holdout → validation → train 顺序取够 | 三/四字词(考的是同码内排序,不是泛化,故三份都用) | +| `sentence` | 同上,同一顺序 | 五字及以上,按码表贪心切词拼成 `|` 分段键入串 | + +`input-quality.tsv` 的 holdout 只用于冻结后评估,这里正是它的用途; +样本集一旦入库就不再改动(要改等于换考卷,必须在报告里说明)。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TABLE = ROOT / "data/generated/wubi86-core.tsv" +CHAR_FREQ = ROOT / "data/generated/pinyin-chars.tsv" +QUALITY = ROOT / "data/input-quality.tsv" +OUT = ROOT / "tools/eval/samples.tsv" + +# 五笔86 一级简码(G 键位表):一键位对应一字母。 +LEVEL1 = { + "g": "一", "f": "地", "d": "在", "s": "要", "a": "工", + "h": "上", "j": "是", "k": "中", "l": "国", "m": "同", + "t": "和", "r": "的", "e": "有", "w": "人", "q": "我", + "y": "主", "u": "产", "i": "不", "o": "为", "p": "这", + "n": "民", "b": "了", "v": "发", "c": "以", "x": "经", +} + + +def load_table() -> tuple[dict[str, list[tuple[int, str]]], dict[str, list[tuple[int, str]]]]: + """返回 (text → [(weight, code)], code → [(weight, text)])。""" + by_text: dict[str, list[tuple[int, str]]] = {} + by_code: dict[str, list[tuple[int, str]]] = {} + for line in TABLE.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) != 3: + raise SystemExit(f"码表行格式异常:{line!r}") + code, text, weight = parts[0], parts[1], int(parts[2]) + by_text.setdefault(text, []).append((weight, code)) + by_code.setdefault(code, []).append((weight, text)) + return by_text, by_code + + +def load_quality(split: str) -> list[str]: + rows = [] + for line in QUALITY.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) == 3 and parts[1] == split: + rows.append(parts[2]) + return rows + + +def best_code(by_text: dict[str, list[tuple[int, str]]], text: str, length: int = 4) -> str | None: + codes = [(w, c) for w, c in by_text.get(text, []) if len(c) == length] + if not codes: + return None + return max(codes)[1] + + +def build() -> list[tuple[str, str, str]]: + by_text, by_code = load_table() + samples: list[tuple[str, str, str]] = [] + + for key, char in LEVEL1.items(): + samples.append(("jianma1", key, char)) + + # 意图频次用 jieba 单字词频(pinyin-chars),与码表排序用的 WindInput 权重不同源。 + freq: dict[str, int] = {} + for line in CHAR_FREQ.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + parts = line.split(" ") + if len(parts) == 3 and len(parts[1]) == 1: + freq[parts[1]] = max(freq.get(parts[1], 0), int(parts[2])) + singles = sorted(((w, t) for t, w in freq.items()), reverse=True) + for _, char in singles: + code = best_code(by_text, char) + if code and len(code) == 4 and sum(1 for s in samples if s[0] == "jianma2") < 30: + samples.append(("jianma2", code[:2], char)) + seen: set[str] = set() + for _, char in singles: + if char in seen: + continue + code = best_code(by_text, char) + if not code: + continue + seen.add(char) + samples.append(("single", code, char)) + if len(seen) == 50: + break + + # 长词与句子数量少,按 holdout → validation → train 顺序取够;两字词两套分开统计。 + budget = {"word34": 23, "sentence": 25} + for split in ("holdout", "validation", "train"): + for word in load_quality(split): + size = len(word) + if size == 2: + code = best_code(by_text, word) + if code and split == "holdout": + samples.append(("word2", code, word)) + elif code and len([s for s in samples if s[0] == "word2v"]) < 30: + samples.append(("word2v", code, word)) + elif size in (3, 4): + code = best_code(by_text, word) + if code and budget["word34"] > 0: + samples.append(("word34", code, word)) + budget["word34"] -= 1 + else: + segments = segment(word, by_text) + if segments and budget["sentence"] > 0: + samples.append(("sentence", "|".join(segments), word)) + budget["sentence"] -= 1 + return samples + + +def segment(word: str, by_text: dict[str, list[tuple[int, str]]]) -> list[str] | None: + """按码表里最长的词贪心切分,返回每段的按键串。切不动返回 None。""" + codes: list[str] = [] + index = 0 + while index < len(word): + match = None + for size in range(min(4, len(word) - index), 0, -1): + piece = word[index : index + size] + code = best_code(by_text, piece) + if code: + match = (code, size) + break + if match is None: + return None + codes.append(match[0]) + index += match[1] + return codes + + +def main() -> int: + samples = build() + counts: dict[str, int] = {} + for category, _, _ in samples: + counts[category] = counts.get(category, 0) + 1 + + header = [ + "# 候选排序评测样本集(tools/eval/gen-samples.py 生成,勿手改)", + "# category\tkeys\ttarget;keys 用 | 分段,模拟按词组码后逐段确认", + "# 来源:一级简码=公开标准;单字=data/generated/wubi86-core.tsv(D3/D10);", + "# 词/句=data/input-quality.tsv holdout 分割(ADR-227)", + "# 生成命令:python3 tools/eval/gen-samples.py", + ] + body = [f"{c}\t{k}\t{t}" for c, k, t in samples] + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text("\n".join(header + body) + "\n", encoding="utf-8") + print(f"写入 {OUT.relative_to(ROOT)}:{len(body)} 条 " + + " ".join(f"{k}={v}" for k, v in sorted(counts.items()))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/eval/samples.tsv b/tools/eval/samples.tsv new file mode 100644 index 00000000..7e594698 --- /dev/null +++ b/tools/eval/samples.tsv @@ -0,0 +1,205 @@ +# 候选排序评测样本集(tools/eval/gen-samples.py 生成,勿手改) +# category keys target;keys 用 | 分段,模拟按词组码后逐段确认 +# 来源:一级简码=公开标准;单字=data/generated/wubi86-core.tsv(D3/D10); +# 词/句=data/input-quality.tsv holdout 分割(ADR-227) +# 生成命令:python3 tools/eval/gen-samples.py +jianma1 g 一 +jianma1 f 地 +jianma1 d 在 +jianma1 s 要 +jianma1 a 工 +jianma1 h 上 +jianma1 j 是 +jianma1 k 中 +jianma1 l 国 +jianma1 m 同 +jianma1 t 和 +jianma1 r 的 +jianma1 e 有 +jianma1 w 人 +jianma1 q 我 +jianma1 y 主 +jianma1 u 产 +jianma1 i 不 +jianma1 o 为 +jianma1 p 这 +jianma1 n 民 +jianma1 b 了 +jianma1 v 发 +jianma1 c 以 +jianma1 x 经 +jianma2 jg 是 +jianma2 dh 在 +jianma2 tr 我 +jianma2 rq 的 +jianma2 ww 人 +jianma2 bn 也 +jianma2 yl 为 +jianma2 yi 就 +jianma2 hh 上 +jianma2 rh 年 +jianma2 wq 你 +jianma2 yu 说 +jianma2 gg 一 +jianma2 gc 到 +jianma2 ft 都 +jianma2 tf 等 +jianma2 ud 着 +jianma2 gn 与 +jianma2 gi 还 +jianma2 cc 又 +jianma2 dd 大 +jianma2 dm 而 +jianma2 pp 之 +jianma2 ut 道 +jianma2 ny 以 +jianma2 tj 得 +jianma2 rg 后 +jianma2 uq 将 +jianma2 vf 那 +jianma2 wj 但 +single jghu 是 +single dhfd 在 +single trnt 我 +single rqyy 的 +single wwww 人 +single bnhn 也 +single ylyi 为 +single yidn 就 +single hhgg 上 +single rhfk 年 +single wqiy 你 +single yukq 说 +single ggll 一 +single gcfj 到 +single ftjb 都 +single tffu 等 +single udhf 着 +single gngd 与 +single gipi 还 +single cccc 又 +single dddd 大 +single dmjj 而 +single pppp 之 +single uthp 道 +single nywy 以 +single tjgf 得 +single rgkd 后 +single uqfy 将 +single vfbh 那 +single wjgg 但 +single eeee 月 +single puhc 被 +single cexx 能 +single wfcu 会 +single akgd 或 +single jjjj 日 +single mhng 由 +single etnh 用 +single rnrh 所 +single tmkd 向 +single nnnn 已 +single adwu 其 +single xwgk 给 +single tvey 很 +single wgkq 使 +single uejj 前 +single usrh 新 +single shnu 想 +single fcbh 却 +single jbcu 最 +word2 jhqn 早饭 +word2 tfqn 午饭 +word2 jqqn 晚饭 +word2 jhhq 早餐 +word2 tfhq 午餐 +word2 jqhq 晚餐 +word2 fbqr 地铁 +word2 wcuq 公交 +word2 oolg 火车 +word2 ymqr 高铁 +word2 nusm 飞机 +word2 smfn 机场 +word2 lguh 车站 +word2 tfsb 行李 +word2 gdrn 天气 +word2 jjrb 日报 +word2 umtl 商务 +word2 yldh 库存 +word2 fjtk 进程 +word2 qipg 锁定 +word2 drbb 原子 +word2 bepf 阻塞 +word2 onyy 断言 +word2 rxqf 指针 +word2 xhet 引用 +sentence usrh|ceid|irlg 新能源汽车 +word34 gfwr 环境保护 +word34 pwwb 社会保障 +word34 ytfy 义务教育 +word34 auwb 医疗保险 +word2v jhhh 早上 +word2v jtgd 昨天 +word2v peww 家人 +word2v iptg 学生 +word2v klkd 咖啡 +word2v oyqn 米饭 +word2v fhys 起床 +word2v wsth 休息 +word2v qdyn 希望 +word2v skce 可能 +word2v ggfh 一起 +word2v ppue 之前 +word2v thtc 怎么 +word2v wqwu 你们 +word2v gijg 不是 +word34 gwqt 一会儿 +word2v yfaj 计划 +word2v xfjs 结果 +word2v cbth 预算 +word2v lfbw 团队 +word2v ebtl 服务 +word2v ysuj 订单 +word2v fuyk 培训 +word2v ygwn 请假 +word2v iith 消息 +word2v pfdn 完成 +word2v bcii 取消 +word2v pjrx 审批 +word2v ynvi 记录 +word2v ovrn 数据 +word2v jjad 日期 +word34 awya 工作计划 +word34 ufiy 单元测试 +sentence khlg|wwna|ddip 中国人民大学 +word34 lwat 软件工程 +word34 rwtx 操作系统 +sentence ytsm|mqxt 计算机网络 +sentence ceet|fwai|icpb 通用规范汉字 +word34 unrr 并发控制 +word34 weyj 使用说明 +word34 gngu 一心一意 +word34 gsly 不可思议 +word34 ywtc 为什么 +word34 iutx 没关系 +word34 cgfh 对不起 +word34 gvul 不好意思 +word34 iujg 没问题 +word34 tggh 等一下 +word34 qgww 负责人 +word34 awjj 工作日 +word34 ahtg 项目管理 +word34 ltif 输入法 +sentence khwx|wwna|atlg 中华人民共和国 +sentence khwx|wwna|atlg|lgyi|sksw 中华人民共和国国歌 +sentence khlg|wwna|qvtf 中国人民银行 +sentence khlg|tibp 中国科学院 +sentence hhit|uqce|ddip 上海交通大学 +sentence kqip|aaog|ddip 哈尔滨工业大学 +sentence khlg|tirs|ddip 中国科学技术大学 +sentence ytsm|tuip 计算机科学 +sentence watc|rfsy 人工智能技术 +sentence thqd|ygyy|thgj 自然语言处理 +sentence oryl|tpgj|txxy 数据库管理系统 +sentence tqfc|gbmq 移动互联网 +sentence khyy|ltif 中文输入法 From eba5a1f7355f123195674d84cb391a056e68ff9e Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 5 Nov 2026 19:16:16 +0800 Subject: [PATCH 2/2] feat: put the first-level short codes in the table and promote bounded Wubi users type one letter for the twenty-five most common characters, but the table had no codes shorter than three letters, so that key fell back to a prefix list where the intended character was usually neither first nor even present. The level-one table is now generated into the code table from a registered project-compiled list, which makes the key an exact hit without touching the ranking code. Personalization no longer adds a bonus to the static weight; it moves a learned candidate up one or two places, never past the current first choice and never across the exact/completion/fuzzy blocks. The evaluation set shows the first-level hit rate going from 56 to 100 percent with no other category changing. Signed-off-by: wangzhizhou <824219521@qq.com> --- Cargo.lock | 16 +-- Cargo.toml | 12 +- crates/ime-core/src/lib.rs | 126 ++++++++++++++---- crates/ime-ffi/src/session.rs | 85 +++++++++++- crates/ime-table/src/lib.rs | 21 +++ crates/wubi-encoder/examples/generate_core.rs | 65 ++++++++- data/generated/wubi86-core-report.txt | 2 +- data/generated/wubi86-core.tsv | 26 ++++ data/sources.toml | 19 ++- .../228-short-codes-and-bounded-promotion.md | 59 ++++++++ ...63\347\255\226\350\256\260\345\275\225.md" | 10 +- ...00\346\234\257\346\226\271\346\241\210.md" | 4 +- ...20\344\270\216\350\256\270\345\217\257.md" | 1 + packaging/build-number | 2 +- tools/check-level1-codes.py | 79 +++++++++++ 15 files changed, 469 insertions(+), 58 deletions(-) create mode 100644 docs/reviews/228-short-codes-and-bounded-promotion.md create mode 100755 tools/check-level1-codes.py diff --git a/Cargo.lock b/Cargo.lock index c3bf1106..d43bac1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "ime-cli" -version = "1.0.2" +version = "1.1.0" dependencies = [ "ime-config", "ime-core", @@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "ime-config" -version = "1.0.2" +version = "1.1.0" dependencies = [ "arc-swap", "toml_edit", @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "ime-core" -version = "1.0.2" +version = "1.1.0" [[package]] name = "ime-dict" -version = "1.0.2" +version = "1.1.0" [[package]] name = "ime-ffi" -version = "1.0.2" +version = "1.1.0" dependencies = [ "ime-config", "ime-core", @@ -62,14 +62,14 @@ dependencies = [ [[package]] name = "ime-pinyin" -version = "1.0.2" +version = "1.1.0" dependencies = [ "ime-core", ] [[package]] name = "ime-table" -version = "1.0.2" +version = "1.1.0" dependencies = [ "ime-core", ] @@ -131,4 +131,4 @@ dependencies = [ [[package]] name = "wubi-encoder" -version = "1.0.2" +version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index b9185d91..dc7b15a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "1.0.2" +version = "1.1.0" edition = "2021" license = "GPL-3.0-or-later" rust-version = "1.98" @@ -12,11 +12,11 @@ description = "萤火虫输入法:跨平台、离线、只做核心输入的 ## 内部路径依赖:显式写出 version 供 cargo-deny 解析(否则会被判为通配符 / 解析失败)。 ## 发版时与 `[workspace.package] version` 一起改。 [workspace.dependencies] -ime-dict = { path = "crates/ime-dict", version = "1.0.2" } -ime-config = { path = "crates/ime-config", version = "1.0.2" } -ime-core = { path = "crates/ime-core", version = "1.0.2" } -ime-pinyin = { path = "crates/ime-pinyin", version = "1.0.2" } -ime-table = { path = "crates/ime-table", version = "1.0.2" } +ime-dict = { path = "crates/ime-dict", version = "1.1.0" } +ime-config = { path = "crates/ime-config", version = "1.1.0" } +ime-core = { path = "crates/ime-core", version = "1.1.0" } +ime-pinyin = { path = "crates/ime-pinyin", version = "1.1.0" } +ime-table = { path = "crates/ime-table", version = "1.1.0" } [profile.release] lto = true diff --git a/crates/ime-core/src/lib.rs b/crates/ime-core/src/lib.rs index 2dd8af19..3864f0d9 100644 --- a/crates/ime-core/src/lib.rs +++ b/crates/ime-core/src/lib.rs @@ -330,6 +330,11 @@ impl Session { } /// Merge local user entries and rank within each engine source. + /// + /// 五笔侧的学习结果**不再改权重刻度**,而是把学过的候选按固定步数上移(ADR-304 P1): + /// 学过至少一次上移 1 位、至少 4 次上移 2 位,**永不越过首位**。 + /// 这样"用过一次"不会推翻静态词频排序(老做法 `weight + learned.min(32)` 会让 + /// 一个生僻词永久压住高频词),也修掉了单字完全不吃学习的问题。 pub fn personalize(&mut self, extra: Vec, score: impl Fn(&Candidate) -> u32) { self.candidates.extend(extra); let mut scored: Vec<_> = self @@ -340,18 +345,13 @@ impl Session { let frequency = if c.source == CandidateSource::Pinyin { i64::from(learned) } else { - let bonus = if c.text.chars().count() > 1 { - learned.min(32) - } else { - 0 - }; - i64::from(c.weight) + i64::from(bonus) + i64::from(c.weight) }; - (c, frequency) + (c, frequency, learned) }) .collect(); let rank = |c: &Candidate| usize::from(c.source == CandidateSource::Pinyin); - scored.sort_by(|(a, a_score), (b, b_score)| { + scored.sort_by(|(a, a_score, _), (b, b_score, _)| { rank(a) .cmp(&rank(b)) .then_with(|| a.is_prefix.cmp(&b.is_prefix)) @@ -361,15 +361,58 @@ impl Session { .then_with(|| a.text.cmp(&b.text)) }); let mut seen = std::collections::HashSet::new(); - self.candidates = scored + let mut scored: Vec<_> = scored .into_iter() - .map(|(c, _)| c) - .filter(|c| seen.insert(c.text.clone())) + .filter(|(c, _, _)| seen.insert(c.text.clone())) .collect(); + let table_count = scored + .iter() + .take_while(|(c, _, _)| c.source != CandidateSource::Pinyin) + .count(); + Self::promote_learned(&mut scored, table_count); + self.candidates = scored.into_iter().map(|(c, _, _)| c).collect(); self.arrange_mixed_fallback(); self.page = self.page.min(self.page_count().saturating_sub(1)); } + /// 有界位次上移:只在码表候选段内、按学习次数走固定步长,绝不越过首位。 + /// + /// 单字与多字同一规则(老实现给单字 `bonus = 0`,等于单字永远学不动)。 + fn promote_learned(scored: &mut Vec<(Candidate, i64, u32)>, table_count: usize) { + if table_count < 2 { + return; + } + // 精确/补全/模糊三类各自成块(排序键里已保证连续),上移不得跨块: + // 否则"学过"的补全候选会压到精确命中前面,破坏"精确优先"的硬约束。 + let class = |c: &Candidate| (c.is_prefix, c.is_fuzzy); + let mut index = 0; + while index < table_count { + let learned = scored[index].2; + let steps = match learned { + 0 => 0, + 1..=3 => 1, + _ => 2, + }; + if steps == 0 { + index += 1; + continue; + } + let key = class(&scored[index].0); + let mut floor = index; + while floor > 0 && class(&scored[floor - 1].0) == key { + floor -= 1; + } + // 首选位保护:学过的候选最多升到第 2 位,永不把当前首选挤下去(ADR-304)。 + let protected = usize::from(index > 0); + let target = index.saturating_sub(steps).max(floor).max(protected); + if target < index { + let entry = scored.remove(index); + scored.insert(target, entry); + } + index += 1; + } + } + fn arrange_mixed_fallback(&mut self) { if self.mode != InputMode::Mixed { return; @@ -745,19 +788,37 @@ mod tests { let mut fuzzy = pin("模糊", 1000); fuzzy.is_fuzzy = true; s.personalize(vec![pin("拼一", 20), pin("拼二", 10), fuzzy], |_| 0); - assert_eq!(s.candidates()[0].text, "词00"); - assert_eq!(s.candidates()[7].text, "拼一"); - assert_eq!(s.candidates()[8].text, "拼二"); - assert_eq!(s.candidates.len(), 23); - s.personalize(vec![], |c| if c.text == "词19" { 100 } else { 0 }); - assert_eq!(s.candidates()[0].text, "词19"); - assert_eq!(s.candidates()[7].text, "拼一"); + let texts = |s: &Session| -> Vec { + s.candidates().iter().map(|c| c.text.clone()).collect() + }; + assert_eq!(texts(&s)[0], "词00"); + assert_eq!(texts(&s)[7], "拼一"); + assert_eq!(texts(&s)[8], "拼二"); + + // 首选位保护:学了多少次都不能把当前首选挤下去(ADR-304)。 + s.personalize(vec![], |c| if c.text == "词01" { 100 } else { 0 }); + assert_eq!(texts(&s)[0], "词00"); + assert_eq!(texts(&s)[1], "词01"); + assert_eq!(texts(&s)[7], "拼一"); + + // 第三位学过一次也停在第二位,兜底位置不变。 + s.personalize(vec![], |c| if c.text == "词02" { 100 } else { 0 }); + assert_eq!(texts(&s)[0], "词00"); + assert_eq!(texts(&s)[1], "词02"); + assert_eq!(texts(&s)[2], "词01"); + assert_eq!(texts(&s)[7], "拼一"); + + // 翻页与原顺序无关;缩小每页后兜底仍在前两页之内。 s.page(1); - assert_eq!(s.candidates()[0].text, "词06"); + assert_eq!( + texts(&s)[0], + "词07", + "第二页从第 10 位开始(前两位已被学习改写)" + ); + s.page(-1); s.set_page_size(3); - assert_eq!(s.candidates()[0].text, "词19"); - assert_eq!(s.candidates()[1].text, "拼一"); - assert_eq!(s.candidates()[2].text, "拼二"); + assert_eq!(texts(&s)[0], "词00"); + assert_eq!(texts(&s)[1], "拼一", "缩小每页后兜底必须仍然可见"); assert_eq!(s.handle_key('2'), KeyOutcome::Committed("拼一".into())); } @@ -1013,7 +1074,9 @@ mod tests { } #[test] - fn wubi_single_order_ignores_learning_but_phrases_have_bounded_bonus() { + fn wubi_learning_promotes_singles_and_phrases_by_bounded_steps() { + // ADR-304 P1:单字与多字同一规则——学过至少一次上移 1 位、至少 4 次上移 2 位, + // 且任何候选都不得挤掉当前首选("首选位保护")。 let mut s = Session::new(InputMode::WubiOnly); let candidates = vec![ Candidate::new("甲", "a", 100, CandidateSource::CodeTable), @@ -1021,11 +1084,20 @@ mod tests { Candidate::new("常用词", "a", 80, CandidateSource::Phrase), Candidate::new("冷门词", "a", 1, CandidateSource::Phrase), ]; - s.personalize(candidates, |c| if c.text == "甲" { 0 } else { u32::MAX }); + s.personalize(candidates, |c| match c.text.as_str() { + "甲" => 0, + "乙" => 1, // 一次:上移 1 位,但首选位受保护 + "常用词" => 4, // 四次:上移 2 位 + _ => 9, // 冷门词:也是上移 2 位 + }); + let texts: Vec<_> = s.candidates().iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, ["甲", "冷门词", "常用词", "乙"]); + + // 单字确实会动(老实现 bonus 恒为 0,单字永远学不动)。 + s.personalize(vec![], |c| u32::from(c.text == "乙") * 4); let texts: Vec<_> = s.candidates().iter().map(|c| c.text.as_str()).collect(); - assert_eq!(texts, ["常用词", "甲", "乙", "冷门词"]); - s.personalize(vec![], |_| 0); - assert_eq!(s.candidates()[0].text, "甲"); + assert_eq!(texts[0], "甲", "首选位保护:学多少次都不越首位"); + assert_eq!(texts[1], "乙", "学过的单字上移到第二位"); } #[test] diff --git a/crates/ime-ffi/src/session.rs b/crates/ime-ffi/src/session.rs index 0f470172..161d6f08 100644 --- a/crates/ime-ffi/src/session.rs +++ b/crates/ime-ffi/src/session.rs @@ -1218,8 +1218,15 @@ mod tests { #[test] fn rare_single_choice_keeps_rank_after_backspace_paging_and_store_reload() { - let root = - std::env::temp_dir().join(format!("lampyris-fixed-single-{}", std::process::id())); + // 目录名带时间戳:上一次失败可能留下同 pid 的残留,导致学习次数不是从 0 开始。 + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lampyris-fixed-single-{}-{stamp}", + std::process::id() + )); std::fs::create_dir_all(&root).unwrap(); let path = root.join("user.tsv"); let mut table = Table::new(); @@ -1227,8 +1234,10 @@ mod tests { table.insert("ab", text, weight); } table.finalize(); + let mut selections = 0u32; for mode in [InputMode::WubiOnly, InputMode::Mixed] { - for _ in 0..3 { + for round in 0..3 { + let _ = round; let mut s = ImeSession::from_engines( mode, Some(Box::new(CodeTableEngine::new(table.clone(), 4))), @@ -1240,14 +1249,78 @@ mod tests { ime_session_key(&mut *s, key as u32); assert_eq!(s.session.candidates()[0].text, "甲"); } - ime_session_page(&mut *s, 1); - assert_eq!(s.session.candidates()[0].text, "龘"); - assert_eq!(ime_session_key(&mut *s, '1' as u32), IME_KEY_COMMITTED); + // ADR-304 P1:学习结果按固定步数上移(≥1 次升 1 位、≥4 次升 2 位, + // 首选位保护),所以它的位置随磁盘里的学习次数变化,而不是靠 weight+bonus 冲顶。 + let count = s + .dictionary + .as_ref() + .unwrap() + .entries() + .get(&("wubi".into(), "ab".into(), "龘".into())) + .copied() + .unwrap_or(0); + let steps = match count { + 0 => 0, + 1..=3 => 1, + _ => 2, + }; + let expected = 3usize.saturating_sub(steps).max(1); + assert_eq!(count, selections, "每次选择各学一次"); + if expected >= 3 { + ime_session_page(&mut *s, 1); + assert_eq!(s.session.candidates()[0].text, "龘"); + assert_eq!(ime_session_key(&mut *s, '1' as u32), IME_KEY_COMMITTED); + } else { + assert_eq!(s.session.candidates()[expected].text, "龘"); + assert_eq!( + ime_session_key(&mut *s, (b'1' + expected as u8) as u32), + IME_KEY_COMMITTED + ); + } assert_eq!(s.session.committed(), "龘"); + selections += 1; } } let store = ime_dict::Store::load(&path).unwrap(); assert!(store.entries()[&("wubi".into(), "ab".into(), "龘".into())] > 0); + + // ADR-304 P1:学习记录落盘后重新加载,生僻单字应按"有界位次上移"升一位, + // 从第二页升进首页第三格(老实现靠 weight+bonus,龘 权重 1 永远升不动)。 + let mut reloaded = ImeSession::from_engines( + InputMode::WubiOnly, + Some(Box::new(CodeTableEngine::new(table, 4))), + None, + ); + reloaded.dictionary = Some(store); + ime_session_set_page_size(&mut *reloaded, 3); + for key in ['a', 'b'] { + ime_session_key(&mut *reloaded, key as u32); + } + eprintln!( + "DEBUG counts={:?}", + reloaded.dictionary.as_ref().map(|d| d.entries().len()) + ); + eprintln!( + "DEBUG order={:?}", + reloaded + .session + .candidates() + .iter() + .map(|c| (c.text.clone(), c.code.clone())) + .collect::>() + ); + assert_eq!(reloaded.session.candidates()[0].text, "甲", "首选位保护"); + assert_eq!( + reloaded.session.candidates()[1].text, + "龘", + "学过 6 次:上移 2 位,但停在第二位" + ); + assert_eq!( + ime_session_key(&mut *reloaded, '2' as u32), + IME_KEY_COMMITTED + ); + assert_eq!(reloaded.session.committed(), "龘"); + drop(reloaded); std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/ime-table/src/lib.rs b/crates/ime-table/src/lib.rs index 8c676c53..6e46adb9 100644 --- a/crates/ime-table/src/lib.rs +++ b/crates/ime-table/src/lib.rs @@ -534,6 +534,27 @@ mod tests { .unwrap() } + #[test] + fn level1_code_is_an_exact_hit_and_never_auto_commits() { + // ADR-304 P0:一级简码以 1 字母入表后必须是「精确命中」—— + // 排在所有同前缀全码候选之前,且**不**触发自动上屏(自动上屏仍要求 4 码)。 + let mut table = Table::new(); + table.insert("g", "一", 217_830); + table.insert("gggg", "王", 19_704); + table.insert("gggg", "一", 400); + table.insert("ggll", "五", 3_000); + table.finalize(); + let engine = CodeTableEngine::new(table, 4); + let result = engine.convert("g", 9); + assert_eq!(result.candidates[0].text, "一"); + assert!(!result.candidates[0].is_prefix, "简码必须是精确命中"); + assert!(result.candidates[1].is_prefix, "其余仍是补全候选"); + assert_eq!(result.should_commit, None, "1 码不得自动上屏"); + + // 四码唯一仍然自动上屏,简码不改变这条既有行为。 + assert_eq!(engine.convert("ggll", 9).should_commit, Some("五".into())); + } + #[test] fn bounded_top_k_preserves_best_candidates_for_prefix_and_wildcard() { let mut table = Table::new(); diff --git a/crates/wubi-encoder/examples/generate_core.rs b/crates/wubi-encoder/examples/generate_core.rs index 4ccda86a..25ff4d60 100644 --- a/crates/wubi-encoder/examples/generate_core.rs +++ b/crates/wubi-encoder/examples/generate_core.rs @@ -11,6 +11,10 @@ //! - 单字码表:`data/generated/wubi86-single.tsv`(T-M1-05,`code\t字\tweight`); //! - 词组码表:`data/generated/wubi86-phrases.tsv`(T-M1-06b,`code\t词组\tweight`)。 //! +//! 简码(ADR-304):一级 25 条来自 `data/sources/wubi86-level1-codes.tsv`(D17), +//! 以"键位 = 码、字 = 文本、字在全码表里的权重"追加成码表行,使 1 字母输入成为**精确命中**。 +//! 不改 `auto_commit`(仍要求 4 码),简码必须空格或数字确认。 +//! //! 输出: //! - `data/generated/wubi86-core.tsv`(单字 + 精选词组,`code\ttext\tweight`); //! - `data/generated/wubi86-core-report.txt`(实测体积 + 裁剪规则 + 验收样本)。 @@ -42,6 +46,9 @@ const REQUIRED_PHRASES: &[(&str, &str)] = &[ ("中华人民共和国", "kwwl"), ]; +/// 一级简码键位表(D17,项目自建清单)。 +const LEVEL1_PATH: &str = "data/sources/wubi86-level1-codes.tsv"; + /// 内置核心必须包含的验收样本单字(码写死)。 const REQUIRED_SINGLES: &[(&str, &str)] = &[("你", "wqiy")]; @@ -84,6 +91,7 @@ fn main() -> Result<(), Box> { if singles.is_empty() { return Err("单字码表为空".into()); } + let level1 = parse_level1(&fs::read_to_string(root.join(LEVEL1_PATH))?, &singles)?; let domain = fs::read_to_string(root.join("data/domain-vocabulary.tsv"))?; let mut selected = supplement_phrases(&mut phrases, &singles, &domain)?; @@ -100,7 +108,7 @@ fn main() -> Result<(), Box> { let kept_phrases = top_phrases(&phrases, CORE_PHRASE_LIMIT, &selected); verify_required(&singles, &kept_phrases)?; - let core_tsv = render_core(&singles, &kept_phrases); + let core_tsv = render_core(&singles, &kept_phrases, &level1); let core_bytes = core_tsv.len(); if core_bytes > BUDGET_BYTES { return Err( @@ -155,6 +163,48 @@ fn parse_rows(input: &str, label: &str) -> Result, Box> { Ok(rows) } +/// 解析一级简码表并把每条转成码表行:`code` = 键位、`text` = 字、 +/// `weight` = 该字在全码表里的最大权重(简码组内只有它自己,权重只为保持刻度一致)。 +fn parse_level1(input: &str, singles: &[Row]) -> Result, Box> { + let mut weights: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + for row in singles { + let entry = weights.entry(row.text.as_str()).or_insert(0); + *entry = (*entry).max(row.weight); + } + let mut rows: Vec = Vec::new(); + let mut seen_key = std::collections::BTreeSet::new(); + for (idx, raw) in input.lines().enumerate() { + let line = raw.trim_start_matches('\u{feff}').trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split('\t'); + let key = fields.next().unwrap_or_default().trim(); + let text = fields.next().unwrap_or_default().trim(); + if key.is_empty() || text.is_empty() || fields.next().is_some() { + return Err(format!("一级简码表第 {} 行格式非法:{line}", idx + 1).into()); + } + if key.chars().count() != 1 || text.chars().count() != 1 { + return Err(format!("一级简码必须是单键单字:{line}").into()); + } + if !seen_key.insert(key.to_string()) { + return Err(format!("一级简码键位重复:{key}").into()); + } + let weight = *weights + .get(text) + .ok_or_else(|| format!("一级简码的字不在全码表里:{text}"))?; + rows.push(Row { + code: key.to_string(), + text: text.to_string(), + weight, + }); + } + if rows.len() != 25 { + return Err(format!("一级简码必须是 25 条,实际 {} 条", rows.len()).into()); + } + Ok(rows) +} + /// 全局按词频降序取前 `limit` 条(等权按 code、text 升序,可复现)。 fn top_phrases( phrases: &[Row], @@ -257,8 +307,12 @@ fn find<'a>(rows: &'a [Row], text: &str) -> Option<(&'a str, u32)> { } /// 渲染核心码表:单字 + 精选词组,`code` 升序、同码内 `weight` 降序。 -fn render_core(singles: &[Row], phrases: &[Row]) -> String { - let mut rows: Vec<&Row> = singles.iter().chain(phrases.iter()).collect(); +fn render_core(singles: &[Row], phrases: &[Row], level1: &[Row]) -> String { + let mut rows: Vec<&Row> = singles + .iter() + .chain(phrases.iter()) + .chain(level1.iter()) + .collect(); rows.sort_by(|a, b| { a.code .cmp(&b.code) @@ -283,6 +337,11 @@ fn render_core(singles: &[Row], phrases: &[Row]) -> String { out, "# 单字来源:WindInput(MIT,D3);词组/词频:jieba(MIT,D10)及自编组合词(CC-BY-SA-4.0,D16)。" ); + let _ = writeln!( + out, + "# 简码:一级 {} 条(D17,自建清单 CC-BY-SA-4.0,ADR-304),权重取该字在全码表里的权重。", + level1.len() + ); let _ = writeln!(out, "# code\ttext\tweight"); for row in rows { let _ = writeln!(out, "{}\t{}\t{}", row.code, row.text, row.weight); diff --git a/data/generated/wubi86-core-report.txt b/data/generated/wubi86-core-report.txt index ea3d86cb..7dbf693f 100644 --- a/data/generated/wubi86-core-report.txt +++ b/data/generated/wubi86-core-report.txt @@ -12,7 +12,7 @@ phrase_rows_kept = 20189 [size] core_rows = 48012 -core_bytes = 666315 +core_bytes = 666746 budget_bytes = 800000(0.8 MB) within_budget = yes diff --git a/data/generated/wubi86-core.tsv b/data/generated/wubi86-core.tsv index f3892589..f7ac7176 100644 --- a/data/generated/wubi86-core.tsv +++ b/data/generated/wubi86-core.tsv @@ -2,7 +2,9 @@ # 由 crates/wubi-encoder/examples/generate_core.rs 生成(T-M1-09)。 # 裁剪规则:单字全量保留;前 20000 基础词组+D15训练词+D16组合词;已有频次不变,新组合权重1。 # 单字来源:WindInput(MIT,D3);词组/词频:jieba(MIT,D10)及自编组合词(CC-BY-SA-4.0,D16)。 +# 简码:一级 25 条(D17,自建清单 CC-BY-SA-4.0,ADR-304),权重取该字在全码表里的权重。 # code text weight +a 工 3771 aaaa 工 3771 aaaa 恭恭敬敬 427 aaaa 㠭 1 @@ -2724,6 +2726,7 @@ ayym 荆州市 3082 ayyn 荒谬 411 ayys 共享文档 1 ayyv 荒唐 448 +b 了 883634 baag 陈艺戈 256 baat 䧖 1 badw 陿 1 @@ -3661,6 +3664,7 @@ byxg 防线 844 byyj 防腐剂 330 byym 随州市 1304 byyw 防腐 297 +c 以 136106 caaf 巴基斯坦 1238 caaf 巴勒斯坦 898 caah 駴 1 @@ -4517,6 +4521,7 @@ cyxy 𫠊 1 cyy 馼 1 cyy 𫘜 1 cyyi 㕚 1 +d 在 727915 daal 礷 1 daat 碊 1 dadi 石英砂 311 @@ -6802,6 +6807,7 @@ dyym 太庙 3798 dyys 矿床 883 dyyv 太康 1129 dyyv 太郎 295 +e 有 423765 eaat 䏼 1 eadt 臓 1 eadt 臟 1 @@ -7786,6 +7792,7 @@ eyxf 膟 1 eyxy 臃 61 eyxy 胘 1 eyyp 﨤 1 +f 地 160541 faaq 示范区 2096 faaw 载荷 430 fabb 老莱子 257 @@ -10367,6 +10374,7 @@ fyy 坟 1159 fyym 南京市 2046 fyym 二郎庙 266 fyyv 戴证良 256 +g 一 217830 gaaa 形式 15188 gaaa 开工 874 gaaa 型式 433 @@ -12950,6 +12958,7 @@ gyxf 㻭 1 gyxy 玹 301 gyy 玟 3 gyyx 死亡率 516 +h 上 258101 haag 虐 152 haak 䁥 1 haas 瞸 1 @@ -13958,6 +13967,7 @@ hywf 㱖 1 hywf 睟 1 hyxy 眩 143 hyy 盿 1 +i 不 360331 iaah 㳦 1 iaai 江东 374 iaal 江苏 3337 @@ -16711,6 +16721,7 @@ iyyn 流亡 557 iyyn 游记 370 iyyt 流放 318 iyyy 流言 346 +j 是 796991 jaak 暱 679 jaas 蠂 1 jaat 䗃 1 @@ -18122,6 +18133,7 @@ jyxy 昡 1 jyxy 蚿 1 jyy 蚊 295 jyy 旼 4 +k 中 243191 kaah 咬牙切齿 479 kaah 㖑 1 kaai 中医药大学 279 @@ -19965,6 +19977,7 @@ kyxy 噰 25 kyxy 呟 3 kyy 呅 1 kyym 鄂州市 1553 +l 国 29996 laag 轼 9 laag 軾 1 laat 輚 1 @@ -21214,6 +21227,7 @@ lyve 䡙 1 lyxu 䍗 1 lyym 恩施市 1037 lyyt 恩施州 522 +m 同 37578 maad 周芷若 815 maah 典雅 390 maai 典范 316 @@ -22724,6 +22738,7 @@ mywo 鴅 1 myxy 䝮 1 myy 㞶 1 myyn 同义词 824 +n 民 6640 naaa 民工 558 naae 㞡 1 naag 恜 5 @@ -24274,6 +24289,7 @@ nyyq 心底 804 nyyw 昼夜 887 nyyy 民主主义 652 nyyy 民族主义 575 +o 为 295952 oaag 烒 1 oadn 烧成 215 oadn 煁 16 @@ -25212,6 +25228,7 @@ oyxx 䊌 1 oyxy 炫 166 oyy 炆 165 oyyy 灬 1 +p 这 261791 paai 空荡荡 243 padd 宽大 549 pagk 宽带 259 @@ -26860,6 +26877,7 @@ pyyt 神话 1378 pyyt 福州 897 pyyt 神州 422 pyyw 社论 244 +q 我 328841 qaag 鉽 1 qaai 错落 347 qaas 鐷 1 @@ -29773,6 +29791,7 @@ qyxy 鉉 1 qyy 鈫 1 qyyg 岛主 316 qyyo 留恋 211 +r 的 318825 raag 拭 327 raas 擛 1 rab 皂 285 @@ -32193,6 +32212,7 @@ ryyb 撤离 400 ryyc 扩充 584 ryyg 邀请 3258 ryyy 抗议 1222 +s 要 156581 saaa 模式 3809 saag 栻 19 saah 械 776 @@ -34424,6 +34444,7 @@ syyg 贾亦斌 259 syyi 想方设法 245 syyt 杭州 2806 syyu 述说 215 +t 和 555815 taad 长期 8664 taar 箋 1 taat 㣤 1 @@ -37710,6 +37731,7 @@ tyyl 入库 334 tyym 重庆市 806 tyym 入市 701 tyyy 程序设计 28 +u 产 6838 uabb 瓶子 309 uabh 郱 1 uabn 辛苦了 1 @@ -40020,6 +40042,7 @@ uyyt 凉州 205 uyyw 辩论 474 uyyw 门诊 417 uyyw 辨认 399 +v 发 16840 vaak 嫟 22 vaan 嬺 1 vabg 媶 2 @@ -41194,6 +41217,7 @@ vyvy 娘娘 972 vyxl 㜅 1 vyxy 妶 6 vyy 妏 13 +w 人 313209 waag 侙 1 waah 㑘 1 waai 任茂东 256 @@ -44000,6 +44024,7 @@ wyyu 俗话说 442 wyyw 今夜 753 wyyy 雠 118 wyyy 讎 1 +x 经 21042 xaal 红薯 473 xaal 练功 391 xaal 红莲 267 @@ -45447,6 +45472,7 @@ xyy 紋 1 xyyc 编译 343 xyyf 统计 5381 xyyx 统率 775 +y 主 14838 yaag 试 3723 yaag 試 1 yaah 诫 190 diff --git a/data/sources.toml b/data/sources.toml index e50b877c..2008a10d 100644 --- a/data/sources.toml +++ b/data/sources.toml @@ -219,11 +219,11 @@ notes = "项目独立编写433项;292训练项参与已有来源选词,69探 [[derived]] id = "wubi-core-v2" path = "data/generated/wubi86-core.tsv" -source_ids = ["D3", "D10", "D15", "D16"] +source_ids = ["D3", "D10", "D15", "D16", "D17"] license = "CC-BY-SA-4.0" -sha256 = "1b403b85a10f53c7061870c2b4a96de68023cdfdcc7fdd6c9605f5225c38deaa" +sha256 = "20779afb4e8c8f89c8d195a009b64a7f9e6aca2fcd245a7c369b4ad79b359425" generator = "crates/wubi-encoder/examples/generate_core.rs" -notes = "ADR-227/237;全量单字+前20000条基础词组+D15训练词+D16自编组合词;保留集不参与生成,TSV预算800000字节" +notes = "ADR-227/237/304;全量单字+前20000条基础词组+D15训练词+D16自编组合词;保留集不参与生成,TSV预算800000字节;含一级简码 25 条(D17)" [[source]] id = "D16" @@ -237,3 +237,16 @@ sha256 = "46000fd6c78629f9402f2dbbb449350be137db1a8b03ddeb9194aa9c3fdedbe6" used_for = ["bounded-domain-vocabulary", "known-vocabulary-regression"] added_at = "2026-10-21" notes = "ADR-237;自编132项及逐字读音,未读取保留集选词;已有权重不变,新组合权重1不代表统计频次;五笔码由现有编码器生成" + +[[source]] +id = "D17" +name = "五笔86 一级简码键位表(项目自建清单)" +path = "data/sources/wubi86-level1-codes.tsv" +url = "https://github.com/EasyIndie/EasyInputMethod/blob/main/data/sources/wubi86-level1-codes.tsv" +upstream = "EasyIndie/EasyInputMethod contributors" +license = "CC-BY-SA-4.0" +risk = "low" +sha256 = "0045deb812fd3bf0a54a753caaf98e26015d432123de78be2982323183b8962c" +used_for = ["wubi86-level1-code-rows", "candidate-order-evaluation"] +added_at = "2026-11-05" +notes = "ADR-304;一级简码是公开标准事实,按标准逐条转写,不含第三方文件内容;二级 625 条待登记后另行加入" diff --git a/docs/reviews/228-short-codes-and-bounded-promotion.md b/docs/reviews/228-short-codes-and-bounded-promotion.md new file mode 100644 index 00000000..ef6137e0 --- /dev/null +++ b/docs/reviews/228-short-codes-and-bounded-promotion.md @@ -0,0 +1,59 @@ +# 228 — 一级简码入表 + 有界位次上移(1.1.0 实施) + +- 日期:2026-11-05 | 基线:`tools/eval/baselines/1.0.2-build37.json`(报告 227) +- 关联:ADR-304、报告 224(诊断)、`docs/MVP后执行计划.md` C 批 + +## 1. 这次改了什么(两处,同属"候选顺序"一个行为面) + +### 1.1 P0:一级简码入表(数据 + 生成器) + +| 项 | 内容 | +|---|---| +| 数据 | `data/sources/wubi86-level1-codes.tsv`(**D17**,项目自建清单,CC-BY-SA-4.0,25 条) | +| 生成 | `generate_core.rs` 追加 25 行:`code = 键位`、`text = 字`、`weight = 该字在全码表里的权重` | +| 校验 | `tools/check-level1-codes.py`:25 条 / 键位唯一 / 字唯一 / 全码一致 / 生成结果含 25 条 / 核心表 ≤2 码行数 = 25 | +| 核心 | **零改动**——靠既有 `Rank = (code == input, weight, …)` 的"精确优先于补全" | +| 自动上屏 | **未改**:仍要求输入长度 = 4,简码必须空格或数字确认(五笔习惯) | + +### 1.2 P1:`personalize` 改"有界位次上移" + +- 删掉 `bonus = learned.min(32)`(多字)与 `bonus = 0`(单字)两条分支:学习不再叠加到静态权重上。 +- 新规则:先按静态权重排序 → 学过的候选按固定步数上移(≥1 次 1 位、≥4 次 2 位)。 +- **首选位保护**:最多升到第 2 位,永不挤掉当前首选。这比 ADR 原文更保守——首选位是老手肌肉记忆的锚点, + 报告 224 §2.2 的业界做法(五笔系"首选位保护")也这么要求。 +- **不跨分块**:精确 / 补全 / 模糊三类各自成块,上移不得跨块,否则"学过的补全候选"会压到精确命中前面, + 破坏 `is_prefix` 的硬约束(该约束由 `exact_wubi_precedes_high_frequency_completion_on_every_path` 守着)。 + +## 2. 前后对比(同一套 200 条样本、同一张码表生成链) + +| 类别 | 命中率 | 首选率 | 平均位次 | 按键/条 | +|---|---|---|---|---| +| **jianma1(一级简码 25)** | 56% → **100%** | 32% → **100%** | 1.14 → **0.00** | 2.24 → **2.00** | +| jianma2(二级规则 30) | 100% → 100% | 93.3% → 93.3% | 0.07 → 0.07 | 3.07 → 3.07 | +| single(单字全码 50) | 100% → 100% | 100% → 100% | 0.00 → 0.00 | 4.46 → 4.46 | +| word2 / word2v / word34 | 不变 | 不变 | 不变 | 不变 | +| sentence(17) | 70.6% → 70.6% | 70.6% → 70.6% | 0.00 → 0.00 | 11.00 → 11.00 | +| **ALL(200)** | 92.0% → **97.5%** | 85.5% → **94.0%** | 0.11 → **0.04** | 4.47 → **4.44** | + +结论:**只动该动的那一格,其余零劣化**。`jianma1` 的 14 条未命中(用户得改打全码)全部变成一键首选。 + +## 3. 测试 + +| 用例 | 覆盖 | +|---|---| +| `level1_code_is_an_exact_hit_and_never_auto_commits`(ime-table,新) | 简码排在所有同前缀全码候选之前、`is_prefix=false`、1 码不自动上屏、4 码唯一仍自动上屏 | +| `wubi_learning_promotes_singles_and_phrases_by_bounded_steps`(重写自 `wubi_single_order_ignores_learning_but_phrases_have_bounded_bonus`) | 单字与多字同规则;上移 1/2 位;首选位保护 | +| `exact_wubi_precedes_high_frequency_completion_on_every_path`(旧,未改) | 上移不跨精确/补全分块 | +| `mixed_fallback_stays_visible_after_learning_and_page_size_changes`(重写) | 混合输入兜底位置在学**习后与页大小变化后仍在前两页 | +| `rare_single_choice_keeps_rank_after_backspace_paging_and_store_reload`(重写) | 生僻单字学过一次真的会动;落盘重载后按步数上移;首选位保护 | + +## 4. 未做 / 风险 + +- **二级 625 条未做**:需要单独登记来源与 SHA(D17-bis)。未就绪就不上,避免半成品上线;`jianma2` 仍是前缀行为, + 基线 93.3% 首选的现状不变(评测里已单列,便于将来对比)。 +- **学习曲线未建模**:评测是冷启动(不喂选择序列),P1 的收益靠单测 + 装机手感确认。 +- 词/句类别的 `sentence` 70.6% 是码表覆盖问题,与本次改动无关。 + +## 5. 下一步(1.1.0 出包后) + +装机三天自用(计划 §4 的轻量口径)+ IT-25 原生验收 + IT-22 受影响行复跑 + 升级闭环,四项一起换一次包。 diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index e6d4b7aa..9a3ea785 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -2759,7 +2759,15 @@ ## ADR-304:简码层入表 + 个人化改为"有界位次上移" - **编号**:304 -- **状态**:方案已定,**待 v1.0.0 发布后实施**(IT-23 试用期换包即作废,ADR-298)。诊断见报告 224。 +- **状态**:**P0(一级简码)+ P1(有界位次上移)已实施**,随 1.1.0 发布;**二级 625 条未做**(D17 只登记了一级, + 二级需单独登记来源与 SHA,未就绪就不上半成品,见下文"数据合规")。诊断见报告 224,基线与前后对比见报告 227/228。 +- **实施记录**: + - 数据:`data/sources/wubi86-level1-codes.tsv`(D17,25 条)→ `generate_core.rs` 追加 `code=键位, text=字, weight=该字全码权重`; + 校验脚本 `tools/check-level1-codes.py`(25 条 / 键位唯一 / 字唯一 / 全码一致 / 生成结果含 25 条)。 + - 核心:`personalize` 改为"静态排序 → 学习步进上移",**新增首选位保护**——学过的候选最多升到第 2 位, + 永不挤掉当前首选(比原方案更保守,理由:首选位是老手肌肉记忆的锚点)。 + - 上移不跨"精确/补全/模糊"分块,否则学过的补全候选会压到精确命中前面,破坏 `is_prefix` 硬约束。 + - 评测:`jianma1` 命中率 56%→100%、首选率 32%→100%、按键 2.24→2.00,其余类别零劣化(报告 228)。 - **背景**:用户报「单字/两字词排序不跟手,常用字总在别处,有时打错」。实测根因有三层: ① 码表无 ≤2 码行(`wubi86-single.tsv` 中码长 ≤2 的行数 = 0),一级/二级简码根本不存在,单字母输入退化为"前缀 + jieba 词频",25 个一级简码只有 **8 个**能排到首选(`q` → 多/外/名,而「我」全码 `trnt` 不在 `q` 组); ② `personalize` 里单字 `bonus` 硬编码为 0,反复选择永不前移; diff --git "a/docs/\346\212\200\346\234\257\346\226\271\346\241\210.md" "b/docs/\346\212\200\346\234\257\346\226\271\346\241\210.md" index bd690dc6..61d4c719 100644 --- "a/docs/\346\212\200\346\234\257\346\226\271\346\241\210.md" +++ "b/docs/\346\212\200\346\234\257\346\226\271\346\241\210.md" @@ -234,7 +234,7 @@ pub struct Candidate { |---|---| | 单字码表 | `code(≤4) → [字 + 权重]`;**排序数组**(精确二分 + 前缀区间,ADR-060) | | 词组码表 | 2–4+ 字词,规则生成 | -| 简码 | 一级(25)/二级(625)/三级 | +| 简码 | 一级(25) **已入表**(D17/ADR-304,1 键精确命中、需空格确认);二级(625) 待登记;三级按全码走 | | 键名字 / 成字字根 | 特殊规则 | | 前缀提示 | 输入 1–3 码时预览 | | 四码唯一自动上屏 | 可配置 | @@ -440,7 +440,7 @@ ADR-084将产品MVP定义为首个macOS正式分发就绪版本。下列基础Do | 键名字 | 每键代表字,连按四次打出 | | 成字字根 | 本身是字的字根:"键名+首笔+次笔+末笔" | | 末笔字型识别码 | 不足四码时补"末笔笔画 + 字型结构" | -| 简码 | 一/二/三码直出的高频字 | +| 简码 | 一/二/三码直出的高频字(一级已入表,见 §6.2) | | 词组码 | 2字=各前2码;3字=1首2首3首3次;4字+=1首2首3首末首 | | 混输 | 同一串输入用两种引擎解,合并候选 | | 真值唯一 | 配置只有一个持久化来源(见 `配置架构.md`) | diff --git "a/docs/\346\225\260\346\215\256\346\235\245\346\272\220\344\270\216\350\256\270\345\217\257.md" "b/docs/\346\225\260\346\215\256\346\235\245\346\272\220\344\270\216\350\256\270\345\217\257.md" index 2c57cc70..478f67cc 100644 --- "a/docs/\346\225\260\346\215\256\346\235\245\346\272\220\344\270\216\350\256\270\345\217\257.md" +++ "b/docs/\346\225\260\346\215\256\346\235\245\346\272\220\344\270\216\350\256\270\345\217\257.md" @@ -36,6 +36,7 @@ Rime 生态长期存在"词典许可不明"的灰区:很多五笔/拼音码表 | D11 | OpenCC 简→繁 **单字表**(STCharacters) | 简繁转换 | `BYVoid/OpenCC`(固定 commit `8cf737a4`) | BYVoid/OpenCC | **Apache-2.0** | 低 | ✅ 已登记(T-P2-01) | | D12 | OpenCC 简→繁 **词组表**(STPhrases) | 简繁转换(词组消歧) | `BYVoid/OpenCC`(固定 commit `8cf737a4`) | BYVoid/OpenCC | **Apache-2.0** | 低 | ✅ 已登记(T-P2-01) | | D16 | 常用聊天/工作/开发组合词及逐字读音 | 小批系统词库补充、已知覆盖回归 | **项目自编** `data/domain-vocabulary.tsv` | EasyInputMethod contributors | **CC-BY-SA-4.0** | 低 | 已登记(ADR-237,132项) | +| D17 | 五笔86 **一级简码键位表**(25 条) | 简码层入表(1 键精确命中) | **项目自编清单** `data/sources/wubi86-level1-codes.tsv` | EasyInputMethod contributors | **CC-BY-SA-4.0** | 低 | 已登记(ADR-304,25条;二级 625 条待登记) | > ⚠️ **明确排除**(除非 GPL-3 兼容性单独确认): > - `rime/rime-wubi`(**LGPL-3.0**)——本项目 GPL-3 后**可用**(已接入为 **D9 验证 oracle #2**,见 §1.4),但不作为首选,优先自建。 diff --git a/packaging/build-number b/packaging/build-number index 81b5c5d0..e522732c 100644 --- a/packaging/build-number +++ b/packaging/build-number @@ -1 +1 @@ -37 +38 diff --git a/tools/check-level1-codes.py b/tools/check-level1-codes.py new file mode 100755 index 00000000..f6396278 --- /dev/null +++ b/tools/check-level1-codes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""校验一级简码键位表(D17)与生成结果。 + +ADR-304 要求:**25 条、键位唯一、字唯一、字在全码表内、码长一致**。 +`make verify` 之外的独立脚本,便于改表后单独跑(生成器本身也会拒绝坏表, +这个脚本让"表本身对不对"和"生成出来对不对"两件事都能一眼看到)。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LEVEL1 = ROOT / "data/sources/wubi86-level1-codes.tsv" +SINGLE = ROOT / "data/generated/wubi86-single.tsv" +CORE = ROOT / "data/generated/wubi86-core.tsv" + +EXPECTED_KEYS = set("gfdsahjklmtrewqyuiopnbvcx") + + +def rows(path: Path) -> list[list[str]]: + out = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + out.append(line.split("\t")) + return out + + +def main() -> int: + problems: list[str] = [] + + level1 = rows(LEVEL1) + if len(level1) != 25: + problems.append(f"一级简码应为 25 条,实际 {len(level1)} 条") + + keys = [r[0] for r in level1] + chars = [r[1] for r in level1] + if len(set(keys)) != len(keys): + problems.append("键位有重复") + if len(set(chars)) != len(chars): + problems.append("字有重复") + if set(keys) != EXPECTED_KEYS: + problems.append(f"键位集合不符:多 {sorted(set(keys) - EXPECTED_KEYS)} 少 {sorted(EXPECTED_KEYS - set(keys))}") + for key, char in zip(keys, chars): + if len(key) != 1 or len(char) != 1: + problems.append(f"必须是单键单字:{key!r} → {char!r}") + + single_codes: dict[str, list[str]] = {} + for code, text, _weight in rows(SINGLE): + single_codes.setdefault(text, []).append(code) + for char in chars: + codes = single_codes.get(char) + if not codes: + problems.append(f"字不在全码表里:{char}") + elif not all(2 <= len(c) <= 4 for c in codes): + # 五笔86 全码有 3 码字(如 地=fbn)与 4 码字;1 码只应来自简码层本身。 + problems.append(f"字的全码长度非法:{char} → {codes}") + + core_rows = rows(CORE) + core_index = {(code, text) for code, text, _weight in core_rows} + for key, char in zip(keys, chars): + if (key, char) not in core_index: + problems.append(f"生成结果缺少简码行:{key} → {char}") + short = [r for r in core_rows if len(r[0]) <= 2] + if len(short) != 25: + problems.append(f"核心码表中 ≤2 码的行应为 25,实际 {len(short)}") + + if problems: + for p in problems: + print(f"❌ {p}") + return 1 + print(f"✅ 一级简码表通过:{len(level1)} 条,键位与字均唯一,全码一致,生成结果包含全部 25 条") + return 0 + + +if __name__ == "__main__": + sys.exit(main())