Randori

柔术训练日志。因为我自己在练,所以做了它。

我试过的健身应用都把柔术归进其他一栏——身体所能进行的最艰苦的一小时训练,被记成一笔卡路里估算。下课后我想知道三件事:今晚耗了我多少,我被什么抓住了,以及怎样确保下次不再被抓住。Randori 就是这三个问题的认真作答。

Download on the App Store
Randori 应用图标(浅色):以墨在和纸上挥毫写就的“乱取り”
浅色模式
Randori 应用图标(深色):同一笔迹以白墨落于黑底
深色模式
缘起

我为自己做的——在垫上,在课后

我练柔术。大多数夜晚都以同样的方式结束:坐在垫子边上,努力回想过去一小时究竟发生了什么——哪几局实战把体力彻底掏空,哪个十字固我连着中了两次,教练在我还听得见他说话的那 30 秒里讲了什么。

我试过的应用都希望我是个跑步的人。纸质笔记本被汗水泡烂。于是我做了那个我一直伸手去找、却始终找不到的东西:一本会说柔术语言的日志——强度、位置、降服、用自己的话写的笔记——并把今晚的失误变成明天的研究计划。

这就是全部的产品需求。应用里的每一样东西都在回答我课后三问之一;答不上的,一律砍掉。

训练课

一晚训练的身体真相

开始一堂课,Apple Watch(或已配对的心率胸带)会在你实战时实时传输心率。强度按局记录,就在两局之间的几秒钟里完成——一根拇指就够,坦诚得不留情面。下课时,这一晚的真实轮廓已经在你手里:多辛苦、多久、体力还剩几分。

iPhone 上的 Randori 正显示一堂进行中的训练课,含心率、强度与课时计时
动态流里一堂已记录的课:影片、心率、消耗。
Randori 训练视图,按局记录强度
“训练”标签页:点一下开练,顺带看到本周至今的记录。
Randori 残影视图,在课后汇总整堂训练
残影:一切结束之后,这堂课的代价。
从实战中学习

别再被同一招抓住

这正是我做这个应用想换来的功能。你用自己的话写下发生了什么——是一段笔记,不是下拉菜单——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 统计的是你能掌控的部分:一堂堂课累积成一份诚实的预测,指出你的训练正走向何处,让漫长的路有了里程碑。

白带
蓝带
紫带
棕带
黑带
应用亲笔画下的各段位——进度预测用的正是这同一套腰带图。
Randori 段位视图,显示腰带进度预测
训练正走向何处。
Randori 晋升庆祝画面
那一天真正到来时,应用清楚这一路付出了什么。
设计

写出来的,不是排出来的

这个图标是真正的书法——“乱取り”,也就是柔道所说的乱取(randori),以墨挥毫写在和纸上。浅色图标是墨迹本身;深色图标是同一笔画的白色呈现,恰似笔锋的负片。整个标识中,没有一笔出自字体。

应用的其余部分延续了这份克制。全应用只有一种红——印泥红,用法一如印章:很少落下,一落必有其意。腰带颜色承载段位,因为在道馆里它们本来就承载着。图标另备 8 种笔意(鸟居、印章、朱印、日、雨、浪、雀、山脊),让你的主屏幕自己挑天气。

以墨挥毫写就的“乱取り”(randori)
Torii Hanko Seal Sun Rain Waves Sparrows Ridge
应用内置 8 款备选图标:鸟居、印章、朱印、日、雨、浪、雀、山脊。
搭档与架构

道馆由人组成。这个应用也是。

训练从来不是一个人的事,日志也不该装作是。与真正和你实战的人建立联系、分享训练——这层社交功能运行在 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 与 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"
        }
    }

首发即支持 12 种语言,从日语到阿拉伯语——这项运动的词汇本就通行全球,应用理应到那里与它相会。

你的训练,终于算数。

Randori 1.0 现已登陆 App Store(iPhone 版),并支持 Apple Watch 实时训练。