Randori

El diario de entrenamiento de jiu-jitsu. Creado porque entreno.

Las apps de fitness que probé archivan el jiu-jitsu bajo Otros: una hora del entrenamiento más duro que puede hacer un cuerpo, registrada como una estimación de calorías. Yo quería tres cosas al salir de clase: qué me costó la noche, en qué caí y cómo me aseguro de no volver a caer. Randori es esas tres preguntas, respondidas como se debe.

Download on the App Store
Ícono de la app Randori, versión clara: 乱取り pintado a pincel con tinta sumi sobre papel washi
Modo claro
Ícono de la app Randori, versión oscura: el mismo trazo a pincel en tinta blanca sobre negro
Modo oscuro
Por qué existe

Lo construí para mí, en el tatami, después de clase

Entreno jiu-jitsu. Casi todas las noches terminan igual: sentado al borde del tatami, tratando de recordar qué pasó de verdad en la última hora — qué rolls me vaciaron el tanque, en qué armbar caí dos veces, qué dijo mi profesor en los treinta segundos en que todavía podía escucharlo.

Las apps que probé querían que yo fuera corredor. Las libretas terminaban empapadas. Así que construí lo que siempre buscaba y no encontraba: un diario que habla jiu-jitsu —esfuerzo, posiciones, sumisiones, notas con mis propias palabras— y convierte los errores de esta noche en el plan de estudio de mañana.

Ese es todo el brief del producto. Todo lo que hay en la app responde una de mis tres preguntas de después de clase, y lo que no, quedó fuera.

La sesión

La verdad física de una noche de entrenamiento

Inicia una sesión y el Watch (o una banda de pecho enlazada) transmite la frecuencia cardíaca en vivo mientras ruedas. El esfuerzo se registra por round, en los segundos entre rolls — un solo pulgar, honestidad sin guantes. Cuando termina la clase tienes la forma real de la noche: qué tan duro, cuánto duró, cuánto quedó en el tanque.

Randori en iPhone mostrando una sesión de entrenamiento en vivo con frecuencia cardíaca, esfuerzo y tiempos de la sesión
Una clase registrada en el feed: el video, la frecuencia cardíaca, el costo.
Vista de entrenamiento de Randori con registro de esfuerzo por roll
La pestaña Entrenar: un toque para entrar, y la semana hasta ahora.
Vista de estela de Randori que resume la sesión al terminar la clase
La estela: lo que costó la sesión, una vez terminada.
Aprende de tus rolls

Deja de caer en lo mismo

Esta es la función por la que construí la app. Escribes lo que pasó con tus propias palabras —una nota, no un menú desplegable— y Randori la empareja con lecciones respaldadas en video de instructores de los que vale la pena aprender. Caes en un armbar, ves la defensa. Luego, las contras de las contras.

Nota de sesión

caí en un armbar desde guardia cerrada, no alcancé a recuperar el codo a tiempo

Desde el tatami

Defensa de armbar por apilamiento

Escape · guardia cerrada · 2 videos

El emparejamiento ocurre en tu iPhone: tus notas nunca salen de tu dispositivo para ser comprendidas. La biblioteca va de cinturón blanco a negro, así que la respuesta te encuentra en tu nivel.

Cuando el teléfono tiene Apple Intelligence, un modelo de lenguaje en el dispositivo lee la nota y responde una única pregunta muy acotada: ¿esta técnica te la hicieron a ti o la hiciste tú? Solo elige entre dos páginas que ya existen, así que una mala lectura es un fallo leve, nunca una invención. ¿No hay Apple Intelligence? Unas heurísticas calibradas toman el relevo. Sea como sea, la nota nunca sale del teléfono.

Swift · LearnSemanticIntent
/// The SEMANTIC tier: Apple's on-device foundation model, asked ONE
/// narrow question the lexical scorer is structurally bad at. The
/// words still never leave the device — the model runs in silicon,
/// not a cloud — and every path fails toward the deterministic tier:
/// unavailable hardware, a guardrail refusal, and a timeout all
/// collapse to nil, never to a wrong answer. The model NEVER
/// free-generates a suggestion; it only classifies intent on a
/// technique the scorer already found, and the redirect target is
/// always a graph edge.
enum LearnSemanticIntent {

    /// Apple Intelligence present, enabled, and ready. On anything
    /// else (old hardware, disabled, model still downloading) the
    /// caller falls back to the calibrated heuristics.
    static var isAvailable: Bool {
        SystemLanguageModel.default.availability == .available
    }

    /// The question the bigram heuristic kept fumbling: was this
    /// technique done TO the athlete, or BY them? Binary, grounded
    /// in the exact note and the exact technique — the answer only
    /// ever chooses between two graph-true pages (the technique or
    /// its curriculum counter), so a misread is a soft miss, never
    /// an invention. nil means "no verdict" — heuristic decides.
    static func happenedToAthlete(note: String, techniqueName: String) async -> Bool? {
        guard isAvailable else { return nil }
        let session = LanguageModelSession(instructions: """
            You read one Brazilian jiu-jitsu training note and answer one \
            question about a technique the note refers to: was that technique \
            done TO the athlete who wrote the note (they were caught in it, \
            tapped to it, kept getting stuck in it, struggled to defend it), \
            or did the athlete perform, drill, practice, teach, or study it \
            themselves? Getting caught, tapping out, and being swept or \
            submitted all mean it happened to the athlete. Saying they hit \
            it, landed it, finished it, drilled it, or attacked with it means \
            the athlete performed it — that is NOT done to them, even when \
            the technique sounds violent.
            """)
        do {
            let response = try await session.respond(
                to: "Training note: \"\(note)\"\nTechnique: \(techniqueName)",
                generating: LearnIntentReading.self,
                options: GenerationOptions(sampling: .greedy)
            )
            return response.content.happenedToAthlete
        } catch {
            return nil
        }
    }

    // A silence-filling catalog-pick tier was BUILT here and KILLED
    // by the eval the same hour (07-22): asked to match a lexically
    // silent note against the full catalog, the on-device model
    // answered 5 positives WRONG (rnc-defense for mount-escape
    // notes) and false-alarmed on 7 of 17 adversarial negatives
    // ("took my turtle back to the pet store" → turtle recovery).
    // The 3B model classifies narrow questions well and free-matches
    // badly; open-catalog recall belongs to a frontier model behind
    // a proxy, measured on this same eval, or to nobody.
}

@Generable
struct LearnIntentReading {
    @Guide(description: """
        true when the technique was done TO the athlete — caught in it, \
        tapped to it, kept getting stuck in it; false when the athlete \
        performed, drilled, or studied it themselves
        """)
    var happenedToAthlete: Bool
}
Vista de lección de Randori sugiriendo una defensa de armbar por apilamiento a partir de una nota de sesión
Una nota se convierte en lección: emparejada en el dispositivo, respaldada por video.
Progreso de cinturón

El cinturón, vuelto honesto

El progreso en jiu-jitsu es notoriamente opaco — años entre cinturones, promociones que llegan cuando llegan. Randori cuenta lo que sí puedes controlar: las sesiones se acumulan en un pronóstico honesto de hacia dónde va tu entrenamiento, para que el camino largo tenga sus hitos.

Blanco
Azul
Morado
Marrón
Negro
Los rangos tal como los dibuja la app: el mismo arte de cinturones que usa el pronóstico.
Vista de rango de Randori con el pronóstico de progreso de cinturón
Hacia dónde va el entrenamiento.
Pantalla de celebración de promoción en Randori
El día que llega, la app sabe lo que costó.
El diseño

Dibujado, no tipografiado

El ícono es caligrafía real: 乱取り, randori, pintado a pincel con tinta sumi sobre papel washi. El ícono claro es la tinta; el oscuro es el mismo trazo en blanco, el negativo del pincel. Nada en la marca es una fuente tipográfica.

El resto de la app mantiene esa disciplina. Un solo rojo, el del sello, usado como se usa un hanko: rara vez, y solo con intención. Los colores de cinturón indican el rango porque ya lo hacen en el gimnasio. Y el ícono viene en ocho manos más (torii, hanko, sello, sol, lluvia, olas, gorriones, cresta), para que tu pantalla de inicio elija su propio clima.

乱取り (randori), pintado a pincel con tinta sumi
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
La app incluye ocho íconos alternativos: torii, hanko, sello, sol, lluvia, olas, gorriones, cresta.
Compañeros y arquitectura

Un gimnasio es su gente. La app también.

No entrenas solo, y el diario no debería fingir que sí. Conéctate con la gente con la que de verdad ruedas y comparte sesiones: una capa social que corre sobre la base de datos pública de CloudKit. Sin cuentas, y tus datos de entrenamiento se quedan en tu iCloud. Lo único que llega al estudio son los reportes de abuso y los conteos anónimos de uso, y ninguno lleva tu entrenamiento.

iPhone + Apple WatchSwiftUI · HealthKit · FC en vivo
CloudKitbase de datos pública · sin cuentas
Reportestelemetría 941 · moderación

Las funciones comunitarias traen responsabilidades comunitarias: el reporte de usuarios llegó en la 1.0, conectado a las herramientas de moderación del propio estudio.

La moderación sobre una base de datos pública es un problema de integridad, y la app lo trata como tal. Cada bloqueo va firmado con la clave del estudio, y cada cliente verifica esa firma P256 antes de aplicar la acción. Un registro falsificado no pasa la verificación y se ignora. Cuando la lista de moderación no está disponible, la app falla en abierto: el feed sigue funcionando, porque un tropiezo de red nunca debería silenciar un gimnasio.

Swift · ModerationList
/// The developer's removal power, serverless (guideline 1.2: act on
/// reports by removing content and ejecting users). The team
/// publishes ModerationActionV1 records to the app's PUBLIC CloudKit
/// database — a surface every install can read. Every device fetches
/// the list and enforces it:
///
/// - a banned POST leaves every reader's stored feed,
/// - an ejected USER loses every audience: their posts drop, their
///   invitations are refused, and their own app stops publishing.
///
/// EVERY ACTION IS SIGNED, and an unverifiable one is ignored.
/// CloudKit grants create permission per ROLE, and a server-to-server
/// key acts as an authenticated identity (Apple: "as the developer who
/// created the key") — so the grant the operator needs is one a
/// determined user could also reach with a custom client. The
/// signature is what makes the list trustworthy rather than merely
/// hard to reach: only the holder of the private half can mint a ban,
/// and a forged record dies here.
///
/// Fail-open by design: an unreachable public database never blocks
/// the app; the last fetched list keeps enforcing from disk.
@MainActor
@Observable
final class ModerationList {

    /// The moderation signing key's public half (P-256, X9.63). The
    /// private half lives only in the moderation service.
    private static let signingPublicKey: P256.Signing.PublicKey? = {
        guard let data = Data(base64Encoded:
        "BK+sQ8R8B/PBsA9GuZhVzba4l4NpOn8trxk2tu54/xXqAdBO4xtEc2PZaa+j9drSyfS4TwYu1a/KYofedpdUShc="
        ) else { return nil }
        return try? P256.Signing.PublicKey(x963Representation: data)
    }()

    /// A ban counts only if the signature over "kind|targetID" checks
    /// out against that key. Anything else is somebody else's noise.
    static func isAuthentic(kind: String, targetID: String, signature: String) -> Bool {
        guard let key = signingPublicKey,
              let signatureData = Data(base64Encoded: signature),
              let parsed = try? P256.Signing.ECDSASignature(derRepresentation: signatureData)
        else { return false }
        return key.isValidSignature(parsed, for: Data("\(kind)|\(targetID)".utf8))
    }

    // … refresh() pages through the whole public list; only records
    // that pass isAuthentic() are ever enforced. When the fetch fails:
        } catch let error as CKError
                    where error.code == .unknownItem || error.code == .invalidArguments {
            // The record type does not exist yet in this environment:
            // an empty list, honestly. First-run schema state, not a
            // failure worth surfacing.
            refreshedThisLaunch = true
        } catch {
            // Network or account trouble: keep enforcing the last
            // fetched list. Never louder than that.
        }
}
Vista del feed de Randori mostrando las sesiones de los compañeros de entrenamiento
El feed: tu gimnasio, tus compañeros, nada más.

Maquinaria pequeña y honesta

SwiftUI en iPhone y Watch. Las sesiones se sincronizan por tu iCloud privado y se escriben en Apple Health como entrenamientos reales. Hasta la telemetría anónima de uso define una sesión como lo haría un diario de entrenamiento — una ráfaga de actividad que termina con media hora de silencio:

Swift · SessionTracker
/// Session identity: a burst of activity separated by ≥30 quiet minutes.
/// Memory-only by design — no persistence, no cross-launch identity.
/// Rotation happens in `noteActivity` (called per tracked event), never in
/// `currentID` — reading the id at flush time must not start a new session.
enum SessionTracker {
    static let quietGap: TimeInterval = 30 * 60

    private static let lock = NSLock()
    nonisolated(unsafe) private static var id = UUID().uuidString
    nonisolated(unsafe) private static var startedAt = Date()
    nonisolated(unsafe) private static var lastEventAt = Date()

    /// Session that just closed, reported at rotation so the caller can
    /// record its end under the OLD id.
    struct EndedSession: Sendable {
        let id: String
        let duration: TimeInterval
    }

    /// Called before each tracked event. After a quiet gap the old session
    /// closes (returned) and a fresh one starts.
    static func noteActivity(now: Date = Date()) -> EndedSession? {
        lock.lock()
        defer { lock.unlock() }
        var ended: EndedSession?
        if now.timeIntervalSince(lastEventAt) > quietGap {
            ended = EndedSession(id: id, duration: lastEventAt.timeIntervalSince(startedAt))
            id = UUID().uuidString
            startedAt = now
        }
        lastEventAt = now
        return ended
    }
}

// RandoriTelemetry.swift — the only shape a duration ever leaves in:
    /// Coarse session-length bucket — raw minutes never leave the device.
    static func minutesBucket(_ minutes: Int) -> String {
        switch minutes {
        case ..<30: "u30"
        case 30...44: "30_44"
        case 45...59: "45_59"
        case 60...89: "60_89"
        default: "90_plus"
        }
    }

Doce idiomas desde el lanzamiento, del japonés al árabe, porque el vocabulario del deporte ya es global y la app debería salir a su encuentro.

Tu entrenamiento, por fin contado.

Randori 1.0 está en App Store para iPhone, con sesiones en vivo en Apple Watch.