#include "word_lattice.h" #include #include #include #include #include #include #include #include #include namespace quanpin { namespace { constexpr double kNegInf = -std::numeric_limits::infinity(); std::string join_span(const Segments &span) { std::string key; for (size_t i = 0; i < span.size(); ++i) { if (i) key.push_back('\''); key += span[i]; } return key; } size_t syllable_count_from_key(const std::string &key) { if (key.empty()) return 0; size_t n = 1; for (char c : key) { if (c == '\'') ++n; } return n; } // UTF-8 单字切分。词库值都是合法 UTF-8,不做容错。 std::vector split_utf8_chars(const std::string &text) { std::vector chars; for (size_t i = 0; i < text.size();) { const auto lead = static_cast(text[i]); const size_t len = lead < 0x80 ? 1 : (lead >> 5) == 0x6 ? 2 : (lead >> 4) == 0xE ? 3 : 4; chars.push_back(text.substr(i, len)); i += len; } return chars; } // 词的字数必须等于跨度的音节数,被钉住的那几个字都要被各自的约束认可。 bool lexeme_satisfies(const std::string &value, size_t syllables, const std::vector> &pins) { const auto chars = split_utf8_chars(value); if (chars.size() != syllables) return false; return std::all_of(pins.begin(), pins.end(), [&](const auto &pin) { return pin.second->accept(chars[pin.first]); }); } struct LatticeEdge { size_t end = 0; std::string word; std::string key; std::int64_t weight = 0; // Context-free part of the edge score: the heuristic when there is no // model, the dictionary tiebreak when there is one. double base_score = 0; // Vocabulary index of `word`, resolved once instead of per hypothesis. ngram::WordIndex index = 0; }; // Fallback scorer, used only when no language model is available. Kept on the // natural-log scale it was written on: nothing mixes it with model scores, // because the choice is made once per decode, not per edge. double heuristic_log_prob(std::int64_t weight, size_t syllables, const WordLatticeOptions &options) { const double w = weight > 0 ? static_cast(weight) : 1.0; const double z = options.unigram_z > 1.0 ? options.unigram_z : 1e6; const double lp = std::log(w); // Single-character rows in msime.db are raw corpus counts (often 1e6+); // multi-syllable rows are phrase weights on a much smaller scale. // libpinyin stores comparable log-probabilities; we approximate that by // down-projecting unigrams and giving dictionary phrases a length bonus. if (syllables <= 1) return lp - std::log(z); return lp + options.phrase_length_bonus * static_cast(syllables); } // log10 of the dictionary weight, scaled down to a tiebreak. See // WordLatticeOptions::dictionary_tiebreak for why it has to stay this small. double dictionary_tiebreak(std::int64_t weight, const WordLatticeOptions &options) { const double w = weight > 0 ? static_cast(weight) : 1.0; return options.dictionary_tiebreak * std::log10(w); } // log10 P(词|拼音),用跨度内的权重占比近似。语言模型只认汉字,「卷」「而」这种 // 字它见得多,可它根本不该被 gun / neng 召回——词库里这些多音行权重是 0,占比 // 一算就掉到底,先验替模型补上「这个读音对不对」这一维。 double reading_prior(std::int64_t weight, std::int64_t span_total, const WordLatticeOptions &options) { if (options.reading_prior <= 0 || span_total <= 0) return 0; const double w = weight > 0 ? static_cast(weight) : 0.0; const double share = (w + 1.0) / (static_cast(span_total) + 1.0); const double threshold = options.reading_prior_share > 0 ? options.reading_prior_share : 1.0; if (share >= threshold) return 0; const double floor = options.reading_prior_floor > 0 ? -options.reading_prior_floor : kNegInf; return options.reading_prior * (std::max)(std::log10(share / threshold), floor); } struct Hyp { double score = kNegInf; int prev_pos = -1; int prev_idx = -1; std::string word; std::string key; // Trailing n-gram context of this path. Unused (and left zeroed) when // decoding with the heuristic. ngram::State state; // 路径已累计词文本的尾部(至多 kCollocationTailCodepoints 个码点),供字级 // 搭配打分用。关闭搭配项时保持为空,不为每个假设白付一次字符串拼接。 std::string collocation_tail; }; void keep_beam(std::vector &column, int beam) { if (static_cast(column.size()) <= beam) return; std::partial_sort(column.begin(), column.begin() + beam, column.end(), [](const Hyp &a, const Hyp &b) { return a.score > b.score; }); column.resize(static_cast(beam)); } // The model actually usable for this decode, or null to run the heuristic. const ngram::LanguageModel *active_model(const WordLatticeOptions &options) { if (options.language_model && options.language_model->valid()) return options.language_model; return nullptr; } std::vector> build_graph(const Segments &syllables, const WordLatticeLookup &lookup, const WordLatticeOptions &options) { const ngram::LanguageModel *model = active_model(options); const size_t n = syllables.size(); std::vector> graph(n); const size_t max_len = static_cast(std::max(1, options.max_phrase_syllables)); std::unordered_map> span_cache; for (size_t start = 0; start < n; ++start) { const size_t max_end = (std::min)(n, start + max_len); for (size_t end = start + 1; end <= max_end; ++end) { Segments span(syllables.begin() + static_cast(start), syllables.begin() + static_cast(end)); const std::string span_key = join_span(span); std::vector> pins; for (const auto &constraint : options.char_constraints) { if (constraint.syllable >= start && constraint.syllable < end && constraint.accept) pins.emplace_back(constraint.syllable - start, &constraint); } std::vector constrained_rows; if (!pins.empty()) { // 受约束的跨度按位置筛,结果与起点有关,不进按拼音共享的 span_cache。 const auto &source = options.constrained_lookup ? options.constrained_lookup : lookup; for (auto &row : source(span)) { if (lexeme_satisfies(row.value, span.size(), pins)) constrained_rows.push_back(std::move(row)); } } auto cached = span_cache.end(); if (pins.empty()) { cached = span_cache.find(span_key); if (cached == span_cache.end()) cached = span_cache.emplace(span_key, lookup(span)).first; } const auto &rows = pins.empty() ? cached->second : constrained_rows; const size_t take = (std::min)(rows.size(), static_cast(std::max(0, options.span_limit))); // 先验的分母:跨度内实际参与解码、读音相同的那些行。行数被 span_limit 截断, // 截掉的都是权重最低的尾巴,对和的影响可以忽略。按读音分开求和:模糊音把别的 // 读音的行混进同一跨度,算进一个分母会把敲出读音的行的占比压低。 std::unordered_map reading_totals; for (size_t i = 0; i < take; ++i) reading_totals[rows[i].key] += rows[i].weight > 0 ? rows[i].weight : 0; for (size_t i = 0; i < take; ++i) { const auto &row = rows[i]; if (row.value.empty()) continue; LatticeEdge edge; edge.end = end; edge.word = row.value; edge.key = row.key.empty() ? span_key : row.key; edge.weight = row.weight; if (model) { edge.index = model->index(edge.word); edge.base_score = dictionary_tiebreak(edge.weight, options) + reading_prior(edge.weight, reading_totals[row.key], options); } else { edge.base_score = heuristic_log_prob(edge.weight, end - start, options); } edge.base_score -= row.penalty; graph[start].push_back(std::move(edge)); } } } return graph; } size_t utf8_codepoints(const std::string &text) { size_t n = 0; for (unsigned char c : text) { if ((c & 0xC0) != 0x80) ++n; } return n; } // 搭配打分的上下文窗口:.gram 的查询键最多编入 8 个码点,尾窗留 8 个足够。 constexpr size_t kCollocationTailCodepoints = 8; // 保留字符串末尾至多 n 个 UTF-8 码点。假设 text 是合法 UTF-8(词库行不保证, // 但搭配查询遇到乱码最多损失一次命中,不值得为它做完整校验)。 std::string tail_codepoints(const std::string &text, size_t n) { const size_t total = utf8_codepoints(text); if (total <= n) return text; size_t skip = total - n; size_t i = 0; while (i < text.size() && skip > 0) { ++i; --skip; while (i < text.size() && (static_cast(text[i]) & 0xC0) == 0x80) ++i; } return text.substr(i); } bool covers_all_syllables(const WordItem &item, size_t n_syllables) { if (n_syllables == 0) return false; if (utf8_codepoints(item.word) == n_syllables) return true; if (!item.canonical_pinyin.empty() && syllable_count_from_key(item.canonical_pinyin) == n_syllables) return true; return false; } } // namespace size_t generated_sentence_insert_position(const std::vector &candidates, const Segments &syllables) { if (syllables.empty()) return 0; // 只有「键与输入完全相等」才算精确命中。音节数相等是不够的:词库查不到精确键时 // 会退到前缀区间扫描(key >= "gun'qi" AND key < …),gun'qi 因此会捞出 gun'qiu // 的「滚球」——同样 2 个音节 2 个字,按音节数判会被误认成精确命中,把整句压到 // 它们后面。模糊音展开出来的行同理,键与输入不同,也不该挡住整句。 const std::string typed_key = join_span(syllables); const auto is_exact_full_key_hit = [&](const WordItem &item) { if (item.source != CandidateSource::Database && item.source != CandidateSource::UserDatabase) return false; if (!item.canonical_pinyin.empty()) return item.canonical_pinyin == typed_key; // 没带 canonical key 的行无从比对,退回旧的字数判断。词典层现在每一行都带 // key,这条只为不给历史路径制造回归。 return covers_all_syllables(item, syllables.size()); }; size_t insert_at = 0; while (insert_at < candidates.size() && is_exact_full_key_hit(candidates[insert_at])) ++insert_at; return insert_at; } std::vector decode_word_lattice(const Segments &syllables, const WordLatticeLookup &lookup, const WordLatticeOptions &options) { if (syllables.empty() || !lookup) return {}; const size_t n = syllables.size(); const ngram::LanguageModel *model = active_model(options); const auto graph = build_graph(syllables, lookup, options); std::vector> columns(n + 1); Hyp start; start.score = 0.0; // Not begin_state(): the shipped sc.lm is built from an ARPA without // /, so a sentence-start context would only carry a word the model // has no n-grams for. Starting from the empty context makes the first word // score as a plain unigram, which is what that model can actually express. if (model) start.state = model->null_state(); columns[0].push_back(std::move(start)); for (size_t pos = 0; pos < n; ++pos) { keep_beam(columns[pos], options.beam); if (columns[pos].empty()) continue; for (int hi = 0; hi < static_cast(columns[pos].size()); ++hi) { const Hyp &hyp = columns[pos][static_cast(hi)]; for (const auto &edge : graph[pos]) { Hyp next; next.score = hyp.score + edge.base_score; if (model) next.score += model->score(hyp.state, edge.index, next.state); if (options.collocation_scorer && options.collocation_weight > 0.0) { // 句尾词把 is_rear 置真:.gram 的「词+$」rear 项据此参与打分。 next.score += options.collocation_weight * options.collocation_scorer(hyp.collocation_tail, edge.word, edge.end == n); next.collocation_tail = tail_codepoints(hyp.collocation_tail + edge.word, kCollocationTailCodepoints); } next.prev_pos = static_cast(pos); next.prev_idx = hi; next.word = edge.word; next.key = edge.key; columns[edge.end].push_back(std::move(next)); } } } auto &final_col = columns[n]; if (final_col.empty()) return {}; std::sort(final_col.begin(), final_col.end(), [](const Hyp &a, const Hyp &b) { return a.score > b.score; }); const int take = (std::min)(options.nbest, static_cast(final_col.size())); std::vector paths; paths.reserve(static_cast(take)); std::unordered_set seen; for (int i = 0; i < static_cast(final_col.size()) && static_cast(paths.size()) < take; ++i) { LatticePath path; path.log_prob = final_col[static_cast(i)].score; int pos = static_cast(n); int idx = i; std::vector keys; while (pos > 0 && idx >= 0) { const Hyp &h = columns[static_cast(pos)][static_cast(idx)]; path.words.push_back(h.word); keys.push_back(h.key); pos = h.prev_pos; idx = h.prev_idx; } std::reverse(path.words.begin(), path.words.end()); std::reverse(keys.begin(), keys.end()); for (const auto &w : path.words) path.sentence += w; for (size_t k = 0; k < keys.size(); ++k) { if (k) path.key.push_back('\''); path.key += keys[k]; } if (path.sentence.empty() || !seen.insert(path.sentence).second) continue; paths.push_back(std::move(path)); } return paths; } void merge_lattice_candidates(std::vector &candidates, const Segments &syllables, const WordLatticeLookup &lookup, const std::string &typed_pinyin, const WordLatticeOptions &options, const std::vector &rerankers) { if (!lookup || syllables.size() < 2) return; if (!has_only_complete_pinyin_segments(syllables)) return; auto paths = options.precomputed_paths.empty() ? decode_word_lattice(syllables, lookup, options) : options.precomputed_paths; if (paths.empty()) return; // n-best 是解码的中间产物,不是要给用户看的东西:整个 n-best 铺出来,候选区会被一批 // 只差一个字的整句占满。这里只留各来源的首选,n-best 的其余部分仅供重排挑选。 // 每个重排器都从相同的静态顺序开始,不能让前一个模型的结论变成后一个的输入。保留完整顺序, // 这样首选与已有候选重复时可以从该来源自己的次选继续补位。 std::optional> desktop_paths; std::optional> keyboard_paths; std::vector, CandidateSource>> other_ranked_sources; bool keyboard_enabled = false; for (const SourcedLatticeReranker &entry : rerankers) { if (entry.source == CandidateSource::NeuralKeyboard) keyboard_enabled = true; std::vector reranked_paths = paths; if (!entry.rerank || !entry.rerank(reranked_paths)) continue; if (entry.source == CandidateSource::NeuralDesktop) desktop_paths = std::move(reranked_paths); else if (entry.source == CandidateSource::NeuralKeyboard) keyboard_paths = std::move(reranked_paths); else other_ranked_sources.emplace_back(std::move(reranked_paths), entry.source); } const auto source_prefers = [](const std::optional> &ranked_paths, const std::string &sentence) { return ranked_paths && !ranked_paths->empty() && ranked_paths->front().sentence == sentence; }; const auto unigram_position = std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.source == CandidateSource::Fallback; }); const bool unigram_consensus = unigram_position != candidates.end() && ((options.include_lattice_best && paths.front().sentence == unigram_position->word) || source_prefers(keyboard_paths, unigram_position->word) || source_prefers(desktop_paths, unigram_position->word)); std::unordered_set already; for (const auto &item : candidates) already.insert(item.word); const auto first_distinct = [&](const std::vector &ranked_paths) -> const LatticePath * { for (const LatticePath &path : ranked_paths) { if (already.find(path.sentence) != already.end()) { if (options.show_next_on_duplicate) continue; break; } return &path; } return nullptr; }; const auto select_distinct = [&](const std::vector &ranked_paths, CandidateSource source) -> std::optional { const LatticePath *path = first_distinct(ranked_paths); if (path == nullptr) return std::nullopt; already.insert(path->sentence); // Often negative (log-space). Ranking is insert order, not weight. const auto weight = static_cast(path->log_prob * 1000.0); WordItem item(typed_pinyin, path->sentence, weight, source, path->key); item.sentence_association = true; return item; }; // Existing rows include Unigram, so the lattice row avoids it before the neural models choose // their rows. The lattice's own best path is shown as Colocation when octagram is on (single // injection: octagram only affects the decode, so paths.front() is its result), otherwise as // Generated. Selecting Collocation first means the shared sentence keeps that label and the // Generated row is deduplicated away. std::optional lattice_item; std::optional keyboard_item; std::optional desktop_item; if (options.include_collocation_best) lattice_item = select_distinct(paths, CandidateSource::Collocation); if (!lattice_item && options.include_lattice_best) lattice_item = select_distinct(paths, CandidateSource::Generated); const bool lattice_consensus = lattice_item && lattice_item->word == paths.front().sentence && (source_prefers(keyboard_paths, paths.front().sentence) || source_prefers(desktop_paths, paths.front().sentence)); bool keyboard_before_desktop = false; if (keyboard_paths && desktop_paths) { // Compare each model's best row after excluding Unigram and Trigram, but before the neural // models exclude each other. Keyboard owns a shared best; otherwise desktop keeps its own // best and is displayed first. const LatticePath *keyboard_best = first_distinct(*keyboard_paths); const LatticePath *desktop_best = first_distinct(*desktop_paths); keyboard_before_desktop = keyboard_best != nullptr && desktop_best != nullptr && keyboard_best->sentence == desktop_best->sentence; if (keyboard_before_desktop) { keyboard_item = select_distinct(*keyboard_paths, CandidateSource::NeuralKeyboard); desktop_item = select_distinct(*desktop_paths, CandidateSource::NeuralDesktop); } else { desktop_item = select_distinct(*desktop_paths, CandidateSource::NeuralDesktop); keyboard_item = select_distinct(*keyboard_paths, CandidateSource::NeuralKeyboard); } } else if (keyboard_paths) { keyboard_item = select_distinct(*keyboard_paths, CandidateSource::NeuralKeyboard); } // When both neural modes are enabled, desktop waits for keyboard to finish. Otherwise desktop // could briefly claim keyboard's best distinct row and then visibly change on the next query. else if (desktop_paths && !keyboard_enabled) desktop_item = select_distinct(*desktop_paths, CandidateSource::NeuralDesktop); std::vector other_items; for (const auto &[ranked_paths, source] : other_ranked_sources) { if (auto item = select_distinct(ranked_paths, source)) other_items.push_back(std::move(*item)); } std::optional promoted_unigram; if (unigram_consensus) { promoted_unigram = std::move(*unigram_position); candidates.erase(unigram_position); } // Consensus from another source promotes Unigram first and the lattice row ahead of neural rows. // A shared neural best belongs to keyboard; when the neural best rows differ, desktop goes first. std::vector extra; if (promoted_unigram) extra.push_back(std::move(*promoted_unigram)); if (lattice_consensus) extra.push_back(std::move(*lattice_item)); if (keyboard_before_desktop) { if (keyboard_item) extra.push_back(std::move(*keyboard_item)); if (desktop_item) extra.push_back(std::move(*desktop_item)); } else { if (desktop_item) extra.push_back(std::move(*desktop_item)); if (keyboard_item) extra.push_back(std::move(*keyboard_item)); } extra.insert(extra.end(), std::make_move_iterator(other_items.begin()), std::make_move_iterator(other_items.end())); if (lattice_item && !lattice_consensus) extra.push_back(std::move(*lattice_item)); if (extra.empty()) return; const size_t insert_at = generated_sentence_insert_position(candidates, syllables); candidates.insert(candidates.begin() + static_cast(insert_at), extra.begin(), extra.end()); } void merge_lattice_candidates(std::vector &candidates, const Segments &syllables, const WordLatticeLookup &lookup, const std::string &typed_pinyin, const WordLatticeOptions &options, const LatticeReranker &rerank, CandidateSource rerank_source) { std::vector rerankers; if (rerank) rerankers.push_back({rerank, rerank_source}); merge_lattice_candidates(candidates, syllables, lookup, typed_pinyin, options, rerankers); } } // namespace quanpin