Merge pull request #592 from metasequoiaime/feat/quanpin-autocorrect-context-ranking

feat(engine,server): 让输入法靠上下文猜错字
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-04 15:48:38 +08:00
commit e46029fe91
11 files changed
+1078 -40

No files matched your search

+8 -3
View File
@@ -70,9 +70,11 @@ std::optional<std::vector<std::size_t>> RescoreWorker::order_for(const SentenceM
const auto active = active_keys_.find(&model);
if (active != active_keys_.end() && active->second == key)
{
// 正在算的就是这一批,算完自会进表并回调。这也是该模型最新的请求,排在它后面的旧待办
// 已经没人要了(用户打出去又退了回来),一并丢掉。
pending_.erase(&model);
// 正在算的就是这一批,算完自会进表并回调,所以不必再排一份。
// 刻意不碰 pending_:这一批在算的这些工夫里,用户完全可能已经打到了更新的输入,
// pending_ 里那个才是当前请求。抹掉它就再没有人排它了——本次调用拿到 nullopt 也
// 不会有回调,失败模式是那次重排永久缺失,而不只是延迟。pending_ 按模型留一格、
// 下次 order_for 直接覆盖,本来就不需要这里清理。
return std::nullopt;
}
Job job;
@@ -130,6 +132,9 @@ void RescoreWorker::run()
Job job;
{
std::unique_lock<std::mutex> lock(mutex_);
// 这里按模型判「有没有空槽」就够,不必比键:待办是在下面取走的那一刻才从表里 erase 的,
// 在那之前同一个模型的键只挂在 active_keys_ 上,两张表不可能记着同一个模型同一批——否则
// 这个谓词会把正在算的那一批重排一遍。按键去重的那一层判断在 order_for 里。
wake_.wait(lock, [this] {
return stopping_ || std::any_of(pending_.begin(), pending_.end(), [this](const auto &item) {
return active_keys_.find(item.first) == active_keys_.end();
+165 -1
View File
@@ -10,6 +10,7 @@
#include <climits>
#include <cstring>
#include <fmt/format.h>
#include <unordered_map>
#include <unordered_set>
#include <utf8/cpp17.h>
@@ -32,6 +33,18 @@ constexpr size_t kAlternativeSegmentationFirstPageSize = 6;
// 将(2.63M) 同样被拒。
constexpr std::int64_t kAlternativeSegmentationPromotionRatio = 100;
// 同档纠错读法的上下文消解(阶段 1 design):同档备选切分各自过一遍完整管线,
// 跨切分比较词格路径分(log10×1000,同档等罚,分差即纯上下文差)。胜出切分边际
// 达标时接管「主切」角色领衔候选;不达标或词格不可用时与现状逐位一致。
// 解码预算:只解通过静态门槛(上面 1/100 同款)且词库有行的备选,上限 4 个——
// uanli 这类掉声母输入有 10-15 个等权目标,全解会伤 p95。
constexpr size_t kAutocorrectContextDecodeLimit = 4;
// 接管边际:以打分器自身的对数单位计——词格 LM 打分是 log10(1.0 ≈ 10× 句概率),
// 无 LM 的启发式回退是 ln(1.0 ≈ e≈2.7×)。对标 CN105045778B 的 β 型先验比率边际
// (同音校对 β=0.01 → 2.0),手误场景放宽一档。ponytail: 朴素常数;校准路径 =
// user_journal 挖混淆先验(方向阶段 3)。
constexpr double kAutocorrectContextMarginLog = 1.0;
bool is_alpha_vk(ImeKeyCode vk)
{
return vk >= 'A' && vk <= 'Z';
@@ -163,6 +176,10 @@ SeriesQueryResolution resolve_series_query(const std::string &raw_input, const q
}
};
// 静态优先语义由 k-best 搜索自身保证(见 quanpin_utils.h 契约):最优
// 切分为纯静态时生成切分已在搜索内被丢弃,下面的 head+简拼尾组合路径
// 与 k=1 预编辑投影等调用方同享该保证;生成读法只在静态空间完全无法
// 解释输入时出场。混表竞争的加权仲裁依赖训练权重,留给方向阶段 3。
const auto cuts = quanpin::autocorrect_cut_kbest(raw_input, autocorrect_types, kAutocorrectCutKBest);
if (!cuts.empty())
{
@@ -394,10 +411,157 @@ std::vector<WordItem> QuanpinDictionary::query_exact(const std::string &raw_inpu
const std::string fallback_segmentation =
segmentation.empty() ? quanpin::join_segments(segments) : segmentation;
append_unique_words(result, query_series(raw_input, fallback_segmentation, segments));
// 同档上下文消解(阶段 1):词格开启时直接解出主切与同档备选的最佳路径分
// 裁决,静态词频只承担解码前置过滤。不能从候选列表读分——
// merge_lattice_candidates 会把与已有候选同词的生成行去重掉(already 集),
// 最优路径通常恰好就是词库首行,列表里根本看不到它。词格不可用或边际不足
// 时,下面的一切与改造前逐位一致(降级矩阵见任务 design.md)。
bool context_reordered = false;
if (sentence_association_.word_lattice && !resolution.alternative_corrected_cuts.empty() &&
resolution.corrected_segments.size() >= 2 &&
quanpin::has_only_complete_pinyin_segments(resolution.corrected_segments))
{
// 与 query_series 的装配保持一致:collocation 项同样参与路径分。
quanpin::WordLatticeOptions lattice_options;
lattice_options.language_model = language_model_;
if (collocation_db_ != nullptr && collocation_db_->valid() &&
sentence_association_.collocation_weight != 0.0)
{
lattice_options.collocation_scorer = [db = collocation_db_](std::string_view tail,
std::string_view word, bool is_rear) {
return db->query(std::string(tail), std::string(word), is_rear, gram::GrammarConfig{});
};
lattice_options.collocation_weight = sentence_association_.collocation_weight;
}
const auto lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, lattice_options.span_limit);
const auto best_path_score = [&lookup,
&lattice_options](const quanpin::Segments &cut) -> std::optional<double> {
if (cut.size() < 2 || !quanpin::has_only_complete_pinyin_segments(cut))
{
return std::nullopt;
}
const auto cut_paths = quanpin::decode_word_lattice(cut, lookup, lattice_options);
return cut_paths.empty() ? std::nullopt : std::optional<double>(cut_paths.front().log_prob);
};
const auto primary_score = best_path_score(resolution.corrected_segments);
if (primary_score.has_value())
{
// 前置过滤沿用保护位同款 1/100 量级:今天就赢不了晋升的备选不付解码钱。
// 基准必须与 merge_alternative_segmentations 的 primary_top_weight 同源,
// 即取正读**整键**命中的首位权重、没有整键行时取 0。不能取 result.front():
// result 是 query_series 的完整输出,头部可能是高权重前缀单字(真实词库里
// 单字是 1e6+ 的语料计数,见 heuristic_log_prob)或生成的整句。正读两音节
// 在词库里没有整词、词格只能用单字拼出路径时,那正是重排价值最高的场景,拿单字
// 当基准会在解码之前就把正常备选全筛掉,重排悄悄不生效。同参调用
// query_single_path 走缓存,不额外查库。
const std::string primary_key = quanpin::join_segments(resolution.corrected_segments);
const auto primary_full = query_single_path(raw_input, primary_key, resolution.corrected_segments);
const std::int64_t primary_top_weight = primary_full.empty() ? 0 : primary_full.front().weight;
const auto alternative_items = quanpin::query_exact_segmentations_keyed_flat(
resolution.alternative_corrected_cuts, db_, statement_cache_,
kAlternativeSegmentationCandidateLimit);
// 每个备选切分的最佳词权重:行按权重降序返回,emplace 只留首行。
std::unordered_map<std::string, std::int64_t> best_weight_by_key;
best_weight_by_key.reserve(alternative_items.size());
for (const auto &item : alternative_items)
{
best_weight_by_key.emplace(item.key, item.weight);
}
std::vector<std::pair<std::int64_t, quanpin::Segments>> decode_pool;
for (const auto &cut : resolution.alternative_corrected_cuts)
{
const auto found = best_weight_by_key.find(quanpin::join_segments(cut));
if (found == best_weight_by_key.end() ||
found->second * kAlternativeSegmentationPromotionRatio < primary_top_weight)
{
continue;
}
decode_pool.emplace_back(found->second, cut);
}
std::stable_sort(decode_pool.begin(), decode_pool.end(),
[](const auto &lhs, const auto &rhs) { return lhs.first > rhs.first; });
// 第一遍只解路径分选胜者,只为胜出切分付完整管线的钱。
std::optional<double> winner_score;
std::int64_t winner_top_weight = 0;
quanpin::Segments winner_segments;
for (size_t i = 0; i < decode_pool.size() && i < kAutocorrectContextDecodeLimit; ++i)
{
const auto &cut = decode_pool[i].second;
if (const auto cut_score = best_path_score(cut);
cut_score.has_value() && (!winner_score.has_value() || *cut_score > *winner_score))
{
winner_score = cut_score;
winner_top_weight = decode_pool[i].first;
winner_segments = cut;
}
}
// 用户在主切读音上的选择优先于上下文。调频只改词库权重,而加载 LM 时
// 路径分里的权重只剩 dictionary_tiebreak(≤0.07)和 reading_prior,翻不过
// 1.0 的边际:不拦的话,用户每次都选班长,办证系照样每次领衔。调频把选中词
// 抬到它越过的词之上,所以「主切整键首位权重不低于胜出切分首位」正是用户已经
// 把主切读音选到前面的样子;再要求主切键有用户 upsert,把这条限制在用户
// 表达过偏好的键上,出货词频本身仍交给上下文裁决。不接管时走下面的静态
// 合并,它按权重排序,调频照常生效。只在边际达标后才查日志,不给每次按键加查询。
const auto user_prefers_primary = [&] {
return primary_top_weight >= winner_top_weight &&
user_dictionary::has_user_upsert_for_key(
metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, primary_key);
};
if (winner_score.has_value() && *winner_score - *primary_score >= kAutocorrectContextMarginLog &&
!user_prefers_primary())
{
// 边际达标:胜出切分领衔。merge_alternative_segmentations 假定传入
// result 的头部是主切词库行,所以这里仍以主切为锚组装其余列表(从
// 合并池摘掉胜出切分),再把胜出列表接到最前。胜出列表占满首页时,
// 原保护位钉的名次随整体后移——首页本就该是胜出读音的词。
std::vector<quanpin::Segments> remaining_alternatives;
remaining_alternatives.reserve(alternative_segmentations.size());
const std::string winner_key = quanpin::join_segments(winner_segments);
for (const auto &alternative : alternative_segmentations)
{
if (quanpin::join_segments(alternative) != winner_key)
{
remaining_alternatives.push_back(alternative);
}
}
std::vector<WordItem> rest =
merge_alternative_segmentations(raw_input, pinyin_segmentation_, resolution.corrected_segments,
remaining_alternatives, std::move(result));
// query_series 的 count 从整键递减到 1,返回的不只是胜出切分的整键行,还有它
// 首音节的全部单字。整句与词格行跟整键行同键——append_ime_fallback 用完整
// segmentation 填 canonical_pinyin,词格 select_distinct 用 path->key——所以
// 按 canonical_pinyin == winner_key 能干净地把前缀行摘出去。
//
// 这个判据依赖一个前置事实:整键档的 canonical 必然就是 winner_key 本身。
// 胜出切分只能出自 decode_pool,那里用 query_exact_segmentations_keyed_flat
// 只收精确键,词库里没有整词的切分压根进不了池;于是整键档走的是
// query_single_cut_keyed 的精确键分支,不会退到前缀区间扫描那层降级
// (那种行的 key 是别的读音,会被这个判据误当成前缀行沉到末尾)。
//
// 不切开会破坏 merge_alternative_segmentations 的既有分层(merged_full 在前、
// 前缀作为 remaining 追加):query_series(ban'zheng) 先出 办证/辩证/整句,再出
// 班/办/半/般…,而班长、搬账这些同键位整词在 rest 里、排在单字之后,整组被挤
// 出首页。这里恢复同一分层:整键块、rest、前缀块。
std::vector<WordItem> winner_full;
std::vector<WordItem> winner_prefix;
for (auto &item : query_series(raw_input, winner_key, winner_segments))
{
(item.canonical_pinyin == winner_key ? winner_full : winner_prefix).push_back(std::move(item));
}
result = std::move(winner_full);
append_unique_words(result, std::move(rest));
append_unique_words(result, std::move(winner_prefix));
context_reordered = true;
}
}
}
// Query-time disambiguation (patent M3): the alternative readings of the
// same typo compete with the primary cut under dictionary word
// frequency; the best one keeps a protected slot near the top.
if (!alternative_segmentations.empty())
if (!context_reordered && !alternative_segmentations.empty())
{
result = merge_alternative_segmentations(raw_input, pinyin_segmentation_, resolution.corrected_segments,
alternative_segmentations, std::move(result));
+173 -1
View File
@@ -3,6 +3,7 @@
#include "autocorrect_table.h"
#include "../common/helpcode_utils.h"
#include <algorithm>
#include <array>
#include <limits>
#include <string_view>
#include <unordered_map>
@@ -535,6 +536,10 @@ struct CorrectionTarget
// pair. The tie-break weight is derived from the ENABLED subset at query
// time (see min_enabled_correction_weight), never from a switched-off table.
unsigned type_bit = 0;
// Out-of-table generated pair (non-neighbor substitution / arbitrary-letter
// insertion): flat expensive-tier weight (kAutocorrectGeneratedShapeWeight)
// instead of the per-bit derivation; switch gating still uses type_bit.
bool generated = false;
};
// Canonical per-type tie-break cost. Each correction type carries one fixed
@@ -574,6 +579,126 @@ int min_enabled_correction_weight(const unsigned enabled_bits)
return best;
}
// QWERTY letter-key neighbors (finger-movement range, letter keys only), the
// same adjacency as server/scripts/generate_quanpin_autocorrect.py:42-69.
// Indexed by letter - 'a' (a..z order -- NOT keyboard order). Drift is
// self-healing only in one direction: a pair misclassified as "non-neighbor"
// that actually collides with the static table falls back to the cheaper
// static weight, but a neighbor misclassified as "far" simply skips
// generation -- so keep this table byte-identical to the generator's.
constexpr std::array<std::string_view, 26> kQwertyNeighbors = {
"qwsz", // a
"vghn", // b
"xdfv", // c
"serfcx", // d
"wrsd", // e
"drtgvc", // f
"ftyhbv", // g
"gyujbn", // h
"uojk", // i
"huikmn", // j
"jiolm", // k
"kop", // l
"njk", // m
"bhjm", // n
"ipkl", // o
"ol", // p
"wa", // q
"etdf", // r
"awdexz", // s
"ryfg", // t
"yihj", // u
"cfgb", // v
"qeas", // w
"zsdc", // x
"tugh", // y
"asx", // z
};
constexpr bool is_qwerty_neighbor(char key, char of_letter)
{
const unsigned row = static_cast<unsigned>(of_letter - 'a');
return row < kQwertyNeighbors.size() && kQwertyNeighbors[row].find(key) != std::string_view::npos;
}
// One out-of-table generated pair: wrong-form string plus the legal syllable it
// stands for. wrong is owned by the generated store (static lifetime) because
// the correction index refers to it through string_views.
struct GeneratedPair
{
std::string wrong;
std::string_view syllable;
unsigned type_bit;
};
// Enumerate the out-of-table shapes over every legal syllable: non-neighbor
// substitutions and arbitrary-letter insertions. Transpositions and deletions
// are exhaustive in the static tables -- nothing to add. Wrong forms must be
// non-legal, at least three letters (shorter strings belong to the jianpin
// space) and at most six (the k-best piece scan never looks at longer pieces) --
// the same window as the static tables and the evaluation filter.
std::vector<GeneratedPair> build_generated_pairs()
{
const auto &legal = intact_pinyin_set();
std::vector<GeneratedPair> pairs;
for (const std::string &syllable : intact_pinyin_list())
{
if (syllable.size() < 3)
{
continue; // same-length substitutions would violate the 3-letter floor
}
// 非相邻键替换:换上的字母既不是原字母也不是它的 QWERTY 邻键。
for (size_t i = 0; i < syllable.size(); ++i)
{
for (char key = 'a'; key <= 'z'; ++key)
{
if (key == syllable[i] || is_qwerty_neighbor(key, syllable[i]))
{
continue;
}
std::string wrong = syllable;
wrong[i] = key;
if (legal.find(wrong) != legal.end())
{
continue;
}
pairs.push_back({std::move(wrong), syllable, kAutocorrectNeighbor});
}
}
// 任意字母插入:插入字母不属于静态覆盖集(该位相邻字母及其邻键)。
for (size_t i = 0; i <= syllable.size(); ++i)
{
const char left = i > 0 ? syllable[i - 1] : '\0';
const char right = i < syllable.size() ? syllable[i] : '\0';
for (char key = 'a'; key <= 'z'; ++key)
{
const bool statically_covered = key == left || key == right ||
(left != '\0' && is_qwerty_neighbor(key, left)) ||
(right != '\0' && is_qwerty_neighbor(key, right));
if (statically_covered)
{
continue;
}
std::string wrong = syllable;
wrong.insert(i, 1, key);
if (wrong.size() > 6 || legal.find(wrong) != legal.end())
{
continue; // 7 letters sit outside the k-best piece scan window
}
pairs.push_back({std::move(wrong), syllable, kAutocorrectInsertion});
}
}
}
return pairs;
}
// Static lifetime: the correction index refers to `wrong` through string_views.
const std::vector<GeneratedPair> &generated_pairs()
{
static const std::vector<GeneratedPair> pairs = build_generated_pairs();
return pairs;
}
const std::unordered_map<std::string_view, std::vector<CorrectionTarget>> &correction_index()
{
static const std::unordered_map<std::string_view, std::vector<CorrectionTarget>> kIndex = [] {
@@ -609,6 +734,21 @@ const std::unordered_map<std::string_view, std::vector<CorrectionTarget>> &corre
add_table(autocorrect::kDeletionEntries, autocorrect::kDeletionCount, kAutocorrectDeletion);
add_table(autocorrect::kInsertionEntries, autocorrect::kInsertionCount, kAutocorrectInsertion);
add_table(autocorrect::kNeighborEntries, autocorrect::kNeighborCount, kAutocorrectNeighbor);
// Out-of-table generated shapes: appended AFTER the static tables so a
// shared wrong key keeps its cheaper static targets first. A generated
// pair colliding with a static pair is dropped -- the static table's
// cheaper weight already covers it (self-heals engine/generator drift).
for (const auto &pair : generated_pairs())
{
auto &targets = index[pair.wrong];
const auto duplicate = std::find_if(targets.begin(), targets.end(), [&](const CorrectionTarget &target) {
return target.syllable == pair.syllable;
});
if (duplicate == targets.end())
{
targets.push_back(CorrectionTarget{pair.syllable, pair.type_bit, true});
}
}
return index;
}();
return kIndex;
@@ -624,6 +764,11 @@ struct AutocorrectEdge
size_t raw_length = 0;
std::string_view syllable;
bool corrected = false;
// Out-of-table generated pair (non-neighbor substitution / arbitrary-letter
// insertion). Generated-containing hypotheses rank behind ALL static
// hypotheses (see the finalize comparator) so in-table inputs keep their
// baseline cut set.
bool generated = false;
};
// One propagated cut hypothesis, ranked by (edge_count, weight, arrival):
@@ -638,6 +783,10 @@ struct SearchHypothesis
size_t prev_index = kNoPredecessor;
size_t arrival = 0;
int weight = 0;
// True once any generated edge participates in this path. Such hypotheses
// rank behind every static hypothesis (see the finalize comparator) so
// in-table inputs keep their baseline cut set.
bool has_generated = false;
AutocorrectEdge edge;
// Order-sensitive rolling hash of the joined syllable sequence. Two
// hypotheses reaching the same position share a sequence iff their hashes
@@ -696,6 +845,14 @@ std::vector<AutocorrectCut> autocorrect_cut_kbest(const std::string &pinyin, con
return;
}
std::sort(list.begin(), list.end(), [](const SearchHypothesis &lhs, const SearchHypothesis &rhs) {
// Generated-containing hypotheses rank behind ALL static ones
// regardless of edge count: out-of-table shapes only step in when
// the static space has no better explanation, and in-table inputs
// must keep their baseline cut set (zero-regression by design).
if (lhs.has_generated != rhs.has_generated)
{
return lhs.has_generated < rhs.has_generated;
}
if (lhs.edge_count != rhs.edge_count)
{
return lhs.edge_count < rhs.edge_count;
@@ -743,6 +900,7 @@ std::vector<AutocorrectCut> autocorrect_cut_kbest(const std::string &pinyin, con
child.arrival = arrival++;
child.weight = parent.weight + edge_weight;
child.edge = edge;
child.has_generated = parent.has_generated || edge.generated;
child.seq_hash = extend_sequence_hash(parent.seq_hash, edge.syllable);
best[end].push_back(std::move(child));
};
@@ -788,7 +946,9 @@ std::vector<AutocorrectCut> autocorrect_cut_kbest(const std::string &pinyin, con
}
edge.syllable = target.syllable;
edge.corrected = true;
const int weight = min_enabled_correction_weight(enabled_bits);
edge.generated = target.generated;
const int weight =
target.generated ? kAutocorrectGeneratedShapeWeight : min_enabled_correction_weight(enabled_bits);
for (size_t i = 0; i < hypotheses.size(); ++i)
{
extend(start + len, hypotheses[i], i, edge, weight);
@@ -797,6 +957,18 @@ std::vector<AutocorrectCut> autocorrect_cut_kbest(const std::string &pinyin, con
}
}
finalize(length);
// Static-priority zero-regression (see the header contract): when the best
// cut is pure static, drop every generated cut so every caller -- the
// primary resolver, the head+tail composition retry, the k == 1 preedit
// projection -- consumes exactly the baseline cut set; generated readings
// surface only when no static cut explains the input at all. Living here
// (not in each caller) is the point: a future consumer cannot forget it.
if (!best[length].empty() && !best[length].front().has_generated)
{
best[length].erase(std::remove_if(best[length].begin(), best[length].end(),
[](const SearchHypothesis &hypothesis) { return hypothesis.has_generated; }),
best[length].end());
}
// Rebuild each surviving end hypothesis by walking the predecessor chain.
// raw_length (not syllable.size()) gives the raw span: deletion edges
+44 -4
View File
@@ -78,6 +78,14 @@ inline constexpr int kAutocorrectDeletionWeight = 11;
inline constexpr int kAutocorrectInsertionWeight = 12;
inline constexpr int kAutocorrectNeighborWeight = 13;
// Out-of-table generated shapes (non-neighbor substitutions / arbitrary-letter
// insertions, see CorrectionTarget::generated): fixed expensive-tier weight.
// These slips are rarer than any in-table shape, and the ranking contract
// requires them to never displace a table hit on the same wrong key. ponytail:
// flat constant; calibration path = user_journal confusion priors (direction
// task phase 3).
inline constexpr int kAutocorrectGeneratedShapeWeight = 15;
// One segment of an autocorrect-aware cut. syllable is the canonical (possibly
// table-corrected) text used for dictionary lookups; raw_text/start describe the
// original letters the segment consumed so the preedit can keep showing what the
@@ -134,8 +142,30 @@ Segments autocorrect_cut(const std::string &pinyin, unsigned autocorrect_types);
// graph and gating as autocorrect_cut, but keeping ambiguous table entries
// alive as parallel hypotheses. Ranking key: (corrected edge count, summed
// edge weight, generation order = table order); hypotheses explaining the same
// syllable sequence are deduplicated, keeping the best-ranked one. Every
// returned cut contains at least one corrected edge, so a fully legal input
// syllable sequence are deduplicated, keeping the best-ranked one. Static
// priority: generated-containing hypotheses rank behind every static one, and
// when the top cut is pure static all generated cuts are dropped before
// returning -- in-table inputs get exactly the pre-generated-space cut set,
// and out-of-table shapes surface only when no static cut explains the input.
//
// One caveat that the ranking guarantee does not cover: the generated space hangs
// its out-of-table SUBSTITUTIONS off kAutocorrectNeighbor and its insertions off
// kAutocorrectInsertion, so a type bit no longer maps one-to-one onto a table. An
// input with no static cut at all is now reachable through a single bit -- "sahng"
// reads as "sa'ang" under the neighbour bit alone, "shng" as "sang". Callers must
// not read a bit as "only this family of typo".
//
// Scope of that: the BARE-bit readings above are unreachable in the product,
// because engine.cpp always pairs the deletion and insertion bits with the legacy
// switches, so the only masks that ever occur are 0 / 0xd / 0xe / 0xf. The
// generated space itself is NOT unreachable: 0xe and 0xf still carry the
// neighbour bit and all three carry the insertion bit, so it does add
// user-visible recall (far substitutions under 0xe/0xf, far insertions under
// 0xd). That recall is the intended gain, pinned end-to-end by
// engine/tests/src/test_input_session.cpp ("shatg" -> shang, "zthou" -> zhou).
// The head+jianpin-tail fallback in quanpin_dictionary.cpp additionally masks the
// neighbour bit off, which suppresses generated substitutions on that path only.
// Every returned cut contains at least one corrected edge, so a fully legal input
// with no correction reading yields an empty vector (the caller owns the plain
// segmentation). This is the query-time disambiguation surface of CN 101133411
// B: ambiguity survives the cut layer and is settled by dictionary frequency.
@@ -146,11 +176,21 @@ std::vector<AutocorrectCut> autocorrect_cut_kbest(const std::string &pinyin, uns
std::size_t k = 3);
// True when the input reads as one or more legal syllables plus at most one trailing
// letter ("zheg" = zhe + g): a jianpin-intent shape the correction tables must not
// rewrite. Deliberately NOT true for all-consonant strings of 3+ letters: the engine
// letter ("zheg" = zhe + g): a jianpin-intent shape, which is user intent and never
// a typo. Deliberately NOT true for all-consonant strings of 3+ letters: the engine
// has no multi-letter jianpin, so correction is the only useful reading of e.g.
// "bqng" -> bang. Inputs with manual delimiters return false; the correction path
// excludes them on its own.
//
// Who enforces it: the CALLERS, not the search above. autocorrect_cut,
// autocorrect_cut_kbest and autocorrect_cut_detail never consult this predicate, so
// calling them directly on a jianpin shape still cuts one -- "zheg" reads as "zhei"
// through the generated space, "zher" as "zhe" through the insertion table (both
// pinned by engine/tests/src/test_pinyin.cpp). Both production callers apply the
// guard before reaching the search: quanpin_dictionary.cpp (query layer) and
// input_session_composition.cpp (preedit layer). A new caller has to do the same;
// none of these signatures carries the constraint, so omitting it fails silently
// rather than at compile time.
bool looks_like_syllable_with_jianpin_tail(const std::string &pinyin);
} // namespace quanpin
+9 -1
View File
@@ -26,12 +26,20 @@ cmake --build build --config Release --target <imetest | eval_quanpin_autocorrec
注错的变体生成规则(交换/邻键/漏字/多字、QWERTY 邻键表、长度与合法音节过滤)与 `server/scripts/generate_quanpin_autocorrect.py` **必须逐键一致**——两侧漂移会让注错模型脱离纠错表的假设空间,基线失去可比性。改任一侧时在同一个提交里同步另一侧。
`--seeds-file <路径>` 是独立数据入口:每行 `typo<TAB>期望key[<TAB>期望词]`(`#` 注释、空行跳过,期望词缺省时取该 key 的最高权重词),产出独立的 Seed suite 报告节与 `seeds` 聚合 CSV 行。它**豁免于上述逐键一致契约**——种子表度量的正是契约外与用户反馈直接相关的场景(误报/漏报样例固化)。
资源目录要求:引擎加载 `sc.lm`(词格)与 `sentence-model-*.safetensors`(神经重排),而 `server/assets/tables` 只有 `dict_pinyin.dat`、`helpcode.txt`、`user_dict.dat`——模型文件不入库。本机没有把模型放进 tables 时,用安装数据目录拼一个隔离资源目录(只读模型拷贝 + `msime.db` 副本),`--db` 与 `--resource` 都指过去;直接指安装目录也可以,但评测进程会以读写方式打开 `--db`,拷贝副本更干净。
沙箱环境下子进程可能被拒绝在 `%TEMP%` 下新建目录(表现为 `create_directories: Access is denied.` 后直接退出),把 `TEMP`/`TMP` 环境变量指到仓库内的已存在目录再跑即可。
历史基线与采集笔记在归档任务目录:`.trellis/tasks/archive/2026-09/09-12-quanpin-autocorrect-patent/eval/`(阶段 0 基线)与 `09-13-quanpin-insertion-autocorrect/eval/`(insertion 上线前后对照)。
### 运行
```powershell
# 仓库根目录执行;--model 可选 mixed|deletion|ambiguous|insertion
# 仓库根目录执行;--model 可选 mixed|deletion|ambiguous|insertion|outside
# outside = 表外形状注错(非相邻替换/远键插入),度量生成式纠错空间的覆盖增益,
# 与静态表形状构造性不相交——该档 R@1 在无生成空间的代码上恒为 0。
./engine/tests/build/bin/Release/eval_quanpin_autocorrect.exe `
--db <msime.db 路径> `
--samples 300 --seed 42 --model mixed `
+241 -4
View File
@@ -89,6 +89,7 @@ enum class ErrorModel
Deletion,
Ambiguous,
Insertion,
Outside,
};
enum class ErrorKind
@@ -97,6 +98,8 @@ enum class ErrorKind
Neighbor,
Deletion,
Insertion,
FarSubstitution,
FarInsertion,
};
struct Options
@@ -109,6 +112,7 @@ struct Options
ErrorModel model = ErrorModel::Mixed;
std::string model_name = "mixed";
std::string csv_path;
std::string seeds_path;
int dump = 0;
};
@@ -119,8 +123,8 @@ void usage_exit(const std::string &message)
std::cerr << "ERROR: " << message << "\n";
}
std::cerr << "usage: eval_quanpin_autocorrect --db <msime.db> [--samples N] [--seed S]\n"
<< " [--rates 10,25,50,100] [--model mixed|deletion|ambiguous|insertion]\n"
<< " [--resource <dir>] [--csv <path>] [--dump N]\n";
<< " [--rates 10,25,50,100] [--model mixed|deletion|ambiguous|insertion|outside]\n"
<< " [--resource <dir>] [--csv <path>] [--seeds-file <path>] [--dump N]\n";
std::exit(2);
}
@@ -215,6 +219,10 @@ Options parse_options(int argc, char *argv[])
{
options.model = ErrorModel::Insertion;
}
else if (options.model_name == "outside")
{
options.model = ErrorModel::Outside;
}
else
{
usage_exit("unknown --model: " + options.model_name);
@@ -230,6 +238,10 @@ Options parse_options(int argc, char *argv[])
{
value_or_exit(options.csv_path);
}
else if (argument == "--seeds-file")
{
value_or_exit(options.seeds_path);
}
else if (argument == "--dump")
{
std::string value;
@@ -429,7 +441,7 @@ std::vector<std::string> raw_variants(ErrorKind kind, const std::string &syllabl
out.push_back(syllable.substr(0, i) + syllable.substr(i + 1));
}
}
else
else if (kind == ErrorKind::Insertion)
{
// 插入:与生成器 insertion_variants 同款约束——位置锚定串首(参照右
// 邻)、串尾(参照左邻)、中间(参照任一侧),插入字母必须是相邻键的
@@ -467,6 +479,69 @@ std::vector<std::string> raw_variants(ErrorKind kind, const std::string &syllabl
}
}
}
else if (kind == ErrorKind::FarSubstitution)
{
// 表外形状:非相邻键替换(替换键不是原键也不是其 QWERTY 邻键)。与
// 静态表替换形状构造性不相交,用于度量生成式纠错空间的覆盖增益。
for (size_t i = 0; i < syllable.size(); ++i)
{
std::unordered_set<char> blocked;
blocked.insert(syllable[i]);
for (const auto &[key, neighbors] : qwerty_neighbors())
{
if (key == syllable[i])
{
blocked.insert(neighbors.begin(), neighbors.end());
}
}
for (char key = 'a'; key <= 'z'; ++key)
{
if (blocked.count(key) != 0)
{
continue;
}
out.push_back(syllable.substr(0, i) + key + syllable.substr(i + 1));
}
}
}
else if (kind == ErrorKind::FarInsertion)
{
// 表外形状:插入字母不属于静态覆盖集(该位相邻字母及其邻键)。
for (size_t position = 0; position <= syllable.size(); ++position)
{
std::unordered_set<char> covered;
if (position > 0)
{
covered.insert(syllable[position - 1]);
for (const auto &[key, neighbors] : qwerty_neighbors())
{
if (key == syllable[position - 1])
{
covered.insert(neighbors.begin(), neighbors.end());
}
}
}
if (position < syllable.size())
{
covered.insert(syllable[position]);
for (const auto &[key, neighbors] : qwerty_neighbors())
{
if (key == syllable[position])
{
covered.insert(neighbors.begin(), neighbors.end());
}
}
}
for (char key = 'a'; key <= 'z'; ++key)
{
if (covered.count(key) != 0)
{
continue;
}
out.push_back(syllable.substr(0, position) + key + syllable.substr(position));
}
}
}
return out;
}
@@ -490,7 +565,8 @@ std::vector<std::string> usable_variants(ErrorKind kind, const std::string &syll
}
// mixed = 交换 25% / 邻键 25% / 漏字 25% / 多字 25%;deletion = 100% 漏字;
// insertion = 100% 多字。选中的错误类型在该音节上无可用变体(如 2 字母音节
// insertion = 100% 多字;outside = 非相邻替换 / 远键插入各半(表外形状,度量
// 生成式纠错空间的覆盖增益)。选中的错误类型在该音节上无可用变体(如 2 字母音节
// 或 6 字母音节的插入变体超长)时保持原样,交由样本级作废逻辑处理,不回退
// 到其他类型,保证分布可解释。
std::string corrupt_syllable(const std::string &syllable, std::mt19937 &rng, ErrorModel model,
@@ -509,6 +585,11 @@ std::string corrupt_syllable(const std::string &syllable, std::mt19937 &rng, Err
{
kind = ErrorKind::Insertion;
}
else if (model == ErrorModel::Outside)
{
kind = std::uniform_int_distribution<int>(1, 100)(rng) <= 50 ? ErrorKind::FarSubstitution
: ErrorKind::FarInsertion;
}
const auto variants = usable_variants(kind, syllable, legal);
if (variants.empty())
{
@@ -801,6 +882,128 @@ std::vector<std::string> top_words_for_syllable(sqlite3 *db, const std::string &
return words;
}
// 种子表一条(固定精度套件):键入串 + 期望读音(带撇号切分)+ 目标词。
// 目标词留空 = 取该读音在词库里的最高权重词。来源与维护纪律见任务
// eval/seeds.txt 头注:用户反馈的误报/漏报样例固化于此,豁免于「注错生成器
// 逐键一致」契约——它度量的正是契约外与用户直接相关的场景。
struct SeedCase
{
std::string typo;
std::string expected_key;
std::string word;
};
std::vector<SeedCase> load_seed_cases(const std::string &path)
{
std::ifstream input(path);
if (!input)
{
usage_exit("cannot open seeds file: " + path);
}
std::vector<SeedCase> cases;
std::string line;
while (std::getline(input, line))
{
if (!line.empty() && line.back() == '\r')
{
line.pop_back();
}
if (line.empty() || line.front() == '#')
{
continue;
}
std::istringstream stream(line);
SeedCase seed;
if (!std::getline(stream, seed.typo, '\t') || !std::getline(stream, seed.expected_key, '\t'))
{
continue;
}
std::getline(stream, seed.word, '\t');
cases.push_back(std::move(seed));
}
return cases;
}
// 目标词缺省时的兜底:取该读音在词库里的最高权重词。平局按 value 字典序,与
// build_sample_pool 的排序决胜锚点同一约定,保证逐次运行可复现。key 用绑定参数:
// 多音节 key 带撇号,内插会打碎 SQL 串。
std::string top_word_for_key(sqlite3 *db, const std::string &key)
{
const std::string table = quanpin::build_table_name(quanpin::split_segments(key));
if (table.empty())
{
return {};
}
const std::string sql = "SELECT value FROM " + table + " WHERE key = ?1 ORDER BY weight DESC, value ASC LIMIT 1";
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK)
{
return {};
}
std::string word;
if (sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW)
{
const auto *text = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
if (text != nullptr)
{
word = text;
}
}
sqlite3_finalize(statement);
return word;
}
struct SeedOutcome
{
std::string typo;
std::string expected_key;
std::string word;
int rank = 0;
bool restored = false;
bool skipped = false; // 词库查不到目标词,无法计分
};
struct SeedReport
{
RateReport summary;
std::vector<SeedOutcome> outcomes;
};
// 种子套件:逐条按 evaluate_case 的口径(预热一次、计时第二次查询)跑,
// 汇总一行聚合(R@1/R@3/读音还原/温态),明细进报告供人工对照。
SeedReport run_seed_suite(QuanpinDictionary &dictionary, sqlite3 *db, const Options &options,
unsigned autocorrect_types)
{
SeedReport report;
report.summary.rate = 100;
for (const auto &seed : load_seed_cases(options.seeds_path))
{
SeedOutcome outcome{seed.typo, seed.expected_key, seed.word, 0, false, false};
if (outcome.word.empty())
{
outcome.word = top_word_for_key(db, seed.expected_key);
}
if (outcome.word.empty())
{
outcome.skipped = true;
report.outcomes.push_back(std::move(outcome));
continue;
}
const Entry entry{seed.expected_key, outcome.word};
const auto case_outcome = evaluate_case(dictionary, seed.typo, entry, autocorrect_types);
outcome.rank = case_outcome.rank;
outcome.restored = case_outcome.restored;
++report.summary.samples;
report.summary.recall1 += outcome.rank == 1 ? 1 : 0;
report.summary.recall3 += outcome.rank >= 1 && outcome.rank <= 3 ? 1 : 0;
report.summary.restored += outcome.restored ? 1 : 0;
report.summary.warm_ms.push_back(case_outcome.warm_ms);
report.outcomes.push_back(std::move(outcome));
}
return report;
}
struct AmbiguousTargetOutcome
{
std::string input;
@@ -946,6 +1149,12 @@ int main(int argc, char *argv[])
reports = run_injected_model(dictionary, pool, options, autocorrect_types);
}
std::optional<SeedReport> seed_report;
if (!options.seeds_path.empty())
{
seed_report = run_seed_suite(dictionary, sampling_db, options, autocorrect_types);
}
std::ostringstream markdown;
markdown << "# Quanpin autocorrect evaluation\n\n";
markdown << "- model: " << options.model_name << "\n";
@@ -1013,6 +1222,30 @@ int main(int argc, char *argv[])
<< percent(report.gate.serviceable_recall3, report.gate.serviceable) << " |\n";
}
}
if (seed_report.has_value())
{
markdown << "\n## Seed suite (fixed precision cases)\n\n";
markdown << "| typo | expected key | target word | rank | reading restored |\n";
markdown << "|------|--------------|-------------|-----:|-----------------:|\n";
for (const auto &outcome : seed_report->outcomes)
{
markdown << "| " << outcome.typo << " | " << outcome.expected_key << " | " << outcome.word << " | ";
if (outcome.skipped)
{
markdown << "skip (word not in dictionary) |\n";
}
else
{
markdown << outcome.rank << " | " << (outcome.restored ? "yes" : "no") << " |\n";
}
}
markdown << "\n- seed aggregate: R@1 " << percent(seed_report->summary.recall1, seed_report->summary.samples)
<< ", R@3 " << percent(seed_report->summary.recall3, seed_report->summary.samples) << ", restored "
<< percent(seed_report->summary.restored, seed_report->summary.samples) << ", p50 "
<< milliseconds(percentile(seed_report->summary.warm_ms, 0.50)) << ", p95 "
<< milliseconds(percentile(seed_report->summary.warm_ms, 0.95)) << " (" << seed_report->summary.samples
<< " scored)\n";
}
std::cout << markdown.str();
if (!options.csv_path.empty())
@@ -1027,6 +1260,10 @@ int main(int argc, char *argv[])
{
append_csv_row(csv, "ambiguous-protected", ambiguous_report.gate_protected);
}
if (seed_report.has_value())
{
append_csv_row(csv, "seeds", seed_report->summary);
}
std::cout << "csv written: " << options.csv_path << "\n";
}
+330
View File
@@ -337,6 +337,331 @@ void run_umlaut_alias_session_tests(const std::filesystem::path &data_directory)
// 光标驱动的前缀解码(PRD R2–R7,Stage 1):候选与量化边界按「光标之前的完整音节
// 单元前缀」重算。自建隔离词库,不与主 fixture 互相污染;Server(Stage 2)将以
// set_caret + recompute_candidates 的同一方式消费这些入口。
// 阶段 1 上下文消解(任务 quanpin-autocorrect-context-ranking):同档纠错读法
// 在词格路径分边际达标时由胜出切分接管领衔。zhng 的删除目标按表序 zhang 在前
// (主切),fixture 让 zheng 侧的办证权重高 20 倍——启发式路径分差 ln(20)=3.0
// (log10 1.3),两种标度下都过 1.0 接管边际。fixture 无 sc.lm,词格退回
// 启发式打分(ln(weight)+词长奖励),权重完全决定路径分,测试因此确定。
void run_autocorrect_context_ranking_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "autocorrect-context";
std::filesystem::create_directories(directory);
{
Database database(directory / "msime.db");
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', 5000);"
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 900);"
"INSERT INTO tbl_1_s VALUES('sheng', 's', '生', 800);");
}
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
SentenceAssociationOptions lattice_only;
lattice_only.word_lattice = true;
const auto candidate_words = [](const metasequoia::InputSession &session) {
std::vector<std::string> words;
for (const auto &item : session.candidates())
{
words.push_back(item.word);
}
return words;
};
// 关联关闭 = 现状静态路径:合并池按权重排序,办证系与班长系交错。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "banzhng");
const auto words = candidate_words(session);
require(words.size() == 4 && words[0] == "办证" && words[1] == "班长" && words[2] == "搬账" &&
words[3] == "辩证",
"The static same-tier merge must interleave the readings by dictionary weight.");
}
// 关联开启:zheng 切分上下文胜出接管领衔——办证系整体前移,辩证从末位
// 升到第 2 位,班长系整体后移。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
session.set_sentence_association(lattice_only);
type(session, "banzhng");
const auto words = candidate_words(session);
require(words.size() == 4 && words[0] == "办证" && words[1] == "辩证" && words[2] == "班长" &&
words[3] == "搬账",
"The context-ranked reading must lead with its whole word group.");
}
// 单音节纠错无词格可搭(词格要求 >=2 完整音节):两种状态必须逐位一致。
{
metasequoia::InputSession off(SchemeType::Quanpin, both, true, true, true, paths);
type(off, "shng");
metasequoia::InputSession on(SchemeType::Quanpin, both, true, true, true, paths);
on.set_sentence_association(lattice_only);
type(on, "shng");
require(same_candidate_words(off, on),
"A single-syllable correction must take the static path even with the word lattice on.");
require(candidate_words(on).size() == 2, "The single-syllable fixture must surface both deletion targets.");
}
}
// 审阅缺陷 D1(PR #592 行内评论):上下文重排接管后,胜出切分的单字前缀曾被插到
// 同键位整词前面。query_series 的 count 从整键递减到 1,ban'zheng 的输出里除了
// 办证/辩证/整句还带着首音节的单字;这些单字排在 rest(班长、搬账)之前,把同键位的
// 其他整词整组挤出首页。原 fixture 没有 tbl_1_b,前缀这条路根本没被走到。
// 这里补单字表,并直接断言「整键在前、前缀在后」这条分层不变量。
// 同上一组 fixture:词库目录里没有 sc.lm,词格退回启发式打分,权重完全决定路径分,
// 因此 zheng 侧稳定胜出、重排稳定接管,用例不依赖任何模型文件。
void run_autocorrect_context_layering_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "autocorrect-context-layering";
std::filesystem::create_directories(directory);
{
Database database(directory / "msime.db");
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', 5000);"
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
// 单字表:query_series 递减到 count==1 时查它,前缀行由此产生。单字权重取 1e6
// 量级(真实词库里单字是语料计数),确保它们不会被 append 阶段的去重或调频挪走。
database.execute("CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_b VALUES('ban', 'b', '办', 1000000);"
"INSERT INTO tbl_1_b VALUES('ban', 'b', '半', 900000);");
}
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
SentenceAssociationOptions lattice_only;
lattice_only.word_lattice = true;
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
session.set_sentence_association(lattice_only);
type(session, "banzhng");
const auto &items = session.candidates();
const auto index_of = [&items](const std::string &word) {
for (size_t i = 0; i < items.size(); ++i)
{
if (items[i].word == word)
{
return i;
}
}
return items.size();
};
// 先确认 fixture 真的同时产出了整键行与单字前缀行,否则下面的分层断言是空的。
const size_t prefix_a = index_of("办");
const size_t prefix_b = index_of("半");
require(prefix_a != items.size() && prefix_b != items.size(),
"The layering fixture must surface single-character prefix rows for the winner cut.");
const size_t sibling = index_of("班长");
require(sibling != items.size(), "The layering fixture must surface the same-tier whole word.");
require(index_of("办证") < prefix_a, "The winner cut's whole word must precede its own single-character prefixes.");
require(sibling < prefix_a && sibling < prefix_b,
"Same-tier whole words must not be pushed behind the winner cut's single-character prefixes.");
// 分层的完整形式:所有整键行都在任何前缀行之前。全拼键用 ' 分隔音节,
// 单音节键不含 '。
const auto is_prefix_row = [&items](size_t i) { return items[i].canonical_pinyin.find('\'') == std::string::npos; };
bool seen_prefix = false;
for (size_t i = 0; i < items.size(); ++i)
{
if (is_prefix_row(i))
{
seen_prefix = true;
}
else
{
require(!seen_prefix, "Every whole-key row must precede every single-character prefix row.");
}
}
}
// 用户在主切读音上的选择优先于上下文接管。加载 LM 时路径分只剩 dictionary_tiebreak
// 一点点词库权重,调频翻不过接管边际,所以前两组 fixture 的启发式打分测不出来:这里
// 放一份手写 ARPA 当 sc.lm(kenlm 按文本格式载入),让 LM 稳定偏向办证(log10 差 2.5),
// 同时把班长的词库权重设成调频后的样子——越过办证。
void run_autocorrect_context_user_choice_tests(const std::filesystem::path &data_directory)
{
const auto make_fixture = [&data_directory](const char *name, int banzhang_weight) {
const std::filesystem::path directory = data_directory / name;
std::filesystem::create_directories(directory);
{
Database database(directory / "msime.db");
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
const std::string banzhang =
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', " + std::to_string(banzhang_weight) + ");";
database.execute(banzhang.c_str());
}
// 二进制写:文本模式在 Windows 上会把换行写成 \r\n,kenlm 的 ARPA 解析不认。
std::ofstream arpa(directory / metasequoia::assets::language_model, std::ios::binary);
arpa << "\\data\\\n"
"ngram 1=7\n"
"ngram 2=1\n"
"\n"
"\\1-grams:\n"
"-1.0\t<unk>\t0\n"
"-99\t<s>\t0\n"
"-1.0\t</s>\t0\n"
"-1.0\t办证\t0\n"
"-3.5\t班长\t0\n"
"-3.5\t搬账\t0\n"
"-4.0\t辩证\t0\n"
"\n"
"\\2-grams:\n"
"-0.5\t<s> 办证\n"
"\n"
"\\end\\\n";
require(static_cast<bool>(arpa), "Failed to write the user-choice language model fixture.");
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
return paths;
};
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
SentenceAssociationOptions lattice_only;
lattice_only.word_lattice = true;
const auto first_word = [both, &lattice_only](const metasequoia::RuntimePaths &paths) {
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
session.set_sentence_association(lattice_only);
type(session, "banzhng");
require(!session.candidates().empty(), "The user-choice fixture produced no candidates.");
return session.candidates().front().word;
};
{
const auto paths = make_fixture("autocorrect-context-user-choice", 150000);
// 没有用户记录:班长权重再高也只是出货词频,仍交给上下文裁决。这一步同时确认
// ARPA 真的载入了——启发式打分下班长权重更高,不会是办证领衔。
require(first_word(paths) == "办证",
"Without a user record the language model must still let the context reading lead.");
require(user_dictionary::record_upsert(metasequoia::path_to_utf8(paths.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, "ban'zhang", "班长", 150000),
"Failed to write the user-choice journal record.");
require(first_word(paths) == "班长",
"A word the user ranked above the context winner must not be displaced by the context takeover.");
}
{
// 用户动过主切的键,但主切首位仍低于胜出切分首位:用户并没有把这组读音选到前面,照常接管。
const auto paths = make_fixture("autocorrect-context-user-touched", 5000);
require(user_dictionary::record_upsert(metasequoia::path_to_utf8(paths.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, "ban'zhang", "搬账", 4000),
"Failed to write the user-touched journal record.");
require(first_word(paths) == "办证",
"A user record that does not outrank the context winner must not block the takeover.");
}
}
// 阶段 2 生成式纠错空间(任务 quanpin-autocorrect-generated-space):静态表
// 形状之外的单编辑手误由生成式索引兜底,权重落贵档(15)。隔离 fixture:
// - shatg = shang 的 n→t(t 非邻键,远键替换)
// - zthou = zhou 的 z/h 之间插 t(t 不在静态覆盖集,远键插入)
// - chng 同键双读:chng→chang(静态漏字 11)与 chng→cang(生成 15),
// 静态最优时生成切分整体丢弃——静态优先语义的判别性断言
// - shatngzh = head shatng + 简拼尾 zh:head 组合路径同享静态优先
void run_autocorrect_generated_space_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "autocorrect-generated";
std::filesystem::create_directories(directory);
{
Database database(directory / "msime.db");
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 900);");
database.execute("CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_z VALUES('zhou', 'z', '周', 900);");
database.execute("CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_c VALUES('chang', 'c', '长', 900);"
"INSERT INTO tbl_1_c VALUES('cang', 'c', '仓', 5000);");
database.execute("CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_s VALUES('shang''zh', 'sz', '上周', 900);");
database.execute("CREATE TABLE tbl_3_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_3_s VALUES('sha''tang''zh', 'stz', '沙汤扎', 900);");
}
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
// 远键替换:shatg → shang(生成对,贵档 15)。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "shatg");
const auto found = find_candidate_index(session, "上");
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "shatg",
"A far-substitution typo must be corrected to 上 with the typed letters recorded.");
}
// 远键插入(词中位):zthou → zhou(t 不在 z/h 的覆盖集)。fixture 特意
// 选无竞争切分的输入:zhwou 这类插入位会拼出 [zha(w 邻键替换)+ou] 等更
// 便宜的合法切分(权重 13 < 15),生成读法按契约排后被前缀候选遮蔽——
// 那是排序语义,不是生成类失效。zthou 无任何竞争切分,唯一读即纠错读法。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "zthou");
const auto found = find_candidate_index(session, "周");
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "zthou",
"A far-insertion typo must be corrected to 周 with the typed letters recorded.");
}
// 静态优先语义:chng = chang 漏 a。读法竞争——chng→chang(静态漏字 11)与
// chng→cang(生成远键替换 h→a,15)。最优切分为纯静态时生成切分整体丢弃:
// 仓不出现(混表加权仲裁依赖训练权重,方向阶段 3),长照常领衔。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "chng");
require(candidate_index(session, "长") == 0, "The in-table cheaper deletion reading must lead for chng.");
require(
find_candidate_index(session, "仓") == session.candidates().size(),
"Static-priority semantics: the generated cang reading must not surface when a static cut tops the input.");
}
// head+简拼尾组合路径的静态优先:shatngzh 在整串 k-best 上无解(尾部 zh 非
// 完整音节),走 head 重试。head shatng 的读法竞争——sha+tng→tang(静态漏字
// 11)与 shatng→shang(生成插入 15)。丢弃规则由 k-best 搜索自身执行,head
// 路径同享:静态读法的沙汤扎(sha'tang'zh 精确键)在场,上周(shang+zh 生成
// 读法经 costlier 档,表内输入的基线里不存在)不得出现。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "shatngzh");
require(find_candidate_index(session, "沙汤扎") < session.candidates().size(),
"The head-composition path must still correct shatng to sha+tang.");
require(find_candidate_index(session, "上周") == session.candidates().size(),
"Static priority must hold on the head path: the generated shang+zh reading must not surface.");
}
// 守卫与合法输入:尾位插入(shangv 类)受简拼尾守卫——纠错不触发由
// test_pinyin 的 zheg 用例与输入门不变式覆盖,此处不重复;合法拼写原样直出。
{
metasequoia::InputSession legal(SchemeType::Quanpin, both, true, true, true, paths);
type(legal, "shang");
require(candidate_index(legal, "上") == 0 && legal.candidates()[0].corrected_from.empty(),
"A legal spelling must stay uncorrected.");
}
}
void run_caret_prefix_session_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "caret-prefix";
@@ -1242,6 +1567,11 @@ int run_test()
run_umlaut_alias_session_tests(data_directory);
run_caret_prefix_session_tests(data_directory);
run_autocorrect_context_ranking_tests(data_directory);
run_autocorrect_context_layering_tests(data_directory);
run_autocorrect_context_user_choice_tests(data_directory);
run_autocorrect_generated_space_tests(data_directory);
#endif
#ifndef METASEQUOIA_SKIP_FREQUENCY_TESTS
+51 -17
View File
@@ -947,12 +947,22 @@ void test_quanpin_autocorrect_switches_and_guard()
const unsigned both = transposition_only | neighbor_only;
// Cut-level switch matrix: 'sahng' is a transposition fix, 'shabg' a neighbor
// fix, and each type bit must enable exactly its own family (AC1-AC4).
// fix (AC1-AC4). Since the generated correction space was added, the neighbour
// bit also carries out-of-table substitutions, so a bit no longer maps onto
// exactly one table and the 'must NOT correct' rows had to give up their empty
// expectation for the input's widened reading. The bare bits used here
// (neighbour alone, T|N) are unreachable in the product: engine.cpp always
// pairs the deletion and insertion bits with the legacy switches, so only
// 0xd/0xe/0xf ever occur. That does NOT make the generated space unreachable
// -- 0xe/0xf still carry the neighbour bit and all three carry the insertion
// bit -- it only means these particular one-bit rows cannot be triggered by a
// user. The space itself is reachable and is covered by the session-level
// cases below plus test_input_session.cpp.
expect(quanpin::autocorrect_cut("sahng", none).empty(), "Both switches off must disable the correction cut.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", transposition_only)) == "shang",
"Transposition-only must correct 'sahng'.");
expect(quanpin::autocorrect_cut("sahng", neighbor_only).empty(),
"Neighbor-only must not correct the transposition case 'sahng'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", neighbor_only)) == "sa'ang",
"Neighbor-only reads 'sahng' as sa + ang through a generated substitution.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", both)) == "shang",
"Both switches on must correct 'sahng'.");
@@ -990,19 +1000,26 @@ void test_quanpin_autocorrect_switches_and_guard()
"'gau' must correct to 'gua' via transposition once the guard lets it through.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("bqng", neighbor_only)) == "bang",
"'bqng' -> bang must remain a valid neighbor correction.");
expect(quanpin::autocorrect_cut("zheg", both).empty(),
"The cleaned table must offer no correction path for 'zheg' (2-letter keys are gone).");
// "zheg" is jianpin intent (zhe + g). The static tables still offer no path
// for it -- 2-letter keys are gone -- but the generated space substitutes the
// trailing letter ("zheg" -> "zhei", weight 15), so the cut is no longer empty.
// Production is unchanged: with the deletion bit on (0xd/0xe/0xf) the static
// reading "zheng" costs 11 and wins, and this search consults no guard by
// design -- the callers screen jianpin intent first.
expect(quanpin::join_segments(quanpin::autocorrect_cut("zheg", both)) == "zhei",
"The cleaned static table has no path for 'zheg', but a generated one exists.");
// Deletion bit (phase 2): "shng" (dropped "a") is a deletion fix; only
// that bit may correct it, and the legacy switches keep their own families.
// Deletion bit (phase 2): "shng" (dropped "a") is a deletion fix, and that
// bit still corrects it directly. The legacy switches keep their own
// families apart from where the generated space widened the neighbour bit.
const unsigned deletion_only = quanpin::kAutocorrectDeletion;
const unsigned all = transposition_only | neighbor_only | deletion_only;
expect(quanpin::join_segments(quanpin::autocorrect_cut("shng", deletion_only)) == "shang",
"Deletion-only must correct 'shng'.");
expect(quanpin::autocorrect_cut("shng", transposition_only).empty(),
"Transposition-only must not correct the deletion case 'shng'.");
expect(quanpin::autocorrect_cut("shng", neighbor_only).empty(),
"Neighbor-only must not correct the deletion case 'shng'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shng", neighbor_only)) == "sang",
"Neighbor-only reads 'shng' as sang through a generated substitution.");
expect(quanpin::autocorrect_cut("sahng", deletion_only).empty() == false &&
quanpin::join_segments(quanpin::autocorrect_cut("sahng", deletion_only)) == "sa'hang",
"Bits gate tables, not intents: deletion-only still explains 'sahng' via sa + hng -> hang.");
@@ -1010,8 +1027,8 @@ void test_quanpin_autocorrect_switches_and_guard()
"Deletion-only must not correct the neighbor case 'shabg'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", all)) == "shang'zhi",
"A deletion edge followed by a neighbor edge must cut 'shngzhk'.");
expect(quanpin::autocorrect_cut("shngzhk", both).empty(),
"Without the deletion bit 'shngzhk' must stay unexplained.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", both)) == "sang'zhi",
"Without the deletion bit 'shngzhk' still cuts: generated sang + static zhk -> zhi.");
// Insertion bit (fourth type): "shangg" (doubled g) and "sjhang" (j is a
// QWERTY neighbor of h) are insertion fixes; only that bit may correct them.
@@ -1124,9 +1141,19 @@ void test_quanpin_autocorrect_switches_and_guard()
expect(!transposed.empty() && transposed.front().word == "上",
"Transposition-only must correct 'sahng' at the dictionary layer (AC2).");
// AC2 gating is verified through the correction marker rather than through
// 上's presence. Under neighbor_only the generated space now explains
// 'sahng' as sa + ang, so the correction path runs; 上 still comes back, but
// via the correction-mode segmentation 'shang' (cut_pinyin_by_mode lists it
// as an alternative), which hits the 'shang' key exactly and therefore
// carries no corrected_from. The transposition reading is not produced, so
// nothing is marked. Note this is a different route than the AC3 case below,
// where 'shabg' only reaches the key by a 'sha' PREFIX scan -- same symptom
// (an unmarked 上), different mechanism.
const auto neighbor_denied = dictionary.query("sahng", "sa'h'n'g", neighbor_only);
expect(std::none_of(neighbor_denied.begin(), neighbor_denied.end(), is_shang),
"Neighbor-only must not correct the transposition case 'sahng' (AC2).");
expect(std::none_of(neighbor_denied.begin(), neighbor_denied.end(),
[](const WordItem &item) { return item.corrected_from == "sahng"; }),
"Neighbor-only must not produce a corrected candidate for the transposition case 'sahng' (AC2).");
const auto neighbor_corrected = dictionary.query("shabg", "sha'b'g", neighbor_only);
expect(!neighbor_corrected.empty() && neighbor_corrected.front().word == "上",
@@ -1302,10 +1329,17 @@ void test_quanpin_autocorrect_display()
sahnghao_cut.segments[1].start == 5 && !sahnghao_cut.segments[1].corrected,
"The untouched tail of 'sahnghao' must keep its raw span.");
expect(quanpin::autocorrect_cut_detail("zheg", both).empty(),
"The cleaned table must leave the jianpin shape 'zheg' unexplained by the BFS.");
expect(quanpin::autocorrect_cut_detail("keneng", both).empty(),
"A fully legal spelling must produce no correction cut.");
const auto zheg_cut = quanpin::autocorrect_cut_detail("zheg", both);
expect(zheg_cut.segments.size() == 1 && zheg_cut.segments[0].syllable == "zhei",
"The detail cut reads the jianpin shape 'zheg' as 'zhei' via a generated substitution.");
// "keneng" is ken + eng, both legal syllables. It used to yield no cut, but
// not because a guard stopped it -- this search consults no guard, as the
// "zher" case above documents -- rather because the static tables happened to
// lack the "eng" -> "ang" pair. The generated space supplies it, so the input
// now cuts. Jianpin intent stays protected by the callers, which apply
// looks_like_syllable_with_jianpin_tail before reaching this search.
expect(!quanpin::autocorrect_cut_detail("keneng", both).empty(),
"A fully legal spelling now cuts too, via a generated 'eng' -> 'ang' substitution.");
expect(quanpin::autocorrect_cut_detail("sahng", none).empty(),
"Both switches off must disable the range-carrying cut too.");
expect(quanpin::autocorrect_cut_detail("xi'an", both).empty(),
@@ -581,6 +581,17 @@ bool is_user_inserted(const std::string &user_db_path, DictionaryKind kind, cons
sqlite3_column_int(stmt.get(), 0) != 0;
}
bool has_user_upsert_for_key(const std::string &user_db_path, DictionaryKind kind, const std::string &key)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt = prepare(db.get(), "SELECT 1 FROM user_dictionary_operations "
"WHERE dictionary=?1 AND key=?2 AND operation='upsert' LIMIT 1");
return stmt && bind_text(stmt.get(), 1, kind_name(kind)) && bind_text(stmt.get(), 2, key) &&
sqlite3_step(stmt.get()) == SQLITE_ROW;
}
bool record_pinyin_upsert_from_database(const std::string &main_db_path, const std::string &key,
const std::string &value, const std::string &user_db_path)
{
@@ -28,6 +28,9 @@ bool record_delete(const std::string &user_db_path, DictionaryKind kind, const s
const std::string &value);
bool is_user_inserted(const std::string &user_db_path, DictionaryKind kind, const std::string &key,
const std::string &value);
// 该键下是否有用户留下的权重(调频或造词的 upsert,不看具体词)。删除记录不算:
// 它表达的是「不要这个词」,不是对这组读音的偏好。
bool has_user_upsert_for_key(const std::string &user_db_path, DictionaryKind kind, const std::string &key);
bool ensure_user_database(const std::string &user_db_path);
// Switch an existing dictionary database (msime.db) to WAL so the key thread's
// queries never wait for a learning write's commit. The user journal does this
+43 -9
View File
@@ -669,17 +669,28 @@ TEST_CASE(QuanpinAutocorrectInsertionTableEntriesAreLegal)
}
}
TEST_CASE(QuanpinAutocorrectCutGatesEachTypeIndependently)
TEST_CASE(QuanpinAutocorrectCutTypeBitSwitchMatrix)
{
const unsigned none = 0;
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
const unsigned neighbor_only = quanpin::kAutocorrectNeighbor;
const unsigned both = transposition_only | neighbor_only;
// The generated correction space attaches its out-of-table substitutions to
// kAutocorrectNeighbor, so that bit no longer maps one-to-one onto the static
// neighbour table: swapping "sang"'s leading vowel for "h" is not a QWERTY
// neighbour pair (kQwertyNeighbors['a'] == "qwsz"), and the generated space
// explains "sahng" as "sa'ang" under the neighbour bit alone. The cases below
// used to pin that one-to-one relation and are kept, with the widened reading
// spelled out. The bare bit used here is unreachable in the product: engine.cpp
// assembles the deletion and insertion bits alongside the legacy switches, so
// the only non-zero masks are 0xd/0xe/0xf. That makes THIS row untriggerable by
// a user; it does not make the generated space unreachable, because 0xe/0xf
// still carry the neighbour bit and all three carry the insertion bit.
// "sahng" is a transposition fix; only that bit may correct it.
REQUIRE(quanpin::autocorrect_cut("sahng", none).empty());
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahng", transposition_only)), std::string("shang"));
REQUIRE(quanpin::autocorrect_cut("sahng", neighbor_only).empty());
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahng", neighbor_only)), std::string("sa'ang"));
// "shabg" is a neighbor fix; only that bit may correct it.
REQUIRE(quanpin::autocorrect_cut("shabg", none).empty());
REQUIRE(quanpin::autocorrect_cut("shabg", transposition_only).empty());
@@ -711,6 +722,24 @@ TEST_CASE(QuanpinJianpinShapeGuardBlocksCorrection)
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("wj"));
}
// The counterpart of QuanpinJianpinShapeGuardBlocksCorrection: the shape predicate
// says "jianpin intent", and the correction search still cuts it anyway. Both are
// correct -- the guard is applied by the callers, not here.
TEST_CASE(QuanpinAutocorrectCutExplainsJianpinShapeByGeneratedSubstitution)
{
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
// "keneng" is ken + eng with zero corrected letters. That guard is the CALLERS'
// responsibility (quanpin_dictionary.cpp, input_session_composition.cpp) and
// autocorrect_cut deliberately does not consult it -- test_pinyin.cpp's "zher"
// case documents that same contract, where the correction cut exists by design.
// What the generated space changed is coverage, not the guard: it supplies
// "eng" -> "ang" as an out-of-table substitution, so the search now explains an
// input the static tables happened not to. develop returned empty here only
// because no static deletion pair happened to exist, not because a guard fired.
// The mask is unreachable in production: see QuanpinAutocorrectCutTypeBitSwitchMatrix.
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("keneng", both)), std::string("ken'ang"));
}
TEST_CASE(QuanpinAutocorrectCutSingleSyllableTranspositions)
{
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
@@ -741,7 +770,7 @@ TEST_CASE(QuanpinAutocorrectCutPrefersFewestCorrections)
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahnguai", both)), std::string("shan'guai"));
}
TEST_CASE(QuanpinAutocorrectCutRejectsInputsOutOfScope)
TEST_CASE(QuanpinAutocorrectCutRejectsInputsWithNoCorrectionPath)
{
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
// The mask short-circuits before any table lookup.
@@ -749,7 +778,6 @@ TEST_CASE(QuanpinAutocorrectCutRejectsInputsOutOfScope)
// Legal input: the caller must only invoke autocorrect when the correction
// cut already failed, so a fully legal spelling yields no correction.
REQUIRE(quanpin::autocorrect_cut("shang", both).empty());
REQUIRE(quanpin::autocorrect_cut("keneng", both).empty());
// Manual delimiter: user-intended boundary, never rewritten.
REQUIRE(quanpin::autocorrect_cut("xi'an", both).empty());
// No correction path: unresolvable garbage.
@@ -851,15 +879,18 @@ TEST_CASE(QuanpinAutocorrectCutMixedDeletionAndNeighbor)
// "shngzhk" mixes a deletion edge (shng -> shang) with a neighbor edge
// (zhk -> zhi): two corrected edges, weights 11 + 13.
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", all)), std::string("shang'zhi"));
// Without the deletion bit the same input stays unexplained.
REQUIRE(quanpin::autocorrect_cut("shngzhk", both).empty());
// Without the deletion bit the generated space still explains it: "shng"
// becomes "sang" through an out-of-table substitution on the leading vowel,
// then the static neighbour edge zhk -> zhi carries the rest (15 + 13 = 28).
// The deletion-bit reading above still wins because it costs 11 + 13 = 24.
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", both)), std::string("sang'zhi"));
// A two-letter jianpin leftover ("zh") stays out of correction scope even
// with deletion on: the tables hold no 2-letter keys.
REQUIRE(quanpin::autocorrect_cut("sahngzh", all).empty());
}
TEST_CASE(QuanpinAutocorrectCutGatesDeletionIndependently)
TEST_CASE(QuanpinAutocorrectCutDeletionBitSwitchMatrix)
{
const unsigned none = 0;
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
@@ -867,10 +898,13 @@ TEST_CASE(QuanpinAutocorrectCutGatesDeletionIndependently)
const unsigned deletion_only = quanpin::kAutocorrectDeletion;
const unsigned all = transposition_only | neighbor_only | deletion_only;
// "shng" is a deletion fix; only that bit may correct it.
// "shng" is a deletion fix; only that bit may correct it directly.
REQUIRE(quanpin::autocorrect_cut("shng", none).empty());
REQUIRE(quanpin::autocorrect_cut("shng", transposition_only).empty());
REQUIRE(quanpin::autocorrect_cut("shng", neighbor_only).empty());
// ...except that the generated space also reads "shng" as "sang" (out-of-table
// substitution on the leading vowel, weight 15) under the neighbour bit.
// The direct deletion reading costs 11 and still wins wherever both bits are on.
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", neighbor_only)), std::string("sang"));
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", deletion_only)), std::string("shang"));
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", all)), std::string("shang"));