-
Notifications
You must be signed in to change notification settings - Fork 0
research type mapping strategy serialization options
Three approaches for converting Vec<Entity> from duckling into a Ruby Array of Hashes.
Before evaluating options, the actual serde attributes on the relevant types in
src/types.rs
and dimensions/time_grain/mod.rs
must be known. Here is what the source shows:
Entity (#[derive(serde::Serialize)], no container attributes):
- Field-level:
#[serde(skip_serializing_if = "Option::is_none")]onlatent - Struct fields serialize with their Rust names:
body,start,end,value,latent
DimensionValue (#[derive(serde::Serialize)], no container attributes):
- No
#[serde(tag = ...)], no#[serde(rename_all = ...)] - Default serde representation for enums with data: externally tagged
-
DimensionValue::Time(tv)serializes as{"Time": <tv>}, not{"type": "time", ...}
TimeValue (#[derive(serde::Serialize)], no container attributes):
- Field-level attributes only:
#[serde(skip_serializing_if = "Option::is_none", rename = "holidayBeta")]onholiday - No
#[serde(tag = "type")]container attribute -
TimeValue::Single { ... }serializes as{"Single": {"value": ..., "values": [...]}} -
TimeValue::Interval { ... }serializes as{"Interval": {"from": ..., "to": ..., "values": [...]}}
TimePoint (#[derive(serde::Serialize)], no container attributes):
- No container attributes
-
TimePoint::Naive { value, grain }serializes as{"Naive": {"value": "2013-02-13T00:00:00", "grain": "Naive"}} -
TimePoint::Instant { value, grain }serializes as{"Instant": {"value": "2013-02-12T04:30:00-02:00", "grain": "Minute"}}
Grain (#[derive(serde::Serialize)], no container attributes):
- Unit enum variants serialize as their Rust name (PascalCase):
"Day","Hour","Minute", etc. -
Grain::NoGrainserializes as"NoGrain"— not"no_grain"as returned byas_str() - Critically: does NOT match pyduckling's lowercase grain strings (
"day","hour", etc.)
IntervalEndpoints (#[derive(serde::Serialize)]):
- No container attributes; struct fields
fromandtoserialize directly
MeasurementValue (#[derive(serde::Serialize)]):
- No container attributes; externally tagged format
Given the above, serde_magnus::serialize(&entity) on a Time entity for "tomorrow" would
produce this Ruby hash:
{
"body" => "tomorrow",
"start" => 0,
"end" => 8,
"value" => {
"Time" => { # <-- externally-tagged DimensionValue::Time
"Single" => { # <-- externally-tagged TimeValue::Single
"value" => {
"Naive" => { # <-- externally-tagged TimePoint::Naive
"value" => "2013-02-13T00:00:00",
"grain" => "Day" # <-- PascalCase, not "day"
}
},
"values" => [
{"Naive" => {"value" => "2013-02-13T00:00:00", "grain" => "Day"}}
]
}
}
}
# latent omitted because skip_serializing_if = "Option::is_none"
}This does not match the pyduckling format. The mismatches are:
-
"Time"key wrappingDimensionValue(pyduckling: flat"value"key on Entity) -
"Single"/"Interval"keys wrappingTimeValue(pyduckling:"type": "value"/"interval") -
"Naive"/"Instant"keys wrappingTimePoint(pyduckling: flat object with"type"field) - Grain serialized as
"Day"not"day"
Use the serde_magnus crate (by OneSignal) to convert any serde::Serialize type directly
to a Magnus Value. serde_magnus::serialize(&entity) returns a magnus::Value representing
the complete Ruby object graph.
Cargo.toml addition:
[dependencies]
serde_magnus = "0.8"Usage:
fn parse_time(ruby: &Ruby, text: String, ...) -> Result<Value, Error> {
let entities = duckling::parse_en(&text, &[DimensionKind::Time]);
serde_magnus::serialize(&entities).map_err(|e| /* convert error */)
}Pros:
- Minimal Rust code — two lines to convert
Vec<Entity>to a Ruby Array - All public types already implement
serde::Serialize - No need to enumerate variants manually
Cons:
- As verified above, the output shape does NOT match pyduckling. All enum types use externally-tagged format; Grain uses PascalCase variant names
- Fixing the shape requires adding serde container attributes to duckling types
(
#[serde(tag = "type", rename_all = "lowercase")]on DimensionValue, TimeValue, TimePoint;#[serde(rename_all = "lowercase")]on Grain) - Those changes belong in duckling, not this gem — either upstream them as a PR or fork the crate; neither is trivial for 0.2.0
When to use: Only if the shape mismatch is acceptable (tests that don't cross-validate against pyduckling format), or if the serde attributes are added upstream.
Construct Ruby hashes field-by-field using magnus::RHash, magnus::RArray, and
magnus::Value primitives directly in Rust. The extension controls the exact key names
and structure.
Pros:
- Full control over Ruby hash shape — can match pyduckling exactly
- For 0.2.0 scope (Time only), only one
DimensionValuevariant needs deep handling - No additional crate dependency
- Makes NaiveDateTime representation explicit (see Target Ruby Hash Schema (0.2.0 — Time Entities Only))
Cons:
- More Rust code: must implement a match arm for every
DimensionValuevariant (or at minimum the Time variant and a fallthrough) - Ongoing maintenance cost when duckling adds new variants
See Magnus Type Conversions for example Rust code.
When to use: Recommended for 0.2.0. Gives pyduckling format compatibility and makes the NaiveDateTime/Instant distinction explicit.
Serialize entities to a JSON string with serde_json, return the string to Ruby, let Ruby
parse it with JSON.parse.
Cargo.toml addition:
[dependencies]
serde_json = "1" # already a transitive dep of wafer-inc-ducklingPros:
- Simplest Rust code:
serde_json::to_string(&entities).unwrap() - No Magnus complexity beyond returning a String
Cons:
- Returns a
String, not aHash— caller mustrequire "json"and callJSON.parse - Adds unnecessary serialization + deserialization round-trip (native bridge benefit lost)
- Output has the same enum shape problems as Option A (externally-tagged, PascalCase grains)
- Not a production pattern for a native extension
When to use: Only as a debugging tool or rapid prototype to inspect what serde produces.
Reviewer suggestion (PR #3), not yet evaluated — added here to preserve the idea, not to declare it decided.
Options A and C above both treat serde_magnus's externally-tagged shape
({"Time" => tv}, {"Single" => {...}}, PascalCase Grain variants) as a defect to be
fixed with serde container attributes or worked around with manual mapping. This option
reframes it: keep the externally-tagged shape — don't fight serde's default enum
representation — and instead:
- Make the only serde-side change be symbolizing keys (not renaming or re-tagging),
so the output is
{Time: {Single: {value: {...}, values: [...]}}}with Symbol keys throughout rather than String keys. Ruby'scase/inpattern matching (and thedeconstruct_keysprotocol generally) is built around Symbol-keyed Hash patterns, so this is the one serde tweak that actually matters for ergonomics — not the fullrename_all/tag = "type"treatment Option A considered. - In Ruby, write factory methods that
case/inpattern-match on that symbol-keyed, externally-tagged shape and constructData-based value objects (Data.define(...)) forEntity,TimeValue::Single,TimeValue::Interval,TimePoint::Naive,TimePoint::Instant, etc., rather than returning raw nested Hashes to callers.
Rationale (reviewer's framing): hashes are not the preferred end-user API. A
Data-based API gives callers proper immutable value objects with named accessors and
===/pattern-matching support of their own, instead of a Hash whose shape callers have
to memorize and re-parse. The externally-tagged wrapper keys ("Time", "Single",
"Naive") that Options A/C treat as noise to strip out are, from this angle, exactly the
discriminant that Ruby pattern matching wants to switch on — so there's no need to change
serde's enum representation at all, only its key type.
This does not resolve the NaiveDateTime timezone question (still Option N1 vs. N2 in
Target Ruby Hash Schema (0.2.0 — Time Entities Only)) — it's a proposal about the shape of the
returned object (Hash vs. Data) and how the extension gets there (manual mapping vs.
symbol-keyed serde_magnus + Ruby-side pattern matching), orthogonal to that question. See
issue #33 for the naive-time-handling ticket.
Status: Exploratory. Option B (Manual Magnus Mapping) remains the shipped 0.2.0 implementation — this option is a proposed direction for a future release, tracked as issue #32 rather than folded into the current recommendation below.
Use Option B (Manual Magnus Mapping) for 0.2.0.
Rationale:
- The verified serde attributes confirm that Option A (serde_magnus) produces the wrong shape without changes to duckling that are out of scope for 0.2.0.
- Manual mapping for 0.2.0 is bounded in scope: only
DimensionValue::Timeneeds deep handling; other variants can return an opaque hash or be omitted. - Manual mapping makes the NaiveDateTime representation a first-class decision rather than a side effect of serde's chrono integration.
- Option B keeps the native bridge benefit (no JSON parsing in Ruby) and is idiomatic for Magnus extensions.
Option A becomes practical in a later release if duckling adds appropriate serde container attributes. At that point, serde_magnus can replace the manual mapping with much less code.