File size: 4,067 Bytes
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57ed4c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""Unit tests for schedule math: d_hat, overlaps, P0 lock, strong feedback."""

from app.schedule_math import (
    d_hat,
    is_strong_feedback,
    overlaps,
    recompute_priors,
    validate_blocks,
)
from app.schedule_reschedule import rules_backup_reschedule


def test_d_hat_shrinkage() -> None:
    # n=3, mean=40, prior=30, m=3 → 0.5*40 + 0.5*30 = 35
    assert abs(d_hat(40.0, 3, 30.0, m=3) - 35.0) < 1e-9
    assert d_hat(None, 0, 45.0, m=3) == 45.0


def test_strong_feedback() -> None:
    assert is_strong_feedback(
        {"did": "done", "actual_min": 20, "quality": 4, "fun": 3}
    )
    assert not is_strong_feedback({"did": "skipped", "actual_min": 20, "quality": 4, "fun": 3})
    assert not is_strong_feedback({"did": "done", "actual_min": None, "quality": 4, "fun": 3})


def test_overlap_and_p0_lock() -> None:
    assert overlaps("09:00", "10:00", "09:30", "10:30")
    assert not overlaps("09:00", "10:00", "10:00", "11:00")
    blocks = [
        {
            "id": "a",
            "start": "09:00",
            "end": "10:00",
            "planned_min": 60,
            "priority": "P0",
            "locked": True,
        }
    ]
    prev = [{"id": "a", "start": "09:00", "end": "10:00", "priority": "P0", "locked": True}]
    moved = [
        {
            "id": "a",
            "start": "11:00",
            "end": "12:00",
            "planned_min": 60,
            "priority": "P0",
            "locked": True,
        }
    ]
    errors_moved, _ = validate_blocks(moved, previous_p0=prev, allow_p0_move=False)
    errors_ok, _ = validate_blocks(blocks, previous_p0=prev, allow_p0_move=False)
    assert errors_moved
    assert not errors_ok


def test_all_grind_soft_warn() -> None:
    duty = [
        {
            "id": "a",
            "start": "09:00",
            "end": "10:00",
            "planned_min": 60,
            "intent": "duty",
            "priority": "P1",
        }
    ]
    errors, warnings = validate_blocks(
        duty,
        must_include_explore_or_restore=True,
        capacity_hint=0.7,
        hard_explore=False,
    )
    assert not errors
    assert any("all-grind" in w for w in warnings)
    errors_hard, _ = validate_blocks(
        duty,
        must_include_explore_or_restore=True,
        capacity_hint=0.7,
        hard_explore=True,
    )
    assert any("all-grind" in e for e in errors_hard)


def test_recompute_priors() -> None:
    blocks = {
        "b1": {"id": "b1", "kind": "earn_ship", "planned_min": 45},
    }
    feedback = [
        {
            "block_id": "b1",
            "did": "done",
            "actual_min": 50,
            "quality": 4,
            "fun": 2,
            "strong": True,
            "would_repeat": "yes",
        }
    ]
    priors = recompute_priors(feedback, blocks, shrink_k=3)
    assert priors["earn_ship"]["n"] == 1
    assert priors["earn_ship"]["d_hat"] > 0


def test_rules_backup_keeps_p0() -> None:
    blocks = [
        {
            "id": "p0",
            "date": "2026-07-19",
            "start": "08:00",
            "end": "08:30",
            "title": "Food",
            "kind": "food_out",
            "intent": "duty",
            "priority": "P0",
            "planned_min": 30,
            "status": "planned",
            "locked": True,
            "notes": "",
            "version_added": 1,
            "source": "user",
        },
        {
            "id": "p2",
            "date": "2026-07-19",
            "start": "14:00",
            "end": "16:00",
            "title": "Flex",
            "kind": "other",
            "intent": "duty",
            "priority": "P2",
            "planned_min": 120,
            "status": "planned",
            "locked": False,
            "notes": "",
            "version_added": 1,
            "source": "user",
        },
    ]
    out = rules_backup_reschedule(
        blocks,
        day="2026-07-19",
        risk_score=2.0,
        capacity_hint=0.4,
    )
    p0 = next(b for b in out if b.id == "p0")
    assert p0.start == "08:00" and p0.end == "08:30"