Issue #6349 - Algorithm
Something like this?
isApproxEq : a, a -> Bool
isApproxEq = a, b -> (Num.abs (a - b)) < 0.000000001 #epsilon 1e-9
We should probably use the numpy style comparison here where it takes in two epsilon values atol and rtol. We can take a record with default fields as the last parameter to enable this
This is much more robust: https://numpy.org/doc/stable/reference/generated/numpy.isclose.html
Though, for rocs case, we probably should just ignore nan
How does ROC default parameters? It looks like Option type is not supported yet.
Does ROC support scientific notation or planning to?
isApproxEq = a,b, Option rtol, Option atol ->
_rtol = when rtol is
Some x -> x
None -> 1e-05
_atol = when atol is
Some y -> y
None -> 1e-08
(Num.abs (a - b)) <= (_atol + _rtol * (Num.abs b))
result = isApproxEq (0.1 + 0.2) 0.3 None None
Another option
isApproxEq = a, b -> isApproxEqWithEpsilon a b None None
isApproxEqWithEpsilon = # above signature
Roc doesn't have an option type, though one is trivial to define. Instead roc uses results or custom tags.
we have optional record fields rather than optional parameters
Also, this is what I was talking about for optionals: https://www.roc-lang.org/tutorial#optional-record-fields
So the default call would be isApproxEq a b {}
And more args could be passes isApproxEq a b { absoluteTolerance: 0.1, relativeTolerance 0.001 }
Not sure the state of scientific notation parsing.
Cool, so it's similar to an options object in Javascript.
I still like the idea of wrapping isApproxEq. What % of use cases would actually modify the tolerance?
isApproxEq a b
isApproxEqWithEpsilon a b {}
I don't think It will be modified that often, but I do like isApproxEq a b {} cause it helps people remember that their are parameters that they are ignoring....though maybe that isn't important
I mean I personally modify It all the time (but I also work on ML compilers which is a very special case)
cause it helps people remember that their are parameters that they are ignoring
I agree with this
:+1:
Does anyone like this shorthand syntax?
a ~= b is the same as isApproxEq a b {}
I think it is reasonable but probably premature. As in, I'm not sure how much a short hand for that is wanted currently. Totally a reasonable topic to discuss in general though.
I'd really like this function to be in the stdlib, because I end up adding it to a Utils.roc file in about 50% of libraries I write :sweat_smile: mainly for comparing values in unit tests, e.g here:
https://github.com/Hasnep/roc-math/blob/9a5a114c4e3e4500e5a7a4a6c8d939ef9c7cb85a/src/Utils.roc
I think I copied that implementation from the Julia stdlib, just because I'm used to reading Julia
Yeah, I think we should add this to the standard as Num.isApproxEq
Do we want the implementation of isApproxEq to be symmetric or asymmetric? isApproxEq(a, b) != isApproxEq(b, a)
See math.isClose vs Numpy.isClose.
https://docs.python.org/3/library/math.html#math.isclose
https://numpy.org/doc/stable/reference/generated/numpy.isclose.html
basile-henry also brought up a good point about handling infinity and denormals correctly.
https://floating-point-gui.de/errors/comparison/
In the mean time, I'll add several more test cases to the PR.
Asymmetric is much more useful in practice
Otherwise you don't have control over which value is the reference and get way more values excepted by rtol than you actually want
If we are really concerned about it, we could through all values in the record to give clearer names isApproxEq { val: a, reference: b } val and reference may not be the best name, but that idea in general.
We may need an extra check around division by zero, but I wouldn't worry about denormals for now.
Float step based algorithms are really hard to reason about and probably would confuse users more.
So, I have already changed my mind on the isApproxEq function and want to change it slightly.
I was talking to some kernel engineers that work with high performance cpu and gpu kernels of various kinds.
They suggest two changes from the numpy version:
A bit of detail about why.
For symmetric vs asymmetric. The fear of using symmetric is that it can allow for more total relative error. Imagine an relative tolerance of 0.10. In that case a/b might be 1.11 which is a failure, but b/a would then be 0.90 which is a success. This is a true statement, but anyone using a relative tolerance of 0.10 already has crazy giant issues, so this doesn't matter anyway. In practice, relative tolerance is multiple orders of magnitude smaller then this. So the affect of switching arg order is negligible.
The gains for having a symmetric version are quite obvious:
isApproxEq a b == isApproxEq b a is a true statement. Which is simply nice.The second change is to use max instead of sum of tolerances. That is to say instead of <= atol + rtol * Num.abs refValue, it is <= Num.max atol (rtol * Num.abs refValue). This decouples absolute and relative tolerances such that they become solo concepts that you can think about in isolation. Instead of verifying some conflated and merged absolute and relative tolerance, you are verifying either a match for the absolute tolerance or a match for the relative tolerance. This matches how people expect this function to work and gives more precisely scoped results.
So I think I want to change the implementation to hold these two properties. This would make the implementation:
isApproxEq = \x, y, { rtol ? 0.00001, atol ? 0.00000001 } ->
eq = x <= y && x >= y
meetsTolerance = Num.absDiff x y <= Num.max atol (rtol * Num.max (Num.abs x) (Num.abs y))
eq || meetsTolerance
makes sense to me! :thumbs_up:
Last updated: Jun 16 2026 at 16:19 UTC