Duckling
Ruby FFI adapter to a Rust Duckling NER engine — no HTTP service required.
Installation
Install the gem and add to the application's Gemfile by executing:
bundle add duckling
If bundler is not being used to manage dependencies, install the gem by executing:
gem install duckling
Usage
require "duckling"
Duckling.parse("tomorrow", locale: "en")
# =>
# [{ body: "tomorrow", start: 0, end: 8, dim: :time,
# value: { Time: { Single: { value: { Naive: { value: 2026-07-02 00:00:00 +0000, grain: :day } },
# values: [...] } } } }]
# (the date resolves relative to now; pass reference_time: for a fixed anchor)
Duckling.parse takes required text plus keyword options, and returns an
Array of entity Hashes (empty if nothing matched):
Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
Keyword arguments
locale:(String, default"en") — alang[-region]tag, e.g."en"or"en-GB". An unrecognized language or region raisesArgumentError.dims:(Array of String, default["time"]) — which dimensions to extract. See "Supported dimensions" below. An unrecognized dimension name raisesArgumentError.reference_time:(Time, defaultnil) — anchors relative expressions like "tomorrow" or "next week". Itsutc_offsetis preserved into every time result's:value— both wall-clock expressions ("tomorrow", "next week") and offset-aware ones ("in one hour"), not flattened to UTC. Defaults to the current UTC time; pass an explicitTimefor deterministic output. Anything else responding toto_time—ActiveSupport::TimeWithZone(Time.current/Time.zone.now), stdlibDateTime, etc. — is coerced to aTimeautomatically. A value that's neither aTimenorto_time-able (e.g. a raw Integer Unix timestamp) raisesTypeError; wrap it inTime.at(seconds)first.with_latent:(Boolean, defaultfalse) — include ambiguous/latent matches (e.g. a bare "morning") in the results.reference_zone:(String, defaultnil) — an IANA zone name, e.g."America/New_York". Resolves each wall-clock result's UTC offset against that zone on the result's own date, so a result before a DST transition and one after it get different offsets instead of sharingreference_time:'s single fixed one. An unknown identifier raisesArgumentError; so does areference_time:whoseutc_offsetdisagrees with the zone at that instant. It does not anchor the parse — see "Time zone data" below.
There is no Duckling::Error class — invalid locale:/dims: values raise
plain ArgumentError, as do an unknown reference_zone: and a
reference_time: whose offset disagrees with it. The one named error a caller
is likely to meet is Duckling::TZDataUnavailable, for a host with no tz
database at all — see "Time zone data" below.
Time zone data
reference_zone: resolves against tzinfo, which uses the tzinfo-data
gem when it is installed and the host's own zoneinfo files otherwise. This gem
does not depend on tzinfo-data, so by default you get the host's database.
That is usually what you want, and it is the faster of the two to start up. Two cases where it is not:
- Backward-compatibility names. Debian and Ubuntu ship names like
"US/Eastern"in a separatetzdata-legacypackage that is not installed by default, so roughly a hundred valid IANA identifiers raiseArgumentErroron a stock host. Eithergem "tzinfo-data"orapt install tzdata-legacyrestores them. The error message names whichever database answered and how many identifiers it has, so you can tell this apart from a typo. - No zoneinfo files at all, as in a scratch or distroless container.
reference_zone:raisesDuckling::TZDataUnavailablethere, naming both fixes. Addgem "tzinfo-data"to bundle the data with your app, where you can also patch its vintage by bumping one gem, or install the systemtzdatapackage. Every other keyword works without a tz database.
reference_zone: reinterprets result offsets; it does not anchor the parse.
Given without reference_time:, a relative expression like "tomorrow" still
anchors on the machine-local clock rather than on "now" in that zone — pass a
reference_time: in the zone to anchor as well.
Return value
Each entity in the returned array is a Hash with:
-
:body(String) — the matched substring. -
:start/:end(Integer) — character offsets into the input text. -
:dim(Symbol) — the dimension, e.g.:time. -
:latent(Boolean) — present only when the match is latent. -
:value— every entity carries one; its shape depends on:dim.Every dimension's
:valueis serde's externally-tagged representation of the underlying Rust value, with all Hash keys symbolized — a one-pairHashkeyed by a PascalCase tag matching the dimension ({Numeral: 42.0},{Email: "user@example.com"},{Url: {value: "...", domain: "..."}},{Temperature: {Value: {value: 37.0, unit: "celsius"}}}). Seetest/duckling_parse_dimensions_test.rbfor the exact shape of every dimension.:timeis the one dimension with additional nested tagging, since its underlying leaf datetimes aren't serde-serializable as RubyTimeobjects and need patching after the fact (seeext/duckling/src/lib.rs'spatch_time_value/patch_time_point):# a single point in time, e.g. "tomorrow" { Time: { Single: { value: { Naive: { value: 2026-07-02 00:00:00 +0000, grain: :day } }, values: [...] } } } # an interval, e.g. "from 3pm to 5pm" { Time: { Interval: { from: { Naive: { value: 2013-02-12 15:00:00 -0200, grain: :hour } }, to: { Naive: { value: 2013-02-12 18:00:00 -0200, grain: :hour } }, values: [...] } } }Every
TimePoint(the primaryvalue:, eachvalues:recurrence entry, and an interval'sfrom:/to:) is taggedNaive(wall-clock, no timezone assumption — e.g. "tomorrow", "5pm") orInstant(offset-aware — e.g. "in one hour", "now").grainis one ofsecond,minute,hour,day,week,month,quarter,year. The nestedvalue:is always a real RubyTime, not a formatted string — itsutc_offsetmatchesreference_time:'s (or UTC, ifreference_time:was omitted). A holiday match (e.g. "christmas") additionally carriesholidayBeta:(a String) alongsideSingle'svalue:/values:.Gotcha: an interval's
:tois the exclusive boundary, not the literal named time —"from 3pm to 5pm"resolves:toto18:00, not17:00. This matches upstream duckling behavior.Gotcha: an unbounded interval (e.g.
"after 3pm","until 5pm") still carries both:fromand:tokeys — the missing bound is an explicitnilvalue, not an absent key. Checkinterval[:to].nil?(notinterval.key?(:to)) to detect an unbounded endpoint.
Supported dimensions
dims: accepts time, number, ordinal, temperature, distance,
volume, quantity, amount-of-money, email, phone-number, url,
credit-card-number, time-grain, and duration — every entity comes back
with a populated :value (see "Return value" above for the shape). An
unrecognized dimension name raises ArgumentError.
Known limitation: bare comma-separated lists
A run of date/time expressions joined by bare commas, with nothing else between them, collapses into a single entity — every date after the first in that run is silently dropped:
Duckling.parse("birthdays are march 3, march 9, april 12 and may 5", locale: "en")
.select { |r| r[:dim] == :time }
.map { |r| r[:value][:value] }
# => [2013-03-03 00:00:00 +0000, 2013-05-05 00:00:00 +0000]
# (march 9 and april 12 are silently dropped)
This is an upstream grammar/ranking behavior in the wrapped duckling engine, not something this gem can work around. Joining dates with "and", periods, or a name/label immediately before each date avoids the collapse:
Duckling.parse("march 3 and march 9 and april 12 and may 5", locale: "en")
.select { |r| r[:dim] == :time }
.map { |r| r[:value][:value] }
# => [2013-03-03 00:00:00 +0000, 2013-03-09 00:00:00 +0000, 2013-04-12 00:00:00 +0000, 2013-05-05 00:00:00 +0000]
See test/duckling_comma_list_test.rb for the full characterization,
including cases where the surviving value isn't even reliably the leftmost
date in the collapsed run.
Performance
Benchmarked with benchmark-ips
against Duckling.parse, including Magnus/Ruby conversion overhead (not
just the underlying Rust engine), plus GC pressure and threaded-worker-pool
throughput. See docs/benchmarks/ for the latest
numbers, broken out by environment (GitHub Actions CI, Claude Code Web,
local dev) — results vary enough by machine that comparing across
environments is more meaningful than a single blended trend.
Development
After checking out the repo, run bin/setup to install dependencies. Building
the native Rust extension requires a Rust toolchain; on macOS with Homebrew
installed, bin/setup installs it automatically via brew bundle and the
project's Brewfile (no-op if Homebrew isn't present). Then run
rake compile to build the extension before running rake test, or just run
rake (or bundle exec rake) with no arguments to lint, compile, and test in
order. You can also run bin/console for an interactive prompt that will
allow you to experiment.
bin/setup also seeds a .env.local file (from .env.local.example) with
RB_SYS_CARGO_PROFILE=dev, so local rake compile runs build the extension
in Cargo's dev profile by default — slower at runtime, but much faster to
compile while iterating. .env.local is gitignored and untouched by CI, so
CI and rake release still build the optimized release profile. Delete or
edit .env.local to opt back into a release-profile local build, or run
bundle exec rake dev compile test for a one-off dev-profile build without
.env.local in place.
To install this gem onto your local machine, run bundle exec rake install. To release a new version: bump Duckling::VERSION in version.rb, merge that change to main, then run bundle exec rake release (or push a matching vX.Y.Z tag directly) to create and push the git tag. Pushing the tag triggers a GitHub Actions pipeline that re-runs CI as a gate, cross-compiles x86_64-linux/x86_64-darwin binary gems, verifies the tag matches Duckling::VERSION, builds and publishes the gems (source + both binary platforms) to rubygems.org, cuts a GitHub release, and opens a PR appending an entry to CHANGELOG.md.
bin/benchmark (or bundle exec rake benchmark) runs the benchmark-ips
suite locally and prints results to the console — no files written.
bin/benchmark record (or rake benchmark:record) additionally writes
docs/benchmarks/<environment>/<version>.json and regenerates
docs/benchmarks/README.md. bin/benchmark record-pr (or rake benchmark:record_pr) does the same against a fresh branch off origin/main
and opens (and auto-merges) a PR via gh — this is what the release
pipeline runs automatically, and what you'd also run from a Claude Code Web
session or a local dev machine to contribute that environment's numbers
ahead of a release. gh needs to be installed (bin/setup does this via
the Brewfile on macOS) and authenticated (gh auth login) for the
record-pr variant.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/cpb/duckling. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Duckling project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.