From 627ca30e61a968b6025845e8f788a6131dd953b4 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 6 Oct 2026 14:48:33 +0000 Subject: [PATCH] A substitution under a binder neither captures the value nor passes over a body without the target Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 16 +++ Sources/AngouriMath/Functions/Substitute.cs | 134 ++++++++---------- .../Core/SubstitutionUnderABinderTest.cs | 73 ++++++++++ 3 files changed, 146 insertions(+), 77 deletions(-) create mode 100644 Sources/Tests/UnitTests/Core/SubstitutionUnderABinderTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index f6dd61c5c..772500478 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -176,6 +176,22 @@ after it |---|---|---| | `"(a*c + b*c*x)^(-3-2*p)*(f + g*x)*(a^2 + 2*a*b*x + b^2*x^2)^p".ToEntity().Integrate("x")` | `integral(...)`; an answer with no value on the unreleased master | the antiderivative | +### A value substituted under a binder is not captured by the name it binds + +**Wrong answers fixed.** An integral, a sum, a product, a derivative, a limit, a set builder and the +other binders renamed their bound name to a temporary, substituted, and renamed it back, so a value +holding the bound name was captured by it: `integral(t + a, t, 0, 1)` with `a := t` became +`integral(t + t, t, 0, 1)`, which is 1 where the integral is `t + 1/2`. Where the value holds the +bound name, the binder now takes a fresh one, `t_1`, and keeps it, as a lambda already did. A +substitution whose target is absent from the body no longer passes over it, which made a chain of +nested integrals cost twice as much per level +([#1808](https://github.com/asc-community/AngouriMath/issues/1808)). + +| Input | Was (2.5.0) | Now | +|---|---|---| +| `new Integralf("t + a".ToEntity(), "t", (0, 1)).Substitute("a", "t")` | `integral(t + t, t, 0, 1)` | `integral(t_1 + t, t_1, 0, 1)` | +| `new Summationf("k * a".ToEntity(), "k", 1, 3).Substitute("a", "k")` | `sum(k * k, k, 1, 3)`, 14 | `sum(k_1 * k, k_1, 1, 3)`, `6 k` | + ### A power of a constant below `1e-50` is no longer simplified to zero **Answers that were wrong.** Evaluation rounds a value within `1e-50` of an integer onto it, and diff --git a/Sources/AngouriMath/Functions/Substitute.cs b/Sources/AngouriMath/Functions/Substitute.cs index af6b6af44..0cbb04a1f 100644 --- a/Sources/AngouriMath/Functions/Substitute.cs +++ b/Sources/AngouriMath/Functions/Substitute.cs @@ -570,50 +570,63 @@ partial record Set { partial record ConditionalSet { - // TODO: it might be optimized /// public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Predicate + Var).Vars); + var (postSubs, boundName) = SubstituteUnderABinder(Predicate, Var, x, value); - // { x | x > a } -> { temp_1 | temp_1 > a } - var tempSubstituted = Predicate.Substitute(Var, replacement); - - // a = 0 -> { temp_1 | temp_1 > a } -> { temp_1 | temp_1 > 0 } - // x = 0 -> { temp_1 | temp_1 > a } -> { temp_1 | temp_1 > a } - var subs = tempSubstituted.Substitute(x, value); - - // { temp_1 | temp_1 > a } -> { x | x > a } - var postSubs = subs.Substitute(replacement, Var); - - return New(Var, postSubs); + return New(boundName, postSubs); } } } + /// + /// , bound over , with replaced + /// by where it occurs free, and the name the binder binds afterwards. + /// + /// + /// + /// Where holds the bound name, the binder takes a fresh one and keeps + /// it, as does: renamed back after the substitution, the value's + /// occurrence of the name was captured, and integral(t + a, t, 0, 1) with a := t + /// became integral(t + t, t, 0, 1), which is 1 where the integral is t + 1/2. + /// + /// + /// The body is passed over only where it can change. A body without , or + /// under a binder over itself, is returned as it is; where neither + /// nor holds the bound name, one pass gives what a + /// renaming would. Renaming it three times at every level, as every binder did, made a chain of + /// nested integrals cost a multiple of itself per level + /// (https://github.com/asc-community/AngouriMath/issues/1808). + /// + /// + private static (Entity Body, Entity Bound) SubstituteUnderABinder(Entity body, Entity bound, Entity x, Entity value) + { + if (x == bound || !body.ContainsNode(x)) + return (body, bound); + if (value.ContainsNode(bound)) + { + var fresh = Variable.CreateUnique(body + bound + x + value, bound is Variable named ? named.Name : "t"); + return (body.Substitute(bound, fresh).Substitute(x, value), fresh); + } + if (!x.ContainsNode(bound)) + return (body.Substitute(x, value), bound); + var replacement = Variable.CreateTemp((x + value + body + bound).Vars); + return (body.Substitute(bound, replacement).Substitute(x, value).Substitute(replacement, bound), bound); + } + partial record Integralf { - // TODO: it might be optimized /// public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - - // integrate(x ^ 2 + a, x) -> integrate(temp_1 ^ 2 + a, temp_1) - var tempSubstituted = Expression.Substitute(Var, replacement); + var (postSubs, boundName) = SubstituteUnderABinder(Expression, Var, x, value); - // a = 0 -> integrate(temp_1 ^ 2 + a, temp_1) -> integrate(temp_1 ^ 2 + 0, temp_1) - // x = 0 -> integrate(temp_1 ^ 2 + a, temp_1) -> integrate(temp_1 ^ 2 + a, temp_1) - var subs = tempSubstituted.Substitute(x, value); - - // integrate(temp_1 ^ 2 + a, temp_1) -> integrate(x ^ 2 + a, temp_1) - var postSubs = subs.Substitute(replacement, Var); - - return New(postSubs, Var, Range is var (from, to) ? (from.Substitute(x, value), to.Substitute(x, value)) : null); + return New(postSubs, boundName, Range is var (from, to) ? (from.Substitute(x, value), to.Substitute(x, value)) : null); } } @@ -629,11 +642,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var tempSubstituted = Expression.Substitute(Var, replacement); - var subs = tempSubstituted.Substitute(x, value); - var postSubs = subs.Substitute(replacement, Var); - return New(postSubs, Var, From.Substitute(x, value), To.Substitute(x, value)); + var (postSubs, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(postSubs, boundName, From.Substitute(x, value), To.Substitute(x, value)); } } @@ -645,9 +655,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var renamed = Expression.Substitute(Var, replacement).Substitute(x, value).Substitute(replacement, Var); - return New(renamed, Var, Over.Substitute(x, value)); + var (renamed, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(renamed, boundName, Over.Substitute(x, value)); } } @@ -659,9 +668,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var renamed = Expression.Substitute(Var, replacement).Substitute(x, value).Substitute(replacement, Var); - return New(renamed, Var, Over.Substitute(x, value)); + var (renamed, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(renamed, boundName, Over.Substitute(x, value)); } } @@ -672,9 +680,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var renamed = Expression.Substitute(Var, replacement).Substitute(x, value).Substitute(replacement, Var); - return New(renamed, Var, Over.Substitute(x, value)); + var (renamed, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(renamed, boundName, Over.Substitute(x, value)); } } @@ -685,9 +692,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var renamed = Expression.Substitute(Var, replacement).Substitute(x, value).Substitute(replacement, Var); - return New(renamed, Var, Over.Substitute(x, value)); + var (renamed, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(renamed, boundName, Over.Substitute(x, value)); } } @@ -698,9 +704,8 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var renamed = Expression.Substitute(Var, replacement).Substitute(x, value).Substitute(replacement, Var); - return New(renamed, Var, Over.Substitute(x, value)); + var (renamed, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(renamed, boundName, Over.Substitute(x, value)); } } @@ -711,61 +716,36 @@ public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - var tempSubstituted = Expression.Substitute(Var, replacement); - var subs = tempSubstituted.Substitute(x, value); - var postSubs = subs.Substitute(replacement, Var); - return New(postSubs, Var, From.Substitute(x, value), To.Substitute(x, value)); + var (postSubs, boundName) = SubstituteUnderABinder(Expression, Var, x, value); + return New(postSubs, boundName, From.Substitute(x, value), To.Substitute(x, value)); } } partial record Derivativef { - // TODO: it might be optimized /// public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - - // derive(x ^ 2 + a, x) -> derive(temp_1 ^ 2 + a, temp_1) - var tempSubstituted = Expression.Substitute(Var, replacement); - - // a = 0 -> derive(temp_1 ^ 2 + a, temp_1) -> derive(temp_1 ^ 2 + 0, temp_1) - // x = 0 -> derive(temp_1 ^ 2 + a, temp_1) -> derive(temp_1 ^ 2 + a, temp_1) - var subs = tempSubstituted.Substitute(x, value); + var (postSubs, boundName) = SubstituteUnderABinder(Expression, Var, x, value); - // derive(temp_1 ^ 2 + a, temp_1) -> derive(x ^ 2 + a, temp_1) - var postSubs = subs.Substitute(replacement, Var); - - return New(postSubs, Var); + return New(postSubs, boundName); } } partial record Limitf { - // TODO: it might be optimized /// public override Entity Substitute(Entity x, Entity value) { if (this == x) return value; - var replacement = Variable.CreateTemp((x + value + Expression + Var).Vars); - - // derive(x ^ 2 + a, x) -> derive(temp_1 ^ 2 + a, temp_1) - var tempSubstituted = Expression.Substitute(Var, replacement); - - // a = 0 -> derive(temp_1 ^ 2 + a, temp_1) -> derive(temp_1 ^ 2 + 0, temp_1) - // x = 0 -> derive(temp_1 ^ 2 + a, temp_1) -> derive(temp_1 ^ 2 + a, temp_1) - var subs = tempSubstituted.Substitute(x, value); - - // derive(temp_1 ^ 2 + a, temp_1) -> derive(x ^ 2 + a, temp_1) - var postSubs = subs.Substitute(replacement, Var); + var (postSubs, boundName) = SubstituteUnderABinder(Expression, Var, x, value); var dst = Destination.Substitute(x, value); - return New(postSubs, Var, dst, ApproachFrom); + return New(postSubs, boundName, dst, ApproachFrom); } } diff --git a/Sources/Tests/UnitTests/Core/SubstitutionUnderABinderTest.cs b/Sources/Tests/UnitTests/Core/SubstitutionUnderABinderTest.cs new file mode 100644 index 000000000..0e07e9271 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/SubstitutionUnderABinderTest.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using AngouriMath.Extensions; +using Xunit; +using static AngouriMath.Entity; + +namespace AngouriMath.Tests.Core +{ + /// + /// Substituting under a binder: what is bound is not reached, a value holding the bound name is + /// not captured, and a body the target does not occur in is not traversed again at every level. + /// #1808 + /// + [Trait("Area", "Core")] + public sealed class SubstitutionUnderABinderTest + { + private static Entity Nested(int depth, Entity upper) + { + if (depth == 0) + return 1; + var dummy = Variable.CreateVariableUnchecked($"u_{depth}"); + return new Integralf(Nested(depth - 1, dummy), dummy, (0, upper)); + } + + /// + /// One integral per level, the target in the outermost bound only. Renaming every body three + /// times at every level made this take about twice as long per level, past a few minutes by + /// twenty; it is linear in the depth now. The answer is the chain built with the replacement. + /// + [Fact] + public void AChainOfIntegralsIsSubstitutedInItsOuterBoundOnly() + { + var target = Variable.CreateVariableUnchecked("target"); + var replacement = Variable.CreateVariableUnchecked("w"); + Assert.Equal(Nested(20, replacement), Nested(20, target).Substitute(target, replacement)); + } + + [Fact] + public void TheBoundNameIsNotReached() + { + var integral = new Integralf("t^2 + a".ToEntity(), "t", (0, "b".ToEntity())); + Assert.Equal(integral, integral.Substitute("t", 5)); + Assert.Equal(integral, integral.Substitute("t^2".ToEntity(), 5)); + Assert.Equal(new Integralf("t^2 + 3".ToEntity(), "t", (0, "b".ToEntity())), integral.Substitute("a", 3)); + } + + [Fact] + public void AValueHoldingTheBoundNameIsNotCaptured() + { + var integral = new Integralf("t + a".ToEntity(), "t", (0, 1)); + var substituted = (Integralf)integral.Substitute("a", "t"); + Assert.NotEqual(Variable.CreateVariableUnchecked("t"), substituted.Var); + Assert.Equal(1.5.ToNumber(), substituted.Substitute("t", 1).EvalNumerical()); + var sum = (Summationf)new Summationf("k * a".ToEntity(), "k", 1, 3).Substitute("a", "k"); + Assert.NotEqual(Variable.CreateVariableUnchecked("k"), sum.Var); + Assert.Equal(12.ToNumber(), sum.Substitute("k", 2).EvalNumerical()); + } + + [Fact] + public void ASumIsSubstitutedOutsideItsIndex() + { + var sum = new Summationf("k * a".ToEntity(), "k", 1, "n".ToEntity()); + Assert.Equal(new Summationf("k * 2".ToEntity(), "k", 1, "n".ToEntity()), sum.Substitute("a", 2)); + Assert.Equal(new Summationf("k * a".ToEntity(), "k", 1, 4), sum.Substitute("n", 4)); + Assert.Equal(sum, sum.Substitute("k", 7)); + } + } +}