Skip to content

feat(finance): Ohio aggregators — direct contributions + 31-U outside spending (PR 6) - #542

Merged
shu1513 merged 3 commits into
mainfrom
claude/ohio-finance-aggregators-pr6
Aug 5, 2026
Merged

shu1513 merged 3 commits into
mainfrom
claude/ohio-finance-aggregators-pr6

Conversation

@shu1513

@shu1513 shu1513 commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Ohio campaign-finance PR 6 of 9 (ohio_plan.md). Capability-matrix row: standard state-finance tables, std direct categories (contribution_size only), donor/industry outside breakdowns.

What

  • ohioDirectContributionAggregator.ts — streaming accumulator (decision 10: the ~90 MB CAC_CON files are never materialized; the caller streams each file once and feeds every open accumulator). Contribution-size buckets only (decision 6 — Ohio ships without occupations). Fail-closed SHORT_DESCRIPTION vocabulary: 31-A Stmt of Contribution / 31-E FR Contributions / 31-J-1 In-Kind Cont Rcvd are donor support; 31-A-2 Other Income counts toward receipts only; anything new lands in unknownShortDescriptionRowCount instead of a bucket.
  • ohioOutsideSpendingAggregator.ts — two-stage Form 31-U (decision 4). Stage one pins each report's spender from the annual (MASTER_KEY, REPORT_KEY) pair — the detail export has no MASTER_KEY — across all six expenditure files (candidate and party committees file 31-U too, not just PACs). Stage two aggregates detail rows behind a per-report reconciliation gate: detail total must equal the annual total, and the cover page's VALUE_IND_EXPENDITURES must agree when a cover row exists. Target matching per revised decision 5: strict unique name match (shared resolver helpers, incl. the middle/suffix conflict guard), office as a confirming filter that also rejects a contradicting stated office; unmatched/ambiguous targets quarantine with dollar amounts. No dedup of identical rows (decision 3).
  • Resolver exports two shared helpers (ohioPersonNamesMatch, ohioSosOfficeTokenForOfficeName) so both matchers accept and reject exactly the same pairs.

Design decisions grounded in the real 2026-cycle files

  • Summary receipts/disbursements come from cover pages, cash from the latest filed report. Cover reports chain exactly (AMT_FORWARD = previous BALANCE_ON_HAND, zero duplicate report keys, zero AMENDED rows) and satisfy receipts − disbursements + forward = cash to the cent for all sampled committees. Itemized-only receipts would miss Ramaswamy's $25.4M non-itemized federal-account transfer ($30.3M itemized vs $55.1M actual). The itemized sum is kept as a reconciliation diagnostic. Negative balance → NULL + flag (decision 1).
  • A missing cover row never quarantines a 31-U report. 14 of 28 cycle report keys (federal-calendar super-PAC reports) have no cover row at all; where one exists, VALUE_IND_EXPENDITURES matched the annual total exactly in all 14 cases.

Verification

  • npm run typecheck + npm test green in backend/ (6,245 passed).
  • Real-data smoke against the cached 305 MB cycle: all 13 bundle reports reconciled exactly; Acton oppose $8,211,114.50, Ramaswamy support $600,000, Stephens oppose $217,704.16 (spike numbers reproduced); JON HUSTED (federal race) correctly quarantined as unmatched; the 15 report keys the spike bundle predates are surfaced in missingDetailReportKeys.

Next: PR 7 sync + batchSync + npm scripts.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Ohio direct-contribution financial summaries, including receipts, disbursements, cash on hand, donor totals, and contribution-size breakdowns.
    • Added Ohio outside-spending summaries with support/oppose amounts grouped by spender, candidate, and committee.
    • Improved candidate matching across names and supported office aliases.
    • Added safeguards and diagnostics for invalid, incomplete, unmatched, ambiguous, or unreconciled financial reports.
  • Documentation
    • Updated the Ohio finance implementation plan to reflect the completed reporting capabilities.

… aggregators

Direct aggregator is a streaming accumulator (decision 10): the caller
streams each CAC_CON file once and feeds every open accumulator. Size
buckets are the only direct breakdown (decision 6 — no occupations);
the SHORT_DESCRIPTION vocabulary is fail-closed (31-A / 31-E / 31-J-1
donor support, 31-A-2 receipts-only, unknown values counted). Summary
receipts and disbursements come from the cover pages, cash from the
latest filed report with negative balances written as NULL (decision 1)
— on the real files cover satisfies receipts − disbursements + forward
= cash to the cent, while itemized-only receipts would miss a $25.4M
non-itemized transfer; the itemized sum stays as a diagnostic.

Outside aggregator implements the two-stage 31-U model (decision 4):
spender identity pinned from the annual (MASTER_KEY, REPORT_KEY) pair
across all six expenditure files (candidate and party committees file
31-U too), every report gated by annual-vs-detail reconciliation plus
the cover-page IE leg when a cover row exists (14 of 28 cycle report
keys have none — structural, never a mismatch). Target matching per
revised decision 5: strict unique name match via the resolver's shared
helpers, office confirming-only but rejecting a contradicting stated
office; unmatched/ambiguous targets quarantine with amounts.

Real-data smoke reproduced the spike exactly: 13/13 bundle reports
reconciled, Acton oppose $8,211,114.50, Ramaswamy support $600,000,
Stephens oppose $217,704.16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@shu1513, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 65cd8284-651c-4777-8be4-31858ca901d7

📥 Commits

Reviewing files that changed from the base of the PR and between 4819642 and b785d96.

📒 Files selected for processing (2)
  • backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts
  • backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts
📝 Walkthrough

Walkthrough

The PR adds Ohio direct-contribution and Form 31-U outside-spending aggregators. It adds shared candidate and office matching helpers, exports the new modules, adds comprehensive tests, and marks the implementation complete in the project plan.

Changes

Ohio finance aggregation

Layer / File(s) Summary
Shared matching helpers and module exports
backend/src/pipeline/ohioFinance/ohioCandidateCommitteeResolver.ts, backend/src/pipeline/ohioFinance/index.ts
The resolver exports strict person-name matching and Ohio SoS office-token mapping. The barrel exports both aggregators.
Direct contribution accumulation and finance summaries
backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts, backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts, ohio_plan.md
The streaming accumulator filters and classifies contribution rows, aggregates donor breakdowns, selects cover-page finances, records diagnostics, and returns dollar-denominated summaries. Tests cover filtering, reconciliation, balances, limits, validation, and lifecycle behavior.
Form 31-U reconciliation and candidate attribution
backend/src/pipeline/ohioFinance/ohioOutsideSpendingAggregator.ts, backend/tests/pipeline/ohioFinance/ohioOutsideSpendingAggregator.test.ts
The aggregator reconciles annual, detail, and cover-page reports, quarantines invalid reports, matches candidate targets, attributes support and oppose spending, groups spenders, and records diagnostics. Tests cover matching, reconciliation, missing data, duplicate keys, and group limits.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnnualRows
  participant aggregateOhioOutsideSpending
  participant DetailReports
  participant CoverPages
  participant CandidateTargets
  AnnualRows->>aggregateOhioOutsideSpending: provide annual expenditure rows
  aggregateOhioOutsideSpending->>DetailReports: resolve report keys and process detail rows
  aggregateOhioOutsideSpending->>CoverPages: reconcile independent-expenditure totals
  aggregateOhioOutsideSpending->>CandidateTargets: match normalized names and office tokens
  aggregateOhioOutsideSpending-->>AnnualRows: return candidate totals and diagnostics
Loading

Possibly related PRs

  • shu1513/voteapp#534: Provides the Ohio finance schema, writer, and eligible-office foundation used by these aggregators.
  • shu1513/voteapp#538: Provides Ohio SoS bulk-file schemas and 31-U detail and reconciliation data consumed here.
  • shu1513/voteapp#539: Adds the Ohio resolver foundation extended with the shared matching helpers in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: Ohio direct-contribution and Form 31-U outside-spending aggregators.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/ohio-finance-aggregators-pr6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…gregators

Replace the raw NUL separator bytes with the textual escape sequence
(backslash-u0000) so Git stops classifying the two aggregator files as
binary (behavior identical).

Fully blank cover rows (every money column empty - 3 of 1,673 cycle
rows on the real file, e-filing damage) are now excluded from the cover
sums, the latest-report pick, and coverReportCount, and surfaced as
blankCoverRowCount: a blank latest filing no longer nulls a real cash
balance, and a committee with only blank rows falls back to itemized
receipts instead of publishing fabricated zeroes. Partially blank rows
keep their ?? 0 treatment deliberately - all 583 blank-contribution
rows on the real file satisfy TOTAL_FUNDS = AMT_FORWARD +
TOTAL_OTHER_INCOME exactly, so a blank cell on an otherwise-filled row
provably means zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repository owner deleted a comment from coderabbitai Bot Aug 5, 2026
@shu1513

shu1513 commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
ohio_plan.md (1)

390-399: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Record the blank-cover-row rule in the PR 6 entry.

The entry states that receipts and disbursements come from cover sums and cash from the latest report. It omits the fully blank cover row exclusion that the implementation adds. In ohioDirectContributionAggregator.ts lines 64-69 and 310-311, a cover row with every money column null is excluded from the sums, from the latest-report pick, and from coverReportCount, and a committee with only blank rows falls back to itemized receipts with NULL disbursements and cash. That rule changes published output, so the plan should carry it.

📝 Proposed plan addition
       cover satisfies receipts − disbursements + forward = cash to the cent,
       while itemized-only receipts would miss Ramaswamy's $25.4M non-itemized
-      federal transfer (itemized sum kept as a diagnostic); 31-U spans all six
+      federal transfer (itemized sum kept as a diagnostic); cover rows with
+      every money column blank (3 of 1,673 on the 2026-cycle file, e-filing
+      damage) are excluded from the sums, the latest-report pick, and
+      `coverReportCount` and counted in `blankCoverRowCount`, so a
+      only-blank committee falls back to itemized receipts with NULL
+      disbursements/cash instead of fabricated zeroes; 31-U spans all six
       expenditure files (candidate and party committees file it too — 28
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ohio_plan.md` around lines 390 - 399, Update the PR 6 entry in ohio_plan.md
to document that fully blank cover rows, with all money columns null, are
excluded from cover sums, latest-report selection, and coverReportCount. Also
record that committees containing only blank cover rows fall back to itemized
receipts with NULL disbursements and cash.
backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts (1)

141-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin skippedContributionRowCount for the description-classification paths.

add increments skippedContributionRowCount for other-income rows and for unknown-description rows. No test asserts that behavior, so a change to the skip accounting would pass. Add the assertions to lock the contract that the diagnostics consumer depends on.

💚 Proposed test additions
     expect(result.otherIncomeRowCount).toBe(1);
     expect(result.unknownShortDescriptionRowCount).toBe(0);
+    expect(result.includedContributionRowCount).toBe(1);
+    expect(result.skippedContributionRowCount).toBe(1);
     expect(result.directBreakdowns).toHaveLength(1);
     expect(result.unknownShortDescriptionRowCount).toBe(1);
+    expect(result.includedContributionRowCount).toBe(0);
+    expect(result.skippedContributionRowCount).toBe(1);
     expect(result.directBreakdowns).toHaveLength(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts`
around lines 141 - 164, Add assertions in both tests around
createOhioDirectContributionAccumulator to verify skippedContributionRowCount is
1 for the other-income row and 1 for the unknown SHORT_DESCRIPTION row, while
preserving the existing receipt, direct-total, bucket, and diagnostic
assertions.
backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts (1)

337-341: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make the breakdown tie-break ordering deterministic.

localeCompare without an explicit locale resolves against the runtime default locale and ICU data. If two buckets carry equal amountCents, the emitted order can differ between environments. The test at lines 108-130 asserts the full array, so a future tie would make ordering environment-dependent. Use a plain code-unit comparison, because the bucket labels are a fixed ASCII vocabulary.

♻️ Proposed refactor
       .sort(
         (left, right) =>
-          right.amountCents - left.amountCents || left.categoryName.localeCompare(right.categoryName)
+          right.amountCents - left.amountCents ||
+          (left.categoryName < right.categoryName ? -1 : left.categoryName > right.categoryName ? 1 : 0)
       )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts` around
lines 337 - 341, Update the tie-break comparison in the directBreakdowns sort
within the Ohio finance aggregator to use deterministic plain code-unit ordering
for categoryName instead of localeCompare. Preserve descending amountCents
ordering and the existing category-name tie-break behavior for the fixed ASCII
vocabulary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts`:
- Around line 52-62: Update the “Skip reasons” documentation near the aggregator
counters in
backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts:52-62 to
state that all five counters partition skippedContributionRowCount. In
backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts:141-164,
add skippedContributionRowCount and matching includedContributionRowCount
assertions to both the other-income and unknown-description tests.

---

Nitpick comments:
In `@backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts`:
- Around line 337-341: Update the tie-break comparison in the directBreakdowns
sort within the Ohio finance aggregator to use deterministic plain code-unit
ordering for categoryName instead of localeCompare. Preserve descending
amountCents ordering and the existing category-name tie-break behavior for the
fixed ASCII vocabulary.

In `@backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts`:
- Around line 141-164: Add assertions in both tests around
createOhioDirectContributionAccumulator to verify skippedContributionRowCount is
1 for the other-income row and 1 for the unknown SHORT_DESCRIPTION row, while
preserving the existing receipt, direct-total, bucket, and diagnostic
assertions.

In `@ohio_plan.md`:
- Around line 390-399: Update the PR 6 entry in ohio_plan.md to document that
fully blank cover rows, with all money columns null, are excluded from cover
sums, latest-report selection, and coverReportCount. Also record that committees
containing only blank cover rows fall back to itemized receipts with NULL
disbursements and cash.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d15b69c0-cff8-437a-b930-3caba871bcd0

📥 Commits

Reviewing files that changed from the base of the PR and between 434404d and 4819642.

📒 Files selected for processing (7)
  • backend/src/pipeline/ohioFinance/index.ts
  • backend/src/pipeline/ohioFinance/ohioCandidateCommitteeResolver.ts
  • backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts
  • backend/src/pipeline/ohioFinance/ohioOutsideSpendingAggregator.ts
  • backend/tests/pipeline/ohioFinance/ohioDirectContributionAggregator.test.ts
  • backend/tests/pipeline/ohioFinance/ohioOutsideSpendingAggregator.test.ts
  • ohio_plan.md

Comment thread backend/src/pipeline/ohioFinance/ohioDirectContributionAggregator.ts Outdated
…regator

The result-type comment now states that matched = included + skipped and
that the five reason counters partition skippedContributionRowCount,
with the two description-classified reasons still counting toward
itemized receipts. Tests assert the partition for the other-income and
unknown-description cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shu1513
shu1513 merged commit 938c0bb into main Aug 5, 2026
6 checks passed
@shu1513
shu1513 deleted the claude/ohio-finance-aggregators-pr6 branch August 5, 2026 21:59
shu1513 added a commit that referenced this pull request Aug 6, 2026
…rgets, fail-closed reader, cover-IE zero

Four fixes from the Codex/CodeRabbit review of PR #545:

- A 31-U detail bundle missing report keys the annual files discovered now
  fails the year closed to outside-unavailable instead of publishing
  partial totals and zeros. The real cache's spike bundle misses 15 keys
  carrying ~$5.9M of annual 31-U money, so this was production-relevant.
- The outside target dedupe keys on candidateId + office, not display
  name: one person due for primary and general still shares a target,
  while two different people with the same name stay separate and the
  aggregator quarantines that name as ambiguous instead of paying both.
- The version-1 bundle reader validates every field the aggregator
  consumes (direction pinned to support/oppose/null, integer cents,
  matching report keys) instead of casting - a bogus direction would have
  landed in the aggregator's oppose branch.
- Cover-IE gate (leftover from the #542 review): a blank
  VALUE_IND_EXPENDITURES on an otherwise-filled cover row now counts as
  zero and feeds the third reconciliation leg; fully-blank rows stay
  ignored like missing ones (shared isBlankOhioSosCoverPageRow). A probe
  of all 28 cycle report keys found 13 exact IE matches, 15 no-cover-row,
  and 0 blank-on-filled, so this is added protection with no behavior
  change on the real files.

Also: direct contribution rows route to their accumulator by MASTER_KEY
instead of the rows x committees scan, and the outside catch warning no
longer claims "artifacts unavailable" for what may be an aggregation
failure.

Declined from review: streaming JSON for the bundle (9.8 KB file - the
305 MB figure is the whole cache), shared CLI flag parser, and dropping
force from the recurring scheduler payload (both verbatim the
maryland/all-states template; repo-wide questions).

Verified: typecheck green, 6,299 tests pass (10 new), and the real-data
dry run now reports outside unavailable on the stale spike bundle with
direct totals unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant