← 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.
01Emparejar nombres entre fuentes: Jaro-Winkler
Congreso Abierto
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
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
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
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
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 privadofunc 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
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 privadoexport 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
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)
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
- Primero el problema, con un caso real. «Chiconi, Abel» contra «CHICONI RICARDO ABEL» vale más que cualquier definición.
- Lo más simple que se pueda medir. Si una regla clara alcanza, no uso un modelo; si hace falta una medida, la defino y la pruebo con datos reales.
- Tests con los casos difíciles. Los ejemplos que motivaron el algoritmo quedan fijados como tests antes de publicar un número.
- Mostrar la incertidumbre. Lo dudoso se informa, no se esconde: casos dudosos en Congreso Abierto, envíos inciertos en laburo.