[API review] trig-pi functions

Hi all --

I've been dusting off some work from a few years ago on trig-pi functions for Numerics, and would appreciate any feedback you might have on these API to be added as requirements on the RealFunctions protocol:

  /// The arccosine (inverse cosine) of `x`, scaled by 1/π.
  ///
  /// If `x.magnitude <= 1`, the result is the angle (in half-turns) formed
  /// between the positive real axis and the vector `(x, √(1-x²))`. This is
  /// the value `y` in `-0.5...0.5` such that ``cos(piTimes: y)`` is `x` up
  /// to floating-point rounding.
  ///
  /// **Exact values and edge cases:**
  /// - If x is not in `-1...1`, then `acosOverPi(x)` is NaN.
  /// - If x is -1, then `acosOverPi(x)` is `1.0`.
  /// - If x is ±0, then `acosOverPi(x)` is `0.5`.
  /// - If x is +1, then `acosOverPi(x)` is `0.0`.
  static func acosOverPi(_ x: Self) -> Self
  
  /// The arcsine  (inverse sine) of `x`, scaled by 1/π.
  ///
  /// If `x.magnitude <= 1`, the result is the angle (in half-turns) formed
  /// between the positive real axis and the vector `(√(1-x²), x)`. This is
  /// the value `y` in `-0.5...0.5` such that ``sin(piTimes: y)`` is `x` up
  /// to floating-point rounding.
  ///
  /// **Symmetry:**
  /// arcsine is an odd function. Thus `asinOverPi(-x)` is the same as
  /// `-asinOverPi(x)`.
  ///
  /// **Exact values and edge cases:**
  /// - If x is not in `-1...1`, then `asinOverPi(x)` is NaN.
  /// - If x is ±1, then `asinOverPi(x)` is `±0.5`.
  /// - If x is ±0, then `asinOverPi(x)` is x.
  static func asinOverPi(_ x: Self) -> Self
  
  /// `atan(y/x)/π`, with representative selected based on the quadrant
  /// of `(x,y)`.
  ///
  /// The atan2OverPi function computes the angle (in half-turns,
  /// in the range [-1, 1]) formed between the positive real axis and the
  /// point `(x,y)`. The sign of the result always matches the sign of y.
  ///
  /// > Warning:
  /// Note the parameter ordering of this function; the `y` parameter
  /// comes *before* the `x` parameter. This is a historical curiosity
  /// going back to early FORTRAN math libraries (the ordering makes
  /// some sense because atan2(y, x) computes atan(y / x) in the first
  /// and fourth quadrants). In order to minimize opportunities for
  /// confusion and subtle bugs, we require explicit parameter labels
  /// with this function.
  static func atan2OverPi(y: Self, x: Self) -> Self
  
  /// The arctangent  (inverse tangent) of `x`, scaled by 1/π.
  ///
  /// The angle (in half-turns) formed between the positive real axis
  /// and the point `(1, x)`. This is the value `y` in `-0.5...0.5`
  /// such that ``tan(piTimes: y)`` is `x` up to floating-point rounding.
  ///
  /// **Symmetry:**
  /// arctangent is an odd function. Thus `atanOverPi(-x)` is the same as
  /// `-atanOverPi(x)`.
  ///
  /// **Exact values and edge cases:**
  /// - If x is NaN, then `atanOverPi(x)` is NaN.
  /// - If x is ±infinty, then `atanOverPi(x)` is `±0.5`.
  /// - If x is ±1, then `atanOverPi(x)` is `±0.25`.
  /// - If x is ±0, then `atanOverPi(x)` is x.
  static func atanOverPi(_ x: Self) -> Self
  
  /// The cosine of π times `x`.
  ///
  /// Because π is not representable in any `FloatingPoint` type, for large
  /// `x`, `.cos(.pi * x)` can have arbitrarily large relative error;
  /// `.cos(piTimes: x)` always provides a result with small relative error.
  ///
  /// This is observable even for modest arguments; consider `0.5`:
  /// ```swift
  /// Float.cos(.pi * 0.5)    // 7.54979e-08
  /// Float.cos(piTimes: 0.5) // 0.0
  /// ```
  /// It's important to be clear that there is no bug in the example
  /// given above. Every step of both computations is producing the most
  /// accurate possible result.
  ///
  /// **Symmetry:**
  /// cosine is an even function. Thus for every finite `x`,
  /// ```swift
  /// .cos(piTimes: -x) == .cos(piTimes: x)
  /// ```
  ///
  /// **Exact values and edge cases:**
  /// - If x is not finite, `cos(piTimes: x)` is NaN.
  /// - If x is an even integer, `cos(piTimes: x)` is 1.
  ///   > Note:
  ///     _All_ finite values with magnitude greater than or equal to
  ///     `(radix/ulpOfOne)` are even integers.
  ///     E.g. `Double.radix` is 2, and `Double.ulpOfOne` is 2⁻⁵², so
  ///     for every `Double` x with `x.magnitude` >= 2⁵³, `cos(piTimes: x)`
  ///     is 1.
  /// - If x is an odd integer, `cos(piTimes: x)` is -1.
  /// - If x is a half-integer (i.e. x = n + ½ for some integer n),
  ///   then `cos(piTimes: x)` is +0.
  static func cos(piTimes x: Self) -> Self
  
  /// The sine of π times `x`.
  ///
  /// Because π is not representable in any `FloatingPoint` type, for large
  /// `x`, `.sin(.pi * x)` can have arbitrarily large relative error;
  /// `.sin(piTimes: x)` always provides a result with small relative error.
  ///
  /// This is observable even for modest arguments; consider `10`:
  /// ```swift
  /// Float.sin(.pi * 10)    // -2.4636322e-06
  /// Float.sin(piTimes: 10) // 0.0
  /// ```
  /// It's important to be clear that there is no bug in the example
  /// given above. Every step of both computations is producing the most
  /// accurate possible result.
  ///
  /// **Symmetry:**
  /// sine is an odd function. Thus for every finite `x`,
  /// ```swift
  /// .sin(piTimes: -x) == -.sin(piTimes: x)
  /// ```
  ///
  /// **Exact values and edge cases:**
  /// - If x is not finite, `sin(piTimes: x)` is NaN.
  /// - If x is an integer, `sin(piTimes: x)` is zero, with the same sign
  ///   as x (so that the function is odd).
  ///   > Note:
  ///     _All_ finite values with magnitude greater than or equal to
  ///     `(1/.ulpOfOne)` are integers.
  ///     E.g. `Double.ulpOfOne` is 2⁻⁵², so for every `Double` x with
  ///     `x.magnitude` >= 2⁵², `sin(piTimes: x)` is zero.
  /// - If x is a half-integer (i.e. x = n + ½ for some integer n),
  ///   then `sin(piTimes: x)` is 1 if n is even and -1 if n is odd.
  static func sin(piTimes x: Self) -> Self
  
  /// The tangent of π times `x`.
  ///
  /// Because π is not representable in any `FloatingPoint` type, for
  /// large `x`, `.tan(.pi * x)` can have arbitrarily large relative
  /// error; `.tan(piTimes: x)` always provides a result with small
  /// relative error.
  ///
  /// This is observable even for modest arguments; consider `0.5`:
  /// ```swift
  /// Float.tan(.pi * 0.5)    // 13245402.0
  /// Float.tan(piTimes: 0.5) // infinity
  /// ```
  /// It's important to be clear that there is no bug in either example
  /// given above. Every step of both computations is producing the most
  /// accurate possible result.
  ///
  /// **Symmetry:**
  /// tangent is an odd function. Thus for every finite `x`,
  /// ```swift
  /// .tan(piTimes: -x) == -.tan(piTimes: x)
  /// ```
  ///
  /// **Exact values and edge cases:**
  /// - If x is not finite, `tan(piTimes: x)` is NaN.
  /// - If x is an integer, `tan(piTimes: x)` is zero, with the sign given
  ///   by `sin(piTimes: x)/cos(piTimes: x)`.
  /// - If x is a half-integer, `tan(piTimes: x)` is infinity, with the sign
  ///   given by `sin(piTimes: x)/cos(piTimes: x)`
  ///
  ///   Thus, if `n` is a positive even integral value and `n.ulp <= 0.5`:
  ///   - `tan(piTimes: n)` is +0/+1 = +0
  ///   - `tan(piTimes: n + 0.5)` is +1/+0 = +infinity
  ///   - `tan(piTimes: n + 1.0)` is +0/-1 = -0
  ///   - `tan(piTimes: n + 1.5)` is -1/+0 = -infinity
  ///
  ///   This means that `tan(piTimes:)` is 2-periodic, even though the
  ///   mathematical tangent function is π-periodic (not 2π).
  ///
  ///   > Note:
  ///     _All_ finite values with magnitude greater than or equal to
  ///     `(2/.ulpOfOne)` are even integers.
  ///     E.g. `Double.ulpOfOne` is 2⁻⁵², so for every `Double` x with
  ///     `x.magnitude` >= 2⁵³, `tan(piTimes: x)` is ±0.
  static func tan(piTimes x: Self) -> Self

The basic rationale for these additions is that by making the scaling by π implicit, we get to work with exact values for many common angles, and reduction from arbitrary angles into the fundamental range [-1,1] becomes an simple and exact operation. These are mostly IEEE 754 recommend operations since the 2008 standard revision, and the C23 standard adds bindings for most of them as well (matching the edge cases specified here). I will, however, provide implementations for these on all platforms, both because C23 support is pretty spotty and because it's desirable for these operations to get the same result on all platforms.

My main question here is the naming of the inverse functions; sin(piTimes: x) works really nicely, but asinOverPi(x) isn't quite as neat, but I don't have any ideas that I like much more. If there are any compelling alternatives, I'm interested to hear them (C calls this asinpi, fwiw).

Review of this proposal will run through June 1, 2024. A WIP implementation branch is available for experimentation (partially backed by placeholder implementations for now).

8 Likes

I'd say value.sin, value.asinpi, etc, i.e. instance methods, no extra brackets when possible, no spelling type explicitly and match original C names. atan2pi could be, either static or value.atan2pi(otherValue).

+1, these are all useful functions.


Is there existing Swift “precedent” on the capitalization of the letter immediately following a numeral in an identifier? To me, keeping that letter lowercase is more readable, especially when it is confusable for a numeral.

Specifically, I find atan2OverPi slightly less readable than atan2overPi, in part because at first glance the capitalized version looks like the nonsensical “atan twenty ver pi”.

1 Like

It would be weirder for atan2overpi to differ from atanoverpi in capitalization of “over”.

4 Likes

I agree, but I don't think that there's much precedent here, and not capitalizing is also weird.

3 Likes

All of this is defensible, but not going to be considered in this API change because we want to be consistent with the rest of ElementaryFunctions and RealFunctions; we're not going to reinvent all of the library conventions for a small API addition.

Agree that these (capitalized as-is) are better than the alternatives. Neither C/C++ nor IEEE naming conventions are great; something that is close enough to them to be discoverable but with just the necessary bits to add clarity is probably the best we can do, and I think you’ve got it.

Yeah.

  • I don't love "over" because it's a little too casual, but all the more precise alternatives are way too verbose (atanDividedByPi?)
  • Following the C names (atanpi) or IEEE 754 names (atanPi) is actually kind of appealing for these, but I would then expect to follow the C/754 names for everything else, which we don't do, because a bunch of them are pretty bad and we can do better.
2 Likes

Well, there is piDividingAtan(x)

Less casual than "over" and less wordy than "divided by," and it has the key virtue (in my book) of separating the argument and pi. But it would be a hit on discovery given that they would all sort together and away from their non-pi counterparts, and to polyglots it'd be another one of those weirdo Swift-isms.

3 Likes

Rather than including the pi in the function names, how about putting those functions in a namespace such as Canonical to convey the scaling by pi in the computations.

For example:

Canonical.sin (x)
Canonical.asin (y)

Canonical.cos (x)
Canonical.acos (y)

Canonical.tan (x)
Canonical.atan (y)
1 Like

Did you consider using the raw IEEE names for all those functions (asinpi, etc.)? I know we have Swift API guidelines. I'm just not sure they should apply when there exists a pre-existing technical jargon.

As a data point, I used to "translate" advanced SQLite apis into Swift, but I eventually stopped. It's much easier to read and write .SQLITE_BUSY instead of .busy when your code, Google, reference documents, Stackoverflow, blog posts, etc. agree on a common vocabulary.

Here we're bringing in functions that are expected. People will look for them by their expected names (asinpi, etc). We're not quite free to make as if we were designing from scratch.

5 Likes

A couple of more ideas:

  1. names with "DividingByPi" to follow the standard library convention of other dividingBy "reporting overflow" APIs.
atanDividingByPi(x)
  1. An "explicit" approach:
enum Pi { case pi }
func atan(_ v: Double, dividingBy: Pi) -> Double { .... }

// usage:
atan(x, dividingBy: .pi)

although (2) might be surprising when people try to pass anything but ".pi" as a divider and get back an error.

  1. "Sleight of hand" approach:
struct Pi {}
let pi = Pi()

func / (lhs: Double, rhs: Pi) -> Double {
    atanpi(lhs)
}
// usage:
atan(42) / pi // actually uses `atanpi`
atan(42) / .pi // uses atan and a normal division afterwards

The "raw IEEE 754" name would be asinPi, but it's important to note that IEEE 754 very explicitly does not define language bindings. Each language is expected to expose 754 operations in a manner natural to that language, so the names in 754 should not be taken as guidance.

3 Likes

What are these functions named in Rust and Julia?

Rust is not very interested in these sorts of operations, and doesn't provide bindings AFAIK (they do diverge from C names for <math.h> functions, however, e.g. ln_1p instead of log1p).

Julia just takes the C library naming for sinpi and cospi, but (weirdly!) doesn't provide any of the π-scaled inverse operations (I think that this is because OpenLibm doesn't have them?)

Either way, no good precedent from either of those quarters.

1 Like

I think a lot of people would assume this computes atan(x/π) rather than atan(x)/π.

This is cute, but a bit too magical, I think. These functions are more like the low-level implementation hooks that someone would use to build something like that if they wanted to. We really want these to be discoverable and to give a good hint to what they do for someone who hasn't seen them before,¹ without any magic that makes the reader have to stop and say "wait, how does that work?"


¹ This is a big part of why I don't much like the C23 names; you can make sense of atanpi if you've already seen tanpi, but if you haven't it doesn't give you much to go on.

2 Likes

When you start typing asin and autocomplete pops up with asin(_:) and asinOverPi(_:), it's usually clear enough what you're looking for (I do think that it's important that they share the stem for this reason, however).

3 Likes

Yeah. Besides it was totally wrong :man_facepalming:

On the "over" in name: it does feel too informal as you mentioned, and it doesn't follow the precedent established by dividingBy "reporting overflow" API. In other words, if "over" is better here why didn't we use it there, and vice versa.

We have expMinusOne not expSubtractingByOne, log(onePlus:) not log(oneAdding:), and it’s certainly better to have sin(piTimes:) rather than sin(piMultiplyingBy:).

On the other hand, we would never call it plussingReportingOverflow.

1 Like

Maybe acosDivPi