Skip to content

domain/contraction — naming a natural ebb

backend/src/domain/contraction.py (177 lines). Pure contraction detection: given a snapshot of how a user's habit foundation behaved over a recent window, decide whether the moment warrants a warm, declinable Higher Self reflection that names a contraction — and, for someone who has travelled far, offers the optional five-week Return (contraction.py:1-6).

The module's ethics are explicit (backend/src/domain/contraction.py:8-15):

This module encodes NO shaming and NO gamification. It never counts a "broken
streak", never compares the user to anyone, never demotes, and stays silent by
default. An absent signal is the expected, healthy case — not a deficiency. A
contraction is framed exactly as the product philosophy frames it: *Contraction
follows Expansion; it is ok to need a break from progress.*

Pure by design — no session, no I/O, no clock; the caller gathers aggregates and hands them in (contraction.py:17-21).

Thresholds

Constant Value Meaning
FOUNDATION_UNMET_CONSECUTIVE_DAYS 14 Consecutive days a scheduled goal is logged-but-unmet (checked in at zero units) before naming — "long enough that this reads as a genuine season, not a bad day or a busy week" (contraction.py:30-35)
FOUNDATION_UNCHECKED_CONSECUTIVE_DAYS 14 Consecutive days entirely unchecked; held equal so "silence and zero-effort days are treated with the same patience" (contraction.py:37-41)
RETURN_MIN_HIGHEST_STAGE RETURN_MINIMUM_STAGE (= 5, sourced from domain.metta_return) Highest stage reached before the Return variant is offered — one canonical constant, never re-declared (contraction.py:43-49)

Types

HabitFoundationSignal(habit_id, consecutive_unmet_days, consecutive_unchecked_days); ContractionAggregates(habits: tuple) — a tuple, not a list, so the value object is "immutable by content, not just by attribute binding" (contraction.py:104-114); ContractionSignal(flagged_habit_ids); ContractionInvitation(variant, message) with ContractionVariant ∈ {simple_ease_off, return_offer} (contraction.py:77-135).

detect_contraction(aggregates) -> ContractionSignal | None

Silent by default. A single habit crossing either window flags — "a thinning foundation is worth naming even when only one thread has gone quiet. The boundary is exact — one day short of a window never flags" (contraction.py:146-157). _habit_crosses_window is a >= test on either counter (contraction.py:138-143).

build_contraction_invitation(highest_stage_reached) -> ContractionInvitation

Precondition: a contraction was already detected — the copy depends only on furthest reach, so the signal is not an input (contraction.py:160-167). highest_stage_reached >= 5RETURN_OFFER with the Return message; otherwise SIMPLE_EASE_OFF (contraction.py:169-177). The ease-off copy "deliberately avoids the word 'return' so the five-week Return remains the higher-stage variant's distinct offer" (contraction.py:51-55); both messages hand back agency and promise nothing is lost (contraction.py:56-74).

Worked example

Habits A (unmet=14, unchecked=0) and B (unmet=3, unchecked=13): A crosses the unmet window, B crosses nothing → ContractionSignal(flagged_habit_ids=(A,)). With highest_stage_reached=4SIMPLE_EASE_OFF; with 5RETURN_OFFER. Both counters at 13 → None (exact boundary).

Related: metta-return (the Return itself), stage-progress (highest_stage_reached's monotonicity).


Grounded in adepthood@fbc529d, 2026-07-31.