Skip to content

Commit bb8a724

Browse files
arnogclaude
andauthored
feat: add factorial and binomial simplification rules (#289)
Add simplification rules for factorial quotients (n!/k! via partial products), binomial detection (n!/(k!(n-k)!) → Binomial(n,k)), binomial identities (C(n,0)→1, C(n,1)→n, C(n,n)→1), and factorial sum factoring (n!-(n-1)! → (n-1)!*(n-1)). Works for both concrete integers and symbolic expressions. https://claude.ai/code/session_0114soGikCy942KAFJU8xn79 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4682c2d commit bb8a724

5 files changed

Lines changed: 530 additions & 2 deletions

File tree

src/compute-engine/boxed-expression/simplify.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,8 @@ function simplifyNonCommutativeFunction(
433433
const isAbsRule = because?.startsWith('|');
434434
// Quotient-power distribution: a/(b/c)^d -> a*(c/b)^d eliminates nested fractions
435435
const isQuotientPowerRule = because === 'a / (b/c)^d -> a * (c/b)^d';
436+
// Factorial factoring: n! - (n-1)! -> (n-1)! * (n-1) is structurally preferred
437+
const isFactorialFactoring = because === 'factor common factorial';
436438
// Expand may produce more nodes but enables term cancellation
437439
// Accept when expansion reduces terms or eliminates Power-of-Add patterns
438440
const isExpandWithSimplification =
@@ -474,6 +476,7 @@ function simplifyNonCommutativeFunction(
474476
!isRootSignRule &&
475477
!isAbsRule &&
476478
!isQuotientPowerRule &&
479+
!isFactorialFactoring &&
477480
!isExpandWithSimplification
478481
)
479482
return steps;

src/compute-engine/symbolic/simplify-divide.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import type { Expression, RuleStep } from '../global-types';
22
import { isFunction, isNumber } from '../boxed-expression/type-guards';
33
import { asRational } from '../boxed-expression/numerics';
4+
import { baseOffset } from './simplify-factorial';
45

56
/**
67
* Division simplification rules consolidated from simplify-rules.ts.
7-
* Handles ~5 patterns for simplifying Divide expressions.
88
*
99
* Patterns:
1010
* - a/a -> 1 (when a ≠ 0)
1111
* - 1/(1/a) -> a (when a ≠ 0)
1212
* - a/(1/b) -> a*b (when b ≠ 0)
1313
* - a/(b/c) -> a*c/b (when c ≠ 0)
1414
* - 0/a -> 0 (when a ≠ 0)
15+
* - n!/k! -> partial product (concrete integers)
16+
* - n!/k! -> (k+1)(k+2)...n (symbolic, small constant diff)
17+
* - n!/(k!(n-k)!) -> Binomial(n, k)
1518
*
1619
* IMPORTANT: Do not call .simplify() on results to avoid infinite recursion.
1720
*/
@@ -125,5 +128,119 @@ export function simplifyDivide(x: Expression): RuleStep | undefined {
125128
}
126129
}
127130

131+
// ── Factorial quotient: Factorial(a) / Factorial(b) ──
132+
if (
133+
num.operator === 'Factorial' &&
134+
denom.operator === 'Factorial' &&
135+
isFunction(num) &&
136+
isFunction(denom)
137+
) {
138+
const a = num.op1;
139+
const b = denom.op1;
140+
141+
// Concrete integer case: compute partial product directly
142+
if (
143+
isNumber(a) &&
144+
isNumber(b) &&
145+
a.isInteger &&
146+
b.isInteger &&
147+
a.isNonNegative &&
148+
b.isNonNegative
149+
) {
150+
const aVal = BigInt(a.re);
151+
const bVal = BigInt(b.re);
152+
if (aVal >= bVal) {
153+
// n!/k! = (k+1)(k+2)...n
154+
let result = 1n;
155+
for (let i = bVal + 1n; i <= aVal; i++) result *= i;
156+
return { value: ce.number(result), because: 'n!/k! partial product' };
157+
} else {
158+
// n < k: n!/k! = 1/((n+1)(n+2)...k)
159+
let result = 1n;
160+
for (let i = aVal + 1n; i <= bVal; i++) result *= i;
161+
return {
162+
value: ce.number([1, result]),
163+
because: 'n!/k! -> 1/(partial product)',
164+
};
165+
}
166+
}
167+
168+
// Symbolic case: check if a and b differ by a small integer constant
169+
const aBO = baseOffset(a);
170+
const bBO = baseOffset(b);
171+
if (aBO && bBO && aBO.base.isSame(bBO.base)) {
172+
const d = aBO.offset - bBO.offset;
173+
if (Number.isInteger(d) && d >= 1 && d <= 8) {
174+
// a!/b! = (b+1)(b+2)...a
175+
let product: Expression = b.add(ce.One);
176+
for (let i = 2; i <= d; i++) {
177+
product = product.mul(b.add(ce.number(i)));
178+
}
179+
return { value: product, because: 'n!/k! -> (k+1)..n' };
180+
}
181+
if (Number.isInteger(d) && d <= -1 && d >= -8) {
182+
// a < b: a!/b! = 1/((a+1)(a+2)...b)
183+
let product: Expression = a.add(ce.One);
184+
for (let i = 2; i <= -d; i++) {
185+
product = product.mul(a.add(ce.number(i)));
186+
}
187+
return {
188+
value: ce.One.div(product),
189+
because: 'n!/k! -> 1/((n+1)..k)',
190+
};
191+
}
192+
}
193+
}
194+
195+
// ── Binomial detection: n! / (k! * (n-k)!) → Binomial(n, k) ──
196+
if (
197+
num.operator === 'Factorial' &&
198+
isFunction(num) &&
199+
denom.operator === 'Multiply' &&
200+
isFunction(denom)
201+
) {
202+
const n = num.op1;
203+
const factorialOps = denom.ops.filter(
204+
(op) => op.operator === 'Factorial' && isFunction(op)
205+
);
206+
const otherOps = denom.ops.filter(
207+
(op) => !(op.operator === 'Factorial' && isFunction(op))
208+
);
209+
210+
if (
211+
factorialOps.length === 2 &&
212+
otherOps.length === 0 &&
213+
isFunction(factorialOps[0]) &&
214+
isFunction(factorialOps[1])
215+
) {
216+
const k = factorialOps[0].op1;
217+
const m = factorialOps[1].op1;
218+
219+
// Check if k + m = n (numeric)
220+
if (
221+
isNumber(n) &&
222+
isNumber(k) &&
223+
isNumber(m) &&
224+
k.re + m.re === n.re
225+
) {
226+
// Use the smaller of k, m for efficiency
227+
const smaller = k.re <= m.re ? k : m;
228+
return {
229+
value: ce._fn('Binomial', [n, smaller]),
230+
because: 'n!/(k!(n-k)!) -> Binomial',
231+
};
232+
}
233+
234+
// Symbolic: check if k + m structurally equals n
235+
const sum = k.add(m);
236+
if (sum.isSame(n)) {
237+
return {
238+
value: ce._fn('Binomial', [n, k]),
239+
because: 'n!/(k!(n-k)!) -> Binomial',
240+
};
241+
}
242+
}
243+
}
244+
128245
return undefined;
129246
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { Expression, RuleStep } from '../global-types';
2+
import { isFunction, isNumber } from '../boxed-expression/type-guards';
3+
4+
/**
5+
* Extracts base + integer offset from an expression.
6+
* - Symbol `n` → { base: n, offset: 0 }
7+
* - Add(n, 3) → { base: n, offset: 3 }
8+
* - Add(n, -2) → { base: n, offset: -2 }
9+
* - Multiply(2, x) → { base: Multiply(2, x), offset: 0 }
10+
* - Number → null (pure numeric, no symbolic base)
11+
*/
12+
export function baseOffset(
13+
expr: Expression
14+
): { base: Expression; offset: number } | null {
15+
if (isNumber(expr)) return null;
16+
17+
if (expr.operator === 'Add' && isFunction(expr) && expr.nops === 2) {
18+
const [op1, op2] = [expr.op1, expr.op2];
19+
if (isNumber(op2) && Number.isInteger(op2.re) && !isNumber(op1))
20+
return { base: op1, offset: op2.re };
21+
if (isNumber(op1) && Number.isInteger(op1.re) && !isNumber(op2))
22+
return { base: op2, offset: op1.re };
23+
}
24+
25+
return { base: expr, offset: 0 };
26+
}
27+
28+
/**
29+
* Simplification rules for Binomial and Choose expressions.
30+
*
31+
* Patterns:
32+
* - C(n, 0) → 1
33+
* - C(n, 1) → n
34+
* - C(n, n) → 1
35+
* - C(n, n-1) → n
36+
*/
37+
export function simplifyBinomial(x: Expression): RuleStep | undefined {
38+
if (x.operator !== 'Binomial' && x.operator !== 'Choose') return undefined;
39+
if (!isFunction(x)) return undefined;
40+
41+
const n = x.op1;
42+
const k = x.op2;
43+
if (!n || !k) return undefined;
44+
45+
const ce = x.engine;
46+
47+
// C(n, 0) → 1
48+
if (k.is(0)) return { value: ce.One, because: 'C(n,0) -> 1' };
49+
50+
// C(n, 1) → n
51+
if (k.is(1)) return { value: n, because: 'C(n,1) -> n' };
52+
53+
// C(n, n) → 1
54+
if (k.isSame(n)) return { value: ce.One, because: 'C(n,n) -> 1' };
55+
56+
// C(n, n-1) → n (structural check via baseOffset)
57+
const nBO = baseOffset(n);
58+
const kBO = baseOffset(k);
59+
if (
60+
nBO &&
61+
kBO &&
62+
nBO.base.isSame(kBO.base) &&
63+
nBO.offset - kBO.offset === 1
64+
)
65+
return { value: n, because: 'C(n,n-1) -> n' };
66+
67+
return undefined;
68+
}
69+
70+
/**
71+
* Extracts factorial information from a term in an Add expression.
72+
* Handles: Factorial(n), Negate(Factorial(n)), Multiply(c, Factorial(n))
73+
*/
74+
function extractFactorialTerm(
75+
term: Expression
76+
): { coeff: number; factArg: Expression } | null {
77+
// Direct: Factorial(n)
78+
if (term.operator === 'Factorial' && isFunction(term))
79+
return { coeff: 1, factArg: term.op1 };
80+
81+
// Negate(Factorial(n))
82+
if (
83+
term.operator === 'Negate' &&
84+
isFunction(term) &&
85+
term.op1.operator === 'Factorial' &&
86+
isFunction(term.op1)
87+
)
88+
return { coeff: -1, factArg: term.op1.op1 };
89+
90+
// Multiply with integer coefficient and Factorial
91+
if (term.operator === 'Multiply' && isFunction(term)) {
92+
const ops = term.ops;
93+
let factIdx = -1;
94+
for (let i = 0; i < ops.length; i++) {
95+
if (ops[i].operator === 'Factorial' && isFunction(ops[i])) {
96+
if (factIdx >= 0) return null; // Multiple factorials
97+
factIdx = i;
98+
}
99+
}
100+
if (factIdx < 0) return null;
101+
102+
const factOp = ops[factIdx];
103+
if (!isFunction(factOp)) return null;
104+
const factArg = factOp.op1;
105+
const others = ops.filter((_, i) => i !== factIdx);
106+
107+
if (
108+
others.length === 1 &&
109+
isNumber(others[0]) &&
110+
Number.isInteger(others[0].re)
111+
)
112+
return { coeff: others[0].re, factArg };
113+
114+
return null;
115+
}
116+
117+
return null;
118+
}
119+
120+
/**
121+
* Simplification for Add expressions containing factorial terms.
122+
* Factors out the common (smallest) factorial for symbolic expressions.
123+
*
124+
* Examples:
125+
* - n! - (n-1)! → (n-1)! * (n - 1)
126+
* - (n+1)! - n! → n! * n
127+
* - (n+1)! + n! → n! * (n + 2)
128+
*/
129+
export function simplifyFactorialAdd(x: Expression): RuleStep | undefined {
130+
if (x.operator !== 'Add' || !isFunction(x)) return undefined;
131+
132+
const ops = x.ops;
133+
if (ops.length < 2) return undefined;
134+
135+
// Extract factorial info from each operand
136+
const factTerms: Array<{
137+
coeff: number;
138+
factArg: Expression;
139+
index: number;
140+
}> = [];
141+
142+
for (let i = 0; i < ops.length; i++) {
143+
const info = extractFactorialTerm(ops[i]);
144+
if (info) factTerms.push({ ...info, index: i });
145+
}
146+
147+
// Need at least 2 factorial terms
148+
if (factTerms.length < 2) return undefined;
149+
150+
const ce = x.engine;
151+
152+
// Get base+offset for each factorial argument
153+
const bos = factTerms.map((f) => ({ ...f, bo: baseOffset(f.factArg) }));
154+
const validBOs = bos.filter((b) => b.bo !== null);
155+
156+
if (validBOs.length < 2) return undefined;
157+
158+
// Check if all share the same symbolic base
159+
const refBase = validBOs[0].bo!.base;
160+
if (!validBOs.every((b) => b.bo!.base.isSame(refBase))) return undefined;
161+
162+
// Find the minimum offset (smallest factorial)
163+
let minOffset = Infinity;
164+
for (const b of validBOs) {
165+
if (b.bo!.offset < minOffset) minOffset = b.bo!.offset;
166+
}
167+
168+
// Find the argument expression for the minimum factorial
169+
const minFactArg = validBOs.find((v) => v.bo!.offset === minOffset)!.factArg;
170+
171+
// Build the inner terms: for each factorial, compute the partial product
172+
const innerTerms: Expression[] = [];
173+
174+
for (const b of validBOs) {
175+
const d = b.bo!.offset - minOffset;
176+
177+
if (d === 0) {
178+
// This term IS the minimum factorial
179+
innerTerms.push(ce.number(b.coeff));
180+
} else if (d > 0 && d <= 8) {
181+
// Build product (minArg+1)(minArg+2)...(minArg+d)
182+
let product: Expression = minFactArg.add(ce.One);
183+
for (let i = 2; i <= d; i++) {
184+
product = product.mul(minFactArg.add(ce.number(i)));
185+
}
186+
if (b.coeff === 1) {
187+
innerTerms.push(product);
188+
} else if (b.coeff === -1) {
189+
innerTerms.push(product.neg());
190+
} else {
191+
innerTerms.push(ce.number(b.coeff).mul(product));
192+
}
193+
} else {
194+
// Difference too large or negative (shouldn't happen since we found min)
195+
return undefined;
196+
}
197+
}
198+
199+
// Sum the inner terms
200+
const innerSum =
201+
innerTerms.length === 1
202+
? innerTerms[0]
203+
: ce.function('Add', innerTerms);
204+
205+
// Result: Factorial(minFactArg) * innerSum
206+
// Use _fn to avoid canonicalization that might re-distribute the product
207+
const factorialExpr = ce._fn('Factorial', [minFactArg]);
208+
const factored = ce._fn('Multiply', [factorialExpr, innerSum]);
209+
210+
// Include any non-factorial terms from the original Add
211+
const factIndices = new Set(factTerms.map((f) => f.index));
212+
const nonFactTerms = ops.filter((_, i) => !factIndices.has(i));
213+
214+
if (nonFactTerms.length > 0)
215+
return {
216+
value: ce._fn('Add', [factored, ...nonFactTerms]),
217+
because: 'factor common factorial',
218+
};
219+
220+
return { value: factored, because: 'factor common factorial' };
221+
}

0 commit comments

Comments
 (0)