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,120 @@
1
+ require "json"
2
+
3
+ module Acute
4
+ # A length of time as the parts said, so "1 month" stays a month rather than a guess at its seconds
5
+ class Duration
6
+ # @api private
7
+ UNIT_PART = Ractor.make_shareable(
8
+ { year: [:years, 1], decade: [:years, 10], century: [:years, 100], quarter: [:months, 3], season: [:months, 3], month: [:months, 1],
9
+ week: [:weeks, 1], fortnight: [:weeks, 2], day: [:days, 1], business_day: [:business_days, 1],
10
+ hour: [:hours, 1], minute: [:minutes, 1], second: [:seconds, 1] }
11
+ )
12
+ FIXED = { weeks: 604_800, days: 86_400, hours: 3600, minutes: 60, seconds: 1 }.freeze
13
+ SHORT = Ractor.make_shareable({ years: "y", months: "mo", weeks: "w", business_days: " business days", days: "d",
14
+ hours: "h", minutes: "m", seconds: "s" })
15
+ LONG = Ractor.make_shareable({ years: "year", months: "month", weeks: "week", business_days: "business day",
16
+ days: "day", hours: "hour", minutes: "minute", seconds: "second" })
17
+ ORDER = SHORT.keys.freeze
18
+ private_constant :FIXED, :SHORT, :LONG, :ORDER
19
+ UNIT = { years: :year, months: :month, weeks: :week, business_days: :business_day, days: :day, hours: :hour,
20
+ minutes: :minute }.freeze
21
+
22
+ attr_reader :parts
23
+
24
+ # A fraction goes to the next smaller unit: "1.5 hours" is 1h 30m, "3.5 years" 3y 6mo; past seconds it stays
25
+ def self.from_units(units)
26
+ parts = Hash.new(0)
27
+ units.each do |u|
28
+ count = u.count
29
+ unit = u.unit
30
+ loop do
31
+ part, k = UNIT_PART.fetch(unit)
32
+ n = (smaller = Resolve::Period::SMALLER[unit]) ? count.floor : count
33
+ parts[part] += n * k
34
+ break if (count -= n).zero?
35
+ unit, m = smaller
36
+ count *= m
37
+ end
38
+ end
39
+ new(**parts)
40
+ end
41
+
42
+ def initialize(**parts)
43
+ unknown = parts.keys - ORDER
44
+ raise ArgumentError, "unknown duration parts: #{unknown.join(", ")}" if unknown.any?
45
+ @parts = ORDER.filter_map { |k| [k, whole(parts[k])] if parts[k] && parts[k] != 0 }.to_h.freeze
46
+ freeze
47
+ end
48
+
49
+ def kind = :duration
50
+
51
+ # With from:, the seconds from that Time to #after it
52
+ def seconds(from: nil, **ctx)
53
+ return whole(after(from, **ctx).to_r - from.to_r) if from
54
+ calendar = @parts.keys - FIXED.keys
55
+ if calendar.any?
56
+ raise ArgumentError, "a #{LONG[calendar[0]]} has no fixed length; use #after or #seconds(from:)"
57
+ end
58
+ @parts.sum { |k, n| n * FIXED[k] }
59
+ end
60
+
61
+ # D47: the Time this long after time, as "<this> from <time>" reads (D23): years and months first, on the
62
+ # calendar, then weeks and days of wall clock, then hours to seconds of elapsed time
63
+ def after(time, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil, fiscal_year_start: nil,
64
+ strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
65
+ ctx = Context.new(time, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
66
+ business_days, year_bias)
67
+ seconds = @parts.fetch(:seconds, 0)
68
+ node = @parts.reduce(nil) do |anchor, (part, n)|
69
+ part == :seconds ? anchor : Grammar::Shift.new(n, UNIT[part], anchor)
70
+ end
71
+ node = Grammar::Shift.new(seconds.floor, :second, node) if node.nil? || seconds.floor != 0
72
+ readings = Resolve.call(node, ctx, failure: true)
73
+ raise Explain.error(readings, to_s) if Failure === readings
74
+ epoch = readings[0].begin
75
+ Time.at(epoch, in: ctx.zone.offset_at(epoch)) + (seconds - seconds.floor) + (Time === time ? time.subsec : 0)
76
+ end
77
+
78
+ # Weeks alone stay weeks ("P2W"); ISO 8601 has no business days, so those give nil
79
+ def iso8601
80
+ return if @parts.key?(:business_days)
81
+ return "P#{@parts[:weeks]}W" if @parts.keys == [:weeks]
82
+ p = @parts
83
+ days = p.fetch(:days, 0) + p.fetch(:weeks, 0) * 7
84
+ date = [[p[:years], "Y"], [p[:months], "M"], [days.nonzero?, "D"]].filter_map { |n, l| "#{num(n)}#{l}" if n }.join
85
+ time = [[p[:hours], "H"], [p[:minutes], "M"], [p[:seconds], "S"]].filter_map { |n, l| "#{num(n)}#{l}" if n }.join
86
+ "P#{date}#{"T#{time}" unless time.empty?}"
87
+ end
88
+
89
+ def to_s(style = :short)
90
+ if style == :short
91
+ return @parts.map { |k, n| k == :business_days && n == 1 ? "1 business day" : "#{num(n)}#{SHORT[k]}" }.join(" ")
92
+ end
93
+ @parts.map { |k, n| "#{num(n)} #{LONG[k]}#{"s" unless n == 1}" }.join(" ")
94
+ end
95
+
96
+ # seconds is nil when a part has no fixed length; a fraction of a second is a Float
97
+ def to_h
98
+ fixed = (@parts.keys - FIXED.keys).empty?
99
+ { kind: "duration", parts: @parts.transform_values { json_num(_1) }, iso8601: iso8601,
100
+ seconds: fixed ? json_num(seconds) : nil }
101
+ end
102
+
103
+ def to_json(*args) = to_h.to_json(*args)
104
+
105
+ def as_json(*) = JSON.parse(to_json)
106
+
107
+ def ==(other) = Duration === other && other.parts == @parts
108
+
109
+ def inspect = "#<Acute::Duration #{self}>"
110
+
111
+ private
112
+
113
+ def json_num(n) = Rational === n ? n.to_f : n
114
+
115
+ def whole(n) = Rational === n && n.denominator == 1 ? n.to_i : n
116
+
117
+ # "123.45s": a fraction of a second as a decimal
118
+ def num(n) = Rational === n ? n.to_f.to_s : n.to_s
119
+ end
120
+ end
@@ -0,0 +1,47 @@
1
+ module Acute
2
+ # What every error acute raises on purpose includes, so one rescue catches them all
3
+ module Error; end
4
+
5
+ # T30: inspect escapes control characters but leaves Unicode format characters as they are, and a bidi
6
+ # override in a quoted word can reorder how the rest of a terminal or log line reads. A message shows them
7
+ # escaped, as inspect shows a newline; so are the control characters of a message that quotes raw (OptionParser's).
8
+ INVISIBLE = /[\p{Cc}\p{Cf}\u2028\u2029]/
9
+ def self.visible(message)
10
+ return message unless message.match?(INVISIBLE)
11
+ message.gsub(INVISIBLE) { _1.ord < 128 ? _1.inspect[1..-2] : format("\\u%04X", _1.ord) }
12
+ end
13
+
14
+ # A value a message quotes, cut so a huge one costs nothing to report or log
15
+ def self.quote(string, limit = 64) = string.length > limit ? "#{string[0, limit].inspect[0..-2]}…\"" : string.inspect
16
+
17
+ class ParseError < StandardError
18
+ include Error
19
+
20
+ attr_reader :code, :position, :unknown, :hint
21
+
22
+ def initialize(message, code:, position:, unknown: [], hint: nil)
23
+ super(Acute.visible(message))
24
+ @code = code
25
+ @position = position
26
+ @unknown = unknown
27
+ @hint = hint
28
+ end
29
+ end
30
+
31
+ # A bad value for an option; an ArgumentError, so code rescuing that is unchanged
32
+ class OptionError < ArgumentError
33
+ include Error
34
+
35
+ attr_reader :option, :value
36
+
37
+ def initialize(message, option:, value:)
38
+ super(Acute.visible(message))
39
+ @option = option
40
+ @value = value
41
+ end
42
+ end
43
+
44
+ # What a stage returns instead of raising: start and stop are byte offsets into the text
45
+ # @api private
46
+ Failure = Struct.new(:code, :start, :stop)
47
+ end
@@ -0,0 +1,165 @@
1
+ require_relative "errors"
2
+ require_relative "lexer"
3
+ require_relative "calendar"
4
+ require_relative "tables/words"
5
+
6
+ module Acute
7
+ # Turns a Failure into a ParseError with a message, character positions and a hint. Only the error
8
+ # path comes here, so it can afford to rescan the text.
9
+ # @api private
10
+ module Explain
11
+ def self.error(failure, text)
12
+ return ParseError.new("the text is not valid UTF-8", code: :invalid_encoding, position: 0...0) if failure.code == :invalid_encoding
13
+ first = chars(text, failure.start)...chars(text, failure.stop)
14
+ slice = text.byteslice(failure.start, failure.stop - failure.start)
15
+ case failure.code
16
+ when :unknown_word
17
+ words = Lexer.unknown_ranges(text).map { |r| text.byteslice(r.begin, r.size) }
18
+ hint = suggest(words.first)
19
+ noun = words.size == 1 ? "word" : "words"
20
+ ParseError.new("unknown #{noun} #{words.map(&:inspect).join(", ")}", code: :unknown_word, position: first,
21
+ unknown: words, hint: hint && "did you mean #{hint.inspect}?")
22
+ when :invalid_date
23
+ ParseError.new(invalid(text, failure) || "#{slice.inspect} is not a valid time", code: :invalid_date, position: first)
24
+ when :incomplete
25
+ ParseError.new("the phrase is incomplete", code: :incomplete, position: first)
26
+ when :conflict
27
+ ParseError.new("#{slice.inspect} doesn't fit with the rest of the phrase", code: :conflict, position: first)
28
+ when :no_occurrence
29
+ ParseError.new("the rule ends before it starts", code: :no_occurrence, position: first)
30
+ when :vague_rule
31
+ ParseError.new("#{slice.inspect} says how often, not when", code: :vague_rule, position: first)
32
+ when :not_rule
33
+ ParseError.new("not a repeating time", code: :not_rule, position: first)
34
+ when :dst_skipped_time
35
+ ParseError.new("#{slice.inspect} names a wall time the clocks skip as they go forward", code: failure.code,
36
+ position: first, hint: "dst: :earlier, :later or :compatible picks an instant for it")
37
+ when :dst_repeated_time
38
+ ParseError.new("#{slice.inspect} names a wall time that happens twice as the clocks go back",
39
+ code: failure.code, position: first, hint: "dst: :earlier or :later picks one")
40
+ when :business_day_limit
41
+ ParseError.new("more than #{Resolve::Period::LIMIT} business days in one call is too many to count", code: :business_day_limit,
42
+ position: first)
43
+ when :occurrence_limit
44
+ ParseError.new("more than #{Rule::MAX_OCCURRENCES} occurrences in one call is too many to list; each lists them lazily",
45
+ code: :occurrence_limit, position: first)
46
+ when :step_limit
47
+ ParseError.new("no occurrence within #{Rule::STEPS} steps of the search", code: :step_limit, position: first)
48
+ else
49
+ ParseError.new("longer than #{MAX_LENGTH} bytes", code: :too_long, position: first)
50
+ end
51
+ end
52
+
53
+ def self.chars(text, byte) = text.byteslice(0, byte).length
54
+
55
+ # D48's account of what is out of range in an error's span, which names no input; nil when there is none
56
+ def self.reason(error, text)
57
+ return unless error.code == :invalid_date
58
+ span = error.position
59
+ invalid(text, Failure.new(:invalid_date, text[0, span.begin].bytesize, text[0, span.end].bytesize))
60
+ end
61
+
62
+ L = Lexer
63
+ MONTHS = %w[_ January February March April May June July August September October November December].freeze
64
+
65
+ # D48: what is out of range in an invalid date or time ("June has 30 days"), or nil when it can't say
66
+ def self.invalid(text, failure)
67
+ all = Lexer.lex(text, []).each_slice(L::STRIDE).to_a
68
+ t = all.select { |_, _, _, st, e| st >= failure.start && e <= failure.stop }
69
+ mer = all[all.index(t.last) + 1]&.first == L::MER if t.any?
70
+ month = day = year = nil
71
+ nums = []
72
+ t.each_with_index do |(k, v, f), i|
73
+ case k
74
+ when L::CLOCK
75
+ hour = v / 10_000
76
+ return "there is no hour #{hour} on a 12-hour clock" if mer && hour > 12 && hour < 24
77
+ return clock(hour, v / 100 % 100, v % 100)
78
+ when L::MONTH
79
+ month = v
80
+ day ||= [i > 0 && t[i - 1], t[i + 1]].find { |x| x && x[0] == L::NUM && x[2] & L::DIGITS <= 2 }&.at(1)
81
+ when L::ORD then day = v
82
+ when L::HOLIDAY then (month, day = 2, 29) if v == :leap_day
83
+ when L::NUM
84
+ case f & L::DIGITS
85
+ when 4 then year = v
86
+ when 1, 2 then nums << v
87
+ else return nil
88
+ end
89
+ end
90
+ end
91
+ return "there is no hour #{nums[0]} on a 12-hour clock" if mer && t.size == 1 && nums[0].to_i > 12
92
+ month, day = numeric(nums, year && t[0][1] == year) unless month
93
+ return unless month && day && month.between?(1, 12) && day >= 1
94
+ return "#{year} is not a leap year" if month == 2 && day == 29 && year && !Calendar.leap?(year)
95
+ max = year ? Calendar.days_in_month(year, month) : month == 2 ? 29 : Calendar::MONTH_DAYS[month]
96
+ return if day <= max
97
+ return "February has 28 days, 29 in a leap year" if month == 2 && !year
98
+ "#{MONTHS[month]}#{" #{year}" if month == 2} has #{max} days"
99
+ end
100
+
101
+ def self.clock(hour, min, sec)
102
+ return "there is no hour #{hour}" if hour > 24 || (hour == 24 && min + sec > 0)
103
+ return "there is no minute #{min}" if min > 59
104
+ "there is no second #{sec}" if sec > 59
105
+ end
106
+
107
+ # A numeric date's month and day: year first, or the one order where only one number can be the month
108
+ def self.numeric(nums, year_first)
109
+ return unless nums.size == 2
110
+ return nums if year_first
111
+ a, b = nums
112
+ if a <= 12 && b > 12 then [a, b]
113
+ elsif b <= 12 && a > 12 then [b, a]
114
+ end
115
+ end
116
+
117
+ def self.letters(bytes) = bytes.inject(0) { |m, c| m | 1 << (c & 63) }
118
+
119
+ HINTS = Ractor.make_shareable(Tables::HINT_WORDS.map { |w| [w, w.bytes, letters(w.bytes)] })
120
+
121
+ # The closest known word within two edits, if it is close enough to be a likely typo
122
+ def self.suggest(word)
123
+ return unless word&.ascii_only?
124
+ a = word.downcase.bytes
125
+ mask = letters(a)
126
+ best = nil
127
+ best_distance = 3
128
+ HINTS.each do |candidate, b, m|
129
+ next if (b.size - a.size).abs >= best_distance
130
+ # each letter one word has and the other lacks costs an edit, so this bounds the distance from below
131
+ next if (mask & ~m).to_s(2).count("1") >= best_distance || (m & ~mask).to_s(2).count("1") >= best_distance
132
+ d = distance(a, b, best_distance)
133
+ if d < best_distance && d < b.size / 2
134
+ best = candidate
135
+ best_distance = d
136
+ end
137
+ end
138
+ best
139
+ end
140
+
141
+ # Optimal string alignment distance: Levenshtein plus adjacent transpositions, three rows at a time.
142
+ # Gives up with limit once a whole row is at least limit, since the distance can only grow from there.
143
+ def self.distance(a, b, limit = Float::INFINITY)
144
+ before = nil
145
+ prev = (0..b.size).to_a
146
+ a.each_with_index do |ca, i|
147
+ row = [i + 1]
148
+ low = i + 1
149
+ b.each_with_index do |cb, j|
150
+ d = prev[j + 1] + 1
151
+ d = row[j] + 1 if row[j] + 1 < d
152
+ s = prev[j] + (ca == cb ? 0 : 1)
153
+ d = s if s < d
154
+ d = before[j - 1] + 1 if i > 0 && j > 0 && ca == b[j - 1] && a[i - 1] == cb && before[j - 1] + 1 < d
155
+ row << d
156
+ low = d if d < low
157
+ end
158
+ return limit if low >= limit
159
+ before = prev
160
+ prev = row
161
+ end
162
+ prev[b.size]
163
+ end
164
+ end
165
+ end