diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 0a3300a..5bb5df7 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -12,6 +12,36 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version * Performance memoization for hot paths * Plugin system for custom validators and converters +=== Fixed +* `dcc:list` no longer parses as an empty `si:list`. The D-SI re-export was + overwriting the DCC `list` registration, so every list and the quantities + inside it were dropped on parse. +* `si:expandedUnc` and `si:coverageInterval` now survive a parse. Both are + members of the same `si:real` choice group and neither was mapped, so D-SI + v1 uncertainties were discarded. +* `dcc:mathml` now parses. It is a `dcc:xmlType` wrapper around `ml:math`, + not the `ml:math` element itself, so `formula.mathml` was always `nil`. + Known gap: `ml:ci/@xref` does not survive a round-trip. `Mml::V3::Ci` + has no `xref` attribute, so the value is lost on parse and never reaches + the output. Those xrefs bind the formula variables to their + `dcc:quantity/@id`. A round-tripped formula keeps its MathML and loses + those bindings, with no warning. The fix belongs in the `mml` gem, not + here. +* Decimal XML lists serialize as one space-separated element again. Each + value was being written as its own element containing a Ruby array + literal, e.g. `[0.72e-1]`. `NaN` + round-trips too, which every D-SI decimal type admits. +* `UncertaintyConsistency` accepts a single entry broadcast across every + value, and now checks `coverageFactorXMLList`, `coverageProbabilityXMLList` + and `distributionXMLList` rather than the uncertainty list alone. +* CSV export renders list values as plain decimals instead of `0.72e-1`. + +=== Changed +* *Breaking:* `formula.mathml` returns a `Dcc::V2::Mathml` / `Dcc::V3::Mathml` + wrapper instead of an `Mml::V3::Math`. The MathML tree moves one level + down, so callers now read `formula.mathml.math`. This matches the XSD, + where `dcc:mathml` is a `dcc:xmlType` wrapper. + == [0.1.0] — 2026-07-22 === Added diff --git a/TODO.complete/19-formula-extraction.md b/TODO.complete/19-formula-extraction.md index 5c7a980..db4074d 100644 --- a/TODO.complete/19-formula-extraction.md +++ b/TODO.complete/19-formula-extraction.md @@ -1,13 +1,6 @@ # 19 — Formula extraction and evaluation (P1) -**Status:** PENDING - -## Gaps -- None of the five files exist. `lib/dcc/extract/` contains only `file.rb` - and `ring.rb`. -- `lib/dcc/extract.rb:7` still autoloads `:Formula`, so referencing - `Dcc::Extract::Formula` raises `LoadError`. -- `spec/dcc/extract/formula_spec.rb` was removed — see phase 38. +**Status:** COMPLETED ## Goal Extract MathML `` formula trees from `dcc:formula`, build a Ruby AST, and evaluate numerically with BigDecimal precision. diff --git a/dcc.gemspec b/dcc.gemspec index 1958646..24e355e 100644 --- a/dcc.gemspec +++ b/dcc.gemspec @@ -36,7 +36,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.2.0" spec.add_dependency "base64" # not a default gem since Ruby 3.4 + spec.add_dependency "bigdecimal" spec.add_dependency "csv" # not a default gem since Ruby 3.4 + spec.add_dependency "forwardable" # stdlib, declared for the packaging guard spec.add_dependency "liquid" spec.add_dependency "lutaml-model", "~> 0.8" spec.add_dependency "mml", "~> 2.4.0" diff --git a/lib/dcc/base/formula.rb b/lib/dcc/base/formula.rb index 5e504ac..87e384e 100644 --- a/lib/dcc/base/formula.rb +++ b/lib/dcc/base/formula.rb @@ -5,7 +5,7 @@ module Dcc module Base # `dcc:formulaType` — formula expression. Contains `latex` string, - # `mathml` (a typed `Mml::V3::Math` model), or legacy `siunitx`. + # `mathml` (a `dcc:xmlType` wrapper around `ml:math`), or legacy `siunitx`. module Formula def self.included(klass) klass.class_eval do @@ -13,7 +13,7 @@ def self.included(klass) attribute :ref_id, :string attribute :ref_type, :string attribute :latex, :string - attribute :mathml, ::Mml::V3::Math + attribute :mathml, :mathml attribute :siunitx, :string xml do diff --git a/lib/dcc/base/mathml.rb b/lib/dcc/base/mathml.rb new file mode 100644 index 0000000..db397d2 --- /dev/null +++ b/lib/dcc/base/mathml.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "mml" + +module Dcc + module Base + # `dcc:mathml` — a `dcc:xmlType` wrapper carrying a single foreign-namespace + # child, which for a formula is `ml:math`. The MathML tree hangs below the + # wrapper rather than replacing it. + module Mathml + # rubocop:disable Metrics/MethodLength -- one lutaml mapping block, + # split the same way every sibling module in Dcc::Base is written. + def self.included(klass) + klass.class_eval do + attribute :id, :string + attribute :ref_id, :string + attribute :ref_type, :string + attribute :math, ::Mml::V3::Math + + xml do + namespace ::Dcc::Namespace::Dcc + element "mathml" + map_attribute "id", to: :id + map_attribute "refId", to: :ref_id + map_attribute "refType", to: :ref_type + map_element "math", to: :math + end + end + end + # rubocop:enable Metrics/MethodLength + end + end +end diff --git a/lib/dcc/cli/cli.rb b/lib/dcc/cli/cli.rb index 3e08e5a..fc67396 100644 --- a/lib/dcc/cli/cli.rb +++ b/lib/dcc/cli/cli.rb @@ -83,29 +83,24 @@ def convert(format, file) end desc "extract TARGET FILE", - "Extract embedded files or quantities from a DCC" + "Extract embedded files or formulae from a DCC" method_option :index, type: :numeric, desc: "Index of the file to extract" method_option :output, type: :string, banner: "PATH", desc: "Write extracted payload to PATH" method_option :ring, type: :string, enum: %w[administrativeData measurementResults comment document] + # `repeatable` and not `type: :array`: with an array type Thor lets a + # second `-v` overwrite the first, so `-v T=42 -v R0=1` would silently + # drop T. PTB's CLI accumulates, and so must this. + method_option :variable, type: :string, aliases: "-v", repeatable: true, + banner: "NAME=VALUE", + desc: "Override a formula variable (repeatable; NAME=V1,V2 for a list)" def extract(target, file) Cli.ensure_loaded! dcc = Dcc.parse(File.read(file)) case target - when "files" - files = ::Dcc::Extract::File.each(dcc) - if options[:ring] - files = files.select do |f| - f.ring.to_s == options[:ring] - end - end - if options[:index] - f = files[options[:index].to_i] || abort("No file at index #{options[:index]}") - output_to(f.data, options[:output], f.file_name) - else - ::Dcc::Cli::Formatters.print_files(files) - end + when "files" then extract_files(dcc) + when "formulae" then extract_formulae(dcc) else abort "Unknown extract target: #{target}" end @@ -161,6 +156,119 @@ def output_to(payload, path, file_name) $stdout.write(payload) end end + + def extract_files(dcc) + files = ::Dcc::Extract::File.each(dcc) + if options[:ring] + files = files.select { |f| f.ring.to_s == options[:ring] } + end + return ::Dcc::Cli::Formatters.print_files(files) unless options[:index] + + index = options[:index].to_i + file = files[index] || abort("No file at index #{index}") + output_to(file.data, options[:output], file.file_name) + end + end + + no_commands do + def extract_formulae(dcc) + variables = parse_variables(options[:variable]) + formulae = ::Dcc::Extract::Formula.call(dcc) + check_overrides(formulae, variables) + print_formulae(formulae, variables) + rescue ::Dcc::ExtractionError => e + abort e.message + end + + # The evaluator slices bindings down to the names a formula + # actually references, so a mistyped -v reaches nothing and the + # run still reports success with plausible default numbers. + # `decimal_or_abort` already aborts on a malformed -v; an + # override that lands nowhere is the same class of mistake. + def check_overrides(formulae, variables) + unknown = variables.keys - formulae.flat_map(&:variables) + return if unknown.empty? + + abort "No formula references #{unknown.join(', ')}" + end + end + + no_commands do + def parse_variables(assignments) + Array(assignments).each_with_object({}) do |assignment, variables| + name, values = split_assignment(assignment) + variables[name] = override_value(values) + end + end + + # `-v T=42` is a scalar and broadcasts across the document's lists; + # only `-v T=1,2` is a list, and a list has to match every other + # list's length. Wrapping a lone value in an array made `-v R0=1` + # a one-element list that collided with the document's own. + def override_value(values) + decimals = values.map { |v| decimal_or_abort(v) } + decimals.size == 1 ? decimals.first : decimals + end + end + + no_commands do + # `split(",", -1)` keeps trailing empty fields, so `T=42,` reaches + # validation instead of silently losing the empty entry. + def split_assignment(assignment) + name, values = assignment.split("=", 2) + if name.nil? || name.empty? || values.nil? || values.empty? + abort "#{assignment} is not a valid NAME=value" + end + + [name, values.split(",", -1)] + end + + # Validated here, at the command line, not where the value is used. + # The evaluator only coerces variables the formula references, so a + # bad `-v` for an unreferenced variable — or any `-v` against a + # document with no formulae — would otherwise pass in silence. + # + # Delegates rather than calling BigDecimal itself: a second + # coercion site is exactly the drift the single boundary exists to + # prevent, even when it starts out agreeing. + def decimal_or_abort(value) + ::Dcc::Extract::Formula::Quantity.decimal(value) + rescue ::Dcc::ExtractionError + abort "#{value} is not a valid decimal value" + end + end + + no_commands do + def print_formulae(formulae, variables) + return puts("No formulae found.") if formulae.empty? + + # `map`, deliberately, not `all?` with a block: `all?` + # short-circuits, and stopping at the first failure is exactly + # the bug this is fixing. + printed = formulae.map { |formula| print_formula(formula, variables) } + exit(1) unless printed.all? + end + end + + no_commands do + # Evaluates before printing the header. A header printed ahead + # of a failure leaves a truncated record on stdout that reads + # like a formula with no values. One formula that cannot be + # evaluated is reported on stderr and the rest still print. + def print_formula(formula, variables) + values = formula.evaluate(variables) + puts formula + values.each { |value| puts " #{value.to_s('F')}" } + true + rescue ::Dcc::ExtractionError => e + # `$stderr.puts`, not `warn`: Kernel#warn prints nothing when + # warnings are off, so `RUBYOPT=-W0` would leave the user with + # exit 1 and no explanation at all. The cop's own rationale — + # "to allow such output to be disabled" — is the thing this + # message must not permit. + $stderr.puts "#{formula}: #{e.message}" # rubocop:disable Style/StderrPuts + false + end end end end diff --git a/lib/dcc/convert/csv.rb b/lib/dcc/convert/csv.rb index 82f86bc..1bba536 100644 --- a/lib/dcc/convert/csv.rb +++ b/lib/dcc/convert/csv.rb @@ -101,7 +101,11 @@ def value_of(q) return reals.first.value.to_s if reals.any? lists = Array(q.real_list_xml_list) - return lists.first.value_xml_list.map(&:to_s).join("; ") if lists.any? + # Serialized rather than mapped over: BigDecimal#to_s renders 0.072 + # as "0.72e-1", which is not what the document said. + if lists.any? + return ::Dcc::Type::DecimalXmlList.serialize(lists.first.value_xml_list) + end "" end diff --git a/lib/dcc/diff.rb b/lib/dcc/diff.rb index ded5eed..985c782 100644 --- a/lib/dcc/diff.rb +++ b/lib/dcc/diff.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "bigdecimal" +require "date" require "lutaml/model" module Dcc @@ -12,6 +13,12 @@ module Diff autoload :Result, "dcc/diff/result" autoload :Change, "dcc/diff/change" + # Leaf types compared directly rather than descended into. Numeric covers + # BigDecimal and Date covers DateTime, so neither needs its own entry. + PRIMITIVE_TYPES = [ + ::String, ::Numeric, ::Symbol, ::Time, ::Date + ].freeze + class << self # @param a [Lutaml::Model::Serializable] # @param b [Lutaml::Model::Serializable] @@ -42,7 +49,7 @@ def collect_differences(a, b, path, changes) # Both primitives - compare values if primitive?(a) || primitive?(b) - if a != b + unless equivalent?(a, b) changes << Change.new(path: path, kind: :change, before: a, after: b) end @@ -67,14 +74,35 @@ def collect_differences(a, b, path, changes) end end + # "Did this document change?" is not "are these numbers equal?". + # `BigDecimal("NaN") == BigDecimal("NaN")` is false by IEEE, and + # `Values#==` honours that, so two documents carrying the same + # not-measured entry would otherwise report a change to itself. + def equivalent?(a, b) + values = ::Dcc::Type::DecimalXmlList::Values + return a == b unless a.is_a?(values) && b.is_a?(values) + + a.size == b.size && a.to_a.zip(b.to_a).all? { |x, y| same_decimal?(x, y) } + end + + # `Values.new` takes whatever it is handed, so a list assigned in Ruby + # rather than parsed can hold anything. Only BigDecimals are asked + # whether they are NaN. + def same_decimal?(left, right) + return true if left == right + + left.is_a?(::BigDecimal) && right.is_a?(::BigDecimal) && + left.nan? && right.nan? + end + def primitive?(value) - return true if value.nil? - return true if value.is_a?(::String) || value.is_a?(::Numeric) || value.is_a?(::Symbol) - return true if [true, false].include?(value) - return true if value.is_a?(::Time) || value.is_a?(::Date) || value.is_a?(::DateTime) - return true if value.is_a?(::BigDecimal) + return true if value.nil? || [true, false].include?(value) + return false if value.is_a?(::Array) + return true if PRIMITIVE_TYPES.any? { |type| value.is_a?(type) } - false + # Anything we cannot descend into is compared with `==`, which covers + # value objects such as decimal XML lists. + !value.class.respond_to?(:attributes) end def summarize(node) diff --git a/lib/dcc/extract/formula.rb b/lib/dcc/extract/formula.rb new file mode 100644 index 0000000..3df6fda --- /dev/null +++ b/lib/dcc/extract/formula.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "bigdecimal" + +module Dcc + module Extract + # `Dcc::Extract::Formula` pulls content-MathML formulae out of a parsed + # DCC. Each `dcc:formula` that carries a `dcc:mathml` becomes one `Ast`, + # bound to the `dcc:quantity` values sitting in the same `dcc:data` + # block — that is where PTB puts a formula's parameters and inputs. + module Formula + autoload :Ast, "dcc/extract/formula/ast" + autoload :Evaluator, "dcc/extract/formula/evaluator" + autoload :Parser, "dcc/extract/formula/parser" + autoload :Quantity, "dcc/extract/formula/quantity" + + class << self + # @param dcc [Lutaml::Model::Serializable] a parsed DCC. + # @return [Array] in document order. + def call(dcc) + data_blocks(dcc).flat_map { |data| formulae_in(data) } + end + + private + + def formulae_in(data) + bindings = bindings_in(data) + data.formula.filter_map do |formula| + math = formula.mathml&.math + Parser.call(math, bindings: bindings) if math + end + end + + # Parameters live in `dcc:data/dcc:list/dcc:quantity`, but the XSD + # also allows a bare `dcc:quantity` directly under `dcc:data`. + def bindings_in(data) + quantities_in(data).each_with_object({}) do |quantity, bindings| + next unless quantity.id + + value = quantity_value(quantity) + bindings[quantity.id] = value if value + end + end + + # `dcc:listType` is recursive, and both it and `dcc:dataType` + # expose `quantity` and `list`. Walking only the first level of + # lists loses every binding nested below it. + def quantities_in(container) + container.quantity + + container.list.flat_map { |list| quantities_in(list) } + end + + # `si:value` is declared `attribute :value, :string` in + # `lib/dcc/si/base/real.rb` — completely unvalidated, so a real + # document can carry "banana", "Infinity", or nothing at all. Hand + # it to Quantity untouched and let the one coercion boundary decide; + # calling `BigDecimal` here would leak a raw ArgumentError out of a + # public extraction call. + # + # Both `real` and `real_list_xml_list` are collection attributes on + # `Dcc::V3::Quantity`, so both need `.first`. + def quantity_value(quantity) + real = quantity.real.first + return scalar(real) if real + + list = quantity.real_list_xml_list.first + list_values(list) if list + end + + def scalar(real) + Quantity.new(value: real.value, unit: real.unit, uncertainty: nil) + end + + def list_values(list) + Quantity.new( + value: list.value_xml_list, + unit: list.unit_xml_list, + uncertainty: nil, + ) + end + + # Same shape as `Dcc::Extract::File.each`: walk the model graph and + # collect every `dcc:data` node in document order. + def data_blocks(dcc) + blocks = [] + walk(dcc, ::Set.new, blocks) + blocks + end + + # `Set#add?` returns nil when already present, so it doubles as the + # cycle guard for the self-referential list types. + def walk(node, visited, blocks) + return unless node.is_a?(::Lutaml::Model::Serializable) + return unless visited.add?(node.object_id) + + blocks << node if data?(node) + node.class.attributes.each_key do |attr_name| + Array(node.public_send(attr_name)).each do |child| + walk(child, visited, blocks) + end + end + end + + def data?(node) + node.class.name.end_with?("::Data") + end + end + end + end +end diff --git a/lib/dcc/extract/formula/ast.rb b/lib/dcc/extract/formula/ast.rb new file mode 100644 index 0000000..683148d --- /dev/null +++ b/lib/dcc/extract/formula/ast.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "bigdecimal" + +module Dcc + module Extract + module Formula + # One extracted formula: its declared name, the lambda's bound + # variables, the expression tree, and the variable bindings found + # alongside it in the DCC. + class Ast + attr_reader :name, :bound_variables, :body, :bindings + + # @param name [String, nil] the `ml:declare` function name (e.g. "R"). + # @param bound_variables [Array] the `ml:lambda` bvar names. + # @param body [Object] the expression tree root. + # @param bindings [Hash{String => Quantity}] variable values + # found in the DCC. + def initialize(name:, bound_variables:, body:, bindings:) + @name = name + @bound_variables = bound_variables + @body = body + @bindings = bindings + end + + # @param overrides [Hash{String => BigDecimal, Array}] + # @return [Array] + def evaluate(overrides = {}) + Evaluator.call(self, overrides) + end + + # Bound and free variables are both reported: an override may + # legitimately name either. Repeats are left in — the evaluator + # slices with these names and the CLI subtracts them, and neither + # cares how often a name appears. + # + # @return [Array] every variable the body mentions. + def variables + names_in(body) + end + + def to_s + "#{name || '(anonymous)'}(#{bound_variables.join(', ')})" + end + + # `Formula.call` hands `Ast` objects back as public API, so callers + # legitimately build and hold nodes. Number therefore coerces + # through the same boundary as Quantity. + Number = ::Data.define(:value) do + def initialize(value:) + super(value: Quantity.decimal(value)) + end + end + + Variable = ::Data.define(:name) + BoundVariable = ::Data.define(:name) + + # The symbols the evaluator can dispatch on. Its `case` statements + # have no `else`, so an unrecognised name would evaluate to nil — + # a silent wrong answer for a constant, a NoMethodError for an + # operator. Rejecting at construction keeps both out of the API. + CONSTANTS = %i[pi e].freeze + OPERATORS = %i[+ - * / ** root exp ln log sin cos tan abs].freeze + + Constant = ::Data.define(:name) do + def initialize(name:) + Constant.check_name(name) + super + end + + def self.check_name(name) + return if CONSTANTS.include?(name) + + raise ::Dcc::ExtractionError, + "#{name} is not a formula constant, expected one of " \ + "#{CONSTANTS.join(', ')}" + end + end + + # How many operands each operator accepts; anything absent is a + # single-argument function. Without this, `` with one + # operand quietly computes `x ** x` and with three quietly drops + # the middle one — wrong answers rather than errors. + ARITY = { + "+": (1..), + "*": (1..), + "-": (1..2), + "/": (2..2), + "**": (2..2), + root: (1..2), + }.freeze + + UNARY = (1..1) + + # Checked in the constructor so a directly-built AST is held to the + # same contract as a parsed one. + Apply = ::Data.define(:operator, :operands) do + def initialize(operator:, operands:) + Apply.check_operator(operator) + Apply.check_arity(operator, operands) + super(operator: operator, operands: operands.dup.freeze) + end + + def self.check_operator(operator) + return if OPERATORS.include?(operator) + + raise ::Dcc::ExtractionError, + "#{operator} is not a formula operator, expected one of " \ + "#{OPERATORS.join(', ')}" + end + + def self.check_arity(operator, operands) + allowed = ARITY.fetch(operator, UNARY) + return if allowed.cover?(operands.size) + + expected = allowed.end ? allowed.to_s : "#{allowed.begin} or more" + raise ::Dcc::ExtractionError, + "#{operator} takes #{expected} operand(s) in formula, " \ + "got #{operands.size}" + end + end + + private + + # Walks the tree for `variables`. Private: the tree shape is not + # part of what this gem promises. + def names_in(node) + case node + in (Variable | BoundVariable) => ref then [ref.name] + in Apply(operands:) then operands.flat_map { |o| names_in(o) } + else [] + end + end + end + end + end +end diff --git a/lib/dcc/extract/formula/evaluator.rb b/lib/dcc/extract/formula/evaluator.rb new file mode 100644 index 0000000..8df71f5 --- /dev/null +++ b/lib/dcc/extract/formula/evaluator.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +require "bigdecimal" +require "bigdecimal/math" + +module Dcc + module Extract + module Formula + # Evaluates a `Formula::Ast` numerically. + # + # Only variables the formula actually references take part: + # extraction hands over every identified quantity in the data block, + # and letting an unreferenced sibling list drive the result width + # would fail valid formulae. Referenced list variables must agree on + # length and combine element-wise, so the result is always an array. + # + # PRECISION applies to inexact operations only — `/`, `root`, `exp`, + # `ln`, `log`, the trig functions, non-integer `**` and negative + # integer `**`. Addition, subtraction, multiplication and + # non-negative integer powers stay exact. + # + # Only the long-stable `BigMath` subset is used (`sqrt`, `exp`, + # `log`, `sin`, `cos`, `PI`, `E`) — `BigMath.tan` and + # `BigMath.log10` are recent additions and `bigdecimal` ships as a + # bundled gem from Ruby 3.4, so the version we get is not ours to + # assume. + module Evaluator + PRECISION = 34 + TEN = BigDecimal(10) + ZERO_DIVISION = "Division by zero in formula" + + # A cost ceiling, not a correctness one. Three things in here + # scale with the size of a value, and `BigDecimal#precision` + # counts digits across the exponent span, so one limit covers + # all three: + # + # * BigMath reduces a trigonometric argument before it can + # answer, and that is linear in the exponent — sin(1e1000) + # is 1ms, sin(1e100000) 0.74s, sin(1e1000000) 8.6s. + # * exact `**` and `*` allocate one digit at a time, and the + # exponent does not constrain that at all: 0.99999999999 + # raised to 999 twice keeps exponent 0 while going from 11 + # digits to eleven million. + # * the printed value — `to_s("F")` of 1e1000000 is a megabyte. + # + # Nothing real comes near it: exp(1000) is 435, tan(pi/2) is 34, + # 2**1000 is 302, and IEEE double tops out at 1e308. + # + # `precision` has been on BigDecimal since 3.0.0, well under the + # gem's Ruby floor of 3.2. + MAX_DIGITS = 1_000 + + class << self + # @param ast [Dcc::Extract::Formula::Ast] + # @param overrides [Hash{String => Object}] + # @return [Array] + def call(ast, overrides = {}) + env = environment(ast, overrides) + Array.new(width(env)) do |index| + evaluate(ast.body, scope(env, index)) + end + end + + private + + # Values stay wrapped in a Quantity through the whole environment + # so `list?` survives. Flattening to a bare array here would make + # a genuine one-element list indistinguishable from a scalar, and + # it would then broadcast against a longer list instead of + # raising a length mismatch. + def environment(ast, overrides) + ast.bindings.merge(overrides) + .slice(*ast.variables) + .to_h { |name, value| [name, sized(name, quantify(value))] } + end + + # Only the referenced slice is checked. `Formula.bindings_in` + # builds a Quantity for every identified quantity in the data + # block before it looks at the formulae, so refusing an + # oversized one at coercion would take down every other + # formula in the document over a value none of them uses. + def sized(name, quantity) + subject = "Formula variable '#{name}'" + numbers = quantity.values + numbers.each { |value| check_size(value, subject) } + quantity + end + + # A Quantity coerced every value in its constructor, so its + # contents are already finite BigDecimals. A raw override has had + # no such treatment, so it goes through the same constructor. + def quantify(value) + return value if value.is_a?(Quantity) + + Quantity.new(value: value) + end + + # Only list variables set the width; scalars broadcast. Every + # list must agree, whatever its length — a one-element list is + # still a list. + def width(env) + lists = env.select { |_, quantity| quantity.list? } + distinct = lists.each_value.map { |q| q.values.size }.uniq + check_lengths(distinct, lists.keys) + + distinct.first || 1 + end + + # Refuses both ways a referenced list can make a width + # meaningless. An empty one used to give width zero, so + # `Array.new(0)` never evaluated the body at all: no values, + # no error, exit 0 — and a genuinely missing variable went + # unreported with it, because nothing ever looked one up. + def check_lengths(distinct, names) + if distinct.size > 1 + raise ::Dcc::ExtractionError, + "Formula variables have mismatched list lengths: " \ + "#{distinct.sort.inspect}" + end + return unless distinct == [0] + + raise ::Dcc::ExtractionError, + "Formula variables have empty value lists: " \ + "#{names.sort.inspect}" + end + + def scope(env, index) + env.transform_values do |quantity| + quantity.list? ? quantity.values[index] : quantity.value + end + end + + # Bound and free variables resolve identically; the distinction + # matters to the parser, not here. They share one clause via + # `(A | B) => ref`, which binds the node itself — `A(name:) | + # B(name:)` is a syntax error ("duplicated variable name"). + def evaluate(node, scope) + case node + in Ast::Number(value:) then check_size(value, "A formula number") + in Ast::Constant(name:) then constant(name) + in (Ast::Variable | Ast::BoundVariable) => ref + lookup(ref.name, scope) + in Ast::Apply(operator:, operands:) + operate(operator, operands.map { |o| evaluate(o, scope) }) + end + end + + # Needs no size check: BigMath.PI(34) and E(34) are 34 digits + # by construction, so a constant cannot reach the ceiling. + def constant(name) + case name + when :pi then BigMath.PI(PRECISION) + when :e then BigMath.E(PRECISION) + end + end + + def lookup(name, scope) + scope.fetch(name) do + raise ::Dcc::ExtractionError, + "No value for formula variable '#{name}'" + end + end + + # Every computed value is checked before it can become another + # operand. Guarding inputs alone is not enough: `exp(1e100)` is + # Infinity and `sin(Infinity)` is NaN from finite inputs, and a + # non-finite operand then poisons everything above it. A huge but + # finite result still passes. + def operate(operator, args) + checked(dispatch(operator, args), operator) + end + + # Every value the evaluator produces goes through here — the + # answer of an operation and every intermediate inside an + # n-ary fold alike. Checking only the answer let + # 1.25e998 * 1.25e998 * 8e-999 * 8e-999 run the accumulator up + # to an exponent of 1997 and land back on exactly 1 unnoticed. + def checked(value, operator, role = "result of") + check_size(finite(value, operator), "The #{role} #{operator}") + end + + def check_size(value, subject) + return value if value.precision <= MAX_DIGITS + + raise ::Dcc::ExtractionError, + "#{subject} exceeds the formula limit of " \ + "#{MAX_DIGITS} digits" + end + + def finite(result, operator) + return result if result.finite? + + raise ::Dcc::ExtractionError, + "#{operator} produced a non-finite result in formula" + end + + # Split by shape: n-ary algebraic operators fold over their + # operands, the rest are single-argument functions. + def dispatch(operator, args) + case operator + when :+, :*, :-, :/ then fold(operator, args) + when :** then power(args.first, args.last) + when :root then root(args.first, args[1]) + else function(operator, args.first) + end + end + + # Folds through `checked` so an intermediate cannot exceed the + # ceiling on its way to an answer that does not. + # + # `Array#sum` seeds with 0 and that seed is observable — + # `[-0].sum` is 0.0 where `[-0].reduce(:+)` is -0.0 — so `+` + # folds from the same seed and no printed value moves. + def fold(operator, args) + return -args.first if operator == :- && args.size == 1 + + operands = operator == :+ ? [BigDecimal(0), *args] : args + operands.reduce do |left, right| + checked(step(operator, left, right), operator) + end + end + + def step(operator, left, right) + case operator + when :+ then left + right + when :* then left * right + when :- then left - right + else divide(left, right) + end + end + + def function(operator, arg) + case operator + when :exp then BigMath.exp(arg, PRECISION) + when :ln then ln(arg) + when :log then divide(ln(arg), ln(TEN)) + when :abs then arg.abs + else trigonometric(operator, arg) + end + end + + def trigonometric(operator, arg) + case operator + when :sin then BigMath.sin(arg, PRECISION) + when :cos then BigMath.cos(arg, PRECISION) + when :tan then tan(arg) + end + end + + # BigDecimal returns Infinity/NaN for a zero divisor rather than + # raising, so guard the input. + def divide(numerator, denominator) + raise ::Dcc::ExtractionError, ZERO_DIVISION if denominator.zero? + + numerator.div(denominator, PRECISION) + end + + def tan(value) + divide(BigMath.sin(value, PRECISION), + BigMath.cos(value, PRECISION)) + end + + def ln(value) + if value <= 0 + raise ::Dcc::ExtractionError, + "Logarithm of a non-positive value in formula" + end + + BigMath.log(value, PRECISION) + end + + # Non-negative integer exponents stay exact; everything else goes + # through the explicit-precision path. `0 ** 0.5` is 0, not an + # error, so zero is handled before the ln guard can reject it. + def power(base, exponent) + return integer_power(base, exponent.to_i) if exponent.frac.zero? + return zero_to_the(exponent) if base.zero? + if base.negative? + raise ::Dcc::ExtractionError, + "Cannot raise a negative base to a fractional power " \ + "in formula" + end + + scaled = checked(exponent * ln(base), :**, "intermediate in") + BigMath.exp(scaled, PRECISION) + end + + # `**` chooses its own precision, so a negative exponent is + # inexact (`3 ** -1` gives 32 digits, not 34). Route it through + # `divide`. + def integer_power(base, exponent) + check_power_size(base, exponent) + return base**exponent unless exponent.negative? + raise ::Dcc::ExtractionError, ZERO_DIVISION if base.zero? + + divide(BigDecimal(1), base**-exponent) + end + + # The exact path allocates the whole number before any result + # check can see how big it got, so the size is predicted here + # instead. `2 ** 1e8` takes 2.7s and `2 ** 1e9` 25s, and + # `(0.99999999999 ** 999) ** 999` needs eleven million digits + # while its exponent never leaves zero. + # + # `precision * |exponent|` is an upper bound, not the exact + # size, so it refuses some powers that would have fitted: + # anything with an integer exponent past MAX_DIGITS, however + # small the base (`2 ** 1001` really lands on 302 digits), and + # `1 ** huge`, which costs nothing. Predicting exactly means + # hand-rolling capped exponentiation, and a subtle bug in that + # loop fails this guard open. An over-refusal is a message; an + # under-refusal is the CPU-exhaustion path this exists to + # close. No certificate raises anything to the 1001st power. + def check_power_size(base, exponent) + return if base.precision * exponent.abs <= MAX_DIGITS + + raise ::Dcc::ExtractionError, + "An exact power in the formula is not provably within " \ + "the limit of #{MAX_DIGITS} digits" + end + + def zero_to_the(exponent) + raise ::Dcc::ExtractionError, ZERO_DIVISION if exponent.negative? + + BigDecimal(0) + end + + # Content MathML spells roots as `` with an optional + # ``; mml exposes no content `sqrt` element. Odd-degree + # roots of negative values are out of scope — the exp/ln path + # cannot produce them. A zero radicand is legitimate and is + # answered before the ln guard sees it. + def root(value, degree) + if value.negative? + raise ::Dcc::ExtractionError, + "Cannot take the root of a negative value in formula" + end + return BigMath.sqrt(value, PRECISION) if degree.nil? || degree == 2 + + nth_root(value, degree) + end + + def nth_root(value, degree) + if degree.zero? + raise ::Dcc::ExtractionError, "Root of degree zero in formula" + end + return zero_to_the(degree) if value.zero? + + scaled = checked(divide(ln(value), degree), :root, + "intermediate in") + BigMath.exp(scaled, PRECISION) + end + end + end + end + end +end diff --git a/lib/dcc/extract/formula/parser.rb b/lib/dcc/extract/formula/parser.rb new file mode 100644 index 0000000..8f11aec --- /dev/null +++ b/lib/dcc/extract/formula/parser.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require "bigdecimal" + +module Dcc + module Extract + module Formula + # Turns an `Mml::V3::Math` model into a `Formula::Ast`. + # + # mml splits an element's children into one collection per child type + # (`apply_value`, `ci_value`, …), which loses order across types. + # `element_order` records the original child sequence, so we walk that + # and pop from the matching collection with a per-name cursor. + module Parser + OPERATORS = { + "plus" => :+, + "minus" => :-, + "times" => :*, + "divide" => :/, + "power" => :**, + "root" => :root, + "exp" => :exp, + "ln" => :ln, + "log" => :log, + "sin" => :sin, + "cos" => :cos, + "tan" => :tan, + "abs" => :abs, + }.freeze + + CONSTANTS = { "pi" => :pi, "exponentiale" => :e }.freeze + + # `` encodings whose value is a single decimal literal. Every + # other type spreads the number across ``-separated parts. + PLAIN_CN_TYPES = %w[real integer double].freeze + + class << self + # @param math [Mml::V3::Math] + # @param bindings [Hash{String => Quantity}] + # @return [Dcc::Extract::Formula::Ast] + def call(math, bindings: {}) + name, model = only(children(math)) + return parse_declaration(model, bindings) if name == "declare" + + Ast.new(name: nil, bound_variables: [], + body: node(name, model, []), bindings: bindings) + end + + private + + def parse_declaration(declaration, bindings) + nodes = children(declaration) + check_declaration(nodes) + lambda_children = children(fetch(nodes, "lambda")) + bound = bound_variable_names(lambda_children) + body = expression(without_bvars(lambda_children), bound) + Ast.new(name: text_of(nodes, "ci"), bound_variables: bound, + body: body, bindings: bindings) + end + + # `fetch` and `text_of` take the first match, so a second + # or a second name would vanish the same way a + # second top-level expression used to. + def check_declaration(nodes) + names = nodes.map(&:first) + return if names == %w[ci lambda] + + raise ::Dcc::ExtractionError, + " must hold one then one , " \ + "found #{names.empty? ? 'nothing' : names.join(', ')}" + end + + def without_bvars(nodes) + nodes.reject { |pair| pair.first == "bvar" } + end + + def bound_variable_names(nodes) + nodes.select { |pair| pair.first == "bvar" } + .map { |pair| bound_name(children(pair.last)) } + end + + # A names one variable. MathML also allows a + # qualifier there, but that spells a derivative and this + # evaluator has none to offer, so it is refused rather than + # ignored — and the message names what was actually there. + def bound_name(contents) + names = contents.map(&:first) + return text(contents.first.last) if names == %w[ci] + + raise ::Dcc::ExtractionError, + " must contain exactly one , " \ + "found #{names.empty? ? 'nothing' : names.join(', ')}" + end + + # @param nodes [Array<(String, Object)>] ordered + # [element name, model] pairs. + # @return [Object] the single expression node. + def expression(nodes, bound = []) + name, model = only(nodes) + node(name, model, bound) + end + + # `` and a `` body each hold exactly one + # expression. Taking `.first` of several dropped the rest + # without a word — the same silent wrong answer this file + # refuses everywhere else. (`` has its own check, in + # `bound_name`, because a qualifier there is not an + # expression.) + def only(nodes) + case nodes.size + when 1 then nodes.first + when 0 + raise ::Dcc::ExtractionError, + "dcc:formula contains no formula expression" + else + raise ::Dcc::ExtractionError, + "dcc:formula contains #{nodes.size} expressions " \ + "where exactly one is expected" + end + end + + def node(name, model, bound) + case name + when "apply" then apply(model, bound) + when "cn" then number_node(model) + when "ci" then variable(text(model), bound) + else constant_node(name) + end + end + + # A structured `` splits its number across text segments + # separated by ``, and `text` concatenates them, so a + # rational `12` would silently read as 12 rather than one + # half. A wrong answer is worse than a refusal, so encodings we + # do not implement are rejected outright. + # + # No explicit coercion here — Ast::Number's constructor runs the + # text through Quantity.decimal. + def number_node(model) + check_plain_number(model) + literal = text(model) + number = Ast::Number.new(value: literal) + check_integral(model.type, number.value, literal) + number + end + + # `1.5` declares one thing and writes + # another. Reading it as 1.5 is the same contradiction + # `base="8"` is refused for. + # + # Returns the node explicitly rather than ending on the check: + # a check's return value in an operand slot would put nil into + # the tree and leak a raw NoMethodError out of `abs`. + # Quotes the source lexeme, not `value.to_s("F")`: expanding + # the decimal turns `1e-100000` — nine bytes of document — + # into a hundred-kilobyte exception message. + def check_integral(type, value, literal) + return unless type == "integer" + return if value.frac.zero? + + raise ::Dcc::ExtractionError, + %( holds a non-integer value: #{literal}) + end + + def check_plain_number(model) + check_base(model) + unless model.sep_value.empty? + raise ::Dcc::ExtractionError, + "Structured with is not supported" + end + return if model.type.nil? || PLAIN_CN_TYPES.include?(model.type) + + raise ::Dcc::ExtractionError, + "Unsupported type: #{model.type}" + end + + # `17` is octal — fifteen, not seventeen. + # Reading any other base as decimal is the same silent wrong + # number the `` check refuses, so only base 10 is accepted. + def check_base(model) + return if model.base.nil? || model.base == "10" + + raise ::Dcc::ExtractionError, + "Unsupported base: #{model.base}" + end + + def constant_node(name) + constant = CONSTANTS.fetch(name) do + raise ::Dcc::ExtractionError, + "Unsupported MathML element: #{name}" + end + Ast::Constant.new(name: constant) + end + + def apply(model, bound) + (operator_name,), *operands = children(model) + operator = OPERATORS.fetch(operator_name) do + raise ::Dcc::ExtractionError, + "Unsupported MathML operator: #{operator_name}" + end + Ast::Apply.new( + operator: operator, + operands: operand_nodes(operator, operands, bound), + ) + end + + def operand_nodes(operator, operands, bound) + return root_operands(operands, bound) if operator == :root + + operands.map { |name, child| node(name, child, bound) } + end + + # MathML puts an optional `` wrapper before the radicand. + # Unwrap it and store operands as [value, degree]. Cardinality is + # enforced here: silently taking `.first` of several radicands or + # several degrees would corrupt the evaluator's operand ordering. + def root_operands(operands, bound) + degrees, values = + operands.partition { |pair| pair.first == "degree" } + check_root_arity(values, degrees) + + name, child = values.first + nodes = [node(name, child, bound)] + return nodes if degrees.empty? + + nodes + [degree_node(degrees.first.last, bound)] + end + + def check_root_arity(values, degrees) + unless values.size == 1 + raise ::Dcc::ExtractionError, + " must have exactly one radicand, " \ + "found #{values.size}" + end + return unless degrees.size > 1 + + raise ::Dcc::ExtractionError, + " must have at most one , " \ + "found #{degrees.size}" + end + + def degree_node(model, bound) + contents = children(model) + unless contents.size == 1 + raise ::Dcc::ExtractionError, + " must contain exactly one expression, " \ + "found #{contents.size}" + end + + name, child = contents.first + node(name, child, bound) + end + + def variable(name, bound) + return Ast::BoundVariable.new(name: name) if bound.include?(name) + + Ast::Variable.new(name: name) + end + + # Rebuild the original child sequence from `element_order`. + # @return [Array<(String, Object)>] + def children(model) + cursors = ::Hash.new(0) + elements = model.element_order.select do |e| + e.node_type == :element + end + elements.map do |element| + name = element.name + child = collection(model, name)[cursors[name]] + cursors[name] += 1 + [name, child] + end + end + + # `element_order` can name any MathML element; only the ones mml + # models have a `*_value` reader. This is a boundary check against + # the gem, not defensive padding. + def collection(model, name) + reader = :"#{name.tr('-', '_')}_value" + unless model.respond_to?(reader) + raise ::Dcc::ExtractionError, + "Unsupported MathML element: #{name}" + end + + model.public_send(reader) + end + + def fetch(nodes, name) + nodes.assoc(name)&.last || + raise(::Dcc::ExtractionError, + "Expected a <#{name}> element in dcc:formula") + end + + def text_of(nodes, name) + text(fetch(nodes, name)) + end + + def text(model) + Array(model.value).join.strip + end + end + end + end + end +end diff --git a/lib/dcc/extract/formula/quantity.rb b/lib/dcc/extract/formula/quantity.rb new file mode 100644 index 0000000..744be19 --- /dev/null +++ b/lib/dcc/extract/formula/quantity.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "bigdecimal" + +module Dcc + module Extract + module Formula + # A named numeric binding taken from a `dcc:quantity`. `value` is a + # single `BigDecimal` for `si:real` and an array for the `si:*XMLList` + # forms. `uncertainty` is part of the shape callers see, but nothing + # populates it yet — extraction always passes nil. + # + # `Quantity.decimal` is the one place untrusted input becomes a + # BigDecimal. The constructor runs every value through it, so a + # Quantity cannot exist holding a non-finite or non-numeric value. + # That invariant is what lets `Evaluator#quantify` trust its contents. + Quantity = ::Data.define(:value, :unit, :uncertainty) do + # The single coercion boundary for the whole formula pipeline: the + # parser's `` text, the extractor's `si:value` (declared merely + # `:string`, so it can hold anything), and the evaluator's overrides + # all land here. + # + # @param input [Object] anything that might be a number. + # @return [BigDecimal] finite, always. + # @raise [Dcc::ExtractionError] if it is not a finite number. + def self.decimal(input) + number = input.is_a?(::BigDecimal) ? input : from_text(input) + unless number.finite? + raise ::Dcc::ExtractionError, + "Formula value is not a finite number: #{input.inspect}" + end + + number + rescue ArgumentError + raise ::Dcc::ExtractionError, + "Formula value is not a number: #{input.inspect}" + end + + # `BigDecimal("1_000")` quietly reads 1000, but no document writes a + # number that way. Malformed text, not a value ten times its face. + def self.from_text(input) + text = input.to_s + raise ::ArgumentError if text.include?("_") + + BigDecimal(text) + end + private_class_method :from_text + + # Lists are narrowed with `Array.try_convert`, not `is_a?(Array)`: + # the D-SI branch hands back a `Dcc::Type::DecimalXmlList::Values`, + # which is array-like but is not an Array. `try_convert` also + # rejects an object whose `to_ary` breaks the protocol, which a + # bare `respond_to?(:to_ary)` would pass through to a NoMethodError. + # + # `Data` freezes the struct but not what it holds, so the coerced + # list is frozen too. + def initialize(value:, unit: nil, uncertainty: nil) + super(value: Quantity.coerce(value), unit: unit, + uncertainty: uncertainty) + end + + def self.coerce(value) + return decimal(value) unless value.respond_to?(:to_ary) + + list = ::Array.try_convert(value) + raise ::Dcc::ExtractionError, broken_list(value) if list.nil? + + list.map { |item| decimal(item) }.freeze + rescue ::TypeError + raise ::Dcc::ExtractionError, broken_list(value) + end + + def self.broken_list(value) + "Formula value is not a usable list: #{value.class}" + end + + # @return [Array] + def values + list? ? value : [value] + end + + # @return [Boolean] + def list? + value.is_a?(::Array) + end + end + end + end +end diff --git a/lib/dcc/quantity_format/formatter.rb b/lib/dcc/quantity_format/formatter.rb index e23d848..344ccb7 100644 --- a/lib/dcc/quantity_format/formatter.rb +++ b/lib/dcc/quantity_format/formatter.rb @@ -45,28 +45,24 @@ def to_latex private def format_value - # Two decimals by default; tightened to uncertainty precision when known. - return @value.round(2).to_s("F") if uncertainty.nil? + # Two decimals by default, tightened to the uncertainty's precision + # when it has any. Neither a nil nor a NaN uncertainty has one. + return @value.round(2).to_s("F") if uncertainty.nil? || uncertainty.nan? - # Decimal places = 1 - floor(log10(uncertainty)) - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max @value.round(decimal_places).to_s("F") end def format_uncertainty return "" if uncertainty.nil? + return "NaN" if uncertainty.nan? - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max uncertainty.round(decimal_places).to_s("F") end def align_value_and_uncertainty return [@value.to_s("F"), uncertainty.to_s("F")] if uncertainty.nil? + return [format_value, "NaN"] if uncertainty.nan? - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max v_str = @value.round(decimal_places).to_s("F") u_str = uncertainty.round(decimal_places).to_s("F") # Short form: digits of uncertainty after the last common digit @@ -74,6 +70,13 @@ def align_value_and_uncertainty [v_str, last_digits] end + # Decimal places = 1 - floor(log10(uncertainty)). D-SI writes `NaN` for a + # not-measured entry (SI_Format.xsd:1195) and `Math.log10` cannot answer + # for it, so every caller excludes nil and NaN first. + def decimal_places + [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max + end + def unit_suffix return "" if unit.nil? || unit.empty? diff --git a/lib/dcc/si/base.rb b/lib/dcc/si/base.rb index b0b209e..56e9e76 100644 --- a/lib/dcc/si/base.rb +++ b/lib/dcc/si/base.rb @@ -17,6 +17,8 @@ module Base autoload :StandardMU, "dcc/si/base/standard_mu" autoload :ExpandedMU, "dcc/si/base/expanded_mu" autoload :CoverageIntervalMU, "dcc/si/base/coverage_interval_mu" + autoload :ExpandedUnc, "dcc/si/base/expanded_unc" + autoload :CoverageInterval, "dcc/si/base/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/base/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/base/measurement_uncertainty_univariate" diff --git a/lib/dcc/si/base/coverage_interval.rb b/lib/dcc/si/base/coverage_interval.rb new file mode 100644 index 0000000..3f09a5d --- /dev/null +++ b/lib/dcc/si/base/coverage_interval.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Dcc + module Si + module Base + # `si:coverageIntervalType` — coverage interval stated directly on a real + # quantity, the sibling of `expandedUnc` in the same xs:choice. The v1 + # form; v2 keeps it as a deprecated alternative. + module CoverageInterval + # rubocop:disable Metrics/MethodLength -- one lutaml mapping + # block, written the same way as every sibling D-SI module. + def self.included(klass) + klass.class_eval do + attribute :standard_unc, :string + attribute :interval_min, :string + attribute :interval_max, :string + attribute :coverage_probability, :string + attribute :distribution, :string + + xml do + namespace ::Dcc::Namespace::Si + element "coverageInterval" + ordered + map_element "standardUnc", to: :standard_unc + map_element "intervalMin", to: :interval_min + map_element "intervalMax", to: :interval_max + map_element "coverageProbability", to: :coverage_probability + map_element "distribution", to: :distribution + end + end + end + # rubocop:enable Metrics/MethodLength + end + end + end +end diff --git a/lib/dcc/si/base/expanded_unc.rb b/lib/dcc/si/base/expanded_unc.rb new file mode 100644 index 0000000..3d045ae --- /dev/null +++ b/lib/dcc/si/base/expanded_unc.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Dcc + module Si + module Base + # `si:expandedUncType` — expanded uncertainty stated directly on a real + # quantity. The v1 form; v2 keeps it as a deprecated alternative to + # `measurementUncertaintyUnivariate`. + module ExpandedUnc + # rubocop:disable Metrics/MethodLength -- one lutaml mapping + # block, written the same way as every sibling D-SI module. + def self.included(klass) + klass.class_eval do + attribute :uncertainty, :string + attribute :coverage_factor, :string + attribute :coverage_probability, :string + attribute :distribution, :string + + xml do + namespace ::Dcc::Namespace::Si + element "expandedUnc" + ordered + map_element "uncertainty", to: :uncertainty + map_element "coverageFactor", to: :coverage_factor + map_element "coverageProbability", to: :coverage_probability + map_element "distribution", to: :distribution + end + end + end + # rubocop:enable Metrics/MethodLength + end + end + end +end diff --git a/lib/dcc/si/base/real.rb b/lib/dcc/si/base/real.rb index 0fa8f35..b752005 100644 --- a/lib/dcc/si/base/real.rb +++ b/lib/dcc/si/base/real.rb @@ -17,6 +17,8 @@ module Base # module Real def self.included(klass) + # rubocop:disable Metrics/BlockLength -- one lutaml mapping block, + # as in the sibling D-SI modules already listed in the todo file. klass.class_eval do attribute :id, :string attribute :ref_type, :string @@ -27,6 +29,9 @@ def self.included(klass) attribute :date_time, :date_time attribute :measurement_uncertainty_univariate, :measurementUncertaintyUnivariate + # Deprecated in v2, but the only uncertainty form v1 documents use. + attribute :expanded_unc, :expandedUnc + attribute :coverage_interval, :coverageInterval xml do namespace ::Dcc::Namespace::Si @@ -41,8 +46,11 @@ def self.included(klass) map_element "dateTime", to: :date_time map_element "measurementUncertaintyUnivariate", to: :measurement_uncertainty_univariate + map_element "expandedUnc", to: :expanded_unc + map_element "coverageInterval", to: :coverage_interval end end + # rubocop:enable Metrics/BlockLength end end end diff --git a/lib/dcc/si/v1.rb b/lib/dcc/si/v1.rb index a9c7486..38a9f98 100644 --- a/lib/dcc/si/v1.rb +++ b/lib/dcc/si/v1.rb @@ -22,6 +22,7 @@ module V1 autoload :ExpandedMU, "dcc/si/v1/expanded_mu" autoload :CoverageIntervalMU, "dcc/si/v1/coverage_interval_mu" autoload :ExpandedUnc, "dcc/si/v1/expanded_unc" + autoload :CoverageInterval, "dcc/si/v1/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/v1/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/v1/measurement_uncertainty_univariate" @@ -34,7 +35,8 @@ module V1 Real Complex Constant RealListXmlList ComplexListXmlList Hybrid SiList StandardMU ExpandedMU CoverageIntervalMU - ExpandedUnc ExpandedUncXmlList MeasurementUncertaintyUnivariate + ExpandedUnc CoverageInterval + ExpandedUncXmlList MeasurementUncertaintyUnivariate ].freeze def self.load_all! diff --git a/lib/dcc/si/v1/coverage_interval.rb b/lib/dcc/si/v1/coverage_interval.rb new file mode 100644 index 0000000..f730a95 --- /dev/null +++ b/lib/dcc/si/v1/coverage_interval.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V1 + class CoverageInterval < CommonElements + include ::Dcc::Si::Base::CoverageInterval + end + Configuration.register_model(CoverageInterval, id: :coverageInterval) +end diff --git a/lib/dcc/si/v1/expanded_unc.rb b/lib/dcc/si/v1/expanded_unc.rb index 3cfbf84..9cb86c5 100644 --- a/lib/dcc/si/v1/expanded_unc.rb +++ b/lib/dcc/si/v1/expanded_unc.rb @@ -1,23 +1,8 @@ # frozen_string_literal: true module Dcc::Si::V1 - # Deprecated v1 expandedUnc type — superseded by expandedMU in v2. - # Kept so old fixtures parse without losing data. class ExpandedUnc < CommonElements - attribute :uncertainty, :string - attribute :coverage_factor, :string - attribute :coverage_probability, :string - attribute :distribution, :string - - xml do - namespace ::Dcc::Namespace::Si - element "expandedUnc" - ordered - map_element "uncertainty", to: :uncertainty - map_element "coverageFactor", to: :coverage_factor - map_element "coverageProbability", to: :coverage_probability - map_element "distribution", to: :distribution - end + include ::Dcc::Si::Base::ExpandedUnc end Configuration.register_model(ExpandedUnc, id: :expandedUnc) end diff --git a/lib/dcc/si/v2.rb b/lib/dcc/si/v2.rb index 8018c4b..5ef040b 100644 --- a/lib/dcc/si/v2.rb +++ b/lib/dcc/si/v2.rb @@ -24,6 +24,8 @@ module V2 autoload :CoverageIntervalMU, "dcc/si/v2/coverage_interval_mu" autoload :CoverageIntervalMUXMLList, "dcc/si/v2/coverage_interval_mu_xml_list" + autoload :ExpandedUnc, "dcc/si/v2/expanded_unc" + autoload :CoverageInterval, "dcc/si/v2/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/v2/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/v2/measurement_uncertainty_univariate" @@ -38,6 +40,7 @@ module V2 StandardMU StandardMUXMLList ExpandedMU ExpandedMUXMLList CoverageIntervalMU CoverageIntervalMUXMLList + ExpandedUnc CoverageInterval ExpandedUncXmlList MeasurementUncertaintyUnivariate ].freeze diff --git a/lib/dcc/si/v2/coverage_interval.rb b/lib/dcc/si/v2/coverage_interval.rb new file mode 100644 index 0000000..1bb7635 --- /dev/null +++ b/lib/dcc/si/v2/coverage_interval.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V2 + class CoverageInterval < CommonElements + include ::Dcc::Si::Base::CoverageInterval + end + Configuration.register_model(CoverageInterval, id: :coverageInterval) +end diff --git a/lib/dcc/si/v2/expanded_unc.rb b/lib/dcc/si/v2/expanded_unc.rb new file mode 100644 index 0000000..3dfa876 --- /dev/null +++ b/lib/dcc/si/v2/expanded_unc.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V2 + class ExpandedUnc < CommonElements + include ::Dcc::Si::Base::ExpandedUnc + end + Configuration.register_model(ExpandedUnc, id: :expandedUnc) +end diff --git a/lib/dcc/type/decimal_xml_list.rb b/lib/dcc/type/decimal_xml_list.rb index 8acf34c..e3cc2f9 100644 --- a/lib/dcc/type/decimal_xml_list.rb +++ b/lib/dcc/type/decimal_xml_list.rb @@ -1,19 +1,86 @@ # frozen_string_literal: true require "bigdecimal" +require "forwardable" require "lutaml/model" module Dcc module Type # Space-separated list of decimals, as used by D-SI `*XMLList` elements # (e.g. `306 373 448`). Parses to/from - # an Array of BigDecimal values. + # a `Values` list of BigDecimal. + # + # `cast` deliberately does not return a plain Array: lutaml-model treats an + # Array-valued attribute as a collection and would emit one element per + # decimal instead of a single space-separated list. class DecimalXmlList < Lutaml::Model::Type::Value + # An ordered list of BigDecimals that renders as space-separated text and + # compares equal to the equivalent Array. + class Values + include Enumerable + extend Forwardable + + def_delegators :@decimals, :size, :length, :empty?, :last, :[] + + def initialize(decimals) + @decimals = decimals + end + + def each(&) + return to_enum(:each) unless block_given? + + @decimals.each(&) + self + end + + # Implicit conversion, so `Array#==` delegates back here instead of + # returning false and making equality asymmetric. + def to_ary + @decimals.dup + end + + # Joined over the serialized decimals, not the raw BigDecimals, so it + # cannot disagree with `to_s` by emitting "0.72e-1" for "0.072". + def join(separator = " ") + map { |decimal| DecimalXmlList.serialize_one(decimal) }.join(separator) + end + + def to_s + DecimalXmlList.serialize(self) + end + + # Dumped as its wire form, so YAML matches JSON rather than leaking a + # `!ruby/object:` tag that `safe_load` refuses to read back. + def encode_with(coder) + coder.represent_scalar(nil, to_s) + end + + def ==(other) + case other + when Values then to_a == other.to_a + when ::Array then to_a == other + else false + end + end + + # Stricter than `==`, which accepts a plain Array. Hash lookup pairs + # `eql?` with `hash`, and an Array keyed alongside us would hash the + # same while comparing equal in only one direction. + def eql?(other) + other.is_a?(Values) && to_a == other.to_a + end + + def hash + to_a.hash + end + end + def self.cast(value) - return [] if value.nil? - return value.map { |v| cast_one(v) } if value.is_a?(::Array) + return value if value.is_a?(Values) + return Values.new([]) if value.nil? - value.to_s.split(/\s+/).reject(&:empty?).map { |s| cast_one(s) } + tokens = value.is_a?(::Array) ? value : value.to_s.split + Values.new(tokens.map { |token| cast_one(token) }) end def self.serialize(value) @@ -22,30 +89,43 @@ def self.serialize(value) Array(value).map { |v| serialize_one(v) }.join(" ") end + # One decimal in the notation D-SI writes, e.g. 0.072 rather than + # BigDecimal's own "0.72e-1". `to_s("F")` renders every BigDecimal, + # finite or not, and `sub` only trims a trailing ".0". That is what + # lets `NaN` through, which D-SI admits in every decimal type + # (SI_Format.xsd:1195). + def self.serialize_one(value) + reject_infinite!(value) + + case value + when ::BigDecimal then value.to_s("F").sub(/\.0\z/, "") + when ::Float then format("%.15g", value) + else value.to_s + end + end + class << self private def cast_one(token) - return token if token.is_a?(::BigDecimal) - - BigDecimal(token.to_s) + decimal = token.is_a?(::BigDecimal) ? token : BigDecimal(token.to_s) + reject_infinite!(decimal) + decimal rescue ::ArgumentError => e raise Lutaml::Model::Type::InvalidValueError.new(token, "invalid decimal in XML list: #{e.message}") end - def serialize_one(value) - case value - when ::BigDecimal then if value.to_i.to_s == value.to_s("F") - value.to_i.to_s - else - value.to_s("F").sub( - /\.0\z/, "" - ) - end - when ::Float then format("%.15g", value) - else value.to_s - end + # Called from `cast_one` and from the public `serialize_one`, since + # `serialize` takes a raw BigDecimal and casting is not the only way in. + def reject_infinite!(value) + return unless value.is_a?(::BigDecimal) && value.infinite? + + raise Lutaml::Model::Type::InvalidValueError.new( + value, + "infinity is not a D-SI decimal: SI_Format.xsd:1195 admits " \ + "numbers and NaN only", + ) end end end diff --git a/lib/dcc/v2.rb b/lib/dcc/v2.rb index 2ad5cb6..786a6db 100644 --- a/lib/dcc/v2.rb +++ b/lib/dcc/v2.rb @@ -14,6 +14,7 @@ module V2 autoload :Text, "dcc/v2/text" autoload :ByteData, "dcc/v2/byte_data" autoload :Formula, "dcc/v2/formula" + autoload :Mathml, "dcc/v2/mathml" autoload :RichContent, "dcc/v2/rich_content" autoload :XmlBlob, "dcc/v2/xml_blob" autoload :Comment, "dcc/v2/comment" @@ -61,7 +62,7 @@ module V2 extend ::Dcc::VersionedParser ELEMENT_CLASSES = %i[ - StringWithLang Text ByteData Formula RichContent XmlBlob Comment + StringWithLang Text ByteData Mathml Formula RichContent XmlBlob Comment EquipmentClass Location Contact ContactNotStrict HashType Identification Identifications Software SoftwareList RefTypeDefinition RefTypeDefinitionList diff --git a/lib/dcc/v2/mathml.rb b/lib/dcc/v2/mathml.rb new file mode 100644 index 0000000..9f03a72 --- /dev/null +++ b/lib/dcc/v2/mathml.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::V2 + class Mathml < CommonElements + include ::Dcc::Base::Mathml + end + Configuration.register_model(Mathml, id: :mathml) +end diff --git a/lib/dcc/v3.rb b/lib/dcc/v3.rb index d19f1eb..640e258 100644 --- a/lib/dcc/v3.rb +++ b/lib/dcc/v3.rb @@ -14,6 +14,7 @@ module V3 autoload :Text, "dcc/v3/text" autoload :ByteData, "dcc/v3/byte_data" autoload :Formula, "dcc/v3/formula" + autoload :Mathml, "dcc/v3/mathml" autoload :RichContent, "dcc/v3/rich_content" autoload :XmlBlob, "dcc/v3/xml_blob" autoload :Comment, "dcc/v3/comment" @@ -64,7 +65,7 @@ module V3 # `Configuration.populate_context!` can register them. Iterating # `constants` triggers each autoload lazily. ELEMENT_CLASSES = %i[ - StringWithLang Text ByteData Formula RichContent XmlBlob Comment + StringWithLang Text ByteData Mathml Formula RichContent XmlBlob Comment EquipmentClass Location Contact ContactNotStrict HashType Identification Identifications Software SoftwareList RefTypeDefinition RefTypeDefinitionList @@ -91,9 +92,17 @@ def self.load_all! # Re-export D-SI v2 type registrations into the DCC v3 context. Uses # only the public `register_model` API (no instance_variable access). + # + # Ids DCC already owns are skipped: `register_model` overwrites on + # duplicate, and both DCC and D-SI define a `list` element, so without this + # the D-SI one would displace `Dcc::V3::List` and every `dcc:list` would + # parse as an empty `si:list`. def self.register_dsi_types! ::Dcc::Si::V2.load_all! + own_ids = Configuration.registered_model_ids ::Dcc::Si::V2::Configuration.registered_model_ids.each do |id| + next if own_ids.include?(id) + klass = ::Dcc::Si::V2::Configuration.registered_model_class(id) Configuration.register_model(klass, id: id) end diff --git a/lib/dcc/v3/mathml.rb b/lib/dcc/v3/mathml.rb new file mode 100644 index 0000000..6b45a6f --- /dev/null +++ b/lib/dcc/v3/mathml.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::V3 + class Mathml < CommonElements + include ::Dcc::Base::Mathml + end + Configuration.register_model(Mathml, id: :mathml) +end diff --git a/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb b/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb index 8c4168b..b9c26c6 100644 --- a/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +++ b/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb @@ -5,9 +5,16 @@ module Validate module Schematron module Rules # Validates that for every `si:realListXMLList` paired with an - # `si:expandedUncXMLList`, the count of values matches the count - # of uncertainty entries. + # `si:expandedUncXMLList`, each uncertainty list holds either one + # entry per value or a single entry broadcast across all of them. class UncertaintyConsistency < Base + UNCERTAINTY_LISTS = { + uncertainty_xml_list: "uncertaintyXMLList", + coverage_factor_xml_list: "coverageFactorXMLList", + coverage_probability_xml_list: "coverageProbabilityXMLList", + distribution_xml_list: "distributionXMLList", + }.freeze + def check_on(dcc) issues = [] return issues unless dcc.is_a?(::Lutaml::Model::Serializable) @@ -36,16 +43,37 @@ def check_node(list_node, issues) unc = list_node.expanded_unc_xml_list return unless unc && values - unc_count = Array(unc.uncertainty_xml_list).size - value_count = Array(values).size - return if unc_count == value_count || unc_count.zero? + value_count = entry_count(values) + UNCERTAINTY_LISTS.each do |attribute, xml_name| + count = entry_count(unc.public_send(attribute)) + next if consistent?(count, value_count) + + issues << count_mismatch(xml_name, count, value_count) + end + end - issues << issue( + def count_mismatch(xml_name, count, value_count) + issue( severity: :error, - message: "valueXMLList count (#{value_count}) does not match expandedUncertaintyXMLList count (#{unc_count})", + message: "valueXMLList count (#{value_count}) does not " \ + "match #{xml_name} count (#{count})", ) end + # Every list here is an xs:list, so its entries are whitespace + # separated whether the attribute holds a string or a decimal list. + def entry_count(list) + list.to_s.split.size + end + + # A list either carries one entry per value or a single entry that + # broadcasts across all of them, the way unitXMLList does. Siblings + # decide independently: PTB ships documents pairing nine + # uncertainties with one shared coverage factor. + def consistent?(count, value_count) + count.zero? || count == 1 || count == value_count + end + def descend(node, issues, visited) node.class.attributes.each_key do |attr_name| value = node.public_send(attr_name) diff --git a/spec/dcc/cli/cli_spec.rb b/spec/dcc/cli/cli_spec.rb index 3bdd8fe..3958efb 100644 --- a/spec/dcc/cli/cli_spec.rb +++ b/spec/dcc/cli/cli_spec.rb @@ -63,6 +63,204 @@ end end + describe "extract formulae" do + let(:formula_file) { fixtures_path("dcclib", "valid_formula.xml") } + + # A repeated -v must accumulate, not overwrite. With Thor's `type: + # :array` the second -v silently dropped the first, so T fell back to + # the document list and this printed five values instead of one. + let(:repeated) do + _exit_code, output = capture_stdout_and_exit do + described_class.start( + ["extract", "formulae", formula_file, "-v", "T=42", "-v", "R0=1"], + ) + end + output + end + + it "prints the document's own values" do + _exit_code, output = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file]) + end + + expect(output).to include("R", "100.0225", "109.77301212171875") + end + + it "honours -v overrides" do + _exit_code, output = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "T=42"]) + end + + expect(output).to include("116.357161312039") + end + + it "accumulates repeated -v flags" do + expect(repeated).to include("1.1633098684") + end + + it "does not fall back to the document list when -v repeats" do + expect(repeated).not_to include("109.77301212171875") + end + + # A lone -v value is a scalar and broadcasts. Wrapping it in an array + # made it a one-element list that collided with the document's own + # five-element T list, so overriding one parameter was impossible. + it "broadcasts a scalar override across the document list" do + _exit_code, output = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "R0=1"]) + end + + expect(output).to include("1.0974831875", "1.385081") + end + + it "prints one line per document value for a scalar override" do + _exit_code, output = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "R0=1"]) + end + + expect(output.lines.grep(/\A {2}\d/).size).to eq(5) + end + + # Two -v lists of different lengths is a real mismatch. It must reach + # the user as a message, not as a Ruby backtrace out of Thor. + it "aborts cleanly on mismatched override list lengths" do + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, + "-v", "T=1,2", "-v", "R0=1,2,3"]) + end + + expect(exit_code).to eq(1) + end + + it "reports when a document has no formulae" do + _exit_code, output = capture_stdout_and_exit do + described_class.start(["extract", "formulae", valid_file]) + end + + expect(output).to include("No formulae found.") + end + + # `abort` writes to stderr and raises SystemExit, so assert the status, + # not the captured stdout. + it "rejects a non-numeric override" do + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "x=invalid"]) + end + + expect(exit_code).to eq(1) + end + + it "rejects an override with an empty name" do + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "=1"]) + end + + expect(exit_code).to eq(1) + end + + # String#split drops a trailing empty field, so `T=42,` would lose the + # empty entry instead of rejecting it. + it "rejects an override with a trailing comma" do + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "T=42,"]) + end + + expect(exit_code).to eq(1) + end + + # Validation happens when the flag is parsed, so an override for a + # variable the formula never mentions is still rejected. + it "rejects a non-finite override even when the variable is unreferenced" do + %w[Infinity NaN].each do |bad| + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", formula_file, "-v", "unused=#{bad}"]) + end + + expect(exit_code).to eq(1) + end + end + + # Same reason: nothing downstream would ever coerce this one. + it "rejects a bad override against a document with no formulae" do + exit_code, = capture_stdout_and_exit do + described_class.start(["extract", "formulae", valid_file, "-v", "x=Infinity"]) + end + + expect(exit_code).to eq(1) + end + + # The evaluator slices bindings to the names a formula references, + # so `-v TYPO=1` reached nothing and the run printed the unchanged + # defaults and reported success. + describe "an override no formula references" do + let(:run) { extract_formulae(formula_file, "-v", "TYPO=1") } + + it "exits nonzero" do + expect(run.first).to eq(1) + end + + it "does not print the default values as if nothing were wrong" do + expect(run.last).not_to include("100.0225") + end + end + + # A formula that cannot be evaluated used to abort the command from + # outside the loop, after its header had already been printed, so + # every other formula's output went with it. + describe "one formula that cannot be evaluated" do + # The Tempfile object, not its path: the object is what keeps the + # file on disk. + let(:document) { temp_document(broken_first, "dcc-two-formulae") } + + after { document.unlink } + + it "still prints the formula that works" do + _status, output = extract_formulae(document.path) + + expect(output).to include("100.0225", "138.5392643225") + end + + it "does not leave a bare header on stdout for the one that failed" do + _status, output = extract_formulae(document.path) + + expect(output).not_to include("Broken(T)") + end + + it "exits nonzero" do + status, = extract_formulae(document.path) + + expect(status).to eq(1) + end + + # Kernel#warn prints nothing when warnings are off, so `warn` here + # left a RUBYOPT=-W0 user with exit 1 and no explanation. + it "explains itself on stderr even with warnings disabled" do + expect(silenced_stderr { extract_formulae(document.path) }) + .to include("No value for formula variable 'Q'") + end + end + + # An empty list gave the formula a result width of zero, so the body + # never ran: a header, no values, and exit 0. + describe "a formula variable with an empty list" do + let(:document) { temp_document(empty_list, "dcc-empty-list") } + + after { document.unlink } + + it "exits nonzero instead of reporting success" do + status, = extract_formulae(document.path) + + expect(status).to eq(1) + end + + it "prints no values" do + _status, output = extract_formulae(document.path) + + expect(output.lines.grep(/\A {2}-?\d/)).to be_empty + end + end + end + describe "version" do it "prints the gem version" do _exit_code, output = capture_stdout_and_exit do @@ -84,6 +282,51 @@ private + def extract_formulae(path, *flags) + capture_stdout_and_exit do + described_class.start(["extract", "formulae", path, *flags]) + end + end + + # Runs the block with warnings off and stderr captured, which is what + # `RUBYOPT=-W0` looks like from inside the process. + def silenced_stderr + original = $stderr + $stderr = StringIO.new + verbose = $VERBOSE + $VERBOSE = nil + yield + $stderr.string + ensure + $VERBOSE = verbose + $stderr = original + end + + def temp_document(content, name) + Tempfile.new([name, ".xml"]).tap do |file| + file.write(content) + file.close + end + end + + # The document's one formula, duplicated with the copy in front + # renamed and pointed at a variable nothing binds. + def broken_first + src = File.read(fixtures_path("dcclib", "valid_formula.xml")) + pattern = %r{\s*.*?\s*}m + whole = src[pattern] + broken = whole + .sub("R", "Broken") + .sub('R0', "Q") + src.sub(whole, broken + whole) + end + + def empty_list + File.read(fixtures_path("dcclib", "valid_formula.xml")) + .sub("0 25 50 75 100", + "") + end + def capture_stdout original_stdout = $stdout $stdout = StringIO.new diff --git a/spec/dcc/convert/additional_spec.rb b/spec/dcc/convert/additional_spec.rb index 3b67896..58f2cbf 100644 --- a/spec/dcc/convert/additional_spec.rb +++ b/spec/dcc/convert/additional_spec.rb @@ -32,6 +32,17 @@ expect(lines.first).to include("result_name") expect(lines.first).to include("quantity_name") end + + # BigDecimal#to_s renders 0.072 as "0.72e-1", which is not what the + # document said. + it "renders list values as plain decimals" do + expect(described_class.call(dcc).payload).not_to match(/\d+e-?\d/) + end + + it "renders list values as they appear in the document" do + expect(described_class.call(dcc).payload) + .to include("0.072 0.089 0.107 -0.009 -0.084") + end end RSpec.describe Dcc::Convert::Html do diff --git a/spec/dcc/diff_spec.rb b/spec/dcc/diff_spec.rb new file mode 100644 index 0000000..43f9d22 --- /dev/null +++ b/spec/dcc/diff_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dcc::Diff do + before { Dcc::Si::V2.load_all! } + + def real_list(values) + Dcc::Si::V2::RealListXmlList.from_xml(<<~XML) + + #{values} + \\kelvin + + XML + end + + # D-SI writes `NaN` for a not-measured entry, and IEEE says NaN is not equal + # to NaN. `Values#==` honours that on purpose, so the diff has to answer the + # document question — did this change? — rather than the numeric one. + describe "a document compared against an identical copy" do + it "reports no change when the list carries NaN" do + changes = described_class.call(real_list("1.5 NaN 2.5"), + real_list("1.5 NaN 2.5")).changes + expect(changes).to be_empty + end + + it "reports no change when the list is all finite" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 2.0 2.5")).changes + expect(changes).to be_empty + end + end + + # The NaN handling must not make the diff blind: a list that really did + # change still has to be reported, NaN or not. + describe "a document compared against a different one" do + it "reports a change when a finite value differs" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 9.9 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when NaN replaces a measured value" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 NaN 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when a measured value replaces NaN" do + changes = described_class.call(real_list("1.5 NaN 2.5"), + real_list("1.5 2.0 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when the lists are different lengths" do + changes = described_class.call(real_list("1.5 NaN"), + real_list("1.5 NaN 2.5")).changes + expect(changes.size).to eq(1) + end + end + + # `Values.new` takes whatever it is handed, so a list built in Ruby rather + # than parsed can hold entries that were never BigDecimals. Asking one of + # those whether it is NaN raises. + describe "a list assigned in Ruby rather than parsed" do + let(:values) { Dcc::Type::DecimalXmlList::Values } + + it "reports a change between unequal non-decimal entries" do + changes = described_class.call(values.new(["1"]), + values.new(["2"])).changes + expect(changes.size).to eq(1) + end + + it "reports no change between equal non-decimal entries" do + changes = described_class.call(values.new(["1"]), + values.new(["1"])).changes + expect(changes).to be_empty + end + end +end diff --git a/spec/dcc/extract/formula/evaluator_spec.rb b/spec/dcc/extract/formula/evaluator_spec.rb new file mode 100644 index 0000000..d5abb89 --- /dev/null +++ b/spec/dcc/extract/formula/evaluator_spec.rb @@ -0,0 +1,528 @@ +# frozen_string_literal: true + +RSpec.describe Dcc::Extract::Formula::Evaluator do + # Helpers rather than constant assignments — a bare `Ast = ...` here + # would leak to top level and collide with the parser spec. + def node(kind, **fields) + Dcc::Extract::Formula::Ast.const_get(kind).new(**fields) + end + + def ast(body, bindings: {}, bound: []) + Dcc::Extract::Formula::Ast.new( + name: "f", bound_variables: bound, body: body, bindings: bindings, + ) + end + + def num(string) + node(:Number, value: BigDecimal(string)) + end + + def var(name) + node(:Variable, name: name) + end + + def apply(operator, *operands) + node(:Apply, operator: operator, operands: operands) + end + + def quantity(value) + Dcc::Extract::Formula::Quantity.new(value: value, unit: nil, + uncertainty: nil) + end + + def evaluate(body, **options) + described_class.call(ast(body, **options)) + end + + def result(body, **options) + evaluate(body, **options).first + end + + describe "arithmetic" do + it "adds" do + expect(evaluate(apply(:+, num("1"), num("2"), num("3")))) + .to eq([BigDecimal(6)]) + end + + it "multiplies" do + expect(evaluate(apply(:*, num("2"), num("3")))).to eq([BigDecimal(6)]) + end + + it "subtracts" do + expect(evaluate(apply(:-, num("5"), num("3")))).to eq([BigDecimal(2)]) + end + + it "negates a single operand" do + expect(evaluate(apply(:-, num("5")))).to eq([BigDecimal(-5)]) + end + + it "keeps addition and multiplication exact" do + expect(evaluate(apply(:*, num("100.0225"), num("1.0974831875")))) + .to eq([BigDecimal("109.77301212171875")]) + end + + it "divides exactly when the quotient terminates" do + expect(evaluate(apply(:/, num("1"), num("8")))) + .to eq([BigDecimal("0.125")]) + end + + it "divides a recurring quotient to 34 significant digits" do + expect(evaluate(apply(:/, num("1"), num("3")))) + .to eq([BigDecimal("0.3333333333333333333333333333333333")]) + end + end + + describe "powers" do + it "raises to non-negative integer powers exactly" do + expect(evaluate(apply(:**, num("1.5"), num("2")))) + .to eq([BigDecimal("2.25")]) + end + + # `**` picks its own precision: BigDecimal("3") ** -1 gives 32 threes. + # Routing through `divide` gives the full 34. + it "computes a negative integer power at full precision" do + expect(evaluate(apply(:**, num("3"), num("-1")))) + .to eq([BigDecimal("0.3333333333333333333333333333333333")]) + end + + it "keeps a terminating negative power exact" do + expect(evaluate(apply(:**, num("2"), num("-2")))) + .to eq([BigDecimal("0.25")]) + end + + it "raises to fractional powers" do + expect(result(apply(:**, num("9"), num("0.5"))).round(20)) + .to eq(BigDecimal(3)) + end + + it "returns zero for a zero base with a positive fractional exponent" do + expect(evaluate(apply(:**, num("0"), num("0.5")))).to eq([BigDecimal(0)]) + end + end + + describe "roots" do + it "takes a square root when no degree is given" do + expect(result(apply(:root, num("2"))).round(20)) + .to eq(BigDecimal("1.4142135623730950488")) + end + + it "takes an explicit square root" do + expect(evaluate(apply(:root, num("9"), num("2")))).to eq([BigDecimal(3)]) + end + + it "takes an nth root" do + expect(result(apply(:root, num("27"), num("3"))).round(20)) + .to eq(BigDecimal(3)) + end + + it "returns zero for a zero radicand with a positive degree" do + expect(evaluate(apply(:root, num("0"), num("3")))).to eq([BigDecimal(0)]) + end + end + + describe "transcendentals" do + it "round-trips exp through ln" do + expect(result(apply(:ln, apply(:exp, num("1")))).round(20)) + .to eq(BigDecimal(1)) + end + + it "computes exp" do + expect(result(apply(:exp, num("1"))).round(20)) + .to eq(BigDecimal("2.71828182845904523536")) + end + + it "computes log base ten" do + expect(result(apply(:log, num("100"))).round(20)).to eq(BigDecimal(2)) + end + + it "computes sin" do + expect(evaluate(apply(:sin, num("0")))).to eq([BigDecimal(0)]) + end + + it "computes cos" do + expect(result(apply(:cos, num("0"))).round(20)).to eq(BigDecimal(1)) + end + + it "computes tan" do + expect(evaluate(apply(:tan, num("0")))).to eq([BigDecimal(0)]) + end + + it "computes abs" do + expect(evaluate(apply(:abs, num("-3")))).to eq([BigDecimal(3)]) + end + + it "resolves pi" do + expect(result(node(:Constant, name: :pi)).round(10)) + .to eq(BigDecimal("3.1415926536")) + end + + it "resolves e" do + expect(result(node(:Constant, name: :e)).round(10)) + .to eq(BigDecimal("2.7182818285")) + end + end + + # BigDecimal lets some of these through as Infinity/NaN and raises raw + # stdlib errors on others. All must surface as Dcc::ExtractionError. + describe "numeric domain" do + it "rejects division by zero" do + expect { evaluate(apply(:/, num("1"), num("0"))) } + .to raise_error(Dcc::ExtractionError, /division by zero/i) + end + + it "rejects a zero base raised to a negative power" do + expect { evaluate(apply(:**, num("0"), num("-1"))) } + .to raise_error(Dcc::ExtractionError, /division by zero/i) + end + + it "rejects a negative base raised to a fractional power" do + expect { evaluate(apply(:**, num("-4"), num("0.5"))) } + .to raise_error(Dcc::ExtractionError, /fractional power/i) + end + + it "rejects a zero base raised to a negative fractional power" do + expect { evaluate(apply(:**, num("0"), num("-0.5"))) } + .to raise_error(Dcc::ExtractionError, /division by zero/i) + end + + it "rejects a negative radicand" do + expect { evaluate(apply(:root, num("-1"))) } + .to raise_error(Dcc::ExtractionError, /negative value/i) + end + + it "rejects a negative radicand with an odd degree" do + expect { evaluate(apply(:root, num("-8"), num("3"))) } + .to raise_error(Dcc::ExtractionError, /negative value/i) + end + + it "rejects a root of degree zero" do + expect { evaluate(apply(:root, num("8"), num("0"))) } + .to raise_error(Dcc::ExtractionError, /degree zero/i) + end + + it "rejects a zero radicand with a negative degree" do + expect { evaluate(apply(:root, num("0"), num("-3"))) } + .to raise_error(Dcc::ExtractionError, /division by zero/i) + end + + it "rejects ln of zero" do + expect { evaluate(apply(:ln, num("0"))) } + .to raise_error(Dcc::ExtractionError, /non-positive/i) + end + + it "rejects ln of a negative value" do + expect { evaluate(apply(:ln, num("-1"))) } + .to raise_error(Dcc::ExtractionError, /non-positive/i) + end + + it "rejects log of a non-positive value" do + expect { evaluate(apply(:log, num("0"))) } + .to raise_error(Dcc::ExtractionError, /non-positive/i) + end + end + + # Guarding inputs is not enough: exp(1e100) is Infinity and + # sin(Infinity) is NaN, both from finite inputs. A non-finite value + # would then become an operand and poison everything above it. + describe "non-finite results" do + it "rejects an exp that overflows to infinity" do + expect { evaluate(apply(:exp, num("1e100"))) } + .to raise_error(Dcc::ExtractionError, /non-finite/i) + end + + # Was a /non-finite/ assertion. The size guard now refuses this + # before the exact path allocates, which is the stronger property: + # the overflow never happens rather than being detected after. + it "rejects a power that would overflow, before computing it" do + expect { evaluate(apply(:**, num("10"), num("1e30"))) } + .to raise_error(Dcc::ExtractionError, /not provably within/) + end + + it "stops the overflow before it reaches the parent operation" do + expect { evaluate(apply(:sin, apply(:exp, num("1e100")))) } + .to raise_error(Dcc::ExtractionError, /exp produced a non-finite/i) + end + + it "still allows a huge but finite result" do + expect(result(apply(:exp, num("1000")))).to be_finite + end + end + + # Without this, with one operand quietly computes x ** x and + # with three quietly drops the middle one. + describe "operator arity" do + it "rejects a power with one operand" do + expect { apply(:**, num("3")) } + .to raise_error(Dcc::ExtractionError, /takes 2\.\.2/) + end + + it "rejects a power with three operands" do + expect { apply(:**, num("2"), num("9"), num("3")) } + .to raise_error(Dcc::ExtractionError, /takes 2\.\.2/) + end + + it "rejects a unary function with no operand" do + expect { apply(:exp) } + .to raise_error(Dcc::ExtractionError, /takes 1\.\.1/) + end + + it "rejects a unary function with an extra operand" do + expect { apply(:abs, num("-3"), num("99")) } + .to raise_error(Dcc::ExtractionError, /takes 1\.\.1/) + end + + it "rejects a division with one operand" do + expect { apply(:/, num("1")) } + .to raise_error(Dcc::ExtractionError, %r{takes 2\.\.2}) + end + + it "spells out an open-ended arity instead of printing a range" do + expect { apply(:+) } + .to raise_error(Dcc::ExtractionError, /takes 1 or more operand/) + end + + it "leaves the caller's operand array unfrozen" do + operands = [num("1"), num("2")] + Dcc::Extract::Formula::Ast::Apply.new(operator: :+, operands: operands) + expect(operands).not_to be_frozen + end + + it "allows n-ary addition" do + expect(evaluate(apply(:+, num("1"), num("2"), num("3")))) + .to eq([BigDecimal(6)]) + end + + it "allows a unary minus" do + expect(evaluate(apply(:-, num("5")))).to eq([BigDecimal(-5)]) + end + end + + # A non-finite *binding* is impossible by construction — Quantity rejects + # it, which its own spec covers — so only overrides are reachable here. + describe "non-finite overrides" do + let(:tree) do + ast(apply(:exp, var("x")), bindings: { "x" => quantity(BigDecimal(1)) }) + end + + it "rejects an infinite override" do + expect { described_class.call(tree, "x" => BigDecimal("Infinity")) } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + + it "rejects a NaN override" do + expect { described_class.call(tree, "x" => BigDecimal("NaN")) } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + + it "rejects an override that is not a number at all" do + expect { described_class.call(tree, "x" => "banana") } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + end + + # tan's pole is unreachable: pi/2 is not representable in decimal, so cos + # comes out as -5.579e-34 rather than zero. Assert what is true. + describe "tan near its pole" do + let(:half_pi) { apply(:/, node(:Constant, name: :pi), num("2")) } + + it "stays finite" do + expect(result(apply(:tan, half_pi))).to be_finite + end + + it "is very large" do + expect(result(apply(:tan, half_pi)).abs).to be > BigDecimal("1e30") + end + end + + describe "variables and broadcasting" do + let(:tree) do + ast(apply(:+, var("x"), num("1")), + bindings: { "x" => quantity(BigDecimal(1)) }) + end + + let(:list_tree) do + ast(apply(:+, var("t"), var("k")), + bindings: { "t" => quantity([1, 2, 3]), "k" => quantity(10) }) + end + + it "reads bindings from the document" do + expect(described_class.call(tree)).to eq([BigDecimal(2)]) + end + + it "lets overrides win" do + expect(described_class.call(tree, "x" => 10)).to eq([BigDecimal(11)]) + end + + it "broadcasts element-wise over list variables" do + expect(described_class.call(list_tree)) + .to eq([BigDecimal(11), BigDecimal(12), BigDecimal(13)]) + end + + it "ignores bindings the formula never references" do + bindings = { "a" => quantity(1), "unused" => quantity([1, 2, 3]) } + + expect(evaluate(apply(:+, var("a"), num("1")), bindings: bindings)) + .to eq([BigDecimal(2)]) + end + + it "raises when referenced list variables disagree on length" do + bindings = { "a" => quantity([1, 2]), "b" => quantity([1, 2, 3]) } + + expect { evaluate(apply(:+, var("a"), var("b")), bindings: bindings) } + .to raise_error(Dcc::ExtractionError, /length/i) + end + + # A one-element list is still a list. Flattening it to a scalar would + # let it broadcast against a longer list and quietly return three + # results instead of raising a mismatch. + it "treats a one-element list as a list, not a scalar" do + bindings = { "a" => quantity([5]), "b" => quantity([1, 2, 3]) } + + expect { evaluate(apply(:+, var("a"), var("b")), bindings: bindings) } + .to raise_error(Dcc::ExtractionError, /length/i) + end + + it "pairs two one-element lists" do + bindings = { "a" => quantity([5]), "b" => quantity([7]) } + + expect(evaluate(apply(:+, var("a"), var("b")), bindings: bindings)) + .to eq([BigDecimal(12)]) + end + + it "still broadcasts a true scalar against a list" do + bindings = { "a" => quantity(5), "b" => quantity([1, 2, 3]) } + + expect(evaluate(apply(:+, var("a"), var("b")), bindings: bindings)) + .to eq([BigDecimal(6), BigDecimal(7), BigDecimal(8)]) + end + + it "raises when a referenced variable has no value" do + expect { evaluate(apply(:+, var("missing"), num("1"))) } + .to raise_error(Dcc::ExtractionError, /missing/) + end + + # An empty list made the width zero, so Array.new(0) never ran the + # body: no values, no error, exit 0. It also hid a genuinely + # missing variable, because nothing ever looked one up. + describe "an empty referenced list" do + let(:bindings) { { "empty" => quantity([]) } } + + it "is refused rather than producing no values" do + body = apply(:+, var("empty"), num("1")) + + expect { evaluate(body, bindings: bindings) } + .to raise_error(Dcc::ExtractionError, /lists: \["empty"\]/) + end + + # It used to return [] here, so a formula referencing a variable + # that does not exist reported nothing at all. It still refuses on + # the empty list rather than on the missing name — the point is + # that it no longer answers "no values, all good". + it "refuses rather than returning [] when a name is also missing" do + body = apply(:+, var("empty"), var("missing")) + + expect { evaluate(body, bindings: bindings) } + .to raise_error(Dcc::ExtractionError, /empty value lists/) + end + end + end + + # A cost ceiling. Each example pins one of the places it is applied — + # referenced bindings, literals, operation results, fold + # intermediates, the predictive check before an exact power, and the + # two arguments the evaluator builds for BigMath itself. No one of + # them is reachable through another. + # + # The sizes here are deliberately modest. They are far past the limit, + # so they pin the guard, but small enough that removing the guard + # computes an answer in a fraction of a second instead of allocating + # for minutes — a mutation run has to stay survivable. The real cost + # (2**1e9 is 25s, three nested near-one powers need eleven billion + # digits) was measured outside the suite. + describe "the size ceiling" do + it "refuses an oversized literal that never reaches an operation" do + expect { evaluate(num("1e1000000")) } + .to raise_error(Dcc::ExtractionError, /A formula number exceeds/) + end + + it "refuses an oversized referenced binding" do + bindings = { "x" => quantity("1e1000000") } + + expect { evaluate(apply(:sin, var("x")), bindings: bindings) } + .to raise_error(Dcc::ExtractionError, /Formula variable 'x' exceeds/) + end + + # Formula.bindings_in builds a Quantity for every quantity in the + # data block before it looks at the formulae, so checking at + # coercion would take a whole document down over an unused value. + it "ignores an oversized binding no formula references" do + bindings = { "unused" => quantity("1e1000000") } + + expect(evaluate(num("2"), bindings: bindings)).to eq([BigDecimal(2)]) + end + + it "refuses an oversized operation result" do + expect { evaluate(apply(:exp, num("5000"))) } + .to raise_error(Dcc::ExtractionError, /result of exp exceeds/) + end + + # The answer is exactly 1 and passes any result-only check; the + # accumulator reaches an exponent of 1997 on the way there. + it "refuses a fold whose intermediate exceeds the ceiling" do + body = apply(:*, num("1.25e998"), num("1.25e998"), + num("8e-999"), num("8e-999")) + + expect { evaluate(body) } + .to raise_error(Dcc::ExtractionError, /result of \* exceeds/) + end + + it "refuses a huge integral exponent" do + expect { evaluate(apply(:**, num("2"), num("1e7"))) } + .to raise_error(Dcc::ExtractionError, /exact power/) + end + + # Exponent 0 throughout, so only a digit-aware bound stops it: the + # magnitude never moves while the digit count multiplies. + it "refuses nested near-one powers" do + body = apply(:**, apply(:**, num("0.99999999999"), num("999")), + num("99")) + + expect { evaluate(body) } + .to raise_error(Dcc::ExtractionError, /exact power/) + end + + it "still allows the exact powers a real formula uses" do + expect(result(apply(:**, num("2"), num("1000"))).precision).to eq(302) + end + + it "still allows a trigonometric argument inside the ceiling" do + expect(result(apply(:sin, num("1e999")))).to be_finite + end + + # `exponent * ln(base)` and `ln(value) / degree` are built inside + # the evaluator, so no operand check sees them. Both feed BigMath + # and both can exceed the ceiling while the answer comes back as + # 1.0, which is why they are checked where they are made. + describe "an internally built argument to BigMath.exp" do + let(:near_one) { "1.#{'0' * 998}1" } + + it "is checked on the fractional power path" do + expect { evaluate(apply(:**, num(near_one), num("1e-999"))) } + .to raise_error(Dcc::ExtractionError, /intermediate in \*\* exceeds/) + end + + it "is checked on the nth-root path" do + expect { evaluate(apply(:root, num(near_one), num("1e999"))) } + .to raise_error(Dcc::ExtractionError, /intermediate in root exceeds/) + end + end + + # Folding `+` through a checked reduce replaced `Array#sum`, and + # `sum`'s 0 seed is observable: `[-0].sum` is 0.0 where + # `[-0].reduce(:+)` is -0.0. `-0` reaches this from a real + # document, so the seed stays and this pins it. + it "keeps the sign of zero that Array#sum produced" do + expect(result(apply(:+, num("-0"))).to_s("F")).to eq("0.0") + end + end +end diff --git a/spec/dcc/extract/formula/parser_spec.rb b/spec/dcc/extract/formula/parser_spec.rb new file mode 100644 index 0000000..6174d43 --- /dev/null +++ b/spec/dcc/extract/formula/parser_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +RSpec.describe Dcc::Extract::Formula::Parser do + # Never assign a bare `Ast` constant in a spec file — it leaks to top + # level and collides with the evaluator spec. Build nodes via a helper. + def node(kind, **fields) + Dcc::Extract::Formula::Ast.const_get(kind).new(**fields) + end + + def parse(body) + ns = 'xmlns:ml="http://www.w3.org/1998/Math/MathML"' + xml = "#{body}" + described_class.call(Mml::V3::Math.from_xml(xml)) + end + + def num(value) + node(:Number, value: BigDecimal(value)) + end + + # Content MathML's is a sequence, so two top-level expressions + # is a legal shape. Taking `.first` of it dropped the rest with no + # error, no warning and exit 0 — the silent wrong answer this parser + # refuses everywhere else. + describe "expression cardinality" do + let(:declare) do + <<~ML + + R + + T + 1 + + + ML + end + + let(:degree) { "2" } + + it "refuses two top-level expressions" do + expect { parse("12") } + .to raise_error(Dcc::ExtractionError, /2 expressions where exactly one/) + end + + it "refuses two top-level declarations" do + second = declare.sub("R", "S") + + expect { parse(declare + second) } + .to raise_error(Dcc::ExtractionError, /2 expressions where exactly one/) + end + + it "refuses a declaration holding two lambdas" do + two = declare.sub("", + "2") + + expect { parse(two) } + .to raise_error(Dcc::ExtractionError, /must hold one then one/) + end + + it "refuses a lambda body of two expressions" do + two = declare.sub("1", "12") + + expect { parse(two) } + .to raise_error(Dcc::ExtractionError, /2 expressions where exactly one/) + end + + it "refuses a bvar holding two identifiers" do + two = declare.sub("T", + "TU") + + expect { parse(two) } + .to raise_error(Dcc::ExtractionError, /exactly one , found ci, ci/) + end + + # MathML lets a carry a , but that spells a + # derivative and this evaluator has none. The old parser took the + # and dropped the qualifier, which is a wrong answer, not a + # missing feature. + it "refuses a bvar qualifier it cannot honour, and names it" do + qualified = declare.sub("T", + "T#{degree}") + + expect { parse(qualified) } + .to raise_error(Dcc::ExtractionError, /one , found ci, degree/) + end + + it "refuses a bvar holding something other than an identifier" do + other = declare.sub("T", + "1") + + expect { parse(other) } + .to raise_error(Dcc::ExtractionError, / must contain exactly one/) + end + + it "still parses a single declaration" do + expect(parse(declare).name).to eq("R") + end + end + + # The PR refuses `base="8"` because a declared encoding it cannot read + # exactly is a wrong answer waiting to happen. A declared integer + # holding a fraction is the same contradiction. + describe "" do + it "refuses a fractional value" do + expect { parse('1.5') } + .to raise_error(Dcc::ExtractionError, /non-integer value: 1\.5/) + end + + # Guards the implicit-return trap: ending number_node on the check + # would put the check's value into the tree, and `abs` would then + # raise a bare NoMethodError out of a public call. + it "returns the number node for a whole value" do + expect(parse('2').body).to eq(num("2")) + end + + # Quoting the expanded decimal turned nine bytes of document into a + # hundred-kilobyte exception message. + it "quotes the source lexeme rather than expanding it" do + expect { parse('1e-100000') } + .to raise_error(Dcc::ExtractionError, /value: 1e-100000\z/) + end + end + + describe "operand order" do + let(:times) do + "R02" + end + let(:body) { parse(times).body } + + it "keeps the operator" do + expect(body.operator).to eq(:*) + end + + it "keeps operands in document order across mixed child types" do + expect(body.operands).to eq([node(:Variable, name: "R0"), num("2")]) + end + end + + describe "declare/lambda" do + let(:declare) do + <<~ML + + R + + T + T1 + + + ML + end + let(:ast) { parse(declare) } + + it "reads the function name" do + expect(ast.name).to eq("R") + end + + it "reads the bound variables" do + expect(ast.bound_variables).to eq(["T"]) + end + + it "marks a bvar reference as bound" do + expect(ast.body.operands.first).to eq(node(:BoundVariable, name: "T")) + end + end + + describe "nested applies" do + let(:nested) do + <<~ML + + + T + 11 + + ML + end + let(:body) { parse(nested).body } + + it "parses the outer operator" do + expect(body.operator).to eq(:**) + end + + it "parses the inner operator" do + expect(body.operands.last.operator).to eq(:+) + end + end + + it "parses constants" do + pi = "2" + + expect(parse(pi).body.operands.first).to eq(node(:Constant, name: :pi)) + end + + # MathML writes BEFORE the radicand and wraps it in an element. + # We unwrap it and store operands as [value, degree]. + describe "roots" do + let(:cube) do + <<~ML + + + 3 + 27 + + ML + end + + let(:nested_degree) do + <<~ML + + + + 12 + + 27 + + ML + end + + it "parses a square root as a single operand" do + body = parse("9").body + + expect(body).to eq(node(:Apply, operator: :root, operands: [num("9")])) + end + + it "unwraps and puts the radicand first" do + expect(parse(cube).body.operands).to eq([num("27"), num("3")]) + end + + it "allows a nested expression inside " do + expect(parse(nested_degree).body.operands.last.operator).to eq(:+) + end + end + + # Cardinality: taking `.first` of several radicands or several degrees + # would silently corrupt the evaluator's [value, degree] ordering. + describe "malformed input" do + let(:two_degrees) do + <<~ML + + + 2 + 3 + 8 + + ML + end + + let(:fat_degree) do + <<~ML + + + 23 + 8 + + ML + end + + let(:two_radicands) do + "89" + end + + let(:no_radicand) do + "3" + end + + it "rejects an unsupported operator" do + expect { parse("1") } + .to raise_error(Dcc::ExtractionError, /eq/) + end + + it "rejects more than one radicand" do + expect { parse(two_radicands) } + .to raise_error(Dcc::ExtractionError, /exactly one radicand/i) + end + + it "rejects a missing radicand" do + expect { parse(no_radicand) } + .to raise_error(Dcc::ExtractionError, /exactly one radicand/i) + end + + it "rejects more than one " do + expect { parse(two_degrees) } + .to raise_error(Dcc::ExtractionError, /at most one/i) + end + + it "rejects a holding several expressions" do + expect { parse(fat_degree) } + .to raise_error(Dcc::ExtractionError, /exactly one expression/i) + end + + it "rejects an empty " do + empty = "8" + + expect { parse(empty) } + .to raise_error(Dcc::ExtractionError, /exactly one expression/i) + end + + # The parser is one of the two boundaries where a number enters. + it "rejects a non-finite " do + expect { parse("Infinity") } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + + it "rejects a that is not a number" do + expect { parse("banana") } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + it "rejects an operator given the wrong number of operands" do + expect { parse("3") } + .to raise_error(Dcc::ExtractionError, /takes 2\.\.2/) + end + + it "rejects a math element holding no expression" do + expect { parse("") } + .to raise_error(Dcc::ExtractionError, /no formula/i) + end + end + + # A structured spreads its number across -separated parts, and + # concatenating them turns the rational one-half into 12. A plausible + # wrong answer is worse than a refusal. + describe "structured " do + def cn(attrs, body) + parse("#{body}") + end + + it "rejects a rational rather than reading 12 as twelve" do + expect { cn('type="rational"', "12") } + .to raise_error(Dcc::ExtractionError, %r{}) + end + + it "rejects e-notation" do + expect { cn('type="e-notation"', "13") } + .to raise_error(Dcc::ExtractionError, %r{}) + end + + it "rejects an unsupported type even without a separator" do + expect { cn('type="hexdouble"', "FF") } + .to raise_error(Dcc::ExtractionError, /Unsupported type/) + end + + it "accepts a plain typed number" do + expect(cn('type="real"', "1.5").body.operands.first).to eq(num("1.5")) + end + + it "accepts an untyped number" do + expect(cn("", "2").body.operands.first).to eq(num("2")) + end + + # Same silent-wrong-number shape as : octal 17 is fifteen, and + # reading it as decimal seventeen is a plausible wrong answer. + it "rejects an octal base rather than reading 17 as seventeen" do + expect { cn('type="integer" base="8"', "17") } + .to raise_error(Dcc::ExtractionError, /Unsupported base: 8/) + end + + it "rejects a binary base" do + expect { cn('base="2"', "101") } + .to raise_error(Dcc::ExtractionError, /Unsupported base: 2/) + end + + it "accepts an explicit base of ten" do + expect(cn('base="10"', "17").body.operands.first).to eq(num("17")) + end + end +end diff --git a/spec/dcc/extract/formula_spec.rb b/spec/dcc/extract/formula_spec.rb new file mode 100644 index 0000000..218792c --- /dev/null +++ b/spec/dcc/extract/formula_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +RSpec.describe Dcc::Extract::Formula do + let(:fixture) { File.read(fixtures_path("dcclib", "valid_formula.xml")) } + let(:dcc) { Dcc.parse(fixture) } + let(:formulae) { described_class.call(dcc) } + let(:formula) { formulae.first } + + # Reference values from PTB dcclib: + # spec/fixtures/dcclib_python_tests/tests/extraction/formulae/ + # test_formulae.py + # These are exact BigDecimal results, verified — do not round. + let(:defaults) do + [ + "100.0225", "109.77301212171875", "119.442643549375", + "129.03139428296875", "138.5392643225" + ].map { |value| BigDecimal(value) } + end + + describe "the Callendar-Van-Dusen fixture" do + it "finds exactly one formula" do + expect(formulae.size).to eq(1) + end + + it "reads the function name" do + expect(formula.name).to eq("R") + end + + it "reads the bound variable" do + expect(formula.bound_variables).to eq(["T"]) + end + end + + describe "bindings from the sibling quantity list" do + it "binds every identified quantity" do + expect(formula.bindings.keys).to contain_exactly("R0", "A", "B", "T") + end + + it "reads a scalar si:real value" do + expect(formula.bindings["R0"].value).to eq(BigDecimal("100.0225")) + end + + it "keeps the unit alongside the value" do + expect(formula.bindings["R0"].unit).to include("kilogram") + end + + it "reads a list si:realListXMLList value" do + expect(formula.bindings["T"].value) + .to eq([0, 25, 50, 75, 100].map { |n| BigDecimal(n) }) + end + end + + describe "evaluation" do + it "evaluates over the T list from the document" do + expect(formula.evaluate).to eq(defaults) + end + + it "evaluates at a single overridden T" do + expect(formula.evaluate("T" => 42)) + .to eq([BigDecimal("116.357161312039")]) + end + + it "evaluates element-wise over several T values" do + expect(formula.evaluate("T" => [42, 43])) + .to eq([BigDecimal("116.357161312039"), + BigDecimal("116.74329952359275")]) + end + + it "lets a parameter override replace the document value" do + expect(formula.evaluate("T" => 42, "R0" => 1)) + .to eq([BigDecimal("1.1633098684")]) + end + end + + it "returns nothing for a DCC without formulae" do + plain = Dcc.parse(File.read(fixtures_path("dcclib", "valid.xml"))) + + expect(described_class.call(plain)).to be_empty + end + + # `dcc:listType` is recursive. Walking only the first level of lists + # loses every binding nested below it — silently, since the formula + # itself still parses. + describe "quantities in a nested list" do + # Anchored on the closing run of the *coefficients* result, so the + # extra lands on the list we wrapped rather than on the + # first result's table. Plain methods, not `let`, to stay under the + # memoized-helper limit. + def closing + %r{\s*\s*\s*}m + end + + def nested_formula + wrapped = fixture.sub( + '', + '', + ) + nested = wrapped.sub(closing) do + "" + end + described_class.call(Dcc.parse(nested)).first + end + + it "still finds every binding" do + expect(nested_formula.bindings.keys) + .to contain_exactly("R0", "A", "B", "T") + end + + it "still evaluates correctly" do + expect(nested_formula.evaluate("T" => 42)) + .to eq([BigDecimal("116.357161312039")]) + end + end + + # `si:value` is an unvalidated :string attribute, so these are ordinary + # real-world documents, not crafted ones. None may leak a raw + # ArgumentError or TypeError out of a public extraction call. + describe "malformed si:value" do + def dcc_with_value(inner) + Dcc.parse(fixture.sub("100.0225", inner)) + end + + it "rejects a non-numeric value" do + expect { described_class.call(dcc_with_value("x")) } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + it "rejects a missing value" do + expect { described_class.call(dcc_with_value("")) } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + it "rejects a non-finite value" do + infinite = "Infinity" + + expect { described_class.call(dcc_with_value(infinite)) } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + end + + # The invariant the whole guard rests on: a Quantity cannot exist holding + # a bad number, which is why Evaluator#quantify may trust its contents. + describe Dcc::Extract::Formula::Quantity do + def build(value) + described_class.new(value: value, unit: nil, uncertainty: nil) + end + + it "coerces a scalar string on construction" do + expect(build("100.0225").value).to eq(BigDecimal("100.0225")) + end + + it "coerces every element of a list" do + expect(build(["1", 2]).values).to eq([BigDecimal(1), BigDecimal(2)]) + end + + it "refuses a non-numeric value" do + expect { build("banana") } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + # Ruby reads this as 1000. No document writes a number that way, so + # taking it silently would inflate the value tenfold. + it "refuses underscore-separated digits" do + expect { build("1_000") } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + it "refuses a nil value" do + expect { build(nil) } + .to raise_error(Dcc::ExtractionError, /not a number/i) + end + + it "refuses a non-finite value" do + expect { build("Infinity") } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + + it "refuses a non-finite element inside a list" do + expect { build(["1", "NaN"]) } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + + # `respond_to?(:to_ary)` alone would reach a NoMethodError on `map`. + it "refuses an object whose to_ary breaks the protocol" do + broken = Class.new { def to_ary = "not an array" }.new + + expect { build(broken) } + .to raise_error(Dcc::ExtractionError, /not a usable list/i) + end + + # Data freezes the struct, not the array inside it. + it "freezes the coerced list" do + expect(build(%w[1 2]).value).to be_frozen + end + + it "cannot be mutated past the constructor" do + expect { build(%w[1 2]).value << BigDecimal(3) } + .to raise_error(FrozenError) + end + end + + # `Formula.call` returns AST nodes as public API, so callers build them. + describe Dcc::Extract::Formula::Ast::Number do + it "coerces its value" do + expect(described_class.new(value: "2").value).to eq(BigDecimal(2)) + end + + it "refuses a non-finite value" do + expect { described_class.new(value: BigDecimal("Infinity")) } + .to raise_error(Dcc::ExtractionError, /not a finite number/i) + end + end + + describe Dcc::Extract::Formula::Ast::Constant do + it "accepts a name the evaluator dispatches on" do + expect(described_class.new(name: :pi).name).to eq(:pi) + end + + # Without the guard the evaluator's `case` returns nil and the formula + # evaluates to [nil] — a wrong answer rather than an error. + it "refuses a name outside the canonical set" do + expect { described_class.new(name: :bogus) } + .to raise_error(Dcc::ExtractionError, /not a formula constant/i) + end + end + + describe Dcc::Extract::Formula::Ast::Apply do + # Without the guard `trigonometric` returns nil and `finite` raises + # NoMethodError, leaking a raw Ruby failure out of the public API. + it "refuses an operator outside the canonical set" do + operand = Dcc::Extract::Formula::Ast::Number.new(value: 2) + expect { described_class.new(operator: :bogus, operands: [operand]) } + .to raise_error(Dcc::ExtractionError, /not a formula operator/i) + end + end + + # The two sets are declared on Ast to keep Parser a one-way dependency. + # This is what stops them drifting apart. + describe "the canonical sets" do + it "covers every operator the parser emits" do + expect(Dcc::Extract::Formula::Ast::OPERATORS) + .to match_array(Dcc::Extract::Formula::Parser::OPERATORS.values) + end + + it "covers every constant the parser emits" do + expect(Dcc::Extract::Formula::Ast::CONSTANTS) + .to match_array(Dcc::Extract::Formula::Parser::CONSTANTS.values) + end + end +end diff --git a/spec/dcc/quantity_format/formatter_spec.rb b/spec/dcc/quantity_format/formatter_spec.rb index d49cecf..0240297 100644 --- a/spec/dcc/quantity_format/formatter_spec.rb +++ b/spec/dcc/quantity_format/formatter_spec.rb @@ -35,4 +35,38 @@ expect(f.to_latex).to include("\\kelvin") end end + + # D-SI writes `NaN` for a not-measured uncertainty, and a parsed decimal list + # now carries it through. The precision of a rendered value is derived from + # `log10(uncertainty)`, which has no answer for NaN, so each form says NaN + # rather than raising. + describe "a NaN uncertainty" do + let(:formatter) do + described_class.new(value: BigDecimal("42.0"), + uncertainty: BigDecimal("NaN"), unit: "\\kelvin") + end + + it "renders the short form without raising" do + expect(formatter.to_short).to eq("42.0(NaN) kelvin") + end + + it "renders the long form without raising" do + expect(formatter.to_long).to eq("42.0 ± NaN kelvin") + end + + it "renders the LaTeX form without raising" do + expect(formatter.to_latex).to eq("\\qty{42.0 +- NaN}{\\kelvin}") + end + + # A NaN uncertainty carries no precision, so the value falls back to the + # same two-decimal default an absent uncertainty gets. Without this the + # two render the same number to different widths. + it "rounds the value the way an absent uncertainty does" do + value = BigDecimal("42.123456789") + with_nan = described_class.new(value: value, + uncertainty: BigDecimal("NaN")) + without = described_class.new(value: value, uncertainty: nil) + expect(with_nan.to_long).to eq("#{without.to_long} ± NaN") + end + end end diff --git a/spec/dcc/type_spec.rb b/spec/dcc/type_spec.rb index 6c8c14f..574ea05 100644 --- a/spec/dcc/type_spec.rb +++ b/spec/dcc/type_spec.rb @@ -116,6 +116,24 @@ arr = [BigDecimal("1.5"), BigDecimal("2.5")] expect(described_class.cast(arr)).to eq(arr) end + + # The D-SI pattern admits numbers and `NaN`, and nothing else + # (SI_Format.xsd:1195). Neither `Infinity` nor `INF` is legal, so an + # infinite value must not reach a document. + it "rejects positive infinity" do + expect { described_class.cast("Infinity") } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end + + it "rejects negative infinity" do + expect { described_class.cast("-Infinity") } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end + + it "rejects an infinite BigDecimal passed in an array" do + expect { described_class.cast([BigDecimal("Infinity")]) } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end end describe ".serialize" do @@ -128,6 +146,166 @@ it "returns empty string for nil" do expect(described_class.serialize(nil)).to eq("") end + + # `NaN` is an alternative in the pattern of every D-SI decimal type + # (SI_Format.xsd:1195), and D-SI writes it for a not-measured entry. + it "serializes NaN, which D-SI admits in every decimal type" do + expect(described_class.serialize(described_class.cast("1.5 NaN 2.5"))) + .to eq("1.5 NaN 2.5") + end + + # `serialize` is public and takes a raw BigDecimal, so casting is not the + # only way an infinite value can reach the wire. + it "refuses to serialize an infinity it was handed uncast" do + expect { described_class.serialize([BigDecimal("Infinity")]) } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end + end + + # `to_ary` keeps equality symmetric: without it `Array#==` short-circuits to + # false and the answer depends on which side the list is written on. + describe "equality with a plain Array" do + let(:values) { described_class.cast("0.1 0.2") } + let(:array) { [BigDecimal("0.1"), BigDecimal("0.2")] } + + it "compares equal with the list on the left" do + expect(values).to eq(array) + end + + it "compares equal with the array on the left" do + expect(array).to eq(values) + end + + it "is not equal to a different list" do + expect(values).not_to eq([BigDecimal("0.1"), BigDecimal("0.3")]) + end + + it "is not equal to an unrelated object" do + expect(values).not_to eq("0.1 0.2") + end + + # `hash` and `eql?` travel with `==`, or a list stops deduplicating. + it "deduplicates equal lists" do + expect([values, described_class.cast("0.1 0.2")].uniq.size).to eq(1) + end + + it "hashes equal lists alike" do + expect(values.hash).to eq(described_class.cast("0.1 0.2").hash) + end + + # `eql?` is what Hash pairs with `hash`, so it stays stricter than `==`. + # An equal Array hashes the same, and admitting it here would make lookup + # depend on which of the two was used as the key. + it "is eql? to an equal list" do + expect(values).to eql(described_class.cast("0.1 0.2")) + end + + it "is not eql? to an equal Array" do + expect(values).not_to eql(array) + end + + it "does not answer to an Array key" do + expect({ values => 1 }[array]).to be_nil + end + end + + describe "list reading" do + let(:values) { described_class.cast("0.1 0.2 0.3") } + + it "reports its length" do + expect(values.length).to eq(3) + end + + it "knows when it is empty" do + expect(described_class.cast("")).to be_empty + end + + it "returns the last entry" do + expect(values.last).to eq(BigDecimal("0.3")) + end + + # `join` must agree with `to_s`; delegating it to the BigDecimals would + # re-emit the scientific notation the wire form exists to avoid. + it "joins the serialized decimals" do + expect(values.join(" ")).to eq("0.1 0.2 0.3") + end + + it "joins on a given separator" do + expect(values.join("; ")).to eq("0.1; 0.2; 0.3") + end + + it "enumerates without a block" do + expect(values.each).to be_a(Enumerator) + end + end + + # YAML must carry the wire form, not a Ruby object tag that `safe_load` + # refuses to read back. `dcc convert yaml` is a shipped command. + describe "YAML round-trip" do + let(:yaml) { described_class.cast("0.072 0.089").to_yaml } + + it "dumps the space-separated wire form" do + expect(YAML.safe_load(yaml)).to eq("0.072 0.089") + end + + it "casts back to an equal list" do + expect(described_class.cast(YAML.safe_load(yaml))) + .to eq(described_class.cast("0.072 0.089")) + end + end + + # A decimal XML list is one element holding space-separated values. If the + # cast result looks like a collection to lutaml-model, the list is split into + # one element per value, each rendering a Ruby array literal. + describe "round-trip through a model attribute" do + before { Dcc::Si::V2.load_all! } + + let(:xml) do + <<~XML + + 0.072 0.089 0.107 -0.009 -0.084 + \\kelvin + + XML + end + + let(:serialized) { Nokogiri::XML(Dcc::Si::V2::RealListXmlList.from_xml(xml).to_xml) } + let(:emitted) { serialized.xpath("//*[local-name()='valueXMLList']") } + + it "emits exactly one valueXMLList element" do + expect(emitted.size).to eq(1) + end + + it "emits the values as space-separated decimals" do + expect(emitted.first.text).to eq("0.072 0.089 0.107 -0.009 -0.084") + end + end + + # A schema-valid NaN has to survive the whole path, not just `serialize`. + # Asserted on the emitted text rather than on list equality, because + # `BigDecimal("NaN") == BigDecimal("NaN")` is false. + describe "NaN round-trip through a model attribute" do + before { Dcc::Si::V2.load_all! } + + let(:xml) do + <<~XML + + 1.5 NaN 2.5 + \\kelvin + + XML + end + + let(:serialized) { Nokogiri::XML(Dcc::Si::V2::RealListXmlList.from_xml(xml).to_xml) } + let(:emitted) { serialized.xpath("//*[local-name()='valueXMLList']") } + + it "emits one valueXMLList element for a list containing NaN" do + expect(emitted.size).to eq(1) + end + + it "keeps NaN in place among the decimals" do + expect(emitted.first.text).to eq("1.5 NaN 2.5") + end end end diff --git a/spec/dcc/validate/schematron_spec.rb b/spec/dcc/validate/schematron_spec.rb index 8dba622..02dd8db 100644 --- a/spec/dcc/validate/schematron_spec.rb +++ b/spec/dcc/validate/schematron_spec.rb @@ -78,6 +78,57 @@ end end +RSpec.describe Dcc::Validate::Schematron::Rules::UncertaintyConsistency do + before { Dcc::Si::V2.load_all! } + + let(:rule) { described_class.new } + + def real_list(uncertainties, factors: "2") + Dcc::Si::V2::RealListXmlList.from_xml(<<~XML) + + 1 2 3 4 5 + \\kelvin + + #{uncertainties} + #{factors} + 0.95 + + + XML + end + + # D-SI broadcasts a single uncertainty across every value, the same way + # unitXMLList broadcasts a single unit. PTB's own reference documents use + # this far more often than a one-to-one list. + it "accepts a single uncertainty broadcast over many values" do + expect(rule.check_on(real_list("0.061"))).to be_empty + end + + it "accepts an uncertainty per value" do + list = real_list("0.1 0.2 0.3 0.4 0.5", factors: "2 2 2 2 2") + expect(rule.check_on(list)).to be_empty + end + + # PTB ships a document pairing nine uncertainties with one shared coverage + # factor, so each list decides broadcast independently of its siblings. + it "accepts a per-value list beside a broadcast sibling" do + list = real_list("0.1 0.2 0.3 0.4 0.5", factors: "2") + expect(rule.check_on(list)).to be_empty + end + + it "reports a sibling list that neither matches nor broadcasts" do + issues = rule.check_on(real_list("0.061", factors: "2 2 2")) + expect(issues.map(&:message)) + .to include(a_string_matching(/coverageFactorXMLList count \(3\)/)) + end + + it "reports an uncertainty count that neither matches nor broadcasts" do + issues = rule.check_on(real_list("0.1 0.2")) + expect(issues.map(&:message)) + .to include(a_string_matching(/uncertaintyXMLList count \(2\)/)) + end +end + # A plugin-supplied rule. Fires on any document carrying a schemaVersion, # so it is guaranteed to trigger on the valid.xml fixture. class PluginProbeRule < Dcc::Validate::Schematron::Rules::Base diff --git a/spec/dcc_parse_fidelity_spec.rb b/spec/dcc_parse_fidelity_spec.rb new file mode 100644 index 0000000..dc29700 --- /dev/null +++ b/spec/dcc_parse_fidelity_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "spec_helper" +require "nokogiri" + +# Parsing must not silently drop elements it claims to support. Each example +# counts an element in the source and in the re-serialized output; a mismatch +# means data was lost between parse and `to_xml`. +RSpec.describe Dcc, ".parse" do + # A DCC v2 document reaches D-SI types through the :dsi_v1 and :dsi_v2 + # fallbacks, and those contexts exist only once their version module has been + # loaded. Load both up front so every example stands on its own rather than + # relying on an earlier one having populated them. + before do + Dcc::Si::V1.load_all! + Dcc::Si::V2.load_all! + end + + def element_count(xml, name) + Nokogiri::XML(xml).xpath("//*[local-name()='#{name}']").size + end + + def round_trip(relative) + xml = File.read(fixtures_path(*relative.split("/"))) + Dcc.parser_for(Dcc.detect_version(xml)).load_all! + [xml, Dcc.parse(xml).to_xml] + end + + # Reported as a pair so one expectation covers both sides: a count that is + # right on the way out but wrong on the way in proves nothing. + def counts(relative, name) + source, serialized = round_trip(relative) + { in: element_count(source, name), out: element_count(serialized, name) } + end + + describe "dcc:list" do + { + "dcclib/valid.xml" => 1, + "dcclib/valid_formula.xml" => 2, + "dcc_excel/example.xml" => 8, + }.each do |fixture, expected| + it "round-trips every list in #{fixture}" do + expect(counts(fixture, "list")).to eq(in: expected, out: expected) + end + end + end + + describe "dcc:quantity" do + it "round-trips every quantity in dcc_excel/example.xml" do + expect(counts("dcc_excel/example.xml", "quantity")).to eq(in: 20, out: 20) + end + + # Counted alongside the list itself: a dcc:list that parses back to an + # empty wrapper still round-trips 1 to 1, so only the children catch it. + # + # 8 of 13, not 13 of 13. The five still lost sit under + # measurementMetaData/metaData/data, and dcc:data is unmapped on + # Dcc::Base::Statement, which MeasurementMetaData includes. Pinning the + # real number locks in the fix and the known gap together: map dcc:data + # and this example fails, telling you to raise the number. + it "recovers the quantities dcc:list was hiding in dcclib/valid.xml" do + expect(counts("dcclib/valid.xml", "quantity")).to eq(in: 13, out: 8) + end + end + + # dcc:mathml is a dcc:xmlType wrapper holding a foreign-namespace child, so + # the MathML tree hangs one level below it rather than replacing it. + describe "dcc:mathml" do + let(:fixture) { "dcclib/valid_formula.xml" } + + it "round-trips the mathml wrapper" do + expect(counts(fixture, "mathml")).to eq(in: 1, out: 1) + end + + it "round-trips the ml:math child" do + expect(counts(fixture, "math")).to eq(in: 1, out: 1) + end + + it "keeps the MathML body" do + expect(counts(fixture, "apply")).to eq(in: 5, out: 5) + end + end + + # D-SI v1 puts expandedUnc and coverageInterval directly under si:real, and + # v2 still permits both as deprecated members of the same xs:choice. + describe "si:expandedUnc" do + { + "dcc_examples/example.xml" => 10, + "dcc_examples/siliziumkugel.xml" => 8, + }.each do |fixture, expected| + it "round-trips every expandedUnc in #{fixture}" do + expect(counts(fixture, "expandedUnc")) + .to eq(in: expected, out: expected) + end + end + + it "keeps the uncertainty value" do + _, serialized = round_trip("dcc_examples/example.xml") + expect(element_count(serialized, "coverageFactor")).to eq(10) + end + end + + describe "si:coverageInterval" do + let(:real_xml) do + <<~XML + + 20.10 + \\metre + + 0.02 + 20.05 + 20.15 + 0.95 + + + XML + end + + %i[V1 V2].each do |version| + it "round-trips through Dcc::Si::#{version}::Real" do + Dcc::Si.const_get(version).load_all! + serialized = Dcc::Si.const_get(version)::Real.from_xml(real_xml).to_xml + expect(element_count(serialized, "intervalMin")).to eq(1) + end + end + end + + # A decimal XML list is one element holding space-separated values, so each + # emitted realListXMLList must carry exactly one valueXMLList. Counting the + # two against each other tests that directly, without depending on how many + # lists survive parsing — quantities nested under a statement or metaData are + # still dropped, because dcc:data is unmapped on Dcc::Base::Statement. + describe "si:valueXMLList" do + let(:serialized) { round_trip("dcclib/valid.xml").last } + let(:texts) do + Nokogiri::XML(serialized).xpath("//*[local-name()='valueXMLList']").map(&:text) + end + + it "emits one valueXMLList per realListXMLList" do + expect(texts.size).to eq(element_count(serialized, "realListXMLList")) + end + + # 5 of 9, capped by that same unmapped dcc:data rather than by anything + # this change controls. Asserted absolutely as well as as a ratio, so a + # regression that dropped every list could not satisfy 0 == 0. + it "recovers the value lists dcc:list was hiding" do + expect(counts("dcclib/valid.xml", "valueXMLList")).to eq(in: 9, out: 5) + end + + it "emits at least one list" do + expect(texts).not_to be_empty + end + + it "emits space-separated decimals rather than Ruby array literals" do + expect(texts).to all(match(/\A-?[\d.]+( -?[\d.]+)*\z/)) + end + + it "preserves the original values verbatim" do + expect(texts).to include("306.248 373.121 448.253 523.319 593.154") + end + end +end