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,277 @@
1
+ module Acute
2
+ # Rules, read only by Acute.recurrence. The words that make one ("every", "daily", a plural weekday) fail a
3
+ # one-off parse, so phrase never comes here.
4
+ class Grammar
5
+ # interval: units between occurrences; days: weekdays picked (0 = Sunday); nths: [n, wday] within the month
6
+ # (R8); monthdays: days of the month, -1 the last; setpos: BYSETPOS over Monday to Friday; month and date:
7
+ # what was said (R9); until, start, within, except and window hold one-off trees; length the Units of "for 6
8
+ # weeks"
9
+ Recur = Struct.new(:interval, :unit, :days, :nths, :monthdays, :setpos, :month, :date, :clocks, :part,
10
+ :until, :start, :within, :except_days, :except, :count, :length, :window)
11
+
12
+ # R5
13
+ FREQUENCY = { daily: [:day, 1], hourly: [:hour, 1], weekly: [:week, 1], monthly: [:month, 1], yearly: [:year, 1],
14
+ quarterly: [:month, 3], semiannual: [:month, 6], biweekly: [:week, 2], bimonthly: [:month, 2] }.freeze
15
+ TAIL = { until: true, through: true, except: true, starting: true, in: true, for: true }.freeze
16
+ MON_FRI = [1, 2, 3, 4, 5].freeze
17
+ SAT_SUN = [6, 0].freeze
18
+
19
+ MARKS = { every: true, once: true, **FREQUENCY.transform_values { true } }.freeze
20
+
21
+ def self.rule(tokens) = new(tokens).rule
22
+
23
+ # Whether the tokens hold a word that makes a rule: "every", a frequency, or a plural day or part of day
24
+ def self.rule?(t)
25
+ (0...t.size).step(S).any? do |i|
26
+ k = t[i]
27
+ (k == L::WORD && MARKS[t[i + 1]]) ||
28
+ ((k == L::WDAY || k == L::PART || k == L::WEEKEND || (k == L::UNIT && t[i + 1] == :business_day)) && t[i + 2] & L::PLURAL != 0)
29
+ end
30
+ end
31
+
32
+ COUNTS = { "twice" => "2 times", "thrice" => "3 times" }.freeze
33
+
34
+ # R15: whether text that failed as a rule says how often but not when: a count per period with no days or
35
+ # times ("twice a week", "3 times a day"), or a part of the day with no clock ("every morning"). Only
36
+ # recurrence! asks, once the phrase has failed, so "twice" is no word the lexer has to know.
37
+ def self.vague?(text)
38
+ t = Lexer.lex(text.gsub(/\b(twice|thrice)\b/i) { COUNTS[$1.downcase] })
39
+ return false unless Array === t
40
+ count = days = part = false
41
+ (0...t.size).step(S) do |i|
42
+ k = t[i]
43
+ return false if k == L::CLOCK || k == L::AT || k == L::MER
44
+ days ||= k == L::WDAY || k == L::ORD || k == L::MONTH
45
+ part ||= k == L::PART
46
+ next unless k == L::NUM && t[i + S] == L::WORD && t[i + S + 1] == :times
47
+ j = i + 2 * S
48
+ count ||= (t[j] == L::WORD && FREQUENCY[t[j + 1]]) || # "3 times daily"
49
+ ((t[j] == L::ART || (t[j] == L::WORD && t[j + 1] == :every)) && t[j + S] == L::UNIT)
50
+ end
51
+ (count && !days) || (part && rule?(t))
52
+ end
53
+
54
+ def rule
55
+ r = Recur.new(nil, nil, [], [], [], nil, nil, nil, [], nil, nil, nil, nil, [], [], nil, nil, nil)
56
+ every = false
57
+ while @i < @n
58
+ k = @t[@i]
59
+ v = @t[@i + 1]
60
+ if k == L::WORD && v == :every
61
+ every = true
62
+ @i += S
63
+ k = @t[@i]
64
+ if k == L::WORD && @t[@i + 1] == :other # R7
65
+ r.interval = 2
66
+ @i += S
67
+ elsif k == L::ORD && @t[@i + 1] == 2 && !r.unit && every_second?(@i) # R5: "every second" is the unit, not the 2nd of the month
68
+ r.unit = :second
69
+ @i += @i + S < @n && @t[@i + S + 1] == :of ? 4 * S : S
70
+ elsif (k == L::NUM || (k == L::ORD && @t[@i + 2 * S + 1] != :of)) && @t[@i + S] == L::UNIT &&
71
+ @t[@i + S + 1] != :business_day # R5: "every 2 weeks", "every third day"
72
+ r.interval = @t[@i + 1]
73
+ @i += S
74
+ end
75
+ elsif k == L::WORD && (f = FREQUENCY[v])
76
+ return failure(:conflict) if r.unit
77
+ every = true
78
+ r.unit, r.interval = f
79
+ @i += S
80
+ elsif k == L::WORD && v == :once && @t[@i + S] == L::ART && @t[@i + 2 * S] == L::UNIT # R5: "once a week"
81
+ every = true
82
+ @i += 2 * S
83
+ elsif k == L::WORD && (v == :the || v == :on)
84
+ @i += S
85
+ elsif k == L::WORD && v == :of
86
+ @i += S
87
+ k = @t[@i]
88
+ if k == L::WORD && ((w = @t[@i + 1]) == :every || w == :the) && @t[@i + S] == L::UNIT && @t[@i + S + 1] == :month
89
+ every ||= w == :every
90
+ @i += 2 * S
91
+ elsif k != L::MONTH
92
+ return failure(:conflict)
93
+ end
94
+ elsif k == L::WORD && v == :except
95
+ @i += S
96
+ if (k = @t[@i]) == L::WDAY || k == L::WEEKEND || (k == L::UNIT && @t[@i + 1] == :business_day)
97
+ while (k = @t[@i]) == L::WDAY || k == L::WEEKEND || (k == L::UNIT && @t[@i + 1] == :business_day)
98
+ r.except_days.concat(k == L::WDAY ? [@t[@i + 1]] : k == L::WEEKEND ? SAT_SUN : MON_FRI)
99
+ @i += S
100
+ @i += S if @t[@i] == L::WORD && @t[@i + 1] == :and
101
+ end
102
+ else
103
+ loop do
104
+ x = tail_phrase(true)
105
+ return x if Failure === x
106
+ r.except << x
107
+ break unless @t[@i] == L::WORD && @t[@i + 1] == :and
108
+ @i += S
109
+ end
110
+ end
111
+ elsif k == L::WORD && (v == :until || v == :through)
112
+ return failure(:conflict) if r.until
113
+ @i += S
114
+ r.until = tail_phrase
115
+ return r.until if Failure === r.until
116
+ elsif starting?(@i)
117
+ @i += S
118
+ r.start = tail_phrase
119
+ return r.start if Failure === r.start
120
+ elsif k == L::WORD && (v == :from || v == :between) && r.unit && !r.window # R27: "every 15 minutes from 9 to 5"
121
+ stop = @i + S
122
+ stop += S while stop < @n && !window_end?(stop)
123
+ r.window = Grammar.new(@t[@i...stop]).phrase
124
+ return r.window if Failure === r.window
125
+ @i = stop
126
+ elsif k == L::WORD && v == :in
127
+ @i += S
128
+ r.within = tail_phrase
129
+ return r.within if Failure === r.within
130
+ elsif k == L::WORD && v == :for
131
+ @i += S
132
+ if @t[@i] == L::NUM && @t[@i + S + 1] == :times # R11: "for 10 occurrences"
133
+ r.count = @t[@i + 1]
134
+ @i += 2 * S
135
+ else
136
+ stop = tail_end(@i)
137
+ units = Grammar.new(@t[@i...stop]).duration
138
+ return units if Failure === units
139
+ r.length = units
140
+ @i = stop
141
+ end
142
+ elsif k == L::NUM && @t[@i + S + 1] == :times && @t[@i + S] == L::WORD
143
+ @i += 2 * S
144
+ # R15: "3 times a day" is a count per period
145
+ return failure(:conflict) if (k = @t[@i]) == L::ART || (k == L::WORD && @t[@i + 1] == :every)
146
+ r.count = @t[@i - 2 * S + 1]
147
+ elsif k == L::ORD || (k == L::REL && v == :last)
148
+ ords = rule_ords
149
+ k = @t[@i]
150
+ if k == L::WDAY # R8
151
+ days, plural = rule_days
152
+ every ||= plural
153
+ ords.each { |n| days.each { |w| r.nths << [n, w] } }
154
+ elsif k == L::UNIT && @t[@i + 1] == :business_day
155
+ r.setpos = ords
156
+ @i += S
157
+ else
158
+ @i += S if k == L::UNIT && @t[@i + 1] == :day
159
+ return failure(:conflict) if ords.any? { |n| n == 0 || n > 31 }
160
+ r.monthdays.concat(ords)
161
+ end
162
+ elsif k == L::WDAY
163
+ days, plural = rule_days
164
+ every ||= plural
165
+ r.days.concat(days)
166
+ elsif k == L::WEEKEND || (k == L::UNIT && v == :business_day) # R6
167
+ every ||= @t[@i + 2] & L::PLURAL != 0
168
+ r.days.concat(k == L::WEEKEND ? SAT_SUN : MON_FRI)
169
+ @i += S
170
+ elsif k == L::UNIT
171
+ return failure(:conflict) if r.unit
172
+ r.unit = v
173
+ @i += S
174
+ elsif k == L::PART
175
+ return failure(:conflict) if r.part
176
+ every ||= @t[@i + 2] & L::PLURAL != 0
177
+ r.part = v
178
+ @i += S
179
+ elsif k == L::MONTH
180
+ d = month_atom
181
+ return failure(:conflict) if d.year || r.month
182
+ r.month = d.month
183
+ r.date = d if d.day
184
+ elsif k == L::AT || clock_at?(@i) || k == L::NUM
185
+ @i += S if k == L::AT
186
+ loop do
187
+ at = @i
188
+ c = atom(true)
189
+ return c if Failure === c
190
+ if Date === c && !c.year && c.month && c.day && !r.month # "each 4/15"
191
+ r.date = c
192
+ break
193
+ end
194
+ return failure(:conflict, at) unless Clock === c
195
+ r.clocks << c
196
+ break unless @t[@i] == L::WORD && @t[@i + 1] == :and && (clock_at?(@i + S) || @t[@i + S] == L::AT)
197
+ @i += S
198
+ @i += S if @t[@i] == L::AT
199
+ end
200
+ else
201
+ return failure(:conflict)
202
+ end
203
+ end
204
+ every ? r : failure(:incomplete)
205
+ end
206
+
207
+ private
208
+
209
+ # R5: an ORD 2 after "every" that is "second" written as a word, and ends the phrase, goes on with a rule word
210
+ # or is "of the day": "every second", "every second until 5pm". Before a day, a unit or "of the month" it is
211
+ # an ordinal.
212
+ def every_second?(i)
213
+ return false unless @t[i + 2] & L::ENGLISH != 0
214
+ j = i + S
215
+ return true if j >= @n
216
+ return false unless @t[j] == L::WORD
217
+ w = @t[j + 1]
218
+ TAIL[w] || (w == :of && (@t[j + S + 1] == :the || @t[j + S + 1] == :every) && @t[j + 2 * S] == L::UNIT &&
219
+ @t[j + 2 * S + 1] == :day)
220
+ end
221
+
222
+ # "first and third", "last", "second to last" (R8)
223
+ def rule_ords
224
+ out = []
225
+ loop do
226
+ if @t[@i] == L::ORD && @t[@i + S + 1] == :to && @t[@i + 2 * S] == L::REL && @t[@i + 2 * S + 1] == :last
227
+ out << -@t[@i + 1]
228
+ @i += 3 * S
229
+ else
230
+ out << (@t[@i] == L::REL ? -1 : @t[@i + 1])
231
+ @i += S
232
+ end
233
+ break unless @t[@i] == L::WORD && @t[@i + 1] == :and && (@t[@i + S] == L::ORD || @t[@i + S + 1] == :last)
234
+ @i += S
235
+ end
236
+ out
237
+ end
238
+
239
+ # "monday and wednesday", "mondays, wednesdays and fridays", and whether one was plural (R6)
240
+ def rule_days
241
+ out = []
242
+ plural = false
243
+ loop do
244
+ out << @t[@i + 1]
245
+ plural ||= @t[@i + 2] & L::PLURAL != 0
246
+ @i += S
247
+ break unless @t[@i] == L::WDAY || (@t[@i] == L::WORD && @t[@i + 1] == :and && @t[@i + S] == L::WDAY)
248
+ @i += S if @t[@i] == L::WORD
249
+ end
250
+ [out, plural]
251
+ end
252
+
253
+ # The one-off phrase up to the next rule word, or with and_stop: true also up to "and"
254
+ def tail_phrase(and_stop = false)
255
+ stop = tail_end(@i, and_stop)
256
+ return failure(:incomplete) if stop == @i
257
+ node = Grammar.new(@t[@i...stop]).phrase
258
+ @i = stop
259
+ node
260
+ end
261
+
262
+ # A window's clock range ends at the days it is on, or at the next rule word
263
+ def window_end?(j)
264
+ k = @t[j]
265
+ w = @t[j + 1]
266
+ (k == L::WORD && (TAIL[w] || w == :on || w == :every)) || k == L::WDAY || k == L::WEEKEND ||
267
+ (k == L::UNIT && w == :business_day) || (k == L::NUM && @t[j + S + 1] == :times)
268
+ end
269
+
270
+ def tail_end(j, and_stop = false)
271
+ j += S
272
+ j += S while j < @n && !(@t[j] == L::WORD && (TAIL[w = @t[j + 1]] || (and_stop && w == :and))) &&
273
+ !(@t[j] == L::NUM && @t[j + S + 1] == :times)
274
+ j > @n ? @n : j
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,226 @@
1
+ module Acute
2
+ # Counts of units: "3 days", "a week"; moved from now or an anchor: "3 days ago", "2 years 4 months ago", "+2 days"
3
+ class Grammar
4
+ private
5
+
6
+ WEEKS = { week: true, fortnight: true }.freeze
7
+
8
+ # After a quantity: "ago", "from now", "from tomorrow", "after next tuesday", or nothing. D44: more
9
+ # quantities before the tail are each applied in the order said ("1 week and a day from now").
10
+ def shift_tail(q)
11
+ start = @i
12
+ more = more_units(q)
13
+ w = @t[@i + 1]
14
+ w = :after if w == :past && @t[@i] == L::REL # "three hours past noon"
15
+ if more && !SHIFT_TAIL[w]
16
+ @i = start
17
+ more = nil
18
+ end
19
+ case w
20
+ when :ago
21
+ @i += S
22
+ shifts(-1, q, more, nil)
23
+ when :hence, :later
24
+ @i += S
25
+ shifts(1, q, more, nil)
26
+ when :from, :after, :before
27
+ @i += S
28
+ anchor = item(true)
29
+ return anchor if Failure === anchor
30
+ shifts(w == :before ? -1 : 1, q, more, Now === anchor ? nil : anchor)
31
+ else
32
+ # "a week today", "a fortnight on tuesday": that long after the day
33
+ if WEEKS[q.unit] && !more && (@t[@i] == L::DAY || (@t[@i + 1] == :on && @t[@i + S] == L::WDAY))
34
+ @i += S if @t[@i] != L::DAY
35
+ anchor = atom(true)
36
+ return Failure === anchor ? anchor : shifts(1, q, nil, anchor)
37
+ end
38
+ return q unless !more && starting?(@i)
39
+ @i += S
40
+ @i += S if @t[@i + 1] == :from
41
+ start = item(true)
42
+ Failure === start ? start : Lasting.new(q, start)
43
+ end
44
+ end
45
+
46
+ # D44: "for 3 days from today", "for 1 week, from 20th dec": that long from the start of what follows;
47
+ # nil when no "from" or "starting" follows the length
48
+ def for_from
49
+ i = @i
50
+ @i += S
51
+ q = duration_quantity
52
+ if q && !(Failure === q) && (@t[@i + 1] == :from || starting?(@i))
53
+ @i += S
54
+ @i += S if @t[@i + 1] == :from
55
+ start = item(true)
56
+ return Failure === start ? start : Lasting.new(q, start)
57
+ end
58
+ @i = i
59
+ nil
60
+ end
61
+
62
+ # D48: "from 4pm for thirty minutes", "from 18th Dec for 10 days": that long from its start, as "for 10
63
+ # days from 18th Dec"
64
+ def lasting(start)
65
+ @i += S
66
+ q = duration_quantity
67
+ return q if Failure === q
68
+ q ? Lasting.new(q, start) : failure(@i < @n ? :conflict : :incomplete)
69
+ end
70
+
71
+ # D44: "starting", "commencing", or "beginning" with no "of" after it: from the start of what follows. Not
72
+ # "start from october", a verb before its own "from".
73
+ def starting?(i)
74
+ return true if @t[i] == L::WORD && @t[i + 1] == :starting
75
+ @t[i] == L::FUZZ && @t[i + 1] == :start && (w = @t[i + S + 1]) != :of && w != :from
76
+ end
77
+
78
+ # D44: "3 fridays from now", "2nd monday before may 25", "two weekends ago", D46: "5 mornings ago": i is the day after the count
79
+ def counted?(i)
80
+ k = @t[i]
81
+ (k == L::WDAY || k == L::WEEKEND || k == L::HOLIDAY || k == L::PART || k == L::SEASON) && @t[i + S] == L::WORD &&
82
+ SHIFT_TAIL[@t[i + S + 1]]
83
+ end
84
+
85
+ # D44: "the sunday after next wed", "the monday before christmas" are the first one after or before the
86
+ # day named; "friday after next", "March after the next", "tuesday before last" the one after next friday
87
+ def around(node)
88
+ return node unless @t[@i] == L::WORD && ((w = @t[@i + 1]) == :after || w == :before)
89
+ j = @i + S
90
+ j += S if @t[j] == L::WORD && @t[j + 1] == :the
91
+ k = @t[j]
92
+ if k == L::REL && (r = @t[j + 1]) == (w == :after ? :next : :last) && nothing_own?(j + S)
93
+ @i = j + S
94
+ return Counted.new(w == :after ? 1 : -1, node, Rel.new(r, node))
95
+ end
96
+ return node unless k == L::REL || k == L::WDAY || k == L::DAY || k == L::HOLIDAY || k == L::MONTH
97
+ @i += S
98
+ anchor = item(true)
99
+ return anchor if Failure === anchor
100
+ Counted.new(w == :after ? 1 : -1, node, anchor)
101
+ end
102
+
103
+ # "next" in "the week after next" has nothing of its own when what follows is a time or nothing
104
+ def nothing_own?(j)
105
+ k = @t[j]
106
+ !k || k == L::CLOCK || k == L::NUM || k == L::AT || k == L::PART || (k == L::WORD && @t[j + 1] == :in)
107
+ end
108
+
109
+ def counted(n)
110
+ k = @t[@i]
111
+ v = @t[@i + 1]
112
+ node = k == L::WDAY ? Weekday.new(v) : k == L::HOLIDAY ? Holiday.new(v) : k == L::PART ? Part.new(v) :
113
+ k == L::SEASON ? Season.new(v) : WEEKEND
114
+ w = @t[@i + S + 1]
115
+ @i += 2 * S
116
+ return Counted.new(-n, node, nil) if w == :ago
117
+ return Counted.new(n, node, nil) if w == :hence || w == :later
118
+ anchor = item(true)
119
+ return anchor if Failure === anchor
120
+ Counted.new(w == :before ? -n : n, node, Now === anchor ? nil : anchor)
121
+ end
122
+
123
+ def shifts(sign, q, more, anchor)
124
+ node = Shift.new(sign * q.count, q.unit, anchor)
125
+ more&.each { |u| node = Shift.new(sign * u.count, u.unit, node) }
126
+ node
127
+ end
128
+
129
+ # "4 months" in "2 years 4 months ago", "a day" in "1 week and a day", "and a half" in "an hour and a
130
+ # half ago"; nil when none follow
131
+ def more_units(last)
132
+ list = nil
133
+ while @i < @n
134
+ save = @i
135
+ @i += S if @t[@i + 1] == :and
136
+ unless (q = duration_tail(last.unit) || quantity)
137
+ @i = save
138
+ break
139
+ end
140
+ (list ||= []) << q
141
+ last = q
142
+ end
143
+ list
144
+ end
145
+
146
+ # D44: "+2 days", "-3y", "- 3 seconds", "2023-12-29 -10days": a signed count of units shifts what comes
147
+ # before it, or now. After other atoms a minus needs a space before it and none after.
148
+ def signed?(last)
149
+ return false unless @t[@i + S] == L::NUM && unit_at(@i + 2 * S)
150
+ # "now - 2 hours" is two hours ago, never a range
151
+ @t[@i] == L::PLUS || last.nil? || last.equal?(NOW) || (@t[@i + 3] > @t[@i - 1] && @t[@i + 4] == @t[@i + S + 3])
152
+ end
153
+
154
+ # Each sign holds for the unsigned counts after it: "+1 day 2 hours", "+1 decade -2 years"
155
+ def signed_shifts(node)
156
+ sign = 1
157
+ while @i < @n
158
+ k = @t[@i]
159
+ if (signed = k == L::PLUS || k == L::DASH)
160
+ sign = k == L::PLUS ? 1 : -1
161
+ @i += S
162
+ end
163
+ unless (q = quantity)
164
+ return failure(:incomplete) if signed
165
+ break
166
+ end
167
+ node = Shift.new(sign * q.count, q.unit, node)
168
+ end
169
+ node
170
+ end
171
+
172
+ # "a week", "3 days", "1/2 hour"; nil (nothing read) unless the tokens are a count and a unit
173
+ def quantity
174
+ i = @i
175
+ count = nil
176
+ if (said = spoken(i))
177
+ count, i = said
178
+ elsif @t[i] == L::ART then count = 1
179
+ elsif @t[i] == L::NUM
180
+ count = @t[i + 1]
181
+ if @t[i + S] == L::SLASH && @t[i + 2 * S] == L::NUM && @t[i + 2 * S + 1] > 0
182
+ count = Rational(count, @t[i + 2 * S + 1])
183
+ i += 2 * S
184
+ elsif @t[i + S] == L::DOT && @t[i + 2 * S] == L::NUM && unit_at(i + 3 * S) && touching?(i, i + 2 * S) # "2.5 hours", not "1. 5 hours" (D47)
185
+ count += Rational(@t[i + 2 * S + 1], 10**(@t[i + 2 * S + 2] & L::DIGITS))
186
+ i += 2 * S
187
+ end
188
+ elsif @t[i + 1] == :half && @t[i + S] == L::ART # "half an hour"
189
+ count = Rational(1, 2)
190
+ i += S
191
+ elsif @t[i + 1] == :couple # D44: "a couple of days" is two
192
+ count = 2
193
+ elsif @t[i] == L::DECADE && @t[i + 2] & (L::WORDY | L::QUOTED) == 0 # "30s", "5400s": seconds, not a decade (D47); "70's" and "nineties" are decades (D50)
194
+ @i = i + S
195
+ return Unit.new(:second, @t[i + 1])
196
+ else return
197
+ end
198
+ unit = unit_at(i + S)
199
+ return unless unit
200
+ @i = i + 2 * S
201
+ Unit.new(unit, count)
202
+ end
203
+
204
+ # "this time tomorrow", "same time next week": now moved by the day or unit
205
+ def same_time
206
+ i = @i
207
+ @i += S
208
+ return failure(:incomplete) if @i >= @n
209
+ x = atom(true)
210
+ if DayRef === x then Shift.new(x.offset, :day, nil)
211
+ elsif Rel === x && Unit === x.node && !x.node.count && (x.word == :next || x.word == :last)
212
+ Shift.new(x.word == :next ? 1 : -1, x.node.unit, nil)
213
+ else Failure === x ? x : failure(:conflict, i)
214
+ end
215
+ end
216
+
217
+ def touching?(from, to) = (from...to).step(S).all? { |j| @t[j + 4] == @t[j + S + 3] }
218
+
219
+ # "second" is the ordinal unless it follows a count
220
+ def unit_at(i)
221
+ k = @t[i]
222
+ return @t[i + 1] if k == L::UNIT
223
+ :second if k == L::ORD && @t[i + 1] == 2 && @t[i + 2] & L::WORDY != 0 && @t[i + S] != L::UNIT # not "2005 second quarter"
224
+ end
225
+ end
226
+ end