1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
//! Integration tests for expand_power_exp, expand_power_base, ratsimp,
//! separateand inverse Laplace completing-the-square enhancements.
use symplex::prelude::*;
// ═══════════════════════════════════════════════════════════════════════════
// 1. expand_power_exp: x^(a+b) → x^a · x^b
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn expand_power_exp_does_not_panic() {
// expand_power_exp splits x^(a+b) → x^a · x^b internally,
// but canon_mul recombines same-base powers back into x^(a+b).
// Verify the code path runs without error and is idempotent.
let ctx = Context::new();
let x = ctx.symbol("x");
let a = ctx.symbol("a");
let b = ctx.symbol("b");
let expr = x.pow(&(&a + &b));
let expanded = expr.expand();
let s = format!("{expanded}");
// Canonicalization recombines: x^a * x^b → x^(a+b), so result
// is the same as the input expression.
assert!(
s.contains("x") && s.contains("a") && s.contains("b"),
"x^(a+b) expand should preserve the expression, got: {s}"
);
}
#[test]
fn expand_power_exp_with_integer_addend() {
// x^(2 + a) → expanded: the expand_power_exp path fires, but
// canon_mul recombines. Verify no panic and valid result.
let ctx = Context::new();
let x = ctx.symbol("x");
let a = ctx.symbol("a");
let expr = x.pow(&(&a + 2));
let expanded = expr.expand();
let s = format!("{expanded}");
assert!(
s.contains("x"),
"x^(a+2) expand should produce valid expression, got: {s}"
);
// Expanding twice should be idempotent
let expanded2 = expanded.expand();
assert_eq!(
format!("{expanded}"),
format!("{expanded2}"),
"expand should be idempotent for power-exp case"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. expand_power_base: (x·y)^n → x^n · y^n
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn expand_power_base_simple() {
// (x*y)^a = x^a * y^a is only an identity for non-negative bases (or
// integer a): for x = y = -1, a = 1/2 the left side is 1 and the right
// side is -1. Since 0.2 `expand` guards the rewrite with the
// assumption system, so the symbols are declared positive here.
let ctx = Context::new();
let x = ctx.symbol_with("x", &[Assumption::Positive]);
let y = ctx.symbol_with("y", &[Assumption::Positive]);
let a = ctx.symbol("a");
// (x*y)^a should expand to x^a * y^a
let expr = (&x * &y).pow(&a);
let expanded = expr.expand();
let s = format!("{expanded}");
assert!(
s.contains("x^a") && s.contains("y^a"),
"(x*y)^a should expand to x^a * y^a, got: {s}"
);
}
#[test]
fn expand_power_base_blocked_for_unassumed_symbols() {
let ctx = Context::new();
let x = ctx.symbol("x");
let y = ctx.symbol("y");
let a = ctx.symbol("a");
let expr = (&x * &y).pow(&a);
let expanded = expr.expand();
assert_eq!(
format!("{expanded}"),
format!("{expr}"),
"(x*y)^a must not split without positivity information"
);
}
#[test]
fn expand_power_base_three_factors() {
let ctx = Context::new();
let x = ctx.symbol_with("x", &[Assumption::Positive]);
let y = ctx.symbol_with("y", &[Assumption::Positive]);
let z = ctx.symbol_with("z", &[Assumption::Positive]);
let n = ctx.symbol("n");
// (x*y*z)^n → x^n * y^n * z^n
let expr = (&x * &y * &z).pow(&n);
let expanded = expr.expand();
let s = format!("{expanded}");
assert!(
s.contains("x^n") && s.contains("y^n") && s.contains("z^n"),
"(x*y*z)^n should expand to x^n*y^n*z^n, got: {s}"
);
}
#[test]
fn expand_power_base_integer_exp_uses_multinomial() {
// (x+y)^2 should still use multinomial (not power_base),
// because the base is Add, not Mul.
let ctx = Context::new();
let x = ctx.symbol("x");
let y = ctx.symbol("y");
let expr = (&x + &y).powi(2);
let expanded = expr.expand();
let s = format!("{expanded}");
// Should be x^2 + 2*x*y + y^2
assert!(
s.contains("x^2") && s.contains("y^2"),
"(x+y)^2 should multinomial-expand, got: {s}"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. ratsimp convenience method
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn ratsimp_combines_fractions() {
let ctx = Context::new();
let x = ctx.symbol("x");
// 1/x + 1/x = 2/x
let expr = &x.powi(-1) + &x.powi(-1);
let simplified = expr.simplify_rational();
let s = format!("{simplified}");
assert!(
s.contains("2"),
"1/x + 1/x should ratsimp to 2/x or 2*x^(-1), got: {s}"
);
}
#[test]
fn ratsimp_cancels_common_factor() {
let ctx = Context::new();
let x = ctx.symbol("x");
// (x^2 - 1) / (x - 1) should simplify to x + 1
let expr = (&x.powi(2) - 1) / (&x - 1);
let simplified = expr.simplify_rational();
let s = format!("{simplified}");
assert!(
s.contains("x") && s.contains("1"),
"(x^2-1)/(x-1) should ratsimp to x+1, got: {s}"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. separatevars
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn separatevars_independent_factors() {
let ctx = Context::new();
let x = ctx.symbol("x");
let y = ctx.symbol("y");
// x * y — each factor depends on a different variable
let expr = &x * &y;
let groups = expr.separate_vars(&[&x, &y]);
// Should produce at least 2 groups (one for x, one for y)
assert!(
groups.len() >= 2,
"x*y should separate into ≥2 groups, got {} groups",
groups.len()
);
}
#[test]
fn separatevars_with_constant() {
let ctx = Context::new();
let x = ctx.symbol("x");
// 3 * x — should have a constant group and an x group
let expr = &x * 3;
let groups = expr.separate_vars(&[&x]);
let const_groups: Vec<_> = groups.iter().filter(|(deps, _)| deps.is_empty()).collect();
let x_groups: Vec<_> = groups.iter().filter(|(deps, _)| !deps.is_empty()).collect();
assert!(!const_groups.is_empty(), "3*x should have a constant group");
assert!(!x_groups.is_empty(), "3*x should have an x group");
}
#[test]
fn separatevars_non_mul_single_group() {
let ctx = Context::new();
let x = ctx.symbol("x");
// Just a symbol, not a Mul — should return a single group
let groups = x.separate_vars(&[&x]);
assert_eq!(groups.len(), 1, "a single symbol should be one group");
assert_eq!(groups[0].0.len(), 1, "should depend on x");
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. Inverse Laplace completing-the-square (underdamped β² > 0)
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn inverse_laplace_completing_square_underdamped() {
let ctx = Context::new();
let s = ctx.symbol("s");
let t = ctx.symbol("t");
// F(s) = 1 / (s² + 2s + 5)
// Complete the square: (s+1)² + 4, so α = -1, β = 2
// L⁻¹ = (1/2) · exp(-t) · sin(2t)
let denom = &s.powi(2) + &s * 2 + 5;
let f_s = 1 / &denom;
let result = f_s.inverse_laplace(&s, &t);
let s_repr = format!("{result}");
// Should contain exp and sin
assert!(
s_repr.contains("sin") && s_repr.contains("exp"),
"inverse Laplace of 1/(s²+2s+5) should have exp and sin, got: {s_repr}"
);
}
#[test]
fn inverse_laplace_completing_square_overdamped() {
let ctx = Context::new();
let s = ctx.symbol("s");
let t = ctx.symbol("t");
// F(s) = 1 / (s² + 2s - 3)
// Complete the square: (s+1)² - 4, so α = -1, γ = 2
// β² = -3 - 1 = -4 < 0 → overdamped
// L⁻¹ = (1/2) · exp(-t) · sinh(2t)
let denom = &s.powi(2) + &s * 2 - 3;
let f_s = 1 / &denom;
let result = f_s.inverse_laplace(&s, &t);
let s_repr = format!("{result}");
// Should produce a result containing exp (may use sinh or partial fractions)
assert!(
s_repr.contains("exp") || s_repr.contains("sinh"),
"inverse Laplace of 1/(s²+2s-3) should have exp/sinh, got: {s_repr}"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// expand_power_exp soundness guard tests
//
// The guard in expand_power_exp prevents x^(a+b) → x^a * x^b when x
// could be negative (the identity fails for negative bases with fractional
// exponents due to complex branch cuts).
//
// Note: even when the guard ALLOWS the split, canon_mul may recombine
// 2^a * 2^b back into 2^(a+b) (same-base power collection). That's
// correct behavior — the important thing is that the unsound cases are
// BLOCKED.
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn expand_power_exp_blocks_symbolic_base() {
// x^(a+b) must NOT split to x^a * x^b when x is a general symbol,
// because for negative x with fractional exponents, the identity fails.
let ctx = Context::new();
symplex::syms!(ctx; x, a, b);
let expr = x.pow(&(&a + &b));
let expanded = expr.expand();
let s = format!("{expanded}");
// Must NOT contain a split product — should remain as x^(...)
assert!(
!s.contains("x^a*x^b"),
"x^(a+b) should NOT be split for symbolic base, got: {s}"
);
}
#[test]
fn expand_power_exp_euler_becomes_exp_node() {
// e^(a+b) is canonicalized to Exp(a+b) at construction time,
// so it never reaches expand_power_exp (which handles Pow nodes).
// Verify the canonical form is Exp and expand doesn't crash.
let ctx = Context::new();
symplex::syms!(ctx; a, b);
let expr = ctx.e().pow(&(&a + &b));
let s = format!("{expr}");
assert!(
s.contains("exp("),
"e^(a+b) should be canonicalized to exp(...), got: {s}"
);
// Expand should be a no-op (Exp(Add) has no expand rule)
let expanded = expr.expand();
let s2 = format!("{expanded}");
assert!(
s2.contains("exp("),
"e^(a+b) should remain as exp(...) after expand, got: {s2}"
);
}
#[test]
fn expand_power_exp_positive_numeric_base_no_panic() {
// 2^(a+b): the guard ALLOWS the split (base is positive).
// canon_mul immediately recombines 2^a * 2^b → 2^(a+b).
// The important thing: no panic, and the result is valid.
let ctx = Context::new();
symplex::syms!(ctx; a, b);
let expr = ctx.int(2).pow(&(&a + &b));
let expanded = expr.expand();
let s = format!("{expanded}");
assert!(
s.contains("2") && s.contains("a") && s.contains("b"),
"2^(a+b) expand should produce valid expression, got: {s}"
);
// Expanding twice should be idempotent
let expanded2 = expanded.expand();
assert_eq!(
format!("{expanded}"),
format!("{expanded2}"),
"expand should be idempotent for positive numeric base case"
);
}
#[test]
fn expand_power_exp_blocks_negative_numeric_base() {
// (-2)^(a+b) must NOT split — negative base with symbolic exponents.
// The identity (-2)^(a+b) = (-2)^a * (-2)^b fails for fractional exponents.
let ctx = Context::new();
symplex::syms!(ctx; a, b);
let neg2 = -&ctx.int(2);
let expr = neg2.pow(&(&a + &b));
let expanded = expr.expand();
let s = format!("{expanded}");
assert!(
!s.contains("(-2)^a*(-2)^b"),
"(-2)^(a+b) should NOT be split, got: {s}"
);
}
#[test]
fn expand_power_exp_numeric_exponents_allowed() {
// x^(2+3): canon_add folds 2+3 → 5 at construction time,
// so by the time expand sees it, it's already x^5.
// This tests that the all-nonneg guard path doesn't cause issues.
let ctx = Context::new();
symplex::syms!(ctx; x);
let expr = x.pow(&(&ctx.int(2) + &ctx.int(3)));
let s = format!("{expr}");
// Canon already folded 2+3 to 5
assert!(
s.contains("x^5"),
"x^(2+3) should be x^5 after canonicalization, got: {s}"
);
}