groupster-engine
Version:
Randomly group objects using do-group and don't-group rules.
159 lines (147 loc) • 5.95 kB
JavaScript
import _ from 'lodash';
import Joi from 'joi-browser';
import SafeJoi from './SafeJoi';
import buildRuleMap from './buildRuleMap';
export const determineGroupSizes = (idCount, desiredGroupSize) => {
const errors = [
SafeJoi(
idCount,
Joi.number().min(1),
`idCount must be a number greater than 0 (it was ${idCount})`,
),
SafeJoi(
desiredGroupSize,
Joi.number().min(2).max(idCount),
`desiredGroupSize must be a number greater than 1 and less than or equal to idCount (it was ${idCount})`,
),
].filter(Boolean);
const groups = [];
if (!errors.length) {
while (idCount >= desiredGroupSize) {
groups.push(desiredGroupSize);
idCount -= desiredGroupSize;
}
// If some are remaining then the end goal is to have max # of groups of desired size, with minimal # of groups off by 1.
// If not possible just drop remaining into group of their own.
// Universal rule is that you should never have a group of size 1.
if (idCount > 0) {
// See if possible to distribute into other groups
let canDistribute = idCount <= groups.length;
// See if possible to skim other groups into this group
let canSkim = idCount + groups.length >= desiredGroupSize - 1;
if ((idCount < desiredGroupSize - 1 && (canSkim || canDistribute)) || idCount === 1) {
// See how many groups would be affected by distributing remaining into other groups
const groupsAffectedByDistribute = idCount;
// See how many groups would be affected by skimming other groups by 1 until remainder is almost a full group
const groupsAffectedBySkim = desiredGroupSize - idCount;
if (canSkim && canDistribute) {
if (groupsAffectedBySkim <= groupsAffectedByDistribute && idCount > 1) {
// Prefer skimming -> this leads to smaller groups
canDistribute = false;
}
else {
canSkim = false;
}
}
if (canSkim) {
// Skim groups, starting at end, until remaining is enough for its own group
for (let i = groups.length - 1; i >= 0; i -= 1) {
groups[i] -= 1;
idCount += 1;
if (idCount >= desiredGroupSize - 1) {
groups.push(idCount);
break;
}
}
}
else {
// Distribute remaining among groups, starting at end
for (let i = groups.length - 1; i >= 0; i -= 1) {
groups[i] += 1;
idCount -= 1;
if (idCount === 0) {
break;
}
}
}
}
// If neither are possible to get all groups within 1 of the desiredGroupSize then give up and just use remainder as group
else {
groups.push(idCount);
}
}
}
return {
groups,
errors,
};
};
/**
* Given a array of IDs and an array of the group sizes desired, along with
* an optional set of rules to govern how they should be grouped, returns
* an array of arrays containing grouped IDs.
*
* @export
* @param { array } IDs - Array containing [unique] ids to be grouped.
* @param { number[] } groupSizes -
* Array containing sizes of groups desired. The sum of its values must equal the length of 'IDs' param.
* @param { array } [rules] -
* Array of rule objects. A rule object must contain a boolean 'shouldGroup' property and an 'IDs' property
* containing an array of IDs for which the rule applies. If the 'shouldGroup' property is false these IDs
* will be placed into separate groups (to the extent possible). If true then they will be placed into
* the same group (to the extent possible). For now 'shouldGroup: false' rules will take precedence.
*
* @returns Array of groups. Each group is an array containing ids.
* @throws ValidationError if any of the parameters don't conform to their schemas.
* @throws Error if any IDs are duplicates, or rules reference IDs not present in 'IDs' param.
*/
export default function Group(IDs, groupSizes, rules = []) {
// The keys of ruleMap are deleted as each id is placed
const ruleMap = buildRuleMap(IDs, groupSizes, rules);
const groups = groupSizes.map(size => {
const list = [];
return {
get size() { return size; },
get list() { return list; },
get roomLeft() { return size - list.length; },
add(id) {
list.push(id);
},
fitRating(id) {
// If room is left
if (size - list.length > 0) {
return list.reduce((acc, otherID) => {
const rating = ruleMap[id][otherID];
if (rating === undefined) {
return acc + 0;
}
return acc + (rating ? 1 : -1);
}, 0);
}
// If no room
return -Infinity;
},
};
});
const addToBestGroup = id => {
// Add to the best group (preferring one with the most room)
const groupRatings = groups.map((group, index) => ({ index, roomLeft: group.roomLeft, rating: group.fitRating(id) }));
const bestGroupIndex = _.sortBy(groupRatings, ['rating', 'roomLeft'])[groupRatings.length - 1].index;
groups[bestGroupIndex].add(id);
// Remove from ruleMap
delete ruleMap[id];
};
const dontGroups = rules.filter(rule => !rule.shouldGroup).map(rule => _.shuffle(rule.IDs)).sort((a, b) => b.length - a.length);
dontGroups.forEach(dontGroup => {
const remaining = dontGroup.filter(id => _.has(ruleMap, id));
remaining.forEach(addToBestGroup);
});
const doGroups = rules.filter(rule => rule.shouldGroup).map(rule => _.shuffle(rule.IDs)).sort((a, b) => b.length - a.length);
doGroups.forEach(doGroup => {
const remaining = doGroup.filter(id => _.has(ruleMap, id));
remaining.forEach(addToBestGroup);
});
// Place remaining IDs
_.shuffle(Object.keys(ruleMap)).forEach(addToBestGroup);
return groups.map(group => group.list);
}