diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 4b216b673..bc986a28c 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -49,6 +49,7 @@ read first. | loud | `Compile` over a missing variable | `KeyNotFoundException` | `UncompilableNodeException` | | loud | `Expand` of a quotient of factorials | `AngouriBugException` | the expanded polynomial | | loud | parsing a `provided` in a parenthesised comma list | `NullReferenceException` | `UnhandledParseException` | +| loud | `floor(x)`, `ceil(x)`, `ceiling(x)` | `UnrecognizedFunctionParseException` | the functions | | loud | the target frameworks | `net7.0;netstandard2.0` | `netstandard2.0;net8.0;net10.0` | | **silent** | `abs(x) = c` for a negative `c` | a set of non-solutions | the empty set | @@ -1290,6 +1291,33 @@ throw. `Simplify` was never affected — it answered `1 + x` throughout. Issue [#817](https://github.com/asc-community/AngouriMath/issues/817). +### `floor` and `ceil` exist, so they no longer raise + +```csharp +"floor(x)".ToEntity(); +// was UnrecognizedFunctionParseException: floor is not a function this library has +// is floor(x) + +"floor(x) - 3 = 0".ToEntity().Solve("x"); +// was UnrecognizedFunctionParseException +// is { 3 + t_1 provided t_1 in RR and t_1 >= 0 and t_1 < 1 } +``` + +`ceiling(` is accepted as well, since that is SymPy's spelling; `Stringize` prints the short +`ceil(`. Both round toward the infinities rather than toward zero — `floor(-3/2)` is `-2` — and both +are taken componentwise on a complex argument, which is what SymPy and Mathematica do. Every value +in the test file was measured against SymPy 1.14 rather than reasoned about. + +**If you were catching `UnrecognizedFunctionParseException` around these names, that is now dead +code.** Nothing else moves: `round`, `trunc`, `min`, `max`, `gcd` and `lcm` are still refused by +name, and `floor` on its own — without a bracket — is still an ordinary variable. + +The derivative is `0 provided not x in ZZ`: flat between the integers, undefined at each of them. +SymPy leaves that derivative unevaluated; this library states the condition instead, which is the +stance `Signumf` and `Absf` already take. + +Issue [#809](https://github.com/asc-community/AngouriMath/issues/809). + ### A parse failure no longer escapes as a `NullReferenceException` ```csharp diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 876c81824..849f1b983 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -29,3 +29,8 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Core/Transformations/*.cs] file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + +# A new file in a directory whose other files predate it, so the section is on the file +# rather than the folder. +[AngouriMath/Core/Entity/Continuous/Entity.Continuous.Floors.Classes.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n diff --git a/Sources/AngouriMath/Convenience/MathS.cs b/Sources/AngouriMath/Convenience/MathS.cs index 2a6d73ed2..490f578f1 100644 --- a/Sources/AngouriMath/Convenience/MathS.cs +++ b/Sources/AngouriMath/Convenience/MathS.cs @@ -973,6 +973,63 @@ public static Integer GreatestCommonDivisor(Integer a, Integer b) [MethodImpl(MethodImplOptions.AggressiveInlining), NativeExport] public static Entity Abs(Entity a) => new Absf(a); + /// + /// The greatest integer not above . + /// + /// The argument to take the floor of. + /// The floor of the argument. + /// + /// Toward negative infinity, not toward zero: floor(-3/2) is -2. On a + /// complex argument it is taken componentwise, which is what SymPy and Mathematica + /// both do. + /// + /// + /// + /// using System; + /// using static AngouriMath.MathS; + /// + /// Console.WriteLine(Floor("3/2").Simplify()); + /// Console.WriteLine(Floor("-3/2").Simplify()); + /// Console.WriteLine(Floor("x")); + /// + /// Prints + /// + /// 1 + /// -2 + /// floor(x) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Entity Floor(Entity a) => new Floorf(a); + + /// + /// The least integer not below . + /// + /// The argument to take the ceiling of. + /// The ceiling of the argument. + /// + /// Toward positive infinity, not away from zero: ceil(-3/2) is -1. + /// Componentwise on a complex argument, as is. + /// + /// + /// + /// using System; + /// using static AngouriMath.MathS; + /// + /// Console.WriteLine(Ceil("3/2").Simplify()); + /// Console.WriteLine(Ceil("-3/2").Simplify()); + /// Console.WriteLine(Ceil("x")); + /// + /// Prints + /// + /// 2 + /// -1 + /// ceil(x) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Entity Ceil(Entity a) => new Ceilf(a); + /// Boolean negation /// Wikipedia /// Argument node of which Negation function will be taken diff --git a/Sources/AngouriMath/Core/Antlr/AngouriMath.g b/Sources/AngouriMath/Core/Antlr/AngouriMath.g index 4e9d1034d..14f86da08 100644 --- a/Sources/AngouriMath/Core/Antlr/AngouriMath.g +++ b/Sources/AngouriMath/Core/Antlr/AngouriMath.g @@ -407,15 +407,17 @@ atom returns[Entity value] | 'sign(' args = function_arguments ')' { Assert("sign", 1, $args.list.Count); $value = MathS.Signum($args.list[0]); } | 'abs(' args = function_arguments ')' { Assert("abs", 1, $args.list.Count); $value = MathS.Abs($args.list[0]); } | 'phi(' args = function_arguments ')' { Assert("phi", 1, $args.list.Count); $value = MathS.NumberTheory.Phi($args.list[0]); } + | 'floor(' args = function_arguments ')' { Assert("floor", 1, $args.list.Count); $value = MathS.Floor($args.list[0]); } + | 'ceil(' args = function_arguments ')' { Assert("ceil", 1, $args.list.Count); $value = MathS.Ceil($args.list[0]); } + /* SymPy's spelling, accepted so that an expression copied from there parses. Stringize + prints the short form, which is what the round-trip test pins. */ + | 'ceiling(' args = function_arguments ')' { Assert("ceiling", 1, $args.list.Count); $value = MathS.Ceil($args.list[0]); } /* Names the library does not have. Each is a function every other CAS spells this way, so a caller reaches for it, and without these rules each is silently read as a product -- see NotImplementedFunction above. Refusing is not the feature; it is the difference between a missing function and a wrong answer. */ - | 'floor(' args = function_arguments ')' { $value = NotImplementedFunction("floor", "rounding functions"); } - | 'ceil(' args = function_arguments ')' { $value = NotImplementedFunction("ceil", "rounding functions"); } - | 'ceiling(' args = function_arguments ')' { $value = NotImplementedFunction("ceiling", "rounding functions"); } | 'round(' args = function_arguments ')' { $value = NotImplementedFunction("round", "rounding functions"); } | 'trunc(' args = function_arguments ')' { $value = NotImplementedFunction("trunc", "rounding functions"); } | 'min(' args = function_arguments ')' { $value = NotImplementedFunction("min", "minimum or maximum function"); } diff --git a/Sources/AngouriMath/Core/Antlr/AngouriMathParser.cs b/Sources/AngouriMath/Core/Antlr/AngouriMathParser.cs index 6fb5f8e8c..23ea8b798 100644 --- a/Sources/AngouriMath/Core/Antlr/AngouriMathParser.cs +++ b/Sources/AngouriMath/Core/Antlr/AngouriMathParser.cs @@ -3064,7 +3064,7 @@ public AtomContext atom() { _localctx.args = function_arguments(); State = 806; Match(T__40); - _localctx.value = NotImplementedFunction("floor", "rounding functions"); + Assert("floor", 1, _localctx.args.list.Count); _localctx.value = MathS.Floor(_localctx.args.list[0]); } break; case 103: @@ -3076,7 +3076,7 @@ public AtomContext atom() { _localctx.args = function_arguments(); State = 811; Match(T__40); - _localctx.value = NotImplementedFunction("ceil", "rounding functions"); + Assert("ceil", 1, _localctx.args.list.Count); _localctx.value = MathS.Ceil(_localctx.args.list[0]); } break; case 104: @@ -3088,7 +3088,7 @@ public AtomContext atom() { _localctx.args = function_arguments(); State = 816; Match(T__40); - _localctx.value = NotImplementedFunction("ceiling", "rounding functions"); + Assert("ceiling", 1, _localctx.args.list.Count); _localctx.value = MathS.Ceil(_localctx.args.list[0]); } break; case 105: diff --git a/Sources/AngouriMath/Core/Domains.Classes.cs b/Sources/AngouriMath/Core/Domains.Classes.cs index f3cd6f220..982f4bbf7 100644 --- a/Sources/AngouriMath/Core/Domains.Classes.cs +++ b/Sources/AngouriMath/Core/Domains.Classes.cs @@ -200,6 +200,21 @@ partial record Absf public override Domain Codomain { get; protected init; } = Domain.Real; } + partial record Floorf + { + // Complex rather than Integer: taken componentwise, floor of a complex number + // is a Gaussian integer, and there is no domain for those. The codomain of the + // real case is stated by the value, not by this. + /// + public override Domain Codomain { get; protected init; } = Domain.Complex; + } + + partial record Ceilf + { + /// + public override Domain Codomain { get; protected init; } = Domain.Complex; + } + partial record Boolean { /// diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Definition.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Definition.cs index 55f1cfe88..3e58b7d80 100644 --- a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Definition.cs @@ -127,5 +127,9 @@ public abstract partial record CalculusOperator(Entity Expression, Entity Var) : public Entity Signum() => new Signumf(this); /// public Entity Abs() => new Absf(this); + /// + public Entity Floor() => new Floorf(this); + /// + public Entity Ceil() => new Ceilf(this); } } diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Floors.Classes.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Floors.Classes.cs new file mode 100644 index 000000000..05f94f543 --- /dev/null +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Floors.Classes.cs @@ -0,0 +1,56 @@ +// +// 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; + +namespace AngouriMath +{ + partial record Entity + { +#pragma warning disable CS1591 // only while records' parameters cannot be documented + /// + /// A node of floor: the greatest integer not above the argument. + /// + /// + /// On a complex argument it is taken componentwise, so that + /// floor(3/2 + 5/2i) is 1 + 2i. That is what SymPy and Mathematica + /// both do, and it is the only reading under which floor of a real number + /// keeps its meaning when the imaginary part happens to be zero. + /// + public sealed partial record Floorf(Entity Argument) : Function, IUnaryNode + { + public Entity NodeChild => Argument; + + private Floorf New(Entity arg) => + ReferenceEquals(Argument, arg) ? this : new(arg); + /// + public override Entity Replace(Func func) => func(New(Argument.Replace(func))); + /// + protected override Entity[] InitDirectChildren() => new[] { Argument }; + } + + /// + /// A node of ceil: the least integer not below the argument. + /// + /// + /// Componentwise on a complex argument, for the same reason as + /// . + /// + public sealed partial record Ceilf(Entity Argument) : Function, IUnaryNode + { + public Entity NodeChild => Argument; + + private Ceilf New(Entity arg) => + ReferenceEquals(Argument, arg) ? this : new(arg); + /// + public override Entity Replace(Func func) => func(New(Argument.Replace(func))); + /// + protected override Entity[] InitDirectChildren() => new[] { Argument }; + } +#pragma warning restore CS1591 // only while records' parameters cannot be documented + } +} diff --git a/Sources/AngouriMath/Functions/Continuous/Differentiation.cs b/Sources/AngouriMath/Functions/Continuous/Differentiation.cs index bef6ba8df..f8a465ec5 100644 --- a/Sources/AngouriMath/Functions/Continuous/Differentiation.cs +++ b/Sources/AngouriMath/Functions/Continuous/Differentiation.cs @@ -341,6 +341,25 @@ protected override Entity InnerDifferentiate(Variable variable) => MathS.Signum(Argument).Provided(!Argument.EqualTo(Integer.Zero)) * Argument.InnerDifferentiate(variable); } + partial record Floorf + { + // Flat between consecutive integers and discontinuous at each of them, so the + // derivative is 0 wherever it exists and nowhere at an integer argument. Saying + // that with a condition is the stance Signumf and Absf take above; SymPy + // instead leaves the derivative unevaluated, which says less, and this library + // has Providedf to say more with. + /// + protected override Entity InnerDifferentiate(Variable variable) + => Integer.Zero.Provided(!Argument.In(MathS.Sets.Z)); + } + + partial record Ceilf + { + /// + protected override Entity InnerDifferentiate(Variable variable) + => Integer.Zero.Provided(!Argument.In(MathS.Sets.Z)); + } + partial record Providedf { /// diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs index bee882dda..044692da2 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/InvertNode.Classes.cs @@ -273,6 +273,42 @@ private protected override IEnumerable InvertNode(Entity value, Entity x } } + partial record Floorf + { + // floor(f(x)) = value + // solvable only where value is an integer, and then f(x) is anywhere in the + // half-open interval [value, value + 1) -- so the preimage is a parameter t in + // [0, 1) rather than a point, which is the same device Signumf and Absf use for + // their own many-to-one inverses. + private protected override IEnumerable InvertNode(Entity value, Entity x) + { + var t = Variable.CreateUnique(value + Argument, "t"); + return Argument.Invert(value + t, x) + .Select(c => c.Provided( + value.In(MathS.Sets.Z) + & t.In(MathS.Sets.R) + & new GreaterOrEqualf(t, 0) + & new Lessf(t, 1))); + } + } + + partial record Ceilf + { + // ceil(f(x)) = value + // likewise integer-valued, with f(x) in (value - 1, value] -- so the parameter + // is subtracted rather than added. + private protected override IEnumerable InvertNode(Entity value, Entity x) + { + var t = Variable.CreateUnique(value + Argument, "t"); + return Argument.Invert(value - t, x) + .Select(c => c.Provided( + value.In(MathS.Sets.Z) + & t.In(MathS.Sets.R) + & new GreaterOrEqualf(t, 0) + & new Lessf(t, 1))); + } + } + partial record Boolean { private protected override IEnumerable InvertNode(Entity value, Entity x) diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs index 4e8d8c827..1cdfb8308 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs @@ -266,6 +266,51 @@ protected override Entity InnerSimplify(bool isExact) (@this, a) => ((Signumf)@this).New(a), isExact); } + public partial record Floorf + { + // Defined everywhere in the complex plane, taken componentwise. + private protected override Entity IntrinsicCondition => Boolean.True; + + /// + protected override Entity InnerSimplify(bool isExact) + => ExpandOnOneArgument(Argument, + a => a switch + { + // An integer is already its own floor, and it stays exact. + Integer n => n, + Rational n => Integer.Create(n.EDecimal.Floor().ToEInteger()), + Real n when !isExact => Integer.Create(n.EDecimal.Floor().ToEInteger()), + Complex n when !isExact => Complex.Create( + n.RealPart.EDecimal.Floor(), n.ImaginaryPart.EDecimal.Floor()), + // Idempotent: the floor of an integer is that integer, and floor + // always produces one. + Floorf or Ceilf => a, + _ => null + }, + (@this, a) => ((Floorf)@this).New(a), isExact); + } + + public partial record Ceilf + { + // Defined everywhere in the complex plane, taken componentwise. + private protected override Entity IntrinsicCondition => Boolean.True; + + /// + protected override Entity InnerSimplify(bool isExact) + => ExpandOnOneArgument(Argument, + a => a switch + { + Integer n => n, + Rational n => Integer.Create(n.EDecimal.Ceiling().ToEInteger()), + Real n when !isExact => Integer.Create(n.EDecimal.Ceiling().ToEInteger()), + Complex n when !isExact => Complex.Create( + n.RealPart.EDecimal.Ceiling(), n.ImaginaryPart.EDecimal.Ceiling()), + Floorf or Ceilf => a, + _ => null + }, + (@this, a) => ((Ceilf)@this).New(a), isExact); + } + public partial record Absf { // Absolute value is defined everywhere in the complex plane diff --git a/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs index 47f354f20..1a3348501 100644 --- a/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs @@ -158,6 +158,20 @@ public override string Latexise() => $@"\left|{Argument.Latexise()}\right|"; } + partial record Floorf + { + /// + public override string Latexise() + => $@"\left\lfloor{{{Argument.Latexise()}}}\right\rfloor"; + } + + partial record Ceilf + { + /// + public override string Latexise() + => $@"\left\lceil{{{Argument.Latexise()}}}\right\rceil"; + } + partial record Phif { /// diff --git a/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs index d9211893b..2c29467f5 100644 --- a/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs @@ -109,6 +109,22 @@ public partial record Absf public override string ToString() => Stringize(); } + public partial record Floorf + { + /// + public override string Stringize() => $"floor({Argument.Stringize()})"; + /// + public override string ToString() => Stringize(); + } + + public partial record Ceilf + { + /// + public override string Stringize() => $"ceil({Argument.Stringize()})"; + /// + public override string ToString() => Stringize(); + } + public partial record Factorialf { /// diff --git a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs index 7079c1544..1bbc32fec 100644 --- a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs @@ -64,6 +64,19 @@ internal override string ToSymPy() => $@"sympy.Abs({Argument.ToSymPy()})"; } + public partial record Floorf + { + internal override string ToSymPy() + => $@"sympy.floor({Argument.ToSymPy()})"; + } + + public partial record Ceilf + { + // SymPy spells it in full; `ceil` is not a name it has. + internal override string ToSymPy() + => $@"sympy.ceiling({Argument.ToSymPy()})"; + } + public partial record Phif { internal override string ToSymPy() => $"sympy.totient({Argument.ToSymPy()})"; diff --git a/Sources/AngouriMath/Functions/Substitute.cs b/Sources/AngouriMath/Functions/Substitute.cs index 7d9ed6c48..1627407a3 100644 --- a/Sources/AngouriMath/Functions/Substitute.cs +++ b/Sources/AngouriMath/Functions/Substitute.cs @@ -172,6 +172,20 @@ public override Entity Substitute(Entity x, Entity value) => this == x ? value : New(Argument.Substitute(x, value)); } + partial record Floorf + { + /// + public override Entity Substitute(Entity x, Entity value) + => this == x ? value : New(Argument.Substitute(x, value)); + } + + partial record Ceilf + { + /// + public override Entity Substitute(Entity x, Entity value) + => this == x ? value : New(Argument.Substitute(x, value)); + } + partial record Boolean { /// diff --git a/Sources/AngouriMath/Functions/TreeAnalyzer/Sort.Classes.cs b/Sources/AngouriMath/Functions/TreeAnalyzer/Sort.Classes.cs index 9e682a28c..d6eddeae8 100644 --- a/Sources/AngouriMath/Functions/TreeAnalyzer/Sort.Classes.cs +++ b/Sources/AngouriMath/Functions/TreeAnalyzer/Sort.Classes.cs @@ -203,6 +203,18 @@ private protected override string SortHashName(SortLevel level) => Choice(level, "sgnabs_", "abs_", "abs_"); } + public partial record Floorf + { + private protected override string SortHashName(SortLevel level) + => Choice(level, "floorceil_", "floor_", "floor_"); + } + + public partial record Ceilf + { + private protected override string SortHashName(SortLevel level) + => Choice(level, "floorceil_", "ceil_", "ceil_"); + } + partial record Boolean { private protected override string SortHashName(SortLevel level) diff --git a/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs b/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs new file mode 100644 index 000000000..c2c5ccf94 --- /dev/null +++ b/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs @@ -0,0 +1,136 @@ +// +// 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; + +namespace AngouriMath.Tests.Convenience +{ + /// + /// floor and ceil#809. + /// + /// + /// The expected values are SymPy 1.14's, measured rather than reasoned about, as + /// AGENTS.md asks when a convention has to be chosen. The ones that matter are the + /// negative arguments — rounding toward the infinities rather than toward zero — and the + /// complex case, which both SymPy and Mathematica take componentwise. + /// + [Trait("Area", "Convenience")] + public sealed class FloorCeilTest + { + [Theory] + [InlineData("floor(3/2)", 1)] + [InlineData("floor(-3/2)", -2)] + [InlineData("floor(-1/2)", -1)] + [InlineData("floor(5/2)", 2)] + [InlineData("floor(-5/2)", -3)] + [InlineData("floor(2)", 2)] + [InlineData("floor(-2)", -2)] + [InlineData("ceil(3/2)", 2)] + [InlineData("ceil(-3/2)", -1)] + [InlineData("ceil(-1/2)", 0)] + [InlineData("ceil(5/2)", 3)] + [InlineData("ceil(-5/2)", -2)] + [InlineData("ceil(2)", 2)] + [InlineData("ceil(-2)", -2)] + public void TheValueIsWhatSymPyGives(string input, int expected) + => Assert.Equal(Entity.Number.Integer.Create(expected), input.ToEntity().Simplify()); + + // Componentwise, so that floor of a real keeps its meaning when the imaginary part + // happens to be zero. + [Theory] + [InlineData("floor(3/2 + 5/2 * i)", "1 + 2i")] + [InlineData("ceil(3/2 + 5/2 * i)", "2 + 3i")] + [InlineData("floor(-3/2 - 5/2 * i)", "-2 - 3i")] + [InlineData("ceil(-3/2 - 5/2 * i)", "-1 - 2i")] + public void AComplexArgumentIsTakenComponentwise(string input, string expected) + => Assert.Equal(expected.ToEntity().EvalNumerical(), input.ToEntity().EvalNumerical()); + + // Both produce an integer, and both leave one alone. + [Theory] + [InlineData("floor(floor(x))", "floor(x)")] + [InlineData("ceil(ceil(x))", "ceil(x)")] + [InlineData("floor(ceil(x))", "ceil(x)")] + [InlineData("ceil(floor(x))", "floor(x)")] + public void ApplyingItTwiceChangesNothing(string input, string expected) + => Assert.Equal(expected.ToEntity(), input.ToEntity().Simplify()); + + [Theory] + [InlineData("floor(x)")] + [InlineData("ceil(x)")] + [InlineData("floor(x + y)")] + [InlineData("ceil(sin(x))")] + public void ASymbolicArgumentIsLeftAlone(string input) + => Assert.Equal(input.ToEntity(), input.ToEntity().Simplify()); + + // Flat between the integers and discontinuous at each of them, so the derivative is + // zero where it exists and the condition says where that is. SymPy declines to + // answer at all here; this library can say more, because it has Providedf. + [Theory] + [InlineData("floor(x)")] + [InlineData("ceil(x)")] + public void TheDerivativeIsZeroAwayFromTheIntegers(string input) + { + var derivative = input.ToEntity().Differentiate("x"); + var provided = Assert.IsType(derivative); + Assert.Equal(Entity.Number.Integer.Create(0), provided.Expression); + } + + [Theory] + [InlineData("floor(x)", @"\left\lfloor{x}\right\rfloor")] + [InlineData("ceil(x)", @"\left\lceil{x}\right\rceil")] + public void TheLatexIsTheUsualBrackets(string input, string expected) + => Assert.Equal(expected, input.ToEntity().Latexise()); + + // Stringize prints the short spelling, and SymPy's `ceiling` is accepted on the way + // in so that an expression copied from there parses. + [Theory] + [InlineData("floor(x)", "floor(x)")] + [InlineData("ceil(x)", "ceil(x)")] + [InlineData("ceiling(x)", "ceil(x)")] + public void ItPrintsTheShortSpellingAndParsesBoth(string input, string expected) + => Assert.Equal(expected, input.ToEntity().Stringize()); + + [Theory] + [InlineData("floor(x)")] + [InlineData("ceil(x)")] + [InlineData("floor(x) + ceil(y)")] + public void ThePrintedFormParsesBackToTheSameExpression(string input) + => Assert.Equal(input.ToEntity(), input.ToEntity().Stringize().ToEntity()); + + [Theory] + [InlineData("floor(x)", "7/2", 3)] + [InlineData("ceil(x)", "7/2", 4)] + [InlineData("floor(x)", "-7/2", -4)] + [InlineData("ceil(x)", "-7/2", -3)] + public void SubstitutingReachesTheValue(string input, string at, int expected) + => Assert.Equal(Entity.Number.Integer.Create(expected), + input.ToEntity().Substitute("x", at.ToEntity()).Simplify()); + + /// + /// floor(x) = 3 holds on the whole of [3, 4), so the inverse is a + /// parameter over that interval rather than a point. What it must not do is invent a + /// single root. + /// + [Fact] + public void SolvingGivesTheWholeIntervalAndNotAPoint() + { + var solutions = "floor(x) - 3 = 0".ToEntity().Solve("x"); + Assert.NotNull(solutions); + var conditions = solutions!.Nodes.OfType().ToList(); + Assert.NotEmpty(conditions); + + // Every point of [3, 4) is a solution, and 4 is not. + foreach (var point in new[] { "3", "3.5", "39/10" }) + Assert.Equal(Entity.Number.Integer.Create(3), + $"floor({point})".ToEntity().Simplify()); + Assert.Equal(Entity.Number.Integer.Create(4), "floor(4)".ToEntity().Simplify()); + } + } +} diff --git a/Sources/Tests/UnitTests/Convenience/MissingFunctionNamesRefusedTest.cs b/Sources/Tests/UnitTests/Convenience/MissingFunctionNamesRefusedTest.cs index bbde307fd..584c6d65a 100644 --- a/Sources/Tests/UnitTests/Convenience/MissingFunctionNamesRefusedTest.cs +++ b/Sources/Tests/UnitTests/Convenience/MissingFunctionNamesRefusedTest.cs @@ -26,10 +26,9 @@ namespace AngouriMath.Tests.Convenience [Trait("Area", "Convenience")] public sealed class MissingFunctionNamesRefusedTest { + // floor, ceil and ceiling were on this list until they were implemented: + // https://github.com/asc-community/AngouriMath/issues/809 [Theory] - [InlineData("floor(x)")] - [InlineData("ceil(x)")] - [InlineData("ceiling(x)")] [InlineData("round(x)")] [InlineData("trunc(x)")] [InlineData("erf(x)")] @@ -46,7 +45,7 @@ public void ANameTheLibraryDoesNotHaveIsRefused(string written) => /// cannot otherwise tell what happened to their expression. /// [Theory] - [InlineData("floor(x)", "floor")] + [InlineData("round(x)", "round")] [InlineData("gcd(x, y)", "gcd")] [InlineData("conjugate(x)", "conjugate")] public void TheRefusalNamesTheFunction(string written, string name) => @@ -63,11 +62,22 @@ public void TheRefusalNamesTheFunction(string written, string name) => [InlineData("min(x)")] [InlineData("min(x, y)")] [InlineData("min(x, y, z)")] - [InlineData("floor(x)")] - [InlineData("floor(x, y)")] + [InlineData("round(x)")] + [InlineData("round(x, y)")] public void TheArgumentCountDoesNotDecideWhetherItIsReported(string written) => Assert.Throws(() => written.ToEntity()); + /// + /// A name the library does have, called with the wrong number of arguments, is + /// a different complaint and says so -- the count is the problem, not the name. + /// + [Theory] + [InlineData("floor(x, y)", "floor")] + [InlineData("ceil(x, y)", "ceil")] + public void AnImplementedNameWithTheWrongArgumentCountSaysThat(string written, string name) + => Assert.Contains(name, + Assert.Throws(() => written.ToEntity()).Message); + /// /// What the defect actually cost, and the reason this is worth a parse error rather /// than being left as a feature request: the solver answered confidently and wrongly. @@ -76,8 +86,24 @@ public void TheArgumentCountDoesNotDecideWhetherItIsReported(string written) => public void TheEquationThatUsedToBeAnsweredWithNonsenseNowSaysWhyItCannotBe() { var thrown = Assert.Throws( - () => "floor(x) - 3 = 0".ToEntity().Solve("x")); - Assert.Contains("floor", thrown.Message); + () => "round(x) - 3 = 0".ToEntity().Solve("x")); + Assert.Contains("round", thrown.Message); + } + + /// + /// And the original expression from #733 is now answered rather than refused, since + /// floor exists. floor(x) = 3 holds on the whole of [3, 4), so the + /// answer is that interval carried as a parameter and not a single point -- what it + /// must never be again is { 3 / floor }. + /// https://github.com/asc-community/AngouriMath/issues/809 + /// + [Fact] + public void TheEquationFromTheOriginalReportIsAnsweredNow() + { + var solutions = "floor(x) - 3 = 0".ToEntity().Solve("x"); + Assert.NotNull(solutions); + Assert.DoesNotContain(solutions!.Nodes, node => node is Entity.Variable { Name: "floor" }); + Assert.Contains(solutions.Nodes, node => node is Entity.Providedf); } /// @@ -114,6 +140,9 @@ public void EverythingElseParsesAsItDid(string written, string expected) => [InlineData("sin(x)")] [InlineData("arsinh(x)")] [InlineData("phi(x)")] + [InlineData("floor(x)")] + [InlineData("ceil(x)")] + [InlineData("ceiling(x)")] public void TheNamesTheLibraryHasStillParse(string written) => Assert.NotNull(written.ToEntity());