Modelando una Regla
Una interfaz, una jerarquía sellada de resultados y un número de prioridad — ese es todo el vocabulario sobre el que se construye el resto del motor de decisión.
Antes de escribir un motor que pueda evaluar reglas, necesitas una forma en la que encaje una regla — un tipo de Kotlin que cualquier guardrail, boost o rechazo pueda implementar de la misma manera, sin importar cuán distintos sean sus umbrales y justificaciones de negocio. Esa forma es deliberadamente pequeña: una interfaz con un nombre, una prioridad y un único método que toma los datos de un vehículo y devuelve lo que sea que esta regla tenga que decir sobre él — o nada en absoluto, si la regla no aplica. Toda la flexibilidad de un motor de reglas viene de mantener este contrato así de estrecho.
`ValuationContext` es ese paquete de datos: el VIN, el kilometraje, el año del modelo, la cantidad de accidentes reportados y el número crudo que un proveedor como Black Book ya devolvió. Una regla solo ve este contexto — no accede al repositorio, no llama a un proveedor por su cuenta y no sabe nada de Postgres, Redis ni HTTP. Ese aislamiento es lo que hace que una regla sea trivial de probar: construyes un `ValuationContext` a mano, llamas a `evaluate()` y verificas el resultado, sin ninguna base de datos ni proveedor simulado a la vista.
El tipo de retorno es la decisión de diseño más interesante. `evaluate()` devuelve un `RuleOutcome` nullable — null significa "esta regla no tiene nada que decir sobre este vehículo", lo que le permite al motor simplemente saltársela. Cuando una regla sí aplica, devuelve uno de cuatro subtipos sellados: `Guardrail`, una parada dura que significa rechazar el vehículo por completo; `NoBuy`, un segundo tipo de parada dura reservado normalmente para una razón de negocio distinta (un problema de título frente a un límite de política, por ejemplo) pero tratado de forma idéntica por el motor; `Boost`, un ajuste positivo de precio con un monto en dólares asociado; y `NoOffer`, que significa mantener el vehículo en juego pero no dejar que un algoritmo lo tase — enviarlo a un humano. Que esto sea una sealed class en vez de, digamos, un enum simple importa porque cada variante lleva exactamente los datos que necesita — un `Boost` necesita un monto, un `Guardrail` solo necesita una razón — mientras el compilador sigue obligando a que cualquier `when` sobre `RuleOutcome` maneje los cuatro casos, o diga explícitamente que no lo va a hacer.
Fíjate en lo que estos cuatro resultados no son: no hay un tipo genérico de "ajusta el precio por este delta, positivo o negativo". Esa es una simplificación deliberada para este curso, pero el mismo principio aplica en un sistema real — entre menos formas de resultado distintas permitas, más simple se mantiene el motor que las consume. Cada nuevo tipo de resultado que agregas es un caso nuevo que todo consumidor de `RuleOutcome` tiene que considerar, para siempre.
La prioridad es lo que convierte una bolsa de reglas en una evaluación ordenada. Cada regla lleva un entero pequeño, y el motor ordena por él antes de ejecutar nada — los guardrails y los no-buy reciben números bajos para correr primero, los boosts reciben números más altos para correr al final. Esto importa por una regla que el negocio va a insistir la primera vez que salga en una revisión de diseño: una parada dura siempre gana, y no debería perder tiempo ni arriesgar un efecto secundario calculando primero boosts que están a punto de descartarse. Una lista ordenada le permite al bucle de evaluación comprobar si hay una parada dura después de que solo hayan corrido las reglas de mayor prioridad, cortando el proceso antes de siquiera llegar a los boosts que están más adelante en la lista.
Las dos reglas concretas de abajo muestran cuán poco código necesita en realidad una regla individual una vez que la interfaz existe. `MaxAgeGuardrail` fija 2012 como corte y devuelve un `Guardrail` cuando el año del modelo de un vehículo es anterior a eso; `LowMileageBoost` fija 30,000 millas y devuelve un `Boost` fijo de $500 para cualquier cosa por debajo. Estas son intencionalmente las implementaciones más simples posibles — los umbrales reales no deberían vivir horneados en una clase así, que es exactamente el problema que resuelve la siguiente lección al cargar la misma forma de regla desde filas de un CSV y de una base de datos.
Corre el ejemplo de abajo y observa el orden por prioridad haciendo su trabajo: un vehículo con poco kilometraje dispara el boost porque ninguna regla de mayor prioridad se activa antes, mientras que un vehículo lo bastante viejo para chocar con el guardrail nunca llega siquiera a la comprobación de kilometraje — el bucle imprime un mensaje y corta con un `break` en el momento en que el guardrail se dispara. Ese corte, hecho aquí a mano con un `break`, es exactamente el comportamiento que el motor real, dos lecciones más adelante, implementará sobre una lista arbitrariamente larga de reglas configuradas en vez de tres reglas fijas en el código.
data class ValuationContext(val vin: String,val mileage: Int,val modelYear: Int,val accidentCount: Int,val providerValuation: Double,)sealed class RuleOutcome {data class Guardrail(val reason: String) : RuleOutcome() // hard stop: refuse the vehicledata class NoBuy(val reason: String) : RuleOutcome() // hard stop: refuse for a different business reasondata class Boost(val amount: Double, val reason: String) : RuleOutcome() // positive price adjustmentdata class NoOffer(val reason: String) : RuleOutcome() // keep the vehicle, but defer to a human}interface Rule {val name: Stringval priority: Int // lower runs first; guardrails and no-buys should sit near zerofun evaluate(context: ValuationContext): RuleOutcome? // null means "does not apply"}
The vocabulary every rule and every outcome shares: a context of plain vehicle facts, a sealed hierarchy of the four things a rule is allowed to decide, and the interface that ties them together.
// Two concrete rules. Thresholds are hardcoded here on purpose --// the next lesson pulls exactly these numbers out into data.class MaxAgeGuardrail(override val priority: Int = 0) : Rule {override val name = "max-age-guardrail"private val oldestModelYearAllowed = 2012override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear < oldestModelYearAllowed) {RuleOutcome.Guardrail("Model year ${context.modelYear} is older than $oldestModelYearAllowed")} else null}class LowMileageBoost(override val priority: Int = 10) : Rule {override val name = "low-mileage-boost"private val mileageThreshold = 30_000private val boostAmount = 500.0override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.mileage < mileageThreshold) {RuleOutcome.Boost(boostAmount, "Mileage ${context.mileage} is under $mileageThreshold")} else null}
Two concrete rules built from that interface. The thresholds are hardcoded on purpose — the next lesson pulls exactly these numbers out into data.
data class ValuationContext(val vin: String,val mileage: Int,val modelYear: Int,val accidentCount: Int,val providerValuation: Double,)sealed class RuleOutcome {data class Guardrail(val reason: String) : RuleOutcome()data class Boost(val amount: Double, val reason: String) : RuleOutcome()data class NoBuy(val reason: String) : RuleOutcome()data class NoOffer(val reason: String) : RuleOutcome()}interface Rule {val name: Stringval priority: Intfun evaluate(context: ValuationContext): RuleOutcome?}class MaxAgeGuardrail(override val priority: Int = 0) : Rule {override val name = "max-age-guardrail"private val oldestModelYearAllowed = 2012override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear < oldestModelYearAllowed) {RuleOutcome.Guardrail("Model year ${context.modelYear} is older than $oldestModelYearAllowed")} else null}class AccidentHistoryNoOffer(override val priority: Int = 5) : Rule {override val name = "accident-history-no-offer"private val accidentThreshold = 2override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.accidentCount >= accidentThreshold) {RuleOutcome.NoOffer("Accident count ${context.accidentCount} requires manual review")} else null}class LowMileageBoost(override val priority: Int = 10) : Rule {override val name = "low-mileage-boost"private val mileageThreshold = 30_000private val boostAmount = 500.0override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.mileage < mileageThreshold) {RuleOutcome.Boost(boostAmount, "Mileage ${context.mileage} is under $mileageThreshold")} else null}fun main() {val rules: List<Rule> = listOf(LowMileageBoost(),AccidentHistoryNoOffer(),MaxAgeGuardrail(),).sortedBy { it.priority }val vehicles = listOf(ValuationContext("VIN-001", mileage = 18_000, modelYear = 2021, accidentCount = 0, providerValuation = 21_500.0),ValuationContext("VIN-002", mileage = 62_000, modelYear = 2009, accidentCount = 0, providerValuation = 6_200.0),)for (vehicle in vehicles) {println("Evaluating ${vehicle.vin} in priority order ${rules.map { it.name }}:")for (rule in rules) {val outcome = rule.evaluate(vehicle) ?: continueprintln(" [${rule.name}] fired -> $outcome")if (outcome is RuleOutcome.Guardrail) {println(" Guardrail hit -- short-circuiting, no further rules evaluated.")break}}}}
A complete, runnable program: the full Rule vocabulary, three concrete rules including a NoOffer, and a hand-rolled loop that sorts by priority and short-circuits the moment a guardrail fires.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. `Rule.evaluate()` returns a nullable `RuleOutcome`, and `RuleOutcome` is a sealed class with four variants (`Guardrail`, `NoBuy`, `Boost`, `NoOffer`) instead of, say, a plain enum with no attached data. What does modeling it this way actually buy you?