duckling 0.2.0 → 0.3.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 +4 -4
- data/.claude/settings.json +46 -0
- data/AGENTS.md +178 -0
- data/Brewfile +3 -0
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +1 -0
- data/{ext/duckling/Cargo.lock → Cargo.lock} +20 -0
- data/Cargo.toml +9 -0
- data/README.md +176 -4
- data/Rakefile +229 -3
- data/docs/2026-07-01-roadmap.md +106 -0
- data/ext/duckling/Cargo.toml +4 -2
- data/ext/duckling/src/lib.rs +411 -79
- data/ext/duckling/src/ruby_value.rs +104 -0
- data/lib/duckling/version.rb +1 -1
- data/lib/duckling.rb +265 -1
- metadata +70 -7
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
use magnus::r_hash::ForEach;
|
|
2
|
+
use magnus::value::ReprValue;
|
|
3
|
+
use magnus::{Error, RArray, RHash, RString, Ruby, Symbol, Value};
|
|
4
|
+
|
|
5
|
+
/// Recursively rewrites every `Hash` reachable from `value` (including
|
|
6
|
+
/// through `Array` elements, at any depth) so its keys are `Symbol`s instead
|
|
7
|
+
/// of `String`s, mutating in place rather than allocating replacement
|
|
8
|
+
/// Hashes/Arrays. Keys that aren't `String` (e.g. already a `Symbol`) pass
|
|
9
|
+
/// through unchanged; non-Hash, non-Array values are untouched.
|
|
10
|
+
///
|
|
11
|
+
/// This is deliberately an in-place rewrite rather than a recursive rebuild:
|
|
12
|
+
/// `serde_magnus::serialize`'s externally-tagged output already allocates one
|
|
13
|
+
/// Hash per enum layer (e.g. `{"Naive" => {...}}`), so a rebuild pass would
|
|
14
|
+
/// double that cost by discarding and reallocating the whole tree just to
|
|
15
|
+
/// change key types.
|
|
16
|
+
///
|
|
17
|
+
/// GC safety note: keys collected off the `Hash` are staged in a Ruby
|
|
18
|
+
/// `RArray`, never a Rust-native `Vec<Value>`. A `Value` held only in a
|
|
19
|
+
/// `Vec` (heap-allocated Rust memory) is invisible to MRI's conservative
|
|
20
|
+
/// stack-scanning GC once it's no longer reachable from any Ruby-visible
|
|
21
|
+
/// root — deleting an entry from its `Hash` and stashing the freed key/value
|
|
22
|
+
/// `Value`s in a `Vec` across further Magnus calls (which can trigger GC) is
|
|
23
|
+
/// a real use-after-free, not just a style concern. Keeping them in an
|
|
24
|
+
/// `RArray` instead means the GC treats them as reachable for as long as
|
|
25
|
+
/// the array itself is reachable (it lives in a local, stack-scanned
|
|
26
|
+
/// `RArray` variable for the duration of this function).
|
|
27
|
+
pub fn symbolize_keys_in_place(ruby: &Ruby, value: Value) -> Result<(), Error> {
|
|
28
|
+
if let Some(hash) = RHash::from_value(value) {
|
|
29
|
+
let keys = ruby.ary_new();
|
|
30
|
+
hash.foreach(|k: Value, _v: Value| {
|
|
31
|
+
keys.push(k)?;
|
|
32
|
+
Ok(ForEach::Continue)
|
|
33
|
+
})?;
|
|
34
|
+
|
|
35
|
+
for i in 0..keys.len() as isize {
|
|
36
|
+
let k: Value = keys.entry(i)?;
|
|
37
|
+
// Only a String key needs the hash structure itself touched
|
|
38
|
+
// (delete under the old key, reinsert under the new Symbol);
|
|
39
|
+
// serde_magnus already emits Symbol keys for struct fields,
|
|
40
|
+
// so most entries just need their value recursed into.
|
|
41
|
+
//
|
|
42
|
+
// Tested with `RString::from_value` (an Option-returning type
|
|
43
|
+
// check) rather than `String::try_convert` (a Result-returning
|
|
44
|
+
// conversion): the latter builds a full TypeError — exception
|
|
45
|
+
// object plus its formatted message String plus the class-name
|
|
46
|
+
// String — for every already-Symbol key it rejects, which is most
|
|
47
|
+
// of them. That is three throwaway allocations per key on the
|
|
48
|
+
// hot path, purely to answer a question `from_value` answers for
|
|
49
|
+
// free.
|
|
50
|
+
match RString::from_value(k) {
|
|
51
|
+
Some(s) => {
|
|
52
|
+
let v: Value = hash.delete(k)?;
|
|
53
|
+
symbolize_keys_in_place(ruby, v)?;
|
|
54
|
+
// Interned via String#to_sym on the key's own VALUE
|
|
55
|
+
// rather than round-tripping through Rust: `as_str` +
|
|
56
|
+
// `to_owned` + `ruby.to_symbol` costs a Rust String copy
|
|
57
|
+
// plus a fresh Ruby String (magnus 0.8.2's
|
|
58
|
+
// `&str.into_symbol_with` is `rb_to_symbol(str_new(..))`)
|
|
59
|
+
// per key, where `to_sym` interns the existing String
|
|
60
|
+
// directly. `s` stays GC-reachable across the recursion
|
|
61
|
+
// above through the `keys` array (see the note on this
|
|
62
|
+
// function), so the deferred use is safe.
|
|
63
|
+
let sym: Symbol = s.funcall("to_sym", ())?;
|
|
64
|
+
hash.aset(sym, v)?;
|
|
65
|
+
}
|
|
66
|
+
None => {
|
|
67
|
+
let v: Value = hash.aref(k)?;
|
|
68
|
+
symbolize_keys_in_place(ruby, v)?;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Ok(());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if let Some(arr) = RArray::from_value(value) {
|
|
76
|
+
for i in 0..arr.len() as isize {
|
|
77
|
+
let item: Value = arr.entry(i)?;
|
|
78
|
+
symbolize_keys_in_place(ruby, item)?;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Ok(())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Serializes `input` via `serde_magnus` and symbolizes every Hash key in
|
|
86
|
+
/// the result, preserving serde's externally-tagged representation verbatim
|
|
87
|
+
/// (e.g. `{Numeral: 42.0}`, `{Url: {value:, domain:}}`,
|
|
88
|
+
/// `{Temperature: {Value: {value:, unit:}}}`). The PascalCase tag key is
|
|
89
|
+
/// kept uniformly across every enum layer — the outer `DimensionValue` tag
|
|
90
|
+
/// and nested tags like `MeasurementValue`'s `Value`/`Interval` get the same
|
|
91
|
+
/// treatment, so consumers see one consistent tagged shape rather than a
|
|
92
|
+
/// mix of unwrapped and tagged layers.
|
|
93
|
+
///
|
|
94
|
+
/// GC safety: the serialized `Value` lives in a stack-scanned local for the
|
|
95
|
+
/// duration of this function — see `symbolize_keys_in_place`'s note for why
|
|
96
|
+
/// heap-held `Vec<Value>`s are the thing to avoid.
|
|
97
|
+
pub fn serialize_symbolized<T>(ruby: &Ruby, input: &T) -> Result<Value, Error>
|
|
98
|
+
where
|
|
99
|
+
T: serde::Serialize + ?Sized,
|
|
100
|
+
{
|
|
101
|
+
let serialized: Value = serde_magnus::serialize(ruby, input)?;
|
|
102
|
+
symbolize_keys_in_place(ruby, serialized)?;
|
|
103
|
+
Ok(serialized)
|
|
104
|
+
}
|
data/lib/duckling/version.rb
CHANGED
data/lib/duckling.rb
CHANGED
|
@@ -1,4 +1,268 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tzinfo"
|
|
4
|
+
|
|
3
5
|
require_relative "duckling/version"
|
|
4
|
-
|
|
6
|
+
|
|
7
|
+
# A precompiled gem carries one binary per Ruby ABI, each in its own
|
|
8
|
+
# lib/duckling/<major.minor>/ directory. Load the one for the running Ruby.
|
|
9
|
+
#
|
|
10
|
+
# rb-sys reads Ruby's internal object layout through the headers it compiles
|
|
11
|
+
# against, so a binary understands only the Ruby that built it. Load the wrong
|
|
12
|
+
# one and it misreads objects: magnus rejects a genuine Time as not a Time.
|
|
13
|
+
#
|
|
14
|
+
# A source-gem build, and a plain `rake compile` in a checkout, write one
|
|
15
|
+
# binary straight to lib/duckling/ instead. That build always matches the
|
|
16
|
+
# running Ruby, so it needs no version directory.
|
|
17
|
+
begin
|
|
18
|
+
require_relative "duckling/#{RUBY_VERSION[/\d+\.\d+/]}/duckling"
|
|
19
|
+
rescue LoadError => abi_load_error
|
|
20
|
+
begin
|
|
21
|
+
require_relative "duckling/duckling"
|
|
22
|
+
rescue LoadError
|
|
23
|
+
# The ABI directory is missing, and so is the plain path. Raise the first
|
|
24
|
+
# error, not the second.
|
|
25
|
+
#
|
|
26
|
+
# A binary that exists but refuses to load fails here too, and its error
|
|
27
|
+
# says why. An Alpine install does this: RubyGems matches an unversioned
|
|
28
|
+
# -linux gem against a musl runtime, so the binary is present and asks for
|
|
29
|
+
# glibc. Reporting a missing file there would hide the real reason.
|
|
30
|
+
raise abi_load_error
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module Duckling
|
|
35
|
+
# Raised when the serialized :time :value shape drifts from the typed walk in
|
|
36
|
+
# ext/duckling/src/lib.rs's patch_time_value/patch_time_point — a wrapper bug.
|
|
37
|
+
# Deliberately a RuntimeError subclass (not ArgumentError) so a caller
|
|
38
|
+
# rescuing ArgumentError around bad locale:/reference_zone: input can't
|
|
39
|
+
# swallow it, and a *named* one (not bare RuntimeError) so it's greppable and
|
|
40
|
+
# can't be satisfied by the unrelated native-panic RuntimeError. Mirrors the
|
|
41
|
+
# internal_error() class in lib.rs.
|
|
42
|
+
class ShapeError < RuntimeError; end
|
|
43
|
+
|
|
44
|
+
# Native.parse already releases the GVL around the native call, but a bare
|
|
45
|
+
# GVL release alone does not hand control back to an Async::Reactor —
|
|
46
|
+
# Ruby 3.4's Fiber::Scheduler#blocking_operation_wait auto-offload path
|
|
47
|
+
# requires a flag rb_thread_call_without_gvl never sets. Spawning a real
|
|
48
|
+
# background Thread lets the calling Fiber yield to the reactor via
|
|
49
|
+
# Thread#value's block/unblock scheduler hooks instead, which have been
|
|
50
|
+
# present since Ruby 3.0. See
|
|
51
|
+
# https://github.com/cpb/duckling/wiki/research-fiber-scheduler-mechanism-spike
|
|
52
|
+
# for the empirical result driving this.
|
|
53
|
+
#
|
|
54
|
+
# Only worth paying for when a Fiber scheduler is actually installed on the
|
|
55
|
+
# calling thread: a plain thread pool (Puma/Sidekiq-style, no reactor to
|
|
56
|
+
# yield to) already gets its concurrency from Native.parse's own GVL
|
|
57
|
+
# release, so the extra Thread.new there is a pure spawn+join tax. Calling
|
|
58
|
+
# Native.parse directly (no thread) is also the benchmark suite's baseline
|
|
59
|
+
# for measuring the dispatch overhead itself.
|
|
60
|
+
#
|
|
61
|
+
# report_on_exception is disabled from the very first line inside the
|
|
62
|
+
# spawned thread (not set on the Thread object afterward, which would race
|
|
63
|
+
# a fast-failing call) so a rescued error doesn't also print a
|
|
64
|
+
# thread-termination backtrace to stderr — Thread#value still re-raises it
|
|
65
|
+
# to the caller as ordinary control flow.
|
|
66
|
+
#
|
|
67
|
+
# reference_time: is coerced here, not in the native extension:
|
|
68
|
+
# Native.parse's Magnus binding only accepts a strict kind_of?(Time) (issue
|
|
69
|
+
# #45), which rejects ActiveSupport::TimeWithZone and stdlib DateTime even
|
|
70
|
+
# though both carry the same to_i/utc_offset a real Time does — #to_time
|
|
71
|
+
# normalizes any of those (and anything else that offers the same
|
|
72
|
+
# conversion) to a real Time before it crosses into Rust.
|
|
73
|
+
# reference_zone: never crosses into Native.parse — the wrapped Rust crate
|
|
74
|
+
# has no IANA-zone concept at all, only the single FixedOffset it derives
|
|
75
|
+
# from reference_time:. Per-date-correct offsets therefore have to come from
|
|
76
|
+
# a real tz database on the Ruby side, so reference_zone: is applied as a
|
|
77
|
+
# two-part step around the native call: validate the zone (and reference_time:'s
|
|
78
|
+
# agreement with it) before, then reinterpret Naive results after.
|
|
79
|
+
#
|
|
80
|
+
# A fixed offset and a zone that disagree at the reference instant have no
|
|
81
|
+
# principled resolution — silently preferring either would resolve results
|
|
82
|
+
# against an offset the caller never asked for — so that combination raises
|
|
83
|
+
# rather than guessing.
|
|
84
|
+
#
|
|
85
|
+
# reference_zone: only reinterprets result offsets after the fact; it does
|
|
86
|
+
# NOT anchor the parse. Given without reference_time:, relative expressions
|
|
87
|
+
# ("tomorrow") still anchor on the machine-local clock, not on "now" in that
|
|
88
|
+
# zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
|
|
89
|
+
# calendar day. Pass a reference_time: in the zone to anchor as well.
|
|
90
|
+
def self.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
|
|
91
|
+
reference_time = reference_time.to_time if reference_time && !reference_time.is_a?(Time) && reference_time.respond_to?(:to_time)
|
|
92
|
+
|
|
93
|
+
if reference_zone
|
|
94
|
+
zone = timezone_for(reference_zone)
|
|
95
|
+
verify_reference_time_offset!(reference_time, zone, reference_zone) if reference_time
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
kwargs = {locale: locale, dims: dims, with_latent: with_latent}
|
|
99
|
+
kwargs[:reference_time] = reference_time if reference_time
|
|
100
|
+
|
|
101
|
+
entities = if Fiber.scheduler
|
|
102
|
+
Thread.new do
|
|
103
|
+
Thread.current.report_on_exception = false
|
|
104
|
+
Native.parse(text, **kwargs)
|
|
105
|
+
end.value
|
|
106
|
+
else
|
|
107
|
+
Native.parse(text, **kwargs)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
zone ? reinterpret_entities!(entities, zone) : entities
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Reinterprets every TimePoint::Naive (wall-clock) leaf of each :time entity
|
|
114
|
+
# against `reference_zone`, using the real IANA offset for that leaf's own
|
|
115
|
+
# date rather than the single fixed offset reference_time: carries.
|
|
116
|
+
#
|
|
117
|
+
# TimePoint::Instant leaves are left strictly alone: the wrapped crate
|
|
118
|
+
# already collapsed their relative arithmetic against one FixedOffset before
|
|
119
|
+
# this gem ever saw the result, so there is no wall-clock left to reinterpret.
|
|
120
|
+
# That arithmetic's DST imprecision is known and out of scope (issue #83).
|
|
121
|
+
#
|
|
122
|
+
# Walks the externally-tagged shape ext/duckling/src/lib.rs's patch_time_value
|
|
123
|
+
# produces, and raises on any tag it doesn't recognize: a shape drift on the
|
|
124
|
+
# Rust side must fail loudly here rather than quietly returning results
|
|
125
|
+
# resolved against the wrong offset.
|
|
126
|
+
def self.apply_reference_zone(entities, reference_zone)
|
|
127
|
+
return entities unless reference_zone
|
|
128
|
+
|
|
129
|
+
reinterpret_entities!(entities, timezone_for(reference_zone))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Zone-object core of apply_reference_zone. parse calls this directly with
|
|
133
|
+
# the TZInfo::Timezone it already resolved for offset validation, so the
|
|
134
|
+
# zone is looked up once per call rather than once for validation and again
|
|
135
|
+
# for reinterpretation.
|
|
136
|
+
def self.reinterpret_entities!(entities, zone)
|
|
137
|
+
entities.each do |entity|
|
|
138
|
+
next unless entity[:dim] == :time
|
|
139
|
+
reinterpret_time_value!(entity[:value][:Time], zone)
|
|
140
|
+
end
|
|
141
|
+
entities
|
|
142
|
+
end
|
|
143
|
+
private_class_method :reinterpret_entities!
|
|
144
|
+
|
|
145
|
+
# Walks the Single/Interval + Naive/Instant tagged shape. A primary value
|
|
146
|
+
# (single[:value], or an interval's from/to) and every `values` recurrence
|
|
147
|
+
# entry are resolved identically — see local_time_in_zone.
|
|
148
|
+
def self.reinterpret_time_value!(value, zone)
|
|
149
|
+
if (single = value && value[:Single])
|
|
150
|
+
reinterpret_time_point!(single[:value], zone)
|
|
151
|
+
single[:values]&.each { |point| reinterpret_time_point!(point, zone) }
|
|
152
|
+
elsif (interval = value && value[:Interval])
|
|
153
|
+
reinterpret_interval_endpoints!(interval, zone)
|
|
154
|
+
interval[:values]&.each { |endpoints| reinterpret_interval_endpoints!(endpoints, zone) }
|
|
155
|
+
else
|
|
156
|
+
raise ShapeError, "unrecognized :time value shape, expected a :Single or :Interval tag: #{value.inspect}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
private_class_method :reinterpret_time_value!
|
|
160
|
+
|
|
161
|
+
# An Interval's from/to are Option<TimePoint> on the Rust side, and serde
|
|
162
|
+
# emits Option::None as a present key holding nil — hence the nil tolerance
|
|
163
|
+
# in reinterpret_time_point!, rather than a missing-key check here.
|
|
164
|
+
def self.reinterpret_interval_endpoints!(endpoints, zone)
|
|
165
|
+
reinterpret_time_point!(endpoints[:from], zone)
|
|
166
|
+
reinterpret_time_point!(endpoints[:to], zone)
|
|
167
|
+
end
|
|
168
|
+
private_class_method :reinterpret_interval_endpoints!
|
|
169
|
+
|
|
170
|
+
def self.reinterpret_time_point!(point, zone)
|
|
171
|
+
return if point.nil?
|
|
172
|
+
|
|
173
|
+
if (naive = point[:Naive])
|
|
174
|
+
naive[:value] = local_time_in_zone(zone, naive[:value])
|
|
175
|
+
elsif !point.key?(:Instant)
|
|
176
|
+
raise ShapeError, "unrecognized TimePoint shape, expected a :Naive or :Instant tag: #{point.inspect}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
private_class_method :reinterpret_time_point!
|
|
180
|
+
|
|
181
|
+
# The Rust side already resolved this Naive wall-clock against reference_time:'s
|
|
182
|
+
# fixed offset, so the Time's own calendar fields still read as that intended
|
|
183
|
+
# wall-clock — re-anchoring those same fields in `zone` is what picks up the
|
|
184
|
+
# per-date DST offset.
|
|
185
|
+
#
|
|
186
|
+
# A wall-clock that a spring-forward gap skipped, or that a fall-back overlap
|
|
187
|
+
# made ambiguous, has no single correct offset — but there's no benefit to
|
|
188
|
+
# raising over it either, whether the value is a primary one the caller
|
|
189
|
+
# literally named or a generated recurrence entry: both get the same
|
|
190
|
+
# deterministic resolution — a gap shifts the wall clock forward by the
|
|
191
|
+
# transition's delta (02:30 on a US spring-forward day becomes 03:30 EDT),
|
|
192
|
+
# an overlap takes the first (pre-transition) occurrence.
|
|
193
|
+
#
|
|
194
|
+
# The first occurrence is selected via the block form (periods_for_local
|
|
195
|
+
# yields periods in chronological order), NOT tzinfo's dst flag: dst=true
|
|
196
|
+
# only means "first" where the pre-transition period observes DST, and
|
|
197
|
+
# negative-DST zones invert that — Europe/Dublin models winter GMT as its
|
|
198
|
+
# dst?==true period, so dst=true there would pick the second occurrence, an
|
|
199
|
+
# hour off as an instant. (ActiveSupport::TimeZone#local has exactly that
|
|
200
|
+
# Dublin behavior, via period_for_local's dst=true default — a deliberate
|
|
201
|
+
# departure here, like the Lord Howe one on gap_delta.) The explicit nil dst
|
|
202
|
+
# argument keeps a global Timezone.default_dst setting from pre-filtering
|
|
203
|
+
# the periods before the block sees them.
|
|
204
|
+
#
|
|
205
|
+
# Preserving the gap's original wall clock and merely stamping the
|
|
206
|
+
# post-transition offset on it would produce a Time whose offset the zone
|
|
207
|
+
# does not observe at that instant — 02:30 -04:00 is 06:30Z, and at 06:30Z
|
|
208
|
+
# New York is still on EST — so it would read back as 01:30 EST, an hour
|
|
209
|
+
# before the occurrence it stands for and on the wrong side of the
|
|
210
|
+
# transition. Shifting forward keeps the instant and its rendered local
|
|
211
|
+
# time in agreement.
|
|
212
|
+
def self.local_time_in_zone(zone, time)
|
|
213
|
+
first_occurrence = lambda do |t|
|
|
214
|
+
zone.local_time(t.year, t.month, t.day, t.hour, t.min, t.sec, t.subsec, nil) do |periods|
|
|
215
|
+
periods.first
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
first_occurrence.call(time)
|
|
219
|
+
rescue TZInfo::PeriodNotFound
|
|
220
|
+
first_occurrence.call(time + gap_delta(zone, time))
|
|
221
|
+
end
|
|
222
|
+
private_class_method :local_time_in_zone
|
|
223
|
+
|
|
224
|
+
# How wide the spring-forward gap `time` fell into is — i.e. how far forward
|
|
225
|
+
# a skipped wall clock must move to land on a real one. DST transitions are
|
|
226
|
+
# months apart, so the single offset-increasing transition within a day of
|
|
227
|
+
# `time` is necessarily the one whose gap it landed in.
|
|
228
|
+
#
|
|
229
|
+
# The ±1-day scan window is centered on the skipped wall clock itself read
|
|
230
|
+
# as UTC — not on the UTC midnight of its date. The transition's UTC instant
|
|
231
|
+
# is the wall clock minus a zone offset, and offsets never reach a day, so
|
|
232
|
+
# this window always contains it; a midnight-anchored window does not. A
|
|
233
|
+
# gap late in the local day in a negative-offset zone (America/Nuuk springs
|
|
234
|
+
# forward at 23:00 local) has its transition instant past the *next* UTC
|
|
235
|
+
# midnight, outside a midnight-anchored window — `find` returned nil and
|
|
236
|
+
# this method crashed instead of resolving.
|
|
237
|
+
#
|
|
238
|
+
# Read from the transition rather than assumed to be 3600. ActiveSupport's
|
|
239
|
+
# TimeWithZone#get_period_and_ensure_valid_local_time instead hardcodes
|
|
240
|
+
# `@time += 1.hour` and retries: for Australia/Lord_Howe's 30-minute gap
|
|
241
|
+
# that lands half an hour past the gap's end (02:15 → 03:15 rather than
|
|
242
|
+
# 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
|
|
243
|
+
# — resolves identically either way.
|
|
244
|
+
def self.gap_delta(zone, time)
|
|
245
|
+
wall_clock = Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
|
|
246
|
+
transition = zone.transitions_up_to(wall_clock + 86400, wall_clock - 86400)
|
|
247
|
+
.find { |t| t.offset.observed_utc_offset > t.previous_offset.observed_utc_offset }
|
|
248
|
+
transition.offset.observed_utc_offset - transition.previous_offset.observed_utc_offset
|
|
249
|
+
end
|
|
250
|
+
private_class_method :gap_delta
|
|
251
|
+
|
|
252
|
+
def self.timezone_for(reference_zone)
|
|
253
|
+
TZInfo::Timezone.get(reference_zone)
|
|
254
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
255
|
+
raise ArgumentError, "invalid reference_zone: #{reference_zone.inspect}"
|
|
256
|
+
end
|
|
257
|
+
private_class_method :timezone_for
|
|
258
|
+
|
|
259
|
+
def self.verify_reference_time_offset!(reference_time, zone, reference_zone)
|
|
260
|
+
zone_offset = zone.period_for(reference_time).observed_utc_offset
|
|
261
|
+
return if reference_time.utc_offset == zone_offset
|
|
262
|
+
|
|
263
|
+
raise ArgumentError,
|
|
264
|
+
"reference_time's utc_offset (#{reference_time.utc_offset}) does not match " \
|
|
265
|
+
"reference_zone #{reference_zone.inspect}'s utc_offset (#{zone_offset}) at that instant"
|
|
266
|
+
end
|
|
267
|
+
private_class_method :verify_reference_time_offset!
|
|
268
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: duckling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Caleb Buxton
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|
|
@@ -16,28 +16,84 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.9.
|
|
19
|
+
version: 0.9.128
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.9.
|
|
26
|
+
version: 0.9.128
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: tzinfo
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: tzinfo-data
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.2024'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.2024'
|
|
27
55
|
- !ruby/object:Gem::Dependency
|
|
28
56
|
name: rake-compiler
|
|
29
57
|
requirement: !ruby/object:Gem::Requirement
|
|
30
58
|
requirements:
|
|
31
59
|
- - "~>"
|
|
32
60
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: 1.
|
|
61
|
+
version: 1.3.1
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 1.3.1
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: benchmark-ips
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: async
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.41'
|
|
34
90
|
type: :development
|
|
35
91
|
prerelease: false
|
|
36
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
93
|
requirements:
|
|
38
94
|
- - "~>"
|
|
39
95
|
- !ruby/object:Gem::Version
|
|
40
|
-
version:
|
|
96
|
+
version: '2.41'
|
|
41
97
|
description: Duckling NER without an HTTP service for Ruby
|
|
42
98
|
email:
|
|
43
99
|
- me@cpb.ca
|
|
@@ -46,15 +102,22 @@ extensions:
|
|
|
46
102
|
- ext/duckling/extconf.rb
|
|
47
103
|
extra_rdoc_files: []
|
|
48
104
|
files:
|
|
105
|
+
- ".claude/settings.json"
|
|
106
|
+
- AGENTS.md
|
|
107
|
+
- Brewfile
|
|
49
108
|
- CHANGELOG.md
|
|
109
|
+
- CLAUDE.md
|
|
50
110
|
- CODE_OF_CONDUCT.md
|
|
111
|
+
- Cargo.lock
|
|
112
|
+
- Cargo.toml
|
|
51
113
|
- LICENSE.txt
|
|
52
114
|
- README.md
|
|
53
115
|
- Rakefile
|
|
54
|
-
-
|
|
116
|
+
- docs/2026-07-01-roadmap.md
|
|
55
117
|
- ext/duckling/Cargo.toml
|
|
56
118
|
- ext/duckling/extconf.rb
|
|
57
119
|
- ext/duckling/src/lib.rs
|
|
120
|
+
- ext/duckling/src/ruby_value.rs
|
|
58
121
|
- lib/duckling.rb
|
|
59
122
|
- lib/duckling/version.rb
|
|
60
123
|
homepage: https://github.com/cpb/duckling
|