dcc 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +19 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +560 -10
  6. data/CHANGELOG.adoc +34 -1
  7. data/CONTRIBUTING.adoc +8 -1
  8. data/Gemfile +5 -7
  9. data/README.adoc +162 -7
  10. data/dcc.gemspec +9 -2
  11. data/docs/schema-versions.adoc +20 -7
  12. data/examples/build_minimal_dcc.rb +0 -0
  13. data/examples/convert_all_formats.rb +2 -1
  14. data/examples/parse_and_inspect.rb +0 -0
  15. data/examples/quantity_math_demo.rb +7 -7
  16. data/examples/validate_full.rb +1 -1
  17. data/lib/dcc/base/calibration_laboratory.rb +2 -1
  18. data/lib/dcc/base/condition.rb +5 -2
  19. data/lib/dcc/base/core_data.rb +8 -3
  20. data/lib/dcc/base/formula.rb +3 -3
  21. data/lib/dcc/base/identification.rb +6 -3
  22. data/lib/dcc/base/mathml.rb +33 -0
  23. data/lib/dcc/base/quantity.rb +2 -1
  24. data/lib/dcc/base/resp_person.rb +4 -2
  25. data/lib/dcc/base/text.rb +1 -1
  26. data/lib/dcc/base.rb +2 -1
  27. data/lib/dcc/builder/dsl.rb +1 -1
  28. data/lib/dcc/builder/session.rb +16 -14
  29. data/lib/dcc/builder.rb +3 -3
  30. data/lib/dcc/cli/cli.rb +152 -26
  31. data/lib/dcc/cli/formatters.rb +35 -7
  32. data/lib/dcc/cli.rb +1 -1
  33. data/lib/dcc/context_configuration.rb +2 -1
  34. data/lib/dcc/context_options.rb +4 -2
  35. data/lib/dcc/convert/csv.rb +27 -9
  36. data/lib/dcc/convert/html.rb +26 -110
  37. data/lib/dcc/convert/json.rb +10 -7
  38. data/lib/dcc/convert/liquid_filters.rb +58 -0
  39. data/lib/dcc/convert/result.rb +1 -1
  40. data/lib/dcc/convert/templates/report.liquid +250 -0
  41. data/lib/dcc/convert/yaml.rb +1 -1
  42. data/lib/dcc/convert.rb +1 -0
  43. data/lib/dcc/diff/change.rb +1 -1
  44. data/lib/dcc/diff/result.rb +8 -4
  45. data/lib/dcc/diff.rb +43 -11
  46. data/lib/dcc/error.rb +7 -0
  47. data/lib/dcc/extract/file.rb +3 -4
  48. data/lib/dcc/extract/formula/ast.rb +137 -0
  49. data/lib/dcc/extract/formula/evaluator.rb +357 -0
  50. data/lib/dcc/extract/formula/parser.rb +305 -0
  51. data/lib/dcc/extract/formula/quantity.rb +89 -0
  52. data/lib/dcc/extract/formula.rb +110 -0
  53. data/lib/dcc/extract/ring.rb +1 -1
  54. data/lib/dcc/extract.rb +1 -1
  55. data/lib/dcc/i18n/text_lookup.rb +8 -4
  56. data/lib/dcc/i18n.rb +1 -1
  57. data/lib/dcc/inspect/report.rb +51 -14
  58. data/lib/dcc/inspect.rb +1 -1
  59. data/lib/dcc/migrate/route.rb +44 -0
  60. data/lib/dcc/migrate/v2_to_v3.rb +445 -0
  61. data/lib/dcc/migrate.rb +130 -26
  62. data/lib/dcc/plugin/base.rb +64 -0
  63. data/lib/dcc/plugin/registry.rb +7 -2
  64. data/lib/dcc/plugin.rb +6 -2
  65. data/lib/dcc/quantity_format/formatter.rb +16 -10
  66. data/lib/dcc/quantity_format.rb +1 -1
  67. data/lib/dcc/quantity_math/complex.rb +252 -0
  68. data/lib/dcc/quantity_math/matrix.rb +217 -0
  69. data/lib/dcc/quantity_math/quantity.rb +1 -1
  70. data/lib/dcc/quantity_math/real.rb +6 -6
  71. data/lib/dcc/quantity_math.rb +31 -3
  72. data/lib/dcc/schema/version.rb +2 -2
  73. data/lib/dcc/server/app.html +282 -0
  74. data/lib/dcc/server/app.rb +171 -87
  75. data/lib/dcc/server/demo_cert.xml +244 -0
  76. data/lib/dcc/server/storage/entry.rb +1 -1
  77. data/lib/dcc/server/storage/memory.rb +3 -2
  78. data/lib/dcc/server/storage.rb +1 -1
  79. data/lib/dcc/server.rb +1 -1
  80. data/lib/dcc/si/base/complex.rb +1 -1
  81. data/lib/dcc/si/base/complex_list_xml_list.rb +1 -1
  82. data/lib/dcc/si/base/constant.rb +1 -1
  83. data/lib/dcc/si/base/coverage_interval.rb +36 -0
  84. data/lib/dcc/si/base/coverage_interval_mu.rb +1 -1
  85. data/lib/dcc/si/base/expanded_mu.rb +1 -1
  86. data/lib/dcc/si/base/expanded_unc.rb +34 -0
  87. data/lib/dcc/si/base/expanded_unc_xml_list.rb +3 -2
  88. data/lib/dcc/si/base/hybrid.rb +3 -2
  89. data/lib/dcc/si/base/measurement_uncertainty_univariate.rb +1 -1
  90. data/lib/dcc/si/base/real.rb +13 -3
  91. data/lib/dcc/si/base/real_list_xml_list.rb +5 -3
  92. data/lib/dcc/si/base/si_list.rb +3 -2
  93. data/lib/dcc/si/base/standard_mu.rb +1 -1
  94. data/lib/dcc/si/base.rb +5 -2
  95. data/lib/dcc/si/v1/coverage_interval.rb +8 -0
  96. data/lib/dcc/si/v1/expanded_unc.rb +1 -16
  97. data/lib/dcc/si/v1/measurement_uncertainty_univariate.rb +2 -1
  98. data/lib/dcc/si/v1.rb +5 -2
  99. data/lib/dcc/si/v2/coverage_interval.rb +8 -0
  100. data/lib/dcc/si/v2/coverage_interval_mu_xml_list.rb +4 -2
  101. data/lib/dcc/si/v2/expanded_mu_xml_list.rb +2 -1
  102. data/lib/dcc/si/v2/expanded_unc.rb +8 -0
  103. data/lib/dcc/si/v2/measurement_uncertainty_univariate.rb +2 -1
  104. data/lib/dcc/si/v2.rb +8 -3
  105. data/lib/dcc/signature/result.rb +2 -2
  106. data/lib/dcc/signature/signer.rb +31 -18
  107. data/lib/dcc/signature/verifier.rb +23 -21
  108. data/lib/dcc/signature.rb +1 -1
  109. data/lib/dcc/streaming/reader.rb +246 -0
  110. data/lib/dcc/streaming.rb +79 -0
  111. data/lib/dcc/transform/result.rb +1 -1
  112. data/lib/dcc/transform/xslt.rb +22 -12
  113. data/lib/dcc/transform.rb +5 -4
  114. data/lib/dcc/type/base64_binary.rb +5 -3
  115. data/lib/dcc/type/decimal_xml_list.rb +99 -13
  116. data/lib/dcc/type/iso_country_code.rb +3 -2
  117. data/lib/dcc/type/iso_language_code.rb +3 -2
  118. data/lib/dcc/type/schema_version.rb +3 -2
  119. data/lib/dcc/type/si_unit.rb +3 -2
  120. data/lib/dcc/type_guards.rb +3 -3
  121. data/lib/dcc/v2/calibration_laboratory.rb +2 -1
  122. data/lib/dcc/v2/condition.rb +22 -1
  123. data/lib/dcc/v2/configuration.rb +1 -1
  124. data/lib/dcc/v2/digital_calibration_certificate.rb +2 -1
  125. data/lib/dcc/v2/identification.rb +14 -1
  126. data/lib/dcc/v2/mathml.rb +8 -0
  127. data/lib/dcc/v2/measurement_meta_data_list.rb +2 -1
  128. data/lib/dcc/v2/quantity.rb +4 -2
  129. data/lib/dcc/v2.rb +4 -2
  130. data/lib/dcc/v3/calibration_laboratory.rb +2 -1
  131. data/lib/dcc/v3/configuration.rb +1 -1
  132. data/lib/dcc/v3/digital_calibration_certificate.rb +2 -1
  133. data/lib/dcc/v3/mathml.rb +8 -0
  134. data/lib/dcc/v3/measurement_meta_data_list.rb +2 -1
  135. data/lib/dcc/v3/quantity.rb +4 -2
  136. data/lib/dcc/v3.rb +12 -2
  137. data/lib/dcc/validate/business_rules.rb +23 -9
  138. data/lib/dcc/validate/issue.rb +2 -1
  139. data/lib/dcc/validate/result.rb +6 -4
  140. data/lib/dcc/validate/schematron/profile.rb +73 -18
  141. data/lib/dcc/validate/schematron/rule.rb +13 -3
  142. data/lib/dcc/validate/schematron/rules/administrative_data_completeness.rb +11 -4
  143. data/lib/dcc/validate/schematron/rules/base.rb +1 -2
  144. data/lib/dcc/validate/schematron/rules/date_range_check.rb +11 -5
  145. data/lib/dcc/validate/schematron/rules/id_refid_linking.rb +0 -3
  146. data/lib/dcc/validate/schematron/rules/influence_conditions_placement.rb +22 -12
  147. data/lib/dcc/validate/schematron/rules/iso_code_validation.rb +31 -14
  148. data/lib/dcc/validate/schematron/rules/language_code_dedup.rb +12 -5
  149. data/lib/dcc/validate/schematron/rules/non_si_declaration.rb +7 -4
  150. data/lib/dcc/validate/schematron/rules/release_format_check.rb +7 -4
  151. data/lib/dcc/validate/schematron/rules/schema_version_check.rb +13 -9
  152. data/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +39 -10
  153. data/lib/dcc/validate/schematron/rules/unit_format_check.rb +2 -3
  154. data/lib/dcc/validate/schematron/rules/used_methods_placement.rb +15 -10
  155. data/lib/dcc/validate/schematron/rules/used_software_placement.rb +15 -10
  156. data/lib/dcc/validate/schematron/rules/xml_list_spacing.rb +4 -3
  157. data/lib/dcc/validate/schematron/rules.rb +28 -14
  158. data/lib/dcc/validate/schematron.rb +1 -1
  159. data/lib/dcc/validate/xsd.rb +9 -3
  160. data/lib/dcc/validate.rb +1 -1
  161. data/lib/dcc/version.rb +1 -1
  162. data/lib/dcc/versioned_parser.rb +1 -1
  163. data/lib/dcc.rb +68 -10
  164. data/sample_dcc.xml +140 -0
  165. data/web/index.html +12 -0
  166. data/web/package-lock.json +1599 -0
  167. data/web/package.json +22 -0
  168. data/web/src/App.vue +349 -0
  169. data/web/src/lib/builder.ts +171 -0
  170. data/web/src/lib/dcc.ts +338 -0
  171. data/web/src/lib/units.json +5581 -0
  172. data/web/src/main.ts +5 -0
  173. data/web/src/style.css +27 -0
  174. data/web/tsconfig.json +15 -0
  175. data/web/vite.config.ts +14 -0
  176. metadata +148 -9
data/lib/dcc/error.rb CHANGED
@@ -37,9 +37,16 @@ module Dcc
37
37
  # Raised when an unsupported schema version is requested.
38
38
  class UnknownVersionError < Error; end
39
39
 
40
+ # Raised when a migration direction is not supported — any downgrade, and
41
+ # any pair whose incompatibilities have not been verified.
42
+ class UnsupportedMigrationError < Error; end
43
+
40
44
  # Raised when the builder DSL is used incorrectly.
41
45
  class BuilderError < Error; end
42
46
 
47
+ # Raised when a plugin gem's entry file cannot be loaded.
48
+ class PluginError < Error; end
49
+
43
50
  # Raised when a soft dependency (`xmldsig`, `sinatra`, etc.) is required
44
51
  # but not installed.
45
52
  class MissingDependencyError < Error
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
- require "set"
5
4
 
6
5
  module Dcc
7
6
  module Extract
@@ -40,7 +39,7 @@ module Dcc
40
39
  # @param dcc [Lutaml::Model::Serializable]
41
40
  # @yieldparam [Dcc::Extract::File]
42
41
  # @return [Array<Dcc::Extract::File>] the list when no block given.
43
- def each(dcc, &block)
42
+ def each(dcc, &)
44
43
  list = []
45
44
  visited = ::Set.new
46
45
 
@@ -65,7 +64,7 @@ module Dcc
65
64
  collect.call(dcc, nil)
66
65
  return list unless block_given?
67
66
 
68
- list.each(&block)
67
+ list.each(&)
69
68
  end
70
69
 
71
70
  # @param dcc [Lutaml::Model::Serializable]
@@ -140,4 +139,4 @@ module Dcc
140
139
  end
141
140
  end
142
141
  end
143
- end
142
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Dcc
6
+ module Extract
7
+ module Formula
8
+ # One extracted formula: its declared name, the lambda's bound
9
+ # variables, the expression tree, and the variable bindings found
10
+ # alongside it in the DCC.
11
+ class Ast
12
+ attr_reader :name, :bound_variables, :body, :bindings
13
+
14
+ # @param name [String, nil] the `ml:declare` function name (e.g. "R").
15
+ # @param bound_variables [Array<String>] the `ml:lambda` bvar names.
16
+ # @param body [Object] the expression tree root.
17
+ # @param bindings [Hash{String => Quantity}] variable values
18
+ # found in the DCC.
19
+ def initialize(name:, bound_variables:, body:, bindings:)
20
+ @name = name
21
+ @bound_variables = bound_variables
22
+ @body = body
23
+ @bindings = bindings
24
+ end
25
+
26
+ # @param overrides [Hash{String => BigDecimal, Array<BigDecimal>}]
27
+ # @return [Array<BigDecimal>]
28
+ def evaluate(overrides = {})
29
+ Evaluator.call(self, overrides)
30
+ end
31
+
32
+ # Bound and free variables are both reported: an override may
33
+ # legitimately name either. Repeats are left in — the evaluator
34
+ # slices with these names and the CLI subtracts them, and neither
35
+ # cares how often a name appears.
36
+ #
37
+ # @return [Array<String>] every variable the body mentions.
38
+ def variables
39
+ names_in(body)
40
+ end
41
+
42
+ def to_s
43
+ "#{name || '(anonymous)'}(#{bound_variables.join(', ')})"
44
+ end
45
+
46
+ # `Formula.call` hands `Ast` objects back as public API, so callers
47
+ # legitimately build and hold nodes. Number therefore coerces
48
+ # through the same boundary as Quantity.
49
+ Number = ::Data.define(:value) do
50
+ def initialize(value:)
51
+ super(value: Quantity.decimal(value))
52
+ end
53
+ end
54
+
55
+ Variable = ::Data.define(:name)
56
+ BoundVariable = ::Data.define(:name)
57
+
58
+ # The symbols the evaluator can dispatch on. Its `case` statements
59
+ # have no `else`, so an unrecognised name would evaluate to nil —
60
+ # a silent wrong answer for a constant, a NoMethodError for an
61
+ # operator. Rejecting at construction keeps both out of the API.
62
+ CONSTANTS = %i[pi e].freeze
63
+ OPERATORS = %i[+ - * / ** root exp ln log sin cos tan abs].freeze
64
+
65
+ Constant = ::Data.define(:name) do
66
+ def initialize(name:)
67
+ Constant.check_name(name)
68
+ super
69
+ end
70
+
71
+ def self.check_name(name)
72
+ return if CONSTANTS.include?(name)
73
+
74
+ raise ::Dcc::ExtractionError,
75
+ "#{name} is not a formula constant, expected one of " \
76
+ "#{CONSTANTS.join(', ')}"
77
+ end
78
+ end
79
+
80
+ # How many operands each operator accepts; anything absent is a
81
+ # single-argument function. Without this, `<power/>` with one
82
+ # operand quietly computes `x ** x` and with three quietly drops
83
+ # the middle one — wrong answers rather than errors.
84
+ ARITY = {
85
+ "+": (1..),
86
+ "*": (1..),
87
+ "-": (1..2),
88
+ "/": (2..2),
89
+ "**": (2..2),
90
+ root: (1..2),
91
+ }.freeze
92
+
93
+ UNARY = (1..1)
94
+
95
+ # Checked in the constructor so a directly-built AST is held to the
96
+ # same contract as a parsed one.
97
+ Apply = ::Data.define(:operator, :operands) do
98
+ def initialize(operator:, operands:)
99
+ Apply.check_operator(operator)
100
+ Apply.check_arity(operator, operands)
101
+ super(operator: operator, operands: operands.dup.freeze)
102
+ end
103
+
104
+ def self.check_operator(operator)
105
+ return if OPERATORS.include?(operator)
106
+
107
+ raise ::Dcc::ExtractionError,
108
+ "#{operator} is not a formula operator, expected one of " \
109
+ "#{OPERATORS.join(', ')}"
110
+ end
111
+
112
+ def self.check_arity(operator, operands)
113
+ allowed = ARITY.fetch(operator, UNARY)
114
+ return if allowed.cover?(operands.size)
115
+
116
+ expected = allowed.end ? allowed.to_s : "#{allowed.begin} or more"
117
+ raise ::Dcc::ExtractionError,
118
+ "#{operator} takes #{expected} operand(s) in formula, " \
119
+ "got #{operands.size}"
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ # Walks the tree for `variables`. Private: the tree shape is not
126
+ # part of what this gem promises.
127
+ def names_in(node)
128
+ case node
129
+ in (Variable | BoundVariable) => ref then [ref.name]
130
+ in Apply(operands:) then operands.flat_map { |o| names_in(o) }
131
+ else []
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/math"
5
+
6
+ module Dcc
7
+ module Extract
8
+ module Formula
9
+ # Evaluates a `Formula::Ast` numerically.
10
+ #
11
+ # Only variables the formula actually references take part:
12
+ # extraction hands over every identified quantity in the data block,
13
+ # and letting an unreferenced sibling list drive the result width
14
+ # would fail valid formulae. Referenced list variables must agree on
15
+ # length and combine element-wise, so the result is always an array.
16
+ #
17
+ # PRECISION applies to inexact operations only — `/`, `root`, `exp`,
18
+ # `ln`, `log`, the trig functions, non-integer `**` and negative
19
+ # integer `**`. Addition, subtraction, multiplication and
20
+ # non-negative integer powers stay exact.
21
+ #
22
+ # Only the long-stable `BigMath` subset is used (`sqrt`, `exp`,
23
+ # `log`, `sin`, `cos`, `PI`, `E`) — `BigMath.tan` and
24
+ # `BigMath.log10` are recent additions and `bigdecimal` ships as a
25
+ # bundled gem from Ruby 3.4, so the version we get is not ours to
26
+ # assume.
27
+ module Evaluator
28
+ PRECISION = 34
29
+ TEN = BigDecimal(10)
30
+ ZERO_DIVISION = "Division by zero in formula"
31
+
32
+ # A cost ceiling, not a correctness one. Three things in here
33
+ # scale with the size of a value, and `BigDecimal#precision`
34
+ # counts digits across the exponent span, so one limit covers
35
+ # all three:
36
+ #
37
+ # * BigMath reduces a trigonometric argument before it can
38
+ # answer, and that is linear in the exponent — sin(1e1000)
39
+ # is 1ms, sin(1e100000) 0.74s, sin(1e1000000) 8.6s.
40
+ # * exact `**` and `*` allocate one digit at a time, and the
41
+ # exponent does not constrain that at all: 0.99999999999
42
+ # raised to 999 twice keeps exponent 0 while going from 11
43
+ # digits to eleven million.
44
+ # * the printed value — `to_s("F")` of 1e1000000 is a megabyte.
45
+ #
46
+ # Nothing real comes near it: exp(1000) is 435, tan(pi/2) is 34,
47
+ # 2**1000 is 302, and IEEE double tops out at 1e308.
48
+ #
49
+ # `precision` has been on BigDecimal since 3.0.0, well under the
50
+ # gem's Ruby floor of 3.2.
51
+ MAX_DIGITS = 1_000
52
+
53
+ class << self
54
+ # @param ast [Dcc::Extract::Formula::Ast]
55
+ # @param overrides [Hash{String => Object}]
56
+ # @return [Array<BigDecimal>]
57
+ def call(ast, overrides = {})
58
+ env = environment(ast, overrides)
59
+ Array.new(width(env)) do |index|
60
+ evaluate(ast.body, scope(env, index))
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # Values stay wrapped in a Quantity through the whole environment
67
+ # so `list?` survives. Flattening to a bare array here would make
68
+ # a genuine one-element list indistinguishable from a scalar, and
69
+ # it would then broadcast against a longer list instead of
70
+ # raising a length mismatch.
71
+ def environment(ast, overrides)
72
+ ast.bindings.merge(overrides)
73
+ .slice(*ast.variables)
74
+ .to_h { |name, value| [name, sized(name, quantify(value))] }
75
+ end
76
+
77
+ # Only the referenced slice is checked. `Formula.bindings_in`
78
+ # builds a Quantity for every identified quantity in the data
79
+ # block before it looks at the formulae, so refusing an
80
+ # oversized one at coercion would take down every other
81
+ # formula in the document over a value none of them uses.
82
+ def sized(name, quantity)
83
+ subject = "Formula variable '#{name}'"
84
+ numbers = quantity.values
85
+ numbers.each { |value| check_size(value, subject) }
86
+ quantity
87
+ end
88
+
89
+ # A Quantity coerced every value in its constructor, so its
90
+ # contents are already finite BigDecimals. A raw override has had
91
+ # no such treatment, so it goes through the same constructor.
92
+ def quantify(value)
93
+ return value if value.is_a?(Quantity)
94
+
95
+ Quantity.new(value: value)
96
+ end
97
+
98
+ # Only list variables set the width; scalars broadcast. Every
99
+ # list must agree, whatever its length — a one-element list is
100
+ # still a list.
101
+ def width(env)
102
+ lists = env.select { |_, quantity| quantity.list? }
103
+ distinct = lists.each_value.map { |q| q.values.size }.uniq
104
+ check_lengths(distinct, lists.keys)
105
+
106
+ distinct.first || 1
107
+ end
108
+
109
+ # Refuses both ways a referenced list can make a width
110
+ # meaningless. An empty one used to give width zero, so
111
+ # `Array.new(0)` never evaluated the body at all: no values,
112
+ # no error, exit 0 — and a genuinely missing variable went
113
+ # unreported with it, because nothing ever looked one up.
114
+ def check_lengths(distinct, names)
115
+ if distinct.size > 1
116
+ raise ::Dcc::ExtractionError,
117
+ "Formula variables have mismatched list lengths: " \
118
+ "#{distinct.sort.inspect}"
119
+ end
120
+ return unless distinct == [0]
121
+
122
+ raise ::Dcc::ExtractionError,
123
+ "Formula variables have empty value lists: " \
124
+ "#{names.sort.inspect}"
125
+ end
126
+
127
+ def scope(env, index)
128
+ env.transform_values do |quantity|
129
+ quantity.list? ? quantity.values[index] : quantity.value
130
+ end
131
+ end
132
+
133
+ # Bound and free variables resolve identically; the distinction
134
+ # matters to the parser, not here. They share one clause via
135
+ # `(A | B) => ref`, which binds the node itself — `A(name:) |
136
+ # B(name:)` is a syntax error ("duplicated variable name").
137
+ def evaluate(node, scope)
138
+ case node
139
+ in Ast::Number(value:) then check_size(value, "A formula number")
140
+ in Ast::Constant(name:) then constant(name)
141
+ in (Ast::Variable | Ast::BoundVariable) => ref
142
+ lookup(ref.name, scope)
143
+ in Ast::Apply(operator:, operands:)
144
+ operate(operator, operands.map { |o| evaluate(o, scope) })
145
+ end
146
+ end
147
+
148
+ # Needs no size check: BigMath.PI(34) and E(34) are 34 digits
149
+ # by construction, so a constant cannot reach the ceiling.
150
+ def constant(name)
151
+ case name
152
+ when :pi then BigMath.PI(PRECISION)
153
+ when :e then BigMath.E(PRECISION)
154
+ end
155
+ end
156
+
157
+ def lookup(name, scope)
158
+ scope.fetch(name) do
159
+ raise ::Dcc::ExtractionError,
160
+ "No value for formula variable '#{name}'"
161
+ end
162
+ end
163
+
164
+ # Every computed value is checked before it can become another
165
+ # operand. Guarding inputs alone is not enough: `exp(1e100)` is
166
+ # Infinity and `sin(Infinity)` is NaN from finite inputs, and a
167
+ # non-finite operand then poisons everything above it. A huge but
168
+ # finite result still passes.
169
+ def operate(operator, args)
170
+ checked(dispatch(operator, args), operator)
171
+ end
172
+
173
+ # Every value the evaluator produces goes through here — the
174
+ # answer of an operation and every intermediate inside an
175
+ # n-ary fold alike. Checking only the answer let
176
+ # 1.25e998 * 1.25e998 * 8e-999 * 8e-999 run the accumulator up
177
+ # to an exponent of 1997 and land back on exactly 1 unnoticed.
178
+ def checked(value, operator, role = "result of")
179
+ check_size(finite(value, operator), "The #{role} #{operator}")
180
+ end
181
+
182
+ def check_size(value, subject)
183
+ return value if value.precision <= MAX_DIGITS
184
+
185
+ raise ::Dcc::ExtractionError,
186
+ "#{subject} exceeds the formula limit of " \
187
+ "#{MAX_DIGITS} digits"
188
+ end
189
+
190
+ def finite(result, operator)
191
+ return result if result.finite?
192
+
193
+ raise ::Dcc::ExtractionError,
194
+ "#{operator} produced a non-finite result in formula"
195
+ end
196
+
197
+ # Split by shape: n-ary algebraic operators fold over their
198
+ # operands, the rest are single-argument functions.
199
+ def dispatch(operator, args)
200
+ case operator
201
+ when :+, :*, :-, :/ then fold(operator, args)
202
+ when :** then power(args.first, args.last)
203
+ when :root then root(args.first, args[1])
204
+ else function(operator, args.first)
205
+ end
206
+ end
207
+
208
+ # Folds through `checked` so an intermediate cannot exceed the
209
+ # ceiling on its way to an answer that does not.
210
+ #
211
+ # `Array#sum` seeds with 0 and that seed is observable —
212
+ # `[-0].sum` is 0.0 where `[-0].reduce(:+)` is -0.0 — so `+`
213
+ # folds from the same seed and no printed value moves.
214
+ def fold(operator, args)
215
+ return -args.first if operator == :- && args.size == 1
216
+
217
+ operands = operator == :+ ? [BigDecimal(0), *args] : args
218
+ operands.reduce do |left, right|
219
+ checked(step(operator, left, right), operator)
220
+ end
221
+ end
222
+
223
+ def step(operator, left, right)
224
+ case operator
225
+ when :+ then left + right
226
+ when :* then left * right
227
+ when :- then left - right
228
+ else divide(left, right)
229
+ end
230
+ end
231
+
232
+ def function(operator, arg)
233
+ case operator
234
+ when :exp then BigMath.exp(arg, PRECISION)
235
+ when :ln then ln(arg)
236
+ when :log then divide(ln(arg), ln(TEN))
237
+ when :abs then arg.abs
238
+ else trigonometric(operator, arg)
239
+ end
240
+ end
241
+
242
+ def trigonometric(operator, arg)
243
+ case operator
244
+ when :sin then BigMath.sin(arg, PRECISION)
245
+ when :cos then BigMath.cos(arg, PRECISION)
246
+ when :tan then tan(arg)
247
+ end
248
+ end
249
+
250
+ # BigDecimal returns Infinity/NaN for a zero divisor rather than
251
+ # raising, so guard the input.
252
+ def divide(numerator, denominator)
253
+ raise ::Dcc::ExtractionError, ZERO_DIVISION if denominator.zero?
254
+
255
+ numerator.div(denominator, PRECISION)
256
+ end
257
+
258
+ def tan(value)
259
+ divide(BigMath.sin(value, PRECISION),
260
+ BigMath.cos(value, PRECISION))
261
+ end
262
+
263
+ def ln(value)
264
+ if value <= 0
265
+ raise ::Dcc::ExtractionError,
266
+ "Logarithm of a non-positive value in formula"
267
+ end
268
+
269
+ BigMath.log(value, PRECISION)
270
+ end
271
+
272
+ # Non-negative integer exponents stay exact; everything else goes
273
+ # through the explicit-precision path. `0 ** 0.5` is 0, not an
274
+ # error, so zero is handled before the ln guard can reject it.
275
+ def power(base, exponent)
276
+ return integer_power(base, exponent.to_i) if exponent.frac.zero?
277
+ return zero_to_the(exponent) if base.zero?
278
+ if base.negative?
279
+ raise ::Dcc::ExtractionError,
280
+ "Cannot raise a negative base to a fractional power " \
281
+ "in formula"
282
+ end
283
+
284
+ scaled = checked(exponent * ln(base), :**, "intermediate in")
285
+ BigMath.exp(scaled, PRECISION)
286
+ end
287
+
288
+ # `**` chooses its own precision, so a negative exponent is
289
+ # inexact (`3 ** -1` gives 32 digits, not 34). Route it through
290
+ # `divide`.
291
+ def integer_power(base, exponent)
292
+ check_power_size(base, exponent)
293
+ return base**exponent unless exponent.negative?
294
+ raise ::Dcc::ExtractionError, ZERO_DIVISION if base.zero?
295
+
296
+ divide(BigDecimal(1), base**-exponent)
297
+ end
298
+
299
+ # The exact path allocates the whole number before any result
300
+ # check can see how big it got, so the size is predicted here
301
+ # instead. `2 ** 1e8` takes 2.7s and `2 ** 1e9` 25s, and
302
+ # `(0.99999999999 ** 999) ** 999` needs eleven million digits
303
+ # while its exponent never leaves zero.
304
+ #
305
+ # `precision * |exponent|` is an upper bound, not the exact
306
+ # size, so it refuses some powers that would have fitted:
307
+ # anything with an integer exponent past MAX_DIGITS, however
308
+ # small the base (`2 ** 1001` really lands on 302 digits), and
309
+ # `1 ** huge`, which costs nothing. Predicting exactly means
310
+ # hand-rolling capped exponentiation, and a subtle bug in that
311
+ # loop fails this guard open. An over-refusal is a message; an
312
+ # under-refusal is the CPU-exhaustion path this exists to
313
+ # close. No certificate raises anything to the 1001st power.
314
+ def check_power_size(base, exponent)
315
+ return if base.precision * exponent.abs <= MAX_DIGITS
316
+
317
+ raise ::Dcc::ExtractionError,
318
+ "An exact power in the formula is not provably within " \
319
+ "the limit of #{MAX_DIGITS} digits"
320
+ end
321
+
322
+ def zero_to_the(exponent)
323
+ raise ::Dcc::ExtractionError, ZERO_DIVISION if exponent.negative?
324
+
325
+ BigDecimal(0)
326
+ end
327
+
328
+ # Content MathML spells roots as `<root/>` with an optional
329
+ # `<degree>`; mml exposes no content `sqrt` element. Odd-degree
330
+ # roots of negative values are out of scope — the exp/ln path
331
+ # cannot produce them. A zero radicand is legitimate and is
332
+ # answered before the ln guard sees it.
333
+ def root(value, degree)
334
+ if value.negative?
335
+ raise ::Dcc::ExtractionError,
336
+ "Cannot take the root of a negative value in formula"
337
+ end
338
+ return BigMath.sqrt(value, PRECISION) if degree.nil? || degree == 2
339
+
340
+ nth_root(value, degree)
341
+ end
342
+
343
+ def nth_root(value, degree)
344
+ if degree.zero?
345
+ raise ::Dcc::ExtractionError, "Root of degree zero in formula"
346
+ end
347
+ return zero_to_the(degree) if value.zero?
348
+
349
+ scaled = checked(divide(ln(value), degree), :root,
350
+ "intermediate in")
351
+ BigMath.exp(scaled, PRECISION)
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end