From 35d20b0cb58e423954fc279ab990fc44cd0effb0 Mon Sep 17 00:00:00 2001 From: Jordan Humberto de Souza Date: Wed, 3 Jun 2026 21:37:14 -0300 Subject: [PATCH] Add coherence preset with optional 4s post-inhale hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new `coherence` preset (4 in / 4 hold / 4 out, 5 bpm, 10 min) is the only breathing pattern that includes a retention phase. The hold is opt-in via `--hold` on the coherence preset only, hard-capped at 4 seconds, and rejected on every other preset and on custom ratios. Long holds cross into Valsalva territory and have no clinical evidence base in HFrEF. The render loop now transits INHALE → HOLD → EXHALE (3 phases). HOLD is rendered with a static full bar and a single terminal-bell cue. Pause/resume from any phase snaps back to INHALE; interrupted phases are not counted toward breaths. This relaxes the original C1 safety constraint; the new policy ("hold is a property of the coherence preset, not a free parameter") is documented in dev/breathe-cli-spec.md v2.0 and CLAUDE.md. Bumps VERSION and pyproject.toml to 2.0. --- CLAUDE.md | 9 +- README.md | 26 +++-- breathe.py | 114 +++++++++++++++----- dev/breathe-cli-spec.md | 55 ++++++++-- pyproject.toml | 2 +- test_breathe.py | 233 ++++++++++++++++++++++++++++++++-------- 6 files changed, 341 insertions(+), 98 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3211259..4bfe780 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,12 +18,11 @@ Single-file Python 3 CLI app (`breathe.py`) that paces resonance breathing for H These are load-bearing design decisions, not features to be added later: -1. **No breath retention** — only inhale:exhale ratios. Reject three-number ratios (e.g. `4-7-8`) with an explicit safety error. +1. **Optional short breath hold** — a `--hold N` flag is supported only on the `coherence` preset, with N in 0–4 seconds. Anything longer is rejected: holds beyond ~4 s cross into Valsalva territory and have no clinical evidence base in HFrEF. Custom `--ratio` cannot be combined with `--hold` — hold is a property of the preset, not a free parameter. 2. **No rapid breathing** — total cycle must be >= 8 seconds. Reject shorter cycles at parse time. -3. **No breath holds** — never prompt for a hold phase. -4. **Graceful exit** — `q`, `Ctrl+C`, or any exception must restore the terminal. The `finally` block is the most important code in the file. +3. **Graceful exit** — `q`, `Ctrl+C`, or any exception must restore the terminal. The `finally` block is the most important code in the file. -Do not add breathing patterns, retention phases, or cycle speeds not in the spec, even if asked. Refer to spec §2. +Do not add breathing patterns, hold durations, or cycle speeds not in the spec, even if asked. Refer to spec §2. ## Testing @@ -42,7 +41,7 @@ python3 -m unittest test_breathe -v ## Common pitfalls - Don't clear the whole screen each frame — it flickers on Terminal.app. Move cursor to each zone and rewrite. -- Breath counter increments only after a full cycle (inhale + exhale), not after each phase. +- Breath counter increments only after a full cycle (inhale + [+ hold] + exhale), not after each phase. - Elapsed time tracks completed breathing only (`breaths * cycle_s`). The state machine has no `total_paused` — pause simply stops the loop, resume resets the cycle. - The `-q` short flag (quiet mode) does not conflict with the `q` runtime key — one is argv, the other is stdin during a session. - `afplay` subprocess must never block the render loop. Use `Popen`, not `run`. diff --git a/README.md b/README.md index 0bf966b..1f1950f 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,11 @@ breathe --ratio 4-8 # 12s cycle = 5.0 bpm, strong exhale emphasis This app is deliberately constrained. Several common breathing-app features are excluded for safety and focus: -**No breath retention.** Breath holds (kumbhaka) raise intrathoracic pressure via a Valsalva-like mechanism and can trigger vasovagal syncope or arrhythmia in cardiac patients. The Bernardi protocols use continuous breathing with no hold phases. The app rejects three-number ratios like `4-7-8` with an explicit safety error. +**No breath retention by default.** Breath holds (kumbhaka) raise intrathoracic pressure via a Valsalva-like mechanism and can trigger vasovagal syncope or arrhythmia in cardiac patients. The Bernardi protocols use continuous breathing with no hold phases, and that remains the default for `balanced`, `calm`, and `extended`. The `coherence` preset is the sole exception: a 4-second hold after INHALE, with no hold after EXHALE. The cycle is 4 in / 4 hold / 4 out = 12 s (5 bpm), and the hold is capped at 4 s — longer holds cross into Valsalva territory and have no clinical evidence base in HFrEF. The `--hold` flag is rejected on any other preset, and cannot be combined with a custom `--ratio` (the hold is a property of the preset, not a free parameter). -**No rapid breathing.** Patterns faster than 7.5 bpm (cycles shorter than 8 seconds) move toward hyperventilation territory, reducing arterial CO2 and mobilising catecholamines — the opposite of the vagal intent (Russo et al. 2017). The app enforces a minimum cycle length of 8 seconds. +**No rapid breathing.** Patterns faster than 7.5 bpm (cycles shorter than 8 seconds) move toward hyperventilation territory, reducing arterial CO2 and mobilising catecholamines — the opposite of the vagal intent (Russo et al. 2017). The app enforces a minimum total cycle of 8 seconds, including the hold phase. -**No breath holds between phases.** There is no pause between inhale and exhale. The breath is continuous, matching the protocol in Bernardi et al. (1998, 2002). +**No breath holds between phases by default.** There is no pause between inhale and exhale in the default `balanced`/`calm`/`extended` presets; the breath is continuous, matching the protocol in Bernardi et al. (1998, 2002). The `coherence` preset inserts a single hold between INHALE and EXHALE; there is no hold after EXHALE. **Immediate exit, always.** Pressing `q` or `Ctrl+C` ends the session within one frame. The terminal is always restored — cursor, colours, input mode — even if the app crashes. The `finally` block that does this is the most important code in the file. @@ -137,13 +137,13 @@ breathe With no arguments, the app picks a preset based on the time of day: -| Time of day | Preset | Duration | Ratio | BPM | -|--------------|-------------|----------|-------|-----| -| Before noon | `balanced` | 10 min | 5s-5s | 6 | -| 12:00–16:59 | `extended` | 20 min | 4s-6s | 6 | -| 17:00+ | `calm` | 15 min | 4s-6s | 6 | +| Time of day | Preset | Duration | Ratio | BPM | +|--------------|-------------|----------|---------|-----| +| Before noon | `balanced` | 10 min | 5s-5s | 6 | +| 12:00–16:59 | `extended` | 20 min | 4s-6s | 6 | +| 17:00+ | `calm` | 15 min | 4s-6s | 6 | -All presets target 6 breaths per minute. The `balanced` preset uses equal inhale/exhale (5-5) as a neutral baseline. The `calm` and `extended` presets use a longer exhale (4-6), which emphasises vagal activation during the expiratory phase. The time-of-day auto-select picks `calm` in the evening as a default — but you can use any preset at any time. +The default three presets target 6 breaths per minute. The `balanced` preset uses equal inhale/exhale (5-5) as a neutral baseline. The `calm` and `extended` presets use a longer exhale (4-6), which emphasises vagal activation during the expiratory phase. The time-of-day auto-select picks `calm` in the evening as a default — but you can use any preset at any time. The `coherence` preset (4 in / 4 hold / 4 out, 5 bpm) is opt-in via `--preset coherence`; it is not part of the time-of-day auto-select. ### Presets @@ -151,9 +151,12 @@ All presets target 6 breaths per minute. The `balanced` preset uses equal inhale breathe --preset balanced # 10 min, 5s-5s breathe --preset calm # 15 min, 4s-6s breathe --preset extended # 20 min, 4s-6s (full Bernardi protocol dose) +breathe --preset coherence # 10 min, 4s-4s-4s (4 in / 4 hold / 4 out, 5 bpm) breathe --list-presets # show the table ``` +The `coherence` preset is the only one that supports a hold phase. The hold is part of the preset definition (4 s); the `--hold` flag is rejected on every other preset and cannot be combined with a custom `--ratio`. + ### Custom sessions ```bash @@ -171,6 +174,7 @@ Duration: 1–60 minutes (rounded up to complete breath cycles). Ratio: inhale a | `--preset NAME` | `-p` | Use a named preset | | `--duration MIN` | `-d` | Session length in minutes (1–60) | | `--ratio IN-EX` | `-r` | Breath ratio, e.g. `5-5` or `4-6` | +| `--hold SEC` | | Optional post-inhale hold (0–4 s, `coherence` preset only) | | `--no-sound` | `-n` | Disable audio cues | | `--quiet` | `-q` | Suppress startup warnings | | `--no-log` | | Don't log this session | @@ -202,6 +206,8 @@ During a session: space pause · s mute · q quit <- available controls ``` +On the `coherence` preset, the ratio string reads `4-4-4` (in-hold-out) and the display cycles through a `HOLD` phase between INHALE and EXHALE. During HOLD the bar is full and static (no animation); a single terminal-bell cue marks the transition. + The status indicator shows `●` during breathing, `‖` when paused, and `🔇` when muted. The countdown timer tracks completed breathing time only. If you pause for 30 seconds during a 1-minute session, the session takes ~90 seconds of wall-clock time to complete — the timer doesn't advance while paused. @@ -238,7 +244,7 @@ Run `breathe --safety` for the full safety screen. The short version: - **Palpitations** — stop, note the time, mention it at your next cardiology visit. - **Tingling in hands or face** — a hyperventilation signal. Stop and return to normal breathing. -This app deliberately does not support breath retention, rapid breathing, or any pattern not grounded in the slow-breathing clinical literature. These constraints are enforced in the code and cannot be overridden. See [The science in brief](#the-science-in-brief) and [Design choices](#design-choices) for the clinical rationale. +This app deliberately does not support long breath holds, rapid breathing, or any pattern not grounded in the slow-breathing clinical literature. The single exception is the `coherence` preset's 4-second post-inhale hold, which is the only opt-in retention allowed; longer holds, custom-ratio holds, and post-exhale holds are all rejected at parse time. The 8-second minimum cycle, inhale/exhale range (3–10 s), and 2:1 exhale ceiling remain in force. See [The science in brief](#the-science-in-brief) and [Design choices](#design-choices) for the clinical rationale. ## Disclaimer diff --git a/breathe.py b/breathe.py index dc136fc..5820428 100755 --- a/breathe.py +++ b/breathe.py @@ -18,17 +18,19 @@ # ── Constants ──────────────────────────────────────────────────────── -VERSION = '1.9' +VERSION = '2.0' PRESETS = { - 'balanced': {'duration_min': 10, 'inhale_s': 5, 'exhale_s': 5}, - 'calm': {'duration_min': 15, 'inhale_s': 4, 'exhale_s': 6}, - 'extended': {'duration_min': 20, 'inhale_s': 4, 'exhale_s': 6}, + 'balanced': {'duration_min': 10, 'inhale_s': 5, 'hold_s': 0, 'exhale_s': 5}, + 'calm': {'duration_min': 15, 'inhale_s': 4, 'hold_s': 0, 'exhale_s': 6}, + 'extended': {'duration_min': 20, 'inhale_s': 4, 'hold_s': 0, 'exhale_s': 6}, + 'coherence': {'duration_min': 10, 'inhale_s': 4, 'hold_s': 4, 'exhale_s': 4}, } PRESET_DESCRIPTIONS = {'balanced': 'Equal ratio, neutral baseline', 'calm': 'Exhale-weighted, parasympathetic emphasis', - 'extended': 'Full dose, Bernardi protocol'} + 'extended': 'Full dose, Bernardi protocol', + 'coherence': 'Box-style with short post-inhale hold'} SOUND_INHALE = '/System/Library/Sounds/Tink.aiff' SOUND_EXHALE = '/System/Library/Sounds/Pop.aiff' @@ -49,6 +51,7 @@ COUNTDOWN_SECS = 3 MIN_TERM_WIDTH = 40 MIN_CYCLE_SECS = 8 +MAX_HOLD_SECS = 4 ANSI_CLEAR = '\033[2J\033[H' ANSI_HIDE_CUR = '\033[?25l' @@ -57,10 +60,13 @@ ANSI_DIM = '\033[2m' ANSI_CYAN = '\033[36m' ANSI_GREEN = '\033[32m' +ANSI_YELLOW = '\033[33m' ANSI_CLR_LINE = '\033[K' -INHALE, EXHALE, PAUSED = 'INHALE', 'EXHALE', 'PAUSED' -PHASE_LABEL = {INHALE: 'IN', EXHALE: 'OUT'} +INHALE, HOLD, EXHALE, PAUSED = 'INHALE', 'HOLD', 'EXHALE', 'PAUSED' +PHASE_LABEL = {INHALE: 'IN', HOLD: 'HOLD', EXHALE: 'OUT'} +PHASE_DUR = {INHALE: 'inhale_s', HOLD: 'hold_s', EXHALE: 'exhale_s'} +PHASE_COLOUR = {INHALE: ANSI_CYAN, HOLD: ANSI_YELLOW, EXHALE: ANSI_GREEN} SAFETY_TEXT = """\ Breathe CLI \u2014 safety notes @@ -79,9 +85,15 @@ return to normal breathing. This app deliberately does NOT support: - \u2022 Breath retention (kumbhaka) of any length \u2022 Rapid breathing (kapalbhati, bhastrika, Wim Hof patterns) \u2022 Total breath cycles shorter than 8 seconds + \u2022 Breath holds longer than 4 seconds + \u2022 Breath holds on presets other than `coherence` + +The `coherence` preset is the only exception: a 4-second post-inhale +hold (no hold after exhale). Use `--preset coherence` to access it. +The `--hold` flag is rejected on every other preset and cannot be +combined with a custom `--ratio`. Press q or Ctrl+C to end any session. Exit is always immediate. @@ -97,13 +109,16 @@ class Config: duration_s: int inhale_s: int + hold_s: int exhale_s: int - preset_name: str # 'balanced', 'calm', 'extended', or 'custom' + preset_name: str # 'balanced', 'calm', 'extended', 'coherence', or 'custom' sound_enabled: bool quiet: bool @property def ratio_str(self): + if self.hold_s > 0: + return '{}-{}-{}'.format(self.inhale_s, self.hold_s, self.exhale_s) return '{}-{}'.format(self.inhale_s, self.exhale_s) @dataclass @@ -199,6 +214,10 @@ def check_audio(quiet): return 'bell' def play_sound(phase, audio_mode): + if phase == HOLD: + sys.stdout.write('\a') + sys.stdout.flush() + return if audio_mode == 'winsound': try: import winsound @@ -297,7 +316,7 @@ def draw_phase(layout, phase): sys.stdout.write(ANSI_CLR_LINE) label = PHASE_LABEL.get(phase, phase) if layout.use_colour: - colour = ANSI_CYAN if phase == INHALE else ANSI_GREEN + colour = PHASE_COLOUR.get(phase, ANSI_RESET) styled = colour + label + ANSI_RESET else: styled = label @@ -312,6 +331,8 @@ def draw_bar(layout, progress, phase): sys.stdout.write(ANSI_CLR_LINE) if phase == INHALE: filled = round(progress * BAR_WIDTH) + elif phase == HOLD: + filled = BAR_WIDTH else: filled = round((1.0 - progress) * BAR_WIDTH) filled = max(0, min(BAR_WIDTH, filled)) @@ -329,7 +350,7 @@ def draw_bar(layout, progress, phase): def draw_progress(layout, config, elapsed): move_to(layout.progress_row, 1) sys.stdout.write(ANSI_CLR_LINE) - cycle_s = config.inhale_s + config.exhale_s + cycle_s = config.inhale_s + config.hold_s + config.exhale_s frac = min(1.0, elapsed / config.duration_s) if config.duration_s > 0 else 1.0 filled = round(frac * BAR_WIDTH) filled = max(0, min(BAR_WIDTH, filled)) @@ -405,7 +426,7 @@ def run_session(config, result): if not config.quiet: sys.stderr.write('Warning: not a TTY, running without animation.\n') start = time.monotonic() - cycle_s = config.inhale_s + config.exhale_s + cycle_s = config.inhale_s + config.hold_s + config.exhale_s try: time.sleep(config.duration_s) result.completed = True @@ -436,7 +457,7 @@ def run_session(config, result): result.aborted = True return - cycle_s = config.inhale_s + config.exhale_s + cycle_s = config.inhale_s + config.hold_s + config.exhale_s state = INHALE phase_start_wall = time.monotonic() breathing_base = 0.0 @@ -477,13 +498,12 @@ def run_session(config, result): continue # Resume: fall through to active code - # ── INHALE / EXHALE ───────────────────────────── + # ── INHALE / HOLD / EXHALE ─────────────────────── if _abort[0]: result.aborted = True break - phase_dur = (config.inhale_s if state == INHALE - else config.exhale_s) + phase_dur = getattr(config, PHASE_DUR[state]) phase_elapsed = now - phase_start_wall progress = phase_elapsed / phase_dur @@ -491,6 +511,16 @@ def run_session(config, result): if progress >= 1.0: if state == INHALE: phase_start_wall += config.inhale_s + if config.hold_s > 0: + state = HOLD + if not muted and audio_mode != 'none': + play_sound(HOLD, audio_mode) + else: + state = EXHALE + if not muted and audio_mode != 'none': + play_sound(EXHALE, audio_mode) + elif state == HOLD: + phase_start_wall += config.hold_s state = EXHALE if not muted and audio_mode != 'none': play_sound(EXHALE, audio_mode) @@ -510,8 +540,7 @@ def run_session(config, result): # Recalculate for the new phase so the render below # shows the correct label and bar on the same frame # the sound fires (no stale-frame flicker). - phase_dur = (config.inhale_s if state == INHALE - else config.exhale_s) + phase_dur = getattr(config, PHASE_DUR[state]) phase_elapsed = now - phase_start_wall progress = phase_elapsed / phase_dur @@ -522,11 +551,17 @@ def run_session(config, result): elapsed_display = breathing_base + phase_elapsed remaining_s = (config.duration_s - breathing_base - clean_phase_s) - else: + elif state == HOLD: elapsed_display = (breathing_base + config.inhale_s + phase_elapsed) remaining_s = (config.duration_s - breathing_base - config.inhale_s - clean_phase_s) + else: + elapsed_display = (breathing_base + config.inhale_s + + config.hold_s + phase_elapsed) + remaining_s = (config.duration_s - breathing_base + - config.inhale_s - config.hold_s + - clean_phase_s) key = poll_key() if key == 'q': @@ -611,11 +646,17 @@ def print_safety(): def print_presets(): print('Available presets:\n') fmt = ' {:<10} {:>8} {:<20} {}' - print(fmt.format('Name', 'Duration', 'Ratio (in-ex)', 'Target use')) + print(fmt.format('Name', 'Duration', 'Ratio (in-hold-ex)', 'Target use')) print(fmt.format('\u2500' * 10, '\u2500' * 8, '\u2500' * 20, '\u2500' * 24)) for name, p in PRESETS.items(): - bpm = 60.0 / (p['inhale_s'] + p['exhale_s']) - ratio = '{}s-{}s ({:.0f} bpm)'.format(p['inhale_s'], p['exhale_s'], bpm) + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] + bpm = 60.0 / cycle_s + if p['hold_s'] > 0: + ratio = '{}-{}-{} ({:.0f} bpm)'.format( + p['inhale_s'], p['hold_s'], p['exhale_s'], bpm) + else: + ratio = '{}-{} ({:.0f} bpm)'.format( + p['inhale_s'], p['exhale_s'], bpm) print(fmt.format(name, '{} min'.format(p['duration_min']), ratio, PRESET_DESCRIPTIONS[name])) @@ -624,11 +665,10 @@ def _die(msg): sys.exit(1) def parse_ratio(ratio_str): - _fmt_err = 'Ratio must be in the form `inhale-exhale` (e.g. `5-5` or `4-6`).' + _fmt_err = ('Ratio must be in the form `inhale-exhale` (e.g. `5-5` or ' + '`4-6`). To add a hold, use `--hold N` with the `coherence` ' + 'preset.') parts = ratio_str.split('-') - if len(parts) > 2: - _die('Three-number ratios imply a breath hold. ' - 'This app does not support breath retention. See `breathe --safety`.') if len(parts) != 2: _die(_fmt_err) try: @@ -659,11 +699,14 @@ def build_parser(): parser.add_argument('--list-presets', action='store_true', help='Show available presets and exit') parser.add_argument('--preset', '-p', choices=list(PRESETS.keys()), - help='Use a named preset (balanced, calm, extended)') + help='Use a named preset (balanced, calm, extended, coherence)') parser.add_argument('--duration', '-d', type=int, metavar='MINUTES', help='Session duration in minutes (1\u201360, default: 10)') parser.add_argument('--ratio', '-r', metavar='IN-EX', help='Breath ratio as inhale-exhale (e.g. 5-5 or 4-6)') + parser.add_argument('--hold', type=int, metavar='SECS', + help='Optional post-inhale hold in seconds (0\u20134, ' + 'coherence preset only)') parser.add_argument('--no-sound', '-n', action='store_true', help='Disable audio cues') parser.add_argument('--quiet', '-q', action='store_true', @@ -707,11 +750,13 @@ def main(): sys.exit(0) # Build config from args + hold_s = 0 if args.preset: if args.duration is not None or args.ratio is not None: _die('--preset cannot be combined with --duration or --ratio.') p = PRESETS[args.preset] inhale_s, exhale_s = p['inhale_s'], p['exhale_s'] + hold_s = p['hold_s'] duration_min = p['duration_min'] preset_name = args.preset elif args.duration is not None or args.ratio is not None: @@ -733,19 +778,32 @@ def main(): preset_name = 'calm' p = PRESETS[preset_name] inhale_s, exhale_s = p['inhale_s'], p['exhale_s'] + hold_s = p['hold_s'] duration_min = p['duration_min'] + if args.hold is not None: + if not (0 <= args.hold <= MAX_HOLD_SECS): + _die('Hold must be 0\u2013{} seconds (no clinical evidence for ' + 'longer holds in HFrEF).'.format(MAX_HOLD_SECS)) + if args.ratio is not None: + _die('Hold cannot be combined with a custom ratio. ' + 'Use `--preset coherence`.') + if args.preset != 'coherence': + _die('Hold is supported only on the `coherence` preset.') + hold_s = args.hold + if not (1 <= duration_min <= 60): _die('Duration must be 1\u201360 minutes.') # Round duration up to a whole number of breath cycles so that # the countdown, progress bar, and session end are all in sync. - cycle_s = inhale_s + exhale_s + cycle_s = inhale_s + hold_s + exhale_s duration_s = -(-duration_min * 60 // cycle_s) * cycle_s config = Config( duration_s=duration_s, inhale_s=inhale_s, + hold_s=hold_s, exhale_s=exhale_s, preset_name=preset_name, sound_enabled=not args.no_sound, diff --git a/dev/breathe-cli-spec.md b/dev/breathe-cli-spec.md index 12b3d2d..1b4c54f 100644 --- a/dev/breathe-cli-spec.md +++ b/dev/breathe-cli-spec.md @@ -2,11 +2,16 @@ title: 'Breathe CLI — Safety & Acceptance Tests' subtitle: 'Reference document for a paced-breathing terminal app' author: 'Marek Kowalczyk (spec by Claude, for Claude Opus 4.6)' -date: 2026-05-30 -version: 1.8 +date: '2026-06-03' +version: 2.0 target_platform: 'macOS 10.14.6 (Mojave) & Windows 11' target_runtime: 'Python 3.7+ stdlib only' status: 'implementation complete — this document retains safety constraints and acceptance tests' + +> **Version 2.0 change:** Optional short breath hold (≤4 s) is now supported +> via the `--hold` flag on the new `coherence` preset. All previous safety +> rationale is preserved; the hold is a property of the preset, not a free +> parameter. See §2 C1. --- ## 1. Purpose @@ -23,14 +28,27 @@ For implementation constraints, see `../CLAUDE.md`. These are load-bearing design constraints, not features to be added later. They rule out whole categories of functionality. -**C1. No breath retention.** The app must never prompt for a hold phase. -Valid ratios are inhale:exhale only. If a user tries to pass a -three-number ratio (e.g. `4-7-8`), the app rejects with a clear error -referencing the safety rationale. +**C1. Optional short breath hold.** A `--hold N` flag is supported only +on the `coherence` preset, with N in 0–4 seconds (the `coherence` preset +itself uses 4 s). Holds longer than 4 s cross into Valsalva territory +and have no clinical evidence base in HFrEF. The flag is rejected: + +- with N > 4 (any preset), +- when combined with a preset other than `coherence`, +- when combined with a custom `--ratio` (hold is a property of the + preset, not a free parameter). + +The hold is rendered as a discrete `HOLD` phase between INHALE and +EXHALE, with a single terminal-bell cue at phase entry. There is no +hold after EXHALE — the breath cycle is IN → HOLD → EXHALE, three +phases. No three-number `--ratio` form is supported; users wanting a +hold must use `--preset coherence`. **C2. No rapid breathing.** The app must not allow total breath cycles shorter than 8 seconds (i.e. >7.5 bpm). Hyperventilation-adjacent patterns mobilise catecholamines — the opposite of the vagal intent. +The total cycle includes the hold (e.g. `coherence` 4-4-4 = 12 s = 5 +bpm, well above the 8 s floor). **C3. Visible warning signs.** The safety screen (`--safety`) lists the specific stop-session symptoms: lightheadedness, palpitations, tingling @@ -48,8 +66,11 @@ without having "missed" any breaths. | User input | Response | |------------|----------| -| `--ratio 4-7-8` | Error: "Three-number ratios imply a breath hold. This app does not support breath retention. See `breathe --safety`." | +| `--hold 5` | Error: "Hold must be 0–4 seconds (no clinical evidence for longer holds in HFrEF)." | +| `--hold 2 --preset balanced` | Error: "Hold is supported only on the `coherence` preset." | +| `--ratio 4-6 --hold 2` | Error: "Hold cannot be combined with a custom ratio. Use `--preset coherence`." | | `--ratio 2-2` | Error: "Total breath cycle must be ≥ 8 seconds (no rapid breathing)." | +| `--ratio 4-7-8` | Error: "Ratio must be in the form `inhale-exhale` (e.g. `5-5` or `4-6`). To add a hold, use `--hold N` with the `coherence` preset." | | `--ratio foo` | Error: "Ratio must be in the form `inhale-exhale` (e.g. `5-5` or `4-6`)." | | `--ratio 3-7` | Error: "Exhale must not exceed twice the inhale (no clinical evidence for extreme ratios). See README.md for details." | | `--duration 0` | Error: "Duration must be 1–60 minutes." | @@ -62,7 +83,7 @@ Manual tests, no framework required. Run in order. ### 3.1 Smoke tests 1. `breathe --help` prints help and exits 0. -2. `breathe --version` prints `breathe 1.8` and exits 0. +2. `breathe --version` prints `breathe 2.0` and exits 0. 3. `breathe --safety` prints the safety block and exits 0. 4. `breathe --list-presets` prints the preset table and exits 0. 5. `breathe -d 1` runs for ~60 seconds, renders breath animation, exits cleanly with `completed` status. @@ -70,7 +91,7 @@ Manual tests, no framework required. Run in order. ### 3.2 Safety-rejection tests -7. `breathe -r 4-7-8` exits non-zero with the three-number ratio error message. +7. `breathe -r 4-7-8` exits non-zero with the ratio-format error (the hold is now passed via `--hold`, not a three-number ratio). 8. `breathe -r 2-2` exits non-zero with the "cycle must be ≥ 8 seconds" error. 9. `breathe -d 0` exits non-zero with the duration-range error. 10. `breathe -d 120` exits non-zero with the duration-range error. @@ -107,3 +128,19 @@ Manual tests, no framework required. Run in order. 23. `breathe --log` prints the log file path and exits 0. 24. Delete `~/.breathe_log.csv`, run `breathe --log`: prints path with "(no sessions logged yet)". 25. `chmod 000 ~/.breathe_log.csv`, run `breathe -d 1`: session completes normally, stderr shows a one-line warning about logging failure. Restore permissions afterwards. + +### 3.8 Hold-validation tests + +26. `breathe --hold 5` exits non-zero with "Hold must be 0–4 seconds". +27. `breathe --hold -1` exits non-zero with the same hold-range error. +28. `breathe --preset balanced --hold 2` exits non-zero with "Hold is supported only on the `coherence` preset." +29. `breathe --preset extended --hold 4` exits non-zero with the same message. +30. `breathe -r 4-6 --hold 2` exits non-zero with "Hold cannot be combined with a custom ratio. Use `--preset coherence`." +31. `breathe -d 1 --hold 2` (custom duration with custom hold) exits non-zero with the same message. + +### 3.9 Coherence preset state-machine tests + +32. `breathe --preset coherence` starts a 10-minute 4-4-4 session. The header shows `coherence · 4-4-4 · 10:00 ...` (counting down). The phase display cycles through `INHALE → HOLD → EXHALE → INHALE ...` in 4-second beats; the bar fills during INHALE, holds a full static bar during HOLD (no animation), and drains during EXHALE. +33. During a coherence session, listen at each phase transition: a single terminal-bell cue is audible on entry to HOLD. There is no bell on entry to INHALE or EXHALE (those use the existing `afplay` / `winsound` cues). +34. During a coherence session, press `space` while the phase is `HOLD`. The header shows `‖`, the bar freezes at full, and the countdown freezes. Press `space` again: the bar resets to the beginning of INHALE, the countdown snaps back to the last completed-cycle boundary, and the interrupted HOLD is not counted. (Pause from HOLD must resume to INHALE, never to HOLD — HOLD is a transient state.) +35. `breathe --preset coherence` runs to completion in 10 minutes (50 cycles of 12 s). `~/.breathe_log.csv` gains a row with `preset=coherence`, `ratio=4-4-4`, `breaths=50`, `completion_pct=100`, `status=completed`. diff --git a/pyproject.toml b/pyproject.toml index 9e5b5e2..57e0088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "breathe-cli" -version = "1.9" +version = "2.0" description = "Paced resonance breathing for vagal tone training. Single-file terminal app, no dependencies." readme = "README.md" license = "MIT" diff --git a/test_breathe.py b/test_breathe.py index 62b85d4..e0cfdc9 100644 --- a/test_breathe.py +++ b/test_breathe.py @@ -41,32 +41,45 @@ def test_exact_minutes(self): class TestConfigRatioStr(unittest.TestCase): def test_ratio_str(self): - c = breathe.Config(600, 5, 5, 'balanced', True, False) + c = breathe.Config(600, 5, 0, 5, 'balanced', True, False) self.assertEqual(c.ratio_str, '5-5') def test_ratio_str_asymmetric(self): - c = breathe.Config(900, 4, 6, 'calm', True, False) + c = breathe.Config(900, 4, 0, 6, 'calm', True, False) self.assertEqual(c.ratio_str, '4-6') + def test_ratio_str_with_hold(self): + c = breathe.Config(600, 4, 4, 4, 'coherence', True, False) + self.assertEqual(c.ratio_str, '4-4-4') + class TestPresets(unittest.TestCase): - def test_all_presets_at_6_bpm(self): + def test_default_presets_at_6_bpm(self): for name, p in breathe.PRESETS.items(): - cycle_s = p['inhale_s'] + p['exhale_s'] + if name == 'coherence': + continue + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] bpm = 60.0 / cycle_s self.assertEqual(bpm, 6.0, '{} preset is {:.1f} bpm, expected 6.0'.format(name, bpm)) + def test_coherence_at_5_bpm(self): + p = breathe.PRESETS['coherence'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] + bpm = 60.0 / cycle_s + self.assertEqual(bpm, 5.0, + 'coherence is {:.1f} bpm, expected 5.0'.format(bpm)) + def test_all_presets_cycle_ge_8(self): for name, p in breathe.PRESETS.items(): - cycle_s = p['inhale_s'] + p['exhale_s'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] self.assertGreaterEqual(cycle_s, breathe.MIN_CYCLE_SECS, '{} cycle too short'.format(name)) def test_all_presets_duration_divides_evenly(self): for name, p in breathe.PRESETS.items(): duration_s = p['duration_min'] * 60 - cycle_s = p['inhale_s'] + p['exhale_s'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] self.assertEqual(duration_s % cycle_s, 0, '{} duration not divisible by cycle'.format(name)) @@ -74,6 +87,15 @@ def test_all_presets_have_descriptions(self): for name in breathe.PRESETS: self.assertIn(name, breathe.PRESET_DESCRIPTIONS) + def test_only_coherence_has_hold(self): + for name, p in breathe.PRESETS.items(): + if name == 'coherence': + self.assertGreater(p['hold_s'], 0, + 'coherence should have hold > 0') + else: + self.assertEqual(p['hold_s'], 0, + '{} should not have hold'.format(name)) + class TestParseRatio(unittest.TestCase): def test_valid_equal(self): @@ -137,27 +159,27 @@ def test_single_number_rejected(self): class TestCompletion(unittest.TestCase): def test_completed(self): - c = breathe.Config(600, 5, 5, 'balanced', True, False) + c = breathe.Config(600, 5, 0, 5, 'balanced', True, False) r = breathe.Result(breaths=60, elapsed=600.0, completed=True) pct, status = breathe._completion(c, r) self.assertEqual(pct, 100) self.assertEqual(status, 'completed') def test_aborted_partial(self): - c = breathe.Config(600, 5, 5, 'balanced', True, False) + c = breathe.Config(600, 5, 0, 5, 'balanced', True, False) r = breathe.Result(breaths=30, elapsed=300.0, completed=False, aborted=True) pct, status = breathe._completion(c, r) self.assertEqual(pct, 50) self.assertEqual(status, 'ended early (user)') def test_zero_duration(self): - c = breathe.Config(0, 5, 5, 'custom', True, False) + c = breathe.Config(0, 5, 0, 5, 'custom', True, False) r = breathe.Result(completed=True) pct, _ = breathe._completion(c, r) self.assertEqual(pct, 100) def test_pct_capped_at_100(self): - c = breathe.Config(600, 5, 5, 'balanced', True, False) + c = breathe.Config(600, 5, 0, 5, 'balanced', True, False) r = breathe.Result(elapsed=650.0, completed=True) pct, _ = breathe._completion(c, r) self.assertEqual(pct, 100) @@ -168,7 +190,7 @@ class TestBreathingBase(unittest.TestCase): def test_breathing_base_multiples(self): for preset_name, p in breathe.PRESETS.items(): - cycle_s = p['inhale_s'] + p['exhale_s'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] duration_s = p['duration_min'] * 60 total_cycles = duration_s // cycle_s for breaths in range(total_cycles + 1): @@ -178,7 +200,7 @@ def test_breathing_base_multiples(self): .format(bb, cycle_s)) def test_breathing_base_type_is_int(self): - cycle_s = 10 # 5 + 5 + cycle_s = 10 # 5 + 0 + 5 for breaths in range(10): bb = breaths * cycle_s self.assertIsInstance(bb, int) @@ -191,41 +213,60 @@ class TestRemainingTime(unittest.TestCase): so that when the fix lands, it can be validated automatically. """ - def _remaining(self, duration_s, inhale_s, exhale_s, breaths, + def _remaining(self, duration_s, inhale_s, hold_s, exhale_s, breaths, phase, phase_elapsed): """Reproduce the remaining_s calculation from run_session.""" - cycle_s = inhale_s + exhale_s + cycle_s = inhale_s + hold_s + exhale_s total_cycles = -(-duration_s // cycle_s) session_s = total_cycles * cycle_s breathing_base = breaths * cycle_s clean_phase_s = int(phase_elapsed) if phase == breathe.INHALE: remaining = session_s - breathing_base - clean_phase_s - else: + elif phase == breathe.HOLD: remaining = (session_s - breathing_base - inhale_s - clean_phase_s) + else: + remaining = (session_s - breathing_base + - inhale_s - hold_s - clean_phase_s) return remaining - def _elapsed(self, inhale_s, exhale_s, breaths, phase, phase_elapsed): + def _elapsed(self, inhale_s, hold_s, exhale_s, breaths, phase, phase_elapsed): """Reproduce the elapsed_display calculation from run_session.""" - cycle_s = inhale_s + exhale_s - breathing_base = breaths * cycle_s + breathing_base = breaths * (inhale_s + hold_s + exhale_s) if phase == breathe.INHALE: return breathing_base + phase_elapsed - else: + elif phase == breathe.HOLD: return breathing_base + inhale_s + phase_elapsed + else: + return breathing_base + inhale_s + hold_s + phase_elapsed + + def _ratio_cases(self): + return [(5, 0, 5), (4, 0, 6), (4, 0, 4), (4, 4, 4)] + + def _phase_list(self, hold_s): + if hold_s > 0: + return [breathe.INHALE, breathe.HOLD, breathe.EXHALE] + return [breathe.INHALE, breathe.EXHALE] + + def _phase_dur(self, phase, inhale, hold, exhale): + if phase == breathe.INHALE: + return inhale + if phase == breathe.HOLD: + return hold + return exhale # ── Snap-back on resume ────────────────────────────────────── def test_resume_snaps_to_cycle_boundary(self): """After pause-resume, remaining should be a multiple of cycle_s.""" - for inhale, exhale in [(5, 5), (4, 6), (4, 4)]: - cycle_s = inhale + exhale + for inhale, hold, exhale in self._ratio_cases(): + cycle_s = inhale + hold + exhale duration_s = 60 total_cycles = -(-duration_s // cycle_s) for breaths in range(total_cycles): # phase_elapsed = 0 right after resume (state = INHALE) - rem = self._remaining(duration_s, inhale, exhale, + rem = self._remaining(duration_s, inhale, hold, exhale, breaths, breathe.INHALE, 0.0) self.assertEqual(rem % cycle_s, 0, 'remaining {} not multiple of cycle_s {} ' @@ -236,39 +277,39 @@ def test_resume_snaps_to_cycle_boundary(self): def test_remaining_at_end_of_last_exhale(self): """remaining_s must be 0 at the exact end of the last exhale, not earlier. This is the core of bug #13.""" - for inhale, exhale in [(5, 5), (4, 6), (4, 4)]: - cycle_s = inhale + exhale + for inhale, hold, exhale in self._ratio_cases(): + cycle_s = inhale + hold + exhale duration_s = 60 total_cycles = -(-duration_s // cycle_s) last_breaths = total_cycles - 1 # before last cycle completes # End of last inhale: phase_elapsed = inhale rem_inhale_end = self._remaining( - duration_s, inhale, exhale, + duration_s, inhale, hold, exhale, last_breaths, breathe.INHALE, float(inhale)) self.assertGreater(rem_inhale_end, 0, 'remaining hit 0 at end of last inhale ' - '(ratio {}-{})'.format(inhale, exhale)) + '(ratio {}-{}-{})'.format(inhale, hold, exhale)) # End of last exhale: phase_elapsed = exhale rem_exhale_end = self._remaining( - duration_s, inhale, exhale, + duration_s, inhale, hold, exhale, last_breaths, breathe.EXHALE, float(exhale)) self.assertEqual(rem_exhale_end, 0, 'remaining not 0 at end of last exhale ' - '(ratio {}-{})'.format(inhale, exhale)) + '(ratio {}-{}-{})'.format(inhale, hold, exhale)) def test_remaining_never_negative(self): """remaining_s should never go negative during a session.""" - for inhale, exhale in [(5, 5), (4, 6), (4, 4)]: - cycle_s = inhale + exhale + for inhale, hold, exhale in self._ratio_cases(): + cycle_s = inhale + hold + exhale duration_s = 60 total_cycles = -(-duration_s // cycle_s) for breaths in range(total_cycles): - for phase in [breathe.INHALE, breathe.EXHALE]: - phase_dur = inhale if phase == breathe.INHALE else exhale + for phase in self._phase_list(hold): + phase_dur = self._phase_dur(phase, inhale, hold, exhale) for t in [0.0, 0.5, 1.0, phase_dur - 0.05, float(phase_dur)]: - rem = self._remaining(duration_s, inhale, exhale, + rem = self._remaining(duration_s, inhale, hold, exhale, breaths, phase, t) self.assertGreaterEqual(rem, 0, 'negative remaining at breaths={} ' @@ -276,17 +317,17 @@ def test_remaining_never_negative(self): def test_remaining_monotonically_decreasing(self): """remaining_s should never increase during uninterrupted breathing.""" - for inhale, exhale in [(5, 5), (4, 6), (4, 4)]: - cycle_s = inhale + exhale + for inhale, hold, exhale in self._ratio_cases(): + cycle_s = inhale + hold + exhale duration_s = 60 total_cycles = -(-duration_s // cycle_s) session_s = total_cycles * cycle_s prev_rem = session_s + 1 for breaths in range(total_cycles): - for phase in [breathe.INHALE, breathe.EXHALE]: - phase_dur = inhale if phase == breathe.INHALE else exhale + for phase in self._phase_list(hold): + phase_dur = self._phase_dur(phase, inhale, hold, exhale) for t in range(phase_dur): - rem = self._remaining(duration_s, inhale, exhale, + rem = self._remaining(duration_s, inhale, hold, exhale, breaths, phase, float(t)) self.assertLessEqual(rem, prev_rem, 'remaining increased at breaths={} ' @@ -297,16 +338,16 @@ def test_remaining_monotonically_decreasing(self): def test_countdown_zero_matches_progress_full(self): """When remaining_s == 0, elapsed_display should == session_s.""" - for inhale, exhale in [(5, 5), (4, 6), (4, 4)]: - cycle_s = inhale + exhale + for inhale, hold, exhale in self._ratio_cases(): + cycle_s = inhale + hold + exhale duration_s = 60 total_cycles = -(-duration_s // cycle_s) session_s = total_cycles * cycle_s breaths = total_cycles - 1 # End of last exhale - rem = self._remaining(duration_s, inhale, exhale, + rem = self._remaining(duration_s, inhale, hold, exhale, breaths, breathe.EXHALE, float(exhale)) - elapsed = self._elapsed(inhale, exhale, + elapsed = self._elapsed(inhale, hold, exhale, breaths, breathe.EXHALE, float(exhale)) if rem == 0: self.assertEqual(elapsed, session_s, @@ -319,14 +360,14 @@ class TestDurationRounding(unittest.TestCase): def test_divisible_unchanged(self): """When duration divides evenly, no rounding needed.""" - c = breathe.Config(600, 5, 5, 'balanced', True, False) + c = breathe.Config(600, 5, 0, 5, 'balanced', True, False) self.assertEqual(c.duration_s, 600) # 600 / 10 = 60 def test_indivisible_rounded_up(self): """When duration doesn't divide evenly, round up to next cycle.""" - cycle_s = 4 + 4 # 8 + cycle_s = 4 + 0 + 4 # 8 duration_s = -(-60 // cycle_s) * cycle_s # 64 - c = breathe.Config(duration_s, 4, 4, 'custom', True, False) + c = breathe.Config(duration_s, 4, 0, 4, 'custom', True, False) self.assertEqual(c.duration_s % cycle_s, 0) self.assertGreaterEqual(c.duration_s, 60) @@ -357,14 +398,30 @@ class TestPhaseLabels(unittest.TestCase): def test_inhale_label(self): self.assertEqual(breathe.PHASE_LABEL[breathe.INHALE], 'IN') + def test_hold_label(self): + self.assertEqual(breathe.PHASE_LABEL[breathe.HOLD], 'HOLD') + def test_exhale_label(self): self.assertEqual(breathe.PHASE_LABEL[breathe.EXHALE], 'OUT') +class TestStateConstants(unittest.TestCase): + def test_all_phases_defined(self): + for phase in (breathe.INHALE, breathe.HOLD, breathe.EXHALE, breathe.PAUSED): + self.assertIsNotNone(phase) + + def test_phases_distinct(self): + phases = {breathe.INHALE, breathe.HOLD, breathe.EXHALE, breathe.PAUSED} + self.assertEqual(len(phases), 4) + + class TestConstants(unittest.TestCase): def test_min_cycle_secs(self): self.assertEqual(breathe.MIN_CYCLE_SECS, 8) + def test_max_hold_secs(self): + self.assertEqual(breathe.MAX_HOLD_SECS, 4) + def test_bar_width(self): self.assertGreater(breathe.BAR_WIDTH, 0) @@ -373,5 +430,91 @@ def test_frame_rate(self): self.assertAlmostEqual(breathe.FRAME_SLEEP, 1.0 / breathe.FRAME_RATE_HZ) +class TestCoherencePreset(unittest.TestCase): + def test_definition(self): + p = breathe.PRESETS['coherence'] + self.assertEqual(p['duration_min'], 10) + self.assertEqual(p['inhale_s'], 4) + self.assertEqual(p['hold_s'], 4) + self.assertEqual(p['exhale_s'], 4) + + def test_cycle_is_12_seconds(self): + p = breathe.PRESETS['coherence'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] + self.assertEqual(cycle_s, 12) + + def test_bpm_is_5(self): + p = breathe.PRESETS['coherence'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] + self.assertEqual(60.0 / cycle_s, 5.0) + + def test_duration_s_round(self): + duration_min = breathe.PRESETS['coherence']['duration_min'] + cycle_s = 12 + duration_s = -(-duration_min * 60 // cycle_s) * cycle_s + self.assertEqual(duration_s, 600) # 10 min = 50 cycles * 12s + + def test_config_ratio_str(self): + c = breathe.Config(600, 4, 4, 4, 'coherence', True, False) + self.assertEqual(c.ratio_str, '4-4-4') + + def test_coherence_above_min_cycle(self): + p = breathe.PRESETS['coherence'] + cycle_s = p['inhale_s'] + p['hold_s'] + p['exhale_s'] + self.assertGreaterEqual(cycle_s, breathe.MIN_CYCLE_SECS) + + def test_coherence_hold_at_max(self): + p = breathe.PRESETS['coherence'] + self.assertLessEqual(p['hold_s'], breathe.MAX_HOLD_SECS) + + +class TestHoldValidation(unittest.TestCase): + """Hold-range and compatibility validation rules from spec §2 C1. + + These tests drive main() end-to-end and assert it exits non-zero. + We patch sys.argv to keep argparse happy; main() rejects the + invalid config and _die()s before any rendering, so this is fast. + """ + + def _exit_code(self, *argv): + import sys + old_argv = sys.argv + sys.argv = ['breathe'] + list(argv) + try: + breathe.main() + return 0 + except SystemExit as e: + return e.code if e.code is not None else 0 + finally: + sys.argv = old_argv + + def test_hold_five_rejected(self): + self.assertEqual(self._exit_code('--hold', '5'), 1) + + def test_hold_negative_rejected(self): + self.assertEqual(self._exit_code('--preset', 'coherence', '--hold', '-1'), 1) + + def test_hold_with_balanced_rejected(self): + self.assertEqual(self._exit_code('--preset', 'balanced', '--hold', '2'), 1) + + def test_hold_with_calm_rejected(self): + self.assertEqual(self._exit_code('--preset', 'calm', '--hold', '2'), 1) + + def test_hold_with_extended_rejected(self): + self.assertEqual(self._exit_code('--preset', 'extended', '--hold', '4'), 1) + + def test_hold_with_custom_ratio_rejected(self): + self.assertEqual(self._exit_code('-r', '4-6', '--hold', '2'), 1) + + def test_hold_with_custom_duration_rejected(self): + self.assertEqual(self._exit_code('-d', '1', '--hold', '2'), 1) + + def test_hold_alone_rejected(self): + # No preset specified: time-of-day auto-select picks non-coherence, + # and validation rejects. (Or args.preset is None and the + # args.preset != 'coherence' check fires.) + self.assertEqual(self._exit_code('--hold', '2'), 1) + + if __name__ == '__main__': unittest.main()