-
Notifications
You must be signed in to change notification settings - Fork 1
Add programmatic composite unit builder #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
suleman-uzair
wants to merge
18
commits into
main
Choose a base branch
from
feature/composite_units_builder
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
550bd55
Add programmatic composite unit builder
a6a6c0c
feat(compose): add dimensions keyword and harden the builder
653339f
feat(compose): add fluent chaining builder (#unit/#dimension)
735cd5d
fix(compose): validate the fluent #dimension reference
8fbb0d4
fix(compose): validate fluent-chain ref/power/prefix/multiplier
574b8f3
fix(compose): validate the name metadata like multiplier
9358a51
docs: note fluent-chain + metadata validation in compose section
ae3d2f0
refactor(compose): resolve units by symbol id only (drop short names)
e680e72
docs(compose): document the default * separator and multiplier: control
bf10d02
fix(compose): address Copilot review findings
b6ec6d2
feat(compose): validate power_numerator type in Unit and Dimension
f2e0b9d
fix(compose): storage-specific message for unsupported power types
3d5f80b
feat(compose): division keeps "/", explicit extenders, input hardening
309352b
fix(compose): store power_numerator as a Number; address Copilot review
d1251b9
docs(compose): note Unit.new/Dimension.new validate on construction
ce2c108
fix(compose): harden power/prefix/quantity/extender edges (Codex + Co…
c6970de
fix(compose): harden Prefix-object name via safe_string (Codex re-rev…
cd038bc
docs(compose): clarify validation comments
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Unitsml | ||
| # Programmatic composite-unit construction: the operator DSL / fluent chain | ||
| # (Composable), the Unitsml.compose keyword form (Composite), and the shared | ||
| # assembly (Builder). The raw-input validators below are shared by BOTH the | ||
| # keyword form and the fluent chain so a bad reference/power/prefix fails fast | ||
| # with the same Errors::* class no matter which surface is used. | ||
| module Compose | ||
| autoload :Builder, "unitsml/compose/builder" | ||
| autoload :Composable, "unitsml/compose/composable" | ||
| autoload :Composite, "unitsml/compose/composite" | ||
| autoload :TermTree, "unitsml/compose/term_tree" | ||
|
|
||
| module_function | ||
|
|
||
| # A unit reference just needs to be non-blank; Unit.new then fail-fasts on | ||
| # an unresolvable (or dim_*) name. Rejects nil/"" (which Unit.new would let | ||
| # through as its internal empty sentinel and crash at render). | ||
| def unit_ref(reference) | ||
| string = reference.to_s | ||
| return string unless string.strip.empty? | ||
|
|
||
| raise Errors::UnknownUnitError.new(value: reference) | ||
| end | ||
|
|
||
| # Validate a dimension reference (Dimension.new does not), so the keyword | ||
| # form and the fluent #dimension chain both fail fast with the same | ||
| # Errors::UnknownDimensionError instead of crashing at render. | ||
|
suleman-uzair marked this conversation as resolved.
Outdated
|
||
| def dimension_ref(reference) | ||
| string = reference.to_s | ||
| return string if Unitsdb.dimensions.parsables.key?(string) | ||
|
|
||
| raise Errors::UnknownDimensionError.new(value: reference) | ||
| end | ||
|
|
||
| # A prefix supplied as a Prefix object is reduced to its name so Unit.new | ||
| # validates it; other values (String/Symbol/nil) pass through. | ||
| def prefix_ref(prefix) | ||
| prefix.is_a?(Prefix) ? prefix.prefix_name : prefix | ||
| end | ||
|
|
||
| # A Number power must still be a parser-valid exponent (integer or n/m, | ||
| # optionally signed / double-slashed); a decimal ("0.5") or garbage ("abc") | ||
| # has no parsed equivalent and is rejected. Other types (Integer/Rational/ | ||
| # Float/nil) are validated by Builder during assembly. | ||
| def power(value) | ||
| return value unless value.is_a?(Number) | ||
| return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) | ||
|
|
||
| raise Errors::InvalidPowerError.new(value: value, | ||
| reason: :invalid_number) | ||
| end | ||
|
suleman-uzair marked this conversation as resolved.
|
||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Unitsml | ||
| module Compose | ||
| # Assembles composed root Formulas for both the operator DSL (build_product) | ||
| # and the keyword form (from_terms). Each leaf is REBUILT with its final | ||
| # power computed up front (coerce, negate for division, drop an | ||
| # inversion-produced ^1); the builder only READS operands and constructs | ||
| # fresh objects, so it can never mutate a caller's operand and never runs an | ||
| # in-place negation on shared state. Branch structure is rebuilt via | ||
| # TermTree; leaf text lives on Unit/Dimension. | ||
| module Builder | ||
| class << self | ||
| # Combine a left term list with the right operand's terms, interleaving | ||
| # multiplication. `/` inverts every right-hand leaf's power. | ||
| def build_product(left_terms, right_terms, operator) | ||
| left = build_terms(left_terms, invert: false) | ||
| right = build_terms(right_terms, invert: operator == "/") | ||
| build_root_formula(left + [mul_extender] + right) | ||
| end | ||
|
|
||
| # Build a root Formula from an ordered list of unit/dimension operands, | ||
| # interleaving multiplication between them. | ||
| def from_terms(operands, quantity: nil, name: nil, multiplier: nil) | ||
| terms = interleave(build_terms(operands, invert: false)) | ||
| build_root_formula(terms, build_metadata(quantity, name, multiplier)) | ||
| end | ||
|
|
||
| # Return a Formula carrying the given render metadata (quantity/name/ | ||
| # multiplier). A Formula gets a copy with merged metadata; a lone | ||
| # Unit/Dimension is wrapped into a root Formula. Backs the fluent | ||
| # #quantity/#name/#multiplier chain methods. | ||
| def attach_metadata(node, **extras) | ||
| return from_terms([node], **extras) unless node.is_a?(Formula) | ||
|
|
||
| added = build_metadata(extras[:quantity], extras[:name], | ||
| extras[:multiplier]) | ||
| copy = node.dup | ||
| copy.explicit_value = merge_metadata(node.explicit_value, added) | ||
| copy | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def merge_metadata(existing, added) | ||
| merged = (existing || {}).merge(added || {}) | ||
| merged.empty? ? nil : merged | ||
| end | ||
|
|
||
| def build_root_formula(terms, metadata = nil) | ||
| reject_mixed_terms!(terms) | ||
| text = terms_text(terms) | ||
| Formula.new(terms, explicit_value: metadata, root: true, | ||
| orig_text: text, norm_text: text) | ||
| end | ||
|
|
||
| # --- leaf reconstruction (via TermTree.map_leaves) ----------------- | ||
|
|
||
| # Rebuild every operand: map_leaves reconstructs branch wrappers and | ||
| # each Unit/Dimension leaf is copied with a freshly built final power | ||
| # (and a duplicated prefix). Non-leaf terms (Extender/Number) plain-dup. | ||
| def build_terms(terms, invert:) | ||
| TermTree.map_leaves(terms) { |leaf| rebuild_leaf(leaf, invert) } | ||
| end | ||
|
|
||
| def rebuild_leaf(leaf, invert) | ||
| return leaf.dup unless leaf.is_a?(Unit) || leaf.is_a?(Dimension) | ||
|
|
||
| copy = leaf.dup | ||
| copy.power_numerator = final_power(leaf.power_numerator, invert) | ||
| copy.prefix = leaf.prefix.dup if leaf.is_a?(Unit) && leaf.prefix | ||
| copy | ||
| end | ||
|
|
||
| # The leaf's final exponent as a fresh Number (or nil). For division the | ||
| # exponent is negated; a negation that resolves to +1 (dividing by a | ||
| # ^-1 term) renders as no exponent, matching the parser, so it is | ||
| # dropped. A non-inverted explicit ^1 is preserved. | ||
| def final_power(power, invert) | ||
| raw = power_string(power) | ||
| return raw && Number.new(raw) unless invert | ||
|
|
||
| negated = negate_string(raw) | ||
| negated == "1" ? nil : Number.new(negated) | ||
| end | ||
|
|
||
| # A builder-supplied power as a parser-style exponent string (or nil): | ||
| # 1 -> "1", 1/2 -> "1/2", 2.0 -> "2". A non-integer Float is rejected | ||
| # (the parser has no decimal exponent); an existing Number is read by | ||
| # value, never shared. | ||
| def power_string(power) | ||
| case power | ||
| when nil then nil | ||
| when Number then power.raw_value | ||
| when Integer then power.to_s | ||
| when Rational then rational_string(power) | ||
| when Float then float_string(power) | ||
| else raise Errors::InvalidPowerError.new(value: power) | ||
| end | ||
| end | ||
|
|
||
| def float_string(power) | ||
| unless power.finite? && power == power.to_i | ||
| raise Errors::InvalidPowerError.new(value: power, | ||
| reason: :non_integer_float) | ||
| end | ||
|
|
||
| power.to_i.to_s | ||
| end | ||
|
|
||
| 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. | ||
| def negate_string(raw) | ||
| return "-1" if raw.nil? | ||
|
|
||
| raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}" | ||
| end | ||
|
suleman-uzair marked this conversation as resolved.
|
||
|
|
||
| # --- unit/dimension mixing guard (via TermTree.each_leaf) ---------- | ||
|
|
||
| # Units and dimensions cannot share one expression (the XML serializer | ||
| # would drop the unit block); a single walk collects the leaf kinds. | ||
| def reject_mixed_terms!(terms) | ||
| kinds = leaf_kinds(terms) | ||
| return unless kinds.include?(Unit) && kinds.include?(Dimension) | ||
|
|
||
| raise Errors::MixedTermsError | ||
| end | ||
|
|
||
| def leaf_kinds(terms) | ||
| kinds = [] | ||
| TermTree.each_leaf(terms) do |leaf| | ||
| kinds << leaf.class if leaf.is_a?(Unit) || leaf.is_a?(Dimension) | ||
| end | ||
| kinds | ||
| end | ||
|
|
||
| # --- text synthesis ------------------------------------------------ | ||
|
|
||
| # Reconstruct parser-style source text; leaf text lives on the domain | ||
| # objects (Unit/Dimension#xml_postprocess_name) and branches wrap their | ||
| # inner text. Recurses over the same tree shape as TermTree. | ||
| def terms_text(term) | ||
| case term | ||
| when Array then term.map { |child| terms_text(child) }.join | ||
| when Unit, Dimension then term.xml_postprocess_name | ||
| when Extender then term.symbol | ||
| else branch_text(term) | ||
| end | ||
| end | ||
|
|
||
| def branch_text(term) | ||
| case term | ||
| when Fenced | ||
| "#{term.open_paren}#{terms_text(term.value)}#{term.close_paren}" | ||
| when Sqrt then "sqrt(#{terms_text(term.value)})" | ||
| when Formula then terms_text(term.value) | ||
| else "" | ||
| end | ||
| end | ||
|
|
||
| # --- small helpers ------------------------------------------------- | ||
|
|
||
| def interleave(operands) | ||
| operands.each_with_index.flat_map do |operand, index| | ||
| index.zero? ? [operand] : [mul_extender, operand] | ||
| end | ||
| end | ||
|
|
||
| def build_metadata(quantity, name, multiplier) | ||
| validate_name!(name) | ||
| validate_multiplier!(multiplier) | ||
| metadata = { quantity: quantity, name: name, | ||
| multiplier: multiplier }.compact | ||
| metadata.empty? ? nil : metadata | ||
| end | ||
|
|
||
| # name is embedded directly into <UnitName>, so it must be a plain | ||
| # String/Symbol; a Hash/other would serialize as garbage. (quantity is | ||
| # resolved and dropped silently when unresolvable, so needs no guard.) | ||
| def validate_name!(name) | ||
| return if name.nil? || name.is_a?(String) || name.is_a?(Symbol) | ||
|
|
||
| raise Errors::InvalidUnitEntryError.new(value: name, field: :name) | ||
| end | ||
|
|
||
| # The multiplier is a render separator: nil, a String, or :space/ | ||
| # :nospace. Reject anything else at the compose boundary so a bad value | ||
| # fails as an Errors::* instead of leaking at render. | ||
| def validate_multiplier!(multiplier) | ||
| return if multiplier.nil? || multiplier.is_a?(String) | ||
| return if %i[space nospace].include?(multiplier) | ||
|
|
||
| raise Errors::InvalidUnitEntryError.new(value: multiplier, | ||
| field: :multiplier) | ||
| end | ||
|
|
||
| def mul_extender | ||
| Extender.new("*") | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.