Tracking versioned NSF PAPPG updates in code
The National Science Foundation (NSF) rewrites its Proposal & Award Policies & Procedures Guide (PAPPG) on a recurring cycle, and a proposal is judged against the version in force on the day its deadline falls — not the version that was current when the principal investigator started drafting. A team that hard-codes “the current rules” will silently apply last year’s page limits, biosketch formats, or budget captions to a proposal due under a newer guide, and Research.gov will bounce it on a rule the author never saw change. This page shows how to model each PAPPG revision as a versioned object, compute the exact rule deltas between any two versions, and pin the effective version per proposal by deadline — the temporal backbone behind the parent NSF Proposal Guide Taxonomy. Treating the guide as a moving target rather than a constant is what keeps a validator honest across cycles.
Phase 1 — Model a PAPPG version and its rule deltas
A PAPPG version is not a document blob; for automation it is a dated policy record carrying the machine-checkable rules your pipeline actually enforces. Model the version identity and the rules separately so you can diff rules across versions without re-parsing prose every time. Each version has an identifier (NSF labels them like 24-1), an effective date, and — crucially — the date it stops being authoritative, which is simply the effective date of its successor.
Implementation steps:
- Assign a stable version key. Use the NSF label (
23-1,24-1,25-1) as the primary key, never a parse timestamp, so two ingests of the same guide collapse to one record. - Record the effective window. Store
effective_dateand leave the upper bound open; resolve it at query time from the next version so inserting a new guide never requires editing old records. - Capture rules as typed entries. Reduce each enforceable clause to a
(rule_id, value)pair —research_description_page_limit,biosketch_format,budget_justification_pages— so a delta is a set operation, not a diff of paragraphs. - Keep provenance. Retain the PAPPG section number each rule came from so a flagged change can be traced back, mirroring the anchoring discipline used when parsing NSF PAPPG section headers programmatically.
Pin the environment so a resolution run reproduces exactly when the same proposal is re-checked after a guide is superseded:
python -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6"
Define the version as a Pydantic v2 model. The rules are a plain mapping keyed by rule_id, which makes the Phase 2 diff a dictionary comparison rather than bespoke code per field:
from datetime import date
from pydantic import BaseModel, Field, field_validator
class PappgRule(BaseModel):
"""A single machine-checkable clause lifted from one PAPPG section."""
rule_id: str # e.g. "research_description_page_limit"
value: str | int | bool
section: str # PAPPG section number, for provenance
class PappgVersion(BaseModel):
"""One dated revision of the NSF PAPPG."""
label: str # NSF label, e.g. "24-1"
effective_date: date
rules: dict[str, PappgRule] = Field(default_factory=dict)
@field_validator("label")
@classmethod
def _looks_like_pappg_label(cls, v: str) -> str:
# NSF labels are two-digit year, dash, revision index: 24-1, 24-2.
if not (len(v) == 4 and v[2] == "-" and v[:2].isdigit() and v[3:].isdigit()):
raise ValueError(f"unrecognized PAPPG label: {v!r}")
return v
Phase 2 — Diff versions and resolve the effective version by deadline
Two operations sit at the center of PAPPG tracking. First, given two versions, compute what actually changed — additions, removals, and value changes — so a policy update becomes a short, reviewable list instead of a diff of hundreds of pages. Second, given a proposal deadline, resolve which version was authoritative on that date. Both are deterministic and both belong in the agency schema registry that owns every agency’s versioned profile.
Core transformation steps:
- Diff by rule key. Compare the two rule mappings: keys present only in the newer version are additions, keys only in the older are removals, shared keys with differing values are changes.
- Sort versions once. Order the known versions by
effective_dateascending so the effective window of each is[its date, next date). - Resolve by deadline. Select the version whose effective window contains the proposal deadline — the latest version whose
effective_date <= deadline. - Fail on an out-of-range deadline. A deadline earlier than the oldest known version is a data gap, not version
23-1; raise rather than guess.
from dataclasses import dataclass
@dataclass(slots=True)
class RuleDelta:
rule_id: str
kind: str # "added" | "removed" | "changed"
old: object | None
new: object | None
def diff_versions(old: PappgVersion, new: PappgVersion) -> list[RuleDelta]:
"""Compute the rule-level delta from `old` to `new`."""
deltas: list[RuleDelta] = []
old_keys, new_keys = set(old.rules), set(new.rules)
for rid in sorted(new_keys - old_keys): # in new only
deltas.append(RuleDelta(rid, "added", None, new.rules[rid].value))
for rid in sorted(old_keys - new_keys): # in old only
deltas.append(RuleDelta(rid, "removed", old.rules[rid].value, None))
for rid in sorted(old_keys & new_keys): # shared: value moved?
o, n = old.rules[rid].value, new.rules[rid].value
if o != n:
deltas.append(RuleDelta(rid, "changed", o, n))
return deltas
def resolve_effective(versions: list[PappgVersion], deadline: date) -> PappgVersion:
"""Return the PAPPG version in force on `deadline`."""
ordered = sorted(versions, key=lambda v: v.effective_date)
if deadline < ordered[0].effective_date:
raise ValueError(f"deadline {deadline} predates the earliest known PAPPG")
# Latest version whose effective_date is on or before the deadline.
chosen = ordered[0]
for v in ordered:
if v.effective_date <= deadline:
chosen = v
else:
break # ordered: no later match
return chosen
The diagram below shows both operations at once: two PAPPG versions feeding a rule diff, and a proposal deadline driving the effective-version selection among those same versions.
Phase 3 — Edge cases and solicitation overrides
A calendar of clean, non-overlapping versions is the easy case. Three situations break a naive resolver, and each has a defined correct behavior.
Mid-cycle changes and clarifications. NSF occasionally issues a corrected or clarified PAPPG that keeps the same headline label. Never mutate an existing version record in place — that destroys the reproducibility a re-check depends on. Instead admit a minor revision (24-1.1) with its own effective date, so the effective window splits cleanly and an audit can show exactly which text governed a given deadline.
Back-dated reprocessing. When you re-validate a proposal that was already submitted — for a post-award audit, or after a bug fix — resolve against the version in force on the original deadline, not today’s date. Store the resolved label alongside the proposal at submission time and prefer that pinned label on any later run; only fall back to date resolution when no label was recorded.
Solicitation-specific overrides on top of PAPPG. A specific NSF program solicitation can tighten or override a PAPPG default — a 10-page limit where the guide allows 15, or an extra required section. The PAPPG is the base layer; the solicitation is an overlay applied after. Resolve the PAPPG version first, then apply the solicitation’s overrides so the two sources never silently merge into an untraceable rule set. Keep the layering explicit so each enforced value carries whether it came from the guide or the call.
| Situation | Wrong behavior | Correct behavior |
|---|---|---|
| Clarified guide, same label | Overwrite the record | Add a minor revision (24-1.1) with its own date |
| Re-checking a submitted proposal | Resolve against today | Resolve against the original deadline / pinned label |
| Solicitation caps below PAPPG | Take the looser PAPPG value | Apply solicitation override on top of the resolved version |
| Deadline before earliest version | Default to oldest | Raise a data-gap error and route to review |
Because these version records are shared across every proposal your office runs, they belong under the same semantic-version discipline described in versioning agency schema profiles with semantic versions — a policy change is a versioned data release, not an ad-hoc code edit.
Phase 4 — Validation and pytest
Version resolution is exactly the kind of date-boundary logic that fails on the edge, so test the boundaries explicitly: the day a version takes effect, the day before its successor, and a deadline falling on a changeover date. Use fixed dates in fixtures so a test never depends on the system clock.
from datetime import date
def _v(label: str, d: date, **rules) -> PappgVersion:
return PappgVersion(
label=label,
effective_date=d,
rules={k: PappgRule(rule_id=k, value=v, section="II.C") for k, v in rules.items()},
)
V23 = _v("23-1", date(2023, 1, 30), research_description_page_limit=15)
V24 = _v("24-1", date(2024, 5, 20), research_description_page_limit=15, safe_computing_plan=True)
def test_deadline_after_changeover_picks_newer() -> None:
chosen = resolve_effective([V23, V24], date(2024, 8, 1))
assert chosen.label == "24-1"
def test_deadline_on_effective_date_is_inclusive() -> None:
# A proposal due the very day 24-1 takes effect is governed by 24-1.
assert resolve_effective([V23, V24], date(2024, 5, 20)).label == "24-1"
def test_deadline_before_earliest_version_raises() -> None:
try:
resolve_effective([V23, V24], date(2022, 12, 1))
except ValueError:
pass
else:
raise AssertionError("expected a data-gap error for a pre-range deadline")
def test_diff_reports_added_rule() -> None:
deltas = diff_versions(V23, V24)
added = [d for d in deltas if d.kind == "added"]
assert any(d.rule_id == "safe_computing_plan" for d in added)
A short acceptance checklist catches what unit tests miss: confirm a re-check of an already-submitted proposal reuses the pinned label rather than today’s date, that a clarified guide became a new minor revision instead of an in-place edit, that solicitation overrides sit on top of the resolved version with their source preserved, and that every resolution writes the chosen label into the audit trail. Only a data-driven, versioned model survives the next PAPPG cycle without a code change.
Frequently asked questions
Which PAPPG version governs a proposal — the one current at drafting or at the deadline?
The version in effect on the proposal’s deadline date. NSF applies the guide that is authoritative when the proposal is due, regardless of when the author began writing. That is why the resolver keys off the deadline, and why pinning the resolved label at submission time matters for any later re-check.
How should I store rule changes between PAPPG versions?
Reduce each enforceable clause to a (rule_id, value) pair keyed by a stable identifier, and keep the PAPPG section number for provenance. A change between versions then becomes a set operation over rule keys — added, removed, or value-changed — which is reviewable and testable, rather than a diff of the guide’s prose.
NSF re-issued a clarified guide under the same label. What do I do?
Never overwrite the existing record. Admit it as a minor revision such as 24-1.1 with its own effective date so the effective window splits cleanly. An audit can then show precisely which text governed any deadline, and a proposal re-checked later resolves to the same governing version it was originally judged against.
How do solicitation-specific rules interact with the PAPPG?
The PAPPG is the base layer and the program solicitation is an overlay applied on top. Resolve the PAPPG version by deadline first, then apply the solicitation’s overrides, preserving which source produced each enforced value. Merging them into one flat rule set hides where a stricter limit came from and makes an audit finding hard to trace.
Related
- Parsing NSF PAPPG section headers programmatically — extracting the structure whose rules this version model tracks.
- Agency schema registry — where versioned policy profiles for every agency are stored and served.
- Versioning agency schema profiles with semantic versions — the semantic-version discipline this PAPPG model follows.
- NSF Proposal Guide Taxonomy — the parent section that consumes the effective-version records resolved here.
Up one level: NSF Proposal Guide Taxonomy