feat: GitHub crawl shard experiment with wall-clock/API/rate-limit guardrails (#456)
Merging crawl shard experiment. Will be validated via a separate action before replacing the main crawl. Key metrics to judge: performance improvement and quality parity (map/reduce must not degrade analysis quality). The main value is unlocking more sources without context window pressure.
Juan Manuel Servera committed
Jun 13, 2026 at 17:50 UTC
8efce6c9a588b5babdee1e82d72b97713c3fd744
3 files changed
+1585
docs/experiments/shard-435-plan.md
new
+71
@@ -0,0 +1,71 @@
1
+# Shard 435 Crawl Experiment Plan
2
+
3
+## Goal
4
+
5
+Run a local-only, no-publish experiment that compares the monolithic GitHub crawl with a sharded variant while enforcing:
6
+
7
+- at least 25% wall-clock improvement
8
+- no more than 10% GitHub API growth
9
+- no secondary-rate-limit regression
10
+- byte-stable downstream payloads after deterministic fan-in
11
+
12
+## Shard Boundaries
13
+
14
+1. **Shard 1 — new-search**
15
+ - runs the new-repository search query group
16
+ - in config mode, runs primary queries first and only falls back to secondary queries when needed
17
+
18
+2. **Shard 2 — trending-search**
19
+ - runs the trending-repository search query group
20
+ - skipped only when config mode intentionally has no trending bucket and must preserve existing semantics
21
+
22
+3. **Shard 3+ — validate-N**
23
+ - consume deduplicated candidate chunks from a shared queue
24
+ - perform README/detail validation and final filtering
25
+ - requeue unfinished work when a shard hits its wall-clock limit so other shards can absorb the remainder
26
+
27
+## Deterministic Fan-in
28
+
29
+- keep new/trending buckets separate to preserve the canonical raw-artifact shape
30
+- dedupe within each bucket by `full_name`
31
+- sort final outputs like `crawl.py`
32
+ - new repos by stars descending
33
+ - trending repos by `stars_gained`, then stars descending
34
+- rebuild signals and star snapshots only after fan-in
35
+- compare canonical payload bytes after stripping volatile timestamp metadata
36
+
37
+## Guardrails
38
+
39
+- **Wall-clock budget:** each shard gets a configurable deadline (default 120s)
40
+- **API budget:** shard mode shares a global counter capped at `baseline_calls × 1.1`
41
+- **Shared backoff:** retry backoff windows are propagated across shard clients
42
+- **Rollback condition:** any secondary rate limit aborts remaining shard work and records a guardrail event
43
+
44
+## Assumptions
45
+
46
+- threading is sufficient because the workload is API I/O bound
47
+- cache reads are safe to share across shard clients
48
+- cross-bucket duplicates may still appear in both `new_repos` and `trending_repos` because the baseline crawl already allows that
49
+- at least three representative real runs are still required after implementation
50
+
51
+## Output Artifacts
52
+
53
+All experiment artifacts stay under `data/experiments/shard-435/EXPERIMENT_ID/`:
54
+
55
+- `baseline-raw.json`
56
+- `baseline-stars.json`
57
+- `shard-raw.json`
58
+- `shard-stars.json`
59
+- `report.json`
60
+
61
+## Suggested Command
62
+
63
+```bash
64
+python -m scripts.crawl_shard_experiment \
65
+ --since 2026-06-06 \
66
+ --as-of 2026-06-13 \
67
+ --shards 3 \
68
+ --wall-clock-budget 300 \
69
+ --api-budget-multiplier 1.1 \
70
+ --output-dir data/experiments/shard-435
71
+```
scripts/crawl_shard_experiment.py
new
+1145
@@ -0,0 +1,1145 @@
1
+#!/usr/bin/env python3
2
+"""Run a local no-publish GitHub crawl sharding experiment for issue #435."""
3
+
4
+from __future__ import annotations
5
+
6
+import argparse
7
+import json
8
+import math
9
+import os
10
+import threading
11
+import time
12
+from collections import Counter
13
+from concurrent.futures import ThreadPoolExecutor, as_completed
14
+from dataclasses import dataclass, field
15
+from datetime import UTC, datetime
16
+from pathlib import Path
17
+from queue import Empty, Queue
18
+from typing import Any
19
+
20
+from scripts.crawl import (
21
+ GitHubClient,
22
+ RAW_ROOT,
23
+ build_signals,
24
+ build_star_snapshot,
25
+ collect_repositories,
26
+ github_artifact_checksum,
27
+ github_crawl_config_checksum,
28
+ github_schema_checksum,
29
+ iso_timestamp,
30
+ load_previous_star_snapshot,
31
+ load_topic_queries,
32
+ significance_skip_reason,
33
+ to_repo_record,
34
+ utc_now,
35
+ validate_payload,
36
+ week_slug,
37
+ write_payload,
38
+)
39
+from scripts.topic_paths import cache_dir, raw_dir, snapshots_dir
40
+
41
+EXPERIMENT_ROOT = Path("data/experiments/shard-435")
42
+DEFAULT_WALL_CLOCK_BUDGET = 120
43
+DEFAULT_API_BUDGET_MULTIPLIER = 1.1
44
+
45
+
46
+class ExperimentAbort(RuntimeError):
47
+ """Abort the shard experiment immediately."""
48
+
49
+
50
+class ShardBudgetExceeded(RuntimeError):
51
+ """Stop a shard gracefully once its wall-clock budget is exhausted."""
52
+
53
+
54
+@dataclass(slots=True)
55
+class GuardrailEvent:
56
+ kind: str
57
+ shard: str
58
+ message: str
59
+ at: str
60
+ details: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ payload = {"kind": self.kind, "shard": self.shard, "message": self.message, "at": self.at}
64
+ if self.details:
65
+ payload["details"] = self.details
66
+ return payload
67
+
68
+
69
+@dataclass(slots=True)
70
+class SearchPlan:
71
+ shard_name: str
72
+ repo_group: str
73
+ primary_queries: list[str]
74
+ secondary_queries: list[str] = field(default_factory=list)
75
+ min_repos_per_week: int = 0
76
+
77
+
78
+@dataclass(slots=True)
79
+class ValidationItem:
80
+ repo_group: str
81
+ sequence: int
82
+ repo: dict[str, Any]
83
+
84
+
85
+@dataclass(slots=True)
86
+class ValidatedRecord:
87
+ repo_group: str
88
+ sequence: int
89
+ record: dict[str, Any]
90
+
91
+
92
+@dataclass(slots=True)
93
+class CrawlContext:
94
+ args: argparse.Namespace
95
+ topic_id: str | None
96
+ topic_raw: Path
97
+ topic_snapshots: Path
98
+ topic_cache: Path
99
+ crawled_at: datetime
100
+ run_started_at: datetime
101
+ since: datetime
102
+ window_end: datetime
103
+ week: str
104
+ max_results: int
105
+ config_checksum: str
106
+ current_code_sha: str
107
+ source_refresh_policy: str
108
+ baseline_output_path: Path
109
+ baseline_snapshot_path: Path
110
+ shard_output_path: Path
111
+ shard_snapshot_path: Path
112
+
113
+
114
+@dataclass(slots=True)
115
+class RunResult:
116
+ name: str
117
+ payload: dict[str, Any]
118
+ snapshot_payload: dict[str, Any]
119
+ api_calls: int
120
+ cache_hits: int
121
+ stale_cache_hits: int
122
+ rate_limit_events: int
123
+ secondary_rate_limit_events: int
124
+ partial_failures: list[str]
125
+ wall_clock_s: float
126
+ shards_used: int
127
+ completed: bool
128
+ guardrail_events: list[dict[str, Any]]
129
+
130
+
131
+@dataclass(slots=True)
132
+class ShardResult:
133
+ shard_id: str
134
+ repos_found: list[dict[str, Any]]
135
+ api_calls: int
136
+ errors: list[str]
137
+ wall_clock_s: float
138
+ rate_limit_detected: bool = False
139
+ rate_limit_events: int = 0
140
+
141
+
142
+@dataclass(slots=True)
143
+class ExperimentReport:
144
+ speedup_pct: float | None
145
+ api_growth_pct: float | None
146
+ rate_limit_regression: bool
147
+ output_stable: bool
148
+ partial_data: bool
149
+ baseline_complete: bool
150
+ shard_complete: bool
151
+ verdict: str
152
+
153
+ @classmethod
154
+ def from_comparison(cls, comparison: dict[str, Any]) -> "ExperimentReport":
155
+ speedup_pct = comparison.get("speedup_pct")
156
+ api_growth_pct = comparison.get("api_growth_pct")
157
+ rate_limit_regression = bool(
158
+ comparison.get("rate_limit_regression", comparison.get("secondary_rate_limit_regression", False))
159
+ )
160
+ output_stable = bool(comparison.get("output_stable", False))
161
+ partial_data = bool(comparison.get("partial_data", False))
162
+ baseline_complete = bool(comparison.get("baseline_complete", True))
163
+ shard_complete = bool(comparison.get("shard_complete", True))
164
+ if partial_data or not baseline_complete or not shard_complete:
165
+ verdict = "inconclusive"
166
+ elif (
167
+ speedup_pct is not None
168
+ and api_growth_pct is not None
169
+ and output_stable
170
+ and not rate_limit_regression
171
+ and float(speedup_pct) >= 25.0
172
+ and float(api_growth_pct) <= 10.0
173
+ ):
174
+ verdict = "pass"
175
+ else:
176
+ verdict = "fail"
177
+ return cls(
178
+ speedup_pct=speedup_pct,
179
+ api_growth_pct=api_growth_pct,
180
+ rate_limit_regression=rate_limit_regression,
181
+ output_stable=output_stable,
182
+ partial_data=partial_data,
183
+ baseline_complete=baseline_complete,
184
+ shard_complete=shard_complete,
185
+ verdict=verdict,
186
+ )
187
+
188
+
189
+class SharedQuotaTracker:
190
+ def __init__(self, cap: int) -> None:
191
+ self.cap = max(0, int(cap))
192
+ self.count = 0
193
+ self._lock = threading.Lock()
194
+
195
+ def increment(self) -> bool:
196
+ with self._lock:
197
+ if self.count >= self.cap:
198
+ return False
199
+ self.count += 1
200
+ return True
201
+
202
+ def reset(self) -> None:
203
+ with self._lock:
204
+ self.count = 0
205
+
206
+
207
+class WallClockBudget:
208
+ def __init__(self, budget_s: float) -> None:
209
+ self.budget_s = float(budget_s)
210
+ self.started_at = time.monotonic()
211
+
212
+ def elapsed_s(self) -> float:
213
+ return time.monotonic() - self.started_at
214
+
215
+ def is_exceeded(self) -> bool:
216
+ return self.elapsed_s() > self.budget_s
217
+
218
+
219
+class SharedQuotaCoordinator:
220
+ def __init__(self, api_hard_cap: int | None) -> None:
221
+ self.api_hard_cap = api_hard_cap
222
+ self.total_api_calls = 0
223
+ self.global_backoff_until = 0.0
224
+ self.secondary_rate_limit_hit = False
225
+ self.abort_reason: str | None = None
226
+ self._guardrail_events: list[GuardrailEvent] = []
227
+ self._lock = threading.Lock()
228
+
229
+ def before_request(self, shard_name: str, query: str, deadline: float | None) -> None:
230
+ self._raise_if_aborted()
231
+ self._raise_if_budget_exceeded(shard_name, deadline)
232
+ while True:
233
+ with self._lock:
234
+ resume_at = self.global_backoff_until
235
+ now = time.monotonic()
236
+ if resume_at <= now:
237
+ break
238
+ time.sleep(min(resume_at - now, 1.0))
239
+ self._raise_if_aborted()
240
+ self._raise_if_budget_exceeded(shard_name, deadline)
241
+ # Atomically reserve one API call slot to prevent concurrent over-cap
242
+ with self._lock:
243
+ if self.api_hard_cap is not None and self.total_api_calls >= self.api_hard_cap:
244
+ if self.abort_reason is None:
245
+ self.abort_reason = (
246
+ f"API budget cap reached before {shard_name} requested {query} "
247
+ f"({self.total_api_calls}/{self.api_hard_cap})."
248
+ )
249
+ self._guardrail_events.append(
250
+ GuardrailEvent(
251
+ kind="api_budget_cap",
252
+ shard=shard_name,
253
+ message=self.abort_reason,
254
+ at=iso_timestamp(utc_now()),
255
+ details={"api_calls_used": self.total_api_calls, "api_hard_cap": self.api_hard_cap},
256
+ )
257
+ )
258
+ raise ExperimentAbort(self.abort_reason)
259
+ # Reserve capacity for this request before releasing the lock
260
+ self.total_api_calls += 1
261
+
262
+ def register_api_calls(self, delta: int, shard_name: str) -> None:
263
+ # Subtract the 1 call already reserved in before_request()
264
+ additional = delta - 1
265
+ if additional <= 0:
266
+ return
267
+ with self._lock:
268
+ self.total_api_calls += additional
269
+ if self.api_hard_cap is not None and self.total_api_calls > self.api_hard_cap and self.abort_reason is None:
270
+ self.abort_reason = (
271
+ f"API budget cap exceeded by {shard_name} "
272
+ f"({self.total_api_calls}/{self.api_hard_cap})."
273
+ )
274
+ self._guardrail_events.append(
275
+ GuardrailEvent(
276
+ kind="api_budget_cap",
277
+ shard=shard_name,
278
+ message=self.abort_reason,
279
+ at=iso_timestamp(utc_now()),
280
+ details={"api_calls_used": self.total_api_calls, "api_hard_cap": self.api_hard_cap},
281
+ )
282
+ )
283
+
284
+ def register_backoff(self, shard_name: str, delay: float, reason: str, *, secondary: bool = False) -> None:
285
+ delay = max(delay, 1.0)
286
+ with self._lock:
287
+ self.global_backoff_until = max(self.global_backoff_until, time.monotonic() + delay)
288
+ self._guardrail_events.append(
289
+ GuardrailEvent(
290
+ kind="secondary_rate_limit" if secondary else "rate_limit_backoff",
291
+ shard=shard_name,
292
+ message=reason,
293
+ at=iso_timestamp(utc_now()),
294
+ details={"delay_seconds": round(delay, 2)},
295
+ )
296
+ )
297
+ if secondary:
298
+ self.secondary_rate_limit_hit = True
299
+ self.abort_reason = reason
300
+
301
+ def record_budget_exceeded(self, shard_name: str, budget_seconds: int) -> None:
302
+ with self._lock:
303
+ self._guardrail_events.append(
304
+ GuardrailEvent(
305
+ kind="wall_clock_budget",
306
+ shard=shard_name,
307
+ message=f"{shard_name} exhausted its {budget_seconds}s wall-clock budget.",
308
+ at=iso_timestamp(utc_now()),
309
+ details={"budget_seconds": budget_seconds},
310
+ )
311
+ )
312
+
313
+ def record_redistribution(self, shard_name: str, repo_count: int) -> None:
314
+ if repo_count <= 0:
315
+ return
316
+ with self._lock:
317
+ self._guardrail_events.append(
318
+ GuardrailEvent(
319
+ kind="work_redistributed",
320
+ shard=shard_name,
321
+ message=f"Redistributed {repo_count} remaining repositories from {shard_name}.",
322
+ at=iso_timestamp(utc_now()),
323
+ details={"repo_count": repo_count},
324
+ )
325
+ )
326
+
327
+ def guardrail_events(self) -> list[dict[str, Any]]:
328
+ with self._lock:
329
+ return [event.to_dict() for event in self._guardrail_events]
330
+
331
+ def _raise_if_aborted(self) -> None:
332
+ with self._lock:
333
+ if self.abort_reason is not None:
334
+ raise ExperimentAbort(self.abort_reason)
335
+
336
+ def _raise_if_budget_exceeded(self, shard_name: str, deadline: float | None) -> None:
337
+ if deadline is not None and time.monotonic() >= deadline:
338
+ raise ShardBudgetExceeded(f"{shard_name} exceeded its wall-clock budget.")
339
+
340
+
341
+class InstrumentedGitHubClient(GitHubClient):
342
+ def __init__(
343
+ self,
344
+ token: str,
345
+ *,
346
+ cache_dir: Path,
347
+ shard_name: str,
348
+ coordinator: SharedQuotaCoordinator | None = None,
349
+ deadline: float | None = None,
350
+ timeout: int = 30,
351
+ max_retries: int = 6,
352
+ ) -> None:
353
+ self._api_calls_used = 0
354
+ self.shard_name = shard_name
355
+ self.coordinator = coordinator
356
+ self.deadline = deadline
357
+ self.rate_limit_events = 0
358
+ self.secondary_rate_limit_events = 0
359
+ super().__init__(token, cache_dir=cache_dir, timeout=timeout, max_retries=max_retries)
360
+
361
+ @property
362
+ def api_calls_used(self) -> int:
363
+ return self._api_calls_used
364
+
365
+ @api_calls_used.setter
366
+ def api_calls_used(self, value: int) -> None:
367
+ prior = getattr(self, "_api_calls_used", 0)
368
+ self._api_calls_used = value
369
+ delta = value - prior
370
+ if self.coordinator is not None and delta > 0:
371
+ self.coordinator.register_api_calls(delta, self.shard_name)
372
+
373
+ def _pause_for_rate_limit(self, query: str) -> None:
374
+ if self.coordinator is not None:
375
+ self.coordinator.before_request(self.shard_name, query, self.deadline)
376
+ elif self.deadline is not None and time.monotonic() >= self.deadline:
377
+ raise ShardBudgetExceeded(f"{self.shard_name} exceeded its wall-clock budget.")
378
+ super()._pause_for_rate_limit(query)
379
+
380
+ def _respect_min_interval(self, url: str) -> None:
381
+ if self.deadline is not None and time.monotonic() >= self.deadline:
382
+ raise ShardBudgetExceeded(f"{self.shard_name} exceeded its wall-clock budget.")
383
+ super()._respect_min_interval(url)
384
+
385
+ def _sleep_before_retry(
386
+ self,
387
+ attempt: int,
388
+ headers: dict[str, str] | None,
389
+ body: str,
390
+ query: str,
391
+ retry_limit: int,
392
+ max_delay_seconds: float,
393
+ ) -> None:
394
+ lowered = body.lower()
395
+ retry_after = None
396
+ if headers and headers.get("Retry-After"):
397
+ try:
398
+ retry_after = max(float(headers["Retry-After"]), 1.0)
399
+ except ValueError:
400
+ retry_after = None
401
+ reset_delay = self._reset_delay(headers)
402
+ delay = retry_after or reset_delay or min(2**attempt, max_delay_seconds)
403
+ if "secondary rate limit" in lowered:
404
+ self.rate_limit_events += 1
405
+ self.secondary_rate_limit_events += 1
406
+ reason = f"{self.shard_name} hit a secondary rate limit while requesting {query}."
407
+ if self.coordinator is not None:
408
+ self.coordinator.register_backoff(self.shard_name, max(delay, 8.0), reason, secondary=True)
409
+ raise ExperimentAbort(reason)
410
+ if headers and (
411
+ headers.get("Retry-After") is not None
412
+ or headers.get("X-RateLimit-Remaining") == "0"
413
+ or (self.rate_limit_remaining is not None and self.rate_limit_remaining <= 0)
414
+ ):
415
+ self.rate_limit_events += 1
416
+ if self.coordinator is not None:
417
+ self.coordinator.register_backoff(
418
+ self.shard_name,
419
+ max(delay, 1.0),
420
+ f"{self.shard_name} backing off before retrying {query}.",
421
+ )
422
+ super()._sleep_before_retry(attempt, headers, body, query, retry_limit, max_delay_seconds)
423
+
424
+
425
+def parse_args() -> argparse.Namespace:
426
+ parser = argparse.ArgumentParser(description=__doc__)
427
+ parser.add_argument("--since", required=True, help="UTC crawl window start date (YYYY-MM-DD).")
428
+ parser.add_argument("--as-of", required=True, help="UTC crawl window end date (YYYY-MM-DD).")
429
+ parser.add_argument("--max-results", type=int, default=250, help="Maximum repositories per query.")
430
+ parser.add_argument("--topic", default=None, help="Optional topic id.")
431
+ parser.add_argument("--config", default=None, help="Optional crawl topic config file.")
432
+ parser.add_argument("--shards", type=int, default=3, help="Total shards including search shards.")
433
+ parser.add_argument("--wall-clock-budget", type=int, default=DEFAULT_WALL_CLOCK_BUDGET)
434
+ parser.add_argument("--api-budget-multiplier", type=float, default=DEFAULT_API_BUDGET_MULTIPLIER)
435
+ parser.add_argument("--output-dir", default=str(EXPERIMENT_ROOT))
436
+ parser.add_argument("--experiment-id", default=None)
437
+ return parser.parse_args()
438
+
439
+
440
+def build_context(args: argparse.Namespace, experiment_dir: Path) -> CrawlContext:
441
+ crawled_at = utc_now()
442
+ since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=UTC)
443
+ window_end = datetime.strptime(args.as_of, "%Y-%m-%d").replace(tzinfo=UTC)
444
+ topic_id = args.topic
445
+ max_results = max(1, min(int(args.max_results), 1000))
446
+ config_args = argparse.Namespace(
447
+ since=args.since,
448
+ as_of=args.as_of,
449
+ max_results=max_results,
450
+ output=str(experiment_dir / "baseline-raw.json"),
451
+ topic=topic_id,
452
+ config=args.config,
453
+ )
454
+ return CrawlContext(
455
+ args=args,
456
+ topic_id=topic_id,
457
+ topic_raw=raw_dir(topic_id),
458
+ topic_snapshots=snapshots_dir(topic_id),
459
+ topic_cache=cache_dir(topic_id),
460
+ crawled_at=crawled_at,
461
+ run_started_at=crawled_at,
462
+ since=since,
463
+ window_end=window_end,
464
+ week=week_slug(window_end),
465
+ max_results=max_results,
466
+ config_checksum=github_crawl_config_checksum(config_args, since, window_end, max_results),
467
+ current_code_sha=os.environ.get("CRAWLER_CODE_SHA", ""),
468
+ source_refresh_policy="force-refresh",
469
+ baseline_output_path=experiment_dir / "baseline-raw.json",
470
+ baseline_snapshot_path=experiment_dir / "baseline-stars.json",
471
+ shard_output_path=experiment_dir / "shard-raw.json",
472
+ shard_snapshot_path=experiment_dir / "shard-stars.json",
473
+ )
474
+
475
+
476
+def next_experiment_id(output_dir: Path) -> str:
477
+ index = 0
478
+ for candidate in output_dir.glob("shard-435-run-*"):
479
+ suffix = candidate.name.removeprefix("shard-435-run-")
480
+ if suffix.isdigit():
481
+ index = max(index, int(suffix))
482
+ return f"shard-435-run-{index + 1:03d}"
483
+
484
+
485
+def build_search_plans(context: CrawlContext) -> list[SearchPlan]:
486
+ if context.args.config:
487
+ queries = load_topic_queries(
488
+ context.args.config,
489
+ {"last_week": context.since.date().isoformat(), "today": context.window_end.date().isoformat()},
490
+ )
491
+ return [
492
+ SearchPlan(
493
+ shard_name="new-search",
494
+ repo_group="new",
495
+ primary_queries=list(queries["primary"]),
496
+ secondary_queries=list(queries["secondary"]),
497
+ min_repos_per_week=int(queries["min_repos_per_week"]),
498
+ )
499
+ ]
500
+ return [
501
+ SearchPlan(
502
+ shard_name="new-search",
503
+ repo_group="new",
504
+ primary_queries=[f"created:{context.since.date().isoformat()}..{context.window_end.date().isoformat()} stars:>50"],
505
+ ),
506
+ SearchPlan(
507
+ shard_name="trending-search",
508
+ repo_group="trending",
509
+ primary_queries=[f"pushed:{context.since.date().isoformat()}..{context.window_end.date().isoformat()} stars:>50"],
510
+ ),
511
+ ]
512
+
513
+
514
+def run_search_plan(
515
+ plan: SearchPlan,
516
+ client: InstrumentedGitHubClient,
517
+ *,
518
+ max_results: int,
519
+ wall_clock_budget: int,
520
+ coordinator: SharedQuotaCoordinator | None,
521
+) -> tuple[list[dict[str, Any]], dict[str, Any]]:
522
+ candidates: list[dict[str, Any]] = []
523
+ errors: list[str] = []
524
+ completed = True
525
+ try:
526
+ for query in plan.primary_queries:
527
+ candidates.extend(client.search_repositories(query, max_results=max_results))
528
+ if plan.secondary_queries and len(candidates) < plan.min_repos_per_week:
529
+ for query in plan.secondary_queries:
530
+ candidates.extend(client.search_repositories(query, max_results=max_results))
531
+ except ShardBudgetExceeded as exc:
532
+ completed = False
533
+ errors.append(str(exc))
534
+ if coordinator is not None:
535
+ coordinator.record_budget_exceeded(plan.shard_name, wall_clock_budget)
536
+ except ExperimentAbort:
537
+ raise
538
+ return candidates, {
539
+ "completed": completed,
540
+ "errors": errors + list(client.errors),
541
+ "api_calls": client.api_calls_used,
542
+ "cache_hits": client.cache_hits,
543
+ "stale_cache_hits": client.stale_cache_hits,
544
+ "rate_limit_events": client.rate_limit_events,
545
+ "secondary_rate_limit_events": client.secondary_rate_limit_events,
546
+ }
547
+
548
+
549
+def chunk_validation_items(items: list[ValidationItem], worker_count: int) -> list[list[ValidationItem]]:
550
+ if not items:
551
+ return []
552
+ chunk_size = max(1, math.ceil(len(items) / max(worker_count * 2, 1)))
553
+ return [items[index : index + chunk_size] for index in range(0, len(items), chunk_size)]
554
+
555
+
556
+def prepare_validation_items(
557
+ new_candidates: list[dict[str, Any]],
558
+ trending_candidates: list[dict[str, Any]],
559
+ worker_count: int,
560
+) -> tuple[Queue[list[ValidationItem]], dict[str, int]]:
561
+ duplicate_counts = {"new": 0, "trending": 0}
562
+ grouped_unique_items: list[ValidationItem] = []
563
+ for repo_group, candidates in (("new", new_candidates), ("trending", trending_candidates)):
564
+ seen: set[str] = set()
565
+ for sequence, repo in enumerate(candidates):
566
+ full_name = repo.get("full_name")
567
+ if not full_name:
568
+ grouped_unique_items.append(ValidationItem(repo_group=repo_group, sequence=sequence, repo=repo))
569
+ continue
570
+ if full_name in seen:
571
+ duplicate_counts[repo_group] += 1
572
+ continue
573
+ seen.add(full_name)
574
+ grouped_unique_items.append(ValidationItem(repo_group=repo_group, sequence=sequence, repo=repo))
575
+ queue: Queue[list[ValidationItem]] = Queue()
576
+ for chunk in chunk_validation_items(grouped_unique_items, worker_count):
577
+ queue.put(chunk)
578
+ return queue, duplicate_counts
579
+
580
+
581
+def trending_stars_gained(
582
+ repo: dict[str, Any],
583
+ *,
584
+ previous_stars: dict[str, int] | None,
585
+ trending_cutoff: datetime | None,
586
+) -> int | None:
587
+ if previous_stars is None:
588
+ return None
589
+ previous_value = previous_stars.get(str(repo.get("full_name") or ""))
590
+ current_stars = int(repo.get("stargazers_count") or 0)
591
+ if previous_value is not None:
592
+ return max(current_stars - previous_value, 0)
593
+ if previous_stars and trending_cutoff is not None and isinstance(repo.get("created_at"), str):
594
+ created = datetime.fromisoformat(str(repo["created_at"]).replace("Z", "+00:00"))
595
+ if created >= trending_cutoff:
596
+ return current_stars
597
+ return None
598
+
599
+
600
+def validate_item(
601
+ client: InstrumentedGitHubClient,
602
+ item: ValidationItem,
603
+ *,
604
+ previous_stars: dict[str, int] | None,
605
+ trending_cutoff: datetime | None,
606
+) -> tuple[ValidatedRecord | None, str | None]:
607
+ full_name = item.repo.get("full_name")
608
+ if not full_name:
609
+ return None, "missing_full_name"
610
+ skip_reason = significance_skip_reason(item.repo)
611
+ if skip_reason:
612
+ return None, skip_reason
613
+ try:
614
+ if not client.has_readme(full_name):
615
+ return None, "missing_readme"
616
+ except RuntimeError as exc:
617
+ client.record_error(f"README lookup failed for {full_name}: {exc}")
618
+ return None, "readme_lookup_failed"
619
+ stars_gained = None
620
+ if item.repo_group == "trending":
621
+ stars_gained = trending_stars_gained(
622
+ item.repo,
623
+ previous_stars=previous_stars,
624
+ trending_cutoff=trending_cutoff,
625
+ )
626
+ return ValidatedRecord(item.repo_group, item.sequence, to_repo_record(item.repo, stars_gained=stars_gained)), None
627
+
628
+
629
+def sort_validated_records(
630
+ records: list[ValidatedRecord],
631
+ *,
632
+ previous_stars: dict[str, int] | None,
633
+) -> list[dict[str, Any]]:
634
+ if records and records[0].repo_group == "trending" and previous_stars:
635
+ ordered = sorted(
636
+ records,
637
+ key=lambda item: (
638
+ -int(item.record.get("stars_gained", -1) if item.record.get("stars_gained") is not None else -1),
639
+ -int(item.record.get("stars") or 0),
640
+ item.sequence,
641
+ ),
642
+ )
643
+ else:
644
+ ordered = sorted(records, key=lambda item: (-int(item.record.get("stars") or 0), item.sequence))
645
+ return [item.record for item in ordered]
646
+
647
+
648
+def validation_worker(
649
+ worker_index: int,
650
+ queue: Queue[list[ValidationItem]],
651
+ client: InstrumentedGitHubClient,
652
+ *,
653
+ previous_stars: dict[str, int] | None,
654
+ trending_cutoff: datetime | None,
655
+ wall_clock_budget: int,
656
+ coordinator: SharedQuotaCoordinator,
657
+) -> dict[str, Any]:
658
+ shard_name = f"validate-{worker_index}"
659
+ results = {"new": [], "trending": []}
660
+ filters: dict[str, Counter[str]] = {"new": Counter(), "trending": Counter()}
661
+ completed = True
662
+ while True:
663
+ try:
664
+ chunk = queue.get_nowait()
665
+ except Empty:
666
+ break
667
+ current_index = 0
668
+ try:
669
+ for current_index, item in enumerate(chunk):
670
+ validated, skip_reason = validate_item(
671
+ client,
672
+ item,
673
+ previous_stars=previous_stars,
674
+ trending_cutoff=trending_cutoff,
675
+ )
676
+ if validated is not None:
677
+ results[item.repo_group].append(validated)
678
+ elif skip_reason is not None:
679
+ filters[item.repo_group][skip_reason] += 1
680
+ except ShardBudgetExceeded:
681
+ completed = False
682
+ coordinator.record_budget_exceeded(shard_name, wall_clock_budget)
683
+ remaining_items = chunk[current_index:]
684
+ if remaining_items:
685
+ queue.put(remaining_items)
686
+ coordinator.record_redistribution(shard_name, len(remaining_items))
687
+ break
688
+ except ExperimentAbort:
689
+ raise
690
+ return {
691
+ "completed": completed,
692
+ "results": results,
693
+ "filters": {name: dict(counter) for name, counter in filters.items()},
694
+ "errors": list(client.errors),
695
+ "api_calls": client.api_calls_used,
696
+ "cache_hits": client.cache_hits,
697
+ "stale_cache_hits": client.stale_cache_hits,
698
+ "rate_limit_events": client.rate_limit_events,
699
+ "secondary_rate_limit_events": client.secondary_rate_limit_events,
700
+ }
701
+
702
+
703
+def build_payload(
704
+ *,
705
+ context: CrawlContext,
706
+ output_path: Path,
707
+ snapshot_path: Path,
708
+ new_repos: list[dict[str, Any]],
709
+ trending_repos: list[dict[str, Any]],
710
+ new_candidates: list[dict[str, Any]],
711
+ trending_candidates: list[dict[str, Any]],
712
+ api_calls: int,
713
+ cache_hits: int,
714
+ stale_cache_hits: int,
715
+ rate_limit_limit: int | None,
716
+ rate_limit_remaining: int | None,
717
+ rate_limit_reset: int | None,
718
+ rate_limit_resource: str | None,
719
+ partial_failures: list[str],
720
+ filter_summary: dict[str, dict[str, int]],
721
+ run_mode: str,
722
+) -> tuple[dict[str, Any], dict[str, Any]]:
723
+ star_snapshot = build_star_snapshot(new_candidates, trending_candidates)
724
+ snapshot_payload = {
725
+ "week": context.week,
726
+ "captured_at": iso_timestamp(context.crawled_at),
727
+ "repository_count": len(star_snapshot),
728
+ "stars": star_snapshot,
729
+ }
730
+ payload = {
731
+ "week": context.week,
732
+ "crawled_at": iso_timestamp(context.crawled_at),
733
+ "new_repos": new_repos,
734
+ "trending_repos": trending_repos,
735
+ "signals": build_signals(new_repos, trending_repos),
736
+ "metadata": {
737
+ "api_calls_used": api_calls,
738
+ "cache_hits": cache_hits,
739
+ "stale_cache_hits": stale_cache_hits,
740
+ "rate_limit_limit": rate_limit_limit,
741
+ "rate_limit_remaining": rate_limit_remaining,
742
+ "rate_limit_reset": rate_limit_reset,
743
+ "rate_limit_resource": rate_limit_resource,
744
+ "partial_failures": partial_failures,
745
+ "run_id": f"local-{run_mode}",
746
+ "crawl_window": {
747
+ "since": context.since.date().isoformat(),
748
+ "until": context.window_end.date().isoformat(),
749
+ },
750
+ "crawl_config_checksum": context.config_checksum,
751
+ "schema_checksum": github_schema_checksum(),
752
+ "same_day_reuse": {"status": "not_reused", "source": "github", "source_id": "github-search"},
753
+ "filter_summary": filter_summary,
754
+ "snapshot_path": snapshot_path.as_posix(),
755
+ "source_refresh_policy": context.source_refresh_policy,
756
+ "crawler_code_sha": context.current_code_sha,
757
+ "experiment_mode": run_mode,
758
+ "output_path": output_path.as_posix(),
759
+ },
760
+ }
761
+ payload["metadata"]["artifact_checksum"] = github_artifact_checksum(payload)
762
+ validate_payload(payload)
763
+ return payload, snapshot_payload
764
+
765
+
766
+def run_baseline(context: CrawlContext, token: str) -> RunResult:
767
+ started_at = time.monotonic()
768
+ search_plans = build_search_plans(context)
769
+ client = InstrumentedGitHubClient(token, cache_dir=context.topic_cache, shard_name="baseline")
770
+ previous_stars = load_previous_star_snapshot(
771
+ context.topic_snapshots,
772
+ context.week,
773
+ context.baseline_output_path.parent,
774
+ context.topic_raw,
775
+ RAW_ROOT,
776
+ )
777
+ new_candidates: list[dict[str, Any]] = []
778
+ trending_candidates: list[dict[str, Any]] = []
779
+ if context.args.config:
780
+ new_candidates, _ = run_search_plan(
781
+ search_plans[0],
782
+ client,
783
+ max_results=context.max_results,
784
+ wall_clock_budget=context.args.wall_clock_budget,
785
+ coordinator=None,
786
+ )
787
+ else:
788
+ for plan in search_plans:
789
+ candidates, _ = run_search_plan(
790
+ plan,
791
+ client,
792
+ max_results=context.max_results,
793
+ wall_clock_budget=context.args.wall_clock_budget,
794
+ coordinator=None,
795
+ )
796
+ if plan.repo_group == "new":
797
+ new_candidates = candidates
798
+ else:
799
+ trending_candidates = candidates
800
+ new_repos, new_filters = collect_repositories(client, new_candidates)
801
+ trending_repos, trending_filters = collect_repositories(
802
+ client,
803
+ trending_candidates,
804
+ previous_stars=previous_stars,
805
+ trending_cutoff=context.since,
806
+ )
807
+ payload, snapshot_payload = build_payload(
808
+ context=context,
809
+ output_path=context.baseline_output_path,
810
+ snapshot_path=context.baseline_snapshot_path,
811
+ new_repos=new_repos,
812
+ trending_repos=trending_repos,
813
+ new_candidates=new_candidates,
814
+ trending_candidates=trending_candidates,
815
+ api_calls=client.api_calls_used,
816
+ cache_hits=client.cache_hits,
817
+ stale_cache_hits=client.stale_cache_hits,
818
+ rate_limit_limit=client.rate_limit_limit,
819
+ rate_limit_remaining=client.rate_limit_remaining,
820
+ rate_limit_reset=client.rate_limit_reset,
821
+ rate_limit_resource=client.rate_limit_resource,
822
+ partial_failures=list(client.errors),
823
+ filter_summary={"new_repos": new_filters, "trending_repos": trending_filters},
824
+ run_mode="baseline",
825
+ )
826
+ write_payload(context.baseline_output_path, payload)
827
+ write_payload(context.baseline_snapshot_path, snapshot_payload)
828
+ return RunResult(
829
+ name="baseline",
830
+ payload=payload,
831
+ snapshot_payload=snapshot_payload,
832
+ api_calls=client.api_calls_used,
833
+ cache_hits=client.cache_hits,
834
+ stale_cache_hits=client.stale_cache_hits,
835
+ rate_limit_events=client.rate_limit_events,
836
+ secondary_rate_limit_events=client.secondary_rate_limit_events,
837
+ partial_failures=list(client.errors),
838
+ wall_clock_s=round(time.monotonic() - started_at, 3),
839
+ shards_used=1,
840
+ completed=not bool(client.errors),
841
+ guardrail_events=[],
842
+ )
843
+
844
+
845
+def run_sharded(context: CrawlContext, token: str, baseline_api_calls: int) -> RunResult:
846
+ started_at = time.monotonic()
847
+ search_plans = build_search_plans(context)
848
+ validation_workers = max(1, int(context.args.shards) - len(search_plans))
849
+ api_hard_cap = max(1, math.floor(baseline_api_calls * float(context.args.api_budget_multiplier)))
850
+ coordinator = SharedQuotaCoordinator(api_hard_cap)
851
+ previous_stars = load_previous_star_snapshot(
852
+ context.topic_snapshots,
853
+ context.week,
854
+ context.shard_output_path.parent,
855
+ context.topic_raw,
856
+ RAW_ROOT,
857
+ )
858
+ aggregated_errors: list[str] = []
859
+ aggregated_api_calls = 0
860
+ aggregated_cache_hits = 0
861
+ aggregated_stale_cache_hits = 0
862
+ aggregated_rate_limit_events = 0
863
+ aggregated_secondary_rate_limit_events = 0
864
+ search_results: dict[str, list[dict[str, Any]]] = {"new": [], "trending": []}
865
+
866
+ try:
867
+ with ThreadPoolExecutor(max_workers=max(len(search_plans), 1)) as pool:
868
+ futures = {
869
+ pool.submit(
870
+ run_search_plan,
871
+ plan,
872
+ InstrumentedGitHubClient(
873
+ token,
874
+ cache_dir=context.topic_cache,
875
+ shard_name=plan.shard_name,
876
+ coordinator=coordinator,
877
+ deadline=time.monotonic() + int(context.args.wall_clock_budget),
878
+ ),
879
+ max_results=context.max_results,
880
+ wall_clock_budget=int(context.args.wall_clock_budget),
881
+ coordinator=coordinator,
882
+ ): plan
883
+ for plan in search_plans
884
+ }
885
+ for future in as_completed(futures):
886
+ plan = futures[future]
887
+ candidates, metrics = future.result()
888
+ search_results[plan.repo_group] = candidates
889
+ aggregated_errors.extend(metrics["errors"])
890
+ aggregated_api_calls += metrics["api_calls"]
891
+ aggregated_cache_hits += metrics["cache_hits"]
892
+ aggregated_stale_cache_hits += metrics["stale_cache_hits"]
893
+ aggregated_rate_limit_events += metrics["rate_limit_events"]
894
+ aggregated_secondary_rate_limit_events += metrics["secondary_rate_limit_events"]
895
+ except ExperimentAbort as exc:
896
+ aggregated_errors.append(str(exc))
897
+
898
+ validation_queue, duplicate_counts = prepare_validation_items(
899
+ search_results["new"], search_results["trending"], validation_workers
900
+ )
901
+ validated_records: dict[str, list[ValidatedRecord]] = {"new": [], "trending": []}
902
+ filter_summary: dict[str, Counter[str]] = {
903
+ "new_repos": Counter({"duplicate": duplicate_counts["new"]}),
904
+ "trending_repos": Counter({"duplicate": duplicate_counts["trending"]}),
905
+ }
906
+ if coordinator.abort_reason is None:
907
+ try:
908
+ with ThreadPoolExecutor(max_workers=validation_workers) as pool:
909
+ futures = {
910
+ pool.submit(
911
+ validation_worker,
912
+ index + 1,
913
+ validation_queue,
914
+ InstrumentedGitHubClient(
915
+ token,
916
+ cache_dir=context.topic_cache,
917
+ shard_name=f"validate-{index + 1}",
918
+ coordinator=coordinator,
919
+ deadline=time.monotonic() + int(context.args.wall_clock_budget),
920
+ ),
921
+ previous_stars=previous_stars,
922
+ trending_cutoff=context.since,
923
+ wall_clock_budget=int(context.args.wall_clock_budget),
924
+ coordinator=coordinator,
925
+ ): index + 1
926
+ for index in range(validation_workers)
927
+ }
928
+ for future in as_completed(futures):
929
+ result = future.result()
930
+ validated_records["new"].extend(result["results"]["new"])
931
+ validated_records["trending"].extend(result["results"]["trending"])
932
+ filter_summary["new_repos"].update(result["filters"]["new"])
933
+ filter_summary["trending_repos"].update(result["filters"]["trending"])
934
+ aggregated_errors.extend(result["errors"])
935
+ aggregated_api_calls += result["api_calls"]
936
+ aggregated_cache_hits += result["cache_hits"]
937
+ aggregated_stale_cache_hits += result["stale_cache_hits"]
938
+ aggregated_rate_limit_events += result["rate_limit_events"]
939
+ aggregated_secondary_rate_limit_events += result["secondary_rate_limit_events"]
940
+ except ExperimentAbort as exc:
941
+ aggregated_errors.append(str(exc))
942
+
943
+ new_repos = sort_validated_records(validated_records["new"], previous_stars=None)
944
+ trending_repos = sort_validated_records(validated_records["trending"], previous_stars=previous_stars)
945
+ payload, snapshot_payload = build_payload(
946
+ context=context,
947
+ output_path=context.shard_output_path,
948
+ snapshot_path=context.shard_snapshot_path,
949
+ new_repos=new_repos,
950
+ trending_repos=trending_repos,
951
+ new_candidates=search_results["new"],
952
+ trending_candidates=search_results["trending"],
953
+ api_calls=aggregated_api_calls,
954
+ cache_hits=aggregated_cache_hits,
955
+ stale_cache_hits=aggregated_stale_cache_hits,
956
+ rate_limit_limit=None,
957
+ rate_limit_remaining=None,
958
+ rate_limit_reset=None,
959
+ rate_limit_resource="mixed",
960
+ partial_failures=aggregated_errors,
961
+ filter_summary={name: dict(counter) for name, counter in filter_summary.items()},
962
+ run_mode="shard",
963
+ )
964
+ write_payload(context.shard_output_path, payload)
965
+ write_payload(context.shard_snapshot_path, snapshot_payload)
966
+ return RunResult(
967
+ name="shard",
968
+ payload=payload,
969
+ snapshot_payload=snapshot_payload,
970
+ api_calls=aggregated_api_calls,
971
+ cache_hits=aggregated_cache_hits,
972
+ stale_cache_hits=aggregated_stale_cache_hits,
973
+ rate_limit_events=aggregated_rate_limit_events,
974
+ secondary_rate_limit_events=aggregated_secondary_rate_limit_events,
975
+ partial_failures=aggregated_errors,
976
+ wall_clock_s=round(time.monotonic() - started_at, 3),
977
+ shards_used=len(search_plans) + validation_workers,
978
+ completed=not aggregated_errors and validation_queue.empty() and not coordinator.secondary_rate_limit_hit,
979
+ guardrail_events=coordinator.guardrail_events(),
980
+ )
981
+
982
+
983
+def canonicalize_payload(payload: dict[str, Any]) -> bytes:
984
+ canonical = {
985
+ "week": payload.get("week"),
986
+ "new_repos": payload.get("new_repos", []),
987
+ "trending_repos": payload.get("trending_repos", []),
988
+ "signals": payload.get("signals", {}),
989
+ }
990
+ return json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
991
+
992
+
993
+def canonicalize_snapshot(snapshot_payload: dict[str, Any]) -> bytes:
994
+ canonical = {
995
+ "week": snapshot_payload.get("week"),
996
+ "repository_count": snapshot_payload.get("repository_count"),
997
+ "stars": snapshot_payload.get("stars", {}),
998
+ }
999
+ return json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
1000
+
1001
+
1002
+def _normalize_for_comparison(value: Any) -> Any:
1003
+ if isinstance(value, dict):
1004
+ return {
1005
+ key: _normalize_for_comparison(item)
1006
+ for key, item in sorted(value.items())
1007
+ if key not in {"crawled_at", "captured_at", "report_generated_at"}
1008
+ }
1009
+ if isinstance(value, list):
1010
+ return [_normalize_for_comparison(item) for item in value]
1011
+ return value
1012
+
1013
+
1014
+def deterministic_merge(shard_results: list[ShardResult]) -> dict[str, Any]:
1015
+ merged: dict[str, dict[str, Any]] = {}
1016
+ for result in shard_results:
1017
+ for repo in result.repos_found:
1018
+ full_name = str(repo.get("full_name") or "")
1019
+ if full_name and full_name not in merged:
1020
+ merged[full_name] = repo
1021
+ repos = sorted(
1022
+ merged.values(),
1023
+ key=lambda item: (
1024
+ -(int(item.get("stars_gained", -1) or -1)),
1025
+ -(int(item.get("stars") or 0)),
1026
+ str(item.get("full_name") or ""),
1027
+ ),
1028
+ )
1029
+ return {"repos": repos}
1030
+
1031
+
1032
+def compare_results(baseline: dict[str, Any], shard: dict[str, Any]) -> dict[str, Any]:
1033
+ baseline_wall = float(baseline.get("wall_clock_s", baseline.get("elapsed_s", 0.0)) or 0.0001)
1034
+ shard_wall = float(shard.get("wall_clock_s", shard.get("elapsed_s", 0.0)) or 0.0)
1035
+ baseline_api = int(baseline.get("api_calls", baseline.get("api_calls_used", 0)) or 0)
1036
+ shard_api = int(shard.get("api_calls", shard.get("api_calls_used", 0)) or 0)
1037
+ baseline_output = _normalize_for_comparison(baseline.get("canonical_output", baseline.get("output", {})))
1038
+ shard_output = _normalize_for_comparison(shard.get("canonical_output", shard.get("output", {})))
1039
+ return {
1040
+ "speedup_pct": round(((baseline_wall - shard_wall) / baseline_wall) * 100, 2),
1041
+ "api_growth_pct": round(((shard_api - baseline_api) / max(baseline_api, 1)) * 100, 2),
1042
+ "output_stable": baseline_output == shard_output,
1043
+ "rate_limit_regression": int(shard.get("rate_limit_events", 0) or 0)
1044
+ > int(baseline.get("rate_limit_events", 0) or 0),
1045
+ "partial_data": bool(shard.get("partial_data", False) or baseline.get("partial_data", False)),
1046
+ "baseline_complete": not bool(baseline.get("partial_data", False)),
1047
+ "shard_complete": not bool(shard.get("partial_data", False)),
1048
+ }
1049
+
1050
+
1051
+def build_report(experiment_id: str, baseline: RunResult, shard: RunResult) -> dict[str, Any]:
1052
+ baseline_wall = baseline.wall_clock_s or 0.0001
1053
+ speedup_pct = round(((baseline_wall - shard.wall_clock_s) / baseline_wall) * 100, 2)
1054
+ api_growth_pct = round(((shard.api_calls - baseline.api_calls) / max(baseline.api_calls, 1)) * 100, 2)
1055
+ output_stable = canonicalize_payload(baseline.payload) == canonicalize_payload(shard.payload) and canonicalize_snapshot(
1056
+ baseline.snapshot_payload
1057
+ ) == canonicalize_snapshot(shard.snapshot_payload)
1058
+ baseline_incomplete = bool(baseline.partial_failures) or not baseline.completed
1059
+ shard_incomplete = bool(shard.partial_failures) or not shard.completed
1060
+ report_card = ExperimentReport.from_comparison(
1061
+ {
1062
+ "speedup_pct": speedup_pct,
1063
+ "api_growth_pct": api_growth_pct,
1064
+ "rate_limit_regression": shard.secondary_rate_limit_events > baseline.secondary_rate_limit_events,
1065
+ "output_stable": output_stable,
1066
+ "partial_data": baseline_incomplete or shard_incomplete,
1067
+ "baseline_complete": not baseline_incomplete,
1068
+ "shard_complete": not shard_incomplete,
1069
+ }
1070
+ )
1071
+ verdict = report_card.verdict
1072
+ if report_card.partial_data and any(event["kind"] == "secondary_rate_limit" for event in shard.guardrail_events):
1073
+ verdict = "fail"
1074
+ return {
1075
+ "experiment_id": experiment_id,
1076
+ "baseline": {
1077
+ "wall_clock_s": baseline.wall_clock_s,
1078
+ "api_calls": baseline.api_calls,
1079
+ "rate_limit_events": baseline.rate_limit_events,
1080
+ "repos_new": len(baseline.payload.get("new_repos", [])),
1081
+ "repos_trending": len(baseline.payload.get("trending_repos", [])),
1082
+ },
1083
+ "shard": {
1084
+ "wall_clock_s": shard.wall_clock_s,
1085
+ "api_calls": shard.api_calls,
1086
+ "rate_limit_events": shard.rate_limit_events,
1087
+ "shards_used": shard.shards_used,
1088
+ "repos_new": len(shard.payload.get("new_repos", [])),
1089
+ "repos_trending": len(shard.payload.get("trending_repos", [])),
1090
+ },
1091
+ "comparison": {
1092
+ "speedup_pct": speedup_pct,
1093
+ "api_growth_pct": api_growth_pct,
1094
+ "output_stable": output_stable,
1095
+ "secondary_rate_limit_regression": shard.secondary_rate_limit_events > baseline.secondary_rate_limit_events,
1096
+ },
1097
+ "verdict": verdict,
1098
+ "guardrail_events": shard.guardrail_events,
1099
+ }
1100
+
1101
+
1102
+def main() -> int:
1103
+ args = parse_args()
1104
+ if args.shards < 3:
1105
+ print("--shards must be >= 3.", file=os.sys.stderr)
1106
+ return 1
1107
+ if args.wall_clock_budget <= 0:
1108
+ print("--wall-clock-budget must be positive.", file=os.sys.stderr)
1109
+ return 1
1110
+ if args.api_budget_multiplier < 1.0:
1111
+ print("--api-budget-multiplier must be >= 1.0.", file=os.sys.stderr)
1112
+ return 1
1113
+ token = os.environ.get("GITHUB_TOKEN")
1114
+ if not token:
1115
+ print("GITHUB_TOKEN is required", file=os.sys.stderr)
1116
+ return 1
1117
+ output_dir = Path(args.output_dir)
1118
+ output_dir.mkdir(parents=True, exist_ok=True)
1119
+ experiment_id = args.experiment_id or next_experiment_id(output_dir)
1120
+ experiment_dir = output_dir / experiment_id
1121
+ experiment_dir.mkdir(parents=True, exist_ok=True)
1122
+ context = build_context(args, experiment_dir)
1123
+ baseline = run_baseline(context, token)
1124
+ shard = run_sharded(context, token, baseline.api_calls)
1125
+ report = build_report(experiment_id, baseline, shard)
1126
+ report_path = experiment_dir / "report.json"
1127
+ write_payload(report_path, report)
1128
+ print(
1129
+ json.dumps(
1130
+ {
1131
+ "experiment_id": experiment_id,
1132
+ "verdict": report["verdict"],
1133
+ "speedup_pct": report["comparison"]["speedup_pct"],
1134
+ "api_growth_pct": report["comparison"]["api_growth_pct"],
1135
+ "output_stable": report["comparison"]["output_stable"],
1136
+ "report_path": report_path.as_posix(),
1137
+ },
1138
+ ensure_ascii=False,
1139
+ )
1140
+ )
1141
+ return 0 if report["verdict"] != "fail" else 1
1142
+
1143
+
1144
+if __name__ == "__main__":
1145
+ raise SystemExit(main())
tests/test_crawl_shard_experiment.py
new
+369
@@ -0,0 +1,369 @@
1
+from __future__ import annotations
2
+
3
+import dataclasses
4
+import importlib.util
5
+import inspect
6
+import json
7
+from concurrent.futures import ThreadPoolExecutor
8
+from typing import Any
9
+
10
+import pytest
11
+
12
+if importlib.util.find_spec("scripts.crawl_shard_experiment") is None:
13
+ pytest.skip("scripts.crawl_shard_experiment is not implemented yet", allow_module_level=True)
14
+
15
+from scripts import crawl_shard_experiment as experiment
16
+
17
+
18
+def _build_instance(factory: Any, **values: Any) -> Any:
19
+ signature = inspect.signature(factory)
20
+ kwargs = {}
21
+ for name, parameter in signature.parameters.items():
22
+ if name in values:
23
+ kwargs[name] = values[name]
24
+ elif parameter.default is inspect._empty and parameter.kind in (
25
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
26
+ inspect.Parameter.KEYWORD_ONLY,
27
+ ):
28
+ raise AssertionError(f"missing required parameter {name!r} for {factory}")
29
+ return factory(**kwargs)
30
+
31
+
32
+def _resolve_attr(target: Any, *names: str) -> Any:
33
+ if isinstance(target, dict):
34
+ for name in names:
35
+ if name in target:
36
+ return target[name]
37
+ for name in names:
38
+ if hasattr(target, name):
39
+ return getattr(target, name)
40
+ raise AssertionError(f"could not resolve any of {names!r} on {target!r}")
41
+
42
+
43
+def _resolve_method(target: Any, *names: str):
44
+ for name in names:
45
+ candidate = getattr(target, name, None)
46
+ if callable(candidate):
47
+ return candidate
48
+ raise AssertionError(f"could not resolve any of {names!r} on {target!r}")
49
+
50
+
51
+def _make_tracker(cap: int):
52
+ return _build_instance(
53
+ experiment.SharedQuotaTracker,
54
+ cap=cap,
55
+ hard_cap=cap,
56
+ limit=cap,
57
+ quota_cap=cap,
58
+ max_calls=cap,
59
+ )
60
+
61
+
62
+def _tracker_cap(tracker: Any) -> int:
63
+ return int(_resolve_attr(tracker, "cap", "hard_cap", "limit", "quota_cap", "max_calls"))
64
+
65
+
66
+def _tracker_count(tracker: Any) -> int:
67
+ return int(_resolve_attr(tracker, "count", "used", "api_calls_used", "value", "calls_used"))
68
+
69
+
70
+def _consume_quota(tracker: Any) -> bool:
71
+ method = _resolve_method(tracker, "increment", "try_acquire", "acquire", "consume", "record_call")
72
+ try:
73
+ result = method()
74
+ except Exception as exc: # pragma: no cover
75
+ message = str(exc).lower()
76
+ if "quota" in message or "cap" in message or "limit" in message:
77
+ return False
78
+ raise
79
+ if isinstance(result, bool):
80
+ return result
81
+ if isinstance(result, int):
82
+ return result <= _tracker_cap(tracker)
83
+ return _tracker_count(tracker) <= _tracker_cap(tracker)
84
+
85
+
86
+def _reset_tracker(tracker: Any) -> None:
87
+ _resolve_method(tracker, "reset", "clear")()
88
+
89
+
90
+def _make_budget(seconds: float):
91
+ return _build_instance(
92
+ experiment.WallClockBudget,
93
+ budget_s=seconds,
94
+ budget_seconds=seconds,
95
+ seconds=seconds,
96
+ limit_s=seconds,
97
+ )
98
+
99
+
100
+def _budget_exceeded(budget: Any) -> bool:
101
+ status = _resolve_method(budget, "is_exceeded", "expired", "timed_out", "should_stop", "exhausted")
102
+ return bool(status())
103
+
104
+
105
+def _elapsed_seconds(budget: Any) -> float:
106
+ for name in ("elapsed_s", "elapsed_seconds", "elapsed"):
107
+ if hasattr(budget, name):
108
+ value = getattr(budget, name)
109
+ return float(value() if callable(value) else value)
110
+ raise AssertionError(f"could not resolve elapsed time on {budget!r}")
111
+
112
+
113
+def _make_shard_result(*, repos_found: list[dict[str, Any]], api_calls: int, errors: list[str], wall_clock_s: float):
114
+ return _build_instance(
115
+ experiment.ShardResult,
116
+ shard_id="github:new-repos:q1",
117
+ repos_found=repos_found,
118
+ repos=repos_found,
119
+ api_calls=api_calls,
120
+ api_calls_used=api_calls,
121
+ errors=errors,
122
+ wall_clock_s=wall_clock_s,
123
+ duration_s=wall_clock_s,
124
+ rate_limit_detected=False,
125
+ rate_limit_events=0,
126
+ )
127
+
128
+
129
+def _extract_repos(payload: Any) -> list[dict[str, Any]]:
130
+ if isinstance(payload, list):
131
+ return payload
132
+ for name in ("repos", "repos_found", "items", "results", "merged_repos"):
133
+ if isinstance(payload, dict) and name in payload:
134
+ return payload[name]
135
+ if hasattr(payload, name):
136
+ return getattr(payload, name)
137
+ raise AssertionError(f"could not extract merged repos from {payload!r}")
138
+
139
+
140
+def _metric(result: Any, *names: str) -> Any:
141
+ return _resolve_attr(result, *names)
142
+
143
+
144
+def _percent(value: Any) -> float:
145
+ numeric = float(value)
146
+ return numeric * 100.0 if abs(numeric) <= 1.0 else numeric
147
+
148
+
149
+def _canonical_json(payload: Any) -> str:
150
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
151
+
152
+
153
+def _make_report(comparison: dict[str, Any]):
154
+ report_cls = experiment.ExperimentReport
155
+ for factory_name in ("from_comparison", "from_metrics", "evaluate", "build"):
156
+ factory = getattr(report_cls, factory_name, None)
157
+ if callable(factory):
158
+ signature = inspect.signature(factory)
159
+ if "comparison" in signature.parameters:
160
+ return factory(comparison=comparison)
161
+ if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()):
162
+ return factory(**comparison)
163
+ allowed = {name: value for name, value in comparison.items() if name in signature.parameters}
164
+ return factory(**allowed)
165
+ signature = inspect.signature(report_cls)
166
+ if "comparison" in signature.parameters:
167
+ return report_cls(comparison=comparison)
168
+ if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()):
169
+ return report_cls(**comparison)
170
+ allowed = {name: value for name, value in comparison.items() if name in signature.parameters}
171
+ return report_cls(**allowed)
172
+
173
+
174
+def _verdict(report: Any) -> str:
175
+ return str(_resolve_attr(report, "verdict", "status")).lower()
176
+
177
+
178
+@pytest.fixture
179
+def repo_alpha() -> dict[str, Any]:
180
+ return {
181
+ "full_name": "octo/alpha",
182
+ "name": "alpha",
183
+ "owner": "octo",
184
+ "stars": 150,
185
+ "stars_gained": 42,
186
+ "language": "Python",
187
+ "topics": ["ai", "testing"],
188
+ "url": "https://github.com/octo/alpha",
189
+ }
190
+
191
+
192
+@pytest.fixture
193
+def repo_beta() -> dict[str, Any]:
194
+ return {
195
+ "full_name": "octo/beta",
196
+ "name": "beta",
197
+ "owner": "octo",
198
+ "stars": 120,
199
+ "stars_gained": 15,
200
+ "language": "Go",
201
+ "topics": ["infra"],
202
+ "url": "https://github.com/octo/beta",
203
+ }
204
+
205
+
206
+@pytest.fixture
207
+def repo_gamma() -> dict[str, Any]:
208
+ return {
209
+ "full_name": "tools/gamma",
210
+ "name": "gamma",
211
+ "owner": "tools",
212
+ "stars": 220,
213
+ "stars_gained": 5,
214
+ "language": "Rust",
215
+ "topics": ["developer-tools"],
216
+ "url": "https://github.com/tools/gamma",
217
+ }
218
+
219
+
220
+class TestSharedQuotaTracker:
221
+ def test_thread_safe_increment_and_cap_enforcement(self) -> None:
222
+ tracker = _make_tracker(cap=10)
223
+ with ThreadPoolExecutor(max_workers=8) as pool:
224
+ successes = list(pool.map(lambda _: _consume_quota(tracker), range(25)))
225
+ assert sum(bool(result) for result in successes) == 10
226
+ assert _tracker_count(tracker) == 10
227
+ assert _consume_quota(tracker) is False
228
+
229
+ def test_reset_clears_accumulated_usage(self) -> None:
230
+ tracker = _make_tracker(cap=3)
231
+ assert _consume_quota(tracker) is True
232
+ assert _consume_quota(tracker) is True
233
+ assert _tracker_count(tracker) == 2
234
+ _reset_tracker(tracker)
235
+ assert _tracker_count(tracker) == 0
236
+ assert _consume_quota(tracker) is True
237
+
238
+
239
+class TestWallClockBudget:
240
+ def test_budget_not_exceeded_before_deadline(self, monkeypatch: pytest.MonkeyPatch) -> None:
241
+ clock = {"now": 100.0}
242
+ monkeypatch.setattr(experiment.time, "monotonic", lambda: clock["now"])
243
+ budget = _make_budget(5.0)
244
+ clock["now"] = 104.9
245
+ assert _elapsed_seconds(budget) == pytest.approx(4.9)
246
+ assert _budget_exceeded(budget) is False
247
+
248
+ def test_budget_exceeded_triggers_graceful_stop(self, monkeypatch: pytest.MonkeyPatch) -> None:
249
+ clock = {"now": 250.0}
250
+ monkeypatch.setattr(experiment.time, "monotonic", lambda: clock["now"])
251
+ budget = _make_budget(5.0)
252
+ clock["now"] = 255.1
253
+ assert _elapsed_seconds(budget) == pytest.approx(5.1)
254
+ assert _budget_exceeded(budget) is True
255
+
256
+
257
+def test_shard_result_dataclass_contract(repo_alpha: dict[str, Any]) -> None:
258
+ assert dataclasses.is_dataclass(experiment.ShardResult)
259
+ field_names = {field.name for field in dataclasses.fields(experiment.ShardResult)}
260
+ assert {"repos_found", "api_calls", "errors", "wall_clock_s"}.issubset(field_names)
261
+ result = _make_shard_result(repos_found=[repo_alpha], api_calls=4, errors=[], wall_clock_s=1.75)
262
+ assert _resolve_attr(result, "repos_found", "repos") == [repo_alpha]
263
+ assert _resolve_attr(result, "api_calls", "api_calls_used") == 4
264
+ assert result.errors == []
265
+ assert _resolve_attr(result, "wall_clock_s", "duration_s") == pytest.approx(1.75)
266
+
267
+
268
+class TestDeterministicMerge:
269
+ def test_deduplicates_by_full_name_and_preserves_star_gain(
270
+ self, repo_alpha: dict[str, Any], repo_beta: dict[str, Any], repo_gamma: dict[str, Any]
271
+ ) -> None:
272
+ first = _make_shard_result(repos_found=[repo_alpha, repo_beta], api_calls=3, errors=[], wall_clock_s=1.1)
273
+ duplicate_alpha = {**repo_alpha, "stars": 999, "stars_gained": 42}
274
+ second = _make_shard_result(repos_found=[duplicate_alpha, repo_gamma], api_calls=2, errors=[], wall_clock_s=1.2)
275
+ merged = experiment.deterministic_merge([first, second])
276
+ merged_repos = _extract_repos(merged)
277
+ assert [repo["full_name"] for repo in merged_repos].count("octo/alpha") == 1
278
+ assert {repo["full_name"] for repo in merged_repos} == {"octo/alpha", "octo/beta", "tools/gamma"}
279
+ alpha = next(repo for repo in merged_repos if repo["full_name"] == "octo/alpha")
280
+ assert alpha["stars_gained"] == 42
281
+
282
+ def test_same_inputs_produce_byte_identical_output_regardless_of_shard_order(
283
+ self, repo_alpha: dict[str, Any], repo_beta: dict[str, Any], repo_gamma: dict[str, Any]
284
+ ) -> None:
285
+ shard_a = _make_shard_result(repos_found=[repo_alpha, repo_gamma], api_calls=3, errors=[], wall_clock_s=1.0)
286
+ shard_b = _make_shard_result(repos_found=[repo_beta], api_calls=2, errors=[], wall_clock_s=1.0)
287
+ merged_ab = experiment.deterministic_merge([shard_a, shard_b])
288
+ merged_ba = experiment.deterministic_merge([shard_b, shard_a])
289
+ assert _canonical_json(merged_ab) == _canonical_json(merged_ba)
290
+
291
+
292
+class TestCompareResults:
293
+ def test_calculates_speedup_and_api_growth(self, repo_alpha: dict[str, Any], repo_beta: dict[str, Any]) -> None:
294
+ canonical = {"repos": [repo_alpha, repo_beta], "metadata": {"note": "stable"}}
295
+ baseline = {"wall_clock_s": 100.0, "api_calls": 100, "output": canonical, "canonical_output": canonical}
296
+ shard = {"wall_clock_s": 70.0, "api_calls": 108, "output": canonical, "canonical_output": canonical}
297
+ comparison = experiment.compare_results(baseline, shard)
298
+ assert _percent(_metric(comparison, "speedup_pct", "speedup_percent", "speedup")) == pytest.approx(30.0)
299
+ assert _percent(_metric(comparison, "api_growth_pct", "api_growth_percent", "api_growth")) == pytest.approx(8.0)
300
+
301
+ def test_output_stability_ignores_timestamp_fields(
302
+ self, repo_alpha: dict[str, Any], repo_beta: dict[str, Any]
303
+ ) -> None:
304
+ baseline = {
305
+ "wall_clock_s": 100.0,
306
+ "api_calls": 100,
307
+ "output": {
308
+ "week": "2026-W24",
309
+ "crawled_at": "2026-06-13T12:00:00Z",
310
+ "report_generated_at": "2026-06-13T12:00:01Z",
311
+ "repos": [repo_alpha, repo_beta],
312
+ },
313
+ }
314
+ shard = {
315
+ "wall_clock_s": 75.0,
316
+ "api_calls": 105,
317
+ "output": {
318
+ "week": "2026-W24",
319
+ "crawled_at": "2026-06-13T12:10:00Z",
320
+ "report_generated_at": "2026-06-13T12:10:01Z",
321
+ "repos": [repo_alpha, repo_beta],
322
+ },
323
+ }
324
+ comparison = experiment.compare_results(baseline, shard)
325
+ assert bool(_metric(comparison, "output_stable", "stable_output", "is_output_stable")) is True
326
+
327
+
328
+class TestExperimentReport:
329
+ def test_pass_verdict_when_all_guardrails_are_met(self) -> None:
330
+ report = _make_report(
331
+ {
332
+ "speedup_pct": 30.0,
333
+ "api_growth_pct": 8.0,
334
+ "rate_limit_regression": False,
335
+ "output_stable": True,
336
+ "partial_data": False,
337
+ "baseline_complete": True,
338
+ "shard_complete": True,
339
+ }
340
+ )
341
+ assert _verdict(report) == "pass"
342
+
343
+ def test_fail_verdict_when_any_required_criterion_fails(self) -> None:
344
+ report = _make_report(
345
+ {
346
+ "speedup_pct": 20.0,
347
+ "api_growth_pct": 8.0,
348
+ "rate_limit_regression": False,
349
+ "output_stable": True,
350
+ "partial_data": False,
351
+ "baseline_complete": True,
352
+ "shard_complete": True,
353
+ }
354
+ )
355
+ assert _verdict(report) == "fail"
356
+
357
+ def test_inconclusive_verdict_for_partial_data(self) -> None:
358
+ report = _make_report(
359
+ {
360
+ "speedup_pct": None,
361
+ "api_growth_pct": None,
362
+ "rate_limit_regression": False,
363
+ "output_stable": False,
364
+ "partial_data": True,
365
+ "baseline_complete": True,
366
+ "shard_complete": False,
367
+ }
368
+ )
369
+ assert _verdict(report) == "inconclusive"