Randori

柔術訓練日誌。因為我在練,所以做了它。

我試過的健身 App 都把柔術歸在其他——身體所能承受最艱苦的一小時訓練,只被記成一個卡路里猜測。下課後我想知道三件事:今晚耗了我多少、我被什麼抓到、又該怎麼確保不再被同一招抓到。Randori,就是這三個問題被好好回答的樣子。

Download on the App Store
Randori App 圖示(淺色):以墨筆書於和紙上的乱取り
淺色模式
Randori App 圖示(深色):同一筆勢改以白墨落在黑底上
深色模式
緣起

這是為我自己做的——在墊上,在下課後

我練柔術。多數夜晚的結尾都一樣:坐在墊子邊,努力回想剛剛那一小時到底發生了什麼——哪幾輪實戰把體力掏空、哪一記十字固我兩度自己撞上去、教練在我還聽得進去的那三十秒裡說了什麼。

我試過的 App 都希望我是個跑者。紙本筆記則被汗水浸爛。於是我動手做出那個一直想拿卻拿不到的東西:一本說柔術語言的日誌——強度、位置、降伏技,還有用自己的話寫下的筆記——並把今晚的失誤,變成明天的研究計畫。

這就是整份產品需求。App 裡的每一項功能,都在回答我下課後的三個問題之一;答不上的,一律砍掉。

訓練課

一晚訓練的身體真相

開始一堂課,Apple Watch(或已配對的心率胸帶)就會在您實戰時即時串流心率。強度逐局記錄,在兩局之間的幾秒內完成——一根拇指就好,誠實得毫不修飾。下課時,您手上就是這一晚真實的樣貌:練得多猛、撐了多久、油箱還剩多少。

iPhone 上的 Randori,顯示進行中的訓練課:心率、強度與課程計時
動態裡一堂記錄完成的課:影片、心率,與付出的代價。
Randori 訓練畫面,逐局記錄強度
「訓練」分頁:點一下開始,以及本週至今的累積。
Randori 課後殘影畫面,總結整堂訓練
殘影:課程結束後,這一晚付出了什麼。
從實戰中學習

別再被同一招抓到

這正是我做這個 App 最想要的功能。用自己的話寫下發生了什麼——是筆記,不是下拉選單——Randori 會把它配對到值得學習的教練所拍的影片課程。被十字固抓到,就看防禦怎麼做;然後,再看反制的反制。

課後筆記

在封閉式防禦被上了十字固,手肘來不及收回來

來自墊上

疊壓式十字固防禦

逃脫 · 封閉式防禦 · 2 部影片

配對就在您的 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 只數您能掌控的事:一堂堂課累積成一份誠實的預測,指出訓練正往哪裡去,讓漫漫長路有了里程標。

白帶
藍帶
紫帶
棕帶
黑帶
App 筆下的各個段位——升帶預測用的正是同一套腰帶圖。
Randori 段位畫面,顯示升帶進度預測
訓練正往哪裡去。
Randori 升帶慶祝畫面
那一天到來時,App 知道它是怎麼換來的。
設計

筆墨寫成,而非排版

這枚圖示是真正的書法——乱取り(randori),以墨汁揮毫在和紙上。淺色圖示是墨跡本身;深色圖示則是同一筆畫的白色版本,如筆墨的負片。整個標誌沒有一處出自字型。

App 的其餘部分也守著同樣的紀律。唯一的紅是印章紅,用法一如判子:極少出現,一出現就有分量。腰帶顏色承載段位,因為在道館裡本來就是如此。圖示另外還有八款筆意(鳥居、判子、朱印、日、雨、浪、雀、山稜),讓您的主畫面自己選天氣。

乱取り(randori),以墨筆揮毫
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
App 內建八款替代圖示:鳥居、判子、朱印、日、雨、浪、雀、山稜。
夥伴與架構

道館是由人組成的。這個 App 也是。

訓練從來不是一個人的事,日誌也不該裝作是。與真正跟您實戰的夥伴連結、分享課程——這層社交功能建立在 CloudKit 的公用資料庫上。不需要帳號,訓練資料留在您自己的 iCloud。唯一會傳回工作室的,只有濫用檢舉與匿名使用次數,兩者都不含您的訓練內容。

iPhone + Apple WatchSwiftUI · HealthKit · 即時心率
CloudKit公用資料庫 · 免帳號
檢舉941 遙測 · 審核

有社群功能,就有社群責任:使用者檢舉自 1.0 起內建,並接上工作室自有的審核工具。

在公用資料庫上做審核,本質是完整性問題,App 也就把它當完整性問題來處理。每一筆封禁都以工作室金鑰簽署,每個用戶端都會先驗證 P256 簽章才執行動作;偽造的紀錄過不了驗證,直接被忽略。當審核清單連不上時,App 選擇失效開放(fail open):動態照常運作,因為網路打個嗝,不該讓整間道館噤聲。

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。連匿名使用遙測對「一次使用」的定義,都跟訓練日誌一個樣——一段密集的活動,以半小時的安靜作結:

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

上市即支援十二種語言,從日文到阿拉伯文——因為這項運動的語彙早已遍布全球,App 也理當在那裡與它相會。

您的訓練,終於算數了。

Randori 1.0 現已於 App Store 推出 iPhone 版本,並支援 Apple Watch 即時訓練。