A practical and composable score computation engine for TypeScript
score-engine
propose a functional pattern and bring utilities in order to orchestrate multi-scoring behavior.
All of the strength of score-engine
is to not implement specific algorithm and let the user choose his implementation. Ref: Separation of Concerns.
type Item = { id: string, label: string };
const lengthAlgorithm = (items: Item[], context: any) => {
return items.reduce((scoreMap, item) => ({
...scoreMap,
[item.id]: [item.label.length],
}), {} as ScoreMap);
}
A score algorithm is a just a pure function that take several items of type T, a context of type C and return a ScoreMap
.
Please note:
- A
ScoreMap
is aRecord<string, number[]>
, it represents several scores indexed by item id. - All items should have an uniq
id
as score identifier.
There are several score algorithms enhancers
which allow you to customize your algorithm.
With them, you can compose algorithms between them, apply weighting and bounding.
import ScoreEngine from 'score-engine';
type Item = { id: string }
type Context = {};
const noopAlgorithm = () => ({});
const computeScore = ScoreEngine(noopAlgorithm, { idSelector: (x: Item) => x.id });
// or ScoreEngine(noopAlgorithm, { idSelector: 'id' });
const items: Item[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const context: Context = {};
const scoredItems = computeScore(items, context); // return (Item & { _scores: [string] })[]
console.log(scoredItems[0]._scores); // all items score are equals to [0] because noopAlgorithm
Please see the tutorial for more details.
Compose several algorithm into one, each scoreMap
is computed separately and all corresponding scores are summed.
import { pipeAlgorithms } from 'score-engine';
const finalAlgorithm = pipeAlgorithms(
algo1,
algo2,
algo3,
pipeAlgorithms( // can be nested
algo4,
algo4,
),
);
To have relevant scores, you have to be careful with proportions, every score algorithm should compute score in the same bound.
For example, it's a non-sense to compose 2 algorithms if algo1
compute score between 0 and 10 and algo2
compute score between 0 and 1.
To keep proportions, you have 3 solutions :
- weighting using
withWeight
- bounding using
withPercentages
- bounding using
withStrictBound
Apply a given weight to a score algorithm.
import { withWeight } from 'score-engine';
const newAlgorithm = withWeight(100, myAlgorithm)
- For example:
{ a: [-0.25], b: [0.75] }
will be transformed into{ a: [-25], b: [75] }
Compute bounded percentages score using the sum of all absolute scores.
This enhancer restrict score range between -1 and 1.
- For example:
{ a: [-250], b: [750] }
will be transformed into{ a: [-0.25], b: [0.75] }
Compute bounded score using the lowest possible divisor.
This enhancer restrict score range between -1 and 1
- For example:
{ a: [-250], b: [750] }
will be transformed into{ a: [-0.3333333333333333], b: [1.00] }
Apply a side effect function to the ScoreMap result.
import { withFx } from 'score-engine';
const myAlgorithm = () => ({}); // any score algorithm
const myAlgorithmWithLog = withFx(x => console.log(x), myAlgorithm);
Through this dummy example, you'll be able to write your own algorithms.
The goal here is to write and configure 3 algorithms :
- one that compute score as item string length
- one that count contextual occurences (using context.query)
- one that count hardcoded "zevia" occurences
For the example, we need this imports:
import ScoreEngine, { ScoreMap, ScoreAlgorithm, withWeight, withPercentages, pipeAlgorithms } from 'score-engine';
We take back this type:
type Item = { id: string, label: string };
we will use a very simple Context type:
type Context = { query: string };
and we need this utility function:
const countOccurences = (searchValue: string) => (base: string) => (base.match(new RegExp(searchValue, 'g')) || []).length;
const lengthAlgorithm: ScoreAlgorithm<Item, Context> = (items) => {
return items.reduce((scoreMap, item) => ({
...scoreMap,
[item.id]: [item.label.length],
}), {} as ScoreMap);
}
const queryCountAlgorithm: ScoreAlgorithm<Item, Context> = (items, { query }) => {
return items.reduce((scoreMap, item) => ({
...scoreMap,
[item.id]: [countOccurences(query)(item.label)],
}), {} as ScoreMap);
}
const countZevia = countOccurences('Zevia');
const zeviaCountAlgorithm: ScoreAlgorithm<Item, Context> = (items) => {
return items.reduce((scoreMap, item) => ({
...scoreMap,
[item.id]: [countZevia(item.label)],
}), {} as ScoreMap);
}
All of this algorithm should be bounded between 0 and 1 (using withPercentages
enhancer).
First algorithm should have a weight of 10, second of 100, and third of 1000.
const finalAlgorithm = pipeAlgorithms( // order does not matter
withWeight(10, withPercentages(lengthAlgorithm)),
withWeight(100, withPercentages(queryCountAlgorithm)),
withWeight(1000, withPercentages(zeviaCountAlgorithm)),
);
const computeScore = ScoreEngine(finalAlgorithm, { idSelector: 'id' });
Please note: withWeight
must be the last enhancer to be applied.
const items: Item[] = [
{ id: '0', label: '' },
{ id: '1', label: 'just a simple sentence' },
{ id: 'a', label: 'Station: one app to rule them all' },
{ id: 'b', label: 'every guys working at Station drink Zevia !' },
{ id: 'c', label: 'Station ! Station ! Station ! Station ! Station !' },
{ id: 'd', label: 'Zevia Zevia Zevia !' },
];
const scoredItems: Scored<Item>[] = computeScore(items, { query: 'Station' });
console.log(scoredItems);
/*
[
{
id: '0',
label: '',
_scores: [0, 0, 0],
},
{
id: '1',
label: 'just a simple sentence',
_scores: [1.3253012048192772, 0, 0],
},
{
id: 'a',
label: 'Station: one app to rule them all',
_scores: [
1.9879518072289157,
14.285714285714285,
0,
],
},
{
id: 'b',
label: 'every guys working at Station drink Zevia !',
_scores: [
2.5903614457831328,
14.285714285714285,
250,
],
},
{
id: 'c',
label: 'Station ! Station ! Station ! Station ! Station !',
_scores: [
2.951807228915663,
71.42857142857143,
0,
],
},
{
id: 'd',
label: 'Zevia Zevia Zevia !',
_scores: [
1.144578313253012,
0,
750,
],
},
]
*/
As you can see in _scores
, there are 3 computed score for each item.
Each score represent a single score algorithm result, applied in the order of composed algorithms in pipeAlgorithms
.
So it let you compute your final score with a custom strategy, e.g.
const getFinalScore = (item: Scored<Item>): number => {
const [lengthScore, queryScore, zeviaScore] = item._scores;
return (lengthScore || 1) * (queryScore + zeviaScore);
};
const finalScoredItems = scoredItems.map(item => ({
...item,
_scores: [getFinalScore(item)],
}));
No, score-engine intends to be a pure synchronous library
No, but we plan to provide generic algorithm factories in the future. (fusejs integration, frecency scoring, and so on...)
You can use Dependency injection using context second argument of your algorithm.
import { readFileSync } from 'fs';
const context = { readFileSync };
It's possible to make an algorithm factory using Dependency injection by Currying.
const myAlgorithmFactory = (fs) => (items) => {
// algorithm implementation
}
Please note you cannot configure or compose an algorithm factory, you have to create an algorithm with it, that's the main difference between using context and using factory.
Yes, a score algorithm enhancer (or higher order score algorithm) is just a function that take a score algorithm and return a score algorithm.
example:
const myAlgorithm = () => ({}); // any score algorithm
// This function take a threshold and a score algorithm and return a new score algorithm
// filtering all scores which be lower than threshold
const withThreshold = (threshold, algorithm) => (items, context) => {
const scoreMap = algorithm(items, context);
return Object.keys(scoreMap).reduce((finalScoreMap, id) => {
const score = scoreMap[id];
return {
...finalScoreMap,
[id]: [score >= threshold ? score : 0],
};
}, {} as ScoreMap);
}
const myAlgorithmWithThreshold = withThreshold(0.5, myAlgorithm)
Is this library is related to Fisher's scoring ?
Absolutely not