DEV Community

Cover image for Structured AI Interview Rebuilt the Core of an App
Patricio Gabriel Maseda
Patricio Gabriel Maseda

Posted on

Structured AI Interview Rebuilt the Core of an App

The bug that revealed the real problem

The trigger was a bug report on timed breathing sessions. The app was confusing rounds with reps. Hold values that made sense for counted breathing made no sense for timed breathing. The concept of "20 breaths per minute" and "a round lasts 30 seconds" were being shoehorned into the same fields. And the long full-lungs retention in hormesis — a 60–120 second hold after rapid breathing — was being modeled as just a very long in-rep hold, which broke everything about how retentions should work.

It wasn't a bug in the usual sense. It was a data model that couldn't express the domain it was trying to represent.


The old model

The engine was built around a Cycle: one inhale + optional holds + one exhale. Rounds were cyclesPerRound. The custom builder called cycles "rounds." Timed and counted modes were handled by a mode discriminator on each phase. Long retentions were just cycles with enormous hold values.

The types looked roughly like this:

type BreathPhase =
  | { mode: 'time'; inhaleSec: number; exhaleSec: number; holds: HoldConfig }
  | { mode: 'bpm'; bpm: number; holds: HoldConfig }

type Cycle = {
  phases: BreathPhase[]
  cyclesPerRound: number
  repeatLast?: boolean
}
Enter fullscreen mode Exit fullscreen mode

Three problems were baked in:

  1. Rounds vs reps conflated. cyclesPerRound was a mapping from internal engine cycles to user-visible rounds — a leaky abstraction that every UI component had to work around.
  2. Two breath modes that were almost the same. mode: 'time' and mode: 'bpm' both described a breath with four phases. The distinction forced a discriminator into every type, every render, every conversion.
  3. Retentions as long holds. A 90-second empty-lungs retention after hormesis power breathing was stored as holdAfterExhaleSec: 90 — the same field used for a 4-second box-breathing pause. Nothing in the model distinguished them.

The question wasn't how to fix the bug. The question was what the model should actually look like.


The interview

Instead of jumping to code, the session started with a structured design interview: one question at a time, each with a stated recommendation and an explicit trade-off. The goal was to lock every decision before a single line changed.

Here are the four questions that unlocked the architecture.

Q1 — Vocabulary

Right now the code has Cycle and cyclesPerRound. You're saying this is wrong. I think the right vocabulary is a 3-level hierarchy:

  • Rep = one inhale + optional hold + exhale + optional hold. The atomic breath unit.
  • Round = a sequence of reps, defined by either total reps (count mode) or total duration (timed mode).
  • Program = an ordered sequence of rounds.

Does this match your mental model?

User: yes that matches, program → round → rep, this also works for constructing any breathing

One exchange. Three levels. The entire naming problem resolved.


Q2 — One rep shape or two?

A rep is one inhale + optional holds + exhale + optional holds. The question is how it's parameterized.

Option A — One unified shape: 4 numbers.
Every rep is { inhaleSec, holdAfterInhaleSec, exhaleSec, holdAfterExhaleSec }. BPM is just a UI convenience — "20 bpm, equal split" maps to inhaleSec=1.5, exhaleSec=1.5. Internally there's only one rep type.

Option B — Two rep modes.
{ mode: 'time', ... } and { mode: 'bpm', ... }. Preserves a conceptual distinction between a metronome-paced breath and a time-paced breath.

Recommendation: Option A. The internal model stays a single 4-tuple. Animation can detect "fast equal split" and pulse instead of expanding smoothly — that's a render-time decision, not a data-model one.

User: Option A, but keep the count UX, we can count up if that's easier

This was the biggest design unlock. Collapsing two modes into one 4-number shape removed a discriminator from every type downstream. The data model went from "two kinds of breathing phases that mostly behave the same" to "every breath is the same shape; rounds vary." That simplification only became visible by writing it down and asking: is this distinction real?


Q4 — Retentions as a distinct round type

Hormesis's recovery is a 60–120s full-lungs hold. Box-breathing holds are 4–10s. These are not the same thing.

Option A — Retention is a distinct property of a breathing round. Hormesis = breathing round + retention, kept together.

Option B — Long holds are just long holds inside a rep.

Option C — Holds are first-class steps in a round's sequence.

Recommendation: A. Matches user mental model: "do 30 power breaths, then hold." Solves "hold values make no sense for timed version" naturally — the 10s cap applies to in-rep holds; retentions are a separate thing with their own range.

User: A

Two optional fields on RoundemptyLungsRetentionSec and fullLungsRetentionSec — replaced the entire confusion between short holds and long retentions.


Q6 — Infinite breathing

Box breathing currently uses repeatLast: true so the last cycle loops forever. Where does "breathe like this until I stop" fit in the new model?

User: there's no need for infinite, never asked for this, if it is in the code we can get rid of it

One of the most clarifying moments in any design session: discovering that a feature was never actually needed. repeatLast was removed entirely.


The new model

Eleven questions. Every decision locked. Then the types:

type Rep = {
  inhaleSec: number           // > 0
  holdAfterInhaleSec: number  // 0–10s
  exhaleSec: number           // > 0
  holdAfterExhaleSec: number  // 0–10s
}

type Round = {
  rep: Rep
  mode: 'count' | 'time'
  count?: number
  durationSec?: number
  emptyLungsRetentionSec?: number  // 0 or 30–180s
  fullLungsRetentionSec?: number   // 0 or 15–60s
}

type Program = {
  rounds: Round[]
  // id, name, category, level, ...
}
Enter fullscreen mode Exit fullscreen mode

The engine processes each round through five stages in order: reps → emptyRetention → transitionIn → fullRetention → transitionOut. Time-mode rounds finish the current rep before moving on — never cutting someone off mid-inhale. Skip button appears only on holds of 30 seconds or longer. Transitions between retention types are hardcoded at 2 seconds, matching how the body actually works.

The old model's concepts — Cycle, BreathPhase modes, cyclesPerRound, repeatLast — are gone entirely.


Implementation in numbers

The rewrite was planned as 9 phases and executed in order:

# Phase File(s)
1 Rewrite breath types packages/types/src/breath.ts
2a Rewrite breath engine packages/engine/src/breathEngine.ts
2b Rewrite catalog packages/engine/src/programs.ts
3 Rewrite custom programs storage apps/mobile/src/lib/customPrograms.ts
3b Supabase migration v2 supabase/migrations/…_custom_programs_v2.sql
4 Rewrite custom builder UI apps/mobile/src/screens/CustomBuilderScreen.tsx
5 Update SessionScreen apps/mobile/src/screens/SessionScreen.tsx
6 Update i18n keys packages/i18n/src/locales/{en,es}.ts
7 Cleanup and type-check CLAUDE.md

Single TypeScript pass. Zero errors across apps/mobile, packages/engine, and packages/types.

Two highlights worth noting: the engine became a stage machine — a central advance() function dispatches to the next step based on current stage and round configuration, which is far easier to reason about than the previous nested condition tree. And a pre-existing pause/resume bug (phase elapsed time not re-incremented after resume) was fixed by re-deriving phaseElapsed from phaseStartTime after the pause-duration shift — something that became obvious once the engine had a clear structure.


What the interview actually did

The architecture simplification in Q2 — one rep shape instead of two — was only visible because the design was written down and questioned. Free-form prompting would likely have produced code that preserved the mode discriminator, because that's what the existing code had and it's the path of least resistance.

The structured interview forced the question: is this distinction real? It wasn't. Removing it simplified every downstream type, every render, every test.

That's the pattern: before touching the codebase, spend the time to ask whether the distinctions in your current model reflect actual domain differences — or just accumulated implementation decisions that nobody questioned. An AI that asks focused questions with explicit recommendations and trade-offs is a useful forcing function for that conversation.

Eleven questions. Nine phases. One TypeScript pass. A breathing engine that can now express anything the domain actually needs.

Top comments (0)