Skip to content

Coach 400/503 fixed (#77); Activity day nav (#76); sleep record delete (#78); quiet-hours gate (#79) - #80

Merged
foureight84 merged 9 commits into
mainfrom
fix/coach-openai-schema-77
Sep 25, 2026
Merged

foureight84 merged 9 commits into
mainfrom
fix/coach-openai-schema-77

Conversation

@foureight84

@foureight84 foureight84 commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #77, closes #76, closes #78, closes #79, closes #81.

Started as the #77 fix; while in there, the other three tester requests went in as their own commits, and the #78 follow-up's side-note became its own fix. Everything is unit-tested and ./gradlew testDebugUnitTest is green (1381+ tests). Reviewable per commit.


1 · #77 — the OpenAI 400 was the schema, and there were two bugs in it

@Albabit's mechanism was exactly right: the request was rejected before the model ever saw it, reproducible for anyone on OpenAI. The chat sends strict: true, and under structured outputs the schema itself is validated first:

  1. The reported error: the chart object declared five properties but no additionalProperties: false.
  2. The next one, waiting behind it: required listed 4 of 11 properties — strict mode demands every property, with optionality as a null union instead of a missing key.

Both fixed (chart is now ["object","null"] with full required; safety_note/data_quality_note null unions; publisher required in sources). Gemini is untouched — its cleanSchema already strips additionalProperties and rewrites null unions to nullable. A new test walks the whole schema asserting the two strict-mode invariants so the next field can't reintroduce this.

Gemini 503 "high demand" (-latest aliases routing to a congested pool): GeminiClient now retries a 503 up to twice with backoff (2 s, 4 s, cancellable). A 503 means the request was never served — nothing generated, nothing billed — so a retry cannot double-charge; that reasoning is why ResponsesHttp never retries HTTP statuses, so the retry lives in GeminiClient's call site and the shared layer's policy is untouched. A 429 is deliberately not retried (quota buckets refill daily, not in seconds) — the unexplained 429-with-headroom is most plausibly per-model free-tier buckets; if it recurs on the pinned gemini-2.5-flash slug, that's a Google support conversation, not a client fix.

Model picker: retired gpt-4o / gpt-4o-mini / o4-mini dropped; gpt-5.4 stays (the errors were schema-level, the model was never the problem). A stale stored selection still renders until changed.

2 · #76 — Day navigation on the Activity screen

The header mirrors Sleep's (iOS #84): chevrons stepping a clamped window (a week past the earliest stored day, one-year floor), Today / Yesterday / weekday / date. The summary card and the RECORDS card follow the shown day, with the same delete affordance; the weekly-goal widget keeps reading live-today, as a week widget should. deleteBucket no longer resets the shown day — verifying a past-day deletion was exactly the reported use case. New ActivityDailyDao.earliestDay bounds the pager.

3 · #78 — delete one ring record from the night

The RECORDS card's rows each get a delete with a two-step confirm. Three rules, mirroring the activity deletion's (#70):

  • Tombstone the blocks, not the session row. The write path re-derives the waking day from the blocks the ring re-sends, so deleting the row alone rebuilds on the next sync. A block's minute-grid startAt is the stable identity (same record re-sent → same starts), keyed by waking day; upsertSleepSessionAtomic consults the tombstones before writing.
  • Re-derive the session from the surviving blocks (bounds, asleep minutes, score — the score banding moved to SleepInsights.sleepStageScore so restatement and the write path share one source of truth). Empty means the row goes and the day reads as unslept.
  • sleepRecordRuns re-splits the survivors, so the card reseals itself. Also fixes its now-reachable singular ("1 part", not "1 parts").

Tests cover the tombstone identity: a re-sent record's blocks are all suppressed, neighbours survive, the same minute-of-day on another night doesn't, and re-deleting replaces rather than grows.

Fixed in the same PR, on its own issue #81: the side-note above — brief wakings that reopen a record show as "Awake Xm between" on this card but didn't count in the Awake card. awakeMinutes() now counts staged blocks plus inter-record gaps at the same 2-minute real-boundary threshold sleepRecordRuns draws — the one-minute seam (#63) stays excluded, or every unsplit night grows a spurious minute. The Day view (single session + carousel) and the aggregate's per-night average all use the one function, so the two displays can't drift again.

4 · #79 — opt-in quiet-hours gate on import

Shipped as a time window (off by default, Wearable screen) rather than the quiet-Modes design the ticket proposed: a Mode gate needs notification-policy access plus a recorded interruption-filter history to compare imports against, and the sofa case is mostly caught by a plain window. The Modes version remains open as a follow-up — @Albabit offered to build it, and that offer stands; the gate reads [QuietHoursPrefs.covers] per record, so a Modes implementation would swap that one decision point.

Properties the defaults bake in: the gate reads per record (a settings change takes effect on the next packet); it never deletes (already-imported nights untouched); drop-on-import is one-way — what the gate skips while narrower stays skipped — which is exactly why it's opt-in. Default 22:00 → 07:00, wraps midnight, start-inclusive/end-exclusive; start == end covers the whole day.


Testing

./gradlew testDebugUnitTest — all green. New: schema-strictness walk, three Gemini retry tests, four tombstone-identity tests, five window-logic tests. As before, the discriminating checks are on hardware: @Albabit on the OpenAI key (the built-in trend question), and a night with a phantom record for #78/#79.

…77)

strict: true makes OpenAI validate the JSON schema itself, and two things in
CoachResponseSchema failed that validation, so every native-OpenAI coach turn
400'd with "Invalid schema for response_format 'coach_response'":

- the chart object omitted additionalProperties: false (the reported error)
- required listed four properties where strict mode demands all eleven,
  optionality expressed as a null union rather than a missing key

Gemini is unaffected: cleanSchema strips additionalProperties and rewrites
the null unions to its own nullable flag.

Also on this ticket: Gemini's 503 "high demand" (the -latest aliases routing
to a congested pool) now gets a bounded retry with backoff — a 503 means the
request was never served, so a retry cannot double-bill; a 429 still
surfaces immediately, since a quota bucket refills daily, not in seconds.
And the model picker drops the retired gpt-4o / gpt-4o-mini / o4-mini slugs.
Sleep and the vital detail screens could step back through stored days; the
Activity tab could not, so once a day rolled over its steps, distance and
calories were out of reach — and a deleted block could not be re-checked
later (the exact check the reporter wanted on #70's deletion).

The header mirrors Sleep's: chevrons stepping a clamped [0, maxDayOffset]
window (a week past the earliest stored day, one-year floor), Today /
Yesterday / weekday / date label. The summary card and the RECORDS card
follow the shown day; the weekly-goal widget keeps reading live-today, as a
week widget should. deleteBucket no longer resets the shown day — verifying
a past-day deletion was the point.

New ActivityDailyDao.earliestDay bounds the pager, same rule as sleep's.
A night can arrive as several ring records merged into one session, and the
merge is usually right — but the ring opens a record on a still wrist, so
the evening before the wearer got into bed can land as a full phantom
session and the night runs an hour high. The RECORDS card (issue #68) shows
the parts; now each row gets a delete.

Three rules, mirroring the activity deletion's (issue #70):

- Tombstone the blocks, not the session row: the write path re-derives the
  waking day from the blocks the ring re-sends, so the row alone would
  rebuild on the next sync. A block's minute-grid startAt is the stable
  identity — the same record re-sent reproduces it — keyed by waking day
  (sleep:<day>:<start>), and upsertSleepSessionAtomic consults the
  tombstones before writing.
- Re-derive the session from the blocks that remain (bounds, asleep minutes,
  score), never hand-patch; empty means the row goes and the day reads as
  unslept. The stage-score banding moves to SleepInsights.sleepStageScore so
  the restatement and the write path share one source of truth.
- sleepRecordRuns re-splits the survivors, so the card reseals itself.

Also fixes the card's now-reachable singular: a one-record night says
"1 part", not "1 parts".
A still wrist reads as sleep, so an evening on the sofa can land as a
phantom session and run the night an hour high (the same night #78's
delete handles, caught before import instead of after). Opt-in, off by
default, on the Wearable screen: sleep the ring opens outside a daily
window is declined before any write.

A time window rather than the reporter's first choice of Android quiet
Modes: a Mode gate needs notification-policy access plus a recorded
interruption-filter history to compare imports against, and the sofa case
is mostly caught by a plain window. The Modes version stays open as a
follow-up; the reporter offered to build it.

Properties worth the defaults: the gate reads per record, so a settings
change takes effect on the next packet; it never deletes (already-imported
nights are untouched); and drop-on-import is one-way — what the gate skips
while narrower stays skipped, which is why it is opt-in. Window boundaries
are start-inclusive/end-exclusive, a start==end window covers the whole
day, and the default 22:00→07:00 wraps midnight.
@foureight84 foureight84 changed the title fix(coach): OpenAI rejected the coach schema before the model saw it (#77) Coach 400/503 fixed (#77); Activity day nav (#76); sleep record delete (#78); quiet-hours gate (#79) Sep 22, 2026
The ring reports a brief night-waking two ways — an AWAKE stage, or closing
the record and reopening a new one. The second form appears on the RECORDS
card as "Awake Xm between" but contributed nothing to the Awake card, so one
night read 4m awake on one card and 0m on the other (five wakings, four
reopened, one staged).

awakeMinutes() counts staged blocks plus inter-record gaps at the same
2-minute real-boundary threshold sleepRecordRuns draws — the one-minute
seam (issue #63) stays excluded, or every unsplit night grows a spurious
minute. The Day-view Awake card (single session + carousel) and the
aggregate's per-night average both use it.
…, #81)

Deleting a middle record restated the survivors as one session, so the hole
it left was counted by awakeMinutes() as a between-record waking: deleting
00:25-03:19 from the reported night showed ~3h20m Awake. The next sync would
also have disagreed, since reconcileWakingDay splits at the 60-minute session
gap. SleepRecordDeletion now re-segments the survivors the same way; the
best-overlapping segment keeps the row id, others take the write path's
sleep-<day>-<start> id so a re-sync matches rather than twins them.

The Awake card and the RECORDS card's "Awake Xm between" line now read one
rule (betweenRecordAwakeMinutes), so the one-minute seam no longer shows as
"Awake 1m between" while counting 0.

Multi-session days (night + nap) had no RECORDS card and so no delete; a
phantom session the merge didn't join to the night couldn't be removed. The
carousel pages now show it, even for a one-record session. A failed delete
shows a toast instead of silently doing nothing.
…ropping them (#79)

The gate accepted or declined a whole record by its start minute. The ring
often opens one record on the sofa and runs it straight into the real night,
so the reported 23:08 start either kept the sofa hour (it is inside the
default 22:00-07:00) or, with a later start set, threw the whole night away
- permanently, since the gate is drop-on-import.

keptMinutes() now keeps the longest contiguous stretch of the record inside
the window, and the record is imported from there. The settings copy tells
the wearer to set the start to their real bedtime and says naps outside the
window are skipped.
Dropping gpt-4o / gpt-4o-mini / o4-mini from the picker left anyone who had
one selected stuck on it, with nothing in the list showing what was stored.
OpenAIModel.normalize() maps the retired slugs (and blank) to the default in
ApiKeyStore.model; a typed unknown slug is left alone.

The picker now lists OpenAIModel's entries instead of a second hand-kept
list, which had already drifted (gpt-5.4-mini and gpt-5.5 were missing).

The Gemini 503 retry backs off 2 s then 4 s (2000 shl 1), not 8 s as the
comment said.
maxDayOffset was computed once in init, so days synced (or a midnight rolled)
since the ViewModel was built stayed out of the chevrons' reach.
@foureight84

Copy link
Copy Markdown
Owner Author

Review follow-up: 4 fix commits pushed

I checked the PR against what each linked ticket asked for (#76, #77, #78 plus the follow-up data, #79, #81). The new commits fix the problems that review found. ./gradlew testDebugUnitTest: 1395 tests, 0 failures. assembleDebug builds cleanly.

Fixed

65fc875 — sleep delete × Awake (#78, #81)

  • Deleting a middle record showed the hole as hours of Awake time. The restate kept the survivors as one session, so awakeMinutes() counted the gap left behind as a waking between records. On the reported night (23:08–00:05 / 00:25–03:19 / 03:26–07:08), deleting the middle record showed about 3 h 20 m Awake. The next sync would also have disagreed, because reconcileWakingDay splits at the 60-minute session gap. SleepRecordDeletion now re-segments the survivors with SleepSegmentation. The segment that overlaps the old bounds most keeps the row id; any other segment gets the write path's sleep-<day>-<start> id, so a re-sync matches it instead of creating a duplicate. (Deleting a short middle record, under an hour including the gaps, still counts its span as awake. You said it wasn't sleep, so that's the honest reading.)
  • The Awake card and the RECORDS card now use one gap rule (betweenRecordAwakeMinutes). Before, a 1-minute seam printed "Awake 1m between" while the Awake card counted 0.
  • You can now delete a record on a day with a nap as well as a night. Those days render as a carousel, which had no RECORDS card at all. A phantom session the merge didn't join to the night becomes its own page and couldn't be removed. Carousel pages now show the card, including for a single-record session.
  • A failed delete now shows a toast. Before, the returned Boolean was ignored.

a1ae0e3 — the quiet-hours gate trims instead of dropping (#79)

  • The gate kept or dropped a whole record based on its start minute. The ring often runs the sofa hour and the real night as one record. So the reported 23:08 start was either kept, because it falls inside the default 22:00→07:00 window, or, with a later start set, the entire night was dropped, permanently, since the gate works on import. keptMinutes() now keeps the longest continuous stretch of the record that falls inside the window. That's closer to "dropping segments that fall outside the quiet windows" as the ticket proposed.
  • The settings text now tells the wearer to set the start to their real bedtime, and says naps outside the window are skipped.

5258b2d — coach (#77)

  • A stored retired slug (gpt-4o / gpt-4o-mini / o4-mini) now reads as the default through OpenAIModel.normalize(). Before, anyone who had one selected stayed stuck on it. Unknown slugs typed by the user are left alone.
  • The picker now lists OpenAIModel.entries instead of a second hand-kept list, which had already dropped gpt-5.4-mini and gpt-5.5.
  • The Gemini 503 backoff is 2 s then 4 s (2000 shl 1), not 8 s. I corrected the code comment; the PR description above still says 8 s.

8770aa2 — Activity pager (#76)

  • maxDayOffset is now recomputed on resume. It was computed once in init, so days synced after that stayed out of reach.

Still open, and not code

Still needs a hardware check: the trimmed quiet-hours import on a real night, and a delete of a middle record followed by a re-sync.

@Albabit

Albabit commented Sep 25, 2026

Copy link
Copy Markdown

@foureight84 ,
Picking up the three open points, since two of them are mine to answer.

#78 editing: happy for delete-only to ship and for editing to be tracked separately. I'll open a follow-up issue for adjusting a record's start and end, so it doesn't vanish when this merges.

#79: yes, I still want to build the quiet-Modes version. I'll open a follow-up issue for it too so the offer has somewhere to live. Worth saying the trimming change makes the time window a lot more usable in the meantime, since my bad night started at 23:08 and would have survived the old all-or-nothing gate.

#77's 429: for the record, quota exhaustion doesn't explain it on my side. When I hit it, my Google AI Studio dashboard showed peak usage well under every ceiling over 28 days: 13/20 requests per day on 2.5 Flash, 3/20 on 3.8 Flash, 3/500 on 3.5 Flash Lite. So it wasn't me running out. I'm fine with #77 closing on the schema and model-list fixes, which are the parts that were clearly broken, and I'll file it separately if the 429 comes back once the retry is in.

Happy to run both hardware checks whenever there's a build: the trimmed quiet-hours import on a real night, and deleting a middle record then re-syncing. I get a split night most nights, so the second one should be easy to reproduce.

@foureight84
foureight84 merged commit 3f0d72d into main Sep 25, 2026
1 check passed
@foureight84

Copy link
Copy Markdown
Owner Author

@Albabit ,

Thanks again for the detailed numbers and breakdown!

This is now merged into main, and a pre-release test build is live:
👉 PulseLoop 2.9.2 (57) Pre-release (v2.9.2+57-rc1) (Direct asset: pulseloop-2.9.2+57-universal.apk)

Whenever you have a moment, please run the two hardware checks on this build:

  1. Trimmed quiet-hours import on a real night to ensure bedtime couch hours are trimmed properly without discarding the night.
  2. Deleting a middle record on a split night followed by a re-sync to verify the new re-segmentation logic doesn't inflate Awake time or create duplicates.

Also, as discussed, please feel free to open the two follow-up tickets whenever you're ready:

Once you've had a chance to verify on hardware, we'll promote this to a full release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment