duckling 0.4.7-aarch64-linux

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.
data/lib/duckling.rb ADDED
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tzinfo"
4
+
5
+ require_relative "duckling/version"
6
+ require_relative "duckling/tzinfo_capabilities"
7
+
8
+ # A precompiled gem carries one binary per Ruby ABI, each in its own
9
+ # lib/duckling/<major.minor>/ directory. Load the one for the running Ruby.
10
+ #
11
+ # rb-sys reads Ruby's internal object layout through the headers it compiles
12
+ # against, so a binary understands only the Ruby that built it. Load the wrong
13
+ # one and it misreads objects: magnus rejects a genuine Time as not a Time.
14
+ #
15
+ # A source-gem build, and a plain `rake compile` in a checkout, write one
16
+ # binary straight to lib/duckling/ instead. That build always matches the
17
+ # running Ruby, so it needs no version directory.
18
+ begin
19
+ require_relative "duckling/#{RUBY_VERSION[/\d+\.\d+/]}/duckling"
20
+ rescue LoadError => abi_load_error
21
+ begin
22
+ require_relative "duckling/duckling"
23
+ rescue LoadError
24
+ # The ABI directory is missing, and so is the plain path. Raise the first
25
+ # error.
26
+ #
27
+ # A binary that exists but refuses to load fails here too, and its error
28
+ # says why. An Alpine install does this: RubyGems matches an unversioned
29
+ # -linux gem against a musl runtime, so the binary is present and asks for
30
+ # glibc. Reporting a missing file there would hide the real reason.
31
+ raise abi_load_error
32
+ end
33
+ end
34
+
35
+ module Duckling
36
+ # Raised when the serialized :time :value shape drifts from the typed walk in
37
+ # ext/duckling/src/lib.rs's patch_time_value/patch_time_point — a wrapper bug.
38
+ # Deliberately a RuntimeError subclass (not ArgumentError) so a caller
39
+ # rescuing ArgumentError around bad locale:/reference_zone: input can't
40
+ # swallow it, and a *named* one (not bare RuntimeError) so it's greppable and
41
+ # can't be satisfied by the unrelated native-panic RuntimeError. Mirrors the
42
+ # internal_error() class in lib.rs.
43
+ class ShapeError < RuntimeError; end
44
+
45
+ # Raised when `reference_zone:` is given on a host with no tz database at
46
+ # all (no zoneinfo files, no tzinfo-data gem). Deliberately outside
47
+ # ArgumentError: it reports the deployment, so input-validation rescues do
48
+ # not swallow it. See docs/tz-database-axis.md.
49
+ class TZDataUnavailable < RuntimeError; end
50
+
51
+ # Native.parse already releases the GVL around the native call, but a bare
52
+ # GVL release alone does not hand control back to an Async::Reactor —
53
+ # Ruby 3.4's Fiber::Scheduler#blocking_operation_wait auto-offload path
54
+ # requires a flag rb_thread_call_without_gvl never sets. Spawning a real
55
+ # background Thread lets the calling Fiber yield to the reactor via
56
+ # Thread#value's block/unblock scheduler hooks instead, which have been
57
+ # present since Ruby 3.0. See
58
+ # https://github.com/cpb/duckling/wiki/research-fiber-scheduler-mechanism-spike
59
+ # for the empirical result driving this.
60
+ #
61
+ # Only worth paying for when a Fiber scheduler is actually installed on the
62
+ # calling thread: a plain thread pool (Puma/Sidekiq-style, no reactor to
63
+ # yield to) already gets its concurrency from Native.parse's own GVL
64
+ # release, so the extra Thread.new there is a pure spawn+join tax. Calling
65
+ # Native.parse directly (no thread) is also the benchmark suite's baseline
66
+ # for measuring the dispatch overhead itself.
67
+ #
68
+ # report_on_exception is disabled from the very first line inside the
69
+ # spawned thread (not set on the Thread object afterward, which would race
70
+ # a fast-failing call) so a rescued error doesn't also print a
71
+ # thread-termination backtrace to stderr — Thread#value still re-raises it
72
+ # to the caller as ordinary control flow.
73
+ #
74
+ # reference_time: is coerced here because the native extension cannot:
75
+ # Native.parse's Magnus binding only accepts a strict kind_of?(Time) (issue
76
+ # #45), which rejects ActiveSupport::TimeWithZone and stdlib DateTime even
77
+ # though both carry the same to_i/utc_offset a real Time does — #to_time
78
+ # normalizes any of those (and anything else that offers the same
79
+ # conversion) to a real Time before it crosses into Rust.
80
+ # reference_zone: never crosses into Native.parse — the wrapped Rust crate
81
+ # has no IANA-zone concept at all, only the single FixedOffset it derives
82
+ # from reference_time:. Per-date-correct offsets therefore have to come from
83
+ # a real tz database on the Ruby side, so reference_zone: is applied as a
84
+ # two-part step around the native call: validate the zone (and reference_time:'s
85
+ # agreement with it) before, then reinterpret Naive results after.
86
+ #
87
+ # A fixed offset and a zone that disagree at the reference instant have no
88
+ # principled resolution — silently preferring either would resolve results
89
+ # against an offset the caller never asked for — so that combination raises.
90
+ #
91
+ # reference_zone: only reinterprets result offsets after the fact; it does
92
+ # NOT anchor the parse. Given without reference_time:, relative expressions
93
+ # ("tomorrow") still anchor on the machine-local clock. They do not anchor
94
+ # on "now" in that zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
95
+ # calendar day. Pass a reference_time: in the zone to anchor as well.
96
+ def self.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
97
+ reference_time = reference_time.to_time if reference_time && !reference_time.is_a?(Time) && reference_time.respond_to?(:to_time)
98
+
99
+ if reference_zone
100
+ zone = timezone_for(reference_zone)
101
+ verify_reference_time_offset!(reference_time, zone, reference_zone) if reference_time
102
+ end
103
+
104
+ kwargs = {locale: locale, dims: dims, with_latent: with_latent}
105
+ kwargs[:reference_time] = reference_time if reference_time
106
+
107
+ entities = if Fiber.scheduler
108
+ Thread.new do
109
+ Thread.current.report_on_exception = false
110
+ Native.parse(text, **kwargs)
111
+ end.value
112
+ else
113
+ Native.parse(text, **kwargs)
114
+ end
115
+
116
+ zone ? reinterpret_entities!(entities, zone) : entities
117
+ end
118
+
119
+ # Reinterprets every TimePoint::Naive (wall-clock) leaf of each :time entity
120
+ # against `reference_zone`, using the real IANA offset for that leaf's own
121
+ # date. reference_time: carries a single fixed offset, which cannot be right
122
+ # for every leaf.
123
+ #
124
+ # TimePoint::Instant leaves are left strictly alone: the wrapped crate
125
+ # already collapsed their relative arithmetic against one FixedOffset before
126
+ # this gem ever saw the result, so there is no wall-clock left to reinterpret.
127
+ # That arithmetic's DST imprecision is known and out of scope (issue #83).
128
+ #
129
+ # Walks the externally-tagged shape ext/duckling/src/lib.rs's patch_time_value
130
+ # produces, and raises on any tag it doesn't recognize: a shape drift on the
131
+ # Rust side must fail loudly here. The outcome to prevent is quiet results
132
+ # resolved against the wrong offset.
133
+ def self.apply_reference_zone(entities, reference_zone)
134
+ return entities unless reference_zone
135
+
136
+ reinterpret_entities!(entities, timezone_for(reference_zone))
137
+ end
138
+
139
+ # Zone-object core of apply_reference_zone. parse calls this directly with
140
+ # the TZInfo::Timezone it already resolved for offset validation, so the
141
+ # zone is looked up once per call. Validation and reinterpretation share the
142
+ # lookup.
143
+ def self.reinterpret_entities!(entities, zone)
144
+ entities.each do |entity|
145
+ next unless entity[:dim] == :time
146
+ reinterpret_time_value!(entity[:value][:Time], zone)
147
+ end
148
+ entities
149
+ end
150
+ private_class_method :reinterpret_entities!
151
+
152
+ # Walks the Single/Interval + Naive/Instant tagged shape. A primary value
153
+ # (single[:value], or an interval's from/to) and every `values` recurrence
154
+ # entry are resolved identically — see local_time_in_zone.
155
+ def self.reinterpret_time_value!(value, zone)
156
+ if (single = value && value[:Single])
157
+ reinterpret_time_point!(single[:value], zone)
158
+ single[:values]&.each { |point| reinterpret_time_point!(point, zone) }
159
+ elsif (interval = value && value[:Interval])
160
+ reinterpret_interval_endpoints!(interval, zone)
161
+ interval[:values]&.each { |endpoints| reinterpret_interval_endpoints!(endpoints, zone) }
162
+ else
163
+ raise ShapeError, "unrecognized :time value shape, expected a :Single or :Interval tag: #{value.inspect}"
164
+ end
165
+ end
166
+ private_class_method :reinterpret_time_value!
167
+
168
+ # An Interval's from/to are Option<TimePoint> on the Rust side, and serde
169
+ # emits Option::None as a present key holding nil — hence the nil tolerance
170
+ # in reinterpret_time_point!. A missing-key check here would reject that shape.
171
+ def self.reinterpret_interval_endpoints!(endpoints, zone)
172
+ reinterpret_time_point!(endpoints[:from], zone)
173
+ reinterpret_time_point!(endpoints[:to], zone)
174
+ end
175
+ private_class_method :reinterpret_interval_endpoints!
176
+
177
+ def self.reinterpret_time_point!(point, zone)
178
+ return if point.nil?
179
+
180
+ if (naive = point[:Naive])
181
+ naive[:value] = local_time_in_zone(zone, naive[:value])
182
+ elsif !point.key?(:Instant)
183
+ raise ShapeError, "unrecognized TimePoint shape, expected a :Naive or :Instant tag: #{point.inspect}"
184
+ end
185
+ end
186
+ private_class_method :reinterpret_time_point!
187
+
188
+ # The Rust side already resolved this Naive wall-clock against reference_time:'s
189
+ # fixed offset, so the Time's own calendar fields still read as that intended
190
+ # wall-clock — re-anchoring those same fields in `zone` is what picks up the
191
+ # per-date DST offset.
192
+ #
193
+ # A wall-clock that a spring-forward gap skipped, or that a fall-back overlap
194
+ # made ambiguous, has no single correct offset — but there's no benefit to
195
+ # raising over it either, whether the value is a primary one the caller
196
+ # literally named or a generated recurrence entry: both get the same
197
+ # deterministic resolution — a gap shifts the wall clock forward by the
198
+ # transition's delta (02:30 on a US spring-forward day becomes 03:30 EDT),
199
+ # an overlap takes the first (pre-transition) occurrence.
200
+ #
201
+ # The first occurrence is selected via the block form (periods_for_local
202
+ # yields periods in chronological order), NOT tzinfo's dst flag: dst=true
203
+ # only means "first" where the pre-transition period observes DST, and
204
+ # negative-DST zones invert that — Europe/Dublin models winter GMT as its
205
+ # dst?==true period, so dst=true there would pick the second occurrence, an
206
+ # hour off as an instant. (ActiveSupport::TimeZone#local has exactly that
207
+ # Dublin behavior, via period_for_local's dst=true default — a deliberate
208
+ # departure here, like the Lord Howe one on gap_delta.) The explicit nil dst
209
+ # argument keeps a global Timezone.default_dst setting from pre-filtering
210
+ # the periods before the block sees them.
211
+ #
212
+ # Preserving the gap's original wall clock and merely stamping the
213
+ # post-transition offset on it would produce a Time whose offset the zone
214
+ # does not observe at that instant — 02:30 -04:00 is 06:30Z, and at 06:30Z
215
+ # New York is still on EST — so it would read back as 01:30 EST, an hour
216
+ # before the occurrence it stands for and on the wrong side of the
217
+ # transition. Shifting forward keeps the instant and its rendered local
218
+ # time in agreement.
219
+ def self.local_time_in_zone(zone, time)
220
+ first_occurrence = lambda do |t|
221
+ zone.local_time(t.year, t.month, t.day, t.hour, t.min, t.sec, t.subsec, nil) do |periods|
222
+ periods.first
223
+ end
224
+ end
225
+ first_occurrence.call(time)
226
+ rescue TZInfo::PeriodNotFound
227
+ first_occurrence.call(time + gap_delta(zone, time))
228
+ end
229
+ private_class_method :local_time_in_zone
230
+
231
+ # How wide the spring-forward gap `time` fell into is — i.e. how far forward
232
+ # a skipped wall clock must move to land on a real one. DST transitions are
233
+ # months apart, so the single offset-increasing transition within a day of
234
+ # `time` is necessarily the one whose gap it landed in.
235
+ #
236
+ # The ±1-day scan window is centered on the skipped wall clock itself read
237
+ # as UTC. The UTC midnight of its date would be the wrong anchor. The transition's UTC instant
238
+ # is the wall clock minus a zone offset, and offsets never reach a day, so
239
+ # this window always contains it; a midnight-anchored window does not. A
240
+ # gap late in the local day in a negative-offset zone (America/Nuuk springs
241
+ # forward at 23:00 local) has its transition instant past the *next* UTC
242
+ # midnight, outside a midnight-anchored window — `find` returned nil and
243
+ # this method crashed.
244
+ #
245
+ # Read from the transition. Do not assume 3600. ActiveSupport's
246
+ # TimeWithZone#get_period_and_ensure_valid_local_time instead hardcodes
247
+ # `@time += 1.hour` and retries: for Australia/Lord_Howe's 30-minute gap
248
+ # that lands half an hour past the gap's end (02:15 → 03:15; the correct
249
+ # answer is 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
250
+ # — resolves identically either way.
251
+ def self.gap_delta(zone, time)
252
+ wall_clock = Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
253
+ transition = zone.transitions_up_to(wall_clock + 86400, wall_clock - 86400)
254
+ .find { |t| t.offset.observed_utc_offset > t.previous_offset.observed_utc_offset }
255
+ transition.offset.observed_utc_offset - transition.previous_offset.observed_utc_offset
256
+ end
257
+ private_class_method :gap_delta
258
+
259
+ def self.timezone_for(reference_zone)
260
+ TZInfo::Timezone.get(reference_zone)
261
+ rescue TZInfo::InvalidTimezoneIdentifier
262
+ raise ArgumentError,
263
+ "invalid reference_zone: #{reference_zone.inspect} " \
264
+ "(#{TZInfoCapabilities.unknown_identifier_diagnosis})"
265
+ rescue TZInfo::DataSourceNotFound => error
266
+ # tzinfo raises this before any identifier lookup, so restate it in
267
+ # terms of the keyword the caller passed, naming both fixes.
268
+ raise TZDataUnavailable,
269
+ "cannot resolve reference_zone: #{reference_zone.inspect} — this host has no tz database. " \
270
+ "Add `gem \"tzinfo-data\"` to bundle the IANA data with your app, or install the system " \
271
+ "tzdata package to provide zoneinfo files. (#{error.message.lines.first.to_s.strip})"
272
+ end
273
+ private_class_method :timezone_for
274
+
275
+ def self.verify_reference_time_offset!(reference_time, zone, reference_zone)
276
+ zone_offset = zone.period_for(reference_time).observed_utc_offset
277
+ return if reference_time.utc_offset == zone_offset
278
+
279
+ raise ArgumentError,
280
+ "reference_time's utc_offset (#{reference_time.utc_offset}) does not match " \
281
+ "reference_zone #{reference_zone.inspect}'s utc_offset (#{zone_offset}) at that instant"
282
+ end
283
+ private_class_method :verify_reference_time_offset!
284
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duckling
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.7
5
+ platform: aarch64-linux
6
+ authors:
7
+ - Caleb Buxton
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tzinfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake-compiler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.3.1
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.3.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: benchmark-ips
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: async
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.41'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.41'
69
+ description: Duckling NER without an HTTP service for Ruby
70
+ email:
71
+ - me@cpb.ca
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - Brewfile
77
+ - CHANGELOG.md
78
+ - CODE_OF_CONDUCT.md
79
+ - LICENSE.txt
80
+ - NOTICES
81
+ - README.md
82
+ - Rakefile
83
+ - docs/2026-07-01-roadmap.md
84
+ - docs/tz-database-axis.md
85
+ - lib/duckling.rb
86
+ - lib/duckling/3.2/duckling.so
87
+ - lib/duckling/3.3/duckling.so
88
+ - lib/duckling/3.4/duckling.so
89
+ - lib/duckling/4.0/duckling.so
90
+ - lib/duckling/tzinfo_capabilities.rb
91
+ - lib/duckling/version.rb
92
+ homepage: https://github.com/cpb/duckling
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ homepage_uri: https://github.com/cpb/duckling
97
+ source_code_uri: https://github.com/cpb/duckling
98
+ rubygems_mfa_required: 'true'
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 3.2.0
108
+ - - "<"
109
+ - !ruby/object:Gem::Version
110
+ version: 4.1.dev
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.5.23
118
+ signing_key:
119
+ specification_version: 4
120
+ summary: Ruby FFI adapter to a Rust Duckling
121
+ test_files: []