Refery.

05. Motores de reglas

La mayoría de las decisiones en una plataforma de reclutamiento no son genuinamente ambiguas. Un puesto de ingeniería senior en una empresa estadounidense en Serie A que indica "$50K de salario base" es un error tipográfico. Un "Director de Operaciones" en una consultora de 4 personas no es el ICP adecuado para el producto de Refery. Un candidato que pidió explícitamente no ser contactado nunca debería aparecer en ninguna consulta de contacto.

Refery codifica estas decisiones en motores de reglas: rutas de código pequeñas, deterministas y declarativas que se ejecutan a un costo de cómputo cercano a cero y producen resultados auditables. El principio es simple: las decisiones deterministas pertenecen a las reglas, las decisiones ambiguas pertenecen al panel. Las reglas son gratuitas; las llamadas a LLM no lo son.

Este capítulo describe tres de los motores de reglas en producción:

  1. Filtrado de puestos con auto-borrador, que mueve los puestos fuera del ICP al estado de borrador antes de que ocurra cualquier emparejamiento.
  2. Cascada de contacto por niveles, que selecciona el contacto adecuado en cada empresa según una jerarquía de cinco niveles.
  3. Capa de cumplimiento de bloqueo absoluto, que aplica las listas de bloqueo en la capa SQL.

Filtrado de puestos con auto-borrador

Cuando se ingieren nuevos puestos en Refery, se pasan por una secuencia de filtros que clasifica cada puesto como "listo para emparejamiento" o "movido a borrador". Los borradores no se emparejan, no se muestran a los candidatos y no se incluyen en el índice de recuperación en vivo.

Los filtros están ordenados por costo: primero los más baratos. Un puesto que falla en el filtro de ubicación nunca llega al filtro de palabras clave, nunca llega al filtro de salario y, desde luego, nunca llega al clasificador de ICP basado en LLM (que se reserva para casos límite genuinamente ambiguos).

Filtro 1: Geografía

Las startups con sede en EE. UU. son el ICP principal de Refery. Los puestos etiquetados explícitamente para ubicaciones exclusivamente fuera de EE. UU. se mueven a borrador.

// rules/job-filters/geography.ts

const NON_US_INDICATORS = [
  'london', 'berlin', 'paris', 'tokyo', 'singapore', 'sydney',
  'mumbai', 'bangalore', 'hyderabad', 'mexico city', 'são paulo',
  'amsterdam', 'munich', 'zurich', 'stockholm', 'copenhagen',
];

const US_OVERRIDE_INDICATORS = [
  'us only', 'united states', 'us-based', 'us residents only',
  'sf', 'san francisco', 'nyc', 'new york', 'remote (us)',
];

export function passesGeographyFilter(job: Job): FilterResult {
  const location = job.location.toLowerCase();
  const description = job.description.toLowerCase().slice(0, 500);

  const hasUSOverride = US_OVERRIDE_INDICATORS.some(s =>
    location.includes(s) || description.includes(s)
  );

  if (hasUSOverride) {
    return { pass: true };
  }

  const hasNonUSIndicator = NON_US_INDICATORS.some(s =>
    location.includes(s)
  );

  if (hasNonUSIndicator) {
    return {
      pass: false,
      reason: `Non-US location: "${job.location}"`,
      suggestedStage: 'draft',
    };
  }

  return { pass: true };
}

Filtro 2: Seniority e ICP

Los puestos junior están fuera del alcance. Refery coloca ingenieros senior y perfiles de GTM senior. El filtro de seniority es una comprobación de palabras clave contra el título y la descripción.

// rules/job-filters/seniority.ts

const JUNIOR_TITLE_INDICATORS = [
  'intern', 'internship', 'apprentice', 'trainee',
  'junior', 'jr.', 'jr ', 'entry level', 'entry-level',
  'graduate', 'new grad', 'associate', 'assistant',
];

const SENIOR_OVERRIDE_INDICATORS = [
  'senior', 'sr.', 'sr ', 'staff', 'principal', 'lead',
  'director', 'vp', 'head of', 'chief',
];

export function passesSeniorityFilter(job: Job): FilterResult {
  const title = job.title.toLowerCase();

  // Si el título indica explícitamente senior+, pasa de inmediato
  if (SENIOR_OVERRIDE_INDICATORS.some(s => title.includes(s))) {
    return { pass: true };
  }

  // Si el título contiene indicadores junior, falla
  if (JUNIOR_TITLE_INDICATORS.some(s => title.includes(s))) {
    return {
      pass: false,
      reason: `Junior title: "${job.title}"`,
      suggestedStage: 'draft',
    };
  }

  return { pass: true };
}

Filtro 3: Suelo salarial

Si un salario está indicado explícitamente y cae por debajo del suelo de mercado de tecnología senior para la ubicación del puesto, el puesto se mueve a borrador. Esto detecta tanto errores de captura de datos como puestos que están sistemáticamente fuera del ICP.

// rules/job-filters/salary.ts

const SALARY_FLOORS_USD: Record<string, number> = {
  'eng_us':       150_000,
  'eng_us_sf':    180_000,
  'eng_us_nyc':   170_000,
  'gtm_us':       120_000,
  'gtm_us_sf':    140_000,
  'gtm_us_nyc':   130_000,
};

export function passesSalaryFilter(job: Job): FilterResult {
  if (job.salary_max == null || job.salary_max === 0) {
    return { pass: true };  // salario desconocido, se delega a otros filtros
  }

  const floorKey = computeFloorKey(job.function, job.location_metro);
  const floor = SALARY_FLOORS_USD[floorKey] ?? 100_000;

  if (job.salary_max < floor) {
    return {
      pass: false,
      reason: `Below floor: $${job.salary_max} < $${floor} (${floorKey})`,
      suggestedStage: 'draft',
    };
  }

  return { pass: true };
}

Filtro 4: Categoría

Algunos puestos vienen etiquetados en categorías para las que Refery no coloca: puestos exclusivamente de marketing, soporte al cliente, finanzas/contabilidad, generalistas de RR. HH. El filtro de categoría es una lista de exclusión.

const OFF_CATEGORY_INDICATORS = [
  'recruiter', 'talent acquisition partner', 'sourcer',
  'social media manager', 'content marketing manager',
  'customer support specialist', 'cs rep',
  'accountant', 'bookkeeper',
  'office manager', 'executive assistant',
  'hr generalist', 'people ops coordinator',
];

Composición

Los filtros se componen secuencialmente. El primer fallo interrumpe la cadena (short-circuit) y asigna la etapa sugerida.

// rules/job-filters/index.ts

const FILTER_PIPELINE = [
  passesGeographyFilter,
  passesSeniorityFilter,
  passesSalaryFilter,
  passesCategoryFilter,
] as const;

export function classifyJob(job: Job): JobClassification {
  for (const filter of FILTER_PIPELINE) {
    const result = filter(job);
    if (!result.pass) {
      return {
        stage: result.suggestedStage,
        reason: result.reason,
        filterFailed: filter.name,
      };
    }
  }
  return { stage: 'open', reason: null, filterFailed: null };
}

Todo este pipeline se ejecuta en microsegundos por puesto. No hay llamada a LLM. No hay llamada a API. Aproximadamente entre el 60-70% de los puestos recién ingeridos se clasifican correctamente solo con estas reglas, reservando el cómputo de LLM para los casos restantes genuinamente ambiguos.

Cascada de contacto por niveles

Cuando Refery quiere contactar a una empresa cliente potencial sobre un candidato, la pregunta no es "¿deberíamos contactar?" sino "¿quién en esta empresa es el contacto adecuado?".

La respuesta depende de la etapa de la empresa, del seniority del puesto y de la relación que Refery ya tiene con esa empresa. Refery lo codifica como una cascada de cinco niveles (de 1차 a 5차) que se desplaza automáticamente hacia arriba cuando faltan niveles superiores o estos no son alcanzables.

Los cinco niveles

TierRole patternWhen to use
1차CEO / FundadorPredeterminado para etapas tempranas (pre-seed, seed). Los fundadores toman las decisiones de contratación.
2차Otro cofundadorAlternativa si no se puede alcanzar al CEO; específicamente cuando el puesto está en el dominio del cofundador
3차CTO / Director de Ingeniería / VP de IngenieríaPredeterminado para puestos de ingeniería en Serie A+
4차Responsable de Talento / Personas / ReclutamientoUsar solo cuando esté explícitamente orientado al reclutamiento o cuando la empresa cuente con un liderazgo de contratación dedicado
5차Otro contacto senior (operador, BD, GM)Último recurso; solo si todos los niveles anteriores están bloqueados o no responden

Lógica de campaña multironda

La cascada es la base de las campañas de contacto multironda. La Ronda 1 alcanza al contacto 1차 en todas las empresas relevantes. Las empresas que no responden dentro de la ventana de respuesta entran en la Ronda 2, que apunta al contacto 2차, y así sucesivamente.

// outreach/waterfall.ts

type Tier = '1차' | '2차' | '3차' | '4차' | '5차';

const TIER_ORDER: Tier[] = ['1차', '2차', '3차', '4차', '5차'];

interface CompanyContact {
  email: string;
  name: string;
  role: string;
  tier: Tier;
}

export function selectContactForRound(
  companyContacts: CompanyContact[],
  round: number,
  alreadyContacted: Set<string>  // correos contactados en rondas anteriores
): CompanyContact | null {
  const targetTier = TIER_ORDER[round - 1];
  if (!targetTier) return null;

  // Buscar un contacto en el nivel objetivo que aún no haya sido contactado
  const candidate = companyContacts
    .filter(c => c.tier === targetTier)
    .find(c => !alreadyContacted.has(c.email));

  if (candidate) return candidate;

  // Desplazamiento automático: si no hay contacto en el nivel objetivo, bajar al siguiente nivel
  for (let i = round; i < TIER_ORDER.length; i++) {
    const fallbackTier = TIER_ORDER[i];
    const fallback = companyContacts
      .filter(c => c.tier === fallbackTier)
      .find(c => !alreadyContacted.has(c.email));
    if (fallback) return fallback;
  }

  // Desplazamiento automático: subir si es necesario (poco frecuente)
  for (let i = round - 2; i >= 0; i--) {
    const fallbackTier = TIER_ORDER[i];
    const fallback = companyContacts
      .filter(c => c.tier === fallbackTier)
      .find(c => !alreadyContacted.has(c.email));
    if (fallback) return fallback;
  }

  return null;
}

Esta lógica parece simple, pero la consecuencia operativa es significativa: un candidato que tiene 30 empresas bien emparejadas en el tablero en vivo puede avanzar por una campaña de contacto de 3 rondas en dos semanas, con cada ronda apuntando automáticamente a la persona adecuada en el nivel adecuado. La alternativa ("elegir manualmente un contacto en cada empresa") es lo que un reclutador humano tarda horas en hacer.

Registro

Cada acción de contacto se registra en una tabla dedicada para auditoría y para reentrenar el modelo de puntuación de calidad de contactos.

CREATE TABLE outreach_log (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  candidate_id      uuid NOT NULL REFERENCES candidates(id),
  company_id        uuid REFERENCES companies(id),
  contact_email     text NOT NULL,
  contact_tier      text NOT NULL,
  round_number      int NOT NULL,
  channel           text NOT NULL CHECK (channel IN ('email', 'linkedin')),
  gmail_thread_id   text,
  subject           text,
  sent_at           timestamptz NOT NULL DEFAULT now(),
  reply_received    boolean DEFAULT false,
  reply_received_at timestamptz,
  outcome           text  -- 'positive_reply', 'negative_reply', 'no_reply'
);

CREATE INDEX idx_outreach_candidate ON outreach_log(candidate_id);
CREATE INDEX idx_outreach_company ON outreach_log(company_id);
CREATE INDEX idx_outreach_sent_at ON outreach_log(sent_at DESC);

Este registro se convierte en una señal de entrenamiento sobre qué contactos en qué empresas realmente responden, lo cual se realimenta a la puntuación de calidad de contactos utilizada por las cascadas futuras.

La capa de cumplimiento de bloqueo absoluto

La lista de bloqueo no es una recomendación. Se aplica en la capa SQL, en cada consulta que toca candidatos o empresas. No existe ninguna ruta de código de aplicación que pueda eludirla accidentalmente.

Esquema

-- la tabla candidates tiene una columna do_not_contact
ALTER TABLE candidates
  ADD COLUMN do_not_contact boolean NOT NULL DEFAULT false;

-- la tabla companies tiene la misma columna
ALTER TABLE companies
  ADD COLUMN do_not_contact boolean NOT NULL DEFAULT false;

-- Un índice para filtrado rápido
CREATE INDEX idx_candidates_dnc ON candidates(do_not_contact) WHERE do_not_contact = true;
CREATE INDEX idx_companies_dnc ON companies(do_not_contact) WHERE do_not_contact = true;

Política de seguridad a nivel de fila

La aplicación más estricta ocurre en la capa RLS. El código de aplicación que utiliza el cliente estándar authenticated de Supabase no puede leer las filas con do_not_contact = true de forma predeterminada; la política las excluye.

-- Ejemplo de política RLS sobre candidates
CREATE POLICY "exclude_blacklisted_candidates" ON candidates
  FOR SELECT
  TO authenticated
  USING (do_not_contact = false OR auth.uid() = '<admin-uuid>');

Esto significa que un desarrollador que escribe una nueva funcionalidad no puede mostrar accidentalmente un candidato en la lista de bloqueo. La propia base de datos se niega a devolverlos.

Por qué esto importa

Las listas de bloqueo en la mayoría de las plataformas son orientativas: una bandera en la aplicación que se comprueba en algunas consultas y en otras no. Tarde o temprano se publica una funcionalidad que olvida la comprobación, y un contacto de la lista de bloqueo termina apareciendo. La arquitectura de Refery hace que esto sea estructuralmente imposible, porque la regla se aplica en la base de datos, no en el código de aplicación.

Por qué importan estos motores de reglas

Cada uno de estos motores maneja una clase de decisión que, si se delegara a un LLM, consumiría cómputo significativo e introduciría no determinismo. Codificados como reglas, son:

  • Gratuitos. La evaluación de reglas toma microsegundos, no segundos.
  • Deterministas. La misma entrada siempre produce la misma salida. Pruébalo una vez, confía para siempre.
  • Auditables. El comportamiento de una regla queda completamente descrito por su código fuente.
  • Componibles. Las reglas se pueden agregar, eliminar o reordenar sin tocar el resto del sistema.
  • Baratos de extender. Un nuevo filtro son decenas de líneas de código, no una ejecución de fine-tuning.

Esta es la opción de ingeniería poco de moda. La opción de moda en 2026 es lanzar un LLM a cada problema. La apuesta de Refery es la opuesta: lanzar un LLM a los problemas que genuinamente requieren razonamiento de LLM, y usar reglas para todo lo demás. Las cifras del capítulo 07 describen exactamente cuánto cuesta esa apuesta y cuánto rinde.

Por qué esto es novedoso

  1. Cumplimiento aplicado por la base de datos. La mayoría de las plataformas aplican las listas de bloqueo en el código de aplicación. Las exclusiones aplicadas por RLS hacen que la elusión accidental sea estructuralmente imposible.
  2. Pipeline de filtros con los más baratos primero. Geografía (microsegundos) antes que salario (microsegundos) antes que el clasificador de ICP por LLM (segundos). La arquitectura está construida en torno al gradiente de costo.
  3. Cascada de niveles con desplazamiento automático y lógica multironda. Esto codifica directamente el know-how de reclutamiento. La mayoría de las herramientas de contacto asumen un único contacto por empresa; la cascada es una política estructurada.
  4. Registro de contacto de solo anexado (append-only) que alimenta una señal de entrenamiento. El sistema se vuelve más inteligente con cada campaña.

Estos motores de reglas no son glamorosos. También son el mayor contribuyente individual a la ventaja de costo por decisión de Refery frente a plataformas de reclutamiento con IA comparables.