|
1 | 1 | # PR #1544 Review: QuantityValue as Fractional Number |
2 | 2 |
|
3 | | -**Review date**: 2026-03-09, updated 2026-03-26 | **Branch**: `fractional-quantity-value` |
| 3 | +Great work on this PR — the architectural improvements (ConversionExpression, centralized UnitConverter, builder pattern, exact rational coefficients) are solid and we want to get this merged. |
4 | 4 |
|
5 | | ---- |
| 5 | +The main concern is the breaking change impact of replacing `double` with `QuantityValue` across the public API. We evaluated two approaches to mitigate this and implemented both to get empirical data. |
6 | 6 |
|
7 | | -## P0 — Reducing the breaking change impact of `QuantityValue` |
| 7 | +--- |
8 | 8 |
|
9 | | -The PR replaces `double` with `QuantityValue` (BigInteger-backed rational) across the entire public API. Without mitigation, ~60-70% of consumer code breaks on upgrade. We propose two alternative solutions. |
| 9 | +## The problem |
10 | 10 |
|
11 | | -### Summary |
| 11 | +Without mitigation, ~60-70% of consumer code breaks on upgrade: |
12 | 12 |
|
13 | | -| | Option A: Add implicit cast | Option B: Keep `double` public | |
14 | | -|---|---|---| |
15 | | -| **Approach** | Add `implicit operator double(QuantityValue)` | Revert public API to `double`, use `QuantityValue` internally for conversions | |
16 | | -| **Rework effort** | 1 line library code + 101 test fixes (mechanical) | Multi-day: ~10 source files, CodeGen templates, 131 regenerated files, serialization rework | |
17 | | -| **PR value preserved** | 100% | ~90% | |
18 | | -| **Consumer breaking changes** | Eliminated for ~60-70% of code | Eliminated for ~60-70% of code | |
19 | | -| **Precision model** | Consumers store and pass `QuantityValue` (full precision until they convert to `double`) | Consumers always work with `double`; precision only during conversion pipeline | |
20 | | -| **Risk** | Verified: 0 library build errors, 0 new test failures | Not implemented; significant risk of cascading issues in serialization layer | |
21 | | -| **`var x = len.Meters`** | `x` is `QuantityValue` (but implicitly converts to `double` wherever needed) | `x` is `double` | |
22 | | -| **Future flexibility** | Consumers who want exact precision can use `QuantityValue` directly today | Would require a later API change to expose `QuantityValue` to consumers | |
23 | | - |
24 | | -**Our recommendation: Option A.** Empirically verified: 1 line of library code changed, 0 build errors, 0 new test failures across ~51,900 tests. Option B achieves the same consumer-facing goal but requires multi-day rework with no additional benefit. |
| 13 | +```csharp |
| 14 | +double meters = length.Meters; // ❌ won't compile (returns QuantityValue) |
| 15 | +double ratio = len1 / len2; // ❌ won't compile (returns QuantityValue) |
| 16 | +Math.Round(length.Meters, 2); // ❌ won't compile |
| 17 | +SomeApi(length.Value); // ❌ won't compile |
| 18 | +``` |
25 | 19 |
|
26 | 20 | --- |
27 | 21 |
|
28 | | -## Option A: Add implicit conversion `QuantityValue → double` |
29 | | - |
30 | | -### What changes |
| 22 | +## Option A: Add implicit conversion `QuantityValue → double` (recommended) |
31 | 23 |
|
32 | | -One line added to `QuantityValue.ConvertToNumber.cs`: |
| 24 | +Change one keyword in `QuantityValue.ConvertToNumber.cs`: |
33 | 25 |
|
34 | 26 | ```csharp |
| 27 | +// Before: |
| 28 | +public static explicit operator double(QuantityValue value) => value.ToDouble(); |
| 29 | +// After: |
35 | 30 | public static implicit operator double(QuantityValue value) => value.ToDouble(); |
36 | 31 | ``` |
37 | 32 |
|
38 | | -The existing `explicit` keyword changes to `implicit`. Everything else in the PR stays as-is. |
39 | | - |
40 | 33 | ### Why it works |
41 | 34 |
|
42 | | -The PR is clean — **no generated operator takes `double`**. Every operator uses `QuantityValue`: |
| 35 | +The PR is clean — **no generated operator takes `double`**. Every operator uses `QuantityValue` exclusively: |
43 | 36 |
|
44 | 37 | ```csharp |
45 | | -// Generated operators (e.g. Length.g.cs): |
46 | 38 | Length operator *(QuantityValue left, Length right) |
47 | 39 | Length operator *(Length left, QuantityValue right) |
48 | 40 | Length operator /(Length left, QuantityValue right) |
49 | 41 | QuantityValue operator /(Length left, Length right) |
50 | 42 | ``` |
51 | 43 |
|
52 | | -`QuantityValue` arithmetic operators are all `(QuantityValue, QuantityValue)` — no mixed-type overloads. |
53 | | - |
54 | | -With bidirectional implicit conversions (`double ↔ QuantityValue`), C# overload resolution prefers user-defined operators over built-in ones when both require one conversion. So `QV + double` resolves to user-defined `QV + QV` (promoting `double`), not built-in `double + double` (demoting `QV`). |
| 44 | +With bidirectional implicit conversions (`double ↔ QuantityValue`), C# overload resolution prefers user-defined operators over built-in ones when both require one implicit conversion. So `QV + double` resolves to user-defined `QV + QV`, not built-in `double + double`. |
55 | 45 |
|
56 | | -### Scenario analysis |
| 46 | +### Verified empirically (branch `option-a-implicit-cast`) |
57 | 47 |
|
58 | | -| Scenario | Works? | Explanation | |
59 | | -|----------|--------|-------------| |
60 | | -| `double meters = length.Meters` | Yes | `QuantityValue` implicitly converts to `double` | |
61 | | -| `double ratio = len1 / len2` | Yes | Operator returns `QV`, implicit converts to `double` | |
62 | | -| `Math.Round(length.Meters, 2)` | Yes | Resolves to `Math.Round(double, int)` via implicit | |
63 | | -| `SomeDoubleApi(length.Meters)` | Yes | Implicit conversion handles the call | |
64 | | -| `Length * double` | Yes | `double` promotes to `QV`, uses `Length * QV` operator | |
65 | | -| `QV + double` | Yes | C# prefers user-defined `QV+QV` over built-in `double+double` | |
66 | | -| `double x = double + QV` | Yes | Operator returns `QV`, implicit converts for assignment | |
67 | | -| `var x = length.Meters` | `x` is `QV` | Not a problem — `QV` implicitly converts wherever `double` is expected | |
68 | | -| `QV == double` | Yes | User-defined `QV==QV` is preferred by C# overload resolution | |
69 | | - |
70 | | -### Pros |
| 48 | +We implemented this and built/tested the full solution: |
71 | 49 |
|
72 | | -- **Minimal rework** — one operator change, rest of PR untouched |
73 | | -- **Preserves 100% of PR value** — all architecture, all new features |
74 | | -- **Future-proof** — consumers who want exact precision can use `QuantityValue` directly; those who don't never notice the difference |
75 | | -- **Gradual adoption** — consumers can migrate to `QuantityValue` at their own pace |
| 50 | +- **Library code**: 1 line changed. **Zero compilation errors.** |
| 51 | +- **Test code**: 101 `Assert.Equal(double, QuantityValue)` calls needed `(double)` casts to resolve xUnit overload ambiguity — a mechanical fix, not a behavioral change. |
| 52 | +- **Build**: 0 errors, 0 warnings. |
| 53 | +- **Tests**: **0 new failures** across ~51,900 tests. |
76 | 54 |
|
77 | | -### Cons |
| 55 | +The xUnit ambiguity is specific to `Assert.Equal`'s many overloads (including `DateTime`). Standard consumer code — method calls, assignments, operators, comparisons — is unaffected. |
78 | 56 |
|
79 | | -- **Bidirectional implicit conversion is unusual** — while our analysis found no issues, there may be edge cases in consumer code we can't predict (e.g. complex overload resolution scenarios) |
80 | | -- **`var` infers `QuantityValue`** — not a functional problem (implicit conversion kicks in), but consumers see `QuantityValue` in IDE tooltips, debugger, etc. |
81 | | -- **Silent precision loss** — converting `QuantityValue` to `double` is lossy, and implicit makes it invisible. This is the *desired* trade-off for most consumers, but purists may object. |
82 | | -- **lipchev has stated this is a "no-go"** — may need to be verified empirically by building locally with the change |
| 57 | +### Consumer code scenarios |
83 | 58 |
|
84 | | -### Verified empirically |
85 | | - |
86 | | -We implemented Option A on branch `option-a-implicit-cast` and built/tested the full solution. |
| 59 | +| Scenario | Works? | Explanation | |
| 60 | +|----------|--------|-------------| |
| 61 | +| `double meters = length.Meters` | ✅ | Implicit conversion | |
| 62 | +| `double ratio = len1 / len2` | ✅ | Operator returns `QV`, implicit converts | |
| 63 | +| `Math.Round(length.Meters, 2)` | ✅ | Resolves to `Math.Round(double, int)` | |
| 64 | +| `SomeDoubleApi(length.Meters)` | ✅ | Implicit conversion | |
| 65 | +| `Length * double` | ✅ | `double` promotes to `QV`, uses `Length * QV` | |
| 66 | +| `double x = double + QV` | ✅ | Operator returns `QV`, implicit converts for assignment | |
| 67 | +| `var x = length.Meters` | ✅ | `x` is `QV` — implicitly converts wherever `double` is expected | |
87 | 68 |
|
88 | | -**Results:** |
89 | | -- **Library code**: 1 line changed (`explicit` → `implicit` in `QuantityValue.ConvertToNumber.cs`). **Zero library compilation errors.** |
90 | | -- **Test code**: 101 `Assert.Equal(double, QuantityValue)` calls needed `(double)` casts to resolve xUnit overload ambiguity. This is a mechanical fix — the test semantics are unchanged. |
91 | | -- **Build**: 0 errors, 0 warnings across all projects. |
92 | | -- **Tests**: 0 new failures. All ~51,900 tests pass. (4 pre-existing failures on the base PR branch are unrelated: locale-dependent `ConversionExpression.ToString` and custom quantity serialization registration.) |
| 69 | +### Trade-offs |
93 | 70 |
|
94 | | -The only issue found was xUnit `Assert.Equal` overload ambiguity when one argument is `double` and the other is `QuantityValue`. This is an xUnit-specific quirk (many overloads including `DateTime`), not a general C# problem. Consumer code using standard method calls, assignments, and operators is unaffected. |
| 71 | +- `var x = length.Meters` infers `QuantityValue` — consumers see this in IDE tooltips. Not a functional issue (implicit conversion handles everything), but visible. |
| 72 | +- Precision loss from `QuantityValue → double` is silent. This is the desired trade-off for most consumers. Those who want exact precision can stay on `QuantityValue` directly. |
| 73 | +- Bidirectional implicit conversion is unusual in C#, but our empirical testing confirms it works cleanly here. |
95 | 74 |
|
96 | 75 | --- |
97 | 76 |
|
98 | | -## Option B: Keep `double` public, use `QuantityValue` internally |
99 | | - |
100 | | -### What changes |
| 77 | +## Option B: Keep `double` public, use `QuantityValue` internally (not recommended) |
101 | 78 |
|
102 | | -- `IQuantity.Value` reverts to `double` |
103 | | -- Generated quantity unit properties (`.Meters`, `.Centimeters`, etc.) revert to `double` |
104 | | -- Generated quantity `_value` field reverts to `double` |
105 | | -- Scalar operators revert to `double` (`Length * double`, `Length / Length → double`) |
106 | | -- `From()` factory methods accept `double` |
107 | | -- `QuantityValue` remains as an internal type used by the conversion pipeline |
108 | | -- `ConversionExpression`, `UnitConverter`, `UnitsNetSetup` builder — all unchanged |
| 79 | +Revert the public API to `double` while keeping `QuantityValue` for the internal conversion pipeline. We fully implemented this to evaluate the trade-offs. |
109 | 80 |
|
110 | | -### How the conversion pipeline works |
| 81 | +### Verified empirically (branch `option-b-double-public`) |
111 | 82 |
|
112 | | -``` |
113 | | -double input → QuantityValue(input) × exact_rational_coefficient → .ToDouble() → double output |
114 | | -``` |
| 83 | +- **608 files changed**: ~10 manual source files, CodeGen templates (~15 locations), `UnitRelations.json`, all 131 generated quantities regenerated, serialization adapters, test and benchmark code. |
| 84 | +- **Build**: 0 library errors, 0 warnings. |
| 85 | +- **Tests**: **2,509 failures out of 51,899 (5%).** |
115 | 86 |
|
116 | | -The precision benefit is preserved: conversion factors like `1250/381` (feet-to-meters) are exact rationals. The stored value is `double`, and the conversion pipeline temporarily promotes to `QuantityValue` for exact arithmetic, then converts back. |
| 87 | +### The failures are not test bugs |
117 | 88 |
|
118 | | -### What's preserved from the PR (~90%) |
| 89 | +They reveal a fundamental precision loss from `double` storage: |
119 | 90 |
|
120 | | -| Component | Status | |
121 | | -|-----------|--------| |
122 | | -| `QuantityValue` type (BigInteger rational) | Preserved for internal use | |
123 | | -| `ConversionExpression` model with exact rational coefficients | Fully preserved | |
124 | | -| `UnitConverter` / `FrozenQuantityConverter` / `DynamicQuantityConverter` | Fully preserved | |
125 | | -| `UnitInfo.ConversionFromBase` / `ConversionToBase` | Fully preserved | |
126 | | -| `UnitsNetSetup` builder pattern | Fully preserved | |
127 | | -| CodeGen rational coefficient generation | Fully preserved | |
128 | | -| `UnitsNet.Serialization.SystemTextJson` package | Preserved | |
| 91 | +| Failure category | Count | Root cause | |
| 92 | +|-----------------|-------|------------| |
| 93 | +| `ToUnit_FromNonBaseUnit` | 1,215 | Roundtrip conversion loses precision — `double` introduces rounding at each step | |
| 94 | +| `ToUnit_FromIQuantity` | 491 | Same root cause | |
| 95 | +| `ToString` | 256 | Precision-sensitive formatting | |
| 96 | +| `ConversionRoundTrip` | 68 | Direct roundtrip tests | |
129 | 97 |
|
130 | | -### What's lost (~10%) |
131 | | - |
132 | | -- Consumers cannot work with `QuantityValue` directly — no exact precision in user-facing code |
133 | | -- Intermediate arithmetic between quantities uses `double`, not rationals (precision only in conversion factors) |
134 | | -- Would require a later API change to expose `QuantityValue` to consumers who want it |
135 | | - |
136 | | -### Pros |
137 | | - |
138 | | -- **Zero risk** — `double` public API is well-understood, no edge cases |
139 | | -- **No consumer breaking changes from the type change** — all `double`-based code works as before |
140 | | -- **Familiar to consumers** — no new type to learn about |
141 | | -- **Smaller memory footprint** — `double` is 8 bytes vs `QuantityValue` (16+ bytes for two BigInteger fields) |
142 | | - |
143 | | -### Cons |
144 | | - |
145 | | -- **Significant rework** — CodeGen templates, generated types, interface definitions all need adjustment |
146 | | -- **Loses future flexibility** — consumers can't access `QuantityValue` precision without a later API change |
147 | | -- **Precision only in conversion pipeline** — consumer arithmetic (`length1 + length2`) uses `double`, not rationals |
148 | | -- **~10% of PR value lost** — the `QuantityValue`-as-public-type feature and its test coverage |
149 | | - |
150 | | -### Verified empirically |
151 | | - |
152 | | -Option B was fully implemented on branch `option-b-double-public` (608 files changed). |
153 | | - |
154 | | -**Changes made:** |
155 | | -- ~10 manual source files: `IQuantity.cs`, `IArithmeticQuantity.cs`, `ILogarithmicQuantity.cs`, `QuantityExtensions.cs`, `QuantityParser.cs`, `Quantity.cs`, `UnitConverter.cs`, `Comparison.cs`, `QuantityInfo.cs`, plus 10 custom quantity extra files |
156 | | -- CodeGen templates: `QuantityGenerator.cs` (~15 locations), `UnitTestBaseClassGenerator.cs`, `NumberExtensionsGenerator.cs`, `NumberExtensionsCS14Generator.cs`, `QuantityRelationsParser.cs` |
157 | | -- `UnitRelations.json` type reference |
158 | | -- All 131 generated quantity files + test base classes regenerated |
159 | | -- Serialization adapters updated |
160 | | -- Benchmark and test code updated (decimal literals → double, QuantityValue references → double) |
161 | | - |
162 | | -**Build: 0 library errors, 0 warnings.** All 4 library projects compile cleanly. |
163 | | - |
164 | | -**Tests: 2,509 failures out of 51,899.** The failures are NOT test bugs — they reveal a fundamental trade-off: |
165 | | -- **1,215 `ToUnit_FromNonBaseUnit` failures**: Converting a quantity to another unit and back loses precision because `double` storage introduces floating-point rounding at each step. With `QuantityValue`, this roundtrip was lossless. |
166 | | -- **491 `ToUnit_FromIQuantity` failures**: Same root cause. |
167 | | -- **256 `ToString` failures**: Precision-sensitive formatting. |
168 | | -- **68 `ConversionRoundTrip` failures**: Direct roundtrip precision tests. |
169 | | - |
170 | | -**Key finding**: Option B fundamentally degrades conversion precision. The exact rational conversion pipeline (`QuantityValue × rational_coefficient`) helps, but the `double` input/output introduces rounding that compounds across multi-step conversions. This defeats a core value proposition of the PR. |
171 | | - |
172 | | -Given that Option A achieves the same consumer-facing goal with 1 line of library code, 0 new test failures, and preserves full precision, **Option B is not recommended.** |
| 98 | +With `QuantityValue`, converting `3.0 feet → meters → feet` is lossless (exact rational arithmetic). With `double` storage, each conversion step introduces floating-point rounding that compounds. **This defeats a core value proposition of the PR.** |
173 | 99 |
|
174 | 100 | --- |
175 | 101 |
|
176 | | -## Other Changes Required Before Merge |
177 | | - |
178 | | -### P1 — Must do |
| 102 | +## Comparison |
179 | 103 |
|
180 | | -1. **Revert `EmitDefaultValue = false` on DataMember** |
181 | | - Remove the `EmitDefaultValue = false` parameter from `[DataMember]` on `_value` and `_unit` fields in generated quantities. `Length.Zero` should always serialize with both Value and Unit present. |
| 104 | +| | Option A | Option B | |
| 105 | +|---|---|---| |
| 106 | +| Library code changes | 1 line | 608 files | |
| 107 | +| Build errors | 0 | 0 | |
| 108 | +| New test failures | 0 | 2,509 (5%) | |
| 109 | +| Precision preserved | Full | Degraded (roundtrip loss) | |
| 110 | +| Consumer breaking changes eliminated | ~60-70% | ~60-70% | |
| 111 | +| Future flexibility | Consumers can use `QuantityValue` directly | Requires later API change | |
182 | 112 |
|
183 | | -### P2 — Should do |
| 113 | +## Recommendation |
184 | 114 |
|
185 | | -2. **Document DataContract serialization as a breaking change** |
186 | | - State in v6 release notes that DataContract serialization format changed (XML and JSON). XML surrogate exists and works. DataContractJsonSerializer surrogate is blocked by a .NET runtime bug — recommend migrating to JsonNet or System.Text.Json packages. |
| 115 | +**Option A.** One line of library code, zero new test failures, full precision preserved. It eliminates the largest consumer-facing breaking change while keeping 100% of the PR's value. |
187 | 116 |
|
188 | 117 | --- |
189 | 118 |
|
190 | | -## Deferred (Post-Merge) |
191 | | - |
192 | | -### Before stable release |
| 119 | +## Other items to address before merge |
193 | 120 |
|
194 | | -3. **Create migration guide** for breaking changes in this PR. Will need re-evaluation for full v6 migration guide later. |
195 | | -4. **Add test coverage for `InterfaceQuantityWithUnitTypeConverter`** — currently appears unused/untested. |
| 121 | +### Must do |
196 | 122 |
|
197 | | -### Low priority |
| 123 | +1. **Revert `EmitDefaultValue = false` on DataMember** — `Length.Zero` serialized via DataContract should always include both Value and Unit. @angularsen flagged this. |
198 | 124 |
|
199 | | -5. **QuantityGenerator.cs** — Add example code comments in generated code sections for readability. |
200 | | -6. **QuantityValueFormatOptions.cs** — Align serialization/deserialization enum names (`DecimalPrecision` vs `ExactNumber` should use consistent naming). |
201 | | -7. **QuantityInfoBuilderExtensions** — `Configure` extension on `UnitDefinition[]` is awkward API; consider wrapper type or static method. |
| 125 | +### Should do |
202 | 126 |
|
203 | | -### Won't do |
| 127 | +2. **Document DataContract serialization as a breaking change** in v6 release notes. The `_value` field type change means existing DataContract-serialized data won't deserialize. XML surrogate works; JSON surrogate blocked by [.NET runtime bug](https://github.com/dotnet/runtime/issues/100553). Recommend migrating to JsonNet or System.Text.Json packages. |
204 | 128 |
|
205 | | -- Roslyn analyzer for migration assistance. |
206 | | -- Commented code cleanup (already mostly done, 6 lines remain in CodeGen internal code). |
207 | | - |
208 | | ---- |
| 129 | +### Post-merge |
209 | 130 |
|
210 | | -## Reference: Remaining Breaking Changes |
| 131 | +3. **Migration guide** for this PR's breaking changes (to be expanded for full v6 later). |
| 132 | +4. **Test coverage for `InterfaceQuantityWithUnitTypeConverter`** — appears unused/untested. |
| 133 | +5. **Align `QuantityValueFormatOptions` enum names** — `DecimalPrecision` vs `ExactNumber` should use consistent naming. |
| 134 | +6. **`Configure` extension on `UnitDefinition[]`** — consider wrapper type or static method for cleaner API. |
211 | 135 |
|
212 | | -These apply regardless of which option is chosen for `QuantityValue`: |
| 136 | +### Remaining breaking changes (regardless of QuantityValue approach) |
213 | 137 |
|
214 | | -| Change | Before (v5) | After (v6) | |
215 | | -|--------|-------------|------------| |
| 138 | +| Change | v5 | v6 | |
| 139 | +|--------|-----|-----| |
216 | 140 | | `As()`, `ToUnit()` | Interface methods | Extension methods (some `[Obsolete]`) | |
217 | | -| `UnitConverter()` constructor | Public, parameterless | Removed; use `UnitConverter.Create(...)` | |
| 141 | +| `UnitConverter()` constructor | Public, parameterless | Removed | |
218 | 142 | | `SetConversionFunction` / `GetConversionFunction` | Available | Removed | |
219 | | -| `UnitsNetSetup` constructor | Public | Private; use builder pattern | |
220 | | -| DataContract serialization | `double` field | Changed format | |
221 | | -| `AbbreviatedUnitsConverter` (JsonNet) | `IReadOnlyDictionary` constructor | `UnitParser` + `QuantityValueFormatOptions` | |
222 | | -| Default JSON precision | ~17 significant digits | Up to 29 significant digits | |
223 | | -| `MissingMemberHandling.Error` | Silently skipped unknowns | Now correctly throws (bug fix) | |
| 143 | +| `UnitsNetSetup` constructor | Public | Private; use builder | |
| 144 | +| DataContract serialization | `double` field | `QuantityValue` struct | |
| 145 | +| `AbbreviatedUnitsConverter` (JsonNet) | `IReadOnlyDictionary` ctor | `UnitParser` + options | |
| 146 | +| Default JSON precision | ~17 digits | Up to 29 digits | |
| 147 | +| `MissingMemberHandling.Error` | Silently skipped unknowns | Correctly throws (bug fix) | |
224 | 148 | | Null `IQuantity` deserialization | Returns `.Zero` | Returns `null` | |
225 | | -| Conversion factors | Floating-point approximations | Exact rational fractions | |
0 commit comments