Handoff β 2026-07-12
Frozen snapshot β do not update. Kept for how a decision was reached, not for what is true now. Current state lives in Architecture β Current State.
Audience: a new engineer or agent taking over AceSense with zero prior context.
Written: 2026-07-12. Backend HEAD: acesense-gpu-backend @ 4969bd2 (pushed, auto-deployed).
Read this top to bottom before touching anything. It explains what the product is, what shipped in July, what is proven vs. still open, the exact state of production and live infrastructure, and the conventions this codebase runs under. A first-hour checklist is at the end.
One thing to internalize first: the older
HANDOFF.md(dated 2026-07-09) opens by saying "every Firebase deploy is blocked on expired CLI credentials." That is stale. Akshay reauthed on 2026-07-10 and Wave 1 was deployed and hash-verified to prod that day; another design-audit wave deployed 2026-07-11. TreatHANDOFF.mdas a historical record of what shipped, not of what is blocked. This document supersedes it.
1. Product and repository mapβ
What AceSense isβ
AceSense is an AI tennis-analysis product. The user flow:
Athlete uploads a match video (Flutter app)
β
βΌ
Firebase Functions dispatch the job β RunPod GPU worker
β
βΌ
GPU/ML pipeline (PyTorch) extracts: shots, stroke types, pose, ball track,
court, bounces, landings, heatmaps, per-shot clips
β
βΌ
Analysis JSON + PDF report + LLM coaching insights written back to Firebase
β
βΌ
Flutter app shows shots / overlays / insights; React admin console manages jobs
The repos (all under AcesenseProd/, each its own git repo)β
There is no umbrella repo. Each of the ~10 repos has its own .git, sits on branch main,
and has remote git@github-acesense:Acesense/<name>.git. Commit per repo β never one
cross-repo commit.
| Repo | Stack | Role | Matters most? |
|---|---|---|---|
| acesense-gpu-backend | Python ~30k LOC, PyTorch, RunPod | The ML analysis pipeline. The heart of July's work. | Yes |
| acesense-frontend | Flutter/Dart ~43k LOC (Riverpod, go_router, Firebase) | The athlete-facing mobile/web app | Yes |
| acesense-auth-function | Node/TS Firebase Functions ~8k | Callables: admin, api, coach, feedback, video, insights (LLM + PDF) | Secondary |
| acesense-admin | React/Vite/TS ~12k | Admin dashboard (jobs, users, moderation) | Secondary |
| acesense-landing | React/Vite | Marketing site (SEO prerender) | Low |
| acesense-docs | Docusaurus + Firebase functions | Docs site (admin-locked auth gate) | Low |
| acesense-annotate | Tauri (React/TS + Rust) | Internal labelling tool (App.tsx is a 1135-line god component) | Low |
| acesense-launchpad | React/Vite | Investor/pitch site (own CLAUDE.md) | Low |
| acesense-api-hosting | Firebase hosting shim | Rewrite shim | Low |
| acesense-brand | brand-book, logos, tokens/tokens.css | Design tokens (hand-copied into 5+ consumers) | Low |
plan/, screenshots/, .tmp-crops/ at the product root are scratch, not repos.
Deploy model (know this cold)β
| Target | How it deploys |
|---|---|
| acesense-gpu-backend | Push to main auto-deploys to RunPod. No Firebase. git push == deploy. |
| Everything else (frontend, functions, admin, landing) | Manual Firebase CLI (firebase deploy). Project = acesense-prod. |
Firebase specifics: hosting targets app β acesense-prod-app (acesense-prod-app.web.app),
landing β acesense-prod (acesense-prod.web.app). Functions codebase is literally named
functions, so single-function deploy is --only "functions:functions:<name>". All callables
region europe-west1. Deploy order when multiple repos change: backend functions β frontend β
admin β landing (UIs call callables that must already exist).
2. What was done in July 2026 (newest first)β
Each item lists WHAT WORKED and WHAT DIDN'T / WATCH OUT.
2a. 2026-07-12 β Unified pipeline (commit 4969bd2, pushed)β
pipeline_v2 was folded into process_video as a stage between event detection and
enrichment. Previously v2 ran as a post-hoc second pass that produced a separate
_analysis_v2_merged.json which the cloud hook swapped in. Now there is one pipeline, one
analysis JSON.
How it works (src/pipeline_v2/unified.py::apply_unified_shots):
- Runs v2 segment-first machinery (scene segmentation β gameplay-gated pose pass β kink+wrist contact detection β STGCN++ stroke classification) on the in-memory v1 outputs.
- Replaces the v1 event-detector shot list with v2 contacts + stroke labels, written back
in the v1 shot schema. Player mapping:
nearβbottom/player_id 2;farβtop/player_id 1. - v1 enrichment (
pose_sequence,swing_data,landing,hit_position) then runs natively on the v2 shots β full field parity, plusclassification.{type,confidence,probabilities}(6-class) and av2sidecar block (detection_confidence,channel). main.pycloud hook skips its legacy two-pass swap when unified shots are present.- Kill switch:
UNIFIED_PIPELINE=0reverts to v1 shots (and the legacy two-pass hook takes over). Fail-open: any exception leaves the v1 shots untouched. - A guard in
shot_enrichment.pystops the legacy ONNX pose classifier from overwriting the STGCN++ label on v2 shots (checksdetection_method == "pipeline_v2").
WHAT WORKED (A100-verified):
- 1-min video: 5 v2 shots replaced 9 v1 shots, all enriched 5/5. Hook-skip produced a byte-identical JSON.
- 5-min match: 79 shots (34 forehand / 22 serve / 18 backhand / 5 unknown); pose 78/79, swing 79/79, landing 67/79; e2e 265s.
- Golden gate PASS; pytest 517 pass.
WHAT DIDN'T / WATCH OUT:
- 3 pre-existing pytest failures remain (NOT caused by this work β verified pre-existing at HEAD
via
git stash):tests/insights/test_phase6_insights.py(Γ2) andtests/pipeline/test_phase6_outputs.py::test_player_crop_rect_maps_player_id_to_top_or_bottom.
2b. 2026-07-12 β GPU optimization wave (commit ec2d7c6, pushed)β
The pipeline was single-core CPU-bound: aggregate CPU ~3% on 30 cores (= 1 core pegged), GPU sitting at 0β19%. Root cause: every batch was decoded/resized/stacked/transposed on the CPU strictly before the GPU ran, so preprocess and inference never overlapped.
Fix β new src/utils/frame_feed.py producer/consumer feeder:
- Producer thread (CPU): decode (
cv2.VideoCapture) or slice in-memory frames +cv2.resizeto uint8 BGR. - Consumer (GPU): pinned non_blocking upload β 3-frame stack + HWCβCHW +
float()/255on device. - Numerical parity: frames stay uint8 until on-device; resize deliberately stays
cv2in the feeder (cv2's fixed-point uint8 resize is NOT bit-reproducible withF.interpolate). On-device float/255 + concat are elementwise IEEE ops β bit-identical to the numpy path (torch.equalverified).
Per-stage results (samp_match_03_5min, A100-40GB):
| Stage | Before β After | Equivalence |
|---|---|---|
| Ball (TrackNet) | 53 β 152 fps | bit-identical (dmax 0.0 px) |
| Players | 65 β 239 fps | identical on 1500 real-matrix frames |
| Court | ~1.1Γ only | nondeterministic (see below) |
v2 run_pose_pass | 62 β 89 fps | wrists/kpts/scores bit-identical |
| End-to-end 5-min | 459s β 185s (2.5Γ) | all 77 shots field-for-field identical |
Key findings:
- The player-stage cost was NOT YOLO (258 fps). It was two per-frame full-res court-mask
warpPerspectivecalls in the tracking tail (~16 ms/frame). Replaced bysrc/players/mask_sampling.py::warped_mask_at_pointsβ point sampling viacv2.remap(same fixed-point bilinear engine), identical outputs. - Court model is nondeterministic run-to-run under fp16 autocast: 806/1500 frames differ old-vs-old. Never expect bit equality downstream of court β use a noise floor.
WHAT DIDN'T / left deliberately:
- TrackNet batch 8 is FASTER than batch 64 on A100 (41s vs 47s). The
auto_batch_sizeceiling of 64 is counterproductive here. Left unchanged deliberately (unverified to change safely) β a candidate micro-tune.
2c. Earlier July waves (summary)β
2026-07-11 β pipeline_v2 built (5 parallel agents, all 27 planned improvements), merged
549afbb..3273e0b, auto-deployed. Everything lives in src/pipeline_v2/:
segmentation.pyβ one-pass NVDEC decode: scene cuts + dissolves + gameplay probe.contact_detection.py+contracts.pyβ kink+segment+wrist contact logic, per-segment health routing, evidence-only gates, attribution, confidence.classifier.pyβ STGCN++ TorchScript stroke classifier.orchestrator.py,grammar.py(Viterbi rally decoder),traj_features.py(serve-variant separators),regression/golden_run.py(CI gate),cropsheets.py,flywheel.py,gemini_check.py.- A100-verified: golden gate PASS; tennis_1 e2e contacts exact with correct near/far attribution.
Flywheel round 1 β Gemini-labeled 662 contact windows across 6 videos (~86% purity).
Fine-tuned STGCN++ from 12 THETIS classes β 6 product classes. Holdout agreement with Gemini
20.0% β 48.8% (still climbing at ep25 β more labels = more gain). Shipped
games/tennis/models/stgcnpp_product.pt (+ .labels.json); classifier.py prefers it when
present (commit 3273e0b). Both files verified present in the repo.
Cloud auto-v2 hook (commit 891e2ee) β uploaded analysis JSON auto-gets pipeline_v2 applied
(now largely superseded by the unified pipeline, retained as the UNIFIED_PIPELINE=0 fallback).
THETIS action-model R&D (H100) β PoseC3D won in-domain (73.6% subject-independent), but
STGCN++ wins cross-domain (98% serve recall vs PoseC3D 58% on real US-Open pose) because its
PreNormalize2D is translation/scale-invariant and travels across the broadcast/phone domain.
Ship STGCN++ for real footage. See acesense-action-model-thetis.md.
Shot-contact detection R&D β F1 1.0 on broadcast; ~0.87β0.93 verified precision across 6
videos / 4 domains. Ball-centered crop sheets are the verification standard (contact happens
where the ball is). See acesense-shot-detection-kink.md.
Design-audit wave (2026-07-11) β 20+ UI fixes across 4 repos, deployed same day. See
acesense-design-audit-wave.md.
Insights + PDF moved to a Cloud Function β generateSessionInsights in
acesense-auth-function/insights/. Gemini AQ.-format key works only via REST
generativelanguage.googleapis.com/.../generateContent with the X-goog-api-key header β the
@google/genai SDK 401s it. Use fetch, not the SDK. See acesense-insights-report-function.md.
3. What did NOT work / known failure modesβ
Be honest with these; they cost real time and will bite again.
Environment / dependency trapsβ
- decord segfaults on the GPU boxes β read frames with cv2, never decord.
- mmpose is blocked by chumpy (
setup.pydoesimport pip). Use ultralytics YOLO11x-pose for pose extraction instead. - onnxruntime-gpu pulls a CUDA-13
libcudart(boxes are CUDA 12.8). Avoid it. - numpy must be pinned
1.26.4LAST with--ignore-installedβ mmaction/other installs re-bump it to 2.x and break ABI. - scipy 1.15 wheels break with numpy 1.26 on py3.10 β use scipy 1.13.1.
- Lambda boxes: system pip has an invalid
flatbuffersdistro version that breaks pip; avenv --system-site-packagesproduces ABI soup (scipy/numpy_fitpackTypeError). Always use an isolated venv. System ffmpeg has NO CUDA hwaccel.
SSH gotchasβ
pkill -f <pattern>over ssh: if<pattern>appears anywhere in the remote command line (including laternohupargs), it kills its own session. Split into separate ssh calls.- Multiline
python -c '...'inside ssh single-quotes hangs. Alwaysscpa script file and run it.
Algorithm / domain failure modesβ
- Homography explodes to Β±300m on court-level footage exactly at contacts. Physics-bounds
hygiene exists (drop court samples outside
x[-6,17] y[-8,32], cap velocity 60 m/s). court_ydepth is garbage on court-level (behind-baseline) footage β the homography's ill-conditioned axis. Both detectors go mediocre there. Production fix path: image-space ball kinematics for near-player contacts + audio onset detection (geometry-free ~10 ms).- Gameplay gate needs
court_frames β₯ 100to activate, else it nukes court-level recall to 0 (the court detector barely fires from behind the baseline, so the gate must self-disable there). - Scene-cut gate must NEVER touch ball-classified hits β a 6Ο "cut" on broadcast was a fast pan and deleted a real hit. Gate wrist-adds only, and reject only on positive evidence (ball tracked AND far), never on missing data.
- Residual known false-positive class = ball-juggle-while-walking β kinematically inseparable from a soft feed; belongs to rally/point segmentation, not kinematics.
- THETIS "skeleton" folders are rendered videos, not joint data β extract your own pose.
- Published THETIS numbers leak subjects (3 near-identical reps per player). Always evaluate leave-players-out (subject-independent).
4. Current production stateβ
| Thing | State |
|---|---|
| acesense-gpu-backend | main @ 4969bd2 β auto-deployed to RunPod on push. Working tree clean. |
| Unified pipeline | Default ON. Kill switch UNIFIED_PIPELINE=0. Fail-open to v1 shots. |
| Frontend | Needs NO changes. The model is defensively nullable and reads classification.type etc.; unknown/missing fields degrade gracefully. |
| Firebase deploys | Not blocked. The 2026-07-09 expired-CLI-creds blocker was resolved 2026-07-10; Wave 1 + the design-audit wave are live and hash-verified. (Re-check acesense-fix-sweep-pending-deploy.md if creds seem stale again.) |
| Deployed URLs | app acesense-prod-app.web.app, admin acesense-admin.web.app, landing acesense-prod.web.app, health https://health-p2byrhfnxq-ew.a.run.app. |
Local artifacts store (canonical, not in any git repo):
~/Desktop/Workspace/RunningProjects/acesense/tennis-action-artifacts/
Contains: checkpoints/ (exported models), shot-detection/ (the ONLY canonical copy of the
shot-detection R&D scripts β a scratchpad wipe once destroyed them, so keep patches here),
pipeline-v2-verify/ (labels.jsonl = 662 Gemini labels, finetune_product.py,
flywheel_label.py, verification evidence, stgcnpp_product.pt).
5. Live infrastructure RIGHT NOW (costs money)β
A Lambda A100 box is still running with the full rig:
- Host:
ubuntu@132.226.88.61(ssh key~/.ssh/id_ed25519_lambda) ~/gpu-backendβ synced working tree (post-optimization)~/gpu-backend-baselineβ pre-optimization tree (for equivalence gating)~/venvβ isolated: torch 2.7.0+cu126, numpy 1.26.4, scipy 1.13.1, ultralytics, mediapipe 0.10.5~/videos_tmp_dir/βtennis_1.mp4,samp_match_02_1min.mp4,samp_match_03_5min.mp4~/prof/β all equivalence/verification logs + scripts
This box costs money. Terminate it via the Lambda API when it is no longer needed β this is Akshay's decision, not an automatic one. Do not terminate without confirming.
6. Open items / next stepsβ
| Item | Notes |
|---|---|
| Flywheel crank 2 | More Gemini labels β retrain STGCN++ toward 90%+ holdout agreement. Tools: finetune_product.py + flywheel_label.py in tennis-action-artifacts/pipeline-v2-verify/. Current agreement is 48.8% and was still climbing β this is the biggest accuracy lever. |
| Watch first real cloud job | Tail the first real RunPod cloud job logs for the unified-stage line unified: v2 shots active. Confirms the unified pipeline fires in prod, not just on the A100. |
| Frontend surfacing (optional) | Consider showing v2 detection confidence + the 6-class probabilities in the UI. Backend already emits them (classification.probabilities, v2.detection_confidence). |
| TrackNet batch ceiling | Micro-tune the auto_batch_size ceiling (8 vs 64 on A100). Unverified β gate it. |
| e2e tail | Remaining time is video loading (~18s) + output generation (~30s) + enrichment (~25s). Diminishing returns; only chase if needed. |
| Landing legal facts | Registered entity name + address, Delaware-Inc vs "EU company", "no data leaves the EU" vs US-servers+SCC. Founder-decision, agent must not invent. See HANDOFF.md Β§6. |
| Wave 2 backlog | Notifications (push needs APNs/VAPID keys), storage retention, App Check (needs reCAPTCHA v3 key), full l10n. See HANDOFF.md Β§7. |
| Payments | Explicitly excluded by Akshay. Do not implement IAP/payments. |
7. Working conventions (non-negotiable)β
From acesense-working-style.md β "Ponytail mode":
- Boring, small, verified changes. Prefer the smallest cut that satisfies the item. No new frameworks/DSLs/codegen unless a smaller fix genuinely can't work. Do not convert to a monorepo.
- One commit per repo per logical change. Never a cross-repo commit. Push only when asked.
- Every optimization is equivalence-gated: run OLD twice first for the noise floor, then compare old-vs-new. Court/anything downstream of it is nondeterministic β use the noise floor, never expect bit equality.
- Ball-centered crop sheets are the visual-verification standard for detections.
- Never fabricate. Real numbers/identity/scores or an honest empty state β this is a hard
product rule (
kUseMockDatais the only mock path; injury//bodycontent must never make medical claims). - Never fabricate test runners. Backend uses pytest; frontend uses Flutter
(
flutter analyze+flutter test); TS repos use pnpm + vitest. There is nopnpm linton the backend, etc. β use what each repo actually defines. - Agents never
git pushwithout the verification battery passing first (golden_run + pytest on backend).
Reference material (don't duplicate β read these)β
src/pipeline_v2/CONTRACT.mdβ the module interface contract every v2 module obeys (fps-invariance, evidence-only gates, numpy+cv2+stdlib only in core, torch allowed only inclassifier.py).HANDOFF.mdβ the 2026-07-09 Firebase/frontend/admin/landing wave detail: exact field mappings, deploy sequence, decisions. Still-valid frontend field mapping and deploy mechanics live there.acesense-local-ui-rig.md(memory) β how to run all 3 UIs against the Firebase emulator and screenshot them headlessly, incl. emulator ports (auth 9099 / firestore 8080 / functions 5001 / storage 9199 / UI 4000), demo creds (athlete@demo.acesense.io/Demo1234!, adminadmin@acesense.io), and the headless-auth injection tricks.- The memory directory
(
~/.claude/projects/-Users-akshaysarode-Desktop-Workspace-RunningProjects-acesense-AcesenseProd/memory/) βMEMORY.mdis the index; the per-topic files are the deep record.
8. First-hour checklist for the new agentβ
- Read
src/pipeline_v2/CONTRACT.mdinacesense-gpu-backendβ it defines the shared types and the rules every v2 module follows. This is the single most important orientation file for the ML work. - Run the backend test suite:
pytestinacesense-gpu-backend. Expect 517 pass, 3 pre-existing failures (2Γtests/insights/test_phase6_insights.py, 1Γtest_phase6_outputs.py::test_player_crop_rect_maps_player_id_to_top_or_bottom). Anything beyond those three is a regression you introduced. - Run the golden CI gate:
python -m src.pipeline_v2.regression.golden_runβ it exits nonzero if tennis_1 F1 < 1.0 or rally F1 drops below baseline. This is the shot-detection safety net. - Check the RunPod dashboard for a real cloud job, and grep its logs for
unified: v2 shots activeto confirm the unified pipeline is firing in production (not just on the A100). - Decide the fate of the Lambda A100 box (
ubuntu@132.226.88.61) β it is running and costing money. Confirm with Akshay before terminating; terminate via the Lambda API when the verification rig is no longer needed.
End of handoff. When in doubt, prefer reading the memory files and CONTRACT.md over inferring
from code, and keep changes boring, small, and equivalence-gated.