Skip to content

feat(lite): lazily load "*-full" dictionaries one word at a time - #649

Merged
ddehilster merged 3 commits into
masterfrom
feat/lite-lazy-full-dictionary
Jun 11, 2026
Merged

ddehilster merged 3 commits into
masterfrom
feat/lite-lazy-full-dictionary

Conversation

@ddehilster

Copy link
Copy Markdown
Member

Summary

A dictionary file whose stem ends in -full (e.g. en-full.kbb, en-full.dict) is no longer read whole into the KB. It is kept open and binary-searched per word the first time that word is needed during analysis, so only the text's words are loaded instead of the entire (~370k-word) dictionary.

On the TestEnFullKbb analyzer with a 1019-word text this took KB load from ~291 s → ~1–2 s.

What it does

  • Detection + open — readKBBs/readDicts detect *-full files and open them lazily. The file must be sorted (binary-search precondition); if not, the engine warns and falls back to the original full load.
  • Shared parsers — refactored readKBB/readDict into parseKBBLine/parseDictLine so the lazy loader reuses the exact same parsing as the full load (no behavior drift for non-full files).
  • Per-word lookup in CG::findWordConcept — on a KB miss, binary-search the open file(s):
    • the flat .dict (which carries pos directly on the word) is consulted first, so the tokenizer's findAttrs(word) attaches pos;
    • the .kbb is also consulted to populate the top dictionary concept (getConcept(findRoot(),"dictionary")) and to link the word to its kbb entry via a meaning attribute (mirrors the full-load matchDictKB pairing).
  • Caching — found words cached via sym->con / dict_get_word; a run-wide negative cache skips re-searching absent words.
  • missing-words.log — words not found in any *-full file are recorded (canonical lowercase, deduped) into each input file's output *_log folder, independently per file.

Validation (built with MSVC/x86 + vcpkg ICU)

  • All present words resolve and get their pos; e.g. long → noun/verb/adj/adv/abbr.
  • meaning attribute appears on every token: ("meaning" concept:"<word>").
  • Output kbdumptree of dictionary contained 530 words = 539 unique text words − 9 genuinely missing — i.e. only the text's words loaded.
  • missing-words.log verified: all 9 logged words confirmed absent from both files; multi-file run confirmed each file's log is independent.

Files

  • cs/libconsh/cg.cpp, include/Api/consh/cg.h — lazy loading, binary search, caching, meaning link, missing-words log.
  • lite/nlp_engine.cpp — points the missing-words log at each file's *_log output folder.

Note (not in this PR)

While testing I found a pre-existing crash: nlp.exe builds a std::string from a null workdir when -WORK is omitted (main.cpp:53). Unrelated to this change; happy to fix separately.

🤖 Generated with Claude Code

ddehilster and others added 2 commits June 10, 2026 23:12
A dictionary file whose stem ends in "-full" (e.g. en-full.kbb,
en-full.dict) is no longer read whole into the KB. It is kept open and
binary-searched per word the first time that word is needed during
analysis, so only the text's words are loaded instead of the entire
(~370k-word) dictionary.

- readKBBs/readDicts detect "*-full" files and open them lazily; if a
  file is not sorted (binary-search precondition) they warn and fall
  back to the original full load.
- Refactor readKBB/readDict into shared parseKBBLine/parseDictLine so
  the lazy loader reuses the same parsing as the full load.
- CG::findWordConcept: on a KB miss, binary-search the open file(s).
  The flat ".dict" (pos directly on the word) is consulted first so the
  tokenizer's findAttrs(word) sees pos; the ".kbb" also populates the
  top "dictionary" concept and links the word to its kbb entry via a
  "meaning" attribute.
- Caching: found words via sym->con / dict_get_word; a run-wide negative
  cache skips re-searching absent words.
- Words not found in any "*-full" file are recorded in
  missing-words.log (canonical lowercase, deduped) in each input file's
  output "*_log" folder, independently per file.

Also fixes a latent Linux bug exposed by this change: the CG constructor
only zeroed hkbdll_ under #ifndef LINUX, leaving the compiled-KB handle
uninitialized on Linux. readKB() treats a nonzero hkbdll_ as "compiled
KB loaded" and skips reading the dictionary files; adding members to CG
shifted its layout so the uninitialized value became nonzero, producing
an empty KB (every word "unknown") on the Linux runners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ddehilster
ddehilster force-pushed the feat/lite-lazy-full-dictionary branch from 6f633d1 to d7218ba Compare June 11, 2026 03:13
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ddehilster
ddehilster merged commit b02f951 into master Jun 11, 2026
7 checks passed
ddehilster added a commit that referenced this pull request Jun 17, 2026
#669) (#670)

* fix: infinite hang on input chars that sort past the last full-dict word (#669)

nlp.exe hung forever (observed 11+ hours) when input contained an em-dash
(U+2014) or en-dash (U+2013) -- and, generally, any character that sorts after
every word in a lazily-loaded "*full" dictionary.

Root cause: the lazy full-dict/full-kbb binary search (findFullWord, added in
#649). nextDictKey/nextKBBKey set `afterKey = f.stream.tellg()`. Reading the
file's last line (no trailing newline) leaves the stream at EOF, where tellg()
returns -1. For a search key greater than every word, the lower-bound search
walks to that last line; `afterKey = -1` then makes searchDictFile/searchKBBFile
do `lo = -1`, so `lo < hi` stays true and the search restarts from the middle
forever. Normal words sort within the file and converge before hitting this, so
only past-the-end keys (em/en-dash, other high code points) trigger it.

Fix:
- nextDictKey / nextKBBKey: clamp `afterKey` to `f.size` when tellg() returns
  negative (EOF after the last line). That is the true "position past the last
  key", so the search terminates cleanly and reports the word absent.
- searchDictFile / searchKBBFile: defensive guard -- if `afterKey <= lo` in the
  key<str branch, break instead of moving lo backwards/sideways (belt and
  suspenders against any future non-progress).

Verified with the parse-pt-br analyzer (which ships pt-full.dict): em-dash,
en-dash, and a full Portuguese travessao dialogue all previously hung and now
complete; in-dict words still get their POS/meaning attributes; parse-en-us
unaffected. Bumps engine to 3.5.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: CI regression for the full-dict binary-search hang (#669)

Add a self-contained fixture analyzer that reproduces the infinite hang: a tiny
sorted "zz-full.dict" (last line intentionally has no trailing newline, so the
final getline hits EOF and tellg() returns -1) plus an em-dash input (which
sorts after every dict word, driving the lower-bound search to that last line).

Validated against the pre-fix engine: the fixture hangs (timeout) without the
fix and completes with it. Wired into the Windows and Linux builds; a hang trips
the step timeout and fails the job. A .gitattributes marks the dict binary so
git never appends a newline or normalizes EOLs (which would mask the bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant