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,82 @@
1
+ module Acute
2
+ class Tool
3
+ # The model pays for every char of a result on every call, so a tool gives the library's JSON without what
4
+ # repeats: the input's echo, a begin equal to time, a mention's kind and text again in its value, the expected
5
+ # reading of a verdict that holds, now's offset, which the now string ends with, a rule's ical when it holds
6
+ # only its dtstart and rrule, whichever of cron and cron_reason is null, and empty lists. An assumption's
7
+ # message is its short form, the gist in about half the chars. The schema rules make the same cuts in the output
8
+ # schemas, so the two can't drift.
9
+ # @api private
10
+ module Compact
11
+ def self.call(out)
12
+ case out["kind"]
13
+ when "time" then result(out)
14
+ when "extraction"
15
+ out.delete("text")
16
+ out["mentions"].each do |m|
17
+ m.delete("kind")
18
+ case m["value"]["kind"]
19
+ when "time" then result(m["value"])
20
+ when "rule" then rule(m["value"])
21
+ end
22
+ end
23
+ when "verdict"
24
+ out.delete("text")
25
+ if out["ok"] then out.delete("expected")
26
+ elsif out["expected"] then result(out["expected"])
27
+ end
28
+ when "rule" then rule(out)
29
+ when "now" then out.delete("offset")
30
+ end
31
+ out.delete("unread") if out["unread"] == []
32
+ out
33
+ end
34
+
35
+ def self.result(r)
36
+ r.delete("text")
37
+ if r["assumptions"].empty? then r.delete("assumptions")
38
+ else r["assumptions"].each { _1["message"] = Assumption.for(_1["code"].to_sym).short }
39
+ end
40
+ r.delete("alternatives") if r["alternatives"].empty?
41
+ [r, *r["alternatives"]].each { _1.delete("begin") if _1["begin"] == _1["time"] }
42
+ end
43
+ private_class_method :result
44
+
45
+ def self.rule(r)
46
+ r.delete("text")
47
+ r.delete("ical") unless r["ical"].include?("\nEXDATE")
48
+ r.delete(r["cron"] ? "cron_reason" : "cron")
49
+ end
50
+ private_class_method :rule
51
+
52
+ # defs: the schemas tool's output schema holds, by file name, before they are inlined. The recurrence tool
53
+ # adds a rule's first occurrences, and extract a count of the mentions past its cap.
54
+ def self.schema(defs, tool)
55
+ trim(defs["result"], drop: %w[text], optional: %w[begin assumptions alternatives])
56
+ trim(defs.dig("result", "properties", "alternatives", "items"), optional: %w[begin])
57
+ trim(defs["extraction"], drop: %w[text])
58
+ trim(defs.dig("extraction", "properties", "mentions", "items"), drop: %w[kind])
59
+ defs["extraction"]["properties"]["more"] = { "type" => "integer" } if defs["extraction"]
60
+ trim(defs["verdict"], drop: %w[text], optional: %w[expected unread])
61
+ if (rule = defs["rule"])
62
+ trim(rule, drop: %w[text], optional: %w[ical cron cron_reason])
63
+ rule["properties"] = rule["properties"].merge("cron" => { "type" => "string" }, "cron_reason" => { "type" => "string" })
64
+ end
65
+ if tool == "recurrence"
66
+ rule["properties"]["next"] = { "type" => "array", "items" => { "type" => "string" }, "maxItems" => OCCURRENCES }
67
+ rule["required"] += ["next"]
68
+ end
69
+ trim(defs["now"], drop: %w[offset])
70
+ trim(defs["failure"], drop: %w[text]) # T5: a tool never echoes its input
71
+ defs
72
+ end
73
+
74
+ def self.trim(node, drop: [], optional: [])
75
+ return unless node
76
+ node["properties"] = node["properties"].except(*drop)
77
+ node["required"] -= drop + optional
78
+ end
79
+ private_class_method :trim
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,29 @@
1
+ begin
2
+ require "ruby_llm"
3
+ rescue LoadError => e
4
+ raise LoadError, "acute/tool/ruby_llm needs the ruby_llm gem; add it to your Gemfile (#{e.message})"
5
+ end
6
+ require_relative "../tool"
7
+
8
+ module Acute
9
+ class Tool
10
+ # One RubyLLM::Tool per tool, bound to this one's options: chat.with_tools(*Acute::Tool.new(zone: user.zone).ruby_llm)
11
+ def ruby_llm = definitions.map { RubyLLMTool.new(self, _1) }
12
+
13
+ # RubyLLM reads a tool's name, description and parameters_schema from the instance, and validates the
14
+ # arguments against execute's keywords before calling it, so execute takes any and Acute::Tool checks them
15
+ class RubyLLMTool < ::RubyLLM::Tool
16
+ attr_reader :name, :description, :parameters_schema
17
+
18
+ def initialize(tool, definition)
19
+ super()
20
+ @tool = tool
21
+ @name = definition[:name]
22
+ @description = definition[:description]
23
+ @parameters_schema = definition[:input_schema]
24
+ end
25
+
26
+ def execute(**arguments) = @tool.call(@name, arguments)
27
+ end
28
+ end
29
+ end
data/lib/acute/tool.rb ADDED
@@ -0,0 +1,192 @@
1
+ require_relative "../acute"
2
+ require_relative "tool/compact"
3
+
4
+ module Acute
5
+ # The tools an app's LLM calls, for any SDK. The app fixes the options at construction; the input schemas in
6
+ # schema/tools/ leave them out, so the model can't set them. What the model sends is a boundary: anything wrong
7
+ # with it comes back as a failure value. T5: no string the model reads as prose quotes its input; text
8
+ # that needs pointing at goes in unread, which the schema marks as data.
9
+ class Tool
10
+ # @api private: NAMES, SCHEMAS and UNREAD_LENGTH are read by MCP
11
+ NAMES = %w[parse duration recurrence extract verify describe now].freeze
12
+ SCHEMAS = Ractor.make_shareable(NAMES.to_h do |name|
13
+ [name, JSON.parse(File.read(File.expand_path("../../schema/tools/#{name}.json", __dir__)))]
14
+ end)
15
+ UNREAD = 5
16
+ OCCURRENCES = 3
17
+ MENTIONS = 50 # T34: a reply many times its text would crowd the model's context
18
+ UNREAD_LENGTH = 60
19
+ PARSE_ERRORS = {
20
+ unknown_word: "acute couldn't read the words in unread",
21
+ invalid_date: "the date or time in unread doesn't exist",
22
+ incomplete: "the phrase is incomplete",
23
+ conflict: "the words in unread don't fit with the rest of the phrase",
24
+ not_rule: "not a repeating time",
25
+ vague_rule: "the phrase says how often, not when: ask the user which days or times",
26
+ no_occurrence: "the rule ends before it starts",
27
+ dst_skipped_time: "the time in unread is skipped as the clocks go forward: ask the user for a time either side of it",
28
+ dst_repeated_time: "the time in unread happens twice as the clocks go back: ask the user which they meant",
29
+ business_day_limit: "more than #{Resolve::Period::LIMIT} business days in one call is too many to count",
30
+ too_long: "the text is longer than #{MAX_LENGTH} bytes",
31
+ invalid_encoding: "the text is not valid UTF-8"
32
+ }.freeze
33
+ OPTION_ERRORS = { value: "value must be an ISO 8601 time or date" }.freeze
34
+ POINTED = %i[invalid_date conflict dst_skipped_time dst_repeated_time].freeze
35
+ ISO_DURATION = /\AP(?!\z)(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?
36
+ (?:T(?=\d)(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?\z/x
37
+ ISO_PARTS = %i[years months weeks days hours minutes seconds].freeze
38
+ ISO_TIME = /\A\d{4}-\d\d-\d\d(?:[T ]|\z)/
39
+ private_constant :UNREAD, :OCCURRENCES, :MENTIONS, :PARSE_ERRORS, :OPTION_ERRORS, :POINTED, :ISO_DURATION, :ISO_PARTS, :ISO_TIME
40
+
41
+ attr_reader :parser, :prefix
42
+
43
+ def initialize(parser = nil, prefix: "", **options)
44
+ raise ArgumentError, "expected a parser Acute.with made, got #{parser.class}" unless parser.nil? || Parser === parser
45
+ # Tool names may hold letters, digits, _ and -, and no more than 64 of them
46
+ unless String === prefix && prefix.valid_encoding? && prefix.match?(/\A[A-Za-z0-9_-]{0,56}\z/)
47
+ raise OptionError.new("prefix: must be up to 56 letters, digits, _ or -", option: :prefix, value: prefix)
48
+ end
49
+ @parser = (parser || Acute).with(**options)
50
+ @prefix = -prefix
51
+ freeze
52
+ end
53
+
54
+ def definitions(format = :anthropic)
55
+ NAMES.map do |name|
56
+ schema = SCHEMAS[name]
57
+ full = "#{@prefix}#{name}"
58
+ description = schema["description"].gsub(/\{(\w+)\}/) { "#{@prefix}#{$1}" }
59
+ input = schema.except("$schema", "$id", "title", "description")
60
+ case format
61
+ when :anthropic then { name: full, description: description, input_schema: input }
62
+ when :openai then { type: "function", function: { name: full, description: description, parameters: input } }
63
+ else raise ArgumentError, "format must be :anthropic or :openai, got #{format.inspect}"
64
+ end
65
+ end
66
+ end
67
+
68
+ # arguments as an SDK gives them: a Hash with string or symbol keys, or OpenAI's JSON string
69
+ def call(name, arguments) = Compact.(answer(name, arguments))
70
+
71
+ def inspect = "#<Acute::Tool #{"prefix: #{@prefix.inspect} " unless @prefix.empty?}#{@parser.inspect}>"
72
+
73
+ private
74
+
75
+ def answer(name, arguments)
76
+ tool = name.delete_prefix(@prefix) if String === name && name.valid_encoding? && name.start_with?(@prefix)
77
+ return failure(:unknown_tool, "there is no tool by the name in unread", [name]) unless NAMES.include?(tool)
78
+ args = arguments(arguments) or return failure(:invalid_argument, "the arguments must be a JSON object")
79
+ wrong = check(SCHEMAS[tool], args) and return wrong
80
+ run(tool, args)
81
+ rescue ParseError => e
82
+ parse_failure(e, args["text"])
83
+ rescue OptionError => e
84
+ failure(:invalid_argument, OPTION_ERRORS.fetch(e.option, "#{e.option} isn't one acute can use"), [e.value])
85
+ end
86
+
87
+ def arguments(given)
88
+ given = JSON.parse(given) if String === given
89
+ # A null is an argument left out, as some SDKs send every optional one
90
+ given.to_h { |k, v| [k.to_s, v] }.compact if Hash === given
91
+ rescue JSON::ParserError, EncodingError
92
+ nil
93
+ end
94
+
95
+ def check(schema, args)
96
+ props = schema["properties"]
97
+ unknown = args.keys - props.keys
98
+ return failure(:unknown_argument, "there are no arguments by the names in unread", unknown) if unknown.any?
99
+ missing = schema["required"].to_a - args.keys
100
+ return failure(:missing_argument, "#{missing.join(" and ")} #{missing.size == 1 ? "is" : "are"} required") if missing.any?
101
+ args.each do |key, value|
102
+ prop = props[key]
103
+ error =
104
+ if prop["enum"] then "#{key} must be one of #{prop["enum"].join(", ")}" unless prop["enum"].include?(value)
105
+ elsif !(String === value) then "#{key} must be a string"
106
+ elsif !value.valid_encoding? then "#{key} is not valid UTF-8"
107
+ elsif value.length > prop["maxLength"] then "#{key} is longer than #{prop["maxLength"]} characters"
108
+ end
109
+ return failure(:invalid_argument, error) if error
110
+ end
111
+ nil
112
+ end
113
+
114
+ def run(tool, args)
115
+ text = args["text"]
116
+ context = args.key?("context") ? { context: args["context"].to_sym } : {}
117
+ case tool
118
+ when "parse" then @parser.parse!(text, **context).as_json
119
+ when "duration" then @parser.duration!(text).as_json
120
+ when "recurrence" then recurrence(text)
121
+ when "extract" then extract(text, context)
122
+ when "verify" then verify(text, args["value"], context)
123
+ when "describe" then describe(args["value"], args.fetch("style", "relative").to_sym)
124
+ else now
125
+ end
126
+ end
127
+
128
+ def verify(text, value, context)
129
+ Acute.send(:verifier, text, value, **@parser.options, **context).tool_verdict.as_json.merge("unread" => [])
130
+ rescue ParseError => e
131
+ failed = parse_failure(e, text)
132
+ message = "The text can't be read: #{failed["error"]}. Ask the user to rephrase."
133
+ Verdict.new(text, :unparsed, message, nil).as_json.merge("unread" => failed["unread"])
134
+ end
135
+
136
+ # The rule's first occurrences, DTSTART's included, so the model can check what it set up. each rather than
137
+ # next: next counts from now and leaves out a DTSTART equal to it. each ends when its steps run out, so a
138
+ # rule whose occurrences are that far apart lists fewer.
139
+ def recurrence(text)
140
+ rule = @parser.recurrence!(text)
141
+ rule.as_json.merge("next" => rule.each.first(OCCURRENCES).map(&:iso8601))
142
+ end
143
+
144
+ # more: how many mentions past the first MENTIONS were left out
145
+ def extract(text, context)
146
+ json = @parser.extract(text, **context).as_json
147
+ more = json["mentions"].size - MENTIONS
148
+ return json unless more.positive?
149
+ json["mentions"] = json["mentions"].first(MENTIONS)
150
+ json.merge("more" => more)
151
+ end
152
+
153
+ def describe(value, style)
154
+ target = duration(value) || (@parser.parse(value, strict: true) if value.match?(ISO_TIME))
155
+ return failure(:invalid_argument, "value must be an ISO 8601 time, date or duration", [value]) unless target
156
+ { "kind" => "description", "description" => @parser.describe(target, style: style) }
157
+ end
158
+
159
+ def duration(value)
160
+ m = ISO_DURATION.match(value) or return
161
+ Duration.new(**ISO_PARTS.zip(m.captures).to_h { |part, n| [part, n && (n.include?(".") ? n.to_r : n.to_i)] })
162
+ end
163
+
164
+ def now
165
+ ctx = Context.new(@parser.options[:now], @parser.options[:zone], :future)
166
+ zone = ctx.zone
167
+ at = ctx.now
168
+ change = zone.next_change(at)
169
+ local = Time.at(at, in: zone.offset_at(at))
170
+ { "kind" => "now", "now" => local.strftime("%FT%T%:z"), "weekday" => local.strftime("%A"), "zone" => zone.name,
171
+ "offset" => local.strftime("%:z"), "dst" => !change.nil? && zone.offset_at(at) > zone.offset_at(change),
172
+ "next_change" => change && Time.at(change, in: zone.offset_at(change)).strftime("%FT%T%:z") }
173
+ end
174
+
175
+ def parse_failure(e, text)
176
+ unread = e.code == :unknown_word ? e.unknown : POINTED.include?(e.code) ? [text[e.position]] : []
177
+ found = failure(e.code, Explain.reason(e, text) || PARSE_ERRORS.fetch(e.code), unread)
178
+ # a position over the whole phrase tells the model nothing the error doesn't
179
+ found["position"] = [e.position.begin, e.position.end] unless whole?(e.position, text)
180
+ # only unknown_word's hint is for the model; the others name library options the app fixes
181
+ found["hint"] = e.hint if e.hint && e.code == :unknown_word
182
+ found
183
+ end
184
+
185
+ def whole?(span, text) = text[0...span.begin].strip.empty? && text[span.end..].to_s.strip.empty?
186
+
187
+ def failure(code, error, unread = [])
188
+ words = unread.first(UNREAD).map { _1.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, UNREAD_LENGTH] }
189
+ { "kind" => "failure", "code" => code.to_s, "error" => error, "unread" => words }
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,216 @@
1
+ module Acute
2
+ def self.verify(text, value, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
3
+ fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
4
+ verifier(text, value, now:, zone:, context:, locale:, week_start:, hemisphere:, fiscal_year_start:, strict:, dst:,
5
+ holidays:, business_days:, year_bias:).verdict
6
+ end
7
+
8
+ # Acute::Tool takes the Verify, by send, to word the verdict without the input (T5)
9
+ def self.verifier(text, value, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
10
+ fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
11
+ ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
12
+ business_days, year_bias)
13
+ opts = { now: Time.at(ctx.now), zone: zone, context: context, locale: locale, week_start: week_start,
14
+ hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, strict: strict, dst: dst,
15
+ holidays: holidays, business_days: business_days, year_bias: year_bias }
16
+ Verify.new(text, value, ctx, opts)
17
+ end
18
+ private_class_method :verifier
19
+
20
+ # Whether a time someone else worked out is what the text says. The message is written to go straight back
21
+ # to a model (D45).
22
+ class Verdict
23
+ STATUSES = %i[match inside alternative mismatch unparsed].freeze
24
+ OK = %i[match inside alternative].freeze
25
+ private_constant :STATUSES, :OK
26
+
27
+ attr_reader :text, :status, :message, :expected
28
+
29
+ # @api private
30
+ def initialize(text, status, message, expected)
31
+ @text = text
32
+ @status = status
33
+ @message = Acute.visible(message)
34
+ @expected = expected
35
+ freeze
36
+ end
37
+
38
+ def ok? = OK.include?(@status)
39
+
40
+ # JSON can't hold invalid UTF-8, so such a text is given scrubbed
41
+ def to_h = { kind: "verdict", text: @text.dup.force_encoding(Encoding::UTF_8).scrub, status: @status.to_s, ok: ok?, message: @message, expected: @expected&.to_h }
42
+
43
+ def to_json(*args) = to_h.to_json(*args)
44
+
45
+ def as_json(*) = JSON.parse(to_json)
46
+
47
+ def inspect = "#<Acute::Verdict #{@status} #{@message.inspect}>"
48
+ end
49
+
50
+ # @api private
51
+ class Verify
52
+ DAY = 86_400
53
+ ORDINALS = %w[first second third fourth fifth sixth seventh eighth ninth tenth].freeze
54
+ DATE = /\A\d{4}-\d\d-\d\d\z/
55
+
56
+ def initialize(text, value, ctx, opts)
57
+ @text = text
58
+ @ctx = ctx
59
+ @zone = ctx.zone
60
+ @opts = opts
61
+ @value = target(value)
62
+ @given = given(value)
63
+ end
64
+
65
+ def verdict
66
+ judge(@given, @text.inspect)
67
+ rescue ParseError => e
68
+ Verdict.new(@text, :unparsed, "#{shorten(@text)} can't be read as a time: #{unknown(e)}. Ask the user to say it " \
69
+ "another way.", nil)
70
+ end
71
+
72
+ # T5: the message names the text by its readings' labels and a mismatched value as acute normalised it, never
73
+ # as they came in; the model knows which value it sent. A text it can't read raises the ParseError, for the caller to word.
74
+ def tool_verdict = judge("the value", "the text")
75
+
76
+ private
77
+
78
+ def judge(value, text)
79
+ r = Acute.parse!(@text, **@opts)
80
+ also = others(r, " It can also mean %s; ask the user if that matters.")
81
+ status = relation(r)
82
+ i = r.alternatives.index { relation(_1) } unless status
83
+ status ||= i ? :alternative : :mismatch
84
+ message =
85
+ case status
86
+ when :match then "#{value} matches #{text}: #{label(r)}.#{also}"
87
+ when :inside then "#{value} is inside #{text}, which is #{label(r)}.#{also}"
88
+ when :alternative
89
+ "#{value} is the #{ORDINALS[i + 1] || "#{i + 2}th"} reading of #{text}; the first is #{label(r)}. Ask the user " \
90
+ "which they meant."
91
+ else "#{text} is #{label(r)}#{others(r, " (or %s)")}, not #{label(@value)}."
92
+ end
93
+ # the tool's subjects begin a sentence in lower case; the library's are a quote or a digit
94
+ Verdict.new(@text, status, message.sub(/\A\w/, &:upcase), r)
95
+ end
96
+
97
+ def unknown(error)
98
+ words = error.unknown
99
+ return error.message.length > 80 ? "#{error.message[0, 80]}…" : error.message if words.empty?
100
+ shown = words.first(5).map { shorten(_1, 20) }
101
+ "unknown #{words.size == 1 ? "word" : "words"} #{shown.join(", ")}#{" and #{words.size - 5} more" if words.size > 5}"
102
+ end
103
+
104
+ def others(r, form) = r.ambiguous? ? format(form, r.alternatives.map { label(_1) }.join(" or ")) : ""
105
+
106
+ # A date ("2026-10-23", a Date or a range of them) is checked by its days; anything else by its instants
107
+ def target(value)
108
+ @days = Date === value || (Range === value && (Date === value.begin || Date === value.end))
109
+ if String === value
110
+ # never the lenient reading: trailing words are not an ISO 8601 string
111
+ r = (Acute.parse(value, **@opts, strict: true) if readable?(value) && value.match?(/\A\d{4}-\d\d-\d\d/))
112
+ unless r && !r.ambiguous?
113
+ raise OptionError.new("value must be a Time, Date, Range or ISO 8601 string, got #{shorten(value)}",
114
+ option: :value, value: value)
115
+ end
116
+ @days = DATE.match?(value)
117
+ @parsed = value = r
118
+ end
119
+ Describe::Target.for(value, @ctx)
120
+ end
121
+
122
+ def given(value)
123
+ case value
124
+ when String then @days ? @parsed.date.iso8601 : moment_iso(@parsed)
125
+ when Date then value.iso8601
126
+ when Range then "#{given(value.begin) if value.begin}#{value.exclude_end? ? "..." : ".."}#{given(value.end) if value.end}"
127
+ else value.respond_to?(:to_time) ? value.to_time.getlocal(@zone.offset_at(value.to_time.to_i)).strftime("%FT%T%:z") : value.inspect
128
+ end
129
+ end
130
+
131
+ def readable?(string) = (string.encoding == Encoding::UTF_8 || string.encoding == Encoding::US_ASCII) && string.valid_encoding?
132
+
133
+ def shorten(string, limit = 60) = string.length > limit ? "#{string[0, limit].inspect[0..-2]}…\"" : string.inspect
134
+
135
+ def moment_iso(r)
136
+ return r.time.getlocal(@zone.offset_at(r.time.to_i)).strftime("%FT%T%:z") if r.instant?
137
+ "#{r.begin && moment_iso_at(r.begin)}..#{r.end && moment_iso_at(r.end)}"
138
+ end
139
+
140
+ def moment_iso_at(t) = t.getlocal(@zone.offset_at(t.to_i)).strftime("%FT%T%:z")
141
+
142
+ # :match when the reading is the value, :inside when the value lies within it
143
+ def relation(r)
144
+ v = @value
145
+ span = [r.begin&.to_i, r.end&.to_i]
146
+ if @days
147
+ return :match if cover(r) == [v.b, v.e]
148
+ elsif v.instant
149
+ return :match if r.instant? && r.time.to_i == v.point
150
+ return r.instant? || !within?(span, v.point, v.point + 1) ? nil : :inside
151
+ elsif !r.instant? && span == [v.b, v.e]
152
+ return :match
153
+ end
154
+ :inside if !r.instant? && within?(span, v.b, v.e)
155
+ end
156
+
157
+ def within?((b, e), vb, ve) = (b.nil? || (vb && vb >= b)) && (e.nil? || (ve && ve <= e))
158
+
159
+ # The whole days a reading falls on; one of a day or less ("tonight", "3pm") is its point's day
160
+ def cover(r)
161
+ b = r.begin&.to_i
162
+ e = r.end&.to_i
163
+ if b && e && e - b <= DAY
164
+ d = day_of(r.time.to_i)
165
+ return [day_start(d), day_start(d + 1)]
166
+ end
167
+ [b && day_start(day_of(b)), e && day_start(day_of(e - 1) + 1)]
168
+ end
169
+
170
+ # "Fri 23 Oct", "Fri 23 Oct, 3pm", "Sat 17 Oct – Sun 18 Oct", "from Sat 24 Oct": one form whatever the
171
+ # locale, as the reader is a model
172
+ def label(x)
173
+ if Result === x
174
+ return @days ? day(day_of(x.time.to_i)) : moment(x.time.to_i) if x.instant?
175
+ b, e = @days ? cover(x) : [x.begin&.to_i, x.end&.to_i]
176
+ else
177
+ return moment(x.point) if x.instant
178
+ b = x.b
179
+ e = x.e
180
+ end
181
+ return "from #{edge(b)}" unless e
182
+ return "before #{edge(e)}" unless b
183
+ return days(b, e) if aligned?(b) && aligned?(e)
184
+ return "#{moment(b)} – #{Describe.clock(secs_of(e))}" if day_of(b) == day_of(e)
185
+ "#{moment(b)} – #{moment(e)}"
186
+ end
187
+
188
+ def days(b, e)
189
+ first = day_of(b)
190
+ last = day_of(e - 1)
191
+ first == last ? day(first) : "#{day(first)} – #{day(last)}"
192
+ end
193
+
194
+ def edge(t) = aligned?(t) ? day(day_of(t)) : moment(t)
195
+
196
+ def moment(t) = "#{day(day_of(t))}, #{Describe.clock(secs_of(t))}"
197
+
198
+ def day(d)
199
+ x = Calendar.civil_from_days(d)
200
+ year = x / 10_000 == Calendar.civil_from_days(@ctx.today) / 10_000 ? "" : " #{x / 10_000}"
201
+ "#{Result::WEEKDAYS[Calendar.weekday(d)]} #{x % 100} #{Result::MONTHS[x / 100 % 100 - 1]}#{year}"
202
+ end
203
+
204
+ def local(t) = t + @zone.offset_at(t)
205
+
206
+ def day_of(t) = local(t).div(DAY)
207
+
208
+ def secs_of(t) = local(t) % DAY
209
+
210
+ def day_start(d) = @zone.local_to_utc(d * DAY, @ctx)
211
+
212
+ def aligned?(t) = day_start(day_of(t)) == t
213
+ end
214
+
215
+ private_constant :Verify
216
+ end
@@ -0,0 +1,3 @@
1
+ module Acute
2
+ VERSION = "1.0.0".freeze
3
+ end