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
+
+ # `