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,222 @@
1
+ module Acute
2
+ # Written forms with no words in them: ISO and compact ISO dates, ISO weeks, bare digits as clocks, dotted clocks, spaced dates
3
+ class Grammar
4
+ SEPARATOR = { L::DASH => true, L::SLASH => true, L::DOT => true }.freeze
5
+
6
+ private
7
+
8
+ # D44: "20261123", "20030925T104941", "20030925T10", "19980915T09:05:32": a compact ISO date, then the time after its T
9
+ def compact_date(v, at)
10
+ m = v / 100 % 100
11
+ d = v % 100
12
+ return failure(:invalid_date, at) unless m >= 1 && m <= 12 && d >= 1 && d <= 31
13
+ date = Date.new(v / 10_000, m, d, nil, false)
14
+ k = @t[@i]
15
+ return date unless (k == L::NUM || k == L::CLOCK) && @t[@i + 3] - @t[at + 4] <= 1
16
+ if k == L::CLOCK
17
+ c = written(@t[@i + 1], @t[@i + 2])
18
+ return c if Failure === c
19
+ else
20
+ n = @t[@i + 1]
21
+ digits = @t[@i + 2] & L::DIGITS
22
+ if digits == 2
23
+ return failure(:invalid_date, @i) if n > 23
24
+ @i += S
25
+ return Within.new(Clock.new(n, 0, 0, nil, nil, true, :hour), date)
26
+ end
27
+ return failure(:invalid_date, @i) unless digits == 6 || digits == 4
28
+ n *= 100 if digits == 4
29
+ j = @i + S
30
+ # D48: "20030925T104941.5": a fraction of the second, as after "10:49:41"
31
+ frac = digits == 6 && @t[j] == L::DOT && @t[j + S] == L::NUM && @t[j - 1] == @t[j + 3] && @t[j + 4] == @t[j + S + 3]
32
+ f = frac ? (@t[j + S + 1] << 5 | @t[j + S + 2] & L::DIGITS) << L::FRACTION : 0
33
+ c = written(n, digits == 6 ? L::SECONDS | f : 0)
34
+ return c if Failure === c
35
+ @i += 2 * S if frac
36
+ end
37
+ c.h24 = true unless c.meridiem
38
+ Within.new(c, date)
39
+ end
40
+
41
+ # D44: "330 pm", "at 730 in the evening", "tonight 815": three digits marked as a time, by am or pm after
42
+ # them or "at" or a part of day before, are its hour and minutes; so are four before am or pm, "1140 a.m." (D5)
43
+ def three_digits(v, at)
44
+ h = v / 100
45
+ m = v % 100
46
+ return failure(:invalid_date, at) if m > 59
47
+ if @t[@i] == L::MER
48
+ return failure(:invalid_date, at) if h > 12
49
+ @i += S
50
+ return Clock.new(h, m, 0, @t[@i - S + 1] == 0 ? :am : :pm, nil, false, false)
51
+ end
52
+ Clock.new(h, m, 0, nil, nil, false, false)
53
+ end
54
+
55
+ # D44: "15h30", "3h18": a joined "h" between hours and minutes is a 24-hour clock; "15h" alone after "at",
56
+ # another atom or before a zone ("at 16h", "Thursday 8h GMT"). Nil when it counts: "2h ago", "15h" alone.
57
+ def h_clock(v, i, loose)
58
+ j = i + 2 * S
59
+ if @t[j] == L::NUM && @t[j + 2] & L::DIGITS == 2 && @t[j + 3] == @t[i + S + 4]
60
+ return failure(:invalid_date, j) if @t[j + 1] > 59
61
+ @i = j + S
62
+ return Clock.new(v, @t[j + 1], 0, nil, nil, true, false)
63
+ end
64
+ k = @t[j]
65
+ return unless (loose && !(k == L::WORD && SHIFT_TAIL[@t[j + 1]])) || k == L::ZONE
66
+ @i = j
67
+ Clock.new(v, 0, 0, nil, nil, true, false)
68
+ end
69
+
70
+ # D5: a time with a leading zero, after "at", or with "h"/"hrs"; otherwise a year
71
+ def four_digits(v, flags, at)
72
+ if flags & L::ZERO != 0 || (at >= S && @t[at - S] == L::AT) || @t[at + S] == L::UNIT
73
+ return failure(:invalid_date, at) if v / 100 > 23 || v % 100 > 59
74
+ Clock.new(v / 100, v % 100, 0, nil, nil, flags & L::ZERO != 0, false)
75
+ else
76
+ Date.new(v, nil, nil, nil, false)
77
+ end
78
+ end
79
+
80
+ # D44: "9.30pm", "12.55 pm", "01.03.12 a.m.", "at 12.30": numbers joined by dots before am or pm, or two
81
+ # after "at" or beside a written date ("29.02.2020 13.12", "8.20 2017-06-30"), are a clock
82
+ def dotted_clock?(i)
83
+ j = i + 2 * S
84
+ return false unless @t[i + 2] & L::DIGITS <= 2 && @t[j] == L::NUM && @t[j + 2] & L::DIGITS == 2
85
+ # D48: "18.20" makes no date either way round; "last fri 9.30" has its day already
86
+ if (i >= S && (@t[i - S] == L::AT || @t[i - S] == L::WDAY)) || (i >= 2 * S && @t[i - S] == L::NUM && SEPARATOR[@t[i - 2 * S]]) ||
87
+ (@t[j + S] == L::NUM && SEPARATOR[@t[j + 2 * S]]) || (@t[i + 1] > 12 && @t[j + 1] > 12)
88
+ return @t[j + S] != L::DOT && @t[i + 1] <= 23 && @t[j + 1] <= 59
89
+ end
90
+ j += 2 * S if @t[j + S] == L::DOT && @t[j + 2 * S] == L::NUM && @t[j + 2 * S + 2] & L::DIGITS == 2
91
+ # D48: "9.30-4.30pm": the start of a range of dotted clocks with a meridiem
92
+ j += 4 * S if @t[j + S] == L::DASH && @t[j + 2 * S] == L::NUM && @t[j + 3 * S] == L::DOT && @t[j + 4 * S] == L::NUM
93
+ @t[j + S] == L::MER
94
+ end
95
+
96
+ # D44: "2014 12 28", "10 31 1974": digits and spaces only, a year first or last, read as a slashed date (D37)
97
+ def spaced_date?(i, digits)
98
+ return false unless @t[i + 2 * S] == L::NUM && (digits == 4 || digits <= 2)
99
+ b = @t[i + S + 2]
100
+ c = @t[i + 2 * S + 2]
101
+ return false if (b | c | @t[i + 2]) & L::WORDY != 0 || b & L::DIGITS > 2
102
+ digits == 4 ? c & L::DIGITS <= 2 : c & L::DIGITS == 4
103
+ end
104
+
105
+ def spaced_date(v, digits)
106
+ t = @t
107
+ i = @i
108
+ @i += 3 * S
109
+ return Date.new(v, t[i + S + 1], t[i + 2 * S + 1], nil, false) if digits == 4
110
+ Date.new(t[i + 2 * S + 1], v, t[i + S + 1], :slash, false)
111
+ end
112
+
113
+ def dotted_clock(i)
114
+ v = @t[i + 1] * 10_000 + @t[i + 2 * S + 1] * 100
115
+ @i = i + 2 * S
116
+ if @t[@i + S] == L::DOT
117
+ @i += 2 * S
118
+ return written(v + @t[@i + 1], L::SECONDS)
119
+ end
120
+ written(v, 0)
121
+ end
122
+
123
+ def iso_date
124
+ t = @t
125
+ i = @i
126
+ y = t[i + 1]
127
+ m = t[i + 2 * S + 1]
128
+ if t[i + 2 * S + 2] & L::DIGITS == 3
129
+ # "2026-001": the day of the year
130
+ days = Calendar.days_from_civil(y, 1, 1) + m - 1
131
+ return failure(:invalid_date, i + 2 * S) unless m >= 1 && Calendar.year_of(days) == y
132
+ ymd = Calendar.civil_from_days(days)
133
+ m = ymd / 100 % 100
134
+ d = ymd % 100
135
+ @i += 3 * S
136
+ elsif @t[i + 3 * S] == L::DASH && @t[i + 4 * S] == L::NUM
137
+ d = t[i + 4 * S + 1]
138
+ @i += 5 * S
139
+ else
140
+ @i += 3 * S
141
+ return Date.new(y, m, nil, nil, false)
142
+ end
143
+ date = Date.new(y, m, d, nil, false)
144
+ @i += S if @t[@i] == L::WORD && @t[@i + 1] == :t && @t[@i + S] == L::CLOCK
145
+ # D44: "2003-09-25T10", "1978-12-22T23Z": two digits alone after the date are its hour, at the hour's grain
146
+ if @t[@i] == L::NUM && @t[@i + 2] & L::DIGITS == 2 && ((k = @t[@i + S]).nil? || k == L::ZONE) &&
147
+ @t[@i + 3] - @t[@i - 1] <= 1
148
+ return failure(:invalid_date, @i) if @t[@i + 1] > 23
149
+ @i += S
150
+ return Within.new(Clock.new(@t[@i - S + 1], 0, 0, nil, nil, true, :hour), date)
151
+ end
152
+ dotted = @t[@i] == L::NUM && @t[@i + S] == L::DOT && dotted_clock?(@i)
153
+ return date unless dotted || @t[@i] == L::CLOCK
154
+ c = dotted ? dotted_clock(@i) : written(@t[@i + 1], @t[@i + 2])
155
+ return c if Failure === c
156
+ c.h24 = true unless c.meridiem || c.named # D4, D29: after an ISO date the time is 24-hour
157
+ Within.new(c, date)
158
+ end
159
+
160
+ # D13: "week 42", "W42", "W42-3", "W423", "week 53 of 2026"
161
+ def iso_week
162
+ w = @t[@i + S + 1]
163
+ day = nil
164
+ if @t[@i + S + 2] & L::DIGITS == 3
165
+ day = w % 10
166
+ w /= 10
167
+ end
168
+ @i += 2 * S
169
+ if !day && joined?(@i) && @t[@i + S] == L::NUM && @t[@i + S + 2] & L::DIGITS == 1 && @t[@i + S + 1].between?(1, 7)
170
+ day = @t[@i + S + 1]
171
+ @i += 2 * S
172
+ end
173
+ of(IsoWeek.new(w, day))
174
+ end
175
+
176
+ def week_number?(i)
177
+ return false unless @t[i] == L::NUM
178
+ v = @t[i + 1]
179
+ # "W481": week 48, day 1, with no space after the W
180
+ return @t[i - 1] == @t[i + 3] && (v / 10).between?(1, 53) && (v % 10).between?(1, 7) if @t[i + 2] & L::DIGITS == 3
181
+ return false unless @t[i + 2] & L::DIGITS <= 2 && v.between?(1, 53)
182
+ k = @t[i + S]
183
+ k != L::UNIT && k != L::MER && k != L::SLASH && k != L::DOT && k != L::MONTH
184
+ end
185
+
186
+ # D44: "3 in the AM", "4:30 in the pm": am or pm after "in the" is the clock's
187
+ def in_the_meridiem? = @t[@i + 1] == :in && @t[@i + S + 1] == :the && @t[@i + 2 * S] == L::MER && @t[@i] == L::WORD
188
+
189
+ # D44: "0am", "00:00 am": hour 0 with am is midnight, as said by those who write 24-hour clocks
190
+ def midnight_am?(kind) = kind == L::MER && @t[@i + 1] == 0
191
+
192
+ def written(v, flags)
193
+ hour = v / 10_000
194
+ min = v / 100 % 100
195
+ sec = v % 100
196
+ at = @i
197
+ @i += S
198
+ f = flags >> L::FRACTION
199
+ frac = Rational(f >> 5, 10**(f & 31)) if f > 0
200
+ return failure(:invalid_date, at) if (hour > 23 && (v != 240_000 || frac)) || min > 59 || sec > 59
201
+ @i += 2 * S if in_the_meridiem?
202
+ kind = @t[@i]
203
+ # D44: "24:00" is the end of the day, 24-hour only
204
+ return Clock.new(24, 0, 0, nil, nil, true, flags & L::SECONDS != 0) if hour == 24 && kind != L::MER && kind != L::ART
205
+ if kind == L::MER || (kind == L::ART && @t[at + 4] == @t[@i + 3]) # "9:00a"
206
+ return failure(:invalid_date, at) if (hour < 1 && !midnight_am?(kind)) || hour > 12
207
+ meridiem = kind == L::ART || @t[@i + 1] == 0 ? :am : :pm
208
+ @i += S
209
+ Clock.new(hour, min, sec, meridiem, nil, false, flags & L::SECONDS != 0, frac)
210
+ elsif kind == L::NAMED && hour == 12 && min == 0 && sec == 0
211
+ @i += S
212
+ named(@t[@i - S + 1])
213
+ # D44: "06:00h"; D48: "06:00 hours", "18:30 hours", written as a 24-hour clock
214
+ elsif kind == L::UNIT && @t[@i + 1] == :hour && (@t[@i + 4] - @t[at + 4] == 1 || flags & L::ZERO != 0 || hour > 12)
215
+ @i += S
216
+ Clock.new(hour, min, sec, nil, nil, true, flags & L::SECONDS != 0, frac)
217
+ else
218
+ Clock.new(hour, min, sec, nil, nil, flags & L::ZERO != 0, flags & L::SECONDS != 0, frac)
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,93 @@
1
+ module Acute
2
+ # Lengths of time: counted units only, so "next friday" is not one
3
+ class Grammar
4
+ def self.duration(tokens) = new(tokens).duration
5
+
6
+ # "3 days", "1h 30m", "2 weeks and 3 days", "an hour and a half", "half an hour", "for 5 days": the Units said
7
+ def duration
8
+ parts = []
9
+ @i += S if @t[0] == L::WORD && @t[1] == :for
10
+ while @i < @n
11
+ @i += S if parts.any? && (@t[@i + 1] == :and || @t[@i] == L::PLUS)
12
+ if parts.any? && (q = duration_tail(parts.last.unit))
13
+ parts << q
14
+ next
15
+ end
16
+ half = @t[@i + 1] == :half
17
+ @i += S if half
18
+ if half && @t[@i] == L::UNIT # "half hour"
19
+ q = Unit.new(@t[@i + 1], 1)
20
+ @i += S
21
+ elsif half && @t[@i] != L::ART # D47: "half an hour", not "half145h"
22
+ return failure(@i < @n ? :conflict : :incomplete)
23
+ else
24
+ q = duration_quantity
25
+ end
26
+ return failure(@i < @n ? :conflict : :incomplete) unless q&.count&.positive?
27
+ q.count = Rational(q.count, 2) if half
28
+ parts << q
29
+ end
30
+ parts.empty? ? failure(:incomplete) : parts
31
+ end
32
+
33
+ private
34
+
35
+ # After a unit: "and a half", "and half", "and three quarters" are that much of it; a bare number last is
36
+ # the next smaller unit's ("1 hour thirty", "2 hours and 10")
37
+ def duration_tail(unit)
38
+ if (f = fraction(@i)) && @t[f[0]] != L::UNIT && @t[f[0]] != L::ART
39
+ @i = f[0]
40
+ return Unit.new(unit, f[1])
41
+ end
42
+ return unless @t[@i] == L::NUM && @i + S == @n && (unit == :hour || unit == :minute) && @t[@i + 1].between?(1, 59)
43
+ @i += S
44
+ Unit.new(unit == :hour ? :minute : :second, @t[@i - S + 1])
45
+ end
46
+
47
+ # "two and a half years", "one and three quarters of an hour", "a quarter of an hour", "3-min", "2 more
48
+ # minutes", or a quantity
49
+ def duration_quantity
50
+ i = @i
51
+ if (f = fraction(i)) && @t[f[0] + 1] == :of && @t[f[0]] == L::WORD
52
+ j = f[0] + S
53
+ j += S if @t[j] == L::ART
54
+ if (unit = unit_at(j))
55
+ @i = j + S
56
+ return Unit.new(unit, f[1])
57
+ end
58
+ end
59
+ if @t[i] == L::DECADE && @t[i + 2] & (L::WORDY | L::QUOTED) == 0 # "5400s": in a length, seconds, whatever the digits; not "70's" or "nineties" (D50)
60
+ @i = i + S
61
+ return Unit.new(:second, @t[i + 1])
62
+ end
63
+ return quantity unless @t[i] == L::NUM
64
+ if @t[i + S + 1] == :and && (f = fraction(i + 2 * S))
65
+ j = f[0]
66
+ j += S if @t[j] == L::WORD && @t[j + 1] == :of
67
+ j += S if @t[j] == L::ART
68
+ if (unit = unit_at(j))
69
+ @i = j + S
70
+ return Unit.new(unit, @t[i + 1] + f[1])
71
+ end
72
+ end
73
+ joined = @t[i + S] == L::DASH && @t[i + 4] == @t[i + S + 3] && @t[i + S + 4] == @t[i + 2 * S + 3]
74
+ if (joined || @t[i + S + 1] == :more) && (unit = unit_at(i + 2 * S))
75
+ @i = i + 3 * S
76
+ return Unit.new(unit, @t[i + 1])
77
+ end
78
+ quantity
79
+ end
80
+
81
+ # A fraction from j, and where it ends: "a half", "half", "a quarter", "three quarters"
82
+ def fraction(j)
83
+ j += S if @t[j] == L::ART && @t[j + S + 1] == :half
84
+ return [j + S, Rational(1, 2)] if @t[j] == L::WORD && @t[j + 1] == :half
85
+ n = 1
86
+ if @t[j] == L::ART || @t[j] == L::NUM
87
+ n = @t[j + 1]
88
+ j += S
89
+ end
90
+ [j + S, Rational(n, 4)] if @t[j] == L::UNIT && @t[j + 1] == :quarter && n.between?(1, 3)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,104 @@
1
+ module Acute
2
+ # An item's atoms nested by size, the coarsest outside
3
+ class Grammar
4
+ private
5
+
6
+ def pair(x, xs, y, ys)
7
+ if Clock === x && fuzzy_part?(y) then y = y.node
8
+ elsif Clock === y && fuzzy_part?(x) then x = x.node
9
+ end
10
+ a = size(x)
11
+ b = size(y)
12
+ a >= b ? nest(x, y, a == b, ys) : nest(y, x, false, xs)
13
+ end
14
+
15
+ def combine(atoms, starts)
16
+ return atoms[0] if atoms.size == 1
17
+ return pair(atoms[0], starts[0], atoms[1], starts[1]) if atoms.size == 2
18
+ atoms.map! { fuzzy_part?(_1) ? _1.node : _1 } if atoms.any? { Clock === _1 }
19
+ sizes = atoms.map { size(_1) }
20
+ # coarsest first, in the order said among equals; an insertion sort, as there are only a few
21
+ order = []
22
+ j = 0
23
+ while j < sizes.size
24
+ k = order.size
25
+ k -= 1 while k > 0 && sizes[order[k - 1]] < sizes[j]
26
+ order.insert(k, j)
27
+ j += 1
28
+ end
29
+ node = atoms[order[0]]
30
+ o = 1
31
+ while o < order.size
32
+ j = order[o]
33
+ node = nest(node, atoms[j], sizes[j] == sizes[order[o - 1]], starts[j])
34
+ if Failure === node
35
+ # The atoms nested so far conflict whatever follows the last said of them, so a shorter try (Grammar.longest)
36
+ # need not keep the atoms after it
37
+ last = order[0..o].max + 1
38
+ @stuck = starts[last] if last < starts.size
39
+ return node
40
+ end
41
+ o += 1
42
+ end
43
+ node
44
+ end
45
+
46
+ # a inside node, the coarser; same: the two are the same size, which conflicts but for a weekday and a date
47
+ def nest(node, a, same, start)
48
+ if (Shift === node || Shift === a) && (r = shifted(node, a))
49
+ return r
50
+ end
51
+ if same && !(Weekday === a && Date === node && node.day) && !(Date === a && a.day && Weekday === node)
52
+ return failure(:conflict, start)
53
+ end
54
+ return failure(:conflict, start) if Unit === a || Unit === node
55
+ # D37: a weekday with a date only picks among the date's readings
56
+ Weekday === node && Date === a ? Within.new(node, a) : Within.new(a, node)
57
+ end
58
+
59
+ # D44: "early morning at 8:00": beside a clock, early or late adds nothing to what the part says, its half
60
+ # of the day
61
+ def fuzzy_part?(a) = Fuzzy === a && Part === a.node
62
+
63
+ # D44: a shift from now beside a day moves a day that is already fixed ("3 years ago tomorrow"); one that
64
+ # recurs is within it, which resolve reads as the one in the period around the shifted instant
65
+ def shifted(x, y)
66
+ s, d = Shift === x ? [x, y] : [y, x]
67
+ return if s.anchor || Shift === d || UNIT_SIZE[s.unit] < 2 || size(d) < 2
68
+ return Shift.new(s.count, s.unit, d) if DayRef === d || Rel === d || (Date === d && d.year) || UNIT_SIZE[s.unit] == 2
69
+ Within.new(d, s)
70
+ end
71
+
72
+ # "23 oct" + "2026", or "10/23" + "2026", is one date
73
+ def merge_year(atoms, starts)
74
+ y = atoms.index { |a| Date === a && a.year && a.month.nil? && a.sep.nil? }
75
+ return unless y
76
+ d = atoms.index { |a| Date === a && a.year.nil? && a.month }
77
+ return unless d
78
+ atoms[d] = Date.new(atoms[y].year, atoms[d].month, atoms[d].day, atoms[d].sep, atoms[y].short)
79
+ atoms.delete_at(y)
80
+ starts.delete_at(y)
81
+ end
82
+
83
+ # How coarse a constituent is, for nesting: clock 0, part 1, day 2, week 3, month 5, season 6, year 7
84
+ def size(a)
85
+ case KIND[a.class]
86
+ when :clock, :approx then 0
87
+ when :part then 1
88
+ when :day_ref, :weekday, :holiday then 2
89
+ when :weekend, :iso_week then 3
90
+ when :date then a.day ? 2 : a.month ? 5 : 7
91
+ when :season then 6
92
+ when :fiscal then 7
93
+ when :unit, :years then UNIT_SIZE[a.unit]
94
+ when :rel then size(a.node)
95
+ when :fuzzy then size(a.node) - 1
96
+ when :nth then Unit === a.node ? UNIT_SIZE[a.node.unit] : size(a.node)
97
+ when :shift then UNIT_SIZE[a.unit] >= 2 ? 2 : 0
98
+ when :within then size(a.inner)
99
+ when :range then size(a.from)
100
+ else 2
101
+ end
102
+ end
103
+ end
104
+ end