Skip to content

Commit f96964d

Browse files
angularsenclaude
andcommitted
Revise PR 1544 review as GitHub-ready comment
Restructured for posting on PR #1544: - Leads with the problem and consumer impact - Presents both options with empirical data and comparison table - Clear recommendation for Option A (implicit cast) - Actionable items organized by priority - Remaining breaking changes reference table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9dfa5f2 commit f96964d

File tree

1 file changed

+84
-161
lines changed

1 file changed

+84
-161
lines changed

review_pr1544.md

Lines changed: 84 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,225 +1,148 @@
11
# PR #1544 Review: QuantityValue as Fractional Number
22

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.
44

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.
66

7-
## P0 — Reducing the breaking change impact of `QuantityValue`
7+
---
88

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
1010

11-
### Summary
11+
Without mitigation, ~60-70% of consumer code breaks on upgrade:
1212

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+
```
2519

2620
---
2721

28-
## Option A: Add implicit conversion `QuantityValue → double`
29-
30-
### What changes
22+
## Option A: Add implicit conversion `QuantityValue → double` (recommended)
3123

32-
One line added to `QuantityValue.ConvertToNumber.cs`:
24+
Change one keyword in `QuantityValue.ConvertToNumber.cs`:
3325

3426
```csharp
27+
// Before:
28+
public static explicit operator double(QuantityValue value) => value.ToDouble();
29+
// After:
3530
public static implicit operator double(QuantityValue value) => value.ToDouble();
3631
```
3732

38-
The existing `explicit` keyword changes to `implicit`. Everything else in the PR stays as-is.
39-
4033
### Why it works
4134

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:
4336

4437
```csharp
45-
// Generated operators (e.g. Length.g.cs):
4638
Length operator *(QuantityValue left, Length right)
4739
Length operator *(Length left, QuantityValue right)
4840
Length operator /(Length left, QuantityValue right)
4941
QuantityValue operator /(Length left, Length right)
5042
```
5143

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`.
5545

56-
### Scenario analysis
46+
### Verified empirically (branch `option-a-implicit-cast`)
5747

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:
7149

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.
7654

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.
7856

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
8358

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 |
8768

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
9370

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.
9574

9675
---
9776

98-
## Option B: Keep `double` public, use `QuantityValue` internally
99-
100-
### What changes
77+
## Option B: Keep `double` public, use `QuantityValue` internally (not recommended)
10178

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.
10980

110-
### How the conversion pipeline works
81+
### Verified empirically (branch `option-b-double-public`)
11182

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%).**
11586

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
11788

118-
### What's preserved from the PR (~90%)
89+
They reveal a fundamental precision loss from `double` storage:
11990

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 |
12997

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.**
17399

174100
---
175101

176-
## Other Changes Required Before Merge
177-
178-
### P1 — Must do
102+
## Comparison
179103

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 |
182112

183-
### P2 — Should do
113+
## Recommendation
184114

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.
187116

188117
---
189118

190-
## Deferred (Post-Merge)
191-
192-
### Before stable release
119+
## Other items to address before merge
193120

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
196122

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.
198124

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
202126

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.
204128

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
209130

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.
211135

212-
These apply regardless of which option is chosen for `QuantityValue`:
136+
### Remaining breaking changes (regardless of QuantityValue approach)
213137

214-
| Change | Before (v5) | After (v6) |
215-
|--------|-------------|------------|
138+
| Change | v5 | v6 |
139+
|--------|-----|-----|
216140
| `As()`, `ToUnit()` | Interface methods | Extension methods (some `[Obsolete]`) |
217-
| `UnitConverter()` constructor | Public, parameterless | Removed; use `UnitConverter.Create(...)` |
141+
| `UnitConverter()` constructor | Public, parameterless | Removed |
218142
| `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) |
224148
| Null `IQuantity` deserialization | Returns `.Zero` | Returns `null` |
225-
| Conversion factors | Floating-point approximations | Exact rational fractions |

0 commit comments

Comments
 (0)