수업이 끝난 매트 위에서, 나를 위해 만든 앱
저는 주짓수를 해요. 대부분의 밤은 똑같이 끝나요. 매트 가장자리에 앉아, 지난 한 시간 동안 실제로 무슨 일이 있었는지 떠올려 보는 거죠. 어떤 롤링이 체력을 바닥냈는지, 어떤 암바에 두 번이나 걸려들었는지, 아직 귀에 들어오던 30초 동안 코치님이 뭐라고 했는지.
써 본 앱들은 제가 러너이길 바랐어요. 노트는 흠뻑 젖어 버렸고요. 그래서 늘 찾았지만 어디에도 없던 것을 직접 만들었어요. 주짓수의 언어로 말하는 로그 — 강도, 포지션, 서브미션, 내 말로 쓰는 노트 — 그리고 오늘 밤의 실수를 내일의 학습 계획으로 바꾸는 로그요.
그게 제품 기획서의 전부예요. 앱의 모든 것은 수업 후 세 가지 질문 중 하나에 답하고, 답하지 못한 것은 전부 잘라냈어요.
하룻밤 훈련의 물리적 진실
세션을 시작하면 롤링하는 동안 Watch(또는 페어링한 가슴 스트랩)가 심박수를 실시간으로 흘려보내요. 강도는 라운드마다, 롤링 사이 몇 초 안에 기록해요 — 엄지 하나로, 숨김없이 정직하게. 수업이 끝나면 그날 밤의 진짜 윤곽이 남아요. 얼마나 격했는지, 얼마나 길었는지, 체력이 얼마나 남았는지.
같은 기술에 계속 당하는 일, 이제 그만
이 기능을 얻으려고 앱을 만들었어요. 무슨 일이 있었는지 내 말로 적으면 — 드롭다운이 아니라 노트예요 — Randori가 그것을 배울 가치가 있는 인스트럭터들의 영상 기반 레슨과 맞춰 줘요. 암바에 걸렸다면 그 방어법을 보고, 그다음엔 카운터의 카운터까지.
클로즈드 가드에서 암바에 걸렸는데 팔꿈치를 제때 빼지 못했다
스태킹 암바 디펜스
매칭은 내 iPhone 위에서 일어나요 — 노트를 이해하겠다고 기기 밖으로 보내는 일은 없어요. 라이브러리는 화이트 벨트부터 블랙 벨트까지 이어지니, 답은 지금의 내 수준에서 나를 만나요.
iPhone에 Apple Intelligence가 있으면 온디바이스 언어 모델이 노트를 읽고 딱 하나의 좁은 질문에만 답해요. 내가 당한 기술인가, 내가 건 기술인가? 모델은 이미 존재하는 두 페이지 중 하나를 고를 뿐이라, 잘못 읽어도 조금 빗나갈 뿐 없는 걸 만들어내진 않아요. Apple Intelligence가 없다면? 보정된 휴리스틱이 이어받아요. 어느 쪽이든 노트는 폰을 떠나지 않아요.
/// 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는 스스로 통제할 수 있는 것을 세어요. 세션이 쌓여 훈련이 어디로 향하는지에 대한 정직한 예측이 되고, 긴 길 위에 이정표가 생겨요.
활자가 아니라, 붓으로
아이콘은 진짜 붓글씨예요 — 와시 종이에 먹으로 쓴 乱取り, 란도리죠. 라이트 아이콘은 먹 그대로이고, 다크 아이콘은 같은 획을 흰색으로 뒤집은 붓의 네거티브예요. 이 마크 어디에도 폰트는 없어요.
앱의 나머지도 그 절제를 그대로 지켜요. 빨강은 낙관 하나뿐이고, 한코를 찍듯 써요 — 드물게, 그리고 진심일 때만. 벨트 색이 랭크를 말하는 건 체육관에서 이미 그렇기 때문이죠. 아이콘은 여덟 가지 필치(토리이, 한코, 낙관, 해, 비, 파도, 참새, 능선)로도 함께 제공되니, 홈 화면이 저마다의 날씨를 고를 수 있어요.
체육관은 결국 사람이에요. 이 앱도 그래요.
혼자 훈련하는 게 아닌데, 로그가 혼자인 척해선 안 되죠. 실제로 함께 롤링하는 사람들과 연결해 세션을 공유하세요 — CloudKit 공개 데이터베이스 위에서 돌아가는 소셜 레이어예요. 계정은 없고, 훈련 데이터는 내 iCloud 안에 머물러요. 스튜디오에 도달하는 건 신고와 익명 사용 집계뿐이고, 어느 쪽에도 훈련 내용은 담기지 않아요.
커뮤니티 기능에는 커뮤니티에 대한 책임이 따라요. 사용자 신고 기능은 1.0부터 탑재됐고, 스튜디오 자체 모더레이션 도구와 연결돼 있어요.
공개 데이터베이스 위의 모더레이션은 무결성 문제이고, 앱도 정확히 그렇게 다뤄요. 모든 차단은 스튜디오 키로 서명되며, 각 클라이언트는 그 P256 서명을 검증한 뒤에만 조치를 따라요. 위조된 레코드는 검증에 실패하고 무시돼요. 모더레이션 목록에 닿을 수 없을 때 앱은 열린 쪽으로 실패해요. 피드는 계속 돌아가요 — 네트워크가 잠깐 흔들렸다고 체육관이 조용해져서는 안 되니까요.
/// 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. } }
작고 정직한 장치
iPhone과 Watch 모두 SwiftUI예요. 세션은 개인 iCloud를 통해 동기화되고, Apple Health에 실제 운동으로 기록돼요. 익명 사용 텔레메트리조차 세션을 훈련 로그의 방식으로 정의해요 — 한바탕의 활동이, 30분의 고요로 끝나는 것:
/// 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 라이브 세션도 함께요.