diff --git a/iris/analysis/activity_timeline.py b/iris/analysis/activity_timeline.py index cec895d..971e819 100644 --- a/iris/analysis/activity_timeline.py +++ b/iris/analysis/activity_timeline.py @@ -147,11 +147,16 @@ def calculate_activity_timeline( classified = classify_commit(c) intent_dist[classified.intent.value] += 1 - # Origin distribution - origin_dist: dict[str, int] = defaultdict(int) - for c in wc: - origin = classify_origin(c) - origin_dist[origin.value] += 1 + # Origin distribution (only meaningful with enough commits — same + # floor as stabilization; a single AI-tagged commit in an otherwise + # quiet week shouldn't be able to swing weekly AI-adoption to 100%). + origin_dist: dict[str, int] = {} + if total_commits >= MIN_COMMITS_FOR_RATIO: + origin_counts: dict[str, int] = defaultdict(int) + for c in wc: + origin = classify_origin(c) + origin_counts[origin.value] += 1 + origin_dist = dict(origin_counts) # Stabilization and churn (only meaningful with enough commits) stab_ratio = None diff --git a/tests/test_activity_timeline.py b/tests/test_activity_timeline.py new file mode 100644 index 0000000..c2fdb38 --- /dev/null +++ b/tests/test_activity_timeline.py @@ -0,0 +1,82 @@ +"""Tests for activity_timeline's weekly origin-distribution sample floor (#189). + +A week's origin_distribution (which drives weekly AI-adoption %) previously +had no minimum commit count, unlike stabilization_ratio — a single AI-tagged +commit in an otherwise quiet week could swing that week to 100% AI adoption, +a statistically meaningless spike that leaked into the org timeline chart as +visible noise. + +Runnable as: `python -m pytest tests/test_activity_timeline.py -v` +""" + +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from iris.analysis.activity_timeline import ( + MIN_COMMITS_FOR_RATIO, + calculate_activity_timeline, +) +from iris.models.commit import Commit + + +def _commit(day: int, attribution_trailers: list[str] | None = None) -> Commit: + return Commit( + hash=f"h{day}", + author="Alice", + date=datetime(2026, 1, day, tzinfo=timezone.utc), + attribution_trailers=attribution_trailers or [], + ) + + +def test_week_below_min_commits_has_empty_origin_distribution(): + # Week A (Jan 1): a single AI-tagged commit — below MIN_COMMITS_FOR_RATIO. + # Week B (Jan 15-17): three human commits — a two-week gap safely clears + # any ISO-week-boundary ambiguity between the two groups. + commits = [ + _commit(1, attribution_trailers=["copilot@users.noreply.github.com"]), + _commit(15), + _commit(16), + _commit(17), + ] + result = calculate_activity_timeline(commits, churn_days=14) + assert result is not None + + week_a = next(w for w in result.weeks if w.commits == 1) + assert week_a.origin_distribution == {} + assert week_a.stabilization_ratio is None + + +def test_week_at_min_commits_has_populated_origin_distribution(): + commits = [ + _commit(1, attribution_trailers=["copilot@users.noreply.github.com"]), + _commit(2), + _commit(3), + _commit(15), + ] + assert len([c for c in commits if c.date.day <= 3]) == MIN_COMMITS_FOR_RATIO + + result = calculate_activity_timeline(commits, churn_days=14) + assert result is not None + + week_a = next(w for w in result.weeks if w.commits == MIN_COMMITS_FOR_RATIO) + assert sum(week_a.origin_distribution.values()) == MIN_COMMITS_FOR_RATIO + assert week_a.origin_distribution.get("AI_ASSISTED") == 1 + + +if __name__ == "__main__": + tests = [fn for name, fn in globals().items() if name.startswith("test_")] + failed = 0 + for fn in tests: + try: + fn() + print(f"ok {fn.__name__}") + except AssertionError: + failed += 1 + print(f"FAIL {fn.__name__}") + if failed: + print(f"\n{failed} failure(s)") + sys.exit(1) + print(f"\n{len(tests)} tests passed")