diff --git a/internal/session/session.go b/internal/session/session.go index 8f2110a..4a29625 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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 diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 119a809..3232e3a 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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) + } +} diff --git a/internal/session/stats.go b/internal/session/stats.go index 60db52d..ff285d4 100644 --- a/internal/session/stats.go +++ b/internal/session/stats.go @@ -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)) }