From 8963256ac70038f5cc743ae22d285cef2f7c08a4 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 15:05:51 +0000 Subject: [PATCH] An equation in a remainder is solved to its whole family f mod a = r has one solution per period. It is now inverted the way the trigonometric functions are, with a whole parameter: f = r + |a| n where r is a value the floored remainder takes -- [0, a) for a positive a, (a, 0] for a negative one -- and no solution where it is not. (x mod 4) + 1 = 3 is { 2 + 4 n_1 }, where 2.5.0 answered { }; (2x + 1) mod 4 = 3 is every odd number, where 2.5.0 listed six of them. A divisor or value that is not a number is left unsolved. Closes #1629. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 38 ++++++-- .../EquationSolver/InvertNode.Classes.cs | 28 +++++- .../AnUnwrittenInverseIsNotTheEmptySetTest.cs | 5 +- .../SolveTest/RemainderEquationTest.cs | 90 +++++++++++++++++++ .../UnitTests/Convenience/ModulusTest.cs | 16 ++-- 5 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 Sources/Tests/UnitTests/Algebra/SolveTest/RemainderEquationTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index e4bc750a0..61a1498eb 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -845,6 +845,27 @@ and `13` is not, every `n` from `24` is `4 a + 9 b` and `23` is not | `exists a in ZZ* : exists b in ZZ* : 13 = 3 a + 8 b` | `UnhandledParseException` (left as written when quantifiers arrived) | `False` | | `forall n in ZZ : n >= 14 implies (exists a in ZZ* : exists b in ZZ* : n = 3 a + 8 b)` | `UnhandledParseException` (left as written when quantifiers arrived) | `True` | +### An equation in a remainder is solved to its whole family + +`f mod a = r` has one solution per period, and the solver had no way to write that. On 2.5.0 +`(x mod 4) + 1 = 3` answered `{ }` -- no solution, for an equation with infinitely many. Where +the numeric fallback reached it, it answered with the few solutions it found in its window: +`(2x + 1) mod 4 = 3` was `{ -5, -3, -1, 1, 3, 5 }`, and 7 is a solution as well. The remainder +is now inverted the way the trigonometric functions are, with a whole parameter: `f = r + a n` +where `r` is a value the floored remainder takes -- `[0, a)` for a positive `a`, `(a, 0]` for a +negative one -- and no solution where it is not. A divisor or value that is not a number is left +unsolved. +[#1629](https://github.com/asc-community/AngouriMath/issues/1629), Algebrite's issue 87 from +[#180](https://github.com/asc-community/AngouriMath/issues/180). Both columns measured on a build, +`v2.5.0` against this change. + +| `"….".ToEntity().Solve("x")` of | Was (2.5.0) | Is | +|---|---|---| +| `(x mod 4) + 1 = 3` | `{ }` | `{ 2 + 4 * n_1 }` | +| `(2x + 1) mod 4 = 3` | `{ -5, -3, -1, 1, 3, 5 }`, six of infinitely many | `{ (3 + 4 * n_1 - 1) / 2 }`, every odd number | +| `x mod (-4) = -1` | `{ }` | `{ -1 + 4 * n_1 }` | +| `x mod 4 = 5` | `{ }` | `{ }` (unchanged: modulo 4 the remainder never takes 5) | + ### The modulus of a whole number is a whole number With the facts in scope making `s` a whole number, `|s| in ZZ` and `|s| in ZZ*` are `True`, and @@ -2411,18 +2432,19 @@ now has no value, as the interpreter's has none. ### An equation the solver cannot invert is left unsolved, not answered with no roots `x! = 6` was answered `{ }`, a claim that it has no roots, and it has 3. The solver isolates `x` -by inverting the function around it, and for a factorial, a binomial coefficient, `mod`, `gcd`, -`lcm`, `min`, `max`, `phi`, `prime`, the valuation, a sum, a product, a limit, a set with `x` -inside it and a few more, the inversion had no way to write the preimage and returned none. Such -an equation is now left unsolved, as the set of `x` for which it holds, the way a statement -the solver has no arm for already was. Roots found beside it are kept. A value these functions -provably never take still has no roots: the factorial is the gamma function one along, which -has no zeros, so `x! = 0` is still `{ }`, and so is `arcsin(x) = 5`. +by inverting the function around it, and for a factorial, a binomial coefficient, `mod` by a +divisor that is not a number, `gcd`, `lcm`, `min`, `max`, `phi`, `prime`, the valuation, a sum, +a product, a limit, a set with `x` inside it and a few more, the inversion had no way to write +the preimage and returned none. Such an equation is now left unsolved, as the set of `x` for +which it holds, the way a statement the solver has no arm for already was. Roots found beside it +are kept. A value these functions provably never take still has no roots: the factorial is the +gamma function one along, which has no zeros, so `x! = 0` is still `{ }`, and so is +`arcsin(x) = 5`. | Input | Was (2.5.0) | Now | |---|---|---| | `"x! = 6".Solve("x")` | `{ }` | `{ x : x! = 6 }` | -| `"x mod 3 = 1".Solve("x")` | `{ }` | `{ x : x mod 3 = 1 }` | +| `"x mod a = 1".Solve("x")` | `{ }` | `{ x : x mod a = 1 }` | | `"gcd(x, 4) = 2".Solve("x")` | `{ }` | `{ x : gcd(x, 4) = 2 }` | | `"max(x, 1) = 3".Solve("x")` | `{ }` | `{ x : max(x, 1) = 3 }` | | `"phi(x) = 4".Solve("x")` | `{ }` | `{ x : phi(x) = 4 }` | diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs index 9f84d96cf..a81fe9cba 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs @@ -69,11 +69,31 @@ partial record Divf partial record Modf { - // x % a = value has one solution per period, so inverting it means introducing - // an integer parameter the way the trigonometric inversions do. Until that is - // written the equation is left unsolved: no solutions would claim it has none. + // x mod a = value has one solution per period, and is solved the way the + // trigonometric inversions are, with a whole parameter n. The remainder is the + // floored one, taking the sign of the divisor -- -1 mod 4 is 3, 5 mod (-4) is -3 and + // 5/2 mod 2 is 1/2 -- so it takes each value in [0, a) once per period for a positive + // a, and each in (a, 0] for a negative one. So x = value + |a| n for every whole n + // where value is in that range -- the same family as value + a n, since n runs over + // every whole number -- and there is no x where it is not: (x mod 4) + 1 = 3 is + // x = 2 + 4 n, the congruence x = 2 (mod 4) written out. A divisor with x in it, or a + // divisor or value that is not a number, leaves the equation unsolved, which is not + // the same as claiming it has no solution. + // https://github.com/asc-community/AngouriMath/issues/1629 private protected override IEnumerable? InvertNode(Entity value, Entity x) - => null; + { + if (Divisor.ContainsNode(x) + || Divisor.Evaled is not Real { IsFinite: true, IsZero: false } period + || value.Evaled is not Real { IsFinite: true } remainder) + return null; + var inRange = period.IsNegative + ? (remainder.IsNegative || remainder.IsZero) && remainder > period + : !remainder.IsNegative && remainder < period; + if (!inRange) + return Enumerable.Empty(); + var step = period.IsNegative ? (-Divisor).InnerSimplified : Divisor; + return Dividend.Invert(value + step * Variable.CreateUnique(this + value, "n"), x); + } } partial record Powf diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/AnUnwrittenInverseIsNotTheEmptySetTest.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/AnUnwrittenInverseIsNotTheEmptySetTest.cs index 1cf765add..8c5143dd4 100644 --- a/Sources/Tests/UnitTests/Algebra/SolveTest/AnUnwrittenInverseIsNotTheEmptySetTest.cs +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/AnUnwrittenInverseIsNotTheEmptySetTest.cs @@ -15,13 +15,15 @@ namespace AngouriMath.Tests.Algebra.SolveTest { /// /// An equation in a function whose preimage the inverter cannot write -- a factorial, a - /// binomial coefficient, a residue, a gcd -- was answered with the empty set, a claim that + /// binomial coefficient, a gcd -- was answered with the empty set, a claim that /// it has no roots. x! = 6 has the root 3. Such an equation is now left unsolved, as /// the set of x for which it holds. /// /// /// These assert what the answers mean -- which values are in the set -- rather than the /// shape they are written in, so that solving one of them properly later does not fail them. + /// A solution written as a family in a whole parameter, as a remainder's is, is checked at + /// values of the parameter instead, since membership does not range over it. /// [Trait("Area", "Algebra")] public sealed class AnUnwrittenInverseIsNotTheEmptySetTest @@ -33,7 +35,6 @@ public sealed class AnUnwrittenInverseIsNotTheEmptySetTest [Theory] [InlineData("x! = 6", "3", "2")] [InlineData("binomial(x, 2) = 3", "-2", "2")] - [InlineData("x mod 3 = 1", "4", "3")] [InlineData("gcd(x, 4) = 2", "6", "8")] [InlineData("max(x, 1) = 3", "3", "1")] [InlineData("prime(x) = 7", "4", "3")] diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/RemainderEquationTest.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/RemainderEquationTest.cs new file mode 100644 index 000000000..db521a74a --- /dev/null +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/RemainderEquationTest.cs @@ -0,0 +1,90 @@ +// +// 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 System.Linq; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; +using static AngouriMath.Entity; + +namespace AngouriMath.Tests.Algebra +{ + /// + /// An equation in a remainder, f mod a = r, solved as the congruence it is: + /// f = r + a n for every whole n, where r is a value the floored remainder + /// takes, and no solution where it is not. Algebrite's issue 87, from #180. + /// https://github.com/asc-community/AngouriMath/issues/1629 + /// + [Trait("Area", "Algebra")] + public sealed class RemainderEquationTest + { + /// Every member of the family satisfies the equation, at several values of its parameter. + [Theory] + [InlineData("(x mod 4) + 1 = 3")] + [InlineData("x mod 4 = 0")] + [InlineData("x mod (-4) = -1")] + [InlineData("(2x + 1) mod 4 = 3")] + [InlineData("x mod 2 = 1/2")] + public void EverySolutionSatisfiesIt(string equation) + { + var solutions = Assert.IsType(equation.ToEntity().Solve("x")); + Assert.NotEmpty(solutions.Elements); + foreach (var solution in solutions.Elements) + { + var parameter = Assert.Single(solution.Vars); + for (var n = -2; n <= 2; n++) + Assert.Equal(Boolean.True, equation.ToEntity().Substitute("x", solution.Substitute(parameter, n)).Evaled); + } + } + + /// + /// And the family is all of them: x mod 4 = 2 holds exactly when x - 2 is a + /// multiple of 4, so at n from -2 to 2 the family is -6, -2, 2, 6 and 10. + /// + [Fact] + public void TheFamilyIsEverySolution() + { + var solution = Assert.Single(Assert.IsType("(x mod 4) + 1 = 3".ToEntity().Solve("x")).Elements); + var parameter = Assert.Single(solution.Vars); + Assert.Equal(new Entity[] { -6, -2, 2, 6, 10 }, + Enumerable.Range(-2, 5).Select(n => solution.Substitute(parameter, n).Evaled).ToArray()); + } + + /// + /// A value solves it exactly when the family reaches it at a whole parameter: for + /// x mod 3 = 1, 4 is reached at n = 1, and 3 only at n = 2/3. + /// + [Theory] + [InlineData("4", true)] + [InlineData("3", false)] + public void AValueSolvesItWhenTheFamilyReachesItAtAWholeParameter(string value, bool solves) + { + var solution = Assert.Single(Assert.IsType("x mod 3 = 1".ToEntity().Solve("x")).Elements); + var parameter = Assert.Single(solution.Vars); + var at = Assert.Single(Assert.IsType(solution.Equalizes(value.ToEntity()).Solve(parameter)).Elements); + Assert.Equal(solves, at.Evaled is Number.Integer); + } + + /// + /// A value the remainder never takes has no solution: modulo 4 it lies in [0, 4), modulo + /// -4 in (-4, 0]. + /// + [Theory] + [InlineData("x mod 4 = 5")] + [InlineData("x mod 4 = -1")] + [InlineData("x mod (-4) = 1")] + public void AValueTheRemainderNeverTakesHasNoSolution(string equation) + => Assert.Empty(Assert.IsType(equation.ToEntity().Solve("x")).Elements); + + /// A divisor or a value that is not a number leaves it unsolved, which is not the same as no solution. + [Theory] + [InlineData("x mod a = 1")] + [InlineData("x mod 4 = y")] + public void WhatItCannotReadIsLeftUnsolved(string equation) + => Assert.IsType(equation.ToEntity().Solve("x")); + } +} diff --git a/Sources/Tests/UnitTests/Convenience/ModulusTest.cs b/Sources/Tests/UnitTests/Convenience/ModulusTest.cs index 6d287e282..cf4a6b23a 100644 --- a/Sources/Tests/UnitTests/Convenience/ModulusTest.cs +++ b/Sources/Tests/UnitTests/Convenience/ModulusTest.cs @@ -195,14 +195,16 @@ public void TheLinqCompilerKeepsTheFractionalPart() => Assert.Equal(1.5, "x mod y".ToEntity().Compile("x", "y")(7.5, 2), 9); /// - /// x mod a = value has one solution per period, so answering it means introducing an - /// integer parameter the way the trigonometric inversions do. Until that is written the - /// equation is left unsolved, as the set of x for which it holds. This asserted the - /// empty set, which claims there is no such x, and 4 is one. Pinned so that whoever - /// writes the inversion sees this change. + /// x mod a = value has one solution per period, and is answered the way the + /// trigonometric inversions are, with a whole parameter: x mod 3 = 1 is x = 1 + 3 n, + /// which is 4 at n = 1. https://github.com/asc-community/AngouriMath/issues/1629 /// [Fact] - public void SolvingIsNotClaimed() => - Assert.IsType(("x mod 3".ToEntity() - 1).SolveEquation("x")); + public void SolvingGivesEverySolution() + { + var solution = Assert.Single(Assert.IsType(("x mod 3".ToEntity() - 1).SolveEquation("x")).Elements); + var parameter = Assert.Single(solution.Vars); + Assert.Equal((Entity)4, solution.Substitute(parameter, 1).Evaled); + } } }