PR 8b (#281, H-0109) measurements, develop 036cd18, 2026-10-01
(revised after design review round 1: census population, gain bound, grep count;
 and round 2: the gain bound's subnormal range)
=================================================================

All instruments live in ../instruments/ and run with `uv run python <file>`.

1. Which reporting-surface readings do not survive export -> load
   instrument: pr8b_load_census.py     output: pr8b_load_census.txt
   definition: tests/test_persistence/_load_census.py (shared with the
   permanent check test_reporting_surfaces_survive_load.py)

   Population: the 24 public names on Model. 4 are not reporting surfaces
   (fit, tune, export, load: the lifecycle steps and the artifact's writer and
   reader); the other 20 are read as 22 surfaces (importance x split / gain /
   shap; export_code through the arguments it passes to generate_code;
   fit_result through FitResult's fields). Bound: every surface is called with
   its defaults; non-default arguments are not measured.
   Round 1 of the design review found evaluate() missing; evaluate, fit_result
   and predict were added.

   22 surfaces x 4 configurations (regression, binary, binary with platt
   calibration, multiclass) x 5 lifecycles (fit, tune_fit, fit_tune,
   tune_fit_reexport, tune_resume_fit) = 440 cells; 80 differ.

   | surface                    | differing cells | cause                                      |
   |----------------------------|-----------------|--------------------------------------------|
   | params_table               | 12              | validation_ratio: tuned 0.45 -> config 0.2 (#281) |
   | export_code                | 12              | validation_ratio: tuned 0.45 -> config 0.2 (#281) |
   | importance_gain            | 20              | LightGBM text format (item 2)               |
   | tuning_table               | 16              | trials not persisted (H-0086); empty frame -> #315 |
   | tuning_plot                | 16              | trials not persisted (H-0086); empty figure -> #315 |
   | boundary_table             | 4               | boundary report not persisted (H-0086); raises -> #315 |

   The other 16 surfaces, evaluate / evaluate_table / fit_result / predict
   included, agree in every cell.

   The 12 + 12 #281 cells are exactly the lifecycles in which a fit consumed a
   tuning result (tune_fit, tune_fit_reexport, tune_resume_fit) x 4
   configurations. fit_tune agrees: the loaded model falls back to the config,
   which is what that fit used.

   Every surface has at least 4 exercised cells (the fitted model returned a
   reading). boundary_table has a reading only in tune_resume_fit (4);
   calibration_plot and probability_histogram_plot only in binary_calibrated
   (5 each).

2. Is the gain drift LightGBM's text format?
   instrument: pr8b_gain_precision.py  output: pr8b_gain_precision.txt

   The fold-0 booster against Booster(model_str=booster.model_to_string()):
   the text writes split_gain= with six significant digits ("4.87775
   3.24358 ..."), read back as binary32. The pickled adapter's gain equals the
   text round trip's exactly in all three tasks; predictions and OOF were
   bit-identical after load in these fixtures. Largest relative gain error on
   the real models: 1.040e-06. All split gains of those models are positive
   (smallest 0.0144321).
   Bound, per split gain g: |after - g| <= C |g| + 2**-150, with
   C = (1 + 5e-6)(1 + 2**-24) - 1, approximately 5.059605e-06. It combines the
   decimal rounding (5e-6 |g|) with the binary32 read of the rounded value d,
   |fl(d) - d| <= 2**-24 |d| + 2**-150 for every d in binary32's finite
   range, under round-to-nearest: the absolute term is half
   the subnormal spacing and also covers d just under 2**-126 rounding up into
   the normal range (round 3: d = 2**-126 - 2**-150 has relative error just
   over 2**-24). Summed over n non-negative split gains: C G + n 2**-150, and
   n 2**-150 < 2**-126 for n < 2**24.
   History: the first version said 5e-6 and left out the binary32 read
   (round 1 measured 5.054e-6); the second gave the relative term alone and
   swept normal values only (round 2 measured 5.602e-6 on a subnormal gain near
   1e-39; under the new bound 5.605e-45 <= 5.763e-45).
   Sweep of %g -> float32: 334,506 positive finite values at decimal exponents
   1e-45..1e38, subnormals included, with leading-digit-1 values next to every
   half-unit boundary: largest error / bound = 0.998971, all within; largest
   relative error over normal values 5.054398e-06 (round 1's value).
   The test tolerance is rtol 5.1e-6 + atol 2**-126.

3. What a loaded model's tuning surfaces present
   instrument: pr8b_tuning_surfaces_after_load.py
   output: pr8b_tuning_surfaces_after_load.txt

   tuning_table (4, 14) -> (0, 0); tuning_plot 2 traces -> 0 traces;
   boundary_table table -> raises MODEL_NOT_FIT "Run tune(resume=True) ...".
   A never-tuned model raises MODEL_NOT_FIT on all three (design review
   round 1), so the loaded presentation matches neither the tuned nor the
   untuned model. Filed as #315 (out of scope for #281; H-0086 rejected
   persisting trials).

4. Legacy metadata key set (before H-0109)
   instrument: pr8b_metadata_keys.py   output: pr8b_metadata_keys_before.txt

   fit:      checksums config feature_names format_version lizyml_version
             metrics python_version run_id task timestamp
   tune_fit / fit_tune: the same plus tuning.
   tuning.best_training_params is {'early_stopping_rounds': 219,
   'validation_ratio': 0.45} in BOTH tune_fit and fit_tune, so the tuning
   block cannot tell which fit consumed it (fit_tune's fit consumed nothing).
   The early_stopping_rounds value is an int from the default space's IntDim
   and reaches JSON as an int.
   This is a key set, not bytes: test_the_record_is_the_only_new_key checks the
   implemented exporter adds only the new key.

5. Positions the rule binds (grep applied_training_params over lizyml/: 10 lines)
   lizyml/core/model.py:160            initialised empty
   lizyml/core/model.py:333            set in fit's commit group
   lizyml/core/model.py:921            copied into FitState
   lizyml/core/_model_state.py:52      tuning_result docstring (points at it)
   lizyml/core/_model_state.py:53,76   FitState field + docstring
   lizyml/core/_model_tables.py:301,314  params_table reader
   lizyml/core/_model_persistence.py:211,269  export_code reader
   plus the two persistence functions that must carry it:
   lizyml/persistence/exporter.py export(), lizyml/core/_model_persistence.py
   ModelPersistenceMixin.export() / load().
