Skip to content

Commit a28da2f

Browse files
committed
Unit: fix compound-symbol parsing, times-compound formatting, and derived-base custom units (#42, #43, #44); gitignore inflection data
1 parent 6f2ff68 commit a28da2f

12 files changed

Lines changed: 746 additions & 7 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ mise.toml
3535
!/priv/localize/locales/en.etf
3636
!/priv/localize/locales/und.etf
3737

38+
# Inflection data (sources and generated ETFs) — belongs to the
39+
# inflection feature branch, not main
40+
/data/inflection/
41+
/priv/localize/inflection/
42+
3843
# Experiment files
3944
bench_unit_parser.sh
4045

data/data.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ defmodule Localize.Data do
6868
{"common/supplemental/pluralRanges.xml", "pluralRanges.xml"},
6969
{"common/supplemental/subdivisions.xml", "subdivisions.xml"},
7070
{"common/supplemental/units.xml", "units.xml"},
71+
{"common/supplemental/grammaticalFeatures.xml", "grammaticalFeatures.xml"},
7172
{"common/bcp47/timezone.xml", "bcp47_timezone.xml"}
7273
]
7374

@@ -123,6 +124,8 @@ defmodule Localize.Data do
123124
{"territory_subdivision_containment.etf",
124125
&Localize.Data.XmlExtractors.generate_territory_subdivision_containment/0},
125126
{"unit_data.etf", &Localize.Data.XmlExtractors.generate_unit_data/0},
127+
{"unit_grammatical_derivations.etf",
128+
&Localize.Data.XmlExtractors.generate_unit_grammatical_derivations/0},
126129
{"collation_tailoring.etf", &Localize.Data.Collation.generate_collation_tailoring/0},
127130
{"coverage_levels.etf", &Localize.Data.Supplemental.generate_coverage_levels/0},
128131
{"measurement_systems.etf", &Localize.Data.XmlExtractors.generate_measurement_systems/0},

data/xml_extractors.ex

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,103 @@ defmodule Localize.Data.XmlExtractors do
319319
}
320320
end
321321

322+
@doc """
323+
Generates the compound-unit grammatical-derivation table from
324+
`grammaticalFeatures.xml`.
325+
326+
CLDR derives the plural category, grammatical case, and gender of a
327+
compound unit's components from the compound as a whole (TR35
328+
"Compound Units"). For example the default "times" derivation makes
329+
the leading component singular and lets the trailing component carry
330+
the count (`newton-meters`), while French pluralizes every component
331+
(`tonnes-kilomètres`). The unit formatter reads this table to compose
332+
the localized name of a compound unit that has no precomposed pattern.
333+
334+
Returns a map keyed by the base language subtag string (plus `"root"`
335+
for the default), each value a map of the form:
336+
337+
%{
338+
plural: %{times: {:one, :compound}, per: {:compound, :one}, ...},
339+
case: %{times: {:nominative, :compound}, ...},
340+
gender: %{times: 1, per: 0, ...}
341+
}
342+
343+
For `deriveComponent` features (`:plural`, `:case`) the value is a
344+
`{value0, value1}` tuple where `:compound` means "use the compound's
345+
own category" and any other atom is a fixed category. For the
346+
`deriveCompound` `:gender` feature the value is the `0`/`1` index of
347+
the component whose gender the compound inherits. Each locale's block
348+
is merged over `"root"` so per-feature fallbacks are already resolved.
349+
350+
"""
351+
def generate_unit_grammatical_derivations do
352+
raw =
353+
"grammatical_features.xml"
354+
|> read_xml()
355+
|> SweetXml.parse()
356+
|> xpath(~x"//grammaticalDerivations"l,
357+
locales: ~x"./@locales"s,
358+
components: [
359+
~x"./deriveComponent"l,
360+
feature: ~x"./@feature"s,
361+
structure: ~x"./@structure"s,
362+
value0: ~x"./@value0"s,
363+
value1: ~x"./@value1"s
364+
],
365+
compounds: [
366+
~x"./deriveCompound"l,
367+
feature: ~x"./@feature"s,
368+
structure: ~x"./@structure"s,
369+
value: ~x"./@value"s
370+
]
371+
)
372+
|> Map.new(fn %{locales: locales, components: components, compounds: compounds} ->
373+
{locales, build_derivation_map(components, compounds)}
374+
end)
375+
376+
root = Map.get(raw, "root", %{})
377+
378+
raw
379+
|> Enum.flat_map(fn {locales, derivations} ->
380+
merged = deep_merge_derivations(root, derivations)
381+
for locale <- String.split(locales), do: {locale, merged}
382+
end)
383+
|> Map.new()
384+
end
385+
386+
# Folds a block's `deriveComponent`/`deriveCompound` rows into the
387+
# nested `%{feature => %{structure => value}}` shape.
388+
defp build_derivation_map(components, compounds) do
389+
from_components =
390+
Enum.reduce(components, %{}, fn %{feature: feature, structure: structure} = row, acc ->
391+
value = {derivation_atom(row.value0), derivation_atom(row.value1)}
392+
put_derivation(acc, feature, structure, value)
393+
end)
394+
395+
Enum.reduce(compounds, from_components, fn %{feature: feature, structure: structure} = row,
396+
acc ->
397+
put_derivation(acc, feature, structure, String.to_integer(row.value))
398+
end)
399+
end
400+
401+
defp put_derivation(acc, feature, structure, value) do
402+
feature = String.to_atom(feature)
403+
structure = String.to_atom(structure)
404+
Map.update(acc, feature, %{structure => value}, &Map.put(&1, structure, value))
405+
end
406+
407+
defp derivation_atom(""), do: nil
408+
defp derivation_atom(value), do: String.to_atom(value)
409+
410+
# Merges a locale block over `root` at the feature level, so a locale
411+
# that overrides only some features (French overrides plural but not
412+
# case) inherits the rest from root.
413+
defp deep_merge_derivations(root, override) do
414+
Map.merge(root, override, fn _feature, root_structures, override_structures ->
415+
Map.merge(root_structures, override_structures)
416+
end)
417+
end
418+
322419
@doc """
323420
Generates measurement system data from `bcp47/measure.xml`.
324421
@@ -453,6 +550,7 @@ defmodule Localize.Data.XmlExtractors do
453550
"bcp47/timezone.xml" => {:bcp47, "timezone.xml"},
454551
"bcp47/measure.xml" => {:bcp47, "measure.xml"},
455552
"units.xml" => {:supplemental, "units.xml"},
553+
"grammatical_features.xml" => {:supplemental, "grammaticalFeatures.xml"},
456554
"validity/unit.xml" => {:validity, "unit.xml"}
457555
}
458556

lib/localize/supplemental_data.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ defmodule Localize.SupplementalData do
3939
load_supplemental("likely_subtags.etf")
4040
end
4141

42+
@doc false
43+
# The CLDR compound-unit grammatical-derivation table, keyed by base
44+
# language subtag string (with `"root"` as the default). Read by the
45+
# unit formatter to derive each component's plural/case when composing
46+
# a compound unit that has no precomposed pattern.
47+
@spec unit_grammatical_derivations() :: %{String.t() => map()}
48+
def unit_grammatical_derivations do
49+
load_supplemental("unit_grammatical_derivations.etf")
50+
end
51+
4252
@doc false
4353
@spec aliases() :: map()
4454
def aliases do

lib/localize/unit/custom_registry.ex

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ defmodule Localize.Unit.CustomRegistry do
1111
1212
Each definition is a map with the following keys:
1313
14-
* `:base_unit` (required) — the CLDR base unit this custom unit converts to
15-
(e.g., `"meter"`, `"kilogram"`, `"second"`).
14+
* `:base_unit` (required) — the CLDR unit this custom unit converts to
15+
(e.g., `"meter"`, `"kilogram"`, `"second"`). It need not be a
16+
fundamental base unit: a derived unit such as `"day"` is accepted and
17+
folded down to its fundamental base (`"second"`) at registration, with
18+
the factor and offset adjusted accordingly, so the custom unit stays
19+
convertible against every CLDR unit in the same category.
1620
1721
* `:factor` (required) — the conversion factor:
1822
`1 custom_unit = factor * base_unit`.
@@ -129,9 +133,10 @@ defmodule Localize.Unit.CustomRegistry do
129133
def register(name, definition) do
130134
with :ok <- validate_name(name),
131135
:ok <- validate_definition(definition),
132-
:ok <- validate_no_collision(name) do
136+
:ok <- validate_no_collision(name),
137+
{:ok, normalized} <- normalize_base(definition) do
133138
current = all()
134-
:persistent_term.put(@persistent_term_key, Map.put(current, name, definition))
139+
:persistent_term.put(@persistent_term_key, Map.put(current, name, normalized))
135140
:ok
136141
end
137142
end
@@ -173,8 +178,9 @@ defmodule Localize.Unit.CustomRegistry do
173178
Enum.reduce(definitions, %{}, fn {name, definition}, acc ->
174179
with :ok <- validate_name(name),
175180
:ok <- validate_definition(definition),
176-
:ok <- validate_no_collision(name) do
177-
Map.put(acc, name, definition)
181+
:ok <- validate_no_collision(name),
182+
{:ok, normalized} <- normalize_base(definition) do
183+
Map.put(acc, name, normalized)
178184
else
179185
{:error, _reason} -> acc
180186
end
@@ -322,6 +328,67 @@ defmodule Localize.Unit.CustomRegistry do
322328
:ok
323329
end
324330

331+
# ── Base-unit normalization ──
332+
333+
# A custom unit's `:base_unit` must be a fundamental CLDR base unit,
334+
# because compatibility and conversion compare fully-reduced base
335+
# units: `compatible?/2` succeeds only when both units reduce to the
336+
# same base. When a definition is expressed in terms of a *derived*
337+
# unit (e.g. `%{base_unit: "day", factor: 1}`), storing "day"
338+
# verbatim leaves the custom unit reducing to "day" while every CLDR
339+
# unit reduces "day" to "second" — so the two never match.
340+
#
341+
# Fold the derived unit's own conversion into the custom factor and
342+
# offset so the stored base becomes the fundamental base with an
343+
# equivalent factor: `guestnight` given as `base_unit: "day",
344+
# factor: 1` is stored as `base_unit: "second", factor: 86400.0`.
345+
# A definition already expressed against a fundamental base is stored
346+
# unchanged, and `:special` (function-based) definitions — which do
347+
# not convert via a linear factor — are left untouched.
348+
defp normalize_base(%{factor: factor} = definition) when is_number(factor) do
349+
base = definition.base_unit
350+
351+
case Localize.Unit.BaseUnit.base_unit(base) do
352+
{:ok, ^base} ->
353+
{:ok, definition}
354+
355+
{:ok, true_base} ->
356+
with {:ok, base_factor, base_offset} <- base_conversion(base, true_base) do
357+
offset = Map.get(definition, :offset, 0.0)
358+
359+
{:ok,
360+
definition
361+
|> Map.put(:base_unit, true_base)
362+
|> Map.put(:factor, factor * base_factor)
363+
|> Map.put(:offset, offset * base_factor + base_offset)}
364+
end
365+
366+
{:error, _reason} ->
367+
{:error, "unknown base unit: #{inspect(base)}"}
368+
end
369+
end
370+
371+
defp normalize_base(definition), do: {:ok, definition}
372+
373+
# The linear parameters `{factor, offset}` that convert one unit of
374+
# `base` into `true_base`, such that
375+
# `value_in_true_base = value_in_base * factor + offset`. For a simple
376+
# derived unit CLDR stores these directly; for a compound derived unit
377+
# they are recovered from two reference conversions (the offset is the
378+
# image of zero, the factor the difference across one unit).
379+
defp base_conversion(base, true_base) do
380+
case Localize.Unit.Data.conversion_factor_raw(base) do
381+
%{factor: factor, offset: offset} when is_number(factor) ->
382+
{:ok, factor, offset}
383+
384+
_other ->
385+
with {:ok, at_zero} <- Localize.Unit.Conversion.convert(0, base, true_base),
386+
{:ok, at_one} <- Localize.Unit.Conversion.convert(1, base, true_base) do
387+
{:ok, at_one - at_zero, at_zero}
388+
end
389+
end
390+
end
391+
325392
# ── Validation ──
326393

327394
@name_pattern ~r/^[a-z][a-z0-9_-]*$/

0 commit comments

Comments
 (0)