agentsclimarketplace

Nmtc eligibility

Skill Jaypatel1511/cdfi-superpowers/skills/nmtc-eligibility

AI skills for NMTC eligibility, bank-CDFI peer benchmarking & HMDA analysis — grounded in audited PyPI tools, not hallucinated.

Install
npx -y skills add Jaypatel1511/cdfi-superpowers --skill nmtc-eligibility

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • 28 days oldThe repository was created 28 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Check whether a U.S. address or census tract is New Markets Tax Credit (NMTC) eligible as a Low-Income Community, and screen a project's NMTC feasibility. Use when the user asks "is this address/tract NMTC eligible", about "distress criteria", "severe distress", "deep distress", "low-income community" / "LIC" status, or wants a first-pass NMTC deal feasibility score. Backed by the audited PyPI packages nmtc-mapper and nmtc-screener — never estimate eligibility from general knowledge.

SKILL.md

25.4 KB, as published. Nobody here has run it

NMTC Eligibility

Grounds NMTC eligibility answers in two published, audited packages instead of guessing. nmtc-mapper geocodes an address to a census tract and looks the tract up in the CDFI Fund's NMTC Low-Income Community (LIC) eligibility table. nmtc-screener runs a structured first-pass feasibility score on a project.

When to use

  • "Is 2400 Grand Concourse, Bronx NY NMTC eligible?"
  • "Is census tract 36005023702 a low-income community?"
  • "Does this tract qualify for severe distress / the 85% investment commitment?"
  • "Screen this $8.5M grocery project for NMTC feasibility."

When NOT to use

  • Anything requiring the official CDFI Fund allocation decision — this is a screening/eligibility lookup, not an allocation award or legal determination.
  • Historic Tax Credit, LIHTC, or Opportunity Zone investment structuring (OZ flag is reported by the mapper, but OZ deal mechanics are oz-tracker).
  • NMTC transaction / credit / capital-stack modeling beyond the screener's first-pass estimate — that depth lives in nmtc-calc.

Install

pip install "nmtc-mapper>=0.4.1" nmtc-screener

Verified this session (PyPI): nmtc-mapper 0.4.1, nmtc-screener 0.1.0 (nmtc-calc 0.2.1 is pulled in as a dependency). The >=0.4.1 floor is not cosmetic — 0.4.0 is where nmtc_eligible became tri-state (see below), and 0.4.1 binds the geocoder vintage to the eligibility table's 2020 tract basis (see Data dependencies & fragility). A reader on 0.3.x following this skill's third-state guidance would never see None, because 0.3.x collapses "could not determine" into False.

Import names (dist name ≠ import name):

distimport
nmtc-mappernmtcmapper
nmtc-screenernmtc_screener

The answer space is TRI-STATE (0.4.0 — read this before anything else)

nmtc_eligible is Optional[bool]True, False, or None. There are three outcomes, not two:

nmtc_eligibledistress_levelmeaning
Truedeep / severe / licverified eligible — the table says YES
Falseineligibleverified ineligible — the table says NO
NoneunknownINDETERMINATE — no verdict was reached

None / "unknown" means "could not be determined." It is NOT "not eligible." Never render None as "no," "ineligible," "not eligible," or a falsy False. A None reached two ways: the address did not geocode, or the tract is absent from the ~85k-tract universe (a bad/mistyped GEOID, or a vintage mismatch). Neither is a NO — both are "we don't know."

EligibilityResult.eligibility_status (property, 0.4.0) collapses this into one explicit four-way string so you never have to infer intent from a None:

verified-eligible  |  verified-ineligible  |  not-found  |  geocode-failed

not-found and geocode-failed are the two indeterminate cases. summary() prints indeterminate results as ❓ UNKNOWN — … (indeterminate, NOT ineligible) on the eligibility line itself — that inline qualifier is defined in nmtcmapper/eligibility/checker.py::EligibilityResult.summary, not a footer.

The hard failure rule (non-negotiable)

If a tool errors, report the error verbatim and stop. NEVER estimate NMTC eligibility from general knowledge, from the address alone, or from what a neighborhood "seems like." Eligibility is a specific tract-level lookup against a specific CDFI Fund table; there is no valid way to infer it. A wrong "eligible" answer can send a real deal down a dead end. A user asking for a "best guess," "ballpark," or "rough" eligibility answer does not override this rule; decline and report that the lookup failed.

The third-state rule (non-negotiable — the load-bearing addition)

The hard failure rule above governs a tool that errors. This rule governs a lookup that succeeds and returns UNKNOWN (nmtc_eligible is None, distress_level == "unknown", eligibility_status in {not-found, geocode-failed}). An unknown verdict is a result, not an error — and it must be reported as its own answer:

  • Report it as "NMTC eligibility could not be determined for this tract", and name the tract ID (or state the address did not geocode). Say why: tract absent from the vintage's universe, or address failed to geocode.
  • Never collapse it into "not eligible," "no," or "ineligible."
  • Never soften it into "probably not eligible" or "likely ineligible."
  • Never resolve it from a neighboring tract, the ZIP, the city, or the address's apparent neighborhood — the same anti-pressure posture as the best-guess rule above.

Why, inline (a model reading this needs the reason, not just the rule): a None rendered as "not eligible" is a fabricated negative. It kills a deal that may genuinely qualify — the tract simply was not checkable in this vintage, and the correct next step is to re-check against the vintage in force at application time, not to declare the deal dead. A false "ineligible" is exactly as damaging as a false "eligible," in the opposite direction.

The vintage-scope rule (non-negotiable)

This package carries the 2016–2020 ACS vintage ONLY (the 85,395-tract table below). NMTC LIC eligibility is governed by the ACS vintage tied to the deal's QLICI close date, and answering the wrong vintage confidently is the same class of failure as rendering None as "not eligible" — a confident answer against data that does not govern. The CDFI Fund's transition rules (primary: CDFI Fund, 2016-2020 ACS Data FAQ, updated Feb 1, 2024NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf at cdfifund.gov/system/files/2024-01/, announced at cdfifund.gov/news/567; secondary, quoted verbatim: NMTC Coalition, nmtccoalition.org/2023/09/06/new-nmtc-data):

QLICI close dateGoverning dataMay this package answer?
before Sept 1, 2023must use 2011–2015 ACSNo — 2011–2015 is not carried here
Sept 1, 2023 – Aug 31, 2024may use either 2011–2015 or 2016–2020Yes, but the 2011–2015 vintage is equally permitted
on/after Sept 1, 2024must use 2016–2020 ACS on 2020 tractsYes — authoritative

Apply it:

  • Close date before Sept 1, 2023 → the 2016–2020 table does not govern; 2011–2015 ACS does, and this package does not carry it. Do not answer from the 2016–2020 table. Say so and route the user to the CDFI Fund's CIMS (CDFI Information Mapping System), which carries the governing vintage.
  • Close date in the Sept 1, 2023 – Aug 31, 2024 window → the 2016–2020 answer is valid and permitted, but state that 2011–2015 is also an acceptable basis in this window, so the deal may qualify under the other vintage even if 2016–2020 says NO.
  • Close date on/after Sept 1, 2024, or unknown → state which vintage the answer is based on (2016–2020) and that it is valid for QLICIs closing on/after Sept 1, 2023 and mandatory on/after Sept 1, 2024. If the close date is unknown or earlier, confirm it before relying on the answer — for a pre-Sept-1-2023 closing the 2011–2015 vintage (not carried here) governs.

Island Areas are a second scope hole of the same class. This table's ~85,395 rows cover the 50 states + DC + Puerto Rico only (PR verified present this session). The Island Areas — American Samoa, Guam, the CNMI, and the US Virgin Islands — were NOT covered by the 2016–2020 ACS and are absent from this package's table entirely. The CDFI Fund publishes a separate Island Areas NMTC LIC file — NMTC_LIC_Territory_2020_December_2023.xlsx, built on the 2020 Island Areas Decennial Census (not the 2016–2020 ACS), released Dec 19, 2023 and available in CIMS as of Jan 25, 2024 — which this package does not carry. Per the CDFI Fund's 2016-2020 ACS Data FAQ (updated Feb 1, 2024, General Q3): "For Island areas, CDEs should continue to use 2011-2015 NMTC Low-Income Community eligibility data and follow the same transition dates outlined in question 3." An Island Area address/tract that is absent here is therefore "not carried by this package," never "ineligible" — route to CIMS or to the separate territory file; do not answer it from this 2016–2020 ACS table.

Worked example — address eligibility (executed)

import nmtcmapper as nm

m = nm.NMTCMapper()
result = m.check_address("2400 Grand Concourse, Bronx, NY 10458")
result.summary()          # prints a formatted block; returns None
print(result.eligibility_status)   # -> 'verified-eligible'

Actual output this session (nmtc-mapper 0.4.1, clean-venv PyPI install; byte-identical on 0.4.0):

NMTC Eligibility Result
==================================================
  Address:          2400 Grand Concourse, Bronx, NY 10458
  Census Tract:     36005023702
  NMTC Eligible:    ✅ YES
  Distress Level:   SEVERE
  Description:      Severe Distress — qualifies for 85% investment commitment

  Poverty Rate:     32.1%
  AMI Ratio:        53.2%
  Unemployment:     10.7%
  Non-Metro:        No
  Opportunity Zone: No
  High Migration:   No

eligibility_status is verified-eligible. Tract 36005023702 verified present in the live 2016–2020 table this session.

Do not repeat the Opportunity Zone: No line as fact. Verified this session, 36005023702 is genuinely absent from the loaded OZ list (is_opportunity_zone is False) — which, under the OZ rule below, is exactly the ambiguous negative. Narrate it as "not confirmed as an Opportunity Zone," never "not an Opportunity Zone." The summary() block prints a bare No; the skill's job is to re-narrate that No, not echo it — a False here cannot distinguish not-designated from a 2010/2020 tract-vintage miss.

The EligibilityResult fields (read these, don't re-derive): address, tract_id, nmtc_eligible (Optional[bool] — True / False / None), distress_level (str: 'deep', 'severe', 'lic', 'ineligible', 'unknown'), poverty_rate, ami_ratio, unemployment_rate, is_non_metro, is_high_migration_rural, is_nmtc_native_area (always False — see note), severe_distress, deep_distress, geocode_success, is_opportunity_zone, tract_found (bool, 0.4.0 — False when the tract is absent from the table). Properties: distress_description (plain-English line, e.g. "Severe Distress — qualifies for 85% investment commitment") and eligibility_status (the four-way string above).

is_nmtc_native_area is always False in this package — no column in the live .xlsb populates it (verified this session: False for all 85,395 tracts, True count 0; nmtc-mapper 0.4.1 Known Issues). It is hardcoded/defaulted, so a False here means "not determined," not "not a Native Area." Do not report it as a fact about a tract.

Note .summary is a method — call result.summary(). result.summary alone returns the bound method object, not the text.

Worked example — verified-ineligible tract + the NaN honesty rule (executed)

A tract that is present in the table with an explicit NO flag — distinct from an absent tract (next example). Verified this session: 11001980000 is one of the 85,395 rows, flagged not-eligible, with null (NaN) poverty and income. The CDFI Fund documents several reasons a tract carries null demographics: per its 2016-2020 ACS Data FAQ (updated Feb 1, 2024; NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf, General Q2), the Census Bureau could not estimate income or poverty for such tracts — a significant majority have no or very low population, and the remainder's population is largely in group quarters (e.g. prisons, college dormitories), which the ACS excludes from income and poverty calculations. Which of those applies to 11001980000 is not something this lookup reports, so do not assert it.

import nmtcmapper as nm
m = nm.NMTCMapper()
r = m.check_tract("11001980000")   # present, explicit NO, null demographics
print(r.nmtc_eligible, r.distress_level, r.poverty_rate, r.ami_ratio)
print(r.eligibility_status, "| tract_found:", r.tract_found)

Actual output this session:

False ineligible nan nan
verified-ineligible | tract_found: True

poverty_rate and ami_ratio came back NaN — the Fund does not publish an income or poverty estimate for this tract (see the FAQ Q2 reasons above) — render them "not available," never invent a number. nmtc_eligible=False / eligibility_status='verified-ineligible' / tract_found=True is a real NO from the table — the answer is ineligible. This is NOT the third state; contrast the next example, where the tract is absent and the honest answer is "unknown."

Worked example — the third state: an ABSENT tract (executed)

The teaching case for None/"unknown". A syntactically valid GEOID that is not in the 2016–2020 universe (a mistyped tract, or one from a different vintage). Verified absent this session: 36061980000 is not among the 85,395 rows.

import nmtcmapper as nm
m = nm.NMTCMapper()
r = m.check_tract("36061980000")   # a tract ABSENT from the 2016-2020 universe
print(r.nmtc_eligible, r.distress_level, r.eligibility_status, r.tract_found)
r.summary()

Actual output this session:

None unknown not-found False
NMTC Eligibility Result
==================================================
  Address:          Census Tract 36061980000
  Census Tract:     36061980000
  NMTC Eligible:    ❓ UNKNOWN — tract not in eligibility table (indeterminate, NOT ineligible)
  Distress Level:   UNKNOWN
  Description:      Indeterminate — eligibility not verified (no match / tract absent)
  Non-Metro:        No
  Opportunity Zone: No
  High Migration:   No

Report this as: "NMTC eligibility could not be determined for tract 36061980000 — it is absent from the 2016–2020 eligibility universe." Do not report it as "not eligible." The Description line — "Indeterminate — eligibility not verified (no match / tract absent)" — is the verbatim value of DISTRESS_LEVELS["unknown"] in nmtcmapper/data/schema.py.

The program administrator documents this exact case — it is not just a first-principles argument. The CDFI Fund's 2016-2020 ACS Data FAQ (updated Feb 1, 2024; NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf, Q10, "I can't find a 2010 census tract in the 2016-2020 ACS Low-Income Community data. Where is it?") explains that the 2011–2015 data is built on 2010 census tracts and the 2016–2020 data on 2020 tracts, and that as part of the 2020 census the Bureau eliminated certain 2010 tracts and folded their land into new tracts — so a tract absent from this table is a vintage artifact, not an ineligibility finding. The FAQ routes the reader to the Census Bureau tract-relationship files and to CIMS for geocoding; do the same rather than reporting "not eligible."

The same third state reaches you from check_address when an address does not geocode: nmtc_eligible=None, distress_level="unknown", eligibility_status="geocode-failed", and summary() prints "❓ UNKNOWN — address could not be geocoded (indeterminate, NOT ineligible)." (executed this session against a deliberately unresolvable address).

Worked example — project feasibility screen (executed)

import nmtc_screener as ns

r = ns.run_screening(
    project_name="Maple Street Grocery",
    location="Bronx, NY (Tract 36005023702)",
    total_project_cost=8_500_000,
    project_type="commercial",
    annual_revenue=3_200_000,
    lic_status="yes",          # accepted: "yes" | "unknown" | (anything else = treated as not-LIC)
)
print(r.qualification_likelihood, r.qualification_score)

Actual output this session (nmtc-screener 0.1.0):

HIGH 95

qualification_reasons (actual):

Project is in a confirmed Low Income Community census tract (+35 pts)
Project type 'Other': Eligibility depends on specific business activities and community benefit. (+10 pts)
Project cost ≥$5MM — meets minimum viable deal size (+5 pts)
Revenue ($3,200,000/yr) covers estimated debt service at 1.25x DSCR (+5 pts)

Note: the screener does not currently map project_type='commercial' to a specific category — it scores it as 'Other' (+10 pts), so this component of the score is type-agnostic; treat the result as a first-pass heuristic, not underwriting.

run_screening signature (positional or keyword): run_screening(project_name, location, total_project_cost, project_type, annual_revenue, lic_status). The ScreeningResult also carries estimated_allocation, transaction_result, credit_result, subsidy_result, and a plain_english_summary.

lic_status is the user's assertion, not a lookup. If the user has not confirmed LIC status, either run nmtcmapper first and pass the real answer, or pass "unknown" — do not pass "yes" on assumption. And if the mapper returned the third state (None/"unknown"), pass "unknown" to the screener — never "yes", and never "no", because you do not know. The screener's score is only as honest as this input.

Output-presentation rules

  • Always report the census tract ID alongside any eligibility verdict — it is the unit the answer is actually about — and, for an indeterminate result, name the tract (or state the address did not geocode) as part of the "could not be determined" answer.
  • State the eligibility table vintage (below) so the user knows what the answer is current as of.
  • Render NaN/None demographic fields as "not available," never as a number.
  • Report a None/"unknown" eligibility verdict as "could not be determined," never as "not eligible." (See the third-state rule.)
  • Distinguish the mapper's tract-eligibility lookup (authoritative table lookup) from the screener's feasibility score (a heuristic first pass).
  • OZ status is asymmetric — treat a No like the third state. NMTC eligibility and OZ status are independent, so report OZ separately either way. An OZ "Yes" (is_opportunity_zone is True) may be reported as fact. An OZ "No" must NOT be reported as "not an Opportunity Zone" — report it as "not confirmed as an Opportunity Zone (this package cannot distinguish not-designated from a 2010/2020 tract-vintage miss; ~16% of designations are unreachable)." is_opportunity_zone is a plain bool, so a False means EITHER not-designated OR a vintage miss: OZs were designated in 2018 on 2010 tracts, the geocoder now returns 2020 tracts, and the ~16% that split/merged/renumbered can never match — a confident False for 1,408 of the 8,764 designated OZs (16.1%). Same posture as the third-state rule — an unknowable negative is not a negative. (nmtc-mapper 0.4.1 Known Issues; the fix to Optional[bool] is slated for 0.5.0. The 8,764-tract OZ load verified this session; the 1,408 vintage-miss count is from the 0.4.1 Known Issues.)

Data dependencies & fragility (must document)

  • Census geocodergeocode_address() / check_address() call geocoding.geo.census.gov. This host has no cloud WAF, so it works from cloud/datacenter IPs (unlike the CRA/Cloudflare-blocked hosts elsewhere in the portfolio). Verified working this session (geocoded 2400 Grand Concourse to tract 36005023702).
  • CDFI Fund eligibility tableload_eligibility_table() downloads the NMTC LIC eligibility workbook from cdfifund.gov and caches it under ~/.nmtcmapper/cache/. CDFI Fund URLs move: the Fund relocates these files periodically, so a download can fail even though the package is fine. On failure the loader now raises EligibilityDownloadError / EligibilityParseError (0.3.4+) rather than silently substituting demo data — say the lookup failed and why; never guess eligibility. (For offline demos only, NMTCMapper.from_sample() exists and stamps data_source == "sample"; its 12 synthetic tracts are NEVER valid for a real answer.)
  • Tract vintage in force (verified this session): the cached table is NMTC_LIC_Eligibility_2016_2020.xlsb, 85,395 census tracts, sourced from the CDFI Fund's Aug-2025b Severe/Deep Distress release. Normalized columns include nmtc_eligible, distress_level, poverty_rate, ami_ratio, unemployment_rate, is_non_metro, is_high_migration_rural, is_nmtc_native_area, severe_distress, deep_distress. This is a 2016–2020 ACS-based vintage. Per the CDFI Fund's transition rules this vintage became usable as of Sept 1, 2023 and is mandatory for QLICIs closing on/after Sept 1, 2024 (primary: CDFI Fund, 2016-2020 ACS Data FAQ, updated Feb 1, 2024 — NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf at cdfifund.gov/system/files/2024-01/, announced at cdfifund.gov/news/567; secondary, stating the mandatory date plainly: NMTC Coalition, nmtccoalition.org/2023/09/06/new-nmtc-data). This package carries only this vintage — see the vintage-scope rule above before answering for any deal whose QLICI closed before Sept 1, 2024. Report the vintage with the answer; the CDFI Fund periodically re-bases eligibility, and a deal must be checked against the vintage in force at its QLICI close date. 0.4.0 validates this structure at load (EligibilitySchemaError / EligibilityValueError) before trusting any row, because the loader binds columns positionally.
  • Geocoder vintage is bound to the table (0.4.1). 0.4.1 pins the Census geocoder to the eligibility table's 2020 tract basis (schema.TRACT_VINTAGE). 0.4.0 and earlier geocoded at Current_Current, which since the 2022 ACS returns COG-based county FIPS for Connecticut while the CDFI Fund table keeps legacy county FIPS — so every CT address missed the lookup (883 tracts, 316 eligible). Fixed in 0.4.1; noted here only as data-source fragility context.

Failure modes

Geocoder (0.4.0 splits the old single None return into four distinct outcomes). geocode_address / check_address now behave as follows. All four executed this session against the installed 0.4.0 wheel: the no-match, disagree, and agree branches ran against the live Census endpoint; the transport failure was induced by pointing the geocoder URL at a closed local port, which raised GeocoderTransportError (connection/DNS, isinstance NMTCMapperError == True), message naming the failure kind and the address.

  • Transport / HTTP-status / decode failure (403, 5xx, timeout, connection/DNS, non-JSON body), after retries are exhausted → raises GeocoderTransportError. The message names the failure kind and the address. Report it verbatim and stop.

  • Address matches multiple tracts that disagreeraises AmbiguousAddressError, naming the candidate tracts; it refuses to silently take the first match. Report it and stop — do not pick one.

  • Genuine no-match (HTTP 200, zero address matches) → returns None. This is the only thing None means now. It is the third state, not a NO: report "address could not be geocoded" / eligibility_status='geocode-failed' — do not fall back to a ZIP-code or city-level guess.

  • Matches that all agree on the same tract → proceeds normally, returning that tract.

    Both GeocoderTransportError and AmbiguousAddressError subclass GeocoderError, which subclasses NMTCMapperError (verified by reflection this session) — so except NMTCMapperError catches every error the package raises. Source: nmtcmapper/exceptions.py, nmtcmapper/geocoder/census.py.

Tract absent from the table (check_tract on a GEOID not in the ~85k universe): not a failure — it is the third state. Returns nmtc_eligible=None, distress_level="unknown", tract_found=False, eligibility_status="not-found". Report "could not be determined," never "ineligible." (See the third-state rule and its worked example.)

CDFI Fund file download fails / 404 (URL moved): raises EligibilityDownloadError / EligibilityParseError. Report the error and that eligibility could not be determined. Do not answer from memory.

NaN demographic fields (a real tract with no measurable population, e.g. 11001980000): report "not available," never fabricate. This is orthogonal to the tri-state verdict — the tract is genuinely False/verified-ineligible; only its demographics are null.

Screener lic_status misuse: passing "yes" without confirming LIC status produces a falsely high score. Confirm first; pass "unknown" when the mapper returned the third state.

Caveats

  • This is an eligibility and feasibility screening layer, not an allocation award, legal opinion, or the CDFI Fund's determination.
  • Eligibility is tract-specific and vintage-specific. An "eligible" answer is only valid for the table vintage named above.
  • An "unknown" answer is a real answer — "could not be determined for this tract/address," never "not eligible."
  • The screener's score and estimated allocation are first-pass heuristics to triage deals, not underwriting or a commitment.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.