Skip to content

Commit a66cca9

Browse files
committed
Improve context switch calculations
1 parent fc9709f commit a66cca9

4 files changed

Lines changed: 118 additions & 91 deletions

File tree

README.md

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,47 @@ Calculate the real-world cost of GitHub pull requests with detailed breakdowns o
77
```
88
$ prcost https://github.com/chainguard-dev/apko/pull/1860
99
10-
PULL REQUEST COST ANALYSIS
11-
==========================
12-
13-
PR URL: https://github.com/chainguard-dev/apko/pull/1860
14-
Hourly Rate: $156.25 ($250000 salary * 1.3X total benefits multiplier)
15-
16-
AUTHOR COSTS
17-
Code Cost (COCOMO) $ 7531.93 (132 LOC, 48.20 hrs)
18-
Code Context Switching $ 547.30 (3.50 hrs)
19-
GitHub Time $ 161.50 (3 events, 1.03 hrs)
20-
GitHub Context Switching $ 208.33 (2 sessions, 1.33 hrs)
21-
---
22-
Author Subtotal $ 8449.06 (54.07 hrs total)
23-
24-
PARTICIPANT COSTS
25-
philroche
26-
Event Time $ 52.08 (1 events, 0.33 hrs)
27-
Context Switching $ 104.17 (1 sessions, 0.67 hrs)
28-
Subtotal $ 156.25 (1.00 hrs total)
29-
justinvreeland
30-
Event Time $ 52.08 (1 events, 0.33 hrs)
31-
Context Switching $ 104.17 (1 sessions, 0.67 hrs)
32-
Subtotal $ 156.25 (1.00 hrs total)
33-
---
34-
Participants Subtotal $ 312.50 (2.00 hrs total)
35-
36-
DELAY COST
37-
Project Delay (20%) $ 2677.27 (68.54 hrs)
38-
Future GitHub (3 events) $ 468.75 (3.00 hrs)
39-
---
40-
Total Delay Cost $ 3146.02
41-
42-
==========================
43-
TOTAL COST $ 11907.58
44-
==========================
10+
https://github.com/chainguard-dev/apko/pull/1860
11+
Rate: $156.25/hr • Salary: $250,000.00 • Benefits: 1.3x
12+
13+
Author
14+
──────
15+
Development Effort $7,531.93 132 LOC • 2.0 days
16+
GitHub Activity $156.25 2 sessions • 1.0 hrs
17+
GitHub Context Switching $208.33 1.3 hrs
18+
────────────
19+
Subtotal $7,896.51 2.1 days
20+
21+
Participants
22+
────────────
23+
philroche
24+
Review Activity $75.00 1 sessions • 29 min
25+
Context Switching $104.17 40 min
26+
justinvreeland
27+
Review Activity $75.00 1 sessions • 29 min
28+
Context Switching $104.17 40 min
29+
────────────
30+
Subtotal $358.33 2.3 hrs
31+
32+
Merge Delay
33+
───────────
34+
Cost of Delay $9,481.36 2.5 days (capped)
35+
Cognitive Load $3,160.45 20.2 hrs (capped)
36+
────────────
37+
Subtotal $12,641.81 3.4 days
38+
39+
Future Costs
40+
────────────
41+
Code Churn (18% drift) $1,155.39 7.4 hrs
42+
Review $75.00 29 min
43+
Merge $52.08 20 min
44+
Context Switching $208.33 1.3 hrs
45+
────────────
46+
Subtotal $1,490.81 9.5 hrs
47+
48+
═══════════════════════════════════════════════════════════════
49+
Total $22,387.47 6.0 days
50+
4551
```
4652

4753
## Caveats

cmd/prcost/main.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,10 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
221221
fmt.Println(" ──────")
222222
fmt.Printf(" Development Effort %12s %d LOC • %s\n",
223223
formatCurrency(breakdown.Author.CodeCost), breakdown.Author.LinesAdded, formatTimeUnit(breakdown.Author.CodeHours))
224-
fmt.Printf(" GitHub Activity %12s %d events • %s\n",
225-
formatCurrency(breakdown.Author.GitHubCost), breakdown.Author.Events, formatTimeUnit(breakdown.Author.GitHubHours))
226-
fmt.Printf(" GitHub Context Switching %12s %d sessions • %s\n",
227-
formatCurrency(breakdown.Author.GitHubContextCost), breakdown.Author.Sessions, formatTimeUnit(breakdown.Author.GitHubContextHours))
224+
fmt.Printf(" GitHub Activity %12s %d sessions • %s\n",
225+
formatCurrency(breakdown.Author.GitHubCost), breakdown.Author.Sessions, formatTimeUnit(breakdown.Author.GitHubHours))
226+
fmt.Printf(" GitHub Context Switching %12s %s\n",
227+
formatCurrency(breakdown.Author.GitHubContextCost), formatTimeUnit(breakdown.Author.GitHubContextHours))
228228
fmt.Println(" ────────────")
229229
fmt.Printf(" Subtotal %12s %s\n",
230230
formatCurrency(breakdown.Author.TotalCost), formatTimeUnit(breakdown.Author.TotalHours))
@@ -244,10 +244,10 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
244244
fmt.Println(" ────────────")
245245
for _, p := range breakdown.Participants {
246246
fmt.Printf(" %s\n", p.Actor)
247-
fmt.Printf(" Review Activity %12s %d events • %s\n",
248-
formatCurrency(p.GitHubCost), p.Events, formatTimeUnit(p.GitHubHours))
249-
fmt.Printf(" Context Switching %12s %d sessions • %s\n",
250-
formatCurrency(p.GitHubContextCost), p.Sessions, formatTimeUnit(p.GitHubContextHours))
247+
fmt.Printf(" Review Activity %12s %d sessions • %s\n",
248+
formatCurrency(p.GitHubCost), p.Sessions, formatTimeUnit(p.GitHubHours))
249+
fmt.Printf(" Context Switching %12s %s\n",
250+
formatCurrency(p.GitHubContextCost), formatTimeUnit(p.GitHubContextHours))
251251
}
252252
fmt.Println(" ────────────")
253253
fmt.Printf(" Subtotal %12s %s\n",

pkg/cost/cost.go

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ func DefaultConfig() Config {
7373
AnnualSalary: 250000.0,
7474
BenefitsMultiplier: 1.3,
7575
HoursPerYear: 2080.0,
76-
EventDuration: 20 * time.Minute,
77-
ContextSwitchDuration: 20 * time.Minute,
78-
SessionGapThreshold: 60 * time.Minute,
76+
EventDuration: 10 * time.Minute, // 10 minutes per GitHub event
77+
ContextSwitchDuration: 20 * time.Minute, // 20 minutes to context switch in/out
78+
SessionGapThreshold: 20 * time.Minute, // Events within 20 min are same session
7979
DeliveryDelayFactor: 0.15, // 15% opportunity cost
8080
CoordinationFactor: 0.05, // 5% mental overhead
8181
MaxDelayAfterLastEvent: 14 * 24 * time.Hour, // 14 days (2 weeks) after last event
@@ -592,19 +592,25 @@ func calculateParticipantCosts(data PRData, cfg Config, hourlyRate float64) []Pa
592592
// calculateSessionCosts computes GitHub and context switching costs based on event sessions.
593593
//
594594
// Session Logic:
595-
// - Events within SessionGapThreshold (default 60 min) are part of the same session
596-
// - Events >60 min apart start a new session
595+
// - Events within SessionGapThreshold (default 20 min) are part of the same session
596+
// - Events >20 min apart start a new session
597597
//
598-
// Time Calculation per Session:
599-
// - First event: ContextSwitchIn + EventTime + GapToNext (or ContextSwitchOut if last)
600-
// - Middle events: GapFromPrev + EventTime + GapToNext
601-
// - Last event: GapFromPrev + EventTime + ContextSwitchOut
598+
// GitHub Time Calculation:
599+
// - Each event counts as EventDuration (default 10 min)
600+
// - Gaps between events within a session don't add time (assumed to be part of the work)
602601
//
603-
// Example: 3 events 5 minutes apart
604-
// - Event 1: 20 (context in) + 20 (event) + 5 (gap) = 45 min
605-
// - Event 2: 5 (gap) + 20 (event) + 5 (gap) = 30 min
606-
// - Event 3: 5 (gap) + 20 (event) + 20 (context out) = 45 min
607-
// - Total: 120 minutes (60 GitHub, 40 context, 20 gaps).
602+
// Context Switching:
603+
// - First session: ContextSwitchDuration (20 min) at start
604+
// - Between sessions: min(2 × ContextSwitchDuration, gap) to avoid double-counting
605+
// - If gap >= 40 min: full 20 min out + 20 min in
606+
// - If gap < 40 min: split gap evenly (gap/2 out, gap/2 in)
607+
// - Last session: ContextSwitchDuration (20 min) at end
608+
//
609+
// Example: 3 events in one session, then 1 event 30 min later
610+
// - Session 1: 20 (context in) + 3×10 (events) + 20 (context out, but see gap)
611+
// - Gap: 30 min (< 40), so context overhead = 30 min total (15 out, 15 in)
612+
// - Session 2: (15 context in from gap) + 1×10 (event) + 20 (context out)
613+
// - Total context: 20 + 30 + 20 = 70 min
608614
func calculateSessionCosts(events []ParticipantEvent, cfg Config) (githubHours, contextHours float64, sessions int) {
609615
if len(events) == 0 {
610616
return 0, 0, 0
@@ -621,18 +627,19 @@ func calculateSessionCosts(events []ParticipantEvent, cfg Config) (githubHours,
621627
contextSwitch := cfg.ContextSwitchDuration
622628
eventDur := cfg.EventDuration
623629

624-
var githubTime time.Duration
625-
var contextTime time.Duration
626-
count := 0
630+
// Group events into sessions
631+
type session struct {
632+
start int
633+
end int
634+
}
635+
var sessionGroups []session
627636

628637
i := 0
629638
for i < len(sorted) {
630-
// Start a new session
631-
count++
632639
start := i
640+
end := start
633641

634642
// Find the end of this session (events within SessionGapThreshold)
635-
end := start
636643
for end+1 < len(sorted) {
637644
gap := sorted[end+1].Timestamp.Sub(sorted[end].Timestamp)
638645
if gap > gapThreshold {
@@ -641,35 +648,49 @@ func calculateSessionCosts(events []ParticipantEvent, cfg Config) (githubHours,
641648
end++
642649
}
643650

644-
// Calculate costs for this session
645-
// Context switch in at the start
646-
contextTime += contextSwitch
647-
648-
// First event: use default duration (we don't know how long it took)
649-
githubTime += eventDur
650-
651-
// Subsequent events within the session:
652-
// - If gap <= eventDur (20 min): use actual gap (we know they stayed engaged)
653-
// - If gap > eventDur: use eventDur (we don't know what they did during the gap)
654-
for j := start; j < end; j++ {
655-
gap := sorted[j+1].Timestamp.Sub(sorted[j].Timestamp)
656-
if gap <= eventDur {
657-
githubTime += gap
658-
} else {
659-
githubTime += eventDur
660-
}
661-
}
651+
sessionGroups = append(sessionGroups, session{start: start, end: end})
652+
i = end + 1
653+
}
662654

663-
// Context switch out at the end
664-
contextTime += contextSwitch
655+
// Calculate GitHub time (simple: eventDur per event)
656+
var githubTime time.Duration
657+
for _, sess := range sessionGroups {
658+
eventsInSession := sess.end - sess.start + 1
659+
githubTime += time.Duration(eventsInSession) * eventDur
660+
}
665661

666-
// Move to next session
667-
i = end + 1
662+
// Calculate context switching with gap awareness
663+
var contextTime time.Duration
664+
665+
if len(sessionGroups) == 0 {
666+
return 0, 0, 0
668667
}
669668

669+
// First session: context in
670+
contextTime += contextSwitch
671+
672+
// Between sessions: context out + context in, capped by gap
673+
for i := 0; i < len(sessionGroups)-1; i++ {
674+
lastEventOfSession := sorted[sessionGroups[i].end].Timestamp
675+
firstEventOfNextSession := sorted[sessionGroups[i+1].start].Timestamp
676+
gap := firstEventOfNextSession.Sub(lastEventOfSession)
677+
678+
// Maximum context switch is 2 × contextSwitch (out + in)
679+
maxContextSwitch := 2 * contextSwitch
680+
if gap >= maxContextSwitch {
681+
contextTime += maxContextSwitch
682+
} else {
683+
// Cap at gap (implicitly split as gap/2 out + gap/2 in)
684+
contextTime += gap
685+
}
686+
}
687+
688+
// Last session: context out
689+
contextTime += contextSwitch
690+
670691
githubHours = githubTime.Hours()
671692
contextHours = contextTime.Hours()
672-
sessions = count
693+
sessionCount := len(sessionGroups)
673694

674-
return githubHours, contextHours, sessions
695+
return githubHours, contextHours, sessionCount
675696
}

pkg/cost/cost_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ func TestDefaultConfig(t *testing.T) {
1919
t.Errorf("Expected benefits multiplier 1.3, got %.2f", cfg.BenefitsMultiplier)
2020
}
2121

22-
if cfg.EventDuration != 20*time.Minute {
23-
t.Errorf("Expected 20 minutes per event, got %v", cfg.EventDuration)
22+
if cfg.EventDuration != 10*time.Minute {
23+
t.Errorf("Expected 10 minutes per event, got %v", cfg.EventDuration)
2424
}
2525

26-
if cfg.SessionGapThreshold != 60*time.Minute {
27-
t.Errorf("Expected 60 minute session gap, got %v", cfg.SessionGapThreshold)
26+
if cfg.SessionGapThreshold != 20*time.Minute {
27+
t.Errorf("Expected 20 minute session gap, got %v", cfg.SessionGapThreshold)
2828
}
2929

3030
if cfg.DeliveryDelayFactor != 0.15 {

0 commit comments

Comments
 (0)