# IA local en iOS: cuando 12.300 km se convierten en 12,3

> La IA local leyó mal los km de Rydger y eligió aceite de horquilla. Qué saqué del modelo, cómo se corrige la tarea y qué sigue sin cubrir el arreglo.

**Respuesta corta:** la IA local de mi iPhone leyó `12.300 km` como 12,3 kilómetros. El arreglo fue dar prioridad a reglas que leen los datos explícitos de la frase y dejar que el usuario cambie la tarea propuesta. La parte incómoda: una de esas reglas todavía no manda en todos los caminos del código.

El 5 de noviembre estaba probando la entrada en una línea de [Rydger](https://www.rydger.com/), mi app de mantenimiento para motos y coches, con el iPhone configurado en inglés. Yo escribía los números a la española. El modelo de Apple que se ejecuta en el móvil se comió los miles; además, `oil` no contaba como mantenimiento. Esta frase tampoco salía bien:

```text
cambio de aceite en Motos Pepe 89,90 € a 15.000 km ayer
```

En la misma prueba, con una BMW F 850 GS, «cambio de aceite» acabó asociado a `Fork oil`, el aceite de horquilla. El formulario enseñaba la tarea como texto fijo. Veía el error y no podía cambiarlo.

No tengo un benchmark del modelo ni una tasa de fallos. Tengo esas pruebas en el móvil, los cambios que salieron de ellas y los tests de regresión. Con eso basta para revisar dónde estaba dejando decidir a la IA más de la cuenta.

## Un número válido puede ser el kilometraje equivocado

Rydger usa Foundation Models para convertir la frase en una estructura Swift. La [generación guiada de Apple](https://developer.apple.com/documentation/foundationmodels) garantiza que la respuesta encaje en los tipos definidos. Eso ayuda con la forma de los datos. No decide si ese punto separa miles.

`12.3` cabe perfectamente en un `Double`. Es positivo. Un schema que solo pida un número mayor que cero lo acepta tan tranquilo, aunque yo haya escrito doce mil trescientos kilómetros.

Ahí está el hueco que no tapa «valida la salida del modelo». Hay que comprobar qué relación tiene el valor con lo que escribió la persona. Y el idioma del teléfono no obliga a nadie a escribir los números como espera ese idioma. Mi móvil estaba en inglés; yo seguía siendo de Málaga.

## Lo que la frase dice explícitamente tiene prioridad

El primer arreglo añadió `OneLineEntryTextHints`: reglas para leer kilómetros, importes en euros y expresiones como «ayer». Si encuentran un valor, ese valor manda sobre el del modelo.

La precedencia se ve en esta línea de `OneLineEntryOnDevice.proposal`:

```swift
let amount = OneLineEntryTextHints.amount(in: request.text)
    ?? positive(reading.amount)
```

No se trata de extraer cualquier número que aparezca. Los kilómetros necesitan una unidad reconocida; el importe necesita una referencia explícita al euro. Así, `12.300 km` no acaba también como gasto. El parser trata los grupos de tres cifras como miles y admite la coma decimal de `89,90 €`. Son convenciones de esta entrada, con límites que cuento abajo.

Para «ayer», la regla parte del día de referencia de la petición. No le vuelve a preguntar al modelo qué día era ayer. Si no encuentra una expresión que conozca, conserva el valor propuesto por el modelo.

El test `theDeviceTestSentence` usa la frase de Motos Pepe y le inyecta una respuesta deliberadamente mala: gasto, 15 km y 8.990 de importe. **Esa respuesta es una fixture del test, no una captura literal de lo que contestó el iPhone.** Espera un servicio a 15.000 km por 89,90 €, con la fecha de ayer, y conserva el taller que sí venía en la respuesta.

Es el test que me interesa: aunque el modelo insista en el valor malo, la regla gana. Que mañana acierte no es motivo para quitarla.

## Encontrar «oil» no identifica qué aceite se cambió

El segundo fallo estaba en el enlace con las tareas de mantenimiento. Había varias con `oil` en el nombre y se cogía la primera. En esa moto fue `Fork oil`. El orden de una lista estaba decidiendo qué pieza había mantenido.

La corrección introduce una distinción pequeña: una tarea que menciona una pieza que la frase no nombra pierde frente a una candidata sin esa especificidad. Si escribo «aceite de horquilla», queda justificado elegir esa tarea. Si escribo «cambio de aceite», no.

Estos son casos del test del selector determinista, no nuevas pruebas en el móvil:

| Frase | Tareas disponibles | Resultado esperado |
| --- | --- | --- |
| `cambio de aceite` | Fork oil, Final drive oil, Engine oil, Brake fluid | Engine oil |
| `aceite de horquilla` | Fork oil, Engine oil | Fork oil |
| `cambio de aceite` | Fork oil, Final drive oil | Ninguna |

La tercera fila importa más que la primera. Si solo hay candidatos demasiado específicos, el selector devuelve `nil`. «No he encontrado una tarea» es algo que el usuario puede resolver en el formulario. Adivinar una pieza que no he mencionado le complica el trabajo al usuario.

Eso tampoco demuestra que un cambio genérico sea siempre de aceite de motor. Es la preferencia de producto que recoge esta regla, no conocimiento mecánico infalible.

## La revisión necesita un sitio donde corregir

Después, el formulario de servicio ahora tiene un selector de tarea, incluida la opción de no enlazar ninguna. El flujo de entrada en una línea guarda la selección modificada, en vez de recuperar la propuesta original.

Antes, pedir que revisara los datos tenía una pega bastante obvia: el dato que estaba mal no se podía tocar. Había construido una pantalla de revisión a la que le faltaba precisamente esa revisión.

Además, el propio formulario avisa de que enlazar el servicio marca la tarea como hecha y actualiza los datos con los que se calcula el próximo vencimiento. El nombre de la tarea no es una etiqueta decorativa. Cambiarlo afecta a lo que la app considera que ya se ha mantenido.

**El cambio que hace el usuario tiene que conservarse al guardar.** Un selector que cambia el texto pero deja intacto el enlace original sería el mismo fallo, con más interfaz.

## Las reglas también se equivocan, y queda un hueco

Sería cómodo terminar con «una regex lo arregló». La comprobación del código para este artículo encontró una pega que mi nota de la incidencia no recogía.

El selector determinista sí devuelve `nil` cuando solo encuentra aceites de piezas no mencionadas. Pero la función que mezcla su resultado con el del modelo solo descarta una elección como `Fork oil` **si hay una alternativa determinista**. Si el modelo propone horquilla y la regla no encuentra alternativa, esa propuesta puede quedarse. Es una conclusión de leer el código actual, no otra prueba que haya hecho en el teléfono.

Así que la tercera fila de la tabla describe al selector, no garantiza el comportamiento de toda la función. Falta cubrir ese caso en la mezcla final. El selector del formulario permite corregirlo, pero no convierte la propuesta en correcta.

Los números tienen su propia letra pequeña. Interpretar un separador seguido de tres cifras como miles sirve para estas lecturas del cuentakilómetros. No copiaría esa convención a una app que necesite tres decimales. Y la lista de trabajos y piezas es finita: añadir idiomas o aceptar nombres nuevos obliga a mantenerla.

## Empieza con una respuesta del modelo que sabes que está mal

Antes de retocar el prompt, cogería una frase que ya haya fallado y probaría la función que prepara el formulario con una respuesta falsa. Este fragmento adapta el test de Rydger; los tipos son del proyecto:

```swift
let request = OneLineEntryRequest(
    text: "oil 60 € a 12.300 km",
    now: Date(timeIntervalSince1970: 1_793_900_000),
    locale: Locale(identifier: "en_US"), currency: "EUR",
    odometerKm: 12_000, items: ["Oil change"]
)
let wrong = OneLineEntryReading(
    kind: "expense", odometerKm: 12.3,
    amount: 60, category: "maintenance"
)
let result = OneLineEntryOnDevice.proposal(from: wrong, request: request)
#expect(result?.odometerKm == 12_300)
#expect(result?.kind == .service)
```

Eso comprueba quién manda cuando hay desacuerdo. Después añadiría el caso sin alternativa: solo `Fork oil` disponible, cambio genérico en la frase y el modelo empeñado en enlazarlo. Es justo el hueco que queda en mi función.

## Fuentes

- Apple, [Foundation Models](https://developer.apple.com/documentation/foundationmodels), consultado el 8 de noviembre de 2026: alcance de la generación guiada.
- Mis pruebas del 5 de noviembre y el código de Rydger, repo privado: las reglas de `OneLineEntryTextHints`, su uso en `OneLineEntryOnDevice` y el selector de `ServiceRecordForm`. Para este artículo, un agente contrastó las afirmaciones con el código actual; los tests citados se leyeron, no se volvieron a ejecutar. No he medido aquí la precisión del modelo después del cambio.

**Lectura relacionada:** [qué comprueba el harness cuando el modelo se equivoca](/blog/harness-engineering-por-que-falla-tu-agente/).

---

**P.D.** ¿Tu formulario deja corregir todo lo que propone la IA, o hay algún dato que se queda fijo? Cuéntamelo en [LinkedIn](https://www.linkedin.com/in/oscargallegoruiz).
