Metadata-Version: 2.4
Name: association-py
Version: 5.0.0
Summary: ESPN NBA stats: resumable fetch -> compact Parquet -> DuckDB warehouse -> local-LLM natural-language query
Author-email: Jeff Knupp <jeff@jeffknupp.com>
License-Expression: BSD-3-Clause
Project-URL: Homepage, https://github.com/jeffknupp/association
Project-URL: Documentation, https://association.readthedocs.io/en/latest/
Project-URL: Source, https://github.com/jeffknupp/association
Project-URL: Changelog, https://github.com/jeffknupp/association/blob/master/CHANGES.md
Project-URL: Issues, https://github.com/jeffknupp/association/issues
Keywords: nba,basketball,espn,duckdb,parquet,ollama,llm,statistics
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Typing :: Typed
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: boto3>=1.34
Requires-Dist: botocore>=1.34
Requires-Dist: click>=8.1
Requires-Dist: curl_cffi>=0.7
Requires-Dist: duckdb>=1.0
Requires-Dist: ollama>=0.6.2
Requires-Dist: pyarrow>=15.0
Requires-Dist: rapidfuzz>=3.0
Requires-Dist: tqdm>=4.66
Provides-Extra: dev
Requires-Dist: codespell[toml]>=2.4; extra == "dev"
Requires-Dist: deptry>=0.23; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Requires-Dist: import-linter>=2.1; extra == "dev"
Requires-Dist: mypy>=2.3.1; extra == "dev"
Requires-Dist: pre-commit>=4.6.2; extra == "dev"
Requires-Dist: pyright>=1.1.390; extra == "dev"
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-xdist>=3.8.0; extra == "dev"
Requires-Dist: ruff>=0.16.5; extra == "dev"
Requires-Dist: shellcheck-py>=0.11; extra == "dev"
Requires-Dist: vulture>=2.14; extra == "dev"
Requires-Dist: xenon>=0.9; extra == "dev"
Provides-Extra: docs
Requires-Dist: furo>=2024.8.6; extra == "docs"
Requires-Dist: myst-parser>=4.0; extra == "docs"
Requires-Dist: packaging>=24; extra == "docs"
Requires-Dist: sphinx<9,>=8.1; extra == "docs"
Requires-Dist: sphinx-autodoc-typehints>=2.5; extra == "docs"
Requires-Dist: sphinx-click>=6.0; extra == "docs"
Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
Provides-Extra: web
Requires-Dist: fastapi>=0.115; extra == "web"
Requires-Dist: pydantic>=2; extra == "web"
Requires-Dist: uvicorn>=0.30; extra == "web"
Dynamic: license-file

# association

A local-first NBA stats pipeline: fetch from ESPN's stats APIs into Parquet,
build a DuckDB analytics warehouse from it, and ask questions about it in
plain English — answered by a local LLM via [Ollama](https://ollama.com), with
no cloud API calls anywhere.

## Ask it something

```bash
uv tool install 'association-py[web] @ git+https://github.com/jeffknupp/association@v5.0.0'
association web
# association is serving at http://127.0.0.1:40525  (ctrl-c to stop)
```

<img src="docs/_static/web_ui_example.png" alt="The association web interface: a top-5 scoring leaderboard rendered as a table, and a four-season 3PT% history rendered as a sparkline over a table, each labeled with the template that produced it and how long it took" width="700">

No default port — it binds a free one and prints the URL for your terminal to
linkify. Common question shapes are rendered from structured data: leaderboards
and game logs as tables, a multi-season history as a sparkline, a team's record
as a card. Anything without a renderer still answers, in the same text the CLI
prints — and every rendered answer keeps that text one click away.

Every answer says whether a **template** produced it or the question was
**refused** for want of a reading, because that is the most useful thing you
can know about how far to trust it. Each message is a new question; there is
no conversation memory.

Shot charts and NetPoints fingerprints draw in the conversation, served from
the same directory the CLI writes to — so a chart made at the terminal opens in
the browser, and the CLI still writes the identical standalone file:

<img src="docs/_static/web_chart_dark_example.png" alt="The association web interface in dark mode, showing a NetPoints fingerprint radar for Shai Gilgeous-Alexander drawn inline in the conversation, its lower edge fading out to show there is more of it below, with a link to open the full standalone file underneath" width="640">

The page follows your system theme, and so do the charts — both screenshots
above are the same build, one light and one dark.

## Or from the command line

```bash
association query "who led the league in assists this season?"
# Nikola Jokic led the league in assists per game in the 2026 regular season
# (minimum 20 games), at 10.7.

association query "how many times did the 76ers play the Celtics this season?"
# The Philadelphia 76ers and the Boston Celtics met 4 times in the 2026 regular
# season, splitting them 2-2.

association query "Luka Doncic vs Shai Gilgeous-Alexander this season"
# Luka Doncic vs Shai Gilgeous-Alexander, 2026 regular season:
#                          Luka Doncic  Shai Gilgeous-Alexander
# games                             64                       68
# points                          33.5                     31.1
# rebounds                         7.7                      4.3
# assists                          8.3                      6.6
# ...
# net pts/100                    +6.55                    +9.91

association query "top 5 rebounders on the Lakers in the playoffs"
# Deandre Ayton led the Los Angeles Lakers in rebounds per game in the 2026
# postseason (minimum 5 games), at 9.6. Next: LeBron James (6.7), Austin Reaves (4.0), Rui Hachimura (4.0), Luke Kennard (3.5).

association query "Steph Curry's 3pt percentage over the past 4 seasons"
# Stephen Curry, 3PT% by regular season, 2023-2026 (most recent first):
# season   G  3PT%  3PM  3PA
#   2026  43  39.3  190  484
#   2025  70  39.7  311  784
#   2024  74  40.8  357  876
#   2023  56  42.7  273  639

association query "who had the most assists in a single game this season?"
# Ryan Nembhard had the most assists in a single game in the 2026 regular season:
# 23, on 2026-04-12 vs CHI. Next: Isaiah Collier (22), Josh Giddey (19).
```

Questions like these are answered in about a second. Anything outside that
set is refused, and the refusal says what is missing - an intent nothing
answers, a narrowing the data cannot honor, a season a table does not reach.
No model writes SQL here: the one that used to, for questions no template
covered, answered one in 23 and is gone.

The templates cover the shapes real NBA stat questions take, measured against
StatMuse's live query feed. For example:

- **Against one opponent**: "jaylen brown last 8 games vs pistons", "evan mobley avg against bucks" (the averages, then the meetings behind them)
- **Games kept under or over a line, one game of a series, a season by its place in a career**: "Sga games with under 14 fta", "paul reed gamelog with 25 minutes", "maxey's stats for game 4 against the knicks", "how many 40+ point games does lebron have in his 18th season"
- **Splits**: "Nikola Jokic home and away splits", "Joe Ingles stats when starting vs coming off the bench"
- **With or without a teammate**: "Celtics record without Tatum"
- **A record under a condition**: "Sixers record when Embiid scores 30 points"
- **Two players' meetings**: "lebron vs kawhi head to head"
- **A quarter or half**: "How many points did Jokic score in the 3rd quarter?", "76ers 4th quarter scoring against Boston"
- **Streaks**: "Lakers longest winning streak this season"
- **Careers**: "career points leaders", "Jokic career averages"
- **Team rankings, lines and outlook**: "which team scores the most points per game", "Knicks home record", "what are the celtics playoff odds"

A question that narrows to something no template can honor, such as "on back-to-backs",
"in the Finals" or "since returning from injury", is not answered for everything instead:
it is refused, naming the narrowing. A question about a season a table does not reach,
such as a 1996 shot chart, is refused, and the answer says why.

Some questions render a chart instead of text:

```bash
association query "plot Stephen Curry's shot chart from his last game this season"
# Rendered shot chart for Stephen Curry (7/14 made, 50.0%) to query_output/shotchart_stephen_curry_401811054.html
```

<img src="docs/_static/curry_shotchart_example.png" alt="Half-court shot chart for Stephen Curry's last regular-season game, showing makes and misses plotted by location" width="380">

That needs play-by-play data pulled first (`--include-pbp`), and writes a
self-contained, theme-aware HTML/SVG file — open it in a browser.

A player's NetPoints "fingerprint" — how they add value, across 20 play-type
skills — renders the same way, and needs no play-by-play:

```bash
association query "plot Shai Gilgeous-Alexander's fingerprint for 2025"
# Rendered NetPoints fingerprint (total) for Shai Gilgeous-Alexander (2025 season, percentile scale) to query_output/fingerprint_shai_gilgeous_alexander_2025_total_percentile.html
```

<img src="docs/_static/sga_fingerprint_example.png" alt="NetPoints fingerprint radar for Shai Gilgeous-Alexander's 2025 season: 20 play-type skills grouped into scoring, shot types, creation, rebounding and defense, each plotted as a percentile of the league, with a table of the same numbers underneath" width="520">

Naming two players draws both on the same axes and shades each skill to
whoever leads it.

Asking about one game ("steph curry's fingerprint from his last game") draws
that game in its own net points rather than a per-100 rate. It needs the
per-game NetPoints files, which are an opt-in pull
(`--include-net-points-daily`).

## Features

- **Resumable, rate-limited fetch** from ESPN's stats APIs — checkpointed per
  game and season, safe to interrupt, cheap to re-run
- **A local DuckDB warehouse** built from the Parquet on disk, rebuildable any
  time without touching the network
- **Data coverage auditing** — cross-check what's on disk against what ESPN
  reports, optionally live
- **Natural-language queries with no cloud calls** — everything runs against
  a local Ollama model
- **Fast, deterministic answers** for common question shapes (rankings by
  any stat or over a career, player and team lines, splits, with/without a
  teammate, streaks, head-to-head, comparisons, game logs, shot charts,
  fingerprints, multi-season history, …), and a refusal naming what is
  missing for anything else
- **Computed advanced stats** ESPN's API doesn't expose directly — true
  shooting %, effective FG%, usage rate, game score
- **NetPoints ratings** from ESPN Analytics — player/team ratings plus a
  per-play-type "fingerprint" breakdown, as numbers or as a radar plot
- **A local web interface** (`association web`) — the same answers in a
  chat-shaped page, rendered as tables, sparklines and cards per question
  shape, with progress streamed while a slow question runs
- **A full trace of every query** — command, tool calls, timing, and answer —
  written to disk regardless of verbosity
- **Shell completion** for bash, zsh, and fish

## Setup

```bash
uv tool install git+https://github.com/jeffknupp/association@v5.0.0
brew install ollama               # or see https://ollama.com/download
ollama serve &
ollama pull qwen2.5:3b            # the normalizer - required, ~1.9GB
```

One model: it copies the names and the stat out of the question for the
parser, which reads everything else from the words.

> **Note** — `pip install association-py` is the install command from the
> next release on: the distribution is named `association-py` on PyPI, since
> PyPI refuses the bare name though nobody holds it (the import package, the
> `association` command and this repository keep it). Nothing is on PyPI yet,
> so install from the tag as above for now. Releases also attach their wheel
> and sdist to the
> [releases page](https://github.com/jeffknupp/association/releases). This note
> goes away once the first version is up.

To work on `association` itself, clone the repo and sync it instead (see
[Development](#development)).

### Hardware

Recommended: 8GB of RAM. The normalizer's model uses about 2GB once loaded,
leaving headroom for DuckDB and the OS. No GPU is required.

### Shell completion

Tab-complete subcommands, options, and `--log-level`'s choices — generated
directly from the CLI's own command definitions, so there's nothing to keep in
sync by hand.

```bash
# bash
echo 'source /path/to/association/completions/association.bash' >> ~/.bashrc
# zsh
echo 'source /path/to/association/completions/association.zsh' >> ~/.zshrc
# fish
cp completions/association.fish ~/.config/fish/completions/
```

Or generate it fresh, which picks up any future CLI changes automatically:

```bash
eval "$(_ASSOCIATION_COMPLETE=bash_source association)"   # bash, in ~/.bashrc
eval "$(_ASSOCIATION_COMPLETE=zsh_source association)"    # zsh, in ~/.zshrc
_ASSOCIATION_COMPLETE=fish_source association | source    # fish, in ~/.config/fish/config.fish
```

## Documentation

Full documentation — architecture, a command reference, usage recipes, data
source notes, and the complete API — is at
[association.readthedocs.io](https://association.readthedocs.io/en/latest/),
or build it locally:

```bash
uv sync --extra docs
scripts/build_docs.sh          # docs/_build/html/index.html
```

## Data

Fetches teams, games/box scores, standings, player and team season stats,
ESPN's Basketball Power Index, and optionally play-by-play, shot charts, and
win probability. It also pulls NetPoints — ESPN Analytics' advanced
player/team rating — from a separate, unauthenticated source. See
[Data sources](https://association.readthedocs.io/en/latest/data-sources.html)
for exactly what's fetched from where, and
[Architecture](https://association.readthedocs.io/en/latest/architecture.html)
for how the warehouse and query engine are built from it.

## Project layout

```
src/association/
  cli/              the `association` command (commands.py) and the default warehouse/data paths (paths.py)
  nba/              what fetch and query both need to know: seasons and Eastern dates, franchise names by season, each table's coverage floor, NetPoints categories
  fetch/            client, endpoints, parse, storage, pipeline, warehouse
    repairs/        load-time repairs of ESPN's faults, and the filtered game list and rebuilt box lines
  check/            data coverage report, cross-checked live against ESPN
  query/            the reader (parse.py, what the model is told in normalizer.py, and the stages in router.py), query templates, entity resolution, leaderboard, conditions (splits, with/without, streaks), team metrics, shot chart, fingerprint, prompt/knowledge base, tools, court and radar renderers, agent loop
  web/              the local web interface: HTTP API, one-at-a-time runner, single-page app
scripts/
  backfill_markers.sh   re-derive completion markers for data fetched before they existed
  check_coverage.py     verify each table's coverage floor against a built warehouse
  check_nicknames.py    verify the player nickname table against a built warehouse
  check_net_points_games.py  verify which game each per-game NetPoints row lands on
completions/          generated bash/zsh/fish shell completion scripts
tests/              pytest, one file per source module
.history/           one command/trace/timing log per question, from query and web (gitignored)
```

## Development

```bash
uv sync --extra dev --extra docs --extra web
pre-commit install     # one-time, wires the git hook
pytest -q
```

All three extras are needed even just to run the tests and gates: the docs
build is one of the pre-commit hooks, and `tests/web/` imports `fastapi`
directly with no skip guard, so it fails to collect without the `web` extra
installed.

`ruff` and `mypy` run as pre-commit hooks, along with docstring coverage and
type completeness checks on `src/`. The same checks run in CI on push and pull
request, alongside the docs build. Any commit touching `src/` must also update
[CHANGES.md](https://github.com/jeffknupp/association/blob/master/CHANGES.md),
enforced by a pre-commit hook.

## Known limitations

- ESPN's stats API is undocumented and unofficial — endpoints or shapes can
  change without notice.
- ESPN's own Real Plus-Minus (RPM) isn't available at a stable JSON endpoint —
  NetPoints (see above) is its successor and *is* included. PER, Win Shares,
  BPM, and VORP are not included; see the computed advanced stats notes in the
  docs for why.
- NetPoints only has data back to the 2018-19 season — nothing earlier exists
  on their side.
- `net_points_team` (season-level) only ever reflects the current season;
  `net_points_team_game` (per-game, `--include-net-points-daily`) has full
  history instead.
- `net_points_player_game` and `net_points_player_game_fingerprint` (both
  per-game, `--include-net-points-daily`) match players by exact display-name
  text, so spelling differences between sources can leave a real player's game
  rows unmatched rather than wrongly matched.
- **Tables start in different seasons.** Standings from 1987-88; playoffs from
  1989; box scores and regular-season games from 1993-94; play-by-play from
  2001-02 (about half of that season); shot charts from 2001-02 (only part of
  2001-02 and 2002-03); ESPN's power index from 2016-17; win probability from
  2017-18; NetPoints from 2018-19. These are ESPN's gaps, and no pull fills
  them. A question below a table's floor is refused with the reason.
- **The 2001 playoffs are missing ten games**, Games 1-4 of the Lakers-76ers
  Final among them, and ESPN's archive has them nowhere. A 2001 playoff answer
  carries a note saying which series are short.
- **Careers.** Career answers cover only careers that reached 1993-94, because
  players are found through box scores: Kareem Abdul-Jabbar, Larry Bird and
  Julius Erving are not in the warehouse, and a career list says it is not
  all-time.
- **Empty box scores.** Every game Chicago or New Orleans played from 2012-13
  to 2017-18, playoffs included, has an empty box score for both teams in
  ESPN's data, apart from two games. Per-game and per-condition answers (game
  logs, single-game highs, threshold counts, splits, streaks, with/without,
  head-to-head) rebuild the missing line from play-by-play for points,
  rebounds, assists, steals, blocks, and made field goals and free throws, and
  say the figure is rebuilt; turnovers and fouls are refused rather than
  guessed. Season totals summed directly from the box scores are still short
  (about 87% of ESPN's own totals for those team-seasons) since a rebuilt
  season aggregate is right only about half the time - `player_season_stats`,
  a separate ESPN endpoint, is unaffected.
- **No round or conference data.** Nothing records a playoff round or a team's
  conference.
- `data check --live` cross-checks are opt-in and can be slow for seasons
  without a local completion marker yet — `pull` first to build those up.
