Skip to content

change Round function to use sprintf and combat machine rounding error#1422

Open
Alex-Jordan wants to merge 1 commit into
openwebwork:PG-2.21from
Alex-Jordan:round
Open

change Round function to use sprintf and combat machine rounding error#1422
Alex-Jordan wants to merge 1 commit into
openwebwork:PG-2.21from
Alex-Jordan:round

Conversation

@Alex-Jordan
Copy link
Copy Markdown
Contributor

This may be controversial, and I will close it in a heartbeat if other developers don't like it. But it's to address #403.

The philosophy is that if we have a number like 134.49999999999997 it was probably supposed to be 134.5 and should round up to 135. So we add an extremely small amount (numZeroLevelTolDefault) before doing the rounding.

Of course the risk is that the number really was supposed to be 134.49999999999997, and really should round down. It is hard for me to come up with a scenario where you'd really be working with a number like this and need to round it.

To test, this problem gives different results before and after this commit:

DOCUMENT();
loadMacros(qw(PGstandard.pl PGML.pl));

$x = 3.3625/0.025;
$rounded = Round($x,0);

TEXT("When you round $x you get $rounded.");
ENDDOCUMENT();

@dpvc
Copy link
Copy Markdown
Member

dpvc commented May 26, 2026

This approach is dependent on the magnitude of the number being rounded. For example, your approach will round 1000.499999999999 to 1001, but 10000.49999999999 to 10000. That is because the $numZeroLevelDefault of 1E-12 falls below the last 9, and so doesn't do what you want. You can't just use a fixed adjustment, but must change the position of the adjustment depending on the magnitude of the number.

Peter Staab and I worked on this idea for his SignificantFigures context, and came up with the following (adjusted to your setting):

sub Round {
	my ($x, $n) = @_;
	my $e = (split(/E/, sprintf("%E", $x)))[1] + 0;      # exponent for $x
	my $s = ($x < 0 ? -1 : 1);                           # the sign of $x
	my $N = $e + $n;                                     # number of digits to retain
	my $m = main::max($N, 14 - $N);                      # position to use to adjust for repeated 9s
	$x += $s * 10**($e - $m);                            # adjust for repeated 9s
	return sprintf("%.${N}E", $x) + 0 unless $N == -1;   # round the adjusted value

	# For zero original digits, we add a digit just above the first one
	# in $x, round that, then remove the added digit, getting 0 if $x
	# didn't round up, or 1 in the proper place if it did.  This means
	# that 0.005 rounds to .01, for example, when 2 digits are requested.

	my $d = $s * 10**($e + 1);
	return sprintf("%.0E", $x + $d) - $d;
}

This uses the sprintf() function to do the rounding to the proper place. It would be possible to use an adaptive adjustment in your approach as well if you don't like using sprintf() for the result, but I don't like using the multiply and divide by a power of 10, as I think that can lead to a small truncation error in some cases as well, though I may be wrong on that. These things can be quite subtle, as you know.

@Alex-Jordan
Copy link
Copy Markdown
Contributor Author

Oh, how silly of me not to recognize the dependency on order of magnitude.

Is it best for this, for the "14" to be hard coded?

@Alex-Jordan Alex-Jordan changed the title add a bit to a number before rounding to combatx machine rounding error change Round function to use sprintf and combat machine rounding error May 26, 2026
@Alex-Jordan
Copy link
Copy Markdown
Contributor Author

This is still all up for debate, but I changed it to @dpvc and @pstaabp's code.

@dpvc
Copy link
Copy Markdown
Member

dpvc commented May 26, 2026

Is it best for this, for the "14" to be hard coded?

Yes. This is based on the fact that floating-point real numbers are stored with 16 to 17 decimal digits of precision, so the 14 is two or three digits from the end, which should pick up a run of 9s (sometimes you end up with .4999999999945 or something like that, so need to be a little in from the end).

@drgrice1
Copy link
Copy Markdown
Member

The fact that the unit test is failing for a test specifically testing the Round function functionality, clearly indicates that something is not right with this.

@Alex-Jordan
Copy link
Copy Markdown
Contributor Author

Yes, I didn't miss the unit test failing. I just haven't come back to it yet.

@dpvc
Copy link
Copy Markdown
Member

dpvc commented May 26, 2026

The fact that the unit test is failing for a test specifically testing the Round function functionality, clearly indicates that something is not right with this.

My fault. It was a rushed translation of code that took the number of significant digits to one that takes a number of decimal places. I think it is the correct one:

sub Round {
	my ($x, $n) = @_;
	my $e = (split(/E/, sprintf("%E", $x)))[1] + 0;     # exponent for $x
	my $s = ($x < 0 ? -1 : 1);                          # the sign of $x
	my $N = $e + $n;                                    # number of digits to retain
	$x += $s * 10**($e - 15);                           # adjust for repeated 9s
	return sprintf("%.${N}E", $x) + 0 unless $N == -1;  # round the adjusted value

	# For zero original digits, we add a digit just above the first one
	# in $x, round that, then remove the added digit, getting 0 if $x
	# didn't round up, or 1 in the proper place if it did.  This means
	# that 0.005 rounds to .01, for example, when 2 digits are requested.

	my $d = $s * 10**($e + 1);
	return sprintf("%.0E", $x + $d) - $d;
}

Sorry about that!

@Alex-Jordan
Copy link
Copy Markdown
Contributor Author

Thanks @dpvc, I just updated it. The tests pass now.

This is still just for discussion. Although I support a change to Round like this, I understand the argument to leave things be here. I just find it hard to imagine a PG problem that truly intended to work with numbers like 1.49999999999991 and at the same time, needs to round them. Working with a number like that might arise if the small deviation from 1.5 matters in context, but then why would you be rounding that number?

@drgrice1
Copy link
Copy Markdown
Member

I don't think you could even reliably deal with 1.49999999999991 in perl. Perl can only handle 15 significant digits, and usually there is rounding error in the last digit or two.

@Alex-Jordan
Copy link
Copy Markdown
Contributor Author

Yes, so the question here is: Given that we are writing PG exercises, is it reasonable/acceptable to officially assume that a number like 1.49999999999991 has rounding error, and "should" have been 1.5? So that we we intentionally round, it rounds to 2, not 1.

This most often comes up for me in problems where the answer is Currency, to the cent. So something like 0.01499999999991 is 0.015 when the student does work on paper. And the student wants to answer with $0.02, but WeBWorK is expecting $0.01.

@pstaabp
Copy link
Copy Markdown
Member

pstaabp commented May 28, 2026

This would be a good candidate for a unit test to make sure that a lot of the cases work as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants