Sync: officeholders v3 — fresh export from live Atlas DB (13,329 tenures)

Re-exported directly from Postgres (10.0.0.116), not Atlas's introspection,
which under-reported the roster (its v3 export had 11,473, missing the
restored Florida League of Cities municipal set). Live DB current tenures:
state 7,561 · municipal 4,009 · county 1,217 · federal 542.

- build.py now reads officeholders-v3.jsonl (was v2); dataset vintage 2026-07-04
- fixed one corrupt start_date at source-export (21021-12-03 -> 2021-12-03,
  Lorraine Borowski, Mayor of Decorah IA)
- scripts/build_viz.py: committed, deterministic generator for the Board's
  county_data.json / county_detail.json (previously built ad-hoc)
- generate_changelog.py: ignore files whose only change is the vintage stamp,
  so the diff reflects government change, not re-export bookkeeping

Person files 11,285 -> 13,329. Non-FL municipal officials (1,903) are in the
tree but not yet county-mappable (place->county crosswalk still NULL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Fabio
2026-07-04 18:56:37 -04:00
parent 092e54ae21
commit 7a2cc64362
14318 changed files with 156645 additions and 58602 deletions
+4 -4
View File
@@ -2,7 +2,7 @@
"""Build the OKF entity tree from the raw source exports in data/.
Inputs (committed raw, one JSON object per line):
officeholders-v2.jsonl Atlas — person->seat records (11,285)
officeholders-v3.jsonl Atlas — person->seat records (13,329)
bodies.jsonl congress-legislators — institutions (233)
leadership.jsonl current federal leadership roles (28)
committee_memberships.jsonl person->committee edges (3,879)
@@ -27,7 +27,7 @@ REPO = Path(__file__).resolve().parent.parent
DATA = REPO / "data"
OUT = DATA / "jurisdictions"
BODIES_OUT = OUT / "us" / "bodies"
DATASET_DATE = "2026-06-20" # officeholders v2 build date
DATASET_DATE = "2026-07-04" # officeholders v3 export date
CONGRESS_DATE = "2026-07-03" # congress-legislators ingest date
@@ -304,7 +304,7 @@ def person_body(rec, enr):
any_src = True
if not any_src:
out.append("- (no field-level source recorded)")
out += ["", f"Generated from the Atlas officeholders v2 export ({DATASET_DATE})."]
out += ["", f"Generated from the Atlas officeholders v3 export ({DATASET_DATE})."]
return out
@@ -537,7 +537,7 @@ def load(name):
def main():
officeholders = load("officeholders-v2.jsonl")
officeholders = load("officeholders-v3.jsonl")
bodies = load("bodies.jsonl")
leadership = load("leadership.jsonl")
memberships = load("committee_memberships.jsonl")
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Rebuild the Board's viz data from the officeholders export + ACS counties.
The Board (viz/board.html) is a rebuilt *view* of the canonical tree, never a
source. This regenerates its two companions deterministically:
viz/county_data.json {fips: {name, state, pop, income, poverty, home,
unemp, oh}} — nationwide choropleth + officeholder
count per county.
viz/county_detail.json {fips: {slug, county:[{n,r}], munis:[{m, p:[{n,r}]}]}}
— per-county drill-down roster.
County/municipal officials are mapped to a county the same way build.py places
them in the tree (county_slug / city helpers), so the viz and the tree agree by
construction. Officials whose county can't be resolved to an ACS FIPS (e.g. the
non-FL municipal rows still blocked on the place->county crosswalk) are counted
in the tree but do not appear in the county-keyed viz — an honest gap, not a
silent drop; the tally is printed at the end.
Deterministic: sorted iteration, fixed key order. Two runs are byte-identical.
"""
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import build # noqa: E402 — reuse the exact tree-placement helpers
DATA = build.DATA
VIZ = build.REPO / "viz"
def city_name(rec):
"""Human city label for a municipal rec (matches the tree's municipality)."""
m = re.match(r"(.+?),\s*[A-Z]{2}$", rec.get("jurisdiction_label") or "")
if m:
return m.group(1).strip()
m = re.match(r"Mayor of (.+)$", rec.get("title") or "")
if m:
return m.group(1).strip()
return build.city_slug(rec).replace("-", " ").title()
def main():
officeholders = build.load("officeholders-v3.jsonl")
acs_county = build.load("acs_county.jsonl")
# slug -> fips, and fips -> (name, state, demographics), deduped by fips
slug_to_fips = {}
county_meta = {}
for row in sorted(acs_county, key=lambda r: build.canonical_fips(r["county_fips"])):
cf = build.canonical_fips(row["county_fips"])
if cf in county_meta:
continue
st = row.get("state_abbr")
if not st:
continue
base = re.sub(r",\s*[A-Z]{2}$", "", row.get("county_name") or "")
cslug = build.slugify(build.norm_county(re.sub(r"\s+County$", "", base)))
demog = build.normalize_demog(row)
county_meta[cf] = {"name": base, "state": st, "slug": cslug, "demog": demog}
slug_to_fips[(st.lower(), cslug)] = cf
# officials grouped by resolved county key (state, slug)
oh_count = defaultdict(int)
county_roster = defaultdict(list) # key -> [(name, role)]
muni_roster = defaultdict(lambda: defaultdict(list)) # key -> city -> [(name, role)]
unresolved = 0
for rec in officeholders:
if rec["level"] not in ("county", "municipal"):
continue
key = ((rec.get("state_abbr") or "").lower(), build.county_slug(rec))
oh_count[key] += 1
name = rec.get("full_name") or rec.get("title") or "Unknown"
role = rec.get("title") or ""
if rec["level"] == "county":
county_roster[key].append((name, role))
else:
muni_roster[key][city_name(rec)].append((name, role))
if key not in slug_to_fips:
unresolved += 1
# ---- county_data.json (nationwide) ----
county_data = {}
for cf, meta in sorted(county_meta.items()):
d = meta["demog"]
key = (meta["state"].lower(), meta["slug"])
county_data[cf] = {
"name": meta["name"],
"state": meta["state"],
"pop": d.get("population"),
"income": d.get("median_household_income"),
"poverty": d.get("poverty_rate"),
"home": d.get("homeownership_rate"),
"unemp": d.get("unemployment_rate"),
"oh": oh_count.get(key, 0),
}
# ---- county_detail.json (only counties with resolved officials) ----
county_detail = {}
keys_with_people = set(county_roster) | set(muni_roster)
for key in keys_with_people:
cf = slug_to_fips.get(key)
if cf is None:
continue
county = [{"n": n, "r": r} for n, r in sorted(county_roster.get(key, []))]
munis = []
for city in sorted(muni_roster.get(key, {})):
people = [{"n": n, "r": r} for n, r in sorted(muni_roster[key][city])]
munis.append({"m": city, "p": people})
county_detail[cf] = {"slug": key[1], "county": county, "munis": munis}
county_detail = {k: county_detail[k] for k in sorted(county_detail)}
VIZ.mkdir(exist_ok=True)
(VIZ / "county_data.json").write_text(
json.dumps(county_data, ensure_ascii=False, sort_keys=True) + "\n")
(VIZ / "county_detail.json").write_text(
json.dumps(county_detail, ensure_ascii=False, sort_keys=True) + "\n")
total_oh = sum(oh_count.values())
print(f"county_data.json: {len(county_data)} counties, "
f"{sum(1 for v in county_data.values() if v['oh'])} with officeholders")
print(f"county_detail.json: {len(county_detail)} counties with rosters")
print(f"officials mapped: {total_oh - unresolved}/{total_oh} "
f"(county-unresolvable, tree-only: {unresolved})")
if __name__ == "__main__":
main()
+38
View File
@@ -22,12 +22,37 @@ from collections import defaultdict
PREFIX = "data/jurisdictions/"
# Lines that are pure build bookkeeping, not government facts. A file whose only
# change is one of these (the dataset-vintage stamp) did NOT change as a mirror
# of government — it was just re-exported — so it must not surface as a "change".
# See build.py: the frontmatter `timestamp` and the "Generated from ..." footer.
BOOKKEEPING = re.compile(
r'^\s*timestamp:\s*".*"\s*$'
r'|^Generated from the Atlas officeholders v\d+ export \(.*\)\.\s*$')
def git(*args):
return subprocess.run(["git", *args], capture_output=True, text=True,
check=True).stdout
def bookkeeping_only_files(base, head):
"""Paths modified ONLY in bookkeeping lines (vintage stamp), to be ignored."""
diff = git("diff", "--unified=0", f"{base}..{head}", "--", PREFIX)
changed = defaultdict(list) # path -> list of changed content lines
path = None
for line in diff.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif line.startswith("--- ") or line.startswith("diff ") \
or line.startswith("@@") or line.startswith("index "):
continue
elif path and line and line[0] in "+-":
changed[path].append(line[1:])
return {p for p, lines in changed.items()
if lines and all(BOOKKEEPING.match(ln) for ln in lines)}
def classify(path):
"""(entity type, jurisdiction label) from a repo path — fast, no file reads."""
if not path.startswith(PREFIX):
@@ -84,6 +109,8 @@ def main():
per_state = defaultdict(lambda: defaultdict(int))
added_bodies = []
added_by_state = defaultdict(lambda: defaultdict(int)) # state -> etype -> n added
ignore = bookkeeping_only_files(base, head)
skipped = 0
for line in status.splitlines():
parts = line.split("\t")
@@ -92,6 +119,9 @@ def main():
op = {"A": "added", "M": "modified", "D": "removed", "R": "modified"}.get(code)
if not op:
continue
if op == "modified" and path in ignore:
skipped += 1
continue
etype, juris = classify(path)
if etype is None:
continue
@@ -111,6 +141,10 @@ def main():
out.append("")
if total == 0:
out.append("No entity changes in this range — the government, as mirrored, held still.")
if skipped:
out.append("")
out.append(f"*({skipped} files were re-exported with a new vintage stamp "
f"but no government change.)*")
emit(out, write)
return 0
@@ -155,6 +189,10 @@ def main():
out.append(f"- **{juris}**: {' '.join(b for b in bits if b)}{types}")
out.append("")
out.append(f"*{total} entity changes total.*")
if skipped:
out.append("")
out.append(f"*(Ignored {skipped} files whose only change was the "
f"dataset-vintage stamp — re-export, not a government change.)*")
emit(out, write)
return 0