Skip to content

Commit

Permalink
Merge pull request #5759 from espoon-voltti/dateset-binary-search
Browse files Browse the repository at this point in the history
Parannetaan RangeBasedSet-tietorakenteiden suorituskykyä binäärihaulla
  • Loading branch information
Gekkio authored Oct 9, 2024
2 parents 655e87d + 0eb97ce commit 0edbf97
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 27 deletions.
1 change: 1 addition & 0 deletions service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ dependencies {
// JUnit
testImplementation("org.junit.jupiter:junit-jupiter")

testImplementation("io.kotest:kotest-property")
testImplementation("net.bytebuddy:byte-buddy")
testImplementation("net.logstash.logback:logstash-logback-encoder")
testImplementation("org.jetbrains:annotations")
Expand Down
1 change: 1 addition & 0 deletions service/evaka-bom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
api("com.networknt:json-schema-validator:1.5.0")
api("com.zaxxer:HikariCP:6.0.0")
api("io.github.microutils:kotlin-logging-jvm:3.0.5")
api("io.kotest:kotest-property:5.9.1")
api("jakarta.annotation:jakarta.annotation-api:3.0.0")
api("jakarta.jws:jakarta.jws-api:3.0.0")
api("jakarta.xml.ws:jakarta.xml.ws-api:4.0.0")
Expand Down
2 changes: 2 additions & 0 deletions service/src/main/kotlin/fi/espoo/evaka/shared/data/DateSet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class DateSet private constructor(ranges: List<FiniteDateRange>) :
override fun range(start: LocalDate, end: LocalDate): FiniteDateRange =
FiniteDateRange(start, end)

override fun range(point: LocalDate): FiniteDateRange = FiniteDateRange(point, point)

override fun equals(other: Any?): Boolean = other is DateSet && this.ranges == other.ranges

override fun hashCode(): Int = Objects.hash(ranges)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class DateTimeSet private constructor(ranges: List<HelsinkiDateTimeRange>) :
override fun range(start: HelsinkiDateTime, end: HelsinkiDateTime): HelsinkiDateTimeRange =
HelsinkiDateTimeRange(start, end)

override fun range(point: HelsinkiDateTime): HelsinkiDateTimeRange =
HelsinkiDateTimeRange(point, point.plusSeconds(1))

override fun equals(other: Any?): Boolean = other is DateTimeSet && this.ranges == other.ranges

override fun hashCode(): Int = Objects.hash(ranges)
Expand Down
141 changes: 114 additions & 27 deletions service/src/main/kotlin/fi/espoo/evaka/shared/data/RangeBasedSet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ abstract class RangeBasedSet<
* range.
*/
fun intersectRanges(range: Range): Sequence<Range> =
ranges
partition(this.ranges, range, adjacentBelongToCenter = false)
.center
.asSequence()
.dropWhile { !it.overlaps(range) }
.takeWhile { it.overlaps(range) }
.mapNotNull { it.intersection(range) }

/**
Expand Down Expand Up @@ -114,10 +113,13 @@ abstract class RangeBasedSet<
operator fun minus(ranges: Sequence<Range>): This = removeAll(ranges)

/** Returns true if the given range is fully contained by the set. */
fun contains(range: Range): Boolean = this.ranges.any { it.contains(range) }
fun contains(range: Range): Boolean =
partition(this.ranges, range, adjacentBelongToCenter = false).center.any {
it.contains(range)
}

/** Returns true if any of the ranges includes the given point. */
fun includes(date: Point): Boolean = this.ranges.any { it.includes(date) }
fun includes(point: Point): Boolean = contains(range(point))

/**
* Returns a new set representing the intersections of currently contained ranges and ranges in
Expand Down Expand Up @@ -146,7 +148,93 @@ abstract class RangeBasedSet<
/** Constructs a range from endpoints. */
protected abstract fun range(start: Point, end: Point): Range

/** Constructs the smallest range that includes the given point. */
protected abstract fun range(point: Point): Range

companion object {
/**
* Returns a view into the original list.
*
* This is sometimes better than the Kotlin standard subList(IntRange) which copies all the
* data instead of returning a view
*/
private fun <T> List<T>.sliceView(range: IntRange): List<T> =
if (range.isEmpty()) emptyList() else subList(range.first, range.last + 1)

private data class Partition<T>(val left: List<T>, val center: List<T>, val right: List<T>)

/**
* Partitions a sorted list of ranges into left/center/right list views depending on
* adjacentBelongToCenter.
*
* If adjacentBelongToCenter is true, adjacent ranges are included in the center list but
* not left/right. If adjacentBelongToCenter is false, adjacent ranges are included in the
* corresponding left/right list but not center.
*
* The returned partitioning contains *list views*, which are views into the original list
* instead of full copies. Warning: if a mutable list is used, mutations to the original are
* reflected in the returned views and may violate the partitioning.
*
* Examples:
*
* ranges={[1, 3], [5, 6], [9, 10]}, range=[4,7], adjacentBelongToCenter=true
* - left: {}
* - center: {[1, 3], [5, 6]}
* - right: {[9, 10]}
*
* ranges={[1, 3], [5, 6], [9, 10]}, range=[4,7], adjacentBelongToCenter=false
* - left: {[1, 3]}
* - center: {[5, 6]}
* - right: {[9, 10]}
*/
private fun <Point : Comparable<Point>, Range : BoundedRange<Point, Range>> partition(
sortedList: List<Range>,
range: Range,
adjacentBelongToCenter: Boolean,
): Partition<Range> {
// Find the smallest index of the position that does *not* belong to the left list
val leftIdx =
sortedList
.binarySearch {
when (val relation = it.relationTo(range)) {
is BoundedRange.Relation.LeftTo ->
if (adjacentBelongToCenter && relation.gap == null) 1 else -1
else -> 1
}
}
// If an exact match is not found, the returned index is (-insertion point + 1)
// Our comparison function above never returns 0 so the returned index always
// has this format. See binarySearch docs for more details
.let { -(it + 1) }

// Find the smallest index of the position that belongs to the left list
val rightIdx =
sortedList
.binarySearch(fromIndex = leftIdx) {
when (val relation = it.relationTo(range)) {
is BoundedRange.Relation.RightTo ->
if (adjacentBelongToCenter && relation.gap == null) -1 else 1
else -> -1
}
}
// If an exact match is not found, the returned index is (-insertion point + 1).
// Our comparison function above never returns 0 so the returned index always
// has this format. See binarySearch docs for more details
.let { -(it + 1) }

val left = 0..<leftIdx
val center = leftIdx..<rightIdx
val right = rightIdx..<sortedList.size
if (!left.isEmpty() && !center.isEmpty()) require(left.last < center.first)
if (!center.isEmpty() && !right.isEmpty()) require(center.last < right.first)
if (!left.isEmpty() && !right.isEmpty()) require(left.last < right.first)
return Partition(
left = sortedList.sliceView(left),
center = sortedList.sliceView(center),
right = sortedList.sliceView(right),
)
}

/**
* Adds a range to a sorted list of non-overlapping ranges.
*
Expand All @@ -155,17 +243,18 @@ abstract class RangeBasedSet<
* in sorted order, and the returned list is also guaranteed to be sorted.
*/
fun <Point : Comparable<Point>, Range : BoundedRange<Point, Range>> add(
ranges: List<Range>,
sortedRanges: List<Range>,
range: Range,
): List<Range> =
ranges
.partition { it.overlaps(range) || it.adjacentTo(range) }
.let { (conflicts, unchanged) ->
val result = unchanged.toMutableList()
result.add(conflicts.fold(range) { acc, it -> acc.merge(it) })
result.sortBy { it.start }
result
}
): List<Range> {
val p = partition(sortedRanges, range, adjacentBelongToCenter = true)
val result = mutableListOf<Range>()
result += p.left
result +=
if (p.center.isNotEmpty()) range.merge(p.center.first()).merge(p.center.last())
else range
result += p.right
return result
}

/**
* Removes a range from a sorted non-overlapping list of ranges.
Expand All @@ -175,19 +264,17 @@ abstract class RangeBasedSet<
* the returned list is also guaranteed to be sorted.
*/
fun <Point : Comparable<Point>, Range : BoundedRange<Point, Range>> remove(
ranges: List<Range>,
sortedRanges: List<Range>,
range: Range,
): List<Range> =
ranges
.partition { it.overlaps(range) }
.let { (conflicts, unchanged) ->
val result = unchanged.toMutableList()
for (conflict in conflicts) {
result.addAll(conflict.subtract(range))
}
result.sortBy { it.start }
result
}
): List<Range> {
val p = partition(sortedRanges, range, adjacentBelongToCenter = false)
val result = mutableListOf<Range>()
result += p.left
if (p.center.isNotEmpty()) result += p.center.first() - range
if (p.center.size > 1) result += p.center.last() - range
result += p.right
return result
}

/**
* Calculates the intersection of two sorted iterators of non-overlapping ranges.
Expand Down
5 changes: 5 additions & 0 deletions service/src/main/kotlin/fi/espoo/evaka/shared/data/TimeSet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ class TimeSet private constructor(ranges: List<TimeRange>) :
override fun range(start: TimeRangeEndpoint, end: TimeRangeEndpoint): TimeRange =
TimeRange(start, end)

override fun range(point: TimeRangeEndpoint): TimeRange =
point.asStart().let { start ->
TimeRange(start, TimeRangeEndpoint.Start(start.inner.plusNanos(1)))
}

override fun equals(other: Any?): Boolean = other is TimeSet && this.ranges == other.ranges

override fun hashCode(): Int = Objects.hash(ranges)
Expand Down
53 changes: 53 additions & 0 deletions service/src/test/kotlin/fi/espoo/evaka/shared/ArbExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

package fi.espoo.evaka.shared

import fi.espoo.evaka.shared.data.DateSet
import fi.espoo.evaka.shared.data.DateTimeSet
import fi.espoo.evaka.shared.domain.FiniteDateRange
import fi.espoo.evaka.shared.domain.HelsinkiDateTime
import fi.espoo.evaka.shared.domain.HelsinkiDateTimeRange
import io.kotest.property.Arb
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.localDate
import io.kotest.property.arbitrary.long
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.positiveLong
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime

fun Arb.Companion.finiteDateRange(
start: Arb<LocalDate> =
Arb.localDate(minDate = LocalDate.of(2019, 1, 1), maxDate = LocalDate.of(2030, 12, 1)),
durationDays: Arb<Long> = Arb.positiveLong(max = 365_0L),
): Arb<FiniteDateRange> =
Arb.bind(start, durationDays) { startDate, days ->
FiniteDateRange(startDate, startDate.plusDays(days))
}

fun Arb.Companion.dateSet(): Arb<DateSet> =
Arb.list(Arb.finiteDateRange()).map { ranges -> DateSet.of(ranges) }

fun Arb.Companion.helsinkiDateTime(
min: HelsinkiDateTime = HelsinkiDateTime.of(LocalDateTime.of(2019, 1, 1, 12, 0)),
max: HelsinkiDateTime = HelsinkiDateTime.of(LocalDateTime.of(2030, 12, 31, 12, 0)),
): Arb<HelsinkiDateTime> =
Arb.long(min = 0, max = Duration.between(min.toInstant(), max.toInstant()).seconds).map {
secondsSinceMin ->
min.plusSeconds(secondsSinceMin)
}

fun Arb.Companion.helsinkiDateTimeRange(
start: Arb<HelsinkiDateTime> = Arb.helsinkiDateTime(),
durationSeconds: Arb<Long> = Arb.positiveLong(max = 48L * 60L * 60L),
): Arb<HelsinkiDateTimeRange> =
Arb.bind(start, durationSeconds) { startTimestamp, seconds ->
HelsinkiDateTimeRange(startTimestamp, startTimestamp.plusSeconds(seconds))
}

fun Arb.Companion.dateTimeSet(): Arb<DateTimeSet> =
Arb.list(Arb.helsinkiDateTimeRange()).map { ranges -> DateTimeSet.of(ranges) }
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

package fi.espoo.evaka.shared.data

import fi.espoo.evaka.shared.dateSet
import fi.espoo.evaka.shared.domain.FiniteDateRange
import fi.espoo.evaka.shared.finiteDateRange
import io.kotest.common.runBlocking
import io.kotest.property.Arb
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.positiveLong
import io.kotest.property.checkAll
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class DateSetPropertyTest : RangeBasedSetPropertyTest<LocalDate, FiniteDateRange, DateSet>() {
@Test
fun `it includes every date of every range added to it`() {
runBlocking {
checkAll(Arb.list(Arb.finiteDateRange(durationDays = Arb.positiveLong(max = 10)))) {
ranges ->
val set = emptySet().addAll(ranges)
assertTrue(set.ranges().flatMap { it.dates() }.all { set.includes(it) })
}
}
}

@Test
fun `when a range is removed, it no longer includes any points of the range`() {
runBlocking {
checkAll(
Arb.list(arbitraryRange()),
Arb.finiteDateRange(durationDays = Arb.positiveLong(max = 10L)),
) { otherRanges, range ->
val set = DateSet.of(range).addAll(otherRanges).remove(range)
assertFalse(range.dates().any { set.includes(it) })
}
}
}

override fun emptySet(): DateSet = DateSet.empty()

override fun arbitrarySet(): Arb<DateSet> = Arb.dateSet()

override fun arbitraryRange(): Arb<FiniteDateRange> = Arb.finiteDateRange()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

package fi.espoo.evaka.shared.data

import fi.espoo.evaka.shared.dateTimeSet
import fi.espoo.evaka.shared.domain.HelsinkiDateTime
import fi.espoo.evaka.shared.domain.HelsinkiDateTimeRange
import fi.espoo.evaka.shared.helsinkiDateTimeRange
import io.kotest.property.Arb

class DateTimeSetPropertyTest :
RangeBasedSetPropertyTest<HelsinkiDateTime, HelsinkiDateTimeRange, DateTimeSet>() {

override fun emptySet(): DateTimeSet = DateTimeSet.empty()

override fun arbitrarySet(): Arb<DateTimeSet> = Arb.dateTimeSet()

override fun arbitraryRange(): Arb<HelsinkiDateTimeRange> = Arb.helsinkiDateTimeRange()
}
Loading

0 comments on commit 0edbf97

Please sign in to comment.