feat: implement repo scoring pipeline (#61) (#95)

* feat: add prediction ledger output (#64) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: implement repo scoring pipeline (#61) Add scripts/score_repos.py that scores crawled repos 0-100 on topic relevance using star count, velocity, language match, topic overlap, and age signals. Reads config from squadscope.topic.yml scoring section and filters repos below min_relevance_score threshold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 19, 2026 at 15:45 UTC 4436ebf24b8dab34377b17e659eed1b4ee5ac20b
4 files changed +1206
scripts/prediction_ledger.py new
+305
@@ -0,0 +1,305 @@
1 +#!/usr/bin/env python3
2 +"""Generate prediction ledger entries from analyzed summaries and raw data.
3 +
4 +Reads an analyzed summary markdown and its corresponding raw JSON to produce
5 +3-5 heuristic predictions about repos likely to gain momentum. Predictions
6 +are appended to a per-topic JSONL file for later validation.
7 +
8 +Usage:
9 + python scripts/prediction_ledger.py [--input FILE] [--topic TOPIC] [--raw FILE]
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +import argparse
15 +import json
16 +import re
17 +from pathlib import Path
18 +from typing import Any
19 +
20 +from scripts.topic_paths import analyzed_dir, metrics_dir, raw_dir
21 +
22 +FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n(.*)\Z", re.DOTALL)
23 +WEEK_PATTERN = re.compile(r"\d{4}-W\d{2}")
24 +REPO_LINK_PATTERN = re.compile(
25 + r"\[(?P<full_name>[^\]]+/[^\]]+)\]\(https://github\.com/[^\)]+\)"
26 +)
27 +
28 +PREDICTION_TYPES = [
29 + "rising_star",
30 + "emerging_topic",
31 + "momentum_shift",
32 + "breakout_candidate",
33 + "declining_signal",
34 +]
35 +
36 +MAX_PREDICTIONS = 5
37 +MIN_PREDICTIONS = 3
38 +
39 +
40 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
41 + parser = argparse.ArgumentParser(
42 + description="Generate prediction ledger entries from analyzed summaries."
43 + )
44 + parser.add_argument(
45 + "--input",
46 + default=None,
47 + help="Path to analyzed summary markdown (data/analyzed/{topic}/YYYY-WNN-summary.md).",
48 + )
49 + parser.add_argument(
50 + "--topic",
51 + default=None,
52 + help="Topic ID for path resolution. Defaults to general.",
53 + )
54 + parser.add_argument(
55 + "--raw",
56 + default=None,
57 + help="Path to raw JSON data. Inferred from summary week if not given.",
58 + )
59 + return parser.parse_args(argv)
60 +
61 +
62 +def find_latest_summary(topic_id: str | None) -> Path:
63 + """Find the most recent analyzed summary for a topic."""
64 + search = analyzed_dir(topic_id)
65 + candidates = sorted(search.glob("*-summary.md"))
66 + if not candidates:
67 + raise FileNotFoundError(f"No summaries found in {search}")
68 + return candidates[-1]
69 +
70 +
71 +def extract_week(text: str) -> str | None:
72 + """Extract YYYY-WNN week identifier from text."""
73 + match = WEEK_PATTERN.search(text)
74 + return match.group(0) if match else None
75 +
76 +
77 +def infer_raw_path(summary_path: Path, topic_id: str | None) -> Path:
78 + """Infer the raw JSON path from the summary filename."""
79 + week = extract_week(summary_path.name)
80 + if not week:
81 + raise ValueError(f"Cannot infer week from {summary_path.name}")
82 + return raw_dir(topic_id) / f"{week}.json"
83 +
84 +
85 +def parse_summary(content: str) -> dict[str, Any]:
86 + """Parse an analyzed summary markdown into frontmatter and body."""
87 + match = FRONTMATTER_PATTERN.match(content)
88 + if not match:
89 + return {"frontmatter": {}, "body": content}
90 +
91 + fm_text, body = match.group(1), match.group(2)
92 + frontmatter: dict[str, Any] = {}
93 + for line in fm_text.splitlines():
94 + if ":" in line:
95 + key, _, value = line.partition(":")
96 + value = value.strip().strip('"').strip("'")
97 + frontmatter[key.strip()] = value
98 + return {"frontmatter": frontmatter, "body": body}
99 +
100 +
101 +def extract_repos_from_summary(body: str) -> list[str]:
102 + """Extract repo full_names mentioned in the summary body."""
103 + seen: set[str] = set()
104 + repos: list[str] = []
105 + for match in REPO_LINK_PATTERN.finditer(body):
106 + name = match.group("full_name")
107 + if name not in seen:
108 + seen.add(name)
109 + repos.append(name)
110 + return repos
111 +
112 +
113 +def load_raw_data(raw_path: Path) -> dict[str, Any]:
114 + """Load and return raw JSON data."""
115 + with open(raw_path, encoding="utf-8") as f:
116 + return json.load(f)
117 +
118 +
119 +def build_repo_index(raw_data: dict[str, Any]) -> dict[str, dict[str, Any]]:
120 + """Index repos from raw data by full_name for quick lookup."""
121 + index: dict[str, dict[str, Any]] = {}
122 + for section in ("new_repos", "trending_repos"):
123 + for repo in raw_data.get(section, []):
124 + full_name = repo.get("full_name", "")
125 + if full_name:
126 + entry = index.get(full_name, {})
127 + entry.update(repo)
128 + entry["_source"] = section
129 + index[full_name] = entry
130 + return index
131 +
132 +
133 +def score_rising_star(repo: dict[str, Any]) -> float:
134 + """Score a repo for rising_star potential."""
135 + stars = repo.get("stars", 0)
136 + is_new = repo.get("_source") == "new_repos"
137 + # High stars on a new repo is a strong signal
138 + if is_new and stars >= 1000:
139 + return min(0.9, 0.5 + (stars / 10000))
140 + if is_new and stars >= 100:
141 + return min(0.7, 0.3 + (stars / 5000))
142 + if stars >= 5000:
143 + return 0.4
144 + return 0.2
145 +
146 +
147 +def score_breakout_candidate(repo: dict[str, Any]) -> float:
148 + """Score a repo for breakout_candidate potential."""
149 + stars = repo.get("stars", 0)
150 + forks = repo.get("forks", 0)
151 + is_new = repo.get("_source") == "new_repos"
152 + fork_ratio = forks / max(stars, 1)
153 + if is_new and fork_ratio > 0.1 and stars >= 50:
154 + return min(0.8, 0.4 + fork_ratio)
155 + if stars >= 500 and fork_ratio > 0.15:
156 + return 0.6
157 + return 0.2
158 +
159 +
160 +def score_momentum_shift(repo: dict[str, Any]) -> float:
161 + """Score a repo for momentum_shift (trending but established)."""
162 + stars = repo.get("stars", 0)
163 + is_trending = repo.get("_source") == "trending_repos"
164 + if is_trending and stars >= 10000:
165 + return 0.6
166 + if is_trending and stars >= 1000:
167 + return 0.5
168 + return 0.2
169 +
170 +
171 +def classify_prediction(repo: dict[str, Any]) -> tuple[str, float, str]:
172 + """Classify a repo into a prediction type with confidence and reason."""
173 + scores = {
174 + "rising_star": score_rising_star(repo),
175 + "breakout_candidate": score_breakout_candidate(repo),
176 + "momentum_shift": score_momentum_shift(repo),
177 + }
178 +
179 + best_type = max(scores, key=scores.get) # type: ignore[arg-type]
180 + confidence = scores[best_type]
181 +
182 + reasons = {
183 + "rising_star": f"New repo with {repo.get('stars', 0)} stars and active development",
184 + "breakout_candidate": (
185 + f"High fork ratio ({repo.get('forks', 0)} forks / "
186 + f"{repo.get('stars', 0)} stars) suggests community adoption"
187 + ),
188 + "momentum_shift": (
189 + f"Established repo ({repo.get('stars', 0)} stars) trending this week"
190 + ),
191 + }
192 +
193 + return best_type, round(confidence, 2), reasons[best_type]
194 +
195 +
196 +def generate_predictions(
197 + summary_content: str,
198 + raw_data: dict[str, Any],
199 + week: str,
200 +) -> list[dict[str, Any]]:
201 + """Generate 3-5 predictions from analyzed summary and raw data."""
202 + parsed = parse_summary(summary_content)
203 + mentioned_repos = extract_repos_from_summary(parsed["body"])
204 + repo_index = build_repo_index(raw_data)
205 +
206 + predictions: list[dict[str, Any]] = []
207 +
208 + # Score mentioned repos that exist in raw data
209 + candidates: list[tuple[str, str, float, str]] = []
210 + for repo_name in mentioned_repos:
211 + if repo_name in repo_index:
212 + pred_type, confidence, reason = classify_prediction(repo_index[repo_name])
213 + candidates.append((repo_name, pred_type, confidence, reason))
214 +
215 + # Sort by confidence descending, take top entries
216 + candidates.sort(key=lambda x: x[2], reverse=True)
217 +
218 + for repo_name, pred_type, confidence, reason in candidates[:MAX_PREDICTIONS]:
219 + predictions.append(
220 + {
221 + "week": week,
222 + "repo": repo_name,
223 + "prediction": pred_type,
224 + "confidence": confidence,
225 + "reason": reason,
226 + "validated": None,
227 + }
228 + )
229 +
230 + # If we have fewer than MIN_PREDICTIONS from mentioned repos,
231 + # supplement from raw data's new_repos
232 + if len(predictions) < MIN_PREDICTIONS:
233 + existing = {p["repo"] for p in predictions}
234 + for repo in raw_data.get("new_repos", []):
235 + if len(predictions) >= MIN_PREDICTIONS:
236 + break
237 + full_name = repo.get("full_name", "")
238 + if full_name and full_name not in existing:
239 + pred_type, confidence, reason = classify_prediction(repo)
240 + if confidence >= 0.3:
241 + predictions.append(
242 + {
243 + "week": week,
244 + "repo": full_name,
245 + "prediction": pred_type,
246 + "confidence": confidence,
247 + "reason": reason,
248 + "validated": None,
249 + }
250 + )
251 + existing.add(full_name)
252 +
253 + return predictions[:MAX_PREDICTIONS]
254 +
255 +
256 +def append_predictions(predictions: list[dict[str, Any]], output_path: Path) -> None:
257 + """Append predictions to a JSONL file."""
258 + output_path.parent.mkdir(parents=True, exist_ok=True)
259 + with open(output_path, "a", encoding="utf-8") as f:
260 + for pred in predictions:
261 + f.write(json.dumps(pred, ensure_ascii=False) + "\n")
262 +
263 +
264 +def main(argv: list[str] | None = None) -> list[dict[str, Any]]:
265 + """Main entry point. Returns the generated predictions."""
266 + args = parse_args(argv)
267 + topic_id = args.topic
268 +
269 + # Resolve input summary
270 + if args.input:
271 + summary_path = Path(args.input)
272 + else:
273 + summary_path = find_latest_summary(topic_id)
274 +
275 + # Read summary
276 + summary_content = summary_path.read_text(encoding="utf-8")
277 +
278 + # Resolve raw data path
279 + if args.raw:
280 + raw_path = Path(args.raw)
281 + else:
282 + raw_path = infer_raw_path(summary_path, topic_id)
283 +
284 + raw_data = load_raw_data(raw_path)
285 +
286 + # Determine week
287 + week = extract_week(summary_path.name) or raw_data.get("week", "unknown")
288 +
289 + # Generate predictions
290 + predictions = generate_predictions(summary_content, raw_data, week)
291 +
292 + # Write output
293 + output_path = metrics_dir(topic_id) / "predictions.jsonl"
294 + append_predictions(predictions, output_path)
295 +
296 + # Print summary
297 + print(f"Generated {len(predictions)} predictions for {week}")
298 + for p in predictions:
299 + print(f" [{p['prediction']}] {p['repo']} (confidence: {p['confidence']})")
300 +
301 + return predictions
302 +
303 +
304 +if __name__ == "__main__":
305 + main()
scripts/score_repos.py new
+193
@@ -0,0 +1,193 @@
1 +#!/usr/bin/env python3
2 +"""Score crawled repositories on topic relevance for SquadScope.
3 +
4 +Reads raw crawl JSON, applies scoring criteria from squadscope.topic.yml,
5 +and outputs a ranked list of repos with relevance_score field.
6 +
7 +Usage:
8 + python scripts/score_repos.py [--config squadscope.topic.yml] \
9 + [--input data/raw/ai-ml/2026-W21.json] [--output scored.json] [--topic ai-ml]
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +import argparse
15 +import json
16 +import math
17 +import sys
18 +from datetime import UTC, datetime
19 +from pathlib import Path
20 +from typing import Any
21 +
22 +import yaml
23 +
24 +from scripts.topic_paths import load_topic_id, raw_dir
25 +
26 +
27 +def load_config(config_path: str | Path) -> dict[str, Any]:
28 + """Load and return the full config from a YAML file."""
29 + path = Path(config_path)
30 + if not path.exists():
31 + raise FileNotFoundError(f"Config file not found: {config_path}")
32 + with open(path, encoding="utf-8") as f:
33 + return yaml.safe_load(f) or {}
34 +
35 +
36 +def get_scoring_config(config: dict[str, Any]) -> dict[str, Any]:
37 + """Extract scoring section with defaults."""
38 + defaults = {
39 + "min_stars": 20,
40 + "min_stars_gained": 10,
41 + "max_age_days": 365,
42 + "min_relevance_score": 40,
43 + "language_boost": {},
44 + "topic_relevance": [],
45 + }
46 + scoring = config.get("scoring", {})
47 + return {**defaults, **scoring}
48 +
49 +
50 +def find_latest_raw_json(topic_id: str | None) -> Path | None:
51 + """Find the most recent raw JSON file for a topic."""
52 + directory = raw_dir(topic_id)
53 + if not directory.exists():
54 + return None
55 + json_files = sorted(directory.glob("*.json"), reverse=True)
56 + return json_files[0] if json_files else None
57 +
58 +
59 +def score_stars(stars: int) -> float:
60 + """Score based on star count with diminishing returns (0-25 points)."""
61 + if stars <= 0:
62 + return 0.0
63 + # Log scale: 100 stars ≈ 12.5, 1000 stars ≈ 18.7, 10000 stars ≈ 25
64 + return min(25.0, 25.0 * math.log10(stars) / math.log10(10000))
65 +
66 +
67 +def score_stars_gained(stars_gained: int) -> float:
68 + """Score based on stars gained velocity (0-25 points)."""
69 + if stars_gained <= 0:
70 + return 0.0
71 + # Log scale with faster saturation
72 + return min(25.0, 25.0 * math.log10(1 + stars_gained) / math.log10(1000))
73 +
74 +
75 +def score_language(language: str | None, language_boost: dict[str, float]) -> float:
76 + """Score based on language match (0-15 points)."""
77 + if not language or not language_boost:
78 + return 7.5 # neutral score when no language info or no config
79 + multiplier = language_boost.get(language, 1.0)
80 + return min(15.0, 7.5 * multiplier)
81 +
82 +
83 +def score_topics(repo_topics: list[str], topic_relevance: list[str]) -> float:
84 + """Score based on topic overlap (0-25 points)."""
85 + if not topic_relevance or not repo_topics:
86 + return 0.0
87 + repo_set = set(t.lower() for t in repo_topics)
88 + relevance_set = set(t.lower() for t in topic_relevance)
89 + matches = len(repo_set & relevance_set)
90 + max_possible = min(len(relevance_set), 3) # cap at 3 matches for full score
91 + if max_possible == 0:
92 + return 0.0
93 + return min(25.0, 25.0 * matches / max_possible)
94 +
95 +
96 +def score_age(created_at: str | None, max_age_days: int) -> float:
97 + """Score based on repo age (0-10 points). Newer repos get a boost."""
98 + if not created_at:
99 + return 5.0 # neutral when unknown
100 + try:
101 + created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
102 + except (ValueError, TypeError):
103 + return 5.0
104 + now = datetime.now(UTC)
105 + age_days = (now - created).days
106 + if age_days < 0:
107 + age_days = 0
108 + if age_days > max_age_days:
109 + # Penalty for old repos: scale down from 5 to 0
110 + penalty_ratio = min((age_days - max_age_days) / max_age_days, 1.0)
111 + return max(0.0, 5.0 * (1.0 - penalty_ratio))
112 + # Boost for newer repos: 0 days = 10, max_age_days = 5
113 + freshness = 1.0 - (age_days / max_age_days)
114 + return 5.0 + 5.0 * freshness
115 +
116 +
117 +def compute_relevance_score(repo: dict[str, Any], scoring_config: dict[str, Any]) -> float:
118 + """Compute a 0-100 relevance score for a single repo."""
119 + stars = repo.get("stars", 0) or 0
120 + stars_gained = repo.get("stars_gained", 0) or 0
121 + language = repo.get("language")
122 + topics = repo.get("topics", []) or []
123 + created_at = repo.get("created_at")
124 +
125 + s_stars = score_stars(stars)
126 + s_gained = score_stars_gained(stars_gained)
127 + s_lang = score_language(language, scoring_config.get("language_boost", {}))
128 + s_topics = score_topics(topics, scoring_config.get("topic_relevance", []))
129 + s_age = score_age(created_at, scoring_config.get("max_age_days", 365))
130 +
131 + raw_score = s_stars + s_gained + s_lang + s_topics + s_age
132 + return round(min(100.0, max(0.0, raw_score)), 1)
133 +
134 +
135 +def score_repos(repos: list[dict[str, Any]], scoring_config: dict[str, Any]) -> list[dict[str, Any]]:
136 + """Score and filter a list of repos. Returns sorted list with relevance_score."""
137 + min_score = scoring_config.get("min_relevance_score", 40)
138 + scored = []
139 + for repo in repos:
140 + score = compute_relevance_score(repo, scoring_config)
141 + if score >= min_score:
142 + scored.append({**repo, "relevance_score": score})
143 + scored.sort(key=lambda r: r["relevance_score"], reverse=True)
144 + return scored
145 +
146 +
147 +def main(argv: list[str] | None = None) -> int:
148 + parser = argparse.ArgumentParser(description="Score repos on topic relevance")
149 + parser.add_argument("--config", default="squadscope.topic.yml", help="Path to topic config YAML")
150 + parser.add_argument("--input", default=None, help="Path to raw crawl JSON file")
151 + parser.add_argument("--output", default=None, help="Output file path (default: stdout)")
152 + parser.add_argument("--topic", default=None, help="Topic ID override")
153 + args = parser.parse_args(argv)
154 +
155 + config = load_config(args.config)
156 + scoring_config = get_scoring_config(config)
157 +
158 + topic_id = args.topic or load_topic_id(args.config)
159 +
160 + if args.input:
161 + input_path = Path(args.input)
162 + else:
163 + input_path = find_latest_raw_json(topic_id)
164 + if input_path is None:
165 + print(f"Error: No raw JSON found for topic '{topic_id}'", file=sys.stderr)
166 + return 1
167 +
168 + if not input_path.exists():
169 + print(f"Error: Input file not found: {input_path}", file=sys.stderr)
170 + return 1
171 +
172 + with open(input_path, encoding="utf-8") as f:
173 + repos = json.load(f)
174 +
175 + if not isinstance(repos, list):
176 + print("Error: Input JSON must be a list of repo objects", file=sys.stderr)
177 + return 1
178 +
179 + scored = score_repos(repos, scoring_config)
180 +
181 + output_json = json.dumps(scored, indent=2, ensure_ascii=False)
182 + if args.output:
183 + Path(args.output).parent.mkdir(parents=True, exist_ok=True)
184 + with open(args.output, "w", encoding="utf-8") as f:
185 + f.write(output_json + "\n")
186 + else:
187 + print(output_json)
188 +
189 + return 0
190 +
191 +
192 +if __name__ == "__main__":
193 + sys.exit(main())
tests/test_prediction_ledger.py new
+305
@@ -0,0 +1,305 @@
1 +"""Tests for the prediction ledger module."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +from pathlib import Path
7 +from unittest.mock import patch
8 +
9 +import pytest
10 +
11 +from scripts.prediction_ledger import (
12 + MAX_PREDICTIONS,
13 + MIN_PREDICTIONS,
14 + PREDICTION_TYPES,
15 + append_predictions,
16 + build_repo_index,
17 + classify_prediction,
18 + extract_repos_from_summary,
19 + extract_week,
20 + generate_predictions,
21 + infer_raw_path,
22 + main,
23 + parse_summary,
24 + score_breakout_candidate,
25 + score_momentum_shift,
26 + score_rising_star,
27 +)
28 +
29 +SAMPLE_SUMMARY = """\
30 +---
31 +title: "Week 21, 2026 Analysis"
32 +date: 2026-05-18T12:07:20.778+02:00
33 +week: "2026-W21"
34 +year: 2026
35 +tags: [ai, agents]
36 +top_repo: "vercel-labs/zero"
37 +quality_score: 76
38 +summary: "Strong week for agent tooling."
39 +---
40 +
41 +## Notable New Repositories
42 +
43 +[vercel-labs/zero](https://github.com/vercel-labs/zero) is the top new repo.
44 +Also notable: [org/rising-repo](https://github.com/org/rising-repo) and
45 +[bigcorp/established](https://github.com/bigcorp/established).
46 +"""
47 +
48 +SAMPLE_RAW = {
49 + "week": "2026-W21",
50 + "crawled_at": "2026-05-18T08:54:09Z",
51 + "new_repos": [
52 + {
53 + "name": "zero",
54 + "owner": "vercel-labs",
55 + "full_name": "vercel-labs/zero",
56 + "description": "Agent infra",
57 + "language": "TypeScript",
58 + "stars": 2500,
59 + "forks": 150,
60 + "created_at": "2026-05-10T00:00:00Z",
61 + "topics": ["ai", "agents"],
62 + "license": "MIT",
63 + "url": "https://github.com/vercel-labs/zero",
64 + },
65 + {
66 + "name": "rising-repo",
67 + "owner": "org",
68 + "full_name": "org/rising-repo",
69 + "description": "Hot new project",
70 + "language": "Python",
71 + "stars": 800,
72 + "forks": 120,
73 + "created_at": "2026-05-12T00:00:00Z",
74 + "topics": ["ml"],
75 + "license": "Apache-2.0",
76 + "url": "https://github.com/org/rising-repo",
77 + },
78 + ],
79 + "trending_repos": [
80 + {
81 + "name": "established",
82 + "owner": "bigcorp",
83 + "full_name": "bigcorp/established",
84 + "description": "Major framework",
85 + "language": "JavaScript",
86 + "stars": 50000,
87 + "forks": 8000,
88 + "created_at": "2020-01-01T00:00:00Z",
89 + "topics": ["framework"],
90 + "license": "MIT",
91 + "url": "https://github.com/bigcorp/established",
92 + },
93 + ],
94 + "signals": {"top_topics": ["ai", "agents"]},
95 + "metadata": {"api_calls_used": 10, "rate_limit_remaining": 4990},
96 +}
97 +
98 +
99 +class TestExtractWeek:
100 + def test_extracts_from_filename(self):
101 + assert extract_week("2026-W21-summary.md") == "2026-W21"
102 +
103 + def test_extracts_from_text(self):
104 + assert extract_week("week: 2025-W03") == "2025-W03"
105 +
106 + def test_returns_none_for_no_match(self):
107 + assert extract_week("no week here") is None
108 +
109 +
110 +class TestParseSummary:
111 + def test_parses_frontmatter(self):
112 + result = parse_summary(SAMPLE_SUMMARY)
113 + assert result["frontmatter"]["week"] == "2026-W21"
114 + assert result["frontmatter"]["top_repo"] == "vercel-labs/zero"
115 +
116 + def test_parses_body(self):
117 + result = parse_summary(SAMPLE_SUMMARY)
118 + assert "Notable New Repositories" in result["body"]
119 +
120 + def test_handles_no_frontmatter(self):
121 + result = parse_summary("# Just a header\nSome content.")
122 + assert result["frontmatter"] == {}
123 + assert "Just a header" in result["body"]
124 +
125 +
126 +class TestExtractRepos:
127 + def test_extracts_repo_links(self):
128 + body = parse_summary(SAMPLE_SUMMARY)["body"]
129 + repos = extract_repos_from_summary(body)
130 + assert "vercel-labs/zero" in repos
131 + assert "org/rising-repo" in repos
132 + assert "bigcorp/established" in repos
133 +
134 + def test_deduplicates(self):
135 + text = (
136 + "[a/b](https://github.com/a/b) and [a/b](https://github.com/a/b)"
137 + )
138 + repos = extract_repos_from_summary(text)
139 + assert repos == ["a/b"]
140 +
141 + def test_empty_for_no_links(self):
142 + assert extract_repos_from_summary("no repos here") == []
143 +
144 +
145 +class TestBuildRepoIndex:
146 + def test_indexes_by_full_name(self):
147 + index = build_repo_index(SAMPLE_RAW)
148 + assert "vercel-labs/zero" in index
149 + assert "bigcorp/established" in index
150 +
151 + def test_marks_source(self):
152 + index = build_repo_index(SAMPLE_RAW)
153 + assert index["vercel-labs/zero"]["_source"] == "new_repos"
154 + assert index["bigcorp/established"]["_source"] == "trending_repos"
155 +
156 + def test_empty_data(self):
157 + assert build_repo_index({}) == {}
158 +
159 +
160 +class TestScoring:
161 + def test_rising_star_high_stars_new(self):
162 + repo = {"stars": 3000, "_source": "new_repos"}
163 + score = score_rising_star(repo)
164 + assert 0.5 <= score <= 0.9
165 +
166 + def test_rising_star_low_stars(self):
167 + repo = {"stars": 10, "_source": "new_repos"}
168 + score = score_rising_star(repo)
169 + assert score < 0.4
170 +
171 + def test_breakout_high_fork_ratio(self):
172 + repo = {"stars": 500, "forks": 100, "_source": "new_repos"}
173 + score = score_breakout_candidate(repo)
174 + assert score >= 0.4
175 +
176 + def test_momentum_shift_trending_big(self):
177 + repo = {"stars": 50000, "_source": "trending_repos"}
178 + score = score_momentum_shift(repo)
179 + assert score >= 0.5
180 +
181 + def test_momentum_shift_not_trending(self):
182 + repo = {"stars": 50000, "_source": "new_repos"}
183 + score = score_momentum_shift(repo)
184 + assert score < 0.3
185 +
186 +
187 +class TestClassifyPrediction:
188 + def test_returns_valid_type(self):
189 + repo = {"stars": 3000, "forks": 100, "_source": "new_repos"}
190 + pred_type, confidence, reason = classify_prediction(repo)
191 + assert pred_type in PREDICTION_TYPES
192 + assert 0.0 <= confidence <= 1.0
193 + assert len(reason) > 0
194 +
195 + def test_new_high_star_is_rising_star(self):
196 + repo = {"stars": 5000, "forks": 50, "_source": "new_repos"}
197 + pred_type, _, _ = classify_prediction(repo)
198 + assert pred_type == "rising_star"
199 +
200 + def test_trending_established_is_momentum(self):
201 + repo = {"stars": 50000, "forks": 1000, "_source": "trending_repos"}
202 + pred_type, _, _ = classify_prediction(repo)
203 + assert pred_type == "momentum_shift"
204 +
205 +
206 +class TestGeneratePredictions:
207 + def test_generates_predictions(self):
208 + preds = generate_predictions(SAMPLE_SUMMARY, SAMPLE_RAW, "2026-W21")
209 + assert MIN_PREDICTIONS <= len(preds) <= MAX_PREDICTIONS
210 +
211 + def test_prediction_structure(self):
212 + preds = generate_predictions(SAMPLE_SUMMARY, SAMPLE_RAW, "2026-W21")
213 + for p in preds:
214 + assert p["week"] == "2026-W21"
215 + assert p["prediction"] in PREDICTION_TYPES
216 + assert 0.0 <= p["confidence"] <= 1.0
217 + assert p["validated"] is None
218 + assert "repo" in p
219 + assert "reason" in p
220 +
221 + def test_sorted_by_confidence(self):
222 + preds = generate_predictions(SAMPLE_SUMMARY, SAMPLE_RAW, "2026-W21")
223 + confidences = [p["confidence"] for p in preds]
224 + assert confidences == sorted(confidences, reverse=True)
225 +
226 +
227 +class TestAppendPredictions:
228 + def test_appends_to_file(self, tmp_path):
229 + output = tmp_path / "predictions.jsonl"
230 + preds = [
231 + {
232 + "week": "2026-W21",
233 + "repo": "a/b",
234 + "prediction": "rising_star",
235 + "confidence": 0.7,
236 + "reason": "test",
237 + "validated": None,
238 + }
239 + ]
240 + append_predictions(preds, output)
241 + lines = output.read_text().strip().splitlines()
242 + assert len(lines) == 1
243 + assert json.loads(lines[0])["repo"] == "a/b"
244 +
245 + def test_appends_multiple_calls(self, tmp_path):
246 + output = tmp_path / "predictions.jsonl"
247 + pred = {
248 + "week": "2026-W21",
249 + "repo": "x/y",
250 + "prediction": "rising_star",
251 + "confidence": 0.5,
252 + "reason": "r",
253 + "validated": None,
254 + }
255 + append_predictions([pred], output)
256 + append_predictions([pred], output)
257 + lines = output.read_text().strip().splitlines()
258 + assert len(lines) == 2
259 +
260 + def test_creates_parent_dirs(self, tmp_path):
261 + output = tmp_path / "nested" / "dir" / "predictions.jsonl"
262 + append_predictions([], output)
263 + assert output.parent.exists()
264 +
265 +
266 +class TestInferRawPath:
267 + def test_infers_from_summary(self):
268 + summary = Path("data/analyzed/2026-W21-summary.md")
269 + raw = infer_raw_path(summary, None)
270 + assert raw == Path("data/raw/2026-W21.json")
271 +
272 + def test_infers_with_topic(self):
273 + summary = Path("data/analyzed/ai-ml/2026-W21-summary.md")
274 + raw = infer_raw_path(summary, "ai-ml")
275 + assert raw == Path("data/raw/ai-ml/2026-W21.json")
276 +
277 + def test_raises_on_no_week(self):
278 + with pytest.raises(ValueError):
279 + infer_raw_path(Path("bad-name.md"), None)
280 +
281 +
282 +class TestMain:
283 + def test_end_to_end(self, tmp_path):
284 + # Set up files
285 + summary_path = tmp_path / "analyzed" / "2026-W21-summary.md"
286 + summary_path.parent.mkdir(parents=True)
287 + summary_path.write_text(SAMPLE_SUMMARY)
288 +
289 + raw_path = tmp_path / "raw" / "2026-W21.json"
290 + raw_path.parent.mkdir(parents=True)
291 + raw_path.write_text(json.dumps(SAMPLE_RAW))
292 +
293 + metrics_path = tmp_path / "metrics"
294 +
295 + with patch("scripts.prediction_ledger.metrics_dir", return_value=metrics_path):
296 + preds = main([
297 + "--input", str(summary_path),
298 + "--raw", str(raw_path),
299 + ])
300 +
301 + assert len(preds) >= MIN_PREDICTIONS
302 + output_file = metrics_path / "predictions.jsonl"
303 + assert output_file.exists()
304 + lines = output_file.read_text().strip().splitlines()
305 + assert len(lines) == len(preds)
tests/test_score_repos.py new
+403
@@ -0,0 +1,403 @@
1 +"""Tests for scripts/score_repos.py scoring pipeline."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +import math
7 +from datetime import UTC, datetime, timedelta
8 +from pathlib import Path
9 +from unittest.mock import patch
10 +
11 +import pytest
12 +
13 +from scripts.score_repos import (
14 + compute_relevance_score,
15 + find_latest_raw_json,
16 + get_scoring_config,
17 + load_config,
18 + main,
19 + score_age,
20 + score_language,
21 + score_repos,
22 + score_stars,
23 + score_stars_gained,
24 + score_topics,
25 +)
26 +
27 +
28 +# --- Fixtures ---
29 +
30 +
31 +@pytest.fixture
32 +def scoring_config():
33 + """Default scoring config matching squadscope.topic.yml."""
34 + return {
35 + "min_stars": 20,
36 + "min_stars_gained": 10,
37 + "max_age_days": 365,
38 + "min_relevance_score": 40,
39 + "language_boost": {"Python": 1.2, "Jupyter Notebook": 1.1},
40 + "topic_relevance": [
41 + "machine-learning",
42 + "deep-learning",
43 + "artificial-intelligence",
44 + "neural-network",
45 + "llm",
46 + "transformers",
47 + ],
48 + }
49 +
50 +
51 +@pytest.fixture
52 +def sample_repo():
53 + """A typical high-quality AI/ML repo record."""
54 + return {
55 + "name": "awesome-ml",
56 + "owner": "researcher",
57 + "full_name": "researcher/awesome-ml",
58 + "description": "A machine learning framework",
59 + "language": "Python",
60 + "stars": 500,
61 + "forks": 50,
62 + "created_at": (datetime.now(UTC) - timedelta(days=30)).isoformat(),
63 + "topics": ["machine-learning", "deep-learning", "python"],
64 + "license": "MIT",
65 + "url": "https://github.com/researcher/awesome-ml",
66 + "stars_gained": 100,
67 + }
68 +
69 +
70 +@pytest.fixture
71 +def config_file(tmp_path):
72 + """Create a temporary config YAML file."""
73 + config = {
74 + "topic": {
75 + "id": "ai-ml",
76 + "name": "AI & ML",
77 + "description": "Test topic",
78 + },
79 + "queries": {"primary": ["topic:machine-learning"]},
80 + "scoring": {
81 + "min_stars": 20,
82 + "min_stars_gained": 10,
83 + "max_age_days": 365,
84 + "min_relevance_score": 40,
85 + "language_boost": {"Python": 1.2},
86 + "topic_relevance": ["machine-learning", "deep-learning"],
87 + },
88 + }
89 + path = tmp_path / "test_config.yml"
90 + import yaml
91 +
92 + path.write_text(yaml.dump(config), encoding="utf-8")
93 + return path
94 +
95 +
96 +# --- Test score_stars ---
97 +
98 +
99 +class TestScoreStars:
100 + def test_zero_stars(self):
101 + assert score_stars(0) == 0.0
102 +
103 + def test_negative_stars(self):
104 + assert score_stars(-5) == 0.0
105 +
106 + def test_low_stars(self):
107 + score = score_stars(10)
108 + assert 0 < score < 25
109 +
110 + def test_high_stars(self):
111 + score = score_stars(10000)
112 + assert score == 25.0
113 +
114 + def test_very_high_stars_capped(self):
115 + score = score_stars(1_000_000)
116 + assert score == 25.0
117 +
118 + def test_diminishing_returns(self):
119 + s10 = score_stars(10)
120 + s100 = score_stars(100)
121 + s1000 = score_stars(1000)
122 + # Log scale means equal absolute gains per 10x, but relative gains shrink
123 + assert s100 > s10
124 + assert s1000 > s100
125 + # Verify sublinear: doubling stars doesn't double score
126 + assert score_stars(200) < score_stars(100) * 2
127 +
128 +
129 +# --- Test score_stars_gained ---
130 +
131 +
132 +class TestScoreStarsGained:
133 + def test_zero_gained(self):
134 + assert score_stars_gained(0) == 0.0
135 +
136 + def test_negative_gained(self):
137 + assert score_stars_gained(-10) == 0.0
138 +
139 + def test_moderate_gained(self):
140 + score = score_stars_gained(50)
141 + assert 0 < score < 25
142 +
143 + def test_high_gained_capped(self):
144 + score = score_stars_gained(10000)
145 + assert score == 25.0
146 +
147 +
148 +# --- Test score_language ---
149 +
150 +
151 +class TestScoreLanguage:
152 + def test_no_language(self):
153 + assert score_language(None, {"Python": 1.2}) == 7.5
154 +
155 + def test_no_boost_config(self):
156 + assert score_language("Python", {}) == 7.5
157 +
158 + def test_matching_language_boost(self):
159 + score = score_language("Python", {"Python": 1.2})
160 + assert score == pytest.approx(9.0)
161 +
162 + def test_non_matching_language(self):
163 + score = score_language("Rust", {"Python": 1.2})
164 + assert score == 7.5
165 +
166 + def test_high_boost_capped(self):
167 + score = score_language("Python", {"Python": 3.0})
168 + assert score == 15.0
169 +
170 +
171 +# --- Test score_topics ---
172 +
173 +
174 +class TestScoreTopics:
175 + def test_no_repo_topics(self):
176 + assert score_topics([], ["machine-learning"]) == 0.0
177 +
178 + def test_no_relevance_list(self):
179 + assert score_topics(["python"], []) == 0.0
180 +
181 + def test_single_match(self):
182 + score = score_topics(["machine-learning"], ["machine-learning", "deep-learning", "llm"])
183 + assert score == pytest.approx(25.0 / 3)
184 +
185 + def test_full_match(self):
186 + topics = ["machine-learning", "deep-learning", "llm"]
187 + relevance = ["machine-learning", "deep-learning", "llm", "transformers"]
188 + score = score_topics(topics, relevance)
189 + assert score == 25.0 # 3 matches, capped at 3
190 +
191 + def test_case_insensitive(self):
192 + score = score_topics(["Machine-Learning"], ["machine-learning", "deep-learning", "llm"])
193 + assert score > 0
194 +
195 + def test_no_overlap(self):
196 + assert score_topics(["rust", "wasm"], ["machine-learning", "deep-learning", "llm"]) == 0.0
197 +
198 +
199 +# --- Test score_age ---
200 +
201 +
202 +class TestScoreAge:
203 + def test_no_created_at(self):
204 + assert score_age(None, 365) == 5.0
205 +
206 + def test_invalid_date(self):
207 + assert score_age("not-a-date", 365) == 5.0
208 +
209 + def test_brand_new_repo(self):
210 + now_iso = datetime.now(UTC).isoformat()
211 + score = score_age(now_iso, 365)
212 + assert score == pytest.approx(10.0, abs=0.1)
213 +
214 + def test_old_repo_at_max_age(self):
215 + old = (datetime.now(UTC) - timedelta(days=365)).isoformat()
216 + score = score_age(old, 365)
217 + assert score == pytest.approx(5.0, abs=0.1)
218 +
219 + def test_very_old_repo_penalized(self):
220 + ancient = (datetime.now(UTC) - timedelta(days=730)).isoformat()
221 + score = score_age(ancient, 365)
222 + assert score < 5.0
223 +
224 + def test_extremely_old_repo_zero(self):
225 + ancient = (datetime.now(UTC) - timedelta(days=1000)).isoformat()
226 + score = score_age(ancient, 365)
227 + assert score == pytest.approx(0.0, abs=0.5)
228 +
229 +
230 +# --- Test compute_relevance_score ---
231 +
232 +
233 +class TestComputeRelevanceScore:
234 + def test_high_quality_repo(self, sample_repo, scoring_config):
235 + score = compute_relevance_score(sample_repo, scoring_config)
236 + assert 60 <= score <= 100
237 +
238 + def test_low_quality_repo(self, scoring_config):
239 + repo = {
240 + "name": "old-thing",
241 + "stars": 5,
242 + "stars_gained": 0,
243 + "language": "Shell",
244 + "topics": [],
245 + "created_at": (datetime.now(UTC) - timedelta(days=800)).isoformat(),
246 + }
247 + score = compute_relevance_score(repo, scoring_config)
248 + assert score < 40
249 +
250 + def test_empty_repo(self, scoring_config):
251 + score = compute_relevance_score({}, scoring_config)
252 + assert 0 <= score <= 100
253 +
254 + def test_score_bounded(self, scoring_config):
255 + repo = {
256 + "stars": 1_000_000,
257 + "stars_gained": 100_000,
258 + "language": "Python",
259 + "topics": ["machine-learning", "deep-learning", "llm", "transformers"],
260 + "created_at": datetime.now(UTC).isoformat(),
261 + }
262 + score = compute_relevance_score(repo, scoring_config)
263 + assert score <= 100.0
264 +
265 +
266 +# --- Test score_repos ---
267 +
268 +
269 +class TestScoreRepos:
270 + def test_filters_below_threshold(self, scoring_config):
271 + repos = [
272 + {"name": "good", "stars": 500, "stars_gained": 100, "language": "Python",
273 + "topics": ["machine-learning", "deep-learning"], "created_at": datetime.now(UTC).isoformat()},
274 + {"name": "bad", "stars": 2, "stars_gained": 0, "language": "Shell",
275 + "topics": [], "created_at": (datetime.now(UTC) - timedelta(days=800)).isoformat()},
276 + ]
277 + scored = score_repos(repos, scoring_config)
278 + names = [r["name"] for r in scored]
279 + assert "good" in names
280 + assert "bad" not in names
281 +
282 + def test_sorted_descending(self, scoring_config):
283 + repos = [
284 + {"name": "medium", "stars": 100, "stars_gained": 20, "language": "Python",
285 + "topics": ["machine-learning"], "created_at": datetime.now(UTC).isoformat()},
286 + {"name": "high", "stars": 5000, "stars_gained": 500, "language": "Python",
287 + "topics": ["machine-learning", "deep-learning", "llm"], "created_at": datetime.now(UTC).isoformat()},
288 + ]
289 + scored = score_repos(repos, scoring_config)
290 + assert len(scored) >= 1
291 + if len(scored) >= 2:
292 + assert scored[0]["relevance_score"] >= scored[1]["relevance_score"]
293 +
294 + def test_adds_relevance_score_field(self, scoring_config):
295 + repos = [
296 + {"name": "test", "stars": 500, "stars_gained": 50, "language": "Python",
297 + "topics": ["machine-learning"], "created_at": datetime.now(UTC).isoformat()},
298 + ]
299 + scored = score_repos(repos, scoring_config)
300 + assert len(scored) > 0
301 + assert "relevance_score" in scored[0]
302 + assert isinstance(scored[0]["relevance_score"], float)
303 +
304 + def test_empty_input(self, scoring_config):
305 + assert score_repos([], scoring_config) == []
306 +
307 +
308 +# --- Test load_config ---
309 +
310 +
311 +class TestLoadConfig:
312 + def test_load_valid_config(self, config_file):
313 + config = load_config(config_file)
314 + assert "scoring" in config
315 + assert config["scoring"]["min_stars"] == 20
316 +
317 + def test_missing_file(self):
318 + with pytest.raises(FileNotFoundError):
319 + load_config("nonexistent.yml")
320 +
321 +
322 +# --- Test get_scoring_config ---
323 +
324 +
325 +class TestGetScoringConfig:
326 + def test_with_scoring_section(self):
327 + config = {"scoring": {"min_stars": 50, "language_boost": {"Go": 1.3}}}
328 + sc = get_scoring_config(config)
329 + assert sc["min_stars"] == 50
330 + assert sc["language_boost"] == {"Go": 1.3}
331 +
332 + def test_without_scoring_section(self):
333 + sc = get_scoring_config({})
334 + assert sc["min_stars"] == 20
335 + assert sc["min_relevance_score"] == 40
336 +
337 +
338 +# --- Test find_latest_raw_json ---
339 +
340 +
341 +class TestFindLatestRawJson:
342 + def test_no_directory(self):
343 + assert find_latest_raw_json("nonexistent-topic-xyz") is None
344 +
345 + def test_finds_latest(self, tmp_path, monkeypatch):
346 + topic_dir = tmp_path / "raw" / "test-topic"
347 + topic_dir.mkdir(parents=True)
348 + (topic_dir / "2026-W20.json").write_text("[]")
349 + (topic_dir / "2026-W21.json").write_text("[]")
350 +
351 + monkeypatch.setattr("scripts.score_repos.raw_dir", lambda tid: topic_dir)
352 + result = find_latest_raw_json("test-topic")
353 + assert result is not None
354 + assert "W21" in result.name
355 +
356 +
357 +# --- Test CLI (main) ---
358 +
359 +
360 +class TestMain:
361 + def test_with_input_file(self, tmp_path, config_file):
362 + repos = [
363 + {"name": "repo1", "stars": 500, "stars_gained": 100, "language": "Python",
364 + "topics": ["machine-learning", "deep-learning"], "created_at": datetime.now(UTC).isoformat()},
365 + ]
366 + input_file = tmp_path / "input.json"
367 + input_file.write_text(json.dumps(repos))
368 + output_file = tmp_path / "output.json"
369 +
370 + result = main(["--config", str(config_file), "--input", str(input_file),
371 + "--output", str(output_file)])
372 + assert result == 0
373 + scored = json.loads(output_file.read_text())
374 + assert len(scored) == 1
375 + assert "relevance_score" in scored[0]
376 +
377 + def test_missing_input_file(self, config_file):
378 + result = main(["--config", str(config_file), "--input", "no_such_file.json"])
379 + assert result == 1
380 +
381 + def test_invalid_json_content(self, tmp_path, config_file):
382 + input_file = tmp_path / "bad.json"
383 + input_file.write_text('{"not": "a list"}')
384 + result = main(["--config", str(config_file), "--input", str(input_file)])
385 + assert result == 1
386 +
387 + def test_stdout_output(self, tmp_path, config_file, capsys):
388 + repos = [
389 + {"name": "repo1", "stars": 1000, "stars_gained": 200, "language": "Python",
390 + "topics": ["machine-learning", "llm"], "created_at": datetime.now(UTC).isoformat()},
391 + ]
392 + input_file = tmp_path / "input.json"
393 + input_file.write_text(json.dumps(repos))
394 +
395 + result = main(["--config", str(config_file), "--input", str(input_file)])
396 + assert result == 0
397 + output = capsys.readouterr().out
398 + parsed = json.loads(output)
399 + assert len(parsed) >= 1
400 +
401 + def test_no_input_no_raw_files(self, config_file):
402 + result = main(["--config", str(config_file), "--topic", "nonexistent-xyz-topic"])
403 + assert result == 1