Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions app/services/graduation.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,34 @@ def _no_recommendation(
],
)

# Compute "already done" rate
already_done_count = sum(1 for n in notifications if n.response == "Already done")
# D6 hybrid credit (Amendment 15): mirrors [2G-01] D5 — an expired nudge
# with no response can be credited by a same-date HabitCompletion row.
# Scoped to the LIMIT-14 scheduled_at span so the completion range adapts
# naturally to the habit's current frequency. Explicit responses win:
# "Already done" still counts on its own, and explicit non-"Already done"
# responses (e.g., "Skip today") are never overridden.
# Duplicates [2G-01]'s inline implementation; extraction is Wave 3 #186.
earliest_date = notifications[-1].scheduled_at.date()
latest_date = notifications[0].scheduled_at.date()
completion_dates = {
c.completed_at
for c in db.query(HabitCompletion)
.filter(
HabitCompletion.habit_id == habit_id,
HabitCompletion.completed_at >= earliest_date,
HabitCompletion.completed_at <= latest_date,
)
.all()
}

already_done_count = 0
for n in notifications:
if n.response == "Already done":
already_done_count += 1
elif n.status == "expired" and n.response is None:
if n.scheduled_at.date() in completion_dates:
already_done_count += 1

rate = already_done_count / total

# Check if already at minimum stepped frequency
Expand Down
179 changes: 179 additions & 0 deletions tests/test_graduation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,185 @@ def test_pending_excluded(self, db):
assert result.notifications_evaluated == 10


# ===========================================================================
# D6 hybrid credit — [2G-Bug-03]
# ===========================================================================


class TestEvaluateFrequencyStepDownHybridCredit:
"""Expired nudges credited by same-date HabitCompletion rows.

See `[2G-03]` Amendment 15 / brain3#185. Mirrors D5 in `[2G-01]` / #184 —
same ADHD quality lens: multiple low-friction completion paths feel like
"I did it" and should all count toward step-down rate, regardless of
which channel recorded the completion.
"""

def test_routine_cascade_completion_credited_toward_step_down(self, db):
"""14 expired nudges with matching routine_cascade completions → 100% rate, step down."""
habit = _create_habit(db, notification_frequency="daily")
for i in range(14):
days_ago = i + 1
_create_notification(db, habit.id, None, "expired", days_ago=days_ago)
db.add(
HabitCompletion(
id=uuid.uuid4(),
habit_id=habit.id,
completed_at=date.today() - timedelta(days=days_ago),
source="routine_cascade",
),
)
db.commit()

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 1.0
assert result.recommend_step_down is True
assert result.recommended_frequency == "every_other_day"
assert result.blocking_reasons == []

def test_no_credit_when_no_completion_row_step_down(self, db):
"""Regression guard: expired nudges with no completions don't get hybrid credit."""
habit = _create_habit(db, notification_frequency="daily")
for i in range(14):
_create_notification(db, habit.id, None, "expired", days_ago=i + 1)

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 0.0
assert result.recommend_step_down is False
assert any("below step-down threshold" in r for r in result.blocking_reasons)

def test_mixed_response_and_completion_step_down(self, db):
"""7 'Already done' + 7 expired-with-matching-completion → 100% rate."""
habit = _create_habit(db, notification_frequency="daily")
# First 7 days: explicit "Already done" responses, no completion rows needed
for i in range(7):
_create_notification(db, habit.id, "Already done", "responded", days_ago=i + 1)
# Next 7 days: expired nudges with matching routine_cascade completions
for i in range(7):
days_ago = 8 + i
_create_notification(db, habit.id, None, "expired", days_ago=days_ago)
db.add(
HabitCompletion(
id=uuid.uuid4(),
habit_id=habit.id,
completed_at=date.today() - timedelta(days=days_ago),
source="routine_cascade",
),
)
db.commit()

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 1.0
assert result.recommend_step_down is True
assert result.recommended_frequency == "every_other_day"

def test_same_day_nudge_and_completion_not_double_counted(self, db):
"""De-dup rule: explicit 'Already done' + same-date completion counts once, not twice.

Denominator stays at nudge count; credit caps at total_evaluated.
"""
habit = _create_habit(db, notification_frequency="daily")
for i in range(14):
days_ago = i + 1
_create_notification(db, habit.id, "Already done", "responded", days_ago=days_ago)
db.add(
HabitCompletion(
id=uuid.uuid4(),
habit_id=habit.id,
completed_at=date.today() - timedelta(days=days_ago),
source="individual",
),
)
db.commit()

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 1.0

def test_explicit_negative_response_not_overridden_by_completion_step_down(self, db):
"""Explicit non-'Already done' responses win — completion row does NOT override."""
habit = _create_habit(db, notification_frequency="daily")
for i in range(14):
days_ago = i + 1
_create_notification(db, habit.id, "Skip today", "responded", days_ago=days_ago)
_create_completion(db, habit.id, days_ago=days_ago)

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 0.0
assert result.recommend_step_down is False

def test_extra_completions_outside_nudge_range_do_not_inflate_denominator(self, db):
"""Completion rows do NOT inflate total_evaluated — denominator stays nudge count."""
habit = _create_habit(db, notification_frequency="daily")
# 14 expired nudges on days 1-14 + 14 matching completions
for i in range(14):
days_ago = i + 1
_create_notification(db, habit.id, None, "expired", days_ago=days_ago)
_create_completion(db, habit.id, days_ago=days_ago)
# Extra completions on days 20-25 with no matching nudges — should be ignored
for i in range(6):
_create_completion(db, habit.id, days_ago=20 + i)

result = evaluate_frequency_step_down(db, habit.id)

assert result.notifications_evaluated == 14
assert result.current_rate == 1.0

def test_stacking_stability_inherits_step_down_rate_change(self, db):
"""Transitive: _assess_habit_stability via step-down rate now reflects completion credit.

A routine-cascade-completed habit with no explicit nudge responses used to be
'not stable' (rate 0%); after D6 it's stable (rate 100%) via the first OR'd
criterion in _assess_habit_stability. Verified through the public
get_stacking_recommendation path — the habit is not in blocking_habits.

The habit is intentionally set up to fail criterion 2 (14+ days accountable,
7-day completion streak) so criterion 1 is the only path to stability:
accountable_since=10 days ago means criterion 2 cannot save it.
"""
routine = _create_routine(db)
# accountable_since=10 days: fails stacking criterion 2 (needs 14+).
habit = _create_habit(
db,
routine_id=routine.id,
notification_frequency="daily",
scaffolding_status="accountable",
accountable_since=date.today() - timedelta(days=10),
)
for i in range(14):
days_ago = i + 1
_create_notification(db, habit.id, None, "expired", days_ago=days_ago)
db.add(
HabitCompletion(
id=uuid.uuid4(),
habit_id=habit.id,
completed_at=date.today() - timedelta(days=days_ago),
source="routine_cascade",
),
)
db.commit()

recommendation = get_stacking_recommendation(db, routine.id)

# Habit is stable → not in blocking_habits → routine ready for next stack.
assert recommendation.blocking_habits == []
assert recommendation.ready is True
stability_entries = [
s for s in recommendation.active_accountable_habits if s.habit_id == habit.id
]
assert len(stability_entries) == 1
assert stability_entries[0].is_stable is True


# ===========================================================================
# [2G-03] apply_frequency_step_down
# ===========================================================================
Expand Down
Loading