changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -0,0 +1,180 @@
use domain::correlation::{Adjustment, PValue};
fn p_values(values: &[f64]) -> Vec<PValue> {
values
.iter()
.map(|value| PValue::new(*value).unwrap())
.collect()
}
#[test]
fn the_step_up_procedure_keeps_everything_below_the_largest_passing_rank() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.001, 0.008, 0.039, 0.041, 0.42]));
assert_eq!(held, [true, true, true, true, false]);
}
#[test]
fn a_result_below_its_own_threshold_is_carried_by_a_stronger_one() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.001, 0.079]));
assert_eq!(
held,
[true, true],
"0.079 exceeds rank 1's threshold but rank 2 passes, so both hold"
);
}
#[test]
fn order_of_the_set_does_not_change_who_holds_up() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.42, 0.041, 0.001, 0.039, 0.008]));
assert_eq!(held, [false, true, true, true, true]);
}
#[test]
fn nothing_holds_up_when_nothing_is_small_enough() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.4, 0.5, 0.6]));
assert_eq!(held, [false, false, false]);
}
#[test]
fn a_larger_set_makes_each_result_work_harder() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let alone = adjustment.holds_up(&p_values(&[0.04]));
let among_ten = adjustment.holds_up(&p_values(&[
0.04, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
]));
assert_eq!(alone, [true]);
assert!(!among_ten[0], "0.04 needs to beat 0.01 once ten are tested");
}
#[test]
fn the_threshold_is_whatever_it_is_configured_to_be() {
let strict = Adjustment::controlling_false_discovery_at(0.01);
let loose = Adjustment::controlling_false_discovery_at(0.20);
let borderline = p_values(&[0.03, 0.5]);
assert_eq!(strict.holds_up(&borderline), [false, false]);
assert_eq!(loose.holds_up(&borderline), [true, false]);
}
#[test]
fn an_empty_set_holds_nothing_up_and_does_not_panic() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
assert!(adjustment.holds_up(&[]).is_empty());
}
use domain::correlation::{CorrelationStrategy, Family, Tested};
fn tested(family: Family, strategy: CorrelationStrategy, p: f64) -> Tested {
Tested {
family,
strategy,
p_value: PValue::new(p).unwrap(),
}
}
fn measurement(p: f64) -> Tested {
tested(Family::Measurements, CorrelationStrategy::Spearman, p)
}
#[test]
fn a_result_is_corrected_against_the_others_in_its_own_group() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up_across(&[measurement(0.04), measurement(0.5)]);
assert_eq!(held, [true, false]);
}
#[test]
fn how_many_activities_are_kept_does_not_change_a_measurement_result() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for _ in 0..20 {
entries.push(tested(
Family::Activities,
CorrelationStrategy::MeanDifference,
0.5,
));
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"a measurement must not be corrected against activities"
);
}
#[test]
fn measuring_the_same_thing_several_ways_is_not_several_hypotheses() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for other in [CorrelationStrategy::Pearson, CorrelationStrategy::Kendall] {
for _ in 0..10 {
entries.push(tested(Family::Measurements, other, 0.5));
}
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"correcting across strategies would penalise measuring carefully"
);
}
#[test]
fn more_of_the_same_question_does_make_a_result_work_harder() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04)];
for _ in 0..20 {
entries.push(measurement(0.5));
}
let held = adjustment.holds_up_across(&entries);
assert!(
!held[0],
"twenty more measurements under the same strategy is twenty more comparisons"
);
}
#[test]
fn two_families_are_corrected_apart_even_under_one_strategy() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for _ in 0..20 {
entries.push(tested(
Family::Activities,
CorrelationStrategy::Spearman,
0.5,
));
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"a Family is a question: activities never dilute a measurement, whichever strategy scored them"
);
}

View File

@@ -0,0 +1,58 @@
use domain::entry::Date;
use domain::moon::MoonPhase;
fn on(day: &str) -> MoonPhase {
MoonPhase::on(&Date::from_persistence(day.parse().unwrap()))
}
#[test]
fn a_total_solar_eclipse_happens_at_a_new_moon() {
for day in ["2017-08-21", "2024-04-08"] {
let illumination = on(day).illumination();
assert!(
illumination < 0.02,
"{day} should be dark, got {illumination}"
);
}
}
#[test]
fn a_total_lunar_eclipse_happens_at_a_full_moon() {
for day in ["2000-01-21", "2018-01-31", "2019-01-21", "2022-05-16"] {
let illumination = on(day).illumination();
assert!(
illumination > 0.98,
"{day} should be full, got {illumination}"
);
}
}
#[test]
fn illumination_never_leaves_its_bounds() {
let mut day = Date::from_persistence("2024-01-01".parse().unwrap());
for _ in 0..800 {
let illumination = MoonPhase::on(&day).illumination();
assert!((0.0..=1.0).contains(&illumination), "got {illumination}");
day = day.next();
}
}
#[test]
fn the_same_illumination_is_told_apart_by_waxing_and_waning() {
let waxing = on("2024-04-15");
let waning = on("2024-04-30");
assert!((waxing.illumination() - 0.5).abs() < 0.15);
assert!((waning.illumination() - 0.5).abs() < 0.15);
assert_eq!(waxing.name(), "First Quarter");
assert_eq!(waning.name(), "Last Quarter");
}
#[test]
fn the_extremes_are_named_for_what_they_are() {
assert_eq!(on("2024-04-08").name(), "New");
assert_eq!(on("2022-05-16").name(), "Full");
}

View File

@@ -0,0 +1,107 @@
use domain::correlation::{CorrelationStrategy, Observation, PValue};
use domain::entry::{DayMood, Mood};
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn mood_of(value: u8) -> Mood {
Mood::try_from(value).unwrap()
}
fn loosely_rising() -> Vec<Observation> {
let moods = [3, 1, 4, 2, 3, 5, 2, 4, 3, 5, 4, 3];
moods
.iter()
.enumerate()
.map(|(index, mood)| Observation::new(index as f64 + 1.0, day(mood_of(*mood))))
.collect()
}
fn significance(strategy: CorrelationStrategy, observations: &[Observation]) -> f64 {
strategy
.significance(observations)
.expect("a scored series has a p-value")
.value()
}
#[test]
fn the_familiar_thresholds_come_out_of_the_normal_tail() {
assert!((PValue::from_standard_score(1.96).value() - 0.05).abs() < 1e-4);
assert!((PValue::from_standard_score(1.645).value() - 0.10).abs() < 1e-4);
}
#[test]
fn a_standard_score_of_nothing_is_certain_to_be_nothing() {
assert!((PValue::from_standard_score(0.0).value() - 1.0).abs() < 1e-9);
}
#[test]
fn direction_does_not_change_how_surprising_a_score_is() {
let positive = PValue::from_standard_score(2.5).value();
let negative = PValue::from_standard_score(-2.5).value();
assert!((positive - negative).abs() < 1e-12);
}
#[test]
fn each_strategy_turns_its_own_statistic_into_a_p_value() {
let observations = loosely_rising();
let pearson = significance(CorrelationStrategy::Pearson, &observations);
let spearman = significance(CorrelationStrategy::Spearman, &observations);
let kendall = significance(CorrelationStrategy::Kendall, &observations);
assert!((pearson - 0.198_050_956_8).abs() < 1e-4, "got {pearson}");
assert!((spearman - 0.241_732_162_6).abs() < 1e-4, "got {spearman}");
assert!((kendall - 0.201_603_352_7).abs() < 1e-4, "got {kendall}");
}
#[test]
fn a_relationship_that_holds_all_the_way_is_hard_to_put_down_to_chance() {
let tight: Vec<Observation> = (1..=12)
.map(|day_number| {
let mood = mood_of(((day_number - 1) / 3 + 1) as u8);
Observation::new(day_number as f64, day(mood))
})
.collect();
assert!(significance(CorrelationStrategy::Pearson, &tight) < 0.001);
assert!(significance(CorrelationStrategy::Spearman, &tight) < 0.001);
assert!(significance(CorrelationStrategy::Kendall, &tight) < 0.001);
}
#[test]
fn a_mean_difference_is_judged_by_the_spread_within_each_group() {
let observations: Vec<Observation> = [
(1.0, Mood::Good),
(1.0, Mood::Rad),
(1.0, Mood::Good),
(1.0, Mood::Rad),
(0.0, Mood::Bad),
(0.0, Mood::Meh),
(0.0, Mood::Bad),
(0.0, Mood::Awful),
]
.into_iter()
.map(|(value, mood)| Observation::new(value, day(mood)))
.collect();
let p = significance(CorrelationStrategy::MeanDifference, &observations);
assert!((p - 0.000_000_573_3).abs() < 1e-9, "got {p}");
}
#[test]
fn a_series_that_cannot_be_scored_has_nothing_to_report() {
let unvarying: Vec<Observation> = (1..=10)
.map(|day_number| Observation::new(day_number as f64, day(Mood::Meh)))
.collect();
assert!(
CorrelationStrategy::Spearman
.significance(&unvarying)
.is_none()
);
}

View File

@@ -0,0 +1,117 @@
use domain::correlation::{CorrelationStrategy, Observation};
use domain::entry::{DayMood, Mood};
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn observations(pairs: &[(f64, Mood)]) -> Vec<Observation> {
pairs
.iter()
.map(|(value, mood)| Observation::new(*value, day(*mood)))
.collect()
}
fn spearman(pairs: &[(f64, Mood)]) -> Option<f64> {
CorrelationStrategy::Spearman
.score(&observations(pairs))
.map(|coefficient| coefficient.value())
}
#[test]
fn a_perfectly_ordered_pair_of_series_scores_one() {
let score = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
])
.unwrap();
assert!((score - 1.0).abs() < 1e-12);
}
#[test]
fn a_perfectly_inverted_pair_of_series_scores_minus_one() {
let score = spearman(&[
(1.0, Mood::Rad),
(2.0, Mood::Good),
(3.0, Mood::Meh),
(4.0, Mood::Bad),
])
.unwrap();
assert!((score + 1.0).abs() < 1e-12);
}
#[test]
fn only_the_order_matters_not_the_distance() {
let gentle = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
])
.unwrap();
let one_enormous_day = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(100_000.0, Mood::Good),
])
.unwrap();
assert!((gentle - one_enormous_day).abs() < f64::EPSILON);
}
#[test]
fn tied_moods_share_their_average_rank_rather_than_taking_the_shortcut() {
let score = spearman(&[
(1_000.0, Mood::Meh),
(2_000.0, Mood::Meh),
(3_000.0, Mood::Meh),
(4_000.0, Mood::Good),
(5_000.0, Mood::Good),
(6_000.0, Mood::Rad),
])
.unwrap();
let tie_corrected = 0.925_820_099_8;
let textbook_shortcut = 0.928_571_428_6;
assert!(
(score - tie_corrected).abs() < 1e-9,
"expected the tie-corrected coefficient, got {score}"
);
assert!(
(score - textbook_shortcut).abs() > 1e-9,
"this is the 6*d^2 shortcut, which is wrong when ranks are tied"
);
}
#[test]
fn a_day_count_below_two_scores_nothing() {
assert!(spearman(&[(1.0, Mood::Good)]).is_none());
assert!(spearman(&[]).is_none());
}
#[test]
fn an_unvarying_series_scores_nothing() {
let same_mood_every_day = spearman(&[
(1.0, Mood::Meh),
(2.0, Mood::Meh),
(3.0, Mood::Meh),
(4.0, Mood::Meh),
]);
let same_value_every_day = spearman(&[
(5_000.0, Mood::Awful),
(5_000.0, Mood::Bad),
(5_000.0, Mood::Good),
(5_000.0, Mood::Rad),
]);
assert!(same_mood_every_day.is_none());
assert!(same_value_every_day.is_none());
}

View File

@@ -0,0 +1,180 @@
use domain::activity::ActivityId;
use domain::correlation::{CorrelationInput, CorrelationStrategy, Observation, SeriesShape};
use domain::entry::{DayMood, Mood};
use domain::metric::MetricKind;
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn observations(pairs: &[(f64, Mood)]) -> Vec<Observation> {
pairs
.iter()
.map(|(value, mood)| Observation::new(*value, day(*mood)))
.collect()
}
fn score(strategy: CorrelationStrategy, pairs: &[(f64, Mood)]) -> Option<f64> {
strategy
.score(&observations(pairs))
.map(|coefficient| coefficient.value())
}
const TIED: [(f64, Mood); 6] = [
(1_000.0, Mood::Meh),
(2_000.0, Mood::Meh),
(3_000.0, Mood::Meh),
(4_000.0, Mood::Good),
(5_000.0, Mood::Good),
(6_000.0, Mood::Rad),
];
#[test]
fn kendall_corrects_for_ties_rather_than_counting_pairs_alone() {
let score = score(CorrelationStrategy::Kendall, &TIED).unwrap();
let tau_b = 0.856_348_838_6;
let tau_a = 0.733_333_333_3;
assert!((score - tau_b).abs() < 1e-9, "expected tau-b, got {score}");
assert!(
(score - tau_a).abs() > 1e-9,
"this is tau-a, which overstates disagreement when ranks are tied"
);
}
#[test]
fn kendall_reaches_both_extremes() {
let rising = score(
CorrelationStrategy::Kendall,
&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
],
)
.unwrap();
let falling = score(
CorrelationStrategy::Kendall,
&[
(1.0, Mood::Good),
(2.0, Mood::Meh),
(3.0, Mood::Bad),
(4.0, Mood::Awful),
],
)
.unwrap();
assert!((rising - 1.0).abs() < 1e-12);
assert!((falling + 1.0).abs() < 1e-12);
}
#[test]
fn pearson_measures_the_line_where_the_rank_methods_measure_the_order() {
let curved = [
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(100.0, Mood::Good),
];
let pearson = score(CorrelationStrategy::Pearson, &curved).unwrap();
let spearman = score(CorrelationStrategy::Spearman, &curved).unwrap();
assert!((spearman - 1.0).abs() < 1e-12, "the order is perfect");
assert!(
(pearson - 0.785_026_421).abs() < 1e-9,
"the line is not, got {pearson}"
);
}
#[test]
fn a_mean_difference_is_told_as_a_share_of_the_mood_scale() {
let score = score(
CorrelationStrategy::MeanDifference,
&[
(1.0, Mood::Good),
(1.0, Mood::Rad),
(1.0, Mood::Good),
(1.0, Mood::Rad),
(0.0, Mood::Bad),
(0.0, Mood::Meh),
(0.0, Mood::Bad),
(0.0, Mood::Awful),
],
)
.unwrap();
assert!(
(score - 0.625).abs() < 1e-12,
"two and a half mood points out of four, got {score}"
);
}
#[test]
fn a_mean_difference_needs_days_on_both_sides() {
let only_present = score(
CorrelationStrategy::MeanDifference,
&[(1.0, Mood::Good), (1.0, Mood::Rad)],
);
let only_absent = score(
CorrelationStrategy::MeanDifference,
&[(0.0, Mood::Good), (0.0, Mood::Rad)],
);
assert!(only_present.is_none());
assert!(only_absent.is_none());
}
#[test]
fn a_strategy_scores_one_shape_of_series_and_an_input_has_one() {
assert_eq!(
CorrelationStrategy::MeanDifference.scores(),
SeriesShape::Presence
);
assert_eq!(
CorrelationInput::Activity(ActivityId::generate()).series(),
SeriesShape::Presence
);
for continuous in [
CorrelationStrategy::Pearson,
CorrelationStrategy::Spearman,
CorrelationStrategy::Kendall,
] {
assert_eq!(continuous.scores(), SeriesShape::Continuous);
}
assert_eq!(
CorrelationInput::Metric(MetricKind::Steps).series(),
SeriesShape::Continuous
);
assert_eq!(
CorrelationInput::MoonPhase.series(),
SeriesShape::Continuous
);
}
#[test]
fn only_matching_strategies_are_offered_for_an_input() {
let steps = CorrelationInput::Metric(MetricKind::Steps);
let exercise = CorrelationInput::Activity(ActivityId::generate());
let for_steps: Vec<&str> = CorrelationStrategy::ALL
.into_iter()
.filter(|strategy| strategy.can_score(&steps))
.map(|strategy| strategy.name())
.collect();
let for_exercise: Vec<&str> = CorrelationStrategy::ALL
.into_iter()
.filter(|strategy| strategy.can_score(&exercise))
.map(|strategy| strategy.name())
.collect();
assert_eq!(for_steps, ["pearson", "spearman", "kendall"]);
assert_eq!(for_exercise, ["meanDifference"]);
}