4 Commits

Author SHA1 Message Date
4651fcf571 Merge pull request #5 from taigrr/cd/godoc-tests-readme
docs: add godoc comments, expand README, improve test coverage
2026-04-14 21:23:34 -04:00
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 Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted. purpose with or without fee is hereby granted.

109
README.md
View File

@@ -1,21 +1,104 @@
# Most Specific Period # Most Specific Period
An MSP, or Most Specific Period is defined as follows: [![Go Reference](https://pkg.go.dev/badge/github.com/taigrr/most-specific-period.svg)](https://pkg.go.dev/github.com/taigrr/most-specific-period)
- Given a period that hasnt started or has already finished, an error is returned. [![Go Report Card](https://goreportcard.com/badge/github.com/taigrr/most-specific-period)](https://goreportcard.com/report/github.com/taigrr/most-specific-period)
- 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.)
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 ## What is a Most Specific Period?
GetEndTime() time.Time // Exclusive end time ("expiration time")
GetIdentifier() string 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" "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) { func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
timeStamps := []time.Time{} timeStamps := []time.Time{}
for _, x := range periods { for _, x := range periods {
@@ -37,6 +39,8 @@ func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
return 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) { func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) {
changeOvers := GetChangeOvers(periods...) changeOvers := GetChangeOvers(periods...)
for _, ts := range changeOvers { for _, ts := range changeOvers {
@@ -47,6 +51,8 @@ func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error)
return time.Time{}, ErrNoNextChangeover 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) { func FlattenPeriods(periods ...Period) (ids []string) {
changeovers := GetChangeOvers(periods...) changeovers := GetChangeOvers(periods...)
for _, c := range changeovers { for _, c := range changeovers {

View File

@@ -5,6 +5,10 @@ import (
"time" "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) { func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error) {
// Filter to get only valid periods here // Filter to get only valid periods here
periods = ValidTimePeriods(ts, periods...) periods = ValidTimePeriods(ts, periods...)
@@ -12,7 +16,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
return "", ErrNoValidPeriods return "", ErrNoValidPeriods
} }
// find the shortest duration // 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 { for _, x := range periods {
p, err := GetDuration(x.GetStartTime(), x.GetEndTime()) p, err := GetDuration(x.GetStartTime(), x.GetEndTime())
if err == nil && p < d { if err == nil && p < d {
@@ -34,7 +38,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
newest = x.GetStartTime() 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 var matchingDurationsAndStartTimes []Period
for _, x := range matchingDurations { for _, x := range matchingDurations {
if x.GetStartTime() == newest { 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 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) { func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error) {
if start.After(end) { if start.After(end) {
err = ErrEndAfterStart err = ErrEndAfterStart
@@ -58,6 +64,8 @@ func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error)
return dur, err 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 { func ValidTimePeriods(ts time.Time, periods ...Period) []Period {
var valid []Period var valid []Period
for _, p := range periods { for _, p := range periods {

View File

@@ -5,7 +5,157 @@ import (
"time" "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) { func TestMostSpecificPeriod(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause // use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now() now := time.Now()

View File

@@ -5,24 +5,29 @@ import (
"time" "time"
) )
// TimeWindow is a concrete implementation of the Period interface.
type TimeWindow struct { type TimeWindow struct {
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
Identifier string Identifier string
} }
// GetIdentifier returns the period's identifier string.
func (p TimeWindow) GetIdentifier() string { func (p TimeWindow) GetIdentifier() string {
return p.Identifier return p.Identifier
} }
// GetEndTime returns the period's exclusive end time.
func (p TimeWindow) GetEndTime() time.Time { func (p TimeWindow) GetEndTime() time.Time {
return p.EndTime return p.EndTime
} }
// GetStartTime returns the period's inclusive start time.
func (p TimeWindow) GetStartTime() time.Time { func (p TimeWindow) GetStartTime() time.Time {
return p.StartTime return p.StartTime
} }
// String returns a tab-separated representation of the time window.
func (t TimeWindow) String() string { func (t TimeWindow) String() string {
return fmt.Sprintf("%s\t%s\t%s", return fmt.Sprintf("%s\t%s\t%s",
t.GetIdentifier(), t.GetIdentifier(),
@@ -30,7 +35,8 @@ func (t TimeWindow) String() string {
t.GetEndTime()) 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) { func GenerateTimeline(periods ...Period) (out []Period) {
if len(periods) == 0 { if len(periods) == 0 {
return out 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 package msp
import "time" import "time"
@@ -5,6 +8,7 @@ import "time"
// Compile-time interface check. // Compile-time interface check.
var _ Period = TimeWindow{} var _ Period = TimeWindow{}
// Period represents a named time window with inclusive start and exclusive end.
type Period interface { type Period interface {
GetStartTime() time.Time GetStartTime() time.Time
GetEndTime() time.Time GetEndTime() time.Time