One molar mass of water, not two - #101
Conversation
kg_to_mol_h2o was 55.4939 and kg_per_mol_h2o was 0.018015: two constants naming the same quantity in opposite directions, disagreeing by 0.0277%, used in opposite halves of the model. A flux converted kg -> mol on the demand side and mol -> kg on the supply side did not come back. Which was wrong is not a convention question. 55.4939 is 1/0.018020, a molar mass of 18.0200 g/mol; water's is 18.015 (2(1.008) + 15.999). So there is now one molar_mass_h2o = 0.018015 and both old names derive from it, reciprocal by construction. Both names are kept: plant using-declares both (#51). Moves results: 3710 of 5184 golden cells, median 9.4e-05, of which 3377 within 2x the constant's own 2.77e-04; largest absolute move 1.6e-03. Gradients 60 of 100, worst 2.9e-03. Full split in the first PR comment. Version to 0.4.0, per the rule #99 set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The arithmetic, in fullWater's molar mass from the standard atomic weights is 2(1.008) + 15.999 = 18.015 g/mol. So Two consequences of taking the forced option rather than the digit-preserving one:
Blast radius:
|
| relative move | cells |
|---|---|
| < 1e-9 | 178 |
| 1e-9 – 1e-6 | 68 |
| 1e-6 – 1e-5 | 238 |
| 1e-5 – 1e-4 | 1412 |
| 1e-4 – 1e-3 | 1630 |
| > 1e-3 | 184 |
The interpretation that matters: 3377 of the 3710 moved cells move by no more than 2× the constant's own 2.77e-04. That is the constant propagating, not the solver amplifying it, and it is what you would predict for a multiplicative change to the conductance conversion.
The 184 cells above 1e-3 are small denominators, not large changes. They sit at hot, dry, multi-layer points where the quantity itself is near zero; the largest absolute move anywhere in the file is 1.61e-03, and among those 184 the median absolute move is 1.5e-06. The five worst by relative measure:
| psi_soil | ppfd | vpd | T | layers | field | old | new | rel |
|---|---|---|---|---|---|---|---|---|
| 2 | 500 | 0.5 | 40 | 5 | profit | 0.008784 | 0.009019 | 2.67e-02 |
| 1 | 500 | 4.0 | 40 | 5 | profit | 0.002645 | 0.002715 | 2.64e-02 |
| 1 | 900 | 4.0 | 40 | 5 | profit | 0.004012 | 0.004083 | 1.76e-02 |
| 1 | 1500 | 4.0 | 40 | 5 | profit | 0.004478 | 0.004549 | 1.59e-02 |
| 2 | 100 | 4.0 | 25 | 1 | profit | −0.020600 | −0.020333 | 1.29e-02 |
profit dominates that tail for a structural reason: it is benefit − cost, so where the two nearly cancel a small change in either is large relative to the difference. Point classification is unchanged — 198/42/48 at 25 °C, 160/80/48 at 40 °C, no point changed branch.
Blast radius: gradient_golden.tsv
60 of 100 cells; median 3.15e-04; worst 2.90e-03. By case: interior-5layer 2.90e-03, interior-1layer 1.96e-03, single-potential 8.24e-04, pinned-dry-3layer 4.13e-04, shutdown-1layer 0 (never reaches the collar solve).
Two things in the suite that this broke, and neither was the change
1. A hard-coded Hessian. test-gradient.R pinned g$H = -8.9561; now −8.9578, a move of 1.86e-04 — the constant propagating, same band as everything else. Updated with the magnitude recorded.
2. test-gradient.R's envelope-theorem test lost its operating point, and this is the interesting one.
That test asserts two things at once: the exact instrument says the profit gradient is stationary (|exact| < 1e-9) and a central difference says nothing of the kind (|fd| > 1e-5). It only means something at a point where the differenced instrument is noisy enough to see, and its comment says so: it pinned psi_soil = 0.5, vpd = 2, 3 layers as the worst of the interior rows at fd = 8.0e-05.
After #51 that point's fd collapsed to 1.5e-09 — a ratio of 24 between the two instruments, where the test needs orders. It failed on the fd side, i.e. the test stopped being able to demonstrate its own claim, rather than the claim becoming false.
I re-derived the point rather than adjusting the bound: swept all 198 interior rows of the golden grid, computed exact and fd at each, and took the largest |fd|. It is now psi_soil = 1.0, ppfd = 1500, vpd = 1.0, 1 layer, where exact = 7.8e-16 against fd = 2.7e-04 — a ratio of 3.5e+11, a considerably stronger demonstration than the point it replaces.
The general lesson is now in the test: the choice of operating point does not survive a results change, and a failure on the fd assertion means the point went quiet, not that the feature broke. The sweep recipe is written down next to it so the next person re-derives rather than relaxes. That was not known when the test was written — it reads as though the point were a fixed property of the model, and it is a property of the model's current numbers.
Verification
make -C tests/cpp— 512 checks, 0 failures (6 new, on the reciprocity), golden bit-identical after deliberate regenerationmake -C tests/cpp bench_solve bench_gradient— both buildmake test-cmake— 2/2devtools::test()— 1107 pass, 0 fail, 0 skip- The reciprocity test asserts
kg_to_mol_h2o == 1.0 / molar_mass_h2oexactly, thatkg_per_mol_h2ois the molar mass rather than a second copy, that a kg → mol → kg round trip returns its input to 4ε across five decades, and that the value is 18.015 g/mol and not the pre-kg_to_mol_h2o and kg_per_mol_h2o are deliberately not reciprocals — 0.028%, waiting on a baseline to unify against #51 55.4939. Written because "these two constants are reciprocals" is exactly the kind of claim that was already in this header and was false.
For the record: #100's fingerprint did its job on its first outing
tools/fingerprint.R reported 7a0f1e194fc8 -> e4f5a6ebd157 with the "the numbers moved" warning, unprompted, as part of regenerating. That is the mechanism working the first time it was given a real results change to notice.
The inline consumer program in cpp-tests.yml asserts a profit and a gradient at fixed operating points, and #51 moved both: profit by 5.2e-04 against a 1e-6 tolerance, and dA/dvcmax_25 by 1.61e-05 against 1e-5. Two of the three C++ jobs went red while `make -C tests/cpp`, `make -C tests/cpp bench` and `make test-cmake` were all green locally -- which is the hazard the guide records about this heredoc. Both values now name the file to copy them from, and say what moved them. Verified by extracting the two heredocs by line range and building the consumer against a locally installed prefix, which is the only way to exercise it outside CI: exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // directions are reciprocal by construction and a round trip is the identity to | ||
| // within one rounding. | ||
| // | ||
| // ⚠️ THIS USED TO BE TWO INDEPENDENT NUMBERS THAT DISAGREED BY 0.0277%, and the |
There was a problem hiding this comment.
Do we really need to leave this history here?
| return std::fabs(l.profit_ - 2.5158434915102319) < 1e-6 ? 0 : 1; | ||
| // | ||
| // ⚠️ COPY THIS FROM THE GOLDEN FILE WHENEVER RESULTS MOVE. It was | ||
| // 2.5158434915102319 until #51 unified the water molar mass, which moved |
There was a problem hiding this comment.
Do we really need to leave this history here?
| // argmax correction and not rounding. profit_ and transpiration_ did not move | ||
| // at this tolerance -- profit_ because it is the maximum and therefore flat. | ||
| // | ||
| // #51, unifying the kg <-> mol water constants on the molar mass of water: |
There was a problem hiding this comment.
Do we really need to leave this history here?
Review feedback: comments should carry only what has enduring forward value -- the contract, the invariant, the hazard a future editor could reintroduce -- not an account of what the values used to be or how far they moved. That belongs in the PR body, the first PR comment and NEWS.md, all of which already have it. Net -74 lines across five files. What survives is phrased as a rule: "both directions derive from this, do not reintroduce a second literal", "copy this value from the golden file when results move -- no local build compiles this program", "re-derive the operating point rather than adjusting the bounds". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stacked on #101 — base it there, not on `master`. You asked what else moves results. This is the sweep: every physical constant in `constants.hpp` checked against its accepted value, the same class of defect #51 was. **One was forced.** `gas_constant` was `8.314`; R = N_A k_B has been *exact* since the 2019 SI redefinition, at 8.314462618153240. The 5.56e-05 correction earns a results change because the amplification is not 5.56e-05 — the constant appears only as `Ea/(R*T)` with `Ea/RT` ~ 24, so rates move ~1.3e-03. **And it moves nothing at 25 °C, exactly** — predicted from the algebra before measuring, then confirmed: **1728 of 1728 moved golden cells are at 40 °C and the 25 °C block is byte-identical.** `gradient_golden.tsv` does not move, and all 1107 R tests passed unaltered, the R suite having no coverage above 25 °C. Median relative move 6.49e-05, largest absolute 2.04e-03. **Two were flagged and deliberately left**, documented at the constant: `gravity_head` (6.78e-04 off `rho_w*g`, but the water-density convention is unstated — a 0.3% spread, four times the discrepancy) and `latent_heat_vap` (its comment claimed 25 °C for a value that is ~21.5 °C; belongs to #28). Full audit table in the first comment. Contributes to #51's audit; does not close an issue on its own. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…angco
Four conflicts, all in leaf_model.hpp, all where this branch and master changed the
same expression for different reasons:
* three conductance conversions -- this branch replaced atm_vpd_ with vpd_leaf_,
master made H2O_CO2_stom_diff_ratio a settable member. Both changes kept.
* dprofit's early-return dE_dpsi -- this branch rewrote the derivation to state it
directly, because the coefficient master's version cancels is no longer a single
number once the deficit moves with Tleaf. This branch's version kept; it does not
reference the ratio.
R/RcppR6.R was regenerated rather than hand-resolved, and man/ + NAMESPACE were
stale after the merge and regenerated too.
Verified: 566 C++ checks / 0 failures, golden bit-identical, gradient golden
unchanged, fingerprint unchanged at 3e36d9b94fd0, 1137 R tests / 0 failures,
R CMD check --as-cran Status: OK, ctest 2/2, bench targets build, and the CI
consumer program built against an installed prefix exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kg_to_mol_h2owas 55.4939 andkg_per_mol_h2owas 0.018015: two constants naming the same quantity in opposite directions, disagreeing by 0.0277%, used in opposite halves of the model. The demand side converted transpiration kg → mol with the first, the supply side converted uptake mol → kg with the second, so a flux pushed through both did not come back. The header recorded the discrepancy as deliberate, "kept at the historical 0.018015 to preserve results".Which one was wrong is not a convention question, which is what let this be settled rather than argued. 55.4939 is 1/0.018020, i.e. a molar mass of 18.0200 g/mol; water's is 18.015 (2×1.008 + 15.999). So there is now one
molar_mass_h2o = 0.018015with both old names derived from it, reciprocal by construction. Both names are kept — plantusing-declares both. Note this is the larger of the two possible moves; the other preserves more digits by adopting a molar mass water does not have.Moves results: 3710 of 5184 golden cells, median 9.4e-05, of which 3377 are within 2× the constant's own 2.77e-04; largest absolute move anywhere 1.6e-03. Gradients 60 of 100, worst 2.9e-03. Split by cause in the first comment.
LinkingTo: phylloptim (>= 0.2.0)floor should become>= 0.4.0.Closes #51. Unblocks #50 and #56, which both said this had to be settled first.