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,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
|
data/lib/acute/errors.rb
ADDED
|
@@ -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
|