vicary 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 861bcafb7f5671834b082aea4da0edc1bdd050d71aab888d6b1944f58c70b36f
4
- data.tar.gz: 31fbd6f351e16c1ff1cb0acea2f5422b94decfbeb8b0f8021e20386561cd309c
3
+ metadata.gz: b9a6c30ad9946f8263d26b94e52b3d5d05db3d2ac0629425d992d5cebb1a7981
4
+ data.tar.gz: 8102ad087d3e7d1e3e4ce63a459de3d8598efb96f584e6c5ba02c815ac663611
5
5
  SHA512:
6
- metadata.gz: e5da98338e5c69f9687c1d6daf0b937837dd8d8f074768781f73f47fe789f6e99cebed77b8449b79d32118e16e738e6e45903e79b0b9ab7333acd64575de8827
7
- data.tar.gz: '0148acecb49a386d9cec1b5e3832a01385820572b32f8b30f4bfbbf49c3293af70a2ceba44b93990ffc85056ddb555e80630b0e4e3da786c95aec9c8500ae4a3'
6
+ metadata.gz: 1956a3e47dc351c62ac4a58266ed48fbf936745ed6d6cf72b2abfc35df0f50b19ae2b4f603b80248e330153c866312ade9017777cbdf0f46c211ab2abeccb1fb
7
+ data.tar.gz: 42fa8950725aa8c58efa07462aec67843e671eef013c211a9025655df09970ddba05c54d92c524a3f244241d8bc1d147ee8acea9bd62f01b6b3e1c6d5eed8e15
@@ -186,42 +186,32 @@ module Vicary
186
186
  # of the commonest surname shapes there are.
187
187
  PIECE = /[^'’\-‐-―]+/
188
188
 
189
- # The word tokens {Candidates.interior_capital?} can possibly say yes to,
190
- # and the only ones {Candidates.capitalises_inside_a_word?} needs to look
191
- # at.
189
+ # The only shape a word with an interior capital can carry: a capital with a
190
+ # word character in front of it. Every capital {Candidates.interior_capital?}
191
+ # is allowed to fire on sits at word-index 1 or later, so it is preceded
192
+ # either by the word's own first character or by one of `[A-Za-z'’-]`, and
193
+ # both are inside this class. A document that fails this has no interior
194
+ # capital and needs no word scan at all; a document that passes has told the
195
+ # scan exactly where to look. That makes this a filter and never a second
196
+ # opinion — the answer stays that method's.
192
197
  #
193
- # It is {WORD_TOKEN} with one letter of the continuation class removed: a
194
- # capital somewhere after the first character. That is a *necessary*
195
- # condition for an interior capital every position the exemptions in
196
- # {Candidates.interior_capital?} still allow to fire is at piece-index 1 or
197
- # later, so it is at word-index 1 or later too which makes this a filter
198
- # and never a second opinion. The answer stays that method's.
199
- #
200
- # The match is the same substring {WORD_TOKEN} would have produced, not a
201
- # fragment of one. A token whose first capital after position 0 sits at k
202
- # has every earlier character in `[a-z'’-]` by construction, so the engine
203
- # matches from the token's own start, and the trailing class then runs
204
- # greedily to the token's own end.
205
- #
206
- # Why this is worth a second pattern: the scan is per-document and the words
207
- # it is looking for are rare. On the twenty-essay gate corpus exactly ONE
208
- # document contains an interior capital, so the other nineteen used to be
209
- # tokenised to the last word — and a regex spent on each — to return false.
210
- INTERIOR_CAP_WORD = /[A-Za-z][a-z'’-]*[A-Z][A-Za-z'’-]*/
211
-
212
- # The two-character shape {INTERIOR_CAP_WORD} cannot match without: a
213
- # capital with a word character in front of it. Every capital that pattern
214
- # fires on is preceded either by its own first character or by one of
215
- # `[a-z'’-]`, and both are inside this class, so a document that fails
216
- # this has no interior capital and needs no word scan at all.
217
- #
218
- # It exists because the precise pattern backtracks. `[a-z'’-]*[A-Z]` is
219
- # retried at every letter of every word that has no capital after it, which
220
- # is most words in most documents; this is a two-character scan that cannot
221
- # backtrack. On the twenty-essay gate corpus it clears thirteen documents
222
- # outright at a twentieth of the cost.
198
+ # Two characters, and it cannot backtrack. It replaced a precise word
199
+ # pattern (`[A-Za-z][a-z'’-]*[A-Z][A-Za-z'’-]*`) that did: `[a-z'’-]*[A-Z]`
200
+ # is retried at every letter of every word that has no capital after it,
201
+ # which is most words in most documents, and running it over the whole text
202
+ # cost threefold what running this one and walking out from its hits costs
203
+ # 57 µs per document against 17.5 on the twenty-essay gate corpus. The walk
204
+ # is in {Candidates.capitalises_inside_a_word?}; the word it reaches is the
205
+ # same substring {WORD_TOKEN} would have produced, because the walk uses
206
+ # that token's own continuation class.
223
207
  INTERIOR_CAP_HINT = /[A-Za-z'’-][A-Z]/
224
208
 
209
+ # The complement of {WORD_TOKEN}'s continuation class, and its first-character
210
+ # class. The interior-capital walk reads outwards from a hint rather than
211
+ # forwards from a word, so it needs the boundary rather than the token.
212
+ NOT_WORD_CHARACTER = /[^A-Za-z'’-]/
213
+ WORD_FIRST_CHARACTER = /[A-Za-z]/
214
+
225
215
  # Where a sentence begins: start of text, after terminal punctuation and any
226
216
  # closing quote, after a line break, or immediately inside an *opening*
227
217
  # quote. A capital in one of these positions is required by orthography, so
@@ -383,6 +373,11 @@ module Vicary
383
373
  # One row of the precedence table: a tag, and what it decides.
384
374
  PrecedenceRow = Struct.new(:tag, :mask, :kind)
385
375
 
376
+ # What one pass over a document's words says about how the writer cases
377
+ # them. See {Candidates.document_casing}; either half is empty when the
378
+ # caller did not ask for it.
379
+ DocumentCasing = Struct.new(:written_as_a_capital, :written_in_lower_case)
380
+
386
381
  # The precedence table. The first row whose tag the span carries decides both
387
382
  # the mask/keep verdict and the placeholder, and that is the whole
388
383
  # classification policy.
@@ -1085,16 +1080,63 @@ module Vicary
1085
1080
  # Horses" vouch for "Horses" as a name — the heading corroborating itself,
1086
1081
  # one line removed.
1087
1082
  def mid_sentence_capitals(text, starts, headings = [])
1088
- out = Set.new
1083
+ document_casing(text, starts, headings, lower_case: false).written_as_a_capital
1084
+ end
1085
+
1086
+ # Both halves of the document's casing testimony, from ONE pass over its
1087
+ # words.
1088
+ #
1089
+ # {.mid_sentence_capitals} and {.written_in_lower_case} ask opposite
1090
+ # questions of the same tokens — one reads the words a writer capitalised
1091
+ # where orthography would not have, the other the words they left
1092
+ # lower-case — and a token answers exactly one of them, because a word's
1093
+ # first character is either a capital or it is not. Run separately they
1094
+ # tokenised every document twice.
1095
+ #
1096
+ # That second tokenisation is what this exists to remove, and it is worth
1097
+ # a method rather than a comment: the pair was most of what the two
1098
+ # orthography channels added to the release latency gate.
1099
+ #
1100
+ # Each half is opt-in because each has a caller that does not want it. The
1101
+ # lower-case set is evidence only where the writer marks proper nouns at
1102
+ # all (see `lower_cased` in {.find_candidates}), and asking for a set that
1103
+ # will be discarded is the cost this method was written to avoid.
1104
+ def document_casing(text, starts = Set.new, headings = [], capitals: true, lower_case: true)
1105
+ capitalised = Set.new
1106
+ lowered = Set.new
1089
1107
  each_match(text, WORD_TOKEN) do |m|
1090
1108
  token = m[0]
1091
- next if starts.include?(m.begin(0)) || !token[0].match?(/[A-Z]/)
1109
+ # A byte comparison rather than `token[0].match?(/[A-Z]/)`, which
1110
+ # allocated a one-character string and ran a regex for every word of
1111
+ # every document. Exact, not an approximation: {WORD_TOKEN}'s first
1112
+ # character is `[A-Za-z]`, so it is ASCII, a single byte, and
1113
+ # lower-case exactly when it is not upper-case — which is what splits
1114
+ # the two halves, at one test rather than two.
1115
+ first = token.getbyte(0)
1116
+ if first >= 97 && first <= 122
1117
+ next unless lower_case
1118
+
1119
+ # {.strip} copies the string whether or not it takes anything off,
1120
+ # and almost no word ends in an apostrophe. Same value either way —
1121
+ # a strip that removes nothing returns an equal string, and the set
1122
+ # holds values.
1123
+ #
1124
+ # Only the tail is worth testing. {.strip} works in from both ends,
1125
+ # and the head was just established to be a lower-case ASCII letter,
1126
+ # so the leading pass stops on the first character every time.
1127
+ lowered_token = token.downcase
1128
+ lowered_token = strip(lowered_token, "'’") if lowered_token.end_with?("'", "’")
1129
+ lowered << lowered_token
1130
+ next
1131
+ end
1132
+ next unless capitals
1133
+ next if starts.include?(m.begin(0))
1092
1134
  next if token.length > 1 && upper?(token)
1093
1135
  next if overlaps?(headings, m.begin(0), m.begin(0) + token.length)
1094
1136
 
1095
- out << strip(token.downcase, "'’")
1137
+ capitalised << strip(token.downcase, "'’")
1096
1138
  end
1097
- out
1139
+ DocumentCasing.new(capitalised, lowered)
1098
1140
  end
1099
1141
 
1100
1142
  # ---------------------------------------------------------------------
@@ -1334,13 +1376,27 @@ module Vicary
1334
1376
  # NWP corpus 14 papers carry an interior capital and 2 of them (`SPecial`,
1335
1377
  # `AFter`) carry it only inside a heading.
1336
1378
  #
1337
- # The scan is over {INTERIOR_CAP_WORD} rather than every word token, which
1338
- # is a filter and not a change of answer — see that pattern.
1379
+ # The scan is driven off {INTERIOR_CAP_HINT} rather than run over every
1380
+ # word token, which is a filter and not a change of answer — see that
1381
+ # pattern. Each hit names a capital and the walk reaches the word carrying
1382
+ # it; the hit need not be interior itself (`'ChoaCh'` hints at its own
1383
+ # first letter), so the WHOLE token is handed over rather than the
1384
+ # position being judged here.
1339
1385
  def capitalises_inside_a_word?(text)
1340
- return false unless INTERIOR_CAP_HINT.match?(text)
1341
-
1342
- text.scan(INTERIOR_CAP_WORD) do |word|
1343
- return true if interior_capital?(word)
1386
+ pos = 0
1387
+ while (hint = INTERIOR_CAP_HINT.match(text, pos))
1388
+ capital = hint.begin(0) + 1
1389
+ boundary = text.rindex(NOT_WORD_CHARACTER, capital - 1)
1390
+ # A word begins on a letter, so a run of leading apostrophes or
1391
+ # hyphens the walk crossed belongs to no word — `'Abc` tokenises to
1392
+ # `Abc`. The capital is itself a letter, so this never runs past it.
1393
+ start = text.index(WORD_FIRST_CHARACTER, boundary.nil? ? 0 : boundary + 1)
1394
+ stop = text.index(NOT_WORD_CHARACTER, capital + 1) || text.length
1395
+ return true if interior_capital?(text[start...stop])
1396
+
1397
+ # Past the whole word: it has just been read in full, so a second
1398
+ # capital inside it would ask the same question again.
1399
+ pos = stop
1344
1400
  end
1345
1401
  false
1346
1402
  end
@@ -1359,29 +1415,7 @@ module Vicary
1359
1415
  # letters, which is what keeps the rule free of any imported collision: it
1360
1416
  # consults no list, so it cannot inherit one's mistakes.
1361
1417
  def written_in_lower_case(text)
1362
- out = Set.new
1363
- # `scan` rather than `each_match`, and a byte comparison rather than
1364
- # `token[0].match?(/[a-z]/)` — which allocated a one-character string
1365
- # and ran a regex for every word of every document. Exact, not an
1366
- # approximation: {WORD_TOKEN}'s first character is `[A-Za-z]`, so it is
1367
- # ASCII and a single byte by construction.
1368
- text.scan(WORD_TOKEN) do |token|
1369
- first = token.getbyte(0)
1370
- next unless first >= 97 && first <= 122
1371
-
1372
- # {.strip} copies the string whether or not it takes anything off,
1373
- # and almost no word ends in an apostrophe. Same value either way — a
1374
- # strip that removes nothing returns an equal string, and the set
1375
- # holds values.
1376
- #
1377
- # Only the tail is worth testing. {.strip} works in from both ends,
1378
- # and the head was just established to be a lower-case ASCII letter,
1379
- # so the leading pass stops on the first character every time.
1380
- lowered = token.downcase
1381
- lowered = strip(lowered, "'’") if lowered.end_with?("'", "’")
1382
- out << lowered
1383
- end
1384
- out
1418
+ document_casing(text, capitals: false).written_in_lower_case
1385
1419
  end
1386
1420
 
1387
1421
  # The mid-sentence guard: drop a lone capital a sloppy capitaliser chose.
@@ -1947,21 +1981,23 @@ module Vicary
1947
1981
  blocked.any? { |block_start, block_end| start < block_end && finish > block_start }
1948
1982
  end
1949
1983
 
1950
- written_as_a_capital = mid_sentence_capitals(text, starts, headings)
1951
- # A property of the whole document, read once, for the same reason
1952
- # `habit` is: two call sites computing it separately could disagree.
1984
+ # Both casing questions, read once from one pass over the words, for the
1985
+ # same reason `habit` is read once: two call sites computing it
1986
+ # separately could disagree.
1987
+ #
1988
+ # The lower-case half is asked for only where the writer marks proper
1989
+ # nouns at all: that rule reads the ABSENCE of a capital as testimony,
1990
+ # and the habit states in as many words that an absence means nothing in
1991
+ # a LOWERCASE or SILENT document. Running it there would suppress every
1992
+ # capital the writer did manage.
1993
+ casing = document_casing(
1994
+ text, starts, headings,
1995
+ lower_case: case_variance && !given_name.nil? && marks_proper_nouns?(habit),
1996
+ )
1997
+ written_as_a_capital = casing.written_as_a_capital
1998
+ lower_cased = casing.written_in_lower_case
1953
1999
  stray_capitals = mid_sentence_corroboration && !given_name.nil? &&
1954
2000
  capitalises_ordinary_words?(text, headings)
1955
- # The mirror of `written_as_a_capital`, read once for the same reason.
1956
- # Empty unless the writer marks proper nouns at all: this rule reads the
1957
- # ABSENCE of a capital as testimony, and the habit states in as many
1958
- # words that an absence means nothing in a LOWERCASE or SILENT document.
1959
- # Running it there would suppress every capital the writer did manage.
1960
- lower_cased = if case_variance && !given_name.nil? && marks_proper_nouns?(habit)
1961
- written_in_lower_case(text)
1962
- else
1963
- Set.new
1964
- end
1965
2001
 
1966
2002
  out = []
1967
2003
  each_match(text, CANDIDATE_RE) do |m|
@@ -6,5 +6,5 @@ module Vicary
6
6
  # Shared across all three front doors on purpose: one detector, one number. A
7
7
  # gem 0.3.0 that corresponds to nothing on PyPI cannot be reasoned about, and
8
8
  # the parity claim is between *versions*, not between package names.
9
- VERSION = "0.2.14"
9
+ VERSION = "0.2.15"
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vicary
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.14
4
+ version: 0.2.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Blake Thomas