3 Commits

Author SHA1 Message Date
5d0a2d0cf4 docs: add godoc comments, expand README, add unit tests for GetDuration and ValidTimePeriods 2026-04-06 10:09:26 +00:00
34a9fe93f6 Merge pull request #4 from taigrr/cd/lint-fixes
fix(msp): resolve staticcheck warning and fix typo
2026-03-05 03:24:13 -05:00
523d062b04 fix(msp): resolve unused error value and fix typo
- Fix SA4006 staticcheck warning: unused err from GetDuration
- Fix typo: 'addtion' -> 'addition' in comment
2026-03-05 08:01:11 +00:00
7 changed files with 275 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
Copyright (C) 2021-2022 by Tai Groot <tai@taigrr.com>
Copyright (C) 2021-2026 by Tai Groot <tai@taigrr.com>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.

109
README.md
View File

@@ -1,21 +1,104 @@
# Most Specific Period
An MSP, or Most Specific Period is defined as follows:
- Given a period that hasnt started or has already finished, an error is returned.
- Given a period that is currently valid, the period which is valid is chosen.
- Given two valid periods, one which is a month long and one which is a week long and completely overlap, the week-long period is chosen.
- Given two valid periods, one which is a week long and one which is a month long, but only overlap by a day (in either direction) the week-long period is selected.
- Given two valid periods, each exactly 30 days long, but offset by a week, the second (newer) period is given precedence.
- Given two valid periods with exact same start and end time, the period with the lowest-ranking lexicographic identifier is returned (i.e. period B has precedence over period A, as B comes after A lexicographically.) This is because the default behavior is to choose the newer period, and named periods are often in lexicographical order (increasing numbers, letters, etc.)
[![Go Reference](https://pkg.go.dev/badge/github.com/taigrr/most-specific-period.svg)](https://pkg.go.dev/github.com/taigrr/most-specific-period)
[![Go Report Card](https://goreportcard.com/badge/github.com/taigrr/most-specific-period)](https://goreportcard.com/report/github.com/taigrr/most-specific-period)
This library operates off Period interfaces, which contain the following:
A Go library for selecting the narrowest time period containing a given
timestamp from a set of potentially overlapping periods.
## Installation
```bash
go get github.com/taigrr/most-specific-period
```
type Period interface {
GetStartTime() time.Time // Inclusive start time
GetEndTime() time.Time // Exclusive end time ("expiration time")
GetIdentifier() string
## What is a Most Specific Period?
Given overlapping time periods, the MSP algorithm picks the most precise one:
- Given a single valid period containing the timestamp, that period is chosen.
- Given two overlapping periods of different lengths, the shorter one wins.
- Given two periods of equal length, the one that started more recently wins.
- Given two periods with the same duration and start time, the lexicographically
last identifier wins (e.g. "B" over "A").
- Periods that haven't started yet or have already ended are ignored.
## Usage
Implement the `Period` interface or use the built-in `TimeWindow`:
```go
package main
import (
"fmt"
"time"
"github.com/taigrr/most-specific-period/msp"
)
func main() {
now := time.Now()
periods := []msp.Period{
msp.TimeWindow{
StartTime: now.Add(-24 * time.Hour),
EndTime: now.Add(24 * time.Hour),
Identifier: "this-week",
},
msp.TimeWindow{
StartTime: now.Add(-1 * time.Hour),
EndTime: now.Add(1 * time.Hour),
Identifier: "this-morning",
},
}
id, err := msp.MostSpecificPeriod(now, periods...)
if err != nil {
fmt.Println("No matching period")
return
}
fmt.Printf("MSP: %s\n", id) // "this-morning"
}
```
An example program is available to observe the operation and usage of this library.
### Period Interface
```go
type Period interface {
GetStartTime() time.Time // Inclusive start time
GetEndTime() time.Time // Exclusive end time
GetIdentifier() string
}
```
### Additional Functions
- `GenerateTimeline(periods...)` — Flatten overlapping periods into a
non-overlapping timeline.
- `GetChangeOvers(periods...)` — Get timestamps where the MSP changes.
- `GetNextChangeOver(t, periods...)` — Get the next changeover after time `t`.
- `FlattenPeriods(periods...)` — Get ordered identifiers at each changeover.
- `ValidTimePeriods(ts, periods...)` — Filter periods valid at timestamp `ts`.
- `GetDuration(start, end)` — Calculate duration between two times.
## CLI
A demo CLI is included. It reads periods from stdin (one per three lines:
identifier, start time, end time in RFC 3339) and displays the timeline
and MSP.
```bash
go run . -d 2024-06-15T12:00:00Z <<EOF
summer
2024-06-01T00:00:00Z
2024-09-01T00:00:00Z
june
2024-06-01T00:00:00Z
2024-07-01T00:00:00Z
EOF
```
## License
0BSD — See [LICENSE](LICENSE) for details.

View File

@@ -5,6 +5,8 @@ import (
"time"
)
// GetChangeOvers returns the sorted list of timestamps where the most
// specific period changes from one identifier to another.
func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
timeStamps := []time.Time{}
for _, x := range periods {
@@ -37,6 +39,8 @@ func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
return
}
// GetNextChangeOver returns the first changeover timestamp strictly after t.
// If no such changeover exists, ErrNoNextChangeover is returned.
func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) {
changeOvers := GetChangeOvers(periods...)
for _, ts := range changeOvers {
@@ -47,6 +51,8 @@ func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error)
return time.Time{}, ErrNoNextChangeover
}
// FlattenPeriods returns an ordered list of period identifiers representing
// the most specific period at each changeover point.
func FlattenPeriods(periods ...Period) (ids []string) {
changeovers := GetChangeOvers(periods...)
for _, c := range changeovers {

View File

@@ -5,6 +5,10 @@ import (
"time"
)
// MostSpecificPeriod returns the identifier of the shortest-duration period
// that contains timestamp ts. When multiple periods share the shortest
// duration, the one with the latest start time wins; if start times also
// match, the lexicographically last identifier is returned.
func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error) {
// Filter to get only valid periods here
periods = ValidTimePeriods(ts, periods...)
@@ -12,7 +16,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
return "", ErrNoValidPeriods
}
// find the shortest duration
d, err := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime())
d, _ := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime())
for _, x := range periods {
p, err := GetDuration(x.GetStartTime(), x.GetEndTime())
if err == nil && p < d {
@@ -34,7 +38,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
newest = x.GetStartTime()
}
}
// Determine whichever of these periods have the same start time in addtion to duration
// Determine whichever of these periods have the same start time in addition to duration
var matchingDurationsAndStartTimes []Period
for _, x := range matchingDurations {
if x.GetStartTime() == newest {
@@ -50,6 +54,8 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
return identifiers[len(identifiers)-1], nil
}
// GetDuration returns the duration between start and end. If start is after
// end, ErrEndAfterStart is returned alongside the (negative) duration.
func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error) {
if start.After(end) {
err = ErrEndAfterStart
@@ -58,6 +64,8 @@ func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error)
return dur, err
}
// ValidTimePeriods filters periods to those whose start time is at or before
// ts and whose end time is strictly after ts.
func ValidTimePeriods(ts time.Time, periods ...Period) []Period {
var valid []Period
for _, p := range periods {

View File

@@ -5,7 +5,157 @@ import (
"time"
)
// (periods ...Period) (id string, err error) {
func TestGetDuration(t *testing.T) {
now := time.Now()
testCases := []struct {
testID string
start time.Time
end time.Time
dur time.Duration
err error
}{
{
testID: "Normal duration",
start: now,
end: now.Add(5 * time.Minute),
dur: 5 * time.Minute,
err: nil,
},
{
testID: "Zero duration",
start: now,
end: now,
dur: 0,
err: nil,
},
{
testID: "Start after end",
start: now.Add(5 * time.Minute),
end: now,
dur: -5 * time.Minute,
err: ErrEndAfterStart,
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
dur, err := GetDuration(tc.start, tc.end)
if dur != tc.dur {
t.Errorf("Duration %v does not match expected %v", dur, tc.dur)
}
if err != tc.err {
t.Errorf("Error %v does not match expected %v", err, tc.err)
}
})
}
}
func TestValidTimePeriods(t *testing.T) {
now := time.Now()
testCases := []struct {
testID string
ts time.Time
periods []Period
count int
}{
{
testID: "No periods",
ts: now,
periods: []Period{},
count: 0,
},
{
testID: "One valid period",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
},
count: 1,
},
{
testID: "Period in the past",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
},
count: 0,
},
{
testID: "Period in the future",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(2 * time.Minute),
Identifier: "A",
},
},
count: 0,
},
{
testID: "Timestamp equals start time (inclusive)",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now,
EndTime: now.Add(time.Minute),
Identifier: "A",
},
},
count: 1,
},
{
testID: "Timestamp equals end time (exclusive)",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now,
Identifier: "A",
},
},
count: 0,
},
{
testID: "Mixed valid and invalid",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-3 * time.Minute),
EndTime: now.Add(-2 * time.Minute),
Identifier: "B",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(5 * time.Minute),
Identifier: "C",
},
},
count: 2,
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
valid := ValidTimePeriods(tc.ts, tc.periods...)
if len(valid) != tc.count {
t.Errorf("Got %d valid periods, expected %d", len(valid), tc.count)
}
})
}
}
func TestMostSpecificPeriod(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now()

View File

@@ -5,24 +5,29 @@ import (
"time"
)
// TimeWindow is a concrete implementation of the Period interface.
type TimeWindow struct {
StartTime time.Time
EndTime time.Time
Identifier string
}
// GetIdentifier returns the period's identifier string.
func (p TimeWindow) GetIdentifier() string {
return p.Identifier
}
// GetEndTime returns the period's exclusive end time.
func (p TimeWindow) GetEndTime() time.Time {
return p.EndTime
}
// GetStartTime returns the period's inclusive start time.
func (p TimeWindow) GetStartTime() time.Time {
return p.StartTime
}
// String returns a tab-separated representation of the time window.
func (t TimeWindow) String() string {
return fmt.Sprintf("%s\t%s\t%s",
t.GetIdentifier(),
@@ -30,7 +35,8 @@ func (t TimeWindow) String() string {
t.GetEndTime())
}
// Outputs a formatted timeline of periods
// GenerateTimeline produces a flattened timeline of non-overlapping periods
// by splitting overlapping input periods at changeover points.
func GenerateTimeline(periods ...Period) (out []Period) {
if len(periods) == 0 {
return out

View File

@@ -1,3 +1,6 @@
// Package msp implements the Most Specific Period algorithm, which selects
// the narrowest (shortest-duration) time period containing a given timestamp
// from a set of potentially overlapping periods.
package msp
import "time"
@@ -5,6 +8,7 @@ import "time"
// Compile-time interface check.
var _ Period = TimeWindow{}
// Period represents a named time window with inclusive start and exclusive end.
type Period interface {
GetStartTime() time.Time
GetEndTime() time.Time