Skip to content

Commit

Permalink
feat: vote result hint
Browse files Browse the repository at this point in the history
  • Loading branch information
igor-sirotin committed Jun 29, 2024
1 parent df9939c commit 9448c6f
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 8 deletions.
8 changes: 4 additions & 4 deletions pkg/game/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ func (g *Game) hiddenCurrentState() *protocol.State {
hiddenState.Issues = make([]*protocol.Issue, 0, len(g.state.Issues))
for _, item := range g.state.Issues {
copiedItem := *item
copiedItem.Votes = make(map[protocol.PlayerID]protocol.VoteResult, len(item.Votes))
copiedItem.Votes = make(protocol.IssueVotes, len(item.Votes))
for playerID, vote := range item.Votes {
if item.ID == g.state.ActiveIssue {
copiedItem.Votes[playerID] = vote.Hidden()
Expand Down Expand Up @@ -702,7 +702,7 @@ func (g *Game) addIssue(titleOrURL string) (protocol.IssueID, error) {
issue := protocol.Issue{
ID: issueID,
TitleOrURL: titleOrURL,
Votes: make(map[protocol.PlayerID]protocol.VoteResult),
Votes: make(protocol.IssueVotes),
Result: nil,
}

Expand All @@ -720,11 +720,11 @@ func (g *Game) SelectIssue(index int) error {
}

if index < 0 || index >= len(g.state.Issues) {
return errors.New("invalid issue index")
return errors.New("invalid issue deckIndex")
}

g.state.Issues[index].Result = nil
g.state.Issues[index].Votes = make(map[protocol.PlayerID]protocol.VoteResult)
g.state.Issues[index].Votes = make(protocol.IssueVotes)
g.state.ActiveIssue = g.state.Issues[index].ID
g.notifyChangedState(true)

Expand Down
122 changes: 122 additions & 0 deletions pkg/game/hints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package game

import (
"math"
"sort"

"github.com/pkg/errors"
"golang.org/x/exp/slices"

"github.com/six78/2-story-points-cli/pkg/protocol"
)

type hintMeasurements struct {
// median is the index median value of given votes
median int

// meanDeviation is the mean absolute deviation around the median
// Measured in cards count.
meanDeviation float64

// maxDeviation is the maximum absolute deviation from the median
// Measured in cards count.
maxDeviation float64
}

const (
maxAcceptableMaximumDeviation = 1
maxAcceptableMeanDeviation = 0.5
// Rejection reasons
varietyOfVotesIsTooHigh = "Variety of votes is too high"
maximumDeviationIsTooHigh = "Maximum deviation is too high"
)

var (
ErrVoteNotFoundInDeck = errors.New("vote not found in deck")
)

func GetResultHint(deck protocol.Deck, issueVotes protocol.IssueVotes) (*protocol.Hint, error) {
// Get votes as deck indexes.
// We ignore the actual deck values when calculating the hint.
indexes, err := getVotesAsDeckIndexes(issueVotes, deck)
if err != nil {
return nil, err
}

// Calculate measures for the votes
resultMeasures := getMeasures(indexes)
medianValueIndex := resultMeasures.median
medianValue := deck[medianValueIndex]

// Build the hint based on the measures
hint := &protocol.Hint{
Hint: medianValue,
Advice: "",
Acceptable: true,
}

if resultMeasures.maxDeviation > maxAcceptableMaximumDeviation {
hint.Acceptable = false
hint.RejectReason = maximumDeviationIsTooHigh
}

if resultMeasures.meanDeviation >= maxAcceptableMeanDeviation {
hint.Acceptable = false
hint.RejectReason = varietyOfVotesIsTooHigh
}

return hint, nil
}

func getVotesAsDeckIndexes(issueVotes protocol.IssueVotes, deck protocol.Deck) ([]int, error) {
indexes := make([]int, 0, len(issueVotes))
for _, vote := range issueVotes {
index := slices.Index(deck, vote.Value)
if index < 0 {
return nil, ErrVoteNotFoundInDeck
}
indexes = append(indexes, index)
}
return indexes, nil
}

// getMeasures returns:
// - median value
// - median absolute deviation
// - maximum absolute deviation
// - error if any occurred
func getMeasures(values []int) hintMeasurements {
r := hintMeasurements{}

// median value
r.median = median(values)

// Maximum deviation
r.maxDeviation = 0
for _, v := range values {
deviation := math.Abs(float64(r.median) - float64(v))
r.maxDeviation = math.Max(r.maxDeviation, deviation)
}

// Average deviation
sum := 0
for _, v := range values {
sum += int(math.Abs(float64(r.median) - float64(v)))
}
r.meanDeviation = float64(sum) / float64(len(values))

return r

}

func median(values []int) int {
if len(values) == 0 {
return -1
}

sort.Slice(values, func(i, j int) bool {
return values[i] < values[j]
})
center := len(values) / 2
return values[center]
}
155 changes: 155 additions & 0 deletions pkg/game/hints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package game

import (
"strings"
"testing"

"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/require"

"github.com/six78/2-story-points-cli/pkg/protocol"
)

func TestMedian(t *testing.T) {
// Odd number of votes
votes := []int{1, 1, 1, 1, 2}
hint := median(votes)
require.Equal(t, 1, hint)

// Even number of votes
votes = []int{1, 1, 1, 2}
hint = median(votes)
require.Equal(t, 1, hint)

// Test round up
votes = []int{1, 1, 2, 2}
hint = median(votes)
require.Equal(t, 2, hint)

// Empty list
votes = []int{}
hint = median(votes)
require.Equal(t, -1, hint)
}

func TestHint(t *testing.T) {
deck := protocol.Deck{"1", "2", "3", "5", "8", "13", "21"}
t.Log("deck:", deck)

type Case struct {
values []protocol.VoteValue
measurements hintMeasurements
expectedHint protocol.Hint
}

// NOTE: Some test cases here are double check.
// But the intention was also to see how the algorithm behaves for different scenarios.

testCases := []Case{
{
values: []protocol.VoteValue{"3", "3", "3", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.2, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"},
},
{
values: []protocol.VoteValue{"3", "3", "3", "3", "8"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 2},
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: maximumDeviationIsTooHigh},
},
{
values: []protocol.VoteValue{"3", "3", "3", "3", "13"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 3},
// Test: varietyOfVotesIsTooHigh takes precedence over maximumDeviationIsTooHigh
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh},
},
{
values: []protocol.VoteValue{"3", "3", "3", "3", "21"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.8, maxDeviation: 4},
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh},
},
{
values: []protocol.VoteValue{"3", "3", "3", "5", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"},
},
{
values: []protocol.VoteValue{"3", "3", "3", "5", "8"},
measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 2},
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh},
},
{
values: []protocol.VoteValue{"2", "3", "3", "3", "3", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 7.0, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"},
},
{
values: []protocol.VoteValue{"2", "3", "3", "3", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 6.0, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"},
},
{
values: []protocol.VoteValue{"2", "3", "3", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 5.0, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"},
},
{
values: []protocol.VoteValue{"2", "3", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 4.0, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh},
},
{
values: []protocol.VoteValue{"2", "3", "5"},
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 3.0, maxDeviation: 1},
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh},
},
{
// This also tests round up median when even number of votes
values: []protocol.VoteValue{"2", "3", "5", "8"},
measurements: hintMeasurements{median: 3, meanDeviation: 1, maxDeviation: 2},
expectedHint: protocol.Hint{Acceptable: false, Hint: "5", RejectReason: varietyOfVotesIsTooHigh},
},
}

for _, tc := range testCases {
name := voteValuesString(tc.values)
t.Run(name, func(t *testing.T) {
issueVotes := buildIssueVotes(tc.values)
indexes, err := getVotesAsDeckIndexes(issueVotes, deck)
require.NoError(t, err)

// First, check the measures (private API)
measures := getMeasures(indexes)
require.Equal(t, tc.measurements, measures)

// Now check the actual hint (public API)
hint, err := GetResultHint(deck, issueVotes)
require.NoError(t, err)
require.Equal(t, tc.expectedHint, *hint)
})
}
}

func TestInvalidVote(t *testing.T) {
deck := protocol.Deck{"1", "2"}
issueVotes := buildIssueVotes([]protocol.VoteValue{"1", "X"})
_, err := GetResultHint(deck, issueVotes)
require.Error(t, err)
require.Equal(t, ErrVoteNotFoundInDeck, err)
}

func voteValuesString(values []protocol.VoteValue) string {
list := make([]string, len(values))
for i, v := range values {
list[i] = string(v)
}
return strings.Join(list, ",")
}

func buildIssueVotes(votes []protocol.VoteValue) protocol.IssueVotes {
issueVotes := make(protocol.IssueVotes)
for _, v := range votes {
playerID := protocol.PlayerID(gofakeit.UUID())
issueVotes[playerID] = protocol.VoteResult{Value: v}
}
return issueVotes
}
20 changes: 20 additions & 0 deletions pkg/protocol/hint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package protocol

type Hint struct {
// Acceptable shows if the voting for given issue can be considered as "acceptable".
// It will be false if the variety of votes is too high. In this case Advice will contain
// a suggestion to discuss and re-vote.
Acceptable bool

// RejectReason contains an explanation of why the vote is not acceptable.
// When Acceptable is true, RejectReason is empty.
RejectReason string

// Hint is the recommended value for the issue.
// It's guaranteed to be one of the values from the deck.
Hint VoteValue

// Advice is a text advice for the team about current vote.
// It might contain players mentions in form "@<id>", where <id> a particular player ID.
Advice string
}
11 changes: 7 additions & 4 deletions pkg/protocol/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ type PlayerID string
type IssueID string

type Issue struct {
ID IssueID `json:"id"`
TitleOrURL string `json:"titleOrUrl"`
Votes map[PlayerID]VoteResult `json:"votes"`
Result *VoteValue `json:"result"` // NOTE: keep pointer. Because "empty string means vote is not revealed"
ID IssueID `json:"id"`
TitleOrURL string `json:"titleOrUrl"`
Votes IssueVotes `json:"votes"`
Result *VoteValue `json:"result"` // NOTE: keep pointer. Because "empty string means vote is not revealed"
Hint *Hint `json:"-"`
}

type VoteState string
Expand Down Expand Up @@ -89,3 +90,5 @@ type PlayerVoteMessage struct {
}

type Deck []VoteValue

type IssueVotes map[PlayerID]VoteResult

0 comments on commit 9448c6f

Please sign in to comment.