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"