Randori

柔術のトレーニングログ。自分が練習しているから作りました。

試したフィットネスアプリはどれも、柔術をその他に分類していました。身体にできることの中で最も過酷な練習の1時間が、カロリーの当て推量として記録されるだけです。練習後に知りたいことは3つでした。今夜はどれだけ消耗したのか。何に極められたのか。二度と極められないためには、どうすればいいのか。Randoriは、この3つの問いにきちんと答えるアプリです。

Download on the App Store
Randoriのアプリアイコン(ライト)。和紙に墨で書かれた「乱取り」の筆文字
ライトモード
Randoriのアプリアイコン(ダーク)。同じ筆文字を黒地に白で起こしたもの
ダークモード
なぜ作ったのか

練習後、マットの上の自分のために作りました

柔術をやっています。練習の夜は、たいてい同じように終わります。マットの端に座り込み、この1時間に実際は何が起きたのかを思い出そうとするのです。どのローリングで力を使い果たしたのか。同じアームバーに2度も掛かったのはどれか。まだコーチの声が耳に届いていた30秒のあいだに、何を言われたのか。

試したアプリは、こちらをランナーにしたがりました。ノートは汗で濡れてだめになりました。だから、いつも手を伸ばしては見つからなかったものを自分で作ったのです。柔術の言葉を話すログ。強度、ポジション、サブミッション、そして自分の言葉で書くメモ。今夜のミスは、明日の研究プランに変わります。

プロダクトの要件定義は、これがすべてです。アプリのあらゆる機能は練習後の3つの問いのどれかに答えるためにあり、答えないものはすべて削りました。

セッション

一晩の練習の、身体の真実

セッションを開始すると、Apple Watch(またはペアリングしたチェストストラップ)がローリング中の心拍数をライブで送り続けます。強度はラウンドごとに、ローリングの合間の数秒で記録。親指ひとつ、ごまかしなしの正直さで。練習が終わると、その夜の本当の輪郭が手元に残ります。どれだけ激しかったか、どれだけ長かったか、そして余力がどれだけ残っていたか。

心拍数・強度・経過時間とともにライブのトレーニングセッションを表示するiPhone版Randori
フィードに記録された練習。動画と心拍数、そして消耗。
ローリングごとの強度を記録するRandoriの稽古画面
「稽古」タブ。タップして始める、そして今週のここまで。
練習後にセッションを振り返るRandoriの残像画面
残像。終わってみて初めて分かる、セッションの代償。
ローリングから学ぶ

同じ技に、もう掛からないために

この機能がほしくて、このアプリを作りました。何が起きたかを自分の言葉で書きます。ドロップダウンではなく、自由なメモです。するとRandoriがそのメモを、学ぶ価値のある指導者たちによる動画付きレッスンと結びつけます。アームバーに掛かったら、そのディフェンスを。その次は、カウンターへのカウンターを。

セッションメモ

クローズドガードからアームバーに掛かった。肘を戻すのが間に合わなかった

マットから

スタッキング・アームバーディフェンス

エスケープ · クローズドガード · 動画2本

マッチングはiPhoneの中で行われます。メモが理解のために端末の外へ送られることはありません。ライブラリは白帯から黒帯までを網羅しているので、答えはいつも自分のレベルに合わせて返ってきます。

iPhoneにApple Intelligenceが搭載されている場合は、オンデバイスの言語モデルがメモを読み、ひとつの狭い問いにだけ答えます。この技は自分が掛けられたのか、それとも自分が掛けたのか。選択肢はすでに存在する2つのページだけなので、読み違えても軽い外れで済み、何かがでっち上げられることはありません。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の昇帯を祝う画面
その日が来たとき、そこまでに何が要ったかをアプリは知っています。
デザイン

活字ではなく、筆で

アイコンは本物の書です。和紙に墨で書いた「乱取り」の文字。ライトのアイコンはその墨をそのまま、ダークのアイコンは同じ筆致を白で起こした、筆のネガです。このマークのどこにも、フォントは使われていません。

アプリの他の部分も、その規律を守っています。赤はひとつだけ、落款の赤。使い方は判子と同じで、めったに押さず、押すときは必ず意味を込めます。帯の色がそのまま帯位を語るのは、道場ですでにそうなっているからです。さらにアイコンには8つの別の顔(鳥居・判子・落款・太陽・雨・波・雀・稜線)が用意されているので、ホーム画面はその日の空模様を自分で選べます。

墨で書かれた「乱取り」の筆文字
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
アプリには8つの代替アイコン(鳥居・判子・落款・太陽・雨・波・雀・稜線)が同梱されています。
仲間とアーキテクチャ

道場は人でできている。このアプリも同じです。

練習はひとりでするものではありません。ログがひとりのふりをするべきでもありません。実際にローリングする仲間とつながり、セッションを共有できます。このソーシャルレイヤーはCloudKitのパブリックデータベースの上で動きます。アカウントは不要で、トレーニングデータは自分のiCloudの中にとどまります。開発元に届くのは不正利用の通報と匿名の利用カウントだけで、そのどちらにも練習の中身は含まれません。

iPhone + Apple WatchSwiftUI · HealthKit · ライブ心拍
CloudKitパブリックデータベース · アカウント不要
通報941テレメトリ · モデレーション

コミュニティ機能には、コミュニティへの責任が伴います。ユーザー通報は1.0から搭載され、開発元自身のモデレーションツールに直結しています。

パブリックデータベース上のモデレーションは完全性の問題です。だからアプリも、そのように扱います。すべてのBANは開発元の鍵で署名され、各クライアントはその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もApple 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のライブセッションにも対応しています。