Skip to content

Commit 9923e4e

Browse files
The prompt rebinds the operators it says it rebinds (#1133) (#1134)
* The prompt rebinds the operators it says it rebinds The Terminal opened `AggressiveOperators`, which rebinds `+ - * / **` *and* `= <> < > <= >=`. The wiki says it rebinds five: "By default it applies aggressive operators which override the `+`, `-`, `*`, `/`, `**` operators for any pair of object. This is not an F# console, but AngouriMath terminal, after all." The comparisons are not in that sentence and never were. What they cost is that `if`, `while`, `List.filter` and a `match` guard all want a `bool` and were getting an `Entity`, so a large part of ordinary F# did not typecheck at a prompt whose purpose is to be typed at. Measured, with and without AngouriMath loaded on the same kernel: `if 1 < 2 then 1 else 0`, `let b: bool = 1 < 2`, `[1;2;3] |> List.filter (fun x -> x > 1)` and an active pattern all failed on the comparison. So the Terminal opens `ArithmeticOperators`, which is the five the documentation promises. `AggressiveOperators` is untouched: it is public surface, its comparison overloads are deliberate and tested, and somebody who wants them opens it at the prompt in one line. That was measured too -- `x > 0` is an ordinary comparison until that line and an inequality after it. **What this does not fix, which is the honest half.** Rebinding arithmetic has the same shape of cost, and it is the cost the tool exists to pay: once `+` and `*` build expressions, a function whose branches mix an `Entity` with an `int` does not typecheck, and `List.sum` over expressions has no `+` it can use. `let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)` still does not compile. It fails on the arithmetic now rather than on the comparison, and #1133 used it as the headline example, so it is pinned as a test with a remark saying so rather than left to be discovered as an overclaim. Narrowing bought back the part that was costing something and buying nothing. Five of the seven cases in the issue compile now; the two that do not are the trade the tool makes. 41 terminal tests pass, and `AggressiveOperators`' own 18 are unchanged. Part of #1133. * fib compiles, once a session says which reading of the operators it wants The previous commit narrowed what the prompt rebinds and left `fib` failing, on the grounds that rebinding arithmetic is the trade the tool makes. That is true and it is not the whole answer: the trade can be made per session rather than once, and then `fib` is available. open Microsoft.FSharp.Core.Operators // fib 20 is 6765, and 3 / 2 is 1 open AngouriMath.Interactive.ArithmeticOperators // back: x + 1 is an expression, 3 / 2 is 3/2 Both directions measured, in one session, either following the other. `operators ()` prints them, so it is discoverable from the prompt rather than from a wiki page. **Why not by default.** Rebinding arithmetic and writing ordinary F# are mutually exclusive, which was measured rather than reasoned: with F#'s own operators `fib 20` is 6765 and `3 / 2` is 1; with arithmetic rebound `3 / 2` is 3/2 and `x + 1` is an expression, and `fib` does not typecheck. One operator cannot have both return types. An overload that keeps `int + int` an int was tried before concluding that -- a witness-constrained SRTP operator over a base class carrying the general case. It resolves the simple cases and resolves them all at once: `1 + 1` is 2, `x + 1` is an expression, `1 + 4.5` is 11/2. It does not resolve `let rec`, because the operator has to be picked while the function's own type is still being inferred, so it falls to the general case and `fib` fails anyway. Annotating the parameter and the return type does not help either, which was checked rather than assumed. That is a negative result about this mechanism, not about every mechanism. The test that recorded `fib` as broken is replaced by one that runs it, and it fails if `fib` ever starts compiling by default -- which would mean this reasoning had stopped being true and the remark should be rewritten. Part of #1133. * Share a kernel across the terminal tests rather than starting one per case Each case was calling createKernel, which starts an F# compiler service, and most of the new ones then loaded AngouriMath into it as well: about thirty-four compiler services and sixteen full AngouriMath loads for forty-one assertions. Locally that was thirteen seconds and looked like nothing. On the macOS runner the `Test Terminal Lib` step ran for over two hours without finishing, while the Linux and Windows legs of the same run passed and the previous master run finished the same step in two minutes. So the cost was real and this machine was hiding it. xUnit runs the cases in a class one after another, so there was nothing to share a kernel with in the first place. The cases that only ask the kernel to compile something now share one; the two that change a session by `open`ing something still take a fresh one each, since that is the whole of what they test. Forty-one assertions, five seconds instead of thirteen. * Run the terminal tests one at a time, because each starts a compiler service Every test in this assembly starts an F# compiler service, and xUnit runs test classes in parallel by default. Two modules were fine; the third made three kernels start together, and on the macOS runner the `Test Terminal Lib` step then ran for over an hour against the two minutes it takes on master, while the Linux and Windows legs of the same run passed. Sharing a kernel within each class was the previous commit and was not enough on its own: it cut thirty-four kernels to four, and the four still started at the same time. Measured rather than assumed -- that commit went to CI and the step hung again. Serialising is right on the merits rather than as a workaround. There is nothing to gain from running two compiler services side by side on a three-core runner, and the assembly takes seven seconds either way here. The config is copied to the output directory, which is checked rather than assumed: a xunit.runner.json that stays in the source tree configures nothing.
1 parent 2fdcc9e commit 9923e4e

7 files changed

Lines changed: 214 additions & 5 deletions

File tree

‎Sources/Terminal/AngouriMath.Terminal.Lib/PreRunCode.fs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let eval (x : obj) =
1919
| :? Entity.Number.Complex as cx -> cx.RealPart.EDecimal.ToString() + "" + "" + cx.ImaginaryPart.EDecimal.ToString() + ""i""
2020
| other -> (evaled other).ToString()
2121
22-
open AngouriMath.Interactive.AggressiveOperators
22+
open AngouriMath.Interactive.ArithmeticOperators
2323
2424
let x = symbol ""x""
2525
let y = symbol ""y""
@@ -39,6 +39,25 @@ let help () =
3939
psi.UseShellExecute <- true
4040
System.Diagnostics.Process.Start psi
4141
$""Sending you to {url}""
42+
43+
/// Arithmetic here builds expressions, so `3 / 2` is a rational rather than 1 and `x + 1` is
44+
/// an expression rather than an error. The cost is that ordinary F# whose arithmetic must stay
45+
/// primitive does not typecheck: `let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)`
46+
/// is the usual example. These two say which reading you want, at any point in a session, and
47+
/// either can follow the other.
48+
/// open Microsoft.FSharp.Core.Operators // ordinary F#: fib compiles, 3 / 2 is 1
49+
/// open AngouriMath.Interactive.ArithmeticOperators // back to expressions
50+
/// https://github.com/asc-community/AngouriMath/issues/1133
51+
let operators () =
52+
// Printed rather than returned: what a cell returns is handed to AngouriMath's formatter,
53+
// which parses it, and a sentence is not an expression.
54+
printfn ""Arithmetic builds expressions here, so 3 / 2 is 3/2 and x + 1 is an expression.""
55+
printfn ""For ordinary F# - such as""
56+
printfn "" let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)""
57+
printfn ""- type""
58+
printfn "" open Microsoft.FSharp.Core.Operators""
59+
printfn ""and to come back to expressions,""
60+
printfn "" open AngouriMath.Interactive.ArithmeticOperators""
4261
"
4362

4463
let enableAngouriMath kernel =

‎Sources/Tests/TerminalUnitTests/CompilerServiceWorksTest.fs‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,24 @@ open AngouriMath.Terminal.Lib.Consts
1313
/// AngouriMath's own bindings. The Terminal opens AggressiveOperators over the top, which
1414
/// deliberately rebinds the comparison operators to build expressions — that is the tool's whole
1515
/// point and it is not what this asks about.
16-
let private bareKernel () =
16+
let private newKernel () =
1717
match createKernel () with
1818
| Result.Error _ ->
1919
Assert.True(false, "the kernel did not load at all")
2020
raise (System.Exception())
2121
| Result.Ok kernel -> kernel
2222

23+
/// One kernel for the whole class, not one per case.
24+
///
25+
/// Creating a kernel starts a compiler service, which is the most expensive thing here by a wide
26+
/// margin, and xUnit runs the cases in a class one after another so there is nothing to share it
27+
/// with. Every case below only asks the kernel to compile something and reads the answer, so
28+
/// nothing one of them does changes what the next one sees — the cases that *do* change a session
29+
/// are in OperatorsAtThePromptTest and make their own.
30+
let private shared = lazy (newKernel ())
31+
2332
let private evaluates (code: string) (expected: string) =
24-
match execute (bareKernel ()) code with
33+
match execute shared.Value code with
2534
| ExecutionResult.PlainTextSuccess actual -> Assert.Equal(expected, actual)
2635
| other ->
2736
Assert.True(false, $"expected {expected}, and the kernel answered {other}")
@@ -60,7 +69,7 @@ let ``The compiler service compiles F#`` (code: string) (expected: string) =
6069
[<InlineData("let x: int = \"not an int\"", "typecheck error")>]
6170
[<InlineData("let =", "parse error")>]
6271
let ``The compiler service reports errors`` (code: string) (expected: string) =
63-
match execute (bareKernel ()) code with
72+
match execute shared.Value code with
6473
| ExecutionResult.Error message ->
6574
Assert.Contains(expected, message)
6675
// A position, not just a complaint: the Terminal shows these to whoever typed them.
@@ -74,7 +83,7 @@ let ``The compiler service reports errors`` (code: string) (expected: string) =
7483
[<InlineData("derivative \"x\" \"x3 + sin(x)\"", "3 * x ^ 2 + cos(x)")>]
7584
[<InlineData("integral \"x\" \"x2\"", "x ^ 3 / 3 + C")>]
7685
let ``AngouriMath works through the compiler service`` (code: string) (expected: string) =
77-
let kernel = bareKernel ()
86+
let kernel = newKernel ()
7887
match enableAngouriMath kernel with
7988
| ExecutionResult.Error reason -> Assert.True(false, $"AngouriMath did not load: {reason}")
8089
| _ -> ()
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/// Which operators the Terminal rebinds, and what that costs.
2+
/// https://github.com/asc-community/AngouriMath/issues/1133
3+
module AngouriMath.Terminal.OperatorsAtThePromptTest
4+
5+
open Xunit
6+
open AngouriMath.Terminal.Lib.FSharpInteractive
7+
open AngouriMath.Terminal.Lib.PreRunCode
8+
open AngouriMath.Terminal.Lib.Consts
9+
10+
let private newKernel () =
11+
let k =
12+
match createKernel () with
13+
| Result.Error _ ->
14+
Assert.True(false, "the kernel did not load")
15+
raise (System.Exception())
16+
| Result.Ok k -> k
17+
match enableAngouriMath k with
18+
| ExecutionResult.Error reason ->
19+
Assert.True(false, $"AngouriMath did not load: {reason}")
20+
raise (System.Exception())
21+
| _ -> k
22+
23+
/// One kernel for the cases that only read, for the reason CompilerServiceWorksTest gives: a
24+
/// kernel starts a compiler service and loads AngouriMath into it, and doing that once per case
25+
/// was thirteen full loads in this class alone. The cases that change a session — the two that
26+
/// `open` something — take a fresh one each, because that is the whole of what they are testing.
27+
let private shared = lazy (newKernel ())
28+
29+
let private answers (k: Microsoft.DotNet.Interactive.FSharp.FSharpKernel) (code: string) (expected: string) =
30+
match execute k code with
31+
| ExecutionResult.PlainTextSuccess actual -> Assert.Equal(expected, actual)
32+
| ExecutionResult.LatexSuccess (_, actual) -> Assert.Equal(expected, actual)
33+
| other -> Assert.True(false, $"expected {expected}, and the prompt answered {other}")
34+
35+
/// The Terminal rebinds the five arithmetic operators the wiki says it rebinds, and no longer
36+
/// rebinds the comparisons, which it never said it did.
37+
///
38+
/// The comparisons cost ordinary F#: `if`, `while`, `List.filter` and a `match` guard all want a
39+
/// `bool` and were getting an `Entity`, so a large part of the language did not typecheck at a
40+
/// prompt whose whole purpose is to be typed at.
41+
[<Theory>]
42+
[<InlineData("if 1 < 2 then 1 else 0", "1")>]
43+
[<InlineData("[1;2;3] |> List.filter (fun x -> x > 1)", "[2; 3]")>]
44+
[<InlineData("let b: bool = 1 < 2\nb", "True")>]
45+
[<InlineData("seq { 1..100 } |> Seq.filter (fun x -> x % 7 = 0) |> Seq.length", "14")>]
46+
[<InlineData("let (|Even|Odd|) n = if n % 2 = 0 then Even else Odd\nmatch 4 with Even -> \"e\" | Odd -> \"o\"", "e")>]
47+
let ``Ordinary F# with a comparison in it compiles`` (code: string) (expected: string) =
48+
answers shared.Value code expected
49+
50+
/// And the reading the Terminal exists for is untouched: arithmetic on anything still builds an
51+
/// expression, which is what the wiki documents and what makes this a symbolic prompt rather than
52+
/// an F# one.
53+
[<Theory>]
54+
[<InlineData("x + 1", "x + 1")>]
55+
[<InlineData("3 / 2", "3/2")>]
56+
[<InlineData("x ** 2", "x ^ 2")>]
57+
[<InlineData("solutions \"x\" \"x2 - 5x + 6 = 0\"", "{ 2, 3 }")>]
58+
[<InlineData("derivative \"x\" \"x3\"", "3 * x ^ 2")>]
59+
let ``The symbolic reading of arithmetic survives`` (code: string) (expected: string) =
60+
answers shared.Value code expected
61+
62+
/// What narrowing costs, stated rather than glossed: a bare `x > 0` no longer builds an
63+
/// inequality. It is one line to get back, and that line is the whole of the old behaviour.
64+
[<Fact>]
65+
let ``A comparison on symbols is one open away`` () =
66+
let k = newKernel ()
67+
68+
match execute k "x > 0" with
69+
| ExecutionResult.Error message -> Assert.Contains("comparison", message)
70+
| other -> Assert.True(false, $"expected `x > 0` to be an ordinary comparison now, got {other}")
71+
72+
match execute k "open AngouriMath.Interactive.AggressiveOperators" with
73+
| ExecutionResult.VoidSuccess -> ()
74+
| other -> Assert.True(false, $"opening the aggressive operators answered {other}")
75+
76+
answers k "x > 0" "x > 0"
77+
78+
/// <summary>
79+
/// <c>fib</c> compiles, which is what #1133 asked for — but not by default, and the difference is
80+
/// the point rather than a caveat.
81+
/// </summary>
82+
/// <remarks>
83+
/// <para>
84+
/// Rebinding arithmetic and writing ordinary F# are <b>mutually exclusive</b>, and that was
85+
/// measured rather than assumed. With F#'s own operators, <c>fib 20</c> is 6765 and <c>3 / 2</c> is
86+
/// <c>1</c>; with arithmetic rebound, <c>3 / 2</c> is <c>3/2</c> and <c>x + 1</c> is an expression
87+
/// while <c>fib</c> does not typecheck. Both readings of <c>/</c> cannot be the return type of one
88+
/// operator.
89+
/// </para>
90+
/// <para>
91+
/// An overload that keeps <c>int + int</c> an <c>int</c> was tried, as a witness-constrained SRTP
92+
/// operator, and it does resolve the simple cases — <c>1 + 1</c> is 2, <c>x + 1</c> is an
93+
/// expression, <c>1 + 4.5</c> is <c>11/2</c>, all at once. It does not resolve <c>let rec</c>: the
94+
/// operator has to be picked while the function's own type is still being inferred, so it falls to
95+
/// the general case and <c>fib</c> fails anyway. A type annotation does not help, which was checked
96+
/// rather than assumed.
97+
/// </para>
98+
/// <para>
99+
/// So the answer is that a session says which reading it wants. <c>operators ()</c> at the prompt
100+
/// prints the two lines, and either can follow the other any number of times.
101+
/// </para>
102+
/// </remarks>
103+
[<Fact>]
104+
let ``Ordinary F# is one open away, and reversible`` () =
105+
let k = newKernel ()
106+
107+
// As the prompt starts: expressions, and fib does not compile.
108+
answers k "x + 1" "x + 1"
109+
answers k "3 / 2" "3/2"
110+
match execute k "let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)\nfib 20" with
111+
| ExecutionResult.Error _ -> ()
112+
| other -> Assert.True(false, $"fib compiles by default now, so this test is out of date: {other}")
113+
114+
// One line, and it does.
115+
match execute k "open Microsoft.FSharp.Core.Operators" with
116+
| ExecutionResult.VoidSuccess -> ()
117+
| other -> Assert.True(false, $"restoring F#'s operators answered {other}")
118+
answers k "let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)\nfib 20" "6765"
119+
answers k "[ for i in 1..10 -> i * i ] |> List.sum" "385"
120+
answers k "3 / 2" "1"
121+
122+
// And back, in the same session.
123+
match execute k "open AngouriMath.Interactive.ArithmeticOperators" with
124+
| ExecutionResult.VoidSuccess -> ()
125+
| other -> Assert.True(false, $"going back answered {other}")
126+
answers k "x + 1" "x + 1"
127+
answers k "3 / 2" "3/2"
128+
129+
/// And the prompt says so, rather than leaving it to be discovered.
130+
[<Fact>]
131+
let ``The prompt explains which operators it has`` () =
132+
// It prints rather than returns, so what is asserted is that it runs and says nothing back:
133+
// a returned sentence would go to AngouriMath's formatter, which would try to parse it.
134+
match execute shared.Value "operators ()" with
135+
| ExecutionResult.VoidSuccess -> ()
136+
| other -> Assert.True(false, $"operators () answered {other}")

‎Sources/Tests/TerminalUnitTests/TerminalUnitTests.fsproj‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<!--
12+
Every test in this assembly starts an F# compiler service, and xUnit runs test classes in
13+
parallel by default. Three modules here means three kernels at once, which on the macOS runner
14+
stopped finishing at all: the `Test Terminal Lib` step ran for over an hour against the two
15+
minutes it takes on master, while the Linux and Windows legs of the same run passed. Sharing a
16+
kernel within each class was not enough on its own, because the classes still start together.
17+
https://github.com/asc-community/AngouriMath/issues/1133
18+
-->
19+
<None Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
20+
1121
<Compile Include="Tests.fs" />
1222
<Compile Include="CompilerServiceWorksTest.fs" />
23+
<Compile Include="OperatorsAtThePromptTest.fs" />
1324
<Compile Include="Program.fs" />
1425
</ItemGroup>
1526

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
3+
"parallelizeTestCollections": false
4+
}

‎Sources/Wrappers/AngouriMath.Interactive/AngouriMath.Interactive.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
<None Include="Repack.ps1" />
3030

3131
<Compile Include="KernelExtension.fs" />
32+
<Compile Include="ArithmeticOperators.fs" />
3233
<Compile Include="AggressiveOperators.fs" />
3334
</ItemGroup>
3435

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/// The arithmetic operators, rebound to build expressions, and nothing else.
2+
///
3+
/// <c>AggressiveOperators</c> rebinds the comparisons as well, so that <c>x &gt; 0</c> is an
4+
/// inequality rather than a boolean. That is a real feature and it stays; what it costs is that
5+
/// ordinary F# containing a comparison stops typechecking, because <c>if</c>, <c>while</c>,
6+
/// <c>List.filter</c> and a <c>match</c> guard all want a <c>bool</c> where they now get an
7+
/// <c>Entity</c>. <c>let rec fib n = if n &lt; 2 then n else fib (n-1) + fib (n-2)</c> is the first
8+
/// recursive function most people type at an F# prompt, and it does not compile.
9+
///
10+
/// This is the half a prompt wants by default: arithmetic on anything builds an expression, and
11+
/// the language keeps working. Somebody who wants the comparisons too opens
12+
/// <c>AngouriMath.Interactive.AggressiveOperators</c> over the top of it, which is one line and
13+
/// restores exactly the old behaviour.
14+
///
15+
/// https://github.com/asc-community/AngouriMath/issues/1133
16+
module AngouriMath.Interactive.ArithmeticOperators
17+
18+
open AngouriMath.FSharp.Core
19+
20+
let ( + ) a b =
21+
((parsed a) + (parsed b)).InnerSimplified
22+
let ( - ) a b =
23+
((parsed a) - (parsed b)).InnerSimplified
24+
let ( * ) a b =
25+
((parsed a) * (parsed b)).InnerSimplified
26+
let ( / ) a b =
27+
((parsed a) / (parsed b)).InnerSimplified
28+
let ( ** ) a b =
29+
((parsed a).Pow(parsed b)).InnerSimplified

0 commit comments

Comments
 (0)