A record with a known answer: 120 days of morning weigh-ins, food logs and weekly waist readings, generated from a metabolism that is deliberately not the prior. Steps 3 to 8 all use this episode, rebuilt from the same seed, and score themselves against its hidden truth.
uv run python walkthrough/02_synthetic.py
The script
"""Step 2: synthetic data with a known truth.Generate 120 days of the record the subject would keep: a morning weigh-in onmost days, a food log on most days, and a tape-measure waist reading once aweek. The generator runs the same model as step 1, with parameters deliberatelyaway from the prior, and keeps the hidden states so later steps can be scored."""import jax.numpy as jnpimport numpy as npfrom wdm.config import Subjectfrom wdm.models import Paramsfrom wdm.synthetic import SyntheticSpec, make_episodesubj = Subject(sex="male", age_years=40, height_cm=175.0, body_fat_frac=0.30)c0, c1 = subj.expenditure_prior(100.0)prior = Params.prior(c0, c1)# The "true" metabolism: offset from the prior, so recovering it is a real test.true = Params( rmr_intercept=jnp.asarray(c0 *1.18), rmr_slope=jnp.asarray(c1 *0.90), at_gain=jnp.asarray(0.22), at_tau=jnp.asarray(11.0), gly_gain=jnp.asarray(0.0042), gly_tau=jnp.asarray(2.0), na_gain=jnp.asarray(2.5e-4), na_tau=jnp.asarray(1.0), partition_kappa=jnp.asarray(0.06),)carb_ref, sodium_ref = subj.habitual_intake()spec = SyntheticSpec( n_days=120, start_weight_kg=100.0, body_fat_frac=0.30, intake_fraction=0.80, # eat 80 % of true initial maintenance, +-250 kcal/day obs_sd_kg=0.45, weigh_in_rate=0.85, logging_rate=0.90, waist_every_days=7, # waist = 75 cm + 1.2 cm/kg x fat, read to +-1.5 cm habitual_carb_g=carb_ref, habitual_sodium_mg=sodium_ref,)ep, truth = make_episode(spec, true, name="current", start_date="2026-06-01", seed=7)print(ep.summary())print(f"reference intake: {ep.carb_ref_g:.0f} g carb, {ep.sodium_ref_mg:.0f} mg sodium (habitual)")print()print("true parameters vs the prior center")for name in ("rmr_intercept", "rmr_slope", "at_gain", "at_tau", "gly_gain", "partition_kappa"): t, p =float(getattr(true, name)), float(getattr(prior, name))print(f" {name:16s}{t:10.4g} prior {p:10.4g} ({t / p -1:+.0%})")print()print("first ten days ('-' = not recorded)")print(" date reading truth kcal pro carb fat sodium waist")for k inrange(10): w =f"{ep.weight_kg[k]:7.2f}"if ep.weight_observed[k] else" -" food = ( (f"{ep.energy_kcal[k]:5.0f}{ep.protein_g[k]:5.0f}{ep.carb_g[k]:5.0f}"f" {ep.fat_g[k]:4.0f}{ep.sodium_mg[k]:7.0f}" )if ep.intake_known[k]else" - - - - -" ) waist =f"{ep.waist_cm[k]:6.1f}"if np.isfinite(ep.waist_cm[k]) else" -"print(f" {ep.dates[k]}{w}{truth['scale_kg'][k]:6.2f}{food}{waist}")print()resid = ep.weight_kg[ep.weight_observed] - truth["scale_kg"][ep.weight_observed]logged = ep.intake_knownprint(f"weigh-ins {ep.n_observations}/{ep.n_days}, food logs {logged.sum()}/{ep.n_days},"f" waist readings {ep.n_waist}")print(f"reading minus truth: mean {resid.mean():+.3f} kg, sd {resid.std(ddof=1):.3f} kg"f" (set to {spec.obs_sd_kg})")print(f"logged intake: mean {np.nanmean(ep.energy_kcal):.0f} kcal/day,"f" carb {ep.carb_g[logged].mean():.0f} g/day")print()print("hidden truth, day 0 -> day 119")for key in ("scale_kg", "tissue_kg", "fat_kg", "gly_water_kg", "na_water_kg", "adapt_kcal"): a, b = truth[key][0], truth[key][-1]print(f" {key:13s}{a:8.2f} -> {b:8.2f} ({b - a:+.2f})")
Missing days. 107 of 120 mornings have a weigh-in (89.2 %) and 106 days have a food log (88.3 %), against set rates of 85 % and 90 %. Each is drawn independently every day, so 2026-06-10 has a weigh-in but no food log, and 2026-06-01 has neither (only the tape). The episode keeps every calendar day; a missing value is a missing observation, not a missing row.
The truth is off the prior. The maintenance intercept is 18 % above the prior and the mass slope 10 % below; adaptation is 47 % stronger and 21 % faster; glycogen holds 40 % more water per gram. The true sodium gain, 2.5·10⁻⁴ kg per mg/day, is 25 % above its prior (not in the printed table; step 4 scores it). These are the same values wdm demo and the test suite use. If the truth sat at the prior, an estimator that ignored the data would look perfect.
Readings scatter around the truth. On 2026-06-05 the scale says 99.64 kg while the truth is 98.78 kg, a +0.86 kg reading (1.9 standard deviations at 0.45). Across all 107 readings the error has mean −0.091 kg and standard deviation 0.410 kg, a sample from the 0.45 kg that was set.
The food log. Protein is 180 g every day (1.8 g/kg of the starting 100 kg), fat is 30 % of energy, and carbohydrate takes the rest plus a weekly ±60 g cycle: 278 g on 2026-06-02, 116 g on 2026-06-06, back up to 277 g on 2026-06-08. The cycle exercises the glycogen compartment. Logged energy averages 2233 kcal/day; the target was 80 % of the true day-0 maintenance, 0.80 × (1538 + 13.05 × 100) = 2274 kcal/day, with ±250 kcal of day-to-day scatter.
Sodium moves the truth from day to day. Each day’s sodium is drawn around the generator’s default 3000 mg with an 800 mg spread, and at the true gain one day’s sodium pulls the sodium water toward 2.5·10⁻⁴ × (sodium − 4000). The 1402 mg on 2026-06-04 pulls it toward 2.5·10⁻⁴ × (1402 − 4000) = −0.65 kg, and with a 1-day time constant 63 % of that pull lands by the next morning; the truth on 2026-06-05 is 0.84 kg below the day before (the day’s tissue loss and glycogen jitter are in that number too). Step 3 shows what this scatter does to the scale’s apparent noise.
The habitual reference. Carbohydrate averages 211 g/day against the habitual 260 g, and sodium is drawn around 3000 mg against a habitual 4000 mg, so both water compartments sit below zero on average. On day 119 glycogen water is −0.73 kg and sodium water −0.24 kg, close to 2.5·10⁻⁴ × (3000 − 4000) = −0.25 kg. Both are one day’s values: glycogen water includes the generator’s 0.15 kg/day of random jitter, sodium water that day’s sodium draw.
The tape. 18 readings, on days 0, 7, …, 119. The day-0 reading of 111.0 cm matches the waist model, 75 + 1.2 × 30.00 = 111.0 cm, plus a 1.5 cm reading error.
The hidden truth. Over 120 days the scale falls 11.78 kg, tissue 10.80 kg and fat 7.57 kg (70 % of the tissue). Adaptation ends at −161 kcal/day. These are the numbers steps 4 and 5 try to recover.
Lower logging_rate to 0.5 and rerun steps 2 and 4. On unlogged days the filter assumes intake equals expenditure and widens its uncertainty (Unlogged days); watch the maintenance interval widen.
Under-log by 10 %: in step 4, fit dataclasses.replace(ep, energy_kcal=0.9 * ep.energy_kcal) and see how far rmr_intercept and the maintenance estimate move. The fitted maintenance describes the log, not the body.
Change seed=7 to another value. Every later step rebuilds this episode, so all their numbers change; the conclusions should not.