-
Notifications
You must be signed in to change notification settings - Fork 18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Thoughts on Precision in Decimal #175
Comments
My position is that your axiom is false in a general sense, even though it is objectively true in specific contexts. In other words, it's not that 1 and 1.0 are always interchangeable, it's that 1 and 1.0 aren't always distinct - and I'd personally say they're most often not distinct. While the decision to say "1 point zero stars" definitely implies a distinction, that's not a number, it's additional context around a number. The concept of precision definitely exists! However, it's not inextricably attached to a number system, especially not a universally human-used number system, and since it can't be included in Decimal without downsides (in particular, preventing it from ever becoming a primitive), if it needs to be represented, it should be done as a separate object and not as a Decimal. |
So, the fact that we are talking about the differences between the two entities means that they are distinct entities. In some contexts, they are interchangeable; you claim they are interchangeable in many/most contexts. But, they are objectively still distinct entities, even if interchangeable. The axiom is true in the general sense. Note that I switched the term "value" for "entity". I considered the word "value" to mean any distinct entity, with no connotations about being associated with a spot on the number line. It is not my intent to cause confusion with terminology. |
I'll make one additional observation that I could have included in the OP. I will use @ljharb's language around "interchangeability", which I think is an appropriate term that can help us have productive conversations. The README lists three use cases for this proposal:
Use case 1 specifically calls out "human-readable decimal values". In that context, 1 and 1.0 are definitely not interchangeable, due to their impact on spoken and written language. Use case 2 calls out "data exchange". In that context, 1 and 1.0 are definitely not interchangeable for data exchange: we won't be able to round-trip values coming from other decimal libraries that make the distinction, as most do (see OP for citation). Use case 3 calls out "numerical calculations". That is exactly the scientific computing context where precision matters and 1 and 1.0 are not interchangeable. Therefore, all 3 motivating use cases for this proposal are use cases where the two entities are not interchangeable. |
I very strongly sympathize with this. I myself had problems caused by NumberFormat and PluralRules getting out of sync, because whether I wanted multiple digits or not needs to be specified twice as options to the constructors rather than together with the argument I give them to format.
When it comes to accounting, you always want the maximum possible precision: you want to know exactly how much money is flowing, and saying "roughly $1" is not enough. In that context, 1 and 1.0 have the exact same meaning: in both case it's 1 dollar and zero cents, and not "between 0.5 and 1.5" and "between 0.95 and 1.05". When you multiply $1.00 by 50% (0.5), Decimal128 gives you 0.500 but that's still not representing something different from $0.50. What matters its that the magnitude of the number is exact, and not how the error propagates since there is no error. In sciences the error is relevant, but regardless of whether this proposal preserves trailing zeroes or not you'll have to manually propagate the errors by yourself. If you have two sticks whose length is 0.500cm (0.5 ± 0.0005) and 0.200cm (0.2 ± 0.0005), the length of putting the two sticks together is not what you would represent as the decimal 0.7000cm (0.7 ± 0.0005), but it's 0.7 ± 0.001 (assuming that the two measurements are independent, otherwise we would also have to factor in the correlation between them that is not even represented in either of the two operands).
This is probably the Precision can be relevant in multiple places and not just at the end when displaying a value on screen, even if that will probably be 95% of the use cases on the web. However, the way that this proposal handles precision is:
In both of those cases, you are going to use decimal values as if they were infinite-precision numbers, and then once you are done computing define what precision the result has before passing the value to the next "system". This is exactly what you are doing in the stars example:
and you are manually defining the precision at the boundary between these two parts. My If it was only for the scientific computation use case this (additional thoughts after re-reading my comment) I believe my comment addresses points (1) and (3) from the readme, but I also want to address (2). While its true that a number that looses trailing zeroes cannot use an an itermediate step for round-tripping to a number that cares about trailing zeroes, when does this matter?
|
The fact that we can get the right result out of a proper use of Intl.NumberFormat and Intl.PluralRules makes me wonder if we're looking at something that involves an inherent challenge in i18n, something that experienced i18n developers need to know (among many other complexities in i18n). Or, put differently, I wonder whether decimal numbers have any interesting advantage in this kind of use case. Looking more closely at the restaurant rating example above, in which non-normalized decimals are used, I wonder about whether we're asking PluralRules' (This kind of example opens the door to a topic. We have generally been talking about taking numbers with lots of fractional digits, possibly including trailing zeroes, and rounding. This might be OK for various understandings of "precision", but what about the practice of adding trailing zeroes? In some contexts, that's not OK. If I have This kind of example illustrates that every use case is different. NF and PluralRules can interact in subtle ways, an even well-intentioned programmers might write buggy code, but arguably, that's just how the (i18n) world is. It's not a design flaw in Intl. We may need to trim trailing zeroes in some cases, preserve them others, or even pad the digit string with extra zeroes (imputing data). Or, as in the case above, doing a mixture of the above. I like @nicolo-ribaudo 's idea of a number-plus-precision value. To add to that, I might propose an extension of PluralRules' |
Reflecting on the data exchange use case, one thing that troubles me is that the idea of preserving trailing zeroes loses a bit of its appeal when we consider that a decimal number coming over the wire, possibly with trailing zeroes, might be an input to a calculation in which the number of fractional digits can be very different from those of the original number. Especially with multiplication and division, the number of fractional digits grows rapidly. Putting this another way, the intuitively appealing conservativeness in the data exchange use case ("Don't delete any information given to you") works when a JS engine sitting in the middle receives a decimal and passes it along unchanged to the next system. But that's a fairly trivial use case. I wonder if a different, related conservation principle might be better, along the lines of "Preserve the number as accurately as possible". This may or may not involve preserving trailing zeroes. |
Taking a look at the spec text for The idea, proposed by @nicolo-ribaudo and others in the TG3 call last week, of extending |
About number-with-precision: I'd need to see a specific concrete design, but my initial reaction is that I don't really see a big difference between the use cases for number-with-precision and for Decimal. Going back to the 3 motivational use cases outlined in the README:
|
The values of money don't retain extra zeroes - only the formatting/display of them do. $1 and $1.0 and $1.00 are the exact same amount of money. |
Yes? They are numerically equal entities. As I said in the OP, I don't see how these truisms lead logically to one proposal or another. Being able to represent these entities uniquely in the data model in no way contradicts the fact that they represent the same point on the number line. |
I agree with that - but "the number line" is the only thing that a number system represents. Precision is something extra - not something that belongs directly in the primitive (whether JS primitive, or primitive number concept) |
Hey I don't think that you two keeping telling the other "they are the same number!" "no they represent something different!" is very productive. We all agree that on the number line 1 and 1.0 are the same number, but that in some contexts humans give to 1 and 1.0 different meanings. A new primitive/object can represent either some points on the number line or it can contain more info, and there is nothing theoretically preventing either solution other than deciding which tradeoffs we are willing to make. The main drawback of keeping precision in the number is that it closes the door to introducing decimal primitives in the future. On the other hand, the motivation for keeping the precision is that:
The main precision related-feature that the current proposal has and that we don't need/want is how it propagates through arithmetic operations, because in practice developers who care about precision still need to manually define it correctly after computing something. I wrote down an updated version of the "numeric value with precision idea": https://gist.github.com/nicolo-ribaudo/27c6156cefe27cf488f028e0236dc667 I'd love to hear how y'all feel about it. Some examples for how to use it:
|
I think we can agree that decimals, with or without trailing zeroes, address the three (classes of) use cases we have in mind. As for the tertiary use case, I think the main way to address these is (1) to make decimals fast and (2) to offer mathematical operations that routinely come up in scientific computation, such as the trigonometric functions, logarithm and exponential, and sqrt, and perhaps more. For these operations, decimals with or without trailing zeroes are valid. For most arguments to these functions, we'd need all 34 significant decimal digits to express the result. It's unlikely (though possible) that the last stretch of decimal digits would be 0s. Similar to many of the operations currently found in |
Just for the record: the decimal proposal does have |
Having worked on several invoicing/point-of-sale systems, this is very incorrect. When your tax for an item comes out to $1.1100, those digits are significant. The decision of when it is okay to round/trim your tax numbers to add them to your subtotal is a business decision that your accountant will probably have opinions about. Maybe your system calculates the tax per-line item and rounds the taxable amount per-lineitem. Maybe you sum up all of the lineitems' tax amounts with the full 4 digits of precision, and then round them. Whatever choice you and your accountants make, you want to be very explicit about what precision you are dealing with during each step, and when you are choosing to round the numbers and decrease the precision. If BigDecimal does not support precision, people will need to continue to use userland libraries like financial-number to stay on top of precision. |
The proposal as it is right now implicitly propagates precision, according to the IEEE 754 rules. If the proposal doesn't propagate that implicitly anymore, it's on the develope to round the number at the steps where they want that to happen: for example, after adding tax to each row or the invoice, or at the end after adding tax on the total. For example, let total = rows
.map(x => x.multiply(tax).round(2))
.reduce((a, b) => a.add(b)); vs let total = rows
.map(x => x.multiply(tax))
.reduce((a, b) => a.add(b))
.round(2); |
The correct example from your readme would become const subtotal = new Decimal('1.5').multiply(new Decimal('24.99'))
const rounded_subtotal = subtotal.round(2)
rounded_subtotal.toString() // => '37.48'
const tax = rounded_subtotal.multiply(new Decimal('0.14'))
const rounded_tax = tax.round(2)
rounded_tax.toString() // => '5.24'
const total = rounded_subtotal.add(rounded_tax)
total.toString() // => '42.72' For comparison, with the library it's very similar (just different method names): const subtotal = number('1.5').times('24.99')
const rounded_subtotal = subtotal.changePrecision(2)
rounded_subtotal.toString() // => '37.48'
const tax = rounded_subtotal.times('0.14')
const rounded_tax = tax.changePrecision(2)
rounded_tax.toString() // => '5.24'
const total = rounded_subtotal.plus(rounded_tax)
total.toString() // => '42.72' |
A topic that continues to be raised for discussion in the context of the Decimal proposal is the concept of precision, its interaction with arithmetic, and whether it should be part of the data model at all. I've been meaning to make a write-up for some time, and since we had a discussion on this subject in TG3 today, I thought this would be a good opportunity.
First, I want to start with the fundamental axiom of my position and all arguments that follow. The axiom is that the existence of trailing zeros makes certain values distinct from others. For example, "1" is distinct from "1.0", and "2.5" is distinct from "2.50". I will present evidence for this axiom:
These are all evidence that "1", a natural number, and "1.0", a decimal approximation of something, are distinct values.
I consider this axiom to be a matter of fact, not a matter of opinion. To make an argument to the contrary would be to say that "1" and "1.0" are always fully interchangeable, CLDR is wrong to pluralize "star" in the string "1.0 stars", and software libraries are wrong to represent this in their data models.
Okay, now that I've established the fundamental axiom, I will make a case that core ECMAScript should have a way of representing these values as distinct.
Points 1 and 2 (that the problem exists and that the problem causes real bugs in the wild) are the ones of primary concern to me as an i18n advocate in TC39. Points 3-6 are additional ones I offer.
I will now address three counter-arguments that were raised in the TG3 call today.
@ljharb pointed out that a person often first comes across the concept of precision in numbers in a physics course and that it is often a hard concept to grasp. This is a true statement, and it could perhaps be used as evidence that it is confusing for decimal arithmetic to propagate precision. However, it is not an argument that the concept doesn't exist or whether the concept has applications relevant to the Decimal proposal.
@erights pointed out that the numbers π and ⅓ are distinct numerical concepts also not representable by a Decimal. This is again a true statement. However, I do not see how it leads logically to an argument that 1.0 and 2.50 should be excluded from Decimal. That 1.0 and 2.50 have applications to the Decimal proposal is not changed by the existence of π and ⅓.
Someone else, I think @nicolo-ribaudo, pointed out that representing the precision of decimals is a concern about how to display the mathematical value, i.e., a formatting option, not a concern for the data model. This is a valid position to hold and one I've often seen. My counter-argument is that internationalization's job is to take distinct values and display them for human consumption. Intl's job is to decide what numbering system to use, what symbols to use for decimal and grouping separators, whether to display grouping separators, and where to render plus and minus signs, for example. It is not Intl's job to decide whether to display trailing zeros, since making that decision changes the input from one distinct value to a different distinct value. Relatedly, it is also not generally Intl's job to decide the magnitude to which it should round numbers.
I will close with one more thought. Decimal128 representing trailing zeros does not by itself prevent the i18n bugs noted above. However, it sets us on a path where the cleanest, simplest code is the code that produces the correct i18n behavior. For example, in 2024, code that correctly calculates and renders a restaurant rating would look like this:
Note that an identical
formatOptions
must be passed as an argument to bothnew Intl.PluralRules
andNumber.prototype.toLocaleString
(or equivalentlynew Intl.NumberFormat
); if it is not, you have a bug. However, with a Decimal that represents trailing zeros, the code can be written like this:My unwavering principle in API design is that the easiest code to write should be the code that produces the correct results. We cannot prevent people from doing the wrong thing in a Turing-complete language, but it is our core responsibility as library designers to nudge developers in the right direction.
Also CC: @jessealama @ctcpip @littledan
The text was updated successfully, but these errors were encountered: