Randori
The jiu-jitsu training log. Built because I train.
The fitness apps I tried file jiu-jitsu under Other — an hour of the hardest training a body can do, logged as a calorie guess. I wanted three things after class: what did tonight cost me, what caught me, and how do I make sure it doesn't catch me again. Randori is those three questions, answered properly.
I built this for me, on the mat, after class
I do jiu-jitsu. Most nights end the same way: sitting on the edge of the mat, trying to remember what actually happened in the last hour — which rolls emptied the tank, which armbar I walked into twice, what my coach said in the thirty seconds I could still hear him.
The apps I tried wanted me to be a runner. The notebooks got soaked. So I built the thing I kept reaching for and not finding: a log that speaks jiu-jitsu — effort, positions, submissions, notes in my own words — and turns tonight's mistakes into tomorrow's study plan.
That's the whole product brief. Everything in the app answers one of my three after-class questions, and anything that didn't got cut.
The physical truth of a night of training
Start a session and the Watch (or a paired chest strap) streams heart rate live while you roll. Effort is logged per round, in the seconds between rolls — one thumb, gloves-off honest. When class ends you have the real shape of the night: how hard, how long, how much was left in the tank.
Stop getting caught by the same thing
This is the feature I built the app to get. You write what happened in your own words — a note, not a dropdown — and Randori matches it to film-backed lessons from instructors worth learning from. Get caught in an armbar, see the defense. Then the counters to the counters.
got caught in an armbar from closed guard, couldn't get my elbow back in time
Stacking Armbar Defense
The matching happens on your iPhone — your notes never leave your device to be understood. The library runs white belt through black, so the answer meets you at your level.
When Apple Intelligence is on the phone, an on-device language model reads the note and answers one narrow question: was this technique done to you, or by you? It only ever chooses between two pages that already exist, so a misread is a soft miss, never an invention. No Apple Intelligence? Calibrated heuristics take over. Either way, the note never leaves the 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 }
The belt, made honest
Jiu-jitsu progress is famously opaque — years between belts, promotions that arrive when they arrive. Randori counts what you can control: sessions accumulate into an honest forecast of where your training is heading, so the long road has mile markers.
Drawn, not typeset
The icon is real calligraphy — 乱取り, randori, brushed in sumi ink on washi paper. The light icon is the ink; the dark icon is the same stroke in white, the negative of the brush. Nothing in the mark is a font.
The rest of the app keeps that discipline. One red, the seal, used the way a hanko is used: rarely, and only to mean it. Belt colors carry rank because they already do at the gym. And the icon ships in eight more hands (torii, hanko, seal, sun, rain, waves, sparrows, ridge), so your Home Screen can pick its own weather.
A gym is people. So is the app.
You don't train alone, and the log shouldn't pretend you do. Connect with the people you actually roll with and share sessions — a social layer that runs on CloudKit's public database. No accounts, and your training data stays in your iCloud. The only things that ever reach the studio are abuse reports and anonymous usage counts, and neither carries your training.
Community features carry community responsibilities: user reporting shipped in 1.0, wired into the studio's own moderation tooling.
Moderation on a public database is an integrity problem, so the app treats it as one. Every ban is signed with the studio key, and each client verifies that P256 signature before honoring the action. A forged record fails the check and gets ignored. When the moderation list is unreachable, the app fails open: the feed keeps working, because a network hiccup should never silence a gym.
/// 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. } }
Small, honest machinery
SwiftUI on iPhone and Watch. Sessions sync through your private iCloud and write to Apple Health as real workouts. Even the anonymous usage telemetry defines a session the way a training log would — a burst of activity, ended by half an hour of quiet:
/// 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" } }
Twelve languages at launch, from Japanese to Arabic — because the sport's vocabulary is already global, and the app should meet it there.
Your training, finally counted.
Randori 1.0 is on the App Store for iPhone, with Apple Watch live sessions.