diff --git a/CLAUDE.md b/CLAUDE.md
index 5166473..f12b043 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -97,7 +97,18 @@ one driving it. Two layers, and both earn their place:
`AngularDisplacement`, `AngularVelocity`, `AngularAcceleration` and `AngularJerk`, and that is the
whole of it. Without it an angle is the same type as a ratio and an angular speed the same type as
a frequency; with it, 61 distinct exponent vectors become 63. It is read by the C++ projection and
-carried through `DimensionInfo` on the .NET side, where nothing depends on it yet.
+carried through `DimensionInfo` on the .NET side, where `ktsu.Schema` reads it off a unit to fill
+the eight exponents in its C++ reflection table.
+
+**A unit claimed by two dimensions reports the one with exponents.** `Radian`, `Degree`, `Gradian`,
+`Milliradian` and `Revolution` are in `availableUnits` on both `AngularDisplacement` and
+`Dimensionless`, and the marker interfaces carry both — it is the singular `IUnit.Dimension` that
+has to pick one. Picking the first declared picked by file position, and `Dimensionless` is the
+first entry in `dimensions.json`, so every angular unit reported no exponents at all: the same
+answer a unitless count gives, which is the conflation the axis was added to prevent. A claim that
+says something now beats one that says nothing. Where several say something the first still wins,
+which decides the only other unit claimed twice: `SquareMeter` is `Area` and `NuclearCrossSection`,
+one of the 72-over-63 collisions, so the two answers differ in name and not in exponents.
**A relationship is checked before it is emitted.** The operator is written as
`Result{ lhs.value() * rhs.value() }`, so the exponents have to agree with the declared result or it
diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs
index 73ae11f..b25cf38 100644
--- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs
+++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs
@@ -858,7 +858,7 @@ public sealed record Radian : IUnit, IDimensionlessUnit, IAngularDisplacementUni
public UnitSystem System => UnitSystem.SIDerived;
/// Gets the physical dimension this unit measures.
- public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
+ public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;
/// Gets the multiplication factor used in the to-base affine conversion.
public double ToBaseFactor => 1d;
@@ -885,7 +885,7 @@ public sealed record Degree : IUnit, IDimensionlessUnit, IAngularDisplacementUni
public UnitSystem System => UnitSystem.Other;
/// Gets the physical dimension this unit measures.
- public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
+ public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;
/// Gets the multiplication factor used in the to-base affine conversion.
public double ToBaseFactor => DegreeToRadians;
@@ -1398,7 +1398,7 @@ public sealed record Gradian : IUnit, IDimensionlessUnit, IAngularDisplacementUn
public UnitSystem System => UnitSystem.Other;
/// Gets the physical dimension this unit measures.
- public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
+ public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;
/// Gets the multiplication factor used in the to-base affine conversion.
public double ToBaseFactor => GradianToRadians;
@@ -1425,7 +1425,7 @@ public sealed record Revolution : IUnit, IDimensionlessUnit, IAngularDisplacemen
public UnitSystem System => UnitSystem.Other;
/// Gets the physical dimension this unit measures.
- public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
+ public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;
/// Gets the multiplication factor used in the to-base affine conversion.
public double ToBaseFactor => RevolutionToRadians;
@@ -1452,7 +1452,7 @@ public sealed record Milliradian : IUnit, IDimensionlessUnit, IAngularDisplaceme
public UnitSystem System => UnitSystem.SIDerived;
/// Gets the physical dimension this unit measures.
- public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
+ public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;
/// Gets the multiplication factor used in the to-base affine conversion.
public double ToBaseFactor => MetricMagnitudes.Milli;
diff --git a/Semantics.SourceGenerators/Generators/UnitsGenerator.cs b/Semantics.SourceGenerators/Generators/UnitsGenerator.cs
index 6f6ca76..acc1ce5 100644
--- a/Semantics.SourceGenerators/Generators/UnitsGenerator.cs
+++ b/Semantics.SourceGenerators/Generators/UnitsGenerator.cs
@@ -71,6 +71,8 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata
},
};
+ HashSet withoutExponents = BuildDimensionsWithoutExponents(dimensions);
+
List catalogueUnitNames = [];
foreach (UnitCategory category in units.UnitCategories)
@@ -79,7 +81,7 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata
{
List dims = unitToDimensions.TryGetValue(unit.Name, out List? d) ? d : [];
- sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims));
+ sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims, withoutExponents));
catalogueUnitNames.Add(unit.Name);
}
}
@@ -114,11 +116,48 @@ private static Dictionary> BuildUnitToDimensionsMap(Dimensi
return unitToDimensions;
}
+ ///
+ /// The dimensions whose dimensionalFormula is empty, which is to say the ones that
+ /// measure nothing.
+ ///
+ ///
+ /// Today that is Dimensionless alone, and the set is built rather than named because a
+ /// second one would otherwise have to be remembered here.
+ ///
+ private static HashSet BuildDimensionsWithoutExponents(DimensionsMetadata dimensions) =>
+ new((dimensions.PhysicalDimensions ?? [])
+ .Where(static dim => dim.DimensionalFormula.Count == 0)
+ .Select(static dim => dim.Name));
+
+ ///
+ /// The one dimension a unit reports, out of every dimension that claims it.
+ ///
+ ///
+ ///
+ /// A unit may be claimed by several dimensions, and the marker interfaces carry all of them —
+ /// it is only the singular Dimension property that has to choose. Choosing the first
+ /// declared made that choice by file position: Dimensionless is the first entry in
+ /// dimensions.json, so a radian reported no exponents at all, which is the same answer
+ /// a unitless count gives. That is the conflation the angle axis was added to prevent,
+ /// and a consumer deriving a member's dimension from its unit — ktsu.Schema's C++
+ /// reflection table does exactly that — could not tell an angle from a flag.
+ ///
+ ///
+ /// So a claim that says something is preferred to one that says nothing. Where several claims
+ /// say something the first still wins, which is right for the one case there is: a square metre
+ /// is claimed by Area and NuclearCrossSection, and those are the same exponents
+ /// under two names — one of the collisions the nominal layer exists for — so only the name
+ /// differs and neither answer is wrong.
+ ///
+ ///
+ private static string? ReportedDimension(List dims, HashSet withoutExponents) =>
+ dims.FirstOrDefault(dim => !withoutExponents.Contains(dim)) ?? dims.FirstOrDefault();
+
///
/// Builds the sealed record for one unit, carrying its name, symbol, system, dimension, and
/// the affine to-base conversion (factor plus offset).
///
- private static ClassTemplate BuildUnitClass(UnitDefinition unit, List dims)
+ private static ClassTemplate BuildUnitClass(UnitDefinition unit, List dims, HashSet withoutExponents)
{
List interfaces = ["IUnit"];
foreach (string dimName in dims)
@@ -130,8 +169,8 @@ private static ClassTemplate BuildUnitClass(UnitDefinition unit, List di
string offsetExpr = string.IsNullOrEmpty(unit.Offset) || unit.Offset == "0"
? "0d"
: unit.Offset;
- string dimensionExpr = dims.Count > 0
- ? $"PhysicalDimensions.{dims[0]}"
+ string dimensionExpr = ReportedDimension(dims, withoutExponents) is string reported
+ ? $"PhysicalDimensions.{reported}"
: "null!";
return new ClassTemplate
diff --git a/Semantics.Test/Quantities/UnitDimensionTests.cs b/Semantics.Test/Quantities/UnitDimensionTests.cs
new file mode 100644
index 0000000..010b9e5
--- /dev/null
+++ b/Semantics.Test/Quantities/UnitDimensionTests.cs
@@ -0,0 +1,108 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Test.Quantities;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ktsu.Semantics.Quantities;
+using ktsu.Semantics.Quantities.Units;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers which dimension a unit reports when more than one claims it.
+///
+///
+/// A unit's marker interfaces carry every dimension whose availableUnits names it, so the
+/// singular is the only place that has to choose. It used to choose
+/// the first declared, which is a choice by file position rather than by meaning, and
+/// Dimensionless is the first entry in dimensions.json.
+///
+[TestClass]
+public sealed class UnitDimensionTests
+{
+ ///
+ /// A radian is an angle, not a ratio.
+ ///
+ ///
+ /// The whole of what the angle axis is for: without it an angle is the same thing as a
+ /// ratio, and a unit reporting no exponents says exactly that however the axis is spelled
+ /// elsewhere.
+ ///
+ [TestMethod]
+ public void AnAngularUnitReportsTheAngularDimension()
+ {
+ foreach (IUnit unit in (IUnit[])[new Radian(), new Degree(), new Gradian(), new Milliradian(), new Revolution()])
+ {
+ Assert.AreEqual("AngularDisplacement", unit.Dimension.Name, $"{unit.Name} reports the wrong dimension");
+ Assert.AreEqual(1, unit.Dimension.DimensionalFormula["angle"], $"{unit.Name} is not one angle");
+ Assert.HasCount(1, unit.Dimension.DimensionalFormula, $"{unit.Name} measures something besides an angle");
+ }
+ }
+
+ ///
+ /// A claim that says something beats one that says nothing, for every unit rather than the
+ /// five that prompted it.
+ ///
+ ///
+ /// Stated over the assembly because the fix is a rule rather than a list: a unit added to both
+ /// a real dimension and Dimensionless tomorrow is the same bug, and naming today's five
+ /// would not catch it. Walks the compiled types rather than the metadata, since what a consumer
+ /// reads is the property and not the JSON behind it.
+ ///
+ [TestMethod]
+ public void AUnitClaimedTwiceReportsTheDimensionWithExponents()
+ {
+ Dictionary byName =
+ PhysicalDimensions.All.ToDictionary(static dimension => dimension.Name, StringComparer.Ordinal);
+
+ int claimedTwice = 0;
+
+ foreach (IUnit unit in EveryUnit())
+ {
+ List claims = [.. unit.GetType()
+ .GetInterfaces()
+ .Where(static marker => marker != typeof(IUnit) && marker.Name.StartsWith('I') && marker.Name.EndsWith("Unit", StringComparison.Ordinal))
+ .Select(marker => marker.Name[1..^"Unit".Length])
+ .Where(byName.ContainsKey)
+ .Select(name => byName[name])];
+
+ if (claims.Count < 2)
+ {
+ continue;
+ }
+
+ claimedTwice++;
+
+ if (claims.Exists(static claim => claim.DimensionalFormula.Count > 0))
+ {
+ Assert.IsNotEmpty(
+ unit.Dimension.DimensionalFormula,
+ $"{unit.Name} is claimed by {string.Join(", ", claims.Select(static claim => claim.Name))} and reports the one with no exponents");
+ }
+ }
+
+ Assert.IsGreaterThan(0, claimedTwice, "no unit is claimed twice, so this asserts nothing");
+ }
+
+ private static IEnumerable EveryUnit()
+ {
+ foreach (Type type in typeof(Meter).Assembly.GetTypes())
+ {
+ if (type.IsAbstract || !type.IsClass || !typeof(IUnit).IsAssignableFrom(type))
+ {
+ continue;
+ }
+
+ if (type.GetConstructor(Type.EmptyTypes) is null)
+ {
+ continue;
+ }
+
+ if (Activator.CreateInstance(type) is IUnit unit)
+ {
+ yield return unit;
+ }
+ }
+ }
+}
diff --git a/docs/physics-generator.md b/docs/physics-generator.md
index 753c5e3..976a252 100644
--- a/docs/physics-generator.md
+++ b/docs/physics-generator.md
@@ -179,6 +179,7 @@ type.
Adding one means adding it to `SemanticsDiagnostics` and to `AnalyzerReleases.Unshipped.md`; `AnalyzerReleaseTrackingTests` fails if the second step is forgotten. `GeneratorDiagnosticTests` proves each one still fires on the input it is meant to catch.
- `availableUnits` order matters: the first entry is treated as the SI base unit by `UnitsGenerator`.
+- A unit may appear in `availableUnits` on more than one dimension. It implements an `I{Dimension}Unit` marker for each, and its singular `Dimension` property reports the first claim whose `dimensionalFormula` is non-empty — falling back to the first claim of any kind. Without that preference a unit shared with `Dimensionless`, which is the first entry in the file, could never report anything else.
- `relationships` expressions are emitted verbatim into method bodies. Use `Value` for the current quantity and `T.CreateChecked(...)` (not literal numerics) for constants so all storage types stay correct.
- Generator output is committed. CI must catch metadata/code drift; `git status` should be clean after a build.