acute 1.0.0

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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/data/assumptions.yml +124 -0
  4. data/data/words.yml +699 -0
  5. data/exe/acute +4 -0
  6. data/lib/acute/assumption.rb +13 -0
  7. data/lib/acute/calendar.rb +91 -0
  8. data/lib/acute/chronic.rb +176 -0
  9. data/lib/acute/chronic_duration.rb +321 -0
  10. data/lib/acute/cli.rb +168 -0
  11. data/lib/acute/context.rb +205 -0
  12. data/lib/acute/describe.rb +316 -0
  13. data/lib/acute/duration.rb +120 -0
  14. data/lib/acute/errors.rb +47 -0
  15. data/lib/acute/explain.rb +165 -0
  16. data/lib/acute/extract.rb +493 -0
  17. data/lib/acute/grammar/clock_words.rb +109 -0
  18. data/lib/acute/grammar/compact.rb +222 -0
  19. data/lib/acute/grammar/duration.rb +93 -0
  20. data/lib/acute/grammar/nesting.rb +104 -0
  21. data/lib/acute/grammar/numbers.rb +378 -0
  22. data/lib/acute/grammar/ordinals.rb +101 -0
  23. data/lib/acute/grammar/ranges.rb +112 -0
  24. data/lib/acute/grammar/rule.rb +277 -0
  25. data/lib/acute/grammar/shifts.rb +226 -0
  26. data/lib/acute/grammar/words.rb +307 -0
  27. data/lib/acute/grammar.rb +378 -0
  28. data/lib/acute/holidays.rb +97 -0
  29. data/lib/acute/lexer.rb +487 -0
  30. data/lib/acute/locale.rb +53 -0
  31. data/lib/acute/mcp.rb +224 -0
  32. data/lib/acute/parser.rb +66 -0
  33. data/lib/acute/read.rb +21 -0
  34. data/lib/acute/resolve/clock.rb +75 -0
  35. data/lib/acute/resolve/dates.rb +75 -0
  36. data/lib/acute/resolve/day.rb +24 -0
  37. data/lib/acute/resolve/fuzzy.rb +91 -0
  38. data/lib/acute/resolve/named.rb +116 -0
  39. data/lib/acute/resolve/nth.rb +47 -0
  40. data/lib/acute/resolve/period.rb +160 -0
  41. data/lib/acute/resolve/periodic.rb +274 -0
  42. data/lib/acute/resolve/ranges.rb +165 -0
  43. data/lib/acute/resolve/recur.rb +187 -0
  44. data/lib/acute/resolve/rel.rb +146 -0
  45. data/lib/acute/resolve/shift.rb +81 -0
  46. data/lib/acute/resolve.rb +245 -0
  47. data/lib/acute/result.rb +128 -0
  48. data/lib/acute/rule/cron.rb +222 -0
  49. data/lib/acute/rule/expand.rb +401 -0
  50. data/lib/acute/rule.rb +232 -0
  51. data/lib/acute/tables/assumptions.rb +39 -0
  52. data/lib/acute/tables/words.rb +656 -0
  53. data/lib/acute/tables/zones.rb +930 -0
  54. data/lib/acute/tool/compact.rb +82 -0
  55. data/lib/acute/tool/ruby_llm.rb +29 -0
  56. data/lib/acute/tool.rb +192 -0
  57. data/lib/acute/verify.rb +216 -0
  58. data/lib/acute/version.rb +3 -0
  59. data/lib/acute/zone.rb +252 -0
  60. data/lib/acute.rb +160 -0
  61. data/schema/description.json +12 -0
  62. data/schema/duration.json +26 -0
  63. data/schema/extraction.json +30 -0
  64. data/schema/failure.json +29 -0
  65. data/schema/now.json +17 -0
  66. data/schema/result.json +45 -0
  67. data/schema/rule.json +19 -0
  68. data/schema/tools/describe.json +13 -0
  69. data/schema/tools/duration.json +12 -0
  70. data/schema/tools/extract.json +13 -0
  71. data/schema/tools/now.json +9 -0
  72. data/schema/tools/parse.json +13 -0
  73. data/schema/tools/recurrence.json +12 -0
  74. data/schema/tools/verify.json +14 -0
  75. data/schema/verdict.json +25 -0
  76. metadata +117 -0
@@ -0,0 +1,493 @@
1
+ module Acute
2
+ # Times and durations mentioned inside prose. Lexing is lenient: unknown words split the text into runs of
3
+ # known ones. Each run is cut into the longest pieces the strict grammar accepts, leftmost first (E2, E3).
4
+ class Extraction
5
+ # offset: a character Range into the text; value: a Result, a Duration or a Rule
6
+ Mention = Data.define(:kind, :text, :offset, :value)
7
+
8
+ attr_reader :text, :mentions
9
+
10
+ # @api private
11
+ def initialize(text, mentions)
12
+ @text = text
13
+ @mentions = mentions.freeze
14
+ freeze
15
+ end
16
+
17
+ # The text without its mentions, nor the "on", "at", "for", "by" or "in" left dangling before one (E4)
18
+ def remainder
19
+ out = +""
20
+ at = 0
21
+ @mentions.each do |m|
22
+ out << @text[at...m.offset.begin].sub(/(?:\b(?:on|at|for|by|in)\s*)+\z/i, "")
23
+ at = m.offset.end
24
+ end
25
+ out << @text[at..]
26
+ out.gsub(/\s+/, " ").gsub(/\s+([,.;])/, '\1').gsub(/\A[\s,;]+|[\s,;]+\z/, "")
27
+ end
28
+
29
+ def to_h
30
+ { kind: "extraction", text: @text, remainder: remainder,
31
+ mentions: @mentions.map { |m| { kind: m.kind.to_s, text: m.text, offset: [m.offset.begin, m.offset.end], value: m.value.to_h } } }
32
+ end
33
+
34
+ def to_json(*args) = to_h.to_json(*args)
35
+
36
+ def as_json(*) = JSON.parse(to_json)
37
+ end
38
+
39
+ # @api private
40
+ module Extract
41
+ L = Lexer
42
+ S = Lexer::STRIDE
43
+ PUNCT = [44, 46, 59, 33, 63].freeze
44
+ GLUE = { L::DASH => true, L::SLASH => true, L::DOT => true }.freeze
45
+ BUDGET = 20_000
46
+ FEW = 8
47
+
48
+ # whole: the text each Result is for, when it is not the mention's own (parse with strict: false)
49
+ def self.call(text, make_context, whole = nil)
50
+ unknown = []
51
+ tokens = Lexer.lex(lexable(text), unknown)
52
+ listed_days(text, tokens)
53
+ mentions = []
54
+ from = Chars.new(text)
55
+ to = Chars.new(text)
56
+ spent = [0]
57
+ budget = BUDGET + 32 * tokens.size
58
+ runs(text, tokens, unknown).each do |a, b|
59
+ i = a
60
+ e = a
61
+ # The words in the run that make a rule. Lenient parse (whole) wants a time, so it looks for none.
62
+ marks = whole ? [] : (a...b).select { |k| Grammar.rule?(tokens[k * S, S]) }
63
+ while i < b
64
+ # No mention is longer than a phrase parse takes, so the grammar sees no more than that
65
+ start = from[tokens[i * S + 3]]
66
+ e = i + 1 if e <= i
67
+ e += 1 while e < b && to[tokens[e * S + 4]] - start <= MAX_LENGTH
68
+ # Text that makes every start parse far and fail, as "the the the…", has the grammar see a few words
69
+ # at each once it has cost far more than prose does
70
+ w = spent[0] > budget && e > i + FEW ? i + FEW : e
71
+ starts = starts_mention?(text, tokens, i)
72
+ found = starts && longest(text, tokens[(i * S)...(w * S)], make_context, whole, spent)
73
+ marks.shift while marks.any? && marks[0] < i
74
+ rule = starts && marks.any? && marks[0] < w && rule_at(text, tokens, i, w, marks[0], make_context, spent)
75
+ if rule && (!found || rule[0].offset.end >= found[0].offset.end)
76
+ mentions << rule[0]
77
+ i = rule[1]
78
+ elsif found
79
+ k = i
80
+ look = ->(n) { longest(text, tokens[(n * S)...(w * S)], make_context, whole, spent) }
81
+ # D51: "for 3 days from today" is a span with its "for", where "3 days from today" is a shift
82
+ if i > a && tokens[(i - 1) * S] == L::WORD && tokens[(i - 1) * S + 1] == :for && (with = look.(i - 1)) &&
83
+ with[0].kind == :time && with[0].offset.end >= found[0].offset.end && !same?(with[0].value, found[0].value)
84
+ found = with
85
+ k = i - 1
86
+ end
87
+ # E4: a leading "in", or a meal before "at" (D50), is left out when the rest reads the same, or
88
+ # when the rest reaches further (D50): "in 2 days from today" is "2 days from today". D51: so are
89
+ # "about" and "within" when the rest reaches further
90
+ while (lead = LEADS[tokens[k * S]]&.[](tokens[k * S + 1])) && (tokens[k * S] == L::WORD || tokens[(k + 1) * S] == L::AT)
91
+ shorter = after_lead(text, tokens, k, found, lead == :same, look)
92
+ break unless shorter
93
+ found, k = shorter
94
+ end
95
+ # D51: a bare number ending a mention goes with what follows when it starts a mention that reaches
96
+ # further: "11/20, 12 of Nov"
97
+ last = k + found[1] / S - 1
98
+ if last > k && last + 1 < w && tokens[last * S] == L::NUM && tokens[last * S + 2] & L::DIGITS <= 2 &&
99
+ !GLUE[tokens[(last - 1) * S]] && (after = look.(last)) && after[1] > S &&
100
+ (shorter = longest(text, tokens[(k * S)...(last * S)], make_context, whole, spent))
101
+ found = shorter
102
+ end
103
+ mentions << found[0]
104
+ i = k + found[1] / S
105
+ elsif tokens[i * S] == L::NUM && tokens[i * S + 2] & L::DIGITS == 4 && mentions.any? &&
106
+ (year = joined_year(text, tokens, i, mentions[-1], make_context, whole, spent))
107
+ mentions << year
108
+ i += 1
109
+ else
110
+ i += 1
111
+ end
112
+ end
113
+ end
114
+ chars = Chars.new(text)
115
+ mentions.map! { |m| m.with(offset: chars[m.offset.begin]...chars[m.offset.end]) }
116
+ Extraction.new(text, mentions)
117
+ end
118
+
119
+ # T29: a run of what the lexer's word loop carries a word over (letters, an apostrophe, a dot before a
120
+ # letter, a UTF-8 character but a space or dash), far too long to be a word: a URL, base64, CJK without
121
+ # spaces. The loop packs a word into one Integer, a bignum multiply a byte by then, so 64 KB of one took
122
+ # 0.6 s. parse and the rest reject text over MAX_LENGTH first; extract has the run's middle blanked
123
+ # for the lexer, byte for byte, so every offset stays and its ends still lex as the unknown words they were.
124
+ NOT_SPACE = "\\x00-\\x7F\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000\\u2010-\\u2015\\u2212".freeze
125
+ LONG = /(?:[A-Za-z']|[^#{NOT_SPACE}\u00BD\u2019])(?:[A-Za-z']|\.(?=[A-Za-z])|[^#{NOT_SPACE}]){63,}/
126
+ KEEP = 24 # characters at each end, more than the longest word's 14
127
+
128
+ def self.lexable(text)
129
+ return text unless LONG.match?(text)
130
+ text.gsub(LONG) do |run|
131
+ head = run[0, run[KEEP - 1] == "." ? KEEP + 1 : KEEP] # a dot before a space would lex as a dot
132
+ tail = run[-KEEP..].sub(/\A[.½]+/, "") # so would a dot or "½" after one
133
+ head + (" " * (run.bytesize - head.bytesize - tail.bytesize)) + tail
134
+ end
135
+ end
136
+
137
+ # The longest rule from token i, as [mention, the index after it]: the run up to w, then shorter, while it
138
+ # keeps the rule word at mark and doesn't end in a word that only introduces what follows (D50)
139
+ def self.rule_at(text, tokens, i, w, mark, make_context, spent)
140
+ j = w
141
+ FEW.times do
142
+ j -= 1 while j > mark + 1 && tokens[(j - 1) * S] == L::WORD && TRAILING[tokens[(j - 1) * S + 1]]
143
+ break if j <= mark
144
+ next j -= 1 if glued?(tokens, j * S)
145
+ slice = tokens[(i * S)...(j * S)]
146
+ spent[0] += slice.size
147
+ found = rule_mention(text, slice, make_context)
148
+ return [found, j] if found
149
+ j -= 1
150
+ end
151
+ nil
152
+ end
153
+
154
+ # D50: a number written as a word doesn't start one: "one every 8 hours" is "every 8 hours"
155
+ # The grammar goes first: it says no the most cheaply, and hostile text has it asked at every word
156
+ def self.rule_mention(text, slice, make_context)
157
+ tree = Grammar.rule(slice)
158
+ return if Failure === tree
159
+ st = slice[3]
160
+ return if plain_english?(slice, sentence_start?(text, st)) || in_word?(text, st, slice[-1]) ||
161
+ adjective?(text, slice) || (slice[0] == L::NUM && slice[2] & L::WORDY != 0)
162
+ stop = meridiem_dot(text, slice) || slice[-1]
163
+ piece = text.byteslice(st, stop - st)
164
+ value = Acute.send(:rule, piece, make_context.(), slice, tree)
165
+ Extraction::Mention.new(:rule, piece, st...stop, value) if Rule === value
166
+ end
167
+
168
+ CONTINUES = %w[at with from until till for and or on in by to before after during through].freeze
169
+
170
+ # E6: a lone frequency word before a noun-like word is an adjective: "hourly rate", "the weekly report". Not
171
+ # before a word that goes on with a rule: "take it daily with food"
172
+ def self.adjective?(text, slice)
173
+ content = slice.each_slice(S).reject { |k, v| k == L::ART || (k == L::WORD && v == :the) }
174
+ return false unless content.size == 1 && content[0][0] == L::WORD && Grammar::FREQUENCY[content[0][1]]
175
+ return true if content.size < slice.size / S || determined?(text, slice[3]) || hyphenated?(text, slice[-1])
176
+ at = space_after(text, slice[-1])
177
+ at > slice[-1] && (c = text.getbyte(at)) && (c | 32).between?(97, 122) &&
178
+ !CONTINUES.include?(text.byteslice(at, 12).scrub("")[/\A[a-z]+/i]&.downcase)
179
+ end
180
+
181
+ DETERMINERS = %w[the a an this our my your their his its].freeze
182
+
183
+ # E6: "read the daily", "our weekly with the team": after a determiner it is a noun phrase
184
+ # Walks back over the word byte by byte, no further than the longest determiner and one more: a regex on all
185
+ # the text before would make extract quadratic
186
+ def self.determined?(text, start)
187
+ stop = space_before(text, start)
188
+ word = stop
189
+ word -= 1 while word > 0 && stop - word < 6 && letter?(text.getbyte(word - 1))
190
+ stop < start && word < stop && !(word > 0 && letter?(text.getbyte(word - 1))) &&
191
+ DETERMINERS.include?(text.byteslice(word, stop - word).downcase)
192
+ end
193
+
194
+ def self.letter?(byte) = (byte | 32).between?(97, 122)
195
+
196
+ # E6: "to-do", "on-call": a word joined by a hyphen is a compound noun, not a word that continues a rule
197
+ def self.hyphenated?(text, stop)
198
+ text.getbyte(stop) == 45 && (c = text.getbyte(stop + 1)) && (c | 32).between?(97, 122)
199
+ end
200
+
201
+ # Character offsets of byte offsets, counted from the last one asked for: a walk through the text costs
202
+ # its length once
203
+ class Chars
204
+ def initialize(text)
205
+ @text = text
206
+ @ascii = text.ascii_only?
207
+ @byte = 0
208
+ @char = 0
209
+ end
210
+
211
+ def [](byte)
212
+ return byte if @ascii
213
+ @char += byte >= @byte ? @text.byteslice(@byte, byte - @byte).length : -@text.byteslice(byte, @byte - byte).length
214
+ @byte = byte
215
+ @char
216
+ end
217
+ end
218
+
219
+ # The longest mention at the start of the tokens, as [mention, its length in entries]. Only the prefixes
220
+ # the grammar accepts are tried, longest first: a phrase's, then a duration's.
221
+ def self.longest(text, all, make_context, whole, spent)
222
+ tokens = all
223
+ until tokens.empty?
224
+ tree, t = Grammar.longest(tokens, spent:)
225
+ units, d = Grammar.longest(tokens, duration: true, spent:)
226
+ return unless tree || units
227
+ j = t.to_i > d.to_i ? t : d
228
+ slice = j == tokens.size ? tokens : tokens[0, j]
229
+ found = !glued?(all, j) && !trailing?(tokens, j) && !before_month?(all, j) &&
230
+ mention(text, slice, j == t ? tree : nil, j == d ? units : nil, make_context, whole)
231
+ return [found, j] if found
232
+ tokens = tokens[0, j - S]
233
+ end
234
+ end
235
+
236
+ # Token index ranges [a, b) not split by an unknown word or a sentence's punctuation
237
+ def self.runs(text, tokens, unknown)
238
+ gaps = unknown.each_slice(2).map(&:first)
239
+ out = []
240
+ a = 0
241
+ n = tokens.size / S
242
+ (1..n).each do |k|
243
+ next if k < n && !split?(text, tokens, k, gaps)
244
+ out << [a, k]
245
+ a = k
246
+ end
247
+ out
248
+ end
249
+
250
+ def self.split?(text, tokens, k, gaps)
251
+ from = tokens[(k - 1) * S + 4]
252
+ to = tokens[k * S + 3]
253
+ gaps.shift while gaps.any? && gaps[0] < from
254
+ return true if gaps.any? && gaps[0] < to
255
+ dot = abbreviated?(text, tokens, k - 1) && !capital?(text.getbyte(to)) ? from : -1
256
+ (from...to).any? { |b| (c = text.getbyte(b)) != 44 && b != dot && PUNCT.include?(c) }
257
+ end
258
+
259
+ ABBREVIATED = { L::MONTH => true, L::WDAY => true, L::MER => true }.freeze
260
+
261
+ # E5: a dot right after a month, weekday or meridiem ends its abbreviation, not the sentence: "Oct. 2",
262
+ # "1p.m. to 4". One before a capital still ends the sentence.
263
+ def self.abbreviated?(text, tokens, k)
264
+ ABBREVIATED[tokens[k * S]] && text.getbyte(tokens[k * S + 4]) == 46
265
+ end
266
+
267
+ def self.capital?(byte) = byte && byte >= 65 && byte <= 90
268
+
269
+ START_WORDS = { on: true, and: true, to: true, for: true, during: true }.freeze
270
+ LEADS = { L::WORD => { in: :same, around: :further, within: :further }.freeze,
271
+ L::PART => { lunch: :same, breakfast: :same, dinner: :same }.freeze }.freeze
272
+
273
+ # The mention inside found after its leading word at k, with where it starts: with same, the next one
274
+ # when it reads the same or reaches further; otherwise the first that reaches further
275
+ def self.after_lead(text, tokens, k, found, same, look)
276
+ ((k + 1)...(k + found[1] / S)).each do |n|
277
+ next unless starts_mention?(text, tokens, n)
278
+ m = look.(n)
279
+ return [m, n] if m && (m[0].offset.end > found[0].offset.end ||
280
+ (same && m[0].offset.end == found[0].offset.end && same?(m[0].value, found[0].value)))
281
+ break if same
282
+ end
283
+ nil
284
+ end
285
+
286
+ # "on", "at", "for" and "during" introduce a mention without being part of it (E2 gives a duration its
287
+ # "for", E4 leaves out "on" and "at"). None starts inside a written number, as "12-1234" in "123-12-1234"
288
+ # or "1 in the morning" in "37.1 in the morning".
289
+ def self.starts_mention?(text, tokens, i)
290
+ k = tokens[i * S]
291
+ if i >= 2 && GLUE[tokens[(i - 1) * S]] && tokens[(i - 2) * S] == L::NUM &&
292
+ tokens[(i - 2) * S + 4] == tokens[(i - 1) * S + 3] && tokens[(i - 1) * S + 4] == tokens[i * S + 3]
293
+ return false
294
+ end
295
+ return false if k == L::AT || (k == L::WORD && START_WORDS[tokens[i * S + 1]])
296
+ return false if after?(text, tokens[i * S + 3], CURRENCY) # D50: "$12", "$DEC"
297
+ return false if k == L::NUM && after?(text, tokens[i * S + 3], LABEL) # D51: "building 4 this afternoon"
298
+ return false if k == L::PART && after?(text, tokens[i * S + 3], GREETING) # D50: "Good morning"
299
+ # D50: "start" and "end" in lower case are verbs unless "of" follows: "start 3 minutes from now"
300
+ return false if k == L::FUZZ && tokens[i * S + 2] & L::CAP == 0 &&
301
+ VERBS.match?(text.byteslice(tokens[i * S + 3], tokens[i * S + 4] - tokens[i * S + 3])) &&
302
+ !(tokens[(i + 1) * S] == L::WORD && tokens[(i + 1) * S + 1] == :of)
303
+ true
304
+ end
305
+
306
+ VERBS = /\A(?:start|end)\z/
307
+ CURRENCY = /[$£€]\s*\z/
308
+ GREETING = /\bgood\s+\z/i
309
+ LABEL = /\b(?:building|room|floor|level|gate|platform|terminal|suite|unit|table|track|bay|page|chapter)\s+\z/i
310
+
311
+ # Whether the text just before the byte matches
312
+ # The window can start inside a multibyte character, which a regex refuses to match: "会議は日本 tomorrow"
313
+ def self.after?(text, byte, pattern)
314
+ return false unless byte > 0
315
+ before = text.byteslice([byte - 12, 0].max, [byte, 12].min)
316
+ (before.valid_encoding? ? before : before.scrub("")).match?(pattern)
317
+ end
318
+
319
+ TRAILING = { on: true, the: true, at: true, and: true, of: true, to: true, in: true, for: true, by: true }.freeze
320
+
321
+ # D50: a mention doesn't end in a word that only introduces what follows: "May 6th 2004, the customer"
322
+ def self.trailing?(tokens, j)
323
+ j > S && tokens[j - S] == L::WORD && TRAILING[tokens[j - S + 1]]
324
+ end
325
+
326
+ # D50: a month abbreviation directly before another month is a name: "Angie Mar November 2019"
327
+ def self.before_month?(all, j)
328
+ j == S && all[0] == L::MONTH && all[j] == L::MONTH && all[4] - all[3] <= 3
329
+ end
330
+
331
+ # E4: the same begin and end for every reading, and the same alternatives
332
+ def self.same?(a, b)
333
+ return a == b unless a.kind == :time && b.kind == :time
334
+ [a, *a.alternatives].map { |r| [r.begin, r.end] } == [b, *b.alternatives].map { |r| [r.begin, r.end] }
335
+ end
336
+
337
+ # Nor does one end inside a written number, as "2010-2015" in "2010-2015-86"
338
+ def self.glued?(tokens, j)
339
+ j + S < tokens.size && GLUE[tokens[j]] && tokens[j - S] == L::NUM && tokens[j + S] == L::NUM &&
340
+ tokens[j - 1] == tokens[j + 3] && tokens[j + 4] == tokens[j + S + 3]
341
+ end
342
+
343
+ def self.mention(text, slice, tree, units, make_context, whole)
344
+ st = slice[3]
345
+ return if plain_english?(slice, sentence_start?(text, st)) || (slice.size == S && slice[0] == L::NUM) ||
346
+ spelled?(slice) || in_word?(text, st, slice[-1]) || one_to_one?(slice) || counted?(text, slice) ||
347
+ version?(slice)
348
+ stop = meridiem_dot(text, slice) || slice[-1]
349
+ # E5: "'40s": the apostrophe for the century left out is part of it
350
+ st -= 1 if slice[0] == L::DECADE && st > 0 && text.getbyte(st - 1) == 39
351
+ piece = text.byteslice(st, stop - st)
352
+ value = (tree && time(piece, tree, make_context, whole)) || (units && Duration.from_units(units))
353
+ return unless value
354
+ # E2: "for 2 hours" is the duration's mention, "for" included
355
+ if value.kind == :duration && (f = for_before(text, st))
356
+ st = f
357
+ piece = text.byteslice(st, stop - st)
358
+ end
359
+ Extraction::Mention.new(value.kind, piece, st...stop, value)
360
+ end
361
+
362
+ JOINED = /\A\s*(?:and|or|(?:and |but )?not)\s+\z/
363
+ YEAR_END = /\d{4}\z/
364
+
365
+ # D51: a lone year joined by "and", "or" or "not" to a mention that ends in a year is a year too: "in 2007
366
+ # and 2009", "before 2000 but not 1998". With no mention before it, a lone year is still a number (E3).
367
+ def self.joined_year(text, tokens, i, last, make_context, whole, spent)
368
+ st = tokens[i * S + 3]
369
+ stop = tokens[i * S + 4]
370
+ return unless last.text.match?(YEAR_END) && JOINED.match?(text.byteslice(last.offset.end, st - last.offset.end)) &&
371
+ !glued?(tokens, (i + 1) * S) && !in_word?(text, st, stop)
372
+ slice = tokens[i * S, S]
373
+ tree, = Grammar.longest(slice, spent:)
374
+ piece = text.byteslice(st, stop - st)
375
+ value = tree && time(piece, tree, make_context, whole)
376
+ Extraction::Mention.new(value.kind, piece, st...stop, value) if value
377
+ end
378
+
379
+ LIST_GAP = /\A\s*(?:,\s*|(?:,\s*)?(?:and|or)\s+)\z/i
380
+
381
+ # D18: a weekday in a list of weekdays is a day, though it is also a word: "mon, wed and fri", "mon and
382
+ # wed" mention each day
383
+ def self.listed_days(text, tokens)
384
+ prev = nil
385
+ (0...tokens.size).step(S) do |j|
386
+ if tokens[j] == L::WDAY
387
+ if prev && LIST_GAP.match?(text.byteslice(tokens[prev + 4], tokens[j + 3] - tokens[prev + 4]))
388
+ tokens[prev + 2] &= ~L::ENGLISH
389
+ tokens[j + 2] &= ~L::ENGLISH
390
+ end
391
+ prev = j
392
+ elsif !(tokens[j] == L::WORD && ((w = tokens[j + 1]) == :and || w == :or))
393
+ prev = nil
394
+ end
395
+ end
396
+ end
397
+
398
+ # E5: "4 a.m.", "3 p . m .": the last dot of a meridiem written with dots is part of it
399
+ def self.meridiem_dot(text, slice)
400
+ return unless slice[-S] == L::MER && (abbr = text.byteslice(slice[-2], slice[-1] - slice[-2])).include?(".")
401
+ dot = abbr.include?(" ") ? space_after(text, slice[-1]) : slice[-1]
402
+ dot + 1 if text.getbyte(dot) == 46
403
+ end
404
+
405
+ def self.time(piece, tree, make_context, whole)
406
+ ctx = make_context.()
407
+ readings = Resolve.call(tree, ctx)
408
+ Result.new(whole || piece, readings, ctx.zone, ctx.assumptions(readings[0]), piece) unless readings.empty?
409
+ end
410
+
411
+ # E3: "may", "sat", "second" in lower case and with nothing else to say they are times are words. A
412
+ # capital that starts a sentence says nothing, so "Lunch with Sam" is a word too. D51: a month after "in"
413
+ # is a month: "It may be in may".
414
+ def self.plain_english?(slice, sentence_start)
415
+ return false if slice[0] == L::WORD && slice[1] == :in && slice[S] == L::MONTH
416
+ content = slice.each_slice(S).reject { |k, *| k == L::WORD || k == L::REL || k == L::ART || k == L::AT }
417
+ content.any? && content.each_with_index.all? do |(_, _, flags, start), n|
418
+ flags & L::ENGLISH != 0 && (flags & L::CAP == 0 || (n == 0 && sentence_start && start == slice[3]))
419
+ end
420
+ end
421
+
422
+ JOINS = { to: true, and: true, or: true }.freeze
423
+
424
+ # D50: numbers written as words with no time word beside them are plain English: "two to four people",
425
+ # "One may ask", "The first free fish"
426
+ def self.spelled?(slice)
427
+ tokens = slice.each_slice(S).to_a
428
+ words, content = tokens.partition { |k, *| k == L::WORD || k == L::ART }
429
+ spelled = ->((k, _, flags)) { (k == L::NUM || k == L::ORD) && flags & L::WORDY != 0 }
430
+ if content.all?(&spelled)
431
+ content.size == 1 ? content[0][0] == L::ORD && words.all? { |_, v| v == :the } : words.any? && words.all? { |_, v| JOINS[v] }
432
+ else
433
+ words.empty? && content.size == 2 && spelled.(content[0]) && content[1][2] & (L::ENGLISH | L::CAP) == L::ENGLISH
434
+ end
435
+ end
436
+
437
+ # D50: nothing inside a word or an id: "3am" in "~.~3am3ioYcY0"
438
+ def self.in_word?(text, st, stop)
439
+ (st > 0 && alnum?(text.getbyte(st - 1))) || alnum?(text.getbyte(stop))
440
+ end
441
+
442
+ def self.alnum?(byte) = byte && ((byte >= 48 && byte <= 57) || ((byte | 32) >= 97 && (byte | 32) <= 122))
443
+
444
+ # D50: the same number at both ends is no range: "a 1-1 meeting"
445
+ def self.one_to_one?(slice)
446
+ slice.size == 3 * S && slice[0] == L::NUM && slice[S] == L::DASH && slice[2 * S] == L::NUM && slice[1] == slice[2 * S + 1]
447
+ end
448
+
449
+ # D51: three dotted numbers with a one-digit day or month and a two-digit year are a version: "1.5.30"
450
+ def self.version?(slice)
451
+ slice.size >= 5 * S && slice[S] == L::DOT && slice[3 * S] == L::DOT && slice[4 * S] == L::NUM &&
452
+ slice[4 * S + 2] & L::DIGITS == 2 && slice[2] & L::DIGITS <= 2 &&
453
+ (slice[2] & L::DIGITS == 1 || slice[2 * S + 2] & L::DIGITS == 1) &&
454
+ slice[4] == slice[S + 3] && slice[S + 4] == slice[2 * S + 3] && slice[2 * S + 4] == slice[3 * S + 3] &&
455
+ slice[3 * S + 4] == slice[4 * S + 3]
456
+ end
457
+
458
+ # D50: an ordinal before "time" or "times" counts occasions: "17th times", "the 5th time"
459
+ def self.counted?(text, slice)
460
+ slice.each_slice(S).count { |k, *| k != L::WORD && k != L::ART } == 1 && slice[-S] == L::ORD &&
461
+ text.byteslice(slice[-1], 8).scrub("").match?(/\A\s+times?\b/i) # the 8 bytes can end inside a character
462
+ end
463
+
464
+ SPACE = [32, 9, 10, 11, 12, 13].freeze
465
+
466
+ def self.space_before(text, byte)
467
+ byte -= 1 while byte > 0 && SPACE.include?(text.getbyte(byte - 1))
468
+ byte
469
+ end
470
+
471
+ def self.space_after(text, byte)
472
+ byte += 1 while byte < text.bytesize && SPACE.include?(text.getbyte(byte))
473
+ byte
474
+ end
475
+
476
+ def self.sentence_start?(text, byte)
477
+ byte = space_before(text, byte)
478
+ byte == 0 || [46, 33, 63].include?(text.getbyte(byte - 1))
479
+ end
480
+
481
+ # Where the "for" just before the byte starts, with a space or more between
482
+ def self.for_before(text, byte)
483
+ word = space_before(text, byte)
484
+ return if word == byte || word < 3 || !text.byteslice(word - 3, 3).b.casecmp?("for")
485
+ return 0 if word == 3
486
+ before = word - 4
487
+ before -= 1 while before > 0 && text.getbyte(before) & 0xC0 == 0x80
488
+ word - 3 unless text.byteslice(before, word - 3 - before).match?(/\p{Word}/)
489
+ end
490
+ end
491
+
492
+ private_constant :Extract
493
+ end
@@ -0,0 +1,109 @@
1
+ module Acute
2
+ # Clocks and years said in words: "twenty past four", "a quarter to six", "four twenty-three", "two thousand"
3
+ class Grammar
4
+ private
5
+
6
+ # D42: "five to 9" is 8:55 when the minutes are a word; "5 to 9" and "from five to nine" are ranges.
7
+ # D44: "10 after 2", "twenty before noon" are clocks however the minutes are written.
8
+ def minutes_to?(i)
9
+ w = @t[i + S + 1]
10
+ return hour?(i + 2 * S) && @t[i + 1].between?(1, 59) if w == :after || w == :before
11
+ n = @t[i + 1]
12
+ w == :to && n.between?(1, 30) && hour?(i + 2 * S) &&
13
+ !(i >= S && @t[i - S] == L::WORD && (@t[i - S + 1] == :from || @t[i - S + 1] == :between)) &&
14
+ # D44: "15 to noon" is 11:45, since as a range of hours it would end before it began
15
+ (@t[i + 2] & L::WORDY != 0 || (@t[i + 2 * S] == L::NAMED && @t[i + 2 * S + 1] == 12 && (n <= 5 || n >= 12)))
16
+ end
17
+
18
+ # A number word at i and the words after it that say one clock or year with it, or nil
19
+ def said(v, i)
20
+ t = @t
21
+ w = t[i + S + 1]
22
+ if t[i + S] == L::NUM && t[i + S + 2] & L::WORDY != 0
23
+ # "four twenty-three"
24
+ if v.between?(1, 12) && w.between?(10, 59)
25
+ @i += 2 * S
26
+ return said_clock(v, w)
27
+ end
28
+ # D44: "nineteen ninety five", "twenty ten": a year written as two numbers; "fourteen thirty" may be a
29
+ # clock, so from 13 to 23 the second must be past any minute
30
+ if v.between?(13, 29) && w.between?(10, 99) && (v.between?(18, 20) || v > 23 || w > 59)
31
+ @i += 2 * S
32
+ Date.new(v * 100 + w, nil, nil, nil, false)
33
+ end
34
+ elsif w == :oh # D44: "twelve oh three", "nine zero five"
35
+ if v.between?(1, 12) && t[i + 2 * S] == L::NUM && t[i + 2 * S + 2] & L::WORDY != 0 && (m = t[i + 2 * S + 1]).between?(1, 9)
36
+ @i += 3 * S
37
+ said_clock(v, m)
38
+ end
39
+ elsif w == :thousand && v.between?(1, 2)
40
+ thousands(v, i)
41
+ end
42
+ end
43
+
44
+ def said_clock(h, m)
45
+ c = Clock.new(h, m, 0, nil, nil, false, false)
46
+ if @t[@i] == L::MER
47
+ c.meridiem = @t[@i + 1] == 0 ? :am : :pm
48
+ @i += S
49
+ end
50
+ c
51
+ end
52
+
53
+ # D44: "two thousand", "two thousand and eighteen", "two thousand ten": a year in words
54
+ def thousands(v, i)
55
+ j = i + 2 * S
56
+ j += S if @t[j] == L::WORD && @t[j + 1] == :and && @t[j + S] == L::NUM
57
+ year = v * 1000
58
+ if @t[j] == L::NUM && @t[j + 2] & L::WORDY != 0 && @t[j + 1] < 100
59
+ year += @t[j + 1]
60
+ j += S
61
+ end
62
+ @i = j
63
+ Date.new(year, nil, nil, nil, false)
64
+ end
65
+
66
+ def hour?(i) = (@t[i] == L::NUM && @t[i + 1].between?(1, 12)) || @t[i] == L::NAMED
67
+
68
+ def clock_word?(i)
69
+ k = @t[i]
70
+ v = @t[i + 1]
71
+ (k == L::REL && v == :past) || (k == L::WORD && (v == :to || v == :after || v == :before))
72
+ end
73
+
74
+ # "twenty past four", "half past twelve", "a quarter to six", D44: "twenty after 3pm", "10 before 2",
75
+ # "a quarter past noon"
76
+ def clock_words(minutes)
77
+ @i += S
78
+ @i += S if @t[@i - S] == L::ART
79
+ @i += S if @t[@i] == L::UNIT && (@t[@i + 1] == :quarter || @t[@i + 1] == :minute)
80
+ # D48: "half three" is 3:30, as said in Britain
81
+ half = minutes == 30 && @t[@i - S + 1] == :half && @t[@i] == L::NUM
82
+ return failure(:incomplete) unless half || clock_word?(@i)
83
+ to = !half && (@t[@i + 1] == :to || @t[@i + 1] == :before)
84
+ @i += S unless half
85
+ if @t[@i] == L::NAMED
86
+ noon = @t[@i + 1] == 12
87
+ @i += S
88
+ return Clock.new(11, 60 - minutes, 0, noon ? :am : :pm, nil, false, false) if to
89
+ return Clock.new(12, minutes, 0, noon ? :pm : :am, nil, false, false)
90
+ end
91
+ return failure(:incomplete) unless @t[@i] == L::NUM && @t[@i + 1].between?(1, 12)
92
+ hour = @t[@i + 1]
93
+ @i += S
94
+ min = 0
95
+ if @t[@i] == L::NUM && @t[@i + 2] & L::WORDY != 0 && @t[@i + 1].between?(10, 59) # D48: "three mins to five thirty"
96
+ min = @t[@i + 1]
97
+ @i += S
98
+ end
99
+ @i += S if @t[@i] == L::WORD && @t[@i + 1] == :oclock
100
+ meridiem = nil
101
+ if @t[@i] == L::MER
102
+ meridiem = @t[@i + 1] == 0 ? :am : :pm
103
+ @i += S
104
+ end
105
+ t = (hour % 12 * 60 + min + (to ? -minutes : minutes)) % 720
106
+ Clock.new(t < 60 ? 12 : t / 60, t % 60, 0, meridiem, nil, false, false)
107
+ end
108
+ end
109
+ end