Files
Naomi Kramer b923c39e2d Update configuration (#78)
* Update on config structure, functionality, and tests

Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com>

* Extend subnet type to read/write from db, update tests

Co-Authored-By: Liza Tsibur <liza@activecountermeasures.com>

* updated read file config test and subnet tests

* fixed config and util tests, updated subnet related functions

Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com>

* Remove error return from GetDefaultConfig

Co-Authored-By: Liza Tsibur <liza@activecountermeasures.com>

* added json tags to database struct

* Updated beacon weights validation for config

* updates to score thresholds validation tags

* changes to config subnet validation and testing

* Update subnet.go

* Write missing host entries to http to populate http_proto

* Updating some fields to uint64

* WIP update some field types

* Update zeek count types and fix tests

* Add clickhouse credentials

* Misc fixes

* Update pointer

* Add ability to mark datasets as sample datasets

* fix column name

* Fix datasets exiting import if hour is empty

* Fix zeek count parsing from TSV files

* Remove storing dns conns in arrays, Fix historical first seen dns lag

* Remove unused columns

* Update config.hjson

* Update config.hjson

* updated impact category score functions to use float64

Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com>

* Update subnet.go

* Store import version in imports table

* Fix duplicated SNI/IP long connections

* Update subnet_test.go

* Cleanup output

* Rolling files updates (#39)

* Limit number of days to import for rolling datasets

* Fix breaking imports when import was interrupted

* Remove debug output

---------

Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com>

* Omit parts of env from output

* Set max for threat intel datasize

* Remove SELinux neutering for QA

* Add network size column

* Fix http_proto for missing host, update tests for missing host fixes

* Add online feeds to default config

* Update sshprep (#45)

* Update sshprep

Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com>

* Update sshprep

Add Bradley's suggestion of using head -1 to limit to a single address.

---------

Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com>
Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com>

* Installer Behavior Tweaks (#41)

* Add --yes flag to add-apt-repository command

* Add missing sudo flags, make sure we're using the SUDO variable instead

* Add ability to perform zone transfers (#48)

* Store zone transfer records

Co-Authored-By: moth <25512187+0x6d6f7468@users.noreply.github.com>

* Update config

* Add tests

* Tests, connectivity test

* Update tests

---------

Co-authored-by: moth <25512187+0x6d6f7468@users.noreply.github.com>

* Support RedHat/RHEL as a valid target (#47)

* Update sshprep

Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com>

* Supporrt RedHat/RHEL as a valid target

---------

Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com>
Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com>
Co-authored-by: moth <moth@blackhillsinfosec.com>

* Fix tests (#49)

* Fix tests

* Update WalkFiles to use UTC

* fixed issue with rolling datasets over 24hours old not getting historical first seen timestamp set (#52)

* Change values from float32 to float64 (#50)

* Switch float32 to float64

* Update threat category calculation to match CalculateBucketedScore (#51)

---------

Co-authored-by: Liza Tsibur <liza@activecountermeasures.com>

* Bump max query execution time default value

* Use string instead of error for ZoneTransferConnectivityErrors struct fields (#61)

* Upgrade Golang to version 1.24 (#59) (#60)

* Replace get_url with shell and curl (#58)

* Update sshprep

Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com>

* Replace get_url with shell and curl

* Use get_url by default, fall back to curl if it fails

---------

Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com>
Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com>
Co-authored-by: moth <moth@blackhillsinfosec.com>

* add automated log transfer, AC-Hunter issue 135 (#62)

* Update sshprep

Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com>

* add automated log transfer, PR135

* cron requires non-executable permission

* Specify suggested YAML plugin and config in VSCode workspace

* Linting and light cleanup

* Update generate_installer.sh

Download zeek_log_transport.sh to send to the sensor.

* Create cron file if remote zeek installation

* Only run zeek log import steps for remote sensor installations

---------

Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com>
Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com>
Co-authored-by: moth <moth@blackhillsinfosec.com>

* Temporarily disable RITA/Zeek log transport until installer is modular (#66)

* Uniform -y flag usage for repo management/package installation; Uniform SUDO variable usage (#68)

* Resolve Installer Side Effects and Formalize RHEL Support (#73)

* Add missing necessary wildcards for RHEL versions

* Remove Ansible task replacing python3-requests to avoid RHEL distro installation side effects

* Update supported distros in README

* Update scoring defaults

* Resolve Ansible Reboot Errors (#75)

* Clean up conditionals; Fix reboot step for Ubuntu

* Suppress erroneous error output on RPM systems, ignore errors on reboot necessity checks

* Ignore missing host rows for openhttp (#76)

* Fix integration tests due to prevalence (#77)

---------

Co-authored-by: Liza Tsibur <liza@activecountermeasures.com>
Co-authored-by: moth <moth@blackhillsinfosec.com>
Co-authored-by: William Stearns <william.l.stearns@gmail.com>
Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com>
Co-authored-by: moth <25512187+0x6d6f7468@users.noreply.github.com>
2025-09-22 16:21:05 -04:00

2197 lines
81 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package analysis
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetBeaconScore(t *testing.T) {
// Define test cases
tests := []struct {
name string
tsScore float64
tsWeight float64
dsScore float64
dsWeight float64
durScore float64
durWeight float64
histScore float64
histWeight float64
expectedScore float64
expectedError bool
}{
{
name: "Valid Scores and Weights",
tsScore: 0.8,
tsWeight: 0.2,
dsScore: 0.7,
dsWeight: 0.2,
durScore: 0.6,
durWeight: 0.3,
histScore: 0.5,
histWeight: 0.3,
expectedScore: 0.63,
expectedError: false,
},
{
name: "All Scores 1, All Weights Equal",
tsScore: 1,
tsWeight: 0.25,
dsScore: 1,
dsWeight: 0.25,
durScore: 1,
durWeight: 0.25,
histScore: 1,
histWeight: 0.25,
expectedScore: 1,
expectedError: false,
},
{
name: "All Ccores 1, All Weights Different",
tsScore: 1,
tsWeight: 0.1,
dsScore: 1,
dsWeight: 0.2,
durScore: 1,
durWeight: 0.3,
histScore: 1,
histWeight: 0.4,
expectedScore: 1,
expectedError: false,
},
{
name: "All Scores Different, All Weights Equal",
tsScore: 0.1,
tsWeight: 0.25,
dsScore: 0.2,
dsWeight: 0.25,
durScore: 0.3,
durWeight: 0.25,
histScore: 0.4,
histWeight: 0.25,
expectedScore: 0.25,
expectedError: false,
},
{
name: "All Scores Different, All Weights Different",
tsScore: 0.1,
tsWeight: 0.1,
dsScore: 0.2,
dsWeight: 0.2,
durScore: 0.3,
durWeight: 0.3,
histScore: 0.4,
histWeight: 0.4,
// 0.1*0.1 + 0.2*0.2 + 0.3*0.3 + 0.4*0.4 = 0.01 + 0.04 + 0.09 + 0.16 = 0.30
// (0.30*1000)/1000 = 0.30
expectedScore: 0.30,
expectedError: false,
},
{
name: "High precision scores and weights",
tsScore: 0.111,
tsWeight: 0.173,
dsScore: 0.222,
dsWeight: 0.325,
durScore: 0.333,
durWeight: 0.299,
histScore: 0.444,
histWeight: 0.203, // histWeight = 1 - (0.173 + 0.325 + 0.299) = 0.203
// 0.111*0.173 + 0.222*0.325 + 0.333*0.299 + 0.444*0.203 = 0.019263 + 0.07215 + 0.099567 + 0.090132 = 0.281112
// (0.281112*1000)/1000 = 0.281
expectedScore: 0.281,
expectedError: false,
},
{ // to check for rounding errors
name: "Sensitivity test for closely-valued scores and weights",
tsScore: 0.1001,
tsWeight: 0.249,
dsScore: 0.1002,
dsWeight: 0.251,
durScore: 0.1003,
durWeight: 0.250,
histScore: 0.1004,
histWeight: 0.250,
expectedScore: 0.10025,
expectedError: false,
},
{
name: "Only one active weight",
tsScore: 0.25,
tsWeight: 1,
dsScore: 1,
dsWeight: 0,
durScore: 1,
durWeight: 0,
histScore: 1,
histWeight: 0,
expectedScore: 0.25,
expectedError: false,
},
{
name: "Negative score input",
tsScore: -0.1,
tsWeight: 0.25,
dsScore: 0.5,
dsWeight: 0.25,
durScore: 0.5,
durWeight: 0.25,
histScore: 0.5,
histWeight: 0.25,
expectedError: true,
},
{
name: "Score greater than 1",
tsScore: 1.1,
tsWeight: 0.25,
dsScore: 0.5,
dsWeight: 0.25,
durScore: 0.5,
durWeight: 0.25,
histScore: 0.5,
histWeight: 0.25,
expectedError: true,
},
{
name: "Negative weight input",
tsScore: 0.5,
tsWeight: -0.1,
dsScore: 0.5,
dsWeight: 0.25,
durScore: 0.5,
durWeight: 0.25,
histScore: 0.5,
histWeight: 0.25,
expectedError: true,
},
{
name: "Weight greater than 1",
tsScore: 0.5,
tsWeight: 1.1,
dsScore: 0.5,
dsWeight: 0.25,
durScore: 0.5,
durWeight: 0.25,
histScore: 0.5,
histWeight: 0.25,
expectedError: true,
},
{
name: "Weights sum more than 1",
tsScore: 0.5,
tsWeight: 0.3,
dsScore: 0.5,
dsWeight: 0.3,
durScore: 0.5,
durWeight: 0.3,
histScore: 0.5,
histWeight: 0.3,
expectedError: true,
},
{
name: "Valid weight but sum less than 1",
tsScore: 0.5,
tsWeight: 0.3,
dsScore: 0.5,
dsWeight: 0.3,
durScore: 0.5,
durWeight: 0.2,
histScore: 0.5,
histWeight: 0.1,
expectedError: true,
},
{
name: "Weights sum to more than 1",
tsScore: 0.5,
tsWeight: 0.3,
dsScore: 0.5,
dsWeight: 0.3,
durScore: 0.5,
durWeight: 0.3,
histScore: 0.5,
histWeight: 0.2,
expectedError: true,
},
}
// Run test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
score, err := getBeaconScore(test.tsScore, test.tsWeight, test.dsScore, test.dsWeight, test.durScore, test.durWeight, test.histScore, test.histWeight)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated score
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestGetTimestampScore(t *testing.T) {
tests := []struct {
name string
tsList []uint32
expectedScore float64
expectedSkew float64
expectedMAD float64
expectedUniqueIntervals []int64
expectedUniqueIntervalCounts []int64
expectedTSMode int64
expectedTSModeCount int64
expectedError bool
}{
{
name: "Simple Number List",
tsList: []uint32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
// intervals between timestamps: 1, 1, 1, 1, 1, 1, 1, 1, 1
expectedUniqueIntervals: []int64{1},
expectedUniqueIntervalCounts: []int64{9},
expectedTSMode: 1,
expectedTSModeCount: 9,
// q1 = 1, q2 = 1, q3 = 1
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 1)) = median(0, 1, 0, 1, 0, 1, 0, 1, 0) = 0
expectedMAD: 0, // MAD score = 1 - 0 = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Connection with Perfect Intervals",
// timestamps : 1517338924, 1517338924 + 60, 1517338924 + 120, 1517338924 + 180, 1517338924 + 240, 1517338924 + 300, 1517338924 + 360, 1517338924 + 420, 1517338924 + 480, 1517338924 + 540,
tsList: []uint32{1517338924, 1517338984, 1517339044, 1517339104, 1517339164, 1517339224, 1517339284, 1517339344, 1517339404, 1517339464},
// intervals between timestamps: 60, 60, 60, 60, 60, 60, 60, 60, 60
expectedUniqueIntervals: []int64{60},
expectedUniqueIntervalCounts: []int64{9},
expectedTSMode: 60,
expectedTSModeCount: 9,
// q1 = 60, q2 = 60, q3 = 60
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(60 - 60)) = 0
expectedMAD: 0, // MAD score = 1 - 0 = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Connection with Closely-Valued Intervals",
// timestamps : 1517338924, 1517338924 + 98, 1517338924 + 98 + 99, 1517338924 + 98 + 99 + 99, 1517338924 + 98 + 99 + 99 + 100, 1517338924 + 98 + 99 + 99 + 100 + 100, 1517338924 + 98 + 99 + 99 + 100 + 100 + 100, 1517338924 + 98 + 99 + 99 + 100 + 100 + 100 + 101, 1517338924 + 98 + 99 + 99 + 100 + 100 + 100 + 101 + 101, 1517338924 + 98 + 99 + 99 + 100 + 100 + 100 + 101 + 101 + 102,
tsList: []uint32{1517338924, 1517339022, 1517339121, 1517339220, 1517339320, 1517339420, 1517339520, 1517339621, 1517339722, 1517339824},
// intervals between timestamps: 98, 99, 99, 100, 100, 100, 101, 101, 102
expectedUniqueIntervals: []int64{98, 99, 100, 101, 102},
expectedUniqueIntervalCounts: []int64{1, 2, 3, 2, 1},
expectedTSMode: 100,
expectedTSModeCount: 3,
// q1 = 99, q2 = 100, q3 = 101
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 100)) = median(1, 0, 0, 1, 1, 1, 2, 2, 3) = 1
expectedMAD: 1, // MAD score = 1 - (mad/median) = 1 - 1/100 = 0.99
expectedScore: 0.995, // score = round(((1 + 0.99)/2)*1000)/1000 = 0.995
expectedError: false,
},
{
name: "Connection with Bi-Modal Intervals",
// timestamps : 1517338924, 1517338924 + 98, 1517338924 + 98 + 300, 1517338924 + 98 + 300 + 98, 1517338924 + 98 + 300 + 98 + 300, 1517338924 + 98 + 300 + 98 + 300 + 98, 1517338924 + 98 + 300 + 98 + 300 + 98 + 300, 1517338924 + 98 + 300 + 98 + 300 + 98 + 300 + 98, 1517338924 + 98 + 300 + 98 + 300 + 98 + 300 + 98 + 300, 1517338924 + 98 + 300 + 98 + 300 + 98 + 300 + 98 + 300 + 98,
tsList: []uint32{1517338924, 1517339022, 1517339322, 1517339420, 1517339720, 1517339818, 1517340118, 1517340216, 1517340516, 1517340614, 1517340914},
// intervals between timestamps: 98, 300, 98, 300, 98, 300, 98, 300, 98
expectedUniqueIntervals: []int64{98, 300},
expectedUniqueIntervalCounts: []int64{5, 5},
expectedTSMode: 98,
expectedTSModeCount: 5,
// Quartiles: {98 199 300}
// q1 = 98, q2 = 199, q3 = 300
// numerator = q3 + q1 - 2*q2 = 300 + 98 - 2*199 = 300 + 98 - 398 = 0
// IQR = q3 - q1 = 300 - 98 = 202
// Bowley Skewness = (Q3+Q1 2Q2) / (Q3 Q1) = numerator / IQR
// but if the denominator less than 10 or the median is equal to the lower or upper quartile, the skewness is zero
// skewness = 0
// skewness score = 1 - skewness = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 199)) = median(101, 100, 101, 100, 101, 100, 101, 100, 101) = 101
// MAD score = 1 - (mad/median) = 1 - 101/199 = 0.492
// score = round(((1 + 0.492)/2)*1000)/1000 = 0.746
expectedSkew: 0,
expectedMAD: 101,
expectedScore: 0.746,
expectedError: false,
},
{
name: "Connection with Random Intervals",
tsList: []uint32{1517338924, 1517338925, 1517339224, 1517339249, 1517344224, 1517344314, 1517344316, 1517344358, 1517344858, 1517346358},
// intervals between timestamps: 1, 299, 25, 4975, 90, 2, 42, 500, 1500
expectedUniqueIntervals: []int64{1, 2, 25, 42, 90, 299, 500, 1500, 4975},
expectedUniqueIntervalCounts: []int64{1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedTSMode: 1,
expectedTSModeCount: 1,
// q1 = 13.5, q2 = 90, q3 = 1000
// numerator = q3 + q1 - 2*q2 = 1000 + 13.5 - 2*90 = 1000 + 13.5 - 180 = 833.5
// IQR = q3 - q1 = 1000 - 13.5 = 986.5
// skewness = numerator / IQR = 833.5 / 986.5 = 0.845
// skewness score = 1 - skewness = 1 - 0.845 = 0.155
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 90)) = median(89, 209, 65, 4885, 0, 8, 48, 410, 1410) = 89
// score = round(((0.155 + 0.01111)/2)*1000)/1000 = 0.083
expectedSkew: 0.845, // skewness score = 1 - 0.845 = 0.155
expectedMAD: 89, // MAD score = 1 - (mad/median) = 1 - 89/90 = 0.01111
expectedScore: 0.083,
expectedError: false,
},
{
// should not happen in practice, since we query for connections with > 3 unique timestamps
name: "Connection with < 3 Non-Zero Intervals",
tsList: []uint32{60, 60, 60, 60, 60, 60, 60, 60, 60},
// intervals between timestamps: 0, 0, 0, 0, 0, 0, 0, 0
expectedError: true,
},
{
name: "Length of Timestamp List < 4",
tsList: []uint32{1517338924, 1517338925},
expectedError: true,
},
{
name: "Empty Input Slice",
tsList: []uint32{},
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
score, skew, mad, intervals, intervalCounts, mode, modeCount, err := getTimestampScore(test.tsList)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated debug values
require.EqualValues(test.expectedUniqueIntervals, intervals, "Expected unique intervals to be %v, got %v", test.expectedUniqueIntervals, intervals)
require.EqualValues(test.expectedUniqueIntervalCounts, intervalCounts, "Expected unique interval counts to be %v, got %v", test.expectedUniqueIntervalCounts, intervalCounts)
require.EqualValues(test.expectedTSMode, mode, "Expected mode to be %v, got %v", test.expectedTSMode, mode)
require.EqualValues(test.expectedTSModeCount, modeCount, "Expected mode count to be %v, got %v", test.expectedTSModeCount, modeCount)
// check the calculated score values
require.InDelta(test.expectedSkew, skew, 0.001, "Expected skew to be %v, got %v", test.expectedSkew, skew)
require.InDelta(test.expectedMAD, mad, 0.001, "Expected MAD to be %v, got %v", test.expectedMAD, mad)
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestGetDataSizeScore(t *testing.T) {
tests := []struct {
name string
bytesList []float64
expectedScore float64
expectedSkew float64
expectedMAD float64
expectedUniqueSizes []int64
expectedUniqueSizeCounts []int64
expectedDSMode int64
expectedDSModeCount int64
expectedRange int64
expectedError bool
}{
{
name: "Simple Number List",
bytesList: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedUniqueSizes: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedUniqueSizeCounts: []int64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedDSMode: 1,
expectedDSModeCount: 1,
expectedRange: 9, // 10 - 1 = 9
// q1 = 3, q2 = 5.5, q3 = 8
// numerator = q3 + q1 - 2*q2 = 8 + 3 - 2*5.5 = 8 + 3 - 11 = 0
// IQR = q3 - q1 = 8 - 3 = 5
// skewness = numerator / IQR = 0 / 5 = 0
expectedSkew: 0, // skewness score = 1 - skewness = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 5.5)) = median(4.5, 3.5, 2.5, 1.5, 0.5, 0.5, 1.5, 2.5, 3.5, 4.5) = 2.5
expectedMAD: 2.5, // MAD score = 1 - (mad/median) = 1 - 2.5/5.5 = 0.545454
expectedScore: 0.773,
expectedError: false,
},
{
name: "Connection with Identical Sizes",
bytesList: []float64{60, 60, 60, 60, 60, 60, 60, 60, 60},
expectedUniqueSizes: []int64{60},
expectedUniqueSizeCounts: []int64{9},
expectedDSMode: 60,
expectedDSModeCount: 9,
expectedRange: 0, // 60 - 60 = 0
expectedSkew: 0, // skewness score = 1 - 0 = 1
expectedMAD: 0, // MAD score = 1 - 0 = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Connection with Closely-Valued Sizes",
bytesList: []float64{98, 99, 99, 100, 100, 100, 101, 101, 102},
expectedUniqueSizes: []int64{98, 99, 100, 101, 102},
expectedUniqueSizeCounts: []int64{1, 2, 3, 2, 1},
expectedDSMode: 100,
expectedDSModeCount: 3,
expectedSkew: 0, // skewness score = 1 - 0 = 1
expectedMAD: 1, // MAD score = 1 - (mad/median) = 1 - 1/100 = 0.99
expectedScore: 0.995,
expectedError: false,
},
{
name: "Connection with Random Sizes",
bytesList: []float64{524885, 1, 5000, 98654, 50, 41, 965842, 3},
expectedUniqueSizes: []int64{1, 3, 41, 50, 5000, 98654, 524885, 965842},
expectedUniqueSizeCounts: []int64{1, 1, 1, 1, 1, 1, 1, 1},
expectedDSMode: 1, // if there are multiple modes, the first one in list is chosen
expectedDSModeCount: 1,
// q1 = 22, q2 = 2525, q3 = 311769.5
// numerator = q3 + q1 - 2*q2 = 311769.5 + 22 - 2*2525 = 311769.5 + 22 - 5050 = 306741.5
// IQR = q3 - q1 = 311769.5 - 22 = 311747.5
// skewness = numerator / IQR = 306741.5 / 311747.5 = 0.984
// skewness score = 1 - skewness = 1 - 0.984 = 0.016
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 2525)) = median(2475, 2475, 2484, 2522, 2524, 96129, 522360, 963317) = 2522+2524/2 = 2523
expectedSkew: 0.984, // skewness score = 1 - 0.984 = 0.016
expectedMAD: 2523, // MAD score = 1 - (mad/median) = 1 - (2523/2525) = 0.000792079
expectedScore: 0.008, // score = round(((0.016+0.000792079)/2)*1000)/1000 = 0.008
expectedError: false,
},
{
name: "Bytes List is Comprised of Only 0s",
bytesList: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
expectedUniqueSizes: []int64{0},
expectedUniqueSizeCounts: []int64{10},
expectedDSMode: 0,
expectedDSModeCount: 10,
expectedSkew: 0, // skewness score = 1 - 0 = 1
expectedMAD: 0, // MAD score = 0, since median < 1 and defaultMADScore for datasize scoring is 0
expectedScore: 0.5, // score = round(((1 + 0)/2)*1000)/1000 = 0.5
expectedError: false,
},
// in practice we should have at least 4, since we require at least 4 connections to calculate the score
{
name: "Length of Bytes List < 3",
bytesList: []float64{100, 200},
expectedError: true,
},
{
name: "Empty Input Slice",
bytesList: []float64{},
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
score, skew, mad, sizes, sizeCounts, mode, modeCount, err := getDataSizeScore(test.bytesList)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated debug values
require.EqualValues(test.expectedUniqueSizes, sizes, "Expected unique sizes to be %v, got %v", test.expectedUniqueSizes, sizes)
require.EqualValues(test.expectedUniqueSizeCounts, sizeCounts, "Expected unique size counts to be %v, got %v", test.expectedUniqueSizeCounts, sizeCounts)
require.InDelta(test.expectedDSMode, mode, 0.001, "Expected mode to be %v, got %v", test.expectedDSMode, mode)
require.EqualValues(test.expectedDSModeCount, modeCount, "Expected mode count to be %v, got %v", test.expectedDSModeCount, modeCount)
// check the calculated score values
require.InDelta(test.expectedSkew, skew, 0.001, "Expected skew to be %v, got %v", test.expectedSkew, skew)
require.InDelta(test.expectedMAD, mad, 0.001, "Expected MAD to be %v, got %v", test.expectedMAD, mad)
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestCalculateStatisticalScore(t *testing.T) {
tests := []struct {
name string
values []float64
defaultMadScore float64
expectedScore float64
expectedSkew float64
expectedMAD float64
expectedError bool
}{
{
name: "Simple Number List",
values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
defaultMadScore: 0,
// q1 = 3, q2 = 5.5, q3 = 8
// numerator = q3 + q1 - 2*q2 = 8 + 3 - 2*5.5 = 8 + 3 - 11 = 0
// IQR = q3 - q1 = 8 - 3 = 5
// skewness = numerator / IQR = 0 / 5 = 0
// skewness score = 1 - skewness = 1 - 0 = 1
expectedSkew: 0, // skewness score = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 5.5)) = median(4.5, 3.5, 2.5, 1.5, 0.5, 0.5, 1.5, 2.5, 3.5, 4.5) = 2.5
expectedMAD: 2.5, // MAD score = 1 - (mad/median) = 1 - 2.5/5.5 = 0.545454
expectedScore: 0.773,
expectedError: false,
},
{
name: "Connection with Identical Intervals/Sizes",
values: []float64{60, 60, 60, 60, 60, 60, 60, 60, 60},
defaultMadScore: 0,
// q1 = 60, q2 = 60, q3 = 60
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(60 - 60)) = 0
expectedMAD: 0, // MAD score = 1 - 0 = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
},
{
name: "Connection with Closely-Valued Intervals/Sizes",
values: []float64{98, 99, 99, 100, 100, 100, 101, 101, 102},
// q1 = 99, q2 = 100, q3 = 101
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 100)) = median(1, 0, 0, 1, 1, 1, 2, 2, 3) = 1
expectedMAD: 1, // MAD score = 1 - (mad/median) = 1 - 1/100 = 0.99
// score = round(((1 + 0.99)/2)*1000)/1000 = 0.995
expectedScore: 0.995,
},
{
name: "Connection with Random Intervals/Sizes",
// timestamps : 1517338924, 1517338924 + 1, 1517338924 + 300, 1517338924 + 25, 1517338924 + 5000, 1517338924 + 100, 1517338924 + 1000, 1517338924 + 200, 1517338924 + 1500, 1517338924 + 3000,
// intervals between timestamps: 1, 299, 25, 4975, 90, 900, 800, 500, 1500
values: []float64{1, 299, 25, 4975, 90, 2, 42, 500, 1500},
defaultMadScore: 1,
// q1 = 13.5, q2 = 90, q3 = 1000
// numerator = q3 + q1 - 2*q2 = 1000 + 13.5 - 2*90 = 1000 + 13.5 - 180 = 833.5
// IQR = q3 - q1 = 1000 - 13.5 = 986.5
// skewness = numerator / IQR = 833.5 / 986.5 = 0.845
// skewness score = 1 - skewness = 1 - 0.845 = 0.155
expectedSkew: 0.845, // skewness score = 1 - 0.845 = 0.155
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 90)) = median(89, 209, 65, 4885, 0, 8, 48, 410, 1410) = 89
expectedMAD: 89, // MAD score = 1 - (mad/median) = 1 - 89/90 = 0.01111
expectedScore: 0.083, // score = round(((0.155 + 0.01111)/2)*1000)/1000 = 0.083
expectedError: false,
},
{
name: "Connection with Random Intervals/Sizes, defaultMadScore = 0",
values: []float64{524885, 1, 5000, 98654, 50, 41, 965842, 3},
defaultMadScore: 0,
// q1 = 22, q2 = 2525, q3 = 311769.5
// numerator = q3 + q1 - 2*q2 = 311769.5 + 22 - 2*2525 = 311769.5 + 22 - 5050 = 306741.5
// IQR = q3 - q1 = 311769.5 - 22 = 311747.5
// skewness = numerator / IQR = 306741.5 / 311747.5 = 0.983
expectedSkew: 0.983, // skewness score = 1 - skewness = 1 - 0.983 = 0.017
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 2525)) = median(252363, 2524, 2475, 2509, 2475, 2484, 96317, 2522) = 2523
expectedMAD: 2523, // MAD score = 1 - (mad/median) = 1 - (2523/2525) = 0.00079
expectedScore: 0.009, // score = round(((0.017 + 0.00079)/2)*1000)/1000 = 0.009
expectedError: false,
},
{
// this will not happen in practice for the timestamps, since we use a list of non-zero intervals between timestamps
name: "Median == 0, defaultMadScore = 0",
values: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
defaultMadScore: 0,
expectedSkew: 0, // skewness score = 1 - 0 = 1
expectedMAD: 0, // MAD score = 0, since median < 1 and defaultMADScore = 0
expectedScore: 0.5, // score = round(((1 + 0)/2)*1000)/1000 = 0.5
expectedError: false,
},
{
// this will not happen in practice for the timestamps, since we use a list of non-zero intervals between timestamps
name: "Median == 0, defaultMadScore = 1",
values: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
defaultMadScore: 1,
expectedSkew: 0, // skewness score = 1 - 0 = 1
expectedMAD: 0, // MAD score = 1, since median < 1 and defaultMADScore = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Median < 1, defaultMadScore = 0",
values: []float64{-1, -2, -3, -4, -5, -6, -7, -8, -9, -10},
defaultMadScore: 0, // default score for bytes is zero
// q1 = -3, q2 = -5.5, q3 = -8
// numerator = q3 + q1 - 2*q2 = -8 + -3 - 2*-5.5 = -8 + -3 + 11 = 0
// IQR = q3 - q1 = -8 - -3 = -5
// skewness = numerator / IQR = 0 / -5 = 0
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - -5.5)) = median(4.5, 3.5, 2.5, 1.5, 0.5, 0.5, 1.5, 2.5, 3.5, 4.5) = 2.5
expectedMAD: 2.5, // MAD score = 0 since median < 1 and defaultMADScore = 0
expectedScore: 0.5, // score = round(((1 + 0)/2)*1000)/1000 = 0.5
expectedError: false,
},
{
name: "Bowley's Skew Unreliable: Q1 == Q2",
values: []float64{7, 8, 7, 8, 7, 8, 7, 8, 7},
defaultMadScore: 1,
// q1 = 7, q2 = 7, q3 = 8 // skewness will not be calculated and stay at zero
expectedSkew: 0, // skewness score = 1 - 0 = 1
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 7)) = median(0, 1, 0, 1, 0, 1, 0, 1, 0) = 0
expectedMAD: 0, // MAD score = 1 since defaultMADScore = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Bowley's Skew Unreliable: Q2 == Q3",
values: []float64{1, 1, 2, 2, 2, 2},
defaultMadScore: 1,
// q1 = 1, q2 = 2, q3 = 2 // skewness will not be calculated and stay at zero
expectedSkew: 0,
// median absolute deviation = median(abs(x - median(x))) = median(abs(x - 2)) = median(1, 1, 0, 0, 0, 0) = 0
expectedMAD: 0, // MAD score = 1 since defaultMADScore = 1
expectedScore: 1, // score = round(((1 + 1)/2)*1000)/1000 = 1
expectedError: false,
},
{
name: "Empty Input Slice",
values: []float64{},
defaultMadScore: 0,
expectedSkew: 0,
expectedMAD: 0,
expectedScore: 0,
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
score, skew, mad, err := calculateStatisticalScore(test.values, test.defaultMadScore)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated values
require.InDelta(test.expectedSkew, skew, 0.001, "Expected skew to be %v, got %v", test.expectedSkew, skew)
require.InDelta(test.expectedMAD, mad, 0.001, "Expected MAD to be %v, got %v", test.expectedMAD, mad)
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestGetDurationScore(t *testing.T) {
tests := []struct {
name string
datasetMin int64
datasetMax int64
histMin int64
histMax int64
totalBars int32
longestConsecutiveRun int32
minHoursThreshold int32
idealConsistencyHours int32
expectedCoverage float64
expectedConsistency float64
expectedScore float64
expectedError bool
}{
{
name: "Full Dataset Coverage, Full Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924,
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 24,
longestConsecutiveRun: 24,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 1,
expectedConsistency: 1,
expectedScore: 1,
expectedError: false,
},
{
name: "Full Dataset Coverage, Min Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924,
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 6,
longestConsecutiveRun: 0,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 1,
expectedConsistency: 0,
expectedScore: 1,
expectedError: false,
},
{
name: "First Half Dataset Coverage, Min Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 12*3600, // 12 hours later
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 6,
longestConsecutiveRun: 0,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.5,
expectedConsistency: 0,
expectedScore: 0.5,
expectedError: false,
},
{
name: "Last Half Dataset Coverage, Min Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924,
histMax: 1517338924 + 12*3600, // 12 hours later
totalBars: 6,
longestConsecutiveRun: 0,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.5,
expectedConsistency: 0,
expectedScore: 0.5,
expectedError: false,
},
{
name: "3/4 Dataset Coverage, Min Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 6*3600, // 6 hours later
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 6,
longestConsecutiveRun: 0,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.75,
expectedConsistency: 0,
expectedScore: 0.75,
expectedError: false,
},
{
name: "Max Consistency",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 12*3600, // 12 hours later
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 12,
longestConsecutiveRun: 12,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.5,
expectedConsistency: 1,
expectedScore: 1,
expectedError: false,
},
{
name: "Max Consistency with min TotalBars (6)",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 12*3600, // 12 hours later
histMax: 1517338924 + 18*3600, // 18 hours later
totalBars: 6,
longestConsecutiveRun: 6,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.25,
expectedConsistency: 0.5,
expectedScore: 0.5,
expectedError: false,
},
{
name: "Average Consistency with Average Coverage",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 6*3600, // 6 hours later
histMax: 1517338924 + 18*3600, // 18 hours later
totalBars: 12,
longestConsecutiveRun: 6,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0.5,
expectedConsistency: 0.5,
expectedScore: 0.5,
expectedError: false,
},
{
name: "TotalBars < MinHoursThreshold",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924 + 6*3600, // 6 hours later
histMax: 1517338924 + 18*3600, // 18 hours later
totalBars: 3,
longestConsecutiveRun: 1,
minHoursThreshold: 6,
idealConsistencyHours: 12,
expectedCoverage: 0,
expectedConsistency: 0,
expectedScore: 0,
expectedError: false,
},
{
name: "Ideal Consistency Hours < 1",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924,
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 12,
longestConsecutiveRun: 6,
minHoursThreshold: 6,
idealConsistencyHours: 0,
expectedCoverage: 0,
expectedConsistency: 0,
expectedScore: 0,
expectedError: true,
},
{
name: "Min Hours Threshold < 1",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
histMin: 1517338924,
histMax: 1517338924 + 24*3600, // 24 hours later
totalBars: 12,
longestConsecutiveRun: 6,
minHoursThreshold: 0,
idealConsistencyHours: 12,
expectedCoverage: 0,
expectedConsistency: 0,
expectedScore: 0,
expectedError: true,
},
{
name: "Dataset Min > Dataset Max",
datasetMin: 1,
datasetMax: 0,
histMin: 0,
histMax: 1,
totalBars: 6,
longestConsecutiveRun: 12,
expectedCoverage: 0,
expectedConsistency: 0,
expectedScore: 0,
expectedError: true,
},
{
name: "Hist Min > Hist Max",
datasetMin: 0,
datasetMax: 1,
histMin: 1,
histMax: 0,
totalBars: 6,
longestConsecutiveRun: 12,
expectedCoverage: 0,
expectedConsistency: 0,
expectedScore: 0,
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
coverage, consistency, score, err := getDurationScore(test.datasetMin, test.datasetMax, test.histMin, test.histMax, test.totalBars, test.longestConsecutiveRun, test.minHoursThreshold, test.idealConsistencyHours)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", false, err)
// check the calculated values
require.InDelta(test.expectedConsistency, consistency, 0.001, "Expected consistency to be %v, got %v", test.expectedConsistency, consistency)
require.InDelta(test.expectedCoverage, coverage, 0.001, "Expected coverage to be %v, got %v", test.expectedCoverage, coverage)
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestGetHistogramScore(t *testing.T) {
tests := []struct {
name string
datasetMin int64
datasetMax int64
tsList []uint32
modalSensitivity float64
bimodalOutlierRemoval int32
minHoursForBimodalAnalysis int32
beaconTimeSpan int32
expectedBinEdges []float64
expectedHistogram []int
expectedFreqCount map[int32]int32
expectedTotalBars int32
expectedLongestRun int32
expectedScore float64
expectedError bool
}{
{
name: "Simple Number List",
datasetMin: 1,
datasetMax: 11,
tsList: []uint32{
1,
2, 2,
3, 3, 3,
4, 4, 4, 4,
5, 5, 5, 5, 5,
6, 6, 6, 6, 6, 6,
7, 7, 7, 7, 7, 7, 7,
8, 8, 8, 8, 8, 8, 8, 8,
9, 9, 9, 9, 9, 9, 9, 9, 9,
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 6,
beaconTimeSpan: 10,
expectedBinEdges: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
expectedHistogram: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedFreqCount: map[int32]int32{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1},
expectedTotalBars: 10,
expectedLongestRun: 10,
expectedScore: 0.478,
expectedError: false,
},
{
name: "Connection with Regular Intervals",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
tsList: []uint32{1517338924, 1517338924 + 1*3600, 1517338924 + 2*3600, 1517338924 + 3*3600, 1517338924 + 4*3600, 1517338924 + 5*3600, 1517338924 + 6*3600, 1517338924 + 7*3600, 1517338924 + 8*3600, 1517338924 + 9*3600, 1517338924 + 10*3600, 1517338924 + 11*3600, 1517338924 + 12*3600, 1517338924 + 13*3600, 1517338924 + 14*3600, 1517338924 + 15*3600, 1517338924 + 16*3600, 1517338924 + 17*3600, 1517338924 + 18*3600, 1517338924 + 19*3600, 1517338924 + 20*3600, 1517338924 + 21*3600, 1517338924 + 22*3600, 1517338924 + 23*3600},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 11,
beaconTimeSpan: 24,
// total edges: 24 + 1 = 25
// step: (maxTS - minTS) / total edges - 1 = 24*3600 / 24 = 3600
// first edge: 1517338924, last edge: 1517338924 + 24*3600
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
expectedBinEdges: []float64{1517338924, 1517342524, 1517346124, 1517349724, 1517353324, 1517356924, 1517360524, 1517364124, 1517367724, 1517371324, 1517374924, 1517378524, 1517382124, 1517385724, 1517389324, 1517392924, 1517396524, 1517400124, 1517403724, 1517407324, 1517410924, 1517414524, 1517418124, 1517421724, 1517425324},
expectedHistogram: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedFreqCount: map[int32]int32{1: 24},
expectedTotalBars: 24,
expectedLongestRun: 24,
expectedScore: 1,
expectedError: false,
},
{
name: "Connection with Closely-Valued Timestamps",
datasetMin: 98,
datasetMax: 102,
tsList: []uint32{98, 99, 99, 100, 100, 100, 101, 101, 102},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 6,
beaconTimeSpan: 8,
// total edges: 8 + 1 = 9
// step: (maxTS - minTS) / total edges - 1 = 102 - 98 / 8 = 4/8 = 0.5
// first edge: 98, last edge: 102
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
expectedBinEdges: []float64{98, 98.5, 99, 99.5, 100, 100.5, 101, 101.5, 102},
expectedHistogram: []int{1, 0, 2, 0, 3, 0, 2, 1},
expectedFreqCount: map[int32]int32{1: 2, 2: 2, 3: 1},
expectedTotalBars: 5,
expectedLongestRun: 3,
// MEAN = 1.125, SD = 1.05327, CV = 0.936, cvScore = 1 - 0.936 = 0.064
expectedScore: 0.064, // score = max(cv score, bimodal fit score) = max (0.064, 0) = 0.064
expectedError: false,
},
{
name: "Connection with Random Intervals, CV > 1",
datasetMin: 0,
datasetMax: 1000000,
tsList: []uint32{524885, 1, 5000, 98654, 50, 41, 965842, 3, 12001, 200400, 104001, 199999},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 6,
beaconTimeSpan: 10,
// total edges: 10 + 1 = 11
// step: (maxTS - minTS) / total edges - 1 = 1000000 / 10 = 100000
// first edge: 0, last edge: 1000000
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
expectedBinEdges: []float64{0, 100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000, 1000000},
expectedHistogram: []int{7, 2, 1, 0, 0, 1, 0, 0, 0, 1},
expectedFreqCount: map[int32]int32{1: 3, 2: 1, 7: 1},
expectedTotalBars: 5,
expectedLongestRun: 4,
// mean = 1.2, sd = 2.0396, cv = sd / abs(mean) = 2.0396 / 1.2 = 1.6997, cvScore = 0 (since cv > 1)
expectedScore: 0, // score = max(cv score, bimodal fit score) = max (0, 0) = 0
expectedError: false,
},
{
name: "Connection with Bimodal Histogram",
datasetMin: 0,
datasetMax: 100,
tsList: []uint32{1, 2, 3, 4, 15, 21, 22, 23, 24, 35, 41, 42, 43, 44, 55, 61, 62, 63, 64, 75, 81, 82, 83, 84, 95},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 6,
beaconTimeSpan: 10,
expectedBinEdges: []float64{0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100},
expectedHistogram: []int{4, 1, 4, 1, 4, 1, 4, 1, 4, 1},
expectedFreqCount: map[int32]int32{1: 5, 4: 5},
expectedTotalBars: 10,
expectedLongestRun: 10,
// mean = 2.5, sd = 1.5, cv = sd / abs(mean) = 1.5 / 2.5 = 0.6, cvScore = 1 - 0.6 = 0.4
expectedScore: 1, // score = max(cv score, bimodal fit score) = max (0.4, 1) = 1
expectedError: false,
},
{
name: "Connection with Histogram that has Gaps and a Wraparound Timestamp Run",
datasetMin: 0,
datasetMax: 250,
tsList: []uint32{10, 20, 100, 110, 110, 220},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 6,
beaconTimeSpan: 5,
expectedBinEdges: []float64{0, 50, 100, 150, 200, 250},
expectedHistogram: []int{2, 0, 3, 0, 1},
expectedFreqCount: map[int32]int32{1: 1, 2: 1, 3: 1},
expectedTotalBars: 3,
expectedLongestRun: 2,
// mean = 1.2, sd = 1.1662, cv = sd / abs(mean) = 1.1662 / 1.2 = 0.9718, cvScore = 1 - 0.9718 = 0.0282
expectedScore: 0.028, // score = max(cv score, bimodal fit score) = max (0.028, 0) = 0.028
expectedError: false,
},
{
name: "Connection with Single Bar Histogram",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
tsList: []uint32{1517338924, 1517338924 + 60, 1517338924 + 120, 1517338924 + 180, 1517338924 + 240, 1517338924 + 300, 1517338924 + 360, 1517338924 + 420, 1517338924 + 480, 1517338924 + 540},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 11,
beaconTimeSpan: 24,
expectedBinEdges: []float64{1517338924, 1517342524, 1517346124, 1517349724, 1517353324, 1517356924, 1517360524, 1517364124, 1517367724, 1517371324, 1517374924, 1517378524, 1517382124, 1517385724, 1517389324, 1517392924, 1517396524, 1517400124, 1517403724, 1517407324, 1517410924, 1517414524, 1517418124, 1517421724, 1517425324},
expectedHistogram: []int{10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
expectedFreqCount: map[int32]int32{10: 1},
expectedTotalBars: 1,
expectedLongestRun: 1,
expectedScore: 0,
expectedError: false,
},
{
// this should not happen in practice since min hours for bimodal analysis is vetted when the config is loaded.
// this is to ensure that the bimodal fit score is not calculated for histograms with too few bars, as in that case
// a histogram with 1-2 bars will always be given a high bimoal fit score as it technically has 1-2 modes
name: "Connection with Single Bar Histogram and MinHoursForBimodal Analysis Set to < 3",
datasetMin: 1517338924,
datasetMax: 1517338924 + 24*3600, // 24 hours later
tsList: []uint32{1517338924, 1517338924 + 60, 1517338924 + 120, 1517338924 + 180, 1517338924 + 240, 1517338924 + 300, 1517338924 + 360, 1517338924 + 420, 1517338924 + 480, 1517338924 + 540},
modalSensitivity: 0.05,
bimodalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 1, // < 3
beaconTimeSpan: 24,
expectedBinEdges: []float64{1517338924, 1517342524, 1517346124, 1517349724, 1517353324, 1517356924, 1517360524, 1517364124, 1517367724, 1517371324, 1517374924, 1517378524, 1517382124, 1517385724, 1517389324, 1517392924, 1517396524, 1517400124, 1517403724, 1517407324, 1517410924, 1517414524, 1517418124, 1517421724, 1517425324},
expectedHistogram: []int{10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
expectedFreqCount: map[int32]int32{10: 1},
expectedTotalBars: 1,
expectedLongestRun: 1,
expectedScore: 0, // this would be 1 if minHoursForBimodalAnalysis was not overridden
expectedError: false,
},
{
name: "Empty Timestamp List",
datasetMin: 0,
datasetMax: 10,
tsList: []uint32{},
expectedError: true,
},
{
name: "Dataset Min > Dataset Max",
datasetMin: 1,
datasetMax: 0,
tsList: []uint32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedError: true,
},
{
name: "Dataset Min == Dataset Max",
datasetMin: 1,
datasetMax: 1,
tsList: []uint32{1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
freqList, freqCount, totalBars, longestRun, score, err := getHistogramScore(test.datasetMin, test.datasetMax, test.tsList, test.modalSensitivity, test.bimodalOutlierRemoval, test.minHoursForBimodalAnalysis, test.beaconTimeSpan)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", false, err)
// check the calculated values
require.EqualValues(test.expectedHistogram, freqList, "Expected frequency list to be %v, got %v", test.expectedHistogram, freqList)
require.EqualValues(test.expectedFreqCount, freqCount, "Expected frequency count to be %v, got %v", test.expectedFreqCount, freqCount)
require.EqualValues(test.expectedTotalBars, totalBars, "Expected total bars to be %v, got %v", test.expectedTotalBars, totalBars)
require.EqualValues(test.expectedLongestRun, longestRun, "Expected longest run to be %v, got %v", test.expectedLongestRun, longestRun)
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestCalculateBowleySkewness(t *testing.T) {
testCases := []struct {
name string
intervalsBetweenTimestamps []float64
expectedSkewness float64
expectedSkewnessScore float64
expectedError bool
}{
{
name: "Simple Number List",
intervalsBetweenTimestamps: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedSkewness: 0,
expectedSkewnessScore: 1,
expectedError: false,
},
{
name: "Connection with Perfect Intervals",
// in this case bowley's skew would actually not be calculated since q1 == q2 == q3 and the score would default to 100%
// timestamps : 1517338924, 1517338924 + 60, 1517338924 + 120, 1517338924 + 180, 1517338924 + 240, 1517338924 + 300, 1517338924 + 360, 1517338924 + 420, 1517338924 + 480, 1517338924 + 540,
// intervals between timestamps: 60, 60, 60, 60, 60, 60, 60, 60, 60
intervalsBetweenTimestamps: []float64{60, 60, 60, 60, 60, 60, 60, 60, 60},
expectedSkewness: 0,
expectedSkewnessScore: 1,
expectedError: false,
},
{
name: "Connection with Regular Intervals",
intervalsBetweenTimestamps: []float64{98, 99, 99, 100, 100, 100, 101, 101, 102},
// q1 = 99, q2 = 100, q3 = 101
// numerator = q3 + q1 - 2*q2 = 101 + 99 - 2*100 = 101 + 99 - 200 = 0
// IQR = q3 - q1 = 101 - 99 = 2
// skewness = numerator / IQR = 0 / 2 = 0
// skewness score = 1 - skewness = 1 - 0 = 1
expectedSkewness: 0,
expectedSkewnessScore: 1,
expectedError: false,
},
{
name: "Connection with Random Intervals Sorted",
intervalsBetweenTimestamps: []float64{1, 2, 25, 42, 90, 299, 500, 1500, 4975},
// q1 = 13.5, q2 = 90, q3 = 1000
// numerator = q3 + q1 - 2*q2 = 1000 + 13.5 - 2*90 = 1000 + 13.5 - 180 = 833.5
// IQR = q3 - q1 = 1000 - 13.5 = 986.5
// skewness = numerator / IQR = 833.5 / 986.5 = 0.845
// skewness score = 1 - skewness = 1 - 0.845 = 0.155
expectedSkewness: 0.845,
expectedSkewnessScore: 0.155,
expectedError: false,
},
{
name: "Connection with Random Intervals Unsorted",
// timestamps : 1517338924, 1517338924 + 1, 1517338924 + 300, 1517338924 + 25, 1517338924 + 5000, 1517338924 + 100, 1517338924 + 1000, 1517338924 + 200, 1517338924 + 1500, 1517338924 + 3000,
// intervals between timestamps: 1, 299, 25, 4975, 90, 900, 800, 500, 1500
intervalsBetweenTimestamps: []float64{1, 299, 25, 4975, 90, 2, 42, 500, 1500},
// q1 = 13.5, q2 = 90, q3 = 1000
// numerator = q3 + q1 - 2*q2 = 1000 + 13.5 - 2*90 = 1000 + 13.5 - 180 = 833.5
// IQR = q3 - q1 = 1000 - 13.5 = 986.5
// skewness = numerator / IQR = 833.5 / 986.5 = 0.845
// skewness score = 1 - skewness = 1 - 0.845 = 0.155
expectedSkewness: 0.845,
expectedSkewnessScore: 0.155,
expectedError: false,
},
{
name: "Connection with Random Intervals Unsorted 2",
intervalsBetweenTimestamps: []float64{524885, 1, 5000, 98654, 50, 41, 965842, 3},
// q1 = 22, q2 = 2525, q3 = 311769.5
// numerator = q3 + q1 - 2*q2 = 311769.5 + 22 - 2*2525 = 311769.5 + 22 - 5050 = 306741.5
// IQR = q3 - q1 = 311769.5 - 22 = 311747.5
// skewness = numerator / IQR = 306741.5 / 311747.5 = 0.983
// skewness score = 1 - skewness = 1 - 0.983 = 0.017
expectedSkewness: 0.983,
expectedSkewnessScore: 0.017,
expectedError: false,
},
{
name: "Bowley's Skew Unreliable: Q1 == Q2",
intervalsBetweenTimestamps: []float64{7, 8, 7, 8, 7, 8, 7, 8, 7},
// q1 = 7, q2 = 7, q3 = 8
expectedSkewness: 0, // skewness will not be calculated and stay at zero
expectedSkewnessScore: 1, // score will stay at the deafault 100%
expectedError: false,
},
{
name: "Bowley's Skew Unreliable: Q2 == Q3",
intervalsBetweenTimestamps: []float64{1, 1, 2, 2, 2, 2},
// q1 = 1, q2 = 2, q3 = 2
expectedSkewness: 0, // skewness will not be calculated and stay at zero
expectedSkewnessScore: 1, // score will stay at the deafault 100%
expectedError: false,
},
{
name: "Unsorted Slice",
intervalsBetweenTimestamps: []float64{7.7, 6.6, 4.4, 1.1, 5.5, 3.3, 2.2},
expectedSkewness: 0,
expectedSkewnessScore: 1,
expectedError: false,
},
{
name: "Empty",
intervalsBetweenTimestamps: []float64{},
expectedSkewness: 0,
expectedSkewnessScore: 0,
expectedError: true,
},
{
name: "Less than 3 elements", // can't calculate skewness with fewer than 3 elements
intervalsBetweenTimestamps: []float64{1, 2},
expectedSkewness: 0,
expectedSkewnessScore: 0,
expectedError: true,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
skew, score, err := calculateBowleySkewness(test.intervalsBetweenTimestamps)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated values
require.InDelta(test.expectedSkewness, skew, 0.001, "Expected skewness to be %v, got %v", test.expectedSkewness, skew)
require.InDelta(test.expectedSkewnessScore, score, 0.001, "Expected score to be %v, got %v", test.expectedSkewnessScore, score)
})
}
}
func TestCalculateDistinctCounts(t *testing.T) {
tests := []struct {
name string
sortedInput []float64
expectedDistinct []int64
expectedCounts []int64
expectedMode int64
expectedMaxCount int64
expectError bool
}{
{
name: "Simple List",
sortedInput: []float64{1, 2, 2, 3, 3, 3, 4, 5, 5},
expectedDistinct: []int64{1, 2, 3, 4, 5},
expectedCounts: []int64{1, 2, 3, 1, 2},
expectedMode: 3,
expectedMaxCount: 3,
expectError: false,
},
{
name: "Simple List 2",
sortedInput: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedDistinct: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
expectedCounts: []int64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedMode: 1,
expectedMaxCount: 1,
expectError: false,
},
{
name: "Timestamp Slice",
sortedInput: []float64{1517338924, 1517338924, 1517338924, 1609459200, 1609459200, 1612137600, 1612137600},
expectedDistinct: []int64{1517338924, 1609459200, 1612137600},
expectedCounts: []int64{3, 2, 2},
expectedMode: 1517338924,
expectedMaxCount: 3,
expectError: false,
},
{
name: "Unsorted Slice",
sortedInput: []float64{3.3, 2.2, 4.4, 1.1, 5.5, 3.3, 2.2},
expectedDistinct: []int64{1, 2, 3, 4, 5},
expectedCounts: []int64{1, 2, 2, 1, 1},
expectedMode: 2,
expectedMaxCount: 2,
expectError: false,
},
{
name: "Empty Slice",
sortedInput: []float64{},
expectedDistinct: nil,
expectedCounts: nil,
expectedMode: 0,
expectedMaxCount: 0,
expectError: true,
},
{
name: "Slice with Only One Element",
sortedInput: []float64{1},
expectedDistinct: nil,
expectedCounts: nil,
expectedMode: 0,
expectedMaxCount: 0,
expectError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
distinctNumbers, countsArray, mode, maxCount, err := calculateDistinctCounts(test.sortedInput)
// check if an error was expected
require.EqualValues(test.expectError, err != nil, "Expected error to be %v, got %v", test.expectError, err)
// check the calculated values
require.EqualValues(test.expectedDistinct, distinctNumbers, "Expected distinctNumbers to be %v, got %v", test.expectedDistinct, distinctNumbers)
require.EqualValues(test.expectedCounts, countsArray, "Expected countsArray to be %v, got %v", test.expectedCounts, countsArray)
require.EqualValues(test.expectedMode, mode, "Expected mode to be %v, got %v", test.expectedMode, mode)
require.EqualValues(test.expectedMaxCount, maxCount, "Expected maxCount to be %v, got %v", test.expectedMaxCount, maxCount)
})
}
}
func TestCalculateMedianAbsoluteDeviation(t *testing.T) {
tests := []struct {
name string
inputData []float64
defaultScore float64
expectedMAD float64
expectedScore float64
expectError bool
}{
{
name: "Simple List",
inputData: []float64{1, 2, 2, 3, 3, 3, 4, 5, 5},
defaultScore: 1,
expectedMAD: 1,
expectedScore: 0.6667,
expectError: false,
},
{
name: "Simple List 2",
inputData: []float64{11, 12, 12, 14, 15, 16},
defaultScore: 1,
expectedMAD: 1.5,
expectedScore: 0.8846,
expectError: false,
},
{
name: "Bigger Numbers",
inputData: []float64{1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500},
defaultScore: 1,
expectedMAD: 1250,
expectedScore: 0.6154,
expectError: false,
},
{
name: "Unsorted Slice",
inputData: []float64{4000, 2500, 2000, 1500, 5500, 3500, 1000, 4500, 5000, 3000},
defaultScore: 1,
expectedMAD: 1250,
expectedScore: 0.6154,
expectError: false,
},
{
name: "Empty Slice",
inputData: []float64{},
defaultScore: 1,
expectedMAD: 0,
expectedScore: 0,
expectError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
mad, score, err := calculateMedianAbsoluteDeviation(test.inputData, test.defaultScore)
// check if an error was expected
require.EqualValues(test.expectError, err != nil, "error should match expected value")
// check the calculated MAD
require.InDelta(test.expectedMAD, mad, 0.001, "Expected MAD to be %v, got %v", test.expectedMAD, mad)
// check the calculated score
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestComputeHistogramBins(t *testing.T) {
tests := []struct {
name string
startTime int64
endTime int64
numBins int32
expectedBinEdges []float64
expectedError bool
}{
{
name: "Simple List",
startTime: 0,
endTime: 10,
numBins: 2,
expectedBinEdges: []float64{0, 5, 10},
expectedError: false,
},
{
name: "Total Bins Less Than Time Range",
startTime: 0,
endTime: 100,
numBins: 5,
expectedBinEdges: []float64{0, 20, 40, 60, 80, 100},
expectedError: false,
},
{
name: "Total Bins Equal to Time Range",
startTime: 0,
endTime: 24,
numBins: 24,
// total edges: 24 + 1 = 25
// step: (maxTS - minTS) / total edges - 1 = 24 / 24 = 1
// first edge: 1, last edge: 24
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
// expectedBinEdges: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24},
expectedBinEdges: []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24},
expectedError: false,
},
{
name: "Total Bins Exceed Time Range",
startTime: 0,
endTime: 10,
numBins: 24,
// total edges: 10 + 1 = 11
// step: (maxTS - minTS) / numBins - 1 = 10 / 24 = 0.4167
// first edge: 0, last edge: 10
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
expectedBinEdges: []float64{0, 0.4167, 0.8333, 1.25, 1.6667, 2.0833, 2.5, 2.9167, 3.3333, 3.75, 4.1667, 4.5833, 5, 5.4167, 5.8333, 6.25, 6.6667, 7.0833, 7.5, 7.9167, 8.3333, 8.75, 9.1667, 9.5833, 10},
expectedError: false,
},
{
name: "Timestamp List",
startTime: 1517336042,
endTime: 1517422440,
numBins: 24,
// total edges: 24 + 1 = 25
// step: (maxTS - minTS) / total edges - 1 = 1517422440 - 1517336042 / 24 = 86398 / 24 = 3599.9167
// first edge: 1517336042, last edge: 1517422440
// bin edges: first edge, first edge + step, first edge + 2*step, ... , last edge
expectedBinEdges: []float64{
1517336042,
1517336042 + 3599.9167,
1517336042 + 2*3599.9167,
1517336042 + 3*3599.9167,
1517336042 + 4*3599.9167,
1517336042 + 5*3599.9167,
1517336042 + 6*3599.9167,
1517336042 + 7*3599.9167,
1517336042 + 8*3599.9167,
1517336042 + 9*3599.9167,
1517336042 + 10*3599.9167,
1517336042 + 11*3599.9167,
1517336042 + 12*3599.9167,
1517336042 + 13*3599.9167,
1517336042 + 14*3599.9167,
1517336042 + 15*3599.9167,
1517336042 + 16*3599.9167,
1517336042 + 17*3599.9167,
1517336042 + 18*3599.9167,
1517336042 + 19*3599.9167,
1517336042 + 20*3599.9167,
1517336042 + 21*3599.9167,
1517336042 + 22*3599.9167,
1517336042 + 23*3599.9167,
1517422440,
},
expectedError: false,
},
{
name: "Invalid Number of Bins",
startTime: 0,
endTime: 100,
numBins: 0,
expectedError: true,
},
{
name: "Invalid Time Range - End Time < Start Time",
startTime: 10,
endTime: 5,
numBins: 5,
expectedError: true,
},
{
name: "Invalid Time Range - End Time == Start Time",
startTime: 0,
endTime: 0,
numBins: 5,
expectedBinEdges: nil,
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
divs, err := computeHistogramBins(test.startTime, test.endTime, test.numBins)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", false, err)
// check the calculated values
// require.EqualValues(test.expectedBinEdges, divs, "Expected bin edges to be %v, got %v", test.expectedBinEdges, divs)
require.InDeltaSlice(test.expectedBinEdges, divs, 0.01, "Expected bin edges to be %v, got %v", test.expectedBinEdges, divs)
})
}
}
func TestCalculateCoefficientOfVariationScore(t *testing.T) {
tests := []struct {
name string
freqList []int
total int
expectedScore float64
expectedError bool
}{
{
name: "Uniform Distribution",
freqList: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
expectedScore: 1,
expectedError: false,
},
{
name: "Normal Distribution",
freqList: []int{950, 970, 990, 1010, 1030, 1050, 1070},
total: 7150,
// sd = 40, mean = 1010, CV = 0.0396
expectedScore: 0.96, // score = round((1-0.0396)*1000) = 960, 960/1000 = 0.96
expectedError: false,
},
{
name: "CV less than 1",
freqList: []int{1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009},
// CV = 0.0028594
expectedScore: 0.9971406, // score = 1-0.0028594 = 0.9971406
expectedError: false,
},
{
name: "CV greater than 1",
freqList: []int{1, 5, 10, 50, 100, 500, 1000, 5000, 10000},
expectedScore: 0.0, // score should be 0 for CV > 1
expectedError: false,
},
{
name: "Empty List",
freqList: []int{},
expectedScore: 0,
expectedError: true,
},
{
name: "Negative Slice Values",
freqList: []int{-5, -10, -7, -3, -8},
expectedScore: 0,
expectedError: true,
},
{
name: "Total is zero",
freqList: []int{0, 0, 0, 0, 0},
expectedScore: 0,
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
score, err := calculateCoefficientOfVariationScore(test.freqList)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// check the calculated values
require.InDelta(test.expectedScore, score, 0.001, "Expected score to be %v, got %v", test.expectedScore, score)
})
}
}
func TestCalculateBimodalFitScore(t *testing.T) {
tests := []struct {
name string
freqCount map[int32]int32
totalBars int32
modalOutlierRemoval int32
minHoursForBimodalAnalysis int32
expectedScore float64
expectedError bool
}{
{
name: "Perfect Single Modal",
freqCount: map[int32]int32{
1: 10,
},
totalBars: 10,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
expectedScore: 1,
expectedError: false,
},
{
name: "Imperfect Single Modal",
freqCount: map[int32]int32{
1: 50,
2: 5,
},
totalBars: 60,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
// (50+5)/max(60-1, 1) = 55/59 = 0.932
expectedScore: 0.932,
expectedError: false,
},
{
name: "Perfect Single Modal with Outlier Removal",
freqCount: map[int32]int32{
1: 10,
2: 1,
},
totalBars: 11,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
expectedScore: 1,
expectedError: false,
},
{
name: "Imperfect Single Modal with Outlier Removal",
freqCount: map[int32]int32{
1: 50,
2: 1,
3: 1,
},
totalBars: 56,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
// (50+1/56-1) = 51/55 = 0.927
expectedScore: 0.927,
expectedError: false,
},
{
name: "Perfect Bimodal",
freqCount: map[int32]int32{
1: 10,
2: 10,
},
totalBars: 20,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
expectedScore: 1,
expectedError: false,
},
{
name: "Imperfect Bimodal",
freqCount: map[int32]int32{
1: 50,
2: 30,
3: 1,
4: 2,
},
totalBars: 83,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
// (50+30)/(83-1) = 80/82 = 0.976
expectedScore: 0.976,
expectedError: false,
},
{
name: "Perfect Bimodal with Outlier Removal",
freqCount: map[int32]int32{
1: 10,
2: 10,
3: 1,
},
totalBars: 21,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
expectedScore: 1,
expectedError: false,
},
{
name: "Imperfect Bimodal with Outlier Removal",
freqCount: map[int32]int32{
1: 50,
2: 30,
3: 1,
4: 2,
5: 1,
},
totalBars: 84,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 10,
// (50+30)/(84-1) = 80/83 = 0.964
expectedScore: 0.964,
expectedError: false,
},
{
name: "Number of Bars < Minimum Hours For Bimodal Analysis",
freqCount: map[int32]int32{
1: 2,
2: 3,
},
totalBars: 5,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 11,
expectedScore: 0.0,
expectedError: false,
},
{
// this should not happen in practice, as this value will get vetted when the config is loaded
name: "Minimum Hours For Bimodal Analysis < 3",
freqCount: map[int32]int32{
1: 1,
2: 1,
},
totalBars: 2,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 2,
expectedScore: 0.0, // score would be 100% if the setting was not overridden
expectedError: false,
},
{
name: "Number of Bars <= 0",
freqCount: map[int32]int32{1: 2, 2: 3},
totalBars: 0,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 11,
expectedScore: 0.0,
expectedError: true,
},
{
name: "Empty Frequency Count",
freqCount: map[int32]int32{},
totalBars: 20,
modalOutlierRemoval: 1,
minHoursForBimodalAnalysis: 11,
expectedScore: 0.0,
expectedError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
// run the function
score, err := calculateBimodalFitScore(tc.freqCount, tc.totalBars, tc.modalOutlierRemoval, tc.minHoursForBimodalAnalysis)
// check if an error was expected
require.EqualValues(tc.expectedError, err != nil, "Expected error to be %v, got %v", tc.expectedError, err)
// check the calculated values
require.InDelta(tc.expectedScore, score, 0.001, "score should match expected value")
})
}
}
func TestCreateHistogram(t *testing.T) {
tests := []struct {
name string
binEdges []float64
tsList []uint32
modalSensitivity float64
expectedHistogram []int
expectedFreqCount map[int32]int32
expectedTotalBars int32
expectedLongestRun int32
expectedError bool
errorContains string
}{
{
name: "Simple Flat Histogram",
binEdges: []float64{0, 10, 20, 30},
tsList: []uint32{1, 5, 11, 15, 21, 25},
modalSensitivity: 0.05,
expectedHistogram: []int{2, 2, 2},
expectedFreqCount: map[int32]int32{2: 3},
expectedTotalBars: 3,
expectedLongestRun: 3,
expectedError: false,
},
{
name: "Multiple Bins, but All Timestamps in One",
binEdges: []float64{0, 100, 200},
tsList: []uint32{10, 20, 30, 40},
modalSensitivity: 0.05,
expectedHistogram: []int{4, 0},
expectedFreqCount: map[int32]int32{4: 1},
expectedTotalBars: 1,
expectedLongestRun: 1,
expectedError: false,
},
{
name: "Single Bin",
binEdges: []float64{0, 100},
tsList: []uint32{10, 20, 30, 40, 50},
modalSensitivity: 0.05,
expectedHistogram: []int{5},
expectedFreqCount: map[int32]int32{5: 1},
expectedTotalBars: 1,
expectedLongestRun: 1,
expectedError: false,
},
{
name: "Histogram with Gaps and Wraparound Timestamp Run",
binEdges: []float64{0, 50, 100, 150, 200, 250},
tsList: []uint32{10, 20, 100, 110, 110, 220},
modalSensitivity: 0.05,
expectedHistogram: []int{2, 0, 3, 0, 1},
expectedFreqCount: map[int32]int32{1: 1, 2: 1, 3: 1},
expectedTotalBars: 3,
expectedLongestRun: 2,
expectedError: false,
},
{
name: "Bimodal Histogram",
binEdges: []float64{0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100},
tsList: []uint32{1, 2, 3, 4, 15, 21, 22, 23, 24, 35, 41, 42, 43, 44, 55, 61, 62, 63, 64, 75, 81, 82, 83, 84, 95},
modalSensitivity: 0.05,
expectedHistogram: []int{4, 1, 4, 1, 4, 1, 4, 1, 4, 1},
expectedFreqCount: map[int32]int32{1: 5, 4: 5},
expectedTotalBars: 10,
expectedLongestRun: 10,
expectedError: false,
},
{
name: "Last Value in Value List Equal to Last Bin Edge",
tsList: []uint32{98, 99, 99, 100, 100, 100, 101, 101, 102},
modalSensitivity: 0.05,
binEdges: []float64{98, 98.5, 99, 99.5, 100, 100.5, 101, 101.5, 102},
expectedHistogram: []int{1, 0, 2, 0, 3, 0, 2, 1},
expectedFreqCount: map[int32]int32{1: 2, 2: 2, 3: 1},
expectedTotalBars: 5,
expectedLongestRun: 3,
expectedError: false,
},
{
name: "High Modal Sensitivity",
binEdges: []float64{0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000},
tsList: []uint32{
10, 15, 20, 25, 30, 35, 40, 45, 50,
110, 115, 120, 125, 130, 135, 140, 145, 150, 155,
210, 215, 220, 225, 230, 235, 240, 245, 250, 255, 260,
300, 305, 330, 335, 337, 339, 343, 349, 380, 399,
400, 404, 424, 434, 437, 439, 443, 449, 480, 495, 499,
510, 515, 520, 525, 530, 535, 540, 545, 550,
610, 615, 620, 625, 630, 635, 640, 645, 650, 655,
710, 715, 720, 725, 730, 735, 740, 745, 750, 755, 760,
810, 815, 820, 825, 830, 835, 840, 845, 850, 855,
900, 910, 920, 930, 940, 960, 970, 980, 990,
},
modalSensitivity: 0.2,
expectedHistogram: []int{9, 10, 11, 10, 11, 9, 10, 11, 10, 9},
expectedFreqCount: map[int32]int32{9: 10},
expectedTotalBars: 10,
expectedLongestRun: 10,
expectedError: false,
},
{
name: "Low Modal Sensitivity",
binEdges: []float64{0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000},
tsList: []uint32{
10, 15, 20, 25, 30, 35, 40, 45, 50,
110, 115, 120, 125, 130, 135, 140, 145, 150, 155,
210, 215, 220, 225, 230, 235, 240, 245, 250, 255, 260,
300, 305, 330, 335, 337, 339, 343, 349, 380, 399,
400, 404, 424, 434, 437, 439, 443, 449, 480, 495, 499,
510, 515, 520, 525, 530, 535, 540, 545, 550,
610, 615, 620, 625, 630, 635, 640, 645, 650, 655,
710, 715, 720, 725, 730, 735, 740, 745, 750, 755, 760,
810, 815, 820, 825, 830, 835, 840, 845, 850, 855,
900, 910, 920, 930, 940, 960, 970, 980, 990,
},
modalSensitivity: 0.05,
expectedHistogram: []int{9, 10, 11, 10, 11, 9, 10, 11, 10, 9},
expectedFreqCount: map[int32]int32{9: 3, 10: 4, 11: 3},
expectedTotalBars: 10,
expectedLongestRun: 10,
expectedError: false,
},
{
name: "Bimodal - High Sensitivity",
binEdges: []float64{0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000},
tsList: []uint32{
10, 15, 20, 25, 30, 35, 40, 45, 50,
110, 115, 120, 125, 130, 135, 140, 145, 150, 155,
210, 215, 220, 225, 230, 235, 240, 245, 250, 255, 260,
300, 305, 330, 335, 337, 339, 343, 349, 380, 399,
400, 404, 424, 434, 437, 439, 443, 449, 480, 495, 499,
510, 515, 520, 525, 530, 535, 540, 545, 550,
610, 615, 620, 625, 630, 635, 640, 645, 650, 655,
710, 715, 720, 725, 730, 735, 740, 745, 750, 755, 760,
810, 815, 820, 825, 830, 835, 840, 845, 850, 855,
900, 910, 920, 930, 940, 960, 970, 980, 990,
1010, 1011, 1055,
1110, 1155,
1210, 1260, 1270,
1300, 1330,
1404, 1440, 1445,
1550, 1560,
1600, 1650, 1660,
1770, 1780,
1880, 1890, 1899,
1999, 2000,
},
modalSensitivity: 0.3,
expectedHistogram: []int{9, 10, 11, 10, 11, 9, 10, 11, 10, 9, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2},
expectedFreqCount: map[int32]int32{0: 10, 8: 10},
expectedTotalBars: 20,
expectedLongestRun: 20,
},
{
name: "Bimodal - Low Sensitivity",
binEdges: []float64{0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000},
tsList: []uint32{
10, 15, 20, 25, 30, 35, 40, 45, 50,
110, 115, 120, 125, 130, 135, 140, 145, 150, 155,
210, 215, 220, 225, 230, 235, 240, 245, 250, 255, 260,
300, 305, 330, 335, 337, 339, 343, 349, 380, 399,
400, 404, 424, 434, 437, 439, 443, 449, 480, 495, 499,
510, 515, 520, 525, 530, 535, 540, 545, 550,
610, 615, 620, 625, 630, 635, 640, 645, 650, 655,
710, 715, 720, 725, 730, 735, 740, 745, 750, 755, 760,
810, 815, 820, 825, 830, 835, 840, 845, 850, 855,
900, 910, 920, 930, 940, 960, 970, 980, 990,
1010, 1011, 1055,
1110, 1155,
1210, 1260, 1270,
1300, 1330,
1404, 1440, 1445,
1550, 1560,
1600, 1650, 1660,
1770, 1780,
1880, 1890, 1899,
1999, 2000,
},
modalSensitivity: 0.05,
expectedHistogram: []int{9, 10, 11, 10, 11, 9, 10, 11, 10, 9, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2},
expectedFreqCount: map[int32]int32{9: 3, 10: 4, 11: 3, 2: 5, 3: 5},
expectedTotalBars: 20,
expectedLongestRun: 20,
},
{
name: "Unsorted Slice",
binEdges: []float64{0, 100, 200, 300, 400, 500},
tsList: []uint32{450, 10, 30, 205, 299},
modalSensitivity: 0.05,
expectedHistogram: []int{2, 0, 2, 0, 1},
expectedFreqCount: map[int32]int32{1: 1, 2: 2},
expectedTotalBars: 3,
expectedLongestRun: 2,
expectedError: false,
},
{
name: "Invalid Bin Edges",
binEdges: []float64{10},
tsList: []uint32{15, 22, 35},
modalSensitivity: 0.05,
expectedHistogram: []int(nil),
expectedFreqCount: map[int32]int32(nil),
expectedTotalBars: 0,
expectedLongestRun: 0,
expectedError: true,
errorContains: "binEdges must contain at least 2 elements",
},
{
name: "Empty Timestamps Slice",
binEdges: []float64{10, 20, 30, 40},
tsList: []uint32{},
modalSensitivity: 0.05,
expectedHistogram: []int(nil),
expectedFreqCount: map[int32]int32(nil),
expectedTotalBars: 0,
expectedLongestRun: 0,
expectedError: true,
errorContains: "timestamp slice must not be empty",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
frequencies, freqCount, totalBars, longestRun, err := createHistogram(test.binEdges, test.tsList, test.modalSensitivity)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, err)
// if test.errorContains != "" {
// require.Contains(err.Error(), test.errorContains)
// }
// check the calculated values
require.EqualValues(test.expectedHistogram, frequencies, "Expected frequencies to be %v, got %v", test.expectedHistogram, frequencies)
require.EqualValues(test.expectedFreqCount, freqCount, "Expected freqCount to be %v, got %v", test.expectedFreqCount, freqCount)
require.EqualValues(test.expectedTotalBars, totalBars, "Expected totalBars to be %v, got %v", test.expectedTotalBars, totalBars)
require.EqualValues(test.expectedLongestRun, longestRun, "Expected longestRun to be %v, got %v", test.expectedLongestRun, longestRun)
})
}
}
func TestGetFrequencyCounts(t *testing.T) {
tests := []struct {
name string
histogram []int
bimodalSensitivity float64
expectedCounts map[int32]int32
totalBars int32
longestRun int32
expectedError bool
}{
{
name: "Simple Flat Histogram",
histogram: []int{2, 2, 2, 2, 2, 2, 2, 2, 2, 2},
bimodalSensitivity: 0.05,
expectedCounts: map[int32]int32{2: 10},
totalBars: 10,
longestRun: 10,
expectedError: false,
},
{
name: "Simple Bimodal Histogram",
histogram: []int{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4},
bimodalSensitivity: 0.05,
expectedCounts: map[int32]int32{2: 10, 4: 10},
totalBars: 20,
longestRun: 20,
expectedError: false,
},
{
name: "Flat Histogram with Gaps",
histogram: []int{2, 0, 2, 0, 2, 0, 2, 0, 2, 0},
bimodalSensitivity: 0.05,
expectedCounts: map[int32]int32{2: 5},
totalBars: 5,
longestRun: 1,
expectedError: false,
},
{
name: "Bimodal Histogram with Gaps",
histogram: []int{2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 4, 4, 4, 4, 0},
bimodalSensitivity: 0.05,
expectedCounts: map[int32]int32{2: 5, 4: 4},
totalBars: 9,
longestRun: 5,
expectedError: false,
},
// histogram: []int{9999, 9950, 10000, 10042, 10001, 9960},
{
name: "Flat Histogram with Closely-Valued Bars",
histogram: []int{900, 901, 899, 900, 899, 902, 900, 901},
bimodalSensitivity: 0.05,
// bin size = ceiling(largestnum * 0.05) = 902 * 0.05 = 45.1 = 46
// first bin:
// math.Floor(float64(frequency)/binSize) * binSize = 900/46 = 19.5652 = 19 * 46 = 874
// all values within 46 of 874 will be grouped together
expectedCounts: map[int32]int32{874: 8},
totalBars: 8,
longestRun: 8,
expectedError: false,
},
{
name: "Bimodal Histogram with Closely-Valued Bars",
histogram: []int{100, 105, 100, 300, 305, 300},
bimodalSensitivity: 0.1,
// bin size = ceiling(largestnum * 0.1) = 305 * 0.1 = 30.5 = 31
// first bin:
// math.Floor(float64(frequency)/binSize) * binSize = 100/31 = 3.2258 = 3 * 31 = 93
// second bin:
// math.Floor(float64(frequency)/binSize) * binSize = 300/31 = 9.6774 = 9 * 31 = 279
expectedCounts: map[int32]int32{93: 3, 279: 3},
totalBars: 6,
longestRun: 6,
expectedError: false,
},
{
name: "Multimodal Histogram",
histogram: []int{100, 105, 100, 300, 305, 300, 500, 505, 500, 700, 705, 700},
bimodalSensitivity: 0.1,
// bin size = ceiling(largestnum * 0.1) = 705 * 0.1 = 70.5 = 71
// first bin:
// math.Floor(float64(frequency)/binSize) * binSize = 100/71 = 1.4085 = 1 * 71 = 71
// second bin:
// math.Floor(float64(frequency)/binSize) * binSize = 300/71 = 4.2254 = 4 * 71 = 284
// third bin:
// math.Floor(float64(frequency)/binSize) * binSize = 500/71 = 7.0423 = 7 * 71 = 497
// fourth bin:
// math.Floor(float64(frequency)/binSize) * binSize = 700/71 = 9.8592 = 9 * 71 = 639
expectedCounts: map[int32]int32{71: 3, 284: 3, 497: 3, 639: 3},
totalBars: 12,
longestRun: 12,
expectedError: false,
},
{
name: "High Sensitivity - Fine Grained",
histogram: []int{100, 100, 101, 101, 102, 200, 200, 201, 201, 202, 300, 300, 301, 301, 302},
bimodalSensitivity: 0.001, // High sensitivity to minor variations
expectedCounts: map[int32]int32{100: 2, 101: 2, 102: 1, 200: 2, 201: 2, 202: 1, 300: 2, 301: 2, 302: 1},
totalBars: 15,
longestRun: 15, // Maximum consecutive run is 2
},
{
name: "Low Sensitivity - Coarse Grained",
histogram: []int{100, 100, 101, 101, 102, 200, 200, 201, 201, 202, 300, 300, 301, 301, 302},
bimodalSensitivity: 0.1, // Lower sensitivity, more forgiving to variations
// bin size = ceiling(largestnum * 0.1) = 302 * 0.1 = 30.2 = 31
// first bin:
// math.Floor(float64(frequency)/binSize) * binSize = 100/31 = 3.2258 = 3 * 31 = 93
// second bin:
// math.Floor(float64(frequency)/binSize) * binSize = 200/31 = 6.4516 = 6 * 31 = 186
// third bin:
// math.Floor(float64(frequency)/binSize) * binSize = 300/31 = 9.6774 = 9 * 31 = 279
expectedCounts: map[int32]int32{93: 5, 186: 5, 279: 5},
totalBars: 15,
longestRun: 15,
},
{
name: "Wraparound Longest Run with Gap",
histogram: []int{2002, 2000, 1999, 2005, 1990, 0, 0, 0, 0, 0, 0, 2000, 2001, 2002, 2003, 2004},
bimodalSensitivity: 0.05,
// bin size = ceiling(largestnum * 0.05) = 2005 * 0.05 = 100.25 = 101
// first bin:
// math.Floor(float64(frequency)/binSize) * binSize = 2000/101 = 19.8019 = 19 * 101 = 1919
expectedCounts: map[int32]int32{1919: 10},
totalBars: 10,
longestRun: 10,
expectedError: false,
},
{
name: "Empty Histogram",
histogram: []int{},
bimodalSensitivity: 0.05,
expectedCounts: nil,
totalBars: 0,
longestRun: 0,
expectedError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
// run the function
freqCounts, totalBars, longestRun, err := getFrequencyCounts(test.histogram, test.bimodalSensitivity)
// check if an error was expected
require.EqualValues(test.expectedError, err != nil, "Expected error to be %v, got %v", test.expectedError, false)
// check the calculated values
require.EqualValues(test.expectedCounts, freqCounts, "Expected freqCounts to be %v, got %v", test.expectedCounts, freqCounts)
require.EqualValues(test.totalBars, totalBars, "Expected totalBars to be %v, got %v", test.totalBars, totalBars)
require.EqualValues(test.longestRun, longestRun, "Expected longestRun to be %v, got %v", test.longestRun, longestRun)
})
}
}