feat: cross-source correlation engine (#76) (#108)

* feat: hype risk scoring model (#77) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: cross-source correlation engine (#76) 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 16:17 UTC f2b591faaf3082f6bff5e37747e85867e7092f3a
4 files changed +1144
scripts/correlate.py new
+354
@@ -0,0 +1,354 @@
1 +#!/usr/bin/env python3
2 +"""Cross-source correlation engine for SquadScope.
3 +
4 +Matches TechCrunch articles to GitHub repo activity using fuzzy matching
5 +heuristics to identify press-correlated repositories.
6 +
7 +Usage:
8 + python scripts/correlate.py [--raw data/raw/ai-ml/2026-W21.json] \
9 + [--techcrunch data/raw/ai-ml/2026-W21-techcrunch.json] \
10 + [--output data/analyzed/ai-ml/2026-W21-correlations.json] \
11 + [--topic ai-ml]
12 +"""
13 +
14 +from __future__ import annotations
15 +
16 +import argparse
17 +import json
18 +import re
19 +import sys
20 +from difflib import SequenceMatcher
21 +from pathlib import Path
22 +from typing import Any
23 +
24 +from scripts.topic_paths import analyzed_dir, raw_dir
25 +
26 +
27 +def log(message: str) -> None:
28 + print(f"[correlate] {message}", file=sys.stderr)
29 +
30 +
31 +# ---------------------------------------------------------------------------
32 +# Heuristic matchers
33 +# ---------------------------------------------------------------------------
34 +
35 +
36 +def match_direct_link(repo: dict[str, Any], articles: list[dict[str, Any]]) -> list[dict[str, Any]]:
37 + """Match articles that contain a direct GitHub link to the repo."""
38 + repo_url = (repo.get("url") or "").rstrip("/").lower()
39 + full_name = (repo.get("full_name") or "").lower()
40 + if not repo_url and not full_name:
41 + return []
42 +
43 + matches = []
44 + for article in articles:
45 + for link in article.get("github_links", []):
46 + normalized = link.rstrip("/").lower()
47 + if normalized == repo_url or normalized.endswith(f"/{full_name}"):
48 + matches.append(article)
49 + break
50 + return matches
51 +
52 +
53 +def match_org_name(repo: dict[str, Any], articles: list[dict[str, Any]]) -> list[dict[str, Any]]:
54 + """Match articles whose entities contain the repo owner name."""
55 + owner = (repo.get("owner") or "").lower()
56 + if not owner or len(owner) < 2:
57 + return []
58 +
59 + matches = []
60 + for article in articles:
61 + entities = [e.lower() for e in article.get("entities", [])]
62 + if owner in entities:
63 + matches.append(article)
64 + return matches
65 +
66 +
67 +def _token_overlap_ratio(a: str, b: str) -> float:
68 + """Compute token overlap ratio between two strings."""
69 + tokens_a = set(re.split(r"[\s\-_]+", a.lower()))
70 + tokens_b = set(re.split(r"[\s\-_]+", b.lower()))
71 + tokens_a.discard("")
72 + tokens_b.discard("")
73 + if not tokens_a or not tokens_b:
74 + return 0.0
75 + intersection = tokens_a & tokens_b
76 + return len(intersection) / min(len(tokens_a), len(tokens_b))
77 +
78 +
79 +def fuzzy_name_score(repo_name: str, text: str) -> float:
80 + """Compute fuzzy match score between repo name and text."""
81 + if not repo_name or not text:
82 + return 0.0
83 + # SequenceMatcher ratio
84 + seq_score = SequenceMatcher(None, repo_name.lower(), text.lower()).ratio()
85 + # Token overlap
86 + token_score = _token_overlap_ratio(repo_name, text)
87 + return max(seq_score, token_score)
88 +
89 +
90 +def match_project_name(repo: dict[str, Any], articles: list[dict[str, Any]], threshold: float = 0.6) -> list[dict[str, Any]]:
91 + """Match articles by fuzzy matching repo name against title/entities."""
92 + repo_name = repo.get("name") or ""
93 + if not repo_name or len(repo_name) < 3:
94 + return []
95 +
96 + matches = []
97 + for article in articles:
98 + title = article.get("title") or ""
99 + entities = article.get("entities", [])
100 +
101 + # Check title
102 + if fuzzy_name_score(repo_name, title) >= threshold:
103 + matches.append(article)
104 + continue
105 +
106 + # Check individual entities
107 + for entity in entities:
108 + if fuzzy_name_score(repo_name, entity) >= threshold:
109 + matches.append(article)
110 + break
111 + return matches
112 +
113 +
114 +def match_category(repo: dict[str, Any], articles: list[dict[str, Any]]) -> list[dict[str, Any]]:
115 + """Match articles whose categories overlap with repo topics."""
116 + topics = {t.lower() for t in (repo.get("topics") or [])}
117 + if not topics:
118 + return []
119 +
120 + matches = []
121 + for article in articles:
122 + categories = {c.lower() for c in (article.get("categories") or [])}
123 + if topics & categories:
124 + matches.append(article)
125 + return matches
126 +
127 +
128 +def has_temporal_spike(repo: dict[str, Any], stars_threshold: int = 10) -> bool:
129 + """Check if repo had a stars_gained spike in the same week."""
130 + stars_gained = repo.get("stars_gained")
131 + if stars_gained is None:
132 + return False
133 + return stars_gained >= stars_threshold
134 +
135 +
136 +# ---------------------------------------------------------------------------
137 +# Hype risk assessment
138 +# ---------------------------------------------------------------------------
139 +
140 +
141 +def assess_hype_risk(confidence: float, stars_gained: int | None) -> str:
142 + """Assess hype risk based on correlation confidence and star velocity."""
143 + if confidence >= 0.8:
144 + if stars_gained is not None and stars_gained > 100:
145 + return "high"
146 + return "medium"
147 + if confidence >= 0.6:
148 + return "medium"
149 + if confidence >= 0.4:
150 + return "low"
151 + return "none"
152 +
153 +
154 +# ---------------------------------------------------------------------------
155 +# Main correlation logic
156 +# ---------------------------------------------------------------------------
157 +
158 +
159 +def correlate_repo(repo: dict[str, Any], articles: list[dict[str, Any]]) -> dict[str, Any] | None:
160 + """Correlate a single repo against all articles. Returns correlation or None."""
161 + best_confidence = 0.0
162 + best_type = ""
163 + matched_articles: list[str] = []
164 +
165 + # Priority 1: Direct link match (confidence 1.0)
166 + direct = match_direct_link(repo, articles)
167 + if direct:
168 + best_confidence = 1.0
169 + best_type = "direct_link"
170 + matched_articles = [a["url"] for a in direct]
171 +
172 + # Priority 2: Org name match (confidence 0.8)
173 + if not matched_articles:
174 + org = match_org_name(repo, articles)
175 + if org:
176 + best_confidence = 0.8
177 + best_type = "org_name"
178 + matched_articles = [a["url"] for a in org]
179 +
180 + # Priority 3: Project name fuzzy match (confidence 0.6)
181 + if not matched_articles:
182 + fuzzy = match_project_name(repo, articles)
183 + if fuzzy:
184 + best_confidence = 0.6
185 + best_type = "project_name"
186 + matched_articles = [a["url"] for a in fuzzy]
187 +
188 + # Priority 4: Category correlation (confidence 0.4)
189 + if not matched_articles:
190 + cat = match_category(repo, articles)
191 + if cat:
192 + best_confidence = 0.4
193 + best_type = "category"
194 + matched_articles = [a["url"] for a in cat]
195 +
196 + if not matched_articles:
197 + return None
198 +
199 + # Priority 5: Temporal lag bonus
200 + press_correlated = True
201 + if has_temporal_spike(repo):
202 + best_confidence = min(best_confidence + 0.2, 1.0)
203 + press_correlated = True
204 +
205 + return {
206 + "repo": repo.get("full_name") or f"{repo.get('owner')}/{repo.get('name')}",
207 + "press_correlated": press_correlated,
208 + "correlation_confidence": round(best_confidence, 2),
209 + "matched_articles": matched_articles,
210 + "match_type": best_type,
211 + "hype_risk": assess_hype_risk(best_confidence, repo.get("stars_gained")),
212 + }
213 +
214 +
215 +def correlate_all(repos: list[dict[str, Any]], articles: list[dict[str, Any]], week: str) -> dict[str, Any]:
216 + """Run correlation engine across all repos and articles."""
217 + correlations: list[dict[str, Any]] = []
218 + uncorrelated: list[str] = []
219 +
220 + for repo in repos:
221 + result = correlate_repo(repo, articles)
222 + if result:
223 + correlations.append(result)
224 + else:
225 + name = repo.get("full_name") or f"{repo.get('owner')}/{repo.get('name')}"
226 + uncorrelated.append(name)
227 +
228 + # Sort by confidence descending
229 + correlations.sort(key=lambda c: c["correlation_confidence"], reverse=True)
230 +
231 + articles_matched = len({url for c in correlations for url in c["matched_articles"]})
232 +
233 + return {
234 + "week": week,
235 + "correlations": correlations,
236 + "uncorrelated_repos": uncorrelated,
237 + "metadata": {
238 + "repos_analyzed": len(repos),
239 + "correlations_found": len(correlations),
240 + "articles_matched": articles_matched,
241 + },
242 + }
243 +
244 +
245 +# ---------------------------------------------------------------------------
246 +# File discovery
247 +# ---------------------------------------------------------------------------
248 +
249 +
250 +def find_latest_file(directory: Path, pattern: str) -> Path | None:
251 + """Find the latest file matching a glob pattern in directory."""
252 + if not directory.exists():
253 + return None
254 + files = sorted(directory.glob(pattern), reverse=True)
255 + return files[0] if files else None
256 +
257 +
258 +def load_json(path: Path) -> dict[str, Any]:
259 + """Load and return parsed JSON from a file."""
260 + with open(path, encoding="utf-8") as f:
261 + return json.load(f)
262 +
263 +
264 +def extract_week_from_filename(path: Path) -> str:
265 + """Extract week slug from filename like '2026-W21.json'."""
266 + match = re.search(r"(\d{4}-W\d{2})", path.name)
267 + return match.group(1) if match else "unknown"
268 +
269 +
270 +# ---------------------------------------------------------------------------
271 +# CLI
272 +# ---------------------------------------------------------------------------
273 +
274 +
275 +def main(argv: list[str] | None = None) -> int:
276 + parser = argparse.ArgumentParser(
277 + description="Cross-source correlation engine for SquadScope"
278 + )
279 + parser.add_argument(
280 + "--raw", default=None,
281 + help="Path to raw GitHub repos JSON file",
282 + )
283 + parser.add_argument(
284 + "--techcrunch", default=None,
285 + help="Path to TechCrunch articles JSON file",
286 + )
287 + parser.add_argument(
288 + "--output", default=None,
289 + help="Output file path for correlations",
290 + )
291 + parser.add_argument(
292 + "--topic", default="general",
293 + help="Topic ID for path resolution (default: general)",
294 + )
295 +
296 + args = parser.parse_args(argv)
297 + topic = args.topic
298 +
299 + # Resolve raw repos file
300 + if args.raw:
301 + raw_path = Path(args.raw)
302 + else:
303 + raw_path = find_latest_file(raw_dir(topic), "[0-9]*-W[0-9]*.json")
304 + if raw_path is None:
305 + log(f"No raw data found in {raw_dir(topic)}")
306 + return 1
307 +
308 + if not raw_path.exists():
309 + log(f"Raw file not found: {raw_path}")
310 + return 1
311 +
312 + # Resolve TechCrunch file
313 + if args.techcrunch:
314 + tc_path = Path(args.techcrunch)
315 + else:
316 + tc_path = find_latest_file(raw_dir(topic), "*-techcrunch.json")
317 +
318 + # Load repos
319 + raw_data = load_json(raw_path)
320 + repos = raw_data if isinstance(raw_data, list) else raw_data.get("repos", raw_data.get("repositories", []))
321 +
322 + # Load articles (graceful if missing)
323 + articles: list[dict[str, Any]] = []
324 + if tc_path and tc_path.exists():
325 + tc_data = load_json(tc_path)
326 + articles = tc_data if isinstance(tc_data, list) else tc_data.get("articles", [])
327 + else:
328 + log("No TechCrunch data found; producing empty correlations")
329 +
330 + # Determine week
331 + week = extract_week_from_filename(raw_path)
332 +
333 + # Run correlation
334 + result = correlate_all(repos, articles, week)
335 +
336 + # Write output
337 + if args.output:
338 + output_path = Path(args.output)
339 + else:
340 + out_dir = analyzed_dir(topic)
341 + out_dir.mkdir(parents=True, exist_ok=True)
342 + output_path = out_dir / f"{week}-correlations.json"
343 +
344 + output_path.parent.mkdir(parents=True, exist_ok=True)
345 + with open(output_path, "w", encoding="utf-8") as f:
346 + json.dump(result, f, indent=2, ensure_ascii=False)
347 + f.write("\n")
348 +
349 + log(f"Wrote {output_path}: {result['metadata']['correlations_found']} correlations from {result['metadata']['repos_analyzed']} repos")
350 + return 0
351 +
352 +
353 +if __name__ == "__main__":
354 + sys.exit(main())
scripts/hype_risk.py new
+311
@@ -0,0 +1,311 @@
1 +#!/usr/bin/env python3
2 +"""Hype risk scoring model for SquadScope.
3 +
4 +Classifies repos based on the relationship between press coverage
5 +and GitHub activity patterns.
6 +"""
7 +
8 +from __future__ import annotations
9 +
10 +import argparse
11 +import json
12 +import sys
13 +from pathlib import Path
14 +
15 +sys.path.insert(0, str(Path(__file__).resolve().parent))
16 +import topic_paths # noqa: E402
17 +
18 +
19 +# Risk level definitions
20 +RISK_LEVELS = {
21 + "very_low": "Organic growth",
22 + "low": "Press-validated, community-sustained",
23 + "medium": "Announced but unbuilt",
24 + "high": "Press-driven hype, fading",
25 + "none": "No press signal",
26 +}
27 +
28 +
29 +def classify_repo(
30 + repo_name: str,
31 + press_correlated: bool,
32 + current_stars: int | None = None,
33 + current_stars_gained: int | None = None,
34 + previous_stars: int | None = None,
35 + previous_stars_gained: int | None = None,
36 +) -> dict:
37 + """Classify a single repo's hype risk.
38 +
39 + Returns an assessment dict with risk level, label, confidence, and reasoning.
40 + """
41 + if not press_correlated:
42 + return _assessment(
43 + repo_name,
44 + risk="none",
45 + press_correlated=False,
46 + stars_trend="unknown",
47 + confidence=0.9,
48 + reasoning="No press correlation detected",
49 + )
50 +
51 + # Press correlated but no previous data
52 + if previous_stars is None or previous_stars_gained is None:
53 + return _assessment(
54 + repo_name,
55 + risk="medium",
56 + press_correlated=True,
57 + stars_trend="unknown",
58 + confidence=0.4,
59 + reasoning="Press correlated but insufficient historical data to assess sustainability",
60 + )
61 +
62 + # Press correlated with previous data available
63 + current_gained = current_stars_gained or 0
64 + previous_gained = previous_stars_gained or 0
65 +
66 + # Check decay first: previous spike much larger than current → fading
67 + if previous_gained > 0 and current_gained < previous_gained * 0.5:
68 + return _assessment(
69 + repo_name,
70 + risk="high",
71 + press_correlated=True,
72 + stars_trend="decaying",
73 + confidence=0.7,
74 + reasoning=(
75 + f"Stars spiked after press but are fading "
76 + f"(previous: +{previous_gained}, current: +{current_gained})"
77 + ),
78 + )
79 +
80 + # Check if stars were already growing before press (organic)
81 + if previous_gained > 0 and current_gained > 0:
82 + if previous_gained >= current_gained * 0.5:
83 + # Growth was already happening before press
84 + return _assessment(
85 + repo_name,
86 + risk="very_low",
87 + press_correlated=True,
88 + stars_trend="organic",
89 + confidence=0.8,
90 + reasoning=(
91 + f"Stars were already growing before press coverage "
92 + f"(previous: +{previous_gained}, current: +{current_gained})"
93 + ),
94 + )
95 +
96 + # Stars spiked after article - check sustainability
97 + if current_gained > 0 and previous_gained >= 0:
98 + total_recent_gain = current_gained + previous_gained
99 + if total_recent_gain > 0 and current_gained > total_recent_gain * 0.5:
100 + # Current week still has significant growth - sustained
101 + return _assessment(
102 + repo_name,
103 + risk="low",
104 + press_correlated=True,
105 + stars_trend="sustained",
106 + confidence=0.75,
107 + reasoning=(
108 + f"Stars grew after press coverage and maintained "
109 + f"(+{current_gained} this week, +{previous_gained} previous)"
110 + ),
111 + )
112 +
113 + # Fallback: press correlated but no clear activity spike
114 + return _assessment(
115 + repo_name,
116 + risk="medium",
117 + press_correlated=True,
118 + stars_trend="flat",
119 + confidence=0.5,
120 + reasoning="Press coverage detected but no significant GitHub activity spike",
121 + )
122 +
123 +
124 +def _assessment(
125 + repo: str,
126 + risk: str,
127 + press_correlated: bool,
128 + stars_trend: str,
129 + confidence: float,
130 + reasoning: str,
131 +) -> dict:
132 + return {
133 + "repo": repo,
134 + "hype_risk": risk,
135 + "label": RISK_LEVELS[risk],
136 + "press_correlated": press_correlated,
137 + "stars_trend": stars_trend,
138 + "confidence": confidence,
139 + "reasoning": reasoning,
140 + }
141 +
142 +
143 +def _find_repo_in_raw(raw_repos: list[dict], repo_name: str) -> dict | None:
144 + """Find a repo entry in raw data by name."""
145 + for repo in raw_repos:
146 + name = repo.get("full_name") or repo.get("repo") or repo.get("name", "")
147 + if name == repo_name:
148 + return repo
149 + return None
150 +
151 +
152 +def score_hype_risk(
153 + correlations: dict,
154 + raw_data: dict | list | None = None,
155 + previous_data: dict | list | None = None,
156 +) -> list[dict]:
157 + """Score hype risk for all repos in correlations data.
158 +
159 + Args:
160 + correlations: Correlation analysis output with correlated repos.
161 + raw_data: Current week raw GitHub data.
162 + previous_data: Previous week raw GitHub data.
163 +
164 + Returns:
165 + List of assessment dicts.
166 + """
167 + # Extract correlated repos
168 + correlated_repos = set()
169 + corr_entries = correlations.get("correlations", correlations.get("repos", []))
170 + if isinstance(corr_entries, list):
171 + for entry in corr_entries:
172 + repo_name = entry.get("repo") or entry.get("full_name", "")
173 + if entry.get("press_correlated", False):
174 + correlated_repos.add(repo_name)
175 +
176 + # Normalize raw data to lists
177 + raw_repos = _normalize_raw(raw_data)
178 + prev_repos = _normalize_raw(previous_data)
179 +
180 + # Collect all repo names from raw data
181 + all_repos = set()
182 + for repo in raw_repos:
183 + name = repo.get("full_name") or repo.get("repo") or repo.get("name", "")
184 + if name:
185 + all_repos.add(name)
186 + # Also include correlated repos even if not in current raw
187 + all_repos.update(correlated_repos)
188 +
189 + assessments = []
190 + for repo_name in sorted(all_repos):
191 + press_correlated = repo_name in correlated_repos
192 +
193 + current = _find_repo_in_raw(raw_repos, repo_name)
194 + previous = _find_repo_in_raw(prev_repos, repo_name)
195 +
196 + current_stars = current.get("stars") if current else None
197 + current_gained = current.get("stars_gained") if current else None
198 + prev_stars = previous.get("stars") if previous else None
199 + prev_gained = previous.get("stars_gained") if previous else None
200 +
201 + assessment = classify_repo(
202 + repo_name,
203 + press_correlated=press_correlated,
204 + current_stars=current_stars,
205 + current_stars_gained=current_gained,
206 + previous_stars=prev_stars,
207 + previous_stars_gained=prev_gained,
208 + )
209 + assessments.append(assessment)
210 +
211 + return assessments
212 +
213 +
214 +def _normalize_raw(data: dict | list | None) -> list[dict]:
215 + """Normalize raw data to a list of repo dicts."""
216 + if data is None:
217 + return []
218 + if isinstance(data, list):
219 + return data
220 + # Could be wrapped in a dict with 'repos' or 'repositories' key
221 + if isinstance(data, dict):
222 + for key in ("repos", "repositories", "items"):
223 + if key in data and isinstance(data[key], list):
224 + return data[key]
225 + return []
226 + return []
227 +
228 +
229 +def extract_week(filepath: str | Path | None) -> str:
230 + """Try to extract week identifier from a filepath like 2026-W21.json."""
231 + if filepath is None:
232 + return "unknown"
233 + name = Path(filepath).stem
234 + # Remove suffixes like -correlations, -hype-risk
235 + for suffix in ("-correlations", "-hype-risk", "-metrics"):
236 + if name.endswith(suffix):
237 + name = name[: -len(suffix)]
238 + return name
239 +
240 +
241 +def main(argv: list[str] | None = None) -> None:
242 + parser = argparse.ArgumentParser(description="Hype risk scoring model")
243 + parser.add_argument(
244 + "--correlations",
245 + help="Path to correlations JSON file",
246 + )
247 + parser.add_argument(
248 + "--raw",
249 + help="Path to current week raw data JSON",
250 + )
251 + parser.add_argument(
252 + "--previous",
253 + help="Path to previous week raw data JSON",
254 + )
255 + parser.add_argument(
256 + "--output",
257 + help="Output path for hype risk JSON",
258 + )
259 + parser.add_argument(
260 + "--topic",
261 + help="Topic ID for path resolution",
262 + )
263 + args = parser.parse_args(argv)
264 +
265 + # Resolve paths
266 + topic = args.topic
267 + corr_path = Path(args.correlations) if args.correlations else None
268 + raw_path = Path(args.raw) if args.raw else None
269 + prev_path = Path(args.previous) if args.previous else None
270 + out_path = Path(args.output) if args.output else None
271 +
272 + if corr_path is None:
273 + print("Error: --correlations is required", file=sys.stderr)
274 + sys.exit(1)
275 +
276 + # Load data
277 + with open(corr_path, encoding="utf-8") as f:
278 + correlations = json.load(f)
279 +
280 + raw_data = None
281 + if raw_path and raw_path.exists():
282 + with open(raw_path, encoding="utf-8") as f:
283 + raw_data = json.load(f)
284 +
285 + previous_data = None
286 + if prev_path and prev_path.exists():
287 + with open(prev_path, encoding="utf-8") as f:
288 + previous_data = json.load(f)
289 +
290 + # Score
291 + assessments = score_hype_risk(correlations, raw_data, previous_data)
292 +
293 + # Build output
294 + week = extract_week(args.raw or args.correlations)
295 + output = {
296 + "week": week,
297 + "assessments": assessments,
298 + }
299 +
300 + # Write or print
301 + if out_path:
302 + out_path.parent.mkdir(parents=True, exist_ok=True)
303 + with open(out_path, "w", encoding="utf-8") as f:
304 + json.dump(output, f, indent=2)
305 + print(f"Wrote {len(assessments)} assessments to {out_path}")
306 + else:
307 + print(json.dumps(output, indent=2))
308 +
309 +
310 +if __name__ == "__main__":
311 + main()
tests/test_correlate.py new
+286
@@ -0,0 +1,286 @@
1 +"""Tests for the cross-source correlation engine."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +from pathlib import Path
7 +
8 +import pytest
9 +
10 +from scripts.correlate import (
11 + assess_hype_risk,
12 + correlate_all,
13 + correlate_repo,
14 + extract_week_from_filename,
15 + fuzzy_name_score,
16 + has_temporal_spike,
17 + match_category,
18 + match_direct_link,
19 + match_org_name,
20 + match_project_name,
21 + _token_overlap_ratio,
22 +)
23 +
24 +
25 +# ---------------------------------------------------------------------------
26 +# Fixtures
27 +# ---------------------------------------------------------------------------
28 +
29 +
30 +def _repo(
31 + name: str = "cool-project",
32 + owner: str = "acme",
33 + full_name: str | None = None,
34 + url: str | None = None,
35 + topics: list[str] | None = None,
36 + stars_gained: int | None = None,
37 + stars: int = 100,
38 +) -> dict:
39 + fn = full_name or f"{owner}/{name}"
40 + return {
41 + "name": name,
42 + "owner": owner,
43 + "full_name": fn,
44 + "url": url or f"https://github.com/{fn}",
45 + "topics": topics or [],
46 + "stars_gained": stars_gained,
47 + "stars": stars,
48 + }
49 +
50 +
51 +def _article(
52 + title: str = "Acme launches Cool Project",
53 + url: str = "https://techcrunch.com/2026/05/20/acme-cool-project/",
54 + github_links: list[str] | None = None,
55 + entities: list[str] | None = None,
56 + categories: list[str] | None = None,
57 +) -> dict:
58 + return {
59 + "title": title,
60 + "url": url,
61 + "github_links": github_links or [],
62 + "entities": entities or [],
63 + "categories": categories or [],
64 + }
65 +
66 +
67 +# ---------------------------------------------------------------------------
68 +# Heuristic 1: Direct GitHub link match
69 +# ---------------------------------------------------------------------------
70 +
71 +
72 +class TestDirectLinkMatch:
73 + def test_exact_url_match(self):
74 + repo = _repo(url="https://github.com/acme/cool-project")
75 + article = _article(github_links=["https://github.com/acme/cool-project"])
76 + assert match_direct_link(repo, [article]) == [article]
77 +
78 + def test_trailing_slash_normalization(self):
79 + repo = _repo(url="https://github.com/acme/cool-project/")
80 + article = _article(github_links=["https://github.com/acme/cool-project"])
81 + assert match_direct_link(repo, [article]) == [article]
82 +
83 + def test_case_insensitive(self):
84 + repo = _repo(url="https://github.com/Acme/Cool-Project")
85 + article = _article(github_links=["https://github.com/acme/cool-project"])
86 + assert match_direct_link(repo, [article]) == [article]
87 +
88 + def test_no_match(self):
89 + repo = _repo(name="other-project", owner="acme", url="https://github.com/acme/other-project")
90 + article = _article(github_links=["https://github.com/acme/cool-project"])
91 + assert match_direct_link(repo, [article]) == []
92 +
93 +
94 +# ---------------------------------------------------------------------------
95 +# Heuristic 2: Organization name match
96 +# ---------------------------------------------------------------------------
97 +
98 +
99 +class TestOrgNameMatch:
100 + def test_owner_in_entities(self):
101 + repo = _repo(owner="OpenAI")
102 + article = _article(entities=["OpenAI", "Google"])
103 + assert match_org_name(repo, [article]) == [article]
104 +
105 + def test_case_insensitive_match(self):
106 + repo = _repo(owner="openai")
107 + article = _article(entities=["OpenAI"])
108 + assert match_org_name(repo, [article]) == [article]
109 +
110 + def test_no_match(self):
111 + repo = _repo(owner="acme")
112 + article = _article(entities=["Google", "Meta"])
113 + assert match_org_name(repo, [article]) == []
114 +
115 + def test_short_owner_skipped(self):
116 + repo = _repo(owner="x")
117 + article = _article(entities=["x"])
118 + assert match_org_name(repo, [article]) == []
119 +
120 +
121 +# ---------------------------------------------------------------------------
122 +# Heuristic 3: Project name fuzzy match
123 +# ---------------------------------------------------------------------------
124 +
125 +
126 +class TestProjectNameMatch:
127 + def test_exact_name_in_entity(self):
128 + repo = _repo(name="langchain")
129 + article = _article(entities=["LangChain"])
130 + assert match_project_name(repo, [article]) == [article]
131 +
132 + def test_fuzzy_name_in_title(self):
133 + repo = _repo(name="tensorflow")
134 + article = _article(title="TensorFlow 3.0 released with new features")
135 + assert match_project_name(repo, [article]) == [article]
136 +
137 + def test_no_match_different_name(self):
138 + repo = _repo(name="pytorch")
139 + article = _article(title="React 20 is out", entities=["React"])
140 + assert match_project_name(repo, [article]) == []
141 +
142 + def test_short_name_skipped(self):
143 + repo = _repo(name="go")
144 + article = _article(entities=["Go"])
145 + assert match_project_name(repo, [article]) == []
146 +
147 +
148 +# ---------------------------------------------------------------------------
149 +# Heuristic 4: Category correlation
150 +# ---------------------------------------------------------------------------
151 +
152 +
153 +class TestCategoryMatch:
154 + def test_topic_category_overlap(self):
155 + repo = _repo(topics=["machine-learning", "python"])
156 + article = _article(categories=["machine-learning", "startups"])
157 + assert match_category(repo, [article]) == [article]
158 +
159 + def test_no_overlap(self):
160 + repo = _repo(topics=["rust", "systems"])
161 + article = _article(categories=["machine-learning", "startups"])
162 + assert match_category(repo, [article]) == []
163 +
164 + def test_empty_topics(self):
165 + repo = _repo(topics=[])
166 + article = _article(categories=["ai"])
167 + assert match_category(repo, [article]) == []
168 +
169 +
170 +# ---------------------------------------------------------------------------
171 +# Heuristic 5: Temporal spike
172 +# ---------------------------------------------------------------------------
173 +
174 +
175 +class TestTemporalSpike:
176 + def test_spike_detected(self):
177 + repo = _repo(stars_gained=50)
178 + assert has_temporal_spike(repo) is True
179 +
180 + def test_no_spike(self):
181 + repo = _repo(stars_gained=5)
182 + assert has_temporal_spike(repo) is False
183 +
184 + def test_none_stars_gained(self):
185 + repo = _repo(stars_gained=None)
186 + assert has_temporal_spike(repo) is False
187 +
188 +
189 +# ---------------------------------------------------------------------------
190 +# Hype risk assessment
191 +# ---------------------------------------------------------------------------
192 +
193 +
194 +class TestHypeRisk:
195 + def test_high_confidence_high_stars(self):
196 + assert assess_hype_risk(0.9, 200) == "high"
197 +
198 + def test_high_confidence_low_stars(self):
199 + assert assess_hype_risk(0.8, 50) == "medium"
200 +
201 + def test_medium_confidence(self):
202 + assert assess_hype_risk(0.6, 10) == "medium"
203 +
204 + def test_low_confidence(self):
205 + assert assess_hype_risk(0.4, 5) == "low"
206 +
207 + def test_none_risk(self):
208 + assert assess_hype_risk(0.2, 0) == "none"
209 +
210 +
211 +# ---------------------------------------------------------------------------
212 +# Integration: correlate_repo
213 +# ---------------------------------------------------------------------------
214 +
215 +
216 +class TestCorrelateRepo:
217 + def test_direct_link_takes_priority(self):
218 + repo = _repo(owner="acme", name="cool-project", stars_gained=5)
219 + article = _article(
220 + github_links=["https://github.com/acme/cool-project"],
221 + entities=["Acme"],
222 + )
223 + result = correlate_repo(repo, [article])
224 + assert result is not None
225 + assert result["match_type"] == "direct_link"
226 + assert result["correlation_confidence"] == 1.0
227 +
228 + def test_no_match_returns_none(self):
229 + repo = _repo(owner="nobody", name="nothing", topics=[])
230 + article = _article(entities=["Google"], categories=["finance"])
231 + assert correlate_repo(repo, [article]) is None
232 +
233 + def test_temporal_boost(self):
234 + repo = _repo(owner="acme", name="something", stars_gained=50)
235 + article = _article(entities=["Acme"])
236 + result = correlate_repo(repo, [article])
237 + assert result is not None
238 + assert result["correlation_confidence"] == 1.0 # 0.8 + 0.2
239 +
240 +
241 +# ---------------------------------------------------------------------------
242 +# Integration: correlate_all
243 +# ---------------------------------------------------------------------------
244 +
245 +
246 +class TestCorrelateAll:
247 + def test_full_pipeline(self):
248 + repos = [
249 + _repo(owner="acme", name="project-a"),
250 + _repo(owner="nobody", name="unrelated", topics=[]),
251 + ]
252 + articles = [_article(entities=["Acme"])]
253 + result = correlate_all(repos, articles, "2026-W21")
254 + assert result["week"] == "2026-W21"
255 + assert len(result["correlations"]) == 1
256 + assert result["correlations"][0]["repo"] == "acme/project-a"
257 + assert "nobody/unrelated" in result["uncorrelated_repos"]
258 + assert result["metadata"]["repos_analyzed"] == 2
259 + assert result["metadata"]["correlations_found"] == 1
260 +
261 + def test_empty_articles(self):
262 + repos = [_repo()]
263 + result = correlate_all(repos, [], "2026-W21")
264 + assert result["correlations"] == []
265 + assert len(result["uncorrelated_repos"]) == 1
266 +
267 +
268 +# ---------------------------------------------------------------------------
269 +# Utility functions
270 +# ---------------------------------------------------------------------------
271 +
272 +
273 +class TestUtilities:
274 + def test_extract_week_from_filename(self):
275 + assert extract_week_from_filename(Path("2026-W21.json")) == "2026-W21"
276 + assert extract_week_from_filename(Path("data.json")) == "unknown"
277 +
278 + def test_token_overlap_ratio(self):
279 + assert _token_overlap_ratio("machine-learning", "machine learning") == 1.0
280 + assert _token_overlap_ratio("foo-bar", "baz-qux") == 0.0
281 +
282 + def test_fuzzy_name_score_identical(self):
283 + assert fuzzy_name_score("langchain", "langchain") == 1.0
284 +
285 + def test_fuzzy_name_score_empty(self):
286 + assert fuzzy_name_score("", "something") == 0.0
tests/test_hype_risk.py new
+193
@@ -0,0 +1,193 @@
1 +"""Tests for hype_risk scoring model."""
2 +
3 +import json
4 +import sys
5 +from pathlib import Path
6 +
7 +import pytest
8 +
9 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
10 +from hype_risk import classify_repo, extract_week, score_hype_risk # noqa: E402
11 +
12 +
13 +class TestClassifyRepo:
14 + """Test individual repo classification paths."""
15 +
16 + def test_no_press_correlation(self):
17 + """Not press_correlated → risk = 'none'."""
18 + result = classify_repo("org/repo", press_correlated=False)
19 + assert result["hype_risk"] == "none"
20 + assert result["label"] == "No press signal"
21 + assert result["press_correlated"] is False
22 +
23 + def test_press_correlated_no_previous_data(self):
24 + """Press correlated but no previous data → risk = 'medium'."""
25 + result = classify_repo(
26 + "org/repo",
27 + press_correlated=True,
28 + current_stars=1000,
29 + current_stars_gained=200,
30 + previous_stars=None,
31 + previous_stars_gained=None,
32 + )
33 + assert result["hype_risk"] == "medium"
34 + assert result["press_correlated"] is True
35 + assert result["confidence"] < 0.6
36 +
37 + def test_organic_growth_before_press(self):
38 + """Stars already growing before press → risk = 'very_low'."""
39 + result = classify_repo(
40 + "org/repo",
41 + press_correlated=True,
42 + current_stars=2000,
43 + current_stars_gained=100,
44 + previous_stars=1900,
45 + previous_stars_gained=80, # Already had strong growth
46 + )
47 + assert result["hype_risk"] == "very_low"
48 + assert result["label"] == "Organic growth"
49 + assert result["stars_trend"] == "organic"
50 +
51 + def test_sustained_growth_after_press(self):
52 + """Stars spike after article and sustain → risk = 'low'."""
53 + result = classify_repo(
54 + "org/repo",
55 + press_correlated=True,
56 + current_stars=5000,
57 + current_stars_gained=500,
58 + previous_stars=4500,
59 + previous_stars_gained=100, # Small growth before, big growth now
60 + )
61 + assert result["hype_risk"] == "low"
62 + assert result["label"] == "Press-validated, community-sustained"
63 + assert result["stars_trend"] == "sustained"
64 +
65 + def test_decaying_growth_after_press(self):
66 + """Stars spiked but now fading → risk = 'high'."""
67 + result = classify_repo(
68 + "org/repo",
69 + press_correlated=True,
70 + current_stars=5000,
71 + current_stars_gained=50, # Much lower than previous
72 + previous_stars=4950,
73 + previous_stars_gained=500, # Big spike last week
74 + )
75 + assert result["hype_risk"] == "high"
76 + assert result["label"] == "Press-driven hype, fading"
77 + assert result["stars_trend"] == "decaying"
78 +
79 + def test_press_correlated_no_activity_spike(self):
80 + """Press coverage but no GitHub activity spike → risk = 'medium'."""
81 + result = classify_repo(
82 + "org/repo",
83 + press_correlated=True,
84 + current_stars=100,
85 + current_stars_gained=0,
86 + previous_stars=100,
87 + previous_stars_gained=0,
88 + )
89 + assert result["hype_risk"] == "medium"
90 + assert result["label"] == "Announced but unbuilt"
91 +
92 + def test_assessment_has_all_fields(self):
93 + """Every assessment should have all required fields."""
94 + result = classify_repo("org/repo", press_correlated=False)
95 + assert "repo" in result
96 + assert "hype_risk" in result
97 + assert "label" in result
98 + assert "press_correlated" in result
99 + assert "stars_trend" in result
100 + assert "confidence" in result
101 + assert "reasoning" in result
102 +
103 +
104 +class TestScoreHypeRisk:
105 + """Test the batch scoring function."""
106 +
107 + def test_empty_correlations(self):
108 + result = score_hype_risk({"correlations": []}, None, None)
109 + assert result == []
110 +
111 + def test_scores_correlated_repos(self):
112 + correlations = {
113 + "correlations": [
114 + {"repo": "org/alpha", "press_correlated": True},
115 + {"repo": "org/beta", "press_correlated": False},
116 + ]
117 + }
118 + raw_data = [
119 + {"full_name": "org/alpha", "stars": 1000, "stars_gained": 200},
120 + {"full_name": "org/beta", "stars": 500, "stars_gained": 10},
121 + ]
122 + result = score_hype_risk(correlations, raw_data, None)
123 + assert len(result) == 2
124 +
125 + alpha = next(a for a in result if a["repo"] == "org/alpha")
126 + beta = next(a for a in result if a["repo"] == "org/beta")
127 +
128 + assert alpha["hype_risk"] == "medium" # correlated, no previous
129 + assert beta["hype_risk"] == "none" # not correlated
130 +
131 + def test_with_previous_data(self):
132 + correlations = {
133 + "correlations": [
134 + {"repo": "org/sustained", "press_correlated": True},
135 + ]
136 + }
137 + raw_data = [
138 + {"full_name": "org/sustained", "stars": 3000, "stars_gained": 400},
139 + ]
140 + previous_data = [
141 + {"full_name": "org/sustained", "stars": 2600, "stars_gained": 50},
142 + ]
143 + result = score_hype_risk(correlations, raw_data, previous_data)
144 + sustained = next(a for a in result if a["repo"] == "org/sustained")
145 + assert sustained["hype_risk"] == "low"
146 +
147 + def test_raw_data_wrapped_in_dict(self):
148 + """Raw data may be wrapped in a dict with 'repos' key."""
149 + correlations = {"correlations": [{"repo": "x/y", "press_correlated": False}]}
150 + raw_data = {"repos": [{"full_name": "x/y", "stars": 10, "stars_gained": 1}]}
151 + result = score_hype_risk(correlations, raw_data, None)
152 + assert len(result) == 1
153 + assert result[0]["hype_risk"] == "none"
154 +
155 +
156 +class TestExtractWeek:
157 + def test_simple_week(self):
158 + assert extract_week("data/raw/ai-ml/2026-W21.json") == "2026-W21"
159 +
160 + def test_correlations_suffix(self):
161 + assert extract_week("data/analyzed/ai-ml/2026-W21-correlations.json") == "2026-W21"
162 +
163 + def test_none_path(self):
164 + assert extract_week(None) == "unknown"
165 +
166 +
167 +class TestCLI:
168 + """Test CLI main function."""
169 +
170 + def test_main_with_files(self, tmp_path):
171 + from hype_risk import main
172 +
173 + corr_file = tmp_path / "correlations.json"
174 + raw_file = tmp_path / "2026-W21.json"
175 + out_file = tmp_path / "output.json"
176 +
177 + corr_file.write_text(json.dumps({
178 + "correlations": [{"repo": "org/repo", "press_correlated": True}]
179 + }))
180 + raw_file.write_text(json.dumps([
181 + {"full_name": "org/repo", "stars": 500, "stars_gained": 100}
182 + ]))
183 +
184 + main([
185 + "--correlations", str(corr_file),
186 + "--raw", str(raw_file),
187 + "--output", str(out_file),
188 + ])
189 +
190 + output = json.loads(out_file.read_text())
191 + assert output["week"] == "2026-W21"
192 + assert len(output["assessments"]) == 1
193 + assert output["assessments"][0]["repo"] == "org/repo"