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
data/lib/rcas/trig.rb ADDED
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Trigonometric and logarithmic rewriting: expand_trig, trigsimp,
5
+ # expand_log, logcombine.
6
+ module Trigonometry
7
+ module_function
8
+
9
+ # sin(a + b), cos(a + b) and integer multiples written out.
10
+ def expand_trig(expr)
11
+ expr = Expression.lift(expr).map_children { |c| expand_trig(c) }
12
+ return expr unless expr.is_a?(Fn) && %i[sin cos tan].include?(expr.name) && expr.args.size == 1
13
+ return (expand_trig(Fn.new(:sin, expr.args)) / expand_trig(Fn.new(:cos, expr.args))).simplify if expr.name == :tan
14
+
15
+ arg = expr.args.first.simplify
16
+ constant, terms = Simplify.termize(arg)
17
+ addends = terms.map { |factors, coeff| Simplify.rebuild_product(coeff, factors) }
18
+ addends << Num.new(constant) unless constant.zero?
19
+ if addends.size >= 2
20
+ a = addends.first
21
+ b = addends.drop(1).reduce { |acc, t| acc + t }
22
+ return addition_formula(expr.name, a, b)
23
+ end
24
+ coeff, factors = Simplify.factorize(arg)
25
+ if coeff.is_a?(Integer) && coeff.abs >= 2 && !factors.empty?
26
+ return multiple_angle(expr.name, coeff, Simplify.rebuild_product(1, factors))
27
+ end
28
+ expr
29
+ end
30
+
31
+ # sin(n*u) and cos(n*u) by de Moivre, (cos u + i sin u)**n [AS64, 4.3.
32
+ # 29-30]: one binomial sum. Peeling one u at a time and expanding sin
33
+ # and cos of the rest separately doubled the work per multiple, and
34
+ # sin(16*x) took nine seconds (third review, section 5).
35
+ def multiple_angle(name, n, u)
36
+ s = expand_trig(Fn.new(:sin, [u]))
37
+ c = expand_trig(Fn.new(:cos, [u]))
38
+ m = n.abs
39
+ terms = (0..m).filter_map do |k|
40
+ next nil if name == :sin ? k.even? : k.odd?
41
+ sign = (-1)**(k / 2)
42
+ Num.new(sign * (0...k).reduce(1) { |acc, i| acc * (m - i) } / (1..k).reduce(1, :*)) * c**(m - k) * s**k
43
+ end
44
+ total = terms.reduce(:+).expand
45
+ name == :sin && n.negative? ? Simplify.negate(total).expand : total
46
+ end
47
+
48
+ def addition_formula(name, a, b)
49
+ sa, ca = expand_trig(Fn.new(:sin, [a])), expand_trig(Fn.new(:cos, [a]))
50
+ sb, cb = expand_trig(Fn.new(:sin, [b])), expand_trig(Fn.new(:cos, [b]))
51
+ case name
52
+ when :sin then (sa * cb + ca * sb).expand
53
+ when :cos then (ca * cb - sa * sb).expand
54
+ end
55
+ end
56
+
57
+ # sin and cos repeat every 2*pi, tan and cot every pi, so an added term
58
+ # that is an integer multiple of the period drops out of the argument:
59
+ # sin(pi/6 + 2*pi*k) is sin(pi/6) for an integer k. The multiple has to
60
+ # be *known* to be one, which is what assume(k: ZZ) says - for an
61
+ # undeclared k nothing happens, because k = 1/2 would be another matter.
62
+ PERIODS = { sin: 2, cos: 2, tan: 1 }.freeze
63
+
64
+ def reduce_period(name, arg)
65
+ period = PERIODS[name]
66
+ return nil if period.nil?
67
+ constant, terms = Simplify.termize(Expression.lift(arg))
68
+ kept = terms.reject { |factors, coeff| whole_period?(factors, coeff, period) }
69
+ return nil if kept.size == terms.size
70
+ Simplify.rebuild_sum(constant, kept)
71
+ end
72
+
73
+ # Is coeff * factors an integer multiple of period*pi?
74
+ def whole_period?(factors, coeff, period)
75
+ return false unless factors[PI] == 1
76
+ return false unless coeff.is_a?(Integer) && (coeff % period).zero?
77
+ rest = factors.reject { |base, _| base == PI }
78
+ return true if rest.empty?
79
+ domain = Infer.domain(Simplify.rebuild_product(1, rest))
80
+ !domain.nil? && domain <= ZZ
81
+ end
82
+
83
+ # The function whose even powers are replaced when +keep+ is kept, and
84
+ # what its square becomes: sin**2 + cos**2 = 1, cosh**2 - sinh**2 = 1.
85
+ SQUARES = {
86
+ sin: [:cos, ->(p) { Num.new(1) - p**2 }],
87
+ cos: [:sin, ->(p) { Num.new(1) - p**2 }],
88
+ sinh: [:cosh, ->(p) { Num.new(1) + p**2 }],
89
+ cosh: [:sinh, ->(p) { p**2 - Num.new(1) }]
90
+ }.freeze
91
+
92
+ # Shortest form among several rewritings using sin**2 + cos**2 = 1 and
93
+ # cosh**2 - sinh**2 = 1.
94
+ def trigsimp(expr)
95
+ base = tan_to_sin_cos(Expression.lift(expr)).simplify
96
+ candidates = [Expression.lift(expr).simplify, base, polynomial_cancel(base)]
97
+ [false, true].each do |expanded|
98
+ f = expanded ? expand_trig(base) : base
99
+ SQUARES.each_key do |keep|
100
+ candidates << polynomial_cancel(reduce_squares(f, keep))
101
+ end
102
+ end
103
+ candidates.compact.map(&:simplify).min_by { |c| [c.each_node.count, c.to_s.size] }
104
+ end
105
+
106
+ def tan_to_sin_cos(expr)
107
+ expr = expr.map_children { |c| tan_to_sin_cos(c) }
108
+ expr.is_a?(Fn) && expr.name == :tan ? Fn.new(:sin, expr.args) / Fn.new(:cos, expr.args) : expr
109
+ end
110
+
111
+ # Replace even powers of the function that is not kept: cos**2 => 1 - sin**2.
112
+ def reduce_squares(expr, keep)
113
+ num, den = numerator_denominator(expr)
114
+ (reduce_table(num, keep) / reduce_table(den, keep)).simplify
115
+ end
116
+
117
+ def numerator_denominator(expr)
118
+ _, table = Expand.table(expr)
119
+ den_factors = {}
120
+ table.each_key do |factors|
121
+ factors.each do |base, exp|
122
+ next unless exp.is_a?(Integer) && exp.negative?
123
+ den_factors[base] = [den_factors[base] || 0, -exp].max
124
+ end
125
+ end
126
+ den = Simplify.rebuild_product(1, den_factors)
127
+ [(expr * den).expand, den]
128
+ end
129
+
130
+ def reduce_table(expr, keep)
131
+ victim, square = SQUARES[keep]
132
+ constant, table = Expand.table(expr)
133
+ result = Num.new(constant)
134
+ table.each do |factors, coeff|
135
+ reducible = factors.select { |base, exp| base.is_a?(Fn) && base.name == victim && exp.is_a?(Integer) && exp >= 2 }
136
+ term = Simplify.rebuild_product(coeff, factors.reject { |b, _| reducible.key?(b) })
137
+ reducible.each do |base, exp|
138
+ partner = Fn.new(keep, [base.args.first])
139
+ term = (term * square.call(partner)**(exp / 2) * base**(exp % 2)).expand
140
+ end
141
+ result += term
142
+ end
143
+ result.expand
144
+ end
145
+
146
+ # Cancel common factors treating each function application as a variable.
147
+ def polynomial_cancel(expr)
148
+ atoms = expr.each_node.select { |n| n.is_a?(Fn) }.uniq
149
+ return expr.cancel if atoms.empty?
150
+ names = atoms.each_with_index.to_h { |a, i| [a, Var.new(:"_f#{i}")] }
151
+ substituted = expr.subs(names)
152
+ return nil if substituted.each_node.any? { |n| n.is_a?(Fn) }
153
+ substituted.cancel.subs(names.invert)
154
+ rescue DomainError, NotImplementedError, RCAS::Unsupported
155
+ nil
156
+ end
157
+
158
+ # log(a*b**n/c) => log(a) + n*log(b) - log(c), for the factors it holds
159
+ # for: log(p*u) = log(p) + log(u) and log(p**n) = n*log(p) when p > 0,
160
+ # which is proved (Decide for a constant, the assumptions for a symbol).
161
+ # The fifth review's decision: the rules no longer assume an undeclared
162
+ # argument positive - log(x**2) is 0 at x = -1 and 2*log(x) is 2*i*pi.
163
+ # A factor whose sign is declared negative is taken by its absolute value
164
+ # when it is all there is, with the i*pi an odd number of them leaves
165
+ # (third review, C4, A5). `force: true` is the textbook manipulation,
166
+ # every argument taken as positive (SymPy's name for MuPAD's
167
+ # IgnoreAnalyticConstraints).
168
+ def expand_log(expr, force: false)
169
+ expr = Expression.lift(expr).map_children { |c| expand_log(c, force: force) }
170
+ return expr unless expr.is_a?(Fn) && expr.name == :log && expr.args.size == 1
171
+ coeff, factors = Simplify.factorize(expr.args.first.simplify)
172
+ return expr if factors.empty? || (factors.size == 1 && factors.values.first == 1 && coeff == 1)
173
+ pieces = []
174
+ kept = {}
175
+ negatives = {}
176
+ factors.each do |base, exp|
177
+ sign = base.variables.empty? ? Decide.sign(base) : RCAS.sign_of(base)
178
+ if sign == :negative && exp.is_a?(Integer)
179
+ negatives[base] = exp
180
+ elsif force || sign == :positive
181
+ pieces << Expression.lift(exp) * Fn.new(:log, [base])
182
+ else
183
+ kept[base] = exp
184
+ end
185
+ end
186
+ unless kept.empty?
187
+ # what is not proved positive stays inside one logarithm, with the
188
+ # negative factors and a negative coefficient
189
+ negatives.each { |base, exp| kept[base] = exp }
190
+ if coeff.is_a?(Numeric) && coeff.real? && coeff.positive?
191
+ pieces.concat(coefficient_logs(coeff))
192
+ coeff = 1
193
+ end
194
+ pieces << Fn.new(:log, [Simplify.rebuild_product(coeff, kept)])
195
+ return pieces.reduce { |acc, p| acc + p }.simplify
196
+ end
197
+ flips = negatives.values.sum
198
+ negatives.each { |base, exp| pieces << Num.new(exp) * Fn.new(:log, [Neg.new(base).simplify]) }
199
+ pieces << I * PI if flips.odd? && !(coeff.is_a?(Numeric) && coeff.real? && coeff.negative?)
200
+ if flips.odd? && coeff.is_a?(Numeric) && coeff.real? && coeff.negative?
201
+ coeff = -coeff # two negatives: the product is positive after all
202
+ end
203
+ if coeff.is_a?(Numeric) && coeff.real? && coeff.negative?
204
+ return expr if pieces.empty?
205
+ pieces << Fn.new(:log, [Num.new(coeff)])
206
+ else
207
+ pieces.concat(coefficient_logs(coeff))
208
+ end
209
+ pieces.reduce { |acc, p| acc + p }.simplify
210
+ end
211
+
212
+ def coefficient_logs(coeff)
213
+ return [] if coeff == 1
214
+ return [Fn.new(:log, [Num.new(coeff)])] unless coeff.is_a?(Rational)
215
+ logs = []
216
+ logs << Fn.new(:log, [Num.new(coeff.numerator)]) unless coeff.numerator == 1
217
+ logs << -Fn.new(:log, [Num.new(coeff.denominator)])
218
+ logs
219
+ end
220
+
221
+ # a*log(u) + b*log(v) => log(u**a * v**b) for rational a, b - for the
222
+ # arguments it holds for: positive ones, proved (2*log(-1) is 2*i*pi and
223
+ # log((-1)**2) is 0, and 3*log(i) is not log(-i): third review, A4).
224
+ # One argument whose sign is not known may still join the positive
225
+ # ones with coefficient 1, since log(p*u) = log(p) + log(u) for p > 0.
226
+ # Undeclared arguments are no longer assumed positive (the fifth
227
+ # review's decision); `force: true` combines every logarithm.
228
+ def logcombine(expr, force: false)
229
+ expr = Expression.lift(expr).simplify
230
+ constant, terms = Simplify.termize(expr)
231
+ inside = {}
232
+ rest = {}
233
+ loose = nil
234
+ terms.each do |factors, coeff|
235
+ log = factors.size == 1 && factors.values.first == 1 && factors.keys.first.is_a?(Fn) && factors.keys.first.name == :log &&
236
+ (coeff.is_a?(Integer) || coeff.is_a?(Rational))
237
+ argument = log && factors.keys.first.args.first
238
+ if log && (force || combinable?(argument))
239
+ Simplify.add_factor(inside, argument, coeff)
240
+ elsif log && coeff == 1 && loose.nil? && !argument.each_node.any? { |n| n.is_a?(Num) && n.value.is_a?(Complex) }
241
+ loose = factors
242
+ else
243
+ rest[factors] = coeff
244
+ end
245
+ end
246
+ if loose
247
+ if inside.empty?
248
+ rest[loose] = 1
249
+ else
250
+ Simplify.add_factor(inside, loose.keys.first.args.first, 1)
251
+ end
252
+ end
253
+ return expr if inside.size < 2 && rest.size + inside.size == terms.size && inside.values.all? { |e| e == 1 }
254
+ combined = Simplify.rebuild_sum(constant, rest)
255
+ combined += Fn.new(:log, [Simplify.rebuild_product(1, inside)]) unless inside.empty?
256
+ combined.simplify
257
+ end
258
+
259
+ def combinable?(u)
260
+ return Decide.sign(u) == :positive if u.variables.empty?
261
+ RCAS.sign_of(u) == :positive
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ module Factor
5
+ # How the p-adic factors are put back together: :van_hoeij (the lattice),
6
+ # :zassenhaus (subsets of increasing size, the original method), or
7
+ # :auto, the subsets for as long as they are cheap and the lattice for
8
+ # what is left.
9
+ #
10
+ # The number of local factors does not say which is faster; the size of
11
+ # the subsets the true factors need does. Products of small factors
12
+ # (x**2 - c, cubics, SD(3)) came out about twice as fast by subsets at
13
+ # every count from 17 to 26 local factors, because they are found among
14
+ # the pairs and triples and every one found shrinks the problem, while
15
+ # three SD(4)s (24 local factors, eight to each true factor) took 1.7 s
16
+ # by subsets and 0.9 s by the lattice, and SD(6) (32, all in one) more
17
+ # than three minutes against 3 s (24 Sept 2026). So :auto counts the
18
+ # subsets of the next size, C(r, s), and hands over to the lattice when
19
+ # that exceeds SUBSET_BUDGET - at about 4 microseconds a subset, the
20
+ # budget is what the lattice costs at twenty local factors. The old
21
+ # method stays so that the two can be compared.
22
+ RECOMBINATIONS = %i[auto van_hoeij zassenhaus].freeze
23
+ SUBSET_BUDGET = 50_000
24
+
25
+ def self.recombination = Thread.current[:rcas_recombination] || :auto
26
+
27
+ # The recombination for the calls made inside the block; nil keeps the
28
+ # one already in force, so an outer choice reaches the inner calls.
29
+ def self.with_recombination(method)
30
+ return yield if method.nil?
31
+ method = method.to_sym
32
+ raise ArgumentError, "factor: recombination must be one of #{RECOMBINATIONS.join(', ')}, got #{method}" unless RECOMBINATIONS.include?(method)
33
+ saved = Thread.current[:rcas_recombination]
34
+ Thread.current[:rcas_recombination] = method
35
+ yield
36
+ ensure
37
+ Thread.current[:rcas_recombination] = saved
38
+ end
39
+
40
+ # The number of subsets the search may try at one size before :auto
41
+ # hands the rest over to the lattice; nil for no limit.
42
+ def self.subset_budget = recombination == :auto ? SUBSET_BUDGET : nil
43
+
44
+ # Van Hoeij's recombination of the p-adic factors by lattice reduction.
45
+ #
46
+ # Zassenhaus tries subsets of the r local factors, and an irreducible
47
+ # polynomial with many of them - a Swinnerton-Dyer polynomial splits into
48
+ # quadratics modulo every prime - makes that 2**(r - 1) trials. Van Hoeij
49
+ # asks instead which 0/1 vectors v (v_i = 1 when f_i is in the factor)
50
+ # belong to a true factor, and finds them all at once as short vectors
51
+ # of a lattice: a knapsack problem LLL can solve [vHo02].
52
+ #
53
+ # What makes the lattice is a quantity that is additive over the local
54
+ # factors and small for a true one. The coefficients of the logarithmic
55
+ # derivative times f serve [HvHN11]: for a factor g of f in ZZ[x],
56
+ # f*g'/g = (f/g)*g' has integer coefficients, bounded by B_j (the sum
57
+ # over the roots of g of f/(x - root), each root below the Fujiwara
58
+ # bound); and f*(g*h)'/(g*h) = f*g'/g + f*h'/h, so modulo p**k the
59
+ # coefficient of a product of local factors is the sum of theirs. The
60
+ # lattice spanned by
61
+ #
62
+ # [ e_i | c_i,j / B_j ] for each local factor f_i
63
+ # [ 0 | p**k / B_j ] for each coefficient j used
64
+ #
65
+ # contains (v, small) for every true factor, with squared length at most
66
+ # M = r + (number of columns). After LLL, a lattice vector whose top
67
+ # Gram-Schmidt length exceeds sqrt(M) cannot be one of those, so the
68
+ # true vectors lie in the span of the first s reduced vectors, where s is
69
+ # the last index with |b*_s|**2 <= M. Their first r coordinates are the
70
+ # basis the next round starts from, with more coefficients. When the
71
+ # reduced echelon form of that basis is a 0/1 matrix with one 1 in every
72
+ # column, its rows are the candidate factors; each is checked by
73
+ # division, so a wrong guess costs a round, never a wrong answer. When
74
+ # the coefficients run out undecided, the precision is raised, and after
75
+ # that the subsets take over.
76
+ #
77
+ # Sources (keys: MANUAL.md, Sources): the algorithm [vHo02]; the
78
+ # coefficients of the logarithmic derivative and their bounds [HvHN11];
79
+ # LLL [LLL82], as in lattice.rb; the root bound [Fuj16].
80
+ module VanHoeij
81
+ MAX_RAISES = 3 # precision doublings before giving up to the subsets
82
+ COLUMNS_PER_ROUND = 2
83
+ SAFETY_BITS = 10 # how far p**k has to stand above B_j * 2**(r/2) for column j
84
+
85
+ module_function
86
+
87
+ # The irreducible factors of f (primitive, squarefree, positive leading
88
+ # coefficient), or nil when the lattice did not decide - the caller
89
+ # then recombines by subsets. `lifted` are the local factors already
90
+ # lifted to p**k, when the subsets handed over: lifting again from p
91
+ # cost 0.8 of 3.7 s on SD(6), and is done only when the lattice needs
92
+ # more precision than the subsets had.
93
+ def recombine(f, modular, p, k, lifted: nil)
94
+ r = modular.size
95
+ bounds = cld_bounds(f)
96
+ # enough precision for the first few columns, which have the
97
+ # smallest bounds: the top coefficients
98
+ wanted = bounds.last(3).max * 2**(r / 2 + SAFETY_BITS)
99
+ if p**k <= wanted
100
+ k += 1 while p**k <= wanted
101
+ lifted = nil
102
+ end
103
+ MAX_RAISES.times do
104
+ lifted ||= Zassenhaus.hensel_lift_leading(f, modular, p, k)
105
+ found = attempt(f, lifted, p**k, bounds)
106
+ return found if found
107
+ k *= 2
108
+ lifted = nil
109
+ end
110
+ nil
111
+ end
112
+
113
+ def attempt(f, lifted, m, bounds)
114
+ r = lifted.size
115
+ n = Dense.deg(f)
116
+ cld = lifted.map { |fi| coefficients(f, fi, m, n) }
117
+ # the columns whose bound stands well below p**k, smallest bound first
118
+ usable = (0...n).select { |j| m > bounds[j] * 2**(r / 2 + SAFETY_BITS) }.sort_by { |j| [bounds[j], -j] }
119
+ basis = Array.new(r) { |i| Array.new(r) { |l| i == l ? 1 : 0 } }
120
+ until usable.empty?
121
+ columns = usable.shift(COLUMNS_PER_ROUND)
122
+ basis = cut(basis, cld, columns, m, bounds)
123
+ return nil if basis.empty? # nothing short survived: the bounds were wrong
124
+ subsets = partition(basis)
125
+ next unless subsets
126
+ factors = candidates(f, lifted, subsets, m)
127
+ return factors if factors
128
+ end
129
+ nil
130
+ end
131
+
132
+ # f*fi'/fi modulo m, symmetric, as n coefficients.
133
+ def coefficients(f, fi, m, n)
134
+ q = Dense.divmod_mod(Dense.mod(f, m), fi, m).first
135
+ c = Dense.sym_mod(Dense.mul_mod(q, Dense.derivative(fi), m), m)
136
+ c + Array.new(n - c.size, 0)
137
+ end
138
+
139
+ # One round: the lattice of the current basis with the new columns,
140
+ # reduced, and cut to the vectors a true factor can lie among.
141
+ def cut(basis, cld, columns, m, bounds)
142
+ r = cld.size
143
+ rows = basis.map do |w|
144
+ traces = columns.map do |j|
145
+ t = w.each_index.sum { |i| w[i] * cld[i][j] } % m
146
+ t -= m if t > m / 2
147
+ Rational(t, bounds[j])
148
+ end
149
+ w.map(&:to_r) + traces
150
+ end
151
+ columns.each_with_index do |j, c|
152
+ rows << Array.new(r, 0r) + Array.new(columns.size) { |d| d == c ? Rational(m, bounds[j]) : 0r }
153
+ end
154
+ reduced, = Lattice.reduce(rows, Lattice::DELTA)
155
+ _, lengths = Lattice.gram_schmidt(reduced)
156
+ bound = r + columns.size
157
+ s = lengths.rindex { |b| b <= bound }
158
+ return [] if s.nil?
159
+ reduced.first(s + 1).map { |row| row.first(r).map { |e| e.to_i } }
160
+ end
161
+
162
+ # The rows of the reduced echelon form when it is a 0/1 matrix with
163
+ # exactly one 1 in every column - a partition of the local factors -
164
+ # as lists of indices; nil otherwise.
165
+ def partition(basis)
166
+ echelon = rref(basis.map { |row| row.map(&:to_r) }).reject { |row| row.all?(&:zero?) }
167
+ return nil unless echelon.flatten.all? { |e| e.zero? || e == 1 }
168
+ return nil unless echelon.transpose.all? { |column| column.count(1) == 1 }
169
+ echelon.map { |row| row.each_index.select { |i| row[i] == 1 } }
170
+ end
171
+
172
+ def rref(rows)
173
+ rows = rows.map(&:dup)
174
+ lead = 0
175
+ cols = rows.first&.size || 0
176
+ rows.each_index do |i|
177
+ lead += 1 while lead < cols && rows[i..].all? { |row| row[lead].zero? }
178
+ break if lead >= cols
179
+ j = (i...rows.size).find { |l| !rows[l][lead].zero? }
180
+ rows[i], rows[j] = rows[j], rows[i]
181
+ pivot = rows[i][lead]
182
+ rows[i] = rows[i].map { |e| e / pivot }
183
+ rows.each_index do |l|
184
+ next if l == i || rows[l][lead].zero?
185
+ factor = rows[l][lead]
186
+ rows[l] = rows[l].zip(rows[i]).map { |a, b| a - factor * b }
187
+ end
188
+ lead += 1
189
+ end
190
+ rows
191
+ end
192
+
193
+ # lc(f) times the product of each subset of local factors, made
194
+ # primitive; the list when every one divides f and they multiply to
195
+ # its degree, nil otherwise.
196
+ def candidates(f, lifted, subsets, m)
197
+ lc = f.last
198
+ rest = f
199
+ factors = subsets.map do |indices|
200
+ product = indices.reduce([lc]) { |acc, i| Dense.mul_mod(acc, lifted[i], m) }
201
+ g = Dense.primitive(Dense.sym_mod(product, m))
202
+ g = Dense.scale(g, -1) if g.last.negative?
203
+ quotient = Dense.div_exact(rest, g)
204
+ return nil if quotient.nil?
205
+ rest = quotient
206
+ g
207
+ end
208
+ return nil unless Dense.deg(rest).zero?
209
+ factors.sort_by { |g| [Dense.deg(g), g] }
210
+ end
211
+
212
+ # B_j: the coefficient of x**j in f*g'/g for any factor g of f is at
213
+ # most n * sum_{i > j} |f_i| * R**(i - j - 1), with R a bound on the
214
+ # absolute values of the roots of f.
215
+ def cld_bounds(f)
216
+ n = Dense.deg(f)
217
+ radius = root_bound(f)
218
+ (0...n).map do |j|
219
+ n * (j + 1..n).sum { |i| f[i].abs * radius**(i - j - 1) } + 1
220
+ end
221
+ end
222
+
223
+ # Fujiwara's bound: every root lies within 2*max |a_(n-i)/a_n|**(1/i)
224
+ # [Fuj16], rounded up to an integer.
225
+ def root_bound(f)
226
+ n = Dense.deg(f)
227
+ lead = f.last.abs
228
+ terms = (1..n).map do |i|
229
+ a = f[n - i].abs
230
+ a = Rational(a, 2) if i == n # the constant term enters halved
231
+ next 0 if a.zero?
232
+ ratio = Rational(a, lead)
233
+ root = Simplify.integer_root(ratio.ceil, i)
234
+ root += 1 while root**i < ratio
235
+ root
236
+ end
237
+ 2 * terms.max
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # QQ**3: column vectors of fixed dimension over a domain, and a domain
5
+ # itself: a vector answers `domain` with the space it lives in, and the
6
+ # space answers `base` with the domain its entries come from. Over a ring
7
+ # such as ZZ this is a free module rather than a vector space, but the API
8
+ # is the same.
9
+ class VectorSpace < Domain
10
+ attr_reader :base, :dim
11
+
12
+ def initialize(base, dim)
13
+ raise ArgumentError, "dimension must be a non-negative integer" unless dim.is_a?(Integer) && dim >= 0
14
+ @base = base
15
+ @dim = dim
16
+ freeze
17
+ end
18
+
19
+ # QQ**3 [1, 2, 3] or (QQ**3)[1, 2, 3]
20
+ def [](*entries)
21
+ entries = entries.flatten(1) if entries.size == 1 && entries.first.is_a?(Array)
22
+ raise DomainError, "#{self} needs #{dim} entries, got #{entries.size}" unless entries.size == dim
23
+ entries = entries.map { |e| Scalar.lift(e) }
24
+ entries = entries.map { |e| base.normalize_coefficient(e) } if base.respond_to?(:normalize_coefficient)
25
+ entries.each do |e|
26
+ next if Infer.where_defined { base.include?(e) }
27
+ hint = e.variables.reject { |v| RCAS.assumption(v) }
28
+ hint = hint.empty? ? "" : " (declare #{hint.join(', ')} with assume(#{hint.first}: #{base}))"
29
+ raise DomainError, "#{e} is not in #{base}#{hint}"
30
+ end
31
+ Vector.new(self, entries)
32
+ end
33
+
34
+ def unchecked(entries) = Vector.new(self, entries.map { |e| Scalar.lift(e) })
35
+
36
+ def include?(obj)
37
+ obj = self[*obj] if obj.is_a?(Array) && obj.size == dim
38
+ obj.is_a?(Vector) && obj.dim == dim && obj.entries.all? { |e| base.include?(e) }
39
+ rescue DomainError
40
+ false
41
+ end
42
+ alias member? include?
43
+ def ===(obj) = include?(obj)
44
+
45
+ def zero = unchecked(Array.new(dim, 0))
46
+ def basis = (0...dim).map { |i| unchecked(Array.new(dim) { |j| i == j ? 1 : 0 }) }
47
+
48
+ # A module, not a ring: vectors have no multiplication between them.
49
+ def ring? = false
50
+ def field? = false
51
+
52
+ # Vectors are not numbers: no polynomial has them as coefficients.
53
+ def scalar? = false
54
+
55
+ def over(other_base) = VectorSpace.new(other_base, dim)
56
+
57
+ # The same dimension over a larger base: (ZZ**3) < (QQ**3).
58
+ def subset?(other) = other.is_a?(VectorSpace) && other.dim == dim && base.subset?(other.base)
59
+
60
+ def join(other)
61
+ return VectorSpace.new(base.join(other.base), dim) if other.is_a?(VectorSpace) && other.dim == dim
62
+ raise DomainError, "no common domain for #{self} and #{other}" if other.is_a?(VectorSpace) || other.is_a?(MatrixSpace)
63
+ VectorSpace.new(base.join(other), dim) # a domain of scalars: what scaling gives
64
+ end
65
+
66
+ def ==(other) = other.is_a?(VectorSpace) && other.base == base && other.dim == dim
67
+ alias eql? ==
68
+ def hash = [VectorSpace, base, dim].hash
69
+
70
+ def name = "#{base}**#{dim}"
71
+ end
72
+
73
+ class Vector
74
+ include Algebraic
75
+ include Enumerable
76
+
77
+ attr_reader :space, :entries
78
+
79
+ def initialize(space, entries)
80
+ @space = space
81
+ @entries = entries.freeze
82
+ freeze
83
+ end
84
+
85
+ def dim = entries.size
86
+ alias size dim
87
+ # The space is where the vector lives, base where its entries come from.
88
+ def domain = space
89
+ def base = space.base
90
+ def [](i) = entries.fetch(i)
91
+ def each(&block) = entries.each(&block)
92
+ def to_a = entries.dup
93
+
94
+ # ---- arithmetic -------------------------------------------------------
95
+
96
+ def +(other) = zip_with(other, :+) { |a, b| Scalar.add(a, b) }
97
+ def -(other) = zip_with(other, :-) { |a, b| Scalar.sub(a, b) }
98
+ def -@ = space.unchecked(entries.map { |e| Scalar.neg(e) })
99
+
100
+ # Vector * Vector is the dot product, Vector * Matrix a row-vector product,
101
+ # anything else scales.
102
+ def *(other)
103
+ case other
104
+ when Vector then dot(other)
105
+ when Matrix then Matrix.row_times(self, other)
106
+ else scale(other)
107
+ end
108
+ end
109
+
110
+ def /(scalar)
111
+ s = Scalar.lift(scalar)
112
+ space.over(result_domain(s).fraction_field).unchecked(entries.map { |e| Scalar.div(e, s) })
113
+ end
114
+
115
+ def scale(scalar)
116
+ s = Scalar.lift(scalar)
117
+ space.over(result_domain(s)).unchecked(entries.map { |e| Scalar.mul(e, s) })
118
+ end
119
+
120
+ def coerce(other) = [ScalarProxy.new(other), self]
121
+ def rop(op, left) = op == :* ? scale(left) : super
122
+
123
+ def dot(other)
124
+ check_dim!(other)
125
+ entries.zip(other.entries).map { |a, b| Scalar.mul(a, b) }.reduce { |s, x| Scalar.add(s, x) } || Num.new(0)
126
+ end
127
+
128
+ def cross(other)
129
+ check_dim!(other)
130
+ raise ArgumentError, "cross product needs dimension 3" unless dim == 3
131
+ a, b = entries, other.entries
132
+ VectorSpace.new(base.join(other.base), 3).unchecked([
133
+ Scalar.sub(Scalar.mul(a[1], b[2]), Scalar.mul(a[2], b[1])),
134
+ Scalar.sub(Scalar.mul(a[2], b[0]), Scalar.mul(a[0], b[2])),
135
+ Scalar.sub(Scalar.mul(a[0], b[1]), Scalar.mul(a[1], b[0]))
136
+ ])
137
+ end
138
+
139
+ def norm = LinearAlgebra.norm(self) # Hermitian for complex entries
140
+ def zero? = entries.all? { |e| Scalar.zero?(e) }
141
+
142
+ def ==(other)
143
+ other = space.unchecked(other) if other.is_a?(Array) && other.size == dim
144
+ other.is_a?(Vector) && other.dim == dim &&
145
+ entries.zip(other.entries).all? { |a, b| Scalar.zero?(Scalar.sub(a, b)) }
146
+ end
147
+ alias eql? ==
148
+ def hash = [Vector, entries.map(&:simplify)].hash
149
+
150
+ def simplify = space.unchecked(entries.map(&:simplify))
151
+ def subs(*args) = space.unchecked(entries.map { |e| e.subs(*args) })
152
+ def call(**bindings) = space.unchecked(entries.map { |e| Scalar.lift(e.call(**bindings)) })
153
+
154
+ def to_s = "(#{entries.join(', ')})"
155
+ alias inspect to_s
156
+
157
+ private
158
+
159
+ def check_dim!(other)
160
+ raise TypeError, "expected a Vector, got #{other.class}" unless other.is_a?(Vector)
161
+ raise ArgumentError, "dimension mismatch: #{dim} vs #{other.dim}" unless dim == other.dim
162
+ end
163
+
164
+ def zip_with(other, op)
165
+ raise TypeError, "can't apply #{op} to Vector and #{other.class}" unless other.is_a?(Vector)
166
+ check_dim!(other)
167
+ VectorSpace.new(base.join(other.base), dim).unchecked(entries.zip(other.entries).map { |a, b| yield a, b })
168
+ end
169
+
170
+ def result_domain(scalar)
171
+ d = Scalar.domain(scalar)
172
+ d ? base.join(d) : base
173
+ end
174
+ end
175
+ end