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:
1 parent
589092e603
commit
e7f7addd4f
38 files changed
+3344
-39
No files matched your search
@@ -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__/
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 候选出现,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}};
|
||||
|
||||
@@ -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>).
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 '神经整句模型准备完毕。'
|
||||
Reference in new issue
Block a user