Module: Vicary::Lexicon
- Defined in:
- lib/vicary/lexicon.rb
Overview
Read a language-neutral word list that ships beside the gazetteer.
The stoplist used to be a literal in Python's name_candidates.py, which was
fine while there was one front door. With three, a hand-transliterated word
list is a second detector wearing the first one's name: the divergence shows
up as prose corruption in one language and not the others, and no parity
check on masked output would catch it, because a stop word going missing
changes what gets masked in essays nobody put in a fixture.
So the list is authored once, language-neutrally, under asset/lexicon/ in
the repository, and vendored into each package the same way the gazetteer is.
This is the reader; it owns nothing.
Format
#! lines are directives, # lines are comments, blank lines are skipped,
and every other line contributes whitespace-separated words. One directive is
required:
#!lexicon 1 format version
#!list <name> <count> the list's name, and its DISTINCT word count
The count is asserted, not trusted. A short read here makes the redactor more aggressive — fewer stop words means more capitalised ordinary words become name candidates — which looks privacy-safe, corrupts prose, and passes any check that only asks whether something was masked. Same reasoning as the gazetteer's per-tier counts; same failure mode if it is skipped.
This is the fourth reader of this format, after asset/vicary_build/lexicon.py,
python/src/vicary/lexicon.py and typescript/src/lexicon.ts. The duplication
is deliberate — the build tool must not import one of the implementations it
feeds — and it is only honest because something compares the results.
test/lexicon_test.rb pins this reader's output against the count and the
spot-checks the other three are pinned against.
Defined Under Namespace
Classes: LexiconError
Constant Summary collapse
- LEXICON_FORMAT =
On-disk format version for a lexicon file. Bump when the parse changes, so a stale vendored copy fails loudly rather than parsing to a different list.
1- SUFFIX =
Filename suffix. Named so a second list costs a file rather than a refactor.
".txt"
Class Method Summary collapse
-
.load(name, path: nil) ⇒ Object
The case-folded distinct words of lexicon
name. -
.parse(name, text, where) ⇒ Object
Parse lexicon text into its case-folded distinct words.
-
.path(name) ⇒ Object
Where the vendored copy of
namelives, whether or not it exists.
Class Method Details
.load(name, path: nil) ⇒ Object
The case-folded distinct words of lexicon name.
Raises LexiconError rather than returning a partial list. An empty or truncated stoplist is the quiet failure this whole module exists to prevent.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/vicary/lexicon.rb', line 157 def load(name, path: nil) location = Pathname.new(path || self.path(name)) begin text = location.read(encoding: "UTF-8") rescue Errno::ENOENT raise LexiconError, "lexicon #{name.inspect} missing at #{location}. The installed " \ "vicary gem is incomplete — reinstall it, or vendor the asset " \ "with `rake sync_assets` from a checkout." rescue SystemCallError => e raise LexiconError, "cannot read lexicon at #{location}: #{e.}" end parse(name, text, location.to_s) end |
.parse(name, text, where) ⇒ Object
Parse lexicon text into its case-folded distinct words.
Public so a test can feed it a deliberately malformed document. A parser reachable only through a vendored file on disk is a parser whose failure paths are never exercised — and every one of this parser's failure paths exists to turn a silent short read into a loud one.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/vicary/lexicon.rb', line 83 def parse(name, text, where) declared = nil saw_format = false words = Set.new text.split(/\r?\n/, -1).each_with_index do |line, index| lineno = index + 1 stripped = line.strip if stripped.start_with?("#!") parts = stripped[2..].to_s.split(/\s+/).reject(&:empty?) raise LexiconError, "#{where}:#{lineno}: empty directive" if parts.empty? case parts[0] when "lexicon" saw_format = true unless parts.length == 2 && parts[1] == LEXICON_FORMAT.to_s raise LexiconError, "#{where}:#{lineno}: lexicon format " \ "#{parts[1..].join(' ').inspect}, this build reads " \ "#{LEXICON_FORMAT}" end when "list" unless parts.length == 3 && parts[1] == name raise LexiconError, "#{where}:#{lineno}: expected `\#!list #{name} <count>`, " \ "got #{stripped.inspect}" end # `to_i` would read "3x" as 3, where Python's `int()` raises. The # difference is the whole guard: a count this reader silently # repaired is a count that no longer proves anything about the parse. unless /\A\d+\z/.match?(parts[2]) raise LexiconError, "#{where}:#{lineno}: count #{parts[2].inspect} is not an integer" end declared = parts[2].to_i else # Refused rather than ignored: an unrecognised directive means the # file was written by something that knows more than this reader, # and guessing which lines are still words is how a partial list # loads as a whole one. raise LexiconError, "#{where}:#{lineno}: unknown directive #{parts[0].inspect}" end next end next if stripped.empty? || stripped.start_with?("#") stripped.split(/\s+/).each do |word| words << word.downcase unless word.empty? end end unless saw_format raise LexiconError, "#{where}: no `\#!lexicon` directive; not a lexicon file" end raise LexiconError, "#{where}: no `\#!list #{name} <count>` directive" if declared.nil? unless words.size == declared raise LexiconError, "#{where}: declares #{declared} distinct words, parsed " \ "#{words.size}. A short read makes the redactor more aggressive, " \ "which is why this is an error and not a warning." end words end |
.path(name) ⇒ Object
Where the vendored copy of name lives, whether or not it exists.
Searched along the same path as the gazetteer, in the same order, so a package cannot end up reading its stoplist from one cut and its gazetteer from another.
62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/vicary/lexicon.rb', line 62 def path(name) filename = "#{name}#{SUFFIX}" tried = Asset.search_path found = tried.find { |dir| dir.join(filename).file? } return found.join(filename) if found # Return the first location rather than raising, so `load` reports the # same "missing" error whether the directory is absent or the file # inside it is. (tried.first || Pathname.new(".")).join(filename) end |