Randori

O diário de treino de jiu-jitsu. Feito porque eu treino.

Os apps de fitness que testei arquivam o jiu-jitsu em Outros — uma hora do treino mais duro que um corpo aguenta, registrada como um chute de calorias. Eu queria três coisas depois da aula: quanto a noite de hoje me custou, o que me pegou e como garantir que não me pegue de novo. O Randori é essas três perguntas, respondidas direito.

Download on the App Store
Ícone do app Randori, claro: 乱取り pincelado com tinta sumi sobre papel washi
Modo claro
Ícone do app Randori, escuro: a mesma pincelada em tinta branca sobre preto
Modo escuro
Por que isso existe

Eu fiz isso para mim, no tatame, depois da aula

Eu treino jiu-jitsu. Quase toda noite termina do mesmo jeito: sentado na beirada do tatame, tentando lembrar o que de fato aconteceu na última hora — quais rolas esvaziaram o tanque, em qual armlock eu caí duas vezes, o que o meu professor disse nos trinta segundos em que eu ainda conseguia ouvi-lo.

Os apps que testei queriam que eu fosse corredor. Os cadernos ficavam encharcados. Então construí o que eu vivia procurando e não encontrava: um diário que fala jiu-jitsu — esforço, posições, finalizações, anotações com as minhas palavras — e transforma os erros de hoje à noite no plano de estudo de amanhã.

Esse é o briefing inteiro do produto. Tudo no app responde a uma das minhas três perguntas pós-aula, e o que não respondia foi cortado.

O treino

A verdade física de uma noite de treino

Comece um treino e o Watch (ou uma cinta torácica pareada) transmite a frequência cardíaca ao vivo enquanto você rola. O esforço é registrado por round, nos segundos entre um rola e outro — um polegar só, honestidade na lata. Quando a aula termina, você tem a forma real da noite: quão pesada foi, quanto durou, quanto sobrou no tanque.

Randori no iPhone mostrando uma sessão de treino ao vivo com frequência cardíaca, esforço e duração
Uma aula registrada no feed: o vídeo, a frequência cardíaca, o custo.
Tela de treino do Randori com registro de esforço por round
A aba Treinar: toque para entrar, e a semana até aqui.
Tela de pós-imagem do Randori resumindo o treino depois da aula
A pós-imagem: o que o treino custou, depois que acabou.
Aprenda com os seus rolas

Pare de cair no mesmo golpe

Foi para ter este recurso que eu construí o app. Você escreve o que aconteceu com as suas próprias palavras — uma anotação, não um menu de opções — e o Randori a conecta a aulas em vídeo de professores que valem a pena estudar. Caiu num armlock? Veja a defesa. Depois, os contra-ataques dos contra-ataques.

Anotação do treino

caí num armlock da guarda fechada, não consegui tirar o cotovelo a tempo

Do tatame

Defesa de armlock com empilhamento

Escape · guarda fechada · 2 vídeos

A correspondência acontece no seu iPhone — as suas anotações nunca saem do aparelho para serem entendidas. A biblioteca vai da faixa branca à preta, então a resposta encontra você no seu nível.

Quando a Apple Intelligence está no iPhone, um modelo de linguagem no próprio aparelho lê a anotação e responde a uma única pergunta bem estreita: essa técnica foi aplicada em você ou por você? Ele só escolhe entre duas páginas que já existem, então uma leitura errada é um tropeço leve, nunca uma invenção. Sem Apple Intelligence? Heurísticas calibradas assumem. De um jeito ou de outro, a anotação nunca sai do aparelho.

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
}
Tela de aula do Randori sugerindo uma defesa de armlock com empilhamento a partir de uma anotação de treino
Uma anotação vira aula: conectada no próprio aparelho, respaldada em vídeo.
Progresso de faixa

A faixa, tornada honesta

O progresso no jiu-jitsu é notoriamente opaco — anos entre uma faixa e outra, graduações que chegam quando chegam. O Randori conta o que você pode controlar: os treinos se acumulam numa previsão honesta de para onde você está indo, para que a estrada longa tenha marcos quilométricos.

Branca
Azul
Roxa
Marrom
Preta
As graduações como o app as desenha — a mesma arte de faixas que a previsão usa.
Tela de graduação do Randori mostrando a previsão de progresso de faixa
Para onde o treino está caminhando.
Tela de comemoração de graduação no Randori
No dia em que acontece, o app sabe o que foi preciso.
O design

Desenhado, não tipografado

O ícone é caligrafia de verdade — 乱取り, randori, pincelado com tinta sumi sobre papel washi. O ícone claro é a tinta; o escuro é o mesmo traço em branco, o negativo do pincel. Nada na marca é fonte.

O resto do app mantém essa disciplina. Um vermelho só, o do selo, usado como se usa um hanko: raramente, e só quando é para valer. As cores das faixas carregam a graduação porque já fazem isso na academia. E o ícone vem em mais oito variações (torii, hanko, selo, sol, chuva, ondas, pardais, serra), para a sua Tela de Início escolher o próprio clima.

乱取り (randori), pincelado com tinta sumi
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
O app inclui oito ícones alternativos: torii, hanko, selo, sol, chuva, ondas, pardais, serra.
Parceiros e arquitetura

Uma academia é feita de gente. O app também.

Você não treina sozinho, e o diário não deveria fingir que treina. Conecte-se com as pessoas com quem você realmente rola e compartilhe treinos — uma camada social que roda no banco de dados público do CloudKit. Sem contas, e os seus dados de treino ficam no seu iCloud. As únicas coisas que chegam ao estúdio são denúncias de abuso e contagens anônimas de uso, e nenhuma das duas carrega o seu treino.

iPhone + Apple WatchSwiftUI · HealthKit · FC ao vivo
CloudKitbanco de dados público · sem contas
Denúnciastelemetria 941 · moderação

Recursos de comunidade trazem responsabilidades de comunidade: a denúncia por usuários chegou já na 1.0, integrada às ferramentas de moderação do próprio estúdio.

Moderação num banco de dados público é um problema de integridade, e o app a trata como tal. Cada banimento é assinado com a chave do estúdio, e cada cliente verifica essa assinatura P256 antes de honrar a ação. Um registro forjado falha na checagem e é ignorado. Quando a lista de moderação está inacessível, o app falha aberto: o feed continua funcionando, porque um soluço de rede nunca deveria silenciar uma academia.

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.
        }
}
Tela do feed do Randori mostrando as sessões dos parceiros de treino
O feed: sua academia, seus parceiros, nada mais.

Engrenagens pequenas e honestas

SwiftUI no iPhone e no Watch. Os treinos sincronizam pelo seu iCloud privado e são gravados no Apple Health como exercícios de verdade. Até a telemetria anônima de uso define uma sessão como um diário de treino definiria — uma rajada de atividade, encerrada por meia hora de silêncio:

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"
        }
    }

Doze idiomas no lançamento, do japonês ao árabe — porque o vocabulário do esporte já é global, e o app deve encontrá-lo onde ele está.

Seu treino, enfim contado.

O Randori 1.0 está na App Store para iPhone, com treinos ao vivo no Apple Watch.