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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/data/assumptions.yml +124 -0
- data/data/words.yml +699 -0
- data/exe/acute +4 -0
- data/lib/acute/assumption.rb +13 -0
- data/lib/acute/calendar.rb +91 -0
- data/lib/acute/chronic.rb +176 -0
- data/lib/acute/chronic_duration.rb +321 -0
- data/lib/acute/cli.rb +168 -0
- data/lib/acute/context.rb +205 -0
- data/lib/acute/describe.rb +316 -0
- data/lib/acute/duration.rb +120 -0
- data/lib/acute/errors.rb +47 -0
- data/lib/acute/explain.rb +165 -0
- data/lib/acute/extract.rb +493 -0
- data/lib/acute/grammar/clock_words.rb +109 -0
- data/lib/acute/grammar/compact.rb +222 -0
- data/lib/acute/grammar/duration.rb +93 -0
- data/lib/acute/grammar/nesting.rb +104 -0
- data/lib/acute/grammar/numbers.rb +378 -0
- data/lib/acute/grammar/ordinals.rb +101 -0
- data/lib/acute/grammar/ranges.rb +112 -0
- data/lib/acute/grammar/rule.rb +277 -0
- data/lib/acute/grammar/shifts.rb +226 -0
- data/lib/acute/grammar/words.rb +307 -0
- data/lib/acute/grammar.rb +378 -0
- data/lib/acute/holidays.rb +97 -0
- data/lib/acute/lexer.rb +487 -0
- data/lib/acute/locale.rb +53 -0
- data/lib/acute/mcp.rb +224 -0
- data/lib/acute/parser.rb +66 -0
- data/lib/acute/read.rb +21 -0
- data/lib/acute/resolve/clock.rb +75 -0
- data/lib/acute/resolve/dates.rb +75 -0
- data/lib/acute/resolve/day.rb +24 -0
- data/lib/acute/resolve/fuzzy.rb +91 -0
- data/lib/acute/resolve/named.rb +116 -0
- data/lib/acute/resolve/nth.rb +47 -0
- data/lib/acute/resolve/period.rb +160 -0
- data/lib/acute/resolve/periodic.rb +274 -0
- data/lib/acute/resolve/ranges.rb +165 -0
- data/lib/acute/resolve/recur.rb +187 -0
- data/lib/acute/resolve/rel.rb +146 -0
- data/lib/acute/resolve/shift.rb +81 -0
- data/lib/acute/resolve.rb +245 -0
- data/lib/acute/result.rb +128 -0
- data/lib/acute/rule/cron.rb +222 -0
- data/lib/acute/rule/expand.rb +401 -0
- data/lib/acute/rule.rb +232 -0
- data/lib/acute/tables/assumptions.rb +39 -0
- data/lib/acute/tables/words.rb +656 -0
- data/lib/acute/tables/zones.rb +930 -0
- data/lib/acute/tool/compact.rb +82 -0
- data/lib/acute/tool/ruby_llm.rb +29 -0
- data/lib/acute/tool.rb +192 -0
- data/lib/acute/verify.rb +216 -0
- data/lib/acute/version.rb +3 -0
- data/lib/acute/zone.rb +252 -0
- data/lib/acute.rb +160 -0
- data/schema/description.json +12 -0
- data/schema/duration.json +26 -0
- data/schema/extraction.json +30 -0
- data/schema/failure.json +29 -0
- data/schema/now.json +17 -0
- data/schema/result.json +45 -0
- data/schema/rule.json +19 -0
- data/schema/tools/describe.json +13 -0
- data/schema/tools/duration.json +12 -0
- data/schema/tools/extract.json +13 -0
- data/schema/tools/now.json +9 -0
- data/schema/tools/parse.json +13 -0
- data/schema/tools/recurrence.json +12 -0
- data/schema/tools/verify.json +14 -0
- data/schema/verdict.json +25 -0
- metadata +117 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Named periods: parts of the day, weekends, seasons and holidays, each inside the period that holds one
|
|
4
|
+
module Named
|
|
5
|
+
DAY = Period::DAY
|
|
6
|
+
PART_POINT = Assumption.for(:part_of_day_point)
|
|
7
|
+
WEEKEND_FRIDAY = Assumption.for(:weekend_friday_evening)
|
|
8
|
+
SEASON = Assumption.for(:meteorological_season)
|
|
9
|
+
|
|
10
|
+
# D1, D35: start and end in seconds from the day's start, and the point
|
|
11
|
+
PARTS = Ractor.make_shareable({
|
|
12
|
+
morning: [6 * 3600, 12 * 3600, 9 * 3600], afternoon: [12 * 3600, 17 * 3600, 15 * 3600],
|
|
13
|
+
evening: [17 * 3600, 21 * 3600, 19 * 3600], night: [21 * 3600, 28 * 3600, 22 * 3600],
|
|
14
|
+
tonight: [17 * 3600, 28 * 3600, 22 * 3600], lunch: [12 * 3600, 14 * 3600, 12 * 3600 + 1800],
|
|
15
|
+
early_hours: [0, 4 * 3600, 2 * 3600] # D42
|
|
16
|
+
})
|
|
17
|
+
# D3: meteorological, by the month each starts in; the south is six months on
|
|
18
|
+
SEASONS = { spring: 3, summer: 6, autumn: 9, winter: 12 }.freeze
|
|
19
|
+
# D15, D28: each built-in holiday as a formula. [month, day] is a fixed date; [month, day, wday] the first
|
|
20
|
+
# wday on or after it (the third Monday of January is the first from the 15th); [holiday, days] an offset
|
|
21
|
+
# from Easter or another holiday
|
|
22
|
+
HOLIDAYS = Ractor.make_shareable({
|
|
23
|
+
new_years_day: [1, 1], groundhog_day: [2, 2], valentines: [2, 14], st_patricks: [3, 17], april_fools: [4, 1],
|
|
24
|
+
earth_day: [4, 22], flag_day: [6, 14], juneteenth: [6, 19], independence_day: [7, 4], patriot_day: [9, 11],
|
|
25
|
+
bosses_day: [10, 16], halloween: [10, 31], veterans_day: [11, 11], christmas_eve: [12, 24],
|
|
26
|
+
christmas: [12, 25], boxing_day: [12, 26], new_years_eve: [12, 31],
|
|
27
|
+
mlk_day: [1, 15, 1], presidents_day: [2, 15, 1], mothers_day: [5, 8, 0], memorial_day: [5, 25, 1],
|
|
28
|
+
fathers_day: [6, 15, 0], labor_day: [9, 1, 1], columbus_day: [10, 8, 1], thanksgiving: [11, 22, 4],
|
|
29
|
+
us_thanksgiving: [11, 22, 4], black_friday: [:us_thanksgiving, 1], cyber_monday: [:us_thanksgiving, 4],
|
|
30
|
+
easter: [:easter, 0], good_friday: [:easter, -2], easter_monday: [:easter, 1], palm_sunday: [:easter, -7],
|
|
31
|
+
maundy_thursday: [:easter, -3], ash_wednesday: [:easter, -46], mardi_gras: [:easter, -47],
|
|
32
|
+
pentecost: [:easter, 49], whit_monday: [:easter, 50], trinity_sunday: [:easter, 56],
|
|
33
|
+
# D40, D48: the Tuesday after the first Monday; the Wednesday of April's last full week
|
|
34
|
+
election_day: [11, 2, 2], admin_day: [4, 21, 3], grandparents_day: [:labor_day, 6],
|
|
35
|
+
military_spouse_day: [:mothers_day, -2], lincolns_birthday: [2, 12], law_day: [5, 1], loyalty_day: [5, 1],
|
|
36
|
+
may_day: [5, 1], workers_day: [5, 1], siblings_day: [4, 10], world_vegan_day: [11, 1], kwanzaa: [12, 26],
|
|
37
|
+
ides_of_march: [3, 15], leap_day: [2, 29]
|
|
38
|
+
})
|
|
39
|
+
# D48: a holiday of several days names its first, then all of them
|
|
40
|
+
LASTS = { kwanzaa: 7 }.freeze
|
|
41
|
+
# D15, D27: the holidays a country has its own way, or alone
|
|
42
|
+
GB_IE = { mothers_day: [:easter, -21], mothering_sunday: [:easter, -21], may_day: [5, 1, 1] }.freeze
|
|
43
|
+
LOCAL = Ractor.make_shareable({
|
|
44
|
+
"CA" => { thanksgiving: [10, 8, 1], canada_day: [7, 1], victoria_day: [5, 18, 1], vimy_ridge_day: [4, 9] },
|
|
45
|
+
"AU" => { australia_day: [1, 26], anzac_day: [4, 25], melbourne_cup: [11, 1, 2] },
|
|
46
|
+
"NZ" => { anzac_day: [4, 25] }, "GB" => { **GB_IE, guy_fawkes: [11, 5] }, "IE" => GB_IE,
|
|
47
|
+
"ZA" => { reconciliation_day: [12, 16], kruger_day: [10, 10] }, "PH" => { rizal_day: [12, 30] },
|
|
48
|
+
"TT" => { indian_arrival_day: [5, 30] }, "BZ" => { garifuna_day: [11, 19] }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
def self.part(name, day, ctx)
|
|
52
|
+
b, e, point = PARTS.fetch(name)
|
|
53
|
+
ctx.note(PART_POINT)
|
|
54
|
+
Span.new(day * DAY + b, day * DAY + e, :hour, day * DAY + point)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# D8: Saturday 00:00 to Monday 00:00, then from Friday 18:00
|
|
58
|
+
def self.weekends(week, ctx)
|
|
59
|
+
sat = week.b + ((6 - ctx.week_start) % 7) * DAY
|
|
60
|
+
ctx.note(WEEKEND_FRIDAY)
|
|
61
|
+
[Span.new(sat, sat + 2 * DAY, :weekend, nil), Span.new(sat - 6 * 3600, sat + 2 * DAY, :weekend, nil)]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# D48: "labor day weekend": the weekend nearest the holiday, the one before from Monday to Wednesday, and the
|
|
65
|
+
# holiday with it when it is a Monday or Friday
|
|
66
|
+
def self.long_weekend(h, ctx)
|
|
67
|
+
d = h.b / DAY
|
|
68
|
+
wd = (d + 4) % 7
|
|
69
|
+
sat = (d + (wd == 0 ? -1 : wd <= 3 ? -1 - wd : 6 - wd)) * DAY
|
|
70
|
+
e = [sat + 2 * DAY, h.e].max
|
|
71
|
+
return [Span.new(h.b, e, :weekend, nil)] if h.b < sat
|
|
72
|
+
ctx.note(WEEKEND_FRIDAY)
|
|
73
|
+
[Span.new(sat, e, :weekend, nil), Span.new(sat - 6 * 3600, e, :weekend, nil)]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# D3, D33: a season belongs to the year it starts in
|
|
77
|
+
def self.season(name, year, ctx)
|
|
78
|
+
m = SEASONS.fetch(name)
|
|
79
|
+
m = (m + 5) % 12 + 1 if ctx.south
|
|
80
|
+
ctx.note(SEASON)
|
|
81
|
+
b = Period.civil(year, m, 1)
|
|
82
|
+
Span.new(b, Period.add(b, 3, :month), :season, nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# D15, D28: the holiday in the year, or nil when the locale has none of that name. D47: a holidays: given
|
|
86
|
+
# is asked first
|
|
87
|
+
def self.holiday(name, year, ctx)
|
|
88
|
+
if (given = ctx.holidays) && (date = given.named(name, year))
|
|
89
|
+
d = date.jd - 2_440_588
|
|
90
|
+
return Span.new(d * DAY, d * DAY + DAY, :day, nil)
|
|
91
|
+
end
|
|
92
|
+
if name == :leap_year # D48: named like a holiday, the whole of a year with a 29 February
|
|
93
|
+
return unless Calendar.leap?(year)
|
|
94
|
+
return Span.new(Period.civil(year, 1, 1), Period.civil(year + 1, 1, 1), :year, nil)
|
|
95
|
+
end
|
|
96
|
+
f = (own = LOCAL[ctx.locale.country]) && own[name] || HOLIDAYS[name]
|
|
97
|
+
return unless f
|
|
98
|
+
return if f[1] == 29 && f[0] == 2 && !Calendar.leap?(year) # D48: no leap day in a common year
|
|
99
|
+
d = day_of(f, year)
|
|
100
|
+
Span.new(d * DAY, d * DAY + DAY, :day, nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.day_of(f, year)
|
|
104
|
+
a, b, wday = f
|
|
105
|
+
if Integer === a
|
|
106
|
+
wday ? nth_weekday(Calendar.days_from_civil(year, a, b), wday, 1) : Calendar.days_from_civil(year, a, b)
|
|
107
|
+
elsif a == :easter then Calendar.easter(year) + b
|
|
108
|
+
else day_of(HOLIDAYS.fetch(a), year) + b
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The day number of the nth wday on or after the day first; (first + 4) % 7 is its weekday
|
|
113
|
+
def self.nth_weekday(first, wday, n) = first + (wday - (first + 4) % 7) % 7 + 7 * (n - 1)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
module Periodic
|
|
4
|
+
# "second tuesday of march", "last friday of the month", "Q3", "first day of next month"
|
|
5
|
+
def self.nth(node, s, ctx)
|
|
6
|
+
n = node.n
|
|
7
|
+
x = node.node
|
|
8
|
+
out = case x
|
|
9
|
+
when G::Weekday
|
|
10
|
+
# the nth wday from the first day, or the last one back from the last day; (d + 4) % 7 is d's weekday
|
|
11
|
+
if n > 0
|
|
12
|
+
d = s.b / DAY
|
|
13
|
+
d += (x.wday - (d + 4) % 7) % 7 + 7 * (n - 1)
|
|
14
|
+
else
|
|
15
|
+
d = s.e / DAY - 1
|
|
16
|
+
d -= (d + 4 - x.wday) % 7 - 7 * (n + 1) # D48: "second last friday"
|
|
17
|
+
end
|
|
18
|
+
b = d * DAY
|
|
19
|
+
return b >= s.b && b + DAY <= s.e ? [Span.new(b, b + DAY, :day, nil)] : []
|
|
20
|
+
when G::Weekend
|
|
21
|
+
if n > 0
|
|
22
|
+
sat = Named.nth_weekday(Period.day(s.b), 6, n)
|
|
23
|
+
else
|
|
24
|
+
sat = s.e / DAY - 1
|
|
25
|
+
sat -= (sat - 2) % 7 - 7 * (n + 1) # (sat + 4 - 6) % 7: days back to a Saturday
|
|
26
|
+
end
|
|
27
|
+
# D44: the weekend whose Saturday is in the period, though it ends after it
|
|
28
|
+
return [] unless sat * DAY >= s.b && sat * DAY < s.e
|
|
29
|
+
return Named.weekends(Period.span(:week, ctx, Period.index(:week, ctx, sat * DAY)), ctx)
|
|
30
|
+
else
|
|
31
|
+
unit = x.unit
|
|
32
|
+
if unit == :business_day # D44: the nth weekday, or the last
|
|
33
|
+
b = n > 0 ? Period.business(s.b - DAY, n, ctx) : Period.business(s.e, n, ctx)
|
|
34
|
+
[Span.new(b, b + DAY, :day, nil)]
|
|
35
|
+
elsif Period::DAYS[unit]
|
|
36
|
+
b = n > 0 ? s.b + (n - 1) * Period::DAYS[unit] * DAY : s.e + n * Period::DAYS[unit] * DAY
|
|
37
|
+
[Span.new(b, b + Period::DAYS[unit] * DAY, unit, nil)]
|
|
38
|
+
else
|
|
39
|
+
b = n > 0 ? Period.add(s.b, n - 1, unit) : Period.add(s.e, n, unit)
|
|
40
|
+
[Span.new(b, Period.add(b, 1, unit), unit, nil)]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
out.select { |o| o.b >= s.b && o.e <= s.e }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Calendar periods and unit arithmetic on local wall seconds
|
|
4
|
+
module Period
|
|
5
|
+
DAY = 86_400
|
|
6
|
+
SECONDS = { second: 1, minute: 60, hour: 3600 }.freeze
|
|
7
|
+
MONTHS = { month: 1, quarter: 3, season: 3, year: 12, decade: 120, century: 1200 }.freeze
|
|
8
|
+
DAYS = { day: 1, week: 7, fortnight: 14 }.freeze
|
|
9
|
+
|
|
10
|
+
def self.day(t) = t / DAY
|
|
11
|
+
|
|
12
|
+
def self.ymd(t) = Calendar.civil_from_days(t / DAY)
|
|
13
|
+
|
|
14
|
+
def self.year(t) = Calendar.year_of(t / DAY)
|
|
15
|
+
|
|
16
|
+
def self.civil(y, m, d) = Calendar.days_from_civil(y, m, d) * DAY
|
|
17
|
+
|
|
18
|
+
# Months since year 0, so a month is one Integer
|
|
19
|
+
def self.month_index(t)
|
|
20
|
+
p = ymd(t)
|
|
21
|
+
(p / 10_000) * 12 + p / 100 % 100 - 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.month_start(i) = civil(i / 12, i % 12 + 1, 1)
|
|
25
|
+
|
|
26
|
+
# The first day (a day number) of the week holding day d, for a week starting on week_start
|
|
27
|
+
def self.week_first(d, week_start) = d - (Calendar.weekday(d) - week_start) % 7
|
|
28
|
+
|
|
29
|
+
# A cycle is how often a periodic phrase repeats: :day, :week, :month or :year. Index i counts cycles.
|
|
30
|
+
def self.index(cycle, ctx, t)
|
|
31
|
+
case cycle
|
|
32
|
+
when :day then day(t)
|
|
33
|
+
when :week then (day(t) - ctx.week_start + 4) / 7
|
|
34
|
+
when :month then month_index(t)
|
|
35
|
+
else Calendar.year_of(t / DAY)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.span(cycle, ctx, i)
|
|
40
|
+
case cycle
|
|
41
|
+
when :day then Span.new(i * DAY, (i + 1) * DAY, :day, nil)
|
|
42
|
+
when :week
|
|
43
|
+
b = (i * 7 + ctx.week_start - 4) * DAY
|
|
44
|
+
Span.new(b, b + 7 * DAY, :week, nil)
|
|
45
|
+
when :month then Span.new(month_start(i), month_start(i + 1), :month, nil)
|
|
46
|
+
else
|
|
47
|
+
y = i - 1 # Calendar.year_start, inline
|
|
48
|
+
b = 365 * y + y / 4 - y / 100 + y / 400 - 719_162
|
|
49
|
+
Span.new(b * DAY, (b + (i % 4 == 0 && (i % 100 != 0 || i % 400 == 0) ? 366 : 365)) * DAY, :year, nil)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The calendar unit holding t
|
|
54
|
+
def self.unit(unit, ctx, t)
|
|
55
|
+
case unit
|
|
56
|
+
when :second, :minute, :hour
|
|
57
|
+
len = SECONDS[unit]
|
|
58
|
+
b = t - t % len
|
|
59
|
+
Span.new(b, b + len, unit, nil)
|
|
60
|
+
when :day, :business_day then span(:day, ctx, day(t))
|
|
61
|
+
when :week, :fortnight then span(:week, ctx, index(:week, ctx, t))
|
|
62
|
+
when :month then span(:month, ctx, month_index(t))
|
|
63
|
+
else
|
|
64
|
+
n = MONTHS[unit]
|
|
65
|
+
i = month_index(t)
|
|
66
|
+
i -= unit == :season ? (i + 1) % 3 : i % n # D33: seasons start in December, March, June and September
|
|
67
|
+
Span.new(month_start(i), month_start(i + n), unit, nil)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# t moved by n units (D23: Temporal arithmetic, so jan 31 + 1 month is feb 28). ctx only for business days.
|
|
72
|
+
def self.add(t, n, unit, ctx = nil)
|
|
73
|
+
if (s = SECONDS[unit]) then t + n * s
|
|
74
|
+
elsif (d = DAYS[unit]) then t + n * d * DAY
|
|
75
|
+
elsif unit == :business_day then business(t, n, ctx)
|
|
76
|
+
else
|
|
77
|
+
p = ymd(t)
|
|
78
|
+
y = p / 10_000
|
|
79
|
+
m = p / 100 % 100
|
|
80
|
+
i = y * 12 + m - 1 + n * MONTHS[unit]
|
|
81
|
+
y = i / 12
|
|
82
|
+
m = i % 12 + 1
|
|
83
|
+
d = p % 100
|
|
84
|
+
last = Calendar.days_in_month(y, m)
|
|
85
|
+
civil(y, m, d < last ? d : last) + t % DAY
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
LIMIT = 10_000
|
|
90
|
+
class Limit < StandardError; end
|
|
91
|
+
|
|
92
|
+
# D49: what one call may still count, shared by every count in it (T17): LIMIT business days in all, and
|
|
93
|
+
# LIMIT days a business_days: calendar says aren't. It holds the calendar too, so a holidays-gem year is
|
|
94
|
+
# looked up once a call.
|
|
95
|
+
class Budget
|
|
96
|
+
attr_accessor :days, :skips, :workdays
|
|
97
|
+
|
|
98
|
+
def initialize
|
|
99
|
+
@days = @skips = LIMIT
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# D17: Monday to Friday, or D47: the days business_days: or holidays: give; D38: the time of day is kept.
|
|
104
|
+
# D49: past the call's Budget is a failure.
|
|
105
|
+
def self.business(t, n, ctx)
|
|
106
|
+
return t if n == 0
|
|
107
|
+
budget = ctx.budget
|
|
108
|
+
raise Limit if n.abs > budget.days
|
|
109
|
+
budget.days -= n.abs
|
|
110
|
+
d = t / DAY
|
|
111
|
+
days = ctx.business_days
|
|
112
|
+
to = case days
|
|
113
|
+
when nil then Calendar.add_weekdays(d, n)
|
|
114
|
+
when Holidays::RegionDays then days.add(d, n)
|
|
115
|
+
else step(d, n, days, budget)
|
|
116
|
+
end
|
|
117
|
+
t + (to - d) * DAY
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A business_days: calendar, or holidays: that only answer holiday?, are asked day by day
|
|
121
|
+
def self.step(d, n, days, budget)
|
|
122
|
+
step = n < 0 ? -1 : 1
|
|
123
|
+
n.abs.times do
|
|
124
|
+
d += step
|
|
125
|
+
until days.business_day?(::Date.jd(d + 2_440_588))
|
|
126
|
+
raise Limit if (budget.skips -= 1) < 0
|
|
127
|
+
d += step
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
d
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# D40: "half a year" is 6 months; a fraction becomes a whole count of the next smaller unit
|
|
134
|
+
SMALLER = Ractor.make_shareable({ year: [:month, 12], quarter: [:month, 3], season: [:month, 3], month: [:day, 30], fortnight: [:day, 14],
|
|
135
|
+
week: [:day, 7], day: [:hour, 24], hour: [:minute, 60], minute: [:second, 60] })
|
|
136
|
+
|
|
137
|
+
def self.whole(count, unit)
|
|
138
|
+
while count.is_a?(Rational) && count.denominator != 1 && (smaller = SMALLER[unit])
|
|
139
|
+
unit, k = smaller
|
|
140
|
+
count *= k
|
|
141
|
+
end
|
|
142
|
+
[count.to_i, unit]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# D17, D38: only with fiscal_year_start:, and FY27 is the year ending in 2027; "next fiscal year" is the
|
|
146
|
+
# one after the fiscal year holding today
|
|
147
|
+
def self.fiscal(node, ctx, shift = nil)
|
|
148
|
+
start = ctx.fiscal_year_start
|
|
149
|
+
return [] unless start
|
|
150
|
+
if shift
|
|
151
|
+
ym = ymd(ctx.now_local) / 100
|
|
152
|
+
y = ym / 100 + shift - (ym % 100 < start ? 1 : 0)
|
|
153
|
+
else
|
|
154
|
+
y = start == 1 ? node.year : node.year - 1
|
|
155
|
+
end
|
|
156
|
+
Span.new(civil(y, start, 1), civil(y + 1, start, 1), :year, nil).then { [_1] }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Phrases that repeat: "friday" weekly, "the 3rd" monthly, "march 3" and "christmas" yearly, "tonight"
|
|
4
|
+
# daily. Each is found as an occurrence in one cycle (a week, month, year or day), so every rule about
|
|
5
|
+
# which occurrence is meant (D11, D20, D21, D32, D40) is written once, here and in rel.rb.
|
|
6
|
+
module Periodic
|
|
7
|
+
DAY = Period::DAY
|
|
8
|
+
LIMIT = 12 # cycles to search; enough for "feb 29"
|
|
9
|
+
SAME_DAY = Assumption.for(:same_day_or_next)
|
|
10
|
+
SAME_DAY_PASSED = Assumption.for(:same_day_passed)
|
|
11
|
+
SEASON_YEAR = Assumption.for(:season_year)
|
|
12
|
+
SAME_OR_NEXT = [0, DAY].freeze
|
|
13
|
+
SUB_DAY = { G::Clock => true, G::Part => true, G::Range => true, G::Fuzzy => true }.freeze # "friday early evening"
|
|
14
|
+
|
|
15
|
+
def self.cycle(node)
|
|
16
|
+
case G::KIND[node.class]
|
|
17
|
+
when :weekday, :weekend then :week
|
|
18
|
+
when :date then node.year || node.sep ? nil : node.month ? :year : :month
|
|
19
|
+
when :holiday, :season, :iso_week then :year
|
|
20
|
+
when :part, :clock then :day
|
|
21
|
+
when :nth then G::Unit === node.node && (node.node.unit == :quarter || node.node.unit == :month) ? :year : :month
|
|
22
|
+
when :within then cycle(node.outer)
|
|
23
|
+
when :fuzzy then cycle(node.node)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The occurrences in cycle i, the main one first; empty when there is none ("feb 29" in 2027)
|
|
28
|
+
def self.at(node, ctx, i, cyc = cycle(node))
|
|
29
|
+
inside(node, nil, Period.span(cyc, ctx, i), ctx)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# D11, D23: the current one if it isn't over, else the next; in past context the latest that has begun
|
|
33
|
+
def self.bare(node, ctx, cyc = cycle(node))
|
|
34
|
+
return [] unless cyc
|
|
35
|
+
now = ctx.now_local
|
|
36
|
+
i = Period.index(cyc, ctx, now)
|
|
37
|
+
# The cycles either side of now's are looked at only when an occurrence there can reach into it
|
|
38
|
+
near = contained?(node) ? 0 : 1
|
|
39
|
+
# The weekday or day of the month, under any time or part of day; (today + 4) % 7 is today's weekday
|
|
40
|
+
if cyc == :week || cyc == :month
|
|
41
|
+
day = node
|
|
42
|
+
day = day.outer while G::Within === day && SUB_DAY[day.inner.class]
|
|
43
|
+
today = cyc == :week ? G::Weekday === day && (ctx.today + 4) % 7 == day.wday :
|
|
44
|
+
G::Date === day && day.day == Calendar.civil_from_days(ctx.today) % 100
|
|
45
|
+
return on_that_day(node, ctx, i, cyc) if today && ctx.past != :back
|
|
46
|
+
# A weekday already gone this week (or, looking back, still to come) can't hold the occurrence
|
|
47
|
+
if near == 0 && !today && G::Weekday === day
|
|
48
|
+
w = i * 7 + ctx.week_start - 4 # the week's first day
|
|
49
|
+
d = w + (day.wday - w - 4) % 7
|
|
50
|
+
i += ctx.past? ? (d > ctx.today ? -1 : 0) : (d < ctx.today ? 1 : 0)
|
|
51
|
+
end
|
|
52
|
+
elsif cyc == :year && near == 0
|
|
53
|
+
# The same for a month already gone this year
|
|
54
|
+
d = node
|
|
55
|
+
d = d.outer while G::Within === d
|
|
56
|
+
if G::Date === d && (m = d.month)
|
|
57
|
+
cur = Period.ymd(now) / 100 % 100
|
|
58
|
+
i += ctx.past? ? (m > cur ? -1 : 0) : (m < cur ? 1 : 0)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
if ctx.past?
|
|
62
|
+
list = last(node, ctx, i + near, cyc) { |s| s.b <= now }
|
|
63
|
+
# D44: in past context, a period under way is the current one, then the one before
|
|
64
|
+
if ctx.past == true && !list.empty? && list[0].e > now
|
|
65
|
+
b = list[0].b
|
|
66
|
+
list += last(node, ctx, Period.index(cyc, ctx, b) - 1, cyc) { |s| s.b < b }.take(1)
|
|
67
|
+
end
|
|
68
|
+
return list
|
|
69
|
+
end
|
|
70
|
+
list = first(node, ctx, i - near, cyc) { |s| s.e > now }
|
|
71
|
+
# D40, D41: "tonight" said at exactly 00:00 is the rest of the night under way, then the coming one
|
|
72
|
+
if now % DAY == 0 && G::Part === node && node.name == :tonight && !list.empty? && list[0].b < now
|
|
73
|
+
return [Span.new(now, list[0].e, list[0].grain, nil), *at(node, ctx, i, cyc)]
|
|
74
|
+
end
|
|
75
|
+
list
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# D40, D44: a weekday or a day of the month said on that day, with or without a time: today's, then the
|
|
79
|
+
# next one (in past context the one before); today's comes second once it is over
|
|
80
|
+
def self.on_that_day(node, ctx, i, cyc)
|
|
81
|
+
cur = at(node, ctx, i, cyc)
|
|
82
|
+
if ctx.past?
|
|
83
|
+
other = last(node, ctx, i - 1, cyc) { true }.take(1)
|
|
84
|
+
return other if cur.empty?
|
|
85
|
+
today_first = cur[0].b <= ctx.now_local
|
|
86
|
+
else
|
|
87
|
+
other = first(node, ctx, i + 1, cyc) { true }
|
|
88
|
+
return other if cur.empty?
|
|
89
|
+
other = other.take(1) if cur.size == 1
|
|
90
|
+
today_first = cur[0].e > ctx.now_local
|
|
91
|
+
end
|
|
92
|
+
ctx.note(today_first ? SAME_DAY : SAME_DAY_PASSED)
|
|
93
|
+
today_first ? cur + other : other + cur
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The first occurrence from cycle i on whose main reading passes the block
|
|
97
|
+
# A while loop: a return from inside a block would unwind through it, which costs more than the search
|
|
98
|
+
def self.first(node, ctx, i, cyc = cycle(node), &)
|
|
99
|
+
stop = i + LIMIT
|
|
100
|
+
while i < stop
|
|
101
|
+
list = at(node, ctx, i, cyc)
|
|
102
|
+
return list if !list.empty? && yield(list[0])
|
|
103
|
+
return [] if list.empty? && !skips?(node) && !passed_over?(node, ctx, i, cyc, &)
|
|
104
|
+
i += 1
|
|
105
|
+
end
|
|
106
|
+
[]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether every occurrence lies inside its own cycle
|
|
110
|
+
def self.contained?(node)
|
|
111
|
+
case G::KIND[node.class]
|
|
112
|
+
when :weekday, :date, :holiday, :nth then true
|
|
113
|
+
when :clock then node.named != :midnight
|
|
114
|
+
when :within then contained?(node.inner) && contained?(node.outer)
|
|
115
|
+
else false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# D42: only a date skips the cycles it doesn't exist in ("feb 29", "the 31st"); "5th tuesday in may" is
|
|
120
|
+
# nil, not a later May that has one
|
|
121
|
+
def self.skips?(node) = G::Date === node || G::IsoWeek === node || (G::Within === node && skips?(node.inner)) ||
|
|
122
|
+
(G::Holiday === node && LEAP[node.name]) # D48
|
|
123
|
+
LEAP = { leap_day: true, leap_year: true }.freeze
|
|
124
|
+
|
|
125
|
+
# D37: a weekday with a date filters the date's own occurrence, so a cycle whose date falls on another
|
|
126
|
+
# weekday is passed over only when the date's occurrence there would be
|
|
127
|
+
def self.passed_over?(node, ctx, i, cyc)
|
|
128
|
+
date = unfiltered(node)
|
|
129
|
+
return false if date.equal?(node)
|
|
130
|
+
list = inside(date, nil, Period.span(cyc, ctx, i), ctx)
|
|
131
|
+
!list.empty? && !yield(list[0])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.unfiltered(node)
|
|
135
|
+
return node unless G::Within === node
|
|
136
|
+
return node.outer if G::Weekday === node.inner && G::Date === node.outer
|
|
137
|
+
outer = unfiltered(node.outer)
|
|
138
|
+
outer.equal?(node.outer) ? node : G::Within.new(node.inner, outer)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The latest occurrence at or before cycle i whose main reading passes the block
|
|
142
|
+
def self.last(node, ctx, i, cyc = cycle(node), &)
|
|
143
|
+
stop = i - LIMIT
|
|
144
|
+
while i > stop
|
|
145
|
+
list = at(node, ctx, i, cyc)
|
|
146
|
+
return list if !list.empty? && yield(list[0])
|
|
147
|
+
return [] if list.empty? && !skips?(node) && !passed_over?(node, ctx, i, cyc, &)
|
|
148
|
+
i -= 1
|
|
149
|
+
end
|
|
150
|
+
[]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The readings of node inside the span s; outer is the node s came from, which a clock needs to see a
|
|
154
|
+
# part of day
|
|
155
|
+
def self.inside(node, outer, s, ctx)
|
|
156
|
+
case G::KIND[node.class]
|
|
157
|
+
when :within
|
|
158
|
+
outers = inside(node.outer, outer, s, ctx)
|
|
159
|
+
return inside(node.inner, node.outer, outers[0], ctx) if outers.size == 1
|
|
160
|
+
outers.flat_map { |o| inside(node.inner, node.outer, o, ctx) }
|
|
161
|
+
when :clock then clock(node, outer, s, ctx)
|
|
162
|
+
when :weekday
|
|
163
|
+
d = s.b / DAY
|
|
164
|
+
d += (node.wday - (d + 4) % 7) % 7 # (d + 4) % 7: d's weekday
|
|
165
|
+
d * DAY < s.e ? [Span.new(d * DAY, d * DAY + DAY, :day, nil)] : []
|
|
166
|
+
when :date then date(node, s, ctx)
|
|
167
|
+
when :part then [Named.part(node.name, Period.day(s.b), ctx)]
|
|
168
|
+
when :weekend
|
|
169
|
+
return Named.long_weekend(s, ctx) if G::Holiday === outer
|
|
170
|
+
week = s.grain == :week ? s : Period.span(:week, ctx, Period.index(:week, ctx, s.b))
|
|
171
|
+
Named.weekends(week, ctx)
|
|
172
|
+
when :holiday
|
|
173
|
+
return [] unless (h = Named.holiday(node.name, Period.year(s.b), ctx))
|
|
174
|
+
list = within(h, s)
|
|
175
|
+
n = Named::LASTS[node.name]
|
|
176
|
+
n && !list.empty? ? [*list, Span.new(h.b, h.b + n * DAY, :day, nil)] : list
|
|
177
|
+
when :season
|
|
178
|
+
y = Period.year(s.b)
|
|
179
|
+
list = within(Named.season(node.name, y, ctx), s)
|
|
180
|
+
return list unless G::Date === outer && s.grain == :year
|
|
181
|
+
# D3: "winter 2026" is the one starting in 2026, then the one ending in it
|
|
182
|
+
before = Named.season(node.name, y - 1, ctx)
|
|
183
|
+
return list unless before.e > s.b
|
|
184
|
+
ctx.note(SEASON_YEAR)
|
|
185
|
+
list << before
|
|
186
|
+
when :nth then nth(node, s, ctx)
|
|
187
|
+
when :iso_week then iso_week(node, Period.year(s.b))
|
|
188
|
+
when :range then Ranges.inside(node, outer, s, ctx)
|
|
189
|
+
when :fuzzy then inside(node.node, outer, s, ctx).flat_map { |x| Fuzzy.apply(node.pos, x, node.node, ctx) }
|
|
190
|
+
else Resolve.spans(node, ctx).select { |x| x.b >= s.b && x.b < s.e }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# D13: the ISO week of year y, which may start in December before it; week 53 only in a year that has one
|
|
195
|
+
def self.iso_week(node, y)
|
|
196
|
+
d = Calendar.iso_week_one(y) + 7 * (node.week - 1)
|
|
197
|
+
return [] if node.week == 53 && d == Calendar.iso_week_one(y + 1)
|
|
198
|
+
return [Span.new(d * DAY, d * DAY + 7 * DAY, :week, nil)] unless node.day
|
|
199
|
+
d += node.day - 1
|
|
200
|
+
[Span.new(d * DAY, d * DAY + DAY, :day, nil)]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.within(x, s) = x.b >= s.b && x.b < s.e ? [x] : []
|
|
204
|
+
|
|
205
|
+
def self.date(node, s, ctx)
|
|
206
|
+
return Dates.orders(node, ctx).flat_map { |d| date(d, s, ctx) } if node.sep
|
|
207
|
+
# A year from January 1st (a cycle, not a fiscal year) needs no civil date
|
|
208
|
+
if s.grain == :year && (y = Calendar.year_if_first(s.b / DAY))
|
|
209
|
+
y = node.year || y
|
|
210
|
+
m = node.month || 1
|
|
211
|
+
else
|
|
212
|
+
p = Period.ymd(s.b)
|
|
213
|
+
y = node.year || p / 10_000
|
|
214
|
+
m = node.month || p / 100 % 100
|
|
215
|
+
end
|
|
216
|
+
if node.day
|
|
217
|
+
return [] unless m >= 1 && m <= 12 && node.day >= 1 && (node.day <= 28 || node.day <= Calendar.days_in_month(y, m))
|
|
218
|
+
b = Calendar.days_from_civil(y, m, node.day) * DAY
|
|
219
|
+
b >= s.b && b < s.e ? [Span.new(b, b + DAY, :day, nil)] : []
|
|
220
|
+
elsif node.month
|
|
221
|
+
return [] unless m >= 1 && m <= 12
|
|
222
|
+
b = Calendar.days_from_civil(y, m, 1) * DAY
|
|
223
|
+
b >= s.b && b < s.e ? [Span.new(b, b + (m == 2 ? Calendar.days_in_month(y, m) : Calendar::MONTH_DAYS[m]) * DAY, :month, nil)] : []
|
|
224
|
+
else
|
|
225
|
+
within(Span.new(Calendar.year_start(y) * DAY, Calendar.year_start(y + 1) * DAY, :year, nil), s)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# D4, D6 for a clock inside a day; D35, D40 inside a part of the day
|
|
230
|
+
def self.clock(c, outer, s, ctx)
|
|
231
|
+
cands = Clock.candidates(c, ctx, true)
|
|
232
|
+
grain, len = Clock.grain(c)
|
|
233
|
+
day = s.b - s.b % DAY
|
|
234
|
+
part = part_name(outer)
|
|
235
|
+
# D6: midnight "at the start of" a day is its first moment, "at the end of" its last
|
|
236
|
+
cands = [outer.pos == :start ? 0 : 86_400] if c.named == :midnight && G::Fuzzy === outer && (outer.pos == :start || outer.pos == :end)
|
|
237
|
+
unless part
|
|
238
|
+
return [Span.new(day + cands[0], day + cands[0] + len, grain, nil)] if cands.size == 1
|
|
239
|
+
return cands.map { |secs| Span.new(day + secs, day + secs + len, grain, nil) }
|
|
240
|
+
end
|
|
241
|
+
if c.hour == 12 && !c.meridiem && !c.named # D30, D40: 12 in the morning, 12 at night
|
|
242
|
+
secs = c.min * 60 + c.sec
|
|
243
|
+
cands = [secs + (part == :morning ? 0 : part == :afternoon || part == :lunch ? 43_200 : 86_400)]
|
|
244
|
+
elsif !c.meridiem && part != :night && part != :tonight && part != :lunch
|
|
245
|
+
# D40: the part fixes am or pm, even for a time written with seconds; D35: lunch takes the closer
|
|
246
|
+
pm = part != :morning && part != :early_hours
|
|
247
|
+
cands = [c.hour * 3600 + c.min * 60 + c.sec + (pm ? 43_200 : 0)] if !c.h24 && c.hour >= 1 && c.hour <= 11
|
|
248
|
+
fixed = cands.select { |x| (x >= 43_200) == pm }
|
|
249
|
+
cands = fixed unless fixed.empty?
|
|
250
|
+
end
|
|
251
|
+
best = nil
|
|
252
|
+
best_gap = nil
|
|
253
|
+
cands.each do |secs|
|
|
254
|
+
SAME_OR_NEXT.each do |k| # the next day too, for a part that runs past midnight
|
|
255
|
+
t = day + k + secs
|
|
256
|
+
gap = t < s.b ? s.b - t : t >= s.e ? t - s.e : -1
|
|
257
|
+
next if best_gap && gap >= best_gap
|
|
258
|
+
best = t
|
|
259
|
+
best_gap = gap
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
[Span.new(best, best + len, grain, nil)]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def self.part_name(node)
|
|
266
|
+
case node
|
|
267
|
+
when G::Part then node.name
|
|
268
|
+
when G::Rel, G::Fuzzy then part_name(node.node)
|
|
269
|
+
when G::Within then part_name(node.inner)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|