-
Notifications
You must be signed in to change notification settings - Fork 0
plans stale 02 ruby api design
STALE — fully executed. Matches ext/duckling/src/lib.rs on main. Kept for
historical record — see Stale Plans — Fully Executed (0.2.0 Shipped) and the roadmap
for the live 0.2.x plan.
Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false)-
text— positional String, required. -
locale:— BCP-47 tag string; default"en". Split on-to produceLang+ optionalRegion. Validated at call time; unknown codes raiseArgumentError. -
dims:— Array of dimension name strings; default["time"]. Unknown strings raiseArgumentErrorin 0.2.0 (fail loud rather than silently skip). -
reference_time:— RubyInteger(Unix seconds). Whennil, falls back toContext::default()(Utc::now()+ EN-US locale). Include in 0.2.0 for testability (see Open Questions). -
with_latent:— Boolean; defaultfalse. MirrorsOptions { with_latent }in duckling. Whenfalse, latent/ambiguous entities are excluded (matchesOptions::default()). - Returns
Array<Hash>— one hash per entity, with Symbol keys and Symbol values for dim/type/grain. See Target Ruby Hash Schema (0.2.0 — Time Entities Only).
Do not use serde_magnus. The verified serde attributes on DimensionValue, TimeValue, TimePoint, and Grain produce externally-tagged enum shapes ({"Time": {"Single": {...}}}) and PascalCase grain names ("Day") that do not match the target schema. See Serialization Options: Rust to Ruby for the verified analysis.
Use manual RHash/RArray construction via the Magnus API. Full helper code is in Magnus Type Conversions.
TimePoint::Naive carries a chrono::NaiveDateTime with no timezone. Serialize as "%Y-%m-%dT%H:%M:%S" — no offset appended. Semantically honest; pyduckling applies the reference timezone synthetically, which this gem does not do in 0.2.0. Document the divergence in README.
TimePoint::Instant carries a DateTime<FixedOffset>. Serialize with .to_rfc3339() — preserves the exact offset.
| Decision | Why |
|---|---|
| Keyword args with defaults | Idiomatic Ruby; callers can set only what they need. reference_time: enables deterministic tests. |
| Manual Magnus mapping |
serde_magnus output is wrong (externally-tagged enums, PascalCase grains) without upstream serde-attribute changes out of scope for 0.2.0. Option B is bounded: only DimensionValue::Time needs deep handling. |
| NaiveDateTime as bare ISO8601 |
NaiveDateTime has no IntoValue impl in Magnus's chrono feature. Attaching a synthetic offset requires threading Context timezone into serialization. N1 avoids the complexity and is semantically correct. |
Unknown dim/locale → ArgumentError
|
Fail loud. Silently dropping unknown dims produces confusing empty results. |
reference_time: in 0.2.0 |
Without it, Context::default() uses Utc::now(), making assertions on relative expressions ("tomorrow") non-deterministic. |
See Locale System for supported Lang/Region pairs and Public Types for the full Entity/DimensionValue/TimeValue/TimePoint type hierarchy.
Init function:
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let module = ruby.define_module("Duckling")?;
module.define_singleton_method("parse", function!(parse, -1))?;
Ok(())
}Parse function (uses scan_args for keyword handling):
fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
let args = scan_args::scan_args::<(String,), (), (), (), _, ()>(args)?;
let kw = scan_args::get_kwargs::<
_, (),
(Option<String>, Option<Vec<String>>, Option<i64>, Option<bool>),
()
>(args.keywords, &[], &["locale", "dims", "reference_time", "with_latent"])?;
let text = args.required.0;
let locale_str = kw.optional.0.unwrap_or_else(|| "en".to_string());
let dims_strs = kw.optional.1.unwrap_or_else(|| vec!["time".to_string()]);
let ref_time_i = kw.optional.2; // Option<i64> Unix seconds
let with_latent = kw.optional.3.unwrap_or(false);
let locale = parse_locale_str(&locale_str)?; // -> duckling::Locale
let dims = parse_dims(&dims_strs)?; // -> Vec<DimensionKind>
let context = build_context(ref_time_i)?; // -> duckling::Context
let options = Options { with_latent };
let entities = duckling::parse(&text, &locale, &dims, &context, &options);
let out = ruby.ary_new();
for e in &entities {
out.push(entity_to_ruby(ruby, e)?)?;
}
Ok(out)
}Locale parsing — split "en-GB" on -; match the two-letter lang code to Lang via a match block; match the optional region code to Region similarly; call Locale::new(lang, region). Return ArgumentError for unknown codes. Locale::new normalises unsupported (Lang, Region) pairs silently by setting region = None.
Dim parsing — match strings to DimensionKind variants using their Display strings from Public Types ("time" → Time, "number" → Numeral, "amount-of-money" → AmountOfMoney, etc.). Raise ArgumentError for unrecognised strings.
Context construction — when reference_time is Some(unix_secs), construct
DateTime<FixedOffset> as:
fn build_context(ref_time_i: Option<i64>) -> Result<Context, Error> {
match ref_time_i {
Some(secs) => {
let utc = DateTime::from_timestamp(secs, 0)
.ok_or_else(|| Error::new(magnus::exception::arg_error(), "invalid reference_time"))?;
Ok(Context::new(utc.fixed_offset(), Locale::default()))
}
None => Ok(Context::default()),
}
}Note: DateTime::from_timestamp(secs, 0) produces a UTC DateTime, then .fixed_offset()
converts it to FixedOffset with offset +00:00. This loses the caller's original timezone
offset — a known limitation for 0.2.0. For the hill tests, this does not matter (the test
inputs don't span a date boundary under UTC vs. UTC-2). Revisit in 0.3.0 by accepting a
Ruby Time object and extracting .utc_offset via Magnus.
Helper functions — copy entity_to_ruby, time_value_to_ruby, time_point_to_ruby from Magnus Type Conversions verbatim. Use ruby.to_symbol(grain.as_str()) for grain values (Symbol, not String). Use ruby.to_symbol("body") etc. for all hash keys. The :dim key must be added explicitly:
let dim_str = entity.value.dim_kind().to_string();
h.aset(ruby.to_symbol("dim"), ruby.to_symbol(&dim_str))?;The Rust #[magnus::init] defines the Duckling module and its singleton method. The existing file stays as-is; the native extension is loaded via require "duckling/duckling" (added by rb-sys). Remove the placeholder Error constant stub to avoid confusion.
Change VERSION = "0.1.0" to VERSION = "0.2.0".
- Show
Duckling.parsewithlocale:,dims:, andreference_time:examples. - Note that wall-clock expressions ("tomorrow", "next Monday") produce ISO8601 strings without a timezone offset, unlike pyduckling which applies the reference timezone to all results.
- Note that
reference_time:accepts a Unix timestamp integer (e.g.Time.new(2013,2,12,4,30,0,"-02:00").to_i).
-
reference_time:type: The plan accepts ani64Unix timestamp to avoid MagnusTimeobject parsing complexity, losing the caller's UTC offset. Shipped as-is for 0.2.0. → Issue #45 tracks accepting a RubyTimeobject instead. -
Locale parsing implementation: No
Lang::from_strexists in the public API. Shipped using amatchblock on the two-letter code string, as planned. -
NoGrainstring in output:Grain::NoGrain.as_str()returns"no_grain"; pyduckling emits"nosec". Shipped"no_grain"for 0.2.0. Whether any realTimeentity in the extended corpus actually carriesNoGrainis now folded into the extended-corpus verification work. → Issue #34 -
Non-Time dimensions in 0.2.0:
entity_to_rubyonly handlesDimensionValue::Time, as shipped. Non-dims: ["time"]requests raiseArgumentError. → Issue #46 tracks implementing the remaining 13 dimensions.
After bundle exec rake compile:
require "duckling"
ref = Time.new(2013, 2, 12, 4, 30, 0, "-02:00").to_i
results = Duckling.parse("tomorrow", locale: "en", reference_time: ref)
results.first[:body] # => "tomorrow"
results.first[:dim] # => :time
results.first[:value][:type] # => :value
results.first[:value][:grain] # => :day
results.first[:value][:value] # => "2013-02-13T00:00:00" (no offset — Naive)
results2 = Duckling.parse("in one hour", locale: "en", reference_time: ref)
results2.first[:value][:value] # => "2013-02-12T06:30:00+00:00" (Instant, has offset)
results2.first[:value][:grain] # => :minute
# Note: reference_time is reconstructed at UTC+0 (see Context construction above).
# The Instant value above uses +00:00 because the reference was passed as a bare i64.
# The hill tests do not check the offset on Instant values, so this passes.