Randori
Dziennik treningowy jiu-jitsu. Powstał, bo sam trenuję.
Aplikacje fitness, których próbowałem, wrzucają jiu-jitsu do kategorii Inne — godzina najcięższego treningu, jaki ciało może znieść, zapisana jako zgadywanka kaloryczna. Po zajęciach chciałem wiedzieć trzy rzeczy: ile kosztował mnie dzisiejszy wieczór, co mnie złapało i jak sprawić, żeby nie złapało mnie znowu. Randori to te trzy pytania — z porządną odpowiedzią.
Zbudowałem to dla siebie — na macie, po zajęciach
Trenuję jiu-jitsu. Większość wieczorów kończy się tak samo: siedzę na skraju maty i próbuję sobie przypomnieć, co właściwie wydarzyło się przez ostatnią godzinę — które sparingi opróżniły bak, w którego armbara wszedłem dwa razy, co powiedział trener w tych trzydziestu sekundach, kiedy jeszcze go słyszałem.
Aplikacje, których próbowałem, chciały, żebym był biegaczem. Notesy nasiąkały potem. Zbudowałem więc to, po co ciągle sięgałem i czego nie mogłem znaleźć: dziennik, który mówi językiem jiu-jitsu — wysiłek, pozycje, poddania, notatki własnymi słowami — i zamienia dzisiejsze błędy w jutrzejszy plan nauki.
To cały brief produktu. Wszystko w aplikacji odpowiada na jedno z moich trzech pytań po zajęciach, a co nie odpowiadało — zostało wycięte.
Fizyczna prawda treningowego wieczoru
Wystarczy rozpocząć sesję, a Watch (lub sparowany pas piersiowy) przesyła tętno na żywo podczas sparingu. Wysiłek zapisuje się runda po rundzie, w sekundach między sparingami — jednym kciukiem, szczerze do bólu. Po zajęciach zostaje prawdziwy kształt wieczoru: jak ciężko, jak długo i ile zostało w baku.
Dość łapania się na to samo
To jest ta funkcja, dla której zbudowałem całą aplikację. Zapisujesz własnymi słowami, co się wydarzyło — notatka, nie lista rozwijana — a Randori dopasowuje do niej lekcje z filmami od instruktorów, od których warto się uczyć. Złapali Cię w armbara? Zobacz obronę. A potem kontry na kontry.
złapali mnie w armbara z gardy zamkniętej, nie zdążyłem cofnąć łokcia
Obrona przed armbarem przez stackowanie
Dopasowanie odbywa się na Twoim iPhonie — notatki nie muszą opuszczać urządzenia, żeby zostać zrozumiane. Biblioteka obejmuje wszystko od białego pasa po czarny, więc odpowiedź spotyka Cię na Twoim poziomie.
Jeśli na telefonie działa Apple Intelligence, lokalny model językowy czyta notatkę i odpowiada na jedno wąskie pytanie: czy ta technika została wykonana na Tobie, czy przez Ciebie? Wybiera wyłącznie między dwiema stronami, które już istnieją, więc błędne odczytanie to co najwyżej nietrafienie, nigdy zmyślenie. Brak Apple Intelligence? Wtedy pracę przejmują skalibrowane heurystyki. Tak czy inaczej, notatka nigdy nie opuszcza telefonu.
/// 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 }
Pas, uczciwie policzony
Postępy w jiu-jitsu są notorycznie nieprzejrzyste — lata między pasami, promocje, które przychodzą, kiedy przyjdą. Randori liczy to, na co masz wpływ: sesje składają się na uczciwą prognozę tego, dokąd zmierza Twój trening, żeby ta długa droga miała swoje kamienie milowe.
Namalowane, nie złożone czcionką
Ikona to prawdziwa kaligrafia — 乱取り, randori, nakreślone pędzlem tuszem sumi na papierze washi. Jasna ikona to tusz; ciemna — to samo pociągnięcie bielą, negatyw pędzla. W całym znaku nie ma ani jednej czcionki.
Reszta aplikacji trzyma tę samą dyscyplinę. Jedna czerwień — pieczęć — używana tak, jak używa się hanko: rzadko i tylko wtedy, gdy naprawdę ma coś znaczyć. Kolory pasów niosą stopień, bo na sali robią to od zawsze. A ikona występuje w ośmiu dodatkowych odsłonach (torii, hanko, pieczęć, słońce, deszcz, fale, wróble, grań), więc ekran początkowy może wybrać sobie własną pogodę.
Sala to ludzie. Aplikacja też.
Nie trenujesz w pojedynkę — i dziennik nie powinien udawać, że jest inaczej. Łączysz się z ludźmi, z którymi naprawdę stajesz na macie, i dzielisz się z nimi sesjami — warstwa społecznościowa działa na publicznej bazie danych CloudKit. Bez kont, a dane treningowe zostają w Twoim iCloud. Do studia trafiają wyłącznie zgłoszenia nadużyć i anonimowe liczniki użycia — i żadne z nich nie niesie Twojego treningu.
Funkcje społecznościowe niosą ze sobą społecznościowe obowiązki: zgłaszanie użytkowników weszło już w wersji 1.0, podpięte pod własne narzędzia moderacyjne studia.
Moderacja w publicznej bazie danych to problem integralności — i aplikacja tak go traktuje. Każdy ban jest podpisany kluczem studia, a klient weryfikuje ten podpis P256, zanim zastosuje blokadę. Sfałszowany rekord nie przechodzi weryfikacji i jest ignorowany. Gdy lista moderacyjna jest nieosiągalna, aplikacja przechodzi w tryb fail open: feed pracuje dalej, bo chwilowa czkawka sieci nigdy nie powinna uciszyć sali.
/// 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. } }
Mała, uczciwa maszyneria
SwiftUI na iPhonie i na Watchu. Sesje synchronizują się przez Twój prywatny iCloud i zapisują w Apple Health jako prawdziwe treningi. Nawet anonimowa telemetria użycia definiuje sesję tak, jak zrobiłby to dziennik treningowy — seria aktywności zakończona półgodziną ciszy:
/// 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" } }
Dwanaście języków od premiery, od japońskiego po arabski — bo słownik tego sportu jest już globalny, a aplikacja powinna wyjść mu naprzeciw.
Twój trening, wreszcie policzony.
Randori 1.0 czeka w App Store na iPhone'a — z sesjami na żywo na Apple Watch.