Methods

Every number we show you, with the formula, the table and the pool it came from.

The short version

  • We never compute a rating. No Elo, no MMR, no composite life score, and no norms derived from our own users — a ranking against a pool we happen to have signed up is not a fact about you.
  • Where we place you at all, it is a lookup in a table somebody else published, dated on the page you read it on.
  • The pool is always named next to the number. “62nd percentile” on its own is not a claim; “62nd percentile of people who compete in raw powerlifting” is.
  • Most categories get no standing at all, and say why. That list is below too.
  • The daily verdict names how yesterday departed from your own four-week norms, in your own units. No score, no weighting, and the rule that decides what gets a mention is published in full below.

The day's verdict — departures in your own swing

Every day the app names how yesterday departed from your own norms, in your own units, with the arithmetic printed beside each line. There is no daily score, no weighting, and no number that combines metrics — the rule below is the entire decision, and it is the same rule the app prints on its own “how this works” disclosure. Changing any constant on this page is an amendment to the decision record behind it, not a tuning pass.

The same 40 minutes of missing sleep is news for a metronome sleeper and weather for a shift worker. That is not a rough edge in the rule; it is the whole point of a scale built out of your own days, and it is the answer a product with one fixed threshold — or one proprietary weighting — cannot give you.

The norm

Your norm for a metric is the mean of the trailing 28 days ending the day before the day being judged — four weeks, the same window the weekly letter uses, so two surfaces of one product cannot disagree about what normal means.

  • Observed days only, never interpolated. A day with no reading is dropped from the mean, not filled in and not counted as a zero. A day your phone recorded no steps at all is a real zero and does count; a day it recorded nothing because it was off is unknown, and unknown never becomes a number.
  • At least 8 observed days in those 28, or there is no norm and the metric is not checked. The surface says so and names the floor rather than going quiet.
  • A rhythm gate of 8 active days for the count-shaped metrics — the ones that accrue events over a day. Something that happens twice a month has no weekly rhythm and therefore cannot have a daily one.

The scale — your own swing

The bar cannot be a percentage: 5% is nothing for steps and enormous for a resting heart rate. So the scale is your own typical day-to-day distance from your own norm, over those same observed days:

swing = mean( |value on day i − norm| )

That is the mean absolute deviation about the mean, and two properties earned it the job. Both are worth stating, because one of them is a cost.

  • It does not collapse on ties. The median absolute deviation — the usual robust choice — is exactly zero whenever more than half the window ties, which every integer count and every 1–5 rating arranges routinely; the interquartile range is zero on the same shape. A zero scale would make every departure infinite. This one is zero only when every observed day equals your norm exactly, and a metric with genuinely no daily scale is one we then decline to speak about.
  • It is inflated by one wild day, on purpose. A single extreme day raises your swing, and a raised swing raises the bar for a fortnight. That makes the surface quieter after unusual days rather than louder — the opposite of what a standard deviation would do. For a surface whose failure mode is chattiness, an estimator that errs quiet is the one you want, and we would rather say that out loud than have you find it.

Both numbers — the norm and the swing — come from the 28 days before the day being judged, so that day is genuinely out of sample.

The bar, and the cap of 3

A metric earns a mention when the day lands at least 2.0× your swing away from your norm, it has a norm, the day has a known reading, and the difference is still non-zero once rounded to the precision it prints at. Absence is never a deviation.

At most 3 mentions print. They are ordered by how many swings out they are — a number that is never displayed, because a displayed ranking number across metrics is the score this product refuses to ship. Metrics that measure one construct (a resting heart rate from two devices, sleep summed from two apps) collapse to a single line before the cut; two instruments that disagree about the direction suppress each other entirely, because that is a data-quality fact and not a deviation.

The multiple is editorial, not inferential, and we will say where it came from: under the usual bell-curve assumption this swing runs about 0.8 of a standard deviation, so the bar sits near 1.6 of one — roughly a one-in-nine chance per metric per day, which leaves a typical reader with zero or one line most days. Lower, and something is “off” nearly every day, which is a score with extra steps. Higher, and the surface speaks once a week and stops being worth opening. It is revisited on a published cadence, and revising it means amending the record, not editing a config.

A quiet day is an answer and prints as one, with its denominator: “nothing unusual — 7 metrics were within your 4-week norms”. A surface that says nothing unusual while silently checking two metrics is lying, and printing the count is what makes the sentence checkable.

The day being judged

One complete day, on your own calendar — the timezone your account declares. The day in progress is never judged, for any metric: a partial day would report “steps 6,000 below your norm” at nine every morning. So the newest day the verdict can speak about is the one that has just ended, and the block prints which day it means, every time it renders.

Sleep is filed under the day you woke up, which is the convention the rest of the app uses. A night therefore reaches the verdict once that waking day is over — a newspaper is today’s paper about yesterday.

What is not here: no p-value, no correction

Elsewhere on this platform, findings that make a statistical claim carry a q-value and a multiple-comparison correction. These sentences carry neither, and that is a decision rather than an oversight. “You slept 6h05, 40 minutes under your 4-week norm of 6h45” is a description of four stored numbers: it is true whatever is or is not going on underneath, there is no hypothesis in it, and so there is nothing to reject and no p to correct.

What multiplicity buys instead is the editorial discipline above — a fixed bar, a hard cap, a deterministic order — so that adding metrics to the roster can never add claims to a day. The moment any copy here calls a day abnormal rather than saying what it was, that is a hypothesis and it needs the full apparatus before it ships.

What is checked — 9 things

The roster is curated and short, with the reason attached to each member. A metric being available is not a reason to check it daily.

  • Sleep /health/sleep/asleep_minutes The line the whole surface is for. Additive across sources, so it enters split by source and collapses to one line before it prints.
  • Resting heart rate /health/cardio/resting_heart_rate · /health/cardio/nightly_resting_heart_rate Two names for one construct, joined in the manifests and printed once. An elevated morning resting heart rate is the one daily number with a real-world action behind it.
  • Heart-rate variability /health/cardio/hrv_rmssd The input the incumbents bury inside a recovery score. Shown raw, against your own swing.
  • Respiratory rate /health/cardio/respiratory_rate A nightly level whose day-scale departure is a classic early illness signal, and one that means nothing without a personal baseline.
  • Steps /health/activity/steps A complete-day total with a strong personal rhythm; your swing absorbs the weekday shape a fixed goal cannot.
  • Exercise minutes /health/activity/exercise_minutes A covered day with no training is a real zero, so a training gap is a value like any other. It clears the bar only when your norm is at least twice your swing — news for someone who trains almost every day, and not for someone whose week already contains rest days.
  • Screen time /screen/time/total The drift you suspect and cannot see, and a complete-day total.
  • Mood /journal/mood/level A 1–5 rating, which is exactly the tie-heavy shape that a median-based scale collapses on — see the swing below. A day well off your own mood norm is the single most useful thing this surface can name.
  • Mean glucose /health/glucose/mean For a CGM reader, a complete-day level whose daily departure is directly actionable.

What is deliberately not checked

This list is as much the design as the roster is, and the first entry is permanent.

  • Every vendor composite. Recovery score, readiness, sleep score, day strain. Permanent, not a v1 deferral: naming a black box's deviation amplifies the black box, and its inputs are on the roster above already. We still display a vendor's own score where they publish one — rendering a number its publisher stands behind is a different act from minting one.
  • Weight, VO₂max and body fat. Clock speed. A day's departure in weight is water, and the quarter's drift is the real story; the other two have no daily value to depart from.
  • Bedtime. Clock arithmetic is circular — the mean of 23:50 and 00:10 is not noon — and the regularity ruler already owns consistency.
  • Skin temperature deviation. Already a deviation from a vendor's baseline. Double-baselining is not a second opinion.
  • Blood oxygen. Quantized at 98–99%, so the swing would be a rounding artefact rather than your own variability.
  • Fitness load — CTL, ATL and form. Trailing-window constructions whose day-to-day movement is arithmetic on their own window, not news.
  • Money, ratings and ladders. Lumpy and rent-shaped, or a standing — and a standing is the rest of this page's business, not a day's.
  • Anything yes/no. A yes/no day is 0 or 1, so “deviation from the rate” fires exactly when you did the rarer thing, every time you do it — a tautology dressed as a finding. Habits ask a better question of the same fact.
  • Lab results, ranks, and per-thing metrics. Refused structurally, whatever a manifest asks for: a few blood draws a year has no daily anything, a ladder rank is somebody else's number, and a metric keyed per game or per app has no single daily value to be a deviation of.

What has to be true before we place you

A category shows a named standing only when the ruler is external, absolute and instrument-compatible: published by someone else and citable, statable in your own units, and derived from measurements like the ones we hold. Where any of those fails, the surface prints the refusal and its reason where the rank would have been.

That is a deliberately hard bar, and most things do not clear it. Sleep duration has a U-shaped mortality curve, so a high percentile would be a warning dressed as a win. Books per year is self-reported and Goodhart-corrupted, and ranking it optimises the wrong life. Resting heart rate is not monotone-good — beta blockers and illness both move it the “right” way. HRV, steps and screen time fail on the same grounds. None of those get a standing, and none of them will get one because somebody asked nicely.

Strength — per-lift tiers

What we show. A tier per barbell lift — squat, bench press, deadlift — from your best estimated one-rep max over the last 90 days, read against lifters of the same published column and a similar bodyweight.

The formula. Your one-rep max is an Epley estimate weight × (1 + reps ÷ 30)— taken over your non-warmup sets of 1–12 reps, with a true single counting as its own weight. Your bodyweight is the most recent one we hold, and if you have no scale connected, the one you typed into your profile. A measured weight always wins over a typed one.

The pool, and its skew. people who compete in raw powerlifting: everyone in this table entered a raw powerlifting meet, which is a self-selected group who train these three lifts on purpose. It is not the general population. A low percentile here is a statement about that pool, not about you.

The gap we cannot close. Your number is an estimate off a working set. Theirs is a third attempt on a platform with three judges. Expect the estimate to read a little high against them, and more so the further your sets are from a single. We say this on the sheet as well as here.

The table

Reduced from the OpenPowerlifting bulk archive, snapshot . Public domain dedication; attribution requested, not required. The archive we used hashes to 339a08c5217e0a11.

This ruler uses data from the OpenPowerlifting project, https://www.openpowerlifting.org. You may download a copy of the data at https://gitlab.com/openpowerlifting/opl-data.

4,001,901 rows in, 1,814,639 kept after the filters, 580,708 distinct lifters after deduplication. Download the same archive and run the script below to get these numbers back.

The cut points, and whose they are

The tier names are ours. The percentiles they sit at are 5, 20, 50, 80 and 95, read off the table above. So “Advanced” means “at or above the 80th percentile of people who compete in raw powerliftingat your bodyweight” and nothing else — it is not a certification and no federation issues it.

Everything below the 5th percentile of that pool is the first band, Untrained. Against people who compete, that is where most people who lift start.

Squat — female tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
50 kg52.5709011013022,284
55 kg57.57595115137.535,218
60 kg61.2380102.06122.514542,177
65 kg6585107.5130152.541,414
70 kg7090112.5137.516039,013
80 kg7092.99117.514517019,211
90 kg7095120147.451758,924
Squat — male tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
60 kg77.5105137.5170197.522,714
70 kg100130162.519222053,237
80 kg11515018021024062,062
90 kg127.5165195227.526062,347
100 kg140175210242.5277.546,103
110 kg140180217.5252.529022,648
120 kg145190230272.531016,150
Bench press — female tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
50 kg30405062.57528,455
55 kg32.542.552.5658044,074
60 kg354555708550,527
65 kg37.547.56072.59048,145
70 kg38.565062.5779545,147
80 kg405063.258010022,276
90 kg4050658010010,378
Bench press — male tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
60 kg527090112.513532,624
70 kg67.59011013015075,520
80 kg80100122.5142.8816585,623
90 kg87.5110132.515518084,535
100 kg95120145170195.564,232
110 kg95125154.2182.521034,312
120 kg100130162.5192.522023,377
Deadlift — female tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
50 kg7090110132.515525,525
55 kg7595117.514016040,347
60 kg81.65102.21122.514517047,413
65 kg86.18107.5130152.517545,627
70 kg90112.5135157.5182.543,161
80 kg92.5115140165192.521,159
90 kg92.99117.5140165192.59,935
Deadlift — male tables, kilograms
BodyweightBeginnerNoviceIntermediateAdvancedEliten
60 kg100132.5167.520023025,682
70 kg127.01160192.5225252.561,684
80 kg145180210240272.570,991
90 kg157.5192.5226.8257.5287.571,171
100 kg165204.12237.5272.530553,214
110 kg165.56206.38242.528031527,192
120 kg172.5212.525029032518,927

The reduction, in full

This is the script that turned the archive into the tables above, verbatim. It is here because a ruler nobody can re-derive is a black box with a citation stapled to it.

#!/usr/bin/env python3
"""Reduce the OpenPowerlifting bulk CSV to a frozen strength ruler (ADR 0134).

This is a ONE-OFF, OFFLINE reduction. Nothing in the running platform calls
it; it produces a versioned JSON table that is checked in, cited, and served
verbatim. It is published on /methods so anybody can re-run it against the
same input and get the same table — a ruler nobody can audit is a black box
wearing a citation.

Input
    The OpenPowerlifting bulk CSV distribution, dedicated to the public
    domain by the project:
        https://openpowerlifting.gitlab.io/opl-csv/files/openpowerlifting-latest.zip

Usage
    python3 scripts/rulers/reduce_openpowerlifting.py \
        --csv  openpowerlifting-YYYY-MM-DD/openpowerlifting-YYYY-MM-DD-HASH.csv \
        --zip  openpowerlifting-latest.zip \
        --snapshot YYYY-MM-DD \
        --out  go/platform/rulers/tables/strength_openpowerlifting.json

The pipeline, in the order the steps run
    1. RAW ONLY. Keep Equipment == "Raw" (bare knees or knee sleeves). Wraps
       and every equipped category add kilos no gym est-1RM has, so mixing
       them in would move the whole curve away from the instrument we
       measure people with.
    2. TWO PUBLISHED COLUMNS. Keep Sex in {M, F}. These are the columns
       OpenPowerlifting itself splits on, and the only ones the table can
       honestly offer. "Mx" is left out because its n is far too small to
       make a stable per-bodyweight curve, and a noisy curve presented as a
       ruler is worse than no ruler. An account whose reference_table is
       NONE or unset gets no placement at all, by design.
    3. DROP NON-RESULTS. Skip Place in {DQ, DD, G, NS} and any lift whose
       Best3xxxKg is missing or <= 0 — a failed or unattempted lift is
       recorded as zero or negative, not as a small number.
    4. PLAUSIBLE BODYWEIGHT. Require 35 <= BodyweightKg <= 200.
    5. DEDUP TO ONE ROW PER LIFTER PER LIFT. A lifter with 40 meets in the
       archive would otherwise be counted 40 times, tilting every percentile
       toward whoever competes most often. We keep each lifter's best-ever
       result for each lift, paired with the bodyweight they weighed in at
       for THAT result: a lifter is one person in this table, at their best.
       Lifters are keyed by the archive's own disambiguated Name (the
       project appends "#2" to a second John Doe), which is the only lifter
       identity the CSV carries.
    6. BIN BY BODYWEIGHT. For each sex and lift, take knots every
       KNOT_STEP kg and pool the lifters within +/- WINDOW_KG of the knot.
       A knot is emitted only when its window holds at least MIN_SAMPLES
       lifters; a thin tail is dropped rather than published as a curve.
    7. PERCENTILES. At each knot the sorted window is read at the percentile
       grid below by linear interpolation between order statistics (the
       "linear" / type-7 definition). Between knots a lookup interpolates
       linearly in bodyweight; outside the emitted knots it clamps to the
       nearest one, and the surface says that it did.
    8. DOTS. The DOTS total distribution is reduced the same way but WITHOUT
       bodyweight bins, because DOTS already normalises for bodyweight. Only
       full-power (SBD) results contribute.

What the output deliberately does NOT contain
    No names, no meets, no dates, no rows — only order statistics over pools
    of at least MIN_SAMPLES people. The reduction is one-way.
"""

import argparse
import csv
import hashlib
import json
import sys
from collections import defaultdict

# The percentile grid every emitted curve is sampled at. The five tier cut
# points (5/20/50/80/95) are members of it, so a tier boundary is read from
# the table rather than re-derived; the rest exist so a face can draw the
# distribution and invert it to place one lifter without ever shipping a row.
PERCENTILE_GRID = [1, 2.5, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 97.5, 99]

# The tier cut points (ADR 0134). The NAMES are ours; the percentiles they
# sit at are published on /methods so nobody has to trust the naming.
TIER_CUTS = [5, 20, 50, 80, 95]
TIER_NAMES = ["Untrained", "Beginner", "Novice", "Intermediate", "Advanced", "Elite"]

KNOT_STEP = 5.0  # kg between bodyweight knots
WINDOW_KG = 5.0  # +/- this much around a knot is pooled
MIN_SAMPLES = 500  # a knot below this is not published

LIFTS = {
    # our lift key -> the CSV column holding the best of three
    "squat": "Best3SquatKg",
    "bench": "Best3BenchKg",
    "deadlift": "Best3DeadliftKg",
}

BAD_PLACES = {"DQ", "DD", "G", "NS"}

# The DOTS polynomial, published by OpenPowerlifting and reproduced verbatim
# on /methods. DOTS = total_kg * 500 / poly(bodyweight_kg), with bodyweight
# clamped to the range the polynomial was fitted over.
DOTS_COEFFICIENTS = {
    "M": (-0.000001093, 0.0007391293, -0.1918759221, 24.0900756, -307.75076),
    "F": (-0.0000010706, 0.0005158568, -0.1126655495, 13.6175032, -57.96288),
}
DOTS_BW_CLAMP = {"M": (40.0, 210.0), "F": (40.0, 150.0)}


def quantile(sorted_values, pct):
    """The type-7 quantile: linear interpolation between order statistics.

    Matches numpy's default and R's type 7, spelled out here so the pipeline
    carries no dependency a reader would have to go and check.
    """
    if not sorted_values:
        return None
    if len(sorted_values) == 1:
        return sorted_values[0]
    pos = (len(sorted_values) - 1) * (pct / 100.0)
    low = int(pos)
    high = min(low + 1, len(sorted_values) - 1)
    frac = pos - low
    return sorted_values[low] + (sorted_values[high] - sorted_values[low]) * frac


def parse_float(text):
    if not text:
        return None
    try:
        return float(text)
    except ValueError:
        return None


def dots(sex, total_kg, bodyweight_kg):
    a, b, c, d, e = DOTS_COEFFICIENTS[sex]
    low, high = DOTS_BW_CLAMP[sex]
    bw = min(max(bodyweight_kg, low), high)
    denom = a * bw**4 + b * bw**3 + c * bw**2 + d * bw + e
    if denom <= 0:
        return None
    return total_kg * 500.0 / denom


def read_lifters(csv_path):
    """Steps 1-5: stream the CSV down to one record per lifter.

    Returns {(sex, name): {"squat": (kg, bodyweight), ..., "total": (kg, bw)}}
    """
    best = defaultdict(dict)
    kept_rows = 0
    total_rows = 0
    with open(csv_path, newline="", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            total_rows += 1
            if row["Equipment"] != "Raw":  # step 1
                continue
            sex = row["Sex"]
            if sex not in ("M", "F"):  # step 2
                continue
            if row["Place"] in BAD_PLACES:  # step 3
                continue
            bodyweight = parse_float(row["BodyweightKg"])
            if bodyweight is None or not (35.0 <= bodyweight <= 200.0):  # step 4
                continue
            entry = best[(sex, row["Name"])]
            kept_rows += 1
            for lift, column in LIFTS.items():  # step 5
                kilos = parse_float(row[column])
                if kilos is None or kilos <= 0:
                    continue
                if lift not in entry or kilos > entry[lift][0]:
                    entry[lift] = (kilos, bodyweight)
            # Step 8's input: a DOTS total needs all three lifts, one meet.
            if row["Event"] == "SBD":
                total = parse_float(row["TotalKg"])
                if total and total > 0:
                    if "total" not in entry or total > entry["total"][0]:
                        entry["total"] = (total, bodyweight)
    return best, total_rows, kept_rows


def reduce_lift(records, sex, lift):
    """Steps 6-7 for one sex and one lift: knots -> percentile curves."""
    by_bin = defaultdict(list)
    for (row_sex, _name), entry in records.items():
        if row_sex != sex or lift not in entry:
            continue
        kilos, bodyweight = entry[lift]
        by_bin[int(bodyweight)].append(kilos)

    knots = []
    knot = 35.0
    while knot <= 200.0:
        pooled = []
        for whole_kg in range(int(knot - WINDOW_KG), int(knot + WINDOW_KG) + 1):
            pooled.extend(by_bin.get(whole_kg, ()))
        if len(pooled) >= MIN_SAMPLES:
            pooled.sort()
            knots.append(
                {
                    "bodyweight_kg": knot,
                    "n": len(pooled),
                    "kg": [round(quantile(pooled, p), 2) for p in PERCENTILE_GRID],
                }
            )
        knot += KNOT_STEP
    return knots


def reduce_dots(records, sex):
    """Step 8: one bodyweight-free DOTS distribution per sex."""
    scores = []
    for (row_sex, _name), entry in records.items():
        if row_sex != sex or "total" not in entry:
            continue
        total, bodyweight = entry["total"]
        score = dots(sex, total, bodyweight)
        if score:
            scores.append(score)
    if len(scores) < MIN_SAMPLES:
        return None
    scores.sort()
    return {
        "n": len(scores),
        "dots": [round(quantile(scores, p), 2) for p in PERCENTILE_GRID],
    }


def sha256(path):
    digest = hashlib.sha256()
    with open(path, "rb") as handle:
        for chunk in iter(lambda: handle.read(1 << 20), b""):
            digest.update(chunk)
    return digest.hexdigest()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--csv", required=True, help="the extracted bulk CSV")
    parser.add_argument("--zip", required=True, help="the downloaded zip, hashed into the citation")
    parser.add_argument("--snapshot", required=True, help="the distribution date, YYYY-MM-DD")
    parser.add_argument("--out", required=True)
    args = parser.parse_args()

    records, total_rows, kept_rows = read_lifters(args.csv)
    print(f"rows: {total_rows}  kept: {kept_rows}  lifters: {len(records)}", file=sys.stderr)

    table = {
        "id": "strength_openpowerlifting_v1",
        "version": 1,
        "snapshot_date": args.snapshot,
        # How this ruler ages (ADR 0154). The archive is a feed, so the table
        # DRIFTS and the monitor can watch it with a clock.
        "staleness": {
            "policy": "DRIFTS",
            "max_age_days": 180,
            "rationale": (
                "OpenPowerlifting republishes the bulk archive continuously as meets are "
                "entered, so the same reduction re-run today returns different percentiles. "
                "The snapshot date is the clock: past six months the tiers are quoting a pool "
                "that has moved on, and the fix is mechanical — re-run the reduction and "
                "commit the new snapshot."
            ),
        },
        "source": {
            "name": "OpenPowerlifting",
            "url": "https://www.openpowerlifting.org",
            "data_url": "https://openpowerlifting.gitlab.io/opl-csv/files/openpowerlifting-latest.zip",
            "license": "Public domain dedication; attribution requested, not required.",
            "attribution": (
                "This ruler uses data from the OpenPowerlifting project, "
                "https://www.openpowerlifting.org. You may download a copy of "
                "the data at https://gitlab.com/openpowerlifting/opl-data."
            ),
            "archive_sha256": sha256(args.zip),
        },
        "pipeline": {
            "script": "scripts/rulers/reduce_openpowerlifting.py",
            "equipment": "Raw",
            "dedup": "one row per lifter per lift: their best-ever result, at that meet's bodyweight",
            "knot_step_kg": KNOT_STEP,
            "window_kg": WINDOW_KG,
            "min_samples": MIN_SAMPLES,
            "quantile": "type-7 (linear interpolation between order statistics)",
            "rows_read": total_rows,
            "rows_kept": kept_rows,
            "lifters": len(records),
        },
        "pool_label": "people who compete in raw powerlifting",
        "percentile_grid": PERCENTILE_GRID,
        "tier_cuts": TIER_CUTS,
        "tier_names": TIER_NAMES,
        "lifts": {},
        "dots_total": {},
    }

    for lift in LIFTS:
        table["lifts"][lift] = {}
        for sex in ("F", "M"):
            knots = reduce_lift(records, sex, lift)
            table["lifts"][lift]["FEMALE" if sex == "F" else "MALE"] = knots
            print(f"{lift} {sex}: {len(knots)} knots", file=sys.stderr)

    for sex in ("F", "M"):
        reduced = reduce_dots(records, sex)
        if reduced:
            table["dots_total"]["FEMALE" if sex == "F" else "MALE"] = reduced

    with open(args.out, "w", encoding="utf-8") as handle:
        json.dump(table, handle, indent=1, sort_keys=True)
        handle.write("\n")
    print(f"wrote {args.out}", file=sys.stderr)


if __name__ == "__main__":
    main()

Accessory strength — lift-specific rulers

The earlier audit stopped at three refusals. A second pass found a licensed, instrument-compatible route: FitnessVolt publishes versioned, CC BY 4.0 gym distributions for overhead press and pull-up, and a separately labelled Romanian-deadlift model. We keep those evidence types separate instead of making the model look like a measured population.

The common clock. Like squat, bench and deadlift, these rows use your best qualifying estimate from the last 90 days, read at weekly boundaries with the same 14-day hold and 60-day stale rule. They do not enter DOTS, and they do not borrow the OpenPowerlifting pool or citation.

Snapshot ; API version 1.0.0, data version 2026-06-09. The API’s bodyweight classes are discrete: we select the published class and never smooth across its boundary. Read the FitnessVolt methodology.

The four full API responses and the extracted, licensed RDL table facts are frozen in one dated source archive, SHA-256 dce09287788e45e23b990512a64245482d296ebdef64ca153222c3ffd8581383. Every lift citation names those exact checked-in bytes; the mutable API endpoints and article page are discovery sources, not archives.

Overhead press

Measured self-reported distribution. FitnessVolt republishes sex-by-bodyweight-class percentile tables from the Symmetric Strength self-reported gym population. The population is measured but self-reported, and is kept separate from verified competition results.

Standing barbell overhead-press one-repetition maximum in kilograms: the external barbell load, read against the lifter's sex-specific bodyweight class.

Self-reported gym lifts are not competition-judged: technique, range of motion, 1RM estimation, rounding, and selection into a strength-tracking site can all shift this population away from the lifter being compared.

Strength standards data by FitnessVolt, sourced from the Symmetric Strength self-reported gym dataset. CC BY 4.0; FitnessVolt requires a visible attribution link on every page or product displaying the data. Pool size: 405,810 samples; the API does not promise they are unique people.

Overhead press — female published percentile cuts, kilograms
Bodyweight class10th-24th percentile25th-49th percentile50th-74th percentile75th-89th percentile90th-94th percentile95th-98th percentile99th percentile or aboven
47kg11.915.92429.233.735.440.2451
52kg16.722.527.231.83639.742.41,832
57kg21.925.53033.93840.8453,951
63kg23.126.530.735.839.843.250.34,677
69kg2428.533.438.74547.657.64,538
76kg24.228.434.44045.449.263.32,898
84kg26.230.7374450.352.259.41,952
84+kg24.530.437.445.253.963.371.81,259
Overhead press — male published percentile cuts, kilograms
Bodyweight class10th-24th percentile25th-49th percentile50th-74th percentile75th-89th percentile90th-94th percentile95th-98th percentile99th percentile or aboven
59kg27.234.240.8485558.666.77,068
66kg34.740.847.655.462.466.775.627,809
74kg3946.253.360.96872.382.272,499
83kg43.150.758.767.475.579.890.7115,964
93kg46.35463.573.58287.599.896,809
105kg47.657.267.778.789.196110.142,492
120kg48.258.371.482.694.7103.1116.115,819
120+kg3852.969.487.3104.5111.9133.15,792

Romanian deadlift

Published model — no percentile. FitnessVolt labels these as modeled level tables, ratio-derived from base lifts anchored to OpenPowerlifting, and explicitly says they are modeled estimates rather than direct competition percentiles.

Five published one-repetition-maximum level thresholds by bodyweight. The source's whole-pound rows are retained verbatim and converted to kilograms with 1 lb = 0.45359237 kg; lookup uses the nearest published bodyweight row rather than inventing an interpolation.

This is a ratio-derived training model, not an observed distribution of Romanian-deadlift lifters. RDL range of motion is not federation-standardized, so logged lifts may not be the same movement; no percentile, sample size, or stronger-than share may be inferred.

FitnessVolt Romanian Deadlift Standards by Bodyweight; modeled level table derived from base lifts anchored to OpenPowerlifting. The page's Dataset metadata licenses these modeled rows under ODbL 1.0 (https://opendatacommons.org/licenses/odbl/1-0/). The re-keyed RDL database component is offered under the same terms; see go/platform/rulers/tables/LICENSE-DATA.md. No measured RDL pool or sample size is claimed.

Romanian deadlift — female modeled level thresholds, kilograms
Bodyweight classBeginnerNoviceIntermediateAdvancedEliten
90 lb20.8734.4752.6274.3998.88
100 lb22.6837.1955.7978.02103.42
110 lb24.4939.4658.5181.65107.05
120 lb26.3141.2861.2384.82110.68
130 lb28.1243.5463.587.54113.85
140 lb29.4845.3666.2290.26117.03
150 lb30.8447.1768.4992.99120.2
160 lb32.2148.9970.3195.25122.92
170 lb33.5750.872.5797.98125.65
180 lb34.9352.6274.39100.24128.37
190 lb36.2953.9876.2102.06130.63
200 lb37.6555.3478.02104.33132.9
210 lb39.0157.1579.83106.14135.17
220 lb39.9258.5181.19107.95137.44
230 lb41.2859.8783.01110.22139.25
240 lb42.1861.2384.37111.58141.52
250 lb43.0962.1486.18113.4143.34
260 lb44.4563.587.54115.21145.15
Romanian deadlift — male modeled level thresholds, kilograms
Bodyweight classBeginnerNoviceIntermediateAdvancedEliten
110 lb28.1247.1772.57102.97137.44
120 lb33.1153.9880.74112.94148.78
130 lb38.5660.3388.45122.02159.21
140 lb43.5466.6896.16131.09169.19
150 lb48.5372.57103.42139.71179.17
160 lb53.5278.47110.68147.87188.24
170 lb58.0684.37117.48155.58197.31
180 lb63.0590.26124.28163.29205.93
190 lb67.5995.71130.63171214.1
200 lb72.12101.15136.98178.26222.26
210 lb76.66106.59142.88185.07229.97
220 lb80.74111.58149.23191.87237.68
230 lb85.28116.57154.67198.67244.94
240 lb89.36121.56160.57205.02252.2
250 lb93.89126.55166.01211.37259.45
260 lb97.98131.09171.46217.27265.81
270 lb101.6136.08176.9223.17272.61
280 lb105.69140.61181.89229.06278.96
290 lb109.77144.7187.33234.96285.31
300 lb113.4149.23192.32240.4291.66
310 lb117.03153.77196.86245.85297.56

Weighted pull-up

Measured self-reported distribution. FitnessVolt republishes sex-by-bodyweight-class percentile tables from the Symmetric Strength self-reported gym population. The population is measured but self-reported, and is kept separate from verified competition results.

Pull-up one-repetition maximum in kilograms of TOTAL SYSTEM WEIGHT (bodyweight plus external load), read against the lifter's sex-specific bodyweight class. An added-weight-only estimate is not this measure.

FitnessVolt’s source axis is total system weight. We keep both the estimate and the ruler on that axis throughout replay. Every externally loaded non-warmup Hevy pull-up set of 1–10 reps is considered; a true single is its observed system load. For 2–10 reps the exact rule is:

system e1RM = 100 × (bodyweight + added load) ÷ (48.8 + 53.8 × e^(−0.075 × reps))

That is the Wathan estimator used by the underlying Symmetric Strength methodology. We do not pick Hevy’s Epley-winning set, estimate the added plates alone, or mix a set-day value with a later scale reading. When no measured set-day weight exists, the current profile fallback is used; editing it intentionally recomputes those fallback-based estimates.

Self-reported pull-ups are not competition-judged and may differ in grip, range of motion, assistance, kipping, and 1RM estimation. The source requires total system weight, so an external-load-only metric is instrument-incompatible.

Strength standards data by FitnessVolt, sourced from the Symmetric Strength self-reported gym dataset. CC BY 4.0; FitnessVolt requires a visible attribution link on every page or product displaying the data. Pool size: 202,152 samples; the API does not promise they are unique people.

Weighted pull-up — female published percentile cuts, kilograms
Bodyweight class10th-24th percentile25th-49th percentile50th-74th percentile75th-89th percentile90th-94th percentile95th-98th percentile99th percentile or aboven
47kg39.545.95155.762.169.373.5219
52kg3953.258.160.764.867.275.3943
57kg48.857.862.368.471.874.182.22,113
63kg45.659.965.37278.582.690.72,323
69kg48.861.970.376.181.585.3922,254
76kg51.169.176.78492.595.2100.4903
84kg4765.580.886.392.295.398603
84+kg3650.372.392.7103.2103.2145.8211
Weighted pull-up — male published percentile cuts, kilograms
Bodyweight class10th-24th percentile25th-49th percentile50th-74th percentile75th-89th percentile90th-94th percentile95th-98th percentile99th percentile or aboven
59kg61.46874.78599.7105.4121.44,203
66kg74.179.885.995.3104.5109.5119.115,501
74kg8288.896104.9113.7119.713539,138
83kg9097.5105114.6124.2131.7145.160,622
93kg96.8105.4114.1124.1135.3141.7157.347,757
105kg98.7112.3123.7134.4145.5154.2174.518,425
120kg90.7117.8129.8142.5153.7161.4174.45,403
120+kg61.174.2125.6149.8166.6173.4214.31,534

The vendor pipeline, in full

This is the script that fetches the four API tables, preserves only the RDL page’s licensed whole-pound facts, verifies the evidence labels, and writes both the canonical dated source archive and the smaller serving artifact.

#!/usr/bin/env python3
"""Vendor FitnessVolt accessory-lift standards.

This table deliberately keeps two different kinds of evidence separate:

* overhead press and pull-up are measured percentile tables from the
  Symmetric Strength self-reported gym population, republished by the
  FitnessVolt Strength Standards API under CC BY 4.0;
* Romanian deadlift is FitnessVolt's published MODELLED level table.  Its
  page says the figures are ratio-derived from base lifts anchored to
  OpenPowerlifting and are not direct competition percentiles.  We preserve
  that statement in the artifact and never attach a percentile grid to RDL.

The API's bodyweight rows are competition-style classes, not points on a
continuous curve.  In particular, ``120kg`` and ``120+kg`` (``84kg`` and
``84+kg`` for women) are distinct cohorts.  The output therefore retains
the ordinary rows as inclusive upper bounds and the final plus row as an
open-ended class.  It never interpolates between cohorts.

Each run writes two artifacts:

* one dated, canonical source archive containing the four complete API JSON
  responses plus only the licensed RDL facts extracted from the HTML page;
* the smaller serving table derived from that archive.

The archive is the byte sequence named by every ``archive_sha256`` and
``data_url`` in the serving table.  We do not hash mutable endpoints or retain
FitnessVolt's full HTML page and then point readers somewhere else.

Run from anywhere in the repository:

    python3 scripts/rulers/vendor_fitnessvolt_strength.py

The fetch is intentionally strict.  A changed API version, percentile grid,
source population, HTML table shape, or RDL evidence statement stops the
script instead of silently changing what the ruler means.
"""

from __future__ import annotations

import argparse
import datetime
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import html
from html.parser import HTMLParser
import json
import pathlib
import re
import sys
import urllib.request


REPO = pathlib.Path(__file__).resolve().parents[2]
DEFAULT_OUT = (
    REPO
    / "go"
    / "platform"
    / "rulers"
    / "tables"
    / "strength_fitnessvolt.json"
)
SOURCE_ARCHIVE_DIR = DEFAULT_OUT.parent / "sources"
SOURCE_ARCHIVE_ID = "fitnessvolt_strength_source_archive_v1"
SOURCE_ARCHIVE_REPOSITORY_URL = (
    "https://raw.githubusercontent.com/JuicyPasta/QuantifiedLife/main/"
    "go/platform/rulers/tables/sources/{filename}"
)

API_BASE = "https://fitnessvolt.com/wp-json/fvss/v1"
OPEN_DATA_URL = "https://fitnessvolt.com/strength-standards/strength-data/"
METHODOLOGY_URL = "https://fitnessvolt.com/strength-standards/methodology/"
RDL_URL = "https://fitnessvolt.com/strength-standards/romanian-deadlift/"
RDL_LICENSE_URL = "https://opendatacommons.org/licenses/odbl/1-0/"
CC_BY_LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/"
EXPECTED_API_VERSION = "1.0.0"
EXPECTED_DATA_VERSION = "2026-06-09"
PERCENTILES = [10, 25, 50, 75, 90, 95, 99]
PERCENTILE_KEYS = [f"p{p}" for p in PERCENTILES]
KG_PER_LB_DECIMAL = Decimal("0.45359237")

USER_AGENT = (
    "QuantifiedLife ruler snapshot "
    "(https://quantifiedlife.io/methods; one manual fetch per refresh)"
)

OBSERVED_LIFTS = {
    "overhead_press": {
        "api_slug": "overhead_press",
        "display_name": "Overhead press",
        "formula_id": "strength_fitnessvolt_overhead_press_gym_v1",
        "pool_label": "Symmetric Strength self-reported overhead-press samples",
        "value_basis": (
            "Standing barbell overhead-press one-repetition maximum in kilograms: "
            "the external barbell load, read against the lifter's sex-specific "
            "bodyweight class."
        ),
        "caveat": (
            "Self-reported gym lifts are not competition-judged: technique, range of "
            "motion, 1RM estimation, rounding, and selection into a strength-tracking "
            "site can all shift this population away from the lifter being compared."
        ),
    },
    "weighted_pullup": {
        "api_slug": "pullup",
        "display_name": "Weighted pull-up",
        "formula_id": "strength_fitnessvolt_pullup_system_weight_gym_v1",
        "pool_label": "Symmetric Strength self-reported pull-up samples",
        "value_basis": (
            "Pull-up one-repetition maximum in kilograms of TOTAL SYSTEM WEIGHT "
            "(bodyweight plus external load), read against the lifter's sex-specific "
            "bodyweight class.  An added-weight-only estimate is not this measure."
        ),
        "caveat": (
            "Self-reported pull-ups are not competition-judged and may differ in grip, "
            "range of motion, assistance, kipping, and 1RM estimation.  The source "
            "requires total system weight, so an external-load-only metric is "
            "instrument-incompatible."
        ),
    },
}

OBSERVED_BAND_NAMES = [
    "Below published 10th percentile",
    "10th-24th percentile",
    "25th-49th percentile",
    "50th-74th percentile",
    "75th-89th percentile",
    "90th-94th percentile",
    "95th-98th percentile",
    "99th percentile or above",
]

RDL_LEVEL_NAMES = ["Beginner", "Novice", "Intermediate", "Advanced", "Elite"]
RDL_BAND_NAMES = ["Below Beginner", *RDL_LEVEL_NAMES]


def fetch(url: str) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=45) as response:
        if response.status != 200:
            raise SystemExit(f"{url}: HTTP {response.status}")
        return response.read()


def canonical_json(value: object) -> bytes:
    """The single byte representation hashed, checked in, and regenerated."""
    return (
        json.dumps(value, indent=1, sort_keys=True, ensure_ascii=True) + "\n"
    ).encode("utf-8")


def archive_filename(snapshot_date: str) -> str:
    return f"fitnessvolt_strength_{snapshot_date}.json"


def archive_url(snapshot_date: str) -> str:
    return SOURCE_ARCHIVE_REPOSITORY_URL.format(
        filename=archive_filename(snapshot_date)
    )


def archive_source(archive_hash: str, data_url: str) -> dict[str, str]:
    """Fields shared by the collective table and every per-lift source."""
    return {"archive_sha256": archive_hash, "data_url": data_url}


def write_immutable_archive(path: pathlib.Path, body: bytes) -> None:
    """Create a dated archive, or prove that an existing one is identical."""
    if path.exists() and path.read_bytes() != body:
        raise SystemExit(
            f"{path}: refusing to replace a dated source archive with different "
            "bytes; choose a new --snapshot-date (or inspect with --archive-out)"
        )
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(body)


def clean_api_kg(value: object) -> float:
    """Collapse PHP's binary-float JSON noise to the published 0.1 kg."""
    if not isinstance(value, (int, float)):
        raise SystemExit(f"API threshold {value!r} is not numeric")
    rounded = float(
        Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
    )
    if rounded <= 0:
        raise SystemExit(f"API threshold {value!r} is not positive")
    return rounded


def api_url(slug: str, sex: str) -> str:
    return (
        f"{API_BASE}/standards/{slug}?format=table&sex={sex}"
        "&source=gym&unit=kg"
    )


def parse_bodyweight_class(label: str) -> tuple[float, bool]:
    match = re.fullmatch(r"(\d+)(\+)?kg", label)
    if match is None:
        raise SystemExit(f"unknown FitnessVolt bodyweight class {label!r}")
    return float(match.group(1)), match.group(2) is not None


def fetch_observed_lift(key: str, spec: dict[str, str]) -> tuple[dict, list[dict]]:
    tables: dict[str, list[dict]] = {}
    table_sizes: dict[str, int] = {}
    archived_payloads: list[dict] = []
    request_urls: list[str] = []
    versions: set[str] = set()
    data_versions: set[str] = set()
    attribution: dict | None = None

    for sex in ("female", "male"):
        url = api_url(spec["api_slug"], sex)
        raw = fetch(url)
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise SystemExit(f"{url}: invalid JSON: {exc}") from exc

        if payload.get("success") is not True:
            raise SystemExit(f"{url}: API did not report success")
        if payload.get("lift") != spec["api_slug"]:
            raise SystemExit(f"{url}: lift = {payload.get('lift')!r}")
        if payload.get("sex") != sex or payload.get("unit") != "kg":
            raise SystemExit(f"{url}: wrong sex or unit")
        if payload.get("source") != "self_reported_gym":
            raise SystemExit(f"{url}: source = {payload.get('source')!r}")
        if payload.get("format") != "table":
            raise SystemExit(f"{url}: format = {payload.get('format')!r}")
        request_urls.append(url)
        archived_payloads.append(
            {
                "lift_key": key,
                "request_url": url,
                "response": payload,
                "sex": sex,
            }
        )

        versions.add(payload.get("api_version", ""))
        data_versions.add(payload.get("data_version", ""))
        if attribution is None:
            attribution = payload.get("attribution")
        elif payload.get("attribution") != attribution:
            raise SystemExit(f"{url}: attribution changed between responses")

        total = payload.get("total_sample_size")
        if not isinstance(total, int) or total <= 0:
            raise SystemExit(f"{url}: invalid total_sample_size {total!r}")

        rows = payload.get("weight_classes")
        if not isinstance(rows, list) or len(rows) < 2:
            raise SystemExit(f"{url}: no bodyweight classes")
        all_rows = [row for row in rows if row.get("weight_class") == "all"]
        if len(all_rows) != 1 or all_rows[0].get("sample_size") != total:
            raise SystemExit(f"{url}: missing or inconsistent all-bodyweights row")

        cohorts: list[dict] = []
        previous_upper = 0.0
        for row in rows:
            label = row.get("weight_class")
            if label == "all":
                continue
            if not isinstance(label, str):
                raise SystemExit(f"{url}: class with no label")
            boundary, plus = parse_bodyweight_class(label)
            if (plus and boundary != previous_upper) or (
                not plus and boundary <= previous_upper
            ):
                raise SystemExit(
                    f"{url}: bodyweight classes are not ascending at {label}"
                )
            n = row.get("sample_size")
            if not isinstance(n, int) or n < 30:
                raise SystemExit(f"{url}: {label} sample_size = {n!r}")
            raw_percentiles = row.get("percentiles")
            if not isinstance(raw_percentiles, dict) or list(raw_percentiles) != PERCENTILE_KEYS:
                raise SystemExit(
                    f"{url}: {label} percentile grid = {list(raw_percentiles or {})!r}"
                )
            values = [clean_api_kg(raw_percentiles[name]) for name in PERCENTILE_KEYS]
            if any(values[i] < values[i - 1] for i in range(1, len(values))):
                raise SystemExit(f"{url}: {label} thresholds descend: {values}")

            cohort = {
                "bodyweight_class": label,
                "kg": values,
                "n": n,
                "plus": plus,
            }
            if plus:
                cohort["lower_bound_exclusive_kg"] = boundary
            else:
                cohort["upper_bodyweight_kg"] = boundary
                previous_upper = boundary
            cohorts.append(cohort)

        if sum(1 for row in cohorts if row["plus"]) != 1 or not cohorts[-1]["plus"]:
            raise SystemExit(f"{url}: final bodyweight class is not the sole plus class")
        if cohorts[-1]["lower_bound_exclusive_kg"] != previous_upper:
            raise SystemExit(f"{url}: plus class does not begin above the preceding class")

        table = sex.upper()
        tables[table] = cohorts
        table_sizes[table] = total

    if versions != {EXPECTED_API_VERSION}:
        raise SystemExit(f"{key}: API versions = {sorted(versions)!r}")
    if data_versions != {EXPECTED_DATA_VERSION}:
        raise SystemExit(f"{key}: data versions = {sorted(data_versions)!r}")
    if not attribution or attribution.get("required") is not True:
        raise SystemExit(f"{key}: API no longer requires visible attribution")

    lift = {
        "band_names": OBSERVED_BAND_NAMES,
        "caveat": spec["caveat"],
        "display_name": spec["display_name"],
        "evidence": {
            "api_version": EXPECTED_API_VERSION,
            "data_version": EXPECTED_DATA_VERSION,
            "has_distribution": True,
            "kind": "OBSERVED_SELF_REPORTED_PERCENTILES",
            "request_urls": request_urls,
            "statement": (
                "FitnessVolt republishes sex-by-bodyweight-class percentile tables "
                "from the Symmetric Strength self-reported gym population.  The "
                "population is measured but self-reported, and is kept separate "
                "from verified competition results."
            ),
        },
        "formula_id": spec["formula_id"],
        "percentile_grid": PERCENTILES,
        "pool": {
            "label": spec["pool_label"],
            "population": "Symmetric Strength self-reported gym sample",
            "table_sizes": table_sizes,
            "total_size": sum(table_sizes.values()),
        },
        "source": {
            "attribution": (
                "Strength standards data by FitnessVolt, sourced from the "
                "Symmetric Strength self-reported gym dataset."
            ),
            "license": (
                "CC BY 4.0; FitnessVolt requires a visible attribution link on "
                "every page or product displaying the data."
            ),
            "name": "FitnessVolt Strength Standards API",
            "url": OPEN_DATA_URL,
        },
        "tables": tables,
        "tier_cuts": PERCENTILES,
        "value_basis": spec["value_basis"],
    }
    return lift, archived_payloads


class RDLTableParser(HTMLParser):
    """Extract only the two server-rendered bodyweight table bodies."""

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.active_sex: str | None = None
        self.in_cell = False
        self.cell_text: list[str] = []
        self.row: list[int] | None = None
        self.rows: dict[str, list[list[int]]] = {"female": [], "male": []}
        self.all_text: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attributes = dict(attrs)
        if tag == "tbody" and attributes.get("id") in {"bw-female", "bw-male"}:
            self.active_sex = attributes["id"][3:]
        elif self.active_sex is not None and tag == "tr":
            self.row = []
        elif self.row is not None and tag == "td":
            self.in_cell = True
            self.cell_text = []

    def handle_data(self, data: str) -> None:
        self.all_text.append(data)
        if self.in_cell:
            self.cell_text.append(data)

    def handle_endtag(self, tag: str) -> None:
        if tag == "td" and self.in_cell:
            text = " ".join("".join(self.cell_text).split())
            if not re.fullmatch(r"\d+", text):
                raise SystemExit(f"RDL table cell is not a whole number: {text!r}")
            assert self.row is not None
            self.row.append(int(text))
            self.in_cell = False
            self.cell_text = []
        elif tag == "tr" and self.row is not None:
            if len(self.row) != 6:
                raise SystemExit(f"RDL bodyweight row has {len(self.row)} cells: {self.row}")
            assert self.active_sex is not None
            self.rows[self.active_sex].append(self.row)
            self.row = None
        elif tag == "tbody" and self.active_sex is not None:
            self.active_sex = None


def lb_to_kg(value: int) -> float:
    return float(
        (Decimal(value) * KG_PER_LB_DECIMAL).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )
    )


def fetch_rdl() -> tuple[dict, dict, str]:
    raw = fetch(RDL_URL)
    page = raw.decode("utf-8")
    parser = RDLTableParser()
    parser.feed(page)
    text = " ".join(html.unescape(" ".join(parser.all_text)).split())
    required_claims = [
        "modeled level tables, ratio-derived from base lifts",
        "modeled estimates, not direct competition percentiles",
    ]
    for claim in required_claims:
        if claim not in text:
            raise SystemExit(
                f"{RDL_URL}: evidence statement {claim!r} disappeared; "
                "do not infer what the table means"
            )
    if RDL_LICENSE_URL not in page:
        raise SystemExit(
            f"{RDL_URL}: Dataset license {RDL_LICENSE_URL!r} disappeared; "
            "do not vendor the modeled rows without a data license"
        )

    modified_match = re.search(r'"dateModified":"([^"T]+)(?:T[^"]*)?"', page)
    if modified_match is None:
        raise SystemExit(f"{RDL_URL}: no dateModified in page metadata")
    page_version = modified_match.group(1)

    tables: dict[str, list[dict]] = {}
    archived_tables: dict[str, list[dict]] = {}
    for sex in ("female", "male"):
        rows = parser.rows[sex]
        if len(rows) < 10:
            raise SystemExit(f"{RDL_URL}: only {len(rows)} {sex} bodyweight rows")
        previous_bw = 0
        model_rows: list[dict] = []
        archived_rows: list[dict] = []
        for row in rows:
            bodyweight_lb, *values_lb = row
            if bodyweight_lb <= previous_bw:
                raise SystemExit(f"{RDL_URL}: {sex} bodyweights are not ascending")
            if any(values_lb[i] <= values_lb[i - 1] for i in range(1, len(values_lb))):
                raise SystemExit(f"{RDL_URL}: {sex}/{bodyweight_lb} thresholds do not ascend")
            model_rows.append(
                {
                    "bodyweight_kg": lb_to_kg(bodyweight_lb),
                    "bodyweight_lb": bodyweight_lb,
                    "kg": [lb_to_kg(value) for value in values_lb],
                    "lb": values_lb,
                }
            )
            archived_rows.append(
                {
                    "bodyweight_lb": bodyweight_lb,
                    "level_thresholds_lb": values_lb,
                }
            )
            previous_bw = bodyweight_lb
        tables[sex.upper()] = model_rows
        archived_tables[sex.upper()] = archived_rows

    evidence_statement = (
        "FitnessVolt labels these as modeled level tables, ratio-derived "
        "from base lifts anchored to OpenPowerlifting, and explicitly says "
        "they are modeled estimates rather than direct competition "
        "percentiles."
    )
    lift = {
        "band_names": RDL_BAND_NAMES,
        "caveat": (
            "This is a ratio-derived training model, not an observed distribution "
            "of Romanian-deadlift lifters.  RDL range of motion is not federation-"
            "standardized, so logged lifts may not be the same movement; no "
            "percentile, sample size, or stronger-than share may be inferred."
        ),
        "display_name": "Romanian deadlift",
        "evidence": {
            "has_distribution": False,
            "kind": "MODELLED",
            "page_last_modified": page_version,
            "request_urls": [RDL_URL],
            "statement": evidence_statement,
        },
        "formula_id": "strength_fitnessvolt_rdl_model_v1",
        "level_names": RDL_LEVEL_NAMES,
        "pool": {
            "label": "FitnessVolt ratio-derived model; no measured RDL cohort",
            "population": "No direct Romanian-deadlift population",
            "table_sizes": {},
            "total_size": 0,
        },
        "source": {
            "attribution": (
                "FitnessVolt Romanian Deadlift Standards by Bodyweight; modeled "
                "level table derived from base lifts anchored to OpenPowerlifting."
            ),
            "license": (
                "The page's Dataset metadata licenses these modeled rows under "
                "ODbL 1.0 (https://opendatacommons.org/licenses/odbl/1-0/).  The "
                "re-keyed RDL database component is offered under the same terms; "
                "see go/platform/rulers/tables/LICENSE-DATA.md."
            ),
            "name": "FitnessVolt Romanian Deadlift Standards",
            "url": RDL_URL,
        },
        "tables": tables,
        "value_basis": (
            "Five published one-repetition-maximum level thresholds by bodyweight. "
            "The source's whole-pound rows are retained verbatim and converted to "
            "kilograms with 1 lb = 0.45359237 kg; lookup uses the nearest published "
            "bodyweight row rather than inventing an interpolation."
        ),
    }
    archived_source = {
        "columns": [
            "bodyweight_lb",
            *[f"{name.lower()}_lb" for name in RDL_LEVEL_NAMES],
        ],
        "evidence": {
            "claims_verified_on_source_page": required_claims,
            "kind": "MODELLED",
            "statement": evidence_statement,
        },
        "extraction": (
            "Only the server-rendered bodyweight table facts were retained; "
            "the source page HTML is not archived."
        ),
        "license": {
            "evidence": "Dataset metadata on the source page",
            "name": "Open Data Commons Open Database License 1.0",
            "url": RDL_LICENSE_URL,
        },
        "page_last_modified": page_version,
        "request_url": RDL_URL,
        "tables": archived_tables,
    }
    return lift, archived_source, page_version


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--snapshot-date",
        default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"),
        help="date to stamp on the fetched snapshot (default: today, UTC)",
    )
    parser.add_argument(
        "--out",
        type=pathlib.Path,
        default=DEFAULT_OUT,
        help=f"output path (default: {DEFAULT_OUT})",
    )
    parser.add_argument(
        "--archive-out",
        type=pathlib.Path,
        help=(
            "source-archive output path (default: "
            f"{SOURCE_ARCHIVE_DIR}/fitnessvolt_strength_<snapshot-date>.json)"
        ),
    )
    args = parser.parse_args()
    try:
        datetime.date.fromisoformat(args.snapshot_date)
    except ValueError as exc:
        raise SystemExit(f"invalid --snapshot-date {args.snapshot_date!r}: {exc}") from exc

    lifts: dict[str, dict] = {}
    api_payloads: list[dict] = []
    for key, spec in OBSERVED_LIFTS.items():
        lift, archived_payloads = fetch_observed_lift(key, spec)
        lifts[key] = lift
        api_payloads.extend(archived_payloads)
        sizes = lift["pool"]["table_sizes"]
        print(
            f"{key}: FEMALE n={sizes['FEMALE']:,}; MALE n={sizes['MALE']:,}",
            file=sys.stderr,
        )

    rdl, archived_rdl, rdl_page_version = fetch_rdl()
    lifts["romanian_deadlift"] = rdl
    print(
        "romanian_deadlift: "
        f"FEMALE rows={len(rdl['tables']['FEMALE'])}; "
        f"MALE rows={len(rdl['tables']['MALE'])} (MODELLED, no distribution)",
        file=sys.stderr,
    )

    source_archive = {
        "api": {
            "license": {
                "attribution_requirement": (
                    "FitnessVolt requires a visible attribution link wherever "
                    "the API data is displayed."
                ),
                "name": "Creative Commons Attribution 4.0 International",
                "source_url": OPEN_DATA_URL,
                "url": CC_BY_LICENSE_URL,
            },
            "payloads": api_payloads,
        },
        "id": SOURCE_ARCHIVE_ID,
        "license_notice": "go/platform/rulers/tables/LICENSE-DATA.md",
        "romanian_deadlift": archived_rdl,
        "snapshot_date": args.snapshot_date,
        "version": 1,
    }
    source_archive_bytes = canonical_json(source_archive)
    source_archive_hash = hashlib.sha256(source_archive_bytes).hexdigest()
    source_archive_filename = archive_filename(args.snapshot_date)
    source_archive_url = archive_url(args.snapshot_date)
    source_archive_path = (
        f"go/platform/rulers/tables/sources/{source_archive_filename}"
    )
    source_pointer = archive_source(source_archive_hash, source_archive_url)
    for lift in lifts.values():
        lift["source"].update(source_pointer)

    table_source = {
        **source_pointer,
        "attribution": (
            "FitnessVolt Strength Standards.  OHP and pull-up data sourced from "
            "the Symmetric Strength self-reported gym dataset; Romanian deadlift "
            "is FitnessVolt's separately labeled modeled level table."
        ),
        "license": (
            "FitnessVolt's public API data is CC BY 4.0 with a required visible "
            "attribution link.  The modeled RDL rows are ODbL 1.0; see each "
            "lift's source and go/platform/rulers/tables/LICENSE-DATA.md."
        ),
        "name": "FitnessVolt Strength Standards",
        "url": OPEN_DATA_URL,
    }

    table = {
        "id": "strength_fitnessvolt_accessory_v1",
        "version": 1,
        "snapshot_date": args.snapshot_date,
        "staleness": {
            "policy": "DRIFTS",
            "max_age_days": 180,
            "rationale": (
                "FitnessVolt republishes the API cohorts and the web model in place, "
                "so a later fetch can change both the self-reported percentile rows "
                "and the modeled RDL thresholds.  Refreshing is mechanical: rerun "
                "the vendor script, review the provenance and evidence labels, and "
                "commit the new dated snapshot."
            ),
        },
        "source": table_source,
        "pipeline": {
            "api_version": EXPECTED_API_VERSION,
            "conversion": "kg = lb * 0.45359237, rounded half-up to 0.01 kg",
            "data_version": EXPECTED_DATA_VERSION,
            "methodology_url": METHODOLOGY_URL,
            "note": (
                "OHP and pull-up preserve discrete API bodyweight classes and exact "
                "published percentile points; the aggregate all-bodyweights rows are "
                "excluded.  RDL preserves the page's whole-pound model table and has "
                "no percentile grid."
            ),
            "rdl_page_last_modified": rdl_page_version,
            "script": "scripts/rulers/vendor_fitnessvolt_strength.py",
            "source_archive": source_archive_path,
        },
        "lifts": lifts,
    }

    archive_out = args.archive_out or SOURCE_ARCHIVE_DIR / source_archive_filename
    write_immutable_archive(archive_out, source_archive_bytes)
    print(f"wrote {archive_out} (sha256 {source_archive_hash})")

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_bytes(canonical_json(table))
    print(f"wrote {args.out}")


if __name__ == "__main__":
    main()

DOTS — the total, as detail only

DOTS normalises a powerlifting total for bodyweight. We show it one tap down on the strength sheet and never as a headline, because our version adds three estimates made on three different days from three different rep ranges — that is not a meet total and must not be compared with one as if it were.

The polynomial is OpenPowerlifting’s, not ours:

DOTS = total_kg × 500 ÷ (A·bw⁴ + B·bw³ + C·bw² + D·bw + E)

A, B, C, D, E —
  female: -0.0000010706, 0.0005158568, -0.1126655495, 13.6175032, -57.96288
  male  : -0.000001093, 0.0007391293, -0.1918759221, 24.0900756, -307.75076

Bodyweight is clamped to the range the polynomial was fitted over (40–210 kg for the male coefficients, 40–150 kg for the female ones), which is the formula’s own rule rather than a smoothing of ours.

Female tables, 131,321 lifters with a full-power total: Beginner at 199.53, Novice at 250.59, Intermediate at 305.65, Advanced at 364.26, Elite at 424.97.

Male tables, 281,282 lifters with a full-power total: Beginner at 232.58, Novice at 296.47, Intermediate at 351.75, Advanced at 403.71, Elite at 454.29.

Chess — their rating, and 100-point clubs

What we show. Your rating in each pool — bullet, blitz, rapid — exactly as the site issues it, plus the highest 100-point club you have ever reached, with the date you reached it. Six pools in all: three on Lichess and three on Chess.com, which are separate ladders on separate scales and are never merged.

We do not rate you.There is no chess Elo of ours anywhere. Lichess runs Glicko-2 and Chess.com runs Glicko; both see every game every player plays and we see one player’s, so their number is better than anything we could compute and we pass it through untouched — no smoothing, no weekly average, no conversion between the two sites.

A club is absolute, dated and never taken away. “1600 club — member since 3 November 2025” means your rating touched 1600 on that day. Losing rating afterwards does not remove it. There is no demotion event anywhere on this platform, and for a club there is not even a drop to record; the rating chart above it tells the truth about now.

The rating lives one tap down. Chess ratings arrive pre-loaded with rating anxiety, so the surfaces that lead are games played and how you have been going. A losing streak must not become a reason to avoid opening the app.

Lichess — the published distribution

Lichess publishes a weekly histogram of every established rating in each pool, and uses it itself to tell members they are “better than X%”. We read the same table with the same rule: you count as above a 25-point bucket when your rating exceeds that bucket’s midpoint. Buckets run 4002800, which is Lichess’s own axis, not ours.

Snapshot . Lichess publishes this histogram on its own public statistics page; its database exports are released under CC0 and its site code under AGPL-3.0. We reproduce the counts with attribution and a link back.

Weekly rating distribution published by Lichess (lichess.org/stat/rating/distribution).

A rating of X is above this share of each Lichess pool
RatingBulletBlitzRapid
8003%3%6%
10009%11%16%
120022%24%32%
140036%41%49%
150044%50%58%
160051%60%68%
180067%77%83%
200080%90%94%
220091%97%98%
240097%99%100%

Percentiles are reported to whole percents on the sheet, against 336,259 bullet, 663,877 blitz, 472,854 rapid rated players. The pool is people who play enough online chess to hold an established rating — not chess players in general, and not the general population. Read the same page we snapshotted.

Chess.com — no distribution, and we say so

Chess.com publishes no rating distribution. It removed the percentile page it used to have and offers no endpoint that replaces it, so there is no honest percentile for a Chess.com rating and we show none. We will not estimate one from our own users, and we will not borrow Lichess’s — the two sites rate different people on different scales, and a number read off the wrong pool is worse than no number. Your rating and your club are Chess.com’s own; the sheet says exactly this where a Lichess sheet shows a percentile.

The snapshot, in full

This is the script that fetched the histogram above, verbatim. It runs by hand and the result is committed, because a reference table that can change while you are reading the page citing it is not a citation.

#!/usr/bin/env python3
"""Vendor Lichess's published weekly rating distribution (ADR 0134/0147).

Lichess publishes, for every leaderboardable pool, a histogram of how many
established players sit in each 25-point rating group:

    https://lichess.org/stat/rating/distribution/blitz

That histogram is the only external, absolute, instrument-compatible ruler
that exists for a chess rating — it is Lichess's own count of Lichess's own
players, and it is the same number Lichess itself uses to tell a member they
are "better than 47.3% of blitz players". We reproduce it rather than
recompute anything, and we reproduce it as a DATED SNAPSHOT compiled into the
binary rather than fetching it per request: a reference table that can change
under the product is not a citation (ADR 0144).

Run it, commit the JSON it writes, re-run scripts/gen-chess-ruler.py so the
public /methods page shows the same numbers, and the snapshot date every
surface renders moves with the commit.

    python3 scripts/rulers/fetch_lichess_distribution.py

The histogram is embedded in the page as JSON rather than served by an API:
there is no documented /api/rating/distribution endpoint and the obvious
guess 404s (checked 2026-08-18), so the page is the source. The extraction is
deliberately narrow and asserts the shape it expects, because a scrape that
degrades quietly is worse than one that fails.

The bucket axis is Lichess's, not ours. lila builds the list as

    (Glicko.minRating.value to 2800 by percentileOf.group)

with `Glicko.minRating = 400` and `percentileOf.group = 25`
(modules/perfStat/src/main/PerfStatApi.scala,
modules/perfStat/src/main/package.scala), so bucket i covers ratings
[400 + 25i, 400 + 25i + 25) and there are 97 of them. The count is asserted
below: if Lichess moves its axis, this script stops rather than silently
re-labelling somebody else's numbers.
"""
import argparse
import datetime
import json
import os
import re
import sys
import urllib.request

REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(REPO, "go/platform/rulers/tables/chess_lichess_distribution.json")

PAGE = "https://lichess.org/stat/rating/distribution/{perf}"

# Lichess's own axis. See the module docstring for where each comes from.
MIN_RATING = 400
GROUP = 25
MAX_RATING = 2800
BUCKETS = (MAX_RATING - MIN_RATING) // GROUP + 1  # 97

# The three pools a standing is offered in. Lichess rates far more than
# these, but a category nobody plays is a row of "no signal" on a surface,
# and these are the three that the two platforms both run.
POOLS = {
    "bullet": "Lichess bullet players with an established rating",
    "blitz": "Lichess blitz players with an established rating",
    "rapid": "Lichess rapid players with an established rating",
}

FREQ = re.compile(r'\{"freq":\s*\[[^\]]*\][^{}]*\}')


def fetch(perf):
    url = PAGE.format(perf=perf)
    request = urllib.request.Request(
        url,
        headers={
            # Lichess asks that bots identify themselves. This runs by hand,
            # three times, when a human regenerates the table.
            "User-Agent": "QuantifiedLife ruler snapshot (https://quantifiedlife.io/methods)",
        },
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        if response.status != 200:
            raise SystemExit(f"{url}: HTTP {response.status}")
        html = response.read().decode("utf-8")
    match = FREQ.search(html)
    if match is None:
        raise SystemExit(
            f"{url}: no embedded rating histogram found. Lichess has changed the "
            "page shape; do not guess at the numbers, read the page and fix this script."
        )
    freq = json.loads(match.group(0))["freq"]
    if len(freq) != BUCKETS:
        raise SystemExit(
            f"{url}: {len(freq)} buckets, expected {BUCKETS} "
            f"({MIN_RATING}..{MAX_RATING} by {GROUP}). Lichess has moved its axis; "
            "re-read lila's PerfStatApi before touching the constants above."
        )
    if any(not isinstance(n, int) or n < 0 for n in freq):
        raise SystemExit(f"{url}: histogram holds a non-count")
    if sum(freq) <= 0:
        raise SystemExit(f"{url}: empty histogram")
    return freq


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--snapshot-date",
        default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"),
        help="the date this snapshot is stamped with (default: today, UTC)",
    )
    args = parser.parse_args()

    pools = {}
    for perf, label in POOLS.items():
        freq = fetch(perf)
        pools[perf] = {
            "pool_label": label,
            "n": sum(freq),
            "freq": freq,
        }
        print(f"{perf}: {sum(freq):,} rated players across {BUCKETS} groups", file=sys.stderr)

    table = {
        "id": "chess_lichess_distribution_v1",
        "version": 1,
        "snapshot_date": args.snapshot_date,
        # How this ruler ages (ADR 0154). Lichess publishes only the current
        # histogram, so the table DRIFTS from the day after it is scraped.
        "staleness": {
            "policy": "DRIFTS",
            "max_age_days": 90,
            "rationale": (
                "Lichess recomputes this histogram weekly and publishes only the current one, "
                "so our copy starts diverging the day after it is scraped. Re-fetching costs "
                "one page load, which is why the threshold here is tighter than the strength "
                "ruler's."
            ),
        },
        "source": {
            "name": "Lichess",
            "url": "https://lichess.org",
            "data_url": "https://lichess.org/stat/rating/distribution/blitz",
            "license": "Lichess publishes this histogram on its own public statistics page; its "
            "database exports are released under CC0 and its site code under AGPL-3.0. "
            "We reproduce the counts with attribution and a link back.",
            "attribution": "Weekly rating distribution published by Lichess "
            "(lichess.org/stat/rating/distribution).",
        },
        "pipeline": {
            "script": "scripts/rulers/fetch_lichess_distribution.py",
            "min_rating": MIN_RATING,
            "group": GROUP,
            "max_rating": MAX_RATING,
            "note": "Lichess's own histogram, copied verbatim. Nothing is fitted, smoothed, "
            "interpolated or pooled across speeds. Bucket i counts established players "
            f"rated [{MIN_RATING} + {GROUP}i, {MIN_RATING} + {GROUP}i + {GROUP}). The "
            "percentile read off it is Lichess's own rule: a player counts as above a "
            "bucket when their rating exceeds that bucket's midpoint.",
        },
        "pools": pools,
    }
    with open(OUT, "w", encoding="utf-8") as handle:
        json.dump(table, handle, indent=1, sort_keys=True)
        handle.write("\n")
    print(f"wrote {OUT}")


if __name__ == "__main__":
    main()

League — Riot's ladder, verbatim

What we show.One League standing: the highest current rank across ranked solo, ranked flex and every configured Riot account. The band is Riot’s tier and division — “Gold II”, or the bare apex name “Master” — and the number beside it is Riot’s whole in-division LP from the lptag. The metric’s numeric value is a continuous ladder encoding for charts; it is not display LP and never appears on the receipt as if it were.

No rating of ours.We do not calculate MMR, smooth Riot’s rank, or rename their tiers. A demotion changes the current row without ceremony and is not an event. A newly higher all-time named peak is a promotion: the weekly letter dates it, and the receipt keeps the full-history line “Peak: Gold II — 12 May 2026” permanently even if the current row later moves down.

Best of, never a blend.Each (queue, account) pair is its own entity with its own history, and the standing is the one whole entity currently standing highest — its tier, its division, its LP, its account name on the receipt. Ties break by LP, then queue and account name in fixed order, so the answer is deterministic. Accounts are never pooled: a peak from one account cannot attach to another account’s current rank, and the receipt lists every entity’s own current rank so “best” is inspectable rather than asserted.

Riot serves current ladder snapshots through LEAGUE-V4, not a history endpoint. QuantifiedLife observes those snapshots daily, replays the history it has actually seen, freezes gaps, and marks a standing stale after 60 days without a valid observation for the leading entity.

League — where a tier sits in the ladder

The population context, and whose it is.Riot does not publish a ranked distribution alongside the ladder, so the “above N%” line on a League receipt cites League of Graphs’ rank distribution — their published shares of recently active ranked players, computed from the Riot API — frozen at a dated capture: patch 16.6, all regions, archived . It is not Riot’s official figure, and the pool is players who queued recently, not everyone with an account.

Which sums are ours.The source publishes each rung’s share; the cumulative “share below your rung” is our addition over their published numbers, disclosed as ours on every receipt that prints one. Solo/duo is published per division, so a Gold II reader gets the share below Gold II exactly; flex is published per tier only, so a flex receipt says “at least” and refuses to resolve the division position the source does not publish. We still derive no MMR, scrape no league tables — this is a hand transcription of one archived page, dated and attributed — and estimate nothing from our own users.

Ranked solo/duo — share of ranked players
TierShare
Iron2.39%
Bronze16.9%
Silver23.5%
Gold24.4%
Platinum17.6%
Emerald10.1%
Diamond3.77%
Master1.1%
Grandmaster0.082%
Challenger0.034%
Ranked flex — share of ranked players
TierShare
Iron2.2%
Bronze13%
Silver26%
Gold22%
Platinum18%
Emerald13%
Diamond4.3%
Master0.48%
Grandmaster0.057%
Challenger0.03%

Rank distribution from League of Graphs (leagueofgraphs.com), all regions, as archived 26 March 2026 (patch 16.6). League of Legends and Riot Games are trademarks of Riot Games, Inc.; League of Graphs is not endorsed by Riot Games.

The transcription, in full

The source’s terms rule out automated pulls, so this table is a hand transcription of one archived page, and the script below is what wrote it — with the checks it refuses to write without. Solo division shares ride in it too; the tier table above is their sums.

#!/usr/bin/env python3
"""Vendor League of Graphs' ranked-tier distribution as a frozen snapshot.

A ONE-OFF, OFFLINE re-keying on vendor_friend_vo2max.py's pattern: it writes a
versioned JSON table that is checked in, cited, and served verbatim, published
on /methods so anybody can re-run it and get the same table.

Source
    League of Graphs, "Rank distribution", all regions, as captured by the
    Internet Archive on 2026-03-26 (patch 16.6) — the newest archived capture;
    the live page 403s automated fetches and its terms prohibit automated
    queries, so the pipeline here is a HAND transcription of one dated
    archived page, which is neither scraping nor a scheduled pull:
    http://web.archive.org/web/20260326144525/https://www.leagueofgraphs.com/rankings/rank-distribution

    League of Graphs computes its statistics from the Riot Games API. This
    table is their published distribution reproduced as a small dated factual
    excerpt with attribution; it is not Riot's official figure, and nothing
    here derives an MMR or a rating (the ruler rule, ADR 0134).

    Riot's 2026-03-02 /dev post ("MMR-to-Rank Distribution",
    https://www.leagueoflegends.com/en-us/news/dev/dev-mmr-to-rank-distribution/)
    is the citation for why any pre-March-2026 distribution is stale: the
    ladder was recalibrated that week. This snapshot post-dates it.

What we take, and in what form
    Ranked SOLO/DUO: the per-division "Rank %" shares (I/II/III/IV per tier)
    plus the apex-tier shares. Ranked FLEX: the per-tier shares — League of
    Graphs publishes flex at tier grain only. Shares are percentages of the
    recently-active ranked population as League of Graphs counts it; the
    page's own rounding makes each queue sum to roughly, not exactly, 100.

    Any cumulative ("top X%" / "above N%") number a surface prints is OUR
    addition over these published shares, and every surface that prints one
    says so — the ADR 0155 disclosure rule for arithmetic of ours over
    numbers of theirs.

VERIFICATION
    Shares below were transcribed by hand from the archived page and are
    checked before writing: every division share is positive, division sums
    match the transcription, and each queue's total lands inside [97, 103]
    (the page's own rounding tolerance).
"""

import json
import pathlib

OUT = pathlib.Path(__file__).resolve().parents[2] / (
    "go/platform/rulers/tables/lol_leagueofgraphs_distribution.json"
)

# Ranked solo/duo, all regions, 2026-03-26 (patch 16.6): tier -> division
# shares in LADDER order (IV lowest first), apex tiers as a bare share.
SOLO = [
    ("iron", {"iv": 0.15, "iii": 0.23, "ii": 0.61, "i": 1.4}),
    ("bronze", {"iv": 4.6, "iii": 3.9, "ii": 4.3, "i": 4.1}),
    ("silver", {"iv": 6.9, "iii": 6.0, "ii": 5.9, "i": 4.7}),
    ("gold", {"iv": 8.6, "iii": 6.2, "ii": 5.6, "i": 4.0}),
    ("platinum", {"iv": 7.1, "iii": 4.5, "ii": 3.6, "i": 2.4}),
    ("emerald", {"iv": 4.4, "iii": 2.4, "ii": 1.8, "i": 1.5}),
    ("diamond", {"iv": 1.8, "iii": 0.78, "ii": 0.57, "i": 0.62}),
    ("master", 1.1),
    ("grandmaster", 0.082),
    ("challenger", 0.034),
]

# Ranked flex, all regions, same capture: tier shares only.
FLEX = [
    ("iron", 2.2),
    ("bronze", 13.0),
    ("silver", 26.0),
    ("gold", 22.0),
    ("platinum", 18.0),
    ("emerald", 13.0),
    ("diamond", 4.3),
    ("master", 0.48),
    ("grandmaster", 0.057),
    ("challenger", 0.03),
]

DIVISION_ORDER = ["iv", "iii", "ii", "i"]


def queue(rows: list, label: str, granularity: str) -> dict:
    tiers = []
    total = 0.0
    for tier, shares in rows:
        if isinstance(shares, dict):
            divisions = []
            for division in DIVISION_ORDER:
                share = shares[division]
                assert share > 0, f"{tier} {division} share must be positive"
                divisions.append({"division": division, "share": share})
                total += share
            tiers.append(
                {
                    "tier": tier,
                    "share": round(sum(d["share"] for d in divisions), 3),
                    "divisions": divisions,
                }
            )
        else:
            assert shares > 0, f"{tier} share must be positive"
            tiers.append({"tier": tier, "share": shares})
            total += shares
    assert 97 <= total <= 103, f"{label} shares sum to {total:.2f}, outside [97, 103]"
    return {"pool_label": label, "granularity": granularity, "tiers": tiers}


def main() -> None:
    table = {
        "id": "lol_leagueofgraphs_distribution_v1",
        "version": 1,
        "snapshot_date": "2026-03-26",
        "source": {
            "name": "League of Graphs rank distribution",
            "url": "https://www.leagueofgraphs.com/rankings/rank-distribution",
            "data_url": "http://web.archive.org/web/20260326144525/https://www.leagueofgraphs.com/rankings/rank-distribution",
            "license": (
                "League of Graphs states no reuse license; this is a small "
                "dated factual excerpt reproduced with attribution, "
                "transcribed by hand from an archived capture, never an "
                "automated query. League of Graphs computes its statistics "
                "from the Riot Games API. This is not Riot's official "
                "distribution, and no MMR or rating is derived from it."
            ),
            "attribution": (
                "Rank distribution from League of Graphs "
                "(leagueofgraphs.com), all regions, as archived 26 March 2026 "
                "(patch 16.6). League of Legends and Riot Games are "
                "trademarks of Riot Games, Inc.; League of Graphs is not "
                "endorsed by Riot Games."
            ),
        },
        "staleness": {
            "policy": "DRIFTS",
            "max_age_days": 210,
            "rationale": (
                "The live distribution refreshes continuously and moves with "
                "season resets and Riot recalibrations (the 2026-03-02 "
                "MMR-to-rank recalibration reshaped Iron in a week, which is "
                "also why nothing older than March 2026 may replace this "
                "table). 210 days spans a ranked split with slack; the fix is "
                "re-transcribing a newer dated capture by hand — the source's "
                "terms rule out an automated pull."
            ),
        },
        "pipeline": {
            "script": "scripts/rulers/vendor_leagueofgraphs_lol.py",
            "capture": "Internet Archive, 2026-03-26 14:45:25 UTC",
            "patch": "16.6",
            "regions": "all",
            "note": (
                "Hand transcription of one dated archived page. Solo/duo is "
                "published per division; flex per tier only. Cumulative "
                "'above N%' figures a surface prints are our sums over these "
                "published shares, disclosed as ours."
            ),
        },
        "queues": {
            "ranked_solo": queue(
                SOLO,
                "recently active ranked solo/duo players across all regions",
                "division",
            ),
            "ranked_flex": queue(
                FLEX,
                "recently active ranked flex players across all regions",
                "tier",
            ),
        },
    }
    OUT.write_text(json.dumps(table, indent=1, sort_keys=True) + "\n")
    print(f"wrote {OUT}")


if __name__ == "__main__":
    main()

Cardio — VO₂max against the FRIEND registry

What we show.A band for your VO₂max — how much oxygen your body can use at full effort, in millilitres per kilogram of you per minute — read against people of the same published column and age decade. The number we place is the median of your last 28 days, not your best day: a VO₂max is a level, and a wrist estimate is noisy enough that taking its maximum would find your luckiest reading and call it fitness.

The gap we cannot close, and will not dress up. Your number is an estimate, worked out by a watch from your heart rate against your pace on an outdoor walk or run. Every number in the table below was measured, off a mask, on a treadmill, by a laboratory taking someone to exhaustion. Wrist estimates have run several mL/kg/min below a laboratory test in validation work, so expect this placement to read low. That gap is a bias, not a wobble — which is exactly why you will never see a plus-or-minus next to it here or in the app. An interval would dress a known, one-directional offset up as random noise, and that is the kind of false precision this whole page exists to refuse.

The pool, and its skew. apparently healthy US adults tested to exhaustion on a treadmill with a metabolic cart. Most of them turned up because somebody wanted their fitness measured, so this is not the general population either. A percentile here is a statement about that pool.

The table

The treadmill reference standards from the FRIEND registry, published and covering registry data through 2021-03-31: 16,278 maximal tests from 34 laboratories across the United States, with a maximal-effort criterion of peak RER >= 1.0. The article is open access under CC BY-NC-ND 4.0. The values below are facts re-keyed into our own structure, not a reproduction of the publisher's typeset table.

Kaminsky LA, Arena R, Myers J, Peterman JE, Bonikowske AR, Harber MP, Medina Inojosa JR, Lavie CJ, Squires RW. Updated Reference Standards for Cardiorespiratory Fitness Measured with Cardiopulmonary Exercise Testing: Data from the Fitness Registry and the Importance of Exercise National Database (FRIEND). Mayo Clinic Proceedings. 2022;97(2):285-293.

We take the treadmill column and not the cycle-ergometer one the same paper publishes, because the two sit about 4.5 mL/kg/min apart and your watch estimates from walking and running. Reading a wrist estimate against a cycle table would place you on an instrument nobody put you on. Read the paper — it is open access — and check every number below against its Table 3 (percentiles), Table 1 (sample sizes).

Men — VO₂max by age, mL/kg/min
Age10th20th30th40th50th60th70th80th90thn
20-2928.635.240.043.646.549.051.954.558.61,278
30-3924.929.833.537.039.743.446.450.055.51,473
40-4922.126.729.732.435.337.940.945.250.82,119
50-5918.622.224.526.929.231.834.338.343.42,082
60-6915.818.520.722.824.626.528.732.037.11,663
70-7913.615.917.319.120.622.223.825.929.4776
80-8912.914.816.116.617.618.420.021.422.8173
Women — VO₂max by age, mL/kg/min
Age10th20th30th40th50th60th70th80th90thn
20-2922.527.230.834.036.639.041.844.849.01,142
30-3918.621.924.226.428.331.033.637.042.11,043
40-4917.219.721.823.925.727.730.033.037.81,372
50-5916.518.520.121.522.924.626.328.432.41,457
60-6913.415.417.018.319.620.922.424.327.31,045
70-7912.314.015.216.217.218.319.620.822.8549
80-8911.412.613.714.715.416.017.318.420.8106

What sits between the printed numbers

The paper prints seven age decades and nine percentiles, and we interpolate straight lines between them — nothing smoother. Age: linear between decade midpoints, clamped flat outside 25-85. Each decade’s column is pinned at its midpoint (25, 35, … 85) and read linearly between midpoints, so your band cannot change on your birthday. Value: linear between published decile anchors; saturates at the 10th and 90th— beyond them we report “below the 10th” or “above the 90th” rather than inventing a 3rd or a 97th the paper never published.

We deliberately do not fit a curve through these anchors. A smooth model would look better and would be ours— it would turn somebody else’s cited facts into our own estimate while keeping their citation, which is the wrong direction for a page like this one.

The cut points, and whose they are

The band names are ours. The percentiles they sit at are 10, 30, 50, 70 and 90, read off the table above. So “Excellent” means “at or above the 90th percentile of apparently healthy US adults tested to exhaustion on a treadmill with a metabolic cartin your age decade” and nothing else. No registry, no professional body and no watch manufacturer issues these words — we chose them, and everything below the 10th percentile of that pool is the first band, Low.

The re-keying, in full

There is no archive to reduce here — FRIEND publishes a paper, not a dataset — so the numbers above were typed in by hand from the printed table. That is a step where a mistake is silent, so the script below refuses to write the table at all unless the re-keyed numbers reproduce the paper’s own sample sizes, its per-cell means from a second table, and the two decline statistics in its abstract. It is here, verbatim, for the same reason the other scripts are: a ruler nobody can re-derive is a black box with a citation stapled to it.

#!/usr/bin/env python3
"""Vendor the 2022 FRIEND VO2max reference standards as a frozen ruler (ADR 0134).

This is a ONE-OFF, OFFLINE re-keying. Nothing in the running platform calls it;
it writes a versioned JSON table that is checked in, cited, and served
verbatim. It is published on /methods so anybody can re-run it and get the same
table -- a ruler nobody can audit is a black box wearing a citation.

Unlike scripts/rulers/reduce_openpowerlifting.py there is no bulk archive to
reduce here. FRIEND publishes its reference standards as a PAPER, so the input
is a printed table and the honest pipeline is: re-key the numbers by hand, then
verify them hard enough that a transcription slip cannot survive. That is what
the VERIFICATION section below is for, and it is the whole reason this file
exists rather than a hand-written JSON blob.

Source
    Kaminsky LA, Arena R, Myers J, Peterman JE, Bonikowske AR, Harber MP,
    Medina Inojosa JR, Lavie CJ, Squires RW. "Updated Reference Standards for
    Cardiorespiratory Fitness Measured with Cardiopulmonary Exercise Testing:
    Data from the Fitness Registry and the Importance of Exercise National
    Database (FRIEND)." Mayo Clin Proc. 2022;97(2):285-293.
    https://doi.org/10.1016/j.mayocp.2021.08.020

    The version of record is open access under CC BY-NC-ND 4.0
    (http://creativecommons.org/licenses/by-nc-nd/4.0/), stated on the
    article's own first page.

What we take, and in what form
    The NUMBERS ONLY, re-keyed into our own structure (#559's licensing pass):
    Table 3's treadmill rows and Table 1's per-cell sample sizes and Table 4's
    per-cell means. We never reproduce the publisher's typeset table, never
    crop or screenshot it, and /methods redraws the cut points in our own
    design with the full citation beside them. Facts are not copyrightable
    (Feist v. Rural, 499 U.S. 340) and Elsevier's own permissions policy asks
    for no permission when creating an original table from factual data; what
    IS theirs is the typesetting, which we do not take.

    Anchor on the 2022 paper and NEVER mix the 2015 anchors into it: the 2022
    treadmill values run 1.5-4.6 mLO2/kg/min lower than 2015's, so a table
    holding some of each would be a ruler that measures nothing.

TREADMILL ONLY, and why
    The paper publishes treadmill and cycle-ergometer standards separately,
    and they are 4.5 mLO2/kg/min apart on average -- the same person places
    differently depending on which machine they were tested on. Every VO2max
    we hold is a wrist estimate derived from outdoor walking and running
    (Apple's HKQuantityTypeIdentifierVO2Max), so treadmill is the mode our
    instrument resembles. Vendoring cycle as well would be shipping a column
    nothing may honestly read.

RER >= 1.0, and why
    The paper's Table 3 uses peak RER >= 1.0 as its maximal-effort criterion
    and reports the RER >= 1.1 variant in a supplement. We take the printed
    Table 3. The paper itself observes the two criteria differ by <= 1.0
    mLO2/kg/min in the mean across every cell, which is smaller than the
    wearable bias the sheet already discloses.

Usage
    python3 scripts/rulers/vendor_friend_vo2max.py \
        --out go/platform/rulers/tables/cardio_friend_vo2max.json

    Run with --check to verify without writing.
"""

import argparse
import json
import sys

TABLE_ID = "cardio_friend_vo2max_v1"

# The article's own dates. SNAPSHOT_DATE is the issue the version of record
# appeared in; DATA_THROUGH is when the registry data behind it ends. A
# published reference standard does not drift the way a scraped histogram
# does -- it goes stale when a newer edition is published -- and /methods says
# so rather than letting a large "days old" number imply a decay that is not
# happening.
SNAPSHOT_DATE = "2022-02-01"
DATA_THROUGH = "2021-03-31"

# The day a human last read the DOI and confirmed this is still the current
# edition. This, not SNAPSHOT_DATE, is what the staleness monitor's clock runs
# from (ADR 0154): a re-check that finds nothing new changes no number here, so
# it must be recordable without touching the snapshot. Bump it when you look.
LAST_CHECKED_DATE = "2026-08-19"

# The percentiles the paper publishes: deciles, 10th through 90th. NOT the
# 5/10/25/50/75/90/95 grid #559's licensing pass guessed at from the 2015
# paper's shape -- the 2022 update prints deciles, and the anchors a ruler
# interpolates between have to be the ones its source actually published.
PERCENTILE_GRID = [10, 20, 30, 40, 50, 60, 70, 80, 90]

# The age axis. The paper bins by decade; we pin each decade's column at its
# MIDPOINT and interpolate linearly between midpoints, so nobody's band jumps
# on a birthday (#559). Outside 25..85 the nearest column is used unchanged
# and the surface says that it clamped.
AGE_KNOTS = [25, 35, 45, 55, 65, 75, 85]
AGE_LABELS = ["20-29", "30-39", "40-49", "50-59", "60-69", "70-79", "80-89"]

# The band cuts, on the published grid, and OUR names for the bands they
# start. ADR 0134: the names are ours, the percentiles they sit at are the
# table's, and /methods says exactly that. TIER_NAMES is one longer than
# TIER_CUTS -- the first name is the band BELOW the first cut.
TIER_CUTS = [10, 30, 50, 70, 90]
TIER_NAMES = ["Low", "Below average", "Fair", "Above average", "Good", "Excellent"]

# ---------------------------------------------------------------------------
# The re-keyed numbers. Everything below this line came off the printed page.
# ---------------------------------------------------------------------------

# Table 3, treadmill rows. percentile -> sex -> one value per age group, in
# AGE_LABELS order. Keyed exactly as printed, in mLO2*kg-1*min-1.
TREADMILL_PERCENTILES = {
    90: {
        "MALE": [58.6, 55.5, 50.8, 43.4, 37.1, 29.4, 22.8],
        "FEMALE": [49.0, 42.1, 37.8, 32.4, 27.3, 22.8, 20.8],
    },
    80: {
        "MALE": [54.5, 50.0, 45.2, 38.3, 32.0, 25.9, 21.4],
        "FEMALE": [44.8, 37.0, 33.0, 28.4, 24.3, 20.8, 18.4],
    },
    70: {
        "MALE": [51.9, 46.4, 40.9, 34.3, 28.7, 23.8, 20.0],
        "FEMALE": [41.8, 33.6, 30.0, 26.3, 22.4, 19.6, 17.3],
    },
    60: {
        "MALE": [49.0, 43.4, 37.9, 31.8, 26.5, 22.2, 18.4],
        "FEMALE": [39.0, 31.0, 27.7, 24.6, 20.9, 18.3, 16.0],
    },
    50: {
        "MALE": [46.5, 39.7, 35.3, 29.2, 24.6, 20.6, 17.6],
        "FEMALE": [36.6, 28.3, 25.7, 22.9, 19.6, 17.2, 15.4],
    },
    40: {
        "MALE": [43.6, 37.0, 32.4, 26.9, 22.8, 19.1, 16.6],
        "FEMALE": [34.0, 26.4, 23.9, 21.5, 18.3, 16.2, 14.7],
    },
    30: {
        "MALE": [40.0, 33.5, 29.7, 24.5, 20.7, 17.3, 16.1],
        "FEMALE": [30.8, 24.2, 21.8, 20.1, 17.0, 15.2, 13.7],
    },
    20: {
        "MALE": [35.2, 29.8, 26.7, 22.2, 18.5, 15.9, 14.8],
        "FEMALE": [27.2, 21.9, 19.7, 18.5, 15.4, 14.0, 12.6],
    },
    10: {
        "MALE": [28.6, 24.9, 22.1, 18.6, 15.8, 13.6, 12.9],
        "FEMALE": [22.5, 18.6, 17.2, 16.5, 13.4, 12.3, 11.4],
    },
}

# Table 1, treadmill, RER >= 1.0: how many tests each column is drawn from.
TREADMILL_N = {
    "MALE": [1278, 1473, 2119, 2082, 1663, 776, 173],
    "FEMALE": [1142, 1043, 1372, 1457, 1045, 549, 106],
}

# The totals the paper prints for those same cohorts, used below to catch a
# mis-keyed n rather than to be published.
PRINTED_N_TOTALS = {"MALE": 9564, "FEMALE": 6714}
PRINTED_TREADMILL_TESTS = 16278

# Table 4, treadmill, RER >= 1.0: the per-cell MEANS. These are re-keyed from
# a DIFFERENT table for one reason -- to check Table 3 against. They are not
# published in our JSON; nothing on any surface reads a mean.
TREADMILL_MEANS = {
    "MALE": [45.2, 40.0, 35.8, 30.2, 25.4, 21.2, 17.9],
    "FEMALE": [36.3, 29.5, 26.6, 23.8, 20.0, 17.5, 15.9],
}

# The two headline statistics the abstract states, reproduced below from the
# means above. If a re-keyed mean is wrong these stop matching.
PRINTED_DECLINE_PER_DECADE = 4.0  # mLO2*kg-1*min-1, treadmill, over 6 decades
PRINTED_DECLINE_PERCENT = 13.5

POOL_LABEL = (
    "apparently healthy US adults tested to exhaustion on a treadmill "
    "with a metabolic cart"
)

SOURCE = {
    "name": "FRIEND registry (Kaminsky et al., 2022)",
    "url": "https://doi.org/10.1016/j.mayocp.2021.08.020",
    "data_url": "https://www.mayoclinicproceedings.org/article/S0025-6196(21)00645-5/fulltext",
    "license": (
        "The article is open access under CC BY-NC-ND 4.0. The values below are "
        "facts re-keyed into our own structure, not a reproduction of the "
        "publisher's typeset table."
    ),
    "attribution": (
        "Reference values from Kaminsky LA, Arena R, Myers J, et al. Updated "
        "Reference Standards for Cardiorespiratory Fitness Measured with "
        "Cardiopulmonary Exercise Testing: Data from the Fitness Registry and the "
        "Importance of Exercise National Database (FRIEND). Mayo Clin Proc. "
        "2022;97(2):285-293. doi:10.1016/j.mayocp.2021.08.020"
    ),
    "citation": (
        "Kaminsky LA, Arena R, Myers J, Peterman JE, Bonikowske AR, Harber MP, "
        "Medina Inojosa JR, Lavie CJ, Squires RW. Updated Reference Standards for "
        "Cardiorespiratory Fitness Measured with Cardiopulmonary Exercise Testing: "
        "Data from the Fitness Registry and the Importance of Exercise National "
        "Database (FRIEND). Mayo Clinic Proceedings. 2022;97(2):285-293."
    ),
}


# ---------------------------------------------------------------------------
# VERIFICATION. Every check here exists to catch a transcription slip in the
# block above, because a hand-keyed ruler with no check is a rumour.
# ---------------------------------------------------------------------------


def verify(report):
    """Raise SystemExit on the first failure; report() every check that passed."""

    def fail(message):
        raise SystemExit("FRIEND table verification failed: " + message)

    grid = sorted(TREADMILL_PERCENTILES)
    if grid != sorted(PERCENTILE_GRID):
        fail("keyed percentiles %s do not match the grid %s" % (grid, PERCENTILE_GRID))

    for sex in ("MALE", "FEMALE"):
        # 1. Within one age column the value must rise with the percentile.
        for age_index, label in enumerate(AGE_LABELS):
            column = [TREADMILL_PERCENTILES[p][sex][age_index] for p in PERCENTILE_GRID]
            if any(b <= a for a, b in zip(column, column[1:])):
                fail("%s %s is not ascending across percentiles: %s" % (sex, label, column))

        # 2. Along one percentile row fitness must not RISE with age. The
        #    paper's own finding is a monotone decline; equality is allowed
        #    (women's 90th is flat from 70-79 to 80-89) but a rise means a
        #    column was keyed out of order.
        for p in PERCENTILE_GRID:
            row = TREADMILL_PERCENTILES[p][sex]
            if any(b > a for a, b in zip(row, row[1:])):
                fail("%s p%d rises with age: %s" % (sex, p, row))

        if len(TREADMILL_N[sex]) != len(AGE_LABELS):
            fail("%s has %d sample counts for %d age groups" % (sex, len(TREADMILL_N[sex]), len(AGE_LABELS)))
        if sum(TREADMILL_N[sex]) != PRINTED_N_TOTALS[sex]:
            fail("%s sample counts sum to %d, printed total is %d"
                 % (sex, sum(TREADMILL_N[sex]), PRINTED_N_TOTALS[sex]))

    # 3. The two per-sex totals must add to the treadmill test count in the
    #    abstract.
    total = sum(PRINTED_N_TOTALS.values())
    if total != PRINTED_TREADMILL_TESTS:
        fail("sample counts total %d, abstract says %d treadmill tests" % (total, PRINTED_TREADMILL_TESTS))
    report("sample counts: %d men + %d women = %d treadmill tests, as printed"
           % (PRINTED_N_TOTALS["MALE"], PRINTED_N_TOTALS["FEMALE"], total))

    # 4. Men above women in every cell -- the paper states this for every age
    #    group, so a swapped pair of columns dies here.
    for p in PERCENTILE_GRID:
        for age_index, label in enumerate(AGE_LABELS):
            male = TREADMILL_PERCENTILES[p]["MALE"][age_index]
            female = TREADMILL_PERCENTILES[p]["FEMALE"][age_index]
            if male <= female:
                fail("p%d %s: men %.1f is not above women %.1f" % (p, label, male, female))
    report("men above women in all %d cells, as the paper states"
           % (len(PERCENTILE_GRID) * len(AGE_LABELS)))

    # 5. CROSS-TABLE. Each cell's mean (Table 4) must land between that cell's
    #    40th and 60th percentile (Table 3). The two tables were keyed from
    #    different pages, so a slip in either breaks this, and a slip near the
    #    middle of a distribution -- the values a real reading is most likely
    #    to land on -- is exactly what it catches.
    for sex in ("MALE", "FEMALE"):
        for age_index, label in enumerate(AGE_LABELS):
            mean = TREADMILL_MEANS[sex][age_index]
            low = TREADMILL_PERCENTILES[40][sex][age_index]
            high = TREADMILL_PERCENTILES[60][sex][age_index]
            if not low <= mean <= high:
                fail("%s %s: mean %.1f is outside its own 40th-60th percentile [%.1f, %.1f]"
                     % (sex, label, mean, low, high))
    report("every cell's mean sits inside its own 40th-60th percentile (Table 4 vs Table 3)")

    # 6. CROSS-ABSTRACT. Reproduce the abstract's decline-per-decade figures
    #    from the keyed means. Six decades separate the 20s column from the
    #    80s column.
    decades = len(AGE_LABELS) - 1
    absolute = []
    percent = []
    for sex in ("MALE", "FEMALE"):
        youngest = TREADMILL_MEANS[sex][0]
        oldest = TREADMILL_MEANS[sex][-1]
        absolute.append((youngest - oldest) / decades)
        percent.append((1 - (oldest / youngest) ** (1.0 / decades)) * 100)
    got_absolute = sum(absolute) / len(absolute)
    got_percent = sum(percent) / len(percent)
    if abs(got_absolute - PRINTED_DECLINE_PER_DECADE) > 0.1:
        fail("decline per decade computes to %.2f, abstract says %.1f"
             % (got_absolute, PRINTED_DECLINE_PER_DECADE))
    if abs(got_percent - PRINTED_DECLINE_PERCENT) > 0.5:
        fail("percent decline per decade computes to %.2f, abstract says %.1f"
             % (got_percent, PRINTED_DECLINE_PERCENT))
    report("decline per decade recomputes to %.2f mLO2/kg/min and %.1f%%, "
           "abstract says %.1f and %.1f%%"
           % (got_absolute, got_percent, PRINTED_DECLINE_PER_DECADE, PRINTED_DECLINE_PERCENT))

    # 7. Our band cuts have to be ON the published grid, or a band floor would
    #    be a number we invented rather than one the table printed.
    for cut in TIER_CUTS:
        if cut not in PERCENTILE_GRID:
            fail("band cut %s is not on the published percentile grid" % cut)
    if len(TIER_NAMES) != len(TIER_CUTS) + 1:
        fail("%d band names for %d cuts" % (len(TIER_NAMES), len(TIER_CUTS)))
    report("band cuts %s all sit on the published grid" % TIER_CUTS)

    if len(AGE_KNOTS) != len(AGE_LABELS):
        fail("%d age knots for %d age groups" % (len(AGE_KNOTS), len(AGE_LABELS)))
    report("all checks passed")


def build():
    columns = {}
    for sex in ("FEMALE", "MALE"):
        knots = []
        for age_index, age in enumerate(AGE_KNOTS):
            knots.append(
                {
                    "age_years": age,
                    "age_label": AGE_LABELS[age_index],
                    "n": TREADMILL_N[sex][age_index],
                    "vo2max": [
                        TREADMILL_PERCENTILES[p][sex][age_index] for p in PERCENTILE_GRID
                    ],
                }
            )
        columns[sex] = knots

    return {
        "id": TABLE_ID,
        "version": 1,
        "snapshot_date": SNAPSHOT_DATE,
        # How this ruler ages (ADR 0154), and it is not by the calendar. A
        # paper does not drift; it is SUPERSEDED, once, when the registry
        # publishes a newer edition. LAST_CHECKED_DATE is the clock — the day
        # somebody last read the DOI — and bumping it is a one-line commit
        # that records a real check without faking a snapshot.
        "staleness": {
            "policy": "SUPERSEDED",
            "recheck_every_days": 365,
            "last_checked_date": LAST_CHECKED_DATE,
            "recheck_question": (
                "Has the FRIEND registry published a cardiorespiratory-fitness reference "
                "standard newer than the 2022 edition (Kaminsky et al., Mayo Clin Proc "
                "2022;97(2):285-293)?"
            ),
            "recheck_url": "https://doi.org/10.1016/j.mayocp.2021.08.020",
            "rationale": (
                "This is a paper, not a feed. The 2022 percentiles do not change, so the "
                "snapshot date being years ago is not decay, and reporting it as decay would "
                "be a false alarm that trains everyone to ignore the real one. It goes stale "
                "exactly once — when the registry publishes a newer edition, as it did in "
                "2015, 2017, 2019 and 2022 — and no age threshold of ours can detect that, "
                "because the signal is on a DOI a human has to read. So the monitor asks on a "
                "yearly cadence, comfortably inside the shortest gap between editions, and "
                "the clock it runs from is the day somebody last looked rather than the day "
                "the paper was published."
            ),
        },
        "source": SOURCE,
        "pipeline": {
            "script": "scripts/rulers/vendor_friend_vo2max.py",
            "mode": "treadmill",
            "effort_criterion": "peak RER >= 1.0",
            "source_table": "Table 3 (percentiles), Table 1 (sample sizes)",
            "data_through": DATA_THROUGH,
            "tests": PRINTED_TREADMILL_TESTS,
            "age_interpolation": "linear between decade midpoints, clamped flat outside 25-85",
            "value_interpolation": "linear between published decile anchors; saturates at the 10th and 90th",
            "note": (
                "The published percentiles, re-keyed by hand and verified against the "
                "paper's own sample sizes, per-cell means and abstract statistics. "
                "Nothing is fitted, smoothed or regressed: a curve of ours through "
                "somebody else's anchors would be our model wearing their citation."
            ),
        },
        "pool_label": POOL_LABEL,
        "percentile_grid": PERCENTILE_GRID,
        "tier_cuts": TIER_CUTS,
        "tier_names": TIER_NAMES,
        "columns": columns,
    }


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out", help="where to write the table")
    parser.add_argument("--check", action="store_true", help="verify only, write nothing")
    args = parser.parse_args()

    verify(lambda line: print("ok: " + line, file=sys.stderr))
    if args.check:
        return
    if not args.out:
        raise SystemExit("--out is required unless --check is given")

    table = build()
    with open(args.out, "w", encoding="utf-8") as handle:
        json.dump(table, handle, indent=1, sort_keys=True)
        handle.write("\n")
    print("wrote " + args.out, file=sys.stderr)


if __name__ == "__main__":
    main()

HRV — nightly RMSSD against 8.2 million wrists

What we show.A band for your nightly heart-rate variability — RMSSD, in milliseconds, the number a WHOOP strap or an Oura ring reports for a night’s sleep. The number we place is the median of your last 28 nights: nightly HRV swings with a late meal or a drink, and the middle of a month is the level, where a best would just find your calmest night and call it fitness.

The pool. Fitbit wearers worldwide in the sleep-dominated 6-7am hour: 8,203,261people, in the study’s own words a sleep-dominated hour, measured as RMSSD over 5-min windows; per user, the median of acceptable windows within the hour. People who bought a fitness tracker and wore it to bed are more health-attentive than the general population, and Fitbit’s nightly arithmetic is not your vendor’s — same construct, a few milliseconds of definitional slack. Both caveats print on the receipt.

Quartiles, and nothing finer.The appendix publishes the 25th, 50th and 75th percentiles per sex per age bin — no tails. So the four bands are the quartiles under our names, the highest placement this ruler can honestly show is the top quarter, and a reading outside the published anchors says “below the 25th” or “above the 75th” rather than inventing a 5th or a 95th. The rank the app wears for it caps one rung below the ladder’s top for the same reason: a ruler that cannot see past the 75th percentile does not award its highest mark.

Natarajan A, Pantelopoulos A, Emir-Farinas H, Natarajan P. Heart rate variability with photoplethysmography in 8 million individuals: a cross-sectional study. Lancet Digital Health. 2020;2(12):e650-e657.

The article is open access under CC BY-NC-ND 4.0. The values here are facts re-keyed into our own structure, not a reproduction of the publisher's typeset appendix. Read the appendix and check every number below against its Supplementary appendix Table S3, RMSSD, 0600-0700h.

Men — nightly RMSSD by age, ms
Age25th50th75th
20-21456696
25-26355479
30-31344971
35-36314362
40-41273854
45-46253448
50-51233142
55-56212939
60-61202737
Women — nightly RMSSD by age, ms
Age25th50th75th
20-21375685
25-26324873
30-31314567
35-36294160
40-41263752
45-46243346
50-51223142
55-56222940
60-61212838

Between the printed bins: linear between bin centers, clamped flat outside 20.5-60.5, so a band cannot change on a birthday, and outside 20–61 the nearest published bin is read with the clamp stated on the sheet. Nothing is fitted — the paper’s own power-law model is deliberately not used, because a fitted curve is the authors’ estimate and this page only reproduces their anchors.

Direction, narrowly. A higher nightly RMSSD quartile is the rewarded direction within the ordinary range, which is what lets this category carry a band at all. An HRV far outside that range — either way — is a conversation with a doctor, not a score, and the sheet says so.

The re-keying, in full

The quartiles were typed in by hand from the published appendix and cross-checked against the preprint’s identical table; the script refuses to write the table unless every bin’s quartiles ascend and every column’s medians fall with age, both true of the source.

#!/usr/bin/env python3
"""Vendor the Natarajan 2020 nightly-RMSSD reference values as a frozen ruler.

A ONE-OFF, OFFLINE re-keying, on vendor_friend_vo2max.py's pattern: nothing in
the running platform calls it; it writes a versioned JSON table that is checked
in, cited, and served verbatim, and it is published on /methods so anybody can
re-run it and get the same table.

Source
    Natarajan A, Pantelopoulos A, Emir-Farinas H, Natarajan P. "Heart rate
    variability with photoplethysmography in 8 million individuals: a
    cross-sectional study." Lancet Digital Health. 2020;2(12):e650-e657.
    https://doi.org/10.1016/S2589-7500(20)30246-6

    The article is open access under CC BY-NC-ND 4.0. The percentile values
    live in the supplementary appendix (mmc1.pdf), Table S3 ("HRV Features -
    Time Domain"); the bioRxiv preprint (https://doi.org/10.1101/772285)
    publishes the same medians and quartiles in machine-readable text and is
    the cross-check used below.

What we take, and in what form
    The NUMBERS ONLY, re-keyed into our own structure (the FRIEND licensing
    pass, unchanged): Table S3's RMSSD rows for the 0600-0700h block — the
    sleep-dominated hour, the paper's own words ("significantly more usable
    data when the subjects were asleep") — per sex, per published age bin:
    the 25th percentile, the median, and the 75th percentile, in
    milliseconds. Facts are not copyrightable (Feist v. Rural, 499 U.S. 340);
    the publisher's typeset table is never reproduced, cropped or
    screenshotted, and /methods redraws the quartiles in our own design with
    the full citation beside them.

    The 1800-1900h (awake) block is deliberately NOT vendored: our input is
    the nightly sleep RMSSD a WHOOP strap or an Oura ring reports, and the
    awake block is a different physiological state wearing the same unit.

    The paper's own power-law fit (appendix Table S1) is also NOT vendored:
    a fitted curve is the authors' model, and the ruler rule is anchors only,
    interpolated linearly between the published bins — a curve of ours (or
    theirs) through the anchors would smooth what the source actually says.

Age bins
    The appendix publishes one-year slices at five-year spacing ("age = 20
    includes users between 20 and 21"), 20 through 60. Each bin's knot sits
    at its center (20.5, 25.5, ... 60.5); the platform interpolates linearly
    between knots and clamps flat outside them, stating both on the sheet.

Per-bin sample sizes
    Table S1 publishes user counts by decade, not by the S3 bins, so the
    knots carry n=0 ("not published at this grain") and every surface cites
    the study-level pool instead: 8,203,261 included users.

VERIFICATION
    Every value below was read from the published appendix PDF
    (https://ars.els-cdn.com/content/image/1-s2.0-S2589750020302466-mmc1.pdf,
    rasterized — the digit font defeats text extraction) and cross-checked
    against the bioRxiv preprint's identical table. Two invariants are
    enforced before writing: quartiles strictly ascend within every knot, and
    medians strictly descend with age within every sex — both true of the
    published table, and either failing means a transcription slip.
"""

import json
import pathlib

OUT = pathlib.Path(__file__).resolve().parents[2] / (
    "go/platform/rulers/tables/hrv_natarajan_rmssd.json"
)

# Table S3, RMSSD (ms), 0600-0700h block: age bin -> (p25, p50, p75).
FEMALE = {
    20: (37, 56, 85),
    25: (32, 48, 73),
    30: (31, 45, 67),
    35: (29, 41, 60),
    40: (26, 37, 52),
    45: (24, 33, 46),
    50: (22, 31, 42),
    55: (22, 29, 40),
    60: (21, 28, 38),
}
MALE = {
    20: (45, 66, 96),
    25: (35, 54, 79),
    30: (34, 49, 71),
    35: (31, 43, 62),
    40: (27, 38, 54),
    45: (25, 34, 48),
    50: (23, 31, 42),
    55: (21, 29, 39),
    60: (20, 27, 37),
}

# The study-level pool: 10,424,196 sampled, 8,203,261 included (paper, Methods).
INCLUDED_USERS = 8_203_261


def knots(rows: dict) -> list:
    out = []
    for age, (p25, p50, p75) in sorted(rows.items()):
        assert p25 < p50 < p75, f"quartiles not ascending at age {age}"
        out.append(
            {
                "age_label": f"{age}-{age + 1}",
                "age_years": age + 0.5,
                "n": 0,
                "rmssd": [p25, p50, p75],
            }
        )
    medians = [k["rmssd"][1] for k in out]
    assert medians == sorted(medians, reverse=True), "medians not descending with age"
    return out


def main() -> None:
    table = {
        "id": "hrv_natarajan_rmssd_v1",
        "version": 1,
        "snapshot_date": "2020-12-01",
        "source": {
            "name": "Natarajan et al., Lancet Digital Health 2020",
            "url": "https://doi.org/10.1016/S2589-7500(20)30246-6",
            "data_url": "https://ars.els-cdn.com/content/image/1-s2.0-S2589750020302466-mmc1.pdf",
            "license": (
                "The article is open access under CC BY-NC-ND 4.0. The values "
                "here are facts re-keyed into our own structure, not a "
                "reproduction of the publisher's typeset appendix."
            ),
            "attribution": (
                "Reference values from Natarajan A, Pantelopoulos A, "
                "Emir-Farinas H, Natarajan P. Heart rate variability with "
                "photoplethysmography in 8 million individuals: a "
                "cross-sectional study. Lancet Digit Health. "
                "2020;2(12):e650-e657. doi:10.1016/S2589-7500(20)30246-6"
            ),
            "citation": (
                "Natarajan A, Pantelopoulos A, Emir-Farinas H, Natarajan P. "
                "Heart rate variability with photoplethysmography in 8 million "
                "individuals: a cross-sectional study. Lancet Digital Health. "
                "2020;2(12):e650-e657."
            ),
        },
        "staleness": {
            "policy": "SUPERSEDED",
            "last_checked_date": "2026-08-20",
            "recheck_every_days": 365,
            "recheck_question": (
                "Has a larger consumer-PPG, sleep-window RMSSD percentile "
                "reference been published that supersedes Natarajan 2020 — in "
                "particular, a peer-reviewed Apple Heart & Movement Study or "
                "All of Us HRV reference table with age/sex percentiles?"
            ),
            "recheck_url": "https://doi.org/10.1016/S2589-7500(20)30246-6",
            "rationale": (
                "A paper does not drift: the 2020 quartiles do not change, so "
                "snapshot age is not decay. It goes stale exactly once — when "
                "a larger compatible-instrument reference is published — and "
                "only a human reading the literature can see that, so the "
                "monitor asks yearly."
            ),
        },
        "pipeline": {
            "script": "scripts/rulers/vendor_natarajan_hrv.py",
            "source_table": "Supplementary appendix Table S3, RMSSD, 0600-0700h",
            "hour_block": "0600-0700h, the sleep-dominated hour",
            "window_method": (
                "RMSSD over 5-min windows; per user, the median of acceptable "
                "windows within the hour"
            ),
            "age_interpolation": "linear between bin centers, clamped flat outside 20.5-60.5",
            "value_interpolation": "linear between published quartile anchors; saturates at the 25th and 75th",
            "included_users": INCLUDED_USERS,
            "note": (
                "The published quartiles, re-keyed by hand and cross-checked "
                "against the bioRxiv preprint (10.1101/772285). Nothing is "
                "fitted or smoothed; the paper's own power-law fit is "
                "deliberately not used."
            ),
        },
        "pool_label": "Fitbit wearers worldwide in the sleep-dominated 6-7am hour",
        "percentile_grid": [25, 50, 75],
        "tier_cuts": [25, 50, 75],
        "tier_names": ["Bottom quarter", "Below median", "Above median", "Top quarter"],
        "columns": {"FEMALE": knots(FEMALE), "MALE": knots(MALE)},
    }
    OUT.write_text(json.dumps(table, indent=1, sort_keys=True) + "\n")
    print(f"wrote {OUT}")


if __name__ == "__main__":
    main()

Steps — daily median against 54,509 participants

What we show.A band for your daily step count, as Apple Health’s cross-source daily total or an Oura ring reports it. The number we place is the median across your last 28 days: a single day swings with the weather, the middle of a month is the level, and a best would just find your one long hike.

The pool. All of Us research participants who wore a Fitbit, combined bring-your-own-device and free-device cohorts: 54,509participants of the All of Us Research Program’s general activity cohort, each contributing their own median daily steps over their whole donation window. The paper itself calls the cohort a broad convenience sample, not representative of the US population — many participants were given their device free by the program’s device-distribution study — and that caveat prints on the receipt.

The sex column, and nothing crossed. Table 2 publishes sex and age strata separately, never jointly; this ruler places against the sex column alone and the sheet says so. The published age rows ride in age_context for /methods only. Fabricating a joint sex-by-age distribution from two published marginals would be modeling, and this page only reproduces anchors.

Quartiles, and nothing finer.Table 2 publishes the 25th, 50th and 75th percentiles per stratum — no tails. So the four bands are the quartiles under our names, the highest placement this ruler can honestly show is the top quarter, and a reading outside the published anchors says “below the 25th” or “above the 75th” rather than inventing a 5th or a 95th. The rank the app wears for it caps one rung below the ladder’s top for the same reason.

The number is gameable, and the sheet says so. Steps are the easiest metric on the standings page to inflate — a wrist swung at a desk counts, and no ruler can tell it from a walk. The receipt carries that sentence verbatim, because a placement that pretends otherwise is a placement you can buy with a hand gesture.

The instrument.The pool wore Fitbit devices and the program ingests Fitbit’s own summaries; your steps come from a different vendor’s algorithm. The paper notes step estimates are algorithm-sensitive — cohorts measured with raw accelerometry report roughly 9,000–9,600 steps a day where this pool’s median is 6,454. As a sanity cross-check on the consumer-wearable construct: eFHS (Shapira-Daniels et al., JMIR 2023;25:e43123, CC BY; Apple Watch, n = 923): median 7,227 (IQR 5,699-8,970) daily steps — a different instrument, cited as a sanity cross-check only.

Patten T, Preble EA, Master H, et al. The All of Us Research Program's wearables dataset. Nature Medicine. 2026;32:2302-2310.

The article is open access under CC BY 4.0. The values here are facts re-keyed into our own structure, not a reproduction of the publisher's table. Read the table and check every number below against its Table 2, median (IQR) daily steps, general activity cohort.

Daily steps by published sex column — the strata the ruler places against
Stratum25th50th75thn
Men5,0467,26410,00117,134
Women4,2256,1148,44237,161
Daily steps by published age band — context only: the source never crosses age with sex, so nothing places against these rows
Stratum25th50th75thn
18-245,1126,9489,0172,878
25-344,9086,7328,8488,583
35-444,5826,5649,0459,373
45-544,5006,5008,9559,393
55-644,4486,6089,46010,677
65-744,0616,1698,90810,338
75-843,2255,0987,4273,068
85+1,7743,4605,353199

The re-keying, in full

The quartiles were typed in by hand from the journal’s own HTML table (the article is CC BY 4.0); the script refuses to write the table unless every stratum’s quartiles ascend and the published female quartiles sit below the male at every anchor, both true of the source.

#!/usr/bin/env python3
"""Vendor the All of Us daily-steps reference quartiles as a frozen ruler.

A ONE-OFF, OFFLINE re-keying on vendor_natarajan_hrv.py's pattern: nothing in
the running platform calls it; it writes a versioned JSON table that is
checked in, cited, and served verbatim, and it is published on /methods so
anybody can re-run it and get the same table.

Source
    Patten T, Preble EA, Master H, et al. "The All of Us Research Program's
    wearables dataset." Nature Medicine. 2026;32:2302-2310.
    https://doi.org/10.1038/s41591-026-04352-3

    The article is open access under CC BY 4.0 (verified on the article's
    "Rights and permissions" section, 2026-08-22), which permits reuse and
    adaptation with attribution. The values live in Table 2 ("Baseline
    wearables outcomes by cohort with select demographic group comparisons"),
    which the journal publishes as an HTML table — no PDF rasterization was
    needed; the numbers below were read from
    https://www.nature.com/articles/s41591-026-04352-3/tables/2 on
    2026-08-22.

What we take, and in what form
    The NUMBERS ONLY, re-keyed into our own structure (the FRIEND licensing
    pass, unchanged): Table 2's median (IQR) daily steps for the GENERAL
    ACTIVITY COHORT column — the combined BYOD + WEAR pool, n = 54,509 —
    per published sex-at-birth stratum. Facts are not copyrightable (Feist
    v. Rural, 499 U.S. 340); the publisher's table is never reproduced,
    and /methods redraws the quartiles in our own design with the full
    citation beside them.

    The BYOD-only and WEAR-only columns are deliberately NOT vendored: the
    split is a recruitment artifact (bring-your-own-device versus a free
    distributed device), not a population a reader belongs to, and the
    combined cohort is the paper's own headline pool.

    ⚠ Table 2's strata cross sex OR age, NEVER both — no joint sex x age
    distribution is published, so this ruler places against the sex column
    alone and SAYS SO. The age rows and the full-cohort row are carried in
    the JSON as published context for /methods (the age gradient is real:
    the 75-84 row's median is 5,098 and the 85+ row's 3,460, against 6,454
    overall), but nothing places against them: fabricating a joint
    distribution from two marginals is exactly the modeling the ruler rule
    refuses.

    Suppressed strata (Intersex, Other, "Prefer not to answer" — n < 20
    under the All of Us Data and Statistics Dissemination Policy) publish
    no quartiles and therefore cannot be columns.

Instrument
    The pool wore Fitbit devices (41 models; the program ingests Fitbit API
    summaries, not raw accelerometry). Our metric is /health/activity/steps
    from Apple Health's cross-source daily dedup or an Oura ring. The paper
    itself notes step estimates are algorithm-sensitive (UK Biobank and
    NHANES report ~9,000-9,600 steps/day from raw accelerometry, versus
    6,454 here) — the sheet carries that caveat. Compatible-instrument
    cross-check, NOT vendored (different device, small n): Shapira-Daniels
    et al., eFHS, J Med Internet Res 2023;25:e43123 (CC BY; Apple Watch;
    n = 923) reports median 7,227 (IQR 5,699-8,970) daily steps.

VERIFICATION
    Every value below was read from the published HTML table and the
    invariants enforced before writing: quartiles strictly ascend within
    every stratum, the female medians sit below the male medians (true of
    the published table), and the per-stratum n values sum to less than the
    cohort n (Unknown/Intersex/Other rows are suppressed or unpublished).
    Any failure means a transcription slip.
"""

from __future__ import annotations

import json
import pathlib

OUT = (
    pathlib.Path(__file__).resolve().parents[2]
    / "go"
    / "platform"
    / "rulers"
    / "tables"
    / "steps_allofus_daily.json"
)

# Table 2, "General activity cohort" column: median (n; IQR) daily steps.
FULL_COHORT = {"n": 54509, "steps": [4432.0, 6454.0, 8958.0]}
COLUMNS = {
    "FEMALE": {"label": "Female", "n": 37161, "steps": [4225.0, 6114.0, 8442.0]},
    "MALE": {"label": "Male", "n": 17134, "steps": [5046.0, 7264.0, 10001.0]},
}
# The published age rows, context only (see the module docstring): the ruler
# never places against them because sex x age is not published jointly.
AGE_CONTEXT = [
    {"age_label": "18-24", "n": 2878, "steps": [5112.0, 6948.0, 9017.0]},
    {"age_label": "25-34", "n": 8583, "steps": [4908.0, 6732.0, 8848.0]},
    {"age_label": "35-44", "n": 9373, "steps": [4582.0, 6564.0, 9045.0]},
    {"age_label": "45-54", "n": 9393, "steps": [4500.0, 6500.0, 8955.0]},
    {"age_label": "55-64", "n": 10677, "steps": [4448.0, 6608.0, 9460.0]},
    {"age_label": "65-74", "n": 10338, "steps": [4061.0, 6169.0, 8908.0]},
    {"age_label": "75-84", "n": 3068, "steps": [3225.0, 5098.0, 7427.0]},
    {"age_label": "85+", "n": 199, "steps": [1774.0, 3460.0, 5353.0]},
]

TABLE = {
    "id": "steps_allofus_daily_v1",
    "version": 1,
    "snapshot_date": "2026-04-27",
    "source": {
        "name": "Patten et al., Nature Medicine 2026 (All of Us wearables dataset)",
        "url": "https://doi.org/10.1038/s41591-026-04352-3",
        "data_url": "https://www.nature.com/articles/s41591-026-04352-3/tables/2",
        "license": (
            "The article is open access under CC BY 4.0. The values here are "
            "facts re-keyed into our own structure, not a reproduction of the "
            "publisher's table."
        ),
        "citation": (
            "Patten T, Preble EA, Master H, et al. The All of Us Research "
            "Program's wearables dataset. Nature Medicine. 2026;32:2302-2310."
        ),
        "attribution": (
            "Reference values from Patten T, Preble EA, Master H, et al. The "
            "All of Us Research Program's wearables dataset. Nat Med. "
            "2026;32:2302-2310. doi:10.1038/s41591-026-04352-3 (CC BY 4.0)"
        ),
    },
    "staleness": {
        "policy": "SUPERSEDED",
        "rationale": (
            "A paper does not drift: the published quartiles do not change, so "
            "snapshot age is not decay. It goes stale exactly once — when a "
            "larger compatible-instrument reference is published, or when All "
            "of Us publishes a newer characterization of the same growing "
            "dataset — and only a human reading the literature can see that, "
            "so the monitor asks yearly."
        ),
        "recheck_every_days": 365,
        "last_checked_date": "2026-08-22",
        "recheck_question": (
            "Has a newer or larger consumer-wearable daily-steps percentile "
            "reference been published that supersedes Patten 2026 — in "
            "particular a newer All of Us wearables characterization, or a "
            "peer-reviewed Apple Heart & Movement Study steps table with "
            "sex/age percentiles?"
        ),
        "recheck_url": "https://doi.org/10.1038/s41591-026-04352-3",
    },
    "pipeline": {
        "script": "scripts/rulers/vendor_allofus_steps.py",
        "source_table": "Table 2, median (IQR) daily steps, general activity cohort",
        "cohort": (
            "The combined BYOD + WEAR general activity cohort (n = 54,509); "
            "per-participant median daily steps over each participant's whole "
            "donation window"
        ),
        "value_interpolation": (
            "linear between published quartile anchors; saturates at the 25th "
            "and 75th"
        ),
        "strata_note": (
            "Table 2 publishes sex and age strata separately, never jointly; "
            "this ruler places against the sex column alone and the sheet "
            "says so. The published age rows ride in age_context for "
            "/methods only."
        ),
        "included_participants": 54509,
        "cross_check": (
            "eFHS (Shapira-Daniels et al., JMIR 2023;25:e43123, CC BY; Apple "
            "Watch, n = 923): median 7,227 (IQR 5,699-8,970) daily steps — a "
            "different instrument, cited as a sanity cross-check only."
        ),
        "note": (
            "The published quartiles, re-keyed by hand from the journal's own "
            "HTML table. Nothing is fitted, smoothed, or combined across "
            "strata."
        ),
    },
    "pool_label": (
        "All of Us research participants who wore a Fitbit, combined "
        "bring-your-own-device and free-device cohorts"
    ),
    "percentile_grid": [25.0, 50.0, 75.0],
    "tier_cuts": [25.0, 50.0, 75.0],
    "tier_names": ["Bottom quarter", "Below median", "Above median", "Top quarter"],
    "full_cohort": FULL_COHORT,
    "columns": {
        key: {"label": c["label"], "n": c["n"], "steps": c["steps"]}
        for key, c in COLUMNS.items()
    },
    "age_context": AGE_CONTEXT,
}


def check() -> None:
    for key, c in COLUMNS.items():
        q = c["steps"]
        assert q == sorted(q) and len(set(q)) == 3, f"{key} quartiles not ascending"
    assert FULL_COHORT["steps"] == sorted(FULL_COHORT["steps"])
    for row in AGE_CONTEXT:
        assert row["steps"] == sorted(row["steps"]), f"{row['age_label']} not ascending"
    for i in range(3):
        assert (
            COLUMNS["FEMALE"]["steps"][i] < COLUMNS["MALE"]["steps"][i]
        ), "published female quartiles sit below male at every anchor"
    assert COLUMNS["FEMALE"]["n"] + COLUMNS["MALE"]["n"] < FULL_COHORT["n"], (
        "sex strata must undercount the cohort (suppressed rows exist)"
    )


def main() -> None:
    check()
    OUT.write_text(json.dumps(TABLE, indent=2, ensure_ascii=False) + "\n")
    print(f"wrote {OUT}")


if __name__ == "__main__":
    main()

Anchors — when nobody publishes a curve

Two categories below are compared against the population without a percentile anywhere on them, and the absence is the honest part. An anchor is what we build when a category has a published, citable average from an instrument like yours — but no distribution: bands at stated offsets from that average, with the reason there is no percentile printed in the slot a curve would fill. Nothing is fitted. We do not assume the spread is bell-shaped, convert an offset into a percentile, or invent tails the source never printed. The offsets and the band names are ours; the numbers are theirs; both sheets say which is which.

Sleep — bedtime consistency, anchored

Your number is the spread — the standard deviation — of your weekday bedtimes over the trailing 28 days, from the onset of each night’s sleep as your ring or strap recorded it. Duration is deliberately not ranked (its relationship with health is U-shaped; see the refusals below); consistency is the one axis of sleep where the research points a single direction, and it is compared as a spread in minutes, never as hours slept.

The anchor is the largest published pool measured on an instrument like yours: 220,000 Oura Ring wearers across 35 countries (Willoughby et al. 2023, Sleep Medicine, Supplementary Table S2f, CC BY 4.0). Its USA column: an average weekday bedtime spread of 52.64 minutes per person, give or take 28.25across people. Across the paper’s countries the average runs from 45.27 (New Zealand) to 71.33 (Japan) minutes.

The bands, at the published mean −1, +1 and +2 published spreads. The offsets and the names are ours.
BandFrom
Steadier than typicalbelow the first cut
Typical24.39 min
Less steady than typical80.89 min
Far less steady than typical109.14 min

Why no percentile: the paper publishes means and spreads, not a distribution, and bedtime spreads are right-skewed — reading a percentile off a mean and an SD would be a normality assumption dressed as a citation. The one percentile grid that exists is UK Biobank’s sleep-regularity work (Windred 2021/2024), measured on research accelerometers — a different instrument, which is exactly why the app cites it as context (the most-regular fifth of 60,977 adults held onset spreads of roughly 20–40 minutes) and never as your rank. The National Sleep Foundation’s timing consensus (Sletten 2023) found the evidence insufficient to set any single number, so no surface of ours claims one; the quantified risk anchors the app does cite — a one-hour onset spread associated with 23% higher metabolic-syndrome and 18% higher cardiovascular risk — are Huang & Redline’s (2019, 2020), named as such.

Two more constructions, stated: the statistic is weekday nights only (the nights that end on a Monday-to-Friday morning), because that is what the paper measured; and vendor sleep scores are refused as inputs — Oura’s score and WHOOP’s performance are single-vendor composites, and a standing on a composite would launder a model through a mirror. The onset clock alone feeds this.

Screen — your distracting apps, anchored

We do not rank your screen time. We do not even decide what “distracting” means: you pick the apps that count, the standing averages exactly those apps’ daily minutes over the trailing 28 reported days, and changing the set changes the number — it is your own definition, which is the point. A day your phone reported but your chosen apps never appeared counts as a real zero; averaging only the days you opened them would rank you on your worst days.

The anchor is a metered panel, not a survey — self-reported screen time is famously off by hours: Ofcom’s Online Nation 2025 (Ipsos iris, ~10,000 metered UK participants, May 2025) prints, per UK online adult per day: Meta apps 70 minutes and ByteDance apps 15 minutes. The 85-minute anchor is their sum, and the addition is ours — the report never sums them. Alphabet’s bucket is excluded on purpose: it folds search and mail in with YouTube, and an anchor should not launder a search engine into distraction.

The bands, at half, at, and double the published average. The multiples and the names are ours.
BandFrom
Under half the averagebelow the first cut
Below the average42.5 min
Above the average85 min
More than double the average170 min

Why no percentile: nobody publishes a distribution of logged app minutes for adults. Metered panels and market reports publish averages only, and the one logged distribution in print covers two hundred US teenagers on Android — the wrong pool twice over. Until better data exists, the published average above is the whole comparison. Two instrument facts ride every sheet: the panel meters phones, tablets and computers together while your number is your phone alone (expect to read low against it), and iPhone per-app minutes are floors rounded down to the phone’s reporting step while Android’s are exact.

Lab results — a line somebody drew, or nothing

A blood or urine result is the one place where a comparison is genuinely available off the shelf and genuinely easy to get wrong. Every row of a lab report already carries an H or an L and a printed reference interval, and turning that column into a rank would take about four lines of code. We do not, for three reasons. A reference interval belongs to one laboratory, computed on its own analyser against its own reference population — two laboratories flag the same blood differently. A flag has no direction: an H on ferritin, an H on HDL cholesterol and an H on sodium are three unrelated statements. And a 95% interval flags one healthy person in twenty by construction, which the WHO says out loud about its own haemoglobin cutoffs.

So the flag and the interval travel with your result as provenance, printed on the receipt in their own block, and they never decide a band. What decides a band is a clinical decision limit: a line a named body drew in a dated publication, in the unit your report prints, for the kind of assay that produced your number. That is a fourth shape of ruler — it has no distribution behind it, so the clinical block has no percentile and says why. For ten compatible measurements, a second block can now describe where the measured value sits in a US population distribution. It never changes the clinical band or turns the decision limit into a curve.

We audited all 84 measurements the importer reads, one at a time. 14 earned a threshold. 70 did not, and each of those carries the citation that establishes why — a refusal with no citation is an opinion. Nothing here is a diagnosis or medical advice; a guideline category is a population-level classification, and what it means for you is a conversation with a clinician who can see the rest of your history.

Transcribed by hand from the publishers’ own documents on 2026-08-19, each threshold carrying the sentence it came from. There is no pipeline to re-run here: a clinical guideline is a document a person reads, and claiming a script would be a lie about how this table is maintained. The file itself is go/platform/rulers/tables/labs_biomarker_rulers.json.

Lab population context — NHANES

What we show.A neutral percentile by measured value among US adults represented by the CDC’s NHANES 2017–March 2020 pre-pandemic survey, split into the published female or male table and age 20–39, 40–59 or 60+. It reads “59th percentile by measured value”, never “top 41%”, “better than” or “healthier than”. A high value can be helpful, harmful, both, or neither depending on the biomarker and the person. This is position, not a target.

The pool.Examined adults age 20 or older in the US civilian, noninstitutionalized population represented by that survey. Participants recorded as pregnant are excluded. Everyone else remains: people with diagnoses, prescriptions and interventions are not filtered out, so this is not a “healthy-control” range. The chosen table tells us which published column to read; it is not an identity inferred by the platform. Age at the draw is derived from the birth year on the profile, because the profile does not hold a birthday.

The reduction. The script joins the ten pinned CDC public-use files by participant, uses the full-exam weight WTMECPRP except for fasting glucose and triglycerides, which use WTSAFPRP, and computes a survey-weighted midpoint empirical CDF. It publishes each whole percentile from P5 through P95. A result beyond that grid is called only “below P5” or “at or above P95”; no tail is invented. Every cell has at least 300 observed participants. These are point estimates; no confidence interval is calculated.

Estimated GFR is the one derived input: the script applies the 2021 CKD-EPI creatinine equation to the NHANES standardized creatinine value. The other nine rows use the named NHANES measurement or the stated arithmetic derivation.

Public longevity dashboards are useful as dated examples, but one person’s biomarker panel is an N=1 benchmark, not a percentile distribution. That is why the receipt uses NHANES for population position rather than presenting a public Blueprint biomarker value as though it described a population.

Re-run scripts/rulers/reduce_nhanes_labs.py to download and SHA-256 verify every source file and rebuild the Go table. The complete P5–P95 table used by the API is reproduced below; sample size n is the unweighted observed count in that cell.

Hemoglobin A1c — P5 through P95

NHANES glycohemoglobin result LBXGH, percent. Survey weight WTMECPRP.

Values in %; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,240
Female
40-59
n=1,407
Female
60+
n=1,454
Male
20-39
n=1,116
Male
40-59
n=1,265
Male
60+
n=1,523
P54.85.05.14.64.95.1
P64.85.05.14.75.05.1
P74.85.05.24.75.05.2
P84.85.05.24.85.05.2
P94.85.05.24.85.15.2
P104.95.15.24.95.15.2
P114.95.15.34.95.15.3
P124.95.15.34.95.15.3
P134.95.15.34.95.15.3
P144.95.25.34.95.15.3
P154.95.25.34.95.25.3
P164.95.25.44.95.25.4
P175.05.25.45.05.25.4
P185.05.25.45.05.25.4
P195.05.25.45.05.25.4
P205.05.25.45.05.25.4
P215.05.35.45.05.25.4
P225.05.35.45.05.35.4
P235.05.35.45.05.35.4
P245.05.35.45.05.35.5
P255.05.35.55.05.35.5
P265.05.35.55.15.35.5
P275.15.35.55.15.35.5
P285.15.35.55.15.35.5
P295.15.35.55.15.35.5
P305.15.35.55.15.45.5
P315.15.45.55.15.45.5
P325.15.45.55.15.45.6
P335.15.45.55.15.45.6
P345.15.45.65.15.45.6
P355.15.45.65.15.45.6
P365.15.45.65.25.45.6
P375.15.45.65.25.45.6
P385.15.45.65.25.45.6
P395.25.45.65.25.45.6
P405.25.45.65.25.45.7
P415.25.45.65.25.45.7
P425.25.45.65.25.55.7
P435.25.55.65.25.55.7
P445.25.55.65.25.55.7
P455.25.55.65.25.55.7
P465.25.55.75.25.55.7
P475.25.55.75.35.55.7
P485.25.55.75.35.55.8
P495.25.55.75.35.55.8
P505.25.55.75.35.55.8
P515.35.55.75.35.65.8
P525.35.55.75.35.65.8
P535.35.55.75.35.65.8
P545.35.65.85.35.65.8
P555.35.65.85.35.65.8
P565.35.65.85.35.65.9
P575.35.65.85.35.65.9
P585.35.65.85.35.65.9
P595.35.65.85.35.65.9
P605.35.65.85.45.75.9
P615.35.65.95.45.76.0
P625.35.65.95.45.76.0
P635.35.65.95.45.76.0
P645.45.75.95.45.76.0
P655.45.75.95.45.76.0
P665.45.75.95.45.76.0
P675.45.75.95.45.76.1
P685.45.75.95.45.86.1
P695.45.76.05.45.86.1
P705.45.76.05.45.86.1
P715.45.76.05.45.86.2
P725.45.86.05.55.86.2
P735.45.86.05.55.96.2
P745.45.86.15.55.96.3
P755.55.86.15.55.96.3
P765.55.86.15.55.96.4
P775.55.86.15.55.96.4
P785.55.96.25.56.06.5
P795.55.96.25.56.06.5
P805.55.96.25.66.06.6
P815.55.96.35.66.16.6
P825.56.06.35.66.16.7
P835.66.06.45.66.26.8
P845.66.06.45.66.26.8
P855.66.06.55.66.36.8
P865.66.16.55.76.46.9
P875.76.26.55.76.57.0
P885.76.26.65.76.67.1
P895.76.36.75.76.77.2
P905.76.46.85.76.97.2
P915.86.66.85.87.07.3
P925.86.77.05.87.47.3
P935.96.87.25.87.67.5
P946.07.07.45.97.97.7
P956.07.47.65.98.57.9
Lab glucose — P5 through P95

NHANES fasting plasma glucose LBXGLU, mg/dL. Survey weight WTSAFPRP; fasting participants only.

Values in mg/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=616
Female
40-59
n=712
Female
60+
n=708
Male
20-39
n=552
Male
40-59
n=622
Male
60+
n=757
P5858689879194
P6868691879395
P7868792889395
P8868892889395
P9878992889396
P10878993899496
P11878994909597
P12889094909597
P13889094909697
P14899094909698
P15899195919699
P168991959197100
P178992959297100
P188992969298101
P199092969398101
P209093969398101
P219093979399102
P229194979399102
P239194979499102
P249195989499102
P2591959894100103
P2692959895100103
P2792959995100103
P2892969995100104
P2992969995100104
P30939710095100105
P31939710095101105
P32939710096101105
P33939710096101105
P34939710096101106
P35939810197101106
P36949810197102107
P37949810197102107
P38949810297102107
P39949910298102107
P40949910298103107
P419510010298103108
P429510010398104109
P439510010398104109
P449510010399104110
P459510010499105110
P469510110499105110
P479610110499105111
P489610110499105111
P4996101105100105112
P5096101106100105112
P5197101106100106113
P5297102106100106113
P5397102107100106114
P5497102107100106114
P5597102107101107114
P5697103108101107115
P5797103108101107115
P5897104109102108116
P5998104109102108117
P6098104109102108117
P6198104110102109117
P6299104110103109119
P6399105111103109119
P6499105111103110119
P6599105111103110120
P66100105112103110120
P67100106112104110120
P68100106113104111121
P69100107114104111122
P70101107115105112123
P71101108116105112125
P72102108117106112126
P73102109117106113127
P74102109118107113127
P75103110118107114128
P76103110119107115130
P77104111119107115130
P78104111119108116130
P79105111120108117132
P80105112122108118135
P81106113123108118137
P82106114125109119139
P83106115126109120140
P84107115127109120141
P85107117128109122145
P86107118129110124150
P87109120130110125154
P88110122131110129158
P89110123132111133160
P90111127133111136163
P91112129136112144168
P92114132138113154172
P93117139142115164175
P94117149151115178189
P95120153156118230197
Total cholesterol — P5 through P95

NHANES total cholesterol LBXTC, mg/dL. Survey weight WTMECPRP.

Values in mg/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,223
Female
40-59
n=1,383
Female
60+
n=1,407
Male
20-39
n=1,095
Male
40-59
n=1,243
Male
60+
n=1,494
P5126148131127125116
P6127150137129130117
P7131152139130131119
P8133154142131133120
P9135157144134136122
P10137157146135139125
P11138158148138143126
P12140160150139146128
P13141160151140147130
P14141162152141150131
P15142163153143151132
P16143164155145153134
P17145164156146155135
P18146166157146156136
P19147167159148158137
P20148168160148159139
P21149169161149160140
P22150170163149162141
P23151171164150162143
P24152172165151163144
P25153173166152165145
P26153174169152166146
P27154175170153168147
P28155176171155169147
P29156177173156169149
P30157178175156171149
P31158179176158172151
P32159180178159173151
P33161181179159174152
P34161181179160175153
P35162182180161177155
P36162183181161178157
P37164183182162178158
P38164184184164179160
P39165185185164180161
P40165186186165180162
P41166187187167182163
P42166187188167183165
P43167189189169184165
P44168190191169186166
P45168190192171187167
P46169191193172188168
P47170192194173190169
P48171193194174191169
P49172195195175192170
P50172195197176193171
P51173196197177194173
P52174198198178195174
P53174199199179196174
P54175199200180197175
P55176200200180198176
P56177201202182198177
P57177202203183199178
P58178203204184200179
P59179204205185202180
P60180205207187203181
P61180206208188204183
P62181207209188205184
P63182209210189206185
P64184210211190207186
P65184211212191208188
P66185211213192209189
P67185212214193210189
P68186213215194212190
P69187214216195214192
P70188215217196215193
P71188216218198217195
P72190217219199218196
P73191218220201219197
P74192219222202220199
P75194221223204221200
P76194222224206223202
P77195224225207224202
P78196225226209225203
P79198226227211226204
P80199228228212226207
P81200229230213228207
P82201230233214230210
P83203233234215232212
P84206235236217235214
P85208237237219238218
P86209239240221240221
P87210240243223242222
P88212242249225244225
P89214244252228247228
P90217247254231248229
P91218252258233250233
P92221255262239253234
P93224259266243255235
P94227264269245259238
P95229267274251263241
HDL cholesterol — P5 through P95

NHANES direct HDL cholesterol LBDHDD, mg/dL. Survey weight WTMECPRP.

Values in mg/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,223
Female
40-59
n=1,383
Female
60+
n=1,407
Male
20-39
n=1,095
Male
40-59
n=1,243
Male
60+
n=1,494
P5353737313132
P6353838323233
P7363939333234
P8373940333234
P9384041343334
P10394141343435
P11394142353435
P12404143353536
P13404243353536
P14414244363536
P15414345363637
P16424345373637
P17424445373638
P18434446373738
P19434547383738
P20444547383739
P21444648383839
P22454648393839
P23454748393840
P24464748393940
P25464749403940
P26474850403940
P27474850403941
P28474851404041
P29484851414041
P30484852414141
P31484952414142
P32484953414142
P33485053414142
P34495053414142
P35495054414242
P36505154424242
P37505154424243
P38515255424243
P39515255424343
P40525256434343
P41525356434444
P42535356434444
P43535457444444
P44535457444545
P45545457444545
P46545458444545
P47545558454545
P48555558454646
P49555658464646
P50555659464746
P51555759464747
P52565760474848
P53565760474848
P54575860474848
P55575860484848
P56585861484849
P57585962484949
P58585962484949
P59596062484950
P60596063494950
P61606063494950
P62606164495051
P63606165505051
P64606265505051
P65616366505152
P66626366515152
P67626366515253
P68626466515253
P69636567515253
P70636667525253
P71636668535354
P72636668535354
P73646769545454
P74646869545455
P75656970545455
P76656971555556
P77667072565656
P78667172565657
P79667273575658
P80677274575759
P81677275585859
P82697376595960
P83697377595960
P84707478606061
P85717579606062
P86727580616163
P87737681626363
P88747782636465
P89757883646566
P90768085656666
P91778086666668
P92798388666769
P93818591686970
P94828794697072
P95848895707172
Triglycerides — P5 through P95

NHANES fasting triglyceride result LBXTR, mg/dL. Survey weight WTSAFPRP; fasting participants only.

Values in mg/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=608
Female
40-59
n=701
Female
60+
n=689
Male
20-39
n=539
Male
40-59
n=612
Male
60+
n=748
P5294041334343
P6314043354544
P7334247384847
P8354348394949
P9364451405051
P10374652405153
P11374753415353
P12394954425454
P13405054435556
P14415155465756
P15425457465958
P16435658476058
P17435659486160
P18445760496261
P19455761506263
P20455862506365
P21466063526366
P22476165526567
P23476266536769
P24486367556770
P25496469566871
P26496571567173
P27506671567275
P28516772577378
P29516773577578
P30516774607579
P31526875617680
P32526977627881
P33537177638082
P34537278658283
P35547378668284
P36547579668485
P37557579678486
P38567779688687
P39577883698787
P40578084698888
P41588284709090
P42598487729092
P43608587739192
P44618688749294
P45638790779496
P46648892789597
P47658894789897
P48669095799998
P496690978010299
P5068919782104101
P5168939882105102
P5269949984106103
P53709710085109104
P54719910285111106
P55729910488114107
P567310110690116109
P577510210794116110
P587610410895118111
P597710511096119111
P607710711297121113
P617810911299123114
P6279109113102127115
P6381111116103129118
P6484113117104133120
P6585115119106135122
P6686117122110142122
P6787119123111143125
P6889121125114147126
P6994122126115150129
P7096122128116153130
P7197123130119155131
P7298126131121156131
P73101128132124157134
P74102129134130160138
P75106133135131164139
P76109136137133168142
P77111139138141170143
P78112141140142175150
P79116142142144182152
P80119144142147185155
P81121147144148185155
P82123150148153187157
P83125153152158198160
P84130155156161198160
P85133156158170203162
P86138163164179208165
P87143166167189215170
P88148170172190221177
P89151175178196230185
P90158179184211237197
P91163185186224245199
P92167189190232257215
P93171194200251274228
P94182202209265304232
P95194215220274314242
Non-HDL cholesterol — P5 through P95

LBXTC minus LBDHDD on the same NHANES participant, mg/dL. Survey weight WTMECPRP.

Values in mg/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,223
Female
40-59
n=1,383
Female
60+
n=1,407
Male
20-39
n=1,095
Male
40-59
n=1,243
Male
60+
n=1,494
P5698877738567
P6719180768771
P7749383778973
P8779584799175
P9789586829375
P10799688849576
P11809789869877
P12829991879978
P1383100929010181
P1484101949110282
P1585102959210484
P1686104959310685
P1787105979510886
P1889105989610988
P19901061009711089
P20911071019811191
P21911081039811292
P229211010410011393
P239311210410011494
P249411310610111695
P259511510610211797
P269611610810411997
P279711611010511998
P289811711210512099
P2999118113106121100
P3099119114107122101
P31100120115108123103
P32101121115109125104
P33101123117110127105
P34102123118111127105
P35102124119112128107
P36103124120113129108
P37104126121114129109
P38105127122115130110
P39105128123116131111
P40106129124116132112
P41107130125118133112
P42107131126118134113
P43108132127120135114
P44110133128121137115
P45110134129122138116
P46111134129124138117
P47112135131125140118
P48113137131126140119
P49114138131127142120
P50115138132128143120
P51115138133130144122
P52116139135130145123
P53117140135131147125
P54118140137133149126
P55118141137134150127
P56120142138135151129
P57120143139136152130
P58121144140137152130
P59121145141138154131
P60122146142139155132
P61123147145139157134
P62123148147141158135
P63124150147142159135
P64125151149143159136
P65126152150144161137
P66128153151145161138
P67129154152146162139
P68130155154148163140
P69130155155150165141
P70132156155151166142
P71133158157152167144
P72134158158153168145
P73134160159155170146
P74135162161157171148
P75137163162158172149
P76138164162159173150
P77140166164162175152
P78141167165164176154
P79142169165167177155
P80142170166168179156
P81144171168168180160
P82146173169170182162
P83148174170171183163
P84149176172172184165
P85151177174174186168
P86152178176178189169
P87153181178179190170
P88155183179183193174
P89157185183186195177
P90163186189190198179
P91166189192193202182
P92169192197194206185
P93172195202196209190
P94176200209198212192
P95181209215203217195
High-sensitivity CRP — P5 through P95

NHANES high-sensitivity CRP LBXHSCRP, mg/L. Survey weight WTMECPRP.

Values in mg/L; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,217
Female
40-59
n=1,370
Female
60+
n=1,401
Male
20-39
n=1,088
Male
40-59
n=1,237
Male
60+
n=1,476
P50.30.30.40.30.40.4
P60.30.40.50.30.40.4
P70.30.40.50.30.40.4
P80.30.40.50.30.40.5
P90.40.50.50.30.50.5
P100.40.50.60.40.50.5
P110.40.50.60.40.50.5
P120.40.50.60.40.50.5
P130.40.60.70.40.60.5
P140.50.60.70.40.60.6
P150.50.70.70.40.60.6
P160.50.70.70.50.60.6
P170.50.70.70.50.70.6
P180.60.80.80.50.70.7
P190.60.80.80.50.70.7
P200.60.80.80.60.70.7
P210.60.90.90.60.80.7
P220.60.90.90.60.80.8
P230.60.90.90.60.80.8
P240.71.01.00.60.80.8
P250.71.01.00.60.90.8
P260.71.01.00.60.90.8
P270.71.01.00.71.00.8
P280.71.11.10.71.00.9
P290.81.11.10.71.00.9
P300.81.11.10.71.00.9
P310.81.11.10.81.11.0
P320.81.21.20.81.11.0
P330.91.31.20.81.11.1
P340.91.31.30.81.11.1
P350.91.31.30.91.21.1
P361.01.41.40.91.21.2
P371.01.51.41.01.21.2
P381.01.61.51.01.31.2
P391.11.61.51.01.31.3
P401.11.71.61.01.41.3
P411.11.81.61.11.41.3
P421.21.91.71.11.41.3
P431.31.91.71.11.51.4
P441.32.01.81.11.61.5
P451.42.11.81.21.61.5
P461.52.11.91.21.61.6
P471.62.22.01.31.61.6
P481.62.22.01.31.71.7
P491.72.42.11.31.71.7
P501.72.52.11.41.71.8
P511.82.62.21.41.81.8
P521.92.72.21.51.81.8
P532.02.82.21.51.91.9
P542.12.82.31.61.91.9
P552.22.92.41.62.02.0
P562.32.92.41.72.12.0
P572.43.02.51.82.22.1
P582.63.12.61.82.22.2
P592.63.12.71.92.32.3
P602.73.22.71.92.32.4
P612.83.32.91.92.52.5
P622.93.42.92.02.52.6
P633.13.53.02.12.62.6
P643.23.63.12.22.72.8
P653.33.83.22.22.82.9
P663.53.93.32.32.92.9
P673.64.03.42.33.03.0
P683.74.13.62.43.13.1
P693.94.33.72.53.23.1
P704.14.43.82.73.23.2
P714.24.63.92.83.33.4
P724.35.04.02.83.43.6
P734.55.34.22.93.53.6
P744.75.44.33.03.73.8
P754.95.64.43.03.84.0
P765.15.84.63.13.94.0
P775.35.84.73.34.14.1
P785.46.05.03.54.24.2
P795.66.35.23.64.34.4
P805.86.55.43.74.44.5
P816.36.75.83.94.54.6
P826.57.16.14.04.64.8
P836.97.36.24.24.85.1
P847.37.66.44.45.15.3
P857.57.86.74.65.45.7
P867.98.46.85.15.85.8
P878.38.87.05.35.96.1
P888.68.97.25.76.26.6
P899.09.47.75.86.57.0
P909.610.28.26.26.87.8
P9110.111.38.56.47.18.5
P9210.911.99.36.47.79.9
P9311.612.310.06.88.210.9
P9412.213.211.77.19.211.5
P9513.314.812.18.010.412.4
Ferritin — P5 through P95

NHANES ferritin LBXFER, ng/mL. Survey weight WTMECPRP.

Values in ng/mL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,224
Female
40-59
n=1,396
Female
60+
n=1,426
Male
20-39
n=1,102
Male
40-59
n=1,253
Male
60+
n=1,501
P59923394525
P6101025434827
P7111226475332
P8111329505536
P9121429555840
P10131532596342
P11131534626847
P12141636657251
P13151837687553
P14161839717756
P15162041748059
P16162242788160
P17172343818263
P18182545848464
P19192645858866
P20192747889268
P21202949899571
P222131509010073
P232233539410374
P242333549510677
P252534569710979
P2625365910011283
P2726366210211386
P2826386310411688
P2927386410411790
P3028406710612093
P3128436810712395
P3229447010812898
P3329457111013099
P34314673112133102
P35314874114135103
P36324975116137106
P37335077118139109
P38345178122143111
P39345380125146114
P40355582129149116
P41365884130151118
P42376085131153121
P43386288137155123
P44386490139158126
P45396593141160126
P46406695142164130
P47416696144169133
P48426797149173137
P494369101152178139
P504470102154183143
P514571105157184145
P524773106158189148
P534874109162191149
P544974111163196155
P555076114164201159
P565280116165203161
P575381118166207165
P585483121169211168
P595484122172214171
P605686123175220177
P615888125176226181
P625993126178231184
P635995128181237190
P646198130183244197
P656298133186248201
P6662100135192251205
P6763102136194254211
P6864105139195261216
P6966107142200264222
P7068109144205267225
P7170113149210273227
P7270116153214280234
P7372119157215289238
P7473121163221292244
P7574124166226295246
P7676127174230297255
P7777130179236300266
P7879133183244307272
P7981137188252309279
P8083139190255315286
P8185143194260319294
P8286148197268323309
P8388155200273336324
P8492160207278344327
P8596163212284356345
P8699171217298363352
P87102183226305385367
P88106194234313398380
P89110202241322419396
P90116209251339442413
P91118225266348464422
P92123239284364491459
P93129251299379524484
P94135262321394528512
P95139289360428556586
Hemoglobin — P5 through P95

NHANES complete-blood-count haemoglobin LBXHGB, g/dL. Survey weight WTMECPRP.

Values in g/dL; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,248
Female
40-59
n=1,410
Female
60+
n=1,466
Male
20-39
n=1,121
Male
40-59
n=1,271
Male
60+
n=1,533
P511.411.111.513.613.411.9
P611.611.311.613.613.512.2
P711.811.511.713.813.612.4
P811.911.611.813.813.712.6
P912.011.811.913.913.812.7
P1012.111.912.013.913.912.8
P1112.212.012.214.013.912.9
P1212.212.112.214.114.013.0
P1312.312.212.314.214.013.1
P1412.412.312.314.214.113.2
P1512.412.412.414.214.113.2
P1612.412.412.414.314.213.3
P1712.412.512.514.314.213.3
P1812.512.512.514.414.313.4
P1912.512.612.514.414.313.5
P2012.612.612.614.414.313.6
P2112.612.712.614.414.413.6
P2212.612.712.714.514.413.7
P2312.712.812.714.514.513.7
P2412.712.812.814.514.513.7
P2512.712.912.814.514.513.8
P2612.812.912.814.614.513.8
P2712.812.912.914.614.613.9
P2812.813.012.914.714.614.0
P2912.813.012.914.714.614.0
P3012.913.013.014.814.714.0
P3112.913.013.014.814.714.1
P3212.913.113.114.814.714.1
P3313.013.113.114.814.714.2
P3413.013.113.114.814.814.2
P3513.013.213.114.914.814.2
P3613.013.213.214.914.814.2
P3713.113.213.214.914.814.3
P3813.113.313.214.914.914.3
P3913.113.313.215.014.914.4
P4013.213.313.215.015.014.4
P4113.213.313.315.015.014.4
P4213.213.413.315.015.014.5
P4313.213.413.415.015.014.5
P4413.313.413.415.115.014.5
P4513.313.413.415.115.014.5
P4613.313.513.415.115.114.6
P4713.313.513.415.115.114.6
P4813.413.513.515.115.114.6
P4913.413.513.515.215.214.7
P5013.413.613.515.215.214.7
P5113.413.613.515.215.214.7
P5213.413.613.615.315.314.8
P5313.513.713.615.315.314.8
P5413.513.713.715.315.314.8
P5513.513.713.715.315.314.9
P5613.513.713.715.415.414.9
P5713.613.813.715.415.415.0
P5813.613.813.815.415.515.0
P5913.613.813.815.515.515.0
P6013.613.913.815.515.515.0
P6113.713.913.915.515.515.1
P6213.713.913.915.515.515.1
P6313.713.913.915.615.615.1
P6413.713.913.915.615.615.2
P6513.814.013.915.615.615.2
P6613.814.014.015.615.615.2
P6713.814.014.015.615.615.3
P6813.814.114.015.715.715.3
P6913.914.114.115.715.715.3
P7013.914.114.115.715.715.4
P7113.914.114.215.715.815.4
P7214.014.214.215.715.815.5
P7314.014.214.215.815.815.5
P7414.014.214.215.815.815.6
P7514.014.314.315.815.915.6
P7614.114.314.315.915.915.6
P7714.114.314.315.915.915.7
P7814.114.414.315.915.915.7
P7914.214.414.416.016.015.7
P8014.214.514.416.016.015.8
P8114.214.614.516.016.015.8
P8214.314.614.516.016.015.8
P8314.314.714.616.116.115.9
P8414.414.714.616.116.215.9
P8514.414.714.716.216.216.0
P8614.514.714.716.216.216.0
P8714.514.814.816.216.316.1
P8814.614.814.916.316.316.2
P8914.614.914.916.416.416.2
P9014.714.915.016.416.416.3
P9114.715.015.116.516.516.4
P9214.815.015.216.516.516.5
P9314.815.115.216.616.616.5
P9414.915.215.416.716.716.7
P9515.015.215.416.816.816.9
Estimated GFR — P5 through P95

NIDDK's race-free 2021 CKD-EPI creatinine equation over NHANES serum creatinine LBXSCR, age and sex; mL/min/1.73 m2. Survey weight WTMECPRP.

Values in mL/min/1.73m²; headings show table, age band and unweighted sample size.
PercentileFemale
20-39
n=1,219
Female
40-59
n=1,377
Female
60+
n=1,401
Male
20-39
n=1,089
Male
40-59
n=1,238
Male
60+
n=1,482
P5856844816847
P6867046837050
P7887247857052
P8907449877252
P9917551887254
P10917652897456
P11937853907557
P12947854917657
P13957955927659
P14968056937760
P15968157947860
P16978258957862
P17988358967962
P18998359968063
P19998460978064
P201008561988164
P211018662988165
P221018662988266
P231018663998367
P2410287631008367
P2510288641018467
P2610388651018468
P2710389651028569
P2810489661038669
P2910590671038770
P3010590671048771
P3110691681058871
P3210792681058872
P3310893691068972
P3410993701079073
P3511094701089073
P3611094711089073
P3711195711099174
P3811196721109175
P3911196721109275
P4011297731109276
P4111397731119277
P4211398741119377
P4311499751129477
P4411499751129478
P45115100751139578
P46115100761139579
P47116100771149580
P48116101771149681
P49116101781159681
P50116101781159682
P51117102791169782
P52117102801169882
P53118102801169983
P54118103801169984
P55118103811169984
P561191038211710085
P571191038311710185
P581191038311710186
P591191048411710186
P601201048411710287
P611201048511810287
P621201048611810287
P631201058611810288
P641201058711910388
P651211058811910388
P661211058811910489
P671211068812010489
P681211068912010590
P691221079012010591
P701221079112010591
P711221079112110592
P721221089112110592
P731231089212110693
P741231089212210693
P751231089312210693
P761241099312210794
P771241099412210794
P781241099412310795
P791241099412310795
P801251109512310895
P811251109512410896
P821251119612410896
P831261119612510996
P841261119612510996
P851261129612511097
P861261129712511097
P871271129712611197
P881271129812611198
P891271139912711198
P901281139912711299
P911281139912711299
P921291149912811399
P93129114100128113100
P94130115100128113101
P95130115101129114101

Hemoglobin A1c — American Diabetes Association, Standards of Care in Diabetes

the ADA's diagnostic categories for nonpregnant adults, in %
BandFromPublished as
Below the prediabetes rangeEverything below the first cut.
Prediabetes5.7 %A1C 5.7-6.4% (39-47 mmol/mol) — Table 2.2, "Criteria defining prediabetes in nonpregnant individuals".
Diabetes range6.5 %A1C >=6.5% (>=48 mmol/mol) — Table 2.1, "Criteria for the diagnosis of diabetes in nonpregnant individuals".

This report named no assay, so this reads your result as an NGSP-certified laboratory method traceable to the DCCT reference assay — what US clinical laboratories are graded against, and what the % scale itself implies, since the IFCC scale reports mmol/mol. It would not be a safe assumption for a home kit or a waived point-of-care device.

Edition
Standards of Care in Diabetes—2026, Section 2, Tables 2.1 and 2.2; doi:10.2337/dc26-S002; released 2025-12-08
Specimen
whole blood, reported in %
Assay
A laboratory HbA1c certified by the National Glycohemoglobin Standardization Program as traceable to the DCCT reference assay. Standardization: NGSP / DCCT.
Applies only when
your report prints %; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Lower is better, as far as the literature supports.
Re-checked
2026-08-19, every 180 days. Has the ADA published a Standards of Care edition later than 2026 in which Table 2.1 or Table 2.2 states an A1C cut point other than 5.7% and 6.5%?
Attribution
American Diabetes Association Professional Practice Committee. 2. Diagnosis and Classification of Diabetes: Standards of Care in Diabetes—2026. Diabetes Care 2026;49(Suppl. 1):S27-S49. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The ADA is explicit that one number diagnoses nobody: "In the absence of a clear clinical diagnosis... confirmatory tests are necessary to establish the diagnosis. This can be accomplished by two abnormal screening test results, measured either at the same time or at two different time points."
  • Risk does not begin at the cut point. The ADA's own footnote to Table 2.2 says risk "is continuous, extending below the lower limit of the range and becoming disproportionately greater at the higher end of the range", so the line between the first two bands is drawn across a gradient.
  • Several common conditions break the link between A1C and average glucose, and where they apply this scale does not: "In conditions associated with an altered relationship between A1C and glycemia, such as some hemoglobin variants, pregnancy, glucose-6-phosphate dehydrogenase deficiency, HIV, and conditions that may alter red blood cell turnover, plasma glucose criteria should be used to diagnose diabetes."
  • A low A1C is not automatically a better A1C. Shortened red-cell survival from haemolysis, recent bleeding or a recent transfusion lowers A1C without lowering average glucose.
  • Both ADA tables are captioned "in nonpregnant individuals". These cut points are not the pregnancy criteria and not the paediatric tables, and this platform holds no pregnancy field to check that with.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The lower boundary is genuinely disputed. NICE (PH38, 2012) puts non-diabetic hyperglycaemia at 6.0-6.4%, as did the 2009 International Expert Committee, so an A1C of 5.8% is prediabetes to the ADA and normal to NICE. The >=6.5% diagnostic cut point is agreed across ADA, WHO, NICE and IDF.

Lab glucose — American Diabetes Association, Standards of Care in Diabetes

the ADA's diagnostic categories for nonpregnant adults, in mg/dL
BandFromPublished as
Below the prediabetes rangeEverything below the first cut.
Impaired fasting glucose100 mg/dLFasting plasma glucose 100-125 mg/dL (5.6-6.9 mmol/L) — Table 2.2, "Criteria defining prediabetes in nonpregnant individuals".
Diabetes range126 mg/dLFasting plasma glucose >=126 mg/dL (7.0 mmol/L) — Table 2.1. "Fasting is defined as no caloric intake for at least 8 h."

This report named no assay, so this reads your result as a venous plasma glucose from a clinical laboratory. A fingerstick meter reading is not that: meters are permitted a far wider error and the ADA does not place anybody on these cut points with one.

Edition
Standards of Care in Diabetes—2026, Section 2, Tables 2.1 and 2.2; doi:10.2337/dc26-S002
Specimen
plasma, reported in mg/dL
Assay
A venous plasma glucose measured by a clinical laboratory. Standardization: NIST SRM 917/965 traceability, as CLIA proficiency testing grades glucose against.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"; the draw is recorded as fasting
Direction
U-shaped — both tails are adverse, and the bands name both.
Re-checked
2026-08-19, every 180 days. Has the ADA published a Standards of Care edition later than 2026 in which Table 2.1 or Table 2.2 states a fasting plasma glucose cut point other than 100 mg/dL and 126 mg/dL?
Attribution
American Diabetes Association Professional Practice Committee. 2. Diagnosis and Classification of Diabetes: Standards of Care in Diabetes—2026. Diabetes Care 2026;49(Suppl. 1):S27-S49. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • This band is shown only on a draw the report recorded as fasting, because the ADA defines these cut points on one: "Fasting is defined as no caloric intake for at least 8 h." A draw whose fasting state the report did not state gets the printed refusal instead, because "we do not know" is not "yes".
  • The ADA requires confirmation before anything follows from a single number: "In the absence of a clear clinical diagnosis... confirmatory tests are necessary to establish the diagnosis."
  • The lowest band is named by position and not called normal. A fasting glucose can also be too low — the ADA's own hypoglycaemia classification starts at 70 mg/dL — and no band here says anything about that tail.
  • Both ADA tables are captioned "in nonpregnant individuals", and gestational diabetes uses a different fasting threshold. This platform holds no pregnancy field.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The WHO and the IDF put impaired fasting glucose at 110-125 mg/dL, not 100-125, so a fasting glucose of 105 mg/dL is prediabetes to the ADA and normal to the WHO. The >=126 mg/dL diagnostic cut point is agreed.

Total cholesterol — NCEP Adult Treatment Panel III

the ATP III classification for adults aged 20 and over, in mg/dL
BandFromPublished as
DesirableEverything below the first cut.
Borderline high200 mg/dLTable II.2-4, "ATP III Classification of Total Cholesterol": 200-239 mg/dL Borderline high.
High240 mg/dLTable II.2-4: >=240 mg/dL High.

This report named no assay, so this reads your result as a clinical laboratory cholesterol standardized against the CDC reference method — the programme US laboratories are certified through, and the one ATP III's numbers were set on.

Edition
NIH Publication No. 02-5215 (September 2002), Table II.2-4, page II-5
Specimen
serum, reported in mg/dL
Assay
An enzymatic total cholesterol assay run by a clinical laboratory. Standardization: CDC Lipid Standardization Program / Cholesterol Reference Method Laboratory Network.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has any US body republished a total-cholesterol CLASSIFICATION table with cut points other than 200 and 240 mg/dL? The 2018 AHA/ACC and 2026 ACC/AHA cholesterol guidelines are risk-based and publish no replacement classification, which is why ATP III's table is still the one cited.
Attribution
Third Report of the National Cholesterol Education Program (NCEP) Expert Panel on Detection, Evaluation, and Treatment of High Blood Cholesterol in Adults (Adult Treatment Panel III), Final Report. NIH Publication No. 02-5215, September 2002. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • Total cholesterol is a sum, and the sum hides its parts: a high total driven by high HDL cholesterol is not the same finding as one driven by high LDL cholesterol. ATP III itself treats LDL cholesterol as "the primary target of therapy", not this number.
  • ATP III is an adult table, aged 20 and over. It is not the paediatric classification, and cholesterol rises substantially through pregnancy — this platform holds no pregnancy field to check that with.
  • Nothing on this scale is a treatment decision. Current US guidance drives treatment off overall cardiovascular risk rather than off a cholesterol category, so a band here does not correspond to anything a clinician would act on by itself.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.

LDL cholesterol — NCEP Adult Treatment Panel III

the ATP III classification for adults aged 20 and over, in mg/dL
BandFromPublished as
OptimalEverything below the first cut.
Near optimal / above optimal100 mg/dLTable II.2-4, "ATP III Classification of LDL Cholesterol": 100-129 mg/dL Near optimal/above optimal.
Borderline high130 mg/dLTable II.2-4: 130-159 mg/dL Borderline high.
High160 mg/dLTable II.2-4: 160-189 mg/dL High.
Very high190 mg/dLTable II.2-4: >=190 mg/dL Very high.

The report does not say whether this LDL cholesterol was calculated from the other lipids or measured directly, and the two are not interchangeable at the margins. ATP III's classification was set on calculated values, so that is what this reads yours as.

Edition
NIH Publication No. 02-5215 (September 2002), Table II.2-4, page II-5
Specimen
serum, reported in mg/dL
Assay
An LDL cholesterol result from a clinical laboratory, calculated or direct. Standardization: CDC Lipid Standardization Program / Cholesterol Reference Method Laboratory Network.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"; the same draw's triglycerides are under 400 mg/dL
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has any US body republished an LDL-cholesterol CLASSIFICATION table with cut points other than 100/130/160/190 mg/dL? The 2018 and 2026 ACC/AHA guidelines are risk-based and publish thresholds for treatment decisions rather than a replacement classification.
Attribution
Third Report of the National Cholesterol Education Program (NCEP) Expert Panel on Detection, Evaluation, and Treatment of High Blood Cholesterol in Adults (Adult Treatment Panel III), Final Report. NIH Publication No. 02-5215, September 2002. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • "Optimal" is ATP III's own word for the lowest band and it is a population classification, not a verdict on you. The panel set it against coronary risk in untreated adults; it is not a target anybody has given you.
  • If your triglycerides on the same draw were at or above 400 mg/dL this ruler refuses rather than banding you, because the equation most laboratories calculate LDL cholesterol with stops being accurate there.
  • ATP III is an adult table, aged 20 and over, and it is not the paediatric classification. LDL cholesterol rises through pregnancy and this platform holds no pregnancy field.
  • Newer US guidelines do not classify LDL cholesterol at all — they drive treatment off overall cardiovascular risk. A band here is a position on a 2002 table, not a current treatment recommendation.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The 2019 ESC/EAS guideline sets goals as low as 55 mg/dL for very-high-risk patients, which is a treatment target conditioned on risk rather than a classification of the population, and is not comparable with these bands.

HDL cholesterol — NCEP Adult Treatment Panel III

the ATP III classification for adults aged 20 and over, in mg/dL
BandFromPublished as
Low HDL cholesterolEverything below the first cut.
Between ATP III's two cut points (our label)40 mg/dLTable II.3-2, "ATP III Classification of HDL Cholesterol", names only two categories: <40 mg/dL Low HDL cholesterol and >=60 mg/dL High HDL cholesterol. The span between them is unnamed by the panel, so this label is ours and says only where the value sits.
High HDL cholesterol60 mg/dLTable II.3-2: >=60 mg/dL High HDL cholesterol.

This report named no assay, so this reads your result as a standardized clinical-laboratory HDL cholesterol. Direct HDL assays from different manufacturers disagree most in exactly the samples that are least ordinary, which is worth knowing near a cut point.

Edition
NIH Publication No. 02-5215 (September 2002), Table II.3-2, page II-10
Specimen
serum, reported in mg/dL
Assay
A direct HDL cholesterol assay run by a clinical laboratory. Standardization: CDC Lipid Standardization Program / Cholesterol Reference Method Laboratory Network.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has any US body republished an HDL-cholesterol classification with cut points other than 40 and 60 mg/dL, or has evidence of harm at very high HDL cholesterol moved a body to publish an upper category?
Attribution
Third Report of the National Cholesterol Education Program (NCEP) Expert Panel on Detection, Evaluation, and Treatment of High Blood Cholesterol in Adults (Adult Treatment Panel III), Final Report. NIH Publication No. 02-5215, September 2002. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The middle band's name is ours, not the panel's. ATP III names only two categories — low below 40 and high at or above 60 — and says nothing about the span between them, so the label there states a position and claims nothing else.
  • "High HDL cholesterol" is ATP III's category name, not a compliment. In the Copenhagen General Population Study all-cause mortality was lowest around 73 mg/dL in men and 93 mg/dL in women and rose again above that, roughly doubling in men at or above 116 mg/dL. This is one of the biomarkers where more is not better, and no band here draws a direction.
  • The cut point is sex-blind on purpose, and ATP III says so: "A categorical low HDL cholesterol should be defined as a level of <40 mg/dL, in both men and women." Other bodies disagree.
  • ATP III is an adult table, aged 20 and over. HDL cholesterol rises substantially through pregnancy and the paediatric cut points are not 40 and 60; this platform holds no pregnancy field to check either with.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The National Lipid Association classifies low HDL cholesterol as below 40 mg/dL in men and below 50 mg/dL in women, and publishes no high category at all — so a woman at 45 mg/dL sits between ATP III's cut points here and in the NLA's low category there.

Triglycerides — NCEP Adult Treatment Panel III

the ATP III classification for adults aged 20 and over, in mg/dL
BandFromPublished as
NormalEverything below the first cut.
Borderline high150 mg/dLTable II.3-1, "ATP III Classification of Serum Triglycerides": 150-199 mg/dL Borderline high.
High200 mg/dLTable II.3-1: 200-499 mg/dL High.
Very high500 mg/dLTable II.3-1: >=500 mg/dL Very high.

This report named no assay, so this reads your result as a standardized clinical-laboratory triglyceride measurement — the programme ATP III's numbers were set on.

Edition
NIH Publication No. 02-5215 (September 2002), Table II.3-1, page II-7
Specimen
serum, reported in mg/dL
Assay
An enzymatic triglyceride assay run by a clinical laboratory. Standardization: CDC Lipid Standardization Program.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"; the draw is recorded as fasting
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has any US body republished a triglyceride classification with cut points other than 150/200/500 mg/dL? The 2018 ACC/AHA guideline uses 175 mg/dL as a risk-enhancing factor, which is a risk modifier rather than a replacement classification.
Attribution
Third Report of the National Cholesterol Education Program (NCEP) Expert Panel on Detection, Evaluation, and Treatment of High Blood Cholesterol in Adults (Adult Treatment Panel III), Final Report. NIH Publication No. 02-5215, September 2002. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • This band is shown only on a draw the report recorded as fasting. Triglycerides rise for hours after a meal — that is what they do — so a non-fasting number placed on a fasting table would be a manufactured result. A draw whose fasting state the report did not state gets the printed refusal instead.
  • "Normal" is ATP III's own word for the lowest band. It is the panel's category name and not a statement that nothing else about your lipids matters.
  • ATP III is an adult table, aged 20 and over. Triglycerides roughly double through the third trimester of pregnancy and the paediatric cut points differ; this platform holds no pregnancy field.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The 2018 AHA/ACC cholesterol guideline treats fasting triglycerides at or above 175 mg/dL as a risk-enhancing factor, which lands inside ATP III's borderline-high band.

Non-HDL cholesterol — National Lipid Association

the National Lipid Association's classification for adults, in mg/dL
BandFromPublished as
DesirableEverything below the first cut.
Above desirable130 mg/dLTable 1: non-HDL-C 130-159 mg/dL, "Above desirable".
Borderline high160 mg/dLTable 1: non-HDL-C 160-189 mg/dL, "Borderline high".
High190 mg/dLTable 1: non-HDL-C 190-219 mg/dL, "High".
Very high220 mg/dLTable 1: non-HDL-C >=220 mg/dL, "Very high".

Non-HDL cholesterol is a subtraction the laboratory performs, so it inherits both of its inputs' assays. This report named no assay; this reads both as standardized clinical-laboratory measurements.

Edition
J Clin Lipidol 2015;9(2):129-169, Table 1, "Classifications of cholesterol and triglyceride levels in mg/dL"
Specimen
serum, reported in mg/dL
Assay
Total cholesterol minus HDL cholesterol, both from a standardized clinical-laboratory lipid panel. Standardization: CDC Lipid Standardization Program.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has the National Lipid Association, or any other US body, republished a non-HDL-cholesterol classification with cut points other than 130/160/190/220 mg/dL?
Attribution
Jacobson TA, Ito MK, Maki KC, et al. National Lipid Association Recommendations for Patient-Centered Management of Dyslipidemia: Part 1 — Full Report. J Clin Lipidol 2015;9(2):129-169, Table 1. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • Non-HDL cholesterol is not measured; it is your total cholesterol minus your HDL cholesterol. Anything that moves either of those moves this, and it carries both of their uncertainties.
  • The NLA's bands sit 30 mg/dL above its LDL-cholesterol bands by construction, because non-HDL cholesterol includes the cholesterol carried by triglyceride-rich particles as well.
  • This is an adult classification. It is not the paediatric table, and this platform holds no pregnancy field.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.

Apolipoprotein B — National Lipid Association

the National Lipid Association's risk-enhancing threshold for adults, in mg/dL
BandFromPublished as
Below the National Lipid Association's risk-enhancing thresholdEverything below the first cut.
At or above the risk-enhancing threshold130 mg/dL"An LDL-C level of 160 mg/dL and an apoB level of 130 mg/dL correspond to approximately the 90th percentiles of the untreated adult population (Table 1), both of which are risk-enhancing factors and warrant a clinician-patient discussion regarding initiation or intensification of LLT."

This report named no assay, so this reads your result as a clinical-laboratory apolipoprotein B calibrated to the WHO/IFCC reference material. The consensus itself notes the imprecision that remains: bias below about 4 mg/dL and a coefficient of variation usually between 5% and 6%, which near 130 mg/dL is roughly plus or minus 7 mg/dL.

Edition
NLA expert clinical consensus, published online 2024-09-04; doi:10.1016/j.jacl.2024.08.013
Specimen
serum, reported in mg/dL
Assay
An immunoassay for apolipoprotein B run by a clinical laboratory. Standardization: WHO/IFCC reference material SP3-07.
Applies only when
your report prints mg/dL; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has the National Lipid Association, or the ACC/AHA multisociety dyslipidemia guideline, published an apolipoprotein B risk-enhancing threshold other than 130 mg/dL?
Attribution
Soffer DE, Marston NA, Maki KC, et al. Role of apolipoprotein B in the clinical management of cardiovascular risk in adults: An expert clinical consensus from the National Lipid Association. J Clin Lipidol 2024;18(5):e647-e663. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • There are two bands and one line, because the consensus publishes one threshold. This is not a graded scale and the distance from the line is not a score.
  • The consensus's own percentile table (NHANES 2005-2016, n=12,696) puts the 90th percentile of untreated US adults at 125 mg/dL and the 95th at 137 mg/dL, so 130 mg/dL sits between them. The threshold is a rounded trigger for a conversation, not a percentile, and no percentile is printed here.
  • That percentile table is explicitly "adults 18-85 years of age not receiving lipid-modifying therapy". If you take a statin, ezetimibe or a PCSK9 inhibitor the population anchor behind this threshold does not describe you, and this platform holds no medication field.
  • Repeat draws move across this single line. With a coefficient of variation of 5-6%, an apolipoprotein B near 130 mg/dL can land on either side of it from one draw to the next without anything about you having changed.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The 2019 ESC/EAS guideline states apolipoprotein B goals by risk stratum — below 65 mg/dL at very high risk, below 80 at high risk, below 100 at moderate risk — which are treatment targets conditioned on risk rather than a population threshold, and are not comparable with this line.

Lipoprotein(a) — National Lipid Association

the National Lipid Association's risk categories for adults, in nmol/L
BandFromPublished as
Low riskEverything below the first cut.
Intermediate risk75 nmol/L"Individuals with Lp(a) levels <75 nmol/L (30 mg/dL) are considered low risk... individuals with Lp(a) levels between 75 and 125 nmol/L (30-50 mg/dL) are at intermediate risk."
High risk125 nmol/L"Individuals with Lp(a) levels >=125 nmol/L (50 mg/dL) are considered high risk."

This report does not name the assay that produced this number, and for Lp(a) that is not a detail. The National Lipid Association's own words are that "harmonization of values obtained from different assays, even those reporting in nmol/L, has yet to be undertaken": its 75 and 125 nmol/L boundaries are written for an immunochemical assay insensitive to apolipoprotein(a) isoform size, and an isoform-sensitive assay reporting in the same unit can land the same blood on the other side of a line. Upload a report that prints its method and this fills in.

Edition
NLA focused update, J Clin Lipidol 2024;18(3):e308-e319; doi:10.1016/j.jacl.2024.03.001
Specimen
serum, reported in nmol/L
Assay
An immunochemical Lp(a) assay reporting in nmol/L, calibrated against the WHO/IFCC secondary reference material. Standardization: WHO/IFCC SRM 2B — recommended, but the statement's own words are that "harmonization of values obtained from different assays, even those reporting in nmol/L, has yet to be undertaken".
Applies only when
your report prints nmol/L; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has the National Lipid Association or the ACC/AHA multisociety dyslipidemia guideline moved the 75 and 125 nmol/L risk boundaries, or has a global standardization regime for Lp(a) assays finally been established?
Attribution
Koschinsky ML, Bajaj A, Boffa MB, et al. A focused update to the 2019 NLA scientific statement on use of lipoprotein(a) in clinical practice. J Clin Lipidol 2024;18(3):e308-e319. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • Lp(a) assays are not globally standardized, and the NLA says so itself: "harmonization of values obtained from different assays, even those reporting in nmol/L, has yet to be undertaken". Near a boundary, a different laboratory could put the same blood in a different band.
  • The same statement carries a Class III recommendation against converting between units: "the use of a factor to convert Lp(a) values from mg/dL to nmol/L is not recommended". Nothing here converts anything — a result printed in any unit other than nmol/L gets the refusal instead of a band.
  • Lp(a) is largely genetically set and changes little over a lifetime, so a repeat draw is a check on the assay rather than a check on you. A band here is not something a month of anything will move.
  • Lp(a) distributions differ substantially by ancestry — median levels are higher in people of African ancestry and lower in people of East Asian ancestry — while these risk categories are published as one set. The NLA states they "apply across races and ethnicities", and this platform holds no ancestry field and would not use one.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The 2026 ACC/AHA multisociety dyslipidemia guideline keeps 125 nmol/L and adds a further tier at or above 250 nmol/L. That tier is not shown here, because these bands are one publication's and mixing two guidelines' ladders into one would leave the citation unable to say which line came from where.

High-sensitivity CRP — CDC and American Heart Association

the CDC/AHA relative-risk categories for adults, in mg/L
BandFromPublished as
LowEverything below the first cut.
Average1 mg/LTable 4: "Relative Risk Category and Average hs-CRP Level / Low <1 mg/L / Average 1.0 to 3.0 mg/L / High >3.0 mg/L".
Highabove 3 mg/LTable 4: "High >3.0 mg/L" — the source puts 3.0 itself in Average, so this floor is exclusive.
Not interpretable for cardiovascular riskabove 10 mg/L"If a level of >10 mg/L is identified, the test should be repeated and the patient examined for sources of infection or inflammation. That result of >10 mg/L should then be discarded." — 10.0 itself is still High, so this floor is exclusive.

This report named no assay, so this reads your result as a high-sensitivity assay of the class the statement considered — those with "acceptable precisions down to or below 0.3 mg/L". A standard CRP assay, which does not resolve below about 3-5 mg/L, cannot place anybody on this scale at all.

Edition
Circulation 2003;107:499-511, Table 4, "Laboratory Testing" item 3 (Class IIa, Level of Evidence B)
Specimen
serum, reported in mg/L
Assay
A high-sensitivity CRP immunoassay with acceptable precision at or below 0.3 mg/L, reported in mg/L. Standardization: WHO/IFCC CRP reference preparation; the statement's own Class I recommendation is that "hs-CRP results should be expressed as mg/L only".
Applies only when
your report prints mg/L; the laboratory resolved the value rather than printing a bound like ">60"; — and the published categories are defined on 2 draws, so one shows as provisional
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has the AHA, the ACC or the CDC published a replacement hs-CRP risk-category table? The 2025 ACC inflammation clinical statement discusses a 2 mg/L threshold and the 2026 ACC/AHA dyslipidemia guideline mentions selective hs-CRP use; if either publishes named categories in mg/L, this table is superseded.
Attribution
Pearson TA, Mensah GA, Alexander RW, et al. Markers of Inflammation and Cardiovascular Disease: Application to Clinical and Public Health Practice. A Statement for Healthcare Professionals From the Centers for Disease Control and Prevention and the American Heart Association. Circulation 2003;107:499-511, Table 4. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The top band is not the far end of a scale, it is an instruction to throw the result away. Above 10 mg/L the statement says to repeat the test and look for infection or inflammation, and to discard the number for cardiovascular purposes — so nothing above that line means "worse" here.
  • The published categories are for the AVERAGE of two draws, not one: the table's own column reads "Average hs-CRP Level", and the same table says "Measurement of markers should be done twice (averaging results), optimally two weeks apart". With a single draw on file this standing shows as provisional, and no averaging is done across draws taken months apart because that is not what the statement asked for.
  • hs-CRP is not specific to anything in particular: "Current assays are not specific for atherosclerosis and thus are not useful in the setting of other systemic inflammatory or infectious processes." A cold, a flare, an injury or a hard training week moves it.
  • The statement is explicit that this is not a screening test for the general population, and recommends against using it that way.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. A 2025 ACC clinical statement on inflammation discusses a lower boundary at 2 mg/L rather than 3 mg/L. It is not reproduced here because the figure could not be read from the publisher's own document, and a threshold nobody has verified is exactly what this table exists to not print.

Ferritin — World Health Organization

the WHO's ferritin thresholds for apparently healthy adults, in ng/mL
BandFromPublished as
Iron deficiencyEverything below the first cut.
Between the WHO's two thresholds (our label)15 ng/mLTable 1, Adults (20-59) and Older persons (60+) rows: "<15" for iron deficiency and ">150 females >200 males" for risk of iron overload. The WHO names no category for the span between them, so this label is ours and states only where the value sits.
Risk of iron overload150 ng/mL (female column) / 200 ng/mL (male column)Recommendation 1.3: ferritin "exceeding 150 ug/L in menstruating women and 200 ug/L in men and non-menstruating women who are otherwise healthy"; Table 1 states the same cut-offs as ">150 females" and ">200 males".

This report named no assay, so this reads your result as calibrated to the WHO international standard. That is an assumption and not a fact: the guideline RECOMMENDS such calibration — "Use of the WHO international reference standard of ferritin is recommended for calibration of all commercial kits" — which is a recommendation precisely because it is not universal.

Edition
2020 first edition, ISBN 978-92-4-000012-4; Table 1 and Recommendations 1.1-1.4
Specimen
serum, reported in ng/mL (the source states its thresholds in ug/L)
Assay
A ferritin immunoassay calibrated against the WHO international standard. Standardization: WHO 4th International Standard for Ferritin (Human, Recombinant), NIBSC 19/118, which replaced the 3rd IS (NIBSC 94/572) that the 2020 guideline text cites.
Applies only when
your report prints ng/mL; the laboratory resolved the value rather than printing a bound like ">60"; you have chosen a published column to read
Direction
U-shaped — both tails are adverse, and the bands name both.
Re-checked
2026-08-19, every 365 days. Has the WHO issued a revised ferritin guideline? Watch items: the 2025 Lancet Global Health physiological-threshold work and the American Society of Hematology's draft proposing 20 ng/mL, both of which are challengers rather than WHO editions as of this check.
Attribution
WHO guideline on use of ferritin concentrations to assess iron status in individuals and populations. Geneva: World Health Organization; 2020. ISBN 978-92-4-000012-4. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The upper threshold needs a published column and this account's answer chooses which one to read. The WHO states it two ways in the same document — Table 1 says "females" and "males", while Recommendation 1.3 says "menstruating women" and "men and non-menstruating women" — and this platform holds neither menstrual nor menopausal status. For a woman past menopause the WHO's own recommendation points at the 200 ug/L line even though the table's column says 150.
  • Ferritin rises with inflammation whatever your iron stores are doing, which is why the WHO publishes a separate, higher threshold of 70 ug/L for iron deficiency in the presence of infection or inflammation. A normal-looking ferritin during an illness can hide a real deficiency.
  • The whole upper band assumes you are, in the guideline's words, "otherwise healthy" and free of inflammation. This platform cannot check either.
  • The units differ in name only. The WHO prints these thresholds in ug/L and this report prints ng/mL; one ug/L is one ng/mL exactly, so no number has been converted.
  • This is the adult table. The WHO publishes a separate, lower deficiency threshold of 12 ug/L for children under five.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The American Gastroenterological Association's 2020 technical review favours a much higher deficiency threshold — around 45 ng/mL — in patients who are already anaemic, reporting far better sensitivity there (0.85) than at 15 ng/mL (0.59). So a ferritin of 30 ng/mL is not deficient on the WHO's line and is on the AGA's.

25-OH vitamin D — Institute of Medicine (now the National Academy of Medicine)

the Institute of Medicine's serum 25-hydroxyvitamin D conclusions for the United States and Canada, in ng/mL
BandFromPublished as
At risk of deficiencyEverything below the first cut.
Potentially at risk for inadequacy12 ng/mL"This committee's review of data suggests that persons are at risk of deficiency relative to bone health at serum 25OHD levels of below 30 nmol/L (12 ng/mL). Some, but not all, persons are potentially at risk for inadequacy at serum 25OHD levels between 30 and 50 nmol/L (12 and 20 ng/mL)."
At or above the IOM's sufficiency level20 ng/mL"Practically all persons are sufficient at serum 25OHD levels of at least 50 nmol/L (20 ng/mL). Serum 25OHD concentrations above 75 nmol/L (30 ng/mL) are not consistently associated with increased benefit."
May be reason for concern50 ng/mL"There may be reason for concern at serum 25OHD levels above 125 nmol/L (50 ng/mL)."

This report named no assay, so this reads your result as a standardized total 25-hydroxyvitamin D. Whether this particular laboratory's method is certified could not be established, and immunoassays and mass-spectrometry methods have historically disagreed by several ng/mL on the same sample.

Edition
2011 edition, ISBN 978-0-309-16394-1; the Summary's serum 25OHD conclusions
Specimen
serum, reported in ng/mL
Assay
A serum total 25-hydroxyvitamin D assay. Standardization: CDC Vitamin D Standardization-Certification Program / NIST-Ghent reference measurement procedure.
Applies only when
your report prints ng/mL; the laboratory resolved the value rather than printing a bound like ">60"
Direction
U-shaped — both tails are adverse, and the bands name both.
Re-checked
2026-08-19, every 365 days. Has the National Academy of Medicine issued a revised Dietary Reference Intake for vitamin D? The Endocrine Society's 2024 guideline is not one — it states it is "not meant to replace the current DRIs for vitamin D".
Attribution
Institute of Medicine. Dietary Reference Intakes for Calcium and Vitamin D. Washington, DC: The National Academies Press; 2011. Summary, "Conclusions about Vitamin D Deficiency in the United States and Canada". Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The third band's name is a position, not a verdict about you. The IOM's sentence is about a population — "practically all persons are sufficient" at that level — and it is not a statement that any individual above the line is fine.
  • More is not better here, which is why there is a fourth band. The IOM found levels above 30 ng/mL "not consistently associated with increased benefit" and named concern above 50 ng/mL; the NIH links potential adverse effects particularly above 60 ng/mL.
  • Every one of these numbers is the IOM's own parenthetical. The report is stated in nmol/L and in ng/mL side by side in the source sentence, so nothing here has been converted by this platform.
  • The IOM set these against BONE health specifically. Claims about vitamin D and other outcomes were not what these cut points were drawn for.
  • The Endocrine Society's 2024 guideline suggests against routine 25-hydroxyvitamin D testing in the general adult population at all, in age-stratified recommendations covering under 50, 50 to 74, 75 and over, and pregnancy. It does not replace these DRIs, and it does disagree about whether the test is worth doing.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The Endocrine Society's 2011 guideline used 30 ng/mL as its sufficiency target and called 20-29 ng/mL insufficiency, which would move most readers a band. Its 2024 guideline steps back from naming levels at all.

Hemoglobin — World Health Organization

the WHO's anaemia cutoffs for nonpregnant adults aged 15 to 65, in g/dL
BandFromPublished as
Severe anaemiaEverything below the first cut.
Moderate anaemia8 g/dLTable 3, "Adults, 15-65 years": Severe <80 g/L, Moderate 80-109 g/L. Read here as g/dL: 80 g/L is 8.0 g/dL.
Mild anaemia11 g/dLTable 3: Mild 110-119 g/L for nonpregnant women, 110-129 g/L for men. Read here as g/dL: 110 g/L is 11.0 g/dL.
No anaemia12 g/dL (female column) / 13 g/dL (male column)Table 2 and Table 3, "Adults, 15-65 years": no anaemia at >=120 g/L for nonpregnant women and >=130 g/L for men. Read here as g/dL: 12.0 and 13.0.

This report named no assay, so this reads your result as a venous whole-blood haemoglobin, which is what the WHO's cutoffs are set on. A capillary fingerstick result is not convertible to a venous one and should not be placed on this scale.

Edition
2024 first edition, published 5 March 2024, ISBN 978-92-4-008854-2; supersedes WHO/NMH/NHD/MNM/11.1 (2011)
Specimen
whole blood, reported in g/dL (the source states its thresholds in g/L)
Assay
A venous whole-blood haemoglobin measured on an automated haematology analyser. Standardization: ICSH haemiglobincyanide reference method, via the WHO international reference reagent.
Applies only when
your report prints g/dL; the laboratory resolved the value rather than printing a bound like ">60"; you have chosen a published column to read; your age is between 15 and 65, the range the table publishes
Direction
Position only — we draw no direction on this one.
Re-checked
2026-08-19, every 365 days. Has the WHO issued a haemoglobin-cutoff guideline later than the 2024 edition, or extended its adult rows beyond the 15-65 year range it currently publishes?
Attribution
WHO. Guideline on haemoglobin cutoffs to define anaemia in individuals and populations. Geneva: World Health Organization; 2024. ISBN 978-92-4-008854-2. Tables 2 and 3. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • The cutoffs ARE a fifth percentile, and the WHO says what that means: "The 5th percentile implies that 95% of healthy individuals would have a higher haemoglobin value, and 5% of healthy individuals would still have a lower haemoglobin value than the cutoff." One healthy person in twenty sits below the line by construction.
  • "No anaemia" is the top band and it is open-ended, because this guideline is about anaemia only. It says nothing whatever about a haemoglobin that is too HIGH, and a high value is a real finding that no band here will name.
  • The WHO publishes adult rows only for ages 15 to 65 and reports insufficient data above 65, so a draw outside that range gets the printed refusal rather than the nearest row.
  • Unadjusted values under-detect anaemia at altitude and in people who smoke — the WHO recommends adjusting downwards before comparing — so this band can read "No anaemia" for someone who has it. Nothing here is adjusted.
  • These are the nonpregnant rows. In pregnancy the WHO's cutoffs are lower and differ by trimester, and this platform holds no pregnancy field, so a pregnant reader would be shown the wrong row.
  • The WHO prints these in g/L and this report prints g/dL; 130 g/L is 13.0 g/dL — the same quantity with the decimal point moved, not a conversion.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. Beutler and Waalen's analysis of NHANES and the Scripps-Kaiser cohort put the lower limit at 13.7 g/dL for white men under 60 and 12.9 g/dL for black men under 60, i.e. above the WHO's single 13.0 g/dL line in one group and below it in another, on the same argument the WHO's own guideline makes about population-specific distributions.

Estimated GFR — KDIGO 2024 Clinical Practice Guideline for the Evaluation and Management of Chronic Kidney Disease

KDIGO's GFR categories for adults, in mL/min/1.73m2
BandFromPublished as
G5 — kidney failureEverything below the first cut.
G4 — severely decreased15 mL/min/1.73m2Table 2 | GFR categories in CKD: "G4 / 15-29 / Severely decreased".
G3b — moderately to severely decreased30 mL/min/1.73m2Table 2 | GFR categories in CKD: "G3b / 30-44 / Moderately to severely decreased".
G3a — mildly to moderately decreased45 mL/min/1.73m2Table 2 | GFR categories in CKD: "G3a / 45-59 / Mildly to moderately decreased".
G2 — mildly decreased60 mL/min/1.73m2Table 2 | GFR categories in CKD: "G2 / 60-89 / Mildly decreased".
G1 — normal or high90 mL/min/1.73m2Table 2 | GFR categories in CKD: "G1 / >=90 / Normal or high".

This report named no creatinine assay, so this reads the number as coming from an IDMS-traceable creatinine, which US clinical laboratories have been standardized to since the National Kidney Disease Education Program's recalibration. The equation carries the weight here rather than the assay: the 2021 CKD-EPI equation is defined on standardized creatinine and a laboratory running it on anything else would be misusing its own formula.

Edition
KDIGO 2024 Clinical Practice Guideline for the Evaluation and Management of CKD, Chapter 1, Table 2; Kidney Int. 2024;105(4S):S117-S314; released 2024-03
Specimen
serum, reported in mL/min/1.73m2 (the source states its thresholds in ml/min per 1.73 m2)
Assay
A serum creatinine measured by a method traceable to an isotope-dilution mass spectrometry reference measurement procedure. Standardization: IDMS traceability (NIST SRM 967).
Equation
CKD-EPI 2021. An eGFR is not measured, it is computed, and KDIGO grades a filtration rate estimated by a named equation. This report did not name one. The 2021 CKD-EPI equation, the 2009 CKD-EPI equation and MDRD disagree by several units near the boundary between G2 and G3a, so which equation produced this number decides which category it falls in — and nothing is compared until the report says which it was.
Applies only when
your report prints mL/min/1.73m2; the report names the estimating equation, and it is CKD-EPI 2021; the laboratory resolved the value rather than printing a bound like ">60"
Direction
Higher is better, as far as the literature supports.
Re-checked
2026-08-19, every 180 days. Has KDIGO published a CKD guideline edition later than 2024 in which the GFR categories are bounded at anything other than 15, 30, 45, 60 and 90 ml/min per 1.73 m2, or in which the recommended estimating equation for adults is no longer the 2021 CKD-EPI creatinine equation?
Attribution
Kidney Disease: Improving Global Outcomes (KDIGO) CKD Work Group. KDIGO 2024 Clinical Practice Guideline for the Evaluation and Management of Chronic Kidney Disease. Kidney Int. 2024;105(4S):S117-S314. Source
  • This is a position on somebody else's published scale, not a diagnosis and not medical advice. A guideline category is a population-level classification; what it means for you is a conversation with a clinician who can see the rest of your history.
  • A GFR CATEGORY IS NOT A DIAGNOSIS OF CHRONIC KIDNEY DISEASE. KDIGO defines CKD as abnormalities of kidney structure or function present for more than three months, and grades it by cause, GFR category AND albuminuria category. This number is one of those three and it is from one day: G2 on a single draw is not "stage 2 CKD", and G1 is not a clean bill of kidney health, because albuminuria can be abnormal at any GFR.
  • KDIGO's own words on the top two categories: "GFR categories G1 or G2 do not fulfill the criteria for CKD in the absence of evidence of kidney damage." That is why the highest band here is named "normal or high" rather than "best" — the category does not distinguish a healthy kidney from a hyperfiltering one, and a rising eGFR is not automatically a better one.
  • These categories are the ADULT ones. KDIGO publishes separate guidance for children, and the estimating equations differ; this platform holds no pregnancy field either, and eGFR is not validated in pregnancy.
  • An estimating equation is a population average applied to one person. It is least reliable at the extremes of muscle mass — a bodybuilder, an amputee, somebody with cirrhosis — because it reads creatinine, which muscle produces. A cystatin C-based estimate is what KDIGO suggests where the creatinine one is in doubt.
  • KDIGO prints this unit as "ml/min per 1.73 m2" and this report prints "mL/min/1.73m2". They are the same unit, spelled two ways, and no number was converted.
  • The H or L flag and the reference interval printed beside this result are BioReference's own, set on its own assay against its own reference population. They are shown as provenance and they never place you in a band — the cited guideline's cut points do, and the two can disagree.
  • Where bodies disagree. The categories themselves are not disputed: 15/30/45/60/90 ml/min per 1.73 m2 are KDIGO's and are reproduced by NICE (NG203) and by the US National Kidney Foundation. What is disputed is the estimate fed into them. NICE recommends CKD-EPI 2009 rather than the 2021 refit, so a UK laboratory and a US one can report different eGFRs for the same creatinine and land on different sides of the G2/G3a line — which is exactly why the equation has to be named before any of this is read.

The 70 measurements we will not rank

Grouped by the reason, because the reasons repeat and 70paragraphs would be unreadable. Each one links the publication that establishes the refusal, and each is printed on that measurement’s own page in the app, where the number it is about already is.

One laboratory's own interval (13)

The only line anybody draws here is a reference interval, computed by each laboratory on its own analyser against its own reference population. It is not comparable between laboratories, so it is not a ruler.

Held flat by the body, with both tails adverse (5)

The body holds these inside a narrow range on purpose. Both directions are abnormal, no published table names positions on both sides, and a scale would have to invent a direction that does not exist.

A category, not a measurement (16)

Dipstick grades, colours and microscopy ranges are ordinal readings. Whatever number they arrive as is an artefact of importing them, and ranking an artefact would be ranking our own encoding.

A ratio nobody publishes categories for (14)

The report computes these from two other results. No guideline body classifies the ratio itself, and building categories out of its components' categories would be a composite of ours — which this platform does not make.

Only interpretable with clinical context (8)

A number here means different things depending on facts the platform does not hold and must never infer. Placing one on a scale would drop exactly the context that makes it mean anything.

The threshold belongs to an assay the report does not name (9)

Published thresholds exist, but they are specific to a method, analyser or equation, and this report does not say which produced the number. Applying them anyway would be a guess wearing a citation.

Nobody publishes a threshold at all (5)

There is no external, absolute line to cite. That is the whole answer, and inventing one is the thing this page exists to promise we do not do.

How a standing moves

  • Weekly, not daily. Tiers are evaluated at week boundaries. The one thing that moves daily is the “how far to the next tier” line, which runs on your best right now — and says so, because it and the tier are two different clocks.
  • A crossing waits two weeks. A tier only changes once the new one has held for 14 days, in either direction. There is no countdown anywhere; you see the outcome, not the wait.
  • Not logging never lowers anything. A gap freezes a standing where it was. After 60 days it is marked stale, which says the number is old — it does not make the number smaller.
  • A drop is not an event. No notification, no badge removal, nothing to lose. Your dated best is kept; the chart tells the truth about now. A chess club goes further: it is absolute, so it does not move at all once earned, and it waits for nothing — a hold exists to stop a band flickering across a line, and something that only ever goes up cannot flicker.
  • A percentile is never a headline. It appears on the receipt sheet, with the pool named beside it, and nowhere else.

What we refuse to rank, and why

  • Sleep duration. Its relationship with health is U-shaped, so a high percentile would be a warning printed as a win. Bedtime consistency is the one axis of sleep that earned a comparison, and even that is an anchor with the percentile refusal printed on it — see above.
  • Books. Books per year is self-reported and easy to game by reading shorter books. Ranking it optimises the wrong life.
  • Resting heart rate. The only published percentile table is NHANES’s seated, awake clinical pulse — a different quantity that runs 10–15 bpm above a sleeping wrist reading, so placing yours on it would flatter everyone by construction. The largest wearable cohort publishes a mean and a range, not percentiles, and a resting heart rate never stands in for a VO₂max either way: beta blockers and illness both move it the “right” way. No VO₂max source, no cardio placement. (Nightly HRV once sat in this list; it left when a compatible-instrument percentile table was published — see its section above. This is what an amendment on new evidence looks like.)
  • Steps.Nobody publishes fine-grained step percentiles for a phone-and-watch instrument; the best available is a Fitbit cohort’s quartiles, and steps are the easiest number on this page to game by carrying your phone differently.
  • Total screen time. A total indicts the maps, the boarding passes and the camera, and ranking it low is an invitation to game the measurement. The distracting-apps anchor above compares only a set you chose yourself, against a metered average, with no percentile anywhere.
  • Any score of our own. No composite, no life score, no fitness age, and no Elo for anything we do not have opponents for. Chess and league ratings, when we show them, are the platforms’ own numbers passed through unchanged.
  • Percentiles in the doctor’s PDF. Handing a clinician a wearable estimate placed on a lab-registry table would be worse than useless. That document carries raw values, trends and provenance.

When this page is wrong

Reference data that ages silently becomes a quiet lie, so every table here carries the date of the snapshot it was reduced from, and so does every place the app shows a number from it. But a date is not a monitor. Each table also declares how it ages and how long it may go unattended, in the same file the numbers live in, and a scheduled job reads those declarations every week and files an issue against anything that has passed its own limit.

The declarations below are rendered from that file, not written here. A page describing a policy in prose is a page that can disagree with the policy — which is the failure this whole section is about.

The strength rulerdrifts

OpenPowerlifting republishes the bulk archive continuously as meets are entered, so the same reduction re-run today returns different percentiles. The snapshot date is the clock: past six months the tiers are quoting a pool that has moved on, and the fix is mechanical — re-run the reduction and commit the new snapshot.

Snapshot . We re-cut it at least every 180 days; past that, the monitor says so.

The accessory-strength rulersdrifts

FitnessVolt republishes the API cohorts and the web model in place, so a later fetch can change both the self-reported percentile rows and the modeled RDL thresholds. Refreshing is mechanical: rerun the vendor script, review the provenance and evidence labels, and commit the new dated snapshot.

Snapshot . We re-cut it at least every 180 days; past that, the monitor says so.

The chess distributiondrifts

Lichess recomputes this histogram weekly and publishes only the current one, so our copy starts diverging the day after it is scraped. Re-fetching costs one page load, which is why the threshold here is tighter than the strength ruler's.

Snapshot . We re-cut it at least every 90 days; past that, the monitor says so.

The cardio rulersuperseded

This is a paper, not a feed. The 2022 percentiles do not change, so the snapshot date being years ago is not decay, and reporting it as decay would be a false alarm that trains everyone to ignore the real one. It goes stale exactly once — when the registry publishes a newer edition, as it did in 2015, 2017, 2019 and 2022 — and no age threshold of ours can detect that, because the signal is on a DOI a human has to read. So the monitor asks on a yearly cadence, comfortably inside the shortest gap between editions, and the clock it runs from is the day somebody last looked rather than the day the paper was published.

Snapshot , last re-checked . Every 365 days the monitor asks a person the one question a clock cannot answer — Has the FRIEND registry published a cardiorespiratory-fitness reference standard newer than the 2022 edition (Kaminsky et al., Mayo Clin Proc 2022;97(2):285-293)?

The HRV rulersuperseded

A paper does not drift: the 2020 quartiles do not change, so snapshot age is not decay. It goes stale exactly once — when a larger compatible-instrument reference is published — and only a human reading the literature can see that, so the monitor asks yearly.

Snapshot , last re-checked . Every 365 days the monitor asks a person the one question a clock cannot answer — Has a larger consumer-PPG, sleep-window RMSSD percentile reference been published that supersedes Natarajan 2020 — in particular, a peer-reviewed Apple Heart & Movement Study or All of Us HRV reference table with age/sex percentiles?

The steps rulersuperseded

A paper does not drift: the published quartiles do not change, so snapshot age is not decay. It goes stale exactly once — when a larger compatible-instrument reference is published, or when All of Us publishes a newer characterization of the same growing dataset — and only a human reading the literature can see that, so the monitor asks yearly.

Snapshot , last re-checked . Every 365 days the monitor asks a person the one question a clock cannot answer — Has a newer or larger consumer-wearable daily-steps percentile reference been published that supersedes Patten 2026 — in particular a newer All of Us wearables characterization, or a peer-reviewed Apple Heart & Movement Study steps table with sex/age percentiles?

The League distributiondrifts

The live distribution refreshes continuously and moves with season resets and Riot recalibrations (the 2026-03-02 MMR-to-rank recalibration reshaped Iron in a week, which is also why nothing older than March 2026 may replace this table). 210 days spans a ranked split with slack; the fix is re-transcribing a newer dated capture by hand — the source's terms rule out an automated pull.

Snapshot . We re-cut it at least every 210 days; past that, the monitor says so.

The laboratory population rulersuperseded

This is a frozen historical survey distribution. It changes only when NCHS releases a newer complete, method-compatible cycle or corrects a pinned public-use file.

Snapshot , last re-checked . Every 365 days the monitor asks a person the one question a clock cannot answer — Has NCHS released a newer complete NHANES cycle containing all ten compatible laboratory inputs and final survey weights, or corrected one of the pinned 2017-March 2020 files?

The sleep anchorsuperseded

A paper does not drift: the 2023 Oura numbers are the 2023 Oura numbers forever. It goes stale only when somebody publishes a better pool — larger, percentile-gridded, or closer to this product's users — which the Apple Heart & Movement Study preprint may become. So the monitor asks on a cadence rather than asserting an age.

Snapshot , last re-checked . Every 365 days the monitor asks a person the one question a clock cannot answer — Has a newer or larger consumer-wearable study published sleep-timing-variability norms in a peer-reviewed venue — in particular, has the Apple Heart & Movement Study sleep-consistency analysis (a 2026 preprint at the URL below, >81,000 Apple Watch wearers) been published with distributions?

The screen anchordrifts

Online Nation is an annual series: Ofcom republishes the same measurements every December from the following May's Ipsos iris fieldwork, so the anchor decays from the day it was keyed and the fix is mechanical — re-key the new edition's figure and commit. 430 days is the annual cadence plus slack for a late edition.

Snapshot . We re-cut it at least every 430 days; past that, the monitor says so.

If you think a number in the app disagrees with what this page says, that is a bug and we want it: the app shows the same citation on the sheet the number appears on.