← Portfolio · Artículo · Algoritmos

Algoritmos que usé y por qué

No invento algoritmos: elijo técnicas conocidas que resuelven un problema concreto, las adapto y las mido. Acá están las de mis proyectos, con el problema que resolvían, por qué elegí esa y no otra, y el código real.

Mauro José Ciappina · 11 minutos de lectura · El código de Congreso Abierto, UNNA y SeatFlow es público y cada bloque enlaza a sus líneas; laburo y Mr.Mauroo Studio son privados, y la healthtech está bajo NDA

01Emparejar nombres entre fuentes: Jaro-Winkler

Congreso Abierto

El problema

Cada fuente oficial escribe distinto a la misma persona: «Chiconi, Abel» en el padrón y «CHICONI RICARDO ABEL» en los viajes; «Herrera Ahuad, Oscar A.» contra «HERRERA AHUAD OSCAR ALBERTO». Sin emparejar, los viajes, comisiones y votos quedan sueltos.

Qué elegí y por qué

Normalizo los nombres (sin tildes, en minúscula, sin partículas como «de» o «la») y comparo palabra por palabra con Jaro-Winkler, una medida de parecido que premia los prefijos iguales, justo lo que pasa con los nombres cortados. Cada candidato recibe un puntaje de confianza: el apellido pesa el doble, una inicial como «A.» puede valer por «Alberto», y solo se asigna si supera 0,9 y le gana al segundo por 0,05. Si dos diputados empatan (dos «Torres»), el caso queda como dudoso y se muestra; nunca se asigna a ciegas.

src/lib/linkage.tsVer en GitHub
export function jaroWinkler(a: string, b: string): number {
  if (a === b) return 1;
  if (!a || !b) return 0;
  const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
  const aHits = new Array<boolean>(a.length).fill(false);
  const bHits = new Array<boolean>(b.length).fill(false);
  let matches = 0;
  for (let i = 0; i < a.length; i++) {
    const end = Math.min(i + window + 1, b.length);
    for (let j = Math.max(0, i - window); j < end; j++) {
      if (bHits[j] || a[i] !== b[j]) continue;
      aHits[i] = bHits[j] = true;
      matches++;
      break;
    }
  }
  if (!matches) return 0;
  let transpositions = 0;
  for (let i = 0, j = 0; i < a.length; i++) {
    if (!aHits[i]) continue;
    while (!bHits[j]) j++;
    if (a[i] !== b[j]) transpositions++;
    j++;
  }
  const jaro = (matches / a.length + matches / b.length + (matches - transpositions / 2) / matches) / 3;
  let prefix = 0;
  while (prefix < 4 && prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
  return jaro + prefix * 0.1 * (1 - jaro);

Resultado

En el build del 3 de noviembre de 2026: comisiones 99,6 % emparejadas (1.612 de 1.618), proyectos de Diputados 99,1 % (14.523 de 14.651) y votaciones del Senado 100 % (24.912 de 24.912). Los casos reales difíciles quedaron fijados como tests.

Lo que descarté: comparar el texto exacto (falla con cualquier segundo nombre) y la distancia de Levenshtein (castiga igual un error al principio que al final, y en nombres cortados importa más el principio).

02Comparar de forma justa: estadística robusta

Congreso Abierto

El problema

Quería mostrar qué diputados viajan bastante más que sus pares. Con el promedio, dos o tres casos extremos mueven toda la vara, y comparar a alguien de Jujuy con alguien de la Ciudad de Buenos Aires no es justo.

Qué elegí y por qué

Uso la mediana y el desvío absoluto mediano (MAD), que no se mueven por unos pocos valores extremos, y el z modificado de Iglewicz y Hoaglin: se marca a quien supera 3,5. Los pares no son la provincia sino diputados a una distancia parecida de Buenos Aires, estimada con la fórmula de haversine sobre sus rutas habituales. La página dice «viaja bastante más que sus pares», nunca «abusa».

src/lib/viajes.tsVer en GitHub
/** Iglewicz-Hoaglin modified z-scores, with the mean-absolute-deviation fallback when MAD is 0. */
export function modifiedZScores(values: number[]): number[] {
  const med = median(values);
  const deviations = values.map(x => Math.abs(x - med));
  const mad = median(deviations);
  if (mad > 0) return values.map(x => (0.6745 * (x - med)) / mad);
  const meanAd = deviations.reduce((t, d) => t + d, 0) / (deviations.length || 1);
  if (meanAd > 0) return values.map(x => (x - med) / (1.253314 * meanAd));
  return values.map(() => 0);
}

Resultado

Una comparación que se puede explicar y defender, con la metodología escrita en el sitio y la advertencia de que más tramos no implica una irregularidad.

Lo que descarté: agrupar por región: Buenos Aires mezcla conurbano e interior, así que la región hacía injusta la comparación y la cambié por bandas de distancia.

03Listas rápidas con miles de pedidos: paginación por cursor

UNNA

El problema

Un café cierra cientos de pedidos por semana. Con «página 2, 3, 4…» (OFFSET), la base recorre todo lo anterior en cada página, y si un pedido cambia de estado entre dos páginas, se saltea o se repite una fila.

Qué elegí y por qué

Uso paginación por cursor (keyset): cada página pide «las filas anteriores a esta», ordenadas por fecha y por id para desempatar, con un índice compuesto. El cursor se valida dentro del negocio, así un cursor ajeno nunca arma la consulta.

apps/api/src/business/adapters/keyset-page.ts · extractoVer en GitHub
// Keyset pagination of the business lists (S17), newest first by (createdAt, id).
// The page is "rows strictly before the cursor" in that order, instead of Prisma's
// `cursor` + `skip: 1`: that skip drops a valid row whenever the cursor row no longer
// matches the filters (e.g. an order that was closed between two pages of `open=true`).
export const afterCursor = (cursor: KeysetCursor) => ({
  OR: [{ createdAt: { lt: cursor.createdAt } }, { createdAt: cursor.createdAt, id: { lt: cursor.id } }],
});

Resultado

Medido con unos 730 pedidos: la lista bajó de 73 ms y 1 MB a 4,5 ms y 28 KB.

Lo que descarté: el skip de Prisma, que pierde una fila válida justo en el caso que más pasa en un café: un pedido que se cierra mientras mirás la lista.

04Numeración correlativa sin duplicados: reservas por tiempo

UNNA

El problema

ARCA solo aprueba el número siguiente de cada punto de venta. Si dos cajas facturan a la vez, o si ARCA tarda, hay que evitar pedir dos veces el mismo número sin dejar la base bloqueada mientras se espera la respuesta.

Qué elegí y por qué

Cada secuencia tiene una reserva (lease) con vencimiento: el comprobante que la toma es el único que puede llamar a ARCA. La reserva se toma en una transacción corta con bloqueo de fila, la llamada a ARCA va afuera de cualquier bloqueo, y el resultado se guarda en otra transacción corta. Si el proceso se cae, la reserva vence y otro la retoma.

apps/api/src/business/use-cases/receipt-issue-lease.tsVer en GitHub
if (await locked.sequence.leaseHolder(now)) return { kind: 'busy' };
await locked.sequence.claimLease(draft.id, new Date(now.getTime() + RECEIPT_ISSUE_LEASE_MS));
await locked.receipts.save(draft);
return { kind: 'leased', draft, lastNumber: await locked.sequence.lastNumber() };

Resultado

La numeración queda correlativa aun con dos cajas al mismo tiempo, y si ARCA no responde, el comprobante queda como borrador que se puede reintentar sin gastar el número.

Lo que descarté: mantener el bloqueo de fila durante la llamada a ARCA: una respuesta lenta frenaba todas las ventas de esa sucursal.

05Que un mail no suene a IA: análisis estilométrico

laburo

El problema

Los mails escritos por un modelo se reconocen al instante: oraciones del mismo largo, muletillas como «Además» o «Cabe destacar», todas empezando igual.

Qué elegí y por qué

Antes de mostrar un borrador, lo mido: la variación del largo de las oraciones (en la escritura humana se mezclan cortas y largas), las muletillas típicas de IA, los comienzos repetidos y la mezcla de trato. Cada problema resta puntos; por debajo del mínimo, el borrador se regenera con la lista de problemas.

app/internal/draft/humanize.go · extractoRepo privado
func Analyze(body string) Report {
	r := Report{Score: 100}
	ss := sentences(body)
	if len(ss) >= 3 {
		var lens []float64
		for _, s := range ss {
			lens = append(lens, float64(len(wordRe.FindAllString(s, -1))))
		}
		r.Burstiness = variation(lens)
		if r.Burstiness < minBurstiness {
			r.Score -= 20 // every sentence has about the same length
		}
	}
	// ...AI connectors, repeated sentence starts, mixed tú/ustedes
	return r
}

Resultado

Cada borrador llega con un puntaje de naturalidad y la lista de lo que hay que corregir; los que fallan se regeneran una vez antes de llegar a la vista previa.

Lo que descarté: pedirle al modelo «que no suene a IA» y confiar: sin una medida, no hay forma de saber si lo cumplió.

06Detectar storyboards repetitivos: huella visual

Mr.Mauroo Studio

El problema

Un storyboard puede sacar 10 en «variedad» y aun así ser aburrido: cada cuadro con texto gigante sobre un fondo plano. Hacía falta una medida que viera la imagen, no el texto.

Qué elegí y por qué

Cada cuadro se reduce a una huella: una grilla de 4×3 con cuánto de cada zona no es fondo, cuánto ocupa el fondo y cuántos colores hay encima. Dos cuadros «se repiten» si la distancia entre sus huellas es menor a 0,03 o si los dos son solo texto. El puntaje baja un punto por cada 5 % de cuadros que repiten a alguno de los cuatro anteriores.

src/critic/domain/sameness.ts · extractoRepo privado
export const SAMENESS = { window: 4, layoutAlike: 0.03, flatAlike: 0.1, bareFlat: 0.6, bareColors: 2, sharePerPoint: 0.05 };

const isBare = (f: Fingerprint) => f.flat >= SAMENESS.bareFlat && f.colors <= SAMENESS.bareColors;
const layoutDistance = (a: Fingerprint, b: Fingerprint) =>
  a.layout.reduce((sum, v, i) => sum + Math.abs(v - (b.layout[i] ?? 0)), 0) / Math.max(1, a.layout.length);
const samePicture = (a: Fingerprint, b: Fingerprint) =>
  layoutDistance(a, b) <= SAMENESS.layoutAlike && Math.abs(a.flat - b.flat) <= SAMENESS.flatAlike;

Resultado

El storyboard que antes sacaba 10 en variedad ahora saca 1 en «repetición» (91 de 118 cuadros repetían) y el crítico lo frena; el umbral de 0,03 sale de la medición real, donde había un salto claro entre 0,018 y 0,037.

Lo que descarté: comparar los textos de cada cuadro: dos cuadros con frases distintas pueden verse exactamente iguales.

07Etiquetar cientos de butacas: expansión de patrones

SeatFlow Studio

El problema

En un plano de un teatro hay cientos de butacas y nombrarlas una por una es lento y propenso a errores.

Qué elegí y por qué

Un pequeño parser de rangos expande patrones como A{1..10} o {A..C}{1..2} en todas sus combinaciones (un producto cartesiano de los rangos), con rangos numéricos y de letras, hacia adelante o hacia atrás, y un tope de 500 por rango para no colgar la interfaz.

src/services/labeling/patternParser.tsVer en GitHub
/**
 * Parses a pattern string and generates an array of labels.
 * Examples:
 * - "A{1..3}" -> ["A1", "A2", "A3"]
 * - "{A..C}1" -> ["A1", "B1", "C1"]
 * - "{A..B}{1..2}" -> ["A1", "A2", "B1", "B2"]
 */
export const parsePattern = (pattern: string): string[] => {

Resultado

Una fila entera de un teatro se etiqueta con una sola línea, y el resultado se valida antes de guardarse.

Lo que descarté: una expresión regular gigante: los patrones anidados se vuelven ilegibles y no se pueden testear por partes.

08Buscar sin descifrar: índice ciego con HMAC

Plataforma healthtech (bajo NDA)

El problema

Los datos sensibles se guardan cifrados con AES-256-GCM, que da un resultado distinto cada vez. Eso es seguro, pero impide buscar «¿existe este DNI?» sin descifrar toda la tabla.

Qué elegí y por qué

Al lado del dato cifrado guardo un índice ciego: un HMAC-SHA256 determinístico del valor normalizado, con una clave propia y separada por campo. Buscar es comparar HMACs; el dato original nunca se descifra para buscar.

No muestro el código: el proyecto está bajo acuerdo de confidencialidad.

Lo que descarté: cifrado determinístico del dato mismo, que expone qué registros son iguales y facilita los ataques por frecuencia.

09Cómo elijo un algoritmo