-
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 ruby-hash-schema.md)
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.md 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
ruby-hash-schema.md) — 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.