Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt

Use this file to discover all available pages before exploring further.

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

Screener Implementation

How the multi-step screener collects data

Programs Database

Schema and query patterns