Saltar al contenido

039 — Columnas anchas: modelar desde la consulta

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 07 · ← Anterior · Siguiente →

Parte 07 — Grafos, columnas, tiempo y búsqueda · Avanzado · 3 horas estimadas · motores cassandra, scylladb · laboratorio labs/05-nosql-workloads · 3 fuentes.

Conceptos centrales: clave de partición · clave de agrupamiento · desnormalización por consulta

En este caso se comparan 7 motores: 6 lo resuelven (4 con el resultado comprobado por máquina) y 1 no, con el motivo escrito.

De qué trata esta clase

El método de diseño invertido de las columnas anchas: primero se escribe la lista de consultas y después una tabla por consulta, aunque los mismos datos queden repetidos cinco veces. La clave de partición decide en qué nodo vive la fila y la de agrupamiento el orden dentro de ella; equivocarse en la primera es el error de diseño más caro de esta familia.

flowchart LR
    C["🗄️ Clase 039"]
    C --> K1["clave de partición"]
    C --> K2["clave de agrupamiento"]
    C --> K3["desnormalización por consulta"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
019 Desnormalización deliberada y patrones de acceso redundancia controlada · costo de escritura · agregado · patrón de lectura
034 El agregado como unidad de consistencia agregado · frontera transaccional · entidad · actividad

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
clave de partición La parte de la clave primaria que decide en qué nodo vive la fila. Toda consulta eficiente debe fijarla; una consulta sin ella obliga a preguntar a todo el anillo, y es el error de diseño número uno en columnas anchas. se introduce aquí
clave de agrupamiento La parte de la clave primaria que ordena las filas dentro de una partición. Es lo que permite leer rangos —«los últimos 20 mensajes de este chat»— con una sola lectura secuencial, y por eso el orden se decide al crear la tabla, no al consultar. se introduce aquí
desnormalización por consulta Método de diseño de columnas anchas: se escribe primero la lista de consultas y luego una tabla por consulta, aunque los mismos datos queden repetidos en cinco tablas. La coherencia entre copias pasa a ser responsabilidad de la aplicación. se introduce aquí

Propósito

Modelar en un motor de columnas anchas, donde la regla se invierte: no se normaliza y luego se consulta, sino que se enumeran las consultas y se diseña una tabla por cada una.

Resultados de aprendizaje

Al terminar podrás:

  1. Distinguir clave de partición de clave de agrupamiento y su efecto físico.
  2. Diseñar tablas a partir de las consultas, aceptando la duplicación.
  3. Reconocer las particiones calientes y las particiones sin límite.
  4. Explicar por qué CQL prohíbe deliberadamente operaciones que SQL permite.
  5. Elegir el nivel de consistencia y calcular cuándo hay lectura consistente.

Fundamentos

La clave primaria tiene dos partes

PRIMARY KEY ( (course_id, periodo), registrada_en, student_id )
--             ^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^^^^^
--             clave de partición    claves de agrupamiento

Consecuencias que rigen todo lo demás:

  1. Toda consulta eficiente debe especificar la clave de partición completa. Sin ella, hay que preguntar a todos los nodos.
  2. Solo se puede filtrar por rango en las claves de agrupamiento y en orden, sin saltarse ninguna.
  3. ORDER BY solo funciona sobre las claves de agrupamiento y en el sentido declarado.

Lo que CQL prohíbe, y por qué

Operación SQL En CQL Motivo
JOIN No existe Exigiría coordinación entre nodos
GROUP BY arbitrario Muy limitado Ídem
Filtrar por columna no clave Requiere ALLOW FILTERING Sería un barrido del clúster
Subconsultas No existen Ídem
OR en el WHERE No Rompería la localización por partición

ALLOW FILTERING no es una opción avanzada: es una advertencia. Su presencia en código de producción indica casi siempre que el modelo no corresponde a la consulta.

Las prohibiciones son de diseño. Un motor que permitiera reuniones distribuidas ofrecería consultas cuyo costo crece con el tamaño del clúster, y eso es exactamente lo que Cassandra evita para garantizar latencia predecible.

Modelado dirigido por la consulta

flowchart TD
    Q["Enumerar TODAS las consultas"] --> P["Por cada una: ¿qué se conoce<br/>en el momento de preguntar?"]
    P --> K["Eso es la clave de partición"]
    K --> O["¿En qué orden se quiere el resultado?"]
    O --> C["Eso son las claves de agrupamiento"]
    C --> T["Una tabla por consulta"]
    T --> W["Escribir en todas ellas<br/>en cada operación"]
    W --> V{"¿Alguna partición<br/>crece sin límite?"}
    V -- "Sí" --> B["Añadir un cubo temporal<br/>a la clave de partición"]
    V -- "No" --> OK["Modelo válido"]

Particiones: los dos fallos

Partición caliente: una clave concreta recibe una fracción desproporcionada del tráfico. Si course_id es la clave y un curso tiene el 40 % de las inscripciones, un nodo hace el 40 % del trabajo mientras los demás están ociosos.

Partición sin límite: una partición que crece indefinidamente. Cassandra recomienda mantenerlas por debajo de ~100 MB y ~100 000 filas. Una partición por sensor_id que recibe una medición por segundo supera esa cota en poco más de un día.

La solución para ambos es la misma: añadir un cubo a la clave de partición.

PRIMARY KEY ( (sensor_id, dia), medido_en )

Ahora cada partición cubre un día. El costo: una consulta de siete días debe preguntar por siete particiones, lo que el cliente resuelve con siete consultas en paralelo. Elegir el tamaño del cubo es un cálculo, no una intuición:

mediciones por día = 86 400
tamaño de fila     ≈ 100 bytes
partición diaria   ≈ 8,6 MB          ← correcto
partición mensual  ≈ 260 MB          ← demasiado grande

Consistencia ajustable

Cada operación elige cuántas réplicas deben responder:

Nivel Significado
ONE Una réplica
QUORUM ⌊RF/2⌋ + 1
LOCAL_QUORUM Quórum dentro del centro de datos local
ALL Todas

Regla de lectura consistente: R + W > RF. Con factor de replicación 3:

W R ¿Lectura consistente?
ONE (1) ONE (1) No: 1+1 = 2 ≤ 3
QUORUM (2) QUORUM (2) : 2+2 = 4 > 3
ALL (3) ONE (1) Sí, pero sin tolerancia a fallos en escritura
ONE (1) ALL (3) Sí, pero sin tolerancia a fallos en lectura

QUORUM/QUORUM es el punto de equilibrio habitual: tolera la caída de una réplica en ambos sentidos. Es el mismo cálculo de quórums de Dynamo, que se retoma en la clase 043.

Ejemplo trabajado

Consultas del dominio:

# Consulta Se conoce
Q1 Inscripciones de un curso, las más recientes primero course_id, periodo
Q2 Cursos de un estudiante student_id
Q3 Una inscripción concreta ambos

Tres tablas, una por consulta:

CREATE TABLE inscripciones_por_curso (
  course_id     text, periodo text,
  registrada_en timestamp, student_id int,
  student_nombre text, nota decimal, estado text,
  PRIMARY KEY ((course_id, periodo), registrada_en, student_id)
) WITH CLUSTERING ORDER BY (registrada_en DESC, student_id ASC);

CREATE TABLE cursos_por_estudiante (
  student_id int, periodo text,
  course_id text, course_nombre text, nota decimal, estado text,
  PRIMARY KEY ((student_id), periodo, course_id)
) WITH CLUSTERING ORDER BY (periodo DESC, course_id ASC);

CREATE TABLE inscripcion (
  student_id int, course_id text,
  periodo text, nota decimal, estado text, registrada_en timestamp,
  PRIMARY KEY ((student_id, course_id))
);

Los mismos datos, tres veces. Eso es correcto en este modelo: el almacenamiento es barato y la coordinación distribuida es cara.

Escritura: un lote lógico.

BEGIN BATCH
  INSERT INTO inscripciones_por_curso (course_id, periodo, registrada_en, student_id,
                                       student_nombre, estado)
         VALUES ('bd','2026-1', toTimestamp(now()), 11, 'Ana Pérez', 'activa');
  INSERT INTO cursos_por_estudiante (student_id, periodo, course_id, course_nombre, estado)
         VALUES (11, '2026-1', 'bd', 'Bases de datos', 'activa');
  INSERT INTO inscripcion (student_id, course_id, periodo, estado, registrada_en)
         VALUES (11, 'bd', '2026-1', 'activa', toTimestamp(now()));
APPLY BATCH;

Advertencia importante: un BATCH que abarca varias particiones no es una transacción. Garantiza que todas las sentencias se aplicarán eventualmente, mediante un registro de lote, y tiene un costo de coordinación notable. Los lotes son adecuados para mantener sincronizadas vistas duplicadas de una misma escritura lógica, no para lógica transaccional.

Comprobación de la partición sin límite. inscripciones_por_curso tiene una partición por (course_id, periodo). Un curso masivo con 50 000 inscritos y filas de ~150 bytes da 7,5 MB: dentro de lo aceptable. Si el dominio admitiera cursos de un millón, habría que cubetear por mes de inscripción.

Q2 con ALLOW FILTERING, el antipatrón:

SELECT * FROM inscripciones_por_curso WHERE student_id = 11 ALLOW FILTERING;

Funciona y pregunta a todos los nodos, leyendo todas las particiones. Con 300 cursos, la consulta lee 300 particiones para devolver 8 filas. Por eso existe cursos_por_estudiante.

Comparación

Dimensión Relacional Columnas anchas
Punto de partida El esquema normalizado Las consultas
Duplicación Se evita Se busca
Reuniones En el motor En la escritura
Consultas no previstas Se escriben y funcionan Exigen tabla nueva y relleno
Coste de escritura Uno Uno por vista
Escalado horizontal de escritura Limitado Lineal

Errores frecuentes

  1. ALLOW FILTERING en producción. Señal de modelo ausente.
  2. Particiones sin cota. Degradan la latencia progresivamente hasta el fallo.
  3. Confundir BATCH con transacción. No hay atomicidad entre particiones.
  4. Clave de partición de baja cardinalidad. Concentra los datos en pocos nodos.
  5. Leer con ONE tras escribir con ONE y esperar consistencia. R + W ≤ RF.
  6. Índices secundarios de Cassandra por costumbre. Consultan todos los nodos; en general se prefiere una tabla nueva.

De la clase a la operación

Una consulta no prevista en un modelo de columnas anchas no es un SELECT nuevo: es una tabla nueva más el relleno histórico de todos los datos existentes. Enumerar bien las consultas al principio no es burocracia, es la única forma barata de hacerlo.

Reto de transferencia

  1. Enumera las consultas de tu dominio con lo que se conoce en el momento de preguntar.
  2. Diseña una tabla por consulta con sus claves de partición y agrupamiento.
  3. Calcula el tamaño máximo de la partición más grande y decide el cubo si hace falta.
  4. Elige R y W para dos operaciones distintas y justifica con R + W > RF.

Preguntas de evaluación

  1. ¿Por qué CQL prohíbe las reuniones en lugar de permitirlas y avisar de su costo?
  2. Calcula el tamaño de partición de una serie tuya y elige el cubo temporal.
  3. Con RF = 5, ¿qué combinaciones de R y W dan lectura consistente?
  4. Da una consulta nueva sobre tu modelo y describe el trabajo completo de añadirla.

🌐 El mismo problema en cada motor

Caso: Las dos últimas lecturas de un sensor, de la más reciente a la más antigua

En el modelo de columnas anchas no se modela el dominio: se modela la consulta. La clave primaria de Cassandra tiene dos partes con papeles muy distintos: la de partición decide en qué nodo vive la fila, y la de agrupamiento decide en qué orden están las filas dentro de esa partición, en el disco.

El caso lo pone a prueba con la consulta más común de la telemetría: las dos últimas lecturas de sensor-1. Con la tabla modelada así, la respuesta son dos celdas contiguas de una sola partición, en un solo nodo, sin ordenar nada. Cambiar la pregunta —«las lecturas de todos los sensores a las 10:01»— obliga a otra tabla, y esa es exactamente la lección.

Salida esperada, idéntica en todos los motores que lo resuelven:

momento valor
2026-08-19T10:02:00Z 23
2026-08-19T10:01:00Z 22

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 039: 4 de las 6 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
Apache Cassandra declarado código doc oficial
ScyllaDB declarado código doc oficial
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MongoDB servicio código doc oficial
Redis no doc oficial

Los que resuelven el caso

Apache Cassandra · implementaciones/cassandra/consulta.cql

declarado — se revisa a mano contra la documentación citada; la máquina no lo ejecuta

-- motor: cassandra
-- doc: https://cassandra.apache.org/doc/latest/cassandra/developing/data-modeling/data-modeling_queries.html
-- nota: implementacion declarada. Las dos partes de la clave primaria hacen
--       cosas distintas y conviene no confundirlas:
--         (dispositivo)  clave de PARTICION -> en que nodo vive la fila
--         momento        clave de AGRUPAMIENTO -> en que orden estan las filas
--                        DENTRO de esa particion, en el disco
--       Por eso LIMIT 2 lee dos celdas contiguas de un solo nodo.

-- === preparacion ===
CREATE KEYSPACE IF NOT EXISTS telemetria
  WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};

DROP TABLE IF EXISTS telemetria.lecturas_por_dispositivo;

CREATE TABLE telemetria.lecturas_por_dispositivo (
    dispositivo text,
    momento     timestamp,
    valor       int,
    PRIMARY KEY (dispositivo, momento)
) WITH CLUSTERING ORDER BY (momento DESC);

INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-1', '2026-08-19T10:00:00Z', 21);
INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-1', '2026-08-19T10:01:00Z', 22);
INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-1', '2026-08-19T10:02:00Z', 23);
INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-2', '2026-08-19T10:00:00Z', 30);
INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-2', '2026-08-19T10:01:00Z', 31);

-- === consulta ===
-- Sin ORDER BY: el orden ya esta en el disco. Y sin ALLOW FILTERING: la
-- consulta toca UNA particion.
--
-- La pregunta inversa —«todas las lecturas de las 10:01, de cualquier sensor»—
-- NO se puede responder con esta tabla, y esa es la leccion. Haria falta
-- lecturas_por_minuto, escrita por la aplicacion en la misma operacion.
--
-- Y ojo con la particion infinita: un sensor que emite para siempre hace crecer
-- su particion sin limite. En produccion la clave seria ((dispositivo, dia)).
SELECT momento, valor
FROM telemetria.lecturas_por_dispositivo
WHERE dispositivo = 'sensor-1'
LIMIT 2;

ScyllaDB · implementaciones/scylladb/consulta.cql

declarado — se revisa a mano contra la documentación citada; la máquina no lo ejecuta

-- motor: scylladb
-- doc: https://opensource.docs.scylladb.com/stable/cql/ddl.html
-- nota: implementacion declarada, y deliberadamente IDENTICA a la de Cassandra:
--       ese es el argumento. ScyllaDB habla el mismo CQL y usa el mismo modelo
--       de datos, asi que el diseno migra sin tocar una linea. Lo que cambia
--       esta debajo: implementacion en C++ con un hilo fijado por nucleo y sin
--       recolector de basura, lo que elimina las pausas que en Cassandra hay
--       que ajustar a mano.
--       Lo que NO es identico: indices secundarios, vistas materializadas y
--       herramientas de operacion. La compatibilidad es alta, no total.

-- === preparacion ===
CREATE KEYSPACE IF NOT EXISTS telemetria
  WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};

DROP TABLE IF EXISTS telemetria.lecturas_por_dispositivo;

CREATE TABLE telemetria.lecturas_por_dispositivo (
    dispositivo text,
    momento     timestamp,
    valor       int,
    PRIMARY KEY (dispositivo, momento)
) WITH CLUSTERING ORDER BY (momento DESC);

INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-1', '2026-08-19T10:01:00Z', 22);
INSERT INTO telemetria.lecturas_por_dispositivo (dispositivo, momento, valor)
  VALUES ('sensor-1', '2026-08-19T10:02:00Z', 23);

-- === consulta ===
SELECT momento, valor
FROM telemetria.lecturas_por_dispositivo
WHERE dispositivo = 'sensor-1'
LIMIT 2;

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/lang_createtable.html
-- nota: la clave primaria (dispositivo, momento) ordena las filas en el arbol B
--       exactamente como la clave de agrupamiento de Cassandra ordena las celdas
--       dentro de la particion. La idea es la misma; lo que falta aqui es el
--       reparto entre nodos.

-- === preparacion ===
CREATE TABLE lecturas (
    dispositivo TEXT NOT NULL,
    momento     TEXT NOT NULL,
    valor       INTEGER NOT NULL,
    PRIMARY KEY (dispositivo, momento)
);
INSERT INTO lecturas (dispositivo, momento, valor) VALUES
    ('sensor-1', '2026-08-19T10:00:00Z', 21),
    ('sensor-1', '2026-08-19T10:01:00Z', 22),
    ('sensor-1', '2026-08-19T10:02:00Z', 23),
    ('sensor-2', '2026-08-19T10:00:00Z', 30),
    ('sensor-2', '2026-08-19T10:01:00Z', 31);

-- === consulta ===
-- Las dos ultimas lecturas de sensor-1, de la mas reciente a la mas antigua.
SELECT momento, valor
FROM lecturas
WHERE dispositivo = 'sensor-1'
ORDER BY momento DESC
LIMIT 2;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/query_syntax/orderby.html
-- nota: la pregunta que hay que hacerle a DuckDB no es esta, sino esta otra:
--         SELECT dispositivo, COUNT(*) FROM lecturas
--         GROUP BY dispositivo ORDER BY 2 DESC LIMIT 10;
--       es decir, que particion va a crecer sin limite. Mejor saberlo antes.

-- === preparacion ===
CREATE TABLE lecturas (
    dispositivo VARCHAR NOT NULL,
    momento     VARCHAR NOT NULL,
    valor       INTEGER NOT NULL,
    PRIMARY KEY (dispositivo, momento)
);
INSERT INTO lecturas (dispositivo, momento, valor) VALUES
    ('sensor-1', '2026-08-19T10:00:00Z', 21),
    ('sensor-1', '2026-08-19T10:01:00Z', 22),
    ('sensor-1', '2026-08-19T10:02:00Z', 23),
    ('sensor-2', '2026-08-19T10:00:00Z', 30),
    ('sensor-2', '2026-08-19T10:01:00Z', 31);

-- === consulta ===
-- Las dos ultimas lecturas de sensor-1, de la mas reciente a la mas antigua.
SELECT momento, valor
FROM lecturas
WHERE dispositivo = 'sensor-1'
ORDER BY momento DESC
LIMIT 2;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/ddl-partitioning.html
-- nota: el indice (dispositivo, momento DESC) da el mismo atajo que la clave de
--       agrupamiento de Cassandra: EXPLAIN muestra «Index Scan» sin nodo Sort.
--       La diferencia no esta en la lectura, esta en que la escritura sigue
--       pasando por un unico nodo primario.

-- === preparacion ===
DROP TABLE IF EXISTS lecturas;

CREATE TABLE lecturas (
    dispositivo text NOT NULL,
    momento     text NOT NULL,
    valor       integer NOT NULL,
    PRIMARY KEY (dispositivo, momento)
);
INSERT INTO lecturas (dispositivo, momento, valor) VALUES
    ('sensor-1', '2026-08-19T10:00:00Z', 21),
    ('sensor-1', '2026-08-19T10:01:00Z', 22),
    ('sensor-1', '2026-08-19T10:02:00Z', 23),
    ('sensor-2', '2026-08-19T10:00:00Z', 30),
    ('sensor-2', '2026-08-19T10:01:00Z', 31);

CREATE INDEX lecturas_recientes ON lecturas (dispositivo, momento DESC);

-- === consulta ===
-- Las dos ultimas lecturas de sensor-1, de la mas reciente a la mas antigua.
SELECT momento, valor
FROM lecturas
WHERE dispositivo = 'sensor-1'
ORDER BY momento DESC
LIMIT 2;

MongoDB · implementaciones/mongodb/consulta.js

verificado — se ejecuta contra el motor real levantado con docker compose

// motor: mongodb
// doc: https://www.mongodb.com/docs/manual/core/timeseries-collections/
// nota: una coleccion de series temporales agrupa internamente las medidas por
//       metaField y ventana de tiempo. El metaField hace el papel de la clave
//       de particion de Cassandra, y elegirlo mal produce el mismo problema:
//       un fragmento que recibe toda la escritura.

// === preparacion ===
db.lecturas.drop();
db.createCollection("lecturas", {
  timeseries: { timeField: "momento", metaField: "dispositivo", granularity: "minutes" },
});
db.lecturas.insertMany([
  { dispositivo: "sensor-1", momento: new Date("2026-08-19T10:00:00Z"), valor: 21 },
  { dispositivo: "sensor-1", momento: new Date("2026-08-19T10:01:00Z"), valor: 22 },
  { dispositivo: "sensor-1", momento: new Date("2026-08-19T10:02:00Z"), valor: 23 },
  { dispositivo: "sensor-2", momento: new Date("2026-08-19T10:00:00Z"), valor: 30 },
  { dispositivo: "sensor-2", momento: new Date("2026-08-19T10:01:00Z"), valor: 31 },
]);

// === consulta ===
db.lecturas
  .find({ dispositivo: "sensor-1" })
  .sort({ momento: -1 })
  .limit(2)
  .forEach((d) => print(d.momento.toISOString().replace(".000Z", "Z") + "|" + d.valor));

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
Redis Un conjunto ordenado por marca de tiempo responde esta consulta muy bien, pero todo vive en memoria: la telemetría es precisamente el caso donde el volumen histórico no cabe y no se puede permitir perder. Redis como ventana caliente de las últimas horas, con la serie completa en el almacén que sí puede guardarla; dos sistemas con dos papeles, declarado cuál es cuál. doc

Laboratorio

python scripts/validate_repository.py
python labs/05-nosql-workloads/run_nosql_lab.py

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 07 · ← Anterior · Siguiente →