Skip to content

Commit

Permalink
add markReducer pattern for more efficient live score calculations (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
swantzter authored Oct 30, 2024
1 parent 21cab57 commit eb1be38
Show file tree
Hide file tree
Showing 17 changed files with 502 additions and 237 deletions.
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default [
functions: 'never',
}],
'no-void': 'off',
'no-console': 'warn',
},
},
{
Expand All @@ -90,4 +91,11 @@ export default [
}],
},
},
{
name: 'RopeScore/bin',
files: ['bin/*.ts', 'bin/*.js'],
rules: {
'no-console': 'off',
},
},
]
100 changes: 90 additions & 10 deletions lib/helpers.test.ts → lib/helpers/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { simpleCalculateTallyFactory, clampNumber, filterMarkStream, formatFactor, isObject, parseCompetitionEventDefinition, roundTo, roundToCurry, roundToMultiple } from './helpers/helpers.js'
import { calculateTallyFactory, clampNumber, filterMarkStream, formatFactor, isObject, parseCompetitionEventDefinition, roundTo, roundToCurry, roundToMultiple, simpleReducer } from './helpers.js'
import type { GenericMark, JudgeMeta, JudgeTallyFieldDefinition, Mark } from '../models/types.js'
import assert from 'node:assert'
import test from 'node:test'
import type { GenericMark, JudgeMeta, Mark } from './models/types.js'

export function markGeneratorFactory () {
let sequence = 0
Expand Down Expand Up @@ -127,7 +127,7 @@ void test('helpers', async t => {
}
})

await t.test('simpleCalculateTallyFactory', async t => {
await t.test('calculateTallyFactory', async t => {
const meta: JudgeMeta = {
judgeId: '1',
judgeTypeId: 'S',
Expand All @@ -136,6 +136,12 @@ void test('helpers', async t => {
competitionEvent: '[email protected]',
}

const tallyDefinitions: Array<JudgeTallyFieldDefinition<string>> = [
{ schema: 'formPlus', name: 'Form +' },
{ schema: 'formCheck', name: 'Form c' },
{ schema: 'formMinus', name: 'Form -' },
]

await t.test('Should return tally for MarkScoresheet', () => {
const marks: Array<Mark<string>> = [
{ sequence: 0, schema: 'formPlus', timestamp: 1 },
Expand All @@ -145,22 +151,96 @@ void test('helpers', async t => {
const tally = {
formPlus: 2,
formCheck: 1,
formMinus: 0,
}
assert.deepStrictEqual(simpleCalculateTallyFactory(meta.judgeTypeId)({ meta, marks }), { meta, tally })
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})

await t.test('Should return tally for MarkScoresheet with undo marks', () => {
await t.test('Should return tally for MarkScoresheet with undo of last mark', () => {
const m = markGeneratorFactory()
const marks: Array<Mark<string>> = [
{ sequence: 0, schema: 'formPlus', timestamp: 1 },
{ sequence: 1, schema: 'formCheck', timestamp: 15 },
{ sequence: 2, schema: 'formPlus', timestamp: 30 },
{ sequence: 3, schema: 'undo', timestamp: 45, target: 2 },
m('formPlus'),
m('formCheck'),
m('formPlus'),
m('undo', { target: 2 }),
]
const tally = {
formPlus: 1,
formCheck: 1,
formMinus: 0,
}
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})

await t.test('Should return tally for MarkScoresheet with undo of first mark', () => {
const m = markGeneratorFactory()
const marks: Array<Mark<string>> = [
m('formPlus'),
m('formCheck'),
m('formPlus'),
m('undo', { target: 0 }),
]
const tally = {
formPlus: 1,
formCheck: 1,
formMinus: 0,
}
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})

await t.test('Should return tally for MarkScoresheet with undo of early mark', () => {
const m = markGeneratorFactory()
const marks: Array<Mark<string>> = [
m('formPlus'),
m('formCheck'),
m('formPlus'),
m('undo', { target: 1 }),
]
const tally = {
formPlus: 2,
formCheck: 0,
formMinus: 0,
}
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})

await t.test('Should return tally for MarkScoresheet with clear mark', () => {
const m = markGeneratorFactory()
const marks: Array<Mark<string>> = [
m('formPlus'),
m('formCheck'),
m('formPlus'),
m('clear'),
m('formPlus'),
m('formPlus'),
m('formPlus'),
]
const tally = {
formPlus: 3,
formCheck: 0,
formMinus: 0,
}
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})

await t.test('Should return tally for MarkScoresheet with clear mark and undo targetting before clear', () => {
const m = markGeneratorFactory()
const marks: Array<Mark<string>> = [
m('formPlus'),
m('formCheck'),
m('formPlus'),
m('clear'),
m('formPlus'),
m('formPlus'),
m('formPlus'),
m('undo', { target: 1 }),
]
const tally = {
formPlus: 3,
formCheck: 0,
formMinus: 0,
}
assert.deepStrictEqual(simpleCalculateTallyFactory(meta.judgeTypeId)({ meta, marks }), { meta, tally })
assert.deepStrictEqual(calculateTallyFactory(meta.judgeTypeId, simpleReducer, tallyDefinitions)({ meta, marks }), { meta, tally })
})
})

Expand Down
108 changes: 89 additions & 19 deletions lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { RSRWrongJudgeTypeError } from '../errors.js'
import type { GenericMark, Mark } from '../models/types.js'
import { type JudgeFieldDefinition, type ScoreTally, isClearMark, isUndoMark, type Meta, type EntryResult, type TallyScoresheet, type MarkScoresheet } from '../models/types.js'
import { type GenericMark, type Mark, type JudgeTallyFieldDefinition, type ScoreTally, isClearMark, isUndoMark, type Meta, type EntryResult, type TallyScoresheet, type MarkScoresheet } from '../models/types.js'
import { type CompetitionEventDefinition } from '../preconfigured/types.js'

export function isObject (x: unknown): x is Record<string, unknown> {
Expand Down Expand Up @@ -105,43 +104,114 @@ export function filterMarkStream <Schema extends string> (rawMarks: Readonly<Arr
return marks as Array<GenericMark<Schema>>
}

export function filterTally <Schema extends string> (_tally: ScoreTally, fieldDefinitions?: Readonly<Array<JudgeFieldDefinition<Schema>>>): ScoreTally<Schema> {
if (fieldDefinitions == null) return _tally as ScoreTally<Schema>
const tally: ScoreTally<Schema> = {}
export function normaliseTally <TallySchema extends string> (tallyDefinitions: Readonly<Array<Readonly<JudgeTallyFieldDefinition<TallySchema>>>>, _tally?: Readonly<ScoreTally<TallySchema>>) {
const tally: ScoreTally<TallySchema> = {}

for (const field of fieldDefinitions) {
const v = _tally[field.schema]
for (const field of tallyDefinitions) {
const v = _tally?.[field.schema] ?? field.default ?? 0
if (typeof v !== 'number') continue

tally[field.schema] = clampNumber(v, field)
}

return tally as Required<ScoreTally<TallySchema>>
}

export interface MarkReducerCacheEntry <MarkSchema extends string, TallySchema extends string = MarkSchema> {
tally: ScoreTally<TallySchema>
marks: Array<GenericMark<MarkSchema>>
}
export type MarkReducer<MarkSchema extends string, TallySchema extends string = MarkSchema> = (tally: Required<ScoreTally<TallySchema>>, mark: Readonly<GenericMark<MarkSchema>>, marks: Readonly<Array<Readonly<GenericMark<MarkSchema>>>>) => Required<ScoreTally<TallySchema>>
export interface MarkReducerReturn <MarkSchema extends string, TallySchema extends string = MarkSchema> {
tally: Readonly<ScoreTally<TallySchema>>
addMark: (mark: Mark<MarkSchema> | Omit<Mark<MarkSchema>, 'sequence' | 'timestamp'>) => void
}
export function createMarkReducer <MarkSchema extends string, TallySchema extends string = MarkSchema> (
reducer: MarkReducer<MarkSchema, TallySchema>,
tallyDefinitions: Readonly<Array<Readonly<JudgeTallyFieldDefinition<TallySchema>>>>
): MarkReducerReturn<MarkSchema, TallySchema> {
let nextSeq = 0
let marks: Array<GenericMark<MarkSchema>> = []
const tallies = new Map<number, Readonly<Required<ScoreTally<TallySchema>>>>()

return {
get tally () {
return { ...(tallies.get(nextSeq - 1) ?? normaliseTally(tallyDefinitions)) }
},
addMark (_mark) {
const mark: Mark<MarkSchema> = 'timestamp' in _mark && 'sequence' in _mark
? _mark
: {
sequence: nextSeq,
timestamp: Date.now(),
..._mark,
} as Mark<MarkSchema>

if (mark.sequence !== nextSeq) throw new TypeError('Marks must be provided with sequence in order with a starting sequence of 0')

if (isClearMark(mark)) {
marks = []
tallies.set(nextSeq, normaliseTally(tallyDefinitions))
} else if (isUndoMark(mark)) {
const targetIdx = marks.findLastIndex(searchMark => searchMark.sequence === mark.target)

if (targetIdx >= 0 && !isUndoMark(marks[targetIdx]) && !isClearMark(marks[targetIdx])) {
marks.splice(targetIdx, 1)

const prevMarkSeq = marks[targetIdx - 1]?.sequence ?? -1
let tally = { ...(tallies.get(prevMarkSeq) ?? normaliseTally(tallyDefinitions)) }

for (let idx = targetIdx; idx < marks.length; idx++) {
const markSlice = marks.slice(0, idx + 1)
const mark = markSlice.at(-1)
if (mark != null) {
tally = reducer({ ...tally }, mark, markSlice)
tallies.set(mark.sequence, normaliseTally(tallyDefinitions, tally))
}
}

// if we undid the latest mark there won't be any marks to process in
// the loop
tallies.set(nextSeq, normaliseTally(tallyDefinitions, tally))
} else {
const tally = { ...(tallies.get(nextSeq - 1) ?? normaliseTally(tallyDefinitions)) }
tallies.set(nextSeq, tally)
}
} else {
marks.push(mark)
const tally = reducer({ ...(tallies.get(nextSeq - 1) ?? normaliseTally(tallyDefinitions)) }, mark, marks)
tallies.set(nextSeq, normaliseTally(tallyDefinitions, tally))
}

nextSeq++
},
}
}

export const simpleReducer: MarkReducer<string, string> = (tally, mark) => {
tally[mark.schema] = (tally[mark.schema] ?? 0) + (mark.value ?? 1)
return tally
}

/**
* Takes a scoresheet and returns a tally
*
* if a MarkScoresheet is provided the marks array will be tallied taking undos
* into account.
* Takes a mark scoresheet and returns a tally.
*
* Each value of the tally will also be clamped to the specified max, min and
* step size for that field schema.
*/
export function simpleCalculateTallyFactory <Schema extends string> (judgeTypeId: string, fieldDefinitions?: Readonly<Array<JudgeFieldDefinition<Schema>>>) {
return function simpleCalculateTally (scoresheet: MarkScoresheet<Schema>) {
export function calculateTallyFactory <MarkSchema extends string, TallySchema extends string = MarkSchema> (judgeTypeId: string, reducerFn: MarkReducer<MarkSchema, TallySchema>, tallyDefinitions: Readonly<Array<JudgeTallyFieldDefinition<TallySchema>>>) {
return function calculateTally (scoresheet: MarkScoresheet<MarkSchema>) {
if (!matchMeta(scoresheet.meta, { judgeTypeId })) throw new RSRWrongJudgeTypeError(scoresheet.meta.judgeTypeId, judgeTypeId)
let tally: ScoreTally<Schema> = isTallyScoresheet<Schema>(scoresheet) ? { ...(scoresheet.tally ?? {}) } : {}

for (const mark of filterMarkStream(scoresheet.marks)) {
tally[mark.schema] = (tally[mark.schema] ?? 0) + (mark.value ?? 1)
}
const reducer = createMarkReducer<MarkSchema, TallySchema>(reducerFn, tallyDefinitions)

tally = filterTally(tally, fieldDefinitions)
for (const mark of scoresheet.marks) {
reducer.addMark(mark)
}

return {
meta: scoresheet.meta,
tally,
tally: reducer.tally,
}
}
}
Expand Down
37 changes: 31 additions & 6 deletions lib/models/competition-events/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as mod from './[email protected]'
import * as srMod from './[email protected]'
import { type JudgeResult, type EntryMeta, type JudgeMeta } from '../types.js'
import { RSRWrongJudgeTypeError } from '../../errors.js'
import { markGeneratorFactory } from '../../helpers.test.js'
import { markGeneratorFactory } from '../../helpers/helpers.test.js'

void test('[email protected]', async t => {
await t.test('L', async t => {
Expand Down Expand Up @@ -53,7 +53,7 @@ void test('[email protected]', async t => {
meta,
marks: [m('miss'), m('miss'), m('rqInteractions')],
}),
{ meta, tally: { miss: 2 } }
{ meta, tally: { miss: 2, spaceViolation: 0, timeViolation: 0 } }
)
})

Expand Down Expand Up @@ -127,14 +127,27 @@ void test('[email protected]', async t => {
{
meta,
tally: {
diffL1Minus: 0,
diffL1: 2,
diffL1Plus: 1,

diffL2Minus: 0,
diffL2: 3,
diffL2Plus: 0,

diffL3Minus: 0,
diffL3: 4,
diffL3Plus: 0,

diffL4Minus: 0,
diffL4: 5,
diffL4Plus: 0,

diffL5Minus: 1,
diffL5: 6,
diffL5Plus: 0,

break: 2,
diffL1Plus: 1,
diffL5Minus: 1,
},
}
)
Expand Down Expand Up @@ -208,13 +221,25 @@ void test('[email protected]', async t => {
{
meta,
tally: {
diffL1Minus: 0,
diffL1: 2,
diffL1Plus: 1,

diffL2Minus: 0,
diffL2: 3,
diffL2Plus: 0,

diffL3Minus: 0,
diffL3: 4,
diffL3Plus: 0,

diffL4Minus: 0,
diffL4: 5,
diffL5: 6,
diffL1Plus: 1,
diffL4Plus: 0,

diffL5Minus: 1,
diffL5: 6,
diffL5Plus: 0,
},
}
)
Expand Down
Loading

0 comments on commit eb1be38

Please sign in to comment.