Added working first version.

This commit is contained in:
Andrew Lalis 2021-03-01 23:57:26 +01:00
parent 1d5d1adee1
commit f580857c14
8 changed files with 532 additions and 0 deletions

116
.gitignore vendored Normal file
View File

@ -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

71
pom.xml Normal file
View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>nl.andrewlalis</groupId>
<artifactId>human-task-distributor</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>12</source>
<target>12</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<archive>
<manifest>
<mainClass>nl.andrewlalis.human_task_distributor.HumanTaskDistributor</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<!-- https://mvnrepository.com/artifact/commons-cli/commons-cli -->
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
<version>1.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-csv -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.8</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.18</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,85 @@
package nl.andrewlalis.human_task_distributor;
import java.util.*;
import java.util.stream.Collectors;
public class Distributor {
public Map<Human, Set<Task>> generateDistribution(
Map<Human, Float> weightedHumans,
Set<Task> tasks,
List<Map<Human, Set<Task>>> previousDistributions
) {
if (weightedHumans.isEmpty() || tasks.isEmpty()) {
return Map.of();
}
float unweightedAverageTasksPerHuman = (float) tasks.size() / weightedHumans.size();
// Initialize distribution data sets for each human.
Map<Human, Set<Task>> 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<Human, Float> maxTasksPerHuman = new HashMap<>();
weightedHumans.forEach((h, w) -> maxTasksPerHuman.put(h, w * averageTasksPerHuman));
// Prepare a stack of tasks we can gradually take from.
Stack<Task> 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:
* <ol>
* <li>Whether the person has been given this exact task before.</li>
* <li>The difference between the tasks the person has, and how many they can have at most.</li>
* </ol>
* @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<Human, Set<Task>> tasksPerHuman,
Map<Human, Float> maxTasksPerHuman,
Task task,
List<Map<Human, Set<Task>>> previousDistributions
) {
List<Human> 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);
}
}

View File

@ -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<Human, Float> parseHumanList(String path) {
Map<Human, Float> 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<Task> parseTaskList(String path) {
Set<Task> 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<Map<Human, Set<Task>>> parsePreviousTaskDistributions(String[] paths) {
List<Map<Human, Set<Task>>> previousDistributions = new ArrayList<>();
for (String path : paths) {
Map<Human, Set<Task>> 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;
}
}

View File

@ -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();
}
}

View File

@ -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<Human, Float> nameWeightMap = fileParser.parseHumanList(cmd.getOptionValue("hl"));
Set<Task> tasks = fileParser.parseTaskList(cmd.getOptionValue("tl"));
List<Map<Human, Set<Task>>> previousDistributions = fileParser.parsePreviousTaskDistributions(cmd.getOptionValues("prev"));
Map<Human, Set<Task>> 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<Human, Set<Task>> entry : taskDistributions.entrySet()) {
Human human = entry.getKey();
Set<Task> assignedTasks = entry.getValue();
List<Task> 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;
}
}

View File

@ -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());
}
}

View File

@ -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<Task> tasks;
public TaskDistribution(Human human, float weight, Set<Task> 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 +
'}';
}
}