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
data/lib/acute/mcp.rb
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "tool"
|
|
3
|
+
|
|
4
|
+
module Acute
|
|
5
|
+
# acute mcp: Acute::Tool as an MCP server, one JSON-RPC message per line of stdin and stdout. Dual-era: a request
|
|
6
|
+
# whose _meta names a modern version is served statelessly as that version; an initialize opens a legacy session.
|
|
7
|
+
# T5: no error message quotes the input. The tool's own failures put it in unread.
|
|
8
|
+
# @api private
|
|
9
|
+
class MCP
|
|
10
|
+
MODERN = %w[2026-07-28].freeze
|
|
11
|
+
LEGACY = %w[2025-11-25 2025-06-18].freeze
|
|
12
|
+
VERSION_KEY = "io.modelcontextprotocol/protocolVersion".freeze
|
|
13
|
+
LINE_LIMIT = 64 * 1024
|
|
14
|
+
PARSE_ERROR = -32_700
|
|
15
|
+
INVALID_REQUEST = -32_600
|
|
16
|
+
METHOD_NOT_FOUND = -32_601
|
|
17
|
+
INVALID_PARAMS = -32_602
|
|
18
|
+
INTERNAL_ERROR = -32_603
|
|
19
|
+
UNSUPPORTED_VERSION = -32_022
|
|
20
|
+
ZONED = %w[parse recurrence extract verify describe now].freeze
|
|
21
|
+
ZONE_LENGTH = 64
|
|
22
|
+
ZONE = {
|
|
23
|
+
"type" => "string", "maxLength" => ZONE_LENGTH,
|
|
24
|
+
"description" => "Only if the user names a zone not their own (\"3pm London time\"): IANA or UTC offset"
|
|
25
|
+
}.freeze
|
|
26
|
+
ZONE_ERROR = "zone must be an IANA time zone or a UTC offset such as +05:30".freeze
|
|
27
|
+
OUTPUTS = { "parse" => "result", "duration" => "duration", "recurrence" => "rule", "extract" => "extraction",
|
|
28
|
+
"verify" => "verdict", "describe" => "description", "now" => "now" }.freeze
|
|
29
|
+
ANNOTATIONS = %w[title description examples $comment].freeze
|
|
30
|
+
NAMED = %w[properties $defs].freeze
|
|
31
|
+
SCHEMAS = File.expand_path("../../schema", __dir__)
|
|
32
|
+
|
|
33
|
+
Refusal = Class.new(StandardError) do
|
|
34
|
+
attr_reader :code, :data
|
|
35
|
+
|
|
36
|
+
def initialize(code, message, data = nil)
|
|
37
|
+
super(message)
|
|
38
|
+
@code = code
|
|
39
|
+
@data = data
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A tool's own schema or a failure, in one self-contained document: the schemas it refers to are inlined, so a
|
|
44
|
+
# client resolves everything without fetching
|
|
45
|
+
def self.output_schema(name)
|
|
46
|
+
defs = {}
|
|
47
|
+
hoist = lambda do |file|
|
|
48
|
+
return if defs.key?(file)
|
|
49
|
+
defs[file] = nil
|
|
50
|
+
schema = JSON.parse(File.read(File.join(SCHEMAS, "#{file}.json")))
|
|
51
|
+
defs.merge!(schema.delete("$defs") || {})
|
|
52
|
+
defs[file] = localise(schema.except("$schema", "$id"), hoist)
|
|
53
|
+
end
|
|
54
|
+
own = OUTPUTS.fetch(name)
|
|
55
|
+
[own, "failure"].each(&hoist)
|
|
56
|
+
defs = bare(Tool::Compact.schema(defs, name), names: true)
|
|
57
|
+
root = [own, "failure"].map { { "$ref" => "#/$defs/#{_1}" } }
|
|
58
|
+
uses = JSON.generate([root, defs]).scan(%r{"#/\$defs/(\w+)"}).flatten.tally
|
|
59
|
+
schema = { "type" => "object", "oneOf" => inline(root, defs, uses) }
|
|
60
|
+
shared = defs.select { uses.fetch(_1, 0) > 1 }.transform_values { inline(_1, defs, uses) }
|
|
61
|
+
shared.empty? ? schema : schema.merge("$defs" => shared)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The model's host pays for every char of a listing, and the annotations are for people: validation needs only
|
|
65
|
+
# the structure. Under properties and $defs the keys are names, never annotations.
|
|
66
|
+
def self.bare(node, names: false)
|
|
67
|
+
case node
|
|
68
|
+
when Hash
|
|
69
|
+
node.each_with_object({}) do |(key, value), out|
|
|
70
|
+
out[key] = bare(value, names: !names && NAMED.include?(key)) if names || !ANNOTATIONS.include?(key)
|
|
71
|
+
end
|
|
72
|
+
when Array then node.map { bare(_1) }
|
|
73
|
+
else node
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A schema referred to once is written in place; only those referred to more than once stay in $defs
|
|
78
|
+
def self.inline(node, defs, uses)
|
|
79
|
+
case node
|
|
80
|
+
when Hash
|
|
81
|
+
ref = node["$ref"]&.delete_prefix("#/$defs/")
|
|
82
|
+
return inline(defs.fetch(ref), defs, uses).merge(node.except("$ref")) if ref && uses[ref] == 1
|
|
83
|
+
node.transform_values { inline(_1, defs, uses) }
|
|
84
|
+
when Array then node.map { inline(_1, defs, uses) }
|
|
85
|
+
else node
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.localise(node, hoist)
|
|
90
|
+
case node
|
|
91
|
+
when Hash
|
|
92
|
+
node.to_h do |key, value|
|
|
93
|
+
next [key, localise(value, hoist)] unless key == "$ref" && value.end_with?(".json")
|
|
94
|
+
file = value.delete_suffix(".json")
|
|
95
|
+
hoist.(file)
|
|
96
|
+
[key, "#/$defs/#{file}"]
|
|
97
|
+
end
|
|
98
|
+
when Array then node.map { localise(_1, hoist) }
|
|
99
|
+
else node
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(tool = Tool.new, log: nil)
|
|
104
|
+
@tool = tool
|
|
105
|
+
@log = log
|
|
106
|
+
@tools = tool.definitions.map { listing(_1) }
|
|
107
|
+
@names = @tools.to_h { [_1["name"], _1["name"].delete_prefix(tool.prefix)] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def serve(input, output)
|
|
111
|
+
while (line = read(input))
|
|
112
|
+
next if String === line && line.strip.empty?
|
|
113
|
+
reply = line == :oversize ? error(nil, INVALID_REQUEST, "Invalid request: the line is too long") : handle(line)
|
|
114
|
+
next unless reply
|
|
115
|
+
output.write("#{JSON.generate(reply)}\n")
|
|
116
|
+
output.flush
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def handle(line)
|
|
123
|
+
line = line.dup.force_encoding(Encoding::UTF_8)
|
|
124
|
+
return error(nil, PARSE_ERROR, "Parse error") unless line.valid_encoding?
|
|
125
|
+
respond(JSON.parse(line))
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
error(nil, PARSE_ERROR, "Parse error")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read(input)
|
|
131
|
+
line = input.gets("\n", LINE_LIMIT + 1) or return
|
|
132
|
+
return line if line.end_with?("\n") || line.bytesize <= LINE_LIMIT
|
|
133
|
+
while (rest = input.gets("\n", LINE_LIMIT))
|
|
134
|
+
break if rest.end_with?("\n")
|
|
135
|
+
end
|
|
136
|
+
:oversize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def respond(message)
|
|
140
|
+
return error(nil, INVALID_REQUEST, "Invalid request") unless Hash === message
|
|
141
|
+
# Notifications get no reply, whatever they are
|
|
142
|
+
return if !message.key?("id") && String === message["method"]
|
|
143
|
+
id = message["id"]
|
|
144
|
+
return error(nil, INVALID_REQUEST, "Invalid request") unless String === id || Integer === id
|
|
145
|
+
params = message.fetch("params", {})
|
|
146
|
+
unless message["jsonrpc"] == "2.0" && String === message["method"] && Hash === params
|
|
147
|
+
return error(id, INVALID_REQUEST, "Invalid request")
|
|
148
|
+
end
|
|
149
|
+
{ "jsonrpc" => "2.0", "id" => id, "result" => dispatch(message["method"], params) }
|
|
150
|
+
rescue Refusal => e
|
|
151
|
+
error(id, e.code, e.message, e.data)
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
@log&.puts "acute mcp: #{e.class} at #{e.backtrace&.first}"
|
|
154
|
+
error(id, INTERNAL_ERROR, "Internal error")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def dispatch(method, params)
|
|
158
|
+
meta = params["_meta"]
|
|
159
|
+
version = meta[VERSION_KEY] if Hash === meta && meta.key?(VERSION_KEY)
|
|
160
|
+
if version && !MODERN.include?(version)
|
|
161
|
+
raise Refusal.new(UNSUPPORTED_VERSION, "Unsupported protocol version", { "supported" => MODERN })
|
|
162
|
+
end
|
|
163
|
+
result =
|
|
164
|
+
case method
|
|
165
|
+
when "initialize" then opening(params["protocolVersion"])
|
|
166
|
+
when "server/discover" then discovery
|
|
167
|
+
when "ping" then {}
|
|
168
|
+
when "tools/list" then { "tools" => @tools }
|
|
169
|
+
when "tools/call" then call(params["name"], params.fetch("arguments", {}))
|
|
170
|
+
else raise Refusal.new(METHOD_NOT_FOUND, "Method not found")
|
|
171
|
+
end
|
|
172
|
+
version ? result.merge("resultType" => "complete") : result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def server_info = { "name" => "acute", "version" => VERSION }
|
|
176
|
+
|
|
177
|
+
# A legacy client asking for a version this server doesn't speak gets the latest legacy one: the modern
|
|
178
|
+
# versions have no initialize
|
|
179
|
+
def opening(asked)
|
|
180
|
+
{ "protocolVersion" => LEGACY.include?(asked) ? asked : LEGACY.first, "capabilities" => { "tools" => {} },
|
|
181
|
+
"serverInfo" => server_info }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def discovery
|
|
185
|
+
{ "supportedVersions" => MODERN, "capabilities" => { "tools" => {} },
|
|
186
|
+
"_meta" => { "io.modelcontextprotocol/serverInfo" => server_info } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def call(name, args)
|
|
190
|
+
# A Tool with a prefix: lists and answers to the prefixed names only, as Tool#call does
|
|
191
|
+
base = @names[name] or raise Refusal.new(INVALID_PARAMS, "Unknown tool")
|
|
192
|
+
raise Refusal.new(INVALID_PARAMS, "Invalid params: arguments must be an object") unless Hash === args
|
|
193
|
+
zone = args["zone"] if ZONED.include?(base)
|
|
194
|
+
value = zone.nil? ? @tool.call(name, args) : zoned(zone)&.call(name, args.except("zone")) || zone_failure(zone)
|
|
195
|
+
{ "content" => [{ "type" => "text", "text" => JSON.generate(value) }], "structuredContent" => value,
|
|
196
|
+
"isError" => value["kind"] == "failure" }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def zoned(zone)
|
|
200
|
+
return unless String === zone && zone.length <= ZONE_LENGTH
|
|
201
|
+
Tool.new(@tool.parser, prefix: @tool.prefix, zone: zone)
|
|
202
|
+
rescue OptionError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def zone_failure(zone)
|
|
207
|
+
unread = String === zone ? [zone[0, Tool::UNREAD_LENGTH]] : []
|
|
208
|
+
Tool::Compact.({ "kind" => "failure", "code" => "invalid_argument", "error" => ZONE_ERROR, "unread" => unread })
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def listing(definition)
|
|
212
|
+
name = definition[:name]
|
|
213
|
+
base = name.delete_prefix(@tool.prefix)
|
|
214
|
+
input = definition[:input_schema]
|
|
215
|
+
input = input.merge("properties" => input["properties"].merge("zone" => ZONE)) if ZONED.include?(base)
|
|
216
|
+
{ "name" => name, "description" => definition[:description], "inputSchema" => input,
|
|
217
|
+
"outputSchema" => self.class.output_schema(base) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def error(id, code, message, data = nil)
|
|
221
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message, "data" => data }.compact }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/acute/parser.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
def self.with(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
|
+
Parser.new(now: now, zone: zone, context: context, locale: locale, week_start: week_start,
|
|
5
|
+
hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, strict: strict, dst: dst,
|
|
6
|
+
holidays: holidays, business_days: business_days, year_bias: year_bias)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Every entry point with the same options. Frozen and Ractor-shareable; options given to a call override
|
|
10
|
+
# the parser's (D45). D47: a holidays: or business_days: object is kept as given, not frozen, so a parser
|
|
11
|
+
# holding one is shareable only if the object already is.
|
|
12
|
+
class Parser
|
|
13
|
+
attr_reader :options
|
|
14
|
+
|
|
15
|
+
def initialize(now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
|
|
16
|
+
fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
|
|
17
|
+
ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
|
|
18
|
+
business_days, year_bias)
|
|
19
|
+
# A now that carries a zone (a TimeWithZone) gives the parser that zone, as each call would infer it
|
|
20
|
+
zone ||= ctx.zone.name if Context.carried_zone(now)
|
|
21
|
+
# Copies, so making the parser shareable freezes nothing of the caller's
|
|
22
|
+
opts = { now: now && Time.at(ctx.now).utc, zone: zone && -ctx.zone.name, context: context,
|
|
23
|
+
locale: locale && -locale.to_s, week_start: week_start&.to_sym, hemisphere: hemisphere&.to_sym,
|
|
24
|
+
fiscal_year_start: fiscal_year_start && Integer(fiscal_year_start), strict: strict == false ? false : nil,
|
|
25
|
+
dst: dst&.to_sym, year_bias: year_bias && Integer(year_bias) }
|
|
26
|
+
# A region is copied; an object answering holiday? or business_day? is the caller's, so it is kept as is
|
|
27
|
+
holidays = Array(holidays).map(&:to_sym) if Symbol === holidays || String === holidays || Array === holidays
|
|
28
|
+
@options = opts.merge(holidays: holidays, business_days: business_days).compact
|
|
29
|
+
# extract takes no strict:, as it always skips unknown words
|
|
30
|
+
@extract_options = @options.except(:strict)
|
|
31
|
+
objects = [holidays, business_days].compact.reject { Array === _1 }
|
|
32
|
+
if objects.all? { Ractor.shareable?(_1) }
|
|
33
|
+
Ractor.make_shareable(self)
|
|
34
|
+
else
|
|
35
|
+
@options.each_value { Ractor.make_shareable(_1) unless objects.include?(_1) }
|
|
36
|
+
@options.freeze
|
|
37
|
+
@extract_options.freeze
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse(text, **ctx) = Acute.parse(text, **@options, **ctx)
|
|
43
|
+
|
|
44
|
+
def parse!(text, **ctx) = Acute.parse!(text, **@options, **ctx)
|
|
45
|
+
|
|
46
|
+
def read(text, **ctx) = Acute.read(text, **@options, **ctx)
|
|
47
|
+
|
|
48
|
+
def extract(text, **ctx) = Acute.extract(text, **@extract_options, **ctx)
|
|
49
|
+
|
|
50
|
+
def describe(value, **ctx) = Acute.describe(value, **@options, **ctx)
|
|
51
|
+
|
|
52
|
+
def verify(text, value, **ctx) = Acute.verify(text, value, **@options, **ctx)
|
|
53
|
+
|
|
54
|
+
def duration(text, **ctx) = Acute.duration(text, **@options, **ctx)
|
|
55
|
+
|
|
56
|
+
def duration!(text, **ctx) = Acute.duration!(text, **@options, **ctx)
|
|
57
|
+
|
|
58
|
+
def recurrence(text, **ctx) = Acute.recurrence(text, **@options, **ctx)
|
|
59
|
+
|
|
60
|
+
def recurrence!(text, **ctx) = Acute.recurrence!(text, **@options, **ctx)
|
|
61
|
+
|
|
62
|
+
def with(**more) = Parser.new(**@options, **more)
|
|
63
|
+
|
|
64
|
+
def inspect = "#<Acute::Parser #{@options.map { "#{_1}: #{_2.inspect}" }.join(", ")}>"
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/acute/read.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
ALSO_DURATION = Assumption.for(:also_duration)
|
|
3
|
+
private_constant :ALSO_DURATION
|
|
4
|
+
|
|
5
|
+
# Whatever the text is: a Rule, a Result, a Duration, or nil. A rule comes first, then a time, which wins over
|
|
6
|
+
# a duration (D45). The rule is tried only when a word that makes one is there.
|
|
7
|
+
def self.read(text, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
|
|
8
|
+
fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
|
|
9
|
+
ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
|
|
10
|
+
business_days, year_bias)
|
|
11
|
+
if String === text && (t = utf8(text)) && t.bytesize <= MAX_LENGTH && Array === (tokens = Lexer.lex(t)) && Grammar.rule?(tokens)
|
|
12
|
+
out = rule(t, Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst,
|
|
13
|
+
holidays, business_days, year_bias), tokens)
|
|
14
|
+
return out if Rule === out
|
|
15
|
+
end
|
|
16
|
+
length = duration(text)
|
|
17
|
+
ctx.note(ALSO_DURATION) if length
|
|
18
|
+
out = run(text, ctx)
|
|
19
|
+
Result === out ? out : length
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Times of day: which seconds-of-day a clock can mean, and which day a bare one falls on
|
|
4
|
+
module Clock
|
|
5
|
+
BARE_HOUR = Assumption.for(:bare_hour_window)
|
|
6
|
+
TWELVE = Assumption.for(:twelve_oclock)
|
|
7
|
+
MACHINE = Assumption.for(:machine_time)
|
|
8
|
+
MIDNIGHT_END = Assumption.for(:midnight_end_of_day)
|
|
9
|
+
NEXT = Assumption.for(:next_occurrence)
|
|
10
|
+
PREVIOUS = Assumption.for(:previous_occurrence)
|
|
11
|
+
EQUAL_NOW = Assumption.for(:equal_to_now)
|
|
12
|
+
|
|
13
|
+
NOON = [43_200].freeze
|
|
14
|
+
MIDNIGHT = [0].freeze
|
|
15
|
+
# Midnight attached to a day: its end (86400 = the next 00:00) first, then its start
|
|
16
|
+
DAY_MIDNIGHT = [86_400, 0].freeze
|
|
17
|
+
|
|
18
|
+
# Seconds of the day, most likely first
|
|
19
|
+
def self.candidates(c, ctx, attached)
|
|
20
|
+
ctx.frac = c.frac if c.frac
|
|
21
|
+
secs = c.min * 60 + c.sec
|
|
22
|
+
if c.named == :noon then NOON
|
|
23
|
+
elsif c.named == :midnight
|
|
24
|
+
return MIDNIGHT unless attached
|
|
25
|
+
ctx.note(MIDNIGHT_END) # D6
|
|
26
|
+
DAY_MIDNIGHT
|
|
27
|
+
elsif c.meridiem
|
|
28
|
+
[((c.hour % 12) + (c.meridiem == :pm ? 12 : 0)) * 3600 + secs]
|
|
29
|
+
elsif c.hour == 0 || c.hour >= 13 || c.h24
|
|
30
|
+
[c.hour * 3600 + secs]
|
|
31
|
+
elsif c.seconds || ctx.machine
|
|
32
|
+
ctx.note(MACHINE) # D29
|
|
33
|
+
[c.hour * 3600 + secs]
|
|
34
|
+
elsif c.hour == 12
|
|
35
|
+
ctx.note(TWELVE) # D30
|
|
36
|
+
[43_200 + secs]
|
|
37
|
+
else
|
|
38
|
+
ctx.note(BARE_HOUR) # D4: 6-11 am, 12 and 1-5 pm, then the other half
|
|
39
|
+
am = c.hour * 3600 + secs
|
|
40
|
+
c.hour >= 6 ? [am, am + 43_200] : [am + 43_200, am]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A clock's grain and length in seconds
|
|
45
|
+
SECOND = [:second, 1].freeze
|
|
46
|
+
MINUTE = [:minute, 60].freeze
|
|
47
|
+
HOUR = [:hour, 3600].freeze
|
|
48
|
+
|
|
49
|
+
def self.grain(c) = c.seconds ? (c.seconds == :hour ? HOUR : SECOND) : MINUTE
|
|
50
|
+
|
|
51
|
+
# D23: the next occurrence of each candidate (the previous one in past context); D36: now counts
|
|
52
|
+
def self.bare(c, ctx)
|
|
53
|
+
cands = candidates(c, ctx, false)
|
|
54
|
+
grain, len = grain(c)
|
|
55
|
+
now_secs = ctx.now_secs
|
|
56
|
+
out = []
|
|
57
|
+
i = 0
|
|
58
|
+
while i < cands.size
|
|
59
|
+
secs = cands[i]
|
|
60
|
+
day = ctx.today
|
|
61
|
+
if secs == now_secs
|
|
62
|
+
ctx.note(EQUAL_NOW) if i == 0
|
|
63
|
+
elsif ctx.past? ? secs > now_secs : secs < now_secs
|
|
64
|
+
day += ctx.past? ? -1 : 1
|
|
65
|
+
ctx.note(ctx.past? ? PREVIOUS : NEXT) if i == 0
|
|
66
|
+
end
|
|
67
|
+
b = day * 86_400 + secs
|
|
68
|
+
out << Span.new(b, b + len, grain, nil)
|
|
69
|
+
i += 1
|
|
70
|
+
end
|
|
71
|
+
out
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Calendar dates: "2026-10-23", "oct 23", "10/23", "23.10.2026", "2015"
|
|
4
|
+
module Dates
|
|
5
|
+
ORDER = Assumption.for(:numeric_date_order)
|
|
6
|
+
CENTURY = Assumption.for(:century_from_00)
|
|
7
|
+
DECADE_CENTURY = Assumption.for(:decade_or_century)
|
|
8
|
+
DAY = Period::DAY
|
|
9
|
+
LONGEST = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
10
|
+
|
|
11
|
+
def self.spans(d, ctx)
|
|
12
|
+
if d.sep
|
|
13
|
+
out = []
|
|
14
|
+
orders(d, ctx).each { |x| out.concat(spans(x, ctx)) }
|
|
15
|
+
return out
|
|
16
|
+
end
|
|
17
|
+
return Periodic.bare(d, ctx) unless d.year
|
|
18
|
+
y = d.short ? two_digit_year(d.year, ctx) : d.year
|
|
19
|
+
return [] if y < 1 # D18
|
|
20
|
+
m = d.month
|
|
21
|
+
if (day = d.day)
|
|
22
|
+
return [] unless m >= 1 && m <= 12 && day >= 1 && (day <= 28 || day <= Calendar.days_in_month(y, m))
|
|
23
|
+
b = Calendar.days_from_civil(y, m, day) * DAY
|
|
24
|
+
[Span.new(b, b + DAY, :day, nil)]
|
|
25
|
+
elsif m
|
|
26
|
+
return [] unless m >= 1 && m <= 12
|
|
27
|
+
b = Calendar.days_from_civil(y, m, 1) * DAY
|
|
28
|
+
[Span.new(b, b + (m == 2 ? Calendar.days_in_month(y, m) : Calendar::MONTH_DAYS[m]) * DAY, :month, nil)]
|
|
29
|
+
else
|
|
30
|
+
[Span.new(Calendar.year_start(y) * DAY, Calendar.year_start(y + 1) * DAY, :year, nil)]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# D12: "the 15th century" from 1400, then from 1401; "the 90s" in the two-digit window; "the 2000s" the
|
|
35
|
+
# decade, then the century
|
|
36
|
+
def self.years(node, ctx)
|
|
37
|
+
y = node.short ? two_digit_year(node.year, ctx) : node.year
|
|
38
|
+
b = Calendar.year_start(y) * DAY
|
|
39
|
+
if node.unit == :century
|
|
40
|
+
ctx.note(CENTURY)
|
|
41
|
+
return [Span.new(b, Calendar.year_start(y + 100) * DAY, :century, nil),
|
|
42
|
+
Span.new(Calendar.year_start(y + 1) * DAY, Calendar.year_start(y + 101) * DAY, :century, nil)]
|
|
43
|
+
end
|
|
44
|
+
decade = Span.new(b, Calendar.year_start(y + 10) * DAY, :decade, nil)
|
|
45
|
+
return [decade] unless y % 100 == 0 && !node.short
|
|
46
|
+
ctx.note(DECADE_CENTURY)
|
|
47
|
+
[decade, Span.new(b, Calendar.year_start(y + 100) * DAY, :century, nil)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# D10, D37: the locale's order first (dots are always day first), then the other order when it is also
|
|
51
|
+
# a date. The result is plain dates, month and day in place.
|
|
52
|
+
def self.orders(d, ctx)
|
|
53
|
+
out = []
|
|
54
|
+
[false, true].each { |swap| (x = order(d, ctx, swap)) && !out.include?(x) && out << x }
|
|
55
|
+
ctx.note(ORDER) if out.size > 1
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The date in the locale's order, or with swap in the other, or nil when that is no date
|
|
60
|
+
def self.order(d, ctx, swap)
|
|
61
|
+
y = d.year && d.short ? two_digit_year(d.year, ctx) : d.year
|
|
62
|
+
m, day = (d.sep == :dot || ctx.locale.day_first) == swap ? [d.month, d.day] : [d.day, d.month]
|
|
63
|
+
return unless m.between?(1, 12) && day.between?(1, y ? Calendar.days_in_month(y, m) : LONGEST[m])
|
|
64
|
+
G::Date.new(y, m, day, nil, false)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# D23: chronic's window, the 100 years from year_bias: years before now (50 by default)
|
|
68
|
+
def self.two_digit_year(yy, ctx)
|
|
69
|
+
first = Period.year(ctx.now_local) - ctx.year_bias
|
|
70
|
+
y = first - first % 100 + yy
|
|
71
|
+
y < first ? y + 100 : y
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Day words: today, tomorrow, yesterday
|
|
4
|
+
module Day
|
|
5
|
+
TOMORROW_EARLY = Assumption.for(:tomorrow_after_midnight)
|
|
6
|
+
NIGHT_ENDS = 4 * 3600
|
|
7
|
+
|
|
8
|
+
# The day's spans, most likely first
|
|
9
|
+
def self.whole(d, ctx)
|
|
10
|
+
today = ctx.today
|
|
11
|
+
# D40: before 04:00 the speaker's "tomorrow" is usually the coming day. Exactly 00:00 is excluded:
|
|
12
|
+
# it is what a Date or a time-less fixture gives as now, which means the day itself (D41)
|
|
13
|
+
if d.offset == 1 && ctx.now_secs > 0 && ctx.now_secs < NIGHT_ENDS
|
|
14
|
+
ctx.note(TOMORROW_EARLY)
|
|
15
|
+
[day(today), day(today + 1)]
|
|
16
|
+
else
|
|
17
|
+
[day(today + d.offset)]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.day(d) = Span.new(d * 86_400, d * 86_400 + 86_400, :day, nil)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# early / mid / late / start / end of a period (D2, D35)
|
|
4
|
+
module Fuzzy
|
|
5
|
+
DAY = Period::DAY
|
|
6
|
+
HOUR = 3600
|
|
7
|
+
|
|
8
|
+
# D42: a repeating one is picked by the fuzzy part, so "early morning" said at 10:30 is tomorrow's
|
|
9
|
+
def self.spans(node, ctx)
|
|
10
|
+
return Periodic.bare(node, ctx) if Periodic.cycle(node.node)
|
|
11
|
+
Resolve.spans(node.node, ctx).flat_map { |s| apply(node.pos, s, node.node, ctx) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.apply(pos, s, x, ctx)
|
|
15
|
+
part = Periodic.part_name(x)
|
|
16
|
+
return [part(pos, s, part)] if part
|
|
17
|
+
case s.grain
|
|
18
|
+
when :month then [days(pos, s, 10)]
|
|
19
|
+
when :year, :quarter, :season then [months(pos, s, ctx)]
|
|
20
|
+
when :week then [week(pos, s, ctx)]
|
|
21
|
+
when :day then [day(pos, s)]
|
|
22
|
+
when :decade
|
|
23
|
+
y = Period.year(s.b)
|
|
24
|
+
from, to = { early: [0, 4], mid: [4, 7], late: [7, 10], start: [0, 1], end: [9, 10] }.fetch(pos)
|
|
25
|
+
[Span.new(Period.civil(y + from, 1, 1), Period.civil(y + to, 1, 1), :year, nil)]
|
|
26
|
+
else [s]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# D2: thirds of a month (1-10, 11-20, 21-end); start and end are the first and last day
|
|
31
|
+
def self.days(pos, s, third)
|
|
32
|
+
case pos
|
|
33
|
+
when :start then Span.new(s.b, s.b + DAY, :day, nil)
|
|
34
|
+
when :end then Span.new(s.e - DAY, s.e, :day, nil)
|
|
35
|
+
when :early then Span.new(s.b, s.b + third * DAY, :day, nil)
|
|
36
|
+
when :mid then Span.new(s.b + third * DAY, s.b + 2 * third * DAY, :day, s.b + 14 * DAY)
|
|
37
|
+
else Span.new(s.b + 2 * third * DAY, s.e, :day, nil)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# D2: a year in thirds of four months; a quarter or season in months
|
|
42
|
+
def self.months(pos, s, _ctx)
|
|
43
|
+
return days(pos, s, 0) if pos == :start || pos == :end
|
|
44
|
+
n = s.grain == :year ? 4 : 1
|
|
45
|
+
k = { early: 0, mid: 1, late: 2 }.fetch(pos)
|
|
46
|
+
b = Period.add(s.b, k * n, :month)
|
|
47
|
+
Span.new(b, k == 2 ? s.e : Period.add(b, n, :month), :month, nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# D2: early Mon-Tue, mid Wed-Thu, late Fri-Sat; end of week is Friday; D38: start is the locale's
|
|
51
|
+
# first day
|
|
52
|
+
def self.week(pos, s, ctx)
|
|
53
|
+
mon = s.b + ((1 - ctx.week_start) % 7) * DAY
|
|
54
|
+
case pos
|
|
55
|
+
when :start then Span.new(s.b, s.b + DAY, :day, nil)
|
|
56
|
+
when :end then Span.new(mon + 4 * DAY, mon + 5 * DAY, :day, nil)
|
|
57
|
+
else
|
|
58
|
+
b = mon + { early: 0, mid: 2, late: 4 }.fetch(pos) * DAY
|
|
59
|
+
Span.new(b, b + 2 * DAY, :day, nil)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# D2: end of day is 23:59:59
|
|
64
|
+
def self.day(pos, s)
|
|
65
|
+
case pos
|
|
66
|
+
when :start then Span.new(s.b, s.b + 1, :second, nil)
|
|
67
|
+
when :end then Span.new(s.e - 1, s.e, :second, nil)
|
|
68
|
+
else
|
|
69
|
+
b = s.b + { early: 0, mid: 8, late: 16 }.fetch(pos) * HOUR
|
|
70
|
+
Span.new(b, b + 8 * HOUR, :hour, nil)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# D1: early morning 04-08 and late night 22-04; D35: otherwise halves; D40: mid is the middle third
|
|
75
|
+
def self.part(pos, s, name)
|
|
76
|
+
day = Period.day(s.b) * DAY
|
|
77
|
+
if pos == :early && name == :morning then Span.new(day + 4 * HOUR, day + 8 * HOUR, :hour, day + 6 * HOUR)
|
|
78
|
+
elsif pos == :late && (name == :night || name == :tonight)
|
|
79
|
+
Span.new(day + 22 * HOUR, day + 28 * HOUR, :hour, day + 23 * HOUR)
|
|
80
|
+
else
|
|
81
|
+
len = s.e - s.b
|
|
82
|
+
case pos
|
|
83
|
+
when :early, :start then Span.new(s.b, s.b + len / 2, :hour, nil)
|
|
84
|
+
when :mid then Span.new(s.b + len / 3, s.b + 2 * len / 3, :hour, nil)
|
|
85
|
+
else Span.new(s.b + len / 2, s.e, :hour, nil)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|