---
title: "kycourt-colab — Kentucky Appellate Court Corpus Pipeline (project notes)"
status: "active"
folder: "repo root (kycourt-colab)"
tags: [kycourt, pipeline, colab, project-notes]
created: "2026-08-12"
updated: "2026-08-12"
version: "1.1.0"
---

# kycourt-colab — Kentucky Appellate Court Corpus Pipeline

## Overview

A single **generated Google Colab notebook** (`ky_court_pipeline.ipynb`) that
restores, discovers, downloads, extracts, and verifies the full Kentucky
appellate court document corpus (~**98.7k documents**). It is built to survive
Colab's ephemeral runtimes: it auto-resumes after disconnects, is idempotent,
and mirrors all state to Google Drive so nothing is lost between sessions.

**Remote:** https://github.com/stateofshit/kycourt-colab (private)
**Local worktree:** `/home/user/00-incoming/kycourt-colab`

## What problem it solves

A batch archival crawl of public court documents over a ~10-hour-per-session
horizon, on infrastructure (Colab) that kills your process mid-run. Requirements
that fall out of that:

- Every session must pick up exactly where the last one stopped.
- Re-running must be safe (idempotent) — no double-downloads, no data loss.
- State must live somewhere durable (Drive), because the runtime is throwaway.
- The crawl must be polite to a public API (paced, backoff, retries) and
  deduplicate aggressively (content-addressed storage).

## Architecture

The notebook is **not hand-written** — its cells are inlined from real Python
modules in `notebook_builder/cells/` (one module per cell, `c00` → `c90`), and
regenerated with `notebook_builder/build.py`. This means the pipeline is
version-controlled, unit-testable Python that *just happens to be shipped as* a
Colab notebook.

Two shared abstractions carry every stage:

- **SQLite DB** (`ky_court.sqlite`) — source of truth: `documents`,
  `discovery_runs`, `extraction_log`, `kv`. Opened in **WAL mode** with a
  30s `busy_timeout` so a background reader can snapshot it while the writer
  commits (see concurrency below).
- **`Store` abstraction** — Google Drive in Colab, local filesystem in tests;
  exposes `exists / read_file / write_bytes / upload_file / list_dir`. All
  stages talk to it, never to Drive directly.

**Stage order:** `restore → discover → backfill → download → extract → sync/verify`.

## The six stages (what each actually does)

### 1. Restore — `c40_restore.py`
Rehydrate a fresh/empty workspace from the newest `ky_court-WIP-resume-*.tar.gz`.
Only acts when no DB exists; skips otherwise. If a stale workspace dir is
present it is **moved aside to `-pre-restore` (renamed, never deleted)** before
extracting — the "never destroys data" guarantee. Extracts to temp, then moves
into place.

### 2. Discover — `c50_discover.py`
Enumerate the corpus by searching the court API so we know *what exists*.
Builds search shards (`YYYY-CA`, `YYYY-SC` per year; ceiling-hit shards get
sub-sharded `YYYY-CA-0..9`). Pages results with `X-CTrack-Paging-*` headers up
to a 10k ceiling; a `400` or ceiling-cross raises to force sub-sharding rather
than silently missing docs. Normalizes rows into `documents` with
`status='discovered'`, inserted idempotently via `ON CONFLICT(document_id)`.
Sweeps repeatedly until a pass adds fewer than `CONVERGENCE_DELTA` docs
(convergence). Every sweep logged to `discovery_runs`.

### 3. Backfill — `c60_backfill.py`
Deduplicate *existing* backups into the content-addressed store. Hashes every
file in `pdf_backup/` (SHA-256), writes unique content to `pdf_by_hash/<sha>.pdf`
(9,582 backup files → 3,326 unique PDFs) — "do we already have this?" becomes a
hash lookup. Normalizes DB `local_pdf` pointers. Guarded by `kv backfill_done`.

### 4. Download — `c70_download.py`
Fetch PDFs for all `discovered`/`failed` docs with `attempts < MAX_ATTEMPTS`,
in batches of 50. `api_download` does a `HEAD` (rejects non-200 and anything
> `MAX_PDF_BYTES`), then streams `GET`, hashing as it goes. Dedup wins: a hash
already in the store/manifest uploads nothing. **Retry/backoff** on transient
errors: `sleep = min(60, 5·2^attempts)` (10→20→40→60s, capped), up to 4
attempts. Permanent errors (400/401/403/404/**413**) fail immediately, no
backoff. Writes to `tmp/<id>.part`, promotes to store, paces itself, and pushes
a heartbeat to `status.json` every 100 attempts.

### 5. Extract — `c80_extract.py`
Turn downloaded PDFs into searchable text with `pypdf`. Batches of 500 with 8
parallel worker threads. A PDF with no text layer is a valid `"empty"` result,
not an error. Text written to `text/<sha>.txt`; `extracted_chars` recorded.
Heuristic flags likely-scanned (image-only) PDFs when `chars/pages < 20`.

### 6. Sync/Verify — `c90_verify.py`
Prove nothing is corrupted and push an authoritative snapshot + summaries to
Drive. Runs `PRAGMA integrity_check`; counts totals and the **remaining
queues** (download + extract eligibility queries mirror the stage code exactly,
so `done` is trustworthy). Exports `document_inventory.csv` +
`failed_inventory.csv`. `checkpoint()` makes a consistent DB snapshot via
`src.backup(dst)` (SQLite online backup API) and uploads it as the new
`ky_court.sqlite`. Updates `status.json` with `done = (queues empty)`.
Prints `PASS`/`FAIL`.

## Concurrency (the interesting part)

`checkpoint()` uses SQLite's **online backup API** (`src.backup(dst)`) from a
**separate connection** — not `copytree`, not a WAL checkpoint. It copies the
DB page-by-page and re-copies any page the writer modified during the pass,
looping until a clean pass completes. Combined with **WAL mode** (readers don't
block the writer) and **`busy_timeout=30000`**, this yields a
transactionally-consistent snapshot even while Download is committing every doc.
A naive file copy would produce a torn/corrupt snapshot and drop WAL-committed
data.

## Key design decisions

- **Idempotent + resume-safe everywhere**: skip what's done, never auto-delete
  on Drive.
- **Named permanent-failure set** (`PERMANENT_STATUSES` + `_is_permanent()`)
  distinguishes never-succeed (401/403/404/413) from transient (5xx/conn/429/408).
- **Content-addressed store** (`pdf_by_hash/<sha>.pdf`) makes dedup a lookup.
- **Config via one `CFG` dict** with env overrides (`KY_BUDGET`, `KY_DRY`,
  `KY_PHASES`, `KY_RETRY_HARD`, `KY_LIVE`).

## Config knobs (top cell `CFG`)

| Key | Default | Meaning |
|---|---|---|
| `BUDGET_SECONDS` | 10h | max wall-clock per session (`KY_BUDGET`) |
| `SHUTDOWN_SECONDS` | 15 min | stop early so the session finishes cleanly |
| `PACING_SEARCH` / `PACING_DL` | (0.3,0.7)/(0.3,0.5) | random sleep between API calls |
| `MAX_ATTEMPTS` | 4 | download retries before `failed` |
| `MAX_PDF_BYTES` | 200 MiB | size cap; oversized (413) fails permanently, no retry/backoff |
| `MAX_EXTRACT_ATTEMPTS` | 3 | extraction retries before skip |
| `RETRY_HARD` | 0 (`KY_RETRY_HARD`) | on resume, reset `failed` → `discovered` |
| `PHASES` | discover,backfill,download,extract | stages to run (`KY_PHASES`) |
| `DRY_RUN` | 0 (`KY_DRY`) | 1 = fully offline, no network |

## File layout

```
ky_court_pipeline.ipynb         the deliverable (generated)
notebook_builder/
  build.py                       regenerate the notebook from cells
  cells/  c00_config … c90_verify + drive_store.py
tests/  test_pipeline_local.py + conftest.py (offline; live gated by KY_LIVE)
scripts/  smoke_local.py         full offline pipeline over the real DB
RUNBOOK.md                      operational runbook (first run, resuming, failure modes)
docs/superpowers/ specs/ + plans/  design spec + implementation plan
PROJECT_NOTES.md                 this file
.gitignore                       ignores .venv, extracted/, *.sqlite, __pycache__,
                                 .worktrees/, .superpowers/  (NOT the WIP tars)
```

> **Data is NOT in the repo.** `extracted/`, `*.tar.gz` resume/WIP archives, and
> `sanitize-work/` live only in Drive / locally. Gitignore does not cover the
> WIP `.tar.gz` files, so never `git add -A` from the repo root.

## Running / testing

```bash
python3 -m venv .venv && .venv/bin/pip install -r <pinned deps>
.venv/bin/pytest tests -q                # 20 offline tests (live ones skipped)
.venv/bin/python scripts/smoke_local.py  # full pipeline over the real 98,724-row DB
.venv/bin/python notebook_builder/build.py  # regenerate the notebook
```

Live API tests are gated behind `KY_LIVE=1` and share a ≤10-request budget.
Suite result at v1.2: **20 passed, 3 skipped**.

## Failure modes (from RUNBOOK)

- **Colab disconnect mid-run** → re-run; `recover()` heals stale rows, drops
  `.part` files.
- **Drive quota exceeded** → checkpoint push fails; run continues, store lags.
- **5xx/connection storms** → backoff `min(60, 5·2^attempts)` up to `MAX_ATTEMPTS`.
- **Search shard hits 10k ceiling** → planner sub-shards next run.
- **`integrity_check` not "ok"** → DB corrupt; restore from last resume tar.

## Versioning / releases

Immutable milestone tags (never force-move a published tag):

```
pipeline-v1  33a6ec1  original pipeline milestone
v1.1         ae77344  +README
v1.2         ec248fe  +413 oversize permanent-failure fix (MAX_PDF_BYTES knob)
```

`v1.2` also has a **GitHub release** with changelog:
https://github.com/stateofshit/kycourt-colab/releases/tag/v1.2

## Status

Project complete and shipped. Suite green offline. The pipeline is ready to run in Colab (restore from the resume tar → Run all).

### Roadmap / Future enhancements
- **Parallel extract workers** — tune the 8-worker batch size based on observed Colab runtime limits.
- **Drive quota monitoring** — automated alert when `status.json` reports approaching quota, with graceful degradation to skip upload.
- **CLI entry point** — `kycourt-pipeline run --phase discover` to run individual stages outside Colab.
- **Query layer** — lightweight read-only API over the SQLite DB for inspecting discovered/extracted docs without re-running the pipeline.
- **Vault migration** — if/when the repo is folded into the vault, adapt `folder` frontmatter and consolidate `.tar.gz` WIP archives under the vault's resume format. Remote backup, README, tags, and v1.2 release all
in place on GitHub. Suite green offline. The pipeline is ready to run in Colab
(restore from the resume tar → Run all).
