From 1bdaeb0e4e538d3e46ba2ae926b26e0acaac6f66 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Thu, 5 Nov 2026 16:36:00 +0500 Subject: [PATCH 1/2] Fix builder metadata copies, Number power checks and zero inversion Fluent #name, #quantity_reference and #multiplier now return a deep copy, so changing the result no longer changes the original formula. Unit.new, Dimension.new and the power_numerator setter reject a Number (or fenced Number) power that does not hold a number, instead of rendering it as m^abc and emitting a broken unit id. Dividing by a unit with power 0 keeps the power at 0 rather than writing -0. --- lib/unitsml/compose/builder.rb | 8 +++-- lib/unitsml/power_numerator.rb | 28 +++++++++++++++- spec/unitsml/composite_spec.rb | 59 ++++++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb index 5be4e51..335a03d 100644 --- a/lib/unitsml/compose/builder.rb +++ b/lib/unitsml/compose/builder.rb @@ -70,7 +70,9 @@ def attach_metadata(node, **extras) added = build_metadata(extras[:quantity], extras[:name], extras[:multiplier]) - copy = node.dup + # A deep copy, so the result never shares terms with the caller's + # Formula (a shallow dup would share the term array and leaves). + copy = copy_formula(node) copy.explicit_value = merge_metadata(node.explicit_value, added) copy end @@ -211,9 +213,11 @@ def rational_string(power) power.denominator == 1 ? power.numerator.to_s : power.to_s end - # Negate an exponent string: nil (no exponent) -> "-1", else flip sign. + # Negate an exponent string: nil (no exponent) -> "-1", zero stays + # "0" (never "-0"), else flip sign. def negate_string(raw) return "-1" if raw.nil? + return raw if raw.match?(/\A-?0+\z/) raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}" end diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 26dea69..f75ee8b 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -19,8 +19,17 @@ def power_numerator=(power) private + # The exponent lexemes the library itself stores: an integer, an n/m or + # n//m fraction (parser), or a decimal ("0.5" for sqrt, "2.0" from unit + # decomposition). Anything else ("abc") has no numeric meaning and would + # render and serialize as garbage. + NUMBER_LEXEME = %r{\A-?\d+(\.\d+)?(//?-?\d+)?\z} + private_constant :NUMBER_LEXEME + def coerce_power_type(power) - return power if Compose.type_any?([NilClass, Number, Fenced], power) + return power if Compose.type?(NilClass, power) + return validate_number_power(power) if Compose.type?(Number, power) + return validate_fenced_power(power) if Compose.type?(Fenced, power) # Only the numeric types the parser can express (Integer, Rational, whole # Float) coerce; other Numerics (BigDecimal, Complex, ...) are rejected # rather than stringified into a non-parser exponent. @@ -31,5 +40,22 @@ def coerce_power_type(power) raise Errors::InvalidPowerError.new(value: power, reason: :unsupported_storage) end + + def validate_number_power(power) + lexeme = Compose.safe_string(power.raw_value) + return power if lexeme&.match?(NUMBER_LEXEME) + + raise Errors::InvalidPowerError.new(value: power, + reason: :invalid_number) + end + + # A Fenced exponent is checked at its innermost Number; other parser + # payloads inside the fence are kept as-is. + def validate_fenced_power(power) + inner = power.value + inner = inner.value while Compose.type?(Fenced, inner) + validate_number_power(inner) if Compose.type?(Number, inner) + power + end end end diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 903c59d..b1d8b95 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -468,8 +468,10 @@ def latex_for(power) end it "validates the power of a pre-built Unit entry" do - bad = Unitsml::Unit.new("m", Unitsml::Number.new("abc")) - expect { Unitsml.compose(units: [bad]) } + # A non-numeric Number is now rejected by Unit.new itself; a decimal + # Number is storable but still has no parser form, so compose rejects it. + decimal = Unitsml::Unit.new("m", Unitsml::Number.new("0.5")) + expect { Unitsml.compose(units: [decimal]) } .to raise_error(Unitsml::Errors::InvalidPowerError) end @@ -1266,4 +1268,57 @@ def mutable_parts(formula) /Invalid Number power/) end end + + describe "builder gap fixes" do + let(:original) { Unitsml::Unit.new("W") * Unitsml::Unit.new("m") } + let(:bad_number) { Unitsml::Number.new("abc") } + + { + name: "x", quantity_reference: "radiance", multiplier: "x" + }.each do |setter, value| + it "gives ##{setter} its own copy of the terms" do + before = original.to_xml + copy = original.public_send(setter, value) + copy.value.last.unit_name = "s" + expect(original.to_asciimath).to eq("W*m") + expect(original.to_xml).to eq(before) + end + end + + it "rejects a non-numeric Number power on construction" do + expect { Unitsml::Unit.new("m", bad_number) } + .to raise_error(Unitsml::Errors::InvalidPowerError, + /Invalid Number power/) + expect { Unitsml::Dimension.new("dim_L", bad_number) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + it "rejects a non-numeric power via the setter and inside a fence" do + expect { Unitsml::Unit.new("m").power_numerator = bad_number } + .to raise_error(Unitsml::Errors::InvalidPowerError) + fenced = Unitsml::Fenced.new("(", bad_number, ")") + expect { Unitsml::Unit.new("m", fenced) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + %w[2 -1 0 1/2 -1/2 1//2 1/-2 0.5 2.0 -1.5].each do |raw| + it "still stores the library's exponent form #{raw.inspect}" do + expect { Unitsml::Unit.new("m", Unitsml::Number.new(raw)) } + .not_to raise_error + end + end + + %w[m^1/2 m^1//2 m^((1/2)) m^((-1)) sqrt(m) sqrt(Hz) m^0 m*m*s + kg*m^2*s^-2].each do |text| + it "still renders the parsed #{text.inspect}" do + expect { Unitsml.parse(text).to_xml }.not_to raise_error + end + end + + it "keeps a zero power at zero when dividing" do + formula = Unitsml::Unit.new("s") / Unitsml::Unit.new("m", 0) + expect(formula.to_asciimath).to eq("s/m^0") + expect(formula.norm_text).to eq("s*m^(0)") + end + end end From ec973df01c8fc00fac19537980a4f0f2e08ec7d2 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Thu, 5 Nov 2026 16:36:01 +0500 Subject: [PATCH 2/2] Document metadata, quantity lookup, sqrt text and dimension quantities State that composing further drops metadata (parsed comma metadata too), that an unknown quantity: is skipped rather than raised, that norm_text writes a square root as its exponent, and how to build a dimension quantity. --- docs/README.adoc | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/README.adoc b/docs/README.adoc index a16f06c..bd9cf95 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -182,7 +182,10 @@ Unitsml::Unit.new("m", prefix: "k") # km The same composition can be written as a fluent chain — `#unit`/`#dimension` are sugar over `*` (`unit("m", -1)` == `* Unit.new("m", -1)`), and `#quantity_reference`/`#name`/`#multiplier` attach metadata. Metadata must come -last, since each chained term builds a fresh `Formula`: +last. Each chained term, and each `*` or `/`, builds a fresh `Formula` that does +not carry earlier metadata. The same holds for the comma metadata of a parsed +formula: `Unitsml.parse("W*m^-1, quantity: radiance") * Unitsml::Unit.new("sr")` +has no quantity. [source,ruby] ---- @@ -275,14 +278,20 @@ formula.to_asciimath(multiplier: :space) # => "W m^-1" formula.to_asciimath(multiplier: "·") # => "W·m^-1" ---- -`Unitsml.compose` and the fluent chain validate every reference, power, prefix -and metadata value and raise a `Unitsml::Errors::*` (all subclasses of -`Unitsml::Errors::BaseError`) for anything they cannot resolve: an unknown unit, -dimension or prefix, a units/dimensions mix, an empty composition, a power the -parser cannot express (a decimal exponent — use a `Rational`), or a non-string -`name`/`multiplier`. `Unit.new`/`Dimension.new` validate their own reference, -prefix and power on construction too, so an unresolvable value raises -immediately whichever surface you use. +`Unitsml.compose` and the fluent chain validate every reference, power and +prefix, and the `name` and `multiplier` metadata. They raise a +`Unitsml::Errors::*` (all subclasses of `Unitsml::Errors::BaseError`) for +anything they cannot resolve: an unknown unit, dimension or prefix, a +units/dimensions mix, an empty composition, a power the parser cannot express +(a decimal exponent — use a `Rational`), or a non-string `name`/`multiplier`. +`Unit.new`/`Dimension.new` validate their own reference, prefix and power on +construction too, so an unresolvable value raises immediately whichever surface +you use. A `Unitsml::Number` power must hold a number (`Number.new("abc")` +raises). + +The `quantity` metadata is looked up when `to_xml` runs, by NIST id (such as +`"NISTq1"`) or by English name (such as `"radiance"`). A value that matches no +quantity is not an error: `to_xml` leaves out the `` element. ==== Text of composed units @@ -291,8 +300,10 @@ A composed `Formula` keeps its original term tree for rendering, so `to_latex`, Its `norm_text` is a flat list of the unit leaves joined by `*`, with each stored power written in parentheses. This text parses back into the same units, -prefixes and powers. A single division of two plain units keeps the familiar -form. +prefixes and power values. A single division of two plain units keeps the +familiar form. A square root is written as its exponent, so a composed +`sqrt(Hz)` becomes `Hz^(5/10)`: the parsed text has the same power, but renders +without the root sign. [source,ruby] ---- @@ -310,6 +321,9 @@ The `norm_text` of a parsed `Formula` is unchanged. is parsed. A `Unit` or `Formula` is used without parsing. The older `unit_expression:` keyword still accepts a string. Give only one of the two keywords. Without a unit, or with `unit: nil`, the quantity is a pure number. +`unit:` does not take a bare `Unitsml::Dimension`; build a dimension quantity +from a `Formula`, for example +`unit: Unitsml.compose(dimensions: ["dim_L"])`. [source,ruby] ----