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,966 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Curve sketching and several variables: the calculus a course actually
5
+ # asks for, built from diff, solve and limit.
6
+ #
7
+ # extrema(x**3 - 3*x, x) # => [[-1, 2, :maximum], [1, -2, :minimum]]
8
+ # inflections(x**3 - 3*x, x) # => [0]
9
+ # asymptotes((x**2 + 1)/x, x) # vertical, horizontal and oblique
10
+ # tangent(x**2, x, 1) # => -1 + 2*x
11
+ # gradient(x**2*y, [x, y]) # => (2*x*y, x**2)
12
+ # hessian(x**2*y, [x, y]) # the second derivatives as a matrix
13
+ # lagrange(x + y, [x**2 + y**2 - 1], [x, y])
14
+ #
15
+ # Sources (keys: MANUAL.md, Sources): the second-derivative test, the
16
+ # asymptote limits and Lagrange multipliers are the textbook ones
17
+ # [Spi08, ch. 11, 17]; the Hessian and Jacobian follow [Rud76, ch. 9].
18
+ module Analysis
19
+ module_function
20
+
21
+ def variable(f, var)
22
+ return Expression.lift(var) if var
23
+ names = Expression.lift(f).variables
24
+ raise ArgumentError, "which variable? give one, e.g. extrema(f, x)" unless names.size == 1
25
+ Var.new(names.first)
26
+ end
27
+
28
+ def numeric(value) = RCAS.real_float(value)
29
+
30
+ # ---- one variable ----------------------------------------------------------
31
+
32
+ # Where the derivative vanishes, sorted where they can be compared.
33
+ #
34
+ # An equation solve cannot do is a refusal, not "no critical points":
35
+ # cos(x) + x**2/4 has its maximum at 0 all the same (third review,
36
+ # S11). Points where f itself has no value are not points of its graph.
37
+ def critical_points(f, var = nil)
38
+ f = Expression.lift(f)
39
+ x = variable(f, var)
40
+ found = Solve.solve(f.diff(x), x, principal: true)
41
+ raise RCAS::Unsupported, "critical points of #{f}: the derivative vanishes on a whole interval" unless found.is_a?(Array)
42
+ sort_points(found.select { |p| real_point?(p) && defined_at?(f, x, p) })
43
+ end
44
+
45
+ # f has a real value at the point (a family counts as defined).
46
+ def defined_at?(f, x, point)
47
+ return true if point.is_a?(ImageSet)
48
+ value = f.subs(x => point).simplify
49
+ return false unless Integrate.defined_value?(value)
50
+ real = Inequalities.real?(value)
51
+ real != false
52
+ rescue ZeroDivisionError
53
+ false
54
+ rescue NotImplementedError, RCAS::Unsupported
55
+ true # realness undecided: the point is kept, and says so nowhere else
56
+ rescue StandardError => rescued
57
+ RCAS.guard!(rescued)
58
+ true
59
+ end
60
+
61
+ def sort_points(points)
62
+ points.sort_by { |p| [numeric(p) ? 0 : 1, numeric(p) || 0.0, p.to_s] }
63
+ end
64
+
65
+ # A curve discussion is about a real function, so a complex root of the
66
+ # derivative is not a critical point of its graph: critical_points of
67
+ # x**3 + x reported the two roots of 3*x**2 + 1. A point rcas cannot
68
+ # evaluate stays, since not knowing is not the same as knowing it is
69
+ # complex - but one whose imaginary unit is written into it goes.
70
+ # Decided exactly (Inequalities.real?): 1 + 10**-13*i is not real,
71
+ # however small its imaginary part (third review, Q1); an unevaluated
72
+ # log(-1) is not either.
73
+ def real_point?(point)
74
+ return true if point.is_a?(ImageSet)
75
+ Inequalities.real?(point)
76
+ rescue NotImplementedError, RCAS::Unsupported
77
+ true # not knowing is not knowing it is complex
78
+ rescue StandardError => rescued
79
+ RCAS.guard!(rescued)
80
+ true
81
+ end
82
+
83
+ # [[x, f(x), :minimum | :maximum | :saddle], ...] by the second derivative,
84
+ # falling back to the sign of the first derivative on either side.
85
+ # `points` supplies the critical points, for a caller that knows them
86
+ # (or can find more of them) already.
87
+ #
88
+ # The kind is decided by the first derivative of f that does not vanish
89
+ # at the point, which is exact: an even order is an extremum, an odd
90
+ # one a saddle. The old test read f'' against 1e-12 and then sampled f'
91
+ # at +-1e-4, which stepped over a zero of f' at 10**-5 and called a
92
+ # saddle a minimum (third review, S10).
93
+ def extrema(f, var = nil, points: nil)
94
+ f = Expression.lift(f)
95
+ x = variable(f, var)
96
+ first = f.diff(x)
97
+ candidates = points || critical_points(f, x)
98
+ candidates.filter_map do |point|
99
+ next nil unless defined_at?(f, x, point)
100
+ value = (f.subs(x => point)).simplify
101
+ kind = extremum_kind(first, x, point, candidates)
102
+ kind ? [point, value, kind] : nil
103
+ end
104
+ end
105
+
106
+ def extremum_kind(first, x, point, others)
107
+ found = vanishing(first, x, point)
108
+ if found
109
+ order, sign = found
110
+ return :saddle if order.even? # f' vanishes to an even order: f has an odd one
111
+ return sign == :positive ? :minimum : :maximum
112
+ end
113
+ sign_change(first, x, point, step: safe_step(point, others))
114
+ end
115
+
116
+ # [k, sign] for the smallest k >= 1 with g^(k)(point) != 0 decided, or
117
+ # nil when that cannot be told within the budget.
118
+ def vanishing(g, x, point, limit = nil)
119
+ limit ||= vanishing_limit(g, x)
120
+ limit.times do |k|
121
+ g = g.diff(x)
122
+ value = g.subs(x => point).simplify
123
+ if value.variables.empty?
124
+ sign = Decide.sign(value)
125
+ return nil if sign.nil?
126
+ next if sign == :zero
127
+ return [k + 1, sign]
128
+ end
129
+ next if Scalar.zero?(value)
130
+ # 6*a at 0 for a*x**3 + x**4: whether it vanishes depends on a
131
+ raise RCAS::Unsupported, "whether #{value} is zero decides the shape at #{point}; assume something about #{value.variables.join(', ')}"
132
+ end
133
+ nil
134
+ rescue ArgumentError
135
+ nil
136
+ end
137
+
138
+ # The shape of a critical point from the sign of g on both sides. The
139
+ # step must stay inside the point's own neighbourhood: with a zero of g
140
+ # at 10**-5 next door, the default 10**-4 samples the far side of it and
141
+ # reads the wrong sign (22 Sept 2026, from the second review).
142
+ # The signs are decided exactly at rational points (Decide), so an
143
+ # underflow is not read as a zero (T4a).
144
+ def sign_change(g, x, point, step: nil)
145
+ centre = numeric(point) or return nil
146
+ step ||= 1e-4 * [1.0, centre.abs].max
147
+ return nil unless step&.positive?
148
+ exact = Expression.lift(point)
149
+ delta = Num.new(Rational(step).rationalize(Rational(step) / 1000))
150
+ left = Decide.sign(g.subs(x => (exact - delta).simplify).simplify)
151
+ right = Decide.sign(g.subs(x => (exact + delta).simplify).simplify)
152
+ return nil unless %i[positive negative].include?(left) && %i[positive negative].include?(right)
153
+ return :minimum if left == :negative && right == :positive
154
+ return :maximum if left == :positive && right == :negative
155
+ :saddle
156
+ end
157
+
158
+ # Where the curvature changes sign; `points` supplies the zeros of the
159
+ # second derivative, as for extrema.
160
+ #
161
+ # "Cannot solve f'' = 0" is a refusal, never "no inflections": x*sin(x)
162
+ # has f''(0) = 2 and f''(pi) = -2, so there is one in between (T4b).
163
+ # A point outside the domain of f is no point of the graph (S14).
164
+ def inflections(f, var = nil, points: nil)
165
+ f = Expression.lift(f)
166
+ x = variable(f, var)
167
+ second = f.diff(x, 2)
168
+ candidates = points || Solve.solve(second, x, principal: true)
169
+ raise RCAS::Unsupported, "inflections of #{f}: f'' vanishes on a whole interval" unless candidates.is_a?(Array)
170
+ real = candidates.select { |p| real_point?(p) && defined_at?(f, x, p) }
171
+ sort_points(real.select { |point| inflection_at?(second, x, point, real) })
172
+ end
173
+
174
+ # How far a derivative of g has to be taken before it stops vanishing,
175
+ # for anything that is not a polynomial; a polynomial has its degree as
176
+ # the bound, so x**17 is decided however small its coefficient (T4a).
177
+ MAX_VANISHING = 12
178
+
179
+ def vanishing_limit(g, x)
180
+ coefficients = Solve.polynomial_coefficients(g.expand, x)
181
+ coefficients ? [coefficients.size, 1].max : MAX_VANISHING
182
+ rescue StandardError => rescued
183
+ RCAS.guard!(rescued)
184
+ MAX_VANISHING
185
+ end
186
+
187
+ # f'' changes sign at one of its zeros exactly when it vanishes there to
188
+ # an *odd* order [Spi08, ch. 11], and that order is the first derivative of f'' that
189
+ # does not vanish - which `Scalar.zero?` decides exactly for a rational
190
+ # point of a polynomial. This replaces the old test, which asked whether
191
+ # f''' was numerically above 10**-12 and fell back to a sign chart: with
192
+ # f'' = x**2*(x - a) and a = 10**-5 the chart stepped over a and
193
+ # reported an inflection at 0, and with f'' = x**3*(x - a) the exactly
194
+ # non-zero f'''(a) = a**3 = 10**-15 was read as zero (22 Sept 2026, the
195
+ # second review). The chart is now the fallback, on a step that stays
196
+ # inside the point's own neighbourhood; when even that cannot decide,
197
+ # the answer is "undecided" and not "no inflection".
198
+ def inflection_at?(second, x, point, others)
199
+ found = begin
200
+ vanishing(second, x, point)
201
+ rescue NotImplementedError, RCAS::Unsupported
202
+ # a parameter decides the order, but maybe not its parity: f'' =
203
+ # 6*a*x + 20*x**3 vanishes to order 1 for a != 0 and 3 for a = 0,
204
+ # an inflection either way (the review's S22 had "6*a decides")
205
+ parities = possible_orders(second, x, point)&.map(&:odd?)&.uniq
206
+ return parities.first if parities&.size == 1
207
+ raise
208
+ end
209
+ return found.first.odd? if found
210
+ change = sign_change(second, x, point, step: safe_step(point, others))
211
+ return change != :saddle if change
212
+ raise RCAS::Unsupported, "inflections: whether the curvature changes at #{point} is not decided here"
213
+ end
214
+
215
+ # The orders g could vanish to at the point, over the parameters: each
216
+ # derivative whose value there depends on a parameter is where it
217
+ # stops if that value is not 0, and the search goes on for the case
218
+ # that it is; a constant that is not 0 ends it. nil when undecided.
219
+ def possible_orders(g, x, point)
220
+ orders = []
221
+ vanishing_limit(g, x).times do |k|
222
+ g = g.diff(x)
223
+ value = g.subs(x => point).simplify
224
+ if value.variables.empty?
225
+ sign = Decide.sign(value)
226
+ return nil if sign.nil?
227
+ next if sign == :zero
228
+ return orders << k + 1
229
+ end
230
+ orders << k + 1 unless Scalar.zero?(value)
231
+ end
232
+ nil
233
+ end
234
+
235
+ # The smallest k >= 1 with g^(k)(point) != 0, or nil.
236
+ def vanishing_order(g, x, point, limit = nil) = vanishing(g, x, point, limit)&.first
237
+
238
+ # Half the way to the nearest other candidate, at most the default step:
239
+ # no sample may cross a neighbouring zero.
240
+ def safe_step(point, others)
241
+ centre = numeric(point) or return nil
242
+ step = 1e-4 * [1.0, centre.abs].max
243
+ gaps = others.filter_map { |q| (v = numeric(q)) && (v - centre).abs }.reject { |d| d < 1e-15 }
244
+ gaps.empty? ? step : [step, gaps.min / 2].min
245
+ end
246
+
247
+ # { vertical: [...], horizontal: [...], oblique: [...] }; the horizontal
248
+ # and oblique lines are the limits at minus and plus infinity. `at` names
249
+ # the ends to look at, for a function that only reaches one of them.
250
+ def asymptotes(f, var = nil, at: nil)
251
+ f = Expression.lift(f)
252
+ x = variable(f, var)
253
+ ends = at || infinities
254
+ { vertical: vertical_asymptotes(f, x), horizontal: horizontal_asymptotes(f, x, at: ends), oblique: oblique_asymptotes(f, x, at: ends) }
255
+ end
256
+
257
+ def infinities = [OO, Neg.new(OO).simplify]
258
+
259
+ # The candidates are the zeros of the denominators and the edges of the
260
+ # logarithms' domains - log(x) runs to -oo at 0, which a search of the
261
+ # denominators alone never saw (S12). A denominator whose zeros solve
262
+ # cannot name is a refusal: 1/(exp(x) - x - 2) has two poles (S11).
263
+ def vertical_asymptotes(f, x)
264
+ edges = []
265
+ f.each_node { |n| edges << n.args.first if n.is_a?(Fn) && n.name == :log && n.args.first.variables.include?(x.name) }
266
+ poles = (denominators(f, x) + edges).uniq.flat_map do |g|
267
+ found = Solve.solve(g, x, domain: RR)
268
+ raise RCAS::Unsupported, "vertical asymptotes of #{f}: #{g} = 0 vanishes on a whole interval" unless found.is_a?(Array)
269
+ found
270
+ end
271
+ sort_points(poles.uniq.select { |p| real_point?(p) && runs_away?(f, x, p) })
272
+ end
273
+
274
+ # A whole family of asymptotes is reported as the family: tan has one
275
+ # at every odd multiple of pi/2, and a member of the set stands for all
276
+ # of them when the limit is taken.
277
+ # Either side infinite is an asymptote, both finite is none, and a limit
278
+ # rcas cannot take is neither: a refusal (fourth review, S11). A side
279
+ # where f has no real values (left of 0 for 1/log(x)) does not count.
280
+ def runs_away?(f, x, point)
281
+ probe = point.is_a?(ImageSet) ? point.at(0) : point
282
+ sides = %i[right left].map { |side| Limits.limit(f, x, probe, side) }
283
+ return true if sides.any? { |v| Limits.infinite?(v) }
284
+ sides = sides.zip([1, -1]).reject { |v, side| v.is_a?(Limit) && !real_beside?(f, x, probe, side) }.map(&:first)
285
+ return false if sides.none? { |v| v.is_a?(Limit) }
286
+ raise RCAS::Unsupported, "vertical asymptotes of #{f}: the limit at #{probe} is not decided here"
287
+ rescue ZeroDivisionError
288
+ false
289
+ end
290
+
291
+ # f has real values out towards an infinity: false only when that is
292
+ # decided at +-1000.
293
+ def real_toward?(f, x, point)
294
+ value = f.subs(x => Num.new(point == OO ? 1000 : -1000)).simplify
295
+ Inequalities.real?(value) != false
296
+ rescue ZeroDivisionError, NotImplementedError, RCAS::Unsupported
297
+ true
298
+ end
299
+
300
+ # f has real values just to one side of the point: false only when that
301
+ # is decided at a point 1/1000 away.
302
+ def real_beside?(f, x, point, side)
303
+ value = f.subs(x => (Expression.lift(point) + Num.new(Rational(side, 1000))).simplify).simplify
304
+ Inequalities.real?(value) != false
305
+ rescue ZeroDivisionError, NotImplementedError, RCAS::Unsupported
306
+ true
307
+ end
308
+
309
+ # The denominators f divides by: the one of its normal form, the ones it
310
+ # is written with, which the normal form may have cancelled away
311
+ # ((x**2 - 1)/(x - 1) is still undefined at 1), and the one tan hides -
312
+ # tan(u) is sin(u)/cos(u), so it has a pole wherever cos(u) vanishes,
313
+ # and without that a tangent had no asymptotes and no gaps at all.
314
+ def denominators(f, x)
315
+ found = [RationalFunction.denom(f)]
316
+ f.each_node do |node|
317
+ case node
318
+ when Div then found << node.right
319
+ when Fn then found << Fn.new(:cos, [node.args.first]) if node.name == :tan
320
+ when Pow
321
+ exponent = node.exponent
322
+ found << node.base if exponent.is_a?(Num) && exponent.value.is_a?(Numeric) &&
323
+ !exponent.value.is_a?(Complex) && exponent.value.negative?
324
+ end
325
+ end
326
+ found.select { |d| d.variables.include?(x.name) }.uniq
327
+ end
328
+
329
+ def horizontal_asymptotes(f, x, at: nil)
330
+ (at || infinities).filter_map do |point|
331
+ value = Limits.limit(f, x, point)
332
+ next nil if value.is_a?(Limit) && !real_toward?(f, x, point) # 1/log(x) towards -oo
333
+ raise RCAS::Unsupported, "horizontal asymptotes of #{f}: the limit at #{point} is not decided here" if value.is_a?(Limit)
334
+ next nil if Limits.infinite?(value) || value == UNDEFINED
335
+ value.simplify
336
+ end.uniq
337
+ end
338
+
339
+ # y = m*x + c with m the limit of f/x and c the limit of f - m*x.
340
+ def oblique_asymptotes(f, x, at: nil)
341
+ (at || infinities).filter_map do |point|
342
+ # only a function that runs away has an oblique asymptote there
343
+ value = Limits.limit(f, x, point)
344
+ next nil if value.is_a?(Limit) && !real_toward?(f, x, point)
345
+ raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit at #{point} is not decided here" if value.is_a?(Limit)
346
+ next nil unless Limits.infinite?(value)
347
+ slope = Limits.limit((f / x).cancel, x, point)
348
+ raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit of f/x at #{point} is not decided here" if slope.is_a?(Limit)
349
+ next nil if Limits.infinite?(slope) || slope == UNDEFINED || Scalar.zero?(slope)
350
+ offset = Limits.limit((f - slope * x).cancel, x, point)
351
+ raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit of f - #{slope}*x at #{point} is not decided here" if offset.is_a?(Limit)
352
+ next nil if Limits.infinite?(offset) || offset == UNDEFINED
353
+ (slope * x + offset).simplify
354
+ end.uniq
355
+ end
356
+
357
+ # The tangent and the normal to the graph at a point.
358
+ # A vertical tangent (sqrt(x) at 0) is no line y = m*x + c: refused
359
+ # with a message rather than a ZeroDivisionError (S17).
360
+ def tangent(f, var = nil, at = nil)
361
+ f = Expression.lift(f)
362
+ x = variable(f, var)
363
+ a = Expression.lift(at)
364
+ slope = begin
365
+ f.diff(x).subs(x => a).simplify
366
+ rescue ZeroDivisionError
367
+ raise ArgumentError, "tangent: #{f} has no finite slope at #{x} = #{a}; the tangent there is vertical or does not exist"
368
+ end
369
+ raise ArgumentError, "tangent: #{f} has no finite slope at #{x} = #{a}" if Limits.infinite?(slope) || slope == UNDEFINED
370
+ differentiable!(f, x, a, "tangent")
371
+ value = f.subs(x => a).simplify
372
+ (value + slope * (x - a)).simplify
373
+ end
374
+
375
+ # A kink of abs, sign, floor, ceil or round at the point: the formula
376
+ # for f' there says sign(0) = 0, and |x| got the tangent y = 0 at its
377
+ # corner (fourth review). The one-sided limits of f' decide - they have
378
+ # to exist and agree - and what they cannot decide is refused.
379
+ def differentiable!(f, x, a, name)
380
+ kinks = f.each_node.select { |n| n.is_a?(Fn) && Limits::KINKED.include?(n.name) && n.args.first.variables.include?(x.name) }
381
+ at_kink = kinks.any? do |n|
382
+ u = n.args.first.subs(x => a).simplify
383
+ case n.name
384
+ when :abs, :sign then Decide.zero?(u) != false
385
+ else Decide.zero?((u - Fn.new(:round, [u])).simplify) != false || n.name == :round
386
+ end
387
+ end
388
+ return unless at_kink
389
+ derivative = f.diff(x)
390
+ left, right = %i[left right].map { |side| Limits.limit(derivative, x, a, side) }
391
+ return if [left, right].none? { |v| v.is_a?(Limit) || Limits.infinite?(v) || v == UNDEFINED } && Scalar.zero?((left - right).simplify)
392
+ raise ArgumentError, "#{name}: #{f} has no tangent at #{x} = #{a}: the slopes from the left (#{left}) and the right (#{right}) differ there"
393
+ end
394
+
395
+ def normal(f, var = nil, at = nil)
396
+ f = Expression.lift(f)
397
+ x = variable(f, var)
398
+ a = Expression.lift(at)
399
+ slope = f.diff(x).subs(x => a).simplify
400
+ differentiable!(f, x, a, "normal")
401
+ raise ArgumentError, "normal: the tangent is horizontal at #{a}" if Scalar.zero?(slope)
402
+ (f.subs(x => a) - (x - a) / slope).simplify
403
+ end
404
+
405
+ # Where a real expression is defined: denominators non-zero, even roots
406
+ # and logarithms of positive arguments. => a RealSet.
407
+ def real_domain(f, var = nil)
408
+ f = Expression.lift(f)
409
+ x = variable(f, var)
410
+ parameters, conditions = domain_conditions(f, x).partition { |c| parameter_condition?(c, x) }
411
+ parameters.each do |c|
412
+ decided = decide_without_x(c)
413
+ raise RCAS::Unsupported, "real_domain: whether #{c} holds depends on #{c.lhs.variables.join(', ')}; assume a sign for it" if decided.nil?
414
+ return RealSet.empty unless decided
415
+ end
416
+ set = conditions.empty? ? RealSet.reals : conditions.map { |c| solved_condition(c, x) }.reduce(:&)
417
+ real = real_locus(f, x)
418
+ real ? set & real : set
419
+ end
420
+
421
+ # A condition about parameters alone. It cannot be solved for x, so it
422
+ # is decided instead: with `assume(a < 0)` the logarithm in log(a) + x
423
+ # has no real value anywhere, and skipping the condition claimed the
424
+ # whole line (22 Sept 2026, from the second review).
425
+ def parameter_condition?(c, x) = !c.lhs.variables.empty? && !c.lhs.variables.include?(x.name)
426
+
427
+ # true, false, or nil when the assumptions do not settle it.
428
+ def decide_without_x(c)
429
+ return nil unless c.rhs.is_a?(Num) && c.rhs.zero?
430
+ sign = RCAS.sign_of(c.lhs)
431
+ case c.op
432
+ when :> then POSITIVE.include?(sign) ? true : (NONPOSITIVE.include?(sign) ? false : nil)
433
+ when :>= then NONNEGATIVE.include?(sign) ? true : (sign == :negative ? false : nil)
434
+ when :!= then sign == :zero ? false : (SIGNED.include?(sign) ? true : nil)
435
+ end
436
+ end
437
+
438
+ POSITIVE = %i[positive].freeze
439
+ NONPOSITIVE = %i[negative nonpositive zero].freeze
440
+ NONNEGATIVE = %i[positive nonnegative zero].freeze
441
+ SIGNED = %i[positive negative].freeze
442
+
443
+ # Where an expression carrying i is real at all: its imaginary part has
444
+ # to vanish, so real_domain(I*x, x) is {0} and not the whole line, and
445
+ # x + i is real nowhere (22 Sept 2026, from the second review; the first
446
+ # review had asked for this and the answer then was that it was a gap).
447
+ # Only an explicit complex number puts the question, so nothing without
448
+ # one pays for it. => a RealSet, or nil when there is no condition.
449
+ #
450
+ # "Explicit" was too narrow: (-1)**sqrt(2) is exp(i*pi*sqrt(2)), complex
451
+ # without an i in sight, and real_domain((-1)**sqrt(2) + x) said the whole
452
+ # line (third review, T5a). A constant part shown not to be real puts the
453
+ # question too.
454
+ # A point is in the real domain when every subexpression is real and
455
+ # defined there - Mathematica's rule for FunctionDomain, and the fifth
456
+ # review's decision - so a subexpression that is a non-real constant
457
+ # leaves nothing: i*sqrt(x) and x*log(-2) are real nowhere, however
458
+ # their values fall. "Where is the value real" is another question, asked
459
+ # as solve(im(f) == 0, x). The expression is simplified first, so that
460
+ # (1 + i)*(1 - i)*x, which is 2*x, keeps its line.
461
+ def real_locus(f, x)
462
+ scattered = scattered_set(f, x)
463
+ g = f.simplify
464
+ nonreal = g.each_node.any? do |n|
465
+ (n.is_a?(Num) && n.value.is_a?(Complex) && !n.value.imaginary.zero?) ||
466
+ ((n.is_a?(Pow) || n.is_a?(Fn)) && n.variables.empty? && nonreal_constant?(n))
467
+ end
468
+ nonreal ? RealSet.empty : scattered
469
+ end
470
+
471
+ def nonreal_constant?(n)
472
+ Inequalities.real?(n) == false
473
+ rescue NotImplementedError, RCAS::Unsupported
474
+ false
475
+ rescue StandardError => rescued
476
+ RCAS.guard!(rescued)
477
+ false
478
+ end
479
+
480
+ # b**u with u moving with x and b possibly negative is real on a
481
+ # scattered set, and gamma(u) off one: (-2)**x at the integers, x**x on
482
+ # (0, oo) and at the negative integers, gamma(x) off 0, -1, -2, ... No
483
+ # finite union of intervals is that (S15), so the answer is a
484
+ # ScatteredSet, for a linear argument or exponent; nil when f has no
485
+ # such part, and a refusal for a shape that is not named here or for
486
+ # more than one of them.
487
+ def scattered_set(f, x)
488
+ found = []
489
+ f.each_node do |n|
490
+ if n.is_a?(Fn) && %i[gamma factorial].include?(n.name) && n.args.first.variables.include?(x.name)
491
+ found << poles_of_gamma(n, x)
492
+ next
493
+ end
494
+ next unless n.is_a?(Pow) && n.exponent.variables.include?(x.name)
495
+ base = n.base
496
+ sign = base.variables.empty? ? Decide.sign(base) : RCAS.assume(x.name => RR) { RCAS.sign_of(base) }
497
+ next if sign == :positive
498
+ found << scattered_power(n, x)
499
+ end
500
+ found = found.uniq
501
+ return nil if found.empty?
502
+ raise RCAS::Unsupported, "real_domain: #{f} is real on the meeting of several scattered sets, which is not written here" if found.size > 1
503
+ found.first
504
+ end
505
+
506
+ # gamma(a*x + b) has a pole where a*x + b is 0, -1, -2, ...; factorial(u)
507
+ # where u is -1, -2, ...
508
+ def poles_of_gamma(n, x)
509
+ coefficients = Solve.polynomial_coefficients(n.args.first, x)
510
+ unless coefficients&.size == 2 && coefficients.none? { |c| c.variables.any? }
511
+ raise RCAS::Unsupported, "real_domain: #{n} has a pole at every point where its argument is a non-positive integer"
512
+ end
513
+ b, a = coefficients
514
+ k = Var.new(:k)
515
+ top = n.name == :gamma ? Neg.new(k) : Neg.new(k) - 1
516
+ ScatteredSet.new(RealSet.reals, minus: [ImageSet.new(((top - b) / a).simplify, [k], NN)])
517
+ end
518
+
519
+ # c**(a*x + b) for a negative constant c is real where the exponent is a
520
+ # whole number, since it is |c|**u*exp(i*pi*u); x**x (principal powers
521
+ # all through) is real for x > 0 and at the negative integers.
522
+ def scattered_power(n, x)
523
+ k = Var.new(:k)
524
+ if n.base.variables.empty? && Decide.sign(n.base) == :negative
525
+ coefficients = Solve.polynomial_coefficients(n.exponent, x)
526
+ if coefficients&.size == 2 && coefficients.none? { |c| c.variables.any? }
527
+ b, a = coefficients
528
+ return ScatteredSet.new(RealSet.empty, plus: [ImageSet.new(((k - b) / a).simplify, [k], ZZ)])
529
+ end
530
+ elsif n.base == x && n.exponent == x
531
+ return ScatteredSet.new(RealSet.new([Interval.open(Num.new(0), OO)]), plus: [ImageSet.new((Neg.new(k) - 1).simplify, [k], NN)])
532
+ end
533
+ raise RCAS::Unsupported, "real_domain: #{n} is real only on a scattered set of points where its base is negative"
534
+ end
535
+
536
+ # A condition rcas cannot solve is not an empty one: dropping it would
537
+ # claim the function is defined where nobody has looked. The message
538
+ # says which condition it was, in rcas's own words rather than as a
539
+ # backtrace out of Inequalities.
540
+ def off_zeros(d, x)
541
+ zeros = Solve.solve(d, x, domain: RR)
542
+ return nil unless zeros.is_a?(Array) && zeros.all? { |z| z.is_a?(Expression) && z.variables.empty? }
543
+ real = zeros.select { |z| Inequalities.real?(z) }.map { |z| Inequalities.real_part(z) }
544
+ real.empty? ? RealSet.reals : RealSet.reals - RealSet.new(real.map { |z| Interval.point(z) })
545
+ rescue NotImplementedError, RCAS::Unsupported, ArgumentError
546
+ nil
547
+ end
548
+
549
+ def solved_condition(condition, x)
550
+ Inequalities.solve(condition, x)
551
+ rescue NotImplementedError, RCAS::Unsupported => e
552
+ # d != 0 is the line less the zeros of d, which solve names for more
553
+ # than polynomials (log(x) != 0 is every x but 1)
554
+ if condition.op == :!= && (off = off_zeros(condition.lhs - condition.rhs, x))
555
+ return off
556
+ end
557
+ # cause: nil, or irb prints this backtrace and the one underneath it
558
+ raise RCAS::Unsupported, "real_domain: where #{condition} holds is not decided here (#{e.message})", cause: nil
559
+ end
560
+
561
+ # The inverse functions whose real argument has to stay in [-1, 1].
562
+ # Past that they do have a value, off the real line (Functions
563
+ # .real_branch), which is exactly why the condition has to be named
564
+ # here: real_domain(asin(x), x) answered with the whole line before
565
+ # (20 Sept 2026, after the tenth pass of the review).
566
+ BOUNDED_INVERSES = %i[asin acos].freeze
567
+
568
+ # A condition is worth recording when its argument moves with x, and
569
+ # also when it moves with nothing at all: log(-1) + x is real nowhere,
570
+ # and skipping the constant condition claimed the whole line (22 Sept
571
+ # 2026, from a review). An argument in a *parameter* is left alone,
572
+ # because the answer would then be a case split on the parameter rather
573
+ # than a domain.
574
+ def condition_argument?(u, x) = true
575
+
576
+ # The conditions behind that domain, so that a caller can name them:
577
+ # one per denominator, even root and logarithm, and two for each
578
+ # asin or acos.
579
+ def domain_conditions(f, x)
580
+ conditions = denominators(f, x).map { |d| Inequality.new(d, :!=, 0) }
581
+ f.each_node do |node|
582
+ # every fractional power is the principal one, real only for a base
583
+ # that is not negative - x**(1/3) too; surd(x, 3) is the real root
584
+ # (the fifth review's decision on odd roots)
585
+ if node.is_a?(Pow) && condition_argument?(node.base, x) && fractional_exponent?(node.exponent)
586
+ conditions << Inequality.new(node.base, :>=, 0)
587
+ elsif node.is_a?(Fn) && node.name == :log && condition_argument?(node.args.first, x)
588
+ conditions << Inequality.new(node.args.first, :>, 0)
589
+ elsif node.is_a?(Fn) && BOUNDED_INVERSES.include?(node.name) && condition_argument?(node.args.first, x)
590
+ conditions << Inequality.new(node.args.first, :>=, Num.new(-1))
591
+ conditions << Inequality.new(node.args.first, :<=, Num.new(1))
592
+ end
593
+ end
594
+ conditions
595
+ end
596
+
597
+ # Whether a constant real exponent is not an integer, so that the power
598
+ # asks for a base that is not negative. A literal Rational was the only
599
+ # case once, and x**0.5 and x**pi had the whole line as their domain (a
600
+ # review, 23 Sept 2026). A Float is judged by its value, any other
601
+ # constant by `Infer.excluded?` (pi is not an integer: it is
602
+ # transcendental). An exponent in x (x**x) is `scattered_domain`'s, one
603
+ # in a parameter is left alone like every parameter condition, and a
604
+ # constant whose integrality rcas cannot decide is refused rather than
605
+ # dropped.
606
+ def fractional_exponent?(e)
607
+ return false unless e.variables.empty?
608
+ e = e.simplify
609
+ if e.is_a?(Num)
610
+ v = e.value
611
+ return false unless v.is_a?(Numeric) && v.real?
612
+ return v.is_a?(Float) ? v.finite? && v != v.round : !(v.is_a?(Integer) || (v.is_a?(Rational) && v.denominator == 1))
613
+ end
614
+ return false if (domain = Infer.domain(e)) && domain <= ZZ
615
+ return false unless Decide.sign(ComplexParts.im(e)) == :zero # a complex exponent is not this rule's
616
+ return true if Infer.excluded?(e, ZZ)
617
+ raise RCAS::Unsupported, "real_domain: cannot decide whether the exponent #{e} is an integer, which decides whether a negative base is allowed"
618
+ end
619
+
620
+ # ---- several variables ---------------------------------------------------------
621
+
622
+ # A word for a reader who wrote x**(1/3) and meant the real cube root:
623
+ # ** is the principal root, which has no real value for a negative base
624
+ # (the fifth review's decision), and surd is the real one. One hint per
625
+ # such power whose base moves with x.
626
+ def root_hints(f, x)
627
+ Expression.lift(f).each_node.select do |n|
628
+ n.is_a?(Pow) && n.exponent.is_a?(Num) && n.exponent.value.is_a?(Rational) &&
629
+ n.exponent.value.denominator.odd? && n.exponent.value.denominator > 1 && n.base.variables.include?(x.name)
630
+ end.uniq.map do |n|
631
+ q = n.exponent.value.denominator
632
+ name = q == 3 ? "cube" : "#{q}th"
633
+ "#{n} has no real value where #{n.base} < 0; surd(#{n.base}, #{q}) is the real #{name} root"
634
+ end
635
+ end
636
+
637
+ COORDINATES = %i[x y z].freeze
638
+
639
+ # The coordinates, named rather than guessed (the fifth review's
640
+ # decision, as Mathematica and MuPAD ask for the list): the ones given,
641
+ # or the free names when they are among x, y and z, or - for a field in
642
+ # names of its own - when there are as many of them as components
643
+ # (line_integral's rule).
644
+ # Anything else is refused with the name that is in the way: in
645
+ # x**2 + a*y, a is a parameter, and the gradient had a third component.
646
+ def variables_of(f, vars, components: nil, name: "gradient")
647
+ return Array(vars).map { |v| Expression.lift(v) } if vars && !Array(vars).empty?
648
+ names = Array(f).flat_map { |g| Expression.lift(g).variables }.uniq.sort
649
+ raise ArgumentError, "name the variables, e.g. #{name}(f, [x, y])" if names.empty?
650
+ # the count rule is for a field in names of its own (u, v); a field
651
+ # that uses x or y and one more name has a parameter in it
652
+ counted = names.size == components && (names & COORDINATES).empty?
653
+ return names.map { |n| Var.new(n) } if (names - COORDINATES).empty? || counted
654
+ extra = names - COORDINATES
655
+ coordinates = names & COORDINATES
656
+ suggestion = coordinates.empty? ? names : coordinates
657
+ raise ArgumentError, "#{name}: #{extra.join(', ')} #{extra.size == 1 ? 'is' : 'are'} not a coordinate; " \
658
+ "pass the coordinates, e.g. #{name}(f, [#{suggestion.join(', ')}])"
659
+ end
660
+
661
+ def gradient(f, vars = nil)
662
+ f = Expression.lift(f)
663
+ xs = variables_of(f, vars)
664
+ VectorSpace.new(RR, xs.size).unchecked(xs.map { |x| f.diff(x) })
665
+ end
666
+
667
+ def hessian(f, vars = nil)
668
+ f = Expression.lift(f)
669
+ xs = variables_of(f, vars, name: "hessian")
670
+ rows = xs.map { |a| xs.map { |b| f.diff(a).diff(b) } }
671
+ MatrixSpace.new(RR, xs.size, xs.size).unchecked(rows)
672
+ end
673
+
674
+ def jacobian(fs, vars = nil)
675
+ fs = Array(fs).map { |f| Expression.lift(f) }
676
+ xs = variables_of(fs, vars, components: fs.size, name: "jacobian")
677
+ MatrixSpace.new(RR, fs.size, xs.size).unchecked(fs.map { |f| xs.map { |x| f.diff(x) } })
678
+ end
679
+
680
+ def divergence(field, vars = nil)
681
+ fs = Array(field).map { |f| Expression.lift(f) }
682
+ xs = variables_of(fs, vars, components: fs.size, name: "divergence")
683
+ raise ArgumentError, "divergence: #{fs.size} components for #{xs.size} variables" unless fs.size == xs.size
684
+ fs.each_with_index.map { |f, i| f.diff(xs[i]) }.reduce(:+).simplify
685
+ end
686
+
687
+ def laplacian(f, vars = nil)
688
+ f = Expression.lift(f)
689
+ xs = variables_of(f, vars, name: "laplacian")
690
+ xs.map { |x| f.diff(x, 2) }.reduce(:+).simplify
691
+ end
692
+
693
+ def curl(field, vars = nil)
694
+ fs = Array(field).map { |f| Expression.lift(f) }
695
+ xs = variables_of(fs, vars, components: fs.size, name: "curl")
696
+ raise ArgumentError, "curl: three components and three variables are needed" unless fs.size == 3 && xs.size == 3
697
+ components = [
698
+ fs[2].diff(xs[1]) - fs[1].diff(xs[2]),
699
+ fs[0].diff(xs[2]) - fs[2].diff(xs[0]),
700
+ fs[1].diff(xs[0]) - fs[0].diff(xs[1])
701
+ ]
702
+ VectorSpace.new(RR, 3).unchecked(components.map(&:simplify))
703
+ end
704
+
705
+ # ---- lengths, areas, volumes ---------------------------------------------
706
+
707
+ # The length of a curve: integral(sqrt(1 + f'**2)) for a graph and
708
+ # integral(sqrt(x'**2 + y'**2)) for [x(t), y(t)], in space too. The integral is
709
+ # rarely elementary and then stays an integral(...) node, which evalf
710
+ # or nintegrate finishes.
711
+ def arclength(f, var, from, to)
712
+ var = Expression.lift(var)
713
+ integrand =
714
+ if f.is_a?(Array)
715
+ raise ArgumentError, "arclength: a parametric curve needs two or three components" unless [2, 3].include?(f.size)
716
+ f.map { |c| Expression.lift(c).diff(var)**2 }.reduce(:+)
717
+ else
718
+ Num.new(1) + Expression.lift(f).diff(var)**2
719
+ end
720
+ Integrate.definite(root(integrand), var, from, to)
721
+ end
722
+
723
+ # The volume of the solid the graph of f sweeps out: pi*integral(f**2)
724
+ # about the x-axis, and 2*pi*integral(x*f) (cylindrical shells) about
725
+ # the y-axis. Radius and height are distances, so the shell integrand
726
+ # takes both of them in absolute value - the cylinder of radius and
727
+ # height one came out as -pi over x: -1..0 before (22 Sept 2026, from a
728
+ # review).
729
+ def revolution_volume(f, var, from, to, axis: :x)
730
+ f = Expression.lift(f)
731
+ var = Expression.lift(var)
732
+ integrand =
733
+ if axis == :y
734
+ one_side!(var, from, to, "revolution_volume")
735
+ 2 * PI * distance(var, var, from, to) * distance(f, var, from, to)
736
+ else
737
+ PI * f**2
738
+ end
739
+ Integrate.definite(integrand.simplify, var, from, to)
740
+ end
741
+
742
+ # The area of the surface of revolution: 2*pi*integral(f*sqrt(1 + f'**2))
743
+ # about the x-axis, 2*pi*integral(x*sqrt(1 + f'**2)) about the y-axis.
744
+ # The radius is the distance to the axis, abs(f) or abs(x), and not the
745
+ # signed value: the cylinder of radius one had area -2*pi for f = -1.
746
+ def revolution_surface(f, var, from, to, axis: :x)
747
+ f = Expression.lift(f)
748
+ var = Expression.lift(var)
749
+ one_side!(var, from, to, "revolution_surface") if axis == :y
750
+ radius = axis == :y ? distance(var, var, from, to) : distance(f, var, from, to)
751
+ line = root(Num.new(1) + f.diff(var)**2)
752
+ Integrate.definite((2 * PI * radius * line).simplify, var, from, to)
753
+ end
754
+
755
+ # |u| on the interval, written without the abs only where the sign of u
756
+ # there has been *proved*. Sampling five points said x - 1/10 was
757
+ # positive on 0..1 and dropped the abs, which understated the surface of
758
+ # revolution by 2.4% (22 Sept 2026, from the second review); an abs the
759
+ # integrator can see through is a far smaller price.
760
+ def distance(u, var, from, to)
761
+ u = Expression.lift(u)
762
+ case sign_on_interval(u, var, from, to)
763
+ when :positive then u
764
+ when :negative then Neg.new(u).simplify
765
+ else Fn.new(:abs, [u])
766
+ end
767
+ end
768
+
769
+ # An interval [lo, hi] of Rationals that contains every value of u on
770
+ # the box {name => [lo, hi]} - interval arithmetic, so a proof: x**2 +
771
+ # y**2 lies in [2, 8] on [1, 2]x[1, 2] and never vanishes there. nil for
772
+ # anything it does not enclose (a function, a denominator that may be 0).
773
+ def enclosure(u, box)
774
+ case u
775
+ when Num
776
+ v = u.value
777
+ v.is_a?(Integer) || v.is_a?(Rational) ? [v.to_r, v.to_r] : nil
778
+ when Var then box[u.name]
779
+ when Const then u.name == :pi ? [Rational(314_159_265, 100_000_000), Rational(314_159_266, 100_000_000)] : nil
780
+ when Neg
781
+ a = enclosure(u.arg, box) or return nil
782
+ [-a[1], -a[0]]
783
+ when Add, Sub
784
+ a = enclosure(u.left, box) or return nil
785
+ b = enclosure(u.right, box) or return nil
786
+ u.is_a?(Add) ? [a[0] + b[0], a[1] + b[1]] : [a[0] - b[1], a[1] - b[0]]
787
+ when Mul
788
+ a = enclosure(u.left, box) or return nil
789
+ b = enclosure(u.right, box) or return nil
790
+ products = a.product(b).map { |p, q| p * q }
791
+ [products.min, products.max]
792
+ when Div
793
+ a = enclosure(u.left, box) or return nil
794
+ b = enclosure(u.right, box) or return nil
795
+ return nil if b[0] <= 0 && b[1] >= 0
796
+ enclosure_product(a, [1 / b[1], 1 / b[0]])
797
+ when Pow
798
+ n = u.exponent
799
+ return nil unless n.is_a?(Num) && n.value.is_a?(Integer) && n.value.abs <= 64
800
+ a = enclosure(u.base, box) or return nil
801
+ n = n.value
802
+ if n.negative?
803
+ return nil if a[0] <= 0 && a[1] >= 0
804
+ a = [1 / a[1], 1 / a[0]]
805
+ n = -n
806
+ end
807
+ low, high = a.map { |e| e**n }.minmax
808
+ low = 0r if n.even? && a[0] <= 0 && a[1] >= 0
809
+ [low, high]
810
+ end
811
+ end
812
+
813
+ def enclosure_product(a, b)
814
+ products = a.product(b).map { |p, q| p * q }
815
+ [products.min, products.max]
816
+ end
817
+
818
+ # The box of a list of ranges, innermost first, each bound enclosed
819
+ # over the ranges outside it; nil when a bound is not enclosed.
820
+ def box_of(ranges)
821
+ box = {}
822
+ ranges.reverse_each do |var, from, to|
823
+ lo = enclosure(Expression.lift(from).simplify, box) or return nil
824
+ hi = enclosure(Expression.lift(to).simplify, box) or return nil
825
+ box[Expression.lift(var).name] = [[lo[0], hi[0]].min, [lo[1], hi[1]].max]
826
+ end
827
+ box
828
+ end
829
+
830
+ # :positive, :negative, :nonnegative, :nonpositive or nil for u on the
831
+ # box, by its enclosure.
832
+ def sign_on_box(u, ranges)
833
+ box = box_of(ranges) or return nil
834
+ lo, hi = enclosure(Expression.lift(u).simplify, box)
835
+ return nil if lo.nil?
836
+ return :positive if lo.positive?
837
+ return :negative if hi.negative?
838
+ return :nonnegative if lo.zero?
839
+ return :nonpositive if hi.zero?
840
+ nil
841
+ end
842
+
843
+ # The sign of u on the interval, or nil. A continuous u keeps one sign
844
+ # on an interval in which it has no zero, so the zeros are what decides
845
+ # it - *all* of them: Solve names them completely (a family is counted
846
+ # out member by member; cos(16*pi*x) has sixteen zeros in 1..2, of which
847
+ # the principal ones showed none), a zero strictly inside means there is
848
+ # no single sign, and a zero rcas cannot place means it does not know.
849
+ # "No zero" is only half the argument: u has to be continuous there, so
850
+ # a pole, a place where u stops being real, or a function with jumps
851
+ # inside the interval also leaves the question open (1/((x - 1/100)*
852
+ # (x - 1/50)) has no zero on 0..1 and is negative between its poles;
853
+ # third review, T1). The interior samples are decided exactly and are a
854
+ # veto, never the proof.
855
+ def sign_on_interval(u, var, from, to)
856
+ u = Expression.lift(u)
857
+ var = Expression.lift(var)
858
+ return constant_sign(u) unless u.variables.include?(var.name)
859
+ return nil unless u.variables == [var.name]
860
+ lo, hi = [Expression.lift(from), Expression.lift(to)].sort { |p, q| Inequalities.compare(p, q) || 0 }
861
+ return nil unless Inequalities.compare(lo, hi) == -1 && numeric(lo) && numeric(hi)
862
+ return nil if u.each_node.any? { |n| n.is_a?(Piecewise) || (n.is_a?(Fn) && JUMPS.include?(n.name)) }
863
+ breaks = [u] + denominators(u, var) + domain_conditions(u, var).map { |c| (c.lhs - c.rhs).simplify }
864
+ return nil if breaks.any? { |g| zero_inside?(g, var, lo, hi) != false }
865
+ signs_on(u, var, lo, hi)
866
+ end
867
+
868
+ # Functions that jump: no zero does not mean one sign across them.
869
+ JUMPS = %i[floor ceil round sign].freeze
870
+
871
+ # true when g has a zero strictly between lo and hi, false when it is
872
+ # shown to have none, nil when that cannot be told.
873
+ def zero_inside?(g, var, lo, hi)
874
+ return false unless g.variables.include?(var.name)
875
+ roots = begin
876
+ Solve.solve(g, var, domain: RR)
877
+ rescue StandardError, NotImplementedError, RCAS::Unsupported => rescued
878
+ RCAS.guard!(rescued, refused: true)
879
+ return nil
880
+ end
881
+ return nil unless roots.is_a?(Array) # an identity or a set: no single sign to read
882
+ found = false
883
+ roots.each do |r|
884
+ members =
885
+ if r.is_a?(ImageSet)
886
+ r.between(numeric(lo) - 1, numeric(hi) + 1) or return nil
887
+ else
888
+ [r]
889
+ end
890
+ members.each do |m|
891
+ inside = strictly_between?(m, lo, hi)
892
+ return nil if inside.nil?
893
+ found ||= inside
894
+ end
895
+ end
896
+ found
897
+ end
898
+
899
+ # Is the real point m strictly inside (lo, hi)? false for a point off
900
+ # the real line, nil when that or its position cannot be decided.
901
+ def strictly_between?(m, lo, hi)
902
+ real = begin
903
+ Inequalities.real?(m)
904
+ rescue NotImplementedError, RCAS::Unsupported
905
+ return nil
906
+ end
907
+ return false unless real
908
+ m = Inequalities.real_part(m)
909
+ below = Inequalities.compare(lo, m)
910
+ above = Inequalities.compare(m, hi)
911
+ return nil if below.nil? || above.nil?
912
+ below.negative? && above.negative?
913
+ end
914
+
915
+ # Kept for callers that ask about one root.
916
+ def splits?(root, lo, hi) = strictly_between?(root, Expression.lift(lo), Expression.lift(hi)) != false
917
+
918
+ def constant_sign(u)
919
+ sign = Decide.sign(u.simplify)
920
+ %i[positive negative].include?(sign) ? sign : nil
921
+ end
922
+
923
+ # The interior read at several points, each sign decided; they must
924
+ # agree, and the middle one carries the answer.
925
+ INTERIOR = [1, 2, 3, 4, 5, 6, 7].map { |i| Rational(i, 8) }.freeze
926
+
927
+ def signs_on(u, var, lo, hi)
928
+ signs = INTERIOR.map do |t|
929
+ point = (lo + (hi - lo) * Num.new(t)).simplify
930
+ sign = Decide.sign(u.subs(var => point).simplify)
931
+ return nil unless %i[positive negative].include?(sign)
932
+ sign
933
+ end
934
+ signs.uniq.size == 1 ? signs.first : nil
935
+ end
936
+
937
+ # Shells about the y-axis stand on one side of it; a range that crosses
938
+ # the axis would have the two halves sweeping the same shells, which
939
+ # abs(x) would then count twice. Splitting the range is the reader's
940
+ # call, so rcas says so instead of answering - and says too that the two
941
+ # halves are a union and not a sum wherever they overlap.
942
+ def one_side!(var, from, to, who)
943
+ a = numeric(from)
944
+ b = numeric(to)
945
+ return if a.nil? || b.nil? || a * b >= 0
946
+ raise ArgumentError, "#{who}: the range #{var} = #{from}..#{to} crosses the axis of revolution; " \
947
+ "take the two sides separately - where their shells overlap the two answers are not to be added"
948
+ end
949
+
950
+ def root(u) = Pow.new(u.simplify, Num.new(Rational(1, 2))).simplify
951
+
952
+ # Stationary points of f under the constraints g = 0, by the multiplier
953
+ # rule: grad f = sum(lambda_i grad g_i) together with the constraints.
954
+ def lagrange(f, constraints, vars = nil)
955
+ f = Expression.lift(f)
956
+ gs = Array(constraints).map { |g| Solve.to_zero(g) }
957
+ xs = variables_of([f] + gs, vars, name: "lagrange")
958
+ multipliers = gs.each_index.map { |i| Var.new(gs.size == 1 ? :lambda : :"lambda#{i + 1}") }
959
+ equations = xs.map do |x|
960
+ gs.each_with_index.reduce(f.diff(x)) { |acc, (g, i)| acc - multipliers[i] * g.diff(x) }.simplify
961
+ end
962
+ solutions = Solve.solve(equations + gs, xs + multipliers, principal: true)
963
+ solutions.map { |s| Assignment[xs.to_h { |x| [x, s[x]] }] }
964
+ end
965
+ end
966
+ end