This guide is part of the IRS 990 Data Schema Mapping section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you turn the numbers on Schedule A, Part II into a validated JSON contract that computes a defensible public support percentage — same inputs, same fraction, same pass/fail verdict, every run?
The public support test decides whether a 170(b)(1)(A)(vi) organization stays a public charity or tips into private-foundation status, so the arithmetic cannot live in a spreadsheet whose formulas nobody can replay. This guide builds a pydantic v2 model over the five-year measuring period, keeps every dollar in Decimal, computes the fraction the way Schedule A does, applies the one-third threshold with the 10% facts-and-circumstances alternative, and emits a result object that carries its own line-by-line lineage.
When to Use This Approach
Reach for this model when all three conditions hold:
- The organization files Schedule A, Part II — not Part III. Part II implements the public support test for organizations described in IRS Form 990 Schedule A, Part II (section 170(b)(1)(A)(vi)). The gross-receipts test in Part III (section 509(a)(2)) uses different lines and a different denominator; do not feed Part III figures into this schema. Grantor-specific eligibility rules that key off charity status belong to Grantor-Specific Rule Taxonomies, not here.
- You need a replayable verdict, not a display number. The public support percentage is a regulated determination: it drives the organization’s own filing and, downstream, a funder’s due-diligence file. Every figure the model touches is recorded in a computation lineage so an examiner can trace line 14 back to the individual columns.
- Money precision is load-bearing. Rounding a five-year aggregate through binary floating point can move the fraction across the 33-1/3% line. Every monetary field is
Decimal, quantized to cents, and negative amounts are rejected at validation time rather than silently flowing into the sum.
Field extraction from the filed PDF, alias resolution across form versions, and officer-compensation modeling are explicitly out of scope. Reading the raw return is the job of the parent IRS 990 Data Schema Mapping stage; the companion reference How to Map IRS 990 Part VII to JSON Schema handles the governance section. This page assumes the Part II line values already arrived as clean numbers and concerns itself only with validating and computing them.
Step-by-Step Implementation
The reference implementation targets Python 3.10+ and pydantic v2. Keep pydantic pinned to a v2 line — the validation contract below uses field_validator, ConfigDict, and model_dump, none of which behave the same on v1; the migration trade-offs are covered in the companion Pydantic v1 vs v2 Validation Contracts for IRS 990 Ingestion.
pydantic==2.7.1
One contract to internalize before writing any code: the fraction, not the rounded percentage, decides the test. Schedule A prints line 14 to two decimals, but the pass/fail comparison is against exactly one-third. Comparing a rounded 33.33 against 33.33 can flip a borderline organization, so the gate below compares the raw Decimal fraction against Decimal(1) / Decimal(3).
Step 1: Model the five-year measuring period
Every column of Schedule A, Part II is one tax year. Model a single year as a frozen pydantic model whose monetary fields are Decimal, then wrap exactly five of them in a measuring-period container. Configure the standard-library audit logger first (never print) so every computation is replayable from its logs.
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, List
from pydantic import BaseModel, ConfigDict, Field, field_validator
AUDIT_LOGGER = logging.getLogger("grant_990.schedule_a")
AUDIT_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
AUDIT_LOGGER.addHandler(_handler)
CENTS = Decimal("0.01")
def _money(value: Decimal) -> Decimal:
"""Quantize to cents with banker-safe half-up rounding."""
return value.quantize(CENTS, rounding=ROUND_HALF_UP)
class SupportYear(BaseModel):
"""One column (a)-(e) of Schedule A, Part II: a single tax year."""
model_config = ConfigDict(frozen=True)
tax_year: int = Field(ge=1970, le=2100)
# Section A components that feed the line 4 total support base
line1_gifts_grants_contributions: Decimal = Field(ge=0)
line2_tax_revenues_levied: Decimal = Field(default=Decimal("0"), ge=0)
line3_govt_facilities_furnished: Decimal = Field(default=Decimal("0"), ge=0)
# Section B components counted only in total support
line8_gross_investment_income: Decimal = Field(default=Decimal("0"), ge=0)
line9_unrelated_business_income: Decimal = Field(default=Decimal("0"), ge=0)
line10_other_income: Decimal = Field(default=Decimal("0"), ge=0)
@field_validator("*", mode="after")
@classmethod
def _quantize(cls, value: object) -> object:
return _money(value) if isinstance(value, Decimal) else value
@property
def line4_total(self) -> Decimal:
return _money(
self.line1_gifts_grants_contributions
+ self.line2_tax_revenues_levied
+ self.line3_govt_facilities_furnished
)
The ge=0 constraints reject negative money at parse time, and the wildcard field_validator("*") quantizes every Decimal to cents so no unrounded intermediate can leak into the aggregate. frozen=True makes each year immutable once validated — the audit trail depends on inputs that cannot be mutated after the fact.
Step 2: Define the Decimal-money support schema
Wrap the years in a MeasuringPeriod that enforces the exact five-year window Part II requires and carries line 5 — the portion of any one donor’s gifts exceeding 2% of total support, which is subtracted from the public-support numerator. Validating the count and the distinct years here means the computation in Step 3 can trust its inputs.
class MeasuringPeriod(BaseModel):
"""The five-year window: current year plus four prior, columns (a)-(e)."""
model_config = ConfigDict(frozen=True)
years: List[SupportYear]
# line 5, column (f): donor contributions exceeding the 2% ceiling
line5_excess_contributions: Decimal = Field(default=Decimal("0"), ge=0)
@field_validator("years", mode="after")
@classmethod
def _exactly_five_distinct(cls, years: List[SupportYear]) -> List[SupportYear]:
if len(years) != 5:
raise ValueError(
f"Schedule A Part II requires exactly 5 tax years; received {len(years)}"
)
if len({y.tax_year for y in years}) != 5:
raise ValueError("Measuring period contains duplicate tax years")
return years
@field_validator("line5_excess_contributions", mode="after")
@classmethod
def _quantize_line5(cls, value: Decimal) -> Decimal:
return _money(value)
Line 5 defaults to zero because many small organizations have no donor over the 2% ceiling, but when it is present it must never exceed the line 4 base — a guard the computation enforces so public support can never come out negative.
Step 3: Compute public support and total support
Aggregate the columns into the Schedule A line numbers: line 4 is the sum of every year’s total support base, line 6 is line 4 minus line 5, and line 11 is line 4 (carried to line 7) plus the Section B investment, unrelated-business, and other income. Line 14 is the public support percentage. Record each figure and its formula in a lineage dictionary so the verdict is traceable.
class SupportComputation(BaseModel):
line4_total_support_base: Decimal
line5_excess_contributions: Decimal
line6_public_support: Decimal
line11_total_support: Decimal
line14_public_support_pct: Decimal
lineage: Dict[str, str]
def compute_support(period: MeasuringPeriod) -> SupportComputation:
"""Fold the five-year columns into the Schedule A Part II line values."""
line4 = _money(sum((y.line4_total for y in period.years), Decimal("0")))
if period.line5_excess_contributions > line4:
raise ValueError(
"line 5 excess contributions exceed the line 4 base; public support would be negative"
)
line6 = _money(line4 - period.line5_excess_contributions)
section_b_extra = _money(
sum(
(
y.line8_gross_investment_income
+ y.line9_unrelated_business_income
+ y.line10_other_income
for y in period.years
),
Decimal("0"),
)
)
line11 = _money(line4 + section_b_extra) # line 7 equals the line 4 aggregate
if line11 <= 0:
raise ValueError("total support (line 11) is zero; the fraction is undefined")
pct = _money((line6 / line11) * Decimal("100"))
lineage = {
"line4_total_support_base": f"sum of 5 yr line 4 = {line4}",
"line6_public_support": f"line 4 {line4} - line 5 {period.line5_excess_contributions} = {line6}",
"line11_total_support": f"line 4 {line4} + section B {section_b_extra} = {line11}",
"line14_public_support_pct": f"100 * line 6 {line6} / line 11 {line11} = {pct}",
}
AUDIT_LOGGER.info(
"computed public support | line6=%s line11=%s line14=%s%%", line6, line11, pct
)
return SupportComputation(
line4_total_support_base=line4,
line5_excess_contributions=period.line5_excess_contributions,
line6_public_support=line6,
line11_total_support=line11,
line14_public_support_pct=pct,
lineage=lineage,
)
The explicit zero-denominator guard turns a would-be ZeroDivisionError into a structured ValueError an upstream caller can categorize, and the lineage strings are the exact arithmetic an examiner would redo by hand.
Step 4: Apply the one-third test and the 10 percent fallback
Now gate the fraction. The primary test — IRS Form 990 Schedule A, Part II, line 16a — is met when public support is at least one-third of total support. If it falls short but is still at least 10%, the organization can qualify under the line 17 facts-and-circumstances alternative, which is a narrative determination the code flags rather than decides. Below 10%, it fails.
from enum import Enum
ONE_THIRD = Decimal(1) / Decimal(3)
FACTS_FLOOR = Decimal("0.10")
class SupportStatus(str, Enum):
PUBLICLY_SUPPORTED = "publicly_supported" # line 16a: one-third test met
FACTS_AND_CIRCUMSTANCES = "facts_and_circumstances" # line 17: 10% + narrative
NOT_PUBLICLY_SUPPORTED = "not_publicly_supported"
class PublicSupportResult(BaseModel):
status: SupportStatus
line14_public_support_pct: Decimal
facts_and_circumstances_required: bool
computation: SupportComputation
def evaluate_public_support(computation: SupportComputation) -> PublicSupportResult:
"""Apply the 33-1/3% test, then the 10% facts-and-circumstances fallback."""
fraction = computation.line6_public_support / computation.line11_total_support
if fraction >= ONE_THIRD:
status, facts_required = SupportStatus.PUBLICLY_SUPPORTED, False
AUDIT_LOGGER.info("line 16a met: %s%% >= 33-1/3%%", computation.line14_public_support_pct)
elif fraction >= FACTS_FLOOR:
status, facts_required = SupportStatus.FACTS_AND_CIRCUMSTANCES, True
AUDIT_LOGGER.warning(
"line 16a not met; line 17 available at %s%% - facts-and-circumstances review required",
computation.line14_public_support_pct,
)
else:
status, facts_required = SupportStatus.NOT_PUBLICLY_SUPPORTED, False
AUDIT_LOGGER.warning(
"public support %s%% below 10%%: private-foundation treatment",
computation.line14_public_support_pct,
)
return PublicSupportResult(
status=status,
line14_public_support_pct=computation.line14_public_support_pct,
facts_and_circumstances_required=facts_required,
computation=computation,
)
Comparing fraction against Decimal(1) / Decimal(3) rather than the rounded 33.33 is what keeps a 33.34%-vs-33.33% boundary case correct. The FACTS_AND_CIRCUMSTANCES status is deliberately not a pass — it signals that a human must supply the line 17 narrative before the determination is final.
Step 5: Emit a validated result with computation lineage
Tie the stages together and serialize. model_dump(mode="json") renders every Decimal as a JSON string, so no precision is lost crossing the wire, and the embedded lineage travels with the verdict into whatever ledger or evidence store consumes it.
import json
def run_public_support_test(period: MeasuringPeriod) -> str:
"""Validate, compute, evaluate, and emit a JSON result with lineage."""
computation = compute_support(period)
result = evaluate_public_support(computation)
payload = result.model_dump(mode="json")
AUDIT_LOGGER.info("public support verdict: %s", result.status.value)
return json.dumps(payload, indent=2)
Because the entire pipeline runs on immutable, validated inputs, the emitted JSON is a self-contained artifact: an auditor can re-parse it, recompute line 14 from the lineage, and reach the same status without the original workbook.
Verification
Confirm the model behaves correctly with four checks:
- The one-third test passes cleanly above threshold. Build a period whose public support is 40% of total support and assert
status == SupportStatus.PUBLICLY_SUPPORTEDandfacts_and_circumstances_required is False. - The 10% band routes to facts-and-circumstances. Build a period landing at, say, 22% and assert the status is
FACTS_AND_CIRCUMSTANCESand the flag isTrue— proof the code never auto-passes line 17. - Negative and malformed inputs are rejected. Assert that a negative
line1_gifts_grants_contributionsraises a pydanticValidationError, and that four years (not five) raises one too. - The lineage reproduces line 14. Recompute
100 * line6 / line11from the lineage dictionary and assert it equalsline14_public_support_pctto the cent.
A passing run emits one INFO audit line carrying line 6, line 11, and line 14; a facts-and-circumstances or failing run emits a WARNING. Ship those logs to a write-once tier so the determination trail is replayable.
period = MeasuringPeriod(
years=[
SupportYear(tax_year=y, line1_gifts_grants_contributions=Decimal("400000"),
line8_gross_investment_income=Decimal("50000"))
for y in range(2021, 2026)
],
)
result = evaluate_public_support(compute_support(period))
assert result.status is SupportStatus.PUBLICLY_SUPPORTED
recomputed = (result.computation.line6_public_support
/ result.computation.line11_total_support * Decimal("100")).quantize(CENTS)
assert recomputed == result.line14_public_support_pct
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
| Borderline organization flips pass/fail | Comparing the rounded 33.33 percentage instead of the fraction |
Gate on fraction >= Decimal(1) / Decimal(3); keep the rounded percentage for display only. |
ValidationError on parse |
A monetary field is negative or the period has fewer than five years | Correct the upstream extraction; ge=0 and the five-year validator are contract guards, not bugs. |
ValueError: total support (line 11) is zero |
Every Section A and Section B line is zero or missing | Confirm the return actually carries Part II data; an all-zero period cannot yield a fraction. |
| Public support comes out negative | Line 5 excess contributions exceed the line 4 base | The model raises before dividing; recheck the 2% donor computation feeding line 5. |
| Precision drift across a 5-year sum | Money parsed as float before reaching the model |
Keep values as strings or Decimal end to end; the wildcard validator quantizes but cannot recover lost float precision. |
Related
- Parent section: IRS 990 Data Schema Mapping
- The governance-section counterpart to this schema: How to Map IRS 990 Part VII to JSON Schema
- Why the validators here are pydantic v2, and what changes on v1: Pydantic v1 vs v2 Validation Contracts for IRS 990 Ingestion
- Where charity-status outputs feed funder eligibility rules: Grantor-Specific Rule Taxonomies