Current State
Updated: 2026-08-17. This is the current orientation doc. Everything in ยง3 was verified by running the check on that date, not carried forward from notes; anything measured earlier is labelled with when.
Older handoffs, audits and briefs live in the Archive and are frozen snapshots. Do not update them โ read them for how we got here, not for what is true now.
1. Product (short)โ
AceSense = AI racket-sport analysis.
Flutter upload โ Firebase Functions โ GPU worker โ analysis JSON + clips + insights โ app
GPU/ML heart: acesense-gpu-backend (PyTorch). Capacity: the Lambda
pull-worker fleet is primary โ lambdaScalerTick launches one box per
uncovered queued job up to maxAutoInstances (Firestore config/gpuScaler),
across a region ladder. RunPod is fallback only and can run out of credit;
it is not the capacity plan. Multi-provider dispatch lives in
acesense-auth-function/video/gpu/.
Instances are named acesense-worker-auto-*. The sibling UNFORCE
product shares the same Lambda account and gcloud environment, so the prefix is
load-bearing: orphan reclaim scopes on it, and anything named unforce-* must
never be touched. gcloud config does not default to acesense-prod โ pin
--project acesense-prod in every script.
Repos under AcesenseProd/ are separate git repos (no umbrella). Commit per
repo. Remotes: git@github-acesense:Acesense/<name>.git.
| Repo | Role |
|---|---|
| acesense-gpu-backend | ML pipeline (critical) |
| acesense-frontend | Flutter athlete app (web + mobile) |
| acesense-auth-function | Firebase callables + GPU dispatch |
| acesense-admin / landing / docs / launchpad / annotate / brand / api-hosting | Secondary |
2. Pipeline (what runs)โ
Unified pipeline is default ON (UNIFIED_PIPELINE=1). Kill switch =0 โ
legacy v1 + post-hoc v2 swap.
Flow (sequential, 1 GPU):
- Video load (dual-res: original + 640ร360 model)
- Court (CourtNet, every 10th frame)
- Ball (TrackNet, all frames, batch capped at 8)
- Players (YOLO11m, all frames @ original res)
- Bounce / events
- Unified v2: segmentation โ gameplay-gated pose (YOLO11x-pose @960) โ contacts โ STGCN++
- Enrichment (MediaPipe / swing; ONNX classifier skipped for v2 shots)
- Outputs (JSON + shot dirs; clip ffmpeg on hot path)
Pose already skips non-gameplay. Ball + players do not โ segmentation runs too late to gate them.
Conventions: boring/small/verified changes; equivalence-gate optimizations; pytest on backend; never fabricate scores or identity.
3. Current state โ verified 2026-08-17โ
Repos and CIโ
All repos: dirty=0 unpushed=0 untracked=0, all on main.
CI runs locally โ GitHub Actions is retired for this org (2026-08-24).
Every gate the workflows used to run now lives in ci-local.sh at the family
root: per-repo lint/tests/coverage/build, the brand-separation gate (ported
into each repo as .ci/brand-separation.sh), and the gitleaks secret scan of
a HEAD export. Opt-in modes replace the scheduled/manual workflows:
FULL_HISTORY=1 (all-history secret scan), MUTATION=1 (auth-function
mutation gate), LOAD_SMOKE=1 (k6), RULES_EMULATOR=1 (Firestore rules).
Run it before every push; a red sweep is a red pipeline.
One deliberate standing red under FULL_HISTORY=1:
acesense-gpu-backendโ the pushed history contains a production GCP service-account key, an Anthropic key and two RunPod keys. The scan is correct. Rotate before rewriting history โ scrubbing first leaves live credentials in circulation with no record of where they were.unforce-gpu-backend's history was rewritten clean on 2026-08-16. See Secret Rotation.
Deploy โ read this before shipping anythingโ
All deploys are manual and local. The hosting-only deploy workflow that
used to run on v* tags (and failed every time for lack of credentials,
leaving the live build four versions behind main for two weeks) is gone with
the rest of GitHub Actions. Consequences:
- Git tags are release markers, not deploy triggers.
- Web deploys:
cd acesense-frontend && ./deploy.sh(needsAPPCHECK_RECAPTCHA_KEYโ a public reCAPTCHA v3 site key, not a secret; it is baked into the served bundle and recoverable from it). - App Check service enforcement:
./scripts/enforce-app-check.sh ENFORCE_SERVICESin the frontend repo, after service metrics are healthy. - Run
./ci-local.shgreen before any deploy.
What is liveโ
All hosting surfaces were deployed and verified on 2026-08-16 and tagged
deploy-2026-08-16 in their repos (date-based, because their package.json
versions are placeholders). Admin and landing were both stale before that โ
the served asset hash did not match a fresh build.
| Surface | State |
|---|---|
app.acesense.io / acesense-prod-app | 1.0.22, tag v1.0.22, CSP enforcing, 0 console errors |
acesense.io / acesense-prod.web.app | Landing, current. CSP report-only, never load-tested |
acesense-admin.web.app | Admin, current. CSP report-only, never load-tested |
acesense-launchpad | Hosting + functions:launchpad, current. 401 gate intact |
acesense-prod-docs | 401, auth-gated (expected) |
acesense-prod-api.web.app | Rewrite host, CSP enforcing |
Firebase Functions (functions codebase) | 64 deployed, current as of 2026-08-16 |
| Lambda GPU fleet | Scales to zero. Boxes launch per uncovered queued job and are reaped when idle โ verified 2026-08-17 with a live job: one tracked acesense-worker-auto-* launched, no orphan |
acesense-annotate | Tauri desktop app โ nothing to deploy |
The table records the last live verification. Source configs for landing and admin moved to enforced CSP on 2026-08-21, but those changes have not been deployed or checked through the custom-domain/CDN layer yet.
Functions are current. Deployed 2026-08-16, tag deploy-2026-08-16. The
per-box runtime budget and the acesense-worker-auto-* rename are running;
they had been stuck since 2026-08-03. retryInfraFailedJobs and
reconcileStuckJobs were redeployed 2026-08-17.
The ffmpeg -fps_mode probe is not a Functions change and was not live
with them โ it ships in acesense-gpu-backend, which deploys separately. See
below.
Worker code does not deploy with anything elseโ
Scaler-launched boxes clone nothing. Each Lambda region has its own
acesense-worker filesystem holding a plain copy of acesense-gpu-backend,
and the instance mounts it and runs bootstrap.sh. There is no git pull and
no CI step, so a merged commit is not running anywhere until it is copied
onto every region filesystem โ and nothing reports the drift.
Measured 2026-08-17, every filesystem reachable that day was the same four
files behind main: vfr_normalizer.py, video_encoding.py,
chunked_runner.py and tennis_analysis.py โ i.e. the ffmpeg frame-rate-mode
fix (c046848, d1d1edd, merged 2026-08-14) had never reached a worker
anywhere. File counts matched exactly, 121 either side with nothing missing or
extra, so the drift is purely un-shipped commits rather than local edits.
Verify a drop by checksum, not by assumption โ there is no deployed-commit
marker on the filesystem and no .codedrop_head in the repo:
ssh โฆ@<box> 'cd ~/acesense-worker/acesense-gpu-backend && find src games worker main.py requirements -type f \( -name "*.py" -o -name "*.sh" -o -name "*.txt" \) | sort | xargs md5sum'
A missing asenv/bin/python on a freshly launched box is expected, not a
broken region: the venv's interpreter is a symlink to /usr/bin/python3.10,
which bootstrap.sh apt-installs, and a maintenance box that never ran
bootstrap leaves it dangling.
| Region | Filesystem state |
|---|---|
us-east-1 | Current โ 166/166 files checksum-identical to main, 2026-08-20 |
us-west-1 | Current โ 166/166, 2026-08-20 |
us-southeast-1 | Current โ 166/166, 2026-08-20 |
us-south-2 | Current โ 166/166, 2026-08-20 |
That drop carried the five-module package split, so the file count moved from
121 to 166. It was applied with acesense-gpu-worker/scripts/fleet_sync.py,
which refuses to run while the control-plane snapshot has drifted from the
deployable repos, and verifies creds/ and asenv survive before reporting
success.
Two transport notes worth keeping, both measured on 2026-08-20:
- The cloudflared tunnel throttles to roughly 1.7 KB/s.
rsyncsends only differences so a normal drop is a minute or two, but a first-time region seed over this link is not viable โ and never targames/, which is 122 MB of model weights and would take a day. - Lambda recycles IPs. A maintenance box can come up on an address already in
known_hostswith a different key, andfleet_syncusesStrictHostKeyChecking=accept-new, which fails rather than replaces. Clear it withssh-keygen -R <ip>before blaming the tunnel.
us-south-2 appears in the scaler ladder โ including the only 2x rung โ so a
job placed there still runs the pre-2026-08-17 pipeline and can still hit the
GPU_REJECTED tolerance bug. Copy the four files when capacity returns; it had
none all day on 2026-08-17, while us-southeast-1 went from none to available
within the hour, so re-check rather than assuming.
Updating a filesystem means launching a box in that region that mounts it.
Prefix maintenance boxes acesense-maint- so the orphan reclaim, which is
scoped to acesense-worker-auto-*, leaves them alone โ and terminate them.
Copy individual files rather than rsync --delete: the filesystem also holds
creds/.acesense-worker-sa.json and the asenv virtualenv, and deleting
either takes the whole region's worker launches down.
APP_STORE_PRIVATE_KEY no longer blocks anything. Apple billing sits behind
ENABLE_APPLE_BILLING, off by default, so verifyApplePurchase and
appStoreServerNotification are not exported and the secret is never
declared. Neither had ever been deployed. Android store billing
(verifyGooglePlayPurchase, googlePlayBillingNotification) went live in the
same deploy and needs no secret โ it authenticates with ADC.
The trap worth remembering: gating the exports was not enough.
defineSecret() and defineString() register a parameter with the deploy
even when nothing exported consumes them, so the declarations themselves have
to be conditional. Two unshipped iOS entry points had held back 17 commits of
entirely unrelated work.
To enable iOS: set APP_STORE_PRIVATE_KEY in Secret Manager, then deploy
with ENABLE_APPLE_BILLING=true.
Functions cannot be partially deployed around the missing secret โ Firebase resolves secrets while analysing the whole codebase, so even a scaler-only deploy aborts.
Frontend healthโ
flutter analyze clean ยท 512 tests pass ยท dart format clean ยท
tool/l10n_audit.py reports 0 hardcoded user-facing strings ยท
firestore.indexes.json has 0 drift against production.
Localisation โ done 2026-08-16โ
Six locales (en de es fr it sv), 1542 keys each, no drift. The reported
bug ("still in English after switching") was two independent defects:
- ~280 literals never looked up at all, across 33 files.
- ~200 keys wired correctly but carrying the English string as their non-English value โ Badges, Roadmap, the Profile nav and Settings rendered fully English even though every call site was right.
Three catalogues cached copy behind a provider, so words resolved at build time
outlived a language switch. PlayerCoachingService, DrillItem and ShotType
now carry facts and stable ids; the widget layer turns them into words per
render. ShotType.stableName exists specifically because one lookup compared
against the English label โ localising it would have silently broken that
comparison in five locales.
tool/l10n_audit.py gates this in CI (between analyze and test). It is a
net, not a proof: it cannot see a literal reaching the widget through an
intermediate parameter โ that is how Good morning survived it. Render in
German and read the screen. See ยง5.
3a. Pipeline health (2026-08-17)โ
CI being green says the code compiles and the tests pass. It says nothing about whether real uploads succeed. On 2026-08-16, 40 of the last 60 jobs had failed. Four distinct causes, all now understood:
errorCode | Status |
|---|---|
GPU_JOB_FAILED | Fixed. RunPod removed; 14 stranded jobs auto-recovered |
CLIENT_UPLOAD_FAILED | Fixed in code, needs a Play release to reach users |
STUCK_JOB_TIMEOUT | Mostly a genuinely abandoned upload โ but not always, see below |
GPU_REJECTED | Was not the clip's fault. Fixed 2026-08-17, see below |
The failure counts, measured against Storage (2026-08-17)โ
Two of the four rows above were assumptions, and checking each failed job's
videoPath against the bucket disproved both. Over the preceding 60 days:
errorCode | n | video still in Storage |
|---|---|---|
GPU_REJECTED | 13 | 13 |
STUCK_JOB_TIMEOUT | 36 | 3 |
CLIENT_UPLOAD_FAILED | 40 | 2 |
GPU_JOB_FAILED | 1 | 1 |
"The upload never arrived" is right for most STUCK_JOB_TIMEOUT and
CLIENT_UPLOAD_FAILED jobs and wrong for a few, and it was never right for
GPU_REJECTED at all.
GPU_REJECTED โ integrity tolerances in the wrong unitsโ
All 13 were healthy videos. The CFR integrity check compared container duration
against frames / fps with a tolerance of frame_period * 0.002 โ 67 ยตs at
30 fps. Container duration is stored in time-base ticks, and Matroska (what the
chunked path writes) quantizes to 1 ms, so the bound sat well inside a single
tick and measured rounding rather than damage. Observed deltas ran 67 ยตs to
2.55 ms, the largest a seventeen-hundredth of one frame. One user re-uploaded
the same clip three times and was refused three times.
That bound also contradicted the frame-count check beside it, which forgives a
couple of frames of VFRโCFR resample drift โ tens of milliseconds of duration,
a thousand times the duration bound. CFR only ever passed because the relaxed
retry path re-validates without expected_duration_s at all.
Both bounds now derive from one allowance so they cannot disagree, with the self-consistency bound held strictly under one frame period (phones shoot slow motion at 120 and 240 fps, where a frame is shorter than a flat tick floor). All 13 production rejections are pinned as regression tests, along with negative controls โ a second of truncation is still 4ร the budget.
STUCK_JOB_TIMEOUT โ one was a lost Storage eventโ
Job rCl1eJ0lGcBoMV6hFyH6 sat pending with a complete 661 MB video in the
bucket, no uploadedAt and no preflight: processVideoOnUpload never fired
for it. Storage events are at-least-once, not exactly-once. The sweeper failed
it and told the user their upload never completed โ destroying a finished
upload rather than recovering one.
reconcileStuckJobs now checks Storage before failing an overdue pending
job and re-fires the finalize event when the object is there. That job itself
is already failed and needs a manual retry; the fix prevents the next one.
CLIENT_UPLOAD_FAILED โ "Bad state: Stream has already been listened to"โ
FileDownloader().updates is backed by a plain StreamController in the
plugin, not a broadcast one, so it accepts exactly one listener for the life
of the isolate. background_upload_io.dart subscribed per upload: the first
upload of a session worked and every one after it threw immediately โ including
the user's own retries, so a failed upload stayed failed until the app was
restarted. One user hit it five times in four minutes on a 1.5 GB clip.
Fixed by a single permanent listener feeding a broadcast controller. Cancelling does not help: re-listening to a single-subscription stream throws even after a clean cancel, so the source subscription is deliberately never cancelled.
io-only, so it reaches users through a Play release. The web build was never affected. Until that release ships, mobile users still hit it.
GPU_JOB_FAILED โ RunPod removed, and jobs now self-healโ
RunPod was a paid fallback sitting at 402 Insufficient Balance, so a dead
Lambda worker did not mean slower, it meant the job died with the user's video
intact in Storage. Three places routed to it, including
DEFAULT_GPU_PROVIDER_POLICY โ the policy that applies if GPU_PROVIDER_POLICY
is unset or malformed, so fixing only .env would have left a config slip able
to resurrect a dead provider. All three are Lambda-only; tests pin it.
lambda-reconcile now requeues a dead worker's job onto the Lambda queue,
refreshing the signed URL in the queue doc โ it is minted per dispatch and may
have expired while the worker was dying, and a requeue handing the next agent a
stale URL fails silently.
retryInfraFailedJobs (every 10 min) re-dispatches jobs that failed for
infrastructure reasons and whose video still exists. It recovered all 14
stranded jobs. Bounded on purpose: only GPU_JOB_FAILED, capped per job and per
tick, skips jobs over a week old, and refreshes createdAt with a server
timestamp because the stuck-job sweeper fails any active job older than ten
minutes.
The GPU cost leak โ a failed bootstrap made a box immortalโ
Two 2รH100 boxes billed 19 and 25 hours doing nothing, and
idleTerminateMinutes: 5 was correct config that could not fire.
The scaler evaluated bootstrap first and did bootingCount++; continue; on
failure. A box whose ssh bootstrap could never succeed was therefore counted as
"still booting" forever and never reached boot-timeout, the lifetime cap, or
idle-terminate. The scaler logged bootstrap_failed on every one-minute tick
and acted on none of them.
Boot-timeout now runs before bootstrap, and the ordering lives in a pure
instanceVerdict() with the immortal case pinned by a test. Stale heartbeat
docs are also collected โ seven had accumulated, the oldest 13.6 days.
Verified after the fix with real traffic: a queued job launched exactly one tracked box on the first ladder rung. Tracking is the part that had been missing โ an untracked box is invisible to every reaper rule.
Still open: the orphan reclaim is prefix-scoped to acesense-worker-auto-*,
so a legacy bare worker-auto-* box is invisible to it and must be killed by
hand. That is deliberate โ the Lambda account is shared with UNFORCE and a name
that merely looks like ours is not ours โ but it is a gap.
Sign in with Apple โ removed 2026-08-17โ
The button is gone from sign-in and sign-up, gated behind kAppleSignInEnabled
(a bool.fromEnvironment const, so the branch tree-shakes out of release
builds). AuthService.signInWithApple is untouched โ re-enabling is one
--dart-define.
The four Apple-only accounts were deleted at the owner's instruction, with the
deleteUser cascade removing jobs, coach data, API keys and the
videos//results/ Storage prefixes. All four had zero jobs and zero
files โ signed up in May, signed in once or twice, never uploaded โ so no
user content was destroyed. Provider mix is now google.com 352, password 131,
apple.com 0.
Worker bootstrap and the CUDA guardโ
Boxes were failing bootstrap with
InferenceProviderError: ONNX Runtime silently fell back from CUDA
(active providers: ['CPUExecutionProvider'])
That guard is correct โ src/utils/onnx_inference.py refuses to start a
worker that would run inference on CPU, which would be silently slow and
useless. The scaler logged bootstrap_failed every tick and (before the reaper
fix) let the box bill forever.
Investigated on 2026-08-17 by SSH onto boxes mounting the us-south-2
acesense-worker filesystem. The venv is healthy and the failure did not
reproduce:
onnxruntime-gpu==1.20.2installed,CUDAExecutionProvideravailablenvidia-cudnn-cu12 9.1.0.70present, all eightlibcudnn*.so.9loadvalidate_cuda_onnx_runtime()returns('CUDAExecutionProvider', 'CPUExecutionProvider')on bothgpu_1x_h100_sxm5andgpu_2x_h100_sxm5, so it is not GPU-count specificbootstrap.shcompletes withBOOTSTRAP_OKand the agent runs
One real trap was confirmed along the way: loading
libonnxruntime_providers_cuda.so directly, without the preload, fails with
libcudnn.so.9: cannot open shared object file. The libraries live in the
nvidia-cudnn-cu12 wheel and are not on the default loader path, so
_preload_pytorch_cudnn() in onnx_inference.py is load-bearing โ it is what
makes CUDA work at all here, and it is gated behind import torch succeeding.
Not reproduced, so not fixed. The failures were in an 08:00โ10:26 window and
the same code passes now. If it recurs, the reaper limits it to one boot window
of spend rather than a day, and the ONNX error text is in the
bootstrap_failed log line.
Untested: us-east-1. Each region has its own copy of the filesystem, and
the first failure was on a gpu_1x_a100_sxm4 there. Only us-south-2 was
verified.
Notification copyโ
Recovered jobs were sending the first-time "your report is ready" mail to users
whose previous email about that same job said it had FAILED. reanalyzed was
keyed on backfilledAt, which no retry path sets; it now covers backfill, admin
retry and infrastructure retry via isReanalysis().
4. Sport scopeโ
AceSense is tennis only. acesense-gpu-backend/games/ contains exactly one
directory, tennis, and there is no per-sport dispatch to add a second one to.
Nothing in this product accepts, routes or reports on another racket sport.
:::note Corrected 2026-08-19 This section previously carried a five-row table โ tennis, badminton, padel, pickleball, table tennis โ with a per-sport verdict for each. That table describes UNFORCE, the sibling product, and was carried across when this repo was forked. It was wrong here from the day it landed, and it sat three sections after a paragraph promising everything had been verified by running the check, which is the worst place for a stale claim to hide.
If you want the five-sport picture, it lives in the UNFORCE docs. Do not read it as a roadmap for this product; adding a sport here is a backend project, not a configuration change. :::
The tennis findings from that work do carry over, because tennis is the sport they were measured on:
| Sport | E2E | Solves court | Names strokes | Verdict |
|---|---|---|---|---|
| Tennis | yes | partial | yes | Complete product. Court solve is the weak link |
The upstream defect is homography, not detector recall. Eleven clips marked
"partial" see the court in 79โ100% of frames but solve 0โ1 of them. (That
count comes from the shared 64-video corpus run, which predates the fork and
covers more than tennis โ the mechanism is the same, the absolute number is
not AceSense-only.) The
detector finds keypoints; the solve rejects them. This matters because
court.health.status flips the gate policy wholesale โ "full" bypasses the
stroke gate entirely, "partial" applies it in full. Two opposite failure modes
hanging off one field. Fixing the solve is worth more than any detector tuning.
Roughly half of recorded "failures" are uploads that never completed, not analyses that failed. Separate those before reading the failure rate as a model problem.
5. Traps that have already cost timeโ
- Flutter goldens are Linux-only.
test/preview/goldens/*.pngare rendered by CI on ubuntu. Regenerating them on macOS swaps CoreText for FreeType and blows the 3% CI tolerance โ 37 red goldens with no UI change. Local runs pass either way (12% off-CI), so it only appears after a push. - A quiet browser console proves nothing. A CSP header change does not reach a warm tab; the browser replays the cached document with its old headers. Verify in a fresh tab on a cache-busted URL.
Failed to fetchis ambiguous between CORS and CSP. When probing a policy, always include a known-disallowed host as a negative control โ without it, "no violations" is indistinguishable from "the listener never fired". This is what caughtconnect-srcblocking Firebase Auth'sauthDomainand breaking social sign-in after the CSP went enforcing.- Firestore appends
__name__implicitly. A naive diff of live indexes againstfirestore.indexes.jsonreports false positives. firebase deploy --only functions:NAMEaborts โ the codebase is namedfunctions; use--only functions.
6. Openโ
| Item | Notes |
|---|---|
| Scrub gpu-backend history | Credentials were rotated 2026-08-17, so the exposed keys are now dead. The full-history scan stays red until the history is rewritten; that is now a cleanup, not an incident |
| Play release | CLIENT_UPLOAD_FAILED is fixed in code and ships manually; mobile users keep hitting it until that build lands (ยง3a) |
| Court homography solve | Highest-value product fix (ยง4) |
| Landing + admin CSPs | Report-only, never load-tested. Run the probe in Security before enforcing |
| Legacy-prefix orphan reclaim | A bare worker-auto-* box is invisible to the prefix-scoped sweep and must be killed by hand (ยง3a) |
| Privacy copy names Apple | Still lists Apple as a sub-processor for Sign in with Apple. Legal text โ needs outside input |
| Flywheel / STGCN++ | Still the biggest accuracy lever |
7. Pointersโ
- Pipeline contract:
acesense-gpu-backend/src/pipeline_v2/CONTRACT.md - Bench numbers + approved quotas: GPU Benchmarks
- Speed plan: Speed Roadmap
- Secret rotation: Secret Rotation
- GPU dispatch:
acesense-auth-function/video/gpu/ - Web deploy:
acesense-frontend/docs/DEPLOYMENT.md - l10n gate:
acesense-frontend/tool/l10n_audit.py - History (do not treat as live): the Archive