diff --git a/.github/workflows/ingest.yml b/.github/workflows/ingest.yml index dad9a5c..e5775d8 100644 --- a/.github/workflows/ingest.yml +++ b/.github/workflows/ingest.yml @@ -29,7 +29,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | mkdir -p .inbox - gh issue list --repo "$GITHUB_REPOSITORY" --label result-submission --state open --limit 100 --json number,author,body --jq '.[] | @base64' > .inbox/issues + gh api --paginate "repos/$GITHUB_REPOSITORY/issues?labels=result-submission&state=open&per_page=100" --jq '.[] | select(.pull_request == null) | {number,author,body} | @base64' > .inbox/issues while read -r encoded; do [ -z "$encoded" ] && continue printf '%s' "$encoded" | base64 --decode > .inbox/issue.json @@ -53,6 +53,7 @@ jobs: python scripts/aggregate.py --root . git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + rm -rf .inbox for path in results leaderboard matchmaking clients.json site/data; do [ -e "$path" ] && git add -- "$path" done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5209981..4ff8bc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,6 @@ The submitting issue account and `clientId` must be registered. Every bot must b ## Rules -Do not edit raw facts, generated projections, or the static dashboard data in a pull request. Do not submit replays; retain replay evidence locally. Duplicate battle IDs, engine mismatches, unknown bots, unregistered clients, banned accounts, malformed batches, and implausible score sets are rejected. +Do not edit raw facts, generated projections, or the static dashboard data in a pull request. Do not submit replays; retain replay evidence locally. Duplicate battle IDs, engine mismatches, preset mismatches, unknown bots, unregistered clients, banned accounts, malformed batches, and inconsistent score components are rejected. Fork-pull-request result submission is not supported in V1. All contributions are made under Apache-2.0 and must follow the project code of conduct and governance process. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 98c0803..09eb391 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,6 +1,6 @@ # Governance and operations -The `robocode-dev` organization owns this repository. Moderators review client registrations, catalog updates, bans, and exclusions through ordinary pull requests. CI is the only writer of accepted raw facts and generated projections on `main`. +The `robocode-dev` organization owns this repository. Moderators review client registrations, catalog updates, bans, exclusions, and all ordinary code or policy changes through pull requests with the Verify Rumble data workflow green. CI is the only writer of accepted raw facts and generated projections on `main`. GitHub cannot distinguish the built-in Actions writer from human collaborators in a repository ruleset without a secret-bearing organization app, so this boundary is a documented governance convention for V1. ## Moderation diff --git a/scripts/compact.py b/scripts/compact.py index f55a201..db0ddb4 100644 --- a/scripts/compact.py +++ b/scripts/compact.py @@ -1,39 +1,68 @@ #!/usr/bin/env python3 -"""Pack aged raw facts into equivalent monthly rollups for archival transfer.""" +"""Move aged raw facts to an archive and retain equivalent verified rollups.""" from __future__ import annotations import argparse +import shutil from collections import defaultdict from pathlib import Path +from aggregate import aggregate from common import read_json, write_json -def compact(root: Path, *, before: str) -> list[Path]: - """Write rollups for facts completed before an ISO-8601 date without deleting facts.""" - grouped: dict[tuple[str, str], list[dict]] = defaultdict(list) +def projection_snapshot(root: Path) -> dict[str, bytes]: + """Capture every generated projection for compaction equivalence checking.""" + paths = [root / "clients.json"] + [path for directory in ("leaderboard", "matchmaking", "site/data") for path in (root / directory).rglob("*.json") if (root / directory).exists()] + return {path.relative_to(root).as_posix(): path.read_bytes() for path in paths if path.is_file()} + + +def compact(root: Path, *, before: str, archive_root: Path) -> list[Path]: + """Archive selected raw facts, write rollups, and roll back if projections differ.""" + selected: dict[tuple[str, str], list[Path]] = defaultdict(list) for path in sorted((root / "results" / "raw").rglob("*.json")) if (root / "results" / "raw").exists() else []: record = read_json(path) if str(record.get("completedAt", ""))[:10] < before: date = str(record["completedAt"])[:7].split("-") - grouped[(date[0], date[1])].append(record) - written: list[Path] = [] - for (year, month), records in sorted(grouped.items()): - target = root / "results" / "rollups" / year / f"{month}.json" - write_json(target, {"schemaVersion": 1, "month": f"{year}-{month}", "results": sorted(records, key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash"))))}) - written.append(target) - return written + selected[(date[0], date[1])].append(path) + aggregate(root) + before_snapshot = projection_snapshot(root) + rollups: list[Path] = [] + moved: list[tuple[Path, Path]] = [] + try: + for (year, month), paths in sorted(selected.items()): + records = [read_json(path) for path in paths] + target = root / "results" / "rollups" / year / f"{month}.json" + write_json(target, {"schemaVersion": 1, "month": f"{year}-{month}", "results": sorted(records, key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash"))))}) + rollups.append(target) + for source in paths: + destination = archive_root / source.relative_to(root) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + moved.append((source, destination)) + aggregate(root) + if projection_snapshot(root) != before_snapshot: + raise ValueError("compaction changed a derived projection") + except Exception: + for source, destination in reversed(moved): + source.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(destination), str(source)) + for rollup in rollups: + rollup.unlink(missing_ok=True) + aggregate(root) + raise + return rollups def main() -> int: - """Run compaction from the command line.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, required=True) parser.add_argument("--before", required=True, help="exclusive ISO date, for example 2026-05-01") + parser.add_argument("--archive-root", type=Path, required=True, help="checkout of the archive branch") arguments = parser.parse_args() - paths = compact(arguments.root.resolve(), before=arguments.before) - print(f"wrote {len(paths)} rollup(s)") + paths = compact(arguments.root.resolve(), before=arguments.before, archive_root=arguments.archive_root.resolve()) + print(f"wrote {len(paths)} verified rollup(s)") return 0 diff --git a/site/app.js b/site/app.js index 6bd31c9..316ad1f 100644 --- a/site/app.js +++ b/site/app.js @@ -1,20 +1,28 @@ const select = document.querySelector('#game-type'); const status = document.querySelector('#status'); const body = document.querySelector('#leaderboard'); +let entries = []; +let sortField = 'aps'; +let descending = true; + +function renderEntries() { + body.replaceChildren(); + [...entries].sort((first, second) => descending ? second[sortField] - first[sortField] : first[sortField] - second[sortField]).forEach((entry, index) => { + const row = document.createElement('tr'); + row.innerHTML = `
Loading leaderboard…
| Rank | Bot | APS | Battles | Pairings |
|---|---|---|---|---|
| Rank | Bot |