Nmtc eligibility
AI skills for NMTC eligibility, bank-CDFI peer benchmarking & HMDA analysis — grounded in audited PyPI tools, not hallucinated.
npx -y skills add Jaypatel1511/cdfi-superpowers --skill nmtc-eligibilityAssembled 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):
| dist | import |
|---|---|
| nmtc-mapper | nmtcmapper |
| nmtc-screener | nmtc_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_eligible | distress_level | meaning |
|---|---|---|
True | deep / severe / lic | verified eligible — the table says YES |
False | ineligible | verified ineligible — the table says NO |
None | unknown | INDETERMINATE — 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, 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, quoted verbatim: NMTC Coalition,
nmtccoalition.org/2023/09/06/new-nmtc-data):
| QLICI close date | Governing data | May this package answer? |
|---|---|---|
| before Sept 1, 2023 | must use 2011–2015 ACS | No — 2011–2015 is not carried here |
| Sept 1, 2023 – Aug 31, 2024 | may use either 2011–2015 or 2016–2020 | Yes, but the 2011–2015 vintage is equally permitted |
| on/after Sept 1, 2024 | must use 2016–2020 ACS on 2020 tracts | Yes — 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
Nolike 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_zoneis a plainbool, so aFalsemeans 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 confidentFalsefor 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 toOptional[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 geocoder —
geocode_address()/check_address()callgeocoding.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 tract36005023702). - CDFI Fund eligibility table —
load_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 raisesEligibilityDownloadError/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 stampsdata_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 includenmtc_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.pdfatcdfifund.gov/system/files/2024-01/, announced atcdfifund.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 atCurrent_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 disagree → raises
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 thingNonemeans 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
GeocoderTransportErrorandAmbiguousAddressErrorsubclassGeocoderError, which subclassesNMTCMapperError(verified by reflection this session) — soexcept NMTCMapperErrorcatches 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.