Saltar a contenido

Changelog

Formato basado en Keep a Changelog; el proyecto sigue SemVer: desde 1.0, el pipeline de scorecard (F1) es API estable; las superficies que aún crecen (modelado ML, provisiones, forward-looking, contratos transversales) quedan marcadas como experimentales, fuera de la garantía SemVer 1.x.

[No publicado]

Añadido

  • El model card ya sale con las métricas del modelo. Hasta ahora una corrida completa terminaba con el resumen de métricas vacío: el model card se generaba sin AUC, sin KS, sin PSI y sin decisiones, que es justo el bloque que exige la guía de gobierno de modelos. Había dos consumidores esperando ese resumen —el model card y el registro en MLflow— y ningún productor que lo llenara. Ahora cada dominio publica una lista corta y declarada de métricas y el núcleo las reúne bajo <dominio>.<métrica>.

La lista es de dominio y no un volcado automático, y la diferencia importa: AUC, Gini, KS y PSI no son campos sueltos de ninguna ficha —viven por partición y por comparación—, así que copiar «todo lo numérico» habría publicado el paso de puntaje y el número de deciles como «las métricas del modelo», y habría dejado fuera el AUC.

Una métrica que no se puede evaluar no aparece. Una cartera demasiado corta para estimar un AUC no publica un AUC de cero: publica nada, y la ausencia queda registrada en el audit-trail. Un cero se lee como una medición pésima; la ausencia se lee como lo que es.

  • nikodym.run(config, run_dir=...) deja la evidencia de la corrida en disco. Con run_dir se escriben ahí el audit-trail, el snapshot del entorno, el model_card.json y su versión en Markdown, más la corrida serializada. Cada archivo aparece sólo si su sección está activa: con auditoría pero sin gobernanza hay trail y entorno, y no hay model card.

Sin run_dir no se escribe nada, exactamente como antes. Una librería no debe empezar a dejar archivos en el directorio de trabajo de quien la importa.

  • Los cuatro ejemplos de fábrica traen la auditoría encendida. Es lo que hace que el model card llegue con sus decisiones registradas en vez de una lista vacía. La gobernanza sigue apagada de fábrica a propósito: exige declarar el propósito del modelo, y ese dato sólo lo puede fijar la institución. El motor no lo inventa.

Cambiado

  • ⚠️ Correr un ejemplo de fábrica por código ahora pide decir dónde va la evidencia. Como los cuatro ejemplos traen la auditoría encendida, y el audit-trail ya no puede caer en el directorio de trabajo, un script que hacía
study = nikodym.run(NikodymConfig.model_validate(get_preset(...)["config"]))

ahora falla con un error que nombra el arreglo. La corrección es añadir el destino:

study = nikodym.run(NikodymConfig.model_validate(config), run_dir="mis-corridas/hoy")

Un config propio con la auditoría apagada —o con una ruta absoluta para el trail— no cambia. Desde la interfaz tampoco hay nada que hacer: ella misma archiva el trail junto a su corrida.

Corregido

  • El audit-trail ya no se escribe en el directorio desde el que se lanza la corrida. Se escribía ahí pese a que su documentación decía «dentro del directorio del run», así que dos corridas lanzadas desde el mismo sitio concatenaban sus eventos en el mismo archivo, que es justo lo que el contrato de auditoría prohíbe: un trail por corrida. Ahora se resuelve contra el directorio de la corrida. Una ruta absoluta se sigue respetando; una ruta relativa sin directorio de corrida pasa a ser un error explícito en vez de ensuciar el directorio de trabajo en silencio.

  • El análisis de supervivencia era inalcanzable con la auditoría encendida. El estimador viaja dentro de su resultado y arrastraba el destino del audit-trail —un archivo abierto— al copiarse, de modo que la corrida moría con un error de tipo antes de publicar nada, y sin dejar un estudio inspeccionable. No se notaba porque ningún ejemplo de fábrica traía la auditoría encendida.

Sabido

  • Los datos de la demo publicada siguen mostrando el model card vacío: se regeneran en un paso aparte. La identidad de las corridas de la demo no cambia con esta versión.

[1.12.0] — 2026-08-27

Cambiado

  • La garantía SemVer ahora significa algo, y cubre la regresión logística. Desde 1.0 la promesa es que el pipeline de scorecard (F1) no rompe hasta un 2.0, pero la etiqueta la escribía a mano cada paquete, no la comprobaba ningún test y la referencia de la API publicaba una tercera lista. Se contradecía en las dos direcciones a la vez: nikodym.model —la regresión logística PD, que es el corazón de F1— se declaraba experimental, mientras que nikodym.audit, que no forma parte de F1, se declaraba estable. Para quien instalaba con pip, la etiqueta no distinguía nada.

Ahora la lista vive en un solo sitio (nikodym.testing.stability) y los gates la atan al docstring de cada paquete y a la página de referencia. model queda dentro de la garantía, que es lo que la promesa ya decía; y audit queda dentro por decisión explícita: el trail JSONL, el hashing y el replay ya son superficie de integración, y romperlos en un minor costaría más que sostenerlos.

Añadido

  • La interfaz que se distribuye se ejecuta ahora en un navegador real antes de publicarse. El árbol estático que viaja dentro del wheel se verificaba por SHA-256 contra su procedencia y no lo cargaba nunca un navegador: los tests del frontend corren sin DOM y el smoke de instalación llama a la aplicación en proceso. Un bundle íntegro byte a byte pero roto al ejecutarse —un import muerto, un asset renombrado— pasaba todos los gates y llegaba a PyPI. El nuevo trabajo de integración instala el paquete candidato fuera del repositorio, levanta nikodym-ui desde ahí y recorre con un navegador el camino completo: elegir un ejemplo, ejecutar, ver los resultados con su procedencia y abrir el informe.

  • El smoke de instalación recorre los cuatro ejemplos de fábrica, no sólo el de scorecard. La documentación promete que el extra [ui] los corre todos hasta el informe con una sola instalación, y hasta ahora esa promesa no la ejercía ningún gate sobre el paquete distribuido.

  • La deriva entre uv.lock y el manifiesto de build es ahora un gate. La comprobación existía pero no la invocaba nadie, así que un desajuste se descubría en la corrida de quien usa la librería, no en la nuestra.

Corregido

  • El informe ya no imprime códigos internos en su prosa. Tres párrafos —el de validación, el del orquestador de provisiones y el de IFRS 9— volcaban identificadores como FALTA-DATO-VAL-2 en medio del texto que lee una persona. La regla publicada es que esos códigos aparecen sólo en el volcado de auditoría del anexo, donde el código es el dato, y que la prosa explica la limitación en palabras. Ahora cada aviso se redacta, y uno que el motor no sepa redactar se declara igualmente, sin nombrarse: callar una limitación en un informe regulatorio sería el error contrario, y peor.

  • El README subdeclaraba la reproducibilidad. Decía que el hash del uv.lock estaba pendiente y que el campo «viaja vacío». Es falso desde hace varias versiones: el hash se calcula y se escribe en el lineage de cada corrida.

  • La referencia de la API decía publicar las firmas de 1.4.0 en un paquete que iba por 1.11.0.

  • El sitio afirmaba que survival tiene capítulo propio en el informe. No lo tiene, y el código lo dice por escrito: su curva alimenta la prosa del capítulo de IFRS 9. También contaba tres ejemplos de fábrica cuando hay cuatro.

[1.11.0] — 2026-08-05

Añadido

  • El panel de resultados dice de dónde salió lo que muestra. Quien corre por la interfaz ve el panel antes que el informe, y hasta ahora esa pantalla no publicaba ninguna procedencia: ni el hash de los datos, ni la versión con la que se corrió, ni si el árbol de código tenía cambios sin confirmar, ni las advertencias de determinismo, ni qué artefactos entraron desde fuera. El informe sí lo publica desde siempre, así que la deuda era la asimetría entre dos superficies de la misma corrida. Ahora results.json trae esa procedencia entera —la misma que el anexo del informe— y el panel la enseña. Es aditivo: un cliente que no conozca la clave la ignora, y los resultados guardados antes de este cambio no la traen y se leen igual.

De paso, el panel deja de mostrar el hash de configuración del formulario y muestra el de la corrida: bastaba teclear en cualquier campo después de ejecutar para que la pantalla afirmara un hash que ninguna corrida había producido.

  • Las columnas que identifican una fila se eligen del propio archivo, con casillas, en vez de escribirse como una lista en JSON a mano. Afectaba sólo a ese campo, y la causa era que al desempaquetar un campo opcional se perdía la marca que dice «esto nombra columnas de tus datos».

  • Los conjuntos de datos de ejemplo también traen su perfil de columnas, así que el aviso de «esta columna parece un identificador» —que hasta ahora sólo alcanzaba a los archivos que subías— funciona igual con ellos, incluso si ya los habías usado antes de esta versión.

  • La interfaz ya muestra el valor que el motor usará en un campo que no has llenado. GET /api/schema publica un catálogo nuevo, effective_defaults, con el valor predeterminado real de cada campo del config: el mismo que ejecutan las clases del motor, no una copia escrita a mano. El formulario lo usa sólo para pintar, marcando esos valores como «Predeterminado; se usará mientras no elijas otro». Es aditivo: los tres campos anteriores del payload (json_schema, defaults, section_order) conservan su significado exacto y un cliente que no lo conozca lo ignora. Un dominio cuyo extra no esté instalado no se expande: su sección viaja sin valores debajo, igual que ya no aparecía expandida en el esquema, así que el formulario no ofrece ni un valor para ella.

  • Un ejemplo completo de provisiones sin ninguna normativa local. Entran un conjunto de datos (provision_interna_generica), un preset listo para correr (f5-provision-interna-generica) y una guía, Provisiones sin normativa local. La cartera se llama como la nombra la institución —nomina, microempresa, consumo_senior—, no hay ninguna categoría de supervisor, y la corrida produce su informe con el capítulo de provisiones sin nombrar ningún país. Existía el motor y no existía forma de enseñarlo.

  • El capítulo «Provisiones regulatorias» se emite cuando se calcularon provisiones, y no sólo cuando se compararon dos métodos. Una corrida con un único motor —el interno, o sólo el estándar— llegaba a su informe con la provisión escondida en el anexo de configuración, porque el capítulo dependía del comparador, que por definición exige dos fuentes distintas. Es aditivo: ningún informe pierde nada y los que corren un solo motor ganan su capítulo.

  • La interfaz se organiza por TRABAJOS, y la sesión ya no arranca sembrada con un ejemplo. Al entrar eliges a qué viniste —«Scorecard de comportamiento (PD)», «Validar un modelo existente», «PD + LGD en una corrida»…— y ese trabajo decide qué secciones existen: la pantalla deja de ofrecer las catorce a todo el mundo. GET /api/jobs publica el catálogo, y con él el abanico metodológico: 69 puntos de elección con sus 172 opciones, cada una con qué hace, qué exige y por qué está disponible o bloqueada, en idioma de negocio. Los ejemplos precargados siguen ahí, ahora como lo que son: «ver un ejemplo con datos de muestra».

  • nikodym.run() y nikodym.check_pipeline() aceptan artefactos externos. Un parámetro artifacts= permite inyectar tablas que la corrida no calcula —una PD ya calibrada, el puntaje de un modelo existente— para arrancar el pipeline por la mitad. Es lo que hace ejecutables los trabajos «Validar un modelo existente» y «Provisión interna / LGD». Aditivo y keyword-only: la firma anterior sigue valiendo. Por HTTP la puerta es estrictamente menos poderosa que por código: sólo entran tablas, nunca objetos serializados, y sólo las de los trabajos disponibles.

  • La división de la muestra se puede LEER del archivo en vez de derivarla. partition.strategy gana una cuarta forma, columna: si su panel ya trae la marca de desarrollo/validación/OOT, se declara qué columna es y qué valor corresponde a cada partición. Nada se adivina —ni por parecido de nombre, ni por orden, ni por frecuencia—: un valor declarado que no aparezca en los datos es un error que nombra los que sí aparecen.

  • kaplan_meier deja de exigir el extra lifelines. Ese método no lo usaba; ahora un config que lo elija corre con la instalación base, donde antes fallaba pidiendo una dependencia que no iba a utilizar.

Cambiado

  • 🔴 Un target_pd escrito junto al ancla por defecto ya no se descarta en silencio: detiene la corrida. anchor_source='development_observed' —el valor de fábrica— calcula la tasa central como el promedio observado en Desarrollo, así que el target_pd que el usuario escribía no se usaba y la corrida terminaba done sin decirlo. Medido sobre el preset F3, la diferencia era de 569 millones en la provisión, con los dos campos contiguos en la pantalla. Ahora esa combinación se rechaza al validar, nombrando la salida.

⚠️ Es un cambio de comportamiento que puede romper un pipeline que hoy corre. Si su config tiene ese par, hasta 1.10.0 se ejecutaba (con una cifra que no era la que usted pidió) y desde 1.11.0 no arranca. La salida es una línea: elija anchor_source='business_input' si quiere que su target_pd gobierne, o quite el target_pd si quiere el ancla observada.

  • Survival deja de ajustar en silencio sobre toda la población. Cuando existe una columna de partición y no hay filas de desarrollo, el motor se detiene en vez de ajustar sobre todo: medido, un coeficiente pasaba de +1,92 a −0,02 sin que nada lo señalara.

  • Tres opciones que el config aceptaba y morían a mitad de corrida ahora se rechazan al validar: binning.solver='cp', el modo de proyección period_matrices de markov, y performance.partitions con una sola partición. Ninguna funcionaba en 1.10.0; lo que cambia es cuándo se entera usted — antes de cargar el archivo, no en el paso 8 de 10.

  • GET /api/datasets separa el índice de las columnas. La columna identificador (loan_id en los conjuntos del catálogo) sale de columns y aparece en la clave nueva index_columns. Un cliente que la leyera dentro de columns deja de encontrarla ahí.

  • UiConfig.upload_max_mb gobierna de verdad. Era un campo muerto: declaraba 200 MB y el tope real eran 100 MiB fijos. Ahora el valor declarado manda y se comprueba antes de traer el cuerpo a memoria, también en los cinco POST de JSON, que no tenían ninguna cota. Un despliegue que lo hubiera fijado en 10 corría de hecho con 100 MiB y ahora corre con 10.

  • 🔴 El informe ya no afirma que los montos van en pesos chilenos. Hasta ahora los rotulaba «pesos chilenos (CLP)» en tres capítulos —incluido el de IFRS 9, que es un marco contable internacional— sin que nadie lo hubiera declarado en ninguna parte. Nace report.currency: si declaras una moneda, el informe la publica; si la dejas en blanco, no afirma ninguna y los montos siguen legibles con el símbolo genérico $. El motor no inventa un dato que sólo la institución conoce, y afirmar la moneda equivocada en un documento auditable es peor que no afirmar ninguna.

⚠️ No mueve el config_hash: report es una sección de presentación y está excluida de la identidad de la corrida, igual que la portada del entregable. Un informe existente que quiera seguir diciendo «CLP» sólo tiene que declararlo.

⚠️ La convención numérica del informe (coma decimal, punto de miles) no cambia: es la del idioma en que está escrito —hoy sólo español—, no la de una moneda.

  • 🔴 El método interno de provisiones ya no pide de fábrica una columna con nombre chileno. El valor por defecto de provisioning_internal.portfolio_col pasa de "cmf_portfolio" a "portfolio". Ese motor es jurisdiccionalmente neutro —no conoce ninguna tabla de supervisor, y su cálculo es PE = PI · PDI · Exposición sobre los grupos que tú formas—, pero su estado de fábrica exigía la taxonomía de un supervisor concreto: un banco de cualquier otro país tenía que renombrar su columna para correr un cálculo que no interpreta ninguna norma.

⚠️ Nota de contrato SemVer. Este cambio recalcula el config_hash de un config que omite esa clave y se apoya en el default, y con él su clave de idempotencia en el inventario de MLflow. Por eso sale como minor y no como patch — mismo criterio que 1.4.0 y 1.8.0. Medido, el alcance es más estrecho de lo que sugiere: los presets de fábrica no se muevenf1, f3 y f4 conservan byte a byte el config_hash que tenían en 1.10.0— y un config que declara portfolio_col tampoco, sea cual sea su valor. Sólo cambia quien lo omitía.

⚠️ El método estándar de la CMF conserva "cmf_portfolio", que ahí sí nombra su contenido: la cartera regulatoria chilena. La consecuencia es que los dos defaults dejan de estar alineados, así que una institución que compare estándar contra interno con los defaults de fábrica debe declarar portfolio_col en una de las dos secciones si tiene una sola columna de cartera. La comprobación previa del dataset lo señala antes de ejecutar nada cuando la columna nueva no existe en su archivo.

🔴 El caso que hay que mirar es el otro, y hasta esta versión no avisaba nadie. Si el archivo trae las dos columnas —lo que ocurre por construcción en quien corre IFRS 9 y provisión interna sobre un mismo panel, porque provisioning_ifrs9.portfolio_col también vale "portfolio"—, la comprobación previa daba verde: la columna que el config nombra existe de verdad. La corrida terminaba bien y la agrupación cambiaba en silencio. Ahora esa ambigüedad se avisa en la comprobación previa y queda registrada en el resultado de la corrida, que es lo que la lleva al informe para quien usa la librería por código. El aviso no detiene nada: si "portfolio" es la columna correcta, no hay que hacer nada.

  • 🔴 La provisión que se compara de fábrica es ahora la que exige la norma chilena. El valor por defecto de la segunda fuente de provisioning pasa de la pérdida esperada bajo NIIF 9 al método interno del banco. La regla del Capítulo B-1 de la CMF (Circular N° 2.346, hoja 10-11) es el mayor valor entre el método estándar y el método interno, por institución; el Capítulo A-2 num. 5 excluye el deterioro de NIIF 9 sobre las colocaciones y los créditos contingentes. El default anterior existía por retrocompatibilidad y publicaba un comparativo entre marcos contables que ninguna norma local pide — útil para una filial que reporta a su matriz extranjera, pero que había que saber que estaba mal para corregirlo.

A quién afecta: a quien active la sección provisioning sin declarar la segunda fuente. Su corrida seguirá corriendo, pero comparará contra otra cosa y su config_hash cambiará —y con él la clave de idempotencia de su inventario en MLflow—. Por eso va en minor y no en patch, igual que en 1.4.0. Para conservar el comportamiento anterior basta declararlo:

provisioning:
  source_b: provisioning_ifrs9

Ningún preset ni ejemplo del proyecto se mueve, y conviene decir la razón exacta: sólo f3 activa la sección provisioning, y escribe sus dos fuentes explícitamente; los demás la dejan apagada, así que no tienen dónde heredar el default.

  • El capítulo del informe deja de rotular «Chile» sobre una comparación que la norma no pide. Su título decía «la regla del máximo (Chile)» siempre, aunque se estuviera comparando contra NIIF 9 o por cartera en vez de por institución; el matiz estaba en el cuerpo y la etiqueta honesta, enterrada en el anexo. Ahora el título se deriva de la comparación configurada, con el mismo criterio que el motor ya usaba para elegir su referencia normativa.

Corregido

  • 🔴 El capítulo de provisiones ya no sale mudo cuando corren los dos motores sin comparador. Al emitirse el capítulo por «se calcularon provisiones» y no por «se compararon dos métodos», la combinación de método estándar y método interno con el comparador apagado quedaba con el título puesto y el titular vacío: el lector saltaba directo a la primera subsección y el total nunca aparecía. Ahora publica las dos cifras y dice explícitamente que no se aplicó ninguna regla de selección entre ellas, porque no se comparó nada.

  • 🔴 El informe ya no invoca la regla del máximo del Capítulo B-1 sobre una comparación que no se hizo. Cuando el comparador queda configurado pero sólo una de las dos fuentes llega a calcularse, el capítulo abría citando la Circular N° 2.346 —«el mayor valor entre el método estándar y el método interno»— y no publicaba ni una cifra, porque los montos exigen las dos. Era una afirmación normativa sin respaldo en su propia corrida, dentro de un documento auditable. Ahora dice que la comparación no se realizó y reporta la única fuente que sí corrió.

  • La guía de provisiones sin normativa local publicaba un fragmento de código que no se podía ejecutar: el ejemplo para declarar la moneda usaba una forma que no corresponde al objeto que la propia guía deja en pantalla. El fragmento quedó corregido y ahora lo ejecuta un test junto con el resto del ejemplo, que es lo que impedía verlo.

  • 🔴 La comprobación previa dejó de mentir sobre lo que un paso necesita. Con ml.feature_source='selection_woe', nikodym.check_pipeline rechazaba pipelines que corren —exigía el WoE de binning, que en ese modo el paso no abre nunca— y aceptaba pipelines que mueren —callaba los dos artefactos de selection que sí lee—. La causa: tuning y explain declaraban sus requisitos con el valor de fábrica de ml, copiado a mano en dos constantes, en vez de con el que la corrida iba a usar. De paso, quien traía esos artefactos por nikodym.run(..., artifacts=…) leía que no los usaba nadie, sobre las dos claves que el paso iba a consumir. Los resultados nunca estuvieron en riesgo —el paso revalida antes de calcular—; lo que fallaba era lo que se prometía antes de correr. Un gate nuevo recorre ahora todos los pasos del motor y exige que quien componga sus requisitos con decisiones de otra sección las reciba de verdad, para que el próximo caso no nazca en silencio.

  • 🔴 Pedir la planilla sin tener su librería ya no deja sin informe. xlsx era el único formato que no degradaba: si openpyxl no estaba instalado, la corrida moría al escribir el adjunto y se llevaba por delante el informe entero, incluido el HTML, que no depende de ningún extra —y hasta el .csv que ya se había escrito en disco quedaba fuera del resultado—. Ahora hace lo mismo que el PDF y el Word: avisa, entrega todo lo demás, y el documento no nombra un adjunto que no existe. Quien prefiera lo contrario tiene su interruptor, report.xlsx.fail_if_unavailable, con el mismo default apagado que sus dos hermanos.

  • 🔴 Un puntaje ya no puede medirse al revés de como se construyó. La dirección del puntaje —«un puntaje más alto, ¿es mejor o peor cliente?»— se pregunta en tres sitios, y hasta ahora cada uno se leía por su cuenta: con la tarjeta construida en un sentido y el desempeño midiendo en el otro, la corrida terminaba bien y el informe publicaba un modelo con la discriminación invertida —Gini negativo— sin un solo aviso, con todas las comprobaciones previas en verde. Ahora la dirección viaja con el puntaje: si la respuesta de una sección contradice la escala con que se construyó la tarjeta, se avisa antes de correr y la corrida se detiene diciendo cuál cambiar. Quien trae un puntaje ya construido desde fuera —el caso de «validar un modelo existente»— sigue declarando la suya, porque ahí sólo él la sabe.

  • El formulario dejó de mostrar un config distinto del que la corrida iba a ejecutar. Un campo que el archivo no traía se pintaba vacío, apagado o en cero aunque el motor fuera a usar otro valor: la interfaz leía el default del JSON Schema, que no existe para los bloques que el motor construye solo (report.sections, report.html, model.stepwise, selection.correlation y otros ~80 campos al activar una sección). Dos casos vivían en la configuración estándar F1: «Renderizar gráficos» se veía desactivado corriendo activado, y «Bloques por completar» se veía en blanco corriendo con show.

Ahora esos valores se ven, marcados como predeterminados, sin escribirse en tu config: montar la aplicación, cambiar de sección, abrir un YAML o descargarlo sin editar no añade ni una clave y no mueve el config_hash. El primer gesto sobre un control materializa sólo ese campo; activar una sección o cambiar de variante escribe su bloque completo, que es lo que ese gesto significa. Un valor que escribiste tú se respeta literalmente aunque sea null, false, 0, "" o una lista vacía: ya no se confunde «no lo decidí» con «lo dejé vacío a propósito».

  • Un YAML parcial vuelve al formulario tal como lo escribiste. POST /api/config/from-yaml devuelve la proyección de lo que el archivo traía y ya no su expansión completa, así que un config de veinte líneas deja de convertirse en uno de trescientas. El config_hash sigue calculándose sobre el config completo y no cambia.

  • Inyectar una ficha que el informe sí lee ya no se anuncia como «no la usa nadie». Al pasar una card por nikodym.run(..., artifacts=...) —o cualquiera de las que el informe adopta si existen—, el registro de la corrida la declaraba inerte aunque el documento la fuera a leer. Las tablas y figuras que el informe también adopta siguen declarándose inertes: el contrato cubre las fichas, y ampliarlo cambiaría ese veredicto para otros casos.

  • Un informe ya no exige la ficha de una sección que nadie va a correr. Si report declara una sección obligatoria cuyo dominio está apagado en esa corrida, la comprobación previa (nikodym.check_pipeline) la daba por inejecutable con un error del grafo de pasos, en vez de dejar que decidiera report.sections.missing_policy. Ahora el config es ejecutable y la política hace su trabajo: error detiene la corrida en el paso report diciendo qué falta, warn termina publicando la sección ausente y skip la omite dejando la limitación declarada en el informe. Es un cambio observable: un config que antes se rechazaba ahora corre —o falla más tarde y con mejor diagnóstico—. Un paso que sí corre y no publica la ficha que prometió sigue deteniendo la corrida antes del informe: la política no oculta un productor roto.

  • El comodín de binning ya no usa como predictor una columna que define el target. Con feature_columns="*", las columnas nombradas por data.target.bad_rule y good_rule quedan fuera de las candidatas; indeterminate_rule y exclusion_rules conservan sus columnas porque seleccionan la muestra, no la etiqueta. Una lista explícita sigue respetándose y la decisión queda en el audit-trail y en el model card de gobernanza; la card de binning publica las columnas que el comodín excluyó automáticamente. El cambio funciona igual con la sección data tipada u opaca. Si el AUC baja al actualizar, ésa es la corrección de una fuga previa, no una regresión. No cambian config_hash ni data_hash; sí pueden cambiar variables finales, coeficientes, métricas e informe.

  • Una llave de unicidad simple ya no entra al binning por el comodín. Si data.schema.unique_keys declara una sola columna, feature_columns="*" la excluye como ruido de identificador. Si declara una combinación de dos o más columnas, ninguna se elimina por separado: cada una puede conservar señal predictiva legítima. Las listas explícitas siguen respetándose. Esta corrección es distinta de la fuga del target y queda separada para que un cambio de AUC tenga una causa auditable.

[1.10.0] — 2026-07-29

El formulario de la interfaz deja de ser una vitrina: ya se puede llevar un dataset propio del archivo al informe sin escribir una línea de YAML. Hasta la 1.9.0 ese camino estaba cortado en tres sitios distintos, y el corte no se veía hasta que uno lo intentaba de verdad.

Añadido

  • Elegir las variables del binning ya es posible. Los tres multiselect de binningfeature_columns, exclude_columns, categorical_columns— pintaban «Sin opciones.» incluso con las variables ya dentro del config: no es que no se pudieran editar, es que no se podía ni ver qué variables entraban al modelo. Una lista de nombres de columna no puede traer sus opciones en el schema —dependen del archivo que cargues—, y era lo único que el formulario miraba. Ahora las opciones salen del dataset activo, y un valor que el archivo no trae se conserva marcado en vez de borrarse en silencio.

  • Las once listas de objetos del config se editan fila a fila. data.schema.columns era un <textarea> de cinco líneas con 1.552 caracteres de JSON crudo, etiquetado «Editor JSON (tipo no mapeado)». Ahora cada fila tiene sus campos, con botones para añadir, eliminar y reordenar. Efecto colateral medido: los avisos del preflight que enfocan el campo exacto al hacer click pasan de 0 a 18 de 18 — el salto ya probaba del id más específico al más general, sólo le faltaba que el campo existiera.

  • La interfaz ofrece la sección «Informe», con la portada del entregable (modelo, entidad, cartera, responsable del desarrollo, versión), el idioma, los formatos de salida y qué capítulos exige el documento. Antes esos cinco campos sólo se escribían por YAML o por código, así que el informe que salía del camino por interfaz llegaba con la primera página en blanco. El catálogo de secciones editables pasa de 13 a 14.

  • El config se comprueba contra sus propias invariantes, no sólo contra los nombres de columna. nikodym.check_dataset avisa ahora de cinco requisitos que un config puede incumplir aunque todas sus columnas existan; el caso que lo motivó: con partición aleatoria y stability.temporal_axis en su default "period", la corrida moría en el paso 8 de 10 con las dos comprobaciones previas en verde. Entran además data.partition.strategy.oot_from en blanco o no parseable como fecha, validation.families vacío, y stability.comparisons / performance.partitions con duplicados. Cada aviso nombra su campo y avisa sin bloquear: la corrida sigue siendo la autoridad sobre sí misma.

Cambiado

  • El error de validación de esquema se lee como una frase, no como un volcado de pandera. Decía «validación lazy=True … check: column_in_dataframe; valor ofensor: …; índice: <sin valor>» y lo lee un usuario, no un desarrollador de la librería. Ahora explica en español qué columna falta, qué tipo se esperaba o qué regla se incumplió. Es copy público: el código interno no viaja al lector.

  • report.sections.required_sections declara que sus valores no son columnas. Nombra secciones del informe, y sin esa declaración el formulario lo trataba como una lista de columnas y marcaba sus ocho valores de fábrica como ausentes del dataset. No amplía el alcance del preflight.

Corregido

  • ReportResult.html_path apuntaba a un archivo que no existe cuando report.output_dir es relativo —el caso por defecto de quien usa la librería por código, porque es lo que trae el preset F1—: devolvía reports/reports/scorecard_report.html en vez de reports/scorecard_report.html. Los otros tres formatos nunca tuvieron el defecto. Quien lea el HTML por esa ruta pasa de un FileNotFoundError a abrir el informe.

  • El aviso del preflight ya no llama «columnas» a lo que no lo es, y el mensaje del eje temporal nombra la opción con el literal que el selector muestra de verdad.

Aditivo: no cambia el config_hash de ningún config existente, ni el veredicto de /api/run, ni ninguna firma del pipeline F1. Sale como MINOR porque añade capacidades de interfaz y de comprobación, no porque mueva identidad.

[1.9.0] — 2026-07-28

Añadido

  • El config y tu dataset se comparan ANTES de correr. nikodym.check_dataset(config, columnas) y su espejo REST POST /api/preflight responden qué columnas declara el config que tu archivo no tiene, todas de una vez y sin ejecutar nada. Hasta ahora eso se descubría de a una: cada corrida fallida destapaba el siguiente desajuste. Medido sobre un CSV con nombres de columna propios y el preset F1, eran seis corridas para llegar a la primera ejecución.
  • Cada mensaje trae la ruta del campo en el config (data.partition.strategy.cohort_col), para que la interfaz pueda llevarte directo al campo que hay que corregir.
  • Caso especial de index_col: un archivo CSV no puede transportar un índice, así que cuando esa columna existe pero como columna corriente, el aviso lo dice y nombra las dos salidas.

Aditivo: no cambia comportamiento existente, ni el config_hash, ni el veredicto de /api/run. La comprobación informa, no bloquea — la corrida sigue siendo la autoridad sobre sí misma.

Alcance: el pipeline F1. provisioning*, survival, markov, forward y stress quedan fuera por ahora, y el gate de cobertura lo declara en vez de callarlo.

  • La interfaz lo usa: el aviso aparece mientras trabajas. En «Cargar datos», junto a las columnas de tu archivo, y en cada sección de «Configuración» con los desajustes que le tocan. Un click en un aviso te lleva al campo que hay que corregir. El botón Ejecutar cambia de aspecto y avisa, pero no se bloquea nunca: puedes correr igual.

Salvedad conocida: las listas de variables de binning (feature_columns, exclude_columns, categorical_columns) todavía no son editables desde el formulario —su control se dibuja a partir de las variables WoE, que no existen hasta que la corrida las produce—, así que un aviso sobre ellas te lleva a su sección pero no enfoca un campo. Para ajustarlas, usa «Descargar YAML» / «Cargar YAML» en la misma página. Es anterior a esta versión; el preflight sólo la hace visible. - El formulario alcanza la sección stability (PSI/CSI, umbrales, comparaciones y eje temporal): era parte del camino F1 y sólo se podía editar por YAML o por código.

  • pip install nikodym[ui] ya instala una interfaz que corre. El extra traía el servidor y la interfaz pero no el motor que ésta dispara: medido en venv limpio, los tres presets fallaban —F1 y F3 en el binning, F4 en survival—. Ahora [ui] compone también scoring y survival, así que los tres corren hasta el informe con esa sola instalación. Pesa ~700 MB en disco, y es deliberado: un extra llamado ui que no puede ejecutar nada promete algo que no cumple. El PDF sigue aparte (nikodym[pdf]), por licencia, y también el backend de lectura polars (nikodym[polars]), que sólo acelera la carga sin cambiar el resultado.

Nota de contrato — lee esto si ya usabas nikodym[ui]. El extra cambia de composición, no sólo de tamaño: de 310 a 703 MB y de 0 a 3 presets ejecutables. Dos consecuencias prácticas: (a) la instalación tarda más y ocupa el triple, así que revisa tus imágenes de contenedor y cachés de CI; (b) [ui] hereda ahora el techo scikit-learn<1.8 que scoring ya imponía. Si tenías nikodym[ui] conviviendo con scikit-learn 1.8 o 1.9 —que pip te dejaba instalar, porque el extra no lo acotaba—, al actualizar pip degradará scikit-learn o fallará la resolución. Es la contrapartida de que la interfaz traiga un motor que de verdad corre. No cambia API pública ni config_hash. - La interfaz gráfica ya está documentada (B2.5). El README y docs_site explican cómo instalarla y levantarla en dos comandos —pip install 'nikodym[ui]' y nikodym-ui—, sus opciones (--port, --workdir, --no-open), que escucha sólo en 127.0.0.1 sin forma de cambiarlo, y que edita el mismo NikodymConfig que usarías por código. Hasta ahora el comando no se mencionaba en ninguna parte: sólo se llegaba a él leyendo el pyproject.toml.

Corregido

  • Un invariante de config roto devolvía HTTP 500 en /api/validate, cuyo contrato es responder siempre 200, y la interfaz lo mostraba como «Backend no disponible» — una afirmación falsa sobre un backend sano. Ocurría con algo tan simple como activar un campo opcional sin escribirle valor. La causa: ConfigError no hereda de ValueError, así que Pydantic no lo envuelve y la excepción escapaba entera. Ahora es valid=false con su mensaje, y 422 en /api/preflight, /api/run y /api/config/to-yaml — nunca un 500. Alcanza a las seis secciones de config que validan invariantes propios, no sólo a la que lo destapó.

  • El preflight declaraba compatible un data.schema.index_col que el dataset no tiene. Era el tercer estado de ese campo —ni índice, ni columna corriente— y no tenía diagnóstico: el aviso respondía «todo bien», con la lista de desajustes y la de secciones no inspeccionadas vacías, y la corrida moría en su primer paso. En el caso que motivó la función —un CSV con nombres de columna propios contra el preset F1— los desajustes reportados pasan de 15 a 16: faltaba justo el identificador de la observación. nikodym.check_dataset acepta ahora index_columns= para distinguirlo; omitirlo conserva el comportamiento anterior, porque sin ese dato un índice correcto es indistinguible de uno inexistente.

  • POST /api/preflight no exigía el token de sesión. Respondía 200 —y materializaba el dataset en el directorio de trabajo— a cualquier proceso local, mientras /api/run devolvía 403 en las mismas condiciones: el endpoint nació fuera de la lista de guardas. Ahora pide token y same-origin como los demás. Sigue disponible con allow_live_execution=false: comprobar no es correr, y es el modo donde un aviso de config↔dataset más se agradece.

[1.8.0] — 2026-07-27

La identidad criptográfica del config dejó de depender de qué módulos hubiera importado el proceso.

Nota de contrato (SemVer): sale como minor, no como patch, porque recalcula el config_hash de los configs con secciones opacas y campos omitidos — y con él la clave de idempotencia del inventario MLflow. Un patch se lo llevaría quien tenga un pin ~=1.7.0 sin haberlo decidido. Mismo criterio que 1.4.0, que también recalculó identidad al excluir data.load.source. El algoritmo de canonicalización no cambia y sigue estable dentro de 1.x.

Corregido

  • ⚠️ El config_hash dependía de qué módulos hubiera importado el proceso. El mismo config producía dos identidades distintas según si la capa de ese dominio ya estaba importada: sin ella, la sección viaja como un blob opaco y se canonicaliza sin normalizar, así que los defaults que el YAML no traía no se materializaban. Con la capa importada, sí. Es la identidad criptográfica de la que cuelgan el lineage, el hash del model card, el del informe y el ancla de idempotencia de MLflow.

Se ve con dos líneas, en dos procesos distintos:

# proceso A (sin importar nikodym.binning)     → 8fb0c28b…
# proceso B (con import nikodym.binning)       → e8afdfca…
from nikodym.core.config import NikodymConfig, config_hash
config_hash(NikodymConfig.model_validate({"binning": {"max_n_prebins": 15}}))

Ahora config_hash coacciona las secciones que lleguen opacas antes de canonicalizar: la identidad es la del config que se ejecutaría, que es la misma semántica que el lineage ya adoptó en 1.7.0. El blob opaco del núcleo liviano no cambia — import nikodym.core.config sigue sin arrastrar dominios.

Cambio de comportamiento. Un config con secciones opacas y campos omitidos —típicamente un config.yaml escrito a mano y cargado sin importar sus capas— cambia de config_hash, y con él su clave de idempotencia en el inventario MLflow. Un Study guardado con save() no se ve afectado: escribe el config ya coaccionado y completo, así que su digest no se mueve (verificado con un round-trip saveload entre procesos). Precedente del mismo tipo: la exclusión de data.load.source en 1.4.0. El algoritmo de canonicalización no cambia y sigue estable bajo SemVer 1.x.

  • POST /api/validate podía aprobar un config inválido si era el primer request del proceso. Por la misma causa: un rango violado dentro de una sección de dominio —binning.min_bin_size: -1— devolvía valid: true con cero errores, y publicaba un config_hash para ese config. Por la interfaz no se alcanzaba (el formulario no valida hasta recibir el schema, y GET /api/schema importa los dominios), pero sí un cliente que llame a la API directamente. El significado de valid no cambia: ahora significa lo mismo siempre.

[1.7.0] — 2026-07-27

Corregido

  • ⚠️ Un config inejecutable no dejaba ningún rastro, y en la interfaz daba un HTTP 500. Cuando el pipeline no se puede resolver —por ejemplo, activar provisioning_ifrs9 sin survival, que le provee la term-structure— el motor produce un diagnóstico exacto:
El paso 'provisioning_ifrs9' necesita 'term_structure', que produce 'survival',
y ningún paso anterior lo genera: active 'survival' antes de 'provisioning_ifrs9'
o quite este paso.

Ese mensaje se perdía entero. La resolución del pipeline ocurría antes de que la corrida tuviera run_id y fuera del bloque que registra el fallo, así que nikodym.run() devolvía un Study con status="created" —ni "done" ni "failed"—, run_id=None y error=None: nada que inspeccionar. La interfaz, incapaz de persistir una corrida sin run_id, respondía un 500 opaco.

Ahora una corrida inejecutable queda status="failed" con su run_context.error, su run_id y su lineage, igual que una que falla dentro de un paso, y la interfaz muestra el mensaje del motor.

Cambio de comportamiento para quien inspeccione el Study de un config inejecutable: donde antes veía "created" ahora ve "failed". Es el valor que la documentación siempre dijo que vería. Study.run() sigue re-levantando: el primitivo fail-loud no cambia.

  • La curva de ECL del panel publicaba un plazo que no descuenta. El bloque provisioning_ifrs9.ecl_term_structure de la respuesta de resultados traía time_value crudo —en la unidad del productor de la term-structure— junto a un discount_factor_mean calculado sobre el plazo ya convertido a años. Con una curva mensual, la tabla mostraba un plazo y un factor que no se corresponden: DF ** (-1/time_value) - 1 daba 1,5 % donde la EIR era 20 %, y el lector no podía verificar DF = (1 + EIR)^(-τ) con los números que tenía delante.

Ahora la curva publica las dos columnas, time_value y time_value_years, como ya hacía el artefacto del motor desde 1.6.0. Es un campo nuevo, aditivo: nada de lo que había cambia de significado ni de valor.

  • Una sección de configuración no se podía apagar desde el formulario. El schema compuesto que sirve GET /api/schema perdía la nulabilidad de cada sección de dominio: las secciones son campos Any con default=None, y al empotrar el sub-schema se sustituía el nodo entero, llevándose el "default": null que era su único portador. El compuesto acababa declarando type: "object" + required, o sea lo contrario de la verdad — el mismo payload trae todas las secciones en null en sus defaults.

Ahora cada sección expandida viaja como anyOf: [<objeto>, {"type": "null"}] con default: null, la misma forma que Pydantic emite para un X | None. Afectaba a todas las secciones, no sólo a las de provisiones.

  • Lanzar la interfaz dentro de un clon del repositorio dejaba datos listos para commitear. python -m nikodym.ui crea su directorio de trabajo en el directorio actual, así que arrancarla desde la raíz de un checkout dejaba sin vetar el parquet del dataset materializado y el results.json de cada corrida. Sólo afecta a quien trabaja sobre el repositorio —no al paquete instalado—, pero en un repositorio público es una fuga a un git add de distancia.

Añadido

  • El formulario de la interfaz pasa de 7 secciones a 12: entran survival y las cuatro de provisiones. Hasta ahora, quien instalaba la interfaz sólo podía configurar por formulario el pipeline de scorecard; para calcular provisiones CMF o IFRS 9 había que escribir el config en Python, aunque el motor ya las soportara. Ahora se editan desde la interfaz survival, provisioning_cmf, provisioning_internal, provisioning_ifrs9 y provisioning, con sus 178 campos, cada uno con el mismo tipo, rango y ayuda que declara el config.

El backend ya las enviaba completas: era el front el que las descartaba. En el camino, el formulario deja de pintar la fontanería del config —schema_version, los discriminadores type y otros 38 campos que el usuario no debe tocar— y aprende los 20 tipos de control que el motor declara, de los que antes reconocía cuatro.

  • La interfaz avisa que un config no se puede ejecutar mientras se edita, no al ejecutar. Activar provisioning_ifrs9 sin survival produce un config que reconstruye perfectamente y que el motor no puede correr. Antes eso se descubría apretando Ejecutar; ahora POST /api/validate responde además un bloque pipeline con executable, los steps que correrían y, si no es ejecutable, el diagnóstico del motor, que el formulario muestra en un aviso.

El aviso no bloquea la corrida: el motor sigue siendo la autoridad y registra el intento fallido con su diagnóstico, su run_id y su lineage.

  • nikodym.check_pipeline(config): responde si un config es ejecutable sin ejecutarlo. La misma respuesta que obtiene la interfaz, disponible por código —devuelve executable, los pasos en el orden en que correrían y el diagnóstico si no es ejecutable—, para que trabajar por código y por interfaz no den información distinta. No lee el dataset, no monta sumideros de auditoría ni inventario, y no deja rastro de corrida: comprobar no es correr. El primitivo del núcleo equivalente es Study.check_pipeline(), que re-levanta en vez de capturar.

  • Gate de staleness del fixture del schema del front. web/src/fixtures/schema.json lo genera scripts/gen_schema_fixture.py, pero nada comprobaba que se hubiera corrido —y ya se había desincronizado en silencio una vez, publicando en la demo un encuadre normativo que el código había corregido—. tests/unit/test_ui_schema_fixture.py lo compara ahora con el payload vivo en cada corrida de la suite, tolerando los dominios cuyo extra no esté instalado.

[1.6.0] — 2026-07-26

Corregido

  • ⚠️ La ECL de IFRS 9 se calculaba mal cuando la curva de PD no venía en años. El descuento DF(t) = (1 + EIR)^(-τ) usaba time_value como exponente asumiendo años, sin verificarlo. La misma cartera, en los mismos instantes, declarada en meses en vez de años perdía del orden de un 40-50 % de provisión, en silencio: nada en el motor lo impedía ni lo declaraba.

Desde esta versión la term-structure transporta su unidad temporal en una columna time_unit —la declaran survival (time_grid.time_unit) y markov (dynamics.time_unit), y forward la propaga— e IFRS 9 convierte a años antes de descontar. La evidencia publica las dos columnas, time_value cruda y time_value_years convertida, para que la conversión sea un paso aritmético comprobable y siga reconciliando fila a fila con la curva de origen.

Si su curva ya estaba en años, sus cifras no cambian.

Cambiado

  • ⚠️ Una curva que no declara su unidad temporal ahora detiene la corrida de IFRS 9. Cuando la term-structure no trae time_unit, o trae un literal no convertible, el motor presume años y emite DATO-INSTITUCIONAL-IFRS-7. Es un aviso gobernable, y fail_on_falta_dato viene en True, así que la corrida se detiene en vez de entregar una cifra que podría estar mal por un factor de 12 o de 365.

Esto afecta a quien nunca tocó el campo, porque el default de fábrica de survival y markov es "period", que nombra un índice y no una duración. Las dos salidas, ambas explícitas:

# (a) declarar la unidad — recomendado, y es de una línea
cfg.survival.time_grid.time_unit = "month"   # o "year", "quarter", "day", …
# (b) aceptar la presunción de años, dejando el aviso en el resultado
cfg.provisioning_ifrs9.fail_on_falta_dato = False

La tabla de unidades reconocidas vive en nikodym.core.time_units y acepta español e inglés, singular y plural, con o sin tildes.

  • ⚠️ IFRS 9 verifica que el horizonte de 12 meses dure de verdad un año, y detiene si no (FALTA-DATO-IFRS-8). horizon_12m_periods declara cuántos períodos de su curva cubren doce meses, y hasta ahora nadie lo contrastaba con la curva recibida. Con la unidad ya declarada, el motor mira el período del horizonte y comprueba que caiga cerca de un año.

Esto afecta al default de fábrica. horizon_12m_periods viene en 12, que es correcto para una curva mensual y está mal para una anual o trimestral: sobre una curva anual, el «ECL a 12 meses» de Stage 1 cubría doce años y quedaba unas 7,5 veces sobreestimado, en silencio. La salida es declarar el horizonte que corresponde a su periodicidad —12 mensual, 4 trimestral, 1 anual— o apagar fail_on_falta_dato para que quede sólo anotado.

El aviso cubre también el extremo opuesto: un horizonte por debajo del primer período de la curva, donde Stage 1 provisionaba cero sin error.

  • ⚠️ survival cumple fail_on_falta_dato, que hasta ahora era un campo sin efecto. El propio config lo admitía por escrito («campo reservado: hoy no altera la corrida»). Desde esta versión gobierna sus tres avisos declarados —DATO-INSTITUCIONAL-SUR-1/2/3— con la misma semántica que las otras seis capas: con el flag activo, un aviso detiene la corrida; desactivado, queda registrado en la card y la corrida sigue.

Es un cambio de comportamiento observable y el default es True. Si su config no declara grilla temporal (time_grid.horizon_periods ni time_grid.evaluation_times), survival venía cayendo a los tiempos observados y emitiendo DATO-INSTITUCIONAL-SUR-1 como aviso; ahora esa misma corrida se detiene. Las dos salidas, ambas explícitas:

# (a) declarar la grilla — recomendado: es la definición que el aviso venía pidiendo
cfg.survival.time_grid.horizon_periods = 5
# (b) conservar el comportamiento anterior, dejando el aviso en el resultado
cfg.survival.fail_on_falta_dato = False

Con esto, fail_on_falta_dato significa una sola cosa en las siete capas del paquete (CRP-6 del contrato de resolución de parámetros, cerrado). Los avisos que un motor emite en toda corrida por una capacidad diferida propia siguen sin detener nunca: se registran igual, pero abortar por ellos dejaría el motor inservible con su propio valor por defecto.

  • El preset f4-ifrs9-retail declara los intervalos de confianza de Kaplan-Meier (confidence_level=0.95, confidence_transform="loglog"). El preset corre discrete_hazard, así que sus cifras no cambian; lo que cambia es que ahora sigue corriendo si usted cambia el método a kaplan_meier desde el formulario. Su config_hash cambió, y los fixtures de la demo pública se recapturaron contra él.

Añadido

  • Una corrida que falla ahora dice por qué, por el camino que la documentación recomienda. run_context gana el campo error (RunError: type, message, step, is_domain_error, ts). Hasta ahora el diagnóstico del motor —que es bueno: nombra la columna, el parámetro o el paso concreto— se emitía sólo al audit-trail, y como el preset F1 trae audit: null, quien seguía el getting-started al pie de la letra se quedaba con status == "failed", results vacío y 413 bytes de hashes en el lineage. El campo se puebla sin configurar nada:
study = nikodym.run(config)
if study.run_context.status == "failed":
    print(study.run_context.error.step)     # "data"
    print(study.run_context.error.message)  # el mensaje del motor, íntegro

Extensión aditiva: el campo lleva default None, así que un run_metadata.json guardado antes sigue recargando, y el evento run_end conserva su clave error y sólo suma error_type y step. La documentación decía que el fallo vivía «en el audit-trail y en el lineage» en cinco superficies (README, docs_site/index, tutorial, getting-started y el docstring de run); de los dos lugares, el lineage no lo guardaba nunca y el audit-trail sólo con sink configurado. Corregidas las cinco.

Corregido

  • IFRS 9 dejó de calcular mal en silencio en dos puntos, y de validar tarde en un tercero (primer paso del contrato de resolución de parámetros, CRP-5). Los tres se midieron corriendo el motor, no leyéndolo:
  • La LGD del enfoque workout se subestimaba cuando faltaba recovery_cost. El motor asumía coste cero: con EAD 100 y recuperación 50 devolvía 0.50 en vez de 0.70, 20 puntos porcentuales menos, sin emitir ningún aviso. Era además asimétrico con su insumo hermano recovery_time_years, que siempre levantó. Ahora la columna se exige; si la institución no incurre en costos de recuperación, declara ceros explícitos.
  • Una operación en incumplimiento genuino salía Stage 1. Si la columna is_default —declarada por defecto— no venía en el frame, el gatillo de Stage 3 devolvía «no» para toda la cartera sin decir nada. El motor trataba igual una elección del usuario y una carencia del dato. Ahora la ausencia levanta, y para no evaluar ese gatillo se declara staging.is_default_col=None.
  • Los pesos de escenario inválidos ya habían ponderado la PD antes de ser rechazados. La validación vivía sólo en el cálculo de la ECL, es decir después de calcular con el número malo. El veredicto era correcto y el momento no: ahora se validan al resolverlos, antes de ponderar.

Cambio de comportamiento: una corrida que hoy pasa puede empezar a fallar si el frame no trae recovery_cost (sólo con lgd.method="workout") o is_default. En ambos casos el fallo sustituye a un resultado que era incorrecto o incompleto. provisioning/ifrs9 es experimental y queda fuera de la garantía SemVer 1.x.

  • El panel de resultados de la UI muestra el fallo real, no un texto genérico. El backend no tenía otro que dar —el mensaje moría en el sink— y el front ya sabía mostrarlo. Ahora publica el mensaje del motor con el paso que falló («El paso 'scorecard' falló: …»), sin el código de aviso declarado: once raise del motor lo traen dentro del texto y el panel es copy público, así que se recorta con strip_declared_codes(). El mensaje íntegro, con el código, sigue disponible por código en run_context.error.message. Un fallo que no es error de dominio conserva el mensaje genérico más el tipo de la excepción: su texto es detalle interno y puede traer rutas del servidor.

  • Una corrida fallida sellaba finished_at en None. Terminaba, y el contexto no decía cuándo.

Cambiado

  • La marca FALTA-DATO se separa en dos, porque cubría dos cosas opuestas. Un aviso declarado puede tener dos causas distintas: que a Nikodym le falte algo, o que le falte a la institución un parámetro que sólo ella puede fijar. Hasta 1.5.0 ambas compartían la marca FALTA-DATO, de modo que el argumento de venta del producto —el motor se niega a inventar un supuesto que no le corresponde— aparecía rotulado como defecto propio 34 veces. Ahora:
  • FALTA-DATO queda sólo para las brechas del motor: IFRS-4 (EAD constante, sin panel longitudinal), IFRS-6/FWD-6 (LGD forward descartada), FWD-8 (el motor no selecciona el rango de cointegración del VECM), STR-5 (motor ECL no conectado a stress), ML-1 (data_raw diferido), VAL-1/VAL-2/VAL-3 (convención del t-test ECB, cortes del semáforo y p-valor de Jeffreys, pendientes de verificar contra el documento oficial) y los dos pending_items del manifiesto CMF. Se le suma el aviso de validation cuando el detail de IFRS 9 llega sin las columnas estimadas que ese mismo motor produce.
  • DATO-INSTITUCIONAL es la marca nueva de los 34 códigos que declaran un input de la institución: shocks macro, taxonomía de estados, definición operacional de default, umbrales de gobierno, rho, EIR, cobertura del comparativo de provisiones.

Familia y número se conservan (FALTA-DATO-FWD-1DATO-INSTITUCIONAL-FWD-1), así que la trazabilidad contra los SDD y este changelog se mantiene. Tres normalizaciones de códigos que habían nacido sin número: STR-LGDSTR-8, el PROV sin número → PROV-2 y el FWD sin número → FWD-8. Todos los códigos que cambian pertenecen a capas experimentales; el pipeline F1 estable no emite ninguno. Los campos falta_dato y fail_on_falta_dato del config no cambian: siguen nombrando el conjunto de avisos declarados, para no romper el config de quien instaló 1.5.0.

  • Los códigos que no significaban nada para un usuario salen del contrato. Siete eran TODO de ingeniería —pins de fastapi/uvicorn, librería de charts del front, presupuesto de CI del tuning, evaluador de importancia de Optuna, determinismo cross-versión de los backends y de shap— y viven como issues del repo. Uno de ellos, UI-3, estaba marcado ✅ RESUELTO y se seguía contando como brecha abierta. Otros cuatro (SUR-6, MKV-3, MKV-4, MKV-6) sólo esperaban que otro SDD fijara algo que ya está implementado y se cierran con su evidencia.

Eliminado

  • Extra sweep (hydra-core + omegaconf), que nunca tuvo consumidor. Se declaraba como «barridos CLI» y entraba en el meta-extra all, de modo que todo pip install "nikodym[all]" bajaba hydra-core, omegaconf y antlr4-python3-runtime sin que una sola línea del paquete las importara: no existe src/nikodym/sweep/ ni un consumidor de Hydra/OmegaConf en src/. En un paquete que se audita por licencias y por contenido de distribución, un extra sin código que lo use es superficie muerta que hay que auditar igual. Se retira de pyproject.toml, del all, del mapa EXTRA_TO_DISTRIBUTIONS y de la tabla de extras de la documentación; el cierre runtime de all baja de 155 a 152 distribuciones. pip install "nikodym[sweep]" deja de existir; nada más cambia, porque no había funcionalidad detrás. Si algún día se implementan barridos por CLI (diseño en SDD-05 §5.6, que sigue vigente como diseño), el extra vuelve en el mismo cambio que su consumidor, no antes.

[1.5.0] — 2026-07-22

Bloque B1 del plan operativo, completo: higiene de lo que el informe y el editor de configuración le muestran a quien instala la librería. Ninguna cifra de negocio cambia; lo que cambia es que el artefacto deje de mostrar precisión que el dato no tiene, jerga del repositorio y afirmaciones que el motor no respalda.

Añadido

  • Rótulo de las dos cifras de ECL del anexo IFRS 9. total_ecl_reported y ecl_by_scenario difieren ~2× por construcción y salían pegadas sin explicación, lo que se lee como descuadre contable. Se añade la clave hermana ecl_by_scenario_basis —extensión aditiva, la clave original no se toca— que declara las dos razones de la brecha: la cifra por escenario no aplica scenario_weights y cubre el horizonte completo de la curva, mientras la reportada sí pondera y trunca Stage 1 a 12 meses. No es, ni era, un error de cálculo.

Cambiado

  • total_expected_loss_rate (método interno) pasa de texto a número. Se publicaba como str(Decimal) —única forma de cruzar el gate JSON de metric_sections— y el anexo mostraba sus 51 dígitos: "0.038203224448922777376082337534204431258983735881240". Ahora es un float, que cruza el mismo gate, llega como número a quien lo consuma y el informe formatea a 0,0382. ⚠️ Cambio de tipo en una superficie experimental (provisiones, fuera de la garantía SemVer 1.x): un consumidor que tratara ese campo como cadena debe leerlo como número. Las cifras contables (total_exposure, total_internal_provision) siguen exactas en Decimal.
  • Los diagnósticos de selección y de modelo se publican con 6 cifras significativas, no con 12. El informe mostraba iv=0.650693017601 y, en el caso del VIF, la mantisa completa del float; ahora iv=0.650693. Alcanza a los campos detail de IV, VIF, correlación, contribución de IV y p-valores Wald/LR. Se usa .6g y no .6f a propósito: una cifra diminuta conserva su magnitud (1.23457e-06) en vez de aplanarse a 0.000000. Los mensajes de excepción conservan las 12 cifras, que ahí sí sirven para depurar.
  • El editor de configuración deja de hablarle al usuario en jerga del repositorio. El texto de ayuda de cada campo venía citando documentos internos de diseño que quien instala la librería no tiene: el índice de secciones decía cosas como Sección de binning supervisado WoE/IV (capa binning, SDD-06) y 22 campos mostraban literalmente == @register('standard', domain='...'). Se reescribieron 185 textosdescription, title y encabezados de grupo— en castellano funcional, más los descriptores de preset y dataset que la demo publica. Se conservan, porque no son jerga: las referencias normativas (Circular N° 2.346, Cap. B-1, NIIF 9 B5.5.37), la terminología de riesgo (WoE, IV, PD, LGD, ECL, Stage 1/2/3, PSI, SICR) y los códigos FALTA-DATO-* que el motor imprime en el informe y el usuario necesita rastrear.

Corregido

  • Cinco textos del config afirmaban cosas que el motor no hace. Salieron de verificar cada enunciado contra el código, y varias venían de antes de esta limpieza:
  • Los dos gatillos SICR de Stage 2 se describían con un mínimo (>=2x, >=3x) que el validador no impone: ambos campos son gt=1.0, así que el motor acepta 1,05. Ahora el texto dice que 2,0 y 3,0 son el valor de referencia y que moverlos cambia la población en Stage 2.
  • El indicador de incumplimiento declarado (is_default_col, CMF) decía mover solo el factor de conversión B-3 de contingentes; también clasifica al deudor de consumo en incumplimiento (PI 100 %) con mora menor a 90 días, lo que cambia la provisión de sus operaciones directas.
  • La orquestación de provisiones decía aplicar "la regla del máximo" cuando la regla es configurable (max o use_internal).
  • El umbral de nulos por columna prometía que la selección de variables eliminaría esas columnas: ninguna etapa las elimina; solo se registran como decisión en el audit-trail.
  • Las métricas del challenger decían tomarse de la etapa de desempeño "sin recalcularlas"; el paso instancia su propio evaluador y las recalcula con el mismo motor.
  • Tres campos que no hacen nada dejan de anunciarse como operativos. repro.strict_determinism, tuning.validation.fit_partition y survival.fail_on_falta_dato no los lee ningún componente del motor, y report.pdf.enabled solo aplica al uso directo del renderizador —en una corrida el PDF se activa incluyendo 'pdf' en formats—. Los cuatro textos ahora lo declaran. Cablearlos o retirarlos es trabajo aparte.

[1.4.1] — 2026-07-21

Corrección de tres defectos que la verificación adversarial previa a la reunión con Interbank encontró en lo que la demo y el informe muestran, más la documentación que un comprador institucional busca primero y no encontraba.

Añadido

  • SECURITY.md: cómo reportar una vulnerabilidad, qué esperar y en qué plazos, versiones con soporte, y el alcance de lo que consideramos un problema de seguridad. Incluye la nota de datos: el paquete no lleva telemetría y el pipeline de cálculo no abre conexiones por sí solo; las dos salidas posibles (narración por IA y registro MLflow) son opcionales y las controla quien lo usa.
  • SUPPORT.md: qué canal atiende qué, y —explícito— lo que el proyecto no promete: no hay SLA en el canal abierto, no somos el validador de tu institución, las matrices normativas exigen validación humana, y qué superficie está bajo garantía SemVer 1.x frente a la experimental.
  • README: sección «Tus datos no salen de tu infraestructura». El argumento que más pesa para una institución financiera —que esto es una librería que corre en su red, sin telemetría, sin llamadas de red en el cálculo y sin depender de un servicio nuestro para funcionar— no estaba escrito en ninguna parte.

Cambiado

  • Clasificador de madurez en PyPI: 3 - Alpha4 - Beta. pypi.org anunciaba «Alpha» mientras el README declara el pipeline de scorecard como API estable bajo SemVer 1.x desde 1.0: la primera señal de madurez que recibía un evaluador contradecía al propio proyecto. No se sube a 5 - Production/Stable porque las provisiones siguen marcadas experimentales por madurez.

Corregido

  • Preset F1: la PD ancla se estima de los datos en vez de fijarse a mano. El preset f1-estandar-consumo calibraba con anchor_source="business_input" y target_pd=0.20 sobre una cartera cuya tasa observada en Desarrollo es 23,3 %. La calibración desplazaba el intercepto hacia un nivel que los datos no respaldan, y Hosmer-Lemeshow rechazaba en las tres particiones (desarrollo χ²=37,2 p=0,00001 · holdout χ²=20,0 p=0,010 · OOT χ²=32,2 p=0,00008): el informe insignia del pipeline estable abría su resumen ejecutivo con «3 de 3 tests fallidos · Falla técnica». Con anchor_source="development_observed" y target_pd nulo —la misma configuración que el preset F3 ya usaba, y el default del motor— el sesgo de nivel desaparece (desarrollo χ²=6,0 p=0,65 · holdout χ²=10,1 p=0,26) y sólo queda el rechazo de OOT (χ²=19,5 p=0,013), que es deriva temporal de la cartera y no un defecto de configuración. Cambia el config_hash del preset F1 y las PD calibradas; no cambia la discriminación (AUC/Gini/KS son invariantes al nivel de calibración) ni el config efectivo del F3, que ya aplicaba el override.
  • Informe: las cifras de plata dejan de mostrarse con seis decimales. Las celdas de exposición, provisión y pérdida esperada volcaban 697376973.922913 —doce dígitos de precisión falsa— justo debajo de la prosa que muestra el mismo monto como $697.376.974; eran 63 celdas en el informe de provisiones. Los floats de magnitud ≥ 1.000 se emiten ahora con dos decimales. El corte es por magnitud y no por nombre de columna, de modo que alcanza a cualquier monto futuro; los indicadores de riesgo (AUC, KS, PSI, IV, PD, LGD, tasas) viven muy por debajo y conservan su precisión. Sólo presentación: no cambia valores ni data_hash, y se mantiene el punto decimal sin agrupar miles, porque estas tablas son volcado técnico pensado para copiarse a una herramienta de análisis.
  • Demo: el editor de configuración ya no afirma tener un backend en vivo. La demo pública marca la fuente del schema como «backend» para que el editor no se vea degradado, y el aviso decía «Schema en vivo desde el backend (/api/schema)» en una página estática donde no hay ninguna llamada de red. Ahora declara que el schema fue capturado de una corrida real y que la demo no ejecuta cálculo en el navegador.
  • Demo: separador de miles consistente. Los tooltips del histograma de score y del gráfico de lift formateaban las frecuencias en en-US (n = 3,961) mientras el catálogo de datasets y la pestaña de datos usan es-CL (3.961 filas), de modo que la misma pantalla mostraba las dos convenciones.

[1.4.0] — 2026-07-20

Informe con formato editorial, capítulo de validación formal y contexto poblacional; cierre de seis brechas del contrato forward→IFRS 9; y pulido del informe y la demo previo a la reunión Interbank.

Añadido

  • Informe: capítulo condicional «Validación formal». Cuando la corrida publica el ValidationResult atómico (validation.result), el informe emite un capítulo nuevo —tras «Resultados», antes de provisiones— con una subsección por familia declarada en families_run (discriminación, calibración, estabilidad, backtesting). Las tablas se copian del DTO: report no recalcula métricas ni decisiones. El resumen ejecutivo suma la métrica «Estado técnico de validación formal», y el veredicto sigue siendo un bloque humano POR COMPLETAR: el estado del motor no lo sustituye. Si la corrida trae además una card suelta validation.card que no coincide con result.card, el builder falla en vez de mezclar lecturas de momentos distintos. La prosa de alcance deja de prometer futuro: sin validación, el informe dice que esta corrida no ejecutó la capa formal. El contrato es aditivo (validation no entra en ReportStep.requires ni en required_sections): ninguna cadena existente se rompe.
  • Informe: subsección «Población, particiones y exclusiones» en el capítulo de Contexto. Si el dominio data publicó su card, el informe proyecta tres tablas —estados, particiones (tamaño y tasa de incumplimiento) y exclusiones por motivo— copiadas literalmente del DataCardSection: no infiere conteos ni recalcula estadísticas. Es opcional: su ausencia no es error.
  • Anexo C: cada dominio configurado publica su effective_config. Antes sólo lo anexaban survival y provisioning_ifrs9. Ahora lo hace todo dominio presente en el config de la corrida —data, el pipeline scorecard, survival, markov, forward, provisioning_ifrs9, validation y las tres secciones de provisiones F3 (provisioning_cmf, provisioning_internal y el orquestador provisioning con su regla del máximo)—, incluso si no emitió card por ser config puro. No se agrega un dump top-level de NikodymConfig, y effective_config queda excluido del payload que se envía a la narrativa IA opcional.

Corregido

  • Identidad del config (config_hash): la ruta del dataset ya no altera la identidad. El campo data.load.source (ubicación del archivo en disco) entraba al config_hash, de modo que el mismo dataset en otra ruta —o el preset con source: null frente a la corrida con la ruta real— producía un hash distinto, desalineando el config_hash que muestra la app y el que aparece en el informe. El data_hash ya captura el contenido del dataset; la ruta es incidental. Ahora data.load.source se excluye del config_hash (además de las secciones de infraestructura). Nota de contrato (SemVer): esto recalcula el config_hash de todo config que fijaba una ruta de dataset; el hash del config por defecto (sin data) no cambia. Es una corrección de defecto, no una nueva convención.
  • POST /api/config/to-yaml era no-determinista frente al estado de imports. La sección report (report: Any en el schema) se coacciona a ReportConfig sólo si nikodym.report ya fue importado, y esa coacción materializa report.document (default_factory) que el config del cliente no traía. El YAML salía con o sin ese bloque según qué se hubiera importado antes (así se colaba report.document al capturar los fixtures de la demo tras generar un informe). Ahora el endpoint vuelca con exclude_unset=True: idéntico byte a byte en ambos casos, sin tocar el config_hash ni el lineage de corrida de study.
  • Informe: coma decimal (es-CL) en la prosa y en las cifras destacadas. Los porcentajes y números (_pct/_num) emitían punto decimal (2.99 %, IV 0.03) mientras las cifras en pesos ya usaban el punto como separador de miles. Ahora el decimal es coma (2,99 %), coherente con la convención chilena. Sólo presentación: no cambia valores ni data_hash. Las tablas de detalle y los ejes de los gráficos conservan el punto a propósito: son volcado técnico de results, pensado para copiarse a una herramienta de análisis.
  • Informe: marcador único «—» para celdas sin valor en las tablas de detalle. NaN se volcaba como nan y el sentinel de dominio "none" (de los enums iv_band/expected_sign/action = «sin banda/ signo/acción») como none; ambos parecían celdas rotas. Se unifican a un em-dash (convención de estados financieros para nil/ninguno/no-aplica), sin tocar los enums de la API de results ni el data_hash. Los inf/-inf se conservan crudos a propósito (anomalía real que debe verse).
  • Informe: las celdas de tabla ya no vuelcan Decimal crudo ni colecciones vacías. Los motores de provisiones publican sus cifras en Decimal para no perder exactitud contable, pero el renderer no tenía rama para ese tipo y caía en str(value): la tabla «Provisión interna por grupo homogéneo» mostraba pd_group = 0.0052290061597687685198855454245661540747073248842022 (52 dígitos), y lo mismo lgd_group y expected_loss_rate. Ahora se formatean con la misma regla que un float (el anexo JSON ya lo hacía así). En la misma línea, una colección vacía (warning_codes: []) muestra el em-dash de «ninguno» en vez de []/{} crudos. Sólo presentación: las cifras no cambian.
  • PDF: las tablas de 10+ columnas eran ilegibles y ahora van en hoja apaisada. En A4 vertical («Desempeño por decil de score»: 20 columnas sobre 170 mm útiles) cada columna recibía ~8 mm: los encabezados se solapaban entre sí y cada cifra se partía en tres líneas (505. / 4441 / 62), de modo que la evidencia de monotonía y lift del scorecard llegaba al comité como un amasijo. Esas tablas se marcan table-block--wide y en @media print van a @page wide-table (A4 apaisada) con ancho automático, tipografía menor y celdas sin partir. No se descarta ninguna columna —perder una sería perder trazabilidad— y en pantalla no cambia nada.
  • Informe IFRS 9: la tabla insignia se titulaba con la clave interna. La única tabla del cuerpo —la que sostiene ECL, EAD y cobertura— salía como Tabla «provisioning_ifrs9.summary» (en mayúsculas por CSS) porque faltaba del catálogo de títulos, donde su hermana CMF sí estaba. Pasa a «Pérdida crediticia esperada (ECL) por etapa».
  • IFRS 9 (experimental): seis brechas del contrato forward→IFRS 9, cerradas con guards fail-fast. Cuatro configuraciones que el motor aceptaba y luego degradaba en silencio ahora fallan con un mensaje que dice qué usar en su lugar: (1) pd.rho_col se rechaza al construir IfrsPdConfig —el motor v1 sólo consume pd.rho escalar, y honrar la columna con el escalar sería una etiqueta falsa—; (2) pit_mode='apply_vasicek' exige siempre systemic_factor_col: se elimina la exención de scenarios.source='forward', que suponía un factor sistémico Z que forward no publica (sus curvas ya son PIT ⇒ pit_mode='consume_pit'); (3) aplicar Vasicek sobre una term-structure ya etiquetada pd_basis='pit' queda bloqueado, evitando el doble ajuste macro (espejo del guard que consume_pit ya tenía); (4) forbid_mean_scenario=True pasa de auditado a bloqueante, en el config y en el motor, sobre las tres fuentes de escenarios y sin distinguir mayúsculas (mean/average/weighted_mean_input) —se ponderan outputs por escenario, nunca inputs macro promediados—; el escape hatch flag=False sigue disponible y queda auditado. Queda además caracterizada con tests y golden (sin tocar el motor) la frontera de pesos cero: forward admite peso 0 y IFRS 9 exige peso > 0; su resolución de fondo es una decisión de política pendiente. Nota de contrato: ninguna ruta válida cambia de resultado numérico (config_hash y demo F4 invariantes), pero un config que antes corría degradado ahora aborta. La capa IFRS 9 es experimental, fuera de la garantía SemVer 1.x.
  • IFRS 9: la LGD de la capa forward ya no se descarta en silencio. Si la term-structure trae una columna lgd con algún valor no nulo, el motor —que en v1 estima la LGD desde el frame según IfrsLgdConfig— declara el descarte con el código FALTA-DATO-IFRS-6: aparece en los warning_codes de cada fila, en card.falta_dato, en la traza de auditoría ifrs9_lgd y como frase explícita en el informe. Sólo declaración: las cifras no cambian.
  • IFRS 9: descriptions honestas de rho_col y fail_on_falta_dato. Ambas prometían conducta que el motor no implementa: rho_col decía «sobrescribe rho por fila» pero el motor la rechaza fail-fast (guard introducido en este mismo release, ver arriba; correlación heterogénea diferida en v1); fail_on_falta_dato sugería un modo «marcar FALTA-DATO y continuar» ante Vasicek sin rho/Z que no existe (el motor falla en cálculo siempre). Se reescriben las descriptions (y títulos) para reflejar la conducta real.
  • Captura y config de la UI: la ruta del dataset deja de ser específica del host. Con workdir relativo (el default), la ruta que run_pipeline cablea a data.load.source se conserva relativa y en separadores POSIX, de modo que el mismo config sale idéntico en distintos checkouts y en Windows/macOS/Linux; una ruta con ancla (raíz, unidad o UNC) conserva su semántica nativa porque es una elección explícita del usuario. Además, la validación de contención del directorio de datasets ahora resuelve enlaces simbólicos también en el directorio, no sólo en el archivo: un workdir/datasets symlinkeado fuera del workdir pasaba el control anterior.

Cambiado

  • Informe HTML con formato editorial (tema nikodym, el de fábrica). El HTML pasa a un layout de tres columnas en pantalla —sidebar de secciones con la marca Nikodym, contenido e índice lateral «En esta página» con los entregables— sobre las clases ya existentes (portada, lineage, firmas SR 11-7, veredicto/callouts, chips de estado, tablas). Los rieles son de pantalla: en @media print el documento colapsa a una columna A4, así que el PDF (WeasyPrint) queda intacto. El tema plain no cambia. El markup del documento —data-section-id, orden canónico de secciones, id/thead/tbody de las tablas y los literales config_hash=/data_hash=/git_sha=/ root_seed=— se conserva: quien parsea el HTML no se ve afectado.
  • Preset F1 de la UI: la corrida estándar ejecuta validación formal. standard_preset() deja de traer validation: null y activa discriminación, calibración (Hosmer-Lemeshow + Brier sobre la PD calibrada) y estabilidad, reusando lo que ya calculan performance/stability. El contraste por grado y el backtesting quedan apagados (el dataset no trae grade ni realizados). Los presets F3 (CMF) y F4 (IFRS 9) declaran validation: null explícitamente: conservan su alcance previo.
  • Demo: badge «Experimental» en la card de provisiones CMF (F3), igual que la de IFRS 9 (F4), pues ambos motores de provisiones son experimentales por madurez.
  • Bump de versión 1.3.0 → 1.4.0.

[1.3.0] — 2026-07-17

Añadido

  • Extra opcional markov (pip install nikodym[markov], que provee scipy) para el módulo de cadenas de Markov (nikodym.markov). Los mensajes de dependencia ausente en markov/*.py ya apuntaban a nikodym[markov]; ahora ese extra existe de verdad.
  • Smoke clean-room del wheel para el preset f4-ifrs9-retail. scripts/smoke_instalacion_pip.py queda parametrizado por preset (argumento CLI o variable de entorno), conservando el modo scorecard F1 como comportamiento por defecto (el que usa el CI). Sobre una instalación real por pip, verifica que la corrida IFRS 9 termina en done y produce provisioning_ifrs9 con staging (Stage 1/2/3) y ECL/cobertura.

Cambiado

  • Bump de versión 1.2.0 → 1.3.0.
  • Documentación (docs_site/index.md, docs_site/api.md) y estado del repo (AGENTS.md, CLAUDE.md) reflejan 1.3.0.
  • Recaptura de los informes demo (F1 · F3 · F4): el lineage library_versions.nikodym de los fixtures reporta 1.3.0. Sin cambio de cifras insignia ni del config_hash de los presets.

[1.2.0] — 2026-07-15

🔴 Corregido — una regla normativa que afirmábamos y que NO EXISTE

Nikodym declaraba, en el código y en toda su documentación, que la provisión reportada es el máximo entre el ECL de IFRS 9 y un "piso prudencial CMF", y lo presentaba como norma citada. La fuente era un documento interno de este proyecto que no citaba ninguna circular.

Verificado contra el texto oficial del Compendio de Normas Contables para Bancos (CMF):

  • Cap. A-2, num. 5"Lo establecido en el Capítulo 5.5 (deterioro de valor) de la NIIF9 (…) no será aplicado respecto de las colocaciones (…) ni sobre los "Créditos contingentes", ya que los criterios para estos temas se definen en los Capítulos B-1 a B-3 de este Compendio." → En Chile, un banco no calcula ECL de NIIF 9 sobre su cartera de colocaciones: el B-1 lo sustituye. No hay nada contra lo que comparar.
  • Cap. B-1, hoja 10-11 (Circular N° 2.346 / 06.03.2024)"La constitución de provisiones se efectuará considerando el mayor valor obtenido entre el respectivo método estándar y el método interno. (…) Esta regla se deberá aplicar para cada institución en Chile que consolida con el banco." → La regla del máximo es estándar vs. interno, a nivel de entidad.

max(CMF, IFRS 9) no es "el piso prudencial de la CMF" y deja de presentarse como tal en todas las superficies. El comparativo entre ambos marcos se mantiene —es útil, por ejemplo, para una filial que reporta ECL a su matriz extranjera— pero declarado como lo que es: un comparativo entre marcos contables, sin norma chilena que lo exija.

🔴 Corregido — el motor subprovisionaba al deudor refinanciado

El incumplimiento del Cap. B-1 numeral 3.2 tiene tres causales: mora ≥ 90 días, un crédito otorgado para dejar vigente una operación con más de 60 días de atraso, y reestructuración forzosa o condonación parcial. El motor de consumo derivaba solo la primera, de la mora.

Consecuencia: un deudor reestructurado o refinanciado al día recibía la PI de su tramo de mora (6,6 %) en vez del 100 % que la norma exige. Sub-provisión de 15×, y en la dirección que un regulador no perdona. La columna is_default existía en la config y el motor ya la leía para los contingentes B-3, pero en consumo nunca la consultaba.

Ahora el motor la lee también en consumo: la columna es opcional (sin ella, el comportamiento no cambia) y sus nulos se leen como "no marcado", de modo que el flag solo puede sumar incumplimiento, nunca quitar el que impone la mora. El incumplimiento se consolida a nivel deudor —la norma arrastra todos los créditos del deudor— y la traza de auditoría reporta la categoría incumplimiento, no el tramo de mora, para que el PI de 100 % sea visible.

Verificado — la matriz de consumo, contra el compendio oficial

Las 23 celdas de consumer_standard_v2025 (16 de PI, 6 de PDI y el PI = 100 % de incumplimiento) se cotejaron una a una contra el texto del Compendio de Normas Contables, Cap. B-1 numeral 3.1.3, hojas 16-18 (Circular N° 2.346 / 06.03.2024). Coinciden exactamente. Sigue sin ser una validación de la CMF —la Comisión no certifica implementaciones de terceros—, pero deja de ser una transcripción sin contrastar.

También queda anclado el benchmark del dataset provisiones_consumo: el índice de riesgo de la cartera de consumo del sistema bancario es 8,30 % (CMF, Informe del Desempeño del Sistema Bancario y Cooperativas, noviembre 2025, sección 2.2). La cartera sintética produce 8,63 % — 33 pb sobre el sistema. Antes se comparaba contra el 2,59 % del sistema completo, que es el agregado de todas las carteras y no es comparable con una cartera de consumo (consumo va 3,2× sobre ese agregado).

Añadido

  • nikodym.provisioning.internal — el motor del método interno del Cap. B-1, que faltaba: provisión(g) = Exposición(g) · PD(g) · LGD(g) por grupo homogéneo, tal como la norma lo describe textualmente. La PD sale del scorecard calibrado, de modo que el modelo del banco entra por fin en la provisión reportada. Métodos pd_lgd y direct_loss_rate (los dos que el B-1 admite), agrupación por banda de score / segmento / provista, aritmética en Decimal, y golden verificado a mano al centavo.
  • provisioning.source_a / source_b / rule — el orquestador compara fuentes configurables en vez de estar cableado a CMF↔IFRS 9. rule="use_internal" implementa la otra mitad de la norma: con método interno evaluado y no objetado por la Comisión, la provisión se constituye según el interno aunque el estándar sea mayor.
  • Dataset sintético provisiones_consumo — cartera de consumo con las columnas económico-regulatorias que exigen los motores (exposición, mora, deudor con varias operaciones, producto, flags de sistema, LGD), coherente por construcción y con tasa de default de un dígito.
  • Capítulo de provisiones en el informe — un capítulo condicional (ChapterSpec.requires_domain) que solo aparece cuando la corrida calculó provisiones. Su titular es el número que vende: la provisión a constituir y el sobrecosto del método estándar en CLP (para la cartera de la demo, $388.732.916 por encima de lo que el método interno pediría). Trae las tres tablas agregadas (comparación estándar-vs-interno, provisión estándar por categoría, provisión interna por grupo), declara la asimetría de consolidación que la norma impone (estándar por deudor, interno por banda de score) e imprime los warnings del orquestador. Con el capítulo ya presente, el informe deja de decir que las provisiones "corresponden a fases posteriores". Como consecuencia, report ahora corre al final del pipeline (antes su builder nunca veía las cards de provisiones).
  • Las cards de provisiones en results.json — el serializer de la UI expone las tres cards (estándar CMF, método interno, orquestador con la regla del máximo) y sus frames agregados graficables: el desglose del estándar por categoría, el del interno por grupo homogéneo y la comparación estándar-vs-interno. Los frames detail por operación (6.000 filas) jamás entran al payload — reventarían /api/results. Los Decimal contables salen como número, no string.
  • Preset f3-provisiones-consumo + rutas REST — un config listo para correr sin tocar nada que, encima del scorecard F1, calcula el método estándar de la CMF, el método interno y la regla del máximo estándar-vs-interno a nivel de entidad. El selector del front lo descubre por GET /api/config/presets; el detalle se pide por GET /api/config/preset/{id}. La calibración usa development_observed, NO se hereda del F1: con el target_pd=0.20 del F1 la PD se inflaría 3x, el método interno superaría al estándar y la regla del máximo no mordería — un test end-to-end corre la cadena entera y falla si el estándar deja de ser el que manda. El delta de provisiones se deriva —y se verifica corriendo— con scripts/derive_provisiones_preset.py.
  • Capítulos condicionales del informe (ChapterSpec.requires_domain): un informe de scorecard ya no puede traer un capítulo de provisiones vacío, ni uno con provisiones declarar que no las cubre.
  • scripts/gen_schema_fixture.py — regenera el fixture del schema de la demo, que hasta ahora se actualizaba a mano y se desincronizaba en silencio.

Corregido

  • Los Decimal de provisiones tumbaban /api/results entero. El serializador de la UI no conocía Decimal (los motores de provisiones trabajan en Decimal porque es una cifra contable) y su guard de serialización es global: no fallaba la sección de provisiones, fallaba todo el payload. Igual en el informe, que imprimía {"unsupported_type": "Decimal"} donde va la cifra.
  • Coherencia del material público (auditoría adversarial pre-1.2.0). El informe insignia renderizaba el markdown **mayor valor** como asteriscos crudos (la prosa va autoescapada), y su frase de alcance seguía declarando "solo validación de scorecard" pese a incluir el capítulo de provisiones — ahora la salvedad reconoce el capítulo regulatorio (experimental, fuera de SemVer 1.x). En la landing, el dominio de provisiones se rotulaba "CMF + IFRS 9" con superficie UI cuando la pantalla compara CMF vs. método interno (IFRS 9 corre en el motor, no en la UI). Y el pie de la demo declara ahora que la corrida real usa un dataset sintético de ejemplo, no la cartera de un banco.

Nota para quien audite

Los parámetros normativos de las matrices CMF siguen siendo una transcripción del compendio asistida por IA, con verificación visual: no son parámetros oficiales de la CMF ni están validados por ella, y requieren validación humana contra la norma vigente antes de cualquier uso productivo.

[1.1.3] — 2026-07-13

Release de documentación y metadata. Sin cambios de código: el motor es idéntico al 1.1.2.

Añadido

  • Sección "Quién lo construye" en el README y en la home de la documentación: el motor lo construye Nexo Labs, y hasta ahora ninguna superficie decía cómo llegar a quien lo mantiene.
  • [project.urls]: Demo (demo.nikodym.cl) y el enlace a la consultora en la barra lateral de PyPI.

Corregido

  • Documentation apuntaba al propio README (#readme), no a docs.nikodym.cl. Lo mismo en la sección "Documentación" del README, que enlazaba a sí misma.
  • El enlace a la licencia era relativo, y los enlaces relativos se rompen en la página de PyPI (el README es la long_description).

[1.1.2] — 2026-07-13

Corregido

  • pip install nikodym no corría: la primera corrida de todo usuario nuevo moría. Las dependencias publicadas declaraban rangos abiertos (pandas>=2.0, scikit-learn>=1.6), y la resolución libre de pip —la que hace cualquiera que instale desde PyPI— traía hoy scikit-learn 1.9, que eliminó el force_all_finite que optbinning invoca (el binning muere con un TypeError), y pandas 3.0, que rompe la serialización de resultados con un 500. El motor no arrancaba en 1.0.0, 1.1.0 ni 1.1.1.

El CI no podía verlo: corre con uv sync --locked, que fija pandas 2.3.3 y scikit-learn 1.7.2. El techo scikit-learn<1.8 incluso ya existía, pero en [tool.uv] constraint-dependencies —donde protege al desarrollador y al CI, y no viaja en el wheel—. Ahora los techos (pandas<3, scikit-learn<1.8) están donde el usuario los recibe.

Se añade el gate que faltaba: el CI instala el wheel con pip resolviendo libre y corre el preset estándar de punta a punta (scripts/smoke_instalacion_pip.py). El smoke anterior solo hacía import nikodym, y importar siempre funcionaba.

[1.1.1] — 2026-07-13

Corregido

  • La base editable se descargaba con todas las imágenes rotas. GET /api/report/{run_id}/md entregaba un ZIP con el .qmd y ninguna figura: el documento citaba sus cinco SVG por ruta relativa (scorecard_report_figuras/…) y ninguna viajaba en el paquete, así que quarto render no compilaba y el informe se abría sin gráficos. La causa está en la costura entre dos funciones que por separado eran correctas: runs.save normaliza el documento a report.qmd pero copia la carpeta de figuras con el basename que el propio .qmd referencia, mientras que el empaquetador derivaba el nombre de esa carpeta del stem del archivo persistido (report_figuras), que nunca existe. Ahora el ZIP empaqueta las carpetas *_figuras que realmente están en la corrida. El test del endpoint fabricaba a mano un estado que save nunca produce, y por eso pasaba en verde: ahora monta el estado real y exige la invariante que importa —toda figura citada por el documento viaja en el paquete, con la misma ruta relativa—.
  • La UI ofrecía marcar json en los formatos del informe, y marcarlo garantizaba un error. El Literal de BasicReportFormat seguía declarando json pese a que ningún motor lo genera, así que GET /api/schema lo publicaba en el enum, el multiselect pintaba su checkbox y quien lo marcaba se llevaba un ValidationError que no tenía forma de prever desde la interfaz. El formato sale del enum: lo que la UI ofrece es ahora exactamente lo que la corrida puede cumplir. El validador que ya rechazaba los formatos sin motor se conserva como red de seguridad para quien amplíe el enum sin cablear la generación. Por coherencia, json también sale de ReportOutputFormat: un formato que no se puede pedir tampoco se puede producir.

[1.1.0] — 2026-07-13

Añadido

  • Metodología y Resultados llevan prosa generada y determinista, redactada con los parámetros efectivos de la corrida (método de binning y sus umbrales, criterios de selección, estimación, escalado, calibración). Sin red y sin IA: un informe regulatorio no puede variar entre dos corridas del mismo modelo.
  • Base editable descargable: el informe se exporta como .qmd (Quarto/Markdown, con front-matter y el lineage, para editarlo y compilarlo) y como .docx (Word, con estilos de encabezado reales, tablas nativas y figuras embebidas). Introducción, Contexto y Conclusiones vienen como placeholders con guía de qué escribir, ocultables con report.document.placeholders="hide".
  • Los formatos csv y xlsx ahora existen de verdad: exportan las tablas por observación (puntaje, PD, datasets WoE) completas, y se publican en ReportResult.data_exports.
  • Extra nuevo nikodym[docx] (python-docx, MIT) para el export Word. Entra en el meta-extra all y en ui, así que quien instala nikodym[all] o nikodym[ui] ya lo tiene. El extra pdf sigue aparte a propósito: WeasyPrint arrastra Pyphen (tri-licencia con GPL) y el gate de licencias del CI lo mantiene fuera del cierre redistribuible.

Cambiado

  • El reporte pasa de ser un volcado a ser un documento. Antes emitía una sección por paso del pipeline, cada una con su Payload y tablas tituladas con el nombre de la variable interna. Ahora es un informe de validación: portada con campos de proyecto, resumen ejecutivo (veredicto y métricas clave), índice, Introducción, Contexto, Metodología, Resultados, Conclusiones, Limitaciones y anexos técnicos. Todo el detalle de antes se conserva: baja a los anexos. Quien parsee el HTML o los section.id del ReportManifest verá otra estructura (el esquema de ReportManifest no cambió: los campos nuevos de ReportSection son aditivos y traen default).
  • report.formats ya no acepta en silencio lo que no implementa: pedir un formato sin ruta real falla con un error explícito en vez de validar y no producir nada. Cambio incompatible acotado: un config con json en report.formats —que en 1.0.0 validaba pero no producía archivo alguno— ahora falla al cargar.
  • Las tablas por observación salen del cuerpo del documento (iban truncadas a 200 filas, sin servir ni como dato ni como informe) y pasan a los exports de datos. El informe de referencia de la demo baja de 58 a 39 páginas.
  • El preset estándar pide los cuatro entregables (HTML, PDF, .qmd, .docx). Antes pedía solo HTML y, como la interfaz no expone dónde cambiarlo, las descargas de PDF y base editable respondían 404 siempre. report es infraestructura, así que el config_hash del preset no cambia.
  • Las funciones de nikodym.report.charts aceptan fmt="svg" | "png" y su retorno pasa de str a str | bytes. El default no cambia (svg), así que el comportamiento en runtime es el mismo.

Corregido

  • ranking_preserved reportaba rankings rotos que no lo estaban. Comparaba los rangos con igualdad exacta, así que una calibración monótona (intercept_offset) que colapsara dos PD separadas por ~1e-17 al mismo float64 se reportaba como ranking degradado, sin que ningún par se hubiera invertido. Ahora distingue la inversión de orden y el colapso de deudores que el modelo sí distinguía (ambos, False) del empate por precisión de coma flotante (True). El colapso se sigue contando en ties_created.
  • Sin las librerías nativas de WeasyPrint, la corrida entera moría. pdf.fail_if_unavailable=False promete degradar y entregar el HTML igual, pero solo cubría "WeasyPrint no instalado": las nativas ausentes (Pango/HarfBuzz/libffi) escapaban como OSError crudo. Es el caso normal de pip install nikodym[pdf] en macOS o Windows sin Pango.

[1.0.0] — 2026-07-12

Primer release estable. Congela la superficie pública del pipeline de validación de scorecard (F1) bajo garantía SemVer 1.x (no rompe hasta un 2.0): nikodym.run, el config raíz (runStudyNikodymConfig) y los dominios data, eda, binning, selection, scorecard, calibration, performance (AUC/KS/Gini), stability (PSI/CSI) y el reporte HTML.

Estable (SemVer 1.x)

  • Pipeline scorecard F1 de punta a punta y su config declarativo; audit-trail y reproducibilidad (config_hash).

Sigue experimental (fuera de la garantía SemVer 1.x)

  • Modelado ML/tuning/explicabilidad, forward-looking, Markov, survival y stress testing.
  • Provisiones CMF e IFRS 9/ECL (motores implementados y deterministas, pero su superficie regulatoria aún crece y no está battle-tested en producción).
  • Validación avanzada (backtesting/discriminación), gobernanza/tracking, formatos de reporte PDF/DOCX y narrativa por IA, y los contratos transversales de resultados/métricas/orquestación.

Changed

  • Marcadores de estabilidad por módulo: Experimental (SemVer 0.x)Estable (SemVer 1.x) en el core F1, y → Experimental (fuera de la garantía SemVer 1.x) en la superficie que crece.

[0.9.0] — 2026-07-10

Primer release público en PyPI. Motor V1 completo (F0–F7) y verde en CI; API pública versionada como 0.x honesto (puede cambiar hasta la 1.0).

Incluye

  • Núcleo reproducible (F0): config declarativo Pydantic v2, Study/lineage, audit-trail, artifacts namespaced, gobernanza SR 11-7.
  • Scorecard (F1): binning/WoE monotónico (optbinning), selección, regresión logística, scorecard escalado, calibración, desempeño (AUC/KS/Gini) y estabilidad (PSI/CSI).
  • Backends ML (F2): XGBoost, LightGBM, CatBoost, tuning (Optuna) y explicabilidad (SHAP) como extras selectivos.
  • Provisiones: motores CMF (Chile) e IFRS 9/ECL separados (provisión = máximo).
  • Forward-looking y stress testing.
  • UI (F7): flujo Scorecard F1 (Datos · Ejecutar · Resultados · Reporte) — React + backend FastAPI, con modo claro/oscuro y reporte HTML del modelo.
  • Empaquetado: publicación en PyPI vía Trusted Publishing (OIDC, sin tokens).

Detalle de la Fundación (F0)

  • Esqueleto del paquete: pyproject.toml (uv + hatchling, layout src/, 7 deps base, extras de usuario y grupos de desarrollo PEP 735), LICENSE Apache-2.0, README, CHANGELOG.
  • nikodym.core.exceptions: jerarquía de excepciones con raíz NikodymError (código regulatorio, cobertura objetivo 100 %).
  • nikodym.core.seeding: SeedManager — derivación determinista por nombre vía SeedSequence(entropy=[root_seed, hashlib]) (código regulatorio, cobertura objetivo 100 %).
  • nikodym.core.config: configuración declarativa (Pydantic v2). NikodymConfig frozen construible sin argumentos, secciones ReproConfig/RunConfig; config_hash (SHA-256 del JSON canónico que excluye INFRA_SECTIONS, estable e idéntico entre procesos); load_config/ dump_config (round-trip YAML con safe_load); version-gate migrate + decorador @migration (registro vacío en 1.0.0, cadena lineal validada en import-time). Experimental (SemVer 0.x).
  • nikodym.utils.optional: require_extra / has_extra / EXTRA_TO_DISTRIBUTIONS (import perezoso de extras con mensaje accionable).
  • Paths regulatorios declarados (nikodym.provisioning.cmf, nikodym.provisioning.ifrs9) para el gate de cobertura regulatoria; su implementación llega en F3/F4.
  • nikodym.core (resto de la Fundación, 9 módulos): primer Study end-to-end con lineage reproducible. audit (AuditEvent/AuditKind/AuditSink, NullAuditSink/InMemoryAuditSink/ FanOutSink); results (Protocols económicos ProvisionResultLike/ECLResultLike con term_structure(), CT-2); base (BaseNikodymEstimator raíz propia + 6 familias, semántica get_params/set_params/from_config estilo scikit-learn sin heredarlo); mixins (AuditableMixin, SerializationMixin con puerta trust); registry/artifacts (registro y almacén namespaced (domain, key)); steps (Step/StepAdapter, requires/provides, CT-1); lineage (LineageBundle/RunContext); study (Study: orquestador motor v1 con validación de prerequisitos CT-1, persistencia en directorio atómico, recarga con verificación de config_hash y reproducibilidad). Experimental (SemVer 0.x): orquestación y Protocols de resultados crecerán.
  • nikodym.data (B2a — capa data, configuración + endurecimiento, SDD-02 §5): sub-config declarativo DataConfig (nikodym/data/config.py): árbol Pydantic completo (Loading/Schema/Target/ Missing/Partition), mini-DSL declarativo Predicate/Rule (allowlist cerrada de operadores, sin eval), unión discriminada anidada de la estrategia de partición (temporal/random/cohort) por factory local, model_validator de fracciones (suman 1) y de regla no vacía, alias schema con populate_by_name. Endurecido NikodymConfig.data de Any a DataConfig | None (tipado estricto para mypy; coerción en runtime vía hook _DATA_CONFIG_CLS que nikodym.data puebla al importarse — el núcleo sigue liviano, no importa data). Golden config_hash por defecto invariante. Deps base activadas: pandera>=0.24 (uso import pandera.pandas) y pyarrow>=14. Experimental (SemVer 0.x).