Research paper
Age and production, lightly
Why the NBA aging-curve check returned not_buildable, and what a cross-sectional profile would not have shown
Abstract
An aging curve plots average production against player age. The aging_curve_lite module was asked to build one from the CourtVision NBA corpus and returned a status of not_buildable. Its gate needs two things at once: an age or birthdate source, and at least three season snapshots. n_seasons is 3, so the season condition holds; age_source_found is null, so the other does not, and because the gate is a logical AND the module computed no curve. The corpus is not thin: player_metric_landscape.json records 77,744 player-game rows, 807 unique players and 3,611 unique games across the 2023-24, 2024-25 and 2025-26 seasons, with all 17 core box columns at 0 percent missing, and grades other metric families on that table supported while grading its aging_curve row not_supported for the same reason. What the scan establishes is narrow: the top-level parquet files it could read in one directory carry no column name matching its birthdate/age/dob pattern, which is not proof that no age information exists under another name. No age source was found, so no age curve was computed, and no age bin, per-age production measure or peak-age estimate is reported here.
1 Question
An aging curve groups player-seasons by age, averages a production measure inside each group, and plots the result. It shows up constantly in public sports analytics because it looks like it answers the question every team and every fan asks, which is when a player is likely to be at his best. aging_curve_lite is not a curve-fitting routine but a buildability check: it looks for the two ingredients a curve needs -- a label tying a player-season to an age, and enough season snapshots for an age axis to have more than a couple of points -- and reports whether both are present before computing anything.
2 Data and definitions
The underlying table is data/domains/basketball_nba/player_boxscores.parquet. A companion module built from the same table, player_metric_landscape.json, records its coverage directly: 77,744 player-game rows (coverage.rows), 807 unique players (coverage.unique_players) and 3,611 unique games (coverage.unique_games), across the three seasons in coverage.seasons -- 2023-24, 2024-25 and 2025-26 -- with all 17 core box columns at 0 percent missing (coverage.box_col_missing_pct). Its own note states the discipline this paper follows: support means raw input-column coverage, never a claim that a branded metric has been reproduced.
aging_curve_lite.json carries the verdict in four fields. status is not_buildable, age_source_found is null, n_seasons is 3, and seasons_available lists the three snapshots the box-score table spans.
3 Method
The gate has two conditions and both must hold for the module to report buildable: an age or birthdate source column must be found, and at least three season snapshots must exist. The season condition is satisfied (n_seasons is 3); the age-source condition is not (age_source_found is null). Because the gate is a logical AND, one satisfied and one unsatisfied condition still produce not_buildable, with no partial-credit path to a coarse or provisional curve.
buildable = (age_source_found is not null) AND (n_seasons >= 3)
What the age scan does is narrow and worth stating precisely. It lists the top-level parquet files in data/domains/basketball_nba/, opens each one it can read, and tests that file's column names against a birthdate/age/dob token pattern; the first match would have been recorded as age_source_found. It does not descend into subdirectories, it moves past a file it cannot open, and a column name is the only thing it inspects. A null result says that no readable top-level table in that directory carries a column named in a way the pattern recognises -- not that the corpus was semantically audited, and not that age is unavailable under another name or from an external roster source.
No proxy for age was substituted and none is scored here. Seasons of professional experience, or a draft-year-to-season gap, are stand-ins other analysts use; aging_curve_lite attempts neither, and this paper does not construct one, because a proxy carries its own bias that would need separate accounting rather than being folded quietly into a headline number.
4 Results
The table restates the gate as an observed-versus-required comparison, using only fields the artifact records.
| Requirement | Threshold | Observed |
|---|---|---|
| Age or birthdate column in a readable top-level table under data/domains/basketball_nba/ | at least one matching column name | none matched; age_source_found = null |
| Season snapshots of box scores available | 3 or more | 3 (2023-24, 2024-25, 2025-26); n_seasons = 3 |
| Overall status | both rows above true | not_buildable |
Source: aging_curve_lite.json, fields status, age_source_found, seasons_available, n_seasons.
The season count clears its own bar, and the corpus is not thin in games or players: player_metric_landscape.json grades several other metric families on the same table as supported, including a box-only value index and a per-36 rate cast. Its aging_curve row is graded not_supported, with the reason given verbatim as no birthdate/age column and only 3 season snapshots. Both modules read the same directory and report the same absence, so their agreement adds no corroboration.
5 What a built curve would not have shown
A note on what a built curve would have meant, because the topic invites one specific misreading. A cross-sectional age profile groups whichever players happen to be a given age in the corpus and averages within each group; a within-player trajectory instead follows one player's own production as that player ages. Nothing in the cross-sectional construction requires a single player to contribute to two age points, and survivorship follows: who is still in the league at 34 is not a random sample of those who were 24 ten years earlier, and the players who did not last are absent from the 34-year-old group rather than averaged in low. A peak-age estimate from such a profile locates where the pooled sample averages highest, a fact about who occupied each age group, not a forecast for any individual.
6 Robustness and what would falsify this
- The verdict is cheap to falsify: add a roster or biographical table carrying a birthdate or age column to data/domains/basketball_nba/, re-run the scan, and age_source_found flips from null to that file's name while status flips to buildable, because the season condition already holds.
- The pattern is token-bounded, so a column named usagepercentage does not false-positively satisfy the age condition; the same boundary means a column carrying age under an unrecognised name would be missed, which is why the null is reported as the result of a column-name scan rather than an audit of what the corpus means.
7 Limitations
- This is a buildability check, not an aging analysis; it says nothing about the shape, direction or size of any real NBA aging effect.
- The scan covers only the readable top-level parquet files in one directory; no subdirectory or external roster source was queried.
- Even with an age column added, the corpus would still pool only the 3 season snapshots in seasons_available, which the artifact treats as thin for delta or GAM-style estimation.
- The cross-sectional and survivorship discussion describes general properties of this class of measurement and is not estimated from CourtVision data.
- aging_curve_lite.json and player_metric_landscape.json record the same absent column in the same directory, so their agreement is one fact checked twice, not independent corroboration.
8 How to read this on the site
The module page for aging_curve_lite carries the status directly: read status first, then age_source_found and seasons_available. There is no chart underneath it, because the status is the published artifact rather than a summary of one. player_metric_landscape's page grades nine metric families against the same input-column coverage discipline.
Evidence
- aging_curve_lite.jsondate not publishedSource path: /analytics/m/aging_curve_lite/
Evidence field inventory (6 paths)
- status
- seasons_available
- n_seasons
- age_source_found
- why
- source
- player_metric_landscape.jsondate not publishedSource path: /analytics/m/player_metric_landscape/
Evidence field inventory (8 paths)
- note
- coverage.rows
- coverage.unique_players
- coverage.unique_games
- coverage.seasons
- coverage.box_col_missing_pct
- metric_families.aging_curve.verdict
- metric_families.aging_curve.why