How to enforce NSF page limits with a Pydantic rule model

The National Science Foundation (NSF) caps the Project Description of a research proposal at 15 pages and requires a separate one-page Project Summary built from three labeled blocks, and the Research.gov intake screen returns a non-conforming proposal without review rather than routing it to a program officer. That “Return Without Review” verdict costs a principal investigator an entire submission window, so the check cannot be a page-count glance the night before the deadline. This guide builds the NSF envelope as a typed Pydantic v2 rule model and a pdfplumber measurement pass, one detector inside the broader page-limit and font enforcement toolkit. The NSF rule set differs from the National Institutes of Health (NIH) approach on almost every axis — what is counted, the font floor, the margin, and whether a summary carries mandated headings — so a validator written for one agency mislabels the other unless the policy lives in data.

Where the NIH validator described in enforcing NIH 12-page limit rules programmatically scopes its count to a Research Strategy and treats the Specific Aims page as a separate one-page artifact, NSF folds the aims-equivalent narrative into the single 15-page Project Description and instead pulls the abstract out into a structured Project Summary. Enforcing the NSF rule therefore means isolating two different regions and applying two different checks — a page ceiling on one and a mandated-heading check on the other.

Phase 1 — Isolate the Project Description and Project Summary

The 15-page ceiling applies to the Project Description alone. It excludes the Project Summary, the References Cited, biographical sketches, the budget and its justification, the Facilities and Data Management Plan, and every other supplementary document that Research.gov uploads as a distinct file. Counting raw sheets over-counts every proposal, so the first phase clips the validation scope to the two regions the NSF rules actually govern, reusing the section-boundary logic the required section mapping engine already produces.

Implementation steps:

  1. Read the upload manifest. A Research.gov package is assembled from separately uploaded PDFs, so the fastest path is the per-document mapping, not a monolithic scan. Locate the Project Description file and the Project Summary file by their component labels before touching glyphs.
  2. Confirm the Project Summary blocks. The one-page summary must present three headed segments — Overview, Intellectual Merit, and Broader Impacts. An unlabeled or merged summary is itself a return-without-review trigger, independent of any page count.
  3. Bound the Project Description. Map its first and last content page and strip everything before and after it from scope, so References Cited and biosketches can never inflate the 15-page tally.
  4. Fallback heuristic. When components arrive flattened into one PDF, anchor on the canonical headers — a Project Summary band near the front and a References Cited header that closes the description — and treat a missing anchor as a routed review case, never a silent pass.
What the NSF 15-page limit counts inside a Research.gov package An NSF proposal package listed top to bottom as six components. The Project Summary is one page and must carry the Overview, Intellectual Merit, and Broader Impacts blocks; it is separate from the page count. The Project Description is highlighted as the only counted section, capped at 15 pages. References Cited has no page cap, biographical sketches are separate, the budget and justification are separate, and the Data Management Plan carries its own two-page cap. Every component except the Project Description is tagged excluded, because counting raw package pages over-counts every proposal. NSF PROPOSAL (RESEARCH.GOV PACKAGE) Project Summary 1 page · Overview / Intellectual Merit / Broader Impacts Project Description the narrative · 15 pages References Cited no page cap Biographical Sketches separate · per-person limit Budget & Justification separate upload Data Management Plan 2-page cap · separate COUNTED excluded excluded excluded excluded 15-page limit applies to this section only
Only the Project Description counts toward the 15-page ceiling; the Project Summary is scoped separately and screened for its three mandated blocks.

Pin the environment so a re-check after a revision reproduces the earlier verdict exactly:

bash
python -m venv .venv
source .venv/bin/activate
pip install "pdfplumber==0.11.4" "pydantic>=2.6" "pytest>=8.0"

Phase 2 — Count content pages and validate 10-point type and 1-inch margins

With the two regions isolated, the enforcement pass reads the Project Description at the glyph level. NSF sets a 10-point minimum font size — one point smaller than the NIH floor — a one-inch (72-point) minimum margin on every edge, which is twice the NIH margin, and a line-spacing ceiling of no more than six lines per inch. All three checks read the same page.chars records that carry each glyph’s size and bounding box, so they run in a single walk.

Codify the policy in a Pydantic v2 model rather than scattering literals, so an out-of-policy configuration fails at construction instead of silently passing every proposal. This is the same Pydantic validation layer discipline the ingestion pipeline applies to parsed data.

python
from pydantic import BaseModel, Field, field_validator


class NSFPageRule(BaseModel):
    """Versioned NSF formatting envelope for one proposal type."""

    proposal_type: str
    description_max_pages: int = 15           # Project Description ceiling
    summary_max_pages: int = 1                # Project Summary is a one-pager
    min_font_pt: float = 10.0                 # one point below NIH's 11 pt
    min_margin_pt: float = 72.0               # 1.0 in x 72 pt/in (NIH uses 36)
    summary_blocks: tuple[str, ...] = ("Overview", "Intellectual Merit", "Broader Impacts")

    @field_validator("description_max_pages", "summary_max_pages")
    @classmethod
    def _positive_ceiling(cls, v: int) -> int:
        if v < 1:
            raise ValueError("page ceilings must be positive")
        return v

    @field_validator("min_margin_pt")
    @classmethod
    def _one_inch_or_less(cls, v: float) -> float:
        if not 0 < v <= 72:
            raise ValueError("min_margin_pt must fall within (0, 72] points")
        return v

The measurement pass counts only pages that carry content, records each glyph-level violation with its coordinates, and confirms the Project Summary presents all three labeled blocks:

python
import re
import pdfplumber
from dataclasses import dataclass, field


@dataclass(slots=True)
class Violation:
    region: str        # "description" | "summary"
    page: int
    kind: str          # "font" | "margin" | "page_count" | "missing_block"
    detail: str


@dataclass(slots=True)
class NSFResult:
    proposal_type: str
    description_pages: int = 0
    violations: list[Violation] = field(default_factory=list)

    @property
    def is_compliant(self) -> bool:
        return not self.violations


def validate_project_description(
    pages: list["pdfplumber.page.Page"],
    rule: NSFPageRule,
) -> NSFResult:
    """Count content pages and flag 10 pt / 1 in violations. Description only."""
    result = NSFResult(rule.proposal_type)
    for n, page in enumerate(pages, start=1):
        chars = page.chars
        if not chars:
            continue  # blank trailing page is not counted
        result.description_pages += 1
        right, bottom = page.width - rule.min_margin_pt, page.height - rule.min_margin_pt
        for ch in chars:
            if ch.get("size", 0) and ch["size"] < rule.min_font_pt:
                result.violations.append(Violation(
                    "description", n, "font", f'{ch["size"]:.1f}pt < {rule.min_font_pt}pt'))
            if ch["x0"] < rule.min_margin_pt or ch["x1"] > right \
                    or ch["top"] < rule.min_margin_pt or ch["bottom"] > bottom:
                result.violations.append(Violation(
                    "description", n, "margin", "glyph inside the 1-inch margin"))
    if result.description_pages > rule.description_max_pages:
        result.violations.append(Violation(
            "description", result.description_pages, "page_count",
            f"{result.description_pages} pages > {rule.description_max_pages}"))
    return result


def validate_summary_blocks(summary_text: str, rule: NSFPageRule) -> list[Violation]:
    """The Project Summary must present all three labeled blocks."""
    missing = [
        b for b in rule.summary_blocks
        if not re.search(rf"^\s*{re.escape(b)}\b", summary_text, re.I | re.M)
    ]
    return [Violation("summary", 1, "missing_block", b) for b in missing]

Two cautions carry over from the NIH build and one is NSF-specific. Use page.chars, not extract_words(), because the word grouper discards the size key the 10-point floor depends on. Correct for baseline-shifted superscripts before flagging compressed spacing. And unlike NIH’s 0.5-inch margin, the NSF one-inch band is wide enough that a full-bleed figure spilling a caption into it is a common false trigger — scope margin checks to the narrative zone, not to embedded graphics.

Phase 3 — NSF-specific overrides: CAREER, MRI, and the references exemption

The 15-page figure is the standard research-proposal default, not a universal constant. NSF program solicitations override it, and the current Proposal & Award Policies & Procedures Guide (PAPPG) is authoritative only for the version in force. Always read the ceiling from the solicitation, cross-checking against the NSF proposal guide taxonomy record for that program, and keep the per-program numbers in the threshold tuning for compliance layer so a policy change is a data edit rather than a code change.

  • CAREER. The Faculty Early Career Development Program keeps the 15-page Project Description but requires the narrative to integrate a described education plan, and it adds a departmental letter as a separate document that is exempt from the count. The page rule is unchanged; the required-content check is what differs.
  • MRI. Major Research Instrumentation proposals set their own Project Description limit in the solicitation and add instrument-specific sections, so the default 15 must be overridden per call rather than assumed.
  • References Cited exemption. The References Cited section has no page limit and is uploaded separately, so it must be excluded from the description tally — the single most common source of a false over-length flag when a monolithic PDF is measured naively.

The table below contrasts the NSF envelope with the NIH one this toolkit already enforces, dimension by dimension, so a shared engine can serve both by swapping the rule instance.

Rule input NSF (standard research) NSF CAREER NSF MRI NIH R01 (contrast)
Counted section Project Description Project Description Project Description Research Strategy
Page ceiling 15 15 Per-solicitation 12
Separate one-pager Project Summary (3 blocks) Project Summary (3 blocks) Project Summary (3 blocks) Specific Aims
Min font size 10 pt 10 pt 10 pt 11 pt
Min margin 1 in 1 in 1 in 0.5 in
Extra required item Departmental letter Instrument sections
Authority PAPPG (current) Program solicitation Program solicitation Activity-code NOFO

Phase 4 — Validation, pytest, and a return-without-review guard

NSF’s return-without-review screen is unforgiving, so the validator earns tests that pin each failure mode against committed fixtures rather than live Research.gov exports, which change without notice.

python
def test_sixteen_page_description_is_flagged() -> None:
    rule = NSFPageRule(proposal_type="standard")
    pages = load_fixture_pages("tests/fixtures/nsf_16_page_desc.pdf")  # description only
    result = validate_project_description(pages, rule)
    assert not result.is_compliant
    assert any(v.kind == "page_count" for v in result.violations)


def test_nine_point_font_is_flagged() -> None:
    rule = NSFPageRule(proposal_type="standard")
    pages = load_fixture_pages("tests/fixtures/nsf_9pt_font.pdf")
    result = validate_project_description(pages, rule)
    assert any(v.kind == "font" for v in result.violations)


def test_summary_missing_broader_impacts_block() -> None:
    rule = NSFPageRule(proposal_type="standard")
    text = "Overview\n...\nIntellectual Merit\n..."  # no Broader Impacts heading
    missing = validate_summary_blocks(text, rule)
    assert any(v.detail == "Broader Impacts" for v in missing)


def test_mri_override_reads_solicitation_ceiling() -> None:
    rule = NSFPageRule(proposal_type="MRI", description_max_pages=20)
    assert rule.description_max_pages == 20  # never hard-coded to 15

A manual acceptance pass catches what fixtures miss: confirm References Cited and biosketches were excluded from the count, that the Project Summary’s three blocks were each detected as headings and not as mid-sentence phrases, that the ceiling was read from the solicitation, and that a flattened or corrupt package routes to review instead of crashing the batch. Only then release the structured result — bound, as in the NIH walkthrough, to a hash of the checked bytes — into the audit log so the verdict is reproducible across the funding cycle.

Frequently asked questions

Does the NSF 15-page limit include the Project Summary or the References Cited?

No. The 15-page ceiling covers the Project Description alone. The one-page Project Summary, the References Cited, biographical sketches, the budget and its justification, and the Data Management Plan are all separate uploads and are excluded from the count. Phase 1 isolates the Project Description first precisely so those sections cannot inflate the tally.

How is the NSF check different from the NIH page-limit check?

Four ways. NSF counts a 15-page Project Description where NIH counts a 12-page Research Strategy; NSF’s font floor is 10 pt against NIH’s 11 pt; NSF requires one-inch margins where NIH allows a half inch; and NSF pulls the abstract into a structured Project Summary with three mandated blocks rather than a separate Specific Aims page. Because the policy lives in the NSFPageRule model, one engine serves both agencies by swapping the rule instance.

What makes the Project Summary fail even when it fits on one page?

A summary that does not present the Overview, Intellectual Merit, and Broader Impacts as three labeled segments is a return-without-review trigger regardless of length. The validate_summary_blocks check anchors each block name to the start of a line so a merged or unlabeled summary is caught before the proposal is ever routed.

Is 15 pages a safe constant to hard-code for CAREER or MRI proposals?

No. CAREER keeps 15 pages but adds required content and a separate departmental letter, and MRI sets its own ceiling in the solicitation. Read the limit from the program solicitation via the NSF proposal guide taxonomy record and keep the number in the threshold-tuning layer so a program change never means editing enforcement code.

Up one level: Page Limit & Font Enforcement