Nucleic-Session: 129BD35A-FB15-4E39-82E2-F476950F60AD Co-authored-by: Nucleic <[email protected]>
106 lines
4.6 KiB
Swift
106 lines
4.6 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import NucleicProtocol
|
|
|
|
@Suite struct ActivityScaleTests {
|
|
private let cal: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.timeZone = TimeZone(identifier: "UTC")!
|
|
return c
|
|
}()
|
|
private let epoch = Date(timeIntervalSince1970: 0)
|
|
/// Build an activity map from a list of per-day counts (each on its own day).
|
|
private func activity(_ counts: [Int]) -> [Date: Int] {
|
|
Dictionary(uniqueKeysWithValues: counts.enumerated().map { i, n in
|
|
(cal.date(byAdding: .day, value: i, to: epoch)!, n)
|
|
})
|
|
}
|
|
|
|
@Test func noActivityIsInert() {
|
|
let scale = ActivityScale(activityByDay: [:])
|
|
#expect(scale.upperBound == 1)
|
|
#expect(scale.intensity(for: 0) == 0)
|
|
// A count can't really occur with no data, but the scale must stay well-defined.
|
|
#expect(scale.intensity(for: 5) == 1.0)
|
|
}
|
|
|
|
@Test func emptyDayHasZeroBrightness() {
|
|
let scale = ActivityScale(activityByDay: activity([1, 2, 3]))
|
|
#expect(scale.intensity(for: 0) == 0)
|
|
}
|
|
|
|
@Test func activeDayIsLiftedToFloor() {
|
|
// The lightest active day sits at the floor, never near-black, so it stays visible.
|
|
let scale = ActivityScale(activityByDay: activity([1, 100, 100, 100]))
|
|
#expect(scale.intensity(for: 1) >= ActivityScale.defaultFloor)
|
|
#expect(scale.intensity(for: 1) > scale.intensity(for: 0))
|
|
}
|
|
|
|
@Test func brightnessIsMonotonicInActivity() {
|
|
let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4, 5, 6, 7, 8]))
|
|
let ramp = (0...8).map { scale.intensity(for: $0) }
|
|
for (lo, hi) in zip(ramp, ramp.dropFirst()) { #expect(hi >= lo) }
|
|
}
|
|
|
|
@Test func relativeToOwnDistribution() {
|
|
// Same absolute count reads very differently against a light vs a heavy user.
|
|
let light = ActivityScale(activityByDay: activity([1, 2, 3, 4])) // busiest normal day = 4
|
|
let heavy = ActivityScale(activityByDay: activity([20, 40, 60, 80]))
|
|
#expect(light.upperBound == 4)
|
|
#expect(heavy.upperBound == 80)
|
|
// 4 messages is a peak day for the light user, a quiet day for the heavy one.
|
|
#expect(light.intensity(for: 4) == 1.0)
|
|
#expect(heavy.intensity(for: 4) < 0.5)
|
|
}
|
|
|
|
@Test func linearBetweenFloorAndFull() {
|
|
let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4])) // upperBound 4
|
|
let floor = ActivityScale.defaultFloor
|
|
#expect(abs(scale.intensity(for: 2) - (floor + (1 - floor) * 0.5)) < 1e-9)
|
|
#expect(abs(scale.intensity(for: 4) - 1.0) < 1e-9)
|
|
}
|
|
|
|
@Test func outliersDoNotCrushTheStandardRange() {
|
|
// Seven ordinary days (2…8) plus one 200-message marathon. The fence is Q3 + 1.5·IQR
|
|
// (= 12.5 here), so the scale tops out at the busiest *non-outlier* day (8) — the
|
|
// marathon clamps to full instead of dragging every normal day toward black.
|
|
let scale = ActivityScale(activityByDay: activity([2, 3, 4, 5, 6, 7, 8, 200]))
|
|
#expect(scale.upperBound == 8)
|
|
#expect(scale.intensity(for: 8) == 1.0) // busiest normal day = full brightness
|
|
#expect(scale.intensity(for: 200) == 1.0) // outlier clamps to the top, doesn't exceed it
|
|
|
|
// Contrast: naive max-scaling (top = 200) would crush the busiest normal day toward the floor.
|
|
let naive = ActivityScale(upperBound: 200)
|
|
#expect(naive.intensity(for: 8) < 0.5)
|
|
#expect(scale.intensity(for: 8) > naive.intensity(for: 8) + 0.4)
|
|
}
|
|
|
|
@Test func noOutliersUsesTheMax() {
|
|
// A tidy distribution has no Tukey outliers, so the bound is just the max.
|
|
let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4]))
|
|
#expect(scale.upperBound == 4)
|
|
}
|
|
|
|
@Test func uniformActivityIsUniformlyBright() {
|
|
// Every day identical ⇒ IQR 0, fence == that count ⇒ all days full brightness.
|
|
let scale = ActivityScale(activityByDay: activity([7, 7, 7, 7, 7]))
|
|
#expect(scale.upperBound == 7)
|
|
#expect(scale.intensity(for: 7) == 1.0)
|
|
}
|
|
|
|
@Test func singleActiveDayIsFullBrightness() {
|
|
let scale = ActivityScale(activityByDay: activity([5]))
|
|
#expect(scale.upperBound == 5)
|
|
#expect(scale.intensity(for: 5) == 1.0)
|
|
}
|
|
|
|
@Test func quantileMatchesType7() {
|
|
// Sanity-check the quantile helper against known values (NumPy/R default, type 7).
|
|
let xs = [1, 2, 3, 4]
|
|
#expect(abs(ActivityScale.quantile(xs, 0.25) - 1.75) < 1e-9)
|
|
#expect(abs(ActivityScale.quantile(xs, 0.75) - 3.25) < 1e-9)
|
|
#expect(ActivityScale.quantile([9], 0.5) == 9)
|
|
}
|
|
}
|