Skip to main content

Eligibility Engine

The eligibility engine evaluates household data against program rules to determine potential eligibility.

Overview

The engine takes two inputs and produces match results:

Household Data Model

The screener collects data incrementally. Each field has a defined type and affects matching.
interface HouseholdData {
  // Location (Step 1)
  zipCode?: string;
  state?: string;
  countyFips?: string;
  city?: string;

  // Household composition (Step 2)
  householdSize?: number;
  adults?: number;
  children?: number;
  childrenAges?: number[];

  // Income (Step 3)
  grossMonthlyIncome?: number;
  netMonthlyIncome?: number;
  incomeType?: IncomeType[];       // wages, self_employment, benefits, etc.
  hasIncome?: boolean;

  // Demographics (Step 4)
  citizenshipStatus?: CitizenshipStatus;
  hasElderly?: boolean;            // Anyone 60+
  hasDisabled?: boolean;
  isPregnant?: boolean;
  isVeteran?: boolean;
  isStudent?: boolean;

  // Assets (Step 5, optional)
  countableResources?: number;     // Bank accounts, etc.
  hasVehicle?: boolean;
  ownsHome?: boolean;

  // Current benefits (Step 6, optional)
  currentBenefits?: string[];      // Programs already receiving
}

type CitizenshipStatus =
  | "citizen"
  | "permanent_resident"
  | "qualified_alien"
  | "refugee"
  | "asylee"
  | "undocumented"
  | "unknown";

type IncomeType =
  | "wages"
  | "self_employment"
  | "social_security"
  | "ssi"
  | "ssdi"
  | "unemployment"
  | "child_support"
  | "alimony"
  | "pension"
  | "investment"
  | "rental"
  | "other";

Federal Poverty Level (FPL)

Many programs use FPL-based income limits. The engine calculates FPL dynamically.
// 2024 Federal Poverty Guidelines (48 contiguous states)
const FPL_2024 = {
  baseAmount: 15060,      // For 1 person
  perPersonIncrement: 5380,
};

function calculateFPL(householdSize: number): number {
  return FPL_2024.baseAmount + (householdSize - 1) * FPL_2024.perPersonIncrement;
}

function calculateFPLPercent(
  monthlyIncome: number,
  householdSize: number
): number {
  const annualIncome = monthlyIncome * 12;
  const fpl = calculateFPL(householdSize);
  return Math.round((annualIncome / fpl) * 100);
}

// Example: Family of 4 earning $3,000/month
// Annual: $36,000
// FPL for 4: $15,060 + (3 * $5,380) = $31,200
// Percent: 36000 / 31200 * 100 = 115% FPL

FPL Thresholds by Program

ProgramIncome LimitNotes
SNAP130% gross / 100% netCategorical eligibility may override
Medicaid (adults)138%ACA expansion states
Medicaid (children)200-300%+Varies by state
CHIP200-400%Above Medicaid, varies by state
LIHEAP150%Federal minimum, states may differ
Head Start100%Some slots for 100-130%
WIC185%Or on Medicaid/SNAP
Free School Meals130%
Reduced School Meals185%
Lifeline135%Or on qualifying program

Rule Evaluation

The engine evaluates each rule and produces a tri-state result.
type EligibilityResult = "eligible" | "maybe" | "ineligible";

interface RuleEvaluationResult {
  ruleId: string;
  result: EligibilityResult;
  reason?: string;
}

function evaluateRule(
  rule: EligibilityRule,
  household: HouseholdData
): RuleEvaluationResult {
  const fieldValue = getFieldValue(household, rule.field);

  // If data is missing, result is "maybe"
  if (fieldValue === undefined || fieldValue === null) {
    return {
      ruleId: rule.id,
      result: "maybe",
      reason: `Missing data for ${rule.field}`
    };
  }

  const passes = evaluateCondition(fieldValue, rule.operator, rule.value, household);

  return {
    ruleId: rule.id,
    result: passes ? "eligible" : "ineligible",
    reason: passes ? undefined : `${rule.field} does not meet requirement`
  };
}

Operator Implementations

function evaluateCondition(
  fieldValue: any,
  operator: EligibilityOperator,
  ruleValue: RuleValue,
  household: HouseholdData
): boolean {
  switch (operator) {
    case "equals":
      return fieldValue === ruleValue;

    case "not_equals":
      return fieldValue !== ruleValue;

    case "greater_than":
      return fieldValue > ruleValue;

    case "less_than":
      return fieldValue < ruleValue;

    case "greater_than_or_equal":
      return fieldValue >= ruleValue;

    case "less_than_or_equal":
      return fieldValue <= ruleValue;

    case "in":
      return (ruleValue as string[]).includes(fieldValue);

    case "not_in":
      return !(ruleValue as string[]).includes(fieldValue);

    case "contains":
      return Array.isArray(fieldValue) && fieldValue.includes(ruleValue);

    case "less_than_fpl_percent": {
      const fplPercent = calculateFPLPercent(
        household.grossMonthlyIncome || 0,
        household.householdSize || 1
      );
      return fplPercent <= (ruleValue as number);
    }

    case "age_at_least":
      return Math.max(...(household.childrenAges || [0])) >= (ruleValue as number);

    case "age_under":
      return (household.childrenAges || []).some(age => age < (ruleValue as number));

    case "has_dependent_under":
      return (household.childrenAges || []).some(age => age < (ruleValue as number));

    case "is_citizen_or_qualified":
      return ["citizen", "permanent_resident", "qualified_alien", "refugee", "asylee"]
        .includes(household.citizenshipStatus || "");

    default:
      return false;
  }
}

Matching Algorithm

The full matching process combines geography, rules, and scoring.
interface MatchResult {
  programId: string;
  program: Program;
  eligibility: "likely" | "possible" | "unlikely";
  score: number;              // 0-100
  matchedRules: string[];     // Rules that passed
  failedRules: string[];      // Rules that failed
  unknownRules: string[];     // Rules with missing data
  reasons: string[];          // Human-readable explanations
}

async function matchPrograms(
  household: HouseholdData
): Promise<MatchResult[]> {
  // Step 1: Get geographically available programs
  const programs = await getProgramsByLocation(
    household.state,
    household.countyFips
  );

  // Step 2: Evaluate each program
  const results: MatchResult[] = [];

  for (const program of programs) {
    const result = evaluateProgram(program, household);
    results.push(result);
  }

  // Step 3: Sort by eligibility and score
  return results.sort((a, b) => {
    // Likely > Possible > Unlikely
    const eligibilityOrder = { likely: 0, possible: 1, unlikely: 2 };
    if (eligibilityOrder[a.eligibility] !== eligibilityOrder[b.eligibility]) {
      return eligibilityOrder[a.eligibility] - eligibilityOrder[b.eligibility];
    }
    // Then by score descending
    return b.score - a.score;
  });
}

Program Evaluation

function evaluateProgram(
  program: Program,
  household: HouseholdData
): MatchResult {
  const rules = program.eligibilityRules || [];
  const ruleResults = rules.map(rule => evaluateRule(rule, household));

  const passed = ruleResults.filter(r => r.result === "eligible");
  const failed = ruleResults.filter(r => r.result === "ineligible");
  const unknown = ruleResults.filter(r => r.result === "maybe");

  // Calculate eligibility
  let eligibility: "likely" | "possible" | "unlikely";

  if (failed.length > 0) {
    eligibility = "unlikely";
  } else if (unknown.length > 0) {
    eligibility = "possible";
  } else {
    eligibility = "likely";
  }

  // Calculate score
  const totalRules = rules.length || 1;
  const score = Math.round((passed.length / totalRules) * 100);

  return {
    programId: program.id,
    program,
    eligibility,
    score,
    matchedRules: passed.map(r => r.ruleId),
    failedRules: failed.map(r => r.ruleId),
    unknownRules: unknown.map(r => r.ruleId),
    reasons: failed.map(r => r.reason).filter(Boolean) as string[]
  };
}

Categorical Eligibility

Some programs grant automatic eligibility based on participation in other programs.
const CATEGORICAL_ELIGIBILITY: Record<string, string[]> = {
  // If you receive SNAP, you qualify for:
  "snap": ["free_school_meals", "lifeline", "wic"],

  // If you receive TANF, you qualify for:
  "tanf": ["snap_categorical", "free_school_meals", "medicaid"],

  // If you receive SSI, you qualify for:
  "ssi": ["snap_categorical", "medicaid", "lifeline"],

  // If you receive Medicaid, you qualify for:
  "medicaid": ["wic", "lifeline"],
};

function applyCategoricalEligibility(
  household: HouseholdData,
  results: MatchResult[]
): MatchResult[] {
  const currentBenefits = household.currentBenefits || [];

  for (const benefit of currentBenefits) {
    const qualifiesFor = CATEGORICAL_ELIGIBILITY[benefit.toLowerCase()] || [];

    for (const result of results) {
      if (qualifiesFor.includes(result.program.slug)) {
        result.eligibility = "likely";
        result.score = 100;
        result.reasons.push(`Categorical eligibility from ${benefit}`);
      }
    }
  }

  return results;
}

Progressive Refinement

As users provide more data, matches become more accurate.

Confidence Scoring

interface MatchConfidence {
  overall: number;          // 0-100
  geographic: number;       // Location data completeness
  household: number;        // Household data completeness
  income: number;           // Income data completeness
  demographic: number;      // Demographics completeness
}

function calculateConfidence(household: HouseholdData): MatchConfidence {
  const geographic =
    (household.state ? 25 : 0) +
    (household.countyFips ? 50 : 0) +
    (household.city ? 25 : 0);

  const householdData =
    (household.householdSize ? 50 : 0) +
    (household.children !== undefined ? 25 : 0) +
    (household.childrenAges?.length ? 25 : 0);

  const income =
    (household.hasIncome !== undefined ? 25 : 0) +
    (household.grossMonthlyIncome ? 50 : 0) +
    (household.incomeType?.length ? 25 : 0);

  const demographic =
    (household.citizenshipStatus ? 40 : 0) +
    (household.hasElderly !== undefined ? 20 : 0) +
    (household.hasDisabled !== undefined ? 20 : 0) +
    (household.isVeteran !== undefined ? 20 : 0);

  return {
    overall: Math.round((geographic + householdData + income + demographic) / 4),
    geographic,
    household: householdData,
    income,
    demographic
  };
}

Performance Considerations

Caching Strategy

// Cache FPL calculations (rarely change)
const fplCache = new Map<number, number>();

// Cache geographic lookups (expensive)
const geoCache = new Map<string, GeocodedLocation>();

// Don't cache program matches (user-specific)

Query Optimization

-- Use covering index for common query pattern
CREATE INDEX idx_programs_matching ON programs(
  is_active,
  coverage_type,
  category
) INCLUDE (
  name,
  short_description,
  benefit_value_cents
);

-- Partial index for active programs only
CREATE INDEX idx_programs_active_search ON programs
USING GIN(search_vector)
WHERE is_active = true;

Edge Cases

Handling Missing Data

// If household size is missing, use 1 (conservative)
const effectiveHouseholdSize = household.householdSize || 1;

// If income is missing, don't filter by income (permissive)
if (!household.grossMonthlyIncome) {
  result.eligibility = "possible";
  result.reasons.push("Provide income for more accurate matching");
}

State-Specific Rules

Some rules vary by state (e.g., Medicaid expansion, SNAP policies).
interface StateOverride {
  state: string;
  programSlug: string;
  overrideRules: EligibilityRule[];
}

// Example: California has higher SNAP income limits
const CA_SNAP_OVERRIDE: StateOverride = {
  state: "CA",
  programSlug: "snap",
  overrideRules: [
    {
      id: "income",
      field: "household_income_monthly",
      operator: "less_than_fpl_percent",
      value: 200  // California uses 200% FPL
    }
  ]
};

Next Steps