rcas 0.2.0
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 +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
data/lib/rcas/hold.rb
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
# Ruby keeps the source of eval'd code (and so lets us read a block's
|
|
5
|
+
# syntax tree) only while this is on. irb turns it on itself; other hosts
|
|
6
|
+
# such as the chat REPL evaluate input with eval, so we turn it on here.
|
|
7
|
+
RubyVM.keep_script_lines = true
|
|
8
|
+
|
|
9
|
+
# hold { 1 + 2 } builds the expression tree of the block's source instead of
|
|
10
|
+
# letting Ruby evaluate it, so numeric arithmetic is kept symbolic:
|
|
11
|
+
#
|
|
12
|
+
# hold { 1 + 2 } # => 1 + 2
|
|
13
|
+
# hold { 1 / 2 }.simplify # => 1/2 (Ruby alone would give 0)
|
|
14
|
+
# hold { 2 * x**2 / 2 } # => 2*x**2/2
|
|
15
|
+
#
|
|
16
|
+
# Inside the block: literals become Num, bare identifiers and symbols
|
|
17
|
+
# become Var, the arithmetic operators build nodes, sin/cos/... build Fn,
|
|
18
|
+
# local variables and constants are read from the block's binding, and any
|
|
19
|
+
# other method call is performed normally on the held arguments.
|
|
20
|
+
module Hold
|
|
21
|
+
OPERATORS = { :+ => Add, :- => Sub, :* => Mul, :/ => Div, :** => Pow }.freeze
|
|
22
|
+
LITERALS = %i[LIT INTEGER FLOAT RATIONAL IMAGINARY SYM].freeze
|
|
23
|
+
# Calls kept as formal nodes instead of being evaluated; see Expression#evaluate.
|
|
24
|
+
FORMAL = %i[integrate diff sum product limit discuss].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
IDENTIFIER = RCAS::IDENTIFIER
|
|
29
|
+
|
|
30
|
+
# The syntax tree of a piece of source text, without Ruby's lint
|
|
31
|
+
# warnings. Parsing `x + 1` on its own warns "possibly useless use of +
|
|
32
|
+
# in void context" under -w, and so does every line of a session that is
|
|
33
|
+
# read to be inspected (In[n], the chat's checks) rather than run: the
|
|
34
|
+
# warnings are about the fragment, not about anything the reader wrote
|
|
35
|
+
# wrong, and the test suite printed dozens of them.
|
|
36
|
+
def parse(text) = quietly { RubyVM::AbstractSyntaxTree.parse(text) }
|
|
37
|
+
|
|
38
|
+
# AbstractSyntaxTree.of parses the block's lines again, with the same
|
|
39
|
+
# warnings.
|
|
40
|
+
def quietly
|
|
41
|
+
verbose = $VERBOSE
|
|
42
|
+
$VERBOSE = nil
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
$VERBOSE = verbose
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A block whose source cannot be read is refused, never run: running it
|
|
49
|
+
# is exactly what hold was asked not to do, and `hold { 1 / 2 }` came
|
|
50
|
+
# back as 0 under Ruby 4 that way (a review, 23 Sept 2026).
|
|
51
|
+
def hold(block)
|
|
52
|
+
body = block_body(block)
|
|
53
|
+
raise RCAS::Unsupported, "hold could not read the block's source, so it cannot keep it unevaluated" if body.nil?
|
|
54
|
+
Builder.new(block.binding).build(body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The syntax tree of the block's body. RubyVM::AbstractSyntaxTree.of
|
|
58
|
+
# reads it directly where the block was compiled by parse.y (Ruby 3.3,
|
|
59
|
+
# or --parser=parse.y later, which hands back the whole call, ITER,
|
|
60
|
+
# rather than the block's SCOPE). A block compiled by Prism - the
|
|
61
|
+
# default since Ruby 3.4 - has no such tree, and `reparsed_body` cuts
|
|
62
|
+
# its source out by the code location of its instruction sequence.
|
|
63
|
+
def block_body(block)
|
|
64
|
+
ast = quietly { RubyVM::AbstractSyntaxTree.of(block, keep_script_lines: true) }
|
|
65
|
+
ast = ast.children.last if ast&.type == :ITER
|
|
66
|
+
ast&.children&.last
|
|
67
|
+
rescue ArgumentError, RuntimeError, IOError, SystemCallError
|
|
68
|
+
reparsed_body(block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reparsed_body(block)
|
|
72
|
+
iseq = RubyVM::InstructionSequence.of(block)
|
|
73
|
+
l1, c1, l2, c2 = iseq.to_a[4][:code_location]
|
|
74
|
+
eval_lines = iseq.script_lines
|
|
75
|
+
lines = eval_lines || (File.readlines(iseq.path) if File.file?(iseq.path))
|
|
76
|
+
return nil unless lines && l1
|
|
77
|
+
# a file starts at line 1; code eval'd with a line number (irb's, the
|
|
78
|
+
# chat's) starts wherever that says, which the block does not record,
|
|
79
|
+
# so every start that keeps the block inside the text is tried
|
|
80
|
+
bases = eval_lines ? [1, *(l2 - lines.size + 1..l1)].uniq : [1]
|
|
81
|
+
bases.each do |base|
|
|
82
|
+
from, to = l1 - base, l2 - base
|
|
83
|
+
next if from.negative? || to >= lines.size
|
|
84
|
+
text = from == to ? lines[from].byteslice(c1...c2) : lines[from].byteslice(c1..) + lines[from + 1...to].join + lines[to].byteslice(0, c2)
|
|
85
|
+
next unless text&.match?(/\A(\{|do\b)/)
|
|
86
|
+
node = begin
|
|
87
|
+
Hold.parse("proc #{text}").children.last
|
|
88
|
+
rescue SyntaxError
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
return node.children.last.children.last if node&.type == :ITER
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
rescue ArgumentError, RuntimeError, IOError, SystemCallError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The same for a line of source text instead of a block: the expression
|
|
99
|
+
# the line builds, held. +context+ is the binding its names are read in.
|
|
100
|
+
# nil for an empty line; raises SyntaxError on one that does not parse
|
|
101
|
+
# and ArgumentError on a node hold cannot keep. RCAS::Results uses it
|
|
102
|
+
# for In[n].
|
|
103
|
+
def source(text, context = nil)
|
|
104
|
+
body = parse(text).children.last
|
|
105
|
+
body = body.children.first if body&.type == :BEGIN # an empty line, or a comment
|
|
106
|
+
body.nil? ? nil : Builder.new(context || TOPLEVEL_BINDING).build(body)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Builder
|
|
110
|
+
def initialize(binding)
|
|
111
|
+
@binding = binding
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build(node)
|
|
115
|
+
case node.type
|
|
116
|
+
when *LITERALS then literal(node.children.first)
|
|
117
|
+
when :OPCALL then operator(node)
|
|
118
|
+
when :LVAR, :DVAR then lift(@binding.local_variable_get(node.children.first))
|
|
119
|
+
when :VCALL then identifier(node.children.first)
|
|
120
|
+
when :FCALL then function(node.children[0], arguments(node.children[1]))
|
|
121
|
+
when :CALL then call(build(node.children[0]), node.children[1], arguments(node.children[2]))
|
|
122
|
+
when :BLOCK then node.children.map { |c| build(c) }.last
|
|
123
|
+
when :BEGIN then build(node.children.first)
|
|
124
|
+
when :HASH then hash_node(node)
|
|
125
|
+
when :DOT2, :DOT3 then range_node(node)
|
|
126
|
+
when :CONST, :COLON2, :COLON3 then lift(@binding.eval(constant_path(node)))
|
|
127
|
+
when :SELF then lift(@binding.receiver)
|
|
128
|
+
else evaluate(node)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Anything else (a block call, a string, an array, ...) is evaluated as
|
|
135
|
+
# written and the value is used.
|
|
136
|
+
def evaluate(node)
|
|
137
|
+
source = begin
|
|
138
|
+
node.source
|
|
139
|
+
rescue StandardError # user code: a NameError is an answer here
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
raise ArgumentError, "hold: can't keep a #{node.type} node (line #{node.first_lineno})" if source.nil?
|
|
143
|
+
lift(@binding.eval(source))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def literal(value)
|
|
147
|
+
case value
|
|
148
|
+
when Symbol then Var.new(value)
|
|
149
|
+
when Numeric then Num.new(value)
|
|
150
|
+
else raise ArgumentError, "hold: unsupported literal #{value.inspect}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def operator(node)
|
|
155
|
+
receiver, op, list = node.children
|
|
156
|
+
left = build(receiver)
|
|
157
|
+
if list.nil?
|
|
158
|
+
return Neg.new(left) if op == :-@
|
|
159
|
+
return left if op == :+@
|
|
160
|
+
raise ArgumentError, "hold: unsupported unary operator #{op}"
|
|
161
|
+
end
|
|
162
|
+
right = build(list.children.first)
|
|
163
|
+
return Equation.new(left, right) if op == :== # hold { x**2 - 3 == 0 } is the equation
|
|
164
|
+
return Inequality.new(left, :!=, right) if op == :!=
|
|
165
|
+
klass = OPERATORS[op]
|
|
166
|
+
klass ? klass.new(left, right) : lift(left.public_send(op, right))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A bare name: a real method of the block's self if there is one
|
|
170
|
+
# (irb's auto-symbols included), otherwise a variable.
|
|
171
|
+
# A bare name: a local of the block's binding when one is set (irb
|
|
172
|
+
# re-parses each line on its own, so earlier locals show up as bare
|
|
173
|
+
# names), else a real method of the block's self (irb's auto-symbols
|
|
174
|
+
# included), else a variable. A hoisted-but-unassigned local is nil
|
|
175
|
+
# and is skipped.
|
|
176
|
+
def identifier(name)
|
|
177
|
+
if @binding.local_variable_defined?(name) && !(value = @binding.local_variable_get(name)).nil?
|
|
178
|
+
return lift(value)
|
|
179
|
+
end
|
|
180
|
+
value = @binding.receiver.__send__(name)
|
|
181
|
+
value.nil? ? Var.new(name) : lift(value) # Kernel#p without arguments returns nil: an indeterminate
|
|
182
|
+
rescue NameError
|
|
183
|
+
Var.new(name)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def function(name, args)
|
|
187
|
+
return Pow.new(args.first, Num.new(Rational(1, 2))) if name == :sqrt && args.size == 1
|
|
188
|
+
return Fn.new(name, args) if Functions::NAMES.include?(name)
|
|
189
|
+
return formal(name, args) if FORMAL.include?(name)
|
|
190
|
+
receiver = @binding.receiver
|
|
191
|
+
target = receiver.respond_to?(name, true) ? receiver : RCAS
|
|
192
|
+
lift(target.__send__(name, *args))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def call(receiver, name, args)
|
|
196
|
+
klass = OPERATORS[name]
|
|
197
|
+
return klass.new(receiver, args.first) if klass && args.size == 1
|
|
198
|
+
return Neg.new(receiver) if name == :-@
|
|
199
|
+
# RCAS.integrate(f, x) is the same call as a bare integrate(f, x):
|
|
200
|
+
# outside bin/rcas that qualified form is how the manual writes it.
|
|
201
|
+
# x.in?(ZZ) inside a block is the statement, not the answer, the way
|
|
202
|
+
# == is an Equation here and != an Inequality.
|
|
203
|
+
return Membership.new(receiver, args.first) if name == :in? && args.size == 1 && args.first.is_a?(Domain)
|
|
204
|
+
return function(name, args) if receiver.equal?(RCAS)
|
|
205
|
+
return formal(name, [receiver] + args) if FORMAL.include?(name) && receiver.is_a?(Expression)
|
|
206
|
+
lift(receiver.public_send(name, *args))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# integrate(f, x) / integrate(f, x: 0..1), diff(f, x, n), sum(f, k, a, b) /
|
|
210
|
+
# sum(f, k: 1..n), limit(f, x, a) / limit(f, x: 0), discuss(f, x) as
|
|
211
|
+
# unevaluated nodes.
|
|
212
|
+
def formal(name, args)
|
|
213
|
+
opts = args.last.is_a?(Hash) ? args.pop : {}
|
|
214
|
+
f = Expression.lift(args.shift)
|
|
215
|
+
case name
|
|
216
|
+
when :integrate
|
|
217
|
+
var, from, to = Functions.range_arguments(*args.values_at(0, 1, 2), opts, "integrate", discrete: false) if args.size < 2 && (opts.any? || args.size == 1)
|
|
218
|
+
var, from, to = args.values_at(0, 1, 2) if args.size >= 2
|
|
219
|
+
Integral.new(f, Expression.lift(var), from && Expression.lift(from), to && Expression.lift(to))
|
|
220
|
+
when :diff
|
|
221
|
+
var, n = args
|
|
222
|
+
Derivative.new(f, Expression.lift(var), n.is_a?(Num) ? n.value : (n || 1))
|
|
223
|
+
when :sum, :product
|
|
224
|
+
var, from, to = Functions.range_arguments(*args.values_at(0, 1, 2), opts, name.to_s, discrete: true)
|
|
225
|
+
(name == :sum ? Sum : Product).new(f, Expression.lift(var), Expression.lift(from), Expression.lift(to))
|
|
226
|
+
when :limit
|
|
227
|
+
opts = opts.dup
|
|
228
|
+
opts.delete(:dir)
|
|
229
|
+
var, point, = Functions.point_arguments(args[0], args[1], nil, opts, "limit")
|
|
230
|
+
Limit.new(f, Expression.lift(var), Expression.lift(point))
|
|
231
|
+
when :discuss
|
|
232
|
+
# A curve discussion has no node of its own: it is a question,
|
|
233
|
+
# not a value, and steps { discuss(f, x) } is what holds it.
|
|
234
|
+
Fn.new(:discuss, args.first ? [f, Expression.lift(args.first)] : [f])
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Keyword arguments such as `x: 0..1` (symbol keys stay symbols).
|
|
239
|
+
def hash_node(node)
|
|
240
|
+
list = node.children.first
|
|
241
|
+
return {} if list.nil?
|
|
242
|
+
list.children.compact.each_slice(2).to_h do |key, value|
|
|
243
|
+
k = LITERALS.include?(key.type) && key.children.first.is_a?(Symbol) ? key.children.first : build(key)
|
|
244
|
+
[k, build(value)]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def range_node(node)
|
|
249
|
+
lo, hi = node.children.map { |c| c.nil? ? nil : build(c) }
|
|
250
|
+
Range.new(lo, hi, node.type == :DOT3)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def arguments(list)
|
|
254
|
+
return [] if list.nil?
|
|
255
|
+
list.children.compact.map { |c| build(c) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def constant_path(node)
|
|
259
|
+
case node.type
|
|
260
|
+
when :CONST then node.children.first.to_s
|
|
261
|
+
when :COLON3 then "::#{node.children.first}"
|
|
262
|
+
when :COLON2 then "#{constant_path(node.children[0])}::#{node.children[1]}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Expressions, symbols and numbers become nodes; anything else
|
|
267
|
+
# (a polynomial, a matrix, a string) is passed through untouched.
|
|
268
|
+
def lift(value)
|
|
269
|
+
Expression.lift(value)
|
|
270
|
+
rescue TypeError
|
|
271
|
+
value
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.hold(&block) = Hold.hold(block)
|
|
277
|
+
end
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
# Hypothesis tests and confidence intervals.
|
|
5
|
+
#
|
|
6
|
+
# ttest([5.1, 4.9, 5.6, 5.2, 5.0], mu: 5) # one-sample t test
|
|
7
|
+
# ttest(xs, ys) # Welch's two-sample t test
|
|
8
|
+
# ttest(xs, ys, paired: true)
|
|
9
|
+
# ztest(data, sigma: 2, mu: 100)
|
|
10
|
+
# chisquare_test([18, 22, 20, 25, 15]) # goodness of fit
|
|
11
|
+
# chisquare_test([[30, 20], [15, 35]]) # independence
|
|
12
|
+
# ftest(xs, ys) # ratio of variances
|
|
13
|
+
# binomial_test(9, 10) # exact, p stays a rational
|
|
14
|
+
# confidence_interval(data) # t interval for the mean
|
|
15
|
+
# confidence_interval(data, parameter: :variance)
|
|
16
|
+
# proportion_interval(41, 100)
|
|
17
|
+
#
|
|
18
|
+
# A test returns a TestResult: statistic, degrees of freedom, p value,
|
|
19
|
+
# `reject?(alpha)`. The p values come from the t, chi-square, F and normal
|
|
20
|
+
# CDFs and are Floats, labelled as numeric; the binomial test is exact.
|
|
21
|
+
# `alternative:` is :two_sided (the default), :less or :greater.
|
|
22
|
+
#
|
|
23
|
+
# Sources (keys: MANUAL.md, Sources): [Ros14, ch. 8-9]; Welch's degrees of
|
|
24
|
+
# freedom [Wel47]; the exact binomial p value is the sum of the outcomes no
|
|
25
|
+
# more probable than the observed one [Ros14, §9.7]; Wilson's score interval
|
|
26
|
+
# for a proportion [Wil27].
|
|
27
|
+
module Hypothesis
|
|
28
|
+
ALTERNATIVES = %i[two_sided less greater].freeze
|
|
29
|
+
|
|
30
|
+
# The outcome of a test: printable, and usable as a value.
|
|
31
|
+
class TestResult
|
|
32
|
+
attr_reader :name, :statistic_name, :statistic, :pvalue, :distribution, :alternative, :estimate, :parameters
|
|
33
|
+
|
|
34
|
+
def initialize(name:, statistic:, pvalue:, distribution:, alternative:, statistic_name: "statistic", estimate: nil, parameters: {})
|
|
35
|
+
@name = name
|
|
36
|
+
@statistic_name = statistic_name
|
|
37
|
+
@statistic = Expression.lift(statistic)
|
|
38
|
+
@pvalue = Expression.lift(pvalue)
|
|
39
|
+
@distribution = distribution
|
|
40
|
+
@alternative = alternative
|
|
41
|
+
@estimate = estimate
|
|
42
|
+
@parameters = parameters
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Is the null hypothesis rejected at this level?
|
|
47
|
+
def reject?(alpha = 0.05)
|
|
48
|
+
p = pvalue.evalf
|
|
49
|
+
raise ArgumentError, "reject?: the p value #{pvalue} is not numeric" unless p.is_a?(Numeric) && !p.is_a?(Complex)
|
|
50
|
+
p <= alpha
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Exact values print exactly, unless the fraction is unwieldy (the exact
|
|
54
|
+
# binomial p value for n = 100, say); the value itself stays exact.
|
|
55
|
+
def self.number(value)
|
|
56
|
+
v = Expression.lift(value)
|
|
57
|
+
if v.is_a?(Num) && (v.value.is_a?(Integer) || (v.value.is_a?(Rational) && v.value.denominator <= 10**6))
|
|
58
|
+
return v.to_s
|
|
59
|
+
end
|
|
60
|
+
f = v.evalf
|
|
61
|
+
f.is_a?(Numeric) ? format("%.6g", f) : v.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parameter_text = parameters.map { |k, v| "#{k} = #{self.class.number(v)}" }
|
|
65
|
+
|
|
66
|
+
def to_s
|
|
67
|
+
parts = ["#{statistic_name} = #{self.class.number(statistic)}"] + parameter_text
|
|
68
|
+
parts << "p = #{self.class.number(pvalue)}"
|
|
69
|
+
"#{name}: #{parts.join(', ')} (#{alternative.to_s.tr('_', '-')})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect = to_s
|
|
73
|
+
|
|
74
|
+
def to_latex(wrap: nil)
|
|
75
|
+
parts = ["#{LaTeX.escape(statistic_name)} = #{self.class.number(statistic)}"] +
|
|
76
|
+
parameters.map { |k, v| "#{LaTeX.escape(k.to_s)} = #{self.class.number(v)}" } +
|
|
77
|
+
["p = #{self.class.number(pvalue)}"]
|
|
78
|
+
"\\text{#{LaTeX.escape(name)}}: #{parts.join(',\; ')}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
def check_alternative(alternative)
|
|
85
|
+
raise ArgumentError, "alternative: use #{ALTERNATIVES.join(', ')}" unless ALTERNATIVES.include?(alternative)
|
|
86
|
+
alternative
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def float(value, name)
|
|
90
|
+
v = Expression.lift(value).evalf
|
|
91
|
+
raise ArgumentError, "#{name}: a real number is needed, got #{value}" unless v.is_a?(Numeric) && !v.is_a?(Complex)
|
|
92
|
+
v.to_f
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# P(|T| >= |t|), P(T <= t) or P(T >= t) for a symmetric or one-sided statistic.
|
|
96
|
+
# The upper tail is the distribution's survival function, not 1 - cdf:
|
|
97
|
+
# z = 10 has p = 1.5e-23, and 1 - cdf said 0 (third review, P-11).
|
|
98
|
+
def tail(distribution, statistic, alternative, symmetric: true)
|
|
99
|
+
t = float(statistic, "p value")
|
|
100
|
+
case alternative
|
|
101
|
+
when :less then float(distribution.cdf(t), "p value")
|
|
102
|
+
when :greater then float(distribution.survival(t), "p value")
|
|
103
|
+
else
|
|
104
|
+
if symmetric
|
|
105
|
+
[2.0 * float(distribution.survival(t.abs), "p value"), 1.0].min
|
|
106
|
+
else
|
|
107
|
+
lower = float(distribution.cdf(t), "p value")
|
|
108
|
+
upper = float(distribution.survival(t), "p value")
|
|
109
|
+
[2.0 * [lower, upper].min, 1.0].min
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ---- t tests ---------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def ttest(data, other = nil, mu: 0, alternative: :two_sided, paired: false, equal_variance: false)
|
|
117
|
+
check_alternative(alternative)
|
|
118
|
+
return one_sample_t(data, mu, alternative) if other.nil?
|
|
119
|
+
if paired
|
|
120
|
+
xs = Statistics.data(data, "ttest")
|
|
121
|
+
ys = Statistics.data(other, "ttest")
|
|
122
|
+
raise ArgumentError, "ttest: paired samples must have the same length" unless xs.size == ys.size
|
|
123
|
+
return one_sample_t(xs.zip(ys).map { |x, y| (x - y).simplify }, mu, alternative, name: "paired t test")
|
|
124
|
+
end
|
|
125
|
+
two_sample_t(data, other, mu, alternative, equal_variance)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def one_sample_t(data, mu, alternative, name: "one-sample t test")
|
|
129
|
+
values = Statistics.data(data, "ttest")
|
|
130
|
+
n = values.size
|
|
131
|
+
raise ArgumentError, "ttest: at least two values are needed" if n < 2
|
|
132
|
+
mean = Statistics.mean(values)
|
|
133
|
+
error = (Statistics.stdev(values) / RCAS.sqrt(Num.new(n))).simplify
|
|
134
|
+
raise ArgumentError, "ttest: the sample has no spread" if Scalar.zero?(error)
|
|
135
|
+
t = ((mean - Expression.lift(mu)) / error).simplify
|
|
136
|
+
df = n - 1
|
|
137
|
+
TestResult.new(name: name, statistic_name: "t", statistic: t, distribution: Distributions::StudentT.new(df),
|
|
138
|
+
pvalue: Num.new(tail(Distributions::StudentT.new(df), t, alternative)),
|
|
139
|
+
alternative: alternative, estimate: mean, parameters: { df: df })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Welch by default (unequal variances), the pooled test with equal_variance: true.
|
|
143
|
+
def two_sample_t(xs, ys, mu, alternative, equal_variance)
|
|
144
|
+
xs = Statistics.data(xs, "ttest")
|
|
145
|
+
ys = Statistics.data(ys, "ttest")
|
|
146
|
+
raise ArgumentError, "ttest: at least two values per sample are needed" if xs.size < 2 || ys.size < 2
|
|
147
|
+
n = xs.size
|
|
148
|
+
m = ys.size
|
|
149
|
+
vx = float(Statistics.variance(xs), "ttest")
|
|
150
|
+
vy = float(Statistics.variance(ys), "ttest")
|
|
151
|
+
difference = float(Statistics.mean(xs), "ttest") - float(Statistics.mean(ys), "ttest") - float(mu, "ttest")
|
|
152
|
+
if equal_variance
|
|
153
|
+
pooled = ((n - 1) * vx + (m - 1) * vy) / (n + m - 2.0)
|
|
154
|
+
error = Math.sqrt(pooled * (1.0 / n + 1.0 / m))
|
|
155
|
+
df = n + m - 2
|
|
156
|
+
name = "two-sample t test"
|
|
157
|
+
else
|
|
158
|
+
error = Math.sqrt(vx / n + vy / m)
|
|
159
|
+
df = (vx / n + vy / m)**2 / ((vx / n)**2 / (n - 1) + (vy / m)**2 / (m - 1))
|
|
160
|
+
name = "Welch t test"
|
|
161
|
+
end
|
|
162
|
+
raise ArgumentError, "ttest: the samples have no spread" if error.zero?
|
|
163
|
+
t = difference / error
|
|
164
|
+
distribution = Distributions::StudentT.new(df)
|
|
165
|
+
TestResult.new(name: name, statistic_name: "t", statistic: Num.new(t), distribution: distribution,
|
|
166
|
+
pvalue: Num.new(tail(distribution, t, alternative)), alternative: alternative,
|
|
167
|
+
estimate: Num.new(difference), parameters: { df: Num.new(df) })
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ---- z test ----------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def ztest(data, sigma:, mu: 0, alternative: :two_sided)
|
|
173
|
+
check_alternative(alternative)
|
|
174
|
+
values = Statistics.data(data, "ztest")
|
|
175
|
+
n = values.size
|
|
176
|
+
mean = Statistics.mean(values)
|
|
177
|
+
z = ((mean - Expression.lift(mu)) / (Expression.lift(sigma) / RCAS.sqrt(Num.new(n)))).simplify
|
|
178
|
+
normal = Distributions::Normal.new(0, 1)
|
|
179
|
+
TestResult.new(name: "z test", statistic_name: "z", statistic: z, distribution: normal,
|
|
180
|
+
pvalue: Num.new(tail(normal, z, alternative)), alternative: alternative,
|
|
181
|
+
estimate: mean, parameters: { n: n })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ---- chi-square ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# A flat list is a goodness-of-fit test against +expected+ (counts or
|
|
187
|
+
# probabilities, uniform by default); a list of rows is a test of
|
|
188
|
+
# independence. +df:+ lowers the degrees of freedom for estimated parameters.
|
|
189
|
+
def chisquare_test(observed, expected: nil, df: nil, alternative: :greater)
|
|
190
|
+
check_alternative(alternative)
|
|
191
|
+
rows = observed.is_a?(Matrix) ? observed.to_a : observed
|
|
192
|
+
return independence_test(rows) if rows.is_a?(Array) && rows.first.is_a?(Array)
|
|
193
|
+
goodness_of_fit(rows, expected, df, alternative)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def goodness_of_fit(observed, expected, df, alternative)
|
|
197
|
+
counts = Statistics.data(observed, "chisquare_test")
|
|
198
|
+
total = counts.reduce(:+).simplify
|
|
199
|
+
expected = if expected.nil?
|
|
200
|
+
Array.new(counts.size) { (total / counts.size).simplify }
|
|
201
|
+
else
|
|
202
|
+
e = Statistics.data(expected, "chisquare_test")
|
|
203
|
+
raise ArgumentError, "chisquare_test: #{e.size} expected values for #{counts.size} observed ones" unless e.size == counts.size
|
|
204
|
+
sum = e.reduce(:+).simplify
|
|
205
|
+
# probabilities sum to 1 and counts to the number observed;
|
|
206
|
+
# anything else is an input error, as R has it (P-16)
|
|
207
|
+
if Scalar.zero?((sum - 1).simplify) || close?(sum, 1)
|
|
208
|
+
e.map { |v| (v * total).simplify }
|
|
209
|
+
elsif Scalar.zero?((sum - total).simplify) || close?(sum, total)
|
|
210
|
+
e
|
|
211
|
+
elsif e.all? { |v| float(v, "chisquare_test") <= 1 }
|
|
212
|
+
raise ArgumentError, "chisquare_test: the expected probabilities sum to #{sum}, not 1"
|
|
213
|
+
else
|
|
214
|
+
raise ArgumentError, "chisquare_test: the expected counts sum to #{sum}, not to the #{total} observed"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
expected.each { |v| raise ArgumentError, "chisquare_test: expected counts must be positive" if float(v, "chisquare_test") <= 0 }
|
|
218
|
+
statistic = counts.zip(expected).map { |o, e| ((o - e)**2 / e).expand }.reduce(:+).simplify
|
|
219
|
+
degrees = df || counts.size - 1
|
|
220
|
+
distribution = Distributions::ChiSquare.new(degrees)
|
|
221
|
+
TestResult.new(name: "chi-square goodness of fit", statistic_name: "X^2", statistic: statistic,
|
|
222
|
+
distribution: distribution, pvalue: Num.new(tail(distribution, statistic, alternative, symmetric: false)),
|
|
223
|
+
alternative: alternative, parameters: { df: degrees })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def close?(a, b)
|
|
227
|
+
x = float(a, "chisquare_test")
|
|
228
|
+
y = float(b, "chisquare_test")
|
|
229
|
+
(x - y).abs <= 1e-9 * [1.0, y.abs].max
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def independence_test(rows)
|
|
233
|
+
table = rows.map { |row| Statistics.data(row, "chisquare_test") }
|
|
234
|
+
width = table.first.size
|
|
235
|
+
raise ArgumentError, "chisquare_test: the rows must have the same length" unless table.all? { |r| r.size == width }
|
|
236
|
+
raise ArgumentError, "chisquare_test: a table of at least 2 by 2 is needed" if table.size < 2 || width < 2
|
|
237
|
+
total = table.flatten.reduce(:+).simplify
|
|
238
|
+
row_sums = table.map { |r| r.reduce(:+).simplify }
|
|
239
|
+
column_sums = (0...width).map { |j| table.map { |r| r[j] }.reduce(:+).simplify }
|
|
240
|
+
statistic = table.each_with_index.map do |row, i|
|
|
241
|
+
row.each_with_index.map do |observed, j|
|
|
242
|
+
expected = (row_sums[i] * column_sums[j] / total).simplify
|
|
243
|
+
raise ArgumentError, "chisquare_test: a row or column sums to zero" if Scalar.zero?(expected)
|
|
244
|
+
((observed - expected)**2 / expected).expand
|
|
245
|
+
end.reduce(:+)
|
|
246
|
+
end.reduce(:+).simplify
|
|
247
|
+
degrees = (table.size - 1) * (width - 1)
|
|
248
|
+
distribution = Distributions::ChiSquare.new(degrees)
|
|
249
|
+
TestResult.new(name: "chi-square test of independence", statistic_name: "X^2", statistic: statistic,
|
|
250
|
+
distribution: distribution, pvalue: Num.new(tail(distribution, statistic, :greater, symmetric: false)),
|
|
251
|
+
alternative: :greater, parameters: { df: degrees })
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ---- F test ----------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def ftest(xs, ys, alternative: :two_sided)
|
|
257
|
+
check_alternative(alternative)
|
|
258
|
+
xs = Statistics.data(xs, "ftest")
|
|
259
|
+
ys = Statistics.data(ys, "ftest")
|
|
260
|
+
raise ArgumentError, "ftest: at least two values per sample are needed" if xs.size < 2 || ys.size < 2
|
|
261
|
+
vx = Statistics.variance(xs)
|
|
262
|
+
vy = Statistics.variance(ys)
|
|
263
|
+
raise ArgumentError, "ftest: the second sample has no spread" if Scalar.zero?(vy)
|
|
264
|
+
f = (vx / vy).simplify
|
|
265
|
+
distribution = Distributions::FRatio.new(xs.size - 1, ys.size - 1)
|
|
266
|
+
TestResult.new(name: "F test of two variances", statistic_name: "F", statistic: f, distribution: distribution,
|
|
267
|
+
pvalue: Num.new(tail(distribution, f, alternative, symmetric: false)), alternative: alternative,
|
|
268
|
+
estimate: f, parameters: { df1: xs.size - 1, df2: ys.size - 1 })
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# ---- exact binomial test ------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
# Two-sided: the sum of the probabilities of all outcomes no more probable
|
|
274
|
+
# than the observed one. Exact, so the p value stays a rational.
|
|
275
|
+
def binomial_test(successes, trials, p: Rational(1, 2), alternative: :two_sided)
|
|
276
|
+
check_alternative(alternative)
|
|
277
|
+
k = successes.is_a?(Num) ? successes.value : successes
|
|
278
|
+
n = trials.is_a?(Num) ? trials.value : trials
|
|
279
|
+
raise ArgumentError, "binomial_test: 0 <= successes <= trials is needed" unless k.is_a?(Integer) && n.is_a?(Integer) && k >= 0 && k <= n
|
|
280
|
+
distribution = Distributions::Binomial.new(n, p)
|
|
281
|
+
probabilities = exact_binomial_pmf(n, p) || (0..n).map { |j| distribution.pdf(j) }
|
|
282
|
+
pvalue =
|
|
283
|
+
case alternative
|
|
284
|
+
when :less then probabilities[0..k].reduce(:+)
|
|
285
|
+
when :greater then probabilities[k..].reduce(:+)
|
|
286
|
+
else
|
|
287
|
+
# "no more probable than the observed one", compared exactly when
|
|
288
|
+
# the probabilities are exact: as Floats, 2**-1100 underflowed and
|
|
289
|
+
# every outcome was as improbable as k = 0 (third review, P-12)
|
|
290
|
+
observed = probabilities[k]
|
|
291
|
+
exact = probabilities.all? { |q| q.is_a?(Rational) || q.is_a?(Integer) }
|
|
292
|
+
if exact
|
|
293
|
+
probabilities.select { |q| q <= observed }.sum
|
|
294
|
+
else
|
|
295
|
+
reference = float(observed, "binomial_test")
|
|
296
|
+
probabilities.select { |q| float(q, "binomial_test") <= reference * (1 + 1e-9) }.reduce(:+)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
TestResult.new(name: "exact binomial test", statistic_name: "k", statistic: Num.new(k), distribution: distribution,
|
|
300
|
+
pvalue: Expression.lift(pvalue).simplify, alternative: alternative,
|
|
301
|
+
estimate: Num.new(Simplify.normalize_number(Rational(k, n))), parameters: { n: n })
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The pmf of Binomial(n, p) for a rational p as Rationals, the
|
|
305
|
+
# binomial coefficients built up one from the next; nil otherwise.
|
|
306
|
+
def exact_binomial_pmf(n, p)
|
|
307
|
+
q = p.is_a?(Num) ? p.value : p
|
|
308
|
+
return nil unless q.is_a?(Rational) || q.is_a?(Integer)
|
|
309
|
+
q = Rational(q)
|
|
310
|
+
coefficient = 1
|
|
311
|
+
(0..n).map do |j|
|
|
312
|
+
coefficient = coefficient * (n - j + 1) / j unless j.zero?
|
|
313
|
+
coefficient * q**j * (1 - q)**(n - j)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# ---- confidence intervals -------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
# For the mean (Student t, or normal when sigma is known), the variance or
|
|
320
|
+
# the standard deviation of a sample. Returns an Interval.
|
|
321
|
+
def confidence_interval(data, level: 0.95, sigma: nil, parameter: :mean)
|
|
322
|
+
values = Statistics.data(data, "confidence_interval")
|
|
323
|
+
n = values.size
|
|
324
|
+
raise ArgumentError, "confidence_interval: at least two values are needed" if n < 2
|
|
325
|
+
alpha = 1.0 - float(level, "confidence_interval")
|
|
326
|
+
raise ArgumentError, "confidence_interval: level must be in (0, 1)" unless alpha.positive? && alpha < 1
|
|
327
|
+
case parameter
|
|
328
|
+
when :mean
|
|
329
|
+
mean = float(Statistics.mean(values), "confidence_interval")
|
|
330
|
+
if sigma
|
|
331
|
+
error = float(sigma, "confidence_interval") / Math.sqrt(n)
|
|
332
|
+
critical = float(Distributions::Normal.new(0, 1).quantile(1 - alpha / 2), "confidence_interval")
|
|
333
|
+
else
|
|
334
|
+
error = float(Statistics.stdev(values), "confidence_interval") / Math.sqrt(n)
|
|
335
|
+
critical = float(Distributions::StudentT.new(n - 1).quantile(1 - alpha / 2), "confidence_interval")
|
|
336
|
+
end
|
|
337
|
+
Interval.closed(Num.new(mean - critical * error), Num.new(mean + critical * error))
|
|
338
|
+
when :variance, :stdev
|
|
339
|
+
scaled = (n - 1) * float(Statistics.variance(values), "confidence_interval")
|
|
340
|
+
chi = Distributions::ChiSquare.new(n - 1)
|
|
341
|
+
upper = scaled / float(chi.quantile(alpha / 2), "confidence_interval")
|
|
342
|
+
lower = scaled / float(chi.quantile(1 - alpha / 2), "confidence_interval")
|
|
343
|
+
lower, upper = [Math.sqrt(lower), Math.sqrt(upper)] if parameter == :stdev
|
|
344
|
+
Interval.closed(Num.new(lower), Num.new(upper))
|
|
345
|
+
else raise ArgumentError, "confidence_interval: parameter must be :mean, :variance or :stdev"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Wilson's score interval for a proportion.
|
|
350
|
+
def proportion_interval(successes, trials, level: 0.95)
|
|
351
|
+
k = float(successes, "proportion_interval")
|
|
352
|
+
n = float(trials, "proportion_interval")
|
|
353
|
+
raise ArgumentError, "proportion_interval: 0 <= successes <= trials is needed" unless k >= 0 && k <= n && n.positive?
|
|
354
|
+
alpha = 1.0 - float(level, "proportion_interval")
|
|
355
|
+
z = float(Distributions::Normal.new(0, 1).quantile(1 - alpha / 2), "proportion_interval")
|
|
356
|
+
phat = k / n
|
|
357
|
+
centre = (phat + z * z / (2 * n)) / (1 + z * z / n)
|
|
358
|
+
spread = z * Math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) / (1 + z * z / n)
|
|
359
|
+
low = k.zero? ? 0.0 : [centre - spread, 0.0].max
|
|
360
|
+
high = k == n ? 1.0 : [centre + spread, 1.0].min
|
|
361
|
+
Interval.closed(Num.new(low), Num.new(high))
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|