Range-Validated Quantity Points
Theme
Contents

Range-Validated Quantity Points

Range-Validated Quantity Points

Document #: P4412R0 Latest Status
Date: 2026-10-23
Project: Programming Language C++
Audience: SG6 Numerics
Reply-to: Mateusz Pusz
<>

1 Revision history

1.1 R0

Initial revision. The design was presented as Section 8 “Range-validated quantity points” of [P4185R1]. At Brno 2026, SG6 took the following poll:

POLL: Encourage a split of Section 8 “Range validated quantity points” in P4185R1 into its own paper so that we can forward it to LEWG. SG6 believes this approach is likely to increase consensus of P3045R8 “Quantities and units library”

SF
F
N
A
SA
3 5 1 1 0

Outcome: Consensus in favour

Changes since that section:

2 Introduction

A latitude lies in [−90°,90°][-90°, 90°], a longitude wraps around at ±180°±180°, a drone may fly up to 120m120\ \mathrm{m} above the ground, and a clinical thermometer that reports 51°C51\ \mathrm{°C} is broken and its reading must not reach the code that doses a drug.

[P3045R10] models each of these values as a point in an affine space, measured from an origin. It checks dimensions, units, and quantity kinds at compile time. It doesn’t check that a value lies in the domain of its origin, so every function that receives one has to check it.

This paper attaches a bounds policy to a point origin. The library applies the policy to the values stored at that origin: on construction, on conversion to another unit, representation, or origin, on ++ and --, and on += and -=. A policy either checks the value or brings it into the range:

inline constexpr struct geo_longitude : named_quantity_spec<isq::angular_measure> {} geo_longitude;

inline constexpr struct prime_meridian
  : absolute_point_origin<geo_longitude, wrap_to_range{-180. * deg, 180. * deg}> {} prime_meridian;

template<typename T = double>
using longitude = quantity<point<geo_longitude[deg], prime_meridian>, T>;

longitude<> lon = prime_meridian + 270. * deg;   // stored as -90°

The feature is an addition to [P3045R10]. An origin without a bounds policy behaves as it does there.

3 Motivation

3.1 A bounded representation type can’t see the unit

One way to bound a point is to delegate bounds to the representation: a numeric type whose constructor rejects a value outside the range. The representation doesn’t know the unit, so the bounds mean nothing once the unit changes. A bounded<double, 0, 360> meant for a bearing in degrees accepts 7 when the same bearing is stored in radians, and 7 rad is about 401°.

A representation can be taught to rescale its bounds with the unit, as the example of magnitude-aware scaling in [P3045R10] shows. It still can’t follow a change of origin. Converting a point to another origin adds an offset to the value. The representation never sees that offset, so its bounds stay where they were. The bounds also become part of the representation type: they are restated wherever the type is named, and nothing stops two representations with different bounds from being used at the same origin.

3.2 Checks at the call site are repeated and forgotten

Another way to bound a point is to check every value where it is used. Here, a trim angle is measured from an origin trim_zero that has no bounds:

inline constexpr struct trim_zero : absolute_point_origin<isq::angular_measure> {} trim_zero;

void set_trim(quantity<point<deg, trim_zero>> trim)
{
  const quantity a = trim.delta_from(trim_zero);
  if (a < -10. * deg || a > 10. * deg) throw std::out_of_range("trim angle");
  // ...
}

The comparison converts both sides to a common unit, so unlike a bounded representation it stays correct if trim is stored in radians. The other problems remain. The bounds are written again in every function that takes a trim angle. A caller can’t tell whether its value was validated already, so it checks again or doesn’t check at all. For a cyclic domain, every operation that produces a new value needs its own modulo reduction: turning a bearing by a given angle, in either direction, can leave [0°,360°)[0°, 360°).

3.3 The range belongs to the origin

Whether a value is in range depends on the origin it is measured from. An altitude above mean sea level may lie in [−500m,12000m][-500\ \mathrm{m}, 12\,000\ \mathrm{m}], and the altitude of a drone above a runway in [0m,120m][0\ \mathrm{m}, 120\ \mathrm{m}]. Both are altitudes in meters, but they are measured from different origins, and this is why we put the bounds on the origin:

Alternatives considered explains why the bounds are not a parameter of the point type and not a property of a quantity specification.

4 Design

4.1 Bounds on an origin

absolute_point_origin and relative_point_origin take an optional bounds policy after the quantity specification or the origin point. The library recognizes it by its min or max data member (Writing a policy lists the rest of the requirements), and any other argument is ill-formed:

inline constexpr struct equator
  : absolute_point_origin<geo_latitude, check_in_range{-90. * deg, 90. * deg}> {} equator;

inline constexpr struct ice_point_sensor
  : relative_point_origin<si::ice_point + delta<deg_C>(0.),
                          check_in_range{delta<deg_C>(-40.), delta<deg_C>(85.)}> {} ice_point_sensor;

The bounds are deltas from the origin they are attached to, not points. They are written in any unit, and the policy converts them to the unit and representation of the value it tests. Bounds given in degrees work for a point stored in radians:

quantity<point<geo_longitude[rad], prime_meridian>> lon{4. * rad, prime_meridian};
// 4 rad is about 229.2°, so the stored value is about -130.8°, or -2.283 rad

The trim angle of Checks at the call site are repeated and forgotten gets its range the same way, and the check moves from set_trim into the construction of the point. The later examples that name trim_zero use this definition:

inline constexpr struct trim_zero
  : absolute_point_origin<isq::angular_measure, check_in_range{-10. * deg, 10. * deg}> {} trim_zero;

A bounds policy is a template argument, so it adds no storage to the point. The point type quantity<point<R, PO>, Rep> is as large as Rep, and it stays trivially copyable whenever Rep is.

4.2 Where the bounds apply

The library applies the policy of the origin to the values it stores:

The binary operators point + quantity, point - quantity, origin + quantity, and origin - quantity construct their result, so the result is checked as well. The result of point - point or of delta_from() is a delta, not a point, and isn’t checked.

Value-initialization, as in T{} and std::vector<T>(n), stores a zero delta, which is the origin itself, without applying the policy. We propose that a point be default-constructible only where its origin lies within the range of the policy that construction applies. As in the nesting rule of Bounds of their own, the range is that of the policy’s min and max, with the max of wrap_to_range always excluded, so the answer is known at compile time for every policy. A user-defined policy whose test is narrower than its min and max, as in the bands example, can still reject or move the zero delta when zero falls in a gap of its domain. At an origin with check_in_range{10. * m, 20. * m}, the point type isn’t std::default_initializable: std::vector<T>(n), resize(n), and std::map::operator[] don’t compile for it, while std::vector<T>(n, value) and resize(n, value) do. An origin with check_in_range{0. * m, 10. * m} and a wrapping origin such as prime_meridian keep the default constructor, and so does a point that [P4411R0] floors at zero when it is measured from the natural origin (see Interaction with non-negative quantities). Default-initialization, T t;, leaves the value indeterminate, as it does for the representation.

4.3 Checking and adjusting policies

The return type of a policy’s call operator decides what the library does with the result:

Any other return type is ill-formed. A bool is neither a quantity nor a valid representation of one, so the two kinds can’t be confused.

The paper proposes six policies:

Policy
Kind
Range
Behavior
check_in_range checking [min, max], ends excludable rejects a value outside
check_non_negative checking [0, +∞] rejects a negative value
clamp_to_range adjusting [min, max] stores the nearest end
clamp_non_negative adjusting [0, +∞] stores zero for a negative one
wrap_to_range adjusting [min, max) wraps around, e.g. angles
reflect_in_range adjusting [min, max] folds back at both ends

check_non_negative and clamp_non_negative exist because many domains are bounded on one side only, and [0,+∞][0, +∞] is the most common one. They need no argument. A half-line has no period to wrap around, so there is no wrap_non_negative. Reflecting at zero would be abs(), and no reflect_non_negative is proposed.

A domain that isn’t an interval, or a response the library doesn’t offer, takes a user-defined policy. Writing a policy shows several.

4.4 Excluded ends

A plain quantity given as min or max is included in the range. exclusive_bound{q} excludes it:

inline constexpr struct north
  : absolute_point_origin<geo_bearing, check_in_range{0. * deg, exclusive_bound{360. * deg}}> {} north;

check_in_range accepts exclusive_bound at either end. wrap_to_range never produces its max, so its max is excluded whether or not it is written as exclusive_bound. However, wrapping can produce min, and an excluded min is ill-formed there. clamp_to_range and reflect_in_range need a value at each end to clamp to or reflect at. This is why they reject exclusive_bound at compile time.

In namespace std the name has to say what it qualifies. exclusive, open, and excluded are too generic, and std::open reads as an I/O function.

4.5 Bounds the point can hold

A policy compares the value with each bound in the type of the value: the policy converts the bound to the quantity type of the point and compares there. It doesn’t compare in a common type, because two quantity types need not compare with each other: a quantity with the constrained<double> representation of [mp-units] doesn’t compare with a bound stored as double. Without the rule proposed below, a bound that the point’s quantity type can’t hold would therefore be moved before it is compared, and the range would be checked somewhere else than declared:

inline constexpr struct tape_start
  : absolute_point_origin<isq::length, check_in_range{16'777'217 * m, 20'000'000 * m}> {} tape_start;

quantity<point<isq::length[m], tape_start>, float> p{16'777'216.f * m, tape_start};
// as a float, the lower bound is 16'777'216 m, so a value below the declared one passes

A relative origin has the same problem with its offset. A point measured from a relative origin without bounds of its own is translated by the offset into the frame of the ancestor whose bounds apply. With an integral representation, an offset of 0.5 * m becomes 0 m, and an offset of 1'500 * m becomes 1 km in a point stored in int kilometers.

We propose that every constant the check uses be exact in the type the check runs in. An adjusting policy stores its result in the point, so its check runs in the point’s quantity type, and so does the check of bounds that belong to the point’s own origin:

  1. Each bound converts to the point’s quantity type, translated into the frame of the origin whose bounds apply, without changing its value.
  2. With a representation that isn’t a floating-point one, the offset into that frame converts to the point’s quantity type without changing its value.

A checking policy inherited from an ancestor only reports, and the value is stored unchanged, so nothing has to be translated back into the point’s quantity type. Such a check runs in the common type of the value and the offset into the frame of the ancestor, and each bound has to be exact in that type only. A point stored in int kilometers, measured from an origin without bounds that lies 1'500 * m above an origin with check_in_range{delta<m>(-500), delta<m>(12'000)}, is checked in int meters. The same holds for the floor of [P4411R0] (see Interaction with non-negative quantities): a point in degrees Celsius is measured from si::ice_point, 273.15 K above the true zero, which no int in degrees Celsius holds. Its check runs in int millikelvin, so point<deg_C>(-273) is accepted and point<deg_C>(-274) is rejected exactly.

A point that breaks the rule that applies to it offers no constructor from a delta, no is_within_bounds, no try_make, no min(), and no max(). These functions reject it through their constraints, so a generic algorithm can test for it. Other operations on such a point, such as a conversion from another point or +=, are ill-formed when used. A point whose inherited checking bounds are exact only in the common type offers the constructors, is_within_bounds, and try_make, but not min() and max(), which return a value of the point’s own type (see min(), max(), and numeric_limits). The min and max members of a user-defined policy are subject to the same rules.

A floating-point representation is treated differently in two places:

The rule has a cost for a float point. 0.1 * m is a double, and the double value of 0.1 isn’t a float value, so this origin can’t be used with a float point:

inline constexpr struct tape_end : absolute_point_origin<isq::length, check_in_range{0.1 * m, 10. * m}> {} tape_end;
inline constexpr struct tape_end_f : absolute_point_origin<isq::length, check_in_range{0.1f * m, 10.f * m}> {} tape_end_f;

quantity<point<isq::length[m], tape_end>> a{0.5 * m, tape_end};               // OK
quantity<point<isq::length[m], tape_end_f>, float> b{0.5f * m, tape_end_f};   // OK
// quantity<point<isq::length[m], tape_end>, float> c{0.5f * m, tape_end};    // ill-formed

The float point needs an origin with bounds written as float, such as tape_end_f. A relative origin that only inherits the bounds of tape_end can be used with a float point, because the inherited check runs in double, the common type of the value and the offset. A bound with a small integral value, such as -90. * deg, is exact in float, which holds every integer up to 2242^{24}.

Author preference is this rule. It rejects code that combines a float point with double bounds, and SG6 may prefer to accept rounding for floating-point points altogether. The options are discussed in Exactness for floating-point points.

4.6 NaN and infinity

A floating-point value can be NaN or infinite. Each policy states what it does with them:

Policy
NaN
±∞
check_in_range rejected rejected
check_non_negative rejected +∞ accepted, -∞ rejected
clamp_to_range stored as NaN nearest end
clamp_non_negative stored as NaN +∞ stored, -∞ gives 0
wrap_to_range, reflect_in_range stored as NaN stored as NaN

The checking policies are written as a positive test, min <= v && v <= max, so a value that compares unordered with the bounds fails it. A NaN never passes a checking policy. check_non_negative has no upper bound to compare with, so it accepts +∞. The clamping policies compare v < min and v > max, both false for NaN, so NaN passes through them unchanged. An infinite value has no position after it is wrapped or reflected, so the wrapping and reflecting policies return NaN for an infinite or NaN input.

wrap_to_range and reflect_in_range bring any finite value into the range, however far outside it lies. For an integral representation they take the exact remainder, provided that the period fits in the representation. For a floating-point one they subtract multiples of the period that double in size, so a value far outside the range doesn’t need a step per period. A wrapped value never equals max: a floating-point result that rounds onto max is stored as min, which is the same point of the circle.

4.7 Contract semantics

What happens to a value that a checking policy rejects depends on the representation type.

4.7.1 A precondition by default

By default, the operations that store a value have a narrow contract. A value that the applied checking policy rejects violates the precondition of the operation that stores it. This applies to every operation listed in Where the bounds apply. For the constructor from a value and an origin:

template<quantity_reference auto R, representation_of<get_quantity_spec(R)> Rep>
class quantity {
public:
  // a member of a point only, where R is point<R2, PO>
  template<typename FwdQ>
  constexpr quantity(FwdQ&& q, decltype(PO) origin)
    pre(/* q satisfies the checking policy applied at PO,
           unless constraint_violation_handler<Rep> is specialized */);
  // ...
};

We propose that the precondition be a contract assertion [P2900R14], so that violating it has the behavior of the evaluation semantic and isn’t undefined behavior:

A program that validates its inputs elsewhere can turn the checks off to avoid any overhead, while a safety-critical build can enforce them. A plain Preconditions element wouldn’t give this behavior, because violating it is undefined behavior. A hardened precondition [P3471R4] wouldn’t either, because only a hardened implementation checks it. How the wording spells the contract assertion is for LEWG to decide.

An adjusting policy places no precondition on the value, and neither does an origin without bounds. Operations on such a point have a wide contract. Their behavior doesn’t depend on the evaluation semantic, unless the policy contains an assertion of its own (see Rounding noise and real errors).

4.7.2 A wide contract with a handler

Some code needs the check in every build, for example a control loop that must not run on a corrupted value. For it, a representation type can specialize constraint_violation_handler:

class checked_double { /* ... */ };

template<>
struct std::constraint_violation_handler<checked_double> {
  static void on_violation(std::string_view msg) { throw std::domain_error(std::string(msg)); }
};

When the specialization exists, the operations that store a value at a point with that representation have no precondition on the value. The library calls on_violation for every value a checking policy rejects, whatever the evaluation semantic. It does so before the value is stored, so a handler that throws leaves the point unchanged. The handler may throw, terminate, or log and return. If it returns, the value is stored unchanged.

The handler is chosen by the representation, not by the origin. With the origin trim_zero of Bounds on an origin, a front end that must never store an invalid value can use quantity<point<deg, trim_zero>, checked_double>, and a solver can use quantity<point<deg, trim_zero>, double> with the checks turned off. Both share the origin, its bounds, and the conversions between them.

[mp-units] also provides constrained<T, ErrorPolicy>, a wrapper that specializes the handler for any T, with a throw_policy and a terminate_policy. It isn’t proposed here. It is a convenience that can be proposed later on top of the customization point.

4.7.3 During constant evaluation

Every proposed policy and every operation that applies one is usable in constant expressions. Without a handler, a violation during constant evaluation follows the evaluation semantic that the implementation chooses for that evaluation (6.11.2 Evaluation [basic.contract.eval]). With a terminating semantic, a violation in a manifestly constant-evaluated context, such as the initializer of a constexpr variable, makes the program ill-formed. With observe the implementation issues a diagnostic, and with ignore the value is accepted:

// 11° is outside [-10°, 10°]: ill-formed under enforce or quick-enforce
constexpr quantity<point<deg, trim_zero>> trim{11. * deg, trim_zero};

With a handler, a constexpr variable initialized from a rejected value is ill-formed unless the handler is usable in constant expressions and returns.

4.8 Validating a value before construction

A value that comes from outside the program, such as a sensor reading or a field of a message, has to be tested and rejected without stopping the program. Neither path above returns the outcome to the caller: a precondition isn’t meant to be violated, and a handler reports through an exception or not at all. Without help, the caller reads the bounds back and writes the comparison by hand.

The paper proposes two static member functions of the point type, a query, is_within_bounds, and try_make, built on it:

using trim_angle = quantity<point<deg, trim_zero>>;

std::optional<trim_angle> parse_trim(quantity<deg> raw)
{
  if (!trim_angle::is_within_bounds(raw, trim_zero)) return std::nullopt;
  return trim_angle{raw, trim_zero};
}

std::expected<trim_angle, bounds_violation> make_trim(quantity<deg> raw)
{
  return trim_angle::try_make(raw, trim_zero);
}

is_within_bounds returns true when constructing the point from the same arguments would not report a violation. It doesn’t construct the point it is asked about. It converts the value to the point’s quantity type and calls the predicate of the policy that construction applies, so it also works with a user-defined checking policy. With the proposed checking policies, it returns false for NaN.

try_make returns the point, or a bounds_violation, which has no data members. It tests the value once. A rejected value is never stored, and a value that passes is stored without being tested again. The outcome therefore doesn’t depend on a handler or on the evaluation semantic. It is the wide-contract counterpart of the constructor. A user can write the same function over std::optional or an in-house error type from is_within_bounds and the constructor, as parse_trim does. However, the constructor then tests the value a second time.

is_within_bounds and try_make are offered when the policy that construction applies is a checking one, and for a point without bounds, where is_within_bounds always returns true. An adjusting policy accepts every value but may store it changed, so true wouldn’t say whether the point stores the value it is given. Neither is offered for a point that breaks the rule of Bounds the point can hold.

A value can also arrive as a point of another type: measured from another origin with the same absolute origin, stored in another unit or representation, or imported from a type that quantity_like_traits describes as a point with the same absolute origin. Both functions also take such a point, and test the conversion that constructing the point from it performs. Here a trim_angle is converted to a point measured from a narrower origin:

inline constexpr struct takeoff_trim
  : relative_point_origin<trim_zero + 0. * deg, check_in_range{-5. * deg, 5. * deg}> {} takeoff_trim;

using takeoff_angle = quantity<point<deg, takeoff_trim>>;

std::expected<takeoff_angle, bounds_violation> for_takeoff(trim_angle trim)
{
  return takeoff_angle::try_make(trim);
}

Both functions are members of the point type and not functions in namespace std. A free std::try_make<T>(args...) reads as the fallible construction of any T, a facility that has nothing to do with quantities, and taking that name for points would block it. A free std::is_within_bounds<T>(args...) reads just as generically. Neither can be a hidden friend, because the point type appears only as an explicit template argument, which doesn’t take part in argument-dependent lookup. As members, they still let the caller name the point type once, as with min() and max(), which are static members of the point type already. try_from was considered and rejected: it names a conversion, while try_make constructs a point, as the constructor it wraps does.

4.9 min(), max(), and numeric_limits

A bounded point type reports its bounds through its static min() and max() and through std::numeric_limits, instead of the limits of its representation. For a point, min() and lowest() of numeric_limits both return the lower bound:

static_assert(longitude<>::min().delta_from(prime_meridian) == -180. * deg);
static_assert(std::numeric_limits<longitude<>>::lowest() == longitude<>::min());
// longitude<>::max() doesn't exist: the max of wrap_to_range is excluded

An excluded end has no value in the range, so neither the point type nor numeric_limits provides min() for an excluded lower end or max() for an excluded upper end. Neither is provided for a point whose bounds and offset aren’t exact in its own quantity type, even where an inherited check is exact in the common type (see Bounds the point can hold).

A point that [P4411R0] floors at zero reports as its min() the true zero, translated into its frame, or the lower bound of its origin’s range where that is higher, provided that the value is exact in its quantity type. For quantity<point<isq::distance[m]>> it is 0 m, and for quantity<point<deg_C>> it is -273.15 °C. lowest() of numeric_limits returns min() wherever min() exists. quantity<point<deg_C>, int> has no min(), and its lowest() is -273 °C, the lowest value that the floor accepts. A floored point without min() has no lowest() when its representation is a floating-point one or when the range of its origin bounds it from below.

A point whose origin has no bounds of its own reports the bounds of its nearest bounded ancestor, translated into its own frame. With a floating-point representation the translation rounds, and the rounded value may be one that the ancestor rejects:

inline constexpr struct strip_start : absolute_point_origin<isq::length, check_in_range{0.1 * m, 0.9 * m}> {} strip_start;
inline constexpr struct strip_mark : relative_point_origin<strip_start + 0.7 * m> {} strip_mark;

using strip_point = quantity<point<isq::length[m], strip_mark>>;

// 0.1 m - 0.7 m = -0.6 m, but -0.6 m + 0.7 m rounds to 0.09999999999999998 m
static_assert(strip_point::min().delta_from(strip_mark) == delta<m>(-0.5999999999999999));
static_assert(strip_point::max().delta_from(strip_mark) == 0.2000000000000001 * m);
// one step above 0.9 m - 0.7 m still translates back to 0.9 m

We propose to specify min() by what it guarantees: it returns the lowest value of the point type that, translated as construction translates it, isn’t below the lower bound of the ancestor, and max() the highest that isn’t above its upper bound. The translated bound as computed, -0.6 m here, is below the ancestor’s lower bound once it is translated back. [mp-units] translates the bound once, from the ancestor that declares it, and then moves the result by one representable value at a time until it is the extreme one that the policy accepts. With an integral representation the translation is exact and no step is taken.

The other members of numeric_limits, epsilon(), round_error(), infinity(), quiet_NaN(), signaling_NaN(), and denorm_min(), construct a point from a value of the representation. At an origin with bounds, its own or inherited, the policy would check or change that value. At an origin with check_in_range, quiet_NaN() would violate the precondition in a noexcept function, and at an origin with clamp_to_range{10. * deg, 20. * deg}, infinity() would return 20° and epsilon() 10°. We propose that numeric_limits not provide these members for a point whose origin has bounds, even where a policy accepts the value, and that has_infinity, has_quiet_NaN, has_signaling_NaN, and is_iec559 be false there.

The floor of [P4411R0] isn’t a bound of an origin, so a point that it floors keeps these members. Its quiet_NaN() and signaling_NaN() are built without the floor check, and reading the number back in the point’s own unit doesn’t check it again.

4.10 Relative origins

4.10.1 Inheriting the bounds of an ancestor

A relative_point_origin without bounds of its own is checked against the bounds of its nearest bounded ancestor. The library translates the value into the frame of that ancestor, applies its policy there, and translates the result back. When an adjusting policy changes nothing, the original value is stored, so that a round trip through a floating-point offset can’t change it. With a representation that isn’t a floating-point one, the offset has to be exact in it (see Bounds the point can hold):

inline constexpr struct mean_sea_level
  : absolute_point_origin<isq::altitude, check_in_range{delta<m>(-500.), delta<m>(12'000.)}> {} mean_sea_level;

inline constexpr struct runway : relative_point_origin<mean_sea_level + 1'600. * m> {} runway;

quantity<point<isq::altitude[m], runway>> a{8'000. * m, runway};   // OK: 9'600 m above sea level
quantity<point<isq::altitude[m], runway>> b{11'000. * m, runway};  // violation: 12'600 m

The policy runs once, on one translated value, however many origins lie between the point and the ancestor. The offset is computed at compile time.

4.10.2 Bounds of their own

A relative origin may also declare bounds of its own, usually because it describes something more restricted than its parent, for example a drone corridor above a runway, or a room inside a building whose HVAC system covers a wider range. Then only the child’s policy is applied, again once. That is sound only if every value inside the child’s bounds is also inside the bounds of every origin above it, so the library checks this at compile time:

inline constexpr struct drone_corridor : relative_point_origin<runway + 0. * m, check_in_range{0. * m, 120. * m}> {} drone_corridor;
// [1'600 m, 1'720 m] above sea level fits in [-500 m, 12'000 m]

The check catches a mistake like this one, where a maintainer copies the ceiling of the sea-level frame into the corridor:

inline constexpr struct drone_corridor
  : relative_point_origin<runway + 0. * m, check_in_range{0. * m, 12'000. * m}> {} drone_corridor;

This corridor ends 13’600 m above sea level, and a point at 11’000 m above the runway, 12’600 m above sea level, would pass its check. Without the nesting rule, the error surfaces at runtime, as a value that is valid for one origin and invalid for the other. With it, the program is ill-formed:

error: static assertion failed: relative origin upper bound violates the bounds of an origin above it

Altitudes above mean sea level: the drone corridor, 1'600 m to 1'720 m, lies within the bounds of mean_sea_level, -500 m to 12'000 m, while the copied ceiling ends at 13'600 m, outside them

The rule is this. Let B be a relative origin with bounds of its own, and A the nearest origin above B that has bounds. Origins without bounds between them are skipped. Then:

  1. The bounds of B, translated into the frame of A, lie within the bounds of A.
  2. If A bounds a side, B bounds it too. A corridor with only check_non_negative{} is rejected, because nothing would check its upper side against the sea-level ceiling.
  3. The ends are compared through the min and max members of the two policies, by value and by whether each end is excluded. Where A excludes an end that B includes, the end of B lies strictly inside the range of A.

Rule 3 catches a mistake that is harder to see than the copied ceiling. The western half of the compass, measured from south, ends where north of Excluded ends ends:

inline constexpr struct western_half
  : relative_point_origin<north + 180. * deg, check_in_range{0. * deg, 180. * deg}> {} western_half;

Both ranges end at 360° from north, but western_half includes that value, and north excludes it. Using western_half is ill-formed, with the same error as the copied ceiling. check_in_range{0. * deg, exclusive_bound{180. * deg}} is accepted, and so is an included end inside the range of north, such as 179.9 * deg.

Every bounded origin is checked against its nearest bounded ancestor, and that ancestor against its own, so a value inside the bounds of B lies between the min and max of every bounded origin above it. With floating-point offsets this holds up to the rounding of the translation. Two cases fall outside the guarantee. A policy whose test is narrower than [min, max], such as the bands example, may reject a value the child accepts. And a child policy that lets NaN through, such as clamp_to_range, can store a NaN that the ancestor’s check would reject when the point is converted to the ancestor’s origin.

The check runs when the bounds of the origin are first used: on construction of a point, in arithmetic, in is_within_bounds, try_make, min(), and max(). Defining the origin or naming a point type doesn’t trigger it. The check costs nothing at runtime.

For the user, the rule is short: a child’s range must fit inside the range of its nearest bounded ancestor. The compile-time check lets the library apply only one policy at runtime. The alternative, applying every policy up the chain on every store, costs a check per level and needs a rule for which policy wins when they disagree, for example when a child clamps and its parent rejects.

4.11 Interaction with non-negative quantities

[P4411R0] proposes a non_negative property of a quantity specification, and with it a floor at zero for points. A point is floored when its own quantity specification is non-negative, where a quantity kind kind_of<QS> takes the domain of its root QS, and when the chain of its origins ends at a natural origin, unless its representation is a complex number. The floor rejects a point below the true zero:

quantity d1 = point<isq::distance[m]>(5.);   // OK
quantity d2 = point<isq::distance[m]>(-1.);  // contract violation
quantity d3 = point<m>(-5.);                 // contract violation: kind_of<isq::length> is non-negative
quantity a1 = point<isq::altitude[m]>(-5.);  // OK: altitude is possibly_negative

The floor is a property of the quantity. [P4411R0] specifies it, and it isn’t one of the policies of this paper. The policies stay properties of origins, and the user attaches check_non_negative or clamp_non_negative to an origin like any other policy. A point measured from an origin of the user’s own, whose chain doesn’t end at a natural origin, isn’t floored. A net reading on a balance is measured from the tare and may lie below it:

inline constexpr struct tare : absolute_point_origin<isq::mass> {} tare;

quantity net = tare - 0.3 * kg;  // OK: 0.3 kg below the tare

Temperature needs no policy on any origin. [P4185R2] defines si::absolute_zero as the natural origin of thermodynamic temperature under a name of its own, and si::ice_point stays a relative origin defined from it. The chain of a point in degrees Celsius therefore ends at the natural origin, and the point is floored at the true zero (see Bounds the point can hold for an int point):

quantity t1 = point<deg_C>(-40.);   // OK
quantity t2 = point<deg_C>(-300.);  // contract violation: 26.85 K below the true zero

Without [P4185R2], si::absolute_zero is the absolute_point_origin of [P3045R10], and a point in kelvin or degrees Celsius isn’t floored.

Both apply when a floored point is measured from an origin with a policy, its own or inherited. The policy runs first, and the floor then checks the value to be stored, so the result of a clamp is checked too. The floor isn’t a bound of the natural origin, so the nesting rule of Bounds of their own doesn’t compare a range with it, and the range of an origin may reach below the true zero. Such an origin is well-formed, and a floored point measured from it gets both checks:

inline constexpr struct shelf
  : relative_point_origin<natural_point_origin<isq::length> + 5. * m, check_in_range{delta<m>(-10.), delta<m>(15.)}> {} shelf;

quantity<point<isq::height[m], shelf>> h1{delta<m>(-3.), shelf};    // OK: 2 m above the true zero
quantity<point<isq::height[m], shelf>> h2{delta<m>(-7.), shelf};    // contract violation: below the floor
quantity<point<isq::altitude[m], shelf>> a1{delta<m>(-7.), shelf};  // OK: an altitude isn't floored

The floor tests that the value isn’t negative, as the sign check of an absolute in [P4411R0] does, and a NaN isn’t negative, so it passes the floor. Whether a NaN is valid is a question of range: check_in_range, and check_non_negative attached to an origin, reject it. An adjusting policy that stores NaN, as nan_outside_range of NaN for a value outside the physics does at ambient_zero, stores it at a floored point too.

Nothing in this paper depends on [P4411R0]. Without it, no point is floored, and a user attaches check_non_negative to an origin explicitly.

5 Writing a policy

A policy is a structural type with:

Every example below was compiled and run against [mp-units] master, respelled to its quantity_point<R, PO, Rep>. The one that uses contract_assert needs GCC 16 with -fcontracts.

5.1 A domain that isn’t an interval

A transmitter may be tuned only inside its licensed bands. The domain is a union of two intervals, which check_in_range can’t express:

struct vhf_uhf_bands {
  quantity<MHz> min = 144 * MHz;
  quantity<MHz> max = 440 * MHz;

  template<delta_quantity V>
  constexpr bool operator()(const V& f) const
  {
    return (f >= V{144 * MHz} && f <= V{148 * MHz}) || (f >= V{430 * MHz} && f <= V{440 * MHz});
  }
};

inline constexpr struct carrier_zero
  : absolute_point_origin<isq::frequency, vhf_uhf_bands{}> {} carrier_zero;

using carrier = quantity<point<kHz, carrier_zero>>;

static_assert(carrier::min().delta_from(carrier_zero) == 144 * MHz);
bool ok = carrier::is_within_bounds(435. * MHz, carrier_zero);   // true
bool no = carrier::is_within_bounds(200. * MHz, carrier_zero);   // false

min and max declare the extent of the domain, not every gap in it. A relative origin under carrier_zero in the hierarchy without bounds of its own is checked with the band test itself. One with bounds of its own is compared by the nesting rule against the extent, [144,440]MHz[144, 440]\ \mathrm{MHz}, so the rule accepts a child range that covers the gap between the bands. A value in that gap stored at such a child isn’t tested against the bands.

5.2 NaN for a value outside the physics

A value outside what the physics allows can be stored as NaN instead of being rejected. That is an adjusting policy:

template<delta_quantity Q>
struct nan_outside_range {
  Q min;
  Q max;

  template<delta_quantity V>
    requires std::numeric_limits<typename V::rep>::has_quiet_NaN
  constexpr V operator()(const V& v) const
  {
    if (v >= V{min} && v <= V{max}) return v;
    return std::numeric_limits<V>::quiet_NaN();
  }
};

inline constexpr struct ambient_zero
  : relative_point_origin<si::ice_point + delta<deg_C>(0.),
                          nan_outside_range{delta<deg_C>(-60.), delta<deg_C>(60.)}> {} ambient_zero;

quantity t = ambient_zero + delta<deg_C>(85.);  // stored as NaN

The requires clause rejects an integral representation, which has no NaN to store. A NaN input fails the test and is stored as NaN again.

5.3 Rounding noise and real errors

A value in a non-negative domain often comes out slightly negative because of rounding. clamp_non_negative hides that, and it also hides a value that is negative because of a bug. A policy can tell the two apart:

template<delta_quantity Q>
struct snap_within_tolerance {
  Q min;
  Q max;
  Q tolerance;

  template<delta_quantity V>
  constexpr V operator()(const V& v) const
  {
    contract_assert(v >= V{min - tolerance} && v <= V{max + tolerance});
    if (v < V{min}) return V{min};
    if (v > V{max}) return V{max};
    return v;
  }
};

inline constexpr struct tank_bottom
  : absolute_point_origin<isq::height, snap_within_tolerance{0. * m, 2. * m, 1e-9 * m}> {} tank_bottom;

quantity l1 = tank_bottom - 1e-12 * m;  // stored as 0 m
quantity l2 = tank_bottom - 0.1 * m;    // contract violation in the policy

As this policy is an adjusting one, the storing operation has no precondition, and is_within_bounds isn’t offered. The violation is reported by the policy’s own contract_assert, which follows the evaluation semantic like any other assertion.

5.4 State in a policy

A policy is a template argument, so its non-static data members are compile-time constants. Any mutable state it uses has static or thread storage duration: a static member or local variable, or an object the policy points or refers to. Such state behaves as a global does, and every point checked by the policy shares it: at the origin, at the origins that inherit its bounds, and at any other origin that uses the same policy. This paper doesn’t propose per-object state:

State of one value can live in its representation type. The handler of A wide contract with a handler is chosen by the representation, and a representation can carry per-value data of its own.

5.5 Measurement uncertainty

A measurement is an observed value and an error distribution, and the distribution may extend past the bounds. A policy sees the value that is about to be stored, in the representation of the point. For double, that is the estimate, and the policy decides about the estimate alone.

A measured mass of −0.2±0.5kg-0.2 ± 0.5\ \mathrm{kg} is a valid output of an estimator, even though no mass is negative. Storing it at an origin that checks non-negativity is a violation. The program keeps it as a plain quantity, or at an origin without bounds, and constructs the checked point once it has decided which value to use.

A representation that carries an uncertainty, such as uncertain<T> of [mp-units], can be used with a user-defined checking policy that receives the whole value and tests, for example, whether the interval of one standard uncertainty around the estimate overlaps the domain:

template<delta_quantity Q>
struct overlaps_range {
  Q min;
  Q max;
  template<delta_quantity V>
  constexpr bool operator()(const V& v) const
  {
    const auto& m = v.numerical_value_ref_in(V::unit);
    return m.upper_bound() >= min.numerical_value_in(V::unit) && m.lower_bound() <= max.numerical_value_in(V::unit);
  }
};

inline constexpr struct scale_zero : absolute_point_origin<isq::mass, overlaps_range{0. * kg, 1000. * kg}> {} scale_zero;

using reading = quantity<point<isq::mass[kg], scale_zero>, uncertain<double>>;

static_assert(reading::is_within_bounds(delta<kg>(uncertain{-0.2, 0.5}), scale_zero));
static_assert(!reading::is_within_bounds(delta<kg>(uncertain{-0.9, 0.5}), scale_zero));

The point type still reports 0 kg and 1000 kg as its min() and max(), so the estimate of an accepted value may lie outside them.

The proposed checking policies compare the value with each bound in the order of the representation. The order of uncertain<T> compares the estimates only, so check_in_range{0. * kg, 1000. * kg} accepts 1000±0.5kg1000 ± 0.5\ \mathrm{kg}, as it accepts 0±0.5kg0 ± 0.5\ \mathrm{kg}, and it rejects −0.2±0.5kg-0.2 ± 0.5\ \mathrm{kg}. If the order also compared the uncertainties, as a defaulted operator<=> does, both 0±0.5kg0 ± 0.5\ \mathrm{kg} and 1000±0.5kg1000 ± 0.5\ \mathrm{kg} would compare greater than the bound they lie on. The first would be accepted and the second rejected.

6 Feedback from Brno 2026

SG6 reviewed Section 8 of [P4185R1] at Brno 2026.

Feedback
Source
Answer
Write examples of a checker Action item Writing a policy
Can an out-of-physics value be stored as NaN? Participant NaN for a value outside the physics
Can a checker carry state, like an allocator, not a global? Participant State in a policy
Must bounds be part of the library, or can they be layered? Participant Alternatives considered
A measurement’s error distribution may exceed the bounds Participant Measurement uncertainty
Rounding can push a non-negative value below zero Participant Rounding noise and real errors
Hierarchical bounds look too complex, add an example Participant Bounds of their own
Split the section into its own paper Participant, poll this paper
The section isn’t ready, explore more Against vote Open questions

Earlier reviews raised the same subject. At Sofia 2025, a participant asked for runtime bounds checking, and another pointed out that preconditions are checks that HPC code can turn off. At Croydon 2026, a participant asked that the response to a negative value of a non-negative quantity be customizable, and another asked to see the possible error policies with their consequences and a recommendation. The recommendation of this paper is a precondition by default, with the evaluation semantic deciding what a violation does (see Contract semantics). An adjusting policy serves a domain where a value should be corrected, and a handler serves code where a check must run in every build.

7 Alternatives considered

7.1 A policy parameter of the point type

The policy could be a template parameter of the point type, so that one origin could be used with different policies in different parts of a program. Two points that differ only in their policy would then be distinct types. Assigning one to another, comparing them, or passing them to generic code would need conversions, the same burden that std::vector<T, Alloc> imposes when two allocators differ. The bounds describe the frame of measurement, so they are declared where the frame is defined. What happens to a rejected value, which may reasonably differ between two parts of one program, is chosen by the representation and by the evaluation semantic.

7.2 Bounded variants as distinct quantities

altitude_msl and altitude_agl could be siblings in the quantity hierarchy, each with its own bounds. This fails on several counts:

  1. Altitude is measured from mean sea level, the ground, the WGS 84 ellipsoid, the geoid, a pressure surface, and more. Every constraint would add a name for the same quantity.
  2. Converting altitude_agl to altitude_msl needs the elevation of the ground. Conversions in the quantity hierarchy are the same for every value, while each runway or site has its own elevation. A relative origin per site holds it. An elevation known only at runtime is out of scope for both designs.
  3. Subtracting two points of one variant must yield the same delta quantity for both variants. If it does, altitude_msl - altitude_agl also yields that delta and silently ignores the offset between the frames. Separate deltas per variant fix that and break every algorithm written for the shared delta. With origins, subtracting two points measured from related origins applies the offset between them, and points measured from unrelated origins can’t be subtracted at all.
  4. The ISQ defines altitude without a range. The range of a particular coordinate system or regulation isn’t part of the physical quantity, and it changes without the physics changing.

7.3 Bounds layered on top of the library

Bounds could be layered on top of the library instead of being part of it. A wrapper type around a point can check its own constructor. It can’t make the library’s operations check the result: +=, in(), point_for(), and point + quantity on the wrapped point produce unchecked values, and the wrapper has to re-implement each of them. It also can’t give a relative origin the bounds of its parent, because the library converts between origins and the wrapper never sees that conversion.

7.4 Coupled constraints

Some domains couple several values. Reflecting a latitude at a pole also moves the longitude by 180°, and polar coordinates couple r≥0r ≥ 0 with the range of the angle. A policy sees one value and can’t express that. A type that holds both coordinates enforces the coupled rule in its constructor, as geographic.h in the examples of [mp-units] does. This paper doesn’t propose support for it.

8 Proposed interface

This section lists the declarations the paper adds to [P3045R10], in the style of its synopsis. Wording follows once SG6 and LEWG confirm the design.

namespace std {

// point origins: Args is empty or a single bounds policy
template<quantity_spec auto QS, auto... Args>
struct absolute_point_origin : point−origin−interfacepoint-origin-interface {};

template<point_quantity auto QP, auto... Args>
struct relative_point_origin : point−origin−interfacepoint-origin-interface {};

// excluded end of a range
template<delta_quantity Q>
struct exclusive_bound {
  Q value;
};

// checking policies
template<range−boundrange-bound Lo, range−boundrange-bound Hi>
struct check_in_range {
  Lo min;
  Hi max;
  template<delta_quantity V>
  constexpr bool operator()(const V& v) const;
};

struct check_non_negative {
  zero−boundzero-bound min;
  template<delta_quantity V>
  constexpr bool operator()(const V& v) const;
};

// adjusting policies
template<delta_quantity Q>
struct clamp_to_range {
  Q min;
  Q max;
  template<delta_quantity V>
  constexpr V operator()(V v) const;
};

struct clamp_non_negative {
  zero−boundzero-bound min;
  template<delta_quantity V>
  constexpr V operator()(V v) const;
};

template<delta_quantity Q>
struct wrap_to_range {
  Q min;
  exclusive_bound<Q> max;
  constexpr wrap_to_range(Q min, Q max);
  constexpr wrap_to_range(Q min, exclusive_bound<Q> max);
  template<delta_quantity V>
  constexpr V operator()(V v) const;
};

template<delta_quantity Q>
struct reflect_in_range {
  Q min;
  Q max;
  template<delta_quantity V>
  constexpr V operator()(V v) const;
};

// opt-in to a wide contract; a specialization provides
// static void on_violation(string_view msg) and is declared before the point type is used
template<typename Rep>
struct constraint_violation_handler;  // not defined

// validation before construction
struct bounds_violation {
  friend constexpr bool operator==(const bounds_violation&, const bounds_violation&) = default;
};

template<quantity_reference auto R, representation_of<get_quantity_spec(R)> Rep>
class quantity {
public:
  // ...
  // members of a point only, where R is point<R2, PO>
  quantity() requires see below = default;
  template<typename FwdQ>
    static constexpr bool is_within_bounds(FwdQ&& q, decltype(PO) po);
  template<typename FwdQ>
    static constexpr bool is_within_bounds(FwdQ&& q);  // PO == default_point_origin(R2)
  template<typename QP>
    static constexpr bool is_within_bounds(const QP& qp);
  template<typename FwdQ>
    static constexpr expected<quantity, bounds_violation> try_make(FwdQ&& q, decltype(PO) po);
  template<typename FwdQ>
    static constexpr expected<quantity, bounds_violation> try_make(FwdQ&& q);  // PO == default_point_origin(R2)
  template<typename QP>
    static constexpr expected<quantity, bounds_violation> try_make(const QP& qp);
};

}

Args of a point origin is empty or a single bounds policy. The default constructor of a point is offered only where its origin lies within its range, as described in Where the bounds apply. range-bound is satisfied by a delta and by a specialization of exclusive_bound. zero-bound is an exposition-only type that represents zero in any unit. The deduction guides of clamp_to_range, reflect_in_range, and wrap_to_range reject exclusive_bound where Excluded ends says so. The static min() and max() of the point type gain the constraints of min(), max(), and numeric_limits. The constructors from a delta, the default constructor, is_within_bounds, try_make, min(), and max() gain the constraint of Bounds the point can hold. is_within_bounds(qp) takes a point of another type with the same absolute origin, or a value whose quantity_like_traits describe such a point, provided that the point type is constructible from it. Each try_make is offered when the is_within_bounds overload with the same parameters is.

The numeric_limits specialization of [P3045R10] changes for a point at a bounded origin:

namespace std {

template<auto R, typename Rep>
  requires numeric_limits<Rep>::is_specialized
class numeric_limits<quantity<R, Rep>> : public numeric_limits<Rep> {
public:
  // unchanged: min() and max() return the static min() and max() of the point type,
  // and lowest() returns min()
  static constexpr quantity<R, Rep> min() noexcept
    requires requires { quantity<R, Rep>::min(); };
  static constexpr quantity<R, Rep> max() noexcept
    requires requires { quantity<R, Rep>::max(); };
  static constexpr quantity<R, Rep> lowest() noexcept
    requires requires { quantity<R, Rep>::min(); };

  // changed for a point at a bounded origin
  static constexpr bool has_infinity = !bounded−pointbounded-point<R> && numeric_limits<Rep>::has_infinity;
  static constexpr bool has_quiet_NaN = !bounded−pointbounded-point<R> && numeric_limits<Rep>::has_quiet_NaN;
  static constexpr bool has_signaling_NaN = !bounded−pointbounded-point<R> && numeric_limits<Rep>::has_signaling_NaN;
  static constexpr bool is_iec559 = !bounded−pointbounded-point<R> && numeric_limits<Rep>::is_iec559;

  static constexpr quantity<R, Rep> epsilon() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::epsilon(); };
  static constexpr quantity<R, Rep> round_error() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::round_error(); };
  static constexpr quantity<R, Rep> infinity() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::infinity(); };
  static constexpr quantity<R, Rep> quiet_NaN() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::quiet_NaN(); };
  static constexpr quantity<R, Rep> signaling_NaN() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::signaling_NaN(); };
  static constexpr quantity<R, Rep> denorm_min() noexcept
    requires (!bounded−pointbounded-point<R>) && requires { numeric_limits<Rep>::denorm_min(); };
};

}

bounded-point<R> is true when R is point<R2, PO> and PO, or one of its ancestors, has bounds. The floor of [P4411R0] doesn’t make a point a bounded-point.

9 Implementation experience

The design is implemented in [mp-units]: on its master branch in the quantity_point<R, PO, Rep> spelling of the 2.x releases, and on its version 3 branch, which implements [P3045R10], in the spelling of this paper. It has been public since April 2026 [Range-Validated Quantity Points]. It was prompted by feedback at Croydon 2026 and by a user of geodetic coordinates who found no way to express the different wrapping rules of latitude and longitude [mp-units].

Compile-time tests cover every policy, the nesting rule, the exactness rule, and min(), max(), and numeric_limits. Runtime tests cover the handler, is_within_bounds, and try_make. The precondition is checked with C++26 contracts where the compiler supports them, and otherwise with an assertion library, such as gsl-lite, where one is configured. The examples in this paper were compiled with GCC 15, GCC 16, and Clang 23.

10 Open questions

10.1 Nested cyclic origins

The nesting rule compares ranges as intervals on a line. On a circle that rejects a valid hierarchy. A heading measured counterclockwise from north is the geometric azimuth, measured from east, minus 90°, so its origin lies at an azimuth of 90°:

inline constexpr struct east
  : absolute_point_origin<geometric_azimuth, wrap_to_range{-180. * deg, 180. * deg}> {} east;

inline constexpr struct north_ccw
  : relative_point_origin<east + 90. * deg, wrap_to_range{-180. * deg, 180. * deg}> {} north_ccw;

Translated into the frame of east, the range of north_ccw is [−90°,270°)[-90°, 270°), which isn’t inside [−180°,180°)[-180°, 180°), so the first use of north_ccw is ill-formed. On the circle both ranges are the whole circle, and the definition is correct. Without bounds of its own, north_ccw inherits the wrapping of east, but its values are then in [−270°,90°)[-270°, 90°) instead of [−180°,180°)[-180°, 180°).

The options are:

Author preference is the second option. It isn’t implemented yet.

10.2 Exactness for floating-point points

Bounds the point can hold requires a bound to be exact in a floating-point representation, and accepts the rounding of unit scaling and of offsets. The line is drawn between converting the representation and scaling the unit. SG6 may want it elsewhere. The options are:

  1. Exact representation, rounded scaling (proposed). A float point with a double bound of 0.1 m is ill-formed, and the same bound written in feet for a double point in meters is accepted.

    The conversion of every bound’s representation is exact, and the 16’777’217 m case is rejected. An inherited checking bound is compared in the common type of the value and the offset, so the restriction falls on bounds that the point’s own origin declares and on adjusting policies. However, code that pairs float points with double literals is ill-formed, and the rule has two parts that a user has to learn. Scaling to another unit can still round a bound with an integral value: check_in_range{134'221 * m, ...} passes the rule for a float point in millimeters, and 134’221’000 mm becomes 134’220’992 mm.

  2. No exactness for floating point. Only a representation that isn’t a floating-point one has to hold its bounds and offsets exactly. A floating-point point accepts every bound and rounds it.

    This is a single, simple rule, and no floating-point code is rejected. However, a bound can move by up to half a unit in the last place of the point’s representation, so 16’777’217 m in a float point admits 16’777’216 m.

  3. Compare in a common type where the representations allow it. A float value and a double bound are compared as double, so 0.1 m stays exact, and the bound is converted only where the two quantity types don’t compare, as with the constrained<double> representation.

    With it, 0.1 * m keeps working for a float point. However, the comparison type then depends on whether the quantity types support a mixed comparison, an adjusting policy still has to convert the bound to store it, and a clamped float value becomes the rounded bound anyway.

Author preference is option 1.

11 Straw polls

  1. The bounds of a quantity point belong to its point origin, as proposed in P4412R0.
  2. The six policies of P4412R0 (check_in_range, check_non_negative, clamp_to_range, clamp_non_negative, wrap_to_range, reflect_in_range) and exclusive_bound should be the initial vocabulary, with user-defined checking and adjusting policies distinguished by the return type of their call operator.
  3. A value that a checking policy rejects should violate a precondition of the operation that stores it, as proposed in P4412R0.
  4. constraint_violation_handler<Rep> should be provided so that a representation type can opt into checks in every build mode.
  5. The static member functions is_within_bounds and try_make should be provided as proposed in P4412R0.
  6. Every bound, and the offset to a bounded ancestor for a representation that isn’t a floating-point one, should be required to be exact in the quantity type of the point, or, for an inherited checking policy, in the common type of the value and the offset, as proposed in P4412R0 (option 1 of “Exactness for floating-point points”).
  7. The bounds of a relative point origin should be required at compile time to lie within the bounds of its nearest bounded ancestor, as proposed in P4412R0 (see “Nested cyclic origins” for cyclic ancestors).
  8. Forward P4412R0 to LEWG with the recommendation to adopt it together with [P3045R10].

12 Acknowledgments

Special thanks and recognition goes to The C++ Alliance for supporting Mateusz’s membership in the ISO C++ Committee and the production of this proposal.

The author also thanks the participants of the Sofia 2025, Croydon 2026, and Brno 2026 reviews for the questions that shaped this paper, and the user who opened [mp-units].

13 References

[mp-units] mp-units - A Physical Quantities and Units library for C++.
https://mpusz.github.io/mp-units
[mp-units] How to handle type level difference in coordinate/orientation angle types?
https://github.com/mpusz/mp-units/discussions/782
[P2900R14] Joshua Berne, Timur Doumler, Andrzej Krzemieński. 2025-02-14. Contracts for C++.
https://wg21.link/p2900r14
[P3045R10] Mateusz Pusz. Quantities and units library.
https://wg21.link/p3045r10
[P3471R4] Konstantin Varlamov, Louis Dionne. 2025-02-14. Standard Library Hardening.
https://wg21.link/p3471r4
[P4185R1] Mateusz Pusz. 2026-06-12. Completing the Mathematical Model for C++ Quantities and Units.
https://wg21.link/p4185r1
[P4185R2] Mateusz Pusz. Absolute Quantities.
https://wg21.link/p4185r2
[P4411R0] Mateusz Pusz. Non-Negative Quantity Specifications.
https://wg21.link/p4411r0
[Range-Validated Quantity Points] Mateusz Pusz. Range-Validated Quantity Points (mp-units blog, 2026-04-06).
https://mpusz.github.io/mp-units/latest/blog/2026/04/06/range-validated-quantity-points/