diff --git a/Semantics.Benchmarks/AbstractionCostBenchmarks.cs b/Semantics.Benchmarks/AbstractionCostBenchmarks.cs
new file mode 100644
index 0000000..d9e78a0
--- /dev/null
+++ b/Semantics.Benchmarks/AbstractionCostBenchmarks.cs
@@ -0,0 +1,168 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Benchmarks;
+
+using System.Numerics;
+
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+
+using ktsu.PreciseNumber;
+using ktsu.Semantics.Quantities;
+
+///
+/// Measures what the quantity types cost over doing the same arithmetic on the bare storage type.
+///
+///
+///
+/// The claim this library makes is that a quantity is a readonly record struct holding one
+/// value in the SI base unit, so an operator on it is the storage type's own arithmetic and a
+/// struct initialiser, and the wrapper costs nothing once the JIT has inlined it. That is a claim
+/// about a number, and the C++ projection has always been held to it against bare floats. This is
+/// the same question asked on this side.
+///
+///
+/// Each pair runs the identical arithmetic twice, once on and once on the
+/// quantity types over it, and the bare one is the BenchmarkDotNet baseline — so the answer is read
+/// off the Ratio column rather than by dividing two rows by hand. A ratio of 1.00 is the
+/// claim being kept.
+///
+///
+/// Why these are loops rather than single operations. A single operator over operands that
+/// do not change between iterations is loop-invariant, and the JIT hoists it out: that is what
+/// makes report a ZeroMeasurement warning for
+/// and , and it would make a ratio between two hoisted
+/// methods meaningless. Here each iteration feeds the next, so there is nothing to hoist and both
+/// sides of a pair are measurable for every storage type.
+///
+///
+/// What the loop costs, and which way it biases. Both sides pay the same counter increment
+/// and branch. It is a dependency chain, so on a superscalar core most of that overlaps the
+/// arithmetic rather than adding to it, but whatever does not is added equally to numerator and
+/// denominator and therefore pulls the ratio toward 1.00. So a ratio at 1.00 is the claim kept, and
+/// a ratio above it is a floor on the real cost rather than the whole of it.
+///
+///
+/// Why the operands stay bounded. PreciseNumber carries as many digits as the
+/// arithmetic produces, so a chain that grows its operand measures that growth instead of the
+/// operation. Both loops here accumulate rather than compound: the running value gains a step and
+/// the accumulator takes a product, so neither runs away, and the comparison stays about the
+/// wrapper for every storage type rather than only for the fixed-width ones.
+///
+///
+/// The storage type.
+[MemoryDiagnoser]
+[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
+[CategoriesColumn]
+[GenericTypeArguments(typeof(double))]
+[GenericTypeArguments(typeof(float))]
+[GenericTypeArguments(typeof(decimal))]
+[GenericTypeArguments(typeof(PreciseNumber))]
+public class AbstractionCostBenchmarks
+ where T : struct, INumber
+{
+ ///
+ /// Operations per invocation. Enough that the loop's own cost is a small share of the work,
+ /// few enough that an arbitrary-precision storage type still finishes an iteration promptly.
+ ///
+ private const int Operations = 256;
+
+ private T seed;
+ private T step;
+ private T other;
+
+ private Length seedLength;
+ private Length stepLength;
+ private Length otherLength;
+
+ ///
+ /// Prepares the operands, the bare ones and the wrapped ones holding the same values.
+ ///
+ [GlobalSetup]
+ public void Setup()
+ {
+ seed = Operands.Of("1234.5678901234567890");
+ step = Operands.Of("0.0009765625");
+ other = Operands.Of("3.14159265358979323846");
+
+ seedLength = Length.Create(seed);
+ stepLength = Length.Create(step);
+ otherLength = Length.Create(other);
+ }
+
+ /// Adds along a chain, on the bare storage type.
+ /// The accumulated value.
+ [BenchmarkCategory("Add")]
+ [Benchmark(Baseline = true, OperationsPerInvoke = Operations)]
+ public T BareAdd()
+ {
+ T accumulator = seed;
+
+ for (int i = 0; i < Operations; i++)
+ {
+ accumulator += step;
+ }
+
+ return accumulator;
+ }
+
+ /// Adds along the same chain, on quantities of the same dimension.
+ /// The accumulated quantity.
+ [BenchmarkCategory("Add")]
+ [Benchmark(OperationsPerInvoke = Operations)]
+ public Length QuantityAdd()
+ {
+ Length accumulator = seedLength;
+
+ for (int i = 0; i < Operations; i++)
+ {
+ accumulator += stepLength;
+ }
+
+ return accumulator;
+ }
+
+ /// Multiplies and accumulates, on the bare storage type.
+ /// The accumulated value.
+ [BenchmarkCategory("Multiply")]
+ [Benchmark(Baseline = true, OperationsPerInvoke = Operations)]
+ public T BareMultiply()
+ {
+ T accumulator = T.Zero;
+ T value = seed;
+
+ for (int i = 0; i < Operations; i++)
+ {
+ accumulator += value * other;
+ value += step;
+ }
+
+ return accumulator;
+ }
+
+ ///
+ /// Multiplies and accumulates over the same values, through the generated physics relationship
+ /// that takes two lengths to an area.
+ ///
+ /// The accumulated area.
+ ///
+ /// The one to read. This is where a quantity does something a bare number cannot — the product
+ /// lands on a different dimension, and the type system knows it — so if any of the vocabulary
+ /// were going to cost something at run time rather than only at compile time, it would be here.
+ ///
+ [BenchmarkCategory("Multiply")]
+ [Benchmark(OperationsPerInvoke = Operations)]
+ public Area QuantityMultiply()
+ {
+ Area accumulator = Area.Zero;
+ Length value = seedLength;
+
+ for (int i = 0; i < Operations; i++)
+ {
+ accumulator += value * otherLength;
+ value += stepLength;
+ }
+
+ return accumulator;
+ }
+}
diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md
index 1e8bc48..225f2ab 100644
--- a/Semantics.Benchmarks/README.md
+++ b/Semantics.Benchmarks/README.md
@@ -36,6 +36,9 @@ dotnet run -c Release --project Semantics.Benchmarks -- --filter '*'
# One class across all four storage types, or one storage type across all classes
dotnet run -c Release --project Semantics.Benchmarks -- --filter '*VectorBenchmarks*'
dotnet run -c Release --project Semantics.Benchmarks -- --filter '**'
+
+# What the quantity types cost over the bare storage type
+dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*'
```
## Measuring a published release
@@ -73,8 +76,50 @@ working copy.
| `OperatorBenchmarks` | The generated physics relationships. `Add` is the control, being the one operation that changes no dimension. |
| `VectorBenchmarks` | Componentwise arithmetic, and the square root two of the operations need. `LengthSquared` is `Length` without the root, so the gap between them is the root alone — which for `decimal` and `PreciseNumber` is a Newton refinement rather than a hardware instruction. |
| `ComparisonBenchmarks` | The two comparison routes. The operators are the storage type's own; `CompareTo` and the `IPhysicalQuantity` overload of `Equals` box the argument and check dimensions first, which is what buys the ability to refuse a length against a mass. |
+| `AbstractionCostBenchmarks` | The same arithmetic twice, once on the bare storage type and once on quantities over it, paired so BenchmarkDotNet reports the ratio. See below. |
| `BaselineBenchmarks` | Touches none of this library. It exists so that timings taken in different CI jobs can be compared; see its remarks, and do not edit its body. |
+### What the quantity types cost over the bare storage type
+
+`AbstractionCostBenchmarks` is the one that answers the question the rest of the suite only implies:
+a quantity is a `readonly record struct` holding one value in the SI base unit, so an operator on it
+should be the storage type's own arithmetic and a struct initialiser, and nothing more once the JIT
+has inlined it. The C++ projection has always been held to that against bare floats. This holds the
+.NET side to it the same way.
+
+Each pair runs identical arithmetic on `T` and on quantities over `T`, with the bare one marked
+`Baseline = true`, so the answer is the `Ratio` column rather than two rows divided by hand:
+
+| storage | `Add` | `Multiply` (to `Area`) | allocation |
+|---|---|---|---|
+| `float` | 1.00 | 0.94 | none either side |
+| `double` | 1.02 | 0.94 | none either side |
+| `decimal` | 1.04 | 0.90 | none either side |
+| `PreciseNumber` | 1.03 | 1.03 | 193 B either side, ratio 1.00 |
+
+The wrapper is free. Read the ratios below 1.00 as noise and code layout rather than as the quantity
+being faster than the number inside it — the spread across three short-run iterations covers that
+much, and there is no mechanism by which it could be.
+
+The `PreciseNumber` row is the one that says it most precisely, because it is the only storage type
+here that allocates at all: **the allocation ratio is exactly 1.00**. Every byte belongs to the
+`BigInteger` inside, and the quantity adds none of its own.
+
+**These are loops, and that is deliberate.** A single operator over operands that do not change is
+loop-invariant and the JIT hoists it out, which is exactly what makes `OperatorBenchmarks` report
+ZeroMeasurement for `double` and `float` — and a ratio between two hoisted methods would mean
+nothing. Here each iteration feeds the next, so there is nothing to hoist and both sides of a pair
+are measurable for every storage type.
+
+**The loop's own cost biases toward 1.00, not away from it.** Both sides pay the same counter and
+branch; it is a dependency chain, so most of that overlaps the arithmetic, and whatever does not is
+added equally to numerator and denominator. So a ratio at 1.00 is the claim kept, and a ratio above
+it is a floor on the real cost rather than the whole of it.
+
+**The operands stay bounded on purpose.** `PreciseNumber` carries as many digits as the arithmetic
+produces, so a chain that compounds its operand would measure digit growth instead of the operation.
+Both loops accumulate rather than compound.
+
### An operator on a `double` is below the floor
`OperatorBenchmarks` over `double` and `float` comes back with a ZeroMeasurement warning: the
diff --git a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj
index 8bdeb4f..3731767 100644
--- a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj
+++ b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj
@@ -44,12 +44,21 @@
-
+
+ Unconditional, and it has to stay that way. This was once switchable per consumer through
+ AdditionalProperties="BundleAnalyzerDependencies=false", which MSBuild turns into a global
+ property, and global properties are what key its project-instance cache. Two consumers
+ asking for this project with different global properties are two instances of it, and both
+ of them build, concurrently, into this one bin\Release\netstandard2.0 directory. A third
+ node reading Semantics.SourceGenerators.dll as an analyzer while one of those copies is in
+ flight fails with CS0006 "Metadata file could not be found", naming the generator from a
+ project that has nothing wrong with it. That is a race, so it struck on CI and never
+ locally.
+
+ A consumer that wants the assembly without the bundle now filters its own compile
+ references instead; Semantics.Test carries that target and explains it. Filtering is a
+ consumer's own business and forks nothing. -->
+
diff --git a/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs b/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs
new file mode 100644
index 0000000..1c7ec6a
--- /dev/null
+++ b/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs
@@ -0,0 +1,129 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Test.Quantities;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Xml.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Checks that every reference to the source generator project asks for it the same way.
+///
+///
+///
+/// MSBuild keys its project-instance cache on the project path together with the global properties
+/// it was asked for. AdditionalProperties on a ProjectReference becomes a global
+/// property, so two consumers passing different ones are asking for two instances of the same
+/// project — and both build, concurrently, into the one
+/// Semantics.SourceGenerators\bin\$(Configuration)\netstandard2.0 directory.
+///
+///
+/// A compiler reading Semantics.SourceGenerators.dll as an analyzer while one of those
+/// copies is in flight fails with CS0006: Metadata file could not be found, reported against
+/// Semantics.Quantities, which has nothing wrong with it. Being a race it needs the build to
+/// be wide enough to lose, so it passed locally and on the test runners and failed in
+/// Analyze & Release.
+///
+///
+/// The condition is cheap to state and cheap to check, so it is checked here rather than left to a
+/// comment: every reference to the generator carries the same global properties, which today means
+/// none carries any. A consumer wanting something different from the generator's output filters its
+/// own references instead — see DropBundledGeneratorDependencies in this project's file.
+///
+///
+[TestClass]
+public class GeneratorProjectReferenceTests
+{
+ private const string GeneratorProject = "Semantics.SourceGenerators.csproj";
+
+ ///
+ /// The ProjectReference attributes that turn into global properties on the referenced project,
+ /// and so decide how many instances of it MSBuild builds.
+ ///
+ private static readonly string[] ForkingAttributes =
+ [
+ "AdditionalProperties",
+ "SetTargetFramework",
+ "GlobalPropertiesToRemove",
+ "UndefineProperties",
+ ];
+
+ [TestMethod]
+ public void NoProjectForksTheGeneratorWithAdditionalProperties()
+ {
+ List offenders =
+ [
+ .. GeneratorReferences()
+ .Where(reference => reference.Element.Attribute("AdditionalProperties") is not null)
+ .Select(reference =>
+ $"{reference.Project}: AdditionalProperties=\"{reference.Element.Attribute("AdditionalProperties")!.Value}\"")
+ ];
+
+ Assert.IsEmpty(
+ offenders,
+ "A ProjectReference to the source generator passes AdditionalProperties, which forks it " +
+ "into a second MSBuild instance building into the same output directory. Filter the " +
+ "resolved references in the consuming project instead. Offenders: " +
+ string.Join("; ", offenders));
+ }
+
+ [TestMethod]
+ public void EveryGeneratorReferenceAsksForTheSameGlobalProperties()
+ {
+ List<(string Project, string Properties)> asked =
+ [
+ .. GeneratorReferences()
+ .Select(reference => (
+ reference.Project,
+ Properties: string.Join(
+ ";",
+ ForkingAttributes
+ .Select(name => reference.Element.Attribute(name)?.Value ?? "")
+ .Where(value => value.Length > 0))))
+ ];
+
+ Assert.IsNotEmpty(asked, $"No project references {GeneratorProject}; this test is checking nothing.");
+
+ List distinct = [.. asked.Select(one => one.Properties).Distinct(StringComparer.Ordinal)];
+
+ Assert.HasCount(
+ 1,
+ distinct,
+ "References to the source generator ask for different global properties, so MSBuild " +
+ "builds it more than once into one output directory: " +
+ string.Join(", ", asked.Select(one => $"{one.Project} -> \"{one.Properties}\"")));
+ }
+
+ private static IEnumerable<(string Project, XElement Element)> GeneratorReferences()
+ {
+ foreach (string project in Directory.EnumerateFiles(RepositoryRoot(), "*.csproj", SearchOption.AllDirectories))
+ {
+ XDocument document = XDocument.Load(project);
+ IEnumerable references = document
+ .Descendants()
+ .Where(element => element.Name.LocalName == "ProjectReference")
+ .Where(element =>
+ (element.Attribute("Include")?.Value ?? "")
+ .Replace('\\', '/')
+ .EndsWith(GeneratorProject, StringComparison.Ordinal));
+
+ foreach (XElement reference in references)
+ {
+ yield return (Path.GetFileName(project), reference);
+ }
+ }
+ }
+
+ private static string RepositoryRoot()
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Semantics.SourceGenerators")))
+ {
+ directory = directory.Parent;
+ }
+
+ Assert.IsNotNull(directory, "Could not locate the repository root from the test output directory.");
+ return directory!.FullName;
+ }
+}
diff --git a/Semantics.Test/Semantics.Test.csproj b/Semantics.Test/Semantics.Test.csproj
index 01b9343..a1edefd 100644
--- a/Semantics.Test/Semantics.Test.csproj
+++ b/Semantics.Test/Semantics.Test.csproj
@@ -21,11 +21,9 @@
-
+ this project. Only the generator assembly itself is wanted here; DropBundledGeneratorDependencies
+ below drops the rest. -->
+
@@ -57,4 +55,32 @@
CopyToOutputDirectory="PreserveNewest" />
+
+
+
+
+ <_SemanticsGeneratorProject>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)..', 'Semantics.SourceGenerators', 'Semantics.SourceGenerators.csproj'))
+
+
+ <_ResolvedProjectReferencePaths
+ Remove="@(_ResolvedProjectReferencePaths)"
+ Condition="'%(MSBuildSourceProjectFile)' == '$(_SemanticsGeneratorProject)' And '%(Filename)' != 'Semantics.SourceGenerators'" />
+
+
+