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.
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 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.
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.
pris dans une clé de bras depuis la garde fermée, pas réussi à rentrer mon coude à temps
Défense de clé de bras en stacking
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.
/// 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 }
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.
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.
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.
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.
/// 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. } }
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 :
/// 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.