• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pashagolub / confelo / 18841565881

27 Oct 2025 12:51PM UTC coverage: 63.047%. First build
18841565881

Pull #1

github

web-flow
Merge 8f91927c2 into fde715f38
Pull Request #1: Add GHA workflows

103 of 140 new or added lines in 6 files covered. (73.57%)

3605 of 5718 relevant lines covered (63.05%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.86
/pkg/data/session.go
1
// Package data provides session management functionality for conference talk ranking.
2
// It implements session state persistence, comparison tracking, and integration
3
// with the Elo rating engine for seamless rating updates and convergence tracking.
4
package data
5

6
import (
7
        "crypto/rand"
8
        "encoding/hex"
9
        "encoding/json"
10
        "errors"
11
        "fmt"
12
        "math"
13
        "os"
14
        "path/filepath"
15
        "sort"
16
        "strings"
17
        "sync"
18
        "time"
19
)
20

21
// Error types for session management
22
var (
23
        ErrSessionNotFound       = errors.New("session not found")
24
        ErrInvalidSessionState   = errors.New("invalid session state")
25
        ErrSessionCorrupted      = errors.New("session data corrupted")
26
        ErrComparisonNotActive   = errors.New("no active comparison")
27
        ErrInvalidComparison     = errors.New("invalid comparison data")
28
        ErrAtomicOperationFailed = errors.New("atomic operation failed")
29
        ErrModeDetectionFailed   = errors.New("session mode detection failed")
30
        ErrSessionNameInvalid    = errors.New("session name contains invalid characters")
31
)
32

33
// SessionMode represents the detected operational mode for automatic mode detection
34
type SessionMode int
35

36
const (
37
        // StartMode indicates a new session should be created
38
        StartMode SessionMode = iota
39
        // ResumeMode indicates an existing session should be resumed
40
        ResumeMode
41
)
42

43
// String returns a string representation of the SessionMode
44
func (sm SessionMode) String() string {
×
45
        switch sm {
×
46
        case StartMode:
×
47
                return "Start"
×
48
        case ResumeMode:
×
49
                return "Resume"
×
50
        default:
×
51
                return "Unknown"
×
52
        }
53
}
54

55
// SessionStatus represents the current state of a ranking session
56
type SessionStatus string
57

58
const (
59
        // StatusCreated indicates a newly created session that hasn't started comparisons yet
60
        StatusCreated SessionStatus = "created"
61
        // StatusActive indicates a session with ongoing comparisons
62
        StatusActive SessionStatus = "active"
63
        // StatusPaused indicates a temporarily paused session
64
        StatusPaused SessionStatus = "paused"
65
        // StatusComplete indicates a finished session with final rankings
66
        StatusComplete SessionStatus = "complete"
67
)
68

69
// ComparisonMethod represents the type of comparison being performed
70
type ComparisonMethod string
71

72
const (
73
        // MethodPairwise compares two proposals at a time
74
        MethodPairwise ComparisonMethod = "pairwise"
75
        // MethodTrio compares three proposals at a time
76
        MethodTrio ComparisonMethod = "trio"
77
        // MethodQuartet compares four proposals at a time
78
        MethodQuartet ComparisonMethod = "quartet"
79
)
80

81
// Session manages the complete ranking workflow and persistent state
82
type Session struct {
83
        // Core identity
84
        Name      string        `json:"name"`       // Unique session name (used as identifier)
85
        Status    SessionStatus `json:"status"`     // Current session state
86
        CreatedAt time.Time     `json:"created_at"` // Session creation timestamp
87
        UpdatedAt time.Time     `json:"updated_at"` // Last modification timestamp
88

89
        // Configuration and data
90
        Config         SessionConfig      `json:"config"`          // Session configuration
91
        Proposals      []Proposal         `json:"-"`               // Collection of proposals (reloaded from CSV, never serialized)
92
        ProposalScores map[string]float64 `json:"proposal_scores"` // Current scores by ID (lightweight persistence)
93
        ProposalIndex  map[string]int     `json:"-"`               // Fast ID lookup (not serialized)
94
        InputCSVPath   string             `json:"input_csv_path"`  // Original input CSV file path for export
95

96
        // Comparison tracking (lightweight persistence for progress/confidence)
97
        ComparisonCounts     map[string]int   `json:"comparison_counts"` // Per-proposal comparison count for confidence
98
        TotalComparisons     int              `json:"total_comparisons"` // Total comparisons performed for progress
99
        CurrentComparison    *ComparisonState `json:"-"`                 // Active comparison state (not persisted)
100
        CompletedComparisons []Comparison     `json:"-"`                 // Historical comparisons (not persisted)
101

102
        // Analytics and optimization
103
        ConvergenceMetrics *ConvergenceMetrics `json:"convergence_metrics"` // Progress tracking
104
        MatchupHistory     []MatchupHistory    `json:"matchup_history"`     // Pairing optimization data
105
        RatingBins         []RatingBin         `json:"rating_bins"`         // Strategic grouping
106

107
        // Internal state management
108
        mutex            sync.RWMutex `json:"-"` // Thread safety (not serialized)
109
        storageDirectory string       `json:"-"` // Where to persist session
110
}
111

112
// ComparisonState represents the current active comparison
113
type ComparisonState struct {
114
        ID             string           `json:"id"`              // Unique comparison identifier
115
        ProposalIDs    []string         `json:"proposal_ids"`    // Proposals being compared
116
        Method         ComparisonMethod `json:"method"`          // Comparison type
117
        StartedAt      time.Time        `json:"started_at"`      // When comparison began
118
        PresentedOrder []string         `json:"presented_order"` // Order shown to user (for consistency)
119
}
120

121
// Comparison records a completed evaluation event between proposals
122
type Comparison struct {
123
        ID          string           `json:"id"`           // Unique comparison identifier
124
        SessionName string           `json:"session_name"` // Parent session name
125
        ProposalIDs []string         `json:"proposal_ids"` // Proposals that were compared
126
        WinnerID    string           `json:"winner_id"`    // Selected best proposal ID (empty if skipped)
127
        Rankings    []string         `json:"rankings"`     // Full ranking order for multi-proposal (optional)
128
        Method      ComparisonMethod `json:"method"`       // Comparison type
129
        Timestamp   time.Time        `json:"timestamp"`    // When comparison was completed
130
        Duration    time.Duration    `json:"duration"`     // Time spent on comparison
131
        Skipped     bool             `json:"skipped"`      // Whether comparison was skipped
132
        SkipReason  string           `json:"skip_reason"`  // Why comparison was skipped (optional)
133
        EloUpdates  []EloUpdate      `json:"elo_updates"`  // Rating changes from this comparison
134
}
135

136
// EloUpdate records rating changes from a single comparison
137
type EloUpdate struct {
138
        ID           string  `json:"id"`            // Unique update identifier
139
        ComparisonID string  `json:"comparison_id"` // Parent comparison
140
        ProposalID   string  `json:"proposal_id"`   // Affected proposal
141
        OldRating    float64 `json:"old_rating"`    // Rating before comparison
142
        NewRating    float64 `json:"new_rating"`    // Rating after comparison
143
        RatingDelta  float64 `json:"rating_delta"`  // Change amount (NewRating - OldRating)
144
        KFactor      int     `json:"k_factor"`      // K-factor used for this calculation
145
}
146

147
// ConvergenceMetrics tracks session progress and convergence indicators
148
type ConvergenceMetrics struct {
149
        TotalComparisons    int       `json:"total_comparisons"`     // Number of comparisons performed
150
        AvgRatingChange     float64   `json:"avg_rating_change"`     // Rolling average of rating changes
151
        RatingVariance      float64   `json:"rating_variance"`       // Variance in recent rating changes
152
        RankingStability    float64   `json:"ranking_stability"`     // Percentage of stable top-N positions
153
        CoveragePercentage  float64   `json:"coverage_percentage"`   // Percentage of meaningful pairs compared
154
        ConvergenceScore    float64   `json:"convergence_score"`     // Overall convergence indicator 0-1
155
        LastCalculated      time.Time `json:"last_calculated"`       // When metrics were last updated
156
        RecentRatingChanges []float64 `json:"recent_rating_changes"` // Last N rating changes for variance calc
157
}
158

159
// MatchupHistory tracks comparison pairings to optimize future matchup selection
160
type MatchupHistory struct {
161
        ProposalA               string    `json:"proposal_a"`                // First proposal ID
162
        ProposalB               string    `json:"proposal_b"`                // Second proposal ID
163
        ComparisonCount         int       `json:"comparison_count"`          // Times this pair has been compared
164
        LastCompared            time.Time `json:"last_compared"`             // Most recent comparison timestamp
165
        RatingDifferenceHistory []float64 `json:"rating_difference_history"` // Rating gaps at each comparison
166
        InformationGain         float64   `json:"information_gain"`          // Measured impact on ranking stability
167
}
168

169
// RatingBin groups proposals by rating ranges for strategic matchup selection
170
type RatingBin struct {
171
        BinIndex    int       `json:"bin_index"`    // Numeric bin identifier
172
        MinRating   float64   `json:"min_rating"`   // Lower bound of rating range
173
        MaxRating   float64   `json:"max_rating"`   // Upper bound of rating range
174
        ProposalIDs []string  `json:"proposal_ids"` // Proposals currently in this bin
175
        LastUpdated time.Time `json:"last_updated"` // When bin assignments were recalculated
176
}
177

178
// NewSession creates a new ranking session with the given proposals and configuration
179
// inputCSVPath is required - it's used to reload proposals when resuming the session
180
func NewSession(name string, proposals []Proposal, config SessionConfig, inputCSVPath string) (*Session, error) {
1✔
181
        if len(proposals) < 2 {
2✔
182
                return nil, fmt.Errorf("%w: session must contain at least 2 proposals", ErrInvalidSessionState)
1✔
183
        }
1✔
184

185
        if name == "" {
2✔
186
                return nil, fmt.Errorf("%w: session name is required", ErrRequiredField)
1✔
187
        }
1✔
188

189
        if inputCSVPath == "" {
1✔
190
                return nil, fmt.Errorf("%w: input CSV path is required", ErrRequiredField)
×
191
        }
×
192

193
        // Validate configuration
194
        if err := config.Validate(); err != nil {
1✔
195
                return nil, fmt.Errorf("invalid session configuration: %w", err)
×
196
        }
×
197

198
        now := time.Now()
1✔
199

1✔
200
        // Build proposal index for fast lookups
1✔
201
        proposalIndex := make(map[string]int, len(proposals))
1✔
202
        for i, proposal := range proposals {
2✔
203
                proposalIndex[proposal.ID] = i
1✔
204
        }
1✔
205

206
        // Initialize convergence metrics
207
        convergenceMetrics := &ConvergenceMetrics{
1✔
208
                TotalComparisons:    0,
1✔
209
                AvgRatingChange:     0.0,
1✔
210
                RatingVariance:      0.0,
1✔
211
                RankingStability:    0.0,
1✔
212
                CoveragePercentage:  0.0,
1✔
213
                ConvergenceScore:    0.0,
1✔
214
                LastCalculated:      now,
1✔
215
                RecentRatingChanges: make([]float64, 0, 10), // Keep last 10 changes for variance
1✔
216
        }
1✔
217

1✔
218
        session := &Session{
1✔
219
                Name:                 name,
1✔
220
                Status:               StatusCreated,
1✔
221
                CreatedAt:            now,
1✔
222
                UpdatedAt:            now,
1✔
223
                Config:               config,
1✔
224
                Proposals:            proposals,
1✔
225
                ProposalIndex:        proposalIndex,
1✔
226
                InputCSVPath:         inputCSVPath, // Store CSV path for reload on resume
1✔
227
                ComparisonCounts:     make(map[string]int),
1✔
228
                TotalComparisons:     0,
1✔
229
                CurrentComparison:    nil,
1✔
230
                CompletedComparisons: make([]Comparison, 0),
1✔
231
                ConvergenceMetrics:   convergenceMetrics,
1✔
232
                MatchupHistory:       make([]MatchupHistory, 0),
1✔
233
                RatingBins:           make([]RatingBin, 0),
1✔
234
                storageDirectory:     "./sessions", // Default storage directory
1✔
235
        }
1✔
236

1✔
237
        // Initialize rating bins
1✔
238
        session.updateRatingBins()
1✔
239

1✔
240
        return session, nil
1✔
241
}
242

243
// LoadSession loads an existing session from the storage directory
244
// This is a convenience function that uses FileStorage internally
245
func LoadSession(sessionName string, storageDir string) (*Session, error) {
1✔
246
        if sessionName == "" {
1✔
247
                return nil, fmt.Errorf("%w: session name is required", ErrRequiredField)
×
248
        }
×
249

250
        sessionFile := filepath.Join(storageDir, SanitizeFilename(sessionName)+".json")
1✔
251

1✔
252
        // Check if session file exists
1✔
253
        if _, err := os.Stat(sessionFile); os.IsNotExist(err) {
2✔
254
                return nil, fmt.Errorf("%w: session %s", ErrSessionNotFound, sessionName)
1✔
255
        }
1✔
256

257
        // Use FileStorage to load session properly (handles proposal reloading from CSV)
258
        storage := NewFileStorage()
1✔
259
        session, err := storage.LoadSession(sessionFile)
1✔
260
        if err != nil {
2✔
261
                return nil, err
1✔
262
        }
1✔
263

264
        // Set storage directory
265
        session.storageDirectory = storageDir
1✔
266

1✔
267
        // Validate loaded session
1✔
268
        if err := session.validate(); err != nil {
1✔
269
                return nil, fmt.Errorf("%w: %v", ErrSessionCorrupted, err)
×
270
        }
×
271

272
        return session, nil
1✔
273
}
274

275
// validate performs integrity checks on the loaded session
276
func (s *Session) validate() error {
1✔
277
        // Check basic fields
1✔
278
        if s.Name == "" {
1✔
279
                return errors.New("session name is empty")
×
280
        }
×
281
        if len(s.Proposals) < 2 {
1✔
282
                return errors.New("session must have at least 2 proposals")
×
283
        }
×
284

285
        // Validate proposal index consistency
286
        if len(s.ProposalIndex) != len(s.Proposals) {
1✔
287
                return errors.New("proposal index size mismatch")
×
288
        }
×
289

290
        for i, proposal := range s.Proposals {
2✔
291
                if idx, exists := s.ProposalIndex[proposal.ID]; !exists || idx != i {
1✔
292
                        return fmt.Errorf("proposal index inconsistency for ID: %s", proposal.ID)
×
293
                }
×
294
        }
295

296
        // Validate configuration
297
        if err := s.Config.Validate(); err != nil {
1✔
298
                return fmt.Errorf("invalid session configuration: %w", err)
×
299
        }
×
300

301
        return nil
1✔
302
}
303

304
// Save persists the session to storage using atomic operations
305
func (s *Session) Save() error {
1✔
306
        s.mutex.Lock()
1✔
307
        defer s.mutex.Unlock()
1✔
308

1✔
309
        // Update timestamp
1✔
310
        s.UpdatedAt = time.Now()
1✔
311

1✔
312
        // Use FileStorage for consistent saving
1✔
313
        storage := NewFileStorage()
1✔
314
        sessionFile := filepath.Join(s.storageDirectory, SanitizeFilename(s.Name)+".json")
1✔
315
        return storage.SaveSession(s, sessionFile)
1✔
316
}
1✔
317

318
// SetStorageDirectory configures where the session should be persisted
319
func (s *Session) SetStorageDirectory(dir string) {
1✔
320
        s.mutex.Lock()
1✔
321
        defer s.mutex.Unlock()
1✔
322
        s.storageDirectory = dir
1✔
323
}
1✔
324

325
// GetStatus returns the current session status (thread-safe)
326
func (s *Session) GetStatus() SessionStatus {
1✔
327
        s.mutex.RLock()
1✔
328
        defer s.mutex.RUnlock()
1✔
329
        return s.Status
1✔
330
}
1✔
331

332
// GetProposalCount returns the number of proposals in the session
333
func (s *Session) GetProposalCount() int {
1✔
334
        s.mutex.RLock()
1✔
335
        defer s.mutex.RUnlock()
1✔
336
        return len(s.Proposals)
1✔
337
}
1✔
338

339
// GetProposalByID retrieves a proposal by its ID
340
func (s *Session) GetProposalByID(id string) (*Proposal, error) {
1✔
341
        s.mutex.RLock()
1✔
342
        defer s.mutex.RUnlock()
1✔
343

1✔
344
        idx, exists := s.ProposalIndex[id]
1✔
345
        if !exists {
1✔
346
                return nil, fmt.Errorf("proposal not found: %s", id)
×
347
        }
×
348

349
        // Return a copy to prevent external modifications
350
        proposal := s.Proposals[idx]
1✔
351
        return &proposal, nil
1✔
352
}
353

354
// GetProposals returns a copy of all proposals (thread-safe)
355
func (s *Session) GetProposals() []Proposal {
1✔
356
        s.mutex.RLock()
1✔
357
        defer s.mutex.RUnlock()
1✔
358

1✔
359
        // Return a deep copy to prevent external modifications
1✔
360
        proposals := make([]Proposal, len(s.Proposals))
1✔
361
        copy(proposals, s.Proposals)
1✔
362
        return proposals
1✔
363
}
1✔
364

365
// updateRatingBins recalculates rating bin assignments based on current proposal ratings
366
func (s *Session) updateRatingBins() {
1✔
367
        // This is a placeholder implementation - will be fully implemented in integration task
1✔
368
        // For now, just initialize empty bins
1✔
369
        s.RatingBins = make([]RatingBin, 0)
1✔
370
        // TODO: Implement intelligent binning algorithm based on rating distribution
1✔
371
}
1✔
372

373
// StartComparison initiates a new comparison with the specified proposals
374
func (s *Session) StartComparison(proposalIDs []string, method ComparisonMethod) error {
1✔
375
        s.mutex.Lock()
1✔
376
        defer s.mutex.Unlock()
1✔
377

1✔
378
        // Validate input
1✔
379
        if len(proposalIDs) < 2 {
2✔
380
                return fmt.Errorf("%w: comparison requires at least 2 proposals", ErrInvalidComparison)
1✔
381
        }
1✔
382

383
        // Validate comparison method matches proposal count
384
        var expectedCount int
1✔
385
        switch method {
1✔
386
        case MethodPairwise:
1✔
387
                expectedCount = 2
1✔
388
        case MethodTrio:
1✔
389
                expectedCount = 3
1✔
390
        case MethodQuartet:
×
391
                expectedCount = 4
×
392
        default:
×
393
                return fmt.Errorf("%w: unknown comparison method: %s", ErrInvalidComparison, method)
×
394
        }
395

396
        if len(proposalIDs) != expectedCount {
2✔
397
                return fmt.Errorf("%w: method %s requires exactly %d proposals, got %d",
1✔
398
                        ErrInvalidComparison, method, expectedCount, len(proposalIDs))
1✔
399
        }
1✔
400

401
        // Verify all proposals exist
402
        for _, id := range proposalIDs {
2✔
403
                if _, exists := s.ProposalIndex[id]; !exists {
2✔
404
                        return fmt.Errorf("%w: proposal not found: %s", ErrInvalidComparison, id)
1✔
405
                }
1✔
406
        }
407

408
        // Check if there's already an active comparison
409
        if s.CurrentComparison != nil {
1✔
410
                return fmt.Errorf("%w: comparison already in progress", ErrInvalidSessionState)
×
411
        }
×
412

413
        // Generate comparison ID
414
        comparisonID, err := generateComparisonID()
1✔
415
        if err != nil {
1✔
416
                return fmt.Errorf("failed to generate comparison ID: %w", err)
×
417
        }
×
418

419
        // Create comparison state
420
        s.CurrentComparison = &ComparisonState{
1✔
421
                ID:             comparisonID,
1✔
422
                ProposalIDs:    make([]string, len(proposalIDs)),
1✔
423
                Method:         method,
1✔
424
                StartedAt:      time.Now(),
1✔
425
                PresentedOrder: make([]string, len(proposalIDs)),
1✔
426
        }
1✔
427

1✔
428
        // Copy proposal IDs and create presentation order
1✔
429
        copy(s.CurrentComparison.ProposalIDs, proposalIDs)
1✔
430
        copy(s.CurrentComparison.PresentedOrder, proposalIDs)
1✔
431
        // TODO: Add randomization of presentation order to avoid bias
1✔
432

1✔
433
        // Update session status
1✔
434
        if s.Status == StatusCreated {
2✔
435
                s.Status = StatusActive
1✔
436
        }
1✔
437

438
        s.UpdatedAt = time.Now()
1✔
439

1✔
440
        // Note: Autosave disabled for now to avoid mutex deadlock issues
1✔
441
        // TODO: Implement proper autosave mechanism
1✔
442

1✔
443
        return nil
1✔
444
}
445

446
// CompleteComparison finishes the current comparison with the specified result
447
// Automatically saves the session after completion
448
func (s *Session) CompleteComparison(winnerID string, rankings []string, skipped bool, skipReason string) (*Comparison, error) {
1✔
449
        s.mutex.Lock()
1✔
450
        comparison, err := s.completeComparisonInternal(winnerID, rankings, skipped, skipReason)
1✔
451
        s.mutex.Unlock()
1✔
452

1✔
453
        // Auto-save after each comparison (outside mutex to avoid deadlock)
1✔
454
        if err == nil && s.storageDirectory != "" {
2✔
455
                if saveErr := s.Save(); saveErr != nil {
1✔
456
                        // Log warning but don't fail the comparison
×
457
                        fmt.Printf("Warning: failed to auto-save session after comparison: %v\n", saveErr)
×
458
                }
×
459
        }
460

461
        return comparison, err
1✔
462
}
463

464
// completeComparisonInternal performs comparison completion without acquiring mutex (internal use)
465
func (s *Session) completeComparisonInternal(winnerID string, rankings []string, skipped bool, skipReason string) (*Comparison, error) {
1✔
466
        // Check if there's an active comparison
1✔
467
        if s.CurrentComparison == nil {
2✔
468
                return nil, ErrComparisonNotActive
1✔
469
        }
1✔
470

471
        comparison := &Comparison{
1✔
472
                ID:          s.CurrentComparison.ID,
1✔
473
                SessionName: s.Name,
1✔
474
                ProposalIDs: make([]string, len(s.CurrentComparison.ProposalIDs)),
1✔
475
                WinnerID:    winnerID,
1✔
476
                Rankings:    rankings,
1✔
477
                Method:      s.CurrentComparison.Method,
1✔
478
                Timestamp:   time.Now(),
1✔
479
                Duration:    time.Since(s.CurrentComparison.StartedAt),
1✔
480
                Skipped:     skipped,
1✔
481
                SkipReason:  skipReason,
1✔
482
                EloUpdates:  make([]EloUpdate, 0),
1✔
483
        }
1✔
484

1✔
485
        copy(comparison.ProposalIDs, s.CurrentComparison.ProposalIDs)
1✔
486

1✔
487
        // Validate result if not skipped
1✔
488
        if !skipped {
2✔
489
                if winnerID == "" {
1✔
490
                        return nil, fmt.Errorf("%w: winner ID required for non-skipped comparison", ErrInvalidComparison)
×
491
                }
×
492

493
                // Verify winner is in the comparison
494
                winnerFound := false
1✔
495
                for _, id := range comparison.ProposalIDs {
2✔
496
                        if id == winnerID {
2✔
497
                                winnerFound = true
1✔
498
                                break
1✔
499
                        }
500
                }
501
                if !winnerFound {
2✔
502
                        return nil, fmt.Errorf("%w: winner ID not in comparison proposals", ErrInvalidComparison)
1✔
503
                }
1✔
504

505
                // Validate rankings if provided (for multi-proposal comparisons)
506
                if rankings != nil {
2✔
507
                        if len(rankings) != len(comparison.ProposalIDs) {
1✔
508
                                return nil, fmt.Errorf("%w: rankings length must match proposal count", ErrInvalidComparison)
×
509
                        }
×
510

511
                        // Verify all proposals are in rankings
512
                        rankingSet := make(map[string]bool)
1✔
513
                        for _, id := range rankings {
2✔
514
                                if rankingSet[id] {
1✔
515
                                        return nil, fmt.Errorf("%w: duplicate proposal in rankings: %s", ErrInvalidComparison, id)
×
516
                                }
×
517
                                rankingSet[id] = true
1✔
518
                        }
519

520
                        for _, id := range comparison.ProposalIDs {
2✔
521
                                if !rankingSet[id] {
1✔
522
                                        return nil, fmt.Errorf("%w: proposal missing from rankings: %s", ErrInvalidComparison, id)
×
523
                                }
×
524
                        }
525
                }
526
        }
527

528
        // Add to completed comparisons
529
        s.CompletedComparisons = append(s.CompletedComparisons, *comparison)
1✔
530

1✔
531
        // Clear current comparison
1✔
532
        s.CurrentComparison = nil
1✔
533

1✔
534
        // Update convergence metrics
1✔
535
        s.updateConvergenceMetrics()
1✔
536

1✔
537
        s.UpdatedAt = time.Now()
1✔
538

1✔
539
        // Note: Autosave disabled for now to avoid mutex deadlock issues
1✔
540
        // TODO: Implement proper autosave mechanism
1✔
541

1✔
542
        return comparison, nil
1✔
543
}
544

545
// CancelComparison aborts the current active comparison
546
func (s *Session) CancelComparison() error {
×
547
        s.mutex.Lock()
×
548
        defer s.mutex.Unlock()
×
549

×
550
        if s.CurrentComparison == nil {
×
551
                return ErrComparisonNotActive
×
552
        }
×
553

554
        s.CurrentComparison = nil
×
555
        s.UpdatedAt = time.Now()
×
556

×
557
        return nil
×
558
}
559

560
// CompleteSession marks the session as complete
561
func (s *Session) CompleteSession() error {
×
562
        s.mutex.Lock()
×
563

×
564
        // Cancel any active comparison
×
565
        s.CurrentComparison = nil
×
566
        s.Status = StatusComplete
×
567

×
568
        s.UpdatedAt = time.Now()
×
569
        s.mutex.Unlock()
×
570

×
571
        // Force save when completing (outside mutex to avoid deadlock)
×
572
        return s.Save()
×
573
}
×
574

575
// GetCurrentComparison returns the current active comparison (thread-safe copy)
576
func (s *Session) GetCurrentComparison() *ComparisonState {
1✔
577
        s.mutex.RLock()
1✔
578
        defer s.mutex.RUnlock()
1✔
579

1✔
580
        if s.CurrentComparison == nil {
2✔
581
                return nil
1✔
582
        }
1✔
583

584
        // Return a deep copy to prevent external modifications
585
        comparison := &ComparisonState{
1✔
586
                ID:             s.CurrentComparison.ID,
1✔
587
                ProposalIDs:    make([]string, len(s.CurrentComparison.ProposalIDs)),
1✔
588
                Method:         s.CurrentComparison.Method,
1✔
589
                StartedAt:      s.CurrentComparison.StartedAt,
1✔
590
                PresentedOrder: make([]string, len(s.CurrentComparison.PresentedOrder)),
1✔
591
        }
1✔
592

1✔
593
        copy(comparison.ProposalIDs, s.CurrentComparison.ProposalIDs)
1✔
594
        copy(comparison.PresentedOrder, s.CurrentComparison.PresentedOrder)
1✔
595

1✔
596
        return comparison
1✔
597
}
598

599
// GetComparisonHistory returns all completed comparisons (thread-safe copy)
600
func (s *Session) GetComparisonHistory() []Comparison {
1✔
601
        s.mutex.RLock()
1✔
602
        defer s.mutex.RUnlock()
1✔
603

1✔
604
        // Return a deep copy to prevent external modifications
1✔
605
        history := make([]Comparison, len(s.CompletedComparisons))
1✔
606
        copy(history, s.CompletedComparisons)
1✔
607
        return history
1✔
608
}
1✔
609

610
// GetConvergenceMetrics returns current convergence metrics (thread-safe copy)
611
func (s *Session) GetConvergenceMetrics() *ConvergenceMetrics {
1✔
612
        s.mutex.RLock()
1✔
613
        defer s.mutex.RUnlock()
1✔
614

1✔
615
        if s.ConvergenceMetrics == nil {
1✔
616
                return nil
×
617
        }
×
618

619
        // Return a copy to prevent external modifications
620
        metrics := *s.ConvergenceMetrics
1✔
621
        metrics.RecentRatingChanges = make([]float64, len(s.ConvergenceMetrics.RecentRatingChanges))
1✔
622
        copy(metrics.RecentRatingChanges, s.ConvergenceMetrics.RecentRatingChanges)
1✔
623

1✔
624
        return &metrics
1✔
625
}
626

627
// generateComparisonID creates a unique identifier for a comparison
628
func generateComparisonID() (string, error) {
1✔
629
        randomBytes := make([]byte, 8)
1✔
630
        if _, err := rand.Read(randomBytes); err != nil {
1✔
631
                return "", err
×
632
        }
×
633
        return fmt.Sprintf("comp_%s", hex.EncodeToString(randomBytes)), nil
1✔
634
}
635

636
// updateConvergenceMetrics recalculates convergence indicators
637
func (s *Session) updateConvergenceMetrics() {
1✔
638
        if s.ConvergenceMetrics == nil {
1✔
639
                return
×
640
        }
×
641

642
        s.ConvergenceMetrics.TotalComparisons = len(s.CompletedComparisons)
1✔
643
        s.ConvergenceMetrics.LastCalculated = time.Now()
1✔
644

1✔
645
        // Perform full convergence calculations
1✔
646
        s.calculateConvergenceMetrics()
1✔
647
}
648

649
// AddEloUpdate records a rating change from a comparison
650
func (s *Session) AddEloUpdate(comparisonID, proposalID string, oldRating, newRating float64, kFactor int) error {
×
651
        s.mutex.Lock()
×
652
        defer s.mutex.Unlock()
×
653

×
654
        // Find the comparison
×
655
        var targetComparison *Comparison
×
656
        for i := range s.CompletedComparisons {
×
657
                if s.CompletedComparisons[i].ID == comparisonID {
×
658
                        targetComparison = &s.CompletedComparisons[i]
×
659
                        break
×
660
                }
661
        }
662

663
        if targetComparison == nil {
×
664
                return fmt.Errorf("comparison not found: %s", comparisonID)
×
665
        }
×
666

667
        // Verify proposal is part of this comparison
668
        proposalFound := false
×
669
        for _, id := range targetComparison.ProposalIDs {
×
670
                if id == proposalID {
×
671
                        proposalFound = true
×
672
                        break
×
673
                }
674
        }
675
        if !proposalFound {
×
676
                return fmt.Errorf("proposal %s not part of comparison %s", proposalID, comparisonID)
×
677
        }
×
678

679
        // Generate update ID
680
        updateID, err := generateUpdateID()
×
681
        if err != nil {
×
682
                return fmt.Errorf("failed to generate update ID: %w", err)
×
683
        }
×
684

685
        // Create EloUpdate
686
        eloUpdate := EloUpdate{
×
687
                ID:           updateID,
×
688
                ComparisonID: comparisonID,
×
689
                ProposalID:   proposalID,
×
690
                OldRating:    oldRating,
×
691
                NewRating:    newRating,
×
692
                RatingDelta:  newRating - oldRating,
×
693
                KFactor:      kFactor,
×
694
        }
×
695

×
696
        // Add to comparison's update list
×
697
        targetComparison.EloUpdates = append(targetComparison.EloUpdates, eloUpdate)
×
698

×
699
        // Update the proposal's rating
×
700
        if idx, exists := s.ProposalIndex[proposalID]; exists {
×
701
                s.Proposals[idx].Score = newRating
×
702
                s.Proposals[idx].UpdatedAt = time.Now()
×
703
        }
×
704

705
        // Update convergence metrics with new rating change
706
        if s.ConvergenceMetrics != nil {
×
707
                // Add to recent rating changes (keep last 10)
×
708
                s.ConvergenceMetrics.RecentRatingChanges = append(s.ConvergenceMetrics.RecentRatingChanges, eloUpdate.RatingDelta)
×
709
                if len(s.ConvergenceMetrics.RecentRatingChanges) > 10 {
×
710
                        s.ConvergenceMetrics.RecentRatingChanges = s.ConvergenceMetrics.RecentRatingChanges[1:]
×
711
                }
×
712

713
                // Recalculate metrics
714
                s.calculateConvergenceMetrics()
×
715
        }
716

717
        // Update rating bins
718
        s.updateRatingBins()
×
719

×
720
        s.UpdatedAt = time.Now()
×
721

×
722
        return nil
×
723
}
724

725
// UpdateProposalRating directly updates a proposal's rating (used by Elo engine)
726
func (s *Session) UpdateProposalRating(proposalID string, newRating float64) error {
×
727
        s.mutex.Lock()
×
728
        defer s.mutex.Unlock()
×
729

×
730
        idx, exists := s.ProposalIndex[proposalID]
×
731
        if !exists {
×
732
                return fmt.Errorf("proposal not found: %s", proposalID)
×
733
        }
×
734

735
        s.Proposals[idx].Score = newRating
×
736
        s.Proposals[idx].UpdatedAt = time.Now()
×
737
        s.UpdatedAt = time.Now()
×
738

×
739
        return nil
×
740
}
741

742
// RecordMatchup tracks a pairwise comparison for optimization purposes
743
func (s *Session) RecordMatchup(proposalA, proposalB string, informationGain float64) {
×
744
        s.mutex.Lock()
×
745
        defer s.mutex.Unlock()
×
746

×
747
        s.recordMatchupInternal(proposalA, proposalB, informationGain)
×
748
}
×
749

750
// recordMatchupInternal tracks a pairwise comparison without acquiring mutex (internal use)
751
func (s *Session) recordMatchupInternal(proposalA, proposalB string, informationGain float64) {
1✔
752
        // Ensure consistent ordering (A < B alphabetically)
1✔
753
        if proposalA > proposalB {
1✔
754
                proposalA, proposalB = proposalB, proposalA
×
755
        }
×
756

757
        // Find existing matchup history
758
        var matchup *MatchupHistory
1✔
759
        for i := range s.MatchupHistory {
2✔
760
                if s.MatchupHistory[i].ProposalA == proposalA && s.MatchupHistory[i].ProposalB == proposalB {
2✔
761
                        matchup = &s.MatchupHistory[i]
1✔
762
                        break
1✔
763
                }
764
        }
765

766
        // Create new matchup history if not found
767
        if matchup == nil {
2✔
768
                s.MatchupHistory = append(s.MatchupHistory, MatchupHistory{
1✔
769
                        ProposalA:               proposalA,
1✔
770
                        ProposalB:               proposalB,
1✔
771
                        ComparisonCount:         0,
1✔
772
                        LastCompared:            time.Time{},
1✔
773
                        RatingDifferenceHistory: make([]float64, 0),
1✔
774
                        InformationGain:         0.0,
1✔
775
                })
1✔
776
                matchup = &s.MatchupHistory[len(s.MatchupHistory)-1]
1✔
777
        }
1✔
778

779
        // Update matchup record
780
        matchup.ComparisonCount++
1✔
781
        matchup.LastCompared = time.Now()
1✔
782
        matchup.InformationGain = informationGain
1✔
783

1✔
784
        // Calculate current rating difference
1✔
785
        ratingA := s.getProposalRating(proposalA)
1✔
786
        ratingB := s.getProposalRating(proposalB)
1✔
787
        ratingDiff := ratingA - ratingB
1✔
788
        if ratingDiff < 0 {
2✔
789
                ratingDiff = -ratingDiff
1✔
790
        }
1✔
791

792
        matchup.RatingDifferenceHistory = append(matchup.RatingDifferenceHistory, ratingDiff)
1✔
793
}
794

795
// getProposalRating gets the current rating for a proposal (internal, no locking)
796
func (s *Session) getProposalRating(proposalID string) float64 {
1✔
797
        if idx, exists := s.ProposalIndex[proposalID]; exists {
2✔
798
                return s.Proposals[idx].Score
1✔
799
        }
1✔
800
        return s.Config.Elo.InitialRating // Default rating if not found
×
801
}
802

803
// GetMatchupHistory returns matchup optimization data (thread-safe copy)
804
func (s *Session) GetMatchupHistory() []MatchupHistory {
1✔
805
        s.mutex.RLock()
1✔
806
        defer s.mutex.RUnlock()
1✔
807

1✔
808
        // Return a deep copy to prevent external modifications
1✔
809
        history := make([]MatchupHistory, len(s.MatchupHistory))
1✔
810
        for i, matchup := range s.MatchupHistory {
2✔
811
                history[i] = MatchupHistory{
1✔
812
                        ProposalA:       matchup.ProposalA,
1✔
813
                        ProposalB:       matchup.ProposalB,
1✔
814
                        ComparisonCount: matchup.ComparisonCount,
1✔
815
                        LastCompared:    matchup.LastCompared,
1✔
816
                        InformationGain: matchup.InformationGain,
1✔
817
                }
1✔
818
                // Deep copy rating difference history
1✔
819
                history[i].RatingDifferenceHistory = make([]float64, len(matchup.RatingDifferenceHistory))
1✔
820
                copy(history[i].RatingDifferenceHistory, matchup.RatingDifferenceHistory)
1✔
821
        }
1✔
822

823
        return history
1✔
824
}
825

826
// calculateConvergenceMetrics performs detailed convergence analysis
827
func (s *Session) calculateConvergenceMetrics() {
1✔
828
        if s.ConvergenceMetrics == nil {
1✔
829
                return
×
830
        }
×
831

832
        metrics := s.ConvergenceMetrics
1✔
833

1✔
834
        // Calculate average rating change from recent changes
1✔
835
        if len(metrics.RecentRatingChanges) > 0 {
1✔
836
                sum := 0.0
×
837
                for _, change := range metrics.RecentRatingChanges {
×
838
                        if change < 0 {
×
839
                                change = -change
×
840
                        }
×
841
                        sum += change
×
842
                }
843
                metrics.AvgRatingChange = sum / float64(len(metrics.RecentRatingChanges))
×
844

×
845
                // Calculate variance
×
846
                if len(metrics.RecentRatingChanges) > 1 {
×
847
                        variance := 0.0
×
848
                        for _, change := range metrics.RecentRatingChanges {
×
849
                                if change < 0 {
×
850
                                        change = -change
×
851
                                }
×
852
                                diff := change - metrics.AvgRatingChange
×
853
                                variance += diff * diff
×
854
                        }
855
                        metrics.RatingVariance = variance / float64(len(metrics.RecentRatingChanges)-1)
×
856
                }
857
        }
858

859
        // Calculate coverage percentage (simplified)
860
        totalPossiblePairs := len(s.Proposals) * (len(s.Proposals) - 1) / 2
1✔
861
        uniquePairs := len(s.MatchupHistory)
1✔
862
        if totalPossiblePairs > 0 {
2✔
863
                metrics.CoveragePercentage = float64(uniquePairs) / float64(totalPossiblePairs) * 100.0
1✔
864
        }
1✔
865

866
        // Calculate convergence score (0-1)
867
        // Simple heuristic: high coverage + low variance = high convergence
868
        coverageFactor := metrics.CoveragePercentage / 100.0
1✔
869
        varianceFactor := 1.0
1✔
870
        if metrics.RatingVariance > 0 {
1✔
871
                varianceFactor = 1.0 / (1.0 + metrics.RatingVariance/10.0) // Normalize variance impact
×
872
        }
×
873

874
        metrics.ConvergenceScore = (coverageFactor + varianceFactor) / 2.0
1✔
875
        if metrics.ConvergenceScore > 1.0 {
1✔
876
                metrics.ConvergenceScore = 1.0
×
877
        }
×
878

879
        // TODO: Implement more sophisticated ranking stability calculation
880
        metrics.RankingStability = 0.0 // Placeholder
1✔
881
}
882

883
// generateUpdateID creates a unique identifier for an Elo update
884
func generateUpdateID() (string, error) {
×
885
        randomBytes := make([]byte, 6)
×
886
        if _, err := rand.Read(randomBytes); err != nil {
×
887
                return "", err
×
888
        }
×
889
        return fmt.Sprintf("upd_%s", hex.EncodeToString(randomBytes)), nil
×
890
}
891

892
// ListSessions returns all available session names in the storage directory
893
func ListSessions(storageDir string) ([]string, error) {
1✔
894
        if _, err := os.Stat(storageDir); os.IsNotExist(err) {
1✔
895
                return make([]string, 0), nil
×
896
        }
×
897

898
        entries, err := os.ReadDir(storageDir)
1✔
899
        if err != nil {
1✔
900
                return nil, fmt.Errorf("failed to read storage directory: %w", err)
×
901
        }
×
902

903
        sessions := make([]string, 0)
1✔
904
        for _, entry := range entries {
2✔
905
                if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
2✔
906
                        sessionName := strings.TrimSuffix(entry.Name(), ".json")
1✔
907
                        sessions = append(sessions, sessionName)
1✔
908
                }
1✔
909
        }
910

911
        return sessions, nil
1✔
912
}
913

914
// ValidateSessionFile checks if a session file is valid without fully loading it
915
func ValidateSessionFile(sessionName, storageDir string) error {
1✔
916
        sessionFile := filepath.Join(storageDir, SanitizeFilename(sessionName)+".json")
1✔
917

1✔
918
        // Check if file exists
1✔
919
        if _, err := os.Stat(sessionFile); os.IsNotExist(err) {
2✔
920
                return fmt.Errorf("%w: session file not found", ErrSessionNotFound)
1✔
921
        }
1✔
922

923
        // Read and parse file
924
        data, err := os.ReadFile(sessionFile)
1✔
925
        if err != nil {
1✔
926
                return fmt.Errorf("failed to read session file: %w", err)
×
927
        }
×
928

929
        // Basic JSON validation
930
        var rawSession map[string]any
1✔
931
        if err := json.Unmarshal(data, &rawSession); err != nil {
1✔
932
                return fmt.Errorf("%w: invalid JSON format", ErrSessionCorrupted)
×
933
        }
×
934

935
        // Check required fields
936
        requiredFields := []string{"name", "status", "created_at", "config"}
1✔
937
        for _, field := range requiredFields {
2✔
938
                if _, exists := rawSession[field]; !exists {
1✔
939
                        return fmt.Errorf("%w: missing required field: %s", ErrSessionCorrupted, field)
×
940
                }
×
941
        }
942

943
        return nil
1✔
944
}
945

946
// GetSessionInfo returns basic session information without loading the full session
947
func GetSessionInfo(sessionName, storageDir string) (*SessionInfo, error) {
×
948
        sessionFile := filepath.Join(storageDir, SanitizeFilename(sessionName)+".json")
×
949

×
950
        // Check if file exists
×
951
        stat, err := os.Stat(sessionFile)
×
952
        if os.IsNotExist(err) {
×
953
                return nil, fmt.Errorf("%w: session %s", ErrSessionNotFound, sessionName)
×
954
        }
×
955
        if err != nil {
×
956
                return nil, fmt.Errorf("failed to stat session file: %w", err)
×
957
        }
×
958

959
        // Read file
960
        data, err := os.ReadFile(sessionFile)
×
961
        if err != nil {
×
962
                return nil, fmt.Errorf("failed to read session file: %w", err)
×
963
        }
×
964

965
        // Parse just the fields we need
966
        var partial struct {
×
967
                Name                 string     `json:"name"`
×
968
                Status               string     `json:"status"`
×
969
                CreatedAt            time.Time  `json:"created_at"`
×
970
                UpdatedAt            time.Time  `json:"updated_at"`
×
971
                Proposals            []struct{} `json:"proposals"`
×
972
                CompletedComparisons []struct{} `json:"completed_comparisons"`
×
973
        }
×
974

×
975
        if err := json.Unmarshal(data, &partial); err != nil {
×
976
                return nil, fmt.Errorf("%w: failed to parse session metadata", ErrSessionCorrupted)
×
977
        }
×
978

979
        return &SessionInfo{
×
980
                Name:            partial.Name,
×
981
                Status:          SessionStatus(partial.Status),
×
982
                CreatedAt:       partial.CreatedAt,
×
983
                UpdatedAt:       partial.UpdatedAt,
×
984
                ProposalCount:   len(partial.Proposals),
×
985
                ComparisonCount: len(partial.CompletedComparisons),
×
986
                FileSize:        stat.Size(),
×
987
                LastModified:    stat.ModTime(),
×
988
        }, nil
×
989
}
990

991
// SessionInfo provides summary information about a session
992
type SessionInfo struct {
993
        Name            string        `json:"name"`
994
        Status          SessionStatus `json:"status"`
995
        CreatedAt       time.Time     `json:"created_at"`
996
        UpdatedAt       time.Time     `json:"updated_at"`
997
        ProposalCount   int           `json:"proposal_count"`
998
        ComparisonCount int           `json:"comparison_count"`
999
        FileSize        int64         `json:"file_size"`
1000
        LastModified    time.Time     `json:"last_modified"`
1001
}
1002

1003
// DeleteSession removes a session and all its backups from storage
1004
func DeleteSession(sessionName, storageDir string) error {
1✔
1005
        if sessionName == "" {
1✔
1006
                return fmt.Errorf("session name is required")
×
1007
        }
×
1008

1009
        // Remove main session file
1010
        sessionFile := filepath.Join(storageDir, SanitizeFilename(sessionName)+".json")
1✔
1011
        if err := os.Remove(sessionFile); err != nil && !os.IsNotExist(err) {
1✔
1012
                return fmt.Errorf("failed to remove session file: %w", err)
×
1013
        }
×
1014

1015
        // Remove backup files
1016
        pattern := filepath.Join(storageDir, SanitizeFilename(sessionName)+"_backup_*.json")
1✔
1017
        backups, err := filepath.Glob(pattern)
1✔
1018
        if err != nil {
1✔
1019
                return fmt.Errorf("failed to search for backup files: %w", err)
×
1020
        }
×
1021

1022
        for _, backup := range backups {
1✔
NEW
1023
                _ = os.Remove(backup) // Continue removing other backups even if one fails
×
1024
        }
×
1025

1026
        return nil
1✔
1027
}
1028

1029
// EloEngine represents the interface to the Elo rating calculation engine
1030
type EloEngine interface {
1031
        CalculatePairwise(winner, loser EloRating) (EloRating, EloRating, error)
1032
        CalculatePairwiseWithResult(winner, loser EloRating) (EloComparisonResult, error)
1033
}
1034

1035
// EloRating represents a proposal's rating for Elo calculations
1036
type EloRating struct {
1037
        ID         string  // Unique proposal identifier
1038
        Score      float64 // Current Elo rating
1039
        Confidence float64 // Statistical confidence (0.0-1.0)
1040
        Games      int     // Number of comparisons participated in
1041
}
1042

1043
// EloComparisonResult represents the result of Elo calculations
1044
type EloComparisonResult struct {
1045
        Updates   []EloRatingUpdate // Rating changes for each affected proposal
1046
        Method    ComparisonMethod  // Type of comparison performed
1047
        Timestamp time.Time         // When calculation was performed
1048
}
1049

1050
// EloRatingUpdate represents an individual rating change
1051
type EloRatingUpdate struct {
1052
        ProposalID string  // Proposal being updated
1053
        OldRating  float64 // Rating before comparison
1054
        NewRating  float64 // Rating after comparison
1055
        Delta      float64 // Change in rating (NewRating - OldRating)
1056
        KFactor    int     // K-factor used for this update
1057
}
1058

1059
// ProcessPairwiseComparison integrates with Elo engine to process a pairwise comparison
1060
func (s *Session) ProcessPairwiseComparison(winnerID, loserID string, engine EloEngine) error {
1✔
1061
        s.mutex.Lock()
1✔
1062
        defer s.mutex.Unlock()
1✔
1063

1✔
1064
        // Verify we have an active comparison
1✔
1065
        if s.CurrentComparison == nil {
1✔
1066
                return ErrComparisonNotActive
×
1067
        }
×
1068

1069
        // Verify this is a pairwise comparison
1070
        if s.CurrentComparison.Method != MethodPairwise {
1✔
1071
                return fmt.Errorf("%w: expected pairwise comparison, got %s", ErrInvalidComparison, s.CurrentComparison.Method)
×
1072
        }
×
1073

1074
        // Verify proposals are in the comparison
1075
        if len(s.CurrentComparison.ProposalIDs) != 2 {
1✔
1076
                return fmt.Errorf("%w: pairwise comparison must have exactly 2 proposals", ErrInvalidComparison)
×
1077
        }
×
1078

1079
        winnerFound, loserFound := false, false
1✔
1080
        for _, id := range s.CurrentComparison.ProposalIDs {
2✔
1081
                if id == winnerID {
2✔
1082
                        winnerFound = true
1✔
1083
                }
1✔
1084
                if id == loserID {
2✔
1085
                        loserFound = true
1✔
1086
                }
1✔
1087
        }
1088

1089
        if !winnerFound {
1✔
1090
                return fmt.Errorf("%w: winner not in current comparison: %s", ErrInvalidComparison, winnerID)
×
1091
        }
×
1092
        if !loserFound {
1✔
1093
                return fmt.Errorf("%w: loser not in current comparison: %s", ErrInvalidComparison, loserID)
×
1094
        }
×
1095

1096
        // Get current proposal ratings
1097
        winnerProposal, err := s.getProposalByIDInternal(winnerID)
1✔
1098
        if err != nil {
1✔
1099
                return fmt.Errorf("winner proposal not found: %w", err)
×
1100
        }
×
1101

1102
        loserProposal, err := s.getProposalByIDInternal(loserID)
1✔
1103
        if err != nil {
1✔
1104
                return fmt.Errorf("loser proposal not found: %w", err)
×
1105
        }
×
1106

1107
        // Convert to Elo rating format
1108
        winnerRating := EloRating{
1✔
1109
                ID:         winnerProposal.ID,
1✔
1110
                Score:      winnerProposal.Score,
1✔
1111
                Confidence: 0.8, // TODO: Calculate actual confidence based on games played
1✔
1112
                Games:      s.getProposalGameCount(winnerID),
1✔
1113
        }
1✔
1114

1✔
1115
        loserRating := EloRating{
1✔
1116
                ID:         loserProposal.ID,
1✔
1117
                Score:      loserProposal.Score,
1✔
1118
                Confidence: 0.8, // TODO: Calculate actual confidence based on games played
1✔
1119
                Games:      s.getProposalGameCount(loserID),
1✔
1120
        }
1✔
1121

1✔
1122
        // Calculate new ratings using Elo engine
1✔
1123
        result, err := engine.CalculatePairwiseWithResult(winnerRating, loserRating)
1✔
1124
        if err != nil {
1✔
NEW
1125
                return fmt.Errorf("elo calculation failed: %w", err)
×
1126
        }
×
1127

1128
        // Complete the comparison with results (internal version, mutex already held)
1129
        comparison, err := s.completeComparisonInternal(winnerID, nil, false, "")
1✔
1130
        if err != nil {
1✔
1131
                return fmt.Errorf("failed to complete comparison: %w", err)
×
1132
        }
×
1133

1134
        // Apply rating updates
1135
        for _, update := range result.Updates {
2✔
1136
                eloUpdate := EloUpdate{
1✔
1137
                        ID:           fmt.Sprintf("upd_%s_%d", update.ProposalID, time.Now().UnixNano()),
1✔
1138
                        ComparisonID: comparison.ID,
1✔
1139
                        ProposalID:   update.ProposalID,
1✔
1140
                        OldRating:    update.OldRating,
1✔
1141
                        NewRating:    update.NewRating,
1✔
1142
                        RatingDelta:  update.Delta,
1✔
1143
                        KFactor:      update.KFactor,
1✔
1144
                }
1✔
1145

1✔
1146
                // Add to comparison
1✔
1147
                for i := range s.CompletedComparisons {
2✔
1148
                        if s.CompletedComparisons[i].ID == comparison.ID {
2✔
1149
                                s.CompletedComparisons[i].EloUpdates = append(s.CompletedComparisons[i].EloUpdates, eloUpdate)
1✔
1150
                                break
1✔
1151
                        }
1152
                }
1153

1154
                // Update proposal rating
1155
                if err := s.updateProposalRatingInternal(update.ProposalID, update.NewRating); err != nil {
1✔
1156
                        return fmt.Errorf("failed to update proposal rating: %w", err)
×
1157
                }
×
1158
        }
1159

1160
        // Record matchup for optimization
1161
        informationGain := s.calculateInformationGain(winnerID, loserID, result.Updates)
1✔
1162
        s.recordMatchupInternal(winnerID, loserID, informationGain)
1✔
1163

1✔
1164
        // Update convergence metrics
1✔
1165
        s.updateConvergenceMetrics()
1✔
1166

1✔
1167
        // Update rating bins
1✔
1168
        s.updateRatingBins()
1✔
1169

1✔
1170
        return nil
1✔
1171
}
1172

1173
// ProcessMultiProposalComparison integrates with Elo engine to process trio/quartet comparisons
1174
func (s *Session) ProcessMultiProposalComparison(rankings []string, engine EloEngine) error {
1✔
1175
        s.mutex.Lock()
1✔
1176
        defer s.mutex.Unlock()
1✔
1177

1✔
1178
        // Verify we have an active comparison
1✔
1179
        if s.CurrentComparison == nil {
1✔
1180
                return ErrComparisonNotActive
×
1181
        }
×
1182

1183
        // Verify this is a multi-proposal comparison
1184
        if s.CurrentComparison.Method == MethodPairwise {
1✔
1185
                return fmt.Errorf("%w: use ProcessPairwiseComparison for pairwise comparisons", ErrInvalidComparison)
×
1186
        }
×
1187

1188
        // Validate rankings
1189
        if len(rankings) != len(s.CurrentComparison.ProposalIDs) {
1✔
1190
                return fmt.Errorf("%w: rankings length must match proposal count", ErrInvalidComparison)
×
1191
        }
×
1192

1193
        // Verify all proposals are in rankings
1194
        rankingSet := make(map[string]bool)
1✔
1195
        for _, id := range rankings {
2✔
1196
                if rankingSet[id] {
1✔
1197
                        return fmt.Errorf("%w: duplicate proposal in rankings: %s", ErrInvalidComparison, id)
×
1198
                }
×
1199
                rankingSet[id] = true
1✔
1200
        }
1201

1202
        for _, id := range s.CurrentComparison.ProposalIDs {
2✔
1203
                if !rankingSet[id] {
1✔
1204
                        return fmt.Errorf("%w: proposal missing from rankings: %s", ErrInvalidComparison, id)
×
1205
                }
×
1206
        }
1207

1208
        // Decompose multi-proposal comparison into pairwise comparisons
1209
        allUpdates := make([]EloRatingUpdate, 0)
1✔
1210

1✔
1211
        for i := 0; i < len(rankings); i++ {
2✔
1212
                for j := i + 1; j < len(rankings); j++ {
2✔
1213
                        winnerID := rankings[i] // Better ranked (lower index)
1✔
1214
                        loserID := rankings[j]  // Worse ranked (higher index)
1✔
1215

1✔
1216
                        // Get current proposals
1✔
1217
                        winnerProposal, err := s.getProposalByIDInternal(winnerID)
1✔
1218
                        if err != nil {
1✔
1219
                                return fmt.Errorf("winner proposal not found: %w", err)
×
1220
                        }
×
1221

1222
                        loserProposal, err := s.getProposalByIDInternal(loserID)
1✔
1223
                        if err != nil {
1✔
1224
                                return fmt.Errorf("loser proposal not found: %w", err)
×
1225
                        }
×
1226

1227
                        // Convert to Elo rating format
1228
                        winnerRating := EloRating{
1✔
1229
                                ID:         winnerProposal.ID,
1✔
1230
                                Score:      winnerProposal.Score,
1✔
1231
                                Confidence: 0.8, // TODO: Calculate based on games
1✔
1232
                                Games:      s.getProposalGameCount(winnerID),
1✔
1233
                        }
1✔
1234

1✔
1235
                        loserRating := EloRating{
1✔
1236
                                ID:         loserProposal.ID,
1✔
1237
                                Score:      loserProposal.Score,
1✔
1238
                                Confidence: 0.8, // TODO: Calculate based on games
1✔
1239
                                Games:      s.getProposalGameCount(loserID),
1✔
1240
                        }
1✔
1241

1✔
1242
                        // Calculate pairwise Elo update
1✔
1243
                        result, err := engine.CalculatePairwiseWithResult(winnerRating, loserRating)
1✔
1244
                        if err != nil {
1✔
NEW
1245
                                return fmt.Errorf("elo calculation failed for %s vs %s: %w", winnerID, loserID, err)
×
1246
                        }
×
1247

1248
                        // Collect updates
1249
                        allUpdates = append(allUpdates, result.Updates...)
1✔
1250

1✔
1251
                        // Record matchup
1✔
1252
                        informationGain := s.calculateInformationGain(winnerID, loserID, result.Updates)
1✔
1253
                        s.recordMatchupInternal(winnerID, loserID, informationGain)
1✔
1254
                }
1255
        }
1256

1257
        // Complete the comparison (internal version, mutex already held)
1258
        winnerID := rankings[0] // Best ranked proposal
1✔
1259
        comparison, err := s.completeComparisonInternal(winnerID, rankings, false, "")
1✔
1260
        if err != nil {
1✔
1261
                return fmt.Errorf("failed to complete comparison: %w", err)
×
1262
        }
×
1263

1264
        // Apply all rating updates
1265
        updatesByProposal := make(map[string][]EloRatingUpdate)
1✔
1266
        for _, update := range allUpdates {
2✔
1267
                updatesByProposal[update.ProposalID] = append(updatesByProposal[update.ProposalID], update)
1✔
1268
        }
1✔
1269

1270
        for proposalID, updates := range updatesByProposal {
2✔
1271
                // Calculate net rating change for this proposal
1✔
1272
                totalDelta := 0.0
1✔
1273
                finalRating := 0.0
1✔
1274
                for _, update := range updates {
2✔
1275
                        totalDelta += update.Delta
1✔
1276
                        finalRating = update.NewRating // Use last calculated rating
1✔
1277
                }
1✔
1278

1279
                // Create consolidated Elo update
1280
                eloUpdate := EloUpdate{
1✔
1281
                        ID:           fmt.Sprintf("upd_%s_%d", proposalID, time.Now().UnixNano()),
1✔
1282
                        ComparisonID: comparison.ID,
1✔
1283
                        ProposalID:   proposalID,
1✔
1284
                        OldRating:    updates[0].OldRating,
1✔
1285
                        NewRating:    finalRating,
1✔
1286
                        RatingDelta:  totalDelta,
1✔
1287
                        KFactor:      updates[0].KFactor, // Use K-factor from first update
1✔
1288
                }
1✔
1289

1✔
1290
                // Add to comparison
1✔
1291
                for i := range s.CompletedComparisons {
2✔
1292
                        if s.CompletedComparisons[i].ID == comparison.ID {
2✔
1293
                                s.CompletedComparisons[i].EloUpdates = append(s.CompletedComparisons[i].EloUpdates, eloUpdate)
1✔
1294
                                break
1✔
1295
                        }
1296
                }
1297

1298
                // Update proposal rating
1299
                if err := s.updateProposalRatingInternal(proposalID, finalRating); err != nil {
1✔
1300
                        return fmt.Errorf("failed to update proposal rating: %w", err)
×
1301
                }
×
1302
        }
1303

1304
        // Update convergence metrics
1305
        s.updateConvergenceMetrics()
1✔
1306

1✔
1307
        // Update rating bins
1✔
1308
        s.updateRatingBins()
1✔
1309

1✔
1310
        return nil
1✔
1311
}
1312

1313
// getProposalByIDInternal retrieves a proposal by ID (internal, no locking)
1314
func (s *Session) getProposalByIDInternal(id string) (*Proposal, error) {
1✔
1315
        idx, exists := s.ProposalIndex[id]
1✔
1316
        if !exists {
1✔
1317
                return nil, fmt.Errorf("proposal not found: %s", id)
×
1318
        }
×
1319
        return &s.Proposals[idx], nil
1✔
1320
}
1321

1322
// updateProposalRatingInternal updates a proposal's rating (internal, no locking)
1323
func (s *Session) updateProposalRatingInternal(proposalID string, newRating float64) error {
1✔
1324
        idx, exists := s.ProposalIndex[proposalID]
1✔
1325
        if !exists {
1✔
1326
                return fmt.Errorf("proposal not found: %s", proposalID)
×
1327
        }
×
1328

1329
        s.Proposals[idx].Score = newRating
1✔
1330
        s.Proposals[idx].UpdatedAt = time.Now()
1✔
1331
        s.UpdatedAt = time.Now()
1✔
1332

1✔
1333
        return nil
1✔
1334
}
1335

1336
// getProposalGameCount counts how many comparisons a proposal has participated in
1337
func (s *Session) getProposalGameCount(proposalID string) int {
1✔
1338
        count := 0
1✔
1339
        for _, comparison := range s.CompletedComparisons {
2✔
1340
                for _, id := range comparison.ProposalIDs {
2✔
1341
                        if id == proposalID {
2✔
1342
                                count++
1✔
1343
                                break
1✔
1344
                        }
1345
                }
1346
        }
1347
        return count
1✔
1348
}
1349

1350
// calculateInformationGain estimates the information value of a comparison
1351
func (s *Session) calculateInformationGain(proposalA, proposalB string, updates []EloRatingUpdate) float64 {
1✔
1352
        // Simple heuristic: larger rating changes indicate more informative comparisons
1✔
1353
        totalAbsDelta := 0.0
1✔
1354
        for _, update := range updates {
2✔
1355
                if update.ProposalID == proposalA || update.ProposalID == proposalB {
2✔
1356
                        delta := update.Delta
1✔
1357
                        if delta < 0 {
2✔
1358
                                delta = -delta
1✔
1359
                        }
1✔
1360
                        totalAbsDelta += delta
1✔
1361
                }
1362
        }
1363

1364
        // Normalize to 0-1 scale (typical Elo changes are 0-64 points with K=32)
1365
        return math.Min(totalAbsDelta/64.0, 1.0)
1✔
1366
}
1367

1368
// GetOptimalMatchups returns suggested proposal pairs for next comparisons
1369
func (s *Session) GetOptimalMatchups(count int) []ProposalPair {
1✔
1370
        s.mutex.RLock()
1✔
1371
        defer s.mutex.RUnlock()
1✔
1372

1✔
1373
        if count <= 0 {
1✔
1374
                return make([]ProposalPair, 0)
×
1375
        }
×
1376

1377
        type matchupCandidate struct {
1✔
1378
                ProposalA       string
1✔
1379
                ProposalB       string
1✔
1380
                Priority        float64 // Higher is better
1✔
1381
                RatingDistance  float64
1✔
1382
                ComparisonCount int
1✔
1383
                LastCompared    time.Time
1✔
1384
        }
1✔
1385

1✔
1386
        candidates := make([]matchupCandidate, 0)
1✔
1387

1✔
1388
        // Generate all possible pairs
1✔
1389
        for i := 0; i < len(s.Proposals); i++ {
2✔
1390
                for j := i + 1; j < len(s.Proposals); j++ {
2✔
1391
                        proposalA := s.Proposals[i].ID
1✔
1392
                        proposalB := s.Proposals[j].ID
1✔
1393

1✔
1394
                        // Calculate rating distance
1✔
1395
                        ratingA := s.Proposals[i].Score
1✔
1396
                        ratingB := s.Proposals[j].Score
1✔
1397
                        distance := ratingA - ratingB
1✔
1398
                        if distance < 0 {
2✔
1399
                                distance = -distance
1✔
1400
                        }
1✔
1401

1402
                        // Find existing matchup history
1403
                        comparisonCount := 0
1✔
1404
                        lastCompared := time.Time{}
1✔
1405
                        for _, matchup := range s.MatchupHistory {
2✔
1406
                                if (matchup.ProposalA == proposalA && matchup.ProposalB == proposalB) ||
1✔
1407
                                        (matchup.ProposalA == proposalB && matchup.ProposalB == proposalA) {
2✔
1408
                                        comparisonCount = matchup.ComparisonCount
1✔
1409
                                        lastCompared = matchup.LastCompared
1✔
1410
                                        break
1✔
1411
                                }
1412
                        }
1413

1414
                        // Calculate priority (prefer close ratings, fewer previous comparisons, older comparisons)
1415
                        priority := 0.0
1✔
1416

1✔
1417
                        // Rating distance factor (closer ratings are more informative)
1✔
1418
                        if distance > 0 {
2✔
1419
                                priority += 100.0 / (1.0 + distance/100.0) // Normalize by typical rating range
1✔
1420
                        }
1✔
1421

1422
                        // Comparison count factor (prefer less compared pairs)
1423
                        priority += 50.0 / (1.0 + float64(comparisonCount))
1✔
1424

1✔
1425
                        // Recency factor (prefer pairs not recently compared)
1✔
1426
                        if !lastCompared.IsZero() {
2✔
1427
                                hoursSince := time.Since(lastCompared).Hours()
1✔
1428
                                priority += math.Min(hoursSince/24.0*10.0, 25.0) // Up to 25 points for day+ old comparisons
1✔
1429
                        } else {
2✔
1430
                                priority += 25.0 // Never compared gets full recency points
1✔
1431
                        }
1✔
1432

1433
                        candidates = append(candidates, matchupCandidate{
1✔
1434
                                ProposalA:       proposalA,
1✔
1435
                                ProposalB:       proposalB,
1✔
1436
                                Priority:        priority,
1✔
1437
                                RatingDistance:  distance,
1✔
1438
                                ComparisonCount: comparisonCount,
1✔
1439
                                LastCompared:    lastCompared,
1✔
1440
                        })
1✔
1441
                }
1442
        }
1443

1444
        // Sort by priority (highest first)
1445
        sort.Slice(candidates, func(i, j int) bool {
2✔
1446
                return candidates[i].Priority > candidates[j].Priority
1✔
1447
        })
1✔
1448

1449
        // Return top candidates
1450
        maxResults := min(count, len(candidates))
1✔
1451
        results := make([]ProposalPair, maxResults)
1✔
1452
        for i := 0; i < maxResults; i++ {
2✔
1453
                candidate := candidates[i]
1✔
1454
                results[i] = ProposalPair(candidate)
1✔
1455
        }
1✔
1456

1457
        return results
1✔
1458
}
1459

1460
// ProposalPair represents a suggested pairing for comparison
1461
type ProposalPair struct {
1462
        ProposalA       string    `json:"proposal_a"`
1463
        ProposalB       string    `json:"proposal_b"`
1464
        Priority        float64   `json:"priority"`
1465
        RatingDistance  float64   `json:"rating_distance"`
1466
        ComparisonCount int       `json:"comparison_count"`
1467
        LastCompared    time.Time `json:"last_compared"`
1468
}
1469

1470
// Close closes the session and releases resources including the audit trail
1471
func (s *Session) Close() error {
1✔
1472
        s.mutex.Lock()
1✔
1473
        defer s.mutex.Unlock()
1✔
1474

1✔
1475
        return nil
1✔
1476
}
1✔
1477

1478
// min returns the minimum of two integers
1479
func min(a, b int) int {
1✔
1480
        if a < b {
1✔
1481
                return a
×
1482
        }
×
1483
        return b
1✔
1484
}
1485

1486
// SessionDetector provides session existence detection and mode determination
1487
type SessionDetector struct {
1488
        sessionsDir string
1489
}
1490

1491
// NewSessionDetector creates a new session detector for the specified sessions directory
1492
func NewSessionDetector(sessionsDir string) *SessionDetector {
1✔
1493
        return &SessionDetector{
1✔
1494
                sessionsDir: sessionsDir,
1✔
1495
        }
1✔
1496
}
1✔
1497

1498
// DetectMode determines whether to start a new session or resume an existing one
1499
// based on the session name and existing session files
1500
func (sd *SessionDetector) DetectMode(sessionName string) (SessionMode, error) {
1✔
1501
        // Validate session name first
1✔
1502
        if err := sd.validateSessionName(sessionName); err != nil {
2✔
1503
                return StartMode, fmt.Errorf("%w: %v", ErrSessionNameInvalid, err)
1✔
1504
        }
1✔
1505

1506
        // Ensure sessions directory exists
1507
        if err := sd.ensureSessionsDirectory(); err != nil {
1✔
1508
                return StartMode, fmt.Errorf("%w: failed to access sessions directory: %v", ErrModeDetectionFailed, err)
×
1509
        }
×
1510

1511
        // Look for existing session files matching the session name
1512
        sessionFile, err := sd.FindSessionFile(sessionName)
1✔
1513
        if err != nil {
1✔
1514
                return StartMode, fmt.Errorf("%w: %v", ErrModeDetectionFailed, err)
×
1515
        }
×
1516

1517
        if sessionFile == "" {
2✔
1518
                // No existing session found - start new session
1✔
1519
                return StartMode, nil
1✔
1520
        }
1✔
1521

1522
        // Validate the found session file
1523
        if err := sd.ValidateSession(sessionFile); err != nil {
×
1524
                return StartMode, fmt.Errorf("%w: session file corrupted: %v", ErrSessionCorrupted, err)
×
1525
        }
×
1526

1527
        // Valid existing session found - resume mode
1528
        return ResumeMode, nil
×
1529
}
1530

1531
// FindSessionFile locates a session file by session name
1532
// Returns empty string if no session file is found
1533
func (sd *SessionDetector) FindSessionFile(sessionName string) (string, error) {
1✔
1534
        if sessionName == "" {
2✔
1535
                return "", fmt.Errorf("session name cannot be empty")
1✔
1536
        }
1✔
1537

1538
        // Sanitize session name for filesystem
1539
        safeName := SanitizeFilename(sessionName)
1✔
1540

1✔
1541
        // Construct direct filename
1✔
1542
        sessionFile := filepath.Join(sd.sessionsDir, safeName+".json")
1✔
1543

1✔
1544
        // Check if file exists
1✔
1545
        if _, err := os.Stat(sessionFile); err != nil {
2✔
1546
                if os.IsNotExist(err) {
2✔
1547
                        return "", nil // No session found
1✔
1548
                }
1✔
1549
                return "", fmt.Errorf("failed to check session file: %w", err)
×
1550
        }
1551

1552
        return sessionFile, nil
×
1553
}
1554

1555
// ValidateSession validates the integrity of a session file
1556
func (sd *SessionDetector) ValidateSession(sessionPath string) error {
1✔
1557
        if sessionPath == "" {
2✔
1558
                return fmt.Errorf("session path cannot be empty")
1✔
1559
        }
1✔
1560

1561
        // Check if file exists and is readable
1562
        file, err := os.Open(sessionPath)
1✔
1563
        if err != nil {
2✔
1564
                if os.IsNotExist(err) {
2✔
1565
                        return fmt.Errorf("session file not found: %s", sessionPath)
1✔
1566
                }
1✔
1567
                return fmt.Errorf("cannot read session file: %w", err)
×
1568
        }
1569
        defer func() { _ = file.Close() }()
2✔
1570

1571
        // Validate JSON structure by attempting to decode
1572
        var sessionData map[string]any
1✔
1573
        decoder := json.NewDecoder(file)
1✔
1574
        if err := decoder.Decode(&sessionData); err != nil {
2✔
1575
                return fmt.Errorf("invalid JSON in session file: %w", err)
1✔
1576
        }
1✔
1577

1578
        // Check for required fields
1579
        requiredFields := []string{"name", "created_at", "config"}
1✔
1580
        for _, field := range requiredFields {
2✔
1581
                if _, exists := sessionData[field]; !exists {
2✔
1582
                        return fmt.Errorf("missing required field '%s' in session file", field)
1✔
1583
                }
1✔
1584
        }
1585

1586
        return nil
1✔
1587
}
1588

1589
// validateSessionName validates the session name for filesystem safety
1590
func (sd *SessionDetector) validateSessionName(name string) error {
1✔
1591
        if name == "" {
2✔
1592
                return fmt.Errorf("session name cannot be empty")
1✔
1593
        }
1✔
1594

1595
        // Check for invalid filesystem characters
1596
        invalidChars := `<>:"/\|?*`
1✔
1597
        if strings.ContainsAny(name, invalidChars) {
2✔
1598
                return fmt.Errorf("session name contains invalid characters: %s", name)
1✔
1599
        }
1✔
1600

1601
        // Check for reserved names (Windows)
1602
        reservedNames := []string{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4",
1✔
1603
                "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5",
1✔
1604
                "LPT6", "LPT7", "LPT8", "LPT9"}
1✔
1605
        upperName := strings.ToUpper(name)
1✔
1606
        for _, reserved := range reservedNames {
2✔
1607
                if upperName == reserved {
2✔
1608
                        return fmt.Errorf("session name '%s' is reserved", name)
1✔
1609
                }
1✔
1610
        }
1611

1612
        return nil
1✔
1613
}
1614

1615
// SanitizeFilename converts a session name into a safe filename
1616
func SanitizeFilename(name string) string {
1✔
1617
        // Replace invalid filesystem characters with underscores
1✔
1618
        invalidChars := `<>:"/\|?*`
1✔
1619
        result := name
1✔
1620
        for _, char := range invalidChars {
2✔
1621
                result = strings.ReplaceAll(result, string(char), "_")
1✔
1622
        }
1✔
1623

1624
        // Replace spaces with underscores for cleaner filenames
1625
        result = strings.ReplaceAll(result, " ", "_")
1✔
1626

1✔
1627
        // Ensure it's not empty after sanitization
1✔
1628
        if result == "" {
1✔
1629
                result = "session"
×
1630
        }
×
1631

1632
        return result
1✔
1633
}
1634

1635
// ensureSessionsDirectory creates the sessions directory if it doesn't exist
1636
func (sd *SessionDetector) ensureSessionsDirectory() error {
1✔
1637
        if sd.sessionsDir == "" {
1✔
1638
                return fmt.Errorf("sessions directory path is empty")
×
1639
        }
×
1640

1641
        // Check if directory exists
1642
        if _, err := os.Stat(sd.sessionsDir); os.IsNotExist(err) {
2✔
1643
                // Create directory with appropriate permissions
1✔
1644
                if err := os.MkdirAll(sd.sessionsDir, 0755); err != nil {
1✔
1645
                        return fmt.Errorf("failed to create sessions directory: %w", err)
×
1646
                }
×
1647
        } else if err != nil {
1✔
1648
                return fmt.Errorf("failed to access sessions directory: %w", err)
×
1649
        }
×
1650

1651
        return nil
1✔
1652
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc