Added working first version.
This commit is contained in:
parent
1d5d1adee1
commit
f580857c14
|
@ -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
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue