diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4047357 --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Java template +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Maven template +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + + +.idea/ +*.iml +distribution.csv diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6bd5970 --- /dev/null +++ b/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + nl.andrewlalis + human-task-distributor + 1.0-SNAPSHOT + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 12 + 12 + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.1.1 + + + + nl.andrewlalis.human_task_distributor.HumanTaskDistributor + + + + jar-with-dependencies + + false + + + + make-assembly + package + + single + + + + + + + + + + + commons-cli + commons-cli + 1.4 + + + + org.apache.commons + commons-csv + 1.8 + + + + org.projectlombok + lombok + 1.18.18 + provided + + + \ No newline at end of file diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/Distributor.java b/src/main/java/nl/andrewlalis/human_task_distributor/Distributor.java new file mode 100644 index 0000000..f1efecc --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/Distributor.java @@ -0,0 +1,85 @@ +package nl.andrewlalis.human_task_distributor; + +import java.util.*; +import java.util.stream.Collectors; + +public class Distributor { + + public Map> generateDistribution( + Map weightedHumans, + Set tasks, + List>> previousDistributions + ) { + if (weightedHumans.isEmpty() || tasks.isEmpty()) { + return Map.of(); + } + + float unweightedAverageTasksPerHuman = (float) tasks.size() / weightedHumans.size(); + + // Initialize distribution data sets for each human. + Map> taskDistributions = new HashMap<>(weightedHumans.size()); + weightedHumans.forEach((h, w) -> taskDistributions.put(h, new HashSet<>())); + final float totalWeight = weightedHumans.values().stream().reduce(Float::sum).orElse(0.0f); + final float averageTasksPerHuman = unweightedAverageTasksPerHuman / (totalWeight / taskDistributions.size()); + + Map maxTasksPerHuman = new HashMap<>(); + weightedHumans.forEach((h, w) -> maxTasksPerHuman.put(h, w * averageTasksPerHuman)); + + // Prepare a stack of tasks we can gradually take from. + Stack taskStack = new Stack<>(); + taskStack.addAll(tasks); + Collections.shuffle(taskStack); + + while (!taskStack.empty()) { + Task t = taskStack.pop(); + Human h = this.chooseHumanForNextTask( + taskDistributions, + maxTasksPerHuman, + t, + previousDistributions + ); + taskDistributions.get(h).add(t); + } + + return taskDistributions; + } + + /** + * Chooses the next person for a task, using a ranked choice based on two + * criteria: + *
    + *
  1. Whether the person has been given this exact task before.
  2. + *
  3. The difference between the tasks the person has, and how many they can have at most.
  4. + *
+ * @param tasksPerHuman A set of tasks that each person is already assigned to. + * @param maxTasksPerHuman For each person, a floating point maximum number + * of tasks that they may be assigned to. + * @param task The task being assigned. + * @param previousDistributions A list of distributions done previously, in + * order to avoid assigning people to the same + * tasks. + * @return The human to use for the task. + */ + private Human chooseHumanForNextTask( + Map> tasksPerHuman, + Map maxTasksPerHuman, + Task task, + List>> previousDistributions + ) { + List rankedHumans = maxTasksPerHuman.keySet().stream() + .sorted((h1, h2) -> { + // Sort first so people who haven't had the task are sorted first. + boolean h1NeverHadTask = previousDistributions.stream().noneMatch(map -> map.getOrDefault(h1, Set.of()).contains(task)); + boolean h2NeverHadTask = previousDistributions.stream().noneMatch(map -> map.getOrDefault(h2, Set.of()).contains(task)); + int previousTaskCompare = Boolean.compare(h1NeverHadTask, h2NeverHadTask); + if (previousTaskCompare != 0) { + return previousTaskCompare; + } + final float diff1 = maxTasksPerHuman.get(h1) - tasksPerHuman.get(h1).size(); + final float diff2 = maxTasksPerHuman.get(h2) - tasksPerHuman.get(h2).size(); + return Float.compare(diff2, diff1); // Reverse sorting direction so largest diffs appear first. + }) + .collect(Collectors.toList()); + return rankedHumans.get(0); + } +} diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/FileParser.java b/src/main/java/nl/andrewlalis/human_task_distributor/FileParser.java new file mode 100644 index 0000000..e517238 --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/FileParser.java @@ -0,0 +1,77 @@ +package nl.andrewlalis.human_task_distributor; + +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.*; + +import static nl.andrewlalis.human_task_distributor.HumanTaskDistributor.CSV_FORMAT; + +public class FileParser { + + public Map parseHumanList(String path) { + Map humanNameWeightMap = new HashMap<>(); + try (CSVParser csvParser = CSVParser.parse(Paths.get(path), StandardCharsets.UTF_8, CSV_FORMAT)) { + for (CSVRecord record : csvParser) { + if (record.size() > 0) { + String name = record.get(0).trim(); + float weight = 1.0f; + if (record.size() > 1) { + try { + weight = Float.parseFloat(record.get(1)); + } catch (NumberFormatException e) { + // Do nothing here, simply skip a custom weight. + } + } + humanNameWeightMap.put(new Human(name), weight); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + return humanNameWeightMap; + } + + public Set parseTaskList(String path) { + Set taskSet = new HashSet<>(); + try (CSVParser csvParser = CSVParser.parse(Paths.get(path), StandardCharsets.UTF_8, CSV_FORMAT)) { + for (CSVRecord record : csvParser) { + if (record.size() > 0) { + String taskName = record.get(0); + if (!taskName.isBlank()) { + taskSet.add(new Task(taskName.trim())); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + return taskSet; + } + + public List>> parsePreviousTaskDistributions(String[] paths) { + List>> previousDistributions = new ArrayList<>(); + for (String path : paths) { + Map> distribution = new HashMap<>(); + try (CSVParser csvParser = CSVParser.parse(Paths.get(path), StandardCharsets.UTF_8, CSV_FORMAT)) { + for (CSVRecord record : csvParser) { + if (record.size() > 1) { + Human h = new Human(record.get(0).trim()); + Task t = new Task(record.get(1).trim()); + if (!distribution.containsKey(h)) { + distribution.put(h, new HashSet<>()); + } + distribution.get(h).add(t); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + previousDistributions.add(distribution); + } + return previousDistributions; + } +} diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/Human.java b/src/main/java/nl/andrewlalis/human_task_distributor/Human.java new file mode 100644 index 0000000..0d575e1 --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/Human.java @@ -0,0 +1,32 @@ +package nl.andrewlalis.human_task_distributor; + +import lombok.Getter; + +import java.util.Objects; + +@Getter +public class Human { + private final String name; + + public Human(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Human human = (Human) o; + return getName().equals(human.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(getName()); + } + + @Override + public String toString() { + return this.getName(); + } +} diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/HumanTaskDistributor.java b/src/main/java/nl/andrewlalis/human_task_distributor/HumanTaskDistributor.java new file mode 100644 index 0000000..c793337 --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/HumanTaskDistributor.java @@ -0,0 +1,92 @@ +package nl.andrewlalis.human_task_distributor; + +import org.apache.commons.cli.*; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class HumanTaskDistributor { + public static final CSVFormat CSV_FORMAT = CSVFormat.RFC4180; + + public static void main(String[] args) { + final Options options = getOptions(); + CommandLineParser cmdParser = new DefaultParser(); + try { + CommandLine cmd = cmdParser.parse(options, args); + FileParser fileParser = new FileParser(); + Map nameWeightMap = fileParser.parseHumanList(cmd.getOptionValue("hl")); + Set tasks = fileParser.parseTaskList(cmd.getOptionValue("tl")); + List>> previousDistributions = fileParser.parsePreviousTaskDistributions(cmd.getOptionValues("prev")); + Map> taskDistributions = new Distributor().generateDistribution(nameWeightMap, tasks, previousDistributions); + taskDistributions.forEach((key, value) -> { + System.out.println("Task distribution for " + key + ", " + value.size() + " tasks:"); + value.forEach(t -> System.out.println("\t" + t.getName())); + }); + String filePath = cmd.hasOption("o") ? cmd.getOptionValue("o") : "distribution.csv"; + CSVPrinter printer = new CSVPrinter(Files.newBufferedWriter(Paths.get(filePath), StandardCharsets.UTF_8), CSV_FORMAT); + for (Map.Entry> entry : taskDistributions.entrySet()) { + Human human = entry.getKey(); + Set assignedTasks = entry.getValue(); + List sortedTasks = assignedTasks.stream().sorted(Comparator.comparing(Task::getName)).collect(Collectors.toList()); + for (Task task : sortedTasks) { + printer.printRecord(human.getName(), task.getName()); + } + } + printer.close(true); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + HelpFormatter hf = new HelpFormatter(); + hf.printHelp("HumanTaskDistributor", options); + System.exit(1); + } + } + + private static Options getOptions() { + Options options = new Options(); + options.addOption(Option.builder("hl") + .longOpt("humans-list") + .hasArg(true) + .desc("Path to a CSV file containing list of humans to distribute tasks to. First column should be the name of the person, and second column can be empty, or contain a floating-point weight.") + .required(true) + .numberOfArgs(1) + .type(String.class) + .build() + ); + options.addOption(Option.builder("tl") + .longOpt("tasks-list") + .hasArg(true) + .desc("Path to a CSV file containing list of tasks that can be distributed to humans. First column should be unique task name.") + .required(true) + .numberOfArgs(1) + .type(String.class) + .build() + ); + options.addOption(Option.builder("prev") + .longOpt("previous-distributions") + .desc("One or more CSV files containing previous task distribution results, to aid in balancing distribution over multiple iterations. Each should be of the form: person name, task name") + .numberOfArgs(Option.UNLIMITED_VALUES) + .hasArg(true) + .required(false) + .valueSeparator(',') + .build() + ); + options.addOption(Option.builder("o") + .longOpt("output") + .desc("Output file to write CSV distribution data to.") + .hasArg(true) + .numberOfArgs(1) + .required(false) + .type(String.class) + .build() + ); + return options; + } +} diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/Task.java b/src/main/java/nl/andrewlalis/human_task_distributor/Task.java new file mode 100644 index 0000000..1b91e6c --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/Task.java @@ -0,0 +1,27 @@ +package nl.andrewlalis.human_task_distributor; + +import lombok.Getter; + +import java.util.Objects; + +@Getter +public class Task { + private final String name; + + public Task(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Task task = (Task) o; + return getName().equals(task.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(getName()); + } +} diff --git a/src/main/java/nl/andrewlalis/human_task_distributor/TaskDistribution.java b/src/main/java/nl/andrewlalis/human_task_distributor/TaskDistribution.java new file mode 100644 index 0000000..2ba2f40 --- /dev/null +++ b/src/main/java/nl/andrewlalis/human_task_distributor/TaskDistribution.java @@ -0,0 +1,32 @@ +package nl.andrewlalis.human_task_distributor; + +import lombok.Getter; + +import java.util.HashSet; +import java.util.Set; + +@Getter +public class TaskDistribution { + private final Human human; + private final float weight; + private final Set tasks; + + public TaskDistribution(Human human, float weight, Set tasks) { + this.human = human; + this.weight = weight; + this.tasks = tasks; + } + + public TaskDistribution(Human human, float weight) { + this(human, weight, new HashSet<>()); + } + + @Override + public String toString() { + return "TaskDistribution{" + + "human=" + human + + ", weight=" + weight + + ", tasks=" + tasks + + '}'; + } +}