From 2d2405c1b3ed72644f4f930f248b94d09db11761 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:05:43 -0700 Subject: [PATCH] feat: extend rota forge --- .github/workflows/ci.yml | 15 ++- build.sbt | 2 +- data/ward/days.csv | 8 ++ data/ward/preferences.csv | 14 +++ data/ward/rules.csv | 5 + data/ward/shifts.csv | 4 + data/ward/staff.csv | 7 ++ src/main/scala/rotaforge/Main.scala | 2 +- src/main/scala/rotaforge/core/Annealer.scala | 5 +- .../scala/rotaforge/core/InitialRoster.scala | 3 +- .../scala/rotaforge/core/RosterIndex.scala | 5 +- src/main/scala/rotaforge/model/Skills.scala | 25 +++++ .../scala/rotaforge/report/HtmlReport.scala | 2 +- src/main/scala/rotaforge/score/Scorer.scala | 4 +- src/test/resources/ward/days.csv | 8 ++ src/test/resources/ward/preferences.csv | 14 +++ src/test/resources/ward/rules.csv | 5 + src/test/resources/ward/shifts.csv | 4 + src/test/resources/ward/staff.csv | 7 ++ .../scala/rotaforge/InputLoaderSuite.scala | 12 +- src/test/scala/rotaforge/ScorerSuite.scala | 31 +++++ src/test/scala/rotaforge/SkillsSuite.scala | 43 +++++++ src/test/scala/rotaforge/TestRosters.scala | 6 +- src/test/scala/rotaforge/WardSuite.scala | 106 ++++++++++++++++++ 24 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 data/ward/days.csv create mode 100644 data/ward/preferences.csv create mode 100644 data/ward/rules.csv create mode 100644 data/ward/shifts.csv create mode 100644 data/ward/staff.csv create mode 100644 src/main/scala/rotaforge/model/Skills.scala create mode 100644 src/test/resources/ward/days.csv create mode 100644 src/test/resources/ward/preferences.csv create mode 100644 src/test/resources/ward/rules.csv create mode 100644 src/test/resources/ward/shifts.csv create mode 100644 src/test/resources/ward/staff.csv create mode 100644 src/test/scala/rotaforge/SkillsSuite.scala create mode 100644 src/test/scala/rotaforge/WardSuite.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad5cf4..a6dda89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,20 @@ jobs: grep -q "Constraint scores" out/ci-demo/report.html grep -q "Weekend balance" out/ci-demo/report.html - - name: Upload the demo report + - name: Solve the ward example + run: sbt -batch -no-colors "run data/ward out/ci-ward" + + - name: Verify the ward outputs + run: | + test -s out/ci-ward/roster.csv + test -s out/ci-ward/report.html + grep -q "Skill match" out/ci-ward/report.html + grep -q "Tom Walker" out/ci-ward/report.html + grep -q "Ana Reyes" out/ci-ward/report.html + + - name: Upload the demo reports uses: actions/upload-artifact@v4 with: - name: clinic-report + name: reports path: out/ci-demo if-no-files-found: error diff --git a/build.sbt b/build.sbt index 51bb104..dfd4b04 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ ThisBuild / organization := "io.rotaforge" -ThisBuild / version := "0.1.0" +ThisBuild / version := "0.2.0" ThisBuild / scalaVersion := "3.3.8" lazy val root = (project in file(".")) diff --git a/data/ward/days.csv b/data/ward/days.csv new file mode 100644 index 0000000..93441dd --- /dev/null +++ b/data/ward/days.csv @@ -0,0 +1,8 @@ +date +2026-06-01 +2026-06-02 +2026-06-03 +2026-06-04 +2026-06-05 +2026-06-06 +2026-06-07 diff --git a/data/ward/preferences.csv b/data/ward/preferences.csv new file mode 100644 index 0000000..07d4327 --- /dev/null +++ b/data/ward/preferences.csv @@ -0,0 +1,14 @@ +staff,date,shift,weight +W1,2026-06-01,D,5 +W1,2026-06-02,N,3 +W2,2026-06-03,E,4 +W3,2026-06-04,D,5 +W5,2026-06-02,E,3 +W5,2026-06-05,D,2 +W6,2026-06-07,D,4 +W4,2026-06-03,D,0 +W4,2026-06-03,E,0 +W4,2026-06-03,N,0 +W2,2026-06-06,D,0 +W2,2026-06-06,E,0 +W2,2026-06-06,N,0 diff --git a/data/ward/rules.csv b/data/ward/rules.csv new file mode 100644 index 0000000..35cc39d --- /dev/null +++ b/data/ward/rules.csv @@ -0,0 +1,5 @@ +key,value +maxShiftsPerPerson,5 +maxConsecutiveDays,4 +minRestHours,12 +hardPenalty,1000000 diff --git a/data/ward/shifts.csv b/data/ward/shifts.csv new file mode 100644 index 0000000..4203c3b --- /dev/null +++ b/data/ward/shifts.csv @@ -0,0 +1,4 @@ +id,name,start,end,skill,count +D,Day Care,07:00,19:00,C.A.,2 +E,Clinical Shift,15:00,03:00,R.N.,1 +N,Night Duty,21:00,09:00,*,1 diff --git a/data/ward/staff.csv b/data/ward/staff.csv new file mode 100644 index 0000000..3ab7bea --- /dev/null +++ b/data/ward/staff.csv @@ -0,0 +1,7 @@ +id,name,skill +W1,Maria Costa,R.N. +W2,Yuki Tanaka,R.N. +W3,Omar Diallo,C.A. +W4,Priya Nair,C.A. +W5,Tom Walker,R.N.;C.A. +W6,Ana Reyes,R.N.;C.A. diff --git a/src/main/scala/rotaforge/Main.scala b/src/main/scala/rotaforge/Main.scala index 5825e77..263120a 100644 --- a/src/main/scala/rotaforge/Main.scala +++ b/src/main/scala/rotaforge/Main.scala @@ -7,7 +7,7 @@ import rotaforge.score.ConstraintKind /** Command line entry point for Rota Forge. */ object Main { - private val version = "0.1.0" + private val version = "0.2.0" private val usage: String = s"""Rota Forge $version - staff rostering solver diff --git a/src/main/scala/rotaforge/core/Annealer.scala b/src/main/scala/rotaforge/core/Annealer.scala index d82dbf9..91c5a45 100644 --- a/src/main/scala/rotaforge/core/Annealer.scala +++ b/src/main/scala/rotaforge/core/Annealer.scala @@ -1,6 +1,7 @@ package rotaforge.core import rotaforge.model.Preference +import rotaforge.model.Skills import rotaforge.score.Scorer import scala.util.Random @@ -174,8 +175,8 @@ final class Annealer(index: RosterIndex, scorer: Scorer) { st += 1 } if (a == -1 || b == -1) return null - val aSkilled = index.shiftSkill(s2) == "*" || index.staffSkill(a) == index.shiftSkill(s2) - val bSkilled = index.shiftSkill(s1) == "*" || index.staffSkill(b) == index.shiftSkill(s1) + val aSkilled = Skills.matches(index.shiftSkills(s2), index.staffSkills(a)) + val bSkilled = Skills.matches(index.shiftSkills(s1), index.staffSkills(b)) if (!aSkilled || !bSkilled) return null if (!canWork(a, day, s2) || !canWork(b, day, s1)) return null ShiftSwap(day, a, b, s1, s2) diff --git a/src/main/scala/rotaforge/core/InitialRoster.scala b/src/main/scala/rotaforge/core/InitialRoster.scala index b9930cc..56fc7a5 100644 --- a/src/main/scala/rotaforge/core/InitialRoster.scala +++ b/src/main/scala/rotaforge/core/InitialRoster.scala @@ -2,6 +2,7 @@ package rotaforge.core import rotaforge.model.Preference import rotaforge.model.Rules +import rotaforge.model.Skills /** * Builds a first feasible schedule with a greedy assignment pass. @@ -45,7 +46,7 @@ object InitialRoster { val eligible = duty(st)(day) == -1 && workload(st) < rules.maxShiftsPerPerson && - (index.shiftSkill(s) == "*" || index.staffSkill(st) == index.shiftSkill(s)) && + Skills.matches(index.shiftSkills(s), index.staffSkills(st)) && !blocked.contains((st, day, s)) if (eligible) { val key = workload(st) diff --git a/src/main/scala/rotaforge/core/RosterIndex.scala b/src/main/scala/rotaforge/core/RosterIndex.scala index 7baa794..fd0231b 100644 --- a/src/main/scala/rotaforge/core/RosterIndex.scala +++ b/src/main/scala/rotaforge/core/RosterIndex.scala @@ -3,6 +3,7 @@ package rotaforge.core import java.time.LocalDate import rotaforge.model.Roster import rotaforge.model.ShiftDef +import rotaforge.model.Skills /** * Compact integer indexes over a roster. @@ -22,7 +23,7 @@ final class RosterIndex(val roster: Roster) { val staffByIndex: Array[String] = roster.staff.map(_.id).toArray val staffName: Array[String] = roster.staff.map(_.name).toArray - val staffSkill: Array[String] = roster.staff.map(_.skill).toArray + val staffSkills: Array[Set[String]] = roster.staff.map(s => Skills.parse(s.skill)).toArray val daysByIndex: Array[LocalDate] = roster.days.toArray val isWeekend: Array[Boolean] = @@ -32,7 +33,7 @@ final class RosterIndex(val roster: Roster) { val shiftName: Array[String] = roster.shifts.map(_.name).toArray val startMinute: Array[Int] = roster.shifts.map(_.startMinute).toArray val endMinute: Array[Int] = roster.shifts.map(_.endMinute).toArray - val shiftSkill: Array[String] = roster.shifts.map(_.skill).toArray + val shiftSkills: Array[Set[String]] = roster.shifts.map(s => Skills.parse(s.skill)).toArray val headcount: Array[Int] = roster.shifts.map(_.headcount).toArray def requireValid(): Unit = { diff --git a/src/main/scala/rotaforge/model/Skills.scala b/src/main/scala/rotaforge/model/Skills.scala new file mode 100644 index 0000000..cd7c50b --- /dev/null +++ b/src/main/scala/rotaforge/model/Skills.scala @@ -0,0 +1,25 @@ +package rotaforge.model + +/** + * Parses and compares skill lists. + * + * A skill column can hold one skill or several. A semicolon separates + * the skills. For example, `R.N.;C.A.` means the staff member can fill + * either a nurse role or a care assistant role. A shift with the + * wildcard `*` accepts any staff member. + */ +object Skills { + + /** Split a raw skill column into a set of skills. */ + def parse(raw: String): Set[String] = + raw.split(";").iterator.map(_.trim).filter(_.nonEmpty).toSet + + /** + * True when the staff member can fill the shift. + * + * The shift is fillable when it takes anyone (the `*` wildcard) or + * when the staff member holds at least one of its required skills. + */ + def matches(shiftSkills: Set[String], staffSkills: Set[String]): Boolean = + shiftSkills.contains("*") || shiftSkills.exists(staffSkills.contains) +} diff --git a/src/main/scala/rotaforge/report/HtmlReport.scala b/src/main/scala/rotaforge/report/HtmlReport.scala index 81b7b5f..1d00ec8 100644 --- a/src/main/scala/rotaforge/report/HtmlReport.scala +++ b/src/main/scala/rotaforge/report/HtmlReport.scala @@ -251,7 +251,7 @@ $shiftLegend diff --git a/src/main/scala/rotaforge/score/Scorer.scala b/src/main/scala/rotaforge/score/Scorer.scala index ae11b89..8275299 100644 --- a/src/main/scala/rotaforge/score/Scorer.scala +++ b/src/main/scala/rotaforge/score/Scorer.scala @@ -4,6 +4,7 @@ import rotaforge.core.Grid import rotaforge.core.RosterIndex import rotaforge.model.Preference import rotaforge.model.Rules +import rotaforge.model.Skills /** * Scores a schedule against hard and soft constraints. @@ -71,8 +72,7 @@ final class Scorer(index: RosterIndex) { while (st < nStaff) { val s = duty(st)(day) if (s != -1) { - val required = index.shiftSkill(s) - if (required != "*" && required != index.staffSkill(st)) + if (!Skills.matches(index.shiftSkills(s), index.staffSkills(st))) violations += 1 } st += 1 diff --git a/src/test/resources/ward/days.csv b/src/test/resources/ward/days.csv new file mode 100644 index 0000000..93441dd --- /dev/null +++ b/src/test/resources/ward/days.csv @@ -0,0 +1,8 @@ +date +2026-06-01 +2026-06-02 +2026-06-03 +2026-06-04 +2026-06-05 +2026-06-06 +2026-06-07 diff --git a/src/test/resources/ward/preferences.csv b/src/test/resources/ward/preferences.csv new file mode 100644 index 0000000..07d4327 --- /dev/null +++ b/src/test/resources/ward/preferences.csv @@ -0,0 +1,14 @@ +staff,date,shift,weight +W1,2026-06-01,D,5 +W1,2026-06-02,N,3 +W2,2026-06-03,E,4 +W3,2026-06-04,D,5 +W5,2026-06-02,E,3 +W5,2026-06-05,D,2 +W6,2026-06-07,D,4 +W4,2026-06-03,D,0 +W4,2026-06-03,E,0 +W4,2026-06-03,N,0 +W2,2026-06-06,D,0 +W2,2026-06-06,E,0 +W2,2026-06-06,N,0 diff --git a/src/test/resources/ward/rules.csv b/src/test/resources/ward/rules.csv new file mode 100644 index 0000000..35cc39d --- /dev/null +++ b/src/test/resources/ward/rules.csv @@ -0,0 +1,5 @@ +key,value +maxShiftsPerPerson,5 +maxConsecutiveDays,4 +minRestHours,12 +hardPenalty,1000000 diff --git a/src/test/resources/ward/shifts.csv b/src/test/resources/ward/shifts.csv new file mode 100644 index 0000000..4203c3b --- /dev/null +++ b/src/test/resources/ward/shifts.csv @@ -0,0 +1,4 @@ +id,name,start,end,skill,count +D,Day Care,07:00,19:00,C.A.,2 +E,Clinical Shift,15:00,03:00,R.N.,1 +N,Night Duty,21:00,09:00,*,1 diff --git a/src/test/resources/ward/staff.csv b/src/test/resources/ward/staff.csv new file mode 100644 index 0000000..3ab7bea --- /dev/null +++ b/src/test/resources/ward/staff.csv @@ -0,0 +1,7 @@ +id,name,skill +W1,Maria Costa,R.N. +W2,Yuki Tanaka,R.N. +W3,Omar Diallo,C.A. +W4,Priya Nair,C.A. +W5,Tom Walker,R.N.;C.A. +W6,Ana Reyes,R.N.;C.A. diff --git a/src/test/scala/rotaforge/InputLoaderSuite.scala b/src/test/scala/rotaforge/InputLoaderSuite.scala index e5e9e08..2028b58 100644 --- a/src/test/scala/rotaforge/InputLoaderSuite.scala +++ b/src/test/scala/rotaforge/InputLoaderSuite.scala @@ -14,7 +14,6 @@ class InputLoaderSuite extends munit.FunSuite { private val staffCsv = "id,name,skill\nN1,Ana Silva,R.N.\nN2,Boris Chen,R.N.\n" - private val daysCsv = "date\n2026-05-04\n2026-05-05\n2026-05-06\n" @@ -88,4 +87,15 @@ class InputLoaderSuite extends munit.FunSuite { } assert(error.getMessage.contains("staff.csv")) } + + test("a semicolon skill list loads as several skills") { + val dir = Files.createTempDirectory("rotaforge-multiskill") + write(dir, "staff.csv", "id,name,skill\nN1,Ana Silva,R.N.\nN2,Bo Lin,R.N.;L.P.N.\n") + write(dir, "days.csv", daysCsv) + write(dir, "shifts.csv", shiftsCsv) + + val index = new rotaforge.core.RosterIndex(InputLoader.load(dir)) + assertEquals(index.staffSkills(index.staffIdx("N2")), Set("R.N.", "L.P.N.")) + assertEquals(index.staffSkills(index.staffIdx("N1")), Set("R.N.")) + } } diff --git a/src/test/scala/rotaforge/ScorerSuite.scala b/src/test/scala/rotaforge/ScorerSuite.scala index c0fc5b2..801ee7a 100644 --- a/src/test/scala/rotaforge/ScorerSuite.scala +++ b/src/test/scala/rotaforge/ScorerSuite.scala @@ -28,6 +28,37 @@ class ScorerSuite extends munit.FunSuite { assert(result.hardViolations > 0) } + private def dualRoster: rotaforge.model.Roster = + TestRosters.roster( + extraStaff = Vector( + rotaforge.model.Staff("E", "Elena", "R.N.;L.P.N."), + rotaforge.model.Staff("F", "Feng", "L.P.N.") + ), + extraShifts = Vector( + rotaforge.model.ShiftDef("A", "Care Assist", 8 * 60, 20 * 60, "L.P.N.", 1) + ) + ) + + test("a multi-skilled staff member fills shifts that need either skill") { + val roster = dualRoster + val grid = TestRosters.grid( + roster, + Map( + "A" -> Map(d0 -> "D"), + "E" -> Map(d1 -> "D", d2 -> "A") + ) + ) + val result = score(grid, roster) + assertEquals(result.byKey("skill-match").get.violations, 0) + } + + test("a staff member without the skill still violates on a multi-skill shift") { + val roster = dualRoster + val grid = TestRosters.grid(roster, Map("F" -> Map(d0 -> "D"))) + val result = score(grid, roster) + assertEquals(result.byKey("skill-match").get.violations, 1) + } + test("a zero-weight preference blocks an assignment") { val roster = TestRosters.roster(Vector(Preference("A", d0, "D", 0))) val grid = TestRosters.grid(roster, Map("A" -> Map(d0 -> "D"))) diff --git a/src/test/scala/rotaforge/SkillsSuite.scala b/src/test/scala/rotaforge/SkillsSuite.scala new file mode 100644 index 0000000..f0ed7c0 --- /dev/null +++ b/src/test/scala/rotaforge/SkillsSuite.scala @@ -0,0 +1,43 @@ +package rotaforge + +import rotaforge.model.Skills + +class SkillsSuite extends munit.FunSuite { + + test("parse keeps a single skill") { + assertEquals(Skills.parse("R.N."), Set("R.N.")) + } + + test("parse splits a semicolon list") { + assertEquals(Skills.parse("R.N.;C.A."), Set("R.N.", "C.A.")) + } + + test("parse trims surrounding spaces") { + assertEquals(Skills.parse(" R.N. ; C.A. "), Set("R.N.", "C.A.")) + } + + test("parse ignores empty entries") { + assertEquals(Skills.parse("R.N.;;C.A."), Set("R.N.", "C.A.")) + } + + test("matches accepts the exact skill") { + assert(Skills.matches(Set("R.N."), Set("R.N."))) + } + + test("matches accepts when the staff hold one of several required skills") { + assert(Skills.matches(Set("R.N.", "C.A."), Set("C.A."))) + } + + test("matches rejects a staff member without the skill") { + assert(!Skills.matches(Set("R.N."), Set("C.A."))) + } + + test("matches rejects a staff member with no matching skill") { + assert(!Skills.matches(Set("R.N.", "C.A."), Set("P.T."))) + } + + test("the wildcard shift accepts any staff member") { + assert(Skills.matches(Set("*"), Set("R.N."))) + assert(Skills.matches(Set("*"), Set("C.A."))) + } +} diff --git a/src/test/scala/rotaforge/TestRosters.scala b/src/test/scala/rotaforge/TestRosters.scala index 1805c99..c5434eb 100644 --- a/src/test/scala/rotaforge/TestRosters.scala +++ b/src/test/scala/rotaforge/TestRosters.scala @@ -41,9 +41,11 @@ object TestRosters { /** Build a roster from the shared fixture, adding preferences. */ def roster( preferences: Vector[rotaforge.model.Preference] = Vector.empty, - rulesOverride: Rules = rules + rulesOverride: Rules = rules, + extraStaff: Vector[Staff] = Vector.empty, + extraShifts: Vector[ShiftDef] = Vector.empty ): Roster = - Roster(days, staff, shifts, preferences, rulesOverride) + Roster(days, staff ++ extraStaff, shifts ++ extraShifts, preferences, rulesOverride) def index(roster: Roster): RosterIndex = new RosterIndex(roster) diff --git a/src/test/scala/rotaforge/WardSuite.scala b/src/test/scala/rotaforge/WardSuite.scala new file mode 100644 index 0000000..47ba946 --- /dev/null +++ b/src/test/scala/rotaforge/WardSuite.scala @@ -0,0 +1,106 @@ +package rotaforge + +import java.net.URL +import java.nio.file.Path +import java.nio.file.Paths +import rotaforge.core.Annealer +import rotaforge.core.Grid +import rotaforge.core.InitialRoster +import rotaforge.core.RosterIndex +import rotaforge.io.InputLoader +import rotaforge.score.Scorer +import scala.util.Random + +class WardSuite extends munit.FunSuite { + + private def wardDir: Path = { + val resource: URL = getClass.getClassLoader.getResource("ward") + require(resource != null, "ward fixtures not found on the test classpath") + Paths.get(resource.toURI) + } + + private def setup(): (RosterIndex, Scorer) = { + val roster = InputLoader.load(wardDir) + val index = new RosterIndex(roster) + index.requireValid() + (index, new Scorer(index)) + } + + private def solve(index: RosterIndex, scorer: Scorer, seed: Long, iterations: Int): Grid = { + val initial = InitialRoster.build(index) + val rng = new Random(seed) + new Annealer(index, scorer).run(initial, iterations, 200.0, 0.5, rng) + } + + private def shiftIsFilledBy(index: RosterIndex, grid: Grid, shiftIdx: Int): Vector[Int] = { + val staff = Vector.newBuilder[Int] + var day = 0 + while (day < index.nDays) { + var st = 0 + while (st < index.nStaff) { + if (grid.duty(st)(day) == shiftIdx) staff += st + st += 1 + } + day += 1 + } + staff.result() + } + + test("the ward example loads multi-skilled staff") { + val (index, _) = setup() + assertEquals(index.staffSkills(index.staffIdx("W5")), Set("R.N.", "C.A.")) + assertEquals(index.staffSkills(index.staffIdx("W6")), Set("R.N.", "C.A.")) + } + + test("the greedy initial roster satisfies every hard rule") { + val (index, scorer) = setup() + val grid = InitialRoster.build(index) + val breakdown = scorer.score(grid) + assertEquals(breakdown.hardViolations, 0) + assertEquals(breakdown.byKey("coverage").get.violations, 0) + } + + test("every care shift is filled by staff with the care skill") { + val (index, scorer) = setup() + val grid = solve(index, scorer, seed = 11L, iterations = 40000) + assertEquals(scorer.score(grid).hardViolations, 0) + + val careIdx = index.shiftIdx("D") + val staff = shiftIsFilledBy(index, grid, careIdx) + assert(staff.nonEmpty, "the care shift must have assignments") + staff.foreach { st => + assert(index.staffSkills(st).contains("C.A."), s"staff ${index.staffByIndex(st)} lacks the care skill") + } + } + + test("every clinical shift is filled by staff with the nursing skill") { + val (index, scorer) = setup() + val grid = solve(index, scorer, seed = 11L, iterations = 40000) + assertEquals(scorer.score(grid).hardViolations, 0) + + val clinicalIdx = index.shiftIdx("E") + val staff = shiftIsFilledBy(index, grid, clinicalIdx) + assert(staff.nonEmpty, "the clinical shift must have assignments") + staff.foreach { st => + assert(index.staffSkills(st).contains("R.N."), s"staff ${index.staffByIndex(st)} lacks the nursing skill") + } + } + + test("the multi-skilled staff cover both roles") { + val (index, scorer) = setup() + val grid = solve(index, scorer, seed = 11L, iterations = 40000) + + val dual = Set(index.staffIdx("W5"), index.staffIdx("W6")) + val careAssigned = shiftIsFilledBy(index, grid, index.shiftIdx("D")).toSet + val clinicalAssigned = shiftIsFilledBy(index, grid, index.shiftIdx("E")).toSet + assert(careAssigned.exists(dual.contains), "a multi-skilled member should help on care shifts") + assert(clinicalAssigned.exists(dual.contains), "a multi-skilled member should help on clinical shifts") + } + + test("the result is deterministic for a fixed seed") { + val (index, scorer) = setup() + val first = solve(index, scorer, seed = 3L, iterations = 20000) + val second = solve(index, scorer, seed = 3L, iterations = 20000) + assertEquals(scorer.score(first).total, scorer.score(second).total) + } +}