8000 Improve `toBest` when no candidates meet the cutoff by wchargin · Pull Request #347 · convert-units/convert-units · GitHub
[go: up one dir, main page]

Skip to content

Conversation

@wchargin
Copy link
Contributor

This patch changes the semantics of toBest's cut-off number in the case where no candidate conversion meets the cut-off. The old semantics could be described as:

toBest finds the conversion whose scalar is closest to the cut-off,
without exceeding it.

The new semantics can be described as:

toBest finds the conversion whose scalar is closest to the cut-off,
preferring to not exceed the cut-off if at all possible.

Consider a system with the units ["mm", "cm", "m"]. If the user asks for the "best" representation of 0.01 cm, candidate answers are 0.1 mm, 0.01 cm, or 0.0001 m. Of these, none meets the standard cutoff of having a scalar value at least 1. But we can still see intuitively that 0.1 mm is the answer that would be most readable to a human.

Previously, this library at 3.0.0-beta.6 would return null in such a case, but at 3.0.0-beta.7 it returns the input value unchanged---even if it was in a different system of units than the one requested! Callers could work around the null-return behavior by doing extra logic in that case, but the change to just return the input made it possible to distinguish this "couldn't find an answer" case from one in which the input was legitimately the best result.

I believe that this behavior is more natural than the existing behavior when using the default cut-off of 1, and in a GitHub-wide code search I could not find any public examples of users setting the value to something other than 1.

This resolves #346 by making it so that we no longer need to call toBest with a cut-off value of 0. :-)

Test Plan:
All existing tests pass. Unit tests added, which fail before this commit and pass after it.

wchargin-branch: tobest-soft-cutoff

This patch changes the semantics of `toBest`'s cut-off number in the
case where no candidate conversion meets the cut-off. The old semantics
could be described as:

> `toBest` finds the conversion whose scalar is closest to the cut-off,
> without exceeding it.

The new semantics can be described as:

> `toBest` finds the conversion whose scalar is closest to the cut-off,
> preferring to not exceed the cut-off if at all possible.

Consider a system with the units `["mm", "cm", "m"]`. If the user asks
for the "best" representation of 0.01 cm, candidate answers are 0.1 mm,
0.01 cm, or 0.0001 m. Of these, none meets the standard cutoff of having
a scalar value at least 1. But we can still see intuitively that 0.1 mm
is the answer that would be most readable to a human.

Previously, this library at 3.0.0-beta.6 would return `null` in such a
case, but at 3.0.0-beta.7 it returns the input value unchanged---even if
it was in a different system of units than the one requested! Callers
could work around the `null`-return behavior by doing extra logic in
that case, but the change to just return the input made it possible to
distinguish this "couldn't find an answer" case from one in which the
input was legitimately the best result.

I believe that this behavior is more natural than the existing behavior
when using the default cut-off of 1, and in a GitHub-wide code search
I could not find any public examples of users setting the value to
something other than 1.

This resolves convert-units#346 by making it so that we no longer need to call
`toBest` with a cut-off value of 0. :-)

Test Plan:
All existing tests pass. Unit tests added, which fail before this commit
and pass after it.

wchargin-branch: tobest-soft-cutoff
wchargin-source: 6b312ca8e98572c60ab2e5cae4d58724561a16af
Copy link
Contributor Author
@wchargin wchargin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notes on prior behavior, now changed

const convert = configureMeasurements<'length', LengthSystems, LengthUnits>({
length,
});
const actual = convert(0.5e-3).from('μm').toBest({ cutOffNumber: 1 }),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior to this patch, converting 0.0005 μm to best returned 0.0005 μm instead of 0.5 nm.

Comment on lines +187 to +189
const actual = convert(inputInches)
.from('in')
.toBest({ cutOffNumber: 1, system: 'metric' }),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior to this patch, asking for the best metric-system representation of 1.968e-8 inches gave 1.968e-8 inches, even though that is an imperial measurement!

@wchargin
Copy link
Contributor Author

Hello! This is a semantic change to toBest. I hope that's okay, given your perspective in #313 that the definition of best is subjective, and also given that 3.0 is still in beta.

We use convert-units at my work, and the change in #313 means that we can't upgrade from 3.0.0-beta.6 to 3.0.0-beta.7 because we can no longer distinguish two important cases (see PR description). This PR would fix that for us by making the toBest result appropriate in more cases so we don't need to do post-processing.

As always, open to your feedback/thoughts.

@Taar
Copy link
Collaborator
Taar commented May 27, 2025

Sorry for the late reply and thank you for taking the time to look into improving the toBest feature. I really like the purposed improvements however, I have some suggestions that I would like addressed before merging this PR. I'll be leaving those suggestions as comments on the code so that it's easier to discuss.

const convert = configureMeasurements<'length', LengthSystems, LengthUnits>({
length,
});
const inputInches = convert(0.5).from('nm').to('in');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer the value that is going to be used in the test be in-lined rather than using the library to calculate that value. I believe it would make the test little brittle and remove the possibility of the test failing if there is a regression in the to method which would add a fair bit of noise. In general, I like the tests to be as lean as possible and only utilize code that is absolutely necessary for the test. I also believe that it makes the test more readable given that I wouldn't need to convert 0.5nm to in in order to see what the input value is.

Comment on lines +251 to +275
const meetsCutOff = (result: BestResult<TUnits>): boolean =>
isNegative ? result.val <= cutOffNumber : result.val >= cutOffNumber;

// Any result that meets the cutoff is better than any result that doesn't.
// Among candidates that meet the cutoff, smaller results (closer to the
// cutoff) are better. Among candidates that fail the cutoff, larger
// results (again, closer to the cutoff) are better.
const isBetter = (
candidate: BestResult<TUnits>,
best: BestResult<TUnits>
): boolean => {
const candidateMeetsCutOff = meetsCutOff(candidate);
const bestMeetsCutOff = meetsCutOff(best);
if (candidateMeetsCutOff && !bestMeetsCutOff) {
return true;
}
if (bestMeetsCutOff && !candidateMeetsCutOff) {
return false;
}
if (candidateMeetsCutOff) {
return isNegative ? candidate.val > best.val : candidate.val < best.val;
} else {
return isNegative ? candidate.val < best.val : candidate.val > best.val;
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like how clean and readable this code is! It's a large improvement over the original. However, I am not a fan of defining a function within another function unless it is being returned (a closure). I would like to see these functions placed inline or turned into private class methods. Though, I do think that the meetsCutOff could be inline as the variable names are descriptive enough. I really don't see the benefit of having a function for a single line of logic that is used in two places which are right beside one another.

Comment on lines +259 to +260
candidate: BestResult<TUnits>,
best: BestResult<TUnits>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the .val property is the only property being accessed, this function should accept number instead of BestResult<TUnits>. However, I'm wondering if this function definition could be improved to make it a bit more explicit which of the two arguments the returning boolean value is actually referring too. It's sort of implied but when I just look at the function being using, isBetter(candidate, best), it's not super clear to me. The variable names sort of hint at it but I still need to read the function's body to find out if my assumption is correct.

Which makes me wonder if the function signature was this: (candidate: BestResult<TUnits>, best: BestResult<TUnits>): BestResult<TUnits>, it would be a lot more clear and easier to reason about at a glance. Maybe the null check could be handled by the function as well: (candidate: BestResult<TUnits>, best: BestResult<TUnits> | null): BestResult<TUnits>. Returning the candidate if best is null.

This would also, in my opinion, simplify the code at line 282 to 289.

best = isBetter(
  {
    val: this.to(possibility),
    unit: possibility,
    singular: unit.singular,
    plural: unit.plural,
  }, 
  best,
);

I'm not sure if the name isBetter since makes sense in this case but at the moment I am not able to think of a better name. Maybe you have some ideas?

The function would also need to know if the original value is negative and also the cut off number. So maybe a closure would make sense? With that approach it would be possible to return a different function based on isNegative since it does not change once it is set. Not sure if removing a simple check would make a huge difference. Though, there is a possibility that doing so could also make the code harder to read. Maybe some experimentation is warranted :)

Anyways, sorry that was a bit rambly. What are your thoughts about all of this? Am I off base here?

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.

Conversion bug for values < 1 with cutOffNumber 0

2 participants

0