Randori

Le carnet d'entraînement de jiu-jitsu. Conçu parce que je m'entraîne.

Les apps de fitness que j'ai essayées classent le jiu-jitsu dans Autre — une heure de l'entraînement le plus dur qu'un corps puisse encaisser, consignée comme une estimation de calories. Je voulais trois choses après le cours : ce que la soirée m'a coûté, ce qui m'a piégé, et comment faire pour ne plus me faire prendre. Randori, ce sont ces trois questions, avec de vraies réponses.

Download on the App Store
Icône de l'app Randori, version claire : 乱取り calligraphié à l'encre sumi sur papier washi
Mode clair
Icône de l'app Randori, version sombre : le même geste de pinceau, encre blanche sur fond noir
Mode sombre
Pourquoi cette app existe

J'ai construit cette app pour moi, sur le tapis, après le cours

Je fais du jiu-jitsu. La plupart des soirées se terminent de la même façon : assis au bord du tapis, à essayer de me rappeler ce qui s'est vraiment passé pendant la dernière heure — quels sparrings ont vidé le réservoir, dans quelle clé de bras je me suis jeté deux fois, ce que mon coach a dit pendant les trente secondes où je pouvais encore l'entendre.

Les apps que j'ai essayées voulaient faire de moi un coureur. Les carnets papier finissaient trempés. Alors j'ai construit ce que je cherchais sans jamais le trouver : un journal qui parle jiu-jitsu — effort, positions, soumissions, notes avec mes propres mots — et qui transforme les erreurs du soir en programme d'étude du lendemain.

C'est tout le cahier des charges. Tout dans l'app répond à l'une de mes trois questions d'après-cours, et ce qui n'y répondait pas a été coupé.

La séance

La vérité physique d'une soirée d'entraînement

Lancez une séance et l'Apple Watch (ou une ceinture cardio jumelée) diffuse votre fréquence cardiaque en direct pendant que vous roulez. L'effort se consigne round par round, dans les secondes qui séparent deux sparrings — d'un seul pouce, sans prendre de gants. Quand le cours se termine, vous avez la vraie forme de la soirée : quelle intensité, quelle durée, ce qui restait dans le réservoir.

Randori sur iPhone affichant une séance d'entraînement en direct avec fréquence cardiaque, effort et chronométrage
Un cours enregistré dans le fil : la vidéo, la fréquence cardiaque, le coût.
Vue Entraînement de Randori avec saisie de l'effort round par round
L'onglet Entraînement : on entre d'un tap, et la semaine jusqu'ici.
Vue rémanence de Randori résumant la séance après le cours
La rémanence : ce que la séance a coûté, une fois terminée.
Apprendre de vos sparrings

Ne plus se faire prendre par la même chose

C'est pour cette fonctionnalité que j'ai construit l'app. Vous écrivez ce qui s'est passé avec vos propres mots — une note, pas un menu déroulant — et Randori la relie à des leçons en vidéo, données par des instructeurs qui valent la peine d'être écoutés. Pris dans une clé de bras ? Voici la défense. Puis les contres des contres.

Note de séance

pris dans une clé de bras depuis la garde fermée, pas réussi à rentrer mon coude à temps

Depuis le tapis

Défense de clé de bras en stacking

Sortie · garde fermée · 2 vidéos

La mise en correspondance se fait sur votre iPhone — vos notes n'ont jamais besoin de quitter votre appareil pour être comprises. La bibliothèque va de la ceinture blanche à la noire, pour que la réponse vous rejoigne à votre niveau.

Quand Apple Intelligence est sur le téléphone, un modèle de langage embarqué lit la note et répond à une seule question, très étroite : cette technique, l'avez-vous subie ou exécutée ? Il ne choisit jamais qu'entre deux pages qui existent déjà : une mauvaise lecture est donc un raté sans gravité, jamais une invention. Pas d'Apple Intelligence ? Des heuristiques calibrées prennent le relais. Dans les deux cas, la note ne quitte jamais le téléphone.

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
}
Vue leçon de Randori proposant une défense de clé de bras en stacking à partir d'une note de séance
Une note devient une leçon : mise en correspondance sur l'appareil, appuyée par la vidéo.
Progression de ceinture

La ceinture, rendue honnête

La progression en jiu-jitsu est notoirement opaque — des années entre les ceintures, des promotions qui arrivent quand elles arrivent. Randori compte ce que vous pouvez contrôler : les séances s'accumulent en une prévision honnête de la direction que prend votre entraînement, pour que la longue route ait ses bornes.

Blanche
Bleue
Violette
Marron
Noire
Les rangs tels que l'app les dessine — le même dessin de ceintures que celui de la prévision.
Vue rang de Randori montrant la prévision de progression de ceinture
La direction que prend l'entraînement.
Écran de célébration de promotion dans Randori
Le jour où ça arrive, l'app sait ce que ça a coûté.
Le design

Un coup de pinceau, pas une police

L'icône est une vraie calligraphie — 乱取り, randori, tracé à l'encre sumi sur papier washi. L'icône claire est l'encre ; l'icône sombre est le même trait en blanc, le négatif du pinceau. Rien dans cette marque ne sort d'une police de caractères.

Le reste de l'app garde cette discipline. Un seul rouge, celui du sceau, employé comme on emploie un hanko : rarement, et jamais à la légère. Les couleurs de ceinture portent le rang, parce qu'elles le portent déjà au club. Et l'icône se décline en huit autres mains (torii, hanko, sceau, soleil, pluie, vagues, moineaux, crête), pour que votre écran d'accueil choisisse sa propre météo.

乱取り (randori), calligraphié à l'encre sumi
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
Huit icônes alternatives sont incluses dans l'app : torii, hanko, sceau, soleil, pluie, vagues, moineaux, crête.
Partenaires & architecture

Un club, ce sont des gens. L'app aussi.

Vous ne vous entraînez pas seul, et le carnet ne devrait pas faire comme si. Connectez-vous aux personnes avec qui vous roulez vraiment et partagez vos séances — une couche sociale qui tourne sur la base de données publique de CloudKit. Aucun compte, et vos données d'entraînement restent dans votre iCloud. Les seules choses qui remontent au studio sont les signalements d'abus et des compteurs d'usage anonymes, et ni les uns ni les autres ne contiennent votre entraînement.

iPhone + Apple WatchSwiftUI · HealthKit · FC en direct
CloudKitbase de données publique · aucun compte
Signalementstélémétrie 941 · modération

Les fonctionnalités communautaires impliquent des responsabilités communautaires : le signalement par les utilisateurs est présent dès la 1.0, relié aux outils de modération du studio.

La modération sur une base de données publique est un problème d'intégrité, et l'app la traite comme tel. Chaque bannissement est signé avec la clé du studio, et chaque client vérifie cette signature P256 avant d'appliquer l'action. Un enregistrement falsifié échoue à la vérification et est ignoré. Quand la liste de modération est injoignable, l'app reste ouverte : le fil continue de fonctionner, parce qu'un hoquet réseau ne devrait jamais faire taire un club.

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.
        }
}
Vue du fil Randori montrant les séances des partenaires d'entraînement
Le fil : votre club, vos partenaires, rien d'autre.

Une petite mécanique honnête

SwiftUI sur iPhone et Apple Watch. Les séances se synchronisent via votre iCloud privé et s'inscrivent dans Apple Health comme de vrais entraînements. Même la télémétrie d'usage anonyme définit une session comme le ferait un carnet d'entraînement — une salve d'activité, close par une demi-heure de calme :

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

Douze langues au lancement, du japonais à l'arabe — parce que le vocabulaire de ce sport est déjà mondial, et que l'app doit le rejoindre là où il est.

Votre entraînement, enfin compté.

Randori 1.0 est sur l'App Store pour iPhone, avec les séances en direct sur Apple Watch.