Randori

주짓수 트레이닝 로그. 제가 수련하니까, 직접 만들었어요.

제가 써 본 피트니스 앱들은 주짓수를 기타로 분류해요. 몸이 할 수 있는 가장 혹독한 훈련 한 시간이 칼로리 추정치로 기록되는 거죠. 수업이 끝나면 알고 싶은 건 세 가지였어요. 오늘 밤 나는 무엇을 치렀나, 무엇에 당했나, 어떻게 해야 다시 당하지 않나. Randori는 그 세 가지 질문에 제대로 답한 결과예요.

Download on the App Store
Randori 앱 아이콘 라이트 버전: 와시 종이에 먹으로 쓴 乱取り
라이트 모드
Randori 앱 아이콘 다크 버전: 검은 바탕에 흰 먹으로 쓴 같은 붓글씨
다크 모드
왜 만들었나

수업이 끝난 매트 위에서, 나를 위해 만든 앱

저는 주짓수를 해요. 대부분의 밤은 똑같이 끝나요. 매트 가장자리에 앉아, 지난 한 시간 동안 실제로 무슨 일이 있었는지 떠올려 보는 거죠. 어떤 롤링이 체력을 바닥냈는지, 어떤 암바에 두 번이나 걸려들었는지, 아직 귀에 들어오던 30초 동안 코치님이 뭐라고 했는지.

써 본 앱들은 제가 러너이길 바랐어요. 노트는 흠뻑 젖어 버렸고요. 그래서 늘 찾았지만 어디에도 없던 것을 직접 만들었어요. 주짓수의 언어로 말하는 로그 — 강도, 포지션, 서브미션, 내 말로 쓰는 노트 — 그리고 오늘 밤의 실수를 내일의 학습 계획으로 바꾸는 로그요.

그게 제품 기획서의 전부예요. 앱의 모든 것은 수업 후 세 가지 질문 중 하나에 답하고, 답하지 못한 것은 전부 잘라냈어요.

세션

하룻밤 훈련의 물리적 진실

세션을 시작하면 롤링하는 동안 Watch(또는 페어링한 가슴 스트랩)가 심박수를 실시간으로 흘려보내요. 강도는 라운드마다, 롤링 사이 몇 초 안에 기록해요 — 엄지 하나로, 숨김없이 정직하게. 수업이 끝나면 그날 밤의 진짜 윤곽이 남아요. 얼마나 격했는지, 얼마나 길었는지, 체력이 얼마나 남았는지.

심박수, 강도, 세션 시간과 함께 라이브 훈련 세션을 보여 주는 iPhone 위의 Randori
피드에 기록된 수업 하나: 영상, 심박수, 그리고 치른 대가.
롤링마다 강도를 기록하는 Randori 훈련 화면
훈련(Train) 탭: 탭하면 바로 시작, 이번 주 기록도 한눈에.
수업이 끝난 뒤 세션을 요약하는 Randori 잔상 화면
잔상: 세션이 끝난 뒤, 무엇을 치렀는지.
롤링에서 배우기

같은 기술에 계속 당하는 일, 이제 그만

이 기능을 얻으려고 앱을 만들었어요. 무슨 일이 있었는지 내 말로 적으면 — 드롭다운이 아니라 노트예요 — Randori가 그것을 배울 가치가 있는 인스트럭터들의 영상 기반 레슨과 맞춰 줘요. 암바에 걸렸다면 그 방어법을 보고, 그다음엔 카운터의 카운터까지.

세션 노트

클로즈드 가드에서 암바에 걸렸는데 팔꿈치를 제때 빼지 못했다

매트에서

스태킹 암바 디펜스

이스케이프 · 클로즈드 가드 · 영상 2편

매칭은 내 iPhone 위에서 일어나요 — 노트를 이해하겠다고 기기 밖으로 보내는 일은 없어요. 라이브러리는 화이트 벨트부터 블랙 벨트까지 이어지니, 답은 지금의 내 수준에서 나를 만나요.

iPhone에 Apple Intelligence가 있으면 온디바이스 언어 모델이 노트를 읽고 딱 하나의 좁은 질문에만 답해요. 내가 당한 기술인가, 내가 건 기술인가? 모델은 이미 존재하는 두 페이지 중 하나를 고를 뿐이라, 잘못 읽어도 조금 빗나갈 뿐 없는 걸 만들어내진 않아요. Apple Intelligence가 없다면? 보정된 휴리스틱이 이어받아요. 어느 쪽이든 노트는 폰을 떠나지 않아요.

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
}
세션 노트를 바탕으로 스태킹 암바 디펜스를 제안하는 Randori 레슨 화면
노트가 레슨이 돼요: 온디바이스에서 매칭하고, 영상으로 뒷받침해요.
벨트 진행

정직해진 벨트

주짓수의 성장은 유명할 정도로 불투명해요. 벨트와 벨트 사이엔 몇 년이 걸리고, 승급은 올 때가 되어야 와요. Randori는 스스로 통제할 수 있는 것을 세어요. 세션이 쌓여 훈련이 어디로 향하는지에 대한 정직한 예측이 되고, 긴 길 위에 이정표가 생겨요.

화이트
블루
퍼플
브라운
블랙
앱이 그리는 랭크 — 예측 화면이 쓰는 것과 같은 벨트 아트예요.
벨트 진행 예측을 보여 주는 Randori 랭크 화면
훈련이 향하는 곳.
Randori 승급 축하 화면
그날이 오면, 앱은 여기까지 무엇이 들었는지 알고 있어요.
디자인

활자가 아니라, 붓으로

아이콘은 진짜 붓글씨예요 — 와시 종이에 먹으로 쓴 乱取り, 란도리죠. 라이트 아이콘은 먹 그대로이고, 다크 아이콘은 같은 획을 흰색으로 뒤집은 붓의 네거티브예요. 이 마크 어디에도 폰트는 없어요.

앱의 나머지도 그 절제를 그대로 지켜요. 빨강은 낙관 하나뿐이고, 한코를 찍듯 써요 — 드물게, 그리고 진심일 때만. 벨트 색이 랭크를 말하는 건 체육관에서 이미 그렇기 때문이죠. 아이콘은 여덟 가지 필치(토리이, 한코, 낙관, 해, 비, 파도, 참새, 능선)로도 함께 제공되니, 홈 화면이 저마다의 날씨를 고를 수 있어요.

먹으로 쓴 乱取り(란도리) 붓글씨
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
앱에 함께 실린 여덟 가지 대체 아이콘: 토리이, 한코, 낙관, 해, 비, 파도, 참새, 능선.
파트너와 아키텍처

체육관은 결국 사람이에요. 이 앱도 그래요.

혼자 훈련하는 게 아닌데, 로그가 혼자인 척해선 안 되죠. 실제로 함께 롤링하는 사람들과 연결해 세션을 공유하세요 — CloudKit 공개 데이터베이스 위에서 돌아가는 소셜 레이어예요. 계정은 없고, 훈련 데이터는 내 iCloud 안에 머물러요. 스튜디오에 도달하는 건 신고와 익명 사용 집계뿐이고, 어느 쪽에도 훈련 내용은 담기지 않아요.

iPhone + Apple WatchSwiftUI · HealthKit · 실시간 심박수
CloudKit공개 데이터베이스 · 계정 없음
신고941 텔레메트리 · 모더레이션

커뮤니티 기능에는 커뮤니티에 대한 책임이 따라요. 사용자 신고 기능은 1.0부터 탑재됐고, 스튜디오 자체 모더레이션 도구와 연결돼 있어요.

공개 데이터베이스 위의 모더레이션은 무결성 문제이고, 앱도 정확히 그렇게 다뤄요. 모든 차단은 스튜디오 키로 서명되며, 각 클라이언트는 그 P256 서명을 검증한 뒤에만 조치를 따라요. 위조된 레코드는 검증에 실패하고 무시돼요. 모더레이션 목록에 닿을 수 없을 때 앱은 열린 쪽으로 실패해요. 피드는 계속 돌아가요 — 네트워크가 잠깐 흔들렸다고 체육관이 조용해져서는 안 되니까요.

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.
        }
}
훈련 파트너들의 세션이 보이는 Randori 피드 화면
피드: 내 체육관, 내 파트너, 그 외엔 아무것도.

작고 정직한 장치

iPhone과 Watch 모두 SwiftUI예요. 세션은 개인 iCloud를 통해 동기화되고, Apple Health에 실제 운동으로 기록돼요. 익명 사용 텔레메트리조차 세션을 훈련 로그의 방식으로 정의해요 — 한바탕의 활동이, 30분의 고요로 끝나는 것:

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

출시부터 일본어에서 아랍어까지 12개 언어를 지원해요. 이 스포츠의 어휘는 이미 세계 공용이고, 앱은 바로 그 지점에서 만나야 하니까요.

당신의 훈련, 마침내 제대로 헤아려져요.

Randori 1.0이 iPhone용으로 App Store에 나와 있어요. Apple Watch 라이브 세션도 함께요.