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/Rakefile ADDED
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "dotenv"
5
+ require "minitest/test_task"
6
+ require "rb_sys/extensiontask"
7
+
8
+ require_relative "cross_targets"
9
+
10
+ # Loads RB_SYS_CARGO_PROFILE=dev from .env.local when present (seeded by
11
+ # bin/setup from .env.local.example), so local compiles default to the dev
12
+ # profile without needing the :dev task below. .env.local is gitignored and
13
+ # never checked out in CI, so `bundle exec rake` there still builds release.
14
+ Dotenv.load(".env.local")
15
+
16
+ GEMSPEC = Gem::Specification.load("duckling.gemspec")
17
+
18
+ RbSys::ExtensionTask.new("duckling", GEMSPEC) do |ext|
19
+ ext.lib_dir = "lib/duckling"
20
+
21
+ # rake-compiler derives a native gem's required_ruby_version from the ABIs
22
+ # it cross-compiled against, and writes its own floor over the gemspec's.
23
+ # The gemspec's floor can be stricter, so state both bounds here: the
24
+ # gemspec's own requirement, plus the ceiling from cross_targets.rb.
25
+ ext.cross_compiling do |spec|
26
+ spec.required_ruby_version =
27
+ GEMSPEC.required_ruby_version.as_list + ["< #{CrossTargets::ABI_CEILING}"]
28
+ end
29
+ end
30
+
31
+ # rake-compiler always builds two things for this extension:
32
+ # - The real cross build for RUBY_TARGET.
33
+ # - An extra "local" build for the host Ruby. rake-compiler runs this
34
+ # local build even when cross_compile is off.
35
+ #
36
+ # Gem::PackageTask lists the local build's plain output path as a
37
+ # prerequisite of the final .gem file.
38
+ #
39
+ # Each rbsys/<platform> Docker image sets RUST_TARGET and
40
+ # CARGO_BUILD_TARGET in its environment. These variables make a plain
41
+ # `cargo build` target that image's platform. Every process in the
42
+ # container inherits them, including the local build.
43
+ #
44
+ # The Ruby that drives rake in each container is an x86_64 Linux Ruby.
45
+ # So the local build always makes a file named duckling.so.
46
+ #
47
+ # On x86_64-linux, this causes no problem. The local build's target is
48
+ # already correct.
49
+ #
50
+ # On x86_64-darwin and arm64-darwin, this causes no problem either.
51
+ # Those gems need duckling.bundle. They do not list duckling.so, so the
52
+ # local build is not a prerequisite and never runs.
53
+ #
54
+ # On aarch64-linux, the two names collide. The gem needs duckling.so,
55
+ # which is the name the local build makes. So the packaging step waits
56
+ # for the local build. That build compiles code for aarch64, but links
57
+ # the code with the host's plain gcc. rake-compiler picked this gcc for
58
+ # the host platform. The link step then fails, and stops the build.
59
+ #
60
+ # extconf.rb runs in its own subprocess. ENV changes made there do not
61
+ # reach the parent process, and the parent process runs `make`.
62
+ #
63
+ # So this fix changes ENV here, in the Rakefile.
64
+ # The fix adds a prerequisite task to the local build's Makefile task.
65
+ # This prerequisite task sets the correct host target. It runs before
66
+ # the Makefile task, and before `make` runs later.
67
+ if (ruby_target = ENV["RUBY_TARGET"]) && ruby_target != RUBY_PLATFORM
68
+ local_makefile = "tmp/#{RUBY_PLATFORM}/duckling/#{RUBY_VERSION}/Makefile"
69
+
70
+ # CARGO_BUILD_TARGET must hold a real triple before RUST_TARGET goes
71
+ # away. Clearing RUST_TARGET alone lets rb_sys fall back to the target
72
+ # baked into the container's $CARGO_HOME/config.toml, which is the
73
+ # cross-compile target again. So a host triple that cannot be read is a
74
+ # hard error.
75
+ #
76
+ # Both variables belong to the whole rake process, and the cross build
77
+ # reads them too. This is safe only because rake generates the cross
78
+ # Makefile, which bakes its own --target in, before packaging reaches
79
+ # the local pass. Nothing in rake states that order. If it ever
80
+ # inverted, the cross build would compile for the host and produce a
81
+ # correctly *named* binary for the wrong architecture — which is what
82
+ # `file(1)` on every binary in test/gem/packaged_gem_test.rb catches.
83
+ task :fix_local_pass_cargo_target do
84
+ rustc_version_info = begin
85
+ `rustc -vV`
86
+ rescue Errno::ENOENT
87
+ raise "Cannot run `rustc -vV` to find the host target triple. rustc must be on PATH."
88
+ end
89
+
90
+ host_target = rustc_version_info[/^host: (\S+)$/, 1]
91
+ raise "`rustc -vV` printed no `host:` line:\n#{rustc_version_info}" unless host_target
92
+
93
+ ENV["CARGO_BUILD_TARGET"] = host_target
94
+ ENV.delete("RUST_TARGET")
95
+ end
96
+
97
+ # local_makefile reconstructs a path rake-compiler builds from its own
98
+ # internals (tmp_dir, extension name, the local pass's Ruby version).
99
+ # A gem upgrade can change any of them. Say so here, because the
100
+ # alternative is a silent no-op and a link failure deep in a container.
101
+ unless Rake::Task.task_defined?(local_makefile)
102
+ raise "Expected rake-compiler to define a Makefile task at #{local_makefile}. " \
103
+ "The local-pass Cargo target override needs that exact task name — check " \
104
+ "define_compile_tasks in rake-compiler's extensiontask.rb for the current path."
105
+ end
106
+
107
+ Rake::Task[local_makefile].enhance([:fix_local_pass_cargo_target])
108
+ end
109
+
110
+ task :dev do
111
+ ENV["RB_SYS_CARGO_PROFILE"] = "dev"
112
+ end
113
+
114
+ # rb-sys-dock mounts the working directory into the build container with
115
+ # `-v $(pwd):$(pwd)` and nothing else. In a git worktree — which is how
116
+ # bin/worktree sets up every branch here — .git is a *file* naming a path under
117
+ # the main checkout's .git/worktrees/, which that mount does not cover. Every
118
+ # git command inside the container then fails, including the `git ls-files` in
119
+ # duckling.gemspec, whose file list comes back empty. The gem that comes out
120
+ # holds the compiled binaries and no Ruby at all.
121
+ #
122
+ # So build from a throwaway plain clone instead, whose .git is a real
123
+ # directory under the mount. Two consequences: the gem carries *committed*
124
+ # state (uncommitted changes are excluded), and the clone gets its own Cargo
125
+ # target directory separate from this checkout's.
126
+ CLONE_DIR = "tmp/native_gem_clone"
127
+
128
+ def build_native_gem(platform)
129
+ # bundler exports BUNDLE_GEMFILE pointing at this checkout, and rb-sys-dock
130
+ # mounts $(pwd) — the two have to move together or the container gets one
131
+ # directory's Gemfile and another's source.
132
+ Bundler.with_unbundled_env do
133
+ sh "bundle", "install"
134
+ sh "bundle", "exec", "rb-sys-dock", "--platform", platform,
135
+ "--ruby-versions", CrossTargets::RUBY_ABIS.join(","), "--build"
136
+ end
137
+ end
138
+
139
+ desc "Cross-compile the native extension for a given platform via rb-sys-dock (e.g. `rake 'native_gem[x86_64-linux]'`)"
140
+ task :native_gem, [:platform] do |_t, platform:|
141
+ next build_native_gem(platform) unless File.file?(".git")
142
+
143
+ head = `git rev-parse HEAD`.strip
144
+ raise "Could not read HEAD to pin the build clone." if head.empty?
145
+
146
+ unless `git status --porcelain`.empty?
147
+ warn "native_gem: building #{head[0, 7]} from a clone — uncommitted changes are not in this gem."
148
+ end
149
+
150
+ rm_rf CLONE_DIR
151
+ begin
152
+ sh "git", "clone", "--local", "--no-checkout", Dir.pwd, CLONE_DIR
153
+ sh "git", "-C", CLONE_DIR, "checkout", "--detach", head
154
+
155
+ Dir.chdir(CLONE_DIR) { build_native_gem(platform) }
156
+
157
+ mkdir_p "pkg"
158
+ cp FileList["#{CLONE_DIR}/pkg/*.gem"], "pkg"
159
+ ensure
160
+ rm_rf CLONE_DIR
161
+ end
162
+ end
163
+
164
+ task :benchmark_env do
165
+ # Force a realistic release-profile build regardless of .env.local's
166
+ # RB_SYS_CARGO_PROFILE=dev (local dev checkouts only; CI never has it).
167
+ # Must reenable :compile in case it already ran earlier in this same
168
+ # rake process, so it recompiles under the forced profile. A stale
169
+ # dev-profile build would be reused otherwise.
170
+ ENV.delete("RB_SYS_CARGO_PROFILE")
171
+ Rake::Task[:compile].reenable
172
+ end
173
+
174
+ desc "Run the benchmark-ips suite (console output only, no file writes)"
175
+ task benchmark: [:benchmark_env, :compile] do
176
+ ruby "-Ilib", "benchmark/parse_benchmark.rb"
177
+ end
178
+
179
+ namespace :benchmark do
180
+ desc "Run benchmarks, write docs/benchmarks/<environment>/<version>.json, regenerate docs/benchmarks/README.md"
181
+ task record: [:benchmark_env, :compile] do
182
+ ruby "-Ilib", "benchmark/report.rb"
183
+ end
184
+
185
+ desc "Run :record on a fresh branch off origin/main, then commit/push and open+auto-merge a PR via gh"
186
+ task record_pr: ["release:guard_clean"] do
187
+ # Explicit bash: Rake's default `sh -c` is dash on Debian/Ubuntu
188
+ # runners, and dash's `set` doesn't support the `-o pipefail` flag below.
189
+ sh("bash", "-c", <<~SH)
190
+ set -euo pipefail
191
+ original_ref="$(git symbolic-ref -q --short HEAD || git rev-parse HEAD)"
192
+ git fetch origin main
193
+ git checkout -b "benchmark/pending-$(date +%s)" origin/main
194
+
195
+ bundle exec rake benchmark:record
196
+
197
+ version="$(ruby -Ilib -e 'require "duckling"; puts Duckling::VERSION')"
198
+ environment="$(ruby -Ilib -e 'require_relative "benchmark/report"; puts DucklingBenchmark::Report::ENVIRONMENT')"
199
+ branch="benchmark/${environment}/${version}-$(date +%s)"
200
+ git branch -m "$branch"
201
+
202
+ git add docs/benchmarks
203
+ git commit -m "Record ${environment} benchmark results for ${version}"
204
+ git push origin "$branch"
205
+ gh pr create --base main --head "$branch" \\
206
+ --title "Benchmark results (${environment}, ${version})" \\
207
+ --body "Automated benchmark recording from ${environment}."
208
+ gh pr merge "$branch" --auto --squash
209
+
210
+ git checkout "$original_ref"
211
+ git branch -D "$branch"
212
+ SH
213
+ end
214
+ end
215
+
216
+ # The default suite exercises the extension compiled in this checkout.
217
+ # Three test subtrees run outside it:
218
+ # - test/gem/ exercises a *built* or *installed* gem instead — it needs one
219
+ # handed to it, and the installed suite must not see this checkout's lib/.
220
+ # - test/capabilities/ is loaded by test_helper itself, gated on the tz probes.
221
+ # - test/environments/ holds contracts invoked directly by their CI step.
222
+ # See docs/tz-database-axis.md.
223
+ Minitest::TestTask.create do |t|
224
+ t.test_globs = FileList["test/**/*_test.rb"].exclude("test/gem/**/*", "test/capabilities/**/*", "test/environments/**/*")
225
+ end
226
+
227
+ # Minitest::TestTask has no built-in way to declare a task dependency, and
228
+ # `task default: %i[standard compile test]`'s array ordering only protects
229
+ # `bundle exec rake` itself — `bundle exec rake test` run directly has no
230
+ # guarantee `compile` ran first, which would surface as a confusing
231
+ # LoadError/stale-behavior failure unrelated to the code under test.
232
+ task test: %i[compile]
233
+
234
+ require "standard/rake"
235
+
236
+ task default: %i[standard compile test]
237
+
238
+ # bundler/gem_tasks's default `release` task builds and pushes the .gem
239
+ # itself, which would race the tag-triggered CI pipeline in
240
+ # .github/workflows/release.yml that already does the actual build and
241
+ # publish once a vX.Y.Z tag lands. Narrow `release` to just tagging.
242
+ Rake::Task["release"].clear
243
+ task release: ["release:guard_clean", "release:source_control_push"]
@@ -0,0 +1,106 @@
1
+ # Roadmap — Issue #1: Ship duckling gem (time extraction via Magnus + wafer-inc-duckling)
2
+
3
+ **0.2.0 has shipped.** The original three-plan implementation sequence (native
4
+ extension → Ruby API → test suite/CI) fully executed — see
5
+ [the wiki's stale plans](https://github.com/cpb/duckling/wiki/plans-stale) for
6
+ that historical record. All research and design-exploration docs that grounded these
7
+ decisions have moved to [the project wiki](https://github.com/cpb/duckling/wiki); this
8
+ file is the only planning document that stays in the repo.
9
+
10
+ This document is the live plan: **what's left, tracked as GitHub issues**, not prose
11
+ duplicated here. Nothing below needs to happen for the gem to work today — these are
12
+ 0.2.x-and-beyond follow-ups raised during PR #3 review, while re-verifying the stale
13
+ plans against what actually shipped, and during issue #57's research into the
14
+ async-reactor-blocking fix.
15
+
16
+ If you're new here, read this file top-to-bottom once; after that, treat it as a
17
+ living index and jump straight to a section via the table of contents below.
18
+
19
+ ## Table of contents
20
+
21
+ | Section | Covers |
22
+ |---|---|
23
+ | [Environment & tooling alignment](#environment--tooling-alignment) | Keeping local dev, Claude Code Web, and CI on the same Ruby/Rust versions |
24
+ | [Developer workflow (Rakefile)](#developer-workflow-rakefile) | Rake task dependency ordering |
25
+ | [API design exploration (post-0.2.0 direction)](#api-design-exploration-post-020-direction) | Whether 0.2.0's manual Magnus/Hash API is the final shape |
26
+ | [Test coverage](#test-coverage) | Extending and auditing the test corpus |
27
+ | [Performance & concurrency](#performance--concurrency) | Benchmarking, and unblocking `Duckling.parse` under Fiber-scheduled reactors |
28
+ | [Settled Decisions (0.2.0, verified shipped)](#settled-decisions-020-verified-shipped) | Choices already made and shipped — no action needed |
29
+ | [Further reading](#further-reading) | Where the research that grounded all of the above lives |
30
+
31
+ ## Environment & tooling alignment
32
+
33
+ Keep local dev, Claude Code Web, and CI targeting the same Ruby/Rust versions so nothing
34
+ needs manual installation in any of them.
35
+
36
+ - ~~[#26](https://github.com/cpb/duckling/issues/26)~~ `bin/setup` Brewfile step for Rust — **shipped**
37
+ - ~~[#27](https://github.com/cpb/duckling/issues/27)~~ `bin/claude-code-web-setup` just-in-time deps — **shipped**
38
+ - ~~[#28](https://github.com/cpb/duckling/issues/28)~~ Pin CI's Rust toolchain to Claude Code Web's version — **shipped**
39
+
40
+ | Issue | What |
41
+ |-------|------|
42
+ | [#29](https://github.com/cpb/duckling/issues/29) | Expand the CI matrix to Ruby 3.4 / latest Ruby / latest Rust (forward-compat signal) |
43
+ | [#43](https://github.com/cpb/duckling/issues/43) | Publish precompiled binary gems for `x86_64-darwin-24` and `x86_64-linux` |
44
+
45
+ ## Developer workflow (Rakefile)
46
+
47
+ - ~~[#30](https://github.com/cpb/duckling/issues/30)~~ `:dev` Rake task (`RB_SYS_CARGO_PROFILE=dev`) — **shipped**
48
+
49
+ | Issue | What |
50
+ |-------|------|
51
+ | [#31](https://github.com/cpb/duckling/issues/31) | Explore making `test` explicitly depend on `:compile`, instead of relying on `default` task array ordering |
52
+
53
+ ## API design exploration (post-0.2.0 direction)
54
+
55
+ The 0.2.0 API (manual Magnus hash mapping, matching pyduckling's Hash-based shape) is
56
+ not necessarily the final shape — see "Option D" in
57
+ [research-type-mapping-strategy-serialization-options](https://github.com/cpb/duckling/wiki/research-type-mapping-strategy-serialization-options)
58
+ on the wiki.
59
+
60
+ | Issue | What |
61
+ |-------|------|
62
+ | [#32](https://github.com/cpb/duckling/issues/32) | Explore serde_magnus (symbol keys) + Ruby pattern-matching `Data` factories as a Hash-free API |
63
+ | [#33](https://github.com/cpb/duckling/issues/33) | v0.3.0: handle Naive time values the Rails ActiveSupport way (resolve against reference zone) |
64
+ | [#45](https://github.com/cpb/duckling/issues/45) | `reference_time:` — accept a Ruby `Time` object to preserve UTC offset |
65
+ | [#46](https://github.com/cpb/duckling/issues/46) | Implement the remaining 13 `DimensionValue` variants beyond `Time` |
66
+ | [#47](https://github.com/cpb/duckling/issues/47) | Explore upstreaming serde container attributes to wafer-inc/duckling |
67
+
68
+ ## Test coverage
69
+
70
+ | Issue | What |
71
+ |-------|------|
72
+ | [#34](https://github.com/cpb/duckling/issues/34) | Implement the extended test corpus designed in [research-test-coverage-ruby-test-design](https://github.com/cpb/duckling/wiki/research-test-coverage-ruby-test-design) on the wiki (`test-first`) |
73
+ | [#35](https://github.com/cpb/duckling/issues/35) | Audit wafer-inc-duckling's test coverage against pyduckling and upstream Haskell duckling |
74
+
75
+ ## Performance & concurrency
76
+
77
+ - ~~[#36](https://github.com/cpb/duckling/issues/36)~~ `benchmark-ips` suite with automated README/CHANGELOG reporting — **shipped** (`benchmark/parse_benchmark.rb`, `bin/benchmark record-pr`; see `docs/benchmarks/README.md`)
78
+
79
+ | Issue | What |
80
+ |-------|------|
81
+ | [#38](https://github.com/cpb/duckling/issues/38) | Test-drive the Falcon Fiber-blocking claim in [research-ffi-risks](https://github.com/cpb/duckling/wiki/research-ffi-risks) on the wiki (`test-first`) — hill test drafted in [PR #50](https://github.com/cpb/duckling/pull/50), pending human review |
82
+ | [#64](https://github.com/cpb/duckling/issues/64) | Implement thread-per-call GVL release (`rb_thread_call_without_gvl` + a spawned background `Thread`) to make `test/falcon_fiber_blocking_test.rb` pass once #38's hill lands — see [research-async-reactor-blocking](https://github.com/cpb/duckling/wiki/research-async-reactor-blocking) on the wiki for why a bare GVL release isn't enough. Includes splitting `Duckling::Native.parse` out from `Duckling.parse` as a benchmarking seam, and a with-thread-vs-without-thread benchmark comparison. |
83
+
84
+ **Deferred follow-ups from #57's research** (explicitly out of scope for #64 itself — revisit only if their trigger condition below actually occurs):
85
+
86
+ - **Persistent worker-pool dispatch** instead of thread-per-call — only worth it if a production workload dominated by many back-to-back `empty`/`no_match`-shaped calls makes the ~70µs/call thread-spawn floor a measurable fraction of total latency. See [research-concurrency-alternatives-comparison-worker-pool](https://github.com/cpb/duckling/wiki/research-concurrency-alternatives-comparison-worker-pool) — a single-worker pool would serialize concurrent callers despite the wrapped crate supporting true concurrency, so this is a real regression risk, not a free win; a *bounded* pool (N > 1) was flagged as uninvestigated if this is ever picked up.
87
+ - **Conditional thread-spawn based on `Fiber.current_scheduler`** (skip the `Thread.new` when no reactor is present, e.g. a plain script or non-Fiber-scheduled Puma worker) — the detection signal was analyzed as sound (correctly distinguishes "no reactor" from "reactor present but current Fiber is blocking," both cases where spawning a thread buys nothing) but was never exercised in a test. Deferred because #64 (and #57 more broadly) has no throughput-optimization goal — adding a conditional branch here is itself a latency optimization for the non-reactor case, the same category of complexity rejected for the worker-pool alternative. Revisit if the ~70µs/call floor is ever shown to matter for a non-reactor caller in practice.
88
+ - **Ruby version floor** (`>= 3.2.0` today) — [#77](https://github.com/cpb/duckling/issues/77) spiked whether calling `rb_nogvl` directly with `RB_NOGVL_OFFLOAD_SAFE` (instead of `rb_thread_call_without_gvl`, which #64 uses and which always passes `flags: 0`) lets `Fiber::Scheduler#blocking_operation_wait` auto-offload `duckling::parse`, obviating #64's Thread wrapper on that floor. **Verdict: CONFIRMED** on Ruby 3.4+ — see the wiki's `research-rb-nogvl-offload-safe-spike` for the full methodology and data. Important caveat found along the way: `io-event`'s automatic `WorkerPool` support (what lets the real `async`/Falcon gem exercise this hook without a hand-rolled scheduler) requires `rb_fiber_scheduler_blocking_operation_extract`, which only ships in Ruby 4.0+ — so `async`-based callers won't see any benefit until they're on Ruby 4.0, even though the underlying mechanism works on 3.4. Not proposing a floor bump for #64's sake either way; a follow-up implementation issue to actually add the 3.4+-gated dispatch path is recommended but was deferred to #77's PR review rather than opened by the spike itself.
89
+
90
+ ## Settled Decisions (0.2.0, verified shipped)
91
+
92
+ - **[duckling](https://github.com/wafer-inc/duckling) on crates.io** — Published as `duckling = "0.4"`.
93
+ - **Symbol keys and Symbol values throughout** — `:body`, `:dim`, `:value`, `:type`, `:grain`, etc. Settled by the hill tests in PR #2, confirmed shipped in `test/duckling_test.rb` on `main`.
94
+ - **NaiveDateTime → bare ISO8601 (no offset)** — Option N1. Shipped as-is; Option N2 (ActiveSupport-style zone resolution) is tracked for 0.3.0 as [#33](https://github.com/cpb/duckling/issues/33).
95
+ - **Manual Magnus mapping, not serde_magnus** — shipped as Option B; `magnus = "0.8"` (not `"0.9"`, which was never published to crates.io).
96
+ - **Source gem, not pre-compiled binaries** — shipped this way for 0.2.0; [#43](https://github.com/cpb/duckling/issues/43) tracks adding pre-compiled binary gems.
97
+
98
+ ## Further reading
99
+
100
+ The full research that grounded these decisions — wafer-inc-duckling's API surface,
101
+ Magnus/rb-sys build wiring, type-mapping strategy options, test corpus design,
102
+ empirical FFI risk analysis (GVL blocking, panic safety, GC pressure, day-of-week
103
+ validation gap), and issue #57's async-reactor-blocking investigation (GVL-release
104
+ mechanics, the wrapped crate's thread/panic-safety, dispatch-strategy comparison, and
105
+ the empirical spike proving GVL-release-alone doesn't work) — lives on
106
+ [the project wiki](https://github.com/cpb/duckling/wiki), not in this repo.