Split session time into on/off/unclassified buckets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -249,6 +249,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
||||
SessionID: sessionID,
|
||||
StartedUnix: now.Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
seed := c.latestWindow
|
||||
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
||||
@@ -278,7 +280,7 @@ func (c *Controller) enterReview(outcome string) error {
|
||||
}
|
||||
// Flush the final open segment, then freeze accounting.
|
||||
if c.stats != nil && c.stats.hasLast {
|
||||
c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
|
||||
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
|
||||
c.stats.hasLast = false
|
||||
}
|
||||
c.runtimeState = next
|
||||
|
||||
@@ -1376,3 +1376,35 @@ func TestEnforcementLevelPersists(t *testing.T) {
|
||||
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditLockedSplitsByDriftStatus(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.stats = &EvidenceStats{
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
k := bucketKey{Class: "code", Title: "main.go"}
|
||||
|
||||
c.driftStatus = driftOnTask
|
||||
c.creditLocked(k, 10*time.Second)
|
||||
c.driftStatus = driftDrifting
|
||||
c.creditLocked(k, 5*time.Second)
|
||||
c.driftStatus = driftIdle
|
||||
c.creditLocked(k, 3*time.Second)
|
||||
c.driftStatus = driftPending
|
||||
c.creditLocked(k, 2*time.Second)
|
||||
|
||||
if got := c.stats.OnTask[k]; got != 10*time.Second {
|
||||
t.Errorf("OnTask = %v, want 10s", got)
|
||||
}
|
||||
if got := c.stats.OffTask[k]; got != 5*time.Second {
|
||||
t.Errorf("OffTask = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending
|
||||
t.Errorf("unclassified = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four
|
||||
t.Errorf("Buckets total = %v, want 20s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ type bucketKey struct{ Class, Title string }
|
||||
|
||||
// EvidenceStats is the in-memory accounting for the current session only.
|
||||
type EvidenceStats struct {
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
|
||||
OnTask map[bucketKey]time.Duration // on-task portion per bucket
|
||||
OffTask map[bucketKey]time.Duration // off-task portion per bucket
|
||||
unclassified time.Duration // idle/pending time; aggregate only
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
}
|
||||
|
||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||
@@ -26,7 +29,7 @@ type EvidenceStats struct {
|
||||
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
||||
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||
if c.stats.hasLast {
|
||||
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
|
||||
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
|
||||
}
|
||||
newKey := keyFor(snap)
|
||||
if c.stats.hasLast && newKey != c.stats.lastKey {
|
||||
@@ -38,6 +41,24 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||
c.stats.Current = snap
|
||||
}
|
||||
|
||||
// creditLocked credits duration d to bucket k: always to the running total, and
|
||||
// to the on/off/unclassified split per the live drift status — which, at every
|
||||
// credit site, is the classification of the segment being credited (applyEvent
|
||||
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
|
||||
// before the state transition). idle/pending route to unclassified: honest, never
|
||||
// falsely on-task. Caller holds mu.
|
||||
func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
|
||||
c.stats.Buckets[k] += d
|
||||
switch c.driftStatus {
|
||||
case driftOnTask:
|
||||
c.stats.OnTask[k] += d
|
||||
case driftDrifting:
|
||||
c.stats.OffTask[k] += d
|
||||
default: // driftIdle, driftPending
|
||||
c.stats.unclassified += d
|
||||
}
|
||||
}
|
||||
|
||||
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
||||
func (c *Controller) replayStats(sessionID string) {
|
||||
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||
@@ -46,6 +67,8 @@ func (c *Controller) replayStats(sessionID string) {
|
||||
SessionID: sessionID,
|
||||
StartedUnix: c.clock().Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -53,7 +76,12 @@ func (c *Controller) replayStats(sessionID string) {
|
||||
SessionID: sessionID,
|
||||
StartedUnix: events[0].AtUnixMillis / 1000,
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
// Drift status is not persisted, so replayed pre-crash segments credit to
|
||||
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
|
||||
// only the on/off split degrades — honest, never falsely on-task.
|
||||
for _, e := range events {
|
||||
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user