Merge pull request #592 from metasequoiaime/feat/quanpin-autocorrect-context-ranking
feat(engine,server): 让输入法靠上下文猜错字
This commit is contained in:
11 files changed
+1078
-40
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 `
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in new issue
Block a user