dcc 0.2.1 → 0.2.3
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.
- checksums.yaml +4 -4
- data/dcc.gemspec +3 -2
- data/lib/dcc/cli/cli.rb +122 -14
- data/lib/dcc/extract/formula/ast.rb +137 -0
- data/lib/dcc/extract/formula/evaluator.rb +357 -0
- data/lib/dcc/extract/formula/parser.rb +305 -0
- data/lib/dcc/extract/formula/quantity.rb +89 -0
- data/lib/dcc/extract/formula.rb +110 -0
- data/lib/dcc/quantity_math/complex.rb +252 -0
- data/lib/dcc/quantity_math/matrix.rb +217 -0
- data/lib/dcc/quantity_math.rb +30 -2
- data/lib/dcc/streaming/reader.rb +246 -0
- data/lib/dcc/streaming.rb +79 -0
- data/lib/dcc/version.rb +1 -1
- data/lib/dcc.rb +13 -3
- metadata +29 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc6fdbc90145de1027d8d4325d922f7648fd7b86a506deb17832d9f93c16e477
|
|
4
|
+
data.tar.gz: bce157ca35d3768d80180fe644401510cb3e633b0db3b9eb0f0e79a23bf909ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88afd32d40eef8163762dd59ceb1a6581e1d3557605625e74b900c3a1c01383101a9cf574c97863869119b880266c7fc57d3ba421b105b0d19efa8b1d89f00d8
|
|
7
|
+
data.tar.gz: d1dcab970fb6e3c9fedf6c56075dba82b81310a30476d77e21aa5715c4308f9318601eedb49b4759a755e5e1bf10d0eb679f42e42f4863ba12c203a4d9be9444
|
data/dcc.gemspec
CHANGED
|
@@ -36,12 +36,13 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
spec.required_ruby_version = ">= 3.2.0"
|
|
37
37
|
|
|
38
38
|
spec.add_dependency "base64" # not a default gem since Ruby 3.4
|
|
39
|
+
spec.add_dependency "bigdecimal" # not a default gem since Ruby 3.4
|
|
39
40
|
spec.add_dependency "csv" # not a default gem since Ruby 3.4
|
|
40
41
|
spec.add_dependency "forwardable" # stdlib, declared for the packaging guard
|
|
41
42
|
spec.add_dependency "liquid", "~> 5"
|
|
42
|
-
spec.add_dependency "lutaml-model", "~> 0.8.
|
|
43
|
+
spec.add_dependency "lutaml-model", "~> 0.8.156"
|
|
43
44
|
spec.add_dependency "mml", "~> 2.4.0"
|
|
44
|
-
spec.add_dependency "moxml", "~> 0.
|
|
45
|
+
spec.add_dependency "moxml", "~> 0.6.0" # moxml 0.6 removed the Opal surface (lutaml/moxml#516); owner call to forward-pin the 0.6 series
|
|
45
46
|
spec.add_dependency "nokogiri", "~> 1.16"
|
|
46
47
|
spec.add_dependency "thor", "~> 1.4"
|
|
47
48
|
end
|
data/lib/dcc/cli/cli.rb
CHANGED
|
@@ -83,29 +83,24 @@ module Dcc
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
desc "extract TARGET FILE",
|
|
86
|
-
"Extract embedded files or
|
|
86
|
+
"Extract embedded files or formulae from a DCC"
|
|
87
87
|
method_option :index, type: :numeric, desc: "Index of the file to extract"
|
|
88
88
|
method_option :output, type: :string, banner: "PATH",
|
|
89
89
|
desc: "Write extracted payload to PATH"
|
|
90
90
|
method_option :ring, type: :string,
|
|
91
91
|
enum: %w[administrativeData measurementResults comment document]
|
|
92
|
+
# `repeatable` and not `type: :array`: with an array type Thor lets a
|
|
93
|
+
# second `-v` overwrite the first, so `-v T=42 -v R0=1` would silently
|
|
94
|
+
# drop T. PTB's CLI accumulates, and so must this.
|
|
95
|
+
method_option :variable, type: :string, aliases: "-v", repeatable: true,
|
|
96
|
+
banner: "NAME=VALUE",
|
|
97
|
+
desc: "Override a formula variable (repeatable; NAME=V1,V2 for a list)"
|
|
92
98
|
def extract(target, file)
|
|
93
99
|
Cli.ensure_loaded!
|
|
94
100
|
dcc = Dcc.parse(File.read(file))
|
|
95
101
|
case target
|
|
96
|
-
when "files"
|
|
97
|
-
|
|
98
|
-
if options[:ring]
|
|
99
|
-
files = files.select do |f|
|
|
100
|
-
f.ring.to_s == options[:ring]
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
if options[:index]
|
|
104
|
-
f = files[options[:index].to_i] || abort("No file at index #{options[:index]}")
|
|
105
|
-
output_to(f.data, options[:output], f.file_name)
|
|
106
|
-
else
|
|
107
|
-
::Dcc::Cli::Formatters.print_files(files)
|
|
108
|
-
end
|
|
102
|
+
when "files" then extract_files(dcc)
|
|
103
|
+
when "formulae" then extract_formulae(dcc)
|
|
109
104
|
else
|
|
110
105
|
abort "Unknown extract target: #{target}"
|
|
111
106
|
end
|
|
@@ -161,6 +156,119 @@ module Dcc
|
|
|
161
156
|
$stdout.write(payload)
|
|
162
157
|
end
|
|
163
158
|
end
|
|
159
|
+
|
|
160
|
+
def extract_files(dcc)
|
|
161
|
+
files = ::Dcc::Extract::File.each(dcc)
|
|
162
|
+
if options[:ring]
|
|
163
|
+
files = files.select { |f| f.ring.to_s == options[:ring] }
|
|
164
|
+
end
|
|
165
|
+
return ::Dcc::Cli::Formatters.print_files(files) unless options[:index]
|
|
166
|
+
|
|
167
|
+
index = options[:index].to_i
|
|
168
|
+
file = files[index] || abort("No file at index #{index}")
|
|
169
|
+
output_to(file.data, options[:output], file.file_name)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
no_commands do
|
|
174
|
+
def extract_formulae(dcc)
|
|
175
|
+
variables = parse_variables(options[:variable])
|
|
176
|
+
formulae = ::Dcc::Extract::Formula.call(dcc)
|
|
177
|
+
check_overrides(formulae, variables)
|
|
178
|
+
print_formulae(formulae, variables)
|
|
179
|
+
rescue ::Dcc::ExtractionError => e
|
|
180
|
+
abort e.message
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The evaluator slices bindings down to the names a formula
|
|
184
|
+
# actually references, so a mistyped -v reaches nothing and the
|
|
185
|
+
# run still reports success with plausible default numbers.
|
|
186
|
+
# `decimal_or_abort` already aborts on a malformed -v; an
|
|
187
|
+
# override that lands nowhere is the same class of mistake.
|
|
188
|
+
def check_overrides(formulae, variables)
|
|
189
|
+
unknown = variables.keys - formulae.flat_map(&:variables)
|
|
190
|
+
return if unknown.empty?
|
|
191
|
+
|
|
192
|
+
abort "No formula references #{unknown.join(', ')}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
no_commands do
|
|
197
|
+
def parse_variables(assignments)
|
|
198
|
+
Array(assignments).each_with_object({}) do |assignment, variables|
|
|
199
|
+
name, values = split_assignment(assignment)
|
|
200
|
+
variables[name] = override_value(values)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# `-v T=42` is a scalar and broadcasts across the document's lists;
|
|
205
|
+
# only `-v T=1,2` is a list, and a list has to match every other
|
|
206
|
+
# list's length. Wrapping a lone value in an array made `-v R0=1`
|
|
207
|
+
# a one-element list that collided with the document's own.
|
|
208
|
+
def override_value(values)
|
|
209
|
+
decimals = values.map { |v| decimal_or_abort(v) }
|
|
210
|
+
decimals.size == 1 ? decimals.first : decimals
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
no_commands do
|
|
215
|
+
# `split(",", -1)` keeps trailing empty fields, so `T=42,` reaches
|
|
216
|
+
# validation instead of silently losing the empty entry.
|
|
217
|
+
def split_assignment(assignment)
|
|
218
|
+
name, values = assignment.split("=", 2)
|
|
219
|
+
if name.nil? || name.empty? || values.nil? || values.empty?
|
|
220
|
+
abort "#{assignment} is not a valid NAME=value"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
[name, values.split(",", -1)]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Validated here, at the command line, not where the value is used.
|
|
227
|
+
# The evaluator only coerces variables the formula references, so a
|
|
228
|
+
# bad `-v` for an unreferenced variable — or any `-v` against a
|
|
229
|
+
# document with no formulae — would otherwise pass in silence.
|
|
230
|
+
#
|
|
231
|
+
# Delegates rather than calling BigDecimal itself: a second
|
|
232
|
+
# coercion site is exactly the drift the single boundary exists to
|
|
233
|
+
# prevent, even when it starts out agreeing.
|
|
234
|
+
def decimal_or_abort(value)
|
|
235
|
+
::Dcc::Extract::Formula::Quantity.decimal(value)
|
|
236
|
+
rescue ::Dcc::ExtractionError
|
|
237
|
+
abort "#{value} is not a valid decimal value"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
no_commands do
|
|
242
|
+
def print_formulae(formulae, variables)
|
|
243
|
+
return puts("No formulae found.") if formulae.empty?
|
|
244
|
+
|
|
245
|
+
# `map`, deliberately, not `all?` with a block: `all?`
|
|
246
|
+
# short-circuits, and stopping at the first failure is exactly
|
|
247
|
+
# the bug this is fixing.
|
|
248
|
+
printed = formulae.map { |formula| print_formula(formula, variables) }
|
|
249
|
+
exit(1) unless printed.all?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
no_commands do
|
|
254
|
+
# Evaluates before printing the header. A header printed ahead
|
|
255
|
+
# of a failure leaves a truncated record on stdout that reads
|
|
256
|
+
# like a formula with no values. One formula that cannot be
|
|
257
|
+
# evaluated is reported on stderr and the rest still print.
|
|
258
|
+
def print_formula(formula, variables)
|
|
259
|
+
values = formula.evaluate(variables)
|
|
260
|
+
puts formula
|
|
261
|
+
values.each { |value| puts " #{value.to_s('F')}" }
|
|
262
|
+
true
|
|
263
|
+
rescue ::Dcc::ExtractionError => e
|
|
264
|
+
# `$stderr.puts`, not `warn`: Kernel#warn prints nothing when
|
|
265
|
+
# warnings are off, so `RUBYOPT=-W0` would leave the user with
|
|
266
|
+
# exit 1 and no explanation at all. The cop's own rationale —
|
|
267
|
+
# "to allow such output to be disabled" — is the thing this
|
|
268
|
+
# message must not permit.
|
|
269
|
+
$stderr.puts "#{formula}: #{e.message}" # rubocop:disable Style/StderrPuts
|
|
270
|
+
false
|
|
271
|
+
end
|
|
164
272
|
end
|
|
165
273
|
end
|
|
166
274
|
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
|