Skip to main content

git_koi/
koi.rs

1//! Reproducible koi development from account observations, independent of rendering.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt;
6
7use chrono::{Datelike, Days, IsoWeek, NaiveDate, Weekday};
8
9use crate::history::{
10    AccountSnapshot, ConflictingRepository, ContributionHistoryError, ContributionSummary,
11    ContributionWeek, LanguageColor, calculate_language_shares, summarize_contributions,
12};
13
14/// Derived inputs for appearance; source observations remain in the account snapshot.
15#[derive(Debug, PartialEq)]
16pub struct KoiDevelopment {
17    /// The stable numeric GitHub account ID.
18    pub identity_seed: u64,
19    /// Each counted language's share of eligible repositories, from zero to one, by name.
20    pub language_shares: BTreeMap<String, f64>,
21    /// GitHub's color for each language in `language_shares`; `None` means it has no color.
22    pub language_colors: BTreeMap<String, Option<LanguageColor>>,
23    /// Unweighted contributions available to the renderer's growth curve.
24    pub total_contributions: u64,
25    /// The date every time-based measurement is taken against.
26    pub observed_on: NaiveDate,
27    /// The longest and the most recent weekly streaks.
28    pub streaks: StreakDevelopment,
29    /// Counts around the observation date that behavior selection reads.
30    pub recent_activity: RecentActivity,
31}
32
33/// Recent activity as of the observation date, as counts rather than judgments.
34#[derive(Debug, Eq, PartialEq)]
35pub struct RecentActivity {
36    /// Days in the run of contribution-free days that ends on the observation date;
37    /// an account that has never contributed counts every observed day.
38    pub quiet_day_count: u32,
39    /// Contributions in the seven days ending on the observation date.
40    pub recent_contributions: u64,
41    /// Contributions in each complete Monday-Sunday week, oldest first. The
42    /// observation week is still in progress and is left out.
43    pub complete_week_contributions: Vec<u64>,
44    /// Each observed day of the observation week, from Monday through the
45    /// observation date.
46    pub observation_week_contributions: Vec<u64>,
47}
48
49/// Weekly streaks: runs of consecutive Monday-Sunday weeks with at least one active day.
50#[derive(Debug, Eq, PartialEq)]
51pub struct StreakDevelopment {
52    /// The earliest longest run, replaced only by a strictly longer run.
53    pub longest: Vec<ContributionWeek>,
54    /// None means no observed active week, not missing history.
55    pub recent: Option<RecentStreak>,
56}
57
58/// The most recent streak, still running or ended.
59#[derive(Debug, Eq, PartialEq)]
60pub enum RecentStreak {
61    /// Still running through the observation week; carries its weeks, oldest first.
62    Current(Vec<ContributionWeek>),
63    /// Ended by a completed week without activity.
64    Ended {
65        /// The streak's weeks, oldest first.
66        weeks: Vec<ContributionWeek>,
67        /// The Monday after the first wholly inactive week has completed.
68        /// Further inactivity does not move this date or restart a fade.
69        ended_on: NaiveDate,
70    },
71}
72
73/// Derive one account's identity, language composition, and contribution development.
74///
75/// Any active day sustains a Monday-Sunday weekly streak. A completed inactive
76/// week ends it; the observation week is still in progress and cannot end it.
77/// Each streak retains weekly participation rather than just its length.
78/// Returning activity replaces the recent streak without discarding the record.
79///
80/// History owns input validation and language eligibility. Its errors propagate;
81/// unavailable history never becomes an undeveloped koi. Confirmed empty history
82/// produces zero contributions and no streaks. Every counted language needs a
83/// recorded color, so a snapshot without colors fails instead of producing an
84/// uncolored koi. Inputs are borrowed without mutation; this operation performs
85/// no I/O and reads no clock. It assigns no visual thresholds.
86pub fn derive_koi_development(
87    account_snapshot: &AccountSnapshot,
88) -> Result<KoiDevelopment, KoiDevelopmentError> {
89    let contribution_summary = summarize_contributions(account_snapshot)
90        .map_err(KoiDevelopmentError::ContributionHistory)?;
91    let language_shares =
92        calculate_language_shares(account_snapshot).map_err(KoiDevelopmentError::Repositories)?;
93    let recorded_colors = account_snapshot
94        .language_colors
95        .as_ref()
96        .ok_or(KoiDevelopmentError::MissingLanguageColors)?;
97    let language_colors = language_shares
98        .keys()
99        .map(|language_name| {
100            recorded_colors
101                .get(language_name)
102                .map(|language_color| (language_name.clone(), *language_color))
103                .ok_or_else(|| KoiDevelopmentError::MissingLanguageColor(language_name.clone()))
104        })
105        .collect::<Result<_, _>>()?;
106    Ok(KoiDevelopment {
107        identity_seed: account_snapshot.account_id,
108        language_shares,
109        language_colors,
110        total_contributions: contribution_summary.total_contributions,
111        observed_on: contribution_summary.observed_on,
112        streaks: calculate_streak_development(&contribution_summary),
113        recent_activity: measure_recent_activity(&contribution_summary),
114    })
115}
116
117/// Young koi show their language pigments pale until this total.
118pub const FULL_COLOR_CONTRIBUTIONS: u64 = 500;
119/// Young koi grow into the adult head, eyes, body width, and fins by this total.
120pub const YOUNG_SHAPE_CONTRIBUTIONS: u64 = 1_500;
121/// The adult form: past this total the body keeps lengthening and the fins and tail
122/// grow, but the anatomy is settled.
123pub const ADULT_CONTRIBUTIONS: u64 = 12_750;
124/// Growth stops here; larger totals keep the maximum form.
125pub const MAX_GROWTH_CONTRIBUTIONS: u64 = 100_000;
126
127/// Map total contributions to a growth fraction from zero through one.
128/// Gains diminish until MAX_GROWTH_CONTRIBUTIONS; larger totals stay at one.
129/// The caller supplies the observed total; elapsed time does not affect growth.
130pub fn calculate_growth_fraction(total_contributions: u64) -> f64 {
131    const CONTRIBUTION_SCALE: f64 = 50.0;
132    let bounded_total = total_contributions.min(MAX_GROWTH_CONTRIBUTIONS);
133    (1.0 + bounded_total as f64 / CONTRIBUTION_SCALE).log2()
134        / (1.0 + MAX_GROWTH_CONTRIBUTIONS as f64 / CONTRIBUTION_SCALE).log2()
135}
136
137/// Why a snapshot produced no development. Each message names the repair.
138#[derive(Debug, Eq, PartialEq)]
139pub enum KoiDevelopmentError {
140    /// The contribution history failed its rules.
141    ContributionHistory(ContributionHistoryError),
142    /// Two observations of one repository disagree.
143    Repositories(ConflictingRepository),
144    /// The snapshot predates language colors.
145    MissingLanguageColors,
146    /// The snapshot has no color entry for this counted language.
147    MissingLanguageColor(String),
148}
149
150impl fmt::Display for KoiDevelopmentError {
151    fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            Self::ContributionHistory(history_error) => write!(output_formatter, "{history_error}"),
154            Self::Repositories(repository_error) => write!(output_formatter, "{repository_error}"),
155            Self::MissingLanguageColors => write!(
156                output_formatter,
157                "the snapshot has no language colors; collect it again with `git-koi collect`"
158            ),
159            Self::MissingLanguageColor(language_name) => write!(
160                output_formatter,
161                "the snapshot has no color for {language_name}; collect it again with `git-koi collect`"
162            ),
163        }
164    }
165}
166
167impl Error for KoiDevelopmentError {
168    fn source(&self) -> Option<&(dyn Error + 'static)> {
169        match self {
170            Self::ContributionHistory(history_error) => Some(history_error),
171            Self::Repositories(repository_error) => Some(repository_error),
172            Self::MissingLanguageColors | Self::MissingLanguageColor(_) => None,
173        }
174    }
175}
176
177fn measure_recent_activity(contribution_summary: &ContributionSummary) -> RecentActivity {
178    const RECENT_DAY_COUNT: u64 = 7;
179    const DAYS_PER_WEEK: u64 = 7;
180    let observed_on = contribution_summary.observed_on;
181    let daily_counts = &contribution_summary.daily_counts;
182    let quiet_day_count = daily_counts
183        .values()
184        .rev()
185        .take_while(|&&contribution_count| contribution_count == 0)
186        .count() as u32;
187    let recent_start = observed_on
188        .checked_sub_days(Days::new(RECENT_DAY_COUNT - 1))
189        .expect("observation dates are far from the calendar's start");
190    let recent_contributions = daily_counts
191        .range(recent_start..=observed_on)
192        .map(|(_, &contribution_count)| u64::from(contribution_count))
193        .sum();
194    let mut weekly_totals: BTreeMap<IsoWeek, (u64, u64)> = BTreeMap::new();
195    for (contribution_date, &contribution_count) in daily_counts {
196        let (week_contributions, week_day_count) = weekly_totals
197            .entry(contribution_date.iso_week())
198            .or_default();
199        *week_contributions += u64::from(contribution_count);
200        *week_day_count += 1;
201    }
202    let observation_week = observed_on.iso_week();
203    let observation_monday = observed_on
204        .checked_sub_days(Days::new(u64::from(
205            observed_on.weekday().num_days_from_monday(),
206        )))
207        .expect("observation dates are far from the calendar's start");
208    RecentActivity {
209        quiet_day_count,
210        recent_contributions,
211        complete_week_contributions: weekly_totals
212            .into_iter()
213            .filter(|&(iso_week, (_, week_day_count))| {
214                week_day_count == DAYS_PER_WEEK && iso_week != observation_week
215            })
216            .map(|(_, (week_contributions, _))| week_contributions)
217            .collect(),
218        observation_week_contributions: daily_counts
219            .range(observation_monday..=observed_on)
220            .map(|(_, &contribution_count)| u64::from(contribution_count))
221            .collect(),
222    }
223}
224
225fn calculate_streak_development(contribution_summary: &ContributionSummary) -> StreakDevelopment {
226    let mut longest_week_range = 0..0;
227    let mut recent_week_range = 0..0;
228    let mut current_start_week_index = None;
229    let mut ended_on = None;
230    let observation_week = contribution_summary.observed_on.iso_week();
231    for (week_index, contribution_week) in contribution_summary.weeks.iter().enumerate() {
232        if contribution_week.active_day_count > 0 {
233            let start_week_index = *current_start_week_index.get_or_insert(week_index);
234            recent_week_range = start_week_index..week_index + 1;
235            ended_on = None;
236            if recent_week_range.len() > longest_week_range.len() {
237                longest_week_range = recent_week_range.clone();
238            }
239        } else if contribution_week.iso_week != observation_week
240            && current_start_week_index.take().is_some()
241        {
242            let inactive_sunday = NaiveDate::from_isoywd_opt(
243                contribution_week.iso_week.year(),
244                contribution_week.iso_week.week(),
245                Weekday::Sun,
246            )
247            .expect("a completed observation week has a Sunday");
248            ended_on = Some(
249                inactive_sunday
250                    .succ_opt()
251                    .expect("a completed week precedes the observation date"),
252            );
253        }
254    }
255    let recent_streak = if recent_week_range.is_empty() {
256        None
257    } else {
258        let recent_weeks = contribution_summary.weeks[recent_week_range].to_vec();
259        Some(match ended_on {
260            Some(ended_on) => RecentStreak::Ended {
261                weeks: recent_weeks,
262                ended_on,
263            },
264            None => RecentStreak::Current(recent_weeks),
265        })
266    };
267    StreakDevelopment {
268        longest: contribution_summary.weeks[longest_week_range].to_vec(),
269        recent: recent_streak,
270    }
271}