Skip to content

Why a Quantity Has a Character

A few years ago at CppCon, an engineer who works with electrical power systems every day stopped me after a talk. He told me that his team confuses active power, reactive power, apparent power, and complex power all the time, and that the mistake is easy to make and expensive to find. Then he said the sentence that has stuck with me since: a units library that will not make those four incompatible types is of no use in his industry.

He is right. And he is not alone.

The problem is not the dimension

Dimension safety is powerful, and it already catches more than people expect. The mass versus weight confusion is a good example. At a Croydon ISO C++ evening session someone told me, with full confidence, that the pound is a unit of force. It is a unit of mass, and the pound-force is the separate unit of force. It is an easy mistake to make, and a common one. A dimension-safe library catches that one for free, because mass (in kilograms) and a weight force (in newtons) have different dimensions.

The hard cases are the ones that share a dimension, where dimension safety goes blind:

  • speed and velocity. Speed is the magnitude of velocity, in the same unit. A one-dimensional motion model often stores both as a plain double, where a negative value means "moving backwards." Same dimension, different character: speed is a scalar, velocity a vector. (That a speed is also non-negative is a separate guarantee, one that comes from absolute quantities, not from character.)
  • active, reactive, apparent, and complex power, from the opening. Same dimension, and for complex power a different character as well.

A library that is only unit-safe and dimension-safe cannot help with these. It happily accepts a velocity where a speed is expected, or a reactive power where an active power is expected, because the units and the dimensions match. A speed is a velocity with its direction discarded, and a function written for velocities cannot tell when it is handed a speed instead:

// one axis along the road lane, both readings backed by double
double car_velocity(car id);                                     // signed, +/- along the lane
double car_speed(car id) { return std::abs(car_velocity(id)); }  // speedometer: |velocity|

// expects two velocities in the same frame; returns how fast the cars' gap closes
double closing_speed(double v_a, double v_b)
{
  return std::abs(v_a - v_b);
}

car a = ...;     // car A reverses at 12
car b = ...;     // car B approaching at 30
closing_speed(car_velocity(a), car_velocity(b));  // 42 m/s: correct, both are velocities
closing_speed(car_speed(a),    car_velocity(b));  // 18 m/s: wrong, car A's reverse gear is gone
closing_speed(car_speed(a),    car_speed(b));     // 18 m/s: wrong again, and nothing flags either slip
// one axis along the road lane, both readings backed by double
auto car_velocity(car id);                                      // signed, +/- along the lane
auto car_speed(car id) { return magnitude(car_velocity(id)); }  // speedometer: |velocity|

// accepts everything and return anything (like the `double` case)
auto closing_speed(auto v_a, auto v_b)
{
  return magnitude(v_a - v_b);
}

car a = ...;     // car A reverses at 12
car b = ...;     // car B approaching at 30
closing_speed(car_velocity(a), car_velocity(b));  // ok: the magnitude of a velocity difference is a speed
closing_speed(car_speed(a),    car_velocity(b));  // error: can't subtract different characters (V3)
closing_speed(car_speed(a),    car_speed(b));     // error: can't take a magnitude of a scalar quantity
// one axis along the road lane, both readings backed by double
QuantityOf<isq::velocity> auto car_velocity(car id);                                   // +/- along the lane
QuantityOf<isq::speed> auto car_speed(car id) { return magnitude(car_velocity(id)); }  // speedometer: |velocity|

// constrained to velocities, so the wrong argument is turned away at the call site
QuantityOf<isq::speed> auto closing_speed(QuantityOf<isq::velocity> auto v_a,
                                          QuantityOf<isq::velocity> auto v_b)
{
  return magnitude(v_a - v_b);
}

car a = ...;     // car A reverses at 12
car b = ...;     // car B approaching at 30
closing_speed(car_velocity(a), car_velocity(b));  // ok: the magnitude of a velocity difference is a speed
closing_speed(car_speed(a),    car_velocity(b));  // error: a speed is not a velocity
closing_speed(car_speed(a),    car_speed(b));     // error: a speed is not a velocity

In the plain-double world all three calls compile, and two are silently wrong: the moment a speed stands in for a velocity, car A's reverse gear is gone and closing_speed returns 18 instead of 42, with nothing to flag it. Strong quantities close the gap from two directions. Even with loose auto parameters, character governs the arithmetic inside the function: you cannot take the magnitude of a scalar speed, so a pair of speeds never makes it past the norm, and in V3 subtracting a scalar from a vector is rejected outright. Constrain the parameters to QuantityOf<isq::velocity> and the mistake is caught even earlier, at the call site, with a message that names the real problem: a speed is not a velocity.

mp-units tells these quantities apart through two complementary properties: quantity kind (the ISQ hierarchy, where active, reactive, apparent, and complex power are separate, incompatible kinds living in different trees) and quantity character. Character does two jobs:

  • Rejecting a representation type that does not fit a strongly-typed quantity. A plain double for a complex quantity such as a voltage phasor (a real type has no room for its phase), or a cartesian_vector where a scalar speed is expected. That mismatch is easy to introduce in a large codebase, and character turns it into a compile-time error.
  • Governing which operations are legal, and which derived quantity comes out, so a calculation cannot drift into nonsense. You can take the magnitude() of a velocity or a stress tensor but not of a scalar speed, and you do not multiply two vectors, you take their scalar product or their vector product, each yielding a different quantity.

The simplest of these guards already ship: a scalar has no magnitude(). The operations that return a different kind, like the products, are largely V3, built on the character model in place today.

This post is about character: where the name came from, the design dead ends we hit on the way (some of them more than once), and why none of it is the scope creep it is sometimes accused of being. It is an engineering journey, and it is not finished. Comments at the bottom are genuinely wanted.

The objection, stated honestly

Before the history, the objection, because it is a good one. In issue #648, Roth Michaels asked it plainly:

#648: when do we need quantity_character?

I'm not sure I fully understand why we need quantity_character to support vector, tensor, and complex types. Why will it not work if the representation type is a vector, tensor, or complex value type?

It is a fair question, and a tempting design: the representation type already knows what it is. std::complex<double> is complex. Eigen::Vector3d is a vector. So why does a quantity need to carry a separate character at all? Why not just follow the type?

The objection has a sharper, more developed form. In discussion #683, Chip Hogg set out a working hypothesis that vector character is a scope mistake, reasoning from "same program, only safer": users already choose the vector, matrix, or scalar types that suit them, so a units library should wrap that choice and add safety, not second-guess it. Including character mostly just forbids combinations, and for that to pay off, the real mistakes it catches must outweigh the legitimate uses it blocks. It is a good argument, and parts of it are simply right. I will come back to it once the design is on the table.

The honest answer took us several years and a few wrong turns to pin down.

The short version

The representation type does not know, it cannot be trusted, and the character, both its field (real or complex) and its order (scalar, vector, or tensor), is a property of the quantity rather than of its storage.

The rest of this post earns that claim.

A short history of quantity character

Before: dimension-safe and nothing more

In mp-units v0.8.0 a quantity was defined like this:

template<Dimension D, UnitOf<D> U, Representation Rep = double>
class quantity {
  Rep number_;
  // ...
};

A quantity was a dimension, a unit, and a number. That is unit-safe and dimension-safe, and it already prevents a large class of bugs. It does nothing for the conflations that share a dimension: speed and velocity, or the four powers from the opening, were all identical to the type system. There was simply no place to record the difference.

The name comes from ISO 80000

We did not invent the term. ISO 80000-1:2009 says, when defining how a derived quantity's dimension is formed:

ISO 80000-1:2009

In deriving the dimension of a quantity, no account is taken of its scalar, vector, or tensor character.

That single sentence is doing a lot of work. It tells you that a quantity has a character, that the character is one of scalar, vector, or tensor, and that the dimension deliberately ignores it. In other words, the standard itself says the dimension is not enough to describe a quantity. The character is a first-class property of the quantity in the ISQ, and the dimension throws it away on purpose. So when mp-units set out to be not only unit-safe and dimension-safe but quantity-safe, the character was the obvious missing property, and ISO handed us both the concept and the name.

We started with exactly those three: scalar, vector, tensor.

The CppCon pivot: complex enters the game

Three characters were enough until the power-systems engineer from the opening. His domain needs a distinction that scalar, vector, and tensor do not capture. We need to be able to discriminate between a real scalar and a complex one. Complex power is a complex scalar, and active, reactive, and apparent power are its real part, imaginary part, and modulus. Telling the three real powers apart from one another is a matter of quantity kind, since they share a character. What character adds is the orthogonal real-versus-complex distinction.

Consider how this plays out in code. apparent power and complex power are both measured in volt-amperes, so in a units library that keys on the unit alone, this compiles and tells you almost nothing:

auto p = value * VA;   // apparent power? complex power? VA is the unit of both

The unit cannot decide which quantity p is, the library has no way to check that the type of value is the right one, and the auto leaves the identifier p as the only hint of what was meant. Everyone in the field knows the storage convention, complex power in a std::complex and the rest in a double, but the library cannot enforce a convention it cannot see.

In mp-units you name the quantity, and character checks the representation against it:

auto reading = read_meter();      // returns a double, today
auto phasor  = read_phasor();     // returns a std::complex<double>

quantity ap = reading * isq::apparent_power[VA];    // real scalar: a double satisfies it
quantity cp = phasor  * isq::complex_power[VA];     // complex scalar: a std::complex satisfies it
// quantity bad = reading * isq::complex_power[VA]; // error: reading is real, not complex

A std::complex<double> satisfies only isq::complex_power, and a double only isq::apparent_power. The two are now distinct, incompatible types even though both are spelled in VA, and a representation that does not match the character does not compile.

Note that the types of reading and phasor are nowhere in sight at the multiplication, and they may be refactored later. You do not have to eyeball them, and you should not have to. The day read_phasor() is changed to return a plain double, the complex_power line stops compiling instead of silently discarding the phase.

The same character governs how these quantities are defined, not only how they are stored. apparent power is, by definition, the modulus of complex power, and in V3 that is exactly how its specification will read:

inline constexpr struct apparent_power : quantity_spec<modulus(complex_power)> {} apparent_power;

modulus() is meaningful only on a complex quantity. Strip the character away and modulus(apparent_power), or modulus(active_power), would compile just as readily, defining a quantity as the modulus of something that has no imaginary part. That is physical nonsense, and character is what keeps the defining equation well-formed only where it makes sense.

So we extended the list. Scalar became real_scalar and complex_scalar, and for a while the character was a flat enumeration of four values: real_scalar, complex_scalar, vector, tensor.

The recent realization: a flat list does not scale

A flat list of four hard-codes an assumption that turns out to be false: that "complex" only ever happens to scalars. It does not. A complex vector and a complex tensor are perfectly ordinary in electromagnetism and signal processing: the phasor electric field strength of a time-harmonic wave is a complex vector, and the permittivity of a lossy anisotropic medium is a complex second-order tensor. The flat enumeration could not name either. We had quietly baked "real" into every vector and tensor.

That is what triggered the refactor this post accompanies, and the article itself. The fix was to stop treating the character as a flat list and recognize that it was two independent questions all along.

Two axes, not four values

A quantity's character is a pair:

  • a field: real or complex,
  • an order: scalar, vector, or tensor.

The four flat values were just four of the six combinations, with the other two (complex vector, complex tensor) accidentally unreachable. Splitting the character into two orthogonal axes makes all six expressible and removes the special-casing.

The order axis stops at scalar, vector, and tensor, the three ISO 80000-2 recognizes. We did look further: geometric algebra offers richer objects such as bivectors and pseudoscalars, and for a while it was tempting to model them too. We decided against it. No ISQ quantity needs the distinction (the standard treats those quantities as vectors and scalars), and a character that no quantity uses is precisely the scope creep we are trying to avoid. This is the rule the whole design follows: a character earns its place only when a real quantity cannot be expressed without it, which is exactly how complex got in and exactly why bivectors stayed out.

This is the point in the story where the temptation from #648 returns with full force, because once you have two clean axes it looks like the representation type could just answer both of them. It cannot, and here is why.

Why the type cannot tell you

The type is underdetermined

The same storage type legitimately backs quantities of different characters. double is the clearest case. It is a real scalar for mass. It is a one-dimensional real vector for velocity along a known axis, where the sign carries the direction. It is even a degenerate real tensor for a scalar stress measure. All three are normal engineering practice, and they all store the value in the same double. That a scalar may serve as a vector or a tensor at all is ISO 80000-2's own ordering:

ISO 80000-2

A vector is a tensor of the first order and a scalar is a tensor of order zero.

quantity m  = isq::mass(5.0 * kg);                 // real scalar
quantity v  = isq::velocity(-3.0 * m / s);         // 1-D vector: sign is direction
quantity sp = isq::speed(3.0 * m / s);             // real scalar, non-negative by meaning
quantity st = isq::stress(100.0 * Pa);             // degenerate tensor: a scalar stress measure

If the character came from the type, all three would be identical, and there would be no way to say that a speed is the magnitude of a velocity, a relationship that runs one way only (a velocity has a speed, but a speed has no direction to recover a velocity from), or that speed is non-negative while velocity is signed. The type cannot distinguish them, because they are the same type. The distinguishing information lives in the quantity, not in the number.

A richer type is not the right type

The reverse mistake is just as easy. Conflate speed and velocity the other way and put a 3D vector into a speed, which is a scalar quantity. A library that follows the type inspects that vector, sees that it offers scalar_product, vector_product, and magnitude, and happily accepts calling any of those on what was declared a speed. It is not fine. Speed is a scalar, and a 3D vector is the wrong representation for it no matter how many vector operations it exposes. The representation type's rich API is exactly what lures a follow-the-type library into accepting it. Character rejects it, because the quantity says scalar and a scalar slot does not take an order-1 representation.

The type lies

Even when the representation is a richer type, its surface is not a reliable witness of its character. This is not hypothetical. It is exactly what the two most popular C++ linear algebra libraries do.

  • Eigen and Blaze expose real() and imag() on their real matrices and vectors, because a real value is a degenerate complex one. If you detect "complex" by the presence of real()/imag(), every real Eigen vector is misread as complex. We hit this during the refactor: the build classified a real Eigen::Vector3d quantity as complex until we stopped trusting the API. And because real and complex are matched exactly, that is worse than a stray label: a vector marked complex is no longer real, so it can no longer back the real quantity it actually is, a real velocity for instance. A correct and common use simply stops compiling.
  • An Eigen column vector is an N x 1 matrix, so it exposes a two-index operator()(i, j). If you detect "tensor" by the presence of two-index access, every Eigen vector is misread as an order-2 tensor.

Neither of these is a bug in Eigen or Blaze. Both are mathematically defensible: the reals embed in the complex numbers, and a vector is a one-column matrix. That is the whole point. The type's exposed surface is genuinely ambiguous about character, so a units library that simply "follows the type" will follow it straight into a misclassification.

Underneath both is one fact: a representation type describes storage, not character. A matrix is a rectangular array of numbers. A tensor is a different kind of object, defined by how its components transform when the coordinate frame changes. Every second-order tensor can be written as a 3×3 matrix once a basis is fixed, but not every 3×3 matrix is a tensor. Whether an Eigen::Matrix3d is a stress tensor or just a table of nine numbers is a fact about the quantity, not the type, and no amount of inspecting the matrix recovers it. That fact is the character, and it lives on the quantity.

The operations a type offers are not the ones a quantity allows

The objection from #648 has an operational form, aimed at character's second job. Grant that the character belongs on the quantity. Why should it govern the arithmetic too? Why not just let the representation offer whatever operations it happens to have? Because the operations a type exposes are a menu of what is syntactically possible, not what is physically legal, and the gap between those two is where the bugs live.

A double offers * and abs() whether it holds a velocity or a speed, and nothing stops you from reaching for them:

double vx = 3.0, vy = 4.0;   // two velocity components, in m/s
auto k = vx * vy;            // compiles: 12. a scalar product? a vector product? neither, just m²/s²
auto s = std::abs(vx);       // a speed? only if vx was a velocity; on a value already a speed it does nothing

Both operations compile, and both are wrong or controversial. The product of two velocity components is neither a scalar product nor a vector product, just a number with a unit. abs() is meaningful on a velocity and meaningless on a speed, and the double cannot tell the two apart. A real Eigen vector, as we just saw, even offers real() and imag() it has no business offering. Let the available operations drive the calculation and you have built a machine for confidently computing the wrong thing. Only the quantity's character knows which operations make sense and what they produce, which is why, in V3, the operations are defined on the character rather than scavenged from the representation.

This goes deeper than any single calculation. When you do pure dimensional analysis, or when the ISQ itself defines a derived quantity, there is no representation type in sight at all, only quantity specifications and their characters. velocity is displacement over duration, work is the scalar product of force and displacement, and moment of force is the vector product of a position vector and a force. Which of those equations is even well-formed, and which quantity each one produces, is decided by the character of the operands, with no number anywhere to consult. The correctness of the entire ISQ rests on character governing operations at the specification level. Here the trouble with following the representation is not that it is unreliable. There is no representation to follow.

The field is a domain fact, not a storage fact

The power-systems case makes this sharpest. Whether a power is active, reactive, apparent, or complex is fixed by what the quantity means, before any C++ type is chosen. A complex power is complex because it carries a magnitude and a phase. An active power is real because it is a single signed number. That is a fact about the physics, not about storage, and the storage is what must conform to it, not the other way around.

This is why mp-units matches the field exactly: a real quantity requires a real representation, and a complex quantity requires a complex one, with no implicit lift between them. It is tempting to allow the real-to-complex direction, since the reals embed in the complex numbers: a double is a complex number with a zero imaginary part. We tried it, and it is a trap (more on that below). A real representation has nowhere to hold an imaginary part, so a quantity that starts in a double can never grow one, and the first power-systems calculation that needs the phase has nowhere to put it.

So the character lives on the quantity, and the bridge is a customization point

Put these observations together and the design follows. The character cannot be read off the type, so it lives on the quantity_spec, which is where the quantity's meaning already lives. It is declared once, with the quantity, and inherited through the equations that derive other quantities:

inline constexpr struct displacement : quantity_spec<length, quantity_tensor_order::vector> {} displacement;
inline constexpr struct velocity : quantity_spec<displacement / duration> {} velocity;  // vector, inherited
inline constexpr struct moment_of_inertia
  : quantity_spec<angular_momentum / angular_velocity, quantity_tensor_order::tensor> {} moment_of_inertia;

The representation type still has to answer a narrower, humbler question: can this storage actually hold and manipulate a value of the required character? Because the structural answer is sometimes wrong (Eigen, Blaze), that question is answered by a trait with a sensible default and an adapter override:

  • numeric_field<T> reports the field. The default detects it from the real()/imag() API. Eigen and Blaze declare it from their element type instead.
  • tensor_order<T> reports the order. The default detects it from a type's indexing operators (t[i] for a vector, t[i, j] for a tensor). The Eigen adapter reads RowsAtCompileTime / ColsAtCompileTime instead.

The override is genuinely small. The entire Eigen adapter for the two character axes is this:

template<typename T>
  requires /* T is an Eigen type */
constexpr quantity_field numeric_field<T> = numeric_field<typename T::Scalar>;  // a real matrix stays real

template<typename T>
  requires /* T is an Eigen type */
constexpr std::size_t tensor_order<T> = (T::RowsAtCompileTime == 1 || T::ColsAtCompileTime == 1) ? 1 : 2;

This is the answer to #648. The character is not redundant with the type, because the type is underdetermined, unreliable, and the wrong place for a domain fact. And this is the answer to the scope-creep charge: we did not bloat the quantity with a speculative feature. We recorded a property the ISO standard says a quantity has, that a real engineer says he cannot work without, and we kept the type's role as small as possible, a reasonable default plus a two-line override. The Eigen and Blaze cases prove both halves are necessary.

The dead ends (where the design, and the AI, kept slipping)

Much of this design was worked out in conversation with an AI, leaned on not to write the code but to reason about the physics and mathematics where my own footing is least sure: geometric algebra, complex analysis, the corners of ISO 80000. Even there it was a good measure of how subtle the space is. The intuitive answer is reliably the wrong one, and the plausible-but-wrong model kept resurfacing, in its suggestions and in my own, until a concrete engineering scenario or an ISO clause settled it. The interesting part is not that a capable Artificial Intelligence stumbled. It is where it stumbled, because those are exactly the places a human designer slips too. Each dead end taught a reusable principle.

Trap 1: read the character off the type

The tempting model: let the representation type answer everything. It looks right for std::complex and Eigen::Vector3d. It is wrong because double backs three characters at once and because Eigen and Blaze misreport both axes. This one was the stickiest of all. It kept coming back every time the code needed the character, and only the Eigen/Blaze reality finally settled it. Principle: the character is the quantity's, not the storage's.

Trap 2: let real quietly satisfy complex

Because the reals embed in the complex numbers, it seems harmless to let a real representation fill a complex slot. It is not. Start a quantity in a double and you can never grow an imaginary part into it: the first calculation that produces one has nowhere to put it. In the power-systems domain that is the exact bug we are trying to prevent. The fix is exact, disjoint field matching. Principle: a representation is safe only when a domain expert would sign off on it, and real-only storage for a complex quantity is not.

Trap 3: flag the character, take 1 (opt in)

The earliest V2 releases made character a manual opt-in. A representation declared itself through is_scalar, is_vector, and is_tensor. Because vector and tensor defaulted to false, every linear-algebra type a user wanted had to be marked by hand (inline constexpr bool mp_units::is_vector<my_type> = true;). It worked, but the burden fell on the caller: each user, for each type, had to remember to annotate something the library could in principle work out for itself. 2.5.0 removed it. Principle: detect what you can, do not tax every user with an annotation the library could derive.

Trap 4: flag the character, take 2 (opt out)

The model that followed flipped the polarity. It detected a character by default and let a type opt out through disable_vector and disable_tensor. Less boilerplate, but the same shape of mistake: a flag standing in for something that is not a matter of choice. A vector and a tensor share the same algebraic API, the same addition, scaling, and magnitude, so those operations cannot tell them apart. The difference is purely one of order, how many indices it takes to address an element, so "a tensor is not a vector" is not an opt-out at all but a fact about order. Replacing the flags with the intrinsic tensor_order trait made the rule fall out for free: a tensor has order 2, a vector slot wants order at most 1, and 2 does not fit. Principle: do not encode a structural fact as a flag, compute it.

Trap 5: one narrow opt-out instead of one general one

Even after the traits landed, a single narrow knob survived, disable_real, whose only real job was to keep bool out. But "should this type be a representation at all?" is a different and more general question than "is it a real scalar?", and there was no knob for it. We consolidated to one character-agnostic disable_representation<T>. At first it defaulted to false, with a single explicit specialization for bool, taking over the one job disable_real had. NotQuantity was still a separate concept at that point, keeping quantities and quantity-like types out of a representation slot. It was exactly the same category of opt-out, so we folded it into disable_representation too, leaving one trait to answer for both bool and the quantity-like types. Fewer knobs, and the general question finally has an answer. Principle: when you find yourself adding the third special-case opt-out, step back and look for the general one.

Back to the scope question

Which brings us back to Chip's working hypothesis from #683. Two of its points we took to heart, and the design is better for them. The vector / pseudovector / multivector sophistication really is a rabbit hole with no single right answer, so we do not model it: the order axis stops at scalar, vector, and tensor. And the implementation he called fraught, the scalar-as-vector workaround in particular, really was. Back then, character was opt-in: a type counted as a vector only if you declared it one. The Kalman-filter examples store their vector quantities in plain doubles, so they had to opt those in by hand:

template<class T>
  requires mp_units::is_scalar<T>
constexpr bool mp_units::is_vector<T> = true;

One line, but a sweeping one: it reclassifies every scalar in the program as a vector, just to let a few doubles act as one-dimensional vectors. That is exactly the kind of hand-written flag the rank-ordering model removes. A scalar is now a degenerate vector on its own, because its order is below the vector's, with nothing for the user to declare. His critique did its job: it narrowed the scope and pushed the implementation toward something simpler.

Where we landed differently is one empirical question, whether the mistakes actually happen. Chip's bet was that they would not, because few users sit down and pick a type with the wrong character, and on that he is right. But the wrong character does not arrive by deliberate choice. It arrives from ordinary code: one type serving two characters (a double that is a speed in one function and a velocity in the next), a return type refactored under its callers (read_phasor() quietly becoming a double), or a unit that names two quantities at once (VA for both apparent power and complex power). None of those require anyone to choose badly, only to write a large program over time. And the ledger is not only "forbidden combinations": character also decides which operations are legal and how derived quantities are formed, and at the specification level, in the ISQ's own defining equations, there is no representation to choose in the first place. That is the part of the cost/benefit the hypothesis did not weigh, and it is where most of the value turns out to be.

What shipped, and what is still V3

To be clear about scope, because it matters. What landed now is the foundation: the two-axis character model on the quantity_spec, the representation concepts that match a representation to a character, and the numeric_field / tensor_order / disable_representation customization points. The whole character model is, at its core, just this:

// the two orthogonal axes
enum class quantity_tensor_order : std::int8_t { scalar, vector, tensor };  // rank-ordered: a lower order fills a higher
enum class quantity_field : std::int8_t { real, complex };                  // matched exactly: never one for the other

// a character is exactly one of each
struct quantity_character {
  quantity_tensor_order order = quantity_tensor_order::scalar;
  quantity_field field = quantity_field::real;
};

That is what makes apparent power and complex power, or speed and velocity incompatible where they should be. Everything else, the concepts, the traits, the matching rules, is built on top of those two little enums.

The richer machinery from Bringing Quantity-Safety To The Next Level, the character-specific operations such as scalar_product and vector_product, the affine-like relationships inside a single quantity tree, and the full quantity-level complex story where active power is the real part of complex power, all land properly in V3. The power-systems engineer's complete wish list is not in your package manager yet. The abstraction along which it becomes expressible is.

Open questions

The journey is genuinely unfinished, and one question matters more than the rest: is the two-axis split the right granularity, or will a real domain eventually need a distinction we have not anticipated, the way complex surprised us once already? Complex was not on the roadmap until an engineer made the case for it, and the next axis, if there is one, will probably arrive the same way.

So if you work in a domain where these distinctions are load-bearing, electrical power, structural mechanics, electromagnetism, robotics, or anywhere mass and weight have ever been confused in a code review, we would like to hear how this model holds up against your reality, and where it does not. The comments are open.

Comments