feat(engine): 神经网络整句联想与词格 n-best 重排

- 新增 engine/neural:移植 chinese-ime-lm 的字符级 Transformer 推理,在词格解出的最多 12 条
  n-best 里重排挑选整句,desktop / keyboard 两档模型各出一条首选,后台 worker 异步打分
- 词格支持 n-best 输出与重排(quanpin/lattice_rerank),全拼、双拼共用
- 新增 SentenceAssociationOptions,经 QueryRequest 传给各 provider;四个来源默认全关
- 资源契约登记两个 safetensors 与 NOTICE;模型二进制不进仓库,由
  scripts/fetch-neural-model.ps1 按 neural-model/lock.json 下载并校验 SHA256
- 新增 neural_sentence 测试
This commit is contained in:
fanlusky committed 2026-09-24 14:03:39 +08:00
1 parent 589092e603
commit e7f7addd4f
38 files changed
+3344 -39

No files matched your search

+4
View File
@@ -22,4 +22,8 @@ log.txt
/language-model/cache/
/language-model/sc.lm
/language-model/sc.lm.incoming
# 神经整句模型:lock.json 和 NOTICE.md 进仓库,下载下来的 safetensors 不进 ——
# 由 scripts/fetch-neural-model.ps1 按 lock.json 从 release 重新取到并校验 sha256。
/neural-model/*.safetensors
__pycache__/
+9
View File
@@ -50,6 +50,9 @@ set(METASEQUOIA_IME_ENGINE_SOURCES
local_modes/kaomoji_query.cpp
local_modes/quick_phrase_query.cpp
local_modes/unicode_query.cpp
neural/sentence_model.cpp
neural/neural_decoder.cpp
neural/rescore_worker.cpp
providers/japanese_candidate_provider.cpp
providers/pinyin_candidate_provider.cpp
providers/provider_registry.cpp
@@ -58,6 +61,7 @@ set(METASEQUOIA_IME_ENGINE_SOURCES
quanpin/quanpin_dictionary.cpp
quanpin/quanpin_query.cpp
quanpin/quanpin_utils.cpp
quanpin/lattice_rerank.cpp
quanpin/word_lattice.cpp
schemes/japanese_romaji_scheme.cpp
schemes/quanpin_scheme.cpp
@@ -207,6 +211,11 @@ if(BUILD_TESTING)
target_link_libraries(test_engine_smoke PRIVATE MetasequoiaIme::Engine)
add_test(NAME engine_smoke COMMAND test_engine_smoke)
# 神经整句排序集成测试:需数据目录里备好 sentence-model*.safetensors,缺文件时优雅跳过。
add_executable(test_neural_sentence tests/src/test_neural_sentence.cpp)
target_link_libraries(test_neural_sentence PRIVATE MetasequoiaIme::Engine)
add_test(NAME neural_sentence COMMAND test_neural_sentence)
add_executable(test_input_session tests/src/test_input_session.cpp)
target_compile_definitions(test_input_session PRIVATE METASEQUOIA_SKIP_FREQUENCY_TESTS)
target_link_libraries(test_input_session PRIVATE MetasequoiaIme::Engine)
+3
View File
@@ -11,6 +11,9 @@ inline constexpr char japanese_model[] = "dict_japanese.dat";
inline constexpr char japanese_notice[] = "mozc_dictionary_oss_README.txt";
inline constexpr char dictionary_manifest[] = "dictionary-manifest.json";
inline constexpr char language_model[] = "sc.lm";
inline constexpr char neural_model_desktop[] = "sentence-model-desktop.safetensors";
inline constexpr char neural_model_keyboard[] = "sentence-model.safetensors";
inline constexpr char notice_19[] = "licenses/chinese-ime-lm-NOTICE.md";
inline constexpr char pinyin_model[] = "dict_pinyin.dat";
inline constexpr char translations[] = "custom_translations.txt";
inline constexpr char helpcode_lantian[] = "helpcodes/helpcode.txt";
+27
View File
@@ -66,6 +66,33 @@
"desktop"
]
},
{
"id": "neural_model_desktop",
"path": "sentence-model-desktop.safetensors",
"role": "resource",
"source": "neural-model/sentence-model-desktop.safetensors",
"profiles": [
"desktop"
]
},
{
"id": "neural_model_keyboard",
"path": "sentence-model.safetensors",
"role": "resource",
"source": "neural-model/sentence-model.safetensors",
"profiles": [
"desktop"
]
},
{
"id": "notice_19",
"path": "licenses/chinese-ime-lm-NOTICE.md",
"role": "notice",
"source": "neural-model/NOTICE.md",
"profiles": [
"desktop"
]
},
{
"id": "pinyin_model",
"path": "dict_pinyin.dat",
+2
View File
@@ -288,6 +288,8 @@ void ImeSession::apply_request_options(QueryRequest &request) const
(quanpin_autocorrect_types_ & quanpin::kAutocorrectTransposition) != 0;
request.enable_quanpin_autocorrect_neighbor = (quanpin_autocorrect_types_ & quanpin::kAutocorrectNeighbor) != 0;
request.fuzzy_pinyin = fuzzy_pinyin_;
request.sentence_association = sentence_association_;
request.rescoring_context = rescoring_context_;
}
void ImeSession::refresh_candidates()
+12
View File
@@ -3,6 +3,7 @@
#include "composition_state.h"
#include "input_session_types.h"
#include "scheme_type.h"
#include "sentence_association_options.h"
#include "../providers/provider_registry.h"
#include "../schemes/input_scheme.h"
#include "../schemes/wubi_scheme.h"
@@ -25,6 +26,15 @@ class ImeSession
{
fuzzy_pinyin_ = options;
}
void set_sentence_association(const SentenceAssociationOptions &options)
{
sentence_association_ = options;
}
// 本会话最近上屏的文本,随查询下发给神经重排当前文。
void set_rescoring_context(std::string context)
{
rescoring_context_ = std::move(context);
}
void set_wubi_input_options(metasequoia::WubiInputOptions options);
void replace_shuangpin_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases);
void replace_quanpin_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases);
@@ -90,6 +100,8 @@ class ImeSession
bool enable_quanpin_helpcode_ = false;
unsigned quanpin_autocorrect_types_ = 0;
metasequoia::FuzzyPinyinOptions fuzzy_pinyin_;
SentenceAssociationOptions sentence_association_;
std::string rescoring_context_;
metasequoia::WubiInputOptions wubi_options_;
// Resolved when the scheme changes rather than on every keystroke.
WubiScheme *wubi_scheme_ = nullptr;
+2 -1
View File
@@ -978,7 +978,8 @@ std::optional<std::string> InputSession::learn_candidate(std::size_t index)
// 整句候选走造词,不走调频:它在词库里没有行可改。不受 frequency_adjustment_
// 约束(那是候选词频策略),也不跳过 index == 0——首位整句同样没有落库,
// 下次仍要靠猜,存下来才稳定。
if (selected.source == CandidateSource::Generated || selected.source == CandidateSource::Fallback)
if (selected.source == CandidateSource::Generated || selected.source == CandidateSource::Fallback ||
selected.source == CandidateSource::NeuralDesktop || selected.source == CandidateSource::NeuralKeyboard)
{
return learn_sentence_candidate(selected);
}
+8
View File
@@ -209,6 +209,14 @@ class InputSession
{
engine_.set_fuzzy_pinyin_options(options);
}
void set_sentence_association(const SentenceAssociationOptions &options)
{
engine_.set_sentence_association(options);
}
void set_rescoring_context(std::string context)
{
engine_.set_rescoring_context(std::move(context));
}
void set_chinese_punctuation_enabled(bool enabled)
{
chinese_punctuation_enabled_ = enabled;
+2 -1
View File
@@ -526,7 +526,8 @@ int InputSession::store_user_phrase_from_canonical_pinyin(std::string pinyin, st
return canonical_phrase_engine_->create_word_from_canonical_pinyin(std::move(pinyin), std::move(word));
}
// 整句候选(词格 CandidateSource::Generated、Google 解码器 CandidateSource::Fallback)
// 整句候选(词格 CandidateSource::Generated、Google 解码器 CandidateSource::Fallback、
// 神经整句 NeuralDesktop / NeuralKeyboard)
// 是猜出来的,词库里没有它那一行,所以调频对它无效:update_weight_by_pinyin_and_word
// 改的是 SQLite 里已存在的行,找不到行就什么也不做,用户于是发现自己选多少次都提不上来。
// 选中即落库才是对的处理:存成用户词组之后,下次同样的输入它以 UserDatabase 候选出现,
+7
View File
@@ -3,6 +3,7 @@
#include "key_event.h"
#include "fuzzy_pinyin_options.h"
#include "scheme_type.h"
#include "sentence_association_options.h"
#include <string>
#include <vector>
@@ -30,5 +31,11 @@ struct QueryRequest
bool enable_quanpin_autocorrect_neighbor = false;
std::vector<KeyStroke> key_strokes;
metasequoia::FuzzyPinyinOptions fuzzy_pinyin;
// 整句候选来源与去重补位选项,默认全关。
SentenceAssociationOptions sentence_association;
// 给神经重排看的前文:本会话最近上屏的文本。空着也能重排,只是模型看不到语境,「shanghai」
// 到底是上海还是伤害就只能靠词格自己的静态分。词典层只取末尾若干字(RerankOptions::
// context_chars),所以这里给多了也无妨。
std::string rescoring_context;
bool valid = false;
};
@@ -0,0 +1,30 @@
#pragma once
// 整句联想的开关。全部默认关闭,由用户在设置里手动开启。
//
// 整句候选最多四行:词格首选、Google 解码器,以及两个神经模型的首选各一条。
//
// 前两个各是一条独立来源的整句。
//
// 后两个不一样。神经模型不再自己造句,它只在词格内部解出的最多 12 条 n-best 里挑一条(见
// engine/neural/neural_decoder.h)。它们不依赖 word_lattice 的显示开关:只开神经时词格仍会在内部
// 生成备选,但只显示神经选中的句子;同时开 word_lattice 时才额外保留词格首选作对照。两个神经
// 开关都开时各自独立打分,各出一条首选;两边选中相同文本时合并成一条。
struct SentenceAssociationOptions
{
bool word_lattice = false; // kenlm 词格整句,CandidateSource::Generated
bool google = false; // Google 解码器整句,CandidateSource::Fallback
bool neural_desktop = false; // 用高精度档的神经模型重排词格整句
bool neural_keyboard = false; // 用快速档的神经模型重排词格整句
bool show_next_on_duplicate = false; // 某来源首选被去重时,显示该来源下一条不同结果
bool operator==(const SentenceAssociationOptions &other) const
{
return word_lattice == other.word_lattice && google == other.google && neural_desktop == other.neural_desktop &&
neural_keyboard == other.neural_keyboard && show_next_on_duplicate == other.show_next_on_duplicate;
}
bool operator!=(const SentenceAssociationOptions &other) const
{
return !(*this == other);
}
};
+6
View File
@@ -16,6 +16,12 @@ enum class CandidateSource
Kaomoji,
Generated,
Fallback,
// 神经整句重排挑中的那一条:句子本身还是词格解出来的 n-best,chinese-ime-lm 的字符级
// Transformer 只负责在里面挑最准的一句,挑中的那行记在这里,词格自己的第一名仍记
// Generated。重排器弃权或后台还没算完时不会出现这两个来源。desktop 档更准更慢,
// keyboard 档更快。两者都属于「猜出来的整句」,落库/学习判定与 Generated/Fallback 同类。
NeuralDesktop,
NeuralKeyboard,
};
struct WordItem
+464
View File
@@ -0,0 +1,464 @@
#pragma once
// Minimal, self-contained JSON parser for the safetensors header.
//
// The core engine target does not depend on a JSON library (nlohmann is only pulled in by the
// optional `voice` feature), and the safetensors header is small and regular. Rather than add a
// build-wide dependency, this parses the handful of shapes the loader needs: the flat object of
// tensor descriptors, plus the `__metadata__` block whose `config` and `vocab` values are
// themselves JSON strings parsed a second time.
//
// It is a straightforward recursive-descent parser over UTF-8 text. serde_json (which writes the
// files) emits raw UTF-8 for non-ASCII, but `\uXXXX` escapes are handled anyway so a re-encoded
// file still loads.
#include <cstdint>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace neural::json
{
struct Value
{
enum class Type
{
Null,
Bool,
Number,
String,
Array,
Object,
};
Type type = Type::Null;
bool boolean = false;
double number = 0.0;
std::string string;
std::vector<Value> array;
std::vector<std::pair<std::string, Value>> object;
bool is_object() const
{
return type == Type::Object;
}
bool is_array() const
{
return type == Type::Array;
}
bool is_string() const
{
return type == Type::String;
}
bool is_number() const
{
return type == Type::Number;
}
// Linear lookup: objects here have at most a few dozen keys, and the tensor table is read once.
const Value *find(const std::string &key) const
{
for (const auto &entry : object)
{
if (entry.first == key)
{
return &entry.second;
}
}
return nullptr;
}
};
class ParseError : public std::runtime_error
{
public:
explicit ParseError(const std::string &message) : std::runtime_error(message)
{
}
};
namespace detail
{
class Parser
{
public:
explicit Parser(const std::string &text) : text_(text)
{
}
Value parse()
{
skip_whitespace();
Value value = parse_value();
skip_whitespace();
if (position_ != text_.size())
{
fail("trailing characters after JSON value");
}
return value;
}
private:
const std::string &text_;
size_t position_ = 0;
[[noreturn]] void fail(const std::string &message) const
{
throw ParseError("json: " + message + " at offset " + std::to_string(position_));
}
char peek() const
{
if (position_ >= text_.size())
{
fail("unexpected end of input");
}
return text_[position_];
}
char next()
{
char c = peek();
++position_;
return c;
}
void expect(char c)
{
if (next() != c)
{
fail(std::string("expected '") + c + "'");
}
}
void skip_whitespace()
{
while (position_ < text_.size())
{
char c = text_[position_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
{
++position_;
}
else
{
break;
}
}
}
Value parse_value()
{
skip_whitespace();
char c = peek();
switch (c)
{
case '{':
return parse_object();
case '[':
return parse_array();
case '"': {
Value value;
value.type = Value::Type::String;
value.string = parse_string();
return value;
}
case 't':
case 'f':
return parse_bool();
case 'n':
return parse_null();
default:
return parse_number();
}
}
Value parse_object()
{
Value value;
value.type = Value::Type::Object;
expect('{');
skip_whitespace();
if (peek() == '}')
{
++position_;
return value;
}
while (true)
{
skip_whitespace();
std::string key = parse_string();
skip_whitespace();
expect(':');
Value child = parse_value();
value.object.emplace_back(std::move(key), std::move(child));
skip_whitespace();
char c = next();
if (c == ',')
{
continue;
}
if (c == '}')
{
break;
}
fail("expected ',' or '}' in object");
}
return value;
}
Value parse_array()
{
Value value;
value.type = Value::Type::Array;
expect('[');
skip_whitespace();
if (peek() == ']')
{
++position_;
return value;
}
while (true)
{
value.array.push_back(parse_value());
skip_whitespace();
char c = next();
if (c == ',')
{
continue;
}
if (c == ']')
{
break;
}
fail("expected ',' or ']' in array");
}
return value;
}
Value parse_bool()
{
Value value;
value.type = Value::Type::Bool;
if (text_.compare(position_, 4, "true") == 0)
{
position_ += 4;
value.boolean = true;
}
else if (text_.compare(position_, 5, "false") == 0)
{
position_ += 5;
value.boolean = false;
}
else
{
fail("invalid literal");
}
return value;
}
Value parse_null()
{
Value value;
value.type = Value::Type::Null;
if (text_.compare(position_, 4, "null") == 0)
{
position_ += 4;
}
else
{
fail("invalid literal");
}
return value;
}
Value parse_number()
{
size_t start = position_;
if (position_ < text_.size() && (text_[position_] == '-' || text_[position_] == '+'))
{
++position_;
}
while (position_ < text_.size())
{
char c = text_[position_];
if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
{
++position_;
}
else
{
break;
}
}
if (position_ == start)
{
fail("invalid number");
}
Value value;
value.type = Value::Type::Number;
try
{
value.number = std::stod(text_.substr(start, position_ - start));
}
catch (const std::exception &)
{
fail("invalid number");
}
return value;
}
std::string parse_string()
{
expect('"');
std::string out;
while (true)
{
if (position_ >= text_.size())
{
fail("unterminated string");
}
char c = text_[position_++];
if (c == '"')
{
break;
}
if (c == '\\')
{
if (position_ >= text_.size())
{
fail("unterminated escape");
}
char escape = text_[position_++];
switch (escape)
{
case '"':
out.push_back('"');
break;
case '\\':
out.push_back('\\');
break;
case '/':
out.push_back('/');
break;
case 'b':
out.push_back('\b');
break;
case 'f':
out.push_back('\f');
break;
case 'n':
out.push_back('\n');
break;
case 'r':
out.push_back('\r');
break;
case 't':
out.push_back('\t');
break;
case 'u':
append_unicode(out);
break;
default:
fail("invalid escape");
}
}
else
{
out.push_back(c);
}
}
return out;
}
unsigned read_hex4()
{
if (position_ + 4 > text_.size())
{
fail("truncated \\u escape");
}
unsigned code = 0;
for (int i = 0; i < 4; ++i)
{
char c = text_[position_++];
code <<= 4;
if (c >= '0' && c <= '9')
{
code |= static_cast<unsigned>(c - '0');
}
else if (c >= 'a' && c <= 'f')
{
code |= static_cast<unsigned>(c - 'a' + 10);
}
else if (c >= 'A' && c <= 'F')
{
code |= static_cast<unsigned>(c - 'A' + 10);
}
else
{
fail("invalid hex digit in \\u escape");
}
}
return code;
}
void append_unicode(std::string &out)
{
unsigned code = read_hex4();
if (code >= 0xD800 && code <= 0xDBFF)
{
// High surrogate: a low surrogate must follow to form a code point above the BMP.
if (position_ + 2 > text_.size() || text_[position_] != '\\' || text_[position_ + 1] != 'u')
{
fail("lone high surrogate in \\u escape");
}
position_ += 2;
unsigned low = read_hex4();
if (low < 0xDC00 || low > 0xDFFF)
{
fail("invalid low surrogate in \\u escape");
}
code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
}
append_utf8(out, code);
}
static void append_utf8(std::string &out, unsigned code)
{
if (code <= 0x7F)
{
out.push_back(static_cast<char>(code));
}
else if (code <= 0x7FF)
{
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
}
else if (code <= 0xFFFF)
{
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
}
else
{
out.push_back(static_cast<char>(0xF0 | (code >> 18)));
out.push_back(static_cast<char>(0x80 | ((code >> 12) & 0x3F)));
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
}
}
};
} // namespace detail
// Parses `text` as JSON. Throws ParseError on malformed input.
inline Value parse(const std::string &text)
{
return detail::Parser(text).parse();
}
} // namespace neural::json
+103
View File
@@ -0,0 +1,103 @@
#include "neural_decoder.h"
#include "sentence_model.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <memory>
#include <mutex>
#include <numeric>
namespace neural
{
namespace
{
// The lattice reports log10 (kenlm's unit); the model reports natural log. Interpolating the two
// without this would silently scale lambda by 2.3.
constexpr double kLog10ToNats = 2.302585092994046;
} // namespace
std::string last_characters(const std::string &text, std::size_t count)
{
if (count == 0)
{
return {};
}
std::size_t seen = 0;
std::size_t start = text.size();
for (std::size_t i = text.size(); i-- > 0;)
{
if ((static_cast<unsigned char>(text[i]) & 0xC0) == 0x80)
{
continue; // continuation byte
}
start = i;
if (++seen == count)
{
break;
}
}
return text.substr(start);
}
const SentenceModel *shared_sentence_model(const std::string &path)
{
static std::mutex mutex;
// Value may be null: an entry records that loading `path` was attempted, so a missing file is
// not re-read on every keystroke.
static std::map<std::string, std::unique_ptr<SentenceModel>> cache;
std::lock_guard<std::mutex> lock(mutex);
auto it = cache.find(path);
if (it == cache.end())
{
it = cache.emplace(path, SentenceModel::load_file(path)).first;
}
return it->second.get();
}
std::vector<std::size_t> rerank_order(const SentenceModel &model, const std::string &context,
const std::vector<std::string> &sentences,
const std::vector<double> &static_scores, const RerankOptions &options)
{
if (sentences.size() < 2 || sentences.size() != static_scores.size() || options.lambda == 0.0)
{
return {};
}
const std::size_t scored_count = std::min(sentences.size(), options.max_paths);
if (scored_count < 2)
{
return {};
}
const std::vector<std::string> texts(sentences.begin(),
sentences.begin() + static_cast<std::ptrdiff_t>(scored_count));
const std::vector<double> neural = model.score_sentences(last_characters(context, options.context_chars), texts);
if (neural.size() != scored_count)
{
// The model failed or was inconsistent. Half a reranking is worse than none, so the caller
// keeps its own order.
return {};
}
std::vector<double> combined(scored_count, 0.0);
for (std::size_t i = 0; i < scored_count; ++i)
{
const double statik = static_scores[i] * kLog10ToNats;
combined[i] = statik + options.lambda * (neural[i] - statik);
}
std::vector<std::size_t> order(sentences.size());
std::iota(order.begin(), order.end(), std::size_t{0});
// Stable so that sentences the two models score identically keep the lattice's ordering, and so
// that the unscored tail past max_paths stays as it was.
std::stable_sort(order.begin(), order.begin() + static_cast<std::ptrdiff_t>(scored_count),
[&](std::size_t a, std::size_t b) { return combined[a] > combined[b]; });
return order;
}
} // namespace neural
+77
View File
@@ -0,0 +1,77 @@
#pragma once
// Neural reranking of whole-sentence candidates.
//
// The model does not produce sentences. The word lattice (engine/quanpin/word_lattice.*) already
// decodes the typed pinyin into its n-best sentences with a kenlm trigram; this layer asks the
// character-level Transformer how likely each of those sentences is given what the user has already
// committed, and reorders them by
//
// score = static + lambda * (neural - static)
//
// both terms in nats. That is a log-linear interpolation of the two language models, with lambda
// controlling how much the neural model is allowed to override the lattice. It cannot invent a
// sentence the lattice did not propose, which is the point: the lattice is good at coverage and bad
// at long-range choices, and the Transformer is the reverse.
//
// This replaces an earlier design that ran a per-syllable beam search over the model to generate
// sentences directly. That needed one full forward pass per beam per syllable with no K/V reuse --
// on the order of 10^10 multiply-accumulates for a dozen syllables, seconds of wall clock on the
// typing path. Scoring a small batch of finished sentences against a cached context is far less work, because
// the sentences come from the lattice for free and the batch streams the weights once.
//
// The word lattice always supplies the paths when neural reranking is enabled. Its own candidate
// does not have to be enabled for display: in that mode the lattice is only an internal candidate
// generator and the user sees the neural model's pick.
#include <cstddef>
#include <string>
#include <vector>
namespace neural
{
class SentenceModel;
struct RerankOptions
{
// How much of the model's opinion to apply. 0 leaves the lattice order untouched; 1 replaces
// the lattice score outright. 0.5 keeps the lattice's own evidence (user dictionary weights,
// reading priors) in play, which matters because the neural model has never seen them.
double lambda = 0.5;
// Paths to rescore. The lattice's own ordering past the first handful is already noise, and
// every extra path is another row in the batch.
std::size_t max_paths = 12;
// Characters of committed text shown to the model as context. Bounded by the model's own
// window; 64 matches what the model was trained to use.
std::size_t context_chars = 64;
};
// Last `count` Unicode characters of `text`, not bytes: the context bound is in characters because
// the model's window is in characters. Exposed so a caller that caches results keyed by context can
// trim to exactly what will be scored -- two histories with the same tail are the same context, and
// keying on the untrimmed text would miss the cache on every commit.
std::string last_characters(const std::string &text, std::size_t count);
// Loads (once, process-wide) and returns the model at `path`, or nullptr if it is missing or
// invalid. The returned pointer is owned by the cache and lives for the process; a failed load is
// remembered so it is not retried on every keystroke.
const SentenceModel *shared_sentence_model(const std::string &path);
// The order `sentences` should be shown in, as indices into it.
//
// `static_scores` are the lattice's own path scores in log10, as LatticePath::log_prob reports
// them; they are converted to nats here. `context` is the text already committed before the
// composition, and is truncated to the last `options.context_chars` characters.
//
// Returns an empty vector when the model could not be consulted at all (fewer than two sentences,
// lambda of zero, mismatched inputs). The caller must then leave its own order alone: a partial
// reorder is worse than none, because it mixes two incomparable rankings.
//
// Only the first `options.max_paths` sentences are rescored; any beyond that keep their relative
// order at the end.
std::vector<std::size_t> rerank_order(const SentenceModel &model, const std::string &context,
const std::vector<std::string> &sentences,
const std::vector<double> &static_scores, const RerankOptions &options = {});
} // namespace neural
+166
View File
@@ -0,0 +1,166 @@
#include "rescore_worker.h"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <utility>
namespace neural
{
namespace
{
// 结果表只是给「同一次输入被重查一遍」兜底的,不是长期缓存:装满就整张丢掉,比维护 LRU 简单,
// 代价也只是重算一次。
constexpr std::size_t kMaxResults = 128;
constexpr std::size_t kWorkerCount = 2;
std::string make_key(const SentenceModel &model, const std::string &context, const std::vector<std::string> &sentences)
{
// \x01 不会出现在 UTF-8 文本里,拿它当分隔符就不会把两批不同的候选拼成同一个键。
std::string key = std::to_string(reinterpret_cast<std::uintptr_t>(&model));
key.push_back('\x01');
key += context;
for (const std::string &sentence : sentences)
{
key.push_back('\x01');
key += sentence;
}
return key;
}
} // namespace
RescoreWorker &RescoreWorker::instance()
{
// 故意泄漏:后台线程只碰这个对象的成员,而静态对象的析构发生在退出时线程可能已被系统干掉之
// 后,join 会挂、detach 会悬空。永不析构把这道选择题去掉,进程退出时操作系统收走就是了。
static RescoreWorker *worker = new RescoreWorker();
return *worker;
}
void RescoreWorker::set_ready_callback(Ready ready)
{
std::lock_guard<std::mutex> lock(mutex_);
ready_ = std::move(ready);
}
void RescoreWorker::clear()
{
std::lock_guard<std::mutex> lock(mutex_);
done_.clear();
pending_.clear();
}
std::optional<std::vector<std::size_t>> RescoreWorker::order_for(const SentenceModel &model, const std::string &context,
const std::vector<std::string> &sentences,
const std::vector<double> &static_scores,
const RerankOptions &options)
{
std::string key = make_key(model, context, sentences);
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_)
{
return std::nullopt;
}
const auto found = done_.find(key);
if (found != done_.end())
{
return found->second;
}
Job job;
job.model = &model;
job.key = std::move(key);
job.context = context;
job.sentences = sentences;
job.static_scores = static_scores;
job.options = options;
pending_[&model] = std::move(job);
ensure_threads();
}
wake_.notify_one();
return std::nullopt;
}
void RescoreWorker::shutdown()
{
std::unique_lock<std::mutex> lock(mutex_);
stopping_ = true;
ready_ = nullptr; // 目标很可能已经析构了,这之后一律不再回调
pending_.clear(); // 排着队还没开始的,直接不要了
wake_.notify_all();
idle_.wait(lock, [this] { return busy_workers_ == 0; });
}
void RescoreWorker::ensure_threads()
{
// 调用方持有 mutex_。两个执行槽让速度优先模型不必等效果优先模型打完分。
// 线程只在第一次真有活干时才起,没开神经重排的用户不会平白多线程。
if (threads_started_)
{
return;
}
threads_started_ = true;
for (std::size_t i = 0; i < kWorkerCount; ++i)
std::thread([this] { run(); }).detach();
std::atexit([] { instance().shutdown(); });
}
void RescoreWorker::run()
{
for (;;)
{
Job job;
{
std::unique_lock<std::mutex> lock(mutex_);
wake_.wait(lock, [this] {
return stopping_ || std::any_of(pending_.begin(), pending_.end(), [this](const auto &item) {
return active_models_.find(item.first) == active_models_.end();
});
});
if (stopping_)
{
return;
}
auto next = std::find_if(pending_.begin(), pending_.end(), [this](const auto &item) {
return active_models_.find(item.first) == active_models_.end();
});
job = std::move(next->second);
pending_.erase(next);
active_models_.insert(job.model);
++busy_workers_;
}
// 打分在锁外做:它是这里最慢的一步,占着锁会让按键路径上的 order_for 跟着等。
std::vector<std::size_t> order =
rerank_order(*job.model, job.context, job.sentences, job.static_scores, job.options);
Ready ready;
{
std::lock_guard<std::mutex> lock(mutex_);
// 期间可能又来了新请求,说明用户还在打字,这一份存下来留给「他退回来」的情况。
if (done_.size() >= kMaxResults)
{
done_.clear();
}
done_.emplace(std::move(job.key), std::move(order));
ready = ready_; // shutdown 已经清过就是空的,那就不叫了
}
// 回调在锁外发:它会一路走到重查,重查又会调 order_for,在锁内发就是自锁。busy_ 要压到回
// 调之后才放,否则 shutdown 会在回调正往已析构的目标里走的时候就返回。
if (ready)
{
ready();
}
{
std::lock_guard<std::mutex> lock(mutex_);
active_models_.erase(job.model);
--busy_workers_;
}
wake_.notify_all();
idle_.notify_all();
}
}
} // namespace neural
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// 神经整句重排的后台线程。
//
// 给十二条候选打一次分要几十毫秒——比一次按键的预算大一个数量级,所以它不能长在按键路径上。这里
// 把它挪开:查询照旧同步返回词格的静态顺序,同时把这一批候选交给后台线程;线程算完把顺序存进结
// 果表,再叫醒调用方,调用方丢掉候选缓存重查一次,这次就能在表里查到顺序了。第一次查询按原顺序
// 出,几十毫秒后原地重排——用户看到的是候选先出来、再自己理顺,而不是按键卡住。
//
// 「每个模型最新的赢」:用户还在打字时,同一模型前几次请求的结果已经没人要,所以新请求直接盖掉
// 该模型的旧待办。不同模型各有一个待办位和独立执行槽,速度优先不会排在效果优先后面等待。
//
// 结果表按 (模型, 前文, 候选列表) 做键,不含静态分:同一批候选的静态分来自同一次词格解码,
// 必然相同。模型必须入键,否则两个模型会误读彼此的排序结果。
#include "neural_decoder.h"
#include "sentence_model.h"
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <vector>
namespace neural
{
class RescoreWorker
{
public:
using Ready = std::function<void()>;
// 进程唯一,且故意不析构(见 .cpp)。
static RescoreWorker &instance();
// 把「算完了」的通知接出去,调用方据此重查。不设也行:结果照样算、照样进表,只是要等下一次
// 按键才被取走。
void set_ready_callback(Ready ready);
// 有现成结果就返回;没有就把这一批排上队并返回空,调用方这一次照静态顺序走。
std::optional<std::vector<std::size_t>> order_for(const SentenceModel &model, const std::string &context,
const std::vector<std::string> &sentences,
const std::vector<double> &static_scores,
const RerankOptions &options);
// 上屏、换方案、换前文:存着的顺序对新语境没有意义了。
void clear();
// 停工,并等当前这一批算完。
//
// 必须有:进程退出时先跑静态析构、再终止别的线程,中间这一段里后台线程还活着,手上却握着已
// 经析构掉的模型表和回调目标(回调一路通到服务端的任务队列)。第一次起线程时用 atexit 挂上
// 它——atexit 是后注册先执行,而模型表、任务队列都是更早注册的,正好排在它后面。
void shutdown();
private:
RescoreWorker() = default;
~RescoreWorker() = delete;
RescoreWorker(const RescoreWorker &) = delete;
RescoreWorker &operator=(const RescoreWorker &) = delete;
struct Job
{
const SentenceModel *model = nullptr;
std::string key;
std::string context;
std::vector<std::string> sentences;
std::vector<double> static_scores;
RerankOptions options;
};
void ensure_threads();
void run();
std::mutex mutex_;
std::condition_variable wake_; // 有活干了,或者该收工了
std::condition_variable idle_; // 手上这一批算完了
bool threads_started_ = false;
std::size_t busy_workers_ = 0;
bool stopping_ = false;
std::map<const SentenceModel *, Job> pending_;
std::set<const SentenceModel *> active_models_;
std::map<std::string, std::vector<std::size_t>> done_;
Ready ready_;
};
} // namespace neural
File diff suppressed because it is too large. Load diff
+185
View File
@@ -0,0 +1,185 @@
#pragma once
// Character-level Transformer "sentence model" from the chinese-ime-lm project, ported to C++.
//
// This is a faithful port of the Apache-2.0 Rust reference implementation
// (chinese-ime-lm/reference/src/{weights.rs,lib.rs}) and the format spec (docs/format.md). The
// model is a decoder-only Pre-LN Transformer with tied input/output embeddings; given some
// committed context it produces a log-probability distribution over the next character.
//
// The primitive the IME actually uses is score_sentences: log P(sentence | context) for a handful
// of already-formed sentences at once, which neural_decoder.* turns into a reranking of the word
// lattice's n-best paths. next_log_probabilities remains as the single-step reference API that the
// parity tests check against the Rust implementation.
//
// Two things make the scorer fast enough to run on the typing path, and both matter far more than
// the arithmetic itself. The weights are ~25 MB, which does not fit in cache, so a forward pass is
// memory-bandwidth bound: every matmul must stream the whole matrix. Therefore (1) the context's
// per-layer K/V is cached (PrefixCache) so an unchanged context is never recomputed, and (2) all
// candidates are run as one batch through each layer, so each weight matrix is streamed once for
// the whole batch instead of once per candidate per token.
//
// Numerical details that MUST match the reference or results are garbage: exact erf-based GELU
// (not the tanh approximation), LayerNorm eps 1e-5, attention scale 1/sqrt(n_embd/n_head), int8
// per-output-row symmetric dequant (scale applied once after the dot product), and the rule that
// position i predicts token i+1.
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace neural
{
// A weight matrix kept in whatever precision the file stored it in. Quantized matrices are not
// expanded on load: the per-output-row scale is a constant factor for the whole row, so it lifts
// out of the dot product and multiplies the result once.
struct Matrix
{
bool quantized = false;
std::vector<float> floats; // used when !quantized
std::vector<std::int8_t> ints; // used when quantized
std::vector<float> scales; // per output row, used when quantized
std::size_t len() const
{
return quantized ? ints.size() : floats.size();
}
// dot(x, row) * scale[row], where the scale is 1 for an unquantized matrix.
float dot_row(const float *x, std::size_t row, std::size_t width) const;
// One row as f32, for the token table read as an embedding as well as an output projection.
void row_into(std::size_t index, std::size_t width, float *out) const;
};
struct Config
{
std::size_t vocab = 0;
std::size_t n_layer = 0;
std::size_t n_head = 0;
std::size_t n_embd = 0;
std::size_t context = 0;
};
struct Block
{
std::vector<float> ln1_weight;
std::vector<float> ln1_bias;
Matrix qkv_weight;
std::vector<float> qkv_bias;
Matrix proj_weight;
std::vector<float> proj_bias;
std::vector<float> ln2_weight;
std::vector<float> ln2_bias;
Matrix fc_weight;
std::vector<float> fc_bias;
Matrix out_weight;
std::vector<float> out_bias;
};
// Per-layer keys and values for a fixed run of leading tokens, each [length, n_embd] row-major.
// Every batch row of a later segment attends to the same cache, so a context shared by all the
// candidates is paid for once.
struct PrefixCache
{
std::vector<std::vector<float>> keys; // one entry per layer
std::vector<std::vector<float>> values; // one entry per layer
std::size_t length = 0; // tokens covered, i.e. the position the next token takes
};
class SentenceModel
{
public:
// Loads a model from an in-memory safetensors file. Returns nullptr on any malformed input
// rather than throwing, so a missing or corrupt model leaves the IME working without it.
static std::unique_ptr<SentenceModel> load(const std::vector<std::uint8_t> &bytes);
// Loads a model from disk. Returns nullptr when the file is absent or unreadable/invalid.
static std::unique_ptr<SentenceModel> load_file(const std::string &path);
std::size_t context_length() const
{
return config_.context;
}
const std::string &attribution() const
{
return attribution_;
}
const std::string &license() const
{
return license_;
}
// The token id this model uses for a character (a Unicode code point), if any.
std::optional<std::uint32_t> token(char32_t character) const;
// log P(next character | text), over the whole vocabulary. Index by token id.
std::vector<float> next_log_probabilities(const std::string &text) const;
// Sum over characters of log P(character | context and everything before it in the text), one
// entry per text. Characters outside the vocabulary score as <unk>, so a text is never dropped.
//
// The whole batch is one pass: `context` (minus its last token) becomes a PrefixCache reused
// across calls while the context is unchanged, and the texts are padded to equal length and run
// together. An empty `texts` returns empty. A text longer than the model's context window is
// truncated from the left, as is the context when the two together do not fit.
//
// Thread-safe: the prefix cache is guarded, and concurrent callers with different contexts are
// correct but will evict each other.
std::vector<double> score_sentences(const std::string &context, const std::vector<std::string> &texts) const;
private:
static constexpr std::uint32_t kUnk = 1;
static constexpr std::uint32_t kBos = 2;
Config config_;
std::unordered_map<char32_t, std::uint32_t> index_;
Matrix token_;
std::vector<float> position_;
std::vector<Block> blocks_;
std::vector<float> final_weight_;
std::vector<float> final_bias_;
std::string attribution_;
std::string license_;
// Guards the cached prefix below. mutable so scoring stays const.
mutable std::mutex cache_mutex_;
mutable std::vector<std::uint32_t> cached_tokens_;
mutable PrefixCache cached_prefix_;
std::vector<std::uint32_t> encode(const std::string &text) const;
std::vector<float> embed(const std::vector<std::uint32_t> &tokens, std::size_t width, std::size_t offset) const;
// Runs `batch` rows of `width` tokens (row-major in `tokens`, padded to equal length by the
// caller) at positions [past_len, past_len + width), where every row additionally attends to
// `past`. Returns the final-LayerNorm'd hidden states, [batch * width, n_embd].
//
// When `record` is non-null the K/V produced here is stored into it, which requires batch == 1
// (a cache is shared by later rows, so it may only ever describe one sequence).
std::vector<float> forward(const std::vector<std::uint32_t> &tokens, std::size_t batch, std::size_t width,
const PrefixCache *past, PrefixCache *record) const;
// Builds the K/V cache for `tokens`, which are taken to start at position 0.
PrefixCache build_prefix(const std::vector<std::uint32_t> &tokens) const;
// log-softmax over the vocabulary for one hidden state.
std::vector<float> distribution(const std::vector<float> &hidden) const;
// For each of `rows` hidden states, log P(targets[row]) under that state's distribution. Rows
// with target kNoTarget are skipped and report 0.
static constexpr std::uint32_t kNoTarget = 0xFFFFFFFFu;
std::vector<float> target_log_probs(const std::vector<float> &hidden, std::size_t rows,
const std::vector<std::uint32_t> &targets) const;
void attention(const std::vector<float> &q, const std::vector<float> &k, const std::vector<float> &v,
std::size_t batch, std::size_t width, const std::vector<float> *past_k,
const std::vector<float> *past_v, std::size_t past_len, std::vector<float> &out) const;
void feed_forward(std::vector<float> &x, const Block &block) const;
};
} // namespace neural
+5
View File
@@ -38,6 +38,11 @@ std::vector<WordItem> QuanpinEngine::query(const QueryRequest &request)
return {};
}
// 整句联想开关随请求下发到词典层,四条整句来源据此各自门控(见 query_series)。
dictionary_.set_sentence_association(request.sentence_association);
// 前文同理随请求下发;换了前文,按旧前文重排出来的缓存顺序作废。
dictionary_.set_rescoring_context(request.rescoring_context);
const size_t helpcode_length =
request.enable_quanpin_helpcode
? quanpin::detect_active_helpcode_length(request.raw_input, request.raw_input_with_cases)
+56
View File
@@ -0,0 +1,56 @@
#include "lattice_rerank.h"
#include "../neural/rescore_worker.h"
#include <optional>
#include <vector>
namespace quanpin
{
LatticeReranker make_neural_reranker(const neural::SentenceModel *model, const std::string &context,
const neural::RerankOptions &options)
{
if (model == nullptr)
{
return {};
}
// 在这里就把前文裁到模型真正会看的那几十个字:后台重排的结果表以「前文 + 候选句」为键,
// 拿没裁过的整段历史当键的话,每上屏一个字键就变了,缓存永远打不中。
const std::string trimmed = neural::last_characters(context, options.context_chars);
return [model, context = trimmed, options](std::vector<LatticePath> &paths) -> bool {
if (paths.size() < 2)
{
return false;
}
std::vector<std::string> sentences;
std::vector<double> scores;
sentences.reserve(paths.size());
scores.reserve(paths.size());
for (const LatticePath &path : paths)
{
sentences.push_back(path.sentence);
scores.push_back(path.log_prob);
}
// 不在这里打分:交给后台线程,第一次必然落空,按词格的静态顺序先出候选。线程算完会叫
// 醒会话重查一次,那一次才在结果表里拿到顺序。
const std::optional<std::vector<std::size_t>> order =
neural::RescoreWorker::instance().order_for(*model, context, sentences, scores, options);
if (!order || order->size() != paths.size())
{
return false; // 还没算出来,或者模型自己弃权了:词格的顺序原样留着
}
std::vector<LatticePath> reordered;
reordered.reserve(paths.size());
for (std::size_t index : *order)
{
reordered.push_back(paths[index]);
}
paths = std::move(reordered);
return true;
};
}
} // namespace quanpin
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// Bridges the word lattice's n-best paths to the neural sentence model.
//
// word_lattice.* knows nothing about the model and engine/neural knows nothing about lattices; this
// is the one place that speaks both. Quanpin and shuangpin share it because their sentence pipelines
// are the same lattice over the same syllables.
#include "../neural/neural_decoder.h"
#include "word_lattice.h"
#include <string>
namespace quanpin
{
// A reranker for merge_lattice_candidates that reorders paths by the neural model, or an empty
// std::function when `model` is null (the model file is absent, or the feature is switched off).
// An empty reranker is not an error: the lattice's own order stands.
//
// `context` is the text already committed before the composition, used as the model's conditioning
// prefix. Empty is fine and simply scores each sentence on its own. Pass the whole history: it is
// trimmed here to the last `options.context_chars` characters, which is what actually gets scored.
LatticeReranker make_neural_reranker(const neural::SentenceModel *model, const std::string &context,
const neural::RerankOptions &options = {});
} // namespace quanpin
+77 -20
View File
@@ -239,6 +239,10 @@ QuanpinDictionary::QuanpinDictionary(std::string db_path, metasequoia::RuntimePa
decoder_(paths_.resource(metasequoia::assets::pinyin_model),
paths_.user(metasequoia::assets::pinyin_user_dictionary)),
language_model_(&ngram::shared_language_model(paths_.resource(metasequoia::assets::language_model))),
neural_desktop_model_(neural::shared_sentence_model(
metasequoia::path_to_utf8(paths_.resource(metasequoia::assets::neural_model_desktop)))),
neural_keyboard_model_(neural::shared_sentence_model(
metasequoia::path_to_utf8(paths_.resource(metasequoia::assets::neural_model_keyboard)))),
db_path_(db_path.empty() ? metasequoia::path_to_utf8(paths_.dictionary(metasequoia::assets::main_dictionary))
: std::move(db_path))
{
@@ -522,33 +526,58 @@ std::vector<WordItem> QuanpinDictionary::query_series(const std::string &raw_inp
//
// 不管走哪条,送进去之前都要把 ü 换成它认的写法:它的音节表里只有 nue/lue,
// nve 会被拆成 nv + e。见 quanpin::to_google_spelling。
const std::string google_input =
raw_input.find('\'') != std::string::npos
? quanpin::to_google_spelling(raw_input)
: remove_delimiters(quanpin::to_google_spelling(segmentation.empty() ? raw_input : segmentation));
const std::string google_sentence = search_sentence_from_ime_engine(google_input);
if (!google_sentence.empty())
// Google 整句(Fallback):仅在开关打开时才解码、才出候选。
if (sentence_association_.google)
{
const auto duplicate = std::find_if(result.begin(), result.end(),
[&](const WordItem &item) { return item.word == google_sentence; });
if (duplicate == result.end())
const std::string google_input =
raw_input.find('\'') != std::string::npos
? quanpin::to_google_spelling(raw_input)
: remove_delimiters(quanpin::to_google_spelling(segmentation.empty() ? raw_input : segmentation));
const std::string google_sentence = search_sentence_from_ime_engine(google_input);
if (!google_sentence.empty())
{
// 整句 fallback 必须带上 canonical quanpin,否则以它结尾的造词无法落库:
// update_creating_word_progress 依赖 canonical_pinyin 才能拼出完整读音。
// segmentation 为空时保持为空,交由既有逻辑判定为不可落库。
const size_t insert_at = quanpin::generated_sentence_insert_position(result, segments);
result.insert(result.begin() + static_cast<std::ptrdiff_t>(insert_at),
WordItem(segmentation.empty() ? raw_input : segmentation, google_sentence, 1,
CandidateSource::Fallback, segmentation));
const auto duplicate = std::find_if(result.begin(), result.end(),
[&](const WordItem &item) { return item.word == google_sentence; });
if (duplicate == result.end())
{
// 整句 fallback 必须带上 canonical quanpin,否则以它结尾的造词无法落库:
// update_creating_word_progress 依赖 canonical_pinyin 才能拼出完整读音。
// segmentation 为空时保持为空,交由既有逻辑判定为不可落库。
const size_t insert_at = quanpin::generated_sentence_insert_position(result, segments);
result.insert(result.begin() + static_cast<std::ptrdiff_t>(insert_at),
WordItem(segmentation.empty() ? raw_input : segmentation, google_sentence, 1,
CandidateSource::Fallback, segmentation));
}
}
}
// 词格给 Trigram 候选和神经模型共用:只开神经时仍在内部解出 n-best 供它重排,
// 但不把词格自己的首选显示出来。神经模型不自己造句,只在这批路径中选一句。
std::vector<quanpin::SourcedLatticeReranker> neural_rerankers;
if (sentence_association_.neural_keyboard && neural_keyboard_model_ != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_keyboard_model_, rescoring_context_),
CandidateSource::NeuralKeyboard});
}
if (sentence_association_.neural_desktop && neural_desktop_model_ != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_desktop_model_, rescoring_context_),
CandidateSource::NeuralDesktop});
}
quanpin::WordLatticeOptions lattice_options;
lattice_options.nbest = 1;
// 神经模型和去重补位都需要完整候选池;否则只解最优的一条。
const bool needs_alternatives = !neural_rerankers.empty() || (sentence_association_.word_lattice &&
sentence_association_.show_next_on_duplicate);
lattice_options.nbest = needs_alternatives ? static_cast<int>(neural::RerankOptions{}.max_paths) : 1;
lattice_options.include_lattice_best = sentence_association_.word_lattice;
lattice_options.show_next_on_duplicate = sentence_association_.show_next_on_duplicate;
lattice_options.language_model = language_model_;
quanpin::merge_lattice_candidates(
result, segments, quanpin::make_lattice_db_lookup(db_, statement_cache_, lattice_options.span_limit),
segmentation.empty() ? raw_input : segmentation, lattice_options);
if (sentence_association_.word_lattice || !neural_rerankers.empty())
{
quanpin::merge_lattice_candidates(
result, segments, quanpin::make_lattice_db_lookup(db_, statement_cache_, lattice_options.span_limit),
segmentation.empty() ? raw_input : segmentation, lattice_options, neural_rerankers);
}
}
if (result.size() < kSparsePinyinFallbackThreshold)
@@ -770,6 +799,11 @@ std::vector<WordItem> QuanpinDictionary::append_ime_fallback(const std::string &
{
return result;
}
// 这条兜底整句也来自 Google 解码器,随「Google 整句联想」开关一起关闭。
if (!sentence_association_.google)
{
return result;
}
const std::string normalized = remove_delimiters(segmentation.empty() ? raw_input : segmentation);
const std::string sentence = search_sentence_from_ime_engine(normalized);
@@ -1068,6 +1102,29 @@ void QuanpinDictionary::reset_cache()
segmentation_cache_.clear();
}
void QuanpinDictionary::set_sentence_association(const SentenceAssociationOptions &options)
{
if (sentence_association_ == options)
{
return;
}
sentence_association_ = options;
// 开关变了,之前缓存的候选列表按旧开关算出,必须清掉,否则打开/关闭后要到缓存过期
// 才见效。整句候选都进这几个缓存,见 query_series / query_single_path。
reset_cache();
}
void QuanpinDictionary::set_rescoring_context(const std::string &context)
{
if (rescoring_context_ == context)
{
return;
}
rescoring_context_ = context;
// 上文变了,缓存里的顺序是按旧上文重排出来的,同 set_sentence_association。
reset_cache();
}
void QuanpinDictionary::reset_cache_if_database_changed()
{
if (db_ == nullptr)
+19
View File
@@ -7,6 +7,9 @@
#include "../common/cache.h"
#include "../core/key_event.h"
#include "../core/word_item.h"
#include "../core/sentence_association_options.h"
#include "../neural/neural_decoder.h"
#include "lattice_rerank.h"
#include "quanpin_query.h"
#include "engine/ngram/language_model.h"
#include "../core/fuzzy_pinyin_options.h"
@@ -70,6 +73,14 @@ class QuanpinDictionary
std::string search_sentence_from_ime_engine(const std::string &user_pinyin);
// 设置整句候选来源与去重补位选项。值发生变化时清一次缓存,避免旧候选被继续复用。
void set_sentence_association(const SentenceAssociationOptions &options);
// 光标前已上屏的文本,作为神经重排给模型看的上下文。宿主能拿到应用里的前文就给它,
// 拿不到就不设——空上下文照样能排,只是模型只看句子本身、看不到上文。
// 变化时清一次缓存:同一串拼音在不同上文下的排序可以不同。
void set_rescoring_context(const std::string &context);
void reset_state();
void reset_cache();
@@ -144,6 +155,14 @@ class QuanpinDictionary
// 词格整句的打分模型(kenlm 三元),按资源路径进程内共享、只读。缺模型时
// valid() 为假,word_lattice 退回旧的启发式打分:整句候选只是变差,不会消失。
const ngram::LanguageModel *language_model_ = nullptr;
// 神经整句模型(chinese-ime-lm),按资源路径进程内共享、只读。两档可同时参与打分;
// 缺文件时对应的整句候选不出,不影响其它候选。
const neural::SentenceModel *neural_desktop_model_ = nullptr;
const neural::SentenceModel *neural_keyboard_model_ = nullptr;
// 整句候选来源与去重补位选项,默认全关;由 set_sentence_association 随请求更新。
SentenceAssociationOptions sentence_association_;
// 神经重排的上下文(光标前已上屏的文本)。宿主没给就是空串。
std::string rescoring_context_;
std::unordered_map<std::string, sqlite3_stmt *> statement_cache_;
std::string db_path_;
+152 -9
View File
@@ -3,7 +3,9 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <iterator>
#include <limits>
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <utility>
@@ -314,30 +316,160 @@ std::vector<LatticePath> decode_word_lattice(const Segments &syllables, const Wo
void merge_lattice_candidates(std::vector<WordItem> &candidates, const Segments &syllables,
const WordLatticeLookup &lookup, const std::string &typed_pinyin,
const WordLatticeOptions &options)
const WordLatticeOptions &options, const std::vector<SourcedLatticeReranker> &rerankers)
{
if (!lookup || syllables.size() < 2)
return;
if (!has_only_complete_pinyin_segments(syllables))
return;
const auto paths = decode_word_lattice(syllables, lookup, options);
auto paths = decode_word_lattice(syllables, lookup, options);
if (paths.empty())
return;
// n-best 是解码的中间产物,不是要给用户看的东西:整个 n-best 铺出来,候选区会被一批
// 只差一个字的整句占满。这里只留各来源的首选,n-best 的其余部分仅供重排挑选。
// 每个重排器都从相同的静态顺序开始,不能让前一个模型的结论变成后一个的输入。保留完整顺序,
// 这样首选与已有候选重复时可以从该来源自己的次选继续补位。
std::optional<std::vector<LatticePath>> desktop_paths;
std::optional<std::vector<LatticePath>> keyboard_paths;
std::vector<std::pair<std::vector<LatticePath>, CandidateSource>> other_ranked_sources;
bool keyboard_enabled = false;
for (const SourcedLatticeReranker &entry : rerankers)
{
if (entry.source == CandidateSource::NeuralKeyboard)
keyboard_enabled = true;
std::vector<LatticePath> 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<std::vector<LatticePath>> &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<std::string> already;
for (const auto &item : candidates)
already.insert(item.word);
std::vector<WordItem> extra;
for (const auto &path : paths)
{
if (!already.insert(path.sentence).second)
continue;
const auto first_distinct = [&](const std::vector<LatticePath> &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<LatticePath> &ranked_paths,
CandidateSource source) -> std::optional<WordItem> {
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<std::int64_t>(path.log_prob * 1000.0);
extra.emplace_back(typed_pinyin, path.sentence, weight, CandidateSource::Generated, path.key);
const auto weight = static_cast<std::int64_t>(path->log_prob * 1000.0);
return WordItem(typed_pinyin, path->sentence, weight, source, path->key);
};
// Existing rows include Unigram, so Trigram avoids it before the neural models choose their rows.
std::optional<WordItem> trigram_item;
std::optional<WordItem> keyboard_item;
std::optional<WordItem> desktop_item;
if (options.include_lattice_best)
trigram_item = select_distinct(paths, CandidateSource::Generated);
const bool trigram_consensus = trigram_item && trigram_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<WordItem> 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<WordItem> promoted_unigram;
if (unigram_consensus)
{
promoted_unigram = std::move(*unigram_position);
candidates.erase(unigram_position);
}
// Consensus from another source promotes Unigram first and Trigram ahead of neural rows. A
// shared neural best belongs to keyboard; when the neural best rows differ, desktop goes first.
std::vector<WordItem> extra;
if (promoted_unigram)
extra.push_back(std::move(*promoted_unigram));
if (trigram_consensus)
extra.push_back(std::move(*trigram_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 (trigram_item && !trigram_consensus)
extra.push_back(std::move(*trigram_item));
if (extra.empty())
return;
@@ -345,4 +477,15 @@ void merge_lattice_candidates(std::vector<WordItem> &candidates, const Segments
candidates.insert(candidates.begin() + static_cast<std::ptrdiff_t>(insert_at), extra.begin(), extra.end());
}
void merge_lattice_candidates(std::vector<WordItem> &candidates, const Segments &syllables,
const WordLatticeLookup &lookup, const std::string &typed_pinyin,
const WordLatticeOptions &options, const LatticeReranker &rerank,
CandidateSource rerank_source)
{
std::vector<SourcedLatticeReranker> rerankers;
if (rerank)
rerankers.push_back({rerank, rerank_source});
merge_lattice_candidates(candidates, syllables, lookup, typed_pinyin, options, rerankers);
}
} // namespace quanpin
+42 -1
View File
@@ -48,6 +48,11 @@ namespace quanpin
//
// Lattice WordItem.weight is log_prob * 1000 and is often negative. List
// order is the ranking; do not sort these rows by weight.
//
// merge_lattice_candidates inserts at most three rows, not the whole n-best: each
// reranker's pick and, when enabled, the lattice's own pick. The
// rest of the n-best exists so the reranker has something to choose from -- several
// near-identical sentences are a decoder detail, not a candidate list.
struct LatticeLexeme
{
@@ -68,6 +73,12 @@ struct WordLatticeOptions
{
int beam = 32;
int nbest = 5;
// Whether the lattice's own best path should become a visible candidate. Neural-only mode sets
// this to false: the lattice still supplies n-best paths to the reranker, but only its pick is shown.
bool include_lattice_best = true;
// Each visible source contributes at most one row. When its first choice duplicates an earlier
// candidate, continue down that source's own ranking until a distinct sentence is found.
bool show_next_on_duplicate = false;
// Cap on lexemes per span (injected lookups). The DB lookup applies the
// same cap itself, inside query_exact_segmentations_keyed_flat.
int span_limit = 32;
@@ -103,6 +114,23 @@ struct WordLatticeOptions
using WordLatticeLookup = std::function<std::vector<LatticeLexeme>(const Segments &span)>;
// Optional second opinion on the decoded n-best, applied before the paths become candidates.
// engine/neural supplies one that reorders them by a character-level Transformer; anything else
// that can rank finished sentences would plug in the same way. Reordering only -- a reranker must
// not add, drop, or edit paths, because their WordItem fields are derived from them afterwards.
//
// Returns whether it actually ranked the paths. False means it declined -- the model abstained, or
// (in the async case) its answer is not back yet -- and the order on return is still the lattice's.
// The caller needs this to attribute the surviving row: a reranker that ran and agreed leaves the
// same order as one that never ran, and those two are not the same thing to report.
using LatticeReranker = std::function<bool(std::vector<LatticePath> &paths)>;
struct SourcedLatticeReranker
{
LatticeReranker rerank;
CandidateSource source = CandidateSource::Generated;
};
// Index where a whole-sentence candidate belongs: just past the leading run of
// Database/UserDatabase hits whose key equals the typed syllables exactly.
// Prefix-range and fuzzy rows are not exact hits even when they happen to have
@@ -114,8 +142,21 @@ size_t generated_sentence_insert_position(const std::vector<WordItem> &candidate
std::vector<LatticePath> decode_word_lattice(const Segments &syllables, const WordLatticeLookup &lookup,
const WordLatticeOptions &options = {});
// `rerank_source` is the source stamped on the reranker's pick, so the candidate list can say which
// component chose that row. When options.include_lattice_best is true, the lattice's own pick stays
// CandidateSource::Generated; when false, no row is inserted until the reranker has produced a result.
void merge_lattice_candidates(std::vector<WordItem> &candidates, const Segments &syllables,
const WordLatticeLookup &lookup, const std::string &typed_pinyin,
const WordLatticeOptions &options = {});
const WordLatticeOptions &options = {}, const LatticeReranker &rerank = {},
CandidateSource rerank_source = CandidateSource::Generated);
// Runs every enabled reranker independently over the same original n-best. Each completed reranker
// may contribute one visible row. Existing candidates (including Unigram) and Trigram reserve their
// rows first. If both neural models have the same best remaining row, NeuralKeyboard owns it and is
// displayed first; otherwise NeuralDesktop keeps its best remaining row and is displayed first.
// A Unigram or Trigram best shared by another enabled source is promoted ahead of the neural rows.
void merge_lattice_candidates(std::vector<WordItem> &candidates, const Segments &syllables,
const WordLatticeLookup &lookup, const std::string &typed_pinyin,
const WordLatticeOptions &options, const std::vector<SourcedLatticeReranker> &rerankers);
} // namespace quanpin
+5
View File
@@ -117,6 +117,11 @@ std::vector<WordItem> ShuangpinEngine::query(const QueryRequest &request)
return {};
}
// 整句联想开关随请求下发到词典层,四条整句来源据此各自门控(见 generateSeries)。
dictionary_.set_sentence_association(request.sentence_association);
// 前文同理随请求下发;换了前文,按旧前文重排出来的缓存顺序作废。
dictionary_.set_rescoring_context(request.rescoring_context);
const std::string &raw_input = request.raw_input;
const std::string &raw_input_with_cases =
request.raw_input_with_cases.empty() ? request.raw_input : request.raw_input_with_cases;
+53 -7
View File
@@ -53,6 +53,10 @@ ShuangpinDictionary::ShuangpinDictionary(const ShuangpinProfile &profile, metase
: profile_(profile), paths_(std::move(paths)), decoder_(paths_.resource(metasequoia::assets::pinyin_model),
paths_.user(metasequoia::assets::pinyin_user_dictionary)),
language_model_(&ngram::shared_language_model(paths_.resource(metasequoia::assets::language_model))),
neural_desktop_model_(neural::shared_sentence_model(
metasequoia::path_to_utf8(paths_.resource(metasequoia::assets::neural_model_desktop)))),
neural_keyboard_model_(neural::shared_sentence_model(
metasequoia::path_to_utf8(paths_.resource(metasequoia::assets::neural_model_keyboard)))),
helpcodes_(HelpcodeUtils::load_helpcode_keymap(paths_.resources, HelpcodeUtils::selected_helpcode_schema())),
_kb_input_sequence(100), _cached_buffer(128), _cached_buffer_sgl(128), _cached_buffer_sgl_reversed(128),
_cached_buffer_dbl(128), _cached_buffer_series(128)
@@ -160,7 +164,7 @@ vector<ShuangpinDictionary::WordItem> ShuangpinDictionary::generateSeries( //
}
else
{ /* 可能数据库查询的结果是空,这时就需要联想,这个只适合在此处联想 */
if (candidate_list.size() == 0)
if (candidate_list.size() == 0 && sentence_association_.google)
{
string quanpin_str =
ShuangpinUtil::convert_seg_shuangpin_to_seg_complete_pinyin(pinyin_segmentation, profile_);
@@ -203,7 +207,8 @@ vector<ShuangpinDictionary::WordItem> ShuangpinDictionary::generateSeries( //
// 命中词库的候选之后;merge_lattice_candidates 算出的位置就是这条 Fallback
// 所在的下标,词格随后会落在它上面。
const auto quanpin_syllables = quanpin::split_segments(quanpin_segmentation);
if (quanpin_syllables.size() >= 2 && quanpin_segmentation.find('\'') != std::string::npos)
if (sentence_association_.google && quanpin_syllables.size() >= 2 &&
quanpin_segmentation.find('\'') != std::string::npos)
{
// 同上:解码器认 nue/lue,词库与下面的 canonical 读音认 nve/lve。
const std::string google_sentence =
@@ -219,13 +224,33 @@ vector<ShuangpinDictionary::WordItem> ShuangpinDictionary::generateSeries( //
WordItem(_pinyin_sequence, google_sentence, 1, CandidateSource::Fallback, quanpin_segmentation));
}
}
// 词格给 Trigram 候选和神经模型共用;只开神经时仍在内部解出 n-best,但不显示词格首选。
// 与全拼同构,见 QuanpinDictionary::query_series。
std::vector<quanpin::SourcedLatticeReranker> neural_rerankers;
if (sentence_association_.neural_keyboard && neural_keyboard_model_ != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_keyboard_model_, rescoring_context_),
CandidateSource::NeuralKeyboard});
}
if (sentence_association_.neural_desktop && neural_desktop_model_ != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_desktop_model_, rescoring_context_),
CandidateSource::NeuralDesktop});
}
quanpin::WordLatticeOptions lattice_options;
lattice_options.nbest = 1;
const bool needs_alternatives = !neural_rerankers.empty() || (sentence_association_.word_lattice &&
sentence_association_.show_next_on_duplicate);
lattice_options.nbest = needs_alternatives ? static_cast<int>(neural::RerankOptions{}.max_paths) : 1;
lattice_options.include_lattice_best = sentence_association_.word_lattice;
lattice_options.show_next_on_duplicate = sentence_association_.show_next_on_duplicate;
lattice_options.language_model = language_model_;
quanpin::merge_lattice_candidates(
candidate_list, quanpin_syllables,
quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, lattice_options.span_limit),
pinyin_sequence, lattice_options);
if (sentence_association_.word_lattice || !neural_rerankers.empty())
{
quanpin::merge_lattice_candidates(
candidate_list, quanpin_syllables,
quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, lattice_options.span_limit),
pinyin_sequence, lattice_options, neural_rerankers);
}
/* 缓存起来 */
_cached_buffer_series.insert(effective_cache_key, candidate_list);
@@ -1115,6 +1140,27 @@ void ShuangpinDictionary::reset_cache()
_cached_buffer_series.clear();
}
void ShuangpinDictionary::set_sentence_association(const SentenceAssociationOptions &options)
{
if (sentence_association_ == options)
{
return;
}
sentence_association_ = options;
// 开关变了就清缓存,否则打开/关闭要等缓存过期才见效。见 generateSeries 的整句块。
reset_cache();
}
void ShuangpinDictionary::set_rescoring_context(const std::string &context)
{
if (rescoring_context_ == context)
{
return;
}
rescoring_context_ = context;
reset_cache();
}
void ShuangpinDictionary::reset_cache_if_database_changed()
{
if (quanpin_db_ == nullptr)
+17
View File
@@ -8,6 +8,9 @@
#include "../common/cache.h"
#include "../core/key_event.h"
#include "../core/word_item.h"
#include "../core/sentence_association_options.h"
#include "../neural/neural_decoder.h"
#include "../quanpin/lattice_rerank.h"
#include "../quanpin/quanpin_query.h"
#include "engine/ngram/language_model.h"
#include "shuangpin_profile.h"
@@ -66,6 +69,12 @@ class ShuangpinDictionary
std::string search_sentence_from_ime_engine(const std::string &user_pinyin);
// 设置整句候选来源与去重补位选项。值变化时清缓存,见 QuanpinDictionary::set_sentence_association。
void set_sentence_association(const SentenceAssociationOptions &options);
// 神经重排的上下文,见 QuanpinDictionary::set_rescoring_context。
void set_rescoring_context(const std::string &context);
explicit ShuangpinDictionary(const ShuangpinProfile &profile = GetXiaoheShuangpinProfile(),
metasequoia::RuntimePaths paths = metasequoia::RuntimePaths::legacy());
~ShuangpinDictionary();
@@ -84,6 +93,14 @@ class ShuangpinDictionary
metasequoia::PinyinDecoder decoder_;
// 与全拼共用的词格打分模型,见 QuanpinDictionary::language_model_。
const ngram::LanguageModel *language_model_ = nullptr;
// 神经整句模型,见 QuanpinDictionary::neural_desktop_model_。与全拼共用同一份
// 进程内缓存(按资源路径),双拼这侧只是各自持一份指针。
const neural::SentenceModel *neural_desktop_model_ = nullptr;
const neural::SentenceModel *neural_keyboard_model_ = nullptr;
// 整句候选来源与去重补位选项,默认全关;由 set_sentence_association 随请求更新。
SentenceAssociationOptions sentence_association_;
// 神经重排的上下文(光标前已上屏的文本)。宿主没给就是空串。
std::string rescoring_context_;
HelpcodeUtils::SharedKeymap helpcodes_;
std::unordered_map<std::string, sqlite3_stmt *> quanpin_statement_cache_;
void reset_cache_if_database_changed();
+4
View File
@@ -71,6 +71,9 @@ set(
"../local_modes/kaomoji_query.cpp"
"../local_modes/quick_phrase_query.cpp"
"../local_modes/unicode_query.cpp"
"../neural/sentence_model.cpp"
"../neural/neural_decoder.cpp"
"../neural/rescore_worker.cpp"
"../providers/pinyin_candidate_provider.cpp"
"../providers/wubi_candidate_provider.cpp"
"../providers/japanese_candidate_provider.cpp"
@@ -83,6 +86,7 @@ set(
"../quanpin/quanpin_dictionary.cpp"
"../quanpin/quanpin_query.cpp"
"../quanpin/quanpin_utils.cpp"
"../quanpin/lattice_rerank.cpp"
"../quanpin/word_lattice.cpp"
"../shuangpin/engine.cpp"
"../common/helpcode_utils.cpp"
+150
View File
@@ -2,6 +2,8 @@
#include "../../core/data_path.h"
#include "../../english/english_dictionary.h"
#include "../../japanese/romaji_converter.h"
#include "../../neural/neural_decoder.h"
#include "../../quanpin/word_lattice.h"
#include "../../user_dictionary/user_dictionary_journal.h"
#include "test_directory_cleanup.h"
@@ -14,6 +16,8 @@
#include <filesystem>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>
namespace
{
@@ -84,6 +88,152 @@ int run_test()
throw std::runtime_error("Kana conversion changed during the platform port.");
}
{
// Neural-only mode still asks the lattice for twelve internal alternatives, but it must not
// expose the lattice's own pick while the asynchronous neural result is pending.
std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> table;
table["ni"] = {{"ni", "你", 4000}, {"ni", "拟", 3000}, {"ni", "泥", 2000}, {"ni", "逆", 1000}};
table["hao"] = {{"hao", "好", 4000}, {"hao", "号", 3000}, {"hao", "浩", 2000}, {"hao", "耗", 1000}};
const auto lookup = [&](const quanpin::Segments &span) {
std::string key;
for (const std::string &syllable : span)
{
if (!key.empty())
key.push_back('\'');
key += syllable;
}
const auto found = table.find(key);
return found == table.end() ? std::vector<quanpin::LatticeLexeme>{} : found->second;
};
quanpin::WordLatticeOptions options;
options.nbest = static_cast<int>(neural::RerankOptions{}.max_paths);
options.include_lattice_best = false;
options.show_next_on_duplicate = true;
const std::vector<quanpin::LatticePath> neural_paths =
quanpin::decode_word_lattice({"ni", "hao"}, lookup, options);
std::size_t desktop_alternatives = 0;
std::size_t keyboard_alternatives = 0;
std::vector<quanpin::SourcedLatticeReranker> rerankers;
rerankers.push_back({[&](std::vector<quanpin::LatticePath> &paths) {
desktop_alternatives = paths.size();
return true;
},
CandidateSource::NeuralDesktop});
rerankers.push_back({[&](std::vector<quanpin::LatticePath> &paths) {
keyboard_alternatives = paths.size();
return true;
},
CandidateSource::NeuralKeyboard});
std::vector<WordItem> selected;
quanpin::merge_lattice_candidates(selected, {"ni", "hao"}, lookup, "ni'hao", options, rerankers);
if (desktop_alternatives != 12 || keyboard_alternatives != 12 || selected.size() != 2 ||
selected[0].source != CandidateSource::NeuralKeyboard || selected[0].word != neural_paths[0].sentence ||
selected[1].source != CandidateSource::NeuralDesktop || selected[1].word != neural_paths[1].sentence)
{
throw std::runtime_error("Keyboard did not keep the shared neural best before desktop's next choice.");
}
std::vector<WordItem> different_neural_best;
const std::vector<quanpin::SourcedLatticeReranker> different_best_rerankers = {
{[](std::vector<quanpin::LatticePath> &paths) {
std::swap(paths[0], paths[1]);
return true;
},
CandidateSource::NeuralDesktop},
{[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(different_neural_best, {"ni", "hao"}, lookup, "ni'hao", options,
different_best_rerankers);
if (different_neural_best.size() != 2 || different_neural_best[0].source != CandidateSource::NeuralDesktop ||
different_neural_best[0].word != neural_paths[1].sentence ||
different_neural_best[1].source != CandidateSource::NeuralKeyboard ||
different_neural_best[1].word != neural_paths[0].sentence)
{
throw std::runtime_error("Desktop did not precede keyboard when their neural best rows differed.");
}
std::vector<WordItem> pending;
const std::vector<quanpin::SourcedLatticeReranker> pending_rerankers = {
{[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralDesktop},
{[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(pending, {"ni", "hao"}, lookup, "ni'hao", options, pending_rerankers);
if (!pending.empty())
{
throw std::runtime_error("Neural-only sentence selection exposed a lattice row before scoring finished.");
}
std::vector<WordItem> keyboard_ready;
const std::vector<quanpin::SourcedLatticeReranker> keyboard_ready_rerankers = {
{[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralDesktop},
{[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(keyboard_ready, {"ni", "hao"}, lookup, "ni'hao", options,
keyboard_ready_rerankers);
if (keyboard_ready.size() != 1 || keyboard_ready[0].source != CandidateSource::NeuralKeyboard ||
keyboard_ready[0].word != neural_paths[0].sentence)
{
throw std::runtime_error("Keyboard neural output waited for the desktop model.");
}
quanpin::WordLatticeOptions trigram_consensus_options = options;
trigram_consensus_options.include_lattice_best = true;
std::vector<WordItem> trigram_consensus;
const std::vector<quanpin::SourcedLatticeReranker> trigram_consensus_rerankers = {
{[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(trigram_consensus, {"ni", "hao"}, lookup, "ni'hao", trigram_consensus_options,
trigram_consensus_rerankers);
if (trigram_consensus.size() != 2 || trigram_consensus[0].source != CandidateSource::Generated ||
trigram_consensus[0].word != neural_paths[0].sentence ||
trigram_consensus[1].source != CandidateSource::NeuralKeyboard ||
trigram_consensus[1].word != neural_paths[1].sentence)
{
throw std::runtime_error("Trigram consensus was not promoted ahead of the neural rows.");
}
// Simulate an earlier Unigram row equal to every later source's first choice. Ownership must
// remain Unigram, Trigram, keyboard, desktop regardless of reranker input or completion order.
const std::vector<quanpin::LatticePath> static_paths =
quanpin::decode_word_lattice({"ni", "hao"}, lookup, options);
std::vector<WordItem> deduplicated;
deduplicated.emplace_back("ni'hao", static_paths.front().sentence, 1, CandidateSource::Fallback, "ni'hao");
options.include_lattice_best = true;
options.show_next_on_duplicate = true;
const std::vector<quanpin::SourcedLatticeReranker> duplicate_rerankers = {
{[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralDesktop},
{[](std::vector<quanpin::LatticePath> &paths) {
std::swap(paths[0], paths[1]);
return true;
},
CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(deduplicated, {"ni", "hao"}, lookup, "ni'hao", options, duplicate_rerankers);
if (deduplicated.size() != 4 || deduplicated[0].source != CandidateSource::Fallback ||
deduplicated[0].word != static_paths[0].sentence ||
deduplicated[1].source != CandidateSource::NeuralKeyboard ||
deduplicated[1].word != static_paths[2].sentence ||
deduplicated[2].source != CandidateSource::NeuralDesktop ||
deduplicated[2].word != static_paths[3].sentence || deduplicated[3].source != CandidateSource::Generated ||
deduplicated[3].word != static_paths[1].sentence)
{
throw std::runtime_error("Unigram consensus was not promoted before distinct fallback rows.");
}
// Desktop must wait while an enabled keyboard model is pending. Trigram can already appear
// because its ownership was fixed before either neural model.
std::vector<WordItem> partially_ready;
partially_ready.emplace_back("ni'hao", static_paths.front().sentence, 1, CandidateSource::Fallback, "ni'hao");
const std::vector<quanpin::SourcedLatticeReranker> partially_ready_rerankers = {
{[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralDesktop},
{[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralKeyboard}};
quanpin::merge_lattice_candidates(partially_ready, {"ni", "hao"}, lookup, "ni'hao", options,
partially_ready_rerankers);
if (partially_ready.size() != 2 || partially_ready[0].source != CandidateSource::Fallback ||
partially_ready[0].word != static_paths[0].sentence ||
partially_ready[1].source != CandidateSource::Generated ||
partially_ready[1].word != static_paths[1].sentence)
{
throw std::runtime_error("Desktop neural output appeared before keyboard ownership was fixed.");
}
}
const auto unique_suffix = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count());
const std::filesystem::path data_directory =
std::filesystem::temp_directory_path() / std::filesystem::u8path("metasequoia-engine-词库-" + unique_suffix);
+9
View File
@@ -638,6 +638,11 @@ int run_test()
database.execute("CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
{
metasequoia::InputSession sentence(SchemeType::Quanpin);
// 整句联想默认全关,这一段考的就是整句候选,得先把造句那两条打开。
SentenceAssociationOptions association;
association.word_lattice = true;
association.google = true;
sentence.set_sentence_association(association);
type(sentence, "na'yi'tiao");
const auto guessed = candidate_index(sentence, "那一条");
const auto guessed_source = sentence.candidates()[guessed].source;
@@ -660,6 +665,10 @@ int run_test()
{
// 学习整句是造词,不是调频:关掉候选学习的会话一条也不该落库。
metasequoia::InputSession unlearned(SchemeType::Quanpin, 0, true, true, false);
SentenceAssociationOptions association;
association.word_lattice = true;
association.google = true;
unlearned.set_sentence_association(association);
type(unlearned, "na'yi'na");
const auto guessed = candidate_index(unlearned, "那一那");
(void)unlearned.select_candidate(guessed);
+358
View File
@@ -0,0 +1,358 @@
// 神经整句重排的正确性与延迟测试。
//
// 模型现在只重排词格的 n-best,不再自己造句(见 engine/neural/neural_decoder.h)。快是靠两件
// 事换来的:前文的 K/V 缓存下来不重算,以及所有候选拼成一批一起过每一层。这两件事都可能算错
// 而不报错——位置编码串行、padding 行漏掉、缓存没失效,都只会让分数悄悄变样。所以主检查项是
// 对拍:score_sentences 的结果必须等于逐字调用 next_log_probabilities 累加出来的参考值,后者
// 是与 Rust 参考实现对齐过的那条老路径。
//
// 第三件事是打分挪到了后台线程(engine/neural/rescore_worker.h),所以走词典的那段集成检查只看得
// 到静态顺序——重排要等下一次重查。后台那条路单独测:请求一次必落空,回调到了再请求必命中。
//
// 缺 sentence-model*.safetensors 时与其它集成测试一样优雅跳过(返回 0)。用
// METASEQUOIA_IME_DATA_DIR 指到备好资源的目录来实际跑通。
#include "core/sentence_association_options.h"
#include "core/word_item.h"
#include "neural/neural_decoder.h"
#include "neural/rescore_worker.h"
#include "neural/sentence_model.h"
#include "quanpin/quanpin_dictionary.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
namespace
{
int failures = 0;
void expect(bool condition, const std::string &message)
{
if (!condition)
{
std::fprintf(stderr, "FAIL: %s\n", message.c_str());
++failures;
}
}
void expect_near(double actual, double expected, double tolerance, const std::string &message)
{
if (!(std::fabs(actual - expected) <= tolerance))
{
std::fprintf(stderr, "FAIL: %s (got %.6f, want %.6f)\n", message.c_str(), actual, expected);
++failures;
}
}
std::vector<std::string> characters(const std::string &text)
{
std::vector<std::string> out;
for (std::size_t i = 0; i < text.size();)
{
std::size_t width = 1;
const unsigned char c = static_cast<unsigned char>(text[i]);
if ((c & 0xF8) == 0xF0)
width = 4;
else if ((c & 0xF0) == 0xE0)
width = 3;
else if ((c & 0xE0) == 0xC0)
width = 2;
width = std::min(width, text.size() - i);
out.push_back(text.substr(i, width));
i += width;
}
return out;
}
char32_t code_point(const std::string &character)
{
const unsigned char c = static_cast<unsigned char>(character[0]);
if (c < 0x80)
return c;
char32_t code;
std::size_t extra;
if ((c & 0xE0) == 0xC0)
{
code = c & 0x1F;
extra = 1;
}
else if ((c & 0xF0) == 0xE0)
{
code = c & 0x0F;
extra = 2;
}
else
{
code = c & 0x07;
extra = 3;
}
for (std::size_t j = 1; j <= extra && j < character.size(); ++j)
code = (code << 6) | (static_cast<unsigned char>(character[j]) & 0x3F);
return code;
}
// 参考实现:一个字一个字地问「下一个字的分布」,取出实际那个字的 log 概率累加。每一步都重跑
// 整个前缀,正是重构掉的那条慢路径——留在这里当标尺。
double reference_score(const neural::SentenceModel &model, const std::string &context, const std::string &text)
{
double total = 0.0;
std::string prefix = context;
for (const std::string &character : characters(text))
{
const std::vector<float> distribution = model.next_log_probabilities(prefix);
// 词表外的字按 <unk> 记,与 SentenceModel::encode 一致。
const std::optional<std::uint32_t> id = model.token(code_point(character));
total += static_cast<double>(distribution[id ? *id : 1u]);
prefix += character;
}
return total;
}
} // namespace
int main()
{
const metasequoia::RuntimePaths paths = metasequoia::RuntimePaths::legacy();
const neural::SentenceModel *model = neural::shared_sentence_model(
metasequoia::path_to_utf8(paths.resource(metasequoia::assets::neural_model_keyboard)));
const neural::SentenceModel *desktop_model = neural::shared_sentence_model(
metasequoia::path_to_utf8(paths.resource(metasequoia::assets::neural_model_desktop)));
if (model == nullptr)
{
std::printf("Skipped: neural model (sentence-model.safetensors) not present in data directory.\n");
return 0;
}
// ---- 对拍:批量 + 缓存的结果必须等于逐字参考值 ----
const std::vector<std::pair<std::string, std::vector<std::string>>> cases = {
{"", {"你好世界", "拟好世界"}},
{"我今天想去", {"上海", "伤害", "吃饭"}},
// 长短不一,逼出 padding 行;最短的一条只有一个字。
{"明天的会议", {"推迟到下周一", "推迟", "退", "推迟到下周义"}},
};
for (const auto &[context, texts] : cases)
{
const std::vector<double> actual = model->score_sentences(context, texts);
expect(actual.size() == texts.size(), "score_sentences should return one score per text");
if (actual.size() != texts.size())
continue;
for (std::size_t i = 0; i < texts.size(); ++i)
{
// 容差放在 1e-3:两条路径的浮点累加次序不同,但结构上必须是同一个数。
expect_near(actual[i], reference_score(*model, context, texts[i]), 1e-3,
"batched score should match the per-character reference for '" + texts[i] + "'");
expect(actual[i] < 0.0, "a log probability should be negative for '" + texts[i] + "'");
}
}
// ---- 批量与单条一致:padding 行不能污染同批的其它行 ----
{
const std::string context = "我今天想去";
const std::vector<std::string> texts = {"上海", "伤害", "吃饭"};
const std::vector<double> batched = model->score_sentences(context, texts);
for (std::size_t i = 0; i < texts.size(); ++i)
{
const std::vector<double> alone = model->score_sentences(context, {texts[i]});
expect_near(alone[0], batched[i], 1e-4, "scoring alone should match scoring in a batch");
}
}
// ---- 前文缓存必须随前文失效,且换回来后结果不变 ----
{
const std::vector<std::string> texts = {"上海"};
const double first = model->score_sentences("我今天想去", texts)[0];
const double other = model->score_sentences("我昨天去了", texts)[0];
const double again = model->score_sentences("我今天想去", texts)[0];
expect_near(again, first, 1e-6, "the same context must score the same after the cache was evicted");
expect(std::fabs(other - first) > 1e-6, "a different context must actually change the score");
}
// ---- 超长输入不能崩:前文与候选都会被截断 ----
{
std::string long_context;
for (int i = 0; i < 80; ++i)
long_context += "很长的前文。";
expect(model->score_sentences(long_context, {"上海"})[0] < 0.0, "an over-long context should still score");
std::string long_text;
for (int i = 0; i < 200; ++i)
long_text += "字";
expect(model->score_sentences("", {long_text})[0] < 0.0, "an over-long candidate should still score");
}
// ---- rerank_order 的契约 ----
{
const std::vector<std::string> sentences = {"伤害", "上海"};
// 词格分(log10)打平,让神经分说了算。
const std::vector<double> flat = {-5.0, -5.0};
const std::vector<std::size_t> order = neural::rerank_order(*model, "我今天想去", sentences, flat);
expect(order.size() == sentences.size(), "rerank_order should return a full permutation");
if (order.size() == sentences.size())
{
expect(order[0] == 1, "with the lattice tied, the model should prefer 上海 after 我今天想去");
}
// lambda 为 0 等于不重排,调用方必须能看出「没排」。
neural::RerankOptions off;
off.lambda = 0.0;
expect(neural::rerank_order(*model, "", sentences, flat, off).empty(),
"lambda of zero should report that no reranking happened");
expect(neural::rerank_order(*model, "", {"只有一条"}, {-5.0}).empty(), "a single path cannot be reranked");
}
// ---- 前文确实在起作用,且按字数(不是字节数)裁剪 ----
{
// 前文不是摆设:同一个候选换个前文,分数必须跟着变。上屏历史一路传到这里的
// 链路(InputSession -> QueryRequest -> 词典 -> make_neural_reranker)断在任何
// 一环,模型拿到的都是空串,这一条就会挂。
const double after_go = model->score_sentences("我今天想去", {"上海"})[0];
const double after_hurt = model->score_sentences("他对我造成了很大的", {"上海"})[0];
expect(after_go != after_hurt, "the committed text must reach the model, not be dropped on the way");
expect(after_go > after_hurt, "上海 should fit 我今天想去 better than 他对我造成了很大的");
// 上屏历史整段给进来,模型只看末尾若干字;裁剪按 UTF-8 字符边界,不能切出半个汉字。
expect(neural::last_characters("我今天想去上海", 2) == "上海", "the tail should be counted in characters");
expect(neural::last_characters("上海", 99) == "上海", "a short context should come back whole");
expect(neural::last_characters("上海", 0).empty(), "a zero-character window should yield no context");
}
// ---- 后台线程:第一次必落空,算完之后同一批必命中 ----
{
const std::vector<std::string> sentences = {"伤害", "上海"};
const std::vector<double> flat = {-5.0, -5.0};
std::mutex mutex;
std::condition_variable ready;
bool fired = false;
neural::RescoreWorker &worker = neural::RescoreWorker::instance();
worker.clear();
worker.set_ready_callback([&] {
{
std::lock_guard<std::mutex> lock(mutex);
fired = true;
}
ready.notify_all();
});
// 按键路径上的那一次:不等模型,立刻返回空,调用方照词格顺序出候选。
expect(!worker.order_for(*model, "我今天想去", sentences, flat, {}).has_value(),
"the first request must not block the caller waiting for the model");
{
std::unique_lock<std::mutex> lock(mutex);
expect(ready.wait_for(lock, std::chrono::seconds(30), [&] { return fired; }),
"the background thread should finish and call back");
}
// 重查那一次:结果表里已经有顺序了。
const auto order = worker.order_for(*model, "我今天想去", sentences, flat, {});
expect(order.has_value(), "the finished order should be waiting for the requery");
if (order && order->size() == 2)
{
expect((*order)[0] == 1, "the background result should be the same ordering as the direct call");
}
worker.set_ready_callback(nullptr);
worker.clear();
}
// 两个模型共用一个后台线程,但待办和结果必须按模型隔离,不能后提交的覆盖前一个。
if (desktop_model != nullptr)
{
const std::vector<std::string> sentences = {"伤害", "上海"};
const std::vector<double> flat = {-5.0, -5.0};
std::mutex mutex;
std::condition_variable ready;
int fired = 0;
neural::RescoreWorker &worker = neural::RescoreWorker::instance();
worker.clear();
worker.set_ready_callback([&] {
{
std::lock_guard<std::mutex> lock(mutex);
++fired;
}
ready.notify_all();
});
expect(!worker.order_for(*desktop_model, "我今天想去", sentences, flat, {}).has_value(),
"the desktop model should enqueue its own first request");
expect(!worker.order_for(*model, "我今天想去", sentences, flat, {}).has_value(),
"the keyboard model should enqueue without replacing the desktop request");
{
std::unique_lock<std::mutex> lock(mutex);
expect(ready.wait_for(lock, std::chrono::seconds(30), [&] { return fired >= 2; }),
"both neural models should finish independently");
}
expect(worker.order_for(*desktop_model, "我今天想去", sentences, flat, {}).has_value(),
"the desktop result should remain in its own cache entry");
expect(worker.order_for(*model, "我今天想去", sentences, flat, {}).has_value(),
"the keyboard result should remain in its own cache entry");
worker.set_ready_callback(nullptr);
worker.clear();
}
// ---- 延迟:这次重构的全部意义 ----
{
const std::string context = "今天下午的会议讨论了输入法的排序问题,大家觉得整句转换还可以再准一些,";
const std::vector<std::string> texts = {"我们明天再讨论一下", "我们明天在讨论一下", "我们名天再讨论一下",
"我门明天再讨论一下", "我们明天再讨论以下", "我们明天再讨论一夏",
"我们明天再讨论一吓", "我们明天再讨论议下", "我们明天在讨论以下",
"我们名天在讨论一下", "我们明天再讨论一霞", "我们明天再讨论一侠"};
model->score_sentences(context, texts); // 预热,把前文灌进缓存
const auto start = std::chrono::steady_clock::now();
constexpr int kRuns = 10;
for (int i = 0; i < kRuns; ++i)
model->score_sentences(context, texts);
const double cached_ms =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count() / kRuns;
const auto cold_start = std::chrono::steady_clock::now();
for (int i = 0; i < kRuns; ++i)
model->score_sentences(context + std::to_string(i), texts); // 每次换前文,逼它重算缓存
const double cold_ms =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - cold_start).count() / kRuns;
std::printf("12 candidates, 34-char context: %.2f ms cached, %.2f ms recomputed\n", cached_ms, cold_ms);
}
// ---- 集成:两种神经模式可同时提交重排,且不泄漏词格自己的首选 ----
{
QuanpinDictionary dictionary;
SentenceAssociationOptions options;
options.neural_desktop = true;
options.neural_keyboard = true;
dictionary.set_sentence_association(options);
for (const char *query : {"nihaoshijie", "womenzaijianmian", "jintiantianqizhenhao"})
{
const std::vector<WordItem> result = dictionary.query(query);
expect(!result.empty(), std::string("reranked query should still produce candidates for ") + query);
int lattice_rows = 0;
int desktop_rows = 0;
int keyboard_rows = 0;
for (const WordItem &item : result)
{
expect(!item.word.empty(), std::string("no candidate should be empty for ") + query);
if (item.source == CandidateSource::Generated)
++lattice_rows;
else if (item.source == CandidateSource::NeuralDesktop)
++desktop_rows;
else if (item.source == CandidateSource::NeuralKeyboard)
++keyboard_rows;
}
// Trigram 显示开关没开,内部生成的路径只能交给神经模型,不能先冒充可见候选。
expect(lattice_rows == 0, std::string("neural-only mode exposed a lattice row for ") + query);
expect(desktop_rows <= 1, std::string("reranking must not add duplicate desktop rows for ") + query);
expect(keyboard_rows <= 1, std::string("reranking must not add duplicate keyboard rows for ") + query);
}
}
if (failures != 0)
{
std::fprintf(stderr, "%d assertion(s) failed.\n", failures);
return 1;
}
std::printf("Neural sentence reranking test passed.\n");
return 0;
}
@@ -134,6 +134,22 @@ int main()
"A replacement cloud candidate was rejected.");
require(quanpin.candidates()[1].word == "呢" && !has_word(quanpin, "泥"),
"Replacing a cloud candidate left the previous cloud row visible.");
// 神经整句重排落地时宿主会 reset_cache + recompute 重查一次:云/AI 行只在 series cache 里,
// 会被一并清掉,宿主要拿当前 online_query 回填。重查不能推进请求 generation,否则回填会被拒。
quanpin.reset_cache();
quanpin.recompute_candidates();
require(!has_word(quanpin, "呢") && !has_word(quanpin, "拟"),
"reset_cache no longer drops online rows; the host re-apply in ApplyRescoredOrder is redundant.");
const auto recomputed_query = quanpin.online_query();
require(recomputed_query &&
quanpin.apply_online_candidate(*recomputed_query, "呢", CandidateSource::CloudSuggestion) &&
quanpin.apply_online_candidate(*recomputed_query, "拟", CandidateSource::AiSuggestion),
"Online rows could not be restored after a cache reset and recompute.");
require(quanpin.apply_online_candidate(*query, "尼", CandidateSource::CloudSuggestion),
"Recomputing candidates invalidated an in-flight online query.");
require(quanpin.apply_online_candidate(*query, "呢", CandidateSource::CloudSuggestion) &&
quanpin.candidates()[1].word == "呢" && quanpin.candidates()[2].word == "拟",
"Restored online rows did not return to slots two and three.");
const auto candidate_count = quanpin.candidates().size();
require(!quanpin.apply_online_candidate(*query, "你", CandidateSource::CloudSuggestion) &&
quanpin.candidates().size() == candidate_count,
+55
View File
@@ -801,6 +801,61 @@ void test_word_lattice()
"A leading exact-key hit still outranks the sentence; the prefix row behind it does not.");
}
{
// 整句只出各来源的首选,n-best 的其余部分是解码中间产物,不进候选区。
// 没有重排器时就一条;重排器改了主意时,把它的首选和词格的首选各留一条。
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["ni"] = {{"ni", "你", 12000}, {"ni", "拟", 3000}, {"ni", "泥", 2000}};
table["hao"] = {{"hao", "好", 12000}, {"hao", "号", 3000}, {"hao", "耗", 2000}};
std::vector<WordItem> alone;
quanpin::merge_lattice_candidates(alone, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao");
expect(alone.size() == 1, fmt::format("Expected a single lattice sentence, got {}", alone.size()));
// 重排器把第二条顶到首位:两条首选都要留下,重排的在前。
std::vector<WordItem> reranked;
quanpin::merge_lattice_candidates(
reranked, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &paths) {
if (paths.size() >= 2)
std::swap(paths[0], paths[1]);
return true;
},
CandidateSource::NeuralDesktop);
expect(reranked.size() == 2,
fmt::format("Expected the reranker's pick and the lattice's, got {}", reranked.size()));
if (reranked.size() == 2 && alone.size() == 1)
{
expect(reranked.front().word != alone.front().word && reranked[1].word == alone.front().word,
"Expected the reranked pick first and the lattice's own pick behind it.");
// 来源要分得开:候选框据此标注是谁挑的。
expect(reranked.front().source == CandidateSource::NeuralDesktop &&
reranked[1].source == CandidateSource::Generated,
"Expected the reranker's pick tagged with its own source and the lattice's with Generated.");
}
// 重排器同意词格的排序时不该凭空多出一行,但来源要记在重排那边:模型看过并认可了它。
std::vector<WordItem> agreed;
quanpin::merge_lattice_candidates(
agreed, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralDesktop);
expect(agreed.size() == 1,
fmt::format("A reranker that agrees should add no extra row, got {}", agreed.size()));
if (agreed.size() == 1)
{
expect(agreed.front().source == CandidateSource::NeuralDesktop,
"A reranker that ran and agreed should still be credited for the row.");
}
// 弃权(模型没加载、后台还没算完)与「跑了且同意」不是一回事:来源仍是词格。
std::vector<WordItem> declined;
quanpin::merge_lattice_candidates(
declined, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralDesktop);
expect(declined.size() == 1 && declined.front().source == CandidateSource::Generated,
"A reranker that declined must leave the row credited to the lattice.");
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["g"] = {{"g", "个", 100}};
+20
View File
@@ -0,0 +1,20 @@
# chinese-ime-lm sentence models
The two neural sentence models shipped here are from the **chinese-ime-lm** project
(<https://github.com/metasequoiaime/chinese-ime-lm>), release `model-v1`:
- `sentence-model.safetensors` — "keyboard" preset (vocab 8192, 6 layers, n_embd 192, context 64).
- `sentence-model-desktop.safetensors` — "desktop" preset (vocab 12288, 8 layers, n_embd 448, context 128).
Both are character-level Transformer language models used to decode pinyin into whole sentences.
## License
The model **weights** are licensed under **Apache-2.0** (recorded in each file's
`__metadata__["license"]`). The C++ inference code in `engine/neural/` is a port of the project's
Apache-2.0 reference implementation (`reference/`).
## Attribution
Trained on the Chinese portion of C4 (ODC-BY, <https://huggingface.co/datasets/allenai/c4>) and
LCCC (MIT, <https://github.com/thu-coai/CDial-GPT>).
+21
View File
@@ -0,0 +1,21 @@
{
"source": "https://github.com/metasequoiaime/chinese-ime-lm/releases/tag/model-v1",
"release_tag": "model-v1",
"license": "Apache-2.0",
"models": [
{
"file": "sentence-model.safetensors",
"preset": "keyboard",
"url": "https://github.com/metasequoiaime/chinese-ime-lm/releases/download/model-v1/sentence-model.safetensors",
"sha256": "86ac529510cb3b4968a5e6ade83ec8080f5b34a0a681e75c362accbbd387d1c1",
"size": 4486280
},
{
"file": "sentence-model-desktop.safetensors",
"preset": "desktop",
"url": "https://github.com/metasequoiaime/chinese-ime-lm/releases/download/model-v1/sentence-model-desktop.safetensors",
"sha256": "0a6ecba69bf1d39c7eb49549c716477dd6c03fdf05757773e1f50435262fb469",
"size": 25480184
}
]
}
+53
View File
@@ -0,0 +1,53 @@
<#
.SYNOPSIS
下载 chinese-ime-lm 的神经整句模型(safetensors)到 neural-model/,并按 lock.json 校验 sha256。
.DESCRIPTION
与 build-language-model.ps1 对 sc.lm 的处理同理:模型二进制不进仓库(见 .gitignore),
只有 lock.json 和 NOTICE.md 进仓库。打包(installer/Prepare-PackageFiles.ps1)前跑一次
本脚本把两个模型取到位。已存在且校验通过的文件跳过重复下载(除非 -Force)。
.PARAMETER Force
忽略已存在文件,强制重新下载。
#>
[CmdletBinding()]
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$here = Split-Path -Parent $PSScriptRoot
$modelDir = Join-Path $here 'neural-model'
$lockPath = Join-Path $modelDir 'lock.json'
if (-not (Test-Path -LiteralPath $lockPath)) {
throw "找不到 lock.json:$lockPath"
}
$lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
foreach ($model in $lock.models) {
$dest = Join-Path $modelDir $model.file
$needsDownload = $true
if ((Test-Path -LiteralPath $dest) -and -not $Force) {
$hash = (Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -eq $model.sha256.ToLowerInvariant()) {
Write-Host "已存在且校验通过,跳过:$($model.file)"
$needsDownload = $false
}
else {
Write-Host "校验不符,重新下载:$($model.file)"
}
}
if ($needsDownload) {
Write-Host "下载 $($model.file) ..."
Invoke-WebRequest -Uri $model.url -OutFile $dest
$hash = (Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -ne $model.sha256.ToLowerInvariant()) {
throw "$($model.file) sha256 校验失败:期望 $($model.sha256),实际 $hash"
}
Write-Host " 校验通过:$($model.file)"
}
}
Write-Host '神经整句模型准备完毕。'