What this page shows
Nothing a lender pays for, writes or tells us is an input to any order on this site. Verified is a paid listing. Order is not affected by payment. That is a claim a reader should be able to check, so the code is printed here, whole. You do not need to program to check it: search the two ordering files below for the words "plan", "price", "account" or "billing". None is there. The word "paid" appears once, in the last line of the first file, in the sentence that says payment is not an input.
Latest quarterly comparison, November 9, 2026: passed. 80 orderings were run with no lender paid and again with half of every list marked paid, and 0 came out in a different order. The plain-words version of all this is on the methodology page.
1. The order, as the code prints it
Grouped by kind of lender, because banks, credit unions and non-banks are not comparable. Inside a group, in this order: if the deal names a county or a metro area, loans like this one made there, capped at 10, so a lender with more of them comes first; then level; then loans like this one, capped at 10; then the share of the lender's own loans in this lane that are like this one, in 5-point steps; then the most recent evidence; then room above the loan amount on the size estimate; then name. Every step uses public filings and the deal as typed. Nothing a lender has paid or told us is an input.
2. The ordering code
rank.js (126 lines)
Puts the lenders in order. It is handed a list of lenders with their public evidence for the deal, and sorts them by the steps in the sentence above. Nothing else comes in.
SHA-256 8ab4f5f0ab79f73e33c4a8915b98490eb0bb07c91539b2b8d6b36aee644cbe85
// The neutral order. Published on the methodology page and reproducible by hand: every step below is a
// fact from a public filing or the loan a person typed in. It runs on plain objects with no platform
// access at all, and the build check keeps it that way.
//
// A row (see comps.js) carries the evidence as numbers: `level` is decided here from that evidence, the
// five dots come from it, and the comparator is the published order inside a group.
import { DONE_THIS_MIN, BOOK_MIN_DOLLARS, BOOK_MIN_SHARE_PCT, LIKE_LOANS_CAP, SHARE_STEP, LEVELS, GROUPS } from './rank-lanes.js';
/** Book test for "Holds this kind of loan": at least $5M, or at least 5% of the lender's loans. */
export function hasBook(book, loans) {
if (!book || book <= 0) return false;
if (book >= BOOK_MIN_DOLLARS) return true;
return !!loans && (book / loans) * 100 >= BOOK_MIN_SHARE_PCT;
}
/** Local depository, out-of-state depository, or non-bank. */
export function groupOf(row) {
if (row.type === 'nonbank') return 'nonbank';
return row.office ? 'local' : 'out_of_state';
}
// The level of one lender on one deal.
// full loans in the state that fit kind, size and purpose in the latest period
// near* loans that miss exactly one of those
// done_this two or more loans that fit
// done_similar one that fits, or any that miss on one dimension, or a credit union reporting this kind of lending
// done_in_state the 1-4 unit investor lane: loans of the kind in the state, with no way to check their size or purpose
// holds_book a book of the right kind and an office in the state, and nothing better
// elsewhere a slight sign only: loans in this lane but not close to this deal, or a book with no office here
export function levelOf(ev) {
if (ev.inactive) return ev.hasAny ? 'ruled_out' : null;
if (ev.full >= DONE_THIS_MIN) return 'done_this';
if (ev.full === 1 || ev.nearSize || ev.nearPurpose || ev.nearKind || ev.neighborFull || ev.fullPrior) return 'done_similar';
if (ev.aggregateInState > 0) return 'done_in_state';
if (ev.cuCount > 0 && ev.office) return 'done_similar';
if (ev.book && ev.office) return 'holds_book';
if (ev.weakEvidence) return 'elsewhere';
return null;
}
// Filled: a recorded loan shows it. Half: a balance sheet or office suggests it. Empty: nothing public tells us.
const dot = (key, state, note) => ({ key, state, note });
export function dotsFor(ev, row, deal, ctx) {
const place = ctx.placeName;
const recorded = ev.full > 0;
const size = !deal.amount ? dot('size', 'empty', 'No amount given')
: ev.full > 0 || ev.nearPurpose > 0 || ev.nearKind > 0 || ev.neighborFull > 0 || ev.fullPrior > 0 ? dot('size', 'filled', 'A recorded loan this size')
: ev.nearSize > 0 ? dot('size', 'half', 'Recorded loans just outside this size')
: ev.cuAvgInBand ? dot('size', 'half', 'Reported average near this size')
: ev.medianInBand ? dot('size', 'half', 'National median near this size')
: ev.capOk ? dot('size', 'half', 'Size estimate covers this loan')
: dot('size', 'empty', 'Nothing public on size');
const kind = recorded || ev.nearSize > 0 || ev.nearPurpose > 0 || ev.neighborFull > 0 || ev.fullPrior > 0 || ev.aggregateInState > 0 ? dot('kind', 'filled', 'Recorded loans of this kind')
: ev.cuCount > 0 ? dot('kind', 'filled', 'Reports making this kind of loan')
: ev.book ? dot('kind', 'half', 'Holds this kind of loan')
: ev.nearKind > 0 || ev.laneLoans > 0 ? dot('kind', 'half', 'Recorded loans in a related kind')
: dot('kind', 'empty', 'Nothing public on kind');
const purpose = !deal.purpose ? dot('purpose', 'empty', 'No purpose given')
: !ctx.purposeKnown ? dot('purpose', 'empty', 'Not in the public record')
: recorded || ev.nearSize > 0 || ev.nearKind > 0 || ev.neighborFull > 0 || ev.fullPrior > 0 ? dot('purpose', 'filled', 'A recorded loan for this purpose')
: dot('purpose', 'empty', 'No recorded loan for this purpose');
const inPlace = ev.laneInState > 0;
const placeDot = ev.aggPlace > 0 ? dot('place', 'filled', `Five or more recorded loans in ${ctx.placeLabel || place}`)
: inPlace || ev.aggregateInState > 0 ? dot('place', 'filled', `A recorded loan in ${place}`)
: row.office ? dot('place', 'half', `Office in ${place}`)
: ev.neighborFull > 0 || ev.neighborAny > 0 ? dot('place', 'half', 'A recorded loan in a neighboring state')
: dot('place', 'empty', `Nothing public on ${place}`);
const recent = ev.laneRecent > 0 || ev.aggregateInState > 0 || ev.cuCount > 0 ? dot('recent', 'filled', `A recorded loan in ${ctx.recentLabel}`)
: ev.fullPrior > 0 || ev.lanePrior > 0 ? dot('recent', 'half', `Loans before ${ctx.recentLabel}`)
: ev.book ? dot('recent', 'half', 'Book on the latest call report')
: dot('recent', 'empty', 'Nothing recent on record');
return [kind, size, purpose, placeDot, recent];
}
/** The share step: the lender's own loans of this kind that are like this one, in 5-point steps. */
export function shareStep(numerator, denominator) {
if (!numerator || !denominator) return 0;
const pct = Math.min(100, (numerator / denominator) * 100);
return Math.floor(pct / SHARE_STEP) * SHARE_STEP;
}
// The published order inside a group:
// 0. When the deal names a county or a metro: loans like this one made there, capped at 10. A lender with more of them
// comes first, so the place a broker typed changes the list in a way the broker can see (qa decision 4).
// 1. Level. 2. Loans like this, capped at 10, so national volume does not swamp a local lender.
// 3. The share of its own loans in this lane that are like this, in 5-point steps.
// 4. Most recent evidence. 5. Room above the loan on the size estimate. 6. Name.
// Nothing else is an input. Lender-supplied content never reaches this function.
export function publishedOrder(a, b) {
return (Math.min(b.placeN || 0, LIKE_LOANS_CAP) - Math.min(a.placeN || 0, LIKE_LOANS_CAP))
|| (LEVELS[a.level].order - LEVELS[b.level].order)
|| (Math.min(b.evidenceN || 0, LIKE_LOANS_CAP) - Math.min(a.evidenceN || 0, LIKE_LOANS_CAP))
|| ((b.shareStep || 0) - (a.shareStep || 0))
|| String(b.newest || '').localeCompare(String(a.newest || ''))
|| ((b.room ?? -Infinity) - (a.room ?? -Infinity))
|| a.name.localeCompare(b.name, 'en-US');
}
// Rows are paged across the groups in turn: the first of each group, then the second of each, so the first
// page always shows every kind of lender and a position is the same on every page. Order inside a group
// never changes.
export function interleave(groups) {
const lists = groups.map((g) => g.rows);
const out = [];
for (let i = 0; lists.some((l) => i < l.length); i += 1) {
for (const l of lists) if (i < l.length) out.push(l[i]);
}
return out;
}
/** Sorts every group by the published order, and returns the groups in their fixed order. */
export function orderGroups(rows, placeName) {
const byGroup = new Map();
for (const r of rows) {
if (!byGroup.has(r.group)) byGroup.set(r.group, []);
byGroup.get(r.group).push(r);
}
return Object.entries(GROUPS)
.filter(([key]) => byGroup.has(key))
.map(([key, g]) => ({ key, label: g.label(placeName), order: g.order, rows: byGroup.get(key).sort(publishedOrder) }))
.sort((a, b) => a.order - b.order);
}
/** The published order, as text. Printed on every result so that it can be checked. */
export const METHOD = 'Grouped by kind of lender, because banks, credit unions and non-banks are not comparable. Inside a group, in this order: if the deal names a county or a metro area, loans like this one made there, capped at 10, so a lender with more of them comes first; then level; then loans like this one, capped at 10; then the share of the lender\'s own loans in this lane that are like this one, in 5-point steps; then the most recent evidence; then room above the loan amount on the size estimate; then name. Every step uses public filings and the deal as typed. Nothing a lender has paid or told us is an input.'; rank-lanes.js (92 lines)
The numbers the order uses: the size band around the loan amount, how many loans count as "Done this", how large a book counts as "Holds this kind of loan", the cap on loans counted, and the step for shares. The methodology page prints these same numbers, read from this file.
SHA-256 6ba1dc649b55f852173bd783f169e2a59b687f44dc88539e7c4e5e02017055df
// The lanes of Loan Comps and the constants the published order rests on. Plain data, imported by the ranking
// modules. Everything that decides a result is here or in rank.js, and it is all public inputs: changing a
// number in this file changes what the methodology page must say.
//
// A lane is one kind of loan, and it says where the evidence for that kind lives:
// program the loan rows in the public file (HMDA multifamily, SBA 7(a), SBA 504), when they exist
// natN the D1 column with the lender's own national count of loans in this lane, for the share step
// stateN the lender_states column with the same count in one state
// book the D1 balance column, when a call report carries this kind of book
// cu the key under activity.cu_h1 for credit union originations, when NCUA reports them
// po the D1 percentile column that says a credit union has any such originations
export const SIZE_BAND = { low: 0.6, high: 1.5, nearLow: 0.36, nearHigh: 2.25 };
export const DONE_THIS_MIN = 2;
export const BOOK_MIN_DOLLARS = 5_000_000;
export const BOOK_MIN_SHARE_PCT = 5;
export const LIKE_LOANS_CAP = 10;
export const SHARE_STEP = 5;
// A level is a word for the kind of evidence, and nothing more: there is no confidence, score or grade on a lender (qa
// decision 12). `unclear` is the label for a lender that is not an option when no record says why.
//
// done_in_state is the level for the 1-4 unit investor lane, where HMDA carries a lender's loans by state (and, where five or more
// are on record, by county and metro) and nothing about each loan: size and purpose cannot be checked, so "one like it" would
// say more than the record does (ai-native-2 D-15).
export const LEVELS = {
done_this: { order: 1, label: 'Done this' },
done_similar: { order: 2, label: 'Done one like it' },
done_in_state: { order: 3, label: 'Lent in this state' },
holds_book: { order: 4, label: 'Holds this kind of loan' },
elsewhere: { order: 5, label: 'Lends on this elsewhere' },
ruled_out: { order: 6, label: 'Not an option, and why', unclear: 'Status unclear' },
};
export const GROUPS = {
local: { order: 1, label: (place) => `Banks and credit unions with an office in ${place}` },
out_of_state: { order: 2, label: (place) => `Banks and credit unions with no office in ${place}` },
nonbank: { order: 3, label: () => 'Non-banks' },
};
export const SOURCES = {
hmda: { name: 'CFPB HMDA public data', url: 'https://ffiec.cfpb.gov/data-browser/' },
sba: { name: 'SBA 7(a) and 504 FOIA data', url: 'https://data.sba.gov/dataset/7a-504-foia' },
fdic: { name: 'FDIC Call Report', url: 'https://banks.data.fdic.gov/bankfind-suite/bankfind' },
ncua: { name: 'NCUA 5300 Call Report', url: 'https://ncua.gov/analysis/credit-union-corporate-call-report-data/quarterly-data' },
};
export const LANES = {
multifamily: { label: 'multifamily', noun: 'multifamily loan', program: 'hmda_mf', fProgram: 1, source: 'hmda', natN: 'hmda_mf25_n', stateN: 'mf25_n', book: 'bal_mf', bookLabel: 'multifamily loans', cu: 'multifamily', cuLabel: 'multifamily loans' },
investor_1to4: { label: '1-4 unit investor', noun: '1-4 unit investor loan', aggregate: true, source: 'hmda', natN: 'hmda_inv14_n', stateN: 'inv14_n', book: null },
cre_investor: { label: 'investor commercial real estate', noun: 'non-owner-occupied commercial real estate loan', book: 'bal_noo', bookLabel: 'non-owner-occupied commercial real estate loans', cu: 'cre_non_owner_occupied', cuLabel: 'non-owner-occupied commercial real estate loans', po: 'po_noo', recordNoun: 'office, retail or industrial' },
cre_owner: { label: 'owner-occupied commercial real estate', noun: 'owner-occupied commercial real estate loan', book: 'bal_oo', bookLabel: 'owner-occupied commercial real estate loans', cu: 'cre_owner_occupied', cuLabel: 'owner-occupied commercial real estate loans', po: 'po_oo', recordNoun: 'owner-occupied commercial real estate' },
construction: { label: 'construction', noun: 'construction loan', book: 'bal_con', bookLabel: 'construction and land loans', cu: 'construction', cuLabel: 'construction loans', po: 'po_con', recordNoun: 'construction' },
residential_development: { label: 'residential development', noun: 'residential development loan', book: 'bal_con', subBook: '$.book.categories.construction.balance_1to4', bookLabel: '1-4 family construction loans', cu: 'construction', cuLabel: 'construction loans', po: 'po_con', recordNoun: 'residential development' },
farmland: { label: 'farmland', noun: 'farmland loan', book: 'bal_farm', bookLabel: 'farmland loans', recordNoun: 'farmland' },
business: { label: 'business', noun: 'business loan', book: 'bal_ci', bookLabel: 'commercial and industrial loans', cu: 'ci', cuLabel: 'commercial and industrial loans', po: 'po_ci', recordNoun: 'business' },
sba_7a: { label: 'SBA 7(a)', noun: 'SBA 7(a) loan', program: 'sba7a', fProgram: 2, source: 'sba', natN: 'sba7a_net_n', stateN: 'sba7a_n', book: null, industry: true },
sba_504: { label: 'SBA 504', noun: 'SBA 504 first lien', program: 'sba504', fProgram: 3, source: 'sba', natN: 'sba504_net_n', stateN: 'sba504_n', book: null, industry: true },
};
// The purposes the parser, the API and the MCP server accept, and how each reads in a sentence. HMDA carries
// purpose on every row, so only there does it change who counts as loans like this. `hmda` is the list of f_purpose
// codes that count: 1 purchase, 2 improvement, 3 refinance, 4 cash-out.
export const PURPOSES = {
purchase: { hmda: [1], word: 'purchase loan', adjective: 'purchase' },
refinance: { hmda: [3, 4], word: 'refinance', adjective: 'refinance' },
cash_out: { hmda: [4], word: 'cash-out refinance', adjective: 'cash-out' },
improvement: { hmda: [2], word: 'improvement loan', adjective: 'improvement' },
construction: { word: 'construction loan', adjective: 'construction' },
working_capital: { word: 'working capital loan', adjective: 'working capital' },
equipment: { word: 'equipment loan', adjective: 'equipment' },
bridge: { word: 'bridge loan', adjective: 'bridge' },
};
export const UNIT_BANDS = ['5-24', '25-49', '50-99', '100-149', '>149'];
// The businesses a plain-words SBA search names, by NAICS prefix, as the plural a sentence needs: "loans to car washes". Any other code is
// said as "businesses in NAICS 722511", so a filter that was applied is never left out of the sentence (ai-native-2 D-13).
export const INDUSTRY_NOUNS = {
722: 'restaurants', 811192: 'car washes', 238220: 'HVAC and plumbing businesses', 621210: 'dentists', 621111: 'physician practices', 624410: 'child care businesses',
713940: 'gyms and fitness businesses', 484: 'trucking businesses', 8111: 'auto repair businesses', 7211: 'hotels', 4471: 'gas stations and convenience stores',
445110: 'grocery stores', 812112: 'salons', 236: 'general contractors', 33: 'manufacturers',
};
/** The businesses a NAICS code or prefix stands for, as a plural: "car washes", or "businesses in NAICS 722511". Null for no code. */
export const industryWords = (naics) => {
if (naics === null || naics === undefined || naics === '') return null;
const code = String(naics);
return INDUSTRY_NOUNS[code] || `businesses in NAICS ${code}`;
};
export const LENDER_KINDS = { any: null, bank: ['bank', 'savings'], credit_union: ['credit_union'], non_bank: ['nonbank'] };
export const LENDER_TYPE_LABEL = { bank: 'Bank', savings: 'Savings institution', credit_union: 'Credit union', nonbank: 'Non-bank', depository_inactive: 'Depository, no June 2026 call report' }; Lists, search and rankings
The screener, the "who lends here" lists and the rankings are ordered by a database sort, written in rank-lists.js (355 lines). Every sort is a column of public data, then the lender's permanent number as the tie-break. These are the sort choices, and every line of that file that sets an order, with its line number.
SHA-256 of the whole file 6d26902f9493526f9557b1293c94f0489502657de798006ba98afb222d4811f4
const SORTS = {
commercial_total: 'l.commercial_total DESC', assets: 'l.assets DESC', name: 'l.name ASC', multifamily_book: 'l.bal_mf DESC', construction_book: 'l.bal_con DESC',
owner_occupied_book: 'l.bal_oo DESC', non_owner_occupied_book: 'l.bal_noo DESC', business_book: 'l.bal_ci DESC', farmland_book: 'l.bal_farm DESC', multifamily_loans: 'l.hmda_mf25_n DESC', sba_7a_loans: 'l.sba7a_net_n DESC', sba_504_loans: 'l.sba504_net_n DESC',
participations_bought: 'l.part_bought DESC',
}; 119: const orderSql = fts ? (sortKey ? `ORDER BY ${SORTS[sortKey]}, l.rid` : 'ORDER BY f.rank, l.assets DESC') : `ORDER BY ${SORTS[sortKey]}, l.rid`;
193: data.prepare(`SELECT ${NAME_COLUMNS}, s.branches FROM lender_states s JOIN lenders l ON l.rid = s.rid WHERE ${where} ORDER BY l.${book} DESC, l.rid LIMIT ${limit} OFFSET ${offset}`),
212: data.prepare(`SELECT ${NAME_COLUMNS}, s.branches, s.${col} AS place_n FROM lender_states s JOIN lenders l ON l.rid = s.rid WHERE s.state=${q(stateCode)} AND s.${col}>0 AND l.comps_ok=1 ORDER BY s.${col} DESC, l.rid LIMIT ${limit} OFFSET ${offset}`),
226: data.prepare(`SELECT ${NAME_COLUMNS}, d.place_n FROM (SELECT rid, count(*) AS place_n FROM deals WHERE ${base} GROUP BY rid ORDER BY place_n DESC, rid LIMIT ${limit} OFFSET ${offset}) d JOIN lenders l ON l.rid = d.rid WHERE l.comps_ok=1 ORDER BY d.place_n DESC, l.rid`),
312: `SELECT lr_id, slug, name, type, state, ${col} AS value FROM lenders WHERE comps_ok=1 AND ${col}>0 ORDER BY ${col} DESC, rid LIMIT ${ctx.limit} OFFSET ${ctx.offset}`,
317: `SELECT l.lr_id, l.slug, l.name, l.type, l.state, s.${sc} AS value FROM lender_states s JOIN lenders l ON l.rid = s.rid WHERE s.state=${q(scope)} AND s.${sc}>0 AND l.comps_ok=1 ORDER BY s.${sc} DESC, l.rid LIMIT ${ctx.limit} OFFSET ${ctx.offset}`, 3. The test that holds the ordering code (47 lines)
This test runs on every build of the site. It walks every ordering file and every file those files use. It lists the words the ordering code may not contain and the places it may not reach, and it names any violation. A violation stops the build, so a version of the site that broke the rule cannot be published.
SHA-256 2eac967c1beaf6a6f769149a114d44f91f1e4376ac874262c092525d087d18bb
// The most important engineering rule in the product (SPEC section 3, legal P1): ranking code can't reach accounts, plans,
// billing or anything in this Worker. Walks functions/_lib/rank*.js and everything they import, and names any violation.
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
// The file's text with comments removed, for the word checks. Imports are found separately, so a commented-out import doesn't count.
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
// funnel_counts (cro-spec 14.2) is the site's own funnel: ranking code may not read it, write it or import the module that does.
const FORBIDDEN_WORDS = [/\bACCOUNTS\b/, /\bplan\b/i, /\bbilling\b/i, /\bstripe\b/i, /\bis_paid\b/, /\benv\.PRO\b/, /\bstripe_customer\b/, /\bfunnel_counts\b/, /\bcountFunnel\b/, /\bcountEvent\b/,
// ROUND-3 (R3-3, legal 2.3): listing state is never an input to ranking, a default sort or a tie-break.
/\blisting_state\b/, /\blistingState\b/, /\bverified_on\b/];
const FORBIDDEN_PATHS = [/(^|\/)_lib\/gate(\.js)?$/, /(^|\/)gate(\.js)?$/, /(^|\/)worker\//, /(^|\/)api\/pro(\/|$)/, /(^|\/)ads(\.js)?$/, /(^|\/)counters(\.js)?$/, /(^|\/)ad-counters(\.js)?$/, /(^|\/)funnel(\.js)?$/];
const IMPORT = /(?:import\s+[^'"]*?from\s*|import\s*\(?\s*|export\s+[^'"]*?from\s*|require\(\s*)['"]([^'"]+)['"]/g;
function resolveImport(from, spec) {
if (!spec.startsWith('.')) return null;
const base = resolve(dirname(from), spec);
for (const c of [base, `${base}.js`, `${base}.mjs`, join(base, 'index.js')]) if (existsSync(c) && statSync(c).isFile()) return c;
return base; // an import that does not resolve is still checked by name
}
// functionsLib: the _lib folder. root: where paths are reported from. Returns { rankModules, reached, violations }.
export function scanRanking(functionsLib, root = functionsLib, roots = /^rank.*\.m?js$/) {
const rankModules = existsSync(functionsLib) ? readdirSync(functionsLib).filter((f) => roots.test(f)).map((f) => join(functionsLib, f)) : [];
const seen = new Set();
const violations = [];
const walk = (file) => {
if (seen.has(file)) return;
seen.add(file);
const rel = relative(root, file);
if (FORBIDDEN_PATHS.some((re) => re.test(file.replaceAll('\\', '/')))) violations.push(`${rel}: ranking code reaches a billing module`);
if (!existsSync(file) || !statSync(file).isFile()) return;
const text = readFileSync(file, 'utf8');
const code = stripComments(text);
for (const re of FORBIDDEN_WORDS) {
const m = code.match(re);
if (m) violations.push(`${rel}: mentions ${m[0]}`);
}
for (const m of text.matchAll(IMPORT)) {
const target = resolveImport(file, m[1]);
if (target) walk(target);
}
};
for (const f of rankModules) walk(f);
return { rankModules: rankModules.map((f) => relative(root, f)), reached: [...seen].map((f) => relative(root, f)), violations };
} 4. What this does not cover
- Pages that list lenders in a fixed order (A to Z, state pages, ranking pages) are built from the data files before any account exists. They cannot read the accounts database at all.
- An ad on a results page is a separate, labeled box outside the ranked list. It is chosen by different code, after the list is final, and never changes the list.
- "Verified (paid)" and "Ad" are labels, not a position. A Verified (paid) listing adds a lender's own details to its own page. It changes no number and no place in a list.
- This is a record that nothing between the data and the answer changes an order. It is not a rating of any lender, and it says nothing about who is lending today.
Methodology · How we make money · Advertising Policy · Corrections