Módulo 7: Shipping Ai Safely

Proyecto: decide si migrar recommendations de recs-v1 a recs-v2

Descripción

Las siete lecciones de este módulo construyeron, pieza por pieza, la disciplina completa de migrar un modelo con seguridad: por qué un modelo no es código estático (L2), cómo correrlo en shadow sin afectar a nadie (L3), cómo medir la tasa de acuerdo y encontrar dónde difiere de verdad (L4), cómo investigar un incidente que no se reproduce igual dos veces (L5), qué datos hace falta registrar y cuáles no (L6), y cómo se junta todo en un proceso de decisión formal (L7). Este mini-proyecto reutiliza las tres piezas centrales —shadowCompare(), modelMigrationDecision() y redactPII()— sin cambiarles una sola línea, sobre el caso completo: decidir, con evidencia y no con intuición, si recs-v2 está listo para un canary.

Conexión con el módulo. Este proyecto no introduce ninguna función nueva: reutiliza buildMercadoShadowTraffic(), recsV1() y recsV2() de las lecciones 3 y 4, shadowCompare() de la lección 4, modelMigrationDecision() de la lección 7, y redactPII() de la lección 6 — exactamente como quedaron, sin ningún cambio, tal como el proyecto del módulo 4 reutilizó guardrailWatch() sin tocarla. Lo que agrega es la disciplina de un reporte completo, que junta el resultado de la comparación, la decisión formal, y la definición de qué datos se registran, en un solo entregable para el equipo de Mercado.

Una analogía: el reporte de vuelo, antes de autorizar el próximo despegue

Antes de que una aerolínea autorice un motor nuevo para un vuelo con pasajeros, alguien tiene que firmar un reporte formal que junte toda la evidencia de las pruebas anteriores: cuántas horas de simulador acumuló, en qué maniobras coincidió con el motor de siempre y en cuáles no, qué decisión formal tomó el comité de seguridad con esos números, y qué datos de las pruebas se conservan en el archivo permanente y cuáles se descartan por no hacer falta. Ese reporte no es un trámite — es lo que le permite a cualquier persona, incluida una que no participó en ninguna de las pruebas, confiar en que la decisión de autorizar (o no autorizar) el próximo vuelo se tomó con evidencia completa, no con una impresión informal de "se vio bastante bien".

Este proyecto es ese reporte, cerrado sobre el caso real de recs-v2: desde el shadow traffic completo hasta la decisión formal y la definición de qué datos quedan en el registro permanente.

El reporte completo, paso a paso

Parte 1 — Correr shadowCompare() sobre el shadow traffic completo

Reutilizamos buildMercadoShadowTraffic(), recsV1(), recsV2() y shadowCompare() exactamente como quedaron en las lecciones 3 y 4, sobre las 50 requests reales de shadow traffic.

Parte 2 — Aislar el segmento que concentra las diferencias

A partir del resultado de la Parte 1, confirmamos que el desacuerdo no está repartido de forma pareja, sino concentrado casi por completo en un solo segmento.

Parte 3 — La decisión formal con modelMigrationDecision()

Reutilizamos modelMigrationDecision() de la lección 7, sin cambiarla, sobre el resultado real de la Parte 1.

Parte 4 — Qué datos se registran de un caso real, con redactPII()

Tomamos uno de los registros reales de diferencia de la Parte 1 y aplicamos redactPII() de la lección 6, sin cambiarla, para definir qué llega al registro permanente.

// PROYECTO: decidir si migrar recommendations de Mercado de recs-v1 a recs-v2.
// Reutiliza buildMercadoShadowTraffic(), recsV1(), recsV2(), shadowCompare(),
// modelMigrationDecision() y redactPII() EXACTAMENTE como quedaron en las
// lecciones 3, 4, 6 y 7, sin ningun cambio.

const CATEGORIES = ['electronics', 'home', 'fashion', 'sports', 'grocery', 'beauty', 'toys'];
const GENERAL_TRENDING = 'electronics';
const REGION_TRENDING = { north: 'electronics', south: 'home', east: 'fashion', west: 'sports' };
const REGIONS = ['north', 'south', 'east', 'west'];

function recsV1(request) {
  if (request.hasHistory) return request.lastPurchaseCategory;
  return GENERAL_TRENDING;
}

function recsV2(request) {
  if (request.hasHistory) return request.lastPurchaseCategory;
  return REGION_TRENDING[request.region];
}

function buildMercadoShadowTraffic() {
  const requests = [];
  for (let i = 0; i < 35; i++) {
    requests.push({
      requestId: 'req-hist-' + String(i).padStart(2, '0'),
      segment: 'with-history',
      hasHistory: true,
      lastPurchaseCategory: CATEGORIES[i % CATEGORIES.length],
      region: REGIONS[i % REGIONS.length],
    });
  }
  for (let i = 0; i < 15; i++) {
    requests.push({
      requestId: 'req-cold-' + String(i).padStart(2, '0'),
      segment: 'cold-start',
      hasHistory: false,
      lastPurchaseCategory: null,
      region: REGIONS[i % REGIONS.length],
    });
  }
  return requests;
}

function shadowCompare(oldOutputs, newOutputs) {
  const bySegment = {};
  const differences = [];
  let agreements = 0;
  oldOutputs.forEach((oldReq, i) => {
    const newReq = newOutputs[i];
    const agree = oldReq.topRec === newReq.topRec;
    const seg = oldReq.segment;
    if (!bySegment[seg]) bySegment[seg] = { total: 0, agreements: 0 };
    bySegment[seg].total++;
    if (agree) {
      bySegment[seg].agreements++;
      agreements++;
    } else {
      differences.push({ requestId: oldReq.requestId, segment: seg, oldTopRec: oldReq.topRec, newTopRec: newReq.topRec });
    }
  });
  const total = oldOutputs.length;
  const agreementRate = (agreements / total) * 100;
  const segmentBreakdown = Object.entries(bySegment).map(([segment, s]) => ({
    segment, total: s.total, agreements: s.agreements, agreementRate: (s.agreements / s.total) * 100,
  }));
  return { total, agreements, agreementRate, differences, segmentBreakdown };
}

function modelMigrationDecision({ agreementRate, minAgreement, criticalSegmentAgreementRate, minCriticalSegmentAgreement }) {
  if (agreementRate < minAgreement) {
    return 'NO promuevas a canary: la tasa de acuerdo global esta por debajo del minimo -- revisa las diferencias primero';
  }
  if (criticalSegmentAgreementRate < minCriticalSegmentAgreement) {
    return 'NO promuevas a canary todavia: un segmento critico difiere demasiado -- diagnostica esa estrategia antes de exponer trafico real';
  }
  return 'promueve recs-v2 a canary (1%), vigilando los guardrails de M4 en cada etapa';
}

function pseudonymizeId(id) {
  let hash = 0;
  for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 100000;
  return 'usr-' + String(hash).padStart(5, '0');
}

function redactPII(record) {
  const FULLY_REMOVE = ['fullName', 'shippingAddress', 'phoneNumber'];
  const MASK = ['email'];
  const clean = { ...record };
  FULLY_REMOVE.forEach((field) => { delete clean[field]; });
  MASK.forEach((field) => {
    if (clean[field]) {
      const [localPart, domain] = clean[field].split('@');
      clean[field] = localPart.slice(0, 2) + '***@' + domain;
    }
  });
  if (clean.userId) clean.userId = pseudonymizeId(clean.userId);
  return clean;
}

// Parte 1: correr shadowCompare sobre el trafico de shadow completo de Mercado
console.log('=== Parte 1: shadowCompare sobre las 50 requests de shadow traffic ===\n');
const requests = buildMercadoShadowTraffic();
const oldOutputs = requests.map((r) => ({ requestId: r.requestId, segment: r.segment, topRec: recsV1(r) }));
const newOutputs = requests.map((r) => ({ requestId: r.requestId, segment: r.segment, topRec: recsV2(r) }));
const comparison = shadowCompare(oldOutputs, newOutputs);
console.log('Tasa de acuerdo global: ' + comparison.agreementRate.toFixed(1) + '% (' + comparison.agreements + '/' + comparison.total + ')');
comparison.segmentBreakdown.forEach((s) => {
  console.log('  ' + s.segment.padEnd(14) + 'tasa=' + s.agreementRate.toFixed(1) + '%  (' + s.agreements + '/' + s.total + ')');
});

// Parte 2: aislar el segmento problematico
console.log('\n=== Parte 2: el segmento que concentra las diferencias ===\n');
const coldStart = comparison.segmentBreakdown.find((s) => s.segment === 'cold-start');
console.log('cold-start: ' + coldStart.agreementRate.toFixed(1) + '% de acuerdo, contra 100.0% en with-history');
console.log(comparison.differences.length + ' diferencias totales, todas en el segmento cold-start');

// Parte 3: la decision formal con modelMigrationDecision
console.log('\n=== Parte 3: decision con modelMigrationDecision ===\n');
const decision = modelMigrationDecision({
  agreementRate: comparison.agreementRate,
  minAgreement: 70,
  criticalSegmentAgreementRate: coldStart.agreementRate,
  minCriticalSegmentAgreement: 60,
});
console.log('Decision: ' + decision);

// Parte 4: que se registra -- redactPII sobre un caso real de diferencia
console.log('\n=== Parte 4: que datos se registran de un caso real (redactPII) ===\n');
const rawLogCandidate = {
  requestId: 'req-cold-01',
  userId: 'buyer-51190',
  fullName: 'Julio Restrepo Vega',
  email: 'julio.restrepo@example.com',
  shippingAddress: 'Calle 45 #12-30, Medellin',
  phoneNumber: '+57-4-555-0199',
  region: 'south',
  oldTopRec: 'electronics',
  newTopRec: 'home',
  modelVersions: { old: 'recs-v1', new: 'recs-v2' },
  timestamp: '2026-07-22T09:41:55Z',
};
console.log('Registro que shadow logging queria guardar (con PII de mas):');
console.log(JSON.stringify(rawLogCandidate, null, 2));
console.log('\nRegistro que de verdad se persiste, despues de redactPII:');
console.log(JSON.stringify(redactPII(rawLogCandidate), null, 2));

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== Parte 1: shadowCompare sobre las 50 requests de shadow traffic ===

Tasa de acuerdo global: 78.0% (39/50)
  with-history  tasa=100.0%  (35/35)
  cold-start    tasa=26.7%  (4/15)

=== Parte 2: el segmento que concentra las diferencias ===

cold-start: 26.7% de acuerdo, contra 100.0% en with-history
11 diferencias totales, todas en el segmento cold-start

=== Parte 3: decision con modelMigrationDecision ===

Decision: NO promuevas a canary todavia: un segmento critico difiere demasiado -- diagnostica esa estrategia antes de exponer trafico real

=== Parte 4: que datos se registran de un caso real (redactPII) ===

Registro que shadow logging queria guardar (con PII de mas):
{
  "requestId": "req-cold-01",
  "userId": "buyer-51190",
  "fullName": "Julio Restrepo Vega",
  "email": "julio.restrepo@example.com",
  "shippingAddress": "Calle 45 #12-30, Medellin",
  "phoneNumber": "+57-4-555-0199",
  "region": "south",
  "oldTopRec": "electronics",
  "newTopRec": "home",
  "modelVersions": {
    "old": "recs-v1",
    "new": "recs-v2"
  },
  "timestamp": "2026-07-22T09:41:55Z"
}

Registro que de verdad se persiste, despues de redactPII:
{
  "requestId": "req-cold-01",
  "userId": "usr-59282",
  "email": "ju***@example.com",
  "region": "south",
  "oldTopRec": "electronics",
  "newTopRec": "home",
  "modelVersions": {
    "old": "recs-v1",
    "new": "recs-v2"
  },
  "timestamp": "2026-07-22T09:41:55Z"
}

Repasa las cuatro partes con lo que cada una confirma. La Parte 1 corre exactamente el mismo shadowCompare() de la lección 4, sin ningún cambio, sobre el shadow traffic completo: 78.0% de acuerdo global, con el 100.0% concentrado en el segmento con historial y apenas 26.7% en cold-start. La Parte 2 aísla ese resultado en una frase que cualquiera puede entender sin leer código: las 11 diferencias totales están, todas, en el mismo segmento — no hay ningún desacuerdo disperso en el resto del tráfico. La Parte 3 traduce ese hallazgo a una decisión formal, sin ambigüedad: modelMigrationDecision() frena la migración, no porque el modelo nuevo sea malo en general, sino porque un segmento específico y bien identificado todavía no está listo para exponerse a compradores reales. La Parte 4 cierra con la disciplina de datos: de un registro con siete campos potencialmente sensibles, solo dos sobreviven completos (region, que no es PII) y uno queda enmascarado (email) — el userId se pseudonimiza a usr-59282, y fullName, shippingAddress y phoneNumber desaparecen por completo, porque ninguno de los tres hacía falta para el propósito de este log.

La decisión, en limpio

VerificaciónResultado
Tasa de acuerdo global78.0% (39/50) — supera el umbral de 70%
Tasa de acuerdo, segmento with-history100.0% (35/35)
Tasa de acuerdo, segmento cold-start26.7% (4/15) — por debajo del umbral crítico de 60%
Diferencias totales11, todas concentradas en cold-start
Decisión formalNO promover a canary todavía. Diagnosticar la estrategia de cold-start primero.
Dato de log antes de redactPII()7 campos, 3 de ellos PII sin ninguna necesidad para el propósito del log
Dato de log después de redactPII()fullName, shippingAddress, phoneNumber eliminados; email enmascarado; userId pseudonimizado

Fíjate en que la tabla no dice "cancelar recs-v2" en ningún lado — dice, con precisión, "no promover todavía", exactamente la distinción que la lección 4 y la lección 7 enseñaron a sostener. El modelo nuevo coincide perfectamente con el de siempre para el segmento más grande de compradores (con historial); lo que este proyecto confirma es que la nueva estrategia de cold-start —tendencia regional en vez de tendencia general— necesita investigarse con más profundidad antes de que un solo comprador nuevo la vea, no que el proyecto entero de recs-v2 esté descartado.

Errores comunes

Reportar la decisión sin el desglose por segmento que la sostiene. Qué pasa: alguien resume este proyecto como "no migramos todavía", sin mencionar que el problema está concentrado en cold-start ni que el resto del tráfico ya está prácticamente listo. Por qué pasa: la conclusión final ("no migrar") se siente como la única información que hace falta comunicar, y el detalle de qué segmento específico falló parece un añadido opcional. Cómo detectarlo: si el reporte no puede contestar "¿qué parte de recs-v2 sí está lista, y cuál no?", falta la mitad de la evidencia que sostiene la decisión — y sin ese detalle, alguien podría concluir, equivocadamente, que hay que descartar recs-v2 por completo. Cómo corregirlo: como en la Parte 2 de este proyecto, el reporte completo necesita el segmento exacto, sus dos tasas de acuerdo comparadas, y el conteo de diferencias — no solo la palabra "NO" de la decisión final.

Investigar el problema de cold-start sin volver a correr shadow después del cambio. Qué pasa: el equipo ajusta la estrategia de cold-start de recs-v2 —basándose en el diagnóstico de este proyecto— y, confiando en que el ajuste "seguramente lo arregló", pasa directo al canary sin correr shadowCompare() de nuevo sobre el modelo corregido. Por qué pasa: después de diagnosticar un problema con precisión, la corrección se siente como el paso final, y repetir todo el ciclo de medición se siente redundante. Cómo detectarlo: si la decisión de avanzar al canary después de un ajuste no viene acompañada de un nuevo resultado de shadowCompare(), la corrección nunca se validó con datos, solo con la intuición de que debería funcionar. Cómo corregirlo: cualquier cambio al modelo —incluida una corrección dirigida al segmento que falló— reinicia el ciclo: shadow, medir, decidir, exactamente el mismo proceso de este proyecto, no una excepción para los ajustes que "seguramente ya funcionan".

Dar por cerrado el trabajo de este módulo sin haber conectado el resultado con el módulo 8. Qué pasa: el equipo confirma la decisión de no migrar todavía y considera que el trabajo terminó ahí, sin haber articulado todavía cómo este mismo caso —el ganador con guardrail roto de la guía de métricas— se junta con todo lo demás que la guía completa enseñó. Por qué pasa: la decisión de este proyecto se siente como el final de la historia del modelo, porque es la pregunta que este módulo se propuso contestar. Cómo detectarlo: si nadie puede decir "¿cómo se conecta esta decisión sobre recs-v2 con el rollout de recommendations que ya está en curso?", el trabajo de este módulo terminó bien, pero el ciclo completo de la guía todavía no. Cómo corregirlo: este proyecto entrega, con precisión, la decisión sobre la migración del modelo — juntar esa pieza con el flag, el rollout, el monitoreo, el rollback y el postmortem del resto de la guía, todo sobre el mismo caso de Mercado, es exactamente el trabajo del módulo 8, el capstone que cierra la guía completa.

Ejercicios de transferencia

A diferencia de los ejercicios de las lecciones anteriores, este proyecto te pide aplicar el mecanismo completo a un caso que este módulo nunca vio — la prueba real de si aprendiste a medir y decidir, o solo memorizaste el resultado de recs-v2.

Ejercicio 1 — Corre el proyecto sobre searchRankerV2. El equipo de búsqueda de Mercado quiere migrar su modelo de ranking de resultados de búsqueda, searchRankerV2, y corrió su propio shadow test: sobre 40 requests totales, 34 coincidieron con el modelo anterior y 6 no, todas las 6 diferencias concentradas en búsquedas de menos de 3 palabras (12 requests de ese tipo en total, de las cuales solo 6 coincidieron). Usando shadowCompare() conceptualmente (sin volver a escribir el código, solo el razonamiento), ¿cuál sería la tasa de acuerdo global, y cuál la del segmento de búsquedas cortas?

Ver solución

La tasa de acuerdo global sería 34 / 40 * 100 = 85.0%. El segmento de búsquedas cortas tendría 6 / 12 * 100 = 50.0% de acuerdo (6 de las 12 búsquedas cortas coincidieron, ya que las 6 diferencias totales están, según el enunciado, concentradas ahí). Con un umbral global de 70% y un umbral de segmento crítico de 60% —los mismos que usó este proyecto—, modelMigrationDecision() pasaría el primer chequeo (85.0 > 70) pero fallaría el segundo (50.0 < 60), devolviendo la misma decisión que obtuvo recs-v2: no promover todavía, el segmento de búsquedas cortas necesita diagnóstico antes de exponerse.

Ejercicio 2 — Diseña la Parte 5: el mensaje al equipo de producto. Escribe el mensaje (100-150 palabras) que le enviarías al equipo de producto de Mercado, reportando la decisión de no promover recs-v2 a canary todavía. Incluye: el resultado global, el segmento problemático con su número, que el resto del modelo está listo, y qué sigue (sin explicar todavía el detalle técnico del ajuste, solo nombrando que el equipo va a diagnosticarlo).

Ver solución

Un mensaje posible: "Terminamos de correr recs-v2 en shadow sobre tráfico real de Mercado: coincide con recs-v1 en el 78% de los casos. Pero ese número esconde algo importante — para compradores con historial de compras, el acuerdo es perfecto (100%); para compradores nuevos sin historial, cae a solo 27%. Las 11 diferencias que encontramos están, todas, concentradas en ese segundo grupo: recs-v2 cambió la forma de recomendar a compradores nuevos, usando tendencias regionales en vez de la tendencia general del sitio. Todavía no sabemos si ese cambio es una mejora o un problema, así que no vamos a activar el canary hasta investigarlo. El resto del modelo —la mayoría del tráfico— ya está listo. El equipo va a diagnosticar específicamente la estrategia de cold-start antes de la próxima ronda de shadow." El mensaje separa con claridad lo que ya está listo de lo que no, da los números exactos de los dos segmentos, y deja explícito que la decisión es "todavía no", no "nunca".

Ejercicio 3 — Predicción sobre el módulo 8. Sin haber leído todavía el módulo 8, y basándote en todo lo que construiste en este proyecto y en el resto de la guía, ¿qué crees que el capstone final va a necesitar juntar sobre el caso de recommendations, además de esta decisión sobre el modelo?

Ver solución

Probablemente el capstone va a necesitar juntar, sobre el mismo caso de recommendations, todas las piezas que la guía completa construyó: el flag detrás del cual vive la feature (módulo 2), el plan de rollout gradual con sus etapas (módulo 3), el monitoreo de guardrails en cada etapa —incluida la latencia que se rompió a 10% (módulo 4)—, la decisión de frenar o revertir ante ese guardrail roto (módulo 5), el postmortem blameless de esa regresión (módulo 6), y ahora, la decisión sobre si el modelo que impulsa toda la feature está listo para su propia migración (este módulo 7). El capstone, entonces, probablemente no introduce ningún concepto nuevo — junta, sobre un solo caso completo, cada decisión formal que la guía enseñó a tomar por separado.

Resumen y siguiente paso

En este mini-proyecto reuniste el proceso completo de migración sobre el caso real de recs-v2: shadowCompare() confirmó 78.0% de acuerdo global con un 26.7% crítico en el segmento cold-start, modelMigrationDecision() tradujo ese resultado en una decisión formal de no promover todavía, y redactPII() definió exactamente qué datos de un registro real —siete campos, cuatro con algún grado de información personal— sobreviven al proceso de logging: dos completos, uno enmascarado, uno pseudonimizado, tres eliminados. Con esto cierras el módulo 7: tienes, en código ejecutado y verificado, la disciplina completa de comparar antes de exponer, investigar un incidente probabilístico con el criterio correcto, y registrar solo lo necesario.

Hacia dónde sigues. El módulo 8, el capstone final de la guía completa, junta las siete piezas construidas en los módulos anteriores —feature flags, rollout gradual, monitoreo de guardrails, rollback e incident response, postmortems blameless, y ahora la migración segura de un modelo— sobre el mismo caso de recommendations, de principio a fin: la feature detrás de un flag, su rollout gradual, la latencia que rompe el guardrail a 10%, la decisión de frenar, el postmortem de esa regresión, y el plan de mejora del modelo que impulsa la feature completa. Con eso, cierras no solo este módulo, sino el ecosistema completo de Product Engineering — y sabes, con evidencia y no con intuición, cómo lanzar un cambio de producto, monitorearlo, revertirlo si hace falta, aprender del resultado, y migrar con seguridad el modelo de IA que lo sostiene.

Recursos

  • Amazon SageMaker AI, "Shadow tests" — docs.aws.amazon.com/sagemaker/latest/dg/shadow-tests.html. Cierra el módulo donde lo abrió la lección 3: cómo una plataforma real automatiza exactamente el mecanismo que este proyecto ejecutó a mano, desde la comparación en shadow hasta la decisión de promover a producción. En inglés.
  • Google SRE Workbook, Capítulo 16, "Canarying Releases" — sre.google/workbook/canarying-releases. El capítulo que formaliza el paso que sigue después de una decisión positiva de modelMigrationDecision(): exponer primero a una fracción chica, y solo entonces continuar — el mismo rollout gradual de los módulos 2 y 3, ahora aplicado a un modelo. En inglés.