Skip to content

fix(user_db): clamp denormal dee to restore Pack/Unpack round-trip - #1215

Open
fwonce wants to merge 2 commits into
rime:masterfrom
fwonce:master
Open

fix(user_db): clamp denormal dee to restore Pack/Unpack round-trip#1215
fwonce wants to merge 2 commits into
rime:masterfrom
fwonce:master

Conversation

@fwonce

@fwonce fwonce commented Aug 19, 2026

Copy link
Copy Markdown

問題

載入使用者詞庫時,librime 會輸出類似以下的錯誤日誌:

[ERROR] ... failed in parsing key-value from userdb entry 'd=9.88131e-324'.

凡是權重 dee 經過多次衰減後落入**非正規(denormal)**雙精度浮點數範圍內的詞條,都會觸發此錯誤。

根本原因——寫入/讀取不對稱

dee 會隨著 tick 遞增,透過 formula_dd + da * exp((ta - t) / 200))不斷衰減。對於長期存在但很少使用的詞條,dee 可能低於最小正規雙精度浮點數(std::numeric_limits<double>::min() ≈ 2.2e-308),落入非正規範圍(最低約 4.9e-324)。

  • 寫入端: Pack() 透過 std::ostringstream 序列化 dee,非正規數值會被如實輸出為 9.88131e-324 這樣的字串。
  • 讀取端: Unpack() 使用 std::stod 解析 dee,而 std::stod 底層呼叫 strtod,當結果下溢(errno == ERANGE)時會拋出 std::out_of_range——非正規字面量恰好會觸發這種情況。

因此 librime 自己寫入的值,無法再被讀回來。

影響

  1. 日誌刷錯:每次載入受影響的詞條都會產生一筆 ERROR。
  2. 整筆記錄被丟棄,而不只是 dee 欄位。 Unpack() 捕捉異常後回傳 false,且拋出異常會中斷解析迴圈,導致 tick 也來不及讀取。檢查回傳值的呼叫端會丟棄整筆記錄——最明顯的是 UserDictionary::CreateDictEntry
    UserDbValue v;
    if (!v.Unpack(value))
      return e;   // 空記錄 -> 該詞不會出現在候選中
    不檢查回傳值的呼叫端(UpdateEntryUserDbMerger::PutUserDbImporter::Put)則會拿著半解析的 UserDbValuedee/tick 為預設值)繼續執行,可能污染後續的合併或覆寫。

請注意這與 CreateDictEntry設計上的老化淘汰(dee <= kDiscardThreshold,1e-200,於 de21e7d 中加入)是兩回事:非正規詞條理應走到那條乾淨的淘汰路徑,而不是先在解析器裡拋出異常。

修復

共三個 commit,聚焦於解析的完備性,與淘汰策略正交:

  1. Pack() 鉗位:序列化前將已老化(dee <= kUserDbDiscardThreshold)以及負值的 dee 鉗位為 0,從源頭杜絕非正規字串被寫入。鉗位點與 de21e7d 的載入時淘汰閾值共用同一常量(提升為 user_db.h 中的 kUserDbDiscardThreshold),消除此前 [1e-308, 1e-200) 區間「載入時被忽略、寫盤時仍以微小數值序列化」的語義縫隙,讓存儲狀態與淘汰語義一致。
  2. Unpack() 改用 strtol/strtod/strtoulstrtod 在下溢時回傳該值或 0不拋異常,因此使用者資料庫中既有的非正規詞條也能被乾淨地解析;dee 隨後被鉗位到 [0, 10000]。end-pointer 與 errno == ERANGE 檢查保留了對真正格式錯誤輸入的拒絕能力。
  3. CreateDictEntry 改用共享常量,移除原本的地區型定義。

修復後,老化的詞條會無錯誤地被解析,並透過設計上的淘汰路徑被忽略,而不是在解析器中炸開。此修復不會(也不應該)把這類詞條復活為候選——它們確實已經老化出局;修復的價值在於停止日誌刷錯,並避免誤傷其他合法記錄。真正的「視同刪除」(物理清除或刪除標記)屬於行為策略變更,建議另開 issue 討論。

測試

test/user_db_test.cc 中新增 RimeUserDbValueTest 測試套件(7 個案例):

測試 說明
PackUnpackRoundtripNormal 正常值可正確往返
PackUnpackRoundtripZero 零值可往返
PackUnpackRoundtripSmallNormal 小但高於淘汰閾值的雙精度數(3e-150)可存活
PackClampsAgedOutDee 低於淘汰閾值的正規值(1e-250)及恰等於閾值的值被鉗位為 0
UnpackSurvivesDenormal 舊版寫入的非正規字串 9.88131e-324 可無錯誤解析
PackThenUnpackDenormalIsClamped denorm_min() 打包後再解包,得到 d=0 的乾淨往返
UnpackRejectsGarbage 非數字輸入仍會被拒絕

全部 7 個測試通過;完整測試套件 120/120 通過,無回歸。

@lotem
lotem requested a review from ksqsf August 20, 2026 13:37
@lotem

lotem commented Aug 20, 2026

Copy link
Copy Markdown
Member

呼叫 @ksqsf 老師。

我正在度假,而您最近改過 user_db,能否先幫忙把把關?

另外,也許可以發散一下,衰減到極小值視同刪除的邏輯,能否與這個 PR 結合。

@fwonce

fwonce commented Aug 21, 2026

Copy link
Copy Markdown
Author

呼叫 @ksqsf 老師。

我正在度假,而您最近改過 user_db,能否先幫忙把把關?

另外,也許可以發散一下,衰減到極小值視同刪除的邏輯,能否與這個 PR 結合。

謝謝 @lotem ,度假愉快!

這個 PR 原本聚焦於解析的完備性——已寫入的值不該在讀回時拋異常。關於「衰減到極小值視同刪除」與本 PR 結合的想法,我的看法分兩層:

  • ✅ DONE(1dd04d7)——目前兩處閾值並不一致:本 PR 的 Pack() 原本在 numeric_limits::min()(≈2.2e-308)處鉗位,而 de21e7d 的 kDiscardThreshold 是 1e-200。落在 [1e-308, 1e-200) 之間的條目在載入時已被忽略,寫盤時卻仍以微小數值序列化,語義上不一致。已把閾值提升為 user_db.h 中的共享常量 kUserDbDiscardThreshold,Pack() 與 CreateDictEntry 共用,序列化狀態與淘汰語義一致。
  • 真正的「視同刪除」——無論是物理清除還是套用 commits < 0 的刪除標記——屬於行為策略變更,會牽動 UserDbMerger 的合併語義、userdb.txt 導出,以及條目再次被輸入時的復活邏輯。de21e7d 兩週前剛確立了「載入時忽略」的保守策略,這類調整建議另開 issue 由各位對齊方針後再實施。

無論是哪一種,我願意參與討論和實施,期待 @ksqsf 老師的意見。

@ksqsf
ksqsf requested a balanced review from Copilot August 21, 2026 14:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes user database serialization/parsing of denormal decay weights.

Changes:

  • Clamps aged-out weights during serialization.
  • Uses non-throwing numeric parsers during deserialization.
  • Adds shared threshold and round-trip tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/rime/dict/user_db.cc Updates weight serialization and numeric parsing.
src/rime/dict/user_db.h Exposes the shared discard threshold.
src/rime/dict/user_dictionary.cc Uses the shared threshold.
test/user_db_test.cc Adds parsing and round-trip tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/rime/dict/user_db.cc
Comment on lines +54 to +58
long parsed = std::strtol(v.c_str(), &end, 10);
if (end == v.c_str() || errno == ERANGE) {
throw std::invalid_argument("bad commits");
}
commits = static_cast<int>(parsed);
Comment thread src/rime/dict/user_db.cc
Comment on lines +52 to +58
char* end = nullptr;
errno = 0;
long parsed = std::strtol(v.c_str(), &end, 10);
if (end == v.c_str() || errno == ERANGE) {
throw std::invalid_argument("bad commits");
}
commits = static_cast<int>(parsed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里和之前的逻辑似乎没有变化?我的理解是都要求必须解析成功,不成功就 throw。如果是这样的话,这里不用改。

Comment thread src/rime/dict/user_db.cc
Comment on lines +72 to +78
char* end = nullptr;
errno = 0;
unsigned long parsed = std::strtoul(v.c_str(), &end, 10);
if (end == v.c_str() || errno == ERANGE) {
throw std::invalid_argument("bad tick");
}
tick = static_cast<TickCount>(parsed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

k == "c" 分支,不用改。

Comment thread src/rime/dict/user_db.cc
Comment on lines +60 to +70
char* end = nullptr;
errno = 0;
double parsed = std::strtod(v.c_str(), &end);
if (end == v.c_str()) {
throw std::invalid_argument("bad dee");
}
// strtod returns 0 on underflow and HUGE_VAL on overflow; either
// way, clamp to [0, 10000] which is the valid dee range.
if (parsed < 0.0 || std::isnan(parsed))
parsed = 0.0;
dee = (std::min)(10000.0, parsed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里也可以简化,

Suggested change
char* end = nullptr;
errno = 0;
double parsed = std::strtod(v.c_str(), &end);
if (end == v.c_str()) {
throw std::invalid_argument("bad dee");
}
// strtod returns 0 on underflow and HUGE_VAL on overflow; either
// way, clamp to [0, 10000] which is the valid dee range.
if (parsed < 0.0 || std::isnan(parsed))
parsed = 0.0;
dee = (std::min)(10000.0, parsed);
try {
dee = (std::min)(10000.0, std::stod(v));
} catch (out_of_range) {
dee = 0;
}

如果要考虑「旧词视同删除」逻辑,加个 if 就可以了:

Suggested change
char* end = nullptr;
errno = 0;
double parsed = std::strtod(v.c_str(), &end);
if (end == v.c_str()) {
throw std::invalid_argument("bad dee");
}
// strtod returns 0 on underflow and HUGE_VAL on overflow; either
// way, clamp to [0, 10000] which is the valid dee range.
if (parsed < 0.0 || std::isnan(parsed))
parsed = 0.0;
dee = (std::min)(10000.0, parsed);
try {
dee = (std::min)(10000.0, std::stod(v));
} catch (out_of_range) {
dee = 0;
}
if (dee <= threshold)
return false;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants