Skip to content
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

Add most basic runtime conversion checkers #208

Merged
merged 8 commits into from
Dec 11, 2023

Conversation

chiphogg
Copy link
Contributor

@chiphogg chiphogg commented Dec 9, 2023

To start out with, this is only for Quantity (not QuantityPoint),
and only for same-rep conversions (so no explicit-rep variants). The
plan is to start getting some experience with these, check them out on
godbolt, make some runtime-checking converters that use them, etc. We
can follow up later with a more complete set of APIs. Oh, and docs, too
--- this is starting out as an undocumented feature.

Every utility's signature looks something like:

constexpr bool is_foo(Quantity q, TargetUnit target);

The instances of is_foo that we provide are specifically:

  • will_conversion_overflow
  • will_conversion_truncate
  • is_conversion_lossy

The third is the disjunction of the first two. So we would use them
something like this:

constexpr bool ok = is_conversion_lossy(inches(36), feet);

The above would return false, but would return true if we replace
inches(36) with inches(37).

On the implementation side, each magnitude-applying type gets its own
dedicated utility. This is nice, because then the function that checks
each condition appears directly next to the function that applies the
magnitude, so it's easy to check that they're consistent! (For example,
when applying a rational magnitude to an integral type, we check whether
the numerator alone would cause overflow, because we know we'd be
multiplying by that numerator before dividing by the denominator.)

Along the way, we also make a new type trait to make it easy to get the
appropriate type of magnitude applicator. This made it a lot easier to
write the tests.

And speaking of tests, we concentrate the vast majority of them in the
detail-namespaced helpers, which separately check for overflow and
truncation. For the :quantity_test tests, we just pick one
representative test case that is just complicated enough to give us
confidence that we're correctly using the utilities.

The most exciting test case is the one for is_conversion_lossy(). We
take one type (uint8_t), and check for every representable value
that a round-trip unit coversion is the identity, if and only if we
have identified that conversion as "not lossy". We got 100% correct
results for both inches-to-feet (truncation), and feet-to-inches
(overflow). Nice!

Each magnitude applying type gets its own dedicated utility.  This is
nice because the overflow condition appears directly next to the
function that applies the magnitude, so it's easy to check.  For
example, when applying a rational magnitude to an integral type, we
check whether the _numerator alone_ would cause overflow, because we
know we'd be multiplying by that numerator before dividing by the
denominator.

We also make a new type trait to make it easy to get the appropriate
type of magnitude applicator.  This made it a lot easier to write the
tests.
Once again, each magnitude-applying type gets its own dedicated utility,
bringing the same benefits as for the overflow checker.  When it comes
to truncation, most helpers get a much simpler implementation: it's
simply always `false` if we're doing a pure integer multiply, or if the
type itself is floating point.  On the _implementation_, we do get an
extra level of indirection, because the pure-integer-divide application
results are different depending on whether or not our type is floating
point.
Every signature looks something like:

```cpp
constexpr bool is_foo(Quantity q, TargetUnit target);
```

The instances of `is_foo` that we provide are specifically:

- `will_conversion_overflow`
- `will_conversion_truncate`
- `is_conversion_lossy`

The third is the disjunction of the first two.  So we would use them
something like this:

```cpp
constexpr bool ok = is_conversion_lossy(inches(36), feet);
```

The above would return `false`, but would return `true` if we replace
`inches(36)` with `inches(37)`.
@chiphogg chiphogg added the release notes: ✨ lib (enhancement) PR enhancing the library code label Dec 9, 2023
I forgot I had already computed `is_lossy`...
Hey, it still finishes pretty quickly!
@chiphogg
Copy link
Contributor Author

It turns out, this PR's implementation isn't quite right. Due to integer promotion, the type of T * T can be different than the type of T, for small types like uint8_t or int16_t. This results in us being a little too conservative, and labeling some conversions as lossy that actually work fine. We can expose this by testing the conversions for a 16-bit type between meters and yards.

This only occurs for (non-integer, non-inverse-integer) rational factors applied to integral types, and only when the integral types are small enough for integer promotion to come into play.

I think the best approach is to solve this in a follow-up PR at some point. Otherwise, it would probably add too much complexity to this PR.

Copy link
Contributor

@geoffviola geoffviola left a comment

Choose a reason for hiding this comment

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

Looks very useful. The tests cover a lot of cases. The feature seems ready for a soft release.

TEST(IsConversionLossy, CorrectlyDiscriminatesBetweenLossyAndLosslessConversions) {
// We will check literally every representable value in the type, and make sure that the result
// of `is_conversion_lossy()` matches perfectly with the inability to recover the initial value.
auto test_round_trip_for_every_uint8_value = [](auto source_units, auto target_units) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you mean for this to be named uint16?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, thanks! Fixed.

// We will check literally every representable value in the type, and make sure that the result
// of `is_conversion_lossy()` matches perfectly with the inability to recover the initial value.
auto test_round_trip_for_every_uint8_value = [](auto source_units, auto target_units) {
for (int i = 0; i <= 65535; ++i) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense to do the follow?

for (int i = numeric_limits<uint16_t>::min(); i <= numeric_limits<uint16_t>::max() ; ++i) {

{
using ApplyOneBillionToF = ApplyMagnitudeT<float, decltype(ONE_BILLION)>;

EXPECT_TRUE(ApplyOneBillionToF::would_overflow(3.403e29f));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any significance to this value or was it found emperically?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is one one-billionth of the maximum float value, which I looked up.

(Well, slightly above one one-billionth of that value.)

@chiphogg chiphogg merged commit 8eaa08f into main Dec 11, 2023
10 checks passed
@chiphogg chiphogg deleted the chiphogg/runtime-convertibility-checks#110 branch December 11, 2023 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
release notes: ✨ lib (enhancement) PR enhancing the library code
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants