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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. metadata +206 -0
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module RCAS
6
+ module OpenMath
7
+ # The XML encoding of OpenMath objects [OM19, ch. 4]: one of the
8
+ # encodings, not the objects themselves.
9
+ #
10
+ # RCAS::OpenMath::XML.encode(om) # => "<OMOBJ ...>"
11
+ # RCAS::OpenMath::XML.decode(string) # => an OpenMath::Root
12
+ #
13
+ # The reader is written out here rather than taken from REXML, which is
14
+ # a bundled gem rather than the standard library; the encoding is a
15
+ # small, attribute-light subset of XML and needs no more than this.
16
+ # Namespace prefixes are accepted and ignored (every element of the
17
+ # encoding is in the OpenMath namespace).
18
+ module XML
19
+ NAMESPACE = "http://www.openmath.org/OpenMath"
20
+
21
+ ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&apos;" }.freeze
22
+ ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
23
+
24
+ Element = Struct.new(:name, :attrs, :children, :text)
25
+
26
+ module_function
27
+
28
+ # +indent+: a number of spaces per level, or nil for a single line.
29
+ # +namespace+: write the xmlns attribute on the outermost element.
30
+ def encode(node, indent: nil, namespace: true, declaration: false)
31
+ node = Root.new(node) unless node.is_a?(Root)
32
+ out = +""
33
+ out << %(<?xml version="1.0" encoding="UTF-8"?>\n) if declaration
34
+ write(node, out, 0, indent, namespace)
35
+ out
36
+ end
37
+
38
+ def write(node, out, depth, indent, namespace)
39
+ pad = indent ? " " * (depth * indent) : ""
40
+ nl = indent ? "\n" : ""
41
+ tag, attrs, text, children = parts(node)
42
+ attrs = { "xmlns" => NAMESPACE }.merge(attrs) if namespace && depth.zero?
43
+ attrs = attrs.merge("id" => node.id) if node.id
44
+ open = "#{pad}<#{tag}#{attributes(attrs)}"
45
+
46
+ if node.is_a?(Foreign)
47
+ out << open << ">" << node.content << "</#{tag}>" << nl
48
+ elsif text.nil? && children.empty?
49
+ out << open << "/>" << nl
50
+ elsif children.empty?
51
+ out << open << ">" << escape(text) << "</#{tag}>" << nl
52
+ else
53
+ out << open << ">" << nl
54
+ children.each { |c| write(c, out, depth + 1, indent, false) }
55
+ out << pad << "</#{tag}>" << nl
56
+ end
57
+ out
58
+ end
59
+
60
+ # tag, attributes, text content (nil for none), child nodes.
61
+ def parts(node)
62
+ case node
63
+ when Root then ["OMOBJ", { "version" => node.version }, nil, [node.object]]
64
+ when Int then ["OMI", {}, node.value.to_s, []]
65
+ when Double then ["OMF", { "dec" => float_text(node.value) }, nil, []]
66
+ when Text then ["OMSTR", {}, node.value, []]
67
+ when Bytes then ["OMB", {}, [node.value].pack("m0"), []]
68
+ when Variable then ["OMV", { "name" => node.name }, nil, []]
69
+ when ContentSymbol then ["OMS", symbol_attributes(node), nil, []]
70
+ when Application then ["OMA", {}, nil, node.children]
71
+ when BVar then ["OMBVAR", {}, nil, node.vars]
72
+ when Bind then ["OMBIND", {}, nil, node.children]
73
+ when Attribution then ["OMATTR", {}, nil, [AttrList.new(node.pairs), node.object]]
74
+ when AttrList then ["OMATP", {}, nil, node.pairs.flat_map(&:children)]
75
+ when Error then ["OME", {}, nil, node.children]
76
+ when Reference then ["OMR", { "href" => node.href }, nil, []]
77
+ when Foreign then ["OMFOREIGN", node.encoding ? { "encoding" => node.encoding } : {}, node.content, []]
78
+ else raise EncodeError, "no XML encoding for #{node.class}"
79
+ end
80
+ end
81
+
82
+ # The OMATP element: a flat list of key/value pairs inside an OMATTR.
83
+ # It is not an object of its own in the standard, so it is not one of
84
+ # the classes in objects.rb - it exists only while encoding.
85
+ class AttrList < Node
86
+ attr_reader :pairs
87
+
88
+ def initialize(pairs)
89
+ @pairs = pairs
90
+ freeze
91
+ end
92
+
93
+ def children = pairs
94
+ end
95
+
96
+ def symbol_attributes(node)
97
+ attrs = {}
98
+ attrs["cdbase"] = node.cdbase if node.cdbase
99
+ attrs["cd"] = node.cd
100
+ attrs["name"] = node.name
101
+ attrs
102
+ end
103
+
104
+ def attributes(attrs)
105
+ attrs.map { |k, v| %( #{k}="#{escape(v.to_s)}") }.join
106
+ end
107
+
108
+ def float_text(value)
109
+ return "INF" if value == Float::INFINITY
110
+ return "-INF" if value == -Float::INFINITY
111
+ return "NaN" if value.nan?
112
+ value.to_s
113
+ end
114
+
115
+ def escape(string) = string.to_s.gsub(/[&<>"']/) { |c| ESCAPES[c] }
116
+
117
+ def unescape(string)
118
+ string.to_s.gsub(/&(#x?\h+|\w+);/) do
119
+ entity = Regexp.last_match(1)
120
+ case entity
121
+ when /\A#x(\h+)\z/ then Regexp.last_match(1).to_i(16).chr(Encoding::UTF_8)
122
+ when /\A#(\d+)\z/ then Regexp.last_match(1).to_i.chr(Encoding::UTF_8)
123
+ else ENTITIES.fetch(entity) { raise ParseError, "unknown entity &#{entity};" }
124
+ end
125
+ end
126
+ end
127
+
128
+ # ---- reading ----------------------------------------------------------
129
+
130
+ def decode(source)
131
+ scanner = StringScanner.new(source.to_s)
132
+ element = read_element(scanner)
133
+ raise ParseError, "no XML element found" unless element
134
+ build(element)
135
+ end
136
+
137
+ def skip_junk(scanner)
138
+ loop do
139
+ scanner.skip(/\s+/)
140
+ break unless scanner.scan(/<\?.*?\?>/m) || scanner.scan(/<!--.*?-->/m) || scanner.scan(/<!DOCTYPE[^>]*>/m)
141
+ end
142
+ end
143
+
144
+ def read_element(scanner)
145
+ skip_junk(scanner)
146
+ return nil unless scanner.scan(/</)
147
+ name = scanner.scan(%r{[^\s/>]+}) or raise ParseError, "expected an element name"
148
+ attrs = read_attributes(scanner)
149
+ return Element.new(local(name), attrs, [], "") if scanner.scan(%r{\s*/>})
150
+ scanner.scan(/\s*>/) or raise ParseError, "unterminated <#{name}>"
151
+ return read_foreign(scanner, local(name), attrs) if local(name) == "OMFOREIGN"
152
+ read_content(scanner, local(name), attrs)
153
+ end
154
+
155
+ def read_attributes(scanner)
156
+ attrs = {}
157
+ loop do
158
+ scanner.skip(/\s+/)
159
+ break if scanner.check(%r{/?>})
160
+ key = scanner.scan(%r{[^\s=/>]+}) or raise ParseError, "expected an attribute name"
161
+ scanner.skip(/\s*=\s*/) or raise ParseError, "expected = after #{key}"
162
+ quote = scanner.scan(/["']/) or raise ParseError, "expected a quoted value for #{key}"
163
+ value = scanner.scan(/[^#{Regexp.escape(quote)}]*/)
164
+ scanner.skip(/#{Regexp.escape(quote)}/)
165
+ attrs[local(key)] = unescape(value)
166
+ end
167
+ attrs
168
+ end
169
+
170
+ # OMFOREIGN holds data that is not OpenMath - a presentation-MathML or
171
+ # LaTeX rendering, an image, a comment - so its content is taken
172
+ # exactly as it stands, markup and all, and never parsed.
173
+ OPEN_FOREIGN = %r{<\s*(?:[\w.-]+:)?OMFOREIGN\b[^>]*>}.freeze
174
+ CLOSE_FOREIGN = %r{</\s*(?:[\w.-]+:)?OMFOREIGN\s*>}.freeze
175
+
176
+ def read_foreign(scanner, name, attrs)
177
+ raw = +""
178
+ depth = 1
179
+ until scanner.eos?
180
+ chunk = scanner.scan(/[^<]+/)
181
+ raw << chunk if chunk
182
+ break if scanner.eos?
183
+
184
+ if (close = scanner.scan(CLOSE_FOREIGN))
185
+ depth -= 1
186
+ break if depth.zero?
187
+ raw << close
188
+ elsif (open_tag = scanner.scan(OPEN_FOREIGN))
189
+ depth += 1
190
+ raw << open_tag
191
+ else
192
+ raw << scanner.scan(/</)
193
+ end
194
+ end
195
+ raise ParseError, "unclosed <OMFOREIGN>" unless depth.zero?
196
+ Element.new(name, attrs, [], raw)
197
+ end
198
+
199
+ def read_content(scanner, name, attrs)
200
+ children = []
201
+ text = +""
202
+ loop do
203
+ raise ParseError, "unclosed <#{name}>" if scanner.eos?
204
+
205
+ if scanner.check(%r{</})
206
+ scanner.scan(%r{</[^>]*>}) or raise ParseError, "unterminated closing tag"
207
+ break
208
+ elsif scanner.check(/<!--/)
209
+ scanner.scan(/<!--.*?-->/m)
210
+ elsif scanner.check(/</)
211
+ children << read_element(scanner)
212
+ else
213
+ text << unescape(scanner.scan(/[^<]+/))
214
+ end
215
+ end
216
+ Element.new(name, attrs, children, text)
217
+ end
218
+
219
+ # Namespace prefixes carry no information here: every element of the
220
+ # encoding lives in the OpenMath namespace.
221
+ def local(name) = name.split(":").last
222
+
223
+ def build(element)
224
+ node = case element.name
225
+ when "OMOBJ" then Root.new(build(only_child(element)), version: element.attrs.fetch("version", OBJECT_VERSION))
226
+ when "OMI" then Int.new(integer_value(element.text))
227
+ when "OMF" then Double.new(float_value(element.attrs))
228
+ when "OMSTR" then Text.new(element.text)
229
+ when "OMB" then Bytes.new(element.text.unpack1("m") || "")
230
+ when "OMV" then Variable.new(attribute(element, "name"))
231
+ when "OMS" then ContentSymbol.new(attribute(element, "cd"), attribute(element, "name"), cdbase: element.attrs["cdbase"])
232
+ when "OMA" then build_application(element)
233
+ when "OMBVAR" then BVar.new(element.children.map { |c| build(c) })
234
+ when "OMBIND" then build_binding(element)
235
+ when "OMATTR" then build_attribution(element)
236
+ when "OME" then build_error(element)
237
+ when "OMR" then Reference.new(attribute(element, "href"))
238
+ when "OMFOREIGN" then Foreign.new(element.text, encoding: element.attrs["encoding"])
239
+ else raise ParseError, "unknown OpenMath element <#{element.name}>"
240
+ end
241
+ id = element.attrs["id"] || element.attrs["xml:id"]
242
+ id ? node.identified(id) : node
243
+ end
244
+
245
+ def build_application(element)
246
+ children = element.children.map { |c| build(c) }
247
+ raise ParseError, "<OMA> needs at least a head" if children.empty?
248
+ Application.new(children.first, children[1..])
249
+ end
250
+
251
+ def build_binding(element)
252
+ children = element.children
253
+ unless children.size == 3 && children[1].name == "OMBVAR"
254
+ raise ParseError, "<OMBIND> needs a binder, an <OMBVAR> and a body"
255
+ end
256
+ Bind.new(build(children[0]), build(children[1]), build(children[2]))
257
+ end
258
+
259
+ def build_attribution(element)
260
+ pairs_element, object = element.children
261
+ raise ParseError, "<OMATTR> needs an <OMATP> and an object" unless pairs_element&.name == "OMATP" && object
262
+ flat = pairs_element.children.map { |c| build(c) }
263
+ raise ParseError, "<OMATP> needs an even number of children" unless flat.size.even?
264
+ Attribution.new(flat.each_slice(2).map { |k, v| AttrPair.new(k, v) }, build(object))
265
+ end
266
+
267
+ def build_error(element)
268
+ children = element.children.map { |c| build(c) }
269
+ raise ParseError, "<OME> needs a symbol" if children.empty?
270
+ Error.new(children.first, children[1..])
271
+ end
272
+
273
+ def only_child(element)
274
+ children = element.children
275
+ raise ParseError, "<#{element.name}> needs exactly one child" unless children.size == 1
276
+ children.first
277
+ end
278
+
279
+ def attribute(element, name)
280
+ element.attrs[name] or raise ParseError, "<#{element.name}> needs a #{name} attribute"
281
+ end
282
+
283
+ # OMI is decimal, or hexadecimal written x1F / -x1F.
284
+ def integer_value(text)
285
+ stripped = text.to_s.strip
286
+ case stripped
287
+ when /\A(-?)x(\h+)\z/ then (Regexp.last_match(1) == "-" ? -1 : 1) * Regexp.last_match(2).to_i(16)
288
+ when /\A-?\d+\z/ then stripped.to_i
289
+ else raise ParseError, "not an integer: #{text.inspect}"
290
+ end
291
+ end
292
+
293
+ def float_value(attrs)
294
+ if (dec = attrs["dec"])
295
+ case dec
296
+ when "INF" then Float::INFINITY
297
+ when "-INF" then -Float::INFINITY
298
+ when "NaN" then Float::NAN
299
+ else Float(dec)
300
+ end
301
+ elsif (hex = attrs["hex"])
302
+ [hex].pack("H*").unpack1("G")
303
+ else
304
+ raise ParseError, "<OMF> needs a dec or hex attribute"
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openmath/objects"
4
+ require_relative "openmath/xml"
5
+ require_relative "openmath/phrasebook"
6
+ require_relative "openmath/popcorn"
7
+
8
+ module RCAS
9
+ module OpenMath
10
+ module_function
11
+
12
+ # An OpenMath object from its XML encoding, or from an object already
13
+ # parsed. Use #to_expression on the result for the rcas object.
14
+ def parse(source)
15
+ source.is_a?(Node) ? source : XML.decode(source)
16
+ end
17
+
18
+ # The rcas object a piece of OpenMath stands for, unevaluated: the
19
+ # document says 1 + 2, and 1 + 2 is what comes back, not 3.
20
+ def read(source) = parse(source).to_expression
21
+ end
22
+
23
+ class Expression
24
+ # This expression as an OpenMath object; #to_xml writes the encoding.
25
+ def to_openmath = OpenMath::Phrasebook.to_openmath(self)
26
+ end
27
+
28
+ class Equation
29
+ def to_openmath = OpenMath::Phrasebook.to_openmath(self)
30
+ end
31
+
32
+ module Functions
33
+ # openmath(x**2 + 1): the OpenMath object, whose to_xml is the XML
34
+ # encoding. Equations, intervals, matrices and number sets travel too.
35
+ def openmath(obj) = OpenMath::Phrasebook.to_openmath(obj)
36
+
37
+ # from_openmath(xml): the rcas object an OpenMath document stands for,
38
+ # held rather than evaluated - "1 + 2" comes back as 1 + 2.
39
+ def from_openmath(source) = OpenMath.read(source)
40
+
41
+ # popcorn(x**2 + 1): the OpenMath object in POPCORN, the notation meant
42
+ # for people rather than machines - "$x^2 + 1".
43
+ def popcorn(obj) = OpenMath::Phrasebook.to_openmath(obj).to_popcorn
44
+
45
+ # from_popcorn("$x^2 + 1"): the rcas object that notation stands for,
46
+ # held rather than evaluated.
47
+ def from_popcorn(source) = OpenMath::Popcorn.parse(source).to_expression
48
+ end
49
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Petkovsek's algorithm: the hypergeometric solutions of a linear
5
+ # recurrence with polynomial coefficients,
6
+ #
7
+ # p_0(n)*u(n) + p_1(n)*u(n + 1) + ... + p_r(n)*u(n + r) = 0.
8
+ #
9
+ # A hypergeometric term is one whose ratio u(n + 1)/u(n) is rational, and
10
+ # Petkovsek's theorem says every such ratio solving the recurrence can be
11
+ # written
12
+ #
13
+ # u(n + 1)/u(n) = z * a(n)/b(n) * c(n + 1)/c(n)
14
+ #
15
+ # with a, b monic, gcd(a(n), b(n + h)) = 1 for every integer h >= 0,
16
+ # a(n) | p_0(n), b(n) | p_r(n + r - 1), z a constant and c a polynomial.
17
+ # That turns an infinite search into a finite one: run through the monic
18
+ # divisors a and b, read the possible z off the leading coefficients (the
19
+ # top-degree terms have to cancel), and look for c with PolyRecurrence.
20
+ # Every candidate is verified on the ratio before it is returned.
21
+ #
22
+ # Sources (keys: MANUAL.md, Sources): [Pet92]; [Koe14, ch. 9];
23
+ # [PWZ96, ch. 8].
24
+ module Petkovsek
25
+ MAX_DIVISORS = 64
26
+
27
+ module_function
28
+
29
+ # The ratios u(n + 1)/u(n) of the hypergeometric solutions, as rational
30
+ # functions of n.
31
+ def ratios(coeffs, n)
32
+ coeffs = coeffs.map { |c| Expression.lift(c).expand }
33
+ r = coeffs.size - 1
34
+ return [] if r < 1 || Scalar.zero?(coeffs.first) || Scalar.zero?(coeffs.last)
35
+ # first order: u(n + 1)/u(n) = -p0/p1 is the ratio, hypergeometric by
36
+ # definition - no search, and no divisor list to run out of (the
37
+ # search answered [] for (n + 1)...(n + 7): third review, S7)
38
+ return [(Simplify.negate(coeffs[0]) / coeffs[1]).cancel] if r == 1
39
+ ring = ring_for(coeffs, n) or return []
40
+ last = ring.call(coeffs.last.subs(n => n + r - 1).expand)
41
+ found = []
42
+ divisors(ring.call(coeffs.first)).each do |a|
43
+ divisors(last).each do |b|
44
+ next unless Summation.dispersion(a, b, n.name).empty?
45
+ candidates(coeffs, a, b, n, r).each do |ratio|
46
+ found << ratio unless found.any? { |f| Scalar.zero?((f - ratio).cancel) }
47
+ end
48
+ end
49
+ end
50
+ found
51
+ rescue TooMany
52
+ raise
53
+ rescue DomainError, NotImplementedError, RCAS::Unsupported, ZeroDivisionError
54
+ []
55
+ end
56
+
57
+ # The solutions themselves: the ratios turned back into terms, in closed
58
+ # form wherever the product has one (factorials, powers, rationals).
59
+ def solutions(coeffs, n) = ratios(coeffs, n).map { |ratio| term(ratio, n) }
60
+
61
+ # The term with the given ratio, as a product from the first index at
62
+ # which the ratio is defined and non-zero.
63
+ def term(ratio, n)
64
+ i = Var.new(:_i)
65
+ Products.product(ratio.subs(n => i), i, Num.new(start_index(ratio, n)), (n - 1).simplify)
66
+ end
67
+
68
+ # ---- the search --------------------------------------------------------------
69
+
70
+ # The (a, b) pair fixes A_j = a(n)...a(n + j - 1) and B_j = b(n + j)...
71
+ # b(n + r - 1); what is left is z and the polynomial c in
72
+ # sum_j p_j(n) z**j A_j(n) B_j(n) c(n + j) = 0.
73
+ def candidates(coeffs, a, b, n, r)
74
+ base = (0..r).map do |j|
75
+ aj = (0...j).reduce(Num.new(1)) { |acc, i| acc * shift(a, n, i) }
76
+ bj = (j...r).reduce(Num.new(1)) { |acc, i| acc * shift(b, n, i) }
77
+ (coeffs[j] * aj * bj).expand
78
+ end
79
+ zs(base, n, r).flat_map do |z|
80
+ qs = base.each_with_index.map { |q, j| (q * z**j).expand }
81
+ PolyRecurrence.basis(qs, n).filter_map do |c|
82
+ ratio = (z * a.to_expr * c.subs(n => n + 1) / (b.to_expr * c)).cancel
83
+ ratio if satisfies?(coeffs, ratio, n, r)
84
+ end
85
+ end
86
+ end
87
+
88
+ # The constants z for which the top-degree terms of the transformed
89
+ # equation can cancel: the roots of sum_{j in J} lc(q_j)*z**j, where J
90
+ # collects the j of maximal degree.
91
+ def zs(base, n, r)
92
+ degrees = base.map { |q| Solve.polynomial_coefficients(q, n)&.size }
93
+ return [] if degrees.any?(&:nil?)
94
+ top = degrees.each_index.select { |j| degrees[j] == degrees.max }
95
+ return [] if top.size < 2 # one term alone cannot cancel
96
+ coefficients = Array.new(r + 1) { Num.new(0) }
97
+ top.each { |j| coefficients[j] = Solve.polynomial_coefficients(base[j], n).last }
98
+ Solve.polynomial_roots(coefficients).map(&:simplify)
99
+ .reject { |z| Scalar.zero?(z) || z.is_a?(RootOf) }
100
+ .uniq { |z| z.to_s }
101
+ rescue NotImplementedError, RCAS::Unsupported, DomainError
102
+ []
103
+ end
104
+
105
+ # Does a term with this ratio satisfy the recurrence? Checked as a
106
+ # rational identity: sum_j p_j(n) * ratio(n)*...*ratio(n + j - 1) = 0.
107
+ def satisfies?(coeffs, ratio, n, r)
108
+ total = Num.new(0)
109
+ product = Num.new(1)
110
+ (0..r).each do |j|
111
+ total += coeffs[j] * product
112
+ product = (product * ratio.subs(n => n + j)).cancel if j < r
113
+ end
114
+ Scalar.zero?(total.cancel)
115
+ rescue ZeroDivisionError
116
+ false
117
+ end
118
+
119
+ # ---- polynomials -------------------------------------------------------------
120
+
121
+ def ring_for(coeffs, n)
122
+ vars = coeffs.flat_map { |c| c.variables.to_a }.uniq
123
+ vars = [n.name] | vars
124
+ QQ[*vars]
125
+ end
126
+
127
+ def shift(poly, n, h) = poly.to_expr.subs(n => n + h)
128
+
129
+ # Every monic divisor of +poly+, the constant 1 included.
130
+ def divisors(poly)
131
+ return [poly.ring.one] if poly.constant?
132
+ factorization = poly.factor
133
+ list = [poly.ring.one]
134
+ factorization.factors.each do |factor, multiplicity|
135
+ list = list.flat_map { |d| (0..multiplicity).map { |i| d * factor**i } }
136
+ # searching only the divisor 1 would be "no solution" for "too many
137
+ # to try": that is a refusal
138
+ raise TooMany, "hyper: #{poly} has more than #{MAX_DIVISORS} monic divisors to search" if list.size > MAX_DIVISORS
139
+ end
140
+ list.map(&:monic).uniq { |d| d.to_expr.to_s }
141
+ rescue DomainError
142
+ [poly.ring.one]
143
+ end
144
+
145
+ class TooMany < RCAS::Unsupported; end
146
+
147
+ # The first index from which the product of the ratio is finite and
148
+ # non-zero: past every non-negative integer root of numerator and
149
+ # denominator.
150
+ def start_index(ratio, n)
151
+ pair = Fraction.as_fraction(ratio, [n.name]) or return 0
152
+ roots = pair.flat_map do |poly|
153
+ coefficients = (0..poly.degree(n.name)).map { |i| poly.coefficient_in(n.name, i).to_expr }
154
+ next [] unless coefficients.all? { |c| c.variables.empty? }
155
+ Solve.polynomial_roots(coefficients)
156
+ end
157
+ integers = roots.filter_map { |r| r.simplify }
158
+ .select { |r| r.is_a?(Num) && r.value.is_a?(Integer) && !r.value.negative? }
159
+ .map(&:value)
160
+ integers.empty? ? 0 : integers.max + 1
161
+ rescue NotImplementedError, RCAS::Unsupported, DomainError, ZeroDivisionError
162
+ 0
163
+ end
164
+ end
165
+ end