domain/invitations — readiness-signal computation¶
backend/src/domain/invitations.py (185 lines). Pure computation of
invitation coordinates: given a snapshot of cross-feature engagement
(habit streaks, sustained practice weeks, active days in a rolling
window) plus optional Creek Vault corpus-theme readings, decide which
deeper depths the moment invites the user to consider. A later
persistence pass turns the coordinates into InvitationSignal rows
(invitations.py:1-8).
Ethics, stated in code (backend/src/domain/invitations.py:17-22):
This module encodes NO shaming and NO FOMO: it never counts what the user
*failed* to do, never compares them to anyone, and stays silent by default.
An absent signal is the norm, not a deficiency. The thresholds below are
deliberately conservative so an invitation only appears once a rhythm is
genuinely established — the "you choose your depth" principle honoured before a
single row is written.
Pure by design: no session, no I/O, no clock (invitations.py:24-28).
Thresholds¶
| Constant | Value | Rationale |
|---|---|---|
SUSTAINED_HABIT_STREAK_DAYS |
21 |
"Three unbroken weeks … an integrated habit rather than a burst of early enthusiasm" (invitations.py:36-39) |
SUSTAINED_PRACTICE_WEEKS |
4 |
Four consecutive weeks meeting the ≥4-sessions/week cadence = "depth already reached" (invitations.py:41-44) |
HIGH_ENGAGEMENT_ACTIVE_DAYS |
25 |
Active 25 of the last 30 days before the embodied-community invitation — "never a nudge to do more" (invitations.py:46-49) |
ENGAGEMENT_WINDOW_DAYS |
30 |
The rolling window (invitations.py:51-54) |
CORPUS_THEME_FULLNESS_THRESHOLD |
0.75 |
A corpus theme this full is "a genuinely lived Aspect, not a passing mention" (invitations.py:56-59) |
Target/kind strings are referenced from the model enums' values (not
literals) "so the candidate coordinates can never drift from the
persisted vocabulary — the drift-guard test asserts exactly this
coupling" (invitations.py:61-70).
Inputs and output¶
ReadinessAggregates(habits: list[HabitSignal], practices:
list[PracticeSignal], active_days_in_window: int, corpus_themes:
list[CorpusThemeSignal] = []) — corpus_themes was appended last with a
default "so every existing positional construction (behavioral-only)
stays valid" (invitations.py:110-122). Output:
InvitationCandidate(target_type, target_id, kind); target_id is
None for ring-level invitations (invitations.py:73-83).
compute_invitation_candidates(aggregates)¶
Each source contributes independently (invitations.py:169-185):
| Source | Rule | Emitted candidate |
|---|---|---|
| Habits | each habit with streak_days >= 21 |
(habit, habit_id, consistency) (invitations.py:125-131) |
| Practices | each practice with sustained_weeks >= 4 |
(practice, practice_id, mastery) (invitations.py:134-140) |
| Engagement | active_days_in_window >= 25 |
at most one (embodied_community, None, readiness) (invitations.py:143-149) |
| Corpus themes | filter to fullness >= 0.75; strongest wins — highest fullness, lowest stage_number breaking ties |
at most one (course, stage_number, readiness) (invitations.py:152-166) |
Worked example¶
habits=[(id=1, streak=25), (id=2, streak=20)],
practices=[(id=7, weeks=4)], active_days=26,
corpus_themes=[(stage=3, 0.8), (stage=6, 0.8), (stage=2, 0.5)] →
four candidates: (habit, 1, consistency), (practice, 7, mastery),
(embodied_community, None, readiness), and (course, 3, readiness) —
stage 3 beats stage 6 on the tie-break. Habit 2 (20 < 21) and stage 2
(0.5 < 0.75) stay silent.
Persistence dedupe (a declined invitation is never recreated) is DB-level — see data-model/preferences-invitations; endpoints in api/invitations; the vault reading in creek-vault.
Grounded in adepthood@fbc529d, 2026-07-31.