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,558 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # A curve discussion: the questions a course asks about a graph, in the
5
+ # order it asks them. Continental schools give the ritual a name - the
6
+ # German "Kurvendiskussion", the French "etude de fonction", the Italian
7
+ # "studio di funzione"; in English it is curve sketching.
8
+ #
9
+ # discuss(x**3 - 3*x, x) the answers, one row each
10
+ # steps(x**3 - 3*x, x, :discuss) the same questions, worked through
11
+ #
12
+ # Every answer is asked of the function that owns it - real_domain, solve,
13
+ # limit, extrema, inflections, asymptotes - so a discussion cannot
14
+ # disagree with them, and a question rcas cannot answer says so instead of
15
+ # being dropped. The monotonicity and the curvature come from a sign
16
+ # chart: the line is cut at the zeros of f' (of f'') and at the gaps of
17
+ # the domain, and the sign of each piece is read off sample points, which
18
+ # is the table a student draws. Three samples per piece, so a piece whose
19
+ # sign is not constant - a zero the solver missed - is reported as
20
+ # undecided rather than guessed. For a periodic f the chart covers one
21
+ # period and says so.
22
+ #
23
+ # Sources (keys: MANUAL.md, Sources): the questions, the second-derivative
24
+ # test and the sign table are the textbook ones [Spi08, ch. 11].
25
+ module Discussion
26
+ # What a discussion found, one row per question. The expressions stay
27
+ # expressions, so the rows typeset as well as they print.
28
+ class Report
29
+ LABEL = 13
30
+
31
+ SYMMETRY = {
32
+ even: "even: f(-%<x>s) = f(%<x>s), symmetric about the vertical axis",
33
+ odd: "odd: f(-%<x>s) = -f(%<x>s), symmetric about the origin",
34
+ none: "none"
35
+ }.freeze
36
+
37
+ attr_reader :f, :var, :domain, :symmetry, :period, :zeros, :intercept, :gaps,
38
+ :limits, :asymptotes, :derivatives, :extrema, :monotonicity,
39
+ :inflections, :curvature, :hints
40
+
41
+ def initialize(**parts)
42
+ parts.each { |name, value| instance_variable_set("@#{name}", value) }
43
+ freeze
44
+ end
45
+
46
+ # [label, items, separator, suffix]; an item is a list of strings and
47
+ # expressions, so to_s and to_latex render the same rows.
48
+ def rows
49
+ out = []
50
+ out << ["domain", [[domain || "not determined"]]]
51
+ out << ["symmetry", [[symmetry_text]]]
52
+ out << ["period", [[period]]] if period
53
+ out << ["zeros", items(zeros) { |z| [z] }, nil, repeats(zeros)]
54
+ out << ["y intercept", [["f(0) = ", intercept]]] if intercept
55
+ out << ["gaps", gaps.map { |point, kind, _left, _right| [point, " (#{kind})"] }, nil, repeats(gaps)] unless gaps.empty?
56
+ out << ["at infinity", limits.map { |point, value| limit_item(point, value) }, "; "]
57
+ lines = asymptote_items
58
+ out << ["asymptotes", lines.empty? ? [["none"]] : lines]
59
+ out << ["extrema", items(extrema) { |p, v, kind| ["#{kind} at (", p, ", ", v, ")"] }, "; ", repeats(extrema)]
60
+ out << ["monotonic", items(monotonicity) { |i, kind| ["#{kind} on ", i] }, nil, repeats(monotonicity)]
61
+ out << ["inflections", items(inflections) { |p, v| ["(", p, ", ", v, ")"] }, nil, repeats(inflections)]
62
+ out << ["curvature", items(curvature) { |i, kind| ["#{kind} on ", i] }, nil, repeats(curvature)]
63
+ (hints || []).each { |hint| out << ["note", [[hint]]] }
64
+ out
65
+ end
66
+
67
+ def symmetry_text
68
+ template = SYMMETRY[symmetry]
69
+ template.include?("%") ? format(template, x: var) : template
70
+ end
71
+
72
+ # "none" and "not determined" are answers too, and different ones.
73
+ def items(values, &block)
74
+ return [["not determined"]] if values.nil?
75
+ return [["none"]] if values.empty?
76
+ values.map(&block)
77
+ end
78
+
79
+ # A periodic function repeats what a row says, one period on.
80
+ def repeats(values)
81
+ period && values && !values.empty? ? ["(+ k*", period, ", k an integer)"] : nil
82
+ end
83
+
84
+ def limit_item(point, value)
85
+ return ["f -> ", value, " as #{var} -> ", point] if value
86
+ ["#{period ? 'no limit' : 'not determined'} as #{var} -> ", point]
87
+ end
88
+
89
+ def asymptote_items
90
+ return [["not determined"]] if asymptotes.values.all?(&:nil?)
91
+ vertical = (asymptotes[:vertical] || []).map { |p| ["#{var} = ", p] }
92
+ lines = vertical + ((asymptotes[:horizontal] || []) + (asymptotes[:oblique] || [])).map { |l| ["y = ", l] }
93
+ lines + asymptotes.select { |_, found| found.nil? }.map { |kind, _| ["#{kind} not determined"] }
94
+ end
95
+
96
+ def self.render(item) = item.map(&:to_s).join
97
+
98
+ def self.render_latex(item)
99
+ item.map { |part| part.is_a?(String) ? "\\text{#{LaTeX.escape(part)}}" : LaTeX.of(part) }.join
100
+ end
101
+
102
+ def to_s
103
+ body = rows.map do |label, list, separator, suffix|
104
+ values = list.map { |item| Report.render(item) }.join(separator || ", ")
105
+ values += " #{Report.render(suffix)}" if suffix
106
+ " #{label.ljust(LABEL)}#{values}"
107
+ end
108
+ (["f(#{var}) = #{f}"] + body).join("\n")
109
+ end
110
+ alias inspect to_s
111
+
112
+ def to_latex(wrap: nil)
113
+ body = rows.map do |label, list, _separator, suffix|
114
+ values = list.map { |item| Report.render_latex(item) }.join(',\; ')
115
+ values += " #{Report.render_latex(suffix)}" if suffix
116
+ "\\text{#{LaTeX.escape(label)}} &: #{values}"
117
+ end
118
+ "\\begin{aligned} f(#{LaTeX.of(var)}) &= #{LaTeX.of(f)} \\\\ #{body.join(' \\\\ ')} \\end{aligned}"
119
+ end
120
+ end
121
+
122
+ module_function
123
+
124
+ # The whole discussion of f as a function of one indeterminate.
125
+ def discuss(f, var = nil)
126
+ f = Expression.lift(f)
127
+ x = Analysis.variable(f, var)
128
+ domain = domain_of(f, x)
129
+ inside = domain || RealSet.reals
130
+ cycle = period(f, x)
131
+ # zeros are counted in full unless f is periodic, when one period is
132
+ # the chart and the answer: sin(x)/x has zeros at every k*pi, k != 0,
133
+ # and "pi" alone was a truncation (third review, S14)
134
+ @principal = !cycle.nil?
135
+ @cycle = cycle
136
+ first = derivative(f, x)
137
+ second = first && derivative(first, x)
138
+ holes = gaps(f, x)
139
+ cuts = holes.map(&:first)
140
+ Report.new(
141
+ f: f, var: x, domain: domain, symmetry: symmetry(f, x), period: cycle,
142
+ zeros: zeros(f, x, inside), intercept: intercept(f, x, inside),
143
+ gaps: holes, limits: limits_at_infinity(f, x, inside), asymptotes: asymptotes(f, x, inside),
144
+ derivatives: [first, second, second && derivative(second, x)],
145
+ extrema: extrema(f, x, first),
146
+ monotonicity: label(sign_chart(first, x, inside, cuts, cycle), :increasing, :decreasing),
147
+ inflections: inflections(f, x, second),
148
+ curvature: label(sign_chart(second, x, inside, cuts, cycle), :convex, :concave),
149
+ hints: Analysis.root_hints(f, x)
150
+ )
151
+ end
152
+
153
+ # nil when rcas cannot differentiate (an unknown function, floor): the
154
+ # whole second half of the discussion then says it is undecided rather
155
+ # than raising in the middle of the report.
156
+ def derivative(e, x)
157
+ tidy(e.diff(x))
158
+ rescue ArgumentError, NotImplementedError, RCAS::Unsupported
159
+ nil
160
+ end
161
+
162
+ # "Undecided" is an answer here: nil means the row could not be
163
+ # determined and prints as "not determined". These are the ways the
164
+ # library says so. A bare `rescue StandardError` said it for a typo or a
165
+ # genuine bug too, and the tests still passed.
166
+ UNDECIDED = [ArgumentError, DomainError, ZeroDivisionError, NotImplementedError, RCAS::Unsupported, SeriesError].freeze
167
+
168
+ # A derivative reads better cancelled: the second derivative of
169
+ # (x**2 + 1)/x is 2/x**3, not a quotient of quartics.
170
+ def tidy(e)
171
+ Fraction.cancel(e)
172
+ rescue *UNDECIDED
173
+ e.simplify
174
+ end
175
+
176
+ # ---- the single questions --------------------------------------------------
177
+
178
+ def domain_of(f, x)
179
+ Analysis.real_domain(f, x)
180
+ rescue NotImplementedError, RCAS::Unsupported, ArgumentError, DomainError
181
+ nil
182
+ end
183
+
184
+ def reflect(f, x) = f.subs(x => Neg.new(x).simplify).simplify
185
+
186
+ # f(-x) against f(x) and -f(x).
187
+ def symmetry(f, x)
188
+ reflected = reflect(f, x)
189
+ return :even if same?(reflected, f)
190
+ return :odd if same?(reflected, Neg.new(f).simplify)
191
+ :none
192
+ end
193
+
194
+ def same?(a, b)
195
+ difference = a - b
196
+ return true if Scalar.zero?(difference.simplify)
197
+ Scalar.zero?(Fraction.cancel(difference))
198
+ rescue *UNDECIDED
199
+ false
200
+ end
201
+
202
+ # The real solutions of f = 0 that lie in the domain.
203
+ def zeros(f, x, domain)
204
+ points = solutions(f, x) or return nil
205
+ Analysis.sort_points(points.select { |p| in_domain?(domain, p) })
206
+ end
207
+
208
+ def in_domain?(domain, point)
209
+ value = Analysis.numeric(point)
210
+ value.nil? || domain.include?(value)
211
+ end
212
+
213
+ def intercept(f, x, domain)
214
+ return nil unless domain.include?(0)
215
+ value = f.subs(x => Num.new(0)).simplify
216
+ Limits.infinite?(value) ? nil : value
217
+ rescue *UNDECIDED
218
+ nil
219
+ end
220
+
221
+ # Where a denominator vanishes, and whether the graph runs away there:
222
+ # [[point, :pole | :removable | :gap, limit from the left, from the right], ...]
223
+ def gaps(f, x)
224
+ points = Analysis.denominators(f, x).flat_map { |d| solutions(d, x) || [] }.uniq
225
+ Analysis.sort_points(points).map do |point|
226
+ probe = point.is_a?(ImageSet) ? point.at(0) : point # a family stands for its members
227
+ left = one_sided(f, x, probe, :left)
228
+ right = one_sided(f, x, probe, :right)
229
+ kind = if left.nil? || right.nil? then :gap
230
+ elsif Limits.infinite?(left) || Limits.infinite?(right) then :pole
231
+ else :removable
232
+ end
233
+ [point, kind, left, right]
234
+ end
235
+ end
236
+
237
+ def one_sided(f, x, point, side)
238
+ value = Limits.limit(f, x, point, side)
239
+ value.is_a?(Limit) ? nil : value
240
+ rescue *UNDECIDED
241
+ nil
242
+ end
243
+
244
+ # [[-oo, value], [oo, value]]; a limit rcas cannot find is nil, and an
245
+ # end the domain does not reach is not asked about at all.
246
+ def limits_at_infinity(f, x, domain)
247
+ ends(domain).map do |point|
248
+ value = begin
249
+ found = Limits.limit(f, x, point)
250
+ found.is_a?(Limit) ? nil : found.simplify
251
+ rescue *UNDECIDED
252
+ nil
253
+ end
254
+ [point, value]
255
+ end
256
+ end
257
+
258
+ # A line the graph approaches. A line that *is* the graph - the oblique
259
+ # asymptote of 2*x + 1 is 2*x + 1 - is not worth saying. A vertical one
260
+ # is a place rather than a line, and may be a whole family of them
261
+ # ({pi/2 + pi*k | k in ZZ} for the tangent), so that question is not
262
+ # asked of it.
263
+ # Each kind on its own, nil where it is not determined - an undecided
264
+ # limit made every row "none" (fourth review, S11), and a vertical
265
+ # asymptote was lost with a horizontal one rcas could not take. A
266
+ # periodic function that is not constant has no limit at infinity, so
267
+ # neither a horizontal nor an oblique asymptote.
268
+ def asymptotes(f, x, domain)
269
+ ends = ends(domain)
270
+ found = { vertical: undecided_as_nil { Analysis.vertical_asymptotes(f, x) } }
271
+ if @cycle
272
+ found[:horizontal] = found[:oblique] = []
273
+ else
274
+ found[:horizontal] = undecided_as_nil { Analysis.horizontal_asymptotes(f, x, at: ends) }
275
+ found[:oblique] = undecided_as_nil { Analysis.oblique_asymptotes(f, x, at: ends) }
276
+ end
277
+ found.to_h do |kind, lines|
278
+ [kind, kind == :vertical || lines.nil? ? lines : lines.reject { |line| same?(line, f) }]
279
+ end
280
+ end
281
+
282
+ def undecided_as_nil
283
+ yield
284
+ rescue *UNDECIDED
285
+ nil
286
+ end
287
+
288
+ # The infinite ends the domain reaches: log(x) has no minus infinity to
289
+ # ask about, and an answer there would be an answer about nothing.
290
+ def ends(domain)
291
+ minus, plus = Neg.new(OO).simplify, OO
292
+ reached = []
293
+ reached << minus if domain.intervals.first&.low == minus
294
+ reached << plus if domain.intervals.last&.high == plus
295
+ reached
296
+ end
297
+
298
+ # The extrema, but nil rather than [] when the critical points are out of
299
+ # reach: "none" and "I could not tell" are different answers.
300
+ def extrema(f, x, first)
301
+ points = first && solutions(first, x) or return nil
302
+ return nil if points.any? { |p| p.is_a?(ImageSet) } # infinitely many, not charted
303
+ Analysis.extrema(f, x, points: Analysis.sort_points(points))
304
+ rescue *UNDECIDED
305
+ nil
306
+ end
307
+
308
+ def inflections(f, x, second)
309
+ points = second && solutions(second, x) or return nil
310
+ return nil if points.any? { |p| p.is_a?(ImageSet) }
311
+ Analysis.inflections(f, x, points: points).map { |point| [point, f.subs(x => point).simplify] }
312
+ rescue *UNDECIDED
313
+ nil # a curvature rcas cannot decide is "not determined", not "none"
314
+ end
315
+
316
+ # The real zeros of g, or nil when rcas cannot solve g = 0. One period
317
+ # of them for a periodic f, all of them (families included) otherwise.
318
+ def solutions(g, x)
319
+ g = Expression.lift(g)
320
+ return [] unless g.variables.include?(x.name)
321
+ begin
322
+ # a discussion is about a real function: its points are real ones
323
+ # (solve is complete over CC since the fifth review)
324
+ found = (@cycle && one_period(g, x)) || Solve.solve(g, x, principal: @principal != false, domain: RR)
325
+ return nil unless found.is_a?(Array)
326
+ found.select { |root| root.is_a?(ImageSet) ? !root.nonreal? : real?(root) }
327
+ rescue NotImplementedError, RCAS::Unsupported, ArgumentError, DomainError
328
+ factor_solutions(g, x)
329
+ end
330
+ end
331
+
332
+ # The solutions in [0, T) for the period T of f, counted out of the
333
+ # complete answer: the principal solutions of g are one period of g,
334
+ # which need not be f's - sin(2*x)/sin(x) has period 2*pi and its
335
+ # principal zeros were those of sin(2*x), one period of pi, so 3*pi/2
336
+ # was missing (fourth review). nil when the families cannot be counted.
337
+ def one_period(g, x)
338
+ found = Solve.solve(g, x, domain: RR)
339
+ return nil unless found.is_a?(Array)
340
+ top = Analysis.numeric(@cycle) or return nil
341
+ found.flat_map do |root|
342
+ next [root] unless root.is_a?(ImageSet)
343
+ members = root.between(0.0, top) or return nil
344
+ members.reject { |m| Inequalities.compare(m, @cycle) != -1 }
345
+ end
346
+ rescue *UNDECIDED
347
+ nil
348
+ end
349
+
350
+ # A product vanishes where one of its factors does, so a product solve
351
+ # will not touch can still be solvable factor by factor: the derivative
352
+ # of exp(-x**2) is -2*x*exp(-x**2), and the exponential is never zero.
353
+ # Every root found this way is checked against g before it is believed.
354
+ def factor_solutions(g, x)
355
+ roots = pieces(g, x).flat_map { |piece| Solve.solve(piece, x, principal: true).select { |root| real?(root) } }
356
+ roots.uniq.select { |root| vanishes?(g, x, root) }
357
+ rescue *UNDECIDED
358
+ nil
359
+ end
360
+
361
+ MAX_SPLIT = 3
362
+
363
+ # The parts of g that can vanish: its factors, and, for a sum whose terms
364
+ # share a factor, that factor and what the sum leaves when it is divided
365
+ # out. The second derivative of exp(-x**2) is a sum, not a product, until
366
+ # the exponential is taken out of it.
367
+ def pieces(g, x, depth = 0)
368
+ _coefficient, factors = Simplify.factorize(g, simplify: true)
369
+ factors.flat_map do |base, exponent|
370
+ next [] if exponent.is_a?(Numeric) && exponent.negative?
371
+ next [] unless base.variables.include?(x.name)
372
+ parts = depth < MAX_SPLIT ? split_common(base) : nil
373
+ parts ? parts.flat_map { |part| pieces(part, x, depth + 1) } : [base]
374
+ end.uniq
375
+ end
376
+
377
+ # [what every term of the sum has in common, what is left], or nil.
378
+ def split_common(g)
379
+ Simplify.common_factor(g)
380
+ rescue *UNDECIDED
381
+ nil
382
+ end
383
+
384
+ def vanishes?(g, x, root)
385
+ Decide.zero?(g.subs(x => root).simplify) != false
386
+ rescue *UNDECIDED
387
+ true
388
+ end
389
+
390
+ # Decided, not measured: 1 + 10**-13*i is not a real zero of
391
+ # (x - 1)**2 + 10**-26, and an |Im| < 1e-12 test said it was (Q1). A
392
+ # point whose realness cannot be told is kept.
393
+ def real?(value)
394
+ Inequalities.real?(value)
395
+ rescue NotImplementedError, RCAS::Unsupported
396
+ true
397
+ rescue *UNDECIDED
398
+ false
399
+ end
400
+
401
+ # ---- the sign chart --------------------------------------------------------
402
+
403
+ # The sign of g piece by piece: [[Interval, :positive | :negative], ...],
404
+ # or nil when the zeros, the samples or the signs are out of reach. The
405
+ # line is cut at the zeros of g, at the gaps handed in, and at the ends
406
+ # of the domain; pieces that the domain leaves out are dropped, and two
407
+ # pieces meeting at a point of the domain are merged.
408
+ def sign_chart(g, x, domain, extra = [], cycle = nil)
409
+ return nil if g.nil?
410
+ return [] if constant_zero?(g)
411
+ cuts = chart_cuts(g, x, domain, extra, cycle) or return nil
412
+ pieces = []
413
+ cuts.each_cons(2) do |(low_value, low), (high_value, high)|
414
+ sign = piece_sign(g, x, low_value, high_value, domain)
415
+ next if sign == :outside
416
+ return nil if sign.nil?
417
+ piece = Interval.new(low, high, left_open: true, right_open: true)
418
+ last = pieces.last
419
+ # never across a gap: tan is increasing on (0, pi/2) and on
420
+ # (pi/2, pi), and not on (0, pi) (S13)
421
+ across_gap = extra.any? { |e| !e.is_a?(ImageSet) && Inequalities.compare(e, low)&.zero? }
422
+ if last && last[1] == sign && last[0].high == low && domain.include?(low_value) && !across_gap
423
+ pieces[-1] = [Interval.open(last[0].low, high), sign]
424
+ else
425
+ pieces << [piece, sign]
426
+ end
427
+ end
428
+ pieces.empty? ? nil : pieces
429
+ end
430
+
431
+ # [[value, expression], ...] in order, the ends included.
432
+ def chart_cuts(g, x, domain, extra, cycle)
433
+ points = solutions(g, x) or return nil
434
+ return nil if (points + extra).any? { |p| p.is_a?(ImageSet) } # infinitely many pieces
435
+ cuts = (points + extra).map { |p| [Analysis.numeric(p), p] }
436
+ return nil if cuts.any? { |value, _| value.nil? }
437
+ cuts = window(cuts, cycle) or return nil if cycle
438
+ bounds = domain.intervals.flat_map { |i| [[i.low_value, i.low], [i.high_value, i.high]] }
439
+ cuts += bounds.select { |value, _| value.is_a?(Float) && value.finite? } unless cycle
440
+ cuts = cuts.uniq { |value, _| value }.sort_by(&:first)
441
+ return cuts if cycle
442
+ [[-Float::INFINITY, Neg.new(OO).simplify]] + cuts + [[Float::INFINITY, OO]]
443
+ end
444
+
445
+ # One period, from 0: every cut moved into [0, p] by whole periods.
446
+ def window(cuts, cycle)
447
+ length = Analysis.numeric(cycle) or return nil
448
+ moved = cuts.map do |value, point|
449
+ shift = (value / length).floor
450
+ shift.zero? ? [value, point] : [value - shift * length, (point - shift * cycle).simplify]
451
+ end
452
+ ([[0.0, Num.new(0)]] + moved + [[length, cycle]]).uniq { |value, _| value.round(9) }
453
+ end
454
+
455
+ # g is the zero function: a straight graph has no curvature to report,
456
+ # and a constant one no monotonicity.
457
+ def constant_zero?(g)
458
+ Scalar.zero?(g.simplify) # identically zero, cancelled and trig identities included
459
+ rescue *UNDECIDED
460
+ false
461
+ end
462
+
463
+ # :positive, :negative, :outside (no sample is in the domain), or nil
464
+ # when the samples disagree or none of them says anything. A sample too
465
+ # close to zero to read - exp(-x**2) far out - is skipped, not believed.
466
+ def piece_sign(g, x, low, high, domain)
467
+ points = sample_points(low, high).select { |s| domain.include?(s) }
468
+ return :outside if points.empty?
469
+ # each sample decided exactly at a rational point, skipped when
470
+ # undecided (it was a Float below 1e-12 that was skipped)
471
+ signs = points.filter_map do |sample|
472
+ value = begin
473
+ g.subs(x => Num.new(Rational(sample).rationalize(Rational(1, 10**12)))).simplify
474
+ rescue ZeroDivisionError
475
+ next nil
476
+ end
477
+ sign = Decide.sign(value)
478
+ %i[positive negative].include?(sign) ? sign : nil
479
+ end.uniq
480
+ signs.size == 1 ? signs.first : nil
481
+ end
482
+
483
+ # Three points a piece, so that a piece hiding a zero is noticed.
484
+ SAMPLES = 3
485
+ OFFSETS = [0.5, 1.5, 4.0].freeze
486
+
487
+ def sample_points(low, high)
488
+ return OFFSETS.map { |o| o - 1.0 } if low.infinite? && high.infinite?
489
+ return OFFSETS.map { |o| high - o } if low.infinite?
490
+ return OFFSETS.map { |o| low + o } if high.infinite?
491
+ (1..SAMPLES).map { |i| low + (high - low) * i / (SAMPLES + 1) }
492
+ end
493
+
494
+ def label(chart, positive, negative)
495
+ chart&.map { |piece, sign| [piece, sign == :positive ? positive : negative] }
496
+ end
497
+
498
+ # ---- periodicity -----------------------------------------------------------
499
+
500
+ # 2*pi/|a| for sin(a*x + b), pi/|a| for tan.
501
+ PERIODS = { sin: 2, cos: 2, sec: 2, csc: 2, tan: 1, cot: 1 }.freeze
502
+
503
+ # A period of f: the least common multiple of the periods of its
504
+ # trigonometric parts, once a sample of points confirms it (nil
505
+ # otherwise - rcas never guesses one). The smallest period, unless the
506
+ # expression hides a shorter one, as cos(x)**2 = (1 + cos(2*x))/2 does.
507
+ def period(f, x)
508
+ bases = period_bases(f, x) or return nil
509
+ return nil if bases.empty?
510
+ first = Analysis.numeric(bases.first) or return nil
511
+ multiple = Rational(1)
512
+ bases.each do |base|
513
+ value = Analysis.numeric(base) or return nil
514
+ ratio = Rational(value / first).rationalize(Rational(1, 10**6))
515
+ return nil if ratio.denominator > 100
516
+ multiple = Rational(multiple.numerator.lcm(ratio.numerator), multiple.denominator.gcd(ratio.denominator))
517
+ end
518
+ candidate = (bases.first * Num.new(multiple)).simplify
519
+ periodic?(f, x, candidate) ? candidate : nil
520
+ end
521
+
522
+ def period_bases(f, x)
523
+ bases = []
524
+ f.each_node do |node|
525
+ next unless node.is_a?(Fn) && PERIODS.key?(node.name)
526
+ argument = node.args.first
527
+ return nil unless argument.variables.include?(x.name)
528
+ slope = linear_slope(argument, x) or return nil
529
+ bases << (Num.new(PERIODS[node.name]) * PI / slope).simplify
530
+ end
531
+ bases.uniq
532
+ end
533
+
534
+ # The a of a*x + b, positive, or nil when the argument is not linear.
535
+ def linear_slope(argument, x)
536
+ return nil unless Coefficients.degree(argument, x) == 1
537
+ slope = Coefficients.coeff(argument, x, 1)
538
+ value = Analysis.numeric(slope) or return nil
539
+ value.negative? ? Neg.new(slope).simplify : slope
540
+ rescue DomainError, NotImplementedError, RCAS::Unsupported, ArgumentError
541
+ nil
542
+ end
543
+
544
+ # f(x + T) = f(x) as an identity, shown by the normal forms (the
545
+ # addition theorems drop the whole turns); four samples agreeing is not
546
+ # a period - sin(x) + g(x)/10**6 with g vanishing at those samples and
547
+ # their shifts tends to oo (fourth review).
548
+ def periodic?(f, x, candidate)
549
+ shifted = f.subs(x => (x + candidate).simplify)
550
+ difference = (shifted - f).simplify
551
+ return true if difference.is_a?(Num) && difference.value.zero?
552
+ Scalar.identically_zero?(difference) || Scalar.identically_zero?(Trigonometry.expand_trig(difference).simplify)
553
+ rescue StandardError => rescued
554
+ RCAS.guard!(rescued)
555
+ false
556
+ end
557
+ end
558
+ end